Skip to content

Repository files navigation

Proofread — AI-Powered Alcohol Label Verification (POC)

Prototype for TTB label compliance review: upload a label image plus the application's form fields (single or CSV batch) and get a per-field PASS / FAIL / NEEDS_REVIEW verdict (brand name, ABV, net contents, class/type, government warning).

Live demo: https://proofread-3hn.pages.dev — this *.pages.dev URL is the canonical production address; no custom domain is attached, deliberately (the POC keeps the Cloudflare-issued origin, see docs/api-edge-hardening.md).

Where things are documented:

Document What it covers
INSTRUCTIONS.md The original brief
docs/latency-gate.md OCR engine decision record + model A/B evidence
docs/api-edge-hardening.md Why/how the API is reachable only through Cloudflare
Productionize.md POC shortcut → production path, item by item
iac/README.md Terraform bootstrap, state layout, OIDC, cost posture

Quickstart (local)

The only prerequisite is mise — every other tool (python, node, pnpm, uv, pitchfork, lefthook, git-lfs, terraform) is installed and version-pinned by the mise configs. No Docker in the local loop.

brew install mise       # non-Homebrew installers: https://mise.jdx.dev/getting-started.html
mise install --monorepo # installs every package toolchain in one shot
git lfs pull            # first clone only: materialize LFS image assets
cp .env.example .env    # then fill in your Upstash REDIS_URL
mise run dev            # api + worker + frontend under pitchfork

First run only: mise asks you to trust the repo's config files (root and one per package) — accept the prompts, or run mise trust in each directory.

(Image assets are stored in git LFS. git-lfs is installed by mise and its filters are enabled automatically on entering the directory; git lfs pull is only needed if the repo was cloned before mise was set up.)

Frontend: http://localhost:5173 — API: http://localhost:8000/healthz. The local Vite server proxies /api/* to the local FastAPI process, mirroring the deployed edge proxy (VITE_API_URL remains only as an escape hatch to bypass the proxy).

Other tasks: mise run test (pytest + vitest), mise run lint (ruff + eslint). Git hooks install automatically when you enter the directory. Stack debugging: see .claude/skills/dev-stack/SKILL.md.

Two pytest suites are deliberately excluded from the default run and every-push CI (they load real OCR models): pytest -m ocr_model (engine contract smoke) and pytest -m acceptance (default engine over all 53 fixtures vs samples/index.json — also runnable in CI via the dispatch-only acceptance workflow). Both need uv sync --group ocr first.

PR previews, deploys, and scale-to-zero

Opening a PR against main auto-deploys the backend to the shared dev Azure environment, and — when the PR touches the frontend — publishes a Cloudflare Pages preview; a bot comment on the PR carries both URLs. Previews are per-PR and immutable: each PR gets its own https://<hash>.proofread-3hn.pages.dev deployment serving exactly that PR's frontend build (they don't expire, they just go stale). The backend behind every preview's /api proxy is a different story — dev is one long-lived shared environment, updated in place by whichever PR deployed most recently (a deliberate free-tier choice: per-PR backend stacks would need per-PR Redis databases and state, and the free tier allows exactly one). So with several PRs open, a preview's frontend is always its own, but the dev API/worker it talks to are last-writer-wins. Merging to main deploys prod and updates the live site (docs-only changes deploy nothing; pipeline details and rollback: .claude/skills/deploy-rollback/SKILL.md).

Both environments scale the backend to zero when idle, so the first interaction after a quiet spell is slower than the rest: the page's warm-up ping absorbs the API cold start (~9 s), and the first check pays the OCR worker start (~30–50 s — scaler poll + container boot + model load). After that, checks run at the warm numbers under § Deployed checks, and the worker stays warm for 15 minutes after the queue empties. The full scaling model and its knobs: iac/README.md § Scaling model; queue mechanics (key layout, blocking pops, job TTL) are documented in the code that implements them — src/backend/app/queue/redis.py.

Architecture

A React + Vite SPA (Cloudflare Pages) calls the API same-origin at /api/*; a Pages worker (src/frontend/public/_worker.js) proxies those calls to a Python/FastAPI control plane on Azure Container Apps, whose ingress allowlists Cloudflare's edge IPs — direct requests to the ACA FQDN get 403 by design. The API validates uploads and enqueues job IDs into Upstash Redis; OCR workers (RapidOCR behind an OcrEngine interface; Surya as the optional US-provenance engine) consume the queue, run the pure verification library, and write per-label results back to Redis with a 24 h TTL. API and worker are one Python package and one container image with multiple entrypoints, deployed as two Container Apps; locally the same entrypoints run as native processes under pitchfork — no Docker in the local loop.

browser ──── static assets ────► Cloudflare Pages (proofread-3hn.pages.dev)
   │                                  │
   └──── /api/* (same origin) ────► _worker.js proxy (Pages worker at the edge)
                                      │ fetch(https://<aca-fqdn>/…)
                                      ▼
                     ACA api app — FastAPI, 0–1 replicas, scale-to-zero
                     (ingress allowlists Cloudflare edge IPs; direct curl → 403)
                                      │ job IDs + job state (images staged in state)
                                      ▼
                     Upstash Redis — every key prefixed local:|ci:|dev:|prod:
                                      ▲ BLPOP job IDs (≥30 s blocking pops)
                     ACA worker app — RapidOCR + verification core,
                     KEDA-scaled 0→N on queue depth (listLength=5), 900 s cooldown

Dev and prod are the same templatized Terraform stack in separate Azure subscriptions: a PR applies dev and comments a Pages preview URL; a merge to main applies prod and updates the production site (bootstrap and state layout in iac/README.md; rollback in .claude/skills/deploy-rollback/SKILL.md).

Deployed checks (prod, 2026-07-17)

Measured through the real user path (Pages origin → edge proxy → ACA → queue → worker) with .claude/skills/smoke-deployed/smoke.py:

  • Warm single check: 4.83–5.41 s wall-clock (median 5.35 s over six back-to-back runs) — straddling the 5 s budget, recorded honestly as a finding rather than rounded down. The breakdown clears the queue: submit ≈ 0.6 s, worker pickup < 1 s of submit, the rest is OCR + matching on the worker's 1-vCPU replica. The latency gate's 1.6 s median was measured on 4-vCPU x86, so the gap is inference compute on the small replica, not queue/polling overhead — the lever is worker CPU sizing (Productionize.md § 4), not architecture.
  • Cold start: 37.5 s for the first check after the worker scales to zero (30 s KEDA poll + container boot + model load), reported separately — it is not part of the 5 s budget. The frontend's /healthz ping on page load hides the API's own cold start (~9 s observed).
  • Load / KEDA scale-out: 48 labels (12 jobs × 4, from samples/) drained in 99.8 s; worker scaled 1 → 2 replicas within one 30 s KEDA poll of submission and back to 0 after the 900 s cooldown. Kept deliberately small — free-tier credit matters more than a big number.
  • Correctness under load: 0 false verdicts. 28/48 labels landed an acceptable strict verdict (including one degraded fixture where FAIL is the documented alternate outcome), 20 punted to NEEDS_REVIEW — the same profile as the recorded acceptance-gate verdict (32/53 strict, 0 false verdicts; see the verdict-semantics bullet under § Trade-offs).

Try it (deployed)

Single check — open https://proofread-3hn.pages.dev, upload samples/labels/generated/gen-spirit-clean.png, and enter the matching application fields: brand OLD TOM DISTILLERY, class/type Kentucky Straight Bourbon Whiskey, ABV 45% Alc./Vol. (90 Proof), net contents 750 mL — all five fields verify PASS.

Batch check — the browser sends bare filenames, so strip the directory prefix from the fixture manifest for the generated set, then drop the ten samples/labels/generated/*.png images plus that CSV onto the batch screen:

(head -1 samples/batch-manifest.csv; grep '^generated/' samples/batch-manifest.csv | sed 's|^generated/||') > demo-manifest.csv

Or from a terminal, run the deployed smoke check (.claude/skills/smoke-deployed/SKILL.md):

python3 .claude/skills/smoke-deployed/smoke.py     # prod; --base for a PR preview

Do not curl the *.azurecontainerapps.io FQDNs directly — the 403 you'll get is the edge lock working as designed.

Repo map

.mise.toml            Shared tools (git-lfs, lefthook, pitchfork) + root tasks (monorepo root)
pitchfork.toml        Local process supervision: api, worker, frontend
lefthook.yml          Git hooks (pre-commit: staged lint; pre-push: full lint + typecheck)
.env.example          REDIS_URL, QUEUE_ENV, worker/OCR knobs — copy to .env
Productionize.md      What changes when real money/users arrive (per-item production paths)
.claude/skills/       Dev runbooks: dev-stack, upgrade-deps, gen-test-labels, add-ocr-engine,
                      smoke-deployed (deployed smoke + latency check)
.github/workflows/    Path-filtered CI (ci-backend, ci-frontend), image build (Buildah→GHCR),
                      deploy-dev (PR) / deploy-prod (merge), dispatch-only benchmarks
                      (ocr-bench: engine latency/recall on x86; acceptance: full fixture gate)
docs/                 latency-gate.md (OCR decision record), api-edge-hardening.md (edge lock)
src/backend/          One Python package, entrypoints: api (FastAPI), worker (queue consumer)
  ├── app/core/       Pure verification library: ocr/ (engine seam), matching/, verify.py
  ├── app/queue/      JobQueue seam: Redis (QUEUE_ENV-prefixed) + in-memory adapters
  └── .mise.toml      Backend toolchain: uv; python via .python-version
src/frontend/         React + Vite + TypeScript SPA (vitest, eslint)
  ├── public/_worker.js   Pages worker: same-origin /api/* proxy to the env's backend
  └── .mise.toml      Frontend toolchain: node via package.json devEngines + pnpm
iac/                  Terraform: Upstash + Azure Container Apps + KEDA scaler (envs/ tfvars)
  └── .mise.toml      IaC toolchain: terraform via .terraform-version; tflint (latest)
samples/              53 fixture labels (35 real TTB COLA panels — 4 with genuine warning
                      defects — plus generated + degraded variants), ground truth in
                      index.json, batch-manifest.csv; fetch/generate tools in tools/

mise configs are per package and merge with the root: working inside src/frontend (or running its tasks from the root, e.g. mise run '//src/frontend:test') resolves that package's toolchain.

API

  • POST /jobs — single (image + application form fields) or batch (images + CSV manifest, format = samples/batch-manifest.csv); whole batch rejected with every validation problem listed, nothing partially enqueued
  • GET /jobs/{id} — status + progress; GET /jobs/{id}/results — per-label results streaming in as workers finish; GET /healthz — warm-up probe
  • Queue carries job IDs only; job state lives in Redis hashes under a QUEUE_ENV key prefix with a 24 h TTL (app/queue/redis.py documents the key layout)

Dependency policy

No package version younger than 7 days is adopted (supply-chain cooldown), enforced in the resolvers themselves: uv's exclude-newer (backend) and pnpm's minimumReleaseAge (frontend). Upgrades only via .claude/skills/upgrade-deps/SKILL.md.

Trade-offs & production path

Each POC shortcut and its exit is written up per item in Productionize.md; the highlights:

  • No result retentionpersist_results() is a deliberate no-op seam; nothing sensitive is stored. Retention slots in behind that one function.
  • One shared Upstash database for local/dev/prod, walled off by QUEUE_ENV key prefixes (free tier allows exactly one database). Production: one Azure Managed Redis per env — a connection-string swap for the adapter; Azure Service Bus documented as the idiomatic queue adapter (Productionize.md § 1).
  • OCR engine provenance: the default is RapidOCR — Baidu-trained PP-OCR weights on Microsoft's onnxruntime — because it is the only self-hosted engine that met the 5 s CPU budget (evidence + RAPIDOCR_* re-bench knobs in docs/latency-gate.md); bigger/English model tiers were A/B'd and all increased false verdicts. Surya (US-developed) stays behind the same OcrEngine seam as the provenance swap (needs GPU or budget relief); Azure AI Vision Read is the documented cloud fallback (Productionize.md § 5) — relevant because the customer's firewall posture blocks external ML endpoints, so self-hosted is the default.
  • Verdict semantics: false verdicts are the trust-destroying failure mode — the acceptance gate hard-fails on any false PASS/FAIL and tolerates NEEDS_REVIEW punts (an unreadable label goes to a human — the existing agent workflow). Gate: 32/53 strict, 21 punts, 0 false verdicts; the deployed load check reproduced the same profile (above).
  • Edge lock, not network isolation: the API is "only reachable through the frontend" via the Cloudflare allowlist — a free-tier stand-in for the real design (frontend in ACA, backend internal-only ingress), which is Productionize.md § 2.
  • Iron Bank base images required before production (registry1.dso.mil equivalents) — appropriate for a federal (TTB) deployment posture; public images are a POC cost/simplicity choice (Productionize.md § 6).
  • Known limitations: bold detection on the government warning is not attempted (documented matcher limitation); worker cold start is ~35 s (mitigated by the 900 s warm cooldown and the /healthz warm-up ping); COLA integration is out of scope — the manifest loader is the documented seam.

Status

Complete. The build ran as six phases (scaffolding/CI → verification core → control plane/queue/worker → frontend → IaC + deploy pipelines → hardening/docs/deployed checks); the retired phase plan and its per-phase verdicts live in git history (PLAN.md, removed once everything in it shipped) and the merged PR record. The one unbuilt stretch item — an MCP server over the same pure core — is documented as a production add-on in Productionize.md § 7.

About

Alcohol Label Verification App

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages