"""Demo seed (#18): idempotent cabinet, Andrey's modes, colours of the 7 patients, and the console scenario `data/synthetic/andrey.jsonl` replayed against the real dialog engine + demo scheduler + monitoring (sqlite).""" import re from collections.abc import AsyncIterator from datetime import datetime, timedelta from typing import Any import httpx import pytest from sqlalchemy import func, select from app.config import Settings from app.core import clock from app.db import identity_repo, repo, session from app.db.audit import AuditLog from app.db.clinical import ClinicalBase, Dose, SafetyPlan from app.db.identity import ConnectCode, IdentityBase from app.dialog import contracts as dialog_contracts from app.dialog.contracts import Button, Event, Reply from app.dialog.engine import Engine from app.integrations.base import MemoryOutbox, 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.scheduler import jobs from app.services import monitoring as _monitoring # noqa: F401 (registers the real MonitoringService) from scripts import seed_demo from scripts.seed_demo import ANDREY_COLORS, ANDREY_DAYS, OTHERS, SeedResult, seed MEM = "sqlite+aiosqlite:///:memory:" NOW = datetime(2026, 10, 5, 14, 0) # virtual "now" of the seed (Europe/Moscow) @pytest.fixture async def dbs(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[None]: monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True)) clock.set_now(NOW) await session.configure(identity_url=MEM, clinical_url=MEM) yield clock.reset() await session.configure() async def counts() -> dict[str, int]: out: dict[str, int] = {} for base, factory in ((ClinicalBase, session.get_clinical_session), (IdentityBase, session.get_identity_session)): async with factory() as s: for table in base.metadata.sorted_tables: out[table.name] = await s.scalar(select(func.count()).select_from(table)) or 0 return out async def open_codes(token: str, *, acked: bool = False) -> set[str]: async with session.get_clinical_session() as s: alerts = await repo.list_alerts(s, token=token) return {a.code for a in alerts if (a.acked_at is not None) == acked} async def color(token: str) -> str | None: async with session.get_clinical_session() as s: profile = await repo.get_profile(s, token) return profile.status_color if profile else None # ----------------------------------------------------------------------------------------------- seed async def test_seed_is_idempotent(dbs: None) -> None: await seed(12) first = await counts() await seed(12) assert await counts() == first assert first["patient_profiles"] == first["patients"] == 8 and first["doctors"] == 1 await seed(0) again = await counts() await seed(0) assert await counts() == again async def test_reset_recreates_tables_and_zeroes_clock(dbs: None) -> None: await seed(5) clock.advance(days=2) assert await seed_demo.reset(None) is None assert clock.offset() == timedelta(0) assert all(v == 0 for k, v in (await counts()).items() if k != "clock_state") result = await seed_demo.reset() # what POST /demo/reset calls: reset + Andrey on day 0 assert result is not None and result.colors[seed_demo.ANDREY_NAME] == "green" assert (await counts())["patient_profiles"] == 8 async def test_andrey_day0_is_ready_for_the_live_demo(dbs: None) -> None: result = await seed(0) token = result.andrey_token assert re.fullmatch(r"[A-HJ-NP-Z2-9]{6}", result.connect_code) async with session.get_identity_session() as s: patient = await identity_repo.get_patient(s, token) doctor = await identity_repo.get_doctor(s, seed_demo.DOCTOR_ID) code = await s.get(ConnectCode, result.connect_code) assert patient is not None and patient.consent_at is None and patient.display_name == "Андрей С." assert doctor is not None and doctor.on_call and doctor.full_name == "Смирнова Е. А." assert code is not None and code.token == token and code.used_at is None assert await identity_repo.redeem_connect_code(s, code=result.connect_code, now=clock.now()) == token async with session.get_clinical_session() as s: profile = await repo.get_profile(s, token) assert profile is not None assert profile.modules == ["veteran", "depression"] and profile.sex == "m" assert NOW.year - (profile.birth_year or 0) == 34 assert profile.therapy_start == NOW.date() and profile.next_visit == NOW.date() + timedelta(days=28) assert await repo.get_dialog_state(s, token) is None # consent and onboarding happen live assert not await repo.list_checkins(s, token) and not await repo.list_scale_results(s, token) plan = await s.get(SafetyPlan, token) assert plan is not None and len(plan.steps) == 6 assert result.colors["Андрей С."] == "green" @pytest.mark.parametrize("day", [3, 4, 5, 6, 9, 10]) async def test_andrey_colour_follows_the_persona(dbs: None, day: int) -> None: result = await seed(day) assert result.colors["Андрей С."] == ANDREY_COLORS[day - 1] async def test_andrey_day12_yellow(dbs: None) -> None: result = await seed(12) token = result.andrey_token assert result.colors["Андрей С."] == "yellow" assert await open_codes(token) == {"sleep_lt4_3n", "alcohol_3d"} assert await open_codes(token, acked=True) == {"side_effect_3d", "quit_intent"} # doctor called on day 6 async with session.get_clinical_session() as s: profile = await repo.get_profile(s, token) assert profile is not None and profile.therapy_start == NOW.date() - timedelta(days=12) rows = await repo.list_checkins(s, token) assert [(r.mood, r.sleep_bucket, r.alcohol_bucket) for r in rows] == [ (ANDREY_DAYS[n].mood, ANDREY_DAYS[n].sleep, ANDREY_DAYS[n].alcohol) for n in range(1, 13) ] phq = [(r.total, r.item9, r.scheduled) for r in await repo.list_scale_results(s, token, "phq9")] assert phq == [(14, 0, True), (13, 0, True)] totals = {r.scale: r.total for r in await repo.list_scale_results(s, token) if r.ts.date() < rows[0].day} assert totals == {"pcptsd5": 4, "pcl5": 46, "phq9": 14, "auditc": 3} doses = (await s.scalars(select(Dose).where(Dose.token == token).order_by(Dose.planned_at))).all() assert [d.status for d in doses] == ["skipped" if n == 5 else "taken" for n in range(1, 13)] state = await repo.get_dialog_state(s, token) assert state is not None and state.state == "idle" and state.data["consent"] is True async with session.get_identity_session() as s: patient = await identity_repo.get_patient(s, token) assert patient is not None and patient.consent_at is not None async def test_andrey_day13_red_crisis(dbs: None) -> None: result = await seed(13) token = result.andrey_token assert result.colors["Андрей С."] == "red" async with session.get_clinical_session() as s: acute = [a.code for a in await repo.list_alerts(s, token=token, open_only=True) if a.level == "acute"] assert acute == ["phq9_item9"] last = (await repo.list_scale_results(s, token, "phq9"))[-1] assert (last.total, last.item9, last.scheduled) == (18, 2, False) state = await repo.get_dialog_state(s, token) assert state is not None and state.state == "crisis" and state.data["crisis"]["open"] is True audits = (await s.scalars(select(AuditLog.action).where(AuditLog.token == token))).all() assert "crisis_acute" in audits and "ack_alert" in audits async def test_other_patients_match_the_persona_table(dbs: None) -> None: result = await seed(0) expected_codes = { "Дмитрий В.": {"sleep_lt4_3n"}, "Сергей П.": {"missed_doses_7d"}, "Николай Ф.": {"alcohol_3d"}, "Анна Л.": {"side_effect_3d"}, "Ирина К.": set(), "Ольга Н.": set(), "Павел Р.": set(), } for spec in OTHERS: token = result.tokens[spec.name] assert result.colors[spec.name] == spec.color, spec.name assert await open_codes(token) == expected_codes[spec.name], spec.name async with session.get_clinical_session() as s: profile = await repo.get_profile(s, token) assert profile is not None and profile.therapy_start is not None assert (NOW.date() - profile.therapy_start).days == spec.therapy_day assert NOW.year - (profile.birth_year or 0) == spec.age assert profile.next_visit is not None and profile.next_visit > NOW.date() assert await repo.get_dialog_state(s, token) is None # the scheduler leaves them alone assert await open_codes(result.tokens["Ольга Н."], acked=True) == {"side_effect_3d"} # «тошнота прошла» async with session.get_clinical_session() as s: anna = await repo.get_profile(s, result.tokens["Анна Л."]) assert anna is not None and anna.enhanced_monitoring # age < 25, first weeks of therapy pavel = await repo.list_scale_results(s, result.tokens["Павел Р."], "phq9") irina = await repo.list_scale_results(s, result.tokens["Ирина К."], "phq9") assert pavel[-1].total == 4 and [r.total for r in irina] == [16, 13, 9] async def test_seed_is_deterministic_except_connect_code(dbs: None) -> None: async def snapshot(result: SeedResult) -> list[Any]: async with session.get_clinical_session() as s: out = [] for token in result.tokens.values(): out += [(c.day, c.mood, c.sleep_bucket, c.alcohol_bucket) for c in await repo.list_checkins(s, token)] out += [(a.code, a.ts) for a in await repo.list_alerts(s, token=token)] return out first = await snapshot(await seed(12)) assert await snapshot(await seed(12)) == first # ----------------------------------------------------------------------------------------------- scenario file def test_scenario_file_format_and_in_sync_with_config() -> None: lines = seed_demo.load_scenario() assert lines == seed_demo.scenario_lines(), "regenerate: python -m scripts.seed_demo --write-scenario" assert [line["day"] for line in lines] == list(range(0, 14)) for line in lines: assert set(line) == {"day", "note", "actions"} and line["note"] for action in line["actions"]: assert len(action) == 1 and set(action) <= seed_demo.ALLOWED_ACTIONS, action ((key, value),) = action.items() assert isinstance(value, str) and value if key == "advance_to": assert re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", value) if key == "expect_color": assert value in {"green", "yellow", "red"} assert lines[13]["actions"][-1] == {"expect_color": "red"} class Console: """What the demo console (#17) does with a scenario line, in-process: the demo clock + scheduler instead of `/demo/clock/advance`, the engine instead of `/dialog/{token}/events`, the outbox instead of its poll.""" def __init__(self, token: str, engine: Engine, outbox: MemoryOutbox) -> None: self.token, self.engine, self.outbox = token, engine, outbox self.batch: list[Reply] = [] async def advance_to(self, hhmm: str) -> None: now = clock.now() hh, mm = map(int, hhmm.split(":")) target = now.replace(hour=hh, minute=mm, second=0, microsecond=0) if target <= now: target += timedelta(days=1) clock.set_now(target) await jobs.run_until_now() self.batch = self.outbox.drain(self.token) def resolve(self, label: str) -> Button: buttons = [b for r in self.batch for row in r.buttons for b in row] hits = [b for b in buttons if label in b.label] where = f"{clock.now():%d %H:%M}: {[r.text[:50] for r in self.batch]}" assert hits, f"no button «{label}» in {[b.label for b in buttons]} at {where}" # Unambiguous for any console strategy (first substring hit or exact match). assert hits[0].label == label and len({b.data for b in buttons if b.label == label}) == 1, label return hits[0] async def press(self, label: str) -> None: button = self.resolve(label) self.batch = await self.engine.handle(self.token, Event(type="button", button=button.data)) async def text(self, text: str) -> None: self.batch = await self.engine.handle(self.token, Event(type="text", text=text)) async def ack_all(token: str) -> None: """The presenter acknowledges Andrey's alerts on the dashboard (day 6 note).""" 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=seed_demo.DOCTOR_ID, ts=clock.now()) await _monitoring.refresh_status(s, token) async def test_scenario_replays_through_the_real_dialog(dbs: None, monkeypatch: pytest.MonkeyPatch) -> None: gateway = LLMGateway(MockTasks()) engine = Engine(gateway=gateway) outbox = MemoryOutbox() monkeypatch.setattr(integrations, "outbox", outbox) monkeypatch.setattr(med_qa_module, "get_gateway", lambda: gateway) previous = dialog_contracts._factory dialog_contracts.register_engine(lambda: engine) # the scheduler delivers through the same engine try: result = await seed(0) token = result.andrey_token console = Console(token, engine, outbox) colors: dict[int, str | None] = {} for line in seed_demo.load_scenario(): day = line["day"] for action in line["actions"]: ((key, value),) = action.items() if day == 6 and action == {"advance_to": "20:00"}: await ack_all(token) # «до вечернего чек-ина подтвердите алерты» if key == "expect_color": assert await color(token) == value, f"day {day}" else: await getattr(console, key)(value) colors[day] = await color(token) assert (clock.today() - NOW.date()).days == day finally: dialog_contracts.register_engine(previous) assert [colors[d] for d in range(1, 14)] == ANDREY_COLORS assert any(r.kind == "crisis" for r in console.batch) and any(r.kind == "safety_plan" for r in console.batch) async with session.get_clinical_session() as s: rows = {(c.day - NOW.date()).days: c for c in await repo.list_checkins(s, token)} for n, d in ANDREY_DAYS.items(): c = rows[n] assert (c.mood, c.med_taken, c.sleep_bucket, c.nightmares, c.alcohol_bucket) == ( d.mood, d.med_taken, d.sleep, d.nightmares, d.alcohol, ), n phq = [(r.total, r.item9, r.scheduled) for r in await repo.list_scale_results(s, token, "phq9")] assert phq == [(14, 0, True), (13, 0, True), (18, 2, False)] state = await repo.get_dialog_state(s, token) assert state is not None and state.state == "crisis" # The 7 background patients are not touched by 13 days of the demo clock. for spec in OTHERS: assert await color(result.tokens[spec.name]) == spec.color, spec.name async def test_api_serves_the_seeded_cabinet(dbs: None) -> None: from app.main import app result = await seed(13) async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c: rows = (await c.get(f"/api/v1/doctors/{seed_demo.DOCTOR_ID}/patients")).json() personas = (await c.get("/api/v1/demo/personas")).json() card = await c.get(f"/api/v1/patients/{result.andrey_token}", params={"doctor_id": seed_demo.DOCTOR_ID}) assert len(rows) == 8 and rows[0]["display_name"] == "Андрей С." and rows[0]["status_color"] == "red" assert {r["display_name"]: r["status_color"] for r in rows} == result.colors assert personas[0]["token"] == result.andrey_token and personas[0]["therapy_day"] == 13 assert personas[0]["scenario"] == "data/synthetic/andrey.jsonl" assert card.status_code == 200 and len(card.json()["safety_plan"]) == 6