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

This commit was merged in pull request #33.
This commit is contained in:
2026-10-03 15:03:11 +03:00
5 changed files with 1042 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
# Eval-набор для app/core/safety.py (issue #4). Только синтетика.
# label: acute | possible | none — ожидаемый уровень лексикона (без LLM).
# Требование: recall на acute = 100% (каждая acute-фраза → ACUTE). Разметку утверждает психиатр (#8).
cases:
# --- Прямые суицидальные высказывания ---------------------------------------------------------------
- {text: "Я хочу покончить с собой", label: acute}
- {text: "думаю о суициде каждый день", label: acute}
- {text: "Мысли о самоубийстве не отпускают", label: acute}
- {text: "Не хочу жить", label: acute}
- {text: "жить больше не хочется", label: acute}
- {text: "Хочу умереть.", label: acute}
- {text: "ничего не хочу, хочу умереть", label: acute}
- {text: "ничего не хочу хочу умереть", label: acute}
- {text: "Я не могу больше так жить", label: acute}
- {text: "Зачем вообще жить?", label: acute}
- {text: "нет смысла жить дальше", label: acute}
- {text: "Устал жить", label: acute}
- {text: "Я устал от жизни", label: acute}
- {text: "хочу повеситься", label: acute}
- {text: "присмотрел верёвку, сделаю петлю", label: acute}
- {text: "завтра шагну с крыши", label: acute}
- {text: "думаю броситься под поезд", label: acute}
- {text: "наглотаюсь таблеток и всё", label: acute}
- {text: "выпью все таблетки сертралина разом", label: acute}
- {text: "хочу вскрыть вены", label: acute}
- {text: "Опять режу себя", label: acute}
- {text: "хочу сделать себе больно", label: acute}
- {text: "Я написал прощальное письмо", label: acute}
- {text: "скоро меня не станет", label: acute}
- {text: "убью себя", label: acute}
- {text: "покончу со всем этим", label: acute}
- {text: "думаю о смерти постоянно", label: acute}
- {text: "не хочу больше просыпаться", label: acute}
- {text: "может мне просто не быть", label: acute}
- {text: "хочу наложить на себя руки", label: acute}
- {text: "у меня есть план как уйти", label: acute}
- {text: "нажрусь таблеток", label: acute}
# --- Косвенные / разговорные ------------------------------------------------------------------------
- {text: "лучше бы меня не было", label: acute}
- {text: "всем будет лучше без меня", label: acute}
- {text: "Хочу заснуть и не проснуться", label: acute}
- {text: "хочу исчезнуть навсегда", label: acute}
- {text: "Чтобы меня просто не было", label: acute}
- {text: "хочется сдохнуть", label: acute}
- {text: "пора свести счёты с жизнью", label: acute}
- {text: "думаю выпилиться", label: acute}
- {text: "не думаю о смерти, но иногда хочу уйти из жизни", label: acute}
- {text: "мыслей о суициде нет, но не хочу жить", label: acute}
# --- Ветеранский сленг ------------------------------------------------------------------------------
- {text: "лучше бы я там остался с пацанами", label: acute}
- {text: "Лучше бы я погиб тогда", label: acute}
- {text: "хочу к пацанам туда, на небо", label: acute}
- {text: "сам себе груз 200 сделаю", label: acute}
- {text: "проще стать двухсотым", label: acute}
- {text: "пущу себе пулю в лоб", label: acute}
- {text: "застрелюсь из наградного", label: acute}
- {text: "дома ружьё, и жить не хочется", label: acute}
- {text: "есть наградной пистолет, всё бессмысленно", label: acute}
# --- Опечатки, транслит, растягивание ---------------------------------------------------------------
- {text: "думаю о суециде", label: acute}
- {text: "хачу умиреть", label: acute}
- {text: "не хочу жиииить", label: acute}
- {text: "павеситься бы", label: acute}
- {text: "hochu umeret'", label: acute}
- {text: "сaмоубийство (латинская a)", label: acute}
- {text: "ПОКОНЧИТЬ С СОБОЙ", label: acute}
# --- Угроза другим и домашнее насилие ---------------------------------------------------------------
- {text: "Я его убью", label: acute}
- {text: "зарежу соседа", label: acute}
- {text: "Муж меня бьёт", label: acute}
- {text: "он угрожает убить меня", label: acute}
- {text: "вчера схватил меня за горло", label: acute}
- {text: "грозится ружьём, когда выпьет", label: acute}
- {text: "боюсь идти домой, он ломает вещи", label: possible}
# --- Возможный риск: безнадёжность, оружие ----------------------------------------------------------
- {text: "всё бессмысленно, устал от всего", label: possible, note: "демо-персона, день 13"}
- {text: "я обуза для семьи", label: possible}
- {text: "нет смысла что-то делать", label: possible}
- {text: "Не вижу выхода", label: possible}
- {text: "ничего не изменится", label: possible}
- {text: "никому я не нужен", label: possible}
- {text: "зачем я вообще вернулся", label: possible}
- {text: "дома есть ружьё", label: possible}
- {text: "храню наградное оружие в сейфе", label: possible}
- {text: "больше нет сил", label: possible}
- {text: "хочется кого-нибудь ударить", label: possible}
# --- Отрицания: не acute (possible допустим) --------------------------------------------------------
- {text: "Не хочу умирать", label: possible, negation: true}
- {text: "мыслей о суициде нет", label: possible, negation: true}
- {text: "не думаю о самоубийстве", label: possible, negation: true}
- {text: "суицидальных мыслей не было", label: possible, negation: true}
- {text: "никогда не думал о суициде", label: possible, negation: true}
- {text: "я бы никогда не покончил с собой", label: possible, negation: true}
# --- Нейтральные клинические фразы (none) -----------------------------------------------------------
- {text: "от таблеток только хуже, брошу", label: none, note: "демо-персона, день 5"}
- {text: "не могу уснуть без пива", label: none, note: "демо-персона, день 10"}
- {text: "тошнит после сертралина", label: none}
- {text: "сегодня настроение 4 из 10", label: none}
- {text: "Спал часов пять, снились кошмары", label: none}
- {text: "выпил таблетку после завтрака", label: none}
- {text: "Забыл принять лекарство вчера", label: none}
- {text: "голова болит и тревожно", label: none}
- {text: "сходил на работу, вечером гулял с собакой", label: none}
- {text: "Меня поставят на учёт?", label: none}
- {text: "трудно сосредоточиться на работе", label: none}
- {text: "пару бокалов вина в выходные", label: none}
- {text: "Меня трясёт перед сном", label: none}
- {text: "хочу записаться к врачу раньше", label: none}
- {text: "принимаю по одной таблетке утром", label: none}
- {text: "стало немного лучше, спасибо", label: none}
- {text: "Хочу жить нормально, без кошмаров", label: none}
+176
View File
@@ -0,0 +1,176 @@
"""Safety detector tests (issue #4). Recall on acute phrases must be 100%."""
import time
from pathlib import Path
import pytest
import yaml
from app.core.privacy import scrub
from app.core.safety import (
CrisisScript,
SafetyResult,
check,
crisis_reply,
load_lexicon,
normalise,
)
from app.core.types import RiskLevel, ScrubbedText
CASES_PATH = Path(__file__).parent / "data" / "safety_cases.yaml"
CASES: list[dict] = yaml.safe_load(CASES_PATH.read_text(encoding="utf-8"))["cases"]
LABELS = {"acute": RiskLevel.ACUTE, "possible": RiskLevel.POSSIBLE, "none": RiskLevel.NONE}
def _ids(cases: list[dict]) -> list[str]:
return [c["text"][:40] for c in cases]
ACUTE = [c for c in CASES if c["label"] == "acute"]
NOT_ACUTE = [c for c in CASES if c["label"] != "acute"]
def test_eval_set_size_and_labels() -> None:
assert len(CASES) >= 60
assert {c["label"] for c in CASES} == set(LABELS)
assert len(ACUTE) >= 30
@pytest.mark.parametrize("case", ACUTE, ids=_ids(ACUTE))
def test_acute_recall_is_100_percent(case: dict) -> None:
assert check(ScrubbedText(case["text"])).level == RiskLevel.ACUTE
@pytest.mark.parametrize("case", ACUTE, ids=_ids(ACUTE))
def test_acute_recall_survives_scrub(case: dict) -> None:
# Real pipeline: scrub() runs first, so PII placeholders must not break crisis detection.
assert check(scrub(case["text"])).level == RiskLevel.ACUTE
@pytest.mark.parametrize("case", NOT_ACUTE, ids=_ids(NOT_ACUTE))
def test_non_acute_cases_match_label(case: dict) -> None:
# Exact level documents the calibration; the hard requirement is "never ACUTE" for these.
level = check(ScrubbedText(case["text"])).level
assert level != RiskLevel.ACUTE
assert level == LABELS[case["label"]]
@pytest.mark.parametrize(
"text",
["всё бессмысленно, устал от всего", "от таблеток только хуже, брошу", "не могу уснуть без пива"],
)
def test_demo_persona_phrases(text: str) -> None:
level = check(ScrubbedText(text)).level
if "бессмысленно" in text:
assert level >= RiskLevel.POSSIBLE
else:
assert level == RiskLevel.NONE
@pytest.mark.parametrize(
"text",
["не хочу умирать", "мыслей о суициде нет", "не думаю о самоубийстве", "суицидальных мыслей не было"],
)
def test_negation_downgrades_to_possible_not_none(text: str) -> None:
result = check(ScrubbedText(text))
assert result.level == RiskLevel.POSSIBLE
assert result.negated_rules
def test_denied_mention_does_not_hide_affirmed_one() -> None:
assert check(ScrubbedText("не думаю о суициде. думаю о суициде каждую ночь")).level == RiskLevel.ACUTE
assert check(ScrubbedText("мысли о суициде не отпускают")).level == RiskLevel.ACUTE
def test_weapon_alone_possible_with_intent_acute() -> None:
alone = check(ScrubbedText("в сейфе лежит наградное оружие"))
assert alone.level == RiskLevel.POSSIBLE
assert alone.categories == ["weapon_access"]
combo = check(ScrubbedText("в сейфе лежит наградное оружие, я обуза"))
assert combo.level == RiskLevel.ACUTE
assert "combo.weapon_with_risk" in combo.matched_rules
@pytest.mark.parametrize(
("text", "llm", "level", "source"),
[
("сегодня всё нормально", None, RiskLevel.NONE, "none"),
("сегодня всё нормально", RiskLevel.NONE, RiskLevel.NONE, "none"),
("сегодня всё нормально", RiskLevel.POSSIBLE, RiskLevel.POSSIBLE, "llm"),
("сегодня всё нормально", RiskLevel.ACUTE, RiskLevel.ACUTE, "llm"),
("я обуза", None, RiskLevel.POSSIBLE, "lexicon"),
("я обуза", RiskLevel.ACUTE, RiskLevel.ACUTE, "both"),
("хочу умереть", RiskLevel.NONE, RiskLevel.ACUTE, "lexicon"), # LLM can never lower the risk
("хочу умереть", RiskLevel.POSSIBLE, RiskLevel.ACUTE, "both"),
],
)
def test_llm_signal_max_combination(text: str, llm: RiskLevel | None, level: RiskLevel, source: str) -> None:
result = check(ScrubbedText(text), llm_signal=llm)
assert result.level == level
assert result.source == source
def test_result_contains_no_raw_text() -> None:
text = "Алексей пишет: хочу покончить с собой сегодня вечером"
result = check(ScrubbedText(text))
dumped = result.model_dump_json()
for word in ("покончить", "собой", "Алексей", "вечером", normalise(text)):
assert word not in dumped
fields = {"level", "categories", "matched_rules", "negated_rules", "source"}
assert set(SafetyResult.model_fields) == fields
with pytest.raises(Exception): # noqa: B017 - frozen pydantic model raises ValidationError
result.level = RiskLevel.NONE # type: ignore[misc]
def test_empty_and_punctuation_only() -> None:
assert check(ScrubbedText("")).level == RiskLevel.NONE
assert check(ScrubbedText("?!...")).level == RiskLevel.NONE
def test_normalisation() -> None:
assert normalise("Всё БЕССМЫСЛЕННО!!!") == "все бессмысленно"
assert normalise("жиииить") == "жить"
assert normalise("сyицид") == "суицид" # Latin "y" inside a Russian word
assert normalise("pokonchit s soboy") == "покончит с собой"
def test_lexicon_has_all_categories_and_compiles() -> None:
lex = load_lexicon()
cats = {r.category for r in lex.rules}
expected = {"suicide", "self_harm", "harm_others", "weapon_access", "domestic_violence", "hopelessness"}
assert cats == expected
assert "психиатр" in lex.status
def test_performance_under_5ms_per_message() -> None:
texts = [c["text"] for c in CASES] + ["Сегодня был тяжёлый день, спал плохо, снились кошмары. " * 8]
check(ScrubbedText("прогрев"))
start = time.perf_counter()
rounds = 20
for _ in range(rounds):
for t in texts:
check(ScrubbedText(t))
per_msg_ms = (time.perf_counter() - start) * 1000 / (rounds * len(texts))
assert per_msg_ms < 5
def test_crisis_reply_patient_default() -> None:
script = crisis_reply(["suicide"])
assert isinstance(script, CrisisScript)
assert script.variant == "patient.default"
assert "112" in script.text
assert "8-800-2000-122" in script.text
assert "бесплатно, круглосуточно" in script.text
assert "уведомлён" in script.text
assert script.grounding in script.text
assert "психиатр (#8)" in script.status
def test_crisis_reply_variants() -> None:
dv = crisis_reply(["domestic_violence", "suicide"], audience="family")
assert dv.variant == "family.domestic_violence"
assert "потерпеть" in dv.text and "112" in dv.text and "8-800-2000-122" in dv.text
assert crisis_reply(["domestic_violence"]).variant == "patient.domestic_violence"
assert crisis_reply(["harm_others"]).variant == "patient.harm_others"
assert crisis_reply(["harm_others"], audience="family").variant == "family.default"
assert crisis_reply([], audience="family").variant == "family.default"