feat(core): safety detector with RU risk lexicon and fixed crisis script

- check(text, llm_signal) -> SafetyResult: normalisation (ё, homoglyphs, translit,
  repeats), stem regexes per category, negation window (acute -> possible only),
  weapon+risk combination, final level = max(lexicon, llm)
- crisis_reply(categories, audience) loads config/texts/crisis.yaml (patient/family,
  domestic violence and harm-to-others variants)
- 99-phrase eval set; acute recall 100%, < 5 ms per message

Refs #4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 15:01:35 +03:00
co-authored by Claude Opus 5.5
parent bfccc21a58
commit 97f3173c9f
5 changed files with 1035 additions and 0 deletions
+254
View File
@@ -0,0 +1,254 @@
# Лексикон риска для app/core/safety.py (guardrail №2, issue #4).
# СТАТУС: черновик, утверждает психиатр (#8).
#
# Как читать:
# - Шаблоны (`pattern`) — регулярные выражения Python над НОРМАЛИЗОВАННЫМ текстом:
# нижний регистр, ё→е, латиница-двойники в русских словах → кириллица, целиком латинские
# слова транслитерируются (hochu → хочу), пунктуация → пробел, 3+ одинаковых буквы → одна
# («жииить» → «жить»). Поэтому в шаблонах: только строчные, без «ё», слова через `\s+`.
# - Морфологию покрываем основами слов: `\bпоконч\w*`, `\bсу[иейы]ц[иы]д\w*` (заодно частые опечатки).
# - `level`: acute — кризис (фиксированный скрипт + немедленный алерт), possible — алерт врачу ≤ 24 ч + C-SSRS.
# - `negation`: как правило реагирует на отрицание (см. раздел negation внизу):
# pre — (по умолчанию) смотрим отрицание ПЕРЕД совпадением («не думаю о суициде»);
# pre_post — ещё и ПОСЛЕ («мыслей о суициде нет»); только для тем-существительных;
# none — отрицание не учитываем (шаблон сам содержит «не», либо фраза опасна в любом виде).
# Отрицание НИКОГДА не снижает уровень ниже possible: acute → possible. Сомневаемся — выбираем выше.
# - Ложноположительные срабатывания допустимы, пропуск кризиса — нет.
version: 1
status: "черновик, утверждает психиатр (#8)"
categories:
suicide:
description: "Суицидальные мысли, намерение, план, прощание (включая косвенные и сленговые формулировки)"
rules:
- id: suicide.kill_self
pattern: '\bпоконч\w*\s+(\w+\s+)?(с\s+|со\s+)?(собой|жизнью)\b'
level: acute
- id: suicide.keyword
pattern: '\bсу[иейы]ц[иы]д\w*'
level: acute
negation: pre_post
- id: suicide.samoubiystvo
pattern: '\bсамоуби\w*'
level: acute
negation: pre_post
- id: suicide.want_die
pattern: '\b(х[ао]чу|х[ао]чется|х[ао]тел\w*|охота|мечтаю|хотелось\s+бы)\s+(\w+\s+)?(умер(еть|ет)|умирать|умиреть|умерть|помереть|сдохнуть|исчезнуть|уйти\s+из\s+жизни|не\s+просыпаться|не\s+проснуться|на\s+тот\s+свет)\b'
level: acute
- id: suicide.dont_want_live
# «не хочу жить», «не хочется больше так жить»; «не» внутри шаблона → negation: none
pattern: '\bне\s+(х[ао]чу|х[ао]чется|х[ао]телось|х[ао]тел\w*|желаю)\s+(\w+\s+){0,2}ж[иы]т[ьи]?\b'
level: acute
negation: none
- id: suicide.live_dont_want
pattern: '\bж[иы]ть\s+(\w+\s+)?не\s+(х[ао]чу|х[ао]чется|охота|х[ао]телось)'
level: acute
negation: none
- id: suicide.no_reason_to_live
pattern: '\b(незачем|не\s+зачем|нет\s+смысла|смысла\s+нет|не\s+вижу\s+смысла|зачем\s+(мне\s+)?(вообще\s+)?(дальше\s+)?)\s*(\w+\s+)?ж[иы]ть\b'
level: acute
negation: none
- id: suicide.cant_live
pattern: '\bне\s+(могу|в\s+силах|хватает\s+сил)\s+(\w+\s+){0,2}ж[иы]ть\b'
level: acute
negation: none
- id: suicide.tired_of_life
pattern: '\b(устал\w*\s+(\w+\s+)?(жить|от\s+жизни)|надоело\s+жить|жить\s+надоело|жизнь\s+(\w+\s+)?не\s+(нужна|мила))\b'
level: acute
- id: suicide.better_not_exist
# «лучше бы меня не было», «лучше бы я там остался / погиб» (вина выжившего у ветеранов)
pattern: '\bлучше\s+бы\s+(меня|я|мне)?\s*(\w+\s+){0,2}(не\s+было|не\s+стало|не\s+родил\w*|не\s+жить|умер\w*|погиб\w*|сдох\w*|остал\w*\s+там|там\s+остал\w*|не\s+вернул\w*)'
level: acute
negation: none
- id: suicide.better_without_me
pattern: '\bлучше\s+(\w+\s+)?без\s+меня\b'
level: acute
negation: none
- id: suicide.so_that_i_am_gone
pattern: '\b(чтоб(ы)?|если)\s+меня\s+(\w+\s+)?не\s+(было|стало|станет)\b|\bменя\s+(\w+\s+)?не\s+станет\b|\bменя\s+(скоро|больше|совсем|просто|вообще)\s+не\s+будет\b|\b(скоро|больше)\s+меня\s+не\s+будет\b'
level: acute
negation: none
- id: suicide.not_wake_up
pattern: '\b(заснуть|уснуть|лечь|лечь\s+спать)\s+и\s+(больше\s+)?не\s+проснуться\b|\bбольше\s+не\s+(просыпаться|проснуться)\b'
level: acute
negation: none
- id: suicide.dont_want_wake
pattern: '\bне\s+(х[ао]чу|х[ао]чется|х[ао]телось\s+бы)\s+(\w+\s+)?просып\w*|\b(мне|просто|лучше)\s+не\s+быть\b'
level: acute
negation: none
- id: suicide.lay_hands
# «наложить на себя руки»
pattern: '\bналож\w*\s+(\w+\s+)?на\s+себя\s+руки\b|\bруки\s+на\s+себя\s+налож\w*'
level: acute
- id: suicide.plan
pattern: '\bплан\w*\s+(\w+\s+)?(как\s+)?(уйти|умереть|покончить|свести|убить\s+себя|сдохнуть)\b'
level: acute
- id: suicide.leave_forever
pattern: '\b(исчезнуть|исчезну|уйти|уйду)\s+(\w+\s+)?(навсегда|насовсем)\b|\bуйти\s+из\s+жизни\b|\bсч[её]ты\s+с\s+жизнью\b'
level: acute
- id: suicide.end_it_all
pattern: '\b(закончить|закончу|покончить|покончу|кончить|кончу)\s+(со\s+|с\s+)?(всем|все|этим)\s*(этим|разом|сразу)?\b'
level: acute
- id: suicide.hang
pattern: '\bп[ао]ве(шусь|шаюсь|шаться|сит?ь?ся|сился|силась|силсь)\b|\bв\s+петлю\b|\bпетл[яюеи]\b|\bвздерн\w*'
level: acute
- id: suicide.shoot
pattern: '\bзастрел\w*|\b(пулю|пуля)\s+(себе\s+)?(в\s+)?(лоб|висок|голову|рот)\b|\bпустить\s+(себе\s+)?пулю|\bствол\s+(к|в)\s+(виску|висок|рот)\b'
level: acute
- id: suicide.jump
pattern: '\b(прыг\w*|шагн\w*|выпрыг\w*|сброс\w*|выйти|выйду|кинуться|кинусь|брошусь|броситься|лечь)\s+(\w+\s+)?(с\s+(крыши|моста|балкона|многоэтажки|высоты)|из\s+окна|в\s+окно|под\s+(поезд|машину|электричку|колеса|грузовик))\b'
level: acute
- id: suicide.kill_myself
pattern: '\b(уб(ить|ью|ил|ила|ю)|угроб(ить|лю))\s+(\w+\s+)?себя\b|\bубиться\b|\bсдохн(уть|у)\b'
level: acute
- id: suicide.slang
# молодёжный/интернет-сленг: «выпилиться», «роскомнадзорнуться», «самовыпил»
pattern: '\bвыпил(иться|юсь|ился|илась|ится)\b|\bсамовыпил\w*|\bроскомнадзорн\w*|\bркн(уться|усь|улся|улась)\b|\bвыйти\s+в\s+окно\b'
level: acute
- id: suicide.veteran_200
# ветеранский сленг: «стать двухсотым», «уйти в двухсотые», «груз 200 сам себе»
pattern: '\b(стать|стану|быть|буду|был|уйти|уйду|лучше\s+бы\s+я\s+был|сам|сам\s+себе)\s+(\w+\s+)?(двухсот\w*|200\w*|груз\w*\s+200)'
level: acute
- id: suicide.join_fallen
# «уйти к пацанам туда / на небо», «к ребятам, которые не вернулись»
pattern: '\bк\s+(пацанам|ребятам|братишкам|братьям|своим)\s+(\w+\s+)?(туда|на\s+небо|наверх|на\s+тот\s+свет|которые\s+(погибли|не\s+вернулись|там\s+остались))'
level: acute
- id: suicide.join_comrades_vague
pattern: '\b(уйти|уйду|хочу|отправиться|отправлюсь|пора)\s+(\w+\s+)?к\s+(пацанам|ребятам|братишкам)\b'
level: possible
- id: suicide.thoughts_of_death
pattern: '\b(мысл\w*|дума\w*|думаю)\s+(\w+\s+)?(о|об|про)\s+(своей\s+)?смерт\w*'
level: acute
negation: pre_post
- id: suicide.farewell
pattern: '\bпредсмертн\w*|\bпрощальн\w*\s+(записк|письм|сообщени)\w*|\bпрощайте\b|\bэто\s+(мое\s+)?последнее\s+(сообщение|письмо)\b|\bраздал\w*\s+(\w+\s+)?вещи\b'
level: acute
- id: suicide.no_way_out_but
pattern: '\bединственн\w*\s+выход\b'
level: possible
self_harm:
description: "Самоповреждение, передозировка"
rules:
- id: self_harm.cut
pattern: '\b(вскр\w*|перерез\w*|порез\w*|резать|режу|резанул\w*)\s+(\w+\s+)?(себе\s+)?(вены|вену|руки|руку|запясть\w*|себя)\b|\bвскр(ыться|оюсь|ылся|ылась)\b'
level: acute
- id: self_harm.hurt_self
pattern: '\b(причин\w*|наношу|нанести|нанесу)\s+(себе\s+)?(вред|боль|порезы|увечь\w*|травм\w*)\b|\b(сделать|делаю|сделаю)\s+себе\s+больно\b|\b(жгу|прижигаю|обжигаю)\s+себя\b|\bселф\s*харм\w*|\bселфхарм\w*|\bсамоповрежд\w*'
level: acute
- id: self_harm.overdose_all
pattern: '\b(выпить|выпью|выпила|выпил|сожрать|сожру|съесть|съем|принять|приму|глотнуть|закинуться|закинусь)\s+(\w+\s+){0,2}(все|всю|целую|целый|горсть|разом|сразу|пачку|упаковку|блистер)\s+(\w+\s+){0,2}(таблет|колес|пачк|упаковк|снотворн|лекарств|блистер|антидепрессант|пилюл|сертралин|феназепам)\w*'
level: acute
- id: self_harm.overdose_pack
pattern: '\b(выпить|выпью|выпила|выпил|сожрать|сожру|съесть|съем|принять|приму)\s+(\w+\s+)?(всю|целую)\s+(пачку|упаковку|коробку)\b'
level: acute
- id: self_harm.overdose_binge
pattern: '\b(нажр|наем|нажир|обожр|нажира|закинусь|закинуться)\w*\s+(\w+\s+)?(таблет|колес|снотворн|лекарств|антидепрессант|феназепам)\w*'
level: acute
- id: self_harm.overdose_word
pattern: '\bнаглота\w*|\bпередоз\w*|\bотрав(иться|люсь)\b'
level: acute
- id: self_harm.hit_self
pattern: '\b(бью|избиваю)\s+себя\b|\bбьюсь\s+(\w+\s+)?головой\b'
level: possible
harm_others:
description: "Угроза причинить вред другим"
rules:
- id: harm_others.kill_threat
pattern: '\bуб(ь|)(ю|ем)\b|\bприбь(ю|ем)\b|\bзареж(у|ем)\b|\bпорешу\b|\bурою\b|\bпокалечу\b|\bзадуш(у|ить)\b|\bперестреля\w*|\bсверну\s+(\w+\s+)?шею\b'
level: acute
- id: harm_others.kill_target
pattern: '\bуби(ть|л)\s+(\w+\s+)?(его|ее|их|всех|кого\s+нибудь|жену|мужа|соседа|соседей|тещу|начальника|ребенка|детей)\b'
level: acute
- id: harm_others.blow_up
pattern: '\b(взорв|подорв)(у|ать|ем)\b'
level: acute
- id: harm_others.urge_to_hit
pattern: '\b(х[ао]чется|х[ао]чу|тянет)\s+(кого\s+(то|нибудь)\s+)?(ударить|избить|врезать|покалечить)\b'
level: possible
weapon_access:
description: "Доступ к оружию. Само по себе — possible; вместе с суицидальным/агрессивным/безнадёжным сигналом — acute (см. combinations)"
rules:
- id: weapon.gun
pattern: '\bруж[ьъ]?[еяю]\w*|\bпистолет\w*|\bавтомат(а|ом|ы|у)?\b|\bкарабин\w*|\bвинтовк\w*|\bобрез\b|\bсайг[аиу]\b|\bтравмат\w*|\bмакаров\w*|\bкалаш\w*'
level: possible
- id: weapon.generic
# включая «наградное оружие», «оружейный сейф»
pattern: '\bоруж\w*|\bствол(ы|а|ов)?\b|\bпатрон(ы|ов)?\b|\bгранат(а|у|ы)\b|\bбоеприпас\w*'
level: possible
domestic_violence:
description: "Домашнее насилие (сообщает пациент или член семьи)"
rules:
- id: dv.beats
pattern: '\b(бьет|бил|избива\w*|избил\w*|душил\w*|душит|ударил\w*|колотит)\s+(меня|нас|детей|ребенка|маму|сына|дочь)\b|\b(меня|нас|детей|ребенка)\s+(\w+\s+)?(бьет|бил|избива\w*|избил\w*|душил\w*|душит|ударил\w*)\b(?!\s+(дрожь|озноб|током|тик|кашель))'
level: acute
- id: dv.threat
pattern: '\b(угрожа|гроз)\w*\s+(\w+\s+){0,2}(убить|убьет|зарезать|зарежет|пристрелить|застрелить|покалечить|оружием|ружьем|пистолетом|ножом)\b|\b(убьет|прибьет|зарежет|пристрелит|придушит)\s+(меня|нас|детей|ребенка|маму)\b'
level: acute
- id: dv.raised_hand
pattern: '\bподн(ял|има)\w*\s+на\s+(меня|нас|детей|ребенка)\s+руку\b|\bруку\s+на\s+(меня|нас|детей|ребенка)\b|\b(схватил|хватает|сдавил)\w*\s+(\w+\s+)?за\s+(горло|шею)\b|\bброса\w*\s+на\s+(меня|нас|детей)\b'
level: acute
- id: dv.keyword
pattern: '\bдомашн\w*\s+насили\w*|\bабьюз\w*|\bтиран\w*'
level: acute
negation: pre_post
- id: dv.fear
pattern: '\bбоюсь\s+(\w+\s+)?(мужа|жены|отца|отчима|сына|его|ее|возвращаться\s+домой|идти\s+домой)\b|\b(ломает|крушит|разбил|разбивает)\s+(\w+\s+)?(вещи|мебель|посуду|дверь|двери)\b|\b(запира|запер)\w*\s+(меня|нас)\b|\bне\s+выпускает\s+(\w+\s+)?из\s+дома\b|\bв\s+синяках\b'
level: possible
hopelessness:
description: "Безнадёжность, обесценивание себя — сигнал для C-SSRS (possible)"
rules:
- id: hope.meaningless
pattern: '\bбессмысл\w*|\bнет\s+(\w+\s+)?смысла\b|\bсмысла\s+(\w+\s+)?нет\b|\bне\s+вижу\s+(\w+\s+)?смысла\b|\bникакого\s+смысла\b|\bсмысл\w*\s+(\w+\s+)?(потерян|пропал)\w*'
level: possible
- id: hope.tired_of_everything
pattern: '\bустал\w*\s+(\w+\s+)?от\s+(всего|всех|этого\s+всего)\b|\bвс[её]\s+(\w+\s+)?надоело\b|\bничего\s+не\s+х[ао]чу\b'
level: possible
- id: hope.burden
pattern: '\bобуз[аеоуы]\w*'
level: possible
- id: hope.no_way_out
pattern: '\bбезнад[её]ж\w*|\bбезысход\w*|\bвыхода\s+(\w+\s+)?нет\b|\bнет\s+(\w+\s+)?выхода\b|\bне\s+вижу\s+(\w+\s+)?выхода\b|\bтупик\w*'
level: possible
- id: hope.no_future
pattern: '\bничего\s+(\w+\s+)?не\s+(изменится|поменяется|поможет|исправить)\b|\bне\s+вижу\s+(\w+\s+)?будущ\w*|\bбудущего\s+(\w+\s+)?нет\b'
level: possible
- id: hope.worthless
pattern: '\bникому\s+(\w+\s+)?не\s+нуж(ен|на|ны)\b|\bвсем\s+(\w+\s+)?(на\s+меня\s+)?плевать\b|\bзачем\s+я\s+(вообще\s+)?(живу|выжил|вернулся|нужен|родился)\b|\bя\s+(\w+\s+)?ничтожество\b'
level: possible
- id: hope.cant_take_it
pattern: '\bсил\s+(\w+\s+)?нет\s+(терпеть|больше)\b|\bбольше\s+нет\s+сил\b|\bне\s+выдержу\b|\bсдаюсь\b|\bпустот[аеуы]\b'
level: possible
# Комбинации: если в одном сообщении есть совпадения из всех `all_of` и хотя бы одной из `any_of` категорий,
# итоговый уровень поднимается до `level`. Ограничение доступа к средствам — одна из самых доказательных мер
# профилактики, поэтому оружие + любой сигнал риска = acute (ADR-006: «доступ к оружию при риске» → красный).
combinations:
- id: combo.weapon_with_risk
all_of: [weapon_access]
any_of: [suicide, self_harm, harm_others, hopelessness, domestic_violence]
level: acute
# Обработка отрицаний (только для правил уровня acute; снижает acute → possible, не ниже).
# ПЕРЕД совпадением: идём назад по словам (не дальше `window` и не через границу предложения/запятую),
# пропуская «связки» (bridge_words). Встретили отрицание (negators) → правило отрицается.
# Любое другое слово — стоп, отрицания нет. Пример: «не думаю о суициде» → не ← думаю ← о ← [суицид].
# «ничего не хочу хочу умереть» → перед совпадением «хочу» (не связка) → стоп → acute сохраняется.
# ПОСЛЕ совпадения (только negation: pre_post): пропускаем связки, затем ищем post_negators
# («нет», «нету», «отсутствуют») или «не» + слово из post_not_followers («не было», «не возникало»).
# «мысли о суициде не отпускают» → «не отпускают» не в списке → acute сохраняется.
negation:
window: 4
negators: [не, нет, ни, нету, никогда, без, никаких, нисколько]
bridge_words: [о, об, обо, про, думаю, думал, думала, думаем, задумывался, задумывалась, мыслей, мысли, мысль,
планирую, планировал, планировала, собираюсь, собирался, собиралась, планов, намерений,
у, меня, есть, было, были, сейчас, вообще, совсем, даже, уже, точно, давно, разу, раз, и,
суицидальных, суицидальные, таких, этих, никаких, никогда, дома, в, этом]
post_negators: [нет, нету, отсутствуют, отсутствует]
post_not_followers: [было, были, бывает, бывало, возникает, возникали, возникало, возникают, появлялось,
появлялись, появляются, посещают, посещали, испытываю, испытывал, испытывала]
+72
View File
@@ -0,0 +1,72 @@
# Кризисный скрипт (guardrail №2, issue #4). Отправляется ДОСЛОВНО при safety = acute; LLM не вызывается.
# СТАТУС: черновик, утверждает психиатр (#8).
#
# Выбор варианта (app/core/safety.py::crisis_reply): `<audience>.<категория>` по порядку category_priority,
# иначе `<audience>.default`. Итоговое сообщение = text + ресурсы + doctor_notified + grounding.
status: "черновик, утверждает психиатр (#8)"
category_priority: [domestic_violence, harm_others]
resources:
emergency:
name: "Экстренные службы"
phone: "112"
note: "если есть угроза жизни прямо сейчас"
helpline:
name: "Телефон доверия"
phone: "8-800-2000-122"
note: "бесплатно, круглосуточно"
police:
name: "Полиция"
phone: "102"
note: "если вам угрожают или применяют насилие"
variants:
patient.default:
text: |-
Мне очень жаль, что вам сейчас так тяжело. Вы не одни, и хорошо, что вы об этом написали.
Если вы в опасности прямо сейчас — пожалуйста, позвоните 112. Если рядом есть то, чем можно причинить
себе вред (оружие, таблетки), по возможности уберите это подальше или отдайте близкому человеку
и побудьте рядом с кем-то, кому доверяете.
resources: [emergency, helpline]
doctor_notified: "Ваш врач уже уведомлён и свяжется с вами."
grounding: "Сейчас попробуйте сделать медленный вдох на 4 счёта и выдох на 6 — несколько раз. Я рядом."
patient.harm_others:
text: |-
Похоже, сейчас очень много злости и напряжения — это тяжело выдерживать. Вы не одни.
Пожалуйста, отойдите от человека и от всего, чем можно причинить вред, и побудьте там, где спокойнее.
Если кому-то угрожает опасность прямо сейчас — позвоните 112.
resources: [emergency, helpline]
doctor_notified: "Ваш врач уже уведомлён и свяжется с вами."
grounding: "Поставьте ступни на пол, сделайте медленный вдох на 4 счёта и выдох на 6 — несколько раз."
patient.domestic_violence:
text: |-
Мне очень жаль, что с вами это происходит. Вы не виноваты в насилии, и вы не обязаны его терпеть.
Если вам или детям угрожает опасность — уйдите в безопасное место (к соседям, родным) и позвоните 112.
Кризисные службы и телефон доверия помогут найти безопасное место и поддержку.
resources: [emergency, police, helpline]
doctor_notified: "Ваш врач уже уведомлён."
grounding: "Если вы сейчас в безопасности — сделайте медленный вдох на 4 счёта и выдох на 6. Я рядом."
family.default:
text: |-
Спасибо, что написали — вы делаете очень важное дело. Вы не одни.
Если есть угроза жизни прямо сейчас — позвоните 112. По возможности не оставляйте близкого одного,
спокойно уберите из доступа оружие, таблетки и острые предметы. Не спорьте и не стыдите —
просто будьте рядом и слушайте.
resources: [emergency, helpline]
doctor_notified: "Лечащий врач уже уведомлён."
grounding: "Позаботьтесь и о себе: сделайте медленный вдох на 4 счёта и выдох на 6."
family.domestic_violence:
text: |-
Ваша безопасность и безопасность детей важнее всего. Вы не виноваты в насилии и не обязаны его
терпеть — никто не вправе уговаривать вас «потерпеть», даже если близкий болен.
Если угрожает опасность — уйдите в безопасное место (к соседям, родным) и позвоните 112.
Кризисные службы помогут с временным убежищем и поддержкой.
resources: [emergency, police, helpline]
doctor_notified: "Лечащий врач уже уведомлён."
grounding: "Когда будете в безопасности — сделайте медленный вдох на 4 счёта и выдох на 6."