thermograph/backend/api/narrative_routes.py

105 lines
4.1 KiB
Python
Raw Normal View History

"""/api/v2/narrative — serve the precomputed plain-English summary for one day.
This endpoint is **read-only with respect to the model**. It never calls Ollama,
never blocks on one, and has no code path that could. It reads one row from the
derived store and returns it. That is the entire point of the design: inference
runs on the operator's desktop on a batch cadence
(``scripts/generate_narratives.py``), so thermograph.org keeps answering at
database speed whether or not that machine is powered on, whether or not the
WireGuard mesh is up, and whether or not a GPU is currently present in it.
A cache miss is an ordinary, expected outcome a cell nobody has generated for
yet, or a day beyond what the last batch covered. It answers 200 with
``narrative: null`` and ``status: "pending"`` rather than 404, because "no
sentence yet" is not a client error and a frontend should render the page
without the sentence rather than treat the response as a failure.
"""
import datetime
import hashlib
import json
import os
from fastapi import APIRouter, HTTPException, Query, Request, Response
from data import grid
from data import narrator
from data import store
router = APIRouter(tags=["narrative"])
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
BASE = f"/{_BASE}" if _BASE else ""
def _parse_date(value: str | None) -> datetime.date:
"""Target date, defaulting to today. Mirrors web/app.py's _parse_target_date
contract (400 on an unparseable date rather than a silent fallback)."""
if not value:
return datetime.date.today()
try:
return datetime.date.fromisoformat(value)
except ValueError:
raise HTTPException(status_code=400, detail="date must be YYYY-MM-DD")
def _etag_for(cell_id: str, key: str, token: str, stamp) -> str:
"""Weak ETag over the row's identity *and* its generation stamp.
Folding the stamp in is what makes a pending response safely cacheable: while
the narrative is missing the tag is stable (so revalidation is a cheap 304),
and the moment the batch job writes the row the stamp changes, the tag
changes, and the client gets the sentence on its next revalidate."""
raw = f"{cell_id}:{key}:{token}:{stamp if stamp is not None else 'pending'}"
return f'W/"{hashlib.sha1(raw.encode()).hexdigest()[:20]}"'
def _not_modified(request: Request, etag: str) -> bool:
inm = request.headers.get("if-none-match")
if not inm:
return False
tags = {t.strip() for t in inm.split(",")}
return "*" in tags or etag in tags or etag.removeprefix("W/") in tags
def api_narrative(
request: Request,
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
date: str | None = Query(None, description="target date YYYY-MM-DD (default today)"),
):
"""The stored one-sentence summary of how unusual this day was here.
Percentile-relative, like every other Thermograph surface: it describes where
the day sits against ~45 years of this location's own history, and by
construction contains no temperature or rainfall figure (see data/narrator.py
the model is never given one). Not a forecast.
"""
target = _parse_date(date)
cell = grid.snap(lat, lon)
key = narrator.narrative_key(target)
token = narrator.narrative_token()
row = store.get_narrative(cell["id"], key, token)
etag = _etag_for(cell["id"], key, token, (row or {}).get("generated_at"))
if _not_modified(request, etag):
return Response(status_code=304, headers={"ETag": etag})
payload = {
"cell": {"id": cell["id"],
"center_lat": cell["center_lat"],
"center_lon": cell["center_lon"]},
"date": target.isoformat(),
"narrative": row["text"] if row else None,
"status": "ready" if row else "pending",
"model": row["model"] if row else None,
"generated_at": row["generated_at"] if row else None,
}
return Response(
content=json.dumps(payload, separators=(",", ":")).encode(),
media_type="application/json",
headers={"ETag": etag},
)
router.add_api_route("/narrative", api_narrative, methods=["GET"])