Three pieces
One website that behaves like a phone app. No account, no database.
Reads your photo, computes its SHA-256 fingerprint, reads the capture time inside the file, and keeps your whole case on the device (IndexedDB). It also runs the rules and draws the notice PDF.
src/app/ · src/components/ · src/lib/store.ts
Does what the phone can't do safely: holds the AI key and asks a vision model to propose a problem type, signs a receipt for the fingerprint, and fetches the building's public record from NYC Open Data.
src/app/api/classify · receipt · building
Plain data plus plain code. Given the problem type and a few facts, it returns the hazard class, the correction window, the code section and the source link. No AI past this point.
src/lib/rules.json · src/lib/rules.ts
AI proposes, code disposes. The model only chooses from a fixed list; every consequential answer comes from the table.
One action, end to end: snap a photo, see the stamped class
Violet marks the AI step. Each box says what runs, where it lives, and what you see on screen.
-
Phone
Read the photo
Fingerprint, capture time, and metadata-free copies (one stored, a small one for the AI). The fingerprint also goes to
/api/receipt.src/components/ snap/ SnapSheet.tsx takeFile()src/lib/ evidence.ts readEvidence()You see“Evidence · on this device”: fingerprint, time taken, receipt, location removed.
-
Server → AI model
AI proposes
The browser posts the small copy to
POST /api/classify. The model must answer with one of seven ids, a confidence and one sentence, at temperature 0.src/app/ api/ classify/ route.ts POSTsrc/lib/ classify.ts classifyImage()You seeA pencil loader: “reading the photo…”
-
Server · code
Code checks confidence
Below 0.7, code attaches one clarifying question. Otherwise the proposal goes through. The model never sees a class or a deadline.
src/lib/ classify.ts decide()You seeA violet “AI proposes” card with a confidence bar, or one question.
src/components/ snap/ ProposalCard.tsx -
Phone · rules table
Code disposes
The browser runs the table. If a rule needs a fact a photo can't show, it asks first.
src/lib/ rules.ts evaluate()src/lib/ rules.json You see“The rule needs one fact the photo can't show”: for a leak, “Is water actively dripping or flowing right now?”
-
Phone
Stamped result
The card shows the class, window and citation. The repair-by date is counted from the moment you add the photo.
src/components/ snap/ RuleCard.tsx src/lib/ countdown.ts repairByDate()You seeClass C “24-hour correction window” and a live countdown (active leak).
saveIssue() in src/lib/store.ts (IndexedDB, with an in-memory fallback).GET /api/building (src/app/api/building/route.ts, src/lib/building.ts) adds a line like “… open HPD violations — 37 for leaks.” under the card.A three-stop route through the code
Reference route · written from reading the code, not toured interactively
Search for the quoted symbol instead of scrolling; line numbers move.
-
1The step machine
src/
components/ snap/ SnapSheet.tsx Search:
async function takeFileThe sheet walks Photo → AI proposal → Rule → Log (
STEPS).takeFile()reads the photo, starts the receipt request, and calls/api/classify. Further down it callsevaluate()and renders the cards.What to notice
- Search
const outcome = chosen: the rules run here, in the browser, from the chosen type plusknownfacts. - The renter can correct the AI (
IssuePickerinProposalCard.tsx); the pick goes through the sameevaluate(). - If the AI is unavailable,
apply()switches to the manual picker and the rest still works.
- Search
-
2Where the AI stops
src/
lib/ classify.ts Search:
export function decide(decide()is pure: it applies theCONFIDENCE_THRESHOLD(0.7) in code and adds one clarifying question when the model is unsure. The model's whole job isProposalSchema.What to notice
- The check itself:
if (!answered && raw.confidence < CONFIDENCE_THRESHOLD) { classifyImage()tries models in order, rests one that answers 429, and caches identical requests.- The caller is
POSTinsrc/app/api/classify/route.ts: input checks, rate limits, and saved demo answers through the samedecide().
- The check itself:
-
3The table decides
src/
lib/ rules.ts · src/ lib/ rules.json Search:
export function evaluate(·"leak-active"evaluate()walks an issue'svariantsin table order. The first whosewhenfacts all hold wins. A missing fact returns questions instead of a class.One rule, trimmed
{ "ruleId": "leak-active", "when": { "activeLeak": "yes" }, "hazardClass": "C", "window": { "hours": 24, "text": "24 hours" }, "citation": "NYC Admin. Code §27-2005(a), §27-2026" }Each real rule also carries
sourceUrl, a shortquoteand a plain-English line.
To change this, start here
-
Add or correct a legal rule
src/lib/rules.json(a variant withruleId,when, class, window, citation, source) plus a case insrc/lib/rules.test.ts. Runnpm test. -
Change the notice wording (English or Spanish)
src/lib/notice-text.ts(STRINGS). The preview and the PDF both render this text; layout is insrc/components/notice/NoticeDocument.tsx. -
Change the AI models, threshold or rate limits
Models, cooldowns and cache, shared by every AI feature:
src/lib/ai.ts(GOOGLE_MODELS,GATEWAY_MODELS,generateObjectWithFallback). Limits: thecreateLimitercall at the top of each AI route (src/app/api/classify,ask,coach). The 0.7 threshold lives insrc/lib/types.ts(CONFIDENCE_THRESHOLD), shared by the server and the proposal card. -
Change the look
src/app/globals.css(the--color-*tokens) andsrc/components/doodle/(the hand-drawn kit). -
Fix how a building's violations map to a problem type
src/lib/building.ts(violationIssue()and its localRULESpattern list; first match wins), tested insrc/lib/building.test.ts. -
Added after the first build: where each newer feature lives
Heat log:
src/lib/heat.ts+/api/outdoor. Landlord track record:src/lib/portfolio.ts+/api/landlord. Ask the law:src/lib/law/(corpus, BM25retrieve.ts, quoteverify.ts) +/api/ask. Message coach:src/lib/coach.ts(validateDraft) +/api/coach. Same rule as the kernel: the model proposes, plain tested code checks. The spec's "Round 2 additions" records the scope change.
A practice to reuse
Let the AI choose from a closed list; let a cited, tested table decide everything that matters.
- The decisionThe model may only return
ProposalSchema(z.enum(PROPOSAL_IDS), a confidence, a short rationale) insrc/lib/classify.ts. Class, window and deadline come only fromsrc/lib/rules.jsonviaevaluate(). - The evidence
src/lib/rules.test.ts, test “every combination of every fact option reaches an outcome, and each one is cited”. It walks every issue and answer combination, in and out of heat season, and asserts a status, a§citation, an official source URL and a positive window.
How this helps on another project
- Anywhere an AI sorts things that carry consequences (support tickets, expense categories, eligibility checks): have it pick a label from a fixed enum and validate the answer with a schema.
- Keep thresholds, prices or policies in a data file with a source link per row, then write one exhaustive test that fails when an option has no outcome.
- A wrong AI answer can then only pick the wrong row, never invent an outcome, and changing a rule is a small reviewable data edit.
Run it again
Needs Node.js 20+ and npm. No keys for the demo.
$ npm install $ npm run dev
Open http://localhost:3000 → Try the demo building → Snap a problem → pick a sample scene (saved answers in src/lib/demo/).
$ npm test $ npm run test:e2e
npm test runs the Vitest unit tests. test:e2e runs Playwright, builds and starts the app itself, and blanks the AI keys.
Live AI and receipts (optional)
Put GOOGLE_GENERATIVE_AI_API_KEY or AI_GATEWAY_API_KEY in .env.local for live photos, and RECEIPT_SECRET to sign receipts. See .env.example and the README.
Snapshot, not a promise. This map was written against commit 28814de. Code moves and names drift: if a path or symbol is missing, search for the nearest name, or see what changed:
$ git diff --stat 28814de HEAD -- src