""" eval/grounded.py — Shared grounded matching primitives for Bureaucat. Provides the no-invention check used by app.py (to drive the verifying mascot state, D2-05) and by eval/run_eval.py (for bake-off accuracy gating). Stdlib-only: only `re` and `unicodedata`. Importing this module never loads model weights and never imports app. """ # --------------------------------------------------------------------------- # Imports — stdlib only # --------------------------------------------------------------------------- import re import unicodedata # --------------------------------------------------------------------------- # Normalization (D-12) # Python 3 \s on str matches all Unicode whitespace: # U+00A0 non-breaking space, U+2009 thin space, U+202F narrow no-break space, # U+2007 figure space, U+3000 ideographic space — no literal chars needed. # --------------------------------------------------------------------------- _WHITESPACE_RE = re.compile(r"\s+") def normalize(text: str) -> str: """ Collapse all Unicode whitespace to single ASCII space; NFC-normalize; lowercase. Preserves digits, commas, periods (Swedish decimal format), and kr suffix. Purpose: makes OCR whitespace/case variance transparent to value_found(). """ # NFC normalize first — handles combining characters from OCR output text = unicodedata.normalize("NFC", text) # Collapse all Unicode whitespace variants to a single ASCII space text = _WHITESPACE_RE.sub(" ", text) # Lowercase and strip leading/trailing whitespace return text.lower().strip() # --------------------------------------------------------------------------- # Value matching (D-12 no-invention, D-13 recall) # --------------------------------------------------------------------------- def value_found(gold_value: str, haystack: str) -> bool: """ Check if gold_value appears in haystack using normalized substring match with non-digit boundaries on both sides. The digit boundary (`(? list: """ Extract emitted verbatim values from the "Deadlines & money" section. Exploits the locked prompt's "one item per line, verbatim value first" format: - 15 juni 2026 — last day to file - 1 234 kr - belopp For each line: 1. Strip leading bullet marker ('- ' or '* ' or bare '-' at start) and whitespace. 2. Skip blank lines and any "None found" lines. 3. Split on the first interpretation separator: ' — ' (em-dash) or ' - ' (hyphen with surrounding spaces). The surrounding-spaces requirement prevents the internal hyphens in ISO dates (e.g., 2026-06-15) from being split. 4. The text before the separator is the verbatim value; strip it. Returns a list of verbatim value strings. Empty list if nothing matches. """ values = [] for raw_line in section_text.splitlines(): line = raw_line.strip() if not line: continue # Strip leading bullet markers (-, *, •) with optional trailing space line = re.sub(r"^[-*•]\s*", "", line).strip() if not line: continue # Skip "None found" variants (case-insensitive) if re.match(r"none found\.?$", line, re.IGNORECASE): continue # Split on first interpretation separator: # ' — ' (em-dash, U+2014, with surrounding spaces) # ' – ' (en-dash, U+2013, with surrounding spaces — LLMs emit this constantly) # ' - ' (hyphen with surrounding spaces — requires spaces to avoid splitting dates) # The surrounding-spaces requirement keeps internal ISO-date hyphens intact. sep_match = re.search(r"\s+[—–\-]\s+", line) if sep_match: verbatim = line[: sep_match.start()].strip() else: # No separator found — the whole line is the verbatim value verbatim = line if verbatim: values.append(verbatim) return values # --------------------------------------------------------------------------- # No-invention check (D-12, D2-05 verifying mascot state) # --------------------------------------------------------------------------- def check_no_invention(result) -> list: """ Returns list of values from result.deadlines that are NOT found verbatim in result.transcription (the no-invention check, D-12). Called from app.py to drive the verifying mascot state (D2-05). Does NOT require a gold dict — safe to call on any real user letter. Returns empty list = no invention detected. Works on any SimpleNamespace or StructuredResult that has .deadlines and .transcription attributes. """ invented = [] for emitted_val in extract_values_from_section(result.deadlines): if not value_found(emitted_val, result.transcription): invented.append(emitted_val) return invented