diff --git a/app/dialog/engine.py b/app/dialog/engine.py index 495bf48..2f7d43e 100644 --- a/app/dialog/engine.py +++ b/app/dialog/engine.py @@ -28,6 +28,7 @@ Logs carry only the token, states and event codes (guardrail #8). import asyncio import logging +import re from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Any @@ -67,8 +68,12 @@ from app.services.contracts import MonitoringService, get_monitoring log = logging.getLogger(__name__) LAST_PROMPT = "last_prompt" +MEDICINE_WORDS = re.compile( + r"(лекарств|таблет|препарат|сертралин|пароксетин|флуоксетин|эсциталопрам|антидепрессант|побочк)", re.I +) # States with their own free-text handling (idle → flows' `text:idle`; explicit question states). SIDE_ANSWER_SKIP = frozenset({"idle", "kb.question", "meds.question"}) +FREE_TEXT_PREFIXES = ("daily.", "meds.", "kb.") # steps that expect free text (check-in, side effects, questions) # Enter hooks may chain (scale done → next onboarding state → next scale); a cycle must not hang a request. MAX_ENTER_CHAIN = 8 _patient_locks = WeakValueDictionary() @@ -309,12 +314,18 @@ class Engine: doctor acknowledges it, and acute messages never get here (handled above).""" from app.dialog.flows.knowledge import knowledge_answer, looks_like_question - in_crisis = crisis_open(ctx.data) or ctx.state == "crisis" - if ctx.text is None or (ctx.state in SIDE_ANSWER_SKIP and not in_crisis): - return None - if not looks_like_question(ctx.text): - return None - answer = await knowledge_answer(ctx, explicit=True) + in_crisis = (crisis_open(ctx.data) or ctx.state == "crisis") and await ctx.monitoring.is_in_crisis(ctx.token) + free_text_step = ctx.state in SIDE_ANSWER_SKIP or ctx.state.startswith(FREE_TEXT_PREFIXES) + if ctx.text is None or (free_text_step and not in_crisis): + return None # idle / check-in / question states parse free text themselves + asks = looks_like_question(ctx.text) or bool(MEDICINE_WORDS.search(ctx.text)) + if asks: + answer = await knowledge_answer(ctx, explicit=True) + elif in_crisis: + # Not a question, but in a crisis every message gets a human reply (with contacts below), never silence. + answer = await ctx.gateway.phrase("support") + else: + return None # e.g. a free-text answer to the current scale item — the step handles it if not answer: return None log.info("dialog side answer token=%s state=%s crisis=%s", ctx.token, ctx.state, in_crisis) diff --git a/app/services/monitoring.py b/app/services/monitoring.py index 34d82b6..7497116 100644 --- a/app/services/monitoring.py +++ b/app/services/monitoring.py @@ -51,6 +51,37 @@ async def refresh_status(session: AsyncSession, token: str) -> str: return status.color +# Which data a crisis rule is triggered by: a crisis for the same code is raised again only when such data is +# newer than the last alert with that code (acked or not) — otherwise an acked PHQ-9 item 9 re-fired on every +# data change for 24 h (seen live: AUDIT-C answers 0/0/0 → crisis again). +_CRISIS_TRIGGERS: dict[str, tuple[str, ...]] = { + "phq9_item9": ("phq9",), + "cssrs_high": ("cssrs_screen",), + "cssrs_moderate": ("cssrs_screen",), + "weapon_access_risk": ("phq9", "cssrs_screen"), +} + + +async def _drop_handled_crises(s: AsyncSession, token: str, actions: list[Action]) -> list[Action]: + crisis_codes = {a.code for a in actions if a.type == "crisis"} + if not crisis_codes: + return actions + alerts = await repo.list_alerts(s, token=token) + results = await repo.list_scale_results(s, token) + handled: set[str] = set() + for code in crisis_codes: + last_alert = max((a.ts for a in alerts if a.code == code), default=None) + if last_alert is None: + continue + scales = _CRISIS_TRIGGERS.get(code) + last_data = max((r.ts for r in results if scales is None or r.scale in scales), default=None) + if last_data is None or last_data <= last_alert: + handled.add(code) + if handled: + log.info("monitoring token=%s crisis already handled=%s", token, sorted(handled)) + return [a for a in actions if a.code not in handled] + + def _drop_running_crises(actions: list[Action], open_acute: set[str]) -> list[Action]: """Silence every action of a crisis rule whose acute alert is still open (see module docstring).""" running = {a.code for a in actions if a.type == "crisis" and a.code in open_acute} @@ -68,6 +99,7 @@ class RuleMonitoring: return [] open_acute = {a.code for a in await repo.list_alerts(s, token=token, open_only=True) if a.level == "acute"} actions = _drop_running_crises(evaluate(snapshot), open_acute) + actions = await _drop_handled_crises(s, token, actions) for action in actions: if action.type == "enhanced_monitoring": profile.enhanced_monitoring = True diff --git a/config/texts/knowledge.yaml b/config/texts/knowledge.yaml index e9c5cba..ee69678 100644 --- a/config/texts/knowledge.yaml +++ b/config/texts/knowledge.yaml @@ -33,6 +33,13 @@ question_starts: - "чем" - "где" - "есть ли" + - "какие" + - "можно" + - "из чего" + - "в чём" + - "в чем" + - "расскажи" + - "объясни" # Бытовые фразы — не вопросы о лечении, даже со «?»: «как дела?», «как ты?». База знаний для них не ищется # (иначе «как дела» находит «линия делит» — у слов одна основа), бот отвечает обычной фразой поддержки. diff --git a/tests/test_dialog_engine.py b/tests/test_dialog_engine.py index 13beb44..6727f89 100644 --- a/tests/test_dialog_engine.py +++ b/tests/test_dialog_engine.py @@ -270,7 +270,8 @@ async def test_repeat_crisis_does_not_duplicate_script(dbs: None, engine: Engine assert r[0].text.startswith(TEXTS.crisis.repeat) and "112" in r[0].text assert spy.calls == [] r = await text(engine, "ладно") - assert r[0].text == TEXTS.crisis.hold + # In a crisis every message gets a human reply, followed by the emergency contacts (chat never goes silent). + assert r[-1].kind == "crisis" and "112" in r[-1].text and r[0].kind != "crisis" _, data = await get_state() assert [e["handled"] for e in data["safety_events"]] == ["crisis_script", "crisis_repeat"] diff --git a/tests/test_dialog_side_answer.py b/tests/test_dialog_side_answer.py index aa22bd4..eb4441f 100644 --- a/tests/test_dialog_side_answer.py +++ b/tests/test_dialog_side_answer.py @@ -3,9 +3,15 @@ buttons; in a crisis the answer carries the emergency contacts and the crisis st from app.core import clock from app.db import repo, session +from app.services.contracts import NoopMonitoring from tests.test_dialog_knowledge import TOKEN, buttons, engine, joined, press, say, state # noqa: F401 +class _InCrisis(NoopMonitoring): + async def is_in_crisis(self, token: str) -> bool: # an open acute alert, as RuleMonitoring would report + return True + + async def test_question_mid_scale_is_answered_and_question_returns(engine): # noqa: F811 await press(engine, "menu:checkin") # some flow with buttons; then force a scale for determinism async with session.get_clinical_session() as s: @@ -25,6 +31,7 @@ async def test_question_mid_scale_is_answered_and_question_returns(engine): # n async def test_question_in_crisis_gets_answer_and_contacts(engine): # noqa: F811 + engine._monitoring = _InCrisis() async with session.get_clinical_session() as s: await repo.set_dialog_state( s, token=TOKEN, state="crisis", data={"consent": True, "crisis": {"open": True}}, now=clock.now() @@ -43,3 +50,24 @@ async def test_non_question_mid_scale_still_goes_to_the_scale(engine): # noqa: await engine.handle(TOKEN, Event(type="system", system="scale_due", params={"scale": "phq9"})) replies = await say(engine, "ну так себе") assert "Вернёмся к вопросу" not in joined(replies) + + +async def test_crisis_any_text_gets_a_reply_with_contacts(engine): # noqa: F811 + engine._monitoring = _InCrisis() + async with session.get_clinical_session() as s: + await repo.set_dialog_state( + s, token=TOKEN, state="crisis", data={"consent": True, "crisis": {"open": True}}, now=clock.now() + ) + for text in ("привет", "мне страшно", "что содержится в моих лекарствах"): + replies = await say(engine, text) + assert len(replies) >= 2, text + assert replies[-1].kind == "crisis" and "112" in joined(replies) + assert replies[0].kind != "crisis" # a real reply first, not only the stub + + +async def test_medicine_mention_without_question_mark_mid_scale(engine): # noqa: F811 + from app.dialog.contracts import Event + + await engine.handle(TOKEN, Event(type="system", system="scale_due", params={"scale": "phq9"})) + replies = await say(engine, "что содержится в моих лекарствах") + assert "Вернёмся к вопросу" in joined(replies) and buttons(replies) diff --git a/tests/test_services_crisis_refire.py b/tests/test_services_crisis_refire.py new file mode 100644 index 0000000..e81c0aa --- /dev/null +++ b/tests/test_services_crisis_refire.py @@ -0,0 +1,32 @@ +"""An acked crisis is not raised again for the same signal; a newer PHQ-9 with item 9 raises a new one.""" + +from datetime import datetime, timedelta + +from app.core.rules import Action +from app.core.types import AlertLevel +from app.services import monitoring + + +class _A: + def __init__(self, code, ts, scale=None): + self.code, self.ts, self.scale = code, ts, scale + + +async def test_drop_handled_crises(monkeypatch): + t0 = datetime(2026, 10, 4, 16, 32) + alerts = [_A("phq9_item9", t0 + timedelta(seconds=1))] + results = [_A(None, t0, "phq9"), _A(None, t0 + timedelta(hours=2), "auditc")] + + async def list_alerts(s, token): + return alerts + + async def list_scale_results(s, token): + return results + + monkeypatch.setattr(monitoring.repo, "list_alerts", list_alerts) + monkeypatch.setattr(monitoring.repo, "list_scale_results", list_scale_results) + crisis = Action(type="crisis", level=AlertLevel.ACUTE, code="phq9_item9") + other = Action(type="alert", level=AlertLevel.HIGH, code="phq9_severe") + assert await monitoring._drop_handled_crises(None, "t", [crisis, other]) == [other] # AUDIT-C is not a trigger + results.append(_A(None, t0 + timedelta(hours=3), "phq9")) # a new PHQ-9 after the alert + assert await monitoring._drop_handled_crises(None, "t", [crisis]) == [crisis]