Aniket2006 commited on
Commit
504a512
·
1 Parent(s): a7903f2

Add 5-key Gemini API fallback, conversation memory, historical trends, and zone recommendations

Browse files
Files changed (4) hide show
  1. app.py +240 -58
  2. context_aggregator.py +260 -1
  3. intent_classifier.py +87 -0
  4. prompts.py +452 -18
app.py CHANGED
@@ -36,13 +36,35 @@ logging.basicConfig(
36
  logger = logging.getLogger("ChatbotService")
37
 
38
  # ============================================================================
39
- # GEMINI SETUP - Using REST API directly
40
  # ============================================================================
41
- GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 GEMINI_API_KEY:
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={GEMINI_API_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
- GEMINI_URL, GEMINI_MODEL = get_available_model()
 
 
 
 
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-pro:generateContent"
79
- logger.warning("Could not discover model, using default gemini-pro")
80
 
81
- if GEMINI_API_KEY:
82
- logger.info("Gemini API key configured successfully")
83
  else:
84
- logger.warning("GEMINI_API_KEY not set - chatbot will return mock responses")
85
 
86
  # Supabase client
87
  supabase = SupabaseClient()
88
 
89
  # ============================================================================
90
- # LLM CALLER
91
  # ============================================================================
 
 
 
 
 
 
 
 
 
 
92
  def call_gemini_api(prompt: str) -> str:
93
- """Call Gemini API directly using REST with retry logic."""
94
- if not GEMINI_API_KEY:
95
- return "Please configure GEMINI_API_KEY for real responses."
96
 
97
- url = f"{GEMINI_URL}?key={GEMINI_API_KEY}"
 
 
 
 
 
98
 
99
- max_retries = 3
100
- retry_delay = 2
101
 
102
- for attempt in range(max_retries):
103
- try:
104
- response = requests.post(
105
- url,
106
- headers={"Content-Type": "application/json"},
107
- json={
108
- "contents": [{
109
- "parts": [{"text": prompt}]
110
- }],
111
- "generationConfig": {
112
- "temperature": 0.7,
113
- "maxOutputTokens": 2048,
114
- }
115
- },
116
- timeout=60
117
- )
118
-
119
- if response.status_code == 200:
120
- data = response.json()
121
- if "candidates" in data and len(data["candidates"]) > 0:
122
- return data["candidates"][0]["content"]["parts"][0]["text"]
123
- return "No response generated."
124
- elif response.status_code == 429:
125
- if attempt < max_retries - 1:
126
- import time
127
- wait_time = retry_delay * (2 ** attempt)
128
- logger.warning(f"Rate limited, waiting {wait_time}s...")
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
- except Exception as e:
137
- logger.error(f"Gemini request error: {e}")
138
- if attempt < max_retries - 1:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  continue
140
- return f"Error: {str(e)}"
141
 
142
- return "Failed after retries. Please try again."
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. Add previous analysis if available
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = """Based on this diagnostic analysis, generate a helpful response for the farmer.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
  USER QUERY: {query}
161
 
162
- DIAGNOSIS RESULT:
 
163
  {diagnosis}
164
 
165
- EVIDENCE SUMMARY:
166
- {evidence}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- FORMAT:
178
- - Use emojis sparingly for visual clarity: 📊 (data), 🔬 (analysis), ✅ (action), ⚠️ (warning)
179
- - Use **bold** for key findings
180
- - Use bullet points for recommendations
181
- - Avoid technical jargon - explain in farmer-friendly terms
 
 
 
 
 
 
 
 
 
 
 
 
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
+