diff --git a/src/components/features/AtsScoreReadout.tsx b/src/components/features/AtsScoreReadout.tsx index f8345f9a..5f6ea035 100644 --- a/src/components/features/AtsScoreReadout.tsx +++ b/src/components/features/AtsScoreReadout.tsx @@ -9,9 +9,9 @@ import type { AnonymousAtsScore } from "../../lib/score/score.ts"; import { getScoreTier } from "../../lib/score/score.ts"; +import { getScoreRecommendation } from "../../lib/score/recommendation.ts"; import { ScoreRing } from "./ScoreRing.tsx"; import { VerdictHeader } from "./VerdictHeader.tsx"; -import type { VerdictDimension } from "./VerdictHeader.tsx"; import { scoreBandBgClass, scoreBandTextClass } from "./scoreBand.ts"; import { timeAgo } from "../../lib/date-utils.ts"; @@ -82,7 +82,7 @@ export function AtsScoreReadout({ score }: AtsScoreReadoutProps) { // timestamp is unparseable or somehow in the future. const buildAgo = timeAgo(__BUILD_DATE__) || buildDate; - // Compute hint strings once — shared between VerdictHeader and Dimension cards. + // Hint strings for the three Dimension cards. const specificityHint = `${score.specificity.metricBullets}/${score.specificity.totalBullets} bullets carry a metric`; const structureHint = `${score.structure.goodBullets}/${score.structure.totalBullets} bullets within 8–30 words`; const completenessHint = @@ -93,29 +93,8 @@ export function AtsScoreReadout({ score }: AtsScoreReadoutProps) { ? " · Dates appear redacted — use 4-digit years for best results." : ""); - const dimensions: VerdictDimension[] = [ - { - label: "Specificity", - score: score.specificity.score, - max: score.specificity.max, - gradable: score.specificity.gradable, - hint: specificityHint, - }, - { - label: "Structure", - score: score.structure.score, - max: score.structure.max, - gradable: score.structure.gradable, - hint: structureHint, - }, - { - label: "Completeness", - score: score.completeness.score, - max: score.completeness.max, - gradable: score.completeness.gradable, - hint: completenessHint, - }, - ]; + // One actionable next-step sentence for the verdict band (#42). + const recommendation = getScoreRecommendation(score); return (
@@ -145,7 +124,7 @@ export function AtsScoreReadout({ score }: AtsScoreReadoutProps) {
- +
d.gradable && d.max > 0); - const biggestGap = - gradable.length > 0 - ? gradable.reduce((worst, d) => - d.score / d.max < worst.score / worst.max ? d : worst, - ) - : null; - return (

{label}

- {biggestGap && ( -

- - Biggest gap: {biggestGap.label} - - {" — "} - {biggestGap.hint} -

- )} +

{recommendation}

); } diff --git a/src/lib/score/recommendation.test.ts b/src/lib/score/recommendation.test.ts new file mode 100644 index 00000000..c9145c74 --- /dev/null +++ b/src/lib/score/recommendation.test.ts @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The resumelint Authors + +import { getScoreRecommendation } from "./recommendation"; +import type { AnonymousAtsScore } from "./score"; + +/** Build an AnonymousAtsScore with sensible "strong, no penalty" defaults, so + * each test overrides only the fields its branch depends on. */ +function makeScore(overrides: { + overall?: number; + preLayoutOverall?: number; + specificity?: Partial; + structure?: Partial; + completeness?: Partial; + layout?: Partial; +} = {}): AnonymousAtsScore { + return { + overall: overrides.overall ?? 85, + preLayoutOverall: overrides.preLayoutOverall ?? overrides.overall ?? 85, + specificity: { + score: 36, + max: 40, + gradable: true, + metricBullets: 6, + totalBullets: 10, + ...overrides.specificity, + }, + structure: { + score: 27, + max: 30, + gradable: true, + goodBullets: 9, + totalBullets: 10, + ...overrides.structure, + }, + completeness: { + score: 27, + max: 30, + gradable: true, + missing: [], + ...overrides.completeness, + }, + layout: { + triggers: [], + multiplier: 1, + scanned: false, + ...overrides.layout, + }, + }; +} + +describe("getScoreRecommendation", () => { + it("flags a scanned PDF as the hard blocker, ahead of everything else", () => { + const msg = getScoreRecommendation( + makeScore({ layout: { scanned: true, multiplier: 0, triggers: ["scanned"] } }), + ); + expect(msg).toMatch(/scanned image/i); + expect(msg).toMatch(/text-based PDF/i); + }); + + it("leads with the layout penalty and names a single trigger", () => { + const msg = getScoreRecommendation( + makeScore({ + overall: 66, + preLayoutOverall: 78, + layout: { triggers: ["two_column"], multiplier: 0.85, scanned: false }, + }), + ); + expect(msg).toContain("78/100"); + expect(msg).toContain("multi-column layout"); + expect(msg).toMatch(/fix that layout first/i); + }); + + it("names multiple layout triggers conjoined", () => { + const msg = getScoreRecommendation( + makeScore({ + preLayoutOverall: 80, + layout: { + triggers: ["two_column", "fonts_unmappable"], + multiplier: 0.7, + scanned: false, + }, + }), + ); + expect(msg).toContain("multi-column layout"); + expect(msg).toContain("and font encoding the parser can't read"); + }); + + it("scanned takes priority even when other triggers are present", () => { + const msg = getScoreRecommendation( + makeScore({ + layout: { + triggers: ["scanned", "two_column"], + multiplier: 0, + scanned: true, + }, + }), + ); + expect(msg).toMatch(/scanned image/i); + expect(msg).not.toContain("multi-column"); + }); + + it("points at Specificity when it is the weakest gradable dimension", () => { + const msg = getScoreRecommendation( + makeScore({ + overall: 70, + specificity: { score: 8, max: 40 }, // ratio 0.20 — lowest + structure: { score: 24, max: 30 }, // 0.80 + completeness: { score: 24, max: 30 }, // 0.80 + }), + ); + expect(msg).toMatch(/add metrics/i); + expect(msg).toContain("A generic parser gets most of this"); // overall 70 → medium tier + }); + + it("points at Structure when it is the weakest gradable dimension", () => { + const msg = getScoreRecommendation( + makeScore({ + overall: 70, + specificity: { score: 32, max: 40 }, // 0.80 + structure: { score: 6, max: 30 }, // 0.20 — lowest + completeness: { score: 24, max: 30 }, // 0.80 + }), + ); + expect(msg).toMatch(/tighten each bullet/i); + expect(msg).toMatch(/action verb/i); + }); + + it("points at Completeness and cites the missing fields", () => { + const msg = getScoreRecommendation( + makeScore({ + overall: 65, + specificity: { score: 32, max: 40 }, + structure: { score: 24, max: 30 }, + completeness: { score: 3, max: 30, missing: ["phone", "location"] }, + }), + ); + expect(msg).toContain("phone and location"); + expect(msg).toMatch(/extract as plain text/i); + }); + + it("uses singular 'extracts' for a single missing field", () => { + const msg = getScoreRecommendation( + makeScore({ + completeness: { score: 3, max: 30, missing: ["email"] }, + specificity: { score: 36, max: 40 }, + structure: { score: 27, max: 30 }, + }), + ); + expect(msg).toContain("check that email extracts as plain text"); + }); + + it("surfaces the 4-digit-years guidance when dates are redacted", () => { + const msg = getScoreRecommendation( + makeScore({ + completeness: { + score: 6, + max: 30, + missing: ["role dates"], + redactedDates: true, + }, + specificity: { score: 36, max: 40 }, + structure: { score: 27, max: 30 }, + }), + ); + expect(msg).toMatch(/4-digit years/i); + expect(msg).toMatch(/redaction stubs/i); + }); + + it("falls back when no dimension is gradable", () => { + const msg = getScoreRecommendation( + makeScore({ + overall: 30, + specificity: { score: 0, max: 40, gradable: false }, + structure: { score: 0, max: 30, gradable: false }, + completeness: { score: 0, max: 30, gradable: false }, + }), + ); + expect(msg).toMatch(/add a few quantified bullets/i); + expect(msg).toContain("A generic extractor struggles here"); + }); + + it("uses the matching band opener for each tier", () => { + const weakSpec = { specificity: { score: 8, max: 40 } }; + expect( + getScoreRecommendation(makeScore({ overall: 85, ...weakSpec })), + ).toContain("Most generic parsers should read this cleanly"); + expect( + getScoreRecommendation(makeScore({ overall: 65, ...weakSpec })), + ).toContain("A generic parser gets most of this"); + expect( + getScoreRecommendation(makeScore({ overall: 40, ...weakSpec })), + ).toContain("A generic extractor struggles here"); + }); + + it("is deterministic — same input yields the same sentence", () => { + const score = makeScore({ overall: 65, completeness: { score: 6, max: 30, missing: ["phone"] } }); + expect(getScoreRecommendation(score)).toBe(getScoreRecommendation(score)); + }); +}); diff --git a/src/lib/score/recommendation.ts b/src/lib/score/recommendation.ts new file mode 100644 index 00000000..726fa707 --- /dev/null +++ b/src/lib/score/recommendation.ts @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The resumelint Authors + +/** + * Verdict recommendation — turns a computed {@link AnonymousAtsScore} into one + * actionable sentence (#42). The band label tells the user *where they landed*; + * this tells them *what to do next*, keyed off the same breakdown we already + * compute so the copy round-trips to real fields (no vibes, no LLM): + * + * 1. A scanned PDF is a hard blocker — nothing else matters until the text is + * selectable, so it short-circuits first. + * 2. A layout penalty (`multiplier < 1`: two-column / unmappable fonts) is the + * dominant drag even when the underlying content scores well, so it comes + * before the dimension advice and names the actual triggers. + * 3. Otherwise lead with the band and point at the weakest *gradable* + * dimension — the same lowest-`score/max` pick `VerdictHeader` used for its + * "biggest gap" — with the concrete next step for that dimension. + */ + +import type { AnonymousAtsScore } from "./score.ts"; +import { getScoreTier } from "./score.ts"; +import type { ScoreTier } from "./types.ts"; + +/** Band-opening clause (no trailing punctuation — the caller appends the step). */ +const BAND_OPENER: Record = { + high: "Most generic parsers should read this cleanly", + medium: "A generic parser gets most of this", + low: "A generic extractor struggles here", +}; + +/** Short, friendly names for the layout triggers we penalize. Kept terse for + * inline use — the full explanation lives in `LayoutFlagsList`. */ +const TRIGGER_PHRASE: Record = { + two_column: "multi-column layout", + fonts_unmappable: "font encoding the parser can't read", +}; + +function describeTriggers(triggers: readonly string[]): string { + const phrases = triggers + .filter((t) => t !== "scanned") + .map((t) => TRIGGER_PHRASE[t] ?? t.replace(/_/g, " ")); + if (phrases.length === 0) return "the flagged layout"; + if (phrases.length === 1) return phrases[0]; + return `${phrases.slice(0, -1).join(", ")} and ${phrases[phrases.length - 1]}`; +} + +interface GradableDimension { + key: "specificity" | "structure" | "completeness"; + label: string; + ratio: number; +} + +/** Lowest score/max among gradable dimensions — mirrors the math VerdictHeader + * used for "biggest gap" so the recommendation points at the same dimension. */ +function weakestDimension(score: AnonymousAtsScore): GradableDimension | null { + const dims: GradableDimension[] = []; + if (score.specificity.gradable && score.specificity.max > 0) { + dims.push({ + key: "specificity", + label: "Specificity", + ratio: score.specificity.score / score.specificity.max, + }); + } + if (score.structure.gradable && score.structure.max > 0) { + dims.push({ + key: "structure", + label: "Structure", + ratio: score.structure.score / score.structure.max, + }); + } + if (score.completeness.gradable && score.completeness.max > 0) { + dims.push({ + key: "completeness", + label: "Completeness", + ratio: score.completeness.score / score.completeness.max, + }); + } + if (dims.length === 0) return null; + return dims.reduce((worst, d) => (d.ratio < worst.ratio ? d : worst)); +} + +/** The concrete next step for the weakest dimension (lowercase imperative). */ +function dimensionStep( + weakest: GradableDimension, + score: AnonymousAtsScore, +): string { + switch (weakest.key) { + case "specificity": + return "add metrics like numbers, %, or $ to more bullets so each one shows measurable impact"; + case "structure": + return "tighten each bullet to a single line that opens with an action verb"; + case "completeness": { + if (score.completeness.redactedDates) { + return "use real 4-digit years on your roles — the dates currently read as redaction stubs"; + } + const missing = score.completeness.missing; + if (missing.length === 0) { + return "round out the remaining contact and section fields"; + } + const list = missing.slice(0, 2).join(" and "); + const verb = missing.length === 1 ? "extracts" : "extract"; + return `check that ${list} ${verb} as plain text`; + } + } +} + +/** + * Build the single actionable recommendation sentence shown beneath the verdict + * band. Deterministic — the same input always yields the same sentence. + */ +export function getScoreRecommendation(score: AnonymousAtsScore): string { + // 1. Scanned — a plain-text extractor reads nothing; fix this before all else. + if (score.layout.scanned) { + return "This reads as a scanned image, not selectable text — export a real text-based PDF before anything else."; + } + + // 2. Layout penalty dominates even when the content itself scores well. + if (score.layout.multiplier < 1) { + return `Your content scored ${score.preLayoutOverall}/100, but a ${describeTriggers( + score.layout.triggers, + )} will scramble it for many parsers — fix that layout first.`; + } + + // 3. Lead with the band, point at the weakest gradable dimension. + const opener = BAND_OPENER[getScoreTier(score.overall)]; + const weakest = weakestDimension(score); + if (!weakest) { + return `${opener} — add a few quantified bullets under your roles so there's something to grade.`; + } + return `${opener} — ${dimensionStep(weakest, score)}.`; +}