From 97f3173c9f9d4e421e8558b794c5afffc7218f61 Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sat, 3 Oct 2026 15:01:34 +0300 Subject: [PATCH 1/2] feat(core): safety detector with RU risk lexicon and fixed crisis script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/core/safety.py | 429 +++++++++++++++++++++++++++++++++++ config/lexicon/risk_ru.yaml | 254 +++++++++++++++++++++ config/texts/crisis.yaml | 72 ++++++ tests/data/safety_cases.yaml | 111 +++++++++ tests/test_safety.py | 169 ++++++++++++++ 5 files changed, 1035 insertions(+) create mode 100644 app/core/safety.py create mode 100644 config/lexicon/risk_ru.yaml create mode 100644 config/texts/crisis.yaml create mode 100644 tests/data/safety_cases.yaml create mode 100644 tests/test_safety.py diff --git a/app/core/safety.py b/app/core/safety.py new file mode 100644 index 0000000..2ea5fec --- /dev/null +++ b/app/core/safety.py @@ -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), + ) diff --git a/config/lexicon/risk_ru.yaml b/config/lexicon/risk_ru.yaml new file mode 100644 index 0000000..b97fbc7 --- /dev/null +++ b/config/lexicon/risk_ru.yaml @@ -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: [было, были, бывает, бывало, возникает, возникали, возникало, возникают, появлялось, + появлялись, появляются, посещают, посещали, испытываю, испытывал, испытывала] diff --git a/config/texts/crisis.yaml b/config/texts/crisis.yaml new file mode 100644 index 0000000..e8865db --- /dev/null +++ b/config/texts/crisis.yaml @@ -0,0 +1,72 @@ +# Кризисный скрипт (guardrail №2, issue #4). Отправляется ДОСЛОВНО при safety = acute; LLM не вызывается. +# СТАТУС: черновик, утверждает психиатр (#8). +# +# Выбор варианта (app/core/safety.py::crisis_reply): `.<категория>` по порядку category_priority, +# иначе `.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." diff --git a/tests/data/safety_cases.yaml b/tests/data/safety_cases.yaml new file mode 100644 index 0000000..5c7c664 --- /dev/null +++ b/tests/data/safety_cases.yaml @@ -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} diff --git a/tests/test_safety.py b/tests/test_safety.py new file mode 100644 index 0000000..cec5c6f --- /dev/null +++ b/tests/test_safety.py @@ -0,0 +1,169 @@ +"""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.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", 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" From 123111bc9d46a3cb1f791515224f80e775fa6430 Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sat, 3 Oct 2026 15:02:04 +0300 Subject: [PATCH 2/2] test(core): check acute recall after scrub() pipeline Refs #4 Co-Authored-By: Claude Opus 5.5 --- tests/test_safety.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_safety.py b/tests/test_safety.py index cec5c6f..d2cfeb4 100644 --- a/tests/test_safety.py +++ b/tests/test_safety.py @@ -6,6 +6,7 @@ from pathlib import Path import pytest import yaml +from app.core.privacy import scrub from app.core.safety import ( CrisisScript, SafetyResult, @@ -40,6 +41,12 @@ 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.