Spaces:
Sleeping
Sleeping
Commit ·
d6e5751
1
Parent(s): 65058ca
Complete rewrite: direct API fetch, proper vegetation indices + cluster stress extraction
Browse files
app.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
"""
|
| 2 |
-
AGROW Agricultural Chatbot Service
|
| 3 |
-
===================================
|
| 4 |
-
AI-powered agricultural advisor with
|
| 5 |
-
- SAR
|
| 6 |
-
- Sentinel-2 vegetation indices (all
|
|
|
|
| 7 |
- Weather data (current + forecast)
|
| 8 |
-
- Clustering and stress patterns
|
| 9 |
- Farmer profile from questionnaire
|
| 10 |
- Field data from coordinates_quad
|
| 11 |
"""
|
|
@@ -14,6 +14,7 @@ import os
|
|
| 14 |
import json
|
| 15 |
import logging
|
| 16 |
import uuid
|
|
|
|
| 17 |
from datetime import datetime
|
| 18 |
from typing import Optional, List, Dict, Any
|
| 19 |
import traceback
|
|
@@ -26,7 +27,6 @@ import asyncio
|
|
| 26 |
import google.generativeai as genai
|
| 27 |
|
| 28 |
from supabase_client import SupabaseClient
|
| 29 |
-
from context_aggregator import ContextAggregator
|
| 30 |
from prompts import PERSONA_DEFINITIONS, EXPERIENCE_MAP, TECH_COMFORT_MAP, INNOVATION_MAP, FARMING_GOAL_MAP
|
| 31 |
|
| 32 |
# ============================================================================
|
|
@@ -43,6 +43,12 @@ print("=" * 50)
|
|
| 43 |
print(f"===== Application Startup at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} =====")
|
| 44 |
print("=" * 50)
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
# ============================================================================
|
| 47 |
# GEMINI SETUP
|
| 48 |
# ============================================================================
|
|
@@ -55,9 +61,8 @@ else:
|
|
| 55 |
model = None
|
| 56 |
logger.warning("GEMINI_API_KEY not set - chatbot will return mock responses")
|
| 57 |
|
| 58 |
-
# Supabase
|
| 59 |
supabase = SupabaseClient()
|
| 60 |
-
context_aggregator = ContextAggregator(timeout=60)
|
| 61 |
|
| 62 |
# ============================================================================
|
| 63 |
# FASTAPI
|
|
@@ -65,7 +70,7 @@ context_aggregator = ContextAggregator(timeout=60)
|
|
| 65 |
app = FastAPI(
|
| 66 |
title="AGROW Chatbot Service",
|
| 67 |
description="AI agricultural advisor with comprehensive satellite context",
|
| 68 |
-
version="2.
|
| 69 |
)
|
| 70 |
|
| 71 |
app.add_middleware(
|
|
@@ -83,7 +88,7 @@ class ChatRequest(BaseModel):
|
|
| 83 |
session_id: str
|
| 84 |
message: str
|
| 85 |
user_id: Optional[str] = None
|
| 86 |
-
field_id: Optional[str] = None
|
| 87 |
|
| 88 |
class ChatResponse(BaseModel):
|
| 89 |
response: str
|
|
@@ -101,17 +106,6 @@ class SessionResponse(BaseModel):
|
|
| 101 |
title: str
|
| 102 |
created_at: str
|
| 103 |
|
| 104 |
-
class MessageModel(BaseModel):
|
| 105 |
-
id: str
|
| 106 |
-
role: str
|
| 107 |
-
content: str
|
| 108 |
-
created_at: str
|
| 109 |
-
|
| 110 |
-
class HistoryResponse(BaseModel):
|
| 111 |
-
session_id: str
|
| 112 |
-
messages: List[MessageModel]
|
| 113 |
-
|
| 114 |
-
|
| 115 |
# ============================================================================
|
| 116 |
# PERSONA DETECTION
|
| 117 |
# ============================================================================
|
|
@@ -126,22 +120,18 @@ def detect_persona(questionnaire: Dict) -> str:
|
|
| 126 |
goal = questionnaire.get("farming_goal", "Earn Income / Livelihood")
|
| 127 |
role = questionnaire.get("role", "Farmer")
|
| 128 |
|
| 129 |
-
# Map to persona
|
| 130 |
years = EXPERIENCE_MAP.get(experience, 3)
|
| 131 |
tech_level = TECH_COMFORT_MAP.get(tech, "moderate")
|
| 132 |
innovation_level = INNOVATION_MAP.get(innovation, "moderate")
|
| 133 |
-
goal_type = FARMING_GOAL_MAP.get(goal, "income")
|
| 134 |
|
| 135 |
-
# Role-based override
|
| 136 |
if role == "Agricultural Officer":
|
| 137 |
return "agricultural_officer"
|
| 138 |
elif role in ["Agronomist", "Researcher"]:
|
| 139 |
return "agronomist_researcher"
|
| 140 |
|
| 141 |
-
# Experience + innovation matrix
|
| 142 |
if years < 3:
|
| 143 |
return "new_farmer_tech_savvy" if tech_level == "advanced" else "new_farmer_basic_tech"
|
| 144 |
-
elif
|
| 145 |
return "commercial_farmer"
|
| 146 |
elif innovation_level == "innovative":
|
| 147 |
return "experienced_farmer_innovative"
|
|
@@ -150,7 +140,7 @@ def detect_persona(questionnaire: Dict) -> str:
|
|
| 150 |
|
| 151 |
|
| 152 |
# ============================================================================
|
| 153 |
-
# FETCH
|
| 154 |
# ============================================================================
|
| 155 |
def fetch_field_data(user_id: str, field_id: Optional[str] = None) -> Optional[Dict]:
|
| 156 |
"""Fetch field data from Supabase coordinates_quad."""
|
|
@@ -162,7 +152,6 @@ def fetch_field_data(user_id: str, field_id: Optional[str] = None) -> Optional[D
|
|
| 162 |
|
| 163 |
if query.data and len(query.data) > 0:
|
| 164 |
field = query.data[0]
|
| 165 |
-
# Calculate center point
|
| 166 |
lats = [field.get(f"lat{i}", 0) for i in range(1, 5)]
|
| 167 |
lons = [field.get(f"lon{i}", 0) for i in range(1, 5)]
|
| 168 |
center_lat = sum(lats) / 4
|
|
@@ -173,11 +162,9 @@ def fetch_field_data(user_id: str, field_id: Optional[str] = None) -> Optional[D
|
|
| 173 |
"name": field.get("name", "My Field"),
|
| 174 |
"crop_type": field.get("crop_type", "Wheat"),
|
| 175 |
"area_acres": field.get("area_acres", 1.0),
|
| 176 |
-
"
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
"bbox": [min(lons), min(lats), max(lons), max(lats)]
|
| 180 |
-
}
|
| 181 |
}
|
| 182 |
except Exception as e:
|
| 183 |
logger.error(f"Error fetching field data: {e}")
|
|
@@ -196,260 +183,315 @@ def fetch_user_profile(user_id: str) -> Dict:
|
|
| 196 |
return {
|
| 197 |
"name": profile.get("full_name", ""),
|
| 198 |
"location": profile.get("address", ""),
|
| 199 |
-
"questionnaire": profile.get("questionnaire_data", {})
|
| 200 |
}
|
| 201 |
except Exception as e:
|
| 202 |
logger.error(f"Error fetching user profile: {e}")
|
| 203 |
return {"name": "", "location": "", "questionnaire": {}}
|
| 204 |
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
def build_comprehensive_context(user_id: str, field_id: Optional[str] = None) -> Dict:
|
| 207 |
-
"""Build comprehensive context
|
| 208 |
context = {
|
| 209 |
"fetch_timestamp": datetime.now().isoformat(),
|
| 210 |
"data_sources": []
|
| 211 |
}
|
| 212 |
|
| 213 |
# 1. Fetch field data
|
| 214 |
-
|
| 215 |
-
if
|
| 216 |
-
|
| 217 |
-
context
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
|
|
|
| 225 |
persona = detect_persona(questionnaire)
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
"name": user_profile.get("name", ""),
|
| 229 |
-
"location": user_profile.get("location", ""),
|
| 230 |
"persona": persona,
|
| 231 |
-
"
|
|
|
|
|
|
|
| 232 |
}
|
| 233 |
context["data_sources"].append("user_profiles")
|
| 234 |
-
logger.info(f"Farmer persona
|
| 235 |
-
|
| 236 |
-
# 3. Fetch
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
# Merge satellite data
|
| 249 |
-
if satellite_context.get("sar_bands"):
|
| 250 |
-
context["sar_bands"] = satellite_context["sar_bands"]
|
| 251 |
context["data_sources"].append("sar_api")
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
context["data_sources"].append("sentinel2_api")
|
| 256 |
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
context["clustering"] = satellite_context["clustering"]
|
| 262 |
-
|
| 263 |
-
if satellite_context.get("temporal_trends"):
|
| 264 |
-
context["temporal_trends"] = satellite_context["temporal_trends"]
|
| 265 |
-
|
| 266 |
-
if satellite_context.get("historical_trends"):
|
| 267 |
-
context["historical_trends"] = satellite_context["historical_trends"]
|
| 268 |
-
|
| 269 |
-
if satellite_context.get("weather"):
|
| 270 |
-
context["weather"] = satellite_context["weather"]
|
| 271 |
context["data_sources"].append("weather_api")
|
|
|
|
| 272 |
|
| 273 |
-
|
| 274 |
-
context["stressed_patches"] = satellite_context["stressed_patches"]
|
| 275 |
-
|
| 276 |
-
if satellite_context.get("zone_analysis"):
|
| 277 |
-
context["zone_analysis"] = satellite_context["zone_analysis"]
|
| 278 |
-
|
| 279 |
-
if satellite_context.get("anomalies"):
|
| 280 |
-
context["anomalies"] = satellite_context["anomalies"]
|
| 281 |
-
|
| 282 |
-
logger.info(f"Context built from sources: {context['data_sources']}")
|
| 283 |
return context
|
| 284 |
|
| 285 |
|
| 286 |
# ============================================================================
|
| 287 |
-
# BUILD LLM PROMPT
|
| 288 |
# ============================================================================
|
| 289 |
def build_llm_prompt(query: str, context: Dict, history: List[Dict] = None) -> str:
|
| 290 |
-
"""Build comprehensive prompt for LLM with
|
| 291 |
|
| 292 |
-
# Get persona
|
| 293 |
-
|
|
|
|
| 294 |
persona = PERSONA_DEFINITIONS.get(persona_key, PERSONA_DEFINITIONS["experienced_farmer_traditional"])
|
| 295 |
|
| 296 |
-
# Build context
|
| 297 |
-
|
| 298 |
|
| 299 |
-
# Field
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
- Name: {field.get('name', 'Unknown')}
|
| 304 |
- Crop: {field.get('crop_type', 'Unknown')}
|
| 305 |
- Area: {field.get('area_acres', 0):.2f} acres
|
| 306 |
-
- Location: {field.get('
|
| 307 |
|
| 308 |
-
# Vegetation
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
vi_lines = ["## Vegetation Indices (Sentinel-2)"]
|
| 312 |
for name, data in vi.items():
|
| 313 |
-
if isinstance(data, dict):
|
| 314 |
-
mean = data.get("mean",
|
| 315 |
-
change = data.get("
|
| 316 |
-
change_str = f" (Δ{change:+.
|
| 317 |
-
vi_lines.append(f"- {name}: {mean:.4f}{change_str}"
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
stress = sar.get("stress_score", 0)
|
| 325 |
stress_str = f"{stress:.2f}" if isinstance(stress, (int, float)) else str(stress)
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
- Overall Health: {health}
|
| 332 |
- Stress Score: {stress_str}
|
| 333 |
- Confidence: {sar.get('confidence', 0)}%
|
| 334 |
-
- Summary: {
|
| 335 |
-
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
-
|
| 374 |
-
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
# Zone analysis
|
| 385 |
-
if context.get("zone_analysis"):
|
| 386 |
-
za = context["zone_analysis"]
|
| 387 |
-
if za.get("most_critical"):
|
| 388 |
-
mc = za["most_critical"]
|
| 389 |
-
context_parts.append(f"""## Priority Zone
|
| 390 |
-
- Location: {mc.get('location', 'Unknown')}
|
| 391 |
-
- Issue: {mc.get('issue', 'Unknown')}
|
| 392 |
-
- Urgency: {mc.get('urgency', 'Medium')}""")
|
| 393 |
-
|
| 394 |
-
# Farmer profile
|
| 395 |
-
if context.get("farmer_profile"):
|
| 396 |
-
fp = context["farmer_profile"]
|
| 397 |
-
q = fp.get("questionnaire", {})
|
| 398 |
-
context_parts.append(f"""## Farmer Profile
|
| 399 |
-
- Experience: {q.get('experience', 'Unknown')}
|
| 400 |
-
- Farming Goal: {q.get('farming_goal', 'Unknown')}
|
| 401 |
-
- Tech Comfort: {q.get('tech_comfort', 'Unknown')}
|
| 402 |
-
- Persona: {persona.get('description', '')}""")
|
| 403 |
-
|
| 404 |
-
# Build conversation history
|
| 405 |
-
history_text = ""
|
| 406 |
-
if history and len(history) > 0:
|
| 407 |
-
recent = history[-4:] # Last 2 exchanges
|
| 408 |
-
history_text = "\n## Recent Conversation\n"
|
| 409 |
-
for msg in recent:
|
| 410 |
-
role = "User" if msg.get("role") == "user" else "Assistant"
|
| 411 |
-
history_text += f"{role}: {msg.get('content', '')[:150]}...\n"
|
| 412 |
-
|
| 413 |
-
# Combine into full prompt
|
| 414 |
-
context_str = "\n\n".join(context_parts)
|
| 415 |
-
|
| 416 |
-
# Log context for debugging
|
| 417 |
-
logger.info(f"Context sections: {len(context_parts)}")
|
| 418 |
-
for i, part in enumerate(context_parts[:2]): # Log first 2 sections
|
| 419 |
-
logger.info(f"Context[{i}]: {part[:100]}...")
|
| 420 |
|
| 421 |
prompt = f"""You are AGROW AI, an expert agricultural advisor analyzing REAL satellite data for an Indian farmer's field.
|
| 422 |
|
| 423 |
-
# CRITICAL
|
| 424 |
-
You MUST analyze the
|
| 425 |
-
|
| 426 |
|
| 427 |
-
# FARMER
|
| 428 |
{persona.get('description', 'Experienced farmer')}
|
| 429 |
-
|
| 430 |
-
Tone: {persona.get('tone', 'Friendly')}
|
| 431 |
-
Focus: {persona.get('focus', 'Actionable advice')}
|
| 432 |
|
| 433 |
-
# SATELLITE
|
| 434 |
{context_str}
|
| 435 |
-
{history_text}
|
| 436 |
|
| 437 |
# USER'S QUESTION
|
| 438 |
{query}
|
| 439 |
|
| 440 |
# RESPONSE REQUIREMENTS
|
| 441 |
-
1. START
|
| 442 |
-
2. CITE
|
| 443 |
-
3.
|
| 444 |
-
4.
|
| 445 |
-
5.
|
| 446 |
-
6.
|
| 447 |
-
7. Use simple language the farmer understands
|
| 448 |
-
|
| 449 |
-
IMPORTANT: Your response MUST reference at least 3 specific data points from the analysis above.
|
| 450 |
|
| 451 |
-
|
| 452 |
|
|
|
|
| 453 |
return prompt
|
| 454 |
|
| 455 |
|
|
@@ -458,22 +500,16 @@ Provide your analysis:"""
|
|
| 458 |
# ============================================================================
|
| 459 |
def generate_response(user_message: str, history: List[Dict], context: Dict) -> tuple[str, List[str]]:
|
| 460 |
"""Generate AI response using comprehensive context."""
|
| 461 |
-
# Defensive null checks
|
| 462 |
context = context or {}
|
| 463 |
history = history or []
|
| 464 |
context_used = context.get("data_sources", [])
|
| 465 |
|
| 466 |
if model is None:
|
| 467 |
-
return
|
| 468 |
|
| 469 |
try:
|
| 470 |
-
# Build comprehensive prompt
|
| 471 |
prompt = build_llm_prompt(user_message, context, history)
|
| 472 |
|
| 473 |
-
# Log context summary
|
| 474 |
-
logger.info(f"Context: {len(context_used)} sources, Prompt: {len(prompt)} chars")
|
| 475 |
-
|
| 476 |
-
# Generate response
|
| 477 |
response = model.generate_content(
|
| 478 |
prompt,
|
| 479 |
generation_config=genai.types.GenerationConfig(
|
|
@@ -486,6 +522,7 @@ def generate_response(user_message: str, history: List[Dict], context: Dict) ->
|
|
| 486 |
|
| 487 |
except Exception as e:
|
| 488 |
logger.error(f"Gemini error: {e}")
|
|
|
|
| 489 |
return f"I apologize, but I encountered an error: {str(e)}", []
|
| 490 |
|
| 491 |
|
|
@@ -496,22 +533,17 @@ def generate_response(user_message: str, history: List[Dict], context: Dict) ->
|
|
| 496 |
async def root():
|
| 497 |
return {
|
| 498 |
"service": "AGROW Chatbot Service",
|
| 499 |
-
"version": "2.
|
| 500 |
-
"features": ["comprehensive_context", "
|
| 501 |
}
|
| 502 |
|
| 503 |
@app.get("/health")
|
| 504 |
async def health():
|
| 505 |
-
return {
|
| 506 |
-
"status": "healthy",
|
| 507 |
-
"gemini_configured": model is not None,
|
| 508 |
-
"supabase_configured": supabase.is_configured()
|
| 509 |
-
}
|
| 510 |
|
| 511 |
|
| 512 |
@app.post("/session/new", response_model=SessionResponse)
|
| 513 |
async def create_session(request: SessionRequest):
|
| 514 |
-
"""Create a new chat session."""
|
| 515 |
logger.info(f"Creating new session for user: {request.user_id}")
|
| 516 |
try:
|
| 517 |
session = supabase.create_session(
|
|
@@ -530,15 +562,12 @@ async def create_session(request: SessionRequest):
|
|
| 530 |
|
| 531 |
@app.post("/chat", response_model=ChatResponse)
|
| 532 |
async def chat(request: ChatRequest):
|
| 533 |
-
"""Send a message and get AI response with full context."""
|
| 534 |
logger.info(f"Chat request - Session: {request.session_id}")
|
| 535 |
|
| 536 |
try:
|
| 537 |
-
# Load conversation history
|
| 538 |
history = supabase.get_messages(request.session_id)
|
| 539 |
|
| 540 |
-
|
| 541 |
-
user_msg_id = supabase.add_message(
|
| 542 |
session_id=request.session_id,
|
| 543 |
role="user",
|
| 544 |
content=request.message
|
|
@@ -548,12 +577,9 @@ async def chat(request: ChatRequest):
|
|
| 548 |
context = {}
|
| 549 |
if request.user_id:
|
| 550 |
context = build_comprehensive_context(request.user_id, request.field_id)
|
| 551 |
-
logger.info(f"Context built: {context.get('data_sources', [])}")
|
| 552 |
|
| 553 |
-
# Generate AI response
|
| 554 |
response_text, context_used = generate_response(request.message, history, context)
|
| 555 |
|
| 556 |
-
# Save assistant response
|
| 557 |
assistant_msg_id = supabase.add_message(
|
| 558 |
session_id=request.session_id,
|
| 559 |
role="assistant",
|
|
@@ -562,7 +588,6 @@ async def chat(request: ChatRequest):
|
|
| 562 |
)
|
| 563 |
|
| 564 |
supabase.update_session_timestamp(request.session_id)
|
| 565 |
-
logger.info(f"Response generated - {len(response_text)} chars")
|
| 566 |
|
| 567 |
return ChatResponse(
|
| 568 |
response=response_text,
|
|
@@ -574,13 +599,12 @@ async def chat(request: ChatRequest):
|
|
| 574 |
|
| 575 |
except Exception as e:
|
| 576 |
logger.error(f"Chat error: {e}")
|
| 577 |
-
|
| 578 |
raise HTTPException(500, str(e))
|
| 579 |
|
| 580 |
|
| 581 |
@app.post("/chat/stream")
|
| 582 |
async def chat_stream(request: ChatRequest):
|
| 583 |
-
"""Stream chat response with full context."""
|
| 584 |
logger.info(f"Stream chat - Session: {request.session_id}")
|
| 585 |
|
| 586 |
try:
|
|
@@ -596,9 +620,7 @@ async def chat_stream(request: ChatRequest):
|
|
| 596 |
context = {}
|
| 597 |
if request.user_id:
|
| 598 |
context = build_comprehensive_context(request.user_id, request.field_id)
|
| 599 |
-
logger.info(f"Context built: {context.get('data_sources', [])}")
|
| 600 |
|
| 601 |
-
# Generate response
|
| 602 |
response_text, context_used = generate_response(request.message, history, context)
|
| 603 |
|
| 604 |
assistant_msg_id = supabase.add_message(
|
|
@@ -611,15 +633,12 @@ async def chat_stream(request: ChatRequest):
|
|
| 611 |
supabase.update_session_timestamp(request.session_id)
|
| 612 |
|
| 613 |
async def stream_response():
|
| 614 |
-
# Send metadata
|
| 615 |
yield f"data: {json.dumps({'type': 'metadata', 'session_id': request.session_id, 'message_id': assistant_msg_id, 'context_sources': context_used})}\n\n"
|
| 616 |
|
| 617 |
-
# Stream text in chunks
|
| 618 |
for i in range(0, len(response_text), 15):
|
| 619 |
yield f"data: {json.dumps({'type': 'chunk', 'text': response_text[i:i+15]})}\n\n"
|
| 620 |
await asyncio.sleep(0.03)
|
| 621 |
|
| 622 |
-
# Done signal
|
| 623 |
yield f"data: {json.dumps({'type': 'done', 'full_text': response_text})}\n\n"
|
| 624 |
|
| 625 |
return StreamingResponse(stream_response(), media_type="text/event-stream")
|
|
@@ -631,36 +650,21 @@ async def chat_stream(request: ChatRequest):
|
|
| 631 |
|
| 632 |
@app.get("/context/{user_id}")
|
| 633 |
async def get_context(user_id: str, field_id: Optional[str] = None):
|
| 634 |
-
"""
|
| 635 |
-
|
| 636 |
-
return context
|
| 637 |
|
| 638 |
|
| 639 |
-
@app.get("/session/{session_id}/history"
|
| 640 |
async def get_history(session_id: str):
|
| 641 |
-
"""Get conversation history for a session."""
|
| 642 |
try:
|
| 643 |
messages = supabase.get_messages(session_id)
|
| 644 |
-
return
|
| 645 |
-
session_id=session_id,
|
| 646 |
-
messages=[
|
| 647 |
-
MessageModel(
|
| 648 |
-
id=msg.get("id", ""),
|
| 649 |
-
role=msg.get("role", ""),
|
| 650 |
-
content=msg.get("content", ""),
|
| 651 |
-
created_at=msg.get("created_at", "")
|
| 652 |
-
)
|
| 653 |
-
for msg in messages
|
| 654 |
-
]
|
| 655 |
-
)
|
| 656 |
except Exception as e:
|
| 657 |
raise HTTPException(500, str(e))
|
| 658 |
|
| 659 |
|
| 660 |
@app.get("/sessions/{user_id}")
|
| 661 |
async def list_sessions(user_id: str):
|
| 662 |
-
"""List all chat sessions for a user."""
|
| 663 |
-
logger.info(f"Listing sessions for user: {user_id}")
|
| 664 |
try:
|
| 665 |
sessions = supabase.get_user_sessions(user_id)
|
| 666 |
return {"user_id": user_id, "sessions": sessions, "count": len(sessions)}
|
|
@@ -670,8 +674,6 @@ async def list_sessions(user_id: str):
|
|
| 670 |
|
| 671 |
@app.delete("/session/{session_id}")
|
| 672 |
async def delete_session(session_id: str):
|
| 673 |
-
"""Delete a chat session."""
|
| 674 |
-
logger.info(f"Deleting session: {session_id}")
|
| 675 |
try:
|
| 676 |
supabase.delete_session(session_id)
|
| 677 |
return {"status": "deleted", "session_id": session_id}
|
|
@@ -681,5 +683,5 @@ async def delete_session(session_id: str):
|
|
| 681 |
|
| 682 |
if __name__ == "__main__":
|
| 683 |
import uvicorn
|
| 684 |
-
logger.info("Starting AGROW Chatbot Service v2.
|
| 685 |
uvicorn.run(app, host="0.0.0.0", port=7860)
|
|
|
|
| 1 |
"""
|
| 2 |
+
AGROW Agricultural Chatbot Service - Comprehensive Context
|
| 3 |
+
============================================================
|
| 4 |
+
AI-powered agricultural advisor with COMPLETE context from:
|
| 5 |
+
- SAR analysis (crop health, stress scores, recommendations)
|
| 6 |
+
- Sentinel-2 vegetation indices (all from vegetation_indices_summary)
|
| 7 |
+
- Sentinel-2 stress detection (cluster-wise patterns)
|
| 8 |
- Weather data (current + forecast)
|
|
|
|
| 9 |
- Farmer profile from questionnaire
|
| 10 |
- Field data from coordinates_quad
|
| 11 |
"""
|
|
|
|
| 14 |
import json
|
| 15 |
import logging
|
| 16 |
import uuid
|
| 17 |
+
import requests
|
| 18 |
from datetime import datetime
|
| 19 |
from typing import Optional, List, Dict, Any
|
| 20 |
import traceback
|
|
|
|
| 27 |
import google.generativeai as genai
|
| 28 |
|
| 29 |
from supabase_client import SupabaseClient
|
|
|
|
| 30 |
from prompts import PERSONA_DEFINITIONS, EXPERIENCE_MAP, TECH_COMFORT_MAP, INNOVATION_MAP, FARMING_GOAL_MAP
|
| 31 |
|
| 32 |
# ============================================================================
|
|
|
|
| 43 |
print(f"===== Application Startup at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} =====")
|
| 44 |
print("=" * 50)
|
| 45 |
|
| 46 |
+
# ============================================================================
|
| 47 |
+
# API ENDPOINTS
|
| 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 |
# GEMINI SETUP
|
| 54 |
# ============================================================================
|
|
|
|
| 61 |
model = None
|
| 62 |
logger.warning("GEMINI_API_KEY not set - chatbot will return mock responses")
|
| 63 |
|
| 64 |
+
# Supabase
|
| 65 |
supabase = SupabaseClient()
|
|
|
|
| 66 |
|
| 67 |
# ============================================================================
|
| 68 |
# FASTAPI
|
|
|
|
| 70 |
app = FastAPI(
|
| 71 |
title="AGROW Chatbot Service",
|
| 72 |
description="AI agricultural advisor with comprehensive satellite context",
|
| 73 |
+
version="2.1.0"
|
| 74 |
)
|
| 75 |
|
| 76 |
app.add_middleware(
|
|
|
|
| 88 |
session_id: str
|
| 89 |
message: str
|
| 90 |
user_id: Optional[str] = None
|
| 91 |
+
field_id: Optional[str] = None
|
| 92 |
|
| 93 |
class ChatResponse(BaseModel):
|
| 94 |
response: str
|
|
|
|
| 106 |
title: str
|
| 107 |
created_at: str
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
# ============================================================================
|
| 110 |
# PERSONA DETECTION
|
| 111 |
# ============================================================================
|
|
|
|
| 120 |
goal = questionnaire.get("farming_goal", "Earn Income / Livelihood")
|
| 121 |
role = questionnaire.get("role", "Farmer")
|
| 122 |
|
|
|
|
| 123 |
years = EXPERIENCE_MAP.get(experience, 3)
|
| 124 |
tech_level = TECH_COMFORT_MAP.get(tech, "moderate")
|
| 125 |
innovation_level = INNOVATION_MAP.get(innovation, "moderate")
|
|
|
|
| 126 |
|
|
|
|
| 127 |
if role == "Agricultural Officer":
|
| 128 |
return "agricultural_officer"
|
| 129 |
elif role in ["Agronomist", "Researcher"]:
|
| 130 |
return "agronomist_researcher"
|
| 131 |
|
|
|
|
| 132 |
if years < 3:
|
| 133 |
return "new_farmer_tech_savvy" if tech_level == "advanced" else "new_farmer_basic_tech"
|
| 134 |
+
elif FARMING_GOAL_MAP.get(goal) == "commercial":
|
| 135 |
return "commercial_farmer"
|
| 136 |
elif innovation_level == "innovative":
|
| 137 |
return "experienced_farmer_innovative"
|
|
|
|
| 140 |
|
| 141 |
|
| 142 |
# ============================================================================
|
| 143 |
+
# FETCH DATA DIRECTLY FROM APIs
|
| 144 |
# ============================================================================
|
| 145 |
def fetch_field_data(user_id: str, field_id: Optional[str] = None) -> Optional[Dict]:
|
| 146 |
"""Fetch field data from Supabase coordinates_quad."""
|
|
|
|
| 152 |
|
| 153 |
if query.data and len(query.data) > 0:
|
| 154 |
field = query.data[0]
|
|
|
|
| 155 |
lats = [field.get(f"lat{i}", 0) for i in range(1, 5)]
|
| 156 |
lons = [field.get(f"lon{i}", 0) for i in range(1, 5)]
|
| 157 |
center_lat = sum(lats) / 4
|
|
|
|
| 162 |
"name": field.get("name", "My Field"),
|
| 163 |
"crop_type": field.get("crop_type", "Wheat"),
|
| 164 |
"area_acres": field.get("area_acres", 1.0),
|
| 165 |
+
"center_lat": center_lat,
|
| 166 |
+
"center_lon": center_lon,
|
| 167 |
+
"bbox": [min(lons), min(lats), max(lons), max(lats)]
|
|
|
|
|
|
|
| 168 |
}
|
| 169 |
except Exception as e:
|
| 170 |
logger.error(f"Error fetching field data: {e}")
|
|
|
|
| 183 |
return {
|
| 184 |
"name": profile.get("full_name", ""),
|
| 185 |
"location": profile.get("address", ""),
|
| 186 |
+
"questionnaire": profile.get("questionnaire_data", {}) or {}
|
| 187 |
}
|
| 188 |
except Exception as e:
|
| 189 |
logger.error(f"Error fetching user profile: {e}")
|
| 190 |
return {"name": "", "location": "", "questionnaire": {}}
|
| 191 |
|
| 192 |
|
| 193 |
+
def fetch_sar_data(bbox: List, crop_type: str) -> Optional[Dict]:
|
| 194 |
+
"""Fetch SAR data directly from API."""
|
| 195 |
+
try:
|
| 196 |
+
response = requests.post(
|
| 197 |
+
f"{SAR_API_URL}/analyze",
|
| 198 |
+
json={
|
| 199 |
+
"coordinates": bbox,
|
| 200 |
+
"date": datetime.now().strftime("%Y-%m-%d"),
|
| 201 |
+
"crop_type": crop_type,
|
| 202 |
+
"farmer_context": {}
|
| 203 |
+
},
|
| 204 |
+
timeout=60
|
| 205 |
+
)
|
| 206 |
+
if response.status_code == 200:
|
| 207 |
+
data = response.json()
|
| 208 |
+
logger.info(f"SAR raw data keys: {list(data.keys())}")
|
| 209 |
+
return data
|
| 210 |
+
except Exception as e:
|
| 211 |
+
logger.error(f"SAR fetch error: {e}")
|
| 212 |
+
return None
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def fetch_sentinel2_data(center_lat: float, center_lon: float, crop_type: str, field_hectares: float) -> Optional[Dict]:
|
| 216 |
+
"""Fetch Sentinel-2 data directly from API."""
|
| 217 |
+
try:
|
| 218 |
+
response = requests.post(
|
| 219 |
+
f"{SENTINEL2_API_URL}/analyze",
|
| 220 |
+
json={
|
| 221 |
+
"center_lat": center_lat,
|
| 222 |
+
"center_lon": center_lon,
|
| 223 |
+
"crop_type": crop_type,
|
| 224 |
+
"analysis_date": datetime.now().strftime("%Y-%m-%d"),
|
| 225 |
+
"field_size_hectares": field_hectares,
|
| 226 |
+
"farmer_context": {},
|
| 227 |
+
"skip_llm": True
|
| 228 |
+
},
|
| 229 |
+
timeout=90
|
| 230 |
+
)
|
| 231 |
+
if response.status_code == 200:
|
| 232 |
+
data = response.json()
|
| 233 |
+
logger.info(f"Sentinel-2 raw data keys: {list(data.keys())}")
|
| 234 |
+
return data
|
| 235 |
+
except Exception as e:
|
| 236 |
+
logger.error(f"Sentinel-2 fetch error: {e}")
|
| 237 |
+
return None
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def fetch_weather(lat: float, lon: float) -> Optional[Dict]:
|
| 241 |
+
"""Fetch weather from Open-Meteo."""
|
| 242 |
+
try:
|
| 243 |
+
response = requests.get(
|
| 244 |
+
"https://api.open-meteo.com/v1/forecast",
|
| 245 |
+
params={
|
| 246 |
+
"latitude": lat,
|
| 247 |
+
"longitude": lon,
|
| 248 |
+
"current": "temperature_2m,relative_humidity_2m,precipitation,wind_speed_10m",
|
| 249 |
+
"daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max",
|
| 250 |
+
"timezone": "auto",
|
| 251 |
+
"forecast_days": 7
|
| 252 |
+
},
|
| 253 |
+
timeout=15
|
| 254 |
+
)
|
| 255 |
+
if response.status_code == 200:
|
| 256 |
+
data = response.json()
|
| 257 |
+
current = data.get("current", {})
|
| 258 |
+
daily = data.get("daily", {})
|
| 259 |
+
return {
|
| 260 |
+
"current_temp": current.get("temperature_2m"),
|
| 261 |
+
"current_humidity": current.get("relative_humidity_2m"),
|
| 262 |
+
"current_precipitation": current.get("precipitation"),
|
| 263 |
+
"forecast_max_temps": daily.get("temperature_2m_max", [])[:3],
|
| 264 |
+
"forecast_rain_prob": daily.get("precipitation_probability_max", [])[:3],
|
| 265 |
+
"forecast_precipitation": daily.get("precipitation_sum", [])[:3]
|
| 266 |
+
}
|
| 267 |
+
except Exception as e:
|
| 268 |
+
logger.error(f"Weather fetch error: {e}")
|
| 269 |
+
return None
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
# ============================================================================
|
| 273 |
+
# BUILD COMPREHENSIVE CONTEXT - DIRECT EXTRACTION
|
| 274 |
+
# ============================================================================
|
| 275 |
def build_comprehensive_context(user_id: str, field_id: Optional[str] = None) -> Dict:
|
| 276 |
+
"""Build comprehensive context with DIRECT API data extraction."""
|
| 277 |
context = {
|
| 278 |
"fetch_timestamp": datetime.now().isoformat(),
|
| 279 |
"data_sources": []
|
| 280 |
}
|
| 281 |
|
| 282 |
# 1. Fetch field data
|
| 283 |
+
field = fetch_field_data(user_id, field_id)
|
| 284 |
+
if not field:
|
| 285 |
+
logger.warning("No field data found")
|
| 286 |
+
return context
|
| 287 |
+
|
| 288 |
+
context["field"] = field
|
| 289 |
+
context["data_sources"].append("coordinates_quad")
|
| 290 |
+
logger.info(f"✓ Field: {field['name']} ({field['crop_type']})")
|
| 291 |
+
|
| 292 |
+
# 2. Fetch user profile
|
| 293 |
+
profile = fetch_user_profile(user_id)
|
| 294 |
+
questionnaire = profile.get("questionnaire", {})
|
| 295 |
persona = detect_persona(questionnaire)
|
| 296 |
+
context["farmer"] = {
|
| 297 |
+
"name": profile.get("name", ""),
|
|
|
|
|
|
|
| 298 |
"persona": persona,
|
| 299 |
+
"experience": questionnaire.get("experience", ""),
|
| 300 |
+
"tech_comfort": questionnaire.get("tech_comfort", ""),
|
| 301 |
+
"farming_goal": questionnaire.get("farming_goal", "")
|
| 302 |
}
|
| 303 |
context["data_sources"].append("user_profiles")
|
| 304 |
+
logger.info(f"✓ Farmer persona: {persona}")
|
| 305 |
+
|
| 306 |
+
# 3. Fetch SAR data
|
| 307 |
+
sar_data = fetch_sar_data(field["bbox"], field["crop_type"])
|
| 308 |
+
if sar_data:
|
| 309 |
+
context["sar"] = {
|
| 310 |
+
"crop_health": sar_data.get("crop_health", "Unknown"),
|
| 311 |
+
"confidence": sar_data.get("confidence_score", 0),
|
| 312 |
+
"stress_score": sar_data.get("average_stress_score", 0),
|
| 313 |
+
"summary": sar_data.get("summary", ""),
|
| 314 |
+
"recommendations": sar_data.get("recommendations", []),
|
| 315 |
+
"health_summary": sar_data.get("health_summary", {}),
|
| 316 |
+
"stressed_patches": sar_data.get("stressed_patches", [])
|
| 317 |
+
}
|
|
|
|
|
|
|
|
|
|
| 318 |
context["data_sources"].append("sar_api")
|
| 319 |
+
logger.info(f"✓ SAR: health={sar_data.get('crop_health')}, stress={sar_data.get('average_stress_score')}")
|
| 320 |
+
|
| 321 |
+
# 4. Fetch Sentinel-2 data - EXTRACT DIRECTLY
|
| 322 |
+
s2_data = fetch_sentinel2_data(field["center_lat"], field["center_lon"], field["crop_type"], field["area_acres"] * 0.404686)
|
| 323 |
+
if s2_data:
|
| 324 |
+
# Extract vegetation_indices_summary -> contains 'indices' dict
|
| 325 |
+
vi_summary = s2_data.get("vegetation_indices_summary", {})
|
| 326 |
+
indices = vi_summary.get("indices", {})
|
| 327 |
+
|
| 328 |
+
# Extract all vegetation indices
|
| 329 |
+
vi_extracted = {}
|
| 330 |
+
for name, stats in indices.items():
|
| 331 |
+
if isinstance(stats, dict):
|
| 332 |
+
latest = stats.get("latest", {})
|
| 333 |
+
vi_extracted[name.upper()] = {
|
| 334 |
+
"mean": latest.get("mean"),
|
| 335 |
+
"min": stats.get("min_in_field"),
|
| 336 |
+
"max": stats.get("max_in_field"),
|
| 337 |
+
"change": stats.get("change")
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
if vi_extracted:
|
| 341 |
+
context["vegetation_indices"] = vi_extracted
|
| 342 |
+
logger.info(f"✓ Vegetation indices: {list(vi_extracted.keys())}")
|
| 343 |
+
|
| 344 |
+
# Extract stress_detection - cluster-wise patterns
|
| 345 |
+
stress_det = s2_data.get("stress_detection", {})
|
| 346 |
+
if stress_det:
|
| 347 |
+
context["stress_detection"] = {
|
| 348 |
+
"overall_stress": stress_det.get("overall_stress_score"),
|
| 349 |
+
"stress_category": stress_det.get("stress_category"),
|
| 350 |
+
"cluster_summary": stress_det.get("cluster_summary", []),
|
| 351 |
+
"high_stress_zones": stress_det.get("high_stress_zones", []),
|
| 352 |
+
"recommendations": stress_det.get("recommendations", [])
|
| 353 |
+
}
|
| 354 |
+
logger.info(f"✓ Stress detection: {stress_det.get('stress_category')}")
|
| 355 |
+
|
| 356 |
context["data_sources"].append("sentinel2_api")
|
| 357 |
|
| 358 |
+
# 5. Fetch weather
|
| 359 |
+
weather = fetch_weather(field["center_lat"], field["center_lon"])
|
| 360 |
+
if weather:
|
| 361 |
+
context["weather"] = weather
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
context["data_sources"].append("weather_api")
|
| 363 |
+
logger.info(f"✓ Weather: {weather.get('current_temp')}°C")
|
| 364 |
|
| 365 |
+
logger.info(f"Context complete: {context['data_sources']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
return context
|
| 367 |
|
| 368 |
|
| 369 |
# ============================================================================
|
| 370 |
+
# BUILD LLM PROMPT - COMPREHENSIVE
|
| 371 |
# ============================================================================
|
| 372 |
def build_llm_prompt(query: str, context: Dict, history: List[Dict] = None) -> str:
|
| 373 |
+
"""Build comprehensive prompt for LLM with ALL context data."""
|
| 374 |
|
| 375 |
+
# Get persona
|
| 376 |
+
farmer = context.get("farmer", {})
|
| 377 |
+
persona_key = farmer.get("persona", "experienced_farmer_traditional")
|
| 378 |
persona = PERSONA_DEFINITIONS.get(persona_key, PERSONA_DEFINITIONS["experienced_farmer_traditional"])
|
| 379 |
|
| 380 |
+
# Build context sections
|
| 381 |
+
sections = []
|
| 382 |
|
| 383 |
+
# 1. Field Information
|
| 384 |
+
field = context.get("field", {})
|
| 385 |
+
if field:
|
| 386 |
+
sections.append(f"""## Field Information
|
| 387 |
- Name: {field.get('name', 'Unknown')}
|
| 388 |
- Crop: {field.get('crop_type', 'Unknown')}
|
| 389 |
- Area: {field.get('area_acres', 0):.2f} acres
|
| 390 |
+
- Location: {field.get('center_lat', 0):.4f}°N, {field.get('center_lon', 0):.4f}°E""")
|
| 391 |
|
| 392 |
+
# 2. Vegetation Indices (ALL extracted)
|
| 393 |
+
vi = context.get("vegetation_indices", {})
|
| 394 |
+
if vi:
|
| 395 |
+
vi_lines = ["## Vegetation Indices (Sentinel-2 Analysis)"]
|
| 396 |
for name, data in vi.items():
|
| 397 |
+
if isinstance(data, dict) and data.get("mean") is not None:
|
| 398 |
+
mean = data.get("mean", 0)
|
| 399 |
+
change = data.get("change", 0)
|
| 400 |
+
change_str = f" (Δ{change:+.4f})" if isinstance(change, (int, float)) else ""
|
| 401 |
+
vi_lines.append(f"- {name}: {mean:.4f}{change_str}")
|
| 402 |
+
if len(vi_lines) > 1:
|
| 403 |
+
sections.append("\n".join(vi_lines))
|
| 404 |
+
|
| 405 |
+
# 3. SAR Analysis
|
| 406 |
+
sar = context.get("sar", {})
|
| 407 |
+
if sar:
|
| 408 |
stress = sar.get("stress_score", 0)
|
| 409 |
stress_str = f"{stress:.2f}" if isinstance(stress, (int, float)) else str(stress)
|
| 410 |
+
recommendations = sar.get("recommendations", [])
|
| 411 |
+
rec_list = "\n".join([f" • {r}" for r in recommendations[:3]]) if recommendations else " • No specific recommendations"
|
| 412 |
+
|
| 413 |
+
sections.append(f"""## SAR Crop Health Analysis
|
| 414 |
+
- Overall Health: {sar.get('crop_health', 'Unknown')}
|
|
|
|
| 415 |
- Stress Score: {stress_str}
|
| 416 |
- Confidence: {sar.get('confidence', 0)}%
|
| 417 |
+
- Summary: {sar.get('summary', 'N/A')[:300]}
|
| 418 |
+
- Recommendations:
|
| 419 |
+
{rec_list}""")
|
| 420 |
+
|
| 421 |
+
# 4. Stress Detection (Cluster-wise)
|
| 422 |
+
stress = context.get("stress_detection", {})
|
| 423 |
+
if stress:
|
| 424 |
+
clusters = stress.get("cluster_summary", [])
|
| 425 |
+
cluster_lines = []
|
| 426 |
+
for c in clusters[:5]:
|
| 427 |
+
if isinstance(c, dict):
|
| 428 |
+
cluster_lines.append(f" • Cluster {c.get('id', '?')}: {c.get('stress_level', 'Unknown')} stress, {c.get('area_percent', 0):.1f}% of field")
|
| 429 |
+
|
| 430 |
+
high_stress_zones = stress.get("high_stress_zones", [])
|
| 431 |
+
zone_lines = []
|
| 432 |
+
for z in high_stress_zones[:3]:
|
| 433 |
+
if isinstance(z, dict):
|
| 434 |
+
zone_lines.append(f" • {z.get('location', 'Unknown')}: {z.get('severity', 'Unknown')}")
|
| 435 |
+
|
| 436 |
+
sections.append(f"""## Stress Detection (Cluster Analysis)
|
| 437 |
+
- Overall Stress: {stress.get('overall_stress', 'N/A')}
|
| 438 |
+
- Category: {stress.get('stress_category', 'N/A')}
|
| 439 |
+
- Clusters:
|
| 440 |
+
{chr(10).join(cluster_lines) if cluster_lines else ' • No cluster data'}
|
| 441 |
+
- High Stress Zones:
|
| 442 |
+
{chr(10).join(zone_lines) if zone_lines else ' • No high stress zones detected'}""")
|
| 443 |
+
|
| 444 |
+
# 5. Weather
|
| 445 |
+
weather = context.get("weather", {})
|
| 446 |
+
if weather:
|
| 447 |
+
sections.append(f"""## Weather Data
|
| 448 |
+
- Current Temperature: {weather.get('current_temp', 'N/A')}°C
|
| 449 |
+
- Humidity: {weather.get('current_humidity', 'N/A')}%
|
| 450 |
+
- Current Precipitation: {weather.get('current_precipitation', 0)} mm
|
| 451 |
+
- 3-Day Forecast Max Temps: {weather.get('forecast_max_temps', [])}
|
| 452 |
+
- Rain Probability (next 3 days): {weather.get('forecast_rain_prob', [])}%""")
|
| 453 |
+
|
| 454 |
+
# 6. Farmer Profile
|
| 455 |
+
sections.append(f"""## Farmer Profile
|
| 456 |
+
- Experience: {farmer.get('experience', 'Unknown')}
|
| 457 |
+
- Farming Goal: {farmer.get('farming_goal', 'Unknown')}
|
| 458 |
+
- Tech Comfort: {farmer.get('tech_comfort', 'Unknown')}""")
|
| 459 |
+
|
| 460 |
+
# Combine context
|
| 461 |
+
context_str = "\n\n".join(sections)
|
| 462 |
+
|
| 463 |
+
# Log what we're passing
|
| 464 |
+
logger.info(f"Prompt sections: {len(sections)}")
|
| 465 |
+
for i, s in enumerate(sections[:3]):
|
| 466 |
+
logger.info(f"Section[{i}]: {s[:100]}...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
|
| 468 |
prompt = f"""You are AGROW AI, an expert agricultural advisor analyzing REAL satellite data for an Indian farmer's field.
|
| 469 |
|
| 470 |
+
# CRITICAL: USE THE ACTUAL DATA BELOW
|
| 471 |
+
You MUST analyze and cite the specific values provided. DO NOT give generic advice.
|
| 472 |
+
Reference exact numbers like "Your NDVI of 0.65..." or "The 0.10 stress score indicates..."
|
| 473 |
|
| 474 |
+
# FARMER CONTEXT
|
| 475 |
{persona.get('description', 'Experienced farmer')}
|
| 476 |
+
Style: {persona.get('style', 'Practical')} | Tone: {persona.get('tone', 'Friendly')}
|
|
|
|
|
|
|
| 477 |
|
| 478 |
+
# SATELLITE ANALYSIS DATA (TODAY'S READINGS)
|
| 479 |
{context_str}
|
|
|
|
| 480 |
|
| 481 |
# USER'S QUESTION
|
| 482 |
{query}
|
| 483 |
|
| 484 |
# RESPONSE REQUIREMENTS
|
| 485 |
+
1. START with the field name and crop type
|
| 486 |
+
2. CITE at least 3 specific data values from above
|
| 487 |
+
3. If stress detected, address it with locations
|
| 488 |
+
4. Give 2-3 actionable recommendations based on the data
|
| 489 |
+
5. Keep response under 250 words
|
| 490 |
+
6. Use simple farmer-friendly language
|
|
|
|
|
|
|
|
|
|
| 491 |
|
| 492 |
+
Your analysis:"""
|
| 493 |
|
| 494 |
+
logger.info(f"Total prompt length: {len(prompt)} chars")
|
| 495 |
return prompt
|
| 496 |
|
| 497 |
|
|
|
|
| 500 |
# ============================================================================
|
| 501 |
def generate_response(user_message: str, history: List[Dict], context: Dict) -> tuple[str, List[str]]:
|
| 502 |
"""Generate AI response using comprehensive context."""
|
|
|
|
| 503 |
context = context or {}
|
| 504 |
history = history or []
|
| 505 |
context_used = context.get("data_sources", [])
|
| 506 |
|
| 507 |
if model is None:
|
| 508 |
+
return "Please configure GEMINI_API_KEY for real responses.", []
|
| 509 |
|
| 510 |
try:
|
|
|
|
| 511 |
prompt = build_llm_prompt(user_message, context, history)
|
| 512 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
response = model.generate_content(
|
| 514 |
prompt,
|
| 515 |
generation_config=genai.types.GenerationConfig(
|
|
|
|
| 522 |
|
| 523 |
except Exception as e:
|
| 524 |
logger.error(f"Gemini error: {e}")
|
| 525 |
+
traceback.print_exc()
|
| 526 |
return f"I apologize, but I encountered an error: {str(e)}", []
|
| 527 |
|
| 528 |
|
|
|
|
| 533 |
async def root():
|
| 534 |
return {
|
| 535 |
"service": "AGROW Chatbot Service",
|
| 536 |
+
"version": "2.1.0",
|
| 537 |
+
"features": ["comprehensive_context", "cluster_stress", "persona_based"]
|
| 538 |
}
|
| 539 |
|
| 540 |
@app.get("/health")
|
| 541 |
async def health():
|
| 542 |
+
return {"status": "healthy", "gemini_configured": model is not None}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
|
| 544 |
|
| 545 |
@app.post("/session/new", response_model=SessionResponse)
|
| 546 |
async def create_session(request: SessionRequest):
|
|
|
|
| 547 |
logger.info(f"Creating new session for user: {request.user_id}")
|
| 548 |
try:
|
| 549 |
session = supabase.create_session(
|
|
|
|
| 562 |
|
| 563 |
@app.post("/chat", response_model=ChatResponse)
|
| 564 |
async def chat(request: ChatRequest):
|
|
|
|
| 565 |
logger.info(f"Chat request - Session: {request.session_id}")
|
| 566 |
|
| 567 |
try:
|
|
|
|
| 568 |
history = supabase.get_messages(request.session_id)
|
| 569 |
|
| 570 |
+
supabase.add_message(
|
|
|
|
| 571 |
session_id=request.session_id,
|
| 572 |
role="user",
|
| 573 |
content=request.message
|
|
|
|
| 577 |
context = {}
|
| 578 |
if request.user_id:
|
| 579 |
context = build_comprehensive_context(request.user_id, request.field_id)
|
|
|
|
| 580 |
|
|
|
|
| 581 |
response_text, context_used = generate_response(request.message, history, context)
|
| 582 |
|
|
|
|
| 583 |
assistant_msg_id = supabase.add_message(
|
| 584 |
session_id=request.session_id,
|
| 585 |
role="assistant",
|
|
|
|
| 588 |
)
|
| 589 |
|
| 590 |
supabase.update_session_timestamp(request.session_id)
|
|
|
|
| 591 |
|
| 592 |
return ChatResponse(
|
| 593 |
response=response_text,
|
|
|
|
| 599 |
|
| 600 |
except Exception as e:
|
| 601 |
logger.error(f"Chat error: {e}")
|
| 602 |
+
traceback.print_exc()
|
| 603 |
raise HTTPException(500, str(e))
|
| 604 |
|
| 605 |
|
| 606 |
@app.post("/chat/stream")
|
| 607 |
async def chat_stream(request: ChatRequest):
|
|
|
|
| 608 |
logger.info(f"Stream chat - Session: {request.session_id}")
|
| 609 |
|
| 610 |
try:
|
|
|
|
| 620 |
context = {}
|
| 621 |
if request.user_id:
|
| 622 |
context = build_comprehensive_context(request.user_id, request.field_id)
|
|
|
|
| 623 |
|
|
|
|
| 624 |
response_text, context_used = generate_response(request.message, history, context)
|
| 625 |
|
| 626 |
assistant_msg_id = supabase.add_message(
|
|
|
|
| 633 |
supabase.update_session_timestamp(request.session_id)
|
| 634 |
|
| 635 |
async def stream_response():
|
|
|
|
| 636 |
yield f"data: {json.dumps({'type': 'metadata', 'session_id': request.session_id, 'message_id': assistant_msg_id, 'context_sources': context_used})}\n\n"
|
| 637 |
|
|
|
|
| 638 |
for i in range(0, len(response_text), 15):
|
| 639 |
yield f"data: {json.dumps({'type': 'chunk', 'text': response_text[i:i+15]})}\n\n"
|
| 640 |
await asyncio.sleep(0.03)
|
| 641 |
|
|
|
|
| 642 |
yield f"data: {json.dumps({'type': 'done', 'full_text': response_text})}\n\n"
|
| 643 |
|
| 644 |
return StreamingResponse(stream_response(), media_type="text/event-stream")
|
|
|
|
| 650 |
|
| 651 |
@app.get("/context/{user_id}")
|
| 652 |
async def get_context(user_id: str, field_id: Optional[str] = None):
|
| 653 |
+
"""Debug endpoint - returns full context JSON."""
|
| 654 |
+
return build_comprehensive_context(user_id, field_id)
|
|
|
|
| 655 |
|
| 656 |
|
| 657 |
+
@app.get("/session/{session_id}/history")
|
| 658 |
async def get_history(session_id: str):
|
|
|
|
| 659 |
try:
|
| 660 |
messages = supabase.get_messages(session_id)
|
| 661 |
+
return {"session_id": session_id, "messages": messages}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 662 |
except Exception as e:
|
| 663 |
raise HTTPException(500, str(e))
|
| 664 |
|
| 665 |
|
| 666 |
@app.get("/sessions/{user_id}")
|
| 667 |
async def list_sessions(user_id: str):
|
|
|
|
|
|
|
| 668 |
try:
|
| 669 |
sessions = supabase.get_user_sessions(user_id)
|
| 670 |
return {"user_id": user_id, "sessions": sessions, "count": len(sessions)}
|
|
|
|
| 674 |
|
| 675 |
@app.delete("/session/{session_id}")
|
| 676 |
async def delete_session(session_id: str):
|
|
|
|
|
|
|
| 677 |
try:
|
| 678 |
supabase.delete_session(session_id)
|
| 679 |
return {"status": "deleted", "session_id": session_id}
|
|
|
|
| 683 |
|
| 684 |
if __name__ == "__main__":
|
| 685 |
import uvicorn
|
| 686 |
+
logger.info("Starting AGROW Chatbot Service v2.1")
|
| 687 |
uvicorn.run(app, host="0.0.0.0", port=7860)
|