Aniket2006 commited on
Commit
ee8289e
·
1 Parent(s): 2221290

Full Developer Spec implementation: Priority-based Multi-Stage Reasoning

Browse files

- models.py: Pydantic schemas for all data structures
- prompts.py: Enhanced LLM prompts with JSON output formats
- context_aggregator.py: Priority-based satellite data formatting
- reasoning_engine.py: 4-stage pipeline with evidence chains
- priority_mapper.py: Intent-to-context mapping per spec
- intent_classifier.py: Enhanced with phrases and regional support
- app.py: Spec-compliant response with suggested_followups

Files changed (7) hide show
  1. app.py +47 -9
  2. context_aggregator.py +400 -174
  3. intent_classifier.py +201 -57
  4. models.py +308 -0
  5. priority_mapper.py +121 -37
  6. prompts.py +180 -73
  7. reasoning_engine.py +167 -86
app.py CHANGED
@@ -164,21 +164,35 @@ app.add_middleware(
164
  )
165
 
166
  # ============================================================================
167
- # REQUEST/RESPONSE MODELS
168
  # ============================================================================
169
  class ChatRequest(BaseModel):
170
  session_id: str
171
  message: str
172
  user_id: Optional[str] = None
173
  field_context: Optional[Dict[str, Any]] = None
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
  class ChatResponse(BaseModel):
176
- response: str
 
177
  session_id: str
178
  message_id: str
179
- context_used: List[str]
180
  timestamp: str
181
  reasoning_trace: Optional[Dict[str, Any]] = None
 
 
182
 
183
  class SessionRequest(BaseModel):
184
  user_id: str
@@ -199,6 +213,7 @@ class HistoryResponse(BaseModel):
199
  session_id: str
200
  messages: List[MessageModel]
201
 
 
202
  # ============================================================================
203
  # API ENDPOINTS
204
  # ============================================================================
@@ -321,7 +336,11 @@ async def chat(request: ChatRequest):
321
  query=request.message,
322
  context=context
323
  )
324
- context_used = list(reasoning_trace.get("evidence_summary", {}).get("primary", []))
 
 
 
 
325
  else:
326
  # Simple single-stage response
327
  logger.info("Using simple response mode")
@@ -335,14 +354,22 @@ async def chat(request: ChatRequest):
335
  "intent": intent,
336
  "field_context": context.get("user_field") if context else None
337
  }
338
- context_used = list(context.keys()) if context else []
 
 
 
 
 
 
 
 
339
 
340
  # Save assistant response
341
  assistant_msg_id = supabase.add_message(
342
  session_id=request.session_id,
343
  role="assistant",
344
  content=response_text,
345
- context_used=context_used
346
  )
347
 
348
  # Update session timestamp
@@ -350,13 +377,24 @@ async def chat(request: ChatRequest):
350
 
351
  logger.info(f"Response generated - {len(response_text)} chars")
352
 
 
353
  return ChatResponse(
354
- response=response_text,
 
 
 
 
355
  session_id=request.session_id,
356
  message_id=assistant_msg_id,
357
- context_used=context_used,
358
  timestamp=datetime.now().isoformat(),
359
- reasoning_trace=reasoning_trace
 
 
 
 
 
 
 
360
  )
361
 
362
  except Exception as e:
 
164
  )
165
 
166
  # ============================================================================
167
+ # REQUEST/RESPONSE MODELS (Matching Developer Spec)
168
  # ============================================================================
169
  class ChatRequest(BaseModel):
170
  session_id: str
171
  message: str
172
  user_id: Optional[str] = None
173
  field_context: Optional[Dict[str, Any]] = None
174
+ field_name: Optional[str] = None # Optional specific field
175
+
176
+ class ResponseContent(BaseModel):
177
+ message: str
178
+ confidence: float
179
+ diagnosis: Optional[str] = None
180
+
181
+ class ContextPriorityUsed(BaseModel):
182
+ priority_1: List[str] = []
183
+ priority_2: List[str] = []
184
+ priority_3: List[str] = []
185
+ priority_4: List[str] = []
186
 
187
  class ChatResponse(BaseModel):
188
+ """Full response matching developer spec."""
189
+ response: ResponseContent
190
  session_id: str
191
  message_id: str
 
192
  timestamp: str
193
  reasoning_trace: Optional[Dict[str, Any]] = None
194
+ context_priority_used: Optional[ContextPriorityUsed] = None
195
+ suggested_followups: List[str] = []
196
 
197
  class SessionRequest(BaseModel):
198
  user_id: str
 
213
  session_id: str
214
  messages: List[MessageModel]
215
 
216
+
217
  # ============================================================================
218
  # API ENDPOINTS
219
  # ============================================================================
 
336
  query=request.message,
337
  context=context
338
  )
339
+ # Extract context priority info from trace
340
+ context_priority = reasoning_trace.get("context_priority_used", {})
341
+ diagnosis = reasoning_trace.get("stages", {}).get("confirmation", {}).get("final")
342
+ confidence = reasoning_trace.get("stages", {}).get("confirmation", {}).get("confidence", 0.7)
343
+ suggested_followups = reasoning_trace.get("suggested_followups", [])
344
  else:
345
  # Simple single-stage response
346
  logger.info("Using simple response mode")
 
354
  "intent": intent,
355
  "field_context": context.get("user_field") if context else None
356
  }
357
+ context_priority = {}
358
+ diagnosis = None
359
+ confidence = 0.5
360
+ # Generate simple followups
361
+ from prompts import generate_followup_questions
362
+ suggested_followups = generate_followup_questions(
363
+ intent["primary_intent"],
364
+ "general"
365
+ )
366
 
367
  # Save assistant response
368
  assistant_msg_id = supabase.add_message(
369
  session_id=request.session_id,
370
  role="assistant",
371
  content=response_text,
372
+ context_used=list(context_priority.get("priority_1", []))
373
  )
374
 
375
  # Update session timestamp
 
377
 
378
  logger.info(f"Response generated - {len(response_text)} chars")
379
 
380
+ # Build spec-compliant response
381
  return ChatResponse(
382
+ response=ResponseContent(
383
+ message=response_text,
384
+ confidence=confidence,
385
+ diagnosis=diagnosis
386
+ ),
387
  session_id=request.session_id,
388
  message_id=assistant_msg_id,
 
389
  timestamp=datetime.now().isoformat(),
390
+ reasoning_trace=reasoning_trace,
391
+ context_priority_used=ContextPriorityUsed(
392
+ priority_1=context_priority.get("priority_1", []),
393
+ priority_2=context_priority.get("priority_2", []),
394
+ priority_3=context_priority.get("priority_3", []),
395
+ priority_4=context_priority.get("priority_4", [])
396
+ ) if context_priority else None,
397
+ suggested_followups=suggested_followups
398
  )
399
 
400
  except Exception as e:
context_aggregator.py CHANGED
@@ -1,31 +1,34 @@
1
  """
2
  Context Aggregator for Agricultural Chatbot
3
  =============================================
4
- Fetches satellite data from HF Space APIs and formats for LLM context.
 
5
  """
6
 
7
  import os
8
  import logging
9
  import requests
10
  from typing import Dict, List, Any, Optional
11
- from datetime import datetime
12
 
13
  logger = logging.getLogger("ContextAggregator")
14
 
15
- # HF Space URLs
16
- SAR_API_URL = "https://aniket2006-agrow-backend-v2.hf.space"
17
- SENTINEL2_API_URL = "https://aniket2006-agrow-sentinel2.hf.space"
18
- HEATMAP_API_URL = "https://aniket2006-heatmap.hf.space"
19
 
20
 
21
  class ContextAggregator:
22
  """
23
  Aggregates satellite data from multiple HF Space APIs.
24
- Returns structured JSON for LLM context injection.
25
  """
26
 
27
- def __init__(self, timeout: int = 30):
28
  self.timeout = timeout
 
 
29
 
30
  def fetch_full_context(
31
  self,
@@ -38,20 +41,13 @@ class ContextAggregator:
38
  Fetch complete satellite context for a field.
39
 
40
  Args:
41
- coordinates: {"center_lat": float, "center_lon": float, "bbox": [lon_min, lat_min, lon_max, lat_max]}
42
  crop_type: Type of crop
43
  area_acres: Field size in acres
44
- farmer_context: Additional farmer profile data
45
 
46
  Returns:
47
- {
48
- "vegetation_indices": {...},
49
- "sar_data": {...},
50
- "soil_indicators": {...},
51
- "weather_data": {...},
52
- "anomalies": {...},
53
- "temporal_trends": {...}
54
- }
55
  """
56
  context = {
57
  "fetch_timestamp": datetime.now().isoformat(),
@@ -72,23 +68,41 @@ class ContextAggregator:
72
  if not center_lat or not center_lon:
73
  return context
74
 
75
- # Fetch SAR analysis (VV, VH bands, patches, predictions)
76
  sar_data = self._fetch_sar_data(bbox, crop_type, farmer_context)
77
  if sar_data:
78
- context["sar_bands"] = sar_data.get("sar_bands", {})
79
  context["patches"] = sar_data.get("patches", [])
80
- context["stressed_patches"] = sar_data.get("stressed_patches", [])
 
81
  context["health_summary"] = sar_data.get("health_summary", {})
82
  context["temporal_trends"] = sar_data.get("temporal_trends", {})
83
  context["weather_data"] = sar_data.get("weather_data", [])
84
 
85
- # Fetch Sentinel-2 analysis (vegetation indices)
86
- s2_data = self._fetch_sentinel2_data(center_lat, center_lon, crop_type, area_acres, farmer_context)
 
 
87
  if s2_data:
88
- context["vegetation_indices"] = s2_data.get("vegetation_indices", {})
89
- context["soil_indicators"] = s2_data.get("soil_indicators", {})
90
  context["llm_analysis"] = s2_data.get("llm_analysis", {})
91
  context["sentinel2_bands"] = s2_data.get("band_values", {})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
  return context
94
 
@@ -131,13 +145,11 @@ class ContextAggregator:
131
  center_lat: float,
132
  center_lon: float,
133
  crop_type: str,
134
- area_acres: float,
135
  farmer_context: Optional[Dict]
136
  ) -> Optional[Dict]:
137
  """Fetch Sentinel-2 analysis from HF Space."""
138
  try:
139
- field_hectares = area_acres * 0.404686 # Convert acres to hectares
140
-
141
  response = requests.post(
142
  f"{SENTINEL2_API_URL}/analyze",
143
  json={
@@ -163,176 +175,358 @@ class ContextAggregator:
163
  logger.error(f"Sentinel-2 fetch error: {e}")
164
  return None
165
 
166
- def fetch_specific_metrics(
167
  self,
168
  center_lat: float,
169
  center_lon: float,
170
- area_acres: float,
171
- metrics: List[str]
172
- ) -> Dict[str, Any]:
173
- """
174
- Fetch specific heatmap metrics.
175
-
176
- Metrics: soil_moisture, soil_organic_matter, soil_fertility,
177
- soil_salinity, greenness, nitrogen_level,
178
- photosynthetic_capacity, pest_risk, disease_risk
179
- """
180
- results = {}
181
- field_hectares = area_acres * 0.404686
182
-
183
- for metric in metrics:
184
- try:
185
- response = requests.post(
186
- f"{HEATMAP_API_URL}/generate-heatmap",
187
- json={
188
- "center_lat": center_lat,
189
- "center_lon": center_lon,
190
- "field_size_hectares": field_hectares,
191
- "metric": metric,
192
- "gaussian_sigma": 1.5,
193
- "show_field_boundary": False
194
- },
195
- timeout=60
196
- )
197
 
198
- if response.status_code == 200:
199
- data = response.json()
200
- results[metric] = {
201
- "min_value": data.get("min_value"),
202
- "max_value": data.get("max_value"),
203
- "mean_value": data.get("mean_value"),
204
- "level": data.get("level"),
205
- "analysis": data.get("analysis"),
206
- "stress_score": data.get("stress_score"),
207
- "recommendations": data.get("recommendations", [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  }
209
-
210
- except Exception as e:
211
- logger.error(f"Heatmap fetch error for {metric}: {e}")
212
 
213
- return results
 
 
 
 
 
 
 
 
214
 
215
- def format_for_llm(self, context: Dict[str, Any]) -> Dict[str, Any]:
216
- """
217
- Format aggregated context for LLM consumption.
218
- Extracts key values and interpretations.
219
- """
220
- formatted = {
221
- "field_info": context.get("field_info", {}),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  }
 
 
 
 
 
223
 
224
- # Vegetation indices
225
- veg = context.get("vegetation_indices", {})
226
- if veg:
227
- formatted["vegetation_indices"] = {
228
- "NDVI": {
229
- "current": veg.get("ndvi", {}).get("mean"),
230
- "min": veg.get("ndvi", {}).get("min"),
231
- "max": veg.get("ndvi", {}).get("max"),
232
- "interpretation": self._interpret_ndvi(veg.get("ndvi", {}).get("mean", 0))
233
- },
234
- "NDRE": {
235
- "current": veg.get("ndre", {}).get("mean"),
236
- "interpretation": self._interpret_ndre(veg.get("ndre", {}).get("mean", 0))
237
- },
238
- "EVI": {
239
- "current": veg.get("evi", {}).get("mean"),
240
- },
241
- "SMI": {
242
- "current": veg.get("smi", {}).get("mean"),
243
- "interpretation": self._interpret_smi(veg.get("smi", {}).get("mean", 0))
244
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  }
 
 
 
 
 
 
 
 
 
246
 
247
- # SAR bands
248
- sar = context.get("sar_bands", {})
249
- if sar:
250
- formatted["sar_bands"] = {
251
- "VV": sar.get("vv"),
252
- "VH": sar.get("vh"),
253
- "VV_VH_ratio": sar.get("ratio")
254
  }
 
 
 
 
 
 
 
 
 
255
 
256
- # Health summary
257
- health = context.get("health_summary", {})
258
- if health:
259
- formatted["health_summary"] = health
260
-
261
- # Stressed patches
262
- stressed = context.get("stressed_patches", [])
263
- if stressed:
264
- formatted["stress_analysis"] = {
265
- "stressed_patch_count": len(stressed),
266
- "high_stress_patches": [p for p in stressed if p.get("stress_score", 0) > 0.7],
267
- "affected_area_percent": sum(p.get("percentage", 0) for p in stressed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  }
 
269
 
270
- # Weather
271
- weather = context.get("weather_data", [])
272
- if weather:
273
- latest = weather[0] if weather else {}
274
- formatted["weather"] = {
275
- "current": latest,
276
- "recent_days": len(weather)
277
  }
278
 
279
- # LLM analysis from Sentinel-2
280
- llm = context.get("llm_analysis", {})
281
- if llm:
282
- formatted["previous_analysis"] = {
283
- "soil_moisture": llm.get("soil_moisture"),
284
- "soil_fertility": llm.get("soil_fertility"),
285
- "nitrogen_status": llm.get("nitrogen_status"),
286
- "overall_health": llm.get("overall_health")
287
  }
288
 
289
- return formatted
290
-
291
- def _interpret_ndvi(self, value: float) -> str:
292
- if value is None:
293
- return "unknown"
294
- if value > 0.7:
295
- return "excellent_vegetation"
296
- elif value > 0.5:
297
- return "healthy_vegetation"
298
- elif value > 0.3:
299
- return "moderate_stress"
300
- elif value > 0.1:
301
- return "severe_stress"
302
- else:
303
- return "bare_soil_or_water"
304
-
305
- def _interpret_ndre(self, value: float) -> str:
306
- if value is None:
307
- return "unknown"
308
- if value > 0.5:
309
- return "high_chlorophyll"
310
- elif value > 0.3:
311
- return "adequate_chlorophyll"
312
- elif value > 0.1:
313
- return "low_chlorophyll"
314
- else:
315
- return "chlorophyll_deficiency"
316
 
317
- def _interpret_smi(self, value: float) -> str:
318
- if value is None:
319
- return "unknown"
320
- if value > 0.6:
321
- return "adequate_moisture"
322
- elif value > 0.4:
323
- return "moderate_moisture"
324
- elif value > 0.2:
325
- return "low_moisture"
326
- else:
327
- return "critical_moisture_deficit"
 
 
 
 
 
 
 
 
328
 
329
 
330
- # Quick context fetch function
 
 
 
331
  def fetch_field_context(
332
  coordinates: Dict[str, Any],
333
  crop_type: str = "Wheat",
334
  area_acres: float = 1.0,
335
- fetch_satellite: bool = True
 
336
  ) -> Dict[str, Any]:
337
  """
338
  Quick function to fetch and format field context.
@@ -341,7 +535,8 @@ def fetch_field_context(
341
  coordinates: {"center_lat": float, "center_lon": float, "bbox": [...]}
342
  crop_type: Crop type string
343
  area_acres: Field size
344
- fetch_satellite: Whether to fetch from HF APIs (set False for quick response)
 
345
  """
346
  aggregator = ContextAggregator()
347
 
@@ -349,7 +544,8 @@ def fetch_field_context(
349
  raw_context = aggregator.fetch_full_context(
350
  coordinates=coordinates,
351
  crop_type=crop_type,
352
- area_acres=area_acres
 
353
  )
354
  return aggregator.format_for_llm(raw_context)
355
  else:
@@ -360,3 +556,33 @@ def fetch_field_context(
360
  "coordinates": coordinates
361
  }
362
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  Context Aggregator for Agricultural Chatbot
3
  =============================================
4
+ Fetches satellite data from HF Space APIs and formats for priority-based retrieval.
5
+ Supports all data sources: SAR, Sentinel-2, Heatmap, Weather APIs.
6
  """
7
 
8
  import os
9
  import logging
10
  import requests
11
  from typing import Dict, List, Any, Optional
12
+ from datetime import datetime, timedelta
13
 
14
  logger = logging.getLogger("ContextAggregator")
15
 
16
+ # HF Space URLs - Your deployed backends
17
+ SAR_API_URL = os.getenv("SAR_API_URL", "https://aniket2006-agrow-backend-v2.hf.space")
18
+ SENTINEL2_API_URL = os.getenv("SENTINEL2_API_URL", "https://aniket2006-agrow-sentinel2.hf.space")
19
+ HEATMAP_API_URL = os.getenv("HEATMAP_API_URL", "https://aniket2006-heatmap.hf.space")
20
 
21
 
22
  class ContextAggregator:
23
  """
24
  Aggregates satellite data from multiple HF Space APIs.
25
+ Returns structured data organized by priority levels for reasoning stages.
26
  """
27
 
28
+ def __init__(self, timeout: int = 45):
29
  self.timeout = timeout
30
+ self._cache = {}
31
+ self._cache_ttl = 300 # 5 minutes
32
 
33
  def fetch_full_context(
34
  self,
 
41
  Fetch complete satellite context for a field.
42
 
43
  Args:
44
+ coordinates: {"center_lat": float, "center_lon": float, "bbox": [...]}
45
  crop_type: Type of crop
46
  area_acres: Field size in acres
47
+ farmer_context: Farmer profile and action data
48
 
49
  Returns:
50
+ Structured context with all satellite data
 
 
 
 
 
 
 
51
  """
52
  context = {
53
  "fetch_timestamp": datetime.now().isoformat(),
 
68
  if not center_lat or not center_lon:
69
  return context
70
 
71
+ # 1. Fetch SAR analysis (VV, VH bands, patches, predictions)
72
  sar_data = self._fetch_sar_data(bbox, crop_type, farmer_context)
73
  if sar_data:
74
+ context["sar_bands"] = self._extract_sar_bands(sar_data)
75
  context["patches"] = sar_data.get("patches", [])
76
+ context["stressed_patches"] = [p for p in sar_data.get("patches", [])
77
+ if p.get("stress_score", 0) > 0.5]
78
  context["health_summary"] = sar_data.get("health_summary", {})
79
  context["temporal_trends"] = sar_data.get("temporal_trends", {})
80
  context["weather_data"] = sar_data.get("weather_data", [])
81
 
82
+ # 2. Fetch Sentinel-2 analysis (vegetation indices)
83
+ field_hectares = area_acres * 0.404686
84
+ s2_data = self._fetch_sentinel2_data(center_lat, center_lon, crop_type,
85
+ field_hectares, farmer_context)
86
  if s2_data:
87
+ context["vegetation_indices"] = self._extract_vegetation_indices(s2_data)
88
+ context["soil_indicators"] = self._extract_soil_indicators(s2_data)
89
  context["llm_analysis"] = s2_data.get("llm_analysis", {})
90
  context["sentinel2_bands"] = s2_data.get("band_values", {})
91
+ context["clustering"] = self._extract_clustering(s2_data)
92
+ context["anomalies"] = self._extract_anomalies(s2_data)
93
+
94
+ # 3. Add farmer context if provided
95
+ if farmer_context:
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(),
103
+ "source": "sar_analysis",
104
+ "summary": sar_data.get("llm_analysis", {}).get("summary", "")
105
+ }
106
 
107
  return context
108
 
 
145
  center_lat: float,
146
  center_lon: float,
147
  crop_type: str,
148
+ field_hectares: float,
149
  farmer_context: Optional[Dict]
150
  ) -> Optional[Dict]:
151
  """Fetch Sentinel-2 analysis from HF Space."""
152
  try:
 
 
153
  response = requests.post(
154
  f"{SENTINEL2_API_URL}/analyze",
155
  json={
 
175
  logger.error(f"Sentinel-2 fetch error: {e}")
176
  return None
177
 
178
+ def _fetch_heatmap_metric(
179
  self,
180
  center_lat: float,
181
  center_lon: float,
182
+ field_hectares: float,
183
+ metric: str
184
+ ) -> Optional[Dict]:
185
+ """Fetch a specific heatmap metric."""
186
+ try:
187
+ response = requests.post(
188
+ f"{HEATMAP_API_URL}/generate-heatmap",
189
+ json={
190
+ "center_lat": center_lat,
191
+ "center_lon": center_lon,
192
+ "field_size_hectares": field_hectares,
193
+ "metric": metric,
194
+ "gaussian_sigma": 1.5,
195
+ "show_field_boundary": False
196
+ },
197
+ timeout=60
198
+ )
199
+
200
+ if response.status_code == 200:
201
+ return response.json()
202
+ return None
 
 
 
 
 
 
203
 
204
+ except Exception as e:
205
+ logger.error(f"Heatmap fetch error for {metric}: {e}")
206
+ return None
207
+
208
+ # =========================================================================
209
+ # DATA EXTRACTION HELPERS
210
+ # =========================================================================
211
+
212
+ def _extract_sar_bands(self, data: Dict) -> Dict:
213
+ """Extract SAR band values."""
214
+ sar = data.get("sar_bands", {})
215
+ return {
216
+ "VV": sar.get("vv"),
217
+ "VH": sar.get("vh"),
218
+ "VV_VH_ratio": sar.get("ratio"),
219
+ "VV_trend": sar.get("vv_trend", "stable"),
220
+ "VH_trend": sar.get("vh_trend", "stable"),
221
+ "interpretation": self._interpret_sar(sar.get("vv"), sar.get("vh"))
222
+ }
223
+
224
+ def _interpret_sar(self, vv: Optional[float], vh: Optional[float]) -> str:
225
+ """Interpret SAR band values."""
226
+ if vv is None:
227
+ return "no_data"
228
+ if vv > -8:
229
+ return "wet_soil_or_water"
230
+ elif vv > -12:
231
+ return "moist_soil"
232
+ elif vv > -16:
233
+ return "moderate_soil"
234
+ else:
235
+ return "dry_soil"
236
+
237
+ def _extract_vegetation_indices(self, data: Dict) -> Dict:
238
+ """Extract and structure vegetation indices."""
239
+ veg = data.get("vegetation_indices", {})
240
+
241
+ result = {}
242
+ for idx in ["ndvi", "evi", "ndre", "reci", "ndwi", "smi", "psri", "pri", "mcari"]:
243
+ if idx in veg:
244
+ val = veg[idx]
245
+ if isinstance(val, dict):
246
+ result[idx.upper()] = {
247
+ "current": val.get("mean"),
248
+ "min": val.get("min"),
249
+ "max": val.get("max"),
250
+ "trend": val.get("trend"),
251
+ "interpretation": self._interpret_index(idx, val.get("mean"))
252
+ }
253
+ elif isinstance(val, (int, float)):
254
+ result[idx.upper()] = {
255
+ "current": val,
256
+ "interpretation": self._interpret_index(idx, val)
257
  }
 
 
 
258
 
259
+ # Add temporal trends if available
260
+ trends = data.get("temporal_trends", {})
261
+ if trends:
262
+ for idx, trend in trends.items():
263
+ if idx.upper() in result:
264
+ result[idx.upper()]["trend_7d"] = trend.get("change_7d")
265
+ result[idx.upper()]["trend_30d"] = trend.get("change_30d")
266
+
267
+ return result
268
 
269
+ def _interpret_index(self, index: str, value: Optional[float]) -> str:
270
+ """Interpret vegetation index value."""
271
+ if value is None:
272
+ return "no_data"
273
+
274
+ index = index.lower()
275
+
276
+ if index == "ndvi":
277
+ if value > 0.7: return "excellent_vegetation"
278
+ elif value > 0.5: return "healthy_vegetation"
279
+ elif value > 0.3: return "moderate_stress"
280
+ elif value > 0.1: return "severe_stress"
281
+ else: return "bare_soil_or_water"
282
+
283
+ elif index == "ndre":
284
+ if value > 0.5: return "high_chlorophyll"
285
+ elif value > 0.3: return "adequate_chlorophyll"
286
+ elif value > 0.1: return "low_chlorophyll"
287
+ else: return "chlorophyll_deficiency"
288
+
289
+ elif index == "smi":
290
+ if value > 0.6: return "adequate_moisture"
291
+ elif value > 0.4: return "moderate_moisture"
292
+ elif value > 0.2: return "low_moisture"
293
+ else: return "critical_moisture_deficit"
294
+
295
+ elif index == "evi":
296
+ if value > 0.5: return "high_biomass"
297
+ elif value > 0.3: return "moderate_biomass"
298
+ else: return "low_biomass"
299
+
300
+ elif index == "psri":
301
+ if value > 0.2: return "senescence_stress"
302
+ elif value > 0: return "mild_stress"
303
+ else: return "healthy"
304
+
305
+ elif index == "pri":
306
+ if value > 0.05: return "high_photosynthetic_efficiency"
307
+ elif value > 0: return "moderate_efficiency"
308
+ else: return "photosynthetic_stress"
309
+
310
+ return "unknown"
311
+
312
+ def _extract_soil_indicators(self, data: Dict) -> Dict:
313
+ """Extract soil health indicators."""
314
+ soil = data.get("soil_indicators", {})
315
+ llm = data.get("llm_analysis", {})
316
+
317
+ return {
318
+ "moisture": {
319
+ "level": llm.get("soil_moisture", {}).get("level", "unknown"),
320
+ "SMI_value": soil.get("smi"),
321
+ "status": llm.get("soil_moisture", {}).get("analysis", "")
322
+ },
323
+ "salinity": {
324
+ "level": llm.get("soil_salinity", {}).get("level", "unknown"),
325
+ "status": llm.get("soil_salinity", {}).get("analysis", "")
326
+ },
327
+ "organic_matter": {
328
+ "level": llm.get("organic_matter", {}).get("level", "unknown"),
329
+ "status": llm.get("organic_matter", {}).get("analysis", "")
330
+ },
331
+ "fertility": {
332
+ "level": llm.get("soil_fertility", {}).get("level", "unknown"),
333
+ "status": llm.get("soil_fertility", {}).get("analysis", "")
334
+ }
335
  }
336
+
337
+ def _extract_clustering(self, data: Dict) -> Dict:
338
+ """Extract clustering/stress zone data."""
339
+ clustering = data.get("clustering", {})
340
+ stress = data.get("stress_detection", {})
341
 
342
+ clusters = []
343
+ for cluster in clustering.get("clusters", []):
344
+ clusters.append({
345
+ "cluster_id": cluster.get("id"),
346
+ "num_patches": cluster.get("num_patches"),
347
+ "percentage": cluster.get("percentage"),
348
+ "stress_score_mean": cluster.get("stress_mean"),
349
+ "dominant_location": cluster.get("location")
350
+ })
351
+
352
+ stressed_patches = []
353
+ for patch in stress.get("stressed_patches", []):
354
+ if patch.get("stress_score", 0) > 0.5:
355
+ stressed_patches.append(patch)
356
+
357
+ return {
358
+ "clusters": clusters,
359
+ "stressed_patches": stressed_patches,
360
+ "overall_stress_score": stress.get("overall_stress", 0)
361
+ }
362
+
363
+ def _extract_anomalies(self, data: Dict) -> Dict:
364
+ """Extract anomaly detection results."""
365
+ anomalies = data.get("anomaly_detection", {})
366
+
367
+ return {
368
+ "total_detected": anomalies.get("total_anomalies", 0),
369
+ "percentage_affected": anomalies.get("anomaly_percentage", 0),
370
+ "high_priority": [
371
+ a for a in anomalies.get("anomaly_patches", [])
372
+ if a.get("stress_score", 0) > 0.7
373
+ ]
374
+ }
375
+
376
+ def _extract_weather(self, data: Dict) -> Dict:
377
+ """Extract weather data."""
378
+ weather = data.get("weather_data", [])
379
+
380
+ if not weather:
381
+ return {}
382
+
383
+ # Aggregate last 7 days
384
+ recent = weather[:7] if len(weather) >= 7 else weather
385
+
386
+ temps = [w.get("temp_max", 0) for w in recent if w.get("temp_max")]
387
+ precip = sum(w.get("precipitation", 0) for w in recent)
388
+ heat_days = sum(1 for w in recent if w.get("temp_max", 0) > 35)
389
+ dry_days = sum(1 for w in recent if w.get("precipitation", 0) == 0)
390
+
391
+ return {
392
+ "recent_7d": {
393
+ "avg_temp_max": round(sum(temps) / len(temps), 1) if temps else None,
394
+ "heat_stress_days": heat_days,
395
+ "total_precipitation_mm": round(precip, 1),
396
+ "consecutive_dry_days": dry_days
397
+ },
398
+ "stress_indicators": {
399
+ "heat_stress": heat_days >= 3,
400
+ "drought_stress": dry_days >= 5 and precip < 10
401
  }
402
+ }
403
+
404
+ # =========================================================================
405
+ # PRIORITY-BASED CONTEXT FORMATTING
406
+ # =========================================================================
407
+
408
+ def format_for_priority(self, context: Dict, intent: str) -> Dict[str, Dict]:
409
+ """
410
+ Format context into priority levels based on intent.
411
 
412
+ Returns:
413
+ {
414
+ "priority_1": {...}, # Primary evidence
415
+ "priority_2": {...}, # Supporting evidence
416
+ "priority_3": {...}, # Causal factors
417
+ "priority_4": {...} # Validation
 
418
  }
419
+ """
420
+ veg = context.get("vegetation_indices", {})
421
+ sar = context.get("sar_bands", {})
422
+ soil = context.get("soil_indicators", {})
423
+ weather = self._extract_weather(context)
424
+ clustering = context.get("clustering", {})
425
+ anomalies = context.get("anomalies", {})
426
+ farmer = context.get("farmer_actions", {})
427
+ previous = context.get("previous_analysis", {})
428
 
429
+ # Default priority mapping
430
+ priority_1 = {
431
+ "NDVI": veg.get("NDVI"),
432
+ "EVI": veg.get("EVI"),
433
+ "NDRE": veg.get("NDRE"),
434
+ "RECI": veg.get("RECI"),
435
+ "temporal_trends": context.get("temporal_trends", {})
436
+ }
437
+
438
+ priority_2 = {
439
+ "clustering": clustering,
440
+ "anomalies": anomalies,
441
+ "PSRI": veg.get("PSRI"),
442
+ "PRI": veg.get("PRI")
443
+ }
444
+
445
+ priority_3 = {
446
+ "weather": weather,
447
+ "SMI": veg.get("SMI"),
448
+ "soil_indicators": soil,
449
+ "B05": context.get("sentinel2_bands", {}).get("B05"),
450
+ "B08": context.get("sentinel2_bands", {}).get("B08")
451
+ }
452
+
453
+ priority_4 = {
454
+ "SAR": sar,
455
+ "previous_analysis": previous,
456
+ "farmer_actions": farmer
457
+ }
458
+
459
+ # Adjust based on intent
460
+ if intent == "water_stress":
461
+ priority_1 = {
462
+ "SMI": veg.get("SMI"),
463
+ "NDWI": veg.get("NDWI"),
464
+ "SAR": sar,
465
+ "temporal_trends_SMI": context.get("temporal_trends", {}).get("SMI")
466
  }
467
+ priority_2["weather"] = weather
468
 
469
+ elif intent == "nutrient_status":
470
+ priority_1 = {
471
+ "NDRE": veg.get("NDRE"),
472
+ "RECI": veg.get("RECI"),
473
+ "MCARI": veg.get("MCARI"),
474
+ "B05": context.get("sentinel2_bands", {}).get("B05"),
475
+ "B06": context.get("sentinel2_bands", {}).get("B06")
476
  }
477
 
478
+ elif intent == "pest_disease":
479
+ priority_1 = {
480
+ "anomalies": anomalies,
481
+ "PSRI": veg.get("PSRI"),
482
+ "PRI": veg.get("PRI"),
483
+ "clustering_outliers": clustering.get("stressed_patches", [])
 
 
484
  }
485
 
486
+ # Filter out None values
487
+ priority_1 = {k: v for k, v in priority_1.items() if v is not None}
488
+ priority_2 = {k: v for k, v in priority_2.items() if v is not None}
489
+ priority_3 = {k: v for k, v in priority_3.items() if v is not None}
490
+ priority_4 = {k: v for k, v in priority_4.items() if v is not None}
491
+
492
+ return {
493
+ "priority_1": priority_1,
494
+ "priority_2": priority_2,
495
+ "priority_3": priority_3,
496
+ "priority_4": priority_4
497
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
 
499
+ def format_for_llm(self, context: Dict[str, Any]) -> Dict[str, Any]:
500
+ """
501
+ Format aggregated context for LLM consumption.
502
+ Backwards compatible version.
503
+ """
504
+ return {
505
+ "field_info": context.get("field_info", {}),
506
+ "vegetation_indices": context.get("vegetation_indices", {}),
507
+ "sar_bands": context.get("sar_bands", {}),
508
+ "health_summary": context.get("health_summary", {}),
509
+ "stress_analysis": {
510
+ "stressed_patch_count": len(context.get("stressed_patches", [])),
511
+ "high_stress_patches": [p for p in context.get("stressed_patches", [])
512
+ if p.get("stress_score", 0) > 0.7]
513
+ },
514
+ "weather": self._extract_weather(context),
515
+ "soil_indicators": context.get("soil_indicators", {}),
516
+ "previous_analysis": context.get("llm_analysis", {})
517
+ }
518
 
519
 
520
+ # =============================================================================
521
+ # QUICK FUNCTIONS
522
+ # =============================================================================
523
+
524
  def fetch_field_context(
525
  coordinates: Dict[str, Any],
526
  crop_type: str = "Wheat",
527
  area_acres: float = 1.0,
528
+ fetch_satellite: bool = True,
529
+ farmer_context: Optional[Dict] = None
530
  ) -> Dict[str, Any]:
531
  """
532
  Quick function to fetch and format field context.
 
535
  coordinates: {"center_lat": float, "center_lon": float, "bbox": [...]}
536
  crop_type: Crop type string
537
  area_acres: Field size
538
+ fetch_satellite: Whether to fetch from HF APIs
539
+ farmer_context: Optional farmer data
540
  """
541
  aggregator = ContextAggregator()
542
 
 
544
  raw_context = aggregator.fetch_full_context(
545
  coordinates=coordinates,
546
  crop_type=crop_type,
547
+ area_acres=area_acres,
548
+ farmer_context=farmer_context
549
  )
550
  return aggregator.format_for_llm(raw_context)
551
  else:
 
556
  "coordinates": coordinates
557
  }
558
  }
559
+
560
+
561
+ def fetch_priority_context(
562
+ coordinates: Dict[str, Any],
563
+ crop_type: str,
564
+ area_acres: float,
565
+ intent: str,
566
+ farmer_context: Optional[Dict] = None
567
+ ) -> Dict[str, Dict]:
568
+ """
569
+ Fetch satellite context and organize by priority for intent.
570
+
571
+ Returns:
572
+ {
573
+ "priority_1": {...},
574
+ "priority_2": {...},
575
+ "priority_3": {...},
576
+ "priority_4": {...}
577
+ }
578
+ """
579
+ aggregator = ContextAggregator()
580
+
581
+ raw_context = aggregator.fetch_full_context(
582
+ coordinates=coordinates,
583
+ crop_type=crop_type,
584
+ area_acres=area_acres,
585
+ farmer_context=farmer_context
586
+ )
587
+
588
+ return aggregator.format_for_priority(raw_context, intent)
intent_classifier.py CHANGED
@@ -1,66 +1,166 @@
1
  """
2
  Intent Classifier for Agricultural Chatbot
3
  ==========================================
4
- Detects user intent to determine context priority.
 
5
  """
6
 
7
  from typing import Dict, List, Tuple
8
  import re
9
 
10
- # Intent categories with keywords
 
 
 
11
  INTENT_PATTERNS = {
12
  "vegetation_health": {
13
- "keywords": ["yellow", "yellowing", "brown", "dying", "wilting", "healthy", "health",
14
- "crop health", "plant health", "leaf", "leaves", "chlorophyll", "green"],
15
- "sub_intents": ["chlorophyll_issue", "nutrient_deficiency", "general_health"]
 
 
 
 
 
 
 
 
 
16
  },
 
17
  "water_stress": {
18
- "keywords": ["water", "irrigation", "irrigate", "dry", "drought", "moisture",
19
- "thirsty", "watering", "rain", "wet"],
20
- "sub_intents": ["drought_stress", "overwatering", "irrigation_timing"]
 
 
 
 
 
 
 
 
21
  },
 
22
  "nutrient_status": {
23
- "keywords": ["fertilizer", "nutrient", "nitrogen", "phosphorus", "potassium",
24
- "npk", "deficiency", "feeding", "feed"],
25
- "sub_intents": ["nitrogen_deficiency", "nutrient_excess", "fertilizer_timing"]
 
 
 
 
 
 
 
 
26
  },
 
27
  "pest_disease": {
28
- "keywords": ["pest", "disease", "insect", "bug", "infection", "fungus",
29
- "blight", "rot", "spots", "holes", "eating"],
30
- "sub_intents": ["pest_damage", "fungal_disease", "bacterial_issue"]
31
- },
32
- "forecast_query": {
33
- "keywords": ["forecast", "predict", "future", "next week", "tomorrow",
34
- "will", "expect", "trend", "coming days"],
35
- "sub_intents": ["growth_forecast", "stress_prediction", "weather_impact"]
 
 
 
36
  },
 
37
  "zone_specific": {
38
- "keywords": ["area", "zone", "patch", "section", "part", "corner",
39
- "northeast", "northwest", "southeast", "southwest", "north", "south"],
40
- "sub_intents": ["zone_diagnosis", "zone_comparison"]
 
 
 
 
 
 
 
 
41
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  "action_recommendation": {
43
- "keywords": ["what should", "how to", "fix", "solve", "recommend", "advice",
44
- "help", "do", "action", "steps", "treatment"],
45
- "sub_intents": ["immediate_action", "long_term_plan"]
 
 
 
 
 
 
 
 
46
  },
 
47
  "comparison": {
48
- "keywords": ["compare", "better", "worse", "change", "changed", "difference",
49
- "last week", "before", "improvement", "decline"],
50
- "sub_intents": ["temporal_comparison", "zone_comparison"]
 
 
 
 
 
 
 
 
51
  },
 
52
  "general_query": {
53
- "keywords": ["what", "how", "why", "tell", "about", "explain"],
54
- "sub_intents": ["general_info"]
 
 
 
 
 
 
 
55
  }
56
  }
57
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  class IntentClassifier:
60
- """Classifies user queries into agricultural intent categories."""
 
 
 
61
 
62
  def __init__(self):
63
  self.patterns = INTENT_PATTERNS
 
64
 
65
  def classify(self, query: str) -> Dict:
66
  """
@@ -70,41 +170,37 @@ class IntentClassifier:
70
  {
71
  "primary_intent": str,
72
  "sub_intents": List[str],
73
- "confidence": float,
74
- "matched_keywords": List[str]
 
75
  }
76
  """
77
  query_lower = query.lower()
78
  intent_scores = {}
79
  matched_keywords = {}
80
 
81
- # Score each intent based on keyword matches
82
  for intent, config in self.patterns.items():
83
- keywords = config["keywords"]
84
- matches = [kw for kw in keywords if kw in query_lower]
85
 
86
- if matches:
87
- # Score based on number and specificity of matches
88
- score = len(matches) * 0.2
89
- # Boost for longer, more specific matches
90
- for match in matches:
91
- score += len(match) * 0.01
92
-
 
93
  intent_scores[intent] = min(score, 1.0)
94
  matched_keywords[intent] = matches
95
 
96
  if not intent_scores:
97
- # Default to general query
98
- return {
99
- "primary_intent": "general_query",
100
- "sub_intents": ["general_info"],
101
- "confidence": 0.5,
102
- "matched_keywords": []
103
- }
104
 
105
- # Get highest scoring intent
106
- primary_intent = max(intent_scores, key=intent_scores.get)
107
- confidence = intent_scores[primary_intent]
 
108
 
109
  # Get sub-intents
110
  sub_intents = self._detect_sub_intents(query_lower, primary_intent)
@@ -113,26 +209,74 @@ class IntentClassifier:
113
  "primary_intent": primary_intent,
114
  "sub_intents": sub_intents,
115
  "confidence": round(confidence, 2),
116
- "matched_keywords": matched_keywords.get(primary_intent, [])
 
117
  }
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  def _detect_sub_intents(self, query: str, primary_intent: str) -> List[str]:
120
  """Detect more specific sub-intents within the primary intent."""
121
  sub_intents = []
122
  config = self.patterns.get(primary_intent, {})
123
 
124
- # Add base sub-intents
125
  if config.get("sub_intents"):
126
  sub_intents.append(config["sub_intents"][0])
127
 
128
- # Detect additional context
129
  if "why" in query:
130
  sub_intents.append("causal_analysis")
131
- if "how much" in query or "how many" in query:
132
  sub_intents.append("quantitative")
133
  if "when" in query:
134
  sub_intents.append("temporal")
135
  if "where" in query:
136
  sub_intents.append("spatial")
 
 
 
 
137
 
138
  return sub_intents if sub_intents else ["general"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  Intent Classifier for Agricultural Chatbot
3
  ==========================================
4
+ Detects user intent with sub-intents for priority context selection.
5
+ Matching Developer Specification categories.
6
  """
7
 
8
  from typing import Dict, List, Tuple
9
  import re
10
 
11
+ # =============================================================================
12
+ # INTENT PATTERNS - Based on Developer Spec
13
+ # =============================================================================
14
+
15
  INTENT_PATTERNS = {
16
  "vegetation_health": {
17
+ "keywords": [
18
+ "yellow", "yellowing", "brown", "browning", "dying", "wilting",
19
+ "healthy", "health", "crop health", "plant health", "leaf", "leaves",
20
+ "chlorophyll", "green", "greenness", "stunted", "weak", "pale",
21
+ "ndvi", "evi", "vegetation", "biomass", "vigor", "canopy"
22
+ ],
23
+ "phrases": [
24
+ "why is my crop", "is my crop healthy", "crop looks",
25
+ "plants look", "leaves turning", "plant dying"
26
+ ],
27
+ "sub_intents": ["chlorophyll_issue", "nutrient_deficiency", "general_health"],
28
+ "priority": 1
29
  },
30
+
31
  "water_stress": {
32
+ "keywords": [
33
+ "water", "irrigation", "irrigate", "dry", "drought", "moisture",
34
+ "thirsty", "watering", "rain", "wet", "smi", "ndwi", "soil moisture",
35
+ "dehydrated", "wilt", "drooping", "crispy", "parched"
36
+ ],
37
+ "phrases": [
38
+ "need water", "should i water", "need irrigation", "drought stress",
39
+ "when to irrigate", "soil is dry", "field is dry"
40
+ ],
41
+ "sub_intents": ["drought_stress", "overwatering", "irrigation_timing"],
42
+ "priority": 2
43
  },
44
+
45
  "nutrient_status": {
46
+ "keywords": [
47
+ "fertilizer", "nutrient", "nitrogen", "phosphorus", "potassium",
48
+ "npk", "deficiency", "feeding", "feed", "ndre", "reci", "mcari",
49
+ "urea", "dap", "mop", "manure", "compost", "micronutrient"
50
+ ],
51
+ "phrases": [
52
+ "need fertilizer", "nutrient deficiency", "should i fertilize",
53
+ "lacking nutrients", "nitrogen deficiency", "fertilizer amount"
54
+ ],
55
+ "sub_intents": ["nitrogen_deficiency", "nutrient_excess", "fertilizer_timing"],
56
+ "priority": 3
57
  },
58
+
59
  "pest_disease": {
60
+ "keywords": [
61
+ "pest", "disease", "insect", "bug", "infection", "fungus",
62
+ "blight", "rot", "spots", "holes", "eating", "aphid", "borer",
63
+ "rust", "mildew", "virus", "bacteria", "infestation", "damage"
64
+ ],
65
+ "phrases": [
66
+ "pest attack", "disease problem", "insect damage", "fungal infection",
67
+ "what is eating", "spots on leaves", "pest risk"
68
+ ],
69
+ "sub_intents": ["pest_damage", "fungal_disease", "bacterial_issue", "viral_disease"],
70
+ "priority": 4
71
  },
72
+
73
  "zone_specific": {
74
+ "keywords": [
75
+ "area", "zone", "patch", "section", "part", "corner", "side",
76
+ "northeast", "northwest", "southeast", "southwest", "north", "south",
77
+ "east", "west", "center", "edge", "boundary", "specific"
78
+ ],
79
+ "phrases": [
80
+ "which area", "which zone", "which part", "where is the problem",
81
+ "affected area", "problem zone", "specific area"
82
+ ],
83
+ "sub_intents": ["zone_diagnosis", "zone_comparison", "spatial_query"],
84
+ "priority": 5
85
  },
86
+
87
+ "forecast_query": {
88
+ "keywords": [
89
+ "forecast", "predict", "prediction", "future", "next week", "tomorrow",
90
+ "will", "expect", "trend", "coming days", "upcoming", "projection",
91
+ "growth", "yield", "estimate", "outlook"
92
+ ],
93
+ "phrases": [
94
+ "what will happen", "next week", "in the future", "will my crop",
95
+ "expected yield", "growth forecast", "weather forecast"
96
+ ],
97
+ "sub_intents": ["growth_forecast", "stress_prediction", "weather_impact", "yield_forecast"],
98
+ "priority": 6
99
+ },
100
+
101
  "action_recommendation": {
102
+ "keywords": [
103
+ "what should", "how to", "fix", "solve", "recommend", "advice",
104
+ "help", "do", "action", "steps", "treatment", "remedy", "solution",
105
+ "best practice", "suggestion", "improve"
106
+ ],
107
+ "phrases": [
108
+ "what should i do", "how do i fix", "how to solve", "recommend",
109
+ "give me advice", "best action", "immediate action"
110
+ ],
111
+ "sub_intents": ["immediate_action", "long_term_plan", "preventive_action"],
112
+ "priority": 7
113
  },
114
+
115
  "comparison": {
116
+ "keywords": [
117
+ "compare", "comparison", "better", "worse", "change", "changed",
118
+ "difference", "last week", "before", "improvement", "decline",
119
+ "progress", "regression", "historical", "trend"
120
+ ],
121
+ "phrases": [
122
+ "compared to", "better than", "worse than", "has it improved",
123
+ "how has it changed", "over time", "last month"
124
+ ],
125
+ "sub_intents": ["temporal_comparison", "zone_comparison", "historical_analysis"],
126
+ "priority": 8
127
  },
128
+
129
  "general_query": {
130
+ "keywords": [
131
+ "what", "how", "why", "tell", "about", "explain", "hello", "hi",
132
+ "information", "details", "overview", "status", "summary"
133
+ ],
134
+ "phrases": [
135
+ "tell me about", "what is", "how does", "explain"
136
+ ],
137
+ "sub_intents": ["general_info"],
138
+ "priority": 9
139
  }
140
  }
141
 
142
+ # Hindi/regional language keywords (common agricultural terms)
143
+ REGIONAL_KEYWORDS = {
144
+ "vegetation_health": ["पीला", "पत्ते", "सूखा", "मुरझाना"],
145
+ "water_stress": ["पानी", "सिंचाई", "सूखा"],
146
+ "nutrient_status": ["खाद", "यूरिया", "उर्वरक"],
147
+ "pest_disease": ["कीट", "रोग", "कीड़ा"]
148
+ }
149
+
150
+
151
+ # =============================================================================
152
+ # INTENT CLASSIFIER
153
+ # =============================================================================
154
 
155
  class IntentClassifier:
156
+ """
157
+ Classifies user queries into agricultural intent categories.
158
+ Uses keyword matching, phrase matching, and confidence scoring.
159
+ """
160
 
161
  def __init__(self):
162
  self.patterns = INTENT_PATTERNS
163
+ self.regional = REGIONAL_KEYWORDS
164
 
165
  def classify(self, query: str) -> Dict:
166
  """
 
170
  {
171
  "primary_intent": str,
172
  "sub_intents": List[str],
173
+ "confidence": float (0.0-1.0),
174
+ "matched_keywords": List[str],
175
+ "all_intents": List[Tuple[str, float]] # All detected intents with scores
176
  }
177
  """
178
  query_lower = query.lower()
179
  intent_scores = {}
180
  matched_keywords = {}
181
 
182
+ # Score each intent
183
  for intent, config in self.patterns.items():
184
+ score, matches = self._score_intent(query_lower, config)
 
185
 
186
+ # Also check regional keywords
187
+ if intent in self.regional:
188
+ for kw in self.regional[intent]:
189
+ if kw in query:
190
+ score += 0.3
191
+ matches.append(kw)
192
+
193
+ if score > 0:
194
  intent_scores[intent] = min(score, 1.0)
195
  matched_keywords[intent] = matches
196
 
197
  if not intent_scores:
198
+ return self._default_response()
 
 
 
 
 
 
199
 
200
+ # Sort intents by score
201
+ sorted_intents = sorted(intent_scores.items(), key=lambda x: x[1], reverse=True)
202
+ primary_intent = sorted_intents[0][0]
203
+ confidence = sorted_intents[0][1]
204
 
205
  # Get sub-intents
206
  sub_intents = self._detect_sub_intents(query_lower, primary_intent)
 
209
  "primary_intent": primary_intent,
210
  "sub_intents": sub_intents,
211
  "confidence": round(confidence, 2),
212
+ "matched_keywords": matched_keywords.get(primary_intent, []),
213
+ "all_intents": [(intent, round(score, 2)) for intent, score in sorted_intents[:3]]
214
  }
215
 
216
+ def _score_intent(self, query: str, config: Dict) -> Tuple[float, List[str]]:
217
+ """Calculate score for a single intent."""
218
+ score = 0.0
219
+ matches = []
220
+
221
+ keywords = config.get("keywords", [])
222
+ phrases = config.get("phrases", [])
223
+
224
+ # Check keyword matches
225
+ for kw in keywords:
226
+ if kw in query:
227
+ score += 0.15
228
+ matches.append(kw)
229
+ # Bonus for exact word match (not substring)
230
+ if re.search(rf'\b{re.escape(kw)}\b', query):
231
+ score += 0.05
232
+
233
+ # Check phrase matches (higher score)
234
+ for phrase in phrases:
235
+ if phrase in query:
236
+ score += 0.35
237
+ matches.append(phrase)
238
+
239
+ # Boost for multiple matches
240
+ if len(matches) >= 3:
241
+ score += 0.1
242
+
243
+ return score, matches
244
+
245
  def _detect_sub_intents(self, query: str, primary_intent: str) -> List[str]:
246
  """Detect more specific sub-intents within the primary intent."""
247
  sub_intents = []
248
  config = self.patterns.get(primary_intent, {})
249
 
250
+ # Add base sub-intent
251
  if config.get("sub_intents"):
252
  sub_intents.append(config["sub_intents"][0])
253
 
254
+ # Detect question type
255
  if "why" in query:
256
  sub_intents.append("causal_analysis")
257
+ if "how much" in query or "how many" in query or "quantity" in query:
258
  sub_intents.append("quantitative")
259
  if "when" in query:
260
  sub_intents.append("temporal")
261
  if "where" in query:
262
  sub_intents.append("spatial")
263
+ if "should" in query or "recommend" in query:
264
+ sub_intents.append("recommendation_needed")
265
+ if "urgent" in query or "immediately" in query or "emergency" in query:
266
+ sub_intents.append("urgent")
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 {
273
+ "primary_intent": "general_query",
274
+ "sub_intents": ["general_info"],
275
+ "confidence": 0.4,
276
+ "matched_keywords": [],
277
+ "all_intents": [("general_query", 0.4)]
278
+ }
279
+
280
+ def get_priority_for_intent(self, intent: str) -> int:
281
+ """Get priority number for an intent."""
282
+ return self.patterns.get(intent, {}).get("priority", 9)
models.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic Models for Agricultural Chatbot
3
+ =========================================
4
+ Structured data models matching the Developer Specification.
5
+ """
6
+
7
+ from pydantic import BaseModel, Field
8
+ from typing import Optional, List, Dict, Any
9
+ from datetime import datetime
10
+
11
+
12
+ # =============================================================================
13
+ # VEGETATION INDICES MODELS
14
+ # =============================================================================
15
+
16
+ class IndexValue(BaseModel):
17
+ """Single vegetation index with value and interpretation."""
18
+ current: Optional[float] = None
19
+ trend_7d: Optional[float] = None
20
+ trend_30d: Optional[float] = None
21
+ min: Optional[float] = None
22
+ max: Optional[float] = None
23
+ interpretation: Optional[str] = None
24
+ zone_values: Optional[Dict[str, float]] = None # NW, NE, SW, SE
25
+
26
+
27
+ class VegetationIndices(BaseModel):
28
+ """Complete vegetation indices data."""
29
+ NDVI: Optional[IndexValue] = None
30
+ EVI: Optional[IndexValue] = None
31
+ NDRE: Optional[IndexValue] = None
32
+ RECI: Optional[IndexValue] = None
33
+ NDWI: Optional[IndexValue] = None
34
+ SMI: Optional[IndexValue] = None
35
+ PSRI: Optional[IndexValue] = None
36
+ PRI: Optional[IndexValue] = None
37
+ MCARI: Optional[IndexValue] = None
38
+ SOMI: Optional[IndexValue] = None
39
+ SFI: Optional[IndexValue] = None
40
+
41
+
42
+ # =============================================================================
43
+ # SAR DATA MODELS
44
+ # =============================================================================
45
+
46
+ class SARBands(BaseModel):
47
+ """SAR band data from Sentinel-1."""
48
+ VV: Optional[float] = None
49
+ VH: Optional[float] = None
50
+ VV_VH_ratio: Optional[float] = None
51
+ VV_trend: Optional[str] = None # "increasing", "decreasing", "stable"
52
+ VH_trend: Optional[str] = None
53
+ interpretation: Optional[str] = None
54
+
55
+
56
+ # =============================================================================
57
+ # CLUSTERING/STRESS MODELS
58
+ # =============================================================================
59
+
60
+ class ClusterStats(BaseModel):
61
+ """Statistics for a stress cluster."""
62
+ cluster_id: int
63
+ num_patches: int
64
+ percentage: float # % of field
65
+ stress_score_mean: float
66
+ stress_score_std: Optional[float] = None
67
+ dominant_location: Optional[str] = None # "northeast", "southwest", etc.
68
+ spectral_signature: Optional[Dict[str, float]] = None
69
+
70
+
71
+ class AnomalyPatch(BaseModel):
72
+ """Detected anomaly patch."""
73
+ patch_id: int
74
+ location: Optional[str] = None
75
+ anomaly_type: str # "spectral_outlier", "temporal_anomaly", etc.
76
+ stress_score: float
77
+ coordinates: Optional[List[float]] = None
78
+
79
+
80
+ class ClusteringData(BaseModel):
81
+ """All clustering and anomaly data."""
82
+ clusters: List[ClusterStats] = []
83
+ stressed_clusters: List[ClusterStats] = []
84
+ anomalies_detected: int = 0
85
+ anomaly_patches: List[AnomalyPatch] = []
86
+
87
+
88
+ # =============================================================================
89
+ # WEATHER MODELS
90
+ # =============================================================================
91
+
92
+ class WeatherData(BaseModel):
93
+ """Weather information."""
94
+ avg_temp_max: Optional[float] = None
95
+ avg_temp_min: Optional[float] = None
96
+ heat_stress_days: Optional[int] = None
97
+ total_precipitation_mm: Optional[float] = None
98
+ avg_humidity: Optional[float] = None
99
+ consecutive_dry_days: Optional[int] = None
100
+ heat_stress: bool = False
101
+ drought_stress: bool = False
102
+
103
+
104
+ class WeatherForecast(BaseModel):
105
+ """Weather forecast data."""
106
+ rain_expected: bool = False
107
+ rain_mm: Optional[float] = None
108
+ temperature_trend: Optional[str] = None
109
+
110
+
111
+ # =============================================================================
112
+ # FARMER CONTEXT MODELS
113
+ # =============================================================================
114
+
115
+ class FarmerActions(BaseModel):
116
+ """Farmer's recent actions."""
117
+ last_irrigation: Optional[str] = None # ISO date
118
+ days_since_irrigation: Optional[int] = None
119
+ last_fertilizer: Optional[str] = None
120
+ days_since_fertilizer: Optional[int] = None
121
+ last_spraying: Optional[str] = None
122
+ notes: List[str] = []
123
+
124
+
125
+ class FarmerProfile(BaseModel):
126
+ """Farmer profile information."""
127
+ role: Optional[str] = None
128
+ years_farming: Optional[int] = None
129
+ irrigation_method: Optional[str] = None
130
+ farming_goal: Optional[str] = None
131
+
132
+
133
+ # =============================================================================
134
+ # PRIORITY CONTEXT MODELS
135
+ # =============================================================================
136
+
137
+ class Priority1Context(BaseModel):
138
+ """Primary evidence - most diagnostic data."""
139
+ NDVI: Optional[Dict] = None
140
+ EVI: Optional[Dict] = None
141
+ NDRE: Optional[Dict] = None
142
+ RECI: Optional[Dict] = None
143
+ temporal_trends: Optional[Dict] = None
144
+
145
+
146
+ class Priority2Context(BaseModel):
147
+ """Supporting evidence - clusters, anomalies."""
148
+ clustering: Optional[Dict] = None
149
+ anomalies: Optional[Dict] = None
150
+ PSRI: Optional[Dict] = None
151
+
152
+
153
+ class Priority3Context(BaseModel):
154
+ """Causal factors - weather, soil, bands."""
155
+ weather: Optional[Dict] = None
156
+ SMI: Optional[Dict] = None
157
+ band_values: Optional[Dict] = None
158
+
159
+
160
+ class Priority4Context(BaseModel):
161
+ """Validation - SAR, previous analysis, farmer actions."""
162
+ SAR: Optional[Dict] = None
163
+ previous_analysis: Optional[Dict] = None
164
+ farmer_actions: Optional[Dict] = None
165
+
166
+
167
+ # =============================================================================
168
+ # REASONING STAGE MODELS
169
+ # =============================================================================
170
+
171
+ class ClaimStage(BaseModel):
172
+ """Stage 3A: Initial claim output."""
173
+ initial_claim: str
174
+ hypothesis: str
175
+ evidence_cited: List[str] = []
176
+ confidence: float
177
+ uncertainties: List[str] = []
178
+
179
+
180
+ class ValidateStage(BaseModel):
181
+ """Stage 3B: Validation output."""
182
+ validation_result: str # "confirmed", "weakened", "neutral"
183
+ confidence_updated: float
184
+ spatial_notes: Optional[str] = None
185
+ new_evidence_summary: Optional[str] = None
186
+
187
+
188
+ class ContradictStage(BaseModel):
189
+ """Stage 3C: Contradiction output."""
190
+ contradiction_found: bool
191
+ contradicting_evidence: List[str] = []
192
+ alternative_hypothesis: Optional[str] = None
193
+ alternative_confidence: float = 0.0
194
+ reasoning: Optional[str] = None
195
+
196
+
197
+ class ConfirmStage(BaseModel):
198
+ """Stage 3D: Confirmation output."""
199
+ final_diagnosis: str
200
+ confidence: float
201
+ causal_chain: Optional[str] = None
202
+ root_cause: Optional[str] = None
203
+ symptoms: List[str] = []
204
+ evidence_summary: Optional[Dict[str, List[str]]] = None
205
+ recommendation: Optional[str] = None
206
+
207
+
208
+ # =============================================================================
209
+ # REASONING TRACE MODELS
210
+ # =============================================================================
211
+
212
+ class StageTrace(BaseModel):
213
+ """Trace for a single reasoning stage."""
214
+ hypothesis: Optional[str] = None
215
+ result: Optional[str] = None
216
+ found: Optional[bool] = None
217
+ alternative: Optional[str] = None
218
+ final: Optional[str] = None
219
+ confidence: float
220
+ context_used: List[str]
221
+
222
+
223
+ class ReasoningTrace(BaseModel):
224
+ """Complete reasoning trace for transparency."""
225
+ intent_detected: str
226
+ intent_confidence: float
227
+ sub_intents: List[str]
228
+ stages: Dict[str, StageTrace]
229
+ causal_chain: Optional[str] = None
230
+ evidence_summary: Dict[str, List[str]]
231
+
232
+
233
+ class ContextPriorityUsed(BaseModel):
234
+ """Which context was used at each priority level."""
235
+ priority_1: List[str] = []
236
+ priority_2: List[str] = []
237
+ priority_3: List[str] = []
238
+ priority_4: List[str] = []
239
+
240
+
241
+ # =============================================================================
242
+ # API REQUEST/RESPONSE MODELS
243
+ # =============================================================================
244
+
245
+ class ChatRequest(BaseModel):
246
+ """Chat request from Flutter app."""
247
+ session_id: str
248
+ message: str
249
+ user_id: Optional[str] = None
250
+ field_context: Optional[Dict[str, Any]] = None
251
+ field_name: Optional[str] = None # Optional specific field
252
+
253
+
254
+ class ResponseContent(BaseModel):
255
+ """Main response content."""
256
+ message: str
257
+ confidence: float
258
+ diagnosis: Optional[str] = None
259
+
260
+
261
+ class ChatResponse(BaseModel):
262
+ """Full chat response matching spec."""
263
+ response: ResponseContent
264
+ session_id: str
265
+ message_id: str
266
+ timestamp: str
267
+ reasoning_trace: Optional[ReasoningTrace] = None
268
+ context_priority_used: Optional[ContextPriorityUsed] = None
269
+ suggested_followups: List[str] = []
270
+
271
+
272
+ # =============================================================================
273
+ # FULL SATELLITE CONTEXT
274
+ # =============================================================================
275
+
276
+ class FullSatelliteContext(BaseModel):
277
+ """Complete satellite context for reasoning."""
278
+ field_info: Dict[str, Any] = {}
279
+ vegetation_indices: Optional[VegetationIndices] = None
280
+ sar_bands: Optional[SARBands] = None
281
+ clustering: Optional[ClusteringData] = None
282
+ weather: Optional[WeatherData] = None
283
+ weather_forecast: Optional[WeatherForecast] = None
284
+ farmer_actions: Optional[FarmerActions] = None
285
+ previous_analysis: Optional[Dict[str, Any]] = None
286
+ temporal_trends: Optional[Dict[str, Any]] = None
287
+ band_values: Optional[Dict[str, float]] = None
288
+
289
+
290
+ # =============================================================================
291
+ # HELPER FUNCTIONS
292
+ # =============================================================================
293
+
294
+ def create_empty_response(session_id: str, message: str = "Unable to process") -> ChatResponse:
295
+ """Create a fallback empty response."""
296
+ return ChatResponse(
297
+ response=ResponseContent(
298
+ message=message,
299
+ confidence=0.0,
300
+ diagnosis=None
301
+ ),
302
+ session_id=session_id,
303
+ message_id="",
304
+ timestamp=datetime.now().isoformat(),
305
+ reasoning_trace=None,
306
+ context_priority_used=None,
307
+ suggested_followups=[]
308
+ )
priority_mapper.py CHANGED
@@ -2,71 +2,90 @@
2
  Priority Context Mapper for Agricultural Chatbot
3
  =================================================
4
  Maps detected intents to prioritized context selection.
 
5
  """
6
 
7
  from typing import Dict, List, Any, Optional
8
 
9
- # Intent to Context Priority Mapping
 
 
 
10
  INTENT_CONTEXT_PRIORITIES = {
11
  "vegetation_health": {
12
  "priority_1": ["NDVI", "EVI", "NDRE", "RECI", "temporal_trends.NDVI"],
13
- "priority_2": ["clustering.stressed_patches", "anomalies", "PSRI"],
14
- "priority_3": ["weather.temperature", "SMI", "B05", "B08"],
15
- "priority_4": ["SAR.VV", "previous_analysis", "farmer_actions"]
16
  },
 
17
  "water_stress": {
18
- "priority_1": ["SMI", "NDWI", "SAR.VV", "SAR.VH"],
19
  "priority_2": ["temporal_trends.SMI", "weather.precipitation", "weather.evapotranspiration"],
20
- "priority_3": ["NDVI", "clustering.moisture_clusters", "B11", "B12"],
21
  "priority_4": ["farmer_actions.irrigation", "forecast.rain", "previous_analysis"]
22
  },
 
23
  "nutrient_status": {
24
- "priority_1": ["NDRE", "RECI", "MCARI", "B05", "B06", "B07"],
25
  "priority_2": ["NDVI", "EVI", "temporal_trends.NDRE"],
26
- "priority_3": ["SMI", "SFI", "clustering.nutrient_clusters"],
27
  "priority_4": ["farmer_actions.fertilizer", "weather", "previous_analysis"]
28
  },
 
29
  "pest_disease": {
30
- "priority_1": ["anomalies", "PSRI", "PRI", "spatial_patterns.hotspots"],
31
- "priority_2": ["NDVI", "temporal_trends.sudden_changes", "clustering.outliers"],
32
- "priority_3": ["weather.humidity", "B04", "B05"],
33
  "priority_4": ["farmer_actions.spraying", "previous_analysis", "historical_issues"]
34
  },
 
35
  "zone_specific": {
36
- "priority_1": ["clustering.zone_stats", "patch_assignments", "spatial_embeddings"],
37
- "priority_2": ["anomalies.in_zone", "all_indices.zone_values"],
38
- "priority_3": ["temporal_trends.zone_specific"],
39
  "priority_4": ["previous_analysis.zone_notes", "farmer_actions.zone_specific"]
40
  },
 
41
  "forecast_query": {
42
- "priority_1": ["forecast.predictions", "temporal_trends.all", "weather.forecast"],
43
- "priority_2": ["NDVI", "SMI", "current_stress_level"],
44
- "priority_3": ["historical_patterns", "growth_stage"],
45
  "priority_4": ["farmer_actions.planned", "previous_analysis"]
46
  },
 
47
  "action_recommendation": {
48
- "priority_1": ["stress_summary", "NDVI", "SMI", "anomalies"],
49
- "priority_2": ["weather.current", "weather.forecast"],
50
- "priority_3": ["clustering.priority_zones", "temporal_trends"],
51
  "priority_4": ["farmer_actions", "previous_analysis", "recommendations_history"]
52
  },
 
53
  "comparison": {
54
- "priority_1": ["temporal_trends.all", "historical.NDVI", "historical.SMI"],
55
  "priority_2": ["change_detection", "improvement_metrics"],
56
  "priority_3": ["weather.historical", "farmer_actions.historical"],
57
  "priority_4": ["previous_analysis", "baseline_values"]
58
  },
 
59
  "general_query": {
60
- "priority_1": ["NDVI", "stress_summary", "weather.current"],
61
- "priority_2": ["SMI", "anomalies", "clustering.summary"],
62
- "priority_3": ["temporal_trends", "forecast"],
63
  "priority_4": ["farmer_actions", "previous_analysis"]
64
  }
65
  }
66
 
67
 
 
 
 
 
68
  class PriorityContextMapper:
69
- """Maps intents to prioritized context for selective retrieval."""
 
 
 
70
 
71
  def __init__(self):
72
  self.priority_map = INTENT_CONTEXT_PRIORITIES
@@ -86,7 +105,7 @@ class PriorityContextMapper:
86
 
87
  Args:
88
  intent: Detected intent
89
- full_context: Complete context data
90
  priority_levels: Which priority levels to include
91
 
92
  Returns:
@@ -120,7 +139,7 @@ class PriorityContextMapper:
120
  for path in field_paths:
121
  value = self._get_nested_value(context, path)
122
  if value is not None:
123
- # Use last part of path as key for simplicity
124
  key = path.split(".")[-1]
125
  extracted[key] = value
126
 
@@ -128,12 +147,24 @@ class PriorityContextMapper:
128
 
129
  def _get_nested_value(self, data: Dict, path: str) -> Any:
130
  """Get nested value using dot notation (e.g., 'weather.temperature')."""
 
 
 
131
  keys = path.split(".")
132
  current = data
133
 
134
  for key in keys:
135
- if isinstance(current, dict) and key in current:
136
- current = current[key]
 
 
 
 
 
 
 
 
 
137
  else:
138
  return None
139
 
@@ -149,17 +180,70 @@ class PriorityContextMapper:
149
 
150
  Returns:
151
  {
152
- "claim_context": {...}, # Priority 1
153
- "validate_context": {...}, # Priority 2
154
- "contradict_context": {...}, # Priority 3
155
- "confirm_context": {...} # Priority 4
156
  }
157
  """
158
  priority_context = self.extract_priority_context(intent, full_context)
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  return {
161
- "claim_context": priority_context.get("priority_1", {}),
162
- "validate_context": priority_context.get("priority_2", {}),
163
- "contradict_context": priority_context.get("priority_3", {}),
164
- "confirm_context": priority_context.get("priority_4", {})
165
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  Priority Context Mapper for Agricultural Chatbot
3
  =================================================
4
  Maps detected intents to prioritized context selection.
5
+ Following Developer Specification exactly.
6
  """
7
 
8
  from typing import Dict, List, Any, Optional
9
 
10
+ # =============================================================================
11
+ # INTENT TO CONTEXT PRIORITY MAPPING (From Developer Spec)
12
+ # =============================================================================
13
+
14
  INTENT_CONTEXT_PRIORITIES = {
15
  "vegetation_health": {
16
  "priority_1": ["NDVI", "EVI", "NDRE", "RECI", "temporal_trends.NDVI"],
17
+ "priority_2": ["clustering.stressed_patches", "anomalies", "PSRI", "PRI"],
18
+ "priority_3": ["weather.temperature", "SMI", "B05", "B08", "weather.heat_stress"],
19
+ "priority_4": ["SAR.VV", "SAR.VH", "previous_analysis", "farmer_actions"]
20
  },
21
+
22
  "water_stress": {
23
+ "priority_1": ["SMI", "NDWI", "SAR.VV", "SAR.VH", "soil_indicators.moisture"],
24
  "priority_2": ["temporal_trends.SMI", "weather.precipitation", "weather.evapotranspiration"],
25
+ "priority_3": ["NDVI", "clustering.moisture_clusters", "B11", "B12", "sentinel2_bands.B11"],
26
  "priority_4": ["farmer_actions.irrigation", "forecast.rain", "previous_analysis"]
27
  },
28
+
29
  "nutrient_status": {
30
+ "priority_1": ["NDRE", "RECI", "MCARI", "B05", "B06", "B07", "sentinel2_bands.B05"],
31
  "priority_2": ["NDVI", "EVI", "temporal_trends.NDRE"],
32
+ "priority_3": ["SMI", "SFI", "SOMI", "clustering.nutrient_clusters", "soil_indicators.fertility"],
33
  "priority_4": ["farmer_actions.fertilizer", "weather", "previous_analysis"]
34
  },
35
+
36
  "pest_disease": {
37
+ "priority_1": ["anomalies", "PSRI", "PRI", "spatial_patterns.hotspots", "clustering.outliers"],
38
+ "priority_2": ["NDVI", "temporal_trends.sudden_changes", "clustering.stressed_patches"],
39
+ "priority_3": ["weather.humidity", "weather.temperature", "B04", "B05"],
40
  "priority_4": ["farmer_actions.spraying", "previous_analysis", "historical_issues"]
41
  },
42
+
43
  "zone_specific": {
44
+ "priority_1": ["clustering.zone_stats", "patch_assignments", "spatial_embeddings", "clustering.clusters"],
45
+ "priority_2": ["anomalies.in_zone", "vegetation_indices", "all_indices.zone_values"],
46
+ "priority_3": ["temporal_trends.zone_specific", "temporal_trends"],
47
  "priority_4": ["previous_analysis.zone_notes", "farmer_actions.zone_specific"]
48
  },
49
+
50
  "forecast_query": {
51
+ "priority_1": ["forecast.predictions", "temporal_trends", "weather.forecast", "weather_data"],
52
+ "priority_2": ["NDVI", "SMI", "current_stress_level", "health_summary"],
53
+ "priority_3": ["historical_patterns", "growth_stage", "clustering"],
54
  "priority_4": ["farmer_actions.planned", "previous_analysis"]
55
  },
56
+
57
  "action_recommendation": {
58
+ "priority_1": ["health_summary", "NDVI", "SMI", "anomalies", "stressed_patches"],
59
+ "priority_2": ["weather", "weather.forecast", "clustering.priority_zones"],
60
+ "priority_3": ["temporal_trends", "soil_indicators"],
61
  "priority_4": ["farmer_actions", "previous_analysis", "recommendations_history"]
62
  },
63
+
64
  "comparison": {
65
+ "priority_1": ["temporal_trends", "historical.NDVI", "historical.SMI"],
66
  "priority_2": ["change_detection", "improvement_metrics"],
67
  "priority_3": ["weather.historical", "farmer_actions.historical"],
68
  "priority_4": ["previous_analysis", "baseline_values"]
69
  },
70
+
71
  "general_query": {
72
+ "priority_1": ["NDVI", "health_summary", "weather", "field_info"],
73
+ "priority_2": ["SMI", "anomalies", "clustering.summary", "vegetation_indices"],
74
+ "priority_3": ["temporal_trends", "forecast", "soil_indicators"],
75
  "priority_4": ["farmer_actions", "previous_analysis"]
76
  }
77
  }
78
 
79
 
80
+ # =============================================================================
81
+ # PRIORITY CONTEXT MAPPER
82
+ # =============================================================================
83
+
84
  class PriorityContextMapper:
85
+ """
86
+ Maps intents to prioritized context for selective retrieval.
87
+ Key principle: NOT all context at once - priority-based selection.
88
+ """
89
 
90
  def __init__(self):
91
  self.priority_map = INTENT_CONTEXT_PRIORITIES
 
105
 
106
  Args:
107
  intent: Detected intent
108
+ full_context: Complete context data from aggregator
109
  priority_levels: Which priority levels to include
110
 
111
  Returns:
 
139
  for path in field_paths:
140
  value = self._get_nested_value(context, path)
141
  if value is not None:
142
+ # Use last part of path as key
143
  key = path.split(".")[-1]
144
  extracted[key] = value
145
 
 
147
 
148
  def _get_nested_value(self, data: Dict, path: str) -> Any:
149
  """Get nested value using dot notation (e.g., 'weather.temperature')."""
150
+ if not data or not path:
151
+ return None
152
+
153
  keys = path.split(".")
154
  current = data
155
 
156
  for key in keys:
157
+ if isinstance(current, dict):
158
+ # Try exact match first
159
+ if key in current:
160
+ current = current[key]
161
+ # Try case-insensitive match
162
+ elif key.upper() in current:
163
+ current = current[key.upper()]
164
+ elif key.lower() in current:
165
+ current = current[key.lower()]
166
+ else:
167
+ return None
168
  else:
169
  return None
170
 
 
180
 
181
  Returns:
182
  {
183
+ "claim_context": {...}, # Priority 1 - for initial hypothesis
184
+ "validate_context": {...}, # Priority 2 - supporting evidence
185
+ "contradict_context": {...}, # Priority 3 - causal factors
186
+ "confirm_context": {...} # Priority 4 - validation
187
  }
188
  """
189
  priority_context = self.extract_priority_context(intent, full_context)
190
 
191
+ # Also add full vegetation indices if available for easier access
192
+ veg = full_context.get("vegetation_indices", {})
193
+
194
+ claim = priority_context.get("priority_1", {})
195
+ validate = priority_context.get("priority_2", {})
196
+ contradict = priority_context.get("priority_3", {})
197
+ confirm = priority_context.get("priority_4", {})
198
+
199
+ # Enrich with direct index access if not already present
200
+ if veg:
201
+ for idx in ["NDVI", "EVI", "NDRE", "SMI", "NDWI"]:
202
+ if idx in veg and idx not in claim:
203
+ claim[idx] = veg[idx]
204
+
205
+ # Add field info to claim context
206
+ field_info = full_context.get("field_info", {})
207
+ if field_info:
208
+ claim["crop_type"] = field_info.get("crop_type")
209
+ claim["area_acres"] = field_info.get("area_acres")
210
+
211
+ # Add SAR data to confirm context
212
+ sar = full_context.get("sar_bands", {})
213
+ if sar and "VV" not in confirm:
214
+ confirm["SAR"] = sar
215
+
216
+ # Add farmer actions if available
217
+ farmer = full_context.get("farmer_actions", {})
218
+ if farmer:
219
+ confirm["farmer_actions"] = farmer
220
+
221
+ # Add previous analysis
222
+ prev = full_context.get("previous_analysis", {})
223
+ if prev:
224
+ confirm["previous_analysis"] = prev
225
+
226
  return {
227
+ "claim_context": claim,
228
+ "validate_context": validate,
229
+ "contradict_context": contradict,
230
+ "confirm_context": confirm
231
  }
232
+
233
+ def get_context_for_stage(
234
+ self,
235
+ stage: str,
236
+ intent: str,
237
+ full_context: Dict[str, Any]
238
+ ) -> Dict[str, Any]:
239
+ """Get context for a specific reasoning stage."""
240
+ staged = self.build_staged_context(intent, full_context)
241
+
242
+ stage_map = {
243
+ "claim": "claim_context",
244
+ "validate": "validate_context",
245
+ "contradict": "contradict_context",
246
+ "confirm": "confirm_context"
247
+ }
248
+
249
+ return staged.get(stage_map.get(stage, "claim_context"), {})
prompts.py CHANGED
@@ -2,129 +2,154 @@
2
  LLM Prompts for Multi-Stage Reasoning
3
  ======================================
4
  Prompts for each stage: Claim → Validate → Contradict → Confirm
 
5
  """
6
 
7
  # =============================================================================
8
  # SYSTEM PROMPT (Base context)
9
  # =============================================================================
10
 
11
- SYSTEM_PROMPT = """You are AGROW AI, an expert agricultural advisor for farmers in India.
12
 
13
- You specialize in:
14
- - Satellite imagery interpretation (Sentinel-2, SAR data)
15
- - Vegetation indices analysis (NDVI, NDRE, EVI, SMI, etc.)
16
- - Crop stress diagnosis (water, nutrient, pest/disease)
17
  - Climate-smart farming recommendations
18
- - Regional crop knowledge (wheat, rice, cotton, sugarcane, pulses, etc.)
19
-
20
- Communication Style:
21
- - Use simple, practical language farmers can understand
22
- - Mention specific numbers from data when available
23
- - Give actionable recommendations
24
- - Reference local conditions when possible
 
25
  - Be concise but thorough
26
 
27
- When you lack specific data, acknowledge it and give general guidance."""
 
 
 
 
 
 
28
 
29
  # =============================================================================
30
- # STAGE 1: CLAIM PROMPT
31
  # =============================================================================
32
 
33
  CLAIM_PROMPT = """You are analyzing agricultural satellite data to diagnose crop issues.
34
 
35
  USER QUERY: {query}
36
 
37
- AVAILABLE EVIDENCE (Primary indicators only):
38
  {priority_1_context}
39
 
40
  Based ONLY on this primary evidence:
41
  1. State your initial hypothesis about what's happening
42
- 2. Cite specific values that support your hypothesis
43
  3. Rate your confidence (0.0 to 1.0)
 
44
 
45
- Respond in JSON format:
46
  {{
47
- "initial_claim": "Your hypothesis in 1-2 sentences",
48
- "hypothesis": "single_word_label",
49
- "evidence_cited": ["index1: value", "index2: value"],
50
- "confidence": 0.X,
51
- "uncertainties": ["what you're unsure about"]
52
- }}"""
 
 
53
 
54
  # =============================================================================
55
- # STAGE 2: VALIDATE PROMPT
56
  # =============================================================================
57
 
58
  VALIDATE_PROMPT = """You previously hypothesized: {previous_hypothesis}
59
  Initial confidence: {previous_confidence}
60
 
61
- ADDITIONAL SUPPORTING EVIDENCE:
62
  {priority_2_context}
63
 
64
- Does this new evidence:
65
- 1. CONFIRM your hypothesis? (increases confidence)
66
- 2. WEAKEN your hypothesis? (decreases confidence)
67
- 3. Add SPATIAL context? (where is the issue concentrated?)
68
 
69
- Respond in JSON format:
70
  {{
71
  "validation_result": "confirmed|weakened|neutral",
72
- "confidence_updated": 0.X,
73
- "spatial_notes": "location details if any",
74
- "reasoning": "why confidence changed"
75
- }}"""
 
 
76
 
77
  # =============================================================================
78
- # STAGE 3: CONTRADICT PROMPT
79
  # =============================================================================
80
 
81
  CONTRADICT_PROMPT = """CURRENT HYPOTHESIS: {hypothesis} (confidence: {confidence})
82
 
83
- YOUR TASK: Actively look for evidence that CONTRADICTS this hypothesis.
 
84
 
85
- ALTERNATIVE CAUSAL FACTORS TO CONSIDER:
86
  {priority_3_context}
87
 
88
  Questions to answer:
89
- 1. Could something ELSE explain the symptoms?
90
- 2. Is there evidence that contradicts the current hypothesis?
91
- 3. What's an alternative explanation?
 
92
 
93
- Respond in JSON format:
94
  {{
95
- "contradiction_found": true|false,
96
- "contradicting_evidence": ["evidence that doesn't fit"],
97
- "alternative_hypothesis": "alternative explanation",
98
- "alternative_confidence": 0.X,
99
- "reasoning": "why alternative might be correct"
100
- }}"""
 
 
101
 
102
  # =============================================================================
103
- # STAGE 4: CONFIRM PROMPT
104
  # =============================================================================
105
 
106
  CONFIRM_PROMPT = """COMPETING HYPOTHESES:
107
  1. {hypothesis_1} (confidence: {conf_1})
108
  2. {hypothesis_2} (confidence: {conf_2})
109
 
110
- FINAL VALIDATION DATA:
111
  {priority_4_context}
112
 
113
  Determine the FINAL diagnosis by:
114
- 1. Weighing evidence for each hypothesis
115
- 2. Considering farmer's recent actions
116
- 3. Checking consistency with previous analyses
117
  4. Identifying the ROOT CAUSE vs symptoms
 
118
 
119
- Respond in JSON format:
120
  {{
121
- "final_diagnosis": "clear diagnosis statement",
122
- "confidence": 0.X,
123
- "causal_chain": "ABCsymptom",
124
- "root_cause": "the underlying cause",
125
- "symptoms": ["observable symptoms"],
126
- "recommendation": "what to do next"
127
- }}"""
 
 
 
 
 
 
 
128
 
129
  # =============================================================================
130
  # RESPONSE GENERATION PROMPT
@@ -141,16 +166,43 @@ EVIDENCE SUMMARY:
141
  {evidence}
142
 
143
  Generate a response that:
144
- 1. Directly answers the farmer's question
145
- 2. Explains the diagnosis in simple terms
146
- 3. Cites key evidence (with numbers)
147
- 4. Provides actionable recommendations
148
- 5. Is concise but complete (3-5 paragraphs max)
149
-
150
- Use emojis sparingly for visual clarity (📊 for data, 🔬 for analysis, ✅ for recommendations).
 
 
 
 
 
 
151
 
152
  Respond in natural language (not JSON)."""
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  # =============================================================================
155
  # HELPER FUNCTIONS
156
  # =============================================================================
@@ -162,23 +214,78 @@ def build_context_prompt(context: dict) -> str:
162
 
163
  lines = []
164
  for key, value in context.items():
 
 
165
  if isinstance(value, dict):
166
  lines.append(f"**{key}**:")
167
  for k, v in value.items():
168
- lines.append(f" - {k}: {v}")
 
 
 
 
169
  elif isinstance(value, list):
170
- lines.append(f"**{key}**: {', '.join(str(v) for v in value)}")
 
171
  else:
172
- lines.append(f"**{key}**: {value}")
 
 
 
173
 
174
- return "\n".join(lines)
175
 
176
 
177
  def format_stage_prompt(template: str, **kwargs) -> str:
178
  """Format a stage prompt with provided values."""
179
  # Convert context dicts to strings
 
180
  for key, value in kwargs.items():
181
  if isinstance(value, dict):
182
- kwargs[key] = build_context_prompt(value)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
 
184
- return template.format(**kwargs)
 
 
 
 
 
2
  LLM Prompts for Multi-Stage Reasoning
3
  ======================================
4
  Prompts for each stage: Claim → Validate → Contradict → Confirm
5
+ Following the Developer Specification exactly.
6
  """
7
 
8
  # =============================================================================
9
  # SYSTEM PROMPT (Base context)
10
  # =============================================================================
11
 
12
+ SYSTEM_PROMPT = """You are AGROW AI, an expert agricultural advisor for Indian farmers.
13
 
14
+ SPECIALIZATIONS:
15
+ - Satellite imagery interpretation (Sentinel-1 SAR, Sentinel-2 optical bands)
16
+ - Vegetation indices analysis (NDVI, NDRE, EVI, SMI, PSRI, PRI, MCARI, etc.)
17
+ - Crop stress diagnosis (water stress, nutrient deficiency, pest/disease)
18
  - Climate-smart farming recommendations
19
+ - Regional crop knowledge (wheat, rice, cotton, sugarcane, pulses, mustard, etc.)
20
+
21
+ COMMUNICATION STYLE:
22
+ - Use simple, practical language farmers understand
23
+ - Cite specific values from satellite data when available
24
+ - Distinguish ROOT CAUSE from SYMPTOMS
25
+ - Give actionable, prioritized recommendations
26
+ - Reference local conditions and seasonal context
27
  - Be concise but thorough
28
 
29
+ ANALYSIS APPROACH:
30
+ - Always consider multiple hypotheses before concluding
31
+ - Seek contradicting evidence actively
32
+ - Build causal chains: Event A → Effect B → Symptom C
33
+ - Confidence scores reflect evidence strength
34
+
35
+ When you lack specific data, acknowledge it honestly and provide general guidance based on described symptoms."""
36
 
37
  # =============================================================================
38
+ # STAGE 3A: CLAIM PROMPT - Initial Hypothesis
39
  # =============================================================================
40
 
41
  CLAIM_PROMPT = """You are analyzing agricultural satellite data to diagnose crop issues.
42
 
43
  USER QUERY: {query}
44
 
45
+ AVAILABLE EVIDENCE (Priority 1 - Primary indicators only):
46
  {priority_1_context}
47
 
48
  Based ONLY on this primary evidence:
49
  1. State your initial hypothesis about what's happening
50
+ 2. Cite specific values that support your hypothesis
51
  3. Rate your confidence (0.0 to 1.0)
52
+ 4. List what you're unsure about
53
 
54
+ Respond in JSON format ONLY:
55
  {{
56
+ "initial_claim": "Your hypothesis in 1-2 sentences describing what's likely happening",
57
+ "hypothesis": "single_word_label (e.g., chlorophyll_deficiency, water_stress, nutrient_issue)",
58
+ "evidence_cited": ["NDVI: 0.45", "NDRE: 0.32", "trend: declining"],
59
+ "confidence": 0.72,
60
+ "uncertainties": ["cannot determine spatial distribution", "need weather data"]
61
+ }}
62
+
63
+ Return ONLY the JSON object, no additional text."""
64
 
65
  # =============================================================================
66
+ # STAGE 3B: VALIDATE PROMPT - Confirm with Supporting Evidence
67
  # =============================================================================
68
 
69
  VALIDATE_PROMPT = """You previously hypothesized: {previous_hypothesis}
70
  Initial confidence: {previous_confidence}
71
 
72
+ ADDITIONAL SUPPORTING EVIDENCE (Priority 2):
73
  {priority_2_context}
74
 
75
+ Analyze this new evidence and determine:
76
+ 1. Does it CONFIRM your hypothesis? (increases confidence)
77
+ 2. Does it WEAKEN your hypothesis? (decreases confidence)
78
+ 3. Does it add SPATIAL context? (where is the issue concentrated?)
79
 
80
+ Respond in JSON format ONLY:
81
  {{
82
  "validation_result": "confirmed|weakened|neutral",
83
+ "confidence_updated": 0.81,
84
+ "spatial_notes": "Stress concentrated in northeast quadrant, 12 patches affected",
85
+ "new_evidence_summary": "EVI also low (0.38), clustering shows 18.75% field under stress"
86
+ }}
87
+
88
+ Return ONLY the JSON object, no additional text."""
89
 
90
  # =============================================================================
91
+ # STAGE 3C: CONTRADICT PROMPT - Seek Alternative Explanations
92
  # =============================================================================
93
 
94
  CONTRADICT_PROMPT = """CURRENT HYPOTHESIS: {hypothesis} (confidence: {confidence})
95
 
96
+ YOUR CRITICAL TASK: Actively look for evidence that CONTRADICTS this hypothesis.
97
+ Do NOT confirm - seek alternative explanations!
98
 
99
+ ALTERNATIVE CAUSAL FACTORS TO CONSIDER (Priority 3):
100
  {priority_3_context}
101
 
102
  Questions to answer:
103
+ 1. Could something ELSE explain the observed symptoms?
104
+ 2. Is there evidence that CONTRADICTS the current hypothesis?
105
+ 3. What's the strongest alternative explanation?
106
+ 4. Could the current hypothesis be a SYMPTOM of a deeper ROOT CAUSE?
107
 
108
+ Respond in JSON format ONLY:
109
  {{
110
+ "contradiction_found": true,
111
+ "contradicting_evidence": ["SMI critically low (0.18)", "5 consecutive heat stress days"],
112
+ "alternative_hypothesis": "water_stress",
113
+ "alternative_confidence": 0.76,
114
+ "reasoning": "Low SMI and heat stress suggest water deficit as ROOT CAUSE. The chlorophyll deficiency may be a SYMPTOM of water stress, not the primary issue."
115
+ }}
116
+
117
+ Return ONLY the JSON object, no additional text."""
118
 
119
  # =============================================================================
120
+ # STAGE 3D: CONFIRM PROMPT - Final Diagnosis
121
  # =============================================================================
122
 
123
  CONFIRM_PROMPT = """COMPETING HYPOTHESES:
124
  1. {hypothesis_1} (confidence: {conf_1})
125
  2. {hypothesis_2} (confidence: {conf_2})
126
 
127
+ FINAL VALIDATION DATA (Priority 4):
128
  {priority_4_context}
129
 
130
  Determine the FINAL diagnosis by:
131
+ 1. Weighing evidence for EACH hypothesis against this validation data
132
+ 2. Considering farmer's recent actions and their impact
133
+ 3. Checking consistency with any previous analyses
134
  4. Identifying the ROOT CAUSE vs symptoms
135
+ 5. Building a causal chain explaining how events led to current state
136
 
137
+ Respond in JSON format ONLY:
138
  {{
139
+ "final_diagnosis": "water_stress_induced_chlorophyll_decline",
140
+ "confidence": 0.89,
141
+ "causal_chain": "Heat stress (5 days >38°C) + No irrigation (8 days) Soil moisture deficit (SMI 0.18) Plant water stress Reduced nutrient uptake → Chlorophyll degradation (NDRE 0.32) → Visible yellowing",
142
+ "root_cause": "water_stress",
143
+ "symptoms": ["chlorophyll_decline", "yellowing_leaves", "low_NDRE"],
144
+ "evidence_summary": {{
145
+ "supporting": ["SAR VV increasing (drier soil)", "last irrigation 8 days ago"],
146
+ "contradicting": ["no pest/disease indicators"],
147
+ "inconclusive": ["nitrogen status unclear without fertilizer history"]
148
+ }},
149
+ "recommendation": "Irrigate immediately, prioritizing northeast sector where stress is highest. Consider light foliar feeding once moisture is restored."
150
+ }}
151
+
152
+ Return ONLY the JSON object, no additional text."""
153
 
154
  # =============================================================================
155
  # RESPONSE GENERATION PROMPT
 
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
187
+ # =============================================================================
188
+
189
+ FOLLOWUP_PROMPT = """Based on this conversation:
190
+
191
+ USER QUERY: {query}
192
+ DIAGNOSIS: {diagnosis}
193
+ INTENT: {intent}
194
+
195
+ Generate 3 relevant follow-up questions the farmer might want to ask next.
196
+
197
+ Rules:
198
+ - Make them specific to the diagnosis and context
199
+ - Include at least one spatial question ("Which area...")
200
+ - Include at least one action question ("How do I...")
201
+ - Keep them short and natural-sounding
202
+
203
+ Respond as a JSON list:
204
+ ["Question 1?", "Question 2?", "Question 3?"]"""
205
+
206
  # =============================================================================
207
  # HELPER FUNCTIONS
208
  # =============================================================================
 
214
 
215
  lines = []
216
  for key, value in context.items():
217
+ if value is None:
218
+ continue
219
  if isinstance(value, dict):
220
  lines.append(f"**{key}**:")
221
  for k, v in value.items():
222
+ if v is not None:
223
+ if isinstance(v, float):
224
+ lines.append(f" - {k}: {v:.4f}")
225
+ else:
226
+ lines.append(f" - {k}: {v}")
227
  elif isinstance(value, list):
228
+ if len(value) > 0:
229
+ lines.append(f"**{key}**: {value}")
230
  else:
231
+ if isinstance(value, float):
232
+ lines.append(f"**{key}**: {value:.4f}")
233
+ else:
234
+ lines.append(f"**{key}**: {value}")
235
 
236
+ return "\n".join(lines) if lines else "No specific data available."
237
 
238
 
239
  def format_stage_prompt(template: str, **kwargs) -> str:
240
  """Format a stage prompt with provided values."""
241
  # Convert context dicts to strings
242
+ formatted_kwargs = {}
243
  for key, value in kwargs.items():
244
  if isinstance(value, dict):
245
+ formatted_kwargs[key] = build_context_prompt(value)
246
+ else:
247
+ formatted_kwargs[key] = value
248
+
249
+ return template.format(**formatted_kwargs)
250
+
251
+
252
+ def generate_followup_questions(intent: str, diagnosis: str) -> list:
253
+ """Generate suggested followup questions based on intent and diagnosis."""
254
+ followup_templates = {
255
+ "vegetation_health": [
256
+ "Which part of my field is most affected?",
257
+ "How much water/fertilizer should I apply?",
258
+ "Will this damage spread if I don't act now?"
259
+ ],
260
+ "water_stress": [
261
+ "How much should I irrigate?",
262
+ "Is rain expected this week?",
263
+ "Which zone needs water most urgently?"
264
+ ],
265
+ "nutrient_status": [
266
+ "Which fertilizer should I use?",
267
+ "How much fertilizer per acre?",
268
+ "When is the best time to apply?"
269
+ ],
270
+ "pest_disease": [
271
+ "What pesticide should I use?",
272
+ "How fast is this spreading?",
273
+ "Should I quarantine the affected area?"
274
+ ],
275
+ "forecast_query": [
276
+ "What should I prepare for?",
277
+ "How will weather affect my crop next week?",
278
+ "When is the best time to harvest?"
279
+ ],
280
+ "action_recommendation": [
281
+ "How soon should I act?",
282
+ "What's the most cost-effective solution?",
283
+ "Can I wait for rain instead of irrigating?"
284
+ ]
285
+ }
286
 
287
+ return followup_templates.get(intent, [
288
+ "What should I do next?",
289
+ "Is my crop at risk?",
290
+ "How can I prevent this in the future?"
291
+ ])
reasoning_engine.py CHANGED
@@ -1,25 +1,30 @@
1
  """
2
  Multi-Stage Reasoning Engine for Agricultural Chatbot
3
  ======================================================
4
- Implements: Claim → Validate → Contradict → Confirm pipeline
 
5
  """
6
 
7
  import json
8
  import logging
9
- from typing import Dict, List, Any, Optional, Tuple
10
- from dataclasses import dataclass
11
 
12
  from intent_classifier import IntentClassifier
13
  from priority_mapper import PriorityContextMapper
14
  from prompts import (
15
  SYSTEM_PROMPT, CLAIM_PROMPT, VALIDATE_PROMPT,
16
  CONTRADICT_PROMPT, CONFIRM_PROMPT, RESPONSE_PROMPT,
17
- format_stage_prompt, build_context_prompt
18
  )
19
 
20
  logger = logging.getLogger("ReasoningEngine")
21
 
22
 
 
 
 
 
23
  @dataclass
24
  class StageResult:
25
  """Result from a single reasoning stage."""
@@ -27,6 +32,7 @@ class StageResult:
27
  output: Dict[str, Any]
28
  context_used: List[str]
29
  confidence: float
 
30
 
31
 
32
  @dataclass
@@ -39,17 +45,28 @@ class ReasoningResult:
39
  final_diagnosis: str
40
  final_confidence: float
41
  causal_chain: str
 
 
42
  recommendation: str
43
  evidence_summary: Dict[str, List[str]]
44
 
45
 
 
 
 
 
46
  class ReasoningEngine:
47
  """
48
  Multi-stage reasoning engine for agricultural chatbot.
49
- Does NOT ingest all context - uses priority-based selection.
 
 
 
 
 
50
  """
51
 
52
- def __init__(self, llm_caller):
53
  """
54
  Args:
55
  llm_caller: Function that takes (prompt: str) -> str
@@ -64,7 +81,7 @@ class ReasoningEngine:
64
  context: Optional[Dict[str, Any]] = None
65
  ) -> Tuple[str, Dict[str, Any]]:
66
  """
67
- Process a user query through the full reasoning pipeline.
68
 
69
  Returns:
70
  (response_text, reasoning_trace)
@@ -73,9 +90,9 @@ class ReasoningEngine:
73
 
74
  # Stage 1: Classify intent
75
  intent = self.intent_classifier.classify(query)
76
- logger.info(f"Detected intent: {intent['primary_intent']} ({intent['confidence']})")
77
 
78
- # Stage 2: Get prioritized context
79
  staged_context = self.priority_mapper.build_staged_context(
80
  intent=intent["primary_intent"],
81
  full_context=context or {}
@@ -87,8 +104,14 @@ class ReasoningEngine:
87
  # Stage 4: Generate response
88
  response = self._generate_response(query, reasoning_result)
89
 
90
- # Build reasoning trace
91
- trace = self._build_trace(intent, reasoning_result, staged_context)
 
 
 
 
 
 
92
 
93
  return response, trace
94
 
@@ -98,28 +121,34 @@ class ReasoningEngine:
98
  intent: Dict,
99
  staged_context: Dict
100
  ) -> ReasoningResult:
101
- """Execute 4-stage reasoning pipeline."""
102
 
103
- # Stage A: Initial Claim
 
104
  claim = self._stage_claim(query, staged_context["claim_context"])
105
 
106
- # Stage B: Validate
 
107
  validation = self._stage_validate(
108
- claim.output.get("hypothesis", "unknown"),
109
- claim.confidence,
110
- staged_context["validate_context"]
111
  )
112
 
113
- # Stage C: Contradict
 
 
 
114
  contradiction = self._stage_contradict(
115
- validation.output.get("hypothesis", claim.output.get("hypothesis", "unknown")),
116
- validation.confidence,
117
- staged_context["contradict_context"]
118
  )
119
 
120
- # Stage D: Confirm
 
121
  confirmation = self._stage_confirm(
122
- hypothesis_1=claim.output.get("hypothesis", "unknown"),
123
  conf_1=validation.confidence,
124
  hypothesis_2=contradiction.output.get("alternative_hypothesis", "none"),
125
  conf_2=contradiction.output.get("alternative_confidence", 0),
@@ -134,17 +163,22 @@ class ReasoningEngine:
134
  final_diagnosis=confirmation.output.get("final_diagnosis", "Undetermined"),
135
  final_confidence=confirmation.confidence,
136
  causal_chain=confirmation.output.get("causal_chain", ""),
 
 
137
  recommendation=confirmation.output.get("recommendation", ""),
138
  evidence_summary={
139
  "primary": claim.context_used,
140
  "supporting": validation.context_used,
141
  "alternative": contradiction.context_used,
142
- "validation": confirmation.context_used
 
 
 
143
  }
144
  )
145
 
146
  def _stage_claim(self, query: str, context: Dict) -> StageResult:
147
- """Stage 3A: Make initial claim using Priority 1 context."""
148
  prompt = format_stage_prompt(
149
  CLAIM_PROMPT,
150
  query=query,
@@ -154,20 +188,20 @@ class ReasoningEngine:
154
  full_prompt = f"{SYSTEM_PROMPT}\n\n{prompt}"
155
  response = self.llm(full_prompt)
156
 
157
- try:
158
- output = self._parse_json(response)
159
- except:
160
- output = {
161
- "initial_claim": response,
162
- "hypothesis": "general_issue",
163
- "confidence": 0.5
164
- }
165
 
166
  return StageResult(
167
  stage="claim",
168
  output=output,
169
  context_used=list(context.keys()),
170
- confidence=output.get("confidence", 0.5)
 
171
  )
172
 
173
  def _stage_validate(
@@ -187,13 +221,12 @@ class ReasoningEngine:
187
  full_prompt = f"{SYSTEM_PROMPT}\n\n{prompt}"
188
  response = self.llm(full_prompt)
189
 
190
- try:
191
- output = self._parse_json(response)
192
- except:
193
- output = {
194
- "validation_result": "neutral",
195
- "confidence_updated": confidence
196
- }
197
 
198
  # Carry forward hypothesis
199
  output["hypothesis"] = hypothesis
@@ -202,7 +235,8 @@ class ReasoningEngine:
202
  stage="validate",
203
  output=output,
204
  context_used=list(context.keys()),
205
- confidence=output.get("confidence_updated", confidence)
 
206
  )
207
 
208
  def _stage_contradict(
@@ -211,7 +245,7 @@ class ReasoningEngine:
211
  confidence: float,
212
  context: Dict
213
  ) -> StageResult:
214
- """Stage 3C: Seek contradictions using Priority 3 context."""
215
  prompt = format_stage_prompt(
216
  CONTRADICT_PROMPT,
217
  hypothesis=hypothesis,
@@ -222,20 +256,20 @@ class ReasoningEngine:
222
  full_prompt = f"{SYSTEM_PROMPT}\n\n{prompt}"
223
  response = self.llm(full_prompt)
224
 
225
- try:
226
- output = self._parse_json(response)
227
- except:
228
- output = {
229
- "contradiction_found": False,
230
- "alternative_hypothesis": "none",
231
- "alternative_confidence": 0
232
- }
233
 
234
  return StageResult(
235
  stage="contradict",
236
  output=output,
237
  context_used=list(context.keys()),
238
- confidence=output.get("alternative_confidence", 0)
 
239
  )
240
 
241
  def _stage_confirm(
@@ -251,7 +285,7 @@ class ReasoningEngine:
251
  CONFIRM_PROMPT,
252
  hypothesis_1=hypothesis_1,
253
  conf_1=conf_1,
254
- hypothesis_2=hypothesis_2,
255
  conf_2=conf_2,
256
  priority_4_context=context
257
  )
@@ -259,34 +293,47 @@ class ReasoningEngine:
259
  full_prompt = f"{SYSTEM_PROMPT}\n\n{prompt}"
260
  response = self.llm(full_prompt)
261
 
262
- try:
263
- output = self._parse_json(response)
264
- except:
265
- # Generate simple output if parsing fails
266
- output = {
267
- "final_diagnosis": f"Likely {hypothesis_1}",
268
- "confidence": conf_1,
269
- "recommendation": "Further investigation recommended"
270
- }
 
 
 
 
 
 
 
 
271
 
272
  return StageResult(
273
  stage="confirm",
274
  output=output,
275
  context_used=list(context.keys()),
276
- confidence=output.get("confidence", conf_1)
 
277
  )
278
 
279
  def _generate_response(self, query: str, result: ReasoningResult) -> str:
280
  """Generate final user-facing response."""
 
 
 
 
 
 
 
 
 
281
  prompt = format_stage_prompt(
282
  RESPONSE_PROMPT,
283
  query=query,
284
- diagnosis=json.dumps({
285
- "diagnosis": result.final_diagnosis,
286
- "confidence": result.final_confidence,
287
- "causal_chain": result.causal_chain,
288
- "recommendation": result.recommendation
289
- }, indent=2),
290
  evidence=json.dumps(result.evidence_summary, indent=2)
291
  )
292
 
@@ -299,9 +346,10 @@ class ReasoningEngine:
299
  self,
300
  intent: Dict,
301
  result: ReasoningResult,
302
- staged_context: Dict
 
303
  ) -> Dict[str, Any]:
304
- """Build reasoning trace for debugging/transparency."""
305
  return {
306
  "intent_detected": intent["primary_intent"],
307
  "intent_confidence": intent["confidence"],
@@ -309,58 +357,84 @@ class ReasoningEngine:
309
  "stages": {
310
  "claim": {
311
  "hypothesis": result.claim.output.get("hypothesis"),
 
312
  "confidence": result.claim.confidence,
313
- "context_used": result.claim.context_used
 
314
  },
315
  "validation": {
316
  "result": result.validation.output.get("validation_result"),
317
  "confidence": result.validation.confidence,
 
318
  "context_used": result.validation.context_used
319
  },
320
  "contradiction": {
321
  "found": result.contradiction.output.get("contradiction_found"),
322
  "alternative": result.contradiction.output.get("alternative_hypothesis"),
323
- "confidence": result.contradiction.confidence,
 
324
  "context_used": result.contradiction.context_used
325
  },
326
  "confirmation": {
327
  "final": result.final_diagnosis,
328
  "confidence": result.final_confidence,
 
 
329
  "context_used": result.confirmation.context_used
330
  }
331
  },
332
  "causal_chain": result.causal_chain,
333
- "evidence_summary": result.evidence_summary
 
 
 
 
 
 
 
 
 
334
  }
335
 
336
- def _parse_json(self, text: str) -> Dict:
337
- """Extract and parse JSON from LLM response."""
338
- # Try to find JSON in response
 
 
339
  text = text.strip()
340
 
341
- # Look for JSON block
342
  if "```json" in text:
343
  start = text.find("```json") + 7
344
  end = text.find("```", start)
345
- text = text[start:end].strip()
 
346
  elif "```" in text:
347
  start = text.find("```") + 3
348
  end = text.find("```", start)
349
- text = text[start:end].strip()
 
350
 
351
  # Find JSON object
352
  start = text.find("{")
353
  end = text.rfind("}") + 1
 
354
  if start >= 0 and end > start:
355
- text = text[start:end]
 
 
 
356
 
357
- return json.loads(text)
358
 
359
 
360
- # Simple interface for single-stage reasoning (fallback)
361
- def simple_reason(query: str, context: Dict, llm_caller) -> str:
 
 
 
362
  """Simplified single-stage reasoning for when full pipeline isn't needed."""
363
- context_str = build_context_prompt(context) if context else "No context available."
364
 
365
  prompt = f"""{SYSTEM_PROMPT}
366
 
@@ -369,6 +443,13 @@ User Query: {query}
369
  Available Context:
370
  {context_str}
371
 
372
- Provide a helpful, actionable response."""
 
 
 
 
 
 
 
373
 
374
  return llm_caller(prompt)
 
1
  """
2
  Multi-Stage Reasoning Engine for Agricultural Chatbot
3
  ======================================================
4
+ Implements the full spec: Claim → Validate → Contradict → Confirm pipeline
5
+ with priority-based context selection and evidence tracking.
6
  """
7
 
8
  import json
9
  import logging
10
+ from typing import Dict, List, Any, Optional, Tuple, Callable
11
+ from dataclasses import dataclass, field
12
 
13
  from intent_classifier import IntentClassifier
14
  from priority_mapper import PriorityContextMapper
15
  from prompts import (
16
  SYSTEM_PROMPT, CLAIM_PROMPT, VALIDATE_PROMPT,
17
  CONTRADICT_PROMPT, CONFIRM_PROMPT, RESPONSE_PROMPT,
18
+ format_stage_prompt, build_context_prompt, generate_followup_questions
19
  )
20
 
21
  logger = logging.getLogger("ReasoningEngine")
22
 
23
 
24
+ # =============================================================================
25
+ # DATA CLASSES
26
+ # =============================================================================
27
+
28
  @dataclass
29
  class StageResult:
30
  """Result from a single reasoning stage."""
 
32
  output: Dict[str, Any]
33
  context_used: List[str]
34
  confidence: float
35
+ raw_response: str = ""
36
 
37
 
38
  @dataclass
 
45
  final_diagnosis: str
46
  final_confidence: float
47
  causal_chain: str
48
+ root_cause: str
49
+ symptoms: List[str]
50
  recommendation: str
51
  evidence_summary: Dict[str, List[str]]
52
 
53
 
54
+ # =============================================================================
55
+ # REASONING ENGINE
56
+ # =============================================================================
57
+
58
  class ReasoningEngine:
59
  """
60
  Multi-stage reasoning engine for agricultural chatbot.
61
+
62
+ Following the spec:
63
+ - Does NOT ingest all context at once
64
+ - Uses priority-based context selection per intent
65
+ - Actively seeks contradictions in Stage C
66
+ - Tracks evidence chain for transparency
67
  """
68
 
69
+ def __init__(self, llm_caller: Callable[[str], str]):
70
  """
71
  Args:
72
  llm_caller: Function that takes (prompt: str) -> str
 
81
  context: Optional[Dict[str, Any]] = None
82
  ) -> Tuple[str, Dict[str, Any]]:
83
  """
84
+ Process user query through full reasoning pipeline.
85
 
86
  Returns:
87
  (response_text, reasoning_trace)
 
90
 
91
  # Stage 1: Classify intent
92
  intent = self.intent_classifier.classify(query)
93
+ logger.info(f"Intent: {intent['primary_intent']} ({intent['confidence']})")
94
 
95
+ # Stage 2: Get prioritized context (NOT all context at once!)
96
  staged_context = self.priority_mapper.build_staged_context(
97
  intent=intent["primary_intent"],
98
  full_context=context or {}
 
104
  # Stage 4: Generate response
105
  response = self._generate_response(query, reasoning_result)
106
 
107
+ # Stage 5: Generate followups
108
+ followups = generate_followup_questions(
109
+ intent["primary_intent"],
110
+ reasoning_result.final_diagnosis
111
+ )
112
+
113
+ # Build complete trace
114
+ trace = self._build_trace(intent, reasoning_result, staged_context, followups)
115
 
116
  return response, trace
117
 
 
121
  intent: Dict,
122
  staged_context: Dict
123
  ) -> ReasoningResult:
124
+ """Execute 4-stage reasoning pipeline as per spec."""
125
 
126
+ # Stage A: Initial Claim (Priority 1 context only)
127
+ logger.info("Stage A: Making initial claim...")
128
  claim = self._stage_claim(query, staged_context["claim_context"])
129
 
130
+ # Stage B: Validate (Add Priority 2 context)
131
+ logger.info(f"Stage B: Validating hypothesis '{claim.output.get('hypothesis')}'...")
132
  validation = self._stage_validate(
133
+ hypothesis=claim.output.get("hypothesis", "unknown"),
134
+ confidence=claim.confidence,
135
+ context=staged_context["validate_context"]
136
  )
137
 
138
+ # Stage C: Contradict (Priority 3 - actively seek alternatives)
139
+ current_hypothesis = validation.output.get("hypothesis",
140
+ claim.output.get("hypothesis", "unknown"))
141
+ logger.info(f"Stage C: Seeking contradictions to '{current_hypothesis}'...")
142
  contradiction = self._stage_contradict(
143
+ hypothesis=current_hypothesis,
144
+ confidence=validation.confidence,
145
+ context=staged_context["contradict_context"]
146
  )
147
 
148
+ # Stage D: Confirm (Priority 4 - final decision)
149
+ logger.info("Stage D: Final confirmation...")
150
  confirmation = self._stage_confirm(
151
+ hypothesis_1=current_hypothesis,
152
  conf_1=validation.confidence,
153
  hypothesis_2=contradiction.output.get("alternative_hypothesis", "none"),
154
  conf_2=contradiction.output.get("alternative_confidence", 0),
 
163
  final_diagnosis=confirmation.output.get("final_diagnosis", "Undetermined"),
164
  final_confidence=confirmation.confidence,
165
  causal_chain=confirmation.output.get("causal_chain", ""),
166
+ root_cause=confirmation.output.get("root_cause", "unknown"),
167
+ symptoms=confirmation.output.get("symptoms", []),
168
  recommendation=confirmation.output.get("recommendation", ""),
169
  evidence_summary={
170
  "primary": claim.context_used,
171
  "supporting": validation.context_used,
172
  "alternative": contradiction.context_used,
173
+ "validation": confirmation.context_used,
174
+ "supporting_evidence": confirmation.output.get("evidence_summary", {}).get("supporting", []),
175
+ "contradicting_evidence": confirmation.output.get("evidence_summary", {}).get("contradicting", []),
176
+ "inconclusive_evidence": confirmation.output.get("evidence_summary", {}).get("inconclusive", [])
177
  }
178
  )
179
 
180
  def _stage_claim(self, query: str, context: Dict) -> StageResult:
181
+ """Stage 3A: Make initial claim using Priority 1 context only."""
182
  prompt = format_stage_prompt(
183
  CLAIM_PROMPT,
184
  query=query,
 
188
  full_prompt = f"{SYSTEM_PROMPT}\n\n{prompt}"
189
  response = self.llm(full_prompt)
190
 
191
+ output = self._parse_json_safe(response, {
192
+ "initial_claim": response[:200] if response else "No analysis available",
193
+ "hypothesis": "general_issue",
194
+ "evidence_cited": list(context.keys()),
195
+ "confidence": 0.5,
196
+ "uncertainties": ["Limited data available"]
197
+ })
 
198
 
199
  return StageResult(
200
  stage="claim",
201
  output=output,
202
  context_used=list(context.keys()),
203
+ confidence=output.get("confidence", 0.5),
204
+ raw_response=response
205
  )
206
 
207
  def _stage_validate(
 
221
  full_prompt = f"{SYSTEM_PROMPT}\n\n{prompt}"
222
  response = self.llm(full_prompt)
223
 
224
+ output = self._parse_json_safe(response, {
225
+ "validation_result": "neutral",
226
+ "confidence_updated": confidence,
227
+ "spatial_notes": "Unable to determine spatial distribution",
228
+ "new_evidence_summary": ""
229
+ })
 
230
 
231
  # Carry forward hypothesis
232
  output["hypothesis"] = hypothesis
 
235
  stage="validate",
236
  output=output,
237
  context_used=list(context.keys()),
238
+ confidence=output.get("confidence_updated", confidence),
239
+ raw_response=response
240
  )
241
 
242
  def _stage_contradict(
 
245
  confidence: float,
246
  context: Dict
247
  ) -> StageResult:
248
+ """Stage 3C: Actively seek contradictions using Priority 3 context."""
249
  prompt = format_stage_prompt(
250
  CONTRADICT_PROMPT,
251
  hypothesis=hypothesis,
 
256
  full_prompt = f"{SYSTEM_PROMPT}\n\n{prompt}"
257
  response = self.llm(full_prompt)
258
 
259
+ output = self._parse_json_safe(response, {
260
+ "contradiction_found": False,
261
+ "contradicting_evidence": [],
262
+ "alternative_hypothesis": "none",
263
+ "alternative_confidence": 0.0,
264
+ "reasoning": "No strong contradicting evidence found"
265
+ })
 
266
 
267
  return StageResult(
268
  stage="contradict",
269
  output=output,
270
  context_used=list(context.keys()),
271
+ confidence=output.get("alternative_confidence", 0.0),
272
+ raw_response=response
273
  )
274
 
275
  def _stage_confirm(
 
285
  CONFIRM_PROMPT,
286
  hypothesis_1=hypothesis_1,
287
  conf_1=conf_1,
288
+ hypothesis_2=hypothesis_2 if hypothesis_2 != "none" else "no_alternative",
289
  conf_2=conf_2,
290
  priority_4_context=context
291
  )
 
293
  full_prompt = f"{SYSTEM_PROMPT}\n\n{prompt}"
294
  response = self.llm(full_prompt)
295
 
296
+ # Pick the more confident hypothesis as default
297
+ default_diagnosis = hypothesis_1 if conf_1 >= conf_2 else hypothesis_2
298
+ default_conf = max(conf_1, conf_2)
299
+
300
+ output = self._parse_json_safe(response, {
301
+ "final_diagnosis": default_diagnosis,
302
+ "confidence": default_conf,
303
+ "causal_chain": f"{default_diagnosis} leads to observed symptoms",
304
+ "root_cause": default_diagnosis,
305
+ "symptoms": [],
306
+ "evidence_summary": {
307
+ "supporting": list(context.keys()),
308
+ "contradicting": [],
309
+ "inconclusive": []
310
+ },
311
+ "recommendation": "Further investigation recommended based on available data"
312
+ })
313
 
314
  return StageResult(
315
  stage="confirm",
316
  output=output,
317
  context_used=list(context.keys()),
318
+ confidence=output.get("confidence", default_conf),
319
+ raw_response=response
320
  )
321
 
322
  def _generate_response(self, query: str, result: ReasoningResult) -> str:
323
  """Generate final user-facing response."""
324
+ diagnosis_data = {
325
+ "diagnosis": result.final_diagnosis,
326
+ "confidence": result.final_confidence,
327
+ "causal_chain": result.causal_chain,
328
+ "root_cause": result.root_cause,
329
+ "symptoms": result.symptoms,
330
+ "recommendation": result.recommendation
331
+ }
332
+
333
  prompt = format_stage_prompt(
334
  RESPONSE_PROMPT,
335
  query=query,
336
+ diagnosis=json.dumps(diagnosis_data, indent=2),
 
 
 
 
 
337
  evidence=json.dumps(result.evidence_summary, indent=2)
338
  )
339
 
 
346
  self,
347
  intent: Dict,
348
  result: ReasoningResult,
349
+ staged_context: Dict,
350
+ followups: List[str]
351
  ) -> Dict[str, Any]:
352
+ """Build reasoning trace for transparency as per spec."""
353
  return {
354
  "intent_detected": intent["primary_intent"],
355
  "intent_confidence": intent["confidence"],
 
357
  "stages": {
358
  "claim": {
359
  "hypothesis": result.claim.output.get("hypothesis"),
360
+ "initial_claim": result.claim.output.get("initial_claim"),
361
  "confidence": result.claim.confidence,
362
+ "context_used": result.claim.context_used,
363
+ "evidence_cited": result.claim.output.get("evidence_cited", [])
364
  },
365
  "validation": {
366
  "result": result.validation.output.get("validation_result"),
367
  "confidence": result.validation.confidence,
368
+ "spatial_notes": result.validation.output.get("spatial_notes"),
369
  "context_used": result.validation.context_used
370
  },
371
  "contradiction": {
372
  "found": result.contradiction.output.get("contradiction_found"),
373
  "alternative": result.contradiction.output.get("alternative_hypothesis"),
374
+ "alternative_confidence": result.contradiction.output.get("alternative_confidence"),
375
+ "reasoning": result.contradiction.output.get("reasoning"),
376
  "context_used": result.contradiction.context_used
377
  },
378
  "confirmation": {
379
  "final": result.final_diagnosis,
380
  "confidence": result.final_confidence,
381
+ "root_cause": result.root_cause,
382
+ "causal_chain": result.causal_chain,
383
  "context_used": result.confirmation.context_used
384
  }
385
  },
386
  "causal_chain": result.causal_chain,
387
+ "root_cause": result.root_cause,
388
+ "symptoms": result.symptoms,
389
+ "evidence_summary": result.evidence_summary,
390
+ "context_priority_used": {
391
+ "priority_1": list(staged_context.get("claim_context", {}).keys()),
392
+ "priority_2": list(staged_context.get("validate_context", {}).keys()),
393
+ "priority_3": list(staged_context.get("contradict_context", {}).keys()),
394
+ "priority_4": list(staged_context.get("confirm_context", {}).keys())
395
+ },
396
+ "suggested_followups": followups
397
  }
398
 
399
+ def _parse_json_safe(self, text: str, default: Dict) -> Dict:
400
+ """Safely extract and parse JSON from LLM response."""
401
+ if not text:
402
+ return default
403
+
404
  text = text.strip()
405
 
406
+ # Look for JSON code block
407
  if "```json" in text:
408
  start = text.find("```json") + 7
409
  end = text.find("```", start)
410
+ if end > start:
411
+ text = text[start:end].strip()
412
  elif "```" in text:
413
  start = text.find("```") + 3
414
  end = text.find("```", start)
415
+ if end > start:
416
+ text = text[start:end].strip()
417
 
418
  # Find JSON object
419
  start = text.find("{")
420
  end = text.rfind("}") + 1
421
+
422
  if start >= 0 and end > start:
423
+ try:
424
+ return json.loads(text[start:end])
425
+ except json.JSONDecodeError as e:
426
+ logger.warning(f"JSON parse error: {e}")
427
 
428
+ return default
429
 
430
 
431
+ # =============================================================================
432
+ # SIMPLE REASONING (Fallback)
433
+ # =============================================================================
434
+
435
+ def simple_reason(query: str, context: Dict, llm_caller: Callable) -> str:
436
  """Simplified single-stage reasoning for when full pipeline isn't needed."""
437
+ context_str = build_context_prompt(context) if context else "No specific field data available."
438
 
439
  prompt = f"""{SYSTEM_PROMPT}
440
 
 
443
  Available Context:
444
  {context_str}
445
 
446
+ Provide a helpful, actionable response. If specific data is available, cite it.
447
+ If not, provide general guidance based on the query.
448
+
449
+ Use this format:
450
+ 1. Direct answer to the question
451
+ 2. Key observations from data (if available)
452
+ 3. Practical recommendations
453
+ 4. What to monitor or check next"""
454
 
455
  return llm_caller(prompt)