feat(dashboard): doctor dashboard with red zones, patient card, live alerts and patient connect
CI / ruff · pytest (pull_request) Successful in 2m1s
CI / ruff · pytest (pull_request) Successful in 2m1s
- Patient list sorted red -> yellow -> grey -> green with KPI row, trend arrows, 7-day adherence bars, discontinuation-risk badge with factor tooltip. - Patient card: scales with severity bands from config/scales (unscheduled marked), mood/sleep/alcohol small multiples, dose heat-strip, alerts feed with ack, safety plan, AI summary with disclaimer. - Live alerts: st.fragment(run_every=2.5s) polling, toast + pinned acute banner on every page; full rerun on new alert only on doctor pages. - Connect form -> POST /patients/connect -> code, QR, deep link. - Thin httpx ApiClient over the frozen schemas with graceful "API недоступен". - app.py discovers dashboard/pages/*.py automatically (st.navigation); demo pages of #17 plug in by adding a file. Refs #16 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+117
-13
@@ -1,18 +1,122 @@
|
||||
"""Doctor dashboard and demo console (Streamlit). Talks to the backend only via the API."""
|
||||
"""Doctor dashboard and demo console (Streamlit). Talks to the backend only via the API.
|
||||
|
||||
import os
|
||||
Entry point: `streamlit run dashboard/app.py`. Pages are discovered from `dashboard/pages/*.py` — adding a page
|
||||
is adding a file, app.py never needs editing:
|
||||
|
||||
import httpx
|
||||
import streamlit as st
|
||||
* file name `<N>[letter]_<Title>.py` — N (and the optional letter) sets the order, underscores in the title become
|
||||
spaces (`2_Демо-консоль.py` → «Демо-консоль»);
|
||||
* section: pages with N == 1 go to «Кабинет врача», the rest to «Демо»;
|
||||
* optional module-level constants (read as text, the file is not imported):
|
||||
`NAV_TITLE = "..."`, `NAV_ICON = ":material/terminal:"`, `NAV_SECTION = "..."`, `NAV_URL = "slug"`.
|
||||
"""
|
||||
|
||||
API = os.environ.get("API_BASE_URL", "http://localhost:8000")
|
||||
from __future__ import annotations
|
||||
|
||||
st.set_page_config(page_title="PTSRaiAgent — кабинет врача", page_icon="🩺", layout="wide")
|
||||
st.title("Кабинет врача")
|
||||
st.caption("Прототип на синтетических данных")
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
status = httpx.get(f"{API}/health", timeout=2).json().get("status")
|
||||
except httpx.HTTPError:
|
||||
status = "недоступен"
|
||||
st.write(f"API: **{status}**")
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
_HERE = str(Path(__file__).resolve().parent)
|
||||
# `streamlit run` puts dashboard/ on sys.path, where this very file would shadow the backend `app` package
|
||||
# (needed for the frozen schemas). Import everything from the repo root as `dashboard.*` / `app.*` instead.
|
||||
sys.path[:] = [p for p in sys.path if p not in ("", _HERE) and Path(p or ".").resolve() != Path(_HERE)]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
PAGES_DIR = Path(__file__).resolve().parent / "pages"
|
||||
DOCTOR_SECTION = "Кабинет врача"
|
||||
DEMO_SECTION = "Демо"
|
||||
DEFAULT_ICONS = {
|
||||
"Пациенты": ":material/groups:",
|
||||
"Подключить пациента": ":material/person_add:",
|
||||
"Демо-консоль": ":material/smartphone:",
|
||||
"Сплит-экран": ":material/splitscreen:",
|
||||
}
|
||||
|
||||
_NAME_RE = re.compile(r"^(\d+)([a-z]?)_(.+)$")
|
||||
_META_RE = re.compile(r"""^NAV_(TITLE|ICON|SECTION|URL)\s*(?::\s*str\s*)?=\s*["'](.+?)["']\s*$""", re.MULTILINE)
|
||||
_TRANSLIT = dict(
|
||||
zip(
|
||||
"абвгдеёжзийклмнопрстуфхцчшщъыьэюя",
|
||||
[*"abvgdee", "zh", *"zijklmnoprstufh", "ts", "ch", "sh", "sch", "_", "y", "_", "e", "yu", "ya"],
|
||||
strict=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PageSpec:
|
||||
path: Path
|
||||
title: str
|
||||
icon: str
|
||||
section: str
|
||||
url_path: str
|
||||
order: tuple[int, str]
|
||||
|
||||
|
||||
def slugify(title: str) -> str:
|
||||
out = "".join(_TRANSLIT.get(ch, ch) for ch in title.lower())
|
||||
out = out.replace("_", "")
|
||||
return re.sub(r"[^a-z0-9]+", "-", out).strip("-") or "page"
|
||||
|
||||
|
||||
def discover_pages(pages_dir: Path = PAGES_DIR) -> list[PageSpec]:
|
||||
specs = []
|
||||
for path in pages_dir.glob("*.py"):
|
||||
m = _NAME_RE.match(path.stem)
|
||||
if not m:
|
||||
continue
|
||||
number, letter, raw = int(m.group(1)), m.group(2), m.group(3)
|
||||
meta = dict(_META_RE.findall(path.read_text(encoding="utf-8")))
|
||||
title = meta.get("TITLE", raw.replace("_", " "))
|
||||
specs.append(
|
||||
PageSpec(
|
||||
path=path,
|
||||
title=title,
|
||||
icon=meta.get("ICON", DEFAULT_ICONS.get(title, ":material/description:")),
|
||||
section=meta.get("SECTION", DOCTOR_SECTION if number == 1 else DEMO_SECTION),
|
||||
url_path=meta.get("URL", slugify(title)),
|
||||
order=(number, letter),
|
||||
)
|
||||
)
|
||||
return sorted(specs, key=lambda s: s.order)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import streamlit as st
|
||||
|
||||
from dashboard import theme
|
||||
from dashboard.components import live_alerts, state, ui
|
||||
|
||||
st.set_page_config(page_title="Кабинет врача · MedITron", page_icon="🩺", layout="wide")
|
||||
st.html(theme.CSS)
|
||||
|
||||
specs = discover_pages()
|
||||
nav: dict[str, list[st.Page]] = {}
|
||||
section_of: dict[str, str] = {}
|
||||
for i, s in enumerate(specs):
|
||||
page = st.Page(str(s.path), title=s.title, icon=s.icon, url_path=s.url_path, default=i == 0)
|
||||
nav.setdefault(s.section, []).append(page)
|
||||
section_of[s.url_path] = s.section
|
||||
current = st.navigation(nav, position="sidebar", expanded=True)
|
||||
|
||||
with st.sidebar:
|
||||
short, _ = state.doctor_names()
|
||||
st.html(
|
||||
'<div style="padding:4px 2px 10px"><div class="mt-kicker">MedITron · сопровождение</div>'
|
||||
f'<div style="font-weight:650;font-size:1rem;margin-top:2px">{short}</div>'
|
||||
'<div class="mt-meta">психиатр · кабинет МПК</div></div>'
|
||||
)
|
||||
st.caption("Прототип на синтетических данных")
|
||||
|
||||
live_alerts.flush_pending_toasts()
|
||||
# Full-app rerun on a new alert only on doctor pages: demo-console pages may be mid-scenario.
|
||||
live_alerts.render(rerun_app_on_new=section_of.get(current.url_path) == DOCTOR_SECTION)
|
||||
current.run()
|
||||
ui.footer()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user