release: dev → main (сдача хакатона) #106
+16
-5
@@ -28,6 +28,7 @@ Logs carry only the token, states and event codes (guardrail #8).
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -67,8 +68,12 @@ from app.services.contracts import MonitoringService, get_monitoring
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
LAST_PROMPT = "last_prompt"
|
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).
|
# States with their own free-text handling (idle → flows' `text:idle`; explicit question states).
|
||||||
SIDE_ANSWER_SKIP = frozenset({"idle", "kb.question", "meds.question"})
|
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.
|
# Enter hooks may chain (scale done → next onboarding state → next scale); a cycle must not hang a request.
|
||||||
MAX_ENTER_CHAIN = 8
|
MAX_ENTER_CHAIN = 8
|
||||||
_patient_locks = WeakValueDictionary()
|
_patient_locks = WeakValueDictionary()
|
||||||
@@ -309,12 +314,18 @@ class Engine:
|
|||||||
doctor acknowledges it, and acute messages never get here (handled above)."""
|
doctor acknowledges it, and acute messages never get here (handled above)."""
|
||||||
from app.dialog.flows.knowledge import knowledge_answer, looks_like_question
|
from app.dialog.flows.knowledge import knowledge_answer, looks_like_question
|
||||||
|
|
||||||
in_crisis = crisis_open(ctx.data) or ctx.state == "crisis"
|
in_crisis = (crisis_open(ctx.data) or ctx.state == "crisis") and await ctx.monitoring.is_in_crisis(ctx.token)
|
||||||
if ctx.text is None or (ctx.state in SIDE_ANSWER_SKIP and not in_crisis):
|
free_text_step = ctx.state in SIDE_ANSWER_SKIP or ctx.state.startswith(FREE_TEXT_PREFIXES)
|
||||||
return None
|
if ctx.text is None or (free_text_step and not in_crisis):
|
||||||
if not looks_like_question(ctx.text):
|
return None # idle / check-in / question states parse free text themselves
|
||||||
return None
|
asks = looks_like_question(ctx.text) or bool(MEDICINE_WORDS.search(ctx.text))
|
||||||
|
if asks:
|
||||||
answer = await knowledge_answer(ctx, explicit=True)
|
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:
|
if not answer:
|
||||||
return None
|
return None
|
||||||
log.info("dialog side answer token=%s state=%s crisis=%s", ctx.token, ctx.state, in_crisis)
|
log.info("dialog side answer token=%s state=%s crisis=%s", ctx.token, ctx.state, in_crisis)
|
||||||
|
|||||||
@@ -51,6 +51,37 @@ async def refresh_status(session: AsyncSession, token: str) -> str:
|
|||||||
return status.color
|
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]:
|
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)."""
|
"""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}
|
running = {a.code for a in actions if a.type == "crisis" and a.code in open_acute}
|
||||||
@@ -68,6 +99,7 @@ class RuleMonitoring:
|
|||||||
return []
|
return []
|
||||||
open_acute = {a.code for a in await repo.list_alerts(s, token=token, open_only=True) if a.level == "acute"}
|
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 = _drop_running_crises(evaluate(snapshot), open_acute)
|
||||||
|
actions = await _drop_handled_crises(s, token, actions)
|
||||||
for action in actions:
|
for action in actions:
|
||||||
if action.type == "enhanced_monitoring":
|
if action.type == "enhanced_monitoring":
|
||||||
profile.enhanced_monitoring = True
|
profile.enhanced_monitoring = True
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ question_starts:
|
|||||||
- "чем"
|
- "чем"
|
||||||
- "где"
|
- "где"
|
||||||
- "есть ли"
|
- "есть ли"
|
||||||
|
- "какие"
|
||||||
|
- "можно"
|
||||||
|
- "из чего"
|
||||||
|
- "в чём"
|
||||||
|
- "в чем"
|
||||||
|
- "расскажи"
|
||||||
|
- "объясни"
|
||||||
|
|
||||||
# Бытовые фразы — не вопросы о лечении, даже со «?»: «как дела?», «как ты?». База знаний для них не ищется
|
# Бытовые фразы — не вопросы о лечении, даже со «?»: «как дела?», «как ты?». База знаний для них не ищется
|
||||||
# (иначе «как дела» находит «линия делит» — у слов одна основа), бот отвечает обычной фразой поддержки.
|
# (иначе «как дела» находит «линия делит» — у слов одна основа), бот отвечает обычной фразой поддержки.
|
||||||
|
|||||||
@@ -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 r[0].text.startswith(TEXTS.crisis.repeat) and "112" in r[0].text
|
||||||
assert spy.calls == []
|
assert spy.calls == []
|
||||||
r = await text(engine, "ладно")
|
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()
|
_, data = await get_state()
|
||||||
assert [e["handled"] for e in data["safety_events"]] == ["crisis_script", "crisis_repeat"]
|
assert [e["handled"] for e in data["safety_events"]] == ["crisis_script", "crisis_repeat"]
|
||||||
|
|
||||||
|
|||||||
@@ -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.core import clock
|
||||||
from app.db import repo, session
|
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
|
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
|
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
|
await press(engine, "menu:checkin") # some flow with buttons; then force a scale for determinism
|
||||||
async with session.get_clinical_session() as s:
|
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
|
async def test_question_in_crisis_gets_answer_and_contacts(engine): # noqa: F811
|
||||||
|
engine._monitoring = _InCrisis()
|
||||||
async with session.get_clinical_session() as s:
|
async with session.get_clinical_session() as s:
|
||||||
await repo.set_dialog_state(
|
await repo.set_dialog_state(
|
||||||
s, token=TOKEN, state="crisis", data={"consent": True, "crisis": {"open": True}}, now=clock.now()
|
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"}))
|
await engine.handle(TOKEN, Event(type="system", system="scale_due", params={"scale": "phq9"}))
|
||||||
replies = await say(engine, "ну так себе")
|
replies = await say(engine, "ну так себе")
|
||||||
assert "Вернёмся к вопросу" not in joined(replies)
|
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)
|
||||||
|
|||||||
@@ -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]
|
||||||
Reference in New Issue
Block a user