mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
42235076ac
commit
ca37fab63c
18 files changed
+686
-156
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user