feat(dashboard): doctor dashboard with red zones, patient card, live alerts and patient connect
CI / ruff · pytest (pull_request) Successful in 2m1s

- Patient list sorted red -> yellow -> grey -> green with KPI row, trend arrows,
  7-day adherence bars, discontinuation-risk badge with factor tooltip.
- Patient card: scales with severity bands from config/scales (unscheduled marked),
  mood/sleep/alcohol small multiples, dose heat-strip, alerts feed with ack,
  safety plan, AI summary with disclaimer.
- Live alerts: st.fragment(run_every=2.5s) polling, toast + pinned acute banner on
  every page; full rerun on new alert only on doctor pages.
- Connect form -> POST /patients/connect -> code, QR, deep link.
- Thin httpx ApiClient over the frozen schemas with graceful "API недоступен".
- app.py discovers dashboard/pages/*.py automatically (st.navigation); demo pages
  of #17 plug in by adding a file.

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 12:51:07 +03:00
co-authored by Claude Opus 5.5
parent 178cab5b1b
commit 2582c73d12
15 changed files with 2170 additions and 13 deletions
+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>"
)