- 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>
413 lines
18 KiB
Python
413 lines
18 KiB
Python
"""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 (("&", "&"), ("<", "<"), (">", ">")):
|
||
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
|