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
+138
View File
@@ -0,0 +1,138 @@
"""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