Spaces:
Sleeping
Sleeping
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
- app.py +47 -9
- context_aggregator.py +400 -174
- intent_classifier.py +201 -57
- models.py +308 -0
- priority_mapper.py +121 -37
- prompts.py +180 -73
- 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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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
|
| 25 |
"""
|
| 26 |
|
| 27 |
-
def __init__(self, timeout: int =
|
| 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": [
|
| 42 |
crop_type: Type of crop
|
| 43 |
area_acres: Field size in acres
|
| 44 |
-
farmer_context:
|
| 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"] =
|
| 79 |
context["patches"] = sar_data.get("patches", [])
|
| 80 |
-
context["stressed_patches"] = sar_data.get("
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 87 |
if s2_data:
|
| 88 |
-
context["vegetation_indices"] =
|
| 89 |
-
context["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 |
-
|
| 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
|
| 167 |
self,
|
| 168 |
center_lat: float,
|
| 169 |
center_lon: float,
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
) ->
|
| 173 |
-
"""
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
"metric": metric,
|
| 192 |
-
"gaussian_sigma": 1.5,
|
| 193 |
-
"show_field_boundary": False
|
| 194 |
-
},
|
| 195 |
-
timeout=60
|
| 196 |
-
)
|
| 197 |
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
}
|
| 209 |
-
|
| 210 |
-
except Exception as e:
|
| 211 |
-
logger.error(f"Heatmap fetch error for {metric}: {e}")
|
| 212 |
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
|
| 215 |
-
def
|
| 216 |
-
"""
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
"
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
"
|
| 252 |
-
"
|
| 253 |
-
"VV_VH_ratio": sar.get("ratio")
|
| 254 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
-
#
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
}
|
|
|
|
| 269 |
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
"
|
| 276 |
-
"
|
| 277 |
}
|
| 278 |
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
"
|
| 284 |
-
"
|
| 285 |
-
"nitrogen_status": llm.get("nitrogen_status"),
|
| 286 |
-
"overall_health": llm.get("overall_health")
|
| 287 |
}
|
| 288 |
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
if
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 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
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
|
| 329 |
|
| 330 |
-
#
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from typing import Dict, List, Tuple
|
| 8 |
import re
|
| 9 |
|
| 10 |
-
#
|
|
|
|
|
|
|
|
|
|
| 11 |
INTENT_PATTERNS = {
|
| 12 |
"vegetation_health": {
|
| 13 |
-
"keywords": [
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
},
|
|
|
|
| 17 |
"water_stress": {
|
| 18 |
-
"keywords": [
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
},
|
|
|
|
| 22 |
"nutrient_status": {
|
| 23 |
-
"keywords": [
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
},
|
|
|
|
| 27 |
"pest_disease": {
|
| 28 |
-
"keywords": [
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
"
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
| 36 |
},
|
|
|
|
| 37 |
"zone_specific": {
|
| 38 |
-
"keywords": [
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
"action_recommendation": {
|
| 43 |
-
"keywords": [
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
},
|
|
|
|
| 47 |
"comparison": {
|
| 48 |
-
"keywords": [
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
},
|
|
|
|
| 52 |
"general_query": {
|
| 53 |
-
"keywords": [
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
}
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
class IntentClassifier:
|
| 60 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 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
|
| 82 |
for intent, config in self.patterns.items():
|
| 83 |
-
|
| 84 |
-
matches = [kw for kw in keywords if kw in query_lower]
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
| 93 |
intent_scores[intent] = min(score, 1.0)
|
| 94 |
matched_keywords[intent] = matches
|
| 95 |
|
| 96 |
if not intent_scores:
|
| 97 |
-
|
| 98 |
-
return {
|
| 99 |
-
"primary_intent": "general_query",
|
| 100 |
-
"sub_intents": ["general_info"],
|
| 101 |
-
"confidence": 0.5,
|
| 102 |
-
"matched_keywords": []
|
| 103 |
-
}
|
| 104 |
|
| 105 |
-
#
|
| 106 |
-
|
| 107 |
-
|
|
|
|
| 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-
|
| 125 |
if config.get("sub_intents"):
|
| 126 |
sub_intents.append(config["sub_intents"][0])
|
| 127 |
|
| 128 |
-
# Detect
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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
|
| 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": ["
|
| 49 |
-
"priority_2": ["weather
|
| 50 |
-
"priority_3": ["
|
| 51 |
"priority_4": ["farmer_actions", "previous_analysis", "recommendations_history"]
|
| 52 |
},
|
|
|
|
| 53 |
"comparison": {
|
| 54 |
-
"priority_1": ["temporal_trends
|
| 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", "
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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)
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 162 |
-
"validate_context":
|
| 163 |
-
"contradict_context":
|
| 164 |
-
"confirm_context":
|
| 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
|
| 12 |
|
| 13 |
-
|
| 14 |
-
- Satellite imagery interpretation (Sentinel-
|
| 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 |
-
|
| 21 |
-
- Use simple, practical language farmers
|
| 22 |
-
-
|
| 23 |
-
-
|
| 24 |
-
-
|
|
|
|
| 25 |
- Be concise but thorough
|
| 26 |
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
# =============================================================================
|
| 30 |
-
# STAGE
|
| 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": ["
|
| 50 |
-
"confidence": 0.
|
| 51 |
-
"uncertainties": ["
|
| 52 |
-
}}
|
|
|
|
|
|
|
| 53 |
|
| 54 |
# =============================================================================
|
| 55 |
-
# STAGE
|
| 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 |
-
|
| 65 |
-
1. CONFIRM your hypothesis? (increases confidence)
|
| 66 |
-
2. WEAKEN your hypothesis? (decreases confidence)
|
| 67 |
-
3.
|
| 68 |
|
| 69 |
-
Respond in JSON format:
|
| 70 |
{{
|
| 71 |
"validation_result": "confirmed|weakened|neutral",
|
| 72 |
-
"confidence_updated": 0.
|
| 73 |
-
"spatial_notes": "
|
| 74 |
-
"
|
| 75 |
-
}}
|
|
|
|
|
|
|
| 76 |
|
| 77 |
# =============================================================================
|
| 78 |
-
# STAGE
|
| 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
|
| 91 |
-
3. What's
|
|
|
|
| 92 |
|
| 93 |
-
Respond in JSON format:
|
| 94 |
{{
|
| 95 |
-
"contradiction_found": true
|
| 96 |
-
"contradicting_evidence": ["
|
| 97 |
-
"alternative_hypothesis": "
|
| 98 |
-
"alternative_confidence": 0.
|
| 99 |
-
"reasoning": "
|
| 100 |
-
}}
|
|
|
|
|
|
|
| 101 |
|
| 102 |
# =============================================================================
|
| 103 |
-
# STAGE
|
| 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
|
| 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": "
|
| 122 |
-
"confidence": 0.
|
| 123 |
-
"causal_chain": "
|
| 124 |
-
"root_cause": "
|
| 125 |
-
"symptoms": ["
|
| 126 |
-
"
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
# =============================================================================
|
| 130 |
# RESPONSE GENERATION PROMPT
|
|
@@ -141,16 +166,43 @@ EVIDENCE SUMMARY:
|
|
| 141 |
{evidence}
|
| 142 |
|
| 143 |
Generate a response that:
|
| 144 |
-
1.
|
| 145 |
-
2. Explains the diagnosis in simple terms
|
| 146 |
-
3. Cites key evidence
|
| 147 |
-
4. Provides
|
| 148 |
-
5.
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
elif isinstance(value, list):
|
| 170 |
-
|
|
|
|
| 171 |
else:
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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"
|
| 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 |
-
#
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 116 |
-
validation.confidence,
|
| 117 |
-
staged_context["contradict_context"]
|
| 118 |
)
|
| 119 |
|
| 120 |
-
# Stage D: Confirm
|
|
|
|
| 121 |
confirmation = self._stage_confirm(
|
| 122 |
-
hypothesis_1=
|
| 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 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 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 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 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:
|
| 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 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 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 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
return StageResult(
|
| 273 |
stage="confirm",
|
| 274 |
output=output,
|
| 275 |
context_used=list(context.keys()),
|
| 276 |
-
confidence=output.get("confidence",
|
|
|
|
| 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
|
| 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 |
-
"
|
|
|
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
}
|
| 335 |
|
| 336 |
-
def
|
| 337 |
-
"""
|
| 338 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 346 |
elif "```" in text:
|
| 347 |
start = text.find("```") + 3
|
| 348 |
end = text.find("```", start)
|
| 349 |
-
|
|
|
|
| 350 |
|
| 351 |
# Find JSON object
|
| 352 |
start = text.find("{")
|
| 353 |
end = text.rfind("}") + 1
|
|
|
|
| 354 |
if start >= 0 and end > start:
|
| 355 |
-
|
|
|
|
|
|
|
|
|
|
| 356 |
|
| 357 |
-
return
|
| 358 |
|
| 359 |
|
| 360 |
-
#
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
| 362 |
"""Simplified single-stage reasoning for when full pipeline isn't needed."""
|
| 363 |
-
context_str = build_context_prompt(context) if context else "No
|
| 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)
|