Screen model output on the status-card surface and thread locale into the outbound guard

The outbound safety screen ran on the Coach only. A dose-change imperative or
a fabricated clinical risk score written onto a per-metric, biomarker or
derived-score status card persisted as that day's cached assessment, while the
identical sentence in the Coach was caught and replaced -- the only transform
between provider and persistence was whitespace normalisation.

Move the pattern banks into a shared screen that takes the reader's locale.
The old signature took no locale, so the dose-refusal and risk-score contracts
had no enforcement for fr/es/it/pl even though the ground rules ship in all six
contract files. Banks now cover all six locales and each screen runs the
reader's bank plus English, since a provider often answers in English
regardless of the language directive.

Status cards withhold on a trip: serve the deterministic stub and persist no
model text. Nobody waits on a background-generated card, and persisting a
refusal would pin it to the card for the day.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 03:36:42 +02:00
1 parent 42235076ac
commit ca37fab63c
18 files changed
+686 -156

No files matched your search

+1 -1
View File
@@ -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({
@@ -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);
});
});
@@ -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);
});
});
+1 -1
View File
@@ -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");
}
+39 -100
View File
@@ -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: <N>% (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,
};
}
/**
+376
View File
@@ -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<Locale, 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",
),
// 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<Locale, readonly RegExp[]> = {
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<Locale, readonly RegExp[]> = {
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<OutboundContract, Record<Locale, readonly RegExp[]>> = {
dose: DOSE_PATTERNS,
risk: RISK_PATTERNS,
causal: CAUSAL_PATTERNS,
};
const REASON_FOR_CONTRACT: Record<OutboundContract, OutboundReason> = {
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 };
}
+1 -1
View File
@@ -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({
+17 -1
View File
@@ -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}`);
}
+21 -3
View File
@@ -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<StatusCardResult> => {
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",
+21 -3
View File
@@ -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<StatusCardResult> => {
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");
}
+21 -3
View File
@@ -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<StatusCardResult> => {
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");
}
+17 -1
View File
@@ -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}`);
}
+21 -3
View File
@@ -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<StatusCardResult> => {
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");
}
+21 -3
View File
@@ -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<StatusCardResult> => {
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");
}
+53
View File
@@ -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 };
}
+15 -2
View File
@@ -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<void> {
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;
+21 -3
View File
@@ -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<StatusCardResult> => {
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");
}
+3 -3
View File
@@ -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;