diff --git a/src/lib/ai/coach/__tests__/outbound-guard.test.ts b/src/lib/ai/coach/__tests__/outbound-guard.test.ts new file mode 100644 index 000000000..1c71d24fa --- /dev/null +++ b/src/lib/ai/coach/__tests__/outbound-guard.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; + +import { + screenCoachReply, + coachOutboundFallback, + COACH_OUTBOUND_DOSE_BLOCK_EN, + COACH_OUTBOUND_DOSE_BLOCK_DE, + COACH_OUTBOUND_RISK_BLOCK_EN, +} 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.", + ]; + for (const reply of blocked) { + it(`blocks: ${reply.slice(0, 32)}`, () => { + const d = screenCoachReply(reply); + 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.", + ]; + for (const reply of allowed) { + it(`allows factual mention: ${reply.slice(0, 32)}`, () => { + expect(screenCoachReply(reply).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.", + ]; + for (const reply of blocked) { + it(`blocks: ${reply.slice(0, 32)}`, () => { + const d = screenCoachReply(reply); + expect(d.block).toBe(true); + expect(d.reason).toBe("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, + ).toBe(false); + }); +}); + +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); + }); + it("passes an empty reply through (handled upstream)", () => { + expect(screenCoachReply("").block).toBe(false); + }); +}); + +describe("coachOutboundFallback", () => { + it("returns localised dose-block copy", () => { + expect(coachOutboundFallback("dose_prescription", "en")).toBe( + COACH_OUTBOUND_DOSE_BLOCK_EN, + ); + expect(coachOutboundFallback("dose_prescription", "de")).toBe( + COACH_OUTBOUND_DOSE_BLOCK_DE, + ); + }); + it("returns risk-block copy and rides EN for non-de locales", () => { + expect(coachOutboundFallback("risk_score", "en")).toBe( + COACH_OUTBOUND_RISK_BLOCK_EN, + ); + expect(coachOutboundFallback("risk_score", "fr")).toBe( + COACH_OUTBOUND_RISK_BLOCK_EN, + ); + }); +}); diff --git a/src/lib/ai/coach/outbound-guard.ts b/src/lib/ai/coach/outbound-guard.ts new file mode 100644 index 000000000..1470b3d59 --- /dev/null +++ b/src/lib/ai/coach/outbound-guard.ts @@ -0,0 +1,148 @@ +/** + * v1.18.10 (HIGH-2) — Coach OUTBOUND safety screen. + * + * `detectRefusal()` guards the INBOUND channel (the user's message) only. The + * model's reply was tokenised and streamed verbatim with no equivalent + * server-side check, so a dose-prescription ("step up to 2.4 mg") or a + * fabricated risk score in the assistant turn reached the user unfiltered. The + * GLP-1 dose-safety contract and grounding block are composed into the Coach + * system prompt, but those are instructions, not enforcement. + * + * 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. + * + * 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. + */ +import type { Locale } from "@/lib/i18n/config"; + +/** Why the outbound reply was blocked, for the Wide-Event annotation. */ +export type CoachOutboundReason = "dose_prescription" | "risk_score"; + +export interface CoachOutboundDecision { + /** True when the assembled reply must be replaced with the fallback. */ + block: boolean; + /** Which screen tripped — drives Wide-Event metadata. */ + reason: CoachOutboundReason | null; +} + +/** + * 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. + */ +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", + ), +]; + +/** + * 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 }; +} + +/** + * Safe fallback copy when the outbound screen trips. de/en carry a native + * body; the other UI locales ride the EN body (the Coach itself replies in + * de/en, so this is the consistent posture with the inbound refusal copy). + * Exposed as constants so server-only code (tests, logs) can pin the wording. + */ +export const COACH_OUTBOUND_DOSE_BLOCK_EN = + "I can't suggest a specific medication dose or a change to one — dose decisions belong with your prescribing clinician. I can help you read what your own data shows around your medication; ask me about that and I'll stay grounded in your numbers."; + +export const COACH_OUTBOUND_DOSE_BLOCK_DE = + "Eine konkrete Medikamenten-Dosis oder eine Dosisänderung kann ich nicht vorschlagen — solche Entscheidungen gehören zu deiner behandelnden Ärztin oder deinem Arzt. Gerne helfe ich dir, das zu lesen, was deine eigenen Daten rund um deine Medikation zeigen; frag mich danach, dann bleibe ich bei deinen Zahlen."; + +export const COACH_OUTBOUND_RISK_BLOCK_EN = + "I can't put a number on a clinical risk like that — it isn't something your tracked data lets me compute, and an invented figure would be misleading. I can walk you through what your own measurements are doing instead; ask me about a specific metric."; + +export const COACH_OUTBOUND_RISK_BLOCK_DE = + "Eine solche klinische Risikozahl kann ich nicht angeben — deine erfassten Daten lassen das nicht berechnen, und ein erfundener Wert wäre irreführend. Stattdessen kann ich dir zeigen, was deine eigenen Messwerte machen; frag mich nach einer konkreten Metrik."; + +/** Resolve the localised fallback copy for a tripped outbound screen. */ +export function coachOutboundFallback( + reason: CoachOutboundReason, + locale: Locale, +): string { + const de = locale === "de"; + if (reason === "dose_prescription") { + return de ? COACH_OUTBOUND_DOSE_BLOCK_DE : COACH_OUTBOUND_DOSE_BLOCK_EN; + } + return de ? COACH_OUTBOUND_RISK_BLOCK_DE : COACH_OUTBOUND_RISK_BLOCK_EN; +}