"""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()