diff --git a/src/lib/ai/safety/__tests__/outbound-screen-false-positives.test.ts b/src/lib/ai/safety/__tests__/outbound-screen-false-positives.test.ts new file mode 100644 index 000000000..c9b264faa --- /dev/null +++ b/src/lib/ai/safety/__tests__/outbound-screen-false-positives.test.ts @@ -0,0 +1,195 @@ +/** + * What the dose screen must let through: realistic, harmless Coach replies + * in every language. Advice to talk to the doctor, negated warnings, + * questions, conditions, a "dose" of caffeine, salt or exercise, and a + * correct restatement of the person's own plan. The positive corpus sits in + * `outbound-screen.test.ts` (`DOSE_CLASS_VIOLATIONS`); a pattern that widens + * the class has to keep both green. + */ +import { describe, expect, it } from "vitest"; + +import type { Locale } from "@/lib/i18n/config"; + +import { + CONVERSATIONAL_CONTRACTS, + screenModelOutput, +} from "../outbound-screen"; + +const PLAN = [ + { name: "Ramipril", dose: 5 }, + { name: "Metformin", dose: 1000 }, +]; + +const HARMLESS: Record = { + en: [ + "Talk to your doctor before you change your dose.", + "Discuss with your doctor: reduce your dose?", + "If your doctor agrees, then reduce your dose gradually.", + "Don't reduce your dose for now.", + "Never reduce your dose for a missed meal without guidance.", + "Do not take half a tablet unless your pharmacist says it is scored.", + "Increase your dose? Not without your doctor.", + "Should you lower your insulin? That is a question for your diabetes team.", + "Reduce your dose of caffeine in the afternoon.", + "Cut your salt a little this week.", + "Increase your activity gradually.", + "Your doctor said to reduce your dose for a week.", + "You take metformin 1000 mg twice daily, as your plan says.", + "Keep taking ramipril 5 mg as before.", + "Reduce your sugar intake by half.", + "Ask your pharmacist whether the tablet can be split in half.", + ], + de: [ + "Sprich mit deiner Ärztin, bevor du die Dosis änderst.", + "Erhöhe dein Insulin heute nicht, auch wenn der Wert hoch ist.", + "Reduzieren Sie die Dosis jetzt nicht eigenmächtig.", + "Senke die Dosis bis zum Arzttermin nicht.", + "Nimm die halbe Dosis nicht ohne Rücksprache.", + "Teile die Tablette in zwei Hälften nur, wenn deine Apotheke zustimmt.", + "Wenn deine Ärztin zustimmt, reduziere die Dosis schrittweise.", + "Soll ich die Dosis erhöhen? Das klärst du mit deiner Praxis.", + "Reduziere den Zucker um die Hälfte.", + "Reduziere deinen Salzkonsum.", + "Steigere deine Aktivität langsam.", + "Frag deine Ärztin, ob du die halbe Dosis nehmen kannst.", + "Du nimmst Metformin 1000 mg zweimal täglich, wie geplant.", + "Nimm Ramipril 5 mg wie bisher weiter.", + "Erhöhe die Dosis also für ein paar Tage nicht.", + ], + fr: [ + "Parlez à votre médecin avant de changer votre dose.", + "Ne réduisez pas votre dose maintenant.", + "Ne prenez pas une demi-dose sans avis.", + "Faut-il diminuer votre dose ? Demandez à votre médecin.", + "Si votre médecin le juge utile, il peut diminuer votre dose.", + "Réduisez le sel de moitié.", + "Augmentez votre activité progressivement.", + "Diminuez votre café l’après-midi.", + "Vous prenez metformine 1000 mg deux fois par jour.", + "Demandez à votre pharmacien si le comprimé peut être coupé en deux.", + "Il est préférable que vous ne diminuiez pas votre dose maintenant.", + "Avant de diminuer votre dose, parlez à votre médecin.", + "Prenez une demi-heure pour marcher.", + "Augmentez votre consommation d’eau.", + "N’augmentez pas votre insuline sans votre médecin.", + ], + es: [ + "Hable con su médico antes de que reduzca la dosis.", + "Consulte si conviene que reduzca la dosis.", + "Espere a su cita antes de que aumente la insulina.", + "Cuando su médico lo indique, reduzca la dosis.", + "No reduzca la dosis hoy.", + "¿Debería reducir la dosis? Pregunte a su médico.", + "Es importante que no reduzca la dosis hoy.", + "Reduzca el azúcar a la mitad.", + "Aumente su actividad poco a poco.", + "Tome media hora para caminar.", + "Usted toma metformina 1000 mg dos veces al día.", + "Pregunte a su farmacéutico si puede partir la pastilla.", + "Le recomiendo que no aumente la dosis ahora.", + "Antes de que baje la dosis, consulte a su médico.", + "Reduzca el café por la tarde.", + ], + it: [ + "Parli con il medico prima di cambiare la dose.", + "Non riduca la dose ora.", + "Se il medico decide, riduca la dose per gradi.", + "Chieda al medico se può prendere mezza dose.", + "Ridurre la dose? Solo se il medico lo consiglia.", + "Prima che riduca la dose, ne parli con il medico.", + "È importante che lei non riduca la dose ora.", + "Riduca lo zucchero della metà.", + "Aumenti l'attività gradualmente.", + "Prenda mezz'ora per camminare.", + "Lei prende metformina 1000 mg due volte al giorno.", + "Riduca il caffè nel pomeriggio.", + "Chieda al farmacista se la compressa si può dividere.", + "Non aumenti l'insulina senza il medico.", + "Aumenti l'acqua durante il giorno.", + ], + pl: [ + "Porozmawiaj z lekarzem, zanim zmienisz dawkę.", + "Nie zwiększaj dawki teraz.", + "Jeśli lekarz zaleci, zmniejsz dawkę stopniowo.", + "Lekarz może zdecydować: zmniejsz dawkę leku.", + "Zapytaj lekarza, czy możesz przyjmować pół dawki.", + "Zmniejszyć dawkę? Tylko jeśli lekarz tak zdecyduje.", + "Zmniejsz dawkę kofeiny.", + "Zmniejsz dawkę cukru.", + "Zwiększ dawkę ruchu.", + "Zmniejsz spożycie soli o połowę.", + "Zwiększ aktywność stopniowo.", + "Przyjmujesz metforminę 1000 mg dwa razy dziennie.", + "Odstaw telefon przed snem.", + "Nie zmniejszaj dawki insuliny bez lekarza.", + "Zapytaj farmaceutę, czy tabletkę można podzielić na pół.", + ], + nl: [ + "Overleg met je arts voordat je je dosis aanpast.", + "Verlaag je dosis nu niet.", + "Verhoog je insuline vandaag niet zelf.", + "Verlaag je dosis vanaf morgen niet zonder overleg.", + "Vraag je arts: verlaag je dosis?", + "Vraag je arts of je een halve dosis mag nemen.", + "Verlaag je dosis alleen als je arts dat zegt.", + "Verlaag je suiker met de helft.", + "Verhoog je activiteit geleidelijk.", + "Neem een half uur de tijd.", + "Je neemt metformine 1000 mg twee keer per dag.", + "Vraag je apotheker of de tablet doormidden mag.", + "Verminder je koffie in de middag.", + "Verdubbel je stappen twee weken lang.", + "Neem geen halve dosis zonder overleg.", + ], + "pt-BR": [ + "Converse com seu médico antes de mudar a dose.", + "Não reduza a dose agora.", + "É importante que você não reduza a dose agora.", + "Antes que você reduza a dose, fale com seu médico.", + "Pergunte ao seu médico se você pode tomar meia dose.", + "Reduzir a dose? Só se o médico indicar.", + "Reduza o açúcar pela metade.", + "Aumente sua atividade aos poucos.", + "Tome meia hora para caminhar.", + "Você toma metformina 1000 mg duas vezes ao dia.", + "Corte o pão ao meio.", + "Reduza o café à tarde.", + "Pergunte ao farmacêutico se o comprimido pode ser partido.", + "Não aumente a insulina sem o médico.", + "Aumente a ingestão de água.", + ], + ko: [ + "의사와 상의 후 복용량을 늘리세요.", + "복용량을 늘리세요라고 말씀드릴 수는 없어요.", + "의사와 상의하지 않고 복용량을 줄이지 마세요.", + "약 복용량을 바꾸고 싶으시면 먼저 의사와 상의하세요.", + "용량을 줄이지 마세요.", + "복용량을 늘려야 할까요? 주치의와 이야기해 보세요.", + "걸음 수를 늘리세요.", + "물 섭취를 늘리세요.", + "약 2주 동안 걷는 시간을 늘리세요.", + "커피를 줄이세요.", + "하루 두 번 1000mg을 복용하고 계세요.", + "약사에게 알약을 반으로 나눠도 되는지 물어보세요.", + "설탕 섭취를 절반으로 줄이세요.", + "운동량을 천천히 늘리세요.", + "담배를 끊으세요.", + ], +}; + +describe("dose screen false-positive corpus", () => { + for (const [locale, sentences] of Object.entries(HARMLESS) as Array< + [Locale, readonly string[]] + >) { + it(`${locale} carries at least 15 harmless replies`, () => { + expect(sentences.length).toBeGreaterThanOrEqual(15); + }); + it.each(sentences)(`passes in ${locale}: %s`, (text) => { + const d = screenModelOutput(text, locale, CONVERSATIONAL_CONTRACTS, { + dosePlan: PLAN, + medicationNames: PLAN.map((entry) => entry.name), + }); + expect(d.block).toBe(false); + }); + } +}); diff --git a/src/lib/ai/safety/__tests__/outbound-screen-guard2.test.ts b/src/lib/ai/safety/__tests__/outbound-screen-guard2.test.ts index d0feee9f5..bfd4083b1 100644 --- a/src/lib/ai/safety/__tests__/outbound-screen-guard2.test.ts +++ b/src/lib/ai/safety/__tests__/outbound-screen-guard2.test.ts @@ -18,7 +18,7 @@ describe("screenModelOutput — dose exemption gated on the plan", () => { it("passes a continuation dose that MATCHES the user's plan", () => { const d = screenModelOutput( - "You should keep taking your prescribed 7.5 mg exactly as directed.", + "You should keep taking your prescribed semaglutide 7.5 mg exactly as directed.", "en", CONVERSATIONAL_CONTRACTS, { dosePlan: PLAN }, @@ -30,7 +30,7 @@ describe("screenModelOutput — dose exemption gated on the plan", () => { // The plan says 7.5; "keep taking your 15 mg" is a wrong maintenance // dose and must not ride the continuation exemption. const d = screenModelOutput( - "You should keep taking your 15 mg as prescribed.", + "You should keep taking your semaglutide 15 mg as prescribed.", "en", CONVERSATIONAL_CONTRACTS, { dosePlan: PLAN }, @@ -61,12 +61,12 @@ describe("screenModelOutput — dose exemption gated on the plan", () => { } }); - it("passes the same sentences when the plan holds that dose", () => { + it("passes a named continuation when the plan holds that dose for it", () => { for (const [text, locale] of [ - ["Take 40 mg as before.", "en"], - ["Tome 40 mg como antes.", "pt-BR"], - ["Nimm 40 mg wie bisher.", "de"], - ["Neem 40 mg zoals eerder.", "nl"], + ["Take atorvastatin 40 mg as before.", "en"], + ["Tome atorvastatina 40 mg como antes.", "pt-BR"], + ["Nimm Atorvastatin 40 mg wie bisher.", "de"], + ["Neem atorvastatine 40 mg zoals eerder.", "nl"], ] as const) { expect( screenModelOutput(text, locale, CONVERSATIONAL_CONTRACTS, { @@ -77,6 +77,43 @@ describe("screenModelOutput — dose exemption gated on the plan", () => { } }); + it("blocks an amount with no medication named beside it, even on the plan", () => { + for (const text of [ + "Take 40 mg as before.", + "You should keep taking your prescribed 7.5 mg exactly as directed.", + ]) { + expect( + screenModelOutput(text, "en", CONVERSATIONAL_CONTRACTS, { + dosePlan: PLAN, + }).block, + text, + ).toBe(true); + } + }); + + it("blocks a brand the plan does not carry, and swapped doses", () => { + for (const text of [ + "Take Eliquis 5 mg as before.", + "Take Xarelto 40 mg as before.", + "Take ramipril 40 mg and atorvastatin 5 mg as before.", + ]) { + expect( + screenModelOutput(text, "en", CONVERSATIONAL_CONTRACTS, { + dosePlan: PLAN, + }).block, + text, + ).toBe(true); + } + expect( + screenModelOutput( + "Take ramipril 5 mg and atorvastatin 40 mg as before.", + "en", + CONVERSATIONAL_CONTRACTS, + { dosePlan: PLAN }, + ).block, + ).toBe(false); + }); + it("holds a named medication to its own dose, not to any dose on the plan", () => { // 40 mg is on the plan, but for atorvastatin, not ramipril. expect( @@ -137,7 +174,7 @@ describe("screenModelOutput — dose exemption gated on the plan", () => { it("matches a comma-decimal planned dose (DE reader)", () => { const d = screenModelOutput( - "Nimm weiterhin deine 7,5 mg wie verordnet.", + "Nimm weiterhin dein Semaglutid 7,5 mg wie verordnet.", "de", CONVERSATIONAL_CONTRACTS, { dosePlan: PLAN }, diff --git a/src/lib/ai/safety/__tests__/outbound-screen.test.ts b/src/lib/ai/safety/__tests__/outbound-screen.test.ts index c0414bc10..e31c57899 100644 --- a/src/lib/ai/safety/__tests__/outbound-screen.test.ts +++ b/src/lib/ai/safety/__tests__/outbound-screen.test.ts @@ -290,7 +290,7 @@ describe("screenModelOutput — dose continuation-exclusion (D7)", () => { it("PASSES an adherence-supporting continuation of the planned dose", () => { const d = screenModelOutput( - "You should keep taking your prescribed 7.5 mg exactly as directed.", + "You should keep taking your prescribed semaglutide 7.5 mg exactly as directed.", "en", CONVERSATIONAL_CONTRACTS, { dosePlan: PLAN }, @@ -343,7 +343,7 @@ describe("screenModelOutput — dose continuation-exclusion (D7)", () => { // "du solltest … 7,5 mg" trips the DE consider/should dose pattern; the // "weiterhin … wie verordnet" maintenance anchor exempts it. const d = screenModelOutput( - "Du solltest weiterhin deine 7,5 mg wie verordnet einnehmen.", + "Du solltest weiterhin dein Semaglutid 7,5 mg wie verordnet einnehmen.", "de", CONVERSATIONAL_CONTRACTS, { dosePlan: PLAN }, @@ -796,7 +796,7 @@ describe("screenModelOutput — the dose-change class, every locale", () => { // On schedule: the maintenance anchor exempts it. expect( screenModelOutput( - "Take your 1000 mg as usual.", + "Take your metformin 1000 mg as usual.", "en", CONVERSATIONAL_CONTRACTS, { dosePlan: [{ name: "Metformin", dose: 1000 }] }, @@ -805,7 +805,7 @@ describe("screenModelOutput — the dose-change class, every locale", () => { // Off schedule: the same phrasing names a dose the user is not on. expect( screenModelOutput( - "Take your 2000 mg as usual.", + "Take your metformin 2000 mg as usual.", "en", CONVERSATIONAL_CONTRACTS, { dosePlan: [{ name: "Metformin", dose: 1000 }] }, diff --git a/src/lib/ai/safety/outbound-screen.ts b/src/lib/ai/safety/outbound-screen.ts index f0a314aac..ce81c2e74 100644 --- a/src/lib/ai/safety/outbound-screen.ts +++ b/src/lib/ai/safety/outbound-screen.ts @@ -315,8 +315,10 @@ export const INSIGHTS_CONTRACTS: readonly OutboundContract[] = [ * Continuation-exclusion for the dose bank (v1.32.7 — D7). A sentence that * matches a dose pattern is EXEMPT only when it is anchored to a maintenance * object ("keep taking … as prescribed"), carries no change stem, AND every - * amount it states is one the person is on for the medication it names. So - * "keep taking your prescribed 7.5 mg" passes for someone on 7.5 mg, while + * amount it states sits beside one of the person's medications, named, at + * exactly that planned dose. So "keep taking your semaglutide 7.5 mg as + * prescribed" passes for someone on semaglutide 7.5 mg, an unnamed "keep + * taking your 7.5 mg" does not, while * "you should keep in mind that trying 5 mg" (no maintenance anchor) and * "you should continue tapering to 2.4 mg" (a change stem present) stay * blocked. The direction is exclusion-of-continuation, never @@ -364,26 +366,53 @@ function sameDose(value: number, planned: number): boolean { const INN_STEM_RE = new RegExp(INN_STEM, "i"); /** - * True when every amount a continuation sentence states is on the person's - * plan for the medication it names. A sentence naming one of their - * medications is held to that medication's doses; one naming a drug outside - * the plan is never trusted; one naming none is held to the whole plan. + * Where one stated amount ends and the next begins: a comma before a space + * (a decimal comma, "7,5", is not one), a semicolon, "and" / "or". + */ +const CLAUSE_SPLIT = + /,(?=\s)|;|\s(?:and|und|en|et|y|e|i|oraz|plus|sowie|ou|o|or|oder)\s|그리고|및/i; + +/** + * True when every amount a continuation sentence states is paired with one + * of the person's medications, named beside it, at exactly that dose. Each + * clause that states an amount must name exactly one medication on the plan + * (as it is written on the plan, the only alias the screen can know), and + * every amount in it must be one of that medication's planned doses. An + * amount with no name beside it, a name the plan does not carry (a brand + * like "Eliquis" is simply not a plan name; a generic stem like "apixaban" + * is recognised and refused), or a dose that belongs to another medication + * on the plan ("ramipril 500 mg and metformin 5 mg") is never trusted. */ function continuationMatchesPlan( sentence: string, plan: readonly ScheduledDose[] | undefined, ): boolean { - const values = sentenceDoseValues(sentence); - if (values.length === 0 || !plan || plan.length === 0) return false; - const named = plan.filter((entry) => { + if (!plan || plan.length === 0) return false; + const matchers = plan.map((entry) => { const alternation = medicationNameAlternation([entry.name]); - return alternation !== null && new RegExp(alternation, "i").test(sentence); + return alternation === null ? null : new RegExp(alternation, "gi"); }); - if (named.length === 0 && INN_STEM_RE.test(sentence)) return false; - const pool = named.length > 0 ? named : plan; - return values.every((value) => - pool.some((entry) => sameDose(value, entry.dose)), - ); + let stated = 0; + for (const clause of sentence.split(CLAUSE_SPLIT)) { + const values = sentenceDoseValues(clause); + if (values.length === 0) continue; + stated += values.length; + const named = plan.filter((_, i) => matchers[i]?.test(clause) ?? false); + matchers.forEach((m) => m && (m.lastIndex = 0)); + const names = new Set(named.map((entry) => entry.name.toLowerCase())); + if (names.size !== 1) return false; + // A generic name left once the plan's own names are taken out is a drug + // the plan does not carry. + const rest = matchers.reduce( + (text, m) => (m ? text.replace(m, " ") : text), + clause, + ); + if (INN_STEM_RE.test(rest)) return false; + if (!values.every((v) => named.some((e) => sameDose(v, e.dose)))) { + return false; + } + } + return stated > 0; } /** True when a dose-change imperative trips, honouring the continuation rule. */ @@ -427,9 +456,10 @@ function doseTrips( export interface ScreenOptions { /** * The person's active medications with their dose magnitudes. A "keep - * taking your N mg" is trusted only when N is on this plan for the - * medication the sentence names; without the plan, a stated amount is never - * exempt. + * taking your X N mg" is trusted only when X is a medication on this plan, + * named beside the amount, planned at exactly N; an unnamed amount, a name + * the plan does not carry, or a dose of another medication is never + * exempt, and without the plan no stated amount is. */ dosePlan?: readonly ScheduledDose[]; /** diff --git a/src/lib/ai/safety/outbound-screen/locales/de.ts b/src/lib/ai/safety/outbound-screen/locales/de.ts index ce1203e6c..d46093db0 100644 --- a/src/lib/ai/safety/outbound-screen/locales/de.ts +++ b/src/lib/ai/safety/outbound-screen/locales/de.ts @@ -7,6 +7,9 @@ import { LETTER, ENGINE, medicationExtras, + unlessDeferred, + WORD_START, + WORD_END, type LocaleBank, } from "../shared"; @@ -59,6 +62,9 @@ const RISK_VERDICT_DE = `(?:ist|liegt|erscheint|wirkt|bleibt|zeigt|deutet\\s+auf const DE_MED_CLASS = "\\S*(?:blutverdünner\\w*|gerinnungshemmer\\w*|antikoagul\\w*|statin\\w*|betablocker\\w*|diuretik\\w*|wassertablette\\w*|blutdrucksenker\\w*|antidepressiv\\w*|schlaftablette\\w*)"; +/** Signals that the number-free change shapes stand down for (`unlessDeferred`). */ +const DE_DEFERRAL = `\\?|${WORD_START}(?:nicht|kein\\w*|nie|niemals|nur\\s*,?\\s*(?:wenn|falls|nach)|erst\\s*,?\\s*(?:wenn|nach)|ohne|frag\\w*|besprich\\w*|sprich\\s+mit|rücksprache|ärzt\\w*|arzt\\w*|apothek\\w*|hausärzt\\w*|diabetolog\\w*|fachperson\\w*|praxis)${WORD_END}`; + export const bank: LocaleBank = { vocabulary: (names) => `(?:${DE_MED_NOUN}|${medicationExtras(DE_MED_CLASS, names)})`, @@ -109,24 +115,36 @@ export const bank: LocaleBank = { // Hälfte", "nehmen Sie nur die halbe Dosis", "teilen Sie die Tablette in // zwei Hälften". "Teilen" alone also means "share", so the split needs // its "in Hälften". - new RegExp( - `\\b(?:reduzier(?:e|en)?|verringer(?:e|n)?|senk(?:e|en)?|kürz(?:e|en)?)(?:\\s+sie)?\\s+${DE_SLOT}${med}\\s+um\\s+(?:die\\s+)?hälfte`, - "i", + unlessDeferred( + DE_DEFERRAL, + new RegExp( + `\\b(?:reduzier(?:e|en)?|verringer(?:e|n)?|senk(?:e|en)?|kürz(?:e|en)?)(?:\\s+sie)?\\s+${DE_SLOT}${med}\\s+um\\s+(?:die\\s+)?hälfte`, + "i", + ), ), - new RegExp( - `\\b(?:nimm|nehmen\\s+sie|spritz|spritzen\\s+sie|injizier(?:e)?|injizieren\\s+sie)\\s+(?:nur\\s+)?(?:die\\s+|eine\\s+)?(?:halbe|hälfte\\s+(?:der|deiner|ihrer))\\s+${DE_SHORT}${med}`, - "i", + unlessDeferred( + DE_DEFERRAL, + new RegExp( + `\\b(?:nimm|nehmen\\s+sie|spritz|spritzen\\s+sie|injizier(?:e)?|injizieren\\s+sie)\\s+(?:nur\\s+)?(?:die\\s+|eine\\s+)?(?:halbe|hälfte\\s+(?:der|deiner|ihrer))\\s+${DE_SHORT}${med}`, + "i", + ), ), - new RegExp( - `\\bteil(?:e|en)?(?:\\s+sie)?\\s+${DE_SLOT}${med}\\s+(?:in\\s+(?:zwei\\s+)?hälften|in\\s+der\\s+mitte|durch\\s+zwei)`, - "i", + unlessDeferred( + DE_DEFERRAL, + new RegExp( + `\\bteil(?:e|en)?(?:\\s+sie)?\\s+${DE_SLOT}${med}\\s+(?:in\\s+(?:zwei\\s+)?hälften|in\\s+der\\s+mitte|durch\\s+zwei)`, + "i", + ), ), // A bare change imperative on a medication, no target: "reduzieren Sie // die Dosis.", "erhöhe dein Insulin etwas". The noun closes the clause or // meets a time or degree word; "reduziere die Dosis nicht" does neither. - new RegExp( - `\\b(?:erhöh(?:e|en)?|steiger(?:e|n)?|reduzier(?:e|en)?|senk(?:e|en)?|verringer(?:e|n)?)(?:\\s+sie)?\\s+${DE_SHORT}${med}(?=\\s*(?:[.!?;:)]|$)|\\s+(?:jetzt|heute|morgen|etwas|leicht|langsam|schrittweise|ab\\s+sofort|für|bis)\\b)`, - "i", + unlessDeferred( + DE_DEFERRAL, + new RegExp( + `\\b(?:erhöh(?:e|en)?|steiger(?:e|n)?|reduzier(?:e|en)?|senk(?:e|en)?|verringer(?:e|n)?)(?:\\s+sie)?\\s+${DE_SHORT}${med}(?=\\s*(?:[.!?;:)]|$)|\\s+(?:jetzt|heute|morgen|etwas|leicht|langsam|schrittweise|ab\\s+sofort|für|bis)\\b)`, + "i", + ), ), ], dose: [ diff --git a/src/lib/ai/safety/outbound-screen/locales/en.ts b/src/lib/ai/safety/outbound-screen/locales/en.ts index 60c57a151..b51f765a7 100644 --- a/src/lib/ai/safety/outbound-screen/locales/en.ts +++ b/src/lib/ai/safety/outbound-screen/locales/en.ts @@ -6,6 +6,7 @@ import { TARGET, ENGINE, medicationExtras, + unlessDeferred, type LocaleBank, } from "../shared"; @@ -67,6 +68,9 @@ const RISK_VERDICT_EN = `(?:is|are|looks?|appears?|seems?|sits?|remains?|comes?\ const EN_MED_CLASS = "(?:blood[- ]?thinners?|anti-?coagulants?|anti-?platelets?|statins?|beta[- ]?blockers?|diuretics?|water\\s+pills?|ace\\s+inhibitors?|steroids?|antidepressants?|sleeping\\s+pills?)\\b"; +/** Signals that the number-free change shapes stand down for (`unlessDeferred`). */ +const EN_DEFERRAL = `\\?|\\b(?:not|never|don'?t|doesn'?t|no|unless|only\\s+(?:if|when|after)|without|ask|discuss|talk\\s+to|check\\s+with|doctor|physician|gp|pharmacist|prescriber|clinician|nurse|specialist|care\\s+team|diabetes\\s+team)\\b`; + export const bank: LocaleBank = { vocabulary: (names) => `(?:${EN_MED_NOUN}|${medicationExtras(EN_MED_CLASS, names)})`, @@ -117,21 +121,30 @@ export const bank: LocaleBank = { ), // Halving or a fraction with no number: "reduce your dose by half", // "take half a dose". A half hour or half your sugar names no medication. - new RegExp( - `${EN_LEAD}(?:reduce|cut|lower|decrease|drop|split|break|divide)\\s+${EN_SHORT}${med}\\s+(?:by|in)\\s+(?:a\\s+)?half\\b`, - "im", + unlessDeferred( + EN_DEFERRAL, + new RegExp( + `${EN_LEAD}(?:reduce|cut|lower|decrease|drop|split|break|divide)\\s+${EN_SHORT}${med}\\s+(?:by|in)\\s+(?:a\\s+)?half\\b`, + "im", + ), ), - new RegExp( - `${EN_LEAD}(?:take|use|inject|give\\s+yourself)\\s+(?:only\\s+|just\\s+)?half\\s+(?:of\\s+)?(?:(?:a|an|the|your)\\s+)?${EN_SHORT}${med}`, - "im", + unlessDeferred( + EN_DEFERRAL, + new RegExp( + `${EN_LEAD}(?:take|use|inject|give\\s+yourself)\\s+(?:only\\s+|just\\s+)?half\\s+(?:of\\s+)?(?:(?:a|an|the|your)\\s+)?${EN_SHORT}${med}`, + "im", + ), ), // A bare change imperative on a medication, no target: "reduce your // dose.", "increase your insulin slightly". The medication noun has to // close the clause or meet a time or degree word, so "reduce your dose of // stress" and "cut your medication costs" are not read as one. - new RegExp( - `${EN_LEAD}(?:increase|raise|up|boost|bump|reduce|lower|decrease|cut|drop)\\s+${EN_SHORT}${med}(?=\\s*(?:[.!?;:)]|$)|\\s+(?:now|today|tonight|tomorrow|a\\s+(?:bit|little)|slightly|gradually|somewhat|this\\s+week|for|until|from\\s+now)\\b)`, - "im", + unlessDeferred( + EN_DEFERRAL, + new RegExp( + `${EN_LEAD}(?:increase|raise|up|boost|bump|reduce|lower|decrease|cut|drop)\\s+${EN_SHORT}${med}(?=\\s*(?:[.!?;:)]|$)|\\s+(?:now|today|tonight|tomorrow|a\\s+(?:bit|little)|slightly|gradually|somewhat|this\\s+week|for|until|from\\s+now)\\b)`, + "im", + ), ), ], dose: [ diff --git a/src/lib/ai/safety/outbound-screen/locales/es.ts b/src/lib/ai/safety/outbound-screen/locales/es.ts index 8d59daf52..78222eeee 100644 --- a/src/lib/ai/safety/outbound-screen/locales/es.ts +++ b/src/lib/ai/safety/outbound-screen/locales/es.ts @@ -7,6 +7,9 @@ import { LETTER, ENGINE, medicationExtras, + unlessDeferred, + WORD_START, + WORD_END, type LocaleBank, } from "../shared"; @@ -35,6 +38,9 @@ const RESULT_VERB_ES = const ES_MED_CLASS = "(?:anticoagulantes?|antiagregantes?|estatinas?|betabloqueantes?|diuréticos?|antidepresivos?)"; +/** Signals that the number-free change shapes stand down for (`unlessDeferred`). */ +const ES_DEFERRAL = `\\?|¿|${WORD_START}(?:no|nunca|sin|solo\\s+si|sólo\\s+si|(?:antes\\s+de|hasta|después\\s+de|conviene|si)\\s+que|consult\\w*|pregunt\\w*|hable\\s+con|médic[oa]s?|doctor[a]?|farmac[ée]utic[oa]|enfermer[oa])${WORD_END}`; + export const bank: LocaleBank = { vocabulary: (names) => `(?:${ES_MED_NOUN}|${medicationExtras(ES_MED_CLASS, names)})`, @@ -67,19 +73,28 @@ export const bank: LocaleBank = { ), // Halving or a fraction with no number: "reduzca la dosis a la mitad", // "tome media dosis", "tome la mitad de la dosis". - new RegExp( - `(? `${ELIDED}(?:${FR_MED_NOUN}|${medicationExtras(FR_MED_CLASS, names)})`, @@ -71,19 +76,28 @@ export const bank: LocaleBank = { ), // Halving or a fraction with no number: "réduisez la dose de moitié", // "prenez une demi-dose", "coupez le comprimé en deux". - new RegExp( - `(? `${ELIDED}(?:${IT_MED_NOUN}|${medicationExtras(IT_MED_CLASS, names)})`, @@ -63,19 +68,28 @@ export const bank: LocaleBank = { ), // Halving or a fraction with no number: "riduca la dose della metà", // "prenda mezza dose", "prenda metà della dose". - new RegExp( - `(? `(?:${KO_MED_NOUN}|(?:${medicationExtras(KO_MED_CLASS, names, false)})\\S{0,3})`, @@ -74,8 +78,11 @@ export const bank: LocaleBank = { // "복용량을 늘리세요", "인슐린 용량을 낮추세요". No digit may sit between, // so "약 2주 동안 걷는 시간을 늘리세요" ("약" = about) passes, and the // prohibitive "줄이지 마세요" never meets an imperative ending. - new RegExp( - `${med}[^.?!\\d]{0,8}?(?:(?:늘리|올리|높이|줄이|내리|낮추|증량하|감량하)(?:세요|십시오)|(?:늘려|올려|높여|줄여|내려|낮춰)\\s*(?:보세요|주세요|보십시오))`, + unlessDeferred( + KO_DEFERRAL, + new RegExp( + `${med}[^.?!\\d]{0,8}?(?:(?:늘리|올리|높이|줄이|내리|낮추|증량하|감량하)(?:세요|십시오)|(?:늘려|올려|높여|줄여|내려|낮춰)\\s*(?:보세요|주세요|보십시오))`, + ), ), ], // Korean is verb-final: the dose comes first and the change verb closes the @@ -106,7 +113,12 @@ export const bank: LocaleBank = { ), // Both a medication object AND a trial cue are required, as in the EN bank, // so "걸음 수를 두 배로 늘려 2주간 해보세요" never trips. - /(?:약|알|정|인슐린|주사|복용|용량)[^.?!]{0,25}(?:절반|반으로|두\s?배|건너뛰|중단|끊)[^.?!]{0,40}(?:주간|주 동안|일 동안|달 동안|보세요|보십시오|보자|해 ?보)/, + // Number-free too, so it stands down where a pharmacist or doctor is + // asked ("약사에게 … 물어보세요" ends on the same "보세요"). + unlessDeferred( + KO_DEFERRAL, + /(?:약|알|정|인슐린|주사|복용|용량)[^.?!]{0,25}(?:절반|반으로|두\s?배|건너뛰|중단|끊)[^.?!]{0,40}(?:주간|주 동안|일 동안|달 동안|보세요|보십시오|보자|해 ?보)/, + ), ], risk: [ new RegExp(`${RISK_NOUN_KO}[^.?!]{0,15}${QUAL_NUM_KO}`, "i"), diff --git a/src/lib/ai/safety/outbound-screen/locales/nl.ts b/src/lib/ai/safety/outbound-screen/locales/nl.ts index b687a6a77..1b1160b7d 100644 --- a/src/lib/ai/safety/outbound-screen/locales/nl.ts +++ b/src/lib/ai/safety/outbound-screen/locales/nl.ts @@ -9,6 +9,8 @@ import { ENGINE, WORD_START, medicationExtras, + unlessDeferred, + WORD_END, type LocaleBank, } from "../shared"; @@ -50,6 +52,9 @@ const RISK_VERDICT_NL = `(?:is|ligt|lijkt|blijft|wijst\\s+op|komt\\s+uit\\s+op)\ const NL_MED_CLASS = "\\S*(?:bloedverdunner\\w*|antistolling\\w*|anticoagul\\w*|plaatjesremmer\\w*|statine\\w*|b[eè]tablokker\\w*|plastablet\\w*|diuretic\\w*|diuretica|antidepressiv\\w*|antidepressiva|slaappil\\w*|slaapmiddel\\w*|bloeddrukverlager\\w*|ace-remmer\\w*)"; +/** Signals that the number-free change shapes stand down for (`unlessDeferred`). */ +const NL_DEFERRAL = `\\?|${WORD_START}(?:niet|geen|nooit|alleen\\s*,?\\s*(?:als|wanneer|na)|pas\\s*,?\\s*(?:als|na)|zonder|vraag|overleg\\w*|bespreek|arts|artsen|huisarts|apothe\\w*|specialist|dokter|zorgverlener\\w*)${WORD_END}`; + export const bank: LocaleBank = { vocabulary: (names) => `(?:${NL_MED_NOUN}|${medicationExtras(NL_MED_CLASS, names)})`, @@ -113,19 +118,28 @@ export const bank: LocaleBank = { ), // Halving or a fraction with no number: "verlaag je dosis met de helft", // "neem een halve dosis", "snijd de tablet doormidden". - new RegExp( - `(? `(?:${PL_MED_NOUN}|${medicationExtras(PL_MED_CLASS, names)})`, @@ -61,20 +67,29 @@ export const bank: LocaleBank = { ), // Halving or a fraction with no number: "zmniejsz dawkę o połowę", // "weź połowę dawki", "przyjmij pół dawki". - new RegExp( - `(? `(?:${PT_MED_NOUN}|${medicationExtras(PT_MED_CLASS, names)})`, @@ -94,19 +100,28 @@ export const bank: LocaleBank = { ), // Halving or a fraction with no number: "reduza a dose pela metade", // "tome meia dose", "corte o comprimido ao meio". - new RegExp( - `(?