Interfaces · Shipped
Paste a résumé and a job description and get a scored gap report — what matches, what's missing, and what to rewrite.
Paste a résumé and a job description and get a scored gap report — what matches, what's missing, and what to rewrite.
Day 02 / 30 of a 30-day build series.
Two text boxes: your résumé on one side, a job posting on the other. Out comes a scored comparison across six competency axes, a radar chart showing the shape of the fit, and a severity-sorted list of gaps — each one naming the specific requirement the résumé doesn't answer, with a suggested rewrite you can generate on the spot.
The point is that "your résumé is a 62% match" is useless on its own. What a candidate needs is the ranked list underneath the number: which two lines to change first, and what to say instead.


The two highlighted nodes are where the work is. Everything downstream of the validator is ordinary React — the whole product depends on the model returning a well-formed scoring object, so the interesting engineering is the constraint and the repair path, not the chart.
The rewrite calls are deliberately outside the main request. A gap list of nine items would mean nine rewrites nobody reads; instead each card requests its own suggestion when opened.
fit-report/
├── app/
│ ├── api/
│ │ ├── analyze/
│ │ │ └── route.ts # rubric + schema-constrained call + repair
│ │ └── rewrite/
│ │ └── route.ts # per-gap suggestion, called lazily
│ ├── debug/
│ │ └── page.tsx # dev-only debug dashboard, 404s in production
│ ├── layout.tsx
│ ├── page.tsx # server component — reads MAX_INPUT_CHARS, renders the client shell
│ └── globals.css
│
├── components/
│ ├── GapScannerApp.tsx # client shell: form state, /api/analyze call, layout
│ ├── InputPanel.tsx # résumé + JD textareas, char counters
│ ├── CompetencyRadar.tsx # Recharts radar, 6 axes
│ ├── GapList.tsx # severity sort + filter over the gap array
│ └── GapCard.tsx # requirement, evidence, severity chip, rewrite button
│
├── lib/
│ ├── schema.ts # Zod schema — the single source of truth
│ ├── rubric.ts # the 6 axes and their scoring criteria, as data
│ ├── claude.ts # structured-output client, validate-and-repair
│ ├── severity.ts # score + requirement weight → red/amber/green
│ ├── apiGuards.ts # shared MAX_INPUT_CHARS / token-cap / timeout guards
│ ├── tokenCap.ts # in-memory DAILY_TOKEN_CAP guard
│ └── debugStore.ts # in-memory ring buffer backing /debug
│
├── shell/ # copied forward from Day 01 (tone-studio)
│ ├── useStream.ts
│ ├── Toast.tsx
│ ├── FileDrop.tsx
│ └── theme.css
│
├── .env.example
├── package.json
└── README.md
lib/apiGuards.ts, lib/tokenCap.ts, and lib/debugStore.ts aren't in the original spec's file list — they grew out of /api/analyze and /api/rewrite needing the exact same input-limit, token-cap, and timeout logic, and the debug dashboard needing somewhere to keep its ring buffer. lib/schema.ts still defines the Zod schema and derives the JSON Schema sent to the model from it, so the contract can't drift between what's requested and what's validated — one edit changes both ends. lib/rubric.ts keeps the axes and their criteria as data for the same reason: changing what "Domain depth" means should be a config edit, not a prompt rewrite buried in a route handler.
| Layer | Choice | Why this one |
|---|---|---|
| Model | Claude (claude-sonnet-4-6) | Reliable adherence to a JSON schema and to explicit rubric criteria |
| Output contract | Structured outputs / JSON schema, via @anthropic-ai/sdk's zodOutputFormat() helper | The dashboard is a rendering of a typed object; prose parsing would fail weekly. Anthropic's structured-output API accepts a narrower JSON Schema subset than Zod's default output (no minimum/maximum on numbers, no maxItems, minItems only as 0 or 1) — hand-rolling z.toJSONSchema() produces requests the API rejects outright, so the schema is run through the SDK's own transform instead |
| Validation | Zod v4, schema derived to JSON Schema | Runtime guarantee at the boundary, plus inferred TypeScript types for free |
| Repair | One retry (default, REPAIR_ATTEMPTS) with the validation error appended | Cheaper and more reliable than regenerating blind |
| Rubric | Criteria table in lib/rubric.ts | Explicit anchors per score band; without them scores are noise |
| Backend | Next.js 16 (App Router) route handlers | Two endpoints, no reason for a separate service |
| Charts | Recharts | Radar out of the box, small enough not to dominate the bundle |
| UI | React 19 + Tailwind CSS v4 | Same shell/ as Day 01, so the scaffolding cost was zero |
| Guards | Input ceiling + token cap + timeout | Résumés and JDs are both attacker-controlled free text on a public demo |
One schema-constrained call scoring all six axes together, rather than one call per axis.
Day 01 fanned out into five parallel calls and that was the right shape there. Here it's the wrong one, and the reason is the difference worth understanding:
The cost of one call is that a schema violation loses all six axes rather than one. That's what the repair retry is for: on a Zod failure the raw output and the validation error go back to the model with an instruction to return corrected JSON only. One retry fixes nearly everything; a second is capped off because a model that has failed twice on the same schema is not going to succeed on the third try, and the honest move is to surface the error.
Secondary decision: rewrite suggestions are generated per gap on demand rather than in the main response. It keeps the first result fast, and most users only ever act on the top two or three gaps.
cd fit-report
cp .env.example .env.local # add your ANTHROPIC_API_KEY
npm install
npm run dev # http://localhost:3000
Other scripts: npm run build (production build), npm start (serve the build), npm run lint, npm run typecheck.
Environment variables:
| Variable | Required | Purpose |
|---|---|---|
ANTHROPIC_API_KEY | yes | Model access |
MAX_INPUT_CHARS | no | Per-field ceiling, defaults to 12000 |
DAILY_TOKEN_CAP | no | Spend guard for the public demo, defaults to no cap |
REPAIR_ATTEMPTS | no | Schema repair retries, defaults to 1 |
MAX_INPUT_CHARS is read server-side and passed down into the client UI (app/page.tsx → components/GapScannerApp.tsx), so the on-screen character counter always matches what the API actually enforces. DAILY_TOKEN_CAP and the per-route request timeouts (45s on /api/analyze, 20s on /api/rewrite) are enforced entirely server-side with no client-visible config — they exist to bound cost and latency, not to change the UI.