From 27b94a6bf91107783dd5789ae304838ca57aea5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 6 Oct 2026 21:31:18 +0200 Subject: [PATCH] fix(safety): count drug names, class words and spelled counts in the dose screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dose-change class demanded a dosage-form noun next to every skip, stop or extra imperative, and a drug name never counted. "Skip the amlodipine", "then take an extra ramipril", "take one more tablet of ramipril", "Nimm abends eine Tablette mehr", "Lass das Ramipril morgen weg" and "Stop your blood thinner before the run" all passed. A medication noun is now a dosage-form word, a drug-class word per locale (blood thinner, Blutverdünner, anticoagulant, statin, insulin, ...), a common generic-name stem (-pril, -sartan, -olol, -dipine, -formin, ...), or one of the person's own medication names. The Coach loads the names with the turn context and passes them to the reply screen and the trail screen; a scheduled brand name is only known that way. Add the extra / another / one more shape in en, de, fr, es, it, pl and ko, spelled-out counts as a dose target, and stop / leave out / go without a named medication. The number-free shapes use a two-word slot that a preposition ends, so "take an extra minute to log your medication" and "stop worrying about your medication" pass. A sentence that only names a drug, a schedule description, a past change from the log, a negated warning and a referral ("ask your doctor whether you should stop ...") still pass. --- src/lib/ai/coach/activity/screen.ts | 7 +- src/lib/ai/coach/outbound-guard.ts | 8 + .../__tests__/reply-guards-named-meds.test.ts | 91 +++ src/lib/ai/coach/turn/context.ts | 5 + src/lib/ai/coach/turn/model.ts | 1 + src/lib/ai/coach/turn/reply-guards.ts | 7 +- .../outbound-screen-named-meds.test.ts | 284 ++++++++ src/lib/ai/safety/outbound-screen.ts | 627 +++++++++++++----- src/lib/medications/medication-names.ts | 22 + 9 files changed, 874 insertions(+), 178 deletions(-) create mode 100644 src/lib/ai/coach/turn/__tests__/reply-guards-named-meds.test.ts create mode 100644 src/lib/ai/safety/__tests__/outbound-screen-named-meds.test.ts create mode 100644 src/lib/medications/medication-names.ts diff --git a/src/lib/ai/coach/activity/screen.ts b/src/lib/ai/coach/activity/screen.ts index a8bd3d4e1..12c084009 100644 --- a/src/lib/ai/coach/activity/screen.ts +++ b/src/lib/ai/coach/activity/screen.ts @@ -36,6 +36,8 @@ export interface ActivityScreenContext { userMessage?: string; /** Scheduled doses, which the dose screen lets through. */ scheduleDoses?: readonly number[]; + /** The schedule's medication names, which count as medication nouns. */ + medicationNames?: readonly string[]; } /** Markdown emphasis, headings and list marks, which the trail never renders. */ @@ -58,7 +60,10 @@ function cut(text: string, max: number): string { function passes(text: string, ctx: ActivityScreenContext): boolean { try { - if (screenCoachReply(text, ctx.locale, ctx.scheduleDoses).block) { + if ( + screenCoachReply(text, ctx.locale, ctx.scheduleDoses, ctx.medicationNames) + .block + ) { return false; } if (detectRefusal({ message: text, locale: ctx.locale }).refuse) { diff --git a/src/lib/ai/coach/outbound-guard.ts b/src/lib/ai/coach/outbound-guard.ts index 10d21bf9f..53d4d1df1 100644 --- a/src/lib/ai/coach/outbound-guard.ts +++ b/src/lib/ai/coach/outbound-guard.ts @@ -58,14 +58,22 @@ export interface CoachOutboundDecision { * 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. + * + * `medicationNames` optionally makes the person's own medication names count + * as medication nouns in the dose-change class, so "skip the Eliquis" or + * "take an extra ramipril" blocks like "skip your dose". Omitted, the class + * still knows the dosage-form words, the drug-class words and the common + * generic-name stems. */ export function screenCoachReply( reply: string, locale: Locale, scheduleDoses?: readonly number[], + medicationNames?: readonly string[], ): CoachOutboundDecision { const decision = screenModelOutput(reply, locale, CONVERSATIONAL_CONTRACTS, { scheduleDoses, + medicationNames, }); return { block: decision.block, diff --git a/src/lib/ai/coach/turn/__tests__/reply-guards-named-meds.test.ts b/src/lib/ai/coach/turn/__tests__/reply-guards-named-meds.test.ts new file mode 100644 index 000000000..79e79f00e --- /dev/null +++ b/src/lib/ai/coach/turn/__tests__/reply-guards-named-meds.test.ts @@ -0,0 +1,91 @@ +/** + * The turn's medication names reach the outbound dose screen: the Coach reply + * and the trail text both treat a scheduled brand name as a medication noun, + * so "skip the Eliquis" is replaced with the dose fallback. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const findMany = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { coachMessage: { findMany: (...a: unknown[]) => findMany(...a) } }, +})); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/ai/coach/reminders", () => ({ + parseRememberSentinel: (prose: string) => ({ prose, reminder: null }), + captureReminderFromSentinel: vi.fn(), +})); + +import { guardReply } from "../reply-guards"; +import type { TurnContext } from "../context"; +import type { ModelOutcome } from "../model"; +import type { TurnConversation } from "../types"; +import { COACH_OUTBOUND_DOSE_BLOCK_EN } from "@/lib/ai/coach/outbound-guard"; +import { screenActivityTitle } from "@/lib/ai/coach/activity/screen"; + +const SKIP = "Skip the Eliquis tomorrow before the race."; + +function run(content: string, medicationNames: string[]) { + const model = { + ok: true, + result: { content }, + toolTrace: [], + toolResultPayloads: [], + inventoryPayloads: [], + noToolsSnapshotPayloads: [], + inventory: [], + steps: [], + results: [], + forcedFinal: false, + } as unknown as Extract; + const ctx = { + scheduleDoses: [], + medicationNames, + aboutMe: null, + turnContext: { guidedBlock: "", includeFullSnapshot: false }, + snapshot: { referenceGrounding: null }, + } as unknown as TurnContext; + const conversation: TurnConversation = { + conversationId: "conv1", + priorTurns: [], + priorUserMessages: [], + priorToolFigures: [], + priorSummary: null, + }; + return guardReply({ + userId: "u1", + locale: "en", + conversation, + ctx, + toolMode: true, + model, + }); +} + +describe("guardReply — the schedule's medication names", () => { + beforeEach(() => { + findMany.mockReset(); + findMany.mockResolvedValue([]); + }); + + it("replaces a skip of a scheduled brand with the dose fallback", async () => { + const out = await run(SKIP, ["Eliquis 5 mg"]); + if (!out.ok) throw new Error(out.code); + expect(out.reply.replyText).toBe(COACH_OUTBOUND_DOSE_BLOCK_EN); + }); + + it("leaves the same sentence alone when the brand is not on the schedule", async () => { + const out = await run(SKIP, []); + if (!out.ok) throw new Error(out.code); + expect(out.reply.replyText).toBe(SKIP); + }); +}); + +describe("screenActivityTitle — the schedule's medication names", () => { + it("drops a trail title that skips a scheduled brand", () => { + const ctx = { locale: "en" as const, figures: () => [] }; + expect(screenActivityTitle(SKIP, ctx)).toBe(SKIP); + expect( + screenActivityTitle(SKIP, { ...ctx, medicationNames: ["Eliquis"] }), + ).toBeNull(); + }); +}); diff --git a/src/lib/ai/coach/turn/context.ts b/src/lib/ai/coach/turn/context.ts index 5eb51c3dd..b7e3ce1c0 100644 --- a/src/lib/ai/coach/turn/context.ts +++ b/src/lib/ai/coach/turn/context.ts @@ -31,6 +31,7 @@ import { } from "@/lib/ai/coach/chat-request-builder"; import { buildWorkoutEvidenceSection } from "@/lib/ai/coach/workout-evidence-builder"; import { getScheduledDoseValues } 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"; import { @@ -62,6 +63,8 @@ export interface TurnContext { snapshot: CoachSnapshotResult; aboutMe: string | null; scheduleDoses: Awaited>; + /** The schedule's medication names, which the outbound dose screen reads. */ + medicationNames: string[]; turnContext: CoachTurnContext; workoutEvidence: Record | null; systemPrompt: string; @@ -129,6 +132,7 @@ export async function assembleTurnContext(args: { // 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(() => []); + const medicationNames = await getMedicationNames(userId).catch(() => []); // v1.22 (B2/F6) — the canonical system-prompt module is owned elsewhere, so // the two memory/action clauses are appended at assembly time here: one // teaches the model to emit `---REMEMBER---` (durable "remind me" capture), @@ -231,6 +235,7 @@ export async function assembleTurnContext(args: { snapshot, aboutMe, scheduleDoses, + medicationNames, turnContext, workoutEvidence, systemPrompt, diff --git a/src/lib/ai/coach/turn/model.ts b/src/lib/ai/coach/turn/model.ts index c032c45b1..ab7e417b7 100644 --- a/src/lib/ai/coach/turn/model.ts +++ b/src/lib/ai/coach/turn/model.ts @@ -307,6 +307,7 @@ export async function runTurnModel(args: { figures: () => figures, userMessage: args.message, scheduleDoses: ctx.scheduleDoses, + medicationNames: ctx.medicationNames, }, }); // A round's digest runs until the next entry opens: the next round's diff --git a/src/lib/ai/coach/turn/reply-guards.ts b/src/lib/ai/coach/turn/reply-guards.ts index c7f687b3c..4fd5d0a83 100644 --- a/src/lib/ai/coach/turn/reply-guards.ts +++ b/src/lib/ai/coach/turn/reply-guards.ts @@ -201,7 +201,12 @@ export async function guardReply(args: { // the system-prompt GLP-1/grounding contracts. On a trip the turn is // replaced with a calm, grounded fallback and any reminder suggestion / // key-value provenance is dropped — the user never sees the unsafe text. - const outbound = screenCoachReply(replyText, locale, ctx.scheduleDoses); + const outbound = screenCoachReply( + replyText, + locale, + ctx.scheduleDoses, + ctx.medicationNames, + ); if (outbound.block && outbound.reason) { replyText = coachOutboundFallback(outbound.reason, locale); annotate({ 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 new file mode 100644 index 000000000..6a0fa23ee --- /dev/null +++ b/src/lib/ai/safety/__tests__/outbound-screen-named-meds.test.ts @@ -0,0 +1,284 @@ +/** + * The dose-change class with a named medication. + * + * The class demanded a medication NOUN ("dose", "tablet", "insulin") next to + * every skip / stop / extra imperative, and a bare drug name never counted, so + * "skip the amlodipine" and "then take an extra ramipril" passed. Spelled-out + * counts ("take two tablets") and the "extra / one more / … mehr" shape were + * missing in every locale. The screen now takes the person's own medication + * names as medication nouns, knows the generic drug-class words of each + * locale (blood thinner, Blutverdünner, anticoagulant, statin, …) and the + * common generic-name stems (-pril, -sartan, -olol, -dipine, …), and covers + * the extra / spelled-count / stop-a-named-drug shapes. + * + * The negative corpus pins the false-positive posture: a sentence that merely + * names a drug, a description of the schedule, a past change from the log, a + * negated warning, a clinician referral without a target, and everyday advice + * that shares the verb ("take an extra walk") all pass. + */ +import { describe, it, expect } from "vitest"; + +import { + screenModelOutput, + CONVERSATIONAL_CONTRACTS, +} from "@/lib/ai/safety/outbound-screen"; +import { screenCoachReply } from "@/lib/ai/coach/outbound-guard"; +import { locales, type Locale } from "@/lib/i18n/config"; + +/** The person's schedule, as each locale's user would have typed it. */ +const NAMES: Record = { + en: ["Ramipril 5 mg", "Amlodipine", "Eliquis"], + de: ["Ramipril 5 mg", "Amlodipin", "Eliquis"], + fr: ["Ramipril", "Amlodipine", "Eliquis"], + es: ["Ramipril", "Amlodipino", "Eliquis"], + it: ["Ramipril", "Amlodipina", "Eliquis"], + pl: ["Ramipril", "Amlodypina", "Eliquis"], + ko: ["라미프릴", "암로디핀", "엘리퀴스"], +}; + +const VIOLATIONS: Record = { + en: [ + "Then take an extra ramipril.", + "skip the amlodipine", + "take one more tablet of ramipril", + "Stop your blood thinner before the run.", + "Take two tablets of ramipril tonight.", + "Take another amlodipine if your pressure is high.", + "Leave out the Eliquis tomorrow.", + "Leave your Eliquis out tomorrow.", + "Pause your Eliquis for the weekend.", + "Take an additional dose of insulin before the run.", + "You should stop your Eliquis before the race.", + "Consider stopping your ramipril for a week.", + "Take half a tablet of amlodipine.", + ], + de: [ + "Nimm abends eine Tablette mehr.", + "Lass das Ramipril morgen weg.", + "Nimm heute eine zusätzliche Tablette Ramipril.", + "Nimm noch eine Tablette Amlodipin.", + "Nimm zwei Tabletten Ramipril.", + "Überspring das Amlodipin morgen.", + "Pausiere den Blutverdünner am Wochenende.", + "Hör mit dem Ramipril auf.", + "Lass den Blutverdünner vor dem Lauf weg.", + "Verzichte morgen auf das Eliquis.", + ], + fr: [ + "Prenez un comprimé de ramipril en plus.", + "Prenez deux comprimés ce soir.", + "Sautez l'amlodipine demain.", + "Arrêtez votre anticoagulant avant la course.", + "Prenez un ramipril supplémentaire.", + "Prenez un autre comprimé de ramipril.", + ], + es: [ + "Tome una pastilla de más esta noche.", + "Tome dos pastillas de ramipril.", + "Omita el amlodipino mañana.", + "Deje el anticoagulante antes de correr.", + "Tome otra pastilla de ramipril.", + ], + it: [ + "Prenda una compressa in più stasera.", + "Prenda due compresse di ramipril.", + "Salti l'amlodipina domani.", + "Sospenda l'anticoagulante prima della corsa.", + "Prenda un'altra compressa di ramipril.", + ], + pl: [ + "Weź dodatkową tabletkę ramiprilu.", + "Weź dwie tabletki wieczorem.", + "Pomiń amlodypinę jutro.", + "Odstaw ramipril przed biegiem.", + "Weź jedną tabletkę więcej.", + ], + ko: [ + "라미프릴을 한 알 더 드세요.", + "암로디핀은 내일 건너뛰세요.", + "항응고제를 중단하세요.", + "인슐린을 추가로 맞으세요.", + "하루 두 알 드세요.", + ], +}; + +const CLEAN: Record = { + en: [ + "Your ramipril log shows every dose taken.", + "Don't skip your ramipril.", + "Never take an extra amlodipine to make up for a missed one.", + "You took an extra ramipril on Tuesday, according to your log.", + "Your log shows you skipped the amlodipine twice last week.", + "Your schedule has one tablet of ramipril every morning.", + "Your Eliquis was stopped in March, as your log shows.", + "Ask your doctor whether to stop your blood thinner before surgery.", + "Ask your doctor whether you should stop your Eliquis before surgery.", + "Talk to your prescriber before you skip any ramipril.", + "Ramipril and amlodipine both lower blood pressure.", + "Take an extra walk after dinner.", + "Take an extra minute to log your medication.", + "Skip the extra coffee today.", + "Stop your run if you feel dizzy.", + "Take one more lap around the block.", + "Stop worrying about your medication; your log is complete.", + "Take your ramipril as prescribed.", + "Your April readings were steady.", + "Take a reading before your morning amlodipine.", + "Take one more reading tonight to confirm the trend.", + "Ask your doctor if you should skip the amlodipine before surgery.", + "I'd suggest asking your doctor before stopping your ramipril.", + "Please don't take an extra ramipril to catch up.", + "Two tablets of metformin a day is what your schedule shows.", + ], + de: [ + "Dein Ramipril-Protokoll zeigt jede Dosis als genommen.", + "Lass das Ramipril nicht weg.", + "Nimm keine zusätzliche Tablette, wenn du eine vergessen hast.", + "Du hast am Dienstag eine Tablette mehr genommen.", + "Laut Plan nimmst du morgens eine Tablette Ramipril.", + "Frag deine Ärztin, ob du den Blutverdünner vor der OP absetzen sollst.", + "Nimm noch eine Runde um den Block.", + "Lass den Zucker im Kaffee weg.", + "Ramipril und Amlodipin senken beide den Blutdruck.", + "Nimm mehr Wasser zu dir.", + "Nimm eine Messung mehr am Abend.", + "Lass dir von deiner Ärztin erklären, ob du das Ramipril weglassen kannst.", + "Nimm dein Ramipril weiter wie verordnet.", + ], + fr: [ + "Votre journal de ramipril montre toutes les prises.", + "Ne sautez pas l'amlodipine.", + "Ne prenez pas de comprimé en plus.", + "Vous avez pris un comprimé de ramipril en plus mardi.", + "Faites une marche en plus après le dîner.", + ], + es: [ + "Su registro de ramipril muestra todas las tomas.", + "No omita el amlodipino.", + "No tome una pastilla de más.", + "Tome un vaso de agua de más.", + "Deje el azúcar por una semana.", + ], + it: [ + "Il suo registro di ramipril mostra tutte le dosi.", + "Non salti l'amlodipina.", + "Non prenda una compressa in più.", + "Faccia una passeggiata in più.", + ], + pl: [ + "Twój dziennik ramiprilu pokazuje wszystkie dawki.", + "Nie pomiń amlodypiny.", + "Nie bierz dodatkowej tabletki.", + "Zrób dodatkowy spacer po obiedzie.", + ], + ko: [ + "라미프릴 기록을 보면 모두 복용하셨어요.", + "암로디핀을 건너뛰지 마세요.", + "물을 한 잔 더 드세요.", + "라미프릴을 한 알 더 드시지 마세요.", + ], +}; + +describe("screenModelOutput — a named medication in the dose-change class", () => { + for (const locale of locales) { + it.each(VIOLATIONS[locale])(`blocks in ${locale}: %s`, (text) => { + const d = screenModelOutput(text, locale, CONVERSATIONAL_CONTRACTS, { + medicationNames: NAMES[locale], + }); + expect(d.block).toBe(true); + expect(d.reason).toBe("dose_prescription"); + }); + + it.each(CLEAN[locale])(`passes in ${locale}: %s`, (text) => { + const d = screenModelOutput(text, locale, CONVERSATIONAL_CONTRACTS, { + medicationNames: NAMES[locale], + }); + expect(d.block).toBe(false); + expect(d.reason).toBeNull(); + }); + } +}); + +describe("screenModelOutput — drug-class words and generic stems without a schedule", () => { + // Surfaces that pass no names (nudges, the document chat) still see the + // class words and the common generic-name stems. + const blocked: Array<[string, Locale]> = [ + ["Stop your blood thinner before the run.", "en"], + ["Skip your statin tonight.", "en"], + ["skip the amlodipine", "en"], + ["Then take an extra ramipril.", "en"], + ["Take an extra insulin shot.", "en"], + ["Lass den Blutverdünner morgen weg.", "de"], + ["Lass das Ramipril morgen weg.", "de"], + ["Arrêtez votre anticoagulant avant la course.", "fr"], + ]; + it.each(blocked)("blocks: %s (%s)", (text, locale) => { + expect( + screenModelOutput(text, locale, CONVERSATIONAL_CONTRACTS).block, + ).toBe(true); + }); + + const clean: Array<[string, Locale]> = [ + ["Your April readings were steady.", "en"], + ["Skip comparing today with last week.", "en"], + ["Your ramipril log shows every dose taken.", "en"], + ]; + it.each(clean)("passes: %s (%s)", (text, locale) => { + expect( + screenModelOutput(text, locale, CONVERSATIONAL_CONTRACTS).block, + ).toBe(false); + }); +}); + +describe("screenModelOutput — a brand name needs the schedule", () => { + it("blocks a brand only when it is on the person's schedule", () => { + const text = "Skip the Eliquis tomorrow."; + expect(screenModelOutput(text, "en", CONVERSATIONAL_CONTRACTS).block).toBe( + false, + ); + expect( + screenModelOutput(text, "en", CONVERSATIONAL_CONTRACTS, { + medicationNames: ["Eliquis 5 mg"], + }).block, + ).toBe(true); + }); + + it("ignores names that are too short or carry only a number", () => { + const d = screenModelOutput( + "Skip the walk tomorrow.", + "en", + CONVERSATIONAL_CONTRACTS, + { medicationNames: ["B", "500", " "] }, + ); + expect(d.block).toBe(false); + }); + + it("treats regex metacharacters in a name literally", () => { + const names = ["Co-Amoxi (875/125)", "Vit. D3+"]; + expect( + screenModelOutput("Skip the walk.", "en", CONVERSATIONAL_CONTRACTS, { + medicationNames: names, + }).block, + ).toBe(false); + expect( + screenModelOutput( + "Stop the co-amoxi tomorrow.", + "en", + CONVERSATIONAL_CONTRACTS, + { medicationNames: names }, + ).block, + ).toBe(true); + }); +}); + +describe("screenCoachReply — passes the schedule's names through", () => { + it("blocks a named-drug skip when the names are supplied", () => { + expect( + screenCoachReply("Skip the Eliquis tomorrow.", "en", [5], ["Eliquis"]) + .block, + ).toBe(true); + expect( + screenCoachReply("Skip the Eliquis tomorrow.", "en", [5]).block, + ).toBe(false); + }); +}); diff --git a/src/lib/ai/safety/outbound-screen.ts b/src/lib/ai/safety/outbound-screen.ts index da8b2a852..d9eec2667 100644 --- a/src/lib/ai/safety/outbound-screen.ts +++ b/src/lib/ai/safety/outbound-screen.ts @@ -101,31 +101,64 @@ const UNIT_END = "(?!\\w|\\s*\\/\\s*(?:dl|l|min|mmol)\\b)"; * dietary noun: "lower your sodium to 1500 mg" and "cut caffeine to 200 mg" * are nutrition guidance, not a dose change. */ -function objectSlot(stopWords: string): string { - return `(?:(?!(?:${stopWords})(?=\\s))[^\\s\\d.?!,;:]+\\s+){0,4}?`; +function objectSlot(stopWords: string, maxWords = 4): string { + return `(?:(?!(?:${stopWords})(?=\\s))[^\\s\\d.?!,;:]+\\s+){0,${maxWords}}?`; } -const EN_SLOT = objectSlot( - "not|never|and|or|but|if|whether|when|sodium|salt|caffeine|sugars?|fib(?:er|re)|protein|carbs?|carbohydrates?|cholesterol|water|fluids?|coffee|alcohol|steps", +const EN_STOP = + "not|never|and|or|but|if|whether|when|sodium|salt|caffeine|sugars?|fib(?:er|re)|protein|carbs?|carbohydrates?|cholesterol|water|fluids?|coffee|alcohol|steps"; +const DE_STOP = + "nicht|nie|niemals|kein\\w*|und|oder|aber|wenn|ob|natrium|salz|koffein|zucker|wasser|eiweiß|protein|ballaststoff\\w*|kalorien"; +const FR_STOP = + "pas|jamais|ne|et|ou|mais|si|sodium|sel|caféine|sucres?|eau|protéines?|fibres?"; +const ES_STOP = + "no|nunca|y|o|pero|si|sodio|sal|cafeína|azúcar|agua|proteínas?|fibra"; +const IT_STOP = + "non|mai|e|o|ma|se|sodio|sale|caffeina|zucchero|acqua|proteine|fibre"; +const PL_STOP = + "nie|i|lub|albo|ale|jeśli|czy|sód|sodu|sól|soli|kofein\\S*|cukr\\S*|wod\\S*|białk\\S*|błonnik\\S*"; + +const EN_SLOT = objectSlot(EN_STOP); +const DE_SLOT = objectSlot(DE_STOP); +const FR_SLOT = objectSlot(FR_STOP); +const ES_SLOT = objectSlot(ES_STOP); +const IT_SLOT = objectSlot(IT_STOP); +const PL_SLOT = objectSlot(PL_STOP); + +/** + * A tighter slot for the shapes that carry no number: an extra / one more / + * stop imperative. At most two words, and a preposition ends it, so "take an + * extra minute to log your medication" and "stop worrying about your + * medication" never reach the medication noun. + */ +const EN_SHORT = objectSlot( + `${EN_STOP}|to|for|with|at|in|on|of|from|before|after|about|into|than|while|during|like`, + 2, ); -const DE_SLOT = objectSlot( - "nicht|nie|niemals|kein\\w*|und|oder|aber|wenn|ob|natrium|salz|koffein|zucker|wasser|eiweiß|protein|ballaststoff\\w*|kalorien", +const DE_SHORT = objectSlot( + `${DE_STOP}|als|zu|mit|für|gegen|statt|ohne|über`, + 2, ); -const FR_SLOT = objectSlot( - "pas|jamais|ne|et|ou|mais|si|sodium|sel|caféine|sucres?|eau|protéines?|fibres?", -); -const ES_SLOT = objectSlot( - "no|nunca|y|o|pero|si|sodio|sal|cafeína|azúcar|agua|proteínas?|fibra", -); -const IT_SLOT = objectSlot( - "non|mai|e|o|ma|se|sodio|sale|caffeina|zucchero|acqua|proteine|fibre", -); -const PL_SLOT = objectSlot( - "nie|i|lub|albo|ale|jeśli|czy|sód|sodu|sól|soli|kofein\\S*|cukr\\S*|wod\\S*|białk\\S*|błonnik\\S*", +const FR_SHORT = objectSlot( + `${FR_STOP}|pour|avec|avant|après|sans|à|au|aux`, + 2, ); +const ES_SHORT = objectSlot(`${ES_STOP}|para|con|antes|después|sin|a|al`, 2); +const IT_SHORT = objectSlot(`${IT_STOP}|per|con|prima|dopo|senza|a|al`, 2); +const PL_SHORT = objectSlot(`${PL_STOP}|z|ze|do|na|po|przed|bez|dla`, 2); + +/** + * A count written as a word: "take two tablets", "nimm zwei Tabletten", + * "prenez deux comprimés", "weź dwie tabletki". The union of every covered + * locale, because a provider mixes languages. Articles ("a", "un", "eine") + * are left out on purpose: "take a tablet with food" is an article, not a + * count, and the "eine Tablette mehr" shape is its own pattern below. + */ +const SPELLED_COUNT = + "(?:one|two|three|four|five|six|half\\s+an?|zwei|drei|vier|fünf|sechs|eine?n?\\s+halbe|halbe|deux|trois|quatre|cinq|demi|dos|tres|cuatro|cinco|media|due|tre|quattro|cinque|mezza|dwie|dwa|trzy|cztery|pięć|jedn[ąa]|pół)"; /** A target dose: a number, a drug unit, and an honest unit end. */ -const TARGET = `[\\d.,]+\\s*${DRUG_UNIT}${UNIT_END}`; +const TARGET = `(?:[\\d.,]+\\s*${DRUG_UNIT}${UNIT_END}|(? = { + en: "(?:blood[- ]?thinners?|anti-?coagulants?|anti-?platelets?|statins?|beta[- ]?blockers?|diuretics?|water\\s+pills?|ace\\s+inhibitors?|steroids?|antidepressants?|sleeping\\s+pills?)\\b", + de: "\\S*(?:blutverdünner\\w*|gerinnungshemmer\\w*|antikoagul\\w*|statin\\w*|betablocker\\w*|diuretik\\w*|wassertablette\\w*|blutdrucksenker\\w*|antidepressiv\\w*|schlaftablette\\w*)", + fr: "(?:anticoagulants?|antiagrégants?|statines?|bêta-?bloquants?|diurétiques?|antidépresseurs?|somnifères?)", + es: "(?:anticoagulantes?|antiagregantes?|estatinas?|betabloqueantes?|diuréticos?|antidepresivos?)", + it: "(?:anticoagulant[ei]|antiaggregant[ei]|statin[ae]|betabloccant[ei]|diuretic[oi]|antidepressiv[oi])", + pl: "(?:lek\\S*\\s+przeciwzakrzepow\\S*|antykoagulant\\S*|statyn\\S*|beta-?bloker\\S*|diuretyk\\S*|lek\\S*\\s+moczopędn\\S*|antydepresant\\S*)", + ko: "(?:혈액\\s*희석제|항응고제|항혈소판제|스타틴|베타\\s*차단제|이뇨제|항우울제)", +}; + +/** + * Common generic-name stems (INN): ramipril, losartan, bisoprolol, + * atorvastatin, amlodipine, metformin, semaglutide, apixaban, omeprazole, + * furosemide. A surface that passes no schedule (a nudge, the document chat) + * still recognises "skip the amlodipine". Two letters must precede the stem, + * so "April" is not a drug; the stems are chosen so no everyday word ends in + * one ("comparing" is why heparin is spelled out rather than stemmed). + */ +const INN_STEM = `(?(); + for (const raw of names.slice(0, 64)) { + const base = (raw ?? "") + .toLowerCase() + .split(/[\d([{/,;+]/)[0] + .replace(/[.\s]+$/, "") + .replace(/\s+/g, " ") + .trim(); + const first = base.split(" ")[0]; + const candidates = [base]; + if (first !== base && first.length >= 4 && !NAME_ALIAS_DENY.has(first)) { + candidates.push(first); + } + for (const candidate of candidates) { + if ([...candidate].length < 3 || NAME_ALIAS_DENY.has(candidate)) continue; + const stem = + candidate.length >= 6 ? candidate.replace(/[aeiouyę]$/, "") : candidate; + stems.add(stem); + } + } + if (stems.size === 0) return null; + const alternation = [...stems] + .sort((x, y) => y.length - x.length) + .map((stem) => escapeRegExp(stem).replace(/ /g, "\\s+")) + .join("|"); + return `(?; + +/** + * Build each locale's medication noun from the dosage-form words, the class + * words, the generic stems and the person's own names. An elided article + * ("l'amlodipine", "dell'insulina") may sit in front of a French or Italian + * noun. + */ +function medVocabulary(names: string | null): MedVocabulary { + const extra = (locale: Locale) => + [MED_CLASS[locale], locale === "ko" ? null : INN_STEM, names] + .filter((part): part is string => part !== null) + .join("|"); + const elided = "(?:[a-z]{1,4}['’])?"; + return { + en: `(?:${EN_MED_NOUN}|${extra("en")})`, + de: `(?:${DE_MED_NOUN}|${extra("de")})`, + fr: `${elided}(?:${FR_MED_NOUN}|${extra("fr")})`, + es: `(?:${ES_MED_NOUN}|${extra("es")})`, + it: `${elided}(?:${IT_MED_NOUN}|${extra("it")})`, + pl: `(?:${PL_MED_NOUN}|${extra("pl")})`, + ko: `(?:${KO_MED_NOUN}|(?:${extra("ko")})\\S{0,3})`, + }; +} /** * Korean dosage-form counters. They attach to the digit with no space ("2정", * "10단위"); `정` is guarded against "정상" (normal) and `알` against "알레르기". */ const KO_COUNT = "(?:단위|유닛|캡슐|정(?!상|도|확|말)|알(?!레))"; -const KO_TARGET = `[\\d.,]+(?:\\s*${DRUG_UNIT}${UNIT_END}|${KO_COUNT})`; +const KO_TARGET = `(?:[\\d.,]+(?:\\s*${DRUG_UNIT}${UNIT_END}|${KO_COUNT})|(?:한|두|세|네|반)\\s*(?:알(?!레)|정(?!상|도|확|말)|캡슐))`; /** * A Korean imperative or recommendation that closes a dose clause. The stem @@ -166,7 +321,6 @@ const KO_TARGET = `[\\d.,]+(?:\\s*${DRUG_UNIT}${UNIT_END}|${KO_COUNT})`; */ const KO_IMPERATIVE = "(?:(?:늘리|올리|높이|줄이|내리|낮추|증량하|감량하|중단하|끊으|건너뛰|멈추|복용하|드시|드|투여하|맞으|주사하|바꾸|변경하)(?:세요|십시오|시기\\s*바랍니다|셔야|는\\s*(?:것이|게)\\s*좋)|(?:늘려|올려|높여|줄여|내려|낮춰|끊어|멈춰|바꿔|드셔|복용해|투여해|증량해|감량해|중단해)\\s*(?:보세요|주세요|보십시오|야\\s*(?:해요|합니다)))"; -const KO_MED = "(?:약을|약은|약\\s|알약|정제|인슐린|주사|용량|복용량|투약)"; /** * Dose-prescription banks. Each entry requires a CHANGE verb plus a target @@ -187,6 +341,9 @@ const KO_MED = "(?:약을|약은|약\\s|알약|정제|인슐린|주사|용량| const DE_DOSE_OBJECT = "(?:\\s+(?:(?:deine|die|ihre|eure|seine)\\s+)?(?:dosis|dosierung|medikation|wochendosis|tagesdosis))?"; +/** A German count in front of a medication noun ("eine Tablette mehr"). */ +const DE_COUNT = "ein(?:e|en)?|zwei|drei|vier|halbe|\\d+(?:[.,]\\d+)?"; + /** * The dose-change class, per locale. The banks below this one were written * one reported sentence at a time and each fixed the exact shape it was handed, @@ -204,7 +361,17 @@ const DE_DOSE_OBJECT = * 3. an imperative to halve, double, skip, pause or stop a medication, which * needs no number at all — "skip your evening dose", "stop taking * metformin". A medication noun is required except for the explicit - * "stop taking X" form, so "skip your walk" and "double your steps" pass. + * "stop taking X" form, so "skip your walk" and "double your steps" pass; + * 4. an imperative to take an extra, another or one more of a medication — + * "take an extra ramipril", "nimm abends eine Tablette mehr", "prenez un + * comprimé en plus", "weź dodatkową tabletkę". + * + * A medication noun is a dosage-form word, a drug-class word ("blood + * thinner", "Blutverdünner", "anticoagulante"), a common generic-name stem + * ("-pril", "-dipine"), or one of the person's own medication names when the + * caller passes them. A count may be spelled out ("two tablets", "zwei + * Tabletten"). The number-free shapes use a two-word slot that a preposition + * ends, because they have no unit to anchor them. * * Verb forms are imperative or infinitive on purpose: the past forms a log * restatement uses ("your dose was increased to 2000 mg", "du nimmst 1000 mg") @@ -214,152 +381,249 @@ const DE_DOSE_OBJECT = * passes; one that names a target ("… whether to increase to 2000 mg") stays * blocked, the same accepted fail-safe residual as "discussing the 2.4 mg step". */ -const DOSE_CHANGE_CLASS: Record = { - en: [ - new RegExp( - `\\b(?:increase|raise|up|bump|boost|titrate|step|move|go|ramp|push|switch|change|adjust|lower|reduce|cut|drop|decrease|taper|halve|double|trim|bring)\\s+${EN_SLOT}(?:from\\s+${TARGET}\\s+)?(?:up\\s+|down\\s+|back\\s+)?(?:to|by)\\s+${TARGET}`, - "i", - ), - new RegExp( - `${EN_CUE}[^.?!]{0,20}?\\b(?:increas(?:e|ing)|rais(?:e|ing)|bump(?:ing)?|boost(?:ing)?|titrat(?:e|ing)|lower(?:ing)?|reduc(?:e|ing)|cut(?:ting)?|drop(?:ping)?|decreas(?:e|ing)|taper(?:ing)?|halv(?:e|ing)|doubl(?:e|ing)|switch(?:ing)?|adjust(?:ing)?|go(?:ing)?\\s+up|step(?:ping)?\\s+up)\\s+${EN_SLOT}(?:from\\s+${TARGET}\\s+)?(?:up\\s+|down\\s+)?(?:to|by)\\s+${TARGET}`, - "i", - ), - new RegExp( - `${EN_LEAD}(?:take|start\\s+(?:taking|on|with|at)|begin\\s+(?:taking|with|at)|inject|use|give\\s+yourself)\\s+${EN_SLOT}${TARGET}`, - "im", - ), - new RegExp( - `${EN_LEAD}(?:halve|double|skip|pause|hold|drop|miss|split|cut)\\s+${EN_SLOT}${EN_MED}`, - "im", - ), - new RegExp( - `${EN_LEAD}(?:(?:(?:stop|quit)\\s+(?:taking|injecting)|discontinue)\\s+\\S|(?:come|wean\\s+(?:yourself\\s+)?)\\s*off\\s+${EN_SLOT}${EN_MED})`, - "im", - ), - new RegExp( - `${EN_CUE}[^.?!]{0,20}?\\b(?:(?:halv|doubl|skipp|paus|hold|dropp|miss|splitt|cutt)ing\\s+${EN_SLOT}${EN_MED}|(?:stopping|quitting)\\s+(?:taking|injecting)\\s+\\S|discontinuing\\s+\\S|coming\\s+off\\s+${EN_SLOT}${EN_MED})`, - "i", - ), - ], - de: [ - new RegExp( - `\\b(?:erhöh(?:e|en)?|steiger(?:e|n)?|reduzier(?:e|en)?|senk(?:e|en)?|verringer(?:e|n)?|halbier(?:e|en)?|verdopp(?:e?le|eln)|stell(?:e|en)?|geh(?:e)?|setz(?:e|en)?|bring(?:e|en)?)(?:\\s+sie)?\\s+${DE_SLOT}(?:(?:hoch|rauf|runter|herunter)\\s+)?(?:auf|um)\\s+${TARGET}`, - "i", - ), - new RegExp( - `\\b(?:nimm|nehmen\\s+sie|spritz|spritzen\\s+sie|injizier(?:e)?|injizieren\\s+sie|beginn(?:e)?\\s+mit|beginnen\\s+sie\\s+mit|starte\\s+mit)\\s+${DE_SLOT}${TARGET}`, - "i", - ), - new RegExp( - `\\b(?:halbier(?:e|en)?|verdopp(?:e?le|eln)|pausier(?:e|en)?|überspring(?:e|en)?)(?:\\s+sie)?\\s+${DE_SLOT}${DE_MED}`, - "i", - ), - new RegExp( - `\\b(?:setz(?:e|en)?)(?:\\s+sie)?(?:\\s+(?!(?:nicht|nie|niemals|kein\\w*)(?=\\s))[^\\s.?!,;:]+){1,4}?\\s+ab(?=\\s*(?:[.?!,;:]|$|und\\s|oder\\s))`, - "i", - ), - new RegExp( - `\\b(?:lass|lassen\\s+sie)\\s+${DE_SLOT}${DE_MED}(?:(?!\\b(?:nicht|nie|niemals)\\b)[^.?!]){0,20}?\\b(?:aus|weg)\\b`, - "i", - ), - new RegExp( - `\\bh(?:ör(?:e)?|ören\\s+sie)\\s+(?:mit\\s+\\S+\\s+)?auf\\b[^.?!]{0,40}?\\b(?:zu\\s+nehmen|einzunehmen|zu\\s+spritzen)`, - "i", - ), - new RegExp( - `\\b(?:solltest|sollten\\s+sie|empfehle|rate\\s+(?:dir|ihnen)|würde\\s+ich)\\b(?:(?!\\b(?:nicht|nie|niemals|kein\\w*)\\b)[^.?!]){0,40}?\\b(?:absetzen|abzusetzen|halbieren|zu\\s+halbieren|verdoppeln|zu\\s+verdoppeln|auslassen|auszulassen|weglassen|wegzulassen|pausieren|zu\\s+pausieren)\\b`, - "i", - ), - ], - fr: [ - new RegExp( - `(? { + return { + en: [ + new RegExp( + `\\b(?:increase|raise|up|bump|boost|titrate|step|move|go|ramp|push|switch|change|adjust|lower|reduce|cut|drop|decrease|taper|halve|double|trim|bring)\\s+${EN_SLOT}(?:from\\s+${TARGET}\\s+)?(?:up\\s+|down\\s+|back\\s+)?(?:to|by)\\s+${TARGET}`, + "i", + ), + new RegExp( + `${EN_CUE}[^.?!]{0,20}?\\b(?:increas(?:e|ing)|rais(?:e|ing)|bump(?:ing)?|boost(?:ing)?|titrat(?:e|ing)|lower(?:ing)?|reduc(?:e|ing)|cut(?:ting)?|drop(?:ping)?|decreas(?:e|ing)|taper(?:ing)?|halv(?:e|ing)|doubl(?:e|ing)|switch(?:ing)?|adjust(?:ing)?|go(?:ing)?\\s+up|step(?:ping)?\\s+up)\\s+${EN_SLOT}(?:from\\s+${TARGET}\\s+)?(?:up\\s+|down\\s+)?(?:to|by)\\s+${TARGET}`, + "i", + ), + new RegExp( + `${EN_LEAD}(?:take|start\\s+(?:taking|on|with|at)|begin\\s+(?:taking|with|at)|inject|use|give\\s+yourself)\\s+${EN_SLOT}${TARGET}`, + "im", + ), + new RegExp( + `${EN_LEAD}(?:halve|double|skip|pause|hold|drop|miss|split|cut)\\s+${EN_SLOT}${m.en}`, + "im", + ), + new RegExp( + `${EN_LEAD}(?:(?:(?:stop|quit)\\s+(?:taking|injecting)|discontinue)\\s+\\S|(?:come|wean\\s+(?:yourself\\s+)?)\\s*off\\s+${EN_SLOT}${m.en})`, + "im", + ), + new RegExp( + `${EN_CUE}[^.?!]{0,20}?\\b(?:(?:halv|doubl|skipp|paus|hold|dropp|miss|splitt|cutt)ing\\s+${EN_SLOT}${m.en}|(?:stopping|quitting)\\s+(?:taking|injecting)\\s+\\S|discontinuing\\s+\\S|coming\\s+off\\s+${EN_SLOT}${m.en})`, + "i", + ), + // An extra, another or one more of a medication: "take an extra + // ramipril", "take one more tablet of ramipril". The short slot ends at a + // preposition, so "take an extra minute to log your medication" passes. + new RegExp( + `${EN_LEAD}(?:take|use|inject|add|give\\s+yourself|pop)\\s+(?:another|(?:(?:an?|one|two|three|\\d+)\\s+)?(?:extra|additional|more|further|second))\\s+${EN_SHORT}${m.en}`, + "im", + ), + // Stop, leave out or go without a named medication or a drug class: + // "stop your blood thinner", "leave out the Eliquis". + new RegExp( + `${EN_LEAD}(?:stop|quit|discontinue|omit|leave\\s+out|hold\\s+off\\s+on|go\\s+without)\\s+${EN_SHORT}${m.en}`, + "im", + ), + new RegExp(`${EN_LEAD}leave\\s+${EN_SHORT}${m.en}\\s+out\\b`, "im"), + // The same behind a recommending cue. A referral ("before", "ask", + // "whether") or a negation between the cue and the verb ends it. + new RegExp( + `${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}${m.en}|taking\\s+(?:another|(?:(?:an?|one|two|\\d+)\\s+)?(?:extra|additional|more))\\s+${EN_SHORT}${m.en})`, + "i", + ), + ], + de: [ + new RegExp( + `\\b(?:erhöh(?:e|en)?|steiger(?:e|n)?|reduzier(?:e|en)?|senk(?:e|en)?|verringer(?:e|n)?|halbier(?:e|en)?|verdopp(?:e?le|eln)|stell(?:e|en)?|geh(?:e)?|setz(?:e|en)?|bring(?:e|en)?)(?:\\s+sie)?\\s+${DE_SLOT}(?:(?:hoch|rauf|runter|herunter)\\s+)?(?:auf|um)\\s+${TARGET}`, + "i", + ), + new RegExp( + `\\b(?:nimm|nehmen\\s+sie|spritz|spritzen\\s+sie|injizier(?:e)?|injizieren\\s+sie|beginn(?:e)?\\s+mit|beginnen\\s+sie\\s+mit|starte\\s+mit)\\s+${DE_SLOT}${TARGET}`, + "i", + ), + new RegExp( + `(?>(); +const NAMED_CLASS_CACHE_SIZE = 32; + +function doseChangeClassFor( + medicationNames: readonly string[] | undefined, +): Record { + const names = medicationNameAlternation(medicationNames); + if (names === null) return DOSE_CHANGE_CLASS; + const cached = NAMED_CLASS_CACHE.get(names); + if (cached) return cached; + const built = buildDoseChangeClass(medVocabulary(names)); + if (NAMED_CLASS_CACHE.size >= NAMED_CLASS_CACHE_SIZE) { + const oldest = NAMED_CLASS_CACHE.keys().next().value; + if (oldest !== undefined) NAMED_CLASS_CACHE.delete(oldest); + } + NAMED_CLASS_CACHE.set(names, built); + return built; +} const DOSE_PATTERNS: Record = { en: [ @@ -961,9 +1225,11 @@ function doseTrips( subject: string, locale: Locale, scheduleDoses?: readonly number[], + medicationNames?: readonly string[], ): boolean { const bank = BANKS.dose; - const own = (l: Locale) => [...bank[l], ...DOSE_CHANGE_CLASS[l]]; + const changeClass = doseChangeClassFor(medicationNames); + const own = (l: Locale) => [...bank[l], ...changeClass[l]]; const patterns = locale === "en" ? own("en") : [...own(locale), ...own("en")]; const continuation = locale === "en" @@ -1006,6 +1272,13 @@ export interface ScreenOptions { * the Coach passes this; every other surface keeps the phrase-anchored rule. */ scheduleDoses?: readonly number[]; + /** + * 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 + * Eliquis" and "take an extra ramipril" block like "skip your dose". A brand + * name is only known this way; generic names are also caught by their stem. + */ + medicationNames?: readonly string[]; } export function screenModelOutput( @@ -1021,7 +1294,9 @@ 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)) { + if ( + doseTrips(subject, locale, opts?.scheduleDoses, opts?.medicationNames) + ) { return { block: true, reason: REASON_FOR_CONTRACT.dose }; } continue; diff --git a/src/lib/medications/medication-names.ts b/src/lib/medications/medication-names.ts new file mode 100644 index 000000000..c2e3623bc --- /dev/null +++ b/src/lib/medications/medication-names.ts @@ -0,0 +1,22 @@ +/** + * The names on a person's medication schedule, for the outbound dose screen. + * + * The screen's dose-change class needs a medication noun next to a skip, stop + * or extra imperative. A brand name ("Eliquis") is no dosage-form word and has + * no generic stem, so without the person's own names "skip the Eliquis" + * passed. Inactive entries are included on purpose: an instruction to take or + * skip a medication the person used to be on is still a dose instruction. + * + * Server-only; a cheap indexed read. `Medication.name` is plaintext. + */ +import { prisma } from "@/lib/db"; + +/** The de-duped medication names of one person, at most 64. */ +export async function getMedicationNames(userId: string): Promise { + const rows = await prisma.medication.findMany({ + where: { userId }, + select: { name: true }, + take: 64, + }); + return [...new Set(rows.map((row) => row.name.trim()).filter(Boolean))]; +}