release: dev → main (сдача хакатона) #106

Merged
Eleboba merged 128 commits from dev into main 2026-10-04 23:00:56 +03:00
6 changed files with 1099 additions and 33 deletions
Showing only changes of commit 25728c4b31 - Show all commits
+89
View File
@@ -0,0 +1,89 @@
"""Demo-console helpers (#12, ADR-005): scripted personas, reset, connect links, outbox access.
Synthetic data only. Scenario files live in `data/synthetic/<slug>.jsonl` (#18); a patient is a scripted persona
when the transliterated first word of the display name equals the slug ("Андрей С." → `andrey.jsonl`).
"""
import inspect
import logging
from importlib import import_module, util
from pathlib import Path
from app.config import get_settings
from app.integrations.base import MemoryOutbox, Outbox, integrations
log = logging.getLogger(__name__)
SEED_MODULE = "scripts.seed_demo"
CONSOLE_PATH = "/console"
_TRANSLIT = {
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh", "з": "z", "и": "i",
"й": "y", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t",
"у": "u", "ф": "f", "х": "kh", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "shch", "ъ": "", "ы": "y", "ь": "",
"э": "e", "ю": "yu", "я": "ya",
} # fmt: skip
def persona_slug(display_name: str) -> str:
first = (display_name.split() or [""])[0].lower()
return "".join(_TRANSLIT.get(ch, ch if ch.isascii() and ch.isalnum() else "") for ch in first)
def scenario_files(data_dir: Path | None = None) -> dict[str, str]:
"""slug → scenario path relative to the project root, e.g. {"andrey": "data/synthetic/andrey.jsonl"}."""
root = data_dir or get_settings().data_dir
folder = root / "synthetic"
if not folder.is_dir():
return {}
return {f.stem: f"data/synthetic/{f.name}" for f in sorted(folder.glob("*.jsonl"))}
async def bot_username() -> str | None:
"""Username of the running Telegram bot (deep links); None when the bot is not started (no TG_BOT_TOKEN)."""
from app.channels import telegram
bot = getattr(telegram, "_bot", None)
if bot is None:
return None
try:
me = await bot.me() # aiogram caches getMe
except Exception as exc: # noqa: BLE001 — network / auth errors: fall back to the web console link
log.warning("bot username unavailable error=%s", type(exc).__name__)
return None
return me.username or None
async def deep_link(code: str) -> str:
username = await bot_username()
return f"https://t.me/{username}?start={code}" if username else f"{CONSOLE_PATH}?code={code}"
def memory_outbox() -> MemoryOutbox | None:
"""The in-memory outbox polled by the web console, also when wrapped (Telegram keeps it as `fallback`)."""
box: Outbox | None = integrations.outbox
for _ in range(5):
if isinstance(box, MemoryOutbox):
return box
box = getattr(box, "fallback", None)
return None
async def reset_demo() -> bool:
"""Clock reset is done by the caller; re-seed via `scripts.seed_demo.reset()` (#18) when it exists."""
box = memory_outbox()
if box is not None:
box._q.clear()
try:
spec = util.find_spec(SEED_MODULE)
except ModuleNotFoundError: # no `scripts` package on the path (e.g. installed app without scripts/)
spec = None
if spec is None:
return False
reset = getattr(import_module(SEED_MODULE), "reset", None)
if reset is None:
return False
result = reset()
if inspect.isawaitable(result):
await result
return True
+368
View File
@@ -0,0 +1,368 @@
"""Doctor dashboard read models (#12): clinical DB rows → frozen API schemas (`app/api/schemas.py`).
Everything shown to the doctor is derived from stored structured data — no LLM decides colours, trends or the
discontinuation risk (ADR-007). Time comes in as `now` (from `app.core.clock`, ADR-005).
Identity data (guardrail #5): the API is the doctor's display boundary, so it reads exactly two identity fields —
`display_name` (minimal identification for the treating doctor) and `birth_year` (age). SNILS, phone and chat
id are never selected into a response model.
"""
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from typing import Any, Literal, cast, get_args
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.schemas import (
AlertOut,
Color,
DiscontinuationRisk,
DoseOut,
LevelName,
Metric,
Module,
PatientCard,
PatientListItem,
SeriesPoint,
)
from app.core.rules import color, patient_status
from app.core.types import AlertLevel
from app.db import repo
from app.db.clinical import Alert, DailyCheckin, Dose, PatientProfile, Prescription, SafetyPlan, ScaleResult
from app.db.identity import Patient
from app.integrations.base import Prescription as PrescriptionOut
COLOR_ORDER: dict[str, int] = {"red": 0, "yellow": 1, "grey": 2, "green": 3}
_MODULES = frozenset(get_args(Module))
_METRICS = frozenset(get_args(Metric))
_SCALE_LABELS = {"phq9": "PHQ-9", "pcl5": "PCL-5", "gad7": "GAD-7", "auditc": "AUDIT-C", "dar5": "DAR-5"}
# Daily check-in buckets → numbers for charts. Sleep: bucket midpoints in hours ("<4" plotted as 3, ">8" as 9).
SLEEP_HOURS = {"<4": 3.0, "4-6": 5.0, "6-8": 7.0, ">8": 9.0}
# Alcohol: 0 = none, 1 = 1–2 drinks, 2 = more (same coding as app/llm/summary.py expects).
ALCOHOL_LEVEL = {"none": 0.0, "1-2": 1.0, "more": 2.0}
# Trend: the last two complete results of one scale. A change smaller than about half of the minimal clinically
# important difference (PHQ-9: 5 points, PCL-5: 10 points) is reported as "flat" so noise does not flip arrows.
TREND_MIN_CHANGE = {"phq9": 3, "pcl5": 5}
CARD_DAYS = 60 # how far back the card series / dose log reach
# --------------------------------------------------------------------------- discontinuation risk index
#
# Rule-based index (NOT a trained model) of the risk that the patient drops the therapy, 0–100, for sorting the
# list and for the "talk about adherence" hint. Each factor is a deterministic check over the clinical DB with a
# fixed weight; the score is the capped sum. Weights reflect the known predictors of early antidepressant
# discontinuation (non-adherence, side effects in the first weeks, expressed intent to stop, low mood, alcohol):
#
# factor weight label for the doctor
# missed/skipped doses on >= 3 of the last 7 days 30 "пропуски приёма N/7 дн."
# ... on 1–2 of the last 7 days 15 "пропуски приёма N/7 дн."
# side effects reported >= 3 days in a row 20 "побочные эффекты N дн. подряд"
# ... 1–2 days in a row 10 "побочные эффекты N дн. подряд"
# intent to stop medication voiced in last 14 days 25 "хотел бросить (день N)"
# open, unacknowledged quit_intent alert +15 "не разобран сигнал «хочет бросить»"
# mood <= 3 for >= 3 days in a row 15 "настроение ≤ 3 N дн. подряд"
# alcohol on >= 3 of the last 7 days 15 "алкоголь N дн. из 7"
# ... on 1–2 of the last 7 days 5 "алкоголь N дн. из 7"
#
# Level: >= 60 high, >= 30 medium, otherwise low. "Day" = calendar day in the clinic time zone; streaks end today
# if today's check-in exists, otherwise yesterday (same anchor as app.core.rules), and a day without a check-in
# breaks a streak — we never assume a symptom the patient did not report.
RISK_WINDOW_DAYS = 7
QUIT_WINDOW_DAYS = 14
MOOD_LOW = 3
W_MISSED_MANY, W_MISSED_SOME = 30, 15
W_SIDE_EFFECTS_LONG, W_SIDE_EFFECTS_SHORT = 20, 10
W_QUIT_INTENT, W_QUIT_ALERT_OPEN = 25, 15
W_MOOD_LOW = 15
W_ALCOHOL_MANY, W_ALCOHOL_SOME = 15, 5
STREAK_DAYS = 3
RISK_HIGH, RISK_MEDIUM = 60, 30
@dataclass
class PatientData:
"""Everything the list item and the card are computed from (one patient, loaded once)."""
profile: PatientProfile
checkins: Sequence[DailyCheckin]
results: Sequence[ScaleResult]
doses: Sequence[Dose]
prescriptions: Sequence[Prescription]
alerts: Sequence[Alert] # newest first
async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> PatientData:
token = profile.token
doses = (await session.scalars(select(Dose).where(Dose.token == token).order_by(Dose.planned_at))).all()
rx = (
await session.scalars(select(Prescription).where(Prescription.token == token).order_by(Prescription.id))
).all()
return PatientData(
profile=profile,
checkins=await repo.list_checkins(session, token),
results=await repo.list_scale_results(session, token),
doses=doses,
prescriptions=rx,
alerts=await repo.list_alerts(session, token=token),
)
def _local(ts: datetime, now: datetime) -> date:
return ts.astimezone(now.tzinfo).date()
def _streak_days(checkins: Iterable[DailyCheckin], today: date, pred: Any) -> int:
"""Length of the run of consecutive check-in days satisfying `pred`, ending at the anchor day."""
by_day = {c.day: c for c in checkins if c.day <= today}
day = today if today in by_day else today - timedelta(days=1)
n = 0
while (c := by_day.get(day)) is not None and pred(c):
n += 1
day -= timedelta(days=1)
return n
def _therapy_day(profile: PatientProfile, day: date) -> int:
return (day - profile.therapy_start).days if profile.therapy_start else 0
def _missed_days(d: PatientData, now: datetime, first: date) -> set[date]:
# Same sources as rule missed_doses_7d: dose records and the check-in answer "did not take".
days = {_local(x.planned_at, now) for x in d.doses if x.status in ("missed", "skipped") and x.planned_at <= now}
days |= {c.day for c in d.checkins if c.med_taken is False}
return {x for x in days if first <= x <= now.date()}
def discontinuation_risk(d: PatientData, now: datetime) -> DiscontinuationRisk:
"""Deterministic discontinuation-risk index; see the weight table above."""
today = now.date()
first = today - timedelta(days=RISK_WINDOW_DAYS - 1)
score = 0
factors: list[str] = []
missed = len(_missed_days(d, now, first))
if missed:
score += W_MISSED_MANY if missed >= STREAK_DAYS else W_MISSED_SOME
factors.append(f"пропуски приёма {missed}/{RISK_WINDOW_DAYS} дн.")
side = _streak_days(d.checkins, today, lambda c: bool(c.side_effects))
if side:
score += W_SIDE_EFFECTS_LONG if side >= STREAK_DAYS else W_SIDE_EFFECTS_SHORT
factors.append(f"побочные эффекты {side} дн. подряд")
quit_since = today - timedelta(days=QUIT_WINDOW_DAYS - 1)
quit_days = [c.day for c in d.checkins if c.quit_intent and quit_since <= c.day <= today]
if quit_days:
score += W_QUIT_INTENT
factors.append(f"хотел бросить (день {_therapy_day(d.profile, max(quit_days))})")
if any(a.code == "quit_intent" and a.acked_at is None for a in d.alerts):
score += W_QUIT_ALERT_OPEN
factors.append("не разобран сигнал «хочет бросить»")
low = _streak_days(d.checkins, today, lambda c: c.mood is not None and c.mood <= MOOD_LOW)
if low >= STREAK_DAYS:
score += W_MOOD_LOW
factors.append(f"настроение ≤ {MOOD_LOW} {low} дн. подряд")
drinking = sum(1 for c in d.checkins if first <= c.day <= today and c.alcohol_bucket not in (None, "none"))
if drinking:
score += W_ALCOHOL_MANY if drinking >= STREAK_DAYS else W_ALCOHOL_SOME
factors.append(f"алкоголь {drinking} дн. из {RISK_WINDOW_DAYS}")
score = min(score, 100)
level: Literal["low", "medium", "high"] = (
"high" if score >= RISK_HIGH else "medium" if score >= RISK_MEDIUM else "low"
)
return DiscontinuationRisk(score=score, level=level, factors=factors)
# --------------------------------------------------------------------------- list item
def _complete(d: PatientData, scale: str) -> list[ScaleResult]:
return [r for r in d.results if r.scale == scale and r.complete] # repo returns them oldest first
def trend(d: PatientData) -> Literal["up", "down", "flat", "unknown"]:
"""Direction of symptom scores (up = worse) from the scale with the most recent pair of results."""
pairs = [(s, rs[-2:]) for s in TREND_MIN_CHANGE if len(rs := _complete(d, s)) >= 2]
if not pairs:
return "unknown"
scale, (prev, last) = max(pairs, key=lambda p: p[1][-1].ts)
delta = last.total - prev.total
if abs(delta) < TREND_MIN_CHANGE[scale]:
return "flat"
return "up" if delta > 0 else "down"
def adherence_7d(d: PatientData, now: datetime) -> float | None:
"""Taken / planned doses of the last 7 days; doses still `pending` (not resolved yet) are not counted."""
since = now - timedelta(days=RISK_WINDOW_DAYS)
resolved = [x for x in d.doses if since < x.planned_at <= now and x.status != "pending"]
if not resolved:
return None
return round(sum(1 for x in resolved if x.status == "taken") / len(resolved), 2)
def _drug_label(rx: Prescription) -> str:
return f"{rx.drug} {rx.dose}".strip()
def _active_rx(d: PatientData, today: date) -> Prescription | None:
active = [
r
for r in d.prescriptions
if (r.start_date is None or r.start_date <= today) and (r.end_date is None or r.end_date >= today)
]
return (active or list(d.prescriptions) or [None])[-1]
def _last_event(d: PatientData, now: datetime) -> str:
alert = d.alerts[0] if d.alerts else None
scales = [r for r in d.results if r.complete and r.scale in _SCALE_LABELS]
scale = scales[-1] if scales else None
if scale is not None and (alert is None or scale.ts > alert.ts):
day = _therapy_day(d.profile, _local(scale.ts, now))
return f"{_SCALE_LABELS[scale.scale]} = {scale.total} (день {day})"
return alert.reason if alert is not None else ""
def _age(patient: Patient | None, profile: PatientProfile, today: date) -> int:
birth_year = (patient.birth_year if patient else None) or profile.birth_year
return today.year - birth_year if birth_year else 0
def list_item(d: PatientData, patient: Patient | None, now: datetime) -> PatientListItem:
p = d.profile
today = now.date()
open_alerts = [a for a in d.alerts if a.acked_at is None]
# ADR-006: the colour is derived from open alert levels (the source of truth), not from the cached column.
status = patient_status(AlertLevel[a.level.upper()] for a in open_alerts)
phq9, pcl5 = _complete(d, "phq9"), _complete(d, "pcl5")
rx = _active_rx(d, today)
return PatientListItem(
token=p.token,
display_name=patient.display_name if patient else "Пациент",
age=_age(patient, p, today),
modules=[cast(Module, m) for m in p.modules or [] if m in _MODULES],
drug=_drug_label(rx) if rx else None,
therapy_day=_therapy_day(p, today),
next_visit=p.next_visit,
status_color=status.color,
top_alert_level=cast(LevelName, status.top_level.name.lower()) if status.top_level is not None else None,
open_alerts=len(open_alerts),
phq9_last=phq9[-1].total if phq9 else None,
pcl5_last=pcl5[-1].total if pcl5 else None,
trend=trend(d),
adherence_7d=adherence_7d(d, now),
discontinuation_risk=discontinuation_risk(d, now),
last_event=_last_event(d, now),
)
def sort_key(item: PatientListItem) -> tuple[int, int, str]:
"""red → yellow → grey → green, then discontinuation risk (desc), then name for a stable order."""
return (COLOR_ORDER[item.status_color], -item.discontinuation_risk.score, item.display_name)
# --------------------------------------------------------------------------- card
def alert_out(a: Alert, patient_name: str) -> AlertOut:
level = cast(LevelName, a.level)
return AlertOut(
id=a.id,
token=a.token,
patient_name=patient_name,
ts=a.ts,
level=level,
color=cast(Color, color([AlertLevel[a.level.upper()]])),
code=a.code,
reason=a.reason,
acked_by=a.acked_by,
acked_at=a.acked_at,
)
def series(d: PatientData, now: datetime) -> list[SeriesPoint]:
since = now.date() - timedelta(days=CARD_DAYS)
out: list[SeriesPoint] = []
for c in d.checkins:
if not since <= c.day <= now.date():
continue
if c.mood is not None:
out.append(SeriesPoint(day=c.day, metric="mood", value=float(c.mood)))
if c.sleep_bucket in SLEEP_HOURS:
out.append(SeriesPoint(day=c.day, metric="sleep_hours", value=SLEEP_HOURS[c.sleep_bucket]))
if c.alcohol_bucket in ALCOHOL_LEVEL:
out.append(SeriesPoint(day=c.day, metric="alcohol", value=ALCOHOL_LEVEL[c.alcohol_bucket]))
for r in d.results:
day = _local(r.ts, now)
if r.complete and r.scale in _METRICS and since <= day and r.ts <= now:
out.append(SeriesPoint(day=day, metric=cast(Metric, r.scale), value=float(r.total), scheduled=r.scheduled))
return sorted(out, key=lambda p: (p.day, p.metric))
def doses(d: PatientData, now: datetime) -> list[DoseOut]:
labels = {r.id: _drug_label(r) for r in d.prescriptions}
since = now - timedelta(days=CARD_DAYS)
return [
DoseOut(planned_at=x.planned_at, drug=labels.get(x.prescription_id, ""), status=x.status) # type: ignore[arg-type]
for x in d.doses
if since <= x.planned_at <= now + timedelta(days=1)
]
def prescriptions(d: PatientData) -> list[PrescriptionOut]:
out = []
for r in d.prescriptions:
schedule = dict(r.schedule or {})
out.append(
PrescriptionOut(
drug=r.drug,
dose=r.dose,
schedule={"times": [str(t) for t in schedule.get("times", [])]},
instructions=" ".join(str(t) for t in _as_list(schedule.get("instructions"))),
start_date=r.start_date,
end_date=r.end_date,
)
)
return out
def _as_list(value: Any) -> list[Any]:
if value is None:
return []
return list(value) if isinstance(value, list) else [value]
def _plan_line(step: Any) -> str:
# Same rendering as the dialog's safety-plan reply (app/dialog/actions.py).
if isinstance(step, dict):
return ": ".join(str(v) for v in step.values() if v)
return str(step)
async def safety_plan(session: AsyncSession, token: str) -> list[str] | None:
row = await session.get(SafetyPlan, token)
return [_plan_line(s) for s in row.steps] if row is not None and row.steps else None
async def build_card(session: AsyncSession, d: PatientData, patient: Patient | None, now: datetime) -> PatientCard:
"""Card without the summary (the caller adds it: the LLM call must not hold the DB session)."""
item = list_item(d, patient, now)
return PatientCard(
patient=item,
prescriptions=prescriptions(d),
series=series(d, now),
doses=doses(d, now),
alerts=[alert_out(a, item.display_name) for a in d.alerts],
safety_plan=await safety_plan(session, d.profile.token),
)
+188 -23
View File
@@ -1,11 +1,20 @@
"""API routes `/api/v1` (#34 stubs → #12 implementation). """API routes `/api/v1` (#34 contract → #12 implementation).
Stubs return deterministic fixtures (`app/api/fixtures.py`) so the dashboard can be built immediately. Paths and schemas are the frozen contract (`app/api/schemas.py`); bodies read the identity and clinical DBs.
Lane B replaces bodies in #12 without changing paths or schemas (those are the frozen contract). Read models live in `app/api/doctor_views.py`, demo-console helpers in `app/api/demo.py`.
Every doctor access to patient data is written to `audit_log` (guardrail #6): `view_patient_list`,
`view_patient` (also a denied attempt: `view_patient_denied`), `view_alerts` (only when new alerts are
disclosed — the dashboard polls every 2–3 s), `ack_alert` (in `repo.ack_alert`), `connect_patient`.
`app/api/fixtures.py` is kept for tests and as the demo cabinet reference (doctor id).
""" """
from fastapi import APIRouter, HTTPException import secrets
from fastapi import APIRouter, HTTPException
from sqlalchemy import select
from app.api import demo as console
from app.api import doctor_views as views
from app.api import fixtures from app.api import fixtures
from app.api.questionnaire import router as questionnaire_router from app.api.questionnaire import router as questionnaire_router
from app.api.schemas import ( from app.api.schemas import (
@@ -23,69 +32,203 @@ from app.api.schemas import (
PatientListItem, PatientListItem,
) )
from app.core import clock from app.core import clock
from app.dialog.contracts import get_engine from app.core.privacy import pseudo_id
from app.integrations.base import MemoryOutbox, integrations from app.db import identity_repo, repo
from app.db.audit import audit
from app.db.clinical import Alert, Message, PatientProfile
from app.db.clinical import Prescription as PrescriptionRow
from app.db.identity import Patient
from app.db.session import get_clinical_session, get_identity_session
from app.dialog.contracts import Event, get_engine
from app.integrations.base import integrations
from app.scheduler import jobs as scheduler from app.scheduler import jobs as scheduler
from app.services.monitoring import refresh_status
router = APIRouter(prefix="/api/v1") router = APIRouter(prefix="/api/v1")
router.include_router(questionnaire_router) router.include_router(questionnaire_router)
DEFAULT_CLINIC = "clinic-demo"
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I: the code may be typed by hand
CODE_LEN = 8
async def _patients(tokens: list[str]) -> dict[str, Patient]:
"""Identity rows for display name / age only (never put SNILS or phone into a response)."""
if not tokens:
return {}
async with get_identity_session() as s:
rows = (await s.scalars(select(Patient).where(Patient.token.in_(tokens)))).all()
return {p.token: p for p in rows}
def _name(patients: dict[str, Patient], token: str) -> str:
p = patients.get(token)
return p.display_name if p else "Пациент"
# ------------------------------------------------------------------ doctor dashboard # ------------------------------------------------------------------ doctor dashboard
@router.get("/doctors/{doctor_id}/patients", response_model=list[PatientListItem], tags=["doctor"]) @router.get("/doctors/{doctor_id}/patients", response_model=list[PatientListItem], tags=["doctor"])
async def list_patients(doctor_id: str) -> list[PatientListItem]: async def list_patients(doctor_id: str) -> list[PatientListItem]:
"""Doctor's patients sorted red → yellow → grey → green, then by discontinuation risk.""" """Doctor's patients sorted red → yellow → grey → green, then by discontinuation risk."""
order = {"red": 0, "yellow": 1, "grey": 2, "green": 3} now = clock.now()
return sorted(fixtures.PATIENTS, key=lambda p: (order[p.status_color], -p.discontinuation_risk.score)) async with get_clinical_session() as s:
profiles = await repo.patients_of_doctor(s, doctor_id)
data = [await views.load_patient_data(s, p) for p in profiles]
await audit(s, actor=doctor_id, action="view_patient_list", token=None, details={"count": len(data)}, ts=now)
patients = await _patients([p.token for p in profiles])
items = [views.list_item(d, patients.get(d.profile.token), now) for d in data]
return sorted(items, key=views.sort_key)
@router.get("/patients/{token}", response_model=PatientCard, tags=["doctor"]) @router.get("/patients/{token}", response_model=PatientCard, tags=["doctor"])
async def patient_card(token: str, doctor_id: str) -> PatientCard: async def patient_card(token: str, doctor_id: str) -> PatientCard:
"""Patient card. Every call is written to audit_log as `view_patient` (#12).""" """Patient card. Every call is written to audit_log as `view_patient`; only the attending doctor may read it."""
card = fixtures.card(token) now = clock.now()
if card is None: patient = (await _patients([token])).get(token)
async with get_clinical_session() as s:
profile = await repo.get_profile(s, token)
if profile is None:
raise HTTPException(404, "patient not found") raise HTTPException(404, "patient not found")
if profile.doctor_id != doctor_id:
await audit(s, actor=doctor_id, action="view_patient_denied", token=token, ts=now)
await s.commit()
raise HTTPException(403, "not your patient")
data = await views.load_patient_data(s, profile)
card = await views.build_card(s, data, patient, now)
await audit(s, actor=doctor_id, action="view_patient", token=token, ts=now)
# Summary after the commit: the (possibly slow) LLM call must not hold the DB transaction.
from app.llm.summary import build_summary # summary imports app.api.schemas; avoid an import cycle
card.summary = await build_summary(card, today=now.date())
return card return card
@router.get("/doctors/{doctor_id}/alerts", response_model=list[AlertOut], tags=["doctor"]) @router.get("/doctors/{doctor_id}/alerts", response_model=list[AlertOut], tags=["doctor"])
async def list_alerts(doctor_id: str, since_id: int = -1, open_only: bool = False) -> list[AlertOut]: async def list_alerts(doctor_id: str, since_id: int = -1, open_only: bool = False) -> list[AlertOut]:
"""Polled every 2–3 s by the dashboard: alerts with id > since_id, newest first.""" """Polled every 2–3 s by the dashboard: alerts with id > since_id, newest first."""
alerts = [a for a in fixtures.all_alerts() if a.id > since_id] async with get_clinical_session() as s:
return [a for a in alerts if a.acked_at is None] if open_only else alerts tokens = list(await s.scalars(select(PatientProfile.token).where(PatientProfile.doctor_id == doctor_id)))
stmt = select(Alert).where(Alert.token.in_(tokens), Alert.id > since_id)
if open_only:
stmt = stmt.where(Alert.acked_at.is_(None))
alerts = (await s.scalars(stmt.order_by(Alert.ts.desc(), Alert.id.desc()))).all()
if alerts:
await audit(
s,
actor=doctor_id,
action="view_alerts",
token=None,
details={"alert_ids": [a.id for a in alerts][:50], "count": len(alerts)},
ts=clock.now(),
)
patients = await _patients(sorted({a.token for a in alerts}))
return [views.alert_out(a, _name(patients, a.token)) for a in alerts]
@router.post("/alerts/{alert_id}/ack", response_model=AlertOut, tags=["doctor"]) @router.post("/alerts/{alert_id}/ack", response_model=AlertOut, tags=["doctor"])
async def ack_alert(alert_id: int, body: AckIn) -> AlertOut: async def ack_alert(alert_id: int, body: AckIn) -> AlertOut:
"""Doctor acknowledges an alert (written to audit_log). Acking an acute alert lets the patient leave crisis.""" """Doctor acknowledges an alert (written to audit_log). Acking an acute alert lets the patient leave crisis."""
alert = next((a for a in fixtures.all_alerts() if a.id == alert_id), None) now = clock.now()
async with get_clinical_session() as s:
alert = await s.get(Alert, alert_id)
if alert is None: if alert is None:
raise HTTPException(404, "alert not found") raise HTTPException(404, "alert not found")
return alert.model_copy(update={"acked_by": body.doctor_id, "acked_at": clock.now()}) profile = await repo.get_profile(s, alert.token)
if profile is None or profile.doctor_id != body.doctor_id:
raise HTTPException(403, "not your patient")
await repo.ack_alert(s, alert_id=alert_id, doctor_id=body.doctor_id, ts=now)
await refresh_status(s, alert.token)
open_acute = [a for a in await repo.list_alerts(s, token=alert.token, open_only=True) if a.level == "acute"]
out = views.alert_out(alert, "")
# The crisis ends only when no acute alert is left open (another acute signal keeps the patient in crisis).
if alert.level == "acute" and not open_acute:
replies = await get_engine().handle(alert.token, Event(type="system", system="doctor_ack"))
if replies:
await integrations.outbox.push(alert.token, replies)
return out.model_copy(update={"patient_name": _name(await _patients([alert.token]), alert.token)})
@router.post("/patients/connect", response_model=ConnectPatientOut, tags=["doctor"]) @router.post("/patients/connect", response_model=ConnectPatientOut, tags=["doctor"])
async def connect_patient(body: ConnectPatientIn) -> ConnectPatientOut: async def connect_patient(body: ConnectPatientIn) -> ConnectPatientOut:
"""Doctor connects a patient after the in-person visit (323-FZ art. 36.2): prescription → МИС mock, code/QR.""" """Doctor connects a patient after the in-person visit (323-FZ art. 36.2): prescription → МИС mock, code/QR."""
return ConnectPatientOut(token="tok-new", code="DEMO-0001", deep_link="https://t.me/ptsr_demo_bot?start=DEMO-0001") now = clock.now()
try:
token = pseudo_id(body.snils)
except ValueError as exc:
raise HTTPException(422, "SNILS must contain 11 digits") from exc
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LEN))
async with get_identity_session() as ids:
if await identity_repo.get_patient(ids, token) is not None:
raise HTTPException(409, "patient already connected")
doctor = await identity_repo.get_doctor(ids, body.doctor_id)
await identity_repo.create_patient(
ids,
token=token,
display_name=body.display_name,
snils=body.snils,
clinic_id=doctor.clinic_id if doctor else DEFAULT_CLINIC,
doctor_id=body.doctor_id,
now=now,
birth_year=body.birth_year,
sex=body.sex,
)
await identity_repo.create_connect_code(ids, code=code, token=token, doctor_id=body.doctor_id, now=now)
# Clinical writes nested inside the identity unit of work: if they fail, the identity row rolls back too.
async with get_clinical_session() as s:
await repo.upsert_profile(
s,
token=token,
doctor_id=body.doctor_id,
modules=list(body.modules),
therapy_start=now.date(),
next_visit=body.next_visit,
birth_year=body.birth_year,
sex=body.sex,
)
schedule: dict[str, list[str]] = {"times": list(body.times)}
if body.instructions:
schedule["instructions"] = [body.instructions]
s.add(
PrescriptionRow(token=token, drug=body.drug, dose=body.dose, schedule=schedule, start_date=now.date())
)
await audit(
s,
actor=body.doctor_id,
action="connect_patient",
token=token,
details={"modules": list(body.modules), "drug": body.drug},
ts=now,
)
return ConnectPatientOut(token=token, code=code, deep_link=await console.deep_link(code))
# ------------------------------------------------------------------ patient dialog (web console / any channel) # ------------------------------------------------------------------ patient dialog (web console / any channel)
async def _require_patient(token: str) -> None:
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
raise HTTPException(404, "patient not found")
@router.post("/dialog/{token}/events", response_model=DialogOut, tags=["dialog"]) @router.post("/dialog/{token}/events", response_model=DialogOut, tags=["dialog"])
async def dialog_event(token: str, body: DialogIn) -> DialogOut: async def dialog_event(token: str, body: DialogIn) -> DialogOut:
await _require_patient(token)
return DialogOut(replies=await get_engine().handle(token, body)) return DialogOut(replies=await get_engine().handle(token, body))
@router.get("/dialog/{token}/outbox", response_model=DialogOut, tags=["dialog"]) @router.get("/dialog/{token}/outbox", response_model=DialogOut, tags=["dialog"])
async def dialog_outbox(token: str) -> DialogOut: async def dialog_outbox(token: str) -> DialogOut:
"""System-initiated replies (reminders, check-ins) waiting for the web console.""" """System-initiated replies (reminders, check-ins) waiting for the web console."""
box = integrations.outbox box = console.memory_outbox()
return DialogOut(replies=box.drain(token) if isinstance(box, MemoryOutbox) else []) return DialogOut(replies=box.drain(token) if box is not None else [])
@router.get("/dialog/{token}/history", response_model=list[MessageOut], tags=["dialog"]) @router.get("/dialog/{token}/history", response_model=list[MessageOut], tags=["dialog"])
async def dialog_history(token: str, limit: int = 100) -> list[MessageOut]: async def dialog_history(token: str, limit: int = 100) -> list[MessageOut]:
return [] """Stored dialog (texts are scrubbed before storage, ADR-003), oldest first, the last `limit` messages."""
async with get_clinical_session() as s:
stmt = select(Message).where(Message.token == token).order_by(Message.ts.desc(), Message.id.desc())
rows = (await s.scalars(stmt.limit(max(1, min(limit, 1000))))).all()
return [MessageOut(ts=m.ts, direction=m.direction, text=m.text_scrubbed) for m in reversed(rows)] # type: ignore[arg-type]
# ------------------------------------------------------------------ demo controls (ADR-005) # ------------------------------------------------------------------ demo controls (ADR-005)
@@ -108,14 +251,36 @@ async def advance_clock(body: ClockAdvanceIn) -> ClockOut:
@router.post("/demo/reset", tags=["demo"]) @router.post("/demo/reset", tags=["demo"])
async def demo_reset() -> dict[str, str]: async def demo_reset() -> dict[str, str]:
"""Clock back to the wall clock, console outbox emptied, demo data re-seeded by `scripts.seed_demo` (#18)."""
clock.reset() clock.reset()
return {"status": "ok"} seeded = await console.reset_demo()
return {"status": "ok", "seed": "reset" if seeded else "skipped"}
@router.get("/demo/personas", response_model=list[DemoPersona], tags=["demo"]) @router.get("/demo/personas", response_model=list[DemoPersona], tags=["demo"])
async def demo_personas() -> list[DemoPersona]: async def demo_personas() -> list[DemoPersona]:
return [ """Patients with a scripted scenario `data/synthetic/<slug>.jsonl`; the demo cabinet's doctor first."""
scenarios = console.scenario_files()
if not scenarios:
return []
async with get_identity_session() as ids:
patients = (await ids.scalars(select(Patient))).all()
scripted = [(p, scenarios[slug]) for p in patients if (slug := console.persona_slug(p.display_name)) in scenarios]
if not scripted:
return []
today = clock.today()
async with get_clinical_session() as s:
profiles = {p.token: await repo.get_profile(s, p.token) for p, _ in scripted}
out = []
for p, path in sorted(scripted, key=lambda x: (x[0].doctor_id != fixtures.DOCTOR_ID, x[0].display_name)):
profile = profiles.get(p.token)
start = profile.therapy_start if profile else None
out.append(
DemoPersona( DemoPersona(
token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl" token=p.token,
display_name=p.display_name,
therapy_day=(today - start).days if start else 0,
scenario=path,
) )
] )
return out
+157
View File
@@ -0,0 +1,157 @@
"""API #12: dialog endpoints (events, outbox, history) and demo controls (clock, reset, personas)."""
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from pathlib import Path
import httpx
import pytest
from app.api import demo
from app.config import Settings
from app.core import clock
from app.core.types import ScrubbedText
from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db
from app.dialog.contracts import Reply
from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations
from app.main import app
MEM = "sqlite+aiosqlite:///:memory:"
TOKEN = "a" * 32
DAY0 = datetime(2026, 10, 5, 9, 0)
@pytest.fixture
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
monkeypatch.setattr(integrations, "notifier", MemoryNotifier())
monkeypatch.setattr(integrations, "outbox", MemoryOutbox())
clock.set_now(DAY0)
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with session.get_identity_session() as ids:
await identity_repo.create_patient(
ids,
token=TOKEN,
display_name="Андрей С.",
snils="11122233344",
phone="+79990000001",
clinic_id="c",
doctor_id="doc-smirnova",
now=clock.now(),
birth_year=1992,
)
async with session.get_clinical_session() as s:
await repo.upsert_profile(
s,
token=TOKEN,
doctor_id="doc-smirnova",
modules=["veteran"],
therapy_start=clock.today() - timedelta(days=13),
)
await repo.set_dialog_state(
s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=clock.now()
)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
yield c
clock.register_persistence(None, None)
clock.reset()
await session.configure()
async def test_events_reach_engine_and_unknown_patient_is_404(client: httpx.AsyncClient) -> None:
r = await client.post(f"/api/v1/dialog/{TOKEN}/events", json={"type": "text", "text": "Привет"})
assert r.status_code == 200 and r.json()["replies"]
r = await client.post("/api/v1/dialog/" + "f" * 32 + "/events", json={"type": "text", "text": "x"})
assert r.status_code == 404
async def test_outbox_drains_once(client: httpx.AsyncClient) -> None:
await integrations.outbox.push(TOKEN, [Reply(text="Пора принять лекарство")])
first = (await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"]
assert [x["text"] for x in first] == ["Пора принять лекарство"]
assert (await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"] == []
async def test_outbox_found_behind_wrapping_outbox(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
inner = MemoryOutbox()
class Wrapper: # like TelegramOutbox: unbound chats fall back to the console queue
fallback = inner
async def push(self, token: str, replies: list[Reply]) -> None:
await inner.push(token, replies)
monkeypatch.setattr(integrations, "outbox", Wrapper())
await inner.push(TOKEN, [Reply(text="x")])
assert len((await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"]) == 1
async def test_history_oldest_first_with_limit(client: httpx.AsyncClient) -> None:
async with session.get_clinical_session() as s:
for i in range(5):
await repo.add_message(
s,
token=TOKEN,
direction="in" if i % 2 == 0 else "out",
text=ScrubbedText(f"m{i}"),
ts=clock.now() + timedelta(minutes=i),
)
rows = (await client.get(f"/api/v1/dialog/{TOKEN}/history", params={"limit": 3})).json()
assert [m["text"] for m in rows] == ["m2", "m3", "m4"]
assert [m["direction"] for m in rows] == ["in", "out", "in"]
async def test_history_stores_scrubbed_text(client: httpx.AsyncClient) -> None:
await client.post(f"/api/v1/dialog/{TOKEN}/events", json={"type": "text", "text": "мой телефон +7 999 123-45-67"})
texts = " ".join(m["text"] for m in (await client.get(f"/api/v1/dialog/{TOKEN}/history")).json())
assert "123-45-67" not in texts and "999" not in texts
async def test_clock_advance_and_reset(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(demo, "SEED_MODULE", "scripts.no_such_seed_module")
before = (await client.get("/api/v1/demo/clock")).json()
r = (await client.post("/api/v1/demo/clock/advance", json={"hours": 2})).json()
assert r["virtual"] is True and r["offset_days"] == pytest.approx(before["offset_days"] + 2 / 24, abs=1e-3)
await integrations.outbox.push(TOKEN, [Reply(text="stale")])
assert (await client.post("/api/v1/demo/reset")).json() == {"status": "ok", "seed": "skipped"}
assert (await client.get("/api/v1/demo/clock")).json()["offset_days"] == pytest.approx(0, abs=1e-6)
assert (await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"] == []
async def test_reset_calls_seed_module(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
import sys
import types
calls: list[str] = []
fake = types.ModuleType("fake_seed_demo")
async def reset() -> None:
calls.append("reset")
fake.reset = reset # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "fake_seed_demo", fake)
monkeypatch.setattr(demo, "SEED_MODULE", "fake_seed_demo")
monkeypatch.setattr(demo.util, "find_spec", lambda name: object())
assert (await client.post("/api/v1/demo/reset")).json()["seed"] == "reset"
assert calls == ["reset"]
async def test_personas_from_scenario_files(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(demo, "get_settings", lambda: Settings(_env_file=None, data_dir=tmp_path))
assert (await client.get("/api/v1/demo/personas")).json() == []
(tmp_path / "synthetic").mkdir()
(tmp_path / "synthetic" / "andrey.jsonl").write_text('{"day": 0, "actions": []}\n', encoding="utf-8")
personas = (await client.get("/api/v1/demo/personas")).json()
assert personas == [
{"token": TOKEN, "display_name": "Андрей С.", "therapy_day": 13, "scenario": "data/synthetic/andrey.jsonl"}
]
def test_persona_slug() -> None:
assert demo.persona_slug("Андрей С.") == "andrey"
assert demo.persona_slug("Юлия Щ.") == "yuliya"
assert demo.persona_slug("") == ""
+288
View File
@@ -0,0 +1,288 @@
"""API #12: doctor dashboard on real DB data — list, card + audit, 403, alerts, ack → crisis ends, connect, no PII."""
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from types import SimpleNamespace
import httpx
import pytest
from sqlalchemy import select
from app.api import doctor_views
from app.config import Settings
from app.core import clock
from app.core.privacy import pseudo_id
from app.core.types import AlertLevel
from app.db import identity_repo, repo, session
from app.db.audit import AuditLog
from app.db.bootstrap import init_db
from app.db.clinical import Dose, PatientProfile, Prescription, SafetyPlan
from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations
from app.main import app
MEM = "sqlite+aiosqlite:///:memory:"
DOCTOR = "doc-smirnova"
OTHER = "doc-other"
DAY0 = datetime(2026, 10, 5, 9, 0)
# Synthetic patients: token suffix → (name, snils, phone, doctor)
ANDREY, DMITRY, OLGA, PAVEL, IRINA, STRANGER = (c * 32 for c in "abcdef")
PEOPLE = {
ANDREY: ("Андрей С.", "11122233344", "+79990000001", DOCTOR),
DMITRY: ("Дмитрий В.", "22233344455", "+79990000002", DOCTOR),
OLGA: ("Ольга К.", "33344455566", "+79990000003", DOCTOR),
PAVEL: ("Павел Н.", "44455566677", "+79990000004", DOCTOR),
IRINA: ("Ирина М.", "55566677788", "+79990000005", DOCTOR),
STRANGER: ("Чужой П.", "66677788899", "+79990000006", OTHER),
}
@pytest.fixture
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
monkeypatch.setattr(integrations, "notifier", MemoryNotifier())
monkeypatch.setattr(integrations, "outbox", MemoryOutbox())
clock.set_now(DAY0 + timedelta(days=13))
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
await seed()
# No lifespan: the scheduler and the Telegram bot are not started.
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
yield c
clock.register_persistence(None, None)
clock.reset()
await session.configure()
async def seed() -> None:
now = clock.now()
start = (DAY0).date()
async with session.get_identity_session() as ids:
for token, (name, snils, phone, doctor) in PEOPLE.items():
await identity_repo.create_patient(
ids,
token=token,
display_name=name,
snils=snils,
phone=phone,
clinic_id="clinic-1",
doctor_id=doctor,
now=now,
birth_year=1990,
sex="m",
)
async with session.get_clinical_session() as s:
for token, (_, _, _, doctor) in PEOPLE.items():
await repo.upsert_profile(
s, token=token, doctor_id=doctor, modules=["veteran"], therapy_start=start, birth_year=1990, sex="m"
)
s.add(Prescription(token=token, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}))
await s.flush()
rx = {r.token: r.id for r in (await s.scalars(select(Prescription))).all()}
# Andrey: acute alert (red), worsening PHQ-9, unscheduled PHQ-9, low mood streak, alcohol, a safety plan.
await repo.add_alert(s, token=ANDREY, level=AlertLevel.ACUTE, code="phq9_item9", reason="PHQ-9 п.9", ts=now)
await repo.add_scale_result(
s, token=ANDREY, scale="phq9", total=12, severity="moderate", complete=True, ts=now - timedelta(days=7)
)
await repo.add_scale_result(
s,
token=ANDREY,
scale="phq9",
total=18,
severity="mod_severe",
complete=True,
ts=now - timedelta(hours=2),
scheduled=False,
item9=2,
)
await repo.add_scale_result(
s, token=ANDREY, scale="pcl5", total=44, severity="-", complete=True, ts=now - timedelta(days=13)
)
for i, (mood, sleep, alcohol) in enumerate([(3, "<4", "more"), (2, "4-6", "1-2"), (2, "<4", "none")]):
await repo.upsert_checkin(
s,
token=ANDREY,
day=now.date() - timedelta(days=2 - i),
now=now,
mood=mood,
sleep_bucket=sleep,
alcohol_bucket=alcohol,
med_taken=True,
)
s.add(
SafetyPlan(
token=ANDREY, steps=["Позвонить брату", {"who": "Кризисная линия", "phone": "8-800"}], updated_at=now
)
)
# Dmitry: high alert (yellow). Olga: info only (grey).
await repo.add_alert(s, token=DMITRY, level=AlertLevel.HIGH, code="pcl5_high", reason="PCL-5 ≥ 50", ts=now)
await repo.add_alert(s, token=OLGA, level=AlertLevel.INFO, code="no_contact_3d", reason="Молчит 3 дня", ts=now)
# Pavel and Irina: green; Pavel missed doses + voiced quit intent → higher risk, sorted first among greens.
for d in range(7):
planned = now - timedelta(days=d, hours=1)
s.add(
Dose(token=PAVEL, prescription_id=rx[PAVEL], planned_at=planned, status="missed" if d < 3 else "taken")
)
s.add(Dose(token=IRINA, prescription_id=rx[IRINA], planned_at=planned, status="taken"))
await repo.upsert_checkin(s, token=PAVEL, day=now.date(), now=now, quit_intent=True, mood=5)
# Foreign doctor's patient with an open alert: must never show up for DOCTOR.
await repo.add_alert(s, token=STRANGER, level=AlertLevel.ACUTE, code="cssrs_high", reason="C-SSRS", ts=now)
await repo.set_dialog_state(
s,
token=ANDREY,
state="crisis",
data={"consent": True, "adult": True, "crisis": {"open": True, "opened_at": now.isoformat()}},
now=now,
)
async def audit_rows(action: str) -> list[AuditLog]:
async with session.get_clinical_session() as s:
return list((await s.scalars(select(AuditLog).where(AuditLog.action == action))).all())
async def test_list_order_colours_and_metrics(client: httpx.AsyncClient) -> None:
rows = (await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).json()
assert [r["token"] for r in rows] == [ANDREY, DMITRY, OLGA, PAVEL, IRINA]
assert [r["status_color"] for r in rows] == ["red", "yellow", "grey", "green", "green"]
andrey, pavel, irina = rows[0], rows[3], rows[4]
assert andrey["display_name"] == "Андрей С." and andrey["age"] == 36
assert andrey["top_alert_level"] == "acute" and andrey["open_alerts"] == 1
assert (andrey["phq9_last"], andrey["pcl5_last"], andrey["trend"]) == (18, 44, "up")
assert andrey["drug"] == "сертралин 50 мг" and andrey["therapy_day"] == 13
assert andrey["last_event"] == "PHQ-9 п.9" # the alert is newer than the last scale
assert rows[1]["last_event"] == "PCL-5 ≥ 50"
factors = " ".join(andrey["discontinuation_risk"]["factors"])
assert "настроение ≤ 3 3 дн. подряд" in factors and "алкоголь 2 дн. из 7" in factors
assert pavel["adherence_7d"] == pytest.approx(4 / 7, abs=0.01)
assert irina["adherence_7d"] == 1.0 and irina["discontinuation_risk"]["score"] == 0
assert pavel["discontinuation_risk"]["score"] == 30 + 25 # 3 missed days + quit intent
assert pavel["discontinuation_risk"]["level"] == "medium"
assert len(await audit_rows("view_patient_list")) == 1
async def test_card_writes_audit_and_has_series(client: httpx.AsyncClient) -> None:
r = await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": DOCTOR})
assert r.status_code == 200
card = r.json()
metrics = {(p["metric"], p["value"], p["scheduled"]) for p in card["series"]}
assert ("phq9", 18.0, False) in metrics and ("phq9", 12.0, True) in metrics
assert ("sleep_hours", 3.0, True) in metrics and ("sleep_hours", 5.0, True) in metrics
assert ("alcohol", 2.0, True) in metrics and ("mood", 2.0, True) in metrics
assert card["safety_plan"] == ["Позвонить брату", "Кризисная линия: 8-800"]
assert card["alerts"][0]["patient_name"] == "Андрей С." and card["alerts"][0]["color"] == "red"
assert card["prescriptions"][0]["schedule"] == {"times": ["08:00"]}
assert card["summary"]
rows = await audit_rows("view_patient")
assert len(rows) == 1 and rows[0].actor == DOCTOR and rows[0].token == ANDREY
async def test_card_forbidden_for_foreign_doctor(client: httpx.AsyncClient) -> None:
r = await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": OTHER})
assert r.status_code == 403
assert await audit_rows("view_patient") == []
assert len(await audit_rows("view_patient_denied")) == 1
assert (await client.get("/api/v1/patients/" + "0" * 32, params={"doctor_id": DOCTOR})).status_code == 404
async def test_alerts_feed_since_id_and_open_only(client: httpx.AsyncClient) -> None:
alerts = (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts")).json()
assert {a["token"] for a in alerts} == {ANDREY, DMITRY, OLGA} # no foreign patient
assert all(a["patient_name"] for a in alerts)
top = max(a["id"] for a in alerts)
assert (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts", params={"since_id": top})).json() == []
assert len(await audit_rows("view_alerts")) == 1 # empty poll is not a disclosure
async def test_ack_acute_ends_crisis(client: httpx.AsyncClient) -> None:
alerts = (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts", params={"open_only": True})).json()
acute = next(a for a in alerts if a["token"] == ANDREY)
assert (await client.post(f"/api/v1/alerts/{acute['id']}/ack", json={"doctor_id": OTHER})).status_code == 403
r = await client.post(f"/api/v1/alerts/{acute['id']}/ack", json={"doctor_id": DOCTOR})
assert r.status_code == 200
out = r.json()
assert out["acked_by"] == DOCTOR and out["acked_at"] and out["patient_name"] == "Андрей С."
async with session.get_clinical_session() as s:
state = await repo.get_dialog_state(s, ANDREY)
profile = await s.get(PatientProfile, ANDREY)
assert state is not None and state.state == "idle" and state.data["crisis"]["open"] is False
assert profile is not None and profile.status_color == "green"
assert isinstance(integrations.outbox, MemoryOutbox)
assert integrations.outbox.drain(ANDREY) # "crisis closed" message waits for the console
assert len(await audit_rows("ack_alert")) == 1
rows = (await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).json()
assert next(x for x in rows if x["token"] == ANDREY)["status_color"] == "green"
async def test_connect_creates_patient_and_redeemable_code(client: httpx.AsyncClient) -> None:
body = {
"doctor_id": DOCTOR,
"display_name": "Сергей Т.",
"birth_year": 1988,
"sex": "m",
"snils": "123-456-789 01",
"drug": "сертралин",
"dose": "50 мг",
"times": ["08:00", "20:00"],
"instructions": "после еды",
"modules": ["veteran", "depression"],
}
r = await client.post("/api/v1/patients/connect", json=body)
assert r.status_code == 200
out = r.json()
assert out["token"] == pseudo_id("12345678901")
assert out["deep_link"] == f"/console?code={out['code']}"
assert "12345678901" not in r.text and "123-456-789" not in r.text
async with session.get_identity_session() as ids:
assert await identity_repo.redeem_connect_code(ids, code=out["code"], now=clock.now()) == out["token"]
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, out["token"])
rx = await s.scalar(select(Prescription).where(Prescription.token == out["token"]))
assert profile is not None and profile.therapy_start == clock.today() and profile.doctor_id == DOCTOR
assert rx is not None and rx.schedule["times"] == ["08:00", "20:00"]
assert len(await audit_rows("connect_patient")) == 1
card = (await client.get(f"/api/v1/patients/{out['token']}", params={"doctor_id": DOCTOR})).json()
assert card["prescriptions"][0]["instructions"] == "после еды"
assert card["patient"]["therapy_day"] == 0 and card["patient"]["status_color"] == "green"
assert (await client.post("/api/v1/patients/connect", json=body)).status_code == 409
async def test_no_pii_in_doctor_responses(client: httpx.AsyncClient) -> None:
texts = [
(await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).text,
(await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": DOCTOR})).text,
(await client.get(f"/api/v1/doctors/{DOCTOR}/alerts")).text,
]
for _, snils, phone, _ in PEOPLE.values():
for text in texts:
assert snils not in text and phone not in text and phone[1:] not in text
def test_risk_index_all_factors_capped() -> None:
now = datetime(2026, 10, 18, 12, 0).astimezone()
days = [now.date() - timedelta(days=i) for i in range(4)]
checkins = [
SimpleNamespace(
day=d, mood=2, med_taken=False, side_effects=["nausea"], quit_intent=True, alcohol_bucket="more"
)
for d in days
]
data = doctor_views.PatientData(
profile=SimpleNamespace(therapy_start=days[-1] - timedelta(days=10)), # type: ignore[arg-type]
checkins=checkins, # type: ignore[arg-type]
results=[],
doses=[],
prescriptions=[],
alerts=[SimpleNamespace(code="quit_intent", acked_at=None)], # type: ignore[list-item]
)
risk = doctor_views.discontinuation_risk(data, now)
assert risk.score == 100 and risk.level == "high"
assert risk.factors == [
"пропуски приёма 4/7 дн.",
"побочные эффекты 4 дн. подряд",
"хотел бросить (день 13)",
"не разобран сигнал «хочет бросить»",
"настроение ≤ 3 4 дн. подряд",
"алкоголь 4 дн. из 7",
]
+5 -6
View File
@@ -21,11 +21,10 @@ async def test_stubs_are_usable() -> None:
await session.configure() await session.configure()
def test_api_fixture_routes() -> None: def test_api_fixtures_and_clock() -> None:
# Routes read the DB since #12 (covered by tests/test_api_*.py); fixtures stay as schema-valid reference data.
from app.api import fixtures
assert fixtures.card("tok-andrey") is not None and fixtures.PATIENTS[0].status_color == "red"
with TestClient(app) as c: with TestClient(app) as c:
patients = c.get("/api/v1/doctors/doc-smirnova/patients").json()
assert patients[0]["status_color"] == "red"
card = c.get("/api/v1/patients/tok-andrey", params={"doctor_id": "doc-smirnova"}).json()
assert card["safety_plan"] and card["series"]
assert c.post("/api/v1/dialog/tok-andrey/events", json={"type": "text", "text": "ok"}).status_code == 200
assert c.get("/api/v1/demo/clock").json()["virtual"] is True assert c.get("/api/v1/demo/clock").json()["virtual"] is True