iPurushottam commited on
Commit
37b38a7
·
0 Parent(s):

fix: correctly add backend files after submodule removal

Browse files
Files changed (12) hide show
  1. .gitignore +10 -0
  2. .pyre_configuration +8 -0
  3. Dockerfile +28 -0
  4. README.md +12 -0
  5. critic.py +41 -0
  6. date_utils.py +125 -0
  7. executor.py +185 -0
  8. groq_llm.py +177 -0
  9. logger.py +28 -0
  10. main.py +0 -0
  11. planner.py +226 -0
  12. weather_service.py +252 -0
.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ climai.log
5
+ *.json
6
+ *.txt
7
+ !requirements.txt
8
+ .git/
9
+ .venv/
10
+ .vscode/
.pyre_configuration ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "site_package_search_strategy": "all",
3
+ "search_path": ["."],
4
+ "source_directories": ["."],
5
+ "strict": [],
6
+ "targets": [],
7
+ "typeshed": "bundled"
8
+ }
Dockerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use an official Python runtime as a parent image
2
+ FROM python:3.10-slim
3
+
4
+ # Set the working directory
5
+ WORKDIR /app
6
+
7
+ # Install system dependencies (needed for AI/ML libraries)
8
+ # libgomp1 is required for XGBoost and LightGBM
9
+ RUN apt-get update && apt-get install -y \
10
+ build-essential \
11
+ curl \
12
+ cmake \
13
+ libgomp1 \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ # Copy requirements and install them
17
+ COPY requirements.txt .
18
+ RUN pip install --no-cache-dir --upgrade pip && \
19
+ pip install --no-cache-dir -r requirements.txt
20
+
21
+ # Copy the rest of the backend code
22
+ COPY . .
23
+
24
+ # Expose the port FastAPI runs on
25
+ EXPOSE 7860
26
+
27
+ # Command to run the app (Hugging Face uses port 7860)
28
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: ClimAI
3
+ emoji: 🌍
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 7860
9
+ ---
10
+
11
+ # ClimAI Backend
12
+ 16GB RAM upgrade for the ClimAI backend.
critic.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+
3
+ def review(query: str, plan: dict, raw_data: dict):
4
+ """
5
+ Critic Module: Detects mistakes and automatically corrects or flags them.
6
+ Checks date parsing logic, data retrieval status, and ML model health.
7
+ """
8
+ corrections = []
9
+
10
+ # 1. Date Misinterpretation Check
11
+ # If the parser defaulted to Jan 1st but the user explicitly asked for another month
12
+ if plan.get("date") and plan["date"].month == 1 and plan["date"].day == 1:
13
+ months = ["feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]
14
+ q_lower = query.lower()
15
+ if any(m in q_lower for m in months):
16
+ corrections.append("date_reparsed_from_jan1_default")
17
+ # In a full self-healing loop, we would re-trigger the planner here
18
+ # with explicit hints. For now, we flag the correction.
19
+
20
+ # 2. Data Missing Check
21
+ if not raw_data:
22
+ corrections.append("data_missing")
23
+ elif isinstance(raw_data, dict):
24
+ # 3. ML Model Failure Check (if ML data exists)
25
+ model_data = raw_data.get("models", {})
26
+ if model_data:
27
+ for m_name, m_res in model_data.items():
28
+ if m_res.get("status") == "error":
29
+ corrections.append(f"fallback_triggered_for_{m_name}")
30
+
31
+ # 4. Empty Open-Meteo Arrays Check
32
+ weather_data = raw_data.get("weather", {})
33
+ if weather_data and "daily" in weather_data:
34
+ if not weather_data["daily"].get("time"):
35
+ corrections.append("open_meteo_returned_empty_arrays")
36
+
37
+ return {
38
+ "corrections": corrections,
39
+ "data": raw_data,
40
+ "is_valid": "data_missing" not in corrections
41
+ }
date_utils.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta
2
+ import re
3
+ import dateparser
4
+
5
+
6
+ def parse_date(query: str):
7
+ """
8
+ Advanced date intelligence module.
9
+ Fixes:
10
+ - "previous year same date" / "last year" / "same day last year" etc.
11
+ now correctly returns today's date minus 1 year, NOT Jan 1st.
12
+ - Explicit dates like "Mar 9 2025", "2025-03-09" parsed correctly.
13
+ - Relative phrases like "3 days ago", "yesterday" work as before.
14
+ """
15
+ clean = query.lower().strip()
16
+ now = datetime.utcnow()
17
+ today = now.date()
18
+
19
+ # ── 1. "same date / same day / today's date — previous year / last year" ──
20
+ # Catches all natural ways a user says "this day but last year"
21
+ same_date_last_year_patterns = [
22
+ r"same (date|day).{0,20}(last|previous|prior) year",
23
+ r"(last|previous|prior) year.{0,20}same (date|day)",
24
+ r"this (date|day).{0,20}(last|previous|prior) year",
25
+ r"(last|previous|prior) year.{0,20}this (date|day)",
26
+ r"(last|previous|prior) year.{0,20}today",
27
+ r"today.{0,20}(last|previous|prior) year",
28
+ r"same date last year",
29
+ r"same day last year",
30
+ r"year ago today",
31
+ r"a year ago",
32
+ r"1 year ago",
33
+ # Handles: "tell me previous year 2025 weather" when today is Mar 9 2026
34
+ # i.e. user wants Mar 9 2025
35
+ r"(previous|last|prior) year \d{4}",
36
+ r"\d{4}.{0,10}(previous|last|prior) year",
37
+ ]
38
+ for pattern in same_date_last_year_patterns:
39
+ if re.search(pattern, clean):
40
+ try:
41
+ return today.replace(year=today.year - 1)
42
+ except ValueError:
43
+ # Handles Feb 29 edge case
44
+ return today.replace(year=today.year - 1, day=28)
45
+
46
+ # ── 2. Explicit relative phrases (fast path before dateparser) ──
47
+ if "yesterday" in clean:
48
+ return (today - timedelta(days=1))
49
+
50
+ if "today" in clean:
51
+ return today
52
+
53
+ if "tomorrow" in clean:
54
+ return (today + timedelta(days=1))
55
+
56
+ # e.g. "3 days ago", "2 weeks ago"
57
+ m = re.search(r'(\d+)\s+(day|days|week|weeks|month|months|year|years)\s+ago', clean)
58
+ if m:
59
+ n, unit = int(m.group(1)), m.group(2)
60
+ if "day" in unit: return (today - timedelta(days=n))
61
+ if "week" in unit: return (today - timedelta(weeks=n))
62
+ if "month" in unit: return (today - timedelta(days=n * 30))
63
+ if "year" in unit:
64
+ try: return today.replace(year=today.year - n)
65
+ except: return today.replace(year=today.year - n, day=28)
66
+
67
+ # ── 3. Explicit date formats (YYYY-MM-DD or DD/MM/YYYY) ──
68
+ m = re.search(r'(\d{4})-(\d{2})-(\d{2})', clean)
69
+ if m:
70
+ try:
71
+ return datetime(int(m.group(1)), int(m.group(2)), int(m.group(3))).date()
72
+ except ValueError:
73
+ pass
74
+
75
+ m = re.search(r'(\d{1,2})[/-](\d{1,2})[/-](\d{4})', clean)
76
+ if m:
77
+ try:
78
+ return datetime(int(m.group(3)), int(m.group(2)), int(m.group(1))).date()
79
+ except ValueError:
80
+ pass
81
+
82
+ # ── 4. Explicit month name + day + year e.g. "Mar 9 2025", "9 March 2025" ──
83
+ m = re.search(
84
+ r'(\d{1,2})\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{4})|'
85
+ r'(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{1,2})[,\s]+(\d{4})',
86
+ clean
87
+ )
88
+ if m:
89
+ parsed = dateparser.parse(m.group(0),
90
+ settings={"PREFER_DATES_FROM": "past",
91
+ "RETURN_AS_TIMEZONE_AWARE": False})
92
+ if parsed:
93
+ return parsed.date()
94
+
95
+ # ── 5. Isolated "in YYYY" or "of YYYY" — return Jan 1 of that year only
96
+ # when the user clearly means a whole year, not a specific date
97
+ m = re.search(r'\b(in|of|year)\s+(19\d{2}|20\d{2})\b', clean)
98
+ if m:
99
+ try:
100
+ return datetime(int(m.group(2)), 1, 1).date()
101
+ except ValueError:
102
+ pass
103
+
104
+ # ── 6. Last resort: strip noise words and try dateparser ──
105
+ # Only pass short date-like fragments, NOT the full sentence
106
+ # (full sentences confuse dateparser into picking Jan 1)
107
+ noise = r'\b(tell|me|what|was|the|weather|like|previous|last|this|same|day|date|year|in|for|at|of|a|an|give|show|fetch|get|want|need|please|how|about|is|are|will|be)\b'
108
+ stripped = re.sub(noise, '', clean).strip()
109
+ stripped = re.sub(r'\s+', ' ', stripped)
110
+
111
+ if stripped and len(stripped) > 2:
112
+ parsed = dateparser.parse(
113
+ stripped,
114
+ settings={"PREFER_DATES_FROM": "past", "RETURN_AS_TIMEZONE_AWARE": False}
115
+ )
116
+ if parsed:
117
+ # Safety check: reject if dateparser returned Jan 1 with no "jan" or "january"
118
+ # or "1st" in the original query — that's a default, not user intent
119
+ if parsed.month == 1 and parsed.day == 1:
120
+ if not re.search(r'\b(jan|january|1st|jan\s*1|01[/-]01)\b', clean):
121
+ return None # Refuse the bad default
122
+ return parsed.date()
123
+
124
+ return None
125
+
executor.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from datetime import datetime, timedelta
3
+ import re
4
+
5
+ OPEN_METEO_FORECAST = "https://api.open-meteo.com/v1/forecast"
6
+ OPEN_METEO_ARCHIVE = "https://archive-api.open-meteo.com/v1/archive"
7
+
8
+ # Default testing coordinates for Chennai
9
+ DEFAULT_LAT = 13.0827
10
+ DEFAULT_LON = 80.2707
11
+
12
+
13
+ def _ensure_datetime(d):
14
+ """Ensure d is a full datetime object, not just a date."""
15
+ if d is None:
16
+ return None
17
+ if isinstance(d, datetime):
18
+ return d
19
+ return datetime(d.year, d.month, d.day)
20
+
21
+
22
+ def _infer_past_date_from_query(query: str):
23
+ current_year = datetime.now().year
24
+ m = re.search(r'\b(19\d{2}|20\d{2})\b', query.lower())
25
+ if m:
26
+ year = int(m.group(1))
27
+ if year < current_year:
28
+ now = datetime.utcnow()
29
+ try:
30
+ return datetime(year, now.month, now.day)
31
+ except ValueError:
32
+ return datetime(year, now.month, 28)
33
+ return None
34
+
35
+
36
+ def _extract_all_past_years(query: str):
37
+ current_year = datetime.now().year
38
+ now = datetime.now()
39
+ q = query.lower()
40
+ seen = set()
41
+ years_to_process = []
42
+
43
+ range_matches = re.finditer(r'\b(19\d{2}|20\d{2})\s*(?:to|-|and)\s*(19\d{2}|20\d{2})\b', q)
44
+ for m in range_matches:
45
+ start_y = int(m.group(1))
46
+ end_y = int(m.group(2))
47
+ if start_y > end_y:
48
+ start_y, end_y = end_y, start_y
49
+ for y in range(start_y, end_y + 1):
50
+ if y < current_year and y not in seen:
51
+ seen.add(y)
52
+ years_to_process.append(y)
53
+
54
+ single_matches = re.finditer(r'\b(19\d{2}|20\d{2}|2[0-5])\b', q)
55
+ for m in single_matches:
56
+ val = m.group(1)
57
+ y = 2000 + int(val) if len(val) == 2 else int(val)
58
+ if y < current_year and y not in seen:
59
+ seen.add(y)
60
+ years_to_process.append(y)
61
+
62
+ years_to_process = sorted(years_to_process, reverse=True)[:6]
63
+ years_to_process.sort()
64
+
65
+ past_dates = []
66
+ for year in years_to_process:
67
+ try:
68
+ past_dates.append(datetime(year, now.month, now.day))
69
+ except ValueError:
70
+ past_dates.append(datetime(year, now.month, 28))
71
+ return past_dates
72
+
73
+
74
+ def execute_plan(plan):
75
+ """
76
+ Executes the deterministic plan generated by the Planner.
77
+ Routes to the correct external APIs or internal ML modules.
78
+ """
79
+ intent = plan.get("intent", "weather")
80
+ all_intents = plan.get("all_intents", [intent])
81
+ target_date = _ensure_datetime(plan.get("date"))
82
+ ctx = plan.get("context", {})
83
+ query = plan.get("query", "")
84
+
85
+ # Fallback: infer past date from query year
86
+ if target_date is None and intent in ["weather_history", "weather"]:
87
+ inferred = _infer_past_date_from_query(query)
88
+ if inferred:
89
+ target_date = inferred
90
+ intent = "weather_history"
91
+ plan["intent"] = intent
92
+
93
+ execution_result = {
94
+ "weather": None,
95
+ "forecast": None,
96
+ "historical_weather": None,
97
+ "historical_comparison": None,
98
+ "cyclone": None,
99
+ "earthquake": None,
100
+ "tsunami": None,
101
+ "models": None
102
+ }
103
+
104
+ try:
105
+ from weather_service import (get_cyclones, get_earthquakes, get_tsunamis,
106
+ get_weather, get_forecast, fetch_historical_weather)
107
+
108
+ now = datetime.utcnow().date()
109
+
110
+ # ── DISASTER ROUTE ────────────────────────────────────────────────────
111
+ # Full report: always fetch weather + forecast + cyclones + earthquakes
112
+ if "disaster" in all_intents:
113
+ execution_result["weather"] = get_weather()
114
+ execution_result["forecast"] = get_forecast()
115
+ execution_result["cyclone"] = get_cyclones()
116
+ execution_result["earthquake"] = get_earthquakes()
117
+ execution_result["tsunami"] = get_tsunamis()
118
+ # Don't return early — other intents below may add more data
119
+
120
+ # ── COMPARISON ROUTE ──────────────────────────────────────────────────
121
+ if intent == "weather_comparison" or ctx.get("wants_comparison"):
122
+ execution_result["weather"] = get_weather()
123
+ past_dates = _extract_all_past_years(query)
124
+
125
+ if not past_dates and target_date:
126
+ target_dt_only = target_date.date() if isinstance(target_date, datetime) else target_date
127
+ if target_dt_only < now:
128
+ past_dates = [target_date]
129
+
130
+ if past_dates:
131
+ comparison_results = []
132
+ for past_dt in past_dates:
133
+ past_date_only = past_dt.date()
134
+ archive_limit = datetime.utcnow().date() - timedelta(days=5)
135
+ if past_date_only <= archive_limit:
136
+ hist = fetch_historical_weather(past_dt, days_range=1)
137
+ if hist and "error" not in hist:
138
+ hist["queried_year"] = past_dt.year
139
+ hist["queried_date"] = past_dt.strftime("%Y-%m-%d")
140
+ comparison_results.append(hist)
141
+
142
+ if comparison_results:
143
+ execution_result["historical_comparison"] = comparison_results
144
+ execution_result["historical_weather"] = comparison_results[0]
145
+
146
+ execution_result["forecast"] = get_forecast()
147
+
148
+ # ── STANDARD WEATHER / HISTORY / PREDICTION ROUTE ────────────────────
149
+ elif intent in ["weather_history", "weather", "prediction"]:
150
+ if target_date:
151
+ target_date_only = target_date.date() if isinstance(target_date, datetime) else target_date
152
+ if target_date_only < now:
153
+ execution_result["historical_weather"] = fetch_historical_weather(target_date, days_range=1)
154
+ elif target_date_only > now and (target_date_only - now).days <= 7:
155
+ execution_result["forecast"] = get_forecast()
156
+
157
+ target_date_only = target_date.date() if target_date and isinstance(target_date, datetime) else target_date
158
+ if not target_date or target_date_only == now:
159
+ execution_result["weather"] = get_weather()
160
+
161
+ if not target_date and intent in ["weather", "prediction"]:
162
+ execution_result["forecast"] = get_forecast()
163
+
164
+ # ── CYCLONE ROUTE ─────────────────────────────────────────────────────
165
+ if "cyclone" in all_intents and execution_result["cyclone"] is None:
166
+ cy_name = ctx.get("cyclone_name")
167
+ cy_year = ctx.get("year")
168
+ c_data = get_cyclones(name=cy_name, year=cy_year)
169
+ if ctx.get("wants_recent") and not cy_name:
170
+ cyc_list = sorted(c_data.get("cyclones", []), key=lambda c: c["year"], reverse=True)[:3]
171
+ c_data["cyclones"] = cyc_list
172
+ execution_result["cyclone"] = c_data
173
+
174
+ # ── EARTHQUAKE ROUTE ──────────────────────────────────────────────────
175
+ if "earthquake" in all_intents and execution_result["earthquake"] is None:
176
+ execution_result["earthquake"] = get_earthquakes()
177
+
178
+ # ── TSUNAMI ROUTE ─────────────────────────────────────────────────────
179
+ if "tsunami" in all_intents and execution_result["tsunami"] is None:
180
+ execution_result["tsunami"] = get_tsunamis()
181
+
182
+ except ImportError as e:
183
+ print(f"Executor Import Error: {e}")
184
+
185
+ return execution_result
groq_llm.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ groq_llm.py — Groq LLM Answer Generator
3
+ Drop-in replacement for build_focused_analysis().
4
+ Reads all fetched data + ML results and generates a smart natural language answer.
5
+
6
+ Install: pip install groq
7
+ Get free API key: https://console.groq.com
8
+ """
9
+
10
+ import json
11
+ import os
12
+ from groq import Groq
13
+
14
+ # ── Put your key here OR set env variable GROQ_API_KEY ──
15
+ GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
16
+
17
+ client = Groq(api_key=GROQ_API_KEY)
18
+
19
+ SYSTEM_PROMPT = """You are ClimAI, an expert disaster and weather intelligence assistant for Chennai, India.
20
+
21
+ You receive structured data fetched from real APIs (Open-Meteo, USGS, NOAA) and ML model predictions.
22
+ Your job is to answer the user's question clearly and conversationally using ONLY the data provided.
23
+
24
+ Rules:
25
+ - Be concise but informative (3-6 sentences max unless a detailed report is asked)
26
+ - Always mention the actual numbers from the data (temperatures, wind speed, etc.)
27
+ - If ML ensemble predictions are present, mention the confidence level
28
+ - If data is missing or has errors, say so honestly
29
+ - Never make up numbers — only use what's in the data
30
+ - Format nicely: use line breaks for readability
31
+ - Always mention the date/time period the data refers to
32
+ - For comparisons and multi-year ranges: if historical_comparison data is present, you MUST use it.
33
+ Show a clear year-by-year breakdown with differences, or summarize the trend over the years.
34
+ Identify the hottest/coldest years or highest precipitation if a range is provided.
35
+ Never say historical data is unavailable if historical_comparison is in the provided data.
36
+ """
37
+
38
+
39
+ def groq_answer(query: str, intents: list, data_sources: dict,
40
+ target_date=None, date_type: str = "today") -> str:
41
+ """
42
+ Generate a natural language answer using Groq LLM.
43
+ """
44
+ data_summary = {}
45
+
46
+ # Current weather
47
+ if "weather" in data_sources and data_sources["weather"]:
48
+ w = data_sources["weather"]
49
+ data_summary["current_weather"] = {
50
+ "temperature": w.get("temperature"),
51
+ "feels_like": w.get("feels_like"),
52
+ "humidity": w.get("humidity"),
53
+ "wind_speed": w.get("wind_speed"),
54
+ "wind_direction": w.get("wind_direction"),
55
+ "precipitation": w.get("precipitation"),
56
+ "cloud_cover": w.get("cloud_cover"),
57
+ }
58
+
59
+ # Single historical weather (non-comparison)
60
+ if "historical_weather" in data_sources and data_sources["historical_weather"]:
61
+ hw = data_sources["historical_weather"]
62
+ if isinstance(hw, dict) and "daily" in hw:
63
+ data_summary["historical_weather"] = {
64
+ "date_range": hw.get("period", hw.get("queried_date", "")),
65
+ "days": hw["daily"][:5] if hw["daily"] else []
66
+ }
67
+ else:
68
+ data_summary["historical_weather"] = hw
69
+
70
+ # ── NEW: Multi-year comparison data ──────────────────────────────────────
71
+ # If executor fetched historical data for multiple past years, include ALL
72
+ # of it so Groq can do a proper side-by-side comparison.
73
+ if "historical_comparison" in data_sources and data_sources["historical_comparison"]:
74
+ comparison_list = data_sources["historical_comparison"]
75
+ comparison_summary = []
76
+ for entry in comparison_list:
77
+ if isinstance(entry, dict) and "daily" in entry:
78
+ comparison_summary.append({
79
+ "year": entry.get("queried_year"),
80
+ "date": entry.get("queried_date"),
81
+ "daily": entry["daily"][:3], # first 3 days is enough
82
+ "source": entry.get("source", "Open-Meteo Archive API"),
83
+ })
84
+ else:
85
+ comparison_summary.append(entry)
86
+ data_summary["historical_comparison"] = comparison_summary
87
+ # ─────────────────────────────────────────────────────────────────────────
88
+
89
+ # Earthquake
90
+ if "earthquake" in data_sources and data_sources["earthquake"]:
91
+ eq = data_sources["earthquake"]
92
+ if isinstance(eq, dict):
93
+ # Include high-level summary + only the 10 most recent/significant events
94
+ data_summary["earthquakes"] = {
95
+ "summary": eq.get("summary"),
96
+ "recent_events": eq.get("events", [])[:10]
97
+ }
98
+ elif isinstance(eq, list):
99
+ data_summary["earthquakes"] = eq[:10]
100
+ else:
101
+ data_summary["earthquakes"] = eq
102
+
103
+ # Cyclone
104
+ if "cyclone" in data_sources and data_sources["cyclone"]:
105
+ cy = data_sources["cyclone"]
106
+ if isinstance(cy, dict) and "cyclones" in cy:
107
+ # Truncate detailed tracks to prevent massive token usage
108
+ truncated_cyc = []
109
+ for c in cy["cyclones"]:
110
+ c_copy = c.copy()
111
+ if "track" in c_copy:
112
+ c_copy["track"] = c_copy["track"][:5] # Just show the start/progression
113
+ truncated_cyc.append(c_copy)
114
+ data_summary["cyclone"] = {"cyclones": truncated_cyc}
115
+ else:
116
+ data_summary["cyclone"] = cy
117
+
118
+ # Tsunami
119
+ if "tsunami" in data_sources and data_sources["tsunami"]:
120
+ data_summary["tsunami"] = data_sources["tsunami"]
121
+
122
+ # ML Ensemble predictions
123
+ if "ensemble" in data_sources and data_sources["ensemble"]:
124
+ ens = data_sources["ensemble"]
125
+ report = ens.get("final_report", {})
126
+ preds = report.get("predictions", [])
127
+ data_summary["ml_predictions"] = {
128
+ "models_used": ens.get("models_used", []),
129
+ "overall_confidence": report.get("overall_confidence", "unknown"),
130
+ "agreement_score": report.get("agreement_score"),
131
+ "next_7_days": preds[:7],
132
+ }
133
+
134
+ # Forecast
135
+ if "forecast" in data_sources and data_sources["forecast"]:
136
+ fc = data_sources["forecast"]
137
+ if isinstance(fc, dict) and "daily" in fc:
138
+ data_summary["forecast"] = fc["daily"][:7]
139
+
140
+ date_str = target_date.strftime("%B %d, %Y") if target_date else "today"
141
+
142
+ # Build a comparison-aware instruction hint for the prompt
143
+ comparison_hint = ""
144
+ if "historical_comparison" in data_summary:
145
+ years = [str(e.get("year", "?")) for e in data_summary["historical_comparison"]]
146
+ comparison_hint = (
147
+ f"\n\nIMPORTANT: The user wants a comparison. "
148
+ f"You have historical data for: {', '.join(years)}. "
149
+ f"You also have current_weather for today (2026). "
150
+ f"Compare them directly — show specific numbers and calculate the differences."
151
+ )
152
+
153
+ user_prompt = f"""User question: "{query}"
154
+
155
+ Detected intents: {', '.join(intents)}
156
+ Date context: {date_str} ({date_type})
157
+ Location: Chennai, India
158
+
159
+ Available data:
160
+ {json.dumps(data_summary, indent=2, default=str)}{comparison_hint}
161
+
162
+ Please answer the user's question based on this data."""
163
+
164
+ try:
165
+ response = client.chat.completions.create(
166
+ model="llama-3.3-70b-versatile",
167
+ messages=[
168
+ {"role": "system", "content": SYSTEM_PROMPT},
169
+ {"role": "user", "content": user_prompt},
170
+ ],
171
+ max_tokens=600,
172
+ temperature=0.3,
173
+ )
174
+ return response.choices[0].message.content.strip()
175
+
176
+ except Exception as e:
177
+ return f"[Groq unavailable: {e}] Data was fetched successfully — check the 'data' field in the response."
logger.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ import sys
4
+
5
+ # Ensure logs directory exists if needed, but logging in the same dir for now
6
+ try:
7
+ logging.basicConfig(
8
+ filename="climai.log",
9
+ level=logging.INFO,
10
+ format="%(asctime)s - %(levelname)s - %(message)s"
11
+ )
12
+ except (PermissionError, OSError):
13
+ # Fallback to console logging if file system is read-only (e.g. on certain Render tiers)
14
+ logging.basicConfig(
15
+ stream=sys.stdout,
16
+ level=logging.INFO,
17
+ format="%(asctime)s - %(levelname)s - %(message)s"
18
+ )
19
+
20
+ def log(message):
21
+ """
22
+ Centralized logging function.
23
+ Accepts strings or dictionaries (which are logged as strings).
24
+ """
25
+ try:
26
+ logging.info(str(message))
27
+ except:
28
+ print(f"FAILED TO LOG: {message}")
main.py ADDED
The diff for this file is too large to render. See raw diff
 
planner.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from date_utils import parse_date
2
+ import re
3
+ import json
4
+ import logging
5
+ from groq_llm import client as groq_client
6
+
7
+ KNOWN_CYCLONES = ["michaung", "mandous", "nivar", "gaja", "vardah", "thane", "nisha",
8
+ "fani", "amphan", "hudhud", "phailin", "laila", "jal"]
9
+
10
+ KNOWN_LOCATIONS = ["chennai", "mumbai", "kolkata", "vizag", "visakhapatnam",
11
+ "bay of bengal", "arabian sea", "tamil nadu", "andhra pradesh",
12
+ "odisha", "west bengal", "india", "puducherry", "cuddalore",
13
+ "nagapattinam", "mahabalipuram"]
14
+
15
+ def _normalize_query(q: str) -> str:
16
+ typo_map = {
17
+ r"\bpervious\b": "previous",
18
+ r"\bprevios\b": "previous",
19
+ r"\bpreviuos\b": "previous",
20
+ r"\bprevioues\b": "previous",
21
+ r"\bprevius\b": "previous",
22
+ r"\bprevioius\b": "previous",
23
+ r"\bhistorcal\b": "historical",
24
+ r"\bhistoricle\b": "historical",
25
+ r"\byesterady\b": "yesterday",
26
+ r"\byestarday\b": "yesterday",
27
+ }
28
+ for pattern, replacement in typo_map.items():
29
+ q = re.sub(pattern, replacement, q)
30
+ return q
31
+
32
+
33
+ def _expand_disaster_intents(intents: list) -> list:
34
+ """If disaster intent detected, always include weather, cyclone, earthquake."""
35
+ if "disaster" in intents:
36
+ for extra in ["weather", "cyclone", "earthquake", "tsunami"]:
37
+ if extra not in intents:
38
+ intents.append(extra)
39
+ return intents
40
+
41
+
42
+ def classify_query(query: str):
43
+ q = _normalize_query(query.lower().strip())
44
+ intents = []
45
+
46
+ past_kw = ["last year", "previous", "history", "historical", "ago", "past",
47
+ "same date", "same day", "this day", "yesterday", "back in",
48
+ "was", "were", "happened", "occurred", "hit", "struck", "recent"]
49
+ future_kw = ["predict", "prediction", "next", "forecast", "tomorrow",
50
+ "coming", "upcoming", "expect", "will", "probability",
51
+ "chance", "future", "model", "ml", "ai"]
52
+
53
+ is_past = any(re.search(rf"\b{k}\b", q) for k in past_kw)
54
+ is_future = any(re.search(rf"\b{k}\b", q) for k in future_kw)
55
+
56
+ current_year = __import__("datetime").datetime.now().year
57
+ past_year_match = re.search(r'\b(19\d{2}|20\d{2})\b', q)
58
+ if past_year_match and int(past_year_match.group(1)) < current_year:
59
+ is_past = True
60
+ is_future = False
61
+
62
+ weather_kw = ["weather", "temperature", "temp", "hot", "cold", "rain", "wind", "humidity",
63
+ "climate", "heat", "sunny", "cloudy", "precipitation", "pressure",
64
+ "detail", "condition", "report"]
65
+ if any(re.search(rf"\b{k}\b", q) for k in weather_kw):
66
+ if is_past: intents.append("weather_history")
67
+ elif is_future: intents.append("prediction")
68
+ else: intents.append("weather")
69
+
70
+ cyclone_kw = ["cyclone", "hurricane", "typhoon", "storm", "wind storm", "tropical",
71
+ "bay of bengal", "vardah", "nivar", "gaja", "mandous", "michaung",
72
+ "thane", "nisha", "fani", "amphan", "hudhud"]
73
+ if any(re.search(rf"\b{k}\b", q) for k in cyclone_kw):
74
+ if is_future: intents.append("cyclone_prediction")
75
+ else: intents.append("cyclone")
76
+
77
+ quake_kw = ["earthquake", "quake", "seismic", "magnitude", "richter", "tremor",
78
+ "tectonic", "fault", "aftershock", "usgs"]
79
+ if any(re.search(rf"\b{k}\b", q) for k in quake_kw):
80
+ intents.append("earthquake")
81
+
82
+ tsunami_kw = ["tsunami", "tidal wave", "ocean wave", "indian ocean", "sumatra",
83
+ "krakatoa", "sulawesi", "wave height"]
84
+ if any(re.search(rf"\b{k}\b", q) for k in tsunami_kw):
85
+ intents.append("tsunami")
86
+
87
+ if not intents and is_future:
88
+ intents.append("prediction")
89
+
90
+ disaster_kw = ["disaster", "catastrophe", "calamity", "danger", "risk",
91
+ "overview", "summary", "all", "report", "threat", "alert"]
92
+ if any(re.search(rf"\b{k}\b", q) for k in disaster_kw):
93
+ intents.append("disaster")
94
+
95
+ if "compare" in q or "difference" in q or re.search(r"\bvs\b", q) or "versus" in q:
96
+ intents.append("weather_comparison")
97
+
98
+ is_range = bool(re.search(r'\b(19\d{2}|20\d{2})\s*(?:to|-|and)\s*(19\d{2}|20\d{2})\b', q))
99
+ if is_range and "weather_comparison" not in intents:
100
+ intents.append("weather_comparison")
101
+
102
+ if not intents:
103
+ intents.append("weather")
104
+
105
+ return _expand_disaster_intents(list(set(intents)))
106
+
107
+
108
+ def extract_query_context(query: str):
109
+ q = _normalize_query(query.lower().strip())
110
+
111
+ cyclone_name = None
112
+ for name in KNOWN_CYCLONES:
113
+ if name in q:
114
+ cyclone_name = name
115
+ break
116
+
117
+ year = None
118
+ m = re.search(r'(?<!\d)(?<!\d[-/])(19\d{2}|20\d{2})(?![-/]\d)(?!\d)', q)
119
+ if m: year = int(m.group(1))
120
+
121
+ location = None
122
+ for loc in KNOWN_LOCATIONS:
123
+ if loc in q:
124
+ location = loc
125
+ break
126
+
127
+ wants_recent = any(k in q for k in ["recent", "latest", "last", "newest", "most recent"])
128
+ wants_comparison = any(k in q for k in ["compare", "vs", "versus", "difference", "than"])
129
+
130
+ is_range = bool(re.search(r'\b(19\d{2}|20\d{2})\s*(?:to|-|and)\s*(19\d{2}|20\d{2})\b', q))
131
+ if is_range:
132
+ wants_comparison = True
133
+
134
+ return {
135
+ "cyclone_name": cyclone_name,
136
+ "year": year,
137
+ "location": location,
138
+ "wants_recent": wants_recent,
139
+ "wants_comparison": wants_comparison
140
+ }
141
+
142
+
143
+ def extract_intent_with_llm(query: str) -> dict:
144
+ system_prompt = """You are an intent classifier for a climate and disaster tracking app.
145
+ Given a user query, you must extract their intent and basic context.
146
+ The query may contain severe typos or bad grammar. You must figure out what they mean.
147
+
148
+ Allowed intents: weather, weather_history, weather_comparison, prediction, cyclone, cyclone_history, cyclone_prediction, earthquake, tsunami, disaster.
149
+
150
+ Output exactly valid JSON in this format:
151
+ {
152
+ "intents": ["list", "of", "intents"],
153
+ "context": {
154
+ "cyclone_name": null,
155
+ "year": null,
156
+ "location": null,
157
+ "wants_recent": false,
158
+ "wants_comparison": false
159
+ }
160
+ }
161
+ """
162
+ try:
163
+ response = groq_client.chat.completions.create(
164
+ model="llama-3.3-70b-versatile",
165
+ messages=[
166
+ {"role": "system", "content": system_prompt},
167
+ {"role": "user", "content": f"Query: {query}"}
168
+ ],
169
+ response_format={"type": "json_object"},
170
+ temperature=0.1,
171
+ max_tokens=200
172
+ )
173
+ result = json.loads(response.choices[0].message.content)
174
+ if "intents" not in result or "context" not in result:
175
+ raise ValueError("LLM returned malformed JSON structure")
176
+ return result
177
+ except Exception as e:
178
+ logging.error(f"LLM extraction failed: {e}")
179
+ return None
180
+
181
+
182
+ def plan_query(query: str):
183
+ """
184
+ Create a deterministic execution plan, using LLM for typo-tolerant intent extraction.
185
+ Falls back to regex parsing if the LLM fails.
186
+ """
187
+ # 1. Try LLM Extraction First
188
+ llm_result = extract_intent_with_llm(query)
189
+
190
+ if llm_result:
191
+ intents = llm_result.get("intents", [])
192
+ context = llm_result.get("context", {})
193
+ # Safety fallback if LLM returns empty intents
194
+ if not intents:
195
+ intents = classify_query(query)
196
+ else:
197
+ # Always apply disaster expansion even for LLM results
198
+ intents = _expand_disaster_intents(intents)
199
+ else:
200
+ # 2. Fallback to Regex
201
+ logging.warning("Falling back to regex intent classification")
202
+ intents = classify_query(query)
203
+ context = extract_query_context(query)
204
+
205
+ date_val = parse_date(query)
206
+
207
+ # 3. Select the primary intent
208
+ primary_intent = "weather"
209
+ if "weather_comparison" in intents:
210
+ primary_intent = "weather_comparison"
211
+ elif "disaster" in intents:
212
+ primary_intent = "disaster"
213
+ elif "cyclone" in intents or "cyclone_history" in intents:
214
+ primary_intent = "cyclone_history"
215
+ elif "weather_history" in intents:
216
+ primary_intent = "weather_history"
217
+ else:
218
+ primary_intent = intents[0] if intents else "unknown"
219
+
220
+ return {
221
+ "intent": primary_intent,
222
+ "all_intents": intents,
223
+ "date": date_val,
224
+ "query": query,
225
+ "context": context
226
+ }
weather_service.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from datetime import datetime, timedelta
3
+ import math
4
+ import random
5
+
6
+ # Chennai coordinates
7
+ LAT = 13.0827
8
+ LON = 80.2707
9
+
10
+ def get_weather():
11
+ """Current weather for Chennai."""
12
+ url = "https://api.open-meteo.com/v1/forecast"
13
+ params = {
14
+ "latitude": LAT,
15
+ "longitude": LON,
16
+ "current": "temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,rain,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m,pressure_msl,surface_pressure",
17
+ "timezone": "Asia/Kolkata",
18
+ }
19
+ try:
20
+ r = requests.get(url, params=params, timeout=10)
21
+ r.raise_for_status()
22
+ data = r.json()
23
+ current = data.get("current", {})
24
+
25
+ deg = current.get("wind_direction_10m", 0)
26
+ directions = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
27
+ "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]
28
+ idx = round(deg / 22.5) % 16
29
+ wind_dir = directions[idx]
30
+
31
+ return {
32
+ "temperature": current.get("temperature_2m"),
33
+ "feels_like": current.get("apparent_temperature"),
34
+ "humidity": current.get("relative_humidity_2m"),
35
+ "wind_speed": current.get("wind_speed_10m"),
36
+ "wind_direction": wind_dir,
37
+ "wind_direction_deg": deg,
38
+ "wind_gusts": current.get("wind_gusts_10m"),
39
+ "cloud_cover": current.get("cloud_cover"),
40
+ "pressure": current.get("surface_pressure"),
41
+ "precipitation": current.get("precipitation"),
42
+ "rain": current.get("rain"),
43
+ }
44
+ except Exception as e:
45
+ return {"error": str(e)}
46
+
47
+ def get_forecast():
48
+ """7-day daily forecast for Chennai."""
49
+ url = "https://api.open-meteo.com/v1/forecast"
50
+ params = {
51
+ "latitude": LAT,
52
+ "longitude": LON,
53
+ "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,wind_direction_10m_dominant,precipitation_probability_max,uv_index_max",
54
+ "hourly": "temperature_2m,wind_speed_10m",
55
+ "forecast_days": 7,
56
+ "timezone": "Asia/Kolkata",
57
+ }
58
+ try:
59
+ r = requests.get(url, params=params, timeout=10)
60
+ r.raise_for_status()
61
+ data = r.json()
62
+ daily = data.get("daily", {})
63
+ hourly = data.get("hourly", {})
64
+
65
+ days = []
66
+ times = daily.get("time", [])
67
+ for i, date_str in enumerate(times):
68
+ dt = datetime.strptime(date_str, "%Y-%m-%d")
69
+ days.append({
70
+ "date": date_str,
71
+ "day": dt.strftime("%a"),
72
+ "temp_max": daily.get("temperature_2m_max", [None])[i] if i < len(daily.get("temperature_2m_max", [])) else None,
73
+ "temp_min": daily.get("temperature_2m_min", [None])[i] if i < len(daily.get("temperature_2m_min", [])) else None,
74
+ "precipitation": daily.get("precipitation_sum", [0])[i] if i < len(daily.get("precipitation_sum", [])) else 0,
75
+ "wind_speed_max": daily.get("wind_speed_10m_max", [0])[i] if i < len(daily.get("wind_speed_10m_max", [])) else 0,
76
+ "precip_prob": daily.get("precipitation_probability_max", [0])[i] if i < len(daily.get("precipitation_probability_max", [])) else 0,
77
+ "uv_index": daily.get("uv_index_max", [0])[i] if i < len(daily.get("uv_index_max", [])) else 0,
78
+ })
79
+
80
+ hourly_data = []
81
+ h_times = hourly.get("time", [])
82
+ h_temps = hourly.get("temperature_2m", [])
83
+ h_winds = hourly.get("wind_speed_10m", [])
84
+ for i, t in enumerate(h_times):
85
+ hourly_data.append({
86
+ "time": t,
87
+ "temperature": h_temps[i] if i < len(h_temps) else None,
88
+ "wind_speed": h_winds[i] if i < len(h_winds) else None,
89
+ })
90
+
91
+ return {"daily": days, "hourly": hourly_data}
92
+ except Exception as e:
93
+ return {"error": str(e)}
94
+
95
+ def fetch_historical_weather(target_date: datetime, days_range: int = 1):
96
+ """Fetch actual historical weather data from Open-Meteo Archive API."""
97
+ start = target_date
98
+ end = target_date + timedelta(days=days_range - 1)
99
+ archive_limit = datetime.now() - timedelta(days=5)
100
+ if end.date() > archive_limit.date():
101
+ return {"error": f"Archive data not yet available for {end.strftime('%Y-%m-%d')}. Data lags 5-7 days."}
102
+
103
+ url = "https://archive-api.open-meteo.com/v1/archive"
104
+ params = {
105
+ "latitude": LAT, "longitude": LON,
106
+ "start_date": start.strftime("%Y-%m-%d"),
107
+ "end_date": end.strftime("%Y-%m-%d"),
108
+ "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,wind_direction_10m_dominant",
109
+ "hourly": "temperature_2m,relative_humidity_2m,wind_speed_10m,cloud_cover,precipitation",
110
+ "timezone": "Asia/Kolkata",
111
+ }
112
+ try:
113
+ r = requests.get(url, params=params, timeout=15)
114
+ r.raise_for_status()
115
+ data = r.json()
116
+ daily = data.get("daily", {})
117
+ hourly = data.get("hourly", {})
118
+ days_data = []
119
+ for i, date_str in enumerate(daily.get("time", [])):
120
+ dt = datetime.strptime(date_str, "%Y-%m-%d")
121
+ days_data.append({
122
+ "date": date_str,
123
+ "day": dt.strftime("%A"),
124
+ "temp_max": daily.get("temperature_2m_max", [None])[i],
125
+ "temp_min": daily.get("temperature_2m_min", [None])[i],
126
+ "precipitation": daily.get("precipitation_sum", [0])[i],
127
+ "wind_speed_max": daily.get("wind_speed_10m_max", [0])[i],
128
+ })
129
+ return {"daily": days_data, "source": "Open-Meteo Archive API"}
130
+ except Exception as e:
131
+ return {"error": str(e)}
132
+
133
+ def get_earthquakes(min_magnitude: float = 4.5, days: int = 30):
134
+ """Significant earthquakes from USGS."""
135
+ end_time = datetime.utcnow()
136
+ start_time = end_time - timedelta(days=days)
137
+ url = "https://earthquake.usgs.gov/fdsnws/event/1/query"
138
+ params = {
139
+ "format": "geojson",
140
+ "starttime": start_time.isoformat(),
141
+ "endtime": end_time.isoformat(),
142
+ "minmagnitude": min_magnitude,
143
+ "latitude": LAT,
144
+ "longitude": LON,
145
+ "maxradiuskm": 8000,
146
+ }
147
+ try:
148
+ r = requests.get(url, params=params, timeout=10)
149
+ r.raise_for_status()
150
+ data = r.json()
151
+ features = data.get("features", [])
152
+ events = []
153
+ for f in features:
154
+ props = f.get("properties", {})
155
+ geom = f.get("geometry", {})
156
+ coords = geom.get("coordinates", [0, 0, 0])
157
+ events.append({
158
+ "id": f.get("id"),
159
+ "magnitude": props.get("mag"),
160
+ "place": props.get("place"),
161
+ "time": datetime.fromtimestamp(props.get("time") / 1000).isoformat(),
162
+ "url": props.get("url"),
163
+ "tsunami": props.get("tsunami"),
164
+ "lat": coords[1],
165
+ "lon": coords[0],
166
+ "depth": coords[2]
167
+ })
168
+ return {
169
+ "events": events,
170
+ "summary": {
171
+ "total": len(events),
172
+ "max_magnitude": max((e["magnitude"] for e in events), default=0),
173
+ "avg_depth": round(sum(e["depth"] for e in events) / len(events), 1) if events else 0,
174
+ "tsunami_alerts": sum(1 for e in events if e["tsunami"]),
175
+ "m6_plus": sum(1 for e in events if e["magnitude"] >= 6.0),
176
+ }
177
+ }
178
+ except Exception as e:
179
+ return {"error": str(e)}
180
+
181
+ def get_cyclones(year: int = None, name: str = None, min_wind: int = None):
182
+ """Historical cyclone data for Bay of Bengal (simulated/expanded dataset)."""
183
+ # ... (content from main.py)
184
+ # I'll use a slightly more compact version to save space but keep logic same
185
+ cyclones = [
186
+ {"name": "Cyclone Michaung", "year": 2023, "category": "Severe", "max_wind_kmh": 110, "rainfall_mm": 240, "damage_crore": 1500, "dates": "Dec 2-6, 2023", "landfall": "Andhra Coast", "impact": "Heavy rain in Chennai, massive flooding"},
187
+ {"name": "Cyclone Mandous", "year": 2022, "category": "Severe", "max_wind_kmh": 105, "rainfall_mm": 180, "damage_crore": 1000, "dates": "Dec 6-10, 2022", "landfall": "Near Mahabalipuram", "impact": "Trees uprooted, coastal flooding"},
188
+ {"name": "Cyclone Nivar", "year": 2020, "category": "Very Severe", "max_wind_kmh": 145, "rainfall_mm": 250, "damage_crore": 2500, "dates": "Nov 23-27, 2020", "landfall": "Near Puducherry", "impact": "Crop damage, power outages"},
189
+ {"name": "Cyclone Gaja", "year": 2018, "category": "Very Severe", "max_wind_kmh": 120, "rainfall_mm": 180, "damage_crore": 6000, "dates": "Nov 10-17, 2018", "landfall": "Near Vedaranyam", "impact": "Direct hit, 130km/h winds"},
190
+ {"name": "Cyclone Vardah", "year": 2016, "category": "Very Severe", "max_wind_kmh": 140, "rainfall_mm": 150, "damage_crore": 5000, "dates": "Dec 6-13, 2016", "landfall": "Near Chennai", "impact": "Direct hit, 130km/h winds"},
191
+ {"name": "Cyclone Thane", "year": 2011, "category": "Very Severe", "max_wind_kmh": 140, "rainfall_mm": 120, "damage_crore": 2200, "dates": "Dec 25-31, 2011", "landfall": "Near Cuddalore", "impact": "Heavy rains"},
192
+ {"name": "Cyclone Nisha", "year": 2008, "category": "Cyclonic Storm", "max_wind_kmh": 75, "rainfall_mm": 500, "damage_crore": 4500, "dates": "Nov 25-27, 2008", "landfall": "Near Karaikal", "impact": "500mm in 48hrs"},
193
+ ]
194
+ if year: cyclones = [c for c in cyclones if c["year"] == year]
195
+ if name: cyclones = [c for c in cyclones if name.lower() in c["name"].lower()]
196
+ if min_wind: cyclones = [c for c in cyclones if c["max_wind_kmh"] >= min_wind]
197
+ avg_wind = sum(c["max_wind_kmh"] for c in cyclones) / len(cyclones) if cyclones else 0
198
+ return {
199
+ "cyclones": cyclones,
200
+ "summary": {
201
+ "total": len(cyclones),
202
+ "avg_wind": round(avg_wind) if avg_wind else 0,
203
+ "max_rainfall": max((c["rainfall_mm"] for c in cyclones), default=0),
204
+ "total_damage": sum(c["damage_crore"] for c in cyclones),
205
+ "period": f"{min((c['year'] for c in cyclones), default=0)}-{max((c['year'] for c in cyclones), default=0)}",
206
+ }
207
+ }
208
+
209
+ def get_tsunamis():
210
+ """Historical tsunami events worldwide — 30 verified events."""
211
+ events = [
212
+ {"name": "2004 Indian Ocean Tsunami", "date": "2004-12-26", "wave_height_m": 30.0, "fatalities": 227898},
213
+ {"name": "2011 Tōhoku Tsunami", "date": "2011-03-11", "wave_height_m": 40.5, "fatalities": 19759},
214
+ {"name": "1960 Valdivia Tsunami", "date": "1960-05-22", "wave_height_m": 25.0, "fatalities": 6000},
215
+ {"name": "1964 Alaska Tsunami", "date": "1964-03-27", "wave_height_m": 67.0, "fatalities": 131},
216
+ {"name": "1883 Krakatoa Tsunami", "date": "1883-08-27", "wave_height_m": 37.0, "fatalities": 36417},
217
+ {"name": "1755 Lisbon Tsunami", "date": "1755-11-01", "wave_height_m": 20.0, "fatalities": 60000},
218
+ {"name": "1868 Arica Tsunami", "date": "1868-08-13", "wave_height_m": 21.0, "fatalities": 25000},
219
+ {"name": "1896 Meiji Sanriku Tsunami", "date": "1896-06-15", "wave_height_m": 38.2, "fatalities": 22066},
220
+ {"name": "1945 Makran Coast Tsunami", "date": "1945-11-28", "wave_height_m": 13.0, "fatalities": 4000},
221
+ {"name": "1941 Andaman Tsunami", "date": "1941-06-26", "wave_height_m": 1.5, "fatalities": 5000},
222
+ {"name": "2018 Sulawesi Tsunami", "date": "2018-09-28", "wave_height_m": 11.0, "fatalities": 4340},
223
+ {"name": "2018 Anak Krakatau Tsunami", "date": "2018-12-22", "wave_height_m": 5.0, "fatalities": 437},
224
+ {"name": "2005 Nias–Simeulue Tsunami", "date": "2005-03-28", "wave_height_m": 3.0, "fatalities": 1313},
225
+ {"name": "1958 Lituya Bay Mega-Tsunami", "date": "1958-07-09", "wave_height_m": 524.0, "fatalities": 5},
226
+ {"name": "1976 Moro Gulf Tsunami", "date": "1976-08-16", "wave_height_m": 9.0, "fatalities": 5000},
227
+ {"name": "1998 Papua New Guinea Tsunami", "date": "1998-07-17", "wave_height_m": 15.0, "fatalities": 2183},
228
+ {"name": "2009 Samoa Tsunami", "date": "2009-09-29", "wave_height_m": 14.0, "fatalities": 192},
229
+ {"name": "2010 Chile Tsunami", "date": "2010-02-27", "wave_height_m": 29.0, "fatalities": 525},
230
+ {"name": "1933 Shōwa Sanriku Tsunami", "date": "1933-03-02", "wave_height_m": 28.7, "fatalities": 3064},
231
+ {"name": "1946 Aleutian Tsunami", "date": "1946-04-01", "wave_height_m": 35.0, "fatalities": 165},
232
+ {"name": "1952 Kamchatka Tsunami", "date": "1952-11-04", "wave_height_m": 18.0, "fatalities": 2336},
233
+ {"name": "1992 Flores Island Tsunami", "date": "1992-12-12", "wave_height_m": 26.0, "fatalities": 2500},
234
+ {"name": "1993 Hokkaido Tsunami", "date": "1993-07-12", "wave_height_m": 31.7, "fatalities": 230},
235
+ {"name": "2006 Java Tsunami", "date": "2006-07-17", "wave_height_m": 7.0, "fatalities": 668},
236
+ {"name": "1929 Grand Banks Tsunami", "date": "1929-11-18", "wave_height_m": 13.0, "fatalities": 28},
237
+ {"name": "1960 Hilo Tsunami", "date": "1960-05-23", "wave_height_m": 10.7, "fatalities": 61},
238
+ {"name": "2007 Solomon Islands Tsunami", "date": "2007-04-01", "wave_height_m": 12.0, "fatalities": 52},
239
+ {"name": "1908 Messina Tsunami", "date": "1908-12-28", "wave_height_m": 12.0, "fatalities": 80000},
240
+ {"name": "1692 Port Royal Tsunami", "date": "1692-06-07", "wave_height_m": 2.0, "fatalities": 2000},
241
+ {"name": "2004 Sri Lanka Tsunami Impact", "date": "2004-12-26", "wave_height_m": 11.0, "fatalities": 35322},
242
+ ]
243
+ return {
244
+ "events": events,
245
+ "summary": {
246
+ "total": len(events),
247
+ "max_wave": max(e["wave_height_m"] for e in events),
248
+ "total_fatalities": sum(e["fatalities"] for e in events),
249
+ "period": "1692-2018",
250
+ }
251
+ }
252
+