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
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.
187 lines
7.9 KiB
Python
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())
|