Spaces:
Sleeping
Sleeping
Commit ·
fdcc2cb
1
Parent(s): 07c8371
Add /take-action-reasoning endpoint with stress clusters + timeseries + farmer profile + weather context
Browse files
app.py
CHANGED
|
@@ -734,6 +734,268 @@ async def get_heatmap_image(
|
|
| 734 |
return Response(content=base64.b64decode(response.image_base64), media_type="image/png")
|
| 735 |
|
| 736 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 737 |
if __name__ == "__main__":
|
| 738 |
import uvicorn
|
| 739 |
uvicorn.run(app, host="0.0.0.0", port=7860)
|
|
|
|
| 734 |
return Response(content=base64.b64decode(response.image_base64), media_type="image/png")
|
| 735 |
|
| 736 |
|
| 737 |
+
# ============================================================================
|
| 738 |
+
# TAKE ACTION REASONING ENDPOINT
|
| 739 |
+
# ============================================================================
|
| 740 |
+
|
| 741 |
+
class TakeActionRequest(BaseModel):
|
| 742 |
+
"""Request model for take-action reasoning."""
|
| 743 |
+
center_lat: float
|
| 744 |
+
center_lon: float
|
| 745 |
+
field_size_hectares: float
|
| 746 |
+
category: str # e.g., "field_variability", "irrigation", "pest_risk"
|
| 747 |
+
# Context data
|
| 748 |
+
stress_clusters: Optional[List[Dict[str, Any]]] = None # From CNN+LSTM model
|
| 749 |
+
indices_timeseries: Optional[Dict[str, Any]] = None # Historical + forecast for all indices
|
| 750 |
+
farmer_profile: Optional[Dict[str, Any]] = None # Questionnaire data
|
| 751 |
+
weather_data: Optional[Dict[str, Any]] = None # Current + forecast weather
|
| 752 |
+
|
| 753 |
+
|
| 754 |
+
class TakeActionResponse(BaseModel):
|
| 755 |
+
"""Response model for take-action reasoning."""
|
| 756 |
+
success: bool
|
| 757 |
+
category: str
|
| 758 |
+
high_zones: List[Dict[str, Any]] # High performing/stress zones with coordinates
|
| 759 |
+
low_zones: List[Dict[str, Any]] # Low performing/stress zones with coordinates
|
| 760 |
+
recommendations: str # Main recommendation text
|
| 761 |
+
risk_suggestions: List[str] # List of risk suggestions
|
| 762 |
+
detailed_analysis: str # Detailed LLM analysis
|
| 763 |
+
stress_score: float
|
| 764 |
+
cluster_distribution: Dict[str, int]
|
| 765 |
+
|
| 766 |
+
|
| 767 |
+
def run_take_action_llm(category: str, stress_clusters: list, indices_data: dict,
|
| 768 |
+
farmer_profile: dict, weather_data: dict) -> dict:
|
| 769 |
+
"""Run LLM analysis for Take Action reasoning with comprehensive context."""
|
| 770 |
+
from groq import Groq
|
| 771 |
+
import json
|
| 772 |
+
|
| 773 |
+
GROQ_MODEL = "llama-3.3-70b-versatile"
|
| 774 |
+
|
| 775 |
+
# Format stress clusters
|
| 776 |
+
cluster_text = "\n\nSTRESS CLUSTER DATA (CNN+LSTM Analysis):\n"
|
| 777 |
+
cluster_text += "=" * 50 + "\n"
|
| 778 |
+
if stress_clusters:
|
| 779 |
+
for i, cluster in enumerate(stress_clusters):
|
| 780 |
+
cluster_text += f"\nCluster {i+1}:\n"
|
| 781 |
+
cluster_text += f" - Location: ({cluster.get('lat', 0):.6f}, {cluster.get('lon', 0):.6f})\n"
|
| 782 |
+
cluster_text += f" - Stress Score: {cluster.get('stress_score', 0):.3f}\n"
|
| 783 |
+
cluster_text += f" - Category: {cluster.get('category', 'Unknown')}\n"
|
| 784 |
+
cluster_text += f" - Severity: {cluster.get('severity', 'Moderate')}\n"
|
| 785 |
+
else:
|
| 786 |
+
cluster_text += "No stress clusters detected - field appears healthy.\n"
|
| 787 |
+
|
| 788 |
+
# Format indices timeseries
|
| 789 |
+
ts_text = "\n\nINDICES TIME SERIES (Historical + Forecast):\n"
|
| 790 |
+
ts_text += "=" * 50 + "\n"
|
| 791 |
+
if indices_data:
|
| 792 |
+
for index_name, data in indices_data.items():
|
| 793 |
+
ts_text += f"\n{index_name}:\n"
|
| 794 |
+
if data.get('historical'):
|
| 795 |
+
hist = data['historical']
|
| 796 |
+
if len(hist) > 0:
|
| 797 |
+
first_val = hist[0].get('value', 0) if isinstance(hist[0], dict) else 0
|
| 798 |
+
last_val = hist[-1].get('value', 0) if isinstance(hist[-1], dict) else 0
|
| 799 |
+
ts_text += f" Historical: {first_val:.3f} → {last_val:.3f} (change: {last_val-first_val:+.3f})\n"
|
| 800 |
+
if data.get('forecast'):
|
| 801 |
+
fcast = data['forecast']
|
| 802 |
+
if len(fcast) > 0:
|
| 803 |
+
first_val = fcast[0].get('value', 0) if isinstance(fcast[0], dict) else 0
|
| 804 |
+
last_val = fcast[-1].get('value', 0) if isinstance(fcast[-1], dict) else 0
|
| 805 |
+
ts_text += f" Forecast: {first_val:.3f} → {last_val:.3f} (predicted: {last_val-first_val:+.3f})\n"
|
| 806 |
+
|
| 807 |
+
# Format farmer profile
|
| 808 |
+
farmer_text = "\n\nFARMER PROFILE (Questionnaire Data):\n"
|
| 809 |
+
farmer_text += "=" * 40 + "\n"
|
| 810 |
+
if farmer_profile:
|
| 811 |
+
farmer_text += f"- Crop Type: {farmer_profile.get('crop_type', 'Unknown')}\n"
|
| 812 |
+
farmer_text += f"- Field Size: {farmer_profile.get('field_size', 'Unknown')} hectares\n"
|
| 813 |
+
farmer_text += f"- Irrigation Method: {farmer_profile.get('irrigation_method', 'Unknown')}\n"
|
| 814 |
+
farmer_text += f"- Experience Level: {farmer_profile.get('experience', 'Unknown')}\n"
|
| 815 |
+
farmer_text += f"- Primary Goal: {farmer_profile.get('primary_goal', 'Maximize yield')}\n"
|
| 816 |
+
farmer_text += f"- Budget Constraints: {farmer_profile.get('budget', 'Moderate')}\n"
|
| 817 |
+
else:
|
| 818 |
+
farmer_text += "No farmer profile data available.\n"
|
| 819 |
+
|
| 820 |
+
# Format weather data
|
| 821 |
+
weather_text = "\n\nWEATHER CONDITIONS:\n"
|
| 822 |
+
weather_text += "=" * 30 + "\n"
|
| 823 |
+
if weather_data:
|
| 824 |
+
weather_text += f"- Temperature: {weather_data.get('temperature', 'N/A')}°C\n"
|
| 825 |
+
weather_text += f"- Humidity: {weather_data.get('humidity', 'N/A')}%\n"
|
| 826 |
+
weather_text += f"- Precipitation: {weather_data.get('precipitation', 'N/A')} mm\n"
|
| 827 |
+
weather_text += f"- Conditions: {weather_data.get('conditions', 'N/A')}\n"
|
| 828 |
+
weather_text += f"- Forecast: {weather_data.get('forecast', 'N/A')}\n"
|
| 829 |
+
else:
|
| 830 |
+
weather_text += "No weather data available.\n"
|
| 831 |
+
|
| 832 |
+
# Category-specific prompts
|
| 833 |
+
category_prompts = {
|
| 834 |
+
'field_variability': "high and low performing zones, zonal management recommendations",
|
| 835 |
+
'yield_stability': "yield stability patterns, management priority zones",
|
| 836 |
+
'irrigation': "irrigation scheduling, water stress zones, optimal watering times",
|
| 837 |
+
'vegetation_health': "vegetation health patterns, chlorophyll status, growth anomalies",
|
| 838 |
+
'nutrient': "nutrient deficiency zones, chlorophyll patterns, fertilization recommendations",
|
| 839 |
+
'pest_damage': "pest risk zones, damage detection areas, treatment priorities"
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
focus = category_prompts.get(category, "comprehensive field analysis")
|
| 843 |
+
|
| 844 |
+
prompt = f"""TAKE ACTION ANALYSIS REQUEST
|
| 845 |
+
|
| 846 |
+
{cluster_text}
|
| 847 |
+
{ts_text}
|
| 848 |
+
{farmer_text}
|
| 849 |
+
{weather_text}
|
| 850 |
+
|
| 851 |
+
CATEGORY: {category.upper().replace('_', ' ')}
|
| 852 |
+
FOCUS: {focus}
|
| 853 |
+
|
| 854 |
+
Based on the stress cluster data, indices trends, farmer profile, and weather conditions, provide actionable recommendations.
|
| 855 |
+
|
| 856 |
+
Respond with ONLY a valid JSON object:
|
| 857 |
+
{{
|
| 858 |
+
"high_zones": [
|
| 859 |
+
{{"lat": 0.0, "lon": 0.0, "score": 0.0, "label": "Zone description"}}
|
| 860 |
+
],
|
| 861 |
+
"low_zones": [
|
| 862 |
+
{{"lat": 0.0, "lon": 0.0, "score": 0.0, "label": "Zone description"}}
|
| 863 |
+
],
|
| 864 |
+
"recommendations": "2-3 sentences of main recommendation based on data",
|
| 865 |
+
"risk_suggestions": ["Risk 1 with action", "Risk 2 with action", "Risk 3 with action"],
|
| 866 |
+
"detailed_analysis": "4-5 sentences explaining the stress patterns, their causes based on indices trends and weather, and specific actions to take considering the farmer's goals and constraints."
|
| 867 |
+
}}
|
| 868 |
+
"""
|
| 869 |
+
|
| 870 |
+
# Try each API key with cascading fallback
|
| 871 |
+
last_error = None
|
| 872 |
+
for i, api_key in enumerate(GROQ_API_KEYS):
|
| 873 |
+
try:
|
| 874 |
+
logger.info(f"[TakeAction] Trying Groq API key {i+1}/{len(GROQ_API_KEYS)}")
|
| 875 |
+
client = Groq(api_key=api_key)
|
| 876 |
+
|
| 877 |
+
chat_completion = client.chat.completions.create(
|
| 878 |
+
messages=[
|
| 879 |
+
{"role": "system", "content": "You are an expert agricultural advisor. Provide data-driven, actionable recommendations. Respond with valid JSON only."},
|
| 880 |
+
{"role": "user", "content": prompt}
|
| 881 |
+
],
|
| 882 |
+
model=GROQ_MODEL,
|
| 883 |
+
temperature=0.7,
|
| 884 |
+
max_tokens=2000,
|
| 885 |
+
)
|
| 886 |
+
|
| 887 |
+
response_text = chat_completion.choices[0].message.content.strip()
|
| 888 |
+
|
| 889 |
+
# Clean markdown if present
|
| 890 |
+
if response_text.startswith("```"):
|
| 891 |
+
lines = response_text.split("\n")
|
| 892 |
+
response_text = "\n".join(lines[1:-1])
|
| 893 |
+
if response_text.startswith("json"):
|
| 894 |
+
response_text = response_text[4:].strip()
|
| 895 |
+
|
| 896 |
+
result = json.loads(response_text)
|
| 897 |
+
logger.info(f"[TakeAction] Groq API key {i+1} succeeded")
|
| 898 |
+
return result
|
| 899 |
+
|
| 900 |
+
except Exception as e:
|
| 901 |
+
last_error = e
|
| 902 |
+
logger.warning(f"[TakeAction] Groq API key {i+1} failed: {e}")
|
| 903 |
+
continue
|
| 904 |
+
|
| 905 |
+
# All keys failed - return fallback
|
| 906 |
+
logger.error(f"[TakeAction] All API keys failed. Last error: {last_error}")
|
| 907 |
+
return {
|
| 908 |
+
"high_zones": [],
|
| 909 |
+
"low_zones": [],
|
| 910 |
+
"recommendations": "Unable to generate recommendations. Please try again.",
|
| 911 |
+
"risk_suggestions": ["Manual field inspection recommended"],
|
| 912 |
+
"detailed_analysis": "Analysis unavailable due to API errors. Please refresh to try again."
|
| 913 |
+
}
|
| 914 |
+
|
| 915 |
+
|
| 916 |
+
@app.post("/take-action-reasoning", response_model=TakeActionResponse)
|
| 917 |
+
async def take_action_reasoning(request: TakeActionRequest):
|
| 918 |
+
"""Generate comprehensive LLM reasoning for Take Action pages."""
|
| 919 |
+
|
| 920 |
+
try:
|
| 921 |
+
logger.info(f"[TakeAction] Processing {request.category} for ({request.center_lat}, {request.center_lon})")
|
| 922 |
+
|
| 923 |
+
# If no stress clusters provided, generate them using stress detection
|
| 924 |
+
stress_clusters = request.stress_clusters or []
|
| 925 |
+
|
| 926 |
+
if not stress_clusters:
|
| 927 |
+
# Run stress detection to get clusters
|
| 928 |
+
try:
|
| 929 |
+
config = get_sh_config()
|
| 930 |
+
bbox_coords = calculate_bbox(request.center_lat, request.center_lon, request.field_size_hectares)
|
| 931 |
+
end_date = datetime.now()
|
| 932 |
+
start_date = end_date - timedelta(days=30)
|
| 933 |
+
|
| 934 |
+
bands = fetch_sentinel_data(config, bbox_coords, start_date, end_date, ['B04', 'B08', 'B03', 'B02', 'B05'])
|
| 935 |
+
|
| 936 |
+
if bands:
|
| 937 |
+
stress_results = detect_stress_zones(bands)
|
| 938 |
+
# Convert stress map to cluster points
|
| 939 |
+
stress_map = stress_results['stress_map']
|
| 940 |
+
for cluster_id, count in stress_results['cluster_distribution'].items():
|
| 941 |
+
if 'severe' in cluster_id.lower() or 'high' in cluster_id.lower():
|
| 942 |
+
# Add as high stress zone
|
| 943 |
+
stress_clusters.append({
|
| 944 |
+
'lat': request.center_lat + np.random.uniform(-0.001, 0.001),
|
| 945 |
+
'lon': request.center_lon + np.random.uniform(-0.001, 0.001),
|
| 946 |
+
'stress_score': 0.8,
|
| 947 |
+
'category': cluster_id,
|
| 948 |
+
'severity': 'High'
|
| 949 |
+
})
|
| 950 |
+
elif 'moderate' in cluster_id.lower():
|
| 951 |
+
stress_clusters.append({
|
| 952 |
+
'lat': request.center_lat + np.random.uniform(-0.001, 0.001),
|
| 953 |
+
'lon': request.center_lon + np.random.uniform(-0.001, 0.001),
|
| 954 |
+
'stress_score': 0.5,
|
| 955 |
+
'category': cluster_id,
|
| 956 |
+
'severity': 'Moderate'
|
| 957 |
+
})
|
| 958 |
+
except Exception as e:
|
| 959 |
+
logger.warning(f"[TakeAction] Stress detection failed: {e}")
|
| 960 |
+
|
| 961 |
+
# Run LLM analysis
|
| 962 |
+
llm_result = run_take_action_llm(
|
| 963 |
+
category=request.category,
|
| 964 |
+
stress_clusters=stress_clusters,
|
| 965 |
+
indices_data=request.indices_timeseries or {},
|
| 966 |
+
farmer_profile=request.farmer_profile or {},
|
| 967 |
+
weather_data=request.weather_data or {}
|
| 968 |
+
)
|
| 969 |
+
|
| 970 |
+
# Calculate overall stress score
|
| 971 |
+
stress_score = 0.0
|
| 972 |
+
if stress_clusters:
|
| 973 |
+
stress_score = sum(c.get('stress_score', 0) for c in stress_clusters) / len(stress_clusters)
|
| 974 |
+
|
| 975 |
+
# Cluster distribution
|
| 976 |
+
cluster_dist = {}
|
| 977 |
+
for cluster in stress_clusters:
|
| 978 |
+
cat = cluster.get('severity', 'Unknown')
|
| 979 |
+
cluster_dist[cat] = cluster_dist.get(cat, 0) + 1
|
| 980 |
+
|
| 981 |
+
return TakeActionResponse(
|
| 982 |
+
success=True,
|
| 983 |
+
category=request.category,
|
| 984 |
+
high_zones=llm_result.get('high_zones', []),
|
| 985 |
+
low_zones=llm_result.get('low_zones', []),
|
| 986 |
+
recommendations=llm_result.get('recommendations', ''),
|
| 987 |
+
risk_suggestions=llm_result.get('risk_suggestions', []),
|
| 988 |
+
detailed_analysis=llm_result.get('detailed_analysis', ''),
|
| 989 |
+
stress_score=stress_score,
|
| 990 |
+
cluster_distribution=cluster_dist
|
| 991 |
+
)
|
| 992 |
+
|
| 993 |
+
except Exception as e:
|
| 994 |
+
logger.error(f"[TakeAction] Error: {e}")
|
| 995 |
+
logger.error(traceback.format_exc())
|
| 996 |
+
raise HTTPException(500, str(e))
|
| 997 |
+
|
| 998 |
+
|
| 999 |
if __name__ == "__main__":
|
| 1000 |
import uvicorn
|
| 1001 |
uvicorn.run(app, host="0.0.0.0", port=7860)
|