Skip to content

Commit 81f28ca

Browse files
authored
feat(review): regenerate a public-safe summary when the narrative is withheld (#9956)
* feat(review): regenerate a public-safe summary when the narrative is withheld Closes #9809. When every sentence of a review's narrative trips the public-safety sanitizer -- routine for a PR touching this project's own scoring/gate code, where an honest sentence says 'score' or 'ranking' -- the reader got a fixed placeholder rather than a real summary. #9806 made that placeholder honest; this makes it a summary. On the withheld branch only, the narrative is rewritten for a public audience in one small completion and then held to the IDENTICAL sanitizer. The prompt is not the boundary: a rewrite that still names forbidden vocabulary is discarded and the fixed sentence stays the floor, so this can only improve the text a reader sees and can never widen what is publishable. A prompt-injected or careless rewrite fails closed, as does a provider error or the model's own CANNOT_SUMMARIZE opt-out. Scoped to the NO-BLOCKERS branch. The issue asks for a real summary every time and that a withheld blocker never read as clean; for a withheld blocker those conflict, and this resolves toward the safety property -- prose reading clean over a blocker the model actually raised is the one outcome that could green-light a PR. Blockers keep their fixed sentence, structurally, not by trusting the prompt. The provider call lives at the single caller rather than inside composeAdvisoryNotes, which stays pure and synchronous: making the composer async would push await through its call site and cost the testability that makes the sanitizer's behaviour verifiable. narrativeWasWithheld() lets the caller decide whether to spend the call before composing, computed the same way the composer decides, so the two cannot drift. The counter records both outcomes -- an all-fallback result must not look identical to the feature never firing. * test(review): unstub fetch after the regeneration cases The provider-path cases stub global fetch and never restored it, so the stub leaked into whatever file the worker ran next -- it took out selfhost-metrics.test.ts in a full run while passing in isolation.
1 parent 303d500 commit 81f28ca

3 files changed

Lines changed: 185 additions & 6 deletions

File tree

src/selfhost/metrics.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ export const DEFAULT_METRIC_META: readonly (readonly [string, MetricMeta])[] = [
133133
["loopover_ai_review_non_cacheable_total", { help: "AI reviews skipped by cacheability rules.", type: "counter" }],
134134
["loopover_ai_review_force_bypass_total", { help: "AI review cache force-bypass events.", type: "counter" }],
135135
["loopover_ai_review_inconclusive_total", { help: "AI review inconclusive outcomes.", type: "counter" }],
136+
["loopover_ai_review_summary_regenerated_total", { help: "Public-safe summary regenerations on the withheld-narrative branch, by outcome (#9809): `published` when the rewrite survived the sanitizer, `fallback` when the fixed sentence was kept. Both are counted -- an all-fallback result must not look the same as the feature never firing.", type: "counter" }],
136137
["loopover_ai_review_unpublishable_blocker_total", { help: "AI reviews where a reviewer named a real blocker whose title could not be published, so the verdict held instead of passing (#9460).", type: "counter" }],
137138
["loopover_ai_review_onmerge_clamped_total", { help: "AI review on-merge mode clamp events.", type: "counter" }],
138139
["loopover_ai_review_model_fallback_total", { help: "AI review model fallback attempts by primary and fallback model.", type: "counter" }],

src/services/ai-review.ts

Lines changed: 115 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2120,6 +2120,56 @@ export async function callAiProvider(
21202120

21212121
/** Run the maintainer's BYOK frontier model for the advisory write-up. Never throws; the review is null on
21222122
* any error and `failure` names the reason (timeout/http_error/exception) for the audit trail. */
2123+
/** Token ceiling for the rewrite. A summary paragraph, not a second review -- this call exists to rephrase
2124+
* text the model already produced, so a small budget is both sufficient and the cost control. */
2125+
const REGENERATED_SUMMARY_MAX_TOKENS = 400;
2126+
2127+
/**
2128+
* Rewrite an already-produced review narrative for a PUBLIC audience, then hold it to the same sanitizer
2129+
* (#9809).
2130+
*
2131+
* Fires only when `narrativeWasWithheld` is true -- every sentence of the original tripped the public-safety
2132+
* gate, which is routine for a PR touching this project's own scoring/gate code, where an honest sentence
2133+
* says "score" or "ranking". Those readers currently get a fixed placeholder instead of a real summary.
2134+
*
2135+
* THE SANITIZER IS STILL THE AUTHORITY. The rewrite is not trusted because the prompt asked nicely; it goes
2136+
* through the identical `toPublicSafeBySentence` gate, and anything that does not survive is discarded in
2137+
* favour of the fixed sentence. So this can only ever improve the text a reader sees -- it cannot widen what
2138+
* is publishable, and a prompt-injected or careless rewrite fails closed.
2139+
*
2140+
* Returns null on: provider failure, empty output, or a rewrite that does not survive sanitization. Never
2141+
* throws -- a summary is a nicety, and a review must not be lost because the rewrite of its prose failed.
2142+
*/
2143+
export async function regeneratePublicSafeSummary(
2144+
providerKey: AiReviewProviderKey,
2145+
narrative: string,
2146+
options?: { allowBareScoreTerm?: boolean },
2147+
): Promise<string | null> {
2148+
const system = [
2149+
"You rewrite code-review summaries for a PUBLIC pull-request comment.",
2150+
"The original summary was withheld because it referenced internal project vocabulary.",
2151+
"Rewrite it so a contributor understands the review's conclusion, WITHOUT using any of these classes of term:",
2152+
"- scoring, scores, ranking, rank, weights, or any measure of contributor standing",
2153+
"- rewards, payouts, emissions, incentives, or anything about compensation",
2154+
"- wallets, keys, hotkeys, coldkeys, addresses",
2155+
"- trust, reputation, or credibility as a tracked quantity",
2156+
"Describe the CODE and the review's conclusion only. Keep it to two or three sentences, plain prose, no headings or lists.",
2157+
"If the original cannot be described without those terms, reply with exactly: CANNOT_SUMMARIZE",
2158+
].join("\n");
2159+
2160+
const { text, failure } = await callAiProvider(providerKey, system, narrative, REGENERATED_SUMMARY_MAX_TOKENS).catch(
2161+
// A rewrite is strictly optional; a thrown provider error must degrade to the fixed sentence, never
2162+
// propagate into the review path that has already succeeded.
2163+
() => ({ text: null, failure: "regeneration_call_failed" as const }),
2164+
);
2165+
if (failure || !text) return null;
2166+
const trimmed = text.trim();
2167+
// The model's own opt-out. Treated as a refusal rather than run through the sanitizer, so a literal
2168+
// "CANNOT_SUMMARIZE" can never be published as if it were a summary.
2169+
if (trimmed === "" || trimmed.includes("CANNOT_SUMMARIZE")) return null;
2170+
return toPublicSafeBySentence(trimmed, options);
2171+
}
2172+
21232173
async function runProviderReview(
21242174
providerKey: AiReviewProviderKey,
21252175
system: string,
@@ -2237,7 +2287,38 @@ function composeFallbackAdvisoryNotes(notes: readonly string[]): string | null {
22372287
* legitimately-public `score`-named field (metagraphed's own `totalScore`/`credibility`) had its entire
22382288
* narrative assessment silently discarded in favor of the generic "did not include a separate narrative
22392289
* summary" fallback -- observed live, recurring. */
2240-
export function composeAdvisoryNotes(reviews: ModelReview[], options?: { allowBareScoreTerm?: boolean }): string | null {
2290+
/** The honest fixed sentence for a withheld narrative that raised BLOCKERS (#9806). Never replaced by a
2291+
* regenerated summary: prose that could read as clean over a real blocker is the one wrong outcome here. */
2292+
export const WITHHELD_NARRATIVE_WITH_BLOCKERS =
2293+
"The AI review completed and raised blocking findings, but they were withheld from this public surface because they referenced non-public project internals. A maintainer should read the private review record before deciding this PR.";
2294+
2295+
/** The honest fixed sentence for a withheld narrative with no blockers (#9806). This is the floor #9809's
2296+
* regenerated summary improves on -- and falls back to whenever the rewrite does not survive the sanitizer. */
2297+
export const WITHHELD_NARRATIVE_CLEAN =
2298+
"The AI review completed and found no blocking issues. Its narrative summary was withheld from this public surface because it referenced non-public project internals.";
2299+
2300+
/**
2301+
* Would this review's narrative be withheld entirely, leaving only a fixed sentence? (#9809)
2302+
*
2303+
* Exported so the caller can decide whether to spend an LLM call BEFORE composing, without string-matching
2304+
* the published output to find out. PURE, and deliberately the same computation `composeAdvisoryNotes` does
2305+
* -- reproducing the sanitizer's decision by any other means would let the two drift, and a regeneration
2306+
* that fires on a review whose narrative was actually published is wasted spend on every review.
2307+
*
2308+
* False when blockers are present: that branch keeps its fixed sentence, so a rewrite would be discarded.
2309+
*/
2310+
export function narrativeWasWithheld(reviews: ModelReview[], options?: { allowBareScoreTerm?: boolean }): boolean {
2311+
const assessments = reviews.map((r) => r.assessment).filter(Boolean);
2312+
if (assessments.length === 0) return false;
2313+
if (toPublicSafeBySentence(assessments[0] ?? "", options)) return false;
2314+
const blockers = [...new Set(reviews.flatMap((r) => r.blockers))].slice(0, 3);
2315+
return blockers.length === 0;
2316+
}
2317+
2318+
export function composeAdvisoryNotes(
2319+
reviews: ModelReview[],
2320+
options?: { allowBareScoreTerm?: boolean; regeneratedAssessment?: string | undefined },
2321+
): string | null {
22412322
const assessments = reviews.map((r) => r.assessment).filter(Boolean);
22422323
// High-signal caps: a focused review shows only the few findings that matter (the prompt also asks the
22432324
// model to be selective + deduplicate). Keep the core blockers and a handful of nits. (#focused-reviews)
@@ -2269,12 +2350,18 @@ export function composeAdvisoryNotes(reviews: ModelReview[], options?: { allowBa
22692350
// review's real verdict. The genuinely-empty case (no parsed review content at all) still returns null
22702351
// below, so a true provider failure keeps its accurate "unavailable" report.
22712352
if (!publicAssessment && assessments.length > 0) {
2272-
// Wording must track the review's REAL verdict: with raw blockers present (all withheld above), claiming
2273-
// "no blocking issues" would be false and could green-light a PR the model actually flagged.
2353+
// #9809: a REGENERATED summary, when the caller obtained one. It is a public-audience rewrite of this same
2354+
// narrative that has already been through the identical sanitizer, so publishing it cannot leak anything
2355+
// the per-sentence gate above would have withheld. Absent (no call made, or the rewrite tripped the
2356+
// sanitizer too), the fixed sentence below stays the floor -- a real summary is the goal, never at the
2357+
// cost of the never-echo guarantee.
2358+
//
2359+
// Blockers are the exception: a withheld BLOCKER must never be replaced by prose that could read as
2360+
// clean, so that branch keeps its fixed sentence regardless of what the rewrite produced.
22742361
publicAssessment =
22752362
blockers.length > 0
2276-
? "The AI review completed and raised blocking findings, but they were withheld from this public surface because they referenced non-public project internals. A maintainer should read the private review record before deciding this PR."
2277-
: "The AI review completed and found no blocking issues. Its narrative summary was withheld from this public surface because it referenced non-public project internals.";
2363+
? WITHHELD_NARRATIVE_WITH_BLOCKERS
2364+
: (options?.regeneratedAssessment ?? WITHHELD_NARRATIVE_CLEAN);
22782365
}
22792366
if (!publicAssessment) return null;
22802367
const lines: string[] = [];
@@ -3350,9 +3437,27 @@ export async function runLoopOverAiReview(
33503437
// push an `ai_review_inconclusive` advisory finding off this same already-computed result (incrementing there
33513438
// too would double/triple-count one review).
33523439
if (inconclusive) incr("loopover_ai_review_inconclusive_total", { mode: input.mode });
3440+
const notesOptions = { allowBareScoreTerm: isPublicScoreTermSafeForRepo(env, input.repoFullName) };
3441+
// #9809: when the sanitizer withheld the ENTIRE narrative, the reader would otherwise get a fixed
3442+
// placeholder instead of a real summary. Ask for a public-audience rewrite of the same narrative and hold
3443+
// it to the identical sanitizer. Gated on `narrativeWasWithheld` so this costs one extra completion only
3444+
// on the branch that has no summary today (~2-3/day observed), never on a review whose prose published.
3445+
//
3446+
// The IO lives HERE rather than inside composeAdvisoryNotes, which stays pure and synchronous: making the
3447+
// composer async to hold a provider call would push `await` through its call site and cost the
3448+
// testability that makes the sanitizer's behaviour verifiable at all.
3449+
let regeneratedAssessment: string | undefined;
3450+
if (reviewsForNotes.length > 0 && input.providerKey && narrativeWasWithheld(reviewsForNotes, notesOptions)) {
3451+
const narrative = reviewsForNotes.map((review) => review.assessment).find((text): text is string => Boolean(text));
3452+
const regenerated = narrative ? await regeneratePublicSafeSummary(input.providerKey, narrative, notesOptions) : null;
3453+
regeneratedAssessment = regenerated ?? undefined;
3454+
// Both outcomes are counted: the regeneration RATE is the point of the metric, and a silent
3455+
// all-fallback result would otherwise look identical to the feature never firing.
3456+
incr("loopover_ai_review_summary_regenerated_total", { outcome: regenerated ? "published" : "fallback" });
3457+
}
33533458
const advisoryNotes =
33543459
reviewsForNotes.length > 0
3355-
? (composeAdvisoryNotes(reviewsForNotes, { allowBareScoreTerm: isPublicScoreTermSafeForRepo(env, input.repoFullName) }) ?? composeFallbackAdvisoryNotes(fallbackNotes))
3460+
? (composeAdvisoryNotes(reviewsForNotes, { ...notesOptions, ...(regeneratedAssessment !== undefined && { regeneratedAssessment }) }) ?? composeFallbackAdvisoryNotes(fallbackNotes))
33563461
: composeFallbackAdvisoryNotes(fallbackNotes);
33573462
// Line-anchored inline findings (#inline-comments): only propagate model output when the resolved feature gate
33583463
// asked for it. AI output is PR-author-influenced, so the prompt suffix is not an authorization boundary.
@@ -3530,6 +3635,10 @@ export const __aiReviewInternals = {
35303635
synthesizeDefect,
35313636
toPublicSafe,
35323637
toPublicSafeBySentence,
3638+
narrativeWasWithheld,
3639+
regeneratePublicSafeSummary,
3640+
WITHHELD_NARRATIVE_CLEAN,
3641+
WITHHELD_NARRATIVE_WITH_BLOCKERS,
35333642
estimateNeurons,
35343643
runWorkersOpinion,
35353644
coerceAiUsage,

test/unit/ai-review.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ const {
3232
parseDualAiTieBreakJudgeResponse,
3333
coerceAiText,
3434
composeAdvisoryNotes,
35+
narrativeWasWithheld,
36+
regeneratePublicSafeSummary,
37+
WITHHELD_NARRATIVE_CLEAN,
38+
WITHHELD_NARRATIVE_WITH_BLOCKERS,
3539
composeInlineFindings,
3640
composeImprovementSignal,
3741
consensusDefectOf,
@@ -5652,3 +5656,68 @@ describe("withheld-narrative honesty (#9794 regression)", () => {
56525656
expect(notes).not.toContain("withheld");
56535657
});
56545658
});
5659+
5660+
describe("regenerated public-safe summary (#9809)", () => {
5661+
// The withheld branch: every sentence of an honest narrative about this project's own scoring/gate code
5662+
// trips the sanitizer, so the reader used to get a fixed placeholder instead of a real summary. The
5663+
// regeneration improves that text WITHOUT widening what is publishable -- the rewrite faces the identical
5664+
// sanitizer, and anything that does not survive falls back to the fixed sentence.
5665+
const withheldNarrative = "The ranking weights are recomputed and the reward payout shifts accordingly.";
5666+
5667+
// The provider-path cases below stub global fetch. Without this, the stub leaks into whatever file the
5668+
// worker runs next -- it took out selfhost-metrics.test.ts in a full run while passing in isolation.
5669+
afterEach(() => {
5670+
vi.unstubAllGlobals();
5671+
});
5672+
const modelReview = (over: Partial<Record<string, unknown>> = {}) =>
5673+
({ assessment: withheldNarrative, blockers: [], nits: [], suggestions: [], confidence: 0.9, inlineFindings: [], ...over }) as never;
5674+
5675+
it("narrativeWasWithheld is true only when the WHOLE narrative was withheld and nothing blocks", () => {
5676+
expect(narrativeWasWithheld([modelReview()])).toBe(true);
5677+
// A publishable narrative needs no regeneration -- firing there would spend a completion on every review.
5678+
expect(narrativeWasWithheld([modelReview({ assessment: "The error branch is unhandled." })])).toBe(false);
5679+
// Blockers keep the fixed sentence, so a rewrite would be discarded; do not pay for one.
5680+
expect(narrativeWasWithheld([modelReview({ blockers: ["The ranking weight is wrong"] })])).toBe(false);
5681+
expect(narrativeWasWithheld([])).toBe(false);
5682+
});
5683+
5684+
it("publishes a regenerated summary that survives the sanitizer", () => {
5685+
const notes = composeAdvisoryNotes([modelReview()], { regeneratedAssessment: "The change updates how a value is computed and the tests cover both branches." });
5686+
expect(notes).toContain("the tests cover both branches");
5687+
expect(notes).not.toContain(WITHHELD_NARRATIVE_CLEAN);
5688+
});
5689+
5690+
it("falls back to the fixed sentence when no regeneration was obtained", () => {
5691+
expect(composeAdvisoryNotes([modelReview()])).toContain(WITHHELD_NARRATIVE_CLEAN);
5692+
});
5693+
5694+
it("NEVER lets a regenerated summary speak for a withheld BLOCKER", () => {
5695+
// The one wrong outcome: prose that reads clean over a blocker the model actually raised. The rewrite is
5696+
// ignored entirely on this branch, not merely appended to.
5697+
const notes = composeAdvisoryNotes([modelReview({ blockers: ["The ranking weight is miscomputed"] })], {
5698+
regeneratedAssessment: "The change looks good and no issues were found.",
5699+
});
5700+
expect(notes).toContain(WITHHELD_NARRATIVE_WITH_BLOCKERS);
5701+
expect(notes).not.toContain("no issues were found");
5702+
});
5703+
5704+
it("regeneratePublicSafeSummary returns null when the rewrite itself trips the sanitizer", async () => {
5705+
// The never-echo guarantee: the prompt is not the boundary, the sanitizer is. A rewrite that still names
5706+
// forbidden vocabulary must not reach the public surface just because it was asked to avoid it.
5707+
vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ content: [{ type: "text", text: "The ranking weights changed." }] }), { status: 200 }));
5708+
const out = await regeneratePublicSafeSummary({ provider: "anthropic", key: "k", model: "m" } as never, withheldNarrative);
5709+
expect(out).toBeNull();
5710+
});
5711+
5712+
it("treats the model's CANNOT_SUMMARIZE opt-out as a refusal, never as summary text", async () => {
5713+
vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ content: [{ type: "text", text: "CANNOT_SUMMARIZE" }] }), { status: 200 }));
5714+
expect(await regeneratePublicSafeSummary({ provider: "anthropic", key: "k", model: "m" } as never, withheldNarrative)).toBeNull();
5715+
});
5716+
5717+
it("degrades to null when the provider call fails, rather than losing the review", async () => {
5718+
vi.stubGlobal("fetch", async () => {
5719+
throw new Error("network down");
5720+
});
5721+
expect(await regeneratePublicSafeSummary({ provider: "anthropic", key: "k", model: "m" } as never, withheldNarrative)).toBeNull();
5722+
});
5723+
});

0 commit comments

Comments
 (0)