diff --git a/src/lib/ai/coach/__tests__/outbound-guard.test.ts b/src/lib/ai/coach/__tests__/outbound-guard.test.ts index e429fc47a..bb91f4e35 100644 --- a/src/lib/ai/coach/__tests__/outbound-guard.test.ts +++ b/src/lib/ai/coach/__tests__/outbound-guard.test.ts @@ -124,3 +124,24 @@ describe("coachOutboundFallback", () => { ); }); }); + +describe("screenCoachReply — a drug-named dose change", () => { + it("replaces 'Increase your metformin to 2000 mg daily'", () => { + const d = screenCoachReply( + "Increase your metformin to 2000 mg daily.", + "en", + [1000], + ); + expect(d.block).toBe(true); + expect(d.reason).toBe("dose_prescription"); + }); + + it("passes a restatement of the logged schedule", () => { + const d = screenCoachReply( + "Your log shows you take 1000 mg twice daily.", + "en", + [1000], + ); + expect(d.block).toBe(false); + }); +}); diff --git a/src/lib/ai/safety/__tests__/outbound-screen.test.ts b/src/lib/ai/safety/__tests__/outbound-screen.test.ts index 9860f0300..6d66785f3 100644 --- a/src/lib/ai/safety/__tests__/outbound-screen.test.ts +++ b/src/lib/ai/safety/__tests__/outbound-screen.test.ts @@ -445,3 +445,199 @@ describe("screenModelOutput — empty input", () => { ); }); }); + +// ── the dose-change class ──────────────────────────────────────────────── + +/** + * "Increase your metformin to 2000 mg daily" passed the screen: the English + * change pattern allowed only "it" or "your dose" between the verb and "to", + * so naming the drug walked past it. These cases pin the class — a change + * verb with any short object and a target, an imperative to take a stated + * amount, and an imperative to halve / skip / stop a medication — in every + * locale the banks cover. + */ +const DOSE_CLASS_VIOLATIONS: Record = { + en: [ + "Increase your metformin to 2000 mg daily.", + "Raise your insulin by 4 units tonight.", + "Up your metformin to 1500 mg.", + "Bump the semaglutide to 1 mg next week.", + "Titrate your evening insulin up to 20 units.", + "Reduce your metformin from 2000 mg to 1000 mg.", + "Lower your lisinopril to 5 mg.", + "Halve your metformin to 500 mg.", + "Double your vitamin D to 4000 IU.", + "Take 2000 mg daily.", + "Take 3 tablets of metformin with dinner.", + "Here's the plan: take 2 pills in the morning.", + "Your glucose ran high.\nTake 1000 mg more tonight.", + "It might be worth increasing your metformin to 2000 mg.", + "Skip your evening dose tonight.", + "Consider skipping tonight's insulin dose.", + "Stop taking metformin.", + "You should stop taking your lisinopril.", + ], + de: [ + "Erhöhe dein Metformin auf 2000 mg täglich.", + "Reduzieren Sie Ihr Insulin um 4 Einheiten.", + "Nimm 2 Tabletten am Abend.", + "Halbiere deine Insulindosis.", + "Lass die Abenddosis heute aus.", + "Setz dein Metformin ab.", + "Du solltest das Metformin absetzen.", + ], + fr: [ + "Augmentez votre metformine à 2000 mg.", + "Réduisez votre insuline de 4 unités.", + "Prenez 2 comprimés le soir.", + "Sautez la dose du soir.", + "Arrêtez de prendre la metformine.", + ], + es: [ + "Aumente su metformina a 2000 mg.", + "Reduzca la insulina en 4 unidades.", + "Tome 2 pastillas por la noche.", + "Omita la dosis de la noche.", + "Deje de tomar la metformina.", + ], + it: [ + "Aumenti la metformina a 2000 mg.", + "Riduca l'insulina di 4 unità.", + "Prenda 2 compresse la sera.", + "Salti la dose serale.", + "Smetta di prendere la metformina.", + ], + pl: [ + "Zwiększ metforminę do 2000 mg.", + "Zmniejsz insulinę o 4 jednostki.", + "Proszę przyjąć 2 tabletki wieczorem.", + "Pomiń wieczorną dawkę.", + "Przestań brać metforminę.", + ], + ko: [ + "메트포르민을 2000mg으로 증량하세요.", + "하루 2정 드세요.", + "저녁 인슐린 용량을 건너뛰세요.", + "메트포르민 복용을 중단하세요.", + ], +}; + +/** + * What must keep passing: a restatement of the logged schedule, a clinician + * referral that names no target, a negated imperative (a warning), a past + * change described from the log, and the ordinary non-dose advice that shares + * a verb or a unit with the class — steps, walks, sodium, water, and lab + * targets written as a concentration. + */ +const DOSE_CLASS_CLEAN: Record = { + en: [ + "You take 1000 mg twice daily.", + "Your log shows 2000 mg.", + "Your log shows you take 1000 mg twice daily, and you haven't missed a dose.", + "Ask your doctor whether to increase your dose.", + "Talk to your doctor before you stop taking your metformin.", + "Don't skip your evening dose.", + "Never stop taking metformin without talking to your prescriber.", + "Your dose was increased to 2000 mg in May.", + "Since your metformin was raised to 2000 mg, your fasting glucose dropped.", + "Double your steps for two weeks.", + "Skip the evening walk if it rains.", + "Stop using your phone an hour before bed.", + "Lower your sodium to 1500 mg a day.", + "Increase your water intake to 2000 ml.", + "Try to keep your glucose under 140 mg/dL after meals.", + "Aim to bring your LDL down to 100 mg/dL with your doctor.", + ], + de: [ + "Du nimmst 1000 mg zweimal täglich.", + "Dein Protokoll zeigt 2000 mg.", + "Setz dein Metformin nicht ab.", + "Lass keine Dosis aus.", + "Frag deine Ärztin, ob du die Dosis erhöhen sollst.", + ], + fr: [ + "Vous prenez 1000 mg deux fois par jour.", + "Ne sautez pas votre dose du soir.", + "N'arrêtez pas de prendre la metformine.", + ], + es: [ + "Usted toma 1000 mg dos veces al día.", + "No deje de tomar la metformina.", + ], + it: [ + "Lei prende 1000 mg due volte al giorno.", + "Non smetta di prendere la metformina.", + ], + pl: [ + "Przyjmuje Pan 1000 mg dwa razy dziennie.", + "Nie przestawaj brać leków.", + ], + ko: [ + "하루 두 번 1000mg을 복용하고 계세요.", + "저녁 용량을 건너뛰지 마세요.", + "용량을 늘릴지 의사와 상담하세요.", + "담배를 끊으세요.", + "혈당을 140mg/dL 아래로 유지해 보세요.", + ], +}; + +describe("screenModelOutput — the dose-change class, every locale", () => { + for (const locale of locales) { + it.each(DOSE_CLASS_VIOLATIONS[locale])( + `blocks in ${locale}: %s`, + (text) => { + const d = screenModelOutput(text, locale, CONVERSATIONAL_CONTRACTS); + expect(d.block).toBe(true); + expect(d.reason).toBe("dose_prescription"); + }, + ); + + it.each(DOSE_CLASS_CLEAN[locale])(`passes in ${locale}: %s`, (text) => { + const d = screenModelOutput(text, locale, CONVERSATIONAL_CONTRACTS); + expect(d.block).toBe(false); + expect(d.reason).toBeNull(); + }); + } + + it("blocks the English drug-named change for a reader of every locale", () => { + for (const locale of locales) { + expect( + screenModelOutput( + "Increase your metformin to 2000 mg daily.", + locale, + CONVERSATIONAL_CONTRACTS, + ).block, + ).toBe(true); + } + }); + + it("blocks the class under the insights contracts too", () => { + const d = screenModelOutput( + "Increase your metformin to 2000 mg daily.", + "en", + INSIGHTS_CONTRACTS, + ); + expect(d.reason).toBe("dose_prescription"); + }); + + it("keeps the schedule-gated continuation exemption for a take imperative", () => { + // On schedule: the maintenance anchor exempts it. + expect( + screenModelOutput( + "Take your 1000 mg as usual.", + "en", + CONVERSATIONAL_CONTRACTS, + { scheduleDoses: [1000] }, + ).block, + ).toBe(false); + // Off schedule: the same phrasing names a dose the user is not on. + expect( + screenModelOutput( + "Take your 2000 mg as usual.", + "en", + CONVERSATIONAL_CONTRACTS, + { scheduleDoses: [1000] }, + ).block, + ).toBe(true); + }); +}); diff --git a/src/lib/ai/safety/outbound-screen.ts b/src/lib/ai/safety/outbound-screen.ts index f9a7e9896..da8b2a852 100644 --- a/src/lib/ai/safety/outbound-screen.ts +++ b/src/lib/ai/safety/outbound-screen.ts @@ -56,13 +56,117 @@ export interface OutboundDecision { reason: OutboundReason | null; } +/** + * Count units — a dose expressed as a number of dosage forms ("2 tablets", + * "zwei Hübe" written as "2 Hübe", "1 comprimé"). The same forms the + * medication wizard offers (`src/lib/medications/dose-units.ts`), in every + * locale the banks cover. Without them "take 3 tablets a day" carried no unit + * the screen recognised and passed every pattern. + */ +const COUNT_UNIT = + "(?:tablets?|pills?|capsules?|drops?|puffs?|sprays?|tabletten?|kapseln?|tropfen|hübe|hub|sprühstöße|comprimés?|gélules?|gouttes?|bouffées?|comprimidos?|pastillas?|c[áa]psulas?|gotas?|compress[ae]|pastigli[ae]|gocce|goccia|tabletk[aęiy]|tabletek|kapsułk[aęiy]|kapsułek|krople|kropli)"; + +/** + * Dose units for a target dose a model must never name. Mass and insulin + * units plus the count forms; `ml` is left out on purpose because + * "increase your water intake to 2000 ml" is ordinary hydration advice. + */ +const DRUG_UNIT = `(?:mg|mcg|µg|iu|i\\.u\\.|units?|unidades?|unità|unités?|jednostek|jednostki|ie|i\\.e\\.|einheiten|${COUNT_UNIT})`; + /** * Dose units, shared across every locale. The GLP-1 + general oral-dose * vocabulary; `ie` / `i.e.` / `einheiten` / `unità` / `jednostk` cover the * insulin-unit spellings the six locales use. */ -const DOSE_UNIT = - "(?:mg|mcg|µg|ml|units?|unidades?|unità|unités?|jednostek|jednostki|ie|i\\.e\\.|einheiten)"; +const DOSE_UNIT = `(?:ml|${DRUG_UNIT})`; + +/** + * What may follow a dose unit. A concentration is a lab value, not a dose: + * "keep your glucose under 140 mg/dL" and "eGFR above 60 ml/min" name no + * medication, and a bare `\b` let both trip the "try … N mg" pattern. A + * per-day or per-kilogram rate ("2000 mg/day", "10 mg/kg") IS a dose and still + * matches. The lookahead also ends a unit that closes on a non-word letter + * ("unità", "i.e.") where `\b` cannot. + */ +const UNIT_END = "(?!\\w|\\s*\\/\\s*(?:dl|l|min|mmol)\\b)"; + +/** + * One to four words of object between a change verb and its target — "your + * metformin", "the evening insulin dose", "votre metformine". The original + * English pattern accepted only "it" or "your dose" in that slot, so naming the + * drug ("increase your metformin to 2000 mg") walked past the screen. + * + * A word may not be a digit run, a sentence break, a negation (a negated + * imperative is a warning, not an instruction), a clause joiner, or a + * 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}?`; +} + +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 DE_SLOT = objectSlot( + "nicht|nie|niemals|kein\\w*|und|oder|aber|wenn|ob|natrium|salz|koffein|zucker|wasser|eiweiß|protein|ballaststoff\\w*|kalorien", +); +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*", +); + +/** A target dose: a number, a drug unit, and an honest unit end. */ +const TARGET = `[\\d.,]+\\s*${DRUG_UNIT}${UNIT_END}`; + +/** + * Where an English imperative can start: the sentence head (or a list bullet), + * after a colon / dash, or after a recommending lead-in. A negated imperative + * ("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+)"; + +/** 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 = + "(?:dose|doses|dosage|pill|pills|tablet|tablets|capsule|capsules|medication|medications|medicine|meds|insulin|injection|injections|shot|jab|inhaler)\\b"; +const DE_MED = + "\\S*(?:dosis|tablette\\w*|medikament\\w*|medikation|spritze\\w*|insulin|pille\\w*|kapsel\\w*)"; +const FR_MED = + "(?:doses?|comprimés?|médicaments?|traitement|insuline|injections?|piqûres?|pilules?|gélules?|cachets?)"; +const ES_MED = + "(?:dosis|pastillas?|comprimidos?|medicamentos?|medicaci[óo]n|insulina|inyecci[óo]n|inyecciones|c[áa]psulas?|tratamiento)"; +const IT_MED = + "(?:dos[ei]|compress[ae]|pastigli[ae]|farmac[oi]|medicinal[ei]|insulina|iniezion[ei]|capsul[ae]|terapia)"; +const PL_MED = + "(?: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.,;:!?]|$)"; + +/** + * 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})`; + +/** + * A Korean imperative or recommendation that closes a dose clause. The stem + * must meet the polite-imperative ending directly, so the prohibitive + * "건너뛰지 마세요" (don't skip) and "줄이지 마세요" (don't lower) never match. + */ +const KO_IMPERATIVE = + "(?:(?:늘리|올리|높이|줄이|내리|낮추|증량하|감량하|중단하|끊으|건너뛰|멈추|복용하|드시|드|투여하|맞으|주사하|바꾸|변경하)(?:세요|십시오|시기\\s*바랍니다|셔야|는\\s*(?:것이|게)\\s*좋)|(?:늘려|올려|높여|줄여|내려|낮춰|끊어|멈춰|바꿔|드셔|복용해|투여해|증량해|감량해|중단해)\\s*(?:보세요|주세요|보십시오|야\\s*(?:해요|합니다)))"; +const KO_MED = "(?:약을|약은|약\\s|알약|정제|인슐린|주사|용량|복용량|투약)"; /** * Dose-prescription banks. Each entry requires a CHANGE verb plus a target @@ -83,21 +187,195 @@ const DOSE_UNIT = const DE_DOSE_OBJECT = "(?:\\s+(?:(?:deine|die|ihre|eure|seine)\\s+)?(?:dosis|dosierung|medikation|wochendosis|tagesdosis))?"; +/** + * 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, + * so "Increase your metformin to 2000 mg daily" passed: the English change + * pattern let only "it" or "your dose" stand between the verb and "to", and a + * drug name is neither. These patterns state the class instead, in three + * shapes every locale shares: + * + * 1. a change verb in its imperative / infinitive form, up to four words of + * object (a drug name, "your dose", "the evening insulin"), and a target + * dose — "increase your metformin to 2000 mg", "réduisez votre insuline + * à 10 unités"; + * 2. an imperative to take or inject a stated amount — "take 2000 mg daily", + * "nimm 2 Tabletten", "prenda 1000 mg"; + * 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. + * + * 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") + * do not match, and the subject pronoun of a present-tense description ("you + * take", "vous prenez") is outside every lead-in. A clinician referral without + * a target ("ask your doctor whether to increase your dose") names no dose and + * passes; one that names a target ("… whether to increase to 2000 mg") stays + * blocked, the same accepted fail-safe residual as "discussing the 2.4 mg step". + */ +const DOSE_CHANGE_CLASS: Record = { + en: [ + new RegExp( + `\\b(?:increase|raise|up|bump|boost|titrate|step|move|go|ramp|push|switch|change|adjust|lower|reduce|cut|drop|decrease|taper|halve|double|trim|bring)\\s+${EN_SLOT}(?:from\\s+${TARGET}\\s+)?(?:up\\s+|down\\s+|back\\s+)?(?:to|by)\\s+${TARGET}`, + "i", + ), + new RegExp( + `${EN_CUE}[^.?!]{0,20}?\\b(?:increas(?:e|ing)|rais(?:e|ing)|bump(?:ing)?|boost(?:ing)?|titrat(?:e|ing)|lower(?:ing)?|reduc(?:e|ing)|cut(?:ting)?|drop(?:ping)?|decreas(?:e|ing)|taper(?:ing)?|halv(?:e|ing)|doubl(?:e|ing)|switch(?:ing)?|adjust(?:ing)?|go(?:ing)?\\s+up|step(?:ping)?\\s+up)\\s+${EN_SLOT}(?:from\\s+${TARGET}\\s+)?(?:up\\s+|down\\s+)?(?:to|by)\\s+${TARGET}`, + "i", + ), + new RegExp( + `${EN_LEAD}(?:take|start\\s+(?:taking|on|with|at)|begin\\s+(?:taking|with|at)|inject|use|give\\s+yourself)\\s+${EN_SLOT}${TARGET}`, + "im", + ), + new RegExp( + `${EN_LEAD}(?:halve|double|skip|pause|hold|drop|miss|split|cut)\\s+${EN_SLOT}${EN_MED}`, + "im", + ), + new RegExp( + `${EN_LEAD}(?:(?:(?:stop|quit)\\s+(?:taking|injecting)|discontinue)\\s+\\S|(?:come|wean\\s+(?:yourself\\s+)?)\\s*off\\s+${EN_SLOT}${EN_MED})`, + "im", + ), + new RegExp( + `${EN_CUE}[^.?!]{0,20}?\\b(?:(?:halv|doubl|skipp|paus|hold|dropp|miss|splitt|cutt)ing\\s+${EN_SLOT}${EN_MED}|(?:stopping|quitting)\\s+(?:taking|injecting)\\s+\\S|discontinuing\\s+\\S|coming\\s+off\\s+${EN_SLOT}${EN_MED})`, + "i", + ), + ], + de: [ + new RegExp( + `\\b(?:erhöh(?:e|en)?|steiger(?:e|n)?|reduzier(?:e|en)?|senk(?:e|en)?|verringer(?:e|n)?|halbier(?:e|en)?|verdopp(?:e?le|eln)|stell(?:e|en)?|geh(?:e)?|setz(?:e|en)?|bring(?:e|en)?)(?:\\s+sie)?\\s+${DE_SLOT}(?:(?:hoch|rauf|runter|herunter)\\s+)?(?:auf|um)\\s+${TARGET}`, + "i", + ), + new RegExp( + `\\b(?:nimm|nehmen\\s+sie|spritz|spritzen\\s+sie|injizier(?:e)?|injizieren\\s+sie|beginn(?:e)?\\s+mit|beginnen\\s+sie\\s+mit|starte\\s+mit)\\s+${DE_SLOT}${TARGET}`, + "i", + ), + new RegExp( + `\\b(?:halbier(?:e|en)?|verdopp(?:e?le|eln)|pausier(?:e|en)?|überspring(?:e|en)?)(?:\\s+sie)?\\s+${DE_SLOT}${DE_MED}`, + "i", + ), + new RegExp( + `\\b(?:setz(?:e|en)?)(?:\\s+sie)?(?:\\s+(?!(?:nicht|nie|niemals|kein\\w*)(?=\\s))[^\\s.?!,;:]+){1,4}?\\s+ab(?=\\s*(?:[.?!,;:]|$|und\\s|oder\\s))`, + "i", + ), + new RegExp( + `\\b(?:lass|lassen\\s+sie)\\s+${DE_SLOT}${DE_MED}(?:(?!\\b(?:nicht|nie|niemals)\\b)[^.?!]){0,20}?\\b(?:aus|weg)\\b`, + "i", + ), + new RegExp( + `\\bh(?:ör(?:e)?|ören\\s+sie)\\s+(?:mit\\s+\\S+\\s+)?auf\\b[^.?!]{0,40}?\\b(?:zu\\s+nehmen|einzunehmen|zu\\s+spritzen)`, + "i", + ), + new RegExp( + `\\b(?:solltest|sollten\\s+sie|empfehle|rate\\s+(?:dir|ihnen)|würde\\s+ich)\\b(?:(?!\\b(?:nicht|nie|niemals|kein\\w*)\\b)[^.?!]){0,40}?\\b(?:absetzen|abzusetzen|halbieren|zu\\s+halbieren|verdoppeln|zu\\s+verdoppeln|auslassen|auszulassen|weglassen|wegzulassen|pausieren|zu\\s+pausieren)\\b`, + "i", + ), + ], + fr: [ + new RegExp( + `(? = { en: [ // step/move/increase/raise/bump/titrate/go up to|by N unit new RegExp( - `\\b(?:step|move|increase|raise|bump|titrat\\w*|go|ramp|push|up)\\s+(?:it\\s+)?(?:up\\s+|your\\s+dose\\s+)?(?:to|by)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:step|move|increase|raise|bump|titrat\\w*|go|ramp|push|up)\\s+(?:it\\s+)?(?:up\\s+|your\\s+dose\\s+)?(?:to|by)\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), // lower/reduce/cut/drop/decrease/back off/taper(ing) to|by N unit new RegExp( - `\\b(?:lower|reduce|cut|drop|decrease|back\\s+off|taper\\w*)\\s+(?:it\\s+|your\\s+dose\\s+)?(?:to|by)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:lower|reduce|cut|drop|decrease|back\\s+off|taper\\w*)\\s+(?:it\\s+|your\\s+dose\\s+)?(?:to|by)\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), // consider/try/should/recommend ... N unit new RegExp( - `\\b(?:consider|try|you\\s+should|i'?d?\\s+recommend|i\\s+suggest)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:consider|try|you\\s+should|i'?d?\\s+recommend|i\\s+suggest)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), // Experiment-shaped dose changes the "to/by N unit" patterns miss. BOTH a @@ -107,26 +385,26 @@ const DOSE_PATTERNS: Record = { ], de: [ new RegExp( - `\\b(?:erhöh\\w*|steiger\\w*|setz\\w*\\s+(?:hoch|rauf)|geh\\w*\\s+(?:hoch|rauf))${DE_DOSE_OBJECT}\\s+(?:auf|um)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:erhöh\\w*|steiger\\w*|setz\\w*\\s+(?:hoch|rauf)|geh\\w*\\s+(?:hoch|rauf))${DE_DOSE_OBJECT}\\s+(?:auf|um)\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), new RegExp( - `\\b(?:reduzier\\w*|senk\\w*|verringer\\w*|nimm\\s+(?:weniger|runter))${DE_DOSE_OBJECT}\\s+(?:auf|um)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:reduzier\\w*|senk\\w*|verringer\\w*|nimm\\s+(?:weniger|runter))${DE_DOSE_OBJECT}\\s+(?:auf|um)\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), new RegExp( - `\\bn[äa]chste\\s+(?:stufe|dosis)\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\bn[äa]chste\\s+(?:stufe|dosis)\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), // German puts the verb last in a subordinate clause ("auf 7,5 mg zu // erhöhen"), so the verb-first patterns above miss the most natural // phrasing of the instruction. Match the inverted order too. new RegExp( - `\\b(?:auf|um)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b[^.?!]{0,30}\\b(?:erhöh\\S*|steiger\\S*|reduzier\\S*|senk\\S*|verringer\\S*|hochsetz\\S*)`, + `\\b(?:auf|um)\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}[^.?!]{0,30}\\b(?:erhöh\\S*|steiger\\S*|reduzier\\S*|senk\\S*|verringer\\S*|hochsetz\\S*)`, "i", ), new RegExp( - `\\b(?:erwäg\\w*|probier\\w*|du\\s+solltest|ich\\s+empfehle|ich\\s+schlage\\s+vor)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:erwäg\\w*|probier\\w*|du\\s+solltest|ich\\s+empfehle|ich\\s+schlage\\s+vor)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), /\b(?:halbier\w*|verdoppel\w*|lass\w*\s+aus|setz\w*\s+ab|pausier\w*)\b[^.?!]{0,25}\b(?:dosis|tablette\w*|medikament\w*|spritze\w*|insulin)\b[^.?!]{0,40}\b(?:für\s+\w+\s+(?:tag|tage|woche|wochen|monat\w*)|um\s+zu\s+(?:sehen|testen)|zum\s+(?:test|ausprobieren))\b/i, @@ -134,80 +412,80 @@ const DOSE_PATTERNS: Record = { fr: [ // augmentez / montez / passez / portez à|de N unit new RegExp( - `\\b(?:augment\\w*|mont\\w*|pass\\w*|port\\w*|majo\\w*)\\s+(?:votre\\s+dose\\s+)?(?:[àa]|de|jusqu'[àa])\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:augment\\w*|mont\\w*|pass\\w*|port\\w*|majo\\w*)\\s+(?:votre\\s+dose\\s+)?(?:[àa]|de|jusqu'[àa])\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), // réduisez / diminuez / baissez à|de N unit new RegExp( - `\\b(?:r[ée]duis\\w*|r[ée]duire|diminu\\w*|baiss\\w*|abaiss\\w*)\\s+(?:votre\\s+dose\\s+)?(?:[àa]|de|jusqu'[àa])\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:r[ée]duis\\w*|r[ée]duire|diminu\\w*|baiss\\w*|abaiss\\w*)\\s+(?:votre\\s+dose\\s+)?(?:[àa]|de|jusqu'[àa])\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), new RegExp( - `\\b(?:envisagez|essayez|vous\\s+devriez|je\\s+recommande|je\\s+sugg[èe]re)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:envisagez|essayez|vous\\s+devriez|je\\s+recommande|je\\s+sugg[èe]re)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), new RegExp( - `\\b(?:prochaine|nouvelle)\\s+dose\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:prochaine|nouvelle)\\s+dose\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), ], es: [ // aumente / suba / pase a|en N unit new RegExp( - `\\b(?:aument\\w*|sub\\w*|pas\\w*|increment\\w*)\\s+(?:su\\s+dosis\\s+)?(?:a|en|hasta)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:aument\\w*|sub\\w*|pas\\w*|increment\\w*)\\s+(?:su\\s+dosis\\s+)?(?:a|en|hasta)\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), // reduzca / baje / disminuya a|en N unit new RegExp( - `\\b(?:reduzc\\w*|reduc\\w*|baj\\w*|disminu\\w*)\\s+(?:su\\s+dosis\\s+)?(?:a|en|hasta)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:reduzc\\w*|reduc\\w*|baj\\w*|disminu\\w*)\\s+(?:su\\s+dosis\\s+)?(?:a|en|hasta)\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), new RegExp( - `\\b(?:considere|pruebe|deber[íi]a|recomiendo|sugiero)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:considere|pruebe|deber[íi]a|recomiendo|sugiero)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), new RegExp( - `\\b(?:pr[óo]xima|siguiente|nueva)\\s+dosis\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:pr[óo]xima|siguiente|nueva)\\s+dosis\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), ], it: [ // aumenti / salga / passi a|di N unit new RegExp( - `\\b(?:aument\\w*|sal\\w*|pass\\w*|increment\\w*)\\s+(?:la\\s+sua\\s+dose\\s+)?(?:a|di|fino\\s+a)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:aument\\w*|sal\\w*|pass\\w*|increment\\w*)\\s+(?:la\\s+sua\\s+dose\\s+)?(?:a|di|fino\\s+a)\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), // riduca / abbassi / diminuisca a|di N unit new RegExp( - `\\b(?:riduc\\w*|ridur\\w*|abbass\\w*|diminu\\w*|cal\\w*)\\s+(?:la\\s+sua\\s+dose\\s+)?(?:a|di|fino\\s+a)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:riduc\\w*|ridur\\w*|abbass\\w*|diminu\\w*|cal\\w*)\\s+(?:la\\s+sua\\s+dose\\s+)?(?:a|di|fino\\s+a)\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), new RegExp( - `\\b(?:consideri|provi|dovrebbe|raccomando|suggerisco)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:consideri|provi|dovrebbe|raccomando|suggerisco)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), new RegExp( - `\\b(?:prossima|nuova)\\s+dose\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:prossima|nuova)\\s+dose\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), ], pl: [ // zwiększ / podnieś / przejdź do|o N unit new RegExp( - `\\b(?:zwi[ęe]ksz\\S*|podnie[śs]\\S*|przejd[źz]\\S*|podwy[żz]sz\\S*)\\s+(?:dawk\\S*\\s+)?(?:do|o)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:zwi[ęe]ksz\\S*|podnie[śs]\\S*|przejd[źz]\\S*|podwy[żz]sz\\S*)\\s+(?:dawk\\S*\\s+)?(?:do|o)\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), // zmniejsz / obniż / zredukuj do|o N unit new RegExp( - `\\b(?:zmniejsz\\S*|obni[żz]\\S*|zreduk\\S*|reduk\\S*)\\s+(?:dawk\\S*\\s+)?(?:do|o)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:zmniejsz\\S*|obni[żz]\\S*|zreduk\\S*|reduk\\S*)\\s+(?:dawk\\S*\\s+)?(?:do|o)\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), new RegExp( - `\\b(?:rozwa[żz]\\S*|spr[óo]buj\\S*|powinien|powinna|zalecam|sugeruj[ęe])[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:rozwa[żz]\\S*|spr[óo]buj\\S*|powinien|powinna|zalecam|sugeruj[ęe])[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), new RegExp( - `\\b(?:nast[ęe]pna|kolejna|nowa)\\s+dawka\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `\\b(?:nast[ęe]pna|kolejna|nowa)\\s+dawka\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), ], // Korean is verb-final: the dose comes first and the change verb closes the @@ -225,15 +503,15 @@ const DOSE_PATTERNS: Record = { "i", ), new RegExp( - `(?:권해|권장|권고|추천|제안)[^.?!]{0,40}[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `(?:권해|권장|권고|추천|제안)[^.?!]{0,40}[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), new RegExp( - `[\\d.,]+\\s*${DOSE_UNIT}\\b[^.?!]{0,40}(?:권해|권장|권고|추천|제안|드심|드셔|복용하세요|시도해)`, + `[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}[^.?!]{0,40}(?:권해|권장|권고|추천|제안|드심|드셔|복용하세요|시도해)`, "i", ), new RegExp( - `(?:다음|새)\\s*(?:용량|단계|복용량)[^.?!]{0,30}[\\d.,]+\\s*${DOSE_UNIT}\\b`, + `(?:다음|새)\\s*(?:용량|단계|복용량)[^.?!]{0,30}[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), // Both a medication object AND a trial cue are required, as in the EN bank, @@ -619,8 +897,8 @@ export const INSIGHTS_CONTRACTS: readonly OutboundContract[] = [ * step with your doctor" stays blocked ("step" is a change stem). */ const DOSE_CONTINUATION: Record = { - en: /\b(?:keep\s+taking|keep\s+on\s+taking|continue\s+(?:taking|with|on)|stay(?:ing)?\s+(?:on|at)|remain(?:ing)?\s+on|as\s+prescribed|as\s+directed)\b/i, - de: /\b(?:weiterhin|weiter\s+(?:einnehmen|nehmen)|nimm\s+weiter|beibehalten|behalte\s+bei|wie\s+(?:verordnet|verschrieben|besprochen)|bleib(?:e|st)?\s+bei)\b/i, + en: /\b(?:keep\s+taking|keep\s+on\s+taking|continue\s+(?:taking|with|on)|stay(?:ing)?\s+(?:on|at)|remain(?:ing)?\s+on|as\s+prescribed|as\s+directed|as\s+usual|as\s+before|as\s+you\s+have\s+been)\b/i, + de: /\b(?:weiterhin|weiter\s+(?:einnehmen|nehmen)|nimm\s+weiter|beibehalten|behalte\s+bei|wie\s+(?:verordnet|verschrieben|besprochen|gewohnt|bisher)|bleib(?:e|st)?\s+bei)\b/i, fr: /\b(?:continuez?\s+(?:à|de|le)|gardez|comme\s+prescrit|tel\s+que\s+prescrit)\b/i, es: /\b(?:siga\s+(?:tomando|con)|continúe|mantenga|según\s+lo\s+prescrito)\b/i, it: /\b(?:continui\s+(?:a|con|il)|mantenga|come\s+prescritto)\b/i, @@ -644,7 +922,7 @@ function splitSentences(text: string): string[] { } /** Every leading-magnitude dose value in one sentence ("keep your 7.5 mg" → [7.5]). */ -const DOSE_VALUE_RE = new RegExp(`([\\d.,]+)\\s*${DOSE_UNIT}\\b`, "gi"); +const DOSE_VALUE_RE = new RegExp(`([\\d.,]+)\\s*${DOSE_UNIT}${UNIT_END}`, "gi"); function sentenceDoseValues(sentence: string): number[] { const values: number[] = []; let m: RegExpExecArray | null; @@ -685,7 +963,8 @@ function doseTrips( scheduleDoses?: readonly number[], ): boolean { const bank = BANKS.dose; - const patterns = locale === "en" ? bank.en : [...bank[locale], ...bank.en]; + const own = (l: Locale) => [...bank[l], ...DOSE_CHANGE_CLASS[l]]; + const patterns = locale === "en" ? own("en") : [...own(locale), ...own("en")]; const continuation = locale === "en" ? [DOSE_CONTINUATION.en]