thermograph/backend/tests/data/test_narrator.py
admin_emi 33efe59111
All checks were successful
secrets-guard / encrypted (push) Successful in 8s
Build + push images (Forgejo registry) / build-push (frontend) (push) Successful in 14s
shell-lint / shellcheck (push) Successful in 19s
Deploy / deploy (frontend) (push) Successful in 24s
Build + push images (Forgejo registry) / build-push (backend) (push) Successful in 43s
Deploy / deploy (backend) (push) Successful in 1m7s
narrative: precomputed plain-English day summaries (#102)
Co-authored-by: admin_emi <admin_emi@noreply.dev.jinemi.com>
Co-committed-by: admin_emi <admin_emi@noreply.dev.jinemi.com>
2026-08-02 01:16:33 +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"