diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index ff439cae9..fcf76ae74 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -58,6 +58,7 @@ import { runRawCompletionWithFallback, } from "@/lib/ai/provider-runner"; import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { screenInsightPayloadProse } from "@/lib/ai/safety/insight-payload-screen"; import { findUngroundedBriefingNumbers, readBriefingBlock, @@ -895,6 +896,26 @@ export const POST = apiHandler((request: NextRequest) => } } + // Outbound safety screen over the whole prose surface, before the persist. + // + // This route is user-initiated but writes the SAME cached payload the + // background generator writes, so a violation must not be persisted here + // either. The user is waiting, so unlike the background path they get an + // explicit error rather than silence -- and because nothing was written, + // the previous cached insight is still what the app renders. + const screened = screenInsightPayloadProse(insights, locale); + if (screened) { + annotate({ + action: { name: "insights.generate.outbound_blocked" }, + meta: { locale, reason: screened }, + }); + return apiError( + "The generated insight did not pass the safety check and was discarded. Try regenerating.", + 502, + { errorCode: "insights.generate.outboundScreened" }, + ); + } + await prisma.user.update({ where: { id: userId }, data: { diff --git a/src/lib/ai/safety/insight-payload-screen.ts b/src/lib/ai/safety/insight-payload-screen.ts new file mode 100644 index 000000000..9d8f659e9 --- /dev/null +++ b/src/lib/ai/safety/insight-payload-screen.ts @@ -0,0 +1,72 @@ +/** + * Outbound safety screen over a whole comprehensive-insight payload. + * + * Lives apart from the generator on purpose: it is a pure function of the + * payload shape, both the background generator and the user-initiated POST + * route run it before their persist, and neither should have to import the + * other to get at it. + * + * What it closes. The number-grounding gate covers `dailyBriefing.paragraph`, + * `signalsOfDay[]` and `keyFindings[]`, and it grades NUMBERS only — so a + * digit-free "your 10-year cardiovascular risk is elevated, consider stepping + * your dose up" passed it untouched, and `recommendations[]` / `summary` were + * not graded at all. This screens every prose field the user can read against + * the insights safety contracts. + */ +import type { Locale } from "@/lib/i18n/config"; +import { readBriefingBlock } from "@/lib/ai/briefing-grounding"; +import { + screenModelOutput, + INSIGHTS_CONTRACTS, + type OutboundReason, +} from "@/lib/ai/safety/outbound-screen"; + +/** Every free-text field of a comprehensive payload the user can read. */ +export function collectInsightProse(parsed: unknown): string[] { + const out: string[] = []; + const push = (v: unknown) => { + if (typeof v === "string" && v.trim().length > 0) out.push(v); + }; + if (!parsed || typeof parsed !== "object") return out; + const root = parsed as Record; + + push(root.summary); + for (const rec of Array.isArray(root.recommendations) + ? root.recommendations + : []) { + if (rec && typeof rec === "object") { + push((rec as Record).text); + } + } + + const briefing = readBriefingBlock(parsed); + if (briefing) { + push(briefing.paragraph); + for (const s of briefing.signalsOfDay ?? []) { + push(s?.headline); + push(s?.nudge); + push(s?.delta); + } + for (const f of briefing.keyFindings ?? []) { + push(f?.headline); + push(f?.detail); + push(f?.delta); + } + } + return out; +} + +/** + * Screen every prose field of a payload. Returns the first tripped contract, + * or null when the whole payload is clean. + */ +export function screenInsightPayloadProse( + parsed: unknown, + locale: Locale, +): OutboundReason | null { + for (const text of collectInsightProse(parsed)) { + const decision = screenModelOutput(text, locale, INSIGHTS_CONTRACTS); + if (decision.block && decision.reason) return decision.reason; + } + return null; +} diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index f9fdff775..563f78b67 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -32,6 +32,7 @@ import { readBriefingBlock, buildBriefingGroundingCorrection, } from "@/lib/ai/briefing-grounding"; +import { screenInsightPayloadProse } from "@/lib/ai/safety/insight-payload-screen"; import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter"; import { getCachedFeatures } from "@/lib/insights/feature-cache"; import { @@ -1038,6 +1039,27 @@ export async function generateComprehensiveInsight( } } + // Outbound safety screen over the WHOLE prose surface. + // + // SURFACE POLICY — WITHHOLD. This payload is generated in the background and + // persisted as the cached insight; nobody is waiting on the turn. Unlike the + // number-grounding gate above, a safety violation cannot be surgically + // stripped: a dose imperative may sit in `recommendations[]`, in `summary`, + // or in the briefing, and deleting an arbitrary field would leave a payload + // whose remaining prose still refers to it. So the whole generation fails and + // persists NOTHING -- the previous cached payload stays, exactly as a + // provider outage or an unparseable response already behaves. The next run + // regenerates from scratch. + const screened = screenInsightPayloadProse(insights, locale); + if (screened) { + annotate({ + action: { name: "insights.generate.outbound_blocked" }, + meta: { locale, reason: screened }, + }); + void recordBriefingFailure({ userId, reason: "outbound-screened", locale }); + return { status: "failed", reason: "outbound-screened" }; + } + // v1.9.0 — the caller abandoned this generation (its bounded timeout // fired and it has moved on to warm the per-status caches itself). Bail // out before the cache write so a late-resolving comprehensive cannot