feat(webllm): section-level rewrite + number-preservation guardrail (#63) - #123
Conversation
s-annam
left a comment
There was a problem hiding this comment.
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.tsis 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 deliberateofexclusion 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
disabledbutton is correctly treated as just the (one-render-late) UI surface. Idempotent release infinally. - Stale-proposal auto-dismiss compares content not identity (
bulletsEqual), with a comment explaining whytrimmedBulletsis a fresh array each render. - Bundled App.tsx fix is genuinely scoped (1 line + 2 regression tests) and the
rawText-stays-original vsparsed-gets-edited split is deliberate and documented.
Key Findings (none blocking)
- [Suggestion]
Carddeviation contradicts a written AC. Issue #63 step 5 says before/after "in ashared/Card"; the PR uses a lightweightrounded borderstrip instead, justified as matchingRewriteButton'sRewriteResultchrome. 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, ifCardlacks a lightweight variant, the canonical move is to add the variant, not bypass it. - [Suggestion] Component has no direct test → drives the
fallowCRAP advisories. The 4 bot comments (CRAP 90/42/30/30) are advisory only — thefallowCI check passed — but they're all "complexity + zero coverage." A singlerenderToStaticMarkupsmoke test (idle label, locked label, proposed panel with a dropped-number warning) would knock the score down and cover thelabelFor/warning-format branches cheaply. - [Nit] "No behavior change" on
rewrite-bullet.tsis slightly overstated. The oldpostProcessstripped straight quotes only;cleanRewriteLinenow 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." - [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.
) 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>
69b75e6 to
85a1a08
Compare
s-annam
left a comment
There was a problem hiding this comment.
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.
…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>
…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>
…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.
…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.
Summary
Phase 1 of the in-browser AI rewrite epic. Each
RoleEntrynow 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)
cleanRewriteLinefactored out ofrewrite-bullet.ts; stripsRewritten:echo, list markers, straight + smart quotes, paired bold/italic, prompt-echo lines.$/€/£/¥, percent, magnitude (5K,10MB,2GB), comma groups, decimals, year ranges, and bare-integer headcounts in people-management context. Sign-flips (15%→-15%) are flagged.ofdeliberately NOT in the verb prefix (over-triggers on "1 of 5"). 22 tests.rewriteSectionWithLlm(bullets, engine) → SectionRewriteResultwithmax_tokens = min(60 × bullets.length, 768), post-processed output split on\n. 19 tests.New / modified UI
<Button>from@design-system(no raw<button>). Renders before/after side-by-side, green border whennumbersPreserved, amber border + token-naming warning when not. Returnsnullwhen 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).tryAcquireSectionRewriteLock()returnsnullon contention. The disabled button is just UI; the real guarantee is this atomic check, so twoonClickhandlers fired in the same React batch can't both succeed. 6 tests.SectionRewriteper role, fedbulletOverrides?.[b.index] ?? b.textso the model sees what the user actually edited.Foundational
MODEL_IDbumped Qwen2 → Qwen2.5-1.5B.cleanRewriteLine. No behavior change (9 existing tests still pass).webllm_section_rewrite_started,_completed(withnumbers_preserved), plus a distinctwebllm_first_section_rewriteone-shot key. Per-bulletwebllm_first_rewriteleft 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(originalparsed) withedited.score(re-graded bullets), sogroupBulletsByExperiencebuilt 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 }toResult.rawTextstays 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 tostate.resultwill 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
$50Kwould 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)
<Card>from@design-systemfor 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 asRewriteButton'sRewriteResult(rounded border + feedback bg). ForcingCardwould visually overweight the surface.rewriteSectionWithLlmreturnsSectionRewriteResult, notPromise<string[]>. The struct surfacesnumbersPreserved/droppedNumbers/addedNumbersthe warning UI needs; returning barestring[]would force the component to re-run the guardrail.SectionRewrite— matches the issue's "Wire into RoleEntry" wording. If you want them too, easy follow-up.Test plan
npm run typecheckcleannpm run testgreen (523/523 tests, was 496)npm run buildgreennpm run dev:(2) | (3)mismatch)