mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge the named-medication dose screen into release/v1.41.0
This commit is contained in:
commit
fac55720a4
9 files changed
+874
-178
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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))];
|
||||
}
|
||||
Reference in new issue
Block a user