"""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 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 from the API; on failure a single fallback persona (env token) and the error text.""" 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) token = fallback_token() fallback = { "token": token, "display_name": "Андрей С." if token == DEFAULT_TOKEN else token, "therapy_day": 0, "scenario": "data/synthetic/andrey.jsonl" if token == DEFAULT_TOKEN else None, } return [fallback], error