Skip to content

Commit be3d885

Browse files
committed
fix(review): filter the AI narrative assessment per sentence, not all-or-nothing
FORBIDDEN_PUBLIC_COMMENT_WORDS is matched with a plain case-insensitive `.includes()` over a list containing ordinary review vocabulary -- "reward", "rewards", "ranking", "rankings", "cohort", "farming", "reviewability" -- and toPublicSafe drops its WHOLE input when sanitizePublicComment throws. A safe review of this codebase's own gate/scoring code ("updates the ranking comparator so ties resolve deterministically") therefore had its entire narrative discarded and replaced by the generic "did not include a separate narrative summary" placeholder. Observed live across ~40% of reviews. The model's assessment was confirmed PRESENT in those cases: no `ai_review_missing_assessment` diagnostic was emitted for the affected PRs, so the text was produced and then thrown away downstream, and the cached rows carry `inconclusive: false` (the review succeeded). Drop only the offending SENTENCE instead. A leaked private VALUE ("trust score 0.82", "reward estimate 12 TAO") necessarily sits in the same sentence as the term naming it, so removing that sentence removes the risky content -- discarding the whole assessment protected nothing additional and cost the reader every other sentence. It also matches how this file already treats findings: safeBlockers/safeNits filter per item and keep the survivors, so the assessment was the lone all-or-nothing holdout. An assessment whose every sentence is unsafe still returns null and takes the existing placeholder path unchanged.
1 parent fb8e2eb commit be3d885

2 files changed

Lines changed: 107 additions & 1 deletion

File tree

src/services/ai-review.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,50 @@ export function toPublicSafe(text: string | null | undefined, options?: { allowB
674674
}
675675
}
676676

677+
/** Sentence boundary: a `.`/`!`/`?` followed by whitespace. Deliberately simple — the narrative assessment the
678+
* prompt asks for is 2-4 sentences of plain prose ("a substantive but CONCISE summary"), not markdown with
679+
* embedded code blocks, so an abbreviation like "e.g." at worst keeps two sentences joined (a slightly larger
680+
* unit is dropped) and never splits mid-word. It can never merge separate sentences, which is the direction
681+
* that would matter for safety. */
682+
const SENTENCE_BOUNDARY = /(?<=[.!?])\s+/;
683+
684+
/**
685+
* Public-safe filter for the narrative assessment, applied PER SENTENCE instead of all-or-nothing.
686+
*
687+
* {@link toPublicSafe} drops its whole input when `sanitizePublicComment` throws, and
688+
* FORBIDDEN_PUBLIC_COMMENT_WORDS is matched with a plain case-insensitive `.includes()` over a list that
689+
* contains ordinary English review vocabulary -- "reward", "rewards", "ranking", "rankings", "cohort",
690+
* "farming", "reviewability". A perfectly safe review of this codebase's own gate/scoring code ("updates the
691+
* ranking comparator so ties resolve deterministically") therefore had its ENTIRE narrative discarded and
692+
* replaced by the generic "did not include a separate narrative summary" placeholder -- observed live across
693+
* ~40% of reviews, with the model's assessment confirmed present (no `ai_review_missing_assessment` diagnostic
694+
* was ever emitted for those PRs, so the text was produced and then thrown away downstream).
695+
*
696+
* Dropping only the offending SENTENCE is both more useful and no less safe. A leaked private VALUE ("trust
697+
* score 0.82", "reward estimate 12 TAO") necessarily sits in the same sentence as the term that names it, so
698+
* removing that sentence removes the risky content -- whereas discarding the whole assessment protected
699+
* nothing additional and cost the reader every other sentence. It also matches how this file ALREADY treats
700+
* findings: `safeNits`/`safeBlockers` filter per item and keep the survivors, so granular filtering is the
701+
* existing convention and the assessment was the lone all-or-nothing holdout.
702+
*
703+
* Returns null when no sentence survives, so the caller's existing fallback path is unchanged for the genuinely
704+
* unsafe case.
705+
*/
706+
export function toPublicSafeBySentence(text: string | null | undefined, options?: { allowBareScoreTerm?: boolean }): string | null {
707+
const trimmed = (text ?? "").trim();
708+
if (!trimmed) return null;
709+
// Whole-text pass first: the common case is entirely safe, and this preserves the exact original spacing
710+
// (a split/rejoin would normalise interior whitespace) plus any multi-sentence markdown the splitter would
711+
// otherwise chop.
712+
const whole = toPublicSafe(trimmed, options);
713+
if (whole) return whole;
714+
const kept = trimmed
715+
.split(SENTENCE_BOUNDARY)
716+
.map((sentence) => toPublicSafe(sentence, options))
717+
.filter((sentence): sentence is string => Boolean(sentence));
718+
return kept.length > 0 ? kept.join(" ") : null;
719+
}
720+
677721
/** Coerce the varied Workers-AI / provider response envelopes into a scannable string. */
678722
export function coerceAiText(result: unknown): string {
679723
if (typeof result === "string") return result;
@@ -1979,7 +2023,10 @@ export function composeAdvisoryNotes(reviews: ModelReview[], options?: { allowBa
19792023
const nits = [
19802024
...new Set(reviews.flatMap((r) => [...r.nits, ...r.suggestions])),
19812025
].slice(0, 5);
1982-
const assessment = toPublicSafe(assessments[0] ?? "", options);
2026+
// Per-sentence, not all-or-nothing: see toPublicSafeBySentence. The findings below already filter per item
2027+
// (`safeBlockers`/`safeNits`); this makes the narrative consistent with them instead of being discarded whole
2028+
// because one sentence used ordinary review vocabulary like "ranking" or "reward".
2029+
const assessment = toPublicSafeBySentence(assessments[0] ?? "", options);
19832030
const safeBlockers = blockers
19842031
.map((s) => toPublicSafe(s, options))
19852032
.filter((s): s is string => Boolean(s));

test/unit/ai-review.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3769,6 +3769,65 @@ describe("pure helpers", () => {
37693769
).toBeNull();
37703770
});
37713771

3772+
// REGRESSION: FORBIDDEN_PUBLIC_COMMENT_WORDS is matched with a plain case-insensitive `.includes()` over a
3773+
// list containing ordinary review vocabulary ("reward", "ranking", "cohort", "farming", "reviewability"), and
3774+
// toPublicSafe drops its WHOLE input when sanitizePublicComment throws. A safe review of this codebase's own
3775+
// gate/scoring code therefore lost its entire narrative to the generic no-summary placeholder -- observed
3776+
// live across ~40% of reviews, with the model's assessment confirmed present (no ai_review_missing_assessment
3777+
// diagnostic was emitted for those PRs, so the text was produced and then discarded downstream).
3778+
it("REGRESSION: keeps the safe sentences of an assessment that mentions ordinary review vocabulary", () => {
3779+
const notes = composeAdvisoryNotes([
3780+
{
3781+
assessment:
3782+
"Updates the ranking comparator so ties resolve deterministically. The change is correct and adds matching coverage.",
3783+
suggestions: [],
3784+
nits: ["Rename the helper."],
3785+
blockers: [],
3786+
inlineFindings: [],
3787+
confidence: 1,
3788+
},
3789+
]);
3790+
// The offending sentence is dropped ...
3791+
expect(notes).not.toContain("ranking comparator");
3792+
// ... the safe one survives, instead of the whole narrative being replaced by the placeholder.
3793+
expect(notes).toContain("The change is correct and adds matching coverage.");
3794+
expect(notes).not.toContain("did not include a separate narrative summary");
3795+
expect(notes).toContain("**Nits (1)**");
3796+
});
3797+
3798+
// SAFETY: the point of the filter is a leaked private VALUE, which necessarily sits in the same sentence as
3799+
// the term naming it -- so sentence-level dropping removes it just as completely as the old whole-text drop.
3800+
it("SAFETY: a sentence carrying a private value is removed, and an all-unsafe assessment still falls back", () => {
3801+
const leak = composeAdvisoryNotes([
3802+
{
3803+
assessment: "This looks correct overall. The contributor trust score is 0.82 and the reward estimate is 12 TAO.",
3804+
suggestions: [],
3805+
nits: ["Rename the helper."],
3806+
blockers: [],
3807+
inlineFindings: [],
3808+
confidence: 1,
3809+
},
3810+
]);
3811+
expect(leak).not.toContain("0.82");
3812+
expect(leak).not.toContain("12 TAO");
3813+
expect(leak).not.toContain("trust score");
3814+
expect(leak).toContain("This looks correct overall.");
3815+
3816+
// Every sentence unsafe ⇒ null assessment ⇒ existing placeholder path, unchanged.
3817+
const allUnsafe = composeAdvisoryNotes([
3818+
{
3819+
assessment: "The trust score is 0.9. The reward estimate is 12 TAO.",
3820+
suggestions: [],
3821+
nits: ["Rename the helper."],
3822+
blockers: [],
3823+
inlineFindings: [],
3824+
confidence: 1,
3825+
},
3826+
]);
3827+
expect(allUnsafe).toContain("did not include a separate narrative summary");
3828+
expect(allUnsafe).toContain("Rename the helper.");
3829+
});
3830+
37723831
it("composeAdvisoryNotes preserves blockers and nits when the model omits a narrative assessment", () => {
37733832
const withBlocker = composeAdvisoryNotes([
37743833
{

0 commit comments

Comments
 (0)