From 25728c4b314df72ed723977cb428286de1ec5652 Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 12:34:29 +0300 Subject: [PATCH] feat(backend): API stage 1 on real data instead of fixtures Doctor list/card/alerts/ack/connect, dialog events/outbox/history and demo controls now read the identity and clinical DBs. Rule-based discontinuation risk index, trend, adherence; every doctor read is audited. Refs #12 Co-Authored-By: Claude Opus 5.5 --- app/api/demo.py | 89 ++++++++++ app/api/doctor_views.py | 368 +++++++++++++++++++++++++++++++++++++++ app/api/routes.py | 219 ++++++++++++++++++++--- tests/test_api_dialog.py | 157 +++++++++++++++++ tests/test_api_doctor.py | 288 ++++++++++++++++++++++++++++++ tests/test_contracts.py | 11 +- 6 files changed, 1099 insertions(+), 33 deletions(-) create mode 100644 app/api/demo.py create mode 100644 app/api/doctor_views.py create mode 100644 tests/test_api_dialog.py create mode 100644 tests/test_api_doctor.py diff --git a/app/api/demo.py b/app/api/demo.py new file mode 100644 index 0000000..535e7cd --- /dev/null +++ b/app/api/demo.py @@ -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/.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 diff --git a/app/api/doctor_views.py b/app/api/doctor_views.py new file mode 100644 index 0000000..8d73a0a --- /dev/null +++ b/app/api/doctor_views.py @@ -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), + ) diff --git a/app/api/routes.py b/app/api/routes.py index 3c6d4e2..5f70926 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -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. -Lane B replaces bodies in #12 without changing paths or schemas (those are the frozen contract). +Paths and schemas are the frozen contract (`app/api/schemas.py`); bodies read the identity and clinical DBs. +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.questionnaire import router as questionnaire_router from app.api.schemas import ( @@ -23,69 +32,203 @@ from app.api.schemas import ( PatientListItem, ) from app.core import clock -from app.dialog.contracts import get_engine -from app.integrations.base import MemoryOutbox, integrations +from app.core.privacy import pseudo_id +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.services.monitoring import refresh_status router = APIRouter(prefix="/api/v1") 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 @router.get("/doctors/{doctor_id}/patients", response_model=list[PatientListItem], tags=["doctor"]) async def list_patients(doctor_id: str) -> list[PatientListItem]: """Doctor's patients sorted red → yellow → grey → green, then by discontinuation risk.""" - order = {"red": 0, "yellow": 1, "grey": 2, "green": 3} - return sorted(fixtures.PATIENTS, key=lambda p: (order[p.status_color], -p.discontinuation_risk.score)) + now = clock.now() + 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"]) async def patient_card(token: str, doctor_id: str) -> PatientCard: - """Patient card. Every call is written to audit_log as `view_patient` (#12).""" - card = fixtures.card(token) - if card is None: - raise HTTPException(404, "patient not found") + """Patient card. Every call is written to audit_log as `view_patient`; only the attending doctor may read it.""" + now = clock.now() + 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") + 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 @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]: """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] - return [a for a in alerts if a.acked_at is None] if open_only else alerts + async with get_clinical_session() as s: + 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"]) 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.""" - alert = next((a for a in fixtures.all_alerts() if a.id == alert_id), None) - if alert is None: - raise HTTPException(404, "alert not found") - return alert.model_copy(update={"acked_by": body.doctor_id, "acked_at": clock.now()}) + now = clock.now() + async with get_clinical_session() as s: + alert = await s.get(Alert, alert_id) + 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"]) 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.""" - 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) +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"]) async def dialog_event(token: str, body: DialogIn) -> DialogOut: + await _require_patient(token) return DialogOut(replies=await get_engine().handle(token, body)) @router.get("/dialog/{token}/outbox", response_model=DialogOut, tags=["dialog"]) async def dialog_outbox(token: str) -> DialogOut: """System-initiated replies (reminders, check-ins) waiting for the web console.""" - box = integrations.outbox - return DialogOut(replies=box.drain(token) if isinstance(box, MemoryOutbox) else []) + box = console.memory_outbox() + return DialogOut(replies=box.drain(token) if box is not None else []) @router.get("/dialog/{token}/history", response_model=list[MessageOut], tags=["dialog"]) 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) @@ -108,14 +251,36 @@ async def advance_clock(body: ClockAdvanceIn) -> ClockOut: @router.post("/demo/reset", tags=["demo"]) 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() - 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"]) async def demo_personas() -> list[DemoPersona]: - return [ - DemoPersona( - token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl" + """Patients with a scripted scenario `data/synthetic/.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( + token=p.token, + display_name=p.display_name, + therapy_day=(today - start).days if start else 0, + scenario=path, + ) ) - ] + return out diff --git a/tests/test_api_dialog.py b/tests/test_api_dialog.py new file mode 100644 index 0000000..328e2d4 --- /dev/null +++ b/tests/test_api_dialog.py @@ -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("") == "" diff --git a/tests/test_api_doctor.py b/tests/test_api_doctor.py new file mode 100644 index 0000000..d86e445 --- /dev/null +++ b/tests/test_api_doctor.py @@ -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", + ] diff --git a/tests/test_contracts.py b/tests/test_contracts.py index cae58c3..8dd8220 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -21,11 +21,10 @@ async def test_stubs_are_usable() -> None: 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: - 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