diff --git a/dashboard/console/api.py b/dashboard/console/api.py index 4c710a3..1368305 100644 --- a/dashboard/console/api.py +++ b/dashboard/console/api.py @@ -135,24 +135,51 @@ class ConsoleApi: params = {"since_id": since_id, "open_only": str(open_only).lower()} return list(self._request("GET", f"/api/v1/doctors/{doctor}/alerts", params=params)) + def connect(self, body: dict[str, Any]) -> dict[str, Any]: + """`POST /patients/connect` (ConnectPatientIn) → {token, code, deep_link}.""" + return dict(self._request("POST", "/api/v1/patients/connect", json=body, timeout=30)) + def ack(self, alert_id: int, doctor: str) -> dict[str, Any]: return dict(self._request("POST", f"/api/v1/alerts/{alert_id}/ack", json={"doctor_id": doctor})) def personas_or_fallback(api: ConsoleApi) -> tuple[list[dict[str, Any]], str | None]: - """Demo personas from the API; on failure a single fallback persona (env token) and the error text.""" + """Demo personas for the selector, degrading step by step so the console always has someone to talk as. + + 1. `GET /demo/personas` (patients with a scenario file); + 2. the demo doctor's patient list (real tokens; no scenario file yet, e.g. before #18 lands); + 3. a single persona with the token from `DEMO_PERSONA_TOKEN` (fixtures use `tok-andrey`). + Returns the personas and a note for the presenter when a fallback was used. + """ try: personas = [p for p in api.personas() if isinstance(p, dict) and p.get("token")] if personas: return personas, None - error = "API вернул пустой список персон" + error = "API не вернул персон со сценарием" except ApiError as exc: error = str(exc) + try: + patients = [p for p in api.patients(doctor_id()) if isinstance(p, dict) and p.get("token")] + except ApiError: + patients = [] + if patients: + # The demo hero first: the scenario file is guessed from the name later (`default_scenario_path`). + patients.sort(key=lambda p: not str(p.get("display_name", "")).startswith("Андрей")) + personas = [ + { + "token": p["token"], + "display_name": p.get("display_name") or p["token"], + "therapy_day": p.get("therapy_day", 0), + "scenario": None, + } + for p in patients + ] + return personas, f"{error}; показаны пациенты врача" token = fallback_token() fallback = { "token": token, "display_name": "Андрей С." if token == DEFAULT_TOKEN else token, "therapy_day": 0, - "scenario": "data/synthetic/andrey.jsonl" if token == DEFAULT_TOKEN else None, + "scenario": None, } - return [fallback], error + return [fallback], f"{error}; используется токен из окружения" diff --git a/dashboard/console/scenario.py b/dashboard/console/scenario.py index 454c59c..410a629 100644 --- a/dashboard/console/scenario.py +++ b/dashboard/console/scenario.py @@ -151,13 +151,30 @@ def load_scenario(path: str | Path, root: Path = REPO_ROOT) -> Scenario: return parse_scenario(text) -def default_scenario_path(persona: dict[str, Any]) -> str | None: - """Scenario path from the persona, or `data/synthetic/.jsonl` guessed from a `tok-` token.""" +_TRANSLIT = { + "а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh", "з": "z", "и": "i", + "й": "y", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t", + "у": "u", "ф": "f", "х": "kh", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "shch", "ъ": "", "ы": "y", "ь": "", + "э": "e", "ю": "yu", "я": "ya", +} # fmt: skip + + +def persona_slug(display_name: str) -> str: + """'Андрей С.' → 'andrey': the backend names scenario files by the transliterated first name (#12).""" + first = (display_name.split() or [""])[0].lower() + return "".join(_TRANSLIT.get(ch, ch if ch.isascii() and ch.isalnum() else "") for ch in first) + + +def default_scenario_path(persona: dict[str, Any], root: Path = REPO_ROOT) -> str | None: + """Scenario path from the persona, else `data/synthetic/.jsonl` guessed from the name or `tok-`.""" if persona.get("scenario"): return str(persona["scenario"]) token = str(persona.get("token", "")) + slugs = [persona_slug(str(persona.get("display_name", "")))] if token.startswith("tok-"): - guess = f"data/synthetic/{token.removeprefix('tok-')}.jsonl" - if resolve_path(guess).exists(): + slugs.append(token.removeprefix("tok-")) + for slug in filter(None, slugs): + guess = f"data/synthetic/{slug}.jsonl" + if resolve_path(guess, root).exists(): return guess return None diff --git a/dashboard/console/widgets.py b/dashboard/console/widgets.py index 50d4360..3b19d75 100644 --- a/dashboard/console/widgets.py +++ b/dashboard/console/widgets.py @@ -89,16 +89,55 @@ def select_persona(container: Any = None) -> dict[str, Any]: where = container or st.sidebar personas, error = _personas(get_api().base_url) if error: - where.caption(f"⚠️ персоны из API недоступны ({error}); используется токен из окружения") + where.caption(f"⚠️ {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]) + labels = {p["token"]: f"{p.get('display_name') or p['token']} ({p['token'][:12]})" for p in personas} + token = where.selectbox("Пациент демо", tokens, index=index, format_func=lambda t: labels.get(t, t)) st.session_state["console_token"] = token return next(p for p in personas if p["token"] == token) +# Synthetic demo hero (docs/product/01_demo_persona.md); the SNILS is fake and used only to derive the token. +DEMO_HERO = { + "display_name": "Андрей С.", + "birth_year": 1992, + "sex": "m", + "snils": "000-000-017 17", + "drug": "сертралин", + "dose": "50 мг", + "times": ["08:00"], + "instructions": "1 раз в сутки утром после еды", + "modules": ["veteran", "depression"], +} + + +def _connect_hero() -> None: + name = st.session_state.get("console_hero_name") or DEMO_HERO["display_name"] + body = {**DEMO_HERO, "doctor_id": doctor_id(), "display_name": name} + try: + out = get_api().connect(body) + except ApiError as exc: + hint = " — пациент уже подключён, выберите его в списке" if "409" in str(exc) else "" + flash(f"Подключение не выполнено: {exc}{hint}", "warning" if hint else "error") + return + token = str(out.get("token", "")) + st.session_state["console_token"] = token + st.session_state[f"console_code:{token}"] = str(out.get("code", "")) + _personas.clear() + flash(f"Пациент подключён, код {out.get('code', '')}. Нажмите «Начать» в чате.", "success") + + +def render_connect_hero(container: Any = None) -> None: + """Fallback when the demo seed is missing: connect the synthetic hero through the doctor API.""" + where = container or st.sidebar + with where.expander("Подключить демо-пациента"): + st.caption("Если сид демо не загружен: врач подключает Андрея (синтетика) — как на приёме.") + st.text_input("Имя", value="Андрей С.", key="console_hero_name") + st.button("Подключить", key="console-connect", on_click=_connect_hero) + + # ------------------------------------------------------------------------------------------------ clock def _drain_quiet(api: ConsoleApi, token: str) -> None: try: @@ -137,6 +176,7 @@ def _reset() -> None: flash(f"Сброс не выполнен: {exc}") return forget_demo_state() + _personas.clear() # the seed may be recreated on reset flash("Демо сброшено: часы вернулись к реальному времени, чат и прогресс сценария очищены.", "success") @@ -263,7 +303,10 @@ def render_chat(token: str, *, height: int = 520, key: str = "chat") -> None: 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("Начать диалог / код подключения"): + code = st.query_params.get("code") # connect links from the doctor point to the console with ?code= + if code and not st.session_state.get(f"console_code:{token}"): + st.session_state[f"console_code:{token}"] = code + with st.expander("Начать диалог / код подключения", expanded=bool(code)): 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") diff --git a/dashboard/pages/2_Демо-консоль.py b/dashboard/pages/2_Демо-консоль.py index 22afa19..903cb5e 100644 --- a/dashboard/pages/2_Демо-консоль.py +++ b/dashboard/pages/2_Демо-консоль.py @@ -17,6 +17,7 @@ from console.playback_ui import render_playback_panel # noqa: E402 st.set_page_config(page_title="Демо-консоль", page_icon="🎬", layout="wide") persona = w.select_persona() +w.render_connect_hero() w.show_flash() st.sidebar.caption(f"API: {w.get_api().base_url}") diff --git a/tests/test_dashboard_console.py b/tests/test_dashboard_console.py index f4dd347..27f3f49 100644 --- a/tests/test_dashboard_console.py +++ b/tests/test_dashboard_console.py @@ -23,7 +23,14 @@ from console.playback import ( # noqa: E402 resolve_press, target_datetime, ) -from console.scenario import Action, load_scenario, parse_action, parse_scenario # noqa: E402 +from console.scenario import ( # noqa: E402 + Action, + default_scenario_path, + load_scenario, + parse_action, + parse_scenario, + persona_slug, +) MSK = timezone(timedelta(hours=3)) @@ -388,3 +395,25 @@ def test_console_api_errors_become_api_error_and_personas_fall_back(monkeypatch) ok = _client(lambda req: httpx.Response(200, json=[{"token": "tok-andrey", "display_name": "Андрей С."}])) personas, error = personas_or_fallback(ok) assert error is None and personas[0]["display_name"] == "Андрей С." + + +def test_personas_fall_back_to_doctor_patients_with_hero_first(): + def handler(req: httpx.Request) -> httpx.Response: + if req.url.path.endswith("/demo/personas"): + return httpx.Response(200, json=[]) + return httpx.Response( + 200, json=[{"token": "h1", "display_name": "Сергей П."}, {"token": "h2", "display_name": "Андрей С."}] + ) + + personas, error = personas_or_fallback(_client(handler)) + assert [p["token"] for p in personas] == ["h2", "h1"] and "пациенты врача" in error + + +def test_scenario_path_guessed_from_name_or_token(tmp_path): + (tmp_path / "data" / "synthetic").mkdir(parents=True) + (tmp_path / "data" / "synthetic" / "andrey.jsonl").write_text("", encoding="utf-8") + assert persona_slug("Андрей С.") == "andrey" and persona_slug("Наталья Ф.") == "natalya" + assert default_scenario_path({"token": "h", "display_name": "Андрей С."}, tmp_path) == "data/synthetic/andrey.jsonl" + assert default_scenario_path({"token": "tok-andrey"}, tmp_path) == "data/synthetic/andrey.jsonl" + assert default_scenario_path({"token": "x", "display_name": "Ольга Н."}, tmp_path) is None + assert default_scenario_path({"token": "x", "scenario": "s.jsonl"}, tmp_path) == "s.jsonl"