| import os |
| import time |
| import requests |
| import json |
| import base64 |
| import threading |
| from PIL import Image |
|
|
| _model_lock = threading.Lock() |
|
|
| |
| try: |
| import llama_cpp |
| default_backend = "llama_cpp" |
| except ImportError: |
| default_backend = "mock" |
| BACKEND = os.environ.get("BACKEND", default_backend).lower() |
|
|
| |
| MODEL_REPO = "bartowski/google_gemma-4-E2B-it-GGUF" |
| MODEL_FILE = "google_gemma-4-E2B-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 |
|
|
| def downscale_image(image_path, max_dim=512): |
| """ |
| Downscales the image if any dimension exceeds max_dim to save compute. |
| Saves the downscaled image to a temporary file and returns its path. |
| """ |
| if not image_path or not os.path.exists(image_path): |
| return image_path |
| |
| try: |
| img = Image.open(image_path) |
| width, height = img.size |
| if width > max_dim or height > max_dim: |
| if width > height: |
| new_width = max_dim |
| new_height = int(height * (max_dim / width)) |
| else: |
| new_height = max_dim |
| new_width = int(width * (max_dim / height)) |
| |
| img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) |
| dir_name = os.path.dirname(image_path) |
| base_name = "downscaled_" + os.path.basename(image_path) |
| temp_path = os.path.join(dir_name, base_name) |
| img.save(temp_path, quality=85) |
| print(f"[llm.py] Downscaled image from {width}x{height} to {new_width}x{new_height}") |
| return temp_path |
| except Exception as e: |
| print(f"[llm.py] Error downscaling image: {e}") |
| return image_path |
|
|
| def encode_image_to_base64(image_path): |
| """Encodes image file to base64 string.""" |
| try: |
| with open(image_path, "rb") as image_file: |
| return base64.b64encode(image_file.read()).decode('utf-8') |
| except Exception as e: |
| print(f"[llm.py] Error encoding image: {e}") |
| return None |
|
|
| |
| _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 use mock fallback.") |
| return None |
| except Exception as e: |
| print(f"[llm.py] Error loading whisper.cpp ASR model: {e}") |
| return None |
|
|
| def transcribe_audio(audio_path, prompt=""): |
| """ |
| Transcribe audio file to text using whisper.cpp (offline, lightweight). |
| Falls back to 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 None: |
| print("[llm.py] whisper.cpp unavailable, using mock ASR fallback.") |
| return _mock_transcribe_audio(prompt) |
| |
| 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) |
| |
| |
| 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) |
| 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, using mock fallback.") |
| return _mock_transcribe_audio(prompt) |
| |
| 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}") |
| 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 "potato" in prompt_lower or "आलू" in prompt_lower: |
| return "आलू में झुलसा रोग (blight) से कैसे बचाएं?" |
| elif "wheat" in prompt_lower or "गेहूं" in prompt_lower: |
| return "गेहूं में पीलापन आ रहा है, क्या करूँ?" |
| else: |
| return "फसल की सिंचाई करने का सही समय और तरीका क्या है?" |
|
|
| |
| 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.""" |
| import re |
| response = "" |
| |
| |
| if "precise data extractor" in system.lower() or "json format" in system.lower(): |
| import datetime |
| date_val = datetime.date.today().strftime("%Y-%m-%d") |
| |
| |
| months_pattern = r"(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|january|february|march|april|june|july|august|september|october|november|december|जनवरी|फ़रवरी|मार्च|अप्रैल|मई|जून|जुलाई|अगस्त|सितंबर|अक्टूबर|नवंबर|दिसंबर)" |
| date_match = re.search(r"(\d{1,2}\s+" + months_pattern + r"(?:\s+\d{4})?)", prompt, re.IGNORECASE) |
| |
| if date_match: |
| date_val = date_match.group(1) |
| else: |
| |
| fmt_match = re.search(r"(\d{4}[-/]\d{1,2}[-/]\d{1,2}|\d{1,2}[-/]\d{1,2}[-/]\d{4})", prompt) |
| if fmt_match: |
| date_val = fmt_match.group(1) |
| else: |
| if any(w in prompt.lower() for w in ["yesterday", "kal", "कल"]): |
| date_val = (datetime.date.today() - datetime.timedelta(days=1)).strftime("%Y-%m-%d") |
| elif any(w in prompt.lower() for w in ["today", "aaj", "आज"]): |
| date_val = datetime.date.today().strftime("%Y-%m-%d") |
| |
| price_val = 0 |
| numbers = re.findall(r"\b\d+\b", prompt) |
| price_match = re.search(r"(\d+)\s*(?:rupees|rupee|rupay|rs|inr|me|में|रुपये|रुपए|रुपयों|रू|रु|\s|$)", prompt, re.IGNORECASE) |
| if price_match: |
| price_val = int(price_match.group(1)) |
| elif numbers: |
| valid_nums = [int(n) for n in numbers if not (2020 <= int(n) <= 2040)] |
| if valid_nums: |
| price_val = valid_nums[-1] |
| |
| qty_val = "1 unit" |
| qty_match = re.search(r"(\d+\s*(?:kilo|kg|kilos|kila|किलो|किग्रा|bags|bag|बोरी|बोरा|unit|units|लीटर|litre|l|liters))", prompt, re.IGNORECASE) |
| if qty_match: |
| qty_val = qty_match.group(1) |
| elif numbers: |
| valid_nums = [int(n) for n in numbers if not (2020 <= int(n) <= 2040) and int(n) != price_val] |
| if valid_nums: |
| qty_val = f"{valid_nums[0]} unit" |
| |
| item_val = "Crop" |
| |
| item_match = re.search(r"\b\d+\s*(?:kilo|kg|kilos|kila|किलो|किग्रा|bags|bag|बोरी|बोरा|unit|units|लीटर|litre|l|liters)\s+(?:of\s+)?([a-zA-Z\u0900-\u097F]+)", prompt, re.IGNORECASE) |
| if item_match: |
| item_val = item_match.group(1).strip() |
| else: |
| |
| items_list = ["rice", "wheat", "potato", "cotton", "urea", "fertilizer", "seed", "pesticide", "धान", "चावल", "गेहूं", "गेहूँ", "आलू", "कपास", "यूरिया", "खाद", "बीज", "कीटनाशक"] |
| for it in items_list: |
| if it in prompt.lower(): |
| item_val = it |
| break |
| |
| type_val = "sale" |
| if any(w in prompt.lower() for w in ["bought", "purchased", "खरीदा", "kharida", "khareeda", "kharid", "khareed", "buy", "purchase"]): |
| type_val = "purchase" |
| elif any(w in prompt.lower() for w in ["sold", "becha", "बेचा", "sale", "sell", "bech"]): |
| type_val = "sale" |
| |
| data = { |
| "date": date_val, |
| "item": item_val, |
| "qty": qty_val, |
| "price": price_val, |
| "type": type_val |
| } |
| |
| yield json.dumps(data) |
| return |
|
|
| |
| if audio_path: |
| transcription = transcribe_audio(audio_path, prompt) |
| response += f"[🎙️ **आवाज का अनुवाद (ASR):** \"{transcription}\"]\n\n" |
| prompt = transcription |
| |
| prompt_lower = prompt.lower() |
| |
| |
| if image_path: |
| response += ( |
| "📸 **छवि विश्लेषण (Image Analysis):**\n" |
| "पत्ती की सतह पर भूरे-काले धब्बे और झुलसा हुआ भाग दिखाई दे रहा है। यह मुख्य रूप से **झुलसा रोग (Blight Disease)** का लक्षण प्रतीत होता है।\n\n" |
| ) |
| if "potato" in prompt_lower or "आलू" in prompt_lower: |
| response += ( |
| "**आलू पिछेती झुलसा रोग निवारण:**\n" |
| "1. मैन्कोजेब (Mancozeb) का 2 ग्राम/लीटर पानी में मिलाकर छिड़काव करें।\n" |
| "2. संक्रमित पौधों की पत्तियों को खेत से निकाल दें ताकि यह अन्य पौधों में न फैले।" |
| ) |
| else: |
| response += ( |
| "**गेहूं पीला रतुआ या धब्बा रोग निवारण:**\n" |
| "1. प्रोपिकोनाजोल २५ ईसी का १ मिली प्रति लीटर पानी में छिड़काव करें।\n" |
| "2. खेत में जल जमाव न होने दें और अतिरिक्त यूरिया के प्रयोग से बचें।" |
| ) |
| else: |
| |
| if "पीलापन" in prompt_lower or "yellow" in prompt_lower: |
| response += ( |
| "गेहूं में पीलापन (Yellowing of wheat leaves) नाइट्रोजन की कमी या अधिक सिंचाई के कारण हो सकता है।\n\n" |
| "**सलाह:**\n" |
| "1. सिंचाई कम करें और मिट्टी में नमी की जांच करें।\n" |
| "2. यूरिया (Nitrogen fertilizer) का छिड़काव करें (लगभग 20-25 किलोग्राम प्रति एकड़)।\n" |
| "3. यदि समस्या बनी रहती है, तो 0.5% फेरस सल्फेट का छिड़काव करें।" |
| ) |
| elif "झुलसा" in prompt_lower or "blight" in prompt_lower: |
| response += ( |
| "आलू में झुलसा रोग (Blight disease) कवक (fungus) के कारण होता है। यह दो प्रकार का होता है: अगेती और पिछेती झुलसा।\n\n" |
| "**सलाह:**\n" |
| "1. मैन्коजेब (Mancozeb) या कॉपर ऑक्सीक्लोराइड का 2 ग्राम प्रति लीटर पानी में मिलाकर छिड़काव करें।\n" |
| "2. संक्रमित पौधों की पत्तियों को खेत से निकाल दें ताकि यह अन्य पौधों में न फैले।\n" |
| "3. रात में अधिक नमी होने पर विशेष ध्यान रखें।" |
| ) |
| elif "सिंचाई" in prompt_lower or "irrigate" in prompt_lower or "water" in prompt_lower: |
| response += ( |
| "गेहूं में सिंचाई की मुख्य 6 अवस्थाएं होती हैं:\n" |
| "1. **ताज जड़ विकास (CRI)**: बुवाई के 21-25 दिन बाद (सबसे महत्वपूर्ण)।\n" |
| "2. **कल्ले फूटते समय (Tillering)**: बुवाई के 40-45 दिन बाद।\n" |
| "3. **गांठ बनते समय (Late Jointing)**: बुवाई के 60-65 दिन बाद।\n" |
| "4. **फूल आने पर (Flowering)**: बुवाई के 80-85 दिन बाद।\n" |
| "5. **दूधिया अवस्था (Milking)**: बुवाई के 100-105 दिन बाद।\n" |
| "6. **दाना पकते समय (Dough)**: बुवाई के 115-120 दिन बाद।" |
| ) |
| elif "ledger" in prompt_lower or "बहीखाता" in prompt_lower or "बेचा" in prompt_lower or "खरीदा" in prompt_lower or "sold" in prompt_lower or "bought" in prompt_lower: |
| if "गेहूं" in prompt_lower or "wheat" in prompt_lower: |
| item = "Wheat (गेहूं)" |
| qty = "40 kg" |
| price = "1200" |
| elif "आलू" in prompt_lower or "potato" in prompt_lower: |
| item = "Potato (आलू)" |
| qty = "500 kg" |
| price = "6000" |
| else: |
| item = "Crop (फसल)" |
| qty = "100 kg" |
| price = "2000" |
| |
| data = { |
| "date": "Today", |
| "item": item, |
| "qty": qty, |
| "price": price, |
| "type": "sale" if ("बेचा" in prompt_lower or "sold" in prompt_lower) else "purchase" |
| } |
| response = f"JSON_OUTPUT: {json.dumps(data)}" |
| elif any(kw in prompt_lower for kw in [ |
| "what were my last", "last message", "last question", |
| "pichle message", "pichle sandesh", "pichle sawal", |
| "pichla sawaal", "मेरे पिछले", "पिछले सवाल", "पिछला सवाल", |
| "पिछले संदेश", "पिछला संदेश", "क्या पूछा था", "kya poocha tha", |
| "mera pichla", "what did i ask", "what did i say" |
| ]): |
| if history: |
| user_msgs = [msg["content"] for msg in history if msg.get("role") == "user"] |
| if user_msgs: |
| numbered = "\n".join(f"{i+1}. {m}" for i, m in enumerate(user_msgs)) |
| response += f"आपने पहले ये सवाल पूछे थे (Your previous questions):\n{numbered}" |
| else: |
| response += "अभी तक कोई पिछला सवाल नहीं मिला। (No previous messages found in history.)" |
| else: |
| response += "मुझे अभी आपकी पिछली बातचीत की जानकारी नहीं है। (I don't have access to your chat history right now.)" |
| else: |
| |
| if history: |
| recent_user_msgs = [msg["content"] for msg in history if msg.get("role") == "user"] |
| recent_bot_msgs = [msg["content"] for msg in history if msg.get("role") == "assistant"] |
| if recent_user_msgs: |
| last_q = recent_user_msgs[-1] |
| response += ( |
| f"मैं आपकी बात समझ रहा हूँ। आपने पहले पूछा था: \"{last_q}\"\n\n" |
| "कृपया अधिक विवरण दें — जैसे फसल का नाम, समस्या या जानकारी जो आप चाहते हैं, " |
| "ताकि मैं आपको सटीक सलाह दे सकूँ।\n" |
| "(Please provide more details — crop name, problem or info needed — so I can give you precise advice.)" |
| ) |
| else: |
| response += ( |
| "मैं आपकी किस विषय में मदद कर सकता हूँ?\n" |
| "कृपया बताएं: फसल का नाम, समस्या (जैसे रोग, सिंचाई, खाद) या बहीखाता दर्ज करना।" |
| ) |
| else: |
| response += ( |
| "नमस्ते! मैं आपका किसान साथी हूँ। मैं आपको फसल प्रबंधन, सिंचाई, और बहीखाता (ledger) में मदद कर सकता हूँ।\n" |
| "आप मुझसे गेहूं या आलू की खेती के बारे में सवाल पूछ सकते हैं, या कोई बिक्री दर्ज करने के लिए कह सकते हैं।" |
| ) |
| |
| for word in response.split(" "): |
| yield word + " " |
| time.sleep(0.04) |
|
|
| 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):** \"{transcription}\"]\n\n" |
| prompt = f"The user asked by voice: '{transcription}'. Response to this query." |
|
|
| if image_path: |
| |
| |
| prompt = f"[📸 छवि अपलोड की गई है / Image uploaded] {prompt}" |
|
|
| formatted_prompt = f"<|im_start|>system\n{system}<|im_end|>\n" |
| if history: |
| for msg in history: |
| role = msg.get("role", "user") |
| content = msg.get("content", "") |
| formatted_prompt += f"<|im_start|>{role}\n{content}<|im_end|>\n" |
| formatted_prompt += f"<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant\n" |
| |
| print(f"\n--- [llama.cpp INPUT PROMPT] ---\n{formatted_prompt}\n--------------------------------") |
| print("--- [llama.cpp STREAMING RESPONSE] ---") |
| try: |
| response = model( |
| formatted_prompt, |
| 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]['text'] |
| cleaned = text.replace("<|im_end|>", "") |
| print(cleaned, end="", flush=True) |
| yield cleaned |
|
|
| for chunk in response_iter: |
| text = chunk['choices'][0]['text'] |
| cleaned = text.replace("<|im_end|>", "") |
| print(cleaned, end="", flush=True) |
| yield cleaned |
| 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: |
| generator = generate_mock(prompt, system, image_path, audio_path, history) |
| |
| if stream: |
| return generator |
| else: |
| res = "" |
| for chunk in generator: |
| res += chunk |
| return res |
|
|