Files
PTSRaiAgent/app/api/routes.py
ElebobaandClaude Opus 5.5 251f946691
CI / ruff · pytest (pull_request) Successful in 1m3s
feat(backend): demo scheduler on the virtual clock — doses, missed, check-ins, scales, silence
- app/scheduler/demo.py: run_until(now) replays due and fresh events in time order: dose rows +
  med_reminder, missed after 2 h without an answer → monitoring, daily_checkin, scale_due by the
  config/modules.yaml schedules, daily monitoring pass (silence, missed doses) → reach_out/scale_due
- idempotent via scheduler_events (unique token+key); stale events are not sent; no contact without
  consent or during an open crisis
- config/scheduler.yaml: times and windows; runs while the questionnaire release is off
- POST /demo/clock/advance replays the scheduler up to the new time

Refs #13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 03:25:44 +03:00

122 lines
5.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""API routes `/api/v1` (#34 stubs → #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).
"""
from fastapi import APIRouter, HTTPException
from app.api import fixtures
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.dialog.contracts import get_engine
from app.integrations.base import MemoryOutbox, integrations
from app.scheduler import jobs as scheduler
router = APIRouter(prefix="/api/v1")
router.include_router(questionnaire_router)
# ------------------------------------------------------------------ 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))
@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")
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
@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()})
@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")
# ------------------------------------------------------------------ patient dialog (web console / any channel)
@router.post("/dialog/{token}/events", response_model=DialogOut, tags=["dialog"])
async def dialog_event(token: str, body: DialogIn) -> DialogOut:
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 [])
@router.get("/dialog/{token}/history", response_model=list[MessageOut], tags=["dialog"])
async def dialog_history(token: str, limit: int = 100) -> list[MessageOut]:
return []
# ------------------------------------------------------------------ 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.reset()
return {"status": "ok"}
@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"
)
]