thermograph/backend/scripts/generate_narratives.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

187 lines
7.9 KiB
Python

#!/usr/bin/env python3
"""Fill the narrative cache — the batch half of the plain-English day summaries.
This is the only thing in the codebase that calls the LLM. It walks the city
list, asks the running API for each city's graded day, has a local model phrase
it, and writes the sentence to the derived store. ``/api/v2/narrative`` then
serves those rows without ever touching a model.
# on the desktop, where Ollama is
python scripts/generate_narratives.py --limit 25
# from a host that reaches the desktop across the mesh
THERMOGRAPH_OLLAMA_URL=http://10.x.x.x:11434 \
python scripts/generate_narratives.py
Two connections, deliberately separate:
* ``--api-base`` (or ``THERMOGRAPH_API_BASE_INTERNAL``) — where to read graded
days from. Reusing the real endpoint means this script inherits the whole
fetch/grade/cache pipeline instead of reimplementing it, and warms the grade
cache as a side effect.
* ``THERMOGRAPH_OLLAMA_URL`` — where inference runs. It needs no access to
anything else, and nothing waits on it but this process.
Safe to interrupt and safe to re-run: work is per-city and committed as it goes,
and an existing valid row is skipped unless ``--force``. Nothing here writes to
climate data — the only table it touches is ``narrative``.
"""
import argparse
import datetime
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import httpx # noqa: E402
from data import cities # noqa: E402
from data import grid # noqa: E402
from data import narrator # noqa: E402
from data import store # noqa: E402
DEFAULT_API = os.environ.get("THERMOGRAPH_API_BASE_INTERNAL", "http://127.0.0.1:8000").rstrip("/")
# The app mounts its API under THERMOGRAPH_BASE (default "/thermograph"), but a
# deployment can and does run with it empty — the LAN dev container serves
# /api/v2/... flat. Guessing wrong turns every city into a 404, so the prefix is
# probed once against the I/O-free /api/version rather than assumed.
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
_PREFIXES = tuple(dict.fromkeys([f"/{_BASE}" if _BASE else "", ""]))
def _detect_prefix(client: httpx.Client, api: str) -> str | None:
for prefix in _PREFIXES:
try:
if client.get(f"{api}{prefix}/api/version", timeout=15).status_code == 200:
return prefix
except Exception: # noqa: BLE001 - try the next candidate
continue
return None
def _grade(client: httpx.Client, api: str, prefix: str, lat: float, lon: float,
target: datetime.date) -> dict | None:
"""One city's /grade payload, or None if the API couldn't produce it.
``after=0`` and a short history window keep the response small: the narrative
only ever describes the target day, so grading two weeks either side of it
would be work this script pays for and then discards."""
try:
r = client.get(f"{api}{prefix}/api/v2/grade",
params={"lat": lat, "lon": lon, "date": target.isoformat(),
"days": 1, "after": 0})
r.raise_for_status()
return r.json()
except Exception as exc: # noqa: BLE001
print(f" grade failed: {type(exc).__name__}", flush=True)
return None
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--api-base", default=DEFAULT_API,
help=f"backend base URL to read graded days from (default {DEFAULT_API})")
ap.add_argument("--date", default=None, help="target date YYYY-MM-DD (default today)")
ap.add_argument("--limit", type=int, default=0, help="stop after N cities (0 = all)")
ap.add_argument("--slug", action="append", default=[],
help="generate only this city slug (repeatable)")
ap.add_argument("--force", action="store_true",
help="regenerate even when a valid row already exists")
ap.add_argument("--dry-run", action="store_true",
help="print the sentence instead of storing it")
args = ap.parse_args()
target = (datetime.date.fromisoformat(args.date) if args.date
else datetime.date.today())
token = narrator.narrative_token()
key = narrator.narrative_key(target)
# Fail fast and loudly rather than timing out a thousand times in a row. The
# processor line is the one people actually need: an accelerator that has
# silently dropped out turns a minutes-long pass into an all-day one.
hp = narrator.health()
print(f"ollama : {hp['url']} reachable={hp['reachable']} "
f"model={hp['model']} present={hp['model_present']}")
if not hp["reachable"]:
print("ERROR: cannot reach Ollama. Set THERMOGRAPH_OLLAMA_URL.", file=sys.stderr)
return 1
if not hp["model_present"]:
print(f"ERROR: model {hp['model']!r} not pulled. Run: ollama pull {hp['model']}",
file=sys.stderr)
return 1
work = cities.all_cities()
if args.slug:
wanted = set(args.slug)
work = [c for c in work if c["slug"] in wanted]
missing = wanted - {c["slug"] for c in work}
if missing:
print(f"ERROR: unknown slug(s): {', '.join(sorted(missing))}", file=sys.stderr)
return 1
if args.limit > 0:
work = work[:args.limit]
print(f"target : {target} token={token}")
print(f"cities : {len(work)}\n")
made = skipped = failed = 0
started = time.time()
with httpx.Client(timeout=120.0) as api_client, httpx.Client(timeout=narrator.TIMEOUT) as llm:
prefix = _detect_prefix(api_client, args.api_base)
if prefix is None:
print(f"ERROR: no Thermograph API at {args.api_base} "
f"(tried {', '.join(repr(p) for p in _PREFIXES)})", file=sys.stderr)
return 1
print(f"api : {args.api_base}{prefix}/api/v2\n")
for n, city in enumerate(work, 1):
label = f"{city['name']}, {city['country']}"
cell = grid.snap(city["lat"], city["lon"])
if not args.force and store.get_narrative(cell["id"], key, token):
skipped += 1
continue
print(f"[{n}/{len(work)}] {label}", flush=True)
payload = _grade(api_client, args.api_base, prefix,
city["lat"], city["lon"], target)
if payload is None:
failed += 1
continue
# Name the city we asked about, not the cell's reverse-geocoded
# neighbourhood — see narrator.facts_from_grade.
facts = narrator.facts_from_grade(payload, target,
place=cities.display_name(city))
if facts is None:
# No gradeable metric for this day — usually a cell whose recent
# bundle hasn't synced yet. Not an error; it'll be picked up on a
# later pass once the data lands.
print(" no graded data for the target day — skipping", flush=True)
skipped += 1
continue
text = narrator.generate(facts, client=llm)
if not text:
print(" generation failed or was rejected by the sanitizer", flush=True)
failed += 1
continue
print(f" {text}", flush=True)
if not args.dry_run:
store.put_narrative(cell["id"], key, token, text, narrator.MODEL)
made += 1
elapsed = time.time() - started
print(f"\ndone in {elapsed:.0f}s — {made} written, {skipped} skipped, {failed} failed")
if args.dry_run:
print("(dry run — nothing was stored)")
# A run that produced nothing while having work to do is a failure worth a
# non-zero exit, so a cron wrapper notices.
return 1 if made == 0 and failed > 0 else 0
if __name__ == "__main__":
raise SystemExit(main())