narrative: precomputed plain-English day summaries #102

Open
admin_emi wants to merge 1 commit from feat/grade-narrative into dev
Owner

Adds GET /api/v2/narrative — a one-sentence, plain-English summary of how unusual a given day was at a location, generated by a local LLM on the desktop and served from cache.

Shape

Generation and serving are fully decoupled:

  • scripts/generate_narratives.py (batch, runs wherever it can reach Ollama) reads graded days from the API, has the model phrase them, and writes rows.
  • api/narrative_routes.py (request path) reads one row and returns it. It never calls the model, and a test replaces narrator.generate with a bomb so it can't start.

So the public endpoint has no dependency on the inference host being reachable, powered on, or holding a GPU. A miss is 200 with narrative: null, status: "pending" — not a 404; "no sentence yet" is a normal state and a frontend should render the page without it.

Grounding

The model is given tier labels and percentile directions only — never a temperature, never a rainfall figure. It cannot misreport a reading it was never shown. _sanitize then rejects output carrying units, a forecast, or advice, strips lead-ins/<think> blocks, and caps length at a sentence boundary. Rejecting is always safe: nothing is stored and the endpoint serves null.

Fact lines state direction explicitly:

Overnight low: warmer than 82% of nights at this time of year here (tier: High)

An earlier revision wrote 82nd percentile (High) and the model read it backwards, describing a warm night as a cool one — a factual inversion no output filter can detect, since nothing about the sentence looks wrong. Percentages mirror grading.pct_ordinal's rounding and 1..99 clamp so a narrative can't disagree with the number printed beside it.

A consequence worth naming: narratives are unit-free, which sidesteps F_COUNTRIES entirely and keeps one cached row per (cell, date) rather than a °C and a °F variant.

Storage

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. Same token-as-validity rule as derived: the token is NARRATIVE_VER:model, so a prompt or model change orphans old text instead of serving it. It deliberately does not track the climate sync stamps; that would invalidate every row every ~4h, which isn't affordable against one desktop GPU and buys nothing.

Verified

  • Full suite: 477 passed, 8 skipped.
  • End-to-end against live Ollama: prompt → generation → sanitize → store → read back through the endpoint.
  • The inversion above is fixed — three repeated Shanghai runs all render the High overnight low correctly.

Operational note

The desktop's RX 7800 XT is currently absent from the PCI bus, so Ollama has run 100% CPU since a Jul 20 reboot. Generation still works (~7-20s/city, so a ~1000-city pass is hours rather than minutes); it speeds up on its own when the card returns, with no code change. Nothing in this PR depends on the GPU.

Adds `GET /api/v2/narrative` — a one-sentence, plain-English summary of how unusual a given day was at a location, generated by a local LLM on the desktop and served from cache. ## Shape Generation and serving are fully decoupled: - **`scripts/generate_narratives.py`** (batch, runs wherever it can reach Ollama) reads graded days from the API, has the model phrase them, and writes rows. - **`api/narrative_routes.py`** (request path) reads one row and returns it. It never calls the model, and a test replaces `narrator.generate` with a bomb so it can't start. So the public endpoint has no dependency on the inference host being reachable, powered on, or holding a GPU. A miss is 200 with `narrative: null`, `status: "pending"` — not a 404; "no sentence yet" is a normal state and a frontend should render the page without it. ## Grounding The model is given tier labels and percentile directions **only** — never a temperature, never a rainfall figure. It cannot misreport a reading it was never shown. `_sanitize` then rejects output carrying units, a forecast, or advice, strips lead-ins/`<think>` blocks, and caps length at a sentence boundary. Rejecting is always safe: nothing is stored and the endpoint serves `null`. Fact lines state direction explicitly: ``` Overnight low: warmer than 82% of nights at this time of year here (tier: High) ``` An earlier revision wrote `82nd percentile (High)` and the model read it backwards, describing a warm night as a cool one — a factual inversion no output filter can detect, since nothing about the sentence looks wrong. Percentages mirror `grading.pct_ordinal`'s rounding and 1..99 clamp so a narrative can't disagree with the number printed beside it. A consequence worth naming: narratives are **unit-free**, which sidesteps `F_COUNTRIES` entirely and keeps one cached row per (cell, date) rather than a °C and a °F variant. ## Storage 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. Same token-as-validity rule as `derived`: the token is `NARRATIVE_VER:model`, so a prompt or model change orphans old text instead of serving it. It deliberately does *not* track the climate sync stamps; that would invalidate every row every ~4h, which isn't affordable against one desktop GPU and buys nothing. ## Verified - Full suite: **477 passed, 8 skipped**. - End-to-end against live Ollama: prompt → generation → sanitize → store → read back through the endpoint. - The inversion above is fixed — three repeated Shanghai runs all render the High overnight low correctly. ## Operational note The desktop's RX 7800 XT is currently **absent from the PCI bus**, so Ollama has run 100% CPU since a Jul 20 reboot. Generation still works (~7-20s/city, so a ~1000-city pass is hours rather than minutes); it speeds up on its own when the card returns, with no code change. Nothing in this PR depends on the GPU.
admin_emi added 1 commit 2026-07-25 21:54:42 +00:00
narrative: precomputed plain-English day summaries
All checks were successful
PR build (required check) / changes (pull_request) Successful in 8s
secrets-guard / encrypted (pull_request) Successful in 7s
shell-lint / shellcheck (pull_request) Successful in 8s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 1m6s
PR build (required check) / gate (pull_request) Successful in 2s
60dd179b19
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.
All checks were successful
PR build (required check) / changes (pull_request) Successful in 8s
secrets-guard / encrypted (pull_request) Successful in 7s
shell-lint / shellcheck (pull_request) Successful in 8s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 1m6s
PR build (required check) / gate (pull_request) Successful in 2s
Required
Details
This pull request is blocked because it's outdated.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/grade-narrative:feat/grade-narrative
git checkout feat/grade-narrative
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Jinemi/thermograph#102
No description provided.