Utkrusht · Résumé Fit · Schema design

A result schema that renders itself

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.

The problem — the schema stores reasoning, so the UI has to reverse-engineer it

Four things make the current score.json hard to use and hard to read:

SmellWhat'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 candidatetype, 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 signalverdict (paragraph) + detail (sentences) + score_impact (another sentence) overlap. The card needs one short line + evidence.
The render inputs are missingNo 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.

1Principle — two layers, store only what varies

Static family definitions — 14 rows, stored once (this is your existing FAMILY_META): family · label · type · effect · what_we_extract. Weight already lives in context.json → weights[family].
Per-candidate result — only the fields that change per candidate, shaped so each maps 1:1 to something on the card. The scorer decides 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.

2The new per-candidate shape

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

3Every field maps to one thing on the card

Card elementField(s)Transform
Chip labelsignals[].headline NEWverbatim
Chip shown at all?signals[].show NEW=== "chip"
Chip / pill colourderived: score + typecore high→green · core low / risk→amber · else grey
Hover titlesignals[].headlineverbatim
Hover bodysignals[].explanationverbatim (one sentence)
Hover metaconfidence · family→def.type"Confidence: High · core"
Hover evidencesignals[].evidence[]source→tag · quote→text
Bucket pillbucketenum→"Strong/Potential/Weak fit"
Overall hoverfit_score · bucket · summaryconcatenate
Contribution pts (optional)score × context.weights[family]derived at render
Strengths / Concerns view (optional)signals grouped by score/typederived 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.

4Worked example — the same candidate, rendered directly

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

Deepanshi
● Weak fit ⚠ 0 / 8 required skills ⚠ 2.5 yrs · below 5-yr bar ⚠ No HR-tech domain

…and the hover for each (drawn straight from the fields; colour derived from score+type)

⚠ 0 / 8 required skills — required_skill_match
0 / 8 required skills (headline)
Confidence: High · core
resume · skillsProduct Thinking, Visual Design… no QA skills
⚠ 2.5 yrs · below 5-yr bar — relevant_experience
2.5 yrs · below 5-yr bar
Confidence: High · core
evaluation contextmin_years = 5; candidate ~2.5 yrs, wrong domain
⚠ No HR-tech domain — industry_domain_match
No HR-tech / recruiting domain
Confidence: High · core
resume · companiesFLAM (AR), LAXIS (SaaS), GROWW (Fintech), EY (Consulting)
● Weak fit — overall
Fit score 7 / 100 · Weak
Confidence: Medium
Strong reject — UX designer; none of the 8 mandatory QA skills; 2.5 yrs vs a 5-yr bar. (summary)

No modal — headline→title, explanation→body, evidence→rows, show→visibility; colour is computed from score+type, not stored.

5What we remove, and why it's safe

RemovedWasWhy safe
DROP positives[] concerns[] unknowns[]flat sentence listspure duplication of signals — a Strengths/Concerns view is signals grouped by score/type
DROP reportlong paragraphrestates the signals a third time; replaced by one summary sentence
DROP recommendation2–3 sentencesfolded into summary
DROP per-signal type effectcore/score…static per family → the definitions layer, not per candidate
DROP per-signal weight contribution_pts0.237 · 0.71weight is in context.weights; pts is score×weight — derive
DROP state tone interpreter confidence_effectpartial · warning · null · nullstate unreliable & tone redundant → chip colour is derived from score+type at render; the other two were almost always null
MERGE verdict + detail + score_impact3 prose fields→ one explanation sentence (+ headline for the short form)

6Rollout

Contained to the scorer step — no consumers today except the local viewer.

Utkrusht — internal design · a Résumé Fit result schema that renders itself