From 3354631a8530adf920ebb5cdf5263ee4dacd2c74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 18:20:02 +0200 Subject: [PATCH] fix(ai): screen numberless halving and dose changes, and hold continuations to the plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outbound screen let a halving with no number through in every language but Dutch ("Reduce your dose by half.", "Reduza a dose pela metade.", "Tome meia dose."), let a bare change imperative through ("Zmniejsz dawkę.", "복용량을 늘리세요.") and did not know the Polish spelling of the generic stems, so "Odstaw ramipryl." passed. Each language's bank now carries a halving and fraction shape and a bare change shape on a medication noun, and the stems include the Polish -pryl, -statyn and -glutyd. The continuation exemption trusted "as before" on any surface that passed no schedule, so "Take 40 mg as before." went out to people who had never taken 40 mg. The screen now takes the dose plan (each active medication with its dose) instead of a bare list of numbers, and exempts a stated amount only when it is the planned dose of the medication the sentence names. Without a plan no stated amount is exempt. --- .../insights/chat/__tests__/dialog-harness.ts | 2 +- .../insights/chat/__tests__/golden-harness.ts | 4 +- .../chat/__tests__/golden-transcripts.test.ts | 2 +- .../__tests__/route-guard-activation.test.ts | 2 +- .../chat/__tests__/route-guard-ledger.test.ts | 2 +- .../ai/coach/__tests__/outbound-guard.test.ts | 4 +- src/lib/ai/coach/activity/screen.ts | 13 +- .../memory/__tests__/propose-plan.test.ts | 4 +- src/lib/ai/coach/memory/contract.ts | 5 +- src/lib/ai/coach/memory/propose-plan.ts | 23 ++-- src/lib/ai/coach/outbound-guard.ts | 14 +- src/lib/ai/coach/tools/dialog-tools.ts | 5 +- src/lib/ai/coach/turn/context.ts | 20 ++- src/lib/ai/coach/turn/model.ts | 4 +- src/lib/ai/coach/turn/reply-guards.ts | 2 +- .../__tests__/locale-bank-liveness.test.ts | 26 ++++ .../__tests__/outbound-screen-guard2.test.ts | 122 ++++++++++++++--- .../outbound-screen-named-meds.test.ts | 5 +- .../safety/__tests__/outbound-screen.test.ts | 123 +++++++++++++++++- src/lib/ai/safety/outbound-screen.ts | 85 ++++++------ .../ai/safety/outbound-screen/locales/de.ts | 23 ++++ .../ai/safety/outbound-screen/locales/en.ts | 18 +++ .../ai/safety/outbound-screen/locales/es.ts | 16 +++ .../ai/safety/outbound-screen/locales/fr.ts | 16 +++ .../ai/safety/outbound-screen/locales/it.ts | 16 +++ .../ai/safety/outbound-screen/locales/ko.ts | 7 + .../ai/safety/outbound-screen/locales/nl.ts | 16 +++ .../ai/safety/outbound-screen/locales/pl.ts | 17 +++ .../safety/outbound-screen/locales/pt-br.ts | 16 +++ src/lib/ai/safety/outbound-screen/shared.ts | 5 +- src/lib/medications/scheduled-doses.ts | 32 +++-- 31 files changed, 526 insertions(+), 123 deletions(-) diff --git a/src/app/api/insights/chat/__tests__/dialog-harness.ts b/src/app/api/insights/chat/__tests__/dialog-harness.ts index b1f2cc60c..dff956574 100644 --- a/src/app/api/insights/chat/__tests__/dialog-harness.ts +++ b/src/app/api/insights/chat/__tests__/dialog-harness.ts @@ -543,7 +543,7 @@ export const modules = { }), aboutMe: () => ({ getSelfContextTextForUser: async () => null }), snapshot: () => ({ buildCoachSnapshot: async () => snapshotFor() }), - scheduledDoses: () => ({ getScheduledDoseValues: async () => [] }), + scheduledDoses: () => ({ getScheduledDosePlan: async () => [] }), workoutEvidence: () => ({ buildWorkoutEvidenceSection: async () => null, }), diff --git a/src/app/api/insights/chat/__tests__/golden-harness.ts b/src/app/api/insights/chat/__tests__/golden-harness.ts index 867f12e45..64324be38 100644 --- a/src/app/api/insights/chat/__tests__/golden-harness.ts +++ b/src/app/api/insights/chat/__tests__/golden-harness.ts @@ -135,7 +135,7 @@ export const m = { getCoachSystemPrompt: vi.fn(), getSelfContextTextForUser: vi.fn(), buildCoachSnapshot: vi.fn(), - getScheduledDoseValues: vi.fn(), + getScheduledDosePlan: vi.fn(), buildWorkoutEvidenceSection: vi.fn(), buildCoachDataInventory: vi.fn(), renderDataInventory: vi.fn(), @@ -278,7 +278,7 @@ export function resetGolden(): void { m.getCoachSystemPrompt.mockReturnValue("SYSTEM"); m.getSelfContextTextForUser.mockResolvedValue(null); m.buildCoachSnapshot.mockImplementation(async () => defaultSnapshot()); - m.getScheduledDoseValues.mockResolvedValue([]); + m.getScheduledDosePlan.mockResolvedValue([]); m.buildWorkoutEvidenceSection.mockResolvedValue({ sportType: "running", durationSec: 2400, diff --git a/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts b/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts index 821db4dee..5bdc6899b 100644 --- a/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts +++ b/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts @@ -122,7 +122,7 @@ vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot: h.m.buildCoachSnapshot, })); vi.mock("@/lib/medications/scheduled-doses", () => ({ - getScheduledDoseValues: h.m.getScheduledDoseValues, + getScheduledDosePlan: h.m.getScheduledDosePlan, })); vi.mock("@/lib/ai/coach/workout-evidence-builder", () => ({ buildWorkoutEvidenceSection: h.m.buildWorkoutEvidenceSection, diff --git a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts index 182861065..18b4e8175 100644 --- a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts @@ -128,7 +128,7 @@ vi.mock("@/lib/ai/coach/facts", () => ({ // Guard II — the schedule read. Empty here; the schedule-gated dose rule is // unit-tested in the outbound-screen suite. vi.mock("@/lib/medications/scheduled-doses", () => ({ - getScheduledDoseValues: vi.fn(async () => []), + getScheduledDosePlan: vi.fn(async () => []), })); const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({ diff --git a/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts b/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts index c692f5205..7b2f1c9c1 100644 --- a/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts @@ -121,7 +121,7 @@ vi.mock("@/lib/ai/coach/facts", () => ({ // Guard II — the schedule read. Empty here; the schedule-gated dose rule is // unit-tested in the outbound-screen suite. vi.mock("@/lib/medications/scheduled-doses", () => ({ - getScheduledDoseValues: vi.fn(async () => []), + getScheduledDosePlan: vi.fn(async () => []), })); const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({ diff --git a/src/lib/ai/coach/__tests__/outbound-guard.test.ts b/src/lib/ai/coach/__tests__/outbound-guard.test.ts index bb91f4e35..c51ffb535 100644 --- a/src/lib/ai/coach/__tests__/outbound-guard.test.ts +++ b/src/lib/ai/coach/__tests__/outbound-guard.test.ts @@ -130,7 +130,7 @@ describe("screenCoachReply — a drug-named dose change", () => { const d = screenCoachReply( "Increase your metformin to 2000 mg daily.", "en", - [1000], + [{ name: "Metformin", dose: 1000 }], ); expect(d.block).toBe(true); expect(d.reason).toBe("dose_prescription"); @@ -140,7 +140,7 @@ describe("screenCoachReply — a drug-named dose change", () => { const d = screenCoachReply( "Your log shows you take 1000 mg twice daily.", "en", - [1000], + [{ name: "Metformin", dose: 1000 }], ); expect(d.block).toBe(false); }); diff --git a/src/lib/ai/coach/activity/screen.ts b/src/lib/ai/coach/activity/screen.ts index 12c084009..c9e2a2a66 100644 --- a/src/lib/ai/coach/activity/screen.ts +++ b/src/lib/ai/coach/activity/screen.ts @@ -23,6 +23,7 @@ import { buildGroundingLedger } from "@/lib/ai/coach/grounding-ledger"; import { findUnverifiedCoachNumbersInLedger } from "@/lib/ai/coach/coach-prose-grounding"; import { ACTIVITY_TEXT_MAX_CHARS, ACTIVITY_TITLE_MAX_CHARS } from "./contract"; +import type { ScheduledDose } from "@/lib/medications/scheduled-doses"; export interface ActivityScreenContext { locale: Locale; @@ -34,8 +35,12 @@ export interface ActivityScreenContext { figures: () => readonly unknown[]; /** The person's own message: figures they stated may be repeated. */ userMessage?: string; - /** Scheduled doses, which the dose screen lets through. */ - scheduleDoses?: readonly number[]; + /** + * The active medications with their doses: the dose screen trusts a + * continuation only at a planned dose, and the ledger counts the doses as + * grounded figures. + */ + dosePlan?: readonly ScheduledDose[]; /** The schedule's medication names, which count as medication nouns. */ medicationNames?: readonly string[]; } @@ -61,7 +66,7 @@ function cut(text: string, max: number): string { function passes(text: string, ctx: ActivityScreenContext): boolean { try { if ( - screenCoachReply(text, ctx.locale, ctx.scheduleDoses, ctx.medicationNames) + screenCoachReply(text, ctx.locale, ctx.dosePlan, ctx.medicationNames) .block ) { return false; @@ -73,7 +78,7 @@ function passes(text: string, ctx: ActivityScreenContext): boolean { const ledger = buildGroundingLedger({ toolPayloads: ctx.figures(), priorUserMessages: ctx.userMessage ? [ctx.userMessage] : [], - scheduleDoses: ctx.scheduleDoses, + scheduleDoses: ctx.dosePlan?.map((entry) => entry.dose), }); const unverified = findUnverifiedCoachNumbersInLedger( text, diff --git a/src/lib/ai/coach/memory/__tests__/propose-plan.test.ts b/src/lib/ai/coach/memory/__tests__/propose-plan.test.ts index 02b0fdd93..ff6bad6b9 100644 --- a/src/lib/ai/coach/memory/__tests__/propose-plan.test.ts +++ b/src/lib/ai/coach/memory/__tests__/propose-plan.test.ts @@ -13,11 +13,11 @@ vi.mock("../../bytes-codec", () => ({ })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); const doses = vi.hoisted(() => ({ - values: [] as number[], + values: [] as { name: string; dose: number }[], names: [] as string[], })); vi.mock("@/lib/medications/scheduled-doses", () => ({ - getScheduledDoseValues: vi.fn(async () => doses.values), + getScheduledDosePlan: vi.fn(async () => doses.values), })); vi.mock("@/lib/medications/medication-names", () => ({ getMedicationNames: vi.fn(async () => doses.names), diff --git a/src/lib/ai/coach/memory/contract.ts b/src/lib/ai/coach/memory/contract.ts index 802eb0cea..03509d3ad 100644 --- a/src/lib/ai/coach/memory/contract.ts +++ b/src/lib/ai/coach/memory/contract.ts @@ -33,6 +33,7 @@ import type { CoachMemoryNote, CoachPlanProposal, } from "@/lib/ai/coach/types"; +import type { ScheduledDose } from "@/lib/medications/scheduled-doses"; export * from "./shared"; @@ -149,10 +150,10 @@ export interface ProposePlanArgs { /** The person's locale, which the outbound screen reads the plan text in. */ locale: Locale; /** - * The turn's scheduled doses and medication names, which the screen reads + * The turn's dose plan and medication names, which the screen reads * the plan text against; loaded for the person when absent. */ - scheduleDoses?: readonly number[]; + dosePlan?: readonly ScheduledDose[]; medicationNames?: readonly string[]; call: ProposePlanCall; } diff --git a/src/lib/ai/coach/memory/propose-plan.ts b/src/lib/ai/coach/memory/propose-plan.ts index 0487e58fc..cd467aef2 100644 --- a/src/lib/ai/coach/memory/propose-plan.ts +++ b/src/lib/ai/coach/memory/propose-plan.ts @@ -28,7 +28,10 @@ import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import type { Locale } from "@/lib/i18n/config"; import { getMedicationNames } from "@/lib/medications/medication-names"; -import { getScheduledDoseValues } from "@/lib/medications/scheduled-doses"; +import { + getScheduledDosePlan, + type ScheduledDose, +} from "@/lib/medications/scheduled-doses"; import { screenCoachReply } from "../outbound-guard"; import { detectRefusal } from "../refusal"; @@ -87,19 +90,15 @@ export function planTextPasses( texts: readonly string[], locale: Locale, screen: { - scheduleDoses: readonly number[]; + dosePlan: readonly ScheduledDose[]; medicationNames: readonly string[]; }, ): boolean { try { for (const text of [...texts, texts.join(" ")]) { if ( - screenCoachReply( - text, - locale, - screen.scheduleDoses, - screen.medicationNames, - ).block + screenCoachReply(text, locale, screen.dosePlan, screen.medicationNames) + .block ) { return false; } @@ -154,9 +153,9 @@ async function runPropose(args: ProposePlanArgs): Promise { ) { return { kind: "declined", reason: "invalid" }; } - const [scheduleDoses, medicationNames] = await Promise.all([ - args.scheduleDoses ?? - getScheduledDoseValues(userId).catch((): number[] => []), + const [dosePlan, medicationNames] = await Promise.all([ + args.dosePlan ?? + getScheduledDosePlan(userId).catch((): ScheduledDose[] => []), args.medicationNames ?? getMedicationNames(userId).catch((): string[] => []), ]); @@ -164,7 +163,7 @@ async function runPropose(args: ProposePlanArgs): Promise { !planTextPasses( [ifCue, thenAction, ...(target ? [target] : [])], args.locale, - { scheduleDoses, medicationNames }, + { dosePlan, medicationNames }, ) ) { return { kind: "declined", reason: "unsafe" }; diff --git a/src/lib/ai/coach/outbound-guard.ts b/src/lib/ai/coach/outbound-guard.ts index 53d4d1df1..b551eb939 100644 --- a/src/lib/ai/coach/outbound-guard.ts +++ b/src/lib/ai/coach/outbound-guard.ts @@ -31,6 +31,7 @@ import { CONVERSATIONAL_CONTRACTS, type OutboundReason, } from "@/lib/ai/safety/outbound-screen"; +import type { ScheduledDose } from "@/lib/medications/scheduled-doses"; /** * Why the outbound reply was blocked, for the Wide-Event annotation. The Coach @@ -53,11 +54,10 @@ export interface CoachOutboundDecision { * Screen an assembled assistant reply against the conversational contracts in * the reader's locale. * - * v1.32.9 (Coach Guard II / G3) — `scheduleDoses` optionally gates the dose - * continuation exemption on the user's actual schedule: a "keep taking your - * N mg" is trusted only when N is a dose the user is on. Omitted by the - * non-Coach callers (nudge / reaction-line), which keep the phrase-anchored - * Guard I behaviour. + * `dosePlan` gates the dose continuation exemption on the user's actual + * plan: a "keep taking your N mg" is trusted only when N is the dose of the + * medication the sentence names. Without it (the nudge and reaction-line + * callers) no stated amount is exempt. * * `medicationNames` optionally makes the person's own medication names count * as medication nouns in the dose-change class, so "skip the Eliquis" or @@ -68,11 +68,11 @@ export interface CoachOutboundDecision { export function screenCoachReply( reply: string, locale: Locale, - scheduleDoses?: readonly number[], + dosePlan?: readonly ScheduledDose[], medicationNames?: readonly string[], ): CoachOutboundDecision { const decision = screenModelOutput(reply, locale, CONVERSATIONAL_CONTRACTS, { - scheduleDoses, + dosePlan, medicationNames, }); return { diff --git a/src/lib/ai/coach/tools/dialog-tools.ts b/src/lib/ai/coach/tools/dialog-tools.ts index 9ff3968f3..f2e6c389c 100644 --- a/src/lib/ai/coach/tools/dialog-tools.ts +++ b/src/lib/ai/coach/tools/dialog-tools.ts @@ -44,6 +44,7 @@ import { loadClarifyRecordChoices, } from "@/lib/ai/coach/clarify"; import type { InventoryEntry } from "./inventory"; +import type { ScheduledDose } from "@/lib/medications/scheduled-doses"; export const ASK_CLARIFICATION_TOOL_NAME = "ask_clarification"; export const REMEMBER_FACT_TOOL_NAME = "remember_fact"; @@ -210,7 +211,7 @@ export interface DialogToolContext { * The person's scheduled doses and medication names, which the outbound * screen reads a proposed plan's text against, as it reads the answer. */ - scheduleDoses?: readonly number[]; + dosePlan?: readonly ScheduledDose[]; medicationNames?: readonly string[]; /** * A window was set for this conversation (the header pill, a deep link): @@ -351,7 +352,7 @@ export async function runDialogTool(args: { userId: ctx.userId, conversationId: ctx.conversationId, locale: ctx.locale, - ...(ctx.scheduleDoses ? { scheduleDoses: ctx.scheduleDoses } : {}), + ...(ctx.dosePlan ? { dosePlan: ctx.dosePlan } : {}), ...(ctx.medicationNames ? { medicationNames: ctx.medicationNames } : {}), diff --git a/src/lib/ai/coach/turn/context.ts b/src/lib/ai/coach/turn/context.ts index 048edc367..70155485e 100644 --- a/src/lib/ai/coach/turn/context.ts +++ b/src/lib/ai/coach/turn/context.ts @@ -33,7 +33,10 @@ import { type CoachTurnContext, } from "@/lib/ai/coach/chat-request-builder"; import { buildWorkoutEvidenceSection } from "@/lib/ai/coach/workout-evidence-builder"; -import { getScheduledDoseValues } from "@/lib/medications/scheduled-doses"; +import { + getScheduledDosePlan, + type ScheduledDose, +} from "@/lib/medications/scheduled-doses"; import { getMedicationNames } from "@/lib/medications/medication-names"; import { buildRememberAddendum } from "@/lib/ai/coach/reminders"; import { buildSuggestActionAddendum } from "@/lib/ai/coach/suggest-action"; @@ -65,7 +68,10 @@ export interface TurnContext { effectiveScope: CoachScope | undefined; snapshot: CoachSnapshotResult; aboutMe: string | null; - scheduleDoses: Awaited>; + /** The active doses' magnitudes, the Grounding Ledger's `schedule` source. */ + scheduleDoses: number[]; + /** The active medications with their doses, which the dose screen reads. */ + dosePlan: ScheduledDose[]; /** The schedule's medication names, which the outbound dose screen reads. */ medicationNames: string[]; turnContext: CoachTurnContext; @@ -137,9 +143,12 @@ export async function assembleTurnContext(args: { const aboutMe = await getSelfContextTextForUser(userId, locale); // v1.32.9 (Coach Guard II / G3) — the user's active medication doses, for the // Grounding Ledger's `schedule` source AND the schedule-gated dose - // continuation exemption. Fail-open: a read failure yields an empty set, which - // keeps the Guard I phrase-anchored dose behaviour. - const scheduleDoses = await getScheduledDoseValues(userId).catch(() => []); + // continuation exemption. A read failure yields an empty plan, under which + // the screen trusts no stated amount (fail closed). + const dosePlan = await getScheduledDosePlan(userId).catch( + (): ScheduledDose[] => [], + ); + const scheduleDoses = [...new Set(dosePlan.map((entry) => entry.dose))]; const medicationNames = await getMedicationNames(userId).catch(() => []); // v1.22 — the canonical system-prompt module is owned elsewhere, so // the two memory/action clauses are appended at assembly time here: one @@ -242,6 +251,7 @@ export async function assembleTurnContext(args: { snapshot, aboutMe, scheduleDoses, + dosePlan, medicationNames, turnContext, workoutEvidence, diff --git a/src/lib/ai/coach/turn/model.ts b/src/lib/ai/coach/turn/model.ts index 5f0b8d6eb..6bdd561b6 100644 --- a/src/lib/ai/coach/turn/model.ts +++ b/src/lib/ai/coach/turn/model.ts @@ -307,7 +307,7 @@ export async function runTurnModel(args: { locale, figures: () => figures, userMessage: args.message, - scheduleDoses: ctx.scheduleDoses, + dosePlan: ctx.dosePlan, medicationNames: ctx.medicationNames, }, }); @@ -466,7 +466,7 @@ export async function runTurnModel(args: { ...(args.userMessageId ? { userMessageId: args.userMessageId } : {}), inventory: inventory.entries, conversationWindowSet: args.conversationWindowSet, - scheduleDoses: ctx.scheduleDoses, + dosePlan: ctx.dosePlan, medicationNames: ctx.medicationNames, }, checkpointScreen: { userMessage: args.message }, diff --git a/src/lib/ai/coach/turn/reply-guards.ts b/src/lib/ai/coach/turn/reply-guards.ts index 1fb4a2dc4..323cf5829 100644 --- a/src/lib/ai/coach/turn/reply-guards.ts +++ b/src/lib/ai/coach/turn/reply-guards.ts @@ -204,7 +204,7 @@ export async function guardReply(args: { const outbound = screenCoachReply( replyText, locale, - ctx.scheduleDoses, + ctx.dosePlan, ctx.medicationNames, ); if (outbound.block && outbound.reason) { diff --git a/src/lib/ai/safety/__tests__/locale-bank-liveness.test.ts b/src/lib/ai/safety/__tests__/locale-bank-liveness.test.ts index 9aefc11ae..fe10e420c 100644 --- a/src/lib/ai/safety/__tests__/locale-bank-liveness.test.ts +++ b/src/lib/ai/safety/__tests__/locale-bank-liveness.test.ts @@ -42,6 +42,9 @@ const BANKS: Record = { const SENTENCES: Record = { en: [ + "Reduce your dose by half.", + "Take half your usual dose.", + "Reduce your dose.", "Increase your metformin to 2000 mg daily.", "You might want to consider increasing your insulin to 20 units.", "Take 2000 mg daily.", @@ -81,6 +84,10 @@ const SENTENCES: Record = { "Keep taking your 7.5 mg as prescribed.", ], de: [ + "Reduzieren Sie die Dosis um die Hälfte.", + "Nehmen Sie nur die halbe Dosis.", + "Teilen Sie die Tablette in zwei Hälften.", + "Reduzieren Sie die Dosis.", "Erhöhe dein Metformin auf 2000 mg.", "Nimm 2 Tabletten am Abend.", "Pausiere dein Insulin.", @@ -120,6 +127,9 @@ const SENTENCES: Record = { "Nimm dein Metformin weiter wie verordnet.", ], fr: [ + "Réduisez la dose de moitié.", + "Prenez une demi-dose.", + "Diminuez votre dose.", "Augmentez votre metformine à 2000 mg.", "Prenez 2 comprimés le soir.", "Sautez votre dose du soir.", @@ -151,6 +161,9 @@ const SENTENCES: Record = { "Continuez à prendre votre metformine.", ], es: [ + "Reduzca la dosis a la mitad.", + "Tome media dosis.", + "Reduzca la dosis.", "Aumente su metformina a 2000 mg.", "Tome 2 comprimidos por la noche.", "Omita la dosis de la noche.", @@ -181,6 +194,9 @@ const SENTENCES: Record = { "Siga tomando su metformina.", ], it: [ + "Riduca la dose della metà.", + "Prenda mezza dose.", + "Riduca la dose.", "Aumenti la metformina a 2000 mg.", "Prenda 2 compresse la sera.", "Salti la dose serale.", @@ -210,6 +226,9 @@ const SENTENCES: Record = { "Continui a prendere la metformina.", ], pl: [ + "Zmniejsz dawkę o połowę.", + "Weź połowę dawki.", + "Zmniejsz dawkę.", "Zwiększ metforminę do 2000 mg.", "Proszę przyjąć 2 tabletki wieczorem.", "Pomiń wieczorną dawkę.", @@ -239,6 +258,9 @@ const SENTENCES: Record = { "Kontynuuj przyjmowanie leku.", ], nl: [ + "Verlaag je dosis met de helft.", + "Neem een halve dosis.", + "Verlaag je dosis.", "Verhoog je metformine naar 2000 mg per dag.", "Bouw je dosis af naar 5 mg.", "Zet je insuline op 20 eenheden.", @@ -282,6 +304,7 @@ const SENTENCES: Record = { "Blijf je metformine nemen.", ], ko: [ + "복용량을 늘리세요.", "메트포르민을 2000mg으로 증량하세요.", "저녁 인슐린 용량을 건너뛰세요.", "메트포르민 복용을 중단하세요.", @@ -313,6 +336,9 @@ const SENTENCES: Record = { "처방대로 복용하세요.", ], "pt-BR": [ + "Reduza a dose pela metade.", + "Tome meia dose.", + "Reduza a dose.", "Aumente sua metformina para 2000 mg por dia.", "Tome 2 comprimidos à noite.", "Pule a dose da noite.", diff --git a/src/lib/ai/safety/__tests__/outbound-screen-guard2.test.ts b/src/lib/ai/safety/__tests__/outbound-screen-guard2.test.ts index 041a4cd21..d0feee9f5 100644 --- a/src/lib/ai/safety/__tests__/outbound-screen-guard2.test.ts +++ b/src/lib/ai/safety/__tests__/outbound-screen-guard2.test.ts @@ -1,6 +1,6 @@ /** - * v1.32.9 (Coach Guard II / G3) — the schedule-gated dose continuation - * exemption (M6/D7 end state) and the fr/es/it/pl risk-bank parity. + * The plan-gated dose continuation exemption and the fr/es/it/pl risk-bank + * parity. */ import { describe, it, expect } from "vitest"; @@ -9,56 +9,138 @@ import { CONVERSATIONAL_CONTRACTS, } from "@/lib/ai/safety/outbound-screen"; -describe("screenModelOutput — dose exemption gated on the schedule", () => { - it("passes a continuation dose that MATCHES the user's schedule", () => { +describe("screenModelOutput — dose exemption gated on the plan", () => { + const PLAN = [ + { name: "Semaglutide", dose: 7.5 }, + { name: "Ramipril", dose: 5 }, + { name: "Atorvastatin", dose: 40 }, + ]; + + it("passes a continuation dose that MATCHES the user's plan", () => { const d = screenModelOutput( "You should keep taking your prescribed 7.5 mg exactly as directed.", "en", CONVERSATIONAL_CONTRACTS, - { scheduleDoses: [7.5] }, + { dosePlan: PLAN }, ); expect(d.block).toBe(false); }); - it("BLOCKS a continuation dose that does NOT match the schedule (closes the M6 window)", () => { - // The schedule says 7.5; "keep taking your 15 mg" is a wrong maintenance + it("BLOCKS a continuation dose that does NOT match the plan (closes the M6 window)", () => { + // The plan says 7.5; "keep taking your 15 mg" is a wrong maintenance // dose and must not ride the continuation exemption. const d = screenModelOutput( "You should keep taking your 15 mg as prescribed.", "en", CONVERSATIONAL_CONTRACTS, - { scheduleDoses: [7.5] }, + { dosePlan: PLAN }, ); expect(d.block).toBe(true); expect(d.reason).toBe("dose_prescription"); }); - it("keeps the Guard I phrase-anchored exemption when NO schedule is supplied", () => { - const d = screenModelOutput( - "You should keep taking your 7.5 mg as prescribed.", - "en", - CONVERSATIONAL_CONTRACTS, - ); - expect(d.block).toBe(false); + it("BLOCKS a stated continuation dose when no plan is supplied", () => { + // "As before" is the model's claim. Without the plan nothing confirms it. + for (const [text, locale] of [ + ["You should keep taking your 7.5 mg as prescribed.", "en"], + ["Take 40 mg as before.", "en"], + ["Tome 40 mg como antes.", "pt-BR"], + ["Nimm 40 mg wie bisher.", "de"], + ["Neem 40 mg zoals eerder.", "nl"], + ] as const) { + expect( + screenModelOutput(text, locale, CONVERSATIONAL_CONTRACTS).block, + text, + ).toBe(true); + expect( + screenModelOutput(text, locale, CONVERSATIONAL_CONTRACTS, { + dosePlan: [], + }).block, + text, + ).toBe(true); + } }); - it("still blocks a titration even when the change lands on a scheduled value", () => { - // A change stem voids the exemption regardless of schedule membership. + it("passes the same sentences when the plan holds that dose", () => { + for (const [text, locale] of [ + ["Take 40 mg as before.", "en"], + ["Tome 40 mg como antes.", "pt-BR"], + ["Nimm 40 mg wie bisher.", "de"], + ["Neem 40 mg zoals eerder.", "nl"], + ] as const) { + expect( + screenModelOutput(text, locale, CONVERSATIONAL_CONTRACTS, { + dosePlan: PLAN, + }).block, + text, + ).toBe(false); + } + }); + + it("holds a named medication to its own dose, not to any dose on the plan", () => { + // 40 mg is on the plan, but for atorvastatin, not ramipril. + expect( + screenModelOutput( + "Take 40 mg of ramipril as before.", + "en", + CONVERSATIONAL_CONTRACTS, + { dosePlan: PLAN }, + ).block, + ).toBe(true); + expect( + screenModelOutput( + "Tome 40 mg de ramipril como antes.", + "pt-BR", + CONVERSATIONAL_CONTRACTS, + { dosePlan: PLAN }, + ).block, + ).toBe(true); + expect( + screenModelOutput( + "Take 40 mg of atorvastatin as before.", + "en", + CONVERSATIONAL_CONTRACTS, + { dosePlan: PLAN }, + ).block, + ).toBe(false); + expect( + screenModelOutput( + "Take 5 mg of ramipril as before.", + "en", + CONVERSATIONAL_CONTRACTS, + { dosePlan: PLAN }, + ).block, + ).toBe(false); + }); + + it("never trusts a continuation for a drug outside the plan", () => { + expect( + screenModelOutput( + "Take 40 mg of simvastatin as before.", + "en", + CONVERSATIONAL_CONTRACTS, + { dosePlan: PLAN }, + ).block, + ).toBe(true); + }); + + it("still blocks a titration even when the change lands on a planned value", () => { + // A change stem voids the exemption regardless of plan membership. const d = screenModelOutput( "You should continue tapering to 7.5 mg next week.", "en", CONVERSATIONAL_CONTRACTS, - { scheduleDoses: [7.5] }, + { dosePlan: PLAN }, ); expect(d.block).toBe(true); }); - it("matches a comma-decimal scheduled dose (DE reader)", () => { + it("matches a comma-decimal planned dose (DE reader)", () => { const d = screenModelOutput( "Nimm weiterhin deine 7,5 mg wie verordnet.", "de", CONVERSATIONAL_CONTRACTS, - { scheduleDoses: [7.5] }, + { dosePlan: PLAN }, ); expect(d.block).toBe(false); }); diff --git a/src/lib/ai/safety/__tests__/outbound-screen-named-meds.test.ts b/src/lib/ai/safety/__tests__/outbound-screen-named-meds.test.ts index 609fa81fb..1941501e6 100644 --- a/src/lib/ai/safety/__tests__/outbound-screen-named-meds.test.ts +++ b/src/lib/ai/safety/__tests__/outbound-screen-named-meds.test.ts @@ -318,13 +318,14 @@ describe("screenModelOutput — a brand name needs the schedule", () => { }); describe("screenCoachReply — passes the schedule's names through", () => { + const PLAN = [{ name: "Eliquis", dose: 5 }]; it("blocks a named-drug skip when the names are supplied", () => { expect( - screenCoachReply("Skip the Eliquis tomorrow.", "en", [5], ["Eliquis"]) + screenCoachReply("Skip the Eliquis tomorrow.", "en", PLAN, ["Eliquis"]) .block, ).toBe(true); expect( - screenCoachReply("Skip the Eliquis tomorrow.", "en", [5]).block, + screenCoachReply("Skip the Eliquis tomorrow.", "en", PLAN).block, ).toBe(false); }); }); diff --git a/src/lib/ai/safety/__tests__/outbound-screen.test.ts b/src/lib/ai/safety/__tests__/outbound-screen.test.ts index 6c80e8528..c0414bc10 100644 --- a/src/lib/ai/safety/__tests__/outbound-screen.test.ts +++ b/src/lib/ai/safety/__tests__/outbound-screen.test.ts @@ -286,14 +286,26 @@ describe("screenModelOutput — risk narrowing (D1/D4/M4)", () => { // ── D7 — dose continuation-exclusion ───────────────────────────────────── describe("screenModelOutput — dose continuation-exclusion (D7)", () => { - it("PASSES an adherence-supporting continuation of the current dose", () => { + const PLAN = [{ name: "Semaglutide", dose: 7.5 }]; + + it("PASSES an adherence-supporting continuation of the planned dose", () => { + const d = screenModelOutput( + "You should keep taking your prescribed 7.5 mg exactly as directed.", + "en", + CONVERSATIONAL_CONTRACTS, + { dosePlan: PLAN }, + ); + expect(d.block).toBe(false); + expect(d.reason).toBeNull(); + }); + + it("blocks the same continuation where no plan can confirm the amount", () => { const d = screenModelOutput( "You should keep taking your prescribed 7.5 mg exactly as directed.", "en", CONVERSATIONAL_CONTRACTS, ); - expect(d.block).toBe(false); - expect(d.reason).toBeNull(); + expect(d.block).toBe(true); }); it("still blocks 'keep in mind that trying 5 mg' — no maintenance anchor", () => { @@ -334,6 +346,7 @@ describe("screenModelOutput — dose continuation-exclusion (D7)", () => { "Du solltest weiterhin deine 7,5 mg wie verordnet einnehmen.", "de", CONVERSATIONAL_CONTRACTS, + { dosePlan: PLAN }, ); expect(d.block).toBe(false); }); @@ -470,6 +483,13 @@ describe("screenModelOutput — empty input", () => { */ const DOSE_CLASS_VIOLATIONS: Record = { en: [ + "Reduce your dose by half.", + "Cut your dose in half.", + "Take half a dose.", + "Take half your usual dose.", + "Reduce your dose.", + "Increase your insulin.", + "Lower your ramipril a little.", "Increase your metformin to 2000 mg daily.", "Raise your insulin by 4 units tonight.", "Up your metformin to 1500 mg.", @@ -490,6 +510,13 @@ const DOSE_CLASS_VIOLATIONS: Record = { "You should stop taking your lisinopril.", ], de: [ + "Reduzieren Sie die Dosis um die Hälfte.", + "Reduziere deine Dosis um die Hälfte.", + "Nehmen Sie nur die halbe Dosis.", + "Teilen Sie die Tablette in zwei Hälften.", + "Reduzieren Sie die Dosis.", + "Erhöhe dein Insulin.", + "Senken Sie die Dosis etwas.", "Erhöhe dein Metformin auf 2000 mg täglich.", "Reduzieren Sie Ihr Insulin um 4 Einheiten.", "Nimm 2 Tabletten am Abend.", @@ -499,6 +526,12 @@ const DOSE_CLASS_VIOLATIONS: Record = { "Du solltest das Metformin absetzen.", ], fr: [ + "Réduisez la dose de moitié.", + "Prenez une demi-dose.", + "Prenez la moitié de votre dose.", + "Coupez le comprimé en deux.", + "Diminuez votre dose.", + "Augmentez votre insuline.", "Augmentez votre metformine à 2000 mg.", "Réduisez votre insuline de 4 unités.", "Prenez 2 comprimés le soir.", @@ -506,6 +539,12 @@ const DOSE_CLASS_VIOLATIONS: Record = { "Arrêtez de prendre la metformine.", ], es: [ + "Reduzca la dosis a la mitad.", + "Tome media dosis.", + "Tome la mitad de la dosis.", + "Parta la pastilla por la mitad.", + "Reduzca la dosis.", + "Aumente la insulina.", "Aumente su metformina a 2000 mg.", "Reduzca la insulina en 4 unidades.", "Tome 2 pastillas por la noche.", @@ -513,6 +552,12 @@ const DOSE_CLASS_VIOLATIONS: Record = { "Deje de tomar la metformina.", ], it: [ + "Riduca la dose della metà.", + "Riduca la dose a metà.", + "Prenda mezza dose.", + "Prenda metà della dose.", + "Riduca la dose.", + "Aumenti l'insulina.", "Aumenti la metformina a 2000 mg.", "Riduca l'insulina di 4 unità.", "Prenda 2 compresse la sera.", @@ -520,6 +565,14 @@ const DOSE_CLASS_VIOLATIONS: Record = { "Smetta di prendere la metformina.", ], pl: [ + "Zmniejsz dawkę o połowę.", + "Weź pół tabletki.", + "Weź połowę dawki.", + "Przyjmij pół dawki.", + "Zmniejsz dawkę.", + "Zwiększ dawkę insuliny.", + "Odstaw ramipryl.", + "Odstaw enalapryl.", "Zwiększ metforminę do 2000 mg.", "Zmniejsz insulinę o 4 jednostki.", "Proszę przyjąć 2 tabletki wieczorem.", @@ -527,6 +580,10 @@ const DOSE_CLASS_VIOLATIONS: Record = { "Przestań brać metforminę.", ], nl: [ + "Verlaag je dosis met de helft.", + "Neem een halve dosis.", + "Verlaag je dosis.", + "Verhoog je insuline.", "Verhoog je metformine naar 2000 mg per dag.", "Verlaag je insuline met 4 eenheden.", "Neem 's avonds 2 tabletten.", @@ -537,6 +594,13 @@ const DOSE_CLASS_VIOLATIONS: Record = { "Bouw je dosis af naar 5 mg.", ], "pt-BR": [ + "Reduza a dose pela metade.", + "Tome meia dose.", + "Tome metade da dose.", + "Corte o comprimido ao meio.", + "Diminua a dose.", + "Reduza a dose.", + "Aumente a insulina.", "Aumente sua metformina para 2000 mg por dia.", "Reduza sua insulina em 4 unidades.", "Tome 2 comprimidos à noite.", @@ -549,6 +613,11 @@ const DOSE_CLASS_VIOLATIONS: Record = { "Aplique 12 UI de insulina antes do jantar.", ], ko: [ + "복용량을 늘리세요.", + "복용량을 줄이세요.", + "인슐린 용량을 낮추세요.", + "반 알만 드세요.", + "용량을 늘려 보세요.", "메트포르민을 2000mg으로 증량하세요.", "하루 2정 드세요.", "저녁 인슐린 용량을 건너뛰세요.", @@ -565,6 +634,16 @@ const DOSE_CLASS_VIOLATIONS: Record = { */ const DOSE_CLASS_CLEAN: Record = { en: [ + "Reduce your sugar intake by half.", + "Cut your screen time in half.", + "Take half an hour to walk.", + "Reduce your salt.", + "Ask your doctor whether to reduce your dose.", + "Your dose was reduced by half in May.", + "Don't reduce your dose.", + "Increase your activity.", + "Lower your blood pressure with walks.", + "Take your medication as prescribed.", "You take 1000 mg twice daily.", "Your log shows 2000 mg.", "Your log shows you take 1000 mg twice daily, and you haven't missed a dose.", @@ -583,6 +662,12 @@ const DOSE_CLASS_CLEAN: Record = { "Aim to bring your LDL down to 100 mg/dL with your doctor.", ], de: [ + "Reduzieren Sie den Zucker um die Hälfte.", + "Reduziere deinen Salzkonsum.", + "Deine Dosis wurde im Mai halbiert.", + "Reduziere die Dosis nicht.", + "Nimm dir eine halbe Stunde Zeit.", + "Teilen Sie Ihre Medikamentenliste mit der Ärztin.", "Du nimmst 1000 mg zweimal täglich.", "Dein Protokoll zeigt 2000 mg.", "Setz dein Metformin nicht ab.", @@ -590,23 +675,42 @@ const DOSE_CLASS_CLEAN: Record = { "Frag deine Ärztin, ob du die Dosis erhöhen sollst.", ], fr: [ + "Réduisez le sel de moitié.", + "Prenez une demi-heure pour marcher.", + "Ne réduisez pas votre dose.", + "Augmentez votre activité.", "Vous prenez 1000 mg deux fois par jour.", "Ne sautez pas votre dose du soir.", "N'arrêtez pas de prendre la metformine.", ], es: [ + "Reduzca el azúcar a la mitad.", + "Tome media hora para caminar.", + "No reduzca la dosis.", + "Aumente su actividad.", "Usted toma 1000 mg dos veces al día.", "No deje de tomar la metformina.", ], it: [ + "Riduca lo zucchero della metà.", + "Prenda mezz'ora per camminare.", + "Non riduca la dose.", + "Aumenti l'attività.", "Lei prende 1000 mg due volte al giorno.", "Non smetta di prendere la metformina.", ], pl: [ + "Zmniejsz spożycie soli o połowę.", + "Nie zmniejszaj dawki.", + "Odstaw telefon przed snem.", + "Zwiększ aktywność.", "Przyjmuje Pan 1000 mg dwa razy dziennie.", "Nie przestawaj brać leków.", ], nl: [ + "Verlaag je suiker met de helft.", + "Verlaag je dosis niet.", + "Neem een half uur de tijd.", "Je neemt 1000 mg twee keer per dag.", "Je logboek laat 2000 mg zien.", "Sla je avonddosis niet over.", @@ -618,6 +722,11 @@ const DOSE_CLASS_CLEAN: Record = { "Vraag je arts voordat je stopt met metformine.", ], "pt-BR": [ + "Reduza o açúcar pela metade.", + "Tome meia hora para caminhar.", + "Corte o pão ao meio.", + "Não reduza a dose.", + "Aumente sua atividade.", "Você toma 1000 mg duas vezes ao dia.", "Seu registro mostra 2000 mg.", "Não pule a dose da noite.", @@ -632,6 +741,10 @@ const DOSE_CLASS_CLEAN: Record = { "Converse com seu médico antes de parar de tomar a metformina.", ], ko: [ + "걸음 수를 늘리세요.", + "용량을 줄이지 마세요.", + "약 2주 동안 걷는 시간을 늘리세요.", + "물 섭취를 늘리세요.", "하루 두 번 1000mg을 복용하고 계세요.", "저녁 용량을 건너뛰지 마세요.", "용량을 늘릴지 의사와 상담하세요.", @@ -686,7 +799,7 @@ describe("screenModelOutput — the dose-change class, every locale", () => { "Take your 1000 mg as usual.", "en", CONVERSATIONAL_CONTRACTS, - { scheduleDoses: [1000] }, + { dosePlan: [{ name: "Metformin", dose: 1000 }] }, ).block, ).toBe(false); // Off schedule: the same phrasing names a dose the user is not on. @@ -695,7 +808,7 @@ describe("screenModelOutput — the dose-change class, every locale", () => { "Take your 2000 mg as usual.", "en", CONVERSATIONAL_CONTRACTS, - { scheduleDoses: [1000] }, + { dosePlan: [{ name: "Metformin", dose: 1000 }] }, ).block, ).toBe(true); }); diff --git a/src/lib/ai/safety/outbound-screen.ts b/src/lib/ai/safety/outbound-screen.ts index 26f84a6b6..f0a314aac 100644 --- a/src/lib/ai/safety/outbound-screen.ts +++ b/src/lib/ai/safety/outbound-screen.ts @@ -53,10 +53,12 @@ import { bank as ko } from "./outbound-screen/locales/ko"; import { bank as ptBR } from "./outbound-screen/locales/pt-br"; import { DOSE_UNIT, + INN_STEM, LETTER, UNIT_END, type LocaleBank, } from "./outbound-screen/shared"; +import type { ScheduledDose } from "@/lib/medications/scheduled-doses"; /** Which contract a caller asks the screen to enforce. */ export type OutboundContract = "dose" | "risk" | "causal"; @@ -312,13 +314,20 @@ export const INSIGHTS_CONTRACTS: readonly OutboundContract[] = [ /** * Continuation-exclusion for the dose bank (v1.32.7 — D7). A sentence that * matches a dose pattern is EXEMPT only when it is anchored to a maintenance - * object ("keep taking … as prescribed") AND carries no change stem. So - * "you should keep taking your prescribed 7.5 mg" passes, while + * object ("keep taking … as prescribed"), carries no change stem, AND every + * amount it states is one the person is on for the medication it names. So + * "keep taking your prescribed 7.5 mg" passes for someone on 7.5 mg, while * "you should keep in mind that trying 5 mg" (no maintenance anchor) and * "you should continue tapering to 2.4 mg" (a change stem present) stay * blocked. The direction is exclusion-of-continuation, never * requirement-of-change — "you should try 5 mg" must keep blocking. * + * The plan check is not optional. The phrase alone used to be enough on every + * surface that passed no schedule, so "Take 40 mg as before." went out from + * a nudge or a briefing to someone who had never taken 40 mg of anything: "as + * before" is the model's claim, and only the plan can confirm it. A surface + * without the plan cannot exempt a stated amount at all. + * * Accepted residual FP, failing safe: "I'd recommend discussing the 2.4 mg * step with your doctor" stays blocked ("step" is a change stem). */ @@ -347,31 +356,41 @@ function sentenceDoseValues(sentence: string): number[] { return values; } -/** True when a dose value matches a scheduled dose (exact / ±2%). */ -function matchesSchedule( - values: readonly number[], - schedule: readonly number[], +/** True when a dose value equals a planned one (exact / ±2%). */ +function sameDose(value: number, planned: number): boolean { + return Math.abs(value - planned) <= Math.max(0.01, Math.abs(planned) * 0.02); +} + +const INN_STEM_RE = new RegExp(INN_STEM, "i"); + +/** + * True when every amount a continuation sentence states is on the person's + * plan for the medication it names. A sentence naming one of their + * medications is held to that medication's doses; one naming a drug outside + * the plan is never trusted; one naming none is held to the whole plan. + */ +function continuationMatchesPlan( + sentence: string, + plan: readonly ScheduledDose[] | undefined, ): boolean { - return values.some((v) => - schedule.some((s) => Math.abs(v - s) <= Math.max(0.01, Math.abs(s) * 0.02)), + const values = sentenceDoseValues(sentence); + if (values.length === 0 || !plan || plan.length === 0) return false; + const named = plan.filter((entry) => { + const alternation = medicationNameAlternation([entry.name]); + return alternation !== null && new RegExp(alternation, "i").test(sentence); + }); + if (named.length === 0 && INN_STEM_RE.test(sentence)) return false; + const pool = named.length > 0 ? named : plan; + return values.every((value) => + pool.some((entry) => sameDose(value, entry.dose)), ); } -/** - * True when a dose-change imperative trips, honouring the continuation rule. - * - * v1.32.9 (Coach Guard II / G3 — M6/D7 end state): when `scheduleDoses` is - * supplied (the Coach passes the user's active doses), the continuation - * exemption ALSO requires the sentence's dose to match one the user is actually - * on. So "keep taking your 7.5 mg" passes only when 7.5 is a scheduled dose; - * "keep taking your 15 mg" when the schedule says 7.5 is a wrong maintenance - * dose and stays blocked. Without a schedule (every non-Coach surface) the - * Guard I phrase-anchored exemption stands unchanged. - */ +/** True when a dose-change imperative trips, honouring the continuation rule. */ function doseTrips( subject: string, locale: Locale, - scheduleDoses?: readonly number[], + dosePlan?: readonly ScheduledDose[], medicationNames?: readonly string[], ): boolean { const bank = BANKS.dose; @@ -386,18 +405,12 @@ function doseTrips( locale === "en" ? [DOSE_CHANGE_STEM.en] : [DOSE_CHANGE_STEM[locale], DOSE_CHANGE_STEM.en]; - const gateOnSchedule = - scheduleDoses !== undefined && scheduleDoses.length > 0; for (const sentence of splitSentences(subject)) { if (!patterns.some((p) => p.test(sentence))) continue; - let exempt = + const exempt = continuation.some((p) => p.test(sentence)) && - !changeStem.some((p) => p.test(sentence)); - if (exempt && gateOnSchedule) { - // The continuation phrasing is only trusted when the dose it names is one - // the user is actually on. An off-schedule maintenance dose is caught. - exempt = matchesSchedule(sentenceDoseValues(sentence), scheduleDoses); - } + !changeStem.some((p) => p.test(sentence)) && + continuationMatchesPlan(sentence, dosePlan); if (!exempt) return true; } return false; @@ -413,12 +426,12 @@ function doseTrips( */ export interface ScreenOptions { /** - * v1.32.9 — the user's active medication doses (numeric magnitudes). When - * present, the dose continuation exemption is additionally gated on a match: - * a "keep taking your N mg" is trusted only when N is a scheduled dose. Only - * the Coach passes this; every other surface keeps the phrase-anchored rule. + * The person's active medications with their dose magnitudes. A "keep + * taking your N mg" is trusted only when N is on this plan for the + * medication the sentence names; without the plan, a stated amount is never + * exempt. */ - scheduleDoses?: readonly number[]; + dosePlan?: readonly ScheduledDose[]; /** * The person's own medication names, as typed into their schedule. Each one * counts as a medication noun in the dose-change class, so "skip the @@ -441,9 +454,7 @@ export function screenModelOutput( if (contract === "dose") { // Dose is sentence-scoped so the continuation exemption cannot be // voided by a change stem in an unrelated sentence. - if ( - doseTrips(subject, locale, opts?.scheduleDoses, opts?.medicationNames) - ) { + if (doseTrips(subject, locale, opts?.dosePlan, opts?.medicationNames)) { return { block: true, reason: REASON_FOR_CONTRACT.dose }; } continue; diff --git a/src/lib/ai/safety/outbound-screen/locales/de.ts b/src/lib/ai/safety/outbound-screen/locales/de.ts index 3c2b184bd..ce1203e6c 100644 --- a/src/lib/ai/safety/outbound-screen/locales/de.ts +++ b/src/lib/ai/safety/outbound-screen/locales/de.ts @@ -105,6 +105,29 @@ export const bank: LocaleBank = { `\\bverzichte(?:n\\s+sie)?\\s+${DE_SHORT}auf\\s+${DE_SHORT}${med}`, "i", ), + // Halving or a fraction with no number: "reduzieren Sie die Dosis um die + // Hälfte", "nehmen Sie nur die halbe Dosis", "teilen Sie die Tablette in + // zwei Hälften". "Teilen" alone also means "share", so the split needs + // its "in Hälften". + new RegExp( + `\\b(?:reduzier(?:e|en)?|verringer(?:e|n)?|senk(?:e|en)?|kürz(?:e|en)?)(?:\\s+sie)?\\s+${DE_SLOT}${med}\\s+um\\s+(?:die\\s+)?hälfte`, + "i", + ), + new RegExp( + `\\b(?:nimm|nehmen\\s+sie|spritz|spritzen\\s+sie|injizier(?:e)?|injizieren\\s+sie)\\s+(?:nur\\s+)?(?:die\\s+|eine\\s+)?(?:halbe|hälfte\\s+(?:der|deiner|ihrer))\\s+${DE_SHORT}${med}`, + "i", + ), + new RegExp( + `\\bteil(?:e|en)?(?:\\s+sie)?\\s+${DE_SLOT}${med}\\s+(?:in\\s+(?:zwei\\s+)?hälften|in\\s+der\\s+mitte|durch\\s+zwei)`, + "i", + ), + // A bare change imperative on a medication, no target: "reduzieren Sie + // die Dosis.", "erhöhe dein Insulin etwas". The noun closes the clause or + // meets a time or degree word; "reduziere die Dosis nicht" does neither. + new RegExp( + `\\b(?:erhöh(?:e|en)?|steiger(?:e|n)?|reduzier(?:e|en)?|senk(?:e|en)?|verringer(?:e|n)?)(?:\\s+sie)?\\s+${DE_SHORT}${med}(?=\\s*(?:[.!?;:)]|$)|\\s+(?:jetzt|heute|morgen|etwas|leicht|langsam|schrittweise|ab\\s+sofort|für|bis)\\b)`, + "i", + ), ], dose: [ new RegExp( diff --git a/src/lib/ai/safety/outbound-screen/locales/en.ts b/src/lib/ai/safety/outbound-screen/locales/en.ts index 9d029fccb..60c57a151 100644 --- a/src/lib/ai/safety/outbound-screen/locales/en.ts +++ b/src/lib/ai/safety/outbound-screen/locales/en.ts @@ -115,6 +115,24 @@ export const bank: LocaleBank = { `${EN_CUE}(?:(?!\\b(?:not|never|before|without|until|unless|ask|asking|whether|if)\\b)[^.?!]){0,20}?\\b(?:(?:stopping|omitting|leaving\\s+out)\\s+${EN_SHORT}${med}|taking\\s+(?:another|(?:(?:an?|one|two|\\d+)\\s+)?(?:extra|additional|more))\\s+${EN_SHORT}${med})`, "i", ), + // Halving or a fraction with no number: "reduce your dose by half", + // "take half a dose". A half hour or half your sugar names no medication. + new RegExp( + `${EN_LEAD}(?:reduce|cut|lower|decrease|drop|split|break|divide)\\s+${EN_SHORT}${med}\\s+(?:by|in)\\s+(?:a\\s+)?half\\b`, + "im", + ), + new RegExp( + `${EN_LEAD}(?:take|use|inject|give\\s+yourself)\\s+(?:only\\s+|just\\s+)?half\\s+(?:of\\s+)?(?:(?:a|an|the|your)\\s+)?${EN_SHORT}${med}`, + "im", + ), + // A bare change imperative on a medication, no target: "reduce your + // dose.", "increase your insulin slightly". The medication noun has to + // close the clause or meet a time or degree word, so "reduce your dose of + // stress" and "cut your medication costs" are not read as one. + new RegExp( + `${EN_LEAD}(?:increase|raise|up|boost|bump|reduce|lower|decrease|cut|drop)\\s+${EN_SHORT}${med}(?=\\s*(?:[.!?;:)]|$)|\\s+(?:now|today|tonight|tomorrow|a\\s+(?:bit|little)|slightly|gradually|somewhat|this\\s+week|for|until|from\\s+now)\\b)`, + "im", + ), ], dose: [ // step/move/increase/raise/bump/titrate/go up to|by N unit diff --git a/src/lib/ai/safety/outbound-screen/locales/es.ts b/src/lib/ai/safety/outbound-screen/locales/es.ts index 5ed1aef67..8d59daf52 100644 --- a/src/lib/ai/safety/outbound-screen/locales/es.ts +++ b/src/lib/ai/safety/outbound-screen/locales/es.ts @@ -65,6 +65,22 @@ export const bank: LocaleBank = { `(? { +): Promise { const rows = await prisma.medication.findMany({ where: { userId, active: true }, - select: { dose: true }, + select: { name: true, dose: true }, }); - const values = new Set(); + const plan: ScheduledDose[] = []; for (const row of rows) { - const value = parseDoseValue(row.dose); - if (value !== null) values.add(value); + const dose = parseDoseValue(row.dose); + if (dose !== null) plan.push({ name: row.name.trim(), dose }); } - return [...values]; + return plan; }