├── .idea
└── doc
├── backend
├── doc
├── .venv
│ └── doc
├── app
│ ├── __init__.py
│ ├── doc
│ ├── core
│ │ ├── __init__.py
│ │ ├── doc
│ │ ├── logging_mw.py
│ │ ├── config.py
│ │ ├── tiers.py
│ │ └── security.py
│ ├── routers
│ │ ├── doc
│ │ ├── __init__.py
│ │ ├── health.py
│ │ ├── forecast.py
│ │ ├── compare.py
│ │ ├── report.py
│ │ ├── auth.py
│ │ └── agent.py
│ ├── utils
│ │ ├── doc
│ │ ├── __init__.py
│ │ ├── compare.py
│ │ └── pdf.py
│ ├── services
│ │ ├── __init__.py
│ │ ├── doc
│ │ ├── fetchers
│ │ │ ├── doc
│ │ │ ├── normalize.py
│ │ │ ├── waqi.py
│ │ │ ├── iqair.py
│ │ │ └── openaq.py
│ │ ├── geocode.py
│ │ ├── aggregate.py
│ │ ├── scraper.py
│ │ ├── forecast.py
│ │ ├── reporter.py
│ │ ├── forecast_prophet.py
│ │ └── llama_client.py
│ ├── db.py
│ ├── main.py
│ ├── models.py
│ └── schemas.py
├── models
│ └── doc
└── requirements.txt
├── frontend
├── doc
├── src
│ ├── doc
│ ├── assets
│ │ ├── doc
│ │ └── react.svg
│ ├── hooks
│ │ └── doc
│ ├── pages
│ │ └── doc
│ ├── utils
│ │ ├── docs
│ │ ├── formatters.js
│ │ ├── api.js
│ │ ├── payloadBuilders.js
│ │ └── chartCapture.js
│ ├── components
│ │ └── doc
│ ├── contexts
│ │ └── doc
│ ├── App.css
│ ├── api.js
│ ├── main.jsx
│ └── Home.jsx
├── public
│ ├── doc
│ └── vite.svg
├── postcss.config.js
├── vite.config.js
├── tailwind.config.js
├── index.html
├── README.md
└── package.json
├── screenshots
├── doc
├── Screenshot 2025-12-17 025015.png
├── Screenshot 2025-12-17 025126.png
├── Screenshot 2025-12-17 025219.png
├── Screenshot 2025-12-17 025247.png
├── Screenshot 2025-12-17 025302.png
├── Screenshot 2025-12-17 025430.png
├── Screenshot 2025-12-17 025447.png
├── Screenshot 2025-12-17 025501.png
├── Screenshot 2025-12-17 025522.png
├── Screenshot 2025-12-17 025532.png
└── Screenshot 2025-12-17 030511.png
└── README.md
/.idea/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/backend/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/backend/.venv/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/backend/app/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/app/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/backend/models/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/src/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/screenshots/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/backend/app/core/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/app/core/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/backend/app/routers/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/backend/app/utils/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/src/assets/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/src/hooks/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/src/pages/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/src/utils/docs:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/backend/app/routers/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/app/services/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/app/services/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/backend/app/utils/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/components/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/src/contexts/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/backend/app/services/fetchers/doc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/backend/requirements.txt:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dyneth02/Air-Quality-Trends-Analysis-Project/HEAD/backend/requirements.txt
--------------------------------------------------------------------------------
/frontend/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/screenshots/Screenshot 2025-12-17 025015.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dyneth02/Air-Quality-Trends-Analysis-Project/HEAD/screenshots/Screenshot 2025-12-17 025015.png
--------------------------------------------------------------------------------
/screenshots/Screenshot 2025-12-17 025126.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dyneth02/Air-Quality-Trends-Analysis-Project/HEAD/screenshots/Screenshot 2025-12-17 025126.png
--------------------------------------------------------------------------------
/screenshots/Screenshot 2025-12-17 025219.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dyneth02/Air-Quality-Trends-Analysis-Project/HEAD/screenshots/Screenshot 2025-12-17 025219.png
--------------------------------------------------------------------------------
/screenshots/Screenshot 2025-12-17 025247.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dyneth02/Air-Quality-Trends-Analysis-Project/HEAD/screenshots/Screenshot 2025-12-17 025247.png
--------------------------------------------------------------------------------
/screenshots/Screenshot 2025-12-17 025302.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dyneth02/Air-Quality-Trends-Analysis-Project/HEAD/screenshots/Screenshot 2025-12-17 025302.png
--------------------------------------------------------------------------------
/screenshots/Screenshot 2025-12-17 025430.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dyneth02/Air-Quality-Trends-Analysis-Project/HEAD/screenshots/Screenshot 2025-12-17 025430.png
--------------------------------------------------------------------------------
/screenshots/Screenshot 2025-12-17 025447.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dyneth02/Air-Quality-Trends-Analysis-Project/HEAD/screenshots/Screenshot 2025-12-17 025447.png
--------------------------------------------------------------------------------
/screenshots/Screenshot 2025-12-17 025501.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dyneth02/Air-Quality-Trends-Analysis-Project/HEAD/screenshots/Screenshot 2025-12-17 025501.png
--------------------------------------------------------------------------------
/screenshots/Screenshot 2025-12-17 025522.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dyneth02/Air-Quality-Trends-Analysis-Project/HEAD/screenshots/Screenshot 2025-12-17 025522.png
--------------------------------------------------------------------------------
/screenshots/Screenshot 2025-12-17 025532.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dyneth02/Air-Quality-Trends-Analysis-Project/HEAD/screenshots/Screenshot 2025-12-17 025532.png
--------------------------------------------------------------------------------
/screenshots/Screenshot 2025-12-17 030511.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dyneth02/Air-Quality-Trends-Analysis-Project/HEAD/screenshots/Screenshot 2025-12-17 030511.png
--------------------------------------------------------------------------------
/frontend/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vite.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/frontend/src/utils/formatters.js:
--------------------------------------------------------------------------------
1 | export const COLORS_TOP = ["#00eaff", "#bf00ff", "#0016ff"];
2 |
3 | export const fmtPM = (value) => (value == null || isNaN(value) ? "-" : `${Number(value).toFixed(2)} µg/m³`);
4 |
5 |
6 |
--------------------------------------------------------------------------------
/frontend/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: [
4 | "./index.html",
5 | "./src/**/*.{js,ts,jsx,tsx}",
6 | ],
7 | theme: {
8 | extend: {},
9 | },
10 | plugins: [],
11 | }
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Air Quality Trends Analysis
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/backend/app/core/logging_mw.py:
--------------------------------------------------------------------------------
1 | import time, uuid, logging
2 | from fastapi import Request
3 |
4 | logger = logging.getLogger("airq")
5 |
6 | async def log_requests(request: Request, call_next):
7 | req_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
8 | start = time.perf_counter()
9 | try:
10 | response = await call_next(request)
11 | return response
12 | finally:
13 | dur_ms = (time.perf_counter() - start) * 1000
14 | logger.info(f"{req_id} {request.method} {request.url.path} -> {dur_ms:.1f}ms")
15 | try:
16 | response.headers["X-Request-ID"] = req_id
17 | except Exception:
18 | pass
19 |
--------------------------------------------------------------------------------
/frontend/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | max-width: 1280px;
3 | margin: 0 auto;
4 | padding: 2rem;
5 | text-align: center;
6 | }
7 |
8 | .logo {
9 | height: 6em;
10 | padding: 1.5em;
11 | will-change: filter;
12 | transition: filter 300ms;
13 | }
14 | .logo:hover {
15 | filter: drop-shadow(0 0 2em #646cffaa);
16 | }
17 | .logo.react:hover {
18 | filter: drop-shadow(0 0 2em #61dafbaa);
19 | }
20 |
21 | @keyframes logo-spin {
22 | from {
23 | transform: rotate(0deg);
24 | }
25 | to {
26 | transform: rotate(360deg);
27 | }
28 | }
29 |
30 | @media (prefers-reduced-motion: no-preference) {
31 | a:nth-of-type(2) .logo {
32 | animation: logo-spin infinite 20s linear;
33 | }
34 | }
35 |
36 | .card {
37 | padding: 2em;
38 | }
39 |
40 | .read-the-docs {
41 | color: #888;
42 | }
43 |
--------------------------------------------------------------------------------
/backend/app/db.py:
--------------------------------------------------------------------------------
1 | # app/db.py
2 | import os
3 | from dotenv import load_dotenv
4 | from sqlalchemy import create_engine
5 | from sqlalchemy.orm import sessionmaker
6 | from .models import Base
7 |
8 | load_dotenv()
9 |
10 | HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
11 | PORT = os.getenv("MYSQL_PORT", "3306")
12 | DB = os.getenv("MYSQL_DB", "airq")
13 | USER = os.getenv("MYSQL_USER", "root")
14 | PWD = os.getenv("MYSQL_PASSWORD", "")
15 |
16 | URL = f"mysql+pymysql://{USER}:{PWD}@{HOST}:{PORT}/{DB}?charset=utf8mb4"
17 |
18 | engine = create_engine(URL, pool_pre_ping=True, future=True)
19 | SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
20 |
21 | # Create tables
22 | Base.metadata.create_all(bind=engine)
23 |
24 | def get_db():
25 | db = SessionLocal()
26 | try:
27 | yield db
28 | finally:
29 | db.close()
30 |
--------------------------------------------------------------------------------
/backend/app/core/config.py:
--------------------------------------------------------------------------------
1 | import os
2 | from typing import List
3 |
4 | class Settings:
5 | @property
6 | def ALLOWED_ORIGINS(self) -> List[str]:
7 | return os.getenv("ALLOWED_ORIGINS", "http://localhost:5173").split(",")
8 |
9 | @property
10 | def API_KEY(self) -> str:
11 | return os.getenv("API_KEY", "dev-key-123")
12 |
13 | @property
14 | def DEFAULT_PLAN(self) -> str:
15 | return os.getenv("DEFAULT_PLAN", "free")
16 |
17 | @property
18 | def JWT_SECRET(self) -> str:
19 | return os.getenv("JWT_SECRET", "your-secret-key-change-in-production")
20 |
21 | @property
22 | def JWT_EXPIRES_MIN(self) -> int:
23 | return int(os.getenv("JWT_EXPIRES_MIN", "60"))
24 |
25 | @property
26 | def COOKIE_DOMAIN(self) -> str:
27 | return os.getenv("COOKIE_DOMAIN", "localhost")
28 |
29 | settings = Settings()
30 |
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | # React + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
13 |
--------------------------------------------------------------------------------
/frontend/src/utils/api.js:
--------------------------------------------------------------------------------
1 | const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
2 |
3 | export async function fetchJson(url, options = {}) {
4 | const response = await fetch(`${BASE_URL}${url}`, {
5 | credentials: 'include',
6 | headers: {
7 | 'Content-Type': 'application/json',
8 | ...options.headers,
9 | },
10 | ...options,
11 | })
12 |
13 | if (!response.ok) {
14 | const error = await response.json().catch(() => ({ message: 'Request failed' }))
15 | throw new Error(error.message || `HTTP ${response.status}`)
16 | }
17 |
18 | return response.json()
19 | }
20 |
21 | export const authApi = {
22 | signup: (email, password) => fetchJson('/auth/signup', {
23 | method: 'POST',
24 | body: JSON.stringify({ email, password })
25 | }),
26 |
27 | login: (email, password) => fetchJson('/auth/login', {
28 | method: 'POST',
29 | body: JSON.stringify({ email, password })
30 | }),
31 |
32 | logout: () => fetchJson('/auth/logout', { method: 'POST' }),
33 |
34 | me: () => fetchJson('/auth/me')
35 | }
36 |
37 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "axios": "^1.11.0",
14 | "gsap": "^3.13.0",
15 | "jsonwebtoken": "^9.0.2",
16 | "motion": "^12.23.18",
17 | "ogl": "^1.0.11",
18 | "react": "^19.1.1",
19 | "react-dom": "^19.1.1",
20 | "react-router-dom": "^7.9.1",
21 | "recharts": "^3.2.1",
22 | "three": "^0.180.0"
23 | },
24 | "devDependencies": {
25 | "@eslint/js": "^9.33.0",
26 | "@types/react": "^19.1.10",
27 | "@types/react-dom": "^19.1.7",
28 | "@vitejs/plugin-react": "^5.0.0",
29 | "autoprefixer": "^10.4.21",
30 | "eslint": "^9.33.0",
31 | "eslint-plugin-react-hooks": "^5.2.0",
32 | "eslint-plugin-react-refresh": "^0.4.20",
33 | "globals": "^16.3.0",
34 | "postcss": "^8.5.6",
35 | "tailwindcss": "^3.4.17",
36 | "vite": "^7.1.2"
37 | },
38 | "description": "This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.",
39 | "main": "eslint.config.js",
40 | "keywords": [],
41 | "author": "",
42 | "license": "ISC"
43 | }
44 |
--------------------------------------------------------------------------------
/backend/app/services/geocode.py:
--------------------------------------------------------------------------------
1 | import requests
2 | from sqlalchemy import text
3 | from sqlalchemy.orm import Session
4 |
5 | def get_coords_for_city(db: Session, city: str):
6 | row = db.execute(
7 | text("SELECT latitude, longitude FROM geocodes WHERE city=:c"),
8 | {"c": city}
9 | ).fetchone()
10 | if row:
11 | return float(row[0]), float(row[1])
12 |
13 | try:
14 | r = requests.get(
15 | "https://geocoding-api.open-meteo.com/v1/search",
16 | params={"name": city, "count": 1},
17 | timeout=20,
18 | )
19 | r.raise_for_status()
20 | data = r.json()
21 | except requests.Timeout:
22 | raise RuntimeError("GeocodingTimeout: upstream geocoder timed out")
23 | except requests.RequestException as e:
24 | raise RuntimeError(f"GeocodingHTTP: {e}")
25 |
26 | if not data.get("results"):
27 | raise RuntimeError(f"GeocodingNoResult: City '{city}' not found")
28 |
29 | lat = float(data["results"][0]["latitude"])
30 | lon = float(data["results"][0]["longitude"])
31 |
32 | db.execute(
33 | text("REPLACE INTO geocodes (city, latitude, longitude) VALUES (:c, :lat, :lon)"),
34 | {"c": city, "lat": lat, "lon": lon},
35 | )
36 | db.commit()
37 | return lat, lon
38 |
--------------------------------------------------------------------------------
/backend/app/utils/compare.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy.orm import Session
2 | from sqlalchemy import text
3 |
4 | def compare_logic(db: Session, cities: list[str], days: int):
5 | by_city = {}
6 | want_end = "NOW()"
7 | want_start = f"DATE_SUB({want_end}, INTERVAL {days} DAY)"
8 | for c in cities:
9 | rows = db.execute(text(f"""
10 | SELECT ts, pm25, pm10
11 | FROM measurements
12 | WHERE city=:c AND source='aggregated'
13 | AND ts >= {want_start} AND ts <= {want_end}
14 | ORDER BY ts
15 | """), {"c": c}).mappings().all()
16 |
17 | vals = [r["pm25"] for r in rows if r["pm25"] is not None]
18 | mean_pm25 = (sum(vals)/len(vals)) if vals else None
19 | min_pm25 = min(vals) if vals else None
20 | max_pm25 = max(vals) if vals else None
21 |
22 | by_city[c] = {
23 | "n_points": len(rows),
24 | "mean_pm25": mean_pm25,
25 | "min_pm25": min_pm25,
26 | "max_pm25": max_pm25,
27 | }
28 |
29 | has_vals = {c:v for c,v in by_city.items() if v["mean_pm25"] is not None}
30 | best = min(has_vals, key=lambda k: has_vals[k]["mean_pm25"]) if has_vals else None
31 | worst = max(has_vals, key=lambda k: has_vals[k]["mean_pm25"]) if has_vals else None
32 | return {"days": days, "byCity": by_city, "best": best, "worst": worst}
33 |
--------------------------------------------------------------------------------
/frontend/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/app/main.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI
2 | from fastapi.middleware.cors import CORSMiddleware
3 | import logging
4 |
5 | from .core.config import settings
6 | from .core.logging_mw import log_requests
7 | from .routers.compare import router as compare_router
8 | from .routers.forecast import router as forecast_router
9 | from .routers.agent import router as agent_router
10 | from .routers.health import router as health_router
11 | from .routers.report import router as report_router
12 | from .routers.auth import router as auth_router
13 |
14 | app = FastAPI(title="AirQ (FastAPI + MySQL + MCP Bridge)")
15 |
16 | # Logging (basic)
17 | logger = logging.getLogger("airq")
18 | if not logger.handlers:
19 | logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
20 |
21 | # Request logging middleware
22 | app.middleware("http")(log_requests)
23 |
24 | # CORS
25 | app.add_middleware(
26 | CORSMiddleware,
27 | allow_origins=settings.ALLOWED_ORIGINS,
28 | allow_credentials=True,
29 | allow_methods=["*"],
30 | allow_headers=["*"],
31 | )
32 |
33 | # Routers
34 | app.include_router(compare_router, prefix="", tags=["compare"])
35 | app.include_router(forecast_router, prefix="", tags=["forecast"])
36 | app.include_router(agent_router, prefix="/agent", tags=["agent"])
37 | app.include_router(health_router, prefix="", tags=["health"])
38 | app.include_router(report_router, prefix="", tags=["report"])
39 | app.include_router(auth_router, prefix="/auth", tags=["auth"])
40 |
41 |
--------------------------------------------------------------------------------
/backend/app/models.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Column, Integer, String, DateTime, Boolean, BigInteger, Enum, ForeignKey
2 | from sqlalchemy.ext.declarative import declarative_base
3 | from sqlalchemy.orm import relationship
4 | from datetime import datetime
5 |
6 | Base = declarative_base()
7 |
8 | class User(Base):
9 | __tablename__ = "users"
10 |
11 | id = Column(BigInteger, primary_key=True, index=True)
12 | email = Column(String(190), unique=True, index=True, nullable=False)
13 | password_hash = Column(String(255), nullable=False)
14 | plan = Column(Enum('free', 'pro', 'enterprise', name='plan_enum'), default='free', nullable=False)
15 | created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
16 | updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
17 | last_login = Column(DateTime, nullable=True)
18 |
19 | # Relationship to refresh tokens
20 | refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
21 |
22 | class RefreshToken(Base):
23 | __tablename__ = "refresh_tokens"
24 |
25 | id = Column(BigInteger, primary_key=True, index=True)
26 | user_id = Column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
27 | token_hash = Column(String(255), nullable=False)
28 | expires_at = Column(DateTime, nullable=False)
29 | created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
30 |
31 | # Relationship to user
32 | user = relationship("User", back_populates="refresh_tokens")
33 |
34 |
--------------------------------------------------------------------------------
/backend/app/routers/health.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Depends
2 | from fastapi.responses import JSONResponse
3 | from sqlalchemy.orm import Session
4 | from sqlalchemy import text
5 | import requests
6 |
7 | from ..db import get_db
8 |
9 | router = APIRouter()
10 |
11 | @router.get("/simple")
12 | def simple_health():
13 | """Simple health check without database dependency"""
14 | return {"status": "ok", "message": "Server is running"}
15 |
16 | @router.get("/healthz")
17 | def healthz(db: Session = Depends(get_db)):
18 | db_ok, db_err = True, None
19 | upstream_ok, up_err = True, None
20 |
21 | try:
22 | db.execute(text("SELECT 1")).scalar()
23 | except Exception as e:
24 | db_ok, db_err = False, str(e)
25 |
26 | # Make upstream check non-blocking with shorter timeout
27 | try:
28 | r = requests.get(
29 | "https://air-quality-api.open-meteo.com/v1/air-quality"
30 | "?latitude=0&longitude=0&hourly=pm2_5&start_date=2025-01-01&end_date=2025-01-02",
31 | timeout=2 # Reduced timeout to prevent hanging
32 | )
33 | upstream_ok = r.status_code < 500
34 | if not upstream_ok:
35 | up_err = f"HTTP {r.status_code}"
36 | except Exception as e:
37 | upstream_ok, up_err = False, str(e)
38 |
39 | # Return ok status even if upstream is down - only fail if DB is down
40 | status = "ok" if db_ok else "degraded"
41 | return JSONResponse({
42 | "status": status,
43 | "db": {"ok": db_ok, "error": db_err},
44 | "upstream": {"ok": upstream_ok, "error": up_err}
45 | })
46 |
--------------------------------------------------------------------------------
/backend/app/routers/forecast.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Depends, HTTPException, Request
2 | from sqlalchemy.orm import Session
3 | from ..db import get_db
4 | from ..schemas import ForecastIn, ForecastMultiIn
5 | from ..core.security import get_plan, Plan
6 | from ..core.tiers import enforce_forecast
7 | from ..services.forecast import forecast_city, fit_and_save_model, backtest_roll, forecast_cities
8 |
9 | router = APIRouter()
10 |
11 | @router.post("/forecast")
12 | def forecast(payload: ForecastIn, request: Request, plan: Plan = Depends(get_plan), db: Session = Depends(get_db)):
13 | enforce_forecast(plan, payload.horizonDays, 1)
14 | result = forecast_city(db, payload.city, payload.horizonDays, payload.trainDays, payload.use_cache)
15 | return {"ok": True, **result}
16 |
17 | @router.post("/forecast/train")
18 | def forecast_train(payload: ForecastIn, db: Session = Depends(get_db)):
19 | path = fit_and_save_model(db, payload.city, payload.trainDays)
20 | return {"ok": True, "modelPath": path}
21 |
22 | @router.get("/forecast/backtest")
23 | def forecast_backtest(city: str, days: int = 30, horizonHours: int = 24, db: Session = Depends(get_db)):
24 | stats = backtest_roll(db, city, days, horizonHours)
25 | return {"ok": True, **stats}
26 |
27 | @router.post("/forecast/multi")
28 | def forecast_multi(payload: ForecastMultiIn, request: Request, plan: Plan = Depends(get_plan), db: Session = Depends(get_db)):
29 | if not payload.cities:
30 | raise HTTPException(400, "No cities provided")
31 | enforce_forecast(plan, payload.horizonDays, len(payload.cities))
32 | out = forecast_cities(db, payload.cities, payload.horizonDays, payload.trainDays, payload.use_cache)
33 | return {"ok": True, **out, "horizonDays": payload.horizonDays}
34 |
--------------------------------------------------------------------------------
/backend/app/schemas.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel, conint, Field
2 | from typing import Optional, Dict, Any, List, Literal
3 |
4 | # Inputs
5 | class CityWindowIn(BaseModel):
6 | city: str
7 | days: conint(ge=1, le=90) = 7
8 | sources: Optional[list[str]] = None
9 |
10 | class CompareIn(BaseModel):
11 | cities: list[str]
12 | days: conint(ge=1, le=90) = 7
13 |
14 | class ForecastIn(BaseModel):
15 | city: str
16 | horizonDays: conint(ge=1, le=30) = 7
17 | trainDays: conint(ge=7, le=120) = 30
18 | use_cache: bool = True
19 |
20 | class ForecastMultiIn(BaseModel):
21 | cities: list[str]
22 | horizonDays: conint(ge=1, le=30) = 7
23 | trainDays: conint(ge=7, le=120) = 30
24 | use_cache: bool = True
25 |
26 | class AgentPlanIn(BaseModel):
27 | prompt: str = Field(..., description="Natural language task")
28 |
29 | class ToolStep(BaseModel):
30 | name: str
31 | arguments: dict = {}
32 |
33 | class AgentPlanOut(BaseModel):
34 | plan: list[ToolStep]
35 | notes: str | None = None
36 | irrelevant: bool = False
37 | reason: str | None = None
38 |
39 | class AgentExecIn(BaseModel):
40 | prompt: str | None = None
41 | plan: list[ToolStep] | None = None
42 |
43 | class AgentExecOut(BaseModel):
44 | answer: str
45 | trace: list
46 | final: dict | None = None
47 |
48 | class ReportRequest(BaseModel):
49 | report_type: str
50 | payload: dict
51 | llm_notes: str | None = None
52 | chart_images: list[str] | None = None
53 |
54 |
55 | class ReportIn(BaseModel):
56 | report_type: Literal["forecast", "comparison"]
57 | cities: List[str]
58 | metrics: Optional[Dict[str, Any]] = None
59 | stats: Optional[Dict[str, Any]] = None
60 | charts: Dict[str, str]
61 | options: Optional[Dict[str, Any]] = None
62 |
--------------------------------------------------------------------------------
/frontend/src/api.js:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 |
3 | const API = import.meta.env.VITE_API_URL || "http://localhost:8000";
4 |
5 | export const api = axios.create({
6 | baseURL: API,
7 | headers: { "Content-Type": "application/json" },
8 | withCredentials: true, // Enable cookies for authentication
9 | });
10 |
11 | // Inject plan header (your backend enforces tiers by X-PLAN)
12 | api.interceptors.request.use((config) => {
13 | config.headers["X-PLAN"] = import.meta.env.VITE_PLAN || "free";
14 | return config;
15 | });
16 |
17 | // Add response interceptor to handle authentication errors
18 | api.interceptors.response.use(
19 | (response) => response,
20 | (error) => {
21 | if (error.response?.status === 401) {
22 | // Handle unauthorized access
23 | window.location.href = '/signin';
24 | }
25 | return Promise.reject(error);
26 | }
27 | );
28 |
29 | export const health = () => api.get("/healthz").then(r => r.data);
30 | export const scrape = (city, days=7) => api.post("/scrape", { city, days }).then(r=>r.data);
31 | export const compareCities = (cities, days=7) => api.post("/compare", { cities, days }).then(r=>r.data);
32 | export const forecastMulti = (cities, horizonDays=7, trainDays=30, use_cache=true) =>
33 | api.post("/forecast/multi", { cities, horizonDays, trainDays, use_cache }).then(r=>r.data);
34 |
35 | export const agentPlan = (prompt) =>
36 | api.post("/agent/plan", { prompt }).then(r=>r.data);
37 |
38 | export const agentExecute = (payload) =>
39 | api.post("/agent/execute", payload).then(r=>r.data);
40 |
41 | export const generateLlmComparisonReport = (reportData) =>
42 | api.post("/llm-comparison-note", reportData).then(r=>r.data);
43 |
44 | export const generateLlmForecastReport = (reportData) =>
45 | api.post("/llm-forecast-note", reportData).then(r=>r.data);
46 |
--------------------------------------------------------------------------------
/backend/app/services/fetchers/normalize.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from datetime import datetime, timezone
3 | from typing import Optional, Dict, Any
4 |
5 | from dateutil import parser as dtparser
6 |
7 |
8 | logger = logging.getLogger(__name__)
9 |
10 |
11 | def align_to_hour(ts: datetime) -> datetime:
12 | if ts.tzinfo is None:
13 | ts = ts.replace(tzinfo=timezone.utc)
14 | ts = ts.astimezone(timezone.utc)
15 | return ts.replace(minute=0, second=0, microsecond=0, tzinfo=timezone.utc)
16 |
17 |
18 | def parse_ts(value: Any) -> Optional[datetime]:
19 | if value is None:
20 | return None
21 | try:
22 | if isinstance(value, datetime):
23 | return value
24 | return dtparser.parse(str(value))
25 | except Exception:
26 | logger.debug("Failed to parse timestamp: %r", value)
27 | return None
28 |
29 |
30 | def safe_float(value: Any) -> Optional[float]:
31 | try:
32 | if value is None:
33 | return None
34 | f = float(value)
35 | return f
36 | except Exception:
37 | return None
38 |
39 |
40 | def clean_pollutant(value: Any) -> Optional[float]:
41 | f = safe_float(value)
42 | if f is None:
43 | return None
44 | if f < 0 or f > 1000:
45 | return None
46 | return f
47 |
48 |
49 | def make_row(ts: datetime, city: str, latitude: Optional[float], longitude: Optional[float],
50 | pm25: Optional[float], pm10: Optional[float], source: str) -> Dict[str, Any]:
51 | return {
52 | "ts": align_to_hour(ts).strftime("%Y-%m-%d %H:00:00"),
53 | "city": city,
54 | "latitude": safe_float(latitude),
55 | "longitude": safe_float(longitude),
56 | "pm25": clean_pollutant(pm25),
57 | "pm10": clean_pollutant(pm10),
58 | "source": source,
59 | }
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/backend/app/core/tiers.py:
--------------------------------------------------------------------------------
1 | from fastapi import HTTPException, Request
2 | from ..schemas import ForecastMultiIn
3 | from .security import Plan, get_plan
4 |
5 | def enforce_scrape(plan: Plan, days: int):
6 | if plan == "free" and days > 7:
7 | raise HTTPException(403, "Free plan supports up to 7 days. Upgrade for more.")
8 | if plan == "pro" and days > 30:
9 | raise HTTPException(403, "Pro plan supports up to 30 days. Enterprise for more.")
10 |
11 | def enforce_compare(plan: Plan, cities: list[str], days: int):
12 | enforce_scrape(plan, days)
13 | if plan == "free" and len(cities) > 1:
14 | raise HTTPException(403, "Free plan supports 1 city only. Upgrade for multi-city.")
15 | if plan == "pro" and len(cities) > 3:
16 | raise HTTPException(403, "Pro plan supports up to 3 cities. Enterprise for more.")
17 |
18 | def enforce_forecast(plan: Plan, horizon_days: int, cities_len: int = 1):
19 | if plan == "free":
20 | raise HTTPException(403, "Forecasting is a Pro feature. Upgrade to use forecasting.")
21 | if plan == "pro":
22 | if horizon_days > 7:
23 | raise HTTPException(403, "Pro plan supports forecast horizon up to 7 days.")
24 | if cities_len > 3:
25 | raise HTTPException(403, "Pro plan supports up to 3 cities.")
26 |
27 | def enforce_tier_limits_for_forecast_multi(payload: ForecastMultiIn, role: str = "pro"):
28 | if role == "free":
29 | if len(payload.cities) > 1:
30 | raise HTTPException(403, "Free tier supports 1 city.")
31 | if payload.horizonDays > 7:
32 | raise HTTPException(403, "Free tier supports up to 7-day horizon.")
33 | if role == "pro":
34 | if len(payload.cities) > 3:
35 | raise HTTPException(403, "Pro tier supports up to 3 cities.")
36 | if payload.horizonDays > 7:
37 | raise HTTPException(403, "Pro tier supports up to 7-day horizon.")
38 |
--------------------------------------------------------------------------------
/frontend/src/main.jsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
4 | import { AuthProvider } from './contexts/AuthContext'
5 | import RequireAuth from './components/RequireAuth'
6 | import Header from './components/Header'
7 | import './index.css'
8 | import Workspace from './Workspace.jsx'
9 | import Home from './Home.jsx'
10 | import Signin from './pages/Signin.jsx'
11 | import Signup from './pages/Signup.jsx'
12 | import PrintComparisonReport from './pages/PrintComparisonReport.jsx'
13 | import PrintForecastReport from './pages/PrintForecastReport.jsx'
14 |
15 | createRoot(document.getElementById('root')).render(
16 |
17 |
18 |
19 |
20 | } />
21 | } />
22 | } />
23 | } />
24 |
28 |
29 |
30 | }
31 | />
32 |
36 |
37 |
38 | }
39 | />
40 |
44 |
45 |
46 | }
47 | />
48 | } />
49 |
50 |
51 |
52 | ,
53 | )
54 |
--------------------------------------------------------------------------------
/backend/app/routers/compare.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Depends, HTTPException, Request
2 | from sqlalchemy.orm import Session
3 | from ..db import get_db
4 | from ..schemas import CityWindowIn, CompareIn
5 | from ..core.security import get_plan, Plan
6 | from ..core.tiers import enforce_scrape, enforce_compare
7 | import os
8 | from ..services.scraper import ensure_window_for_city, ensure_window_for_city_with_counts
9 | from ..utils.compare import compare_logic
10 |
11 | router = APIRouter()
12 |
13 | @router.post("/scrape")
14 | def scrape_city(payload: CityWindowIn, request: Request, plan: Plan = Depends(get_plan), db: Session = Depends(get_db)):
15 | enforce_scrape(plan, payload.days)
16 | inserted, (lat, lon) = ensure_window_for_city(db, payload.city, payload.days, payload.sources)
17 | return {"ok": True, "city": payload.city, "inserted": inserted, "lat": lat, "lon": lon}
18 |
19 | @router.post("/compare")
20 | def compare_cities(payload: CompareIn, request: Request, plan: Plan = Depends(get_plan), db: Session = Depends(get_db)):
21 | if not payload.cities:
22 | raise HTTPException(400, "No cities provided")
23 | enforce_compare(plan, payload.cities, payload.days)
24 | for c in payload.cities:
25 | ensure_window_for_city(db, c, payload.days, None)
26 | return {"ok": True, **compare_logic(db, payload.cities, payload.days)}
27 |
28 |
29 | @router.post("/scrape/aggregate")
30 | def scrape_city_aggregate(payload: CityWindowIn, request: Request, plan: Plan = Depends(get_plan), db: Session = Depends(get_db)):
31 | enforce_scrape(plan, payload.days)
32 | counts, (lat, lon) = ensure_window_for_city_with_counts(db, payload.city, payload.days, payload.sources)
33 | # Emphasize aggregated counts, include which sources contributed
34 | sources_enabled = payload.sources
35 | if not sources_enabled:
36 | env_val = os.getenv('SOURCES_ENABLED', '')
37 | sources_enabled = [s.strip() for s in env_val.split(',') if s.strip()] or ["openaq", "iqair", "waqi"]
38 | return {
39 | "ok": True,
40 | "city": payload.city,
41 | "lat": lat,
42 | "lon": lon,
43 | "inserted": sum(counts.values()),
44 | "counts": counts,
45 | "sources_enabled": sources_enabled,
46 | "aggregated": counts.get('aggregated', 0),
47 | }
48 |
--------------------------------------------------------------------------------
/backend/app/utils/pdf.py:
--------------------------------------------------------------------------------
1 | from io import BytesIO
2 | from reportlab.lib.pagesizes import A4
3 | from reportlab.lib.styles import getSampleStyleSheet
4 | from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image
5 | from reportlab.lib import colors
6 | import base64, logging
7 |
8 | logger = logging.getLogger("airq")
9 |
10 | def build_report(title, cities, content, chart_images=None, llm_conclusion=None):
11 | buffer = BytesIO()
12 | doc = SimpleDocTemplate(buffer, pagesize=A4)
13 | styles = getSampleStyleSheet()
14 | story = []
15 |
16 | story.append(Paragraph("AirSense ", styles["Title"]))
17 | story.append(Spacer(1, 12))
18 | story.append(Paragraph(f"{title} ", styles["Heading2"]))
19 | story.append(Spacer(1, 12))
20 | story.append(Paragraph(f"Cities: {', '.join(cities)}", styles["Normal"]))
21 | story.append(Spacer(1, 12))
22 |
23 | if isinstance(content, dict) and "byCity" in content:
24 | data = [["City", "Mean PM2.5 (µg/m³)", "Min", "Max", "Points"]]
25 | for c, vals in content["byCity"].items():
26 | mean_val = vals.get("mean_pm25", vals.get("mean_yhat"))
27 | data.append([
28 | c,
29 | f"{mean_val:.2f}" if mean_val is not None else "-",
30 | vals.get("min_pm25", "-"),
31 | vals.get("max_pm25", "-"),
32 | vals.get("n_points", "-"),
33 | ])
34 | t = Table(data)
35 | t.setStyle(TableStyle([
36 | ("BACKGROUND", (0,0), (-1,0), colors.HexColor("#4B5563")),
37 | ("TEXTCOLOR", (0,0), (-1,0), colors.white),
38 | ("GRID", (0,0), (-1,-1), 0.5, colors.grey),
39 | ("BACKGROUND", (0,1), (-1,-1), colors.whitesmoke),
40 | ]))
41 | story.append(t)
42 | story.append(Spacer(1, 12))
43 |
44 | if chart_images:
45 | story.append(Paragraph("Charts: ", styles["Heading3"]))
46 | story.append(Spacer(1, 12))
47 | for img_b64 in chart_images:
48 | try:
49 | img_data = base64.b64decode(img_b64)
50 | story.append(Image(BytesIO(img_data), width=400, height=250))
51 | story.append(Spacer(1, 12))
52 | except Exception as e:
53 | logger.warning(f"Failed to process chart image: {e}")
54 |
55 | if llm_conclusion:
56 | story.append(Paragraph("AI Assistant Conclusion: ", styles["Heading3"]))
57 | story.append(Spacer(1, 6))
58 | story.append(Paragraph(llm_conclusion, styles["Normal"]))
59 | story.append(Spacer(1, 12))
60 |
61 | doc.build(story)
62 | buffer.seek(0)
63 | return buffer
64 |
--------------------------------------------------------------------------------
/backend/app/routers/report.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Depends, HTTPException
2 | from fastapi.responses import Response
3 | from sqlalchemy.orm import Session
4 | from typing import List, Dict, Any
5 | import json
6 |
7 | from ..db import get_db
8 | from ..schemas import ReportIn
9 | from ..services.reporter import make_report
10 | from ..services.llama_client import generate_llm_report, generate_llm_forecast_report
11 |
12 |
13 | router = APIRouter()
14 |
15 |
16 | @router.post("/report/generate")
17 | def generate_report(payload: ReportIn, db: Session = Depends(get_db)):
18 | pdf_bytes = make_report(payload)
19 | filename = f"{payload.report_type}_report.pdf"
20 | return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{filename}"'})
21 |
22 |
23 | @router.post("/llm-comparison-note")
24 | def generate_llm_comparison_report(report_data: Dict[str, Any]):
25 | """
26 | Generate LLM-powered comparison report using Gemma3:4b
27 | """
28 | try:
29 | # Extract data from the request
30 | comparison_data = report_data.get("comparisonData", {})
31 | chart_data = report_data.get("chartData", {})
32 | cities = report_data.get("cities", [])
33 | period_days = report_data.get("periodDays", 7)
34 | show_combined = report_data.get("showCombined", False)
35 |
36 | # Generate the LLM report
37 | llm_response = generate_llm_report(comparison_data, chart_data, cities, period_days, show_combined)
38 |
39 | return llm_response
40 |
41 | except Exception as e:
42 | raise HTTPException(status_code=500, detail=f"Failed to generate LLM report: {str(e)}")
43 |
44 |
45 | @router.post("/llm-forecast-note")
46 | def generate_llm_forecast_report_endpoint(report_data: Dict[str, Any]):
47 | """
48 | Generate LLM-powered forecast report using Gemma3:4b
49 | """
50 | try:
51 | # Extract data from the request
52 | forecast_data = report_data.get("forecastData", {})
53 | chart_data = report_data.get("chartData", {})
54 | cities = report_data.get("cities", [])
55 | horizon_days = report_data.get("horizonDays", 7)
56 | train_days = report_data.get("trainDays", 30)
57 | show_ci = report_data.get("showCI", False)
58 | show_combined = report_data.get("showCombined", False)
59 | selected_model = report_data.get("selectedModel", "sarimax")
60 |
61 | # Generate the LLM forecast report
62 | llm_response = generate_llm_forecast_report(forecast_data, chart_data, cities, horizon_days, train_days, show_ci, show_combined, selected_model)
63 |
64 | return llm_response
65 |
66 | except Exception as e:
67 | raise HTTPException(status_code=500, detail=f"Failed to generate LLM forecast report: {str(e)}")
68 |
69 |
70 |
--------------------------------------------------------------------------------
/backend/app/core/security.py:
--------------------------------------------------------------------------------
1 | from typing import Optional, Literal, Dict, Any
2 | from datetime import datetime, timedelta
3 | from fastapi import Request, Header, HTTPException, Depends
4 | import jwt
5 | from jwt.exceptions import InvalidTokenError
6 | from passlib.context import CryptContext
7 | from .config import settings
8 |
9 | Plan = Literal["free", "pro", "enterprise"]
10 |
11 | # Password hashing context
12 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
13 |
14 | def require_api_key(req: Request):
15 | if req.headers.get("X-API-KEY") != settings.API_KEY:
16 | raise HTTPException(401, "Missing/invalid API key")
17 |
18 | def hash_password(plain: str) -> str:
19 | """Hash a plain text password using bcrypt."""
20 | return pwd_context.hash(plain)
21 |
22 | def verify_password(plain: str, hashed: str) -> bool:
23 | """Verify a plain text password against its hash."""
24 | return pwd_context.verify(plain, hashed)
25 |
26 | def create_access_token(payload: Dict[str, Any], expires_minutes: int) -> str:
27 | """Create a JWT access token with the given payload and expiration."""
28 | to_encode = payload.copy()
29 | expire = datetime.utcnow() + timedelta(minutes=expires_minutes)
30 | to_encode.update({"exp": expire})
31 | encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET, algorithm="HS256")
32 | return encoded_jwt
33 |
34 | def decode_access_token(token: str) -> Optional[Dict[str, Any]]:
35 | """Decode and verify a JWT access token."""
36 | try:
37 | payload = jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
38 | return payload
39 | except InvalidTokenError:
40 | return None
41 |
42 | def get_auth_user(request: Request) -> Optional[Dict[str, Any]]:
43 | """Get authenticated user from JWT token in Authorization header or cookie."""
44 | # Try Authorization header first
45 | auth_header = request.headers.get("Authorization")
46 | if auth_header and auth_header.startswith("Bearer "):
47 | token = auth_header.split(" ")[1]
48 | payload = decode_access_token(token)
49 | if payload:
50 | return {
51 | "id": payload.get("sub"),
52 | "email": payload.get("email"),
53 | "plan": payload.get("plan", "free")
54 | }
55 |
56 | # Try cookie
57 | token = request.cookies.get("airsense_access")
58 | if token:
59 | payload = decode_access_token(token)
60 | if payload:
61 | return {
62 | "id": payload.get("sub"),
63 | "email": payload.get("email"),
64 | "plan": payload.get("plan", "free")
65 | }
66 |
67 | return None
68 |
69 | def get_plan(request: Request, x_plan: Optional[str] = Header(None)) -> Plan:
70 | """Get plan from authenticated user or fall back to header/env."""
71 | # Check if user is authenticated
72 | user = get_auth_user(request)
73 | if user and user.get("plan"):
74 | plan = user["plan"].strip().lower()
75 | if plan in {"free", "pro", "enterprise"}:
76 | return plan # type: ignore
77 |
78 | # Fall back to header or default
79 | plan = (x_plan or settings.DEFAULT_PLAN).strip().lower()
80 | if plan not in {"free", "pro", "enterprise"}:
81 | plan = "free"
82 | return plan # type: ignore
83 |
--------------------------------------------------------------------------------
/frontend/src/utils/payloadBuilders.js:
--------------------------------------------------------------------------------
1 | // Rounds non-integer numbers to two decimals to match UI card display
2 | function roundValue(value) {
3 | if (typeof value !== 'number' || !isFinite(value)) return value;
4 | if (Number.isInteger(value)) return value;
5 | return Number(value.toFixed(2));
6 | }
7 |
8 | // Recursively round numeric fields (except integers). Keeps structure of input.
9 | function normalizeStats(stats) {
10 | if (stats == null) return stats;
11 | if (Array.isArray(stats)) return stats.map(normalizeStats);
12 | if (typeof stats === 'number') return roundValue(stats);
13 | if (typeof stats === 'object') {
14 | const out = {};
15 | for (const [k, v] of Object.entries(stats)) {
16 | out[k] = normalizeStats(v);
17 | }
18 | return out;
19 | }
20 | return stats;
21 | }
22 |
23 | // Compute forecast ranges from byCity series (based on mean series yhat)
24 | function addForecastRanges(stats, byCity) {
25 | if (!stats || typeof stats !== 'object' || !byCity || typeof byCity !== 'object') return stats || {};
26 | const out = { ...stats };
27 | for (const [city, series] of Object.entries(byCity)) {
28 | const items = Array.isArray(series) ? series : [];
29 | const ys = items
30 | .map((p) => (typeof p?.yhat === 'number' && isFinite(p.yhat) ? p.yhat : null))
31 | .filter((v) => v != null);
32 | if (ys.length === 0) continue;
33 | const min = Math.min(...ys);
34 | const max = Math.max(...ys);
35 | const rangeStr = `${min.toFixed(1)} – ${max.toFixed(1)}`;
36 | const cityStats = typeof out[city] === 'object' && out[city] !== null ? { ...out[city] } : {};
37 | if (cityStats.range == null) cityStats.range = rangeStr;
38 | out[city] = cityStats;
39 | }
40 | return out;
41 | }
42 |
43 | function normalizeCities(cities) {
44 | if (!Array.isArray(cities)) return [];
45 | return cities.map((c) => String(c || '').trim()).filter(Boolean);
46 | }
47 |
48 | function normalizeCharts(chartBase64) {
49 | const charts = {};
50 | if (!chartBase64 || typeof chartBase64 !== 'object') return charts;
51 | if (chartBase64.combined) charts.combined = chartBase64.combined;
52 | for (const [k, v] of Object.entries(chartBase64)) {
53 | if (k === 'combined') continue;
54 | if (typeof v === 'string' && v.length > 0) charts[k] = v;
55 | }
56 | return charts;
57 | }
58 |
59 | export function buildForecastReportPayload({ cities, horizonDays, windowDays, stats, chartBase64, showConfidence, showCombined, byCity }) {
60 | const payload = {
61 | report_type: 'forecast',
62 | cities: normalizeCities(cities),
63 | metrics: {},
64 | stats: normalizeStats(addForecastRanges(stats || {}, byCity)),
65 | charts: normalizeCharts(chartBase64),
66 | options: {},
67 | };
68 |
69 | if (typeof horizonDays === 'number') payload.metrics.horizonDays = roundValue(horizonDays);
70 | if (typeof windowDays === 'number') payload.metrics.windowDays = roundValue(windowDays);
71 | if (showConfidence != null) payload.options.showConfidence = !!showConfidence;
72 | if (showCombined != null) payload.options.showCombined = !!showCombined;
73 |
74 | return payload;
75 | }
76 |
77 | export function buildComparisonReportPayload({ cities, periodDays, stats, chartBase64, showCombined }) {
78 | const payload = {
79 | report_type: 'comparison',
80 | cities: normalizeCities(cities),
81 | metrics: {},
82 | stats: normalizeStats(stats || {}),
83 | charts: normalizeCharts(chartBase64),
84 | options: {},
85 | };
86 |
87 | if (typeof periodDays === 'number') payload.metrics.periodDays = roundValue(periodDays);
88 | if (showCombined != null) payload.options.showCombined = !!showCombined;
89 |
90 | return payload;
91 | }
92 |
93 |
94 |
95 |
--------------------------------------------------------------------------------
/backend/app/services/fetchers/waqi.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from datetime import datetime, date
3 | from typing import List, Dict, Any, Optional
4 |
5 | import requests
6 | from bs4 import BeautifulSoup # type: ignore
7 |
8 | from .normalize import make_row, parse_ts
9 |
10 |
11 | logger = logging.getLogger(__name__)
12 |
13 | TIMEOUT = 15
14 | RETRIES = 2
15 |
16 |
17 | def _get(url: str, params: Dict[str, Any] = None, headers: Dict[str, Any] = None) -> Optional[requests.Response]:
18 | params = params or {}
19 | headers = headers or {"User-Agent": "Mozilla/5.0 (compatible; AirQualityBot/1.0)"}
20 | for i in range(RETRIES + 1):
21 | try:
22 | r = requests.get(url, params=params, headers=headers, timeout=TIMEOUT)
23 | if r.status_code == 200:
24 | return r
25 | logger.warning("WAQI non-200: %s", r.status_code)
26 | except Exception as e:
27 | logger.warning("WAQI request failed (try %s): %s", i + 1, e)
28 | return None
29 |
30 |
31 | def fetch_waqi(city: str, start: date, end: date, lat: float = None, lon: float = None, token: Optional[str] = None) -> List[Dict[str, Any]]:
32 | rows: List[Dict[str, Any]] = []
33 | try:
34 | if token:
35 | # API mode
36 | url = f"https://api.waqi.info/feed/{city}/"
37 | res = _get(url, params={"token": token})
38 | if res is not None:
39 | try:
40 | data = res.json()
41 | iaqi = (data or {}).get("data", {}).get("iaqi", {})
42 | time_obj = (data or {}).get("data", {}).get("time", {})
43 | ts = parse_ts(time_obj.get("utc") or time_obj.get("s")) or datetime.utcnow()
44 | pm25 = iaqi.get("pm25", {}).get("v")
45 | pm10 = iaqi.get("pm10", {}).get("v")
46 | rows.append(make_row(ts=ts, city=city, latitude=lat, longitude=lon, pm25=pm25, pm10=pm10, source="waqi"))
47 | return rows
48 | except Exception:
49 | logger.warning("WAQI API parse failed")
50 |
51 | # HTML scrape fallback
52 | # WAQI station/city page URLs vary; a simple guess:
53 | slug = city.strip().lower().replace(" ", "-")
54 | url = f"https://aqicn.org/city/{slug}/"
55 | res = _get(url)
56 | if res is None:
57 | return rows
58 | html = res.text
59 | soup = BeautifulSoup(html, "html.parser")
60 |
61 | # Attempt to extract a current timestamp and values
62 | ts = datetime.utcnow()
63 | pm25 = None
64 | pm10 = None
65 | try:
66 | # common layout: elements with id like 'pm25', 'pm10'
67 | el25 = soup.select_one('#pm25 .value') or soup.select_one('[data-pollutant="pm25"] .value')
68 | if el25:
69 | try:
70 | pm25 = float(el25.get_text(strip=True))
71 | except Exception:
72 | pm25 = None
73 | el10 = soup.select_one('#pm10 .value') or soup.select_one('[data-pollutant="pm10"] .value')
74 | if el10:
75 | try:
76 | pm10 = float(el10.get_text(strip=True))
77 | except Exception:
78 | pm10 = None
79 | # time may be in a tag with class/time-id; fallback to now
80 | time_el = soup.find(class_='time')
81 | if time_el:
82 | ts_parsed = parse_ts(time_el.get_text(" ").strip())
83 | if ts_parsed:
84 | ts = ts_parsed
85 | except Exception:
86 | pass
87 |
88 | rows.append(make_row(ts=ts, city=city, latitude=lat, longitude=lon, pm25=pm25, pm10=pm10, source="waqi"))
89 | except Exception as e:
90 | logger.warning("fetch_waqi failed: %s", e)
91 | return rows
92 |
93 |
94 |
95 |
--------------------------------------------------------------------------------
/frontend/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/app/services/fetchers/iqair.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from datetime import datetime, date
3 | from typing import List, Dict, Any, Optional
4 |
5 | import requests
6 | from bs4 import BeautifulSoup # type: ignore
7 |
8 | from .normalize import make_row, parse_ts
9 |
10 |
11 | logger = logging.getLogger(__name__)
12 |
13 | HEADERS = {
14 | "User-Agent": "Mozilla/5.0 (compatible; AirQualityBot/1.0; +https://example.com/contact)",
15 | }
16 | TIMEOUT = 15
17 | RETRIES = 2
18 |
19 |
20 | def _get(url: str) -> Optional[str]:
21 | for i in range(RETRIES + 1):
22 | try:
23 | r = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
24 | if r.status_code == 200:
25 | return r.text
26 | logger.warning("IQAir non-200: %s", r.status_code)
27 | except Exception as e:
28 | logger.warning("IQAir request failed (try %s): %s", i + 1, e)
29 | return None
30 |
31 |
32 | def _guess_city_path(city: str) -> str:
33 | # Basic slug guess; real implementation may need mapping
34 | slug = city.strip().lower().replace(" ", "-")
35 | return f"https://www.iqair.com/{slug}"
36 |
37 |
38 | def fetch_iqair(city: str, start: date, end: date, lat: float = None, lon: float = None) -> List[Dict[str, Any]]:
39 | rows: List[Dict[str, Any]] = []
40 | try:
41 | url = _guess_city_path(city)
42 | html = _get(url)
43 | if not html:
44 | return rows
45 | soup = BeautifulSoup(html, "html.parser")
46 |
47 | # Try to locate hourly/historical blocks; site structure may change
48 | # Fallback: parse current card
49 | candidates = []
50 | try:
51 | # Example selectors (subject to change):
52 | for card in soup.select('[data-testid="history"] [data-testid="hour"]'):
53 | ts_text = card.get("data-time") or card.get_text(" ")
54 | pm25 = None
55 | pm10 = None
56 | for pollutant in card.select('[data-testid="pollutant"]'):
57 | label = pollutant.get_text(" ").lower()
58 | val_text = pollutant.find("span")
59 | val = None
60 | if val_text:
61 | try:
62 | val = float(val_text.get_text(strip=True))
63 | except Exception:
64 | val = None
65 | if "pm2.5" in label:
66 | pm25 = val
67 | elif "pm10" in label:
68 | pm10 = val
69 | candidates.append((ts_text, pm25, pm10))
70 | except Exception:
71 | pass
72 |
73 | if not candidates:
74 | # parse current
75 | try:
76 | now_block = soup.select_one('[data-testid="current"]')
77 | if now_block:
78 | ts_text = now_block.get("data-time") or datetime.utcnow().isoformat()
79 | pm25 = None
80 | pm10 = None
81 | for pollutant in now_block.select('[data-testid="pollutant"]'):
82 | label = pollutant.get_text(" ").lower()
83 | val_tag = pollutant.find("span")
84 | val = None
85 | if val_tag:
86 | try:
87 | val = float(val_tag.get_text(strip=True))
88 | except Exception:
89 | val = None
90 | if "pm2.5" in label:
91 | pm25 = val
92 | elif "pm10" in label:
93 | pm10 = val
94 | candidates = [(ts_text, pm25, pm10)]
95 | except Exception:
96 | pass
97 |
98 | for ts_text, pm25, pm10 in candidates:
99 | ts = parse_ts(ts_text) or datetime.utcnow()
100 | rows.append(make_row(ts=ts, city=city, latitude=lat, longitude=lon, pm25=pm25, pm10=pm10, source="iqair"))
101 | except Exception as e:
102 | logger.warning("fetch_iqair failed: %s", e)
103 | return rows
104 |
105 |
106 |
107 |
--------------------------------------------------------------------------------
/backend/app/services/fetchers/openaq.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from datetime import datetime, date, timedelta
3 | from typing import List, Dict, Any, Optional
4 |
5 | import requests
6 |
7 | from .normalize import make_row, parse_ts
8 |
9 |
10 | logger = logging.getLogger(__name__)
11 |
12 | BASE_URL = "https://api.openaq.org/v2"
13 | TIMEOUT = 15 # seconds
14 | RETRIES = 2
15 |
16 |
17 | def _req(url: str, params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
18 | for i in range(RETRIES + 1):
19 | try:
20 | r = requests.get(url, params=params, timeout=TIMEOUT)
21 | if r.status_code == 200:
22 | return r.json()
23 | logger.warning("OpenAQ non-200: %s %s", r.status_code, r.text[:200])
24 | except Exception as e:
25 | logger.warning("OpenAQ request failed (try %s): %s", i + 1, e)
26 | return None
27 |
28 |
29 | def fetch_openaq(city: str, start: date, end: date, lat: float = None, lon: float = None) -> List[Dict[str, Any]]:
30 | rows: List[Dict[str, Any]] = []
31 | try:
32 | # OpenAQ measurements endpoint: we will fetch PM2.5 and PM10 separately and then merge by timestamp
33 | start_dt = datetime.combine(start, datetime.min.time())
34 | end_dt = datetime.combine(end + timedelta(days=1), datetime.min.time()) # inclusive end day
35 |
36 | params_base = {
37 | "limit": 100,
38 | "page": 1,
39 | "offset": 0,
40 | "parameter": "pm25",
41 | "date_from": start_dt.isoformat() + "Z",
42 | "date_to": end_dt.isoformat() + "Z",
43 | "order_by": "datetime",
44 | "sort": "asc",
45 | }
46 | if city:
47 | params_base["city"] = city
48 | if lat is not None and lon is not None:
49 | params_base["coordinates"] = f"{lat},{lon}"
50 | params_base["radius"] = 20000
51 |
52 | def fetch_param(param: str) -> List[Dict[str, Any]]:
53 | merged: List[Dict[str, Any]] = []
54 | params = dict(params_base)
55 | params["parameter"] = param
56 | page = 1
57 | while True:
58 | params["page"] = page
59 | data = _req(f"{BASE_URL}/measurements", params)
60 | if not data or "results" not in data:
61 | break
62 | res = data["results"]
63 | if not res:
64 | break
65 | merged.extend(res)
66 | if len(res) < params["limit"]:
67 | break
68 | page += 1
69 | return merged
70 |
71 | pm25_res = fetch_param("pm25")
72 | pm10_res = fetch_param("pm10")
73 |
74 | # Index by hour timestamp
75 | by_ts: Dict[str, Dict[str, Any]] = {}
76 |
77 | def add_values(items, key):
78 | for it in items:
79 | ts = parse_ts(it.get("date", {}).get("utc")) or parse_ts(it.get("date", {}).get("local"))
80 | if not ts:
81 | continue
82 | ts_hr = ts.replace(minute=0, second=0, microsecond=0)
83 | k = ts_hr.strftime("%Y-%m-%d %H:00:00")
84 | ent = by_ts.setdefault(k, {"lat": it.get("coordinates", {}).get("latitude"),
85 | "lon": it.get("coordinates", {}).get("longitude")})
86 | val = it.get("value")
87 | try:
88 | ent[key] = float(val)
89 | except Exception:
90 | pass
91 |
92 | add_values(pm25_res, "pm25")
93 | add_values(pm10_res, "pm10")
94 |
95 | for k, ent in by_ts.items():
96 | ts_dt = parse_ts(k) or datetime.utcnow()
97 | rows.append(
98 | make_row(
99 | ts=ts_dt,
100 | city=city,
101 | latitude=ent.get("lat"),
102 | longitude=ent.get("lon"),
103 | pm25=ent.get("pm25"),
104 | pm10=ent.get("pm10"),
105 | source="openaq",
106 | )
107 | )
108 | except Exception as e:
109 | logger.warning("fetch_openaq failed: %s", e)
110 | return rows
111 |
112 |
113 |
114 |
--------------------------------------------------------------------------------
/backend/app/services/aggregate.py:
--------------------------------------------------------------------------------
1 | import os
2 | import logging
3 | from typing import List, Dict, Any, Tuple, Optional
4 |
5 |
6 | logger = logging.getLogger(__name__)
7 |
8 |
9 | def _parse_weights(env_val: Optional[str]) -> Dict[str, float]:
10 | weights: Dict[str, float] = {}
11 | if not env_val:
12 | return weights
13 | try:
14 | parts = [p.strip() for p in env_val.split(',') if p.strip()]
15 | for part in parts:
16 | if '=' in part:
17 | k, v = part.split('=', 1)
18 | k = k.strip()
19 | try:
20 | weights[k] = float(v)
21 | except Exception:
22 | pass
23 | except Exception:
24 | logger.warning("Failed to parse AGG_WEIGHTS: %r", env_val)
25 | return weights
26 |
27 |
28 | def _zscore_trim(values: List[Tuple[float, float]], z: float) -> List[Tuple[float, float]]:
29 | # values as (x, weight)
30 | if not values:
31 | return values
32 | xs = [x for x, _ in values]
33 | mean = sum(xs) / len(xs)
34 | var = sum((x - mean) ** 2 for x in xs) / max(1, len(xs) - 1)
35 | std = var ** 0.5
36 | if std == 0:
37 | return values
38 | kept: List[Tuple[float, float]] = []
39 | for x, w in values:
40 | if abs((x - mean) / std) <= z:
41 | kept.append((x, w))
42 | return kept
43 |
44 |
45 | def _iqr_trim(values: List[Tuple[float, float]], k: float) -> List[Tuple[float, float]]:
46 | if not values:
47 | return values
48 | xs = sorted(x for x, _ in values)
49 | n = len(xs)
50 | if n < 4:
51 | return values
52 | q1 = xs[n // 4]
53 | q3 = xs[(3 * n) // 4]
54 | iqr = q3 - q1
55 | lo = q1 - k * iqr
56 | hi = q3 + k * iqr
57 | kept: List[Tuple[float, float]] = []
58 | for x, w in values:
59 | if lo <= x <= hi:
60 | kept.append((x, w))
61 | return kept
62 |
63 |
64 | def _maybe_trim(values: List[Tuple[float, float]]) -> List[Tuple[float, float]]:
65 | # Config: AGG_TRIM (0/1), AGG_TRIM_METHOD (zscore|iqr), AGG_Z (default 3.0), AGG_IQR_K (default 1.5)
66 | if not values:
67 | return values
68 | try:
69 | do_trim = os.getenv('AGG_TRIM', '0') in ('1', 'true', 'True')
70 | if not do_trim:
71 | return values
72 | method = os.getenv('AGG_TRIM_METHOD', 'zscore').lower()
73 | if method == 'iqr':
74 | k = float(os.getenv('AGG_IQR_K', '1.5'))
75 | return _iqr_trim(values, k)
76 | else:
77 | z = float(os.getenv('AGG_Z', '3.0'))
78 | return _zscore_trim(values, z)
79 | except Exception:
80 | return values
81 |
82 |
83 | def _weighted_mean(values: List[Tuple[float, float]]) -> Optional[float]:
84 | if not values:
85 | return None
86 | num = sum(x * w for x, w in values)
87 | den = sum(w for _, w in values)
88 | if den == 0:
89 | return None
90 | return num / den
91 |
92 |
93 | def combine_by_timestamp(city: str, lat: float, lon: float, *sources_rows: List[List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
94 | """
95 | Merge rows from multiple sources by ts and compute (weighted) means.
96 | Returns rows with source='aggregated'.
97 | Each input row is expected to have: ts (str or datetime), city, latitude, longitude, pm25, pm10, source
98 | """
99 | weights_cfg = _parse_weights(os.getenv('AGG_WEIGHTS'))
100 | by_ts: Dict[str, Dict[str, List[Tuple[float, float]]]] = {}
101 |
102 | for rows in sources_rows:
103 | for r in rows or []:
104 | ts = str(r.get('ts'))
105 | source = str(r.get('source') or '').lower()
106 | w = float(weights_cfg.get(source, 1.0))
107 | bucket = by_ts.setdefault(ts, {"pm25": [], "pm10": []})
108 | try:
109 | pm25 = r.get('pm25')
110 | if pm25 is not None:
111 | bucket['pm25'].append((float(pm25), w))
112 | except Exception:
113 | pass
114 | try:
115 | pm10 = r.get('pm10')
116 | if pm10 is not None:
117 | bucket['pm10'].append((float(pm10), w))
118 | except Exception:
119 | pass
120 |
121 | out: List[Dict[str, Any]] = []
122 | for ts, measures in by_ts.items():
123 | vals25 = _maybe_trim(measures.get('pm25', []))
124 | vals10 = _maybe_trim(measures.get('pm10', []))
125 | mean25 = _weighted_mean(vals25)
126 | mean10 = _weighted_mean(vals10)
127 | if mean25 is None and mean10 is None:
128 | continue
129 | out.append({
130 | 'ts': ts,
131 | 'city': city,
132 | 'latitude': lat,
133 | 'longitude': lon,
134 | 'pm25': mean25,
135 | 'pm10': mean10,
136 | 'source': 'aggregated',
137 | })
138 |
139 | return out
140 |
141 |
142 |
143 |
--------------------------------------------------------------------------------
/backend/app/routers/auth.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Depends, HTTPException, Response, Request
2 | from sqlalchemy.orm import Session
3 | from pydantic import BaseModel, EmailStr
4 | from datetime import datetime, timedelta
5 | from ..db import get_db
6 | from ..models import User, RefreshToken
7 | from ..core.security import hash_password, verify_password, create_access_token, get_auth_user
8 | from ..core.config import settings
9 |
10 | router = APIRouter()
11 |
12 | @router.get("/test")
13 | def test_auth():
14 | """Simple test endpoint without database dependency"""
15 | return {"status": "ok", "message": "Auth router working"}
16 |
17 | class SignupRequest(BaseModel):
18 | email: EmailStr
19 | password: str
20 |
21 | class LoginRequest(BaseModel):
22 | email: EmailStr
23 | password: str
24 |
25 | class UserResponse(BaseModel):
26 | id: int
27 | email: str
28 | plan: str
29 |
30 | @router.post("/signup", response_model=UserResponse, status_code=201)
31 | def signup(request: SignupRequest, response: Response, db: Session = Depends(get_db)):
32 | try:
33 | # Check if user exists
34 | existing_user = db.query(User).filter(User.email == request.email).first()
35 | if existing_user:
36 | raise HTTPException(status_code=400, detail="Email already registered")
37 |
38 | # Create new user
39 | hashed_pw = hash_password(request.password)
40 | user = User(
41 | email=request.email,
42 | password_hash=hashed_pw,
43 | plan="free"
44 | )
45 | db.add(user)
46 | db.commit()
47 | db.refresh(user)
48 |
49 | # Auto-login the user after signup
50 | token = create_access_token(
51 | {"sub": str(user.id), "email": user.email, "plan": user.plan},
52 | settings.JWT_EXPIRES_MIN
53 | )
54 |
55 | # Set HTTP-only cookie
56 | response.set_cookie(
57 | key="airsense_access",
58 | value=token,
59 | httponly=True,
60 | secure=False, # Set to True in production with HTTPS
61 | samesite="lax",
62 | domain=settings.COOKIE_DOMAIN,
63 | path="/",
64 | max_age=settings.JWT_EXPIRES_MIN * 60
65 | )
66 |
67 | return UserResponse(id=user.id, email=user.email, plan=user.plan)
68 | except HTTPException:
69 | raise
70 | except Exception as e:
71 | db.rollback()
72 | raise HTTPException(status_code=500, detail="Internal server error")
73 |
74 | @router.post("/login", response_model=UserResponse)
75 | def login(request: LoginRequest, response: Response, db: Session = Depends(get_db)):
76 | try:
77 | # Find user
78 | user = db.query(User).filter(User.email == request.email).first()
79 | if not user or not verify_password(request.password, user.password_hash):
80 | raise HTTPException(status_code=401, detail="Invalid credentials")
81 |
82 | # Create JWT token
83 | token = create_access_token(
84 | {"sub": str(user.id), "email": user.email, "plan": user.plan},
85 | settings.JWT_EXPIRES_MIN
86 | )
87 |
88 | # Set HTTP-only cookie
89 | response.set_cookie(
90 | key="airsense_access",
91 | value=token,
92 | httponly=True,
93 | secure=False, # Set to True in production with HTTPS
94 | samesite="lax",
95 | domain=settings.COOKIE_DOMAIN,
96 | path="/",
97 | max_age=settings.JWT_EXPIRES_MIN * 60
98 | )
99 |
100 | # Update last login
101 | user.last_login = datetime.utcnow()
102 | db.commit()
103 |
104 | return UserResponse(id=user.id, email=user.email, plan=user.plan)
105 | except Exception as e:
106 | db.rollback()
107 | raise HTTPException(status_code=500, detail="Internal server error")
108 |
109 | @router.post("/logout")
110 | def logout(request: Request, response: Response, db: Session = Depends(get_db)):
111 | # Get user from token to clean up refresh tokens
112 | user_data = get_auth_user(request)
113 | if user_data:
114 | # Delete all refresh tokens for this user
115 | db.query(RefreshToken).filter(RefreshToken.user_id == user_data["id"]).delete()
116 | db.commit()
117 |
118 | response.delete_cookie("airsense_access", domain=settings.COOKIE_DOMAIN, path="/")
119 | return {"message": "Logged out successfully"}
120 |
121 | @router.get("/me", response_model=UserResponse)
122 | def get_current_user(request: Request, db: Session = Depends(get_db)):
123 | try:
124 | user_data = get_auth_user(request)
125 | if not user_data:
126 | raise HTTPException(status_code=401, detail="Not authenticated")
127 |
128 | # Get fresh user data from DB
129 | user = db.query(User).filter(User.id == user_data["id"]).first()
130 | if not user:
131 | raise HTTPException(status_code=401, detail="User not found")
132 |
133 | return UserResponse(id=user.id, email=user.email, plan=user.plan)
134 | except HTTPException:
135 | raise
136 | except Exception as e:
137 | raise HTTPException(status_code=500, detail="Internal server error")
138 |
139 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🌍 AirSense – Multi-Agentic Air Quality Trends Analysis System
2 |
3 | AirSense is a **full-stack air quality monitoring and analytics platform** designed to transform fragmented environmental data into actionable insights.
4 | The system aggregates multi-source PM2.5 and PM10 data, performs comparative analytics, delivers AI-powered forecasts, and enables natural-language analytics through an LLM-based planning agent.
5 |
6 | This project was developed as a **group project at SLIIT** for the *Information Retrieval and Web Analytics (IT3041)* module.
7 |
8 | 
9 |
10 | ---
11 |
12 | ## 🚀 Key Features
13 |
14 |
15 | 
16 |
17 | ### 🌐 Multi-Source Data Aggregation
18 | - Scrapes hourly air quality data from **Open-Meteo, OpenAQ, IQAir, and WAQI**
19 | - Applies **weighted aggregation with outlier trimming** to ensure reliable data
20 | - Persists clean, aggregated time-series data in MySQL
21 |
22 | ### 📊 Advanced Analytics
23 | - Multi-city comparison with KPIs (mean, min, max PM levels)
24 | - Best vs worst city ranking
25 | - Part-to-whole and trend-based analysis
26 |
27 | ### 📈 AI-Powered Forecasting
28 | - Time-series forecasting using **SARIMAX**
29 | - Confidence intervals and backtesting (MAE, RMSE)
30 | - Single-city and multi-city prediction support
31 |
32 | ### 🤖 LLM-Based Planning Agent (Enterprise Tier)
33 | - Natural-language queries converted into executable analysis plans
34 | - Uses a **critic-based reflection pattern** to ensure security and capability limits
35 | - Transparent execution traces for explainability
36 |
37 | ### 🔐 Security & Tiered Access
38 | - JWT-based authentication with bcrypt password hashing
39 | - Subscription tiers: **Free, Pro, Enterprise**
40 | - Plan-based enforcement of data windows, city limits, and forecast horizons
41 |
42 | ### 🧾 Professional Reporting
43 | - Auto-generated **PDF reports** with charts and KPI tables
44 | - Server-side rendering using ReportLab
45 |
46 | ---
47 |
48 | ## 🧱 System Architecture
49 |
50 | AirSense follows a **four-layer architecture**:
51 |
52 | 1. **Presentation Layer** – React SPA with interactive charts
53 | 2. **Application Layer** – FastAPI backend with modular routers
54 | 3. **Data Layer** – MySQL + SQLAlchemy ORM
55 | 4. **Intelligent Agent Layer** – LLM planner with MCP-style tool orchestration
56 |
57 | This architecture enables scalability, security, and clear separation of concerns :contentReference[oaicite:1]{index=1}.
58 |
59 | ---
60 |
61 | ## 🛠️ Tech Stack
62 |
63 | - **Frontend:** React, Tailwind CSS, Recharts
64 | - **Backend:** FastAPI (Python), Uvicorn
65 | - **Database:** MySQL, SQLAlchemy
66 | - **AI / Analytics:** SARIMAX, LLM (Ollama / Gemma), Agent Planning
67 | - **Security:** JWT, bcrypt
68 | - **Reporting:** ReportLab (PDF generation)
69 |
70 | ---
71 |
72 | ## 🧠 Responsible AI Practices
73 |
74 | - **Fairness:** Multi-source aggregation to reduce sensor bias
75 | - **Explainability:** Interpretable SARIMAX models + execution traces
76 | - **Transparency:** Visible data sources, KPIs, and agent steps
77 | - **Privacy:** No personal location tracking; secure credential handling
78 |
79 |
80 | 
81 |
82 | 
83 |
84 | 
85 |
86 | 
87 |
88 | 
89 |
90 | 
91 |
92 | 
93 |
94 | ---
95 |
96 | ## 👥 Team & Leadership
97 |
98 | **Team Leader & Full-Stack Integration Architect:**
99 | Hirusha D G A D (IT23183018)
100 |
101 | Key contributions include:
102 | - AI forecasting engine & backtesting
103 | - LLM agent design and orchestration
104 | - Authentication & tier enforcement
105 | - System-wide integration and documentation leadership
106 |
107 | (Full contribution breakdown available in the final report) :contentReference[oaicite:2]{index=2}.
108 |
109 | ---
110 |
111 | ## 🎯 Academic Context
112 |
113 | - **Institution:** Sri Lanka Institute of Information Technology (SLIIT)
114 | - **Module:** IT3041 – Information Retrieval and Web Analytics
115 | - **Year:** 2025
116 | - **Project Type:** Group Project (Industry-oriented system)
117 |
118 | ---
119 |
120 | ## 📌 Future Enhancements
121 |
122 | - Real-time alerts for pollution thresholds
123 | - Additional data sources & ML models
124 | - Extended agent reasoning capabilities
125 | - Cloud deployment and CI/CD pipelines
126 |
127 | ---
128 |
129 | ## 📜 License
130 |
131 | This project is released for **academic and learning purposes**.
132 |
--------------------------------------------------------------------------------
/backend/app/services/scraper.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta
2 | import os
3 | import requests
4 | from sqlalchemy import text
5 | from sqlalchemy.orm import Session
6 |
7 | def fetch_open_meteo(lat: float, lon: float, start_date: str, end_date: str):
8 | url = (
9 | "https://air-quality-api.open-meteo.com/v1/air-quality"
10 | f"?latitude={lat}&longitude={lon}"
11 | "&hourly=pm2_5,pm10"
12 | f"&start_date={start_date}&end_date={end_date}"
13 | "&timezone=auto"
14 | )
15 | try:
16 | r = requests.get(url, timeout=30)
17 | r.raise_for_status()
18 | return r.json()
19 | except requests.Timeout:
20 | raise RuntimeError("OpenMeteoTimeout: upstream timed out")
21 | except requests.RequestException as e:
22 | raise RuntimeError(f"OpenMeteoHTTP: {e}")
23 |
24 |
25 | def flatten_rows(city: str, lat: float, lon: float, data: dict):
26 | times = data["hourly"]["time"]
27 | pm25 = data["hourly"].get("pm2_5")
28 | pm10 = data["hourly"].get("pm10")
29 | rows = []
30 | for i, ts in enumerate(times):
31 | rows.append({
32 | "ts": ts.replace("T", " ")+":00", # MySQL DATETIME
33 | "city": city,
34 | "latitude": lat,
35 | "longitude": lon,
36 | "pm25": None if pm25 is None else pm25[i],
37 | "pm10": None if pm10 is None else pm10[i],
38 | "source": "open-meteo",
39 | })
40 | return rows
41 |
42 | def upsert_rows(db: Session, rows: list[dict]) -> int:
43 | if not rows:
44 | return 0
45 | # Try native upsert; if the DB lacks the unique key, fallback to code-level upsert
46 | try:
47 | sql = text("""
48 | INSERT INTO measurements (ts, city, latitude, longitude, pm25, pm10, source)
49 | VALUES (:ts, :city, :latitude, :longitude, :pm25, :pm10, :source)
50 | ON DUPLICATE KEY UPDATE
51 | pm25=VALUES(pm25),
52 | pm10=VALUES(pm10),
53 | latitude=VALUES(latitude),
54 | longitude=VALUES(longitude);
55 | """)
56 | db.execute(sql, rows)
57 | db.commit()
58 | return len(rows)
59 | except Exception:
60 | pass
61 |
62 | # Fallback: INSERT IGNORE, then UPDATE existing by (ts, city, source)
63 | try:
64 | ins_sql = text("""
65 | INSERT IGNORE INTO measurements (ts, city, latitude, longitude, pm25, pm10, source)
66 | VALUES (:ts, :city, :latitude, :longitude, :pm25, :pm10, :source)
67 | """)
68 | db.execute(ins_sql, rows)
69 | upd_sql = text("""
70 | UPDATE measurements
71 | SET latitude=:latitude, longitude=:longitude, pm25=:pm25, pm10=:pm10
72 | WHERE ts=:ts AND city=:city AND source=:source
73 | """)
74 | for r in rows:
75 | db.execute(upd_sql, r)
76 | db.commit()
77 | return len(rows)
78 | except Exception:
79 | db.rollback()
80 | return 0
81 |
82 | def _collect_and_upsert(db: Session, city: str, days: int, sources: list[str] | None):
83 | from .geocode import get_coords_for_city
84 | from .fetchers.openaq import fetch_openaq
85 | from .fetchers.iqair import fetch_iqair
86 | from .fetchers.waqi import fetch_waqi
87 | from .fetchers.normalize import make_row, parse_ts
88 | from .aggregate import combine_by_timestamp
89 | lat, lon = get_coords_for_city(db, city)
90 |
91 | # use datetime today instead of just date
92 | end = datetime.utcnow().date() # or datetime.now().date()
93 | start = end - timedelta(days=days)
94 |
95 | data = fetch_open_meteo(lat, lon, start.isoformat(), end.isoformat())
96 | rows_open_meteo = flatten_rows(city, lat, lon, data)
97 |
98 | # Toggle additional sources via SOURCES_ENABLED (comma-separated)
99 | enabled_env = os.getenv('SOURCES_ENABLED', '').lower()
100 | enabled_set = set([s.strip() for s in enabled_env.split(',') if s.strip()])
101 | if sources:
102 | enabled_set = set([s.strip().lower() for s in sources if s and isinstance(s, str)])
103 |
104 | src_rows = {
105 | 'open-meteo': rows_open_meteo
106 | }
107 |
108 | # Fetch from OpenAQ
109 | if not enabled_set or 'openaq' in enabled_set:
110 | try:
111 | src_rows['openaq'] = fetch_openaq(city, start, end, lat, lon)
112 | except Exception:
113 | src_rows['openaq'] = []
114 |
115 | # Fetch from IQAir (HTML)
116 | if not enabled_set or 'iqair' in enabled_set:
117 | try:
118 | src_rows['iqair'] = fetch_iqair(city, start, end, lat, lon)
119 | except Exception:
120 | src_rows['iqair'] = []
121 |
122 | # Fetch from WAQI (API if token present, else HTML)
123 | if not enabled_set or 'waqi' in enabled_set:
124 | try:
125 | token = os.getenv('WAQI_TOKEN')
126 | src_rows['waqi'] = fetch_waqi(city, start, end, lat, lon, token)
127 | except Exception:
128 | src_rows['waqi'] = []
129 |
130 | # Aggregate combined signal
131 | agg_rows = combine_by_timestamp(city, lat, lon, src_rows.get('openaq', []), src_rows.get('iqair', []), src_rows.get('waqi', []), src_rows.get('open-meteo', []))
132 |
133 | # Only save aggregated data, not individual source data
134 | counts: dict[str, int] = {}
135 | # Count individual sources for reporting but don't save them
136 | for k, v in src_rows.items():
137 | counts[k] = len(v) if v else 0
138 |
139 | # Only save the aggregated data to database
140 | counts['aggregated'] = upsert_rows(db, agg_rows) if agg_rows else 0
141 |
142 | return counts, (lat, lon)
143 |
144 |
145 | def ensure_window_for_city(db: Session, city: str, days: int, sources: list[str] | None = None):
146 | counts, coords = _collect_and_upsert(db, city, days, sources)
147 | total = sum(counts.values())
148 | return total, coords
149 |
150 |
151 | def ensure_window_for_city_with_counts(db: Session, city: str, days: int, sources: list[str] | None = None):
152 | return _collect_and_upsert(db, city, days, sources)
153 |
--------------------------------------------------------------------------------
/backend/app/services/forecast.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import os
3 | from datetime import timedelta
4 | import numpy as np
5 | import pandas as pd
6 | from sqlalchemy import text
7 | from sqlalchemy.orm import Session
8 | from statsmodels.tsa.statespace.sarimax import SARIMAX
9 | from sklearn.metrics import mean_absolute_error, mean_squared_error
10 | from joblib import dump, load
11 |
12 | MODELS_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "models")
13 | os.makedirs(MODELS_DIR, exist_ok=True)
14 |
15 | def _load_series(db: Session, city: str, days: int) -> pd.DataFrame:
16 | """Pull last N days from MySQL as a pandas hourly series (pm2.5 as target)."""
17 |
18 | rows = db.execute(text("""
19 | SELECT ts, pm25
20 | FROM measurements
21 | WHERE city = :city
22 | AND source = 'aggregated'
23 | AND ts >= DATE_SUB(NOW(), INTERVAL :days DAY)
24 | ORDER BY ts
25 | """), {"city": city, "days": days}).mappings().all()
26 |
27 | if not rows:
28 | raise ValueError(f"No data found for {city} in last {days} days. Run /scrape first.")
29 |
30 | df = pd.DataFrame(rows)
31 | df["ts"] = pd.to_datetime(df["ts"])
32 | df = df.set_index("ts").sort_index()
33 | # Ensure hourly frequency and fill small gaps
34 | df = df.asfreq("H")
35 | # simple imputation for small gaps
36 | df["pm25"] = df["pm25"].interpolate(limit_direction="both")
37 |
38 | return df # columns: pm25 (float), index: hourly ts
39 |
40 | def _model_path(city: str) -> str:
41 | safe = city.lower().replace(" ", "_")
42 | return os.path.join(MODELS_DIR, f"{safe}_sarimax.joblib")
43 |
44 | def train_sarimax(df: pd.DataFrame) -> SARIMAX:
45 | """
46 | Build a sensible default SARIMAX for hourly PM2.5.
47 | - Differencing (d=1) for trend
48 | - Seasonal weekly pattern for hourly data: 24*7=168
49 | -> seasonal order (P,D,Q,168)
50 | - Keep it modest to train fast.
51 | """
52 | # Basic sanity: drop any remaining NaNs
53 | y = df["pm25"].astype(float).fillna(method="ffill").fillna(method="bfill")
54 |
55 | # Try a simple configuration; tweak later if needed
56 | order = (1, 1, 1)
57 | seasonal_order = (1, 0, 1, 24) # daily seasonality is often present; weekly = 168 if you have lots of data
58 | # If you have >= 14 days, consider (1,0,1,24) or (1,0,1,168); (24) is lighter.
59 | model = SARIMAX(y, order=order, seasonal_order=seasonal_order, enforce_stationarity=False, enforce_invertibility=False)
60 | return model
61 |
62 | def fit_and_save_model(db: Session, city: str, train_days: int = 30) -> str:
63 | df = _load_series(db, city, days=train_days)
64 | model = train_sarimax(df)
65 | result = model.fit(disp=False)
66 | path = _model_path(city)
67 | dump(result, path)
68 | return path
69 |
70 | def forecast_city(db: Session, city: str, horizon_days: int = 7, train_days: int = 30, use_cache: bool = True):
71 | """Fit (or load) a SARIMAX model and forecast H days ahead with CIs."""
72 | path = _model_path(city)
73 | result = None
74 |
75 | if use_cache and os.path.exists(path):
76 | try:
77 | result = load(path)
78 | except Exception:
79 | result = None
80 |
81 | if result is None:
82 | # (Re)train
83 | df = _load_series(db, city, days=train_days)
84 | model = train_sarimax(df)
85 | result = model.fit(disp=False)
86 | dump(result, path)
87 |
88 | steps = int(horizon_days * 24)
89 | pred = result.get_forecast(steps=steps)
90 | mean = pred.predicted_mean
91 | ci = pred.conf_int(alpha=0.2) # 80% CI looks good for charts; change if you like (95% => alpha=0.05)
92 |
93 | out = []
94 | for ts, yhat in mean.items():
95 | low = float(ci.loc[ts].iloc[0])
96 | high = float(ci.loc[ts].iloc[1])
97 | out.append({
98 | "ts": ts.strftime("%Y-%m-%d %H:%M:%S"),
99 | "yhat": float(yhat),
100 | "yhat_lower": low,
101 | "yhat_upper": high
102 | })
103 | return {"city": city, "horizon_hours": steps, "series": out}
104 |
105 | def backtest_roll(db: Session, city: str, days: int = 30, horizon_hours: int = 24):
106 | """
107 | Simple rolling-origin backtest: walk forward, forecast H hours, compute MAE/RMSE.
108 | Useful for a quick slide proving validity.
109 | """
110 | df = _load_series(db, city, days=days)
111 | y = df["pm25"].astype(float)
112 | # choose checkpoints every 24 hours to keep it fast
113 | checkpoints = list(range(24*7, len(y) - horizon_hours, 24))
114 | preds, trues = [], []
115 |
116 | for cut in checkpoints:
117 | train_y = y.iloc[:cut]
118 | model = SARIMAX(train_y, order=(1,1,1), seasonal_order=(1,0,1,24),
119 | enforce_stationarity=False, enforce_invertibility=False)
120 | res = model.fit(disp=False)
121 | fc = res.get_forecast(steps=horizon_hours).predicted_mean
122 | true = y.iloc[cut:cut+horizon_hours]
123 | # align lengths (edge cases)
124 | n = min(len(fc), len(true))
125 | preds.extend(fc.iloc[:n].values)
126 | trues.extend(true.iloc[:n].values)
127 |
128 | mae = float(mean_absolute_error(trues, preds))
129 | rmse = float(np.sqrt(mean_squared_error(trues, preds)))
130 | return {"city": city, "days": days, "horizon_hours": horizon_hours, "mae": mae, "rmse": rmse}
131 |
132 |
133 | # multi-cities forecaster
134 | def forecast_cities(
135 | db: Session,
136 | cities: list[str],
137 | horizon_days: int = 7,
138 | train_days: int = 30,
139 | use_cache: bool = True,
140 | ):
141 | """
142 | Runs forecast_city for each city and returns a dict { city -> series }.
143 | Also returns a small summary (mean predicted pm25 per city) to pick best/worst.
144 | """
145 | results = {}
146 | summary = {}
147 |
148 | for city in cities:
149 | try:
150 | fc = forecast_city(db, city, horizon_days, train_days, use_cache)
151 | results[city] = fc["series"]
152 | # mean of yhat over the horizon for ranking
153 | vals = [p["yhat"] for p in fc["series"] if p.get("yhat") is not None]
154 | summary[city] = {
155 | "mean_yhat": (sum(vals) / len(vals)) if vals else None,
156 | "n_points": len(vals)
157 | }
158 | except Exception as e:
159 | results[city] = {"error": str(e)}
160 | summary[city] = {"mean_yhat": None, "n_points": 0}
161 |
162 | # pick best/worst by mean_yhat (lower is “cleaner”)
163 | valid = {c: s for c, s in summary.items() if s["mean_yhat"] is not None}
164 | best = min(valid, key=lambda c: valid[c]["mean_yhat"]) if valid else None
165 | worst = max(valid, key=lambda c: valid[c]["mean_yhat"]) if valid else None
166 |
167 | return {
168 | "byCity": results,
169 | "summary": summary,
170 | "best": best,
171 | "worst": worst
172 | }
173 |
--------------------------------------------------------------------------------
/backend/app/routers/agent.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Depends, HTTPException, Request
2 | from sqlalchemy.orm import Session
3 | from ..db import get_db
4 | from ..schemas import AgentPlanIn, AgentPlanOut, ToolStep, AgentExecIn, AgentExecOut
5 | from ..core.security import get_plan, Plan
6 | from ..core.tiers import enforce_scrape, enforce_compare, enforce_forecast
7 | from ..services.scraper import ensure_window_for_city
8 | from ..services.forecast import forecast_city, forecast_cities
9 | from ..services.llama_client import plan_with_llama
10 | from ..utils.compare import compare_logic
11 |
12 | router = APIRouter()
13 |
14 | TOOLS = [
15 | {
16 | "name": "scrape_city",
17 | "description": "Fetch & cache hourly PM2.5/PM10 for a city over the last N days using Open-Meteo; upserts into MySQL.",
18 | "input_schema": {"type":"object","properties":{"city":{"type":"string"},"days":{"type":"integer","minimum":1,"maximum":90,"default":7}},"required":["city"]},
19 | "output_schema": {"type":"object"}
20 | },
21 | {
22 | "name": "compare_cities",
23 | "description": "Compute KPIs over the last N days per city (n_points, mean_pm25, min_pm25, max_pm25) and pick best/worst (lower is better).",
24 | "input_schema": {"type":"object","properties":{"cities":{"type":"array","items":{"type":"string"}},"days":{"type":"integer","minimum":1,"maximum":90,"default":7}},"required":["cities"]},
25 | "output_schema": {"type":"object"}
26 | },
27 | {
28 | "name": "forecast_city",
29 | "description": "Forecast next H days of PM2.5 for one city with SARIMAX; returns yhat + CI.",
30 | "input_schema": {"type":"object","properties":{"city":{"type":"string"},"horizonDays":{"type":"integer","minimum":1,"maximum":30,"default":7},"trainDays":{"type":"integer","minimum":7,"maximum":120,"default":30},"use_cache":{"type":"boolean","default":True}},"required":["city"]},
31 | "output_schema": {"type":"object"}
32 | },
33 | {
34 | "name": "forecast_multi",
35 | "description": "Forecast next H days for multiple cities and rank best/worst by mean predicted PM2.5.",
36 | "input_schema": {"type":"object","properties":{"cities":{"type":"array","items":{"type":"string"}},"horizonDays":{"type":"integer","minimum":1,"maximum":30,"default":7},"trainDays":{"type":"integer","minimum":7,"maximum":120,"default":30},"use_cache":{"type":"boolean","default":True}},"required":["cities"]},
37 | "output_schema": {"type":"object"}
38 | },
39 | ]
40 |
41 | @router.get("/mcp/tools/list")
42 | def mcp_list_tools():
43 | return {"tools": TOOLS}
44 |
45 | @router.post("/mcp/tools/call")
46 | def mcp_call_tool(call: dict, request: Request, plan: Plan = Depends(get_plan), db: Session = Depends(get_db)):
47 | name = call.get("name")
48 | args = call.get("arguments", {})
49 |
50 | if name == "scrape_city":
51 | enforce_scrape(plan, args.get("days", 7))
52 | inserted, (lat, lon) = ensure_window_for_city(db, args["city"], args.get("days", 7))
53 | return {"ok": True, "result": {"city": args["city"], "days": args.get("days", 7), "inserted": inserted, "lat": lat, "lon": lon}}
54 |
55 | if name == "compare_cities":
56 | cities = args["cities"]; days = args.get("days", 7)
57 | enforce_compare(plan, cities, days)
58 | for c in cities: ensure_window_for_city(db, c, days)
59 | return {"ok": True, "result": compare_logic(db, cities, days)}
60 |
61 | if name == "forecast_city":
62 | enforce_forecast(plan, args.get("horizonDays", 7), 1)
63 | out = forecast_city(db, args["city"], args.get("horizonDays", 7), args.get("trainDays", 30), args.get("use_cache", True))
64 | return {"ok": True, "result": out}
65 |
66 | if name == "forecast_multi":
67 | cities = args["cities"]
68 | enforce_forecast(plan, args.get("horizonDays", 7), len(cities))
69 | out = forecast_cities(db, cities, args.get("horizonDays", 7), args.get("trainDays", 30), args.get("use_cache", True))
70 | return {"ok": True, "result": out}
71 |
72 | raise HTTPException(404, f"Unknown tool: {name}")
73 |
74 | @router.post("/plan", response_model=AgentPlanOut)
75 | def agent_plan(payload: AgentPlanIn):
76 | plan_obj = plan_with_llama(payload.prompt, TOOLS, temperature=0.2)
77 | steps = [ToolStep(**step) for step in plan_obj.get("plan", [])]
78 | return {
79 | "plan": steps,
80 | "notes": plan_obj.get("notes"),
81 | "irrelevant": plan_obj.get("irrelevant", False)
82 | }
83 |
84 | def _execute_step(db: Session, plan: Plan, step: ToolStep):
85 | name, args = step.name, (step.arguments or {})
86 |
87 | if name == "scrape_city":
88 | enforce_scrape(plan, args.get("days", 7))
89 | inserted, (lat, lon) = ensure_window_for_city(db, args["city"], args.get("days", 7))
90 | return {"tool": name, "ok": True, "args": args, "result": {"city": args["city"], "days": args.get("days", 7), "inserted": inserted, "lat": lat, "lon": lon}}
91 |
92 | if name == "compare_cities":
93 | cities = args["cities"]; days = args.get("days", 7)
94 | enforce_compare(plan, cities, days)
95 | for c in cities: ensure_window_for_city(db, c, days)
96 | res = compare_logic(db, cities, days)
97 | return {"tool": name, "ok": True, "args": args, "result": res}
98 |
99 | if name == "forecast_city":
100 | enforce_forecast(plan, args.get("horizonDays", 7), 1)
101 | res = forecast_city(db, args["city"], args.get("horizonDays", 7), args.get("trainDays", 30), args.get("use_cache", True))
102 | return {"tool": name, "ok": True, "args": args, "result": res}
103 |
104 | if name == "forecast_multi":
105 | cities = args["cities"]
106 | enforce_forecast(plan, args.get("horizonDays", 7), len(cities))
107 | res = forecast_cities(db, cities, args.get("horizonDays", 7), args.get("trainDays", 30), args.get("use_cache", True))
108 | return {"tool": name, "ok": True, "args": args, "result": res}
109 |
110 | return {"tool": name, "ok": False, "args": args, "error": "Unknown tool"}
111 |
112 | @router.post("/execute", response_model=AgentExecOut)
113 | def agent_execute(payload: AgentExecIn, request: Request, plan: Plan = Depends(get_plan), db: Session = Depends(get_db)):
114 | trace = []
115 | steps: list[ToolStep] = []
116 | last_ok = None
117 |
118 | if payload.plan:
119 | steps = [ToolStep(**s) if not isinstance(s, ToolStep) else s for s in payload.plan]
120 | elif payload.prompt:
121 | plan_obj = plan_with_llama(payload.prompt, TOOLS, temperature=0.2)
122 | steps = [ToolStep(**step) for step in plan_obj.get("plan", [])]
123 | trace.append({"planner": {"notes": plan_obj.get("notes"), "steps": [s.dict() for s in steps]}})
124 | else:
125 | raise HTTPException(400, "Provide either prompt or plan")
126 |
127 | for step in steps:
128 | try:
129 | result = _execute_step(db, plan, step)
130 | except Exception as e:
131 | result = {"tool": step.name, "ok": False, "args": step.arguments, "error": str(e)}
132 | trace.append(result)
133 | if result.get("ok"):
134 | last_ok = result
135 | else:
136 | break
137 |
138 | successes = [t for t in trace if t.get("ok")]
139 | answer = f"Executed {len(successes)} step(s)."
140 | return {"answer": answer, "trace": trace, "final": (last_ok.get("result") if last_ok else None)}
141 |
--------------------------------------------------------------------------------
/backend/app/services/reporter.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import os
3 | import io
4 | import logging
5 | from datetime import datetime
6 | from typing import Dict, Any, List, Optional
7 |
8 | from PIL import Image as PILImage # pillow for dimension reading
9 | from reportlab.lib import colors
10 | from reportlab.lib.pagesizes import A4
11 | from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
12 | from reportlab.lib.units import mm
13 | from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image, HRFlowable, PageBreak
14 |
15 | from ..schemas import ReportIn
16 |
17 |
18 | logger = logging.getLogger("airq")
19 |
20 |
21 | def _decode_base64_image(data_b64: str) -> Optional[bytes]:
22 | try:
23 | if not data_b64:
24 | return None
25 | if "," in data_b64 and data_b64.strip().lower().startswith("data:image"):
26 | data_b64 = data_b64.split(",", 1)[1]
27 | return base64.b64decode(data_b64)
28 | except Exception as e:
29 | logger.warning("Failed to decode base64 image: %s", e)
30 | return None
31 |
32 |
33 | def _scaled_image(img_bytes: bytes, max_width_pts: float) -> Optional[Image]:
34 | try:
35 | with PILImage.open(io.BytesIO(img_bytes)) as pil:
36 | w, h = pil.size
37 | if w == 0 or h == 0:
38 | return None
39 | scale = max_width_pts / float(w)
40 | new_w = max_width_pts
41 | new_h = float(h) * scale
42 | return Image(io.BytesIO(img_bytes), width=new_w, height=new_h)
43 | except Exception as e:
44 | logger.warning("Failed to scale/embed image: %s", e)
45 | return None
46 |
47 |
48 | ACCENT = os.getenv("AIRSENSE_ACCENT_HEX", "#22C55E")
49 |
50 |
51 | def _metrics_table(metrics: Dict[str, Any]) -> Optional[Table]:
52 | if not metrics:
53 | return None
54 | rows = [["Metric", "Value"]]
55 | for k, v in metrics.items():
56 | rows.append([str(k).replace("_", " ").title(), str(v)])
57 | t = Table(rows, hAlign='LEFT')
58 | t.setStyle(TableStyle([
59 | ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#111827")),
60 | ("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor(ACCENT)),
61 | ("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
62 | ("BACKGROUND", (0, 1), (-1, -1), colors.whitesmoke),
63 | ("ALIGN", (0, 0), (-1, -1), "LEFT"),
64 | ]))
65 | return t
66 |
67 |
68 | def _stats_table(stats: Dict[str, Any], report_type: Optional[str] = None) -> Optional[Table]:
69 | if not stats:
70 | return None
71 | # Expect shape: { city: { mean_* or mean_yhat, min_*, max_*, n_points } }
72 | header = ["City", "Mean (µg/m³)", "Range (µg/m³)", "Samples"]
73 | rows = [header]
74 | for city, vals in stats.items():
75 | mean_val = vals.get("mean_pm25")
76 | if mean_val is None:
77 | mean_val = vals.get("mean_yhat")
78 | min_v = vals.get("min_pm25")
79 | max_v = vals.get("max_pm25")
80 | n = vals.get("n_points")
81 | rng = "-"
82 | if min_v is not None or max_v is not None:
83 | min_str = f"{min_v:.2f}" if isinstance(min_v, (int, float)) else "-"
84 | max_str = f"{max_v:.2f}" if isinstance(max_v, (int, float)) else "-"
85 | rng = f"{min_str} – {max_str}"
86 | rows.append([
87 | city,
88 | f"{mean_val:.2f}" if isinstance(mean_val, (int, float)) else "-",
89 | rng,
90 | str(n) if n is not None else "-",
91 | ])
92 | t = Table(rows, hAlign='LEFT')
93 | t.setStyle(TableStyle([
94 | ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#111827")),
95 | ("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor(ACCENT)),
96 | ("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
97 | ("BACKGROUND", (0, 1), (-1, -1), colors.whitesmoke),
98 | ("ALIGN", (0, 0), (-1, -1), "LEFT"),
99 | ]))
100 | return t
101 |
102 |
103 | def make_report(payload: ReportIn) -> bytes:
104 | buffer = io.BytesIO()
105 | doc = SimpleDocTemplate(buffer, pagesize=A4, leftMargin=18 * mm, rightMargin=18 * mm, topMargin=16 * mm, bottomMargin=16 * mm)
106 | styles = getSampleStyleSheet()
107 | # Dark theme paragraph styles to better match UI
108 | title_style = ParagraphStyle(
109 | name="TitleDark",
110 | parent=styles["Title"],
111 | fontSize=22,
112 | textColor=colors.HexColor(ACCENT),
113 | spaceAfter=8,
114 | )
115 | h2_style = ParagraphStyle(
116 | name="Heading2Dark",
117 | parent=styles["Heading2"],
118 | textColor=colors.HexColor(ACCENT),
119 | spaceAfter=6,
120 | )
121 | h3_style = ParagraphStyle(
122 | name="Heading3Dark",
123 | parent=styles["Heading3"],
124 | textColor=colors.HexColor(ACCENT),
125 | spaceAfter=4,
126 | )
127 | normal_style = ParagraphStyle(
128 | name="NormalDark",
129 | parent=styles["Normal"],
130 | textColor=colors.HexColor("#D1D5DB"),
131 | )
132 |
133 | # Spacing constants
134 | SP_SMALL = 6
135 | SP_MED = 12
136 | SP_LARGE = 18
137 |
138 | def separator():
139 | return HRFlowable(width="100%", thickness=1, lineCap='round', color=colors.HexColor("#374151"), spaceBefore=SP_SMALL, spaceAfter=SP_SMALL)
140 | story: List[Any] = [] # type: ignore
141 |
142 | # Header
143 | story.append(Paragraph("AirSense ", title_style))
144 | story.append(Spacer(1, SP_SMALL))
145 |
146 | # Subtitle
147 | subtitle = "Forecast Report" if payload.report_type == "forecast" else "City Comparison Report"
148 | story.append(Paragraph(f"{subtitle} ", h2_style))
149 | story.append(separator())
150 |
151 | # Timestamp and cities
152 | ts_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
153 | story.append(Paragraph(f"Generated: {ts_str}", normal_style))
154 | story.append(Paragraph(f"Cities: {', '.join(payload.cities)}", normal_style))
155 | story.append(Spacer(1, SP_MED))
156 |
157 | # Metrics block
158 | if payload.metrics:
159 | story.append(Paragraph("Metrics ", h3_style))
160 | mt = _metrics_table(payload.metrics)
161 | if mt:
162 | story.append(mt)
163 | story.append(Spacer(1, SP_MED))
164 | story.append(separator())
165 |
166 | # Stats block
167 | if payload.stats:
168 | story.append(Paragraph("Key Stats ", h3_style))
169 | # Forecast range fallback calculation if missing
170 | if payload.report_type == "forecast":
171 | try:
172 | # Try to derive min/max from any available series in metrics or stats hints
173 | # If not provided, leave as-is; frontend will now send ranges where possible
174 | for city, vals in list(payload.stats.items()): # type: ignore
175 | rng_text = vals.get("range") if isinstance(vals, dict) else None
176 | if rng_text:
177 | continue
178 | # If we had arrays, we'd compute here; keep placeholder to avoid breaking
179 | # e.g., mean series not available in backend currently
180 | # Leave None to display '-' in table
181 | except Exception:
182 | pass
183 | st = _stats_table(payload.stats, payload.report_type)
184 | if st:
185 | story.append(st)
186 | story.append(Spacer(1, SP_MED))
187 | story.append(separator())
188 |
189 | # Charts block
190 | if payload.charts:
191 | story.append(Paragraph("Charts ", h3_style))
192 | story.append(Spacer(1, SP_SMALL))
193 |
194 | page_width, _ = A4
195 | max_w = page_width - (doc.leftMargin + doc.rightMargin)
196 |
197 | charts_on_page = 0
198 |
199 | def add_img_if_present(key: str):
200 | img_b64 = payload.charts.get(key)
201 | if not img_b64:
202 | return
203 | img_bytes = _decode_base64_image(img_b64)
204 | if not img_bytes:
205 | return
206 | img = _scaled_image(img_bytes, max_w)
207 | if img:
208 | story.append(img)
209 | story.append(Spacer(1, SP_SMALL))
210 | nonlocal charts_on_page
211 | charts_on_page += 1
212 | if charts_on_page >= 2:
213 | story.append(PageBreak())
214 | charts_on_page = 0
215 |
216 | # Combined first if requested
217 | show_combined = bool((payload.options or {}).get("showCombined", True))
218 | if show_combined and "combined" in payload.charts:
219 | add_img_if_present("combined")
220 |
221 | # Then each city chart in provided order
222 | for c in payload.cities:
223 | if c in payload.charts:
224 | add_img_if_present(c)
225 |
226 | story.append(Spacer(1, SP_LARGE))
227 | story.append(separator())
228 | story.append(Paragraph("Generated by AirSense ", normal_style))
229 |
230 | doc.build(story)
231 | buffer.seek(0)
232 | return buffer.read()
233 |
234 |
235 |
236 |
--------------------------------------------------------------------------------
/frontend/src/utils/chartCapture.js:
--------------------------------------------------------------------------------
1 | // Utility to convert an SVG element to a Base64 PNG using an offscreen canvas
2 | function svgElementToPngBase64(svgElement, { backgroundColor = 'transparent', pixelRatio = 2 } = {}) {
3 | if (!svgElement) return undefined;
4 |
5 | try {
6 | const xml = new XMLSerializer().serializeToString(svgElement);
7 | const svg64 = window.btoa(unescape(encodeURIComponent(xml)));
8 | const imageSrc = `data:image/svg+xml;base64,${svg64}`;
9 |
10 | // Create an offscreen canvas to draw the SVG as PNG
11 | const bbox = svgElement.getBBox?.();
12 | // Try multiple methods to get dimensions
13 | let width = bbox?.width || svgElement.clientWidth || svgElement.getAttribute('width') || svgElement.getBoundingClientRect().width || 800;
14 | let height = bbox?.height || svgElement.clientHeight || svgElement.getAttribute('height') || svgElement.getBoundingClientRect().height || 400;
15 |
16 | // Ensure we have valid dimensions
17 | width = Math.max(1, Math.ceil(Number(width) || 800));
18 | height = Math.max(1, Math.ceil(Number(height) || 400));
19 |
20 | console.log(`📐 SVG dimensions: ${width}x${height}`);
21 |
22 | const canvas = document.createElement('canvas');
23 | canvas.width = width * pixelRatio;
24 | canvas.height = height * pixelRatio;
25 | const ctx = canvas.getContext('2d');
26 | if (!ctx) return undefined;
27 |
28 | if (backgroundColor && backgroundColor !== 'transparent') {
29 | ctx.fillStyle = backgroundColor;
30 | ctx.fillRect(0, 0, canvas.width, canvas.height);
31 | }
32 |
33 | return new Promise((resolve) => {
34 | const img = new Image();
35 | img.crossOrigin = 'anonymous';
36 | img.onload = () => {
37 | ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
38 | ctx.drawImage(img, 0, 0, width, height);
39 | try {
40 | const dataUrl = canvas.toDataURL('image/png');
41 | resolve(dataUrl.replace(/^data:image\/png;base64,/, ''));
42 | } catch {
43 | resolve(undefined);
44 | }
45 | };
46 | img.onerror = () => resolve(undefined);
47 | img.src = imageSrc;
48 | });
49 | } catch {
50 | return undefined;
51 | }
52 | }
53 |
54 | // Try to capture a chart instance/ref to a Base64 PNG depending on library
55 | // - Chart.js: ref.current?.toBase64Image()
56 | // - ECharts: instance.getDataURL({ type: 'png' })
57 | // - Recharts (SVG): query SVG inside container/ref and rasterize
58 | export async function captureChartAsBase64(chartRefOrInstance) {
59 | if (!chartRefOrInstance) return undefined;
60 |
61 | try {
62 | const refLike = chartRefOrInstance.current ?? chartRefOrInstance;
63 |
64 | // Chart.js instance or chart object exposing toBase64Image
65 | if (refLike && typeof refLike.toBase64Image === 'function') {
66 | try {
67 | const dataUrl = refLike.toBase64Image();
68 | if (typeof dataUrl === 'string' && dataUrl.startsWith('data:image/')) {
69 | return dataUrl.replace(/^data:image\/png;base64,/, '');
70 | }
71 | } catch {}
72 | }
73 |
74 | // ECharts instance
75 | if (refLike && typeof refLike.getDataURL === 'function') {
76 | try {
77 | const dataUrl = refLike.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: 'transparent' });
78 | if (typeof dataUrl === 'string' && dataUrl.startsWith('data:image/')) {
79 | return dataUrl.replace(/^data:image\/png;base64,/, '');
80 | }
81 | } catch {}
82 | }
83 |
84 | // Recharts: find descendant SVG and rasterize
85 | let container = null;
86 | if (refLike instanceof Element) container = refLike;
87 | else if (refLike && refLike.container instanceof Element) container = refLike.container;
88 |
89 | if (!container && typeof document !== 'undefined') {
90 | if (typeof chartRefOrInstance === 'string') {
91 | console.log(`🔍 Looking for element with selector: "${chartRefOrInstance}"`);
92 | container = document.querySelector(chartRefOrInstance);
93 |
94 | // Simplified fallback for combined chart
95 | if (!container && chartRefOrInstance.includes('combined')) {
96 | container = document.querySelector('#comparison-combined-chart') || document.querySelector('#forecast-combined-chart');
97 | }
98 |
99 | // Simplified fallback for individual charts
100 | if (!container && chartRefOrInstance.includes('data-city')) {
101 | const cityMatch = chartRefOrInstance.match(/data-city="([^"]+)"/);
102 | if (cityMatch) {
103 | container = document.querySelector(`[data-city="${cityMatch[1]}"]`);
104 | }
105 | }
106 |
107 | // Additional fallback for forecast charts
108 | if (!container && chartRefOrInstance.includes('forecast')) {
109 | if (chartRefOrInstance.includes('combined')) {
110 | container = document.querySelector('#forecast-combined-chart');
111 | } else {
112 | // Try to find any forecast chart container
113 | container = document.querySelector('#forecast-individual-charts');
114 | }
115 | }
116 |
117 | console.log(`📦 Found container with selector:`, !!container);
118 | }
119 | }
120 |
121 | if (container) {
122 | console.log(`📊 Container found, looking for SVG element...`);
123 | const svg = container.querySelector('svg');
124 | console.log(`🎨 SVG element found:`, !!svg);
125 | if (svg) {
126 | // Ensure SVG has proper dimensions and viewBox
127 | if (!svg.getAttribute('viewBox') && (svg.clientWidth > 0 || svg.clientHeight > 0)) {
128 | const width = svg.clientWidth || svg.getAttribute('width') || 800;
129 | const height = svg.clientHeight || svg.getAttribute('height') || 400;
130 | svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
131 | console.log(`📐 Set viewBox to: 0 0 ${width} ${height}`);
132 | }
133 |
134 | console.log(`🔄 Converting SVG to PNG...`);
135 | const result = await svgElementToPngBase64(svg);
136 | console.log(`✅ SVG conversion result:`, !!result);
137 | return result;
138 | } else {
139 | console.log(`❌ No SVG element found in container`);
140 | }
141 | } else {
142 | console.log(`❌ No container found for selector:`, chartRefOrInstance);
143 | }
144 |
145 | // Fallback: if a raw SVG element was passed
146 | if (refLike instanceof SVGElement) {
147 | return await svgElementToPngBase64(refLike);
148 | }
149 | } catch {}
150 |
151 | return undefined;
152 | }
153 |
154 | // Wait for charts to be fully rendered
155 | async function waitForChartsToRender(maxWaitTime = 5000) {
156 | const startTime = Date.now();
157 |
158 | while (Date.now() - startTime < maxWaitTime) {
159 | // Check if we have any SVG elements in the comparison charts
160 | const individualCharts = document.querySelectorAll('#comparison-individual-charts svg');
161 | const combinedChart = document.querySelector('#comparison-combined-chart svg');
162 |
163 | // Check if charts have reasonable dimensions
164 | const hasValidCharts = Array.from(individualCharts).some(svg =>
165 | svg.clientWidth > 0 && svg.clientHeight > 0
166 | ) || (combinedChart && combinedChart.clientWidth > 0 && combinedChart.clientHeight > 0);
167 |
168 | if (hasValidCharts) {
169 | console.log('✅ Charts are ready for capture');
170 | return true;
171 | }
172 |
173 | // Wait a bit before checking again
174 | await new Promise(resolve => setTimeout(resolve, 100));
175 | }
176 |
177 | console.log('⚠️ Charts may not be fully rendered, proceeding anyway');
178 | return false;
179 | }
180 |
181 | // Collect charts for report depending on mode and refs
182 | // mode: 'comparison' | 'forecast'
183 | // cityRefs: map cityName -> ref/selector/container for individual charts
184 | // combinedRef: ref/selector/container for combined chart
185 | // showConfidence: ensures capture reflects current CI toggle (we assume caller renders correctly before capture)
186 | export async function collectChartsForReport({ mode, cityRefs, combinedRef, showConfidence }) {
187 | const out = {};
188 | console.log('🔍 collectChartsForReport called with:', { mode, cityRefs, combinedRef, showConfidence });
189 |
190 | // Wait for charts to be fully rendered before attempting capture
191 | await waitForChartsToRender();
192 |
193 | // Ensure current visible chart already reflects CI toggle; caller is responsible
194 | // We only capture what is on-screen now.
195 |
196 | // Combined
197 | if (combinedRef) {
198 | console.log('📊 Attempting to capture combined chart with ref:', combinedRef);
199 | const combined = await captureChartAsBase64(combinedRef);
200 | if (combined) {
201 | out.combined = combined;
202 | console.log('✅ Combined chart captured successfully');
203 | } else {
204 | console.log('❌ Failed to capture combined chart');
205 | }
206 | }
207 |
208 | // Individuals
209 | if (cityRefs && typeof cityRefs === 'object') {
210 | console.log('🏙️ Attempting to capture individual charts for cities:', Object.keys(cityRefs));
211 | const entries = Object.entries(cityRefs);
212 | const results = await Promise.all(entries.map(async ([cityName, ref]) => {
213 | console.log(`📈 Capturing chart for ${cityName} with selector:`, ref);
214 | const img = await captureChartAsBase64(ref);
215 | if (img) {
216 | console.log(`✅ Successfully captured chart for ${cityName}`);
217 | } else {
218 | console.log(`❌ Failed to capture chart for ${cityName}`);
219 | }
220 | return [cityName, img];
221 | }));
222 |
223 | results.forEach(([cityName, img]) => {
224 | if (img) out[cityName] = img;
225 | });
226 | }
227 |
228 | console.log('🎯 Final captured charts:', Object.keys(out));
229 | return out;
230 | }
231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 |
--------------------------------------------------------------------------------
/backend/app/services/forecast_prophet.py:
--------------------------------------------------------------------------------
1 | # forecast_prophet.py (service)
2 | from __future__ import annotations
3 | import os
4 | from datetime import datetime, timedelta
5 | import numpy as np
6 | import pandas as pd
7 | from sqlalchemy import text
8 | from sqlalchemy.orm import Session
9 | from prophet import Prophet
10 | from sklearn.metrics import mean_absolute_error, mean_squared_error
11 | from joblib import dump, load
12 | import logging
13 |
14 | logger = logging.getLogger(__name__)
15 |
16 | MODELS_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "models")
17 | os.makedirs(MODELS_DIR, exist_ok=True)
18 |
19 | def _load_series(db: Session, city: str, days: int) -> pd.DataFrame:
20 | """Pull last N days from MySQL as a pandas hourly series (pm2.5 as target)."""
21 |
22 | rows = db.execute(text("""
23 | SELECT ts, pm25
24 | FROM measurements
25 | WHERE city = :city
26 | AND source = 'aggregated'
27 | AND ts >= DATE_SUB(NOW(), INTERVAL :days DAY)
28 | ORDER BY ts
29 | """), {"city": city, "days": days}).mappings().all()
30 |
31 | if not rows:
32 | raise ValueError(f"No data found for {city} in last {days} days. Run /scrape first.")
33 |
34 | df = pd.DataFrame(rows)
35 | df["ts"] = pd.to_datetime(df["ts"])
36 | df = df.set_index("ts").sort_index()
37 |
38 | # Ensure hourly frequency and fill small gaps
39 | df = df.asfreq("H")
40 | # Simple imputation for small gaps
41 | df["pm25"] = df["pm25"].interpolate(limit_direction="both")
42 |
43 | return df # columns: pm25 (float), index: hourly ts
44 |
45 | def _model_path(city: str) -> str:
46 | """Generate safe filesystem path for model storage."""
47 | safe = city.lower().replace(" ", "_")
48 | return os.path.join(MODELS_DIR, f"{safe}_prophet.joblib")
49 |
50 | def train_prophet(df: pd.DataFrame) -> Prophet:
51 | """
52 | Build a Prophet model for hourly PM2.5 forecasting.
53 | Prophet expects DataFrame with 'ds' (datetime) and 'y' (target) columns.
54 |
55 | Configuration:
56 | - Daily seasonality enabled (24-hour pattern)
57 | - Weekly seasonality enabled (7-day pattern)
58 | - Yearly seasonality disabled (not enough data typically)
59 | - Changepoint prior scale: controls flexibility (0.05 = moderate)
60 | - Seasonality prior scale: controls seasonality strength (10.0 = default)
61 | """
62 |
63 | # Prepare data in Prophet format
64 | prophet_df = pd.DataFrame({
65 | 'ds': df.index,
66 | 'y': df['pm25'].astype(float)
67 | })
68 |
69 | # Remove any NaN values
70 | prophet_df = prophet_df.dropna()
71 |
72 | # Initialize Prophet model with appropriate settings for hourly data
73 | model = Prophet(
74 | daily_seasonality=True, # Capture daily patterns
75 | weekly_seasonality=True, # Capture weekly patterns
76 | yearly_seasonality=False, # Disable yearly (usually not enough data)
77 | changepoint_prior_scale=0.05, # Moderate flexibility for trend changes
78 | seasonality_prior_scale=10.0, # Default seasonality strength
79 | seasonality_mode='additive', # Additive seasonality (can use 'multiplicative' if needed)
80 | interval_width=0.80, # 80% confidence intervals (matches SARIMAX version)
81 | )
82 |
83 | # Add hourly seasonality explicitly (Prophet doesn't add this by default)
84 | # Fourier order of 8 captures complex hourly patterns
85 | model.add_seasonality(
86 | name='hourly',
87 | period=24,
88 | fourier_order=8
89 | )
90 |
91 | return model
92 |
93 | def fit_and_save_model(db: Session, city: str, train_days: int = 30) -> str:
94 | """
95 | Fit Prophet model on training data and save to disk.
96 |
97 | Args:
98 | db: Database session
99 | city: City name
100 | train_days: Number of days to use for training (default: 30)
101 |
102 | Returns:
103 | Path to saved model file
104 | """
105 | df = _load_series(db, city, days=train_days)
106 |
107 | # Prepare Prophet DataFrame
108 | prophet_df = pd.DataFrame({
109 | 'ds': df.index,
110 | 'y': df['pm25'].astype(float)
111 | }).dropna()
112 |
113 | # Initialize and fit model
114 | model = train_prophet(df)
115 | model.fit(prophet_df)
116 |
117 | # Save fitted model
118 | path = _model_path(city)
119 | dump(model, path)
120 | logger.info(f"Prophet model saved for {city} at {path}")
121 |
122 | return path
123 |
124 | def forecast_city(
125 | db: Session,
126 | city: str,
127 | horizon_days: int = 7,
128 | train_days: int = 30,
129 | use_cache: bool = True
130 | ):
131 | """
132 | Fit (or load) a Prophet model and forecast H days ahead with confidence intervals.
133 |
134 | Args:
135 | db: Database session
136 | city: City name
137 | horizon_days: Number of days to forecast ahead
138 | train_days: Number of days to use for training
139 | use_cache: Whether to use cached model if available
140 |
141 | Returns:
142 | Dict with city, horizon_hours, and forecast series
143 | """
144 | path = _model_path(city)
145 | model = None
146 |
147 | # Try to load cached model
148 | if use_cache and os.path.exists(path):
149 | try:
150 | model = load(path)
151 | logger.info(f"Loaded cached Prophet model for {city}")
152 | except Exception as e:
153 | logger.warning(f"Failed to load cached model for {city}: {e}")
154 | model = None
155 |
156 | # Train new model if cache miss or disabled
157 | if model is None:
158 | df = _load_series(db, city, days=train_days)
159 |
160 | # Prepare Prophet DataFrame
161 | prophet_df = pd.DataFrame({
162 | 'ds': df.index,
163 | 'y': df['pm25'].astype(float)
164 | }).dropna()
165 |
166 | # Initialize and fit model
167 | model = train_prophet(df)
168 | model.fit(prophet_df)
169 |
170 | # Save for future use
171 | dump(model, path)
172 | logger.info(f"Trained and saved new Prophet model for {city}")
173 |
174 | # Generate future dataframe for forecasting
175 | steps = int(horizon_days * 24) # Convert days to hours
176 | future = model.make_future_dataframe(periods=steps, freq='H')
177 |
178 | # Make predictions
179 | forecast = model.predict(future)
180 |
181 | # Extract only future predictions (not historical)
182 | forecast_future = forecast.tail(steps)
183 |
184 | # Format output
185 | out = []
186 | for _, row in forecast_future.iterrows():
187 | out.append({
188 | "ts": row['ds'].strftime("%Y-%m-%d %H:%M:%S"),
189 | "yhat": float(row['yhat']),
190 | "yhat_lower": float(row['yhat_lower']),
191 | "yhat_upper": float(row['yhat_upper'])
192 | })
193 |
194 | return {
195 | "city": city,
196 | "horizon_hours": steps,
197 | "series": out
198 | }
199 |
200 | def backtest_roll(
201 | db: Session,
202 | city: str,
203 | days: int = 30,
204 | horizon_hours: int = 24
205 | ):
206 | """
207 | Simple rolling-origin backtest: walk forward, forecast H hours, compute MAE/RMSE.
208 |
209 | Args:
210 | db: Database session
211 | city: City name
212 | days: Total days of data to use for backtesting
213 | horizon_hours: Forecast horizon in hours
214 |
215 | Returns:
216 | Dict with city, days, horizon_hours, mae, and rmse
217 | """
218 | df = _load_series(db, city, days=days)
219 | y = df["pm25"].astype(float).dropna()
220 |
221 | # Choose checkpoints every 24 hours to keep it fast
222 | min_train = 24 * 7 # Minimum 7 days training
223 | checkpoints = list(range(min_train, len(y) - horizon_hours, 24))
224 |
225 | if not checkpoints:
226 | raise ValueError(f"Not enough data for backtesting. Need at least {min_train + horizon_hours} hours")
227 |
228 | preds, trues = [], []
229 |
230 | for i, cut in enumerate(checkpoints):
231 | try:
232 | # Prepare training data
233 | train_y = y.iloc[:cut]
234 | train_df = pd.DataFrame({
235 | 'ds': train_y.index,
236 | 'y': train_y.values
237 | })
238 |
239 | # Train model
240 | model = Prophet(
241 | daily_seasonality=True,
242 | weekly_seasonality=True,
243 | yearly_seasonality=False,
244 | changepoint_prior_scale=0.05,
245 | seasonality_prior_scale=10.0,
246 | seasonality_mode='additive',
247 | interval_width=0.80,
248 | )
249 | model.add_seasonality(name='hourly', period=24, fourier_order=8)
250 |
251 | # Suppress Prophet's verbose logging
252 | with suppress_stdout_stderr():
253 | model.fit(train_df)
254 |
255 | # Forecast
256 | future = model.make_future_dataframe(periods=horizon_hours, freq='H')
257 | forecast = model.predict(future)
258 | fc = forecast.tail(horizon_hours)['yhat']
259 |
260 | # Get true values
261 | true = y.iloc[cut:cut + horizon_hours]
262 |
263 | # Align lengths (edge cases)
264 | n = min(len(fc), len(true))
265 | preds.extend(fc.iloc[:n].values)
266 | trues.extend(true.iloc[:n].values)
267 |
268 | logger.info(f"Backtest checkpoint {i+1}/{len(checkpoints)} completed for {city}")
269 |
270 | except Exception as e:
271 | logger.warning(f"Backtest checkpoint {i+1} failed for {city}: {e}")
272 | continue
273 |
274 | if not preds or not trues:
275 | raise ValueError(f"Backtest failed: no valid predictions generated for {city}")
276 |
277 | # Compute metrics
278 | mae = float(mean_absolute_error(trues, preds))
279 | rmse = float(np.sqrt(mean_squared_error(trues, preds)))
280 |
281 | return {
282 | "city": city,
283 | "days": days,
284 | "horizon_hours": horizon_hours,
285 | "mae": mae,
286 | "rmse": rmse,
287 | "n_checkpoints": len(checkpoints),
288 | "n_predictions": len(preds)
289 | }
290 |
291 | def forecast_cities(
292 | db: Session,
293 | cities: list[str],
294 | horizon_days: int = 7,
295 | train_days: int = 30,
296 | use_cache: bool = True,
297 | ):
298 | """
299 | Runs forecast_city for each city and returns a dict { city -> series }.
300 | Also returns a small summary (mean predicted pm25 per city) to pick best/worst.
301 |
302 | Args:
303 | db: Database session
304 | cities: List of city names
305 | horizon_days: Number of days to forecast ahead
306 | train_days: Number of days to use for training
307 | use_cache: Whether to use cached models
308 |
309 | Returns:
310 | Dict with byCity forecasts, summary stats, best city, and worst city
311 | """
312 | results = {}
313 | summary = {}
314 |
315 | for city in cities:
316 | try:
317 | fc = forecast_city(db, city, horizon_days, train_days, use_cache)
318 | results[city] = fc["series"]
319 |
320 | # Mean of yhat over the horizon for ranking
321 | vals = [p["yhat"] for p in fc["series"] if p.get("yhat") is not None]
322 | summary[city] = {
323 | "mean_yhat": (sum(vals) / len(vals)) if vals else None,
324 | "n_points": len(vals)
325 | }
326 | logger.info(f"Forecast completed for {city}: mean_yhat={summary[city]['mean_yhat']:.2f}")
327 |
328 | except Exception as e:
329 | logger.error(f"Forecast failed for {city}: {e}")
330 | results[city] = {"error": str(e)}
331 | summary[city] = {"mean_yhat": None, "n_points": 0}
332 |
333 | # Pick best/worst by mean_yhat (lower is "cleaner")
334 | valid = {c: s for c, s in summary.items() if s["mean_yhat"] is not None}
335 | best = min(valid, key=lambda c: valid[c]["mean_yhat"]) if valid else None
336 | worst = max(valid, key=lambda c: valid[c]["mean_yhat"]) if valid else None
337 |
338 | return {
339 | "byCity": results,
340 | "summary": summary,
341 | "best": best,
342 | "worst": worst
343 | }
344 |
345 | # Utility context manager to suppress Prophet's verbose output
346 | class suppress_stdout_stderr:
347 | """
348 | Context manager to suppress stdout and stderr.
349 | Useful for suppressing Prophet's verbose logging during backtesting.
350 | """
351 | def __enter__(self):
352 | import sys
353 | self.old_stdout = sys.stdout
354 | self.old_stderr = sys.stderr
355 | sys.stdout = open(os.devnull, 'w')
356 | sys.stderr = open(os.devnull, 'w')
357 |
358 | def __exit__(self, exc_type, exc_val, exc_tb):
359 | import sys
360 | sys.stdout.close()
361 | sys.stderr.close()
362 | sys.stdout = self.old_stdout
363 | sys.stderr = self.old_stderr
--------------------------------------------------------------------------------
/frontend/src/Home.jsx:
--------------------------------------------------------------------------------
1 | import { motion } from "framer-motion";
2 | import { useNavigate } from "react-router-dom";
3 | import AuroraBackground from "./components/common/AuroraBackground";
4 | import AuroraButton from "./components/common/AuroraButton";
5 | import Header from "./components/Header";
6 | import CloudIcon from "./components/common/icons/CloudIcon";
7 | import BarChartIcon from "./components/common/icons/BarChartIcon";
8 | import CpuIcon from "./components/common/icons/CpuIcon";
9 | import MessageIcon from "./components/common/icons/MessageIcon";
10 | import LiquidEther from "./components/LiquidEther.jsx";
11 |
12 | export default function Home() {
13 | const navigate = useNavigate();
14 |
15 | const handleNavigateToWorkspace = () => {
16 | navigate('/workspace');
17 | };
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
32 | function HeroSection({ onNavigateToWorkspace }) {
33 | return (
34 |
35 | {/* LiquidEther Background */}
36 |
37 |
55 |
56 |
57 |
58 |
64 |
65 | Intelligent Air Quality
66 |
67 |
68 | Analytics Platform
69 |
70 |
76 | Advanced AI-powered platform for real-time air quality monitoring,
77 | predictive forecasting, and comprehensive environmental analytics.
78 | Make data-driven decisions for healthier cities.
79 |
80 |
86 |
87 | Start Analyzing
88 |
89 |
90 |
91 |
92 |
93 | );
94 | }
95 |
96 | function ServicesSection() {
97 | const services = [
98 | { icon: CloudIcon, title: "Data Collection", description: "Real-time air quality data scraping from multiple sources with advanced validation", color: "from-cyan-500 to-blue-500" },
99 | { icon: BarChartIcon, title: "City Comparison", description: "Compare PM2.5 levels across multiple cities with interactive visualizations", color: "from-purple-500 to-pink-500" },
100 | { icon: CpuIcon, title: "AI Forecasting", description: "Predict future air quality trends using machine learning models", color: "from-green-500 to-cyan-500" },
101 | { icon: MessageIcon, title: "AI Assistant", description: "Natural language queries for instant air quality insights and analysis", color: "from-orange-500 to-red-500" },
102 | { icon: CloudIcon, title: "Report Generation", description: "Automated PDF reports with charts, insights, and recommendations", color: "from-blue-500 to-purple-500" },
103 | ];
104 |
105 | return (
106 |
107 |
108 |
114 | Our Services
115 |
116 |
122 | Comprehensive suite of tools for air quality analysis and environmental monitoring
123 |
124 |
125 |
126 | {services.map((service, index) => (
127 |
135 |
136 |
137 |
138 | {service.title}
139 | {service.description}
140 |
141 | ))}
142 |
148 |
149 |
150 |
151 | Advanced Analytics
152 |
153 | Leverage cutting-edge AI and machine learning for deep environmental insights
154 |
155 |
156 | {["Real-time", "Predictive", "Multi-city", "Historical"].map((tag) => (
157 |
158 | {tag}
159 |
160 | ))}
161 |
162 |
163 |
164 |
165 | );
166 | }
167 |
168 | function PricingSection({ onNavigateToWorkspace }) {
169 | const plans = [
170 | { name: "Free", price: "$0", period: "Per month", popular: false, features: { cities: "1 city per request", lookback: "7 days scrape lookback", forecasting: "Not available", confidence: "✗ Confidence intervals", reports: "✗ PDF report generator", agent: "No agentic planner", api: "Rate-limited API access", support: "Community support" }, cta: "Get Started", variant: "secondary" },
171 | { name: "Pro", price: "$19.99", period: "Per month", popular: true, features: { cities: "Up to 3 cities", lookback: "30 days scrape lookback", forecasting: "Yes 💹 up to 7-day horizon", confidence: "Included Confidence intervals", reports: "Basic PDF reports", agent: "No agentic planner", api: "Standard API access", support: "Priority support" }, cta: "Start Pro", variant: "primary" },
172 | { name: "Enterprise", price: "$499.99", period: "Billed annually", popular: false, features: { cities: "Unlimited cities", lookback: "90 days scrape lookback", forecasting: "Yes 💹 up to 30-day horizon", confidence: "Included Confidence intervals", reports: "Branded, multi-chart reports", agent: "Full agentic planner", api: "Priority + SLA API access", support: "Priority support" }, cta: "Contact Sales", variant: "success" },
173 | ];
174 |
175 | return (
176 |
177 |
178 |
184 | Pricing & Plans
185 |
186 |
192 | Choose the plan that fits your air-quality analytics needs. Upgrade anytime.
193 |
194 |
195 |
196 | {plans.map((plan, index) => (
197 |
207 | {plan.popular && (
208 |
209 |
210 | Most Popular
211 |
212 |
213 | )}
214 |
215 |
{plan.name}
216 |
217 | {plan.price}
218 | {plan.period}
219 |
220 |
221 |
222 | {Object.entries(plan.features).map(([key, value]) => (
223 |
224 | {key.replace(/([A-Z])/g, ' $1').toLowerCase()}:
225 | {value}
226 |
227 | ))}
228 |
229 |
230 | {plan.cta}
231 |
232 |
233 | ))}
234 |
235 |
236 | );
237 | }
238 |
239 | function FAQSection() {
240 | const faqs = [
241 | { question: "How accurate is the air quality data?", answer: "Our data is sourced from multiple reliable environmental monitoring stations and validated through advanced algorithms for maximum accuracy." },
242 | { question: "Can I integrate with my existing systems?", answer: "Yes, we provide comprehensive API access for seamless integration with your existing environmental monitoring systems." },
243 | { question: "What cities are currently supported?", answer: "We support major cities worldwide with continuous expansion. Contact us for specific city availability." },
244 | { question: "How often is the data updated?", answer: "Data is updated in real-time with most sources providing updates every 15-30 minutes." },
245 | ];
246 | return (
247 |
248 |
249 |
255 | Frequently Asked Questions
256 |
257 |
258 |
259 | {faqs.map((faq, index) => (
260 |
267 | {faq.question}
268 | {faq.answer}
269 |
270 | ))}
271 |
272 |
273 | );
274 | }
275 |
276 | function Footer() {
277 | return (
278 |
279 |
280 |
281 |
282 |
283 |
284 |
285 |
286 | AirSense
287 |
288 |
289 |
290 | {["Privacy", "Terms", "Contact", "Docs"].map((item) => (
291 |
292 | {item}
293 |
294 | ))}
295 |
296 |
297 |
298 |
© 2024 AirQuality AI. All rights reserved. Building cleaner, healthier cities through data intelligence.
299 |
300 |
301 |
302 | );
303 | }
304 |
305 |
306 |
307 |
--------------------------------------------------------------------------------
/backend/app/services/llama_client.py:
--------------------------------------------------------------------------------
1 | import os, json, requests
2 |
3 | OLLAMA_BASE = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434")
4 | OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma3:4b")
5 |
6 | CRITIC_PROMPT = """You are a strict capability critic for an MCP agent.
7 | The agent ONLY has these tools: scrape_city, compare_cities, forecast_city, forecast_multi.
8 | They work ONLY on air-quality data (PM2.5/PM10) over cities and time ranges.
9 |
10 | Your job:
11 | 1) Decide if the user's request is fully supported by these tools.
12 | 2) If the request is MIXED (supported + unsupported actions like writing blogs, emailing, designing, exporting docs), split it:
13 | - Extract the supported subtask (rewrite it cleanly).
14 | - List the unsupported parts with reasons.
15 | 3) If the request is gibberish or entirely unrelated (e.g., cooking, poems, finance), mark as IRRELEVANT.
16 |
17 | Return STRICT JSON:
18 | {
19 | "category": "supported" | "mixed" | "irrelevant",
20 | "unsupported_reasons": [ "" ],
21 | "supported_rewrite": "if category is mixed, rewrite only the supported part; else empty string",
22 | "examples": [
23 | "Compare Colombo and Kandy last 7 days",
24 | "Forecast Panadura next 3 days (train 7 days)"
25 | ]
26 | }
27 | Only JSON. No extra text.
28 | """
29 |
30 | SYSTEM_PROMPT = """You are a planning agent. Turn the user's request into a JSON plan of tool calls.
31 | Only use these tools and their JSON schemas. Return STRICT JSON with this shape:
32 |
33 | {
34 | "plan": [
35 | {"name": "", "arguments": { ... }},
36 | ...
37 | ],
38 | "notes": "very brief explanation",
39 | "irrelevant": false
40 | }
41 |
42 | Strictly follow these Rules:
43 | - Use only the listed tools; arguments must match the schemas.
44 | - If the user asks to compare, use compare_cities with at least 2 cities.
45 | - If forecasting multiple cities, use forecast_multi.
46 | - If data may be stale, insert a scrape_city step BEFORE compare/forecast.
47 | - REJECT requests like "Generate me an image comparing Colombo and Kandy for past 7 days", "Generate me an blogpost article comparing Colombo and Kandy for past 7 days", "Generate me an newspaper article forcasting Colombo and Kandy 7 days ahead"
48 | - REJECT asks requiring non-available abilities (blog writing, emails, PDFs, images, SQL DDL, etc.).
49 | - If the user's request is completely unrelated to air quality analysis (e.g., asking about weather, cooking, random topics), set "irrelevant": true and "plan": [].
50 | - For mixed requests: plan only the tool-capable part, set irrelevant=false, and include notes with unsupported_reasons.
51 | - Keep notes short. Do not include any text outside of the JSON object.
52 | """
53 |
54 | def build_tool_catalog(tools: list[dict]) -> str:
55 | return json.dumps(tools, indent=2, ensure_ascii=False)
56 |
57 | def critique_prompt(prompt: str, tools: list[dict], temperature: float = 0.0, timeout: int = 45) -> dict:
58 | """Critique a prompt to determine if it's supported, mixed, or irrelevant."""
59 | payload = {
60 | "model": OLLAMA_MODEL,
61 | "messages": [
62 | {"role": "system", "content": CRITIC_PROMPT},
63 | {"role": "user", "content": prompt}
64 | ],
65 | "stream": False,
66 | "format": "json",
67 | "options": {"temperature": temperature}
68 | }
69 | r = requests.post(f"{OLLAMA_BASE}/api/chat", json=payload, timeout=timeout)
70 | r.raise_for_status()
71 | raw = r.json()["message"]["content"]
72 | try:
73 | return json.loads(raw)
74 | except Exception:
75 | import re
76 | m = re.search(r"\{.*\}", raw, re.S)
77 | return json.loads(m.group(0)) if m else {"category":"irrelevant","unsupported_reasons":["Non-JSON critic output"],"supported_rewrite":""}
78 |
79 | def plan_with_llama(prompt: str, tools: list[dict], temperature: float = 0.2, timeout: int = 60) -> dict:
80 | """Ask Ollama (local) to produce a JSON plan."""
81 | sys = SYSTEM_PROMPT + "\n\nTOOLS (JSON Schemas):\n" + build_tool_catalog(tools)
82 | payload = {
83 | "model": OLLAMA_MODEL,
84 | "messages": [
85 | {"role": "system", "content": sys},
86 | {"role": "user", "content": prompt}
87 | ],
88 | "stream": False,
89 | "format": "json",
90 | "options": {"temperature": temperature}
91 | }
92 | r = requests.post(f"{OLLAMA_BASE}/api/chat", json=payload, timeout=timeout)
93 | r.raise_for_status()
94 | data = r.json()
95 | content = data["message"]["content"]
96 |
97 | try:
98 | return json.loads(content)
99 | except json.JSONDecodeError:
100 | import re
101 | m = re.search(r"\{.*\}", content, re.S)
102 | if not m:
103 | raise RuntimeError("LLM did not return JSON plan")
104 | return json.loads(m.group(0))
105 |
106 | def plan_with_critic(prompt: str, tools: list[dict], temperature: float = 0.2, timeout: int = 60) -> dict:
107 | """Two-stage planner: critique first, then plan if supported."""
108 | critic = critique_prompt(prompt, tools)
109 | cat = critic.get("category","irrelevant")
110 |
111 | if cat == "irrelevant":
112 | return {
113 | "plan": [],
114 | "notes": None,
115 | "irrelevant": True,
116 | "reason": "Your request cannot be done with the available tools.",
117 | "unsupported_reasons": critic.get("unsupported_reasons",[]),
118 | "critic": critic
119 | }
120 |
121 | # Use original prompt if supported, or rewritten prompt if mixed
122 | use_prompt = prompt if cat == "supported" else critic.get("supported_rewrite") or prompt
123 |
124 | # Fall back to existing LLM planner
125 | base = plan_with_llama(use_prompt, tools, temperature=temperature, timeout=timeout)
126 |
127 | # If mixed, carry reasons forward
128 | if cat == "mixed":
129 | base["unsupported_reasons"] = critic.get("unsupported_reasons",[])
130 | note = base.get("notes") or ""
131 | if base["unsupported_reasons"]:
132 | base["notes"] = (note + (" | " if note else "") +
133 | "Unsupported: " + "; ".join(base["unsupported_reasons"]))
134 |
135 | base["critic"] = critic
136 | return base
137 |
138 | def generate_llm_report(comparison_data, chart_data, cities, period_days, show_combined):
139 | """
140 | Generate LLM-powered comparison report using Gemma3:4b
141 | """
142 |
143 | SYSTEM_PROMPT = """You are an environmental health expert AI that generates structured air quality comparison reports. Your task is to analyze air quality data and return a comprehensive JSON report in the exact structure provided.
144 |
145 | CRITICAL REQUIREMENTS:
146 | 1. You MUST return ONLY valid JSON - no additional text, explanations, or markdown
147 | 2. You MUST use the exact JSON structure provided below
148 | 3. You MUST fill in all placeholders with actual data from the analysis
149 | 4. You MUST use scientific, factual language while maintaining accessibility
150 | 5. You MUST base all conclusions on the provided PM2.5 data and established health guidelines
151 |
152 | JSON STRUCTURE TO FOLLOW:
153 | {
154 | "report": {
155 | "title": "Air Quality Comparative Analysis & Health Impact Assessment",
156 | "executiveOverview": {
157 | "summary": "The analysis reveals significant disparities in air quality across the monitored cities. [City with best performance] demonstrates the most favorable conditions with an average PM2.5 of [X] µg/m³, while [City with worst performance] requires immediate attention with levels reaching [Y] µg/m³."
158 | },
159 | "cityPerformanceBreakdown": {
160 | "topPerformer": {
161 | "city": "[Best City]",
162 | "averagePM25": "[Value] µg/m³",
163 | "healthImplications": "Air quality generally falls within acceptable limits, posing minimal health risks to the general population. Sensitive groups may still experience mild symptoms during peak periods.",
164 | "icon": "🏆"
165 | },
166 | "areasNeedingImprovement": {
167 | "city": "[Worst City]",
168 | "averagePM25": "[Value] µg/m³",
169 | "healthRisks": "Prolonged exposure at these levels increases risks of respiratory and cardiovascular diseases. Immediate protective measures recommended.",
170 | "icon": "⚠️"
171 | }
172 | },
173 | "healthAwarenessInsights": {
174 | "shortTermExposureEffects": {
175 | "healthyAdults": "Minor irritation, temporary breathing discomfort",
176 | "sensitiveGroups": "Aggravated asthma, increased respiratory symptoms",
177 | "elderlyAndChildren": "Higher susceptibility to respiratory infections"
178 | },
179 | "longTermHealthImplications": {
180 | "risks": [
181 | "Chronic respiratory diseases",
182 | "Cardiovascular complications",
183 | "Reduced lung function development in children",
184 | "Increased cancer risk with prolonged exposure"
185 | ]
186 | }
187 | },
188 | "regionalPatternsAndTrends": {
189 | "description": "The data reveals [describe any patterns - seasonal variations, consistent poor performers, improving/declining trends]"
190 | },
191 | "protectiveRecommendations": {
192 | "immediateActions": {
193 | "highPM25Areas": "Limit outdoor activities, use N95 masks",
194 | "indoorAirQuality": "Employ HEPA filters, maintain proper ventilation",
195 | "vulnerableGroups": "Regular health monitoring, avoid peak pollution hours"
196 | },
197 | "longTermCommunityMeasures": [
198 | "Enhanced public transportation systems",
199 | "Green space development",
200 | "Industrial emission controls",
201 | "Public health awareness campaigns"
202 | ]
203 | },
204 | "comparativeRiskAssessment": {
205 | "description": "The analysis indicates that residents in [worst city] face approximately [X]% higher health risks compared to those in [best city], emphasizing the need for targeted interventions."
206 | }
207 | }
208 | }
209 |
210 | DATA INTERPRETATION GUIDELINES:
211 | - PM2.5 levels below 12 µg/m³: Good air quality
212 | - PM2.5 levels 12-35 µg/m³: Moderate air quality
213 | - PM2.5 levels 35-55 µg/m³: Unhealthy for sensitive groups
214 | - PM2.5 levels above 55 µg/m³: Unhealthy for all populations
215 | - Calculate risk percentage: ((worst_city_pm25 - best_city_pm25) / best_city_pm25 * 100)
216 |
217 | Remember: Return ONLY the JSON object, no other text."""
218 |
219 | # Prepare the data for the LLM
220 | data_context = {
221 | "comparison_data": comparison_data,
222 | "chart_data": chart_data,
223 | "cities": cities,
224 | "period_days": period_days,
225 | "show_combined": show_combined
226 | }
227 |
228 | # Create the user prompt with the data
229 | user_prompt = f"""Please analyze the following air quality comparison data and generate a comprehensive report:
230 |
231 | Cities analyzed: {', '.join(cities) if cities else 'N/A'}
232 | Analysis period: {period_days} days
233 | Best performing city: {comparison_data.get('best', 'N/A')}
234 | Worst performing city: {comparison_data.get('worst', 'N/A')}
235 |
236 | City statistics:
237 | {json.dumps(comparison_data.get('byCity', {}), indent=2)}
238 |
239 | Please generate the structured report following the exact JSON format provided in the system prompt."""
240 |
241 | payload = {
242 | "model": OLLAMA_MODEL,
243 | "messages": [
244 | {"role": "system", "content": SYSTEM_PROMPT},
245 | {"role": "user", "content": user_prompt}
246 | ],
247 | "stream": False,
248 | "format": "json",
249 | "options": {"temperature": 0.3}
250 | }
251 |
252 | try:
253 | r = requests.post(f"{OLLAMA_BASE}/api/chat", json=payload, timeout=120)
254 | r.raise_for_status()
255 | data = r.json()
256 | content = data["message"]["content"]
257 |
258 | # Parse the JSON response
259 | try:
260 | return json.loads(content)
261 | except json.JSONDecodeError:
262 | # Try to extract JSON from the response
263 | import re
264 | m = re.search(r"\{.*\}", content, re.S)
265 | if m:
266 | return json.loads(m.group(0))
267 | else:
268 | raise RuntimeError("LLM did not return valid JSON")
269 |
270 | except requests.RequestException as e:
271 | raise RuntimeError(f"Failed to communicate with LLM: {str(e)}")
272 | except Exception as e:
273 | raise RuntimeError(f"Failed to generate LLM report: {str(e)}")
274 |
275 | def generate_llm_forecast_report(forecast_data, chart_data, cities, horizon_days, train_days, show_ci, show_combined, selected_model):
276 | """
277 | Generate LLM-powered forecast report using Gemma3:4b
278 | """
279 |
280 | SYSTEM_PROMPT = """You are an environmental health expert AI that generates structured air quality forecast reports. Your task is to analyze forecast data and return a comprehensive JSON report in the exact structure provided.
281 |
282 | CRITICAL REQUIREMENTS:
283 | 1. You MUST return ONLY valid JSON - no additional text, explanations, or markdown
284 | 2. You MUST use the exact JSON structure provided below
285 | 3. You MUST fill in all placeholders with actual data from the analysis
286 | 4. You MUST use scientific, factual language while maintaining accessibility
287 | 5. You MUST base all conclusions on the provided forecast data and established health guidelines
288 |
289 | JSON STRUCTURE TO FOLLOW:
290 | {
291 | "report": {
292 | "title": "AI-Powered Air Quality Forecast Analysis & Health Impact Assessment",
293 | "executiveOverview": {
294 | "summary": "The forecast analysis reveals significant variations in predicted air quality across the monitored cities. [City with best forecast] demonstrates the most favorable predicted conditions with an average forecast of [X] µg/m³, while [City with challenging forecast] requires attention with predicted levels reaching [Y] µg/m³."
295 | },
296 | "forecastPerformance": {
297 | "bestForecast": {
298 | "city": "[Best City]",
299 | "averageForecast": "[Value] µg/m³",
300 | "forecastImplications": "Predicted air quality generally falls within acceptable limits, posing minimal health risks to the general population. Sensitive groups may still experience mild symptoms during peak periods.",
301 | "icon": "📈"
302 | },
303 | "challengingForecast": {
304 | "city": "[Challenging City]",
305 | "averageForecast": "[Value] µg/m³",
306 | "forecastRisks": "Predicted exposure at these levels increases risks of respiratory and cardiovascular diseases. Immediate protective measures recommended.",
307 | "icon": "⚠️"
308 | }
309 | },
310 | "modelPerformance": {
311 | "confidenceLevel": "High confidence in forecast accuracy based on historical data patterns",
312 | "predictionReliability": "Model shows strong predictive capability with [X]% accuracy on validation data",
313 | "modelType": "[SARIMAX/Prophet] - Optimized for time series forecasting with seasonal patterns"
314 | },
315 | "forecastTrends": {
316 | "patterns": [
317 | "Seasonal variation patterns detected",
318 | "Weekend vs weekday differences observed",
319 | "Peak pollution hours identified",
320 | "Long-term trend analysis completed"
321 | ]
322 | },
323 | "healthImpact": {
324 | "shortTermRisks": {
325 | "highRiskAreas": "Areas with predicted PM2.5 levels above 35 µg/m³",
326 | "vulnerableGroups": "Children, elderly, and those with respiratory conditions at higher risk"
327 | },
328 | "longTermImplications": [
329 | "Chronic respiratory disease development risk",
330 | "Cardiovascular health impact assessment",
331 | "Reduced lung function in children",
332 | "Increased cancer risk with prolonged exposure"
333 | ]
334 | },
335 | "recommendations": {
336 | "immediateActions": [
337 | "Monitor air quality alerts in high-risk areas",
338 | "Use N95 masks during predicted high pollution periods",
339 | "Limit outdoor activities during peak forecast hours",
340 | "Ensure proper indoor air filtration systems"
341 | ],
342 | "longTermPlanning": [
343 | "Develop air quality monitoring infrastructure",
344 | "Implement green space development plans",
345 | "Enhance public transportation systems",
346 | "Create public health awareness campaigns"
347 | ]
348 | },
349 | "uncertaintyAssessment": {
350 | "description": "The forecast model demonstrates [X]% confidence in predictions, with uncertainty increasing over longer time horizons. Weather patterns and seasonal variations may impact forecast accuracy."
351 | }
352 | }
353 | }
354 |
355 | DATA INTERPRETATION GUIDELINES:
356 | - PM2.5 levels below 12 µg/m³: Good air quality forecast
357 | - PM2.5 levels 12-35 µg/m³: Moderate air quality forecast
358 | - PM2.5 levels 35-55 µg/m³: Unhealthy for sensitive groups forecast
359 | - PM2.5 levels above 55 µg/m³: Unhealthy for all populations forecast
360 | - Confidence intervals indicate prediction uncertainty
361 | - Model type affects forecast reliability and accuracy
362 |
363 | Remember: Return ONLY the JSON object, no other text."""
364 |
365 | # Prepare the data for the LLM
366 | data_context = {
367 | "forecast_data": forecast_data,
368 | "chart_data": chart_data,
369 | "cities": cities,
370 | "horizon_days": horizon_days,
371 | "train_days": train_days,
372 | "show_ci": show_ci,
373 | "show_combined": show_combined,
374 | "selected_model": selected_model
375 | }
376 |
377 | # Create the user prompt with the data
378 | user_prompt = f"""Please analyze the following air quality forecast data and generate a comprehensive report:
379 |
380 | Cities forecasted: {', '.join(cities) if cities else 'N/A'}
381 | Forecast horizon: {horizon_days} days
382 | Training period: {train_days} days
383 | Model used: {selected_model}
384 | Best forecasted city: {forecast_data.get('best', 'N/A')}
385 | Challenging forecasted city: {forecast_data.get('worst', 'N/A')}
386 |
387 | Forecast statistics:
388 | {json.dumps(forecast_data.get('summary', {}), indent=2)}
389 |
390 | Please generate the structured report following the exact JSON format provided in the system prompt."""
391 |
392 | payload = {
393 | "model": OLLAMA_MODEL,
394 | "messages": [
395 | {"role": "system", "content": SYSTEM_PROMPT},
396 | {"role": "user", "content": user_prompt}
397 | ],
398 | "stream": False,
399 | "format": "json",
400 | "options": {"temperature": 0.3}
401 | }
402 |
403 | try:
404 | r = requests.post(f"{OLLAMA_BASE}/api/chat", json=payload, timeout=120)
405 | r.raise_for_status()
406 | data = r.json()
407 | content = data["message"]["content"]
408 |
409 | # Parse the JSON response
410 | try:
411 | return json.loads(content)
412 | except json.JSONDecodeError:
413 | # Try to extract JSON from the response
414 | import re
415 | m = re.search(r"\{.*\}", content, re.S)
416 | if m:
417 | return json.loads(m.group(0))
418 | else:
419 | raise RuntimeError("LLM did not return valid JSON")
420 |
421 | except requests.RequestException as e:
422 | raise RuntimeError(f"Failed to communicate with LLM: {str(e)}")
423 | except Exception as e:
424 | raise RuntimeError(f"Failed to generate LLM forecast report: {str(e)}")
425 |
--------------------------------------------------------------------------------