Spaces:
Sleeping
Sleeping
Commit ·
504a512
1
Parent(s): a7903f2
Add 5-key Gemini API fallback, conversation memory, historical trends, and zone recommendations
Browse files- app.py +240 -58
- context_aggregator.py +260 -1
- intent_classifier.py +87 -0
- prompts.py +452 -18
app.py
CHANGED
|
@@ -36,13 +36,35 @@ logging.basicConfig(
|
|
| 36 |
logger = logging.getLogger("ChatbotService")
|
| 37 |
|
| 38 |
# ============================================================================
|
| 39 |
-
# GEMINI SETUP -
|
| 40 |
# ============================================================================
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
# Try to discover available models at startup
|
| 44 |
-
def get_available_model():
|
| 45 |
-
"""Try to find an available Gemini model."""
|
| 46 |
models_to_try = [
|
| 47 |
"gemini-2.0-flash",
|
| 48 |
"gemini-1.5-flash",
|
|
@@ -51,7 +73,7 @@ def get_available_model():
|
|
| 51 |
"gemini-1.0-pro",
|
| 52 |
]
|
| 53 |
|
| 54 |
-
if not
|
| 55 |
return None, None
|
| 56 |
|
| 57 |
for model in models_to_try:
|
|
@@ -59,7 +81,7 @@ def get_available_model():
|
|
| 59 |
url = f"https://generativelanguage.googleapis.com/{version}/models/{model}:generateContent"
|
| 60 |
try:
|
| 61 |
resp = requests.post(
|
| 62 |
-
f"{url}?key={
|
| 63 |
json={"contents": [{"parts": [{"text": "test"}]}]},
|
| 64 |
timeout=10
|
| 65 |
)
|
|
@@ -71,81 +93,169 @@ def get_available_model():
|
|
| 71 |
|
| 72 |
return None, None
|
| 73 |
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
if GEMINI_URL:
|
| 76 |
logger.info(f"Using Gemini model: {GEMINI_MODEL}")
|
| 77 |
else:
|
| 78 |
-
GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-
|
| 79 |
-
logger.warning("Could not discover model, using default gemini-
|
| 80 |
|
| 81 |
-
if
|
| 82 |
-
logger.info("Gemini API
|
| 83 |
else:
|
| 84 |
-
logger.warning("GEMINI_API_KEY
|
| 85 |
|
| 86 |
# Supabase client
|
| 87 |
supabase = SupabaseClient()
|
| 88 |
|
| 89 |
# ============================================================================
|
| 90 |
-
# LLM CALLER
|
| 91 |
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
def call_gemini_api(prompt: str) -> str:
|
| 93 |
-
"""
|
| 94 |
-
|
| 95 |
-
return "Please configure GEMINI_API_KEY for real responses."
|
| 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 |
-
time.sleep(wait_time)
|
| 130 |
-
continue
|
| 131 |
-
return "I'm currently busy. Please try again in a moment."
|
| 132 |
-
else:
|
| 133 |
-
logger.error(f"Gemini API error: {response.status_code}")
|
| 134 |
-
return f"API error: {response.status_code}"
|
| 135 |
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
continue
|
| 140 |
-
return f"Error: {str(e)}"
|
| 141 |
|
| 142 |
-
return "
|
| 143 |
|
| 144 |
|
| 145 |
# Initialize reasoning engine
|
| 146 |
reasoning_engine = ReasoningEngine(llm_caller=call_gemini_api)
|
| 147 |
intent_classifier = IntentClassifier()
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
# ============================================================================
|
| 150 |
# FASTAPI
|
| 151 |
# ============================================================================
|
|
@@ -298,15 +408,77 @@ async def chat(request: ChatRequest):
|
|
| 298 |
user_profile = supabase.get_user_profile(request.user_id)
|
| 299 |
if user_profile:
|
| 300 |
context["user_profile"] = user_profile
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
|
| 302 |
# Detect intent first
|
| 303 |
intent = intent_classifier.classify(request.message)
|
| 304 |
logger.info(f"Intent: {intent['primary_intent']} ({intent['confidence']})")
|
| 305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
# Fetch satellite data for technical queries (vegetation, water, nutrient intents)
|
| 307 |
satellite_intents = [
|
| 308 |
"vegetation_health", "water_stress", "nutrient_status",
|
| 309 |
-
"pest_disease", "forecast_query", "zone_specific", "action_recommendation"
|
|
|
|
| 310 |
]
|
| 311 |
if (intent["primary_intent"] in satellite_intents and
|
| 312 |
context.get("coordinates") and
|
|
@@ -375,6 +547,16 @@ async def chat(request: ChatRequest):
|
|
| 375 |
context_used=list(context_priority.get("priority_1", []))
|
| 376 |
)
|
| 377 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
# Update session timestamp
|
| 379 |
supabase.update_session_timestamp(request.session_id)
|
| 380 |
|
|
|
|
| 36 |
logger = logging.getLogger("ChatbotService")
|
| 37 |
|
| 38 |
# ============================================================================
|
| 39 |
+
# GEMINI SETUP - Multi-API Key Fallback System
|
| 40 |
# ============================================================================
|
| 41 |
+
|
| 42 |
+
# Load multiple API keys (GEMINI_API_KEY_1 through GEMINI_API_KEY_5)
|
| 43 |
+
def load_gemini_api_keys() -> List[str]:
|
| 44 |
+
"""Load all available Gemini API keys from environment."""
|
| 45 |
+
keys = []
|
| 46 |
+
|
| 47 |
+
# Primary key
|
| 48 |
+
primary = os.environ.get("GEMINI_API_KEY")
|
| 49 |
+
if primary:
|
| 50 |
+
keys.append(primary)
|
| 51 |
+
|
| 52 |
+
# Fallback keys 1-5
|
| 53 |
+
for i in range(1, 6):
|
| 54 |
+
key = os.environ.get(f"GEMINI_API_KEY_{i}")
|
| 55 |
+
if key and key not in keys:
|
| 56 |
+
keys.append(key)
|
| 57 |
+
|
| 58 |
+
return keys
|
| 59 |
+
|
| 60 |
+
GEMINI_API_KEYS = load_gemini_api_keys()
|
| 61 |
+
current_key_index = 0 # Track which key is currently in use
|
| 62 |
+
|
| 63 |
+
logger.info(f"Loaded {len(GEMINI_API_KEYS)} Gemini API key(s)")
|
| 64 |
|
| 65 |
# Try to discover available models at startup
|
| 66 |
+
def get_available_model(api_key: str):
|
| 67 |
+
"""Try to find an available Gemini model with given API key."""
|
| 68 |
models_to_try = [
|
| 69 |
"gemini-2.0-flash",
|
| 70 |
"gemini-1.5-flash",
|
|
|
|
| 73 |
"gemini-1.0-pro",
|
| 74 |
]
|
| 75 |
|
| 76 |
+
if not api_key:
|
| 77 |
return None, None
|
| 78 |
|
| 79 |
for model in models_to_try:
|
|
|
|
| 81 |
url = f"https://generativelanguage.googleapis.com/{version}/models/{model}:generateContent"
|
| 82 |
try:
|
| 83 |
resp = requests.post(
|
| 84 |
+
f"{url}?key={api_key}",
|
| 85 |
json={"contents": [{"parts": [{"text": "test"}]}]},
|
| 86 |
timeout=10
|
| 87 |
)
|
|
|
|
| 93 |
|
| 94 |
return None, None
|
| 95 |
|
| 96 |
+
# Initialize with first available key
|
| 97 |
+
GEMINI_URL, GEMINI_MODEL = None, None
|
| 98 |
+
if GEMINI_API_KEYS:
|
| 99 |
+
GEMINI_URL, GEMINI_MODEL = get_available_model(GEMINI_API_KEYS[0])
|
| 100 |
+
|
| 101 |
if GEMINI_URL:
|
| 102 |
logger.info(f"Using Gemini model: {GEMINI_MODEL}")
|
| 103 |
else:
|
| 104 |
+
GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"
|
| 105 |
+
logger.warning("Could not discover model, using default gemini-2.0-flash")
|
| 106 |
|
| 107 |
+
if GEMINI_API_KEYS:
|
| 108 |
+
logger.info(f"Gemini API configured with {len(GEMINI_API_KEYS)} fallback key(s)")
|
| 109 |
else:
|
| 110 |
+
logger.warning("No GEMINI_API_KEY set - chatbot will return mock responses")
|
| 111 |
|
| 112 |
# Supabase client
|
| 113 |
supabase = SupabaseClient()
|
| 114 |
|
| 115 |
# ============================================================================
|
| 116 |
+
# LLM CALLER WITH FALLBACK
|
| 117 |
# ============================================================================
|
| 118 |
+
def get_next_api_key() -> Optional[str]:
|
| 119 |
+
"""Rotate to the next available API key."""
|
| 120 |
+
global current_key_index
|
| 121 |
+
if not GEMINI_API_KEYS:
|
| 122 |
+
return None
|
| 123 |
+
current_key_index = (current_key_index + 1) % len(GEMINI_API_KEYS)
|
| 124 |
+
logger.info(f"Rotated to API key {current_key_index + 1}/{len(GEMINI_API_KEYS)}")
|
| 125 |
+
return GEMINI_API_KEYS[current_key_index]
|
| 126 |
+
|
| 127 |
+
|
| 128 |
def call_gemini_api(prompt: str) -> str:
|
| 129 |
+
"""
|
| 130 |
+
Call Gemini API with fallback across multiple API keys.
|
|
|
|
| 131 |
|
| 132 |
+
Automatically rotates to next key on:
|
| 133 |
+
- 429 (rate limit)
|
| 134 |
+
- 403 (quota exceeded)
|
| 135 |
+
- 500+ (server errors)
|
| 136 |
+
"""
|
| 137 |
+
global current_key_index
|
| 138 |
|
| 139 |
+
if not GEMINI_API_KEYS:
|
| 140 |
+
return "Please configure GEMINI_API_KEY for real responses."
|
| 141 |
|
| 142 |
+
max_retries_per_key = 2
|
| 143 |
+
keys_tried = 0
|
| 144 |
+
|
| 145 |
+
while keys_tried < len(GEMINI_API_KEYS):
|
| 146 |
+
current_key = GEMINI_API_KEYS[current_key_index]
|
| 147 |
+
url = f"{GEMINI_URL}?key={current_key}"
|
| 148 |
+
|
| 149 |
+
for attempt in range(max_retries_per_key):
|
| 150 |
+
try:
|
| 151 |
+
response = requests.post(
|
| 152 |
+
url,
|
| 153 |
+
headers={"Content-Type": "application/json"},
|
| 154 |
+
json={
|
| 155 |
+
"contents": [{"parts": [{"text": prompt}]}],
|
| 156 |
+
"generationConfig": {
|
| 157 |
+
"temperature": 0.7,
|
| 158 |
+
"maxOutputTokens": 2048,
|
| 159 |
+
}
|
| 160 |
+
},
|
| 161 |
+
timeout=60
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
if response.status_code == 200:
|
| 165 |
+
data = response.json()
|
| 166 |
+
if "candidates" in data and len(data["candidates"]) > 0:
|
| 167 |
+
return data["candidates"][0]["content"]["parts"][0]["text"]
|
| 168 |
+
return "No response generated."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
+
elif response.status_code in [429, 403, 500, 502, 503]:
|
| 171 |
+
# Rate limit, quota exceeded, or server error - try next key
|
| 172 |
+
logger.warning(f"API key {current_key_index + 1} got {response.status_code}, rotating...")
|
| 173 |
+
get_next_api_key()
|
| 174 |
+
keys_tried += 1
|
| 175 |
+
break # Exit retry loop, try next key
|
| 176 |
+
|
| 177 |
+
else:
|
| 178 |
+
logger.error(f"Gemini API error: {response.status_code}")
|
| 179 |
+
return f"API error: {response.status_code}"
|
| 180 |
+
|
| 181 |
+
except requests.exceptions.Timeout:
|
| 182 |
+
logger.warning(f"Timeout on key {current_key_index + 1}, attempt {attempt + 1}")
|
| 183 |
+
if attempt == max_retries_per_key - 1:
|
| 184 |
+
get_next_api_key()
|
| 185 |
+
keys_tried += 1
|
| 186 |
+
continue
|
| 187 |
+
|
| 188 |
+
except Exception as e:
|
| 189 |
+
logger.error(f"Gemini request error: {e}")
|
| 190 |
+
if attempt == max_retries_per_key - 1:
|
| 191 |
+
get_next_api_key()
|
| 192 |
+
keys_tried += 1
|
| 193 |
continue
|
|
|
|
| 194 |
|
| 195 |
+
return "All API keys exhausted. Please try again later."
|
| 196 |
|
| 197 |
|
| 198 |
# Initialize reasoning engine
|
| 199 |
reasoning_engine = ReasoningEngine(llm_caller=call_gemini_api)
|
| 200 |
intent_classifier = IntentClassifier()
|
| 201 |
|
| 202 |
+
# Session history for conversation memory (follow-up awareness)
|
| 203 |
+
# In production, this should be stored in Supabase/Redis, but for now use in-memory
|
| 204 |
+
session_history: Dict[str, List[Dict]] = {}
|
| 205 |
+
MAX_HISTORY_TURNS = 5
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def get_session_history(session_id: str) -> List[Dict]:
|
| 209 |
+
"""Get recent conversation history for a session."""
|
| 210 |
+
return session_history.get(session_id, [])[-MAX_HISTORY_TURNS:]
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def add_to_session_history(session_id: str, role: str, content: str,
|
| 214 |
+
intent: str = None, diagnosis: str = None):
|
| 215 |
+
"""Add a turn to session history for follow-up awareness."""
|
| 216 |
+
if session_id not in session_history:
|
| 217 |
+
session_history[session_id] = []
|
| 218 |
+
|
| 219 |
+
turn = {
|
| 220 |
+
"role": role,
|
| 221 |
+
"content": content[:500], # Truncate long content
|
| 222 |
+
"intent": intent,
|
| 223 |
+
"diagnosis": diagnosis[:200] if diagnosis else None
|
| 224 |
+
}
|
| 225 |
+
session_history[session_id].append(turn)
|
| 226 |
+
|
| 227 |
+
# Keep only last N turns
|
| 228 |
+
if len(session_history[session_id]) > MAX_HISTORY_TURNS * 2:
|
| 229 |
+
session_history[session_id] = session_history[session_id][-MAX_HISTORY_TURNS:]
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def build_field_coordinates(field_data: Dict) -> Optional[Dict]:
|
| 233 |
+
"""Build coordinates dict from field data with lat/lon corners."""
|
| 234 |
+
if not field_data:
|
| 235 |
+
return None
|
| 236 |
+
|
| 237 |
+
lats = []
|
| 238 |
+
lons = []
|
| 239 |
+
for i in range(1, 5):
|
| 240 |
+
lat = field_data.get(f"lat{i}")
|
| 241 |
+
lon = field_data.get(f"lon{i}")
|
| 242 |
+
if lat is not None and lon is not None:
|
| 243 |
+
lats.append(float(lat))
|
| 244 |
+
lons.append(float(lon))
|
| 245 |
+
|
| 246 |
+
if not lats or not lons:
|
| 247 |
+
return None
|
| 248 |
+
|
| 249 |
+
center_lat = sum(lats) / len(lats)
|
| 250 |
+
center_lon = sum(lons) / len(lons)
|
| 251 |
+
|
| 252 |
+
return {
|
| 253 |
+
"center_lat": round(center_lat, 6),
|
| 254 |
+
"center_lon": round(center_lon, 6),
|
| 255 |
+
"bbox": [min(lons), min(lats), max(lons), max(lats)]
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
|
| 259 |
# ============================================================================
|
| 260 |
# FASTAPI
|
| 261 |
# ============================================================================
|
|
|
|
| 408 |
user_profile = supabase.get_user_profile(request.user_id)
|
| 409 |
if user_profile:
|
| 410 |
context["user_profile"] = user_profile
|
| 411 |
+
|
| 412 |
+
# Get all user fields for comparison detection
|
| 413 |
+
all_user_fields = supabase.get_user_fields(request.user_id)
|
| 414 |
+
field_names = [f.get("name", "") for f in all_user_fields if f.get("name")]
|
| 415 |
+
else:
|
| 416 |
+
user_profile = None
|
| 417 |
+
all_user_fields = []
|
| 418 |
+
field_names = []
|
| 419 |
+
|
| 420 |
+
# Create persona from user profile for tailored responses
|
| 421 |
+
from prompts import create_user_persona, format_weather_context, format_zone_context, format_trend_context, format_conversation_history
|
| 422 |
+
persona = create_user_persona(user_profile, all_user_fields)
|
| 423 |
+
context["persona"] = persona
|
| 424 |
+
logger.info(f"User persona: {persona.get('type')}")
|
| 425 |
+
|
| 426 |
+
# Get conversation history for follow-up awareness
|
| 427 |
+
conv_history = get_session_history(request.session_id)
|
| 428 |
+
context["conversation_history"] = conv_history
|
| 429 |
+
if conv_history:
|
| 430 |
+
logger.info(f"Loaded {len(conv_history)} previous turns for context")
|
| 431 |
|
| 432 |
# Detect intent first
|
| 433 |
intent = intent_classifier.classify(request.message)
|
| 434 |
logger.info(f"Intent: {intent['primary_intent']} ({intent['confidence']})")
|
| 435 |
|
| 436 |
+
# Check if this is a field comparison query
|
| 437 |
+
is_comparison_query = intent_classifier.is_field_comparison_query(
|
| 438 |
+
request.message, field_names
|
| 439 |
+
)
|
| 440 |
+
mentioned_fields = intent_classifier.extract_field_names(
|
| 441 |
+
request.message, field_names
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
if is_comparison_query and len(mentioned_fields) >= 1:
|
| 445 |
+
logger.info(f"Field comparison detected - fields: {mentioned_fields}")
|
| 446 |
+
context["comparison_requested"] = True
|
| 447 |
+
context["mentioned_fields"] = mentioned_fields
|
| 448 |
+
|
| 449 |
+
# Fetch satellite data for each mentioned field
|
| 450 |
+
comparison_contexts = {}
|
| 451 |
+
for field_name in mentioned_fields:
|
| 452 |
+
field_data = next(
|
| 453 |
+
(f for f in all_user_fields if f.get("name") == field_name),
|
| 454 |
+
None
|
| 455 |
+
)
|
| 456 |
+
if field_data:
|
| 457 |
+
# Build coordinates from field data
|
| 458 |
+
field_coords = build_field_coordinates(field_data)
|
| 459 |
+
if field_coords:
|
| 460 |
+
try:
|
| 461 |
+
field_satellite = fetch_field_context(
|
| 462 |
+
coordinates=field_coords,
|
| 463 |
+
crop_type=field_data.get("crop_type", "Wheat"),
|
| 464 |
+
area_acres=field_data.get("area_acres", 1.0),
|
| 465 |
+
fetch_satellite=True
|
| 466 |
+
)
|
| 467 |
+
comparison_contexts[field_name] = {
|
| 468 |
+
"field_info": field_data,
|
| 469 |
+
"satellite_data": field_satellite
|
| 470 |
+
}
|
| 471 |
+
logger.info(f"Fetched data for comparison field: {field_name}")
|
| 472 |
+
except Exception as e:
|
| 473 |
+
logger.warning(f"Could not fetch data for {field_name}: {e}")
|
| 474 |
+
|
| 475 |
+
context["comparison_fields"] = comparison_contexts
|
| 476 |
+
|
| 477 |
# Fetch satellite data for technical queries (vegetation, water, nutrient intents)
|
| 478 |
satellite_intents = [
|
| 479 |
"vegetation_health", "water_stress", "nutrient_status",
|
| 480 |
+
"pest_disease", "forecast_query", "zone_specific", "action_recommendation",
|
| 481 |
+
"field_comparison" # Also fetch for comparison queries
|
| 482 |
]
|
| 483 |
if (intent["primary_intent"] in satellite_intents and
|
| 484 |
context.get("coordinates") and
|
|
|
|
| 547 |
context_used=list(context_priority.get("priority_1", []))
|
| 548 |
)
|
| 549 |
|
| 550 |
+
# Save to session history for follow-up awareness
|
| 551 |
+
add_to_session_history(
|
| 552 |
+
request.session_id, "user", request.message,
|
| 553 |
+
intent=intent["primary_intent"]
|
| 554 |
+
)
|
| 555 |
+
add_to_session_history(
|
| 556 |
+
request.session_id, "assistant", response_text,
|
| 557 |
+
diagnosis=diagnosis
|
| 558 |
+
)
|
| 559 |
+
|
| 560 |
# Update session timestamp
|
| 561 |
supabase.update_session_timestamp(request.session_id)
|
| 562 |
|
context_aggregator.py
CHANGED
|
@@ -96,7 +96,30 @@ class ContextAggregator:
|
|
| 96 |
context["farmer_profile"] = farmer_context.get("profile", {})
|
| 97 |
context["farmer_actions"] = farmer_context.get("actions", {})
|
| 98 |
|
| 99 |
-
# 4.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
if sar_data:
|
| 101 |
context["previous_analysis"] = {
|
| 102 |
"date": datetime.now().isoformat(),
|
|
@@ -401,6 +424,242 @@ class ContextAggregator:
|
|
| 401 |
}
|
| 402 |
}
|
| 403 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
# =========================================================================
|
| 405 |
# PRIORITY-BASED CONTEXT FORMATTING
|
| 406 |
# =========================================================================
|
|
|
|
| 96 |
context["farmer_profile"] = farmer_context.get("profile", {})
|
| 97 |
context["farmer_actions"] = farmer_context.get("actions", {})
|
| 98 |
|
| 99 |
+
# 4. Fetch comprehensive weather from Open-Meteo (free API)
|
| 100 |
+
weather = self._fetch_weather_openmeteo(center_lat, center_lon)
|
| 101 |
+
if weather:
|
| 102 |
+
context["weather"] = weather
|
| 103 |
+
logger.info("Weather data fetched from Open-Meteo")
|
| 104 |
+
|
| 105 |
+
# 5. Compute historical trends from temporal data
|
| 106 |
+
if context.get("temporal_trends") or context.get("vegetation_indices"):
|
| 107 |
+
trends = self._compute_historical_trends(
|
| 108 |
+
context.get("vegetation_indices", {}),
|
| 109 |
+
context.get("temporal_trends", {})
|
| 110 |
+
)
|
| 111 |
+
context["historical_trends"] = trends
|
| 112 |
+
logger.info(f"Historical trends computed: {trends.get('summary', 'N/A')}")
|
| 113 |
+
|
| 114 |
+
# 6. Identify priority zones from patches
|
| 115 |
+
patches = context.get("patches", []) + context.get("anomalies", {}).get("high_priority", [])
|
| 116 |
+
if patches:
|
| 117 |
+
zone_data = self._identify_priority_zones(patches)
|
| 118 |
+
context["zone_analysis"] = zone_data
|
| 119 |
+
if zone_data.get("most_critical"):
|
| 120 |
+
logger.info(f"Priority zone: {zone_data['most_critical'].get('location', 'N/A')}")
|
| 121 |
+
|
| 122 |
+
# 7. Add previous analysis if available
|
| 123 |
if sar_data:
|
| 124 |
context["previous_analysis"] = {
|
| 125 |
"date": datetime.now().isoformat(),
|
|
|
|
| 424 |
}
|
| 425 |
}
|
| 426 |
|
| 427 |
+
def _fetch_weather_openmeteo(self, center_lat: float, center_lon: float) -> Dict:
|
| 428 |
+
"""
|
| 429 |
+
Fetch comprehensive weather data from Open-Meteo (free API).
|
| 430 |
+
|
| 431 |
+
Returns 7-day historical + 7-day forecast with rolling statistics
|
| 432 |
+
and actionable stress indicators.
|
| 433 |
+
"""
|
| 434 |
+
try:
|
| 435 |
+
response = requests.get(
|
| 436 |
+
"https://api.open-meteo.com/v1/forecast",
|
| 437 |
+
params={
|
| 438 |
+
"latitude": center_lat,
|
| 439 |
+
"longitude": center_lon,
|
| 440 |
+
"daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,"
|
| 441 |
+
"relative_humidity_2m_mean,wind_speed_10m_max,"
|
| 442 |
+
"et0_fao_evapotranspiration",
|
| 443 |
+
"past_days": 7,
|
| 444 |
+
"forecast_days": 7,
|
| 445 |
+
"timezone": "Asia/Kolkata"
|
| 446 |
+
},
|
| 447 |
+
timeout=30
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
if response.status_code == 200:
|
| 451 |
+
return self._structure_weather_response(response.json())
|
| 452 |
+
else:
|
| 453 |
+
logger.warning(f"Open-Meteo returned status {response.status_code}")
|
| 454 |
+
return {}
|
| 455 |
+
|
| 456 |
+
except Exception as e:
|
| 457 |
+
logger.warning(f"Weather fetch error: {e}")
|
| 458 |
+
return {}
|
| 459 |
+
|
| 460 |
+
def _structure_weather_response(self, data: Dict) -> Dict:
|
| 461 |
+
"""Structure weather API response into historical + forecast + stats."""
|
| 462 |
+
daily = data.get("daily", {})
|
| 463 |
+
dates = daily.get("time", [])
|
| 464 |
+
|
| 465 |
+
if not dates:
|
| 466 |
+
return {}
|
| 467 |
+
|
| 468 |
+
# Split into historical (first 7) and forecast (last 7)
|
| 469 |
+
historical = []
|
| 470 |
+
forecast = []
|
| 471 |
+
today_idx = min(7, len(dates))
|
| 472 |
+
|
| 473 |
+
for i, date in enumerate(dates):
|
| 474 |
+
entry = {
|
| 475 |
+
"date": date,
|
| 476 |
+
"temp_max": daily.get("temperature_2m_max", [None] * len(dates))[i],
|
| 477 |
+
"temp_min": daily.get("temperature_2m_min", [None] * len(dates))[i],
|
| 478 |
+
"precipitation": daily.get("precipitation_sum", [None] * len(dates))[i],
|
| 479 |
+
"humidity": daily.get("relative_humidity_2m_mean", [None] * len(dates))[i],
|
| 480 |
+
"wind": daily.get("wind_speed_10m_max", [None] * len(dates))[i],
|
| 481 |
+
"et0": daily.get("et0_fao_evapotranspiration", [None] * len(dates))[i]
|
| 482 |
+
}
|
| 483 |
+
if i < today_idx:
|
| 484 |
+
historical.append(entry)
|
| 485 |
+
else:
|
| 486 |
+
forecast.append(entry)
|
| 487 |
+
|
| 488 |
+
return {
|
| 489 |
+
"historical_7d": historical,
|
| 490 |
+
"forecast_7d": forecast,
|
| 491 |
+
"rolling_stats": self._compute_weather_rolling_stats(historical),
|
| 492 |
+
"stress_indicators": self._detect_weather_stress_indicators(historical, forecast)
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
def _compute_weather_rolling_stats(self, data: List[Dict]) -> Dict:
|
| 496 |
+
"""Compute rolling weather statistics for the past 7 days."""
|
| 497 |
+
temps = [d["temp_max"] for d in data if d.get("temp_max") is not None]
|
| 498 |
+
precip = [d["precipitation"] for d in data if d.get("precipitation") is not None]
|
| 499 |
+
humidity = [d["humidity"] for d in data if d.get("humidity") is not None]
|
| 500 |
+
et0 = [d["et0"] for d in data if d.get("et0") is not None]
|
| 501 |
+
|
| 502 |
+
return {
|
| 503 |
+
"avg_temp_7d": round(sum(temps) / len(temps), 1) if temps else None,
|
| 504 |
+
"max_temp_7d": max(temps) if temps else None,
|
| 505 |
+
"min_temp_7d": min(temps) if temps else None,
|
| 506 |
+
"total_precip_7d": round(sum(precip), 1) if precip else 0,
|
| 507 |
+
"dry_days_count": sum(1 for p in precip if p == 0),
|
| 508 |
+
"heat_stress_days": sum(1 for t in temps if t > 35),
|
| 509 |
+
"avg_humidity_7d": round(sum(humidity) / len(humidity), 1) if humidity else None,
|
| 510 |
+
"total_et0_7d": round(sum(et0), 1) if et0 else None
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
def _detect_weather_stress_indicators(self, hist: List[Dict], fore: List[Dict]) -> Dict:
|
| 514 |
+
"""Detect actionable weather stress indicators."""
|
| 515 |
+
h_temps = [d["temp_max"] for d in hist if d.get("temp_max")]
|
| 516 |
+
f_temps = [d["temp_max"] for d in fore if d.get("temp_max")]
|
| 517 |
+
h_precip = sum((d.get("precipitation") or 0) for d in hist)
|
| 518 |
+
f_precip = sum((d.get("precipitation") or 0) for d in fore)
|
| 519 |
+
|
| 520 |
+
# Check if recent 3 days or upcoming 3 days have heat stress
|
| 521 |
+
recent_3_heat = any(t > 38 for t in h_temps[-3:]) if len(h_temps) >= 3 else False
|
| 522 |
+
upcoming_3_heat = any(t > 38 for t in f_temps[:3]) if len(f_temps) >= 3 else False
|
| 523 |
+
|
| 524 |
+
return {
|
| 525 |
+
"current_heat_stress": recent_3_heat,
|
| 526 |
+
"predicted_heat_stress": upcoming_3_heat,
|
| 527 |
+
"drought_risk": h_precip < 10 and f_precip < 5,
|
| 528 |
+
"flood_risk": f_precip > 100,
|
| 529 |
+
"suitable_for_irrigation": f_precip < 5 and max(f_temps[:3] or [30]) < 40,
|
| 530 |
+
"suitable_for_spraying": f_precip < 2 and max(f_temps[:2] or [30]) < 35
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
def _compute_historical_trends(self, current_indices: Dict,
|
| 534 |
+
temporal_trends: Dict) -> Dict:
|
| 535 |
+
"""
|
| 536 |
+
Compute historical trend metrics comparing current vs past data.
|
| 537 |
+
|
| 538 |
+
Returns trend direction (improving/declining/stable) and change percentages.
|
| 539 |
+
"""
|
| 540 |
+
trends = {"changes": {}}
|
| 541 |
+
|
| 542 |
+
# Try to compute NDVI trends
|
| 543 |
+
ndvi_history = temporal_trends.get("ndvi", [])
|
| 544 |
+
current_ndvi = current_indices.get("ndvi")
|
| 545 |
+
|
| 546 |
+
if current_ndvi and len(ndvi_history) >= 2:
|
| 547 |
+
# Get value from 7 days ago if available
|
| 548 |
+
week_ago_idx = min(6, len(ndvi_history) - 1)
|
| 549 |
+
week_ago = ndvi_history[week_ago_idx].get("value", current_ndvi)
|
| 550 |
+
|
| 551 |
+
change_7d = current_ndvi - week_ago
|
| 552 |
+
trends["changes"]["ndvi_change_7d"] = round(change_7d, 4)
|
| 553 |
+
trends["changes"]["ndvi_pct_change_7d"] = round((change_7d / week_ago * 100) if week_ago else 0, 1)
|
| 554 |
+
|
| 555 |
+
# Classify trend
|
| 556 |
+
if change_7d > 0.03:
|
| 557 |
+
trends["ndvi_trend"] = "improving"
|
| 558 |
+
elif change_7d < -0.03:
|
| 559 |
+
trends["ndvi_trend"] = "declining"
|
| 560 |
+
else:
|
| 561 |
+
trends["ndvi_trend"] = "stable"
|
| 562 |
+
|
| 563 |
+
# Try to compute SMI trends (soil moisture)
|
| 564 |
+
smi_history = temporal_trends.get("smi", [])
|
| 565 |
+
current_smi = current_indices.get("smi")
|
| 566 |
+
|
| 567 |
+
if current_smi and len(smi_history) >= 2:
|
| 568 |
+
week_ago_smi = smi_history[min(6, len(smi_history) - 1)].get("value", current_smi)
|
| 569 |
+
change_smi = current_smi - week_ago_smi
|
| 570 |
+
trends["changes"]["smi_change_7d"] = round(change_smi, 3)
|
| 571 |
+
|
| 572 |
+
if change_smi > 0.05:
|
| 573 |
+
trends["smi_trend"] = "wetter"
|
| 574 |
+
elif change_smi < -0.05:
|
| 575 |
+
trends["smi_trend"] = "drier"
|
| 576 |
+
else:
|
| 577 |
+
trends["smi_trend"] = "stable"
|
| 578 |
+
|
| 579 |
+
# Generate summary
|
| 580 |
+
summaries = []
|
| 581 |
+
if trends.get("ndvi_trend"):
|
| 582 |
+
pct = abs(trends["changes"].get("ndvi_pct_change_7d", 0))
|
| 583 |
+
summaries.append(f"Vegetation {trends['ndvi_trend']} ({pct:.0f}% change)")
|
| 584 |
+
if trends.get("smi_trend"):
|
| 585 |
+
summaries.append(f"Soil {trends['smi_trend']}")
|
| 586 |
+
|
| 587 |
+
trends["summary"] = "; ".join(summaries) if summaries else "Insufficient historical data"
|
| 588 |
+
|
| 589 |
+
return trends
|
| 590 |
+
|
| 591 |
+
def _identify_priority_zones(self, patches: List[Dict]) -> Dict:
|
| 592 |
+
"""
|
| 593 |
+
Identify zones/patches that need the most attention based on stress scores.
|
| 594 |
+
|
| 595 |
+
Returns top 3 priority zones with location and issue details.
|
| 596 |
+
"""
|
| 597 |
+
if not patches:
|
| 598 |
+
return {"priority_zones": [], "most_critical": None, "total_affected_area_pct": 0}
|
| 599 |
+
|
| 600 |
+
# Sort patches by stress score (highest first)
|
| 601 |
+
stressed = sorted(
|
| 602 |
+
[p for p in patches if p.get("stress_score", 0) > 0.3],
|
| 603 |
+
key=lambda p: p.get("stress_score", 0),
|
| 604 |
+
reverse=True
|
| 605 |
+
)
|
| 606 |
+
|
| 607 |
+
priority_zones = []
|
| 608 |
+
for patch in stressed[:3]: # Top 3 stressed zones
|
| 609 |
+
# Determine location description
|
| 610 |
+
location = patch.get("location_description")
|
| 611 |
+
if not location:
|
| 612 |
+
# Try to infer from coordinates or patch_id
|
| 613 |
+
patch_id = patch.get("patch_id", patch.get("id", "Unknown"))
|
| 614 |
+
location = self._infer_location_from_patch(patch_id, patch)
|
| 615 |
+
|
| 616 |
+
priority_zones.append({
|
| 617 |
+
"zone_id": patch.get("patch_id", patch.get("id")),
|
| 618 |
+
"location": location,
|
| 619 |
+
"stress_score": round(patch.get("stress_score", 0), 2),
|
| 620 |
+
"primary_issue": patch.get("predicted_issue", patch.get("issue", "stress detected")),
|
| 621 |
+
"area_percentage": round(patch.get("area_pct", patch.get("area_percentage", 0)), 1),
|
| 622 |
+
"recommended_action": patch.get("recommended_action", "monitor closely")
|
| 623 |
+
})
|
| 624 |
+
|
| 625 |
+
total_affected = sum(z["area_percentage"] for z in priority_zones)
|
| 626 |
+
|
| 627 |
+
return {
|
| 628 |
+
"priority_zones": priority_zones,
|
| 629 |
+
"most_critical": priority_zones[0] if priority_zones else None,
|
| 630 |
+
"total_affected_area_pct": round(total_affected, 1),
|
| 631 |
+
"zones_count": len(priority_zones)
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
def _infer_location_from_patch(self, patch_id: str, patch: Dict) -> str:
|
| 635 |
+
"""Infer human-readable location from patch data."""
|
| 636 |
+
# Common quadrant mappings
|
| 637 |
+
quadrant_map = {
|
| 638 |
+
"NE": "Northeast corner",
|
| 639 |
+
"NW": "Northwest corner",
|
| 640 |
+
"SE": "Southeast corner",
|
| 641 |
+
"SW": "Southwest corner",
|
| 642 |
+
"N": "Northern section",
|
| 643 |
+
"S": "Southern section",
|
| 644 |
+
"E": "Eastern section",
|
| 645 |
+
"W": "Western section",
|
| 646 |
+
"C": "Central area"
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
# Try to extract quadrant from patch_id
|
| 650 |
+
patch_str = str(patch_id).upper()
|
| 651 |
+
for abbr, name in quadrant_map.items():
|
| 652 |
+
if abbr in patch_str:
|
| 653 |
+
return name
|
| 654 |
+
|
| 655 |
+
# Fall back to numbered zone
|
| 656 |
+
if isinstance(patch_id, int) or patch_str.isdigit():
|
| 657 |
+
return f"Zone {patch_id}"
|
| 658 |
+
|
| 659 |
+
return f"Zone {patch_str}"
|
| 660 |
+
|
| 661 |
+
|
| 662 |
+
|
| 663 |
# =========================================================================
|
| 664 |
# PRIORITY-BASED CONTEXT FORMATTING
|
| 665 |
# =========================================================================
|
intent_classifier.py
CHANGED
|
@@ -126,6 +126,21 @@ INTENT_PATTERNS = {
|
|
| 126 |
"priority": 8
|
| 127 |
},
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
"general_query": {
|
| 130 |
"keywords": [
|
| 131 |
"what", "how", "why", "tell", "about", "explain", "hello", "hi",
|
|
@@ -267,6 +282,78 @@ class IntentClassifier:
|
|
| 267 |
|
| 268 |
return sub_intents if sub_intents else ["general"]
|
| 269 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
def _default_response(self) -> Dict:
|
| 271 |
"""Return default classification for unrecognized queries."""
|
| 272 |
return {
|
|
|
|
| 126 |
"priority": 8
|
| 127 |
},
|
| 128 |
|
| 129 |
+
"field_comparison": {
|
| 130 |
+
"keywords": [
|
| 131 |
+
"compare", "comparison", "versus", " vs ", "other field", "another field",
|
| 132 |
+
"between fields", "both fields", "which field", "differ", "different field",
|
| 133 |
+
"my other", "second field", "first field"
|
| 134 |
+
],
|
| 135 |
+
"phrases": [
|
| 136 |
+
"compare with", "compared to my", "how does my * compare", "between my fields",
|
| 137 |
+
"which field is better", "difference between", "compare * and *",
|
| 138 |
+
"other farm", "other farmland", "another farm"
|
| 139 |
+
],
|
| 140 |
+
"sub_intents": ["multi_field_analysis", "field_ranking", "relative_health"],
|
| 141 |
+
"priority": 2
|
| 142 |
+
},
|
| 143 |
+
|
| 144 |
"general_query": {
|
| 145 |
"keywords": [
|
| 146 |
"what", "how", "why", "tell", "about", "explain", "hello", "hi",
|
|
|
|
| 282 |
|
| 283 |
return sub_intents if sub_intents else ["general"]
|
| 284 |
|
| 285 |
+
def extract_field_names(self, query: str, available_fields: List[str]) -> List[str]:
|
| 286 |
+
"""
|
| 287 |
+
Extract field names mentioned in the query.
|
| 288 |
+
|
| 289 |
+
This enables dynamic field comparison - when a user mentions
|
| 290 |
+
another field name, we can fetch data for that field and compare.
|
| 291 |
+
|
| 292 |
+
Args:
|
| 293 |
+
query: User's message
|
| 294 |
+
available_fields: List of user's registered field names
|
| 295 |
+
|
| 296 |
+
Returns:
|
| 297 |
+
List of detected field names (in order of appearance)
|
| 298 |
+
"""
|
| 299 |
+
if not available_fields:
|
| 300 |
+
return []
|
| 301 |
+
|
| 302 |
+
query_lower = query.lower()
|
| 303 |
+
detected = []
|
| 304 |
+
|
| 305 |
+
# Check each registered field name
|
| 306 |
+
for field_name in available_fields:
|
| 307 |
+
if not field_name:
|
| 308 |
+
continue
|
| 309 |
+
# Check if field name appears in query (case-insensitive)
|
| 310 |
+
if field_name.lower() in query_lower:
|
| 311 |
+
detected.append(field_name)
|
| 312 |
+
|
| 313 |
+
# Also check for ordinal patterns like "field 1", "field 2", "first field"
|
| 314 |
+
ordinal_map = {
|
| 315 |
+
"first": 0, "1st": 0, "field 1": 0, "field one": 0,
|
| 316 |
+
"second": 1, "2nd": 1, "field 2": 1, "field two": 1,
|
| 317 |
+
"third": 2, "3rd": 2, "field 3": 2, "field three": 2
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
for pattern, idx in ordinal_map.items():
|
| 321 |
+
if pattern in query_lower and idx < len(available_fields):
|
| 322 |
+
field = available_fields[idx]
|
| 323 |
+
if field not in detected:
|
| 324 |
+
detected.append(field)
|
| 325 |
+
|
| 326 |
+
return detected
|
| 327 |
+
|
| 328 |
+
def is_field_comparison_query(self, query: str, available_fields: List[str]) -> bool:
|
| 329 |
+
"""
|
| 330 |
+
Check if the query is asking to compare multiple fields.
|
| 331 |
+
|
| 332 |
+
Returns True if:
|
| 333 |
+
1. Multiple field names are mentioned, OR
|
| 334 |
+
2. Comparison keywords + at least one field name
|
| 335 |
+
"""
|
| 336 |
+
intent = self.classify(query)
|
| 337 |
+
mentioned_fields = self.extract_field_names(query, available_fields)
|
| 338 |
+
|
| 339 |
+
# Multiple fields mentioned
|
| 340 |
+
if len(mentioned_fields) >= 2:
|
| 341 |
+
return True
|
| 342 |
+
|
| 343 |
+
# Comparison intent + at least one field mentioned
|
| 344 |
+
if intent["primary_intent"] in ["field_comparison", "comparison"]:
|
| 345 |
+
if len(mentioned_fields) >= 1:
|
| 346 |
+
return True
|
| 347 |
+
# Check for phrases like "other field", "another farm"
|
| 348 |
+
comparison_phrases = ["other field", "another field", "other farm", "another farm",
|
| 349 |
+
"my other", "between fields"]
|
| 350 |
+
query_lower = query.lower()
|
| 351 |
+
for phrase in comparison_phrases:
|
| 352 |
+
if phrase in query_lower:
|
| 353 |
+
return True
|
| 354 |
+
|
| 355 |
+
return False
|
| 356 |
+
|
| 357 |
def _default_response(self) -> Dict:
|
| 358 |
"""Return default classification for unrecognized queries."""
|
| 359 |
return {
|
prompts.py
CHANGED
|
@@ -5,6 +5,212 @@ Prompts for each stage: Claim → Validate → Contradict → Confirm
|
|
| 5 |
Following the Developer Specification exactly.
|
| 6 |
"""
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
# =============================================================================
|
| 9 |
# SYSTEM PROMPT (Base context)
|
| 10 |
# =============================================================================
|
|
@@ -155,32 +361,183 @@ Return ONLY the JSON object, no additional text."""
|
|
| 155 |
# RESPONSE GENERATION PROMPT
|
| 156 |
# =============================================================================
|
| 157 |
|
| 158 |
-
RESPONSE_PROMPT = """
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
USER QUERY: {query}
|
| 161 |
|
| 162 |
-
|
|
|
|
| 163 |
{diagnosis}
|
| 164 |
|
| 165 |
-
|
| 166 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
-
Generate a response that:
|
| 169 |
-
1. DIRECTLY answers the farmer's question first
|
| 170 |
-
2. Explains the diagnosis in simple, practical terms
|
| 171 |
-
3. Cites 2-3 key pieces of evidence with specific numbers
|
| 172 |
-
4. Provides a clear causal chain if appropriate
|
| 173 |
-
5. Gives prioritized, actionable recommendations
|
| 174 |
-
6. Mentions which area needs most attention if spatial data available
|
| 175 |
-
7. Is concise but complete (3-4 paragraphs max)
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
-
Respond in natural language (not JSON)."""
|
| 184 |
|
| 185 |
# =============================================================================
|
| 186 |
# FOLLOWUP GENERATION PROMPT
|
|
@@ -289,3 +646,80 @@ def generate_followup_questions(intent: str, diagnosis: str) -> list:
|
|
| 289 |
"Is my crop at risk?",
|
| 290 |
"How can I prevent this in the future?"
|
| 291 |
])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
Following the Developer Specification exactly.
|
| 6 |
"""
|
| 7 |
|
| 8 |
+
from typing import Dict, List, Any, Optional
|
| 9 |
+
|
| 10 |
+
# =============================================================================
|
| 11 |
+
# PERSONA SYSTEM - Tailored Responses Based on User Profile
|
| 12 |
+
# =============================================================================
|
| 13 |
+
|
| 14 |
+
# Granular persona definitions based on role, experience, and context
|
| 15 |
+
PERSONA_DEFINITIONS = {
|
| 16 |
+
"new_farmer_basic_tech": {
|
| 17 |
+
"description": "New farmer (0-3 years), limited smartphone familiarity",
|
| 18 |
+
"style": "Very simple, step-by-step guidance with visual cues",
|
| 19 |
+
"format": "Short bullet points, numbered steps, clear YES/NO answers",
|
| 20 |
+
"focus": "Immediate actions today, what to look for visually, when to seek help",
|
| 21 |
+
"tone": "Warm, patient, encouraging, supportive",
|
| 22 |
+
"recommendations": "Simple low-cost solutions, avoid complex equipment suggestions"
|
| 23 |
+
},
|
| 24 |
+
"new_farmer_tech_savvy": {
|
| 25 |
+
"description": "New farmer (0-3 years), comfortable with technology",
|
| 26 |
+
"style": "Clear explanations with some context, can reference app features",
|
| 27 |
+
"format": "Organized lists, basic metrics explained, graphs mentioned",
|
| 28 |
+
"focus": "Learning-oriented, explain the 'why', build understanding",
|
| 29 |
+
"tone": "Friendly, educational, encouraging experimentation",
|
| 30 |
+
"recommendations": "Modern approaches welcome, mention monitoring features"
|
| 31 |
+
},
|
| 32 |
+
"experienced_farmer_traditional": {
|
| 33 |
+
"description": "Experienced farmer (5+ years), prefers traditional methods",
|
| 34 |
+
"style": "Practical, reference seasonal patterns they would recognize",
|
| 35 |
+
"format": "Direct recommendations, compare to normal conditions",
|
| 36 |
+
"focus": "Cost-effective proven solutions, risk assessment, timing",
|
| 37 |
+
"tone": "Respectful of expertise, collaborative, peer-to-peer",
|
| 38 |
+
"recommendations": "Proven methods first, new tech only if clearly beneficial"
|
| 39 |
+
},
|
| 40 |
+
"experienced_farmer_innovative": {
|
| 41 |
+
"description": "Experienced farmer (5+ years), open to modern methods",
|
| 42 |
+
"style": "Practical with technical context, efficiency-focused",
|
| 43 |
+
"format": "Data-backed recommendations, optimization suggestions",
|
| 44 |
+
"focus": "Maximize yield/profit, precision timing, resource efficiency",
|
| 45 |
+
"tone": "Professional, partner-like, respects their judgment",
|
| 46 |
+
"recommendations": "Innovation welcomed, precision agriculture approaches"
|
| 47 |
+
},
|
| 48 |
+
"commercial_farmer": {
|
| 49 |
+
"description": "Commercial farming focus, business-oriented",
|
| 50 |
+
"style": "Business-focused, ROI considerations, scalable solutions",
|
| 51 |
+
"format": "Clear priorities, cost-benefit mentioned, zone-wise breakdown",
|
| 52 |
+
"focus": "Profit optimization, risk management, market timing",
|
| 53 |
+
"tone": "Professional, efficient, results-oriented",
|
| 54 |
+
"recommendations": "Commercial-grade solutions, labor/resource optimization"
|
| 55 |
+
},
|
| 56 |
+
"agricultural_officer": {
|
| 57 |
+
"description": "Extension officer or field advisor",
|
| 58 |
+
"style": "Professional, documentation-ready, multi-farm applicable",
|
| 59 |
+
"format": "Structured findings, zone-wise data, statistical summary",
|
| 60 |
+
"focus": "Regional patterns, farmer communication tips, policy relevance",
|
| 61 |
+
"tone": "Formal, comprehensive, shareable insights",
|
| 62 |
+
"recommendations": "Scalable solutions, training opportunities"
|
| 63 |
+
},
|
| 64 |
+
"agronomist_researcher": {
|
| 65 |
+
"description": "Technical researcher or scientist",
|
| 66 |
+
"style": "Full technical precision, spectral indices, statistical rigor",
|
| 67 |
+
"format": "Numerical tables, temporal correlations, spatial patterns, confidence intervals",
|
| 68 |
+
"focus": "Causal mechanisms, data quality notes, methodology",
|
| 69 |
+
"tone": "Scientific, analytical, evidence-driven",
|
| 70 |
+
"recommendations": "Research-backed interventions, experimental approaches"
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
# Questionnaire key mappings
|
| 75 |
+
EXPERIENCE_MAP = {
|
| 76 |
+
"Less than 2 years": 1,
|
| 77 |
+
"2 - 5 years": 3,
|
| 78 |
+
"5 - 10 years": 7,
|
| 79 |
+
"More than 10 years": 15
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
TECH_COMFORT_MAP = {
|
| 83 |
+
"I don't know how to use them": "basic",
|
| 84 |
+
"I need help using them": "basic",
|
| 85 |
+
"I can use basic features (calls, WhatsApp, YouTube)": "moderate",
|
| 86 |
+
"I am very comfortable using apps": "advanced"
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
INNOVATION_MAP = {
|
| 90 |
+
"I prefer traditional methods": "traditional",
|
| 91 |
+
"I try new methods occasionally": "moderate",
|
| 92 |
+
"I regularly adopt modern/innovative methods": "innovative"
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
FARMING_GOAL_MAP = {
|
| 96 |
+
"Food for family consumption": "subsistence",
|
| 97 |
+
"Earn Income / Livelihood": "income",
|
| 98 |
+
"Sell Commercially / Business": "commercial",
|
| 99 |
+
"Other": "custom"
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def create_user_persona(user_profile: Dict, all_fields: List = None) -> Dict:
|
| 104 |
+
"""
|
| 105 |
+
Create a comprehensive user persona from questionnaire data.
|
| 106 |
+
|
| 107 |
+
Uses all available context: role, experience, smartphone familiarity,
|
| 108 |
+
innovation attitude, farming goals, irrigation methods, mechanization,
|
| 109 |
+
and cropping frequency to tailor responses appropriately.
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
user_profile: User profile dict with 'questionnaire_data' key
|
| 113 |
+
all_fields: List of user's field info dicts
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
Persona dict with instructions for response generation
|
| 117 |
+
"""
|
| 118 |
+
questionnaire = user_profile.get("questionnaire_data", {}) if user_profile else {}
|
| 119 |
+
|
| 120 |
+
# Extract all questionnaire answers
|
| 121 |
+
role = questionnaire.get("role", "Farmer")
|
| 122 |
+
age_group = questionnaire.get("age_group", "31 - 45")
|
| 123 |
+
farming_exp = questionnaire.get("farming_experience", "2 - 5 years")
|
| 124 |
+
smartphone = questionnaire.get("smartphone_familiarity", "I can use basic features (calls, WhatsApp, YouTube)")
|
| 125 |
+
innovation = questionnaire.get("innovation_attitude", "I try new methods occasionally")
|
| 126 |
+
farming_goal = questionnaire.get("farming_goal", "Earn Income / Livelihood")
|
| 127 |
+
irrigation = questionnaire.get("irrigation_source", "Tube well / Borewell")
|
| 128 |
+
mechanization = questionnaire.get("mechanization_level", "with both by hand and machines")
|
| 129 |
+
cropping_freq = questionnaire.get("cropping_frequency", "2 crops per year")
|
| 130 |
+
|
| 131 |
+
# Map to numeric and categorical values
|
| 132 |
+
years_experience = EXPERIENCE_MAP.get(farming_exp, 3)
|
| 133 |
+
tech_level = TECH_COMFORT_MAP.get(smartphone, "moderate")
|
| 134 |
+
innovation_level = INNOVATION_MAP.get(innovation, "moderate")
|
| 135 |
+
goal_type = FARMING_GOAL_MAP.get(farming_goal, "income")
|
| 136 |
+
|
| 137 |
+
# Determine persona type based on combined factors
|
| 138 |
+
if role == "Agro-tech Researcher":
|
| 139 |
+
persona_type = "agronomist_researcher"
|
| 140 |
+
elif role in ["Extension Officer", "Agricultural Officer"]:
|
| 141 |
+
persona_type = "agricultural_officer"
|
| 142 |
+
elif goal_type == "commercial":
|
| 143 |
+
persona_type = "commercial_farmer"
|
| 144 |
+
elif years_experience <= 3:
|
| 145 |
+
if tech_level == "advanced":
|
| 146 |
+
persona_type = "new_farmer_tech_savvy"
|
| 147 |
+
else:
|
| 148 |
+
persona_type = "new_farmer_basic_tech"
|
| 149 |
+
else:
|
| 150 |
+
if innovation_level == "innovative":
|
| 151 |
+
persona_type = "experienced_farmer_innovative"
|
| 152 |
+
else:
|
| 153 |
+
persona_type = "experienced_farmer_traditional"
|
| 154 |
+
|
| 155 |
+
persona_def = PERSONA_DEFINITIONS.get(persona_type, PERSONA_DEFINITIONS["experienced_farmer_traditional"])
|
| 156 |
+
|
| 157 |
+
# Build context-aware persona instructions
|
| 158 |
+
persona_instructions = f"""
|
| 159 |
+
USER PERSONA: {persona_def['description']}
|
| 160 |
+
|
| 161 |
+
COMMUNICATION STYLE: {persona_def['style']}
|
| 162 |
+
RESPONSE FORMAT: {persona_def['format']}
|
| 163 |
+
PRIMARY FOCUS: {persona_def['focus']}
|
| 164 |
+
TONE: {persona_def['tone']}
|
| 165 |
+
RECOMMENDATION STYLE: {persona_def['recommendations']}
|
| 166 |
+
|
| 167 |
+
USER CONTEXT FOR TAILORING:
|
| 168 |
+
- Farming Experience: {farming_exp} ({years_experience} years)
|
| 169 |
+
- Technology Comfort: {smartphone}
|
| 170 |
+
- Innovation Attitude: {innovation}
|
| 171 |
+
- Farming Goal: {farming_goal}
|
| 172 |
+
- Irrigation Method: {irrigation}
|
| 173 |
+
- Mechanization: {mechanization}
|
| 174 |
+
- Cropping Frequency: {cropping_freq}
|
| 175 |
+
|
| 176 |
+
TAILORING GUIDELINES:
|
| 177 |
+
1. Match recommendations to their IRRIGATION method ({irrigation}):
|
| 178 |
+
- For "Rain only": Focus on rainwater harvesting, moisture conservation
|
| 179 |
+
- For "Drip/Sprinkler": Can suggest precise application rates
|
| 180 |
+
- For "Tube well": Consider water table sustainability
|
| 181 |
+
|
| 182 |
+
2. Match recommendations to their MECHANIZATION level ({mechanization}):
|
| 183 |
+
- For "by hand": Suggest labor-manageable solutions
|
| 184 |
+
- For "with machines": Can suggest mechanized interventions
|
| 185 |
+
|
| 186 |
+
3. Align with their FARMING GOAL ({farming_goal}):
|
| 187 |
+
- Subsistence: Prioritize food security, low-cost solutions
|
| 188 |
+
- Income: Balance cost and yield improvements
|
| 189 |
+
- Commercial: Focus on ROI, market timing, quality
|
| 190 |
+
|
| 191 |
+
4. Respect their INNOVATION preference ({innovation}):
|
| 192 |
+
- Traditional: Lead with proven methods, new tech as optional
|
| 193 |
+
- Innovative: Can suggest modern precision approaches
|
| 194 |
+
|
| 195 |
+
IMPORTANT: Generate a response that this specific user will find most helpful and actionable.
|
| 196 |
+
"""
|
| 197 |
+
|
| 198 |
+
return {
|
| 199 |
+
"type": persona_type,
|
| 200 |
+
"experience_years": years_experience,
|
| 201 |
+
"tech_level": tech_level,
|
| 202 |
+
"innovation_level": innovation_level,
|
| 203 |
+
"goal_type": goal_type,
|
| 204 |
+
"irrigation_method": irrigation,
|
| 205 |
+
"mechanization": mechanization,
|
| 206 |
+
"cropping_frequency": cropping_freq,
|
| 207 |
+
"all_fields": [f.get("name") for f in (all_fields or [])],
|
| 208 |
+
"instructions": persona_instructions,
|
| 209 |
+
"raw_questionnaire": questionnaire
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
|
| 214 |
# =============================================================================
|
| 215 |
# SYSTEM PROMPT (Base context)
|
| 216 |
# =============================================================================
|
|
|
|
| 361 |
# RESPONSE GENERATION PROMPT
|
| 362 |
# =============================================================================
|
| 363 |
|
| 364 |
+
RESPONSE_PROMPT = """
|
| 365 |
+
{persona_instructions}
|
| 366 |
+
|
| 367 |
+
CONVERSATION HISTORY (for follow-up awareness):
|
| 368 |
+
{conversation_history}
|
| 369 |
+
|
| 370 |
+
Based on the diagnostic analysis, generate a FOCUSED, TO-THE-POINT response.
|
| 371 |
+
|
| 372 |
+
USER QUERY: {query}
|
| 373 |
+
|
| 374 |
+
DIAGNOSIS: {diagnosis}
|
| 375 |
+
|
| 376 |
+
EVIDENCE: {evidence}
|
| 377 |
+
|
| 378 |
+
WEATHER: {weather_context}
|
| 379 |
+
|
| 380 |
+
ZONE ANALYSIS:
|
| 381 |
+
{zone_context}
|
| 382 |
+
|
| 383 |
+
HISTORICAL TRENDS:
|
| 384 |
+
{trend_context}
|
| 385 |
+
|
| 386 |
+
═══════════════════════════════════════════════════════════════
|
| 387 |
+
CRITICAL: RESPONSE STRUCTURE (MUST FOLLOW THIS ORDER)
|
| 388 |
+
═══════════════════════════════════════════════════════════════
|
| 389 |
+
|
| 390 |
+
**SECTION 1: DIRECT ANSWER (2-3 sentences MAX)**
|
| 391 |
+
- Answer the user's EXACT question immediately
|
| 392 |
+
- No preamble, no explanation - just the answer
|
| 393 |
+
- If it's a yes/no question, start with "Yes" or "No"
|
| 394 |
+
- Include the key data point that supports your answer
|
| 395 |
+
|
| 396 |
+
**SECTION 2: BRIEF EVIDENCE (1-2 bullets)**
|
| 397 |
+
- Only cite 1-2 most relevant data points
|
| 398 |
+
- Format: "📊 NDVI: 0.45 (below healthy threshold of 0.6)"
|
| 399 |
+
|
| 400 |
+
**SECTION 3: PRIORITY ACTION (1 main action)**
|
| 401 |
+
- ONE clear, immediate action they should take
|
| 402 |
+
- Include timing: "Do this TODAY/this week/before rain"
|
| 403 |
+
|
| 404 |
+
---
|
| 405 |
+
|
| 406 |
+
**SECTION 4: AUXILIARY INFORMATION** (only if relevant)
|
| 407 |
+
After the main answer, you MAY add:
|
| 408 |
+
- 🌤️ Weather timing (if relevant to action)
|
| 409 |
+
- 📍 Zone priority (if spatial variation exists)
|
| 410 |
+
- 📈 Trend context (if historical data shows pattern)
|
| 411 |
+
- 💡 Additional tips (max 2 bullets)
|
| 412 |
+
|
| 413 |
+
═══════════════════════════════════════════════════════════════
|
| 414 |
+
WRONG ❌: "Based on the satellite analysis of your field, I can see that there are several factors to consider. The NDVI values show..."
|
| 415 |
+
RIGHT ✅: "Your crop needs water immediately. NDVI is 0.42, down 15% from last week, indicating water stress."
|
| 416 |
+
═══════════════════════════════════════════════════════════════
|
| 417 |
+
|
| 418 |
+
FOLLOW-UP AWARENESS:
|
| 419 |
+
- If user asks "what about X" after previous discussion, connect to earlier context
|
| 420 |
+
- Reference previous diagnosis if relevant: "Building on the water stress we discussed..."
|
| 421 |
+
|
| 422 |
+
ZONE-SPECIFIC (if zone data available):
|
| 423 |
+
- Identify the PRIORITY ZONE needing immediate attention
|
| 424 |
+
- Give zone-specific dosage/quantity if applicable
|
| 425 |
+
|
| 426 |
+
Keep total response under 150 words for basic farmers, up to 250 for researchers."""
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
# =============================================================================
|
| 430 |
+
# CONFIDENCE-BASED RESPONSE PROMPTS
|
| 431 |
+
# =============================================================================
|
| 432 |
+
|
| 433 |
+
LOW_CONFIDENCE_PROMPT = """
|
| 434 |
+
{persona_instructions}
|
| 435 |
+
|
| 436 |
+
I need more information to give you a confident answer.
|
| 437 |
+
|
| 438 |
+
WHAT I CAN SEE:
|
| 439 |
+
{available_evidence}
|
| 440 |
+
|
| 441 |
+
WHAT'S UNCLEAR:
|
| 442 |
+
{missing_info}
|
| 443 |
+
|
| 444 |
+
**Before I can help accurately, please tell me:**
|
| 445 |
+
{clarifying_questions}
|
| 446 |
+
|
| 447 |
+
Once you provide this information, I can give you specific recommendations.
|
| 448 |
+
"""
|
| 449 |
+
|
| 450 |
+
MEDIUM_CONFIDENCE_PROMPT = """
|
| 451 |
+
{persona_instructions}
|
| 452 |
|
| 453 |
USER QUERY: {query}
|
| 454 |
|
| 455 |
+
Based on available data, here's my analysis (with some uncertainty):
|
| 456 |
+
|
| 457 |
{diagnosis}
|
| 458 |
|
| 459 |
+
⚠️ **Note:** My confidence is moderate because:
|
| 460 |
+
{uncertainty_reasons}
|
| 461 |
+
|
| 462 |
+
**Recommended action:**
|
| 463 |
+
{recommendation}
|
| 464 |
+
|
| 465 |
+
**To be more certain, it would help to know:**
|
| 466 |
+
{additional_info_needed}
|
| 467 |
+
"""
|
| 468 |
+
|
| 469 |
+
# =============================================================================
|
| 470 |
+
# ZONE-SPECIFIC CONTEXT BUILDER
|
| 471 |
+
# =============================================================================
|
| 472 |
+
|
| 473 |
+
def format_zone_context(zone_data: dict) -> str:
|
| 474 |
+
"""Format zone/patch data into readable context for LLM."""
|
| 475 |
+
if not zone_data or not zone_data.get("priority_zones"):
|
| 476 |
+
return "No zone-specific data available."
|
| 477 |
+
|
| 478 |
+
lines = []
|
| 479 |
+
zones = zone_data.get("priority_zones", [])
|
| 480 |
+
|
| 481 |
+
if zones:
|
| 482 |
+
lines.append("PRIORITY ZONES (highest stress first):")
|
| 483 |
+
for i, zone in enumerate(zones[:3], 1):
|
| 484 |
+
lines.append(f" {i}. {zone.get('location', 'Zone')} - "
|
| 485 |
+
f"Stress: {zone.get('stress_score', 0):.0%}, "
|
| 486 |
+
f"Issue: {zone.get('primary_issue', 'unknown')}, "
|
| 487 |
+
f"Area: {zone.get('area_percentage', 0):.1f}%")
|
| 488 |
+
|
| 489 |
+
most_critical = zone_data.get("most_critical")
|
| 490 |
+
if most_critical:
|
| 491 |
+
lines.append(f"\n⚠️ MOST CRITICAL: {most_critical.get('location', 'Zone')} "
|
| 492 |
+
f"needs immediate attention")
|
| 493 |
+
|
| 494 |
+
return "\n".join(lines) if lines else "No zone-specific data available."
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def format_trend_context(trend_data: dict) -> str:
|
| 498 |
+
"""Format historical trend data into readable context for LLM."""
|
| 499 |
+
if not trend_data:
|
| 500 |
+
return "No historical trend data available."
|
| 501 |
+
|
| 502 |
+
lines = []
|
| 503 |
+
changes = trend_data.get("changes", {})
|
| 504 |
+
|
| 505 |
+
if changes.get("ndvi_change_7d") is not None:
|
| 506 |
+
change = changes["ndvi_change_7d"]
|
| 507 |
+
direction = "↑" if change > 0 else "↓" if change < 0 else "→"
|
| 508 |
+
lines.append(f"NDVI Change (7 days): {direction} {abs(change):.3f} "
|
| 509 |
+
f"({trend_data.get('ndvi_trend', 'unknown')})")
|
| 510 |
+
|
| 511 |
+
if changes.get("smi_change_7d") is not None:
|
| 512 |
+
change = changes["smi_change_7d"]
|
| 513 |
+
direction = "↑" if change > 0 else "↓" if change < 0 else "→"
|
| 514 |
+
lines.append(f"Soil Moisture Change: {direction} {abs(change):.2f}")
|
| 515 |
+
|
| 516 |
+
summary = trend_data.get("summary")
|
| 517 |
+
if summary:
|
| 518 |
+
lines.append(f"Summary: {summary}")
|
| 519 |
+
|
| 520 |
+
return "\n".join(lines) if lines else "No historical trend data available."
|
| 521 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 522 |
|
| 523 |
+
def format_conversation_history(history: list) -> str:
|
| 524 |
+
"""Format conversation history for context injection."""
|
| 525 |
+
if not history:
|
| 526 |
+
return "This is the first message in the conversation."
|
| 527 |
+
|
| 528 |
+
lines = ["Recent conversation:"]
|
| 529 |
+
for turn in history[-3:]: # Last 3 turns
|
| 530 |
+
role = turn.get("role", "unknown")
|
| 531 |
+
content = turn.get("content", "")[:150] # Truncate long messages
|
| 532 |
+
if role == "user":
|
| 533 |
+
lines.append(f" User: {content}")
|
| 534 |
+
else:
|
| 535 |
+
# For assistant, show diagnosis summary if available
|
| 536 |
+
diagnosis = turn.get("diagnosis", content[:100])
|
| 537 |
+
lines.append(f" Assistant: {diagnosis}")
|
| 538 |
+
|
| 539 |
+
return "\n".join(lines)
|
| 540 |
|
|
|
|
| 541 |
|
| 542 |
# =============================================================================
|
| 543 |
# FOLLOWUP GENERATION PROMPT
|
|
|
|
| 646 |
"Is my crop at risk?",
|
| 647 |
"How can I prevent this in the future?"
|
| 648 |
])
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
# =============================================================================
|
| 652 |
+
# FIELD COMPARISON PROMPT
|
| 653 |
+
# =============================================================================
|
| 654 |
+
|
| 655 |
+
COMPARISON_RESPONSE_PROMPT = """
|
| 656 |
+
{persona_instructions}
|
| 657 |
+
|
| 658 |
+
The user is asking to compare multiple fields. Analyze the data below and provide a comparative assessment.
|
| 659 |
+
|
| 660 |
+
FIELDS BEING COMPARED:
|
| 661 |
+
{comparison_data}
|
| 662 |
+
|
| 663 |
+
WEATHER CONTEXT (shared for all fields):
|
| 664 |
+
{weather_context}
|
| 665 |
+
|
| 666 |
+
Generate a comparative response that:
|
| 667 |
+
1. Creates a clear side-by-side summary of key health metrics for each field
|
| 668 |
+
2. Explicitly states which field is performing BETTER and which needs MORE ATTENTION
|
| 669 |
+
3. Identifies the KEY DIFFERENCES between fields and potential CAUSES
|
| 670 |
+
4. Provides FIELD-SPECIFIC recommendations (different actions per field)
|
| 671 |
+
5. Uses the weather forecast to suggest optimal timing for interventions
|
| 672 |
+
6. Matches the user's persona communication style
|
| 673 |
+
|
| 674 |
+
COMPARISON FORMAT:
|
| 675 |
+
- Start with a quick overview (which field needs priority)
|
| 676 |
+
- Use a structured comparison (Field A vs Field B)
|
| 677 |
+
- End with specific action items per field
|
| 678 |
+
|
| 679 |
+
Respond in natural language but use clear structure (headers, bullets) for easy comparison."""
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
def format_weather_context(weather_data: dict) -> str:
|
| 683 |
+
"""Format weather data into readable context for LLM."""
|
| 684 |
+
if not weather_data:
|
| 685 |
+
return "No weather data available."
|
| 686 |
+
|
| 687 |
+
lines = []
|
| 688 |
+
|
| 689 |
+
# Historical summary
|
| 690 |
+
hist = weather_data.get("rolling_stats", {})
|
| 691 |
+
if hist:
|
| 692 |
+
lines.append("PAST 7 DAYS:")
|
| 693 |
+
if hist.get("avg_temp_7d"):
|
| 694 |
+
lines.append(f" - Average Max Temp: {hist['avg_temp_7d']}°C")
|
| 695 |
+
if hist.get("total_precip_7d") is not None:
|
| 696 |
+
lines.append(f" - Total Rainfall: {hist['total_precip_7d']} mm")
|
| 697 |
+
if hist.get("dry_days_count") is not None:
|
| 698 |
+
lines.append(f" - Dry Days: {hist['dry_days_count']}")
|
| 699 |
+
if hist.get("heat_stress_days") is not None:
|
| 700 |
+
lines.append(f" - Heat Stress Days (>35°C): {hist['heat_stress_days']}")
|
| 701 |
+
|
| 702 |
+
# Forecast summary
|
| 703 |
+
forecast = weather_data.get("forecast_7d", [])
|
| 704 |
+
if forecast:
|
| 705 |
+
lines.append("\nNEXT 7 DAYS FORECAST:")
|
| 706 |
+
for day in forecast[:3]: # First 3 days
|
| 707 |
+
lines.append(f" - {day.get('date', 'N/A')}: {day.get('temp_max', 'N/A')}°C, Rain: {day.get('precipitation', 0)}mm")
|
| 708 |
+
|
| 709 |
+
# Stress indicators
|
| 710 |
+
stress = weather_data.get("stress_indicators", {})
|
| 711 |
+
if stress:
|
| 712 |
+
lines.append("\nWEATHER STRESS INDICATORS:")
|
| 713 |
+
if stress.get("current_heat_stress"):
|
| 714 |
+
lines.append(" ⚠️ Current heat stress detected")
|
| 715 |
+
if stress.get("predicted_heat_stress"):
|
| 716 |
+
lines.append(" ⚠️ Heat stress predicted in next 3 days")
|
| 717 |
+
if stress.get("drought_risk"):
|
| 718 |
+
lines.append(" ⚠️ Drought risk - low recent and forecast rainfall")
|
| 719 |
+
if stress.get("suitable_for_irrigation"):
|
| 720 |
+
lines.append(" ✅ Good conditions for irrigation")
|
| 721 |
+
if stress.get("suitable_for_spraying"):
|
| 722 |
+
lines.append(" ✅ Good conditions for pesticide/fertilizer application")
|
| 723 |
+
|
| 724 |
+
return "\n".join(lines) if lines else "Weather data not available."
|
| 725 |
+
|