release: dev → main (сдача хакатона) #106

Merged
Eleboba merged 128 commits from dev into main 2026-10-04 23:00:56 +03:00
15 changed files with 2170 additions and 13 deletions
Showing only changes of commit 2f1f8a8f24 - Show all commits
+18
View File
@@ -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
+132
View File
@@ -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)
+115 -11
View File
@@ -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
* 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"`.
"""
from __future__ import annotations
import re
import sys
from dataclasses import dataclass
from pathlib import Path
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
API = os.environ.get("API_BASE_URL", "http://localhost:8000")
from dashboard import theme
from dashboard.components import live_alerts, state, ui
st.set_page_config(page_title="PTSRaiAgent — кабинет врача", page_icon="🩺", layout="wide")
st.title("Кабинет врача")
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("Прототип на синтетических данных")
try:
status = httpx.get(f"{API}/health", timeout=2).json().get("status")
except httpx.HTTPError:
status = "недоступен"
st.write(f"API: **{status}**")
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()
+173
View File
@@ -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
+299
View File
@@ -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))
+85
View File
@@ -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()
+212
View File
@@ -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)
+62
View File
@@ -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
+109
View File
@@ -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>"
)
+97
View File
@@ -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()
@@ -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()
+188
View File
@@ -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}
+126
View File
@@ -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
+212
View File
@@ -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 "&lt;script&gt;" 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] == ["Пациенты", "Подключить пациента"]
+152
View File
@@ -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)