diff --git a/backend/api/narrative_routes.py b/backend/api/narrative_routes.py new file mode 100644 index 0000000..3562c59 --- /dev/null +++ b/backend/api/narrative_routes.py @@ -0,0 +1,104 @@ +"""/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"]) diff --git a/backend/data/narrator.py b/backend/data/narrator.py new file mode 100644 index 0000000..070640e --- /dev/null +++ b/backend/data/narrator.py @@ -0,0 +1,325 @@ +"""Plain-English grade narratives — a local LLM phrases one graded day. + +Thermograph's payloads say a day sits in the 92nd percentile and earns the +"Very High" tier. That is precise and completely inert to read. This module +turns one graded day into a sentence a person actually absorbs, using a model +served by Ollama on the operator's desktop. + +Three properties hold this together, and every one of them is load-bearing: + +* **Generation never happens on the request path.** Nothing here is imported by + a route handler that has to answer quickly. ``scripts/generate_narratives.py`` + fills the cache; ``api/narrative_routes.py`` only ever *reads* it. A public + endpoint therefore has no dependency on the desktop being powered on, the + mesh being up, or a model being loaded — the worst case is a cache miss, which + serves ``null`` and renders as nothing. + +* **The model never sees a number it could misreport.** ``facts_from_grade`` + hands it tier labels and percentile ordinals only — never a temperature, never + a raw reading. A model that hallucinates cannot invent "38°C" because it was + never told a degree value, and the sentence it writes is checked against + :data:`_BANNED` for units it had no business emitting. This is the whole + reason narratives are unit-free rather than rendered per-country: it removes + an entire class of wrong output, and as a bonus there is exactly one cached + narrative per (cell, date) instead of a °C and a °F variant. + +* **The framing stays relative.** Thermograph grades how *unusual* weather is + against ~45 years of that exact location's history — it is not a forecast and + not a thermometer. The prompt says so, and the sanitizer drops output that + drifts into prediction or advice. + +Configuration is entirely environmental, so the same code runs on the desktop +(Ollama on localhost) and on a host that reaches it across WireGuard: + +* ``THERMOGRAPH_OLLAMA_URL`` — default ``http://127.0.0.1:11434`` +* ``THERMOGRAPH_NARRATIVE_MODEL`` — default ``qwen3:14b`` +* ``THERMOGRAPH_NARRATIVE_TIMEOUT`` — seconds, default 120 (generous on purpose: + a CPU-only fallback is roughly an order of magnitude slower than the GPU, and + a batch job would rather wait than lose the row) +""" +import datetime +import math +import os +import re + +import httpx + +# Bump to invalidate every stored narrative at once — it is half of the validity +# token (the model name is the other half), so a prompt or house-style change +# here orphans the old rows rather than silently mixing two generations of text. +# n2: fact lines state the direction ("warmer than 82% of nights…") instead of a +# bare "82nd percentile (High)", which models could and did read backwards. +NARRATIVE_VER = "n2" + +OLLAMA_URL = os.environ.get("THERMOGRAPH_OLLAMA_URL", "http://127.0.0.1:11434").rstrip("/") +MODEL = os.environ.get("THERMOGRAPH_NARRATIVE_MODEL", "qwen3:14b").strip() +TIMEOUT = float(os.environ.get("THERMOGRAPH_NARRATIVE_TIMEOUT", "120")) + +# Hard ceiling on what we will store. The prompt asks for one sentence; this is +# the backstop for a model that ignores it. Sentence-truncated, not chopped +# mid-word (see _sanitize). +MAX_CHARS = 240 + +# The metrics a narrative may mention, in prompt order: payload key, the label +# used in the prompt, and the noun for what the percentile ranks against. +# Anything absent from the graded day is simply omitted — a cell with no +# feels-like reading yields a shorter fact list, never a "None" in the prompt. +# +# The noun matters more than it looks. An earlier version of this prompt wrote +# "Overnight low: 82nd percentile (High)", which a model read as "the low was +# high" and phrased as a *cool* night — an exact inversion of the fact, and one +# no output filter can catch because nothing about the sentence looks wrong. +# Stating the direction outright ("warmer than 82% of nights…") removes the +# ambiguity at the source; the tier is then along for corroboration, not +# interpretation. +_METRICS = ( + ("tmax", "Daytime high", "days"), + ("tmin", "Overnight low", "nights"), + ("feels", "Feels-like", "days"), + ("precip", "Rain", "rainy days"), +) + +# Units and forecast vocabulary the model was never given and must not produce. +# Matched case-insensitively against the finished sentence; a hit voids the +# generation rather than storing something that reads authoritative and is not. +_BANNED = re.compile( + r"(\d+\s*(°|deg|celsius|fahrenheit|c\b|f\b)" # any degree reading + r"|\b(mm|cm|inches|inch|mph|km/?h|knots?)\b" # any measured quantity + r"|\b(tomorrow|forecast|expect|will be|should be)\b" # prediction + r"|\b(wear|bring|advise|recommend|stay hydrated)\b)", # advice + re.I, +) + +# qwen3 is a thinking model. We pass think=False, but a reasoning block leaking +# into the response is exactly the kind of thing that changes between releases, +# so it is stripped defensively too. +_THINK = re.compile(r"]*>.*?", re.I | re.S) + +# Models open with a conversational lead-in more often than they don't, and it +# arrives in two shapes: a bare interjection ("Sure!", "Okay,") and a labelled +# hand-off ("Here's a sentence:"), frequently both at once. Each is anchored to a +# known opener rather than to "anything before a colon", so a real narrative that +# happens to use a colon — "One thing stands out: the night was extraordinary" — +# survives untouched. +_LEADIN = re.compile(r"^\s*(?:sure|certainly|of course|okay|ok|absolutely|got it)" + r"\b[!,.…]*\s*", re.I) +_LABEL = re.compile(r"^\s*(?:here(?:'|’)?s|here is|sentence|summary|answer)" + r"\b[^:\n]{0,60}:\s*", re.I) + + +def _pct_int(pct) -> int: + """A percentile as a whole number for the prompt, clamped to 1..99. + + Same floor(x + 0.5) and same clamp as grading.pct_ordinal, deliberately: a + narrative saying "warmer than 92%" sitting beside a UI reading "92nd" is the + least confusing outcome, and the clamp keeps "warmer than 100% of days" — + which reads as a measurement error — off the page.""" + return min(99, max(1, math.floor(float(pct) + 0.5))) + + +def narrative_key(target: datetime.date) -> str: + """Derived-store key: one narrative per cell per date.""" + return target.isoformat() + + +def narrative_token(model: str | None = None) -> str: + """Validity token. Deliberately independent of the climate sync stamps. + + The obvious move — folding ``recent_synced_at`` in, the way + ``payloads.recent_token`` does — would invalidate every narrative every time + the forecast bundle refreshes (~4h). Regenerating the whole grid that often + is not affordable against a single desktop GPU, let alone the CPU fallback, + and it buys nothing: a sentence about a day being "cooler than four nights in + five" does not stop being true because the high moved a tenth of a degree. + + Freshness is instead the generator's job — it re-runs for the current day on + its own cadence and overwrites in place (put_narrative upserts). This token + exists to orphan rows when the *phrasing contract* changes: the prompt/house + style (NARRATIVE_VER) or the model behind it.""" + return f"{NARRATIVE_VER}:{model or MODEL}" + + +def _day_row(payload: dict, target: datetime.date) -> dict | None: + """The graded row for ``target`` inside a /grade payload's ``recent`` array.""" + want = target.isoformat() + for row in payload.get("recent") or (): + if row.get("date") == want: + return row + return None + + +def facts_from_grade(payload: dict, target: datetime.date, + place: str | None = None) -> dict | None: + """Reduce a /grade payload to the handful of facts the model may use. + + Returns None when the target day carries no gradeable metric at all — there + is nothing to say about it, and an empty fact list would invite the model to + invent something. Note what is *not* in the result: no temperatures, no + cell coordinates, no climatology arrays. See the module docstring. + + ``place`` overrides the payload's own label. The payload carries a + *reverse-geocoded* name for the ~2-mile cell, which in a dense city is a + neighbourhood — grading Shanghai yields "Nanjingdonglu Subdistrict", and a + sentence opening on that reads like it is about somewhere else. A caller that + already knows which city it asked about should say so.""" + row = _day_row(payload, target) + if not row: + return None + + metrics = [] + for key, label, noun in _METRICS: + graded = row.get(key) + if not isinstance(graded, dict): + continue + grade = graded.get("grade") + if not grade: + continue + pct = graded.get("percentile") + # A dry day has a tier ("Dry") but no percentile — it is ranked among rain + # days only, and it isn't one. Carry pct=None and let build_prompt say so + # in words rather than emitting a percentage that doesn't exist. + metrics.append({"label": label, "grade": grade, "noun": noun, + "pct": _pct_int(pct) if pct is not None else None}) + if not metrics: + return None + + if place is None: + raw = payload.get("place") + place = (raw or {}).get("label") if isinstance(raw, dict) else raw + return {"place": place, "date": target.isoformat(), "metrics": metrics} + + +def build_prompt(facts: dict) -> str: + """The full prompt for one narrative. Pure function of ``facts`` — same facts + in, same prompt out, which is what makes a regeneration reproducible.""" + when = datetime.date.fromisoformat(facts["date"]).strftime("%-d %B") + lines = [ + "You write one-sentence summaries for Thermograph, a service that grades " + "how unusual a day's weather is against about 45 years of that exact " + "location's own history.", + "", + "Rules:", + "- Describe how UNUSUAL the day is for this place at this time of year.", + "- Use only the facts listed below. Do not add any fact that is not there.", + "- Never state a temperature, a rainfall amount, or any other measurement. " + "You have not been given any, and inventing one is a serious error.", + "- Never forecast, predict, or give advice.", + "- One sentence, at most 25 words. Plain prose — no markdown, no quotes, " + "no preamble, no trailing commentary.", + "", + "Facts:", + ] + if facts.get("place"): + lines.append(f"Place: {facts['place']}") + lines.append(f"Date: {when}") + for m in facts["metrics"]: + if m["pct"] is None: + # The only percentile-less case in practice is a dry day, whose tier + # ("Dry") is already a plain-English statement of the fact. + lines.append(f"{m['label']}: {m['grade'].lower()} — no measurable rain") + continue + # "warmer/heavier than N% of at this time of year here" — the + # direction is stated, so the tier cannot be read backwards. See _METRICS. + verb = "heavier" if m["label"] == "Rain" else "warmer" + lines.append(f"{m['label']}: {verb} than {m['pct']}% of {m['noun']} " + f"at this time of year here (tier: {m['grade']})") + lines += ["", "Sentence:"] + return "\n".join(lines) + + +def _sanitize(text: str) -> str | None: + """Normalize a raw completion, or None if it isn't fit to store. + + Rejecting is always safe here: the caller stores nothing and the endpoint + serves null, which renders as an absent sentence rather than a wrong one.""" + if not text: + return None + text = _THINK.sub(" ", text) + text = _LABEL.sub("", _LEADIN.sub("", text)) + text = re.sub(r"\s+", " ", text).strip() + text = text.strip('"“”‘’\'').strip() + if len(text) < 20: # too short to be a real sentence + return None + if _BANNED.search(text): # a measurement or a forecast it was never given + return None + if len(text) > MAX_CHARS: + # Truncate at the last sentence end that fits; if there isn't one, the + # model rambled past the limit without punctuation — treat that as a + # failed generation rather than storing a fragment. + cut = max(text.rfind(". ", 0, MAX_CHARS), text.rfind("! ", 0, MAX_CHARS), + text.rfind("? ", 0, MAX_CHARS)) + if cut < 20: + return None + text = text[:cut + 1] + return text + + +def generate(facts: dict, *, model: str | None = None, client: httpx.Client | None = None) -> str | None: + """One narrative for one graded day, or None on any failure. + + Fail-soft by design and by convention (the derived store's readers behave the + same way): an unreachable Ollama, a cold model that blows the timeout, a + refusal, or output that trips the sanitizer all return None. The batch job + logs it, skips the cell, and moves on — a missing narrative is a non-event, + a wrong one is not.""" + prompt = build_prompt(facts) + body = { + "model": model or MODEL, + "prompt": prompt, + "stream": False, + # Reasoning buys nothing for a one-sentence rephrasing and costs the bulk + # of the tokens — decisive when the GPU is unavailable and this is running + # on CPU at single-digit tokens/sec. + "think": False, + "options": { + # Low but not zero: greedy decoding on a small instruct model tends to + # produce the same stock opener for every cell, which reads worse + # across a grid than mild variation. + "temperature": 0.4, + "top_p": 0.9, + # ~25 words plus slack; a hard stop is the cheapest defence against a + # model that decides to write an essay. + "num_predict": 96, + "stop": ["\n\n"], + }, + } + try: + if client is not None: + r = client.post(f"{OLLAMA_URL}/api/generate", json=body, timeout=TIMEOUT) + else: + with httpx.Client(timeout=TIMEOUT) as c: + r = c.post(f"{OLLAMA_URL}/api/generate", json=body) + r.raise_for_status() + return _sanitize((r.json() or {}).get("response") or "") + except Exception: # noqa: BLE001 - see docstring: every failure is a skip + return None + + +def health() -> dict: + """Whether the configured Ollama can serve the configured model, and where it + would run. Used by the batch job to fail fast with a useful message instead of + grinding through a thousand cells that will each time out. + + ``processor`` is the interesting field in practice: Ollama reports "100% CPU" + when no accelerator is present, which is the difference between a full-grid + pass taking minutes and taking most of a day.""" + out = {"url": OLLAMA_URL, "model": MODEL, "reachable": False, + "model_present": False, "processor": None} + try: + with httpx.Client(timeout=10.0) as c: + tags = c.get(f"{OLLAMA_URL}/api/tags").json() + out["reachable"] = True + out["model_present"] = any( + m.get("name") == MODEL or m.get("model") == MODEL + for m in tags.get("models") or () + ) + for m in (c.get(f"{OLLAMA_URL}/api/ps").json() or {}).get("models") or (): + if m.get("name") == MODEL or m.get("model") == MODEL: + total = m.get("size") or 0 + gpu = m.get("size_vram") or 0 + out["processor"] = ("100% CPU" if not gpu + else "100% GPU" if gpu >= total + else f"{round(100 * gpu / total)}% GPU") + except Exception: # noqa: BLE001 - health is advisory; callers handle False + pass + return out diff --git a/backend/data/store.py b/backend/data/store.py index d025994..26f6eb4 100644 --- a/backend/data/store.py +++ b/backend/data/store.py @@ -68,6 +68,15 @@ CREATE TABLE IF NOT EXISTS revgeo ( label TEXT, -- NULL == lookup ran but found no label updated_at REAL NOT NULL ); +CREATE TABLE IF NOT EXISTS narrative ( + cell_id TEXT NOT NULL, + key TEXT NOT NULL, -- target date (ISO) + token TEXT NOT NULL, -- validity: narrator.narrative_token() (version + model) + text TEXT NOT NULL, -- the sentence; short enough not to be worth compressing + model TEXT NOT NULL, -- which model wrote it, for provenance in the payload + updated_at REAL NOT NULL, + PRIMARY KEY (cell_id, key) +); """ # Postgres mirror of _SCHEMA. UNLOGGED = fast, crash-non-durable (the cache is @@ -93,6 +102,24 @@ _PG_SCHEMA = ( updated_at DOUBLE PRECISION NOT NULL ) """, + # LOGGED, unlike its two siblings above — the one table here that is not + # cheaply rebuildable. `derived` and `revgeo` are UNLOGGED because losing them + # costs a recompute from parquet or a Nominatim round-trip. Losing `narrative` + # costs an LLM pass over the whole grid on a single desktop GPU (and far worse + # on the CPU fallback), which is hours, not milliseconds. Durability is worth + # more than the write throughput here: narratives are written once per cell + # per day by a batch job, never on the request path. + """ + CREATE TABLE IF NOT EXISTS narrative ( + cell_id TEXT NOT NULL, + key TEXT NOT NULL, + token TEXT NOT NULL, + text TEXT NOT NULL, + model TEXT NOT NULL, + updated_at DOUBLE PRECISION NOT NULL, + PRIMARY KEY (cell_id, key) + ) + """, ) # Statement text differs by dialect (placeholder style + upsert syntax); the SQLite @@ -113,11 +140,23 @@ if IS_POSTGRES: "INSERT INTO revgeo (key, label, updated_at) VALUES (%s,%s,%s) " "ON CONFLICT (key) DO UPDATE SET label=EXCLUDED.label, updated_at=EXCLUDED.updated_at" ) + _SQL_GET_NARRATIVE = ( + "SELECT token, text, model, updated_at FROM narrative WHERE cell_id=%s AND key=%s") + _SQL_PUT_NARRATIVE = ( + "INSERT INTO narrative (cell_id, key, token, text, model, updated_at) " + "VALUES (%s,%s,%s,%s,%s,%s) " + "ON CONFLICT (cell_id, key) DO UPDATE SET " + "token=EXCLUDED.token, text=EXCLUDED.text, model=EXCLUDED.model, " + "updated_at=EXCLUDED.updated_at" + ) else: _SQL_GET_PAYLOAD = "SELECT token, payload FROM derived WHERE kind=? AND cell_id=? AND key=?" _SQL_PUT_PAYLOAD = "INSERT OR REPLACE INTO derived VALUES (?,?,?,?,?,?)" _SQL_GET_REVGEO = "SELECT label, updated_at FROM revgeo WHERE key=?" _SQL_PUT_REVGEO = "INSERT OR REPLACE INTO revgeo VALUES (?,?,?)" + _SQL_GET_NARRATIVE = ( + "SELECT token, text, model, updated_at FROM narrative WHERE cell_id=? AND key=?") + _SQL_PUT_NARRATIVE = "INSERT OR REPLACE INTO narrative VALUES (?,?,?,?,?,?)" # SQLite connections aren't shareable across threads; uvicorn serves requests on # a thread pool, so each thread lazily opens (and keeps, for its lifetime) its own @@ -312,15 +351,55 @@ def put_revgeo(key: str, label: str | None) -> None: pass +# --- grade narratives -------------------------------------------------------- +# Written only by scripts/generate_narratives.py, read only by +# api/narrative_routes.py. Same token-as-validity rule as `derived`: a row whose +# stored token doesn't match the caller's is a miss, so a prompt or model change +# can never serve text written under the old contract. + +def get_narrative(cell_id: str, key: str, token: str) -> dict | None: + """The stored sentence for one cell/date, or None when absent or token-invalid. + + Returns the provenance alongside the text — the endpoint surfaces which model + wrote it and when, so a narrative that reads oddly can be traced to a specific + generation without guessing.""" + try: + with _conn() as conn: + if conn is None: + return None + row = conn.execute(_SQL_GET_NARRATIVE, (cell_id, key)).fetchone() + if row is None or row[0] != token: + return None + return {"text": row[1], "model": row[2], "generated_at": row[3]} + except Exception: # noqa: BLE001 + return None + + +def put_narrative(cell_id: str, key: str, token: str, text: str, model: str) -> None: + """Upsert one narrative. Overwriting in place is the freshness mechanism for + the current day — see narrator.narrative_token for why the token deliberately + doesn't move with the climate sync stamps.""" + try: + with _conn() as conn: + if conn is None: + return + conn.execute(_SQL_PUT_NARRATIVE, (cell_id, key, token, text, model, time.time())) + conn.commit() + except Exception: # noqa: BLE001 + pass + + def stats() -> dict: """Row counts + on-disk size, for the migrate script's summary output.""" - out = {"db_path": os.path.abspath(DB_PATH), "derived": 0, "revgeo": 0, "bytes": 0} + out = {"db_path": os.path.abspath(DB_PATH), "derived": 0, "revgeo": 0, + "narrative": 0, "bytes": 0} try: with _conn() as conn: if conn is None: return out out["derived"] = conn.execute("SELECT COUNT(*) FROM derived").fetchone()[0] out["revgeo"] = conn.execute("SELECT COUNT(*) FROM revgeo").fetchone()[0] + out["narrative"] = conn.execute("SELECT COUNT(*) FROM narrative").fetchone()[0] # Recent writes may still live in the WAL sidecar — count both files. out["bytes"] = sum(os.path.getsize(p) for p in (DB_PATH, DB_PATH + "-wal") if os.path.exists(p)) diff --git a/backend/scripts/generate_narratives.py b/backend/scripts/generate_narratives.py new file mode 100644 index 0000000..642faec --- /dev/null +++ b/backend/scripts/generate_narratives.py @@ -0,0 +1,187 @@ +#!/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()) diff --git a/backend/tests/api/test_narrative_routes.py b/backend/tests/api/test_narrative_routes.py new file mode 100644 index 0000000..328456c --- /dev/null +++ b/backend/tests/api/test_narrative_routes.py @@ -0,0 +1,109 @@ +"""/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 diff --git a/backend/tests/data/test_narrator.py b/backend/tests/data/test_narrator.py new file mode 100644 index 0000000..f701d87 --- /dev/null +++ b/backend/tests/data/test_narrator.py @@ -0,0 +1,269 @@ +"""Narrative generation: fact extraction, prompt building, and output sanitizing. + +Hermetic — no Ollama is contacted. ``generate`` is exercised through an injected +stub client, and every other function here is pure. +""" +import datetime + +import pytest + +from data import narrator + +TARGET = datetime.date(2026, 7, 25) + + +def _payload(**over): + """A /grade payload trimmed to what facts_from_grade reads.""" + day = { + "date": "2026-07-25", + "tmax": {"value": 72.2, "percentile": 47.8, "grade": "Normal", "class": "normal"}, + "tmin": {"value": 54.3, "percentile": 20.2, "grade": "Low", "class": "cold"}, + "feels": {"value": 53.1, "percentile": 27.9, "grade": "Below Normal", "class": "cool"}, + "precip": {"value": 0.0, "percentile": None, "grade": "Dry", "class": "dry"}, + } + day.update(over.pop("day", {})) + base = { + "place": "Ringaudai, Lithuania", + "recent": [ + {"date": "2026-07-24", "tmax": {"value": 60.9, "percentile": 4.0, + "grade": "Very Low", "class": "very-cold"}}, + day, + ], + } + base.update(over) + return base + + +class _Resp: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self._payload + + +class _Client: + """Stand-in for httpx.Client that records the request and replays a response.""" + + def __init__(self, response=None, raises=None): + self.response = response + self.raises = raises + self.calls = [] + + def post(self, url, json=None, timeout=None): + self.calls.append({"url": url, "json": json}) + if self.raises is not None: + raise self.raises + return _Resp(self.response) + + +# --- fact extraction --------------------------------------------------------- + +def test_facts_pick_the_target_day_not_a_neighbour(): + facts = narrator.facts_from_grade(_payload(), TARGET) + assert facts["date"] == "2026-07-25" + assert facts["place"] == "Ringaudai, Lithuania" + grades = {m["label"]: m["grade"] for m in facts["metrics"]} + # "Very Low" belongs to the 24th; picking it up would mean the wrong row. + assert grades == {"Daytime high": "Normal", "Overnight low": "Low", + "Feels-like": "Below Normal", "Rain": "Dry"} + + +def test_facts_never_carry_a_raw_measurement(): + """The safety property the whole design rests on: a model that is never given + a temperature cannot misreport one.""" + facts = narrator.facts_from_grade(_payload(), TARGET) + blob = repr(facts) + narrator.build_prompt(facts) + for reading in ("72.2", "54.3", "53.1", "60.9"): + assert reading not in blob + + +def test_facts_omit_metrics_the_day_does_not_have(): + facts = narrator.facts_from_grade( + _payload(day={"tmin": None, "feels": None, "precip": None}), TARGET) + assert [m["label"] for m in facts["metrics"]] == ["Daytime high"] + + +def test_facts_are_none_when_nothing_is_gradeable(): + empty = _payload(day={"tmax": None, "tmin": None, "feels": None, "precip": None}) + assert narrator.facts_from_grade(empty, TARGET) is None + + +def test_facts_are_none_when_the_day_is_absent(): + assert narrator.facts_from_grade(_payload(), datetime.date(2026, 8, 9)) is None + + +def test_facts_accept_a_dict_place_label(): + facts = narrator.facts_from_grade(_payload(place={"label": "Kaunas, Lithuania"}), TARGET) + assert facts["place"] == "Kaunas, Lithuania" + + +def test_an_explicit_place_overrides_the_reverse_geocoded_one(): + """A cell in a dense city reverse-geocodes to a neighbourhood; a caller that + knows it asked about Shanghai should not get a sentence about a subdistrict.""" + facts = narrator.facts_from_grade( + _payload(place="Nanjingdonglu Subdistrict"), TARGET, place="Shanghai, China") + assert facts["place"] == "Shanghai, China" + assert "Nanjingdonglu" not in narrator.build_prompt(facts) + + +def test_dry_day_states_the_tier_without_a_percentage(): + """Rain is ranked among rain days only, so a dry day has a tier and no + percentile — it must not render as a percentage that doesn't exist.""" + facts = narrator.facts_from_grade(_payload(), TARGET) + rain = next(m for m in facts["metrics"] if m["label"] == "Rain") + assert rain["pct"] is None + prompt = narrator.build_prompt(facts) + assert "Rain: dry — no measurable rain" in prompt + assert "None" not in prompt + assert "%" not in prompt.split("Rain:")[1] + + +# --- prompt ------------------------------------------------------------------ + +def test_prompt_is_deterministic_and_carries_every_fact(): + facts = narrator.facts_from_grade(_payload(), TARGET) + a, b = narrator.build_prompt(facts), narrator.build_prompt(facts) + assert a == b + assert "Place: Ringaudai, Lithuania" in a + assert "Date: 25 July" in a + assert ("Daytime high: warmer than 48% of days at this time of year here " + "(tier: Normal)") in a + assert ("Overnight low: warmer than 20% of nights at this time of year here " + "(tier: Low)") in a + + +def test_prompt_states_the_direction_so_a_tier_cannot_be_read_backwards(): + """The regression this guards: "Overnight low: 82nd percentile (High)" was + phrased by the model as a *cool* night — an inversion no output filter can + detect. The percentage must carry the direction itself.""" + warm_night = _payload(day={"tmin": {"value": 78.0, "percentile": 82.4, + "grade": "High", "class": "hot"}}) + prompt = narrator.build_prompt(narrator.facts_from_grade(warm_night, TARGET)) + assert "Overnight low: warmer than 82% of nights" in prompt + + +def test_rain_is_described_as_heavier_not_warmer(): + wet = _payload(day={"precip": {"value": 12.0, "percentile": 73.0, + "grade": "Heavy", "class": "wet-6"}}) + prompt = narrator.build_prompt(narrator.facts_from_grade(wet, TARGET)) + assert "Rain: heavier than 73% of rainy days" in prompt + + +@pytest.mark.parametrize("pct, shown", [(47.8, 48), (20.2, 20), (0.1, 1), (99.9, 99)]) +def test_prompt_percentages_match_the_ui_rounding_and_clamp(pct, shown): + """Mirrors grading.pct_ordinal — a narrative disagreeing with the number + printed next to it is the kind of thing users report as a bug.""" + day = _payload(day={"tmax": {"value": 1.0, "percentile": pct, + "grade": "Normal", "class": "normal"}}) + prompt = narrator.build_prompt(narrator.facts_from_grade(day, TARGET)) + assert f"Daytime high: warmer than {shown}% of days" in prompt + + +def test_prompt_omits_the_place_line_when_unknown(): + facts = narrator.facts_from_grade(_payload(place=None), TARGET) + assert "Place:" not in narrator.build_prompt(facts) + + +# --- sanitizing -------------------------------------------------------------- + +@pytest.mark.parametrize("raw, expected", [ + (' "A cool night for late July here." ', + "A cool night for late July here."), + ("Sure! Here's a sentence: A cool night for late July in this corner of Lithuania.", + "A cool night for late July in this corner of Lithuania."), + ("weighing the tiers A cool night for late July here.", + "A cool night for late July here."), + ("A cool night for\nlate July here.", + "A cool night for late July here."), + ("Okay, here is the summary: A cool night for late July here.", + "A cool night for late July here."), +]) +def test_sanitize_normalizes_common_model_habits(raw, expected): + assert narrator._sanitize(raw) == expected + + +def test_sanitize_keeps_a_colon_that_belongs_to_the_sentence(): + """Preamble stripping is anchored to known openers on purpose — "everything + before the first colon" would eat half of a perfectly good narrative.""" + raw = "One thing stands out: the night was colder than four in five late-July nights." + assert narrator._sanitize(raw) == raw + + +@pytest.mark.parametrize("raw", [ + "", + " ", + "Sure!", # too short to be a sentence + "The high reached 31°C, well above normal for July.", # a reading it was never given + "Rainfall hit 12 mm, far more than usual for the season.", # ditto + "Tomorrow will be even more unusual for this location.", # forecast + "Expect an unusually cold night for late July in this area.", # forecast + "A cold night by local standards, so wear an extra layer tonight.", # advice +]) +def test_sanitize_rejects_output_it_should_not_store(raw): + assert narrator._sanitize(raw) is None + + +def test_sanitize_truncates_a_long_answer_at_a_sentence_boundary(): + first = "A distinctly cool night for late July in this part of Lithuania. " + text = narrator._sanitize(first + "B" * 400) + assert text == first.strip() + assert len(text) <= narrator.MAX_CHARS + + +def test_sanitize_rejects_a_long_answer_with_nowhere_to_cut(): + assert narrator._sanitize("A" * 400) is None + + +# --- generate ---------------------------------------------------------------- + +def test_generate_returns_the_sanitized_sentence(): + facts = narrator.facts_from_grade(_payload(), TARGET) + client = _Client(response={"response": ' "A cool night for late July here." '}) + assert narrator.generate(facts, client=client) == "A cool night for late July here." + + +def test_generate_sends_a_bounded_non_thinking_request(): + """Reasoning tokens dominate cost for a one-sentence rewrite, and an unbounded + generation on a CPU fallback is how a batch job stops finishing.""" + facts = narrator.facts_from_grade(_payload(), TARGET) + client = _Client(response={"response": "A cool night for late July here."}) + narrator.generate(facts, client=client, model="test-model") + sent = client.calls[0]["json"] + assert sent["model"] == "test-model" + assert sent["stream"] is False + assert sent["think"] is False + assert sent["options"]["num_predict"] <= 128 + assert client.calls[0]["url"].endswith("/api/generate") + + +def test_generate_is_none_when_the_model_is_unreachable(): + facts = narrator.facts_from_grade(_payload(), TARGET) + client = _Client(raises=OSError("connection refused")) + assert narrator.generate(facts, client=client) is None + + +def test_generate_is_none_when_output_fails_the_sanitizer(): + facts = narrator.facts_from_grade(_payload(), TARGET) + client = _Client(response={"response": "The high hit 31°C today."}) + assert narrator.generate(facts, client=client) is None + + +def test_generate_is_none_on_an_empty_response(): + facts = narrator.facts_from_grade(_payload(), TARGET) + assert narrator.generate(facts, client=_Client(response={})) is None + + +# --- token ------------------------------------------------------------------- + +def test_token_tracks_the_version_and_the_model(): + base = narrator.narrative_token("qwen3:14b") + assert base != narrator.narrative_token("llama3:8b") + assert base.startswith(narrator.NARRATIVE_VER) + + +def test_key_is_the_iso_date(): + assert narrator.narrative_key(TARGET) == "2026-07-25" diff --git a/backend/web/app.py b/backend/web/app.py index cbec4ae..1353011 100644 --- a/backend/web/app.py +++ b/backend/web/app.py @@ -20,6 +20,7 @@ from fastapi.responses import JSONResponse, RedirectResponse from accounts import api_accounts from api import content_routes from api import internal_routes +from api import narrative_routes from core import audit from data import climate from notifications import digest @@ -897,6 +898,10 @@ app.include_router(v2, prefix=f"{BASE}/api/v2") # service (Stage 3) instead of anything in this process. See # backend/api/content_routes.py. app.include_router(content_routes.router, prefix=f"{BASE}/api/v2") +# Precomputed plain-English day summaries. Read-only: this router never calls the +# LLM, so mounting it adds no runtime dependency on the inference host — see +# backend/api/narrative_routes.py. +app.include_router(narrative_routes.router, prefix=f"{BASE}/api/v2") # Internal control surface for the thermograph-daemon Go binary (the gateway # bot + recurring jobs that used to run in _lifespan). Deliberately NOT under # BASE — the daemon hits THERMOGRAPH_API_BASE_INTERNAL directly, same posture