"""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 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.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.scheduler import jobs as scheduler from app.services.monitoring import refresh_status router = APIRouter(prefix="/api/v1") 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 "Пациент" # ------------------------------------------------------------------ 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.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