Merge pull request 'feat(backend): seed демо — Андрей (дни 0–13) и 7 пациентов кабинета' (#68) from feat/18-seed into dev
CI / ruff · pytest (push) Successful in 1m50s

This commit was merged in pull request #68.
This commit is contained in:
2026-10-04 12:53:11 +03:00
3 changed files with 1403 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
{"day": 0, "note": "День 0, визит к психиатру. Врач подключил Андрея (код из seed). Андрей пишет боту: согласие, знакомство, короткий скрининг (PC-PTSD-5, PCL-5, PHQ-9, AUDIT-C), вопрос про оружие. Баллы пациенту не показываются.", "actions": [{"text": "Здравствуйте"}, {"press": "Согласен(на)"}, {"press": "Начнём"}, {"press": "Да"}, {"press": "Да"}, {"press": "Да"}, {"press": "Да"}, {"press": "Да"}, {"press": "Нет"}, {"press": "Сильно"}, {"press": "Сильно"}, {"press": "Сильно"}, {"press": "Сильно"}, {"press": "Сильно"}, {"press": "Сильно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Несколько дней"}, {"press": "Несколько дней"}, {"press": "Ни разу"}, {"press": "2–4 раза в месяц"}, {"press": "3–4"}, {"press": "Никогда"}, {"press": "Нет"}, {"expect_color": "green"}]}
{"day": 1, "note": "День 1. 08:00 — напоминание о сертралине → «Принял». 20:00 — чек-ин: настроение, сон, кошмары, алкоголь.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "4–6 ч"}, {"press": "Да"}, {"press": "Не пил(а)"}, {"press": "Пропустить"}, {"expect_color": "green"}]}
{"day": 2, "note": "День 2. «Подташнивает» → ответ по инструкции к препарату (ГРЛС): частая побочка, проходит за 1–2 недели.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "4–6 ч"}, {"press": "Да"}, {"press": "Не пил(а)"}, {"text": "подташнивает с утра"}, {"expect_color": "green"}]}
{"day": 3, "note": "День 3. Тошнота снова → короткая поддержка, инструкция не повторяется.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "4–6 ч"}, {"press": "Да"}, {"press": "Не пил(а)"}, {"text": "опять тошнит"}, {"expect_color": "green"}]}
{"day": 4, "note": "День 4. Побочка третий день подряд → правило side_effect_3d, у врача жёлтый.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "3"}, {"press": "4–6 ч"}, {"press": "Нет"}, {"press": "Не пил(а)"}, {"text": "тошнота не проходит"}, {"expect_color": "yellow"}]}
{"day": 5, "note": "День 5. «Пропущу» → «Решил(а) бросить»; вечером «брошу» → отсроченный эффект, не бросать без врача; алерт quit_intent (жёлтый).", "actions": [{"advance_to": "08:00"}, {"press": "Пропущу"}, {"press": "Решил(а) бросить"}, {"advance_to": "20:00"}, {"press": "3"}, {"press": "4–6 ч"}, {"press": "Нет"}, {"press": "Не пил(а)"}, {"text": "от таблеток только хуже, брошу"}, {"expect_color": "yellow"}]}
{"day": 6, "note": "День 6. Врач позвонил Андрею. ВЕДУЩИЙ: до вечернего чек-ина подтвердите (ack) алерты Андрея на дашборде — тогда он снова зелёный.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "6–8 ч"}, {"press": "Нет"}, {"press": "Не пил(а)"}, {"text": "врач позвонил, продолжаю"}]}
{"day": 7, "note": "День 7. 10:00 — плановые шкалы по расписанию модулей: скрининг C-SSRS (все «Нет»), затем PHQ-9 = 13 (п.9 = 0). PCL-5 по расписанию раз в 14 дней.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "10:00"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Несколько дней"}, {"press": "Несколько дней"}, {"press": "Несколько дней"}, {"press": "Ни разу"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "6–8 ч"}, {"press": "Нет"}, {"press": "Не пил(а)"}, {"press": "Пропустить"}]}
{"day": 8, "note": "День 8. Сон < 4 ч, кошмары, 1–2 порции алкоголя.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "Меньше 4 ч"}, {"press": "Да"}, {"press": "1–2 порции"}, {"text": "снится одно и то же"}]}
{"day": 9, "note": "День 9. Сон < 4 ч, алкоголь больше.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "Меньше 4 ч"}, {"press": "Да"}, {"press": "Больше"}, {"press": "Пропустить"}]}
{"day": 10, "note": "День 10. Сон < 4 ч три ночи + алкоголь три дня → информация о взаимодействии с сертралином и кнопка «Записаться к врачу раньше»; у врача жёлтый.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "Меньше 4 ч"}, {"press": "Да"}, {"press": "Больше"}, {"text": "без пива не могу уснуть"}, {"press": "Записаться к врачу раньше"}, {"expect_color": "yellow"}]}
{"day": 11, "note": "День 11. Алерты открыты, правило молчит (без повторов).", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "3"}, {"press": "4–6 ч"}, {"press": "Нет"}, {"press": "1–2 порции"}, {"press": "Пропустить"}, {"expect_color": "yellow"}]}
{"day": 12, "note": "День 12. Настроение 3 второй день подряд, у врача по-прежнему жёлтый.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "3"}, {"press": "4–6 ч"}, {"press": "Нет"}, {"press": "Не пил(а)"}, {"press": "Пропустить"}, {"expect_color": "yellow"}]}
{"day": 13, "note": "День 13. «Всё бессмысленно» → скрининг C-SSRS кнопками; настроение ≤ 3 три дня → внеплановый PHQ-9 = 18, п.9 = 2 → кризисный скрипт, план безопасности, алерт дежурному врачу — красный.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "2"}, {"press": "Меньше 4 ч"}, {"press": "Да"}, {"press": "Не пил(а)"}, {"text": "всё бессмысленно, устал от всего"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"expect_color": "red"}]}
+1056
View File
File diff suppressed because it is too large Load Diff
+333
View File
@@ -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