"""Thin synchronous HTTP client for the demo console (frozen API contract, `app/api/schemas.py`). The console never imports backend code: it talks to `API_BASE_URL` only. Responses are kept as plain dicts — the console reads a handful of fields and must survive a backend that still serves fixtures (#12). Every failure is raised as `ApiError` with a short Russian message, so pages can show it instead of crashing. """ from __future__ import annotations import os from typing import Any, Protocol import httpx DEFAULT_DOCTOR = "doc-smirnova" DEFAULT_TOKEN = "tok-andrey" def api_base_url() -> str: return os.environ.get("API_BASE_URL", "http://localhost:8000").rstrip("/") def doctor_id() -> str: return os.environ.get("DEMO_DOCTOR_ID", DEFAULT_DOCTOR) def fallback_token() -> str: return os.environ.get("DEMO_PERSONA_TOKEN", DEFAULT_TOKEN) class ApiError(RuntimeError): """Any API failure (network, HTTP status, bad JSON) with a message fit for the presenter.""" class DemoApi(Protocol): """What the playback runner needs; `ConsoleApi` implements it, tests use a fake.""" def clock(self) -> dict[str, Any]: ... def advance(self, days: float = 0, hours: float = 0) -> dict[str, Any]: ... def send_event( self, token: str, type_: str, *, text: str | None = None, button: str | None = None, params: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: ... def outbox(self, token: str) -> list[dict[str, Any]]: ... def patients(self, doctor: str) -> list[dict[str, Any]]: ... class ConsoleApi: """Client for `/api/v1`. Short timeouts everywhere except clock moves, which replay the scheduler.""" def __init__(self, base_url: str | None = None, *, timeout: float = 5.0, client: httpx.Client | None = None): self.base_url = (base_url or api_base_url()).rstrip("/") self._timeout = timeout self._client = client or httpx.Client(timeout=timeout) # ------------------------------------------------------------------ plumbing def _request(self, method: str, path: str, *, timeout: float | None = None, **kwargs: Any) -> Any: url = f"{self.base_url}{path}" try: resp = self._client.request(method, url, timeout=timeout or self._timeout, **kwargs) except httpx.TimeoutException as exc: raise ApiError(f"{method} {path}: API не ответил вовремя") from exc except httpx.HTTPError as exc: raise ApiError(f"{method} {path}: API недоступен ({type(exc).__name__})") from exc if resp.status_code >= 400: raise ApiError(f"{method} {path}: HTTP {resp.status_code}") try: return resp.json() except ValueError as exc: raise ApiError(f"{method} {path}: ответ не JSON") from exc def close(self) -> None: self._client.close() # ------------------------------------------------------------------ service def health(self) -> bool: try: return self._request("GET", "/health", timeout=2).get("status") == "ok" except (ApiError, AttributeError): return False # ------------------------------------------------------------------ demo controls (ADR-005) def personas(self) -> list[dict[str, Any]]: return list(self._request("GET", "/api/v1/demo/personas")) def clock(self) -> dict[str, Any]: return dict(self._request("GET", "/api/v1/demo/clock")) def advance(self, days: float = 0, hours: float = 0) -> dict[str, Any]: # The backend replays the scheduler up to the new time inside this call: allow it time. body = {"days": days, "hours": hours} return dict(self._request("POST", "/api/v1/demo/clock/advance", json=body, timeout=60)) def reset(self) -> dict[str, Any]: return dict(self._request("POST", "/api/v1/demo/reset", timeout=60)) # ------------------------------------------------------------------ patient dialog def send_event( self, token: str, type_: str, *, text: str | None = None, button: str | None = None, params: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: body: dict[str, Any] = {"type": type_, "params": params or {}} if text is not None: body["text"] = text if button is not None: body["button"] = button data = self._request("POST", f"/api/v1/dialog/{token}/events", json=body, timeout=30) return list(data.get("replies", [])) def outbox(self, token: str) -> list[dict[str, Any]]: return list(self._request("GET", f"/api/v1/dialog/{token}/outbox").get("replies", [])) def history(self, token: str, limit: int = 100) -> list[dict[str, Any]]: return list(self._request("GET", f"/api/v1/dialog/{token}/history", params={"limit": limit})) # ------------------------------------------------------------------ doctor side def patients(self, doctor: str) -> list[dict[str, Any]]: return list(self._request("GET", f"/api/v1/doctors/{doctor}/patients")) def alerts(self, doctor: str, since_id: int = -1, open_only: bool = False) -> list[dict[str, Any]]: params = {"since_id": since_id, "open_only": str(open_only).lower()} return list(self._request("GET", f"/api/v1/doctors/{doctor}/alerts", params=params)) def connect(self, body: dict[str, Any]) -> dict[str, Any]: """`POST /patients/connect` (ConnectPatientIn) → {token, code, deep_link}.""" return dict(self._request("POST", "/api/v1/patients/connect", json=body, timeout=30)) def ack(self, alert_id: int, doctor: str) -> dict[str, Any]: return dict(self._request("POST", f"/api/v1/alerts/{alert_id}/ack", json={"doctor_id": doctor})) def personas_or_fallback(api: ConsoleApi) -> tuple[list[dict[str, Any]], str | None]: """Demo personas for the selector, degrading step by step so the console always has someone to talk as. 1. `GET /demo/personas` (patients with a scenario file); 2. the demo doctor's patient list (real tokens; no scenario file yet, e.g. before #18 lands); 3. a single persona with the token from `DEMO_PERSONA_TOKEN` (fixtures use `tok-andrey`). Returns the personas and a note for the presenter when a fallback was used. """ try: personas = [p for p in api.personas() if isinstance(p, dict) and p.get("token")] if personas: return personas, None error = "API не вернул персон со сценарием" except ApiError as exc: error = str(exc) try: patients = [p for p in api.patients(doctor_id()) if isinstance(p, dict) and p.get("token")] except ApiError: patients = [] if patients: # The demo hero first: the scenario file is guessed from the name later (`default_scenario_path`). patients.sort(key=lambda p: not str(p.get("display_name", "")).startswith("Андрей")) personas = [ { "token": p["token"], "display_name": p.get("display_name") or p["token"], "therapy_day": p.get("therapy_day", 0), "scenario": None, } for p in patients ] return personas, f"{error}; показаны пациенты врача" token = fallback_token() fallback = { "token": token, "display_name": "Андрей С." if token == DEFAULT_TOKEN else token, "therapy_day": 0, "scenario": None, } return [fallback], f"{error}; используется токен из окружения"