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
Co-authored-by: admin_emi <admin_emi@noreply.dev.jinemi.com> Co-committed-by: admin_emi <admin_emi@noreply.dev.jinemi.com>
269 lines
11 KiB
Python
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"
|