"""API routes `/api/v1` (#34 contract → #12 implementation). 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). """ import asyncio import json import logging import secrets from typing import Any from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from sqlalchemy import select from app.api import demo as console from app.api import doctor_views as views from app.api import fixtures, max_notifications from app.api.patient_web import router as patient_web_router from app.api.questionnaire import router as questionnaire_router from app.api.schemas import ( AckIn, AlertOut, ClockAdvanceIn, ClockOut, ConnectPatientIn, ConnectPatientOut, DemoPersona, DialogIn, DialogOut, MessageOut, PatientCard, PatientListItem, ) from app.core import clock 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.llm.agent import stream as agent_stream from app.scheduler import jobs as scheduler from app.services.anonymous_care import display_name as anonymous_name from app.services.monitoring import refresh_status log = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1") router.include_router(max_notifications.router) router.include_router(questionnaire_router) router.include_router(patient_web_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 anonymous_name(token) # ------------------------------------------------------------------ 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.""" 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`; 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.""" 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.""" 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.""" 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.post("/dialog/{token}/events/stream", tags=["dialog"]) async def dialog_event_stream(token: str, body: DialogIn) -> StreamingResponse: """Same as /events, as Server-Sent Events: `status` (agent steps), `delta` (answer as it is written), `reset`, then `done` with the final replies (identical to /events). `error` if the engine failed.""" await _require_patient(token) queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue() marker = agent_stream.STREAM.set(queue) # copied into the task's context below try: async def run() -> None: try: replies = await get_engine().handle(token, body) queue.put_nowait(("done", [r.model_dump() for r in replies])) except Exception: # noqa: BLE001 — reported to the client, which falls back to /events log.exception("dialog stream failed") queue.put_nowait(("error", None)) task = asyncio.create_task(run()) finally: agent_stream.STREAM.reset(marker) async def events(): try: while True: try: kind, payload = await asyncio.wait_for(queue.get(), timeout=15) except TimeoutError: yield ": keepalive\n\n" continue yield f"event: {kind}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" if kind in ("done", "error"): return finally: if not task.done(): await task return StreamingResponse( events(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, # nginx: do not buffer SSE ) @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 = 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]: """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) def _clock_out() -> ClockOut: return ClockOut(now=clock.now(), virtual=clock._virtual(), offset_days=clock.offset().total_seconds() / 86400) @router.get("/demo/clock", response_model=ClockOut, tags=["demo"]) async def get_clock() -> ClockOut: return _clock_out() @router.post("/demo/clock/advance", response_model=ClockOut, tags=["demo"]) async def advance_clock(body: ClockAdvanceIn) -> ClockOut: """Moves the virtual clock and replays the scheduler up to the new time (#13).""" clock.advance(days=body.days, hours=body.hours) await scheduler.run_until_now() return _clock_out() @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() 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]: """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 @router.get("/demo/settings", tags=["demo"]) async def get_demo_settings() -> dict[str, bool]: """Presentation switches: `screening_enabled` — initial questionnaires after consent (off → straight to chat).""" from app.core import demo_settings async with get_clinical_session() as s: return await demo_settings.get_all(s) @router.post("/demo/settings", tags=["demo"]) async def set_demo_settings(body: dict[str, bool]) -> dict[str, bool]: from app.core import demo_settings async with get_clinical_session() as s: if demo_settings.SCREENING in body: await demo_settings.set_screening(s, bool(body[demo_settings.SCREENING])) return await demo_settings.get_all(s)