From 2972b330b020ff9729d1907c35d66a2bd9428abe Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sat, 3 Oct 2026 18:45:53 +0300 Subject: [PATCH] test(bot): Andrey 13-day acceptance scenario and medication flow units Refs #37 Co-Authored-By: Claude Opus 5.5 --- tests/test_dialog_meds.py | 372 ++++++++++++++++++++++++++++ tests/test_dialog_veteran_days.py | 388 ++++++++++++++++++++++++++++++ 2 files changed, 760 insertions(+) create mode 100644 tests/test_dialog_meds.py create mode 100644 tests/test_dialog_veteran_days.py diff --git a/tests/test_dialog_meds.py b/tests/test_dialog_meds.py new file mode 100644 index 0000000..cc48de2 --- /dev/null +++ b/tests/test_dialog_meds.py @@ -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) diff --git a/tests/test_dialog_veteran_days.py b/tests/test_dialog_veteran_days.py new file mode 100644 index 0000000..bd73b02 --- /dev/null +++ b/tests/test_dialog_veteran_days.py @@ -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