diff --git a/src/lib/ai/safety/__tests__/outbound-screen-false-positives.test.ts b/src/lib/ai/safety/__tests__/outbound-screen-false-positives.test.ts index e502c69b0..2bee4b216 100644 --- a/src/lib/ai/safety/__tests__/outbound-screen-false-positives.test.ts +++ b/src/lib/ai/safety/__tests__/outbound-screen-false-positives.test.ts @@ -192,6 +192,20 @@ const HARMLESS: Record = { "Продолжайте принимать рамиприл 5 мг как раньше.", "Снизьте потребление сахара вдвое.", "Спросите фармацевта, можно ли делить таблетку пополам.", + // «ме» (international units) is a prefix of «месяц» and «метр»: a unit + // has to end at a non-letter, Cyrillic included. + "Стоит повторить анализ через 3 месяца.", + "Нужно пересдать анализ через 2 месяца.", + "Можно добавить 2 медленные прогулки в неделю.", + "Выпейте стакан воды перед измерением давления.", + "Перестаньте пить кофе после обеда.", + "Бросьте курить: для давления это важнее всего.", + // Nutrition in mg or ml is not a dose. + "Выпейте 500 мл воды перед измерением.", + "Увеличьте потребление воды до 2000 мл в день.", + "Снизьте натрий до 1500 мг в день.", + "Сократите кофеин до 200 мг в день.", + "Рекомендуется не больше 2000 мг натрия в день.", ], }; diff --git a/src/lib/ai/safety/__tests__/outbound-screen.test.ts b/src/lib/ai/safety/__tests__/outbound-screen.test.ts index a45249fd5..02de86c86 100644 --- a/src/lib/ai/safety/__tests__/outbound-screen.test.ts +++ b/src/lib/ai/safety/__tests__/outbound-screen.test.ts @@ -648,6 +648,12 @@ const DOSE_CLASS_VIOLATIONS: Record = { "Начните с 5 мг завтра.", "Перейдите на 10 мг.", "Снизьте дозу до 1000 мг.", + "Перестаньте принимать варфарин перед поездкой.", + "Можете бросить принимать бисопролол.", + "Колите инсулин 10 ЕД перед сном.", + "Выпейте две таблетки ибупрофена.", + "Увеличьте дозу витамина D до 4000 МЕ.", + "Снизьте инсулин до 20 ЕД.", ], }; diff --git a/src/lib/ai/safety/outbound-screen/locales/ru.ts b/src/lib/ai/safety/outbound-screen/locales/ru.ts index 46e082301..d12de0c1b 100644 --- a/src/lib/ai/safety/outbound-screen/locales/ru.ts +++ b/src/lib/ai/safety/outbound-screen/locales/ru.ts @@ -14,7 +14,7 @@ const RU_MED_NOUN = /** Russian dosage-form counters, with the case endings an object takes. */ const RU_COUNT = - "(?:таблетк[а-яё]*|капсул[а-яё]*|капл[а-яё]*|доз[а-яё]*|единиц[а-яё]*|ед\\.?|мл)"; + "(?:таблетк[а-яё]*|капсул[а-яё]*|капл[а-яё]*|доз[а-яё]*|единиц[а-яё]*|ед\\.?)"; const RU_TARGET = `(?:[\\d.,]+(?:\\s*${DRUG_UNIT}${UNIT_END}|\\s*${RU_COUNT})|(?:одну|одна|две|два|три|четыре|половину|полторы|пол)\\s*${RU_COUNT})`; @@ -26,7 +26,7 @@ const RU_TARGET = `(?:[\\d.,]+(?:\\s*${DRUG_UNIT}${UNIT_END}|\\s*${RU_COUNT})|(? * different ending. */ const RU_IMPERATIVE = - "(?:(?:увеличьте|увеличь|повысьте|повысь|поднимите|подними|нарастите|снизьте|снизь|уменьшите|уменьши|понизьте|понизь|опустите|опусти|сократите|сократи|удвойте|удвой|уполовиньте|разделите|пропустите|пропусти|отмените|отмени|прекратите|прекрати|примите|принимайте|начните|начинайте|перейдите|прекратить|отменить|пропустить|снизить|увеличить|повысить|уменьшить|понизить|сократить|удвоить|начать|перейти))(?![а-яё])"; + "(?:(?:увеличьте|увеличь|повысьте|повысь|поднимите|подними|нарастите|снизьте|снизь|уменьшите|уменьши|понизьте|понизь|опустите|опусти|сократите|сократи|удвойте|удвой|уполовиньте|разделите|пропустите|пропусти|отмените|отмени|прекратите|прекрати|примите|принимайте|начните|начинайте|перейдите|прекратить|отменить|пропустить|снизить|увеличить|повысить|уменьшить|понизить|сократить|удвоить|начать|перейти|выпейте|колите|вколите|уколите|перестаньте|перестань|бросьте|бросить))(?![а-яё])"; // Russian writes "12%" with digits; the spelled-out counters ("двенадцать // процентов") are rare in model prose and collide with the numeral declension, @@ -55,6 +55,17 @@ const RU_MED_CLASS = const RU_DEFERRAL = "\\?|врач[а-яё]*|доктор[а-яё]*|специалист[а-яё]*|эндокринолог[а-яё]*|фармацевт[а-яё]*|обсуди[а-яё]*|посовету[а-яё]*|спроси[а-яё]*|если\\s+только|как\\s+назначено|как\\s+обычно|как\\s+и\\s+раньше|не\\s+"; +/** + * Nutrition, not medication: "снизьте натрий до 1500 мг", "сократите кофеин + * до 200 мг", "увеличьте потребление воды до 2000 мл". The target-dose shapes + * below carry a free gap between the verb and the amount, so a dietary noun + * anywhere in it stands the shape down, the way the other banks keep + * sodium, caffeine and water out of their object slot. + */ +const RU_DIET = + "(?:натри|сол[ьиеюя]|кофеин|сахар|вод[аыуеой]|жидкост|белк|бел[её]к|клетчатк|калори|кальци|магни|кали[йяю]|потреблен)"; +const RU_GAP = (n: number) => `(?:(?!${RU_DIET})[^.?!]){0,${n}}`; + export const bank: LocaleBank = { vocabulary: (names) => `(?:${RU_MED_NOUN}|(?:${medicationExtras(RU_MED_CLASS, names, false)})\\S{0,3})`, @@ -85,7 +96,7 @@ export const bank: LocaleBank = { unlessDeferred( RU_DEFERRAL, new RegExp( - `(?:примите|принимайте|выпейте|выпить|принять)[^.?!]{0,12}[\\d.,]+\\s*(?:мкг|мг|мл|ед|ме)${UNIT_END}`, + `(?:примите|принимайте|выпейте|выпить|принять|колите|вколите|уколите)[^.?!]{0,12}[\\d.,]+\\s*(?:мкг|мг|ед|ме)${UNIT_END}`, "i", ), ), @@ -93,7 +104,7 @@ export const bank: LocaleBank = { unlessDeferred( RU_DEFERRAL, new RegExp( - `(?:начните|начинайте|перейдите|переходите)[^.?!]{0,15}[\\d.,]+\\s*(?:мкг|мг|мл|ед|ме)${UNIT_END}`, + `(?:начните|начинайте|перейдите|переходите)[^.?!]{0,15}[\\d.,]+\\s*(?:мкг|мг|ед|ме)${UNIT_END}`, "i", ), ), @@ -101,7 +112,7 @@ export const bank: LocaleBank = { unlessDeferred( RU_DEFERRAL, new RegExp( - `(?:удвойте|удвой|уполовиньте|уполовинь)[^.?!]{0,25}[\\d.,]+\\s*(?:мкг|мг|мл|ед|ме)${UNIT_END}`, + `(?:удвойте|удвой|уполовиньте|уполовинь)[^.?!]{0,25}[\\d.,]+\\s*(?:мкг|мг|ед|ме)${UNIT_END}`, "i", ), ), @@ -128,7 +139,7 @@ export const bank: LocaleBank = { ), // The same skip/stop in verb-first order: "пропустите дозу метформина". new RegExp( - `(?:пропустите|пропусти|отмените|отмени|прекратите|прекрати|бросьте|пропустить|отменить|прекратить)(?![а-яё])[^.?!]{0,15}${med}`, + `(?:пропустите|пропусти|отмените|отмени|прекратите|прекрати|бросьте|перестаньте|перестань|пропустить|отменить|прекратить|бросить|перестать)(?![а-яё])[^.?!]{0,15}${med}`, "i", ), // One more dose of a medication, closed by an imperative: @@ -152,21 +163,21 @@ export const bank: LocaleBank = { // на этой неделе") names no change verb and does not trip. dose: [ new RegExp( - `[\\d.,]+\\s*${DOSE_UNIT}\\s*(?:до|вплоть\\s*до)[^.?!]{0,20}(?:увелич|повыс|подним|нараст|начинай|переход|сниз|уменьш|пониз|опуст|сократ)`, + `[\\d.,]+\\s*${DOSE_UNIT}\\s*(?:до|вплоть\\s*до)${RU_GAP(20)}(?:увелич|повыс|подним|нараст|начинай|переход|сниз|уменьш|пониз|опуст|сократ)`, "i", ), new RegExp( // The lookahead keeps the past tense out: «дозу повысили» is a record, // «повысьте дозу» is a prescription. - `(?:увелич|повыс|подним|нараст|сниз|уменьш|пониз|опуст|сократ)(?!и?л)[а-яё]*[^.?!]{0,40}[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, + `(?:увелич|повыс|подним|нараст|сниз|уменьш|пониз|опуст|сократ)(?!и?л)[а-яё]*${RU_GAP(40)}[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}(?!\\s*${RU_DIET})`, "i", ), new RegExp( - `(?:рекоменд|совету|предлаг|стоит|нужно|следует|можно)[^.?!]{0,40}[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, + `(?:рекоменд|совету|предлаг|стоит|нужно|следует|можно)${RU_GAP(40)}[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}(?!\\s*${RU_DIET})`, "i", ), new RegExp( - `[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}[^.?!]{0,40}(?:рекоменд|совету|предлаг|стоит|принимайте|примите|начните|перейдите)`, + `[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}${RU_GAP(40)}(?:рекоменд|совету|предлаг|стоит|принимайте|примите|начните|перейдите)`, "i", ), new RegExp( diff --git a/src/lib/ai/safety/outbound-screen/shared.ts b/src/lib/ai/safety/outbound-screen/shared.ts index 31259d885..e57328eea 100644 --- a/src/lib/ai/safety/outbound-screen/shared.ts +++ b/src/lib/ai/safety/outbound-screen/shared.ts @@ -20,7 +20,7 @@ export const COUNT_UNIT = * units plus the count forms; `ml` is left out on purpose because * "increase your water intake to 2000 ml" is ordinary hydration advice. */ -export 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})`; +export 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 @@ -28,7 +28,7 @@ export const DRUG_UNIT = `(?:mg|mcg|µg|iu|ui|i\\.u\\.|units?|unidades?|unità|u * insulin-unit spellings the locales use (`eenheden` is the Dutch one, `ui` * the Portuguese abbreviation of "unidades internacionais"). */ -export const DOSE_UNIT = `(?:ml|${DRUG_UNIT})`; +export const DOSE_UNIT = `(?:ml|мл|${DRUG_UNIT})`; /** * What may follow a dose unit. A concentration is a lab value, not a dose: @@ -36,9 +36,11 @@ export const DOSE_UNIT = `(?:ml|${DRUG_UNIT})`; * 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. + * ("unità", "i.e.") where `\b` cannot. Cyrillic counts as a letter here: + * `\w` is ASCII-only, so without it «3 месяца» read as "3 МЕ". */ -export const UNIT_END = "(?!\\w|\\s*\\/\\s*(?:dl|l|min|mmol)\\b)"; +export const UNIT_END = + "(?![\\w\\u0400-\\u04FF]|\\s*\\/\\s*(?:dl|l|min|mmol)\\b)"; /** * One to four words of object between a change verb and its target — "your