feat(dashboard): demo console with web chat, demo clock, scenario playback and split screen

- pages/2_Демо-консоль.py: persona selector, virtual clock bar (+1 h, to 08:00/20:00,
  next day, reset with confirm), fallback patient web chat (buttons from Reply.buttons,
  kind styling, outbox drained after every action and on a 2 s fragment poll), scenario panel
- pages/3_Сплит-экран.py: patient chat or PATIENT_WEB_URL iframe on the left, compact doctor
  panel (patients, live alerts, red banner on a new acute alert) on the right
- console/playback.py: data/synthetic/<persona>.jsonl runner (advance_to/press/text/
  expect_color/start), date chaining between days, pause on failure with retry/skip/manual
- console/api.py: own HTTP client over the frozen API contract, ApiError only

Refs #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 12:46:14 +03:00
co-authored by Claude Opus 5.5
parent 178cab5b1b
commit 260422138a
11 changed files with 1782 additions and 0 deletions
+158
View File
@@ -0,0 +1,158 @@
"""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