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:
@@ -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
|
||||
Reference in New Issue
Block a user