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; }