thermograph/backend/tests/api/test_narrative_routes.py
Emi Griffith 884df0ade5
All checks were successful
PR build (required check) / changes (pull_request) Successful in 17s
secrets-guard / encrypted (pull_request) Successful in 13s
PR build (required check) / build-frontend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 25s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 46s
PR build (required check) / gate (pull_request) Successful in 3s
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-08-02 01:14:37 +00:00

109 lines
4.1 KiB
Python

"""/api/v2/narrative — the read-only half of the plain-English day summaries.
The load-bearing assertion here is the negative one: this route must never reach
for a model. ``narrator.generate`` is replaced with a bomb for every test, so any
future edit that makes the request path call inference fails loudly instead of
quietly coupling a public endpoint to a desktop GPU.
"""
import datetime
import pytest
from fastapi.testclient import TestClient
from data import grid
from data import narrator
from data import store
from web import app as appmod
Q = {"lat": 47.6062, "lon": -122.3321}
TODAY = datetime.date.today()
@pytest.fixture
def client(monkeypatch):
def _boom(*a, **kw): # pragma: no cover - failing here is the point
raise AssertionError("the request path must never call the LLM")
monkeypatch.setattr(narrator, "generate", _boom)
return TestClient(appmod.app)
@pytest.fixture
def seeded():
"""Store one narrative for today's cell and hand back its identity."""
cell = grid.snap(Q["lat"], Q["lon"])
key = narrator.narrative_key(TODAY)
token = narrator.narrative_token()
store.put_narrative(cell["id"], key, token,
"A cool night for late July here.", "qwen3:14b")
yield {"cell": cell, "key": key, "token": token}
def test_missing_narrative_is_pending_not_an_error(client):
r = client.get("/thermograph/api/v2/narrative",
params={"lat": -33.8688, "lon": 151.2093})
assert r.status_code == 200
body = r.json()
assert body["narrative"] is None
assert body["status"] == "pending"
assert body["model"] is None
def test_stored_narrative_is_served_with_provenance(client, seeded):
r = client.get("/thermograph/api/v2/narrative", params=Q)
assert r.status_code == 200
body = r.json()
assert body["narrative"] == "A cool night for late July here."
assert body["status"] == "ready"
assert body["model"] == "qwen3:14b"
assert body["generated_at"] > 0
assert body["date"] == TODAY.isoformat()
assert body["cell"]["id"] == seeded["cell"]["id"]
def test_conditional_revalidation_returns_304(client, seeded):
first = client.get("/thermograph/api/v2/narrative", params=Q)
etag = first.headers["ETag"]
again = client.get("/thermograph/api/v2/narrative", params=Q,
headers={"If-None-Match": etag})
assert again.status_code == 304
assert again.content == b""
def test_the_pending_etag_changes_once_the_row_lands(client, seeded):
"""A client that cached a pending response has to notice the sentence
appearing — so the two states must not share an ETag."""
ready = client.get("/thermograph/api/v2/narrative", params=Q).headers["ETag"]
pending = client.get("/thermograph/api/v2/narrative",
params={"lat": -33.8688, "lon": 151.2093}).headers["ETag"]
assert ready != pending
def test_a_row_written_under_another_token_is_not_served(client):
"""A prompt or model change must orphan old text rather than serve it."""
cell = grid.snap(35.6762, 139.6503)
store.put_narrative(cell["id"], narrator.narrative_key(TODAY),
"n0:some-old-model", "Written under the old contract.",
"some-old-model")
r = client.get("/thermograph/api/v2/narrative",
params={"lat": 35.6762, "lon": 139.6503})
assert r.status_code == 200
assert r.json()["narrative"] is None
assert r.json()["status"] == "pending"
def test_an_explicit_past_date_is_its_own_row(client, seeded):
r = client.get("/thermograph/api/v2/narrative",
params={**Q, "date": "2020-01-01"})
assert r.status_code == 200
assert r.json()["date"] == "2020-01-01"
assert r.json()["narrative"] is None
def test_an_unparseable_date_is_rejected(client):
r = client.get("/thermograph/api/v2/narrative", params={**Q, "date": "last-tuesday"})
assert r.status_code == 400
@pytest.mark.parametrize("bad", [{"lat": 91, "lon": 0}, {"lat": 0, "lon": 181}])
def test_out_of_range_coordinates_are_rejected(client, bad):
assert client.get("/thermograph/api/v2/narrative", params=bad).status_code == 422