feat(backend): API этапа 1 на реальных данных (#12) #66
@@ -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
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
+192
-27
@@ -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)
|
||||||
raise HTTPException(404, "patient not found")
|
async with get_clinical_session() as s:
|
||||||
|
profile = await repo.get_profile(s, token)
|
||||||
|
if profile is None:
|
||||||
|
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()
|
||||||
if alert is None:
|
async with get_clinical_session() as s:
|
||||||
raise HTTPException(404, "alert not found")
|
alert = await s.get(Alert, alert_id)
|
||||||
return alert.model_copy(update={"acked_by": body.doctor_id, "acked_at": clock.now()})
|
if alert is None:
|
||||||
|
raise HTTPException(404, "alert not found")
|
||||||
|
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."""
|
||||||
DemoPersona(
|
scenarios = console.scenario_files()
|
||||||
token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"
|
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(
|
||||||
|
token=p.token,
|
||||||
|
display_name=p.display_name,
|
||||||
|
therapy_day=(today - start).days if start else 0,
|
||||||
|
scenario=path,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
]
|
return out
|
||||||
|
|||||||
@@ -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("") == ""
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user