Commit ·
6b5e47d
0
Parent(s):
Bureaucat — Build Small Hackathon submission (Qwen3-VL-8B, ZeroGPU, gr.Server)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .git-hooks/pre-commit +108 -0
- .gitattributes +40 -0
- .gitignore +9 -0
- README.md +91 -0
- app.py +1626 -0
- assets/mascot/allclear.png +0 -0
- assets/mascot/confused.png +0 -0
- assets/mascot/deadline.png +0 -0
- assets/mascot/idle.png +0 -0
- assets/mascot/money.png +0 -0
- assets/mascot/reading.png +0 -0
- assets/mascot/verifying.png +0 -0
- assets/mascot/wrong_document.png +0 -0
- data/gallery/csn-aterkrav-result.json +11 -0
- data/gallery/forsakringskassan-komplettering-result.json +11 -0
- data/gallery/migrationsverket-uppehallstillstand-result.json +11 -0
- data/gallery/skatteverket-slutskattebesked-result.json +11 -0
- data/gallery/vardcentral-kallelse-result.json +11 -0
- data/letters/generate_synthetic_gold.py +270 -0
- data/letters/public/adversarial/blurry_unreadable.json +8 -0
- data/letters/public/adversarial/blurry_unreadable.png +0 -0
- data/letters/public/adversarial/generate_adversarial_fixtures.py +256 -0
- data/letters/public/adversarial/non_letter_receipt.json +8 -0
- data/letters/public/adversarial/non_letter_receipt.png +0 -0
- data/letters/public/adversarial/non_swedish_english.json +9 -0
- data/letters/public/adversarial/non_swedish_english.png +0 -0
- data/letters/public/csn-aterkrav.json +21 -0
- data/letters/public/csn-aterkrav.png +0 -0
- data/letters/public/forsakringskassan-komplettering.json +16 -0
- data/letters/public/forsakringskassan-komplettering.png +0 -0
- data/letters/public/migrationsverket-uppehallstillstand.json +16 -0
- data/letters/public/migrationsverket-uppehallstillstand.png +0 -0
- data/letters/public/skatteverket-slutskattebesked.json +21 -0
- data/letters/public/skatteverket-slutskattebesked.png +0 -0
- data/letters/public/vardcentral-kallelse.json +21 -0
- data/letters/public/vardcentral-kallelse.png +0 -0
- eval/cuda_parity.ipynb +318 -0
- eval/grounded.py +146 -0
- eval/regen_gallery.py +161 -0
- eval/run_eval.py +493 -0
- eval/test_eval_matching.py +475 -0
- eval/test_grounded.py +166 -0
- eval/test_parse_output.py +273 -0
- frontend/app.js +788 -0
- frontend/index.html +133 -0
- frontend/style.css +446 -0
- requirements.txt +25 -0
- tests/__init__.py +0 -0
- tests/test_doctype_refusal.py +322 -0
- 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} — {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 & 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 |
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 & 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 & 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()
|