Skip to content

feat(webllm): section-level rewrite + number-preservation guardrail (#63) - #123

Merged
s-annam merged 2 commits into
mainfrom
vk/section-rewrite-issue-63
Jun 18, 2026
Merged

feat(webllm): section-level rewrite + number-preservation guardrail (#63)#123
s-annam merged 2 commits into
mainfrom
vk/section-rewrite-issue-63

Conversation

@Vaishnavi1709

Copy link
Copy Markdown
Collaborator

Summary

Phase 1 of the in-browser AI rewrite epic. Each RoleEntry now exposes a Rewrite section button that asks Qwen2.5-1.5B to rewrite the whole role's bullets at once — the model owns count/order (can merge, dedupe, drop filler, reorder), and a deterministic, model-free regex guardrail compares numeric tokens in vs. out so the UI can name the specific dropped/invented metric inline ("⚠ AI altered a metric — removed $5K…").

Closes #63

What's in the diff

New webllm modules (all unit-tested, no real model fetched in CI)

  • src/lib/webllm/post-process.ts (+ test) — cleanRewriteLine factored out of rewrite-bullet.ts; strips Rewritten: echo, list markers, straight + smart quotes, paired bold/italic, prompt-echo lines.
  • src/lib/webllm/preserve-numbers.ts (+ test) — single-pass ATOM regex classifies each numeric token exactly once. Handles $/€/£/¥, percent, magnitude (5K, 10MB, 2GB), comma groups, decimals, year ranges, and bare-integer headcounts in people-management context. Sign-flips (15%-15%) are flagged. of deliberately NOT in the verb prefix (over-triggers on "1 of 5"). 22 tests.
  • src/lib/webllm/rewrite-section.ts (+ test) — rewriteSectionWithLlm(bullets, engine) → SectionRewriteResult with max_tokens = min(60 × bullets.length, 768), post-processed output split on \n. 19 tests.

New / modified UI

  • src/components/features/SectionRewrite.tsx — uses <Button> from @design-system (no raw <button>). Renders before/after side-by-side, green border when numbersPreserved, amber border + token-naming warning when not. Returns null when WebGPU is unavailable. Auto-dismisses the proposal when the underlying bullets change (so you can't see a "Original (2) | Proposed (3)" mismatch after an edit).
  • src/hooks/useSectionRewriteLock.ts (+ test) — synchronous atomic tryAcquireSectionRewriteLock() returns null on contention. The disabled button is just UI; the real guarantee is this atomic check, so two onClick handlers fired in the same React batch can't both succeed. 6 tests.
  • src/components/features/ReconstructedRole.tsx — wires one SectionRewrite per role, fed bulletOverrides?.[b.index] ?? b.text so the model sees what the user actually edited.

Foundational

  • src/lib/webllm/web-llm.tsMODEL_ID bumped Qwen2 → Qwen2.5-1.5B.
  • src/lib/webllm/rewrite-bullet.ts — refactored to use cleanRewriteLine. No behavior change (9 existing tests still pass).
  • src/lib/analytics.tswebllm_section_rewrite_started, _completed (with numbers_preserved), plus a distinct webllm_first_section_rewrite one-shot key. Per-bullet webllm_first_rewrite left untouched per AC.

Bundled fix (App.tsx wiring)

Discovered while manually testing this PR: editing any bullet caused it to jump to the trailing "Other bullets" group, which blocked end-to-end verification of the guardrail. Root cause was in App.tsx — it passed state.result (original parsed) with edited.score (re-graded bullets), so groupBulletsByExperience built its line→exp map from original descriptions and looked up edited bullet text → no match → "Other bullets."

Fix is one line: pass { ...state.result, parsed: edited.parsed } to Result. rawText stays original on purpose (EvidencePanel shows what the PDF extracted, not what the user typed). Two regression tests in src/lib/edit/apply-overrides.test.ts — one proves the working path, the other pins the bug pattern so anyone "simplifying" the wiring back to state.result will see the test fail.

Why bundled rather than separate: the bug pre-dated this branch but the displacement made testing the guardrail end-to-end impractical (the edited bullet's $50K would land in a different group than the role I was running rewrite on). The fix is 3 lines of code + 2 tests, narrowly scoped, and the alternative was to ship #63 with a known testing gap.

Known scope notes (worth flagging to reviewer)

  • No <Card> from @design-system for the inline before/after panel. That primitive owns top-level panel chrome (rounded-xl border p-5); the inline result strip uses the same lightweight container pattern as RewriteButton's RewriteResult (rounded border + feedback bg). Forcing Card would visually overweight the surface.
  • rewriteSectionWithLlm returns SectionRewriteResult, not Promise<string[]>. The struct surfaces numbersPreserved/droppedNumbers/addedNumbers the warning UI needs; returning bare string[] would force the component to re-run the guardrail.
  • Per-role wiring only. Project/achievement sections don't get SectionRewrite — matches the issue's "Wire into RoleEntry" wording. If you want them too, easy follow-up.

Test plan

  • npm run typecheck clean
  • npm run test green (523/523 tests, was 496)
  • npm run build green
  • Manually verified in npm run dev:
    • Per-role "Rewrite section" buttons render under each experience role; absent in Safari/Firefox (no WebGPU)
    • Cold-start downloads Qwen2.5-1.5B (~1.2GB) on first click only; cached in IndexedDB after
    • Clicking a sibling role's button while one is in flight shows "Another rewrite running…" disabled (lock works)
    • Successful rewrite shows green-bordered before/after with "Use this — copy all bullets" / "Discard"
    • Editing a bullet under a rewritten role auto-dismisses the proposal (no stale (2) | (3) mismatch)
    • Per-bullet sparkle ✨ rewrite still works (regression check on the refactored post-process)
    • Edited bullets stay attached to their role (App.tsx fix)

Comment thread src/components/features/SectionRewrite.tsx
Comment thread src/components/features/SectionRewrite.tsx Fixed
Comment thread src/components/features/SectionRewrite.tsx Fixed
Comment thread src/components/features/SectionRewrite.tsx Fixed
@Vaishnavi1709
Vaishnavi1709 requested a review from s-annam June 18, 2026 20:44
s-annam
s-annam previously approved these changes Jun 18, 2026

@s-annam s-annam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review: feat(webllm): section-level rewrite + number-preservation guardrail (#63)

Summary

High-quality Phase-1 implementation. Every acceptance criterion in #63 is met, logic is cleanly domain-segregated under src/lib/webllm/ with thorough unit tests (523/523 green), CI is fully green (fallow pass, verify pass), and the two intentional deviations from the issue's plan are documented in the PR body with sound reasoning.

Spec Alignment (issue #63)

Requirement Status Notes
MODEL_ID → Qwen2.5-1.5B web-llm.ts
post-process.ts shared, no dup; bullet path slices line[0], section keeps all Both call sites import cleanRewriteLine
rewrite-section.ts, max_tokens = min(60×N, 768) sectionMaxTokens + N=0 floor guard
preserve-numbers.ts model-free; currency/%/magnitude/commas/years/headcounts Single-pass ATOM regex; sign-flip detection; of correctly excluded
SectionRewrite.tsx states + null on no-WebGPU + inline ⚠ naming tokens Mirrors RewriteButton
Wire per-role into RoleEntry, keep per-bullet Fed bulletOverrides ?? text (#82-aware)
Telemetry: 3 section events + distinct webllm_first_section_rewrite Per-bullet key untouched
One-rewrite-at-a-time concurrency useSectionRewriteLock — synchronous atomic acquire, not just disabled

Highlights

  • preserve-numbers.ts is the standout. Single ATOM-regex pass classifying each token exactly once cleanly avoids the year/date-range and verb-prefix/noun-suffix double-emit traps. Multiset diff semantics, sign-flip catch (15%-15%), and the deliberate of exclusion show real care. 22 tests.
  • The lock is correct for the right reason. Synchronous check-and-increment in one turn is the actual guarantee; the disabled button is correctly treated as just the (one-render-late) UI surface. Idempotent release in finally.
  • Stale-proposal auto-dismiss compares content not identity (bulletsEqual), with a comment explaining why trimmedBullets is a fresh array each render.
  • Bundled App.tsx fix is genuinely scoped (1 line + 2 regression tests) and the rawText-stays-original vs parsed-gets-edited split is deliberate and documented.

Key Findings (none blocking)

  1. [Suggestion] Card deviation contradicts a written AC. Issue #63 step 5 says before/after "in a shared/Card"; the PR uses a lightweight rounded border strip instead, justified as matching RewriteButton's RewriteResult chrome. The reasoning is sound and consistent with the sibling — but it does override an explicit AC, so worth a team ✅ rather than a silent pass. Per CLAUDE.md, if Card lacks a lightweight variant, the canonical move is to add the variant, not bypass it.
  2. [Suggestion] Component has no direct test → drives the fallow CRAP advisories. The 4 bot comments (CRAP 90/42/30/30) are advisory only — the fallow CI check passed — but they're all "complexity + zero coverage." A single renderToStaticMarkup smoke test (idle label, locked label, proposed panel with a dropped-number warning) would knock the score down and cover the labelFor/warning-format branches cheaply.
  3. [Nit] "No behavior change" on rewrite-bullet.ts is slightly overstated. The old postProcess stripped straight quotes only; cleanRewriteLine now also strips smart quotes, bold/italic, list markers, and prompt-echo lines. Strictly more cleaning (an improvement, 9 tests still pass) — just not "no change."
  4. [Nit] Branch is 1 commit behind main (off #119, main at #121). Rebase before merge if you want CI on the merged state.

Verdict

APPROVE — 0 blocking findings. Spec fully implemented, CI green, well-tested, deviations documented. The two suggestions (Card AC, component smoke test) are worth a follow-up but shouldn't gate merge.

Comment thread src/components/features/SectionRewrite.tsx
Comment thread src/components/features/SectionRewrite.tsx Outdated
Comment thread src/lib/webllm/rewrite-bullet.ts
)

Phase 1 of the in-browser AI rewrite epic. Replaces the per-bullet rewrite
with a whole-section CTA per RoleEntry: the model owns bullet count/order
(can merge, dedupe, drop filler, reorder), and a deterministic, model-free
regex guardrail compares numeric tokens in vs. out so the UI can name the
specific dropped/invented metric inline.

Highlights:
- MODEL_ID bumped Qwen2-1.5B -> Qwen2.5-1.5B
- New src/lib/webllm/{post-process,rewrite-section,preserve-numbers}.ts
  with 52 tests; rewrite-bullet.ts now shares cleanRewriteLine
- New src/components/features/SectionRewrite.tsx using the existing Button
  primitive from @design-system, plus a cross-instance lock hook at
  src/hooks/useSectionRewriteLock.ts so two role buttons can't fire
  concurrent generate() calls on the shared engine
- Telemetry: webllm_section_rewrite_started/_completed + a distinct
  webllm_first_section_rewrite one-shot key (per-bullet first-rewrite key
  preserved unchanged)
- Stale-proposal auto-dismiss: when a user edits the underlying bullets
  after a proposal is showing, the proposal clears back to idle so the UI
  can't display a mismatched "Original (2) | Proposed (3)" panel

Bundled fix (App.tsx wiring, discovered while testing #63):
- App.tsx was passing state.result (original parsed) but edited.score
  (re-graded bullets), so groupBulletsByExperience built its line->exp
  map from original descriptions and looked up edited bullet text -> no
  match -> bullets displaced into "Other bullets" after every edit.
  Fix: pass edited.parsed via {...state.result, parsed: edited.parsed}.
  rawText stays original (EvidencePanel shows what the PDF extracted).
  Regression tests in apply-overrides.test.ts pin both the working path
  and the failure mode.

Gates: 523/523 tests, typecheck clean, build green.

Closes #63

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…es on PR #123

Adds renderToStaticMarkup-based smoke tests covering the helpers that own
the branching in SectionRewrite: labelFor, formatTokens, ProposedSection,
and NumberPreservationWarning. Asserts the locked-by-other label, the
success-vs-warning chrome split, and that the warning surfaces the
specific dropped/invented token (not a generic message).

To make these helpers reachable from a node test (renderToStaticMarkup can't
drive the component's async state), they're now `export`-ed from
SectionRewrite.tsx with a comment explaining the test-only motivation.
Exposing them as named exports adds no bundle weight (the public API
boundary for callers is unchanged — feature consumers still only import
`SectionRewrite`).

Resolves the four `fallow` CRAP advisories on PR #123
(SectionRewrite/onClick/labelFor/ProposedSection) by adding coverage to
the previously-zero-coverage branches. CI was already green; this just
clears the advisory list.

Gates: 535/535 tests (was 523), typecheck clean, build green.

Refs #63

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@s-annam s-annam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review (fresh approval) — PR #123

All four findings from the first pass are resolved. Re-ran the gates on the rebased branch: typecheck clean, 535/535 tests (was 523), fallow + verify both green.

First-pass finding Resolution
[Suggestion] Card AC deviation Filed #124 for the team decision (recommends a shared InlineResult primitive, keeps Card panel-scoped); thread intentionally left open to land Option A vs B there. Right call — turns a silent deviation into a tracked decision.
[Suggestion] fallow CRAP advisories SectionRewrite.test.ts added — 12 renderToStaticMarkup smoke tests covering labelFor (locked + per-status), formatTokens (1/2/N), NumberPreservationWarning (named tokens + role=alert), and ProposedSection (success/warning chrome, counts, copy-label flip). fallow now passes.
[Nit] "no behavior change" framing Acknowledged; correctly no code change — strictly-more cleaning, covered by post-process.test.ts.
[Nit] 1 commit behind main Rebased; branch is now current with main (0 behind).

The test-only exports on the helpers are documented with a clear motivation comment and are consumed by the new test (not flagged by fallow). Branch is MERGEABLE and up to date with main.

Verdict

APPROVE. Clean follow-through. Ship it.

@s-annam
s-annam merged commit 4abc7eb into main Jun 18, 2026
2 checks passed
@s-annam
s-annam deleted the vk/section-rewrite-issue-63 branch June 18, 2026 21:29
s-annam pushed a commit that referenced this pull request Jun 25, 2026
…123)

Phase 1 of in-browser AI rewrite: per-role section rewrite via Qwen2.5-1.5B with a deterministic number-preservation guardrail. Includes shared post-process helper, atomic single-rewrite lock, section telemetry, and a bundled App.tsx edit-attribution fix.

Resolves #63

Co-Authored-By: Vaishnavi Kale <kvaishnavi301@gmail.com>
s-annam pushed a commit that referenced this pull request Jun 28, 2026
…123)

Phase 1 of in-browser AI rewrite: per-role section rewrite via Qwen2.5-1.5B with a deterministic number-preservation guardrail. Includes shared post-process helper, atomic single-rewrite lock, section telemetry, and a bundled App.tsx edit-attribution fix.

Resolves #63

Co-Authored-By: Vaishnavi Kale <kvaishnavi301@gmail.com>
s-annam added a commit that referenced this pull request Jul 28, 2026
…let revise-pr import feedback

Four coupled changes to the review loop, driven by three problems: the
pr-ready ping was long enough that reviewers skimmed it, its review pointers
biased the review they were meant to help, and its ack accepted "any signal
at all" — so silence and mid-review were indistinguishable, and a second
approver could not tell whether merging would cut across someone's work.

open-pr — add an optional `## Review focus` to the PR body: at most four
entries, phrased as questions, omitted when the risk is obvious. Review
pointers now live here, where the reviewer already is when they act on one.

pr-ready — the ping is now two lines per PR, the mentions, a merge time and
a single asked-for signal; every summary and file slice moved to the PR body.
The ack predicate becomes four states (SILENT / ACKED / REVIEWING /
REVIEWED). A thumbs-up or thread reply suppresses the reminder but does not
move the deadline, because a promise is not evidence of reading; a 👀 on the
PR or a post-ping comment earns one grace window of `hold_grace_minutes`
measured from the claim, never renewed. A lapsed window reports HELD BUT
STALE with the claimant's login, so the author has a person to nudge rather
than an open-ended wait. `ack_reactions` becomes `["+1","thumbsup"]` — the
old allowlist omitted the only emoji reviewers actually send, so real acks
were reported as silence.

pr-review — the description is now read LAST. The linked issue's acceptance
criteria are the spec (new Step 0.5, which pipes the body through a matcher
so only issue numbers, never prose, enter the review), the code is the
evidence, and the body is a claim to falsify in new gate 3f: a claim the code
does not back, or an unimplemented AC under `Closes`, is Blocking. The gate
can add findings, never subtract them. New Step 0.6 posts a 👀 reaction
before the slow work — the only visible "review in progress" marker GitHub
offers, and what pr-ready reads. It is advisory, not a merge block, and the
skill says so.

revise-pr — feedback may now come from another PR (`--from-pr`), one comment
URL (`--from-comment`), or free prose (`--notes`), while the code always
lands on the target. New Step 2.5 re-verifies each import against this branch
before anything changes, because a finding from another PR is a claim about
another branch; non-reproducing imports are never fixed, only answered.
Replies route to the source thread and resolve only where the defect is
actually gone — fixing #A's finding in #B leaves #A's copy live. A run with
no unresolved threads of its own is now legal.

Review nits, all one-line surfaces:

revise-pr gains an `argument-hint`. The three source flags are the feature and
the hint line is where a user discovers they exist; without it the only way to
learn the names is to open the skill file. `--from-comment` now shape-checks the
URL before extracting: `sed` passes its input through unchanged on no-match, so
an unguarded run turned a malformed URL into `SRC_PR=<the whole URL>` and 404'd
on a nonsense path instead of saying the URL was not a review thread. The
`#issuecomment-` form is rejected by name rather than extracted and discarded.

pr-review's Step 0.5 keyword regex accepts the colon form (`:?`). `Closes: #123`
is valid GitHub syntax; for closing keywords `closingIssuesReferences` already
covered it, but `Refs: #N` fell through both commands. `closes#789` stays
unmatched — GitHub rejects that form too. The 👀 staleness guarantee gains its
missing clause: the timestamp filter covers a reaction from an earlier ping
cycle, while a review abandoned within a cycle does read as REVIEWING, bounded
by pr-ready's one-shot grace and reported as HELD BUT STALE with the claimant's
login.
s-annam added a commit that referenced this pull request Jul 28, 2026
…let revise-pr import feedback (#642)

Four coupled changes to the review loop, driven by three problems: the
pr-ready ping was long enough that reviewers skimmed it, its review pointers
biased the review they were meant to help, and its ack accepted "any signal
at all" — so silence and mid-review were indistinguishable, and a second
approver could not tell whether merging would cut across someone's work.

open-pr — add an optional `## Review focus` to the PR body: at most four
entries, phrased as questions, omitted when the risk is obvious. Review
pointers now live here, where the reviewer already is when they act on one.

pr-ready — the ping is now two lines per PR, the mentions, a merge time and
a single asked-for signal; every summary and file slice moved to the PR body.
The ack predicate becomes four states (SILENT / ACKED / REVIEWING /
REVIEWED). A thumbs-up or thread reply suppresses the reminder but does not
move the deadline, because a promise is not evidence of reading; a 👀 on the
PR or a post-ping comment earns one grace window of `hold_grace_minutes`
measured from the claim, never renewed. A lapsed window reports HELD BUT
STALE with the claimant's login, so the author has a person to nudge rather
than an open-ended wait. `ack_reactions` becomes `["+1","thumbsup"]` — the
old allowlist omitted the only emoji reviewers actually send, so real acks
were reported as silence.

pr-review — the description is now read LAST. The linked issue's acceptance
criteria are the spec (new Step 0.5, which pipes the body through a matcher
so only issue numbers, never prose, enter the review), the code is the
evidence, and the body is a claim to falsify in new gate 3f: a claim the code
does not back, or an unimplemented AC under `Closes`, is Blocking. The gate
can add findings, never subtract them. New Step 0.6 posts a 👀 reaction
before the slow work — the only visible "review in progress" marker GitHub
offers, and what pr-ready reads. It is advisory, not a merge block, and the
skill says so.

revise-pr — feedback may now come from another PR (`--from-pr`), one comment
URL (`--from-comment`), or free prose (`--notes`), while the code always
lands on the target. New Step 2.5 re-verifies each import against this branch
before anything changes, because a finding from another PR is a claim about
another branch; non-reproducing imports are never fixed, only answered.
Replies route to the source thread and resolve only where the defect is
actually gone — fixing #A's finding in #B leaves #A's copy live. A run with
no unresolved threads of its own is now legal.

Review nits, all one-line surfaces:

revise-pr gains an `argument-hint`. The three source flags are the feature and
the hint line is where a user discovers they exist; without it the only way to
learn the names is to open the skill file. `--from-comment` now shape-checks the
URL before extracting: `sed` passes its input through unchanged on no-match, so
an unguarded run turned a malformed URL into `SRC_PR=<the whole URL>` and 404'd
on a nonsense path instead of saying the URL was not a review thread. The
`#issuecomment-` form is rejected by name rather than extracted and discarded.

pr-review's Step 0.5 keyword regex accepts the colon form (`:?`). `Closes: #123`
is valid GitHub syntax; for closing keywords `closingIssuesReferences` already
covered it, but `Refs: #N` fell through both commands. `closes#789` stays
unmatched — GitHub rejects that form too. The 👀 staleness guarantee gains its
missing clause: the timestamp filter covers a reaction from an earlier ping
cycle, while a review abandoned within a cycle does read as REVIEWING, bounded
by pr-ready's one-shot grace and reported as HELD BUT STALE with the claimant's
login.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 1: Section-level rewrite (replace whole block) + number-preservation guardrail

3 participants