From 260422138abee0dea49b4183587175ffdf751e09 Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 12:46:12 +0300 Subject: [PATCH 1/2] feat(dashboard): demo console with web chat, demo clock, scenario playback and split screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/.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 --- dashboard/console/__init__.py | 5 + dashboard/console/api.py | 158 ++++++++++ dashboard/console/chatlog.py | 81 +++++ dashboard/console/playback.py | 312 +++++++++++++++++++ dashboard/console/playback_ui.py | 106 +++++++ dashboard/console/scenario.py | 163 ++++++++++ dashboard/console/widgets.py | 412 ++++++++++++++++++++++++++ dashboard/pages/2_Демо-консоль.py | 33 +++ dashboard/pages/3_Сплит-экран.py | 58 ++++ tests/test_dashboard_console.py | 390 ++++++++++++++++++++++++ tests/test_dashboard_console_pages.py | 64 ++++ 11 files changed, 1782 insertions(+) create mode 100644 dashboard/console/__init__.py create mode 100644 dashboard/console/api.py create mode 100644 dashboard/console/chatlog.py create mode 100644 dashboard/console/playback.py create mode 100644 dashboard/console/playback_ui.py create mode 100644 dashboard/console/scenario.py create mode 100644 dashboard/console/widgets.py create mode 100644 dashboard/pages/2_Демо-консоль.py create mode 100644 dashboard/pages/3_Сплит-экран.py create mode 100644 tests/test_dashboard_console.py create mode 100644 tests/test_dashboard_console_pages.py diff --git a/dashboard/console/__init__.py b/dashboard/console/__init__.py new file mode 100644 index 0000000..3ccbeab --- /dev/null +++ b/dashboard/console/__init__.py @@ -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. +""" diff --git a/dashboard/console/api.py b/dashboard/console/api.py new file mode 100644 index 0000000..4c710a3 --- /dev/null +++ b/dashboard/console/api.py @@ -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 diff --git a/dashboard/console/chatlog.py b/dashboard/console/chatlog.py new file mode 100644 index 0000000..dcd07a2 --- /dev/null +++ b/dashboard/console/chatlog.py @@ -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() diff --git a/dashboard/console/playback.py b/dashboard/console/playback.py new file mode 100644 index 0000000..ddf60e7 --- /dev/null +++ b/dashboard/console/playback.py @@ -0,0 +1,312 @@ +"""Scenario playback over the HTTP API (#17): runs `data/synthetic/.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) diff --git a/dashboard/console/playback_ui.py b/dashboard/console/playback_ui.py new file mode 100644 index 0000000..a678125 --- /dev/null +++ b/dashboard/console/playback_ui.py @@ -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:])) diff --git a/dashboard/console/scenario.py b/dashboard/console/scenario.py new file mode 100644 index 0000000..454c59c --- /dev/null +++ b/dashboard/console/scenario.py @@ -0,0 +1,163 @@ +"""Demo scenario files `data/synthetic/.jsonl` (format agreed with #18). + +One JSON object per line: `{"day": , "note": "", "actions": [...]}` where an action +is exactly one of `{"advance_to": "HH:MM"}`, `{"press": "