"""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) == [f"med:taken:{dose_id}", f"med:skip:{dose_id}", f"med:side_effects:{dose_id}"] if taken: r += await a.press(f"med:taken:{dose_id}") else: skip = await a.press(f"med:skip:{dose_id}") assert f"med:skip_reason:quit:{dose_id}" in buttons(skip) r += skip + await a.press(f"med:skip_reason:quit:{dose_id}") # --- 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() # C-SSRS was completed minutes ago: item 9 opens the crisis but does not ask the same screen again (#56). assert data["crisis"]["open"] is True and st == "crisis" and not data.get("pending_scales") async with session.get_clinical_session() as s: assert len(await repo.list_scale_results(s, TOKEN, "cssrs_screen")) == 1 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