diff --git a/data/synthetic/andrey.jsonl b/data/synthetic/andrey.jsonl new file mode 100644 index 0000000..039ad0a --- /dev/null +++ b/data/synthetic/andrey.jsonl @@ -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"}]} diff --git a/scripts/seed_demo.py b/scripts/seed_demo.py new file mode 100644 index 0000000..c0b317a --- /dev/null +++ b/scripts/seed_demo.py @@ -0,0 +1,1056 @@ +"""Demo seed (#18): doctor Smirnova's cabinet — veteran Andrey + 7 other patients. Synthetic data only. + + python -m scripts.seed_demo # Andrey on day 0 (connected, no consent yet) + 7 patients + python -m scripts.seed_demo --andrey-day 12 # Andrey with 12 days of history (13 → crisis, red) + python -m scripts.seed_demo --reset # drop + recreate both DBs, zero the demo clock, then seed + # (same as POST /api/v1/demo/reset in the running app) + python -m scripts.seed_demo --write-scenario # regenerate data/synthetic/andrey.jsonl from config labels + +Importable: `await seed(andrey_day)`, `await reset(andrey_day=0)` (reset + seed; None = only reset), +`scenario_lines()`, `load_scenario()`. + +How the history is built. Rows are written directly into the identity / clinical DBs (no dialog replay, so a +seed takes ~1 s and never calls an LLM), day by day from therapy day 1 to "today". After every day the +monitoring pass is evaluated *as of that day's evening*: snapshot (`app.services.snapshot`) → deterministic +rules (`app.core.rules.evaluate`) → alerts persisted with that timestamp → cached colour (`refresh_status`). +So alerts, their timestamps, dedup and the dashboard colour come from the same rules the live system runs; +the seed only decides the patient-reported data. Doctor acknowledgements happen on fixed days (Andrey day 6, +Olga day 5) like the persona describes. + +Dates are relative to `app.core.clock.now()` (the persisted demo-clock offset is restored first in the CLI), +so "today" is the therapy day given in the persona table. Random variation of the 7 background patients uses +a per-patient seeded `random.Random`, so reruns produce identical data. + +The 7 background patients get identity consent but no `dialog_state`: the demo scheduler contacts only +patients with a consented dialog, so advancing the demo clock does not turn their doses into "missed" and +their colours stay as in the persona table during the whole demo. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import random +import secrets +import sys +from collections.abc import Sequence +from dataclasses import dataclass, field, replace +from datetime import date, datetime, time, timedelta +from pathlib import Path +from typing import Any + +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core import clock +from app.core.modules import due_scales, load_modules +from app.core.privacy import pseudo_id, scrub +from app.core.rules import DailyCheckin as RuleCheckin +from app.core.rules import DoseRecord, PatientSnapshot, ScalePoint, evaluate +from app.core.safety import crisis_reply +from app.core.scales import load_scale, score +from app.db import identity_repo, repo +from app.db import session as db_session +from app.db.audit import audit +from app.db.bootstrap import init_db +from app.db.clinical import ( + ClinicalBase, + ClockState, + DailyCheckin, + Dose, + ItemResponse, + Message, + PatientProfile, + Prescription, + SafetyPlan, + ScaleResult, +) +from app.db.identity import IdentityBase +from app.db.scheduler import SchedulerEvent +from app.dialog.texts import load_dialog_texts +from app.services.monitoring import SYSTEM_ACTOR, flush_clock_writes, refresh_status +from app.services.snapshot import CSSRS_RECENT, CSSRS_SCALES, build_snapshot + +DOCTOR_ID = "doc-smirnova" +DOCTOR_NAME = "Смирнова Е. А." +CLINIC_ID = "kmpk-demo" # кабинет медико-психологического консультирования (synthetic) +SCENARIO_PATH = Path(__file__).resolve().parents[1] / "data" / "synthetic" / "andrey.jsonl" +ALLOWED_ACTIONS = frozenset({"advance_to", "press", "text", "expect_color"}) +_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O, 1/I: the code is typed by hand + +ANDREY_SNILS = "900-000-013 34" # synthetic +ANDREY_NAME = "Андрей С." +ANDREY_AGE = 34 +ANDREY_MAX_DAY = 13 +SAFETY_PLAN = [ # Stanley–Brown, 6 steps (same content as app/api/fixtures.py) + "Мои сигналы: не сплю, пью, «всё бессмысленно»", + "Что помогает: прогулка с собакой, душ", + "Люди и места: брат Олег, спортзал", + "Кому звонить: жена, сослуживец Миша", + "Специалисты: д-р Смирнова, 8-800-2000-122, 112", + "Безопасная среда: ружьё хранится у брата", +] + +# Onboarding answers of Andrey (persona: PC-PTSD-5 = 4, PCL-5 = 46, PHQ-9 = 14 with item 9 = 0, AUDIT-C = 3). +ANDREY_ONBOARDING: dict[str, list[int]] = { + "pcptsd5": [1, 1, 1, 1, 0], + "pcl5": [3] * 6 + [2] * 14, + "phq9": [2, 2, 2, 2, 2, 2, 1, 1, 0], + "auditc": [2, 1, 0], +} +ANDREY_DAY7_PHQ9 = [2] * 5 + [1] * 3 + [0] # 13, item 9 = 0 (scheduled weekly PHQ-9, depression module) +ANDREY_DAY13_PHQ9 = [2] * 9 # 18, item 9 = 2 (unscheduled: mood <= 3 three days) +CSSRS_NO = [0] * 6 + + +@dataclass(frozen=True) +class Day: + """Patient-reported data of one therapy day (None = not asked / not answered).""" + + dose: str = "taken" # taken | skipped | missed | none (no dose planned) + mood: int | None = None + sleep: str | None = None + nightmares: bool | None = None + alcohol: str | None = None + side_effects: tuple[str, ...] = () + quit_intent: bool = False + text: str | None = None + store_text: bool = True # False: the message was taken over by the C-SSRS (day 13), not saved in the check-in + silent: bool = False # no check-in and no message at all that day + + @property + def med_taken(self) -> bool | None: + return {"taken": True, "skipped": False, "missed": False}.get(self.dose) + + +# Andrey's 13 days — docs/product/01_demo_persona.md, same values as tests/test_dialog_veteran_days.py. +ANDREY_DAYS: dict[int, Day] = { + 1: Day(mood=4, sleep="4-6", nightmares=True, alcohol="none"), + 2: Day(mood=4, sleep="4-6", nightmares=True, alcohol="none", side_effects=("nausea",), text="подташнивает с утра"), + 3: Day(mood=4, sleep="4-6", nightmares=True, alcohol="none", side_effects=("nausea",), text="опять тошнит"), + 4: Day(mood=3, sleep="4-6", nightmares=False, alcohol="none", side_effects=("nausea",), text="тошнота не проходит"), + 5: Day( + dose="skipped", + mood=3, + sleep="4-6", + nightmares=False, + alcohol="none", + quit_intent=True, + text="от таблеток только хуже, брошу", + ), + 6: Day(mood=4, sleep="6-8", nightmares=False, alcohol="none", text="врач позвонил, продолжаю"), + 7: Day(mood=4, sleep="6-8", nightmares=False, alcohol="none"), + 8: Day(mood=4, sleep="<4", nightmares=True, alcohol="1-2", text="снится одно и то же"), + 9: Day(mood=4, sleep="<4", nightmares=True, alcohol="more"), + 10: Day(mood=4, sleep="<4", nightmares=True, alcohol="more", text="без пива не могу уснуть"), + 11: Day(mood=3, sleep="4-6", nightmares=False, alcohol="1-2"), + 12: Day(mood=3, sleep="4-6", nightmares=False, alcohol="none"), + 13: Day( + mood=2, sleep="<4", nightmares=True, alcohol="none", text="всё бессмысленно, устал от всего", store_text=False + ), +} +# Persona colours at the end of each day 1..13 (with the doctor's ack on day 6). +ANDREY_COLORS = ["green"] * 3 + ["yellow"] * 2 + ["green"] * 4 + ["yellow"] * 3 + ["red"] + + +@dataclass(frozen=True) +class ScaleEntry: + day: int + scale: str + answers: list[int] + scheduled: bool = True + at: time = time(10, 5) + + +@dataclass +class History: + days: dict[int, Day] + scales: list[ScaleEntry] + ack_days: set[int] = field(default_factory=set) # doctor acknowledges all open alerts that morning + + +@dataclass(frozen=True) +class PatientSpec: + key: str + name: str + snils: str + age: int + sex: str + modules: tuple[str, ...] + drug: str + dose: str + instructions: str + dose_time: str + therapy_day: int # today's therapy day + color: str # expected dashboard colour (persona table) — checked by tests + last_event: str + + +OTHERS: list[PatientSpec] = [ + PatientSpec( + "dmitry", "Дмитрий В.", "900-000-041 01", 41, "m", ("veteran",), "сертралин", "100 мг", "утром после еды", + "08:00", 45, "yellow", "кошмары, сон < 4 ч 3 ночи", + ), + PatientSpec( + "sergey", "Сергей П.", "900-000-037 02", 37, "m", ("veteran",), "пароксетин", "20 мг", "утром во время еды", + "08:00", 30, "yellow", "пропуски 3 из 7 дней", + ), + PatientSpec( + "nikolay", "Николай Ф.", "900-000-052 03", 52, "m", ("veteran",), "сертралин", "50 мг", "утром после еды", + "08:00", 14, "yellow", "алкоголь 4 из 7 дней", + ), + PatientSpec( + "anna", "Анна Л.", "900-000-023 04", 23, "f", ("depression",), "флуоксетин", "20 мг", "утром", + "09:00", 8, "yellow", "возраст < 25: усиленный мониторинг; беспокойство", + ), + PatientSpec( + "irina", "Ирина К.", "900-000-034 05", 34, "f", ("depression",), "эсциталопрам", "10 мг", "утром", + "09:00", 21, "green", "PHQ-9 16 → 9", + ), + PatientSpec( + "olga", "Ольга Н.", "900-000-029 06", 29, "f", ("depression",), "сертралин", "50 мг", "утром после еды", + "08:00", 10, "green", "тошнота прошла", + ), + PatientSpec( + "pavel", "Павел Р.", "900-000-045 07", 45, "m", ("depression",), "эсциталопрам", "10 мг", "утром", + "09:00", 60, "green", "ремиссия, PHQ-9 = 4", + ), +] # fmt: skip + + +@dataclass +class SeedResult: + now: datetime + andrey_token: str + andrey_day: int + connect_code: str + colors: dict[str, str] # display name → dashboard colour + tokens: dict[str, str] # display name → token + + +# ============================================================================================ time helpers + + +def _at(day: date, t: time, now: datetime) -> datetime: + """Local datetime of `t` on `day`, never later than `now` (today's events have already happened).""" + return min(datetime.combine(day, t, tzinfo=now.tzinfo), now - timedelta(minutes=1)) + + +def _hhmm(raw: str) -> time: + return time.fromisoformat(raw) + + +def _birth_year(age: int, now: datetime) -> int: + # The snapshot derives age as `as_of.year - birth_year` (app/services/snapshot.py). + return now.year - age + + +# ============================================================================================ scales + + +def spread(scale_id: str, total: int, fixed: dict[str, int] | None = None) -> list[int]: + """Deterministic item answers summing to `total` (even spread; `fixed` items keep their value).""" + scale = load_scale(scale_id) + fixed = fixed or {} + free = [it for it in scale.items if it.id not in fixed] + rest = total - sum(fixed.values()) + out: dict[str, int] = dict(fixed) + for i, it in enumerate(free): + share = -(-rest // (len(free) - i)) # ceil: earlier items take the remainder + value = max(it.min_value, min(it.max_value, share)) + out[it.id] = value + rest -= value + if rest != 0: + raise ValueError(f"{scale_id}: total {total} not reachable") + return [out[it.id] for it in scale.items] + + +def _phq(total: int) -> list[int]: + """PHQ-9 answers with item 9 (suicidal ideation) = 0.""" + return spread("phq9", total, {"phq9_9": 0}) + + +def _add_scale(s: AsyncSession, token: str, sex: str, entry: ScaleEntry, ts: datetime) -> ScaleResult: + """Item responses + result — what the dialog's scale session stores (rows added, flushed in a batch later).""" + scale = load_scale(entry.scale) + answers = dict(zip([it.id for it in scale.items], entry.answers, strict=True)) + s.add_all( + ItemResponse(token=token, ts=ts, scale=scale.id, item_id=item_id, score=value, source="button") + for item_id, value in answers.items() + ) + result = score(scale.id, answers, sex=sex) # type: ignore[arg-type] + row = ScaleResult( + token=token, + ts=ts, + scale=scale.id, + total=result.total, + severity=result.severity_code, + complete=result.complete, + # PHQ-9 item 9 is kept separately: any value >= 1 escalates regardless of the total. + item9=answers.get("phq9_9") if scale.id == "phq9" else None, + scheduled=entry.scheduled, + flags={f: True for f in result.flags}, + ) + s.add(row) + return row + + +# ============================================================================================ monitoring + + +async def evaluate_at(s: AsyncSession, token: str, as_of: datetime) -> str: + """Monitoring pass on the DB as of `as_of` — the steps of `RuleMonitoring.after_data_change` (real + `build_snapshot` → rules → alerts → cached colour) without doctor notifications. Returns the colour.""" + profile = await repo.get_profile(s, token) + snapshot = await build_snapshot(s, token, as_of, profile=profile) + if profile is None or snapshot is None: + return "green" + actions = evaluate(snapshot) + running: set[str] = set() + if any(a.type == "crisis" for a in actions): + # A crisis whose acute alert is still open stays silent (the crisis script is already running). + open_acute = {a.code for a in await repo.list_alerts(s, token=token, open_only=True) if a.level == "acute"} + running = {a.code for a in actions if a.type == "crisis" and a.code in open_acute} + for action in (a for a in actions if a.code not in running): + if action.type == "enhanced_monitoring": + profile.enhanced_monitoring = True + if not action.creates_alert or action.level is None: + continue + alert = await repo.add_alert( + s, + token=token, + level=action.level, + code=action.code, + reason=action.reason_ru, + ts=as_of, + params=action.params, + ) + if action.type == "crisis": + await audit( + s, + actor=SYSTEM_ACTOR, + action="crisis_acute", + token=token, + details={"alert_id": alert.id, "code": action.code}, + ts=as_of, + ) + return await refresh_status(s, token) + + +async def _color(s: AsyncSession, token: str) -> str: + profile = await repo.get_profile(s, token) + return (profile.status_color if profile else None) or "green" + + +async def _ack_all(s: AsyncSession, token: str, ts: datetime) -> None: + for alert in await repo.list_alerts(s, token=token, open_only=True): + await repo.ack_alert(s, alert_id=alert.id, doctor_id=DOCTOR_ID, ts=ts) + await refresh_status(s, token) + + +# ============================================================================================ history writer + + +def _write_day( + s: AsyncSession, token: str, rx: Prescription, day_date: date, d: Day, now: datetime, rng: random.Random +) -> tuple[list[Dose], DailyCheckin | None]: + times = (rx.schedule or {}).get("times", []) + doses: list[Dose] = [] + if d.dose != "none": + for raw in times: + planned = datetime.combine(day_date, _hhmm(raw), tzinfo=now.tzinfo) + answered = None if d.dose == "missed" else min(planned + timedelta(minutes=rng.randint(3, 40)), now) + doses.append( + Dose(token=token, prescription_id=rx.id, planned_at=planned, status=d.dose, answered_at=answered) + ) + s.add_all(doses) + if d.silent: + return doses, None + checkin_at = _at(day_date, time(20, rng.randint(2, 25)), now) + text = scrub(d.text) if d.text else None + checkin = DailyCheckin( + token=token, + day=day_date, + mood=d.mood, + med_taken=d.med_taken, + sleep_bucket=d.sleep, + alcohol_bucket=d.alcohol, + side_effects=list(d.side_effects), + quit_intent=d.quit_intent or None, + nightmares=d.nightmares, + free_text_scrubbed=text if d.store_text else None, + updated_at=checkin_at, + ) + s.add(checkin) + # What the dialog engine logs for the last check-in step: the free text (scrubbed) or the «skip» button. + s.add(Message(token=token, ts=checkin_at, direction="in", text_scrubbed=text or "[button] daily:text:skip")) + return doses, checkin + + +class _Mirror: + """In-memory copy of what `build_snapshot` reads, kept while the history is written. + + Lets the day-by-day monitoring pass run the real rules without re-reading the whole history from the DB + every evening (that made a seed several seconds slower). The final pass at `now` uses the real + `build_snapshot` on the DB (`evaluate_at`), so the stored state is always checked by the production path. + """ + + def __init__(self, profile: PatientProfile, weapon_access: bool | None) -> None: + self.profile = profile + self.weapon_access = weapon_access + self.checkins: list[RuleCheckin] = [] + self.scales: list[ScaleResult] = [] + self.doses: list[DoseRecord] = [] + self.last_in: datetime | None = None + self.open_alerts: dict[str, str] = {} # code → level of unacknowledged alerts + + def add_day(self, doses: list[Dose], checkin: DailyCheckin | None) -> None: + self.doses += [DoseRecord(planned_at=d.planned_at, status=d.status) for d in doses] # type: ignore[arg-type] + if checkin is not None: + self.checkins.append( + RuleCheckin( + day=checkin.day, + mood=checkin.mood, + med_taken=checkin.med_taken, + sleep_hours_bucket=checkin.sleep_bucket, # type: ignore[arg-type] + alcohol=checkin.alcohol_bucket, # type: ignore[arg-type] + side_effects=list(checkin.side_effects or []), + quit_intent=bool(checkin.quit_intent), + nightmares=checkin.nightmares, + ) + ) + self.last_in = checkin.updated_at + + def snapshot(self, as_of: datetime) -> PatientSnapshot: + p = self.profile + assert p.birth_year is not None and p.therapy_start is not None + cssrs = [r for r in self.scales if r.scale in CSSRS_SCALES and as_of - CSSRS_RECENT < r.ts <= as_of] + return PatientSnapshot( + token=p.token, + as_of=as_of, + age=as_of.year - p.birth_year, + sex=p.sex, # type: ignore[arg-type] + modules=set(p.modules or []), # type: ignore[arg-type] + therapy_day=(as_of.date() - p.therapy_start).days, + checkins=self.checkins, + scale_results=[ + ScalePoint(scale=r.scale, ts=r.ts, total=r.total, item9=r.item9, scheduled=r.scheduled) + for r in self.scales + if r.scale not in CSSRS_SCALES and r.complete + ], + doses=self.doses, + cssrs_risk=cssrs[-1].severity if cssrs else None, # type: ignore[arg-type] + weapon_access=self.weapon_access, + last_patient_message_at=self.last_in, + open_alert_codes=set(self.open_alerts), + ) + + +async def _evening_pass(s: AsyncSession, mirror: _Mirror, as_of: datetime) -> None: + """Monitoring as of a past evening: real rules on the mirrored snapshot, alerts stored with that time.""" + token = mirror.profile.token + for action in evaluate(mirror.snapshot(as_of)): + if action.type == "crisis" and mirror.open_alerts.get(action.code) == "acute": + continue # crisis already running (same as RuleMonitoring) + if action.type == "enhanced_monitoring": + mirror.profile.enhanced_monitoring = True + if not action.creates_alert or action.level is None: + continue + alert = await repo.add_alert( + s, + token=token, + level=action.level, + code=action.code, + reason=action.reason_ru, + ts=as_of, + params=action.params, + ) + mirror.open_alerts[action.code] = alert.level + if action.type == "crisis": + await audit( + s, + actor=SYSTEM_ACTOR, + action="crisis_acute", + token=token, + details={"alert_id": alert.id, "code": action.code}, + ts=as_of, + ) + + +async def _write_history( + s: AsyncSession, + *, + profile: PatientProfile, + rx: Prescription, + history: History, + last_day: int, + now: datetime, + rng: random.Random, + weapon_access: bool | None = None, +) -> None: + """Days 0..last_day: doctor acks, scales, doses, check-ins; monitoring evaluated every evening.""" + token, sex, start = profile.token, profile.sex or "m", profile.therapy_start + assert start is not None + mirror = _Mirror(profile, weapon_access) + scales_by_day: dict[int, list[ScaleEntry]] = {} + for entry in history.scales: + scales_by_day.setdefault(entry.day, []).append(entry) + for n in range(0, last_day + 1): + day_date = start + timedelta(days=n) + if n in history.ack_days: + await _ack_all(s, token, _at(day_date, time(9, 30), now)) + mirror.open_alerts.clear() + for i, entry in enumerate(scales_by_day.get(n, [])): + mirror.scales.append(_add_scale(s, token, sex, entry, _at(day_date, entry.at, now) + timedelta(seconds=i))) + if n in history.days: + mirror.add_day(*_write_day(s, token, rx, day_date, history.days[n], now, rng)) + await _evening_pass(s, mirror, _at(day_date, time(21, 0), now)) + await s.flush() + + +# ============================================================================================ patients + + +async def _create_patient( + *, + token: str, + name: str, + snils: str, + age: int, + sex: str, + created: datetime, + consent_at: datetime | None, + now: datetime, + phone_suffix: int, +) -> None: + async with db_session.get_identity_session() as s: + await identity_repo.create_patient( + s, + token=token, + display_name=name, + snils=snils, + clinic_id=CLINIC_ID, + doctor_id=DOCTOR_ID, + now=created, + phone=f"+7 900 000-00-{phone_suffix:02d}", + birth_year=_birth_year(age, now), + sex=sex, + ) + if consent_at is not None: + await identity_repo.record_consent(s, token=token, version=_consent_version(), now=consent_at) + + +def _consent_version() -> str: + return load_dialog_texts().consent.version + + +async def _profile_and_rx( + s: AsyncSession, + *, + token: str, + modules: Sequence[str], + start: date, + age: int, + sex: str, + drug: str, + dose: str, + instructions: str, + dose_time: str, + now: datetime, + rx_start: date, +) -> tuple[PatientProfile, Prescription]: + therapy_day = (now.date() - start).days + profile = await repo.upsert_profile( + s, + token=token, + doctor_id=DOCTOR_ID, + modules=list(modules), + therapy_start=start, + # Visits every 4 weeks from the start of therapy: the next one still ahead of today. + next_visit=start + timedelta(days=28 * (therapy_day // 28 + 1)), + birth_year=_birth_year(age, now), + sex=sex, + enhanced_monitoring=False, + status_color="green", + ) + rx = Prescription( + token=token, + drug=drug, + dose=dose, + schedule={"times": [dose_time], "instructions": instructions}, + start_date=rx_start, + ) + s.add(rx) + await s.flush() + return profile, rx + + +async def _seed_andrey(andrey_day: int, now: datetime) -> tuple[str, str]: + token = pseudo_id(ANDREY_SNILS) + start = now.date() - timedelta(days=andrey_day) + visit_at = _at(start, time(11, 0), now) + consent_at = _at(start, time(11, 40), now) if andrey_day > 0 else None + await _create_patient( + token=token, + name=ANDREY_NAME, + snils=ANDREY_SNILS, + age=ANDREY_AGE, + sex="m", + created=visit_at, + consent_at=consent_at, + now=now, + phone_suffix=1, + ) + code = "".join(secrets.choice(_CODE_ALPHABET) for _ in range(6)) + async with db_session.get_identity_session() as s: + row = await identity_repo.create_connect_code(s, code=code, token=token, doctor_id=DOCTOR_ID, now=visit_at) + row.used_at = consent_at + rng = random.Random("andrey") + async with db_session.get_clinical_session() as s: + profile, rx = await _profile_and_rx( + s, + token=token, + modules=["veteran", "depression"], + start=start, + age=ANDREY_AGE, + sex="m", + drug="сертралин", + dose="50 мг", + instructions="утром после еды", + dose_time="08:00", + now=now, + rx_start=start + timedelta(days=1), # prescribed at the day-0 visit, first dose next morning + ) + s.add(SafetyPlan(token=token, steps=list(SAFETY_PLAN), updated_at=visit_at)) + if andrey_day == 0: + # Live demo starts here: consent, onboarding and every day happen in the console (andrey.jsonl). + await refresh_status(s, token) + return token, code + scales = [ScaleEntry(0, sc, ans, at=time(11, 50)) for sc, ans in ANDREY_ONBOARDING.items()] + scales += [ + ScaleEntry(7, "cssrs_screen", CSSRS_NO, at=time(10, 2)), + ScaleEntry(7, "phq9", ANDREY_DAY7_PHQ9, at=time(10, 6)), + ScaleEntry(13, "cssrs_screen", CSSRS_NO, scheduled=False, at=time(20, 30)), + ScaleEntry(13, "phq9", ANDREY_DAY13_PHQ9, scheduled=False, at=time(20, 35)), + ] + history = History( + days={d: v for d, v in ANDREY_DAYS.items() if d <= andrey_day}, + scales=[e for e in scales if e.day <= andrey_day], + ack_days={6}, # the doctor called after the day-5 alerts and acknowledged them + ) + await _write_history( + s, profile=profile, rx=rx, history=history, last_day=andrey_day, now=now, rng=rng, weapon_access=False + ) + await _andrey_dialog_state(s, token, rx, start, andrey_day, consent_at or now, now) + await evaluate_at(s, token, now) + return token, code + + +async def _andrey_dialog_state( + s: AsyncSession, token: str, rx: Prescription, start: date, andrey_day: int, consent_at: datetime, now: datetime +) -> None: + """Dialog state as the engine leaves it after onboarding and `andrey_day` days, plus the scheduler's + delivery log, so the running scheduler neither re-sends today's events nor restarts onboarding.""" + onboarding_done = _at(start, time(11, 55), now) + data: dict[str, Any] = { + "consent": True, + "consent_ver": _consent_version(), + "consent_at": consent_at.isoformat(), + "revoked_at": None, + "adult": True, + "facts": { + "trauma_exposure": True, + "weapon_access": False, + "weapon_access_choice": "none", + "weapon_safety_block": False, + "weapon_access_at": onboarding_done.isoformat(), + }, + "veteran_onboarding": {"stage": None, "done_at": onboarding_done.isoformat()}, + "checkin_done": now.date().isoformat(), + } + state = "idle" + if andrey_day >= 10: + data["visit_requests"] = [{"ts": _at(start + timedelta(days=10), time(20, 30), now).isoformat()}] + if andrey_day >= 13: + state = "crisis" + data["crisis"] = { + "open": True, + "opened_at": _at(start + timedelta(days=13), time(20, 35), now).isoformat(), + "source": "rule:phq9_item9", + "variant": crisis_reply([]).variant, + } + data["risk_screen_at"] = _at(start + timedelta(days=13), time(20, 25), now).isoformat() + await repo.set_dialog_state(s, token=token, state=state, data=data, now=now) + + # Same keys as app/scheduler/demo.py builds them. + done_at = now - timedelta(minutes=1) + results = [(r.scale, r.ts.astimezone(now.tzinfo).date()) for r in await repo.list_scale_results(s, token)] + for n in range(0, andrey_day + 1): + day_date = start + timedelta(days=n) + keys: list[tuple[str, str, datetime]] = [ + (f"checkin:{day_date}", "daily_checkin", datetime.combine(day_date, time(20, 0), tzinfo=now.tzinfo)), + (f"eval:{day_date}", "evaluation", datetime.combine(day_date, time(9, 0), tzinfo=now.tzinfo)), + ] + if day_date >= (rx.start_date or start): + for raw in (rx.schedule or {}).get("times", []): + planned = datetime.combine(day_date, _hhmm(raw), tzinfo=now.tzinfo) + keys.append((f"dose:{rx.id}:{planned.isoformat(timespec='minutes')}", "med_reminder", planned)) + # Scales due that day by the module schedules (the seeded day-7 PHQ-9 / C-SSRS are exactly those). + last = {sc: d for sc, d in sorted(results, key=lambda x: x[1]) if d < day_date} + for mod in load_modules().modules.values(): + for entry in mod.schedule: + last.setdefault(entry.scale, start) + for sc in due_scales({"veteran", "depression"}, n, last, day_date): + due = datetime.combine(day_date, time(10, 0), tzinfo=now.tzinfo) + keys.append((f"scale:{sc}:{day_date}", "scale_due", due)) + for key, kind, due in keys: + s.add(SchedulerEvent(token=token, key=key, kind=kind, due_at=due, done_at=min(due, done_at))) + await s.flush() + + +# --------------------------------------------------------------------------------------------- 7 patients + + +def _base_day(spec: PatientSpec, rng: random.Random, mood: tuple[int, int], prev: Day | None) -> Day: + veteran = "veteran" in spec.modules + sleep = alcohol = None + nightmares = None + if veteran: + sleep = rng.choice(["4-6", "4-6", "6-8"]) + nightmares = rng.random() < 0.3 + # Occasional drink, never two days in a row (a 3-day streak is the alcohol_3d rule). + alcohol = "1-2" if (rng.random() < 0.12 and (prev is None or prev.alcohol in (None, "none"))) else "none" + return Day(mood=rng.randint(*mood), sleep=sleep, nightmares=nightmares, alcohol=alcohol) + + +def _history_for(spec: PatientSpec) -> History: + """Daily history within the patient's status (persona table); random parts are seeded per patient.""" + rng = random.Random(f"seed-{spec.key}") + last = spec.therapy_day - 1 # history ends yesterday; today has not been reported yet + days: dict[int, Day] = {} + prev: Day | None = None + for n in range(1, last + 1): + # Mood slowly improves over the course of therapy, never three low days in a row. + lo = min(7, 4 + n // 14) + prev = days[n] = _base_day(spec, rng, (lo, lo + 2), prev) + + def patch(n: int, **kw: Any) -> None: + days[n] = replace(days[n], **kw) + + scales: list[ScaleEntry] = [] + acks: set[int] = set() + if spec.key == "dmitry": + for n in range(last - 2, last + 1): + patch(n, sleep="<4", nightmares=True) + patch(last, text="опять кошмары, сплю по 3 часа") + for n, pcl, phq in ((0, 49, 12), (14, 47, 11), (28, 46, 10), (42, 48, 11)): + scales += [ + ScaleEntry(n, "pcl5", spread("pcl5", pcl)), + ScaleEntry(n, "phq9", _phq(phq)), + ] + scales += [ScaleEntry(n, "dar5", spread("dar5", 12)) for n in (14, 28, 42)] + scales += [ScaleEntry(n, "auditc", [1, 1, 0]) for n in (0, 30)] + elif spec.key == "sergey": + for n in (9, 19, last - 4, last - 2): + patch(n, dose="missed", text="забыл выпить" if n >= last - 4 else None) + patch(last, dose="missed", silent=True) + for n, pcl, phq in ((0, 45, 13), (14, 42, 12), (28, 41, 12)): + scales += [ + ScaleEntry(n, "pcl5", spread("pcl5", pcl)), + ScaleEntry(n, "phq9", _phq(phq)), + ] + scales += [ScaleEntry(n, "dar5", spread("dar5", 10)) for n in (14, 28)] + scales += [ScaleEntry(0, "auditc", [1, 1, 1])] + elif spec.key == "nikolay": + for n in range(1, last + 1): + patch(n, alcohol="none") + for n, amount in ((last - 6, "1-2"), (last - 2, "1-2"), (last - 1, "more"), (last, "1-2")): + patch(n, alcohol=amount) + patch(last, text="по вечерам выпиваю, чтобы уснуть") + scales += [ + ScaleEntry(0, "pcl5", spread("pcl5", 40)), + ScaleEntry(0, "phq9", _phq(13)), + ScaleEntry(0, "auditc", [2, 1, 0]), + ] + elif spec.key == "anna": + for n in range(last - 2, last + 1): + patch(n, side_effects=("agitation",)) + patch(last, text="тревожно, не могу усидеть на месте") + for n in (0, 7): + scales += [ + ScaleEntry(n, "phq9", _phq(15)), + ScaleEntry(n, "cssrs_screen", CSSRS_NO), + ] + elif spec.key == "irina": + for n, phq in ((0, 16), (7, 13), (14, 9)): + scales += [ + ScaleEntry(n, "phq9", _phq(phq)), + ScaleEntry(n, "cssrs_screen", CSSRS_NO), + ] + elif spec.key == "olga": + for n in (2, 3, 4): + patch(n, side_effects=("nausea",), text="подташнивает после таблетки" if n == 2 else None) + patch(6, text="тошнота прошла") + acks.add(5) # the doctor reviewed the day-4 side-effect alert + for n, phq in ((0, 14), (7, 12)): + scales += [ + ScaleEntry(n, "phq9", _phq(phq)), + ScaleEntry(n, "cssrs_screen", CSSRS_NO), + ] + elif spec.key == "pavel": + for n, phq in ((0, 17), (7, 15), (14, 13), (21, 11), (28, 9), (35, 7), (42, 6), (56, 4)): + scales.append(ScaleEntry(n, "phq9", _phq(phq))) + scales += [ScaleEntry(n, "cssrs_screen", CSSRS_NO) for n in range(0, last + 1, 7)] + return History(days=days, scales=scales, ack_days=acks) + + +async def _seed_other(spec: PatientSpec, idx: int, now: datetime) -> tuple[str, str]: + token = pseudo_id(spec.snils) + start = now.date() - timedelta(days=spec.therapy_day) + visit_at = _at(start, time(11, 0), now) + await _create_patient( + token=token, + name=spec.name, + snils=spec.snils, + age=spec.age, + sex=spec.sex, + created=visit_at, + consent_at=_at(start, time(11, 40), now), + now=now, + phone_suffix=idx + 2, + ) + history = _history_for(spec) + async with db_session.get_clinical_session() as s: + profile, rx = await _profile_and_rx( + s, + token=token, + modules=spec.modules, + start=start, + age=spec.age, + sex=spec.sex, + drug=spec.drug, + dose=spec.dose, + instructions=spec.instructions, + dose_time=spec.dose_time, + now=now, + rx_start=start + timedelta(days=1), + ) + await _write_history( + s, + profile=profile, + rx=rx, + history=history, + last_day=spec.therapy_day - 1, + now=now, + rng=random.Random(f"times-{spec.key}"), + ) + color = await evaluate_at(s, token, now) + return token, color + + +# ============================================================================================ entry points + + +async def _clear_tables(keep: frozenset[str] = frozenset({ClockState.__tablename__})) -> None: + async with db_session.get_clinical_session() as s: + for table in reversed(ClinicalBase.metadata.sorted_tables): + if table.name not in keep: + await s.execute(delete(table)) + async with db_session.get_identity_session() as s: + for table in reversed(IdentityBase.metadata.sorted_tables): + await s.execute(delete(table)) + + +async def reset(andrey_day: int | None = 0) -> SeedResult | None: + """Drop and recreate every table of both databases, put the demo clock back to the wall clock, then seed + the cabinet again (Andrey on `andrey_day`; None = leave the databases empty). + + Also called in-process by `POST /api/v1/demo/reset` (app/api/demo.py), so pending clock-offset writes of the + running app are awaited before the tables are dropped and after the clock is reset. + """ + await flush_clock_writes() + async with db_session.identity_engine().begin() as conn: + await conn.run_sync(IdentityBase.metadata.drop_all) + async with db_session.clinical_engine().begin() as conn: + await conn.run_sync(ClinicalBase.metadata.drop_all) + await init_db() + clock.reset() + await flush_clock_writes() + async with db_session.get_clinical_session() as s: + await repo.save_clock_offset(s, clock.offset().total_seconds()) + return None if andrey_day is None else await seed(andrey_day) + + +async def seed(andrey_day: int = 0, *, now: datetime | None = None) -> SeedResult: + """Wipe the demo data (keeps the demo clock) and write the cabinet. Idempotent and deterministic except for + the one-time connect code.""" + if not 0 <= andrey_day <= ANDREY_MAX_DAY: + raise ValueError(f"andrey_day must be within 0..{ANDREY_MAX_DAY}") + await init_db() + await _clear_tables() + now = now or clock.now() + async with db_session.get_identity_session() as s: + await identity_repo.upsert_doctor( + s, doctor_id=DOCTOR_ID, full_name=DOCTOR_NAME, clinic_id=CLINIC_ID, on_call=True + ) + token, code = await _seed_andrey(andrey_day, now) + async with db_session.get_clinical_session() as s: + colors = {ANDREY_NAME: await _color(s, token)} + tokens = {ANDREY_NAME: token} + for idx, spec in enumerate(OTHERS): + tokens[spec.name], colors[spec.name] = await _seed_other(spec, idx, now) + return SeedResult( + now=now, andrey_token=token, andrey_day=andrey_day, connect_code=code, colors=colors, tokens=tokens + ) + + +# ============================================================================================ scenario file + + +def scenario_lines() -> list[dict[str, Any]]: + """Andrey's live scenario for the demo console (`data/synthetic/andrey.jsonl`), built from the button labels + in `config/` so it stays in sync with the dialog. Verified by replay in tests/test_seed_demo.py.""" + from app.dialog.flows import veteran_onboarding as vo + from app.dialog.flows.meds import load_meds_texts + from app.dialog.flows.veteran_daily import load_daily_texts + + dialog, onb, meds, daily = load_dialog_texts(), vo.load_texts(), load_meds_texts(), load_daily_texts() + ck = daily.checkin + + def scale(scale_id: str, values: list[int]) -> list[dict[str, Any]]: + items = load_scale(scale_id).items + return [ + {"press": next(o.label for o in it.options if o.value == v)} for it, v in zip(items, values, strict=True) + ] + + def checkin(d: Day) -> list[dict[str, Any]]: + assert d.mood is not None and d.sleep is not None and d.alcohol is not None + acts: list[dict[str, Any]] = [ + {"advance_to": "20:00"}, + {"press": str(d.mood)}, + {"press": ck.sleep.options[d.sleep]}, + {"press": ck.nightmares.yes if d.nightmares else ck.nightmares.no}, + {"press": ck.alcohol.options[d.alcohol]}, + ] + acts.append({"text": d.text} if d.text else {"press": ck.text.skip}) + return acts + + notes = { + 0: "День 0, визит к психиатру. Врач подключил Андрея (код из seed). Андрей пишет боту: согласие, знакомство, " + "короткий скрининг (PC-PTSD-5, PCL-5, PHQ-9, AUDIT-C), вопрос про оружие. Баллы пациенту не показываются.", + 1: "День 1. 08:00 — напоминание о сертралине → «Принял». 20:00 — чек-ин: настроение, сон, кошмары, алкоголь.", + 2: "День 2. «Подташнивает» → ответ по инструкции к препарату (ГРЛС): частая побочка, проходит за 1–2 недели.", + 3: "День 3. Тошнота снова → короткая поддержка, инструкция не повторяется.", + 4: "День 4. Побочка третий день подряд → правило side_effect_3d, у врача жёлтый.", + 5: "День 5. «Пропущу» → «Решил(а) бросить»; вечером «брошу» → отсроченный эффект, не бросать без врача; " + "алерт quit_intent (жёлтый).", + 6: "День 6. Врач позвонил Андрею. ВЕДУЩИЙ: до вечернего чек-ина подтвердите (ack) алерты Андрея на дашборде — " + "тогда он снова зелёный.", + 7: "День 7. 10:00 — плановые шкалы по расписанию модулей: скрининг C-SSRS (все «Нет»), затем PHQ-9 = 13 " + "(п.9 = 0). PCL-5 по расписанию раз в 14 дней.", + 8: "День 8. Сон < 4 ч, кошмары, 1–2 порции алкоголя.", + 9: "День 9. Сон < 4 ч, алкоголь больше.", + 10: "День 10. Сон < 4 ч три ночи + алкоголь три дня → информация о взаимодействии с сертралином и кнопка " + "«Записаться к врачу раньше»; у врача жёлтый.", + 11: "День 11. Алерты открыты, правило молчит (без повторов).", + 12: "День 12. Настроение 3 второй день подряд, у врача по-прежнему жёлтый.", + 13: "День 13. «Всё бессмысленно» → скрининг C-SSRS кнопками; настроение ≤ 3 три дня → внеплановый PHQ-9 = 18, " + "п.9 = 2 → кризисный скрипт, план безопасности, алерт дежурному врачу — красный.", + } + lines: list[dict[str, Any]] = [] + day0: list[dict[str, Any]] = [{"text": "Здравствуйте"}, {"press": dialog.consent.accept}, {"press": onb.intro.go}] + day0 += [{"press": onb.exposure.answer_yes}] + for sc in ("pcptsd5", "pcl5", "phq9", "auditc"): + day0 += scale(sc, ANDREY_ONBOARDING[sc]) + day0 += [{"press": onb.weapon.no_access}, {"expect_color": "green"}] + lines.append({"day": 0, "note": notes[0], "actions": day0}) + + for n in range(1, ANDREY_MAX_DAY + 1): + d = ANDREY_DAYS[n] + acts: list[dict[str, Any]] = [{"advance_to": "08:00"}] + if d.dose == "taken": + acts.append({"press": meds.reminder.taken}) + else: + acts += [{"press": meds.reminder.skip}, {"press": meds.skip.reasons["quit"]}] + if n == 7: + # The scheduler delivers both due scales at 10:00; the C-SSRS screen comes first, PHQ-9 is queued. + acts += [{"advance_to": "10:00"}, *scale("cssrs_screen", CSSRS_NO), *scale("phq9", ANDREY_DAY7_PHQ9)] + acts += checkin(d) + if n == 10: + acts.append({"press": dialog.visit.button}) + if n == 13: + acts += [*scale("cssrs_screen", CSSRS_NO), *scale("phq9", ANDREY_DAY13_PHQ9)] + color = ANDREY_COLORS[n - 1] + if n not in (6, 7, 8, 9): # green on days 6–9 only after the presenter's ack on the dashboard + acts.append({"expect_color": color}) + lines.append({"day": n, "note": notes[n], "actions": acts}) + return lines + + +def write_scenario(path: Path = SCENARIO_PATH) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + body = "".join(json.dumps(line, ensure_ascii=False) + "\n" for line in scenario_lines()) + path.write_text(body, encoding="utf-8") + return path + + +def load_scenario(path: Path = SCENARIO_PATH) -> list[dict[str, Any]]: + return [json.loads(raw) for raw in path.read_text(encoding="utf-8").splitlines() if raw.strip()] + + +# ============================================================================================ CLI + + +async def _restore_clock() -> None: + """Pick up the demo-clock offset persisted by the running app, so seeded dates match its virtual 'today'.""" + await init_db() + async with db_session.get_clinical_session() as s: + offset = await repo.load_clock_offset(s) + if clock.offset().total_seconds() != offset: + clock.register_persistence(lambda: timedelta(seconds=offset), None) + + +async def _main(args: argparse.Namespace) -> int: + if args.write_scenario: + print(f"scenario written: {write_scenario()}") + return 0 + try: + if args.reset: + result = await reset(args.andrey_day) + assert result is not None + else: + await _restore_clock() + result = await seed(args.andrey_day) + finally: + await db_session.dispose() + print(f"Демо-кабинет: д-р {DOCTOR_NAME} ({DOCTOR_ID}), виртуальное сейчас {result.now:%Y-%m-%d %H:%M}") + for name, color in result.colors.items(): + print(f" {color:<6} {name:<12} token={result.tokens[name]}") + if result.andrey_day == 0: + print(f"Андрей — день 0, согласия ещё нет. Код подключения: {result.connect_code}") + print(" Telegram: /start <код>; веб-консоль: сценарий data/synthetic/andrey.jsonl") + else: + print(f"Андрей — день {result.andrey_day} терапии.") + if args.reset: + print("Часы сброшены. Если приложение уже запущено, лучше сбросить через него: POST /api/v1/demo/reset") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="python -m scripts.seed_demo", description=__doc__.splitlines()[0]) + parser.add_argument("--reset", action="store_true", help="drop/recreate both DBs and zero the demo clock first") + parser.add_argument( + "--andrey-day", + type=int, + default=0, + choices=range(0, ANDREY_MAX_DAY + 1), + metavar="N", + help=f"Andrey's therapy day 0..{ANDREY_MAX_DAY} (0 = live demo from consent; 13 = crisis)", + ) + parser.add_argument("--write-scenario", action="store_true", help="regenerate data/synthetic/andrey.jsonl") + return asyncio.run(_main(parser.parse_args(argv))) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_seed_demo.py b/tests/test_seed_demo.py new file mode 100644 index 0000000..186060e --- /dev/null +++ b/tests/test_seed_demo.py @@ -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