Problem
Promotional copy for a prescription drug (emails, web banners, detail aids for doctors) can only say what the FDA label supports. Medical, legal and regulatory (MLR) reviewers check every claim by hand: is this number in the label, is this population on-label, did the copy drop a qualifier the label attaches, does it play down a boxed warning.
ZS sells a product for this step (ZAIDYN Content's MLR Accelerator, described as claim-to-reference validation) and writes that approval takes 21 to 56 days per asset. FDA's 2025-26 letters cite exactly these problems; it sent more than 100 untitled and warning letters about drug ads in September 2025 alone.
Users
Brand teams checking copy before submission, and MLR reviewers who want the evidence laid out next to each claim. The reviewer makes every decision; OnLabel prepares the evidence.
Constraints
- Free and open-source only. Open-weight models on free tiers (Groq, Google AI Studio), local Ollama for bulk runs, and Render's free tier for hosting (512 MB RAM, about 0.1 CPU).
- Every verdict has to be checkable. No model gets the last word on a regulated claim.
- Rate limits. Groq's free tier allows 8K tokens a minute per model, which caps how many claims a minute one model can judge.
What I built
- Claim splitting. A fast open-weight model copies each claim out of the copy. Every claim must be found there word for word, and sentences are the fallback.
- Retrieval. Section-aware chunks of 12 DailyMed labels (1,595 chunks, character offsets kept), bge-small int8 on plain ONNX Runtime, BM25 with a tokenizer that keeps "14.9" whole, reciprocal-rank fusion and figure pinning. Routing makes an indication claim always see the Indications section, and a safety claim the boxed warning and contraindications.
- Judge. gpt-oss-120b on Groq first, falling back across gpt-oss-20b, Qwen3.8 and Gemma 4 by per-minute budget. It returns strict JSON with a verdict, violation types in FDA's own wording, and verbatim quotes.
- Guards in plain code. A quote counts only if it is found in the label text the judge was shown, and never by fuzzy match when it holds a figure. A traced verdict must contain every figure the claim states, and the claim must carry the conditions of the indication it quotes ("in adults with type 2 diabetes and chronic kidney disease"). A verdict that contradicts itself goes to a human.
- Injection check on the copy. Zero-width characters, HTML comments, invisible text and base64 are exposed first. Then patterns written for MLR copy and Llama Prompt Guard 2 look for instructions aimed at the reviewer.
- A React workspace. Claims highlighted in the copy, label evidence with the quoted sentence marked, the boxed warning drawn as the label's black box, tables with the quoted row, a plain-language list of what the guards caught, the model trace, on-label rewrites re-checked by the same pipeline, keyboard review and a JSON audit export.

Metrics
All figures come from committed reports in the repo; model answers are cached, so the reports reproduce without API keys.
| Measure | Result |
|---|---|
| Benchmark | 132 claims · 63 fact cards · 12 labels |
| Test split (six ingredients not looked at while building) | 70 claims |
| gpt-oss-120b, test split: violative traced, guards off → on | 2 → 0 of 42 |
| gpt-oss-120b, test split: faithful claims traced | 25 of 28 |
| Llama 3.1 8B, all claims: violative traced, guards off → on | 9 → 1 of 78 |
| Gemma 4 26B, all claims: violative traced, guards off → on | 2 → 0 of 78 |
| Governing label text in front of the judge | 98% (92% with fixed 180-word chunks) |
| Real quotes the guards rejected, before → after fixing the matcher | 89 → 35 of 567 |
| Injection attacks that got a violative claim traced | 0 of 18 |
| False alarms on benign copy lines | 0 of 18 |
| Llama Prompt Guard 2 on its own | 3 of 18 caught |
| Tests in CI | 112 Python · 11 front-end |
The 3 faithful claims the strongest model did not trace went to a reviewer: two quotes did not match a label table word for word, and one left the study length out of its quotes. Two guard rules were written after seeing failures that include the test split, so the 2 → 0 is not a blind result; a holdout of claims FDA cited in its letters is the blind test still to run. Full results, intervals and every failure are in EVALS.md.

Trade-offs I made
- Plain code decides what is reported.A traced verdict needs its quotes found in the label, every figure the claim states inside those quotes, and every condition its quoted indication attaches. On the test split this held back 3 faithful claims from the strongest model and took its false approvals from 2 to 0; for the small local model it cut them from 9 to 1.
- The judge is a large hosted model.A fine-tuned small verifier would cost less per claim. I shipped the hosted judge first and measured it, and the small verifier is the next step.
- Patterns written for MLR copy run next to Llama Prompt Guard 2.Prompt Guard caught 3 of 18 attacks on its own and missed every one phrased as an MLR sign-off or a verdict request. The patterns caught 15, with no false alarm on 18 benign copy lines.
- Copy that instructs the reviewer gets no traced claim at all.A false alarm costs a reviewer a look at every claim in that piece.
- The embedding model is small.bge-small int8 fits a free host with about 0.1 CPU. Section-aware chunks and routing to the governing sections carry the retrieval.
What I'd do next
- Test on claims FDA actually cited in its 2024-26 untitled letters, after checking each extracted claim by hand.
- Fine-tune a small verifier (LoRA against full fine-tuning) to cut LLM calls per claim.
- Video ad scripts would be the next format; visual and audio claims are out of scope today.
