release: dev → main (сдача хакатона) #106

Merged
Eleboba merged 128 commits from dev into main 2026-10-04 23:00:56 +03:00
6 changed files with 118 additions and 7 deletions
Showing only changes of commit 99e7b8e55d - Show all commits
+17 -6
View File
@@ -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))
answer = await knowledge_answer(ctx, explicit=True) 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: 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)
+32
View File
@@ -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
+7
View File
@@ -33,6 +33,13 @@ question_starts:
- "чем" - "чем"
- "где" - "где"
- "есть ли" - "есть ли"
- "какие"
- "можно"
- "из чего"
- "в чём"
- "в чем"
- "расскажи"
- "объясни"
# Бытовые фразы — не вопросы о лечении, даже со «?»: «как дела?», «как ты?». База знаний для них не ищется # Бытовые фразы — не вопросы о лечении, даже со «?»: «как дела?», «как ты?». База знаний для них не ищется
# (иначе «как дела» находит «линия делит» — у слов одна основа), бот отвечает обычной фразой поддержки. # (иначе «как дела» находит «линия делит» — у слов одна основа), бот отвечает обычной фразой поддержки.
+2 -1
View File
@@ -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"]
+28
View File
@@ -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)
+32
View File
@@ -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]