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>
139 lines
3.8 KiB
Python
139 lines
3.8 KiB
Python
"""API schemas (frozen contract, #34). Dashboard (lane C) is built against these; lane B implements them (#12).
|
||
|
||
Change only via a separate small contract PR. Colors follow ADR-006: red = acute, yellow = high/moderate,
|
||
green = nothing open, grey = info only (e.g. silence).
|
||
"""
|
||
|
||
from datetime import date, datetime
|
||
from typing import Literal
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
from app.dialog.contracts import Event, Reply
|
||
from app.integrations.base import Prescription
|
||
|
||
Color = Literal["red", "yellow", "green", "grey"]
|
||
LevelName = Literal["info", "moderate", "high", "acute"]
|
||
Module = Literal["veteran", "depression", "family"]
|
||
Metric = Literal["phq9", "pcl5", "gad7", "auditc", "dar5", "mood", "sleep_hours", "alcohol"]
|
||
|
||
|
||
class DiscontinuationRisk(BaseModel):
|
||
"""Rule-based index (not ML) of the risk to drop therapy: missed doses, side effects, quit intent, low mood."""
|
||
|
||
score: int = Field(ge=0, le=100)
|
||
level: Literal["low", "medium", "high"]
|
||
factors: list[str] = Field(default_factory=list) # short Russian labels for the doctor
|
||
|
||
|
||
class PatientListItem(BaseModel):
|
||
token: str
|
||
display_name: str # "Андрей С." — minimal identification for the treating doctor
|
||
age: int
|
||
modules: list[Module]
|
||
drug: str | None = None # "сертралин 50 мг"
|
||
therapy_day: int
|
||
next_visit: date | None = None
|
||
status_color: Color
|
||
top_alert_level: LevelName | None = None
|
||
open_alerts: int = 0
|
||
phq9_last: int | None = None
|
||
pcl5_last: int | None = None
|
||
trend: Literal["up", "down", "flat", "unknown"] = "unknown" # direction of symptom scores (up = worse)
|
||
adherence_7d: float | None = Field(default=None, ge=0, le=1)
|
||
discontinuation_risk: DiscontinuationRisk
|
||
last_event: str = ""
|
||
|
||
|
||
class SeriesPoint(BaseModel):
|
||
day: date
|
||
metric: Metric
|
||
value: float
|
||
scheduled: bool = True # False for unscheduled scale sessions
|
||
|
||
|
||
class DoseOut(BaseModel):
|
||
planned_at: datetime
|
||
drug: str
|
||
status: Literal["taken", "missed", "skipped", "pending"]
|
||
|
||
|
||
class AlertOut(BaseModel):
|
||
id: int
|
||
token: str
|
||
patient_name: str
|
||
ts: datetime
|
||
level: LevelName
|
||
color: Color
|
||
code: str
|
||
reason: str
|
||
acked_by: str | None = None
|
||
acked_at: datetime | None = None
|
||
|
||
|
||
class PatientCard(BaseModel):
|
||
patient: PatientListItem
|
||
prescriptions: list[Prescription]
|
||
series: list[SeriesPoint]
|
||
doses: list[DoseOut]
|
||
alerts: list[AlertOut]
|
||
safety_plan: list[str] | None = None # Stanley–Brown steps, if composed with the doctor
|
||
summary: str = "" # LLM summary built from structured data only (#19)
|
||
|
||
|
||
class AckIn(BaseModel):
|
||
doctor_id: str
|
||
|
||
|
||
class ConnectPatientIn(BaseModel):
|
||
doctor_id: str
|
||
display_name: str
|
||
birth_year: int
|
||
sex: Literal["m", "f"]
|
||
snils: str # synthetic only
|
||
drug: str
|
||
dose: str
|
||
times: list[str] = Field(default_factory=lambda: ["08:00"])
|
||
instructions: str = ""
|
||
next_visit: date | None = None
|
||
modules: list[Module] = Field(default_factory=lambda: ["veteran"])
|
||
|
||
|
||
class ConnectPatientOut(BaseModel):
|
||
token: str
|
||
code: str # one-time connect code (esia_mock)
|
||
deep_link: str # t.me/<bot>?start=<code> or web-console link; dashboard renders it as QR
|
||
|
||
|
||
class DialogIn(Event):
|
||
"""Same as dialog Event; kept as a separate name for OpenAPI clarity."""
|
||
|
||
|
||
class DialogOut(BaseModel):
|
||
replies: list[Reply]
|
||
|
||
|
||
class MessageOut(BaseModel):
|
||
ts: datetime
|
||
direction: Literal["in", "out"]
|
||
text: str # scrubbed for "in", as sent for "out"
|
||
kind: str = "normal"
|
||
|
||
|
||
class ClockOut(BaseModel):
|
||
now: datetime
|
||
virtual: bool
|
||
offset_days: float
|
||
|
||
|
||
class ClockAdvanceIn(BaseModel):
|
||
days: float = 0
|
||
hours: float = 0
|
||
|
||
|
||
class DemoPersona(BaseModel):
|
||
token: str
|
||
display_name: str
|
||
therapy_day: int
|
||
scenario: str | None = None # path of data/synthetic/<persona>.jsonl if scripted
|