105 lines
4.1 KiB
Python
105 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"])
|