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
+5
View File
@@ -0,0 +1,5 @@
"""Demo console building blocks (#17): HTTP API helpers, scenario parser, playback runner, Streamlit widgets.
Pages import this package as `console` (Streamlit puts `dashboard/` on sys.path). Modules other than
`widgets` / `chat_ui` are free of Streamlit so they can be unit-tested.
"""
+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
+81
View File
@@ -0,0 +1,81 @@
"""In-memory transcript of the patient chat shown by the console (kept in Streamlit session state).
Entries are plain dicts: `{"dir": "out", "text", "kind", "buttons"}` for bot replies (the `Reply` contract)
and `{"dir": "in", "text", "pressed": bool}` for what the patient sent.
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
MAX_ENTRIES = 400
def normalize_reply(raw: dict[str, Any]) -> dict[str, Any]:
"""Coerce an API reply into a safe shape: missing fields get defaults, malformed buttons are dropped."""
rows: list[list[dict[str, str]]] = []
for row in raw.get("buttons") or []:
if not isinstance(row, list):
continue
clean = [
{"label": str(b.get("label", "")), "data": str(b.get("data", ""))}
for b in row
if isinstance(b, dict) and b.get("data") is not None
]
if clean:
rows.append(clean)
return {
"dir": "out",
"text": str(raw.get("text") or ""),
"kind": str(raw.get("kind") or "normal"),
"buttons": rows,
}
class ChatLog:
"""Wraps a list owned by the caller (session state), so every page sees the same transcript."""
def __init__(self, entries: list[dict[str, Any]] | None = None):
self.entries: list[dict[str, Any]] = entries if entries is not None else []
def _trim(self) -> None:
if len(self.entries) > MAX_ENTRIES:
del self.entries[: len(self.entries) - MAX_ENTRIES]
def add_in(self, text: str, *, pressed: bool = False) -> None:
self.entries.append({"dir": "in", "text": text, "pressed": pressed})
self._trim()
def add_replies(self, replies: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
added = [normalize_reply(r) for r in replies if isinstance(r, dict)]
self.entries.extend(added)
self._trim()
return added
def add_history(self, messages: Iterable[dict[str, Any]]) -> None:
"""Prefill from `GET /dialog/{token}/history` (MessageOut: no buttons)."""
for m in messages:
if not isinstance(m, dict):
continue
if m.get("direction") == "in":
self.entries.append({"dir": "in", "text": str(m.get("text", "")), "pressed": False})
else:
self.entries.append(normalize_reply({"text": m.get("text"), "kind": m.get("kind")}))
self._trim()
def recent_replies(self, n: int = 10) -> list[dict[str, Any]]:
"""Last `n` bot replies, oldest first."""
outs = [e for e in self.entries if e.get("dir") == "out"]
return outs[-n:] if n > 0 else []
def last_buttons_index(self) -> int | None:
"""Index of the newest bot reply that carries buttons — only its keyboard is clickable in the web chat."""
for i in range(len(self.entries) - 1, -1, -1):
e = self.entries[i]
if e.get("dir") == "out" and e.get("buttons"):
return i
return None
def clear(self) -> None:
self.entries.clear()
+312
View File
@@ -0,0 +1,312 @@
"""Scenario playback over the HTTP API (#17): runs `data/synthetic/<persona>.jsonl` step by step.
Time handling. A scenario day is a calendar date on the demo clock. When day N is played, its date is
derived from the last played day M: date(N) = date(M) + (N − M); with nothing played yet (or if that date
is already behind the clock, e.g. after manual «Следующий день») `advance_to HH:MM` goes to the next
occurrence of HH:MM. The clock only moves forward (`/demo/clock/advance` replays the scheduler), so a target
already in the past is skipped with a warning instead of failing.
Failure handling. Every step is wrapped: an error stops playback, the position is kept in `PlaybackState`
and the page offers «повторить шаг» / «пропустить шаг» / «продолжить вручную». The demo never crashes.
"""
from __future__ import annotations
import time as _time
from collections.abc import Callable, Iterator, Sequence
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta
from typing import Any, Literal
from .api import DEFAULT_DOCTOR, ApiError, DemoApi
from .chatlog import ChatLog
from .scenario import Action, DayScript, Scenario, parse_hhmm
# Virtual time keeps ticking between steps: a target a few minutes behind "now" counts as reached.
TOLERANCE = timedelta(minutes=5)
PRESS_WINDOW = 10
StepStatus = Literal["ok", "warn", "fail", "info"]
# ------------------------------------------------------------------------------------------------ pure helpers
def _norm(s: str) -> str:
return " ".join(s.casefold().replace("ё", "е").split())
def resolve_press(replies: Sequence[dict[str, Any]], label: str, window: int = PRESS_WINDOW) -> tuple[str, str] | None:
"""Find the button to press for a scenario `press` step.
Searches the last `window` replies newest first; inside a reply an exact (case/ё-insensitive) label match
wins over a substring match. Returns `(data, label)` or None. Button `data` is dynamic (dose ids), so
scenarios address buttons by label.
"""
needle = _norm(label)
if not needle:
return None
for reply in reversed(list(replies)[-window:] if window > 0 else []):
buttons = [b for row in reply.get("buttons") or [] for b in row if isinstance(b, dict)]
for exact in (True, False):
for b in buttons:
text = _norm(str(b.get("label", "")))
if (text == needle) if exact else (needle in text):
return str(b.get("data", "")), str(b.get("label", ""))
return None
def parse_clock_now(clock: dict[str, Any]) -> datetime:
"""`ClockOut.now` (ISO, tz-aware) → datetime; raises ValueError on garbage."""
raw = clock.get("now")
if not isinstance(raw, str):
raise ValueError("часы API не вернули поле now")
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
def target_datetime(now: datetime, hhmm: str, day_date: date | None) -> datetime:
"""Moment to move the clock to for `advance_to HH:MM` (see module docstring for the rules)."""
h, m = parse_hhmm(hhmm)
if day_date is None:
cand = now.replace(hour=h, minute=m, second=0, microsecond=0)
if cand < now - TOLERANCE:
cand += timedelta(days=1)
return cand
return datetime.combine(day_date, time(h, m), tzinfo=now.tzinfo)
def hours_until(now: datetime, target: datetime) -> float | None:
"""Hours to advance (≥ 0), 0 within the tolerance window, None if the target is clearly in the past."""
delta = target - now
if delta >= timedelta(0):
return delta.total_seconds() / 3600
if delta >= -TOLERANCE:
return 0.0
return None
def planned_date(dates: dict[int, date], day: int, today: date) -> date | None:
"""Calendar date for scenario `day` from already played days, or None to use "next occurrence" rules."""
if day in dates:
cand = dates[day]
else:
earlier = [d for d in dates if d < day]
if not earlier:
return None
prev = max(earlier)
cand = dates[prev] + timedelta(days=day - prev)
return cand if cand >= today else None
# ------------------------------------------------------------------------------------------------ runner
@dataclass
class StepLog:
day: int
index: int # 0-based step index inside the day; -1 for day-level messages
status: StepStatus
message: str
action: Action | None = None
def render(self) -> str:
icon = {"ok": "✅", "warn": "⚠️", "fail": "❌", "info": "ℹ️"}[self.status]
step = f"шаг {self.index + 1}: " if self.index >= 0 else ""
return f"{icon} день {self.day}, {step}{self.message}"
@dataclass
class Resume:
day: int
step: int
until: int
@dataclass
class PlaybackState:
"""Per-persona playback memory kept in session state; survives an interrupted Streamlit run."""
dates: dict[int, date] = field(default_factory=dict) # scenario day → calendar date it was played on
done: list[int] = field(default_factory=list) # fully played days
failed: Resume | None = None # where playback stopped on an error
position: Resume | None = None # step currently being executed (left set if the run was interrupted)
log: list[str] = field(default_factory=list)
@property
def last_done(self) -> int | None:
return max(self.done) if self.done else None
def reset(self) -> None:
self.dates.clear()
self.done.clear()
self.failed = None
self.position = None
self.log.clear()
class StepFailed(Exception):
pass
class PlaybackRunner:
def __init__(
self,
api: DemoApi,
token: str,
chat: ChatLog,
state: PlaybackState,
*,
doctor: str = DEFAULT_DOCTOR,
delay: float = 0.0,
press_retries: int = 3,
retry_wait: float = 0.7,
sleep: Callable[[float], None] = _time.sleep,
):
self.api = api
self.token = token
self.chat = chat
self.state = state
self.doctor = doctor
self.delay = delay
self.press_retries = press_retries
self.retry_wait = retry_wait
self.sleep = sleep
# ---------------------------------------------------------------- helpers
def drain(self) -> list[dict[str, Any]]:
"""Pull system-initiated replies (reminders, check-ins) into the chat."""
return self.chat.add_replies(self.api.outbox(self.token))
def _now(self) -> datetime:
return parse_clock_now(self.api.clock())
@staticmethod
def _preview(replies: list[dict[str, Any]]) -> str:
if not replies:
return ""
text = replies[-1].get("text", "").replace("\n", " ")
more = f" (+{len(replies) - 1})" if len(replies) > 1 else ""
return f" → «{text[:70]}{'…' if len(text) > 70 else ''}»{more}"
def next_day(self, scenario: Scenario) -> DayScript | None:
last = self.state.last_done
return next((d for d in scenario.days if last is None or d.day > last), None)
# ---------------------------------------------------------------- one step
def _step(self, day: DayScript, action: Action, day_date: list[date | None]) -> tuple[StepStatus, str]:
if action.kind == "advance_to":
now = self._now()
target = target_datetime(now, action.value, day_date[0])
if day_date[0] is None:
day_date[0] = target.date()
self.state.dates[day.day] = target.date()
hours = hours_until(now, target)
if hours is None:
return "warn", f"{action.describe()}: время уже прошло ({now:%d.%m %H:%M}), пропускаю"
if hours > 0:
self.api.advance(hours=hours)
got = self.drain()
return "ok", f"{action.describe()} ({target:%d.%m %H:%M}){self._preview(got)}"
if action.kind == "press":
found = resolve_press(self.chat.recent_replies(PRESS_WINDOW), action.value)
for _ in range(self.press_retries):
if found is not None:
break
# The reply may still be on its way (scheduler tick): wait and drain the outbox again.
self.sleep(self.retry_wait)
self.drain()
found = resolve_press(self.chat.recent_replies(PRESS_WINDOW), action.value)
if found is None:
raise StepFailed(f"кнопка «{action.value}» не найдена в последних {PRESS_WINDOW} сообщениях")
data, label = found
self.chat.add_in(label, pressed=True)
got = self.chat.add_replies(self.api.send_event(self.token, "button", button=data))
got += self.drain()
return "ok", f"{action.describe()}{self._preview(got)}"
if action.kind == "text":
self.chat.add_in(action.value)
got = self.chat.add_replies(self.api.send_event(self.token, "text", text=action.value))
got += self.drain()
return "ok", f"{action.describe()}{self._preview(got)}"
if action.kind == "start":
params = {"code": action.value} if action.value else {}
self.chat.add_in("/start", pressed=True)
got = self.chat.add_replies(self.api.send_event(self.token, "start", params=params))
got += self.drain()
return "ok", f"{action.describe()}{self._preview(got)}"
# expect_color is a soft check: a mismatch is shown to the presenter but never stops the demo.
try:
patients = self.api.patients(self.doctor)
except ApiError as exc:
return "warn", f"{action.describe()}: не удалось проверить ({exc})"
me = next((p for p in patients if isinstance(p, dict) and p.get("token") == self.token), None)
if me is None:
return "warn", f"{action.describe()}: пациента нет в списке врача"
actual = me.get("status_color")
if actual == action.value:
return "ok", f"{action.describe()}: совпало"
return "warn", f"{action.describe()}: у врача сейчас {actual}"
# ---------------------------------------------------------------- days
def run(self, scenario: Scenario, from_day: int, from_step: int, until_day: int) -> Iterator[StepLog]:
"""Play days `from_day..until_day` (those present in the scenario), the first one from `from_step`."""
days = [d for d in scenario.days if from_day <= d.day <= until_day]
if not days:
yield StepLog(from_day, -1, "info", "в сценарии нет дней для проигрывания")
return
self.state.failed = None
for n, day in enumerate(days):
start = from_step if n == 0 else 0
try:
today = self._now().date()
except (ApiError, ValueError) as exc:
self._fail(day.day, max(start, 0), until_day)
yield StepLog(day.day, -1, "fail", f"часы API недоступны: {exc}")
return
day_date: list[date | None] = [planned_date(self.state.dates, day.day, today)]
if day_date[0] is not None:
self.state.dates[day.day] = day_date[0]
yield StepLog(day.day, -1, "info", f"начинаю день {day.day}" + (f": {day.note}" if day.note else ""))
for i in range(start, len(day.actions)):
action = day.actions[i]
self.state.position = Resume(day.day, i, until_day)
try:
status, message = self._step(day, action, day_date)
except Exception as exc: # robustness first: any error pauses playback, never crashes the page
self._fail(day.day, i, until_day)
reason = str(exc) or type(exc).__name__
yield StepLog(day.day, i, "fail", f"{action.describe()}: {reason}", action)
return
yield StepLog(day.day, i, status, message, action)
if self.delay > 0 and i < len(day.actions) - 1:
self.sleep(self.delay)
if day.day not in self.state.dates:
self.state.dates[day.day] = today
if day.day not in self.state.done:
self.state.done.append(day.day)
self.state.position = None
yield StepLog(day.day, -1, "ok", f"день {day.day} проигран")
if self.delay > 0 and n < len(days) - 1:
self.sleep(self.delay)
def _fail(self, day: int, step: int, until: int) -> None:
self.state.failed = Resume(day, step, until)
self.state.position = None
def play_day(self, scenario: Scenario, day: int) -> Iterator[StepLog]:
return self.run(scenario, day, 0, day)
def scroll_to(self, scenario: Scenario, day: int) -> Iterator[StepLog]:
"""Play every not yet played day up to and including `day`."""
nxt = self.next_day(scenario)
if nxt is None or nxt.day > day:
return iter([StepLog(day, -1, "info", f"дни до {day} уже проиграны")])
return self.run(scenario, nxt.day, 0, day)
def resume(self, scenario: Scenario, *, skip: bool) -> Iterator[StepLog]:
"""Continue after a failure (or an interrupted run): retry the failed step or skip it."""
point = self.state.failed or self.state.position
if point is None:
return iter([StepLog(0, -1, "info", "нечего продолжать")])
step = point.step + (1 if skip else 0)
return self.run(scenario, point.day, step, point.until)
+106
View File
@@ -0,0 +1,106 @@
"""Scenario playback panel of the demo console (#17): day list, «Проиграть день N», «Прокрутить до дня N»."""
from __future__ import annotations
from typing import Any
import streamlit as st
from . import widgets as w
from .playback import PlaybackState
from .scenario import Scenario
def _forget_upload(token: str) -> None:
st.session_state.pop(f"console_scenario_text:{token}", None)
st.session_state.pop(f"console_up:{token}", None) # resets the uploader widget too
def _scenario_source_controls(persona: dict[str, Any], source: str) -> None:
token = persona["token"]
with st.expander(f"Файл сценария: {source}"):
st.text_input(
"Путь в репозитории",
key=f"console_scenario_path:{token}",
placeholder=str(persona.get("scenario") or "data/synthetic/andrey.jsonl"),
)
upload = st.file_uploader("…или загрузить .jsonl", type=["jsonl", "json", "txt"], key=f"console_up:{token}")
if upload is not None:
try:
st.session_state[f"console_scenario_text:{token}"] = upload.getvalue().decode("utf-8")
except UnicodeDecodeError:
st.error("Файл не в UTF-8")
if st.session_state.get(f"console_scenario_text:{token}"):
st.button("Забыть загруженный файл", on_click=_forget_upload, args=(token,))
def _day_table(scenario: Scenario, state: PlaybackState, next_day: int | None) -> None:
rows = []
for d in scenario.days:
mark = "✅" if d.day in state.done else ("▶️" if d.day == next_day else "")
rows.append({"": mark, "День": d.day, "Шагов": len(d.actions), "Подсказка ведущему": d.note})
st.dataframe(rows, hide_index=True, width="stretch", height=min(38 + 35 * len(rows), 330))
def _clear_failure(state: PlaybackState) -> None:
state.failed = None
state.position = None
def render_playback_panel(persona: dict[str, Any]) -> None:
token = persona["token"]
scenario, source = w.scenario_for(persona)
state = w.playback_state(token)
st.subheader("🎬 Сценарий демо")
_scenario_source_controls(persona, source)
if scenario.errors:
with st.expander(f"⚠️ Замечания к сценарию: {len(scenario.errors)}", expanded=not scenario.days):
for err in scenario.errors:
st.write(f"- {err}")
if not scenario.days:
st.info("Сценарий не загружен. Демо можно вести вручную: чат слева и кнопки часов сверху.")
return
delay = st.slider("Пауза между шагами, с", 0.0, 3.0, 1.0, 0.25, key=f"console_delay:{token}")
runner = w.make_runner(persona, delay=delay)
nxt = runner.next_day(scenario)
_day_table(scenario, state, nxt.day if nxt else None)
numbers = scenario.day_numbers
default = numbers.index(nxt.day) if nxt else len(numbers) - 1
day = st.selectbox("День", numbers, index=default, key=f"console_day:{token}", format_func=lambda n: f"день {n}")
c1, c2, c3 = st.columns(3)
action: tuple[str, Any] | None = None
if c1.button(f"▶ Проиграть день {day}", type="primary", width="stretch"):
action = ("Проигрываю день", runner.play_day(scenario, day))
if c2.button(f"⏭ Прокрутить до дня {day}", width="stretch", help="Быстро, без пауз: все непроигранные дни"):
runner.delay = 0.0
action = (f"Прокрутка до дня {day}", runner.scroll_to(scenario, day))
if c3.button(
f"▶ Следующий: день {nxt.day}" if nxt else "Все дни проиграны",
width="stretch",
disabled=nxt is None,
):
assert nxt is not None
action = ("Проигрываю день", runner.play_day(scenario, nxt.day))
point = state.failed or state.position
if point is not None and action is None:
what = "остановлено на ошибке" if state.failed else "прервано"
st.warning(f"Проигрывание {what}: день {point.day}, шаг {point.step + 1}.")
r1, r2, r3 = st.columns(3)
if r1.button("🔁 Повторить шаг", width="stretch"):
action = ("Продолжаю", runner.resume(scenario, skip=False))
if r2.button("⏩ Пропустить шаг", width="stretch"):
action = ("Продолжаю", runner.resume(scenario, skip=True))
r3.button("✋ Продолжить вручную", width="stretch", on_click=_clear_failure, args=(state,))
if action is not None:
title, logs = action
w.execute(logs, state, title=title)
st.session_state["console_log_open"] = True
st.rerun() # refresh the chat and the clock with what playback produced
if state.log:
with st.expander("Журнал последнего проигрывания", expanded=st.session_state.pop("console_log_open", False)):
st.text("\n".join(state.log[-80:]))
+163
View File
@@ -0,0 +1,163 @@
"""Demo scenario files `data/synthetic/<persona>.jsonl` (format agreed with #18).
One JSON object per line: `{"day": <therapy day>, "note": "<presenter hint>", "actions": [...]}` where an action
is exactly one of `{"advance_to": "HH:MM"}`, `{"press": "<label substring>"}`, `{"text": "<message>"}`,
`{"expect_color": "green|yellow|red|grey"}`. The console also accepts `{"start": "<connect code or empty>"}`
(sends a `start` event) so a scenario can open the dialog itself.
The parser is lenient on purpose: a broken line or action is reported and skipped, the rest still plays —
a typo in the scenario must not take the live demo down.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
ActionKind = Literal["advance_to", "press", "text", "expect_color", "start"]
ACTION_KINDS: tuple[ActionKind, ...] = ("advance_to", "press", "text", "expect_color", "start")
COLORS = ("green", "yellow", "red", "grey")
_HHMM = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
REPO_ROOT = Path(__file__).resolve().parents[2]
@dataclass(frozen=True)
class Action:
kind: ActionKind
value: str
def describe(self) -> str:
"""Short Russian description for the step log."""
return {
"advance_to": f"⏩ часы → {self.value}",
"press": f"🔘 нажать «{self.value}»",
"text": f"💬 написать «{self.value}»",
"expect_color": f"🎯 ожидаем цвет {self.value}",
"start": "🚀 начать диалог" + (f" (код {self.value})" if self.value else ""),
}[self.kind]
@dataclass(frozen=True)
class DayScript:
day: int
note: str
actions: tuple[Action, ...]
@dataclass
class Scenario:
days: list[DayScript] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def day(self, number: int) -> DayScript | None:
return next((d for d in self.days if d.day == number), None)
@property
def day_numbers(self) -> list[int]:
return [d.day for d in self.days]
def parse_hhmm(value: str) -> tuple[int, int]:
"""'8:05' / '08:05' → (8, 5); raises ValueError on anything else."""
m = _HHMM.match(value.strip())
if m is None:
raise ValueError(f"время должно быть в формате ЧЧ:ММ, получено «{value}»")
return int(m.group(1)), int(m.group(2))
def parse_action(raw: Any) -> Action:
"""Validate one action object; raises ValueError with a Russian message."""
if not isinstance(raw, dict) or len(raw) != 1:
raise ValueError(f"действие должно быть объектом с одним ключом, получено {raw!r}")
((kind, value),) = raw.items()
if kind not in ACTION_KINDS:
raise ValueError(f"неизвестное действие «{kind}»")
if value is None and kind == "start":
value = ""
if not isinstance(value, str):
raise ValueError(f"значение «{kind}» должно быть строкой")
value = value.strip()
if kind == "advance_to":
h, m = parse_hhmm(value)
value = f"{h:02d}:{m:02d}"
elif kind == "expect_color":
value = value.lower()
if value not in COLORS:
raise ValueError(f"цвет должен быть одним из {', '.join(COLORS)}, получено «{value}»")
elif kind in ("press", "text") and not value:
raise ValueError(f"пустое значение «{kind}»")
return Action(kind, value) # type: ignore[arg-type]
def parse_scenario(text: str) -> Scenario:
"""Parse JSONL text. Days are returned sorted; duplicates keep the first occurrence."""
scenario = Scenario()
seen: set[int] = set()
for lineno, line in enumerate(text.splitlines(), start=1):
line = line.strip()
if not line or line.startswith("//") or line.startswith("#"):
continue
where = f"строка {lineno}"
try:
obj = json.loads(line)
except json.JSONDecodeError as exc:
scenario.errors.append(f"{where}: не JSON ({exc.msg})")
continue
if not isinstance(obj, dict):
scenario.errors.append(f"{where}: ожидается объект")
continue
day = obj.get("day")
if isinstance(day, bool) or not isinstance(day, int) or day < 0:
scenario.errors.append(f"{where}: поле day должно быть целым ≥ 0")
continue
if day in seen:
scenario.errors.append(f"{where}: день {day} повторяется, строка пропущена")
continue
raw_actions = obj.get("actions", [])
if not isinstance(raw_actions, list):
scenario.errors.append(f"{where}: actions должно быть списком")
continue
actions: list[Action] = []
for i, raw in enumerate(raw_actions, start=1):
try:
actions.append(parse_action(raw))
except ValueError as exc:
scenario.errors.append(f"{where}, день {day}, действие {i}: {exc} — пропущено")
note = obj.get("note", "")
seen.add(day)
scenario.days.append(DayScript(day=day, note=str(note) if note is not None else "", actions=tuple(actions)))
scenario.days.sort(key=lambda d: d.day)
return scenario
def resolve_path(path: str | Path, root: Path = REPO_ROOT) -> Path:
p = Path(path)
return p if p.is_absolute() else root / p
def load_scenario(path: str | Path, root: Path = REPO_ROOT) -> Scenario:
"""Read and parse a scenario file; a missing/unreadable file yields an empty scenario with an error."""
full = resolve_path(path, root)
try:
text = full.read_text(encoding="utf-8")
except FileNotFoundError:
return Scenario(errors=[f"файл сценария не найден: {path}"])
except OSError as exc:
return Scenario(errors=[f"не удалось прочитать {path}: {type(exc).__name__}"])
return parse_scenario(text)
def default_scenario_path(persona: dict[str, Any]) -> str | None:
"""Scenario path from the persona, or `data/synthetic/<name>.jsonl` guessed from a `tok-<name>` token."""
if persona.get("scenario"):
return str(persona["scenario"])
token = str(persona.get("token", ""))
if token.startswith("tok-"):
guess = f"data/synthetic/{token.removeprefix('tok-')}.jsonl"
if resolve_path(guess).exists():
return guess
return None
+412
View File
@@ -0,0 +1,412 @@
"""Streamlit widgets shared by the demo console and the split screen (#17).
Robustness rules for the live demo: every API call goes through `ConsoleApi` (raises `ApiError` only),
callbacks never raise — errors are queued as flash messages and shown on the next render — and the chat
transcript / playback progress live in `st.session_state`, so both pages share them within one browser tab.
"""
from __future__ import annotations
import contextlib
import os
from datetime import datetime
from typing import Any
import streamlit as st
from .api import ApiError, ConsoleApi, api_base_url, doctor_id, personas_or_fallback
from .chatlog import ChatLog
from .playback import PlaybackRunner, PlaybackState, StepLog, parse_clock_now, target_datetime
from .scenario import Scenario, default_scenario_path, load_scenario, parse_scenario
COLOR_EMOJI = {"red": "🔴", "yellow": "🟡", "green": "🟢", "grey": "⚪"}
LEVEL_EMOJI = {"acute": "🔴", "high": "🟠", "moderate": "🟡", "info": "⚪"}
WEEKDAYS = ("пн", "вт", "ср", "чт", "пт", "сб", "вс")
POLL_SECONDS = 2
# ------------------------------------------------------------------------------------------------ state
@st.cache_resource
def _api_for(base_url: str) -> ConsoleApi:
return ConsoleApi(base_url)
def get_api() -> ConsoleApi:
return _api_for(api_base_url())
def chat_for(token: str) -> ChatLog:
key = f"console_chat:{token}"
if key not in st.session_state:
entries: list[dict[str, Any]] = []
st.session_state[key] = entries
with contextlib.suppress(ApiError): # history is a nice-to-have: the chat may start empty
ChatLog(entries).add_history(get_api().history(token))
return ChatLog(st.session_state[key])
def playback_state(token: str) -> PlaybackState:
key = f"console_playback:{token}"
if key not in st.session_state:
st.session_state[key] = PlaybackState()
return st.session_state[key]
def flash(message: str, level: str = "error") -> None:
"""Report a problem from a callback: a toast now plus a persistent note shown by `show_flash`."""
icon = {"error": "❌", "warning": "⚠️", "success": "✅"}.get(level, "ℹ️")
# Outside a script run context (tests, threads) a toast may fail — the persistent note below is enough.
with contextlib.suppress(Exception):
st.toast(message, icon=icon)
st.session_state["console_last_note"] = (level, message)
def show_flash(container: Any = None) -> None:
"""Last error/notice of the session, kept until the next one (does not vanish with the 2 s poll)."""
note = st.session_state.get("console_last_note")
if not note:
return
level, message = note
where = container or st.sidebar
{"error": where.error, "warning": where.warning, "success": where.success}.get(level, where.info)(message)
def forget_demo_state() -> None:
"""After «Сброс демо»: drop transcripts, playback progress and the alert baseline of every persona."""
for key in list(st.session_state.keys()):
if str(key).startswith(("console_chat:", "console_playback:", "console_alerts_")):
del st.session_state[key]
# ------------------------------------------------------------------------------------------------ persona
@st.cache_data(ttl=15, show_spinner=False)
def _personas(base_url: str) -> tuple[list[dict[str, Any]], str | None]:
return personas_or_fallback(get_api())
def select_persona(container: Any = None) -> dict[str, Any]:
"""Persona selector (sidebar by default); the choice is shared by all console pages."""
where = container or st.sidebar
personas, error = _personas(get_api().base_url)
if error:
where.caption(f"⚠️ персоны из API недоступны ({error}); используется токен из окружения")
tokens = [p["token"] for p in personas]
current = st.session_state.get("console_token")
index = tokens.index(current) if current in tokens else 0
labels = {p["token"]: f"{p.get('display_name') or p['token']} ({p['token']})" for p in personas}
token = where.selectbox("Пациент демо", tokens, index=index, format_func=lambda t: labels[t])
st.session_state["console_token"] = token
return next(p for p in personas if p["token"] == token)
# ------------------------------------------------------------------------------------------------ clock
def _drain_quiet(api: ConsoleApi, token: str) -> None:
try:
chat_for(token).add_replies(api.outbox(token))
except ApiError as exc:
flash(f"Не удалось забрать входящие: {exc}", "warning")
def _advance(token: str, days: float = 0, hours: float = 0) -> None:
api = get_api()
try:
api.advance(days=days, hours=hours)
except ApiError as exc:
flash(f"Часы не сдвинулись: {exc}")
return
_drain_quiet(api, token)
def _advance_to(token: str, hhmm: str) -> None:
api = get_api()
try:
now = parse_clock_now(api.clock())
hours = (target_datetime(now, hhmm, None) - now).total_seconds() / 3600
if hours > 0:
api.advance(hours=hours)
except (ApiError, ValueError) as exc:
flash(f"Часы не сдвинулись: {exc}")
return
_drain_quiet(api, token)
def _reset() -> None:
try:
get_api().reset()
except ApiError as exc:
flash(f"Сброс не выполнен: {exc}")
return
forget_demo_state()
flash("Демо сброшено: часы вернулись к реальному времени, чат и прогресс сценария очищены.", "success")
def _therapy_day(api: ConsoleApi, persona: dict[str, Any]) -> int | None:
try:
me = next((p for p in api.patients(doctor_id()) if p.get("token") == persona["token"]), None)
except ApiError:
me = None
day = (me or persona).get("therapy_day")
return day if isinstance(day, int) else None
def format_now(now: datetime) -> str:
return f"{WEEKDAYS[now.weekday()]} {now:%d.%m.%Y} · {now:%H:%M}"
def render_clock_bar(persona: dict[str, Any], *, key: str, with_reset: bool = True) -> None:
"""Virtual date/time, therapy day and the time controls (ADR-005)."""
api = get_api()
token = persona["token"]
info, *buttons = st.columns([3.2, 1, 1.2, 1.2, 1.3, 1.2] if with_reset else [3.2, 1, 1.2, 1.2, 1.3])
try:
clock = api.clock()
now = parse_clock_now(clock)
day = _therapy_day(api, persona)
mode = "виртуальные часы" if clock.get("virtual") else "реальное время"
info.markdown(f"### 🕒 {format_now(now)}")
info.caption(f"День терапии: **{day if day is not None else '—'}** · {mode}")
except (ApiError, ValueError) as exc:
info.warning(f"Часы недоступны: {exc}")
buttons[0].button("+1 час", key=f"{key}-h1", on_click=_advance, args=(token,), kwargs={"hours": 1})
buttons[1].button("К 08:00 (приём)", key=f"{key}-08", on_click=_advance_to, args=(token, "08:00"))
buttons[2].button("К 20:00 (чек-ин)", key=f"{key}-20", on_click=_advance_to, args=(token, "20:00"))
buttons[3].button(
"Следующий день", key=f"{key}-d1", type="primary", on_click=_advance, args=(token,), kwargs={"days": 1}
)
if with_reset:
with buttons[4].popover("Сброс демо"):
st.write("Вернуть часы к реальному времени и очистить чат и прогресс сценария?")
st.button("Да, сбросить", key=f"{key}-reset", type="primary", on_click=_reset)
# ------------------------------------------------------------------------------------------------ chat
def _send(token: str, type_: str, *, text: str | None = None, button: str | None = None, label: str = "") -> None:
api = get_api()
chat = chat_for(token)
params: dict[str, Any] = {}
if type_ == "start":
code = (st.session_state.get(f"console_code:{token}") or "").strip()
params = {"code": code} if code else {}
chat.add_in("/start" + (f" {code}" if code else ""), pressed=True)
elif type_ == "button":
chat.add_in(label, pressed=True)
else:
chat.add_in(text or "")
try:
chat.add_replies(api.send_event(token, type_, text=text, button=button, params=params))
except ApiError as exc:
flash(f"Сообщение не доставлено: {exc}")
return
_drain_quiet(api, token)
def _submit_text(token: str, key: str) -> None:
text = (st.session_state.get(key) or "").strip()
if text:
_send(token, "text", text=text)
def _render_entry(entry: dict[str, Any]) -> None:
if entry.get("dir") == "in":
with st.chat_message("user", avatar="🧑"):
st.markdown(f"🔘 *{entry['text']}*" if entry.get("pressed") else entry["text"])
return
kind = entry.get("kind", "normal")
with st.chat_message("assistant", avatar="🤝"):
if kind == "crisis":
st.error(entry["text"], icon="🚨")
elif kind == "safety_plan":
with st.container(border=True):
st.markdown("**🛡️ План безопасности**")
st.markdown(entry["text"])
elif kind == "info":
st.caption(entry["text"])
else:
st.markdown(entry["text"])
def render_messages(token: str, *, height: int, key: str) -> None:
"""Transcript + the keyboard of the newest reply with buttons. Drains the outbox on every call."""
_drain_quiet(get_api(), token)
chat = chat_for(token)
box = st.container(height=height, border=True, autoscroll=True)
with box:
if not chat.entries:
st.caption("Сообщений пока нет. Нажмите «Начать» или сдвиньте часы к напоминанию.")
active = chat.last_buttons_index()
for i, entry in enumerate(chat.entries):
_render_entry(entry)
if i == active:
for r, row in enumerate(entry["buttons"]):
cols = st.columns(len(row))
for c, b in enumerate(row):
cols[c].button(
b["label"] or "…",
key=f"{key}-b{i}-{r}-{c}",
width="stretch",
on_click=_send,
args=(token, "button"),
kwargs={"button": b["data"], "label": b["label"]},
)
@st.fragment(run_every=POLL_SECONDS)
def _polled_messages(token: str, height: int, key: str) -> None:
render_messages(token, height=height, key=key)
def render_chat(token: str, *, height: int = 520, key: str = "chat") -> None:
"""Fallback patient web chat: polled transcript, text input, «Начать» with an optional connect code."""
_polled_messages(token, height, key)
input_key = f"{key}-input:{token}"
with st.form(f"{key}-form:{token}", clear_on_submit=True, border=False):
left, right = st.columns([5, 1])
left.text_input("Сообщение", key=input_key, label_visibility="collapsed", placeholder="Сообщение пациента…")
right.form_submit_button("➤", width="stretch", on_click=_submit_text, args=(token, input_key))
with st.expander("Начать диалог / код подключения"):
st.text_input("Код подключения (необязательно)", key=f"console_code:{token}", placeholder="DEMO-0001")
c1, c2 = st.columns(2)
c1.button("🚀 Начать", key=f"{key}-start", on_click=_send, args=(token, "start"), width="stretch")
c2.button(
"Очистить окно чата",
key=f"{key}-clear",
on_click=lambda: chat_for(token).clear(),
width="stretch",
)
def patient_web_url() -> str | None:
url = (os.environ.get("PATIENT_WEB_URL") or "").strip()
return url or None
# ------------------------------------------------------------------------------------------------ doctor panel
def _dismiss_banner(alert_id: int) -> None:
st.session_state.setdefault("console_alerts_dismissed", set()).add(alert_id)
def _ack(alert_id: int) -> None:
try:
get_api().ack(alert_id, doctor_id())
except ApiError as exc:
flash(f"Алерт не подтверждён: {exc}")
_dismiss_banner(alert_id)
def _fmt_ts(raw: Any) -> str:
try:
return datetime.fromisoformat(str(raw).replace("Z", "+00:00")).strftime("%d.%m %H:%M")
except ValueError:
return ""
def render_doctor_panel(highlight_token: str | None, *, key: str = "doc") -> None:
"""Compact doctor view for the projector: patients with colours and the live alerts feed."""
api = get_api()
doctor = doctor_id()
try:
alerts = [a for a in api.alerts(doctor) if isinstance(a, dict)]
except ApiError as exc:
alerts = []
st.warning(f"Алерты недоступны: {exc}")
ids = [int(a.get("id", -1)) for a in alerts]
# Alerts already present when the panel first loads are "old": the banner is only for new acute ones.
if "console_alerts_baseline" not in st.session_state:
st.session_state["console_alerts_baseline"] = max(ids, default=-1)
baseline = st.session_state["console_alerts_baseline"]
dismissed = st.session_state.get("console_alerts_dismissed", set())
fresh_acute = [
a
for a in alerts
if a.get("level") == "acute"
and not a.get("acked_at")
and baseline < int(a.get("id", -1))
and int(a.get("id", -1)) not in dismissed
]
for a in fresh_acute[:1]:
st.markdown(
"<div style='background:#c62828;color:#fff;padding:18px 20px;border-radius:12px;"
"font-size:1.35rem;font-weight:700;margin-bottom:8px'>"
f"🚨 ОСТРЫЙ АЛЕРТ · {_escape(a.get('patient_name', ''))} · {_fmt_ts(a.get('ts'))}<br>"
f"<span style='font-size:1.05rem;font-weight:500'>{_escape(a.get('reason', ''))}</span></div>",
unsafe_allow_html=True,
)
c1, c2 = st.columns(2)
aid = int(a.get("id", -1))
c1.button("Взять в работу (ack)", key=f"{key}-ack{aid}", type="primary", on_click=_ack, args=(aid,))
c2.button("Скрыть", key=f"{key}-hide{aid}", on_click=_dismiss_banner, args=(aid,))
st.markdown("#### Пациенты")
try:
patients = [p for p in api.patients(doctor) if isinstance(p, dict)]
except ApiError as exc:
patients = []
st.warning(f"Список пациентов недоступен: {exc}")
lines = []
for p in patients:
name = str(p.get("display_name", "?"))
if p.get("token") == highlight_token:
name = f"**{name} ◀**"
extra = f" · {p['last_event']}" if p.get("last_event") else ""
lines.append(
f"{COLOR_EMOJI.get(p.get('status_color', ''), '⚪')} {name} · день {p.get('therapy_day', '—')}{extra}"
)
st.markdown("\n".join(f"- {line}" for line in lines) if lines else "_нет пациентов_")
st.markdown("#### Алерты")
if not alerts:
st.caption("Новых алертов нет")
for a in alerts[:8]:
done = " ✔︎" if a.get("acked_at") else ""
icon = LEVEL_EMOJI.get(a.get("level", ""), "⚪")
who = a.get("patient_name", "")
st.markdown(f"{icon} `{_fmt_ts(a.get('ts'))}` **{who}** — {a.get('reason', '')}{done}")
@st.fragment(run_every=POLL_SECONDS)
def polled_doctor_panel(highlight_token: str | None, key: str = "doc") -> None:
render_doctor_panel(highlight_token, key=key)
def _escape(value: Any) -> str:
text = str(value)
for ch, rep in (("&", "&amp;"), ("<", "&lt;"), (">", "&gt;")):
text = text.replace(ch, rep)
return text
# ------------------------------------------------------------------------------------------------ playback
def scenario_for(persona: dict[str, Any]) -> tuple[Scenario, str]:
"""Scenario of the persona: uploaded text (if any) wins over the repo file. Returns (scenario, source)."""
token = persona["token"]
uploaded = st.session_state.get(f"console_scenario_text:{token}")
if uploaded:
return parse_scenario(uploaded), "загруженный файл"
path = st.session_state.get(f"console_scenario_path:{token}") or default_scenario_path(persona)
if not path:
return Scenario(errors=["у персоны нет файла сценария"]), "—"
return load_scenario(path), path
def make_runner(persona: dict[str, Any], *, delay: float) -> PlaybackRunner:
token = persona["token"]
return PlaybackRunner(get_api(), token, chat_for(token), playback_state(token), doctor=doctor_id(), delay=delay)
def execute(logs: Any, state: PlaybackState, *, title: str) -> bool:
"""Run a playback iterator with a live log. Returns True if it finished without a failed step."""
ok = True
state.log.clear()
with st.status(title, expanded=True) as status:
try:
for entry in logs:
line = entry.render() if isinstance(entry, StepLog) else str(entry)
state.log.append(line)
status.write(line)
ok = ok and entry.status != "fail"
except Exception as exc: # last-resort guard: the page must stay alive
ok = False
state.log.append(f"❌ сбой проигрывания: {exc}")
status.write(state.log[-1])
status.update(label=f"{title}: {'готово' if ok else 'остановлено'}", state="complete" if ok else "error")
return ok