feat(dashboard): doctor dashboard with red zones, patient card, live alerts and patient connect
CI / ruff · pytest (pull_request) Successful in 2m1s
CI / ruff · pytest (pull_request) Successful in 2m1s
- Patient list sorted red -> yellow -> grey -> green with KPI row, trend arrows, 7-day adherence bars, discontinuation-risk badge with factor tooltip. - Patient card: scales with severity bands from config/scales (unscheduled marked), mood/sleep/alcohol small multiples, dose heat-strip, alerts feed with ack, safety plan, AI summary with disclaimer. - Live alerts: st.fragment(run_every=2.5s) polling, toast + pinned acute banner on every page; full rerun on new alert only on doctor pages. - Connect form -> POST /patients/connect -> code, QR, deep link. - Thin httpx ApiClient over the frozen schemas with graceful "API недоступен". - app.py discovers dashboard/pages/*.py automatically (st.navigation); demo pages of #17 plug in by adding a file. Refs #16 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||
Reference in New Issue
Block a user