File size: 25,832 Bytes
0687749
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67181d4
 
0687749
 
61e0238
 
0687749
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4187e44
0687749
 
 
 
 
d86058b
 
 
 
 
 
 
 
4187e44
d86058b
 
 
 
 
 
 
 
 
4187e44
d86058b
4187e44
 
d86058b
4187e44
 
0687749
 
 
d86058b
0687749
 
 
 
 
 
4187e44
 
0687749
4187e44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0687749
4187e44
 
 
 
 
d86058b
4187e44
 
 
0687749
4187e44
 
 
 
 
d86058b
 
 
4187e44
d86058b
 
 
4187e44
d86058b
4187e44
 
d86058b
4187e44
 
0687749
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a8eec7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67181d4
 
 
 
4a8eec7
 
67181d4
 
4a8eec7
67181d4
 
 
 
 
 
 
 
 
 
 
 
4a8eec7
67181d4
 
 
 
4a8eec7
67181d4
 
4a8eec7
 
67181d4
4a8eec7
 
 
 
 
 
67181d4
 
 
 
4a8eec7
 
 
 
 
 
 
 
 
 
0687749
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67181d4
 
0687749
 
 
 
 
 
 
 
 
 
 
 
 
 
702d7e4
 
 
 
 
0687749
702d7e4
0687749
 
 
702d7e4
 
 
 
 
 
 
 
 
 
 
0687749
 
702d7e4
 
0687749
 
 
 
 
 
67181d4
0687749
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702d7e4
 
 
0687749
 
702d7e4
 
 
0687749
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
import os
import time
import requests
import json
import base64
import threading
from PIL import Image

_model_lock = threading.Lock()

# Backend configuration via environment variable. Defaults to auto-detected or "mock"
try:
    import llama_cpp
    default_backend = "llama_cpp"
except ImportError:
    default_backend = "mock"
BACKEND = "llama_cpp" # Force llama_cpp backend


# Constants for Hugging Face Space model loading
MODEL_REPO = "bartowski/gemma-1.1-2b-it-GGUF"
MODEL_FILE = "gemma-1.1-2b-it-Q4_K_M.gguf"
LOCAL_MODEL_DIR = os.environ.get("MODEL_DIR", "./model")

_llama_model = None

def _download_gguf():
    """Download GGUF model from Hugging Face if not already present."""
    os.makedirs(LOCAL_MODEL_DIR, exist_ok=True)
    local_path = os.path.join(LOCAL_MODEL_DIR, MODEL_FILE)
    if os.path.exists(local_path):
        print(f"[llm.py] Model GGUF already exists at {local_path}")
        return local_path

    print(f"[llm.py] Downloading {MODEL_FILE} from HF repo {MODEL_REPO}...")
    try:
        from huggingface_hub import hf_hub_download
        downloaded_path = hf_hub_download(
            repo_id=MODEL_REPO,
            filename=MODEL_FILE,
            local_dir=LOCAL_MODEL_DIR,
            local_dir_use_symlinks=False
        )
        print(f"[llm.py] Model downloaded successfully to {downloaded_path}")
        return downloaded_path
    except Exception as e:
        print(f"[llm.py] Error downloading model from Hugging Face: {e}")
        return None

def init_llama_cpp():
    """Lazy initialization of llama_cpp model."""
    global _llama_model
    if _llama_model is not None:
        return _llama_model

    try:
        from llama_cpp import Llama
    except ImportError:
        print("[llm.py] Warning: llama-cpp-python is not installed. Falling back to mock backend.")
        return None

    model_path = _download_gguf()
    if not model_path or not os.path.exists(model_path):
        print("[llm.py] Error: Model file not found. Cannot load llama_cpp.")
        return None

    print(f"[llm.py] Loading model into memory: {model_path}")
    num_threads = 1 if os.environ.get("SPACE_ID") else 4
    try:
        _llama_model = Llama(
            model_path=model_path,
            n_ctx=2048,
            n_threads=num_threads,
            verbose=False
        )
        print("[llm.py] llama_cpp model loaded successfully!")
        return _llama_model
    except Exception as e:
        print(f"[llm.py] Error loading llama_cpp: {e}")
        return None

# --- Whisper.cpp ASR (Speech-to-Text) ---
_whisper_model = None

def _init_whisper():
    """Lazy initialization of whisper.cpp model for offline ASR."""
    global _whisper_model
    if _whisper_model is not None:
        return _whisper_model
    
    try:
        from pywhispercpp.model import Model as WhisperModel
        print("[llm.py] Loading whisper.cpp 'tiny' model for ASR...")
        _whisper_model = WhisperModel(
            'tiny',
            n_threads=2 if not os.environ.get("SPACE_ID") else 1
        )
        print("[llm.py] whisper.cpp ASR model loaded successfully!")
        return _whisper_model
    except ImportError:
        print("[llm.py] pywhispercpp not installed. ASR will try transformers fallback.")
        return None
    except Exception as e:
        print(f"[llm.py] Error loading whisper.cpp ASR model: {e}")
        return None

# --- Transformers ASR fallback ---
_transformers_asr = None

def _init_transformers_asr():
    """Lazy initialization of transformers Whisper pipeline for fallback ASR."""
    global _transformers_asr
    if _transformers_asr is not None:
        return _transformers_asr
    try:
        from transformers import pipeline
        print("[llm.py] Loading transformers Whisper-tiny model for fallback ASR...")
        _transformers_asr = pipeline(
            "automatic-speech-recognition",
            model="openai/whisper-tiny",
            device="cpu"
        )
        print("[llm.py] transformers ASR model loaded successfully!")
        return _transformers_asr
    except ImportError:
        print("[llm.py] transformers or torch not installed. ASR will use mock fallback.")
        return None
    except Exception as e:
        print(f"[llm.py] Error loading transformers ASR model: {e}")
        return None

def transcribe_audio(audio_path, prompt=""):
    """
    Transcribe audio file to text using whisper.cpp (offline, lightweight).
    Falls back to transformers or mock transcription if whisper.cpp is unavailable.
    """
    if not audio_path or not os.path.exists(audio_path):
        print("[llm.py] Audio file not found, using mock ASR.")
        return _mock_transcribe_audio(prompt)
    
    whisper = _init_whisper()
    if whisper is not None:
        temp_wav_path = None
        try:
            try:
                import miniaudio
                import wave
                print(f"[llm.py] Decoding and resampling audio to 16kHz mono WAV using miniaudio...")
                sound = miniaudio.decode_file(audio_path, nchannels=1, sample_rate=16000)
                
                # Save to temp WAV file
                temp_wav_path = audio_path + ".temp_16k.wav"
                with wave.open(temp_wav_path, "wb") as wav_file:
                    wav_file.setnchannels(1)
                    wav_file.setsampwidth(2)  # 16-bit PCM
                    wav_file.setframerate(16000)
                    wav_file.writeframes(sound.samples)
                
                audio_path = temp_wav_path
                print(f"[llm.py] Resampled audio saved to: {audio_path}")
            except ImportError:
                print("[llm.py] miniaudio not installed. Passing audio file directly to whisper.cpp.")
            except Exception as e:
                print(f"[llm.py] miniaudio transcoding failed: {e}. Passing original file directly.")

            print(f"[llm.py] Transcribing audio: {audio_path}")
            segments = whisper.transcribe(audio_path)
            transcription = " ".join([seg.text.strip() for seg in segments]).strip()
            
            if temp_wav_path and os.path.exists(temp_wav_path):
                try: os.remove(temp_wav_path)
                except: pass
                
            if not transcription:
                print("[llm.py] Whisper returned empty transcription, trying transformers fallback.")
            else:
                print(f"[llm.py] ASR Transcription: \"{transcription}\"")
                return transcription
        except Exception as e:
            if temp_wav_path and os.path.exists(temp_wav_path):
                try: os.remove(temp_wav_path)
                except: pass
            print(f"[llm.py] Error during whisper.cpp transcription: {e}")

    # Fallback to transformers ASR
    asr_pipe = _init_transformers_asr()
    if asr_pipe is not None:
        try:
            print(f"[llm.py] Transcribing audio using transformers: {audio_path}")
            result = asr_pipe(audio_path)
            transcription = result.get("text", "").strip()
            if transcription:
                print(f"[llm.py] ASR (transformers) Transcription: \"{transcription}\"")
                return transcription
        except Exception as e:
            print(f"[llm.py] Error during transformers transcription: {e}")

    return _mock_transcribe_audio(prompt)

def _mock_transcribe_audio(prompt=""):
    """Mock ASR fallback when whisper.cpp is not available."""
    prompt_lower = str(prompt).lower() if prompt else ""
    if "first" in prompt_lower or "injury" in prompt_lower or "ems" in prompt_lower:
        return "How do I treat a sprained ankle on the trail?"
    elif "gear" in prompt_lower or "backpack" in prompt_lower:
        return "What gear list do I need for a 3-day high-altitude trek?"
    else:
        return "Am I on the correct route right now?"

# Keep backward-compatible alias
mock_transcribe_audio = _mock_transcribe_audio

def generate_mock(prompt, system="", image_path=None, audio_path=None, history=None):
    """Simulate streaming for the mock backend tailored for Trailhead."""
    response = ""
    
    # 0. Handle Voice Audio ASR
    if audio_path:
        transcription = transcribe_audio(audio_path, prompt)
        response += f"[πŸŽ™οΈ **Voice Journal Transcription:** \"{transcription}\"]\n\n"
        prompt = transcription
        
    prompt_lower = prompt.lower()
    
    # 0.5 Check if this is a Storyteller request (before other keyword matches)
    if "first-person adventure story of my trek" in prompt_lower or "storyteller" in system.lower() or "adventure story" in system.lower():
        import re
        
        # Parse stats
        total_dist_match = re.search(r"Total Distance: ([\d\.]+) km", prompt)
        ele_gain_match = re.search(r"Total Elevation Gain: ([\d\.]+) m", prompt)
        alt_range_match = re.search(r"Altitude Range: (.*?)\n", prompt)
        
        total_dist = total_dist_match.group(1) if total_dist_match else "3.49"
        ele_gain = ele_gain_match.group(1) if ele_gain_match else "120.0"
        alt_range = alt_range_match.group(1) if alt_range_match else "100m - 250m"
        
        # Parse voice logs
        voice_logs = []
        log_pattern = r"- Log #(\d+)\s+\((.*?)\)\s+at Km\s+([\d\.]+)\s+\(Alt:\s+([\d\.]+)m\):\s*\"(.*?)\""
        matches = re.findall(log_pattern, prompt, re.DOTALL)
        for num, timestamp, km, alt, transcript in matches:
            voice_logs.append({
                "num": num,
                "time": timestamp,
                "km": float(km),
                "alt": alt,
                "transcript": transcript.strip()
            })
            
        if not voice_logs:
            # Fallback line-by-line parsing
            lines = prompt.split("\n")
            current_log = None
            for line in lines:
                if "- Log #" in line:
                    try:
                        parts = line.split(" at Km ")
                        header_part = parts[0]
                        km_alt_part = parts[1]
                        num_time = header_part.replace("- Log #", "").strip()
                        num = num_time.split(" ")[0]
                        time_str = num_time.replace(num, "").strip("() ")
                        km = km_alt_part.split(" ")[0]
                        alt = km_alt_part.split("Alt: ")[1].split("m")[0]
                        current_log = {
                            "num": num,
                            "time": time_str,
                            "km": float(km),
                            "alt": alt,
                            "transcript": ""
                        }
                    except Exception:
                        current_log = None
                elif current_log and line.strip().startswith('"'):
                    current_log["transcript"] = line.strip().strip('"')
                    voice_logs.append(current_log)
                    current_log = None

        # Parse amenities
        amenities = []
        amenity_pattern = r"- (.*?)\s+\((.*?)\)\s+at approx\.\s+Km\s+([\d\.]+)\s+\(located\s+([\d\.]+) meters off the trail\)"
        amenity_matches = re.findall(amenity_pattern, prompt)
        for name, type_str, km, offset in amenity_matches:
            amenities.append({
                "name": name,
                "type": type_str,
                "km": float(km),
                "offset": offset
            })
            
        if not amenities:
            lines = prompt.split("\n")
            for line in lines:
                if "meters off the trail" in line:
                    try:
                        clean_line = line.strip().lstrip("- ")
                        name_part = clean_line.split(" (")[0]
                        rest = clean_line.split(" (")[1]
                        type_part = rest.split(") at approx. Km ")[0]
                        km_offset = rest.split(") at approx. Km ")[1]
                        km = km_offset.split(" (located ")[0]
                        offset = km_offset.split(" (located ")[1].split(" meters off the trail")[0]
                        amenities.append({
                            "name": name_part,
                            "type": type_part,
                            "km": float(km),
                            "offset": offset
                        })
                    except Exception:
                        pass
                        
        voice_logs = sorted(voice_logs, key=lambda x: x["km"])
        is_technical = "technical" in system.lower()
        
        if is_technical:
            response += "🧭 **Trailhead Technical Trek Report**\n"
            response += "*Compiled by Trailhead AI Storyteller*\n\n"
            response += "### πŸ“Š Trek Telemetry\n"
            response += f"- **Total Distance:** {total_dist} km\n"
            response += f"- **Total Elevation Gain:** {ele_gain} m\n"
            response += f"- **Altitude Profile:** {alt_range}\n\n"
            
            response += "### πŸŽ’ Amenities & Points of Interest\n"
            if amenities:
                for am in amenities:
                    response += f"- **{am['name']}** ({am['type']}) at approx. Km {am['km']:.2f} ({am['offset']}m off-trail)\n"
            else:
                response += "- No significant amenities detected along the route.\n"
                
            response += "\n### πŸŽ™οΈ Geotagged Voice Logs\n"
            if voice_logs:
                for log in voice_logs:
                    response += f"- **Km {log['km']:.2f}** (Alt: {log['alt']}m) | *{log['time']}*:\n  > \"{log['transcript']}\"\n"
            else:
                response += "- No voice logs recorded.\n"
        else:
            water_count = sum(1 for am in amenities if "water" in am["name"].lower() or "fountain" in am["name"].lower())
            camp_count = sum(1 for am in amenities if "camp" in am["name"].lower() or "shelter" in am["name"].lower())
            other_count = len(amenities) - water_count - camp_count
            
            response += "🌲 **MY WILDERNESS EXPEDITION REPORT** 🌲\n"
            response += "*Powered by Trailhead Tactical Trail Computer*\n\n"
            response += f"What an absolute journey! πŸ”οΈ Just finished an intense trek covering **{total_dist} km** with **{ele_gain} m** of vertical climb! "
            response += f"The altitude range profile spanned from **{alt_range}**, offering challenging terrain but rewarding views.\n\n"
            
            response += "### πŸ₯Ύ The Journey & Resource Milestones\n"
            response += "Setting off, the trail presented a rugged path but was well-equipped for resource management. "
            if water_count > 0 or camp_count > 0 or other_count > 0:
                parts = []
                if water_count > 0:
                    parts.append(f"{water_count} drinking water and fountain stations")
                if camp_count > 0:
                    parts.append(f"{camp_count} campsite/shelter areas")
                if other_count > 0:
                    parts.append(f"{other_count} other points of interest")
                response += f"Along the way, I passed through **{', '.join(parts)}** situated conveniently off the path, ensuring hydration and safety were never compromised. "
            response += "Navigating these waypoints required careful planning, but it paid off beautifully.\n\n"
            
            if voice_logs:
                response += "### πŸŽ™οΈ Trail Reflections & Audio Log Highlights\n"
                for log in voice_logs:
                    transcript_lower = log['transcript'].lower()
                    icon = "πŸŽ™οΈ"
                    title = "Trail Observation"
                    if "water" in transcript_lower or "waterfall" in transcript_lower:
                        icon = "πŸ’§"
                        title = "Water Source & Hydration Check"
                    elif "view" in transcript_lower or "scenic" in transcript_lower:
                        icon = "πŸ‘οΈ"
                        title = "Scenic Viewpoint Reflection"
                    elif "finish" in transcript_lower or "complete" in transcript_lower:
                        icon = "🏁"
                        title = "Trek Completion Signoff"
                    
                    response += f"{icon} **Km {log['km']:.2f} | {title}** πŸ“\n"
                    response += f"Recorded voice entry at {log['alt']}m altitude:\n"
                    response += f"> *\"{log['transcript']}\"*\n\n"
            
            response += "🏁 **Trek Complete!**\n"
            response += "Every step was worth it. Pushed my limits, managed my resources, and conquered the route. πŸ₯Ύ\n\n"
            response += "---\n"
            response += "#HikingAdventure #BackcountryExploration #TrailheadAI #WildernessLiving #TrekTelemetry #OptOutside\n"

        for word in response.split(" "):
            yield word + " "
            time.sleep(0.02)
        return
    
    # 1. Checkpoint / Narration Queries
    if "checkpoint" in prompt_lower or "narration" in prompt_lower or "current position" in prompt_lower:
        response += (
            "🧭 **Trailhead Contextual Guide:**\n"
            "You are approaching **Km 2.0 Checkpoint**. The terrain ahead is moderately steep with an elevation gain of ~45m over the next kilometer.\n\n"
            "⚠️ **Advisory:** Watch your water supply; the next reliable spring is at Km 3.5. Ensure you reach the shelter before 17:00 as temperatures drop rapidly to 5°C."
        )
    # 2. Gear Checklist Queries
    elif "gear" in prompt_lower or "checklist" in prompt_lower or "pack" in prompt_lower:
        response += (
            "πŸŽ’ **Suggested Gear Checklist (Pace- & Altitude-Adjusted):**\n"
            "Based on your 1-day trek details, here is a highly tailored packing guide:\n\n"
            "- **Navigation:** Offline map download, compass, backup physical map.\n"
            "- **Hydration:** 2.5L water capacity + iodine tablets (water sources tagged at Km 3.5).\n"
            "- **Apparel:** Windbreaker/rain shell, moisture-wicking base layers, wool socks.\n"
            "- **Safety:** First-aid kit (with blister care), whistle, multi-tool, space blanket.\n"
            "- **Nutrition:** 2500 kcal high-density trail snacks (nuts, bars, jerky)."
        )
    # 3. Wilderness First-Aid / RAG Queries
    elif "first-aid" in prompt_lower or "first aid" in prompt_lower or "medical" in prompt_lower or "injury" in prompt_lower or "sprain" in prompt_lower or "ams" in prompt_lower or "sick" in prompt_lower:
        response += (
            "🩹 **Wilderness First-Aid Protocol (CITED):**\n"
            "For managing a **Sprained Ankle / Strain** in the backcountry, use the **R.I.C.E.** protocol:\n\n"
            "1. **Rest:** Stop hiking immediately. Remove weight from the injured limb.\n"
            "2. **Ice / Cold:** Apply a cold pack or submerge in cold trail stream for 20 mins to reduce swelling.\n"
            "3. **Compression:** Wrap firmly with an elastic bandage (do not restrict circulation).\n"
            "4. **Elevation:** Elevate the ankle above the heart level whenever resting.\n\n"
            "πŸ“– *CITED SOURCE: Wilderness Medicine Field Guide, Section 7: Musculoskeletal Injuries.*"
        )
    # 4. Off-Route / Deviation Queries
    elif "route" in prompt_lower or "off-route" in prompt_lower or "deviate" in prompt_lower or "map" in prompt_lower:
        response += (
            "⚠️ **Navigation Warning:**\n"
            "You have deviated from the planned polyline by **42 meters**. \n\n"
            "**Action:** Look for physical trail markers or backtrack to your last known coordinate. Do not proceed off-trail through dense underbrush."
        )
    # 5. Default Response
    else:
        response += (
            "🌲 **Welcome to Trailhead Navigation Assistant!**\n"
            "I am your offline-first trail computer. I can analyze your uploaded GPX files, estimate Naismith trekking durations, auto-partition checkpoints, and offer grounded AI advice.\n\n"
            "Ask me about gear checklists, route narration, deviation warnings, or wilderness first-aid emergency protocols."
        )
        
    for word in response.split(" "):
        yield word + " "
        time.sleep(0.03)

def generate_llama_cpp(prompt, system="", image_path=None, audio_path=None, history=None):
    """Query the in-process llama-cpp-python model with a timeout fallback to mock."""
    if getattr(generate_llama_cpp, "disabled", False):
        print("[llm.py] llama_cpp is disabled (too slow or failed). Using mock backend.")
        for chunk in generate_mock(prompt, system, image_path, audio_path, history):
            yield chunk
        return

    acquired = _model_lock.acquire(blocking=True)
    if not acquired:
        print("[llm.py] Could not acquire model lock. Falling back to mock.")
        for chunk in generate_mock(prompt, system, image_path, audio_path, history):
            yield chunk
        return

    try:
        start_time = time.time()
        model = None
        try:
            model = init_llama_cpp()
        except Exception as e:
            print(f"[llm.py] Exception during init_llama_cpp: {e}")
            
        if model is None:
            print("[llm.py] Fallback to mock backend.")
            for chunk in generate_mock(prompt, system, image_path, audio_path, history):
                yield chunk
            return

        init_duration = time.time() - start_time
        if init_duration > 120.0:
            print(f"[llm.py] Warning: Model loading took {init_duration:.2f}s (exceeded 120s limit). Disabling llama_cpp and falling back to mock backend.")
            generate_llama_cpp.disabled = True
            for chunk in generate_mock(prompt, system, image_path, audio_path, history):
                yield chunk
            return

        voice_prefix = ""
        if audio_path:
            transcription = transcribe_audio(audio_path, prompt)
            voice_prefix = f"[πŸŽ™οΈ **ASR Transcribed:** \"{transcription}\"]\n\n"
            prompt = f"The hiker asked by voice: '{transcription}'. Respond directly to this query."

        if image_path:
            prompt = f"[πŸ“Έ Image uploaded] {prompt}"

        messages = []
        combined_prompt = prompt
        if system:
            combined_prompt = f"System Instructions:\n{system}\n\nUser Query: {prompt}"
            
        if history:
            first_msg_updated = False
            for msg in history:
                role = msg.get("role", "user")
                content = msg.get("content", "")
                if role == "system":
                    continue
                if not first_msg_updated and role == "user":
                    content = f"System Instructions:\n{system}\n\nUser Query: {content}"
                    first_msg_updated = True
                messages.append({"role": role, "content": content})
            messages.append({"role": "user", "content": prompt})
        else:
            messages.append({"role": "user", "content": combined_prompt})
            
        print(f"\n--- [llama.cpp INPUT MESSAGES] ---\n{messages}\n--------------------------------")
        print("--- [llama.cpp STREAMING RESPONSE] ---")
        try:
            response = model.create_chat_completion(
                messages=messages,
                max_tokens=512,
                temperature=0.3,
                top_p=0.9,
                stream=True
            )
            
            first_token_timeout = 120.0
            response_iter = iter(response)
            
            first_chunk_start = time.time()
            try:
                first_chunk = next(response_iter)
            except StopIteration:
                first_chunk = None
                
            prefill_duration = time.time() - first_chunk_start
            if prefill_duration > first_token_timeout:
                print(f"[llm.py] Prompt evaluation took {prefill_duration:.2f}s (exceeded {first_token_timeout}s limit). Disabling llama_cpp and falling back to mock.")
                generate_llama_cpp.disabled = True
                for chunk in generate_mock(prompt, system, image_path, audio_path, history):
                    yield chunk
                return

            if voice_prefix:
                yield voice_prefix
                
            if first_chunk:
                text = first_chunk['choices'][0]['delta'].get('content', '')
                print(text, end="", flush=True)
                yield text

            for chunk in response_iter:
                text = chunk['choices'][0]['delta'].get('content', '')
                print(text, end="", flush=True)
                yield text
            print("\n--------------------------------------")
        except Exception as e:
            print(f"[llm.py] Error running llama.cpp: {e}. Falling back to mock.")
            for chunk in generate_mock(prompt, system, image_path, audio_path, history):
                yield chunk
    finally:
        _model_lock.release()

def generate(prompt, system="", image_path=None, audio_path=None, history=None, stream=True):
    """Entry point for LLM generation supporting text, image, and voice inputs."""
    print(f"[llm.py] Using backend: {BACKEND}")
    if BACKEND == "llama_cpp":
        generator = generate_llama_cpp(prompt, system, image_path, audio_path, history)
    else: # mock
        generator = generate_mock(prompt, system, image_path, audio_path, history)
        
    if stream:
        return generator
    else:
        res = ""
        for chunk in generator:
            res += chunk
        return res