Merge pull request 'feat(core): safety-детектор, лексикон риска и кризисный скрипт' (#33) from feat/4-safety into dev
CI / ruff · pytest (push) Successful in 22s

This commit was merged in pull request #33.
This commit is contained in:
2026-10-03 15:03:11 +03:00
5 changed files with 1042 additions and 0 deletions
+429
View File
@@ -0,0 +1,429 @@
"""Crisis detector (guardrail #2, issue #4): runs BEFORE the LLM on every incoming message.
Algorithm of `check()`:
1. Normalise the (already scrubbed) text, see `normalise()`:
lowercase, ё→е, Latin look-alike letters inside Russian words → Cyrillic, all-Latin words transliterated
("hochu umeret" → "хочу умерет"), punctuation → spaces, runs of 3+ identical letters → one ("жииить").
Clause boundaries (punctuation) are remembered per token: negation never crosses them.
2. Run every precompiled regex of `config/lexicon/risk_ru.yaml` over the normalised string.
Regexes are written over word stems to cover Russian morphology and frequent typos.
3. Negation (only for `acute` rules): a small window around the match decides whether the phrase is denied
("не думаю о суициде", "мыслей о суициде нет"). A denied acute match is downgraded to POSSIBLE —
never to NONE: when in doubt we choose the higher risk. Window rules are documented in the YAML.
4. Combinations raise the level (weapon access + any risk signal → ACUTE).
5. Final level = max(lexicon level, llm_signal). The LLM can only raise the risk, never lower it.
The result carries rule ids and category names only — never the raw or normalised text (guardrail #8).
"""
import bisect
import re
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.config import ROOT_DIR
from app.core.types import RiskLevel, ScrubbedText
LEXICON_PATH = ROOT_DIR / "config" / "lexicon" / "risk_ru.yaml"
CRISIS_PATH = ROOT_DIR / "config" / "texts" / "crisis.yaml"
Audience = Literal["patient", "family"]
_LEVELS: dict[str, RiskLevel] = {"acute": RiskLevel.ACUTE, "possible": RiskLevel.POSSIBLE}
# ---------------------------------------------------------------------------------------------------------
# Public result types
# ---------------------------------------------------------------------------------------------------------
class SafetyResult(BaseModel):
"""Outcome of `check()`. Contains no message text by design."""
model_config = ConfigDict(frozen=True)
level: RiskLevel
categories: list[str] = Field(default_factory=list)
matched_rules: list[str] = Field(default_factory=list) # rule ids, e.g. "suicide.keyword"
negated_rules: list[str] = Field(default_factory=list) # subset of matched_rules downgraded by negation
source: Literal["lexicon", "llm", "both", "none"] = "none"
class CrisisResource(BaseModel):
model_config = ConfigDict(frozen=True)
name: str
phone: str
note: str = ""
class CrisisScript(BaseModel):
"""Fixed crisis reply (no LLM). `text` is the full ready-to-send message."""
model_config = ConfigDict(frozen=True)
variant: str
audience: Audience
body: str
resources: list[CrisisResource]
doctor_notified: str
grounding: str
status: str # "черновик, утверждает психиатр (#8)" until clinically approved
text: str
# ---------------------------------------------------------------------------------------------------------
# Lexicon config schema
# ---------------------------------------------------------------------------------------------------------
class _RuleCfg(BaseModel):
id: str
pattern: str
level: Literal["acute", "possible"]
negation: Literal["pre", "pre_post", "none"] = "pre"
@field_validator("pattern")
@classmethod
def _compiles(cls, v: str) -> str:
re.compile(v)
return v
class _CategoryCfg(BaseModel):
description: str = ""
rules: list[_RuleCfg]
class _ComboCfg(BaseModel):
id: str
all_of: list[str]
any_of: list[str] = Field(default_factory=list)
level: Literal["acute", "possible"]
class _NegationCfg(BaseModel):
window: int = 4
negators: set[str]
bridge_words: set[str]
post_negators: set[str]
post_not_followers: set[str]
class _LexiconCfg(BaseModel):
version: int
status: str = ""
categories: dict[str, _CategoryCfg]
combinations: list[_ComboCfg] = Field(default_factory=list)
negation: _NegationCfg
@model_validator(mode="after")
def _unique_ids(self) -> "_LexiconCfg":
ids = [r.id for c in self.categories.values() for r in c.rules] + [c.id for c in self.combinations]
dupes = {i for i in ids if ids.count(i) > 1}
if dupes:
raise ValueError(f"duplicate lexicon rule ids: {sorted(dupes)}")
unknown = {cat for c in self.combinations for cat in c.all_of + c.any_of} - set(self.categories)
if unknown:
raise ValueError(f"combinations reference unknown categories: {sorted(unknown)}")
return self
@dataclass(frozen=True, slots=True)
class _Rule:
id: str
category: str
regex: re.Pattern[str]
level: RiskLevel
negation: str
@dataclass(frozen=True, slots=True)
class Lexicon:
"""Compiled lexicon. Build once via `load_lexicon()`; matching is then pure regex work (< 1 ms)."""
rules: tuple[_Rule, ...]
combinations: tuple[_ComboCfg, ...]
negation: _NegationCfg
status: str
def load_lexicon(path: Path = LEXICON_PATH) -> Lexicon:
cfg = _LexiconCfg.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
rules = tuple(
_Rule(r.id, cat, re.compile(r.pattern), _LEVELS[r.level], r.negation)
for cat, c in cfg.categories.items()
for r in c.rules
)
return Lexicon(rules, tuple(cfg.combinations), cfg.negation, cfg.status)
@lru_cache(maxsize=1)
def _default_lexicon() -> Lexicon:
return load_lexicon()
# ---------------------------------------------------------------------------------------------------------
# Normalisation
# ---------------------------------------------------------------------------------------------------------
# Latin letters that look like Cyrillic ones: "сyицид" typed with a Latin "y" in a Russian word.
_HOMOGLYPHS = str.maketrans("aeopcxykmthb", "аеорсхукмтнв")
# Light transliteration for words typed entirely in Latin. Multi-letter sequences go first.
_TRANSLIT_MULTI = (
("shch", "щ"),
("sch", "щ"),
("yo", "е"),
("zh", "ж"),
("kh", "х"),
("ts", "ц"),
("ch", "ч"),
("sh", "ш"),
("yu", "ю"),
("ju", "ю"),
("ya", "я"),
("ja", "я"),
)
_TRANSLIT_SINGLE = str.maketrans(
"abvgdezijklmnoprstufhcwxq'",
"абвгдезийклмнопрстуфхцвхкь",
)
_LATIN_RE = re.compile(r"[a-z]")
_CYR_RE = re.compile(r"[а-я]")
_LATIN_Y_AFTER_VOWEL = re.compile(r"(?<=[aeiou])y(?![aeiou])")
_WORD_RE = re.compile(r"[0-9a-zа-я']+")
_REPEAT_RE = re.compile(r"([а-яa-z])\1{2,}")
_BOUNDARY_RE = re.compile(r"[.,;:!?()\[\]\"«»…—–\n]")
def _translit(word: str) -> str:
word = _LATIN_Y_AFTER_VOWEL.sub("й", word)
for lat, cyr in _TRANSLIT_MULTI:
word = word.replace(lat, cyr)
return word.translate(_TRANSLIT_SINGLE).replace("y", "ы")
def _fix_word(word: str) -> str:
if _LATIN_RE.search(word):
word = word.translate(_HOMOGLYPHS) if _CYR_RE.search(word) else _translit(word)
return word.replace("'", "")
@dataclass(frozen=True, slots=True)
class _Normalised:
text: str # tokens joined by single spaces
tokens: tuple[str, ...]
starts: tuple[int, ...] # char offset of each token in `text`
boundary_before: tuple[bool, ...] # True if a clause boundary (punctuation) precedes the token
def _normalise(raw: str) -> _Normalised:
s = raw.lower().replace("ё", "е")
s = _REPEAT_RE.sub(r"\1", s)
tokens: list[str] = []
boundaries: list[bool] = []
# Split into clauses first so negation lookup can stop at punctuation.
for clause in _BOUNDARY_RE.split(s):
first = True
for m in _WORD_RE.finditer(clause):
word = _fix_word(m.group())
if not word:
continue
tokens.append(word)
boundaries.append(first)
first = False
starts, pos = [], 0
for t in tokens:
starts.append(pos)
pos += len(t) + 1
return _Normalised(" ".join(tokens), tuple(tokens), tuple(starts), tuple(boundaries))
def normalise(text: str) -> str:
"""Normalised form used for matching (exposed for tests and lexicon authoring)."""
return _normalise(text).text
# ---------------------------------------------------------------------------------------------------------
# Negation
# ---------------------------------------------------------------------------------------------------------
def _negated_before(n: _Normalised, first_tok: int, neg: _NegationCfg) -> bool:
"""Walk left from the match over bridge words; a negator found before any other word denies it."""
i = first_tok
for _ in range(neg.window):
if i == 0 or n.boundary_before[i]:
return False
i -= 1
tok = n.tokens[i]
if tok in neg.negators:
return True
if tok not in neg.bridge_words:
return False
return False
def _negated_after(n: _Normalised, last_tok: int, neg: _NegationCfg) -> bool:
"""Walk right over bridge words looking for "нет" / "не было"-style denial ("мыслей о суициде нет")."""
i = last_tok
for _ in range(neg.window):
i += 1
if i >= len(n.tokens) or n.boundary_before[i]:
return False
tok = n.tokens[i]
if tok in neg.post_negators:
return True
if tok == "не":
nxt = i + 1
if nxt >= len(n.tokens) or n.boundary_before[nxt]:
return False
return n.tokens[nxt] in neg.post_not_followers
if tok not in neg.bridge_words:
return False
return False
def _is_negated(n: _Normalised, m: re.Match[str], mode: str, neg: _NegationCfg) -> bool:
if mode == "none":
return False
first_tok = bisect.bisect_right(n.starts, m.start()) - 1
last_tok = bisect.bisect_right(n.starts, max(m.end() - 1, m.start())) - 1
if _negated_before(n, first_tok, neg):
return True
return mode == "pre_post" and _negated_after(n, last_tok, neg)
def _has_affirmed_match(n: _Normalised, rule: _Rule, neg: _NegationCfg) -> bool:
"""True if at least one reading of the rule in the message is NOT denied.
We try the regex anchored at every token start, not only the leftmost `finditer` hits: in
"ничего не хочу хочу умереть" the leftmost match starts at the first "хочу" (preceded by "не"),
while the reading starting at the second "хочу" is affirmed. Any affirmed reading keeps ACUTE, so a
denied mention can never hide an affirmed one elsewhere in the message.
"""
for pos in n.starts:
m = rule.regex.match(n.text, pos)
if m is not None and not _is_negated(n, m, rule.negation, neg):
return True
# Safety net for a pattern without a leading \b that only matches mid-token.
return any(not _is_negated(n, m, rule.negation, neg) for m in rule.regex.finditer(n.text))
# ---------------------------------------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------------------------------------
def check(
text: ScrubbedText, llm_signal: RiskLevel | None = None, *, lexicon: Lexicon | None = None
) -> SafetyResult:
"""Classify crisis risk of one message. Deterministic; the LLM signal can only raise the level."""
lex = lexicon or _default_lexicon()
n = _normalise(text)
lexicon_level = RiskLevel.NONE
matched: list[str] = []
negated: list[str] = []
categories: set[str] = set()
if n.text:
for rule in lex.rules:
if rule.regex.search(n.text) is None:
continue
level = rule.level
if level == RiskLevel.ACUTE and not _has_affirmed_match(n, rule, lex.negation):
level = RiskLevel.POSSIBLE
negated.append(rule.id)
matched.append(rule.id)
categories.add(rule.category)
lexicon_level = max(lexicon_level, level)
for combo in lex.combinations:
if set(combo.all_of) <= categories and (not combo.any_of or categories & set(combo.any_of)):
matched.append(combo.id)
lexicon_level = max(lexicon_level, _LEVELS[combo.level])
llm_level = llm_signal if llm_signal is not None else RiskLevel.NONE
from_lex, from_llm = lexicon_level > RiskLevel.NONE, llm_level > RiskLevel.NONE
source: Literal["lexicon", "llm", "both", "none"] = (
"both" if from_lex and from_llm else "lexicon" if from_lex else "llm" if from_llm else "none"
)
return SafetyResult(
level=max(lexicon_level, llm_level),
categories=sorted(categories),
matched_rules=matched,
negated_rules=negated,
source=source,
)
# ---------------------------------------------------------------------------------------------------------
# Crisis script
# ---------------------------------------------------------------------------------------------------------
class _VariantCfg(BaseModel):
text: str
resources: list[str]
doctor_notified: str
grounding: str
class _CrisisCfg(BaseModel):
status: str
category_priority: list[str] = Field(default_factory=list)
resources: dict[str, CrisisResource]
variants: dict[str, _VariantCfg]
@model_validator(mode="after")
def _consistent(self) -> "_CrisisCfg":
for aud in ("patient", "family"):
if f"{aud}.default" not in self.variants:
raise ValueError(f"crisis.yaml: missing variant {aud}.default")
missing = {r for v in self.variants.values() for r in v.resources} - set(self.resources)
if missing:
raise ValueError(f"crisis.yaml: unknown resources {sorted(missing)}")
return self
@lru_cache(maxsize=4)
def _crisis_cfg(path: Path = CRISIS_PATH) -> _CrisisCfg:
return _CrisisCfg.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
def _render(body: str, resources: list[CrisisResource], doctor_notified: str, grounding: str) -> str:
lines = [body.strip(), ""]
for r in resources:
lines.append(f"• {r.name}: {r.phone}" + (f" ({r.note})" if r.note else ""))
lines += ["", doctor_notified, grounding]
return "\n".join(lines)
def crisis_reply(
categories: list[str], audience: Audience = "patient", *, path: Path = CRISIS_PATH
) -> CrisisScript:
"""Fixed crisis script for an ACUTE result. Never generated by the LLM."""
cfg = _crisis_cfg(path)
variant = f"{audience}.default"
for cat in cfg.category_priority:
if cat in categories and f"{audience}.{cat}" in cfg.variants:
variant = f"{audience}.{cat}"
break
v = cfg.variants[variant]
resources = [cfg.resources[r] for r in v.resources]
return CrisisScript(
variant=variant,
audience=audience,
body=v.text.strip(),
resources=resources,
doctor_notified=v.doctor_notified,
grounding=v.grounding,
status=cfg.status,
text=_render(v.text, resources, v.doctor_notified, v.grounding),
)
+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."
+111
View File
@@ -0,0 +1,111 @@
# Eval-набор для app/core/safety.py (issue #4). Только синтетика.
# label: acute | possible | none — ожидаемый уровень лексикона (без LLM).
# Требование: recall на acute = 100% (каждая acute-фраза → ACUTE). Разметку утверждает психиатр (#8).
cases:
# --- Прямые суицидальные высказывания ---------------------------------------------------------------
- {text: "Я хочу покончить с собой", label: acute}
- {text: "думаю о суициде каждый день", label: acute}
- {text: "Мысли о самоубийстве не отпускают", label: acute}
- {text: "Не хочу жить", label: acute}
- {text: "жить больше не хочется", label: acute}
- {text: "Хочу умереть.", label: acute}
- {text: "ничего не хочу, хочу умереть", label: acute}
- {text: "ничего не хочу хочу умереть", label: acute}
- {text: "Я не могу больше так жить", label: acute}
- {text: "Зачем вообще жить?", label: acute}
- {text: "нет смысла жить дальше", label: acute}
- {text: "Устал жить", label: acute}
- {text: "Я устал от жизни", label: acute}
- {text: "хочу повеситься", label: acute}
- {text: "присмотрел верёвку, сделаю петлю", label: acute}
- {text: "завтра шагну с крыши", label: acute}
- {text: "думаю броситься под поезд", label: acute}
- {text: "наглотаюсь таблеток и всё", label: acute}
- {text: "выпью все таблетки сертралина разом", label: acute}
- {text: "хочу вскрыть вены", label: acute}
- {text: "Опять режу себя", label: acute}
- {text: "хочу сделать себе больно", label: acute}
- {text: "Я написал прощальное письмо", label: acute}
- {text: "скоро меня не станет", label: acute}
- {text: "убью себя", label: acute}
- {text: "покончу со всем этим", label: acute}
- {text: "думаю о смерти постоянно", label: acute}
- {text: "не хочу больше просыпаться", label: acute}
- {text: "может мне просто не быть", label: acute}
- {text: "хочу наложить на себя руки", label: acute}
- {text: "у меня есть план как уйти", label: acute}
- {text: "нажрусь таблеток", label: acute}
# --- Косвенные / разговорные ------------------------------------------------------------------------
- {text: "лучше бы меня не было", label: acute}
- {text: "всем будет лучше без меня", label: acute}
- {text: "Хочу заснуть и не проснуться", label: acute}
- {text: "хочу исчезнуть навсегда", label: acute}
- {text: "Чтобы меня просто не было", label: acute}
- {text: "хочется сдохнуть", label: acute}
- {text: "пора свести счёты с жизнью", label: acute}
- {text: "думаю выпилиться", label: acute}
- {text: "не думаю о смерти, но иногда хочу уйти из жизни", label: acute}
- {text: "мыслей о суициде нет, но не хочу жить", label: acute}
# --- Ветеранский сленг ------------------------------------------------------------------------------
- {text: "лучше бы я там остался с пацанами", label: acute}
- {text: "Лучше бы я погиб тогда", label: acute}
- {text: "хочу к пацанам туда, на небо", label: acute}
- {text: "сам себе груз 200 сделаю", label: acute}
- {text: "проще стать двухсотым", label: acute}
- {text: "пущу себе пулю в лоб", label: acute}
- {text: "застрелюсь из наградного", label: acute}
- {text: "дома ружьё, и жить не хочется", label: acute}
- {text: "есть наградной пистолет, всё бессмысленно", label: acute}
# --- Опечатки, транслит, растягивание ---------------------------------------------------------------
- {text: "думаю о суециде", label: acute}
- {text: "хачу умиреть", label: acute}
- {text: "не хочу жиииить", label: acute}
- {text: "павеситься бы", label: acute}
- {text: "hochu umeret'", label: acute}
- {text: "сaмоубийство (латинская a)", label: acute}
- {text: "ПОКОНЧИТЬ С СОБОЙ", label: acute}
# --- Угроза другим и домашнее насилие ---------------------------------------------------------------
- {text: "Я его убью", label: acute}
- {text: "зарежу соседа", label: acute}
- {text: "Муж меня бьёт", label: acute}
- {text: "он угрожает убить меня", label: acute}
- {text: "вчера схватил меня за горло", label: acute}
- {text: "грозится ружьём, когда выпьет", label: acute}
- {text: "боюсь идти домой, он ломает вещи", label: possible}
# --- Возможный риск: безнадёжность, оружие ----------------------------------------------------------
- {text: "всё бессмысленно, устал от всего", label: possible, note: "демо-персона, день 13"}
- {text: "я обуза для семьи", label: possible}
- {text: "нет смысла что-то делать", label: possible}
- {text: "Не вижу выхода", label: possible}
- {text: "ничего не изменится", label: possible}
- {text: "никому я не нужен", label: possible}
- {text: "зачем я вообще вернулся", label: possible}
- {text: "дома есть ружьё", label: possible}
- {text: "храню наградное оружие в сейфе", label: possible}
- {text: "больше нет сил", label: possible}
- {text: "хочется кого-нибудь ударить", label: possible}
# --- Отрицания: не acute (possible допустим) --------------------------------------------------------
- {text: "Не хочу умирать", label: possible, negation: true}
- {text: "мыслей о суициде нет", label: possible, negation: true}
- {text: "не думаю о самоубийстве", label: possible, negation: true}
- {text: "суицидальных мыслей не было", label: possible, negation: true}
- {text: "никогда не думал о суициде", label: possible, negation: true}
- {text: "я бы никогда не покончил с собой", label: possible, negation: true}
# --- Нейтральные клинические фразы (none) -----------------------------------------------------------
- {text: "от таблеток только хуже, брошу", label: none, note: "демо-персона, день 5"}
- {text: "не могу уснуть без пива", label: none, note: "демо-персона, день 10"}
- {text: "тошнит после сертралина", label: none}
- {text: "сегодня настроение 4 из 10", label: none}
- {text: "Спал часов пять, снились кошмары", label: none}
- {text: "выпил таблетку после завтрака", label: none}
- {text: "Забыл принять лекарство вчера", label: none}
- {text: "голова болит и тревожно", label: none}
- {text: "сходил на работу, вечером гулял с собакой", label: none}
- {text: "Меня поставят на учёт?", label: none}
- {text: "трудно сосредоточиться на работе", label: none}
- {text: "пару бокалов вина в выходные", label: none}
- {text: "Меня трясёт перед сном", label: none}
- {text: "хочу записаться к врачу раньше", label: none}
- {text: "принимаю по одной таблетке утром", label: none}
- {text: "стало немного лучше, спасибо", label: none}
- {text: "Хочу жить нормально, без кошмаров", label: none}
+176
View File
@@ -0,0 +1,176 @@
"""Safety detector tests (issue #4). Recall on acute phrases must be 100%."""
import time
from pathlib import Path
import pytest
import yaml
from app.core.privacy import scrub
from app.core.safety import (
CrisisScript,
SafetyResult,
check,
crisis_reply,
load_lexicon,
normalise,
)
from app.core.types import RiskLevel, ScrubbedText
CASES_PATH = Path(__file__).parent / "data" / "safety_cases.yaml"
CASES: list[dict] = yaml.safe_load(CASES_PATH.read_text(encoding="utf-8"))["cases"]
LABELS = {"acute": RiskLevel.ACUTE, "possible": RiskLevel.POSSIBLE, "none": RiskLevel.NONE}
def _ids(cases: list[dict]) -> list[str]:
return [c["text"][:40] for c in cases]
ACUTE = [c for c in CASES if c["label"] == "acute"]
NOT_ACUTE = [c for c in CASES if c["label"] != "acute"]
def test_eval_set_size_and_labels() -> None:
assert len(CASES) >= 60
assert {c["label"] for c in CASES} == set(LABELS)
assert len(ACUTE) >= 30
@pytest.mark.parametrize("case", ACUTE, ids=_ids(ACUTE))
def test_acute_recall_is_100_percent(case: dict) -> None:
assert check(ScrubbedText(case["text"])).level == RiskLevel.ACUTE
@pytest.mark.parametrize("case", ACUTE, ids=_ids(ACUTE))
def test_acute_recall_survives_scrub(case: dict) -> None:
# Real pipeline: scrub() runs first, so PII placeholders must not break crisis detection.
assert check(scrub(case["text"])).level == RiskLevel.ACUTE
@pytest.mark.parametrize("case", NOT_ACUTE, ids=_ids(NOT_ACUTE))
def test_non_acute_cases_match_label(case: dict) -> None:
# Exact level documents the calibration; the hard requirement is "never ACUTE" for these.
level = check(ScrubbedText(case["text"])).level
assert level != RiskLevel.ACUTE
assert level == LABELS[case["label"]]
@pytest.mark.parametrize(
"text",
["всё бессмысленно, устал от всего", "от таблеток только хуже, брошу", "не могу уснуть без пива"],
)
def test_demo_persona_phrases(text: str) -> None:
level = check(ScrubbedText(text)).level
if "бессмысленно" in text:
assert level >= RiskLevel.POSSIBLE
else:
assert level == RiskLevel.NONE
@pytest.mark.parametrize(
"text",
["не хочу умирать", "мыслей о суициде нет", "не думаю о самоубийстве", "суицидальных мыслей не было"],
)
def test_negation_downgrades_to_possible_not_none(text: str) -> None:
result = check(ScrubbedText(text))
assert result.level == RiskLevel.POSSIBLE
assert result.negated_rules
def test_denied_mention_does_not_hide_affirmed_one() -> None:
assert check(ScrubbedText("не думаю о суициде. думаю о суициде каждую ночь")).level == RiskLevel.ACUTE
assert check(ScrubbedText("мысли о суициде не отпускают")).level == RiskLevel.ACUTE
def test_weapon_alone_possible_with_intent_acute() -> None:
alone = check(ScrubbedText("в сейфе лежит наградное оружие"))
assert alone.level == RiskLevel.POSSIBLE
assert alone.categories == ["weapon_access"]
combo = check(ScrubbedText("в сейфе лежит наградное оружие, я обуза"))
assert combo.level == RiskLevel.ACUTE
assert "combo.weapon_with_risk" in combo.matched_rules
@pytest.mark.parametrize(
("text", "llm", "level", "source"),
[
("сегодня всё нормально", None, RiskLevel.NONE, "none"),
("сегодня всё нормально", RiskLevel.NONE, RiskLevel.NONE, "none"),
("сегодня всё нормально", RiskLevel.POSSIBLE, RiskLevel.POSSIBLE, "llm"),
("сегодня всё нормально", RiskLevel.ACUTE, RiskLevel.ACUTE, "llm"),
("я обуза", None, RiskLevel.POSSIBLE, "lexicon"),
("я обуза", RiskLevel.ACUTE, RiskLevel.ACUTE, "both"),
("хочу умереть", RiskLevel.NONE, RiskLevel.ACUTE, "lexicon"), # LLM can never lower the risk
("хочу умереть", RiskLevel.POSSIBLE, RiskLevel.ACUTE, "both"),
],
)
def test_llm_signal_max_combination(text: str, llm: RiskLevel | None, level: RiskLevel, source: str) -> None:
result = check(ScrubbedText(text), llm_signal=llm)
assert result.level == level
assert result.source == source
def test_result_contains_no_raw_text() -> None:
text = "Алексей пишет: хочу покончить с собой сегодня вечером"
result = check(ScrubbedText(text))
dumped = result.model_dump_json()
for word in ("покончить", "собой", "Алексей", "вечером", normalise(text)):
assert word not in dumped
fields = {"level", "categories", "matched_rules", "negated_rules", "source"}
assert set(SafetyResult.model_fields) == fields
with pytest.raises(Exception): # noqa: B017 - frozen pydantic model raises ValidationError
result.level = RiskLevel.NONE # type: ignore[misc]
def test_empty_and_punctuation_only() -> None:
assert check(ScrubbedText("")).level == RiskLevel.NONE
assert check(ScrubbedText("?!...")).level == RiskLevel.NONE
def test_normalisation() -> None:
assert normalise("Всё БЕССМЫСЛЕННО!!!") == "все бессмысленно"
assert normalise("жиииить") == "жить"
assert normalise("сyицид") == "суицид" # Latin "y" inside a Russian word
assert normalise("pokonchit s soboy") == "покончит с собой"
def test_lexicon_has_all_categories_and_compiles() -> None:
lex = load_lexicon()
cats = {r.category for r in lex.rules}
expected = {"suicide", "self_harm", "harm_others", "weapon_access", "domestic_violence", "hopelessness"}
assert cats == expected
assert "психиатр" in lex.status
def test_performance_under_5ms_per_message() -> None:
texts = [c["text"] for c in CASES] + ["Сегодня был тяжёлый день, спал плохо, снились кошмары. " * 8]
check(ScrubbedText("прогрев"))
start = time.perf_counter()
rounds = 20
for _ in range(rounds):
for t in texts:
check(ScrubbedText(t))
per_msg_ms = (time.perf_counter() - start) * 1000 / (rounds * len(texts))
assert per_msg_ms < 5
def test_crisis_reply_patient_default() -> None:
script = crisis_reply(["suicide"])
assert isinstance(script, CrisisScript)
assert script.variant == "patient.default"
assert "112" in script.text
assert "8-800-2000-122" in script.text
assert "бесплатно, круглосуточно" in script.text
assert "уведомлён" in script.text
assert script.grounding in script.text
assert "психиатр (#8)" in script.status
def test_crisis_reply_variants() -> None:
dv = crisis_reply(["domestic_violence", "suicide"], audience="family")
assert dv.variant == "family.domestic_violence"
assert "потерпеть" in dv.text and "112" in dv.text and "8-800-2000-122" in dv.text
assert crisis_reply(["domestic_violence"]).variant == "patient.domestic_violence"
assert crisis_reply(["harm_others"]).variant == "patient.harm_others"
assert crisis_reply(["harm_others"], audience="family").variant == "family.default"
assert crisis_reply([], audience="family").variant == "family.default"