thermograph/backend/tests/data/test_narrator.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

269 lines
11 KiB
Python

"""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."),
("<think>weighing the tiers</think> 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"