chore(core): stage 1 contracts — dialog, API schemas + fixtures, integrations, monitoring, modules
CI / ruff · pytest (pull_request) Successful in 22s

Freeze interfaces between lanes A (bot), B (backend), C (dashboard) so three developers work in
parallel without touching each other's files: dialog Event/Reply + engine registry with echo stub,
integration protocols with in-memory defaults, MonitoringService with noop stub, /api/v1 schemas
with fixture-backed routes, frozen main.py wiring, config/modules.yaml + due_scales(), DAR-5,
tzdata, Andrey trajectory, lanes in WORKFLOW, ADR-010.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 15:22:25 +03:00
co-authored by Claude Opus 5.5
parent a86008f248
commit b4731fb1d6
30 changed files with 1126 additions and 89 deletions
+117
View File
@@ -0,0 +1,117 @@
"""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.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
router = APIRouter(prefix="/api/v1")
# ------------------------------------------------------------------ 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; #13 makes the scheduler replay all events up to the new time."""
clock.advance(days=body.days, hours=body.hours)
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"
)
]