# Building Mystery-Mail Guardian: A Project for build-small-hackathon ## Introduction My grandmother can read English. What she can't always do is tell a *real* letter from a scam — the official-looking envelope demanding payment "within 24 hours," the one threatening arrest, the one that's secretly an advertisement dressed up as a bill. For a lot of elderly people, the mailbox is one of the last places they still get attacked, and the attacks are getting better. The obvious move is to point a multimodal model at the letter and ask "is this a scam?" But the letters that matter most — from the bank, the tax office, the doctor — are exactly the documents you should *never* upload to a cloud API. So the interesting constraint wasn't "can a model read a letter." It was: **can a model read a scary letter, helpfully and safely, without a single byte ever leaving the device?** That constraint became the whole project. ## The Idea **Mystery-Mail Guardian** lets you photograph a confusing letter and get back, in plain words: what it is, whether you should worry (with concrete reasons, never a verdict), what to actually do next — and it reads the answer aloud. In English, Hindi, Spanish, and Japanese. Two design rules shaped everything: 1. **Run entirely on-device.** No cloud inference at runtime. The privacy promise isn't a feature bolted on; it's the reason the product gets to exist. 2. **Never let the model have the final word on safety.** A small vision model *will* occasionally say something confidently wrong. So the model proposes, and a separate, deterministic safety layer disposes. ## High-Level Plan: small models, watched closely Everything runs on two OpenBMB models, **3.3B parameters total** — comfortably under the hackathon's cap, and small enough to run on a single ZeroGPU slice: - **MiniCPM-V 4.6** (1.3B) is the central engine: it reads the photographed letter (OCR + layout + reasoning) and returns structured JSON. - **VoxCPM2** (2B) reads the result aloud, in the user's language, with a neutral built-in voice (deliberately no voice cloning — no consent question to answer). There was a tempting third option: bolt an 8B reasoner on top for nicer prose. I built the switch for it, measured it, and left it off — more on that below. The hackathon's "Tiny Titan" award is for models ≤4B, and the lean pair already does the job. Smaller was the right call, not the compromise. ## Internal Logic: the model proposes, the safety layer disposes The pipeline is four stages — **extract → triage → safety → speak** — and the single most important architectural decision is that the safety rules live in *code*, in their own module, not in the prompt. A prompt can be ignored by a 0.8B language head having a bad day. A regex cannot. ### Stage 1 — Extract MiniCPM-V 4.6 reads the photo against a strict JSON schema. The prompt bakes in the safety rules too (defense in depth), but its real job is to pull *structured facts* — sender, amount, deadline, and any scam signals it spots from a fixed taxonomy: ```python "scam_signals": [ {"id": "", "evidence": "short quote or fact from the document"} ], "explanation": { "what_this_is": "1-2 short sentences in {language}, words a 10-year-old understands", "key_facts": ["up to 4 very short facts with real values from the letter"], "worry_reasons": ["each scam signal explained in one short sentence"], "what_to_do": ["2 to 4 short, safe, concrete steps"] } ``` ### Stage 2 — Triage (a second pair of eyes) Here's the part I trust most, because it doesn't trust the model. An *independent* heuristic scan runs over everything the model extracted, looking for scam vocabulary the model might have downplayed — gift cards, wire transfers, OTP requests, arrest threats, urgency — in every supported script: ```python "gift_card_or_crypto": re.compile( r"gift\s*card|itunes\s*card|bitcoin|crypto|गिफ्ट\s*कार्ड|" r"ギフトカード|tarjeta\s+de\s+regalo", re.I), "threat_or_arrest": re.compile( r"\barrest(?:ed)?\b|lawsuit|गिरफ़्तार|逮捕|arresto", re.I), ``` The model's signals and the heuristic signals are unioned, and the worry level is computed from the result — never from the model's mood: ```python def worry_level(document_type, signals): if document_type == "suspected_scam" or len(signals) >= 2: return "warning" if len(signals) == 1: return "caution" return "low" # note: there is no "safe" — only "looks normal, but verify" ``` There is deliberately **no "safe" state**. The best case is "this looks like normal mail, but always double-check before paying or replying." ### Stage 3 — The safety layer Every piece of user-facing text passes through here. Two guarantees matter most. **It softens verdicts.** If the model writes "this is definitely a scam," it becomes "this looks like it could be a scam." We never tell a 78-year-old that a real letter is definitely safe, or that a real bill is definitely fraud. **It strips the letter's own contact details — from every card.** A scam letter's entire goal is to get you to call *its* number. So phone numbers, links, and emails printed in the letter are removed from the answer, and replaced with one constant instruction: contact the company through a number *you already trust*. ```python _PHONE_RE = re.compile(r"\+?\d[\d\-\s().]{7,}\d") _EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.]+\b") def strip_contact_details(text: str) -> str: text = _URL_RE.sub("", text) text = _EMAIL_RE.sub("", text) text = _PHONE_RE.sub("", text) return re.sub(r"[ \t]{2,}", " ", text).strip(" \t,;:") ``` This earned its keep in live testing. Against a real gift-card scam, the model suggested *"check with the tax bureau and ensure you pay soon"* — genuinely dangerous advice. Because it arrived in the wrong shape, the composer discarded it and showed the safe fallback plus the verification advice instead. The scammer's 1-800 number appeared nowhere on the screen. ### Stage 4 — Speak VoxCPM2 turns the safety-checked summary into speech on demand — one big "Read it aloud" button that only appears once there's something to read. ## The QA flywheel: Modal as the proving ground Here's a thing that surprised me: the most valuable use of GPU credits wasn't inference, it was *testing*. The deployed Space runs on a daily ZeroGPU quota I didn't want to burn on my own QA — so every bit of GPU validation runs on **Modal** instead, and the deployed runtime stays 100% local. Off-the-grid stays intact; QA gets unlimited room. I built a little factory that forges synthetic letters with *gold labels derived through the app's own triage rules* (so the labels can never drift from the product), renders them as photos, degrades them like a shaky phone hand, and scores the full pipeline on an A10G. It paid for itself immediately: - **Run #1 of the validation matrix** caught a real bug — a lottery scammer's reply address leaking through the "key facts" card. (The contact-stripping only covered action steps; now it covers every card.) - **A multilingual eval** found Hindi and Japanese scam letters being under-flagged, which led to original-script quoting in the prompt and multilingual scam vocabulary in the heuristics. - **A robustness sweep** — six degradation types × three intensities (blur to 4.5px, brightness to 15%, rotation to 35°, perspective warp, hard shadow, sensor noise) — came back **38/38 honest outcomes**. The scam stayed *warning* and the bill stayed *low* through all of it. And the answer to "should we fine-tune?" came from the data, not from wanting to. I trained a LoRA adapter anyway, to prove the loop works — train loss 2.14 → 0.03 in under six minutes for about forty cents — but the evals showed safety behavior was already at ceiling on the stock model, so the adapter stays shelved through judging. With small models, you earn trust through guardrails and measurement, not through more training. ## A note on the front end The UI is hand-built past the default Gradio look into a warm "kitchen-table post office": letters render as paper sheets, the verdict is a perforated postage stamp, the privacy promise is a green wax seal. The body font is **Atkinson Hyperlegible**, designed by the Braille Institute specifically for low-vision readers — an elder-readability font in an elder-readability app — vendored into the repo so even the fonts make no network request. Every color pair is enforced against WCAG AA contrast by unit tests; if a pretty color fails, the color changes, not the test. (There may also be a Gen X Soft Club easter egg hiding behind one of the language buttons. I'll let you find it.) ## Wrapping up What I like most isn't any single trick — it's the shape of the thing. A model small enough to run in your hand, wrapped in code that refuses to let it be confidently wrong about something that could cost someone their savings. The intelligence proposes; the rules protect. The honest catch is **script coverage**: a 1.3B vision model reads English (and Japanese) print reliably, but Devanagari is near its limit, so Hindi letters can be under-read — which is exactly why every single result, in every language, still ends with "check with someone you trust." The guardrails hold even where the model doesn't. It's live, free, and fully open. Built for the Hugging Face **Build Small** hackathon. If it speaks to you — or to someone you'd want to protect — give it a try, and a ❤️ on the Space genuinely helps. 🤗 👉 **https://huggingface.co/spaces/build-small-hackathon/Mystery_Mail_Guardian**