# ════════════════════════════════════════════════════════════════ # SKYLINE LAND — main.py (CNN + VLM — نسخة موحدة آمنة) # Admin → POST /predict # User → POST /predict + POST /vlm-analyze + POST /vlm-chat # كلاهما يستخدمان نفس جدول diagnostics في Supabase # ════════════════════════════════════════════════════════════════ import os, json, io, uuid, cv2, base64 import numpy as np from datetime import datetime from dotenv import load_dotenv from typing import Optional import tensorflow as tf import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt from PIL import Image from fastapi import FastAPI, File, UploadFile, HTTPException, Form, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from supabase import create_client, Client load_dotenv() # ════════════════════════════════════════════════════════════════ # SUPABASE # ════════════════════════════════════════════════════════════════ SUPABASE_URL = os.getenv("SUPABASE_URL") SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY") TABLE_NAME = os.getenv("TABLE_NAME", "diagnostics") GRADCAM_BUCKET = os.getenv("GRADCAM_BUCKET", "gradcam-results") MODEL_PATH = os.getenv("MODEL_PATH", "./models/skyline_savedmodel") EXPERT_DB_PATH = os.getenv("EXPERT_DB_PATH", "./models/expert_db.json") supabase: Client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY) print("✅ Supabase client initialized.") # ════════════════════════════════════════════════════════════════ # GEMINI VLM — آمن: لا يكسر الـ app إذا المفتاح غير موجود # ════════════════════════════════════════════════════════════════ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") GEMINI_READY = False gemini_model = None if GEMINI_API_KEY: try: import google.generativeai as genai genai.configure(api_key=GEMINI_API_KEY) gemini_model = genai.GenerativeModel("gemini-pro") GEMINI_READY = True print("✅ Gemini VLM initialized.") except Exception as e: print(f"⚠️ Gemini init failed (VLM disabled): {e}") else: print("⚠️ GEMINI_API_KEY not set — VLM endpoints will return fallback from CNN.") # ════════════════════════════════════════════════════════════════ # CNN MODEL # ════════════════════════════════════════════════════════════════ print("⏳ Loading CNN model...") _loaded = tf.saved_model.load(MODEL_PATH) _infer = _loaded.signatures['serving_default'] INPUT_KEY = 'input_layer_7' OUTPUT_KEY = None def _predict_raw(img_input: tf.Tensor) -> tf.Tensor: global OUTPUT_KEY result = _infer(**{INPUT_KEY: img_input}) if OUTPUT_KEY is None: OUTPUT_KEY = list(result.keys())[0] return result[OUTPUT_KEY] _test = tf.zeros((1, 224, 224, 3), dtype=tf.float32) _out = _predict_raw(_test) print(f"✅ CNN loaded — output shape: {_out.shape}") # ════════════════════════════════════════════════════════════════ # EXPERT DB # ════════════════════════════════════════════════════════════════ with open(EXPERT_DB_PATH, 'r', encoding='utf-8') as f: expert_db = json.load(f) CLASS_NAMES = list(expert_db.keys()) HEALTHY_CLASSES = {k for k in CLASS_NAMES if 'healthy' in k.lower()} print(f"✅ Expert DB: {len(CLASS_NAMES)} classes | {len(HEALTHY_CLASSES)} healthy") # ════════════════════════════════════════════════════════════════ # GRAD-CAM # ════════════════════════════════════════════════════════════════ def get_gradcam_and_visualizations(img_array: np.ndarray): img_float = img_array.astype(np.float32) inp = tf.keras.applications.efficientnet.preprocess_input(img_float[np.newaxis]) inp_var = tf.Variable(inp, trainable=True, dtype=tf.float32) with tf.GradientTape() as tape: tape.watch(inp_var) preds = _predict_raw(inp_var) pred_idx = int(tf.argmax(preds[0])) score = preds[:, pred_idx] grads = tape.gradient(score, inp_var) heatmap = tf.reduce_mean(tf.abs(grads[0]), axis=-1).numpy() if heatmap.max() > 0: heatmap = heatmap / heatmap.max() confidence = float(preds[0][pred_idx]) # Original → base64 orig_buf = io.BytesIO() Image.fromarray(img_array.astype(np.uint8)).save(orig_buf, format='PNG') original_b64 = base64.b64encode(orig_buf.getvalue()).decode() # Heatmap → base64 hm_resized = cv2.resize(heatmap, (224, 224)) hm_colored = (plt.cm.jet(hm_resized)[:, :, :3] * 255).astype(np.uint8) hm_buf = io.BytesIO() Image.fromarray(hm_colored).save(hm_buf, format='PNG') heatmap_b64 = base64.b64encode(hm_buf.getvalue()).decode() # Overlay → base64 overlay = np.clip(hm_colored * 0.55 + img_array * 0.45, 0, 255).astype(np.uint8) ov_buf = io.BytesIO() Image.fromarray(overlay).save(ov_buf, format='PNG') overlay_b64 = base64.b64encode(ov_buf.getvalue()).decode() return heatmap, pred_idx, confidence, original_b64, heatmap_b64, overlay_b64 def build_gradcam_png_bytes(heatmap_np, img_array, is_healthy) -> bytes: fig, axes = plt.subplots(1, 3, figsize=(15, 5), facecolor='white') for ax in axes: ax.axis('off') axes[0].imshow(img_array.astype(np.uint8)); axes[0].set_title('Original') if is_healthy: axes[1].imshow(img_array.astype(np.uint8)); axes[1].set_title('Healthy') axes[2].imshow(img_array.astype(np.uint8)); axes[2].set_title('Healthy') else: hm = cv2.resize(heatmap_np, (224, 224)) sup = np.clip(plt.cm.jet(hm)[:, :, :3] * 0.55 + img_array / 255.0 * 0.45, 0, 1) axes[1].imshow(hm, cmap='jet'); axes[1].set_title('Grad-CAM') axes[2].imshow(sup); axes[2].set_title('Overlay') buf = io.BytesIO() plt.tight_layout(); fig.savefig(buf, format='png', dpi=150, facecolor='white') plt.close(fig); buf.seek(0) return buf.read() # ════════════════════════════════════════════════════════════════ # STORAGE # ════════════════════════════════════════════════════════════════ def upload_gradcam_to_storage(png_bytes: bytes, filename: str) -> Optional[str]: try: path = f"overlays/{filename}" supabase.storage.from_(GRADCAM_BUCKET).upload( path=path, file=png_bytes, file_options={"content-type": "image/png", "upsert": "true"} ) url = supabase.storage.from_(GRADCAM_BUCKET).get_public_url(path) print(f"✅ Storage: {path}") return url except Exception as e: print(f"⚠️ Storage upload failed: {e}") return None # ════════════════════════════════════════════════════════════════ # HELPER — حفظ في Supabase (مشترك بين Admin و User) # ════════════════════════════════════════════════════════════════ def save_diagnosis(record: dict) -> bool: try: result = supabase.table(TABLE_NAME).insert(record).execute() if result.data: print(f"✅ DB saved: {record.get('class_key')} | user={record.get('profile_id')} | conf={record.get('confidence')}%") return True print(f"⚠️ Insert returned no data") return False except Exception as e: print(f"⚠️ DB insert failed: {e}") return False # ════════════════════════════════════════════════════════════════ # FASTAPI APP # ════════════════════════════════════════════════════════════════ app = FastAPI(title="Skyline LAND API", version="3.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) # ── GET / ───────────────────────────────────────────────────────── @app.get("/") def root(): return { "status": "Skyline LAND API ✅", "cnn_classes": len(CLASS_NAMES), "vlm_ready": GEMINI_READY, "version": "3.0.0", } # ── GET /health ─────────────────────────────────────────────────── @app.get("/health") def health(): db_ok = False try: supabase.table(TABLE_NAME).select("id").limit(1).execute() db_ok = True except: pass return { "api": "ok", "db": "ok" if db_ok else "error", "cnn": f"{len(CLASS_NAMES)} classes", "vlm": "ready" if GEMINI_READY else "disabled (no API key)", "bucket": GRADCAM_BUCKET, } # ════════════════════════════════════════════════════════════════ # POST /predict ← Admin + User (CNN + Grad-CAM) # كلا الصفحتين تستدعي هذا الـ endpoint # ════════════════════════════════════════════════════════════════ @app.post("/predict") async def predict( file: UploadFile = File(...), user_id: Optional[str] = Form(None), ): if not file.content_type.startswith("image/"): raise HTTPException(400, "File must be an image") raw = await file.read() try: pil_img = Image.open(io.BytesIO(raw)).convert("RGB").resize((224, 224)) img_array = np.array(pil_img, dtype=np.uint8) except Exception as e: raise HTTPException(400, f"Cannot open image: {e}") # CNN + Grad-CAM heatmap_np, class_idx, conf_raw, original_b64, heatmap_b64, overlay_b64 = \ get_gradcam_and_visualizations(img_array) conf = round(conf_raw * 100, 2) class_key = CLASS_NAMES[class_idx] if class_idx < len(CLASS_NAMES) else f"class_{class_idx}" info = expert_db.get(class_key, {}) is_healthy = class_key in HEALTHY_CLASSES # Top-3 inp_np = tf.keras.applications.efficientnet.preprocess_input(img_array.astype(np.float32)[np.newaxis]) all_preds = _predict_raw(inp_np)[0].numpy() top3_idx = np.argsort(all_preds)[-3:][::-1] top3 = [ { "class": CLASS_NAMES[i], "name_en": expert_db.get(CLASS_NAMES[i], {}).get("en", CLASS_NAMES[i]), "conf": round(float(all_preds[i]) * 100, 2), } for i in top3_idx ] # Storage upload (فقط للنبات المريض) gradcam_url = None if not is_healthy: gradcam_png = build_gradcam_png_bytes(heatmap_np, img_array, is_healthy) gradcam_url = upload_gradcam_to_storage(gradcam_png, f"{uuid.uuid4()}.png") # حفظ في Supabase — نفس الجدول للـ Admin والـ User record = { "profile_id": user_id, "class_key": class_key, "disease_name_en": info.get("en", class_key), "disease_name_ar": info.get("ar", ""), "advice_en": info.get("adv_en", ""), "advice_ar": info.get("adv_ar", ""), "treatment_en": info.get("chem_en", ""), "treatment_ar": info.get("chem_ar", ""), "infection_type": info.get("infection_type", None), "is_healthy": is_healthy, "confidence": conf, "gradcam_url": gradcam_url, "analyzed_at": datetime.utcnow().isoformat(), } saved_to_db = save_diagnosis(record) return JSONResponse({ "success": True, "saved_to_db": saved_to_db, "class_key": class_key, "disease_name_en": record["disease_name_en"], "disease_name_ar": record["disease_name_ar"], "confidence": conf, "badge": "High" if conf >= 90 else "Good" if conf >= 75 else "Medium" if conf >= 60 else "Low", "is_healthy": is_healthy, "advice_en": record["advice_en"], "advice_ar": record["advice_ar"], "treatment_en": record["treatment_en"], "treatment_ar": record["treatment_ar"], "infection_type": record["infection_type"], "gradcam_url": gradcam_url, "original_b64": original_b64, "heatmap_b64": heatmap_b64, "overlay_b64": overlay_b64, "top3": top3, }) # ════════════════════════════════════════════════════════════════ # POST /vlm-analyze ← User فقط (تحليل أولي بـ Gemini Vision) # ════════════════════════════════════════════════════════════════ @app.post("/vlm-analyze") async def vlm_analyze( file: Optional[UploadFile] = File(None), lang: str = Form("en"), cnn_context: Optional[str] = Form(None), user_id: Optional[str] = Form(None), ): # ── Fallback إذا Gemini غير جاهز ───────────────────────────── if not GEMINI_READY or gemini_model is None: fallback = _build_fallback_analysis(cnn_context, lang) return JSONResponse({"success": True, "analysis": fallback, "source": "cnn_fallback"}) # ── Prompt حسب اللغة ───────────────────────────────────────── if lang == "ar": prompt = """أنت خبير زراعي متخصص في أمراض النبات والمحاصيل. حلّل الصورة وقدّم تقريراً شاملاً ومنظماً يشمل: **1. التشخيص:** ما المرض أو الحالة الصحية التي تراها؟ **2. الأعراض المرئية:** صف ما يظهر في الصورة بدقة **3. السبب:** الممرض أو العامل البيئي المسؤول **4. درجة الخطورة:** منخفضة / متوسطة / مرتفعة — ولماذا؟ **5. خطر الانتشار:** هل يمكن أن يصيب نباتات مجاورة؟ **6. العلاج الفوري:** خطوات عملية يبدأ بها المزارع الآن **7. الوقاية:** كيف يتجنب تكرار المشكلة في المواسم القادمة أجب باللغة العربية الفصحى البسيطة المناسبة للمزارع الجزائري.""" else: prompt = """You are an expert agricultural plant pathologist. Analyze this plant/field image and provide a structured comprehensive report: **1. Diagnosis:** What disease or health condition is present? **2. Visible Symptoms:** Describe exactly what appears in the image **3. Cause:** Pathogen or environmental factor responsible **4. Severity Level:** Low / Medium / High — and why? **5. Spread Risk:** Can it infect neighboring plants? **6. Immediate Treatment:** Practical steps the farmer can take now **7. Prevention:** How to avoid recurrence in future seasons Be thorough, practical, and clear for a farming context.""" # إضافة سياق CNN إذا متوفر if cnn_context: try: ctx = json.loads(cnn_context) cnn_info = ( f"\n\n[CNN Pre-detection: '{ctx.get('disease_name_en')}' " f"({ctx.get('confidence', 0):.1f}% confidence) — " f"{'Healthy' if ctx.get('is_healthy') else 'Diseased'}]" ) prompt += cnn_info except: pass try: parts = [prompt] if file: raw = await file.read() parts.append({"mime_type": file.content_type or "image/jpeg", "data": raw}) response = gemini_model.generate_content(parts) analysis = response.text return JSONResponse({ "success": True, "analysis": analysis, "source": "gemini_vlm", }) except Exception as e: print(f"⚠️ Gemini VLM error: {e}") fallback = _build_fallback_analysis(cnn_context, lang) return JSONResponse({"success": True, "analysis": fallback, "source": "cnn_fallback"}) # ════════════════════════════════════════════════════════════════ # POST /vlm-chat ← User فقط (دردشة متعددة الأدوار) # ════════════════════════════════════════════════════════════════ @app.post("/vlm-chat") async def vlm_chat( message: str = Form(...), lang: str = Form("en"), history: Optional[str] = Form(None), file: Optional[UploadFile] = File(None), cnn_context: Optional[str] = Form(None), ): # ── Fallback إذا Gemini غير جاهز ───────────────────────────── if not GEMINI_READY or gemini_model is None: reply = _build_fallback_chat(message, cnn_context, lang) return JSONResponse({"success": True, "reply": reply, "source": "cnn_fallback"}) # ── System prompt ───────────────────────────────────────────── if lang == "ar": sys_prompt = "أنت خبير زراعي متخصص. أجب باللغة العربية فقط وبأسلوب بسيط مناسب للمزارع." else: sys_prompt = "You are an expert agricultural advisor. Reply only in English, clearly and practically for a farmer." # إضافة سياق CNN if cnn_context: try: ctx = json.loads(cnn_context) ctx_txt = ( f" CNN detected: '{ctx.get('disease_name_en')}' " f"({ctx.get('confidence', 0):.1f}% confidence)." ) sys_prompt += ctx_txt except: pass # بناء تاريخ المحادثة gemini_history = [ {"role": "user", "parts": [sys_prompt]}, {"role": "model", "parts": ["Understood. I'm your agricultural expert assistant."]}, ] if history: try: hist = json.loads(history) for msg in hist[-6:]: # آخر 6 رسائل فقط لتوفير التوكن role = "user" if msg["role"] == "user" else "model" gemini_history.append({"role": role, "parts": [msg["content"]]}) except: pass try: chat = gemini_model.start_chat(history=gemini_history) parts = [message] if file: raw = await file.read() parts.append({"mime_type": file.content_type or "image/jpeg", "data": raw}) response = chat.send_message(parts) return JSONResponse({ "success": True, "reply": response.text, "source": "gemini_vlm", }) except Exception as e: print(f"⚠️ Gemini chat error: {e}") reply = _build_fallback_chat(message, cnn_context, lang) return JSONResponse({"success": True, "reply": reply, "source": "cnn_fallback"}) # ════════════════════════════════════════════════════════════════ # POST /chat ← دردشة بسيطة (متوافق مع HTML user) # ════════════════════════════════════════════════════════════════ @app.post("/chat") async def chat_simple(request: Request): try: body = await request.json() message = body.get("message", "") lang = body.get("lang", "en") history = body.get("history", []) initial_report = body.get("initial_report", "") # Fallback إذا Gemini غير جاهز if not GEMINI_READY or gemini_model is None: return JSONResponse({ "success": True, "response": _build_simple_chat_fallback(message, initial_report, lang), "reply": _build_simple_chat_fallback(message, initial_report, lang), "source": "fallback" }) # بناء السياق context = "" if initial_report: context = f"Previous diagnosis report: {initial_report[:500]}\n\n" if lang == "ar": system_prompt = f"""أنت خبير زراعي متخصص في أمراض النبات. {context} أجب على سؤال المزارع باللغة العربية فقط، بأسلوب بسيط وعملي. قدم نصائح واضحة حول العلاج والوقاية.""" else: system_prompt = f"""You are an expert agricultural plant pathologist. {context} Answer the farmer's question in English only, in a clear and practical way. Provide specific advice on treatment and prevention.""" # بناء تاريخ المحادثة gemini_history = [ {"role": "user", "parts": [system_prompt]}, {"role": "model", "parts": ["Understood. I'm your agricultural expert assistant. How can I help you today?"]}, ] for msg in history[-10:]: role = "user" if msg.get("role") == "user" else "model" gemini_history.append({"role": role, "parts": [msg.get("content", "")]}) try: chat = gemini_model.start_chat(history=gemini_history) response = chat.send_message(message) return JSONResponse({ "success": True, "response": response.text, "reply": response.text, "source": "gemini_vlm" }) except Exception as e: print(f"⚠️ Gemini chat error: {e}") fallback = _build_simple_chat_fallback(message, initial_report, lang) return JSONResponse({ "success": True, "response": fallback, "reply": fallback, "source": "error_fallback" }) except Exception as e: print(f"⚠️ Chat endpoint error: {e}") return JSONResponse({ "success": False, "response": "Chat service temporarily unavailable. Please try again later.", "reply": "Chat service temporarily unavailable. Please try again later.", "source": "error" }) # ════════════════════════════════════════════════════════════════ # GET /history ← Admin (كل البيانات) + User (بياناته فقط) # ════════════════════════════════════════════════════════════════ @app.get("/history") def history(profile_id: Optional[str] = None, limit: int = 20): try: query = supabase.table(TABLE_NAME).select( "id, profile_id, class_key, disease_name_en, disease_name_ar, " "confidence, is_healthy, gradcam_url, analyzed_at" ) if profile_id: query = query.eq("profile_id", profile_id) response = query.order("analyzed_at", desc=True).limit(limit).execute() return {"success": True, "count": len(response.data), "data": response.data} except Exception as e: raise HTTPException(500, f"DB error: {e}") # ════════════════════════════════════════════════════════════════ # HELPERS — Fallback بدون Gemini (يستخدم نتيجة CNN) # ════════════════════════════════════════════════════════════════ def _build_fallback_analysis(cnn_context: Optional[str], lang: str) -> str: """يبني تحليلاً من نتيجة CNN عندما يكون Gemini غير متاح""" if not cnn_context: return ("لا يمكن الاتصال بـ Gemini VLM حالياً. يرجى استخدام نتيجة CNN أعلاه." if lang == "ar" else "Gemini VLM is not available. Please use the CNN result above.") try: ctx = json.loads(cnn_context) if lang == "ar": return ( f"**التشخيص:** {ctx.get('disease_name_ar') or ctx.get('disease_name_en', '—')}\n\n" f"**نسبة الثقة:** {ctx.get('confidence', 0):.1f}%\n\n" f"**الحالة:** {'نبات سليم ✅' if ctx.get('is_healthy') else 'مصاب ⚠️'}\n\n" f"**النصيحة:** {ctx.get('advice_ar') or ctx.get('advice_en', 'راجع بروتوكول العلاج.')}\n\n" f"**العلاج:** {ctx.get('treatment_ar') or ctx.get('treatment_en', 'استشر خبيراً زراعياً.')}\n\n" f"_ملاحظة: هذا التحليل مبني على نتيجة CNN. قم بإضافة GEMINI_API_KEY للحصول على تحليل VLM كامل._" ) else: return ( f"**Diagnosis:** {ctx.get('disease_name_en', '—')}\n\n" f"**Confidence:** {ctx.get('confidence', 0):.1f}%\n\n" f"**Status:** {'Healthy ✅' if ctx.get('is_healthy') else 'Diseased ⚠️'}\n\n" f"**Advice:** {ctx.get('advice_en', 'Follow the treatment protocol.')}\n\n" f"**Treatment:** {ctx.get('treatment_en', 'Consult an agricultural expert.')}\n\n" f"_Note: This analysis is based on CNN result. Add GEMINI_API_KEY for full VLM analysis._" ) except: return "Analysis based on CNN result. Add GEMINI_API_KEY for VLM capabilities." def _build_fallback_chat(message: str, cnn_context: Optional[str], lang: str) -> str: """يرد على الأسئلة من نتيجة CNN عندما يكون Gemini غير متاح""" ctx = {} if cnn_context: try: ctx = json.loads(cnn_context) except: pass msg_lower = message.lower() ar = lang == "ar" if any(w in msg_lower for w in ["cause","سبب","ممرض","pathogen"]): return (f"سبب المرض: {ctx.get('disease_name_ar') or ctx.get('disease_name_en','غير محدد')}. " f"نوع العدوى: {ctx.get('infection_type','غير محدد')}." if ar else f"Disease cause: {ctx.get('disease_name_en','Unknown')}. " f"Infection type: {ctx.get('infection_type','Unknown')}.") if any(w in msg_lower for w in ["treat","علاج","cure","دواء","مبيد"]): return (ctx.get('treatment_ar') or ctx.get('treatment_en') or ("راجع بروتوكول العلاج في نتيجة CNN." if ar else "Refer to the CNN treatment protocol.")) if any(w in msg_lower for w in ["prevent","وقاية","spread","انتشار"]): return ("لمنع الانتشار: أزل النباتات المصابة، طبّق مبيدات وقائية، وحسّن تهوية الحقل." if ar else "To prevent spread: remove infected plants, apply preventive fungicides, improve field ventilation.") if any(w in msg_lower for w in ["advice","نصيحة","recommend","توصية"]): return (ctx.get('advice_ar') or ctx.get('advice_en') or ("اتبع التوصيات الموضحة في نتيجة التشخيص." if ar else "Follow the recommendations shown in the diagnosis result.")) return (ctx.get('advice_ar') or ctx.get('advice_en') or ("يرجى إضافة GEMINI_API_KEY لتفعيل الدردشة الذكية الكاملة." if ar else "Please add GEMINI_API_KEY to enable full AI chat capabilities.")) def _build_simple_chat_fallback(message: str, initial_report: str, lang: str) -> str: """رد بديل بسيط عندما يكون Gemini غير متاح""" msg_lower = message.lower() if lang == "ar": if any(w in msg_lower for w in ["سبب", "لماذا"]): return "سبب المرض عادة ما يكون فطرياً أو بكتيرياً أو فيروسياً. يرجى الاطلاع على تقرير التشخيص أعلاه." if any(w in msg_lower for w in ["علاج", "مبيد"]): if initial_report: return f"العلاج الموصى به: {initial_report[:200]}" return "العلاج يعتمد على نوع المرض. يرجى استشارة خبير زراعي." if any(w in msg_lower for w in ["منع", "وقاية"]): return "للوقاية: أزل النباتات المصابة، استخدم مبيدات وقائية، وحسن تهوية الحقل." return "شكراً لسؤالك. للحصول على إجابة مفصلة، يرجى تشغيل خدمة Gemini VLM." else: if any(w in msg_lower for w in ["cause", "why"]): return "The disease is typically caused by fungal, bacterial, or viral pathogens. Check the diagnosis report above." if any(w in msg_lower for w in ["treat", "cure", "pesticide"]): if initial_report: return f"Recommended treatment: {initial_report[:200]}" return "Treatment depends on the disease type. Please consult an agricultural expert." if any(w in msg_lower for w in ["prevent", "spread"]): return "To prevent spread: remove infected plants, apply preventive fungicides, improve field ventilation." return "Thank you for your question. For a detailed answer, please enable Gemini VLM service." # ════════════════════════════════════════════════════════════════ # RUN # ════════════════════════════════════════════════════════════════ if __name__ == "__main__": import uvicorn uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)