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>
213 lines
10 KiB
Python
213 lines
10 KiB
Python
"""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)
|