Ahmad Tawil

Interfaces · Shipped

Fit Report

Paste a résumé and a job description and get a scored gap report — what matches, what's missing, and what to rewrite.

Résumé + JD
Schema-constrained call
Zod validator
Repair retry
Radar + gap list
TypeScriptNext.jsClaude APIZodRecharts

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.


1. What it does #

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.

Résumé and job description inputs

Radar chart and severity-sorted gap list


2. Architecture #

Client

Server · /api/analyze

invalid

valid

POST /api/rewrite

Résumé

Job description

Rubric · 6 axes

lib/rubric.ts

Single call

schema-constrained

Zod validate

Repair retry ×1

Claude API

structured output

Scoring object

Radar chart

Ranked gap list

Rewrite · on demand

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.


3. Project structure #

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.


4. Stack #

LayerChoiceWhy this one
ModelClaude (claude-sonnet-4-6)Reliable adherence to a JSON schema and to explicit rubric criteria
Output contractStructured outputs / JSON schema, via @anthropic-ai/sdk's zodOutputFormat() helperThe 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
ValidationZod v4, schema derived to JSON SchemaRuntime guarantee at the boundary, plus inferred TypeScript types for free
RepairOne retry (default, REPAIR_ATTEMPTS) with the validation error appendedCheaper and more reliable than regenerating blind
RubricCriteria table in lib/rubric.tsExplicit anchors per score band; without them scores are noise
BackendNext.js 16 (App Router) route handlersTwo endpoints, no reason for a separate service
ChartsRechartsRadar out of the box, small enough not to dominate the bundle
UIReact 19 + Tailwind CSS v4Same shell/ as Day 01, so the scaffolding cost was zero
GuardsInput ceiling + token cap + timeoutRésumés and JDs are both attacker-controlled free text on a public demo

5. Key decision #

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:

  1. The scores are relative, not independent. Six competency scores have something to say to each other — calling "Domain depth" a 7 only means something next to what "Tooling" got. Scored in six isolated calls, the model has no way to calibrate across axes and everything converges on 6–7 out of 10. Scored together, the spread appears, and the spread is the entire value of the radar chart.
  2. The input is paid for once. Both documents have to be in context for every axis. Six calls means six copies of the same résumé and job description, for output that is only a few hundred tokens.

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.


6. Run it #

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:

VariableRequiredPurpose
ANTHROPIC_API_KEYyesModel access
MAX_INPUT_CHARSnoPer-field ceiling, defaults to 12000
DAILY_TOKEN_CAPnoSpend guard for the public demo, defaults to no cap
REPAIR_ATTEMPTSnoSchema repair retries, defaults to 1

MAX_INPUT_CHARS is read server-side and passed down into the client UI (app/page.tsxcomponents/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.


7. What I'd do next #

  • The six axes are fixed. They fit software roles and start bending on anything else — a nursing post or a sales role wants different axes entirely. Deriving the rubric from the job description first, then scoring against it, is the obvious next version. That's the two-stage pattern Day 06 (Voice Print) is built on.
  • No verification of the evidence. Each gap cites the résumé line it's judging, but nothing checks that the quoted line actually appears in the input. A string match against the source before rendering would catch fabricated evidence cheaply.
  • Scores are unvalidated against reality. There's no ground truth here — the numbers are plausible, not measured. A set of twenty résumé/JD pairs with human-assigned fit labels would show whether the rubric tracks anything, and that harness is what Day 12 builds properly.