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; +}