Files
PTSRaiAgent/dashboard/console/api.py
T
ElebobaandClaude Opus 5.5 cdd5ad9cde
CI / ruff · pytest (pull_request) Failing after 2m33s
feat: presentation demo mode — one-click private demo patient, screening on/off switch
- POST /auth/demo/fresh + «Демо: войти как Андрей» on the welcome screen: every sign-in gets a fresh private
  copy of veteran Andrey (consent, prescription, ЕМИАС record) and lands in the chat — no codes, nobody's
  crisis or half-done questionnaire leaks into another viewer.
- Initial screening switch: SCREENING_ENABLED env default + /demo/settings + a toggle in the demo console;
  off → after consent straight to the chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:01:05 +03:00

192 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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))
def demo_settings(self) -> dict[str, Any]:
return dict(self._request("GET", "/api/v1/demo/settings"))
def set_screening(self, enabled: bool) -> dict[str, Any]:
return dict(self._request("POST", "/api/v1/demo/settings", json={"screening_enabled": enabled}))
# ------------------------------------------------------------------ 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}; используется токен из окружения"