CI / ruff · pytest (pull_request) Successful in 1m44s
- 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>
1057 lines
46 KiB
Python
1057 lines
46 KiB
Python
"""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())
|