From dcb2598bb248ee546a45df7ae1b803dfaa4e4357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 15:26:37 +0200 Subject: [PATCH] refactor(ai): keep each language's outbound-screen banks in its own module outbound-screen.ts held every locale's dose, risk, causal, continuation and change-stem patterns plus the shared units and slot builder, and grew with each language. The banks now live in outbound-screen/locales/.ts, one per language, each exporting the same LocaleBank shape; the units, target-dose shape, object slot and generic-name stems are in outbound-screen/shared.ts. The screen composes them per locale and keeps the matching logic, the continuation rule and the general rationale. No pattern changed: compiling the old and the new module and comparing every bank of every locale, with and without a medication-name list, gives the same regular expressions (108 of 108 identical), and the screen tests still block and pass the same sentences in every locale. The screen is 457 lines instead of 1,597, and buildDoseChangeClass is a four-line composition. --- src/lib/ai/safety/outbound-screen.ts | 1223 +---------------- .../ai/safety/outbound-screen/locales/de.ts | 178 +++ .../ai/safety/outbound-screen/locales/en.ts | 189 +++ .../ai/safety/outbound-screen/locales/es.ts | 115 ++ .../ai/safety/outbound-screen/locales/fr.ts | 124 ++ .../ai/safety/outbound-screen/locales/it.ts | 109 ++ .../ai/safety/outbound-screen/locales/ko.ts | 140 ++ .../ai/safety/outbound-screen/locales/nl.ts | 179 +++ .../ai/safety/outbound-screen/locales/pl.ts | 110 ++ .../safety/outbound-screen/locales/pt-br.ts | 161 +++ src/lib/ai/safety/outbound-screen/shared.ts | 137 ++ 11 files changed, 1486 insertions(+), 1179 deletions(-) create mode 100644 src/lib/ai/safety/outbound-screen/locales/de.ts create mode 100644 src/lib/ai/safety/outbound-screen/locales/en.ts create mode 100644 src/lib/ai/safety/outbound-screen/locales/es.ts create mode 100644 src/lib/ai/safety/outbound-screen/locales/fr.ts create mode 100644 src/lib/ai/safety/outbound-screen/locales/it.ts create mode 100644 src/lib/ai/safety/outbound-screen/locales/ko.ts create mode 100644 src/lib/ai/safety/outbound-screen/locales/nl.ts create mode 100644 src/lib/ai/safety/outbound-screen/locales/pl.ts create mode 100644 src/lib/ai/safety/outbound-screen/locales/pt-br.ts create mode 100644 src/lib/ai/safety/outbound-screen/shared.ts diff --git a/src/lib/ai/safety/outbound-screen.ts b/src/lib/ai/safety/outbound-screen.ts index daadc593c..26f84a6b6 100644 --- a/src/lib/ai/safety/outbound-screen.ts +++ b/src/lib/ai/safety/outbound-screen.ts @@ -40,7 +40,23 @@ * surface is `insights`. Callers name the contracts they want; there is no * "screen everything" default that would silently widen a surface's contract. */ -import type { Locale } from "@/lib/i18n/config"; +import { locales, type Locale } from "@/lib/i18n/config"; + +import { bank as en } from "./outbound-screen/locales/en"; +import { bank as de } from "./outbound-screen/locales/de"; +import { bank as fr } from "./outbound-screen/locales/fr"; +import { bank as es } from "./outbound-screen/locales/es"; +import { bank as it } from "./outbound-screen/locales/it"; +import { bank as pl } from "./outbound-screen/locales/pl"; +import { bank as nl } from "./outbound-screen/locales/nl"; +import { bank as ko } from "./outbound-screen/locales/ko"; +import { bank as ptBR } from "./outbound-screen/locales/pt-br"; +import { + DOSE_UNIT, + LETTER, + UNIT_END, + type LocaleBank, +} from "./outbound-screen/shared"; /** Which contract a caller asks the screen to enforce. */ export type OutboundContract = "dose" | "risk" | "causal"; @@ -56,188 +72,25 @@ 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|druppels?|pillen|pufjes?|pastilhas?|dr[áa]geas?|jatos?|borrifadas?)"; - -/** - * 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|ui|i\\.u\\.|units?|unidades?|unità|unités?|jednostek|jednostki|eenheden|eenheid|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 locales use (`eenheden` is the Dutch one, `ui` - * the Portuguese abbreviation of "unidades internacionais"). - */ -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, maxWords = 4): string { - return `(?:(?!(?:${stopWords})(?=\\s))[^\\s\\d.?!,;:]+\\s+){0,${maxWords}}?`; -} - -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 NL_STOP = - "niet|nooit|geen|en|of|maar|als|wanneer|natrium|zout|cafeïne|suiker\\S*|water|vocht|eiwit\\S*|vezels?|koffie|alcohol|stappen|calorieën"; -const PT_STOP = - "não|nao|nunca|jamais|e|ou|mas|se|sódio|sal|cafeína|açúcar\\S*|água|proteínas?|fibras?|café|álcool|passos|calorias"; - -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); -const NL_SLOT = objectSlot(NL_STOP); -const PT_SLOT = objectSlot(PT_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_SHORT = objectSlot( - `${DE_STOP}|als|zu|mit|für|gegen|statt|ohne|über`, - 2, -); -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); -const NL_SHORT = objectSlot( - `${NL_STOP}|om|voor|met|bij|in|op|van|naar|tot|na|zonder|tijdens|over|dan|te`, - 2, -); -const PT_SHORT = objectSlot( - `${PT_STOP}|para|pra|com|antes|depois|sem|a|ao|à|de|do|da|em|no|na|por|durante|sobre`, - 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ół|één|twee|drie|vier|vijf|zes|halve|dois|duas|três|quatro|meia|meio)"; - -/** A target dose: a number, a drug unit, and an honest unit end. */ -const TARGET = `(?:[\\d.,]+\\s*${DRUG_UNIT}${UNIT_END}|(? = { - en: "(?:blood[- ]?thinners?|anti-?coagulants?|anti-?platelets?|statins?|beta[- ]?blockers?|diuretics?|water\\s+pills?|ace\\s+inhibitors?|steroids?|antidepressants?|sleeping\\s+pills?)\\b", - de: "\\S*(?:blutverdünner\\w*|gerinnungshemmer\\w*|antikoagul\\w*|statin\\w*|betablocker\\w*|diuretik\\w*|wassertablette\\w*|blutdrucksenker\\w*|antidepressiv\\w*|schlaftablette\\w*)", - fr: "(?:anticoagulants?|antiagrégants?|statines?|bêta-?bloquants?|diurétiques?|antidépresseurs?|somnifères?)", - es: "(?:anticoagulantes?|antiagregantes?|estatinas?|betabloqueantes?|diuréticos?|antidepresivos?)", - it: "(?:anticoagulant[ei]|antiaggregant[ei]|statin[ae]|betabloccant[ei]|diuretic[oi]|antidepressiv[oi])", - pl: "(?:lek\\S*\\s+przeciwzakrzepow\\S*|antykoagulant\\S*|statyn\\S*|beta-?bloker\\S*|diuretyk\\S*|lek\\S*\\s+moczopędn\\S*|antydepresant\\S*)", - nl: "\\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*)", - ko: "(?:혈액\\s*희석제|항응고제|항혈소판제|스타틴|베타\\s*차단제|이뇨제|항우울제)", - "pt-BR": - "(?:anticoagulantes?|antiagregantes?|estatinas?|beta-?bloqueadores?|diur[ée]ticos?|antidepressivos?|anti-?hipertensivos?|sonífer[oa]s?|rem[ée]dios?\\s+(?:para\\s+(?:a\\s+)?|de\\s+)(?:press[ãa]o|dormir|afinar\\s+o\\s+sangue))", +/** Every language's banks, one module each under `./outbound-screen/locales/`. */ +const LOCALE_BANKS: Readonly> = { + en, + de, + fr, + es, + it, + pl, + nl, + ko, + "pt-BR": ptBR, }; -/** - * 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 = `(?(pick: (bank: LocaleBank) => T): Record { + return Object.fromEntries( + locales.map((locale) => [locale, pick(LOCALE_BANKS[locale])]), + ) as Record; +} /** * Words a schedule entry's first token may not stand for on its own: a name @@ -303,49 +156,6 @@ function medicationNameAlternation( return `(?; - -/** - * Build each locale's medication noun from the dosage-form words, the class - * words, the generic stems and the person's own names. An elided article - * ("l'amlodipine", "dell'insulina") may sit in front of a French or Italian - * noun. - */ -function medVocabulary(names: string | null): MedVocabulary { - const extra = (locale: Locale) => - [MED_CLASS[locale], locale === "ko" ? null : INN_STEM, names] - .filter((part): part is string => part !== null) - .join("|"); - const elided = "(?:[a-z]{1,4}['’])?"; - return { - en: `(?:${EN_MED_NOUN}|${extra("en")})`, - de: `(?:${DE_MED_NOUN}|${extra("de")})`, - fr: `${elided}(?:${FR_MED_NOUN}|${extra("fr")})`, - es: `(?:${ES_MED_NOUN}|${extra("es")})`, - it: `${elided}(?:${IT_MED_NOUN}|${extra("it")})`, - pl: `(?:${PL_MED_NOUN}|${extra("pl")})`, - nl: `(?:${NL_MED_NOUN}|${extra("nl")})`, - ko: `(?:${KO_MED_NOUN}|(?:${extra("ko")})\\S{0,3})`, - "pt-BR": `(?:${PT_MED_NOUN}|${extra("pt-BR")})`, - }; -} - -/** - * 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})|(?:한|두|세|네|반)\\s*(?:알(?!레)|정(?!상|도|확|말)|캡슐))`; - -/** - * 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*(?:해요|합니다)))"; - /** * Dose-prescription banks. Each entry requires a CHANGE verb plus a target * dose with a unit, so a permitted factual restatement does not match. @@ -357,16 +167,6 @@ const KO_IMPERATIVE = * "NIGDY nie zalecać konkretnej wartości") plus the standard clinical * titration verbs of each language. */ -/** - * The object a German change verb may carry before its target ("erhöhe - * deine Dosis auf 2,4 mg"). English allows "your dose" in the same slot; - * without it the German imperative with an object slipped through. - */ -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 @@ -406,323 +206,13 @@ const DE_COUNT = "ein(?:e|en)?|zwei|drei|vier|halbe|\\d+(?:[.,]\\d+)?"; * blocked, the same accepted fail-safe residual as "discussing the 2.4 mg step". */ function buildDoseChangeClass( - m: MedVocabulary, + names: string | null, ): Record { - 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( - `(? bank.doseChangeClass(bank.vocabulary(names))); } /** The class with no schedule: dosage forms, class words and generic stems. */ -const DOSE_CHANGE_CLASS = buildDoseChangeClass(medVocabulary(null)); +const DOSE_CHANGE_CLASS = buildDoseChangeClass(null); /** * The class built over one person's medication names. A turn screens its reply @@ -739,7 +229,7 @@ function doseChangeClassFor( if (names === null) return DOSE_CHANGE_CLASS; const cached = NAMED_CLASS_CACHE.get(names); if (cached) return cached; - const built = buildDoseChangeClass(medVocabulary(names)); + const built = buildDoseChangeClass(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); @@ -748,211 +238,7 @@ function doseChangeClassFor( return built; } -const DOSE_PATTERNS: Record = { - 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}${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}${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}${UNIT_END}`, - "i", - ), - // Experiment-shaped dose changes the "to/by N unit" patterns miss. BOTH a - // medication object AND a trial cue are required, so a benign behavioural - // experiment ("double your steps for two weeks") never trips. - /\b(?:halv\w*|doubl\w*|skip\w*|stop\s+taking|quit\s+taking|come\s+off)\b[^.?!]{0,25}\b(?:dose|doses|pill|pills|tablet|tablets|medication|meds?|insulin|injection)\b[^.?!]{0,40}\b(?:for\s+\w+\s+(?:day|days|week|weeks|month|months)|to\s+(?:see|test|try|check)|next\s+(?:week|month))\b/i, - ], - 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}${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}${UNIT_END}`, - "i", - ), - new RegExp( - `\\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}${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}${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, - ], - 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}${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}${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}${UNIT_END}`, - "i", - ), - new RegExp( - `\\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}${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}${UNIT_END}`, - "i", - ), - new RegExp( - `\\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}${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}${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}${UNIT_END}`, - "i", - ), - new RegExp( - `\\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}${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}${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}${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}${UNIT_END}`, - "i", - ), - new RegExp( - `\\b(?:nast[ęe]pna|kolejna|nowa)\\s+dawka\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, - "i", - ), - ], - nl: [ - // verhoog / ga omhoog / bouw op naar|tot|met N unit - new RegExp( - `\\b(?:verhoog|verhogen|ophogen|ga\\s+omhoog|bouw\\s+op|opbouwen)(?:\\s+(?:je|jouw|de|uw)\\s+(?:dosis|dosering))?\\s+(?:naar|tot|met)\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, - "i", - ), - // verlaag / verminder / bouw af naar|tot|met N unit - new RegExp( - `\\b(?:verlaag|verlagen|verminder|verminderen|ga\\s+omlaag|bouw\\s+af|afbouwen)(?:\\s+(?:je|jouw|de|uw)\\s+(?:dosis|dosering))?\\s+(?:naar|tot|met)\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, - "i", - ), - // A recommending cue in front of a drug amount. `ml` is left out: "probeer - // 2000 ml water per dag" is hydration advice. - new RegExp( - `\\b(?:overweeg|probeer|je\\s+zou|u\\s+zou|ik\\s+raad|ik\\s+adviseer|ik\\s+stel\\s+voor)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DRUG_UNIT}${UNIT_END}`, - "i", - ), - new RegExp( - `\\b(?:volgende|nieuwe)\\s+(?:dosis|stap)\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, - "i", - ), - /\b(?:halveer|halveren|verdubbel|verdubbelen|sla\s+\S+\s+over|overslaan|stop\s+met)\b[^.?!]{0,25}(?:dosis|tablet\w*|medicijn\w*|medicatie|insuline|injectie\w*)[^.?!]{0,40}\b(?:(?:een|twee|drie|vier|\d+)\s+(?:dag|dagen|week|weken|maand|maanden)|om\s+te\s+(?:zien|testen|kijken|proberen))\b/i, - ], - "pt-BR": [ - // aumente / suba / passe a dose para|a|até|em N unit - new RegExp( - `\\b(?:aumente|aumentar|suba|subir|passe|passar|eleve|elevar)\\s+(?:a\\s+|sua\\s+|a\\s+sua\\s+)?(?:dose\\s+)?(?:para|pra|a|até|em)\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, - "i", - ), - // reduza / diminua / baixe a dose para|a|até|em N unit - new RegExp( - `\\b(?:reduza|reduzir|diminua|diminuir|baixe|baixar|abaixe|abaixar)\\s+(?:a\\s+|sua\\s+|a\\s+sua\\s+)?(?:dose\\s+)?(?:para|pra|a|até|em)\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, - "i", - ), - // A recommending cue in front of a drug amount. `ml` is left out: "tente - // beber 2000 ml de água por dia" is hydration advice. - new RegExp( - `\\b(?:considere|experimente|tente|voc[êe]\\s+deveria|voc[êe]\\s+deve|recomendo|sugiro)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DRUG_UNIT}${UNIT_END}`, - "i", - ), - new RegExp( - `\\b(?:pr[óo]xima|nova)\\s+(?:dose|etapa)\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, - "i", - ), - /\b(?:dobr\w*|duplic\w*|pul(?:e|ar)|suspend\w*|interromp\w*|pare\s+de\s+tomar|parar\s+de\s+tomar)\b[^.?!]{0,25}\b(?:doses?|comprimidos?|rem[ée]dios?|medicamentos?|medica[çc]|insulina|inje[çc])[^.?!]{0,40}\b(?:por\s+(?:\d+|um|uma|dois|duas|tr[êe]s|quatro)\s+(?:dias?|semanas?|m[êe]s|meses)|para\s+(?:ver|testar|experimentar))\b/i, - ], - // Korean is verb-final: the dose comes first and the change verb closes the - // clause ("2.4mg으로 늘려 보세요"), the mirror image of the Latin banks. A - // target-marking particle on the dose is required, the same demand the Latin - // banks make of "to" / "by" / "auf" — without it the permitted restatement - // "7.5mg을 복용 중이고, 체중은 내려가고 있어요" matched the lowering pattern. - ko: [ - new RegExp( - `[\\d.,]+\\s*${DOSE_UNIT}\\s*(?:으로|로|까지)[^.?!]{0,20}(?:올리|올려|늘리|늘려|증량|높이|높여|인상)`, - "i", - ), - new RegExp( - `[\\d.,]+\\s*${DOSE_UNIT}\\s*(?:으로|로|까지)[^.?!]{0,20}(?:내리|내려|줄이|줄여|감량|낮추|낮춰|감축)`, - "i", - ), - new RegExp( - `(?:권해|권장|권고|추천|제안)[^.?!]{0,40}[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, - "i", - ), - new RegExp( - `[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}[^.?!]{0,40}(?:권해|권장|권고|추천|제안|드심|드셔|복용하세요|시도해)`, - "i", - ), - new RegExp( - `(?:다음|새)\\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, - // so "걸음 수를 두 배로 늘려 2주간 해보세요" never trips. - /(?:약|알|정|인슐린|주사|복용|용량)[^.?!]{0,25}(?:절반|반으로|두\s?배|건너뛰|중단|끊)[^.?!]{0,40}(?:주간|주 동안|일 동안|달 동안|보세요|보십시오|보자|해 ?보)/, - ], -}; +const DOSE_PATTERNS = perLocale((bank) => bank.dose); /** * Risk-score fabrication banks. The model is grounded on a server-computed @@ -976,42 +262,6 @@ const DOSE_PATTERNS: Record = { * deliberately no refusal-context exemption (it was a hedge-then-assert bypass: * "I can't compute your ASCVD, but your risk is about 14%"). */ -// Spelled-out cardinal numbers (0–99) so a digit-less "twelve percent" still -// counts as a fabricated figure, not an educational aside. -const SPELLED_EN = - "(?:zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety)(?:[- ](?:one|two|three|four|five|six|seven|eight|nine))?"; -const PCT_WORD_EN = `${SPELLED_EN}\\s+(?:percent|per\\s+cent)`; -const QUAL_NUM_EN = `(?:\\d{1,3}\\s*%|${PCT_WORD_EN}|score\\s+of\\s+\\d)`; -const RISK_NOUN_EN = "(?:risk|chance|probability|likelihood)"; -const ENGINE = "(?:framingham|ascvd|score2|qrisk)"; -const HORIZON_EN = - "(?:10[- ]year|ten[- ]year|lifetime)\\s+(?:cardiovascular|cardiac|heart|stroke|mortality|cvd|ascvd)\\s+risk"; -const RESULT_VERB_EN = - "(?:puts?\\s+you|would\\s+put\\s+you|placing\\s+you|places?\\s+you|you\\s+fall|you'?d\\s+fall|classif\\w*\\s+you)"; -const RISK_BAND_EN = - "(?:high|higher|intermediate|elevated|moderate|low|borderline)[- ]?risk\\s+(?:band|category|group|range)"; -// A categorical VERDICT on the engine / horizon itself ("your 10-year risk IS -// elevated") — a fabricated result with no digits. The horizon phrase and a -// named engine are inherently about-this-user, so an attached risk-level -// adjective is an assertion, not education. A model-perfect refusal names the -// horizon but attaches no such adjective, so it still passes. -const RISK_LEVEL_EN = - "(?:elevated|high|higher|intermediate|moderate|borderline|raised|concerning|significant)"; -const RISK_VERDICT_EN = `(?:is|are|looks?|appears?|seems?|sits?|remains?|comes?\\s+back|runs?|suggests?|indicat\\w*|shows?|reflects?|points?\\s+to)\\s+(?:\\w+\\s+){0,2}${RISK_LEVEL_EN}`; -const SPELLED_DE = - "(?:null|eins?|zwei|drei|vier|fünf|sechs|sieben|acht|neun|zehn|elf|zwölf|dreizehn|vierzehn|fünfzehn|sechzehn|siebzehn|achtzehn|neunzehn|zwanzig|dreißig|vierzig|fünfzig|sechzig|siebzig|achtzig|neunzig)"; -const PCT_WORD_DE = `${SPELLED_DE}\\s+prozent`; -const QUAL_NUM_DE = `(?:\\d{1,3}\\s*%|${PCT_WORD_DE})`; -const RISK_NOUN_DE = "(?:risiko|wahrscheinlichkeit|chance)"; -const HORIZON_DE = "(?:10[- ]jahres|zehn[- ]jahres|lebenszeit)[- ]?risiko"; -const RESULT_VERB_DE = - "(?:ordnet\\s+(?:dich|sie)\\s+ein|stuft\\s+(?:dich|sie)\\s+ein|f[äa]llst\\s+in|einordnen|liegst\\s+im)"; -const RISK_BAND_DE = - "(?:hoh|niedrig|mittler|erhöht|moderat|gering)\\w*[- ]?risiko(?:bereich|kategorie|gruppe|band)"; -const RISK_LEVEL_DE = - "(?:erhöht|hoch|höher|mittel|mäßig|grenzwertig|besorgniserregend|deutlich)"; -const RISK_VERDICT_DE = `(?:ist|liegt|erscheint|wirkt|bleibt|zeigt|deutet\\s+auf|weist\\s+auf)\\s+(?:\\w+\\s+){0,2}${RISK_LEVEL_DE}`; - /* * v1.32.7 narrowed EN + DE only; v1.32.9 (Coach Guard II / B.6) brings fr / es * / it / pl to parity: a spelled-out percent counts as a fabricated figure, the @@ -1021,261 +271,7 @@ const RISK_VERDICT_DE = `(?:ist|liegt|erscheint|wirkt|bleibt|zeigt|deutet\\s+auf * shipped stay. Spelled-out cardinals cover 0–19 + the tens a realistic risk * percentage uses. */ -const SPELLED_FR = - "(?:zéro|un|deux|trois|quatre|cinq|six|sept|huit|neuf|dix|onze|douze|treize|quatorze|quinze|seize|dix-sept|dix-huit|dix-neuf|vingt|trente|quarante|cinquante|soixante)"; -const PCT_WORD_FR = `${SPELLED_FR}\\s+(?:pour\\s+cent|pour-cent)`; -const QUAL_NUM_FR = `(?:\\d{1,3}\\s*%|${PCT_WORD_FR})`; -const RISK_NOUN_FR = "(?:risque|probabilité|chance)"; -const RESULT_VERB_FR = - "(?:vous\\s+(?:met|place|situe|classe|met\\s+dans)|vous\\s+êtes\\s+(?:dans|classé)|vous\\s+tombez\\s+dans)"; -const SPELLED_ES = - "(?:cero|uno|dos|tres|cuatro|cinco|seis|siete|ocho|nueve|diez|once|doce|trece|catorce|quince|dieciséis|diecisiete|dieciocho|diecinueve|veinte|treinta|cuarenta|cincuenta|sesenta)"; -const PCT_WORD_ES = `${SPELLED_ES}\\s+por\\s+ciento`; -const QUAL_NUM_ES = `(?:\\d{1,3}\\s*%|${PCT_WORD_ES})`; -const RISK_NOUN_ES = "(?:riesgo|probabilidad)"; -const RESULT_VERB_ES = - "(?:lo\\s+(?:coloca|sitúa|clasifica|pone)|le\\s+(?:coloca|sitúa|clasifica)|se\\s+encuentra\\s+en|está\\s+en\\s+(?:la\\s+)?categor|cae\\s+en)"; -const SPELLED_IT = - "(?:zero|uno|due|tre|quattro|cinque|sei|sette|otto|nove|dieci|undici|dodici|tredici|quattordici|quindici|sedici|diciassette|diciotto|diciannove|venti|trenta|quaranta|cinquanta|sessanta)"; -const PCT_WORD_IT = `${SPELLED_IT}\\s+per\\s+cento`; -const QUAL_NUM_IT = `(?:\\d{1,3}\\s*%|${PCT_WORD_IT})`; -const RISK_NOUN_IT = "(?:rischio|probabilità)"; -const RESULT_VERB_IT = - "(?:la\\s+(?:colloca|mette|classifica|pone)|rientra\\s+(?:in|nella)|si\\s+trova\\s+(?:in|nella)|ricade\\s+in)"; -const SPELLED_PL = - "(?:zero|jeden|dwa|trzy|cztery|pięć|sześć|siedem|osiem|dziewięć|dziesięć|jedenaście|dwanaście|trzynaście|czternaście|piętnaście|szesnaście|siedemnaście|osiemnaście|dziewiętnaście|dwadzieścia|trzydzieści|czterdzieści|pięćdziesiąt|sześćdziesiąt)"; -const PCT_WORD_PL = `${SPELLED_PL}\\s+procent`; -const QUAL_NUM_PL = `(?:\\d{1,3}\\s*%|${PCT_WORD_PL})`; -const RISK_NOUN_PL = "(?:ryzyk\\w*|prawdopodobieństw\\w*)"; -const RESULT_VERB_PL = - "(?:umieszcza\\s+(?:cię|pana|panią)|klasyfikuje\\s+(?:cię|pana|panią)|znajdujesz\\s+się\\s+w|wpadasz\\s+w|kwalifikuje\\s+(?:cię|pana|panią))"; - -const SPELLED_NL = - "(?:nul|een|één|twee|drie|vier|vijf|zes|zeven|acht|negen|tien|elf|twaalf|dertien|veertien|vijftien|zestien|zeventien|achttien|negentien|twintig|dertig|veertig|vijftig|zestig)"; -const PCT_WORD_NL = `${SPELLED_NL}\\s+procent`; -const QUAL_NUM_NL = `(?:\\d{1,3}\\s*%|${PCT_WORD_NL})`; -const RISK_NOUN_NL = "(?:risico\\w*|kans|waarschijnlijkheid)"; -const HORIZON_NL = - "(?:(?:10|tien)[- ]?jaars?[- ]?risico\\w*|levenslang\\s+risico\\w*|risico\\w*(?:\\s+\\S+){0,3}\\s+(?:binnen|in)\\s+(?:de\\s+komende\\s+)?(?:10|tien)\\s+jaar)"; -const RESULT_VERB_NL = - "(?:plaatst\\s+(?:je|u)|deelt\\s+(?:je|u)\\s+in|zet\\s+(?:je|u)\\s+in|(?:je|u)\\s+valt\\s+in|valt\\s+(?:je|u)\\s+in|brengt\\s+(?:je|u)\\s+in)"; -const RISK_BAND_NL = - "(?:hoog|hoger|laag|lager|middel|matig|verhoogd)\\w*[- ]?risico(?:categorie|groep|klasse|zone)"; -const RISK_LEVEL_NL = - "(?:verhoogd|hoog|hoger|matig|gemiddeld|zorgwekkend|aanzienlijk)"; -const RISK_VERDICT_NL = `(?:is|ligt|lijkt|blijft|wijst\\s+op|komt\\s+uit\\s+op)\\s+(?:\\w+\\s+){0,2}${RISK_LEVEL_NL}`; - -const SPELLED_PT = - "(?:zero|um|uma|dois|duas|tr[êe]s|quatro|cinco|seis|sete|oito|nove|dez|onze|doze|treze|quatorze|catorze|quinze|dezesseis|dezessete|dezoito|dezenove|vinte|trinta|quarenta|cinquenta|sessenta)"; -const PCT_WORD_PT = `${SPELLED_PT}\\s+por\\s+cento`; -const QUAL_NUM_PT = `(?:\\d{1,3}\\s*%|${PCT_WORD_PT})`; -const RISK_NOUN_PT = "(?:risco|probabilidade|chance)"; -const HORIZON_PT = - "(?:risco\\s+(?:cardiovascular|card[íi]aco|de\\s+(?:avc|infarto|morte|mortalidade))\\s+(?:em|para|nos\\s+pr[óo]ximos)\\s+(?:10|dez)\\s+anos)"; -const RESULT_VERB_PT = - "(?:(?:te|lhe|o|a)\\s+(?:coloca|classifica|situa|enquadra|p[õo]e)|coloca\\s+voc[êe]|voc[êe]\\s+(?:est[áa]|fica|cai|se\\s+enquadra)\\s+(?:n[oa]s?|em))"; -const RISK_BAND_PT = - "(?:(?:faixa|categoria|grupo)\\s+de\\s+)?(?:alto|baixo|m[ée]dio|moderado|elevado|intermedi[áa]rio)\\s+risco"; -const RISK_LEVEL_PT = - "(?:elevad[oa]|alt[oa]|moderad[oa]|intermedi[áa]ri[oa]|lim[íi]trofe|preocupante|significativ[oa])"; -const RISK_VERDICT_PT = `(?:é|est[áa]|parece|fica|continua|indica|mostra|aponta\\s+para)\\s+(?:\\S+\\s+){0,2}${RISK_LEVEL_PT}`; - -// Korean carries no `\\b` next to a Hangul token: `\\b` is an ASCII \\w boundary -// and Hangul is not \\w, so /\\b위험/ matches nothing. Spelled-out percentages are -// omitted deliberately — Korean writes "12%", and the Sino-Korean numerals -// ("이", "삼") collide with common particles. -const QUAL_NUM_KO = "(?:\\d{1,3}\\s*(?:%|퍼센트|프로))"; -const RISK_NOUN_KO = "(?:위험(?:도|률|성)?|확률|가능성)"; -const HORIZON_KO = "(?:10년|십\\s?년|평생)\\s*(?:위험(?:도|률)?|리스크)"; -const RESULT_VERB_KO = - "(?:(?:에|으로|로)\\s*(?:분류|해당|속하|들어가)|군에\\s*(?:속하|해당))"; -const RISK_BAND_KO = - "(?:고|중간|중등도|저|높은|낮은)\\s*위험\\s*(?:군|범위|등급|구간)"; -const RISK_LEVEL_KO = "(?:높|상승|증가|중등도|경계|우려)"; -const RISK_VERDICT_KO = `(?:은|는|이|가)\\s*[^.?!]{0,10}${RISK_LEVEL_KO}`; - -const RISK_PATTERNS: Record = { - en: [ - // (1) digit percent adjacent to a risk noun, either order - new RegExp(`\\b\\d{1,3}\\s*%\\s+${RISK_NOUN_EN}\\b`, "i"), - new RegExp( - `\\b${RISK_NOUN_EN}\\s+(?:of|is|at|around|about|near|sits?\\s+at|would\\s+be)\\s+(?:about\\s+|roughly\\s+|approximately\\s+|around\\s+|~)?\\d{1,3}\\s*%`, - "i", - ), - // (2) spelled-out percent as a risk figure, either order (D4b) - new RegExp(`\\b${RISK_NOUN_EN}\\b[^.?!]{0,40}\\b${PCT_WORD_EN}\\b`, "i"), - new RegExp(`\\b${PCT_WORD_EN}\\b[^.?!]{0,40}\\b${RISK_NOUN_EN}\\b`, "i"), - // (3) 10-year horizon phrase + a qualifying number, either order (M4) - new RegExp(`\\b${HORIZON_EN}\\b[^.?!]{0,40}${QUAL_NUM_EN}`, "i"), - new RegExp(`${QUAL_NUM_EN}[^.?!]{0,40}\\b${HORIZON_EN}\\b`, "i"), - // (4) named engine + a qualifying number, either order - new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_EN}`, "i"), - new RegExp(`${QUAL_NUM_EN}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"), - // (5) engine / horizon + a categorical RESULT assertion, numberless (D4) - new RegExp( - `\\b(?:${ENGINE}|${HORIZON_EN})\\b[^.?!]{0,60}${RESULT_VERB_EN}`, - "i", - ), - new RegExp( - `\\b(?:${ENGINE}|${HORIZON_EN})\\b[^.?!]{0,60}${RISK_BAND_EN}`, - "i", - ), - // (6) engine / horizon + a categorical risk-level verdict, numberless - new RegExp( - `\\b(?:${ENGINE}|${HORIZON_EN})\\b[^.?!]{0,40}${RISK_VERDICT_EN}`, - "i", - ), - ], - de: [ - new RegExp( - `\\brisiko\\s+(?:von|bei|liegt\\s+bei)\\s+(?:etwa\\s+|ungefähr\\s+|~)?\\d{1,3}\\s*%`, - "i", - ), - new RegExp(`\\b\\d{1,3}\\s*%\\s+${RISK_NOUN_DE}\\b`, "i"), - // spelled-out percent as a risk figure, either order - new RegExp(`\\b${RISK_NOUN_DE}\\b[^.?!]{0,40}\\b${PCT_WORD_DE}\\b`, "i"), - new RegExp(`\\b${PCT_WORD_DE}\\b[^.?!]{0,40}\\b${RISK_NOUN_DE}\\b`, "i"), - // 10-year horizon + a qualifying number, either order - new RegExp(`\\b${HORIZON_DE}\\b[^.?!]{0,40}${QUAL_NUM_DE}`, "i"), - new RegExp(`${QUAL_NUM_DE}[^.?!]{0,40}\\b${HORIZON_DE}\\b`, "i"), - // named engine (same literals) + qualifying number or categorical result - new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_DE}`, "i"), - new RegExp(`${QUAL_NUM_DE}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"), - new RegExp( - `\\b(?:${ENGINE}|${HORIZON_DE})\\b[^.?!]{0,60}${RESULT_VERB_DE}`, - "i", - ), - new RegExp( - `\\b(?:${ENGINE}|${HORIZON_DE})\\b[^.?!]{0,60}${RISK_BAND_DE}`, - "i", - ), - new RegExp( - `\\b(?:${ENGINE}|${HORIZON_DE})\\b[^.?!]{0,40}${RISK_VERDICT_DE}`, - "i", - ), - ], - fr: [ - /\brisque\s+(?:est\s+)?(?:de\s+|d['’])?(?:environ\s+|~)?\d{1,3}\s*%/i, - /\b\d{1,3}\s*%\s+(?:de\s+)?(?:risque|probabilit[ée]|chance)/i, - /\brisque\s+(?:cardiovasculaire|cardiaque|d'avc|de\s+mortalit[ée])\s+[àa]\s+(?:10|dix)\s+ans\b/i, - // digit percent as a risk figure, either order (the noun need not be adjacent) - new RegExp(`\\b${RISK_NOUN_FR}\\b[^.?!]{0,40}\\d{1,3}\\s*%`, "i"), - new RegExp(`\\d{1,3}\\s*%[^.?!]{0,40}\\b${RISK_NOUN_FR}\\b`, "i"), - // spelled-out percent as a risk figure, either order - new RegExp(`\\b${RISK_NOUN_FR}\\b[^.?!]{0,40}\\b${PCT_WORD_FR}\\b`, "i"), - new RegExp(`\\b${PCT_WORD_FR}\\b[^.?!]{0,40}\\b${RISK_NOUN_FR}\\b`, "i"), - // named engine + qualifying number, either order - new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_FR}`, "i"), - new RegExp(`${QUAL_NUM_FR}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"), - // engine + categorical result assertion, numberless - new RegExp(`\\b${ENGINE}\\b[^.?!]{0,60}${RESULT_VERB_FR}`, "i"), - ], - es: [ - /\briesgo\s+(?:del?|es\s+del?|de\s+aproximadamente)\s+(?:aproximadamente\s+|~)?\d{1,3}\s*%/i, - /\b\d{1,3}\s*%\s+(?:de\s+)?(?:riesgo|probabilidad)/i, - /\briesgo\s+(?:cardiovascular|card[íi]aco|de\s+ictus|de\s+mortalidad)\s+a\s+(?:10|diez)\s+a[ñn]os\b/i, - new RegExp(`\\b${RISK_NOUN_ES}\\b[^.?!]{0,40}\\d{1,3}\\s*%`, "i"), - new RegExp(`\\d{1,3}\\s*%[^.?!]{0,40}\\b${RISK_NOUN_ES}\\b`, "i"), - new RegExp(`\\b${RISK_NOUN_ES}\\b[^.?!]{0,40}\\b${PCT_WORD_ES}\\b`, "i"), - new RegExp(`\\b${PCT_WORD_ES}\\b[^.?!]{0,40}\\b${RISK_NOUN_ES}\\b`, "i"), - new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_ES}`, "i"), - new RegExp(`${QUAL_NUM_ES}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"), - new RegExp(`\\b${ENGINE}\\b[^.?!]{0,60}${RESULT_VERB_ES}`, "i"), - ], - it: [ - /\brischio\s+(?:del?|dell'|[èe]\s+del?|di\s+circa)\s+(?:circa\s+|~)?\d{1,3}\s*%/i, - /\b\d{1,3}\s*%\s+(?:di\s+)?(?:rischio|probabilit[àa])/i, - /\brischio\s+(?:cardiovascolare|cardiaco|di\s+ictus|di\s+mortalit[àa])\s+a\s+(?:10|dieci)\s+anni\b/i, - new RegExp(`\\b${RISK_NOUN_IT}\\b[^.?!]{0,40}\\d{1,3}\\s*%`, "i"), - new RegExp(`\\d{1,3}\\s*%[^.?!]{0,40}\\b${RISK_NOUN_IT}\\b`, "i"), - new RegExp(`\\b${RISK_NOUN_IT}\\b[^.?!]{0,40}\\b${PCT_WORD_IT}\\b`, "i"), - new RegExp(`\\b${PCT_WORD_IT}\\b[^.?!]{0,40}\\b${RISK_NOUN_IT}\\b`, "i"), - new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_IT}`, "i"), - new RegExp(`${QUAL_NUM_IT}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"), - new RegExp(`\\b${ENGINE}\\b[^.?!]{0,60}${RESULT_VERB_IT}`, "i"), - ], - pl: [ - /\bryzyko\s+(?:wynosi\s+|około\s+|~)?\d{1,3}\s*%/i, - /\b\d{1,3}\s*%\s+(?:ryzyka|prawdopodobie[ńn]stwa)/i, - /\bryzyk\w*\s+(?:sercowo[- ]naczyniow\w*|zawału|udaru|zgonu)\s+w\s+(?:ci[ąa]gu\s+)?(?:10|dziesi[ęe]ciu)\s+lat\b/i, - new RegExp(`\\b${RISK_NOUN_PL}\\b[^.?!]{0,40}\\d{1,3}\\s*%`, "i"), - new RegExp(`\\d{1,3}\\s*%[^.?!]{0,40}\\b${RISK_NOUN_PL}\\b`, "i"), - new RegExp(`\\b${RISK_NOUN_PL}\\b[^.?!]{0,40}\\b${PCT_WORD_PL}\\b`, "i"), - new RegExp(`\\b${PCT_WORD_PL}\\b[^.?!]{0,40}\\b${RISK_NOUN_PL}\\b`, "i"), - new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_PL}`, "i"), - new RegExp(`${QUAL_NUM_PL}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"), - new RegExp(`\\b${ENGINE}\\b[^.?!]{0,60}${RESULT_VERB_PL}`, "i"), - ], - nl: [ - new RegExp( - `\\brisico\\w*\\s+(?:van|is|ligt\\s+op|bedraagt|op)\\s+(?:ongeveer\\s+|zo['’]?n\\s+|circa\\s+|rond\\s+|~)?\\d{1,3}\\s*%`, - "i", - ), - new RegExp(`\\b\\d{1,3}\\s*%\\s+${RISK_NOUN_NL}\\b`, "i"), - new RegExp(`\\b${RISK_NOUN_NL}\\b[^.?!]{0,40}\\b${PCT_WORD_NL}\\b`, "i"), - new RegExp(`\\b${PCT_WORD_NL}\\b[^.?!]{0,40}\\b${RISK_NOUN_NL}\\b`, "i"), - new RegExp(`\\b${HORIZON_NL}\\b[^.?!]{0,40}${QUAL_NUM_NL}`, "i"), - new RegExp(`${QUAL_NUM_NL}[^.?!]{0,40}\\b${HORIZON_NL}\\b`, "i"), - new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_NL}`, "i"), - new RegExp(`${QUAL_NUM_NL}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"), - new RegExp( - `\\b(?:${ENGINE}|${HORIZON_NL})\\b[^.?!]{0,60}${RESULT_VERB_NL}`, - "i", - ), - new RegExp( - `\\b(?:${ENGINE}|${HORIZON_NL})\\b[^.?!]{0,60}${RISK_BAND_NL}`, - "i", - ), - new RegExp( - `\\b(?:${ENGINE}|${HORIZON_NL})\\b[^.?!]{0,40}${RISK_VERDICT_NL}`, - "i", - ), - ], - "pt-BR": [ - /\brisco\s+(?:de|é\s+de|fica\s+em|está\s+em)\s+(?:aproximadamente\s+|cerca\s+de\s+|uns\s+|~)?\d{1,3}\s*%/i, - /\b\d{1,3}\s*%\s+(?:de\s+)?(?:risco|probabilidade|chance)/i, - new RegExp(`\\b${RISK_NOUN_PT}\\b[^.?!]{0,40}\\d{1,3}\\s*%`, "i"), - new RegExp(`\\d{1,3}\\s*%[^.?!]{0,40}\\b${RISK_NOUN_PT}\\b`, "i"), - new RegExp(`\\b${RISK_NOUN_PT}\\b[^.?!]{0,40}\\b${PCT_WORD_PT}\\b`, "i"), - new RegExp(`\\b${PCT_WORD_PT}\\b[^.?!]{0,40}\\b${RISK_NOUN_PT}\\b`, "i"), - new RegExp(`\\b${HORIZON_PT}\\b[^.?!]{0,40}${QUAL_NUM_PT}`, "i"), - new RegExp(`${QUAL_NUM_PT}[^.?!]{0,40}\\b${HORIZON_PT}\\b`, "i"), - new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_PT}`, "i"), - new RegExp(`${QUAL_NUM_PT}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"), - new RegExp( - `\\b(?:${ENGINE}|${HORIZON_PT})\\b[^.?!]{0,60}${RESULT_VERB_PT}`, - "i", - ), - new RegExp( - `\\b(?:${ENGINE}|${HORIZON_PT})\\b[^.?!]{0,60}${RISK_BAND_PT}`, - "i", - ), - new RegExp( - `\\b(?:${ENGINE}|${HORIZON_PT})\\b[^.?!]{0,40}${RISK_VERDICT_PT}`, - "i", - ), - ], - ko: [ - new RegExp(`${RISK_NOUN_KO}[^.?!]{0,15}${QUAL_NUM_KO}`, "i"), - new RegExp(`${QUAL_NUM_KO}[^.?!]{0,15}${RISK_NOUN_KO}`, "i"), - new RegExp(`${HORIZON_KO}[^.?!]{0,40}${QUAL_NUM_KO}`, "i"), - new RegExp(`${QUAL_NUM_KO}[^.?!]{0,40}${HORIZON_KO}`, "i"), - new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_KO}`, "i"), - new RegExp(`${QUAL_NUM_KO}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"), - new RegExp( - `(?:\\b${ENGINE}\\b|${HORIZON_KO})[^.?!]{0,60}${RESULT_VERB_KO}`, - "i", - ), - new RegExp( - `(?:\\b${ENGINE}\\b|${HORIZON_KO})[^.?!]{0,60}${RISK_BAND_KO}`, - "i", - ), - new RegExp( - `(?:\\b${ENGINE}\\b|${HORIZON_KO})[^.?!]{0,40}${RISK_VERDICT_KO}`, - "i", - ), - ], -}; +const RISK_PATTERNS = perLocale((bank) => bank.risk); /** * Causal-claim banks — GROUND RULE 12. Descriptive framing stays permitted @@ -1283,117 +279,7 @@ const RISK_PATTERNS: Record = { * asserted causation trips. This bank is enforced ONLY on the insights-family * surfaces the rule declares, never on the Coach. */ -const CAUSAL_PATTERNS: Record = { - en: [ - /\bbecause\b/i, - /\bcaused?\s+by\b/i, - /\bcaus(?:e|es|ed|ing)\b/i, - /\bdue\s+to\b/i, - /\bled\s+to\b/i, - /\bresulted\s+in\b/i, - /\bresult\s+of\b/i, - /\bculprit\b/i, - /\bdriven\s+by\b/i, - /\bthanks\s+to\b/i, - /\bowing\s+to\b/i, - /\bresponsible\s+for\b/i, - ], - de: [ - /\bweil\b/i, - /\bwegen\b/i, - /\bverursach\w*/i, - /\baufgrund\b/i, - /\bführt[e]?\s+zu\b/i, - /\bdurch\s+\w+\s+(?:verursacht|ausgelöst)\b/i, - /\bschuld\b/i, - /\bauslöser\b/i, - /\bverantwortlich\s+für\b/i, - ], - fr: [ - /\bparce\s+qu\w*/i, - /\b[àa]\s+cause\s+de\b/i, - /\bcaus(?:e|es|ent|é|ée)\b/i, - /\ben\s+raison\s+de\b/i, - /\bentra[îi]n\w*/i, - /\ba\s+conduit\s+[àa]/i, - /\bd[ûu]\s+[àa]/i, - /\bresponsable\s+de\b/i, - /\bgr[âa]ce\s+[àa]/i, - /\bprovoqu\w*/i, - ], - es: [ - /\bporque\b/i, - /\bdebido\s+a\b/i, - /\ba\s+causa\s+de\b/i, - /\bcaus(?:a|an|ó|ado)/i, - /\bprovoc\w*/i, - /\bllev[óo]\s+a\b/i, - /\bresponsable\s+de\b/i, - /\bgracias\s+a\b/i, - /\bimpuls\w*\s+(?:por|el|la)\b/i, - ], - it: [ - /\bperch[ée]/i, - /\ba\s+causa\s+di\b/i, - /\bcaus(?:a|ano|ato|ata)\b/i, - /\bprovoc\w*/i, - /\bha\s+portato\s+a\b/i, - /\bdovuto\s+a\b/i, - /\bresponsabile\s+di\b/i, - /\bgrazie\s+a\b/i, - /\bguid\w*\s+da\b/i, - ], - pl: [ - /\bponiewa[żz]/i, - /\bz\s+powodu\b/i, - /\bpowoduj\w*/i, - /\bspowodowa\w*/i, - /\bprzyczyn\w*/i, - /\bdoprowadzi\w*\s+do\b/i, - /\bwskutek\b/i, - /\bdzi[ęe]ki\b/i, - /\bodpowiedzialn\w*\s+za\b/i, - ], - nl: [ - /\bomdat\b/i, - /\bdoordat\b/i, - /\bvanwege\b/i, - /\bveroorza\w*/i, - /\b(?:ten|als)\s+gevolg\s+van\b/i, - /\bleid(?:t|de|den)\s+tot\b/i, - /\bkomt\s+door\b/i, - /\bdankzij\b/i, - /\bde\s+schuld\s+van\b/i, - /\boorzaak\b/i, - /\bverantwoordelijk\s+voor\b/i, - ], - "pt-BR": [ - /\bporque\b/i, - /\bpor\s+causa\s+d/i, - /\bdevido\s+[aà]/i, - /\bcaus(?:a|am|ou|aram|ado|ada|ando)\b/i, - /\bprovoc\w*/i, - /\blev(?:ou|aram)\s+[aà]/i, - /\bresult(?:ou|aram)\s+(?:em|d[eoa])\b/i, - /\bem\s+raz[ãa]o\s+d/i, - /\bem\s+decorr[êe]ncia\s+d/i, - /\bgra[çc]as\s+[aà]/i, - /\brespons[áa]ve(?:l|is)\s+(?:por|pel[oa]s?)\b/i, - /\bculpa\s+d/i, - ], - ko: [ - /때문(?:에|이)/, - /(?:으로|로)\s*인(?:해|하여|한)/, - /탓(?:에|이|으로)/, - /(?:이|가)\s*원인/, - /원인(?:은|이에요|입니다|이라|으로)/, - /유발/, - /초래/, - /야기/, - /덕분(?:에|이)/, - /(?:영향을|결과로)\s*(?:줘서|줬|나타났|이어졌)/, - ], -}; +const CAUSAL_PATTERNS = perLocale((bank) => bank.causal); const BANKS: Record> = { dose: DOSE_PATTERNS, @@ -1436,31 +322,9 @@ export const INSIGHTS_CONTRACTS: readonly OutboundContract[] = [ * Accepted residual FP, failing safe: "I'd recommend discussing the 2.4 mg * 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|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, - pl: /\b(?:kontynuuj|przyjmuj\s+dalej|zgodnie\s+z\s+zaleceniem|pozosta[ńn]\s+przy)\b/i, - nl: /\b(?:blijf|ga\s+door\s+met|neem\s+\S+\s+(?:gewoon\s+)?verder|zoals\s+(?:voorgeschreven|afgesproken|gebruikelijk|gewoonlijk|altijd|eerder|je\s+gewend\s+bent)|volgens\s+(?:voorschrift|afspraak))\b/i, - "pt-BR": - /\b(?:continue\s+(?:tomando|usando|com|a\s+tomar)|siga\s+(?:tomando|com)|mantenha|conforme\s+(?:prescrito|receitado|orientado|combinado)|como\s+(?:prescrito|receitado|de\s+costume|sempre|antes)|de\s+acordo\s+com\s+a\s+(?:receita|prescri))/i, - ko: /(?:그대로\s*(?:유지|복용)|계속\s*(?:복용|드시|유지)|처방(?:대로|받은\s*대로)|지시(?:대로|받은\s*대로)|유지하세요)/, -}; +const DOSE_CONTINUATION = perLocale((bank) => bank.continuation); -const DOSE_CHANGE_STEM: Record = { - en: /\b(?:increas|reduc|taper|titrat|step\s+(?:up|down)|lower|raise|halv|doubl|skip|bump|ramp)\w*/i, - de: /\b(?:erhöh|steiger|reduzier|senk|verringer|halbier|verdoppel|absetz|auslass|hochsetz|runtersetz|titrier)\w*/i, - fr: /\b(?:augment|réduis|réduir|diminu|baiss|abaiss|doubl|arrêt|saut)\w*/i, - es: /\b(?:aument|reduzc|reduc|baj|disminu|dobl|omit|salt)\w*/i, - it: /\b(?:aument|riduc|ridur|abbass|diminu|raddoppi|salt|dimezz)\w*/i, - pl: /\b(?:zwiększ|zmniejsz|obniż|podnie[śs]|podwyższ|zreduk|pomi[ńn]|opu[śs][ćc])\w*/i, - nl: /\b(?:verho|verla|vermind|halve|verdubbel|ophog|afbouw|bouw\s+af|overslaan|sla\s+\S+\s+over|stop)\w*/i, - "pt-BR": - /\b(?:aument|reduz|diminu|dobr|duplic|omit|suspend|interromp|baix(?:e|ar|ando)\b|abaix|elev(?:e|ar)\b|sub(?:a|ir)\b|pul(?:e|ar|ando)\b|parar?\s+de\s+tomar)\w*/i, - ko: /(?:올리|올려|늘리|늘려|증량|높이|높여|인상|내리|내려|줄이|줄여|감량|낮추|낮춰|감축|절반|반으로|두\s?배|건너뛰|중단|끊)/, -}; +const DOSE_CHANGE_STEM = perLocale((bank) => bank.changeStem); /** Sentence-level split — the dose exemption must be scoped to one sentence. */ function splitSentences(text: string): string[] { @@ -1469,6 +333,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}${UNIT_END}`, "gi"); + function sentenceDoseValues(sentence: string): number[] { const values: number[] = []; let m: RegExpExecArray | null; diff --git a/src/lib/ai/safety/outbound-screen/locales/de.ts b/src/lib/ai/safety/outbound-screen/locales/de.ts new file mode 100644 index 000000000..3c2b184bd --- /dev/null +++ b/src/lib/ai/safety/outbound-screen/locales/de.ts @@ -0,0 +1,178 @@ +/** Outbound-screen banks for German, composed by `../../outbound-screen.ts`. */ +import { + DOSE_UNIT, + UNIT_END, + objectSlot, + TARGET, + LETTER, + ENGINE, + medicationExtras, + type LocaleBank, +} from "../shared"; + +const DE_STOP = + "nicht|nie|niemals|kein\\w*|und|oder|aber|wenn|ob|natrium|salz|koffein|zucker|wasser|eiweiß|protein|ballaststoff\\w*|kalorien"; + +const DE_SLOT = objectSlot(DE_STOP); + +const DE_SHORT = objectSlot( + `${DE_STOP}|als|zu|mit|für|gegen|statt|ohne|über`, + 2, +); + +const DE_MED_NOUN = + "\\S*(?:dosis|tablette\\w*|medikament\\w*|medikation|spritze\\w*|insulin|pille\\w*|kapsel\\w*)"; + +/** + * The object a German change verb may carry before its target ("erhöhe + * deine Dosis auf 2,4 mg"). English allows "your dose" in the same slot; + * without it the German imperative with an object slipped through. + */ +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+)?"; + +const SPELLED_DE = + "(?:null|eins?|zwei|drei|vier|fünf|sechs|sieben|acht|neun|zehn|elf|zwölf|dreizehn|vierzehn|fünfzehn|sechzehn|siebzehn|achtzehn|neunzehn|zwanzig|dreißig|vierzig|fünfzig|sechzig|siebzig|achtzig|neunzig)"; + +const PCT_WORD_DE = `${SPELLED_DE}\\s+prozent`; + +const QUAL_NUM_DE = `(?:\\d{1,3}\\s*%|${PCT_WORD_DE})`; + +const RISK_NOUN_DE = "(?:risiko|wahrscheinlichkeit|chance)"; + +const HORIZON_DE = "(?:10[- ]jahres|zehn[- ]jahres|lebenszeit)[- ]?risiko"; + +const RESULT_VERB_DE = + "(?:ordnet\\s+(?:dich|sie)\\s+ein|stuft\\s+(?:dich|sie)\\s+ein|f[äa]llst\\s+in|einordnen|liegst\\s+im)"; + +const RISK_BAND_DE = + "(?:hoh|niedrig|mittler|erhöht|moderat|gering)\\w*[- ]?risiko(?:bereich|kategorie|gruppe|band)"; + +const RISK_LEVEL_DE = + "(?:erhöht|hoch|höher|mittel|mäßig|grenzwertig|besorgniserregend|deutlich)"; + +const RISK_VERDICT_DE = `(?:ist|liegt|erscheint|wirkt|bleibt|zeigt|deutet\\s+auf|weist\\s+auf)\\s+(?:\\w+\\s+){0,2}${RISK_LEVEL_DE}`; + +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*)"; + +export const bank: LocaleBank = { + vocabulary: (names) => + `(?:${DE_MED_NOUN}|${medicationExtras(DE_MED_CLASS, names)})`, + doseChangeClass: (med) => [ + 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( + `(? + `(?:${EN_MED_NOUN}|${medicationExtras(EN_MED_CLASS, names)})`, + doseChangeClass: (med) => [ + 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}${med}`, + "im", + ), + new RegExp( + `${EN_LEAD}(?:(?:(?:stop|quit)\\s+(?:taking|injecting)|discontinue)\\s+\\S|(?:come|wean\\s+(?:yourself\\s+)?)\\s*off\\s+${EN_SLOT}${med})`, + "im", + ), + new RegExp( + `${EN_CUE}[^.?!]{0,20}?\\b(?:(?:halv|doubl|skipp|paus|hold|dropp|miss|splitt|cutt)ing\\s+${EN_SLOT}${med}|(?:stopping|quitting)\\s+(?:taking|injecting)\\s+\\S|discontinuing\\s+\\S|coming\\s+off\\s+${EN_SLOT}${med})`, + "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}${med}`, + "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}${med}`, + "im", + ), + new RegExp(`${EN_LEAD}leave\\s+${EN_SHORT}${med}\\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}${med}|taking\\s+(?:another|(?:(?:an?|one|two|\\d+)\\s+)?(?:extra|additional|more))\\s+${EN_SHORT}${med})`, + "i", + ), + ], + dose: [ + // 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}${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}${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}${UNIT_END}`, + "i", + ), + // Experiment-shaped dose changes the "to/by N unit" patterns miss. BOTH a + // medication object AND a trial cue are required, so a benign behavioural + // experiment ("double your steps for two weeks") never trips. + /\b(?:halv\w*|doubl\w*|skip\w*|stop\s+taking|quit\s+taking|come\s+off)\b[^.?!]{0,25}\b(?:dose|doses|pill|pills|tablet|tablets|medication|meds?|insulin|injection)\b[^.?!]{0,40}\b(?:for\s+\w+\s+(?:day|days|week|weeks|month|months)|to\s+(?:see|test|try|check)|next\s+(?:week|month))\b/i, + ], + risk: [ + // (1) digit percent adjacent to a risk noun, either order + new RegExp(`\\b\\d{1,3}\\s*%\\s+${RISK_NOUN_EN}\\b`, "i"), + new RegExp( + `\\b${RISK_NOUN_EN}\\s+(?:of|is|at|around|about|near|sits?\\s+at|would\\s+be)\\s+(?:about\\s+|roughly\\s+|approximately\\s+|around\\s+|~)?\\d{1,3}\\s*%`, + "i", + ), + // (2) spelled-out percent as a risk figure, either order (D4b) + new RegExp(`\\b${RISK_NOUN_EN}\\b[^.?!]{0,40}\\b${PCT_WORD_EN}\\b`, "i"), + new RegExp(`\\b${PCT_WORD_EN}\\b[^.?!]{0,40}\\b${RISK_NOUN_EN}\\b`, "i"), + // (3) 10-year horizon phrase + a qualifying number, either order (M4) + new RegExp(`\\b${HORIZON_EN}\\b[^.?!]{0,40}${QUAL_NUM_EN}`, "i"), + new RegExp(`${QUAL_NUM_EN}[^.?!]{0,40}\\b${HORIZON_EN}\\b`, "i"), + // (4) named engine + a qualifying number, either order + new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_EN}`, "i"), + new RegExp(`${QUAL_NUM_EN}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"), + // (5) engine / horizon + a categorical RESULT assertion, numberless (D4) + new RegExp( + `\\b(?:${ENGINE}|${HORIZON_EN})\\b[^.?!]{0,60}${RESULT_VERB_EN}`, + "i", + ), + new RegExp( + `\\b(?:${ENGINE}|${HORIZON_EN})\\b[^.?!]{0,60}${RISK_BAND_EN}`, + "i", + ), + // (6) engine / horizon + a categorical risk-level verdict, numberless + new RegExp( + `\\b(?:${ENGINE}|${HORIZON_EN})\\b[^.?!]{0,40}${RISK_VERDICT_EN}`, + "i", + ), + ], + causal: [ + /\bbecause\b/i, + /\bcaused?\s+by\b/i, + /\bcaus(?:e|es|ed|ing)\b/i, + /\bdue\s+to\b/i, + /\bled\s+to\b/i, + /\bresulted\s+in\b/i, + /\bresult\s+of\b/i, + /\bculprit\b/i, + /\bdriven\s+by\b/i, + /\bthanks\s+to\b/i, + /\bowing\s+to\b/i, + /\bresponsible\s+for\b/i, + ], + continuation: + /\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, + changeStem: + /\b(?:increas|reduc|taper|titrat|step\s+(?:up|down)|lower|raise|halv|doubl|skip|bump|ramp)\w*/i, +}; diff --git a/src/lib/ai/safety/outbound-screen/locales/es.ts b/src/lib/ai/safety/outbound-screen/locales/es.ts new file mode 100644 index 000000000..5ed1aef67 --- /dev/null +++ b/src/lib/ai/safety/outbound-screen/locales/es.ts @@ -0,0 +1,115 @@ +/** Outbound-screen banks for Spanish, composed by `../../outbound-screen.ts`. */ +import { + DOSE_UNIT, + UNIT_END, + objectSlot, + TARGET, + LETTER, + ENGINE, + medicationExtras, + type LocaleBank, +} from "../shared"; + +const ES_STOP = + "no|nunca|y|o|pero|si|sodio|sal|cafeína|azúcar|agua|proteínas?|fibra"; + +const ES_SLOT = objectSlot(ES_STOP); + +const ES_SHORT = objectSlot(`${ES_STOP}|para|con|antes|después|sin|a|al`, 2); + +const ES_MED_NOUN = + "(?:dosis|pastillas?|comprimidos?|medicamentos?|medicaci[óo]n|insulina|inyecci[óo]n|inyecciones|c[áa]psulas?|tratamiento)"; + +const SPELLED_ES = + "(?:cero|uno|dos|tres|cuatro|cinco|seis|siete|ocho|nueve|diez|once|doce|trece|catorce|quince|dieciséis|diecisiete|dieciocho|diecinueve|veinte|treinta|cuarenta|cincuenta|sesenta)"; + +const PCT_WORD_ES = `${SPELLED_ES}\\s+por\\s+ciento`; + +const QUAL_NUM_ES = `(?:\\d{1,3}\\s*%|${PCT_WORD_ES})`; + +const RISK_NOUN_ES = "(?:riesgo|probabilidad)"; + +const RESULT_VERB_ES = + "(?:lo\\s+(?:coloca|sitúa|clasifica|pone)|le\\s+(?:coloca|sitúa|clasifica)|se\\s+encuentra\\s+en|está\\s+en\\s+(?:la\\s+)?categor|cae\\s+en)"; + +const ES_MED_CLASS = + "(?:anticoagulantes?|antiagregantes?|estatinas?|betabloqueantes?|diuréticos?|antidepresivos?)"; + +export const bank: LocaleBank = { + vocabulary: (names) => + `(?:${ES_MED_NOUN}|${medicationExtras(ES_MED_CLASS, names)})`, + doseChangeClass: (med) => [ + new RegExp( + `(? + `${ELIDED}(?:${FR_MED_NOUN}|${medicationExtras(FR_MED_CLASS, names)})`, + doseChangeClass: (med) => [ + new RegExp( + `(? + `${ELIDED}(?:${IT_MED_NOUN}|${medicationExtras(IT_MED_CLASS, names)})`, + doseChangeClass: (med) => [ + new RegExp( + `(? + `(?:${KO_MED_NOUN}|(?:${medicationExtras(KO_MED_CLASS, names, false)})\\S{0,3})`, + doseChangeClass: (med) => [ + // 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( + `${med}[^.?!]{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( + `${med}[^.?!]{0,12}(?:(?:(?:한|두|세|\\d+)\\s*(?:알|정|캡슐|번|단위)?\\s*)?더|추가로)\\s*(?:드세요|드십시오|복용하세요|복용하십시오|투여하세요|맞으세요|맞으십시오|주사하세요)`, + ), + ], + // Korean is verb-final: the dose comes first and the change verb closes the + // clause ("2.4mg으로 늘려 보세요"), the mirror image of the Latin banks. A + // target-marking particle on the dose is required, the same demand the Latin + // banks make of "to" / "by" / "auf" — without it the permitted restatement + // "7.5mg을 복용 중이고, 체중은 내려가고 있어요" matched the lowering pattern. + dose: [ + new RegExp( + `[\\d.,]+\\s*${DOSE_UNIT}\\s*(?:으로|로|까지)[^.?!]{0,20}(?:올리|올려|늘리|늘려|증량|높이|높여|인상)`, + "i", + ), + new RegExp( + `[\\d.,]+\\s*${DOSE_UNIT}\\s*(?:으로|로|까지)[^.?!]{0,20}(?:내리|내려|줄이|줄여|감량|낮추|낮춰|감축)`, + "i", + ), + new RegExp( + `(?:권해|권장|권고|추천|제안)[^.?!]{0,40}[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, + "i", + ), + new RegExp( + `[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}[^.?!]{0,40}(?:권해|권장|권고|추천|제안|드심|드셔|복용하세요|시도해)`, + "i", + ), + new RegExp( + `(?:다음|새)\\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, + // so "걸음 수를 두 배로 늘려 2주간 해보세요" never trips. + /(?:약|알|정|인슐린|주사|복용|용량)[^.?!]{0,25}(?:절반|반으로|두\s?배|건너뛰|중단|끊)[^.?!]{0,40}(?:주간|주 동안|일 동안|달 동안|보세요|보십시오|보자|해 ?보)/, + ], + risk: [ + new RegExp(`${RISK_NOUN_KO}[^.?!]{0,15}${QUAL_NUM_KO}`, "i"), + new RegExp(`${QUAL_NUM_KO}[^.?!]{0,15}${RISK_NOUN_KO}`, "i"), + new RegExp(`${HORIZON_KO}[^.?!]{0,40}${QUAL_NUM_KO}`, "i"), + new RegExp(`${QUAL_NUM_KO}[^.?!]{0,40}${HORIZON_KO}`, "i"), + new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_KO}`, "i"), + new RegExp(`${QUAL_NUM_KO}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"), + new RegExp( + `(?:\\b${ENGINE}\\b|${HORIZON_KO})[^.?!]{0,60}${RESULT_VERB_KO}`, + "i", + ), + new RegExp( + `(?:\\b${ENGINE}\\b|${HORIZON_KO})[^.?!]{0,60}${RISK_BAND_KO}`, + "i", + ), + new RegExp( + `(?:\\b${ENGINE}\\b|${HORIZON_KO})[^.?!]{0,40}${RISK_VERDICT_KO}`, + "i", + ), + ], + causal: [ + /때문(?:에|이)/, + /(?:으로|로)\s*인(?:해|하여|한)/, + /탓(?:에|이|으로)/, + /(?:이|가)\s*원인/, + /원인(?:은|이에요|입니다|이라|으로)/, + /유발/, + /초래/, + /야기/, + /덕분(?:에|이)/, + /(?:영향을|결과로)\s*(?:줘서|줬|나타났|이어졌)/, + ], + continuation: + /(?:그대로\s*(?:유지|복용)|계속\s*(?:복용|드시|유지)|처방(?:대로|받은\s*대로)|지시(?:대로|받은\s*대로)|유지하세요)/, + changeStem: + /(?:올리|올려|늘리|늘려|증량|높이|높여|인상|내리|내려|줄이|줄여|감량|낮추|낮춰|감축|절반|반으로|두\s?배|건너뛰|중단|끊)/, +}; diff --git a/src/lib/ai/safety/outbound-screen/locales/nl.ts b/src/lib/ai/safety/outbound-screen/locales/nl.ts new file mode 100644 index 000000000..4cbac6f3a --- /dev/null +++ b/src/lib/ai/safety/outbound-screen/locales/nl.ts @@ -0,0 +1,179 @@ +/** Outbound-screen banks for Dutch, composed by `../../outbound-screen.ts`. */ +import { + DRUG_UNIT, + DOSE_UNIT, + UNIT_END, + objectSlot, + TARGET, + LETTER, + ENGINE, + medicationExtras, + type LocaleBank, +} from "../shared"; + +const NL_STOP = + "niet|nooit|geen|en|of|maar|als|wanneer|natrium|zout|cafeïne|suiker\\S*|water|vocht|eiwit\\S*|vezels?|koffie|alcohol|stappen|calorieën"; + +const NL_SLOT = objectSlot(NL_STOP); + +const NL_SHORT = objectSlot( + `${NL_STOP}|om|voor|met|bij|in|op|van|naar|tot|na|zonder|tijdens|over|dan|te`, + 2, +); + +const NL_MED_NOUN = `(?:[${LETTER}]*(?:dosis|doses|dosering|tablet(?:ten|je|jes)?|medicijn(?:en)?|medicatie|medicament(?:en)?|geneesmiddel(?:en)?|insuline|injecties?|capsules?)|pil(?:len|letje|letjes)?|prik(?:ken)?|spuitjes?|inhalator)(?![${LETTER}])`; + +const SPELLED_NL = + "(?:nul|een|één|twee|drie|vier|vijf|zes|zeven|acht|negen|tien|elf|twaalf|dertien|veertien|vijftien|zestien|zeventien|achttien|negentien|twintig|dertig|veertig|vijftig|zestig)"; + +const PCT_WORD_NL = `${SPELLED_NL}\\s+procent`; + +const QUAL_NUM_NL = `(?:\\d{1,3}\\s*%|${PCT_WORD_NL})`; + +const RISK_NOUN_NL = "(?:risico\\w*|kans|waarschijnlijkheid)"; + +const HORIZON_NL = + "(?:(?:10|tien)[- ]?jaars?[- ]?risico\\w*|levenslang\\s+risico\\w*|risico\\w*(?:\\s+\\S+){0,3}\\s+(?:binnen|in)\\s+(?:de\\s+komende\\s+)?(?:10|tien)\\s+jaar)"; + +const RESULT_VERB_NL = + "(?:plaatst\\s+(?:je|u)|deelt\\s+(?:je|u)\\s+in|zet\\s+(?:je|u)\\s+in|(?:je|u)\\s+valt\\s+in|valt\\s+(?:je|u)\\s+in|brengt\\s+(?:je|u)\\s+in)"; + +const RISK_BAND_NL = + "(?:hoog|hoger|laag|lager|middel|matig|verhoogd)\\w*[- ]?risico(?:categorie|groep|klasse|zone)"; + +const RISK_LEVEL_NL = + "(?:verhoogd|hoog|hoger|matig|gemiddeld|zorgwekkend|aanzienlijk)"; + +const RISK_VERDICT_NL = `(?:is|ligt|lijkt|blijft|wijst\\s+op|komt\\s+uit\\s+op)\\s+(?:\\w+\\s+){0,2}${RISK_LEVEL_NL}`; + +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*)"; + +export const bank: LocaleBank = { + vocabulary: (names) => + `(?:${NL_MED_NOUN}|${medicationExtras(NL_MED_CLASS, names)})`, + doseChangeClass: (med) => [ + new RegExp( + `(? + `(?:${PL_MED_NOUN}|${medicationExtras(PL_MED_CLASS, names)})`, + doseChangeClass: (med) => [ + new RegExp( + `(? + `(?:${PT_MED_NOUN}|${medicationExtras(PT_MED_CLASS, names)})`, + doseChangeClass: (med) => [ + // Brazilian Portuguese addresses the reader as "você", whose imperative + // is the subjunctive form ("aumente", "tome", "pule"). The indicative a + // log restatement uses ("você toma 1000 mg", "a dose passou para 2000 + // mg") is not in any verb list. + new RegExp( + `(? part !== null) + .join("|"); +} + +/** One language's banks, composed by the screen. */ +export interface LocaleBank { + /** + * The medication-noun alternation the dose-change class uses: the + * dosage-form and treatment words, the generic drug-class words ("Stop your + * blood thinner" names no dosage form and no drug, and it is the most + * dangerous stop of all), the generic-name stems, and the person's own + * medication names when the caller passes them. + */ + vocabulary(names: string | null): string; + /** The dose-change class over that medication noun. */ + doseChangeClass(med: string): readonly RegExp[]; + /** Target-dose prescriptions. */ + dose: readonly RegExp[]; + /** Risk-score fabrication. */ + risk: readonly RegExp[]; + /** Asserted causation (GROUND RULE 12). */ + causal: readonly RegExp[]; + /** A maintenance anchor that may exempt a dose sentence. */ + continuation: RegExp; + /** A change stem that cancels that exemption. */ + changeStem: RegExp; +}