Spaces:
Running on Zero
Running on Zero
| import os | |
| # MPS lacks a few VLM ops in bf16; let those individual ops fall back to CPU | |
| # instead of hard-crashing. Must be set BEFORE torch is imported. | |
| os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1") | |
| import json | |
| import time | |
| from pathlib import Path | |
| import gradio as gr | |
| import pypdfium2 as pdfium # PDF → PIL render, server-side (INPUT-02, no system binary needed) | |
| import spaces # ZeroGPU decorator; a harmless no-op when running locally | |
| import torch | |
| from transformers import ( | |
| AutoProcessor, | |
| Qwen3VLForConditionalGeneration, | |
| # Actual class name in transformers 5.10.2 is Qwen2_5_VLForConditionalGeneration | |
| # Aliased to Qwen2_5VLForConditionalGeneration to match the bake-off contract. | |
| Qwen2_5_VLForConditionalGeneration as Qwen2_5VLForConditionalGeneration, | |
| ) | |
| # eval/grounded.py is stdlib-only; importing it never triggers model loading. | |
| # Used to drive the verifying mascot state (D2-05) — pure Python check, no GPU. | |
| from eval.grounded import check_no_invention # noqa: F401 (used in Phase 2 UI plan) | |
| # --------------------------------------------------------------------------- | |
| # Device / dtype — SPACE_ID sentinel (D-02 ratified 2026-06-05) | |
| # The is_available() CUDA check is NOT reliable at module scope on ZeroGPU: | |
| # CUDA emulation is active at import time but that check may return False. | |
| # SPACE_ID is set on all HF Spaces; absent locally → MPS or CPU. | |
| # --------------------------------------------------------------------------- | |
| if os.getenv("SPACE_ID"): | |
| DEVICE = "cuda" | |
| elif torch.backends.mps.is_available(): | |
| DEVICE = "mps" | |
| else: | |
| DEVICE = "cpu" | |
| DTYPE = torch.float32 if DEVICE == "cpu" else torch.bfloat16 | |
| print(f"[Bureaucat] device={DEVICE} dtype={DTYPE}") | |
| # --------------------------------------------------------------------------- | |
| # Model-agnostic bake-off loader (D-16) | |
| # Switch between candidates by setting BUREAUCAT_MODEL env var. | |
| # IMAGE_PATCH_SIZE: 16 for Qwen3-VL (patch_size=16), 14 for Qwen2.5-VL. | |
| # --------------------------------------------------------------------------- | |
| MODEL_VARIANTS = { | |
| "qwen3": { | |
| "model_id": "Qwen/Qwen3-VL-8B-Instruct", | |
| "model_class": Qwen3VLForConditionalGeneration, | |
| "image_patch_size": 16, | |
| }, | |
| "qwen25": { | |
| "model_id": "Qwen/Qwen2.5-VL-7B-Instruct", | |
| "model_class": Qwen2_5VLForConditionalGeneration, | |
| "image_patch_size": 14, | |
| }, | |
| } | |
| # LOCKED MODEL (MODEL-01, Plan 01-04 bake-off, 2026-06-05): "qwen3" = Qwen3-VL-8B-Instruct. | |
| # Verdict: on the calibrated 5-letter gold gate, qwen3 passed 5/5 (zero invented values, | |
| # 100% recall both standard+beginner, severity parseable everywhere); the smaller | |
| # Qwen2.5-VL-7B passed only 3/5 (dropped case/dossier reference numbers on the standard | |
| # pass). "Smallest model that passes wins" → the smaller model does not pass, so qwen3 is | |
| # locked. Default read from env so the harness can still drive qwen25; the Space does NOT | |
| # set BUREAUCAT_MODEL → production runs qwen3. | |
| MODEL_VARIANT = os.getenv("BUREAUCAT_MODEL", "qwen3") | |
| # Resolve patch size once at module scope so run_inference can reference it. | |
| IMAGE_PATCH_SIZE = MODEL_VARIANTS[MODEL_VARIANT]["image_patch_size"] | |
| # Max new tokens: raised 1200 -> 1600 (Phase 3, MODEL-01 amendment 2026-06-06). The | |
| # Phase-1 lock at 1200 was established under stochastic sampling and a single output | |
| # language. The Phase-3 5x5 multilingual matrix surfaced truncation on the token-heaviest | |
| # combination (Hindi + beginner mode: Devanagari is token-dense and beginner mode adds | |
| # inline explanations) — csn-aterkrav hit exactly 1200 mid-output and dropped the SEVERITY | |
| # line. Under greedy decoding (see run_inference_multi) that worst case lands at ~1068 | |
| # tokens, so 1600 gives comfortable (~530-token) headroom across all five languages. | |
| # Latency note: more tokens => longer generation; the D-03 <40s ZeroGPU budget (DEFERRED) | |
| # must be re-checked on the Space now that the ceiling is higher. | |
| MAX_NEW_TOKENS = 1600 | |
| # --------------------------------------------------------------------------- | |
| # Page-cap constants for multi-image input (INPUT-01) | |
| # DEFERRED tuning targets (D-03): the <40s ZeroGPU latency check is not measurable | |
| # on local Apple MPS. Tune after measuring real inference time on the dev Space. | |
| # --------------------------------------------------------------------------- | |
| MAX_PAGES_SOFT = 3 # Warn user above this count (soft advisory threshold) | |
| MAX_PAGES_HARD = 5 # Hard cap: truncate to this many pages before inference | |
| # Vision-token budget (single knob — keep processor + per-image content in sync). | |
| # LOCKED at 1280×28×28 ≈ 1M px: a 1024×28×28 speed experiment (2026-06-12) | |
| # FAILED the eval gate — 4/5 gold recall and the non-Swedish fixture was | |
| # analyzed instead of refused. Any change MUST re-pass the full gate | |
| # (python -m eval.run_eval --model qwen3) before shipping. | |
| MIN_PIXELS = 256 * 28 * 28 # ~200K pixel budget floor (OCR-safe) | |
| MAX_PIXELS = 1280 * 28 * 28 # ~1M pixel budget ceiling (doc images) | |
| def pdf_to_images(pdf_bytes: bytes, max_pages: int = MAX_PAGES_HARD, dpi: int = 200) -> list: | |
| """ | |
| Render the first ≤max_pages pages of a PDF to PIL Images at the given DPI. | |
| Accepts bytes only — never a filesystem path (D3-09 in-memory constraint / T-03-05 | |
| path-traversal mitigation). 200 DPI renders A4 to ~1654×2339 px, within the | |
| qwen-vl-utils max_pixels budget of 1280×28×28 ≈ 1M px (D3-10). | |
| Returns a list[PIL.Image.Image] (possibly empty if the PDF has no pages). | |
| Raises PdfiumError on corrupt / malformed input (caller wraps in try/except). | |
| """ | |
| pdf = pdfium.PdfDocument(pdf_bytes) | |
| images = [] | |
| for i in range(min(len(pdf), max_pages)): | |
| images.append(pdf[i].render(scale=dpi / 72.0).to_pil()) | |
| return images | |
| def load_model(variant: str): | |
| """ | |
| Load model + processor for the given variant key ("qwen3" or "qwen25"). | |
| Applies: | |
| - AutoProcessor with pixel budget controls | |
| - attn_implementation="sdpa" (safe built-in, avoids flash-attn dependency) | |
| - dtype=DTYPE (keeps working baseline kwarg; NOT torch_dtype) | |
| - unconditional .to(DEVICE) — ZeroGPU emulation layer requires .to(), not | |
| the accelerate multi-device dispatch path | |
| - model.eval() | |
| Returns (model, processor). | |
| """ | |
| v = MODEL_VARIANTS[variant] | |
| model_id = v["model_id"] | |
| model_class = v["model_class"] | |
| print(f"[Bureaucat] loading {model_id} ...") | |
| print("[Bureaucat] (first run downloads weights to ~/.cache/huggingface)") | |
| proc = AutoProcessor.from_pretrained( | |
| model_id, | |
| min_pixels=MIN_PIXELS, | |
| max_pixels=MAX_PIXELS, | |
| ) | |
| mdl = model_class.from_pretrained( | |
| model_id, | |
| dtype=DTYPE, | |
| attn_implementation="sdpa", | |
| ) | |
| mdl = mdl.to(DEVICE) | |
| mdl.eval() | |
| print("[Bureaucat] model ready.") | |
| return mdl, proc | |
| # --------------------------------------------------------------------------- | |
| # TEST ESCAPE HATCH | |
| # When BUREAUCAT_NO_MODEL is set (e.g. by unit tests), skip the heavy load. | |
| # The Space and the bake-off do NOT set this var, so the D-02 module-scope | |
| # cuda load still happens in production. | |
| # --------------------------------------------------------------------------- | |
| if os.getenv("BUREAUCAT_NO_MODEL"): | |
| model = None | |
| processor = None | |
| else: | |
| model, processor = load_model(MODEL_VARIANT) | |
| # --------------------------------------------------------------------------- | |
| # Output schema + parser (D-04–D-08) | |
| # --------------------------------------------------------------------------- | |
| import re | |
| from dataclasses import dataclass | |
| from typing import Optional | |
| class StructuredResult: | |
| transcription: str # raw verbatim OCR; used by eval harness (D-04) | |
| quip: str # "Bureaucat says:" value (D-07) | |
| tldr: str | |
| why: str | |
| actions: str | |
| deadlines: str | |
| severity: Optional[int] # None if output truncated (D-06) | |
| raw: str # full raw model output | |
| doctype: str = "letter" # DOCTYPE sentinel: letter | unreadable | not_letter | non_swedish (D3-01) | |
| SECTION_ANCHORS = [ | |
| ("tldr", r"##\s*TL;?DR"), | |
| ("why", r"##\s*Why you got this"), | |
| ("actions", r"##\s*What you need to do"), | |
| ("deadlines", r"##\s*Deadlines\s*&\s*money"), | |
| ] | |
| def _split_sections(text: str) -> dict: | |
| """Split body text into four sections by fixed Markdown heading anchors (D-05).""" | |
| result = {} | |
| for i, (key, pattern) in enumerate(SECTION_ANCHORS): | |
| m = re.search(pattern, text, re.IGNORECASE) | |
| if not m: | |
| continue | |
| start = m.end() | |
| if i + 1 < len(SECTION_ANCHORS): | |
| next_m = re.search(SECTION_ANCHORS[i + 1][1], text, re.IGNORECASE) | |
| end = next_m.start() if next_m else len(text) | |
| else: | |
| end = len(text) | |
| result[key] = text[start:end].strip() | |
| return result | |
| DOCTYPE_RE = re.compile( | |
| r'^DOCTYPE:\s*(letter|unreadable|not_letter|non_swedish)', | |
| re.MULTILINE | re.IGNORECASE, | |
| ) | |
| def parse_output(raw: str) -> StructuredResult: | |
| """ | |
| Parse raw model output into a StructuredResult. | |
| Language-invariant: anchors on fixed English sentinels regardless of | |
| the prose language. Never raises — returns empty fields and severity=None | |
| on malformed/truncated output (T-02-02). | |
| DOCTYPE sentinel (D3-01): extracted after <transcription> block, before section | |
| split. Regex accepts only the four enumerated tokens; any other/absent value | |
| defaults to "letter" (D3-02 lean-toward-analyzing). | |
| """ | |
| if not raw: | |
| return StructuredResult( | |
| transcription="", quip="", tldr="", why="", | |
| actions="", deadlines="", severity=None, raw=raw, | |
| doctype="letter", | |
| ) | |
| # 1. Extract and strip the <transcription> block (D-04). | |
| transcription = "" | |
| trans_match = re.search( | |
| r"<transcription>(.*?)</transcription>", | |
| raw, re.DOTALL | re.IGNORECASE | |
| ) | |
| if trans_match: | |
| transcription = trans_match.group(1).strip() | |
| after_trans = re.sub( | |
| r"<transcription>.*?</transcription>", "", raw, | |
| flags=re.DOTALL | re.IGNORECASE | |
| ).strip() | |
| # 2. Parse DOCTYPE: first matching DOCTYPE line (D3-01). Accepts only the four | |
| # enumerated tokens; defaults to "letter" if absent or unrecognised (D3-02). | |
| doctype_m = DOCTYPE_RE.search(after_trans) | |
| doctype = doctype_m.group(1).lower() if doctype_m else "letter" | |
| # Strip the DOCTYPE line from body so it does not leak into section text. | |
| after_trans = re.sub( | |
| r'^DOCTYPE:\s*\S+\s*\n?', "", after_trans, flags=re.MULTILINE | re.IGNORECASE | |
| ).strip() | |
| # 3. Parse severity: LAST matching SEVERITY: N line (D-06). The schema puts | |
| # SEVERITY on the final line; taking the last match means a stray earlier | |
| # "SEVERITY:" mention in prose never mis-drives the Panic Meter (WR-02). | |
| severity = None | |
| sev_matches = re.findall(r"SEVERITY:\s*([1-5])\s*$", after_trans, re.MULTILINE) | |
| if sev_matches: | |
| severity = int(sev_matches[-1]) | |
| body = re.sub(r"\nSEVERITY:\s*[1-5]\s*$", "", after_trans, flags=re.MULTILINE).strip() | |
| # 4. Parse the "Bureaucat says:" quip (D-07). | |
| quip = "" | |
| quip_match = re.search(r"Bureaucat says:\s*(.+?)(?:\n|$)", body) | |
| if quip_match: | |
| quip = quip_match.group(1).strip() | |
| # 5. Split four sections by fixed English headings (D-05, Pitfall 3). | |
| sections = _split_sections(body) | |
| return StructuredResult( | |
| transcription=transcription, | |
| quip=quip, | |
| tldr=sections.get("tldr", ""), | |
| why=sections.get("why", ""), | |
| actions=sections.get("actions", ""), | |
| deadlines=sections.get("deadlines", ""), | |
| severity=severity, | |
| raw=raw, | |
| doctype=doctype, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Prompt (D-04–D-08) | |
| # --------------------------------------------------------------------------- | |
| SYSTEM_PROMPT = """\ | |
| You are Bureaucat, an assistant that helps expats understand Swedish official letters. | |
| IMPORTANT — output format rules (do not deviate): | |
| 1. First, write a full verbatim OCR transcription of the letter wrapped in XML tags: | |
| <transcription> | |
| [exact text of the letter, every word, number, date] | |
| </transcription> | |
| 2. Immediately after </transcription>, classify the document. Write exactly this line | |
| (always in English, never translated, machine-parsed — do not translate it): | |
| DOCTYPE: [letter|unreadable|not_letter|non_swedish] | |
| Valid values: | |
| - letter = readable Swedish authority or institutional document (analysable) | |
| - unreadable = too blurry, dark, or low-resolution to read reliably | |
| - not_letter = readable image but NOT a letter (e.g. photo, receipt, form, ID card) | |
| - non_swedish = document's PRIMARY language is not Swedish | |
| (a mostly-Swedish letter with embedded English phrases is still: letter) | |
| When uncertain, use: letter | |
| IMPORTANT: if DOCTYPE is not "letter", you MUST: | |
| - Still write the "Bureaucat says:" quip line (in-voice, playful, one-liner) | |
| - Still write the DOCTYPE line | |
| - SKIP the four ## sections (TL;DR, Why you got this, What you need to do, Deadlines & money) | |
| - SKIP the SEVERITY line | |
| 3. Then write exactly this line (always in English, always playful): | |
| Bureaucat says: [your witty one-liner about this letter] | |
| 4. If DOCTYPE is "letter", write the four sections using EXACTLY these English headings: | |
| ## TL;DR | |
| ## Why you got this | |
| ## What you need to do | |
| ## Deadlines & money | |
| (In "Deadlines & money" you MUST list EVERY date, EVERY amount of money, AND | |
| EVERY reference number found anywhere in the letter. Reference numbers include | |
| case numbers (ärendenummer), file/dossier numbers (dossiernummer), booking | |
| numbers (bokningsnummer), and OCR/payment numbers — list them here EVEN IF the | |
| letter has no payment or deadline. Never leave a reference number out of this | |
| section. | |
| Write one item per line. Start each line with the verbatim value from the letter, | |
| then add a dash and your interpretation. Examples: | |
| - 15 juni 2026 — last day to file your tax return | |
| - 1 234 kr — amount to pay | |
| - 9988776 — case number (ärendenummer) | |
| Only write "None found." if the letter contains no dates, amounts, OR reference | |
| numbers of any kind.) | |
| 5. If DOCTYPE is "letter", add a last line, always in English, never translated: | |
| SEVERITY: [1-5] | |
| Rate how worried the reader should be using the FULL 1-5 range. Do NOT default | |
| to 3 — most letters are NOT a 3. Pick the single number that best fits THIS | |
| letter's real-world stakes: | |
| - 1 = purely informational; nothing to do, no deadline, no money owed | |
| (a confirmation, a receipt, an FYI notice) | |
| - 2 = minor or routine action, low stakes, soft/distant or no hard deadline | |
| (book or attend a routine appointment, a small optional fee) | |
| - 3 = a genuine task with a clear deadline OR a modest amount to pay; manageable | |
| - 4 = a significant amount owed, OR a firm deadline whose miss has real | |
| consequences (a repayment demand, a required document submission) | |
| - 5 = urgent and high-stakes: a large sum, an imminent deadline, or a severe | |
| consequence such as rejection, debt collection, eviction, or loss of a | |
| permit / residence status | |
| Hard rules (these OVERRIDE any instinct to pick 3): | |
| - If the letter warns of rejection, having to leave Sweden, losing a permit, | |
| residence status, or benefit, debt collection, or eviction → SEVERITY 5. | |
| - If the letter demands repayment of a specific sum, or sets a firm deadline | |
| to submit documents or the case is closed/denied → SEVERITY at least 4. | |
| - Use 3 only for a routine task with a clear but low-consequence deadline. | |
| - Use 1-2 for informational notices and routine appointments with no real risk. | |
| Write all prose in the language requested in the user's message. Keep the four | |
| section headings, the DOCTYPE line, and the SEVERITY line in English, never translated. | |
| Quote all extracted values (dates, amounts, reference numbers) verbatim as they | |
| appear in the letter — never invent, approximate, or omit them. | |
| If something is unclear, say so. Do not invent details.""" | |
| def build_user_prompt(language: str, beginner_mode: bool) -> str: | |
| """ | |
| Build the per-call user prompt. | |
| beginner_mode adds ONLY inline-explanation guidance within prose — it never | |
| adds/removes sections or alters the SEVERITY line or transcription block (D-08). | |
| """ | |
| # Reference-completeness reminder lives in the BASE prompt (both modes). The Phase-3 | |
| # 5x5 matrix surfaced the model dropping a clearly-labelled reference number from | |
| # "Deadlines & money" — writing "None found." with e.g. "Ärendenummer: 9988776" sitting | |
| # in the transcription. Under greedy decoding this is deterministic (sampling had merely | |
| # masked it). The model's *default* is to omit references it doesn't tie to a deadline or | |
| # amount; this reminder re-anchors the Finding-3 completeness rule per-call without | |
| # touching the SYSTEM_PROMPT. It is mode-independent (standard mode failed too), so it | |
| # belongs in the base prompt, not the beginner branch. NOTE: this is a prompt-level | |
| # mitigation of a genuine model fragility — a deterministic transcription→Deadlines | |
| # cross-check is the more robust follow-up (tracked for the user). | |
| prompt = ( | |
| f"Please analyse this letter and respond in {language}." | |
| "\n\nBefore you write the 'Deadlines & money' section, re-scan the full " | |
| "transcription and list EVERY date, EVERY amount, AND EVERY reference number " | |
| "(ärendenummer, dossiernummer, bokningsnummer, OCR-nummer) that appears anywhere " | |
| "in the letter — each on its own line, verbatim. A reference number must be listed " | |
| "even when it has no associated deadline or amount. Only write 'None found.' if " | |
| "there is truly no date, amount, or reference number of any kind in the letter." | |
| ) | |
| if beginner_mode: | |
| prompt += ( | |
| "\n\nBeginner mode: within each section's prose, add brief " | |
| "parenthetical explanations of Swedish institutions or terms " | |
| "(e.g. Skatteverket, personnummer, OCR-nummer, etc.). " | |
| "Do not add new sections." | |
| ) | |
| return prompt | |
| # --------------------------------------------------------------------------- | |
| # Inference — primary multi-image path + backward-compat single-image wrapper | |
| # --------------------------------------------------------------------------- | |
| def run_inference_multi( | |
| images: list, | |
| language: str, | |
| beginner_mode: bool, | |
| mdl, | |
| proc, | |
| image_patch_size: int, | |
| ) -> StructuredResult: | |
| """ | |
| Encode-generate-parse for one or more images in a single inference call. | |
| This is the single shared inference path for both the eval harness and the | |
| Gradio UI (INPUT-01 multi-page). run_inference() delegates here so there | |
| is exactly one code path — no drift between harness and app. | |
| process_vision_info is imported lazily so importing app under | |
| BUREAUCAT_NO_MODEL=1 never requires qwen_vl_utils. | |
| """ | |
| from qwen_vl_utils import process_vision_info # lazy: not needed for parse_output tests | |
| image_content = [ | |
| { | |
| "type": "image", | |
| "image": img, | |
| "min_pixels": MIN_PIXELS, | |
| "max_pixels": MAX_PIXELS, | |
| } | |
| for img in images | |
| ] | |
| messages = [ | |
| {"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT}]}, | |
| {"role": "user", "content": image_content + [ | |
| {"type": "text", "text": build_user_prompt(language, beginner_mode)}, | |
| ]}, | |
| ] | |
| chat_text = proc.apply_chat_template( | |
| messages, tokenize=False, add_generation_prompt=True | |
| ) | |
| image_inputs, video_inputs = process_vision_info( | |
| messages, image_patch_size=image_patch_size | |
| ) | |
| inputs = proc( | |
| text=[chat_text], | |
| images=image_inputs, | |
| videos=video_inputs, | |
| do_resize=False, | |
| return_tensors="pt", | |
| ).to(DEVICE) | |
| inputs.pop("token_type_ids", None) # Required for Qwen3-VL; harmless on Qwen2.5-VL | |
| # Sanity log: if the image didn't become pixel tensors, the model is | |
| # "reading" a blank page — that would look like plausible nonsense, not an error. | |
| pv = inputs.get("pixel_values") | |
| if pv is None: | |
| return StructuredResult( | |
| transcription="", quip="", tldr="", why="", actions="", deadlines="", | |
| severity=None, raw="ERROR: pixel_values missing from inputs", | |
| ) | |
| print(f"[Bureaucat] pixel_values={tuple(pv.shape)}") | |
| # Greedy decoding (do_sample=False), pinned deliberately (MODEL-01 amendment, | |
| # Phase 3). The shipped Qwen3-VL generation_config defaults to do_sample=true / | |
| # temperature=0.7 / top_p=0.8 / top_k=20 — i.e. stochastic sampling. For a tool | |
| # whose core promise is faithful, never-invented extraction, sampling is the wrong | |
| # regime: it makes the same letter yield different outputs run-to-run (the source of | |
| # the Phase-3 matrix's intermittent beginner-mode failures) and raises invention risk. | |
| # Greedy is deterministic and follows the fixed output-format rules more faithfully. | |
| # NOTE: repetition_penalty / no_repeat_ngram_size are NOT applied to the primary | |
| # pass — they regressed the gold gate (5/5 → 0/5: no_repeat_ngram_size kills the | |
| # legitimately-repeating "- value — label" deadline list, emptying whole sections in | |
| # beginner mode) and the adversarial refusal pass. Pure greedy stays the primary path. | |
| with torch.no_grad(): | |
| out_ids = mdl.generate( | |
| **inputs, | |
| max_new_tokens=MAX_NEW_TOKENS, | |
| do_sample=False, | |
| ) | |
| trimmed = [o[len(i):] for i, o in zip(inputs["input_ids"], out_ids)] | |
| raw_text = proc.batch_decode( | |
| trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False | |
| )[0] | |
| result = parse_output(raw_text) | |
| # SEVERITY-recovery fallback (gate-safe by construction). On very dense real letters | |
| # (long reference-number lists), pure greedy can fall into a degenerate repetition loop | |
| # in "Deadlines & money" and exhaust the token budget BEFORE emitting the trailing | |
| # SEVERITY line → severity=None → the Panic Meter shows "unclear" on an otherwise | |
| # perfectly-read letter. When that happens (letter parsed WITH content but no severity), | |
| # make ONE tiny, bounded text-only follow-up call asking only for the severity, scored | |
| # from the transcription the model already produced. It is capped at a handful of tokens | |
| # so it physically cannot loop (no long list to emit) — unlike a full re-generation, | |
| # which on the densest letters loops again. This never runs on the gold set (those always | |
| # emit SEVERITY on pass one), so it cannot affect the eval gate. | |
| if ( | |
| result.doctype == "letter" | |
| and result.severity is None | |
| and (result.tldr or result.why or result.actions or result.deadlines) | |
| ): | |
| print("[Bureaucat] SEVERITY missing (dense-letter loop) — bounded severity-only retry") | |
| result.severity = _recover_severity(result, mdl, proc) | |
| if result.severity is not None: | |
| print(f"[Bureaucat] recovered severity={result.severity}") | |
| return result | |
| # Compact rubric for the severity-only recovery call — mirrors the SYSTEM_PROMPT scale | |
| # and hard rules so a recovered severity matches what the primary pass would have produced. | |
| 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. | |
| - 1 = purely informational; nothing to do, no deadline, no money owed (a refund, a confirmation, an FYI) | |
| - 2 = minor or routine action, low stakes (a routine appointment, a small fee) | |
| - 3 = a genuine task with a clear deadline OR a modest amount to pay; manageable | |
| - 4 = a significant amount owed, OR a firm deadline whose miss has real consequences (a repayment demand, a required document submission) | |
| - 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) | |
| 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. | |
| Reply with EXACTLY one line and nothing else: SEVERITY: [1-5]""" | |
| def _recover_severity(result, mdl, proc) -> Optional[int]: | |
| """Bounded, loop-proof text-only severity classification from already-extracted text. | |
| Uses the model's own transcription (falling back to the parsed sections) as context. | |
| Returns an int 1-5, or None if the model still does not emit a parseable line. | |
| """ | |
| context = result.transcription.strip() | |
| if not context: | |
| context = "\n\n".join( | |
| s for s in (result.tldr, result.why, result.actions, result.deadlines) if s | |
| ).strip() | |
| if not context: | |
| return None | |
| messages = [ | |
| {"role": "system", "content": [{"type": "text", "text": SEVERITY_ONLY_PROMPT}]}, | |
| {"role": "user", "content": [{"type": "text", "text": ( | |
| "Swedish authority letter (already transcribed):\n\n" | |
| + context | |
| + "\n\nOutput only: SEVERITY: [1-5]" | |
| )}]}, | |
| ] | |
| chat_text = proc.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) | |
| sev_inputs = proc(text=[chat_text], return_tensors="pt").to(DEVICE) | |
| sev_inputs.pop("token_type_ids", None) | |
| with torch.no_grad(): | |
| sev_ids = mdl.generate(**sev_inputs, max_new_tokens=12, do_sample=False) | |
| sev_trimmed = [o[len(i):] for i, o in zip(sev_inputs["input_ids"], sev_ids)] | |
| sev_raw = proc.batch_decode(sev_trimmed, skip_special_tokens=True)[0] | |
| m = re.search(r"SEVERITY:\s*([1-5])", sev_raw) | |
| if not m: | |
| m = re.search(r"\b([1-5])\b", sev_raw) # last-ditch: a bare digit | |
| return int(m.group(1)) if m else None | |
| def run_inference( | |
| image, | |
| language: str, | |
| beginner_mode: bool, | |
| mdl, | |
| proc, | |
| image_patch_size: int, | |
| ) -> StructuredResult: | |
| """ | |
| Single-image entry point — delegates to run_inference_multi([image], ...). | |
| Preserved for backward compatibility so eval/run_eval.py calls (lines 329, 335) | |
| continue to work without modification. Guarantees a single shared code path. | |
| """ | |
| return run_inference_multi([image], language, beginner_mode, mdl, proc, image_patch_size) | |
| # Accurate short duration = better queue priority; visitors only have 3.5 min/day. | |
| # duration=55: placeholder until measured on dev Space (D-03). | |
| def decode(files, language, beginner_mode) -> StructuredResult: | |
| """ | |
| Gradio entry point — accepts a gr.File payload: list[str] of temp file paths. | |
| With file_count="multiple" and type="filepath" (Gradio 6.16.0, confirmed), | |
| Gradio passes a list of temporary file paths (strings) or None when no file | |
| has been uploaded yet. | |
| Dispatches per file: | |
| - *.pdf → read to bytes immediately (T-03-05 path-traversal mitigation), render | |
| via pdf_to_images(); a corrupt/malformed PDF catches PdfiumError and | |
| returns doctype="unreadable" so it routes through the slice-1 refusal | |
| path (confused mascot, no crash) — T-03-04. | |
| - other → Image.open(path).convert("RGB") as before. | |
| All rendered pages are concatenated and capped at MAX_PAGES_HARD before | |
| being passed to run_inference_multi (unchanged). | |
| """ | |
| if not files: | |
| return StructuredResult( | |
| transcription="", quip="", tldr="", why="", | |
| actions="", deadlines="", severity=None, | |
| raw="Please upload or photograph a letter first.", | |
| ) | |
| from PIL import Image as _PILImage | |
| from io import BytesIO as _BytesIO | |
| images: list = [] | |
| for path in files: | |
| if path is None: | |
| continue | |
| # Read to bytes immediately — never trust or retain the temp path (T-03-05). | |
| # Detect type by CONTENT, not filename: the custom gr.Server frontend uploads | |
| # via gradio_client, which lands files as extensionless "blob" temp files, so | |
| # endswith(".pdf") alone misses every real PDF (→ "No valid images found." on | |
| # the error card). Sniff the %PDF magic bytes instead; extension is a fallback. | |
| try: | |
| with open(path, "rb") as fh: | |
| data = fh.read() | |
| except Exception: | |
| continue | |
| is_pdf = data[:5].startswith(b"%PDF") or path.lower().endswith(".pdf") | |
| if is_pdf: | |
| try: | |
| page_images = pdf_to_images(data) | |
| images.extend(page_images) | |
| except Exception: | |
| # Any PDF parse failure (corrupt, malicious, truncated) → refusal. | |
| return StructuredResult( | |
| transcription="", quip="", tldr="", why="", | |
| actions="", deadlines="", severity=None, | |
| doctype="unreadable", | |
| raw="Could not read the PDF. Please try a different file.", | |
| ) | |
| else: | |
| try: | |
| images.append(_PILImage.open(_BytesIO(data)).convert("RGB")) | |
| except Exception: | |
| continue # skip unreadable image files; process remaining pages | |
| if not images: | |
| return StructuredResult( | |
| transcription="", quip="", tldr="", why="", | |
| actions="", deadlines="", severity=None, | |
| raw="No valid images found.", | |
| ) | |
| # Hard page cap — truncate before inference (DEFERRED tuning target D-03) | |
| if len(images) > MAX_PAGES_HARD: | |
| images = images[:MAX_PAGES_HARD] | |
| return run_inference_multi(images, language, beginner_mode, model, processor, IMAGE_PATCH_SIZE) | |
| import html as _html | |
| # --------------------------------------------------------------------------- | |
| # Example gallery — pre-computed results for zero-GPU demo (UI-01) | |
| # Each entry: slug, display label, source image path, cached result JSON path. | |
| # Paths are hardcoded constants (T-02-04-01 mitigation: no user-supplied path). | |
| # --------------------------------------------------------------------------- | |
| # Ordered ascending by severity so the gallery tells a left→right story: | |
| # all-clear (refund) → routine → action-needed → money owed → urgent. | |
| EXAMPLE_LETTERS = [ | |
| { | |
| "slug": "skatteverket-slutskattebesked", | |
| "label": "Skatteverket — tax refund, good news (severity 1)", | |
| "image": "data/letters/public/skatteverket-slutskattebesked.png", | |
| "cached": "data/gallery/skatteverket-slutskattebesked-result.json", | |
| }, | |
| { | |
| "slug": "vardcentral-kallelse", | |
| "label": "Vårdcentral — appointment reminder (severity 2)", | |
| "image": "data/letters/public/vardcentral-kallelse.png", | |
| "cached": "data/gallery/vardcentral-kallelse-result.json", | |
| }, | |
| { | |
| "slug": "forsakringskassan-komplettering", | |
| "label": "Försäkringskassan — submit documents (severity 3)", | |
| "image": "data/letters/public/forsakringskassan-komplettering.png", | |
| "cached": "data/gallery/forsakringskassan-komplettering-result.json", | |
| }, | |
| { | |
| "slug": "csn-aterkrav", | |
| "label": "CSN — student-aid repayment demand (severity 4)", | |
| "image": "data/letters/public/csn-aterkrav.png", | |
| "cached": "data/gallery/csn-aterkrav-result.json", | |
| }, | |
| { | |
| "slug": "migrationsverket-uppehallstillstand", | |
| "label": "Migrationsverket — residence permit at risk (severity 5)", | |
| "image": "data/letters/public/migrationsverket-uppehallstillstand.png", | |
| "cached": "data/gallery/migrationsverket-uppehallstillstand-result.json", | |
| }, | |
| ] | |
| def load_example(evt: gr.SelectData) -> tuple: | |
| """ | |
| Zero-GPU gallery loader — reads cached JSON from disk, returns full render tuple. | |
| NOT decorated with @spaces.GPU. Never calls the model. | |
| Security: evt.index selects from a hardcoded EXAMPLE_LETTERS list (T-02-04-01: | |
| no user-supplied path string — load_example is immune to path traversal). | |
| Returns the same 7-element tuple as render_result() so gallery.select() and | |
| the decode .then() chain share identical output bindings. | |
| """ | |
| entry = EXAMPLE_LETTERS[evt.index] | |
| data = json.loads(Path(entry["cached"]).read_text(encoding="utf-8")) | |
| result = StructuredResult(**data) | |
| # Always render in English (gallery examples are pre-computed in English) | |
| return render_result(result, "English") | |
| # --------------------------------------------------------------------------- | |
| # Pure-Python UI renderer functions (no GPU, no model loading) | |
| # --------------------------------------------------------------------------- | |
| def render_quip(quip: str) -> str: | |
| """ | |
| Render the Bureaucat quip line as a Markdown string. | |
| Returns empty string if quip is blank (hides the component). | |
| The "Bureaucat says:" prefix is prepended HERE at render time — parse_output | |
| already strips the prefix from the stored .quip field (line 220), so never | |
| add it at parse time or you get "Bureaucat says: Bureaucat says: …". | |
| """ | |
| if not quip or not quip.strip(): | |
| return "" | |
| escaped_quip = _html.escape(quip.strip()) | |
| return f'**Bureaucat says:** *{escaped_quip}*' | |
| def render_deadlines_html(deadlines: str) -> str: | |
| """ | |
| Render the Deadlines & money section as an HTML string (for gr.HTML). | |
| Uses gr.HTML (not gr.Markdown) because Markdown sanitization strips <mark> | |
| and inline style= attributes (RESEARCH Pitfall 2, T-02-02-01 mitigated). | |
| Model text in pills is HTML-escaped before wrapping (T-02-02-01). | |
| If deadlines is empty or "None found." → returns plain body text, no pill. | |
| Otherwise: each "- VALUE — interpretation" line becomes a <li> with the | |
| verbatim value highlighted in a warm amber <mark> pill. | |
| Pill spec (UI-SPEC §Deadlines & Money Card): | |
| background: #FFF3CD, color: #8B6914, padding: 4px, border-radius: 4px | |
| """ | |
| if not deadlines or not deadlines.strip(): | |
| return "<p>None found.</p>" | |
| # Check for "None found" (case-insensitive) | |
| if re.match(r"\s*none found\.?\s*$", deadlines, re.IGNORECASE): | |
| return f"<p>{_html.escape(deadlines.strip())}</p>" | |
| lines = deadlines.strip().splitlines() | |
| items = [] | |
| has_pill_line = False | |
| for raw_line in lines: | |
| line = raw_line.strip() | |
| if not line: | |
| continue | |
| # Strip leading bullet markers (-, *, •) | |
| stripped = re.sub(r"^[-*•]\s*", "", line).strip() | |
| if not stripped: | |
| continue | |
| # Check for "None found" lines | |
| if re.match(r"none found\.?$", stripped, re.IGNORECASE): | |
| items.append(f"<li>{_html.escape(stripped)}</li>") | |
| continue | |
| # Split on first interpretation separator (same convention as extract_values_from_section) | |
| sep_match = re.search(r"\s+[—–\-]\s+", stripped) | |
| if sep_match: | |
| value_raw = stripped[: sep_match.start()].strip() | |
| interpretation_raw = stripped[sep_match.end():].strip() | |
| value_esc = _html.escape(value_raw) | |
| interp_esc = _html.escape(interpretation_raw) | |
| pill = ( | |
| f'<mark style="background:#FFF3CD;color:#8B6914;' | |
| f'padding:4px;border-radius:4px">{value_esc}</mark>' | |
| ) | |
| items.append(f"<li>{pill} — {interp_esc}</li>") | |
| has_pill_line = True | |
| else: | |
| # No separator — render as plain text pill (value = whole line) | |
| value_esc = _html.escape(stripped) | |
| pill = ( | |
| f'<mark style="background:#FFF3CD;color:#8B6914;' | |
| f'padding:4px;border-radius:4px">{value_esc}</mark>' | |
| ) | |
| items.append(f"<li>{pill}</li>") | |
| has_pill_line = True | |
| if not items: | |
| return "<p>None found.</p>" | |
| if not has_pill_line: | |
| # Only "None found" lines — render as plain paragraph(s) | |
| return "<p>" + " ".join( | |
| _html.escape(re.sub(r"^[-*•]\s*", "", l.strip()).strip()) | |
| for l in lines if l.strip() | |
| ) + "</p>" | |
| return ( | |
| '<ul style="list-style:none;padding:0;margin:0">' | |
| + "".join(items) | |
| + "</ul>" | |
| ) | |
| def render_footer() -> str: | |
| """ | |
| Always-visible footer HTML containing both required trust statements. | |
| TRUST-01: privacy note (nothing stored, no external APIs) | |
| TRUST-05: legal disclaimer (not legal advice) | |
| """ | |
| return ( | |
| '<div class="bcat-footer">' | |
| "<p>Everything runs on a small model inside this Space. " | |
| "Nothing is sent to external APIs. " | |
| "Nothing is stored after your session ends.</p>" | |
| "<p>Bureaucat explains letters — it does not give legal advice. " | |
| "For legal matters, consult a qualified professional.</p>" | |
| "</div>" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Mascot assets map — Phase 2 wires 6 states; Phase 3 adds confused/wrong_document | |
| # --------------------------------------------------------------------------- | |
| MASCOT_ASSETS = { | |
| "idle": "assets/mascot/idle.png", | |
| "reading": "assets/mascot/reading.png", | |
| "verifying": "assets/mascot/verifying.png", | |
| "allclear": "assets/mascot/allclear.png", | |
| "deadline": "assets/mascot/deadline.png", | |
| "money": "assets/mascot/money.png", | |
| "confused": "assets/mascot/confused.png", # Phase 3: unreadable input | |
| "wrong_document": "assets/mascot/wrong_document.png", # Phase 3: not_letter / non_swedish | |
| } | |
| # Result-state detection regexes (D2-06 — do not change without plan approval) | |
| # Money: numeric amount followed by kr / SEK / currency symbol | |
| _MONEY_RE = re.compile(r'\d[\d\s]*(?:kr|SEK|€|\$|£)', re.IGNORECASE) | |
| # Date: "dd Month yyyy" (Swedish month names) OR ISO "yyyy-mm-dd" | |
| _DATE_RE = re.compile( | |
| r'\b\d{1,2}\s+(?:jan|feb|mar|apr|maj|jun|jul|aug|sep|okt|nov|dec)\w*\s+\d{4}\b' | |
| r'|\b\d{4}-\d{2}-\d{2}\b', | |
| re.IGNORECASE, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Renderers — Plan 03 fills bodies | |
| # (wired in Plan 02 so the .then() chain is complete; implementations in Plan 03) | |
| # --------------------------------------------------------------------------- | |
| _SEVERITY_COLORS = { | |
| 1: "#27AE60", | |
| 2: "#8BC34A", | |
| 3: "#F39C12", | |
| 4: "#E67E22", | |
| 5: "#C0392B", | |
| } | |
| _SEVERITY_LABELS = { | |
| 1: "1 — Informational", | |
| 2: "2 — Low concern", | |
| 3: "3 — Action needed", | |
| 4: "4 — Urgent", | |
| 5: "5 — Act immediately", | |
| } | |
| def render_panic_meter(severity) -> str: | |
| """Return HTML for the Panic Meter — a color-coded *verdict badge* (FUN-01). | |
| severity int 1-5: solid color-tinted badge showing a big "{N}/5" + the | |
| word label. This deliberately is NOT a horizontal fill bar — a fill bar | |
| reads as a progress/loading bar (UAT feedback, 2026-06-06). | |
| severity None: gray "Result unavailable" badge. | |
| Never color-only (accessibility, UI-SPEC line 92): the badge always shows | |
| the integer and the word, and the aria-label carries "{N} — {word}". | |
| """ | |
| if severity is None: | |
| return ( | |
| '<div class="panic-badge panic-badge--none" role="status" ' | |
| 'style="border-color:#9E9E9E" ' | |
| 'aria-label="Result unavailable — try re-uploading">' | |
| '<div class="panic-badge__word">Result unavailable — try re-uploading</div>' | |
| "</div>" | |
| ) | |
| color = _SEVERITY_COLORS.get(severity, "#9E9E9E") | |
| label = _SEVERITY_LABELS.get(severity, f"{severity}") # e.g. "3 — Action needed" | |
| word = label.split("—", 1)[1].strip() if "—" in label else label | |
| return ( | |
| f'<div class="panic-badge severity-{severity}" role="status" ' | |
| f'style="background:{color}" ' | |
| f'aria-label="Panic level: {label}">' | |
| f'<div class="panic-badge__num">{severity}<span class="panic-badge__denom">/5</span></div>' | |
| f'<div class="panic-badge__word">{word}</div>' | |
| f"</div>" | |
| ) | |
| def render_panic_placeholder() -> str: | |
| """Neutral initial state for the panic area (before any analysis) so it | |
| never reads as an empty/stalled progress bar (UAT feedback, 2026-06-06).""" | |
| return ( | |
| '<div class="panic-badge panic-badge--placeholder" role="status">' | |
| '<div class="panic-badge__word">🐾 Feed me a letter and I\'ll tell you how worried to be</div>' | |
| "</div>" | |
| ) | |
| def render_mascot(state: str) -> str: | |
| """Return HTML for the mascot gr.HTML component (img tag + CSS class). | |
| CSS class mascot-{state} drives keyframe animation (CSS-only, no JS). | |
| Unknown state falls back to idle asset. | |
| """ | |
| src = MASCOT_ASSETS.get(state, MASCOT_ASSETS["idle"]) | |
| # Gradio 6 serves allowed_paths files under /gradio_api/file= (the bare | |
| # `file=` route from older Gradio 404s on 6.x). Leading slash → resolves | |
| # from server root regardless of page path. allowed_paths=["assets"] at launch. | |
| return ( | |
| f'<img src="/gradio_api/file={src}" ' | |
| f'class="mascot-img mascot-{state}" ' | |
| f'alt="Bureaucat is {state}" />' | |
| ) | |
| def select_result_state(result) -> str: | |
| """Return the mascot result state based on StructuredResult content (D2-06). | |
| Severity 1 is genuinely good / no-stakes news (e.g. a tax REFUND) → the happy | |
| "allclear" cat, which also drives the celebratory screen effects. This takes | |
| priority over content: a refund has a money amount but is NOT alarming, so it | |
| must not get the "money" (shocked) cat. | |
| Otherwise content-priority: money > deadline > allclear. | |
| "none found" in deadlines text short-circuits to allclear immediately. | |
| """ | |
| if getattr(result, "severity", None) == 1: | |
| return "allclear" | |
| deadlines_text = result.deadlines or "" | |
| if "none found" in deadlines_text.lower(): | |
| return "allclear" | |
| if _MONEY_RE.search(deadlines_text): | |
| return "money" | |
| if _DATE_RE.search(deadlines_text): | |
| return "deadline" | |
| return "allclear" | |
| # --------------------------------------------------------------------------- | |
| # Legacy helper kept for backward compat (replaced by structured render path) | |
| # --------------------------------------------------------------------------- | |
| def _render_result(result: StructuredResult) -> str: | |
| """Extract displayable text from StructuredResult for the Markdown pane.""" | |
| return result.raw or "No output generated." | |
| # --------------------------------------------------------------------------- | |
| # CSS — Global styles (brand palette, card styles, RTL text-align rule) | |
| # Applied via demo.launch(css=GLOBAL_CSS) so it covers the full page. | |
| # DO NOT pass css= to gr.Blocks() — that emits a UserWarning in Gradio 6.x. | |
| # The [dir="rtl"] rule supplies the text-align half of the RTL contract | |
| # (UI-SPEC §Typography line 62) when gr.Markdown's rtl prop sets dir="rtl". | |
| # --------------------------------------------------------------------------- | |
| GLOBAL_CSS = """ | |
| @import url('https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Baloo+2:wght@600;700;800&display=swap'); | |
| .gradio-container { max-width: 1040px !important; margin: 0 auto !important; } | |
| /* Page background is set per-mode via the theme (.set body_background_fill*) | |
| — NOT hardcoded here, which was the dark-mode break (light body + light theme text). */ | |
| /* Hero header — chunky, cartoon, a little bouncy */ | |
| .bcat-hero { display: flex; align-items: center; gap: 16px; padding: 14px 4px 6px; } | |
| .bcat-hero__emoji { font-size: 54px; line-height: 1; animation: bcat-bounce 2.2s ease-in-out infinite; transform-origin: bottom center; } | |
| .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; } | |
| .bcat-hero__tag { font-size: 16px; color: var(--body-text-color-subdued); margin: 4px 0 0; } | |
| @keyframes bcat-bounce { 0%,100%{transform:translateY(0) scale(1)} 30%{transform:translateY(-10px) scale(1.06)} 50%{transform:translateY(0) scale(0.97)} } | |
| /* Big chunky tab labels */ | |
| .tabs button, .tab-nav button { font-family: "Fredoka", sans-serif !important; font-size: 17px !important; font-weight: 600 !important; } | |
| /* Sassy chunky call-to-action button */ | |
| .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; } | |
| .cta-btn:hover { transform: translateY(-2px) !important; box-shadow: 0 8px 0 0 rgba(0,0,0,0.14) !important; } | |
| .cta-btn:active { transform: translateY(3px) !important; box-shadow: 0 2px 0 0 rgba(0,0,0,0.14) !important; } | |
| /* Sassy quip — speech-bubble feel */ | |
| .quip-line { font-size: 17px !important; font-style: italic; margin-top: 8px !important; } | |
| .quip-line p { background: var(--background-fill-secondary); border-radius: 14px; padding: 12px 16px; margin: 0; position: relative; } | |
| /* Surfaces — theme-aware so both light and dark render correctly. | |
| gr.Group(elem_classes="section-card") IS the card; inner blocks are flattened | |
| so the heading + prose read as one surface (not nested boxes). */ | |
| .section-card, .input-card { | |
| background: var(--block-background-fill); | |
| border: 1px solid var(--border-color-primary); | |
| color: var(--body-text-color); | |
| border-radius: 14px !important; padding: 14px 18px; margin-bottom: 12px; | |
| box-shadow: var(--block-shadow, 0 1px 3px rgba(0,0,0,0.06)); | |
| overflow: hidden; | |
| } | |
| .section-card > *, .section-card .block, .section-card .form, | |
| .section-card .prose, .section-card .md { | |
| background: transparent !important; border: none !important; box-shadow: none !important; | |
| } | |
| .section-card h3 { | |
| font-size: 12px; font-weight: 700; color: var(--body-text-color); | |
| opacity: 0.55; margin: 0 0 6px 0; text-transform: uppercase; letter-spacing: 0.07em; | |
| } | |
| /* Panic verdict badge — status chip, deliberately NOT a fill/progress bar */ | |
| .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; } | |
| .panic-badge__num { font-size: 40px; font-weight: 800; line-height: 1; } | |
| .panic-badge__denom { font-size: 18px; font-weight: 600; opacity: 0.8; margin-left: 2px; } | |
| .panic-badge__word { font-size: 17px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; align-self: center; } | |
| .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; } | |
| .panic-badge--none .panic-badge__word, .panic-badge--placeholder .panic-badge__word { text-transform: none; font-weight: 600; letter-spacing: 0; font-size: 15px; } | |
| /* Bouncy pop-in when a real verdict lands (game-feel) */ | |
| @keyframes pop-in { 0%{transform:scale(0.85);opacity:0} 60%{transform:scale(1.04)} 100%{transform:scale(1);opacity:1} } | |
| .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); } | |
| /* Mascot — compact beside the verdict; bigger & bouncier on the fun tab */ | |
| .mascot-panel { text-align: center; padding: 0; } | |
| .mascot-panel img { transition: opacity 0.2s ease; } | |
| .mascot-big img { max-width: 180px !important; } | |
| .mascot-big .mascot-idle { animation: bcat-bounce 2s ease-in-out infinite; transform-origin: bottom center; } | |
| /* Footer — theme-aware trust note */ | |
| .bcat-footer { padding: 16px; border-top: 1px solid var(--border-color-primary); font-size: 13px; color: var(--body-text-color-subdued); } | |
| .bcat-footer p { margin: 4px 0; } | |
| [dir="rtl"] { text-align: right; } | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # CSS animation keyframes — injected as a gr.HTML style block (first element | |
| # inside gr.Blocks context). gr.HTML passes through <style>@keyframes unchanged | |
| # (RESEARCH Pattern 1 / Pattern 2 verified against gradio 6.16.0 source). | |
| # --------------------------------------------------------------------------- | |
| ANIMATION_CSS_HTML = """ | |
| <style> | |
| @keyframes bob { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-4px)} } | |
| @keyframes sway { 0%,100%{transform:rotate(0deg)} 50%{transform:rotate(3deg)} } | |
| @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.6} } | |
| @keyframes pop { 0%{transform:scale(1)} 50%{transform:scale(1.1)} 100%{transform:scale(1)} } | |
| @keyframes shake { 0%,100%{transform:rotate(0deg)} 25%{transform:rotate(-5deg)} 75%{transform:rotate(5deg)} } | |
| .mascot-idle { animation: bob 2s ease-in-out infinite; } | |
| .mascot-reading { animation: sway 0.8s ease-in-out infinite; } | |
| .mascot-verifying{ animation: blink 0.4s ease infinite; } | |
| .mascot-allclear { animation: pop 0.3s ease 1; } | |
| .mascot-deadline { animation: shake 0.15s ease-in-out 3; } | |
| .mascot-money { animation: shake 0.15s ease-in-out 3; } | |
| .mascot-img { transition: opacity 0.2s ease; max-width: 120px; height: auto; min-height: 44px; } | |
| </style> | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # Event handler functions (Task 2b) | |
| # These run outside the GPU context; only decode() is GPU-decorated. | |
| # --------------------------------------------------------------------------- | |
| def set_reading_state() -> str: | |
| """Fires immediately on button click — no GPU. Sets mascot to reading.""" | |
| return render_mascot("reading") | |
| def run_verifying_state(result) -> str: | |
| """ | |
| Fires after decode() completes — pure Python, no GPU. | |
| On refusals (doctype != "letter"): returns the appropriate error mascot | |
| immediately, bypassing check_no_invention (which would operate on empty | |
| values and could raise — Pitfall 2 / T-03-03). | |
| On successful reads: runs the grounded no-invention check (D2-05), then | |
| dwells for 0.6 s so the verifying mascot state is visible in the browser. | |
| Without the dwell, Gradio may coalesce this update with the preceding/ | |
| following step and the verifying state is invisible (RESEARCH Pattern 3 — | |
| NON-GPU .then() step, zero GPU quota cost). | |
| """ | |
| # Refusal guard (T-03-03): skip check_no_invention on non-letter doctypes. | |
| # render_result's own refusal branch then renders the final panes. | |
| if getattr(result, "doctype", "letter") != "letter": | |
| mascot_state = "confused" if getattr(result, "doctype", "") == "unreadable" else "wrong_document" | |
| return render_mascot(mascot_state) | |
| check_no_invention(result) # pure-Python grounded check (eval/grounded.py) | |
| time.sleep(0.6) # deliberate dwell — verifying state must be visible | |
| return render_mascot("verifying") | |
| REFUSAL_GUIDANCE = { | |
| "unreadable": ( | |
| "The photo is too blurry or dark to read. " | |
| "Retake it in better light and make sure the whole letter is in frame and in focus." | |
| ), | |
| "not_letter": ( | |
| "That's a fine image, but it doesn't look like a Swedish authority letter. " | |
| "Upload a letter from Skatteverket, Försäkringskassan, Migrationsverket, CSN, or similar." | |
| ), | |
| "non_swedish": ( | |
| "Bureaucat reads Swedish authority letters. " | |
| "This one looks like it's in another language — upload a letter written in Swedish." | |
| ), | |
| } | |
| def render_refusal(result, language: str) -> tuple: | |
| """ | |
| Render a bad-input refusal as the same 7-element tuple as render_result. | |
| Returns: | |
| [0] panic_html = "" (no Panic Meter for refusals — D3-03) | |
| [1] mascot_html = confused for unreadable; wrong_document for not_letter/non_swedish | |
| [2] quip_md = model's in-voice refusal quip (already escaped in render_quip) | |
| [3] tldr_out = app-side fixed guidance text keyed by doctype (NOT model-driven) | |
| [4] why_out = "" (no section) | |
| [5] actions_out = "" (no section) | |
| [6] deadlines_html = "" (no deadlines) | |
| Guidance copy is app-side (REFUSAL_GUIDANCE constant) — model only provides the quip. | |
| This keeps the function unit-testable without a model (BUREAUCAT_NO_MODEL=1). | |
| """ | |
| is_rtl = language == "Arabic" | |
| def _wrap_rtl_html(html_str: str) -> str: | |
| if is_rtl: | |
| return f'<div dir="rtl" style="text-align:right">{html_str}</div>' | |
| return html_str | |
| def _md_update(text: str) -> dict: | |
| return gr.update(value=text, rtl=is_rtl) | |
| doctype = getattr(result, "doctype", "not_letter") | |
| mascot_state = "confused" if doctype == "unreadable" else "wrong_document" | |
| guidance = REFUSAL_GUIDANCE.get(doctype, REFUSAL_GUIDANCE["not_letter"]) | |
| return ( | |
| "", # 0 panic_html — no Panic Meter (D3-03) | |
| render_mascot(mascot_state), # 1 mascot_html | |
| render_quip(result.quip), # 2 quip_md — model's in-voice refusal quip | |
| _md_update(guidance), # 3 tldr_out — app-side guidance (not model-driven) | |
| _md_update(""), # 4 why_out | |
| _md_update(""), # 5 actions_out | |
| _wrap_rtl_html(""), # 6 deadlines_html | |
| ) | |
| def render_result(result, language: str) -> tuple: | |
| """ | |
| Fires after run_verifying_state — renders all output panes. | |
| Returns a 7-element tuple matching the outputs order: | |
| [panic_html(0), mascot_html(1), quip_md(2), | |
| tldr_out(3), why_out(4), actions_out(5), deadlines_html(6)] | |
| RTL delivery-layer contract (UI-SPEC §Typography, T-02-02-01): | |
| - Prose panes (3, 4, 5) are gr.Markdown. Their rtl prop is toggled via | |
| gr.update(value=..., rtl=True/False) — explicit bool both ways so a | |
| component set RTL on a prior Arabic run is reset for the next English run. | |
| gr.HTML(<div dir="rtl">) would be STRIPPED by sanitize_html=True on | |
| gr.Markdown — that is the silent bug this approach avoids. | |
| - Deadlines & Panic gr.HTML (0, 6) are pass-through. For Arabic, wrap in | |
| <div dir="rtl" style="text-align:right">. For other languages, no wrapper. | |
| - Extracted verbatim values remain Swedish-locale regardless of direction. | |
| Error handling: severity=None / malformed result → 7-element tuple with error | |
| copy in tldr pane; no crash (RESEARCH Pattern 3, UI-SPEC Copywriting Contract). | |
| """ | |
| ERROR_COPY = ( | |
| "Bureaucat had trouble reading that letter. Try uploading a clearer photo." | |
| ) | |
| # Determine if RTL is needed (Arabic only) | |
| is_rtl = language == "Arabic" | |
| def _wrap_rtl_html(html_str: str) -> str: | |
| """Wrap gr.HTML content in dir=rtl div for Arabic; leave others bare.""" | |
| if is_rtl: | |
| return f'<div dir="rtl" style="text-align:right">{html_str}</div>' | |
| return html_str | |
| def _md_update(text: str) -> dict: | |
| """Return gr.update dict for a gr.Markdown prose pane with explicit rtl.""" | |
| return gr.update(value=text, rtl=is_rtl) | |
| # 1. Intentional refusal — doctype check FIRST (D3-03). | |
| # Refusals legitimately have severity=None, so this branch MUST precede | |
| # the severity=None malformed check below (routing order is load-bearing). | |
| if result is not None and getattr(result, "doctype", "letter") != "letter": | |
| return render_refusal(result, language) | |
| # 2. Malformed / error path — only when there is NOTHING to show. A letter that | |
| # produced an analysis but whose trailing SEVERITY line got lost (e.g. a greedy | |
| # repetition loop on a dense letter) still has real content — salvage it below | |
| # rather than discarding it with a false "couldn't read" error. | |
| _has_content = result is not None and ( | |
| getattr(result, "tldr", "") or getattr(result, "why", "") | |
| or getattr(result, "actions", "") or getattr(result, "deadlines", "") | |
| ) | |
| if result is None or not hasattr(result, "severity") or (result.severity is None and not _has_content): | |
| return ( | |
| render_panic_meter(None), # 0 panic_html | |
| render_mascot("idle"), # 1 mascot_html | |
| "", # 2 quip_md | |
| _md_update(ERROR_COPY), # 3 tldr_out | |
| _md_update(""), # 4 why_out | |
| _md_update(""), # 5 actions_out | |
| _wrap_rtl_html(""), # 6 deadlines_html | |
| ) | |
| # Render the result state (money / deadline / allclear) for the mascot | |
| state = select_result_state(result) | |
| return ( | |
| _wrap_rtl_html(render_panic_meter(result.severity)), # 0 panic_html | |
| render_mascot(state), # 1 mascot_html | |
| render_quip(result.quip), # 2 quip_md (always English) | |
| _md_update(result.tldr or ""), # 3 tldr_out | |
| _md_update(result.why or ""), # 4 why_out | |
| _md_update(result.actions or ""), # 5 actions_out | |
| _wrap_rtl_html(render_deadlines_html(result.deadlines)), # 6 deadlines_html | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Gradio UI — Full gr.Blocks layout (Task 2a: static structure; Task 2b: event wiring) | |
| # --------------------------------------------------------------------------- | |
| # Game-feel confetti — runs in the browser via demo.load(js=...) because a | |
| # <script> injected through gr.HTML's innerHTML never executes. Loads the tiny | |
| # canvas-confetti lib from CDN, then a MutationObserver fires a burst whenever | |
| # the all-clear mascot appears (good news only). Best-effort: no-ops if the CDN | |
| # is blocked. Zero GPU cost. | |
| CONFETTI_JS = """ | |
| () => { | |
| if (!window.__bcatConfettiLoaded) { | |
| const s = document.createElement('script'); | |
| s.src = 'https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js'; | |
| document.head.appendChild(s); | |
| window.__bcatConfettiLoaded = true; | |
| } | |
| const fire = () => { | |
| if (window.confetti) { | |
| window.confetti({ particleCount: 110, spread: 75, origin: { y: 0.65 }, | |
| colors: ['#E91E63', '#84CC16', '#FFD166', '#FFFFFF'] }); | |
| } | |
| }; | |
| if (!window.__bcatObserver) { | |
| window.__bcatObserver = new MutationObserver(() => { | |
| if (document.querySelector('.mascot-allclear')) { | |
| const now = Date.now(); | |
| if (!window.__bcatLastFire || now - window.__bcatLastFire > 3000) { | |
| window.__bcatLastFire = now; | |
| setTimeout(fire, 150); | |
| } | |
| } | |
| }); | |
| window.__bcatObserver.observe(document.body, | |
| { subtree: true, childList: true, attributes: true, attributeFilter: ['class'] }); | |
| } | |
| } | |
| """ | |
| with gr.Blocks(title="Bureaucat") as demo: | |
| # Inject animation keyframes as FIRST element (gr.HTML passes <style> unchanged) | |
| gr.HTML(ANIMATION_CSS_HTML) | |
| # Hero header | |
| gr.HTML( | |
| '<div class="bcat-hero">' | |
| '<div class="bcat-hero__emoji">🐱</div>' | |
| '<div>' | |
| '<h1 class="bcat-hero__title">Bureaucat</h1>' | |
| '<p class="bcat-hero__tag">Snap a scary Swedish letter. I\'ll tell you how worried to be — and what to do.</p>' | |
| '</div>' | |
| '</div>' | |
| ) | |
| # gr.State stores the StructuredResult between .then() steps (RESEARCH Pitfall 5) | |
| result_state = gr.State(value=None) | |
| # ---- Input bar (shared, sits above the tabs — upload front and centre) ---- | |
| with gr.Row(elem_classes="input-card", equal_height=True): | |
| with gr.Column(scale=2, min_width=240): | |
| # Combined uploader: accepts images AND PDF (INPUT-02). | |
| # gr.File with type="filepath" (Gradio 6.16.0, verified 2026-06-06) passes | |
| # list[str] of temp file paths to decode(); file_count="multiple" enables | |
| # multi-page letter upload (one image per scanned page, or one multi-page PDF). | |
| input_file = gr.File( | |
| file_count="multiple", | |
| file_types=[".jpg", ".jpeg", ".png", ".pdf"], | |
| type="filepath", | |
| label="Upload letter — image(s) or PDF", | |
| ) | |
| with gr.Column(scale=1, min_width=200): | |
| # English-only product (hackathon scope, 2026-06-07): Bureaucat reads Swedish | |
| # letters and explains them in English. The multi-language output selector was | |
| # removed — the model's non-Latin translation (Arabic/Hindi) was fragile under | |
| # deterministic greedy decoding, and English is the single supported output. | |
| # A fixed "English" flows through the .then() chain via gr.State so decode() and | |
| # render_result() keep their existing (language) signatures and tests unchanged. | |
| lang = gr.State("English") | |
| beginner = gr.Checkbox( | |
| value=True, | |
| label="I'm new to Sweden — explain the institutions & jargon", | |
| ) | |
| btn = gr.Button("🐾 Read it for me!", variant="primary", elem_classes="cta-btn") | |
| # ---- Tabs: fun & sassy by default, the rigorous breakdown one click away ---- | |
| with gr.Tabs(): | |
| # DEFAULT TAB — the fun verdict: big reacting mascot + panic badge + sass | |
| with gr.Tab("😼 How bad is it?"): | |
| with gr.Row(equal_height=True): | |
| with gr.Column(scale=1, min_width=150): | |
| mascot_html = gr.HTML( | |
| render_mascot("idle"), elem_classes="mascot-panel mascot-big" | |
| ) | |
| with gr.Column(scale=2, min_width=240): | |
| # Panic Meter verdict badge (FUN-01) | |
| panic_html = gr.HTML(render_panic_placeholder()) | |
| # Bureaucat's sassy one-liner (the personality moment) | |
| quip_md = gr.Markdown(elem_classes="quip-line") | |
| # The punchy plain-language summary lives on the fun tab | |
| with gr.Group(elem_classes="section-card"): | |
| gr.HTML("<h3>The short version</h3>") | |
| tldr_out = gr.Markdown(rtl=False) | |
| # DETAILS TAB — the actual findings | |
| with gr.Tab("🔍 The full breakdown"): | |
| with gr.Group(elem_classes="section-card"): | |
| gr.HTML("<h3>Why you got this</h3>") | |
| why_out = gr.Markdown(rtl=False) | |
| with gr.Group(elem_classes="section-card"): | |
| gr.HTML("<h3>What you need to do</h3>") | |
| actions_out = gr.Markdown(rtl=False) | |
| # Deadlines card — gr.HTML (not gr.Markdown — sanitize strips <mark> RESEARCH Pitfall 2) | |
| with gr.Group(elem_classes="section-card"): | |
| gr.HTML("<h3>Deadlines & money</h3>") | |
| deadlines_html = gr.HTML() | |
| # Example gallery row — pre-computed analyses, zero GPU cost (UI-01) | |
| with gr.Row(): | |
| with gr.Column(): | |
| gr.Markdown( | |
| "### 👇 No scary letter handy? Borrow one of mine\n\n" | |
| "_Tap any example — I've already read these, so it costs you zero GPU._" | |
| ) | |
| example_gallery = gr.Gallery( | |
| value=[entry["image"] for entry in EXAMPLE_LETTERS], | |
| label=None, | |
| show_label=False, | |
| columns=5, | |
| rows=1, | |
| height="auto", | |
| object_fit="contain", | |
| interactive=False, | |
| allow_preview=False, | |
| ) | |
| # Always-visible footer (outside any accordion, TRUST-01 + TRUST-05, D2-17) | |
| with gr.Row(): | |
| gr.HTML(render_footer()) | |
| # --------------------------------------------------------------------------- | |
| # Event chain (Task 2b) — .then() pattern (RESEARCH Pattern 3): | |
| # click → set_reading_state (no GPU, immediate mascot update) | |
| # .then → decode (GPU, stores StructuredResult in gr.State) | |
| # .then → run_verifying_state (pure Python, 0.6 s dwell, verifying mascot) | |
| # .then → render_result (pure Python, renders all output panes) | |
| # | |
| # decode is called DIRECTLY as event handler — NOT wrapped in a generator | |
| # (RESEARCH Anti-Patterns: generator wrapping may prevent ZeroGPU recognition). | |
| # --------------------------------------------------------------------------- | |
| dep = btn.click( | |
| set_reading_state, | |
| inputs=None, | |
| outputs=mascot_html, | |
| ) | |
| dep2 = dep.then( | |
| decode, | |
| inputs=[input_file, lang, beginner], | |
| outputs=result_state, | |
| ) | |
| dep3 = dep2.then( | |
| run_verifying_state, | |
| inputs=result_state, | |
| outputs=mascot_html, | |
| ) | |
| dep4 = dep3.then( # noqa: F841 | |
| render_result, | |
| inputs=[result_state, lang], | |
| outputs=[panic_html, mascot_html, quip_md, tldr_out, why_out, actions_out, deadlines_html], | |
| ) | |
| # Example gallery select — zero-GPU loader (UI-01 gallery click → cached JSON render) | |
| # No @spaces.GPU on load_example; outputs mirror dep4 exactly so no new binding needed. | |
| example_gallery.select( # noqa: F841 | |
| load_example, | |
| inputs=None, | |
| outputs=[panic_html, mascot_html, quip_md, tldr_out, why_out, actions_out, deadlines_html], | |
| ) | |
| # Selecting a new file clears any previous verdict (stale-result bug) — | |
| # same 7-element binding as dep4, pure Python, zero GPU. | |
| def reset_panes() -> tuple: | |
| return ( | |
| render_panic_placeholder(), | |
| render_mascot("idle"), | |
| "", | |
| gr.update(value="", rtl=False), | |
| gr.update(value="", rtl=False), | |
| gr.update(value="", rtl=False), | |
| "", | |
| ) | |
| input_file.change( # noqa: F841 | |
| reset_panes, | |
| inputs=None, | |
| outputs=[panic_html, mascot_html, quip_md, tldr_out, why_out, actions_out, deadlines_html], | |
| ) | |
| # Game-feel: confetti when the verdict is all-clear (good news). Browser-side. | |
| demo.load(js=CONFETTI_JS) | |
| # --------------------------------------------------------------------------- | |
| # Phase 4 — gr.Server custom frontend (Off-Brand badge) | |
| # | |
| # Opt-in via BUREAUCAT_UI=server (the proven Blocks UI stays the default, so | |
| # the Space can fall back instantly by unsetting one env var). | |
| # | |
| # ZeroGPU pattern (verified against ysharma/text-behind-image + the official | |
| # server-mode guide): @spaces.GPU stays on decode(); the @server.api endpoint | |
| # is a plain wrapper that calls it. The browser MUST call endpoints through | |
| # @gradio/client (it forwards the X-IP-Token header ZeroGPU quota needs) — | |
| # raw fetch() would land every visitor in the anonymous quota tier. | |
| # --------------------------------------------------------------------------- | |
| def _deadline_items(deadlines: str) -> list: | |
| """ | |
| Parse the "Deadlines & money" section into [{value, note}, ...] for the | |
| custom frontend (value pills + the deadline banner). | |
| Mirrors render_deadlines_html's line conventions (bullet strip, the | |
| space-padded —/–/- interpretation separator, "None found" filtering) but | |
| returns data instead of HTML — the JSON API contract for server mode. | |
| """ | |
| items = [] | |
| if not deadlines or re.match(r"\s*none found\.?\s*$", deadlines.strip(), re.IGNORECASE): | |
| return items | |
| for raw_line in deadlines.strip().splitlines(): | |
| stripped = re.sub(r"^[-*•]\s*", "", raw_line.strip()).strip() | |
| if not stripped or re.match(r"none found\.?$", stripped, re.IGNORECASE): | |
| continue | |
| sep = re.search(r"\s+[—–\-]\s+", stripped) | |
| if sep: | |
| items.append({ | |
| "value": stripped[: sep.start()].strip(), | |
| "note": stripped[sep.end():].strip(), | |
| }) | |
| else: | |
| items.append({"value": stripped, "note": ""}) | |
| return items | |
| def _result_to_payload(result) -> dict: | |
| """ | |
| Convert a StructuredResult into the JSON payload the custom frontend | |
| renders. Routing mirrors render_result(): refusal first (doctype is | |
| load-bearing — refusals legitimately have severity=None), then malformed, | |
| then success. Success payloads run the grounded no-invention check and | |
| expose its verdict so the frontend can show a verification badge. | |
| """ | |
| if result is not None and getattr(result, "doctype", "letter") != "letter": | |
| doctype = getattr(result, "doctype", "not_letter") | |
| return { | |
| "kind": "refusal", | |
| "doctype": doctype, | |
| "mascot": "confused" if doctype == "unreadable" else "wrong_document", | |
| "quip": result.quip or "", | |
| "guidance": REFUSAL_GUIDANCE.get(doctype, REFUSAL_GUIDANCE["not_letter"]), | |
| } | |
| # Error only when there is genuinely nothing to show. A letter that produced | |
| # an analysis but lost its trailing SEVERITY line (e.g. a greedy repetition | |
| # loop on a dense letter) still has real content — render it with the gauge | |
| # in an "unclear" state instead of a false "couldn't read" error. | |
| _has_content = result is not None and ( | |
| result.tldr or result.why or result.actions or result.deadlines | |
| ) | |
| if result is None or (getattr(result, "severity", None) is None and not _has_content): | |
| return { | |
| "kind": "error", | |
| "mascot": "idle", | |
| "guidance": "Bureaucat had trouble reading that letter. " | |
| "Try uploading a clearer photo.", | |
| } | |
| invented = check_no_invention(result) | |
| sev = getattr(result, "severity", None) | |
| return { | |
| "kind": "letter", | |
| "doctype": "letter", | |
| "severity": sev, # may be null (SEVERITY line lost) | |
| "severity_label": _SEVERITY_LABELS.get(sev) if sev else None, | |
| "severity_color": _SEVERITY_COLORS.get(sev, "#9E9E9E"), | |
| "mascot": select_result_state(result), | |
| "quip": result.quip or "", | |
| "tldr": result.tldr or "", | |
| "why": result.why or "", | |
| "actions": result.actions or "", | |
| "deadline_items": _deadline_items(result.deadlines or ""), | |
| "deadlines_text": result.deadlines or "", | |
| "grounded": not invented, | |
| "invented_values": invented, | |
| } | |
| def build_server(): | |
| """ | |
| Construct the gr.Server custom-frontend app (Off-Brand mode). | |
| Deferred behind BUREAUCAT_UI=server so the default Blocks deployment never | |
| constructs gr.Server or runs its mounts at import — the Space boots exactly | |
| like a vanilla Blocks Space unless server mode is explicitly requested. | |
| """ | |
| from fastapi.responses import HTMLResponse | |
| from gradio.data_classes import FileData | |
| from starlette.staticfiles import StaticFiles | |
| server = gr.Server() | |
| def api_analyze(files: list[FileData], beginner: bool = True) -> dict: | |
| """ | |
| Custom-frontend inference endpoint. NOT GPU-decorated itself — it calls | |
| decode(), which carries @spaces.GPU(duration=55). This wrapper shape keeps | |
| image/PDF prep outside the metered GPU window. | |
| """ | |
| # Items arrive as FileData objects OR plain dicts depending on how the | |
| # client serializes a list[FileData] param (observed: dicts via gradio_client). | |
| paths = [] | |
| for f in files or []: | |
| if f is None: | |
| continue | |
| p = f.get("path") if isinstance(f, dict) else getattr(f, "path", None) | |
| if p: | |
| paths.append(p) | |
| result = decode(paths, "English", bool(beginner)) | |
| return _result_to_payload(result) | |
| def api_example(index: int) -> dict: | |
| """ | |
| Zero-GPU gallery endpoint — same cached JSONs as load_example(). Index is | |
| clamped into the hardcoded EXAMPLE_LETTERS list (no user-supplied paths). | |
| """ | |
| entry = EXAMPLE_LETTERS[int(index) % len(EXAMPLE_LETTERS)] | |
| data = json.loads(Path(entry["cached"]).read_text(encoding="utf-8")) | |
| return _result_to_payload(StructuredResult(**data)) | |
| async def _homepage(): | |
| return Path("frontend/index.html").read_text(encoding="utf-8") | |
| # Custom routes/mounts take priority over Gradio's defaults (server-mode docs). | |
| server.mount("/static", StaticFiles(directory="frontend"), name="static") | |
| server.mount("/assets", StaticFiles(directory="assets"), name="assets") | |
| server.mount("/letters", StaticFiles(directory="data/letters/public"), name="letters") | |
| return server | |
| if __name__ == "__main__": | |
| if os.getenv("BUREAUCAT_UI", "blocks").strip().lower() == "server": | |
| # Off-Brand custom frontend (Phase 4). Theme/CSS live in frontend/. | |
| build_server().launch(show_error=True) | |
| raise SystemExit(0) | |
| # Gradio 6.0 moved BOTH css and theme to launch() (Blocks-constructor args | |
| # emit a UserWarning and are ignored). Soft theme + brand hues = the modern | |
| # base; GLOBAL_CSS layers the verdict badge / card polish on top. | |
| # allowed_paths: "assets" for mascot images; "data" for gallery source images. | |
| # Fun, gamified theme: chunky rounded Google font + bright lime/pink accents. | |
| # Per-mode page background via the theme (NOT CSS) so dark mode renders | |
| # correctly; body text color is theme-driven so hero/footer stay readable. | |
| theme = gr.themes.Soft( | |
| primary_hue="pink", | |
| secondary_hue="lime", | |
| neutral_hue="stone", | |
| radius_size="lg", | |
| font=[gr.themes.GoogleFont("Fredoka"), "ui-sans-serif", "system-ui", "sans-serif"], | |
| ).set( | |
| body_background_fill="#FFFDF5", | |
| body_background_fill_dark="#12131A", | |
| ) | |
| demo.launch( | |
| css=GLOBAL_CSS, | |
| theme=theme, | |
| allowed_paths=["assets", "data"], | |
| ) | |