diff --git a/src/components/features/NumberPreservationWarning.tsx b/src/components/features/NumberPreservationWarning.tsx new file mode 100644 index 00000000..3895514c --- /dev/null +++ b/src/components/features/NumberPreservationWarning.tsx @@ -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 ( +

+ + Kept your original — the rewrite {detail}, so I didn’t apply it. Try + again for a different attempt. +

+ ); + } + return ( +

+ + AI altered a metric — {detail}. Review before saving. +

+ ); +} + +/** + * 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]}`; +} diff --git a/src/components/features/ResumeRewrite.test.ts b/src/components/features/ResumeRewrite.test.ts index 13d29390..914aa704 100644 --- a/src/components/features/ResumeRewrite.test.ts +++ b/src/components/features/ResumeRewrite.test.ts @@ -45,6 +45,7 @@ const okResult: ResumeRewriteResult = { data: { text: "Senior engineer.", numbersPreserved: true, + reverted: false, droppedNumbers: [], addedNumbers: [], }, @@ -55,6 +56,7 @@ const okResult: ResumeRewriteResult = { data: { bullets: ["Shipped Foo to 10M users."], numbersPreserved: true, + reverted: false, droppedNumbers: [], addedNumbers: [], }, @@ -71,6 +73,7 @@ const driftResult: ResumeRewriteResult = { data: { bullets: ["Saved money."], numbersPreserved: false, + reverted: false, droppedNumbers: ["$5K"], addedNumbers: [], }, @@ -81,6 +84,7 @@ const driftResult: ResumeRewriteResult = { data: { text: "Senior engineer with 99.9% availability.", numbersPreserved: false, + reverted: false, droppedNumbers: [], addedNumbers: ["99.9%"], }, @@ -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", () => { @@ -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"); + }); +}); + diff --git a/src/components/features/ResumeRewrite.tsx b/src/components/features/ResumeRewrite.tsx index 6682e69b..8534e556 100644 --- a/src/components/features/ResumeRewrite.tsx +++ b/src/components/features/ResumeRewrite.tsx @@ -52,6 +52,7 @@ import { ProposedPanel, type ResumeRewriteApply, } from "./ResumeRewriteProposed.tsx"; +import { numberDriftStatus } from "./NumberPreservationWarning.tsx"; import { RewritePromptDisclosure } from "./RewritePromptDisclosure.tsx"; export interface ResumeRewriteParts { @@ -425,7 +426,18 @@ function CompletedList({ ✓ {outcome.input.label} - {!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" && ( + + kept original + + )} + {numberDriftStatus(outcome.data) === "drift" && ( { ).toHaveLength(0); }); }); + +describe("ProposedPanel — rejected rewrites (#778)", () => { + const REVERTED: ResumeRewriteResult = { + allNumbersPreserved: true, + sections: [ + { + kind: "experience", + input: { + kind: "experience", + id: "experience:0", + label: "Senior 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: [], + }, + }, + ], + }; + + it("falls through to the read-only redline instead of offering accept rows", () => { + // The gate returned the input verbatim, so a review row would ask the user + // to "accept" their own bullet. + const handlers: SectionRewriteApply = { + obsIds: ["0|a"], + onReplace: vi.fn(), + onRemove: vi.fn(), + onAdd: vi.fn(), + }; + const el = render( + createElement(ProposedPanel, { + result: REVERTED, + onDismiss: vi.fn(), + onApplied: vi.fn(), + applyBySection: new Map([["experience:0", handlers]]), + }), + ); + const acceptButtons = [...el.querySelectorAll("button")].filter((b) => + b.getAttribute("aria-label")?.startsWith("Accept this"), + ); + expect(acceptButtons.length).toBe(0); + expect(el.textContent).toContain("Kept unchanged"); + expect(el.textContent).toContain("$4.2M"); + }); + + it("still shows the revert caption when the input carried a blank bullet", () => { + // The revert hands the input back verbatim, blanks included, while the + // original side has always filtered them. Filtering only one side made the + // two strings differ, so `InlineDiff` saw a change and suppressed the + // caption — leaving a revert looking like a silent no-op, the exact failure + // the caption exists to prevent. + const withBlank: ResumeRewriteResult = { + allNumbersPreserved: true, + sections: [ + { + kind: "experience", + input: { + kind: "experience", + id: "experience:0", + label: "Senior 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: [], + }, + }, + ], + }; + const el = render( + createElement(ProposedPanel, { + result: withBlank, + onDismiss: vi.fn(), + onApplied: vi.fn(), + }), + ); + expect(el.textContent).toContain("Kept unchanged"); + }); + + it("does not dress a fully-reverted run as a clean success", () => { + // `allNumbersPreserved` is true here by construction; the tone has to come + // from `reverted` or the panel reads green on a run that changed nothing. + const el = render( + createElement(ProposedPanel, { + result: REVERTED, + onDismiss: vi.fn(), + onApplied: vi.fn(), + }), + ); + expect(el.innerHTML).toContain("border-feedback-warning-border"); + expect(el.innerHTML).not.toContain("border-feedback-success-border"); + }); + + it("explains the revert in the résumé-level alert, not just in the border colour", () => { + // The warning used to be guarded on `!allNumbersPreserved` alone, which is + // true-by-construction false here: a revert delivers the ORIGINAL bullets, + // and those preserve their own numbers. So the panel turned warning-toned + // and said nothing — no text, no `role="alert"` for a screen reader. Pinned + // by content, because a border class is not an explanation. + const el = render( + createElement(ProposedPanel, { + result: REVERTED, + onDismiss: vi.fn(), + onApplied: vi.fn(), + }), + ); + const alert = el.querySelector('[role="alert"]'); + expect(alert).not.toBeNull(); + // The reverted copy, not the "AI altered a metric — review before saving" + // variant: the delivered bullets are the user's own. + expect(alert!.textContent).toContain("Kept your original"); + expect(alert!.textContent).toContain("removed $4.2M"); + expect(alert!.textContent).not.toContain("Review before saving"); + }); + + it("names the invented figure when a section was reverted for inventing one", () => { + const invented: ResumeRewriteResult = { + allNumbersPreserved: true, + sections: [ + { + kind: "experience", + input: { + kind: "experience", + id: "experience:0", + label: "Senior Engineer — Acme", + bullets: ["Completed phase 5 of the migration."], + }, + data: { + bullets: ["Completed phase 5 of the migration."], + numbersPreserved: true, + reverted: true, + droppedNumbers: [], + addedNumbers: ["5"], + }, + }, + ], + }; + const el = render( + createElement(ProposedPanel, { + result: invented, + onDismiss: vi.fn(), + onApplied: vi.fn(), + }), + ); + const alert = el.querySelector('[role="alert"]'); + expect(alert?.textContent).toContain("invented 5"); + }); +}); + diff --git a/src/components/features/ResumeRewriteProposed.tsx b/src/components/features/ResumeRewriteProposed.tsx index 1445e6b2..3c09cb9e 100644 --- a/src/components/features/ResumeRewriteProposed.tsx +++ b/src/components/features/ResumeRewriteProposed.tsx @@ -35,10 +35,12 @@ import { } from "../../lib/rewrite-review/align-bullets.ts"; import { resolveSectionWrites } from "../../lib/rewrite-review/apply-accepted.ts"; import { useRewriteReview, type RewriteReview } from "../../hooks/useRewriteReview.ts"; +import type { SectionRewriteApply } from "./SectionRewrite.tsx"; import { + describeNumberDrift, + numberDriftStatus, NumberPreservationWarning, - type SectionRewriteApply, -} from "./SectionRewrite.tsx"; +} from "./NumberPreservationWarning.tsx"; import { BulletReviewRow } from "./RewriteReviewList.tsx"; /** Per-section apply wiring for the whole-résumé review, keyed by the @@ -95,7 +97,18 @@ export function ProposedPanel({ * Absent → every section renders read-only (graceful fallback). */ applyBySection?: ResumeRewriteApply; }) { - const aggregated = useMemo(() => aggregateDrift(result), [result]); + const revertedDrift = useMemo( + () => aggregateDrift(result, (o) => o.data.reverted), + [result], + ); + const emptiedDrift = useMemo( + () => + aggregateDrift( + result, + (o) => !o.data.reverted && !o.data.numbersPreserved, + ), + [result], + ); // Experience sections with an apply wiring become per-bullet reviewable; // pair ids are namespaced by section id so the one combined decision map @@ -106,6 +119,10 @@ export function ProposedPanel({ for (const outcome of result.sections) { const apply = applyBySection?.get(outcome.input.id); if (!apply) continue; + // A rejected rewrite (#778) returns the input verbatim, so aligning it + // would offer the user an "accept" on their own text. Fall through to + // the read-only redline, which captions why there is nothing to see. + if (outcome.data.reverted) continue; const pairs = outcome.kind === "summary" ? summaryPairs( @@ -202,15 +219,43 @@ export function ProposedPanel({ const accepted = review.acceptedCount; + // #778: `allNumbersPreserved` is true when every section reverted — the + // originals preserve themselves — so it cannot carry the tone on its own, and + // it cannot decide whether the warning renders either. Guarding the alert on + // it alone turned the panel warning-coloured on a revert while saying nothing: + // a border colour, no text, no `role="alert"`. Same condition as + // `ProposedSection`'s in SectionRewrite.tsx, one level up. + const anyReverted = result.sections.some((o) => o.data.reverted); + // A generation that came back empty is NOT reverted — `applyNumberPreservation` + // deliberately leaves it blank rather than restoring the original (#778, + // `post-process.ts`) — so its drift can't share the "Kept your original" + // copy with a genuinely reverted section without misdescribing what + // happened to it. Split by outcome rather than one aggregate boolean+list + // covering every section (#874 review). + const anyEmptiedDrift = + emptiedDrift.dropped.length > 0 || emptiedDrift.added.length > 0; + + const tone = + numberDriftStatus({ + numbersPreserved: result.allNumbersPreserved, + reverted: anyReverted, + }) === "clean" + ? "success" + : "warning"; + return ( - - {!result.allNumbersPreserved && ( + + {anyReverted && ( + )} + {anyEmptiedDrift && ( + )}
    @@ -345,13 +390,20 @@ function SectionResult({ outcome }: { outcome: SectionOutcome }) { outcome.input.text, outcome.data.text || "", )} + noChangeLabel={ + outcome.data.reverted ? revertedLabel(outcome.data) : undefined + } /> ); } - const originalBullets = outcome.input.bullets.filter( - (b) => b.trim().length > 0, - ); + // Both sides get the SAME blank filter. A revert hands back the input verbatim + // (blanks included) while this side has always dropped them, so filtering only + // the original made a section with one blank bullet diff as "changed" — which + // suppressed the `noChangeLabel` that exists to stop a revert reading as a + // silent no-op (#778). + const originalBullets = withoutBlanks(outcome.input.bullets); + const proposedBullets = withoutBlanks(outcome.data.bullets); return (

    @@ -360,31 +412,62 @@ function SectionResult({ outcome }: { outcome: SectionOutcome }) { `• ${b}`).join("\n"), - outcome.data.bullets.map((b) => `• ${b}`).join("\n"), + proposedBullets.map((b) => `• ${b}`).join("\n"), )} + noChangeLabel={ + outcome.data.reverted ? revertedLabel(outcome.data) : undefined + } />

    ); } +function withoutBlanks(bullets: readonly string[]): string[] { + return bullets.filter((b) => b.trim().length > 0); +} + +/** + * Why a section in the whole-résumé review shows no redline (#778). Named per + * section rather than aggregated into the résumé-level warning because the + * chain rewrites each section independently — one reverting says nothing about + * the others, and a single banner would leave the reader guessing which. + */ +function revertedLabel(data: { + droppedNumbers: readonly string[]; + addedNumbers: readonly string[]; +}): string { + const detail = describeNumberDrift(data.droppedNumbers, data.addedNumbers); + return detail === "" + ? "Kept unchanged — the rewrite was rejected." + : `Kept unchanged — the rewrite ${detail}.`; +} + export interface AggregateDrift { dropped: string[]; added: string[]; } /** - * Concatenate every section's dropped/added numeric tokens in encounter - * order so the whole-résumé warning quotes the same specific metrics that - * each per-section panel would have shown individually. + * Concatenate the dropped/added numeric tokens of every section `include` + * selects, in encounter order, so a warning built from the result quotes the + * same specific metrics each per-section panel would have shown individually. * - * Both `SectionOutcome` variants store the diff in `.data.droppedNumbers` - * / `.data.addedNumbers`, so the kind discriminator doesn't change the - * lookup — one shared loop covers both. + * `include` exists because "reverted" and "emptied-but-not-reverted" (#778's + * deliberate no-revert-on-empty-generation exemption) are different stories + * about what happened to a section's content, and a caller aggregating both + * into one list would hand a warning copy that's accurate for one and false + * for the other (#874 review). Both `SectionOutcome` variants store the diff + * in `.data.droppedNumbers` / `.data.addedNumbers`, so the kind discriminator + * doesn't change the lookup — one shared loop covers both. */ -export function aggregateDrift(result: ResumeRewriteResult): AggregateDrift { +export function aggregateDrift( + result: ResumeRewriteResult, + include: (outcome: SectionOutcome) => boolean, +): AggregateDrift { const dropped: string[] = []; const added: string[] = []; for (const outcome of result.sections) { + if (!include(outcome)) continue; dropped.push(...outcome.data.droppedNumbers); added.push(...outcome.data.addedNumbers); } diff --git a/src/components/features/RewriteReviewList.tsx b/src/components/features/RewriteReviewList.tsx index 0619c5a4..4ac12624 100644 --- a/src/components/features/RewriteReviewList.tsx +++ b/src/components/features/RewriteReviewList.tsx @@ -236,35 +236,44 @@ export function RewriteReviewList({
    {warning} -
    - - {total} change{total === 1 ? "" : "s"} proposed — review each below. - -
    - - -
    -
    + {/* Nothing to review renders no review chrome. Reachable since #778, + where a rejected rewrite deliberately yields zero pairs — "0 changes + proposed — review each below" over an empty list would be the + surface contradicting itself, with the `warning` above already + carrying the real outcome. */} + {total > 0 && ( + <> +
    + + {total} change{total === 1 ? "" : "s"} proposed — review each below. + +
    + + +
    +
    -
      - {pairs.map((pair) => ( - - ))} -
    +
      + {pairs.map((pair) => ( + + ))} +
    + + )}