release: dev → main (сдача хакатона) #106
@@ -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),
|
||||||
|
)
|
||||||
@@ -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: [было, были, бывает, бывало, возникает, возникали, возникало, возникают, появлялось,
|
||||||
|
появлялись, появляются, посещают, посещали, испытываю, испытывал, испытывала]
|
||||||
@@ -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."
|
||||||
@@ -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}
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user