Files
ElebobaandClaude Opus 5.5 bc91873a13
CI / ruff · pytest (pull_request) Successful in 1m44s
feat(backend): demo seed — veteran Andrey (day 0..13) and 7 cabinet patients
- scripts/seed_demo.py: doctor doc-smirnova (on call), Andrey day 0 (connect code,
  no consent) or day N with history; 7 patients with seeded-random histories.
  Alerts and colours come from the real rules evaluated day by day.
- --reset drops/recreates both DBs and zeroes the demo clock; seed keeps the
  persisted clock offset so dates match the running app.
- data/synthetic/andrey.jsonl: console scenario for days 0..13 generated from
  config button labels (--write-scenario).
- tests: idempotency, modes, colours, scenario format/sync and a full replay of
  the scenario through the real engine, scheduler and monitoring.

Refs #18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:50:30 +03:00

1057 lines
46 KiB
Python
Raw Permalink Blame History

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