Files
ElebobaandClaude Opus 5.5 bc91873a13
CI / ruff · pytest (pull_request) Successful in 1m44s
feat(backend): demo seed — veteran Andrey (day 0..13) and 7 cabinet patients
- 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>
2026-10-04 12:50:30 +03:00

334 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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