"""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())