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,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
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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>"
|
||||
)
|
||||
Reference in New Issue
Block a user