thermograph/backend/api/narrative_routes.py
Emi Griffith 60dd179b19
All checks were successful
PR build (required check) / changes (pull_request) Successful in 8s
shell-lint / shellcheck (pull_request) Successful in 8s
secrets-guard / encrypted (pull_request) Successful in 7s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 2s
PR build (required check) / build-backend (pull_request) Successful in 1m6s
narrative: precomputed plain-English day summaries
Adds /api/v2/narrative, serving a one-sentence summary of how unusual a given
day was at a location. Generation is a batch job (scripts/generate_narratives.py)
that talks to a local Ollama; the endpoint only ever reads the cache, so no
request path depends on the inference host being reachable or powered on. A miss
answers 200 with narrative:null and status "pending" rather than 404.

The model is handed tier labels and percentile directions only, never a
temperature or rainfall figure, so it has nothing to misreport; the sanitizer
then rejects any output that carries units, a forecast or advice. Fact lines
state direction ("warmer than 82% of nights at this time of year here") because
the earlier "82nd percentile (High)" form was read backwards and phrased a warm
night as a cool one. Narratives are unit-free as a result, which also keeps one
cached row per cell/date instead of a per-country degree variant.

Storage is a new `narrative` table in the derived store, LOGGED on Postgres
unlike its two UNLOGGED siblings: losing it costs an LLM pass over the whole
grid, not a recompute from parquet.
2026-07-25 14:54:13 -07:00

104 lines
4.1 KiB
Python

"""/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"])