ravinsingh15 commited on
Commit
6b5e47d
·
0 Parent(s):

Bureaucat — Build Small Hackathon submission (Qwen3-VL-8B, ZeroGPU, gr.Server)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .git-hooks/pre-commit +108 -0
  2. .gitattributes +40 -0
  3. .gitignore +9 -0
  4. README.md +91 -0
  5. app.py +1626 -0
  6. assets/mascot/allclear.png +0 -0
  7. assets/mascot/confused.png +0 -0
  8. assets/mascot/deadline.png +0 -0
  9. assets/mascot/idle.png +0 -0
  10. assets/mascot/money.png +0 -0
  11. assets/mascot/reading.png +0 -0
  12. assets/mascot/verifying.png +0 -0
  13. assets/mascot/wrong_document.png +0 -0
  14. data/gallery/csn-aterkrav-result.json +11 -0
  15. data/gallery/forsakringskassan-komplettering-result.json +11 -0
  16. data/gallery/migrationsverket-uppehallstillstand-result.json +11 -0
  17. data/gallery/skatteverket-slutskattebesked-result.json +11 -0
  18. data/gallery/vardcentral-kallelse-result.json +11 -0
  19. data/letters/generate_synthetic_gold.py +270 -0
  20. data/letters/public/adversarial/blurry_unreadable.json +8 -0
  21. data/letters/public/adversarial/blurry_unreadable.png +0 -0
  22. data/letters/public/adversarial/generate_adversarial_fixtures.py +256 -0
  23. data/letters/public/adversarial/non_letter_receipt.json +8 -0
  24. data/letters/public/adversarial/non_letter_receipt.png +0 -0
  25. data/letters/public/adversarial/non_swedish_english.json +9 -0
  26. data/letters/public/adversarial/non_swedish_english.png +0 -0
  27. data/letters/public/csn-aterkrav.json +21 -0
  28. data/letters/public/csn-aterkrav.png +0 -0
  29. data/letters/public/forsakringskassan-komplettering.json +16 -0
  30. data/letters/public/forsakringskassan-komplettering.png +0 -0
  31. data/letters/public/migrationsverket-uppehallstillstand.json +16 -0
  32. data/letters/public/migrationsverket-uppehallstillstand.png +0 -0
  33. data/letters/public/skatteverket-slutskattebesked.json +21 -0
  34. data/letters/public/skatteverket-slutskattebesked.png +0 -0
  35. data/letters/public/vardcentral-kallelse.json +21 -0
  36. data/letters/public/vardcentral-kallelse.png +0 -0
  37. eval/cuda_parity.ipynb +318 -0
  38. eval/grounded.py +146 -0
  39. eval/regen_gallery.py +161 -0
  40. eval/run_eval.py +493 -0
  41. eval/test_eval_matching.py +475 -0
  42. eval/test_grounded.py +166 -0
  43. eval/test_parse_output.py +273 -0
  44. frontend/app.js +788 -0
  45. frontend/index.html +133 -0
  46. frontend/style.css +446 -0
  47. requirements.txt +25 -0
  48. tests/__init__.py +0 -0
  49. tests/test_doctype_refusal.py +322 -0
  50. tests/test_gallery.py +225 -0
.git-hooks/pre-commit ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Pre-commit hook: block staging of text files containing Swedish personnummer patterns.
3
+ # Installed via: git config core.hooksPath .git-hooks
4
+ #
5
+ # WHAT IT SCANS: text files with these extensions staged for commit:
6
+ # .py .txt .md .json .csv .yaml .yml .toml .sh
7
+ #
8
+ # WHAT IT DOES NOT SCAN: image files (PNG, JPG, etc.).
9
+ # Image redaction is a MANUAL VISUAL gate — verify PNG/JPG files by eye before committing.
10
+ #
11
+ # Pattern covers:
12
+ # 10-digit: YYMMDD-XXXX (e.g., 901215-1234)
13
+ # 12-digit: YYYYMMDD-XXXX (e.g., 19901215-1234)
14
+ # Plus-separator (100+ years): YYMMDD+XXXX
15
+ # Samordningsnummer: same formats; day-part 61-91 is not distinguished (any match blocked)
16
+
17
+ set -euo pipefail
18
+
19
+ # Patterns (regex alternation):
20
+ # [0-9]{6}[+-][0-9]{4} — 10-digit form with hyphen or plus
21
+ # [0-9]{8}-[0-9]{4} — 12-digit full-year form with hyphen
22
+ PATTERN='[0-9]{6}[+-][0-9]{4}|[0-9]{8}-[0-9]{4}'
23
+
24
+ # Get staged text files (added, copied, modified, renamed — not deleted)
25
+ staged_files=$(git diff --cached --name-only --diff-filter=ACMR 2>/dev/null | \
26
+ grep -E '\.(py|txt|md|json|csv|yaml|yml|toml|sh)$' || true)
27
+
28
+ if [ -z "$staged_files" ]; then
29
+ exit 0
30
+ fi
31
+
32
+ # Determine scan backend: prefer grep -P (PCRE); fall back to python3 if unavailable.
33
+ # macOS BSD grep may not support -P; the fallback ensures portability.
34
+ #
35
+ # The probe MUST actually exercise a digit-dash-digit match, or it silently
36
+ # always-fails and never selects grep. We build the probe digits at runtime via
37
+ # printf so this hook file never contains a literal personnummer (which would
38
+ # otherwise self-trip the hook when the hook itself is committed).
39
+ _grep_supports_P() {
40
+ local six="123456" four="7890"
41
+ printf '%s-%s\n' "$six" "$four" | grep -qP '[0-9]{6}-[0-9]{4}' 2>/dev/null
42
+ }
43
+
44
+ # Scan convention (MUST match _scan_file_python): exit 0 = CLEAN (no PII),
45
+ # exit non-zero = PII FOUND. `grep -qP` exits 0 when it FINDS a match, so it is
46
+ # negated here to align the two backends — otherwise the loop guard below would
47
+ # be inverted for the grep path (the CR-01 bug).
48
+ _scan_file_grep() {
49
+ local f="$1"
50
+ ! grep -qP "$PATTERN" "$f" 2>/dev/null
51
+ }
52
+
53
+ _scan_file_python() {
54
+ local f="$1"
55
+ python3 - "$f" <<'PYEOF'
56
+ import re, sys
57
+ pattern = re.compile(r'[0-9]{6}[+-][0-9]{4}|[0-9]{8}-[0-9]{4}')
58
+ with open(sys.argv[1], errors='replace') as fh:
59
+ sys.exit(0 if not pattern.search(fh.read()) else 1)
60
+ PYEOF
61
+ }
62
+
63
+ _get_matches_grep() {
64
+ local f="$1"
65
+ grep -nP "$PATTERN" "$f" 2>/dev/null | head -3 || true
66
+ }
67
+
68
+ _get_matches_python() {
69
+ local f="$1"
70
+ python3 - "$f" <<'PYEOF'
71
+ import re, sys
72
+ pattern = re.compile(r'[0-9]{6}[+-][0-9]{4}|[0-9]{8}-[0-9]{4}')
73
+ with open(sys.argv[1], errors='replace') as fh:
74
+ for i, line in enumerate(fh, 1):
75
+ if pattern.search(line):
76
+ print(f"{i}:{line.rstrip()}")
77
+ PYEOF
78
+ }
79
+
80
+ # Select backend once
81
+ if _grep_supports_P; then
82
+ SCAN_CMD=_scan_file_grep
83
+ MATCH_CMD=_get_matches_grep
84
+ else
85
+ SCAN_CMD=_scan_file_python
86
+ MATCH_CMD=_get_matches_python
87
+ fi
88
+
89
+ found=0
90
+ while IFS= read -r file; do
91
+ if [ -f "$file" ] && ! $SCAN_CMD "$file"; then
92
+ echo "ERROR: Possible personnummer found in: $file"
93
+ $MATCH_CMD "$file"
94
+ found=1
95
+ fi
96
+ done <<< "$staged_files"
97
+
98
+ if [ "$found" -eq 1 ]; then
99
+ echo ""
100
+ echo "COMMIT BLOCKED: Remove or redact personnummer patterns before committing."
101
+ echo "Pattern matched: YYMMDD-XXXX, YYYYMMDD-XXXX, or YYMMDD+XXXX"
102
+ echo ""
103
+ echo "NOTE: Image files (PNG/JPG) are NOT scanned — verify PNG/JPG redaction visually."
104
+ echo " Real letters must live in data/letters/private/ (gitignored), never in public/."
105
+ exit 1
106
+ fi
107
+
108
+ exit 0
.gitattributes ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ # Audio / video demo assets (kept out of the runtime, but LFS if ever added)
37
+ *.mp4 filter=lfs diff=lfs merge=lfs -text
38
+ *.mov filter=lfs diff=lfs merge=lfs -text
39
+ *.wav filter=lfs diff=lfs merge=lfs -text
40
+ *.mp3 filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ data/letters/private/
4
+ *.token
5
+ .claude/
6
+ .DS_Store
7
+ **/.DS_Store
8
+ # Scratch/working notes never meant for the public Space
9
+ data/letters/public/sample-invoice-ocr.png
README.md ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Bureaucat
3
+ emoji: 🐱
4
+ colorFrom: pink
5
+ colorTo: yellow
6
+ sdk: gradio
7
+ sdk_version: 6.16.0
8
+ python_version: "3.12"
9
+ app_file: app.py
10
+ pinned: false
11
+ license: apache-2.0
12
+ short_description: The cat that reads scary Swedish letters for you
13
+ tags:
14
+ - track:backyard
15
+ - achievement:offgrid
16
+ - achievement:offbrand
17
+ - achievement:fieldnotes
18
+ - build-small-hackathon
19
+ - backyard-ai
20
+ - off-the-grid
21
+ - off-brand
22
+ - field-notes
23
+ - zerogpu
24
+ - vision-language-model
25
+ - sweden
26
+ models:
27
+ - Qwen/Qwen3-VL-8B-Instruct
28
+ ---
29
+
30
+ **🔗 Submission:** [Demo video & launch post on LinkedIn](https://www.linkedin.com/posts/ravinarayansingh_buildsmallhackathon-gradio-huggingface-ugcPost-7472036424997974017-l0HA/) — the demo video is embedded in this post.
31
+
32
+ # 🐱 Bureaucat
33
+
34
+ **The cat that reads scary Swedish letters so you don't have to.**
35
+
36
+ You moved to Sweden. One day a thick envelope arrives from **Skatteverket**. Or **Försäkringskassan**. Or — heart rate rising — **Migrationsverket**. It's in formal bureaucratic Swedish, it mentions an amount and a date, and you have no idea if it means *"FYI, everything is fine"* or *"pay 15,600 kr in 30 days or lose your housing allowance."*
37
+
38
+ Bureaucat reads the letter for you and tells you, at a glance and accurately:
39
+
40
+ - **How worried to be** — a 1–5 severity verdict driving a **Panic Meter** and a reacting cat-civil-servant mascot
41
+ - **The short version** — what the letter says, in plain English
42
+ - **Why you got it** — which authority, and what triggered it
43
+ - **What you need to do** — a checklist of required actions
44
+ - **Deadlines & money** — every deadline, amount, and reference number **quoted verbatim from the letter, never invented**
45
+
46
+ ## 🏡 Backyard AI: built for a real person — me
47
+
48
+ I'm an expat in Sweden. Every letter in the example gallery is a type of letter that has actually landed in my mailbox: tax decisions (slutskattebesked), benefit completion requests (komplettering), residence-permit document requests, CSN repayment demands, vårdcentral appointment summons. The first user test was me, on my own mail. The app is stateless by design — nothing is stored, nothing leaves the Space.
49
+
50
+ ## 🤏 Small model, honest fit
51
+
52
+ One model does everything — OCR, reasoning, and explanation in a single pass:
53
+
54
+ | | |
55
+ |---|---|
56
+ | Model | [`Qwen/Qwen3-VL-8B-Instruct`](https://huggingface.co/Qwen/Qwen3-VL-8B-Instruct) (~9B params — well under the 32B cap) |
57
+ | Decoding | **Greedy** (`do_sample=False`) — deterministic output for an accuracy-critical tool |
58
+ | Inference | Entirely in this Space on ZeroGPU. **No cloud APIs.** |
59
+
60
+ The model was chosen by a bake-off, not vibes: Qwen2.5-VL-7B vs Qwen3-VL-8B on a gold set of five synthetic Swedish authority letters with an automated evaluation harness (`eval/run_eval.py`). The smaller 7B model dropped reference numbers (3/5 pass); the 8B passed 5/5 with **zero invented values and 100% recall** — so per "smallest model that passes," the 8B won.
61
+
62
+ ## 🛡️ The anti-hallucination contract
63
+
64
+ Inventing a deadline is the single worst failure this app can have. So:
65
+
66
+ 1. The model first **transcribes** the letter, then extracts — every value in "Deadlines & money" must be a **verbatim substring of its own transcription**, checked by a pure-Python grounding pass (no second model call).
67
+ 2. If a value isn't in the letter, the answer is **"None found."** — never a guess.
68
+ 3. The eval gate (zero invented values, 100% recall, severity parseable, beginner-mode invariance) runs on every gold letter, plus **adversarial fixtures**: a blurry photo, a grocery receipt, and a non-Swedish letter must all be *refused*, not analyzed.
69
+
70
+ ## 👇 How to use it
71
+
72
+ **Upload a saved file** — a photo (JPEG/PNG) or a PDF of your letter, multi-page supported. Bureaucat does **not** use your camera or take live photos; snap the letter with your phone first, then upload the image or PDF here. One upload uses one short ZeroGPU call.
73
+
74
+ The example gallery at the bottom of the app contains **pre-computed analyses** — tap any of the five letters and the full result loads instantly at **zero GPU cost**, no upload needed.
75
+
76
+ *Why synthetic examples?* Real authority letters are full of personal data (names, personnummer, amounts) and can't be published — and the official "example" documents authorities publish are info sheets without the personalized deadlines/amounts that make a letter scary. So the gallery letters are **faithful synthetic recreations** of the five letter types, with realistic Swedish layouts, diacritics, and values — built so nobody's real mail ends up in a public repo. Every letter shown in the app and in the demo video is one of these synthetic mock-ups — I tested Bureaucat privately on my own real Swedish mail, which never gets published.
77
+
78
+ ## 🏅 Badges claimed
79
+
80
+ - **Off the Grid** — no cloud APIs; the 9B VLM runs entirely in this Space
81
+ - **Off-Brand** — custom frontend via `gr.Server`
82
+ - **Field Notes** — build report: what greedy decoding revealed that sampling had masked
83
+
84
+ ## ⚖️ Privacy & disclaimer
85
+
86
+ - **Stateless:** letters are processed in-memory and never stored. Still — redact your personnummer before uploading if you can.
87
+ - **Not legal advice.** Bureaucat helps you understand a letter; always verify deadlines against the original and contact the authority if unsure.
88
+
89
+ ---
90
+
91
+ *Built by an expat, for expats, with one small cat-shaped model.* 🐾
app.py ADDED
@@ -0,0 +1,1626 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ # MPS lacks a few VLM ops in bf16; let those individual ops fall back to CPU
4
+ # instead of hard-crashing. Must be set BEFORE torch is imported.
5
+ os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
6
+
7
+ import json
8
+ import time
9
+ from pathlib import Path
10
+
11
+ import gradio as gr
12
+ import pypdfium2 as pdfium # PDF → PIL render, server-side (INPUT-02, no system binary needed)
13
+ import spaces # ZeroGPU decorator; a harmless no-op when running locally
14
+ import torch
15
+ from transformers import (
16
+ AutoProcessor,
17
+ Qwen3VLForConditionalGeneration,
18
+ # Actual class name in transformers 5.10.2 is Qwen2_5_VLForConditionalGeneration
19
+ # Aliased to Qwen2_5VLForConditionalGeneration to match the bake-off contract.
20
+ Qwen2_5_VLForConditionalGeneration as Qwen2_5VLForConditionalGeneration,
21
+ )
22
+
23
+ # eval/grounded.py is stdlib-only; importing it never triggers model loading.
24
+ # Used to drive the verifying mascot state (D2-05) — pure Python check, no GPU.
25
+ from eval.grounded import check_no_invention # noqa: F401 (used in Phase 2 UI plan)
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Device / dtype — SPACE_ID sentinel (D-02 ratified 2026-06-05)
29
+ # The is_available() CUDA check is NOT reliable at module scope on ZeroGPU:
30
+ # CUDA emulation is active at import time but that check may return False.
31
+ # SPACE_ID is set on all HF Spaces; absent locally → MPS or CPU.
32
+ # ---------------------------------------------------------------------------
33
+ if os.getenv("SPACE_ID"):
34
+ DEVICE = "cuda"
35
+ elif torch.backends.mps.is_available():
36
+ DEVICE = "mps"
37
+ else:
38
+ DEVICE = "cpu"
39
+
40
+ DTYPE = torch.float32 if DEVICE == "cpu" else torch.bfloat16
41
+ print(f"[Bureaucat] device={DEVICE} dtype={DTYPE}")
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Model-agnostic bake-off loader (D-16)
45
+ # Switch between candidates by setting BUREAUCAT_MODEL env var.
46
+ # IMAGE_PATCH_SIZE: 16 for Qwen3-VL (patch_size=16), 14 for Qwen2.5-VL.
47
+ # ---------------------------------------------------------------------------
48
+ MODEL_VARIANTS = {
49
+ "qwen3": {
50
+ "model_id": "Qwen/Qwen3-VL-8B-Instruct",
51
+ "model_class": Qwen3VLForConditionalGeneration,
52
+ "image_patch_size": 16,
53
+ },
54
+ "qwen25": {
55
+ "model_id": "Qwen/Qwen2.5-VL-7B-Instruct",
56
+ "model_class": Qwen2_5VLForConditionalGeneration,
57
+ "image_patch_size": 14,
58
+ },
59
+ }
60
+
61
+ # LOCKED MODEL (MODEL-01, Plan 01-04 bake-off, 2026-06-05): "qwen3" = Qwen3-VL-8B-Instruct.
62
+ # Verdict: on the calibrated 5-letter gold gate, qwen3 passed 5/5 (zero invented values,
63
+ # 100% recall both standard+beginner, severity parseable everywhere); the smaller
64
+ # Qwen2.5-VL-7B passed only 3/5 (dropped case/dossier reference numbers on the standard
65
+ # pass). "Smallest model that passes wins" → the smaller model does not pass, so qwen3 is
66
+ # locked. Default read from env so the harness can still drive qwen25; the Space does NOT
67
+ # set BUREAUCAT_MODEL → production runs qwen3.
68
+ MODEL_VARIANT = os.getenv("BUREAUCAT_MODEL", "qwen3")
69
+
70
+ # Resolve patch size once at module scope so run_inference can reference it.
71
+ IMAGE_PATCH_SIZE = MODEL_VARIANTS[MODEL_VARIANT]["image_patch_size"]
72
+
73
+ # Max new tokens: raised 1200 -> 1600 (Phase 3, MODEL-01 amendment 2026-06-06). The
74
+ # Phase-1 lock at 1200 was established under stochastic sampling and a single output
75
+ # language. The Phase-3 5x5 multilingual matrix surfaced truncation on the token-heaviest
76
+ # combination (Hindi + beginner mode: Devanagari is token-dense and beginner mode adds
77
+ # inline explanations) — csn-aterkrav hit exactly 1200 mid-output and dropped the SEVERITY
78
+ # line. Under greedy decoding (see run_inference_multi) that worst case lands at ~1068
79
+ # tokens, so 1600 gives comfortable (~530-token) headroom across all five languages.
80
+ # Latency note: more tokens => longer generation; the D-03 <40s ZeroGPU budget (DEFERRED)
81
+ # must be re-checked on the Space now that the ceiling is higher.
82
+ MAX_NEW_TOKENS = 1600
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Page-cap constants for multi-image input (INPUT-01)
86
+ # DEFERRED tuning targets (D-03): the <40s ZeroGPU latency check is not measurable
87
+ # on local Apple MPS. Tune after measuring real inference time on the dev Space.
88
+ # ---------------------------------------------------------------------------
89
+ MAX_PAGES_SOFT = 3 # Warn user above this count (soft advisory threshold)
90
+ MAX_PAGES_HARD = 5 # Hard cap: truncate to this many pages before inference
91
+
92
+ # Vision-token budget (single knob — keep processor + per-image content in sync).
93
+ # LOCKED at 1280×28×28 ≈ 1M px: a 1024×28×28 speed experiment (2026-06-12)
94
+ # FAILED the eval gate — 4/5 gold recall and the non-Swedish fixture was
95
+ # analyzed instead of refused. Any change MUST re-pass the full gate
96
+ # (python -m eval.run_eval --model qwen3) before shipping.
97
+ MIN_PIXELS = 256 * 28 * 28 # ~200K pixel budget floor (OCR-safe)
98
+ MAX_PIXELS = 1280 * 28 * 28 # ~1M pixel budget ceiling (doc images)
99
+
100
+
101
+ def pdf_to_images(pdf_bytes: bytes, max_pages: int = MAX_PAGES_HARD, dpi: int = 200) -> list:
102
+ """
103
+ Render the first ≤max_pages pages of a PDF to PIL Images at the given DPI.
104
+
105
+ Accepts bytes only — never a filesystem path (D3-09 in-memory constraint / T-03-05
106
+ path-traversal mitigation). 200 DPI renders A4 to ~1654×2339 px, within the
107
+ qwen-vl-utils max_pixels budget of 1280×28×28 ≈ 1M px (D3-10).
108
+
109
+ Returns a list[PIL.Image.Image] (possibly empty if the PDF has no pages).
110
+ Raises PdfiumError on corrupt / malformed input (caller wraps in try/except).
111
+ """
112
+ pdf = pdfium.PdfDocument(pdf_bytes)
113
+ images = []
114
+ for i in range(min(len(pdf), max_pages)):
115
+ images.append(pdf[i].render(scale=dpi / 72.0).to_pil())
116
+ return images
117
+
118
+
119
+ def load_model(variant: str):
120
+ """
121
+ Load model + processor for the given variant key ("qwen3" or "qwen25").
122
+
123
+ Applies:
124
+ - AutoProcessor with pixel budget controls
125
+ - attn_implementation="sdpa" (safe built-in, avoids flash-attn dependency)
126
+ - dtype=DTYPE (keeps working baseline kwarg; NOT torch_dtype)
127
+ - unconditional .to(DEVICE) — ZeroGPU emulation layer requires .to(), not
128
+ the accelerate multi-device dispatch path
129
+ - model.eval()
130
+
131
+ Returns (model, processor).
132
+ """
133
+ v = MODEL_VARIANTS[variant]
134
+ model_id = v["model_id"]
135
+ model_class = v["model_class"]
136
+
137
+ print(f"[Bureaucat] loading {model_id} ...")
138
+ print("[Bureaucat] (first run downloads weights to ~/.cache/huggingface)")
139
+
140
+ proc = AutoProcessor.from_pretrained(
141
+ model_id,
142
+ min_pixels=MIN_PIXELS,
143
+ max_pixels=MAX_PIXELS,
144
+ )
145
+ mdl = model_class.from_pretrained(
146
+ model_id,
147
+ dtype=DTYPE,
148
+ attn_implementation="sdpa",
149
+ )
150
+ mdl = mdl.to(DEVICE)
151
+ mdl.eval()
152
+ print("[Bureaucat] model ready.")
153
+ return mdl, proc
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # TEST ESCAPE HATCH
158
+ # When BUREAUCAT_NO_MODEL is set (e.g. by unit tests), skip the heavy load.
159
+ # The Space and the bake-off do NOT set this var, so the D-02 module-scope
160
+ # cuda load still happens in production.
161
+ # ---------------------------------------------------------------------------
162
+ if os.getenv("BUREAUCAT_NO_MODEL"):
163
+ model = None
164
+ processor = None
165
+ else:
166
+ model, processor = load_model(MODEL_VARIANT)
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Output schema + parser (D-04–D-08)
170
+ # ---------------------------------------------------------------------------
171
+
172
+ import re
173
+ from dataclasses import dataclass
174
+ from typing import Optional
175
+
176
+
177
+ @dataclass
178
+ class StructuredResult:
179
+ transcription: str # raw verbatim OCR; used by eval harness (D-04)
180
+ quip: str # "Bureaucat says:" value (D-07)
181
+ tldr: str
182
+ why: str
183
+ actions: str
184
+ deadlines: str
185
+ severity: Optional[int] # None if output truncated (D-06)
186
+ raw: str # full raw model output
187
+ doctype: str = "letter" # DOCTYPE sentinel: letter | unreadable | not_letter | non_swedish (D3-01)
188
+
189
+
190
+ SECTION_ANCHORS = [
191
+ ("tldr", r"##\s*TL;?DR"),
192
+ ("why", r"##\s*Why you got this"),
193
+ ("actions", r"##\s*What you need to do"),
194
+ ("deadlines", r"##\s*Deadlines\s*&\s*money"),
195
+ ]
196
+
197
+
198
+ def _split_sections(text: str) -> dict:
199
+ """Split body text into four sections by fixed Markdown heading anchors (D-05)."""
200
+ result = {}
201
+ for i, (key, pattern) in enumerate(SECTION_ANCHORS):
202
+ m = re.search(pattern, text, re.IGNORECASE)
203
+ if not m:
204
+ continue
205
+ start = m.end()
206
+ if i + 1 < len(SECTION_ANCHORS):
207
+ next_m = re.search(SECTION_ANCHORS[i + 1][1], text, re.IGNORECASE)
208
+ end = next_m.start() if next_m else len(text)
209
+ else:
210
+ end = len(text)
211
+ result[key] = text[start:end].strip()
212
+ return result
213
+
214
+
215
+ DOCTYPE_RE = re.compile(
216
+ r'^DOCTYPE:\s*(letter|unreadable|not_letter|non_swedish)',
217
+ re.MULTILINE | re.IGNORECASE,
218
+ )
219
+
220
+
221
+ def parse_output(raw: str) -> StructuredResult:
222
+ """
223
+ Parse raw model output into a StructuredResult.
224
+
225
+ Language-invariant: anchors on fixed English sentinels regardless of
226
+ the prose language. Never raises — returns empty fields and severity=None
227
+ on malformed/truncated output (T-02-02).
228
+
229
+ DOCTYPE sentinel (D3-01): extracted after <transcription> block, before section
230
+ split. Regex accepts only the four enumerated tokens; any other/absent value
231
+ defaults to "letter" (D3-02 lean-toward-analyzing).
232
+ """
233
+ if not raw:
234
+ return StructuredResult(
235
+ transcription="", quip="", tldr="", why="",
236
+ actions="", deadlines="", severity=None, raw=raw,
237
+ doctype="letter",
238
+ )
239
+
240
+ # 1. Extract and strip the <transcription> block (D-04).
241
+ transcription = ""
242
+ trans_match = re.search(
243
+ r"<transcription>(.*?)</transcription>",
244
+ raw, re.DOTALL | re.IGNORECASE
245
+ )
246
+ if trans_match:
247
+ transcription = trans_match.group(1).strip()
248
+ after_trans = re.sub(
249
+ r"<transcription>.*?</transcription>", "", raw,
250
+ flags=re.DOTALL | re.IGNORECASE
251
+ ).strip()
252
+
253
+ # 2. Parse DOCTYPE: first matching DOCTYPE line (D3-01). Accepts only the four
254
+ # enumerated tokens; defaults to "letter" if absent or unrecognised (D3-02).
255
+ doctype_m = DOCTYPE_RE.search(after_trans)
256
+ doctype = doctype_m.group(1).lower() if doctype_m else "letter"
257
+ # Strip the DOCTYPE line from body so it does not leak into section text.
258
+ after_trans = re.sub(
259
+ r'^DOCTYPE:\s*\S+\s*\n?', "", after_trans, flags=re.MULTILINE | re.IGNORECASE
260
+ ).strip()
261
+
262
+ # 3. Parse severity: LAST matching SEVERITY: N line (D-06). The schema puts
263
+ # SEVERITY on the final line; taking the last match means a stray earlier
264
+ # "SEVERITY:" mention in prose never mis-drives the Panic Meter (WR-02).
265
+ severity = None
266
+ sev_matches = re.findall(r"SEVERITY:\s*([1-5])\s*$", after_trans, re.MULTILINE)
267
+ if sev_matches:
268
+ severity = int(sev_matches[-1])
269
+ body = re.sub(r"\nSEVERITY:\s*[1-5]\s*$", "", after_trans, flags=re.MULTILINE).strip()
270
+
271
+ # 4. Parse the "Bureaucat says:" quip (D-07).
272
+ quip = ""
273
+ quip_match = re.search(r"Bureaucat says:\s*(.+?)(?:\n|$)", body)
274
+ if quip_match:
275
+ quip = quip_match.group(1).strip()
276
+
277
+ # 5. Split four sections by fixed English headings (D-05, Pitfall 3).
278
+ sections = _split_sections(body)
279
+
280
+ return StructuredResult(
281
+ transcription=transcription,
282
+ quip=quip,
283
+ tldr=sections.get("tldr", ""),
284
+ why=sections.get("why", ""),
285
+ actions=sections.get("actions", ""),
286
+ deadlines=sections.get("deadlines", ""),
287
+ severity=severity,
288
+ raw=raw,
289
+ doctype=doctype,
290
+ )
291
+
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # Prompt (D-04–D-08)
295
+ # ---------------------------------------------------------------------------
296
+
297
+ SYSTEM_PROMPT = """\
298
+ You are Bureaucat, an assistant that helps expats understand Swedish official letters.
299
+
300
+ IMPORTANT — output format rules (do not deviate):
301
+ 1. First, write a full verbatim OCR transcription of the letter wrapped in XML tags:
302
+ <transcription>
303
+ [exact text of the letter, every word, number, date]
304
+ </transcription>
305
+ 2. Immediately after </transcription>, classify the document. Write exactly this line
306
+ (always in English, never translated, machine-parsed — do not translate it):
307
+ DOCTYPE: [letter|unreadable|not_letter|non_swedish]
308
+ Valid values:
309
+ - letter = readable Swedish authority or institutional document (analysable)
310
+ - unreadable = too blurry, dark, or low-resolution to read reliably
311
+ - not_letter = readable image but NOT a letter (e.g. photo, receipt, form, ID card)
312
+ - non_swedish = document's PRIMARY language is not Swedish
313
+ (a mostly-Swedish letter with embedded English phrases is still: letter)
314
+ When uncertain, use: letter
315
+ IMPORTANT: if DOCTYPE is not "letter", you MUST:
316
+ - Still write the "Bureaucat says:" quip line (in-voice, playful, one-liner)
317
+ - Still write the DOCTYPE line
318
+ - SKIP the four ## sections (TL;DR, Why you got this, What you need to do, Deadlines & money)
319
+ - SKIP the SEVERITY line
320
+ 3. Then write exactly this line (always in English, always playful):
321
+ Bureaucat says: [your witty one-liner about this letter]
322
+ 4. If DOCTYPE is "letter", write the four sections using EXACTLY these English headings:
323
+ ## TL;DR
324
+ ## Why you got this
325
+ ## What you need to do
326
+ ## Deadlines & money
327
+ (In "Deadlines & money" you MUST list EVERY date, EVERY amount of money, AND
328
+ EVERY reference number found anywhere in the letter. Reference numbers include
329
+ case numbers (ärendenummer), file/dossier numbers (dossiernummer), booking
330
+ numbers (bokningsnummer), and OCR/payment numbers — list them here EVEN IF the
331
+ letter has no payment or deadline. Never leave a reference number out of this
332
+ section.
333
+ Write one item per line. Start each line with the verbatim value from the letter,
334
+ then add a dash and your interpretation. Examples:
335
+ - 15 juni 2026 — last day to file your tax return
336
+ - 1 234 kr — amount to pay
337
+ - 9988776 — case number (ärendenummer)
338
+ Only write "None found." if the letter contains no dates, amounts, OR reference
339
+ numbers of any kind.)
340
+ 5. If DOCTYPE is "letter", add a last line, always in English, never translated:
341
+ SEVERITY: [1-5]
342
+ Rate how worried the reader should be using the FULL 1-5 range. Do NOT default
343
+ to 3 — most letters are NOT a 3. Pick the single number that best fits THIS
344
+ letter's real-world stakes:
345
+ - 1 = purely informational; nothing to do, no deadline, no money owed
346
+ (a confirmation, a receipt, an FYI notice)
347
+ - 2 = minor or routine action, low stakes, soft/distant or no hard deadline
348
+ (book or attend a routine appointment, a small optional fee)
349
+ - 3 = a genuine task with a clear deadline OR a modest amount to pay; manageable
350
+ - 4 = a significant amount owed, OR a firm deadline whose miss has real
351
+ consequences (a repayment demand, a required document submission)
352
+ - 5 = urgent and high-stakes: a large sum, an imminent deadline, or a severe
353
+ consequence such as rejection, debt collection, eviction, or loss of a
354
+ permit / residence status
355
+ Hard rules (these OVERRIDE any instinct to pick 3):
356
+ - If the letter warns of rejection, having to leave Sweden, losing a permit,
357
+ residence status, or benefit, debt collection, or eviction → SEVERITY 5.
358
+ - If the letter demands repayment of a specific sum, or sets a firm deadline
359
+ to submit documents or the case is closed/denied → SEVERITY at least 4.
360
+ - Use 3 only for a routine task with a clear but low-consequence deadline.
361
+ - Use 1-2 for informational notices and routine appointments with no real risk.
362
+
363
+ Write all prose in the language requested in the user's message. Keep the four
364
+ section headings, the DOCTYPE line, and the SEVERITY line in English, never translated.
365
+ Quote all extracted values (dates, amounts, reference numbers) verbatim as they
366
+ appear in the letter — never invent, approximate, or omit them.
367
+ If something is unclear, say so. Do not invent details."""
368
+
369
+
370
+ def build_user_prompt(language: str, beginner_mode: bool) -> str:
371
+ """
372
+ Build the per-call user prompt.
373
+
374
+ beginner_mode adds ONLY inline-explanation guidance within prose — it never
375
+ adds/removes sections or alters the SEVERITY line or transcription block (D-08).
376
+ """
377
+ # Reference-completeness reminder lives in the BASE prompt (both modes). The Phase-3
378
+ # 5x5 matrix surfaced the model dropping a clearly-labelled reference number from
379
+ # "Deadlines & money" — writing "None found." with e.g. "Ärendenummer: 9988776" sitting
380
+ # in the transcription. Under greedy decoding this is deterministic (sampling had merely
381
+ # masked it). The model's *default* is to omit references it doesn't tie to a deadline or
382
+ # amount; this reminder re-anchors the Finding-3 completeness rule per-call without
383
+ # touching the SYSTEM_PROMPT. It is mode-independent (standard mode failed too), so it
384
+ # belongs in the base prompt, not the beginner branch. NOTE: this is a prompt-level
385
+ # mitigation of a genuine model fragility — a deterministic transcription→Deadlines
386
+ # cross-check is the more robust follow-up (tracked for the user).
387
+ prompt = (
388
+ f"Please analyse this letter and respond in {language}."
389
+ "\n\nBefore you write the 'Deadlines & money' section, re-scan the full "
390
+ "transcription and list EVERY date, EVERY amount, AND EVERY reference number "
391
+ "(ärendenummer, dossiernummer, bokningsnummer, OCR-nummer) that appears anywhere "
392
+ "in the letter — each on its own line, verbatim. A reference number must be listed "
393
+ "even when it has no associated deadline or amount. Only write 'None found.' if "
394
+ "there is truly no date, amount, or reference number of any kind in the letter."
395
+ )
396
+ if beginner_mode:
397
+ prompt += (
398
+ "\n\nBeginner mode: within each section's prose, add brief "
399
+ "parenthetical explanations of Swedish institutions or terms "
400
+ "(e.g. Skatteverket, personnummer, OCR-nummer, etc.). "
401
+ "Do not add new sections."
402
+ )
403
+ return prompt
404
+
405
+
406
+ # ---------------------------------------------------------------------------
407
+ # Inference — primary multi-image path + backward-compat single-image wrapper
408
+ # ---------------------------------------------------------------------------
409
+
410
+ def run_inference_multi(
411
+ images: list,
412
+ language: str,
413
+ beginner_mode: bool,
414
+ mdl,
415
+ proc,
416
+ image_patch_size: int,
417
+ ) -> StructuredResult:
418
+ """
419
+ Encode-generate-parse for one or more images in a single inference call.
420
+
421
+ This is the single shared inference path for both the eval harness and the
422
+ Gradio UI (INPUT-01 multi-page). run_inference() delegates here so there
423
+ is exactly one code path — no drift between harness and app.
424
+
425
+ process_vision_info is imported lazily so importing app under
426
+ BUREAUCAT_NO_MODEL=1 never requires qwen_vl_utils.
427
+ """
428
+ from qwen_vl_utils import process_vision_info # lazy: not needed for parse_output tests
429
+
430
+ image_content = [
431
+ {
432
+ "type": "image",
433
+ "image": img,
434
+ "min_pixels": MIN_PIXELS,
435
+ "max_pixels": MAX_PIXELS,
436
+ }
437
+ for img in images
438
+ ]
439
+ messages = [
440
+ {"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT}]},
441
+ {"role": "user", "content": image_content + [
442
+ {"type": "text", "text": build_user_prompt(language, beginner_mode)},
443
+ ]},
444
+ ]
445
+
446
+ chat_text = proc.apply_chat_template(
447
+ messages, tokenize=False, add_generation_prompt=True
448
+ )
449
+ image_inputs, video_inputs = process_vision_info(
450
+ messages, image_patch_size=image_patch_size
451
+ )
452
+ inputs = proc(
453
+ text=[chat_text],
454
+ images=image_inputs,
455
+ videos=video_inputs,
456
+ do_resize=False,
457
+ return_tensors="pt",
458
+ ).to(DEVICE)
459
+ inputs.pop("token_type_ids", None) # Required for Qwen3-VL; harmless on Qwen2.5-VL
460
+
461
+ # Sanity log: if the image didn't become pixel tensors, the model is
462
+ # "reading" a blank page — that would look like plausible nonsense, not an error.
463
+ pv = inputs.get("pixel_values")
464
+ if pv is None:
465
+ return StructuredResult(
466
+ transcription="", quip="", tldr="", why="", actions="", deadlines="",
467
+ severity=None, raw="ERROR: pixel_values missing from inputs",
468
+ )
469
+ print(f"[Bureaucat] pixel_values={tuple(pv.shape)}")
470
+
471
+ # Greedy decoding (do_sample=False), pinned deliberately (MODEL-01 amendment,
472
+ # Phase 3). The shipped Qwen3-VL generation_config defaults to do_sample=true /
473
+ # temperature=0.7 / top_p=0.8 / top_k=20 — i.e. stochastic sampling. For a tool
474
+ # whose core promise is faithful, never-invented extraction, sampling is the wrong
475
+ # regime: it makes the same letter yield different outputs run-to-run (the source of
476
+ # the Phase-3 matrix's intermittent beginner-mode failures) and raises invention risk.
477
+ # Greedy is deterministic and follows the fixed output-format rules more faithfully.
478
+ # NOTE: repetition_penalty / no_repeat_ngram_size are NOT applied to the primary
479
+ # pass — they regressed the gold gate (5/5 → 0/5: no_repeat_ngram_size kills the
480
+ # legitimately-repeating "- value — label" deadline list, emptying whole sections in
481
+ # beginner mode) and the adversarial refusal pass. Pure greedy stays the primary path.
482
+ with torch.no_grad():
483
+ out_ids = mdl.generate(
484
+ **inputs,
485
+ max_new_tokens=MAX_NEW_TOKENS,
486
+ do_sample=False,
487
+ )
488
+
489
+ trimmed = [o[len(i):] for i, o in zip(inputs["input_ids"], out_ids)]
490
+ raw_text = proc.batch_decode(
491
+ trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
492
+ )[0]
493
+ result = parse_output(raw_text)
494
+
495
+ # SEVERITY-recovery fallback (gate-safe by construction). On very dense real letters
496
+ # (long reference-number lists), pure greedy can fall into a degenerate repetition loop
497
+ # in "Deadlines & money" and exhaust the token budget BEFORE emitting the trailing
498
+ # SEVERITY line → severity=None → the Panic Meter shows "unclear" on an otherwise
499
+ # perfectly-read letter. When that happens (letter parsed WITH content but no severity),
500
+ # make ONE tiny, bounded text-only follow-up call asking only for the severity, scored
501
+ # from the transcription the model already produced. It is capped at a handful of tokens
502
+ # so it physically cannot loop (no long list to emit) — unlike a full re-generation,
503
+ # which on the densest letters loops again. This never runs on the gold set (those always
504
+ # emit SEVERITY on pass one), so it cannot affect the eval gate.
505
+ if (
506
+ result.doctype == "letter"
507
+ and result.severity is None
508
+ and (result.tldr or result.why or result.actions or result.deadlines)
509
+ ):
510
+ print("[Bureaucat] SEVERITY missing (dense-letter loop) — bounded severity-only retry")
511
+ result.severity = _recover_severity(result, mdl, proc)
512
+ if result.severity is not None:
513
+ print(f"[Bureaucat] recovered severity={result.severity}")
514
+
515
+ return result
516
+
517
+
518
+ # Compact rubric for the severity-only recovery call — mirrors the SYSTEM_PROMPT scale
519
+ # and hard rules so a recovered severity matches what the primary pass would have produced.
520
+ SEVERITY_ONLY_PROMPT = """You rate how worried the reader of a Swedish authority letter should be, on a 1-5 scale. Use the FULL range; do NOT default to 3.
521
+ - 1 = purely informational; nothing to do, no deadline, no money owed (a refund, a confirmation, an FYI)
522
+ - 2 = minor or routine action, low stakes (a routine appointment, a small fee)
523
+ - 3 = a genuine task with a clear deadline OR a modest amount to pay; manageable
524
+ - 4 = a significant amount owed, OR a firm deadline whose miss has real consequences (a repayment demand, a required document submission)
525
+ - 5 = urgent and high-stakes: a large sum, an imminent deadline, or a severe consequence (rejection, debt collection, eviction, loss of a permit / residence status)
526
+ Hard rules: warns of rejection / leaving Sweden / losing a permit or benefit / debt collection / eviction → 5. Demands repayment of a specific sum, or a firm deadline to submit documents or the case is closed → at least 4.
527
+ Reply with EXACTLY one line and nothing else: SEVERITY: [1-5]"""
528
+
529
+
530
+ def _recover_severity(result, mdl, proc) -> Optional[int]:
531
+ """Bounded, loop-proof text-only severity classification from already-extracted text.
532
+
533
+ Uses the model's own transcription (falling back to the parsed sections) as context.
534
+ Returns an int 1-5, or None if the model still does not emit a parseable line.
535
+ """
536
+ context = result.transcription.strip()
537
+ if not context:
538
+ context = "\n\n".join(
539
+ s for s in (result.tldr, result.why, result.actions, result.deadlines) if s
540
+ ).strip()
541
+ if not context:
542
+ return None
543
+
544
+ messages = [
545
+ {"role": "system", "content": [{"type": "text", "text": SEVERITY_ONLY_PROMPT}]},
546
+ {"role": "user", "content": [{"type": "text", "text": (
547
+ "Swedish authority letter (already transcribed):\n\n"
548
+ + context
549
+ + "\n\nOutput only: SEVERITY: [1-5]"
550
+ )}]},
551
+ ]
552
+ chat_text = proc.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
553
+ sev_inputs = proc(text=[chat_text], return_tensors="pt").to(DEVICE)
554
+ sev_inputs.pop("token_type_ids", None)
555
+ with torch.no_grad():
556
+ sev_ids = mdl.generate(**sev_inputs, max_new_tokens=12, do_sample=False)
557
+ sev_trimmed = [o[len(i):] for i, o in zip(sev_inputs["input_ids"], sev_ids)]
558
+ sev_raw = proc.batch_decode(sev_trimmed, skip_special_tokens=True)[0]
559
+ m = re.search(r"SEVERITY:\s*([1-5])", sev_raw)
560
+ if not m:
561
+ m = re.search(r"\b([1-5])\b", sev_raw) # last-ditch: a bare digit
562
+ return int(m.group(1)) if m else None
563
+
564
+
565
+ def run_inference(
566
+ image,
567
+ language: str,
568
+ beginner_mode: bool,
569
+ mdl,
570
+ proc,
571
+ image_patch_size: int,
572
+ ) -> StructuredResult:
573
+ """
574
+ Single-image entry point — delegates to run_inference_multi([image], ...).
575
+
576
+ Preserved for backward compatibility so eval/run_eval.py calls (lines 329, 335)
577
+ continue to work without modification. Guarantees a single shared code path.
578
+ """
579
+ return run_inference_multi([image], language, beginner_mode, mdl, proc, image_patch_size)
580
+
581
+
582
+ # Accurate short duration = better queue priority; visitors only have 3.5 min/day.
583
+ # duration=55: placeholder until measured on dev Space (D-03).
584
+ @spaces.GPU(duration=55)
585
+ def decode(files, language, beginner_mode) -> StructuredResult:
586
+ """
587
+ Gradio entry point — accepts a gr.File payload: list[str] of temp file paths.
588
+
589
+ With file_count="multiple" and type="filepath" (Gradio 6.16.0, confirmed),
590
+ Gradio passes a list of temporary file paths (strings) or None when no file
591
+ has been uploaded yet.
592
+
593
+ Dispatches per file:
594
+ - *.pdf → read to bytes immediately (T-03-05 path-traversal mitigation), render
595
+ via pdf_to_images(); a corrupt/malformed PDF catches PdfiumError and
596
+ returns doctype="unreadable" so it routes through the slice-1 refusal
597
+ path (confused mascot, no crash) — T-03-04.
598
+ - other → Image.open(path).convert("RGB") as before.
599
+
600
+ All rendered pages are concatenated and capped at MAX_PAGES_HARD before
601
+ being passed to run_inference_multi (unchanged).
602
+ """
603
+ if not files:
604
+ return StructuredResult(
605
+ transcription="", quip="", tldr="", why="",
606
+ actions="", deadlines="", severity=None,
607
+ raw="Please upload or photograph a letter first.",
608
+ )
609
+
610
+ from PIL import Image as _PILImage
611
+ from io import BytesIO as _BytesIO
612
+
613
+ images: list = []
614
+ for path in files:
615
+ if path is None:
616
+ continue
617
+ # Read to bytes immediately — never trust or retain the temp path (T-03-05).
618
+ # Detect type by CONTENT, not filename: the custom gr.Server frontend uploads
619
+ # via gradio_client, which lands files as extensionless "blob" temp files, so
620
+ # endswith(".pdf") alone misses every real PDF (→ "No valid images found." on
621
+ # the error card). Sniff the %PDF magic bytes instead; extension is a fallback.
622
+ try:
623
+ with open(path, "rb") as fh:
624
+ data = fh.read()
625
+ except Exception:
626
+ continue
627
+ is_pdf = data[:5].startswith(b"%PDF") or path.lower().endswith(".pdf")
628
+ if is_pdf:
629
+ try:
630
+ page_images = pdf_to_images(data)
631
+ images.extend(page_images)
632
+ except Exception:
633
+ # Any PDF parse failure (corrupt, malicious, truncated) → refusal.
634
+ return StructuredResult(
635
+ transcription="", quip="", tldr="", why="",
636
+ actions="", deadlines="", severity=None,
637
+ doctype="unreadable",
638
+ raw="Could not read the PDF. Please try a different file.",
639
+ )
640
+ else:
641
+ try:
642
+ images.append(_PILImage.open(_BytesIO(data)).convert("RGB"))
643
+ except Exception:
644
+ continue # skip unreadable image files; process remaining pages
645
+
646
+ if not images:
647
+ return StructuredResult(
648
+ transcription="", quip="", tldr="", why="",
649
+ actions="", deadlines="", severity=None,
650
+ raw="No valid images found.",
651
+ )
652
+
653
+ # Hard page cap — truncate before inference (DEFERRED tuning target D-03)
654
+ if len(images) > MAX_PAGES_HARD:
655
+ images = images[:MAX_PAGES_HARD]
656
+
657
+ return run_inference_multi(images, language, beginner_mode, model, processor, IMAGE_PATCH_SIZE)
658
+
659
+
660
+ import html as _html
661
+
662
+
663
+ # ---------------------------------------------------------------------------
664
+ # Example gallery — pre-computed results for zero-GPU demo (UI-01)
665
+ # Each entry: slug, display label, source image path, cached result JSON path.
666
+ # Paths are hardcoded constants (T-02-04-01 mitigation: no user-supplied path).
667
+ # ---------------------------------------------------------------------------
668
+
669
+ # Ordered ascending by severity so the gallery tells a left→right story:
670
+ # all-clear (refund) → routine → action-needed → money owed → urgent.
671
+ EXAMPLE_LETTERS = [
672
+ {
673
+ "slug": "skatteverket-slutskattebesked",
674
+ "label": "Skatteverket — tax refund, good news (severity 1)",
675
+ "image": "data/letters/public/skatteverket-slutskattebesked.png",
676
+ "cached": "data/gallery/skatteverket-slutskattebesked-result.json",
677
+ },
678
+ {
679
+ "slug": "vardcentral-kallelse",
680
+ "label": "Vårdcentral — appointment reminder (severity 2)",
681
+ "image": "data/letters/public/vardcentral-kallelse.png",
682
+ "cached": "data/gallery/vardcentral-kallelse-result.json",
683
+ },
684
+ {
685
+ "slug": "forsakringskassan-komplettering",
686
+ "label": "Försäkringskassan — submit documents (severity 3)",
687
+ "image": "data/letters/public/forsakringskassan-komplettering.png",
688
+ "cached": "data/gallery/forsakringskassan-komplettering-result.json",
689
+ },
690
+ {
691
+ "slug": "csn-aterkrav",
692
+ "label": "CSN — student-aid repayment demand (severity 4)",
693
+ "image": "data/letters/public/csn-aterkrav.png",
694
+ "cached": "data/gallery/csn-aterkrav-result.json",
695
+ },
696
+ {
697
+ "slug": "migrationsverket-uppehallstillstand",
698
+ "label": "Migrationsverket — residence permit at risk (severity 5)",
699
+ "image": "data/letters/public/migrationsverket-uppehallstillstand.png",
700
+ "cached": "data/gallery/migrationsverket-uppehallstillstand-result.json",
701
+ },
702
+ ]
703
+
704
+
705
+ def load_example(evt: gr.SelectData) -> tuple:
706
+ """
707
+ Zero-GPU gallery loader — reads cached JSON from disk, returns full render tuple.
708
+
709
+ NOT decorated with @spaces.GPU. Never calls the model.
710
+ Security: evt.index selects from a hardcoded EXAMPLE_LETTERS list (T-02-04-01:
711
+ no user-supplied path string — load_example is immune to path traversal).
712
+
713
+ Returns the same 7-element tuple as render_result() so gallery.select() and
714
+ the decode .then() chain share identical output bindings.
715
+ """
716
+ entry = EXAMPLE_LETTERS[evt.index]
717
+ data = json.loads(Path(entry["cached"]).read_text(encoding="utf-8"))
718
+ result = StructuredResult(**data)
719
+ # Always render in English (gallery examples are pre-computed in English)
720
+ return render_result(result, "English")
721
+
722
+
723
+ # ---------------------------------------------------------------------------
724
+ # Pure-Python UI renderer functions (no GPU, no model loading)
725
+ # ---------------------------------------------------------------------------
726
+
727
+ def render_quip(quip: str) -> str:
728
+ """
729
+ Render the Bureaucat quip line as a Markdown string.
730
+
731
+ Returns empty string if quip is blank (hides the component).
732
+ The "Bureaucat says:" prefix is prepended HERE at render time — parse_output
733
+ already strips the prefix from the stored .quip field (line 220), so never
734
+ add it at parse time or you get "Bureaucat says: Bureaucat says: …".
735
+ """
736
+ if not quip or not quip.strip():
737
+ return ""
738
+ escaped_quip = _html.escape(quip.strip())
739
+ return f'**Bureaucat says:** *{escaped_quip}*'
740
+
741
+
742
+ def render_deadlines_html(deadlines: str) -> str:
743
+ """
744
+ Render the Deadlines & money section as an HTML string (for gr.HTML).
745
+
746
+ Uses gr.HTML (not gr.Markdown) because Markdown sanitization strips <mark>
747
+ and inline style= attributes (RESEARCH Pitfall 2, T-02-02-01 mitigated).
748
+
749
+ Model text in pills is HTML-escaped before wrapping (T-02-02-01).
750
+
751
+ If deadlines is empty or "None found." → returns plain body text, no pill.
752
+ Otherwise: each "- VALUE — interpretation" line becomes a <li> with the
753
+ verbatim value highlighted in a warm amber <mark> pill.
754
+
755
+ Pill spec (UI-SPEC §Deadlines & Money Card):
756
+ background: #FFF3CD, color: #8B6914, padding: 4px, border-radius: 4px
757
+ """
758
+ if not deadlines or not deadlines.strip():
759
+ return "<p>None found.</p>"
760
+
761
+ # Check for "None found" (case-insensitive)
762
+ if re.match(r"\s*none found\.?\s*$", deadlines, re.IGNORECASE):
763
+ return f"<p>{_html.escape(deadlines.strip())}</p>"
764
+
765
+ lines = deadlines.strip().splitlines()
766
+ items = []
767
+ has_pill_line = False
768
+
769
+ for raw_line in lines:
770
+ line = raw_line.strip()
771
+ if not line:
772
+ continue
773
+
774
+ # Strip leading bullet markers (-, *, •)
775
+ stripped = re.sub(r"^[-*•]\s*", "", line).strip()
776
+ if not stripped:
777
+ continue
778
+
779
+ # Check for "None found" lines
780
+ if re.match(r"none found\.?$", stripped, re.IGNORECASE):
781
+ items.append(f"<li>{_html.escape(stripped)}</li>")
782
+ continue
783
+
784
+ # Split on first interpretation separator (same convention as extract_values_from_section)
785
+ sep_match = re.search(r"\s+[—–\-]\s+", stripped)
786
+ if sep_match:
787
+ value_raw = stripped[: sep_match.start()].strip()
788
+ interpretation_raw = stripped[sep_match.end():].strip()
789
+ value_esc = _html.escape(value_raw)
790
+ interp_esc = _html.escape(interpretation_raw)
791
+ pill = (
792
+ f'<mark style="background:#FFF3CD;color:#8B6914;'
793
+ f'padding:4px;border-radius:4px">{value_esc}</mark>'
794
+ )
795
+ items.append(f"<li>{pill} &mdash; {interp_esc}</li>")
796
+ has_pill_line = True
797
+ else:
798
+ # No separator — render as plain text pill (value = whole line)
799
+ value_esc = _html.escape(stripped)
800
+ pill = (
801
+ f'<mark style="background:#FFF3CD;color:#8B6914;'
802
+ f'padding:4px;border-radius:4px">{value_esc}</mark>'
803
+ )
804
+ items.append(f"<li>{pill}</li>")
805
+ has_pill_line = True
806
+
807
+ if not items:
808
+ return "<p>None found.</p>"
809
+
810
+ if not has_pill_line:
811
+ # Only "None found" lines — render as plain paragraph(s)
812
+ return "<p>" + " ".join(
813
+ _html.escape(re.sub(r"^[-*•]\s*", "", l.strip()).strip())
814
+ for l in lines if l.strip()
815
+ ) + "</p>"
816
+
817
+ return (
818
+ '<ul style="list-style:none;padding:0;margin:0">'
819
+ + "".join(items)
820
+ + "</ul>"
821
+ )
822
+
823
+
824
+ def render_footer() -> str:
825
+ """
826
+ Always-visible footer HTML containing both required trust statements.
827
+
828
+ TRUST-01: privacy note (nothing stored, no external APIs)
829
+ TRUST-05: legal disclaimer (not legal advice)
830
+ """
831
+ return (
832
+ '<div class="bcat-footer">'
833
+ "<p>Everything runs on a small model inside this Space. "
834
+ "Nothing is sent to external APIs. "
835
+ "Nothing is stored after your session ends.</p>"
836
+ "<p>Bureaucat explains letters — it does not give legal advice. "
837
+ "For legal matters, consult a qualified professional.</p>"
838
+ "</div>"
839
+ )
840
+
841
+
842
+ # ---------------------------------------------------------------------------
843
+ # Mascot assets map — Phase 2 wires 6 states; Phase 3 adds confused/wrong_document
844
+ # ---------------------------------------------------------------------------
845
+
846
+ MASCOT_ASSETS = {
847
+ "idle": "assets/mascot/idle.png",
848
+ "reading": "assets/mascot/reading.png",
849
+ "verifying": "assets/mascot/verifying.png",
850
+ "allclear": "assets/mascot/allclear.png",
851
+ "deadline": "assets/mascot/deadline.png",
852
+ "money": "assets/mascot/money.png",
853
+ "confused": "assets/mascot/confused.png", # Phase 3: unreadable input
854
+ "wrong_document": "assets/mascot/wrong_document.png", # Phase 3: not_letter / non_swedish
855
+ }
856
+
857
+ # Result-state detection regexes (D2-06 — do not change without plan approval)
858
+ # Money: numeric amount followed by kr / SEK / currency symbol
859
+ _MONEY_RE = re.compile(r'\d[\d\s]*(?:kr|SEK|€|\$|£)', re.IGNORECASE)
860
+ # Date: "dd Month yyyy" (Swedish month names) OR ISO "yyyy-mm-dd"
861
+ _DATE_RE = re.compile(
862
+ r'\b\d{1,2}\s+(?:jan|feb|mar|apr|maj|jun|jul|aug|sep|okt|nov|dec)\w*\s+\d{4}\b'
863
+ r'|\b\d{4}-\d{2}-\d{2}\b',
864
+ re.IGNORECASE,
865
+ )
866
+
867
+
868
+ # ---------------------------------------------------------------------------
869
+ # Renderers — Plan 03 fills bodies
870
+ # (wired in Plan 02 so the .then() chain is complete; implementations in Plan 03)
871
+ # ---------------------------------------------------------------------------
872
+
873
+ _SEVERITY_COLORS = {
874
+ 1: "#27AE60",
875
+ 2: "#8BC34A",
876
+ 3: "#F39C12",
877
+ 4: "#E67E22",
878
+ 5: "#C0392B",
879
+ }
880
+
881
+ _SEVERITY_LABELS = {
882
+ 1: "1 — Informational",
883
+ 2: "2 — Low concern",
884
+ 3: "3 — Action needed",
885
+ 4: "4 — Urgent",
886
+ 5: "5 — Act immediately",
887
+ }
888
+
889
+
890
+ def render_panic_meter(severity) -> str:
891
+ """Return HTML for the Panic Meter — a color-coded *verdict badge* (FUN-01).
892
+
893
+ severity int 1-5: solid color-tinted badge showing a big "{N}/5" + the
894
+ word label. This deliberately is NOT a horizontal fill bar — a fill bar
895
+ reads as a progress/loading bar (UAT feedback, 2026-06-06).
896
+ severity None: gray "Result unavailable" badge.
897
+ Never color-only (accessibility, UI-SPEC line 92): the badge always shows
898
+ the integer and the word, and the aria-label carries "{N} — {word}".
899
+ """
900
+ if severity is None:
901
+ return (
902
+ '<div class="panic-badge panic-badge--none" role="status" '
903
+ 'style="border-color:#9E9E9E" '
904
+ 'aria-label="Result unavailable — try re-uploading">'
905
+ '<div class="panic-badge__word">Result unavailable — try re-uploading</div>'
906
+ "</div>"
907
+ )
908
+ color = _SEVERITY_COLORS.get(severity, "#9E9E9E")
909
+ label = _SEVERITY_LABELS.get(severity, f"{severity}") # e.g. "3 — Action needed"
910
+ word = label.split("—", 1)[1].strip() if "—" in label else label
911
+ return (
912
+ f'<div class="panic-badge severity-{severity}" role="status" '
913
+ f'style="background:{color}" '
914
+ f'aria-label="Panic level: {label}">'
915
+ f'<div class="panic-badge__num">{severity}<span class="panic-badge__denom">/5</span></div>'
916
+ f'<div class="panic-badge__word">{word}</div>'
917
+ f"</div>"
918
+ )
919
+
920
+
921
+ def render_panic_placeholder() -> str:
922
+ """Neutral initial state for the panic area (before any analysis) so it
923
+ never reads as an empty/stalled progress bar (UAT feedback, 2026-06-06)."""
924
+ return (
925
+ '<div class="panic-badge panic-badge--placeholder" role="status">'
926
+ '<div class="panic-badge__word">🐾 Feed me a letter and I\'ll tell you how worried to be</div>'
927
+ "</div>"
928
+ )
929
+
930
+
931
+ def render_mascot(state: str) -> str:
932
+ """Return HTML for the mascot gr.HTML component (img tag + CSS class).
933
+
934
+ CSS class mascot-{state} drives keyframe animation (CSS-only, no JS).
935
+ Unknown state falls back to idle asset.
936
+ """
937
+ src = MASCOT_ASSETS.get(state, MASCOT_ASSETS["idle"])
938
+ # Gradio 6 serves allowed_paths files under /gradio_api/file= (the bare
939
+ # `file=` route from older Gradio 404s on 6.x). Leading slash → resolves
940
+ # from server root regardless of page path. allowed_paths=["assets"] at launch.
941
+ return (
942
+ f'<img src="/gradio_api/file={src}" '
943
+ f'class="mascot-img mascot-{state}" '
944
+ f'alt="Bureaucat is {state}" />'
945
+ )
946
+
947
+
948
+ def select_result_state(result) -> str:
949
+ """Return the mascot result state based on StructuredResult content (D2-06).
950
+
951
+ Severity 1 is genuinely good / no-stakes news (e.g. a tax REFUND) → the happy
952
+ "allclear" cat, which also drives the celebratory screen effects. This takes
953
+ priority over content: a refund has a money amount but is NOT alarming, so it
954
+ must not get the "money" (shocked) cat.
955
+
956
+ Otherwise content-priority: money > deadline > allclear.
957
+ "none found" in deadlines text short-circuits to allclear immediately.
958
+ """
959
+ if getattr(result, "severity", None) == 1:
960
+ return "allclear"
961
+ deadlines_text = result.deadlines or ""
962
+ if "none found" in deadlines_text.lower():
963
+ return "allclear"
964
+ if _MONEY_RE.search(deadlines_text):
965
+ return "money"
966
+ if _DATE_RE.search(deadlines_text):
967
+ return "deadline"
968
+ return "allclear"
969
+
970
+
971
+ # ---------------------------------------------------------------------------
972
+ # Legacy helper kept for backward compat (replaced by structured render path)
973
+ # ---------------------------------------------------------------------------
974
+
975
+ def _render_result(result: StructuredResult) -> str:
976
+ """Extract displayable text from StructuredResult for the Markdown pane."""
977
+ return result.raw or "No output generated."
978
+
979
+
980
+ # ---------------------------------------------------------------------------
981
+ # CSS — Global styles (brand palette, card styles, RTL text-align rule)
982
+ # Applied via demo.launch(css=GLOBAL_CSS) so it covers the full page.
983
+ # DO NOT pass css= to gr.Blocks() — that emits a UserWarning in Gradio 6.x.
984
+ # The [dir="rtl"] rule supplies the text-align half of the RTL contract
985
+ # (UI-SPEC §Typography line 62) when gr.Markdown's rtl prop sets dir="rtl".
986
+ # ---------------------------------------------------------------------------
987
+
988
+ GLOBAL_CSS = """
989
+ @import url('https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Baloo+2:wght@600;700;800&display=swap');
990
+ .gradio-container { max-width: 1040px !important; margin: 0 auto !important; }
991
+ /* Page background is set per-mode via the theme (.set body_background_fill*)
992
+ — NOT hardcoded here, which was the dark-mode break (light body + light theme text). */
993
+
994
+ /* Hero header — chunky, cartoon, a little bouncy */
995
+ .bcat-hero { display: flex; align-items: center; gap: 16px; padding: 14px 4px 6px; }
996
+ .bcat-hero__emoji { font-size: 54px; line-height: 1; animation: bcat-bounce 2.2s ease-in-out infinite; transform-origin: bottom center; }
997
+ .bcat-hero__title { font-family: "Baloo 2", "Fredoka", sans-serif; font-size: 42px; font-weight: 800; letter-spacing: -0.01em; margin: 0; line-height: 1; background: linear-gradient(90deg, #E91E63, #84CC16); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
998
+ .bcat-hero__tag { font-size: 16px; color: var(--body-text-color-subdued); margin: 4px 0 0; }
999
+ @keyframes bcat-bounce { 0%,100%{transform:translateY(0) scale(1)} 30%{transform:translateY(-10px) scale(1.06)} 50%{transform:translateY(0) scale(0.97)} }
1000
+
1001
+ /* Big chunky tab labels */
1002
+ .tabs button, .tab-nav button { font-family: "Fredoka", sans-serif !important; font-size: 17px !important; font-weight: 600 !important; }
1003
+
1004
+ /* Sassy chunky call-to-action button */
1005
+ .cta-btn { font-family: "Fredoka", sans-serif !important; font-size: 18px !important; font-weight: 700 !important; border-radius: 14px !important; padding: 12px 18px !important; box-shadow: 0 6px 0 0 rgba(0,0,0,0.12) !important; transition: transform 0.08s ease, box-shadow 0.08s ease !important; }
1006
+ .cta-btn:hover { transform: translateY(-2px) !important; box-shadow: 0 8px 0 0 rgba(0,0,0,0.14) !important; }
1007
+ .cta-btn:active { transform: translateY(3px) !important; box-shadow: 0 2px 0 0 rgba(0,0,0,0.14) !important; }
1008
+
1009
+ /* Sassy quip — speech-bubble feel */
1010
+ .quip-line { font-size: 17px !important; font-style: italic; margin-top: 8px !important; }
1011
+ .quip-line p { background: var(--background-fill-secondary); border-radius: 14px; padding: 12px 16px; margin: 0; position: relative; }
1012
+
1013
+ /* Surfaces — theme-aware so both light and dark render correctly.
1014
+ gr.Group(elem_classes="section-card") IS the card; inner blocks are flattened
1015
+ so the heading + prose read as one surface (not nested boxes). */
1016
+ .section-card, .input-card {
1017
+ background: var(--block-background-fill);
1018
+ border: 1px solid var(--border-color-primary);
1019
+ color: var(--body-text-color);
1020
+ border-radius: 14px !important; padding: 14px 18px; margin-bottom: 12px;
1021
+ box-shadow: var(--block-shadow, 0 1px 3px rgba(0,0,0,0.06));
1022
+ overflow: hidden;
1023
+ }
1024
+ .section-card > *, .section-card .block, .section-card .form,
1025
+ .section-card .prose, .section-card .md {
1026
+ background: transparent !important; border: none !important; box-shadow: none !important;
1027
+ }
1028
+ .section-card h3 {
1029
+ font-size: 12px; font-weight: 700; color: var(--body-text-color);
1030
+ opacity: 0.55; margin: 0 0 6px 0; text-transform: uppercase; letter-spacing: 0.07em;
1031
+ }
1032
+
1033
+ /* Panic verdict badge — status chip, deliberately NOT a fill/progress bar */
1034
+ .panic-badge { border-radius: 16px; padding: 16px 20px; color: #fff; display: flex; align-items: baseline; gap: 14px; box-shadow: 0 6px 18px rgba(0,0,0,0.16); margin-bottom: 10px; }
1035
+ .panic-badge__num { font-size: 40px; font-weight: 800; line-height: 1; }
1036
+ .panic-badge__denom { font-size: 18px; font-weight: 600; opacity: 0.8; margin-left: 2px; }
1037
+ .panic-badge__word { font-size: 17px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; align-self: center; }
1038
+ .panic-badge--none, .panic-badge--placeholder { background: var(--background-fill-secondary); color: var(--body-text-color-subdued); border: 1.5px dashed var(--border-color-primary); box-shadow: none; justify-content: center; }
1039
+ .panic-badge--none .panic-badge__word, .panic-badge--placeholder .panic-badge__word { text-transform: none; font-weight: 600; letter-spacing: 0; font-size: 15px; }
1040
+ /* Bouncy pop-in when a real verdict lands (game-feel) */
1041
+ @keyframes pop-in { 0%{transform:scale(0.85);opacity:0} 60%{transform:scale(1.04)} 100%{transform:scale(1);opacity:1} }
1042
+ .panic-badge.severity-1,.panic-badge.severity-2,.panic-badge.severity-3,.panic-badge.severity-4,.panic-badge.severity-5 { animation: pop-in 0.45s cubic-bezier(.22,1.2,.36,1); }
1043
+
1044
+ /* Mascot — compact beside the verdict; bigger & bouncier on the fun tab */
1045
+ .mascot-panel { text-align: center; padding: 0; }
1046
+ .mascot-panel img { transition: opacity 0.2s ease; }
1047
+ .mascot-big img { max-width: 180px !important; }
1048
+ .mascot-big .mascot-idle { animation: bcat-bounce 2s ease-in-out infinite; transform-origin: bottom center; }
1049
+
1050
+ /* Footer — theme-aware trust note */
1051
+ .bcat-footer { padding: 16px; border-top: 1px solid var(--border-color-primary); font-size: 13px; color: var(--body-text-color-subdued); }
1052
+ .bcat-footer p { margin: 4px 0; }
1053
+
1054
+ [dir="rtl"] { text-align: right; }
1055
+ """
1056
+
1057
+
1058
+ # ---------------------------------------------------------------------------
1059
+ # CSS animation keyframes — injected as a gr.HTML style block (first element
1060
+ # inside gr.Blocks context). gr.HTML passes through <style>@keyframes unchanged
1061
+ # (RESEARCH Pattern 1 / Pattern 2 verified against gradio 6.16.0 source).
1062
+ # ---------------------------------------------------------------------------
1063
+
1064
+ ANIMATION_CSS_HTML = """
1065
+ <style>
1066
+ @keyframes bob { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-4px)} }
1067
+ @keyframes sway { 0%,100%{transform:rotate(0deg)} 50%{transform:rotate(3deg)} }
1068
+ @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.6} }
1069
+ @keyframes pop { 0%{transform:scale(1)} 50%{transform:scale(1.1)} 100%{transform:scale(1)} }
1070
+ @keyframes shake { 0%,100%{transform:rotate(0deg)} 25%{transform:rotate(-5deg)} 75%{transform:rotate(5deg)} }
1071
+ .mascot-idle { animation: bob 2s ease-in-out infinite; }
1072
+ .mascot-reading { animation: sway 0.8s ease-in-out infinite; }
1073
+ .mascot-verifying{ animation: blink 0.4s ease infinite; }
1074
+ .mascot-allclear { animation: pop 0.3s ease 1; }
1075
+ .mascot-deadline { animation: shake 0.15s ease-in-out 3; }
1076
+ .mascot-money { animation: shake 0.15s ease-in-out 3; }
1077
+ .mascot-img { transition: opacity 0.2s ease; max-width: 120px; height: auto; min-height: 44px; }
1078
+ </style>
1079
+ """
1080
+
1081
+
1082
+ # ---------------------------------------------------------------------------
1083
+ # Event handler functions (Task 2b)
1084
+ # These run outside the GPU context; only decode() is GPU-decorated.
1085
+ # ---------------------------------------------------------------------------
1086
+
1087
+ def set_reading_state() -> str:
1088
+ """Fires immediately on button click — no GPU. Sets mascot to reading."""
1089
+ return render_mascot("reading")
1090
+
1091
+
1092
+ def run_verifying_state(result) -> str:
1093
+ """
1094
+ Fires after decode() completes — pure Python, no GPU.
1095
+
1096
+ On refusals (doctype != "letter"): returns the appropriate error mascot
1097
+ immediately, bypassing check_no_invention (which would operate on empty
1098
+ values and could raise — Pitfall 2 / T-03-03).
1099
+
1100
+ On successful reads: runs the grounded no-invention check (D2-05), then
1101
+ dwells for 0.6 s so the verifying mascot state is visible in the browser.
1102
+ Without the dwell, Gradio may coalesce this update with the preceding/
1103
+ following step and the verifying state is invisible (RESEARCH Pattern 3 —
1104
+ NON-GPU .then() step, zero GPU quota cost).
1105
+ """
1106
+ # Refusal guard (T-03-03): skip check_no_invention on non-letter doctypes.
1107
+ # render_result's own refusal branch then renders the final panes.
1108
+ if getattr(result, "doctype", "letter") != "letter":
1109
+ mascot_state = "confused" if getattr(result, "doctype", "") == "unreadable" else "wrong_document"
1110
+ return render_mascot(mascot_state)
1111
+ check_no_invention(result) # pure-Python grounded check (eval/grounded.py)
1112
+ time.sleep(0.6) # deliberate dwell — verifying state must be visible
1113
+ return render_mascot("verifying")
1114
+
1115
+
1116
+ REFUSAL_GUIDANCE = {
1117
+ "unreadable": (
1118
+ "The photo is too blurry or dark to read. "
1119
+ "Retake it in better light and make sure the whole letter is in frame and in focus."
1120
+ ),
1121
+ "not_letter": (
1122
+ "That's a fine image, but it doesn't look like a Swedish authority letter. "
1123
+ "Upload a letter from Skatteverket, Försäkringskassan, Migrationsverket, CSN, or similar."
1124
+ ),
1125
+ "non_swedish": (
1126
+ "Bureaucat reads Swedish authority letters. "
1127
+ "This one looks like it's in another language — upload a letter written in Swedish."
1128
+ ),
1129
+ }
1130
+
1131
+
1132
+ def render_refusal(result, language: str) -> tuple:
1133
+ """
1134
+ Render a bad-input refusal as the same 7-element tuple as render_result.
1135
+
1136
+ Returns:
1137
+ [0] panic_html = "" (no Panic Meter for refusals — D3-03)
1138
+ [1] mascot_html = confused for unreadable; wrong_document for not_letter/non_swedish
1139
+ [2] quip_md = model's in-voice refusal quip (already escaped in render_quip)
1140
+ [3] tldr_out = app-side fixed guidance text keyed by doctype (NOT model-driven)
1141
+ [4] why_out = "" (no section)
1142
+ [5] actions_out = "" (no section)
1143
+ [6] deadlines_html = "" (no deadlines)
1144
+
1145
+ Guidance copy is app-side (REFUSAL_GUIDANCE constant) — model only provides the quip.
1146
+ This keeps the function unit-testable without a model (BUREAUCAT_NO_MODEL=1).
1147
+ """
1148
+ is_rtl = language == "Arabic"
1149
+
1150
+ def _wrap_rtl_html(html_str: str) -> str:
1151
+ if is_rtl:
1152
+ return f'<div dir="rtl" style="text-align:right">{html_str}</div>'
1153
+ return html_str
1154
+
1155
+ def _md_update(text: str) -> dict:
1156
+ return gr.update(value=text, rtl=is_rtl)
1157
+
1158
+ doctype = getattr(result, "doctype", "not_letter")
1159
+ mascot_state = "confused" if doctype == "unreadable" else "wrong_document"
1160
+ guidance = REFUSAL_GUIDANCE.get(doctype, REFUSAL_GUIDANCE["not_letter"])
1161
+
1162
+ return (
1163
+ "", # 0 panic_html — no Panic Meter (D3-03)
1164
+ render_mascot(mascot_state), # 1 mascot_html
1165
+ render_quip(result.quip), # 2 quip_md — model's in-voice refusal quip
1166
+ _md_update(guidance), # 3 tldr_out — app-side guidance (not model-driven)
1167
+ _md_update(""), # 4 why_out
1168
+ _md_update(""), # 5 actions_out
1169
+ _wrap_rtl_html(""), # 6 deadlines_html
1170
+ )
1171
+
1172
+
1173
+ def render_result(result, language: str) -> tuple:
1174
+ """
1175
+ Fires after run_verifying_state — renders all output panes.
1176
+
1177
+ Returns a 7-element tuple matching the outputs order:
1178
+ [panic_html(0), mascot_html(1), quip_md(2),
1179
+ tldr_out(3), why_out(4), actions_out(5), deadlines_html(6)]
1180
+
1181
+ RTL delivery-layer contract (UI-SPEC §Typography, T-02-02-01):
1182
+ - Prose panes (3, 4, 5) are gr.Markdown. Their rtl prop is toggled via
1183
+ gr.update(value=..., rtl=True/False) — explicit bool both ways so a
1184
+ component set RTL on a prior Arabic run is reset for the next English run.
1185
+ gr.HTML(<div dir="rtl">) would be STRIPPED by sanitize_html=True on
1186
+ gr.Markdown — that is the silent bug this approach avoids.
1187
+ - Deadlines & Panic gr.HTML (0, 6) are pass-through. For Arabic, wrap in
1188
+ <div dir="rtl" style="text-align:right">. For other languages, no wrapper.
1189
+ - Extracted verbatim values remain Swedish-locale regardless of direction.
1190
+
1191
+ Error handling: severity=None / malformed result → 7-element tuple with error
1192
+ copy in tldr pane; no crash (RESEARCH Pattern 3, UI-SPEC Copywriting Contract).
1193
+ """
1194
+ ERROR_COPY = (
1195
+ "Bureaucat had trouble reading that letter. Try uploading a clearer photo."
1196
+ )
1197
+
1198
+ # Determine if RTL is needed (Arabic only)
1199
+ is_rtl = language == "Arabic"
1200
+
1201
+ def _wrap_rtl_html(html_str: str) -> str:
1202
+ """Wrap gr.HTML content in dir=rtl div for Arabic; leave others bare."""
1203
+ if is_rtl:
1204
+ return f'<div dir="rtl" style="text-align:right">{html_str}</div>'
1205
+ return html_str
1206
+
1207
+ def _md_update(text: str) -> dict:
1208
+ """Return gr.update dict for a gr.Markdown prose pane with explicit rtl."""
1209
+ return gr.update(value=text, rtl=is_rtl)
1210
+
1211
+ # 1. Intentional refusal — doctype check FIRST (D3-03).
1212
+ # Refusals legitimately have severity=None, so this branch MUST precede
1213
+ # the severity=None malformed check below (routing order is load-bearing).
1214
+ if result is not None and getattr(result, "doctype", "letter") != "letter":
1215
+ return render_refusal(result, language)
1216
+
1217
+ # 2. Malformed / error path — only when there is NOTHING to show. A letter that
1218
+ # produced an analysis but whose trailing SEVERITY line got lost (e.g. a greedy
1219
+ # repetition loop on a dense letter) still has real content — salvage it below
1220
+ # rather than discarding it with a false "couldn't read" error.
1221
+ _has_content = result is not None and (
1222
+ getattr(result, "tldr", "") or getattr(result, "why", "")
1223
+ or getattr(result, "actions", "") or getattr(result, "deadlines", "")
1224
+ )
1225
+ if result is None or not hasattr(result, "severity") or (result.severity is None and not _has_content):
1226
+ return (
1227
+ render_panic_meter(None), # 0 panic_html
1228
+ render_mascot("idle"), # 1 mascot_html
1229
+ "", # 2 quip_md
1230
+ _md_update(ERROR_COPY), # 3 tldr_out
1231
+ _md_update(""), # 4 why_out
1232
+ _md_update(""), # 5 actions_out
1233
+ _wrap_rtl_html(""), # 6 deadlines_html
1234
+ )
1235
+
1236
+ # Render the result state (money / deadline / allclear) for the mascot
1237
+ state = select_result_state(result)
1238
+
1239
+ return (
1240
+ _wrap_rtl_html(render_panic_meter(result.severity)), # 0 panic_html
1241
+ render_mascot(state), # 1 mascot_html
1242
+ render_quip(result.quip), # 2 quip_md (always English)
1243
+ _md_update(result.tldr or ""), # 3 tldr_out
1244
+ _md_update(result.why or ""), # 4 why_out
1245
+ _md_update(result.actions or ""), # 5 actions_out
1246
+ _wrap_rtl_html(render_deadlines_html(result.deadlines)), # 6 deadlines_html
1247
+ )
1248
+
1249
+
1250
+ # ---------------------------------------------------------------------------
1251
+ # Gradio UI — Full gr.Blocks layout (Task 2a: static structure; Task 2b: event wiring)
1252
+ # ---------------------------------------------------------------------------
1253
+
1254
+ # Game-feel confetti — runs in the browser via demo.load(js=...) because a
1255
+ # <script> injected through gr.HTML's innerHTML never executes. Loads the tiny
1256
+ # canvas-confetti lib from CDN, then a MutationObserver fires a burst whenever
1257
+ # the all-clear mascot appears (good news only). Best-effort: no-ops if the CDN
1258
+ # is blocked. Zero GPU cost.
1259
+ CONFETTI_JS = """
1260
+ () => {
1261
+ if (!window.__bcatConfettiLoaded) {
1262
+ const s = document.createElement('script');
1263
+ s.src = 'https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js';
1264
+ document.head.appendChild(s);
1265
+ window.__bcatConfettiLoaded = true;
1266
+ }
1267
+ const fire = () => {
1268
+ if (window.confetti) {
1269
+ window.confetti({ particleCount: 110, spread: 75, origin: { y: 0.65 },
1270
+ colors: ['#E91E63', '#84CC16', '#FFD166', '#FFFFFF'] });
1271
+ }
1272
+ };
1273
+ if (!window.__bcatObserver) {
1274
+ window.__bcatObserver = new MutationObserver(() => {
1275
+ if (document.querySelector('.mascot-allclear')) {
1276
+ const now = Date.now();
1277
+ if (!window.__bcatLastFire || now - window.__bcatLastFire > 3000) {
1278
+ window.__bcatLastFire = now;
1279
+ setTimeout(fire, 150);
1280
+ }
1281
+ }
1282
+ });
1283
+ window.__bcatObserver.observe(document.body,
1284
+ { subtree: true, childList: true, attributes: true, attributeFilter: ['class'] });
1285
+ }
1286
+ }
1287
+ """
1288
+
1289
+
1290
+ with gr.Blocks(title="Bureaucat") as demo:
1291
+ # Inject animation keyframes as FIRST element (gr.HTML passes <style> unchanged)
1292
+ gr.HTML(ANIMATION_CSS_HTML)
1293
+
1294
+ # Hero header
1295
+ gr.HTML(
1296
+ '<div class="bcat-hero">'
1297
+ '<div class="bcat-hero__emoji">🐱</div>'
1298
+ '<div>'
1299
+ '<h1 class="bcat-hero__title">Bureaucat</h1>'
1300
+ '<p class="bcat-hero__tag">Snap a scary Swedish letter. I\'ll tell you how worried to be — and what to do.</p>'
1301
+ '</div>'
1302
+ '</div>'
1303
+ )
1304
+
1305
+ # gr.State stores the StructuredResult between .then() steps (RESEARCH Pitfall 5)
1306
+ result_state = gr.State(value=None)
1307
+
1308
+ # ---- Input bar (shared, sits above the tabs — upload front and centre) ----
1309
+ with gr.Row(elem_classes="input-card", equal_height=True):
1310
+ with gr.Column(scale=2, min_width=240):
1311
+ # Combined uploader: accepts images AND PDF (INPUT-02).
1312
+ # gr.File with type="filepath" (Gradio 6.16.0, verified 2026-06-06) passes
1313
+ # list[str] of temp file paths to decode(); file_count="multiple" enables
1314
+ # multi-page letter upload (one image per scanned page, or one multi-page PDF).
1315
+ input_file = gr.File(
1316
+ file_count="multiple",
1317
+ file_types=[".jpg", ".jpeg", ".png", ".pdf"],
1318
+ type="filepath",
1319
+ label="Upload letter — image(s) or PDF",
1320
+ )
1321
+ with gr.Column(scale=1, min_width=200):
1322
+ # English-only product (hackathon scope, 2026-06-07): Bureaucat reads Swedish
1323
+ # letters and explains them in English. The multi-language output selector was
1324
+ # removed — the model's non-Latin translation (Arabic/Hindi) was fragile under
1325
+ # deterministic greedy decoding, and English is the single supported output.
1326
+ # A fixed "English" flows through the .then() chain via gr.State so decode() and
1327
+ # render_result() keep their existing (language) signatures and tests unchanged.
1328
+ lang = gr.State("English")
1329
+ beginner = gr.Checkbox(
1330
+ value=True,
1331
+ label="I'm new to Sweden — explain the institutions & jargon",
1332
+ )
1333
+ btn = gr.Button("🐾 Read it for me!", variant="primary", elem_classes="cta-btn")
1334
+
1335
+ # ---- Tabs: fun & sassy by default, the rigorous breakdown one click away ----
1336
+ with gr.Tabs():
1337
+ # DEFAULT TAB — the fun verdict: big reacting mascot + panic badge + sass
1338
+ with gr.Tab("😼 How bad is it?"):
1339
+ with gr.Row(equal_height=True):
1340
+ with gr.Column(scale=1, min_width=150):
1341
+ mascot_html = gr.HTML(
1342
+ render_mascot("idle"), elem_classes="mascot-panel mascot-big"
1343
+ )
1344
+ with gr.Column(scale=2, min_width=240):
1345
+ # Panic Meter verdict badge (FUN-01)
1346
+ panic_html = gr.HTML(render_panic_placeholder())
1347
+ # Bureaucat's sassy one-liner (the personality moment)
1348
+ quip_md = gr.Markdown(elem_classes="quip-line")
1349
+ # The punchy plain-language summary lives on the fun tab
1350
+ with gr.Group(elem_classes="section-card"):
1351
+ gr.HTML("<h3>The short version</h3>")
1352
+ tldr_out = gr.Markdown(rtl=False)
1353
+
1354
+ # DETAILS TAB — the actual findings
1355
+ with gr.Tab("🔍 The full breakdown"):
1356
+ with gr.Group(elem_classes="section-card"):
1357
+ gr.HTML("<h3>Why you got this</h3>")
1358
+ why_out = gr.Markdown(rtl=False)
1359
+
1360
+ with gr.Group(elem_classes="section-card"):
1361
+ gr.HTML("<h3>What you need to do</h3>")
1362
+ actions_out = gr.Markdown(rtl=False)
1363
+
1364
+ # Deadlines card — gr.HTML (not gr.Markdown — sanitize strips <mark> RESEARCH Pitfall 2)
1365
+ with gr.Group(elem_classes="section-card"):
1366
+ gr.HTML("<h3>Deadlines &amp; money</h3>")
1367
+ deadlines_html = gr.HTML()
1368
+
1369
+ # Example gallery row — pre-computed analyses, zero GPU cost (UI-01)
1370
+ with gr.Row():
1371
+ with gr.Column():
1372
+ gr.Markdown(
1373
+ "### 👇 No scary letter handy? Borrow one of mine\n\n"
1374
+ "_Tap any example — I've already read these, so it costs you zero GPU._"
1375
+ )
1376
+ example_gallery = gr.Gallery(
1377
+ value=[entry["image"] for entry in EXAMPLE_LETTERS],
1378
+ label=None,
1379
+ show_label=False,
1380
+ columns=5,
1381
+ rows=1,
1382
+ height="auto",
1383
+ object_fit="contain",
1384
+ interactive=False,
1385
+ allow_preview=False,
1386
+ )
1387
+
1388
+ # Always-visible footer (outside any accordion, TRUST-01 + TRUST-05, D2-17)
1389
+ with gr.Row():
1390
+ gr.HTML(render_footer())
1391
+
1392
+ # ---------------------------------------------------------------------------
1393
+ # Event chain (Task 2b) — .then() pattern (RESEARCH Pattern 3):
1394
+ # click → set_reading_state (no GPU, immediate mascot update)
1395
+ # .then → decode (GPU, stores StructuredResult in gr.State)
1396
+ # .then → run_verifying_state (pure Python, 0.6 s dwell, verifying mascot)
1397
+ # .then → render_result (pure Python, renders all output panes)
1398
+ #
1399
+ # decode is called DIRECTLY as event handler — NOT wrapped in a generator
1400
+ # (RESEARCH Anti-Patterns: generator wrapping may prevent ZeroGPU recognition).
1401
+ # ---------------------------------------------------------------------------
1402
+ dep = btn.click(
1403
+ set_reading_state,
1404
+ inputs=None,
1405
+ outputs=mascot_html,
1406
+ )
1407
+ dep2 = dep.then(
1408
+ decode,
1409
+ inputs=[input_file, lang, beginner],
1410
+ outputs=result_state,
1411
+ )
1412
+ dep3 = dep2.then(
1413
+ run_verifying_state,
1414
+ inputs=result_state,
1415
+ outputs=mascot_html,
1416
+ )
1417
+ dep4 = dep3.then( # noqa: F841
1418
+ render_result,
1419
+ inputs=[result_state, lang],
1420
+ outputs=[panic_html, mascot_html, quip_md, tldr_out, why_out, actions_out, deadlines_html],
1421
+ )
1422
+
1423
+ # Example gallery select — zero-GPU loader (UI-01 gallery click → cached JSON render)
1424
+ # No @spaces.GPU on load_example; outputs mirror dep4 exactly so no new binding needed.
1425
+ example_gallery.select( # noqa: F841
1426
+ load_example,
1427
+ inputs=None,
1428
+ outputs=[panic_html, mascot_html, quip_md, tldr_out, why_out, actions_out, deadlines_html],
1429
+ )
1430
+
1431
+ # Selecting a new file clears any previous verdict (stale-result bug) —
1432
+ # same 7-element binding as dep4, pure Python, zero GPU.
1433
+ def reset_panes() -> tuple:
1434
+ return (
1435
+ render_panic_placeholder(),
1436
+ render_mascot("idle"),
1437
+ "",
1438
+ gr.update(value="", rtl=False),
1439
+ gr.update(value="", rtl=False),
1440
+ gr.update(value="", rtl=False),
1441
+ "",
1442
+ )
1443
+
1444
+ input_file.change( # noqa: F841
1445
+ reset_panes,
1446
+ inputs=None,
1447
+ outputs=[panic_html, mascot_html, quip_md, tldr_out, why_out, actions_out, deadlines_html],
1448
+ )
1449
+
1450
+ # Game-feel: confetti when the verdict is all-clear (good news). Browser-side.
1451
+ demo.load(js=CONFETTI_JS)
1452
+
1453
+ # ---------------------------------------------------------------------------
1454
+ # Phase 4 — gr.Server custom frontend (Off-Brand badge)
1455
+ #
1456
+ # Opt-in via BUREAUCAT_UI=server (the proven Blocks UI stays the default, so
1457
+ # the Space can fall back instantly by unsetting one env var).
1458
+ #
1459
+ # ZeroGPU pattern (verified against ysharma/text-behind-image + the official
1460
+ # server-mode guide): @spaces.GPU stays on decode(); the @server.api endpoint
1461
+ # is a plain wrapper that calls it. The browser MUST call endpoints through
1462
+ # @gradio/client (it forwards the X-IP-Token header ZeroGPU quota needs) —
1463
+ # raw fetch() would land every visitor in the anonymous quota tier.
1464
+ # ---------------------------------------------------------------------------
1465
+
1466
+ def _deadline_items(deadlines: str) -> list:
1467
+ """
1468
+ Parse the "Deadlines & money" section into [{value, note}, ...] for the
1469
+ custom frontend (value pills + the deadline banner).
1470
+
1471
+ Mirrors render_deadlines_html's line conventions (bullet strip, the
1472
+ space-padded —/–/- interpretation separator, "None found" filtering) but
1473
+ returns data instead of HTML — the JSON API contract for server mode.
1474
+ """
1475
+ items = []
1476
+ if not deadlines or re.match(r"\s*none found\.?\s*$", deadlines.strip(), re.IGNORECASE):
1477
+ return items
1478
+ for raw_line in deadlines.strip().splitlines():
1479
+ stripped = re.sub(r"^[-*•]\s*", "", raw_line.strip()).strip()
1480
+ if not stripped or re.match(r"none found\.?$", stripped, re.IGNORECASE):
1481
+ continue
1482
+ sep = re.search(r"\s+[—–\-]\s+", stripped)
1483
+ if sep:
1484
+ items.append({
1485
+ "value": stripped[: sep.start()].strip(),
1486
+ "note": stripped[sep.end():].strip(),
1487
+ })
1488
+ else:
1489
+ items.append({"value": stripped, "note": ""})
1490
+ return items
1491
+
1492
+
1493
+ def _result_to_payload(result) -> dict:
1494
+ """
1495
+ Convert a StructuredResult into the JSON payload the custom frontend
1496
+ renders. Routing mirrors render_result(): refusal first (doctype is
1497
+ load-bearing — refusals legitimately have severity=None), then malformed,
1498
+ then success. Success payloads run the grounded no-invention check and
1499
+ expose its verdict so the frontend can show a verification badge.
1500
+ """
1501
+ if result is not None and getattr(result, "doctype", "letter") != "letter":
1502
+ doctype = getattr(result, "doctype", "not_letter")
1503
+ return {
1504
+ "kind": "refusal",
1505
+ "doctype": doctype,
1506
+ "mascot": "confused" if doctype == "unreadable" else "wrong_document",
1507
+ "quip": result.quip or "",
1508
+ "guidance": REFUSAL_GUIDANCE.get(doctype, REFUSAL_GUIDANCE["not_letter"]),
1509
+ }
1510
+
1511
+ # Error only when there is genuinely nothing to show. A letter that produced
1512
+ # an analysis but lost its trailing SEVERITY line (e.g. a greedy repetition
1513
+ # loop on a dense letter) still has real content — render it with the gauge
1514
+ # in an "unclear" state instead of a false "couldn't read" error.
1515
+ _has_content = result is not None and (
1516
+ result.tldr or result.why or result.actions or result.deadlines
1517
+ )
1518
+ if result is None or (getattr(result, "severity", None) is None and not _has_content):
1519
+ return {
1520
+ "kind": "error",
1521
+ "mascot": "idle",
1522
+ "guidance": "Bureaucat had trouble reading that letter. "
1523
+ "Try uploading a clearer photo.",
1524
+ }
1525
+
1526
+ invented = check_no_invention(result)
1527
+ sev = getattr(result, "severity", None)
1528
+ return {
1529
+ "kind": "letter",
1530
+ "doctype": "letter",
1531
+ "severity": sev, # may be null (SEVERITY line lost)
1532
+ "severity_label": _SEVERITY_LABELS.get(sev) if sev else None,
1533
+ "severity_color": _SEVERITY_COLORS.get(sev, "#9E9E9E"),
1534
+ "mascot": select_result_state(result),
1535
+ "quip": result.quip or "",
1536
+ "tldr": result.tldr or "",
1537
+ "why": result.why or "",
1538
+ "actions": result.actions or "",
1539
+ "deadline_items": _deadline_items(result.deadlines or ""),
1540
+ "deadlines_text": result.deadlines or "",
1541
+ "grounded": not invented,
1542
+ "invented_values": invented,
1543
+ }
1544
+
1545
+
1546
+ def build_server():
1547
+ """
1548
+ Construct the gr.Server custom-frontend app (Off-Brand mode).
1549
+
1550
+ Deferred behind BUREAUCAT_UI=server so the default Blocks deployment never
1551
+ constructs gr.Server or runs its mounts at import — the Space boots exactly
1552
+ like a vanilla Blocks Space unless server mode is explicitly requested.
1553
+ """
1554
+ from fastapi.responses import HTMLResponse
1555
+ from gradio.data_classes import FileData
1556
+ from starlette.staticfiles import StaticFiles
1557
+
1558
+ server = gr.Server()
1559
+
1560
+ @server.api(name="analyze")
1561
+ def api_analyze(files: list[FileData], beginner: bool = True) -> dict:
1562
+ """
1563
+ Custom-frontend inference endpoint. NOT GPU-decorated itself — it calls
1564
+ decode(), which carries @spaces.GPU(duration=55). This wrapper shape keeps
1565
+ image/PDF prep outside the metered GPU window.
1566
+ """
1567
+ # Items arrive as FileData objects OR plain dicts depending on how the
1568
+ # client serializes a list[FileData] param (observed: dicts via gradio_client).
1569
+ paths = []
1570
+ for f in files or []:
1571
+ if f is None:
1572
+ continue
1573
+ p = f.get("path") if isinstance(f, dict) else getattr(f, "path", None)
1574
+ if p:
1575
+ paths.append(p)
1576
+ result = decode(paths, "English", bool(beginner))
1577
+ return _result_to_payload(result)
1578
+
1579
+ @server.api(name="example")
1580
+ def api_example(index: int) -> dict:
1581
+ """
1582
+ Zero-GPU gallery endpoint — same cached JSONs as load_example(). Index is
1583
+ clamped into the hardcoded EXAMPLE_LETTERS list (no user-supplied paths).
1584
+ """
1585
+ entry = EXAMPLE_LETTERS[int(index) % len(EXAMPLE_LETTERS)]
1586
+ data = json.loads(Path(entry["cached"]).read_text(encoding="utf-8"))
1587
+ return _result_to_payload(StructuredResult(**data))
1588
+
1589
+ @server.get("/", response_class=HTMLResponse)
1590
+ async def _homepage():
1591
+ return Path("frontend/index.html").read_text(encoding="utf-8")
1592
+
1593
+ # Custom routes/mounts take priority over Gradio's defaults (server-mode docs).
1594
+ server.mount("/static", StaticFiles(directory="frontend"), name="static")
1595
+ server.mount("/assets", StaticFiles(directory="assets"), name="assets")
1596
+ server.mount("/letters", StaticFiles(directory="data/letters/public"), name="letters")
1597
+ return server
1598
+
1599
+
1600
+ if __name__ == "__main__":
1601
+ if os.getenv("BUREAUCAT_UI", "blocks").strip().lower() == "server":
1602
+ # Off-Brand custom frontend (Phase 4). Theme/CSS live in frontend/.
1603
+ build_server().launch(show_error=True)
1604
+ raise SystemExit(0)
1605
+ # Gradio 6.0 moved BOTH css and theme to launch() (Blocks-constructor args
1606
+ # emit a UserWarning and are ignored). Soft theme + brand hues = the modern
1607
+ # base; GLOBAL_CSS layers the verdict badge / card polish on top.
1608
+ # allowed_paths: "assets" for mascot images; "data" for gallery source images.
1609
+ # Fun, gamified theme: chunky rounded Google font + bright lime/pink accents.
1610
+ # Per-mode page background via the theme (NOT CSS) so dark mode renders
1611
+ # correctly; body text color is theme-driven so hero/footer stay readable.
1612
+ theme = gr.themes.Soft(
1613
+ primary_hue="pink",
1614
+ secondary_hue="lime",
1615
+ neutral_hue="stone",
1616
+ radius_size="lg",
1617
+ font=[gr.themes.GoogleFont("Fredoka"), "ui-sans-serif", "system-ui", "sans-serif"],
1618
+ ).set(
1619
+ body_background_fill="#FFFDF5",
1620
+ body_background_fill_dark="#12131A",
1621
+ )
1622
+ demo.launch(
1623
+ css=GLOBAL_CSS,
1624
+ theme=theme,
1625
+ allowed_paths=["assets", "data"],
1626
+ )
assets/mascot/allclear.png ADDED
assets/mascot/confused.png ADDED
assets/mascot/deadline.png ADDED
assets/mascot/idle.png ADDED
assets/mascot/money.png ADDED
assets/mascot/reading.png ADDED
assets/mascot/verifying.png ADDED
assets/mascot/wrong_document.png ADDED
data/gallery/csn-aterkrav-result.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "transcription": "CSN\nBeslut om återkrav\nStudiemedel höstterminen 2025\nDu har haft högre inkomst än du angav. Därför kräver CSN\ntillbaka en del av det studiemedel du fått.\nBelopp att återbetala: 15 600 kr\nSista betalningsdag: 31 december 2026\nLåntagarnummer: 555444\nDetta är ett kravbrev. Beloppet har förfallit till betalning.\nOm hela beloppet inte betalas senast sista betalningsdag\nlämnas skulden till Kronofogden för indrivning. Det kan leda\ntill utmätning av din lön eller egendom samt en\nbetalningsanmärkning som försvarar för dig att få lån,\nhyreskontrakt och abonnemang.\nCentrala studiestödsnämnden (CSN)",
3
+ "quip": "Your scholarship just turned into a debt collector’s dream — and it’s got a 2026 deadline.",
4
+ "tldr": "You owe 15,600 kr for a scholarship you received in autumn 2025 because your reported income was too low. The CSN demands payment by December 31, 2026. If you don’t pay, they’ll hand your debt over to the tax authority, which can garnish your wages or assets and block you from getting loans, leases, or subscriptions.",
5
+ "why": "You received a student grant (studiemedel) for autumn 2025, but your reported income was lower than your actual income. The Central Student Support Agency (CSN) is now demanding repayment of the difference — 15,600 kr — because they believe you were overpaid.",
6
+ "actions": "You must pay the 15,600 kr by December 31, 2026. If you cannot pay the full amount, you should contact the CSN to discuss payment plans or other options. Failure to pay may result in your debt being transferred to the tax authority (Kronofogden), which can garnish your wages or assets and prevent you from obtaining loans, leases, or subscriptions.",
7
+ "deadlines": "- 31 december 2026 — last day to pay the debt\n- 15 600 kr — amount owed\n- 555444 — loan account number (Låntagarnummer)",
8
+ "severity": 4,
9
+ "raw": "<transcription>\nCSN\nBeslut om återkrav\nStudiemedel höstterminen 2025\nDu har haft högre inkomst än du angav. Därför kräver CSN\ntillbaka en del av det studiemedel du fått.\nBelopp att återbetala: 15 600 kr\nSista betalningsdag: 31 december 2026\nLåntagarnummer: 555444\nDetta är ett kravbrev. Beloppet har förfallit till betalning.\nOm hela beloppet inte betalas senast sista betalningsdag\nlämnas skulden till Kronofogden för indrivning. Det kan leda\ntill utmätning av din lön eller egendom samt en\nbetalningsanmärkning som försvarar för dig att få lån,\nhyreskontrakt och abonnemang.\nCentrala studiestödsnämnden (CSN)\n</transcription>\nDOCTYPE: letter\nBureaucat says: Your scholarship just turned into a debt collector’s dream — and it’s got a 2026 deadline.\n## TL;DR\nYou owe 15,600 kr for a scholarship you received in autumn 2025 because your reported income was too low. The CSN demands payment by December 31, 2026. If you don’t pay, they’ll hand your debt over to the tax authority, which can garnish your wages or assets and block you from getting loans, leases, or subscriptions.\n## Why you got this\nYou received a student grant (studiemedel) for autumn 2025, but your reported income was lower than your actual income. The Central Student Support Agency (CSN) is now demanding repayment of the difference — 15,600 kr — because they believe you were overpaid.\n## What you need to do\nYou must pay the 15,600 kr by December 31, 2026. If you cannot pay the full amount, you should contact the CSN to discuss payment plans or other options. Failure to pay may result in your debt being transferred to the tax authority (Kronofogden), which can garnish your wages or assets and prevent you from obtaining loans, leases, or subscriptions.\n## Deadlines & money\n- 31 december 2026 — last day to pay the debt\n- 15 600 kr — amount owed\n- 555444 — loan account number (Låntagarnummer)\nSEVERITY: 4",
10
+ "doctype": "letter"
11
+ }
data/gallery/forsakringskassan-komplettering-result.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "transcription": "Försäkringskassan\nBegäran om komplettering\nÄrende: bostadsbidrag\nPersonnummer: XXXXXX-XXXX\nVi kan inte fatta beslut om din ansökan om bostadsbidrag\neftersom underlag saknas. Du måste skicka in dina tre senaste\nlönespecifikationer.\nSista svarsdag: den 30 september 2026\nÄrendenummer: 9988776\nOm vi inte har fått dina uppgifter i tid kommer vi att avsluta\närendet utan beslut och din ansökan avslås.\nFörsäkringskassan",
3
+ "quip": "Your housing allowance application is on hold — send the docs or risk getting rejected.",
4
+ "tldr": "The Swedish Social Insurance Agency (Försäkringskassan) is asking you to submit your three most recent pay slips to process your housing allowance application. If you don’t send them by September 30, 2026, your application will be closed without a decision.",
5
+ "why": "Your housing allowance application (bostadsbidrag) is pending because the agency doesn’t have the required documentation — specifically, your three most recent pay slips (lönespecifikationer). Without these, they cannot make a decision.",
6
+ "actions": "Send your three most recent pay slips to Försäkringskassan. Make sure to include your personnummer (XXXXX-XXXX) and reference number (ärendenummer: 9988776) in your submission.",
7
+ "deadlines": "- den 30 september 2026 — last day to submit your documents; if you miss this, your application will be closed without a decision\n- 9988776 — case number (ärendenummer)",
8
+ "severity": 3,
9
+ "raw": "<transcription>\nFörsäkringskassan\nBegäran om komplettering\nÄrende: bostadsbidrag\nPersonnummer: XXXXXX-XXXX\nVi kan inte fatta beslut om din ansökan om bostadsbidrag\neftersom underlag saknas. Du måste skicka in dina tre senaste\nlönespecifikationer.\nSista svarsdag: den 30 september 2026\nÄrendenummer: 9988776\nOm vi inte har fått dina uppgifter i tid kommer vi att avsluta\närendet utan beslut och din ansökan avslås.\nFörsäkringskassan\n</transcription>\nDOCTYPE: letter\nBureaucat says: Your housing allowance application is on hold — send the docs or risk getting rejected.\n## TL;DR\nThe Swedish Social Insurance Agency (Försäkringskassan) is asking you to submit your three most recent pay slips to process your housing allowance application. If you don’t send them by September 30, 2026, your application will be closed without a decision.\n## Why you got this\nYour housing allowance application (bostadsbidrag) is pending because the agency doesn’t have the required documentation — specifically, your three most recent pay slips (lönespecifikationer). Without these, they cannot make a decision.\n## What you need to do\nSend your three most recent pay slips to Försäkringskassan. Make sure to include your personnummer (XXXXX-XXXX) and reference number (ärendenummer: 9988776) in your submission.\n## Deadlines & money\n- den 30 september 2026 — last day to submit your documents; if you miss this, your application will be closed without a decision\n- 9988776 — case number (ärendenummer)\nSEVERITY: 3",
10
+ "doctype": "letter"
11
+ }
data/gallery/migrationsverket-uppehallstillstand-result.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "transcription": "Migrationsverket\nUppmaning att komplettera ansökan\nAnsökan om uppehållstillstånd\nDin ansökan om förlängt uppehållstillstånd kan inte handläggas utan ett giltigt pass. Du måste lämna in en kopia av ditt pass samt anställningsbevis.\nSenast den 15 augusti 2026\nDossiernummer: 12-345678\nDetta är en sista uppmaning. Om handlingarna inte kommer in senast ovanstående datum avslås din ansökan utan ytterligare påminnelse. Ett avslag innebär att du omedelbart förlorar din rätt att vistas i Sverige, blir skyldig att lämna landet och kan komma att utvisas. Ett avslag kan även leda till återreseförbud till hela Schengenområdet.\nMigrationsverket",
3
+ "quip": "Your visa extension is on life support — and the last warning buzzer is about to go off.",
4
+ "tldr": "This is your final warning from Migrationsverket to submit your visa extension documents (passport copy + employment proof) by August 15, 2026. Failure means automatic rejection, immediate loss of right to stay, deportation, and a Schengen ban.",
5
+ "why": "Your visa extension application is incomplete — you haven’t submitted a copy of your passport and employment proof. Migrationsverket has sent you this final notice because they can’t process your application without these documents.",
6
+ "actions": "Submit a copy of your passport and an employment proof document to Migrationsverket before August 15, 2026.",
7
+ "deadlines": "- 15 augusti 2026 — deadline to submit documents; failure means automatic rejection\n- 12-345678 — case number (dossiernummer)",
8
+ "severity": 5,
9
+ "raw": "<transcription>\nMigrationsverket\nUppmaning att komplettera ansökan\nAnsökan om uppehållstillstånd\nDin ansökan om förlängt uppehållstillstånd kan inte handläggas utan ett giltigt pass. Du måste lämna in en kopia av ditt pass samt anställningsbevis.\nSenast den 15 augusti 2026\nDossiernummer: 12-345678\nDetta är en sista uppmaning. Om handlingarna inte kommer in senast ovanstående datum avslås din ansökan utan ytterligare påminnelse. Ett avslag innebär att du omedelbart förlorar din rätt att vistas i Sverige, blir skyldig att lämna landet och kan komma att utvisas. Ett avslag kan även leda till återreseförbud till hela Schengenområdet.\nMigrationsverket\n</transcription>\nDOCTYPE: letter\nBureaucat says: Your visa extension is on life support — and the last warning buzzer is about to go off.\n## TL;DR\nThis is your final warning from Migrationsverket to submit your visa extension documents (passport copy + employment proof) by August 15, 2026. Failure means automatic rejection, immediate loss of right to stay, deportation, and a Schengen ban.\n## Why you got this\nYour visa extension application is incomplete — you haven’t submitted a copy of your passport and employment proof. Migrationsverket has sent you this final notice because they can’t process your application without these documents.\n## What you need to do\nSubmit a copy of your passport and an employment proof document to Migrationsverket before August 15, 2026.\n## Deadlines & money\n- 15 augusti 2026 — deadline to submit documents; failure means automatic rejection\n- 12-345678 — case number (dossiernummer)\nSEVERITY: 5",
10
+ "doctype": "letter"
11
+ }
data/gallery/skatteverket-slutskattebesked-result.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "transcription": "Skatteverket\n\nSlutskattebesked inkomstår 2025\n\nTill: Anna Andersson\nPersonnummer: XXXXXX-XXXX\n\nDitt slutliga skattebesked är nu klart. Beräkningen visar att\ndu har betalat in för mycket skatt för inkomstår 2025. Du\nfår därför tillbaka pengar på skatten.\n\nBelopp att få tillbaka: 4 250 kr\nUtbetalningsdag: 12 november 2026\nReferensnummer: 1234567890\n\nBeloppet betalas ut automatiskt till det bankkonto du har\nanmält till Skatteverket. Du behöver inte göra någonting.\n\nMed vänlig hälsning, Skatteverket",
3
+ "quip": "You overpaid your taxes — Sweden’s version of a refund check, delivered with zero effort required.",
4
+ "tldr": "You overpaid your 2025 income tax, and Sweden’s Tax Agency (Skatteverket) will automatically refund you 4,250 kr on November 12, 2026. No action needed from you.",
5
+ "why": "This is your final tax assessment for 2025. The calculation shows you paid more tax than you owed, so you’re getting a refund.",
6
+ "actions": "Nothing. The refund will be automatically deposited into the bank account you registered with Skatteverket.",
7
+ "deadlines": "- 4 250 kr — amount to be refunded\n- 12 november 2026 — refund payment date\n- 1234567890 — reference number (Referensnummer)",
8
+ "severity": 1,
9
+ "raw": "<transcription>\nSkatteverket\n\nSlutskattebesked inkomstår 2025\n\nTill: Anna Andersson\nPersonnummer: XXXXXX-XXXX\n\nDitt slutliga skattebesked är nu klart. Beräkningen visar att\ndu har betalat in för mycket skatt för inkomstår 2025. Du\nfår därför tillbaka pengar på skatten.\n\nBelopp att få tillbaka: 4 250 kr\nUtbetalningsdag: 12 november 2026\nReferensnummer: 1234567890\n\nBeloppet betalas ut automatiskt till det bankkonto du har\nanmält till Skatteverket. Du behöver inte göra någonting.\n\nMed vänlig hälsning, Skatteverket\n</transcription>\nDOCTYPE: letter\nBureaucat says: You overpaid your taxes — Sweden’s version of a refund check, delivered with zero effort required.\n## TL;DR\nYou overpaid your 2025 income tax, and Sweden’s Tax Agency (Skatteverket) will automatically refund you 4,250 kr on November 12, 2026. No action needed from you.\n## Why you got this\nThis is your final tax assessment for 2025. The calculation shows you paid more tax than you owed, so you’re getting a refund.\n## What you need to do\nNothing. The refund will be automatically deposited into the bank account you registered with Skatteverket.\n## Deadlines & money\n- 4 250 kr — amount to be refunded\n- 12 november 2026 — refund payment date\n- 1234567890 — reference number (Referensnummer)\nSEVERITY: 1",
10
+ "doctype": "letter"
11
+ }
data/gallery/vardcentral-kallelse-result.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "transcription": "Vårdcentral Centrum\n\nKallelse till besök\n\nTill: Erik Eriksson\n\nDu är välkommen på ett bokat besök hos din distriktssköterska\nför en hälsokontroll.\n\nTid: den 5 maj 2026 kl 10:30\nPatientavgift: 250 kr\nBokningsnummer: 778899\n\nOm du får förhinder, av- eller omboka senast 24 timmar innan,\nannars debiteras du avgiften ändå.\n\nVårdcentral Centrum",
3
+ "quip": "Your health check-up is booked — and your wallet is being gently reminded to pay up.",
4
+ "tldr": "You have a booked appointment with your district nurse for a health check-up on May 5, 2026, at 10:30. There’s a 250 kr patient fee, and you must cancel or reschedule at least 24 hours in advance to avoid being charged.",
5
+ "why": "This is a formal confirmation from Vårdcentral Centrum (Health Center Centrum) that you have a scheduled health check-up with your district nurse. It also includes the fee and cancellation policy.",
6
+ "actions": "- Attend your appointment on May 5, 2026, at 10:30.\n- If you cannot attend, cancel or reschedule at least 24 hours before the appointment to avoid being charged the fee.",
7
+ "deadlines": "- den 5 maj 2026 — appointment date\n- 250 kr — patient fee\n- 778899 — booking number (bokningsnummer)",
8
+ "severity": 2,
9
+ "raw": "<transcription>\nVårdcentral Centrum\n\nKallelse till besök\n\nTill: Erik Eriksson\n\nDu är välkommen på ett bokat besök hos din distriktssköterska\nför en hälsokontroll.\n\nTid: den 5 maj 2026 kl 10:30\nPatientavgift: 250 kr\nBokningsnummer: 778899\n\nOm du får förhinder, av- eller omboka senast 24 timmar innan,\nannars debiteras du avgiften ändå.\n\nVårdcentral Centrum\n</transcription>\nDOCTYPE: letter\nBureaucat says: Your health check-up is booked — and your wallet is being gently reminded to pay up.\n## TL;DR\nYou have a booked appointment with your district nurse for a health check-up on May 5, 2026, at 10:30. There’s a 250 kr patient fee, and you must cancel or reschedule at least 24 hours in advance to avoid being charged.\n\n## Why you got this\nThis is a formal confirmation from Vårdcentral Centrum (Health Center Centrum) that you have a scheduled health check-up with your district nurse. It also includes the fee and cancellation policy.\n\n## What you need to do\n- Attend your appointment on May 5, 2026, at 10:30.\n- If you cannot attend, cancel or reschedule at least 24 hours before the appointment to avoid being charged the fee.\n\n## Deadlines & money\n- den 5 maj 2026 — appointment date\n- 250 kr — patient fee\n- 778899 — booking number (bokningsnummer)\nSEVERITY: 2",
10
+ "doctype": "letter"
11
+ }
data/letters/generate_synthetic_gold.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Synthetic gold-letter generator for the Phase 1 bake-off (Plan 01-04).
3
+
4
+ These are SYNTHETIC, lower-fidelity gold letters (the builder opted to synthesize
5
+ rather than supply real redacted letters). They exist to validate the EVAL-02
6
+ harness end-to-end and lock a model — NOT as a high-fidelity production accuracy
7
+ gate. Provenance is documented here and in the SUMMARY.
8
+
9
+ Design contract that makes the gate reliable:
10
+ - Every `verbatim_swedish` value in a sidecar is the SAME Python string that is
11
+ rendered into the PNG. Recall (D-13) therefore cannot fail for transcription
12
+ reasons — only for genuine model extraction/formatting failures.
13
+ - Values are rendered on their own clearly-labelled lines so OCR is trivial.
14
+ - No real Swedish personnummer is rendered (PII hook compliance): the redacted
15
+ placeholder ``XXXXXX-XXXX`` is used.
16
+ - Authentic Swedish diacritics (å ä ö) are used throughout the body and sender
17
+ text so the bake-off exercises the model's diacritic OCR and the harness's
18
+ NFC normalization path end-to-end (WR-03). The matched verbatim_swedish
19
+ values are dates / amounts / reference numbers, which in Swedish contain no
20
+ diacritics — the diacritic load lands on the transcription/output haystack
21
+ that normalize() processes during the no-invention check.
22
+
23
+ Run: .venv/bin/python data/letters/generate_synthetic_gold.py
24
+ Output: data/letters/public/<slug>.png + data/letters/public/<slug>.json
25
+ """
26
+
27
+ import json
28
+ from pathlib import Path
29
+
30
+ from PIL import Image, ImageDraw, ImageFont
31
+
32
+ OUT_DIR = Path(__file__).resolve().parent / "public"
33
+
34
+ # Page geometry (A4-ish portrait, generous margins for clean OCR)
35
+ PAGE_W, PAGE_H = 1000, 1414
36
+ MARGIN = 80
37
+ TITLE_FONT_PATH = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
38
+ BODY_FONT_PATH = "/System/Library/Fonts/Supplemental/Arial.ttf"
39
+
40
+
41
+ def _font(path: str, size: int) -> ImageFont.FreeTypeFont:
42
+ return ImageFont.truetype(path, size)
43
+
44
+
45
+ def render_letter(slug: str, sender: str, lines: list) -> None:
46
+ """Render `lines` (list of (text, kind) tuples) onto a white page PNG.
47
+
48
+ kind in {"title", "h", "body", "value", "gap"} controls font/spacing.
49
+ """
50
+ img = Image.new("RGB", (PAGE_W, PAGE_H), "white")
51
+ draw = ImageDraw.Draw(img)
52
+
53
+ title_font = _font(TITLE_FONT_PATH, 40)
54
+ head_font = _font(TITLE_FONT_PATH, 26)
55
+ body_font = _font(BODY_FONT_PATH, 24)
56
+ value_font = _font(TITLE_FONT_PATH, 26)
57
+
58
+ y = MARGIN
59
+ # Sender header band
60
+ draw.text((MARGIN, y), sender, fill="black", font=title_font)
61
+ y += 58
62
+ draw.line((MARGIN, y, PAGE_W - MARGIN, y), fill="black", width=2)
63
+ y += 30
64
+
65
+ for text, kind in lines:
66
+ if kind == "gap":
67
+ y += 24
68
+ continue
69
+ if kind == "title":
70
+ draw.text((MARGIN, y), text, fill="black", font=title_font)
71
+ y += 56
72
+ elif kind == "h":
73
+ draw.text((MARGIN, y), text, fill="black", font=head_font)
74
+ y += 40
75
+ elif kind == "value":
76
+ draw.text((MARGIN, y), text, fill="black", font=value_font)
77
+ y += 40
78
+ else: # body — naive wrap at ~62 chars
79
+ for chunk in _wrap(text, 62):
80
+ draw.text((MARGIN, y), chunk, fill="black", font=body_font)
81
+ y += 34
82
+
83
+ img.save(OUT_DIR / f"{slug}.png")
84
+
85
+
86
+ def _wrap(text: str, width: int) -> list:
87
+ words, out, cur = text.split(), [], ""
88
+ for w in words:
89
+ if len(cur) + len(w) + 1 > width:
90
+ out.append(cur)
91
+ cur = w
92
+ else:
93
+ cur = f"{cur} {w}".strip()
94
+ if cur:
95
+ out.append(cur)
96
+ return out or [""]
97
+
98
+
99
+ def write_sidecar(slug: str, gold: dict) -> None:
100
+ (OUT_DIR / f"{slug}.json").write_text(
101
+ json.dumps(gold, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
102
+ )
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Letter definitions. Each value used in `gold` is rendered verbatim below.
107
+ # ---------------------------------------------------------------------------
108
+
109
+ LETTERS = []
110
+
111
+ # 1. Skatteverket — slutskattebesked, tax REFUND (good news, no action). Severity 1.
112
+ LETTERS.append(dict(
113
+ slug="skatteverket-slutskattebesked",
114
+ sender="Skatteverket",
115
+ lines=[
116
+ ("Slutskattebesked inkomståret 2025", "title"),
117
+ ("Till: Anna Andersson", "body"),
118
+ ("Personnummer: XXXXXX-XXXX", "body"),
119
+ ("", "gap"),
120
+ ("Ditt slutliga skattebesked är nu klart. Beräkningen visar att du "
121
+ "har betalat in för mycket skatt för inkomståret 2025. Du får "
122
+ "därför tillbaka pengar på skatten.", "body"),
123
+ ("", "gap"),
124
+ ("Belopp att få tillbaka: 4 250 kr", "value"),
125
+ ("Utbetalningsdag: 12 november 2026", "value"),
126
+ ("Referensnummer: 1234567890", "value"),
127
+ ("", "gap"),
128
+ ("Beloppet betalas ut automatiskt till det bankkonto du har anmält "
129
+ "till Skatteverket. Du behöver inte göra någonting.", "body"),
130
+ ("", "gap"),
131
+ ("Med vänlig hälsning, Skatteverket", "body"),
132
+ ],
133
+ gold=dict(
134
+ expected_severity=1,
135
+ deadlines=[{"verbatim_swedish": "12 november 2026", "meaning": "utbetalningsdag för skatteåterbäring"}],
136
+ amounts=[{"verbatim_swedish": "4 250 kr", "meaning": "belopp att få tillbaka"}],
137
+ references=[{"verbatim_swedish": "1234567890", "meaning": "referensnummer"}],
138
+ ),
139
+ ))
140
+
141
+ # 2. Försäkringskassan — begäran om komplettering, benefit at risk. Severity 3.
142
+ LETTERS.append(dict(
143
+ slug="forsakringskassan-komplettering",
144
+ sender="Försäkringskassan",
145
+ lines=[
146
+ ("Begäran om komplettering", "title"),
147
+ ("Ärende: bostadsbidrag", "body"),
148
+ ("Personnummer: XXXXXX-XXXX", "body"),
149
+ ("", "gap"),
150
+ ("Vi kan inte fatta beslut om din ansökan om bostadsbidrag eftersom "
151
+ "underlag saknas. Du måste skicka in dina tre senaste lönespecifikationer.", "body"),
152
+ ("", "gap"),
153
+ ("Sista svarsdag: den 30 september 2026", "value"),
154
+ ("Ärendenummer: 9988776", "value"),
155
+ ("", "gap"),
156
+ ("Om vi inte har fått dina uppgifter i tid kommer vi att avsluta "
157
+ "ärendet utan beslut och din ansökan avslås.", "body"),
158
+ ("", "gap"),
159
+ ("Försäkringskassan", "body"),
160
+ ],
161
+ gold=dict(
162
+ expected_severity=3,
163
+ deadlines=[{"verbatim_swedish": "30 september 2026", "meaning": "sista svarsdag för komplettering"}],
164
+ amounts=[],
165
+ references=[{"verbatim_swedish": "9988776", "meaning": "ärendenummer"}],
166
+ ),
167
+ ))
168
+
169
+ # 3. Migrationsverket — uppmaning, residence permit. Severity 5.
170
+ LETTERS.append(dict(
171
+ slug="migrationsverket-uppehallstillstand",
172
+ sender="Migrationsverket",
173
+ lines=[
174
+ ("Uppmaning att komplettera ansökan", "title"),
175
+ ("Ansökan om uppehållstillstånd", "body"),
176
+ ("", "gap"),
177
+ ("Din ansökan om förlängt uppehållstillstånd kan inte handläggas "
178
+ "utan ett giltigt pass. Du måste lämna in en kopia av ditt pass "
179
+ "samt anställningsbevis.", "body"),
180
+ ("", "gap"),
181
+ ("Senast den 15 augusti 2026", "value"),
182
+ ("Dossiernummer: 12-345678", "value"),
183
+ ("", "gap"),
184
+ ("Detta är en sista uppmaning. Om handlingarna inte kommer in senast "
185
+ "ovanstående datum avslås din ansökan utan ytterligare påminnelse. "
186
+ "Ett avslag innebär att du omedelbart förlorar din rätt att vistas i "
187
+ "Sverige, blir skyldig att lämna landet och kan komma att utvisas. "
188
+ "Ett avslag kan även leda till återreseförbud till hela Schengenområdet.", "body"),
189
+ ("", "gap"),
190
+ ("Migrationsverket", "body"),
191
+ ],
192
+ gold=dict(
193
+ expected_severity=5,
194
+ deadlines=[{"verbatim_swedish": "15 augusti 2026", "meaning": "deadline att lämna in pass"}],
195
+ amounts=[],
196
+ references=[{"verbatim_swedish": "12-345678", "meaning": "dossiernummer"}],
197
+ ),
198
+ ))
199
+
200
+ # 4. CSN — återkrav of study allowance, debt-collection consequence. Severity 4.
201
+ LETTERS.append(dict(
202
+ slug="csn-aterkrav",
203
+ sender="CSN",
204
+ lines=[
205
+ ("Beslut om återkrav", "title"),
206
+ ("Studiemedel höstterminen 2025", "body"),
207
+ ("", "gap"),
208
+ ("Du har haft högre inkomst än du angav. Därför kräver CSN tillbaka "
209
+ "en del av det studiemedel du fått.", "body"),
210
+ ("", "gap"),
211
+ ("Belopp att återbetala: 15 600 kr", "value"),
212
+ ("Sista betalningsdag: 31 december 2026", "value"),
213
+ ("Låntagarnummer: 555444", "value"),
214
+ ("", "gap"),
215
+ ("Detta är ett kravbrev. Beloppet har förfallit till betalning. Om "
216
+ "hela beloppet inte betalas senast sista betalningsdag lämnas skulden "
217
+ "till Kronofogden för indrivning. Det kan leda till utmätning av din "
218
+ "lön eller egendom samt en betalningsanmärkning som försvårar för dig "
219
+ "att få lån, hyreskontrakt och abonnemang.", "body"),
220
+ ("", "gap"),
221
+ ("Centrala studiestödsnämnden (CSN)", "body"),
222
+ ],
223
+ gold=dict(
224
+ expected_severity=4,
225
+ deadlines=[{"verbatim_swedish": "31 december 2026", "meaning": "sista betalningsdag för återkrav"}],
226
+ amounts=[{"verbatim_swedish": "15 600 kr", "meaning": "belopp att återbetala"}],
227
+ references=[{"verbatim_swedish": "555444", "meaning": "låntagarnummer"}],
228
+ ),
229
+ ))
230
+
231
+ # 5. Vårdcentral — appointment reminder + patient fee. Severity 2.
232
+ LETTERS.append(dict(
233
+ slug="vardcentral-kallelse",
234
+ sender="Vårdcentral Centrum",
235
+ lines=[
236
+ ("Kallelse till besök", "title"),
237
+ ("Till: Erik Eriksson", "body"),
238
+ ("", "gap"),
239
+ ("Du är välkommen på ett bokat besök hos din distriktssköterska "
240
+ "för en hälsokontroll.", "body"),
241
+ ("", "gap"),
242
+ ("Tid: den 5 maj 2026 kl 10:30", "value"),
243
+ ("Patientavgift: 250 kr", "value"),
244
+ ("Bokningsnummer: 778899", "value"),
245
+ ("", "gap"),
246
+ ("Om du får förhinder, av- eller omboka senast 24 timmar innan, "
247
+ "annars debiteras du avgiften ändå.", "body"),
248
+ ("", "gap"),
249
+ ("Vårdcentral Centrum", "body"),
250
+ ],
251
+ gold=dict(
252
+ expected_severity=2,
253
+ deadlines=[{"verbatim_swedish": "5 maj 2026", "meaning": "tid för bokat besök"}],
254
+ amounts=[{"verbatim_swedish": "250 kr", "meaning": "patientavgift"}],
255
+ references=[{"verbatim_swedish": "778899", "meaning": "bokningsnummer"}],
256
+ ),
257
+ ))
258
+
259
+
260
+ def main() -> None:
261
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
262
+ for letter in LETTERS:
263
+ render_letter(letter["slug"], letter["sender"], letter["lines"])
264
+ write_sidecar(letter["slug"], letter["gold"])
265
+ print(f" wrote {letter['slug']}.png + .json")
266
+ print(f"Generated {len(LETTERS)} synthetic gold letters in {OUT_DIR}")
267
+
268
+
269
+ if __name__ == "__main__":
270
+ main()
data/letters/public/adversarial/blurry_unreadable.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fixture_type": "adversarial",
3
+ "expected_doctype": "unreadable",
4
+ "expected_severity": null,
5
+ "deadlines": [],
6
+ "amounts": [],
7
+ "references": []
8
+ }
data/letters/public/adversarial/blurry_unreadable.png ADDED
data/letters/public/adversarial/generate_adversarial_fixtures.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Adversarial fixture generator for Phase 3 TRUST-02/03/04 tests.
3
+
4
+ Generates three unambiguously bad inputs using PIL only — no real PII.
5
+ All images use generic placeholder content (no Swedish personnummer).
6
+
7
+ Run from the project root:
8
+ .venv/bin/python data/letters/public/adversarial/generate_adversarial_fixtures.py
9
+
10
+ Output (this directory):
11
+ blurry_unreadable.png + blurry_unreadable.json (DOCTYPE: unreadable)
12
+ non_letter_receipt.png + non_letter_receipt.json (DOCTYPE: not_letter)
13
+ non_swedish_english.png + non_swedish_english.json (DOCTYPE: non_swedish)
14
+
15
+ PII safety: no real personnummer used anywhere.
16
+ Manual image-redaction visual gate: these are programmatically generated synthetic
17
+ images with no personal data — they cannot contain real PII by construction.
18
+ """
19
+
20
+ import json
21
+ import random
22
+ from pathlib import Path
23
+
24
+ from PIL import Image, ImageDraw, ImageFilter, ImageFont
25
+
26
+ OUT_DIR = Path(__file__).resolve().parent
27
+
28
+ # Page geometry matching the gold letter generator
29
+ PAGE_W, PAGE_H = 1000, 1414
30
+
31
+ TITLE_FONT_PATH = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
32
+ BODY_FONT_PATH = "/System/Library/Fonts/Supplemental/Arial.ttf"
33
+
34
+
35
+ def _font(path: str, size: int) -> ImageFont.FreeTypeFont:
36
+ try:
37
+ return ImageFont.truetype(path, size)
38
+ except OSError:
39
+ # Fallback for non-macOS environments
40
+ return ImageFont.load_default()
41
+
42
+
43
+ def write_sidecar(slug: str, expected_doctype: str) -> None:
44
+ """Write the adversarial sidecar JSON (different schema from gold sidecars)."""
45
+ data = {
46
+ "fixture_type": "adversarial",
47
+ "expected_doctype": expected_doctype,
48
+ "expected_severity": None,
49
+ "deadlines": [],
50
+ "amounts": [],
51
+ "references": [],
52
+ }
53
+ (OUT_DIR / f"{slug}.json").write_text(
54
+ json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
55
+ )
56
+
57
+
58
+ def generate_blurry_unreadable() -> None:
59
+ """
60
+ Generate blurry_unreadable.png — a heavily blurred, low-contrast noise image
61
+ with no legible text. Expected doctype: unreadable.
62
+
63
+ Approach: render faint text on a gray background, then apply multiple rounds
64
+ of Gaussian blur to make it completely illegible.
65
+ """
66
+ img = Image.new("RGB", (PAGE_W, PAGE_H), (180, 180, 180)) # Mid-gray background
67
+ draw = ImageDraw.Draw(img)
68
+
69
+ # Seeded RNG for reproducibility
70
+ rng = random.Random(42)
71
+
72
+ # Add some random noise patches to simulate a very dark/blurry scan
73
+ for _ in range(2000):
74
+ x = rng.randint(0, PAGE_W - 1)
75
+ y = rng.randint(0, PAGE_H - 1)
76
+ shade = rng.randint(120, 200)
77
+ draw.ellipse([x - 2, y - 2, x + 2, y + 2], fill=(shade, shade, shade))
78
+
79
+ # Add some extremely faint "text-like" marks that will be blurred into noise
80
+ body_font = _font(BODY_FONT_PATH, 20)
81
+ for row, text in enumerate([
82
+ "Lorem ipsum dolor sit amet consectetur",
83
+ "adipiscing elit sed do eiusmod tempor",
84
+ "incididunt ut labore et dolore magna",
85
+ ]):
86
+ draw.text((100, 200 + row * 40), text, fill=(130, 130, 130), font=body_font)
87
+
88
+ # Apply heavy Gaussian blur multiple times — makes everything illegible
89
+ for _ in range(8):
90
+ img = img.filter(ImageFilter.GaussianBlur(radius=6))
91
+
92
+ img.save(OUT_DIR / "blurry_unreadable.png")
93
+ write_sidecar("blurry_unreadable", "unreadable")
94
+ print("Generated: blurry_unreadable.png + blurry_unreadable.json")
95
+
96
+
97
+ def generate_non_letter_receipt() -> None:
98
+ """
99
+ Generate non_letter_receipt.png — an obvious grocery receipt (NOT a letter).
100
+ Expected doctype: not_letter.
101
+
102
+ Uses generic placeholder content with no real PII, no real store name.
103
+ """
104
+ img = Image.new("RGB", (PAGE_W, PAGE_H), "white")
105
+ draw = ImageDraw.Draw(img)
106
+
107
+ title_font = _font(TITLE_FONT_PATH, 32)
108
+ body_font = _font(BODY_FONT_PATH, 22)
109
+ small_font = _font(BODY_FONT_PATH, 18)
110
+
111
+ MARGIN = 80
112
+ y = MARGIN
113
+
114
+ # Receipt header — looks NOTHING like an authority letter
115
+ draw.text((MARGIN, y), "STORE RECEIPT", fill="black", font=title_font)
116
+ y += 60
117
+ draw.text((MARGIN, y), "Generic Grocery AB", fill="black", font=body_font)
118
+ y += 36
119
+ draw.text((MARGIN, y), "Receipt no: 00042", fill="black", font=small_font)
120
+ y += 28
121
+ draw.text((MARGIN, y), "Date: 2026-05-15 Time: 14:32", fill="black", font=small_font)
122
+ y += 36
123
+
124
+ draw.line((MARGIN, y, PAGE_W - MARGIN, y), fill="black", width=1)
125
+ y += 20
126
+
127
+ # Item lines — clearly a grocery receipt
128
+ items = [
129
+ ("Milk 1L 12.90"),
130
+ ("Bread (sourdough) 34.50"),
131
+ ("Cheese 500g 49.90"),
132
+ ("Apples 1kg 19.90"),
133
+ ("Pasta 500g 15.50"),
134
+ ("Olive oil 500ml 59.90"),
135
+ ("Yoghurt 400g 18.50"),
136
+ ("Juice 1L 22.90"),
137
+ ("Eggs 12-pack 42.00"),
138
+ ("Butter 500g 38.50"),
139
+ ]
140
+ for item in items:
141
+ draw.text((MARGIN, y), item, fill="black", font=body_font)
142
+ y += 34
143
+
144
+ draw.line((MARGIN, y, PAGE_W - MARGIN, y), fill="black", width=1)
145
+ y += 20
146
+
147
+ draw.text((MARGIN, y), "Subtotal: 314.50", fill="black", font=body_font)
148
+ y += 34
149
+ draw.text((MARGIN, y), "VAT (25%): 78.63", fill="black", font=body_font)
150
+ y += 34
151
+ draw.text((MARGIN, y), "TOTAL: 393.13", fill="black", font=title_font)
152
+ y += 50
153
+
154
+ draw.line((MARGIN, y, PAGE_W - MARGIN, y), fill="black", width=1)
155
+ y += 20
156
+ draw.text((MARGIN, y), "Payment: Credit card", fill="black", font=small_font)
157
+ y += 28
158
+ draw.text((MARGIN, y), "Thank you for shopping with us!", fill="black", font=small_font)
159
+
160
+ img.save(OUT_DIR / "non_letter_receipt.png")
161
+ write_sidecar("non_letter_receipt", "not_letter")
162
+ print("Generated: non_letter_receipt.png + non_letter_receipt.json")
163
+
164
+
165
+ def generate_non_swedish_english() -> None:
166
+ """
167
+ Generate non_swedish_english.png — a wholly English AUTHORITY LETTER.
168
+ Expected doctype: not_letter (see rationale below).
169
+
170
+ This fixture is a non-Swedish institutional LETTER (UK HMRC-style: letterhead, date,
171
+ recipient, salutation, body, sign-off), entirely in English, no Swedish, no real PII.
172
+ It exercises the realistic bad input of an expat uploading a letter from their home
173
+ country.
174
+
175
+ Expected doctype rationale (Phase-3 decision, builder-approved): the locked model
176
+ (Qwen3-VL-8B, greedy) classifies a non-Swedish *letter* as `not_letter`, not
177
+ `non_swedish`. Forcing the `non_swedish` distinction required a SYSTEM_PROMPT
178
+ decision-tree edit that deterministically regressed reference recall on real Swedish
179
+ letters (the project's #1 value), so it was reverted. `not_letter` and `non_swedish`
180
+ route to the IDENTICAL render_refusal (wrong_document mascot, no analysis), so TRUST-03
181
+ holds either way — only the refusal sub-copy differs. We therefore score this fixture's
182
+ refusal as `not_letter`. The `non_swedish` render path remains unit-tested directly in
183
+ tests/test_doctype_refusal.py. No Swedish text, no real PII, generic placeholders.
184
+ """
185
+ img = Image.new("RGB", (PAGE_W, PAGE_H), "white")
186
+ draw = ImageDraw.Draw(img)
187
+
188
+ head_font = _font(TITLE_FONT_PATH, 30)
189
+ sub_font = _font(BODY_FONT_PATH, 20)
190
+ body_font = _font(BODY_FONT_PATH, 22)
191
+
192
+ MARGIN = 80
193
+ y = MARGIN
194
+
195
+ # Letterhead — a non-Swedish (UK) government authority, entirely in English
196
+ draw.text((MARGIN, y), "HM Revenue & Customs", fill="black", font=head_font)
197
+ y += 42
198
+ draw.text((MARGIN, y), "National Insurance Contributions Office", fill="black", font=sub_font)
199
+ y += 30
200
+ draw.text((MARGIN, y), "Benton Park View, Newcastle upon Tyne, NE98 1ZZ", fill="black", font=sub_font)
201
+ y += 30
202
+ draw.text((MARGIN, y), "United Kingdom", fill="black", font=sub_font)
203
+ y += 50
204
+
205
+ # Date + recipient block — letter structure, not a form/bill
206
+ draw.text((MARGIN, y), "12 May 2026", fill="black", font=body_font)
207
+ y += 50
208
+ draw.text((MARGIN, y), "Mr A. Taylor", fill="black", font=body_font)
209
+ y += 32
210
+ draw.text((MARGIN, y), "14 Example Terrace", fill="black", font=body_font)
211
+ y += 32
212
+ draw.text((MARGIN, y), "Manchester, M1 2AB", fill="black", font=body_font)
213
+ y += 56
214
+
215
+ draw.text((MARGIN, y), "Dear Mr Taylor,", fill="black", font=body_font)
216
+ y += 44
217
+ draw.text((MARGIN, y), "Re: Your Self Assessment tax return for 2025/26", fill="black", font=body_font)
218
+ y += 50
219
+
220
+ body_lines = [
221
+ "We are writing to remind you that your Self Assessment tax",
222
+ "return for the year ending 5 April 2026 has not yet been",
223
+ "received. Our records show that you registered for Self",
224
+ "Assessment but have not submitted a completed return.",
225
+ "",
226
+ "Please complete and submit your return as soon as possible to",
227
+ "avoid a late-filing penalty. If you have already sent your",
228
+ "return, please disregard this letter.",
229
+ "",
230
+ "If you need help completing your return, you can find guidance",
231
+ "and contact details on our website. Please quote your reference",
232
+ "when you get in touch so we can deal with your enquiry quickly.",
233
+ ]
234
+ for line in body_lines:
235
+ draw.text((MARGIN, y), line, fill="black", font=body_font)
236
+ y += 34
237
+ y += 24
238
+
239
+ draw.text((MARGIN, y), "Yours sincerely,", fill="black", font=body_font)
240
+ y += 60
241
+ draw.text((MARGIN, y), "J. Morgan", fill="black", font=body_font)
242
+ y += 32
243
+ draw.text((MARGIN, y), "Customer Compliance Officer", fill="black", font=body_font)
244
+
245
+ img.save(OUT_DIR / "non_swedish_english.png")
246
+ write_sidecar("non_swedish_english", "not_letter") # see docstring: model refuses as not_letter
247
+ print("Generated: non_swedish_english.png + non_swedish_english.json")
248
+
249
+
250
+ if __name__ == "__main__":
251
+ generate_blurry_unreadable()
252
+ generate_non_letter_receipt()
253
+ generate_non_swedish_english()
254
+ print("\nAll adversarial fixtures generated successfully.")
255
+ print("Manual image-redaction visual gate: these are programmatically generated")
256
+ print("synthetic images with no real PII — confirmed clean by construction.")
data/letters/public/adversarial/non_letter_receipt.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fixture_type": "adversarial",
3
+ "expected_doctype": "not_letter",
4
+ "expected_severity": null,
5
+ "deadlines": [],
6
+ "amounts": [],
7
+ "references": []
8
+ }
data/letters/public/adversarial/non_letter_receipt.png ADDED
data/letters/public/adversarial/non_swedish_english.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fixture_type": "adversarial",
3
+ "expected_doctype": "non_swedish",
4
+ "accepted_doctypes": ["non_swedish", "not_letter"],
5
+ "expected_severity": null,
6
+ "deadlines": [],
7
+ "amounts": [],
8
+ "references": []
9
+ }
data/letters/public/adversarial/non_swedish_english.png ADDED
data/letters/public/csn-aterkrav.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "expected_severity": 4,
3
+ "deadlines": [
4
+ {
5
+ "verbatim_swedish": "31 december 2026",
6
+ "meaning": "sista betalningsdag för återkrav"
7
+ }
8
+ ],
9
+ "amounts": [
10
+ {
11
+ "verbatim_swedish": "15 600 kr",
12
+ "meaning": "belopp att återbetala"
13
+ }
14
+ ],
15
+ "references": [
16
+ {
17
+ "verbatim_swedish": "555444",
18
+ "meaning": "låntagarnummer"
19
+ }
20
+ ]
21
+ }
data/letters/public/csn-aterkrav.png ADDED
data/letters/public/forsakringskassan-komplettering.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "expected_severity": 3,
3
+ "deadlines": [
4
+ {
5
+ "verbatim_swedish": "30 september 2026",
6
+ "meaning": "sista svarsdag för komplettering"
7
+ }
8
+ ],
9
+ "amounts": [],
10
+ "references": [
11
+ {
12
+ "verbatim_swedish": "9988776",
13
+ "meaning": "ärendenummer"
14
+ }
15
+ ]
16
+ }
data/letters/public/forsakringskassan-komplettering.png ADDED
data/letters/public/migrationsverket-uppehallstillstand.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "expected_severity": 5,
3
+ "deadlines": [
4
+ {
5
+ "verbatim_swedish": "15 augusti 2026",
6
+ "meaning": "deadline att lämna in pass"
7
+ }
8
+ ],
9
+ "amounts": [],
10
+ "references": [
11
+ {
12
+ "verbatim_swedish": "12-345678",
13
+ "meaning": "dossiernummer"
14
+ }
15
+ ]
16
+ }
data/letters/public/migrationsverket-uppehallstillstand.png ADDED
data/letters/public/skatteverket-slutskattebesked.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "expected_severity": 1,
3
+ "deadlines": [
4
+ {
5
+ "verbatim_swedish": "12 november 2026",
6
+ "meaning": "utbetalningsdag för skatteåterbäring"
7
+ }
8
+ ],
9
+ "amounts": [
10
+ {
11
+ "verbatim_swedish": "4 250 kr",
12
+ "meaning": "belopp att få tillbaka"
13
+ }
14
+ ],
15
+ "references": [
16
+ {
17
+ "verbatim_swedish": "1234567890",
18
+ "meaning": "referensnummer"
19
+ }
20
+ ]
21
+ }
data/letters/public/skatteverket-slutskattebesked.png ADDED
data/letters/public/vardcentral-kallelse.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "expected_severity": 2,
3
+ "deadlines": [
4
+ {
5
+ "verbatim_swedish": "5 maj 2026",
6
+ "meaning": "tid för bokat besök"
7
+ }
8
+ ],
9
+ "amounts": [
10
+ {
11
+ "verbatim_swedish": "250 kr",
12
+ "meaning": "patientavgift"
13
+ }
14
+ ],
15
+ "references": [
16
+ {
17
+ "verbatim_swedish": "778899",
18
+ "meaning": "bokningsnummer"
19
+ }
20
+ ]
21
+ }
data/letters/public/vardcentral-kallelse.png ADDED
eval/cuda_parity.ipynb ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "2b67e522",
6
+ "metadata": {},
7
+ "source": [
8
+ "# Bureaucat — CUDA A100 Parity Run\n",
9
+ "\n",
10
+ "**Purpose:** Confirm accuracy parity between local MPS (bfloat16) and A100 CUDA (bfloat16)\n",
11
+ "on the 5 gold English letters.\n",
12
+ "\n",
13
+ "**Scope:** 5 gold letters × English only — the hardware-numerics parity question.\n",
14
+ "Translation accuracy (5×5 matrix) is verified separately on local MPS.\n",
15
+ "\n",
16
+ "**Claims proved here:** zero invented values, recall == local MPS baseline on 5 English letters.\n",
17
+ "\n",
18
+ "**D-03 NOTE — DEFERRED:**\n",
19
+ "This notebook proves ACCURACY parity ONLY.\n",
20
+ "The D-03 gate (`<40s` inference latency AND `device=cuda` in the Space startup log)\n",
21
+ "requires a real ZeroGPU Space and is explicitly DEFERRED to Phase 5 (deploy day).\n",
22
+ "Nothing in this notebook closes D-03.\n",
23
+ "\n",
24
+ "---\n",
25
+ "\n",
26
+ "## Prerequisites\n",
27
+ "- **Colab Pro A100 40GB runtime** (not T4 — model needs ~24-26 GB VRAM at bfloat16).\n",
28
+ "- If Colab schedules an L4 (22.5 GB) instead, the VRAM assertion in Cell 1 will fail.\n",
29
+ " Reconnect runtime until you get an A100.\n",
30
+ "- bfloat16 only — 4-bit quantization is NOT used because it alters numerics and\n",
31
+ " would undermine the parity claim."
32
+ ]
33
+ },
34
+ {
35
+ "cell_type": "code",
36
+ "execution_count": null,
37
+ "id": "14ae03ca",
38
+ "metadata": {},
39
+ "outputs": [],
40
+ "source": [
41
+ "# Cell 1: VRAM assertion — must pass before loading the model\n",
42
+ "# Colab Pro may schedule an L4 (22.5 GB) instead of A100 (40 GB).\n",
43
+ "# This assertion catches that case and fails cleanly rather than OOM-crashing mid-load.\n",
44
+ "\n",
45
+ "import torch\n",
46
+ "\n",
47
+ "assert torch.cuda.is_available(), \"No CUDA device found — start a GPU runtime\"\n",
48
+ "\n",
49
+ "vram_gb = torch.cuda.get_device_properties(0).total_memory / 1e9\n",
50
+ "device_name = torch.cuda.get_device_name(0)\n",
51
+ "\n",
52
+ "assert vram_gb >= 35, (\n",
53
+ " f\"Insufficient VRAM: {vram_gb:.1f} GB on {device_name!r}.\\n\"\n",
54
+ " f\"Need A100 40 GB (>=35 GB threshold). \"\n",
55
+ " f\"Reconnect runtime to get an A100, or use Colab Pro+ with guaranteed A100 access.\\n\"\n",
56
+ " f\"Do NOT fall back to 4-bit quantization — bfloat16 parity is the claim being tested.\"\n",
57
+ ")\n",
58
+ "\n",
59
+ "print(f\"VRAM check PASSED: {vram_gb:.1f} GB on {device_name!r}\")"
60
+ ]
61
+ },
62
+ {
63
+ "cell_type": "code",
64
+ "execution_count": null,
65
+ "id": "fb712c5e",
66
+ "metadata": {},
67
+ "outputs": [],
68
+ "source": [
69
+ "# Cell 2: Install the pinned Space stack and clone the repo\n",
70
+ "# Run this cell once per Colab session.\n",
71
+ "\n",
72
+ "# Install pinned Space requirements (torch 2.11.0 is the ZeroGPU-confirmed ceiling)\n",
73
+ "# Note: torch is already installed in Colab; we pin the inference-stack deps.\n",
74
+ "import subprocess, sys\n",
75
+ "\n",
76
+ "packages = [\n",
77
+ " \"transformers==5.10.2\",\n",
78
+ " \"accelerate==1.13.0\",\n",
79
+ " \"qwen-vl-utils==0.0.14\",\n",
80
+ " \"gradio==6.16.0\",\n",
81
+ " \"spaces==0.50.4\",\n",
82
+ " \"pypdfium2==5.9.0\",\n",
83
+ " \"pillow>=10.0.0\",\n",
84
+ "]\n",
85
+ "subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\"] + packages)\n",
86
+ "\n",
87
+ "print(\"Package install complete.\")"
88
+ ]
89
+ },
90
+ {
91
+ "cell_type": "code",
92
+ "execution_count": null,
93
+ "id": "96fa92df",
94
+ "metadata": {},
95
+ "outputs": [],
96
+ "source": [
97
+ "# Cell 2b: Clone the Bureaucat repo and add to sys.path\n",
98
+ "# Option A (recommended): clone from HF Hub or GitHub\n",
99
+ "# !git clone https://huggingface.co/spaces/build-small-hackathon/bureaucat bureaucat\n",
100
+ "# Option B: upload the repo as a zip via the Colab file panel, then unzip.\n",
101
+ "# After cloning/uploading, set REPO_ROOT below and run this cell.\n",
102
+ "\n",
103
+ "import os, sys\n",
104
+ "\n",
105
+ "# --- Set this path after cloning/uploading the repo ---\n",
106
+ "REPO_ROOT = \"/content/bureaucat\" # adjust if you uploaded to a different path\n",
107
+ "\n",
108
+ "if os.path.isdir(REPO_ROOT):\n",
109
+ " if REPO_ROOT not in sys.path:\n",
110
+ " sys.path.insert(0, REPO_ROOT)\n",
111
+ " os.chdir(REPO_ROOT)\n",
112
+ " print(f\"REPO_ROOT set to: {REPO_ROOT}\")\n",
113
+ "else:\n",
114
+ " raise FileNotFoundError(\n",
115
+ " f\"Repo not found at {REPO_ROOT!r}.\\n\"\n",
116
+ " f\"Clone or upload the Bureaucat repo first, then update REPO_ROOT above.\"\n",
117
+ " )"
118
+ ]
119
+ },
120
+ {
121
+ "cell_type": "code",
122
+ "execution_count": null,
123
+ "id": "ba196e70",
124
+ "metadata": {},
125
+ "outputs": [],
126
+ "source": [
127
+ "# Cell 3: Load Qwen3-VL-8B-Instruct at bfloat16 on CUDA\n",
128
+ "# bfloat16 ONLY — not 4-bit. 4-bit would alter numerics and undermine the parity claim.\n",
129
+ "# The VRAM assertion in Cell 1 ensures we have enough headroom (~24-26 GB peak).\n",
130
+ "\n",
131
+ "import os\n",
132
+ "os.environ.setdefault(\"SPACE_ID\", \"\") # prevent app.py from activating @spaces.GPU paths\n",
133
+ "\n",
134
+ "# Load via app.load_model which handles device detection and dtype\n",
135
+ "from app import load_model, MODEL_VARIANTS\n",
136
+ "\n",
137
+ "model_variant = \"qwen3\" # Qwen/Qwen3-VL-8B-Instruct — MODEL-01 LOCKED\n",
138
+ "variant_cfg = MODEL_VARIANTS[model_variant]\n",
139
+ "image_patch_size = variant_cfg[\"image_patch_size\"]\n",
140
+ "\n",
141
+ "print(f\"Loading {model_variant} at bfloat16 on CUDA...\")\n",
142
+ "mdl, proc = load_model(model_variant)\n",
143
+ "\n",
144
+ "# Verify bfloat16 and CUDA placement\n",
145
+ "import torch\n",
146
+ "assert next(mdl.parameters()).dtype == torch.bfloat16, \"Model must be bfloat16\"\n",
147
+ "assert next(mdl.parameters()).device.type == \"cuda\", \"Model must be on CUDA\"\n",
148
+ "\n",
149
+ "print(f\"Model loaded: dtype={next(mdl.parameters()).dtype}, device={next(mdl.parameters()).device}\")"
150
+ ]
151
+ },
152
+ {
153
+ "cell_type": "code",
154
+ "execution_count": null,
155
+ "id": "fe84bf49",
156
+ "metadata": {},
157
+ "outputs": [],
158
+ "source": [
159
+ "# Cell 4: Run 5 English-language passes on the 5 gold letters using the harness\n",
160
+ "# Reuses run_inference() + evaluate() from eval/run_eval.py (same harness as local MPS).\n",
161
+ "# Only English — CUDA parity is a hardware-numerics question, not a translation question.\n",
162
+ "\n",
163
+ "import json\n",
164
+ "from pathlib import Path\n",
165
+ "from PIL import Image\n",
166
+ "from app import run_inference\n",
167
+ "\n",
168
+ "# Add eval/ to path for run_eval imports\n",
169
+ "import sys\n",
170
+ "_eval_dir = str(Path(\"eval\").resolve())\n",
171
+ "if _eval_dir not in sys.path:\n",
172
+ " sys.path.insert(0, _eval_dir)\n",
173
+ "\n",
174
+ "from run_eval import evaluate\n",
175
+ "\n",
176
+ "letter_dir = Path(\"data/letters/public\")\n",
177
+ "sidecars = sorted(letter_dir.glob(\"*.json\"))\n",
178
+ "\n",
179
+ "cuda_results = []\n",
180
+ "\n",
181
+ "for sidecar in sidecars:\n",
182
+ " image_path = sidecar.with_suffix(\".png\")\n",
183
+ " if not image_path.exists():\n",
184
+ " image_path = sidecar.with_suffix(\".jpg\")\n",
185
+ " if not image_path.exists():\n",
186
+ " print(f\"[SKIP] {sidecar.stem}: no image\")\n",
187
+ " continue\n",
188
+ "\n",
189
+ " image = Image.open(image_path).convert(\"RGB\")\n",
190
+ " gold = json.loads(sidecar.read_text())\n",
191
+ "\n",
192
+ " result = run_inference(\n",
193
+ " image, \"English\", beginner_mode=False,\n",
194
+ " mdl=mdl, proc=proc, image_patch_size=image_patch_size,\n",
195
+ " )\n",
196
+ " verdict = evaluate(result, gold)\n",
197
+ " cuda_results.append({\"letter\": sidecar.stem, \"verdict\": verdict})\n",
198
+ "\n",
199
+ " status = \"PASS\" if verdict[\"pass\"] else \"FAIL\"\n",
200
+ " print(\n",
201
+ " f\" {status} {sidecar.stem}: \"\n",
202
+ " f\"recall={verdict['recall_rate']:.0%} \"\n",
203
+ " f\"invented={verdict['invented_count']} \"\n",
204
+ " f\"severity_mae={verdict['severity_mae']:.1f} \"\n",
205
+ " f\"all_sections={verdict['all_sections_present']}\"\n",
206
+ " )\n",
207
+ "\n",
208
+ "print(f\"\\n{len(cuda_results)} letters evaluated on CUDA A100.\")"
209
+ ]
210
+ },
211
+ {
212
+ "cell_type": "code",
213
+ "execution_count": null,
214
+ "id": "ec66dbad",
215
+ "metadata": {},
216
+ "outputs": [],
217
+ "source": [
218
+ "# Cell 5: Assert parity with local MPS baseline and print verdict table\n",
219
+ "# MPS baseline (from Phase 1 / 03-01 bake-off): 5/5 PASS, 0 invented, 100% recall.\n",
220
+ "# Assert: zero invented values + recall == 100% on all 5 letters.\n",
221
+ "\n",
222
+ "# --- MPS baseline (recorded from local Phase 1 bake-off run) ---\n",
223
+ "MPS_BASELINE = {\n",
224
+ " \"skatteverket-slutskattebesked\": {\"invented_count\": 0, \"recall_rate\": 1.0, \"pass\": True},\n",
225
+ " \"migrationsverket-uppehallstillstand\": {\"invented_count\": 0, \"recall_rate\": 1.0, \"pass\": True},\n",
226
+ " \"forsakringskassan-komplettering\": {\"invented_count\": 0, \"recall_rate\": 1.0, \"pass\": True},\n",
227
+ " \"csn-aterkrav\": {\"invented_count\": 0, \"recall_rate\": 1.0, \"pass\": True},\n",
228
+ " \"vardcentral-kallelse\": {\"invented_count\": 0, \"recall_rate\": 1.0, \"pass\": True},\n",
229
+ "}\n",
230
+ "\n",
231
+ "print(\"=\" * 60)\n",
232
+ "print(\"CUDA A100 vs MPS PARITY TABLE\")\n",
233
+ "print(\"=\" * 60)\n",
234
+ "print(f\"{'Letter':<44} {'CUDA':>5} {'MPS':>5} {'PARITY':>8}\")\n",
235
+ "print(\"-\" * 60)\n",
236
+ "\n",
237
+ "all_parity_ok = True\n",
238
+ "zero_invented = True\n",
239
+ "\n",
240
+ "for row in cuda_results:\n",
241
+ " stem = row[\"letter\"]\n",
242
+ " v = row[\"verdict\"]\n",
243
+ " mps = MPS_BASELINE.get(stem, {})\n",
244
+ "\n",
245
+ " cuda_recall = v[\"recall_rate\"]\n",
246
+ " mps_recall = mps.get(\"recall_rate\", None)\n",
247
+ " recall_match = (mps_recall is None) or (abs(cuda_recall - mps_recall) < 0.001)\n",
248
+ "\n",
249
+ " parity_ok = v[\"invented_count\"] == 0 and recall_match\n",
250
+ " all_parity_ok = all_parity_ok and parity_ok\n",
251
+ " zero_invented = zero_invented and v[\"invented_count\"] == 0\n",
252
+ "\n",
253
+ " cuda_str = f\"{cuda_recall:.0%}\"\n",
254
+ " mps_str = f\"{mps_recall:.0%}\" if mps_recall is not None else \"n/a\"\n",
255
+ " parity_str = \"OK\" if parity_ok else \"FAIL\"\n",
256
+ " print(f\" {stem:<42} {cuda_str:>5} {mps_str:>5} {parity_str:>8}\")\n",
257
+ "\n",
258
+ "print(\"-\" * 60)\n",
259
+ "print(f\" Zero invented values: {'YES' if zero_invented else 'NO (FAIL)'}\")\n",
260
+ "print(f\" Recall == MPS baseline ±0: {'YES' if all_parity_ok else 'NO (FAIL)'}\")\n",
261
+ "print()\n",
262
+ "\n",
263
+ "assert zero_invented, \"PARITY FAIL: CUDA run invented values not present in transcription\"\n",
264
+ "assert all_parity_ok, \"PARITY FAIL: CUDA recall does not match local MPS baseline\"\n",
265
+ "\n",
266
+ "print(\"PARITY VERDICT: PASS — zero invented, recall == MPS baseline on all 5 English letters.\")\n",
267
+ "print()\n",
268
+ "print(\"=\" * 60)\n",
269
+ "print(\"D-03 DEFERRAL NOTE\")\n",
270
+ "print(\"=\" * 60)\n",
271
+ "print(\"This notebook proves ACCURACY parity (zero invented, matched recall) ONLY.\")\n",
272
+ "print(\"The D-03 gate remains DEFERRED:\")\n",
273
+ "print(\" - <40s per-inference latency: requires ZeroGPU Space measurement\")\n",
274
+ "print(\" - device=cuda in startup log: requires real ZeroGPU Space deployment\")\n",
275
+ "print(\"Both will be verified in Phase 5 (deploy day). D-03 is NOT closed here.\")"
276
+ ]
277
+ },
278
+ {
279
+ "cell_type": "markdown",
280
+ "id": "a8741192",
281
+ "metadata": {},
282
+ "source": [
283
+ "## Results Recording\n",
284
+ "\n",
285
+ "After running all cells, record the results in the Phase 3 Plan 03 SUMMARY.md:\n",
286
+ "\n",
287
+ "| Letter | CUDA Recall | MPS Recall | Invented | Parity |\n",
288
+ "|--------|-------------|------------|----------|--------|\n",
289
+ "| (fill from Cell 5 output) | | | | |\n",
290
+ "\n",
291
+ "**Verdict:** PASS / FAIL \n",
292
+ "**Runtime:** A100 40GB / [actual device from Cell 1] \n",
293
+ "**D-03 status:** DEFERRED to Phase 5 (latency + device=cuda Space gate)\n",
294
+ "\n",
295
+ "---\n",
296
+ "\n",
297
+ "## Acknowledgements\n",
298
+ "\n",
299
+ "Model: `Qwen/Qwen3-VL-8B-Instruct` (MODEL-01 LOCKED, Phase 1 bake-off 2026-06-05) \n",
300
+ "Harness: `eval/run_eval.py` (shared with local MPS runs) \n",
301
+ "Gold set: `data/letters/public/*.json` (5 synthetic Swedish authority letters)"
302
+ ]
303
+ }
304
+ ],
305
+ "metadata": {
306
+ "kernelspec": {
307
+ "display_name": "Python 3",
308
+ "language": "python",
309
+ "name": "python3"
310
+ },
311
+ "language_info": {
312
+ "name": "python",
313
+ "version": "3.12.0"
314
+ }
315
+ },
316
+ "nbformat": 4,
317
+ "nbformat_minor": 5
318
+ }
eval/grounded.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ eval/grounded.py — Shared grounded matching primitives for Bureaucat.
3
+
4
+ Provides the no-invention check used by app.py (to drive the verifying mascot
5
+ state, D2-05) and by eval/run_eval.py (for bake-off accuracy gating).
6
+
7
+ Stdlib-only: only `re` and `unicodedata`. Importing this module never loads
8
+ model weights and never imports app.
9
+ """
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Imports — stdlib only
13
+ # ---------------------------------------------------------------------------
14
+
15
+ import re
16
+ import unicodedata
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Normalization (D-12)
21
+ # Python 3 \s on str matches all Unicode whitespace:
22
+ # U+00A0 non-breaking space, U+2009 thin space, U+202F narrow no-break space,
23
+ # U+2007 figure space, U+3000 ideographic space — no literal chars needed.
24
+ # ---------------------------------------------------------------------------
25
+
26
+ _WHITESPACE_RE = re.compile(r"\s+")
27
+
28
+
29
+ def normalize(text: str) -> str:
30
+ """
31
+ Collapse all Unicode whitespace to single ASCII space; NFC-normalize; lowercase.
32
+
33
+ Preserves digits, commas, periods (Swedish decimal format), and kr suffix.
34
+ Purpose: makes OCR whitespace/case variance transparent to value_found().
35
+ """
36
+ # NFC normalize first — handles combining characters from OCR output
37
+ text = unicodedata.normalize("NFC", text)
38
+ # Collapse all Unicode whitespace variants to a single ASCII space
39
+ text = _WHITESPACE_RE.sub(" ", text)
40
+ # Lowercase and strip leading/trailing whitespace
41
+ return text.lower().strip()
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Value matching (D-12 no-invention, D-13 recall)
46
+ # ---------------------------------------------------------------------------
47
+
48
+ def value_found(gold_value: str, haystack: str) -> bool:
49
+ """
50
+ Check if gold_value appears in haystack using normalized substring match
51
+ with non-digit boundaries on both sides.
52
+
53
+ The digit boundary (`(?<!\\d)...(?!\\d)`) prevents a gold value from matching
54
+ INSIDE a longer run of digits — both for short refs (e.g. "123" inside
55
+ "1234567") AND for long values (e.g. ref "12-345678" must NOT match inside
56
+ "912-3456789", and ISO date "2026-06-15" must NOT match inside "12026-06-15").
57
+ Applying the boundary universally (not only when <6 digits) closes the
58
+ superstring hole that previously let a garbled longer number satisfy the
59
+ no-invention / recall gate (WR-01, D-12).
60
+
61
+ Legitimate values are always delimited by whitespace/punctuation in the
62
+ model's output, so the boundary never rejects a real match.
63
+ """
64
+ norm_gold = normalize(gold_value)
65
+ norm_hay = normalize(haystack)
66
+
67
+ pattern = r"(?<!\d)" + re.escape(norm_gold) + r"(?!\d)"
68
+ return bool(re.search(pattern, norm_hay))
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Value extraction from "Deadlines & money" section (D-12 no-invention)
73
+ # ---------------------------------------------------------------------------
74
+
75
+ def extract_values_from_section(section_text: str) -> list:
76
+ """
77
+ Extract emitted verbatim values from the "Deadlines & money" section.
78
+
79
+ Exploits the locked prompt's "one item per line, verbatim value first" format:
80
+ - 15 juni 2026 — last day to file
81
+ - 1 234 kr - belopp
82
+
83
+ For each line:
84
+ 1. Strip leading bullet marker ('- ' or '* ' or bare '-' at start) and whitespace.
85
+ 2. Skip blank lines and any "None found" lines.
86
+ 3. Split on the first interpretation separator: ' — ' (em-dash) or ' - ' (hyphen
87
+ with surrounding spaces). The surrounding-spaces requirement prevents the
88
+ internal hyphens in ISO dates (e.g., 2026-06-15) from being split.
89
+ 4. The text before the separator is the verbatim value; strip it.
90
+
91
+ Returns a list of verbatim value strings. Empty list if nothing matches.
92
+ """
93
+ values = []
94
+ for raw_line in section_text.splitlines():
95
+ line = raw_line.strip()
96
+ if not line:
97
+ continue
98
+
99
+ # Strip leading bullet markers (-, *, •) with optional trailing space
100
+ line = re.sub(r"^[-*•]\s*", "", line).strip()
101
+ if not line:
102
+ continue
103
+
104
+ # Skip "None found" variants (case-insensitive)
105
+ if re.match(r"none found\.?$", line, re.IGNORECASE):
106
+ continue
107
+
108
+ # Split on first interpretation separator:
109
+ # ' — ' (em-dash, U+2014, with surrounding spaces)
110
+ # ' – ' (en-dash, U+2013, with surrounding spaces — LLMs emit this constantly)
111
+ # ' - ' (hyphen with surrounding spaces — requires spaces to avoid splitting dates)
112
+ # The surrounding-spaces requirement keeps internal ISO-date hyphens intact.
113
+ sep_match = re.search(r"\s+[—–\-]\s+", line)
114
+ if sep_match:
115
+ verbatim = line[: sep_match.start()].strip()
116
+ else:
117
+ # No separator found — the whole line is the verbatim value
118
+ verbatim = line
119
+
120
+ if verbatim:
121
+ values.append(verbatim)
122
+
123
+ return values
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # No-invention check (D-12, D2-05 verifying mascot state)
128
+ # ---------------------------------------------------------------------------
129
+
130
+ def check_no_invention(result) -> list:
131
+ """
132
+ Returns list of values from result.deadlines that are NOT found verbatim
133
+ in result.transcription (the no-invention check, D-12).
134
+
135
+ Called from app.py to drive the verifying mascot state (D2-05).
136
+ Does NOT require a gold dict — safe to call on any real user letter.
137
+ Returns empty list = no invention detected.
138
+
139
+ Works on any SimpleNamespace or StructuredResult that has .deadlines
140
+ and .transcription attributes.
141
+ """
142
+ invented = []
143
+ for emitted_val in extract_values_from_section(result.deadlines):
144
+ if not value_found(emitted_val, result.transcription):
145
+ invented.append(emitted_val)
146
+ return invented
eval/regen_gallery.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ eval/regen_gallery.py — Regenerate data/gallery/*-result.json from the five
3
+ EXAMPLE_LETTERS images under the CURRENT SYSTEM_PROMPT (post-DOCTYPE, Phase 3).
4
+
5
+ This is the mandatory D2-16 ripple-completion step: the old gallery JSONs were
6
+ generated before Phase 3's SYSTEM_PROMPT change (DOCTYPE sentinel addition).
7
+ After any prompt change, run this script to refresh the cached results.
8
+
9
+ Usage:
10
+ python eval/regen_gallery.py [--model qwen3|qwen25]
11
+
12
+ Requires model weights (will download from HF Hub if not cached).
13
+ MPS / CUDA / CPU are all supported; ZeroGPU not required (no GPU quota cost on
14
+ MPS — these are local synthetic letters).
15
+
16
+ The script:
17
+ 1. Loads EXAMPLE_LETTERS from app (the canonical list of slug/image/cached paths).
18
+ 2. For each entry, runs run_inference() under the current SYSTEM_PROMPT in English.
19
+ 3. Serializes ALL StructuredResult fields (matching run_eval's _fields tuple) to JSON.
20
+ 4. Overwrites the existing data/gallery/*-result.json files.
21
+ 5. Verifies each file deserializes cleanly via StructuredResult(**data) (round-trip).
22
+
23
+ Ensures:
24
+ - All five gallery JSONs contain doctype == "letter" (normal letters).
25
+ - load_example()'s StructuredResult(**data) call succeeds without schema errors.
26
+ - The field set matches run_eval's _fields tuple (includes doctype).
27
+
28
+ NOTE: py3langid is NOT used here (eval/regen_gallery.py is model-execution, not
29
+ language-ID). No additional dependencies beyond the existing Space requirements.
30
+ """
31
+
32
+ import argparse
33
+ import json
34
+ import sys
35
+ from pathlib import Path
36
+
37
+ # Ensure project root is on sys.path so `from app import ...` resolves
38
+ _PROJECT_ROOT = Path(__file__).resolve().parent.parent
39
+ if str(_PROJECT_ROOT) not in sys.path:
40
+ sys.path.insert(0, str(_PROJECT_ROOT))
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Field set for serialization — matches run_eval's _fields tuple exactly
44
+ # ---------------------------------------------------------------------------
45
+
46
+ _FIELDS = (
47
+ "transcription",
48
+ "quip",
49
+ "tldr",
50
+ "why",
51
+ "actions",
52
+ "deadlines",
53
+ "severity",
54
+ "raw",
55
+ "doctype",
56
+ )
57
+
58
+
59
+ def regen_gallery(model_variant: str = "qwen3") -> bool:
60
+ """
61
+ Regenerate all five gallery JSONs under the current SYSTEM_PROMPT.
62
+
63
+ Returns True if all five JSONs were regenerated successfully with
64
+ doctype == "letter" and round-trip via StructuredResult(**data).
65
+ """
66
+ # Lazy import — keeps module importable without loading model weights
67
+ from app import ( # noqa: PLC0415
68
+ EXAMPLE_LETTERS,
69
+ StructuredResult,
70
+ load_model,
71
+ run_inference,
72
+ MODEL_VARIANTS,
73
+ )
74
+ from PIL import Image # noqa: PLC0415
75
+
76
+ variant_cfg = MODEL_VARIANTS[model_variant]
77
+ image_patch_size = variant_cfg["image_patch_size"]
78
+
79
+ print(f"\n[regen_gallery] Loading model variant: {model_variant}")
80
+ mdl, proc = load_model(model_variant)
81
+
82
+ all_ok = True
83
+
84
+ for entry in EXAMPLE_LETTERS:
85
+ slug = entry["slug"]
86
+ image_path = Path(entry["image"])
87
+ cached_path = Path(entry["cached"])
88
+
89
+ if not image_path.exists():
90
+ print(f" [SKIP] {slug}: image not found at {image_path}")
91
+ all_ok = False
92
+ continue
93
+
94
+ print(f"\n [{slug}] Running inference...")
95
+ image = Image.open(image_path).convert("RGB")
96
+
97
+ result = run_inference(
98
+ image,
99
+ "English",
100
+ beginner_mode=False,
101
+ mdl=mdl,
102
+ proc=proc,
103
+ image_patch_size=image_patch_size,
104
+ )
105
+
106
+ # Serialize ALL StructuredResult fields matching _FIELDS (includes doctype)
107
+ data = {f: getattr(result, f) for f in _FIELDS}
108
+
109
+ # Verify doctype is "letter" (normal letters should always classify as letter)
110
+ if data.get("doctype") != "letter":
111
+ print(
112
+ f" [WARN] {slug}: doctype={data.get('doctype')!r} — expected 'letter'. "
113
+ f"Check SYSTEM_PROMPT and model output."
114
+ )
115
+ # Continue — do not skip; overwrite anyway so the human can inspect
116
+
117
+ # Round-trip verification before writing: StructuredResult(**data) must not raise
118
+ try:
119
+ StructuredResult(**data)
120
+ except TypeError as exc:
121
+ print(f" [ERROR] {slug}: StructuredResult(**data) round-trip failed: {exc}")
122
+ all_ok = False
123
+ continue
124
+
125
+ # Write JSON (utf-8, ensure_ascii=False to preserve Swedish diacritics)
126
+ cached_path.parent.mkdir(parents=True, exist_ok=True)
127
+ cached_path.write_text(
128
+ json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
129
+ )
130
+
131
+ verdict = "OK" if data.get("doctype") == "letter" else "WARN"
132
+ print(
133
+ f" [{verdict}] {slug}: written to {cached_path} "
134
+ f"(doctype={data.get('doctype')!r}, severity={data.get('severity')!r})"
135
+ )
136
+
137
+ print(f"\n[regen_gallery] Done. all_ok={all_ok}")
138
+ return all_ok
139
+
140
+
141
+ def _main():
142
+ parser = argparse.ArgumentParser(
143
+ description=(
144
+ "Regenerate data/gallery/*-result.json from EXAMPLE_LETTERS images "
145
+ "under the current SYSTEM_PROMPT (D2-16 mandatory after any prompt change)."
146
+ )
147
+ )
148
+ parser.add_argument(
149
+ "--model",
150
+ choices=["qwen3", "qwen25"],
151
+ default="qwen3",
152
+ help="Model variant to use for regeneration (default: qwen3)",
153
+ )
154
+ args = parser.parse_args()
155
+
156
+ ok = regen_gallery(args.model)
157
+ sys.exit(0 if ok else 1)
158
+
159
+
160
+ if __name__ == "__main__":
161
+ _main()
eval/run_eval.py ADDED
@@ -0,0 +1,493 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ EVAL-02 harness — Bureaucat bake-off evaluator.
3
+
4
+ Usage:
5
+ python eval/run_eval.py [--model qwen3|qwen25] [--dump PATH]
6
+
7
+ English-only product (2026-06-07 descope): Bureaucat reads Swedish letters and explains
8
+ them in English. The gate runs two passes:
9
+ - Gold-letter accuracy (run_eval): anti-hallucination (D-12/D-13/D-14) + SC1 four-sections
10
+ completeness, each letter run standard + beginner (D-08 invariance).
11
+ - Adversarial refusal (run_adversarial_eval): the three bad-input fixtures must route to
12
+ the refusal path (correct doctype + no analysis rendered).
13
+ Both must pass for exit 0. (The earlier 5x5 multilingual matrix and the py3langid prose-
14
+ language assertion were retired with the English-only descope.)
15
+
16
+ A gold letter passes only when:
17
+ - Pass A verdict passes (no-invention AND recall=100% AND all_sections_present AND severity)
18
+ - Pass B evaluate() verdict passes (same gate on beginner output)
19
+ - beginner_invariant(pass_B_result) holds (structural invariance, D-08)
20
+
21
+ Severity MAE is reported but never fails the gate (D-15, advisory).
22
+
23
+ Gate exits non-zero when ANY letter fails (either pass), the adversarial pass fails, or the
24
+ gold set is empty.
25
+
26
+ CRITICAL lazy-import contract:
27
+ - Stdlib-only at module top (json, re, unicodedata, argparse, pathlib, sys)
28
+ - `from app import ...` lives ONLY inside run_eval()/run_adversarial_eval() so importing
29
+ this module (e.g., for unit tests) never loads the model.
30
+ """
31
+
32
+ import argparse
33
+ import json
34
+ import re
35
+ import sys
36
+ import unicodedata
37
+ from pathlib import Path
38
+
39
+ # Ensure project root is on sys.path so `from app import ...` resolves
40
+ # whether this script is run as:
41
+ # python eval/run_eval.py (cwd = project root)
42
+ # python run_eval.py (cwd = eval/)
43
+ # python -m pytest eval/ (cwd = project root)
44
+ _PROJECT_ROOT = Path(__file__).resolve().parent.parent
45
+ if str(_PROJECT_ROOT) not in sys.path:
46
+ sys.path.insert(0, str(_PROJECT_ROOT))
47
+
48
+ # Also ensure eval/ is on sys.path so `import grounded` resolves when
49
+ # run_eval.py is imported from the project root (e.g. by test_eval_matching.py).
50
+ _EVAL_DIR = str(Path(__file__).resolve().parent)
51
+ if _EVAL_DIR not in sys.path:
52
+ sys.path.insert(0, _EVAL_DIR)
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Import matching primitives from shared module (D2-05 refactor).
56
+ # Names re-bound here so existing `import run_eval as e; e.normalize(...)` calls
57
+ # in eval/test_eval_matching.py continue to work without modification.
58
+ # Use `from grounded import ...` (eval/ is on sys.path when running run_eval.py).
59
+ # ---------------------------------------------------------------------------
60
+ from grounded import normalize, value_found, extract_values_from_section # noqa: F401
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Per-letter verdict (D-12, D-13, D-14, D-15, SC1)
65
+ # ---------------------------------------------------------------------------
66
+
67
+ def evaluate(result, gold: dict) -> dict:
68
+ """
69
+ Return a per-letter verdict dict for a StructuredResult-shaped object and gold dict.
70
+
71
+ No-invention check (D-12): every value extracted from result.deadlines must be
72
+ a normalized substring of result.transcription.
73
+
74
+ Recall check (D-13): every verbatim_swedish value from gold["deadlines"],
75
+ gold["amounts"], gold["references"] must appear in result.deadlines.
76
+
77
+ Four-sections completeness (SC1): tldr, why, actions, deadlines must all be non-empty.
78
+
79
+ Severity (D-15): MAE is computed and included; does NOT affect pass.
80
+
81
+ PASS = no-invention AND recall=100% AND severity is not None AND all_sections_present.
82
+
83
+ Works on any SimpleNamespace or StructuredResult — evaluate() does NOT import app.
84
+ """
85
+ # 1. No-invention check (D-12)
86
+ invented = []
87
+ for emitted_val in extract_values_from_section(result.deadlines):
88
+ if not value_found(emitted_val, result.transcription):
89
+ invented.append(emitted_val)
90
+
91
+ # 2. Recall check (D-13) — must extract verbatim_swedish string (Pitfall 9)
92
+ missing = []
93
+ for category_key in ("deadlines", "amounts", "references"):
94
+ for d in gold.get(category_key, []):
95
+ verbatim = d["verbatim_swedish"]
96
+ if not value_found(verbatim, result.deadlines):
97
+ missing.append(verbatim)
98
+
99
+ # 3. Four-sections completeness (SC1)
100
+ all_sections_present = all(
101
+ bool(getattr(result, f, None))
102
+ for f in ("tldr", "why", "actions", "deadlines")
103
+ )
104
+
105
+ # 4. Severity MAE (D-15, advisory — never fails gate)
106
+ severity = result.severity
107
+ if severity is not None:
108
+ sev_mae = abs(severity - gold["expected_severity"])
109
+ else:
110
+ sev_mae = 5.0 # sentinel: output truncated before SEVERITY line
111
+
112
+ # 5. Recall rate denominator
113
+ total_gold = sum(
114
+ len(gold.get(k, []))
115
+ for k in ("deadlines", "amounts", "references")
116
+ )
117
+
118
+ passed = (
119
+ len(invented) == 0
120
+ and len(missing) == 0
121
+ and severity is not None
122
+ and all_sections_present
123
+ )
124
+
125
+ return {
126
+ "pass": passed,
127
+ "invented_count": len(invented),
128
+ "invented": invented,
129
+ "missing_count": len(missing),
130
+ "missing": missing,
131
+ "recall_rate": 1.0 - len(missing) / max(total_gold, 1),
132
+ "severity_mae": sev_mae,
133
+ "schema_complete": severity is not None,
134
+ "all_sections_present": all_sections_present,
135
+ }
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # D-08 beginner-mode structural invariance checker
140
+ # ---------------------------------------------------------------------------
141
+
142
+ def beginner_invariant(result) -> tuple:
143
+ """
144
+ Assert D-08 structural invariance on a beginner-mode StructuredResult.
145
+
146
+ Checks:
147
+ - All four section fields (tldr, why, actions, deadlines) are non-empty
148
+ - severity is not None (SEVERITY line still present and parseable)
149
+ - transcription is non-empty (transcription block still present)
150
+
151
+ Returns (ok: bool, reasons: list[str]).
152
+ ok=True if all invariants hold; False if any fail, with reasons listing each failure.
153
+ """
154
+ reasons = []
155
+
156
+ # Check all four section fields
157
+ for field in ("tldr", "why", "actions", "deadlines"):
158
+ if not bool(getattr(result, field, None)):
159
+ reasons.append(f"section '{field}' is empty in beginner-mode output (D-08 violation)")
160
+
161
+ # Check severity parseable
162
+ if result.severity is None:
163
+ reasons.append(
164
+ "severity is None in beginner-mode output — SEVERITY line dropped or truncated (D-08 violation)"
165
+ )
166
+
167
+ # Check transcription block present
168
+ if not bool(getattr(result, "transcription", None)):
169
+ reasons.append(
170
+ "transcription is empty in beginner-mode output — transcription block dropped (D-08 violation)"
171
+ )
172
+
173
+ return (len(reasons) == 0, reasons)
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Refusal scorer (Phase 3 TRUST-02/03/04) — called by slice 3 for adversarial fixtures
178
+ # ---------------------------------------------------------------------------
179
+
180
+ def _render_value(pane) -> str:
181
+ """Extract the text value from a render_result pane (gr.update dict or str)."""
182
+ if isinstance(pane, dict):
183
+ return str(pane.get("value", "") or "")
184
+ return str(pane or "")
185
+
186
+
187
+ def evaluate_refusal(result, gold: dict, language: str = "English") -> dict:
188
+ """
189
+ Score a StructuredResult against an adversarial fixture sidecar.
190
+
191
+ Not evaluated with evaluate() — adversarial fixtures have no gold values to recall.
192
+ The pass criterion is the REAL TRUST-03 / SC1 guarantee — *the user is shown no
193
+ four-section analysis* — verified at the render layer, plus correct classification:
194
+ - result.doctype matches gold["expected_doctype"] (drives the refusal route)
195
+ - render_result(result) shows no Panic Meter (panic_html == "") and no analysis
196
+ in the why / actions / deadlines panes (render_refusal suppresses them)
197
+
198
+ Why not the old `result.tldr is empty` proxy: a readable non-Swedish *letter* can be
199
+ (and is) analysed by the model before the render layer suppresses it, so raw `tldr`
200
+ is legitimately non-empty even though the user correctly sees only a refusal. The old
201
+ proxy wrongly failed that case. tldr_empty is still reported, but ADVISORY only.
202
+
203
+ render_result is imported lazily (it is a pure function — no model — so it is safe
204
+ under BUREAUCAT_NO_MODEL=1, and this keeps module import of run_eval app-free).
205
+
206
+ Returns a dict matching evaluate()'s shape (for uniform handling in slice 3).
207
+ """
208
+ expected = gold.get("expected_doctype", "")
209
+ # A fixture may list several equally-correct refusal doctypes. A non-Swedish
210
+ # English letter, for instance, is a correct refusal whether the model labels it
211
+ # "non_swedish" (precise) or "not_letter" (generic) — both route to render_refusal
212
+ # and show the user no analysis. accepted_doctypes makes the gate robust to that
213
+ # benign drift across prompt tweaks; falls back to the single expected_doctype.
214
+ accepted = gold.get("accepted_doctypes") or [expected]
215
+ actual_doctype = getattr(result, "doctype", "letter")
216
+ doctype_correct = actual_doctype in accepted
217
+
218
+ from app import render_result # lazy; pure fn, safe under BUREAUCAT_NO_MODEL=1
219
+ rendered = render_result(result, language)
220
+ panic_html = rendered[0]
221
+ no_analysis_rendered = (
222
+ panic_html == ""
223
+ and not _render_value(rendered[4]).strip() # why
224
+ and not _render_value(rendered[5]).strip() # actions
225
+ and not _render_value(rendered[6]).strip() # deadlines
226
+ )
227
+
228
+ tldr_empty = not getattr(result, "tldr", None) # advisory only
229
+
230
+ passed = doctype_correct and no_analysis_rendered
231
+ verdict = "refusal_correct" if passed else "refusal_wrong"
232
+
233
+ return {
234
+ "pass": passed,
235
+ "verdict": verdict,
236
+ "doctype": actual_doctype,
237
+ "expected_doctype": expected,
238
+ "doctype_correct": doctype_correct,
239
+ "no_analysis_rendered": no_analysis_rendered,
240
+ "tldr_empty": tldr_empty, # advisory — model may legitimately analyse non-Swedish
241
+ }
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # Bake-off runner — app contracts imported LAZILY inside this function only
246
+ # ---------------------------------------------------------------------------
247
+
248
+ def run_eval(
249
+ model_variant: str = "qwen3",
250
+ dump_path: str = None,
251
+ ) -> bool:
252
+ """
253
+ Run the bake-off harness for the given model variant and output language.
254
+
255
+ Imports app contracts lazily BELOW the empty-set guard ONLY:
256
+ load_model, run_inference, MODEL_VARIANTS — the ONLY place app is imported.
257
+
258
+ Iterates sorted data/letters/public/*.json sidecars. For each letter:
259
+ - Pass A (standard, beginner_mode=False): full evaluate() gate
260
+ - Pass B (beginner, beginner_mode=True): full evaluate() gate + beginner_invariant()
261
+
262
+ A letter PASSES only when both passes pass AND beginner_invariant holds.
263
+
264
+ The *language* parameter controls the output language passed to run_inference().
265
+ Default "English" preserves the existing single-language harness behaviour.
266
+
267
+ Exits non-zero (returns False) when any letter fails or gold set is empty.
268
+ The empty-set guard fires BEFORE the app import so no model weights are
269
+ downloaded/loaded when the gold set has not been populated yet.
270
+ """
271
+ # ------------------------------------------------------------------
272
+ # Guard: check gold set exists BEFORE importing app (which triggers
273
+ # load_model() at module scope unless BUREAUCAT_NO_MODEL=1 is set).
274
+ # This ensures `python eval/run_eval.py` on an empty gold set exits
275
+ # immediately with a clear message and no 16GB weight download.
276
+ # ------------------------------------------------------------------
277
+ letter_dir = Path("data/letters/public")
278
+ sidecars = sorted(letter_dir.glob("*.json"))
279
+
280
+ if not sidecars:
281
+ print(
282
+ f"\n[run_eval] ERROR: Gold set is empty — no .json sidecars in {letter_dir}.\n"
283
+ f"The bake-off gate cannot pass with zero letters (D-09).\n"
284
+ f"Add at least 5 annotated gold letters before running Plan 04 bake-off.\n"
285
+ )
286
+ return False
287
+
288
+ # LAZY IMPORT — app is only imported here, after the empty-set guard,
289
+ # so module-scope import of run_eval (for unit tests) never loads the
290
+ # 16GB+ model, AND an empty gold set exits cleanly with no download.
291
+ from app import load_model, run_inference, MODEL_VARIANTS # noqa: PLC0415
292
+
293
+ # Load model once for the entire eval run
294
+ print(f"\n[run_eval] Loading model variant: {model_variant}")
295
+ variant_cfg = MODEL_VARIANTS[model_variant]
296
+ image_patch_size = variant_cfg["image_patch_size"]
297
+ mdl, proc = load_model(model_variant)
298
+
299
+ results = []
300
+ dump = {} # letter_stem -> {"standard": {...fields}, "beginner": {...fields}}
301
+ for sidecar in sidecars:
302
+ # Find matching image (.png preferred, fallback .jpg)
303
+ image_path = sidecar.with_suffix(".png")
304
+ if not image_path.exists():
305
+ image_path = sidecar.with_suffix(".jpg")
306
+ if not image_path.exists():
307
+ print(f" [SKIP] {sidecar.stem}: no matching image file")
308
+ continue
309
+
310
+ from PIL import Image
311
+ image = Image.open(image_path)
312
+ gold = json.loads(sidecar.read_text())
313
+
314
+ # Pass A: standard inference (English-only product — 2026-06-07 descope)
315
+ result_std = run_inference(
316
+ image, "English", beginner_mode=False,
317
+ mdl=mdl, proc=proc, image_patch_size=image_patch_size,
318
+ )
319
+ verdict_std = evaluate(result_std, gold)
320
+
321
+ # Pass B: beginner-mode inference (D-08 invariance check)
322
+ result_beg = run_inference(
323
+ image, "English", beginner_mode=True,
324
+ mdl=mdl, proc=proc, image_patch_size=image_patch_size,
325
+ )
326
+ verdict_beg = evaluate(result_beg, gold)
327
+ inv_ok, inv_reasons = beginner_invariant(result_beg)
328
+
329
+ if dump_path:
330
+ _fields = ("transcription", "quip", "tldr", "why", "actions", "deadlines", "severity", "raw", "doctype")
331
+ dump[sidecar.stem] = {
332
+ "standard": {f: getattr(result_std, f) for f in _fields},
333
+ "beginner": {f: getattr(result_beg, f) for f in _fields},
334
+ }
335
+
336
+ letter_pass = verdict_std["pass"] and verdict_beg["pass"] and inv_ok
337
+
338
+ # Per-letter output
339
+ status = "PASS" if letter_pass else "FAIL"
340
+ print(
341
+ f"\n {status} {sidecar.stem}:\n"
342
+ f" STANDARD: recall={verdict_std['recall_rate']:.0%} "
343
+ f"invented={verdict_std['invented_count']} "
344
+ f"severity_mae={verdict_std['severity_mae']:.1f} "
345
+ f"all_sections={verdict_std['all_sections_present']}\n"
346
+ f" BEGINNER: recall={verdict_beg['recall_rate']:.0%} "
347
+ f"invented={verdict_beg['invented_count']} "
348
+ f"severity_mae={verdict_beg['severity_mae']:.1f} "
349
+ f"all_sections={verdict_beg['all_sections_present']}\n"
350
+ f" BEGINNER_INVARIANT: {'OK' if inv_ok else 'FAIL(' + '; '.join(inv_reasons) + ')'}"
351
+ )
352
+ if not verdict_std["pass"]:
353
+ if verdict_std["invented"]:
354
+ print(f" [STD] Invented values: {verdict_std['invented']}")
355
+ if verdict_std["missing"]:
356
+ print(f" [STD] Missing gold values: {verdict_std['missing']}")
357
+ if not verdict_beg["pass"]:
358
+ if verdict_beg["invented"]:
359
+ print(f" [BEG] Invented values: {verdict_beg['invented']}")
360
+ if verdict_beg["missing"]:
361
+ print(f" [BEG] Missing gold values: {verdict_beg['missing']}")
362
+
363
+ results.append({
364
+ "letter": sidecar.stem,
365
+ "pass": letter_pass,
366
+ })
367
+
368
+ # Overall summary
369
+ n_pass = sum(1 for r in results if r["pass"])
370
+ n_total = len(results)
371
+ gate = n_pass == n_total and n_total > 0
372
+
373
+ print(f"\n=== EVAL RESULTS ({model_variant}) ===")
374
+ print(f" Overall: {n_pass}/{n_total} letters passed both passes")
375
+ print(f" GATE: {'PASS' if gate else 'FAIL'}")
376
+
377
+ if dump_path:
378
+ Path(dump_path).write_text(
379
+ json.dumps(dump, ensure_ascii=False, indent=2), encoding="utf-8"
380
+ )
381
+ print(f" Dumped raw model outputs for {len(dump)} letters to {dump_path}")
382
+
383
+ return gate
384
+
385
+
386
+ # ---------------------------------------------------------------------------
387
+ # Adversarial refusal scoring (Phase 3 TRUST-02/03/04)
388
+ # ---------------------------------------------------------------------------
389
+
390
+ def run_adversarial_eval(
391
+ model_variant: str = "qwen3",
392
+ ) -> tuple[bool, list[dict]]:
393
+ """
394
+ Score the three adversarial fixtures against the refusal gate.
395
+
396
+ Globs data/letters/public/adversarial/*.json — separate from the gold-letter
397
+ loop (glob("*.json") in run_eval is non-recursive so adversarial sidecars never
398
+ enter the EVAL-02 letter loop).
399
+
400
+ Loads the model lazily after the empty-set guard (mirrors run_eval discipline).
401
+ Runs each fixture in English only (adversarial path is not language-specific;
402
+ classification is always English-emitted via the DOCTYPE sentinel).
403
+
404
+ Returns (gate_pass: bool, verdicts: list[dict]).
405
+ """
406
+ adv_dir = Path("data/letters/public/adversarial")
407
+ adv_sidecars = sorted(adv_dir.glob("*.json"))
408
+
409
+ if not adv_sidecars:
410
+ print(
411
+ f"\n[run_adversarial_eval] WARNING: no adversarial fixtures in {adv_dir}.\n"
412
+ f"Skipping adversarial refusal scoring.\n"
413
+ )
414
+ return True, []
415
+
416
+ from app import load_model, run_inference, MODEL_VARIANTS # noqa: PLC0415
417
+ from PIL import Image # noqa: PLC0415
418
+
419
+ variant_cfg = MODEL_VARIANTS[model_variant]
420
+ image_patch_size = variant_cfg["image_patch_size"]
421
+ mdl, proc = load_model(model_variant)
422
+
423
+ verdicts = []
424
+ for sidecar in adv_sidecars:
425
+ image_path = sidecar.with_suffix(".png")
426
+ if not image_path.exists():
427
+ image_path = sidecar.with_suffix(".jpg")
428
+ if not image_path.exists():
429
+ print(f" [SKIP-ADV] {sidecar.stem}: no matching image file")
430
+ continue
431
+
432
+ image = Image.open(image_path)
433
+ gold = json.loads(sidecar.read_text())
434
+
435
+ result = run_inference(
436
+ image, "English", beginner_mode=False,
437
+ mdl=mdl, proc=proc, image_patch_size=image_patch_size,
438
+ )
439
+ verdict = evaluate_refusal(result, gold)
440
+ verdict["letter"] = sidecar.stem
441
+ verdicts.append(verdict)
442
+
443
+ status = "PASS" if verdict["pass"] else "FAIL"
444
+ print(
445
+ f"\n {status} [ADV] {sidecar.stem}:\n"
446
+ f" verdict={verdict['verdict']} "
447
+ f"doctype={verdict['doctype']!r} expected={verdict['expected_doctype']!r} "
448
+ f"tldr_empty={verdict['tldr_empty']}"
449
+ )
450
+
451
+ n_pass = sum(1 for v in verdicts if v["pass"])
452
+ n_total = len(verdicts)
453
+ gate = n_pass == n_total and n_total > 0
454
+
455
+ print(f"\n=== ADVERSARIAL RESULTS ({model_variant}) ===")
456
+ print(f" Overall: {n_pass}/{n_total} adversarial fixtures scored refusal_correct")
457
+ print(f" GATE: {'PASS' if gate else 'FAIL'}")
458
+
459
+ return gate, verdicts
460
+
461
+
462
+ # ---------------------------------------------------------------------------
463
+ # CLI entry point — English-only gate (gold accuracy + adversarial refusal)
464
+ # ---------------------------------------------------------------------------
465
+
466
+ def _main():
467
+ parser = argparse.ArgumentParser(
468
+ description="Bureaucat evaluator — EVAL-02 gate (English-only product)"
469
+ )
470
+ parser.add_argument(
471
+ "--model",
472
+ choices=["qwen3", "qwen25"],
473
+ default="qwen3",
474
+ help="Model variant to evaluate (default: qwen3)",
475
+ )
476
+ parser.add_argument(
477
+ "--dump",
478
+ default=None,
479
+ metavar="PATH",
480
+ help="Write raw per-letter StructuredResult outputs to PATH (JSON) for "
481
+ "offline matching/gold iteration at zero GPU cost.",
482
+ )
483
+ args = parser.parse_args()
484
+
485
+ # English-only product (2026-06-07 descope): gold-letter accuracy gate +
486
+ # adversarial refusal gate. Both must pass.
487
+ gold_gate = run_eval(args.model, dump_path=args.dump)
488
+ adv_gate, _ = run_adversarial_eval(args.model)
489
+ sys.exit(0 if (gold_gate and adv_gate) else 1)
490
+
491
+
492
+ if __name__ == "__main__":
493
+ _main()
eval/test_eval_matching.py ADDED
@@ -0,0 +1,475 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for eval/run_eval.py — matching primitives only.
3
+
4
+ These tests import run_eval DIRECTLY (not via app) and never load model weights.
5
+ No BUREAUCAT_NO_MODEL needed here because run_eval.py does NOT import app at
6
+ module scope — only inside run_eval() (lazy import contract).
7
+
8
+ Test coverage:
9
+ Task 1 primitives (normalize, value_found, extract_values_from_section):
10
+ - normalize: collapses NBSP, thin space, narrow no-break space, lowercases
11
+ - value_found: long ISO date found in transcription with surrounding text
12
+ - value_found short-value boundary: "123" NOT matched inside "1234567"
13
+ - value_found short-value boundary: "123" IS matched when standing alone
14
+ - value_found whitespace tolerance: gold with thin-space found in transcription
15
+ - extract_values_from_section: em-dash separator, strips bullets
16
+ - extract_values_from_section: hyphen-with-spaces separator
17
+ - extract_values_from_section: "None found." yields no values
18
+ - importability: 'app' not in sys.modules after importing run_eval
19
+ (checked in a standalone -c probe, not here — see verify block in plan)
20
+
21
+ Task 2 functions (evaluate, beginner_invariant):
22
+ - evaluate: clean result passes (invented=0, missing=0, all_sections_present, severity)
23
+ - evaluate: invented value (not in transcription) → pass=False
24
+ - evaluate: missing gold value (not in deadlines) → pass=False
25
+ - evaluate: empty section (tldr="") → pass=False even with zero invented/missing
26
+ - evaluate: severity MAE is computed but does not affect pass
27
+ - beginner_invariant: True when all fields present
28
+ - beginner_invariant: False when a section is empty
29
+ - beginner_invariant: False when severity is None
30
+ - beginner_invariant: False when transcription is empty
31
+ """
32
+
33
+ import os
34
+ import sys
35
+
36
+ # Ensure project root and eval/ are on the path regardless of where pytest is run from.
37
+ _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
38
+ _EVAL_DIR = os.path.join(_REPO_ROOT, "eval")
39
+ if _REPO_ROOT not in sys.path:
40
+ sys.path.insert(0, _REPO_ROOT)
41
+ if _EVAL_DIR not in sys.path:
42
+ sys.path.insert(0, _EVAL_DIR)
43
+
44
+ import run_eval as e
45
+ from types import SimpleNamespace as S
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Task 1 primitive tests: normalize
50
+ # ---------------------------------------------------------------------------
51
+
52
+ def test_normalize_case_insensitive():
53
+ """normalize lowercases input."""
54
+ assert e.normalize("1 234,56 KR") == e.normalize("1 234,56 kr")
55
+
56
+
57
+ def test_normalize_collapses_nbsp():
58
+ """U+00A0 non-breaking space collapsed to ASCII space."""
59
+ #   between digits, as OCR might produce
60
+ text_with_nbsp = "1 234 kr"
61
+ text_plain = "1 234 kr"
62
+ assert e.normalize(text_with_nbsp) == e.normalize(text_plain)
63
+
64
+
65
+ def test_normalize_collapses_thin_space():
66
+ """U+2009 thin space collapsed to ASCII space."""
67
+ text_thin = "1 234 kr"
68
+ text_plain = "1 234 kr"
69
+ assert e.normalize(text_thin) == e.normalize(text_plain)
70
+
71
+
72
+ def test_normalize_collapses_narrow_nobreak():
73
+ """U+202F narrow no-break space collapsed to ASCII space."""
74
+ text_narrow = "1 234 kr"
75
+ text_plain = "1 234 kr"
76
+ assert e.normalize(text_narrow) == e.normalize(text_plain)
77
+
78
+
79
+ def test_normalize_nfc():
80
+ """NFC normalization handles combining characters from OCR."""
81
+ # e + combining acute = é (NFC)
82
+ composed = "é" # é
83
+ decomposed = "é" # e + combining acute
84
+ assert e.normalize(decomposed) == e.normalize(composed)
85
+
86
+
87
+ def test_normalize_collapses_multiple_spaces():
88
+ """Multiple spaces (including mixed Unicode) collapse to single space."""
89
+ assert e.normalize("a b") == "a b"
90
+ assert e.normalize("a   b") == "a b"
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Task 1 primitive tests: value_found
95
+ # ---------------------------------------------------------------------------
96
+
97
+ def test_value_found_long_date_in_transcription():
98
+ """An ISO date (>= 6 digits) is found when present with surrounding text."""
99
+ haystack = "Du ska betala senast 2026-06-15. Glöm inte."
100
+ assert e.value_found("2026-06-15", haystack) is True
101
+
102
+
103
+ def test_value_found_long_date_absent():
104
+ """A date not in the transcription is not found."""
105
+ haystack = "Du ska betala senast 2026-06-15."
106
+ assert e.value_found("2026-07-99", haystack) is False
107
+
108
+
109
+ def test_value_found_short_boundary_inside_longer():
110
+ """A 3-digit ref "123" is NOT found inside "1234567" (boundary rule, Pitfall 5)."""
111
+ haystack = "ref 1234567 i systemet"
112
+ assert e.value_found("123", haystack) is False
113
+
114
+
115
+ def test_value_found_short_boundary_standalone():
116
+ """A 3-digit ref "123" IS found when it stands alone."""
117
+ haystack = "OCR-nummer 123 gäller"
118
+ assert e.value_found("123", haystack) is True
119
+
120
+
121
+ def test_value_found_short_boundary_at_start():
122
+ """A 3-digit ref at the start of the string is found."""
123
+ haystack = "123 är ditt referensnummer"
124
+ assert e.value_found("123", haystack) is True
125
+
126
+
127
+ def test_value_found_whitespace_tolerance():
128
+ """Gold "1 234 kr" (thin space) is found in transcription with plain space."""
129
+ haystack = "belopp 1 234 kr forfaller 2026-06-15"
130
+ # Gold may have thin space; haystack has plain space — both normalize
131
+ assert e.value_found("1 234 kr", haystack) is True
132
+
133
+
134
+ def test_value_found_case_insensitive():
135
+ """Match is case-insensitive (normalize lowercases)."""
136
+ assert e.value_found("JUNI", "betala i juni 2026") is True
137
+
138
+
139
+ def test_value_found_not_found_returns_false():
140
+ """A value genuinely absent from haystack returns False."""
141
+ assert e.value_found("2026-12-31", "betala senast 2026-06-15") is False
142
+
143
+
144
+ def test_value_found_six_digit_reference_uses_plain_substring():
145
+ """A 6+ digit reference uses plain normalized substring (no boundary required)."""
146
+ # "123456789" has 9 digits (>= 6) — substring match, no boundary needed
147
+ assert e.value_found("123456789", "OCR: 123456789") is True
148
+
149
+
150
+ def test_value_found_long_value_not_matched_inside_longer_digit_run():
151
+ """WR-01: a value must NOT match inside a longer run of digits, regardless of
152
+ digit count — the non-digit boundary is applied universally."""
153
+ # 6-digit value inside a 7-digit run — must NOT match
154
+ assert e.value_found("123456", "ref 1234567") is False
155
+ # 8-digit reference must NOT match as a superstring (the WR-01 case)
156
+ assert e.value_found("12-345678", "ref 912-3456789") is False
157
+ # ISO date must NOT match inside a longer leading-digit run
158
+ assert e.value_found("2026-06-15", "x12026-06-15") is False
159
+ # ...but the same values DO match when properly delimited
160
+ assert e.value_found("123456", "ref 123456 end") is True
161
+ assert e.value_found("12-345678", "dossier 12-345678.") is True
162
+ assert e.value_found("2026-06-15", "senast 2026-06-15") is True
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Task 1 primitive tests: extract_values_from_section
167
+ # ---------------------------------------------------------------------------
168
+
169
+ def test_extract_em_dash_separator():
170
+ """Em-dash separator lines: verbatim value before ' — '."""
171
+ section = "- 15 juni 2026 — last day to file\n- 1 234 kr — amount owed"
172
+ vals = e.extract_values_from_section(section)
173
+ assert vals == ["15 juni 2026", "1 234 kr"]
174
+
175
+
176
+ def test_extract_hyphen_with_spaces_separator():
177
+ """Hyphen-with-spaces separator lines: verbatim value before ' - '."""
178
+ section = "- 2026-06-15 - sista betalningsdag\n- 1 234 kr - fakturabelopp"
179
+ vals = e.extract_values_from_section(section)
180
+ assert vals == ["2026-06-15", "1 234 kr"]
181
+
182
+
183
+ def test_extract_none_found_yields_empty():
184
+ """'None found.' line (and variants) yields no values."""
185
+ assert e.extract_values_from_section("None found.") == []
186
+ assert e.extract_values_from_section("none found.") == []
187
+ assert e.extract_values_from_section("None found") == []
188
+
189
+
190
+ def test_extract_blank_lines_skipped():
191
+ """Blank lines between entries are skipped."""
192
+ section = "\n- 2026-06-15 — sista dag\n\n- 1 234 kr — belopp\n"
193
+ vals = e.extract_values_from_section(section)
194
+ assert vals == ["2026-06-15", "1 234 kr"]
195
+
196
+
197
+ def test_extract_date_with_internal_hyphens_preserved():
198
+ """ISO date 2026-06-15 is not split by internal hyphens — only ' - ' with spaces."""
199
+ section = "- 2026-06-15 — sista dag"
200
+ vals = e.extract_values_from_section(section)
201
+ assert vals == ["2026-06-15"]
202
+
203
+
204
+ def test_extract_mixed_separators():
205
+ """Mixed em-dash and hyphen-space separators in same section."""
206
+ section = "- 15 juni 2026 — sista dag\n- 1 234 kr - belopp"
207
+ vals = e.extract_values_from_section(section)
208
+ assert vals == ["15 juni 2026", "1 234 kr"]
209
+
210
+
211
+ def test_extract_empty_section_yields_empty():
212
+ """Empty section string yields empty list."""
213
+ assert e.extract_values_from_section("") == []
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Task 2 tests: evaluate (SimpleNamespace stand-ins — no app import)
218
+ # ---------------------------------------------------------------------------
219
+
220
+ def _gold(
221
+ deadlines=None, amounts=None, references=None, expected_severity=4
222
+ ):
223
+ """Helper: build a gold dict with the pattern-6 sidecar schema."""
224
+ return {
225
+ "expected_severity": expected_severity,
226
+ "deadlines": deadlines or [],
227
+ "amounts": amounts or [],
228
+ "references": references or [],
229
+ }
230
+
231
+
232
+ def _good_result():
233
+ """A fully-populated StructuredResult stand-in that should PASS."""
234
+ return S(
235
+ transcription="betala 1 234 kr senast 2026-06-15",
236
+ tldr="You must pay.",
237
+ why="You owe money.",
238
+ actions="Pay the invoice.",
239
+ deadlines="- 2026-06-15 — sista dag\n- 1 234 kr — belopp",
240
+ severity=4,
241
+ )
242
+
243
+
244
+ def test_evaluate_clean_result_passes():
245
+ """A clean, complete result passes all gate checks."""
246
+ gold = _gold(
247
+ deadlines=[{"verbatim_swedish": "2026-06-15"}],
248
+ amounts=[{"verbatim_swedish": "1 234 kr"}],
249
+ )
250
+ v = e.evaluate(_good_result(), gold)
251
+ assert v["pass"] is True
252
+ assert v["invented_count"] == 0
253
+ assert v["missing_count"] == 0
254
+ assert v["all_sections_present"] is True
255
+
256
+
257
+ def test_evaluate_invented_value_fails():
258
+ """A value in deadlines section absent from transcription → pass=False."""
259
+ result = S(
260
+ transcription="betala senast 2026-06-15", # "2026-07-99" NOT in transcription
261
+ tldr="You must pay.",
262
+ why="Because.",
263
+ actions="Pay it.",
264
+ deadlines="- 2026-07-99 — invented\n- 2026-06-15 — real",
265
+ severity=4,
266
+ )
267
+ gold = _gold(deadlines=[{"verbatim_swedish": "2026-06-15"}])
268
+ v = e.evaluate(result, gold)
269
+ assert v["pass"] is False
270
+ assert v["invented_count"] >= 1
271
+
272
+
273
+ def test_evaluate_missing_gold_value_fails():
274
+ """A gold verbatim_swedish value absent from deadlines section → pass=False."""
275
+ result = S(
276
+ transcription="betala 1 234 kr senast 2026-06-15",
277
+ tldr="You must pay.",
278
+ why="Because.",
279
+ actions="Pay it.",
280
+ # "1 234 kr" is NOT in this deadlines section
281
+ deadlines="- 2026-06-15 — sista dag",
282
+ severity=4,
283
+ )
284
+ gold = _gold(
285
+ deadlines=[{"verbatim_swedish": "2026-06-15"}],
286
+ amounts=[{"verbatim_swedish": "1 234 kr"}],
287
+ )
288
+ v = e.evaluate(result, gold)
289
+ assert v["pass"] is False
290
+ assert v["missing_count"] >= 1
291
+
292
+
293
+ def test_evaluate_empty_section_fails_sc1():
294
+ """
295
+ SC1 gate: an empty TL;DR section → pass=False even with zero invented/missing
296
+ and a valid severity.
297
+ """
298
+ result = S(
299
+ transcription="betala 1 234 kr senast 2026-06-15",
300
+ tldr="", # EMPTY — SC1 failure
301
+ why="Because.",
302
+ actions="Pay it.",
303
+ deadlines="- 2026-06-15 — sista dag\n- 1 234 kr — belopp",
304
+ severity=4,
305
+ )
306
+ gold = _gold(
307
+ deadlines=[{"verbatim_swedish": "2026-06-15"}],
308
+ amounts=[{"verbatim_swedish": "1 234 kr"}],
309
+ )
310
+ v = e.evaluate(result, gold)
311
+ assert v["pass"] is False
312
+ assert v["all_sections_present"] is False
313
+
314
+
315
+ def test_evaluate_empty_deadlines_section_fails_sc1():
316
+ """An empty deadlines section → pass=False (SC1)."""
317
+ result = S(
318
+ transcription="betala 1 234 kr senast 2026-06-15",
319
+ tldr="Summary.",
320
+ why="Because.",
321
+ actions="Pay.",
322
+ deadlines="", # EMPTY deadlines — SC1
323
+ severity=4,
324
+ )
325
+ gold = _gold() # No gold values — so 0 invented, 0 missing
326
+ v = e.evaluate(result, gold)
327
+ assert v["pass"] is False
328
+ assert v["all_sections_present"] is False
329
+
330
+
331
+ def test_evaluate_severity_none_fails():
332
+ """severity=None → pass=False (schema_complete=False)."""
333
+ result = S(
334
+ transcription="betala 1 234 kr",
335
+ tldr="Summary.",
336
+ why="Because.",
337
+ actions="Pay.",
338
+ deadlines="- 1 234 kr — belopp",
339
+ severity=None, # truncated output
340
+ )
341
+ gold = _gold(amounts=[{"verbatim_swedish": "1 234 kr"}])
342
+ v = e.evaluate(result, gold)
343
+ assert v["pass"] is False
344
+ assert v["schema_complete"] is False
345
+
346
+
347
+ def test_evaluate_severity_mae_advisory_does_not_fail():
348
+ """Severity MAE is reported but does NOT cause pass=False (D-15)."""
349
+ result = S(
350
+ transcription="betala 1 234 kr senast 2026-06-15",
351
+ tldr="Summary.",
352
+ why="Because.",
353
+ actions="Pay.",
354
+ deadlines="- 2026-06-15 — sista dag\n- 1 234 kr — belopp",
355
+ severity=1, # severity=1 vs expected=4 → MAE=3
356
+ )
357
+ gold = _gold(
358
+ deadlines=[{"verbatim_swedish": "2026-06-15"}],
359
+ amounts=[{"verbatim_swedish": "1 234 kr"}],
360
+ expected_severity=4,
361
+ )
362
+ v = e.evaluate(result, gold)
363
+ # Large MAE but everything else is correct → PASS
364
+ assert v["pass"] is True
365
+ assert v["severity_mae"] == 3
366
+
367
+
368
+ def test_evaluate_all_sections_present_in_verdict():
369
+ """all_sections_present key is always present in the verdict dict."""
370
+ v = e.evaluate(_good_result(), _gold())
371
+ assert "all_sections_present" in v
372
+
373
+
374
+ def test_evaluate_references_checked_in_recall():
375
+ """References in gold sidecar are checked in the recall (deadlines section)."""
376
+ result = S(
377
+ transcription="betala 1 234 kr OCR 123456789",
378
+ tldr="Summary.",
379
+ why="Because.",
380
+ actions="Pay.",
381
+ # "123456789" is absent from deadlines
382
+ deadlines="- 1 234 kr — belopp",
383
+ severity=3,
384
+ )
385
+ gold = _gold(
386
+ amounts=[{"verbatim_swedish": "1 234 kr"}],
387
+ references=[{"verbatim_swedish": "123456789"}],
388
+ )
389
+ v = e.evaluate(result, gold)
390
+ assert v["pass"] is False
391
+ assert v["missing_count"] >= 1
392
+
393
+
394
+ # ---------------------------------------------------------------------------
395
+ # Task 2 tests: beginner_invariant
396
+ # ---------------------------------------------------------------------------
397
+
398
+ def test_beginner_invariant_fully_populated_passes():
399
+ """A fully-populated beginner-mode result passes all D-08 invariants."""
400
+ result = S(
401
+ transcription="betala 1 234 kr senast 2026-06-15",
402
+ tldr="Summary.",
403
+ why="Because.",
404
+ actions="Pay.",
405
+ deadlines="- 2026-06-15 — sista dag\n- 1 234 kr — belopp",
406
+ severity=4,
407
+ )
408
+ ok, reasons = e.beginner_invariant(result)
409
+ assert ok is True
410
+ assert reasons == []
411
+
412
+
413
+ def test_beginner_invariant_empty_section_fails():
414
+ """beginner_invariant returns False when a section is empty."""
415
+ result = S(
416
+ transcription="betala 1 234 kr senast 2026-06-15",
417
+ tldr="", # empty
418
+ why="Because.",
419
+ actions="Pay.",
420
+ deadlines="- 2026-06-15 — sista dag",
421
+ severity=4,
422
+ )
423
+ ok, reasons = e.beginner_invariant(result)
424
+ assert ok is False
425
+ assert len(reasons) >= 1
426
+
427
+
428
+ def test_beginner_invariant_severity_none_fails():
429
+ """beginner_invariant returns False when severity is None (D-08: SEVERITY line dropped)."""
430
+ result = S(
431
+ transcription="betala 1 234 kr",
432
+ tldr="Summary.",
433
+ why="Because.",
434
+ actions="Pay.",
435
+ deadlines="- 1 234 kr — belopp",
436
+ severity=None,
437
+ )
438
+ ok, reasons = e.beginner_invariant(result)
439
+ assert ok is False
440
+ assert any("severity" in r.lower() for r in reasons)
441
+
442
+
443
+ def test_beginner_invariant_empty_transcription_fails():
444
+ """beginner_invariant returns False when transcription is empty (D-08: block dropped)."""
445
+ result = S(
446
+ transcription="", # empty transcription block
447
+ tldr="Summary.",
448
+ why="Because.",
449
+ actions="Pay.",
450
+ deadlines="- 2026-06-15 — sista dag",
451
+ severity=3,
452
+ )
453
+ ok, reasons = e.beginner_invariant(result)
454
+ assert ok is False
455
+ assert any("transcription" in r.lower() for r in reasons)
456
+
457
+
458
+ def test_beginner_invariant_multiple_failures_all_reported():
459
+ """beginner_invariant reports ALL failures, not just the first."""
460
+ result = S(
461
+ transcription="", # empty
462
+ tldr="", # empty
463
+ why="Because.",
464
+ actions="Pay.",
465
+ deadlines="d",
466
+ severity=None,
467
+ )
468
+ ok, reasons = e.beginner_invariant(result)
469
+ assert ok is False
470
+ assert len(reasons) >= 2 # at least transcription + severity
471
+
472
+
473
+ if __name__ == "__main__":
474
+ import pytest
475
+ pytest.main([__file__, "-v"])
eval/test_grounded.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for eval/grounded.py — shared matching primitives + check_no_invention.
3
+
4
+ These tests use the same sys.path bootstrap as test_eval_matching.py.
5
+ No app import needed — grounded.py is stdlib-only.
6
+
7
+ Test coverage:
8
+ - check_no_invention: returns [] when all deadline values appear verbatim in transcription
9
+ - check_no_invention: returns non-empty list when a deadline value is NOT in transcription
10
+ - normalize, value_found, extract_values_from_section: return identical results to known
11
+ cases from test_eval_matching (regression against moved functions)
12
+ - import run_eval still works after refactor (eval-compat regression)
13
+ """
14
+
15
+ import os
16
+ import sys
17
+
18
+ # Ensure project root and eval/ are on the path regardless of where pytest is run from.
19
+ _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
20
+ _EVAL_DIR = os.path.join(_REPO_ROOT, "eval")
21
+ if _REPO_ROOT not in sys.path:
22
+ sys.path.insert(0, _REPO_ROOT)
23
+ if _EVAL_DIR not in sys.path:
24
+ sys.path.insert(0, _EVAL_DIR)
25
+
26
+ import grounded # direct import (eval/ is on sys.path)
27
+ from types import SimpleNamespace as S
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # check_no_invention: no invented values — returns empty list
32
+ # ---------------------------------------------------------------------------
33
+
34
+ def test_check_no_invention_returns_empty_when_all_values_in_transcription():
35
+ """Returns [] when every deadline value appears verbatim in the transcription."""
36
+ result = S(
37
+ transcription="betala 1 234 kr senast 2026-06-15",
38
+ deadlines="- 2026-06-15 — sista dag\n- 1 234 kr — belopp",
39
+ )
40
+ invented = grounded.check_no_invention(result)
41
+ assert invented == []
42
+
43
+
44
+ def test_check_no_invention_returns_nonempty_for_invented_value():
45
+ """Returns a non-empty list when a deadline value is NOT in transcription."""
46
+ result = S(
47
+ transcription="betala senast 2026-06-15", # "2026-07-99" NOT in transcription
48
+ deadlines="- 2026-07-99 — invented date\n- 2026-06-15 — real date",
49
+ )
50
+ invented = grounded.check_no_invention(result)
51
+ assert len(invented) >= 1
52
+ assert "2026-07-99" in invented
53
+
54
+
55
+ def test_check_no_invention_empty_deadlines_returns_empty():
56
+ """Empty deadlines section → no values extracted → returns []."""
57
+ result = S(
58
+ transcription="betala senast 2026-06-15",
59
+ deadlines="",
60
+ )
61
+ invented = grounded.check_no_invention(result)
62
+ assert invented == []
63
+
64
+
65
+ def test_check_no_invention_none_found_returns_empty():
66
+ """'None found.' sentinel in deadlines → no values extracted → returns []."""
67
+ result = S(
68
+ transcription="informational letter",
69
+ deadlines="None found.",
70
+ )
71
+ invented = grounded.check_no_invention(result)
72
+ assert invented == []
73
+
74
+
75
+ def test_check_no_invention_all_invented_returns_all():
76
+ """All values invented → all returned in invented list."""
77
+ result = S(
78
+ transcription="hej det här är ett informationsbrev",
79
+ deadlines="- 2099-01-01 — fake\n- 99 999 kr — fake amount",
80
+ )
81
+ invented = grounded.check_no_invention(result)
82
+ assert len(invented) == 2
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Regression: moved functions return identical results to test_eval_matching cases
87
+ # ---------------------------------------------------------------------------
88
+
89
+ def test_normalize_case_insensitive():
90
+ """normalize lowercases input (regression from test_eval_matching)."""
91
+ assert grounded.normalize("1 234,56 KR") == grounded.normalize("1 234,56 kr")
92
+
93
+
94
+ def test_normalize_collapses_multiple_spaces():
95
+ """Multiple spaces collapse to single space."""
96
+ assert grounded.normalize("a b") == "a b"
97
+
98
+
99
+ def test_value_found_long_date_in_transcription():
100
+ """An ISO date is found when present."""
101
+ haystack = "Du ska betala senast 2026-06-15. Glöm inte."
102
+ assert grounded.value_found("2026-06-15", haystack) is True
103
+
104
+
105
+ def test_value_found_short_boundary_inside_longer():
106
+ """A 3-digit ref is NOT found inside a longer run of digits."""
107
+ haystack = "ref 1234567 i systemet"
108
+ assert grounded.value_found("123", haystack) is False
109
+
110
+
111
+ def test_value_found_short_boundary_standalone():
112
+ """A 3-digit ref IS found when standing alone."""
113
+ haystack = "OCR-nummer 123 gäller"
114
+ assert grounded.value_found("123", haystack) is True
115
+
116
+
117
+ def test_extract_em_dash_separator():
118
+ """Em-dash separator: verbatim value before ' — '."""
119
+ section = "- 15 juni 2026 — last day to file\n- 1 234 kr — amount owed"
120
+ vals = grounded.extract_values_from_section(section)
121
+ assert vals == ["15 juni 2026", "1 234 kr"]
122
+
123
+
124
+ def test_extract_hyphen_with_spaces_separator():
125
+ """Hyphen-with-spaces separator."""
126
+ section = "- 2026-06-15 - sista betalningsdag\n- 1 234 kr - fakturabelopp"
127
+ vals = grounded.extract_values_from_section(section)
128
+ assert vals == ["2026-06-15", "1 234 kr"]
129
+
130
+
131
+ def test_extract_none_found_yields_empty():
132
+ """'None found.' lines yield no values."""
133
+ assert grounded.extract_values_from_section("None found.") == []
134
+
135
+
136
+ def test_extract_date_with_internal_hyphens_preserved():
137
+ """ISO date is not split by internal hyphens."""
138
+ section = "- 2026-06-15 — sista dag"
139
+ vals = grounded.extract_values_from_section(section)
140
+ assert vals == ["2026-06-15"]
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Eval-compat regression: import run_eval still works after refactor
145
+ # ---------------------------------------------------------------------------
146
+
147
+ def test_import_run_eval_succeeds():
148
+ """import run_eval still works after the grounded.py refactor."""
149
+ import run_eval # noqa: F401 — just checking importability
150
+ assert hasattr(run_eval, "normalize"), "run_eval.normalize must be re-bound"
151
+ assert hasattr(run_eval, "value_found"), "run_eval.value_found must be re-bound"
152
+ assert hasattr(run_eval, "extract_values_from_section"), (
153
+ "run_eval.extract_values_from_section must be re-bound"
154
+ )
155
+
156
+
157
+ def test_run_eval_normalize_still_accessible_via_namespace():
158
+ """import run_eval as e; e.normalize(...) still resolves after refactor."""
159
+ import run_eval as e
160
+ # Same case as test_eval_matching.py lines 44-45
161
+ assert e.normalize("A b") == "a b"
162
+
163
+
164
+ if __name__ == "__main__":
165
+ import pytest
166
+ pytest.main([__file__, "-v"])
eval/test_parse_output.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for parse_output() and build_user_prompt() in app.py.
3
+
4
+ These tests NEVER load model weights. The BUREAUCAT_NO_MODEL env var is set
5
+ before app is imported so the module-scope load_model() call is skipped.
6
+ Tests run in seconds (pure Python, no GPU required).
7
+
8
+ Test coverage:
9
+ - Well-formed output → all fields populated, severity int
10
+ - Transcription isolation from displayed sections
11
+ - Truncated output (no SEVERITY line) → severity=None, no crash
12
+ - Non-English body with fixed English headings → language-invariance (Pitfall 3)
13
+ - Empty raw string → empty fields, severity=None, no exception
14
+ - SEVERITY regex tolerates trailing whitespace/newline
15
+ - build_user_prompt() beginner vs standard: structural invariance (D-08 unit guard)
16
+ """
17
+
18
+ import os
19
+ import sys
20
+
21
+ # Set escape hatch BEFORE importing app so model weights are never downloaded.
22
+ os.environ["BUREAUCAT_NO_MODEL"] = "1"
23
+
24
+ # Ensure the project root is on the path when running from eval/
25
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
26
+
27
+ import app
28
+ from app import parse_output, build_user_prompt, StructuredResult
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Fixtures
33
+ # ---------------------------------------------------------------------------
34
+
35
+ WELL_FORMED = """\
36
+ <transcription>
37
+ Faktura 2026-06-15
38
+ Belopp: 1 234 kr
39
+ OCR: 555666777
40
+ </transcription>
41
+
42
+ Bureaucat says: This invoice wants your money, and it's not even polite about it.
43
+
44
+ ## TL;DR
45
+ You have received an invoice for 1 234 kr due on 2026-06-15.
46
+
47
+ ## Why you got this
48
+ You purchased a service and this is the bill.
49
+
50
+ ## What you need to do
51
+ - Pay the invoice by 2026-06-15
52
+ - Use OCR number 555666777
53
+
54
+ ## Deadlines & money
55
+ - 2026-06-15 - sista betalningsdag
56
+ - 1 234 kr - fakturabelopp
57
+ - 555666777 - OCR-nummer
58
+ SEVERITY: 4"""
59
+
60
+ NON_ENGLISH_BODY = """\
61
+ <transcription>
62
+ Skatteverket deklaration
63
+ </transcription>
64
+
65
+ Bureaucat says: The taxman cometh (and speaketh Swedish).
66
+
67
+ ## TL;DR
68
+ आपको अपना कर विवरण जमा करना होगा।
69
+
70
+ ## Why you got this
71
+ आप स्वीडिश करदाता हैं।
72
+
73
+ ## What you need to do
74
+ - कर फार्म भरें
75
+
76
+ ## Deadlines & money
77
+ - 15 जून 2026 - अंतिम तारीख
78
+ SEVERITY: 3"""
79
+
80
+ TRUNCATED = """\
81
+ <transcription>
82
+ Short letter
83
+ </transcription>
84
+
85
+ Bureaucat says: The suspense is killing me.
86
+
87
+ ## TL;DR
88
+ This letter got cut off before the severity was emitted.
89
+
90
+ ## Why you got this
91
+ Unknown - truncated."""
92
+
93
+ ARABIC_BODY = """\
94
+ <transcription>
95
+ مكتوب
96
+ </transcription>
97
+
98
+ Bureaucat says: Even in Arabic, deadlines are stressful.
99
+
100
+ ## TL;DR
101
+ محتوى عربي
102
+
103
+ ## Why you got this
104
+ سبب عربي
105
+
106
+ ## What you need to do
107
+ - إجراء عربي
108
+
109
+ ## Deadlines & money
110
+ - 2026-07-01 - موعد أخير
111
+ SEVERITY: 2"""
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Tests
116
+ # ---------------------------------------------------------------------------
117
+
118
+ def test_well_formed_all_fields_populated():
119
+ """Well-formed output parses into fully-populated StructuredResult."""
120
+ r = parse_output(WELL_FORMED)
121
+ assert r.transcription, "transcription should be non-empty"
122
+ assert r.quip, "quip should be non-empty"
123
+ assert r.tldr, "tldr should be non-empty"
124
+ assert r.why, "why should be non-empty"
125
+ assert r.actions, "actions should be non-empty"
126
+ assert r.deadlines, "deadlines should be non-empty"
127
+ assert r.severity == 4, f"expected severity=4, got {r.severity}"
128
+ assert r.raw == WELL_FORMED
129
+
130
+
131
+ def test_transcription_isolated_from_sections():
132
+ """
133
+ The <transcription> block is stripped from the displayed body (D-04):
134
+ - result.transcription contains the verbatim OCR text
135
+ - The XML <transcription> tags are NOT present in sections or quip
136
+ - Deadlines section can legitimately contain values that also appear in the
137
+ transcription (verbatim extraction is the goal); what we test is that the
138
+ raw transcription BLOCK itself is stripped from the displayed output
139
+ """
140
+ r = parse_output(WELL_FORMED)
141
+ # Transcription field is populated with OCR text
142
+ assert "1 234 kr" in r.transcription, "verbatim value must be in transcription field"
143
+ assert "Faktura" in r.transcription, "OCR text must be in transcription field"
144
+ # The <transcription> XML tags must not appear anywhere in the section fields
145
+ assert "<transcription>" not in r.tldr
146
+ assert "<transcription>" not in r.deadlines
147
+ assert "</transcription>" not in r.tldr
148
+ assert "</transcription>" not in r.deadlines
149
+ # The raw transcription block text (e.g., OCR-only content that would not
150
+ # naturally appear in a section) must not appear verbatim in sections
151
+ assert "OCR: 555666777" not in r.tldr, (
152
+ "raw transcription text must not bleed into tldr section"
153
+ )
154
+ assert "Belopp: 1 234 kr" not in r.tldr, (
155
+ "raw transcription text must not bleed into tldr (only extracted values belong)"
156
+ )
157
+
158
+
159
+ def test_truncated_output_severity_none_no_crash():
160
+ """Truncated output (no SEVERITY line) → severity is None, other fields still parse."""
161
+ r = parse_output(TRUNCATED)
162
+ assert r.severity is None, f"expected None for truncated output, got {r.severity}"
163
+ assert r.tldr, "tldr should still parse even without SEVERITY line"
164
+ assert r.quip, "quip should parse from truncated output"
165
+
166
+
167
+ def test_non_english_body_language_invariance():
168
+ """
169
+ Hindi body with fixed English headings → all four sections split non-empty.
170
+ (Pitfall 3: sections must anchor on English headings regardless of prose language.)
171
+ """
172
+ r = parse_output(NON_ENGLISH_BODY)
173
+ assert r.tldr, f"tldr empty — language invariance broken: {r.tldr!r}"
174
+ assert r.why, f"why empty — language invariance broken: {r.why!r}"
175
+ assert r.actions, f"actions empty — language invariance broken: {r.actions!r}"
176
+ assert r.deadlines, f"deadlines empty — language invariance broken: {r.deadlines!r}"
177
+ assert r.severity == 3
178
+
179
+
180
+ def test_arabic_body_language_invariance():
181
+ """Arabic body with fixed English headings → all four sections split non-empty."""
182
+ r = parse_output(ARABIC_BODY)
183
+ assert r.tldr, "tldr should be non-empty for Arabic body"
184
+ assert r.why, "why should be non-empty for Arabic body"
185
+ assert r.actions, "actions should be non-empty for Arabic body"
186
+ assert r.deadlines, "deadlines should be non-empty for Arabic body"
187
+ assert r.severity == 2
188
+
189
+
190
+ def test_empty_raw_no_exception():
191
+ """Empty raw string → StructuredResult with empty fields and severity=None, no crash."""
192
+ r = parse_output("")
193
+ assert r.severity is None
194
+ assert r.transcription == ""
195
+ assert r.quip == ""
196
+ assert r.tldr == ""
197
+ assert r.why == ""
198
+ assert r.actions == ""
199
+ assert r.deadlines == ""
200
+ assert r.raw == ""
201
+
202
+
203
+ def test_severity_regex_tolerates_trailing_whitespace():
204
+ """SEVERITY regex matches with trailing whitespace/newline and only accepts 1-5."""
205
+ for sev in range(1, 6):
206
+ raw = f"<transcription>x</transcription>\nBureaucat says: hi\n## TL;DR\na\n## Why you got this\nb\n## What you need to do\nc\n## Deadlines & money\nd\nSEVERITY: {sev} \n"
207
+ r = parse_output(raw)
208
+ assert r.severity == sev, f"Expected {sev}, got {r.severity}"
209
+
210
+ # Value 0 and 6 should not match
211
+ for bad in ("0", "6", "10"):
212
+ raw = f"<transcription>x</transcription>\nBureaucat says: hi\n## TL;DR\na\n## Why you got this\nb\n## What you need to do\nc\n## Deadlines & money\nd\nSEVERITY: {bad}\n"
213
+ r = parse_output(raw)
214
+ assert r.severity is None, f"Severity {bad} should not parse to int, got {r.severity}"
215
+
216
+
217
+ def test_build_user_prompt_beginner_mode_invariance():
218
+ """
219
+ D-08 unit guard: beginner and standard prompts differ only by appended
220
+ inline-explanation guidance. Neither adds/removes sections, alters the
221
+ SEVERITY line reference, or touches the transcription block.
222
+ """
223
+ standard = build_user_prompt("English", beginner_mode=False)
224
+ beginner = build_user_prompt("English", beginner_mode=True)
225
+
226
+ # Beginner must be strictly longer (has additional guidance appended)
227
+ assert len(beginner) > len(standard), "beginner prompt must be longer than standard"
228
+
229
+ # Standard must be a prefix of beginner (beginner only appends, never replaces)
230
+ assert beginner.startswith(standard), (
231
+ "beginner prompt must start with the full standard prompt"
232
+ )
233
+
234
+ # The extra beginner content must not mention adding/removing sections
235
+ extra = beginner[len(standard):]
236
+ assert "new section" not in extra.lower() or "do not add new sections" in extra.lower(), (
237
+ "beginner extra guidance must not instruct adding new sections"
238
+ )
239
+
240
+ # Neither prompt should reference SEVERITY or transcription in a way that
241
+ # would alter those structural elements (those are in SYSTEM_PROMPT only)
242
+ assert "SEVERITY" not in standard
243
+ assert "SEVERITY" not in beginner
244
+ assert "<transcription>" not in standard
245
+ assert "<transcription>" not in beginner
246
+
247
+
248
+ def test_build_user_prompt_language_interpolation():
249
+ """Language is correctly embedded in the prompt for different languages."""
250
+ for lang in ("English", "Hindi", "Arabic", "Spanish", "Swedish"):
251
+ p = build_user_prompt(lang, beginner_mode=False)
252
+ assert lang in p, f"Language '{lang}' must appear in prompt"
253
+
254
+
255
+ def test_structured_result_is_dataclass():
256
+ """StructuredResult can be instantiated directly and has all required fields."""
257
+ r = StructuredResult(
258
+ transcription="t", quip="q", tldr="tl", why="w",
259
+ actions="a", deadlines="d", severity=3, raw="raw"
260
+ )
261
+ assert r.severity == 3
262
+ assert r.transcription == "t"
263
+
264
+
265
+ def test_model_is_none_under_no_model_flag():
266
+ """BUREAUCAT_NO_MODEL=1 → app.model is None (set in os.environ before import)."""
267
+ assert app.model is None, "model should be None when BUREAUCAT_NO_MODEL is set"
268
+ assert app.processor is None, "processor should be None when BUREAUCAT_NO_MODEL is set"
269
+
270
+
271
+ if __name__ == "__main__":
272
+ import pytest
273
+ pytest.main([__file__, "-v"])
frontend/app.js ADDED
@@ -0,0 +1,788 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Bureaucat custom frontend (Phase 4, Off-Brand badge).
2
+ *
3
+ * ZeroGPU contract: ALL API calls go through @gradio/client — it forwards the
4
+ * HF iframe auth headers (X-IP-Token) that ZeroGPU quota attribution needs.
5
+ * A raw fetch() here would silently burn the anonymous quota tier. */
6
+
7
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
8
+
9
+ const $ = (id) => document.getElementById(id);
10
+
11
+ const MASCOT = (state) => `/assets/mascot/${state}.png`;
12
+
13
+ // MUST mirror EXAMPLE_LETTERS in app.py IN ORDER — the gallery sends the array
14
+ // index to /example, which indexes app.py's list. If the orders drift, clicking a
15
+ // thumbnail shows a different letter's result.
16
+ const EXAMPLES = [
17
+ { slug: "skatteverket-slutskattebesked", label: "Skatteverket — tax refund (severity 1)" },
18
+ { slug: "vardcentral-kallelse", label: "Vårdcentral — appointment (severity 2)" },
19
+ { slug: "forsakringskassan-komplettering", label: "Försäkringskassan — submit documents (severity 3)" },
20
+ { slug: "csn-aterkrav", label: "CSN — repayment demand (severity 4)" },
21
+ { slug: "migrationsverket-uppehallstillstand", label: "Migrationsverket — permit at risk (severity 5)" },
22
+ ];
23
+
24
+ let client = null;
25
+ let pickedFiles = [];
26
+ let busy = false;
27
+ let pendingSource = null; // { name, thumbUrl } for the letter currently being read
28
+ let lastThumbUrl = null; // object URL to revoke when it's replaced
29
+ let lastPayload = null; // last rendered verdict (for the shareable card)
30
+ let _stepTimer = null; // rotating reading-step interval
31
+
32
+ /* ---------- tiny safe renderers ---------- */
33
+
34
+ const escapeHtml = (s) =>
35
+ String(s).replace(/[&<>"']/g, (c) =>
36
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
37
+
38
+ /* Escape first, then allow exactly **bold** and *italic*; paragraphs on blank lines. */
39
+ function mdLite(text) {
40
+ const esc = escapeHtml(text.trim());
41
+ const inline = esc
42
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
43
+ .replace(/\*([^*]+)\*/g, "<em>$1</em>");
44
+ return inline
45
+ .split(/\n{2,}/)
46
+ .map((p) => `<p>${p.replace(/\n/g, "<br>")}</p>`)
47
+ .join("");
48
+ }
49
+
50
+ function setMascot(state, statusText) {
51
+ const m = $("mascot");
52
+ m.src = MASCOT(state);
53
+ m.alt = `Bureaucat is ${state.replace("_", " ")}`;
54
+ m.className = `mascot mascot--${state}`;
55
+ if (statusText !== undefined) $("status-line").textContent = statusText;
56
+ }
57
+
58
+ // ---- Delightful loading: rotating step messages + skeleton shimmer ----
59
+ const READING_STEPS = [
60
+ "🤓 Putting on my reading glasses…",
61
+ "🔍 Decoding the bureaucratese…",
62
+ "📅 Hunting for every deadline…",
63
+ "🧮 Checking every number twice…",
64
+ "🐾 Forming my expert opinion…",
65
+ ];
66
+ function startReadingSteps() {
67
+ setMascot("reading", READING_STEPS[0]);
68
+ $("skeleton").hidden = false;
69
+ let i = 0;
70
+ clearInterval(_stepTimer);
71
+ _stepTimer = setInterval(() => {
72
+ i = (i + 1) % READING_STEPS.length;
73
+ $("status-line").textContent = READING_STEPS[i];
74
+ }, 2200);
75
+ }
76
+ function stopReadingSteps() {
77
+ clearInterval(_stepTimer);
78
+ _stepTimer = null;
79
+ $("skeleton").hidden = true;
80
+ }
81
+
82
+ // ---- Panic-o-meter: a cartoon gauge dial with a wobbling needle ----
83
+ const FACES = { 1: "😌", 2: "🙂", 3: "😬", 4: "😰", 5: "😱" };
84
+ const SEG_COLORS = ["#27AE60", "#8BC34A", "#F39C12", "#E67E22", "#C0392B"];
85
+
86
+ function polar(cx, cy, r, deg) {
87
+ const a = (deg * Math.PI) / 180;
88
+ return [cx + r * Math.cos(a), cy - r * Math.sin(a)];
89
+ }
90
+ // Sample the true circle as a polyline (no SVG arc-flag ambiguity).
91
+ function arcPoints(cx, cy, r, t1, t2) {
92
+ const pts = [];
93
+ const step = t1 > t2 ? -3 : 3;
94
+ for (let t = t1; step < 0 ? t > t2 : t < t2; t += step) {
95
+ const [x, y] = polar(cx, cy, r, t);
96
+ pts.push(`${x.toFixed(1)},${y.toFixed(1)}`);
97
+ }
98
+ const [xe, ye] = polar(cx, cy, r, t2);
99
+ pts.push(`${xe.toFixed(1)},${ye.toFixed(1)}`);
100
+ return pts.join(" ");
101
+ }
102
+
103
+ function buildGauge(severity, word, color) {
104
+ // severity may be null/undefined when a dense letter read fine but lost its
105
+ // SEVERITY line — render an "unclear" dial (greyed band, no needle/marker).
106
+ const known = severity != null && severity >= 1 && severity <= 5;
107
+ const s = known ? severity : 0;
108
+ const CX = 100, CY = 105, R = 78;
109
+ // Equal-thickness zones forming one continuous curved band (no "active bar" —
110
+ // a thickened top zone read as a flat horizontal line). The needle + a marker
111
+ // dot on the band indicate the level instead.
112
+ let segs = "";
113
+ for (let k = 1; k <= 5; k++) {
114
+ const tc = 180 - (k - 0.5) * 36; // segment centre angle (1=left … 5=right)
115
+ const pts = arcPoints(CX, CY, R, tc + 16, tc - 16);
116
+ const active = known && k === s;
117
+ segs += `<polyline points="${pts}" fill="none" stroke="${SEG_COLORS[k - 1]}" ` +
118
+ `stroke-width="13" stroke-linecap="round" opacity="${active ? 1 : known ? 0.5 : 0.4}"/>`;
119
+ const [nx, ny] = polar(CX, CY, R + 17, tc);
120
+ segs += `<text x="${nx.toFixed(1)}" y="${(ny + 3.5).toFixed(1)}" text-anchor="middle" ` +
121
+ `font-size="11" font-weight="700" ${active ? `fill="${SEG_COLORS[k - 1]}"` : 'class="gauge-tick"'} ` +
122
+ `opacity="${active ? 1 : 0.6}">${k}</text>`;
123
+ }
124
+ let marker = "", needle = "";
125
+ if (known) {
126
+ // Glowing marker dot sitting on the band at the active zone — a dot, not a bar.
127
+ const [mx, my] = polar(CX, CY, R, 180 - (s - 0.5) * 36);
128
+ marker = `<circle cx="${mx.toFixed(1)}" cy="${my.toFixed(1)}" r="9" ` +
129
+ `fill="${color}" filter="url(#glow)"/><circle cx="${mx.toFixed(1)}" cy="${my.toFixed(1)}" r="3.5" fill="#fff"/>`;
130
+ const phi = (s - 3) * 36; // needle rotation; 0 = straight up (level 3)
131
+ // Static transform = correct end state even if SMIL is unavailable; SMIL adds the wobble.
132
+ needle = `<polygon class="gauge-needle" points="100,38 105.5,105 94.5,105" ` +
133
+ `transform="rotate(${phi} 100 105)">` +
134
+ `<animateTransform attributeName="transform" type="rotate" ` +
135
+ `values="0 100 105; ${(phi * 1.18).toFixed(1)} 100 105; ${(phi * 0.9).toFixed(1)} 100 105; ${phi} 100 105" ` +
136
+ `keyTimes="0;0.6;0.82;1" dur="0.8s" fill="freeze"/></polygon>`;
137
+ } else {
138
+ // unclear: a "?" where the needle would be, no hub-needle
139
+ needle = `<text x="100" y="84" text-anchor="middle" font-size="42" font-weight="700" class="gauge-tick">?</text>`;
140
+ }
141
+ const face = known ? FACES[s] : "🤔";
142
+ const num = known ? `${s}<span style="font-size:.6em">/5</span>` : `?<span style="font-size:.6em">/5</span>`;
143
+ return `<div class="panic-gauge" role="img" aria-label="Panic level ${known ? s + " of 5" : "unclear"} — ${escapeHtml(word)}">` +
144
+ `<div class="panic-gauge__title">🐾 Panic-o-meter</div>` +
145
+ `<svg class="panic-gauge__svg" viewBox="0 0 200 126">` +
146
+ `<defs><filter id="glow" x="-40%" y="-40%" width="180%" height="180%">` +
147
+ `<feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${color}" flood-opacity="0.75"/></filter></defs>` +
148
+ segs + marker + needle +
149
+ (known ? `<circle cx="100" cy="105" r="9" class="gauge-needle"/>` +
150
+ `<circle cx="100" cy="105" r="3.5" fill="#fff" opacity="0.85"/>` : "") +
151
+ `</svg>` +
152
+ `<div class="panic-gauge__readout">` +
153
+ `<span class="panic-gauge__face">${face}</span>` +
154
+ `<span class="panic-gauge__num" style="color:${color}">${num}</span>` +
155
+ `<span class="panic-gauge__word" style="color:${color}">${escapeHtml(word)}</span>` +
156
+ `</div></div>`;
157
+ }
158
+
159
+ function setPanic(payload) {
160
+ const panic = $("panic");
161
+ const stage = $("stage");
162
+ if (!payload) {
163
+ panic.innerHTML =
164
+ '<div class="panic__placeholder">🐾 Feed me a letter and I\'ll tell you how worried to be</div>';
165
+ stage.style.removeProperty("--sev");
166
+ stage.classList.remove("stage--alarm");
167
+ return;
168
+ }
169
+ const label = payload.severity_label || "Panic level unclear";
170
+ const word = label.includes("—") ? label.split("—")[1].trim() : label;
171
+ const color = payload.severity_color || "#9E9E9E";
172
+ panic.innerHTML = buildGauge(payload.severity, word, color);
173
+ // Severity colour-wash over the stage + a red alarm aura at the high end.
174
+ stage.style.setProperty("--sev", color);
175
+ stage.classList.toggle("stage--alarm", (payload.severity || 0) >= 4);
176
+ rollPanicNumber(payload.severity);
177
+ }
178
+
179
+ // Roll the panic number 1→N for a little gauge-spinning drama (skipped on reduced motion;
180
+ // buildGauge already shows the final number, so nothing is lost).
181
+ function rollPanicNumber(sev) {
182
+ if (sev == null || REDUCE_MOTION) return;
183
+ const el = document.querySelector(".panic-gauge__num");
184
+ if (!el) return;
185
+ const suffix = '<span style="font-size:.6em">/5</span>';
186
+ let cur = 1;
187
+ el.innerHTML = `1${suffix}`;
188
+ const iv = setInterval(() => {
189
+ cur += 1;
190
+ if (cur > sev) { clearInterval(iv); return; }
191
+ el.innerHTML = `${cur}${suffix}`;
192
+ el.classList.remove("num-pop"); void el.offsetWidth; el.classList.add("num-pop");
193
+ }, 150);
194
+ }
195
+
196
+ function setQuip(quip) {
197
+ const q = $("quip");
198
+ if (quip && quip.trim()) {
199
+ q.innerHTML = `<strong>Bureaucat says:</strong> ${escapeHtml(quip.trim())}`;
200
+ q.hidden = false;
201
+ } else {
202
+ q.hidden = true;
203
+ }
204
+ }
205
+
206
+ function renderChecklist(actions) {
207
+ const box = $("actions");
208
+ box.innerHTML = "";
209
+ const lines = actions.split("\n").map((l) => l.trim()).filter(Boolean);
210
+ for (const line of lines) {
211
+ const m = line.match(/^(?:[-*•]|\d+[.)])\s+(.*)$/);
212
+ if (m) {
213
+ const label = document.createElement("label");
214
+ label.className = "check-item";
215
+ const cb = document.createElement("input");
216
+ cb.type = "checkbox";
217
+ cb.addEventListener("change", () => label.classList.toggle("done", cb.checked));
218
+ const span = document.createElement("span");
219
+ span.innerHTML = mdLite(m[1]);
220
+ label.append(cb, span);
221
+ box.appendChild(label);
222
+ } else {
223
+ const p = document.createElement("p");
224
+ p.className = "prose";
225
+ p.innerHTML = mdLite(line);
226
+ box.appendChild(p);
227
+ }
228
+ }
229
+ if (!box.children.length) box.innerHTML = '<p class="prose">Nothing to do. Enjoy your fika. ☕</p>';
230
+ }
231
+
232
+ // ---- Deadline countdowns: turn a date string into "in 12 days" / "overdue" ----
233
+ const SV_MONTHS = {
234
+ januari: 0, februari: 1, mars: 2, april: 3, maj: 4, juni: 5,
235
+ juli: 6, augusti: 7, september: 8, oktober: 9, november: 10, december: 11,
236
+ };
237
+ function parseLetterDate(s) {
238
+ if (!s) return null;
239
+ let m = s.match(/(\d{4})-(\d{2})-(\d{2})/); // ISO 2026-06-22
240
+ if (m) return new Date(+m[1], +m[2] - 1, +m[3]);
241
+ m = s.toLowerCase().match( // "den 5 maj 2026"
242
+ /(\d{1,2})\s+(januari|februari|mars|april|maj|juni|juli|augusti|september|oktober|november|december)\s+(\d{4})/);
243
+ if (m) return new Date(+m[3], SV_MONTHS[m[2]], +m[1]);
244
+ return null;
245
+ }
246
+ function countdownBadge(value) {
247
+ const date = parseLetterDate(value);
248
+ if (!date) return null;
249
+ const today = new Date(); today.setHours(0, 0, 0, 0);
250
+ const n = Math.round((date - today) / 86400000);
251
+ if (n < 0) return { text: `overdue by ${-n} day${-n === 1 ? "" : "s"}`, cls: "overdue" };
252
+ if (n === 0) return { text: "today", cls: "soon" };
253
+ if (n === 1) return { text: "tomorrow", cls: "soon" };
254
+ if (n <= 7) return { text: `in ${n} days`, cls: "soon" };
255
+ if (n <= 30) return { text: `in ${n} days`, cls: "near" };
256
+ return { text: `in ${Math.round(n / 30)} month${Math.round(n / 30) === 1 ? "" : "s"}`, cls: "far" };
257
+ }
258
+
259
+ function renderDeadlines(payload) {
260
+ const ul = $("deadlines");
261
+ ul.innerHTML = "";
262
+ const banner = $("deadline-banner");
263
+ banner.hidden = true;
264
+
265
+ if (!payload.deadline_items.length) {
266
+ ul.innerHTML = '<li class="none">None found.</li>';
267
+ return;
268
+ }
269
+ let firstDated = null;
270
+ for (const item of payload.deadline_items) {
271
+ const cd = countdownBadge(item.value);
272
+ if (cd && !firstDated) firstDated = { item, cd };
273
+ const li = document.createElement("li");
274
+ li.innerHTML =
275
+ `<mark>${escapeHtml(item.value)}</mark>` +
276
+ (item.note ? ` — ${escapeHtml(item.note)}` : "") +
277
+ (cd ? ` <span class="cd cd--${cd.cls}">${cd.text}</span>` : "");
278
+ ul.appendChild(li);
279
+ }
280
+ // Banner highlights the soonest dated deadline (not just the first listed value).
281
+ const lead = firstDated || { item: payload.deadline_items[0], cd: null };
282
+ banner.innerHTML =
283
+ `⏰ Mark this: <span class="value">${escapeHtml(lead.item.value)}</span>` +
284
+ (lead.item.note ? ` — ${escapeHtml(lead.item.note)}` : "") +
285
+ (lead.cd ? ` <span class="cd cd--${lead.cd.cls}">${lead.cd.text}</span>` : "");
286
+ banner.hidden = false;
287
+ }
288
+
289
+ function renderGrounding(payload) {
290
+ const g = $("grounding");
291
+ if (payload.grounded) {
292
+ g.className = "grounding ok";
293
+ g.textContent = "✓ Verified: every value above appears verbatim in the letter — nothing invented.";
294
+ } else {
295
+ g.className = "grounding fail";
296
+ g.textContent =
297
+ "⚠ Verification failed for: " + payload.invented_values.join(", ") +
298
+ " — these were NOT found in the letter. Double-check against the original.";
299
+ }
300
+ g.hidden = false;
301
+ }
302
+
303
+ /* ---------- celebration: reactive visuals + synth sound ----------
304
+ * Sound is synthesized in-browser via Web Audio (no audio files, no CDN) so it
305
+ * stays fully "Off the Grid". A mute toggle persists in localStorage. Visuals
306
+ * respect prefers-reduced-motion. */
307
+
308
+ const REDUCE_MOTION = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
309
+ const BCAT_COLORS = ["#E91E63", "#84CC16", "#FFD166", "#FFFFFF", "#FF8A3D"];
310
+ let muted = localStorage.getItem("bcat-muted") === "1";
311
+ let _actx = null;
312
+
313
+ function audioCtx() {
314
+ if (!_actx) {
315
+ try { _actx = new (window.AudioContext || window.webkitAudioContext)(); }
316
+ catch { return null; }
317
+ }
318
+ if (_actx.state === "suspended") _actx.resume(); // must be primed by a user gesture
319
+ return _actx;
320
+ }
321
+
322
+ // One enveloped oscillator note. start/dur in seconds, relative to now.
323
+ function tone(freq, start, dur, type = "sine", peak = 0.16) {
324
+ const ac = audioCtx(); if (!ac) return;
325
+ const t0 = ac.currentTime + start;
326
+ const osc = ac.createOscillator(), g = ac.createGain();
327
+ osc.type = type; osc.frequency.value = freq;
328
+ osc.connect(g); g.connect(ac.destination);
329
+ g.gain.setValueAtTime(0.0001, t0);
330
+ g.gain.exponentialRampToValueAtTime(peak, t0 + 0.02);
331
+ g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
332
+ osc.start(t0); osc.stop(t0 + dur + 0.03);
333
+ }
334
+ const chimeGood = () => { if (!muted) [523.25, 659.25, 783.99, 1046.5].forEach((f, i) => tone(f, i * 0.09, 0.4, "triangle", 0.15)); };
335
+ const chimeAlert = () => { if (!muted) { tone(311.13, 0, 0.26, "sawtooth", 0.1); tone(233.08, 0.22, 0.45, "sawtooth", 0.1); } };
336
+ const chimePop = () => { if (!muted) { tone(880, 0, 0.12, "sine", 0.12); tone(1318.5, 0.08, 0.16, "sine", 0.1); } };
337
+ // Calm "noted" cue for action-needed-but-manageable — neutral, NOT celebratory.
338
+ const noteTone = () => { if (!muted) { tone(587.33, 0, 0.16, "sine", 0.12); tone(440, 0.14, 0.26, "sine", 0.11); } };
339
+
340
+ function fireworks() {
341
+ if (!window.confetti || REDUCE_MOTION) return;
342
+ // dual side-cannons streaming for ~1.3s …
343
+ const end = Date.now() + 1300;
344
+ (function frame() {
345
+ window.confetti({ particleCount: 7, angle: 60, spread: 72, startVelocity: 55, origin: { x: 0, y: 0.7 }, colors: BCAT_COLORS });
346
+ window.confetti({ particleCount: 7, angle: 120, spread: 72, startVelocity: 55, origin: { x: 1, y: 0.7 }, colors: BCAT_COLORS });
347
+ if (Date.now() < end) requestAnimationFrame(frame);
348
+ })();
349
+ // … plus staggered center bursts for a real "show"
350
+ window.confetti({ particleCount: 150, spread: 100, origin: { y: 0.62 }, colors: BCAT_COLORS, scalar: 1.1 });
351
+ setTimeout(() => window.confetti({ particleCount: 130, spread: 130, startVelocity: 45, origin: { y: 0.55 }, colors: BCAT_COLORS }), 300);
352
+ setTimeout(() => window.confetti({ particleCount: 90, spread: 160, decay: 0.92, scalar: 1.3, origin: { y: 0.5 }, colors: BCAT_COLORS }), 650);
353
+ }
354
+
355
+ // Action-needed cue: glow the deadline banner to pull the eye to the date/amount.
356
+ // Localized and non-celebratory — the right signal when there's something to do.
357
+ function pulseDeadlineBanner() {
358
+ if (REDUCE_MOTION) return;
359
+ const b = $("deadline-banner");
360
+ if (!b || b.hidden) return;
361
+ b.classList.remove("pulse");
362
+ void b.offsetWidth; // force reflow so the animation restarts on repeat
363
+ b.classList.add("pulse");
364
+ b.addEventListener("animationend", () => b.classList.remove("pulse"), { once: true });
365
+ }
366
+
367
+ function screenFlash(kind) {
368
+ if (REDUCE_MOTION) return;
369
+ const el = document.createElement("div");
370
+ el.className = "screen-flash screen-flash--" + kind;
371
+ document.body.appendChild(el);
372
+ el.addEventListener("animationend", () => el.remove(), { once: true });
373
+ }
374
+
375
+ // Verdict-reactive celebration: good news → fireworks; severe → red alert pulse; else → puff.
376
+ function reactToResult(payload) {
377
+ if (payload.kind !== "letter") return;
378
+ const sev = payload.severity || 0;
379
+ if (payload.mascot === "allclear") {
380
+ // genuinely good news — nothing to pay, no deadline. Celebrate.
381
+ fireworks(); screenFlash("good"); chimeGood();
382
+ } else if (sev >= 4) {
383
+ // urgent — act now
384
+ screenFlash("alert"); chimeAlert();
385
+ } else {
386
+ // action needed but manageable — draw the eye to the deadline, do NOT celebrate
387
+ pulseDeadlineBanner(); noteTone();
388
+ }
389
+ }
390
+
391
+ function setMuted(v) {
392
+ muted = v;
393
+ localStorage.setItem("bcat-muted", v ? "1" : "0");
394
+ const b = $("sound-toggle");
395
+ if (b) { b.textContent = v ? "🔇" : "🔊"; b.title = v ? "Sound off — click to enable" : "Sound on — click to mute"; }
396
+ }
397
+
398
+ /* ---------- shareable verdict card ---------- */
399
+
400
+ function _roundRect(ctx, x, y, w, h, r) {
401
+ ctx.beginPath();
402
+ if (ctx.roundRect) { ctx.roundRect(x, y, w, h, r); return; }
403
+ ctx.moveTo(x + r, y);
404
+ ctx.arcTo(x + w, y, x + w, y + h, r);
405
+ ctx.arcTo(x + w, y + h, x, y + h, r);
406
+ ctx.arcTo(x, y + h, x, y, r);
407
+ ctx.arcTo(x, y, x + w, y, r);
408
+ ctx.closePath();
409
+ }
410
+ function _wrapText(ctx, text, maxWidth) {
411
+ const words = text.split(/\s+/), lines = [];
412
+ let line = "";
413
+ for (const w of words) {
414
+ const test = line ? `${line} ${w}` : w;
415
+ if (ctx.measureText(test).width > maxWidth && line) { lines.push(line); line = w; }
416
+ else line = test;
417
+ }
418
+ if (line) lines.push(line);
419
+ return lines;
420
+ }
421
+ function _loadImage(src) {
422
+ return new Promise((res, rej) => {
423
+ const img = new Image();
424
+ img.onload = () => res(img); img.onerror = rej; img.src = src;
425
+ });
426
+ }
427
+
428
+ // Draw a 1080×1080 social card of the verdict and share/download it. Pure canvas —
429
+ // no external lib, stays "Off the Grid". Doubles as the hackathon social-post asset.
430
+ async function generateShareCard() {
431
+ const p = lastPayload;
432
+ if (!p || p.kind !== "letter") return;
433
+ if (!muted) chimePop();
434
+ const W = 1080, H = 1080;
435
+ const c = document.createElement("canvas");
436
+ c.width = W; c.height = H;
437
+ const ctx = c.getContext("2d");
438
+ const FONT = "Fredoka, system-ui, sans-serif";
439
+ ctx.textAlign = "center";
440
+
441
+ const bg = ctx.createLinearGradient(0, 0, W, H);
442
+ bg.addColorStop(0, "#FFE3F1"); bg.addColorStop(1, "#FFF6D6");
443
+ ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
444
+ ctx.fillStyle = "#ffffff";
445
+ _roundRect(ctx, 56, 56, W - 112, H - 112, 48); ctx.fill();
446
+
447
+ ctx.fillStyle = "#E91E63";
448
+ ctx.font = `700 62px ${FONT}`;
449
+ ctx.fillText("🐱 Bureaucat", W / 2, 168);
450
+ ctx.fillStyle = "#7a7a7a";
451
+ ctx.font = `500 28px ${FONT}`;
452
+ ctx.fillText("read my scary Swedish letter so I didn't have to", W / 2, 214);
453
+
454
+ try {
455
+ const cat = await _loadImage(MASCOT(p.mascot || "idle"));
456
+ const cw = 300, ch = cat.height * (cw / cat.width);
457
+ ctx.drawImage(cat, W / 2 - cw / 2, 250, cw, ch);
458
+ } catch { /* mascot optional */ }
459
+
460
+ const sev = p.severity;
461
+ const color = p.severity_color || "#9E9E9E";
462
+ const face = FACES[sev] || "🤔";
463
+ ctx.fillStyle = color;
464
+ ctx.font = `700 150px ${FONT}`;
465
+ ctx.fillText(`${face} ${sev != null ? sev : "?"}/5`, W / 2, 690);
466
+ const label = p.severity_label || "Panic level unclear";
467
+ ctx.font = `600 46px ${FONT}`;
468
+ ctx.fillText(label.includes("—") ? label.split("—")[1].trim() : label, W / 2, 752);
469
+
470
+ ctx.fillStyle = "#2b2b2b";
471
+ ctx.font = `400 33px ${FONT}`;
472
+ let tldr = (p.tldr || "").replace(/\*\*/g, "").replace(/\s+/g, " ").trim();
473
+ if (tldr.length > 230) tldr = tldr.slice(0, 227) + "…";
474
+ _wrapText(ctx, tldr, W - 220).slice(0, 4).forEach((ln, i) =>
475
+ ctx.fillText(ln, W / 2, 840 + i * 46));
476
+
477
+ ctx.fillStyle = "#9b9b9b";
478
+ ctx.font = `500 25px ${FONT}`;
479
+ ctx.fillText("runs on one small model, inside the Space — no cloud", W / 2, H - 88);
480
+
481
+ c.toBlob(async (blob) => {
482
+ if (!blob) return;
483
+ const file = new File([blob], "bureaucat-panic.png", { type: "image/png" });
484
+ if (navigator.canShare && navigator.canShare({ files: [file] })) {
485
+ try { await navigator.share({ files: [file], title: "My Bureaucat panic level" }); return; }
486
+ catch { /* fall through to download */ }
487
+ }
488
+ const url = URL.createObjectURL(blob);
489
+ const a = document.createElement("a");
490
+ a.href = url; a.download = "bureaucat-panic.png";
491
+ document.body.appendChild(a); a.click(); a.remove();
492
+ setTimeout(() => URL.revokeObjectURL(url), 4000);
493
+ }, "image/png");
494
+ }
495
+
496
+ /* ---------- payload → page ---------- */
497
+
498
+ function showResult(payload, statusText) {
499
+ stopReadingSteps();
500
+ lastPayload = payload;
501
+ $("results").hidden = true;
502
+ $("refusal").hidden = true;
503
+ $("share-row").hidden = true;
504
+
505
+ if (payload.kind === "letter") {
506
+ setPanic(payload);
507
+ setQuip(payload.quip);
508
+ setMascot(payload.mascot, statusText);
509
+ $("tldr").innerHTML = mdLite(payload.tldr);
510
+ $("why").innerHTML = mdLite(payload.why);
511
+ renderChecklist(payload.actions);
512
+ renderDeadlines(payload);
513
+ renderGrounding(payload);
514
+ $("results").hidden = false;
515
+ $("share-row").hidden = false; // letters have a panic level worth sharing
516
+ setTimeout(() => reactToResult(payload), 220);
517
+ } else if (payload.kind === "refusal") {
518
+ setPanic(null);
519
+ setQuip(payload.quip);
520
+ setMascot(payload.mascot, "That didn't look right.");
521
+ $("refusal-title").textContent =
522
+ payload.doctype === "unreadable" ? "🙀 I can't read that…" : "🤨 That's not a Swedish letter…";
523
+ $("refusal-guidance").textContent = payload.guidance;
524
+ $("refusal").hidden = false;
525
+ $("deadline-banner").hidden = true;
526
+ } else {
527
+ setPanic(null);
528
+ setQuip("");
529
+ setMascot("idle", "Something went wrong — try again.");
530
+ $("refusal-title").textContent = "😿 Hmm, that didn't work";
531
+ $("refusal-guidance").textContent = payload.guidance;
532
+ $("refusal").hidden = false;
533
+ $("deadline-banner").hidden = true;
534
+ }
535
+ if (pendingSource) setReadSource(pendingSource.name, pendingSource.thumbUrl);
536
+ armForNext(); // verdict stays; upload area re-arms (selection cleared, CTA off)
537
+ }
538
+
539
+ /* ---------- API ---------- */
540
+
541
+ async function getClient() {
542
+ if (!client) client = await Client.connect(window.location.origin);
543
+ return client;
544
+ }
545
+
546
+ async function analyze() {
547
+ if (busy || !pickedFiles.length) return;
548
+ audioCtx(); // prime audio within this click gesture so the result sound can play later
549
+ busy = true;
550
+ $("analyze-btn").disabled = true;
551
+ $("results").hidden = true;
552
+ $("refusal").hidden = true;
553
+ $("deadline-banner").hidden = true;
554
+ clearReadSource(); // hide any stale "verdict for" while this one reads
555
+ $("share-row").hidden = true;
556
+ setPanic(null);
557
+ setQuip("");
558
+ startReadingSteps(); // rotating step messages + skeleton shimmer
559
+
560
+ // Remember which letter this verdict is for (pickedFiles is cleared once it renders).
561
+ // Thumbnail only for a single image; PDFs/multi just show the name.
562
+ const files = pickedFiles.slice();
563
+ const first = files[0];
564
+ let thumbUrl = null;
565
+ if (files.length === 1 && /\.(jpe?g|png)$/i.test(first.name)) {
566
+ thumbUrl = URL.createObjectURL(first);
567
+ lastThumbUrl = thumbUrl;
568
+ }
569
+ pendingSource = {
570
+ name: files.length > 1 ? `${first.name} +${files.length - 1} more` : first.name,
571
+ thumbUrl,
572
+ };
573
+
574
+ try {
575
+ const c = await getClient();
576
+ // Race the prediction against a timeout so a dropped/hung connection can never
577
+ // leave the UI stuck `busy` (which would silently dead-click "Read it for me").
578
+ let timer;
579
+ const timeout = new Promise((_, rej) => {
580
+ timer = setTimeout(() => rej(new Error("timeout")), 180000);
581
+ });
582
+ let res;
583
+ try {
584
+ res = await Promise.race([
585
+ c.predict("/analyze", {
586
+ files: pickedFiles.map((f) => handle_file(f)),
587
+ beginner: $("beginner").checked,
588
+ }),
589
+ timeout,
590
+ ]);
591
+ } finally {
592
+ clearTimeout(timer);
593
+ }
594
+ const payload = res.data[0];
595
+ // Brief "verifying" beat so the grounding check is a visible moment (D2-05).
596
+ setMascot("verifying", "Double-checking every number against the letter…");
597
+ await new Promise((r) => setTimeout(r, 700));
598
+ showResult(payload, "Verdict delivered.");
599
+ } catch (err) {
600
+ console.error(err);
601
+ client = null; // drop the cached client so the next attempt reconnects fresh
602
+ showResult({
603
+ kind: "error",
604
+ guidance: "The Space hiccuped (queue or quota). Wait a moment and try again — or tap an example below (zero GPU).",
605
+ });
606
+ } finally {
607
+ busy = false;
608
+ $("analyze-btn").disabled = pickedFiles.length === 0;
609
+ }
610
+ }
611
+
612
+ async function loadExample(index) {
613
+ if (busy) return;
614
+ audioCtx(); // prime audio within this click gesture
615
+ busy = true;
616
+ $("refusal").hidden = true;
617
+ clearReadSource();
618
+ setMascot("reading", "Fetching a pre-read letter — zero GPU…");
619
+ const ex = EXAMPLES[index];
620
+ pendingSource = ex
621
+ ? { name: ex.label, thumbUrl: `/letters/${ex.slug}.png` }
622
+ : null;
623
+ try {
624
+ const c = await getClient();
625
+ const res = await c.predict("/example", { index });
626
+ showResult(res.data[0], "Pre-computed example — cost you nothing.");
627
+ } catch (err) {
628
+ console.error(err);
629
+ setMascot("idle", "Couldn't load that example. Try again.");
630
+ } finally {
631
+ busy = false;
632
+ }
633
+ }
634
+
635
+ /* ---------- wiring ---------- */
636
+
637
+ /* ---------- UI state model ----------
638
+ * The upload area and the CTA always agree on one fact: is a letter selected?
639
+ * • CTA enabled ⟺ exactly when a file is selected and ready to read (SELECTED)
640
+ * • CTA disabled ⟺ nothing selected (EMPTY at start, ARMED after a verdict, or READING)
641
+ * Transitions:
642
+ * EMPTY page load / nothing chosen → idle invite, CTA off
643
+ * SELECTED a file is picked → filename shown, CTA on, prior verdict cleared
644
+ * READING CTA clicked → CTA off (busy), mascot reading
645
+ * ARMED a verdict is on screen → selection cleared, dropzone re-arms as the
646
+ * highlighted next-letter target, CTA off
647
+ */
648
+
649
+ const DZ_DEFAULT = {
650
+ icon: "📬",
651
+ title: "Drop a saved photo or PDF here",
652
+ hint: "upload an existing image/PDF file · multi-page is fine · or click to browse",
653
+ };
654
+ const DZ_NEXT = {
655
+ icon: "📸",
656
+ title: "Drop the next letter here to scan it",
657
+ hint: "your verdict is below — drop a new file (or click) to read another",
658
+ };
659
+ function setDropzoneText(t) {
660
+ $("dz-icon").textContent = t.icon;
661
+ $("dz-title").textContent = t.title;
662
+ $("dz-hint").textContent = t.hint;
663
+ }
664
+
665
+ // "Verdict for <letter>" indicator — keeps the result attributable after the
666
+ // dropzone re-arms (you can no longer see which file was read otherwise).
667
+ function setReadSource(name, thumbUrl) {
668
+ $("read-source-name").textContent = name;
669
+ const thumb = $("read-source-thumb");
670
+ if (thumbUrl) { thumb.src = thumbUrl; thumb.hidden = false; }
671
+ else { thumb.removeAttribute("src"); thumb.hidden = true; }
672
+ $("read-source").hidden = false;
673
+ }
674
+ function clearReadSource() {
675
+ $("read-source").hidden = true;
676
+ if (lastThumbUrl) { URL.revokeObjectURL(lastThumbUrl); lastThumbUrl = null; }
677
+ }
678
+
679
+ // Forget any selected file and turn the CTA off — the single source of truth for
680
+ // "no letter is loaded". Always leaves the dropzone showing its idle invite.
681
+ function clearSelection() {
682
+ pickedFiles = [];
683
+ const fi = $("file-input");
684
+ if (fi) fi.value = ""; // so re-picking the same file still fires `change`
685
+ const ul = $("file-list");
686
+ ul.innerHTML = "";
687
+ ul.hidden = true;
688
+ $("dropzone-idle").hidden = false;
689
+ $("analyze-btn").disabled = true;
690
+ }
691
+
692
+ // Hide any verdict currently on screen (does not touch the file selection).
693
+ function hideVerdict() {
694
+ $("results").hidden = true;
695
+ $("refusal").hidden = true;
696
+ $("deadline-banner").hidden = true;
697
+ $("share-row").hidden = true;
698
+ stopReadingSteps();
699
+ setPanic(null);
700
+ setQuip("");
701
+ }
702
+
703
+ // EMPTY — clean slate (page load, or after the user clears everything).
704
+ function resetView() {
705
+ hideVerdict();
706
+ clearSelection();
707
+ clearReadSource();
708
+ pendingSource = null;
709
+ setDropzoneText(DZ_DEFAULT);
710
+ $("dropzone").classList.remove("dropzone--ready");
711
+ setMascot("idle", "Awaiting your scary letter.");
712
+ }
713
+
714
+ // ARMED — a verdict stays on screen; the upload area becomes the obvious, highlighted
715
+ // target for the NEXT letter, with the CTA off because nothing is selected yet.
716
+ function armForNext() {
717
+ clearSelection();
718
+ setDropzoneText(DZ_NEXT);
719
+ $("dropzone").classList.add("dropzone--ready");
720
+ }
721
+
722
+ // SELECTED — a new file was chosen: clear any prior verdict, list the file, enable the CTA.
723
+ function setFiles(fileList) {
724
+ hideVerdict();
725
+ $("dropzone").classList.remove("dropzone--ready");
726
+ setDropzoneText(DZ_DEFAULT);
727
+ pickedFiles = Array.from(fileList).filter((f) =>
728
+ /\.(jpe?g|png|pdf)$/i.test(f.name));
729
+ if (!pickedFiles.length) {
730
+ clearSelection(); // nothing valid picked → back to EMPTY
731
+ return;
732
+ }
733
+ const ul = $("file-list");
734
+ ul.innerHTML = "";
735
+ for (const f of pickedFiles) {
736
+ const li = document.createElement("li");
737
+ li.textContent = `${f.name} (${(f.size / 1024).toFixed(0)} kB)`;
738
+ ul.appendChild(li);
739
+ }
740
+ ul.hidden = false;
741
+ $("dropzone-idle").hidden = true;
742
+ $("analyze-btn").disabled = false; // a letter is ready → CTA on
743
+ setMascot("idle", "Ready when you are — tap “Read it for me!”");
744
+ }
745
+
746
+ const dz = $("dropzone");
747
+ dz.addEventListener("click", () => $("file-input").click());
748
+ dz.addEventListener("keydown", (e) => {
749
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); $("file-input").click(); }
750
+ });
751
+ $("file-input").addEventListener("change", (e) => setFiles(e.target.files));
752
+ ["dragenter", "dragover"].forEach((ev) =>
753
+ dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.add("is-dragover"); }));
754
+ ["dragleave", "drop"].forEach((ev) =>
755
+ dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.remove("is-dragover"); }));
756
+ dz.addEventListener("drop", (e) => setFiles(e.dataTransfer.files));
757
+
758
+ $("analyze-btn").addEventListener("click", analyze);
759
+ $("share-btn").addEventListener("click", generateShareCard);
760
+
761
+ // Sound mute toggle (persists in localStorage; a click also primes the audio ctx)
762
+ setMuted(muted);
763
+ $("sound-toggle").addEventListener("click", () => { setMuted(!muted); if (!muted) chimePop(); });
764
+
765
+ // Gallery thumbnails (static files — zero GPU until clicked, and zero even then).
766
+ const gal = $("gallery");
767
+ EXAMPLES.forEach((ex, i) => {
768
+ const btn = document.createElement("button");
769
+ btn.className = "gallery__item";
770
+ btn.innerHTML =
771
+ `<img src="/letters/${ex.slug}.png" alt="${escapeHtml(ex.label)}" loading="lazy">` +
772
+ `<span>${escapeHtml(ex.label)}</span>`;
773
+ btn.addEventListener("click", () => loadExample(i));
774
+ gal.appendChild(btn);
775
+ });
776
+
777
+ // Confetti lib (best-effort; no-op if CDN blocked).
778
+ const s = document.createElement("script");
779
+ s.src = "https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js";
780
+ document.head.appendChild(s);
781
+
782
+ // Deep-link: /#example-2 auto-loads a pre-computed example (zero GPU) —
783
+ // lets the demo video / judges land straight on a rendered verdict.
784
+ const deepLink = location.hash.match(/^#example-([0-4])$/);
785
+ if (deepLink) loadExample(Number(deepLink[1]));
786
+
787
+ // Warm the client connection in the background.
788
+ getClient().catch(() => { /* connect lazily on first use instead */ });
frontend/index.html ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Bureaucat — the cat that reads scary Swedish letters</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&display=swap" rel="stylesheet" />
10
+ <link rel="stylesheet" href="/static/style.css?v=20260614g" />
11
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🐱</text></svg>" />
12
+ </head>
13
+ <body>
14
+ <!-- Hero -->
15
+ <header class="hero">
16
+ <img src="/assets/mascot/idle.png" alt="Bureaucat, a cat civil servant" class="hero__cat" id="hero-cat" />
17
+ <div class="hero__text">
18
+ <h1>Bureaucat</h1>
19
+ <p>Upload a scary Swedish letter. I'll tell you how worried to be — and what to do.</p>
20
+ </div>
21
+ </header>
22
+
23
+ <main>
24
+ <!-- Upload card -->
25
+ <section class="card upload-card" id="upload-card">
26
+ <div class="dropzone" id="dropzone" role="button" tabindex="0"
27
+ aria-label="Upload letter — images or PDF">
28
+ <input type="file" id="file-input" accept=".jpg,.jpeg,.png,.pdf" multiple hidden />
29
+ <div class="dropzone__idle" id="dropzone-idle">
30
+ <span class="dropzone__icon" id="dz-icon">📬</span>
31
+ <strong id="dz-title">Drop a saved photo or PDF here</strong>
32
+ <span class="dropzone__hint" id="dz-hint">upload an existing image/PDF file · multi-page is fine · or click to browse</span>
33
+ </div>
34
+ <ul class="dropzone__files" id="file-list" hidden></ul>
35
+ </div>
36
+ <div class="upload-card__controls">
37
+ <label class="beginner-toggle">
38
+ <input type="checkbox" id="beginner" checked />
39
+ <span class="beginner-toggle__track" aria-hidden="true"></span>
40
+ I'm new to Sweden — explain the institutions &amp; jargon
41
+ </label>
42
+ <div class="upload-card__buttons">
43
+ <button class="cta" id="analyze-btn" disabled>🐾 Read it for me!</button>
44
+ </div>
45
+ </div>
46
+ </section>
47
+
48
+ <!-- What was read (shown with a verdict so the result is always attributable) -->
49
+ <div class="read-source" id="read-source" hidden>
50
+ <img class="read-source__thumb" id="read-source-thumb" alt="preview of the letter you read" hidden />
51
+ <div class="read-source__meta">
52
+ <span class="read-source__eyebrow">📄 Verdict for</span>
53
+ <strong id="read-source-name"></strong>
54
+ </div>
55
+ </div>
56
+
57
+ <!-- Stage: mascot + verdict -->
58
+ <section class="stage" id="stage">
59
+ <div class="stage__mascot">
60
+ <img src="/assets/mascot/idle.png" alt="Bureaucat is idle" id="mascot" class="mascot mascot--idle" />
61
+ <div class="stage__status" id="status-line">Awaiting your scary letter.</div>
62
+ </div>
63
+ <div class="stage__verdict">
64
+ <div class="panic" id="panic">
65
+ <div class="panic__placeholder">🐾 Feed me a letter and I'll tell you how worried to be</div>
66
+ </div>
67
+ <div class="quip" id="quip" hidden></div>
68
+ </div>
69
+ </section>
70
+
71
+ <!-- Deadline banner (hidden until a result has deadline items) -->
72
+ <div class="deadline-banner" id="deadline-banner" hidden></div>
73
+
74
+ <!-- Skeleton shimmer while the model reads -->
75
+ <section class="skeleton" id="skeleton" hidden aria-hidden="true">
76
+ <div class="skel-card skel-card--tall"></div>
77
+ <div class="skel-card"></div>
78
+ <div class="skel-card"></div>
79
+ <div class="skel-card"></div>
80
+ </section>
81
+
82
+ <!-- Results -->
83
+ <section class="results" id="results" hidden>
84
+ <div class="card result-card result-card--tldr">
85
+ <h2>📨 The short version</h2>
86
+ <div class="prose" id="tldr"></div>
87
+ </div>
88
+ <div class="card result-card">
89
+ <h2>🏛️ Why you got this</h2>
90
+ <div class="prose" id="why"></div>
91
+ </div>
92
+ <div class="card result-card">
93
+ <h2>✅ What you need to do</h2>
94
+ <div class="checklist" id="actions"></div>
95
+ </div>
96
+ <div class="card result-card">
97
+ <h2>⏰ Deadlines &amp; money</h2>
98
+ <ul class="deadlines" id="deadlines"></ul>
99
+ <div class="grounding" id="grounding" hidden></div>
100
+ </div>
101
+ </section>
102
+
103
+ <!-- Share the verdict (letters only) -->
104
+ <div class="share-row" id="share-row" hidden>
105
+ <button type="button" class="cta cta--share" id="share-btn">📤 Share my panic level</button>
106
+ </div>
107
+
108
+ <!-- Refusal panel -->
109
+ <section class="card refusal" id="refusal" hidden>
110
+ <h2 id="refusal-title">🙀 Hold on…</h2>
111
+ <p class="prose" id="refusal-guidance"></p>
112
+ </section>
113
+
114
+ <!-- Example gallery -->
115
+ <section class="gallery-section">
116
+ <h2>👇 No scary letter handy? Borrow one of mine</h2>
117
+ <p class="gallery-section__hint">Tap any example — I've already read these, so it costs you <strong>zero GPU</strong>.</p>
118
+ <div class="gallery" id="gallery"></div>
119
+ </section>
120
+ </main>
121
+
122
+ <footer class="footer">
123
+ <p>Everything runs on a small model inside this Space. Nothing is sent to external APIs.
124
+ Nothing is stored after your session ends.</p>
125
+ <p>Bureaucat explains letters — it does not give legal advice.
126
+ For legal matters, consult a qualified professional.</p>
127
+ </footer>
128
+
129
+ <button id="sound-toggle" class="sound-toggle" type="button" title="Sound on — click to mute">🔊</button>
130
+
131
+ <script type="module" src="/static/app.js?v=20260614g"></script>
132
+ </body>
133
+ </html>
frontend/style.css ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Bureaucat — Off-Brand custom frontend (Phase 4)
2
+ Brand language carried over from the Blocks UI: cream page, Fredoka type,
3
+ pink/lime accents, amber value pills, the 5-step severity color ramp. */
4
+
5
+ :root {
6
+ --bg: #FFFDF5;
7
+ --ink: #2B2A26;
8
+ --muted: #7A766C;
9
+ --card: #FFFFFF;
10
+ --line: #EFE9D9;
11
+ --pink: #E91E63;
12
+ --lime: #84CC16;
13
+ --amber-bg: #FFF3CD;
14
+ --amber-ink: #8B6914;
15
+ --radius: 18px;
16
+ --shadow: 0 6px 24px rgba(43, 42, 38, 0.07);
17
+ }
18
+
19
+ @media (prefers-color-scheme: dark) {
20
+ :root {
21
+ --bg: #12131A;
22
+ --ink: #F0EEE6;
23
+ --muted: #A39F93;
24
+ --card: #1C1E27;
25
+ --line: #2A2D3A;
26
+ --shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
27
+ }
28
+ }
29
+
30
+ * { box-sizing: border-box; }
31
+
32
+ /* display:grid/flex on a class would otherwise beat the UA's [hidden] rule */
33
+ [hidden] { display: none !important; }
34
+
35
+ body {
36
+ margin: 0;
37
+ background: var(--bg);
38
+ color: var(--ink);
39
+ font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
40
+ line-height: 1.55;
41
+ }
42
+
43
+ main { max-width: 980px; margin: 0 auto; padding: 0 20px 40px; }
44
+
45
+ /* ---- Hero ---- */
46
+ .hero {
47
+ max-width: 980px;
48
+ margin: 0 auto;
49
+ padding: 28px 20px 10px;
50
+ display: flex;
51
+ align-items: center;
52
+ gap: 18px;
53
+ }
54
+ .hero__cat {
55
+ width: 84px; height: 84px;
56
+ object-fit: contain;
57
+ filter: drop-shadow(0 4px 10px rgba(0,0,0,0.12));
58
+ }
59
+ .hero__text h1 {
60
+ margin: 0;
61
+ font-size: 40px;
62
+ font-weight: 700;
63
+ letter-spacing: 0.5px;
64
+ background: linear-gradient(90deg, var(--pink), #FF8A3D);
65
+ -webkit-background-clip: text;
66
+ background-clip: text;
67
+ color: transparent;
68
+ }
69
+ .hero__text p { margin: 2px 0 0; color: var(--muted); font-size: 17px; }
70
+
71
+ /* ---- Cards ---- */
72
+ .card {
73
+ background: var(--card);
74
+ border: 1px solid var(--line);
75
+ border-radius: var(--radius);
76
+ box-shadow: var(--shadow);
77
+ padding: 20px;
78
+ }
79
+
80
+ /* ---- Upload ---- */
81
+ .upload-card { margin-top: 16px; display: grid; grid-template-columns: 1.6fr 1fr; gap: 18px; }
82
+ @media (max-width: 720px) { .upload-card { grid-template-columns: 1fr; } }
83
+
84
+ .dropzone {
85
+ border: 2.5px dashed var(--line);
86
+ border-radius: var(--radius);
87
+ min-height: 140px;
88
+ display: flex; align-items: center; justify-content: center;
89
+ cursor: pointer;
90
+ transition: border-color .15s ease, background .15s ease;
91
+ padding: 14px;
92
+ }
93
+ .dropzone:hover, .dropzone:focus-visible, .dropzone.is-dragover {
94
+ border-color: var(--pink);
95
+ background: color-mix(in srgb, var(--pink) 5%, transparent);
96
+ outline: none;
97
+ }
98
+ /* After a verdict: the dropzone becomes an obvious, highlighted target for the next letter. */
99
+ .dropzone--ready {
100
+ border-color: var(--lime);
101
+ background: color-mix(in srgb, var(--lime) 9%, transparent);
102
+ animation: dz-ready-pulse 1.5s ease-out 3;
103
+ }
104
+ .dropzone--ready .dropzone__icon { animation: dz-icon-bob 1.5s ease-in-out 3; }
105
+ @keyframes dz-ready-pulse {
106
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--lime) 55%, transparent); }
107
+ 100% { box-shadow: 0 0 0 14px color-mix(in srgb, var(--lime) 0%, transparent); }
108
+ }
109
+ @keyframes dz-icon-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
110
+
111
+ .dropzone__idle { display: flex; flex-direction: column; align-items: center; gap: 4px; text-align: center; }
112
+ .dropzone__icon { font-size: 34px; }
113
+ .dropzone__hint { color: var(--muted); font-size: 13.5px; }
114
+ .dropzone__files { list-style: none; margin: 0; padding: 0; width: 100%; }
115
+ .dropzone__files li {
116
+ display: flex; align-items: center; gap: 8px;
117
+ font-size: 14px; padding: 4px 8px;
118
+ }
119
+ .dropzone__files li::before { content: "📄"; }
120
+
121
+ .upload-card__controls { display: flex; flex-direction: column; gap: 14px; justify-content: center; }
122
+
123
+ .beginner-toggle {
124
+ display: flex; align-items: center; gap: 10px;
125
+ font-size: 14.5px; cursor: pointer; user-select: none;
126
+ }
127
+ .beginner-toggle input { position: absolute; opacity: 0; }
128
+ .beginner-toggle__track {
129
+ flex: 0 0 auto;
130
+ width: 42px; height: 24px; border-radius: 12px;
131
+ background: var(--line); position: relative; transition: background .15s ease;
132
+ }
133
+ .beginner-toggle__track::after {
134
+ content: ""; position: absolute; top: 3px; left: 3px;
135
+ width: 18px; height: 18px; border-radius: 50%;
136
+ background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.25);
137
+ transition: left .15s ease;
138
+ }
139
+ .beginner-toggle input:checked + .beginner-toggle__track { background: var(--lime); }
140
+ .beginner-toggle input:checked + .beginner-toggle__track::after { left: 21px; }
141
+
142
+ .cta {
143
+ font-family: inherit;
144
+ font-size: 19px; font-weight: 600;
145
+ padding: 14px 22px;
146
+ border: none; border-radius: 14px;
147
+ background: var(--pink); color: #fff;
148
+ cursor: pointer;
149
+ box-shadow: 0 5px 16px color-mix(in srgb, var(--pink) 45%, transparent);
150
+ transition: transform .1s ease, filter .15s ease;
151
+ }
152
+ .cta:hover:not(:disabled) { filter: brightness(1.07); transform: translateY(-1px); }
153
+ .cta:disabled { opacity: .45; cursor: not-allowed; box-shadow: none; }
154
+
155
+ /* ---- Action buttons (Read it for me + Read another) sit together by the upload ---- */
156
+ .upload-card__buttons { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; }
157
+ .upload-card__buttons .cta { flex: 1 1 auto; }
158
+ .cta--again {
159
+ background: var(--lime);
160
+ color: #2B3A0E;
161
+ box-shadow: 0 5px 16px color-mix(in srgb, var(--lime) 45%, transparent);
162
+ }
163
+ .cta--again[hidden] { display: none; }
164
+
165
+ /* ---- "Verdict for <letter>" attribution chip ---- */
166
+ .read-source {
167
+ display: flex; align-items: center; gap: 12px;
168
+ margin-top: 18px; padding: 10px 14px;
169
+ border: 1px solid var(--line); border-radius: 12px;
170
+ background: color-mix(in srgb, var(--pink) 4%, transparent);
171
+ }
172
+ .read-source__thumb {
173
+ width: 42px; height: 54px; flex: 0 0 auto;
174
+ object-fit: cover; object-position: top;
175
+ border-radius: 6px; border: 1px solid var(--line); background: #fff;
176
+ }
177
+ .read-source__meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
178
+ .read-source__eyebrow { font-size: 12px; color: var(--muted); }
179
+ .read-source__meta strong { font-size: 15px; word-break: break-word; }
180
+
181
+ /* ---- Stage (mascot + verdict) ---- */
182
+ .stage {
183
+ margin-top: 22px;
184
+ display: grid; grid-template-columns: 240px 1fr; gap: 20px;
185
+ align-items: center;
186
+ }
187
+ @media (max-width: 720px) { .stage { grid-template-columns: 1fr; text-align: center; } }
188
+
189
+ .stage__mascot { display: flex; flex-direction: column; align-items: center; gap: 8px; }
190
+ .mascot { width: 200px; height: 200px; object-fit: contain; }
191
+ .stage__status { color: var(--muted); font-size: 14px; min-height: 20px; }
192
+
193
+ /* Mascot state animations (mirrors the Blocks keyframes) */
194
+ .mascot--reading { animation: bcat-sway 1.1s ease-in-out infinite; }
195
+ .mascot--verifying { animation: bcat-pulse 0.7s ease-in-out infinite; }
196
+ .mascot--allclear { animation: bcat-bounce 0.9s ease; }
197
+ .mascot--deadline,
198
+ .mascot--money { animation: bcat-shake 0.5s ease 2; }
199
+ .mascot--confused,
200
+ .mascot--wrong_document { animation: bcat-tilt 0.8s ease; }
201
+
202
+ @keyframes bcat-sway { 0%,100% { transform: rotate(-3deg); } 50% { transform: rotate(3deg); } }
203
+ @keyframes bcat-pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.06); } }
204
+ @keyframes bcat-bounce { 0% { transform: translateY(0); } 35% { transform: translateY(-18px); } 70% { transform: translateY(0); } 85% { transform: translateY(-7px); } 100% { transform: translateY(0); } }
205
+ @keyframes bcat-shake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-7px); } 75% { transform: translateX(7px); } }
206
+ @keyframes bcat-tilt { 0% { transform: rotate(0); } 40% { transform: rotate(-9deg); } 100% { transform: rotate(0); } }
207
+
208
+ /* ---- Panic-o-meter gauge ---- */
209
+ .panic__placeholder {
210
+ border: 1.5px dashed var(--line); border-radius: var(--radius);
211
+ padding: 22px; color: var(--muted); text-align: center; font-size: 16px;
212
+ }
213
+ .panic-gauge { text-align: center; animation: gauge-in .35s ease; }
214
+ @keyframes gauge-in { from { transform: scale(.85); opacity: 0; } to { transform: scale(1); opacity: 1; } }
215
+ .panic-gauge__title {
216
+ font-weight: 700; letter-spacing: 2px; font-size: 13px;
217
+ color: var(--muted); text-transform: uppercase; margin-bottom: 2px;
218
+ }
219
+ .panic-gauge__svg { width: 240px; max-width: 100%; height: auto; display: block; margin: 0 auto; }
220
+ .panic-gauge__svg .gauge-needle { fill: var(--ink); }
221
+ .panic-gauge__svg .gauge-tick { fill: var(--muted); }
222
+ .panic-gauge__readout {
223
+ display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: -4px;
224
+ }
225
+ .panic-gauge__face { font-size: 34px; line-height: 1; animation: face-pop .5s ease; }
226
+ @keyframes face-pop { 0% { transform: scale(.3); opacity: 0; } 60% { transform: scale(1.2); } 100% { transform: scale(1); opacity: 1; } }
227
+ .panic-gauge__num { font-size: 30px; font-weight: 700; line-height: 1; }
228
+ .panic-gauge__word { font-size: 18px; font-weight: 600; }
229
+
230
+ /* ---- Quip speech bubble ---- */
231
+ .quip {
232
+ margin-top: 14px;
233
+ background: var(--card);
234
+ border: 1px solid var(--line);
235
+ border-radius: 16px;
236
+ padding: 13px 16px;
237
+ font-style: italic;
238
+ font-size: 16.5px;
239
+ position: relative;
240
+ }
241
+ .quip::before {
242
+ content: ""; position: absolute; left: -9px; top: 22px;
243
+ border: 9px solid transparent; border-right-color: var(--line);
244
+ }
245
+ .quip strong { color: var(--pink); font-style: normal; }
246
+
247
+ /* ---- Deadline banner ---- */
248
+ .deadline-banner {
249
+ margin-top: 20px;
250
+ background: var(--amber-bg);
251
+ color: var(--amber-ink);
252
+ border: 1.5px solid color-mix(in srgb, var(--amber-ink) 30%, transparent);
253
+ border-radius: var(--radius);
254
+ padding: 14px 18px;
255
+ font-size: 17px; font-weight: 600;
256
+ display: flex; align-items: center; gap: 10px;
257
+ }
258
+ .deadline-banner .value { text-decoration: underline; text-underline-offset: 3px; }
259
+ /* Attention pulse for action-needed verdicts (non-celebratory) */
260
+ .deadline-banner.pulse { animation: deadline-pulse 0.9s ease-out 2; }
261
+ @keyframes deadline-pulse {
262
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 209, 102, 0); transform: scale(1); }
263
+ 35% { box-shadow: 0 0 0 6px rgba(255, 209, 102, 0.55); transform: scale(1.015); }
264
+ }
265
+
266
+ /* ---- Results grid ---- */
267
+ .results {
268
+ margin-top: 20px;
269
+ display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
270
+ }
271
+ @media (max-width: 720px) { .results { grid-template-columns: 1fr; } }
272
+ .result-card--tldr { grid-column: 1 / -1; border-top: 4px solid var(--lime); }
273
+ .result-card h2 { margin: 0 0 10px; font-size: 18px; }
274
+ .prose { font-size: 15.5px; }
275
+ .prose p { margin: 0 0 10px; }
276
+
277
+ /* ---- Checklist ---- */
278
+ .checklist { display: flex; flex-direction: column; gap: 8px; }
279
+ .check-item {
280
+ display: flex; gap: 10px; align-items: flex-start;
281
+ padding: 9px 12px;
282
+ background: color-mix(in srgb, var(--lime) 7%, transparent);
283
+ border: 1px solid color-mix(in srgb, var(--lime) 25%, transparent);
284
+ border-radius: 12px;
285
+ cursor: pointer; font-size: 15px;
286
+ }
287
+ .check-item input { margin-top: 3px; accent-color: var(--lime); width: 17px; height: 17px; flex: 0 0 auto; }
288
+ .check-item.done { opacity: .55; text-decoration: line-through; }
289
+
290
+ /* ---- Deadlines list ---- */
291
+ .deadlines { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 9px; }
292
+ .deadlines li { font-size: 15px; }
293
+ .deadlines mark {
294
+ background: var(--amber-bg); color: var(--amber-ink);
295
+ padding: 3px 7px; border-radius: 6px; font-weight: 600;
296
+ }
297
+ .deadlines .none { color: var(--muted); }
298
+
299
+ /* ---- Grounding badge ---- */
300
+ .grounding { margin-top: 14px; font-size: 13.5px; border-radius: 10px; padding: 9px 12px; }
301
+ .grounding.ok { background: color-mix(in srgb, var(--lime) 12%, transparent); color: #3F6212; }
302
+ .grounding.fail { background: #FDECEA; color: #B71C1C; font-weight: 600; }
303
+ @media (prefers-color-scheme: dark) {
304
+ .grounding.ok { color: #B5E48C; }
305
+ .grounding.fail { background: #3a1d1b; color: #FF8A80; }
306
+ }
307
+
308
+ /* ---- Refusal ---- */
309
+ .refusal { margin-top: 20px; border-top: 4px solid #E67E22; }
310
+
311
+ /* ---- Gallery ---- */
312
+ .gallery-section { margin-top: 34px; }
313
+ .gallery-section h2 { font-size: 20px; margin-bottom: 2px; }
314
+ .gallery-section__hint { color: var(--muted); margin-top: 0; font-size: 14.5px; }
315
+ .gallery {
316
+ display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px;
317
+ }
318
+ @media (max-width: 860px) { .gallery { grid-template-columns: repeat(3, 1fr); } }
319
+ @media (max-width: 560px) { .gallery { grid-template-columns: repeat(2, 1fr); } }
320
+ .gallery__item {
321
+ background: var(--card); border: 1px solid var(--line); border-radius: 14px;
322
+ padding: 8px; cursor: pointer; text-align: center;
323
+ transition: transform .12s ease, border-color .12s ease;
324
+ font-family: inherit; color: var(--ink);
325
+ }
326
+ .gallery__item:hover { transform: translateY(-3px); border-color: var(--pink); }
327
+ .gallery__item img { width: 100%; aspect-ratio: 3/4; object-fit: cover; border-radius: 9px; }
328
+ .gallery__item span { display: block; font-size: 12px; margin-top: 6px; color: var(--muted); }
329
+
330
+ /* ---- Verdict screen flash (full-viewport edge pulse) ---- */
331
+ .screen-flash { position: fixed; inset: 0; pointer-events: none; z-index: 9998; }
332
+ .screen-flash--good { animation: flash-good 0.9s ease-out forwards; }
333
+ .screen-flash--alert { animation: flash-alert 0.6s ease-out 2; }
334
+ @keyframes flash-good {
335
+ 0% { box-shadow: inset 0 0 0 0 rgba(132,204,22,0); }
336
+ 30% { box-shadow: inset 0 0 160px 30px rgba(132,204,22,0.45); }
337
+ 100% { box-shadow: inset 0 0 0 0 rgba(132,204,22,0); }
338
+ }
339
+ @keyframes flash-alert {
340
+ 0% { box-shadow: inset 0 0 0 0 rgba(192,57,43,0); }
341
+ 35% { box-shadow: inset 0 0 180px 40px rgba(192,57,43,0.5); }
342
+ 100% { box-shadow: inset 0 0 0 0 rgba(192,57,43,0); }
343
+ }
344
+
345
+ /* ---- Sound toggle ---- */
346
+ .sound-toggle {
347
+ position: fixed; bottom: 16px; right: 16px; z-index: 9999;
348
+ width: 44px; height: 44px; border-radius: 50%;
349
+ border: 1px solid var(--line); background: var(--card); color: var(--ink);
350
+ font-size: 20px; cursor: pointer; box-shadow: var(--shadow);
351
+ display: flex; align-items: center; justify-content: center;
352
+ transition: transform .12s ease;
353
+ }
354
+ .sound-toggle:hover { transform: scale(1.08); }
355
+
356
+ /* ---- Footer ---- */
357
+ .footer {
358
+ max-width: 980px; margin: 30px auto 0; padding: 18px 20px 34px;
359
+ border-top: 1px solid var(--line);
360
+ color: var(--muted); font-size: 13px; text-align: center;
361
+ }
362
+ .footer p { margin: 4px 0; }
363
+
364
+ /* ============================================================
365
+ Engagement pass: countdowns, skeleton, panic reveal, share
366
+ ============================================================ */
367
+
368
+ /* ---- Deadline countdown pills ---- */
369
+ .cd {
370
+ display: inline-block; margin-left: 6px;
371
+ padding: 1px 9px; border-radius: 999px;
372
+ font-size: 12.5px; font-weight: 600; white-space: nowrap;
373
+ vertical-align: middle;
374
+ }
375
+ .cd--overdue { background: #FDE2E1; color: #B3261E; }
376
+ .cd--soon { background: #FDECD2; color: #9A5B00; }
377
+ .cd--near { background: #FFF3CD; color: #8B6914; }
378
+ .cd--far { background: var(--line); color: var(--muted); }
379
+ @media (prefers-color-scheme: dark) {
380
+ .cd--overdue { background: #4A1F1C; color: #FFB4AB; }
381
+ .cd--soon { background: #432F12; color: #FFD8A8; }
382
+ .cd--near { background: #3A340F; color: #F2D98D; }
383
+ }
384
+
385
+ /* ---- Skeleton shimmer while reading ---- */
386
+ .skeleton { max-width: 980px; margin: 22px auto 0; display: grid; gap: 14px; }
387
+ .skel-card {
388
+ height: 96px; border-radius: var(--radius);
389
+ background: linear-gradient(100deg,
390
+ var(--card) 30%, color-mix(in srgb, var(--line) 60%, var(--card)) 50%, var(--card) 70%);
391
+ background-size: 220% 100%;
392
+ border: 1px solid var(--line);
393
+ animation: skel-shimmer 1.25s ease-in-out infinite;
394
+ }
395
+ .skel-card--tall { height: 150px; }
396
+ @keyframes skel-shimmer { 0% { background-position: 180% 0; } 100% { background-position: -80% 0; } }
397
+
398
+ /* ---- Panic reveal: number pop + severity colour-wash + alarm aura ---- */
399
+ .panic-gauge__num.num-pop { animation: num-pop 0.18s ease; }
400
+ @keyframes num-pop { 0% { transform: scale(1); } 45% { transform: scale(1.35); } 100% { transform: scale(1); } }
401
+
402
+ .stage {
403
+ border-radius: var(--radius);
404
+ transition: background 0.4s ease, box-shadow 0.4s ease;
405
+ }
406
+ /* faint wash in the verdict's severity colour (set via --sev on the stage) */
407
+ .stage[style*="--sev"] {
408
+ background:
409
+ radial-gradient(120% 90% at 50% 0%, color-mix(in srgb, var(--sev) 12%, transparent), transparent 70%);
410
+ }
411
+ .stage--alarm { box-shadow: 0 0 0 2px color-mix(in srgb, var(--sev) 35%, transparent); }
412
+ .stage--alarm .mascot { animation: bcat-alarm 0.5s ease-in-out 3; }
413
+ @keyframes bcat-alarm {
414
+ 0%,100% { transform: translateX(0) rotate(0); }
415
+ 25% { transform: translateX(-5px) rotate(-3deg); }
416
+ 75% { transform: translateX(5px) rotate(3deg); }
417
+ }
418
+
419
+ /* ---- Quip as a cat speech bubble ---- */
420
+ .quip {
421
+ position: relative;
422
+ background: var(--card);
423
+ border: 1.5px solid var(--line);
424
+ border-radius: 16px;
425
+ padding: 12px 16px;
426
+ box-shadow: var(--shadow);
427
+ }
428
+ .quip::before {
429
+ content: ""; position: absolute; left: 26px; top: -9px;
430
+ width: 16px; height: 16px; background: var(--card);
431
+ border-left: 1.5px solid var(--line); border-top: 1.5px solid var(--line);
432
+ transform: rotate(45deg);
433
+ }
434
+
435
+ /* ---- Share button ---- */
436
+ .share-row { display: flex; justify-content: center; margin-top: 18px; }
437
+ .cta--share {
438
+ background: linear-gradient(135deg, var(--pink), #FF6FA5);
439
+ color: #fff;
440
+ }
441
+
442
+ @media (prefers-reduced-motion: reduce) {
443
+ .skel-card { animation: none; }
444
+ .panic-gauge__num.num-pop { animation: none; }
445
+ .stage--alarm .mascot { animation: none; }
446
+ }
requirements.txt ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Bureaucat — HF Space dependency manifest
2
+ # Pinned to documented ZeroGPU-compatible versions as of 2026-06-05.
3
+ # torch 2.11.0 is the confirmed ZeroGPU ceiling; do not upgrade to 2.12.0
4
+ # in the Space until ZeroGPU docs explicitly list it (local dev may use 2.12.0).
5
+ # Do NOT add flash-attn — not required (we use attn_implementation="sdpa").
6
+ #
7
+ # torchvision IS required (correction, 2026-06-13): transformers 5.10.2's
8
+ # Qwen3-VL AutoProcessor eagerly loads Qwen3VLVideoProcessor, which has a HARD
9
+ # torchvision backend requirement — even for image-only inference. Without it
10
+ # the Space dies at load_model() with ImportError (RUNTIME_ERROR on first boot).
11
+ # 0.26.0 is the torchvision release paired with torch 2.11.0 (local dev runs
12
+ # torch 2.12.0 + torchvision 0.27.0, which is why this was masked locally).
13
+
14
+ gradio==6.16.0
15
+ spaces==0.50.4
16
+ torch==2.11.0
17
+ torchvision==0.26.0
18
+ transformers==5.10.2
19
+ accelerate==1.13.0
20
+ qwen-vl-utils==0.0.14
21
+ pillow>=10.0.0
22
+ # pypdfium2 5.9.0 — Apache/BSD, self-contained (no system poppler/apt required).
23
+ # Linux manylinux_2_17_x86_64 wheel confirmed on PyPI 2026-06-06 (ABI3 / py3-none).
24
+ # Used for server-side PDF → PIL Image rendering (INPUT-02).
25
+ pypdfium2==5.9.0
tests/__init__.py ADDED
File without changes
tests/test_doctype_refusal.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tests/test_doctype_refusal.py — DOCTYPE refusal parser + routing tests (Phase 3, TRUST-02/03/04).
3
+
4
+ Two-tier test structure:
5
+ (a) No-model unit tests — BUREAUCAT_NO_MODEL=1 import; feed synthetic raw strings
6
+ to parse_output, assert doctype classification and render_result routing.
7
+ These run under: BUREAUCAT_NO_MODEL=1 python -m pytest tests/test_doctype_refusal.py -k "not integration"
8
+
9
+ (b) MPS integration tests — marked "integration"; skipif when BUREAUCAT_NO_MODEL=1.
10
+ These load each adversarial fixture image, run run_inference, and assert the
11
+ emitted doctype matches the sidecar's expected_doctype. Run only with weights.
12
+ These are SKIPPED in the unit test run (no GPU quota cost).
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ import unittest
19
+ from pathlib import Path
20
+
21
+ # ---- escape hatch: must be set BEFORE importing app ----
22
+ os.environ["BUREAUCAT_NO_MODEL"] = "1"
23
+
24
+ # Ensure project root is on sys.path
25
+ _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
26
+ if _REPO_ROOT not in sys.path:
27
+ sys.path.insert(0, _REPO_ROOT)
28
+
29
+ import app # noqa: E402 (after path setup and NO_MODEL set)
30
+ from app import StructuredResult, parse_output, render_result, run_verifying_state # noqa: E402
31
+
32
+ _ADVERSARIAL_DIR = Path(_REPO_ROOT) / "data" / "letters" / "public" / "adversarial"
33
+
34
+
35
+ # ===========================================================================
36
+ # Tier (a): No-model unit tests — parse_output + render_result routing
37
+ # ===========================================================================
38
+
39
+ class TestParseDoctypeExtraction(unittest.TestCase):
40
+ """parse_output() correctly extracts all four DOCTYPE values and defaults to 'letter'."""
41
+
42
+ def _parse(self, raw: str):
43
+ return parse_output(raw)
44
+
45
+ def test_not_letter_doctype(self):
46
+ raw = "<transcription>a</transcription>\nDOCTYPE: not_letter\nBureaucat says: That's a receipt, not a letter."
47
+ result = self._parse(raw)
48
+ self.assertEqual(result.doctype, "not_letter")
49
+
50
+ def test_unreadable_doctype(self):
51
+ raw = "<transcription>??</transcription>\nDOCTYPE: unreadable\nBureaucat says: I squinted and saw nothing."
52
+ result = self._parse(raw)
53
+ self.assertEqual(result.doctype, "unreadable")
54
+
55
+ def test_non_swedish_doctype(self):
56
+ raw = "<transcription>This is in English.</transcription>\nDOCTYPE: non_swedish\nBureaucat says: This one's not Swedish."
57
+ result = self._parse(raw)
58
+ self.assertEqual(result.doctype, "non_swedish")
59
+
60
+ def test_letter_doctype_explicit(self):
61
+ raw = (
62
+ "<transcription>Skatteverket text.</transcription>\n"
63
+ "DOCTYPE: letter\n"
64
+ "Bureaucat says: A tax letter.\n"
65
+ "## TL;DR\nYou owe tax.\n"
66
+ "## Why you got this\nBecause taxes.\n"
67
+ "## What you need to do\nPay.\n"
68
+ "## Deadlines & money\n- 1 jan 2027 — deadline\n"
69
+ "SEVERITY: 3"
70
+ )
71
+ result = self._parse(raw)
72
+ self.assertEqual(result.doctype, "letter")
73
+
74
+ def test_missing_sentinel_defaults_to_letter(self):
75
+ """No DOCTYPE line → defaults to 'letter' (D3-02 lean-toward-analyzing)."""
76
+ result = self._parse("no sentinel here at all")
77
+ self.assertEqual(result.doctype, "letter")
78
+
79
+ def test_empty_input_defaults_to_letter_and_none_severity(self):
80
+ """Empty input → doctype='letter', severity=None, never raises."""
81
+ result = self._parse("")
82
+ self.assertEqual(result.doctype, "letter")
83
+ self.assertIsNone(result.severity)
84
+
85
+ def test_doctype_case_insensitive(self):
86
+ """Regex is IGNORECASE — uppercase variant still parsed."""
87
+ raw = "<transcription>x</transcription>\nDOCTYPE: NOT_LETTER\nBureaucat says: nope"
88
+ result = self._parse(raw)
89
+ self.assertEqual(result.doctype, "not_letter")
90
+
91
+ def test_doctype_not_leaked_into_tldr(self):
92
+ """DOCTYPE line is stripped from body before section split — must not appear in tldr."""
93
+ raw = (
94
+ "<transcription>a</transcription>\n"
95
+ "DOCTYPE: letter\n"
96
+ "Bureaucat says: hi\n"
97
+ "## TL;DR\nhello world\n"
98
+ "## Why you got this\nbecause\n"
99
+ "## What you need to do\npay\n"
100
+ "## Deadlines & money\n- 1 jan 2027 — deadline\n"
101
+ "SEVERITY: 2"
102
+ )
103
+ result = self._parse(raw)
104
+ self.assertNotIn("DOCTYPE", result.tldr)
105
+ self.assertEqual(result.severity, 2)
106
+
107
+ def test_doctype_not_leaked_into_why(self):
108
+ raw = (
109
+ "<transcription>a</transcription>\n"
110
+ "DOCTYPE: letter\nBureaucat says: hi\n"
111
+ "## TL;DR\nhello\n"
112
+ "## Why you got this\nsome reason\n"
113
+ "## What you need to do\npay\n"
114
+ "## Deadlines & money\n- none\nSEVERITY: 1"
115
+ )
116
+ result = self._parse(raw)
117
+ self.assertNotIn("DOCTYPE", result.why)
118
+
119
+
120
+ class TestRenderResultRefusalRouting(unittest.TestCase):
121
+ """render_result() routes doctype != 'letter' to refusal branch BEFORE malformed branch."""
122
+
123
+ def _make_refusal(self, doctype: str, quip: str = "Nope!") -> StructuredResult:
124
+ return StructuredResult(
125
+ transcription="", quip=quip, tldr="", why="", actions="", deadlines="",
126
+ severity=None, raw="", doctype=doctype,
127
+ )
128
+
129
+ def _make_malformed(self) -> StructuredResult:
130
+ """letter + severity=None → malformed path (unchanged from Phase 2)."""
131
+ return StructuredResult(
132
+ transcription="", quip="", tldr="", why="", actions="", deadlines="",
133
+ severity=None, raw="", doctype="letter",
134
+ )
135
+
136
+ def test_not_letter_gets_wrong_document_mascot(self):
137
+ t = render_result(self._make_refusal("not_letter"), "English")
138
+ self.assertIn("wrong_document.png", t[1])
139
+
140
+ def test_not_letter_has_empty_panic_html(self):
141
+ t = render_result(self._make_refusal("not_letter"), "English")
142
+ self.assertEqual(t[0], "", "Panic Meter must be empty string on refusal (D3-03)")
143
+
144
+ def test_non_swedish_gets_wrong_document_mascot(self):
145
+ t = render_result(self._make_refusal("non_swedish"), "English")
146
+ self.assertIn("wrong_document.png", t[1])
147
+
148
+ def test_non_swedish_guidance_mentions_swedish(self):
149
+ t = render_result(self._make_refusal("non_swedish"), "English")
150
+ tldr_value = t[3] # gr.update dict
151
+ guidance_text = tldr_value.get("value", "")
152
+ self.assertIn("Swedish", guidance_text)
153
+
154
+ def test_unreadable_gets_confused_mascot(self):
155
+ u = render_result(self._make_refusal("unreadable"), "English")
156
+ self.assertIn("confused.png", u[1])
157
+
158
+ def test_unreadable_has_empty_panic_html(self):
159
+ u = render_result(self._make_refusal("unreadable"), "English")
160
+ self.assertEqual(u[0], "")
161
+
162
+ def test_letter_severity_none_still_malformed_path(self):
163
+ """doctype='letter' + severity=None → original malformed ERROR_COPY path (unchanged)."""
164
+ m = render_result(self._make_malformed(), "English")
165
+ tldr_value = m[3]
166
+ guidance_text = tldr_value.get("value", "")
167
+ self.assertIn("Bureaucat had trouble", guidance_text)
168
+
169
+ def test_refusal_returns_7_tuple(self):
170
+ t = render_result(self._make_refusal("not_letter"), "English")
171
+ self.assertEqual(len(t), 7)
172
+
173
+ def test_refusal_quip_rendered(self):
174
+ """Model's in-voice quip is passed through render_quip."""
175
+ t = render_result(self._make_refusal("not_letter", quip="That's a receipt!"), "English")
176
+ # quip_md is index 2
177
+ self.assertIn("receipt", t[2])
178
+
179
+ def test_refusal_empty_sections(self):
180
+ """why_out, actions_out are empty on refusal (no four-section analysis)."""
181
+ t = render_result(self._make_refusal("not_letter"), "English")
182
+ why_text = t[4].get("value", "")
183
+ actions_text = t[5].get("value", "")
184
+ self.assertEqual(why_text, "")
185
+ self.assertEqual(actions_text, "")
186
+
187
+
188
+ class TestRunVerifyingStateRefusalBypass(unittest.TestCase):
189
+ """run_verifying_state bypasses check_no_invention on doctype != 'letter'."""
190
+
191
+ def _make_refusal(self, doctype: str) -> StructuredResult:
192
+ return StructuredResult(
193
+ transcription="", quip="", tldr="", why="", actions="", deadlines="",
194
+ severity=None, raw="", doctype=doctype,
195
+ )
196
+
197
+ def test_not_letter_returns_wrong_document_mascot(self):
198
+ result = run_verifying_state(self._make_refusal("not_letter"))
199
+ self.assertIn("wrong_document.png", result)
200
+
201
+ def test_unreadable_returns_confused_mascot(self):
202
+ result = run_verifying_state(self._make_refusal("unreadable"))
203
+ self.assertIn("confused.png", result)
204
+
205
+ def test_non_swedish_returns_wrong_document_mascot(self):
206
+ result = run_verifying_state(self._make_refusal("non_swedish"))
207
+ self.assertIn("wrong_document.png", result)
208
+
209
+ def test_refusal_does_not_raise(self):
210
+ """check_no_invention would raise/error on empty values — guard must prevent that."""
211
+ # The refusal guard ensures check_no_invention is NEVER called on a refusal.
212
+ # If the guard works, this test passes without exception.
213
+ try:
214
+ run_verifying_state(self._make_refusal("not_letter"))
215
+ except Exception as exc: # noqa: BLE001
216
+ self.fail(f"run_verifying_state raised on refusal: {exc}")
217
+
218
+
219
+ class TestAdversarialSidecarSchema(unittest.TestCase):
220
+ """Adversarial JSON sidecars have the correct schema and expected_doctype values."""
221
+
222
+ def _load_sidecar(self, slug: str) -> dict:
223
+ path = _ADVERSARIAL_DIR / f"{slug}.json"
224
+ self.assertTrue(path.exists(), f"Sidecar not found: {path}")
225
+ return json.loads(path.read_text(encoding="utf-8"))
226
+
227
+ def test_blurry_unreadable_sidecar(self):
228
+ data = self._load_sidecar("blurry_unreadable")
229
+ self.assertEqual(data["fixture_type"], "adversarial")
230
+ self.assertEqual(data["expected_doctype"], "unreadable")
231
+ self.assertIsNone(data["expected_severity"])
232
+
233
+ def test_non_letter_receipt_sidecar(self):
234
+ data = self._load_sidecar("non_letter_receipt")
235
+ self.assertEqual(data["fixture_type"], "adversarial")
236
+ self.assertEqual(data["expected_doctype"], "not_letter")
237
+ self.assertIsNone(data["expected_severity"])
238
+
239
+ def test_non_swedish_english_sidecar(self):
240
+ # Phase-3 decision (builder-approved): the model classifies a non-Swedish *letter*
241
+ # as not_letter, not non_swedish; forcing the distinction regressed reference recall
242
+ # on real Swedish letters (reverted). Both route to the same refusal UX, so the
243
+ # fixture's expected refusal is scored as not_letter. The non_swedish render path
244
+ # remains exercised by the render-layer tests above (e.g. test_non_swedish_*).
245
+ data = self._load_sidecar("non_swedish_english")
246
+ self.assertEqual(data["fixture_type"], "adversarial")
247
+ self.assertEqual(data["expected_doctype"], "not_letter")
248
+ self.assertIsNone(data["expected_severity"])
249
+
250
+ def test_fixture_pngs_exist(self):
251
+ for slug in ("blurry_unreadable", "non_letter_receipt", "non_swedish_english"):
252
+ png = _ADVERSARIAL_DIR / f"{slug}.png"
253
+ self.assertTrue(png.exists(), f"PNG fixture missing: {png}")
254
+
255
+
256
+ # ===========================================================================
257
+ # Tier (b): MPS integration tests — skipped when BUREAUCAT_NO_MODEL=1
258
+ # ===========================================================================
259
+
260
+ import pytest # noqa: E402
261
+
262
+
263
+ @pytest.mark.skipif(
264
+ os.getenv("BUREAUCAT_NO_MODEL") == "1",
265
+ reason="Model weights not loaded (BUREAUCAT_NO_MODEL=1); skipping MPS integration tests",
266
+ )
267
+ class TestAdversarialMpsIntegration(unittest.TestCase):
268
+ """
269
+ Integration tests that run each adversarial fixture through run_inference
270
+ and assert the emitted doctype matches the fixture's expected_doctype.
271
+
272
+ SKIPPED under BUREAUCAT_NO_MODEL=1. Run only when weights are available.
273
+ The tests load the model inside the test body — not at module scope.
274
+ """
275
+
276
+ @classmethod
277
+ def setUpClass(cls):
278
+ """Load model once for all integration tests in this class."""
279
+ # Clear the NO_MODEL flag before loading so load_model actually loads
280
+ os.environ.pop("BUREAUCAT_NO_MODEL", None)
281
+ from app import load_model, MODEL_VARIANT, IMAGE_PATCH_SIZE
282
+ cls.mdl, cls.proc = load_model(MODEL_VARIANT)
283
+ cls.image_patch_size = IMAGE_PATCH_SIZE
284
+
285
+ def _run_fixture(self, slug: str):
286
+ from PIL import Image
287
+ from app import run_inference
288
+ png_path = _ADVERSARIAL_DIR / f"{slug}.png"
289
+ json_path = _ADVERSARIAL_DIR / f"{slug}.json"
290
+ image = Image.open(png_path)
291
+ gold = json.loads(json_path.read_text(encoding="utf-8"))
292
+ result = run_inference(
293
+ image, "English", beginner_mode=False,
294
+ mdl=self.mdl, proc=self.proc,
295
+ image_patch_size=self.image_patch_size,
296
+ )
297
+ return result, gold
298
+
299
+ def test_blurry_unreadable_integration(self):
300
+ result, gold = self._run_fixture("blurry_unreadable")
301
+ self.assertEqual(
302
+ result.doctype, gold["expected_doctype"],
303
+ f"blurry_unreadable: expected doctype={gold['expected_doctype']!r}, got {result.doctype!r}",
304
+ )
305
+
306
+ def test_non_letter_receipt_integration(self):
307
+ result, gold = self._run_fixture("non_letter_receipt")
308
+ self.assertEqual(
309
+ result.doctype, gold["expected_doctype"],
310
+ f"non_letter_receipt: expected doctype={gold['expected_doctype']!r}, got {result.doctype!r}",
311
+ )
312
+
313
+ def test_non_swedish_english_integration(self):
314
+ result, gold = self._run_fixture("non_swedish_english")
315
+ self.assertEqual(
316
+ result.doctype, gold["expected_doctype"],
317
+ f"non_swedish_english: expected doctype={gold['expected_doctype']!r}, got {result.doctype!r}",
318
+ )
319
+
320
+
321
+ if __name__ == "__main__":
322
+ unittest.main()
tests/test_gallery.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tests/test_gallery.py — Zero-GPU gallery loader tests (Plan 02-04, UI-01).
3
+
4
+ Verifies:
5
+ (a) load_example returns a 7-element tuple matching render_result's output arity
6
+ and does NOT raise.
7
+ (b) load_example is NOT wrapped by @spaces.GPU and never calls the model —
8
+ run_inference_multi is patched to raise; load_example must still succeed.
9
+ (c) data/gallery/*-result.json fixtures round-trip into StructuredResult correctly
10
+ (severity is int or None, all fields present).
11
+
12
+ BUREAUCAT_NO_MODEL=1 is set BEFORE import to prevent model weight loading.
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ import types
19
+ import unittest
20
+ from pathlib import Path
21
+ from unittest.mock import patch
22
+
23
+ # ---- escape hatch: must be set BEFORE importing app ----
24
+ os.environ["BUREAUCAT_NO_MODEL"] = "1"
25
+
26
+ # Ensure project root is on sys.path when run from tests/ or project root
27
+ _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
28
+ if _REPO_ROOT not in sys.path:
29
+ sys.path.insert(0, _REPO_ROOT)
30
+
31
+ import app # noqa: E402 (after path setup)
32
+ from app import EXAMPLE_LETTERS, StructuredResult, load_example # noqa: E402
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Helpers
37
+ # ---------------------------------------------------------------------------
38
+
39
+ _GALLERY_DIR = Path(_REPO_ROOT) / "data" / "gallery"
40
+
41
+ _STRUCTURED_RESULT_FIELDS = frozenset(
42
+ StructuredResult.__dataclass_fields__.keys()
43
+ )
44
+
45
+ _RENDER_RESULT_ARITY = 7 # (panic_html, mascot_html, quip_md, tldr, why, actions, deadlines_html)
46
+
47
+
48
+ def _make_select_event(index: int):
49
+ """Return a SimpleNamespace that looks like gr.SelectData(index=N)."""
50
+ return types.SimpleNamespace(index=index)
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Tests
55
+ # ---------------------------------------------------------------------------
56
+
57
+ class TestExampleLettersList(unittest.TestCase):
58
+ """EXAMPLE_LETTERS has >= 4 entries with required keys."""
59
+
60
+ def test_length(self):
61
+ self.assertGreaterEqual(len(EXAMPLE_LETTERS), 4, "need >= 4 gallery entries")
62
+
63
+ def test_keys(self):
64
+ for entry in EXAMPLE_LETTERS:
65
+ for key in ("slug", "label", "image", "cached"):
66
+ self.assertIn(key, entry, f"missing key '{key}' in entry {entry}")
67
+
68
+ def test_image_paths_under_public(self):
69
+ for entry in EXAMPLE_LETTERS:
70
+ self.assertIn(
71
+ "data/letters/public/",
72
+ entry["image"],
73
+ f"image path should be under data/letters/public/: {entry['image']}",
74
+ )
75
+
76
+ def test_cached_paths_under_gallery(self):
77
+ for entry in EXAMPLE_LETTERS:
78
+ self.assertIn(
79
+ "data/gallery/",
80
+ entry["cached"],
81
+ f"cached path should be under data/gallery/: {entry['cached']}",
82
+ )
83
+
84
+
85
+ class TestLoadExampleArity(unittest.TestCase):
86
+ """load_example returns correct arity without raising."""
87
+
88
+ def setUp(self):
89
+ """Skip if gallery JSONs don't exist yet (generated by Task 1)."""
90
+ json_path = Path(_REPO_ROOT) / EXAMPLE_LETTERS[0]["cached"]
91
+ if not json_path.exists():
92
+ self.skipTest(f"Gallery JSON not yet generated: {json_path}")
93
+
94
+ def test_returns_tuple_of_correct_arity(self):
95
+ """load_example(index=0) returns a 7-element tuple."""
96
+ evt = _make_select_event(0)
97
+ result_tuple = load_example(evt)
98
+ self.assertIsInstance(result_tuple, tuple)
99
+ self.assertEqual(
100
+ len(result_tuple),
101
+ _RENDER_RESULT_ARITY,
102
+ f"expected {_RENDER_RESULT_ARITY}-element tuple, got {len(result_tuple)}",
103
+ )
104
+
105
+ def test_all_indices(self):
106
+ """load_example works for every valid index in EXAMPLE_LETTERS."""
107
+ for i, entry in enumerate(EXAMPLE_LETTERS):
108
+ json_path = Path(_REPO_ROOT) / entry["cached"]
109
+ if not json_path.exists():
110
+ continue
111
+ with self.subTest(index=i, slug=entry["slug"]):
112
+ evt = _make_select_event(i)
113
+ tpl = load_example(evt)
114
+ self.assertEqual(len(tpl), _RENDER_RESULT_ARITY)
115
+
116
+
117
+ class TestLoadExampleNoModelCall(unittest.TestCase):
118
+ """load_example never calls run_inference_multi — zero GPU guarantee."""
119
+
120
+ def setUp(self):
121
+ json_path = Path(_REPO_ROOT) / EXAMPLE_LETTERS[0]["cached"]
122
+ if not json_path.exists():
123
+ self.skipTest(f"Gallery JSON not yet generated: {json_path}")
124
+
125
+ def test_no_model_call_when_inference_patched_to_raise(self):
126
+ """
127
+ Even if run_inference_multi would raise, load_example still succeeds.
128
+ This proves load_example reads only disk (never calls the model).
129
+ """
130
+ def _boom(*args, **kwargs):
131
+ raise RuntimeError("load_example must NOT call run_inference_multi")
132
+
133
+ with patch.object(app, "run_inference_multi", side_effect=_boom):
134
+ evt = _make_select_event(0)
135
+ # Must NOT raise RuntimeError
136
+ result_tuple = load_example(evt)
137
+ self.assertEqual(len(result_tuple), _RENDER_RESULT_ARITY)
138
+
139
+ def test_load_example_not_spaces_gpu_wrapped(self):
140
+ """
141
+ load_example must not be decorated with @spaces.GPU.
142
+ Presence of __wrapped__ or _spaces_fn_wrapped attributes indicates GPU decoration.
143
+ """
144
+ self.assertFalse(
145
+ hasattr(load_example, "__wrapped__"),
146
+ "load_example should NOT be wrapped with @spaces.GPU (__wrapped__ found)",
147
+ )
148
+ self.assertFalse(
149
+ hasattr(load_example, "_spaces_fn_wrapped"),
150
+ "load_example should NOT be wrapped with @spaces.GPU (_spaces_fn_wrapped found)",
151
+ )
152
+
153
+
154
+ class TestGalleryJsonFixtures(unittest.TestCase):
155
+ """data/gallery/*-result.json files round-trip into StructuredResult."""
156
+
157
+ def _iter_gallery_jsons(self):
158
+ """Yield (stem, dict) for each existing gallery JSON."""
159
+ for entry in EXAMPLE_LETTERS:
160
+ json_path = Path(_REPO_ROOT) / entry["cached"]
161
+ if json_path.exists():
162
+ yield entry["slug"], json.loads(json_path.read_text(encoding="utf-8"))
163
+
164
+ def test_all_fields_present(self):
165
+ found_any = False
166
+ for slug, data in self._iter_gallery_jsons():
167
+ found_any = True
168
+ with self.subTest(slug=slug):
169
+ missing = _STRUCTURED_RESULT_FIELDS - set(data.keys())
170
+ self.assertEqual(
171
+ missing,
172
+ set(),
173
+ f"{slug}: missing StructuredResult fields: {missing}",
174
+ )
175
+
176
+ if not found_any:
177
+ self.skipTest("No gallery JSONs exist yet — run Task 1 first")
178
+
179
+ def test_severity_type(self):
180
+ found_any = False
181
+ for slug, data in self._iter_gallery_jsons():
182
+ found_any = True
183
+ with self.subTest(slug=slug):
184
+ sev = data.get("severity")
185
+ self.assertTrue(
186
+ sev is None or isinstance(sev, int),
187
+ f"{slug}: severity must be int or None, got {type(sev).__name__}",
188
+ )
189
+
190
+ if not found_any:
191
+ self.skipTest("No gallery JSONs exist yet — run Task 1 first")
192
+
193
+ def test_no_quip_prefix(self):
194
+ """quip field must NOT contain 'Bureaucat says:' prefix (prevents double-prefix)."""
195
+ found_any = False
196
+ for slug, data in self._iter_gallery_jsons():
197
+ found_any = True
198
+ with self.subTest(slug=slug):
199
+ quip = data.get("quip", "")
200
+ self.assertNotIn(
201
+ "Bureaucat says:",
202
+ quip,
203
+ f"{slug}: quip contains 'Bureaucat says:' prefix — would double-prefix in UI",
204
+ )
205
+
206
+ if not found_any:
207
+ self.skipTest("No gallery JSONs exist yet — run Task 1 first")
208
+
209
+ def test_structured_result_roundtrip(self):
210
+ """StructuredResult(**data) does not raise for any gallery JSON."""
211
+ found_any = False
212
+ for slug, data in self._iter_gallery_jsons():
213
+ found_any = True
214
+ with self.subTest(slug=slug):
215
+ # Should not raise
216
+ result = StructuredResult(**data)
217
+ self.assertIsInstance(result, StructuredResult)
218
+ self.assertIsNotNone(result.raw, f"{slug}: raw field is None")
219
+
220
+ if not found_any:
221
+ self.skipTest("No gallery JSONs exist yet — run Task 1 first")
222
+
223
+
224
+ if __name__ == "__main__":
225
+ unittest.main()