"""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 '
Данных о приёме пока нет
' glyph = {"taken": "✓", "missed": "✕", "skipped": "–", "pending": "·"} label = {"taken": "принял", "missed": "нет ответа", "skipped": "пропустил", "pending": "ожидается"} cols = f"56px repeat({len(grid.days)}, minmax(18px, 1fr))" cells = ['
'] cells += [f'
{d.day}
' for d in grid.days] for slot in grid.slots: cells.append(f'
{slot}
') for d in grid.days: status = grid.cells.get((slot, d)) if status is None: cells.append('
') continue day = f" · день {(d - start).days}" if start else "" tip = f"{h.short_date(d)}{day}, {slot}: {label[status]}" cells.append(f'
{glyph[status]}
') c = grid.counts legend = ( '
' f'принял · {c["taken"]}' f'пропустил · {c["skipped"]}' f'нет ответа · {c["missed"]}' f'ожидается · {c["pending"]}' "
" ) return f'
{"".join(cells)}
{legend}' def _tile(label: str, value: str, hint: str = "", tone: str = "blue") -> str: return ( f'
{escape(label)}
' f'
{value}
{escape(hint)}
' ) 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, '—', "не проводился", "grey") delta = "" if prev is not None and last != prev: arrow = "↑" if last > prev else "↓" tone = "red" if last > prev else "green" delta = f' {arrow} с {prev:g}' 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( '
' f'
{ui.badge(status.text, status.tone, status.hint, icon="dot")}
' f'

{escape(p.display_name)}

{meta}
' f'
Риск прерывания терапии
' f"{ui.badge(risk.text, risk.tone, risk.hint)}" f'
{escape(", ".join(p.discontinuation_risk.factors))}
' "
" ) 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('
Алерты
') 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'
' f'
{theme.TONES[tone].icon} {level} · {escape(h.when_label(a.ts, today))}
' f'
{escape(a.reason)}
' f'
{escape(status)}
' ) 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('
Саммари (ИИ, по структурированным данным)
') body = escape(text) if text else 'Саммари ещё не сформировано' st.html( f'
{body}
Сформировано по структурированным данным ' "(шкалы, чек-ины, приём, алерты) без свободного текста пациента. Решения принимает врач.
" ) def _safety_plan(plan: list[str] | None) -> None: st.html('
План безопасности
') if not plan: st.caption("Не составлен. Составляется с пациентом при острых сигналах (Stanley–Brown).") return items = "".join(f"
  • {escape(step)}
  • " for step in plan) st.html(f'
      {items}
    ') def _prescriptions(card: PatientCard) -> None: st.html('
    Назначения
    ') 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'
    {escape(rx.drug)} {escape(rx.dose)}' f'
    {escape(times)} · {escape(rx.instructions or "без особых указаний")}{since}
    ' "
    " ) 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( '
    ' + _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"), ) + "
    " ) 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('
    Календарь приёма
    ') 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)