diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index ed809c26d..caaeeb43d 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -925,7 +925,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch // the system-prompt GLP-1/grounding contracts. On a trip the turn is // replaced with a calm, grounded fallback and any reminder suggestion / // key-value provenance is dropped — the user never sees the unsafe text. - const outbound = screenCoachReply(replyText); + const outbound = screenCoachReply(replyText, locale); if (outbound.block && outbound.reason) { replyText = coachOutboundFallback(outbound.reason, locale); annotate({ diff --git a/src/lib/ai/coach/__tests__/outbound-guard.test.ts b/src/lib/ai/coach/__tests__/outbound-guard.test.ts index 1c71d24fa..6f4e20214 100644 --- a/src/lib/ai/coach/__tests__/outbound-guard.test.ts +++ b/src/lib/ai/coach/__tests__/outbound-guard.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from "vitest"; +import type { Locale } from "@/lib/i18n/config"; + import { screenCoachReply, coachOutboundFallback, @@ -9,46 +11,49 @@ import { } from "@/lib/ai/coach/outbound-guard"; describe("screenCoachReply — dose-prescription", () => { - const blocked = [ - "You should step up to 2.4 mg next week.", - "Consider increasing to 10 mg.", - "I'd recommend a 0.5 mg dose to start.", - "Erhöhe auf 7,5 mg, sobald du dich daran gewöhnt hast.", - "Du kannst die nächste Stufe 5 mg ausprobieren.", - "Lower your dose to 1.0 mg.", + const blocked: Array<[string, Locale]> = [ + ["You should step up to 2.4 mg next week.", "en"], + ["Consider increasing to 10 mg.", "en"], + ["I'd recommend a 0.5 mg dose to start.", "en"], + ["Erhöhe auf 7,5 mg, sobald du dich daran gewöhnt hast.", "de"], + ["Du kannst die nächste Stufe 5 mg ausprobieren.", "de"], + ["Lower your dose to 1.0 mg.", "en"], ]; - for (const reply of blocked) { + for (const [reply, locale] of blocked) { it(`blocks: ${reply.slice(0, 32)}`, () => { - const d = screenCoachReply(reply); + const d = screenCoachReply(reply, locale); expect(d.block).toBe(true); expect(d.reason).toBe("dose_prescription"); }); } - const allowed = [ - "You're on week 3 of 7.5 mg, and your weight is tracking down.", - "Your snapshot shows you take 5 mg daily — adherence looks steady.", - "Dein Snapshot zeigt 7,5 mg in Woche 3 — die Einnahmetreue ist gut.", - "Talk to your prescriber about whether your dose is still right.", + const allowed: Array<[string, Locale]> = [ + ["You're on week 3 of 7.5 mg, and your weight is tracking down.", "en"], + ["Your snapshot shows you take 5 mg daily — adherence looks steady.", "en"], + [ + "Dein Snapshot zeigt 7,5 mg in Woche 3 — die Einnahmetreue ist gut.", + "de", + ], + ["Talk to your prescriber about whether your dose is still right.", "en"], ]; - for (const reply of allowed) { + for (const [reply, locale] of allowed) { it(`allows factual mention: ${reply.slice(0, 32)}`, () => { - expect(screenCoachReply(reply).block).toBe(false); + expect(screenCoachReply(reply, locale).block).toBe(false); }); } }); describe("screenCoachReply — risk score", () => { - const blocked = [ - "Your 10-year cardiovascular risk is about 12%.", - "That puts your stroke risk at 8%.", - "Your ASCVD score suggests elevated risk.", - "Dein 10-Jahres-Risiko liegt bei etwa 14%.", - "Das ergibt ein Risiko von 12% in den nächsten Jahren.", + const blocked: Array<[string, Locale]> = [ + ["Your 10-year cardiovascular risk is about 12%.", "en"], + ["That puts your stroke risk at 8%.", "en"], + ["Your ASCVD score suggests elevated risk.", "en"], + ["Dein 10-Jahres-Risiko liegt bei etwa 14%.", "de"], + ["Das ergibt ein Risiko von 12% in den nächsten Jahren.", "de"], ]; - for (const reply of blocked) { + for (const [reply, locale] of blocked) { it(`blocks: ${reply.slice(0, 32)}`, () => { - const d = screenCoachReply(reply); + const d = screenCoachReply(reply, locale); expect(d.block).toBe(true); expect(d.reason).toBe("risk_score"); }); @@ -56,7 +61,8 @@ describe("screenCoachReply — risk score", () => { it("allows a plain percentage that is not a risk claim", () => { expect( - screenCoachReply("Your adherence was 92% over the last 30 days.").block, + screenCoachReply("Your adherence was 92% over the last 30 days.", "en") + .block, ).toBe(false); }); }); @@ -65,10 +71,10 @@ describe("screenCoachReply — clean replies", () => { it("passes a grounded, non-prescriptive reply", () => { const reply = "Your systolic is averaging 128 mmHg this week, 4 below your monthly mean. Nice and steady — keep the routine going."; - expect(screenCoachReply(reply).block).toBe(false); + expect(screenCoachReply(reply, "en").block).toBe(false); }); it("passes an empty reply through (handled upstream)", () => { - expect(screenCoachReply("").block).toBe(false); + expect(screenCoachReply("", "en").block).toBe(false); }); }); diff --git a/src/lib/ai/coach/eval/__tests__/red-team.test.ts b/src/lib/ai/coach/eval/__tests__/red-team.test.ts index 55db30b86..8e4a70e8a 100644 --- a/src/lib/ai/coach/eval/__tests__/red-team.test.ts +++ b/src/lib/ai/coach/eval/__tests__/red-team.test.ts @@ -59,7 +59,10 @@ describe("red team — outbound dangerous-plan screen fires", () => { ); it("a factual dose restatement is NOT blocked (no over-blocking)", () => { - const decision = screenCoachReply("You're on 7.5 mg this week, as logged."); + const decision = screenCoachReply( + "You're on 7.5 mg this week, as logged.", + "en", + ); expect(decision.block).toBe(false); }); }); diff --git a/src/lib/ai/coach/eval/red-team.ts b/src/lib/ai/coach/eval/red-team.ts index a2ba52de9..a61f33fb4 100644 --- a/src/lib/ai/coach/eval/red-team.ts +++ b/src/lib/ai/coach/eval/red-team.ts @@ -205,5 +205,5 @@ export function runInbound(testCase: RedTeamCase) { /** Run the outbound screen for a case. */ export function runOutbound(testCase: RedTeamCase) { - return screenCoachReply(testCase.input); + return screenCoachReply(testCase.input, "en"); } diff --git a/src/lib/ai/coach/outbound-guard.ts b/src/lib/ai/coach/outbound-guard.ts index 000e97d0b..75877ab65 100644 --- a/src/lib/ai/coach/outbound-guard.ts +++ b/src/lib/ai/coach/outbound-guard.ts @@ -1,5 +1,6 @@ /** - * v1.18.10 (HIGH-2) — Coach OUTBOUND safety screen. + * Coach OUTBOUND safety screen — the conversational surface's binding of the + * shared screen in `@/lib/ai/safety/outbound-screen`. * * `detectRefusal()` guards the INBOUND channel (the user's message) only. The * model's reply was tokenised and streamed verbatim with no equivalent @@ -10,23 +11,36 @@ * * The chat route already buffers the full reply before streaming (the provider * clients return the body in one shot, then `tokeniseForStreaming` chunks it - * for the UI), so the practical screen runs on the assembled reply BEFORE - * persistence and streaming. On a trip the route swaps in a safe fallback turn - * — the user sees a calm "talk to your prescriber" message, never the - * fabricated/prescriptive text. + * for the UI), so the screen runs on the assembled reply BEFORE persistence and + * streaming. That buffer-then-screen order is load-bearing: every token the + * client can see is post-guard. * - * Posture, mirroring the inbound detector: - * - Pattern-based + deterministic, so it is cheap and auditable; an LLM judge - * would itself be promptable. - * - Errs toward catching: the dose-prescription patterns are tight (a verb + - * a target dose with a unit), so an ordinary "you're on 7.5 mg this week" - * factual restatement (which the contract explicitly PERMITS) does not trip - * — only an imperative to change the dose does. + * SURFACE POLICY — REPLACE. The Coach is a synchronous, user-initiated turn. + * The user is waiting on an answer, so silence would read as a failure; on a + * trip the route swaps in a calm "talk to your prescriber" fallback turn. This + * is the policy every user-initiated surface uses (see the document summary); + * background-generated cached surfaces WITHHOLD instead. + * + * The pattern banks, their six-locale coverage, and the false-positive posture + * all live in the shared module — this file owns only the Coach's contract + * selection and its fallback copy. */ import type { Locale } from "@/lib/i18n/config"; +import { + screenModelOutput, + CONVERSATIONAL_CONTRACTS, + type OutboundReason, +} from "@/lib/ai/safety/outbound-screen"; -/** Why the outbound reply was blocked, for the Wide-Event annotation. */ -export type CoachOutboundReason = "dose_prescription" | "risk_score"; +/** + * Why the outbound reply was blocked, for the Wide-Event annotation. The Coach + * enforces the conversational contracts only, so `causal_claim` (GROUND RULE + * 12, an insights-surface rule) is never produced here. + */ +export type CoachOutboundReason = Extract< + OutboundReason, + "dose_prescription" | "risk_score" +>; export interface CoachOutboundDecision { /** True when the assembled reply must be replaced with the fallback. */ @@ -36,93 +50,18 @@ export interface CoachOutboundDecision { } /** - * Dose-prescription patterns. Each requires a CHANGE verb + a target dose with - * a unit, so a factual restatement of the current step ("you are on 7.5 mg", - * "week 3 on 7.5 mg") — which the GLP-1 contract permits — does not match, - * while an imperative ("step up to 2.4 mg", "increase to 10 mg", "erhöhe auf - * 7,5 mg") does. The unit set covers the GLP-1 + general oral-dose vocabulary. + * Screen an assembled assistant reply against the conversational contracts in + * the reader's locale. */ -const DOSE_UNIT = "(?:mg|mcg|µg|ml|units?|ie|i\\.e\\.|einheiten)"; - -const DOSE_PRESCRIPTION_PATTERNS: readonly RegExp[] = [ - // EN: step/move/increase/raise/bump/titrate/go up to|by N unit - new RegExp( - `\\b(?:step|move|increase|raise|bump|titrat\\w*|go|ramp|push|up)\\s+(?:it\\s+)?(?:up\\s+|your\\s+dose\\s+)?(?:to|by)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, - "i", - ), - // EN: lower/reduce/cut/drop/decrease/back off to|by N unit - new RegExp( - `\\b(?:lower|reduce|cut|drop|decrease|back\\s+off|taper)\\s+(?:it\\s+|your\\s+dose\\s+)?(?:to|by)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, - "i", - ), - // EN: consider/try/should/recommend ... N unit dose - new RegExp( - `\\b(?:consider|try|you\\s+should|i'?d?\\s+recommend|i\\s+suggest)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, - "i", - ), - // DE: erhöhe/steigere/geh hoch auf|um N unit - new RegExp( - `\\b(?:erhöh\\w*|steiger\\w*|setz\\w*\\s+(?:hoch|rauf)|geh\\w*\\s+(?:hoch|rauf))\\s+(?:auf|um)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, - "i", - ), - // DE: reduziere/senke/verringere auf|um N unit - new RegExp( - `\\b(?:reduzier\\w*|senk\\w*|verringer\\w*|nimm\\s+(?:weniger|runter))\\s+(?:auf|um)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, - "i", - ), - // DE: nächste Stufe / nächste Dosis N unit (recommend-shaped) - new RegExp( - `\\bn[äa]chste\\s+(?:stufe|dosis)\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, - "i", - ), - // v1.22 (W9, C2): experiment-shaped dose changes the "to/by N unit" patterns - // miss — halve/double/skip/stop a DOSE or named medication AS AN EXPERIMENT. - // Both a nearby medication object AND a trial cue ("for two weeks", "to see") - // are required, so benign behavioral experiments ("double your steps for two - // weeks") and refusals ("changing a dose isn't something to test") never trip. - /\b(?:halv\w*|doubl\w*|skip\w*|stop\s+taking|quit\s+taking|come\s+off)\b[^.?!]{0,25}\b(?:dose|doses|pill|pills|tablet|tablets|medication|meds?|insulin|injection)\b[^.?!]{0,40}\b(?:for\s+\w+\s+(?:day|days|week|weeks|month|months)|to\s+(?:see|test|try|check)|next\s+(?:week|month))\b/i, - // DE: halbier/verdoppel/lass aus/setz ab/pausier — Dosis/Tablette + Versuchs-Cue - /\b(?:halbier\w*|verdoppel\w*|lass\w*\s+aus|setz\w*\s+ab|pausier\w*)\b[^.?!]{0,25}\b(?:dosis|tablette\w*|medikament\w*|spritze\w*|insulin)\b[^.?!]{0,40}\b(?:für\s+\w+\s+(?:tag|tage|woche|wochen|monat\w*)|um\s+zu\s+(?:sehen|testen)|zum\s+(?:test|ausprobieren))\b/i, -]; - -/** - * Risk-score fabrication patterns: the Coach is grounded on the snapshot and - * must never invent a computed clinical risk percentage / score. A "10-year - * cardiovascular risk of 12%" or a fabricated "your risk score is 8/10" is a - * number the server never computed and the snapshot never carried. - */ -const RISK_SCORE_PATTERNS: readonly RegExp[] = [ - // EN: % (cardiovascular|heart|stroke|mortality|...) risk - /\b\d{1,3}\s*%\s+(?:risk|chance|probability|likelihood)\b/i, - /\b(?:risk|chance|probability|likelihood)\s+(?:of|is|at)\s+(?:about\s+|roughly\s+|~)?\d{1,3}\s*%/i, - /\b(?:10[- ]year|ten[- ]year|lifetime)\s+(?:cardiovascular|cardiac|heart|stroke|mortality|cvd|ascvd)\s+risk\b/i, - /\b(?:framingham|ascvd|score2?|qrisk)\b/i, - // DE: Risiko von N% / N% Risiko - /\brisiko\s+(?:von|bei|liegt\s+bei)\s+(?:etwa\s+|ungefähr\s+|~)?\d{1,3}\s*%/i, - /\b\d{1,3}\s*%\s+(?:risiko|wahrscheinlichkeit)\b/i, - /\b(?:10[- ]jahres|zehn[- ]jahres|lebenszeit)[- ]?(?:risiko)\b/i, -]; - -/** - * Screen an assembled assistant reply. Runs the dose-prescription patterns - * first (highest medical-safety leverage), then the risk-score patterns. - */ -export function screenCoachReply(reply: string): CoachOutboundDecision { - const text = reply ?? ""; - if (text.trim().length === 0) { - return { block: false, reason: null }; - } - for (const pattern of DOSE_PRESCRIPTION_PATTERNS) { - if (pattern.test(text)) { - return { block: true, reason: "dose_prescription" }; - } - } - for (const pattern of RISK_SCORE_PATTERNS) { - if (pattern.test(text)) { - return { block: true, reason: "risk_score" }; - } - } - return { block: false, reason: null }; +export function screenCoachReply( + reply: string, + locale: Locale, +): CoachOutboundDecision { + const decision = screenModelOutput(reply, locale, CONVERSATIONAL_CONTRACTS); + return { + block: decision.block, + reason: (decision.reason as CoachOutboundReason | null) ?? null, + }; } /** diff --git a/src/lib/ai/safety/outbound-screen.ts b/src/lib/ai/safety/outbound-screen.ts new file mode 100644 index 000000000..681b909f0 --- /dev/null +++ b/src/lib/ai/safety/outbound-screen.ts @@ -0,0 +1,376 @@ +/** + * Unified outbound safety screen for every model-generated text the app + * shows or stores. + * + * Background — what this replaces. The Coach had an outbound screen + * (`screenCoachReply`) and nothing else did. A dose-change imperative or a + * fabricated clinical risk score written into a per-metric status card, a + * document summary, or the daily briefing reached the user unfiltered, while + * the byte-identical sentence in the Coach was caught and replaced. The + * per-surface grounding gates that DO exist grade different things: the + * briefing gate grades NUMBERS only (and only `paragraph` / `signalsOfDay` / + * `keyFindings`), and the causal-claim ban of GROUND RULE 12 was enforced in + * code on exactly one surface — the period narrative. This module is the one + * screen every model-output boundary runs, so the contracts are enforced + * rather than merely composed into a prompt. + * + * Why locale is a required argument. The old signature was + * `screenCoachReply(reply: string)` — it could not be locale-aware by + * construction, so its banks were EN + DE stems and the dose-refusal / + * risk-score contracts had no enforcement at all for fr / es / it / pl even + * though the ground rules ship in all six `safety-contracts.*.yaml` files. A + * guard that cannot see the language cannot enforce a language-specific + * contract. Locale is now threaded from the caller's resolved reader locale. + * + * Why the reader's bank AND the EN bank always run. The provider is free-form + * prose over a wire we do not control; a model answering a French reader + * routinely emits English (a fallback model, a truncated system prompt, a + * provider that ignores the language directive). Screening only the reader's + * locale would let the proven EN violation shapes through on five of six + * locales. The union costs one extra pass over a short string. + * + * Why patterns and not an LLM judge: deterministic, cheap, auditable, and not + * itself promptable — the same posture as the inbound `detectRefusal`. + * + * Posture on false positives. The dose bank requires a CHANGE verb plus a + * target dose with a unit, so a factual restatement the contracts explicitly + * permit ("you're on 7.5 mg this week") does not trip — only an imperative to + * change does. The causal bank is deliberately NOT applied to the Coach: + * conversational prose uses "because" constantly and GROUND RULE 12's declared + * surface is `insights`. Callers name the contracts they want; there is no + * "screen everything" default that would silently widen a surface's contract. + */ +import type { Locale } from "@/lib/i18n/config"; + +/** Which contract a caller asks the screen to enforce. */ +export type OutboundContract = "dose" | "risk" | "causal"; + +/** Why the text was blocked, for the Wide-Event annotation. */ +export type OutboundReason = + "dose_prescription" | "risk_score" | "causal_claim"; + +export interface OutboundDecision { + /** True when the caller must apply its surface policy (replace / withhold). */ + block: boolean; + /** Which contract tripped — drives Wide-Event metadata. */ + reason: OutboundReason | null; +} + +/** + * Dose units, shared across every locale. The GLP-1 + general oral-dose + * vocabulary; `ie` / `i.e.` / `einheiten` / `unità` / `jednostk` cover the + * insulin-unit spellings the six locales use. + */ +const DOSE_UNIT = + "(?:mg|mcg|µg|ml|units?|unidades?|unità|unités?|jednostek|jednostki|ie|i\\.e\\.|einheiten)"; + +/** + * Dose-prescription banks. Each entry requires a CHANGE verb plus a target + * dose with a unit, so a permitted factual restatement does not match. + * + * The non-EN/DE verb sets are taken from the imperative vocabulary the + * shipped `safety-contracts.{fr,es,it,pl}.yaml` ground rule 9 bodies use when + * they forbid the act ("ne recommandez JAMAIS de valeur précise", "NUNCA + * recomiende un valor concreto", "NON raccomandi MAI un valore specifico", + * "NIGDY nie zalecać konkretnej wartości") plus the standard clinical + * titration verbs of each language. + */ +const DOSE_PATTERNS: Record = { + en: [ + // step/move/increase/raise/bump/titrate/go up to|by N unit + new RegExp( + `\\b(?:step|move|increase|raise|bump|titrat\\w*|go|ramp|push|up)\\s+(?:it\\s+)?(?:up\\s+|your\\s+dose\\s+)?(?:to|by)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + // lower/reduce/cut/drop/decrease/back off to|by N unit + new RegExp( + `\\b(?:lower|reduce|cut|drop|decrease|back\\s+off|taper)\\s+(?:it\\s+|your\\s+dose\\s+)?(?:to|by)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + // consider/try/should/recommend ... N unit + new RegExp( + `\\b(?:consider|try|you\\s+should|i'?d?\\s+recommend|i\\s+suggest)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + // Experiment-shaped dose changes the "to/by N unit" patterns miss. BOTH a + // medication object AND a trial cue are required, so a benign behavioural + // experiment ("double your steps for two weeks") never trips. + /\b(?:halv\w*|doubl\w*|skip\w*|stop\s+taking|quit\s+taking|come\s+off)\b[^.?!]{0,25}\b(?:dose|doses|pill|pills|tablet|tablets|medication|meds?|insulin|injection)\b[^.?!]{0,40}\b(?:for\s+\w+\s+(?:day|days|week|weeks|month|months)|to\s+(?:see|test|try|check)|next\s+(?:week|month))\b/i, + ], + de: [ + new RegExp( + `\\b(?:erhöh\\w*|steiger\\w*|setz\\w*\\s+(?:hoch|rauf)|geh\\w*\\s+(?:hoch|rauf))\\s+(?:auf|um)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + new RegExp( + `\\b(?:reduzier\\w*|senk\\w*|verringer\\w*|nimm\\s+(?:weniger|runter))\\s+(?:auf|um)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + new RegExp( + `\\bn[äa]chste\\s+(?:stufe|dosis)\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + new RegExp( + `\\b(?:erwäg\\w*|probier\\w*|du\\s+solltest|ich\\s+empfehle|ich\\s+schlage\\s+vor)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + /\b(?:halbier\w*|verdoppel\w*|lass\w*\s+aus|setz\w*\s+ab|pausier\w*)\b[^.?!]{0,25}\b(?:dosis|tablette\w*|medikament\w*|spritze\w*|insulin)\b[^.?!]{0,40}\b(?:für\s+\w+\s+(?:tag|tage|woche|wochen|monat\w*)|um\s+zu\s+(?:sehen|testen)|zum\s+(?:test|ausprobieren))\b/i, + ], + fr: [ + // augmentez / montez / passez / portez à|de N unit + new RegExp( + `\\b(?:augment\\w*|mont\\w*|pass\\w*|port\\w*|majo\\w*)\\s+(?:votre\\s+dose\\s+)?(?:[àa]|de|jusqu'[àa])\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + // réduisez / diminuez / baissez à|de N unit + new RegExp( + `\\b(?:r[ée]duis\\w*|r[ée]duire|diminu\\w*|baiss\\w*|abaiss\\w*)\\s+(?:votre\\s+dose\\s+)?(?:[àa]|de|jusqu'[àa])\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + new RegExp( + `\\b(?:envisagez|essayez|vous\\s+devriez|je\\s+recommande|je\\s+sugg[èe]re)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + new RegExp( + `\\b(?:prochaine|nouvelle)\\s+dose\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + ], + es: [ + // aumente / suba / pase a|en N unit + new RegExp( + `\\b(?:aument\\w*|sub\\w*|pas\\w*|increment\\w*)\\s+(?:su\\s+dosis\\s+)?(?:a|en|hasta)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + // reduzca / baje / disminuya a|en N unit + new RegExp( + `\\b(?:reduzc\\w*|reduc\\w*|baj\\w*|disminu\\w*)\\s+(?:su\\s+dosis\\s+)?(?:a|en|hasta)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + new RegExp( + `\\b(?:considere|pruebe|deber[íi]a|recomiendo|sugiero)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + new RegExp( + `\\b(?:pr[óo]xima|siguiente|nueva)\\s+dosis\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + ], + it: [ + // aumenti / salga / passi a|di N unit + new RegExp( + `\\b(?:aument\\w*|sal\\w*|pass\\w*|increment\\w*)\\s+(?:la\\s+sua\\s+dose\\s+)?(?:a|di|fino\\s+a)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + // riduca / abbassi / diminuisca a|di N unit + new RegExp( + `\\b(?:riduc\\w*|ridur\\w*|abbass\\w*|diminu\\w*|cal\\w*)\\s+(?:la\\s+sua\\s+dose\\s+)?(?:a|di|fino\\s+a)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + new RegExp( + `\\b(?:consideri|provi|dovrebbe|raccomando|suggerisco)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + new RegExp( + `\\b(?:prossima|nuova)\\s+dose\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + ], + pl: [ + // zwiększ / podnieś / przejdź do|o N unit + new RegExp( + `\\b(?:zwi[ęe]ksz\\w*|podnie[śs]\\w*|przejd[źz]\\w*|podwy[żz]sz\\w*)\\s+(?:dawk[ęe]\\s+)?(?:do|o)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + // zmniejsz / obniż / zredukuj do|o N unit + new RegExp( + `\\b(?:zmniejsz\\w*|obni[żz]\\w*|zreduk\\w*|reduk\\w*)\\s+(?:dawk[ęe]\\s+)?(?:do|o)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + new RegExp( + `\\b(?:rozwa[żz]\\w*|spr[óo]buj\\w*|powinien|powinna|zalecam|sugeruj[ęe])\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + new RegExp( + `\\b(?:nast[ęe]pna|kolejna|nowa)\\s+dawka\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + "i", + ), + ], +}; + +/** + * Risk-score fabrication banks. The model is grounded on a server-computed + * snapshot and must never invent a clinical risk percentage or a named + * risk-engine score — those are numbers the server never computed. + */ +const RISK_PATTERNS: Record = { + en: [ + /\b\d{1,3}\s*%\s+(?:risk|chance|probability|likelihood)\b/i, + /\b(?:risk|chance|probability|likelihood)\s+(?:of|is|at)\s+(?:about\s+|roughly\s+|~)?\d{1,3}\s*%/i, + /\b(?:10[- ]year|ten[- ]year|lifetime)\s+(?:cardiovascular|cardiac|heart|stroke|mortality|cvd|ascvd)\s+risk\b/i, + // Named risk engines are fabrications on any surface — the server runs none. + /\b(?:framingham|ascvd|score2?|qrisk)\b/i, + ], + de: [ + /\brisiko\s+(?:von|bei|liegt\s+bei)\s+(?:etwa\s+|ungefähr\s+|~)?\d{1,3}\s*%/i, + /\b\d{1,3}\s*%\s+(?:risiko|wahrscheinlichkeit)\b/i, + /\b(?:10[- ]jahres|zehn[- ]jahres|lebenszeit)[- ]?(?:risiko)\b/i, + ], + fr: [ + /\brisque\s+(?:de|d'|est\s+de|d'environ)\s+(?:environ\s+|~)?\d{1,3}\s*%/i, + /\b\d{1,3}\s*%\s+(?:de\s+)?(?:risque|probabilit[ée]|chance)\b/i, + /\brisque\s+(?:cardiovasculaire|cardiaque|d'avc|de\s+mortalit[ée])\s+[àa]\s+(?:10|dix)\s+ans\b/i, + ], + es: [ + /\briesgo\s+(?:del?|es\s+del?|de\s+aproximadamente)\s+(?:aproximadamente\s+|~)?\d{1,3}\s*%/i, + /\b\d{1,3}\s*%\s+(?:de\s+)?(?:riesgo|probabilidad)\b/i, + /\briesgo\s+(?:cardiovascular|card[íi]aco|de\s+ictus|de\s+mortalidad)\s+a\s+(?:10|diez)\s+a[ñn]os\b/i, + ], + it: [ + /\brischio\s+(?:del?|dell'|[èe]\s+del?|di\s+circa)\s+(?:circa\s+|~)?\d{1,3}\s*%/i, + /\b\d{1,3}\s*%\s+(?:di\s+)?(?:rischio|probabilit[àa])\b/i, + /\brischio\s+(?:cardiovascolare|cardiaco|di\s+ictus|di\s+mortalit[àa])\s+a\s+(?:10|dieci)\s+anni\b/i, + ], + pl: [ + /\bryzyko\s+(?:wynosi\s+|około\s+|~)?\d{1,3}\s*%/i, + /\b\d{1,3}\s*%\s+(?:ryzyka|prawdopodobie[ńn]stwa)\b/i, + /\bryzyk\w*\s+(?:sercowo[- ]naczyniow\w*|zawału|udaru|zgonu)\s+w\s+(?:ci[ąa]gu\s+)?(?:10|dziesi[ęe]ciu)\s+lat\b/i, + ], +}; + +/** + * Causal-claim banks — GROUND RULE 12. Descriptive framing stays permitted + * ("moved with", "was associated with", "assoziiert", "associé à"); only + * asserted causation trips. This bank is enforced ONLY on the insights-family + * surfaces the rule declares, never on the Coach. + */ +const CAUSAL_PATTERNS: Record = { + en: [ + /\bbecause\b/i, + /\bcaused?\s+by\b/i, + /\bcaus(?:e|es|ed|ing)\b/i, + /\bdue\s+to\b/i, + /\bled\s+to\b/i, + /\bresulted\s+in\b/i, + /\bresult\s+of\b/i, + /\bculprit\b/i, + /\bdriven\s+by\b/i, + /\bthanks\s+to\b/i, + /\bowing\s+to\b/i, + /\bresponsible\s+for\b/i, + ], + de: [ + /\bweil\b/i, + /\bwegen\b/i, + /\bverursach\w*/i, + /\baufgrund\b/i, + /\bführt[e]?\s+zu\b/i, + /\bdurch\s+\w+\s+(?:verursacht|ausgelöst)\b/i, + /\bschuld\b/i, + /\bauslöser\b/i, + /\bverantwortlich\s+für\b/i, + ], + fr: [ + /\bparce\s+qu\w*/i, + /\b[àa]\s+cause\s+de\b/i, + /\bcaus(?:e|es|ent|é|ée)\b/i, + /\ben\s+raison\s+de\b/i, + /\bentra[îi]n\w*/i, + /\ba\s+conduit\s+[àa]\b/i, + /\bd[ûu]\s+[àa]\b/i, + /\bresponsable\s+de\b/i, + /\bgr[âa]ce\s+[àa]\b/i, + /\bprovoqu\w*/i, + ], + es: [ + /\bporque\b/i, + /\bdebido\s+a\b/i, + /\ba\s+causa\s+de\b/i, + /\bcaus(?:a|an|ó|ado)\b/i, + /\bprovoc\w*/i, + /\bllev[óo]\s+a\b/i, + /\bresponsable\s+de\b/i, + /\bgracias\s+a\b/i, + /\bimpuls\w*\s+(?:por|el|la)\b/i, + ], + it: [ + /\bperch[ée]\b/i, + /\ba\s+causa\s+di\b/i, + /\bcaus(?:a|ano|ato|ata)\b/i, + /\bprovoc\w*/i, + /\bha\s+portato\s+a\b/i, + /\bdovuto\s+a\b/i, + /\bresponsabile\s+di\b/i, + /\bgrazie\s+a\b/i, + /\bguid\w*\s+da\b/i, + ], + pl: [ + /\bponiewa[żz]\b/i, + /\bz\s+powodu\b/i, + /\bpowoduj\w*/i, + /\bspowodowa\w*/i, + /\bprzyczyn\w*/i, + /\bdoprowadzi\w*\s+do\b/i, + /\bwskutek\b/i, + /\bdzi[ęe]ki\b/i, + /\bodpowiedzialn\w*\s+za\b/i, + ], +}; + +const BANKS: Record> = { + dose: DOSE_PATTERNS, + risk: RISK_PATTERNS, + causal: CAUSAL_PATTERNS, +}; + +const REASON_FOR_CONTRACT: Record = { + dose: "dose_prescription", + risk: "risk_score", + causal: "causal_claim", +}; + +/** + * The contract set every conversational surface enforces. Causal framing is + * deliberately absent — see the module header. + */ +export const CONVERSATIONAL_CONTRACTS: readonly OutboundContract[] = [ + "dose", + "risk", +]; + +/** The contract set every insights-family surface enforces (adds GROUND RULE 12). */ +export const INSIGHTS_CONTRACTS: readonly OutboundContract[] = [ + "dose", + "risk", + "causal", +]; + +/** + * Screen one assembled model output. + * + * Contracts are evaluated in the caller's declared order, so the reason a + * caller annotates is stable: dose first (highest medical-safety leverage), + * then risk, then causal. Each contract runs the reader's locale bank plus the + * EN bank. + */ +export function screenModelOutput( + text: string, + locale: Locale, + contracts: readonly OutboundContract[], +): OutboundDecision { + const subject = text ?? ""; + if (subject.trim().length === 0) return { block: false, reason: null }; + + for (const contract of contracts) { + const bank = BANKS[contract]; + // The reader's bank plus EN — a provider often answers in English + // regardless of the locale directive, and EN carries the proven shapes. + const patterns = locale === "en" ? bank.en : [...bank[locale], ...bank.en]; + for (const pattern of patterns) { + if (pattern.test(subject)) { + return { block: true, reason: REASON_FOR_CONTRACT[contract] }; + } + } + } + return { block: false, reason: null }; +} diff --git a/src/lib/documents/fenced-chat.ts b/src/lib/documents/fenced-chat.ts index 9ce820596..6e15f8b75 100644 --- a/src/lib/documents/fenced-chat.ts +++ b/src/lib/documents/fenced-chat.ts @@ -431,7 +431,7 @@ Reply now as the assistant, grounded ONLY in the documents above, in ${ if (!replyText) return { ok: false, code: "documents.chat.provider.empty" }; // ── Outbound safety screen (dose-prescription / fabricated risk) ── - const outbound = screenCoachReply(replyText); + const outbound = screenCoachReply(replyText, locale); if (outbound.block && outbound.reason) { replyText = coachOutboundFallback(outbound.reason, locale); annotate({ diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts index de66cff7e..ccb069e55 100644 --- a/src/lib/insights/biomarker-status.ts +++ b/src/lib/insights/biomarker-status.ts @@ -34,6 +34,7 @@ import { runStatusCompletion } from "@/lib/insights/status-provider"; import { normalizeLocale, normalizeSummaryText, + finalizeStatusSummary, parseSummaryFromContent, persistStatusInsight, summarizeSeries, @@ -366,7 +367,22 @@ export async function generateBiomarkerStatus(args: { }); } - const text = normalizeSummaryText(parseSummaryFromContent(outcome.content)); + // Outbound safety screen — WITHHOLD policy for a background-generated card. + const screened = finalizeStatusSummary(outcome.content, locale); + if (!screened.ok) { + annotate({ + action: { name: "insights.status.outbound_blocked" }, + meta: { cacheAction, reason: screened.reason }, + }); + return returnTimeoutFallback({ + cacheAction, + reason: "screened", + userId: args.userId, + todayKey, + stubText: getNoKeyGeneralStatusText(locale), + }); + } + const text = screened.text; if (!text) { throw new Error(`Biomarker-status summary was empty for ${marker.id}`); } diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 76d2bfa79..ffc28f8a2 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -45,6 +45,7 @@ import { type SupportedLocale, normalizeLocale, normalizeSummaryText, + finalizeStatusSummary, parseSummaryFromContent, persistStatusInsight, round, @@ -733,9 +734,26 @@ export async function prepareBloodPressureStatusForUser( stubText: getNoKeyBloodPressureStatusText(locale, sysSignal), }), finalize: async (outcome): Promise => { - const summary = normalizeSummaryText( - parseSummaryFromContent(outcome.content), - ); + // Outbound safety screen. The only transform here used to be + // whitespace-normalisation, so a dose-change imperative or a fabricated + // clinical risk score persisted as the day's cached assessment. Policy + // for a background-generated card is WITHHOLD: serve the deterministic + // stub and persist no model text (see `finalizeStatusSummary`). + const screened = finalizeStatusSummary(outcome.content, locale); + if (!screened.ok) { + annotate({ + action: { name: "insights.status.outbound_blocked" }, + meta: { cacheAction, reason: screened.reason }, + }); + return returnTimeoutFallback({ + cacheAction, + reason: "screened", + userId: userId, + todayKey: todayKey, + stubText: getNoKeyBloodPressureStatusText(locale, sysSignal), + }); + } + const summary = screened.text; if (!summary) { throw new Error( "Blood-pressure-status summary was empty after normalization", diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 218cf91d9..19cbcbb34 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -24,6 +24,7 @@ import { type SupportedLocale, normalizeLocale, normalizeSummaryText, + finalizeStatusSummary, parseSummaryFromContent, persistStatusInsight, round, @@ -434,9 +435,26 @@ export async function prepareBmiStatusForUser( stubText: getNoKeyBmiStatusText(locale, bmiSignal), }), finalize: async (outcome): Promise => { - const summary = normalizeSummaryText( - parseSummaryFromContent(outcome.content), - ); + // Outbound safety screen. The only transform here used to be + // whitespace-normalisation, so a dose-change imperative or a fabricated + // clinical risk score persisted as the day's cached assessment. Policy + // for a background-generated card is WITHHOLD: serve the deterministic + // stub and persist no model text (see `finalizeStatusSummary`). + const screened = finalizeStatusSummary(outcome.content, locale); + if (!screened.ok) { + annotate({ + action: { name: "insights.status.outbound_blocked" }, + meta: { cacheAction, reason: screened.reason }, + }); + return returnTimeoutFallback({ + cacheAction, + reason: "screened", + userId: userId, + todayKey: todayKey, + stubText: getNoKeyBmiStatusText(locale, bmiSignal), + }); + } + const summary = screened.text; if (!summary) { throw new Error("Bmi-status summary was empty after normalization"); } diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index c68292892..7871078e7 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -25,6 +25,7 @@ import { import { normalizeLocale, normalizeSummaryText, + finalizeStatusSummary, parseSummaryFromContent, persistStatusInsight, round, @@ -507,9 +508,26 @@ export async function prepareGeneralStatusForUser( stubText: getNoKeyGeneralStatusText(locale), }), finalize: async (outcome): Promise => { - const summary = normalizeSummaryText( - parseSummaryFromContent(outcome.content), - ); + // Outbound safety screen. The only transform here used to be + // whitespace-normalisation, so a dose-change imperative or a fabricated + // clinical risk score persisted as the day's cached assessment. Policy + // for a background-generated card is WITHHOLD: serve the deterministic + // stub and persist no model text (see `finalizeStatusSummary`). + const screened = finalizeStatusSummary(outcome.content, locale); + if (!screened.ok) { + annotate({ + action: { name: "insights.status.outbound_blocked" }, + meta: { cacheAction, reason: screened.reason }, + }); + return returnTimeoutFallback({ + cacheAction, + reason: "screened", + userId: userId, + todayKey: todayKey, + stubText: getNoKeyGeneralStatusText(locale), + }); + } + const summary = screened.text; if (!summary) { throw new Error("General-status summary was empty after normalization"); } diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index d952f18bf..2684832d0 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -63,6 +63,7 @@ import { type SupportedLocale, normalizeLocale, normalizeSummaryText, + finalizeStatusSummary, parseSummaryFromContent, persistStatusInsight, summarizeSeries, @@ -537,7 +538,22 @@ export async function generateMetricStatus(args: { }); } - const text = normalizeSummaryText(parseSummaryFromContent(outcome.content)); + // Outbound safety screen — WITHHOLD policy for a background-generated card. + const screened = finalizeStatusSummary(outcome.content, locale); + if (!screened.ok) { + annotate({ + action: { name: "insights.status.outbound_blocked" }, + meta: { cacheAction, reason: screened.reason }, + }); + return returnTimeoutFallback({ + cacheAction, + reason: "screened", + userId: args.userId, + todayKey, + stubText: getNoKeyMetricStatusText(locale, signal), + }); + } + const text = screened.text; if (!text) { throw new Error(`Metric-status summary was empty for ${meta.id}`); } diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 0660fcf51..50ee8b838 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -43,6 +43,7 @@ import { type SupportedLocale, normalizeLocale, normalizeSummaryText, + finalizeStatusSummary, parseSummaryFromContent, persistStatusInsight, round, @@ -742,9 +743,26 @@ export async function prepareMoodStatusForUser( stubText: getNoKeyMoodStatusText(locale, moodSignal), }), finalize: async (outcome): Promise => { - const summary = normalizeSummaryText( - parseSummaryFromContent(outcome.content), - ); + // Outbound safety screen. The only transform here used to be + // whitespace-normalisation, so a dose-change imperative or a fabricated + // clinical risk score persisted as the day's cached assessment. Policy + // for a background-generated card is WITHHOLD: serve the deterministic + // stub and persist no model text (see `finalizeStatusSummary`). + const screened = finalizeStatusSummary(outcome.content, locale); + if (!screened.ok) { + annotate({ + action: { name: "insights.status.outbound_blocked" }, + meta: { cacheAction, reason: screened.reason }, + }); + return returnTimeoutFallback({ + cacheAction, + reason: "screened", + userId: userId, + todayKey: todayKey, + stubText: getNoKeyMoodStatusText(locale, moodSignal), + }); + } + const summary = screened.text; if (!summary) { throw new Error("Mood-status summary was empty after normalization"); } diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index a4cc2dd34..06fea02da 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -32,6 +32,7 @@ import { type SupportedLocale, normalizeLocale, normalizeSummaryText, + finalizeStatusSummary, parseSummaryFromContent, persistStatusInsight, round, @@ -478,9 +479,26 @@ export async function preparePulseStatusForUser( stubText: getNoKeyPulseStatusText(locale, pulseSignal), }), finalize: async (outcome): Promise => { - const summary = normalizeSummaryText( - parseSummaryFromContent(outcome.content), - ); + // Outbound safety screen. The only transform here used to be + // whitespace-normalisation, so a dose-change imperative or a fabricated + // clinical risk score persisted as the day's cached assessment. Policy + // for a background-generated card is WITHHOLD: serve the deterministic + // stub and persist no model text (see `finalizeStatusSummary`). + const screened = finalizeStatusSummary(outcome.content, locale); + if (!screened.ok) { + annotate({ + action: { name: "insights.status.outbound_blocked" }, + meta: { cacheAction, reason: screened.reason }, + }); + return returnTimeoutFallback({ + cacheAction, + reason: "screened", + userId: userId, + todayKey: todayKey, + stubText: getNoKeyPulseStatusText(locale, pulseSignal), + }); + } + const summary = screened.text; if (!summary) { throw new Error("Pulse-status summary was empty after normalization"); } diff --git a/src/lib/insights/status-shared.ts b/src/lib/insights/status-shared.ts index dc3191e41..454ead431 100644 --- a/src/lib/insights/status-shared.ts +++ b/src/lib/insights/status-shared.ts @@ -22,6 +22,11 @@ import { prisma } from "@/lib/db"; import { locales, type Locale } from "@/lib/i18n/config"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; +import { + screenModelOutput, + INSIGHTS_CONTRACTS, + type OutboundReason, +} from "@/lib/ai/safety/outbound-screen"; /** * The locales the assessment pipeline carries end-to-end — all six the UI @@ -218,3 +223,51 @@ export async function persistStatusInsight(args: { }); return created.createdAt.toISOString(); } + +/** + * Outcome of turning one status-card completion into a shippable assessment. + * `blocked` carries the tripped contract so the caller can annotate before it + * withholds. + */ +export type StatusSummaryOutcome = + { ok: true; text: string } | { ok: false; reason: OutboundReason }; + +/** + * The single finalize step every per-metric / biomarker / derived-score status + * generator runs on its completion: parse the `{ summary }` envelope, scrub + * chart tokens and whitespace, then SCREEN the prose against the insights + * safety contracts. + * + * Why the screen belongs here. Before this existed, the only transform between + * a provider and a persisted status assessment was `normalizeSummaryText`, + * which is whitespace-only — so a dose-change imperative or a fabricated + * clinical risk score written onto a metric card persisted as that day's cached + * assessment while the identical sentence in the Coach was caught and replaced. + * Every generator shares this chokepoint, so a card cannot be added that + * silently skips the screen. + * + * SURFACE POLICY — WITHHOLD. Unlike the Coach, a status card is generated in + * the background and read from cache; nobody is waiting on the turn. Persisting + * a refusal is wrong (it would pin "I can't advise on doses" to the user's + * Weight card for the day, which is noise on a surface they did not ask a + * question on) and persisting the screened prose is obviously wrong. So the + * caller serves its existing deterministic stub — the same signal-grounded line + * the card shows when no provider is configured — and persists no model text. + * The card degrades to a state the UI already renders honestly, and the short + * negative-cache window lets the next run try again. + * + * The contracts include `causal` here: GROUND RULE 12 declares surface + * `insights`, and these cards are that surface. + */ +export function finalizeStatusSummary( + content: string, + locale: SupportedLocale, +): StatusSummaryOutcome { + const text = normalizeSummaryText(parseSummaryFromContent(content)); + if (!text) return { ok: true, text: "" }; + const decision = screenModelOutput(text, locale, INSIGHTS_CONTRACTS); + if (decision.block && decision.reason) { + return { ok: false, reason: decision.reason }; + } + return { ok: true, text }; +} diff --git a/src/lib/insights/timeout-fallback.ts b/src/lib/insights/timeout-fallback.ts index 8a282dd18..72c3956e4 100644 --- a/src/lib/insights/timeout-fallback.ts +++ b/src/lib/insights/timeout-fallback.ts @@ -25,6 +25,19 @@ import { annotate } from "@/lib/logging/context"; * so a transient stall can't hide the real assessment for the day. */ +/** + * Why the deterministic fallback is being served instead of model prose. + * + * `screened` is the outbound-safety case: the model produced a usable string + * but it tripped a safety contract (see `@/lib/ai/safety/outbound-screen`). + * It rides the same envelope as a stall on purpose — the surface policy for a + * background-generated cached card is WITHHOLD, and this path already means + * exactly "serve the deterministic line, persist no model text". The 5-minute + * negative stub gives the next generation a fresh attempt rather than blanking + * the card until midnight. + */ +export type StatusFallbackReason = "timeout" | "error" | "screened"; + /** Short retry window after a provider stall before a re-enqueue is allowed. */ export const TIMEOUT_NEGATIVE_CACHE_MS = 5 * 60 * 1000; @@ -37,7 +50,7 @@ export async function persistTimeoutNegativeStub(args: { userId: string; cacheAction: string; todayKey: string; - reason: "timeout" | "error"; + reason: StatusFallbackReason; }): Promise { try { await prisma.auditLog.create({ @@ -78,7 +91,7 @@ export async function persistTimeoutNegativeStub(args: { */ export function returnTimeoutFallback(input: { cacheAction: string; - reason: "timeout" | "error"; + reason: StatusFallbackReason; stubText: string; userId?: string; todayKey?: string; diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index b44ef51c3..c7722b528 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -35,6 +35,7 @@ import { type SupportedLocale, normalizeLocale, normalizeSummaryText, + finalizeStatusSummary, parseSummaryFromContent, persistStatusInsight, round, @@ -638,9 +639,26 @@ export async function prepareWeightStatusForUser( stubText: getNoKeyWeightStatusText(locale, weightSignal), }), finalize: async (outcome): Promise => { - const summary = normalizeSummaryText( - parseSummaryFromContent(outcome.content), - ); + // Outbound safety screen. The only transform here used to be + // whitespace-normalisation, so a dose-change imperative or a fabricated + // clinical risk score persisted as the day's cached assessment. Policy + // for a background-generated card is WITHHOLD: serve the deterministic + // stub and persist no model text (see `finalizeStatusSummary`). + const screened = finalizeStatusSummary(outcome.content, locale); + if (!screened.ok) { + annotate({ + action: { name: "insights.status.outbound_blocked" }, + meta: { cacheAction, reason: screened.reason }, + }); + return returnTimeoutFallback({ + cacheAction, + reason: "screened", + userId: userId, + todayKey: todayKey, + stubText: getNoKeyWeightStatusText(locale, weightSignal), + }); + } + const summary = screened.text; if (!summary) { throw new Error("Weight-status summary was empty after normalization"); } diff --git a/src/lib/jobs/coach-nudge-ai.ts b/src/lib/jobs/coach-nudge-ai.ts index 6e45aa434..f5c6e82b0 100644 --- a/src/lib/jobs/coach-nudge-ai.ts +++ b/src/lib/jobs/coach-nudge-ai.ts @@ -138,7 +138,7 @@ Rephrase it warmly and naturally in your own words, following every rule. Return * unusable (empty / over-length / unsafe). Strips wrapping quotes and collapses * whitespace; rejects rather than mid-word-clamping an over-long reply. */ -function sanitiseAiBody(raw: string): string | null { +function sanitiseAiBody(raw: string, locale: Locale): string | null { let text = (raw ?? "").trim(); if (!text) return null; // Strip a single layer of wrapping quotes the model sometimes adds. @@ -151,7 +151,7 @@ function sanitiseAiBody(raw: string): string | null { if (text.length > COACH_NUDGE_AI_MAX_BODY_CHARS) return null; // Same outbound content fence the Coach reply path runs: a dose-prescription // or a fabricated risk score is rejected, falling back to the template. - if (screenCoachReply(text).block) return null; + if (screenCoachReply(text, locale).block) return null; return text; } @@ -234,7 +234,7 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => { result.cachedInputTokens ?? 0, ).catch(() => {}); - const body = sanitiseAiBody(result.content); + const body = sanitiseAiBody(result.content, params.locale); if (!body) { annotate({ action: { name: "coach.nudge.ai.fallback" } }); return null;