Merge the named-medication dose screen into release/v1.41.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-06 21:31:50 +02:00
commit fac55720a4
9 files changed
+874 -178

No files matched your search

+6 -1
View File
@@ -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) {
+8
View File
@@ -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,
@@ -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<ModelOutcome, { ok: true }>;
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();
});
});
+5
View File
@@ -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<ReturnType<typeof getScheduledDoseValues>>;
/** The schedule's medication names, which the outbound dose screen reads. */
medicationNames: string[];
turnContext: CoachTurnContext;
workoutEvidence: Record<string, unknown> | 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,
+1
View File
@@ -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
+6 -1
View File
@@ -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({
@@ -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<Locale, readonly string[]> = {
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<Locale, readonly string[]> = {
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<Locale, readonly string[]> = {
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);
});
});
+451 -176
View File
@@ -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}|(?<![\\w])${SPELLED_COUNT}\\s+${COUNT_UNIT}${UNIT_END})`;
/**
* Where an English imperative can start: the sentence head (or a list bullet),
@@ -133,31 +166,153 @@ const TARGET = `[\\d.,]+\\s*${DRUG_UNIT}${UNIT_END}`;
* ("don't skip your dose") never reaches the verb from any of these.
*/
const EN_LEAD =
"(?:^\\s*(?:[-*•]\\s*|\\d+[.)]\\s*)?|[:;—–]\\s*|\\b(?:please|just|then|now|instead|so|you\\s+(?:should|need\\s+to|must|ought\\s+to)|try\\s+to|go\\s+ahead\\s+and|i'?d|i\\s+would|time\\s+to)\\s+)";
"(?:^\\s*(?:[-*•]\\s*|\\d+[.)]\\s*)?|[:;—–]\\s*|(?<!\\b(?:whether|if)\\s)\\b(?:please|just|then|now|instead|so|you\\s+(?:should|need\\s+to|must|ought\\s+to)|try\\s+to|go\\s+ahead\\s+and|i'?d|i\\s+would|time\\s+to)\\s+)";
/** A recommending cue that may sit in front of a gerund / infinitive change. */
const EN_CUE =
"\\b(?:consider|try|recommend|suggest|worth|might\\s+want\\s+to|may\\s+want\\s+to|need\\s+to|time\\s+to|i'?d|i\\s+would|should|could)\\b";
const EN_MED =
/** The letters a drug name is spelled with in the Latin-script locales. */
const LETTER = "a-zà-ÿąćęłńóśźż";
/**
* Medication nouns, per locale: the dosage-form and treatment words.
*/
const EN_MED_NOUN =
"(?:dose|doses|dosage|pill|pills|tablet|tablets|capsule|capsules|medication|medications|medicine|meds|insulin|injection|injections|shot|jab|inhaler)\\b";
const DE_MED =
const DE_MED_NOUN =
"\\S*(?:dosis|tablette\\w*|medikament\\w*|medikation|spritze\\w*|insulin|pille\\w*|kapsel\\w*)";
const FR_MED =
const FR_MED_NOUN =
"(?:doses?|comprimés?|médicaments?|traitement|insuline|injections?|piqûres?|pilules?|gélules?|cachets?)";
const ES_MED =
const ES_MED_NOUN =
"(?:dosis|pastillas?|comprimidos?|medicamentos?|medicaci[óo]n|insulina|inyecci[óo]n|inyecciones|c[áa]psulas?|tratamiento)";
const IT_MED =
const IT_MED_NOUN =
"(?:dos[ei]|compress[ae]|pastigli[ae]|farmac[oi]|medicinal[ei]|insulina|iniezion[ei]|capsul[ae]|terapia)";
const PL_MED =
const PL_MED_NOUN =
"(?:dawk[aęiy]|dawek|tabletk[aęiy]|tabletek|lek|leki|leku|leków|insulin[aęy]|zastrzyk[ia]?|zastrzyków|kapsułk[aęiy]|pigułk[aęiy])(?=[\\s.,;:!?]|$)";
const KO_MED_NOUN =
"(?:약을|약은|약\\s|알약|정제|인슐린|주사|용량|복용량|투약)";
/**
* Generic drug-class words, per locale. "Stop your blood thinner" names no
* dosage form and no drug, and it is the most dangerous stop of all.
*/
const MED_CLASS: Record<Locale, string> = {
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 = `(?<![${LETTER}])[${LETTER}]{2,}(?:pril|sartan|olol|statin|dipin|dypin|formin|glutid|gliflozin|gliptin|xaban|ksaban|prazol|semid)[${LETTER}]{0,4}(?![${LETTER}])|(?<![${LETTER}])(?:enoxa|dalte)?heparin[${LETTER}]{0,4}`;
/**
* Words a schedule entry's first token may not stand for on its own: a name
* like "Morning blend" must not make every "skip the morning …" a dose stop.
*/
const NAME_ALIAS_DENY = new Set([
"morning",
"evening",
"night",
"daily",
"blood",
"extra",
"water",
"sleep",
"salt",
"slow",
"high",
"low",
]);
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* The person's medication names as one alternation, or null. Each schedule
* entry contributes its name up to the first digit or bracket ("Ramipril 5 mg"
* → "ramipril") and its first word when that word is long enough to be a drug
* on its own ("Metformin retard" → "metformin"). A final vowel is dropped from
* a long name so an inflected form still matches (Polish "amlodypinę",
* Italian "amlodipina"), and up to four letters of ending or a Korean particle
* may follow ("ramiprilu", "라미프릴을").
*/
function medicationNameAlternation(
names: readonly string[] | undefined,
): string | null {
if (!names || names.length === 0) return null;
const stems = new Set<string>();
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 `(?<![${LETTER}])(?:${alternation})[^\\s.,;:!?]{0,4}(?=[\\s.,;:!?'’")]|$)`;
}
/** The medication-noun alternation each locale's patterns use. */
type MedVocabulary = Record<Locale, string>;
/**
* 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<Locale, readonly RegExp[]> = {
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(
`(?<!\\b(?:vous|nous|je|tu|il|elle|on)\\s)(?<!n['’])\\b(?:(?:augment|mont|pass|port|major|diminu|baiss|abaiss|doubl|ramen)(?:ez|e|er)|r[ée]dui(?:sez|s|re))\\s+${FR_SLOT}(?:de\\s+${TARGET}\\s+)?(?:[àa]|de|jusqu'[àa])\\s+${TARGET}`,
"i",
),
new RegExp(
`(?<!\\b(?:vous|nous|je|tu|il|elle|on)\\s)\\b(?:prenez|prends|injectez|injecte|commencez\\s+(?:par|à|avec))\\s+${FR_SLOT}${TARGET}`,
"i",
),
new RegExp(
`(?<!\\b(?:vous|nous|je|tu|il|elle|on)\\s)(?<!n['’])\\b(?:divisez|doublez|sautez|suspendez|interrompez|oubliez|arrêtez|stoppez|supprimez)\\s+${FR_SLOT}${FR_MED}`,
"i",
),
new RegExp(
`(?<!n['’])\\b(?:arrêtez|cessez|stoppez)\\s+de\\s+(?:prendre|vous\\s+injecter)\\b`,
"i",
),
new RegExp(
`\\b(?:vous\\s+devriez|je\\s+recommande\\s+de|je\\s+sugg[èe]re\\s+de|envisagez\\s+de|essayez\\s+de)\\s+(?:sauter|suspendre|arrêter|interrompre|diviser|doubler)\\s+${FR_SLOT}(?:de\\s+prendre\\s+\\S|${FR_MED})`,
"i",
),
],
es: [
new RegExp(
`(?<!\\b(?:no|nunca)\\s)\\b(?:aumente|aumenta|aumentar|suba|sube|subir|pase|pasar|incremente|incrementar|reduzca|reduce|reducir|baje|bajar|disminuya|disminuir|duplique|duplicar|lleve|llevar|ajuste|ajustar)\\s+${ES_SLOT}(?:de\\s+${TARGET}\\s+)?(?:a|en|hasta)\\s+${TARGET}`,
"i",
),
new RegExp(
`(?<!\\b(?:no|nunca)\\s)\\b(?:tome|tómese|inyecte|inyéctese|administre|empiece\\s+con|comience\\s+con)\\s+${ES_SLOT}${TARGET}`,
"i",
),
new RegExp(
`(?<!\\b(?:no|nunca)\\s)\\b(?:omita|sáltese|salte|suspenda|interrumpa|duplique|divida|parta)\\s+${ES_SLOT}${ES_MED}`,
"i",
),
new RegExp(
`(?<!\\b(?:no|nunca)\\s)\\b(?:deje|deja|dejar)\\s+de\\s+(?:tomar|usar|inyectarse)\\b`,
"i",
),
],
it: [
new RegExp(
`(?<!\\b(?:non|mai)\\s)\\b(?:aumenti|aumentare|incrementi|incrementare|porti|portare|passi|passare|salga|salire|riduca|ridurre|abbassi|abbassare|diminuisca|diminuire|dimezzi|dimezzare|raddoppi|raddoppiare)\\s+${IT_SLOT}(?:da\\s+${TARGET}\\s+)?(?:a|di|fino\\s+a)\\s+${TARGET}`,
"i",
),
new RegExp(
`(?<!\\b(?:non|mai)\\s)\\b(?:prenda|assuma|inietti|si\\s+inietti|inizi\\s+con|cominci\\s+con)\\s+${IT_SLOT}${TARGET}`,
"i",
),
new RegExp(
`(?<!\\b(?:non|mai)\\s)\\b(?:salti|sospenda|interrompa|dimezzi|raddoppi|divida|ometta|tralasci)\\s+${IT_SLOT}${IT_MED}`,
"i",
),
new RegExp(
`(?<!\\b(?:non|mai)\\s)\\bsmett(?:a|i|ere)\\s+di\\s+(?:prendere|assumere|usare)\\b`,
"i",
),
],
pl: [
new RegExp(
`(?<!\\bnie\\s)\\b(?:zwi[ęe]ksz(?:y[ćc]|cie)?|podnie[śs](?:[ćc]|cie)?|podwy[żz]sz(?:y[ćc])?|zmniejsz(?:y[ćc]|cie)?|obni[żz](?:y[ćc]|cie)?|zredukuj(?:cie)?|zredukowa[ćc]|przejd[źz](?:cie)?|przej[śs][ćc]|zmie[ńn]|zmieni[ćc])(?=\\s)\\s+${PL_SLOT}(?:z\\s+${TARGET}\\s+)?(?:do|o|na)\\s+${TARGET}`,
"i",
),
new RegExp(
`(?:(?<!\\bnie\\s)\\b(?:przyjmij|przyjmuj|we[źz]|bierz|wstrzyknij|wstrzykuj|zacznij\\s+od)|\\b(?:proszę|należy|warto)\\s+(?:przyj[ąa][ćc]|wzi[ąa][ćc]|bra[ćc]|przyjmowa[ćc]|wstrzykiwa[ćc]|wstrzykn[ąa][ćc]|zacz[ąa][ćc]\\s+od))(?=\\s)\\s+${PL_SLOT}${TARGET}`,
"i",
),
new RegExp(
`(?:(?<!\\bnie\\s)\\b(?:pomi[ńn](?:cie)?|opu[śs][ćc]|odstaw(?:cie)?|wstrzymaj|podziel|podwój)|\\b(?:proszę|należy|warto)\\s+(?:pomin[ąa][ćc]|odstawi[ćc]|wstrzyma[ćc]|przerwa[ćc]|podzieli[ćc]|podwoi[ćc]))(?=\\s)\\s+${PL_SLOT}${PL_MED}`,
"i",
),
new RegExp(
`(?:(?<!\\bnie\\s)\\bprzesta[ńn](?:cie)?|\\b(?:proszę|należy)\\s+przesta[ćc])\\s+(?:brać|przyjmować|stosować)(?=[\\s.,;:!?]|$)`,
"i",
),
],
ko: [
// A stated amount closed by an imperative or a recommendation, with or
// without a target particle: "2000mg으로 증량하세요", "하루 2정 드세요".
new RegExp(`${KO_TARGET}[^.?!]{0,25}${KO_IMPERATIVE}`, "i"),
// A medication object halved, doubled, skipped or stopped by imperative.
new RegExp(
`${KO_MED}[^.?!]{0,20}(?:(?:절반|반)\\s*으로\\s*(?:줄이|나누)(?:세요|십시오)|(?:절반|반)\\s*으로\\s*(?:줄여|나눠)\\s*(?:보세요|주세요)|두\\s?배로\\s*(?:늘리|올리)(?:세요|십시오)|두\\s?배로\\s*(?:늘려|올려)\\s*(?:보세요|주세요)|(?:건너뛰|중단하|끊으|멈추|그만\\s*드시|그만\\s*복용하)(?:세요|십시오))`,
),
new RegExp(
`(?:복용|투약|투여|주사)(?:을|를)?\\s*(?:중단하|멈추|그만하|끊으)(?:세요|십시오)`,
),
],
};
function buildDoseChangeClass(
m: MedVocabulary,
): Record<Locale, readonly 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(
`(?<![${LETTER}])(?:halbier(?:e|en)?|verdopp(?:e?le|eln)|pausier(?:e|en)?|überspring(?:e|en)?|stopp(?:e|en)?)(?:\\s+sie)?\\s+${DE_SLOT}${m.de}`,
"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}${m.de}(?:(?!\\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",
),
// "eine Tablette mehr", "noch eine Tablette", "eine zusätzliche
// Tablette", "mehr Insulin". A negation ("keine") cannot enter the slot.
new RegExp(
`\\b(?:nimm|nehmen\\s+sie|spritz|spritzen\\s+sie|injizier(?:e)?|injizieren\\s+sie)\\s+${DE_SHORT}(?:(?:noch\\s+)?(?:${DE_COUNT})\\s+${DE_SHORT}${m.de}\\s+mehr\\b|noch\\s+(?:${DE_COUNT})\\s+${DE_SHORT}${m.de}|(?:(?:${DE_COUNT})\\s+)?(?:zusätzlich\\w*|extra|weitere\\w*|mehr)[-\\s]+${DE_SHORT}${m.de})`,
"i",
),
new RegExp(
`\\bh(?:ör(?:e)?|ören\\s+sie)\\s+mit\\s+${DE_SHORT}${m.de}\\s+auf\\b`,
"i",
),
new RegExp(
`\\bverzichte(?:n\\s+sie)?\\s+${DE_SHORT}auf\\s+${DE_SHORT}${m.de}`,
"i",
),
],
fr: [
new RegExp(
`(?<!\\b(?:vous|nous|je|tu|il|elle|on)\\s)(?<!n['’])\\b(?:(?:augment|mont|pass|port|major|diminu|baiss|abaiss|doubl|ramen)(?:ez|e|er)|r[ée]dui(?:sez|s|re))\\s+${FR_SLOT}(?:de\\s+${TARGET}\\s+)?(?:[àa]|de|jusqu'[àa])\\s+${TARGET}`,
"i",
),
new RegExp(
`(?<!\\b(?:vous|nous|je|tu|il|elle|on)\\s)\\b(?:prenez|prends|injectez|injecte|commencez\\s+(?:par|à|avec))\\s+${FR_SLOT}${TARGET}`,
"i",
),
new RegExp(
`(?<!\\b(?:vous|nous|je|tu|il|elle|on)\\s)(?<!n['’])\\b(?:divisez|doublez|sautez|suspendez|interrompez|oubliez|arrêtez|cessez|stoppez|supprimez)\\s+${FR_SLOT}${m.fr}`,
"i",
),
new RegExp(
`(?<!n['’])\\b(?:arrêtez|cessez|stoppez)\\s+de\\s+(?:prendre|vous\\s+injecter)\\b`,
"i",
),
new RegExp(
`\\b(?:vous\\s+devriez|je\\s+recommande\\s+de|je\\s+sugg[èe]re\\s+de|envisagez\\s+de|essayez\\s+de)\\s+(?:sauter|suspendre|arrêter|interrompre|diviser|doubler)\\s+${FR_SLOT}(?:de\\s+prendre\\s+\\S|${m.fr})`,
"i",
),
// "un comprimé en plus", "un ramipril supplémentaire", "un autre
// comprimé", "encore une dose".
new RegExp(
`(?<!\\bne\\s)(?<!\\b(?:vous|nous|je|tu|il|elle|on)\\s)\\b(?:prenez|prends|injectez|ajoutez)\\s+${FR_SHORT}(?:(?:encore\\s+)?(?:un|une|deux|trois|\\d+)\\s+${FR_SHORT}${m.fr}[^.?!]{0,30}?(?:\\ben\\s+plus\\b|\\bde\\s+plus\\b|\\bsuppl[ée]mentaires?)|(?:un|une)\\s+autres?\\s+${FR_SHORT}${m.fr}|encore\\s+(?:un|une)\\s+${FR_SHORT}${m.fr})`,
"i",
),
],
es: [
new RegExp(
`(?<!\\b(?:no|nunca)\\s)\\b(?:aumente|aumenta|aumentar|suba|sube|subir|pase|pasar|incremente|incrementar|reduzca|reduce|reducir|baje|bajar|disminuya|disminuir|duplique|duplicar|lleve|llevar|ajuste|ajustar)\\s+${ES_SLOT}(?:de\\s+${TARGET}\\s+)?(?:a|en|hasta)\\s+${TARGET}`,
"i",
),
new RegExp(
`(?<!\\b(?:no|nunca)\\s)\\b(?:tome|tómese|inyecte|inyéctese|administre|empiece\\s+con|comience\\s+con)\\s+${ES_SLOT}${TARGET}`,
"i",
),
new RegExp(
`(?<!\\b(?:no|nunca)\\s)\\b(?:omita|sáltese|salte|suspenda|interrumpa|duplique|divida|parta)\\s+${ES_SLOT}${m.es}`,
"i",
),
new RegExp(
`(?<!\\b(?:no|nunca)\\s)\\b(?:deje|deja|dejar)\\s+de\\s+(?:tomar|usar|inyectarse)\\b`,
"i",
),
// "una pastilla de más", "otra pastilla", "una dosis extra".
new RegExp(
`(?<!\\b(?:no|nunca)\\s)\\b(?:tome|tómese|inyecte|inyéctese|añada|agregue)\\s+${ES_SHORT}(?:(?:un|una|dos|tres|\\d+)\\s+${ES_SHORT}${m.es}[^.?!]{0,20}?(?:\\bm[áa]s(?![${LETTER}])|\\bextra\\b|\\badicional(?:es)?\\b)|otr[oa]s?\\s+${ES_SHORT}${m.es})`,
"i",
),
// Stop a named medication or a drug class: "deje el anticoagulante".
new RegExp(
`(?<!\\b(?:no|nunca)\\s)\\b(?:deje|pare|abandone)\\s+${ES_SHORT}${m.es}`,
"i",
),
],
it: [
new RegExp(
`(?<!\\b(?:non|mai)\\s)\\b(?:aumenti|aumentare|incrementi|incrementare|porti|portare|passi|passare|salga|salire|riduca|ridurre|abbassi|abbassare|diminuisca|diminuire|dimezzi|dimezzare|raddoppi|raddoppiare)\\s+${IT_SLOT}(?:da\\s+${TARGET}\\s+)?(?:a|di|fino\\s+a)\\s+${TARGET}`,
"i",
),
new RegExp(
`(?<!\\b(?:non|mai)\\s)\\b(?:prenda|assuma|inietti|si\\s+inietti|inizi\\s+con|cominci\\s+con)\\s+${IT_SLOT}${TARGET}`,
"i",
),
new RegExp(
`(?<!\\b(?:non|mai)\\s)\\b(?:salti|sospenda|interrompa|dimezzi|raddoppi|divida|ometta|tralasci|smetta)\\s+${IT_SLOT}${m.it}`,
"i",
),
new RegExp(
`(?<!\\b(?:non|mai)\\s)\\bsmett(?:a|i|ere)\\s+di\\s+(?:prendere|assumere|usare)\\b`,
"i",
),
// "una compressa in più", "un'altra compressa", "una dose extra".
new RegExp(
`(?<!\\b(?:non|mai)\\s)\\b(?:prenda|assuma|inietti|aggiunga)\\s+${IT_SHORT}(?:(?:un|una|uno|due|tre|\\d+)\\s+${IT_SHORT}${m.it}[^.?!]{0,30}?(?:\\bin\\s+più|\\bextra\\b|\\baggiuntiv[aoei]\\b|\\bin\\s+aggiunta\\b)|(?:un['’]altra|un\\s+altro|altr[eio])\\s*${IT_SHORT}${m.it})`,
"i",
),
],
pl: [
new RegExp(
`(?<!\\bnie\\s)\\b(?:zwi[ęe]ksz(?:y[ćc]|cie)?|podnie[śs](?:[ćc]|cie)?|podwy[żz]sz(?:y[ćc])?|zmniejsz(?:y[ćc]|cie)?|obni[żz](?:y[ćc]|cie)?|zredukuj(?:cie)?|zredukowa[ćc]|przejd[źz](?:cie)?|przej[śs][ćc]|zmie[ńn]|zmieni[ćc])(?=\\s)\\s+${PL_SLOT}(?:z\\s+${TARGET}\\s+)?(?:do|o|na)\\s+${TARGET}`,
"i",
),
new RegExp(
`(?:(?<!\\bnie\\s)\\b(?:przyjmij|przyjmuj|we[źz]|bierz|wstrzyknij|wstrzykuj|zacznij\\s+od)|\\b(?:proszę|należy|warto)\\s+(?:przyj[ąa][ćc]|wzi[ąa][ćc]|bra[ćc]|przyjmowa[ćc]|wstrzykiwa[ćc]|wstrzykn[ąa][ćc]|zacz[ąa][ćc]\\s+od))(?=\\s)\\s+${PL_SLOT}${TARGET}`,
"i",
),
new RegExp(
`(?:(?<!\\bnie\\s)\\b(?:pomi[ńn](?:cie)?|opu[śs][ćc]|odstaw(?:cie)?|wstrzymaj|podziel|podwój)|\\b(?:proszę|należy|warto)\\s+(?:pomin[ąa][ćc]|odstawi[ćc]|wstrzyma[ćc]|przerwa[ćc]|podzieli[ćc]|podwoi[ćc]))(?=\\s)\\s+${PL_SLOT}${m.pl}`,
"i",
),
new RegExp(
`(?:(?<!\\bnie\\s)\\bprzesta[ńn](?:cie)?|\\b(?:proszę|należy)\\s+przesta[ćc])\\s+(?:brać|przyjmować|stosować)(?=[\\s.,;:!?]|$)`,
"i",
),
// "dodatkową tabletkę", "jeszcze jedną dawkę", "jedną tabletkę więcej".
new RegExp(
`(?:(?<!\\bnie\\s)\\b(?:we[źz]|bierz|przyjmij|wstrzyknij|dodaj)|\\b(?:proszę|należy|warto)\\s+(?:wzi[ąa][ćc]|przyj[ąa][ćc]|wstrzykn[ąa][ćc]|doda[ćc]))(?=\\s)\\s+${PL_SHORT}(?:(?:dodatkow\\S*|kolejn\\S*|jeszcze\\s+jedn\\S*)\\s+${PL_SHORT}${m.pl}|(?:jedn\\S*|dwie|dwa|trzy|\\d+)\\s+${PL_SHORT}${m.pl}\\s+więcej(?=[\\s.,;:!?]|$))`,
"i",
),
],
ko: [
// A stated amount closed by an imperative or a recommendation, with or
// without a target particle: "2000mg으로 증량하세요", "하루 2정 드세요".
new RegExp(`${KO_TARGET}[^.?!]{0,25}${KO_IMPERATIVE}`, "i"),
// A medication object halved, doubled, skipped or stopped by imperative.
new RegExp(
`${m.ko}[^.?!]{0,20}(?:(?:절반|반)\\s*으로\\s*(?:줄이|나누)(?:세요|십시오)|(?:절반|반)\\s*으로\\s*(?:줄여|나눠)\\s*(?:보세요|주세요)|두\\s?배로\\s*(?:늘리|올리)(?:세요|십시오)|두\\s?배로\\s*(?:늘려|올려)\\s*(?:보세요|주세요)|(?:건너뛰|중단하|끊으|멈추|그만\\s*드시|그만\\s*복용하)(?:세요|십시오))`,
),
new RegExp(
`(?:복용|투약|투여|주사)(?:을|를)?\\s*(?:중단하|멈추|그만하|끊으)(?:세요|십시오)`,
),
// One more or an additional dose of a medication, closed by an
// imperative: "라미프릴을 한 알 더 드세요", "인슐린을 추가로 맞으세요".
// The prohibitive "더 드시지 마세요" never meets an imperative ending.
new RegExp(
`${m.ko}[^.?!]{0,12}(?:(?:(?:한|두|세|\\d+)\\s*(?:알|정|캡슐|번|단위)?\\s*)?더|추가로)\\s*(?:드세요|드십시오|복용하세요|복용하십시오|투여하세요|맞으세요|맞으십시오|주사하세요)`,
),
],
};
}
/** The class with no schedule: dosage forms, class words and generic stems. */
const DOSE_CHANGE_CLASS = buildDoseChangeClass(medVocabulary(null));
/**
* The class built over one person's medication names. A turn screens its reply
* and the trail text of the same turn with the same names, so the compiled set
* is kept for the most recent schedules rather than rebuilt per call.
*/
const NAMED_CLASS_CACHE = new Map<string, Record<Locale, readonly RegExp[]>>();
const NAMED_CLASS_CACHE_SIZE = 32;
function doseChangeClassFor(
medicationNames: readonly string[] | undefined,
): Record<Locale, readonly RegExp[]> {
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<Locale, readonly RegExp[]> = {
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;
+22
View File
@@ -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<string[]> {
const rows = await prisma.medication.findMany({
where: { userId },
select: { name: true },
take: 64,
});
return [...new Set(rows.map((row) => row.name.trim()).filter(Boolean))];
}