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))]; +}