Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 5 additions & 26 deletions src/components/features/AtsScoreReadout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 =
Expand All @@ -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 (
<section className="flex flex-col gap-2">
Expand Down Expand Up @@ -145,7 +124,7 @@ export function AtsScoreReadout({ score }: AtsScoreReadoutProps) {
<div className="flex flex-col gap-4 md:flex-row md:items-start">
<div className="flex items-center gap-4 md:min-w-0 md:flex-1">
<ScoreRing score={score.overall} />
<VerdictHeader score={score.overall} dimensions={dimensions} />
<VerdictHeader score={score.overall} recommendation={recommendation} />
</div>
<dl className="grid min-w-0 flex-1 grid-cols-1 gap-3 text-xs sm:grid-cols-3">
<Dimension
Expand Down
34 changes: 6 additions & 28 deletions src/components/features/VerdictHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,45 +4,23 @@
import { getScoreLabel, getScoreTier } from "../../lib/score/score.ts";
import { scoreBandTextClass } from "./scoreBand.ts";

export interface VerdictDimension {
label: string;
score: number;
max: number;
gradable: boolean;
hint: string;
}

interface VerdictHeaderProps {
score: number;
dimensions: VerdictDimension[];
/** One actionable next-step sentence, precomputed by `getScoreRecommendation`
* (#42). Replaces the former "biggest gap" diagnostic: it points at the same
* weakest dimension but is layout/scanned-aware and says what to *do*. */
recommendation: string;
}

export function VerdictHeader({ score, dimensions }: VerdictHeaderProps) {
export function VerdictHeader({ score, recommendation }: VerdictHeaderProps) {
const tier = getScoreTier(score);
const label = getScoreLabel(tier);
const colorCls = scoreBandTextClass(tier);

// Find biggest gap: lowest score/max among gradable dimensions
const gradable = dimensions.filter((d) => 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 (
<div className="flex flex-col justify-center gap-0.5">
<p className={`text-2xl font-semibold ${colorCls}`}>{label}</p>
{biggestGap && (
<p className="text-sm text-content-muted">
<span className="font-medium text-content-secondary">
Biggest gap: {biggestGap.label}
</span>
{" — "}
{biggestGap.hint}
</p>
)}
<p className="text-sm text-content-secondary">{recommendation}</p>
</div>
);
}
200 changes: 200 additions & 0 deletions src/lib/score/recommendation.test.ts
Original file line number Diff line number Diff line change
@@ -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<AnonymousAtsScore["specificity"]>;
structure?: Partial<AnonymousAtsScore["structure"]>;
completeness?: Partial<AnonymousAtsScore["completeness"]>;
layout?: Partial<AnonymousAtsScore["layout"]>;
} = {}): 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));
});
});
Loading
Loading