Spaces:
Sleeping
Sleeping
| import re | |
| import asyncio | |
| from datetime import datetime | |
| from typing import Optional | |
| from fastapi import APIRouter, HTTPException | |
| from app.services.llm_groq import GroqLLM | |
| from app.services.vision_openai import VisionService | |
| from app.services.weather_service import WeatherService | |
| from app.schemas.chat import ChatRequest, ChatResponse | |
| from app.core.context_engine import ContextEngine | |
| from app.core.prompt_builder import PromptBuilder | |
| from app.schemas.sensor import SensorData | |
| from app.utils.crop_detector import detect_crop_from_message | |
| from app.services.product_service import get_all_products_for_crop | |
| from app.services.product_matcher import find_products_for_situation | |
| from app.services.memory_service import MemoryService | |
| from app.services.query_understander import understand_query | |
| from app.services.ipm_retriever import ipm_retriever | |
| from app.utils.logger import setup_logger | |
| # Lingua fissa: il prodotto Γ¨ per il mercato italiano β sempre risponde in italiano | |
| _RESPONSE_LANGUAGE = "IT" | |
| logger = setup_logger() | |
| router = APIRouter() | |
| llm = GroqLLM() | |
| context_engine = ContextEngine() | |
| memory_service = MemoryService() | |
| vision_service = VisionService() | |
| weather_service = WeatherService() | |
| # Max base64 image size (~7MB encoded β ~5MB raw) | |
| MAX_IMAGE_B64_LEN = 7_000_000 | |
| _IMAGE_CORRECTION_RE = re.compile( | |
| r"\b(that|second|first|last|previous|the)\s+(picture|image|photo|pic|foto)\s+was\b" | |
| r"|\b(picture|image|photo|pic|foto)\s+was\b" | |
| r"|\bthat\s+was\s+(a|an)\s+\w+", | |
| re.IGNORECASE, | |
| ) | |
| def _is_image_correction(message: str) -> bool: | |
| """True when user is retroactively correcting what a previous image showed.""" | |
| return bool(_IMAGE_CORRECTION_RE.search(message or "")) | |
| async def chat(request: ChatRequest): | |
| try: | |
| session_id = request.session_id or "default" | |
| logger.info(f"Chat: session={session_id}, has_image={bool(request.image_base64)}, crop={request.crop_type}") | |
| # Guard: reject oversized images early | |
| if request.image_base64 and len(request.image_base64) > MAX_IMAGE_B64_LEN: | |
| raise HTTPException(status_code=400, detail="Image too large. Please send an image under 5MB.") | |
| # ββ Step 1: Load session state ββββββββββββββββββββββββββββββββββββββββ | |
| last_ctx = memory_service.get_last_context(session_id) | |
| history = memory_service.get_conversation_history(session_id) | |
| is_first_message = not bool(history) | |
| last_injected = memory_service.get_last_injected(session_id) | |
| # ββ Step 2: Resolve initial effective values (request > memory > default) β | |
| # Farming type β always have a value; "conventional" is the system default | |
| effective_farming_type = request.farming_type or last_ctx.get("farming_type") or "conventional" | |
| # Crop: message text > dropdown > memory | |
| detected_from_message = detect_crop_from_message(request.message) | |
| effective_crop = detected_from_message or request.crop_type or last_ctx.get("crop_type") | |
| # BBCH: reset stale BBCH whenever the crop changes (text OR dropdown), else carry forward | |
| previous_crop = last_ctx.get("crop_type") or "" | |
| crop_switched_via_text = bool( | |
| detected_from_message and detected_from_message != (request.crop_type or previous_crop) | |
| ) | |
| crop_switched_via_dropdown = bool( | |
| request.crop_type and previous_crop and request.crop_type != previous_crop | |
| ) | |
| if crop_switched_via_text or crop_switched_via_dropdown: | |
| # New crop β only use an explicitly provided BBCH, never inherit from memory | |
| effective_bbch = request.bbch_stage or None | |
| else: | |
| effective_bbch = request.bbch_stage or last_ctx.get("bbch_stage") | |
| # ββ Step 3: Vision analysis βββββββββββββββββββββββββββββββββββββββββββ | |
| vision_result = None | |
| image_number = None | |
| image_switched_plant = False # True when image caused a crop context switch | |
| if request.image_base64: | |
| try: | |
| vision_result = await vision_service.analyze_image( | |
| image_base64=request.image_base64, | |
| crop_type=effective_crop, | |
| bbch_stage=effective_bbch, | |
| ) | |
| image_number = memory_service.get_next_image_number(session_id) | |
| logger.info(f"Vision: image #{image_number}, plant={vision_result.get('plant')}, disease={vision_result.get('disease')}") | |
| vision_plant_detected = (vision_result.get("plant") or "").lower().strip() | |
| if vision_plant_detected: | |
| if not effective_crop: | |
| # No crop context yet β adopt the image plant | |
| effective_crop = vision_plant_detected | |
| effective_bbch = vision_result.get("bbch") | |
| else: | |
| crop_lower_curr = effective_crop.lower().strip() | |
| plant_matches = ( | |
| vision_plant_detected in crop_lower_curr | |
| or crop_lower_curr in vision_plant_detected | |
| ) | |
| if not plant_matches: | |
| # Image shows a DIFFERENT plant β switch subject | |
| logger.info(f"Image subject switch: {effective_crop!r} β {vision_plant_detected!r}") | |
| memory_service.save_plant_context( | |
| session_id, effective_crop, effective_bbch, | |
| last_ctx.get("disease"), effective_farming_type | |
| ) | |
| effective_crop = vision_plant_detected | |
| effective_bbch = vision_result.get("bbch") | |
| image_switched_plant = True | |
| except Exception as e: | |
| logger.warning(f"Vision analysis failed: {e}") | |
| # Disease from vision | |
| disease_detected = None | |
| if vision_result and not vision_result.get("healthy", True): | |
| disease_detected = vision_result.get("disease") | |
| # image_is_different_plant: True when vision context should replace sidebar context. | |
| # Compare sidebar crop (request.crop_type) against vision plant to decide if | |
| # the sidebar fields (BBCH, crop name) are for a different plant than in the image. | |
| sidebar_crop = (request.crop_type or "").lower().strip() | |
| vision_plant_for_flag = (vision_result or {}).get("plant", "").lower().strip() | |
| image_is_different_plant = bool( | |
| vision_plant_for_flag and sidebar_crop | |
| and vision_plant_for_flag not in sidebar_crop | |
| and sidebar_crop not in vision_plant_for_flag | |
| ) | |
| # ββ Step 4: Query understanding + farming/BBCH overrides βββββββββββββ | |
| ipm_excel_results = [] | |
| ipm_pdf_results = [] | |
| # Lingua sempre italiana β prodotto per il mercato italiano | |
| detected_language = _RESPONSE_LANGUAGE | |
| understanding = {} | |
| if ipm_retriever.is_ready(): | |
| try: | |
| understanding = await understand_query( | |
| message=request.message, | |
| history=history[-6:] if len(history) > 6 else history, | |
| ) | |
| logger.debug(f"Query understanding: intent={understanding.get('intent')}, " | |
| f"disease={understanding.get('disease')}, " | |
| f"search_excel={understanding.get('search_excel')}, " | |
| f"search_pdf={understanding.get('search_pdf')}") | |
| # If user stated farming type in message text β override dropdown/memory | |
| if understanding.get("farming_type"): | |
| effective_farming_type = understanding["farming_type"] | |
| logger.debug(f"Farming type overridden by understander: {effective_farming_type!r}") | |
| # If user stated BBCH in message text and no explicit form input β use it | |
| if understanding.get("bbch_stage") and not request.bbch_stage: | |
| effective_bbch = understanding["bbch_stage"] | |
| logger.debug(f"BBCH overridden by understander: {effective_bbch!r}") | |
| # Merge plants_discussed into plant_history β enrich context for each known plant | |
| plants_discussed = understanding.get("plants_discussed") or {} | |
| if isinstance(plants_discussed, dict): | |
| for plant_key, plant_ctx in plants_discussed.items(): | |
| if not isinstance(plant_ctx, dict): | |
| continue | |
| # Only update if we learn something new β never overwrite with null | |
| existing = memory_service.get_plant_context(session_id, plant_key) | |
| merged = { | |
| "bbch": plant_ctx.get("bbch") or existing.get("bbch"), | |
| "disease": plant_ctx.get("disease") or existing.get("disease"), | |
| "farming_type": plant_ctx.get("farming_type") or existing.get("farming_type"), | |
| } | |
| # Only save if we have at least one non-null value | |
| if any(v for v in merged.values()): | |
| memory_service.save_plant_context( | |
| session_id, plant_key, | |
| merged["bbch"], merged["disease"], merged["farming_type"] | |
| ) | |
| if plants_discussed: | |
| logger.debug(f"Merged plants_discussed into history: {list(plants_discussed.keys())}") | |
| # active_plant: use as fallback if effective_crop not resolved from other sources | |
| active_plant = understanding.get("active_plant") | |
| if active_plant and not effective_crop: | |
| effective_crop = active_plant.lower() | |
| # Try to restore context for this plant from history | |
| known = memory_service.get_plant_context(session_id, effective_crop) | |
| if known.get("bbch") and not effective_bbch: | |
| effective_bbch = known["bbch"] | |
| if known.get("farming_type"): | |
| effective_farming_type = known["farming_type"] | |
| logger.debug(f"effective_crop set from active_plant: {effective_crop!r}") | |
| elif active_plant and active_plant.lower() != (effective_crop or "").lower(): | |
| # User switched to a plant mentioned earlier ("back to my vines") | |
| # Restore that plant's known context | |
| known = memory_service.get_plant_context(session_id, active_plant.lower()) | |
| if known: | |
| logger.debug(f"Restoring context for active_plant={active_plant!r}: {known}") | |
| if known.get("bbch") and not request.bbch_stage: | |
| effective_bbch = known["bbch"] | |
| if known.get("farming_type") and not request.farming_type: | |
| effective_farming_type = known["farming_type"] | |
| if known.get("disease"): | |
| disease_detected = disease_detected or known["disease"] | |
| # ββ Force-search safety net βββββββββββββββββββββββββββββββββββ | |
| # If the understander missed the search trigger, catch it here via | |
| # keywords. This ensures regulatory/treatment questions always hit | |
| # the IPM database even when the LLM extraction is uncertain. | |
| _FORCE_SEARCH_KEYWORDS = { | |
| "treatment", "trattament", "spray", "spruzz", "apply", "applic", | |
| "dose", "dosi", "dosage", "quanti", "massimo", "maximum", | |
| "allowed", "ammess", "posso usar", "can i use", "how many", | |
| "how often", "ogni quanto", "substance", "sostanza", "prodott", | |
| "product", "fungicid", "insecticid", "acaricid", "herbicid", | |
| "erbicid", "limit", "restrizi", "restriction", | |
| "treat", "cure", "cura", "combatt", "contro", "against", | |
| "peronospora", "oidio", "botrytis", "muffa", "alternaria", | |
| "phytophthora", "disease", "malattia", "parassit", "pest", | |
| "infezi", "infect", "attacco", "attack", "sintom", "symptom", | |
| # farming-type switch triggers β "cosa cambia per biologico?" etc. | |
| "biologico", "organic", "convenzional", "conventional", | |
| "cambia", "differenz", "invece", "alternativ", | |
| } | |
| # Also force-search when the farming type changed this turn | |
| # (e.g. user switches to organic β need fresh product list for that type) | |
| farming_type_changed = ( | |
| effective_farming_type != (last_ctx.get("farming_type") or "conventional") | |
| ) | |
| if not understanding.get("search_excel") and effective_crop: | |
| msg_lower = (request.message or "").lower() | |
| keyword_hit = any(kw in msg_lower for kw in _FORCE_SEARCH_KEYWORDS) | |
| if keyword_hit or farming_type_changed: | |
| understanding["search_excel"] = True | |
| if farming_type_changed: | |
| logger.debug(f"Force-search triggered by farming_type change: {last_ctx.get('farming_type')!r} β {effective_farming_type!r}") | |
| else: | |
| logger.debug("Force-search triggered by keyword match") | |
| # Build fallback queries if understander gave none | |
| if not understanding.get("excel_queries"): | |
| disease_hint = ( | |
| understanding.get("disease_common") | |
| or understanding.get("disease") | |
| or last_ctx.get("disease") | |
| or "" | |
| ) | |
| understanding["excel_queries"] = [ | |
| f"{effective_crop} {disease_hint} trattamento sostanze attive {effective_farming_type if effective_farming_type == 'organic' else ''}".strip(), | |
| f"{effective_crop} {disease_hint} limitazioni numero massimo trattamenti".strip(), | |
| ] | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Excel retrieval β multi-query parallel search (3 angles) | |
| # Always use effective_crop as the hard filter. | |
| # Never use understanding["crop"] β understander can misextract crop names | |
| # from disease words (e.g. "pero" from "peronospora"). | |
| excel_queries = understanding.get("excel_queries") or [] | |
| if not excel_queries and understanding.get("excel_query"): | |
| excel_queries = [understanding["excel_query"]] # backward compat | |
| if understanding.get("search_excel") and excel_queries: | |
| ipm_excel_results = await ipm_retriever.multi_search_excel( | |
| queries=excel_queries, | |
| crop_code=effective_crop, | |
| farming_type=effective_farming_type, | |
| n_results=5, | |
| ) | |
| logger.debug(f"IPM Excel results: {len(ipm_excel_results)} from {len(excel_queries)} queries") | |
| # PDF retrieval β regulatory guidelines | |
| if understanding.get("search_pdf") and understanding.get("pdf_query"): | |
| ipm_pdf_results = ipm_retriever.search_pdf( | |
| query=understanding["pdf_query"], | |
| n_results=2, | |
| ) | |
| logger.debug(f"IPM PDF results: {len(ipm_pdf_results)}") | |
| except Exception as e: | |
| logger.warning(f"IPM retrieval failed: {e}") | |
| # ββ Product matching β find AllTech products for this situation ββββββββ | |
| # Run after IPM retrieval so we know if a disease was confirmed. | |
| # Only match when a disease/problem exists or user asked for product advice. | |
| matched_products = [] | |
| needs_products = ( | |
| understanding.get("needs_product") | |
| or understanding.get("search_excel") | |
| or bool(disease_detected) | |
| ) | |
| if needs_products and effective_crop: | |
| try: | |
| matched_products = find_products_for_situation( | |
| crop=effective_crop, | |
| disease=disease_detected or understanding.get("disease_common") or understanding.get("disease"), | |
| farming_type=effective_farming_type, | |
| max_results=4, | |
| ) | |
| logger.debug(f"Matched AllTech products: {[p['name'] for p in matched_products]}") | |
| except Exception as e: | |
| logger.warning(f"Product matching failed: {e}") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ββ Step 5: Plant history β save old plant, restore known context βββββ | |
| current_effective_crop = (effective_crop or "").lower() | |
| previous_crop_lower = previous_crop.lower() | |
| if current_effective_crop and previous_crop_lower and current_effective_crop != previous_crop_lower: | |
| # Crop changed this turn (text switch or image switch) | |
| if not image_switched_plant: | |
| # Image switch already saved it; only save for text-based switches | |
| memory_service.save_plant_context( | |
| session_id, previous_crop, | |
| last_ctx.get("bbch_stage"), | |
| last_ctx.get("disease"), | |
| last_ctx.get("farming_type"), | |
| ) | |
| # Check if we have history for the new crop β restore BBCH if we do | |
| if not effective_bbch: | |
| known = memory_service.get_plant_context(session_id, effective_crop) | |
| if known.get("bbch"): | |
| effective_bbch = known["bbch"] | |
| logger.debug(f"Restored BBCH {effective_bbch!r} from plant history for {effective_crop!r}") | |
| # ββ Step 6: Sensor / Weather ββββββββββββββββββββββββββββββββββββββββββ | |
| sensor_data = None | |
| if request.sensors_enabled and any([ | |
| request.soil_moisture, request.soil_temperature, | |
| request.air_temperature, request.humidity | |
| ]): | |
| sensor_data = SensorData( | |
| soil_moisture=request.soil_moisture, | |
| soil_temperature=request.soil_temperature, | |
| air_temperature=request.air_temperature, | |
| humidity=request.humidity, | |
| ) | |
| # Resolve location: understander extraction > frontend request > session memory | |
| # This allows the user to say "I'm in Milan" in chat and have it used immediately. | |
| effective_location = ( | |
| understanding.get("location") | |
| or request.location | |
| or last_ctx.get("location") | |
| ) or None | |
| weather_data = None | |
| should_fetch_weather = ( | |
| request.weather_enabled or understanding.get("needs_weather", False) | |
| ) | |
| if should_fetch_weather and effective_location: | |
| try: | |
| location_str = effective_location.strip() | |
| if "," in location_str: | |
| parts = location_str.split(",") | |
| if len(parts) == 2: | |
| try: | |
| lat = float(parts[0].strip()) | |
| lon = float(parts[1].strip()) | |
| wr = await weather_service.get_7day_forecast(lat=lat, lon=lon) | |
| except ValueError: | |
| wr = await weather_service.get_7day_forecast(city=location_str) | |
| else: | |
| wr = await weather_service.get_7day_forecast(city=location_str) | |
| else: | |
| wr = await weather_service.get_7day_forecast(city=location_str) | |
| weather_data = { | |
| "location": wr.location, | |
| "forecast": [d.model_dump() for d in wr.forecast], | |
| } | |
| logger.debug(f"Weather fetched for '{effective_location}': {len(weather_data.get('forecast', []))} days") | |
| except Exception as e: | |
| logger.warning(f"Weather fetch failed for '{effective_location}': {e}") | |
| # ββ Step 7: Products ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Fetch products for effective_crop (which is already the image plant after a switch) | |
| available_products = [] | |
| if effective_crop: | |
| products_list = get_all_products_for_crop(effective_crop) | |
| available_products = [ | |
| { | |
| "name": p.name, | |
| "category": p.category, | |
| "description": p.description, | |
| "dose_min": p.dose_min, | |
| "function": p.function, | |
| "timing": p.timing, | |
| "organic": p.organic, | |
| } | |
| for p in products_list | |
| ] | |
| # ββ Greeting short-circuit ββββββββββββββββββββββββββββββββββββββββββββ | |
| # Pure greetings / chitchat ("ciao", "grazie", "hi") β skip all context | |
| # injection, RAG, products, sensors, weather. Let AllTech AI reply naturally. | |
| # The understander sets is_greeting=True only when there is NO farming | |
| # question embedded in the message. | |
| is_pure_greeting = bool(understanding.get("is_greeting")) and not request.image_base64 | |
| if is_pure_greeting: | |
| ipm_excel_results = [] | |
| ipm_pdf_results = [] | |
| matched_products = [] | |
| sensor_data = None | |
| weather_data = None | |
| logger.debug("Greeting short-circuit: skipping all context injection") | |
| # ββ Step 8: Build context βββββββββββββββββββββββββββββββββββββββββββββ | |
| context = await context_engine.build_farming_context( | |
| crop_type=effective_crop, | |
| bbch_stage=effective_bbch, | |
| crop_source="user" if request.crop_type else "message", | |
| bbch_source="user", | |
| sensor_data=sensor_data, | |
| weather_data=weather_data, | |
| disease_detected=disease_detected, | |
| disease_image_analyzed=bool(request.image_base64), | |
| sensors_enabled=request.sensors_enabled, | |
| weather_enabled=request.weather_enabled, | |
| ) | |
| # ββ Step 9: Smart injection flags βββββββββββββββββββββββββββββββββββββ | |
| crop_changed = effective_crop != last_injected.get("crop") | |
| bbch_changed = effective_bbch != last_injected.get("bbch") | |
| send_crop = (is_first_message or crop_changed) and not image_is_different_plant | |
| send_bbch = (is_first_message or crop_changed or bbch_changed) and not image_is_different_plant | |
| send_phenology = send_bbch | |
| # Farming type: ALWAYS inject so LLM always knows β it's the most important filter | |
| send_farming_type = bool(effective_farming_type) | |
| logger.debug(f"Farming type effective={effective_farming_type!r}, send={send_farming_type}") | |
| # Weather: send when understander flags it relevant, OR explicit weather question, OR first message. | |
| # needs_weather=true for treatment/disease/irrigation/product/timing queries (set by understander). | |
| # Keyword check is a fallback for when understander is unavailable or returns no flag. | |
| _weather_keywords = ( | |
| "weather", "forecast", "rain", "temperature", "wind", "sunny", "cloudy", "storm", | |
| "meteo", "pioggia", "previsioni", "clima", "tempo", "vento", "sole", "nuvole", | |
| "temporale", "grandine", "gelo", "umiditΓ ", "caldo", "freddo", "nebbia", | |
| # farming operation keywords that always benefit from weather context | |
| "spray", "spruzz", "irrigat", "innaffi", "harvest", "raccolt", "when to", | |
| "quando", "trattament", "treatment", "apply", "applic", "timing", "timing", | |
| ) | |
| _user_asks_weather = any(w in (request.message or "").lower() for w in _weather_keywords) | |
| send_weather = bool(weather_data) and not image_is_different_plant and ( | |
| is_first_message | |
| or understanding.get("needs_weather", False) # primary: understander decision | |
| or _user_asks_weather # fallback: keyword match | |
| ) | |
| # Sensors: first message or any value changes by β₯5 units | |
| send_sensors = False | |
| if sensor_data and not image_is_different_plant: | |
| if is_first_message: | |
| send_sensors = True | |
| else: | |
| last_sv = last_injected.get("sensor_values", {}) | |
| for key, val in [ | |
| ("soil_moisture", sensor_data.soil_moisture), | |
| ("soil_temperature", sensor_data.soil_temperature), | |
| ("air_temperature", sensor_data.air_temperature), | |
| ("humidity", sensor_data.humidity), | |
| ]: | |
| last_val = last_sv.get(key) | |
| if val is not None and (last_val is None or abs(val - last_val) >= 5): | |
| send_sensors = True | |
| break | |
| # Products: first message, crop change, or disease detected | |
| send_products = is_first_message or crop_changed or bool(disease_detected) | |
| # Greeting override β suppress everything except farming type (AllTech AI always | |
| # knows what kind of farm it's talking to, even on a "ciao") | |
| if is_pure_greeting: | |
| send_crop = False | |
| send_bbch = False | |
| send_phenology = False | |
| send_sensors = False | |
| send_weather = False | |
| send_products = False | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Detect retroactive image corrections ("second picture was rose") β no image attached. | |
| # Replace entire context with a short correction note so crop context doesn't drown it. | |
| is_correction = _is_image_correction(request.message) and not request.image_base64 | |
| if is_correction: | |
| context_section = ( | |
| "[NOTA] L'utente sta correggendo l'identitΓ di un'immagine precedente. " | |
| "Cerca nella cronologia della conversazione il blocco [ANALISI VISIVA PIANTA - Immagine #N] " | |
| "a cui si riferisce e rispondi specificamente su quella pianta e sulla sua malattia/condizione." | |
| ) | |
| else: | |
| context_section = PromptBuilder.build_context_section( | |
| context=context, | |
| crop_type=effective_crop, | |
| bbch_stage=effective_bbch, | |
| farming_type=effective_farming_type, | |
| available_products=available_products, | |
| vision_result=vision_result, | |
| image_number=image_number, | |
| image_is_different_plant=image_is_different_plant, | |
| send_crop=send_crop, | |
| send_farming_type=send_farming_type, | |
| send_sensors=send_sensors, | |
| send_bbch=send_bbch, | |
| send_phenology=send_phenology, | |
| send_weather=send_weather, | |
| send_products=send_products, | |
| ) | |
| # Build IPM section β regulations + matched AllTech products in one block | |
| ipm_section = PromptBuilder.build_ipm_section( | |
| ipm_excel_results=ipm_excel_results, | |
| ipm_pdf_results=ipm_pdf_results, | |
| farming_type=effective_farming_type, | |
| matched_products=matched_products, | |
| ) | |
| # ββ Missing info gates ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # When the user needs treatment/product advice but critical context is | |
| # absent, inject a hard instruction so the LLM asks before recommending. | |
| needs_advice = ( | |
| not is_pure_greeting | |
| and (understanding.get("needs_product") or understanding.get("search_excel")) | |
| ) | |
| missing_info_notes = [] | |
| # Location gate: if weather is needed but no location known, ask for it once | |
| if not is_pure_greeting and understanding.get("needs_weather") and not effective_location and not weather_data: | |
| missing_info_notes.append( | |
| "[AZIONE RICHIESTA] I dati meteo aiuterebbero a rispondere con precisione " | |
| "(tempistica trattamenti, finestre di pioggia, pressione malattie dipendono dalle condizioni locali). " | |
| "Chiedi all'utente: 'In quale città o regione ti trovi? Così controllo le previsioni locali.' " | |
| "Fallo in modo naturale β una breve domanda, poi continua con consigli generali." | |
| ) | |
| logger.debug("Location gate: needs_weather=True but no location β asking user for city") | |
| if needs_advice and not effective_farming_type: | |
| missing_info_notes.append( | |
| "[AZIONE RICHIESTA] Il tipo di coltivazione non Γ¨ noto. " | |
| "DEVI chiedere all'utente se coltiva in biologico o in convenzionale " | |
| "PRIMA di consigliare qualsiasi prodotto o sostanza. " | |
| "Non consigliare nulla finchΓ© non hai questa risposta." | |
| ) | |
| if needs_advice and not effective_bbch and effective_crop and not request.image_base64: | |
| missing_info_notes.append( | |
| "[AZIONE RICHIESTA] Lo stadio di crescita (BBCH) non Γ¨ noto. " | |
| "Chiedi all'utente a che stadio si trova la coltura " | |
| "(es. semenzaio, fioritura, fruttificazione β o un numero BBCH) " | |
| "PRIMA di dare consigli di trattamento legati alla fase fenologica." | |
| ) | |
| # Bug #18: User asks about a plant problem but described no symptoms and uploaded no image. | |
| # Prevent the LLM from inventing a diagnosis β force it to ask what they're seeing first. | |
| _SYMPTOM_WORDS = { | |
| # English visual/physical descriptors | |
| "yellow", "yellowing", "brown", "browning", "spot", "spots", "spotted", | |
| "wilt", "wilting", "wilted", "rot", "rotting", "mold", "mould", "mouldy", | |
| "moldy", "lesion", "lesions", "hole", "holes", "curl", "curling", "dead", | |
| "dying", "pale", "dark", "black", "white", "grey", "gray", "rust", "scab", | |
| "powdery", "fuzzy", "sticky", "soft", "droop", "drooping", "discolor", | |
| "discolour", "burn", "burnt", "crack", "cracking", "necrosis", "necrotic", | |
| "chloros", "streak", "streaking", "blotch", "blotchy", | |
| # Italian visual/physical descriptors | |
| "giall", "macchi", "avvizzit", "marciume", "muffa", "muffe", | |
| "buco", "buchi", "arricciament", "appassit", "bruciatur", | |
| "crepa", "chiazz", "necrosi", "imbruniment", "decoloraz", | |
| "molle", "scuro", "chiaro", "nero", "bianco", "grigio", | |
| "ruggine", "ticchiol", "polveroso", "vischios", "viscido", | |
| } | |
| msg_lower_symptoms = (request.message or "").lower() | |
| user_described_symptoms = any(w in msg_lower_symptoms for w in _SYMPTOM_WORDS) | |
| user_named_disease = bool( | |
| understanding.get("disease") or understanding.get("disease_common") | |
| ) | |
| # Also check conversation history β if a disease was established in a previous | |
| # turn, the gate must not fire. Covers follow-up questions like "can I combine | |
| # this product with something?" after disease was named two turns ago. | |
| disease_in_history = bool(last_ctx.get("disease")) | |
| if not disease_in_history and history: | |
| # Scan last 6 history messages for disease keywords as a fallback | |
| _DISEASE_WORDS = { | |
| "peronospora", "oidio", "botrytis", "alternaria", "ticchiolatura", | |
| "phytophthora", "plasmopara", "downy mildew", "powdery mildew", | |
| "scab", "grey mold", "gray mold", "blight", "rust", "muffa", | |
| "antracnosi", "cancro", "marciume", "fire blight", | |
| } | |
| recent = " ".join( | |
| m.get("content", "") for m in history[-6:] | |
| ).lower() | |
| disease_in_history = any(d in recent for d in _DISEASE_WORDS) | |
| if ( | |
| needs_advice | |
| and not request.image_base64 | |
| and not disease_detected | |
| and not user_named_disease | |
| and not user_described_symptoms | |
| and not disease_in_history | |
| ): | |
| missing_info_notes.append( | |
| "[AZIONE RICHIESTA] L'utente sta chiedendo di un problema alla pianta ma non ha descritto " | |
| "NESSUN sintomo specifico e non ha caricato NESSUNA immagine. " | |
| "DEVI chiedere cosa sta vedendo prima di fare qualsiasi diagnosi. " | |
| "Chiedi: 'Cosa stai vedendo esattamente? (es. foglie gialle, macchie marroni, appassimento, polvere bianca)' " | |
| "NON nominare nessuna malattia. NON diagnosticare." | |
| ) | |
| logger.debug("Bug#18 gate: no symptoms + no image + no disease β injecting ask-for-symptoms note") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Componi il messaggio completo: [DATA] β messaggio utente β blocchi contesto β IPM | |
| current_dt = datetime.now().strftime("%A, %Y-%m-%d %H:%M") | |
| dt_line = f"[DATA E ORA CORRENTE] {current_dt}" | |
| body = request.message | |
| if context_section: | |
| body += f"\n\n{context_section}" | |
| if ipm_section: | |
| body += f"\n\n{ipm_section}" | |
| for note in missing_info_notes: | |
| body += f"\n\n{note}" | |
| full_message = f"{dt_line}\n\n{body}" | |
| # LLM call | |
| response = await llm.chat( | |
| message=full_message, | |
| history=history, | |
| crop_type=effective_crop, | |
| bbch_stage=effective_bbch, | |
| available_products=available_products, | |
| ) | |
| # Store turn in session memory β effective values are fully resolved at this point | |
| memory_service.add_to_history( | |
| session_id=session_id, | |
| user_message=full_message, | |
| ai_response=response, | |
| metadata={ | |
| "crop_type": effective_crop, | |
| "bbch_stage": effective_bbch, | |
| "farming_type": effective_farming_type, # always persisted (incl. understander overrides) | |
| "disease": disease_detected, | |
| "location": effective_location, # persisted so we never ask twice | |
| "has_image": bool(request.image_base64), | |
| "sensor_status": context.get("sensor_context", {}).get("status"), | |
| }, | |
| ) | |
| # Update last_injected state so next turn knows what was already sent | |
| if not is_correction: | |
| injected_update = { | |
| # farming_type is always sent, always track its current value | |
| "farming_type": effective_farming_type, | |
| } | |
| if send_crop: | |
| injected_update["crop"] = effective_crop | |
| if send_bbch: | |
| injected_update["bbch"] = effective_bbch | |
| if send_weather: | |
| injected_update["weather_sent"] = True | |
| if send_sensors and sensor_data: | |
| injected_update["sensor_values"] = { | |
| "soil_moisture": sensor_data.soil_moisture, | |
| "soil_temperature": sensor_data.soil_temperature, | |
| "air_temperature": sensor_data.air_temperature, | |
| "humidity": sensor_data.humidity, | |
| } | |
| if send_products: | |
| injected_update["products_crop"] = effective_crop | |
| memory_service.update_last_injected(session_id, injected_update) | |
| return ChatResponse( | |
| response=response, | |
| context_used=list(context.keys()), | |
| weather_data=weather_data, | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Chat error: {e}", exc_info=True) | |
| raise HTTPException(status_code=500, detail="An error occurred processing your request.") | |
| async def clear_session(payload: dict): | |
| session_id = payload.get("session_id") | |
| if session_id: | |
| memory_service.clear_session(session_id) | |
| logger.info(f"Session cleared: {session_id}") | |
| return {"status": "ok"} | |