Merge pull request 'feat(backend): seed демо — Андрей (дни 0–13) и 7 пациентов кабинета' (#68) from feat/18-seed into dev
CI / ruff · pytest (push) Successful in 1m50s
CI / ruff · pytest (push) Successful in 1m50s
This commit was merged in pull request #68.
This commit is contained in:
@@ -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"}]}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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