Spaces:
Sleeping
Sleeping
Commit ·
37b38a7
0
Parent(s):
fix: correctly add backend files after submodule removal
Browse files- .gitignore +10 -0
- .pyre_configuration +8 -0
- Dockerfile +28 -0
- README.md +12 -0
- critic.py +41 -0
- date_utils.py +125 -0
- executor.py +185 -0
- groq_llm.py +177 -0
- logger.py +28 -0
- main.py +0 -0
- planner.py +226 -0
- 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 |
+
|