App map · take-home guide

Habitable, mapped

A New York City renter snaps a photo of something broken. The app fingerprints and dates it, an AI proposes what kind of problem it is, and a cited rules table decides the housing-code class and repair window. Everything lands in a dated log, and one tap makes a repair notice. This page traces one action through the code so you can find your way back in.

Snapshot of commit 28814de

Three pieces

One website that behaves like a phone app. No account, no database.

Phone app (the browser)

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

Small server (API routes)

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

Rules table

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.

  1. 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.

  2. 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 POST
    src/lib/classify.ts classifyImage()

    You seeA pencil loader: “reading the photo…”

  3. 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

  4. 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?”

  5. 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).

Storage · shown lightly“Add to my log” saves the issue on the device: saveIssue() in src/lib/store.ts (IndexedDB, with an in-memory fallback).
NYC Open Data · shown lightlyGET /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.

  1. 1The step machine

    src/components/snap/SnapSheet.tsx

    Search: async function takeFile

    The sheet walks Photo → AI proposal → Rule → Log (STEPS). takeFile() reads the photo, starts the receipt request, and calls /api/classify. Further down it calls evaluate() and renders the cards.

    What to notice
    • Search const outcome = chosen: the rules run here, in the browser, from the chosen type plus known facts.
    • The renter can correct the AI (IssuePicker in ProposalCard.tsx); the pick goes through the same evaluate().
    • If the AI is unavailable, apply() switches to the manual picker and the rest still works.
  2. 2Where the AI stops

    src/lib/classify.ts

    Search: export function decide(

    decide() is pure: it applies the CONFIDENCE_THRESHOLD (0.7) in code and adds one clarifying question when the model is unsure. The model's whole job is ProposalSchema.

    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 POST in src/app/api/classify/route.ts: input checks, rate limits, and saved demo answers through the same decide().
  3. 3The table decides

    src/lib/rules.ts · src/lib/rules.json

    Search: export function evaluate( · "leak-active"

    evaluate() walks an issue's variants in table order. The first whose when facts 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 short quote and a plain-English line.

To change this, start here

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) in src/lib/classify.ts. Class, window and deadline come only from src/lib/rules.json via evaluate().
  • The evidencesrc/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