chore(core): stage 1 contracts — dialog, API schemas + fixtures, integrations, monitoring, modules
CI / ruff · pytest (pull_request) Successful in 22s
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:
@@ -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()
|
||||
Reference in New Issue
Block a user