From 2582c73d120a951361d59ae7179effce0548fdd1 Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 12:47:05 +0300 Subject: [PATCH] feat(dashboard): doctor dashboard with red zones, patient card, live alerts and patient connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .streamlit/config.toml | 18 ++ dashboard/api_client.py | 132 ++++++++++ dashboard/app.py | 130 +++++++++- dashboard/components/charts.py | 173 +++++++++++++ dashboard/components/helpers.py | 299 ++++++++++++++++++++++ dashboard/components/live_alerts.py | 85 ++++++ dashboard/components/patient_card.py | 212 +++++++++++++++ dashboard/components/state.py | 62 +++++ dashboard/components/ui.py | 109 ++++++++ dashboard/pages/1_Пациенты.py | 97 +++++++ dashboard/pages/1b_Подключить_пациента.py | 188 ++++++++++++++ dashboard/theme.py | 188 ++++++++++++++ tests/test_dashboard_api_client.py | 126 +++++++++ tests/test_dashboard_helpers.py | 212 +++++++++++++++ tests/test_dashboard_pages.py | 152 +++++++++++ 15 files changed, 2170 insertions(+), 13 deletions(-) create mode 100644 .streamlit/config.toml create mode 100644 dashboard/api_client.py create mode 100644 dashboard/components/charts.py create mode 100644 dashboard/components/helpers.py create mode 100644 dashboard/components/live_alerts.py create mode 100644 dashboard/components/patient_card.py create mode 100644 dashboard/components/state.py create mode 100644 dashboard/components/ui.py create mode 100644 dashboard/pages/1_Пациенты.py create mode 100644 dashboard/pages/1b_Подключить_пациента.py create mode 100644 dashboard/theme.py create mode 100644 tests/test_dashboard_api_client.py create mode 100644 tests/test_dashboard_helpers.py create mode 100644 tests/test_dashboard_pages.py diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000..c66a55f --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,18 @@ +# Dashboard-only Streamlit settings (doctor dashboard + demo console). +[theme] +base = "light" +primaryColor = "#2A78D6" +backgroundColor = "#F6F7F9" +secondaryBackgroundColor = "#FFFFFF" +textColor = "#1B1F24" +borderColor = "#E3E6EA" +baseRadius = "medium" +# Falls back to the system sans-serif when offline (ADR-004). +font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap, sans-serif" +headingFontWeights = 700 + +[client] +toolbarMode = "minimal" + +[browser] +gatherUsageStats = false diff --git a/dashboard/api_client.py b/dashboard/api_client.py new file mode 100644 index 0000000..fd9d194 --- /dev/null +++ b/dashboard/api_client.py @@ -0,0 +1,132 @@ +"""Thin HTTP client for the dashboard. The frozen API schemas (`app/api/schemas.py`) are the only contract. + +The dashboard never touches the databases: everything goes over HTTP to `API_BASE_URL`. Short timeouts keep the +UI responsive when the backend is down; every failure is turned into `ApiError` with a Russian message that the +page shows as a banner instead of a traceback. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, TypeVar + +import httpx +from pydantic import BaseModel, TypeAdapter, ValidationError + +from app.api.schemas import ( + AckIn, + AlertOut, + ClockOut, + ConnectPatientIn, + ConnectPatientOut, + PatientCard, + PatientListItem, +) + +log = logging.getLogger(__name__) + +DEFAULT_BASE_URL = "http://localhost:8000" +DEFAULT_DOCTOR_ID = "doc-smirnova" +API_PREFIX = "/api/v1" + +T = TypeVar("T") + + +class ApiError(Exception): + """Any API failure, with a message safe to show to the doctor (no PII, no stack traces).""" + + def __init__(self, message: str, status: int | None = None) -> None: + super().__init__(message) + self.message = message + self.status = status + + +class ApiUnavailable(ApiError): + """Backend not reachable (connection refused, DNS, timeout).""" + + +class ApiClient: + def __init__( + self, + base_url: str | None = None, + doctor_id: str | None = None, + *, + timeout: float = 3.0, + transport: httpx.BaseTransport | None = None, + ) -> None: + base = (base_url or os.environ.get("API_BASE_URL") or DEFAULT_BASE_URL).rstrip("/") + if not base.endswith(API_PREFIX): + base += API_PREFIX + self.base_url = base + self.doctor_id = doctor_id or os.environ.get("DOCTOR_ID") or DEFAULT_DOCTOR_ID + self._http = httpx.Client( + base_url=base, timeout=httpx.Timeout(timeout, connect=min(timeout, 1.5)), transport=transport + ) + + # ------------------------------------------------------------------ doctor endpoints + def patients(self) -> list[PatientListItem]: + return self._get(f"/doctors/{self.doctor_id}/patients", list[PatientListItem]) + + def patient_card(self, token: str) -> PatientCard: + return self._get(f"/patients/{token}", PatientCard, params={"doctor_id": self.doctor_id}) + + def alerts(self, since_id: int = -1, *, open_only: bool = False) -> list[AlertOut]: + params = {"since_id": since_id, "open_only": str(open_only).lower()} + return self._get(f"/doctors/{self.doctor_id}/alerts", list[AlertOut], params=params) + + def ack(self, alert_id: int) -> AlertOut: + return self._request("POST", f"/alerts/{alert_id}/ack", AlertOut, json=AckIn(doctor_id=self.doctor_id)) + + def connect(self, body: ConnectPatientIn) -> ConnectPatientOut: + return self._request("POST", "/patients/connect", ConnectPatientOut, json=body) + + def clock(self) -> ClockOut | None: + """Demo clock (ADR-005); optional decoration, so failures return None instead of raising.""" + try: + return self._get("/demo/clock", ClockOut) + except ApiError: + return None + + # ------------------------------------------------------------------ plumbing + def _get(self, path: str, model: type[T], params: dict[str, Any] | None = None) -> T: + return self._request("GET", path, model, params=params) + + def _request( + self, + method: str, + path: str, + model: type[T], + *, + params: dict[str, Any] | None = None, + json: BaseModel | None = None, + ) -> T: + payload = json.model_dump(mode="json") if json is not None else None + try: + resp = self._http.request(method, path, params=params, json=payload) + except (httpx.ConnectError, httpx.TimeoutException) as exc: + log.warning("api unavailable: %s %s (%s)", method, _route(path), type(exc).__name__) + raise ApiUnavailable("API недоступен — проверьте, что сервис запущен") from exc + except httpx.HTTPError as exc: + log.warning("api transport error: %s %s (%s)", method, _route(path), type(exc).__name__) + raise ApiUnavailable("API недоступен — ошибка соединения") from exc + + if resp.status_code == 404: + raise ApiError("Не найдено", 404) + if resp.status_code >= 400: + log.warning("api error: %s %s -> %s", method, _route(path), resp.status_code) + raise ApiError(f"Ошибка API ({resp.status_code})", resp.status_code) + try: + return TypeAdapter(model).validate_python(resp.json()) + except (ValueError, ValidationError) as exc: + log.warning("api bad payload: %s %s", method, _route(path)) + raise ApiError("API вернул неожиданный ответ") from exc + + def close(self) -> None: + self._http.close() + + +def _route(path: str) -> str: + """Path without identifiers, so logs carry no patient tokens.""" + parts = path.strip("/").split("/") + return "/" + "/".join(p if not any(ch.isdigit() for ch in p) and not p.startswith("tok") else "*" for p in parts) diff --git a/dashboard/app.py b/dashboard/app.py index 2dd111b..7eb34bc 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -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 `[letter]_.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() diff --git a/dashboard/components/charts.py b/dashboard/components/charts.py new file mode 100644 index 0000000..8f505b3 --- /dev/null +++ b/dashboard/components/charts.py @@ -0,0 +1,173 @@ +"""Plotly figures of the patient card. Pure builders: data in, `go.Figure` out (tested without Streamlit). + +Chart rules: one y-axis per chart (scales of different range are separate charts, daily metrics are small +multiples on a shared time axis), a single neutral series colour, status colours only for thresholds. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from datetime import date, timedelta + +import plotly.graph_objects as go +from plotly.subplots import make_subplots + +from app.api.schemas import SeriesPoint +from dashboard import theme +from dashboard.components.helpers import SCALE_MAX, metric_points, severity_bands, short_date + +# Band shading: one warm hue with growing opacity (sequential), so "higher = worse" reads without a legend. +_BAND_ALPHA = [0.0, 0.05, 0.09, 0.14, 0.20] + + +def _day_ticks(days: list[date], max_ticks: int = 10) -> dict[str, object]: + if not days: + return {} + first, last = min(days), max(days) + span = (last - first).days + 1 + step = max(1, -(-span // max_ticks)) + ticks = [first + timedelta(days=i) for i in range(0, span, step)] + return {"tickmode": "array", "tickvals": ticks, "ticktext": [short_date(d) for d in ticks]} + + +def _point_label(p: SeriesPoint) -> str: + return f"{p.value:g}" if p.scheduled else f"{p.value:g} · внепл." + + +def _therapy_day(d: date, start: date | None) -> str: + return f"день {(d - start).days}" if start else short_date(d) + + +def scale_figure( + series: Iterable[SeriesPoint], scale_id: str, title: str, start: date | None = None +) -> go.Figure | None: + """Total score over time with the severity gradations from config/scales; unscheduled sessions are hollow.""" + pts = metric_points(series, scale_id) + if not pts: + return None + top = SCALE_MAX.get(scale_id, max(p.value for p in pts)) + fig = go.Figure() + bands = severity_bands(scale_id) + for i, b in enumerate(bands): + alpha = _BAND_ALPHA[min(i, len(_BAND_ALPHA) - 1)] if len(bands) > 3 else [0.0, 0.08, 0.18][min(i, 2)] + fig.add_hrect(y0=b.lo, y1=b.hi + 1 if b.hi < top else top, line_width=0, fillcolor=f"rgba(214,69,69,{alpha})") + fig.add_annotation( + x=0, + xref="paper", + xanchor="left", + y=(b.lo + min(b.hi + 1, top)) / 2, + text=b.label, + showarrow=False, + font={"size": 10, "color": "rgba(122,130,140,0.85)"}, + ) + xs = [p.day for p in pts] + hover = [f"{short_date(p.day)} · {_therapy_day(p.day, start)}" for p in pts] + fig.add_trace( + go.Scatter( + x=xs, + y=[p.value for p in pts], + mode="lines+markers+text", + line={"color": theme.SERIES, "width": 2}, + marker={ + "size": [9 if p.scheduled else 13 for p in pts], + "symbol": ["circle" if p.scheduled else "diamond-open" for p in pts], + "color": theme.SERIES, + "line": {"width": [2 if p.scheduled else 2.5 for p in pts], "color": "#FFFFFF"}, + }, + text=[_point_label(p) if i == len(pts) - 1 else "" for i, p in enumerate(pts)], + textposition="top left", + textfont={"color": theme.INK, "size": 12}, + customdata=[[h, "плановый" if p.scheduled else "внеплановый"] for h, p in zip(hover, pts, strict=True)], + hovertemplate="%{customdata[0]}<br><b>%{y:g}</b> баллов · %{customdata[1]}<extra></extra>", + name=title, + ) + ) + pad = timedelta(days=1) + fig.update_layout( + theme.plotly_layout(title={"text": title, "font": {"size": 14, "color": theme.INK}, "x": 0}, height=260), + hovermode="closest", + ) + fig.update_xaxes(**theme.AXIS, **_day_ticks(xs, 4), tickangle=0, range=[min(xs) - pad, max(xs) + pad]) + fig.update_yaxes(**theme.AXIS, range=[0, top], fixedrange=True) + return fig + + +def wellbeing_figure(series: Iterable[SeriesPoint], start: date | None = None) -> go.Figure | None: + """Daily check-in as three small multiples on one time axis: mood 0–10, sleep hours, alcohol 0/1/2.""" + data = list(series) + mood, sleep, alcohol = (metric_points(data, m) for m in ("mood", "sleep_hours", "alcohol")) + if not (mood or sleep or alcohol): + return None + days = sorted({p.day for p in mood + sleep + alcohol}) + fig = make_subplots( + rows=3, + cols=1, + shared_xaxes=True, + vertical_spacing=0.09, + row_heights=[0.42, 0.33, 0.25], + subplot_titles=("Настроение, 0–10", "Сон, часов", "Алкоголь"), + ) + red_line = {"color": theme.TONES["red"].solid, "width": 1, "dash": "dot"} + + def hov(p: SeriesPoint) -> str: + return f"{short_date(p.day)} · {_therapy_day(p.day, start)}" + + if mood: + # Mood ≤ 3 for three days triggers an unscheduled PHQ-9 (config/rules.yaml) — shade that zone. + fig.add_hrect(y0=0, y1=3, line_width=0, fillcolor="rgba(214,69,69,0.08)", row=1, col=1) + fig.add_trace( + go.Scatter( + x=[p.day for p in mood], + y=[p.value for p in mood], + mode="lines+markers", + line={"color": theme.SERIES, "width": 2}, + marker={"size": 8, "color": theme.SERIES, "line": {"width": 2, "color": "#FFFFFF"}}, + customdata=[hov(p) for p in mood], + hovertemplate="%{customdata}<br>настроение <b>%{y:g}</b>/10<extra></extra>", + ), + row=1, + col=1, + ) + if sleep: + fig.add_trace( + go.Bar( + x=[p.day for p in sleep], + y=[p.value for p in sleep], + marker={"color": [theme.TONES["red"].solid if p.value < 4 else "#9DBFEA" for p in sleep]}, + marker_cornerradius=4, + customdata=[hov(p) for p in sleep], + hovertemplate="%{customdata}<br>сон ≈ <b>%{y:g}</b> ч<extra></extra>", + ), + row=2, + col=1, + ) + fig.add_hline(y=4, line=red_line, row=2, col=1) + if alcohol: + alc_label = {0: "нет", 1: "1–2 порции", 2: "больше"} + fig.add_trace( + go.Bar( + x=[p.day for p in alcohol], + y=[max(p.value, 0.08) for p in alcohol], + marker={ + "color": [ + {0: theme.LINE, 1: theme.TONES["yellow"].solid}.get(int(p.value), theme.TONES["red"].solid) + for p in alcohol + ] + }, + marker_cornerradius=4, + customdata=[[hov(p), alc_label.get(int(p.value), "?")] for p in alcohol], + hovertemplate="%{customdata[0]}<br>алкоголь: <b>%{customdata[1]}</b><extra></extra>", + ), + row=3, + col=1, + ) + fig.update_layout(theme.plotly_layout(height=430, bargap=0.25, hovermode="x")) + fig.update_annotations(font={"size": 12, "color": theme.INK_2}, x=0, xanchor="left") + pad = timedelta(hours=14) + fig.update_xaxes(**theme.AXIS, range=[days[0] - pad, days[-1] + pad]) + fig.update_xaxes(**_day_ticks(days), row=3, col=1) + fig.update_yaxes(**theme.AXIS, fixedrange=True) + fig.update_yaxes(range=[0, 10.5], tickvals=[0, 3, 5, 10], row=1, col=1) + fig.update_yaxes(range=[0, 9], tickvals=[0, 4, 8], row=2, col=1) + fig.update_yaxes(range=[0, 2.2], tickvals=[0, 1, 2], ticktext=["0", "1–2", "3+"], row=3, col=1) + return fig diff --git a/dashboard/components/helpers.py b/dashboard/components/helpers.py new file mode 100644 index 0000000..64c2780 --- /dev/null +++ b/dashboard/components/helpers.py @@ -0,0 +1,299 @@ +"""Pure presentation logic of the dashboard (no Streamlit calls), unit-tested in tests/test_dashboard_helpers.py. + +Clinical meaning stays on the backend: colours come from ADR-006 levels computed by the rules engine and the +discontinuation-risk index is computed server-side. Here we only order, label and reshape that data for display. +""" + +from __future__ import annotations + +import random +import re +from collections.abc import Iterable, Sequence +from dataclasses import dataclass, field +from datetime import date, datetime +from functools import cache +from pathlib import Path +from zoneinfo import ZoneInfo + +import yaml + +from app.api.schemas import AlertOut, DiscontinuationRisk, DoseOut, PatientListItem, SeriesPoint + +TZ = ZoneInfo("Europe/Moscow") +REPO_ROOT = Path(__file__).resolve().parents[2] + +# ADR-006 ordering for the doctor's attention: acute first, then high/moderate, then info-only, then quiet. +STATUS_ORDER = {"red": 0, "yellow": 1, "grey": 2, "green": 3} + +MODULE_LABELS = {"veteran": "ветеран", "depression": "депрессия", "family": "семья"} +LEVEL_LABELS = {"acute": "Кризис", "high": "Высокий", "moderate": "Умеренный", "info": "Инфо"} +MONTHS_SHORT = ["янв", "фев", "мар", "апр", "мая", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"] + +DRUGS: dict[str, list[str]] = { + "сертралин": ["25 мг", "50 мг", "100 мг", "150 мг"], + "пароксетин": ["10 мг", "20 мг", "30 мг"], + "эсциталопрам": ["5 мг", "10 мг", "20 мг"], + "флуоксетин": ["20 мг", "40 мг"], +} + + +# ------------------------------------------------------------------ list: ordering, filtering, KPIs +def sort_patients(items: Iterable[PatientListItem]) -> list[PatientListItem]: + """red → yellow → grey → green; inside a colour the higher discontinuation risk goes first.""" + return sorted(items, key=lambda p: (STATUS_ORDER[p.status_color], -p.discontinuation_risk.score, p.display_name)) + + +def filter_patients( + items: Iterable[PatientListItem], color: str | None = None, query: str = "" +) -> list[PatientListItem]: + q = query.strip().lower() + return [ + p + for p in items + if (color is None or p.status_color == color) + and (not q or q in p.display_name.lower() or q in (p.drug or "").lower()) + ] + + +@dataclass(frozen=True) +class Kpis: + total: int + red: int + yellow: int + grey: int + green: int + high_risk: int + + +def kpis(items: Sequence[PatientListItem]) -> Kpis: + by = {c: sum(1 for p in items if p.status_color == c) for c in STATUS_ORDER} + high = sum(1 for p in items if p.discontinuation_risk.level == "high") + return Kpis(len(items), by["red"], by["yellow"], by["grey"], by["green"], high) + + +# ------------------------------------------------------------------ small visual mappings +@dataclass(frozen=True) +class View: + text: str + tone: str # key of theme.TONES + hint: str = "" + + +def trend_view(trend: str) -> View: + """Direction of symptom scores; "up" means symptoms got worse (schemas.PatientListItem.trend).""" + return { + "up": View("↑", "red", "симптомы нарастают"), + "down": View("↓", "green", "симптомы снижаются"), + "flat": View("→", "grey", "без динамики"), + }.get(trend, View("·", "grey", "мало данных")) + + +def risk_view(risk: DiscontinuationRisk) -> View: + """Badge for the rule-based discontinuation-risk index (score 0–100) with factors as a tooltip.""" + label, tone = {"high": ("Высокий", "red"), "medium": ("Средний", "yellow"), "low": ("Низкий", "green")}[risk.level] + factors = "\n".join(f"• {f}" for f in risk.factors) or "• значимых факторов нет" + return View(f"{label} · {risk.score}", tone, f"Риск прерывания терапии: {risk.score}/100\n{factors}") + + +def adherence_view(adherence: float | None) -> View: + """7-day adherence. ≥ 80 % is the usual "adherent" cut-off for antidepressants; < 60 % needs a call.""" + if adherence is None: + return View("—", "grey") + pct = round(adherence * 100) + tone = "green" if adherence >= 0.8 else "yellow" if adherence >= 0.6 else "red" + return View(f"{pct}%", tone) + + +def status_view(color: str, top_level: str | None = None) -> View: + if color == "red": + return View("Кризис", "red", "острый сигнал — связаться сегодня") + if color == "yellow": + return View("Внимание", "yellow", "врачу ≤ 24 ч или к визиту") + if color == "grey": + return View("Нет ответа", "grey", "молчание / только инфо-флаги") + return View("Стабильно", "green", "открытых сигналов нет") + + +def level_tone(level: str) -> str: + return {"acute": "red", "high": "yellow", "moderate": "yellow"}.get(level, "grey") + + +# ------------------------------------------------------------------ scales +@dataclass(frozen=True) +class Band: + lo: float + hi: float + code: str + label: str + + +SCALE_MAX = {"phq9": 27, "pcl5": 80} + + +@cache +def severity_bands(scale_id: str) -> tuple[Band, ...]: + """Severity gradations from config/scales/<id>.yaml (ADR-008: thresholds live in config, not code).""" + path = REPO_ROOT / "config" / "scales" / f"{scale_id}.yaml" + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except OSError: + return () + rows = data.get("severity", []) + return tuple(Band(float(b["min"]), float(b["max"]), str(b["code"]), str(b["label"])) for b in rows) + + +def severity_label(scale_id: str, value: float | None) -> str: + if value is None: + return "" + for b in severity_bands(scale_id): + if b.lo <= value <= b.hi: + return b.label + return "" + + +def metric_points(series: Iterable[SeriesPoint], metric: str) -> list[SeriesPoint]: + return sorted((p for p in series if p.metric == metric), key=lambda p: p.day) + + +def last_and_prev(series: Iterable[SeriesPoint], metric: str) -> tuple[float | None, float | None]: + pts = metric_points(series, metric) + last = pts[-1].value if pts else None + prev = pts[-2].value if len(pts) > 1 else None + return last, prev + + +# ------------------------------------------------------------------ dose calendar +@dataclass +class DoseGrid: + """Heat-strip of doses: one column per calendar day, one row per scheduled time of day.""" + + days: list[date] = field(default_factory=list) + slots: list[str] = field(default_factory=list) + cells: dict[tuple[str, date], str] = field(default_factory=dict) + counts: dict[str, int] = field(default_factory=dict) + + @property + def adherence(self) -> float | None: + """Share of resolved doses taken (pending doses are not counted yet).""" + resolved = sum(self.counts.get(s, 0) for s in ("taken", "missed", "skipped")) + return self.counts.get("taken", 0) / resolved if resolved else None + + +def _local(dt: datetime) -> datetime: + return dt.astimezone(TZ) if dt.tzinfo else dt + + +def dose_grid(doses: Iterable[DoseOut]) -> DoseGrid: + grid = DoseGrid(counts={"taken": 0, "missed": 0, "skipped": 0, "pending": 0}) + items = sorted(doses, key=lambda d: d.planned_at) + if not items: + return grid + days: set[date] = set() + slots: set[str] = set() + for d in items: + local = _local(d.planned_at) + day, slot = local.date(), local.strftime("%H:%M") + days.add(day) + slots.add(slot) + grid.cells[(slot, day)] = d.status + grid.counts[d.status] += 1 + first, last = min(days), max(days) + grid.days = [date.fromordinal(o) for o in range(first.toordinal(), last.toordinal() + 1)] + grid.slots = sorted(slots) + return grid + + +# ------------------------------------------------------------------ alerts +def apply_local_acks(alerts: Iterable[AlertOut], acks: dict[int, tuple[str, datetime]]) -> list[AlertOut]: + """Overlay acks done in this session (so the UI reflects the click even before the next poll).""" + out = [] + for a in alerts: + if a.acked_at is None and a.id in acks: + by, at = acks[a.id] + a = a.model_copy(update={"acked_by": by, "acked_at": at}) + out.append(a) + return out + + +def split_new(alerts: Iterable[AlertOut], seen: set[int]) -> list[AlertOut]: + return sorted((a for a in alerts if a.id not in seen), key=lambda a: a.id) + + +def sort_alerts(alerts: Iterable[AlertOut]) -> list[AlertOut]: + """Open first (acute before others), then newest first.""" + rank = {"acute": 0, "high": 1, "moderate": 2, "info": 3} + return sorted(alerts, key=lambda a: (a.acked_at is not None, rank.get(a.level, 9), -a.ts.timestamp())) + + +# ------------------------------------------------------------------ Russian formatting +def plural_ru(n: int, one: str, few: str, many: str) -> str: + n = abs(n) + if n % 10 == 1 and n % 100 != 11: + return one + if 2 <= n % 10 <= 4 and not 12 <= n % 100 <= 14: + return few + return many + + +def age_label(age: int) -> str: + if age <= 0: + return "возраст не указан" + return f"{age} {plural_ru(age, 'год', 'года', 'лет')}" + + +def short_date(d: date) -> str: + return f"{d.day} {MONTHS_SHORT[d.month - 1]}" + + +def when_label(dt: datetime, today: date | None = None) -> str: + local = _local(dt) + day = "сегодня" if today and local.date() == today else short_date(local.date()) + return f"{day}, {local:%H:%M}" + + +def visit_label(visit: date | None, today: date | None) -> str: + if visit is None: + return "визит не назначен" + if today is None: + return f"визит {short_date(visit)}" + delta = (visit - today).days + if delta == 0: + return "визит сегодня" + if delta < 0: + return f"визит {short_date(visit)} (просрочен)" + return f"визит {short_date(visit)} · через {delta} {plural_ru(delta, 'день', 'дня', 'дней')}" + + +# ------------------------------------------------------------------ connect form +def absolute_link(link: str, public_url: str) -> str: + """The API may return a relative web-console link (`/console?code=…`); a QR needs an absolute URL.""" + if link.startswith(("http://", "https://", "tg://")): + return link + return public_url.rstrip("/") + "/" + link.lstrip("/") + + +def snils_checksum(digits: str) -> int: + """Control number of a SNILS (Pension Fund algorithm): Σ digit·(9−i), then mod 101, 100/101 → 0.""" + s = sum(int(ch) * (9 - i) for i, ch in enumerate(digits)) + if s < 100: + return s + if s in (100, 101): + return 0 + s %= 101 + return 0 if s == 100 else s + + +def format_snils(digits: str, check: int) -> str: + return f"{digits[0:3]}-{digits[3:6]}-{digits[6:9]} {check:02d}" + + +def is_valid_snils(value: str) -> bool: + d = re.sub(r"\D", "", value) + return len(d) == 11 and snils_checksum(d[:9]) == int(d[9:]) + + +def synthetic_snils(rng: random.Random | None = None) -> str: + """Random checksum-valid SNILS for synthetic test patients. Starts with 000 so it never looks like a real one.""" + r = rng or random.Random() + digits = "000" + "".join(str(r.randint(0, 9)) for _ in range(6)) + return format_snils(digits, snils_checksum(digits)) diff --git a/dashboard/components/live_alerts.py b/dashboard/components/live_alerts.py new file mode 100644 index 0000000..ed52e43 --- /dev/null +++ b/dashboard/components/live_alerts.py @@ -0,0 +1,85 @@ +"""Live alerts across all pages: polls the API every few seconds, toasts new alerts, pins open acute ones on top. + +Uses the built-in `st.fragment(run_every=...)`: only this fragment re-runs on the timer, the page is untouched +unless a new alert arrives on a doctor page (then the whole app reruns so the list and the card pick it up). +""" + +from __future__ import annotations + +from html import escape + +import streamlit as st + +from app.api.schemas import AlertOut +from dashboard import theme +from dashboard.api_client import ApiError +from dashboard.components import helpers as h +from dashboard.components import state, ui + +POLL_SECONDS = 2.5 +MAX_BANNER = 3 + + +def _toast(a: AlertOut) -> None: + icon = theme.TONES[h.level_tone(a.level)].icon + st.toast(f"**{a.patient_name}** — {a.reason}", icon=icon, duration="long" if a.level == "acute" else "short") + + +def flush_pending_toasts() -> None: + """Toasts queued right before a full-app rerun are shown on the next run (a rerun drops pending deltas).""" + for a in st.session_state.pop("pending_toasts", []): + _toast(a) + + +def _banner(acute: list[AlertOut]) -> None: + for a in acute[:MAX_BANNER]: + left, right = st.columns([8, 1.6], vertical_alignment="center") + with left: + st.html( + ui.banner( + f"<b>Кризисный сигнал · {escape(a.patient_name)}</b> — {escape(a.reason)}" + f'<div style="font-size:.8rem;margin-top:2px">{escape(h.when_label(a.ts))} · ' + "по протоколу — связаться с пациентом сегодня</div>", + tone="red", + pulse=True, + ) + ) + with right: + if st.button( + "Карточка", key=f"acute-open-{a.id}", type="primary", icon=":material/arrow_forward:", width="stretch" + ): + st.switch_page(state.PATIENTS_PAGE, query_params={"patient": a.token}) + if len(acute) > MAX_BANNER: + st.caption(f"Ещё кризисных сигналов: {len(acute) - MAX_BANNER}") + + +def render(*, rerun_app_on_new: bool = True) -> None: + """Place once per run (app.py does it above every page).""" + + @st.fragment(run_every=POLL_SECONDS) + def _poll() -> None: + ss = st.session_state + try: + alerts = state.client().alerts(open_only=True) + except ApiError: + return # pages show their own "API недоступен" banner; the poller just retries next tick + acked = state.local_acks() + alerts = [a for a in alerts if a.acked_at is None and a.id not in acked] + + seen: set[int] | None = ss.get("seen_alert_ids") + if seen is None: + # First poll of the session: what is already open is not "new" — no toast storm on page load. + ss["seen_alert_ids"] = {a.id for a in alerts} + new: list[AlertOut] = [] + else: + new = h.split_new(alerts, seen) + seen.update(a.id for a in new) + + if new and rerun_app_on_new: + ss["pending_toasts"] = ss.get("pending_toasts", []) + new + st.rerun(scope="app") + for a in new: + _toast(a) + _banner(h.sort_alerts(a for a in alerts if a.level == "acute")) + + _poll() diff --git a/dashboard/components/patient_card.py b/dashboard/components/patient_card.py new file mode 100644 index 0000000..91b7764 --- /dev/null +++ b/dashboard/components/patient_card.py @@ -0,0 +1,212 @@ +"""Patient card: header, key numbers, charts, dose calendar, alerts feed with ack, safety plan, AI summary.""" + +from __future__ import annotations + +from datetime import date +from html import escape + +import streamlit as st + +from app.api.schemas import AlertOut, PatientCard +from dashboard import theme +from dashboard.api_client import ApiError +from dashboard.components import charts, state, ui +from dashboard.components import helpers as h + + +def dose_strip_html(grid: h.DoseGrid, start: date | None) -> str: + """Heat-strip: columns = days, rows = scheduled times. Glyphs duplicate colour for colour-blind readers.""" + if not grid.days: + return '<div class="mt-dim">Данных о приёме пока нет</div>' + glyph = {"taken": "✓", "missed": "✕", "skipped": "–", "pending": "·"} + label = {"taken": "принял", "missed": "нет ответа", "skipped": "пропустил", "pending": "ожидается"} + cols = f"56px repeat({len(grid.days)}, minmax(18px, 1fr))" + cells = ['<div class="lab"></div>'] + cells += [f'<div class="lab">{d.day}</div>' for d in grid.days] + for slot in grid.slots: + cells.append(f'<div class="lab" style="text-align:left">{slot}</div>') + for d in grid.days: + status = grid.cells.get((slot, d)) + if status is None: + cells.append('<div class="cell c-none"></div>') + continue + day = f" · день {(d - start).days}" if start else "" + tip = f"{h.short_date(d)}{day}, {slot}: {label[status]}" + cells.append(f'<div class="cell c-{status}" title="{escape(tip)}">{glyph[status]}</div>') + c = grid.counts + legend = ( + '<div class="mt-legend">' + f'<span><i style="background:var(--green)"></i>принял · {c["taken"]}</span>' + f'<span><i style="background:var(--yellow)"></i>пропустил · {c["skipped"]}</span>' + f'<span><i style="background:var(--red)"></i>нет ответа · {c["missed"]}</span>' + f'<span><i style="border:1.5px dashed var(--grey)"></i>ожидается · {c["pending"]}</span>' + "</div>" + ) + return f'<div class="mt-doses" style="grid-template-columns:{cols}">{"".join(cells)}</div>{legend}' + + +def _tile(label: str, value: str, hint: str = "", tone: str = "blue") -> str: + return ( + f'<div class="mt-kpi" style="--kpi-accent:{theme.TONES[tone].solid}"><div class="lbl">{escape(label)}</div>' + f'<div class="val">{value}</div><div class="hint">{escape(hint)}</div></div>' + ) + + +def _score_tile(card: PatientCard, scale_id: str, title: str) -> str: + last, prev = h.last_and_prev(card.series, scale_id) + if last is None: + return _tile(title, '<span class="mt-dim">—</span>', "не проводился", "grey") + delta = "" + if prev is not None and last != prev: + arrow = "↑" if last > prev else "↓" + tone = "red" if last > prev else "green" + delta = f' <span style="font-size:1rem;color:{theme.TONES[tone].ink}">{arrow} с {prev:g}</span>' + sev = h.severity_label(scale_id, last) + return _tile(title, f"{last:g}{delta}", sev, "red" if last > (prev or last) else "blue") + + +def _header(card: PatientCard, today: date | None) -> None: + p = card.patient + status = h.status_view(p.status_color) + risk = h.risk_view(p.discontinuation_risk) + chips = "".join(ui.chip(h.MODULE_LABELS.get(m, m)) for m in p.modules) + meta = ( + f"{h.age_label(p.age)} · {chips} · {escape(p.drug or 'без назначения')} · день терапии {p.therapy_day}" + f" · {h.visit_label(p.next_visit, today)}" + ) + st.html( + '<div class="mt-head"><div>' + f'<div style="margin-bottom:6px">{ui.badge(status.text, status.tone, status.hint, icon="dot")}</div>' + f'<h1>{escape(p.display_name)}</h1><div class="mt-sub">{meta}</div></div>' + f'<div class="mt-side"><div class="mt-kicker" style="margin-bottom:4px">Риск прерывания терапии</div>' + f"{ui.badge(risk.text, risk.tone, risk.hint)}" + f'<div class="mt-meta">{escape(", ".join(p.discontinuation_risk.factors))}</div>' + "</div></div>" + ) + + +def _ack(alert: AlertOut) -> None: + try: + res = state.client().ack(alert.id) + except ApiError as exc: + st.toast(f"Не удалось подтвердить: {exc.message}", icon="⚠️") + return + state.local_acks()[alert.id] = (res.acked_by or state.client().doctor_id, res.acked_at or state.now() or alert.ts) + st.toast(f"Алерт подтверждён: {alert.reason}", icon="✅") + + +def _alerts_feed(alerts: list[AlertOut], today: date | None) -> None: + st.html('<div class="mt-panel-title">Алерты</div>') + if not alerts: + st.caption("Сигналов нет") + return + for a in h.sort_alerts(alerts): + tone = h.level_tone(a.level) + acked = a.acked_at is not None + level = h.LEVEL_LABELS.get(a.level, a.level) + status = f"подтверждён {h.when_label(a.acked_at, today)}" if a.acked_at else "открыт" + st.html( + f'<div class="mt-alert t-{tone}{" acked" if acked else ""}">' + f'<div class="when">{theme.TONES[tone].icon} {level} · {escape(h.when_label(a.ts, today))}</div>' + f'<div class="why">{escape(a.reason)}</div>' + f'<div class="when">{escape(status)}</div></div>' + ) + if not acked: + st.button( + "Подтвердить", + key=f"ack-{a.id}", + on_click=_ack, + args=(a,), + icon=":material/check:", + type="primary" if a.level == "acute" else "secondary", + help="Врач видел сигнал и принял меры (запись в журнал аудита)", + ) + + +def _summary(text: str) -> None: + st.html('<div class="mt-panel-title">Саммари (ИИ, по структурированным данным)</div>') + body = escape(text) if text else '<span class="mt-dim">Саммари ещё не сформировано</span>' + st.html( + f'<div class="mt-summary">{body}<div class="disc">Сформировано по структурированным данным ' + "(шкалы, чек-ины, приём, алерты) без свободного текста пациента. Решения принимает врач.</div></div>" + ) + + +def _safety_plan(plan: list[str] | None) -> None: + st.html('<div class="mt-panel-title">План безопасности</div>') + if not plan: + st.caption("Не составлен. Составляется с пациентом при острых сигналах (Stanley–Brown).") + return + items = "".join(f"<li>{escape(step)}</li>" for step in plan) + st.html(f'<div class="mt-plan"><ol>{items}</ol></div>') + + +def _prescriptions(card: PatientCard) -> None: + st.html('<div class="mt-panel-title">Назначения</div>') + if not card.prescriptions: + st.caption(card.patient.drug or "Нет активных назначений") + return + for rx in card.prescriptions: + times = ", ".join(rx.schedule.get("times", [])) + since = f" · с {h.short_date(rx.start_date)}" if rx.start_date else "" + st.html( + f'<div style="margin-bottom:6px"><b>{escape(rx.drug)} {escape(rx.dose)}</b>' + f'<div class="mt-meta">{escape(times)} · {escape(rx.instructions or "без особых указаний")}{since}</div>' + "</div>" + ) + + +def render(card: PatientCard, today: date | None) -> None: + card = card.model_copy(update={"alerts": h.apply_local_acks(card.alerts, state.local_acks())}) + start = card.prescriptions[0].start_date if card.prescriptions else None + _header(card, today) + + grid = h.dose_grid(card.doses) + adh = h.adherence_view(card.patient.adherence_7d) + open_alerts = [a for a in card.alerts if a.acked_at is None] + taken = f"{grid.counts['taken']} из {sum(grid.counts[s] for s in ('taken', 'missed', 'skipped'))} доз за курс" + st.html( + '<div class="mt-kpis">' + + _score_tile(card, "phq9", "PHQ-9 · депрессия") + + _score_tile(card, "pcl5", "PCL-5 · ПТСР") + + _tile("Приём за 7 дней", adh.text, taken if grid.days else "", adh.tone) + + _tile( + "Открытые алерты", + str(len(open_alerts)), + "есть кризисный" if any(a.level == "acute" for a in open_alerts) else "", + "red" if any(a.level == "acute" for a in open_alerts) else ("yellow" if open_alerts else "green"), + ) + + "</div>" + ) + + main, side = st.columns([1.75, 1], gap="large") + with main: + with st.container(border=True): + c1, c2 = st.columns(2) + for col, scale_id, title in ((c1, "phq9", "PHQ-9"), (c2, "pcl5", "PCL-5")): + fig = charts.scale_figure(card.series, scale_id, title, start) + with col: + if fig is None: + st.caption("Шкала ещё не проводилась") + else: + st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key=f"chart-{scale_id}") + st.caption("◇ — внеплановое заполнение (по триггеру правил). Полосы — градации тяжести из config/scales.") + with st.container(border=True): + fig = charts.wellbeing_figure(card.series, start) + if fig is None: + st.caption("Ежедневных чек-инов пока нет") + else: + st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key="chart-wellbeing") + st.caption("Красная зона настроения ≤ 3 (3 дня → внеплановый PHQ-9); пунктир — сон 4 ч.") + with st.container(border=True): + st.html('<div class="mt-panel-title">Календарь приёма</div>') + st.html(dose_strip_html(grid, start)) + with side: + with st.container(border=True): + _alerts_feed(card.alerts, today) + with st.container(border=True): + _summary(card.summary) + with st.container(border=True): + _safety_plan(card.safety_plan) + with st.container(border=True): + _prescriptions(card) diff --git a/dashboard/components/state.py b/dashboard/components/state.py new file mode 100644 index 0000000..a67b475 --- /dev/null +++ b/dashboard/components/state.py @@ -0,0 +1,62 @@ +"""Session-level plumbing shared by all dashboard pages: API client, demo "today", local acks, error banners.""" + +from __future__ import annotations + +import os +from collections.abc import Callable +from datetime import date, datetime +from html import escape +from typing import TypeVar + +import streamlit as st + +from dashboard.api_client import ApiClient, ApiError, ApiUnavailable +from dashboard.components import ui + +T = TypeVar("T") + +# Short doctor names for the header; no other doctor PII is shown. +DOCTORS = {"doc-smirnova": ("д-р Смирнова Е. А.", "д-ра Смирновой Е. А.")} + +PATIENTS_PAGE = "pages/1_Пациенты.py" +CONNECT_PAGE = "pages/1b_Подключить_пациента.py" + + +@st.cache_resource +def _client(base_url: str | None, doctor_id: str | None) -> ApiClient: + return ApiClient(base_url, doctor_id) + + +def client() -> ApiClient: + return _client(os.environ.get("API_BASE_URL"), os.environ.get("DOCTOR_ID")) + + +def doctor_names() -> tuple[str, str]: + did = client().doctor_id + return DOCTORS.get(did, (did, did)) + + +def now() -> datetime | None: + """Demo clock from the API (ADR-005), so day 13 of the scenario is "today" on screen; None if unknown.""" + c = client().clock() + return c.now if c else None + + +def today() -> date | None: + n = now() + return n.date() if n else None + + +def local_acks() -> dict[int, tuple[str, datetime]]: + return st.session_state.setdefault("acked_local", {}) + + +def call(fn: Callable[[], T]) -> T | None: + """Run an API call; on failure show a calm banner instead of a traceback and return None.""" + try: + return fn() + except ApiUnavailable as exc: + st.html(ui.banner(f"<b>API недоступен.</b> {escape(exc.message)}. Данные обновятся автоматически.", "grey")) + except ApiError as exc: + st.html(ui.banner(f"<b>Не удалось загрузить данные.</b> {escape(exc.message)}", "grey")) + return None diff --git a/dashboard/components/ui.py b/dashboard/components/ui.py new file mode 100644 index 0000000..e7884b5 --- /dev/null +++ b/dashboard/components/ui.py @@ -0,0 +1,109 @@ +"""HTML fragments of the dashboard. Builders return strings (testable); `st.html` renders them. + +Every value coming from the API is escaped: patient free text never reaches these views, but the API is still +treated as untrusted input. +""" + +from __future__ import annotations + +from html import escape + +import streamlit as st + +from app.api.schemas import PatientListItem +from dashboard import theme +from dashboard.components import helpers as h + + +def chip(text: str) -> str: + return f'<span class="mt-chip">{escape(text)}</span>' + + +def badge(text: str, tone: str, tip: str = "", icon: str = "") -> str: + tip_attr = f' data-tip="{escape(tip)}"' if tip else "" + cls = " mt-tip" if tip else "" + lead = f'<span class="mt-dot" style="background:{theme.TONES[tone].solid}"></span>' if icon == "dot" else icon + return f'<span class="mt-badge t-{tone}{cls}"{tip_attr}>{lead}{escape(text)}</span>' + + +def bar(fraction: float | None, tone: str) -> str: + pct = 0 if fraction is None else max(0, min(100, round(fraction * 100))) + return f'<div class="mt-bar"><span style="width:{pct}%;background:{theme.TONES[tone].solid}"></span></div>' + + +def page_header(title: str, subtitle: str = "", kicker: str = "") -> str: + k = f'<div class="mt-kicker">{escape(kicker)}</div>' if kicker else "" + s = f'<div class="mt-sub">{subtitle}</div>' if subtitle else "" + return f'<div class="mt-head"><div>{k}<h1>{escape(title)}</h1>{s}</div></div>' + + +def kpi_tiles(k: h.Kpis) -> str: + tiles = [ + ("На сопровождении", str(k.total), "пациентов в кабинете", theme.TONES["blue"].solid), + ("🔴 Красная зона", str(k.red), "острый сигнал — связаться сегодня", theme.TONES["red"].solid), + ("🟡 Жёлтая зона", str(k.yellow), "врачу ≤ 24 ч или к визиту", theme.TONES["yellow"].solid), + ("Риск прерывания терапии", str(k.high_risk), "высокий индекс (правила, не ML)", theme.TONES["red"].solid), + ] + body = "".join( + f'<div class="mt-kpi" style="--kpi-accent:{accent}"><div class="lbl">{escape(lbl)}</div>' + f'<div class="val">{val}</div><div class="hint">{escape(hint)}</div></div>' + for lbl, val, hint, accent in tiles + ) + return f'<div class="mt-kpis">{body}</div>' + + +LIST_COLUMNS = ["Пациент", "Терапия", "PHQ-9", "PCL-5", "Приём 7 дн.", "Риск прерывания", "Последнее событие", "Алерты"] + + +def list_header() -> str: + return '<div class="mt-cols">' + "".join(f"<div>{c}</div>" for c in LIST_COLUMNS) + "</div>" + + +def _score(value: int | None, trend: str) -> str: + if value is None: + return '<span class="mt-dim">—</span>' + t = h.trend_view(trend) + return ( + f'<span class="mt-num">{value}</span> ' + f'<span class="mt-tip" data-tip="{escape(t.hint)}" style="color:{theme.TONES[t.tone].ink};font-weight:700">' + f"{t.text}</span>" + ) + + +def patient_row(p: PatientListItem) -> str: + """One row of the patient list; the grid matches `list_header()`.""" + status = h.status_view(p.status_color) + tone = theme.TONES[status.tone] + chips = "".join(chip(h.MODULE_LABELS.get(m, m)) for m in p.modules) + adh = h.adherence_view(p.adherence_7d) + risk = h.risk_view(p.discontinuation_risk) + if p.open_alerts: + count_style = f"background:{tone.tint};color:{tone.ink}" + else: + count_style = f"background:{theme.TONES['grey'].tint};color:{theme.INK_3}" + return ( + '<div class="mt-row">' + f'<div><div class="mt-name"><span class="mt-dot mt-tip" data-tip="{escape(status.text + " — " + status.hint)}"' + f' style="background:{tone.solid}"></span>{escape(p.display_name)}</div>' + f'<div class="mt-meta">{h.age_label(p.age)} · {chips}</div></div>' + f'<div>{escape(p.drug or "—")}<div class="mt-meta">день терапии {p.therapy_day}</div></div>' + f"<div>{_score(p.phq9_last, p.trend)}</div>" + f"<div>{_score(p.pcl5_last, p.trend)}</div>" + f'<div><span class="mt-num">{adh.text}</span>{bar(p.adherence_7d, adh.tone)}</div>' + f"<div>{badge(risk.text, risk.tone, risk.hint)}</div>" + f'<div class="mt-event">{escape(p.last_event or "—")}</div>' + f'<div><span class="mt-count" style="{count_style}">{p.open_alerts}</span></div>' + "</div>" + ) + + +def banner(html_body: str, tone: str = "red", pulse: bool = False) -> str: + lead = '<span class="mt-pulse"></span>' if pulse else "" + return f'<div class="mt-banner t-{tone}">{lead}<div>{html_body}</div></div>' + + +def footer() -> None: + st.html( + '<div class="mt-footer">Прототип на синтетических данных · MedITron · ' + "ИИ не ставит диагнозы и не назначает лечение — решения принимает врач</div>" + ) diff --git a/dashboard/pages/1_Пациенты.py b/dashboard/pages/1_Пациенты.py new file mode 100644 index 0000000..90f9da6 --- /dev/null +++ b/dashboard/pages/1_Пациенты.py @@ -0,0 +1,97 @@ +"""Doctor's patient list ("red zones", idea 2) and, with `?patient=<token>`, the patient card.""" + +from __future__ import annotations + +import streamlit as st + +from dashboard.components import helpers as h +from dashboard.components import patient_card, state, ui + +NAV_ICON = ":material/groups:" + +COLOR_FILTERS = { + "Все": None, + "🔴 Красные": "red", + "🟡 Жёлтые": "yellow", + "⚪ Нет ответа": "grey", + "🟢 Стабильные": "green", +} + + +def _open(token: str) -> None: + st.query_params["patient"] = token + + +def _close() -> None: + st.query_params.pop("patient", None) + + +def render_card(token: str) -> None: + st.button("Все пациенты", icon=":material/arrow_back:", type="tertiary", on_click=_close) + card = state.call(lambda: state.client().patient_card(token)) + if card is None: + return + patient_card.render(card, state.today()) + + +def render_list() -> None: + _, doctor_gen = state.doctor_names() + today = state.today() + patients = state.call(lambda: state.client().patients()) + subtitle = "Пациенты на сопровождении между визитами — сначала те, кому нужно внимание сегодня" + if today: + subtitle += f" · данные на {h.short_date(today)}" + + head, action = st.columns([4, 1.4], vertical_alignment="bottom") + with head: + st.html(ui.page_header(f"Кабинет {doctor_gen}", subtitle, kicker="Красные зоны")) + with action: + if st.button("Подключить пациента", icon=":material/person_add:", type="primary", width="stretch"): + st.switch_page(state.CONNECT_PAGE) + if patients is None: + return + + items = h.sort_patients(patients) + st.html(ui.kpi_tiles(h.kpis(items))) + + f1, f2 = st.columns([3, 1.4], vertical_alignment="bottom") + with f1: + choice = st.segmented_control( + "Статус", list(COLOR_FILTERS), default="Все", key="status_filter", label_visibility="collapsed" + ) + with f2: + query = st.text_input( + "Поиск", placeholder="Поиск по имени или препарату", key="patient_query", label_visibility="collapsed" + ) + shown = h.filter_patients(items, COLOR_FILTERS.get(choice or "Все"), query or "") + + head_cols = st.columns([11, 1.5]) + with head_cols[0]: + st.html(ui.list_header()) + if not shown: + st.caption("Нет пациентов с выбранным статусом") + for p in shown: + with st.container(key=f"prow-{p.status_color}-{p.token}"): + row, btn = st.columns([11, 1.5], vertical_alignment="center") + with row: + st.html(ui.patient_row(p)) + with btn: + st.button( + "Открыть", + key=f"open-{p.token}", + on_click=_open, + args=(p.token,), + type="primary" if p.status_color == "red" else "secondary", + width="stretch", + ) + st.caption( + "Цвет — уровень открытых алертов (ADR-006): 🔴 кризис · 🟡 высокий/умеренный · ⚪ только инфо · 🟢 нет. " + "Риск прерывания — индекс правил (пропуски, побочки, намерение бросить, низкое настроение), не ML." + ) + + +token = st.query_params.get("patient") +if token: + render_card(token) +else: + render_list() diff --git a/dashboard/pages/1b_Подключить_пациента.py b/dashboard/pages/1b_Подключить_пациента.py new file mode 100644 index 0000000..18386b1 --- /dev/null +++ b/dashboard/pages/1b_Подключить_пациента.py @@ -0,0 +1,188 @@ +"""«Подключить пациента»: after the in-person visit the doctor enters the prescription → one-time code + QR.""" + +from __future__ import annotations + +import io +import os +from datetime import date, timedelta +from html import escape + +import qrcode +import streamlit as st + +from app.api.schemas import ConnectPatientIn, ConnectPatientOut +from dashboard.components import helpers as h +from dashboard.components import state, ui + +NAV_ICON = ":material/person_add:" + +TIME_OPTIONS = ["07:00", "08:00", "09:00", "10:00", "12:00", "14:00", "18:00", "20:00", "21:00", "22:00"] +MODULE_OPTIONS = ["veteran", "depression", "family"] +DEMO = { + "cp_name": "Андрей С.", + "cp_year": 1992, + "cp_sex": "м", + "cp_drug": "сертралин", + "cp_dose": "50 мг", + "cp_times": ["08:00"], + "cp_instr": "утром после еды", + "cp_modules": ["veteran", "depression"], +} + + +def qr_png(data: str) -> bytes: + img = qrcode.make(data, box_size=8, border=2) + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + +def _defaults(today: date) -> None: + ss = st.session_state + ss.setdefault("cp_name", "") + ss.setdefault("cp_year", 1990) + ss.setdefault("cp_sex", "м") + ss.setdefault("cp_snils", h.synthetic_snils()) + ss.setdefault("cp_drug", "сертралин") + ss.setdefault("cp_times", ["08:00"]) + ss.setdefault("cp_instr", "утром после еды") + ss.setdefault("cp_visit", today + timedelta(days=28)) + ss.setdefault("cp_modules", ["veteran"]) + + +def _fill_demo(today: date) -> None: + st.session_state.update(DEMO) + st.session_state["cp_snils"] = h.synthetic_snils() + st.session_state["cp_visit"] = today + timedelta(days=28) + + +def _new_snils() -> None: + st.session_state["cp_snils"] = h.synthetic_snils() + + +def _reset() -> None: + st.session_state.pop("cp_result", None) + for k in [*DEMO, "cp_snils", "cp_visit", "cp_consent"]: + st.session_state.pop(k, None) + + +def _result(res: ConnectPatientOut, name: str) -> None: + link = h.absolute_link(res.deep_link, os.environ.get("DASHBOARD_PUBLIC_URL", "http://localhost:8501")) + with st.container(border=True): + st.html( + f'<div class="mt-kicker">Пациент подключён · {escape(name)}</div>' + '<div class="mt-sub" style="margin:4px 0 10px">Покажите QR пациенту или продиктуйте код — бот спросит ' + "согласие и начнёт сопровождение. Код одноразовый.</div>" + ) + c1, c2 = st.columns([1, 1.4], vertical_alignment="center") + with c1: + st.image(qr_png(link), width=220, caption="QR для подключения") + with c2: + st.html(f'<div class="mt-panel-title">Код подключения</div><div class="mt-code">{escape(res.code)}</div>') + st.link_button("Открыть ссылку", link, icon=":material/open_in_new:") + st.code(link, language=None) + b1, b2 = st.columns(2) + with b1: + if st.button("Карточка пациента", icon=":material/person:", width="stretch"): + st.switch_page(state.PATIENTS_PAGE, query_params={"patient": res.token}) + with b2: + st.button("Подключить ещё", icon=":material/add:", on_click=_reset, width="stretch") + + +def main() -> None: + today = state.today() or date.today() + _defaults(today) + st.html( + ui.page_header( + "Подключить пациента", + "После очного приёма: назначение уходит в МИС (мок), пациент получает код и начинает сопровождение. " + "Только синтетические данные.", + kicker="Кабинет врача", + ) + ) + res: ConnectPatientOut | None = st.session_state.get("cp_result") + if res is not None: + _result(res, st.session_state.get("cp_result_name", "")) + return + + st.button("Заполнить демо-данными (Андрей)", icon=":material/auto_fix_high:", on_click=_fill_demo, args=(today,)) + + left, right = st.columns(2, gap="large") + with left, st.container(border=True): + st.html('<div class="mt-panel-title">Пациент</div>') + st.text_input("Имя и инициал фамилии", key="cp_name", placeholder="Андрей С.", help="Без полного ФИО") + a, b = st.columns(2) + with a: + st.number_input("Год рождения", min_value=1940, max_value=today.year - 18, step=1, key="cp_year") + with b: + st.radio("Пол", ["м", "ж"], key="cp_sex", horizontal=True) + s1, s2 = st.columns([3, 1], vertical_alignment="bottom") + with s1: + st.text_input("СНИЛС (синтетический)", key="cp_snils") + with s2: + st.button("Новый", icon=":material/refresh:", on_click=_new_snils, width="stretch") + st.pills( + "Модули сопровождения", + MODULE_OPTIONS, + selection_mode="multi", + key="cp_modules", + format_func=lambda m: h.MODULE_LABELS[m], + ) + with right, st.container(border=True): + st.html('<div class="mt-panel-title">Назначение</div>') + d1, d2 = st.columns([1.4, 1]) + with d1: + drug = st.selectbox("Препарат (МНН)", list(h.DRUGS), key="cp_drug") + with d2: + doses = h.DRUGS[drug] + if st.session_state.get("cp_dose") not in doses: + st.session_state["cp_dose"] = doses[1] if len(doses) > 1 else doses[0] + st.selectbox("Доза", doses, key="cp_dose") + st.multiselect("Время приёма", TIME_OPTIONS, key="cp_times") + st.text_input("Указания", key="cp_instr") + st.date_input("Следующий визит", key="cp_visit", min_value=today, format="DD.MM.YYYY") + + consent = st.checkbox( + "Пациент подписал информированное добровольное согласие на дистанционное наблюдение (323-ФЗ, ст. 20, 36.2)", + key="cp_consent", + ) + if not st.button("Подключить и получить код", type="primary", icon=":material/qr_code_2:"): + return + + ss = st.session_state + errors = [] + if not ss["cp_name"].strip(): + errors.append("укажите имя") + if not h.is_valid_snils(ss["cp_snils"]): + errors.append("СНИЛС не проходит контрольную сумму — нажмите «Новый»") + if not ss["cp_times"]: + errors.append("выберите время приёма") + if not ss["cp_modules"]: + errors.append("выберите хотя бы один модуль") + if not consent: + errors.append("нужно согласие пациента") + if errors: + st.error("Проверьте форму: " + "; ".join(errors)) + return + + body = ConnectPatientIn( + doctor_id=state.client().doctor_id, + display_name=ss["cp_name"].strip(), + birth_year=int(ss["cp_year"]), + sex="m" if ss["cp_sex"] == "м" else "f", + snils=ss["cp_snils"], + drug=ss["cp_drug"], + dose=ss["cp_dose"], + times=sorted(ss["cp_times"]), + instructions=ss["cp_instr"], + next_visit=ss["cp_visit"], + modules=ss["cp_modules"], + ) + out = state.call(lambda: state.client().connect(body)) + if out is not None: + ss["cp_result"] = out + ss["cp_result_name"] = body.display_name + st.rerun() + + +main() diff --git a/dashboard/theme.py b/dashboard/theme.py new file mode 100644 index 0000000..7283d0d --- /dev/null +++ b/dashboard/theme.py @@ -0,0 +1,188 @@ +"""Visual system of the doctor dashboard: status palette (ADR-006), text tokens, CSS and the plotly template. + +Status colours are reserved for alert state (red = acute, yellow = high/moderate, green = nothing open, +grey = info only). They always ship together with an icon and a text label, never colour alone. +Chart series use a separate neutral blue so that a line is never confused with a status. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +Tone = Literal["red", "yellow", "green", "grey", "blue"] + + +@dataclass(frozen=True) +class ToneSpec: + solid: str # marks, stripes, dots + ink: str # text on light surface (WCAG AA on white / on the tint) + tint: str # badge / banner background + icon: str + label: str + + +TONES: dict[str, ToneSpec] = { + "red": ToneSpec("#D64545", "#A32D2D", "#FCEBEB", "🔴", "Красная зона"), + "yellow": ToneSpec("#E0A800", "#7F5A00", "#FFF5D6", "🟡", "Жёлтая зона"), + "green": ToneSpec("#2E9E5B", "#1D6E3F", "#E6F4EC", "🟢", "Стабильно"), + "grey": ToneSpec("#9AA0A6", "#565C64", "#F0F1F3", "⚪", "Нет ответа"), + "blue": ToneSpec("#2A78D6", "#1F5DA8", "#E8F1FC", "🔵", ""), +} + +INK = "#1B1F24" +INK_2 = "#4B535C" +INK_3 = "#7A828C" +SURFACE = "#FFFFFF" +CANVAS = "#F6F7F9" +LINE = "#E3E6EA" +SERIES = TONES["blue"].solid +FONT_STACK = "'Inter', 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif" + +CSS = f""" +<style> +:root {{ + --ink: {INK}; --ink-2: {INK_2}; --ink-3: {INK_3}; --line: {LINE}; --surface: {SURFACE}; --canvas: {CANVAS}; + --red: {TONES["red"].solid}; --red-ink: {TONES["red"].ink}; --red-tint: {TONES["red"].tint}; + --yellow: {TONES["yellow"].solid}; --yellow-ink: {TONES["yellow"].ink}; --yellow-tint: {TONES["yellow"].tint}; + --green: {TONES["green"].solid}; --green-ink: {TONES["green"].ink}; --green-tint: {TONES["green"].tint}; + --grey: {TONES["grey"].solid}; --grey-ink: {TONES["grey"].ink}; --grey-tint: {TONES["grey"].tint}; + --blue: {TONES["blue"].solid}; --blue-ink: {TONES["blue"].ink}; --blue-tint: {TONES["blue"].tint}; +}} +.stApp {{ background: var(--canvas); }} +.block-container {{ padding-top: 2.2rem; max-width: 1320px; }} +h1, h2, h3 {{ color: var(--ink); letter-spacing: -0.01em; }} + +/* ---------- page header ---------- */ +.mt-head {{ display:flex; justify-content:space-between; align-items:flex-end; gap:16px; flex-wrap:wrap; + margin: 0 0 6px 0; }} +.mt-head > div:first-child {{ flex: 1 1 420px; min-width: 0; }} +.mt-head .mt-side {{ flex: 0 1 320px; text-align: right; }} +.mt-head h1 {{ font-size: 1.75rem; font-weight: 700; margin: 0; padding: 0; line-height: 1.2; }} +.mt-sub {{ color: var(--ink-2); font-size: .95rem; margin-top: 4px; }} +.mt-kicker {{ color: var(--ink-3); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; + font-weight: 600; }} + +/* ---------- KPI tiles ---------- */ +.mt-kpis {{ display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 12px; margin: 14px 0 6px; }} +.mt-kpi {{ background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; + position: relative; overflow: hidden; }} +.mt-kpi::before {{ content:""; position:absolute; left:0; top:0; bottom:0; width:4px; background: var(--kpi-accent, + var(--line)); }} +.mt-kpi .lbl {{ color: var(--ink-2); font-size: .85rem; font-weight: 500; }} +.mt-kpi .val {{ color: var(--ink); font-size: 2rem; font-weight: 650; line-height: 1.15; margin-top: 4px; }} +.mt-kpi .hint {{ color: var(--ink-3); font-size: .78rem; margin-top: 2px; }} + +/* ---------- patient rows ---------- */ +.mt-cols, .mt-row {{ display:grid; align-items:center; gap: 14px; + grid-template-columns: minmax(0,2fr) minmax(0,1.5fr) 58px 58px minmax(0,1fr) minmax(0,1.2fr) minmax(0,2fr) 40px; }} +.mt-row > div {{ min-width: 0; overflow-wrap: anywhere; }} +.mt-cols {{ color: var(--ink-3); font-size: .74rem; text-transform: uppercase; letter-spacing: .06em; + font-weight: 600; padding: 0 0 0 21px; margin-top: 8px; }} +.mt-row {{ font-size: .9rem; color: var(--ink); }} +.mt-name {{ font-weight: 600; font-size: 1rem; display:flex; align-items:center; gap:8px; }} +.mt-meta {{ color: var(--ink-3); font-size: .8rem; margin-top: 3px; }} +.mt-dim {{ color: var(--ink-3); }} +.mt-num {{ font-variant-numeric: tabular-nums; font-weight: 600; }} +.mt-event {{ color: var(--ink-2); font-size: .85rem; line-height: 1.3; }} +.mt-dot {{ width:10px; height:10px; border-radius:50%; display:inline-block; flex: none; }} +div[class*="st-key-prow-"] {{ background: var(--surface); border: 1px solid var(--line); border-radius: 12px; + padding: 10px 12px 10px 16px; border-left-width: 5px; transition: box-shadow .15s ease; }} +div[class*="st-key-prow-"]:hover {{ box-shadow: 0 2px 10px rgba(20,30,40,.08); }} +div[class*="st-key-prow-red"] {{ border-left-color: var(--red); background: linear-gradient(90deg, var(--red-tint) 0, + var(--surface) 22%); }} +div[class*="st-key-prow-yellow"] {{ border-left-color: var(--yellow); }} +div[class*="st-key-prow-green"] {{ border-left-color: var(--green); }} +div[class*="st-key-prow-grey"] {{ border-left-color: var(--grey); }} + +/* ---------- chips, badges, bars ---------- */ +.mt-chip {{ display:inline-block; font-size:.72rem; padding: 1px 8px; border-radius: 999px; margin-right: 4px; + background: var(--blue-tint); color: var(--blue-ink); font-weight: 500; }} +.mt-badge {{ display:inline-flex; align-items:center; gap:6px; font-size:.8rem; font-weight:600; padding: 3px 10px; + border-radius: 999px; white-space: nowrap; }} +.mt-badge.t-red {{ background: var(--red-tint); color: var(--red-ink); }} +.mt-badge.t-yellow {{ background: var(--yellow-tint); color: var(--yellow-ink); }} +.mt-badge.t-green {{ background: var(--green-tint); color: var(--green-ink); }} +.mt-badge.t-grey {{ background: var(--grey-tint); color: var(--grey-ink); }} +.mt-badge.t-blue {{ background: var(--blue-tint); color: var(--blue-ink); }} +.mt-bar {{ height: 6px; background: var(--grey-tint); border-radius: 999px; overflow: hidden; margin-top: 5px; }} +.mt-bar > span {{ display:block; height: 100%; border-radius: 999px; }} +.mt-count {{ display:inline-flex; min-width: 26px; height: 26px; border-radius: 999px; align-items:center; + justify-content:center; font-weight: 700; font-size: .85rem; }} +.mt-tip {{ position: relative; cursor: help; }} +.mt-tip:hover::after {{ content: attr(data-tip); position: absolute; z-index: 50; left: 0; top: 120%; + min-width: 220px; max-width: 320px; white-space: pre-line; background: var(--ink); color: #fff; + font-size: .78rem; font-weight: 400; line-height: 1.4; padding: 8px 10px; border-radius: 8px; + box-shadow: 0 6px 20px rgba(0,0,0,.18); }} + +/* ---------- banners & panels ---------- */ +.mt-banner {{ border-radius: 12px; padding: 12px 16px; display:flex; gap: 12px; align-items:flex-start; + border: 1px solid; margin-bottom: 6px; }} +.mt-banner.t-red {{ background: var(--red-tint); border-color: #F2C4C4; color: var(--red-ink); }} +.mt-banner.t-grey {{ background: var(--grey-tint); border-color: var(--line); color: var(--grey-ink); }} +.mt-banner b {{ color: inherit; }} +.mt-pulse {{ width: 12px; height: 12px; border-radius: 50%; background: var(--red); margin-top: 5px; flex: none; + box-shadow: 0 0 0 0 rgba(214,69,69,.6); animation: mtpulse 1.6s infinite; }} +@keyframes mtpulse {{ 0% {{ box-shadow: 0 0 0 0 rgba(214,69,69,.55); }} 70% {{ box-shadow: 0 0 0 10px + rgba(214,69,69,0); }} 100% {{ box-shadow: 0 0 0 0 rgba(214,69,69,0); }} }} +.mt-panel-title {{ font-size: .8rem; text-transform: uppercase; letter-spacing: .07em; font-weight: 650; + color: var(--ink-3); margin: 2px 0 8px; }} +.mt-alert {{ border-left: 4px solid var(--grey); padding: 4px 0 4px 12px; }} +.mt-alert.t-red {{ border-left-color: var(--red); }} +.mt-alert.t-yellow {{ border-left-color: var(--yellow); }} +.mt-alert.t-grey {{ border-left-color: var(--grey); }} +.mt-alert .when {{ color: var(--ink-3); font-size: .78rem; }} +.mt-alert .why {{ color: var(--ink); font-weight: 600; font-size: .92rem; margin: 2px 0; }} +.mt-alert.acked .why {{ color: var(--ink-3); font-weight: 500; }} +.mt-summary {{ background: var(--blue-tint); border-radius: 12px; padding: 14px 16px; color: var(--ink); + line-height: 1.5; font-size: .93rem; }} +.mt-summary .disc {{ color: var(--blue-ink); font-size: .76rem; margin-top: 8px; }} +.mt-plan ol {{ margin: 0; padding-left: 1.2rem; }} +.mt-plan li {{ margin: 3px 0; color: var(--ink); font-size: .9rem; }} + +/* ---------- dose strip ---------- */ +.mt-doses {{ display:grid; gap: 4px; align-items:center; }} +.mt-doses .cell {{ height: 26px; border-radius: 6px; display:flex; align-items:center; justify-content:center; + font-size: .72rem; font-weight: 700; color: #fff; }} +.mt-doses .c-taken {{ background: var(--green); }} +.mt-doses .c-missed {{ background: var(--red); }} +.mt-doses .c-skipped {{ background: var(--yellow); color: #3D2B00; }} +.mt-doses .c-pending {{ background: var(--surface); border: 1.5px dashed var(--grey); color: var(--ink-3); }} +.mt-doses .c-none {{ background: transparent; }} +.mt-doses .lab {{ color: var(--ink-3); font-size: .72rem; text-align:center; font-variant-numeric: tabular-nums; }} +.mt-legend {{ display:flex; gap: 14px; flex-wrap: wrap; color: var(--ink-2); font-size: .8rem; margin-top: 8px; }} +.mt-legend i {{ display:inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 5px; + vertical-align: -1px; }} + +/* ---------- connect result ---------- */ +.mt-code {{ font-size: 2.4rem; font-weight: 700; letter-spacing: .12em; color: var(--ink); + font-variant-numeric: tabular-nums; }} +.mt-footer {{ color: var(--ink-3); font-size: .78rem; text-align: center; margin: 36px 0 8px; + border-top: 1px solid var(--line); padding-top: 12px; }} + +@media (max-width: 900px) {{ + .mt-kpis {{ grid-template-columns: repeat(2, minmax(0,1fr)); }} + .mt-cols {{ display: none; }} + .mt-row {{ grid-template-columns: 1fr 1fr; }} +}} +</style> +""" + + +def plotly_layout(**overrides: object) -> dict[str, object]: + """Shared plotly layout: recessive grid, no chart junk, the same font as the page.""" + layout: dict[str, object] = { + "font": {"family": FONT_STACK, "size": 12, "color": INK_2}, + "paper_bgcolor": "rgba(0,0,0,0)", + "plot_bgcolor": "rgba(0,0,0,0)", + "margin": {"l": 8, "r": 8, "t": 36, "b": 8}, + "hoverlabel": {"bgcolor": INK, "font": {"color": "#FFFFFF", "family": FONT_STACK}}, + "showlegend": False, + "hovermode": "x unified", + } + layout.update(overrides) + return layout + + +AXIS = {"showgrid": True, "gridcolor": LINE, "zeroline": False, "linecolor": LINE, "tickcolor": LINE, "ticks": ""} +PLOTLY_CONFIG = {"displayModeBar": False, "responsive": True} diff --git a/tests/test_dashboard_api_client.py b/tests/test_dashboard_api_client.py new file mode 100644 index 0000000..0bf4cbf --- /dev/null +++ b/tests/test_dashboard_api_client.py @@ -0,0 +1,126 @@ +"""Dashboard HTTP client: paths, parsing via the frozen schemas, graceful errors (no tracebacks to the UI).""" + +import json + +import httpx +import pytest + +from app.api import fixtures +from app.api.schemas import ConnectPatientIn +from dashboard.api_client import ApiClient, ApiError, ApiUnavailable + + +def _client(handler) -> ApiClient: + return ApiClient("http://api.test", "doc-smirnova", transport=httpx.MockTransport(handler)) + + +def _json(models) -> list | dict: + if isinstance(models, list): + return [m.model_dump(mode="json") for m in models] + return models.model_dump(mode="json") + + +def test_base_url_gets_api_prefix_once(): + assert ApiClient("http://x:8000/").base_url == "http://x:8000/api/v1" + assert ApiClient("http://x:8000/api/v1").base_url == "http://x:8000/api/v1" + + +def test_doctor_id_from_env(monkeypatch): + monkeypatch.setenv("DOCTOR_ID", "doc-other") + assert ApiClient("http://x").doctor_id == "doc-other" + monkeypatch.delenv("DOCTOR_ID") + assert ApiClient("http://x").doctor_id == "doc-smirnova" + + +def test_patients_and_card_are_parsed(): + seen: list[str] = [] + + def handler(req: httpx.Request) -> httpx.Response: + seen.append(str(req.url)) + if req.url.path.endswith("/patients"): + return httpx.Response(200, json=_json(fixtures.PATIENTS)) + return httpx.Response(200, json=_json(fixtures.andrey_card())) + + c = _client(handler) + patients = c.patients() + assert patients[0].display_name == "Андрей С." + card = c.patient_card("tok-andrey") + assert card.patient.status_color == "red" + assert seen[0] == "http://api.test/api/v1/doctors/doc-smirnova/patients" + assert seen[1] == "http://api.test/api/v1/patients/tok-andrey?doctor_id=doc-smirnova" + + +def test_alerts_params_and_ack_body(): + captured: dict = {} + + def handler(req: httpx.Request) -> httpx.Response: + if req.method == "POST": + captured["body"] = json.loads(req.content) + return httpx.Response(200, json=_json(fixtures.all_alerts()[0])) + captured["params"] = dict(req.url.params) + return httpx.Response(200, json=_json(fixtures.all_alerts())) + + c = _client(handler) + assert len(c.alerts(since_id=2, open_only=True)) == 4 + assert captured["params"] == {"since_id": "2", "open_only": "true"} + c.ack(3) + assert captured["body"] == {"doctor_id": "doc-smirnova"} + + +def test_connect_posts_payload(): + def handler(req: httpx.Request) -> httpx.Response: + body = json.loads(req.content) + assert body["drug"] == "сертралин" and body["times"] == ["08:00"] + return httpx.Response(200, json={"token": "t", "code": "DEMO-1", "deep_link": "https://t.me/x?start=DEMO-1"}) + + body = ConnectPatientIn( + doctor_id="doc-smirnova", + display_name="Андрей С.", + birth_year=1992, + sex="m", + snils="000-000-000 00", + drug="сертралин", + dose="50 мг", + ) + assert _client(handler).connect(body).code == "DEMO-1" + + +def test_connection_error_becomes_unavailable(): + def handler(req: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("refused", request=req) + + with pytest.raises(ApiUnavailable) as exc: + _client(handler).patients() + assert "API недоступен" in exc.value.message + + +def test_timeout_becomes_unavailable(): + def handler(req: httpx.Request) -> httpx.Response: + raise httpx.ReadTimeout("slow", request=req) + + with pytest.raises(ApiUnavailable): + _client(handler).alerts() + + +@pytest.mark.parametrize(("status", "fragment"), [(404, "Не найдено"), (500, "500"), (422, "422")]) +def test_http_errors(status, fragment): + c = _client(lambda req: httpx.Response(status, json={"detail": "x"})) + with pytest.raises(ApiError) as exc: + c.patient_card("tok-x") + assert fragment in exc.value.message + assert exc.value.status == status + assert not isinstance(exc.value, ApiUnavailable) + + +def test_bad_payload_is_api_error(): + c = _client(lambda req: httpx.Response(200, json=[{"token": "only"}])) + with pytest.raises(ApiError, match="неожиданный"): + c.patients() + c = _client(lambda req: httpx.Response(200, content=b"<html>")) + with pytest.raises(ApiError): + c.patients() + + +def test_clock_failure_is_none(): + c = _client(lambda req: httpx.Response(503)) + assert c.clock() is None diff --git a/tests/test_dashboard_helpers.py b/tests/test_dashboard_helpers.py new file mode 100644 index 0000000..0580f17 --- /dev/null +++ b/tests/test_dashboard_helpers.py @@ -0,0 +1,212 @@ +"""Pure dashboard logic: ordering, labels, dose strip, scales config, SNILS, chart builders, page discovery.""" + +import random +from datetime import date, datetime, timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +from app.api import fixtures +from app.api.schemas import AlertOut, DiscontinuationRisk, DoseOut, SeriesPoint +from dashboard.app import discover_pages, slugify +from dashboard.components import charts, ui +from dashboard.components import helpers as h + +TZ = ZoneInfo("Europe/Moscow") + + +def _risk(score: int, level: str = "low", factors: list[str] | None = None) -> DiscontinuationRisk: + return DiscontinuationRisk(score=score, level=level, factors=factors or []) + + +def test_sort_red_yellow_grey_green_then_risk(): + items = list(reversed(fixtures.PATIENTS)) + items.append(fixtures.PATIENTS[1].model_copy(update={"token": "g", "display_name": "Г", "status_color": "grey"})) + out = h.sort_patients(items) + colors = [p.status_color for p in out] + assert colors == sorted(colors, key=h.STATUS_ORDER.__getitem__) + assert out[0].token == "tok-andrey" + yellows = [p for p in out if p.status_color == "yellow"] + assert yellows[0].display_name == "Сергей П." # highest discontinuation risk among yellow + assert [p.discontinuation_risk.score for p in yellows] == sorted( + (p.discontinuation_risk.score for p in yellows), reverse=True + ) + + +def test_filter_and_kpis(): + items = fixtures.PATIENTS + assert {p.status_color for p in h.filter_patients(items, "yellow")} == {"yellow"} + assert [p.display_name for p in h.filter_patients(items, query="андр")] == ["Андрей С."] + assert len(h.filter_patients(items, query="пароксетин")) == 1 + k = h.kpis(items) + assert (k.total, k.red, k.yellow, k.green, k.grey, k.high_risk) == (8, 1, 4, 3, 0, 1) + + +def test_trend_arrows(): + assert h.trend_view("up").text == "↑" and h.trend_view("up").tone == "red" + assert h.trend_view("down").text == "↓" and h.trend_view("down").tone == "green" + assert h.trend_view("flat").text == "→" + assert h.trend_view("unknown").tone == "grey" + + +def test_risk_badge_and_tooltip(): + v = h.risk_view(_risk(70, "high", ["пропуски 3 из 7", "нет ответа 2 дня"])) + assert v.text == "Высокий · 70" and v.tone == "red" + assert "• пропуски 3 из 7\n• нет ответа 2 дня" in v.hint + assert h.risk_view(_risk(35, "medium")).tone == "yellow" + assert "значимых факторов нет" in h.risk_view(_risk(5)).hint + + +def test_adherence_thresholds(): + assert h.adherence_view(None).text == "—" + assert h.adherence_view(1.0).tone == "green" + assert h.adherence_view(0.8).tone == "green" + assert h.adherence_view(0.7).tone == "yellow" + assert h.adherence_view(0.57) == h.View("57%", "red") + + +def test_level_tone_matches_adr006(): + assert h.level_tone("acute") == "red" + assert h.level_tone("high") == h.level_tone("moderate") == "yellow" + assert h.level_tone("info") == "grey" + + +def test_severity_bands_come_from_config(): + bands = h.severity_bands("phq9") + assert [b.lo for b in bands] == [0, 5, 10, 15, 20] + assert h.severity_label("phq9", 18) == "Умеренно-тяжёлая" + assert h.severity_label("pcl5", 44) == "Вероятное ПТСР" + assert h.severity_label("pcl5", None) == "" + assert h.severity_bands("no_such_scale") == () + + +def test_last_and_prev(): + card = fixtures.andrey_card() + assert h.last_and_prev(card.series, "phq9") == (18, 13) + assert h.last_and_prev(card.series, "gad7") == (None, None) + + +def test_dose_grid_fills_gaps_and_counts(): + d0 = datetime(2026, 9, 21, 8, tzinfo=TZ) + doses = [ + DoseOut(planned_at=d0, drug="сертралин", status="taken"), + DoseOut(planned_at=d0 + timedelta(hours=12), drug="сертралин", status="missed"), + DoseOut(planned_at=d0 + timedelta(days=2), drug="сертралин", status="skipped"), + DoseOut(planned_at=d0 + timedelta(days=3), drug="сертралин", status="pending"), + ] + g = h.dose_grid(doses) + assert g.days == [date(2026, 9, 21) + timedelta(days=i) for i in range(4)] + assert g.slots == ["08:00", "20:00"] + assert g.cells[("20:00", date(2026, 9, 21))] == "missed" + assert ("08:00", date(2026, 9, 22)) not in g.cells # no dose planned that day → empty cell + assert g.counts == {"taken": 1, "missed": 1, "skipped": 1, "pending": 1} + assert g.adherence == 1 / 3 # pending is not counted + + +def test_dose_grid_empty(): + g = h.dose_grid([]) + assert g.days == [] and g.adherence is None + + +def test_dose_grid_converts_to_moscow_time(): + utc = datetime(2026, 9, 21, 5, tzinfo=ZoneInfo("UTC")) + g = h.dose_grid([DoseOut(planned_at=utc, drug="x", status="taken")]) + assert g.slots == ["08:00"] + + +def _alert(i: int, level: str = "moderate", acked: bool = False, hours: int = 0) -> AlertOut: + ts = datetime(2026, 10, 3, 9, tzinfo=TZ) + timedelta(hours=hours) + return AlertOut( + id=i, + token="t", + patient_name="П", + ts=ts, + level=level, + color=h.level_tone(level), + code="c", + reason="r", + acked_by="d" if acked else None, + acked_at=ts if acked else None, + ) + + +def test_alert_ordering_new_and_local_acks(): + alerts = [_alert(1, "moderate", hours=5), _alert(2, "acute", hours=1), _alert(3, "high", acked=True, hours=9)] + assert [a.id for a in h.sort_alerts(alerts)] == [2, 1, 3] + assert [a.id for a in h.split_new(alerts, {1})] == [2, 3] + at = datetime(2026, 10, 3, 22, tzinfo=TZ) + out = h.apply_local_acks(alerts, {2: ("doc-smirnova", at)}) + assert out[1].acked_by == "doc-smirnova" and out[1].acked_at == at + assert out[0].acked_at is None + + +def test_russian_formatting(): + assert [h.age_label(n) for n in (21, 23, 34, 41, 45, 11, 112)] == [ + "21 год", + "23 года", + "34 года", + "41 год", + "45 лет", + "11 лет", + "112 лет", + ] + assert h.age_label(0) == "возраст не указан" + assert h.absolute_link("/console?code=X", "http://h:8501/") == "http://h:8501/console?code=X" + assert h.absolute_link("https://t.me/b?start=X", "http://h") == "https://t.me/b?start=X" + today = date(2026, 10, 3) + assert h.visit_label(date(2026, 10, 18), today) == "визит 18 окт · через 15 дней" + assert h.visit_label(date(2026, 10, 4), today) == "визит 4 окт · через 1 день" + assert h.visit_label(today, today) == "визит сегодня" + assert "просрочен" in h.visit_label(date(2026, 10, 1), today) + assert h.visit_label(None, today) == "визит не назначен" + assert h.when_label(datetime(2026, 10, 3, 21, tzinfo=TZ), today) == "сегодня, 21:00" + + +def test_snils_checksum(): + assert h.snils_checksum("112233445") == 95 # well-known valid example 112-233-445 95 + assert h.is_valid_snils("112-233-445 95") + assert not h.is_valid_snils("112-233-445 96") + rng = random.Random(1) + for _ in range(50): + s = h.synthetic_snils(rng) + assert s.startswith("000-") and h.is_valid_snils(s) + + +def test_ui_row_escapes_and_shows_values(): + p = fixtures.PATIENTS[0].model_copy(update={"last_event": "<script>x</script>"}) + html = ui.patient_row(p) + assert "<script>" not in html and "<script>" in html + assert "Андрей С." in html and "день терапии 13" in html and "↑" in html + + +def test_scale_figure_marks_unscheduled_and_bands(): + card = fixtures.andrey_card() + fig = charts.scale_figure(card.series, "phq9", "PHQ-9", card.prescriptions[0].start_date) + assert fig is not None + trace = fig.data[0] + assert list(trace.marker.symbol) == ["circle", "circle", "diamond-open"] + assert len(fig.layout.shapes) == 5 # severity bands from config/scales/phq9.yaml + assert charts.scale_figure([], "phq9", "PHQ-9") is None + + +def test_wellbeing_figure_small_multiples(): + card = fixtures.andrey_card() + fig = charts.wellbeing_figure(card.series) + assert fig is not None and len(fig.data) == 3 + assert charts.wellbeing_figure([SeriesPoint(day=date(2026, 1, 1), metric="phq9", value=3)]) is None + + +def test_page_discovery_orders_and_sections(tmp_path: Path): + for name in ("1_Пациенты.py", "1b_Подключить_пациента.py", "2_Демо-консоль.py", "3_Сплит-экран.py", "helper.py"): + (tmp_path / name).write_text("", encoding="utf-8") + (tmp_path / "4_Что-то.py").write_text('NAV_ICON = ":material/star:"\nNAV_SECTION = "Прочее"\n', encoding="utf-8") + specs = discover_pages(tmp_path) + assert [s.title for s in specs] == ["Пациенты", "Подключить пациента", "Демо-консоль", "Сплит-экран", "Что-то"] + assert [s.section for s in specs][:4] == ["Кабинет врача", "Кабинет врача", "Демо", "Демо"] + assert specs[4].icon == ":material/star:" and specs[4].section == "Прочее" + assert specs[2].url_path == "demo-konsol" + assert slugify("Сплит-экран") == "split-ekran" + + +def test_real_pages_dir_has_doctor_pages(): + titles = [s.title for s in discover_pages()] + assert titles[:2] == ["Пациенты", "Подключить пациента"] diff --git a/tests/test_dashboard_pages.py b/tests/test_dashboard_pages.py new file mode 100644 index 0000000..d38b258 --- /dev/null +++ b/tests/test_dashboard_pages.py @@ -0,0 +1,152 @@ +"""Streamlit AppTest smoke tests of the doctor pages against an in-memory API (fixtures over httpx.MockTransport).""" + +from pathlib import Path + +import httpx +import pytest +from streamlit.testing.v1 import AppTest + +from app.api import fixtures +from dashboard.api_client import ApiClient +from dashboard.components import state + +PAGES = Path(__file__).resolve().parents[1] / "dashboard" / "pages" +LIST_PAGE = str(PAGES / "1_Пациенты.py") +CONNECT_PAGE = str(PAGES / "1b_Подключить_пациента.py") + + +def _dump(obj): + return [o.model_dump(mode="json") for o in obj] if isinstance(obj, list) else obj.model_dump(mode="json") + + +class FakeApi: + def __init__(self) -> None: + self.calls: list[tuple[str, str]] = [] + self.extra_alerts: list = [] + + def __call__(self, req: httpx.Request) -> httpx.Response: + path = req.url.path.removeprefix("/api/v1") + self.calls.append((req.method, path)) + if path == "/doctors/doc-smirnova/patients": + return httpx.Response(200, json=_dump(fixtures.PATIENTS)) + if path.startswith("/patients/tok-"): + card = fixtures.card(path.rsplit("/", 1)[1]) + return httpx.Response(200, json=_dump(card)) if card else httpx.Response(404) + if path == "/doctors/doc-smirnova/alerts": + return httpx.Response(200, json=_dump(self.extra_alerts + fixtures.all_alerts())) + if path.startswith("/alerts/") and path.endswith("/ack"): + alert = next(a for a in fixtures.all_alerts() if a.id == int(path.split("/")[2])) + acked = alert.model_copy(update={"acked_by": "doc-smirnova", "acked_at": alert.ts}) + return httpx.Response(200, json=_dump(acked)) + if path == "/patients/connect": + return httpx.Response( + 200, json={"token": "tok-new", "code": "DEMO-0001", "deep_link": "https://t.me/b?start=DEMO-0001"} + ) + if path == "/demo/clock": + return httpx.Response(200, json={"now": "2026-10-03T21:05:00+03:00", "virtual": True, "offset_days": 0}) + return httpx.Response(404) + + +@pytest.fixture +def api(monkeypatch) -> FakeApi: + fake = FakeApi() + client = ApiClient("http://api.test", "doc-smirnova", transport=httpx.MockTransport(fake)) + monkeypatch.setattr(state, "client", lambda: client) + return fake + + +def _html(at: AppTest) -> str: + return "\n".join(str(el.proto.body) for el in at.get("html")) + + +def test_list_page_renders_sorted_patients(api): + at = AppTest.from_file(LIST_PAGE, default_timeout=20).run() + assert not at.exception + html = _html(at) + assert "Кабинет д-ра Смирновой Е. А." in html + assert html.index("Андрей С.") < html.index("Сергей П.") < html.index("Павел Р.") + assert "Высокий · 70" in html # discontinuation-risk badge + open_buttons = [b for b in at.button if b.label == "Открыть"] + assert len(open_buttons) == len(fixtures.PATIENTS) + + +def test_list_filter_and_open_card(api): + at = AppTest.from_file(LIST_PAGE, default_timeout=20).run() + at.segmented_control(key="status_filter").set_value("🔴 Красные").run() + assert len([b for b in at.button if b.label == "Открыть"]) == 1 + at.button(key="open-tok-andrey").click().run() + assert not at.exception + assert at.query_params["patient"] in ("tok-andrey", ["tok-andrey"]) + html = _html(at) + assert "PHQ-9 п.9 = 2" in html and "План безопасности" in html + assert "Решения принимает врач" in html + assert ("GET", "/patients/tok-andrey") in api.calls + + +def test_card_ack_posts_and_marks_alert(api): + at = AppTest.from_file(LIST_PAGE, default_timeout=20) + at.query_params["patient"] = "tok-andrey" + at.run() + assert not at.exception + assert at.button(key="ack-3").label == "Подтвердить" + at.button(key="ack-3").click().run() + assert ("POST", "/alerts/3/ack") in api.calls + assert not [b for b in at.button if b.key == "ack-3"] # acked alert has no button any more + + +def test_api_down_shows_banner(monkeypatch): + def refuse(req: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("refused", request=req) + + client = ApiClient("http://api.test", transport=httpx.MockTransport(refuse)) + monkeypatch.setattr(state, "client", lambda: client) + at = AppTest.from_file(LIST_PAGE, default_timeout=20).run() + assert not at.exception + assert "API недоступен" in _html(at) + + +def test_connect_flow_shows_code(api): + at = AppTest.from_file(CONNECT_PAGE, default_timeout=20).run() + assert not at.exception + at.button[0].click().run() # «Заполнить демо-данными» + at.checkbox(key="cp_consent").check().run() + assert at.text_input(key="cp_name").value == "Андрей С." + submit = next(b for b in at.button if b.label == "Подключить и получить код") + submit.click().run() + assert not at.exception + assert ("POST", "/patients/connect") in api.calls + assert "DEMO-0001" in _html(at) + assert at.get("image") # QR image + + +def test_connect_requires_consent(api): + at = AppTest.from_file(CONNECT_PAGE, default_timeout=20).run() + at.text_input(key="cp_name").set_value("Пётр К.").run() + next(b for b in at.button if b.label == "Подключить и получить код").click().run() + assert at.error and "согласие" in at.error[0].value + assert ("POST", "/patients/connect") not in api.calls + + +def test_app_entry_navigation_and_acute_banner(api): + at = AppTest.from_file(str(PAGES.parent / "app.py"), default_timeout=20).run() + assert not at.exception + html = _html(at) + assert "Кризисный сигнал · Андрей С." in html # open acute alert pinned above every page + assert "Кабинет д-ра Смирновой Е. А." in html # default page = patient list + assert "Прототип на синтетических данных" in html + assert ("GET", "/doctors/doc-smirnova/alerts") in api.calls + + +def test_new_alert_toasts_once(api): + at = AppTest.from_file(str(PAGES.parent / "app.py"), default_timeout=20).run() + assert not at.toast # alerts open at session start are not "new" + new = fixtures.all_alerts()[0].model_copy( + update={"id": 10, "token": "tok-sergey", "patient_name": "Сергей П.", "reason": "C-SSRS: намерение"} + ) + api.extra_alerts.append(new) + at.run() + assert not at.exception + assert any("Сергей П." in t.value for t in at.toast) + assert "Кризисный сигнал · Сергей П." in _html(at) + at.run() + assert not any("Сергей П." in t.value for t in at.toast)