From 629df652c4b46ff45df6bc49bfbc1e0a29aff627 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 08:33:59 +0000 Subject: [PATCH 1/7] rules-v2: midnight ends, tanker hours, bilingual completions - "from 2pm until midnight on 11 September" was read as 11 September 00:00, before the works began (244089, 245031). A 00:00 end is now read against the "from" before its "until": a start earlier that day gives D+1 00:00, a start the day before keeps D 00:00, and no start (or an estimated completion time of midnight) abstains. - An "until" in a sentence about an alternative water supply, tankers or bottled water gave the tanker's hours as the works' end (239696). It is no longer a candidate; alone, the rules abstain. - The Irish-completion check abstained per block, so an English completion below an Irish block was never read (244925, 129 cases). An Irish completion now abstains only when the next block with text has no English completion. - "16:59pm" (245025): an hour 13-23 with "pm" reads as the 24-hour time. Shadow eval against the LLM's 10,870 hash-stable records: coverage 91.7% -> 93.3%, 17 disagreements, 15 of them the LLM's midnight reading. RULES_VERSION bumps, so CI re-answers every rules-v1 case. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168hLW2X3mkV3Jhm26LJSwQ --- src/uisce/rules.py | 65 ++++++++++++++++++++--- tests/test_rules.py | 123 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 180 insertions(+), 8 deletions(-) diff --git a/src/uisce/rules.py b/src/uisce/rules.py index 72b9a5b..0057f77 100644 --- a/src/uisce/rules.py +++ b/src/uisce/rules.py @@ -29,11 +29,11 @@ """ import re -from datetime import date, datetime +from datetime import date, datetime, timedelta from uisce.config import describes_recurrence -RULES_VERSION = "rules-v1" +RULES_VERSION = "rules-v2" MONTH_NUMBERS = {name: i + 1 for i, name in enumerate(( "january", "february", "march", "april", "may", "june", @@ -101,6 +101,18 @@ rf"\bestimated\s+(?:completion|restoration)\s+time\s+of\s+{_TIME}\s+on\s+(?:the\s+)?{_DATE}", _FLAGS) +# The start a midnight end is read against: "from 2pm until midnight on 11 +# September" ends as 12 September begins (cases 244089, 245031). +FROM_BEFORE = re.compile( + rf"\bfrom\s+{_TIME}(?:\s+on\s+(?:the\s+)?{_DATE})?\s*$", _FLAGS) + +# "An alternative water supply will be available ... from 4:30pm until 11:59pm +# on 22 July" gives the tanker's hours, not the works' end (case 239696). +ALTERNATIVE_SUPPLY = re.compile( + r"alternative\s+(?:drinking\s+)?water|tanker|bowser|water\s+station" + r"|bottled\s+water|water\s+bottles|standpipe", re.IGNORECASE) +SENTENCE_END = re.compile(r"(?]+>") @@ -119,6 +131,9 @@ def _match_time(m): return f"{hour:02d}:{minute:02d}" hour, minute = int(m.group("h")), int(m.group("min") or 0) meridiem = (m.group("mer") or m.group("gmer")).lower() + if 13 <= hour <= 23 and meridiem in ("pm", "ppm") and minute <= 59: + # "16:59pm" (case 245025): a 24-hour time with a redundant meridiem + return f"{hour:02d}:{minute:02d}" if not 1 <= hour <= 12 or minute > 59: return None hour %= 12 @@ -166,6 +181,35 @@ def _match_date(m, start_date): return resolved.isoformat() if resolved else None +def _midnight_end_date(before, end_date, start_date): + """The date a 00:00 end falls on, or None when the text leaves it open. + + "until midnight on D" alone is either end of D. A start earlier on D makes + it the end of D, so the instant is D+1 00:00; a start the day before makes + it the start of D ("from 9pm on 21 May until 12am on 22 May").""" + m = FROM_BEFORE.search(before) + if not m or _match_time(m) in (None, "00:00"): + return None + from_date = _match_date(m, start_date) if m.group("d1") or m.group("d2") else end_date + if from_date is None: + return None + end = date.fromisoformat(end_date) + if from_date == end_date: + return (end + timedelta(days=1)).isoformat() + if from_date == (end - timedelta(days=1)).isoformat(): + return end_date + return None + + +def _about_alternative_supply(before): + starts = [m.end() for m in SENTENCE_END.finditer(before)] + return bool(ALTERNATIVE_SUPPLY.search(before[starts[-1] if starts else 0:])) + + +def _body(header, block): + return block[header.end():] if header else block + + def _segments(text): """The description as (header_match_or_None, block_text) pairs, newest first — update blocks are prepended by the feed. Text before the first @@ -209,7 +253,7 @@ def extract(start_date, description): # Completion beats schedule, always (the prompt's step 1). The date and # time come from the header of the block stating the completion; a # completion phrase in a headerless or unparseable block abstains. - for header, block in segments: + for i, (header, block) in enumerate(segments): if COMPLETION.search(block): if header is None: return None @@ -222,9 +266,13 @@ def extract(start_date, description): f"rules: completion phrase under update header " f"{header.group(0).strip()!r}") if IRISH_COMPLETION.search(block): - # An Irish completion with no English phrase: the schedule in the - # English half below is stale, and the Irish forms need the model. - return None + # The English block of the same update follows the Irish one, and + # its header is the one to read: the Irish header is mistyped + # often (232673, 236544, 244190). With no English completion + # there, the schedule below is stale and the Irish needs the model. + following = next((b for h, b in segments[i + 1:] if _body(h, b).strip()), "") + if not COMPLETION.search(following): + return None # No completion anywhere: consider a scheduled end, unless the notice is # one of the shapes settled as model territory. @@ -248,8 +296,13 @@ def extract(start_date, description): candidates = {} for m in scheduled_matches + list(ESTIMATED_END.finditer(newest)): + before = newest[:m.start()] + if _about_alternative_supply(before): + continue local_time = _match_time(m) local_date = _match_date(m, start_date) + if local_time == "00:00" and local_date: + local_date = _midnight_end_date(before, local_date, start_date) if not local_time or not local_date: return None candidates[(local_date, local_time)] = m.group(0).strip() diff --git a/tests/test_rules.py b/tests/test_rules.py index c75d04d..e2aaf82 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -21,7 +21,7 @@ def test_rules_version_is_stamped(): - assert RULES_VERSION == "rules-v1" + assert RULES_VERSION == "rules-v2" class TestCompletionUpdates: @@ -74,6 +74,75 @@ def test_headerless_completion_abstains(self): def test_header_without_time_abstains(self): assert extract(START, "**Update am 15/07/2026** Works are now complete.") is None + def test_twenty_four_hour_header_with_a_stray_pm(self): + # case 245025 + result = extract(START, "**Update 16:59pm 23/09/2026** Works are now complete.") + assert (result["local_date"], result["local_time"]) == ("2026-09-23", "16:59") + + def test_a_stray_am_on_a_twenty_four_hour_time_abstains(self): + assert extract(START, "**Update 14:13am 06/06/2026** Works are now complete.") is None + + +IRISH_COMPLETION_BLOCK = ( + "Tá críoch leis an obair se o anois, agus beidh an soláthar uisce ar ais chomh " + "luath agus is féidir.** Seans go mbeidh cur isteach ar an soláthar uisce i " + "Shanbally. Beidh an obair seo ar siúl ó 2in go dtí 6:15in ar an 22ú Meán Fómhair. " +) +ENGLISH_ORIGINAL = ( + "Mains repair works may cause supply disruptions to Shanbally and surrounding " + "areas in Co. Galway. Works are scheduled to take place from 2pm until 6:15pm on " + "22 September." + BOILERPLATE +) + + +class TestBilingualCompletions: + def test_english_completion_below_an_irish_one_is_read(self): + # case 244925 + result = extract("2026-09-22T13:18:00+00:00", + "**11:31rn 23/09/2026 - " + IRISH_COMPLETION_BLOCK + + "**Update 11:31am 23/09/2026** Works are now complete and " + "supply should have returned to all affected areas. " + + ENGLISH_ORIGINAL) + assert result["end_source"] == "completion_update" + assert (result["local_date"], result["local_time"]) == ("2026-09-23", "11:31") + + def test_the_english_header_wins_over_an_unparseable_irish_one(self): + # case 244845: "9:38 rn" with a space does not parse + result = extract("2026-09-21T12:23:25+00:00", + "** 9:38 rn 22/09/2026 - " + IRISH_COMPLETION_BLOCK + + "**Update 9:38am 22/09/2026** Works are now complete. " + + ENGLISH_ORIGINAL) + assert (result["local_date"], result["local_time"]) == ("2026-09-22", "09:38") + + def test_the_english_header_wins_over_a_mistyped_irish_one(self): + # case 232673: the Irish header says morning, the English one evening + result = extract("2026-05-06T11:12:29+00:00", + "**8:17rn 07/05/2026** " + IRISH_COMPLETION_BLOCK + + "**Update 8:17pm 07/05/2026** Works are now complete. " + + ENGLISH_ORIGINAL) + assert (result["local_date"], result["local_time"]) == ("2026-05-07", "20:17") + + def test_an_empty_duplicate_header_between_them_is_skipped(self): + # case 243212 + result = extract("2026-09-01T14:52:15+00:00", + "**10:44rn 2/09/2026 - " + IRISH_COMPLETION_BLOCK + + "**Update 10:44am 2/09/2026** " + + "**Update 10:44am 2/09/2026** Works are now complete. " + + ENGLISH_ORIGINAL) + assert (result["local_date"], result["local_time"]) == ("2026-09-02", "10:44") + + def test_an_irish_completion_over_a_stale_english_schedule_abstains(self): + assert extract("2026-09-22T13:18:00+00:00", + "**11:31rn 23/09/2026 - " + IRISH_COMPLETION_BLOCK + + ENGLISH_ORIGINAL) is None + + def test_an_irish_completion_over_an_english_update_without_one_abstains(self): + assert extract("2026-09-22T13:18:00+00:00", + "**11:31rn 23/09/2026 - " + IRISH_COMPLETION_BLOCK + + "**Update 11:31am 23/09/2026** Crews remain on site. " + + "**Update 9am 23/09/2026** Works are now complete. " + + ENGLISH_ORIGINAL) is None + class TestScheduledEnds: def test_until_time_on_date(self): @@ -103,7 +172,7 @@ def test_time_words(self): assert result["local_time"] == "12:00" result = extract(START, "Works are scheduled to take place from 4pm until " "midnight on 30 April.") - assert (result["local_date"], result["local_time"]) == ("2026-04-30", "00:00") + assert (result["local_date"], result["local_time"]) == ("2026-05-01", "00:00") def test_24_hour_time(self): result = extract(START, "Works are scheduled to take place until 17:00 on 3 July.") @@ -128,6 +197,40 @@ def test_newest_update_block_owns_the_schedule(self): "scheduled to take place until 2pm on 09 July.") assert (result["local_date"], result["local_time"]) == ("2026-07-10", "21:00") + def test_same_day_midnight_is_the_end_of_that_day(self): + # case 244089: midnight read as the start of 11 September ended the + # works fourteen hours before they began + result = extract("2026-09-11T12:58:09+00:00", + "Works are scheduled to take place from 2pm until midnight " + "on 11 September." + BOILERPLATE) + assert (result["local_date"], result["local_time"]) == ("2026-09-12", "00:00") + + def test_same_day_midnight_rolls_over_a_month_end(self): + # case 245031, spelled as 12am + result = extract("2026-09-28T13:41:25+00:00", + "Works are scheduled to take place from 8pm until 12am on " + "30 September." + BOILERPLATE) + assert (result["local_date"], result["local_time"]) == ("2026-10-01", "00:00") + + def test_same_day_midnight_with_the_date_on_both_ends(self): + result = extract(START, "Works are scheduled to take place from 2pm on 11 July " + "until midnight on 11 July." + BOILERPLATE) + assert (result["local_date"], result["local_time"]) == ("2026-07-12", "00:00") + + def test_midnight_after_a_start_the_day_before_stays_on_its_day(self): + # case 233690 + result = extract("2026-05-21T10:00:00+00:00", + "Works are scheduled to take place from 9pm on 21 May until " + "12am on 22 May." + BOILERPLATE) + assert (result["local_date"], result["local_time"]) == ("2026-05-22", "00:00") + + def test_tanker_hours_beside_the_works_end_do_not_compete_with_it(self): + result = extract(START, "Works are scheduled to take place until 6pm on 22 July. " + "An alternative water supply will be available at the " + "school car park from 4:30pm until 11:59pm on 22 July." + + BOILERPLATE) + assert (result["local_date"], result["local_time"]) == ("2026-07-22", "18:00") + def test_conflicting_schedules_in_one_block_abstain(self): # The original notice carries no header of its own, so a revising # update and the stale text below it share a segment; two different @@ -169,6 +272,22 @@ class TestAbstentions: # Irish-only completion: the English half below is stale "Meastar go mbeidh críoch leis an obair seo ag a 9in ar an 1ú Bealtaine. " "Works are scheduled to take place until 9pm on 1 May.", + # "until midnight on D" with no start: the start or the end of D + # (cases 232957, 232219) + "Works are scheduled to take place until midnight on 9 May." + BOILERPLATE, + "Works are scheduled to take place until Midnight on 30 April." + BOILERPLATE, + "Works are scheduled to take place from until midnight on 23 July.", + "Works now have an estimated completion time of midnight on 29 July.", + # a start two days earlier: midnight on the last day is still either end + "Works are scheduled to take place from 1:25pm on 17 July until midnight " + "on 19 July.", + # the hours an alternative supply is open, not the works' end (case 239696) + "A pump failure may cause supply disruptions to Dundalk. Crews are working to " + "restore supply as soon as possible. An alternative water supply will be " + "available at the school car park in Kilcurly from 4:30pm until 11:59pm on " + "22 July. Please boil the water before use." + BOILERPLATE, + "Water tankers are available at Sladagh until 1pm on 28 May.", + "Bottled water will be available at Woodview Housing Estate until 10pm on 18 July.", # two different schedules in one block "Works are scheduled to take place until 2pm on 28 April. Works are " "scheduled to take place until 5pm on 29 April.", From 24644321fde13f96716092c0270dffe6a5b9c261 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 08:33:59 +0000 Subject: [PATCH 2/7] Refuse unreadable LLM values; warn on stale records - parse_response rejects a local_time, local_date or window value that is not HH:MM (00-23) or an ISO date, so the case counts as failed and is retried. "24:00", "5pm" or "28/04/2026" in the JSONL used to raise in build and fail every CI build after it. - build leaves an already-written unreadable record out with a ::warning::, and uisce-infer treats it as absent so the case is inferred again. - The inferred_cases rebuild runs in one transaction: DROP and CREATE autocommitted, so a raise after them left the table empty. - A published record uisce-infer would redo (description changed, or extractor or prompt retired) is still published but now listed in a ::warning::, decided by the same is_current the inference run uses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168hLW2X3mkV3Jhm26LJSwQ --- src/uisce/build.py | 35 +++++++++++- src/uisce/inference.py | 50 ++++++++++++++--- tests/test_build.py | 120 ++++++++++++++++++++++++++++++++++++++++ tests/test_inference.py | 56 +++++++++++++++++++ 4 files changed, 251 insertions(+), 10 deletions(-) diff --git a/src/uisce/build.py b/src/uisce/build.py index 641e8e0..4c2a4d8 100644 --- a/src/uisce/build.py +++ b/src/uisce/build.py @@ -4,6 +4,7 @@ from datetime import date, datetime, time, timezone from uisce.config import DB_PATH, DUBLIN, JSONL_PATH, RECURRING +from uisce.inference import is_current, record_state, unreadable_fields NO_END_SIGNAL_SOURCES = {"not_found", "lifted_immediate"} @@ -159,6 +160,7 @@ def unquotable_windows(conn): UNQUOTABLE_SHOWN = 15 +STALE_SHOWN = 50 def latest_per_case(records): @@ -191,6 +193,19 @@ def count_never_inferred(conn): ).fetchone() +def stale_cases(conn, latest): + """(case_id, status) for each published record `uisce-infer` would redo: + its description has changed since, or its extractor or prompt is retired. + It is still the best answer there is until the LLM residue run replaces it.""" + by_case = {r["case_id"]: r for r in latest} + return [ + (case_id, status) + for case_id, description, status in conn.execute( + "SELECT id, description, status FROM cases WHERE description IS NOT NULL") + if case_id in by_case and not is_current(record_state(by_case[case_id]), description) + ] + + def check_cases_cover(conn, case_ids): known_ids = {row[0] for row in conn.execute("SELECT id FROM cases")} missing = sorted(case_ids - known_ids) @@ -208,7 +223,9 @@ def run(): records = [json.loads(line) for line in f if line.strip()] first_start_dates = first_start_date_per_case(records) - latest = list(latest_per_case(records)) + unreadable = [(r["case_id"], unreadable_fields(r)) for r in records] + unreadable = [(case_id, fields) for case_id, fields in unreadable if fields] + latest = list(latest_per_case(r for r in records if not unreadable_fields(r))) rows = [ ( @@ -240,6 +257,9 @@ def run(): with sqlite3.connect(DB_PATH) as conn: conn.execute("PRAGMA foreign_keys = ON") check_cases_cover(conn, {r["case_id"] for r in latest}) + # DROP and CREATE autocommit outside an explicit transaction, so a + # failure after them would leave the site an empty table. + conn.execute("BEGIN") create_table(conn) conn.executemany( """ @@ -253,9 +273,14 @@ def run(): rows, ) never_inferred, never_inferred_open = count_never_inferred(conn) + stale = stale_cases(conn, latest) unquotable, inferred_first_dates = unquotable_windows(conn) print(f"Upserted {len(rows)} rows into inferred_cases") + if unreadable: + print(f"::warning::{len(unreadable)} record(s) in {JSONL_PATH} carry a value build " + "cannot read and are left out, so uisce-infer tries their cases again: " + + ", ".join(f"{case_id} ({', '.join(fields)})" for case_id, fields in unreadable)) if unquotable: print(f"{len(unquotable)} recurring window(s) with a value not in the notice's own text:") for case_id, missing in unquotable[:UNQUOTABLE_SHOWN]: @@ -270,3 +295,11 @@ def run(): f"{never_inferred} case(s) have no inference yet ({never_inferred_open} open) — " "open ones accrue to now on the site until uisce-infer runs" ) + if stale: + shown = ", ".join(str(case_id) for case_id, _ in stale[:STALE_SHOWN]) + more = f" and {len(stale) - STALE_SHOWN} more" if len(stale) > STALE_SHOWN else "" + print( + f"::warning::{len(stale)} case(s) ({sum(s == 'Open' for _, s in stale)} open) " + "publish a record uisce-infer would redo, its description or extractor having " + f"changed; each stands until a run replaces it: {shown}{more}" + ) diff --git a/src/uisce/inference.py b/src/uisce/inference.py index 31dee7f..ac596c3 100644 --- a/src/uisce/inference.py +++ b/src/uisce/inference.py @@ -1,8 +1,9 @@ import argparse import hashlib import json +import re import sqlite3 -from datetime import datetime, timezone +from datetime import date, datetime, timezone from uisce.config import DB_PATH, JSONL_PATH, make_session from uisce.rules import RULES_VERSION @@ -134,11 +135,42 @@ def hash_description(description): return hashlib.sha256(description.encode("utf-8")).hexdigest() +_CLOCK = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d") +_ISO_DATE = re.compile(r"\d{4}-\d{2}-\d{2}") + + +def _is_iso_date(value): + try: + return bool(_ISO_DATE.fullmatch(value)) and bool(date.fromisoformat(value)) + except ValueError: + return False + + +def unreadable_fields(result): + """The fields build.py could not read, in a model reply or a JSONL record. + + The prompt asks for "HH:MM" and "YYYY-MM-DD", and nothing made the model + comply: a "24:00", "5pm" or "28/04/2026" written to the JSONL raised in + build.py and failed every CI build after it.""" + fields = [f for f in ("local_time", "window_open", "window_close") + if result.get(f) is not None + and not (isinstance(result[f], str) and _CLOCK.fullmatch(result[f]))] + fields += [f for f in ("local_date", "window_first_date") + if result.get(f) is not None + and not (isinstance(result[f], str) and _is_iso_date(result[f]))] + return fields + + +def record_state(record): + return (record["description_hash"], record.get("prompt_version"), record.get("model")) + + def get_last_hash_by_case_id(jsonl_path): """Latest (description_hash, prompt_version, model) per case. A case is up to date only when the hash and version still match AND its record came from a current extractor, so bumping PROMPT_VERSION re-infers the whole corpus - while bumping RULES_VERSION re-runs only the rules-produced cases.""" + while bumping RULES_VERSION re-runs only the rules-produced cases. A record + build.py cannot read counts as absent, so its case is tried again.""" if not jsonl_path.exists(): return {} latest = {} @@ -147,17 +179,15 @@ def get_last_hash_by_case_id(jsonl_path): if not line.strip(): continue record = json.loads(line) + if unreadable_fields(record): + continue current = latest.get(record["case_id"]) if current is None or record["inferred_at"] > current["inferred_at"]: latest[record["case_id"]] = record - return { - case_id: (record["description_hash"], record.get("prompt_version"), - record.get("model")) - for case_id, record in latest.items() - } + return {case_id: record_state(record) for case_id, record in latest.items()} -def _is_current(state, description): +def is_current(state, description): if state is None: return False digest, version, model = state @@ -175,7 +205,7 @@ def get_cases_needing_inference(db_path, last_state_by_case_id, force=False): cases = [ row for row in cur - if force or not _is_current(last_state_by_case_id.get(row["id"]), row["description"]) + if force or not is_current(last_state_by_case_id.get(row["id"]), row["description"]) ] conn.close() return cases @@ -219,6 +249,8 @@ def parse_response(response_text): result = json.loads(response_text) if not isinstance(result, dict): raise ValueError(f"Expected a JSON object, got: {response_text[:80]!r}") + if unreadable := unreadable_fields(result): + raise ValueError("unreadable " + ", ".join(f"{f} {result[f]!r}" for f in unreadable)) return result diff --git a/tests/test_build.py b/tests/test_build.py index a9e4749..7f27e1a 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -1,7 +1,10 @@ +import json import sqlite3 import pytest +from conftest import make_cases_table +from uisce import build, inference from uisce.build import ( check_cases_cover, compute_notice_to_end_seconds, @@ -204,3 +207,120 @@ def test_a_case_claiming_no_recurrence_is_not_checked(self): conn = self._db(self.QUOTABLE, open_t="03:00") conn.execute("UPDATE inferred_cases SET end_recurrence = 'none'") assert unquotable_windows(conn) == ([], 0) + + +START = "2026-09-22T13:18:00+00:00" +# case 244925 before and after its completion update: the rules abstain on +# neither now, so the stale tests use a description they cannot read +BOILERPLATE = (" We recommend that you allow 3-4 hours after the estimated restoration " + "time for your supply to fully return. Please take note of the following " + "reference number: GAL00121134. LA01") +SCHEDULED = ("Mains repair works may cause supply disruptions to Shanbally and surrounding " + "areas in Co. Galway. Works are scheduled to take place from 2pm until " + "6:15pm on 22 September." + BOILERPLATE) +COMPLETED = ("**11:31rn 23/09/2026 - Tá críoch leis an obair se o anois, agus beidh an " + "soláthar uisce ar ais chomh luath agus is féidir.** Seans go mbeidh cur " + "isteach ar an soláthar uisce i Shanbally. **Update 11:31am 23/09/2026** " + "Works are now complete and supply should have returned to all affected " + "areas. " + SCHEDULED) +INVESTIGATING = "We are investigating reports of supply disruptions to Shanbally." + + +class TestRun: + def _wire(self, tmp_path, monkeypatch, description, records): + db, jsonl = tmp_path / "uisce.db", tmp_path / "inferred.jsonl" + conn = make_cases_table(sqlite3.connect(db)) + conn.execute("INSERT INTO cases (id, description, start_date, status) " + "VALUES (1, ?, ?, 'Open')", (description, START)) + conn.commit() + conn.close() + jsonl.write_text("".join(json.dumps(r) + "\n" for r in records)) + for module in (build, inference): + monkeypatch.setattr(module, "DB_PATH", db) + monkeypatch.setattr(module, "JSONL_PATH", jsonl) + return db, jsonl + + def _record(self, description=SCHEDULED, inferred_at="2026-09-22T20:00:00+00:00", + **result): + return inference.build_record( + 1, description, START, + {"end_source": "scheduled_end_with_time", "local_date": "2026-09-22", + "local_time": "18:15"} | result, + inferred_at=inferred_at) + + def _published(self, db): + return sqlite3.connect(db).execute( + "SELECT end_inferred_at, end_source, end_local_time FROM inferred_cases" + ).fetchall() + + def test_a_value_build_cannot_read_is_left_out_with_a_warning( + self, tmp_path, monkeypatch, capsys + ): + db, _ = self._wire(tmp_path, monkeypatch, SCHEDULED, + [self._record(local_time="24:00")]) + build.run() + assert self._published(db) == [] + out = capsys.readouterr().out + assert "::warning::1 record(s)" in out and "1 (local_time)" in out + + def test_an_unreadable_window_falls_back_to_the_previous_record( + self, tmp_path, monkeypatch + ): + db, _ = self._wire(tmp_path, monkeypatch, SCHEDULED, [ + self._record(), + self._record(inferred_at="2026-09-23T00:00:00+00:00", recurrence="daily", + window_open="7:00", window_close="22:00", + window_first_date="2026-09-22"), + ]) + build.run() + assert self._published(db) == [ + ("2026-09-22T20:00:00+00:00", "scheduled_end_with_time", "18:15")] + + def test_a_failure_part_way_leaves_the_previous_table_standing( + self, tmp_path, monkeypatch + ): + db, _ = self._wire(tmp_path, monkeypatch, SCHEDULED, [self._record()]) + build.run() + + def failing(conn): + raise RuntimeError("after the inserts") + + monkeypatch.setattr(build, "unquotable_windows", failing) + with pytest.raises(RuntimeError): + build.run() + assert len(self._published(db)) == 1 + + def test_a_record_for_an_older_description_is_published_and_warned( + self, tmp_path, monkeypatch, capsys + ): + db, _ = self._wire(tmp_path, monkeypatch, INVESTIGATING, [self._record()]) + build.run() + assert self._published(db) == [ + ("2026-09-22T20:00:00+00:00", "scheduled_end_with_time", "18:15")] + out = capsys.readouterr().out + assert "::warning::1 case(s) (1 open) publish a record uisce-infer would redo" in out + assert out.rstrip().endswith(": 1") + + def test_a_record_from_a_retired_extractor_is_warned_too( + self, tmp_path, monkeypatch, capsys + ): + self._wire(tmp_path, monkeypatch, SCHEDULED, + [self._record() | {"model": "rules-v1"}]) + build.run() + assert "::warning::1 case(s)" in capsys.readouterr().out + + def test_a_current_record_raises_no_warning(self, tmp_path, monkeypatch, capsys): + self._wire(tmp_path, monkeypatch, SCHEDULED, + [self._record() | {"model": inference.MODEL_NAME}]) + build.run() + assert "::warning::" not in capsys.readouterr().out + + def test_a_bilingual_completion_reaches_the_table_from_a_rules_only_run( + self, tmp_path, monkeypatch, capsys + ): + db, jsonl = self._wire(tmp_path, monkeypatch, COMPLETED, + [self._record() | {"model": "rules-v1"}]) + inference.run(["--rules-only"]) + build.run() + assert self._published(db)[0][1:] == ("completion_update", "11:31") + assert "::warning::" not in capsys.readouterr().out diff --git a/tests/test_inference.py b/tests/test_inference.py index e077873..3de635f 100644 --- a/tests/test_inference.py +++ b/tests/test_inference.py @@ -277,6 +277,24 @@ def failing_llm(session, start_date, description): assert "1 failed" in capsys.readouterr().out assert jsonl.read_text() == "" + def test_an_unreadable_llm_value_fails_the_case_and_writes_nothing( + self, tmp_path, monkeypatch, capsys + ): + jsonl = self._wire( + tmp_path, monkeypatch, [(1, "2026-05-18T08:00:00+00:00", self.UNTEMPLATED)] + ) + monkeypatch.setattr(inference, "make_session", lambda: object()) + monkeypatch.setattr( + inference, "call_llm", + lambda session, start_date, description: json.dumps({ + "end_source": "scheduled_end_with_time", "local_date": "2026-05-18", + "local_time": "5pm"}), + ) + + assert inference.run([]) == 1 + assert "1 failed" in capsys.readouterr().out + assert jsonl.read_text() == "" + def test_a_clean_run_exits_zero(self, tmp_path, monkeypatch): self._wire(tmp_path, monkeypatch, [(1, "2026-05-18T08:00:00+00:00", self.TEMPLATED)]) assert inference.run([]) == 0 @@ -350,3 +368,41 @@ def test_rejects_non_object_json(self, bad): def test_rejects_markdown_fences(self): with pytest.raises(json.JSONDecodeError): parse_response('```json\n{"end_source": "not_found"}\n```') + + def test_accepts_well_formed_values_and_nulls(self): + reply = {"end_source": "scheduled_end_with_time", "local_date": "2026-05-18", + "local_time": "23:59", "recurrence": "daily", "window_open": "22:00", + "window_close": "07:00", "window_first_date": "2026-05-10"} + assert parse_response(json.dumps(reply)) == reply + assert parse_response(json.dumps({"end_source": "not_found", "local_date": None, + "local_time": None}))["local_date"] is None + + @pytest.mark.parametrize("field, value", [ + ("local_time", "24:00"), + ("local_time", "5pm"), + ("local_time", "9:30"), + ("local_date", "28/04/2026"), + ("local_date", "2026-02-30"), + ("local_date", "20260428"), + ("window_open", "7:00"), + ("window_close", "24:00"), + ("window_first_date", "9 July"), + ]) + def test_rejects_a_value_build_cannot_read(self, field, value): + with pytest.raises(ValueError, match=field): + parse_response(json.dumps({"end_source": "scheduled_end_with_time", + "local_date": "2026-05-18", "local_time": "17:00", + field: value})) + + +def test_a_malformed_record_already_written_is_inferred_again(tmp_path): + good = build_record(1, "desc", "2026-06-01T00:00:00+00:00", + {"end_source": "not_found"}, inferred_at="2026-06-01T00:00:00+00:00") + bad = build_record(1, "desc", "2026-06-01T00:00:00+00:00", + {"end_source": "scheduled_end_with_time", "local_date": "2026-06-01", + "local_time": "24:00"}, inferred_at="2026-06-02T00:00:00+00:00") + path = tmp_path / "inferred.jsonl" + write_jsonl(path, [bad]) + assert get_last_hash_by_case_id(path) == {} + write_jsonl(path, [good, bad]) + assert get_last_hash_by_case_id(path)[1][0] == good["description_hash"] From 5be4c8c0d2d90b4a06f631e69ce0f33438c68b8b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 08:33:59 +0000 Subject: [PATCH 3/7] Record rules-v2 and the LLM midnight follow-up The rules note gets a dated 2026-09-24 section with the three rules changes, the shadow eval, the labelled replays and the rules-only run against the 2026-09-23 release. The roadmap gets the prompt line that makes the LLM read "until midnight on D" as the start of D, with the 16 affected published records, a proposed wording and the order of work, waiting on the owner. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168hLW2X3mkV3Jhm26LJSwQ --- CLAUDE.md | 1 + notes/roadmap.md | 27 ++++++++ notes/rules-vs-llm-end-times.md | 119 ++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 77e781c..d472543 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,7 @@ with the evidence that closed them. | The design layer (tokens, base CSS, row/bar/card, JS helpers) is shared with esb and lifts via `../statusui`, a **uv git dependency pinned in `uv.lock`** — edit upstream, then `../statusui/rollout.sh` bumps all three sites. Vendored copies were tried first and drifted within a day. `site.css` and the inline blocks are this site's own. | frontend-notes.md — "the vendored copy became a pinned uv git dependency" (2026-08-20); statusui's README for what is shared | | End-time extraction is **rules first, LLM fallback**: `rules.py` answers the templated ~93% (99.99% corpus agreement, 0 wrong emissions on the labelled rounds, 0.6s vs ~11 GPU-hours) and abstains to the LLM for recurring windows, lifts, Irish and everything ambiguous. Rules may only emit `completion_update`/`scheduled_end_with_time`; re-measure with `uv run uisce-eval-rules-shadow`. | rules-vs-llm-end-times.md (2026-08-21) | | `towns` and `resolved` are the county view's data and ship per county in `t/.js`; `data.js` carries months, open and top only (212 KB against 955 KB). `INITIAL_BUDGET` is 512 KB for index.html plus data.js, **warned, never failed**. Folding the breakdown into the history shard was rejected. | frontend-notes.md - "The county's own data left data.js" (2026-09-05) | +| rules-v2: "until midnight on D" after a start earlier on D ends at D+1 00:00, after a start the day before at D 00:00, and with no start the rules abstain. An `until` in a sentence about an alternative supply is never the works' end. An English completion below an Irish one is read; Irish alone still abstains. The LLM prompt still reads midnight as the start of D (roadmap). | rules-vs-llm-end-times.md - "rules-v2" (2026-09-24) | | CI runs `uisce-infer --rules-only` every data build and commits the JSONL to `main`; the LLM residue is run by hand. JSONL stays in this repo (`merge=union`) — a `uisce-data` repo and a release asset were both rejected. | rules-vs-llm-end-times.md — "CI runs the rules half" (2026-08-21) | | The fourteen towns named for their county (Carlow, Sligo, Wexford ...) render one row under the county's, `Sligo` + `town`, reachable from the box. The index always carried them; the `name|county` dedup that hid them was fixed upstream in statusui and the pin moved in the same PR | frontend-notes.md - "Two edges, both left as they are", 2026-09-03 amendment | diff --git a/notes/roadmap.md b/notes/roadmap.md index 9adc879..81d1832 100644 --- a/notes/roadmap.md +++ b/notes/roadmap.md @@ -44,6 +44,33 @@ Started 2026-09-05, from the follow-ups the nine PRs of the missing-features sur ## Decisions waiting on the owner +- **LLM prompt reads "until midnight on D" as the start of D.** Waiting on the owner, because + a prompt change needs its own care. The prompt line `- 12 noon (12:00pm) is 12:00. 12 + midnight (12:00am) is 00:00.` (`inference.py`, PROMPT) makes gemma make the mistake + rules-v2 fixed on 2026-09-24 (rules-vs-llm-end-times.md, "rules-v2"). On the 2026-09-23 + release, 16 published latest LLM records read a same-day "from X until midnight on D" as D + 00:00, all closed cases: 231693-231700 (eight pins of one event), 231812, 231853, 232292, + 232318, 238423, 240587, 240993, and 244597 (stale hash; "until midnight 23 September" with + no "on"). Five more have no start and were read as D 00:00, where the rules now abstain: + 232219, 232957, 233026, 234332, 239927; 232957 and 234332 then end before their own + publication. Proposed replacement for the line: + + > - 12 noon (12:00pm) is 12:00. Midnight (12am) is 00:00 on the day it begins, so "until + > midnight on 11 September" ends at 00:00 on 12 September: report local_date 2026-09-12. + > Only when the works start on the day before ("from 9pm on 21 May until 12am on 22 May") + > is the end the date as written, 2026-05-22 at 00:00. + + Order of work: (1) change the line and bump `PROMPT_VERSION` to 4 in one commit. (2) Run + `uv run uisce-infer` by hand with LM Studio. The bump makes every record stale, rules ones + included, so rules-v2 answers first everything it covers, replacing gemma records for the + 10,137 cases measured on 2026-09-24 (17 of them with a different answer), and the LLM gets + the residue: 754 cases, 612 distinct descriptions on the 2026-09-23 release, about 40 + minutes at the corpus run's ~3.6 s a case. (3) Run the end-time eval, `uv run + uisce-eval-replay --csv ` on all three labelled rounds, before committing the JSONL. + Round 2 labels 231853 correct at 30 April 00:00, so a pv4 that reads it right scores a miss + there; adjudicate that row in end-time-eval.md, never in the CSV. The same bump could say + that an alternative supply's hours are not the works' end (239696-239698 read the tanker's + 23:59 as the end), measured on its own. - **`IGNORE_BOIL_NOTICES`.** Recommendation is to leave it off (boil-notices.md, re-measured 2026-09-05): the two accruing notices and the one paired one are the live warnings the health marker exists for. The cost of leaving it off is 13 of 17 issue events excluded as stale diff --git a/notes/rules-vs-llm-end-times.md b/notes/rules-vs-llm-end-times.md index 09c07ec..b555c8c 100644 --- a/notes/rules-vs-llm-end-times.md +++ b/notes/rules-vs-llm-end-times.md @@ -171,3 +171,122 @@ Where the JSONL lives was the real question — esb and lifts keep collected dat - **A `uisce-data` repo.** The esb/lifts split exists because the Pi is the writer and the raw logs are the source of truth. Here the source of truth (the DB) is already off-repo as a release, and the JSONL is a derived cache of it. A data repo would add a checkout to every workflow and put the JSONL↔DB freshness guard (above) across two repos, for no gain in history or size — the JSONL costs ~4 MB of packed repo after 39 commits. - **The JSONL as a release asset beside `uisce.db`.** Two writers on one blob means last upload wins and the other's records are silently lost; git at least conflicts loudly, and `merge=union` resolves it. + +## rules-v2: midnight, tanker hours, bilingual completions (2026-09-24) + +A review of the 2026-09-23 release found three wrong readings in rules-v1, each emitting or +withholding an answer the site then published. All three are fixed in `rules-v2`, with a +fourth, smaller widening that fell out of checking the cases the review named. + +**1. "until midnight on D" after a start on D.** "from 2pm until midnight on 11 September" +(244089) was read as 11 September 00:00, fourteen hours before the works began and before +the notice was published, so build.py nulled the span and the site charged an imputed one. +245031 ("from 8pm until midnight on 29 September") ended twenty hours before its own start. +Now a 00:00 end (midnight, 12am) is read against the `from` directly before its `until`: + +- a start earlier the same day (no date of its own, or D): D+1 00:00; +- a start the day before ("from 9pm on 21 May until 12am on 22 May", 233690): D 00:00, as + before; +- no start, a start two or more days earlier, or `estimated completion time of midnight`: + abstain. "until midnight on 9 May" alone is either end of the day. + +Rejected: always D+1 (turns the 21-to-22 May shape into a 27-hour outage); always the date +as written (the bug); reading the no-start form as the end of D. The last is probably right +(232957 and 234332 were published the afternoon of the D they name, so D 00:00 is already in +their past) but it is a reading, and reading is the model's job. + +**2. Tanker hours are not the works' end.** "An alternative water supply will be available +at ... from 4:30pm until 11:59pm on 22 July" was emitted as the end of a pump failure with no +stated end (239696, and pins 239697/239698); the case closed on 25 July. An `until` in +a sentence about an alternative supply (alternative water, tanker, bowser, water station, +bottled water, standpipe) is no longer a candidate end. Where it was the only one the rules +abstain; where it had been competing with the works' own end, which made v1 abstain on two +candidates, the works' end is now read. Rejected: abstaining on any notice that mentions an +alternative supply, which would drop 62 scheduled answers that are right today. + +**3. An English completion below an Irish one.** The Irish-completion check ran per block, so +a bilingual update, Irish block first ("11:31rn 23/09/2026 - Tá críoch leis an obair...") and +the English "Update 11:31am 23/09/2026 Works are now complete" below it, abstained on the +Irish block before reaching the English one (244925). The corpus has 129 of these. Now an +Irish completion abstains only when the next block with any text in it carries no English +completion; otherwise the English block's header is read. Rejected: requiring the two +headers to agree. 121 of the 129 do, but the Irish header is the unreliable one (232673 says +`8:17rn` against `8:17pm`, 236544 names March for a June notice, 244926 `4:24rn` against +`4:24pm`) and 3 more do not parse at all (`9:38 rn` with a space), so agreement would throw +away the good header to protect the bad one. 15 Irish-only completions still abstain. + +**4. A 24-hour time with a stray `pm`.** `**Update 16:59pm 23/09/2026**` (245025) was +unparseable, so its completion abstained and the stale schedule stood. An hour 13-23 +followed by `pm` now reads as the 24-hour time; `14:13am` still abstains. 33 such tokens in +the corpus, 31 with `pm`; the LLM read every one of those it answered as the 24-hour time. + +### Measured on the 2026-09-23 release + +`uv run uisce-eval-rules-shadow` against the release DB and the committed JSONL: rules-v2 +answers 12,822 of 13,556 compared cases (94.6%), agreeing on 12,803 (99.85%), 0 on +LLM-recurring cases. That comparison now includes 2,686 rules-v1 records; against the LLM's +own records alone (10,870 hash-stable gemma cases): + +| | rules-v1 | rules-v2 | +|---|---|---| +| answered | 9,970 (91.7%) | 10,137 (93.3%) | +| agree | 9,969 | 10,120 | +| disagree | 1 | 17 | + +What moved on those 10,870, by change: + +| change | effect | vs the LLM | +|---|---|---| +| midnight, same-day start | 15 ends move to D+1 00:00 | 15 disagree: the LLM is wrong (8 are the pins of one event, 231693-231700) | +| midnight, no usable start | 5 abstain (232219, 232957, 233026, 234332, 239927) | were agreeing on D 00:00 | +| tanker hours | 3 abstain (239696-239698); 29 newly answered | the 3 were agreeing on the tanker's 23:59; the 29 all agree | +| bilingual | 125 newly answered completions | 124 agree; 244190 disagrees | +| 24-hour `pm` | 18 completions, 3 schedules newly answered | all agree | + +The 17 disagreements, read in full: the 15 midnight rows; 237463, the LLM's digit +transposition from the rules-v1 section above; and 244190, where the Irish header says +`3:39 in` and the English one `3:49pm`. The rules read the English header, as they do +everywhere; the text cannot say which is true. + +Labelled rounds (`uisce-eval-replay --extractor rules`): round 1 73/73 (64.0% coverage, +unchanged); the 2026-08-21 round 113/113 (94.2%, was 110/110); round 2 111/112 (93.3%, was +110/110). The one miss is 231853, "from 4pm until midnight on 30 April", labelled correct at +30 April 00:00. That instant is 15h49m before the notice was published at 15:49 on 30 April; +the label accepted the model's reading and is exactly what fix 1 corrects. The CSV is +recorded data and stays as it is. + +`uisce-infer --rules-only` against a copy of the release DB and a copy of the JSONL: 2,716 +cases selected (the RULES_VERSION bump makes every rules-v1 record stale), 2,695 answered, +21 left for the LLM. Latest record per case against the committed JSONL: + +- 2,683 rules-v1 records restamped `rules-v2` with the identical answer; +- 2 changed: 244089 and 245031, a day later at 00:00; +- 4 stale records replaced: 244845, 244925 and 245025 go from their original schedule to a + completion (22/09 09:38, 23/09 11:31, 23/09 16:59), 244538 from a superseded schedule to + the current one; +- 6 never-inferred cases answered (4 scheduled, 2 completion); +- rules-v2 emitted 1,773 `completion_update` and 922 `scheduled_end_with_time`, nothing else. + +The first CI build after this merges appends those 2,695 lines (the JSONL goes from 19.8 MB +to 21.1 MB). No historical gemma record changes: the no-backfill rule stands, so the 15 LLM +midnight rows and the 3 tanker rows are still published until the prompt is fixed (roadmap, +"LLM prompt reads 'until midnight on D' as the start of D"). + +### Build no longer hides a stale record or crashes on a bad one (same day) + +Two build.py changes shipped with rules-v2, because the review found both by following the +cases above: + +- A case whose newest record `uisce-infer` would redo (description changed, or extractor or + prompt retired, decided by the same `is_current` the inference run uses) still publishes + that record, the best there is, but build now prints a `::warning::` listing them. Before + rules-v2, 244925's completion sat behind a record for its pre-completion text with nothing + said. On the rules-only run above, 6 remain (5 open): 243084 (its `start_date` is now NULL + in the DB, so no year resolves), 244237/244239/244243 (recurring), 244597 ("until midnight + 23 September" with no "on") and 244720 (two notices concatenated). +- A model value build cannot read ("24:00", "5pm", "28/04/2026", a window "7:00") is refused + by `parse_response`, so the case counts as failed and is retried; one already in the JSONL + is left out with a `::warning::` and its case inferred again. Before, any of them raised in + build and failed every CI build after it, and a raise after `DROP TABLE` left + `inferred_cases` empty because the DROP had autocommitted. The rebuild is now one + transaction. The committed JSONL has 0 such records in 33,974. From b3e593d8b86063a92d0a1e5f26786f4397900c1a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 08:46:38 +0000 Subject: [PATCH 4/7] Point the prompt v4 roadmap entry at its issue Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168hLW2X3mkV3Jhm26LJSwQ --- notes/roadmap.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/notes/roadmap.md b/notes/roadmap.md index 81d1832..55bf652 100644 --- a/notes/roadmap.md +++ b/notes/roadmap.md @@ -44,8 +44,8 @@ Started 2026-09-05, from the follow-ups the nine PRs of the missing-features sur ## Decisions waiting on the owner -- **LLM prompt reads "until midnight on D" as the start of D.** Waiting on the owner, because - a prompt change needs its own care. The prompt line `- 12 noon (12:00pm) is 12:00. 12 +- **LLM prompt reads "until midnight on D" as the start of D.** Deferred by the owner on + 2026-09-24 and tracked in issue #102; a prompt change needs its own care. The prompt line `- 12 noon (12:00pm) is 12:00. 12 midnight (12:00am) is 00:00.` (`inference.py`, PROMPT) makes gemma make the mistake rules-v2 fixed on 2026-09-24 (rules-vs-llm-end-times.md, "rules-v2"). On the 2026-09-23 release, 16 published latest LLM records read a same-day "from X until midnight on D" as D From 606695760a496bbe8985f5a4b29dff6aa9d8bbf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 10:29:59 +0000 Subject: [PATCH 5/7] Keep rules-v2 on the abstaining side where review found it guessing From a code review of this PR against current main: - An alternative-supply `until` was skipped, which could leave the stale original schedule as the only candidate under a revising update. It now makes the rules abstain; ALTERNATIVE_SUPPLY has word boundaries, so "wastewater station" is not a water station. - "until midnight on D" after a start on D-1 is read as D 00:00 only for a start from 18:00; a daytime start abstains. - An Irish completion reads the next block only when it is the same day's update, the Irish date taken from its opening text. - A model value that cannot be read is normalised where it has one meaning and otherwise stored as not_found with the value in its notes, instead of failing and being re-sent at temperature 0 forever. - readable_latest is the one newest-record reading, shared by inference, build and the shadow eval; unreadable newest records are redone and warned about only until they are; the stale warning reuses inference's own selection and no longer counts open by the feed's status. Re-measured: rules-v2 answers 10,082 of 10,870 LLM records (was 10,137), agreeing on 10,065, the same 17 disagreements; labelled rounds 73/73, 110/111, 113/113 as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168hLW2X3mkV3Jhm26LJSwQ --- notes/rules-vs-llm-end-times.md | 35 ++++++++++++++++++ src/uisce/build.py | 55 ++++++++++++---------------- src/uisce/eval_rules_shadow.py | 6 ++-- src/uisce/inference.py | 63 +++++++++++++++++++++++++-------- src/uisce/rules.py | 43 +++++++++++++++------- tests/test_build.py | 25 +++++++++---- tests/test_inference.py | 41 ++++++++++++++------- tests/test_rules.py | 34 ++++++++++++++---- 8 files changed, 214 insertions(+), 88 deletions(-) diff --git a/notes/rules-vs-llm-end-times.md b/notes/rules-vs-llm-end-times.md index b555c8c..eba9f94 100644 --- a/notes/rules-vs-llm-end-times.md +++ b/notes/rules-vs-llm-end-times.md @@ -290,3 +290,38 @@ cases above: build and failed every CI build after it, and a raise after `DROP TABLE` left `inferred_cases` empty because the DROP had autocommitted. The rebuild is now one transaction. The committed JSONL has 0 such records in 33,974. + +### Amended after code review, same day + +A code review of the PR found three places where rules-v2 answered what rules-v1 left to the +model, against the abstain-on-ambiguity rule, and changed them before merge: + +- **Tanker hours abstain; they are never dropped.** Skipping an alternative-supply `until` + could leave one candidate where the skipped one had made two: a revising update ("an + alternative supply is available until 9pm") over the stale original ("from 9am until 5pm") + emitted the stale 17:00. An `until` in an alternative-supply sentence now makes the rules + abstain, as v1 effectively did, so the 29 newly answered above go back to the model; the + 3 tanker emissions (239696-239698) still abstain. `ALTERNATIVE_SUPPLY` also gained word + boundaries, so "wastewater station" is not a water station. +- **Midnight after a start the day before needs a late start.** "from 9am on 21 July until + midnight on 22 July" is 15h or 39h; only a start from 18:00 ("from 9pm on 21 May") fixes D + 00:00. Earlier starts abstain. +- **The Irish completion's English twin must be the same day's update.** The next block with + text could be an older update, whose header was then read. The two blocks' dates must now + match; the Irish block's date is read from its opening text, so a time too garbled for the + header pattern (244845, "9:38 rn") still passes. The rejected "headers must agree" above + compared times as well; this compares dates only. + +Re-measured against the same 10,870 hash-stable gemma records: rules-v2 answers 10,082 +(92.8%, was 10,137), agrees on 10,065, and the 17 disagreements are the same 17. Against the +rules-v1 records it disagrees on 2 (244089 and 245031, the midnight fix) and abstains on 2. +The labelled rounds replay unchanged: 73/73, 110/111 (231853 as above) and 113/113. + +On the build side the review changed two things. A model value that cannot be read is no +longer a failure to retry, because at temperature 0 a retry returns the same reply; the +safe near-misses are normalised ("9:30" is "09:30", "24:00" is 00:00 of the next day, a +window closing "24:00" closes "00:00") and anything else is stored as `not_found` with the +value in its notes. The newest-record reading is one function, `readable_latest`, shared by +`uisce-infer`, `uisce-build-inferred` and the shadow eval; a case whose newest record is +unreadable is redone and warned about until it is, and the stale warning asks inference's +own selection instead of re-implementing it. It no longer counts "open" by the feed's status. diff --git a/src/uisce/build.py b/src/uisce/build.py index 4c2a4d8..3150aa1 100644 --- a/src/uisce/build.py +++ b/src/uisce/build.py @@ -4,7 +4,7 @@ from datetime import date, datetime, time, timezone from uisce.config import DB_PATH, DUBLIN, JSONL_PATH, RECURRING -from uisce.inference import is_current, record_state, unreadable_fields +from uisce.inference import get_cases_needing_inference, readable_latest, record_state NO_END_SIGNAL_SOURCES = {"not_found", "lifted_immediate"} @@ -163,15 +163,6 @@ def unquotable_windows(conn): STALE_SHOWN = 50 -def latest_per_case(records): - latest = {} - for record in records: - current = latest.get(record["case_id"]) - if current is None or record["inferred_at"] > current["inferred_at"]: - latest[record["case_id"]] = record - return latest.values() - - def first_start_date_per_case(records): earliest = {} for record in records: @@ -193,17 +184,14 @@ def count_never_inferred(conn): ).fetchone() -def stale_cases(conn, latest): - """(case_id, status) for each published record `uisce-infer` would redo: - its description has changed since, or its extractor or prompt is retired. - It is still the best answer there is until the LLM residue run replaces it.""" - by_case = {r["case_id"]: r for r in latest} - return [ - (case_id, status) - for case_id, description, status in conn.execute( - "SELECT id, description, status FROM cases WHERE description IS NOT NULL") - if case_id in by_case and not is_current(record_state(by_case[case_id]), description) - ] +def stale_cases(latest): + """Case ids whose published record `uisce-infer` would redo: its description + has changed since, or its extractor or prompt is retired. Asked of inference's + own selection, so the warning and the redo cannot disagree. The record is + still the best answer there is until a run replaces it.""" + states = {case_id: record_state(r) for case_id, r in latest.items()} + return sorted(row["id"] for row in get_cases_needing_inference(DB_PATH, states) + if row["id"] in latest) def check_cases_cover(conn, case_ids): @@ -223,9 +211,8 @@ def run(): records = [json.loads(line) for line in f if line.strip()] first_start_dates = first_start_date_per_case(records) - unreadable = [(r["case_id"], unreadable_fields(r)) for r in records] - unreadable = [(case_id, fields) for case_id, fields in unreadable if fields] - latest = list(latest_per_case(r for r in records if not unreadable_fields(r))) + latest_by_case, unreadable = readable_latest(records) + latest = list(latest_by_case.values()) rows = [ ( @@ -243,7 +230,7 @@ def run(): first_start_dates[r["case_id"]], r["end_source"], r["local_date"], r["local_time"] ), # .get(), not [], because a resumable v3 corpus run leaves this file - # holding a mix of v2 and v3 records for a long time and latest_per_case + # holding a mix of v2 and v3 records for a long time and readable_latest # returns either. A v2 record must project as NULLs, not a KeyError — # that is what keeps the site correct mid-migration. r.get("recurrence"), @@ -273,14 +260,16 @@ def run(): rows, ) never_inferred, never_inferred_open = count_never_inferred(conn) - stale = stale_cases(conn, latest) unquotable, inferred_first_dates = unquotable_windows(conn) + stale = stale_cases(latest_by_case) print(f"Upserted {len(rows)} rows into inferred_cases") if unreadable: - print(f"::warning::{len(unreadable)} record(s) in {JSONL_PATH} carry a value build " - "cannot read and are left out, so uisce-infer tries their cases again: " - + ", ".join(f"{case_id} ({', '.join(fields)})" for case_id, fields in unreadable)) + shown = ", ".join(str(r["case_id"]) for r in unreadable[:STALE_SHOWN]) + more = f" and {len(unreadable) - STALE_SHOWN} more" if len(unreadable) > STALE_SHOWN else "" + print(f"::warning::{len(unreadable)} case(s) have a newest record in {JSONL_PATH} " + "that build cannot read; each publishes its previous record, if any, until " + f"uisce-infer redoes it: {shown}{more}") if unquotable: print(f"{len(unquotable)} recurring window(s) with a value not in the notice's own text:") for case_id, missing in unquotable[:UNQUOTABLE_SHOWN]: @@ -296,10 +285,10 @@ def run(): "open ones accrue to now on the site until uisce-infer runs" ) if stale: - shown = ", ".join(str(case_id) for case_id, _ in stale[:STALE_SHOWN]) + shown = ", ".join(str(case_id) for case_id in stale[:STALE_SHOWN]) more = f" and {len(stale) - STALE_SHOWN} more" if len(stale) > STALE_SHOWN else "" print( - f"::warning::{len(stale)} case(s) ({sum(s == 'Open' for _, s in stale)} open) " - "publish a record uisce-infer would redo, its description or extractor having " - f"changed; each stands until a run replaces it: {shown}{more}" + f"::warning::{len(stale)} case(s) publish a record uisce-infer would redo, " + f"its description or extractor having changed; each stands until a run " + f"replaces it: {shown}{more}" ) diff --git a/src/uisce/eval_rules_shadow.py b/src/uisce/eval_rules_shadow.py index b2165b3..28f7b92 100644 --- a/src/uisce/eval_rules_shadow.py +++ b/src/uisce/eval_rules_shadow.py @@ -22,11 +22,10 @@ import time from datetime import date -from uisce.build import latest_per_case from uisce.config import DB_PATH, JSONL_PATH from uisce.eval_end_time import EVAL_DIR from uisce.eval_replay import normalise_time -from uisce.inference import hash_description +from uisce.inference import hash_description, readable_latest from uisce.rules import RULES_VERSION, extract SHADOW_FIELDNAMES = [ @@ -61,11 +60,12 @@ def compare(llm_record, rules_result): def run(argv=None): - records = latest_per_case( + records, _ = readable_latest( json.loads(line) for line in open(JSONL_PATH) if line.strip() ) + records = records.values() by_case = {record["case_id"]: record for record in records} conn = sqlite3.connect(DB_PATH) diff --git a/src/uisce/inference.py b/src/uisce/inference.py index ac596c3..587ac6f 100644 --- a/src/uisce/inference.py +++ b/src/uisce/inference.py @@ -3,7 +3,7 @@ import json import re import sqlite3 -from datetime import date, datetime, timezone +from datetime import date, datetime, timedelta, timezone from uisce.config import DB_PATH, JSONL_PATH, make_session from uisce.rules import RULES_VERSION @@ -165,26 +165,33 @@ def record_state(record): return (record["description_hash"], record.get("prompt_version"), record.get("model")) +def readable_latest(records): + """({case_id: its latest readable record}, [unreadable records newer than + that]). The one reading of the JSONL that inference, build.py and the shadow + eval share, so what is redone, what is published and what is compared agree.""" + latest, bad = {}, {} + for record in records: + pick = bad if unreadable_fields(record) else latest + current = pick.get(record["case_id"]) + if current is None or record["inferred_at"] > current["inferred_at"]: + pick[record["case_id"]] = record + newer = [r for case_id, r in bad.items() + if case_id not in latest or r["inferred_at"] > latest[case_id]["inferred_at"]] + return latest, newer + + def get_last_hash_by_case_id(jsonl_path): """Latest (description_hash, prompt_version, model) per case. A case is up to date only when the hash and version still match AND its record came from a current extractor, so bumping PROMPT_VERSION re-infers the whole corpus - while bumping RULES_VERSION re-runs only the rules-produced cases. A record - build.py cannot read counts as absent, so its case is tried again.""" + while bumping RULES_VERSION re-runs only the rules-produced cases. A case + whose newest record build.py cannot read is left out, so it is redone.""" if not jsonl_path.exists(): return {} - latest = {} with open(jsonl_path) as f: - for line in f: - if not line.strip(): - continue - record = json.loads(line) - if unreadable_fields(record): - continue - current = latest.get(record["case_id"]) - if current is None or record["inferred_at"] > current["inferred_at"]: - latest[record["case_id"]] = record - return {case_id: record_state(record) for case_id, record in latest.items()} + latest, newer = readable_latest(json.loads(line) for line in f if line.strip()) + redo = {r["case_id"] for r in newer} + return {case_id: record_state(r) for case_id, r in latest.items() if case_id not in redo} def is_current(state, description): @@ -245,12 +252,38 @@ def call_llm(session, start_date, description): return choice["message"]["content"] +def _normalise(result): + """Rewrite the model's near-misses that have one meaning: "9:00" is + "09:00", and "24:00" is 00:00 at the end of the day it names.""" + for field in ("local_time", "window_open", "window_close"): + value = result.get(field) + if isinstance(value, str) and re.fullmatch(r"\d:\d\d", value): + result[field] = "0" + value + if result.get("window_close") == "24:00": + result["window_close"] = "00:00" + if result.get("local_time") == "24:00" and _is_iso_date(result.get("local_date") or ""): + result["local_date"] = (date.fromisoformat(result["local_date"]) + + timedelta(days=1)).isoformat() + result["local_time"] = "00:00" + return result + + def parse_response(response_text): + """The model's answer, or a not_found record naming what could not be read. + Not a failure: at temperature 0 a retry returns the same reply, so a raise + would leave the case re-sent, and uninferred, on every run.""" result = json.loads(response_text) if not isinstance(result, dict): raise ValueError(f"Expected a JSON object, got: {response_text[:80]!r}") + result = _normalise(result) if unreadable := unreadable_fields(result): - raise ValueError("unreadable " + ", ".join(f"{f} {result[f]!r}" for f in unreadable)) + return { + "notes": "unreadable model reply: " + + ", ".join(f"{f} {result[f]!r}" for f in unreadable), + "end_source": "not_found", "local_date": None, "local_time": None, + "recurrence": "none", "window_open": None, "window_close": None, + "window_first_date": None, + } return result diff --git a/src/uisce/rules.py b/src/uisce/rules.py index 0057f77..97ee3f7 100644 --- a/src/uisce/rules.py +++ b/src/uisce/rules.py @@ -109,8 +109,12 @@ # "An alternative water supply will be available ... from 4:30pm until 11:59pm # on 22 July" gives the tanker's hours, not the works' end (case 239696). ALTERNATIVE_SUPPLY = re.compile( - r"alternative\s+(?:drinking\s+)?water|tanker|bowser|water\s+station" - r"|bottled\s+water|water\s+bottles|standpipe", re.IGNORECASE) + r"\b(?:alternative\s+(?:drinking\s+)?water|tankers?|bowsers?|water\s+stations?" + r"|bottled\s+water|water\s+bottles|standpipes?)\b", re.IGNORECASE) +LEADING_DATE = re.compile(rf"\W*[^.]{{0,20}}?{_DATE}", _FLAGS) +# a start the day before only fixes "until midnight on D" as the start of D when +# it is late: "from 9pm on 21 May"; "from 9am on 21 July" could be either +LATE_START = "18:00" SENTENCE_END = re.compile(r"(?]+>") @@ -185,10 +189,11 @@ def _midnight_end_date(before, end_date, start_date): """The date a 00:00 end falls on, or None when the text leaves it open. "until midnight on D" alone is either end of D. A start earlier on D makes - it the end of D, so the instant is D+1 00:00; a start the day before makes - it the start of D ("from 9pm on 21 May until 12am on 22 May").""" + it the end of D, so the instant is D+1 00:00; a late-evening start the day + before makes it the start of D ("from 9pm on 21 May until 12am on 22 May").""" m = FROM_BEFORE.search(before) - if not m or _match_time(m) in (None, "00:00"): + from_time = m and _match_time(m) + if not from_time or from_time == "00:00": return None from_date = _match_date(m, start_date) if m.group("d1") or m.group("d2") else end_date if from_date is None: @@ -196,11 +201,18 @@ def _midnight_end_date(before, end_date, start_date): end = date.fromisoformat(end_date) if from_date == end_date: return (end + timedelta(days=1)).isoformat() - if from_date == (end - timedelta(days=1)).isoformat(): + if from_date == (end - timedelta(days=1)).isoformat() and from_time >= LATE_START: return end_date return None +def _leading_date(block, start_date): + """The date an update block opens with, read even when its time is too + garbled for UPDATE_HEADER ("** 9:38 rn 22/09/2026", case 244845).""" + m = LEADING_DATE.match(block) + return _match_date(m, start_date) if m else None + + def _about_alternative_supply(before): starts = [m.end() for m in SENTENCE_END.finditer(before)] return bool(ALTERNATIVE_SUPPLY.search(before[starts[-1] if starts else 0:])) @@ -267,11 +279,15 @@ def extract(start_date, description): f"{header.group(0).strip()!r}") if IRISH_COMPLETION.search(block): # The English block of the same update follows the Irish one, and - # its header is the one to read: the Irish header is mistyped - # often (232673, 236544, 244190). With no English completion - # there, the schedule below is stale and the Irish needs the model. - following = next((b for h, b in segments[i + 1:] if _body(h, b).strip()), "") - if not COMPLETION.search(following): + # its header is the one to read: the Irish header's time is mistyped + # often (232673, 236544, 244190). It must be the same day's update, + # not an older one; otherwise the Irish needs the model. + following_header, following = next( + ((h, b) for h, b in segments[i + 1:] if _body(h, b).strip()), (None, "")) + irish_date = _leading_date(block, start_date) + same_day = (following_header is not None and irish_date is not None + and irish_date == _match_date(following_header, start_date)) + if not (same_day and COMPLETION.search(following)): return None # No completion anywhere: consider a scheduled end, unless the notice is @@ -297,8 +313,11 @@ def extract(start_date, description): candidates = {} for m in scheduled_matches + list(ESTIMATED_END.finditer(newest)): before = newest[:m.start()] + # The tanker's hours are never the works' end, and dropping them could + # leave one candidate where two would have abstained (a revising update + # over the original schedule), so the model reads these. if _about_alternative_supply(before): - continue + return None local_time = _match_time(m) local_date = _match_date(m, start_date) if local_time == "00:00" and local_date: diff --git a/tests/test_build.py b/tests/test_build.py index 7f27e1a..f887041 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -11,10 +11,10 @@ count_never_inferred, date_forms, first_start_date_per_case, - latest_per_case, time_forms, unquotable_windows, ) +from uisce.inference import readable_latest class TestComputeDurationSeconds: @@ -69,14 +69,16 @@ def _record(case_id, inferred_at, start_date="2026-06-01T00:00:00+00:00"): return {"case_id": case_id, "inferred_at": inferred_at, "start_date": start_date} -def test_latest_per_case_keeps_newest_record(): +def test_readable_latest_keeps_newest_record(): records = [ _record(1, "2026-06-01T00:00:00+00:00"), _record(1, "2026-07-01T00:00:00+00:00"), _record(2, "2026-06-15T00:00:00+00:00"), ] - latest = {r["case_id"]: r["inferred_at"] for r in latest_per_case(records)} - assert latest == {1: "2026-07-01T00:00:00+00:00", 2: "2026-06-15T00:00:00+00:00"} + latest, unreadable = readable_latest(records) + assert {c: r["inferred_at"] for c, r in latest.items()} == { + 1: "2026-07-01T00:00:00+00:00", 2: "2026-06-15T00:00:00+00:00"} + assert unreadable == [] def test_first_start_date_per_case_pins_earliest_run(): @@ -261,7 +263,18 @@ def test_a_value_build_cannot_read_is_left_out_with_a_warning( build.run() assert self._published(db) == [] out = capsys.readouterr().out - assert "::warning::1 record(s)" in out and "1 (local_time)" in out + assert "::warning::1 case(s) have a newest record" in out and out.count(": 1\n") + + def test_an_unreadable_record_superseded_by_a_readable_one_is_not_warned( + self, tmp_path, monkeypatch, capsys + ): + self._wire(tmp_path, monkeypatch, SCHEDULED, [ + self._record(local_time="24:00") | {"model": inference.MODEL_NAME}, + self._record(inferred_at="2026-09-23T00:00:00+00:00") + | {"model": inference.MODEL_NAME}, + ]) + build.run() + assert "::warning::" not in capsys.readouterr().out def test_an_unreadable_window_falls_back_to_the_previous_record( self, tmp_path, monkeypatch @@ -298,7 +311,7 @@ def test_a_record_for_an_older_description_is_published_and_warned( assert self._published(db) == [ ("2026-09-22T20:00:00+00:00", "scheduled_end_with_time", "18:15")] out = capsys.readouterr().out - assert "::warning::1 case(s) (1 open) publish a record uisce-infer would redo" in out + assert "::warning::1 case(s) publish a record uisce-infer would redo" in out assert out.rstrip().endswith(": 1") def test_a_record_from_a_retired_extractor_is_warned_too( diff --git a/tests/test_inference.py b/tests/test_inference.py index 3de635f..bc68aeb 100644 --- a/tests/test_inference.py +++ b/tests/test_inference.py @@ -277,7 +277,7 @@ def failing_llm(session, start_date, description): assert "1 failed" in capsys.readouterr().out assert jsonl.read_text() == "" - def test_an_unreadable_llm_value_fails_the_case_and_writes_nothing( + def test_an_unreadable_llm_value_is_stored_as_not_found_and_not_resent( self, tmp_path, monkeypatch, capsys ): jsonl = self._wire( @@ -291,9 +291,12 @@ def test_an_unreadable_llm_value_fails_the_case_and_writes_nothing( "local_time": "5pm"}), ) - assert inference.run([]) == 1 - assert "1 failed" in capsys.readouterr().out - assert jsonl.read_text() == "" + assert inference.run([]) == 0 + record = json.loads(jsonl.read_text()) + assert record["end_source"] == "not_found" + assert "local_time '5pm'" in record["notes"] + # a temperature-0 retry would return the same reply, so it is not resent + assert inference.get_last_hash_by_case_id(jsonl)[1][0] == record["description_hash"] def test_a_clean_run_exits_zero(self, tmp_path, monkeypatch): self._wire(tmp_path, monkeypatch, [(1, "2026-05-18T08:00:00+00:00", self.TEMPLATED)]) @@ -377,22 +380,31 @@ def test_accepts_well_formed_values_and_nulls(self): assert parse_response(json.dumps({"end_source": "not_found", "local_date": None, "local_time": None}))["local_date"] is None + @pytest.mark.parametrize("field, value, read_as", [ + ("local_time", "9:30", {"local_time": "09:30"}), + ("local_time", "24:00", {"local_date": "2026-05-19", "local_time": "00:00"}), + ("window_open", "7:00", {"window_open": "07:00"}), + ("window_close", "24:00", {"window_close": "00:00"}), + ]) + def test_a_near_miss_with_one_meaning_is_normalised(self, field, value, read_as): + result = parse_response(json.dumps({"end_source": "scheduled_end_with_time", + "local_date": "2026-05-18", "local_time": "17:00", + field: value})) + assert {k: result[k] for k in read_as} == read_as + @pytest.mark.parametrize("field, value", [ - ("local_time", "24:00"), ("local_time", "5pm"), - ("local_time", "9:30"), ("local_date", "28/04/2026"), ("local_date", "2026-02-30"), ("local_date", "20260428"), - ("window_open", "7:00"), - ("window_close", "24:00"), ("window_first_date", "9 July"), ]) - def test_rejects_a_value_build_cannot_read(self, field, value): - with pytest.raises(ValueError, match=field): - parse_response(json.dumps({"end_source": "scheduled_end_with_time", - "local_date": "2026-05-18", "local_time": "17:00", - field: value})) + def test_an_unreadable_value_becomes_not_found(self, field, value): + result = parse_response(json.dumps({"end_source": "scheduled_end_with_time", + "local_date": "2026-05-18", "local_time": "17:00", + field: value})) + assert result["end_source"] == "not_found" and result["local_date"] is None + assert field in result["notes"] def test_a_malformed_record_already_written_is_inferred_again(tmp_path): @@ -404,5 +416,8 @@ def test_a_malformed_record_already_written_is_inferred_again(tmp_path): path = tmp_path / "inferred.jsonl" write_jsonl(path, [bad]) assert get_last_hash_by_case_id(path) == {} + # an older readable record does not make the case current: its newest is unreadable write_jsonl(path, [good, bad]) + assert get_last_hash_by_case_id(path) == {} + write_jsonl(path, [bad, good | {"inferred_at": "2026-06-03T00:00:00+00:00"}]) assert get_last_hash_by_case_id(path)[1][0] == good["description_hash"] diff --git a/tests/test_rules.py b/tests/test_rules.py index e2aaf82..94bdb0a 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -136,6 +136,12 @@ def test_an_irish_completion_over_a_stale_english_schedule_abstains(self): "**11:31rn 23/09/2026 - " + IRISH_COMPLETION_BLOCK + ENGLISH_ORIGINAL) is None + def test_an_irish_completion_does_not_read_an_older_english_update(self): + assert extract("2026-07-21T13:18:00+00:00", + "**10in 23/07/2026** " + IRISH_COMPLETION_BLOCK + + "**Update 9am 22/07/2026** Works are now complete. " + + "**Update 8am 22/07/2026** Crews on site. " + ENGLISH_ORIGINAL) is None + def test_an_irish_completion_over_an_english_update_without_one_abstains(self): assert extract("2026-09-22T13:18:00+00:00", "**11:31rn 23/09/2026 - " + IRISH_COMPLETION_BLOCK @@ -224,12 +230,28 @@ def test_midnight_after_a_start_the_day_before_stays_on_its_day(self): "12am on 22 May." + BOILERPLATE) assert (result["local_date"], result["local_time"]) == ("2026-05-22", "00:00") - def test_tanker_hours_beside_the_works_end_do_not_compete_with_it(self): - result = extract(START, "Works are scheduled to take place until 6pm on 22 July. " - "An alternative water supply will be available at the " - "school car park from 4:30pm until 11:59pm on 22 July." - + BOILERPLATE) - assert (result["local_date"], result["local_time"]) == ("2026-07-22", "18:00") + def test_tanker_hours_beside_the_works_end_abstain(self): + # never the end themselves, and never simply dropped either: see below + assert extract(START, "Works are scheduled to take place until 6pm on 22 July. " + "An alternative water supply will be available at the " + "school car park from 4:30pm until 11:59pm on 22 July." + + BOILERPLATE) is None + + def test_tanker_hours_in_a_revising_update_do_not_leave_the_stale_schedule(self): + assert extract(START, "**Update 3:15pm 22/07/2026** Works are taking longer than " + "expected. An alternative water supply is available at the " + "GAA grounds until 9pm on 22 July. Works are scheduled to " + "take place from 9am until 5pm on 22 July.") is None + + def test_a_wastewater_station_is_not_an_alternative_supply(self): + result = extract(START, "Works at the wastewater station may cause disruption " + "from 9am until 5pm on 22 July." + BOILERPLATE) + assert (result["local_date"], result["local_time"]) == ("2026-07-22", "17:00") + + def test_midnight_after_a_daytime_start_the_day_before_abstains(self): + # 15h or 39h: the text does not say which + assert extract(START, "Works are scheduled to take place from 9am on 21 July " + "until midnight on 22 July." + BOILERPLATE) is None def test_conflicting_schedules_in_one_block_abstain(self): # The original notice carries no header of its own, so a revising From 49acbcf45b9d9553981ac27fdc9862c7aec9baaa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 10:30:10 +0000 Subject: [PATCH 6/7] Bring the rules-v2 row in line with the review changes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168hLW2X3mkV3Jhm26LJSwQ --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5926d6d..28f1dfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ with the evidence that closed them. | The design layer (tokens, base CSS, row/bar/card, JS helpers) is shared with esb and lifts via `../statusui`, a **uv git dependency pinned in `uv.lock`** — edit upstream, then `../statusui/rollout.sh` bumps all three sites. Vendored copies were tried first and drifted within a day. `site.css` and the inline blocks are this site's own. | frontend-notes.md — "the vendored copy became a pinned uv git dependency" (2026-08-20); statusui's README for what is shared | | End-time extraction is **rules first, LLM fallback**: `rules.py` answers the templated ~93% (99.99% corpus agreement, 0 wrong emissions on the labelled rounds, 0.6s vs ~11 GPU-hours) and abstains to the LLM for recurring windows, lifts, Irish and everything ambiguous. Rules may only emit `completion_update`/`scheduled_end_with_time`; re-measure with `uv run uisce-eval-rules-shadow`. | rules-vs-llm-end-times.md (2026-08-21) | | `towns` and `resolved` are the county view's data and ship per county in `t/.js`; `data.js` carries months, open and top only (212 KB against 955 KB). `INITIAL_BUDGET` is 512 KB for index.html plus data.js, **warned, never failed**. Folding the breakdown into the history shard was rejected. | frontend-notes.md - "The county's own data left data.js" (2026-09-05) | -| rules-v2: "until midnight on D" after a start earlier on D ends at D+1 00:00, after a start the day before at D 00:00, and with no start the rules abstain. An `until` in a sentence about an alternative supply is never the works' end. An English completion below an Irish one is read; Irish alone still abstains. The LLM prompt still reads midnight as the start of D (roadmap). | rules-vs-llm-end-times.md - "rules-v2" (2026-09-24) | +| rules-v2: "until midnight on D" after a start earlier on D ends at D+1 00:00, after a start from 18:00 the day before at D 00:00, and otherwise the rules abstain. An `until` in a sentence about an alternative supply makes the rules abstain. An English completion below an Irish one of the same day is read; Irish alone still abstains. The LLM prompt still reads midnight as the start of D (issue #102). | rules-vs-llm-end-times.md - "rules-v2" (2026-09-24) | | CI runs `uisce-infer --rules-only` every data build and commits the JSONL to `main`; the LLM residue is run by hand. JSONL stays in this repo (`merge=union`) — a `uisce-data` repo and a release asset were both rejected. | rules-vs-llm-end-times.md — "CI runs the rules half" (2026-08-21) | | The fourteen towns named for their county (Carlow, Sligo, Wexford ...) render one row under the county's, `Sligo` + `town`, reachable from the box. The index always carried them; the `name|county` dedup that hid them was fixed upstream in statusui and the pin moved in the same PR | frontend-notes.md - "Two edges, both left as they are", 2026-09-03 amendment | From 09e40e2106a43e7fb98b5310487456fc2a59eaa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 11:36:23 +0000 Subject: [PATCH 7/7] Second-pass fixes to rules-v2 and the record handling From a second code review of the review-fix commit: - An unreadable model reply fails the case again. Storing it as not_found silently replaced a good published record with none; a failure keeps that record and shows in the run's count. The normalisation of one-meaning near-misses stays, now type-guarded. - After a start the day before, only a literal "12am on D" reads D 00:00; the word "midnight" abstains whatever the start time (a 6pm start was no less ambiguous than 9am). - The Irish block's date comes from its parsed header when there is one ("10.15rn"), else from its opening text. - The shadow eval compares against the model's own records only. - stale_cases shares inference's state map (current_states) and the open connection (cases_needing_inference). Re-measured: unchanged at 10,082 answered of 10,870 model records, 10,065 agreeing, the same 17 disagreements; replays 73/73, 110/111, 113/113. 4 rules-v1 records keep their answer until the residue run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168hLW2X3mkV3Jhm26LJSwQ --- CLAUDE.md | 2 +- notes/rules-vs-llm-end-times.md | 31 +++++++++++++-------- src/uisce/build.py | 19 +++++++------ src/uisce/eval_rules_shadow.py | 16 +++++------ src/uisce/inference.py | 49 +++++++++++++++++---------------- src/uisce/rules.py | 28 ++++++++++--------- tests/test_inference.py | 28 ++++++++++--------- tests/test_rules.py | 12 ++++++++ 8 files changed, 107 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 28f1dfb..ba1f0fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ with the evidence that closed them. | The design layer (tokens, base CSS, row/bar/card, JS helpers) is shared with esb and lifts via `../statusui`, a **uv git dependency pinned in `uv.lock`** — edit upstream, then `../statusui/rollout.sh` bumps all three sites. Vendored copies were tried first and drifted within a day. `site.css` and the inline blocks are this site's own. | frontend-notes.md — "the vendored copy became a pinned uv git dependency" (2026-08-20); statusui's README for what is shared | | End-time extraction is **rules first, LLM fallback**: `rules.py` answers the templated ~93% (99.99% corpus agreement, 0 wrong emissions on the labelled rounds, 0.6s vs ~11 GPU-hours) and abstains to the LLM for recurring windows, lifts, Irish and everything ambiguous. Rules may only emit `completion_update`/`scheduled_end_with_time`; re-measure with `uv run uisce-eval-rules-shadow`. | rules-vs-llm-end-times.md (2026-08-21) | | `towns` and `resolved` are the county view's data and ship per county in `t/.js`; `data.js` carries months, open and top only (212 KB against 955 KB). `INITIAL_BUDGET` is 512 KB for index.html plus data.js, **warned, never failed**. Folding the breakdown into the history shard was rejected. | frontend-notes.md - "The county's own data left data.js" (2026-09-05) | -| rules-v2: "until midnight on D" after a start earlier on D ends at D+1 00:00, after a start from 18:00 the day before at D 00:00, and otherwise the rules abstain. An `until` in a sentence about an alternative supply makes the rules abstain. An English completion below an Irish one of the same day is read; Irish alone still abstains. The LLM prompt still reads midnight as the start of D (issue #102). | rules-vs-llm-end-times.md - "rules-v2" (2026-09-24) | +| rules-v2: "until midnight on D" after a start earlier on D ends at D+1 00:00, after a start the day before at D 00:00 only for a literal "12am on D", and otherwise the rules abstain. An `until` in a sentence about an alternative supply makes the rules abstain. An English completion below an Irish one of the same day is read; Irish alone still abstains. The LLM prompt still reads midnight as the start of D (issue #102). | rules-vs-llm-end-times.md - "rules-v2" (2026-09-24) | | CI runs `uisce-infer --rules-only` every data build and commits the JSONL to `main`; the LLM residue is run by hand. JSONL stays in this repo (`merge=union`) — a `uisce-data` repo and a release asset were both rejected. | rules-vs-llm-end-times.md — "CI runs the rules half" (2026-08-21) | | The fourteen towns named for their county (Carlow, Sligo, Wexford ...) render one row under the county's, `Sligo` + `town`, reachable from the box. The index always carried them; the `name|county` dedup that hid them was fixed upstream in statusui and the pin moved in the same PR | frontend-notes.md - "Two edges, both left as they are", 2026-09-03 amendment | diff --git a/notes/rules-vs-llm-end-times.md b/notes/rules-vs-llm-end-times.md index eba9f94..7c04c16 100644 --- a/notes/rules-vs-llm-end-times.md +++ b/notes/rules-vs-llm-end-times.md @@ -303,25 +303,32 @@ model, against the abstain-on-ambiguity rule, and changed them before merge: abstain, as v1 effectively did, so the 29 newly answered above go back to the model; the 3 tanker emissions (239696-239698) still abstain. `ALTERNATIVE_SUPPLY` also gained word boundaries, so "wastewater station" is not a water station. -- **Midnight after a start the day before needs a late start.** "from 9am on 21 July until - midnight on 22 July" is 15h or 39h; only a start from 18:00 ("from 9pm on 21 May") fixes D - 00:00. Earlier starts abstain. +- **Midnight after a start the day before abstains unless it says "12am".** "from 9am on 21 + July until midnight on 22 July" is 15h or 39h, and a second pass found a 6pm start no + better (6h or 30h), so no start-time cutoff: after a start the day before, only the + literal "12am on D" (233690, "from 9pm on 21 May until 12am on 22 May") reads D 00:00, and + the word "midnight" abstains. - **The Irish completion's English twin must be the same day's update.** The next block with text could be an older update, whose header was then read. The two blocks' dates must now - match; the Irish block's date is read from its opening text, so a time too garbled for the - header pattern (244845, "9:38 rn") still passes. The rejected "headers must agree" above + match; the Irish date is its parsed header's (so "10.15rn" works), else the date the block + opens with, so a time too garbled for the header pattern (244845, "9:38 rn") still passes. The rejected "headers must agree" above compared times as well; this compares dates only. Re-measured against the same 10,870 hash-stable gemma records: rules-v2 answers 10,082 (92.8%, was 10,137), agrees on 10,065, and the 17 disagreements are the same 17. Against the rules-v1 records it disagrees on 2 (244089 and 245031, the midnight fix) and abstains on 2. -The labelled rounds replay unchanged: 73/73, 110/111 (231853 as above) and 113/113. - -On the build side the review changed two things. A model value that cannot be read is no -longer a failure to retry, because at temperature 0 a retry returns the same reply; the -safe near-misses are normalised ("9:30" is "09:30", "24:00" is 00:00 of the next day, a -window closing "24:00" closes "00:00") and anything else is stored as `not_found` with the -value in its notes. The newest-record reading is one function, `readable_latest`, shared by +The labelled rounds replay unchanged: 73/73, 110/111 (231853 as above) and 113/113. Of the +2,691 cases whose latest record is rules-v1, rules-v2 abstains on 4 (243084, whose +`start_date` is NULL; 244538, 244584, 244720): CI's `--rules-only` run cannot replace those, +so their v1 answer stands, in the stale warning, until the LLM residue is run by hand. +`uisce-eval-rules-shadow` now compares against the model's own records only; before, the +rules-v1 records on the other side compared the rules with themselves. + +On the build side: the safe near-misses in a model reply are normalised ("9:30" is "09:30", +"24:00" is 00:00 of the next day, a window closing "24:00" closes "00:00") and anything else +still fails the case, loudly. Storing it as `not_found` was tried and withdrawn on a second +pass: it would silently replace a good published record with none, where a failure keeps +that record and shows in the run's count. The newest-record reading is one function, `readable_latest`, shared by `uisce-infer`, `uisce-build-inferred` and the shadow eval; a case whose newest record is unreadable is redone and warned about until it is, and the stale warning asks inference's own selection instead of re-implementing it. It no longer counts "open" by the feed's status. diff --git a/src/uisce/build.py b/src/uisce/build.py index 3150aa1..efbe971 100644 --- a/src/uisce/build.py +++ b/src/uisce/build.py @@ -4,7 +4,7 @@ from datetime import date, datetime, time, timezone from uisce.config import DB_PATH, DUBLIN, JSONL_PATH, RECURRING -from uisce.inference import get_cases_needing_inference, readable_latest, record_state +from uisce.inference import cases_needing_inference, current_states, readable_latest NO_END_SIGNAL_SOURCES = {"not_found", "lifted_immediate"} @@ -184,14 +184,17 @@ def count_never_inferred(conn): ).fetchone() -def stale_cases(latest): +def stale_cases(conn, latest, unreadable): """Case ids whose published record `uisce-infer` would redo: its description has changed since, or its extractor or prompt is retired. Asked of inference's - own selection, so the warning and the redo cannot disagree. The record is - still the best answer there is until a run replaces it.""" - states = {case_id: record_state(r) for case_id, r in latest.items()} - return sorted(row["id"] for row in get_cases_needing_inference(DB_PATH, states) - if row["id"] in latest) + own selection and states, so the warning and the redo cannot disagree; a case + whose newest record is unreadable has its own warning. The record is still + the best answer there is until a run replaces it.""" + redo_unreadable = {r["case_id"] for r in unreadable} + return sorted( + row["id"] for row in cases_needing_inference(conn, current_states(latest, unreadable)) + if row["id"] in latest and row["id"] not in redo_unreadable + ) def check_cases_cover(conn, case_ids): @@ -261,7 +264,7 @@ def run(): ) never_inferred, never_inferred_open = count_never_inferred(conn) unquotable, inferred_first_dates = unquotable_windows(conn) - stale = stale_cases(latest_by_case) + stale = stale_cases(conn, latest_by_case, unreadable) print(f"Upserted {len(rows)} rows into inferred_cases") if unreadable: diff --git a/src/uisce/eval_rules_shadow.py b/src/uisce/eval_rules_shadow.py index 28f7b92..d49fed1 100644 --- a/src/uisce/eval_rules_shadow.py +++ b/src/uisce/eval_rules_shadow.py @@ -25,7 +25,7 @@ from uisce.config import DB_PATH, JSONL_PATH from uisce.eval_end_time import EVAL_DIR from uisce.eval_replay import normalise_time -from uisce.inference import hash_description, readable_latest +from uisce.inference import MODEL_NAME, hash_description, readable_latest from uisce.rules import RULES_VERSION, extract SHADOW_FIELDNAMES = [ @@ -60,13 +60,13 @@ def compare(llm_record, rules_result): def run(argv=None): - records, _ = readable_latest( - json.loads(line) - for line in open(JSONL_PATH) - if line.strip() - ) - records = records.values() - by_case = {record["case_id"]: record for record in records} + # the model's own records only: a rules-v1 record on the other side would + # compare the rules with themselves + with open(JSONL_PATH) as f: + by_case, _ = readable_latest( + r for r in (json.loads(line) for line in f if line.strip()) + if r.get("model") == MODEL_NAME + ) conn = sqlite3.connect(DB_PATH) rows = conn.execute( diff --git a/src/uisce/inference.py b/src/uisce/inference.py index 587ac6f..93acde2 100644 --- a/src/uisce/inference.py +++ b/src/uisce/inference.py @@ -3,6 +3,7 @@ import json import re import sqlite3 +from contextlib import closing from datetime import date, datetime, timedelta, timezone from uisce.config import DB_PATH, JSONL_PATH, make_session @@ -180,6 +181,13 @@ def readable_latest(records): return latest, newer +def current_states(latest, newer_unreadable): + """The state each case is judged current by: its latest readable record's, + unless a newer unreadable record means it must be redone.""" + redo = {r["case_id"] for r in newer_unreadable} + return {case_id: record_state(r) for case_id, r in latest.items() if case_id not in redo} + + def get_last_hash_by_case_id(jsonl_path): """Latest (description_hash, prompt_version, model) per case. A case is up to date only when the hash and version still match AND its record came from @@ -189,9 +197,7 @@ def get_last_hash_by_case_id(jsonl_path): if not jsonl_path.exists(): return {} with open(jsonl_path) as f: - latest, newer = readable_latest(json.loads(line) for line in f if line.strip()) - redo = {r["case_id"] for r in newer} - return {case_id: record_state(r) for case_id, r in latest.items() if case_id not in redo} + return current_states(*readable_latest(json.loads(line) for line in f if line.strip())) def is_current(state, description): @@ -203,19 +209,20 @@ def is_current(state, description): and model in (MODEL_NAME, RULES_VERSION)) -def get_cases_needing_inference(db_path, last_state_by_case_id, force=False): - conn = sqlite3.connect(db_path) - conn.row_factory = sqlite3.Row +def cases_needing_inference(conn, last_state_by_case_id, force=False): cur = conn.execute( "SELECT id, start_date, description FROM cases WHERE description IS NOT NULL" ) - cases = [ - row - for row in cur - if force or not is_current(last_state_by_case_id.get(row["id"]), row["description"]) + return [ + {"id": case_id, "start_date": start_date, "description": description} + for case_id, start_date, description in cur + if force or not is_current(last_state_by_case_id.get(case_id), description) ] - conn.close() - return cases + + +def get_cases_needing_inference(db_path, last_state_by_case_id, force=False): + with closing(sqlite3.connect(db_path)) as conn: + return cases_needing_inference(conn, last_state_by_case_id, force) def call_llm(session, start_date, description): @@ -261,7 +268,9 @@ def _normalise(result): result[field] = "0" + value if result.get("window_close") == "24:00": result["window_close"] = "00:00" - if result.get("local_time") == "24:00" and _is_iso_date(result.get("local_date") or ""): + local_date = result.get("local_date") + if result.get("local_time") == "24:00" and isinstance(local_date, str) \ + and _is_iso_date(local_date): result["local_date"] = (date.fromisoformat(result["local_date"]) + timedelta(days=1)).isoformat() result["local_time"] = "00:00" @@ -269,21 +278,15 @@ def _normalise(result): def parse_response(response_text): - """The model's answer, or a not_found record naming what could not be read. - Not a failure: at temperature 0 a retry returns the same reply, so a raise - would leave the case re-sent, and uninferred, on every run.""" + """The model's answer, near-misses normalised. Anything still unreadable fails + the case loudly, so it keeps its previous record; storing it as not_found + would silently replace a good published answer with none.""" result = json.loads(response_text) if not isinstance(result, dict): raise ValueError(f"Expected a JSON object, got: {response_text[:80]!r}") result = _normalise(result) if unreadable := unreadable_fields(result): - return { - "notes": "unreadable model reply: " - + ", ".join(f"{f} {result[f]!r}" for f in unreadable), - "end_source": "not_found", "local_date": None, "local_time": None, - "recurrence": "none", "window_open": None, "window_close": None, - "window_first_date": None, - } + raise ValueError("unreadable " + ", ".join(f"{f} {result[f]!r}" for f in unreadable)) return result diff --git a/src/uisce/rules.py b/src/uisce/rules.py index 97ee3f7..fdc6057 100644 --- a/src/uisce/rules.py +++ b/src/uisce/rules.py @@ -111,10 +111,7 @@ ALTERNATIVE_SUPPLY = re.compile( r"\b(?:alternative\s+(?:drinking\s+)?water|tankers?|bowsers?|water\s+stations?" r"|bottled\s+water|water\s+bottles|standpipes?)\b", re.IGNORECASE) -LEADING_DATE = re.compile(rf"\W*[^.]{{0,20}}?{_DATE}", _FLAGS) -# a start the day before only fixes "until midnight on D" as the start of D when -# it is late: "from 9pm on 21 May"; "from 9am on 21 July" could be either -LATE_START = "18:00" +LEADING_DATE = re.compile(rf"\W*.{{0,40}}?{_DATE}", _FLAGS) SENTENCE_END = re.compile(r"(?]+>") @@ -185,12 +182,13 @@ def _match_date(m, start_date): return resolved.isoformat() if resolved else None -def _midnight_end_date(before, end_date, start_date): +def _midnight_end_date(before, end_date, start_date, said_midnight): """The date a 00:00 end falls on, or None when the text leaves it open. "until midnight on D" alone is either end of D. A start earlier on D makes - it the end of D, so the instant is D+1 00:00; a late-evening start the day - before makes it the start of D ("from 9pm on 21 May until 12am on 22 May").""" + it the end of D, so the instant is D+1 00:00. After a start the day before, + only "12am on D" is the start of D ("from 9pm on 21 May until 12am on 22 + May"); the word "midnight" there still leaves it open.""" m = FROM_BEFORE.search(before) from_time = m and _match_time(m) if not from_time or from_time == "00:00": @@ -201,14 +199,17 @@ def _midnight_end_date(before, end_date, start_date): end = date.fromisoformat(end_date) if from_date == end_date: return (end + timedelta(days=1)).isoformat() - if from_date == (end - timedelta(days=1)).isoformat() and from_time >= LATE_START: + if from_date == (end - timedelta(days=1)).isoformat() and not said_midnight: return end_date return None -def _leading_date(block, start_date): - """The date an update block opens with, read even when its time is too - garbled for UPDATE_HEADER ("** 9:38 rn 22/09/2026", case 244845).""" +def _block_date(header, block, start_date): + """The date an update block is headed with: its parsed header's, else the date + it opens with, for a time too garbled for UPDATE_HEADER ("** 9:38 rn + 22/09/2026", case 244845).""" + if header is not None: + return _match_date(header, start_date) m = LEADING_DATE.match(block) return _match_date(m, start_date) if m else None @@ -284,7 +285,7 @@ def extract(start_date, description): # not an older one; otherwise the Irish needs the model. following_header, following = next( ((h, b) for h, b in segments[i + 1:] if _body(h, b).strip()), (None, "")) - irish_date = _leading_date(block, start_date) + irish_date = _block_date(header, block, start_date) same_day = (following_header is not None and irish_date is not None and irish_date == _match_date(following_header, start_date)) if not (same_day and COMPLETION.search(following)): @@ -321,7 +322,8 @@ def extract(start_date, description): local_time = _match_time(m) local_date = _match_date(m, start_date) if local_time == "00:00" and local_date: - local_date = _midnight_end_date(before, local_date, start_date) + local_date = _midnight_end_date( + before, local_date, start_date, (m.group("word") or "").lower() == "midnight") if not local_time or not local_date: return None candidates[(local_date, local_time)] = m.group(0).strip() diff --git a/tests/test_inference.py b/tests/test_inference.py index bc68aeb..ffdaa2a 100644 --- a/tests/test_inference.py +++ b/tests/test_inference.py @@ -277,7 +277,7 @@ def failing_llm(session, start_date, description): assert "1 failed" in capsys.readouterr().out assert jsonl.read_text() == "" - def test_an_unreadable_llm_value_is_stored_as_not_found_and_not_resent( + def test_an_unreadable_llm_value_fails_the_case_and_writes_nothing( self, tmp_path, monkeypatch, capsys ): jsonl = self._wire( @@ -291,12 +291,10 @@ def test_an_unreadable_llm_value_is_stored_as_not_found_and_not_resent( "local_time": "5pm"}), ) - assert inference.run([]) == 0 - record = json.loads(jsonl.read_text()) - assert record["end_source"] == "not_found" - assert "local_time '5pm'" in record["notes"] - # a temperature-0 retry would return the same reply, so it is not resent - assert inference.get_last_hash_by_case_id(jsonl)[1][0] == record["description_hash"] + # failing, not a silent not_found: the case keeps its previous record + assert inference.run([]) == 1 + assert "1 failed" in capsys.readouterr().out + assert jsonl.read_text() == "" def test_a_clean_run_exits_zero(self, tmp_path, monkeypatch): self._wire(tmp_path, monkeypatch, [(1, "2026-05-18T08:00:00+00:00", self.TEMPLATED)]) @@ -399,12 +397,16 @@ def test_a_near_miss_with_one_meaning_is_normalised(self, field, value, read_as) ("local_date", "20260428"), ("window_first_date", "9 July"), ]) - def test_an_unreadable_value_becomes_not_found(self, field, value): - result = parse_response(json.dumps({"end_source": "scheduled_end_with_time", - "local_date": "2026-05-18", "local_time": "17:00", - field: value})) - assert result["end_source"] == "not_found" and result["local_date"] is None - assert field in result["notes"] + def test_rejects_a_value_build_cannot_read(self, field, value): + with pytest.raises(ValueError, match=field): + parse_response(json.dumps({"end_source": "scheduled_end_with_time", + "local_date": "2026-05-18", "local_time": "17:00", + field: value})) + + def test_a_midnight_with_a_non_string_date_is_rejected_not_crashed(self): + with pytest.raises(ValueError, match="local_date"): + parse_response(json.dumps({"end_source": "scheduled_end_with_time", + "local_date": 20260518, "local_time": "24:00"})) def test_a_malformed_record_already_written_is_inferred_again(tmp_path): diff --git a/tests/test_rules.py b/tests/test_rules.py index 94bdb0a..a3713d2 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -136,6 +136,13 @@ def test_an_irish_completion_over_a_stale_english_schedule_abstains(self): "**11:31rn 23/09/2026 - " + IRISH_COMPLETION_BLOCK + ENGLISH_ORIGINAL) is None + def test_an_irish_header_with_a_dotted_time_is_dated_from_its_header(self): + result = extract("2026-07-22T13:18:00+00:00", + "**10.15rn 23/07/2026** " + IRISH_COMPLETION_BLOCK + + "**Update 10:15am 23/07/2026** Works are now complete. " + + ENGLISH_ORIGINAL) + assert (result["local_date"], result["local_time"]) == ("2026-07-23", "10:15") + def test_an_irish_completion_does_not_read_an_older_english_update(self): assert extract("2026-07-21T13:18:00+00:00", "**10in 23/07/2026** " + IRISH_COMPLETION_BLOCK @@ -248,6 +255,11 @@ def test_a_wastewater_station_is_not_an_alternative_supply(self): "from 9am until 5pm on 22 July." + BOILERPLATE) assert (result["local_date"], result["local_time"]) == ("2026-07-22", "17:00") + def test_midnight_after_an_evening_start_the_day_before_abstains_too(self): + # 6h or 30h; only a literal "12am on D" names the start of D + assert extract(START, "Works are scheduled to take place from 6pm on 21 July " + "until midnight on 22 July." + BOILERPLATE) is None + def test_midnight_after_a_daytime_start_the_day_before_abstains(self): # 15h or 39h: the text does not say which assert extract(START, "Works are scheduled to take place from 9am on 21 July "