feat(backend): demo seed — veteran Andrey (day 0..13) and 7 cabinet patients
CI / ruff · pytest (pull_request) Successful in 1m44s
CI / ruff · pytest (pull_request) Successful in 1m44s
- scripts/seed_demo.py: doctor doc-smirnova (on call), Andrey day 0 (connect code, no consent) or day N with history; 7 patients with seeded-random histories. Alerts and colours come from the real rules evaluated day by day. - --reset drops/recreates both DBs and zeroes the demo clock; seed keeps the persisted clock offset so dates match the running app. - data/synthetic/andrey.jsonl: console scenario for days 0..13 generated from config button labels (--write-scenario). - tests: idempotency, modes, colours, scenario format/sync and a full replay of the scenario through the real engine, scheduler and monitoring. Refs #18 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user