Store the report's render model, not the scorer's reasoning. Today the JSON says the same thing three times and buries the two fields the card actually needs. This redesign makes the candidate report a direct read — the scorer decides what shows, the frontend just shows it.
Four things make the current score.json hard to use and hard to read:
| Smell | What's happening |
|---|---|
| The same judgement, stored 3× | signals[] (structured) + positives/concerns/unknowns[] (flat sentences) + report (a paragraph). "~2.5 yrs — below the 5-yr minimum" is written in all three. The lists and report are just signals re-sorted by outcome. |
| Static config on every candidate | type, effect, weight never vary by candidate — required_skill_match is always core/score, and its weight depends only on the band (already in context.json). contribution_pts = score × weight × 100 — derived, not data. |
| Three prose fields per signal | verdict (paragraph) + detail (sentences) + score_impact (another sentence) overlap. The card needs one short line + evidence. |
| The render inputs are missing | No short chip caption, no explicit "show this?" decision, and state is unreliable (we saw red_flags labelled "positive"). The FE has to re-derive tone & visibility every render. |
Net: 14 keys per signal + 4 redundant top-level blocks — and none of it is chip-ready.
FAMILY_META): family · label · type · effect · what_we_extract. Weight already lives in context.json → weights[family].headline, tone, and show; the FE renders them verbatim.Everything else (a Strengths/Concerns view, contribution points, the "type→effect" legend) is derived at render from these two layers — never stored twice.
// score.json — per candidate { "resume_hash": "…", "position_id": "…", "ctx_hash": "…", // plumbing "fit_score": 7, "bucket": "weak", "confidence": "medium", "summary": "Strong reject — UX designer; none of the 8 mandatory QA skills; 2.5 yrs vs a 5-yr bar.", "signals": [ { "family": "required_skill_match", // → joins the static def for label/type "score": 0.03, // 0–1 (was sub_score) "confidence": "high", "show": "chip", // chip | detail | hidden ★ scorer decides "headline": "0 / 8 required skills",// ≤6 words → chip label + hover title ★NEW // note: chip COLOUR is derived at render from score + type — not stored "explanation": "All 8 mandatory skills absent; entire skill set is UX/Product Design.", "evidence": [ { "source": "resume · skills", "quote": "Product Thinking, Visual Design… no QA skills" } ] } // …one per family (14) ] }
Per-signal: 14 keys → 7, and four of them (headline · explanation · evidence · show) are the render inputs. Top level: 4 redundant blocks → one summary sentence.
| Card element | Field(s) | Transform |
|---|---|---|
| Chip label | signals[].headline NEW | verbatim |
| Chip shown at all? | signals[].show NEW | === "chip" |
| Chip / pill colour | derived: score + type | core high→green · core low / risk→amber · else grey |
| Hover title | signals[].headline | verbatim |
| Hover body | signals[].explanation | verbatim (one sentence) |
| Hover meta | confidence · family→def.type | "Confidence: High · core" |
| Hover evidence | signals[].evidence[] | source→tag · quote→text |
| Bucket pill | bucket | enum→"Strong/Potential/Weak fit" |
| Overall hover | fit_score · bucket · summary | concatenate |
| Contribution pts (optional) | score × context.weights[family] | derived at render |
| Strengths / Concerns view (optional) | signals grouped by score/type | derived at render |
The scorer decides, the FE renders. show and headline are written by the scorer (applying the confidence + missing-≠-negative rules once), so the client never re-derives display logic. Colour is the one thing the FE computes — from score + the static type, never from a stored label. That's what kills the old mislabel bug (state:"positive" on a red flag): a number can't be mislabelled the way the state enum was, so we store neither state nor tone.
Deepanshi (UX designer, Fit 7 · weak) under the new schema. The scorer marked 3 signals show:"chip" — all confident negatives; the other 11 are detail or hidden. education_certs is hidden: a bare degree is table stakes — everyone has one, so it earns no signal (only a real, role-relevant certification would).
No modal — headline→title, explanation→body, evidence→rows, show→visibility; colour is computed from score+type, not stored.
| Removed | Was | Why safe |
|---|---|---|
DROP positives[] concerns[] unknowns[] | flat sentence lists | pure duplication of signals — a Strengths/Concerns view is signals grouped by score/type |
DROP report | long paragraph | restates the signals a third time; replaced by one summary sentence |
DROP recommendation | 2–3 sentences | folded into summary |
DROP per-signal type effect | core/score… | static per family → the definitions layer, not per candidate |
DROP per-signal weight contribution_pts | 0.237 · 0.71 | weight is in context.weights; pts is score×weight — derive |
DROP state tone interpreter confidence_effect | partial · warning · null · null | state unreliable & tone redundant → chip colour is derived from score+type at render; the other two were almost always null |
MERGE verdict + detail + score_impact | 3 prose fields | → one explanation sentence (+ headline for the short form) |
Contained to the scorer step — no consumers today except the local viewer.
steps/step07_score.py, lib/schemas.py): emit headline · explanation · show per signal; drop the removed keys (incl. tone); emit one summary. education_certs ignores a bare degree — only a role-relevant certification scores.label · type · effect · what_we_extract (already FAMILY_META); weight stays in context.json.STEP_VERSIONS["score"] to re-score the 10 sample candidates into the new shape.Utkrusht — internal design · a Résumé Fit result schema that renders itself