Add lightweight bot DEB endpoint
This commit is contained in:
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user