diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index b7d81b76..9d3d21c4 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -758,6 +758,145 @@ def test_cities_endpoint_does_not_block_on_recent_deb_index(monkeypatch): assert denver["deb_recent_sample_count"] == 0 +def test_bot_deb_requires_entitlement(monkeypatch): + monkeypatch.setenv("POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN", "test-token") + + response = client.get("/api/bot/deb?cities=seoul") + + assert response.status_code in {401, 403, 503} + + +def test_bot_deb_returns_cached_city_predictions_without_refresh(monkeypatch): + monkeypatch.setenv("POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN", "test-token") + monkeypatch.setattr(city_api.legacy_routes, "_assert_entitlement", lambda request: None) + monkeypatch.setattr( + city_api.legacy_routes, + "CITIES", + { + "seoul": {"f": False, "tz": 9 * 3600}, + "busan": {"f": False, "tz": 9 * 3600}, + "denver": {"f": True, "tz": -6 * 3600}, + }, + raising=False, + ) + monkeypatch.setattr( + city_api.legacy_routes, + "CITY_REGISTRY", + { + "seoul": {"display_name": "Seoul", "use_fahrenheit": False}, + "busan": {"display_name": "Busan", "use_fahrenheit": False}, + "denver": {"display_name": "Denver", "use_fahrenheit": True}, + }, + raising=False, + ) + + class FakeCache: + def __init__(self): + self.calls = [] + + def get_city_cache(self, kind, city): + self.calls.append((kind, city)) + payloads = { + ("summary", "seoul"): { + "payload": { + "name": "seoul", + "display_name": "Seoul", + "local_time": "18:30", + "temp_symbol": "°C", + "current": {"temp": 23.0}, + "deb": { + "prediction": 27.7, + "version": "deb_v3_guarded_calibrated", + "quality_tier": "medium", + "recent_hit_rate": 58.3, + }, + }, + "updated_at": "2026-06-13T09:30:00+00:00", + "updated_at_ts": 1781343000.0, + }, + ("panel", "busan"): { + "payload": { + "name": "busan", + "display_name": "Busan", + "local_date": "2026-06-13", + "local_time": "18:32", + "temp_symbol": "°C", + "current": {"temp": 22.5}, + "deb": { + "prediction": 26.2, + "version": "deb_v3_guarded_calibrated", + "quality_tier": "high", + "recent_hit_rate": 71.4, + }, + }, + "updated_at": "2026-06-13T09:32:00+00:00", + "updated_at_ts": 1781343120.0, + }, + ("summary", "denver"): { + "payload": { + "name": "denver", + "display_name": "Denver", + "local_date": "2026-06-13", + "local_time": "03:30", + "temp_symbol": "°F", + "current": {"temp": 73.4}, + "deb": { + "prediction": 82.6, + "version": "deb_v3_guarded_calibrated", + "quality_tier": "low", + "recent_hit_rate": 25.0, + }, + }, + "updated_at": "2026-06-13T09:30:00+00:00", + "updated_at_ts": 1781343000.0, + }, + } + return payloads.get((kind, city)) + + fake_cache = FakeCache() + monkeypatch.setattr(city_api.legacy_routes, "_CACHE_DB", fake_cache) + + def fail_refresh(*_args, **_kwargs): + raise AssertionError("bot DEB endpoint must not refresh city caches") + + monkeypatch.setattr(city_api.legacy_routes, "_refresh_city_summary_cache", fail_refresh) + monkeypatch.setattr(city_api.legacy_routes, "_refresh_city_panel_cache", fail_refresh) + monkeypatch.setattr(city_api.legacy_routes, "_refresh_city_full_cache", fail_refresh) + + response = client.get( + "/api/bot/deb?cities=seoul,busan,unknown,denver", + headers={"x-polyweather-entitlement": "test-token"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["count"] == 3 + assert payload["missing"] == ["unknown"] + assert payload["cities"]["seoul"] == { + "local_date": "2026-06-13", + "local_time": "18:30", + "display_name": "Seoul", + "temp_unit": "C", + "deb_prediction": 27.7, + "deb_prediction_c": 27.7, + "deb_version": "deb_v3_guarded_calibrated", + "quality_tier": "medium", + "recent_hit_rate": 58.3, + "settlement_bucket": 28, + "settlement_rule": "wu_round", + "current_temp": 23.0, + "current_temp_c": 23.0, + "source_updated_at": "2026-06-13T09:30:00+00:00", + "cache_kind": "summary", + } + assert payload["cities"]["busan"]["cache_kind"] == "panel" + assert payload["cities"]["busan"]["settlement_bucket"] == 26 + assert payload["cities"]["denver"]["temp_unit"] == "F" + assert payload["cities"]["denver"]["deb_prediction_c"] == 28.1 + assert payload["cities"]["denver"]["current_temp_c"] == 23.0 + assert ("full", "seoul") not in fake_cache.calls + + def test_city_detail_batch_endpoint_builds_multiple_cached_details(monkeypatch): calls = [] diff --git a/web/app_factory.py b/web/app_factory.py index a949668c..5ea0066d 100644 --- a/web/app_factory.py +++ b/web/app_factory.py @@ -12,6 +12,7 @@ from fastapi import FastAPI from web.core import app as core_app from web.routers.analytics import router as analytics_router from web.routers.city import router as city_router +from web.routers.bot import router as bot_router from web.routers.auth import router as auth_router from web.routers.feedback import router as feedback_router from web.routers.ops import router as ops_router @@ -49,6 +50,7 @@ def create_app() -> FastAPI: if not bool(getattr(core_app.state, _ROUTES_REGISTERED_FLAG, False)): core_app.include_router(system_router) core_app.include_router(city_router) + core_app.include_router(bot_router) core_app.include_router(auth_router) core_app.include_router(feedback_router) core_app.include_router(analytics_router) diff --git a/web/routers/bot.py b/web/routers/bot.py new file mode 100644 index 00000000..f02072e8 --- /dev/null +++ b/web/routers/bot.py @@ -0,0 +1,16 @@ +"""Bot-facing API routes.""" + +from typing import Optional + +from fastapi import APIRouter, Request, Response + +from web.services.bot_api import get_bot_deb_payload + +router = APIRouter(tags=["bot"]) + + +@router.get("/api/bot/deb") +async def bot_deb(request: Request, response: Response, cities: Optional[str] = None): + payload = await get_bot_deb_payload(request, cities=cities) + response.headers["Cache-Control"] = "private, max-age=30" + return payload diff --git a/web/services/bot_api.py b/web/services/bot_api.py new file mode 100644 index 00000000..8d74b961 --- /dev/null +++ b/web/services/bot_api.py @@ -0,0 +1,188 @@ +"""Lightweight bot-facing API service functions.""" + +from __future__ import annotations + +import os +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Tuple + +from fastapi import HTTPException, Request +from fastapi.concurrency import run_in_threadpool + +from src.analysis.settlement_rounding import apply_city_settlement, is_exact_settlement_city +from src.auth.supabase_entitlement import extract_bearer_token +import web.routes as legacy_routes + + +_CACHE_KINDS = ("summary", "panel", "full") +_ENTITLEMENT_HEADER = "x-polyweather-entitlement" + + +def _safe_float(value: Any) -> Optional[float]: + try: + if value is None or value == "": + return None + return float(value) + except Exception: + return None + + +def _round_one(value: Optional[float]) -> Optional[float]: + return None if value is None else round(float(value), 1) + + +def _display_to_celsius(value: Optional[float], unit: str) -> Optional[float]: + if value is None: + return None + if unit == "F": + return round((float(value) - 32.0) * 5.0 / 9.0, 1) + return round(float(value), 1) + + +def _bot_temp_unit(city: str, payload: Dict[str, Any]) -> str: + symbol = str(payload.get("temp_symbol") or "").upper() + if "F" in symbol: + return "F" + city_info = legacy_routes.CITIES.get(city) or {} + city_meta = legacy_routes.CITY_REGISTRY.get(city) or {} + if bool(city_info.get("f")) or bool(city_meta.get("use_fahrenheit")): + return "F" + return "C" + + +def _normalize_bot_city_list(raw: Optional[str]) -> Tuple[List[str], List[str]]: + requested = str(raw or "").strip() + if not requested or requested.lower() in {"all", "*"}: + return sorted(legacy_routes.CITIES.keys()), [] + + cities: List[str] = [] + missing: List[str] = [] + aliases = getattr(legacy_routes, "ALIASES", {}) or {} + for part in requested.split(","): + token = str(part or "").strip().lower().replace("-", " ") + if not token: + continue + if token in {"all", "*"}: + return sorted(legacy_routes.CITIES.keys()), [] + city = aliases.get(token, token) + if city in legacy_routes.CITIES: + if city not in cities: + cities.append(city) + elif city not in missing: + missing.append(city) + return cities, missing + + +def _require_bot_entitlement(request: Request) -> None: + expected = str(os.getenv("POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN") or "").strip() + if not expected: + raise HTTPException( + status_code=503, + detail="bot DEB endpoint requires POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN", + ) + token = str(request.headers.get(_ENTITLEMENT_HEADER) or "").strip() + if not token: + token = extract_bearer_token(request.headers.get("authorization")) + if token != expected: + raise HTTPException(status_code=401, detail="Unauthorized") + legacy_routes._assert_entitlement(request) + + +def _cache_updated_at(entry: Dict[str, Any], payload: Dict[str, Any]) -> Optional[str]: + value = str(entry.get("updated_at") or payload.get("updated_at") or "").strip() + return value or None + + +def _entry_local_datetime(city: str, entry: Dict[str, Any]) -> Optional[datetime]: + updated_at_ts = _safe_float(entry.get("updated_at_ts")) + if updated_at_ts is None or updated_at_ts <= 0: + return None + try: + offset = int((legacy_routes.CITIES.get(city) or {}).get("tz") or 0) + except Exception: + offset = 0 + return datetime.fromtimestamp(updated_at_ts, tz=timezone.utc) + timedelta(seconds=offset) + + +def _local_date(payload: Dict[str, Any], city: str, entry: Dict[str, Any]) -> Optional[str]: + value = str(payload.get("local_date") or "").strip() + if value: + return value + local_dt = _entry_local_datetime(city, entry) + return local_dt.strftime("%Y-%m-%d") if local_dt is not None else None + + +def _local_time(payload: Dict[str, Any], city: str, entry: Dict[str, Any]) -> Optional[str]: + value = str(payload.get("local_time") or "").strip() + if value: + return value + local_dt = _entry_local_datetime(city, entry) + return local_dt.strftime("%H:%M") if local_dt is not None else None + + +def _cached_bot_deb_row(city: str) -> Optional[Dict[str, Any]]: + for kind in _CACHE_KINDS: + entry = legacy_routes._CACHE_DB.get_city_cache(kind, city) + if not isinstance(entry, dict): + continue + payload = entry.get("payload") + if not isinstance(payload, dict): + continue + + deb = payload.get("deb") if isinstance(payload.get("deb"), dict) else {} + deb_prediction = _round_one(_safe_float(deb.get("prediction"))) + if deb_prediction is None: + continue + + unit = _bot_temp_unit(city, payload) + current = payload.get("current") if isinstance(payload.get("current"), dict) else {} + current_temp = _round_one(_safe_float(current.get("temp"))) + city_meta = legacy_routes.CITY_REGISTRY.get(city) or {} + display_name = str( + payload.get("display_name") + or city_meta.get("display_name") + or city_meta.get("name") + or city.title() + ).strip() + + return { + "local_date": _local_date(payload, city, entry), + "local_time": _local_time(payload, city, entry), + "display_name": display_name, + "temp_unit": unit, + "deb_prediction": deb_prediction, + "deb_prediction_c": _display_to_celsius(deb_prediction, unit), + "deb_version": deb.get("version"), + "quality_tier": deb.get("quality_tier"), + "recent_hit_rate": _round_one(_safe_float(deb.get("recent_hit_rate"))), + "settlement_bucket": apply_city_settlement(city, deb_prediction), + "settlement_rule": "floor" if is_exact_settlement_city(city) else "wu_round", + "current_temp": current_temp, + "current_temp_c": _display_to_celsius(current_temp, unit), + "source_updated_at": _cache_updated_at(entry, payload), + "cache_kind": kind, + } + return None + + +def _build_bot_deb_payload(cities: Optional[str]) -> Dict[str, Any]: + requested_cities, missing = _normalize_bot_city_list(cities) + rows: Dict[str, Dict[str, Any]] = {} + for city in requested_cities: + row = _cached_bot_deb_row(city) + if row is None: + missing.append(city) + continue + rows[city] = row + return { + "generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "source": "city_cache", + "count": len(rows), + "cities": rows, + "missing": missing, + } + + +async def get_bot_deb_payload(request: Request, cities: Optional[str] = None) -> Dict[str, Any]: + _require_bot_entitlement(request) + return await run_in_threadpool(_build_bot_deb_payload, cities)