Spaces:
Sleeping
Sleeping
Commit ·
e2d9873
1
Parent(s): f895e11
Upgrade to Hybrid Architecture v3 (Fast Lane + Deep Dive)
Browse files- app.py +129 -415
- context_aggregator.py +107 -0
- prompts.py +183 -86
- reasoning_engine.py +109 -13
app.py
CHANGED
|
@@ -1,13 +1,11 @@
|
|
| 1 |
"""
|
| 2 |
-
AGROW Agricultural Chatbot Service -
|
| 3 |
-
============================================================
|
| 4 |
-
AI-powered agricultural advisor with
|
| 5 |
-
-
|
| 6 |
-
-
|
| 7 |
-
-
|
| 8 |
-
-
|
| 9 |
-
- Farmer profile from questionnaire
|
| 10 |
-
- Field data from coordinates_quad
|
| 11 |
"""
|
| 12 |
|
| 13 |
import os
|
|
@@ -27,7 +25,9 @@ import asyncio
|
|
| 27 |
from groq import Groq
|
| 28 |
|
| 29 |
from supabase_client import SupabaseClient
|
| 30 |
-
from
|
|
|
|
|
|
|
| 31 |
|
| 32 |
# ============================================================================
|
| 33 |
# LOGGING
|
|
@@ -40,35 +40,61 @@ logging.basicConfig(
|
|
| 40 |
logger = logging.getLogger("ChatbotService")
|
| 41 |
|
| 42 |
print("=" * 50)
|
| 43 |
-
print(f"=====
|
| 44 |
print("=" * 50)
|
| 45 |
|
| 46 |
# ============================================================================
|
| 47 |
-
#
|
| 48 |
-
# ============================================================================
|
| 49 |
-
SAR_API_URL = os.getenv("SAR_API_URL", "https://aniket2006-agrow-backend-v2.hf.space")
|
| 50 |
-
SENTINEL2_API_URL = os.getenv("SENTINEL2_API_URL", "https://aniket2006-agrow-sentinel2.hf.space")
|
| 51 |
-
|
| 52 |
# ============================================================================
|
| 53 |
-
|
| 54 |
-
# ============================================================================
|
| 55 |
-
import sys
|
| 56 |
-
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)) + "/..")
|
| 57 |
-
from groq_client import GROQ_API_KEYS, GROQ_MODEL, call_groq
|
| 58 |
|
| 59 |
-
# Verify keys loaded
|
| 60 |
logger.info(f"Loaded {len(GROQ_API_KEYS)} Groq API keys")
|
| 61 |
|
| 62 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
supabase = SupabaseClient()
|
|
|
|
|
|
|
| 64 |
|
| 65 |
# ============================================================================
|
| 66 |
# FASTAPI
|
| 67 |
# ============================================================================
|
| 68 |
app = FastAPI(
|
| 69 |
title="AGROW Chatbot Service",
|
| 70 |
-
description="AI agricultural advisor with
|
| 71 |
-
version="
|
| 72 |
)
|
| 73 |
|
| 74 |
app.add_middleware(
|
|
@@ -93,6 +119,7 @@ class ChatResponse(BaseModel):
|
|
| 93 |
session_id: str
|
| 94 |
message_id: str
|
| 95 |
context_used: List[str]
|
|
|
|
| 96 |
timestamp: str
|
| 97 |
|
| 98 |
class SessionRequest(BaseModel):
|
|
@@ -105,40 +132,7 @@ class SessionResponse(BaseModel):
|
|
| 105 |
created_at: str
|
| 106 |
|
| 107 |
# ============================================================================
|
| 108 |
-
#
|
| 109 |
-
# ============================================================================
|
| 110 |
-
def detect_persona(questionnaire: Dict) -> str:
|
| 111 |
-
"""Detect user persona from questionnaire answers."""
|
| 112 |
-
if not questionnaire:
|
| 113 |
-
return "experienced_farmer_traditional"
|
| 114 |
-
|
| 115 |
-
experience = questionnaire.get("experience", "2 - 5 years")
|
| 116 |
-
tech = questionnaire.get("tech_comfort", "I can use basic features")
|
| 117 |
-
innovation = questionnaire.get("innovation", "I try new methods occasionally")
|
| 118 |
-
goal = questionnaire.get("farming_goal", "Earn Income / Livelihood")
|
| 119 |
-
role = questionnaire.get("role", "Farmer")
|
| 120 |
-
|
| 121 |
-
years = EXPERIENCE_MAP.get(experience, 3)
|
| 122 |
-
tech_level = TECH_COMFORT_MAP.get(tech, "moderate")
|
| 123 |
-
innovation_level = INNOVATION_MAP.get(innovation, "moderate")
|
| 124 |
-
|
| 125 |
-
if role == "Agricultural Officer":
|
| 126 |
-
return "agricultural_officer"
|
| 127 |
-
elif role in ["Agronomist", "Researcher"]:
|
| 128 |
-
return "agronomist_researcher"
|
| 129 |
-
|
| 130 |
-
if years < 3:
|
| 131 |
-
return "new_farmer_tech_savvy" if tech_level == "advanced" else "new_farmer_basic_tech"
|
| 132 |
-
elif FARMING_GOAL_MAP.get(goal) == "commercial":
|
| 133 |
-
return "commercial_farmer"
|
| 134 |
-
elif innovation_level == "innovative":
|
| 135 |
-
return "experienced_farmer_innovative"
|
| 136 |
-
else:
|
| 137 |
-
return "experienced_farmer_traditional"
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
# ============================================================================
|
| 141 |
-
# FETCH DATA DIRECTLY FROM APIs
|
| 142 |
# ============================================================================
|
| 143 |
def fetch_field_data(user_id: str, field_id: Optional[str] = None) -> Optional[Dict]:
|
| 144 |
"""Fetch field data from Supabase coordinates_quad."""
|
|
@@ -152,16 +146,13 @@ def fetch_field_data(user_id: str, field_id: Optional[str] = None) -> Optional[D
|
|
| 152 |
field = query.data[0]
|
| 153 |
lats = [field.get(f"lat{i}", 0) for i in range(1, 5)]
|
| 154 |
lons = [field.get(f"lon{i}", 0) for i in range(1, 5)]
|
| 155 |
-
center_lat = sum(lats) / 4
|
| 156 |
-
center_lon = sum(lons) / 4
|
| 157 |
-
|
| 158 |
return {
|
| 159 |
"id": field.get("id"),
|
| 160 |
"name": field.get("name", "My Field"),
|
| 161 |
"crop_type": field.get("crop_type", "Wheat"),
|
| 162 |
"area_acres": field.get("area_acres", 1.0),
|
| 163 |
-
"center_lat":
|
| 164 |
-
"center_lon":
|
| 165 |
"bbox": [min(lons), min(lats), max(lons), max(lats)]
|
| 166 |
}
|
| 167 |
except Exception as e:
|
|
@@ -179,370 +170,88 @@ def fetch_user_profile(user_id: str) -> Dict:
|
|
| 179 |
if query.data and len(query.data) > 0:
|
| 180 |
profile = query.data[0]
|
| 181 |
return {
|
| 182 |
-
"
|
| 183 |
-
"
|
| 184 |
-
"
|
| 185 |
}
|
| 186 |
except Exception as e:
|
| 187 |
logger.error(f"Error fetching user profile: {e}")
|
| 188 |
-
return {"
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
def fetch_sar_data(bbox: List, crop_type: str) -> Optional[Dict]:
|
| 192 |
-
"""Fetch SAR data directly from API."""
|
| 193 |
-
try:
|
| 194 |
-
response = requests.post(
|
| 195 |
-
f"{SAR_API_URL}/analyze",
|
| 196 |
-
json={
|
| 197 |
-
"coordinates": bbox,
|
| 198 |
-
"date": datetime.now().strftime("%Y-%m-%d"),
|
| 199 |
-
"crop_type": crop_type,
|
| 200 |
-
"farmer_context": {}
|
| 201 |
-
},
|
| 202 |
-
timeout=60
|
| 203 |
-
)
|
| 204 |
-
if response.status_code == 200:
|
| 205 |
-
data = response.json()
|
| 206 |
-
logger.info(f"SAR raw data keys: {list(data.keys())}")
|
| 207 |
-
return data
|
| 208 |
-
except Exception as e:
|
| 209 |
-
logger.error(f"SAR fetch error: {e}")
|
| 210 |
-
return None
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
def fetch_sentinel2_data(center_lat: float, center_lon: float, crop_type: str, field_hectares: float) -> Optional[Dict]:
|
| 214 |
-
"""Fetch Sentinel-2 data directly from API."""
|
| 215 |
-
try:
|
| 216 |
-
response = requests.post(
|
| 217 |
-
f"{SENTINEL2_API_URL}/analyze",
|
| 218 |
-
json={
|
| 219 |
-
"center_lat": center_lat,
|
| 220 |
-
"center_lon": center_lon,
|
| 221 |
-
"crop_type": crop_type,
|
| 222 |
-
"analysis_date": datetime.now().strftime("%Y-%m-%d"),
|
| 223 |
-
"field_size_hectares": field_hectares,
|
| 224 |
-
"farmer_context": {},
|
| 225 |
-
"skip_llm": True
|
| 226 |
-
},
|
| 227 |
-
timeout=90
|
| 228 |
-
)
|
| 229 |
-
if response.status_code == 200:
|
| 230 |
-
data = response.json()
|
| 231 |
-
logger.info(f"Sentinel-2 raw data keys: {list(data.keys())}")
|
| 232 |
-
return data
|
| 233 |
-
except Exception as e:
|
| 234 |
-
logger.error(f"Sentinel-2 fetch error: {e}")
|
| 235 |
-
return None
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
def fetch_weather(lat: float, lon: float) -> Optional[Dict]:
|
| 239 |
-
"""Fetch weather from Open-Meteo."""
|
| 240 |
-
try:
|
| 241 |
-
response = requests.get(
|
| 242 |
-
"https://api.open-meteo.com/v1/forecast",
|
| 243 |
-
params={
|
| 244 |
-
"latitude": lat,
|
| 245 |
-
"longitude": lon,
|
| 246 |
-
"current": "temperature_2m,relative_humidity_2m,precipitation,wind_speed_10m",
|
| 247 |
-
"daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max",
|
| 248 |
-
"timezone": "auto",
|
| 249 |
-
"forecast_days": 7
|
| 250 |
-
},
|
| 251 |
-
timeout=15
|
| 252 |
-
)
|
| 253 |
-
if response.status_code == 200:
|
| 254 |
-
data = response.json()
|
| 255 |
-
current = data.get("current", {})
|
| 256 |
-
daily = data.get("daily", {})
|
| 257 |
-
return {
|
| 258 |
-
"current_temp": current.get("temperature_2m"),
|
| 259 |
-
"current_humidity": current.get("relative_humidity_2m"),
|
| 260 |
-
"current_precipitation": current.get("precipitation"),
|
| 261 |
-
"forecast_max_temps": daily.get("temperature_2m_max", [])[:3],
|
| 262 |
-
"forecast_rain_prob": daily.get("precipitation_probability_max", [])[:3],
|
| 263 |
-
"forecast_precipitation": daily.get("precipitation_sum", [])[:3]
|
| 264 |
-
}
|
| 265 |
-
except Exception as e:
|
| 266 |
-
logger.error(f"Weather fetch error: {e}")
|
| 267 |
-
return None
|
| 268 |
|
| 269 |
|
| 270 |
# ============================================================================
|
| 271 |
-
# BUILD
|
| 272 |
# ============================================================================
|
| 273 |
-
def
|
| 274 |
-
"""Build
|
| 275 |
-
context = {
|
| 276 |
-
"fetch_timestamp": datetime.now().isoformat(),
|
| 277 |
-
"data_sources": []
|
| 278 |
-
}
|
| 279 |
|
| 280 |
-
# 1.
|
| 281 |
field = fetch_field_data(user_id, field_id)
|
| 282 |
if not field:
|
| 283 |
logger.warning("No field data found")
|
| 284 |
return context
|
| 285 |
|
| 286 |
-
context["
|
| 287 |
-
|
| 288 |
-
|
|
|
|
|
|
|
| 289 |
|
| 290 |
-
# 2.
|
| 291 |
profile = fetch_user_profile(user_id)
|
| 292 |
-
|
| 293 |
-
persona =
|
| 294 |
-
context["farmer"] = {
|
| 295 |
-
"name": profile.get("name", ""),
|
| 296 |
-
"persona": persona,
|
| 297 |
-
"experience": questionnaire.get("experience", ""),
|
| 298 |
-
"tech_comfort": questionnaire.get("tech_comfort", ""),
|
| 299 |
-
"farming_goal": questionnaire.get("farming_goal", "")
|
| 300 |
-
}
|
| 301 |
-
context["data_sources"].append("user_profiles")
|
| 302 |
-
logger.info(f"✓ Farmer persona: {persona}")
|
| 303 |
|
| 304 |
-
# 3. Fetch
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
"stress_score": sar_data.get("average_stress_score", 0),
|
| 311 |
-
"summary": sar_data.get("summary", ""),
|
| 312 |
-
"recommendations": sar_data.get("recommendations", []),
|
| 313 |
-
"health_summary": sar_data.get("health_summary", {}),
|
| 314 |
-
"stressed_patches": sar_data.get("stressed_patches", [])
|
| 315 |
-
}
|
| 316 |
-
context["data_sources"].append("sar_api")
|
| 317 |
-
logger.info(f"✓ SAR: health={sar_data.get('crop_health')}, stress={sar_data.get('average_stress_score')}")
|
| 318 |
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
vi_summary = s2_data.get("vegetation_indices_summary", {})
|
| 324 |
-
indices = vi_summary.get("indices", {})
|
| 325 |
-
|
| 326 |
-
# Extract all vegetation indices
|
| 327 |
-
vi_extracted = {}
|
| 328 |
-
for name, stats in indices.items():
|
| 329 |
-
if isinstance(stats, dict):
|
| 330 |
-
latest = stats.get("latest", {})
|
| 331 |
-
vi_extracted[name.upper()] = {
|
| 332 |
-
"mean": latest.get("mean"),
|
| 333 |
-
"min": stats.get("min_in_field"),
|
| 334 |
-
"max": stats.get("max_in_field"),
|
| 335 |
-
"change": stats.get("change")
|
| 336 |
-
}
|
| 337 |
-
|
| 338 |
-
if vi_extracted:
|
| 339 |
-
context["vegetation_indices"] = vi_extracted
|
| 340 |
-
logger.info(f"✓ Vegetation indices: {list(vi_extracted.keys())}")
|
| 341 |
-
|
| 342 |
-
# Extract stress_detection - cluster-wise patterns
|
| 343 |
-
stress_det = s2_data.get("stress_detection", {})
|
| 344 |
-
if stress_det:
|
| 345 |
-
context["stress_detection"] = {
|
| 346 |
-
"overall_stress": stress_det.get("overall_stress_score"),
|
| 347 |
-
"stress_category": stress_det.get("stress_category"),
|
| 348 |
-
"cluster_summary": stress_det.get("cluster_summary", []),
|
| 349 |
-
"high_stress_zones": stress_det.get("high_stress_zones", []),
|
| 350 |
-
"recommendations": stress_det.get("recommendations", [])
|
| 351 |
-
}
|
| 352 |
-
logger.info(f"✓ Stress detection: {stress_det.get('stress_category')}")
|
| 353 |
-
|
| 354 |
-
context["data_sources"].append("sentinel2_api")
|
| 355 |
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
|
| 363 |
-
logger.info(f"Context complete: {context['data_sources']}")
|
| 364 |
return context
|
| 365 |
|
| 366 |
|
| 367 |
# ============================================================================
|
| 368 |
-
#
|
| 369 |
# ============================================================================
|
| 370 |
-
def
|
| 371 |
-
"""
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
# Build context sections
|
| 379 |
-
sections = []
|
| 380 |
-
|
| 381 |
-
# 1. Field Information
|
| 382 |
-
field = context.get("field", {})
|
| 383 |
-
if field:
|
| 384 |
-
sections.append(f"""## Field Information
|
| 385 |
-
- Name: {field.get('name', 'Unknown')}
|
| 386 |
-
- Crop: {field.get('crop_type', 'Unknown')}
|
| 387 |
-
- Area: {field.get('area_acres', 0):.2f} acres
|
| 388 |
-
- Location: {field.get('center_lat', 0):.4f}°N, {field.get('center_lon', 0):.4f}°E""")
|
| 389 |
-
|
| 390 |
-
# 2. Vegetation Indices (ALL extracted)
|
| 391 |
-
vi = context.get("vegetation_indices", {})
|
| 392 |
-
if vi:
|
| 393 |
-
vi_lines = ["## Vegetation Indices (Sentinel-2 Analysis)"]
|
| 394 |
-
for name, data in vi.items():
|
| 395 |
-
if isinstance(data, dict) and data.get("mean") is not None:
|
| 396 |
-
mean = data.get("mean", 0)
|
| 397 |
-
change = data.get("change", 0)
|
| 398 |
-
change_str = f" (Δ{change:+.4f})" if isinstance(change, (int, float)) else ""
|
| 399 |
-
vi_lines.append(f"- {name}: {mean:.4f}{change_str}")
|
| 400 |
-
if len(vi_lines) > 1:
|
| 401 |
-
sections.append("\n".join(vi_lines))
|
| 402 |
-
|
| 403 |
-
# 3. SAR Analysis
|
| 404 |
-
sar = context.get("sar", {})
|
| 405 |
-
if sar:
|
| 406 |
-
stress = sar.get("stress_score", 0)
|
| 407 |
-
stress_str = f"{stress:.2f}" if isinstance(stress, (int, float)) else str(stress)
|
| 408 |
-
recommendations = sar.get("recommendations", [])
|
| 409 |
-
rec_list = "\n".join([f" • {r}" for r in recommendations[:3]]) if recommendations else " • No specific recommendations"
|
| 410 |
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
- Stress Score: {stress_str}
|
| 414 |
-
- Confidence: {sar.get('confidence', 0)}%
|
| 415 |
-
- Summary: {sar.get('summary', 'N/A')[:300]}
|
| 416 |
-
- Recommendations:
|
| 417 |
-
{rec_list}""")
|
| 418 |
-
|
| 419 |
-
# 4. Stress Detection (Cluster-wise)
|
| 420 |
-
stress = context.get("stress_detection", {})
|
| 421 |
-
if stress:
|
| 422 |
-
clusters = stress.get("cluster_summary", [])
|
| 423 |
-
cluster_lines = []
|
| 424 |
-
for c in clusters[:5]:
|
| 425 |
-
if isinstance(c, dict):
|
| 426 |
-
cluster_lines.append(f" • Cluster {c.get('id', '?')}: {c.get('stress_level', 'Unknown')} stress, {c.get('area_percent', 0):.1f}% of field")
|
| 427 |
|
| 428 |
-
|
| 429 |
-
zone_lines = []
|
| 430 |
-
for z in high_stress_zones[:3]:
|
| 431 |
-
if isinstance(z, dict):
|
| 432 |
-
zone_lines.append(f" • {z.get('location', 'Unknown')}: {z.get('severity', 'Unknown')}")
|
| 433 |
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
{chr(10).join(zone_lines) if zone_lines else ' • No high stress zones detected'}""")
|
| 441 |
-
|
| 442 |
-
# 5. Weather
|
| 443 |
-
weather = context.get("weather", {})
|
| 444 |
-
if weather:
|
| 445 |
-
sections.append(f"""## Weather Data
|
| 446 |
-
- Current Temperature: {weather.get('current_temp', 'N/A')}°C
|
| 447 |
-
- Humidity: {weather.get('current_humidity', 'N/A')}%
|
| 448 |
-
- Current Precipitation: {weather.get('current_precipitation', 0)} mm
|
| 449 |
-
- 3-Day Forecast Max Temps: {weather.get('forecast_max_temps', [])}
|
| 450 |
-
- Rain Probability (next 3 days): {weather.get('forecast_rain_prob', [])}%""")
|
| 451 |
-
|
| 452 |
-
# 6. Farmer Profile
|
| 453 |
-
sections.append(f"""## Farmer Profile
|
| 454 |
-
- Experience: {farmer.get('experience', 'Unknown')}
|
| 455 |
-
- Farming Goal: {farmer.get('farming_goal', 'Unknown')}
|
| 456 |
-
- Tech Comfort: {farmer.get('tech_comfort', 'Unknown')}""")
|
| 457 |
-
|
| 458 |
-
# Combine context
|
| 459 |
-
context_str = "\n\n".join(sections)
|
| 460 |
-
|
| 461 |
-
# Log what we're passing
|
| 462 |
-
logger.info(f"Prompt sections: {len(sections)}")
|
| 463 |
-
for i, s in enumerate(sections[:3]):
|
| 464 |
-
logger.info(f"Section[{i}]: {s[:100]}...")
|
| 465 |
-
|
| 466 |
-
prompt = f"""You are AGROW AI, an expert agricultural advisor analyzing REAL satellite data for an Indian farmer's field.
|
| 467 |
-
|
| 468 |
-
# CRITICAL: USE THE ACTUAL DATA BELOW
|
| 469 |
-
You MUST analyze and cite the specific values provided. DO NOT give generic advice.
|
| 470 |
-
Reference exact numbers like "Your NDVI of 0.65..." or "The 0.10 stress score indicates..."
|
| 471 |
-
|
| 472 |
-
# FARMER CONTEXT
|
| 473 |
-
{persona.get('description', 'Experienced farmer')}
|
| 474 |
-
Style: {persona.get('style', 'Practical')} | Tone: {persona.get('tone', 'Friendly')}
|
| 475 |
-
|
| 476 |
-
# SATELLITE ANALYSIS DATA (TODAY'S READINGS)
|
| 477 |
-
{context_str}
|
| 478 |
-
|
| 479 |
-
# USER'S QUESTION
|
| 480 |
-
{query}
|
| 481 |
-
|
| 482 |
-
# RESPONSE REQUIREMENTS
|
| 483 |
-
1. START with the field name and crop type
|
| 484 |
-
2. CITE at least 3 specific data values from above
|
| 485 |
-
3. If stress detected, address it with locations
|
| 486 |
-
4. Give 2-3 actionable recommendations based on the data
|
| 487 |
-
5. Keep response under 250 words
|
| 488 |
-
6. Use simple farmer-friendly language
|
| 489 |
-
|
| 490 |
-
Your analysis:"""
|
| 491 |
-
|
| 492 |
-
logger.info(f"Total prompt length: {len(prompt)} chars")
|
| 493 |
-
return prompt
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
# ============================================================================
|
| 497 |
-
# GENERATE RESPONSE
|
| 498 |
-
# ============================================================================
|
| 499 |
-
def generate_response(user_message: str, history: List[Dict], context: Dict) -> tuple[str, List[str]]:
|
| 500 |
-
"""Generate AI response using comprehensive context with API Key Rotation."""
|
| 501 |
-
context = context or {}
|
| 502 |
-
history = history or []
|
| 503 |
-
context_used = context.get("data_sources", [])
|
| 504 |
-
|
| 505 |
-
prompt = build_llm_prompt(user_message, context, history)
|
| 506 |
-
last_error = None
|
| 507 |
-
|
| 508 |
-
# Try keys sequentially with fallback on failure
|
| 509 |
-
for i, api_key in enumerate(GROQ_API_KEYS):
|
| 510 |
-
try:
|
| 511 |
-
logger.info(f"[Chatbot] Trying Groq API key {i+1}/{len(GROQ_API_KEYS)}")
|
| 512 |
-
client = Groq(api_key=api_key)
|
| 513 |
-
|
| 514 |
-
chat_completion = client.chat.completions.create(
|
| 515 |
-
messages=[
|
| 516 |
-
{
|
| 517 |
-
"role": "system",
|
| 518 |
-
"content": "You are AGROW AI, an expert agricultural advisor. Provide helpful, data-driven advice."
|
| 519 |
-
},
|
| 520 |
-
{
|
| 521 |
-
"role": "user",
|
| 522 |
-
"content": prompt
|
| 523 |
-
}
|
| 524 |
-
],
|
| 525 |
-
model=GROQ_MODEL,
|
| 526 |
-
temperature=0.7,
|
| 527 |
-
max_tokens=4096,
|
| 528 |
-
)
|
| 529 |
-
|
| 530 |
-
return chat_completion.choices[0].message.content, context_used
|
| 531 |
-
|
| 532 |
-
except Exception as e:
|
| 533 |
-
last_error = str(e)
|
| 534 |
-
logger.warning(f"[Chatbot] Key {i+1} failed: {e}")
|
| 535 |
-
# If it's not a rate limit issue, maybe we shouldn't retry?
|
| 536 |
-
# But for robustness, we'll assume any error warrants trying another key.
|
| 537 |
-
continue
|
| 538 |
-
|
| 539 |
-
# All keys failed
|
| 540 |
-
logger.error(f"[Chatbot] All {len(GROQ_API_KEYS)} keys failed. Last error: {last_error}")
|
| 541 |
-
traceback.print_exc()
|
| 542 |
-
fallback_msg = "I apologize, but I'm currently experiencing high traffic. Please try again in a moment."
|
| 543 |
-
if context.get('weather'): # Simple fallback if we have context
|
| 544 |
-
fallback_msg += f" (Weather: {context['weather'].get('current_temp')}°C)"
|
| 545 |
-
return fallback_msg, []
|
| 546 |
|
| 547 |
|
| 548 |
# ============================================================================
|
|
@@ -552,18 +261,19 @@ def generate_response(user_message: str, history: List[Dict], context: Dict) ->
|
|
| 552 |
async def root():
|
| 553 |
return {
|
| 554 |
"service": "AGROW Chatbot Service",
|
| 555 |
-
"version": "
|
| 556 |
-
"
|
|
|
|
| 557 |
}
|
| 558 |
|
| 559 |
@app.get("/health")
|
| 560 |
async def health():
|
| 561 |
-
return {"status": "healthy", "
|
| 562 |
|
| 563 |
|
| 564 |
@app.post("/session/new", response_model=SessionResponse)
|
| 565 |
async def create_session(request: SessionRequest):
|
| 566 |
-
logger.info(f"Creating
|
| 567 |
try:
|
| 568 |
session = supabase.create_session(
|
| 569 |
user_id=request.user_id,
|
|
@@ -581,7 +291,7 @@ async def create_session(request: SessionRequest):
|
|
| 581 |
|
| 582 |
@app.post("/chat", response_model=ChatResponse)
|
| 583 |
async def chat(request: ChatRequest):
|
| 584 |
-
logger.info(f"Chat request - Session: {request.session_id}")
|
| 585 |
|
| 586 |
try:
|
| 587 |
history = supabase.get_messages(request.session_id)
|
|
@@ -592,12 +302,14 @@ async def chat(request: ChatRequest):
|
|
| 592 |
content=request.message
|
| 593 |
)
|
| 594 |
|
| 595 |
-
# Build
|
| 596 |
context = {}
|
| 597 |
if request.user_id:
|
| 598 |
-
context =
|
| 599 |
|
| 600 |
-
response_text, context_used = generate_response(
|
|
|
|
|
|
|
| 601 |
|
| 602 |
assistant_msg_id = supabase.add_message(
|
| 603 |
session_id=request.session_id,
|
|
@@ -613,6 +325,7 @@ async def chat(request: ChatRequest):
|
|
| 613 |
session_id=request.session_id,
|
| 614 |
message_id=assistant_msg_id,
|
| 615 |
context_used=context_used,
|
|
|
|
| 616 |
timestamp=datetime.now().isoformat()
|
| 617 |
)
|
| 618 |
|
|
@@ -635,12 +348,13 @@ async def chat_stream(request: ChatRequest):
|
|
| 635 |
content=request.message
|
| 636 |
)
|
| 637 |
|
| 638 |
-
# Build comprehensive context
|
| 639 |
context = {}
|
| 640 |
if request.user_id:
|
| 641 |
-
context =
|
| 642 |
|
| 643 |
-
response_text, context_used = generate_response(
|
|
|
|
|
|
|
| 644 |
|
| 645 |
assistant_msg_id = supabase.add_message(
|
| 646 |
session_id=request.session_id,
|
|
@@ -652,7 +366,7 @@ async def chat_stream(request: ChatRequest):
|
|
| 652 |
supabase.update_session_timestamp(request.session_id)
|
| 653 |
|
| 654 |
async def stream_response():
|
| 655 |
-
yield f"data: {json.dumps({'type': 'metadata', 'session_id': request.session_id, 'message_id': assistant_msg_id, '
|
| 656 |
|
| 657 |
for i in range(0, len(response_text), 15):
|
| 658 |
yield f"data: {json.dumps({'type': 'chunk', 'text': response_text[i:i+15]})}\n\n"
|
|
@@ -670,7 +384,7 @@ async def chat_stream(request: ChatRequest):
|
|
| 670 |
@app.get("/context/{user_id}")
|
| 671 |
async def get_context(user_id: str, field_id: Optional[str] = None):
|
| 672 |
"""Debug endpoint - returns full context JSON."""
|
| 673 |
-
return
|
| 674 |
|
| 675 |
|
| 676 |
@app.get("/session/{session_id}/history")
|
|
@@ -702,5 +416,5 @@ async def delete_session(session_id: str):
|
|
| 702 |
|
| 703 |
if __name__ == "__main__":
|
| 704 |
import uvicorn
|
| 705 |
-
logger.info("Starting AGROW Chatbot Service
|
| 706 |
uvicorn.run(app, host="0.0.0.0", port=7860)
|
|
|
|
| 1 |
"""
|
| 2 |
+
AGROW Agricultural Chatbot Service - Hybrid Architecture v3
|
| 3 |
+
=============================================================
|
| 4 |
+
AI-powered agricultural advisor with:
|
| 5 |
+
- Hybrid Routing (Fast Lane vs Deep Dive)
|
| 6 |
+
- Fast Lane: 1-call for simple queries (<2s latency)
|
| 7 |
+
- Deep Dive: 3-call for complex diagnosis (Hypothesis → Adversary → Judge)
|
| 8 |
+
- Comprehensive satellite context from SAR, Sentinel-2, Weather
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
import os
|
|
|
|
| 25 |
from groq import Groq
|
| 26 |
|
| 27 |
from supabase_client import SupabaseClient
|
| 28 |
+
from reasoning_engine import ReasoningEngine
|
| 29 |
+
from context_aggregator import ContextAggregator
|
| 30 |
+
from prompts import create_user_persona, PERSONA_DEFINITIONS
|
| 31 |
|
| 32 |
# ============================================================================
|
| 33 |
# LOGGING
|
|
|
|
| 40 |
logger = logging.getLogger("ChatbotService")
|
| 41 |
|
| 42 |
print("=" * 50)
|
| 43 |
+
print(f"===== AGROW Chatbot v3.0 (Hybrid) - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} =====")
|
| 44 |
print("=" * 50)
|
| 45 |
|
| 46 |
# ============================================================================
|
| 47 |
+
# GROQ SETUP
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
# ============================================================================
|
| 49 |
+
from groq_client import GROQ_API_KEYS, GROQ_MODEL
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
|
|
|
| 51 |
logger.info(f"Loaded {len(GROQ_API_KEYS)} Groq API keys")
|
| 52 |
|
| 53 |
+
# Global key index for round-robin
|
| 54 |
+
current_key_idx = 0
|
| 55 |
+
|
| 56 |
+
def get_llm_caller():
|
| 57 |
+
"""Create LLM caller function with key rotation."""
|
| 58 |
+
global current_key_idx
|
| 59 |
+
|
| 60 |
+
def call_llm(prompt: str) -> str:
|
| 61 |
+
global current_key_idx
|
| 62 |
+
last_error = None
|
| 63 |
+
|
| 64 |
+
for attempt in range(len(GROQ_API_KEYS)):
|
| 65 |
+
key_idx = (current_key_idx + attempt) % len(GROQ_API_KEYS)
|
| 66 |
+
try:
|
| 67 |
+
client = Groq(api_key=GROQ_API_KEYS[key_idx])
|
| 68 |
+
response = client.chat.completions.create(
|
| 69 |
+
messages=[{"role": "user", "content": prompt}],
|
| 70 |
+
model=GROQ_MODEL,
|
| 71 |
+
temperature=0.7,
|
| 72 |
+
max_tokens=4096,
|
| 73 |
+
)
|
| 74 |
+
# Rotate to next key for next call
|
| 75 |
+
current_key_idx = (key_idx + 1) % len(GROQ_API_KEYS)
|
| 76 |
+
return response.choices[0].message.content
|
| 77 |
+
except Exception as e:
|
| 78 |
+
last_error = e
|
| 79 |
+
logger.warning(f"Key {key_idx+1} failed: {str(e)[:50]}")
|
| 80 |
+
continue
|
| 81 |
+
|
| 82 |
+
raise Exception(f"All {len(GROQ_API_KEYS)} keys failed. Last: {last_error}")
|
| 83 |
+
|
| 84 |
+
return call_llm
|
| 85 |
+
|
| 86 |
+
# Initialize components
|
| 87 |
supabase = SupabaseClient()
|
| 88 |
+
aggregator = ContextAggregator()
|
| 89 |
+
reasoning_engine = ReasoningEngine(llm_caller=get_llm_caller())
|
| 90 |
|
| 91 |
# ============================================================================
|
| 92 |
# FASTAPI
|
| 93 |
# ============================================================================
|
| 94 |
app = FastAPI(
|
| 95 |
title="AGROW Chatbot Service",
|
| 96 |
+
description="AI agricultural advisor with Hybrid Reasoning (Fast Lane + Deep Dive)",
|
| 97 |
+
version="3.0.0"
|
| 98 |
)
|
| 99 |
|
| 100 |
app.add_middleware(
|
|
|
|
| 119 |
session_id: str
|
| 120 |
message_id: str
|
| 121 |
context_used: List[str]
|
| 122 |
+
routing_mode: str # NEW: "FAST_LANE" or "DEEP_DIVE"
|
| 123 |
timestamp: str
|
| 124 |
|
| 125 |
class SessionRequest(BaseModel):
|
|
|
|
| 132 |
created_at: str
|
| 133 |
|
| 134 |
# ============================================================================
|
| 135 |
+
# FETCH FIELD & PROFILE DATA FROM SUPABASE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
# ============================================================================
|
| 137 |
def fetch_field_data(user_id: str, field_id: Optional[str] = None) -> Optional[Dict]:
|
| 138 |
"""Fetch field data from Supabase coordinates_quad."""
|
|
|
|
| 146 |
field = query.data[0]
|
| 147 |
lats = [field.get(f"lat{i}", 0) for i in range(1, 5)]
|
| 148 |
lons = [field.get(f"lon{i}", 0) for i in range(1, 5)]
|
|
|
|
|
|
|
|
|
|
| 149 |
return {
|
| 150 |
"id": field.get("id"),
|
| 151 |
"name": field.get("name", "My Field"),
|
| 152 |
"crop_type": field.get("crop_type", "Wheat"),
|
| 153 |
"area_acres": field.get("area_acres", 1.0),
|
| 154 |
+
"center_lat": sum(lats) / 4,
|
| 155 |
+
"center_lon": sum(lons) / 4,
|
| 156 |
"bbox": [min(lons), min(lats), max(lons), max(lats)]
|
| 157 |
}
|
| 158 |
except Exception as e:
|
|
|
|
| 170 |
if query.data and len(query.data) > 0:
|
| 171 |
profile = query.data[0]
|
| 172 |
return {
|
| 173 |
+
"full_name": profile.get("full_name", ""),
|
| 174 |
+
"address": profile.get("address", ""),
|
| 175 |
+
"questionnaire_data": profile.get("questionnaire_data", {}) or {}
|
| 176 |
}
|
| 177 |
except Exception as e:
|
| 178 |
logger.error(f"Error fetching user profile: {e}")
|
| 179 |
+
return {"full_name": "", "address": "", "questionnaire_data": {}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
|
| 182 |
# ============================================================================
|
| 183 |
+
# BUILD CONTEXT FOR REASONING ENGINE
|
| 184 |
# ============================================================================
|
| 185 |
+
def build_context_for_reasoning(user_id: str, field_id: Optional[str] = None) -> Dict:
|
| 186 |
+
"""Build context dict for reasoning engine using Supabase + APIs."""
|
| 187 |
+
context = {"fetch_timestamp": datetime.now().isoformat()}
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
+
# 1. Field data
|
| 190 |
field = fetch_field_data(user_id, field_id)
|
| 191 |
if not field:
|
| 192 |
logger.warning("No field data found")
|
| 193 |
return context
|
| 194 |
|
| 195 |
+
context["field_info"] = {
|
| 196 |
+
"name": field["name"],
|
| 197 |
+
"crop_type": field["crop_type"],
|
| 198 |
+
"area_acres": field["area_acres"]
|
| 199 |
+
}
|
| 200 |
|
| 201 |
+
# 2. Profile + Persona
|
| 202 |
profile = fetch_user_profile(user_id)
|
| 203 |
+
persona = create_user_persona(profile)
|
| 204 |
+
context["persona"] = persona
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
+
# 3. Fetch satellite data via aggregator
|
| 207 |
+
coordinates = {
|
| 208 |
+
"center_lat": field["center_lat"],
|
| 209 |
+
"center_lon": field["center_lon"],
|
| 210 |
+
"bbox": field["bbox"]
|
| 211 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
+
farmer_context = {
|
| 214 |
+
"profile": persona,
|
| 215 |
+
"actions": {}
|
| 216 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
+
try:
|
| 219 |
+
satellite_context = aggregator.fetch_full_context(
|
| 220 |
+
coordinates=coordinates,
|
| 221 |
+
crop_type=field["crop_type"],
|
| 222 |
+
area_acres=field["area_acres"],
|
| 223 |
+
farmer_context=farmer_context
|
| 224 |
+
)
|
| 225 |
+
context.update(satellite_context)
|
| 226 |
+
logger.info(f"Context built with keys: {list(context.keys())}")
|
| 227 |
+
except Exception as e:
|
| 228 |
+
logger.error(f"Error fetching satellite context: {e}")
|
| 229 |
|
|
|
|
| 230 |
return context
|
| 231 |
|
| 232 |
|
| 233 |
# ============================================================================
|
| 234 |
+
# GENERATE RESPONSE USING HYBRID REASONING
|
| 235 |
# ============================================================================
|
| 236 |
+
def generate_response(user_message: str, history: List[Dict], context: Dict) -> tuple[str, List[str], str]:
|
| 237 |
+
"""Generate AI response using Hybrid Reasoning Engine."""
|
| 238 |
+
try:
|
| 239 |
+
response_text, trace = reasoning_engine.process_query(
|
| 240 |
+
query=user_message,
|
| 241 |
+
context=context
|
| 242 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
|
| 244 |
+
routing_mode = trace.get("routing_mode", "UNKNOWN")
|
| 245 |
+
context_used = list(trace.get("context_priority_used", {}).get("priority_1", {}).keys())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
+
logger.info(f"[Hybrid] Mode: {routing_mode}, Diagnosis: {trace.get('stages', {}).get('confirmation', {}).get('final', 'N/A')[:50]}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
+
return response_text, context_used, routing_mode
|
| 250 |
+
|
| 251 |
+
except Exception as e:
|
| 252 |
+
logger.error(f"Reasoning error: {e}")
|
| 253 |
+
traceback.print_exc()
|
| 254 |
+
return "I apologize, but I encountered an error. Please try again.", [], "ERROR"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
|
| 257 |
# ============================================================================
|
|
|
|
| 261 |
async def root():
|
| 262 |
return {
|
| 263 |
"service": "AGROW Chatbot Service",
|
| 264 |
+
"version": "3.0.0",
|
| 265 |
+
"architecture": "Hybrid (Fast Lane + Deep Dive)",
|
| 266 |
+
"features": ["hybrid_routing", "3_stage_reasoning", "persona_based"]
|
| 267 |
}
|
| 268 |
|
| 269 |
@app.get("/health")
|
| 270 |
async def health():
|
| 271 |
+
return {"status": "healthy", "groq_keys": len(GROQ_API_KEYS), "version": "3.0.0"}
|
| 272 |
|
| 273 |
|
| 274 |
@app.post("/session/new", response_model=SessionResponse)
|
| 275 |
async def create_session(request: SessionRequest):
|
| 276 |
+
logger.info(f"Creating session for user: {request.user_id}")
|
| 277 |
try:
|
| 278 |
session = supabase.create_session(
|
| 279 |
user_id=request.user_id,
|
|
|
|
| 291 |
|
| 292 |
@app.post("/chat", response_model=ChatResponse)
|
| 293 |
async def chat(request: ChatRequest):
|
| 294 |
+
logger.info(f"Chat request - Session: {request.session_id}, Mode: Hybrid")
|
| 295 |
|
| 296 |
try:
|
| 297 |
history = supabase.get_messages(request.session_id)
|
|
|
|
| 302 |
content=request.message
|
| 303 |
)
|
| 304 |
|
| 305 |
+
# Build context and generate response
|
| 306 |
context = {}
|
| 307 |
if request.user_id:
|
| 308 |
+
context = build_context_for_reasoning(request.user_id, request.field_id)
|
| 309 |
|
| 310 |
+
response_text, context_used, routing_mode = generate_response(
|
| 311 |
+
request.message, history, context
|
| 312 |
+
)
|
| 313 |
|
| 314 |
assistant_msg_id = supabase.add_message(
|
| 315 |
session_id=request.session_id,
|
|
|
|
| 325 |
session_id=request.session_id,
|
| 326 |
message_id=assistant_msg_id,
|
| 327 |
context_used=context_used,
|
| 328 |
+
routing_mode=routing_mode,
|
| 329 |
timestamp=datetime.now().isoformat()
|
| 330 |
)
|
| 331 |
|
|
|
|
| 348 |
content=request.message
|
| 349 |
)
|
| 350 |
|
|
|
|
| 351 |
context = {}
|
| 352 |
if request.user_id:
|
| 353 |
+
context = build_context_for_reasoning(request.user_id, request.field_id)
|
| 354 |
|
| 355 |
+
response_text, context_used, routing_mode = generate_response(
|
| 356 |
+
request.message, history, context
|
| 357 |
+
)
|
| 358 |
|
| 359 |
assistant_msg_id = supabase.add_message(
|
| 360 |
session_id=request.session_id,
|
|
|
|
| 366 |
supabase.update_session_timestamp(request.session_id)
|
| 367 |
|
| 368 |
async def stream_response():
|
| 369 |
+
yield f"data: {json.dumps({'type': 'metadata', 'session_id': request.session_id, 'message_id': assistant_msg_id, 'routing_mode': routing_mode})}\n\n"
|
| 370 |
|
| 371 |
for i in range(0, len(response_text), 15):
|
| 372 |
yield f"data: {json.dumps({'type': 'chunk', 'text': response_text[i:i+15]})}\n\n"
|
|
|
|
| 384 |
@app.get("/context/{user_id}")
|
| 385 |
async def get_context(user_id: str, field_id: Optional[str] = None):
|
| 386 |
"""Debug endpoint - returns full context JSON."""
|
| 387 |
+
return build_context_for_reasoning(user_id, field_id)
|
| 388 |
|
| 389 |
|
| 390 |
@app.get("/session/{session_id}/history")
|
|
|
|
| 416 |
|
| 417 |
if __name__ == "__main__":
|
| 418 |
import uvicorn
|
| 419 |
+
logger.info("Starting AGROW Chatbot Service v3.0 (Hybrid Architecture)")
|
| 420 |
uvicorn.run(app, host="0.0.0.0", port=7860)
|
context_aggregator.py
CHANGED
|
@@ -778,6 +778,113 @@ class ContextAggregator:
|
|
| 778 |
}
|
| 779 |
|
| 780 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 781 |
# =============================================================================
|
| 782 |
# QUICK FUNCTIONS
|
| 783 |
# =============================================================================
|
|
|
|
| 778 |
}
|
| 779 |
|
| 780 |
|
| 781 |
+
def build_ultra_compact_context(self, context: Dict) -> str:
|
| 782 |
+
"""
|
| 783 |
+
Build ULTRA-COMPACT context for Fast Lane (1-Call).
|
| 784 |
+
|
| 785 |
+
Strictly filters for:
|
| 786 |
+
1. Primary Health Indices (NDVI, NDRE) + Interpretation
|
| 787 |
+
2. Soil Moisture (SMI) + Interpretation
|
| 788 |
+
3. Weather Summary + Alerts
|
| 789 |
+
4. Interpretation Strings (Crucial for 1-shot)
|
| 790 |
+
|
| 791 |
+
Excludes:
|
| 792 |
+
- SAR data (Too verbose)
|
| 793 |
+
- Historical trends (Unless critical)
|
| 794 |
+
- Raw bands
|
| 795 |
+
- Patch lists (Summary only)
|
| 796 |
+
"""
|
| 797 |
+
lines = []
|
| 798 |
+
|
| 799 |
+
# 1. Primary Indicators (Health)
|
| 800 |
+
veg = context.get("vegetation_indices", {})
|
| 801 |
+
health_parts = []
|
| 802 |
+
for k in ["NDVI", "NDRE", "EVI"]:
|
| 803 |
+
val = veg.get(k)
|
| 804 |
+
if val and isinstance(val, dict):
|
| 805 |
+
curr = val.get("current")
|
| 806 |
+
interp = val.get("interpretation", "")
|
| 807 |
+
if curr is not None:
|
| 808 |
+
health_parts.append(f"{k}:{curr:.2f}({interp})")
|
| 809 |
+
|
| 810 |
+
if health_parts:
|
| 811 |
+
lines.append(f"[HEALTH_SIGNALS] " + " | ".join(health_parts))
|
| 812 |
+
|
| 813 |
+
# 2. Secondary Indicators (Water/Stress)
|
| 814 |
+
water_parts = []
|
| 815 |
+
for k in ["SMI", "NDWI"]:
|
| 816 |
+
val = veg.get(k)
|
| 817 |
+
if val and isinstance(val, dict):
|
| 818 |
+
curr = val.get("current")
|
| 819 |
+
interp = val.get("interpretation", "")
|
| 820 |
+
if curr is not None:
|
| 821 |
+
water_parts.append(f"{k}:{curr:.2f}({interp})")
|
| 822 |
+
|
| 823 |
+
if water_parts:
|
| 824 |
+
lines.append(f"[WATER_SIGNALS] " + " | ".join(water_parts))
|
| 825 |
+
|
| 826 |
+
# 3. Weather Snapshot (Current + Alert)
|
| 827 |
+
weather = context.get("weather", {})
|
| 828 |
+
if weather:
|
| 829 |
+
curr = weather.get("current", {})
|
| 830 |
+
lines.append(f"[WEATHER] {curr.get('temp', '?')}°C, Rain: {curr.get('precip', '?')}mm")
|
| 831 |
+
|
| 832 |
+
# Critical Alerts Only
|
| 833 |
+
alerts = []
|
| 834 |
+
stress = weather.get("stress_indicators", {})
|
| 835 |
+
if stress.get("drought_risk"): alerts.append("DROUGHT_RISK")
|
| 836 |
+
if stress.get("current_heat_stress"): alerts.append("HEAT_STRESS")
|
| 837 |
+
if alerts:
|
| 838 |
+
lines.append(f"[ALERTS] " + ", ".join(alerts))
|
| 839 |
+
|
| 840 |
+
# 4. Stress Pattern
|
| 841 |
+
analysis = context.get("stress_analysis", {})
|
| 842 |
+
pct = analysis.get("impaired_percentage", 0)
|
| 843 |
+
if pct > 10:
|
| 844 |
+
lines.append(f"[PATTERN] {pct:.0f}% of field affected. Widespread stress.")
|
| 845 |
+
|
| 846 |
+
return "\n".join(lines)
|
| 847 |
+
|
| 848 |
+
def build_deep_dive_context(self, context: Dict, stage: str = "hypothesis") -> str:
|
| 849 |
+
"""
|
| 850 |
+
Build specialized context for Deep Dive stages.
|
| 851 |
+
"""
|
| 852 |
+
lines = []
|
| 853 |
+
|
| 854 |
+
# Common Data (Always needed)
|
| 855 |
+
lines.append(self.build_ultra_compact_context(context))
|
| 856 |
+
|
| 857 |
+
if stage == "hypothesis":
|
| 858 |
+
# Add History + Trends for robust hypothesis generation
|
| 859 |
+
trends = context.get("historical_trends", {})
|
| 860 |
+
if trends:
|
| 861 |
+
summary = trends.get("summary", "")
|
| 862 |
+
lines.append(f"[HISTORY] {summary}")
|
| 863 |
+
|
| 864 |
+
elif stage == "adversary":
|
| 865 |
+
# Add SAR + Soil + Detailed Weather for contradiction checking
|
| 866 |
+
# This is data that was HIDDEN in the Fast Lane
|
| 867 |
+
sar = context.get("sar_bands", {})
|
| 868 |
+
if sar:
|
| 869 |
+
lines.append(f"[SAR_DATA] VV:{sar.get('vv', '?')} VH:{sar.get('vh', '?')} Structure:{sar.get('interpretation', 'stable')}")
|
| 870 |
+
|
| 871 |
+
soil = context.get("soil_indicators", {})
|
| 872 |
+
if soil:
|
| 873 |
+
lines.append(f"[SOIL_LAB] Salinity:{soil.get('salinity', {}).get('level')} Organic:{soil.get('organic_matter', {}).get('level')}")
|
| 874 |
+
|
| 875 |
+
elif stage == "judge":
|
| 876 |
+
# Add Farmer Context + Constraints
|
| 877 |
+
farmer = context.get("farmer_profile", {})
|
| 878 |
+
actions = context.get("farmer_actions", {})
|
| 879 |
+
|
| 880 |
+
if farmer:
|
| 881 |
+
lines.append(f"[FARMER] Goal:{farmer.get('farming_goal')} Budget:{farmer.get('budget_level', 'medium')}")
|
| 882 |
+
if actions:
|
| 883 |
+
lines.append(f"[ACTIONS] Irrigated:{actions.get('days_since_irrigation')} days ago. Fertilized:{actions.get('days_since_fertilizer')} days ago.")
|
| 884 |
+
|
| 885 |
+
return "\n".join(lines)
|
| 886 |
+
|
| 887 |
+
|
| 888 |
# =============================================================================
|
| 889 |
# QUICK FUNCTIONS
|
| 890 |
# =============================================================================
|
prompts.py
CHANGED
|
@@ -15,59 +15,59 @@ from typing import Dict, List, Any, Optional
|
|
| 15 |
PERSONA_DEFINITIONS = {
|
| 16 |
"new_farmer_basic_tech": {
|
| 17 |
"description": "New farmer (0-3 years), limited smartphone familiarity",
|
| 18 |
-
"style": "Very simple, step-by-step guidance
|
| 19 |
-
"format": "Short bullet points, numbered steps,
|
| 20 |
-
"focus": "Immediate actions
|
| 21 |
"tone": "Warm, patient, encouraging, supportive",
|
| 22 |
-
"recommendations": "Simple low-cost solutions
|
| 23 |
},
|
| 24 |
"new_farmer_tech_savvy": {
|
| 25 |
"description": "New farmer (0-3 years), comfortable with technology",
|
| 26 |
-
"style": "Clear explanations
|
| 27 |
-
"format": "
|
| 28 |
-
"focus": "Learning-oriented
|
| 29 |
-
"tone": "Friendly, educational, encouraging
|
| 30 |
-
"recommendations": "Modern approaches
|
| 31 |
},
|
| 32 |
"experienced_farmer_traditional": {
|
| 33 |
"description": "Experienced farmer (5+ years), prefers traditional methods",
|
| 34 |
-
"style": "Practical
|
| 35 |
-
"format": "Direct recommendations
|
| 36 |
-
"focus": "
|
| 37 |
-
"tone": "Respectful of expertise,
|
| 38 |
-
"recommendations": "Proven methods first
|
| 39 |
},
|
| 40 |
"experienced_farmer_innovative": {
|
| 41 |
"description": "Experienced farmer (5+ years), open to modern methods",
|
| 42 |
-
"style": "Practical
|
| 43 |
-
"format": "
|
| 44 |
-
"focus": "
|
| 45 |
-
"tone": "Professional, partner-like
|
| 46 |
-
"recommendations": "Innovation welcomed
|
| 47 |
},
|
| 48 |
"commercial_farmer": {
|
| 49 |
"description": "Commercial farming focus, business-oriented",
|
| 50 |
-
"style": "Business-focused
|
| 51 |
-
"format": "Clear priorities
|
| 52 |
-
"focus": "
|
| 53 |
"tone": "Professional, efficient, results-oriented",
|
| 54 |
-
"recommendations": "Commercial-grade solutions
|
| 55 |
},
|
| 56 |
"agricultural_officer": {
|
| 57 |
"description": "Extension officer or field advisor",
|
| 58 |
-
"style": "Professional
|
| 59 |
-
"format": "
|
| 60 |
-
"focus": "Regional patterns
|
| 61 |
-
"tone": "Formal,
|
| 62 |
-
"recommendations": "Scalable solutions
|
| 63 |
},
|
| 64 |
"agronomist_researcher": {
|
| 65 |
"description": "Technical researcher or scientist",
|
| 66 |
-
"style": "Full technical precision
|
| 67 |
-
"format": "
|
| 68 |
-
"focus": "Causal mechanisms, data quality
|
| 69 |
"tone": "Scientific, analytical, evidence-driven",
|
| 70 |
-
"recommendations": "
|
| 71 |
}
|
| 72 |
}
|
| 73 |
|
|
@@ -215,7 +215,8 @@ IMPORTANT: Generate a response that this specific user will find most helpful an
|
|
| 215 |
# SYSTEM PROMPT (Base context)
|
| 216 |
# =============================================================================
|
| 217 |
|
| 218 |
-
SYSTEM_PROMPT = """You are AGROW AI,
|
|
|
|
| 219 |
|
| 220 |
SPECIALIZATIONS:
|
| 221 |
- Satellite imagery interpretation (Sentinel-1 SAR, Sentinel-2 optical bands)
|
|
@@ -225,18 +226,19 @@ SPECIALIZATIONS:
|
|
| 225 |
- Regional crop knowledge (wheat, rice, cotton, sugarcane, pulses, mustard, etc.)
|
| 226 |
|
| 227 |
COMMUNICATION STYLE:
|
| 228 |
-
- Use
|
| 229 |
-
-
|
| 230 |
-
-
|
| 231 |
-
-
|
| 232 |
-
-
|
| 233 |
-
-
|
|
|
|
| 234 |
|
| 235 |
ANALYSIS APPROACH:
|
| 236 |
-
- Always consider multiple hypotheses before concluding
|
| 237 |
-
- Seek contradicting evidence actively
|
| 238 |
-
- Build causal chains: Event A → Effect B → Symptom C
|
| 239 |
-
- Confidence scores reflect evidence strength
|
| 240 |
|
| 241 |
When you lack specific data, acknowledge it honestly and provide general guidance based on described symptoms."""
|
| 242 |
|
|
@@ -367,7 +369,8 @@ RESPONSE_PROMPT = """
|
|
| 367 |
CONVERSATION HISTORY (for follow-up awareness):
|
| 368 |
{conversation_history}
|
| 369 |
|
| 370 |
-
Based on the diagnostic analysis, generate a
|
|
|
|
| 371 |
|
| 372 |
USER QUERY: {query}
|
| 373 |
|
|
@@ -383,47 +386,46 @@ ZONE ANALYSIS:
|
|
| 383 |
HISTORICAL TRENDS:
|
| 384 |
{trend_context}
|
| 385 |
|
| 386 |
-
|
| 387 |
-
CRITICAL: RESPONSE STRUCTURE (MUST FOLLOW THIS
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
**
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
**
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
**
|
| 407 |
-
|
| 408 |
-
-
|
| 409 |
-
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
Keep total response under 150 words for basic farmers, up to 250 for researchers."""
|
| 427 |
|
| 428 |
|
| 429 |
# =============================================================================
|
|
@@ -972,4 +974,99 @@ def format_minimal_diagnosis(result) -> str:
|
|
| 972 |
"""Format diagnosis in minimal tokens."""
|
| 973 |
if isinstance(result, dict):
|
| 974 |
return f"diag:{result.get('final_diagnosis','')} conf:{result.get('final_confidence',0):.2f} cause:{result.get('root_cause','?')}"
|
| 975 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
PERSONA_DEFINITIONS = {
|
| 16 |
"new_farmer_basic_tech": {
|
| 17 |
"description": "New farmer (0-3 years), limited smartphone familiarity",
|
| 18 |
+
"style": "Very simple, step-by-step guidance. NO NUMBERS OR INDICES.",
|
| 19 |
+
"format": "Short bullet points, numbered steps. Use terms like 'Low', 'High', 'Good', 'Bad'.",
|
| 20 |
+
"focus": "Immediate actions. Explain strictly based on their irrigation method.",
|
| 21 |
"tone": "Warm, patient, encouraging, supportive",
|
| 22 |
+
"recommendations": "Simple low-cost solutions. Tailor to their specific farming technique."
|
| 23 |
},
|
| 24 |
"new_farmer_tech_savvy": {
|
| 25 |
"description": "New farmer (0-3 years), comfortable with technology",
|
| 26 |
+
"style": "Clear explanations without complex numbers. NO RAW INDICES.",
|
| 27 |
+
"format": "Use 'High'/'Moderate'/'Low' for status. Visual cues.",
|
| 28 |
+
"focus": "Learning-oriented. Explain 'why' using their specific crop context.",
|
| 29 |
+
"tone": "Friendly, educational, encouraging.",
|
| 30 |
+
"recommendations": "Modern approaches. Consider their specific irrigation setup."
|
| 31 |
},
|
| 32 |
"experienced_farmer_traditional": {
|
| 33 |
"description": "Experienced farmer (5+ years), prefers traditional methods",
|
| 34 |
+
"style": "Practical. NO RAW DATA/NUMBERS. Use traditional terms.",
|
| 35 |
+
"format": "Direct recommendations. Use 'Adequate', 'Stressed', 'Severe'.",
|
| 36 |
+
"focus": "Risk assessment. Relate to their traditional techniques.",
|
| 37 |
+
"tone": "Respectful of expertise, peer-to-peer.",
|
| 38 |
+
"recommendations": "Proven methods first. Match their existing irrigation habits."
|
| 39 |
},
|
| 40 |
"experienced_farmer_innovative": {
|
| 41 |
"description": "Experienced farmer (5+ years), open to modern methods",
|
| 42 |
+
"style": "Practical but simplified metrics. NO COMPLEX DECIMALS.",
|
| 43 |
+
"format": "Use 'High Efficiency' vs 'Low'.",
|
| 44 |
+
"focus": "Efficiency. Optimize their specific machinery/irrigation inputs.",
|
| 45 |
+
"tone": "Professional, partner-like.",
|
| 46 |
+
"recommendations": "Innovation welcomed. Precision approaches for their equipment."
|
| 47 |
},
|
| 48 |
"commercial_farmer": {
|
| 49 |
"description": "Commercial farming focus, business-oriented",
|
| 50 |
+
"style": "Business-focused. Summary stats only (High/Low risks).",
|
| 51 |
+
"format": "Clear priorities. Zone breakdowns by 'Severity' (not index).",
|
| 52 |
+
"focus": "ROI and scalable solutions for their infrastructure.",
|
| 53 |
"tone": "Professional, efficient, results-oriented",
|
| 54 |
+
"recommendations": "Commercial-grade solutions tailored to their scale/inputs."
|
| 55 |
},
|
| 56 |
"agricultural_officer": {
|
| 57 |
"description": "Extension officer or field advisor",
|
| 58 |
+
"style": "Professional summary. Minimal raw numbers, focus on status.",
|
| 59 |
+
"format": "Zone-wise 'Affected' vs 'Healthy'. trend direction.",
|
| 60 |
+
"focus": "Regional patterns. Farmer communication tips.",
|
| 61 |
+
"tone": "Formal, shareable insights",
|
| 62 |
+
"recommendations": "Scalable solutions for the region's common techniques."
|
| 63 |
},
|
| 64 |
"agronomist_researcher": {
|
| 65 |
"description": "Technical researcher or scientist",
|
| 66 |
+
"style": "Full technical precision. HEAVY USE OF NUMERICAL DATA & INDICES.",
|
| 67 |
+
"format": "Tables with exact NDVI/NDRE values. Statistical confidence intervals.",
|
| 68 |
+
"focus": "Causal mechanisms, data quality, methodology.",
|
| 69 |
"tone": "Scientific, analytical, evidence-driven",
|
| 70 |
+
"recommendations": "Experimental approaches. Cite specific spectral bands/thresholds."
|
| 71 |
}
|
| 72 |
}
|
| 73 |
|
|
|
|
| 215 |
# SYSTEM PROMPT (Base context)
|
| 216 |
# =============================================================================
|
| 217 |
|
| 218 |
+
SYSTEM_PROMPT = """You are AGROW AI, a dedicated PERSONAL agricultural advisor for Indian farmers.
|
| 219 |
+
Your goal is to be a trusted partner in their farming journey, not just a data analyzer.
|
| 220 |
|
| 221 |
SPECIALIZATIONS:
|
| 222 |
- Satellite imagery interpretation (Sentinel-1 SAR, Sentinel-2 optical bands)
|
|
|
|
| 226 |
- Regional crop knowledge (wheat, rice, cotton, sugarcane, pulses, mustard, etc.)
|
| 227 |
|
| 228 |
COMMUNICATION STYLE:
|
| 229 |
+
- Use a PERSONAL, RELATABLE tone. Use "I", "We", and refer to "Your field".
|
| 230 |
+
- Avoid neutral, robotic assertions. Show empathy and understanding.
|
| 231 |
+
- Use simple, practical language farmers understand.
|
| 232 |
+
- Cite specific values but explain what they mean for *their* specific field.
|
| 233 |
+
- Distinguish ROOT CAUSE from SYMPTOMS.
|
| 234 |
+
- Give actionable, prioritized recommendations.
|
| 235 |
+
- Reference local conditions and seasonal context.
|
| 236 |
|
| 237 |
ANALYSIS APPROACH:
|
| 238 |
+
- Always consider multiple hypotheses before concluding.
|
| 239 |
+
- Seek contradicting evidence actively.
|
| 240 |
+
- Build causal chains: Event A → Effect B → Symptom C.
|
| 241 |
+
- Confidence scores reflect evidence strength.
|
| 242 |
|
| 243 |
When you lack specific data, acknowledge it honestly and provide general guidance based on described symptoms."""
|
| 244 |
|
|
|
|
| 369 |
CONVERSATION HISTORY (for follow-up awareness):
|
| 370 |
{conversation_history}
|
| 371 |
|
| 372 |
+
Based on the diagnostic analysis, generate a COMPREHENSIVE and DETAILED response.
|
| 373 |
+
The user wants a full explanation, not just a summary.
|
| 374 |
|
| 375 |
USER QUERY: {query}
|
| 376 |
|
|
|
|
| 386 |
HISTORICAL TRENDS:
|
| 387 |
{trend_context}
|
| 388 |
|
| 389 |
+
---------------------------------------------------------------
|
| 390 |
+
CRITICAL: RESPONSE STRUCTURE (MUST FOLLOW THIS EXACT FORMAT)
|
| 391 |
+
---------------------------------------------------------------
|
| 392 |
+
|
| 393 |
+
**DIAGNOSIS & STATUS**
|
| 394 |
+
* Clearly state the primary issue identified (or confirmation of health).
|
| 395 |
+
* Mention the severity level (Mild/Moderate/Severe) based on the data.
|
| 396 |
+
* State the confidence level in this diagnosis.
|
| 397 |
+
|
| 398 |
+
**DETAILED REASONING**
|
| 399 |
+
* Explain *WHY* this is the diagnosis, connecting the dots between different data points.
|
| 400 |
+
* Cite specific metrics (NDVI, SMI, NDRE) and explain what they mean in this context.
|
| 401 |
+
* Explicitly mention if the 3-stage analysis (Hypothesis -> Adversary -> Judge) ruled out other causes.
|
| 402 |
+
* Reference historical trends or weather patterns that support this conclusion.
|
| 403 |
+
|
| 404 |
+
**FUTURE RISKS**
|
| 405 |
+
* Explain what will happen if this issue is ignored for 3-5 days.
|
| 406 |
+
* Mention potential yield impact or long-term damage.
|
| 407 |
+
* Flag any upcoming weather risks (e.g., "Forecast rain might worsen fungal spread").
|
| 408 |
+
|
| 409 |
+
**RECOMMENDATIONS**
|
| 410 |
+
* **Immediate Action**: What needs to be done TODAY? (be specific: amounts, methods).
|
| 411 |
+
* **Follow-up**: What to check in 3 days.
|
| 412 |
+
* **Long-term**: Preventative measures for next season.
|
| 413 |
+
|
| 414 |
+
**NEXT STEPS**
|
| 415 |
+
* End with a specific question to keep the conversation going.
|
| 416 |
+
* Examples:
|
| 417 |
+
* "Should I help you calculate the fertilizer dosage?"
|
| 418 |
+
* "Would you like to analyze the historical trends for this field?"
|
| 419 |
+
* "Shall I monitor this area for you over the next week?"
|
| 420 |
+
|
| 421 |
+
---------------------------------------------------------------
|
| 422 |
+
GUIDELINES:
|
| 423 |
+
* NO EMOJIS in the output.
|
| 424 |
+
* Use asterisk (*) for bullet points, do NOT use hyphens (-).
|
| 425 |
+
* Bold the section headings.
|
| 426 |
+
* Tone: Professional, authoritative, but helpful (Agro-Expert).
|
| 427 |
+
* Length: Comprehensive (300-500 words is acceptable for Deep Dive).
|
| 428 |
+
"""
|
|
|
|
| 429 |
|
| 430 |
|
| 431 |
# =============================================================================
|
|
|
|
| 974 |
"""Format diagnosis in minimal tokens."""
|
| 975 |
if isinstance(result, dict):
|
| 976 |
return f"diag:{result.get('final_diagnosis','')} conf:{result.get('final_confidence',0):.2f} cause:{result.get('root_cause','?')}"
|
| 977 |
+
|
| 978 |
+
# =============================================================================
|
| 979 |
+
# HYBRID ARCHITECTURE PROMPTS
|
| 980 |
+
# =============================================================================
|
| 981 |
+
|
| 982 |
+
FAST_LANE_PROMPT = """You are Agrow-AI.
|
| 983 |
+
TASK: Diagnose the crop issue based on the provided context.
|
| 984 |
+
PRIORITY: SPEED & ACCURACY.
|
| 985 |
+
|
| 986 |
+
CONTEXT:
|
| 987 |
+
{context}
|
| 988 |
+
|
| 989 |
+
INSTRUCTIONS:
|
| 990 |
+
1. [Hypothesis]: Briefly state what the primary signals (NDVI, NDRE, etc.) suggest.
|
| 991 |
+
2. [Check]: Verify if supporting data (Moisture, Weather) aligns or contradicts.
|
| 992 |
+
3. [Diagnosis]: State the final conclusion.
|
| 993 |
+
4. [Action]: One specific corrective action.
|
| 994 |
+
|
| 995 |
+
OUTPUT JSON ONLY:
|
| 996 |
+
{{
|
| 997 |
+
"reasoning_trace": "Hypothesis... Check... Conclusion...",
|
| 998 |
+
"diagnosis": "Final Diagnosis",
|
| 999 |
+
"confidence": 0.0-1.0,
|
| 1000 |
+
"action": "Corrective Action"
|
| 1001 |
+
}}
|
| 1002 |
+
"""
|
| 1003 |
+
|
| 1004 |
+
DEEP_DIVE_HYPOTHESIS_PROMPT = """You are Agrow-AI, conducting a DEEP DIVE diagnosis.
|
| 1005 |
+
STAGE A: HYPOTHESIS GENERATION
|
| 1006 |
+
|
| 1007 |
+
CONTEXT:
|
| 1008 |
+
{context}
|
| 1009 |
+
|
| 1010 |
+
TASK:
|
| 1011 |
+
Identify top 3 possible causes for the observed issues. Do not conclude yet.
|
| 1012 |
+
Think broadly (Nutrients, Pests, Water, Soil, Disease).
|
| 1013 |
+
|
| 1014 |
+
OUTPUT JSON ONLY:
|
| 1015 |
+
{{
|
| 1016 |
+
"hypotheses": [
|
| 1017 |
+
{{"cause": "Cause 1", "likelihood": "High/Med", "reason": "why"}},
|
| 1018 |
+
{{"cause": "Cause 2", "likelihood": "High/Med", "reason": "why"}},
|
| 1019 |
+
{{"cause": "Cause 3", "likelihood": "High/Med", "reason": "why"}}
|
| 1020 |
+
]
|
| 1021 |
+
}}
|
| 1022 |
+
"""
|
| 1023 |
+
|
| 1024 |
+
DEEP_DIVE_ADVERSARY_PROMPT = """You are Agrow-AI.
|
| 1025 |
+
STAGE B: ADVERSARIAL CHECK
|
| 1026 |
+
|
| 1027 |
+
HYPOTHESES:
|
| 1028 |
+
{hypotheses}
|
| 1029 |
+
|
| 1030 |
+
NEW EVIDENCE (Adversarial Data):
|
| 1031 |
+
{context}
|
| 1032 |
+
|
| 1033 |
+
TASK:
|
| 1034 |
+
Actively try to DISPROVE each hypothesis using the new evidence (SAR, Soil, Pests).
|
| 1035 |
+
If evidence contradicts a hypothesis, mark it as INVALID.
|
| 1036 |
+
|
| 1037 |
+
OUTPUT JSON ONLY:
|
| 1038 |
+
{{
|
| 1039 |
+
"analysis": [
|
| 1040 |
+
{{"cause": "Cause 1", "status": "Valid/Invalid", "reason": "Support/Contradiction from new evidence"}},
|
| 1041 |
+
...
|
| 1042 |
+
],
|
| 1043 |
+
"surviving_hypothesis": "The strongest remaining cause",
|
| 1044 |
+
"confidence": 0.0-1.0
|
| 1045 |
+
}}
|
| 1046 |
+
"""
|
| 1047 |
+
|
| 1048 |
+
DEEP_DIVE_JUDGE_PROMPT = """You are Agrow-AI.
|
| 1049 |
+
STAGE C: FINAL VERDICT
|
| 1050 |
+
|
| 1051 |
+
WINNING HYPOTHESIS:
|
| 1052 |
+
{hypothesis}
|
| 1053 |
+
|
| 1054 |
+
CONSTRAINTS & HISTORY:
|
| 1055 |
+
{context}
|
| 1056 |
+
|
| 1057 |
+
TASK:
|
| 1058 |
+
Provide the final diagnostic report and a detailed action plan.
|
| 1059 |
+
Consider farmer constraints (budget, machinery) and historical trends.
|
| 1060 |
+
|
| 1061 |
+
OUTPUT JSON ONLY:
|
| 1062 |
+
{{
|
| 1063 |
+
"final_diagnosis": "Diagnosis",
|
| 1064 |
+
"root_cause": "Root Cause",
|
| 1065 |
+
"detailed_reasoning": "Explanation of why this is the verdict",
|
| 1066 |
+
"action_plan": {{
|
| 1067 |
+
"immediate": "Action 1",
|
| 1068 |
+
"long_term": "Action 2"
|
| 1069 |
+
}}
|
| 1070 |
+
}}
|
| 1071 |
+
"""
|
| 1072 |
+
|
reasoning_engine.py
CHANGED
|
@@ -19,9 +19,14 @@ from prompts import (
|
|
| 19 |
# Compact prompts for token reduction
|
| 20 |
COMPACT_CLAIM_PROMPT, COMPACT_VALIDATE_PROMPT, COMPACT_CONTRADICT_PROMPT,
|
| 21 |
COMPACT_CONFIRM_PROMPT, COMPACT_RESPONSE_PROMPT,
|
| 22 |
-
build_compact_context, get_compact_prompt, format_minimal_diagnosis
|
|
|
|
|
|
|
|
|
|
| 23 |
)
|
| 24 |
|
|
|
|
|
|
|
| 25 |
logger = logging.getLogger("ReasoningEngine")
|
| 26 |
|
| 27 |
# Toggle compact prompts to reduce token usage (saves ~50% tokens)
|
|
@@ -81,6 +86,7 @@ class ReasoningEngine:
|
|
| 81 |
self.llm = llm_caller
|
| 82 |
self.intent_classifier = IntentClassifier()
|
| 83 |
self.priority_mapper = PriorityContextMapper()
|
|
|
|
| 84 |
|
| 85 |
def process_query(
|
| 86 |
self,
|
|
@@ -88,10 +94,7 @@ class ReasoningEngine:
|
|
| 88 |
context: Optional[Dict[str, Any]] = None
|
| 89 |
) -> Tuple[str, Dict[str, Any]]:
|
| 90 |
"""
|
| 91 |
-
Process user query through
|
| 92 |
-
|
| 93 |
-
Returns:
|
| 94 |
-
(response_text, reasoning_trace)
|
| 95 |
"""
|
| 96 |
logger.info(f"Processing query: {query[:50]}...")
|
| 97 |
|
|
@@ -99,16 +102,18 @@ class ReasoningEngine:
|
|
| 99 |
intent = self.intent_classifier.classify(query)
|
| 100 |
logger.info(f"Intent: {intent['primary_intent']} ({intent['confidence']})")
|
| 101 |
|
| 102 |
-
# Stage 2:
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
full_context=context or {}
|
| 106 |
-
)
|
| 107 |
|
| 108 |
-
# Stage 3:
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
# Stage 4: Generate response (pass full context for persona/weather/zone)
|
|
|
|
| 112 |
response = self._generate_response(query, reasoning_result, context)
|
| 113 |
|
| 114 |
# Stage 5: Generate followups
|
|
@@ -118,9 +123,100 @@ class ReasoningEngine:
|
|
| 118 |
)
|
| 119 |
|
| 120 |
# Build complete trace
|
| 121 |
-
trace = self._build_trace(intent, reasoning_result,
|
|
|
|
| 122 |
|
| 123 |
return response, trace
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
def _reason(
|
| 126 |
self,
|
|
|
|
| 19 |
# Compact prompts for token reduction
|
| 20 |
COMPACT_CLAIM_PROMPT, COMPACT_VALIDATE_PROMPT, COMPACT_CONTRADICT_PROMPT,
|
| 21 |
COMPACT_CONFIRM_PROMPT, COMPACT_RESPONSE_PROMPT,
|
| 22 |
+
build_compact_context, get_compact_prompt, format_minimal_diagnosis,
|
| 23 |
+
# Hybrid Prompts
|
| 24 |
+
FAST_LANE_PROMPT, DEEP_DIVE_HYPOTHESIS_PROMPT,
|
| 25 |
+
DEEP_DIVE_ADVERSARY_PROMPT, DEEP_DIVE_JUDGE_PROMPT
|
| 26 |
)
|
| 27 |
|
| 28 |
+
from context_aggregator import ContextAggregator
|
| 29 |
+
|
| 30 |
logger = logging.getLogger("ReasoningEngine")
|
| 31 |
|
| 32 |
# Toggle compact prompts to reduce token usage (saves ~50% tokens)
|
|
|
|
| 86 |
self.llm = llm_caller
|
| 87 |
self.intent_classifier = IntentClassifier()
|
| 88 |
self.priority_mapper = PriorityContextMapper()
|
| 89 |
+
self.aggregator = ContextAggregator()
|
| 90 |
|
| 91 |
def process_query(
|
| 92 |
self,
|
|
|
|
| 94 |
context: Optional[Dict[str, Any]] = None
|
| 95 |
) -> Tuple[str, Dict[str, Any]]:
|
| 96 |
"""
|
| 97 |
+
Process user query through Hybrid Architecture (Fast Lane vs Deep Dive).
|
|
|
|
|
|
|
|
|
|
| 98 |
"""
|
| 99 |
logger.info(f"Processing query: {query[:50]}...")
|
| 100 |
|
|
|
|
| 102 |
intent = self.intent_classifier.classify(query)
|
| 103 |
logger.info(f"Intent: {intent['primary_intent']} ({intent['confidence']})")
|
| 104 |
|
| 105 |
+
# Stage 2: Route Query
|
| 106 |
+
mode = self.route_query(query, intent)
|
| 107 |
+
logger.info(f"Routing mode: {mode}")
|
|
|
|
|
|
|
| 108 |
|
| 109 |
+
# Stage 3: Execute Logic
|
| 110 |
+
if mode == "FAST_LANE":
|
| 111 |
+
reasoning_result = self._execute_fast_lane(query, intent, context or {})
|
| 112 |
+
else:
|
| 113 |
+
reasoning_result = self._execute_deep_dive(query, intent, context or {})
|
| 114 |
|
| 115 |
# Stage 4: Generate response (pass full context for persona/weather/zone)
|
| 116 |
+
# Note: Fast Lane already generates action/diagnosis, but we standardize output format
|
| 117 |
response = self._generate_response(query, reasoning_result, context)
|
| 118 |
|
| 119 |
# Stage 5: Generate followups
|
|
|
|
| 123 |
)
|
| 124 |
|
| 125 |
# Build complete trace
|
| 126 |
+
trace = self._build_trace(intent, reasoning_result, {}, followups)
|
| 127 |
+
trace["routing_mode"] = mode
|
| 128 |
|
| 129 |
return response, trace
|
| 130 |
+
|
| 131 |
+
def route_query(self, query: str, intent: Dict) -> str:
|
| 132 |
+
"""Decide between Fast Lane and Deep Dive."""
|
| 133 |
+
# Intention-based routing
|
| 134 |
+
fast_intents = ["vegetation_health", "water_stress", "nutrient_status"]
|
| 135 |
+
if intent["primary_intent"] in fast_intents and intent["confidence"] > 0.7:
|
| 136 |
+
return "FAST_LANE"
|
| 137 |
+
|
| 138 |
+
# "Why" questions or Comparisons usually need Deep Dive
|
| 139 |
+
if "compare" in query.lower() or "difference" in query.lower():
|
| 140 |
+
return "DEEP_DIVE"
|
| 141 |
+
|
| 142 |
+
return "DEEP_DIVE" # Default to robust mode for safety
|
| 143 |
+
|
| 144 |
+
def _execute_fast_lane(self, query: str, intent: Dict, context: Dict) -> ReasoningResult:
|
| 145 |
+
"""Execute 1-Shot Reasoning."""
|
| 146 |
+
logger.info("Executing FAST LANE (1-Call)...")
|
| 147 |
+
|
| 148 |
+
# Build ultra-compact context
|
| 149 |
+
compact_ctx = self.aggregator.build_ultra_compact_context(context)
|
| 150 |
+
|
| 151 |
+
prompt = FAST_LANE_PROMPT.format(context=compact_ctx)
|
| 152 |
+
full_prompt = f"{SYSTEM_PROMPT}\n\n{prompt}"
|
| 153 |
+
|
| 154 |
+
response = self.llm(full_prompt)
|
| 155 |
+
|
| 156 |
+
output = self._parse_json_safe(response, {
|
| 157 |
+
"reasoning_trace": "Analysis failed",
|
| 158 |
+
"diagnosis": "Unknown",
|
| 159 |
+
"confidence": 0.0,
|
| 160 |
+
"action": "Consult expert"
|
| 161 |
+
})
|
| 162 |
+
|
| 163 |
+
# Create Dummy StageResult for compatibility
|
| 164 |
+
dummy_stage = StageResult("fast_lane", output, [], output.get("confidence", 0.0))
|
| 165 |
+
|
| 166 |
+
return ReasoningResult(
|
| 167 |
+
claim=dummy_stage, # Fill for struct compatibility
|
| 168 |
+
validation=dummy_stage,
|
| 169 |
+
contradiction=dummy_stage,
|
| 170 |
+
confirmation=dummy_stage,
|
| 171 |
+
final_diagnosis=output.get("diagnosis", "Unknown"),
|
| 172 |
+
final_confidence=output.get("confidence", 0.0),
|
| 173 |
+
causal_chain=output.get("reasoning_trace", ""),
|
| 174 |
+
root_cause=output.get("diagnosis", "Unknown"),
|
| 175 |
+
symptoms=[],
|
| 176 |
+
recommendation=output.get("action", ""),
|
| 177 |
+
evidence_summary={"method": ["fast_lane_optimization"]}
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
def _execute_deep_dive(self, query: str, intent: Dict, context: Dict) -> ReasoningResult:
|
| 181 |
+
"""Execute 3-Stage Deep Dive."""
|
| 182 |
+
logger.info("Executing DEEP DIVE (3-Call)...")
|
| 183 |
+
|
| 184 |
+
# 1. Hypothesis Generation
|
| 185 |
+
ctx_hyp = self.aggregator.build_deep_dive_context(context, "hypothesis")
|
| 186 |
+
resp_hyp = self.llm(f"{SYSTEM_PROMPT}\n{DEEP_DIVE_HYPOTHESIS_PROMPT.format(context=ctx_hyp)}")
|
| 187 |
+
out_hyp = self._parse_json_safe(resp_hyp, {"hypotheses": []})
|
| 188 |
+
|
| 189 |
+
# 2. Adversarial Check
|
| 190 |
+
ctx_adv = self.aggregator.build_deep_dive_context(context, "adversary")
|
| 191 |
+
hyp_str = json.dumps(out_hyp, indent=2)
|
| 192 |
+
resp_adv = self.llm(f"{SYSTEM_PROMPT}\n{DEEP_DIVE_ADVERSARY_PROMPT.format(hypotheses=hyp_str, context=ctx_adv)}")
|
| 193 |
+
out_adv = self._parse_json_safe(resp_adv, {"surviving_hypothesis": "Unknown"})
|
| 194 |
+
|
| 195 |
+
# 3. Final Verdict
|
| 196 |
+
ctx_judge = self.aggregator.build_deep_dive_context(context, "judge")
|
| 197 |
+
winner = out_adv.get("surviving_hypothesis", "Unknown")
|
| 198 |
+
resp_judge = self.llm(f"{SYSTEM_PROMPT}\n{DEEP_DIVE_JUDGE_PROMPT.format(hypothesis=winner, context=ctx_judge)}")
|
| 199 |
+
out_judge = self._parse_json_safe(resp_judge, {"final_diagnosis": winner, "action_plan": {}})
|
| 200 |
+
|
| 201 |
+
# Map to ReasoningResult
|
| 202 |
+
# We map stages roughly to Maintain compatibility
|
| 203 |
+
result_hyp = StageResult("hypothesis", out_hyp, [], 0.0)
|
| 204 |
+
result_adv = StageResult("adversary", out_adv, [], 0.0)
|
| 205 |
+
result_judge = StageResult("judge", out_judge, [], 0.0)
|
| 206 |
+
|
| 207 |
+
return ReasoningResult(
|
| 208 |
+
claim=result_hyp,
|
| 209 |
+
validation=result_adv,
|
| 210 |
+
contradiction=result_adv,
|
| 211 |
+
confirmation=result_judge,
|
| 212 |
+
final_diagnosis=out_judge.get("final_diagnosis", "Unknown"),
|
| 213 |
+
final_confidence=0.9, # Deep dive implies high confidence
|
| 214 |
+
causal_chain=out_judge.get("detailed_reasoning", ""),
|
| 215 |
+
root_cause=out_judge.get("root_cause", ""),
|
| 216 |
+
symptoms=[],
|
| 217 |
+
recommendation=str(out_judge.get("action_plan", "")),
|
| 218 |
+
evidence_summary={"method": ["deep_dive_3_stage"]}
|
| 219 |
+
)
|
| 220 |
|
| 221 |
def _reason(
|
| 222 |
self,
|