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
+74
View File
@@ -0,0 +1,74 @@
"""Dialog contracts (frozen, #34). Channel-agnostic: Telegram and the web demo console render the same replies.
Lane A implements `DialogEngine` (#10); lane B calls it from the API (#12) and the scheduler (#13).
Change only via a separate small contract PR.
"""
from collections.abc import Callable
from importlib import import_module, util
from typing import Literal, Protocol
from pydantic import BaseModel, Field
SystemEventKind = Literal[
"med_reminder", # dose is due: params {"dose_id", "drug", "dose"}
"daily_checkin", # time for the daily check-in
"scale_due", # scheduled / unscheduled scale session: params {"scale", "scheduled"}
"reach_out", # silence or missed doses — caring contact
"doctor_ack", # doctor acknowledged a crisis alert: lets the FSM leave `crisis`
]
class Button(BaseModel):
"""Abstract button; channels render it (inline keyboard / web button). `data` is echoed back on press."""
label: str
data: str
class Event(BaseModel):
"""Incoming event for a patient. Exactly one of text / button / system is meaningful per `type`."""
type: Literal["text", "button", "system", "start"]
text: str | None = None # raw patient text — the engine scrubs it before anything else
button: str | None = None # Button.data of the pressed button
system: SystemEventKind | None = None
params: dict[str, object] = Field(default_factory=dict) # e.g. {"code": "..."} for type="start"
class Reply(BaseModel):
"""Outgoing message. `kind` lets channels style crisis/safety messages differently."""
text: str
buttons: list[list[Button]] = Field(default_factory=list) # rows of buttons
kind: Literal["normal", "question", "crisis", "safety_plan", "info"] = "normal"
class DialogEngine(Protocol):
async def handle(self, token: str, event: Event) -> list[Reply]: ...
class EchoEngine:
"""Placeholder until #10 lands: lets the API and the demo console be built and tested now."""
async def handle(self, token: str, event: Event) -> list[Reply]:
if event.type == "system":
return [Reply(text=f"[stub] system event: {event.system}", kind="info")]
return [Reply(text="[stub] движок диалога появится в #10", kind="info")]
_factory: Callable[[], DialogEngine] = EchoEngine
def register_engine(factory: Callable[[], DialogEngine]) -> None:
"""Lane A calls this at import time of `app.dialog.engine` to replace the echo stub."""
global _factory
_factory = factory
def get_engine() -> DialogEngine:
# Importing the engine module registers the real factory; absent module → echo stub.
# find_spec instead of try/except ImportError: real import errors inside the module must surface.
if util.find_spec("app.dialog.engine") is not None:
import_module("app.dialog.engine")
return _factory()