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
102 changes: 102 additions & 0 deletions src/components/features/NumberPreservationWarning.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 The offlinecv Authors

/**
* Number-preservation UI vocabulary (#778), shared by every surface that
* shows the result of `checkNumbersPreserved` / `applyNumberPreservation`
* (`src/lib/webllm/preserve-numbers.ts`, `post-process.ts`): the per-role
* `SectionRewrite` panel, the whole-résumé `ProposedPanel`
* (`ResumeRewriteProposed.tsx`), and the in-flight `CompletedList`
* (`ResumeRewrite.tsx`). Split out of `SectionRewrite.tsx` (#874 review) so a
* file already flagged as known debt over CLAUDE.md's ~200 LOC guidance
* doesn't keep absorbing every future change to this vocabulary, and so the
* three call sites read one classification instead of three independently
* written ones that could drift out of sync.
*
* Pure presentation + one pure classifier — no component state, no hooks.
*/

/**
* The one classification every number-preservation tone/badge decision in
* the tree reduces to. `"reverted"` must be checked before `"drift"`:
* `numbersPreserved` is true on a reverted rewrite by construction (#778),
* so testing it first would read a reverted section as a clean pass.
*/
export type NumberDriftStatus = "reverted" | "drift" | "clean";

export function numberDriftStatus(result: {
numbersPreserved: boolean;
reverted: boolean;
}): NumberDriftStatus {
if (result.reverted) return "reverted";
if (!result.numbersPreserved) return "drift";
return "clean";
}

/**
* Caption the diff carries when a rewrite was rejected (#778). The two sides
* are identical in that case, so without it the panel reads as "the model
* looked at your bullets and changed nothing" — which is a different, and
* false, story about what happened.
*/
export const REVERTED_DIFF_LABEL =
"No changes applied — your original bullets.";

export function NumberPreservationWarning({
dropped,
added,
reverted = false,
}: {
dropped: readonly string[];
added: readonly string[];
/**
* The rewrite was rejected and the original kept (#778). Changes the copy
* from "check what the AI changed" to "nothing changed, and here's why" —
* the delivered bullets are the user's own, so telling them to review a
* metric they never lost would be wrong.
*/
reverted?: boolean;
}) {
const detail = describeNumberDrift(dropped, added);
if (reverted) {
return (
<p
role="alert"
className="text-2xs leading-snug text-feedback-warning-text"
>
<span aria-hidden="true">⚠ </span>
Kept your original — the rewrite {detail}, so I didn’t apply it. Try
again for a different attempt.
</p>
);
}
return (
<p role="alert" className="text-2xs leading-snug text-feedback-warning-text">
<span aria-hidden="true">⚠ </span>
AI altered a metric — {detail}. Review before saving.
</p>
);
}

/**
* The one phrasing of "what the model did to the numbers", shared by the
* revert notice, the drift warning, and the whole-résumé per-section label.
* Both halves are named because the gate reverts on either since the #778
* widening — quoting only the dropped ones left an invention-only revert
* saying the rewrite dropped nothing at all.
*/
export function describeNumberDrift(
dropped: readonly string[],
added: readonly string[],
): string {
const parts: string[] = [];
if (dropped.length > 0) parts.push(`removed ${formatTokens(dropped)}`);
if (added.length > 0) parts.push(`invented ${formatTokens(added)}`);
return parts.join(" and ");
}

export function formatTokens(tokens: readonly string[]): string {
if (tokens.length === 1) return tokens[0]!;
if (tokens.length === 2) return `${tokens[0]} and ${tokens[1]}`;
return `${tokens.slice(0, -1).join(", ")}, and ${tokens[tokens.length - 1]}`;
}
109 changes: 106 additions & 3 deletions src/components/features/ResumeRewrite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const okResult: ResumeRewriteResult = {
data: {
text: "Senior engineer.",
numbersPreserved: true,
reverted: false,
droppedNumbers: [],
addedNumbers: [],
},
Expand All @@ -55,6 +56,7 @@ const okResult: ResumeRewriteResult = {
data: {
bullets: ["Shipped Foo to 10M users."],
numbersPreserved: true,
reverted: false,
droppedNumbers: [],
addedNumbers: [],
},
Expand All @@ -71,6 +73,7 @@ const driftResult: ResumeRewriteResult = {
data: {
bullets: ["Saved money."],
numbersPreserved: false,
reverted: false,
droppedNumbers: ["$5K"],
addedNumbers: [],
},
Expand All @@ -81,6 +84,7 @@ const driftResult: ResumeRewriteResult = {
data: {
text: "Senior engineer with 99.9% availability.",
numbersPreserved: false,
reverted: false,
droppedNumbers: [],
addedNumbers: ["99.9%"],
},
Expand Down Expand Up @@ -164,15 +168,25 @@ describe("sectionsEqual", () => {

describe("aggregateDrift", () => {
it("returns empty arrays for an all-clean result", () => {
expect(aggregateDrift(okResult)).toEqual({ dropped: [], added: [] });
expect(aggregateDrift(okResult, () => true)).toEqual({
dropped: [],
added: [],
});
});

it("collects dropped and added tokens across every section regardless of kind", () => {
expect(aggregateDrift(driftResult)).toEqual({
it("collects dropped and added tokens across every included section regardless of kind", () => {
expect(aggregateDrift(driftResult, () => true)).toEqual({
dropped: ["$5K"],
added: ["99.9%"],
});
});

it("only collects tokens from sections `include` selects (#874 review)", () => {
expect(aggregateDrift(driftResult, () => false)).toEqual({
dropped: [],
added: [],
});
});
});

describe("StepIndicator", () => {
Expand Down Expand Up @@ -330,3 +344,92 @@ describe("ResumeRewritePanel", () => {
expect(html).toContain("99.9%");
});
});

// ── #778: reverted sections must not read as clean passes ───────────────────

describe("reverted sections (#778)", () => {
/** One reverted experience section + one clean one. */
const revertedResult: ResumeRewriteResult = {
allNumbersPreserved: true,
sections: [
{
kind: "experience",
input: {
kind: "experience",
id: "experience:0",
label: "Engineer — Acme",
bullets: ["Grew ARR to $4.2M in FY24."],
},
data: {
bullets: ["Grew ARR to $4.2M in FY24."],
numbersPreserved: true,
reverted: true,
droppedNumbers: ["$4.2M"],
addedNumbers: [],
},
},
{
kind: "experience",
input: {
kind: "experience",
id: "experience:1",
label: "Engineer — Beta",
bullets: ["Owned the write path."],
},
data: {
bullets: ["Owned and hardened the write path."],
numbersPreserved: true,
reverted: false,
droppedNumbers: [],
addedNumbers: [],
},
},
],
};

it("badges a reverted section in the in-flight list instead of showing a clean tick", () => {
// `numbersPreserved` is true on a reverted section by construction, so
// without an explicit branch the user would never learn their section
// went unrewritten.
const status: ResumeRewriteStatus = {
kind: "running",
progress: {
currentIndex: 2,
totalSections: 2,
currentLabel: null,
completed: revertedResult.sections,
},
};
const html = renderToStaticMarkup(
createElement(ResumeRewritePanel, {
status,
onDismiss: () => {},
onApplied: () => {},
onUndo: () => {},
}),
);
expect(html).toContain("kept original");
// The clean section must not pick up the badge.
expect(html.match(/kept original/g)).toHaveLength(1);
expect(html).not.toContain("metric drift");
});

it("captions the reverted section's empty diff in the proposed view", () => {
const status: ResumeRewriteStatus = {
kind: "proposed",
result: revertedResult,
snapshot: [],
};
const html = renderToStaticMarkup(
createElement(ResumeRewritePanel, {
status,
onDismiss: () => {},
onApplied: () => {},
onUndo: () => {},
}),
);
expect(html).toContain("Kept unchanged");
expect(html).toContain("$4.2M");
});
});

14 changes: 13 additions & 1 deletion src/components/features/ResumeRewrite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
ProposedPanel,
type ResumeRewriteApply,
} from "./ResumeRewriteProposed.tsx";
import { numberDriftStatus } from "./NumberPreservationWarning.tsx";
import { RewritePromptDisclosure } from "./RewritePromptDisclosure.tsx";

export interface ResumeRewriteParts {
Expand Down Expand Up @@ -425,7 +426,18 @@ function CompletedList({
</span>
<span>{outcome.input.label}</span>
{!outcome.data.numbersPreserved && (
{/* #778: a reverted section preserves every number by construction,
so `numbersPreserved` alone would render it as a clean pass and
the user would never learn their section went unrewritten. */}
{numberDriftStatus(outcome.data) === "reverted" && (
<span
className="rounded bg-feedback-warning-bg px-1.5 py-0.5 text-3xs text-feedback-warning-text"
title="The rewrite dropped or invented a metric, so the original was kept"
>
kept original
</span>
)}
{numberDriftStatus(outcome.data) === "drift" && (
<span
className="rounded bg-feedback-warning-bg px-1.5 py-0.5 text-3xs text-feedback-warning-text"
title="A metric was altered or removed"
Expand Down
Loading
Loading