Aniket2006 commited on
Commit
d6e5751
·
1 Parent(s): 65058ca

Complete rewrite: direct API fetch, proper vegetation indices + cluster stress extraction

Browse files
Files changed (1) hide show
  1. app.py +287 -285
app.py CHANGED
@@ -1,11 +1,11 @@
1
  """
2
- AGROW Agricultural Chatbot Service
3
- ===================================
4
- AI-powered agricultural advisor with comprehensive context from:
5
- - SAR bands (VV, VH) and analysis
6
- - Sentinel-2 vegetation indices (all 13)
 
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 and Context Aggregator
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.0.0"
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 # Specific field to analyze
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 goal_type == "commercial":
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 COMPREHENSIVE CONTEXT
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
- "coordinates": {
177
- "center_lat": center_lat,
178
- "center_lon": center_lon,
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 from all data sources."""
208
  context = {
209
  "fetch_timestamp": datetime.now().isoformat(),
210
  "data_sources": []
211
  }
212
 
213
  # 1. Fetch field data
214
- field_data = fetch_field_data(user_id, field_id)
215
- if field_data:
216
- context["field_info"] = field_data
217
- context["data_sources"].append("coordinates_quad")
218
- logger.info(f"Field data fetched: {field_data.get('name')}")
219
- else:
220
- return context # Can't proceed without field
221
-
222
- # 2. Fetch user profile and questionnaire
223
- user_profile = fetch_user_profile(user_id)
224
- questionnaire = user_profile.get("questionnaire", {})
 
225
  persona = detect_persona(questionnaire)
226
-
227
- context["farmer_profile"] = {
228
- "name": user_profile.get("name", ""),
229
- "location": user_profile.get("location", ""),
230
  "persona": persona,
231
- "questionnaire": questionnaire
 
 
232
  }
233
  context["data_sources"].append("user_profiles")
234
- logger.info(f"Farmer persona detected: {persona}")
235
-
236
- # 3. Fetch satellite context using ContextAggregator
237
- coordinates = field_data.get("coordinates", {})
238
- crop_type = field_data.get("crop_type", "Wheat")
239
- area_acres = field_data.get("area_acres", 1.0)
240
-
241
- satellite_context = context_aggregator.fetch_full_context(
242
- coordinates=coordinates,
243
- crop_type=crop_type,
244
- area_acres=area_acres,
245
- farmer_context={"profile": user_profile, "questionnaire": questionnaire}
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
- if satellite_context.get("vegetation_indices"):
254
- context["vegetation_indices"] = satellite_context["vegetation_indices"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  context["data_sources"].append("sentinel2_api")
256
 
257
- if satellite_context.get("soil_indicators"):
258
- context["soil_indicators"] = satellite_context["soil_indicators"]
259
-
260
- if satellite_context.get("clustering"):
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
- if satellite_context.get("stressed_patches"):
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 WITH CONTEXT
288
  # ============================================================================
289
  def build_llm_prompt(query: str, context: Dict, history: List[Dict] = None) -> str:
290
- """Build comprehensive prompt for LLM with all context."""
291
 
292
- # Get persona for response style
293
- persona_key = context.get("farmer_profile", {}).get("persona", "experienced_farmer_traditional")
 
294
  persona = PERSONA_DEFINITIONS.get(persona_key, PERSONA_DEFINITIONS["experienced_farmer_traditional"])
295
 
296
- # Build context string
297
- context_parts = []
298
 
299
- # Field info
300
- if context.get("field_info"):
301
- field = context["field_info"]
302
- context_parts.append(f"""## Field Information
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('coordinates', {}).get('center_lat', 0):.4f}°N, {field.get('coordinates', {}).get('center_lon', 0):.4f}°E""")
307
 
308
- # Vegetation indices (all 13)
309
- if context.get("vegetation_indices"):
310
- vi = context["vegetation_indices"]
311
- vi_lines = ["## Vegetation Indices (Sentinel-2)"]
312
  for name, data in vi.items():
313
- if isinstance(data, dict):
314
- mean = data.get("mean", data.get("value", "N/A"))
315
- change = data.get("temporal_change", data.get("change", ""))
316
- change_str = f" (Δ{change:+.3f})" if isinstance(change, (int, float)) else ""
317
- vi_lines.append(f"- {name}: {mean:.4f}{change_str}" if isinstance(mean, float) else f"- {name}: {mean}")
318
- context_parts.append("\n".join(vi_lines))
319
-
320
- # SAR Analysis (crop health from SAR data)
321
- if context.get("sar_bands"):
322
- sar = context["sar_bands"]
323
- health = sar.get("crop_health", "unknown")
324
  stress = sar.get("stress_score", 0)
325
  stress_str = f"{stress:.2f}" if isinstance(stress, (int, float)) else str(stress)
326
- summary = sar.get("summary", "") or ""
327
- summary_str = summary[:200] if summary else 'N/A'
328
- recs = sar.get("recommendations", []) or []
329
- rec_str = recs[0] if recs else 'No specific recommendations'
330
- context_parts.append(f"""## SAR Crop Health Analysis
331
- - Overall Health: {health}
332
  - Stress Score: {stress_str}
333
  - Confidence: {sar.get('confidence', 0)}%
334
- - Summary: {summary_str}
335
- - Top Recommendation: {rec_str}""")
336
-
337
- # Soil indicators
338
- if context.get("soil_indicators"):
339
- soil = context["soil_indicators"]
340
- soil_lines = ["## Soil Indicators"]
341
- for name, data in soil.items():
342
- level = data.get("level", "unknown") if isinstance(data, dict) else data
343
- value = data.get("value", "") if isinstance(data, dict) else ""
344
- val_str = f" ({value:.2f})" if isinstance(value, float) else ""
345
- soil_lines.append(f"- {name.replace('_', ' ').title()}: {level}{val_str}")
346
- context_parts.append("\n".join(soil_lines))
347
-
348
- # Clustering
349
- if context.get("clustering"):
350
- cl = context["clustering"]
351
- context_parts.append(f"""## Spatial Clustering
352
- - Clusters: {cl.get('num_clusters', 'N/A')}
353
- - Distribution: {json.dumps(cl.get('cluster_distribution', {}))}
354
- - Pattern: {cl.get('spatial_pattern', 'N/A')}""")
355
-
356
- # Temporal trends
357
- if context.get("temporal_trends"):
358
- tt = context["temporal_trends"]
359
- context_parts.append(f"""## Temporal Trends
360
- - NDVI Trend: {tt.get('ndvi_trend', 'N/A')}
361
- - 7-day Change: {tt.get('7_day_change', 'N/A')}
362
- - 30-day Change: {tt.get('30_day_change', 'N/A')}
363
- - Anomaly: {tt.get('anomaly_detected', 'N/A')}""")
364
-
365
- # Weather
366
- if context.get("weather"):
367
- w = context["weather"]
368
- current = w.get("current", {})
369
- forecast = w.get("forecast_7_day", w.get("forecast", {}))
370
- context_parts.append(f"""## Weather Data
371
- - Current: {current.get('temp', 'N/A')}°C, Humidity: {current.get('humidity', 'N/A')}%
372
- - 7-day Avg Temp: {w.get('7_day_avg', {}).get('temp', 'N/A')}°C
373
- - Precipitation: {w.get('7_day_avg', {}).get('total_precipitation', 'N/A')} mm
374
- - Forecast Rain Probability: {forecast.get('rain_probability', 'N/A')}%""")
375
-
376
- # Stressed patches
377
- if context.get("stressed_patches") and len(context["stressed_patches"]) > 0:
378
- patches = context["stressed_patches"][:3] # Top 3
379
- patch_lines = ["## Stress Zones Detected"]
380
- for p in patches:
381
- patch_lines.append(f"- {p.get('location', 'Unknown')}: Score {p.get('stress_score', 0):.2f}, Area: {p.get('area_percent', 0):.1f}%")
382
- context_parts.append("\n".join(patch_lines))
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 INSTRUCTION
424
- You MUST analyze the ACTUAL data provided below. DO NOT give generic farming advice.
425
- ALWAYS cite specific values from the analysis (e.g., "Your NDVI of 0.65 indicates...").
426
 
427
- # FARMER PROFILE
428
  {persona.get('description', 'Experienced farmer')}
429
- Response Style: {persona.get('style', 'Practical')}
430
- Tone: {persona.get('tone', 'Friendly')}
431
- Focus: {persona.get('focus', 'Actionable advice')}
432
 
433
- # SATELLITE & FIELD DATA (ACTUAL VALUES FROM TODAY'S ANALYSIS)
434
  {context_str}
435
- {history_text}
436
 
437
  # USER'S QUESTION
438
  {query}
439
 
440
  # RESPONSE REQUIREMENTS
441
- 1. START by acknowledging the specific field and crop being analyzed
442
- 2. CITE exact values from the data (NDVI, moisture levels, stress scores)
443
- 3. EXPLAIN what these values mean for THIS specific crop
444
- 4. PROVIDE 2-3 actionable recommendations based on the data
445
- 5. If any stress zones detected, address them FIRST
446
- 6. Keep response under 300 words
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
- Provide your analysis:"""
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 f"Please configure GEMINI_API_KEY for real responses.", []
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.0.0",
500
- "features": ["comprehensive_context", "persona_based_responses", "satellite_analysis"]
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
- # Save user message
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
- logger.error(traceback.format_exc())
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
- """Get the comprehensive context JSON for debugging."""
635
- context = build_comprehensive_context(user_id, field_id)
636
- return context
637
 
638
 
639
- @app.get("/session/{session_id}/history", response_model=HistoryResponse)
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 HistoryResponse(
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.0")
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)