release: dev → main (сдача хакатона) #106
@@ -0,0 +1,372 @@
|
|||||||
|
"""Medication flow and check-in units (#37): reminder buttons, skip reasons, side effects, medication question,
|
||||||
|
med_info rule reactions, check-in edge cases, text policy of the YAML files."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.config import Settings, get_settings
|
||||||
|
from app.core import clock
|
||||||
|
from app.core.rules import Action
|
||||||
|
from app.db import repo, session
|
||||||
|
from app.db.bootstrap import init_db
|
||||||
|
from app.db.clinical import Dose, Prescription
|
||||||
|
from app.dialog.contracts import Event, Reply
|
||||||
|
from app.dialog.engine import Engine
|
||||||
|
from app.dialog.flows.meds import load_meds_texts
|
||||||
|
from app.dialog.flows.veteran_daily import load_daily_texts
|
||||||
|
from app.dialog.fsm import Ctx, enter_hook_for
|
||||||
|
from app.dialog.scale_session import start_scale
|
||||||
|
from app.dialog.texts import load_dialog_texts
|
||||||
|
from app.integrations.base import DrugSection, integrations
|
||||||
|
from app.llm import med_qa as med_qa_module
|
||||||
|
from app.llm.gateway import LLMGateway
|
||||||
|
from app.llm.med_qa import fallback_text
|
||||||
|
from app.llm.mock import MockTasks
|
||||||
|
|
||||||
|
TOKEN = "m" * 32
|
||||||
|
MEM = "sqlite+aiosqlite:///:memory:"
|
||||||
|
NOW = datetime(2026, 10, 7, 8, 0)
|
||||||
|
MEDS = load_meds_texts()
|
||||||
|
DAILY = load_daily_texts()
|
||||||
|
SECTIONS = {
|
||||||
|
"side_effects": "Тошнота обычно возникает в первые дни приёма и проходит в течение 1–2 недель.",
|
||||||
|
"onset": "Улучшение обычно отмечается через 2–4 недели регулярного приёма.",
|
||||||
|
"alcohol": "Совместный приём с алкоголем не рекомендуется.",
|
||||||
|
"administration": "Препарат принимают один раз в сутки, утром или вечером, независимо от еды.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDrugInfo:
|
||||||
|
async def sections(self, inn: str) -> list[DrugSection]:
|
||||||
|
return [DrugSection(inn=inn, section=k, text=v, source="синтетика") for k, v in SECTIONS.items()]
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptedMonitoring:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls = 0
|
||||||
|
self.actions: list[Action] = []
|
||||||
|
|
||||||
|
async def after_data_change(self, token: str) -> list[Action]:
|
||||||
|
self.calls += 1
|
||||||
|
out, self.actions = self.actions, []
|
||||||
|
return out
|
||||||
|
|
||||||
|
async def is_in_crisis(self, token: str) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mon(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[ScriptedMonitoring]:
|
||||||
|
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
|
||||||
|
monkeypatch.setattr(integrations, "drug_info", FakeDrugInfo())
|
||||||
|
monkeypatch.setattr(med_qa_module, "get_gateway", lambda: LLMGateway(MockTasks()))
|
||||||
|
clock.set_now(NOW)
|
||||||
|
await session.configure(identity_url=MEM, clinical_url=MEM)
|
||||||
|
await init_db()
|
||||||
|
async with session.get_clinical_session() as s:
|
||||||
|
await repo.upsert_profile(
|
||||||
|
s, token=TOKEN, doctor_id="doc", modules=["veteran"], sex="m", birth_year=1990, therapy_start=NOW.date()
|
||||||
|
)
|
||||||
|
s.add(Prescription(token=TOKEN, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}))
|
||||||
|
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True}, now=clock.now())
|
||||||
|
yield ScriptedMonitoring()
|
||||||
|
clock.reset()
|
||||||
|
await session.configure()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def engine(mon: ScriptedMonitoring) -> Engine:
|
||||||
|
return Engine(gateway=LLMGateway(MockTasks()), monitoring=mon)
|
||||||
|
|
||||||
|
|
||||||
|
async def press(e: Engine, data: str) -> list[Reply]:
|
||||||
|
return await e.handle(TOKEN, Event(type="button", button=data))
|
||||||
|
|
||||||
|
|
||||||
|
async def text(e: Engine, t: str) -> list[Reply]:
|
||||||
|
return await e.handle(TOKEN, Event(type="text", text=t))
|
||||||
|
|
||||||
|
|
||||||
|
async def system(e: Engine, kind: str, **params: Any) -> list[Reply]:
|
||||||
|
return await e.handle(TOKEN, Event(type="system", system=kind, params=params)) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def buttons(replies: list[Reply]) -> list[str]:
|
||||||
|
return [b.data for r in replies for row in r.buttons for b in row]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_state() -> tuple[str, dict[str, Any]]:
|
||||||
|
async with session.get_clinical_session() as s:
|
||||||
|
row = await repo.get_dialog_state(s, TOKEN)
|
||||||
|
assert row is not None
|
||||||
|
return row.state, dict(row.data)
|
||||||
|
|
||||||
|
|
||||||
|
async def checkin() -> Any:
|
||||||
|
async with session.get_clinical_session() as s:
|
||||||
|
rows = await repo.list_checkins(s, TOKEN)
|
||||||
|
return rows[-1] if rows else None
|
||||||
|
|
||||||
|
|
||||||
|
async def doses() -> list[Dose]:
|
||||||
|
async with session.get_clinical_session() as s:
|
||||||
|
return list((await s.scalars(select(Dose).where(Dose.token == TOKEN).order_by(Dose.id))).all())
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------------------------- reminder
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reminder_without_dose_id_creates_dose_and_taken(engine: Engine, mon: ScriptedMonitoring) -> None:
|
||||||
|
r = await system(engine, "med_reminder")
|
||||||
|
assert r[0].text == MEDS.reminder.text.format(drug="сертралин", dose="50 мг")
|
||||||
|
assert buttons(r) == ["med:taken", "med:skip", "med:side_effects"]
|
||||||
|
[dose] = await doses()
|
||||||
|
assert dose.status == "pending"
|
||||||
|
r = await press(engine, "med:taken")
|
||||||
|
assert r[0].text == MEDS.taken_ack and mon.calls == 1
|
||||||
|
[dose] = await doses()
|
||||||
|
assert dose.status == "taken" and dose.answered_at is not None
|
||||||
|
assert (await checkin()).med_taken is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stale_reminder_button(engine: Engine) -> None:
|
||||||
|
await system(engine, "med_reminder")
|
||||||
|
clock.advance(days=1)
|
||||||
|
r = await press(engine, "med:taken")
|
||||||
|
assert r[0].text == MEDS.stale and await checkin() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("reason", ["forgot", "other"])
|
||||||
|
async def test_skip_simple_reasons(engine: Engine, reason: str) -> None:
|
||||||
|
await system(engine, "med_reminder")
|
||||||
|
r = await press(engine, "med:skip")
|
||||||
|
assert buttons(r) == [f"med:skip_reason:{x}" for x in ("forgot", "side_effects", "quit", "other")]
|
||||||
|
r = await press(engine, f"med:skip_reason:{reason}")
|
||||||
|
assert r[0].text == MEDS.skip.ack[reason]
|
||||||
|
c = await checkin()
|
||||||
|
assert c.med_taken is False and not c.quit_intent
|
||||||
|
assert [d.status for d in await doses()] == ["skipped"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_skip_quit_sets_intent_and_explains(engine: Engine, mon: ScriptedMonitoring) -> None:
|
||||||
|
await system(engine, "med_reminder")
|
||||||
|
await press(engine, "med:skip")
|
||||||
|
mon.actions = [Action(type="med_info", code="quit_intent", params={"topic": "delayed_effect"})]
|
||||||
|
r = await press(engine, "med:skip_reason:quit")
|
||||||
|
assert r[0].text == MEDS.skip.ack["quit"]
|
||||||
|
assert "2–4 недели" in r[1].text and MEDS.med_info["delayed_effect"].notice in r[1].text
|
||||||
|
assert (await checkin()).quit_intent is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_skip_because_of_side_effects_asks_which(engine: Engine) -> None:
|
||||||
|
await system(engine, "med_reminder")
|
||||||
|
await press(engine, "med:skip")
|
||||||
|
r = await press(engine, "med:skip_reason:side_effects")
|
||||||
|
assert "med:se:nausea" in buttons(r) and "med:se:other" in buttons(r)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_side_effect_button(engine: Engine, mon: ScriptedMonitoring) -> None:
|
||||||
|
await system(engine, "med_reminder")
|
||||||
|
r = await press(engine, "med:side_effects")
|
||||||
|
assert buttons(r) == [f"med:se:{c}" for c in ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")]
|
||||||
|
r = await press(engine, "med:se:nausea")
|
||||||
|
assert "проходит в течение 1–2 недель" in r[0].text # instruction excerpt
|
||||||
|
assert r[1].text == MEDS.side_effects.ask_dose and buttons(r[1:]) == ["med:dose:yes", "med:dose:no"]
|
||||||
|
assert (await checkin()).side_effects == ["nausea"]
|
||||||
|
assert (await doses())[0].side_effects == "nausea"
|
||||||
|
r = await press(engine, "med:dose:yes")
|
||||||
|
assert (await checkin()).med_taken is True and (await doses())[0].status == "taken"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_side_effect_other_free_text(engine: Engine) -> None:
|
||||||
|
await system(engine, "med_reminder")
|
||||||
|
await press(engine, "med:side_effects")
|
||||||
|
r = await press(engine, "med:se:other")
|
||||||
|
assert r[0].text == MEDS.side_effects.describe and (await get_state())[0] == "meds.side_effect_text"
|
||||||
|
r = await text(engine, "голова болит весь день")
|
||||||
|
assert (await checkin()).side_effects == ["headache"] and (await get_state())[0] == "idle"
|
||||||
|
await press(engine, "med:side_effects")
|
||||||
|
await press(engine, "med:se:other")
|
||||||
|
await text(engine, "какое-то странное ощущение")
|
||||||
|
assert (await checkin()).side_effects == ["headache", "other"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_same_side_effect_as_yesterday_gets_support(engine: Engine) -> None:
|
||||||
|
async with session.get_clinical_session() as s:
|
||||||
|
await repo.upsert_checkin(
|
||||||
|
s, token=TOKEN, day=clock.today() - timedelta(days=1), now=clock.now(), side_effects=["nausea"]
|
||||||
|
)
|
||||||
|
await system(engine, "med_reminder")
|
||||||
|
r = await press(engine, "med:se:nausea")
|
||||||
|
assert r[0].text == MEDS.side_effects.repeat
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------------------------- question menu
|
||||||
|
|
||||||
|
|
||||||
|
async def test_med_question_menu(engine: Engine) -> None:
|
||||||
|
r = await press(engine, "menu:med_question")
|
||||||
|
assert r[0].text == MEDS.question.prompt and (await get_state())[0] == "meds.question"
|
||||||
|
r = await text(engine, "можно ли пить алкоголь?")
|
||||||
|
assert "Совместный приём с алкоголем не рекомендуется" in r[0].text and (await get_state())[0] == "idle"
|
||||||
|
await press(engine, "menu:med_question")
|
||||||
|
r = await text(engine, "можно увеличить дозу до 100 мг?")
|
||||||
|
assert r[0].text == fallback_text()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_med_question_menu_left_by_button(engine: Engine) -> None:
|
||||||
|
await press(engine, "menu:med_question")
|
||||||
|
r = await press(engine, "menu:checkin")
|
||||||
|
assert r[0].text == DAILY.checkin.start and (await get_state())[0] == "daily.mood"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_med_question_without_prescription(engine: Engine) -> None:
|
||||||
|
async with session.get_clinical_session() as s:
|
||||||
|
for row in await s.scalars(select(Prescription)):
|
||||||
|
await s.delete(row)
|
||||||
|
r = await press(engine, "menu:med_question")
|
||||||
|
assert r[0].text == MEDS.question.no_prescription and (await get_state())[0] == "idle"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_med_info_without_excerpt_uses_fixed_text(
|
||||||
|
engine: Engine, mon: ScriptedMonitoring, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
class Empty:
|
||||||
|
async def sections(self, inn: str) -> list[DrugSection]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(integrations, "drug_info", Empty())
|
||||||
|
mon.actions = [Action(type="med_info", code="alcohol_3d", params={"topic": "alcohol_interaction"})]
|
||||||
|
r = await press(engine, "menu:visit") # any data-writing step runs monitoring
|
||||||
|
spec = MEDS.med_info["alcohol_interaction"]
|
||||||
|
assert r[0].text == DAILY.visit.requested
|
||||||
|
assert r[1].text == "\n\n".join([spec.lead, spec.fallback, spec.notice])
|
||||||
|
assert (await get_state())[1]["visit_requests"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_idle_free_text_med_question_and_side_effect(engine: Engine, mon: ScriptedMonitoring) -> None:
|
||||||
|
r = await text(engine, "когда подействуют таблетки?")
|
||||||
|
assert "2–4 недели" in r[0].text and "menu:checkin" in buttons(r)
|
||||||
|
r = await text(engine, "подташнивает после таблеток")
|
||||||
|
assert "проходит в течение 1–2 недель" in r[0].text and (await checkin()).side_effects == ["nausea"]
|
||||||
|
assert mon.calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------------------------- check-in edges
|
||||||
|
|
||||||
|
|
||||||
|
async def test_checkin_asks_dose_when_not_answered_and_typed_mood(engine: Engine) -> None:
|
||||||
|
await system(engine, "daily_checkin")
|
||||||
|
r = await text(engine, "отлично")
|
||||||
|
assert DAILY.checkin.buttons_hint in r[0].text and (await get_state())[0] == "daily.mood"
|
||||||
|
r = await text(engine, "7")
|
||||||
|
assert r[0].text == MEDS.checkin.question.format(drug="сертралин")
|
||||||
|
await press(engine, "daily:med:no")
|
||||||
|
c = await checkin()
|
||||||
|
assert (c.mood, c.med_taken) == (7, False)
|
||||||
|
assert (await get_state())[0] == "daily.sleep"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reminder_during_checkin_keeps_checkin(engine: Engine) -> None:
|
||||||
|
await system(engine, "daily_checkin")
|
||||||
|
r = await system(engine, "med_reminder")
|
||||||
|
assert buttons(r[:1]) == ["med:taken", "med:skip", "med:side_effects"] and r[-1].text == DAILY.checkin.mood.question
|
||||||
|
r = await press(engine, "med:taken")
|
||||||
|
assert r[0].text == MEDS.taken_ack and (await get_state())[0] == "daily.mood"
|
||||||
|
await press(engine, "daily:mood:5")
|
||||||
|
assert (await get_state())[0] == "daily.sleep" # dose already answered → no med question
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unfinished_checkin_dropped_next_day(engine: Engine) -> None:
|
||||||
|
await system(engine, "daily_checkin")
|
||||||
|
await press(engine, "daily:mood:2")
|
||||||
|
clock.advance(days=1)
|
||||||
|
r = await system(engine, "daily_checkin")
|
||||||
|
assert r[0].text == DAILY.checkin.start and (await get_state())[0] == "daily.mood"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_checkin_once_a_day(engine: Engine) -> None:
|
||||||
|
await system(engine, "daily_checkin")
|
||||||
|
for b in ("daily:mood:6", "daily:med:yes", "daily:sleep:6-8", "daily:nightmares:no", "daily:alcohol:none"):
|
||||||
|
await press(engine, b)
|
||||||
|
await press(engine, "daily:text:skip")
|
||||||
|
assert await system(engine, "daily_checkin") == []
|
||||||
|
r = await press(engine, "menu:checkin")
|
||||||
|
assert r[0].text == DAILY.checkin.already_done
|
||||||
|
|
||||||
|
|
||||||
|
async def test_alcohol_clarify_for_non_veteran(engine: Engine) -> None:
|
||||||
|
async with session.get_clinical_session() as s:
|
||||||
|
await repo.upsert_profile(s, token=TOKEN, modules=["depression"])
|
||||||
|
await system(engine, "daily_checkin")
|
||||||
|
await press(engine, "daily:mood:5")
|
||||||
|
r = await press(engine, "daily:med:yes")
|
||||||
|
assert buttons(r) == ["daily:text:skip"] # no sleep / alcohol questions outside the veteran module
|
||||||
|
r = await text(engine, "вечером выпил пива с друзьями")
|
||||||
|
assert r[-1].text == DAILY.checkin.alcohol.clarify and (await get_state())[0] == "daily.alcohol_amount"
|
||||||
|
r = await press(engine, "daily:alcohol_amount:more")
|
||||||
|
assert r[0].text == DAILY.checkin.done and (await checkin()).alcohol_bucket == "more"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_buttons_win_over_llm(engine: Engine) -> None:
|
||||||
|
await system(engine, "daily_checkin")
|
||||||
|
for b in ("daily:mood:5", "daily:med:yes", "daily:sleep:6-8", "daily:nightmares:no", "daily:alcohol:none"):
|
||||||
|
await press(engine, b)
|
||||||
|
await text(engine, "почти не спал, кошмары, брошу таблетки")
|
||||||
|
c = await checkin()
|
||||||
|
assert (c.sleep_bucket, c.nightmares, c.alcohol_bucket, c.quit_intent) == ("6-8", False, "none", True)
|
||||||
|
assert c.free_text_scrubbed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scale_intro_not_added_to_onboarding(engine: Engine, mon: ScriptedMonitoring) -> None:
|
||||||
|
await system(engine, "scale_due", scale="dar5", scheduled=True)
|
||||||
|
state, data = await get_state()
|
||||||
|
assert state == "scale.dar5" and data["scales"]["dar5"]["intro"] == DAILY.scale_intros.scheduled["dar5"]
|
||||||
|
|
||||||
|
async with session.get_clinical_session() as s:
|
||||||
|
ctx = Ctx(
|
||||||
|
token=TOKEN,
|
||||||
|
state="idle",
|
||||||
|
data={},
|
||||||
|
now=clock.now(),
|
||||||
|
session=s,
|
||||||
|
gateway=LLMGateway(MockTasks()),
|
||||||
|
monitoring=mon,
|
||||||
|
texts=load_dialog_texts(),
|
||||||
|
)
|
||||||
|
step = await start_scale(ctx, "pcl5", return_state="veteran.onboarding.phq9")
|
||||||
|
ctx.data.update(step.data)
|
||||||
|
ctx.state = "scale.pcl5"
|
||||||
|
hook = enter_hook_for("scale.pcl5")
|
||||||
|
assert hook is not None
|
||||||
|
entered = await hook(ctx)
|
||||||
|
assert DAILY.scale_intros.scheduled["pcl5"] not in entered.replies[0].text
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------------------------- text policy
|
||||||
|
|
||||||
|
|
||||||
|
def _strings(node: Any) -> list[str]:
|
||||||
|
if isinstance(node, str):
|
||||||
|
return [node]
|
||||||
|
if isinstance(node, dict):
|
||||||
|
return [s for v in node.values() for s in _strings(v)]
|
||||||
|
if isinstance(node, list):
|
||||||
|
return [s for v in node for s in _strings(v)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["meds.yaml", "veteran_daily.yaml"])
|
||||||
|
def test_texts_policy(name: str) -> None:
|
||||||
|
path: Path = get_settings().config_dir / "texts" / name
|
||||||
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
assert "черновик, утверждает психиатр (#8)" in raw.splitlines()[2]
|
||||||
|
values = " ".join(_strings(yaml.safe_load(raw))).lower()
|
||||||
|
for word in ("учёт", "учет", "контрол", "надзор", "диагноз", "птср", "депресси", "увеличьте доз", "мг"):
|
||||||
|
assert word not in values, (name, word)
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
"""Acceptance: 13 days of the veteran Andrey (docs/product/01_demo_persona.md) through the real dialog engine.
|
||||||
|
|
||||||
|
Andrey is set up post-onboarding directly in the DBs (no dependency on #36): modules veteran + depression,
|
||||||
|
therapy day 0, sertraline 50 mg at 08:00, consent. Each day: the dose reminder (as the scheduler would send it)
|
||||||
|
→ button; the daily check-in → buttons + the free text from the table.
|
||||||
|
|
||||||
|
Lane B's MonitoringService (#38) is replaced by a double that builds a `PatientSnapshot` from the clinical DB,
|
||||||
|
runs the REAL `app.core.rules.evaluate`, persists alert/crisis actions (so dedup works) and returns the dialog
|
||||||
|
actions — the same split as the frozen contract (ADR-007). Drug instruction excerpts are synthetic (FakeDrugInfo).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.core import clock
|
||||||
|
from app.core.rules import Action, DailyCheckin, DoseRecord, PatientSnapshot, ScalePoint, evaluate, patient_status
|
||||||
|
from app.core.scales import load_scale
|
||||||
|
from app.core.types import AlertLevel
|
||||||
|
from app.db import identity_repo, repo, session
|
||||||
|
from app.db.bootstrap import init_db
|
||||||
|
from app.db.clinical import Dose, Message, Prescription
|
||||||
|
from app.dialog.contracts import Event, Reply
|
||||||
|
from app.dialog.engine import Engine
|
||||||
|
from app.dialog.flows.meds import load_meds_texts
|
||||||
|
from app.dialog.flows.veteran_daily import load_daily_texts
|
||||||
|
from app.integrations.base import DrugSection, integrations
|
||||||
|
from app.llm import med_qa as med_qa_module
|
||||||
|
from app.llm.gateway import LLMGateway
|
||||||
|
from app.llm.mock import MockTasks
|
||||||
|
from app.services import contracts as monitoring_contracts
|
||||||
|
|
||||||
|
TOKEN = "e" * 32
|
||||||
|
MEM = "sqlite+aiosqlite:///:memory:"
|
||||||
|
DAY0 = datetime(2026, 10, 5, 8, 0) # visit day; reminders at 08:00, check-ins at 20:00 (Europe/Moscow)
|
||||||
|
SRC = "ГРЛС: тестовая выдержка (синтетика)"
|
||||||
|
SERTRALINE = {
|
||||||
|
"side_effects": (
|
||||||
|
"Наиболее частые нежелательные реакции в начале терапии: тошнота, диарея, сухость во рту. "
|
||||||
|
"Тошнота обычно возникает в первые дни приёма и проходит в течение 1–2 недель. "
|
||||||
|
"Для уменьшения тошноты препарат принимают после еды."
|
||||||
|
),
|
||||||
|
"onset": (
|
||||||
|
"Терапевтический эффект развивается постепенно. "
|
||||||
|
"Улучшение обычно отмечается через 2–4 недели регулярного приёма. "
|
||||||
|
"Суточная доза может увеличиваться врачом до 200 мг."
|
||||||
|
),
|
||||||
|
"alcohol": (
|
||||||
|
"Совместный приём с алкоголем не рекомендуется. "
|
||||||
|
"Алкоголь может ухудшать сон и настроение и усиливать побочные эффекты."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Persona table: day → (dose taken, mood, sleep, nightmares, alcohol, free text). "" = skip the free text.
|
||||||
|
TABLE: dict[int, tuple[bool, int, str, bool, str, str]] = {
|
||||||
|
1: (True, 4, "4-6", True, "none", ""),
|
||||||
|
2: (True, 4, "4-6", True, "none", "подташнивает с утра"),
|
||||||
|
3: (True, 4, "4-6", True, "none", "опять тошнит"),
|
||||||
|
4: (True, 3, "4-6", False, "none", "тошнота не проходит"),
|
||||||
|
5: (False, 3, "4-6", False, "none", "от таблеток только хуже, брошу"),
|
||||||
|
6: (True, 4, "6-8", False, "none", "врач позвонил, продолжаю"),
|
||||||
|
7: (True, 4, "6-8", False, "none", ""),
|
||||||
|
8: (True, 4, "<4", True, "1-2", "снится одно и то же"),
|
||||||
|
9: (True, 4, "<4", True, "more", ""),
|
||||||
|
10: (True, 4, "<4", True, "more", "без пива не могу уснуть"),
|
||||||
|
11: (True, 3, "4-6", False, "1-2", ""),
|
||||||
|
12: (True, 3, "4-6", False, "none", ""),
|
||||||
|
13: (True, 2, "<4", True, "none", "всё бессмысленно, устал от всего"),
|
||||||
|
}
|
||||||
|
# Expected dashboard colour at the end of each day (persona: 🟢🟢🟢🟡🟡🟢🟢🟢🟢🟡🟡🟡🔴).
|
||||||
|
COLORS = ["green"] * 3 + ["yellow"] * 2 + ["green"] * 4 + ["yellow"] * 3 + ["red"]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDrugInfo:
|
||||||
|
async def sections(self, inn: str) -> list[DrugSection]:
|
||||||
|
if inn != "сертралин":
|
||||||
|
return []
|
||||||
|
return [DrugSection(inn=inn, section=k, text=v, source=SRC) for k, v in SERTRALINE.items()]
|
||||||
|
|
||||||
|
|
||||||
|
class RuleMonitoring:
|
||||||
|
"""Test double of lane B's MonitoringService: real rules over a snapshot of the clinical DB."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.fired: list[str] = []
|
||||||
|
|
||||||
|
async def _snapshot(self, s: Any, token: str) -> PatientSnapshot:
|
||||||
|
now = clock.now()
|
||||||
|
profile = await repo.get_profile(s, token)
|
||||||
|
assert profile is not None and profile.therapy_start is not None and profile.birth_year is not None
|
||||||
|
checkins = [
|
||||||
|
DailyCheckin(
|
||||||
|
day=c.day,
|
||||||
|
mood=c.mood,
|
||||||
|
med_taken=c.med_taken,
|
||||||
|
sleep_hours_bucket=c.sleep_bucket, # type: ignore[arg-type]
|
||||||
|
alcohol=c.alcohol_bucket, # type: ignore[arg-type]
|
||||||
|
side_effects=list(c.side_effects or []),
|
||||||
|
quit_intent=bool(c.quit_intent),
|
||||||
|
nightmares=c.nightmares,
|
||||||
|
)
|
||||||
|
for c in await repo.list_checkins(s, token)
|
||||||
|
]
|
||||||
|
results = [r for r in await repo.list_scale_results(s, token) if r.complete]
|
||||||
|
doses = (await s.scalars(select(Dose).where(Dose.token == token, Dose.status != "pending"))).all()
|
||||||
|
cssrs = [r for r in results if r.scale == "cssrs_screen"]
|
||||||
|
last_in = await s.scalar(select(func.max(Message.ts)).where(Message.token == token, Message.direction == "in"))
|
||||||
|
return PatientSnapshot(
|
||||||
|
token=token,
|
||||||
|
as_of=now,
|
||||||
|
age=now.year - profile.birth_year,
|
||||||
|
sex=profile.sex, # type: ignore[arg-type]
|
||||||
|
modules=set(profile.modules), # type: ignore[arg-type]
|
||||||
|
therapy_day=(now.date() - profile.therapy_start).days,
|
||||||
|
checkins=checkins,
|
||||||
|
scale_results=[
|
||||||
|
ScalePoint(scale=r.scale, ts=r.ts, total=r.total, item9=r.item9, scheduled=r.scheduled)
|
||||||
|
for r in results
|
||||||
|
if r.scale != "cssrs_screen"
|
||||||
|
],
|
||||||
|
doses=[DoseRecord(planned_at=d.planned_at, status=d.status) for d in doses], # type: ignore[arg-type]
|
||||||
|
cssrs_risk=cssrs[-1].severity if cssrs else None, # type: ignore[arg-type]
|
||||||
|
weapon_access=False,
|
||||||
|
last_patient_message_at=last_in,
|
||||||
|
open_alert_codes=await repo.open_alert_codes(s, token),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def after_data_change(self, token: str) -> list[Action]:
|
||||||
|
async with session.get_clinical_session() as s:
|
||||||
|
actions = evaluate(await self._snapshot(s, token))
|
||||||
|
for a in actions:
|
||||||
|
if a.creates_alert:
|
||||||
|
assert a.level is not None
|
||||||
|
await repo.add_alert(
|
||||||
|
s, token=token, level=a.level, code=a.code, reason=a.reason_ru, ts=clock.now(), params=a.params
|
||||||
|
)
|
||||||
|
self.fired += [a.code for a in actions]
|
||||||
|
return [a for a in actions if a.type != "alert"]
|
||||||
|
|
||||||
|
async def is_in_crisis(self, token: str) -> bool:
|
||||||
|
async with session.get_clinical_session() as s:
|
||||||
|
alerts = await repo.list_alerts(s, token=token, open_only=True)
|
||||||
|
return any(a.level == "acute" for a in alerts)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------------------------- fixtures
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def andrey(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[RuleMonitoring]:
|
||||||
|
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
|
||||||
|
monkeypatch.setattr(integrations, "drug_info", FakeDrugInfo())
|
||||||
|
monkeypatch.setattr(med_qa_module, "get_gateway", lambda: LLMGateway(MockTasks()))
|
||||||
|
clock.set_now(DAY0)
|
||||||
|
await session.configure(identity_url=MEM, clinical_url=MEM)
|
||||||
|
await init_db()
|
||||||
|
now = clock.now()
|
||||||
|
async with session.get_identity_session() as s:
|
||||||
|
await identity_repo.create_patient(
|
||||||
|
s,
|
||||||
|
token=TOKEN,
|
||||||
|
display_name="Андрей С. (синтетика)",
|
||||||
|
snils="000-000-000 00",
|
||||||
|
clinic_id="c1",
|
||||||
|
doctor_id="doc-smirnova",
|
||||||
|
now=now,
|
||||||
|
birth_year=1992,
|
||||||
|
sex="m",
|
||||||
|
)
|
||||||
|
await identity_repo.record_consent(s, token=TOKEN, version="v1", now=now)
|
||||||
|
async with session.get_clinical_session() as s:
|
||||||
|
await repo.upsert_profile(
|
||||||
|
s,
|
||||||
|
token=TOKEN,
|
||||||
|
doctor_id="doc-smirnova",
|
||||||
|
modules=["veteran", "depression"],
|
||||||
|
therapy_start=now.date(),
|
||||||
|
next_visit=now.date() + timedelta(days=28),
|
||||||
|
birth_year=1992,
|
||||||
|
sex="m",
|
||||||
|
)
|
||||||
|
s.add(
|
||||||
|
Prescription(
|
||||||
|
token=TOKEN, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}, start_date=now.date()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
screening = now - timedelta(days=3) # day −3 screening (persona)
|
||||||
|
for scale, total in (("pcptsd5", 4), ("pcl5", 46), ("phq9", 14), ("auditc", 3)):
|
||||||
|
await repo.add_scale_result(
|
||||||
|
s,
|
||||||
|
token=TOKEN,
|
||||||
|
scale=scale,
|
||||||
|
total=total,
|
||||||
|
severity="-",
|
||||||
|
complete=True,
|
||||||
|
ts=screening,
|
||||||
|
item9=0 if scale == "phq9" else None,
|
||||||
|
)
|
||||||
|
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=now)
|
||||||
|
mon = RuleMonitoring()
|
||||||
|
monitoring_contracts.get_monitoring() # import lane B's module first if it exists, then override it
|
||||||
|
previous = monitoring_contracts._factory
|
||||||
|
monitoring_contracts.register_monitoring(lambda: mon)
|
||||||
|
yield mon
|
||||||
|
monitoring_contracts.register_monitoring(previous)
|
||||||
|
clock.reset()
|
||||||
|
await session.configure()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
|
||||||
|
class Andrey:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.engine = Engine(gateway=LLMGateway(MockTasks())) # monitoring resolved via register_monitoring
|
||||||
|
|
||||||
|
async def text(self, t: str) -> list[Reply]:
|
||||||
|
return await self.engine.handle(TOKEN, Event(type="text", text=t))
|
||||||
|
|
||||||
|
async def press(self, data: str) -> list[Reply]:
|
||||||
|
return await self.engine.handle(TOKEN, Event(type="button", button=data))
|
||||||
|
|
||||||
|
async def system(self, kind: str, **params: Any) -> list[Reply]:
|
||||||
|
return await self.engine.handle(TOKEN, Event(type="system", system=kind, params=params)) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
async def answer_scale(self, scale_id: str, values: list[int]) -> list[Reply]:
|
||||||
|
out: list[Reply] = []
|
||||||
|
for item, value in zip(load_scale(scale_id).items, values, strict=True):
|
||||||
|
out += await self.press(f"scale:{scale_id}:{item.id}:{value}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def buttons(replies: list[Reply]) -> list[str]:
|
||||||
|
return [b.data for r in replies for row in r.buttons for b in row]
|
||||||
|
|
||||||
|
|
||||||
|
def joined(replies: list[Reply]) -> str:
|
||||||
|
return "\n".join(r.text for r in replies)
|
||||||
|
|
||||||
|
|
||||||
|
async def state() -> tuple[str, dict[str, Any]]:
|
||||||
|
async with session.get_clinical_session() as s:
|
||||||
|
row = await repo.get_dialog_state(s, TOKEN)
|
||||||
|
assert row is not None
|
||||||
|
return row.state, dict(row.data)
|
||||||
|
|
||||||
|
|
||||||
|
async def open_alerts() -> list[Any]:
|
||||||
|
async with session.get_clinical_session() as s:
|
||||||
|
return list(await repo.list_alerts(s, token=TOKEN, open_only=True))
|
||||||
|
|
||||||
|
|
||||||
|
async def ack_all() -> None:
|
||||||
|
async with session.get_clinical_session() as s:
|
||||||
|
for a in await repo.list_alerts(s, token=TOKEN, open_only=True):
|
||||||
|
await repo.ack_alert(s, alert_id=a.id, doctor_id="doc-smirnova", ts=clock.now())
|
||||||
|
|
||||||
|
|
||||||
|
async def new_dose(day: date) -> int:
|
||||||
|
"""What the scheduler (#13) does before sending the reminder: a planned dose row."""
|
||||||
|
async with session.get_clinical_session() as s:
|
||||||
|
rx = await s.scalar(select(Prescription).where(Prescription.token == TOKEN))
|
||||||
|
assert rx is not None
|
||||||
|
row = Dose(token=TOKEN, prescription_id=rx.id, planned_at=clock.now(), status="pending")
|
||||||
|
s.add(row)
|
||||||
|
await s.flush()
|
||||||
|
return row.id
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------------------------- the 13 days
|
||||||
|
|
||||||
|
|
||||||
|
async def test_andrey_13_days(andrey: RuleMonitoring) -> None:
|
||||||
|
a = Andrey()
|
||||||
|
meds = load_meds_texts()
|
||||||
|
daily = load_daily_texts()
|
||||||
|
log: dict[int, list[Reply]] = {}
|
||||||
|
colors: list[str] = []
|
||||||
|
|
||||||
|
for day in range(1, 14):
|
||||||
|
taken, mood, sleep, nightmares, alcohol, free_text = TABLE[day]
|
||||||
|
clock.set_now(DAY0 + timedelta(days=day))
|
||||||
|
if day == 6:
|
||||||
|
await ack_all() # the doctor called Andrey after the day-5 alerts and acknowledged them
|
||||||
|
r: list[Reply] = []
|
||||||
|
|
||||||
|
# --- 08:00 dose reminder
|
||||||
|
dose_id = await new_dose(clock.today())
|
||||||
|
reminder = await a.system("med_reminder", dose_id=dose_id, drug="сертралин", dose="50 мг")
|
||||||
|
assert "сертралин 50 мг" in reminder[0].text
|
||||||
|
assert buttons(reminder) == ["med:taken", "med:skip", "med:side_effects"]
|
||||||
|
if taken:
|
||||||
|
r += await a.press("med:taken")
|
||||||
|
else:
|
||||||
|
skip = await a.press("med:skip")
|
||||||
|
assert "med:skip_reason:quit" in buttons(skip)
|
||||||
|
r += skip + await a.press("med:skip_reason:quit")
|
||||||
|
|
||||||
|
# --- day 7: scheduled scales (PCL-5 = 44, PHQ-9 = 13, item 9 = 0) with friendly intros
|
||||||
|
if day == 7:
|
||||||
|
first = await a.system("scale_due", scale="pcl5", scheduled=True)
|
||||||
|
assert daily.scale_intros.scheduled["pcl5"] in first[0].text
|
||||||
|
await a.system("scale_due", scale="phq9", scheduled=True) # queued behind PCL-5
|
||||||
|
after_pcl = await a.answer_scale("pcl5", [2] * 16 + [3] * 4)
|
||||||
|
assert daily.scale_intros.scheduled["phq9"] in after_pcl[-1].text
|
||||||
|
await a.answer_scale("phq9", [2] * 5 + [1] * 3 + [0])
|
||||||
|
assert (await state())[0] == "idle"
|
||||||
|
|
||||||
|
# --- 20:00 daily check-in
|
||||||
|
clock.set_now(DAY0 + timedelta(days=day, hours=12))
|
||||||
|
start = await a.system("daily_checkin")
|
||||||
|
assert start[0].text == daily.checkin.start and buttons(start)[0] == "daily:mood:0"
|
||||||
|
r += await a.press(f"daily:mood:{mood}") # dose already answered by the reminder → no med question
|
||||||
|
assert "daily:sleep:<4" in buttons(r[-1:])
|
||||||
|
r += await a.press(f"daily:sleep:{sleep}")
|
||||||
|
r += await a.press(f"daily:nightmares:{'yes' if nightmares else 'no'}")
|
||||||
|
r += await a.press(f"daily:alcohol:{alcohol}")
|
||||||
|
assert buttons(r[-1:]) == ["daily:text:skip"]
|
||||||
|
r += await a.text(free_text) if free_text else await a.press("daily:text:skip")
|
||||||
|
log[day] = r
|
||||||
|
if day == 10: # «записаться раньше» offered by rule alcohol_3d
|
||||||
|
visit = await a.press("menu:visit")
|
||||||
|
assert visit[0].text == daily.visit.requested and (await state())[1]["visit_requests"]
|
||||||
|
|
||||||
|
if day == 13:
|
||||||
|
break # the crisis path is checked below
|
||||||
|
assert (await state())[0] == "idle"
|
||||||
|
colors.append(patient_status(AlertLevel[x.level.upper()] for x in await open_alerts()).color)
|
||||||
|
|
||||||
|
# Day 2: first nausea → instruction excerpt (med_qa, section side_effects), not generated advice.
|
||||||
|
assert "По инструкции к препарату" in joined(log[2]) and "проходит в течение 1–2 недель" in joined(log[2])
|
||||||
|
# Day 3: same effect again → short support, the instruction is not repeated.
|
||||||
|
assert meds.side_effects.repeat in joined(log[3]) and "По инструкции" not in joined(log[3])
|
||||||
|
# Day 5: «решил бросить» → rule quit_intent → delayed onset + do not stop on your own + doctor notified.
|
||||||
|
day5 = joined(log[5])
|
||||||
|
assert "2–4 недели" in day5 and "не прекращайте" in day5 and meds.med_info["delayed_effect"].notice in day5
|
||||||
|
assert "200 мг" not in day5 # dosing sentences of the instruction are never quoted
|
||||||
|
# Day 10: sleep < 4 h ×3 + alcohol ×3 → interaction info + «записаться раньше».
|
||||||
|
day10 = joined(log[10])
|
||||||
|
assert "Совместный приём с алкоголем не рекомендуется" in day10
|
||||||
|
assert buttons(log[10])[-1] == "menu:visit"
|
||||||
|
# Day 11: alerts still open → the rule stays silent (no repeated interaction text).
|
||||||
|
assert "Совместный приём с алкоголем" not in joined(log[11]) and "menu:visit" not in buttons(log[11])
|
||||||
|
assert colors == COLORS[:12]
|
||||||
|
|
||||||
|
# Day 13: «всё бессмысленно» → POSSIBLE → C-SSRS screen (buttons only) took over the free text.
|
||||||
|
day13 = log[13]
|
||||||
|
assert day13[-1].kind == "question" and "scale:cssrs_screen:cssrs_1:0" in buttons(day13[-1:])
|
||||||
|
r = await a.answer_scale("cssrs_screen", [0] * 6)
|
||||||
|
# Back in the check-in: the message counts as the answer → check-in closed → mood ≤ 3 three days →
|
||||||
|
# unscheduled PHQ-9 with its own intro.
|
||||||
|
assert daily.checkin.done in joined(r) and daily.scale_intros.unscheduled["phq9"] in r[-1].text
|
||||||
|
st, data = await state()
|
||||||
|
assert st == "scale.phq9" and data["scales"]["phq9"]["scheduled"] is False
|
||||||
|
r = await a.answer_scale("phq9", [2] * 8 + [2]) # total 18, item 9 = 2
|
||||||
|
assert any(x.kind == "crisis" for x in r) and any(x.kind == "safety_plan" for x in r)
|
||||||
|
st, data = await state()
|
||||||
|
assert data["crisis"]["open"] is True and st in ("crisis", "scale.cssrs_screen")
|
||||||
|
acute = [x for x in await open_alerts() if x.level == "acute"]
|
||||||
|
assert [x.code for x in acute] == ["phq9_item9"]
|
||||||
|
assert patient_status(AlertLevel[x.level.upper()] for x in await open_alerts()).color == COLORS[12]
|
||||||
|
|
||||||
|
# Stored check-ins match the persona table.
|
||||||
|
async with session.get_clinical_session() as s:
|
||||||
|
rows = {c.day: c for c in await repo.list_checkins(s, TOKEN)}
|
||||||
|
doses = (await s.scalars(select(Dose).where(Dose.token == TOKEN).order_by(Dose.id))).all()
|
||||||
|
phq = list(await repo.list_scale_results(s, TOKEN, "phq9"))
|
||||||
|
for day, (taken, mood, sleep, nightmares, alcohol, _text) in TABLE.items():
|
||||||
|
c = rows[(DAY0 + timedelta(days=day)).date()]
|
||||||
|
assert (c.med_taken, c.mood, c.sleep_bucket, c.nightmares, c.alcohol_bucket) == (
|
||||||
|
taken,
|
||||||
|
mood,
|
||||||
|
sleep,
|
||||||
|
nightmares,
|
||||||
|
alcohol,
|
||||||
|
), day
|
||||||
|
for day in (2, 3, 4):
|
||||||
|
assert "nausea" in rows[(DAY0 + timedelta(days=day)).date()].side_effects
|
||||||
|
assert rows[(DAY0 + timedelta(days=5)).date()].quit_intent is True
|
||||||
|
assert rows[(DAY0 + timedelta(days=13)).date()].free_text_scrubbed is None # taken over by the C-SSRS
|
||||||
|
assert [d.status for d in doses] == ["skipped" if d == 5 else "taken" for d in range(1, 14)]
|
||||||
|
assert [(p.total, p.item9, p.scheduled) for p in phq] == [(14, 0, True), (13, 0, True), (18, 2, False)]
|
||||||
|
for code in ("side_effect_3d", "quit_intent", "sleep_lt4_3n", "alcohol_3d", "mood_low_3d", "phq9_item9"):
|
||||||
|
assert code in andrey.fired, code
|
||||||
Reference in New Issue
Block a user