Merge pull request 'feat(core): safety-детектор, лексикон риска и кризисный скрипт' (#33) from feat/4-safety into dev
CI / ruff · pytest (push) Successful in 22s
CI / ruff · pytest (push) Successful in 22s
This commit was merged in pull request #33.
This commit is contained in:
@@ -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,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"
|
||||
Reference in New Issue
Block a user