LabelHunter is a prototype that helps a TTB compliance agent verify an alcohol label against its application. Upload a label image and the matching application data. LabelHunter reads the label. It checks each field against the application. It reports a match or mismatch for five fields: brand name, class/type, alcohol content, net contents, and the government warning statement.
A human agent makes the final call on anything the tool flags. LabelHunter narrows the search. It does not replace judgment.
LabelHunter was built for a TTB take-home interview brief. docs/approach.md covers the
design reasoning, tools, assumptions, and trade-offs in full.
Deployed URL: https://labelhunter-web.onrender.com
Access code: 3d30e2b13fbaae325d1390c4e697918b
Enter the code once at the link above. LabelHunter remembers it in your browser for 30 days.
For a non-browser caller (a script, an API test), send it as the x-access-code header
instead.
This section describes what was checked against the live URL, not what PR #43 merging implies. Three checks confirmed it directly:
GET /redirects to the code page.- An unauthenticated
GET /api/review-queuereturns 401. - The same request, with the correct code, returns 200.
- Node.js 22 or later
- pnpm 10.27.0 (
corepack enablepicks up the version this repo pins inpackage.json) - Docker, to run a local Postgres container
- An Anthropic API key (console.anthropic.com) — required for the Haiku extractor and Sonnet resolver
-
Clone the repo.
git clone https://github.com/troysatchell/LabelHunter.git cd LabelHunter -
Install dependencies.
pnpm install
-
Start a local Postgres container. This binds only to your own machine (
127.0.0.1), not your whole network.docker run --name labelhunter-pg \ -e POSTGRES_USER=labelhunter \ -e POSTGRES_PASSWORD=labelhunter_dev_password \ -e POSTGRES_DB=labelhunter_dev \ -p 127.0.0.1:5432:5432 -d postgres:16-alpine
-
Copy the environment file.
cp .env.local.example .env.local
-
Open
.env.local. -
Set
ANTHROPIC_API_KEY. LeaveDATABASE_URLas it is — it already matches the container above. LeaveGOOGLE_API_KEYblank. The running app never reads it (see "What LabelHunter does not call" below). -
Run the database migrations.
pnpm db:migrate
-
Start the app.
pnpm dev
Open
http://localhost:3000.
pnpm typecheck # TypeScript, no emit
pnpm lint # ESLint
pnpm test # Vitest — unit and integration, no live API calls
pnpm test:e2e # Playwright — end-to-end, runs against a fake Anthropic server by default
pnpm build # production buildpnpm test and pnpm test:e2e both need DATABASE_URL pointed at a real Postgres database.
The local container above is enough. Neither command calls the real Anthropic API — both use
golden test-label fixtures and a fake model server instead. Two commands make real, billed API
calls and are not part of the default test run: pnpm eval:check -- --live and
pnpm latency:check. Run them by hand only if you want a fresh accuracy or latency
measurement.
LabelHunter reads a label with a cost-tiered cascade instead of running one expensive model call on every image:
Upload (single label or batch)
↓
Image preprocessing (rotation, resizing, a crop of the government warning block)
↓
Haiku Extractor — reads every field off the label image
↓
Validation Router — deterministic code, no model call, compares extraction to application
├── PASS → done, verdict recorded
├── REVIEW → Sonnet Resolver looks at the flagged field and the reason
└── INVALID → Sonnet Resolver re-extracts and resolves
↓
resolved or needs-human → review queue
Every label goes through Haiku first. Only the labels the router flags go to Sonnet. The
router flags three kinds of cases: an ambiguous field, a low-confidence read, or a possible
mismatch. Most labels resolve on Haiku alone. This keeps the common case fast and cheap. The
cases that need it still get a stronger model's judgment. docs/approach.md explains why this
design beats a single-model pipeline, with measured cost and accuracy numbers.
The government warning has its own, stricter check. TTB's brief requires a word-for-word match
against the statutory warning text. The other fields use a judgment-based comparison instead.
LabelHunter reads the warning block through two independent channels: a vision read and OCR.
It calls a verdict only when both channels agree. When they disagree, it flags the case for a
human instead. docs/approach.md has the full design.
Limitation — bold type on the government warning. 27 CFR 16.22(a)(2) requires the words "GOVERNMENT WARNING" to print in bold type, and it forbids bold type on the rest of the statement. LabelHunter checks the first rule with an advisory signal, not a hard check. It does not check the second rule at all.
measureBoldSignalmeasures the prefix's stroke width against the body's, from the image's own pixels — not a vision model's guess. It reports a three-valued signal: bold, not bold, or uncertain. The signal still never produces a hard FAIL by itself. Stroke width is a relative measurement. It depends on the typeface, the printed size, the photograph's resolution, and the compression the photograph has already been through. A verdict built directly on that signal would accuse a compliant label of a violation it cannot prove. Instead, a not-bold reading on an otherwise-matching warning routes the label to human review (TRO-569) — a defensible flag, not an accusation. LabelHunter does hard-enforce the capitalization rule from the same sentence of the regulation, because capitalization survives a photograph and stroke width does not.
docs/approach.md's "Trade-offs and limitations" and
docs/checkpoints/cp2-warning-subsystem.md §7.2/§7.3 have the full design and the measured
accuracy behind this paragraph.
| Role | Model | Approximate cost per label |
|---|---|---|
| Extractor (every label) | claude-haiku-4-5 |
~$0.005 |
| Resolver (escalated labels only) | claude-sonnet-5 |
~$0.02, on roughly the fraction of labels the router escalates |
docs/approach.md and scripts/eval/results/ hold the measured figures: extraction accuracy,
verdict accuracy, cost per label, and a cascade-vs-single-model comparison.
- Only label and application fields. The database holds what's on the label and what's on the application. Six fields: brand name, class/type, alcohol content, net contents, government warning text, and a beverage-type selector. No applicant name, address, or other personally identifying information is ever stored.
- No reviewer identity. When a human agent approves or rejects a flagged item, the database records the decision. It does not record who made the decision. This is a deliberate design choice, not an oversight.
- The Anthropic API key never enters the repo. It lives in
.env.local, which is git-ignored. Once deployed, it also lives in Render's environment configuration. - Access control is live, confirmed against the deployed instance. A shared access-code
gate, per-IP and global rate limits, and a daily spend budget merged into
main(PR #43) and are now protecting the deployed URL above — checked directly, not assumed from the merge. The budget check runs before every Haiku or Sonnet call, single-label and batch alike. A budget-check failure returns a 503 with a stated reason, never a generic server error.
LabelHunter makes exactly one outbound call to a public vendor API while it runs: the
Anthropic API, for label extraction and resolution. Its own Postgres database is a private,
same-network dependency, not a public vendor endpoint. A GOOGLE_API_KEY variable exists in
.env.local.example for one reason: generating the test-label image set during development
(pnpm golden:build). The deployed, running app never reads it. docs/approach.md's
"Outbound dependencies and degradation" section has the full dependency table and what happens
if the Anthropic endpoint is unreachable.
src/app/— Next.js routes and UI componentssrc/server/— extraction, validation router, comparators, the warning subsystem, batch queuesrc/lib/db/— Drizzle schema and database accessgolden-set/— the committed test-label image set and its ground truthscripts/— evaluation, latency measurement, and deployment toolingdocs/— architecture (PRD.md), approach and trade-offs (approach.md), error states, deployment runbook, design documents, and the checkpoint walkthroughs for the cascade router, the warning subsystem, and the batch queueaudit/requirements/— the requirements-traceability sweep, mapping this brief's requirements to the code and tickets that satisfy them
See docs/deploy.md for the full runbook. In short: render.yaml at the repo root defines a
Render Blueprint (a web service, a background worker, and a Postgres database). Connect this
repo to a Render account. Create a Blueprint from main. Paste in your own Anthropic key.
Render never stores it in the repo.