feat(dashboard): демо-консоль, проигрывание сценария, сплит-экран #69
@@ -135,24 +135,51 @@ class ConsoleApi:
|
|||||||
params = {"since_id": since_id, "open_only": str(open_only).lower()}
|
params = {"since_id": since_id, "open_only": str(open_only).lower()}
|
||||||
return list(self._request("GET", f"/api/v1/doctors/{doctor}/alerts", params=params))
|
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]:
|
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}))
|
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]:
|
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:
|
try:
|
||||||
personas = [p for p in api.personas() if isinstance(p, dict) and p.get("token")]
|
personas = [p for p in api.personas() if isinstance(p, dict) and p.get("token")]
|
||||||
if personas:
|
if personas:
|
||||||
return personas, None
|
return personas, None
|
||||||
error = "API вернул пустой список персон"
|
error = "API не вернул персон со сценарием"
|
||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
error = str(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()
|
token = fallback_token()
|
||||||
fallback = {
|
fallback = {
|
||||||
"token": token,
|
"token": token,
|
||||||
"display_name": "Андрей С." if token == DEFAULT_TOKEN else token,
|
"display_name": "Андрей С." if token == DEFAULT_TOKEN else token,
|
||||||
"therapy_day": 0,
|
"therapy_day": 0,
|
||||||
"scenario": "data/synthetic/andrey.jsonl" if token == DEFAULT_TOKEN else None,
|
"scenario": None,
|
||||||
}
|
}
|
||||||
return [fallback], error
|
return [fallback], f"{error}; используется токен из окружения"
|
||||||
|
|||||||
@@ -151,13 +151,30 @@ def load_scenario(path: str | Path, root: Path = REPO_ROOT) -> Scenario:
|
|||||||
return parse_scenario(text)
|
return parse_scenario(text)
|
||||||
|
|
||||||
|
|
||||||
def default_scenario_path(persona: dict[str, Any]) -> str | None:
|
_TRANSLIT = {
|
||||||
"""Scenario path from the persona, or `data/synthetic/<name>.jsonl` guessed from a `tok-<name>` token."""
|
"а": "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/<slug>.jsonl` guessed from the name or `tok-<slug>`."""
|
||||||
if persona.get("scenario"):
|
if persona.get("scenario"):
|
||||||
return str(persona["scenario"])
|
return str(persona["scenario"])
|
||||||
token = str(persona.get("token", ""))
|
token = str(persona.get("token", ""))
|
||||||
|
slugs = [persona_slug(str(persona.get("display_name", "")))]
|
||||||
if token.startswith("tok-"):
|
if token.startswith("tok-"):
|
||||||
guess = f"data/synthetic/{token.removeprefix('tok-')}.jsonl"
|
slugs.append(token.removeprefix("tok-"))
|
||||||
if resolve_path(guess).exists():
|
for slug in filter(None, slugs):
|
||||||
|
guess = f"data/synthetic/{slug}.jsonl"
|
||||||
|
if resolve_path(guess, root).exists():
|
||||||
return guess
|
return guess
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -89,16 +89,55 @@ def select_persona(container: Any = None) -> dict[str, Any]:
|
|||||||
where = container or st.sidebar
|
where = container or st.sidebar
|
||||||
personas, error = _personas(get_api().base_url)
|
personas, error = _personas(get_api().base_url)
|
||||||
if error:
|
if error:
|
||||||
where.caption(f"⚠️ персоны из API недоступны ({error}); используется токен из окружения")
|
where.caption(f"⚠️ {error}")
|
||||||
tokens = [p["token"] for p in personas]
|
tokens = [p["token"] for p in personas]
|
||||||
current = st.session_state.get("console_token")
|
current = st.session_state.get("console_token")
|
||||||
index = tokens.index(current) if current in tokens else 0
|
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}
|
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[t])
|
token = where.selectbox("Пациент демо", tokens, index=index, format_func=lambda t: labels.get(t, t))
|
||||||
st.session_state["console_token"] = token
|
st.session_state["console_token"] = token
|
||||||
return next(p for p in personas if p["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
|
# ------------------------------------------------------------------------------------------------ clock
|
||||||
def _drain_quiet(api: ConsoleApi, token: str) -> None:
|
def _drain_quiet(api: ConsoleApi, token: str) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -137,6 +176,7 @@ def _reset() -> None:
|
|||||||
flash(f"Сброс не выполнен: {exc}")
|
flash(f"Сброс не выполнен: {exc}")
|
||||||
return
|
return
|
||||||
forget_demo_state()
|
forget_demo_state()
|
||||||
|
_personas.clear() # the seed may be recreated on reset
|
||||||
flash("Демо сброшено: часы вернулись к реальному времени, чат и прогресс сценария очищены.", "success")
|
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, right = st.columns([5, 1])
|
||||||
left.text_input("Сообщение", key=input_key, label_visibility="collapsed", placeholder="Сообщение пациента…")
|
left.text_input("Сообщение", key=input_key, label_visibility="collapsed", placeholder="Сообщение пациента…")
|
||||||
right.form_submit_button("➤", width="stretch", on_click=_submit_text, args=(token, input_key))
|
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")
|
st.text_input("Код подключения (необязательно)", key=f"console_code:{token}", placeholder="DEMO-0001")
|
||||||
c1, c2 = st.columns(2)
|
c1, c2 = st.columns(2)
|
||||||
c1.button("🚀 Начать", key=f"{key}-start", on_click=_send, args=(token, "start"), width="stretch")
|
c1.button("🚀 Начать", key=f"{key}-start", on_click=_send, args=(token, "start"), width="stretch")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from console.playback_ui import render_playback_panel # noqa: E402
|
|||||||
st.set_page_config(page_title="Демо-консоль", page_icon="🎬", layout="wide")
|
st.set_page_config(page_title="Демо-консоль", page_icon="🎬", layout="wide")
|
||||||
|
|
||||||
persona = w.select_persona()
|
persona = w.select_persona()
|
||||||
|
w.render_connect_hero()
|
||||||
w.show_flash()
|
w.show_flash()
|
||||||
st.sidebar.caption(f"API: {w.get_api().base_url}")
|
st.sidebar.caption(f"API: {w.get_api().base_url}")
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,14 @@ from console.playback import ( # noqa: E402
|
|||||||
resolve_press,
|
resolve_press,
|
||||||
target_datetime,
|
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))
|
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": "Андрей С."}]))
|
ok = _client(lambda req: httpx.Response(200, json=[{"token": "tok-andrey", "display_name": "Андрей С."}]))
|
||||||
personas, error = personas_or_fallback(ok)
|
personas, error = personas_or_fallback(ok)
|
||||||
assert error is None and personas[0]["display_name"] == "Андрей С."
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user