379 lines
16 KiB
Python
379 lines
16 KiB
Python
"""Doctor dashboard read models (#12): clinical DB rows → frozen API schemas (`app/api/schemas.py`).
|
||
|
||
Everything shown to the doctor is derived from stored structured data — no LLM decides colours, trends or the
|
||
discontinuation risk (ADR-007). Time comes in as `now` (from `app.core.clock`, ADR-005).
|
||
|
||
Identity data (guardrail #5): the API is the doctor's display boundary, so it reads exactly two identity fields —
|
||
`display_name` (minimal identification for the treating doctor) and `birth_year` (age). SNILS, phone and chat
|
||
id are never selected into a response model.
|
||
"""
|
||
|
||
from collections.abc import Iterable, Sequence
|
||
from dataclasses import dataclass
|
||
from datetime import date, datetime, timedelta
|
||
from typing import Any, Literal, cast, get_args
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.api.schemas import (
|
||
AlertOut,
|
||
Color,
|
||
DiscontinuationRisk,
|
||
DoseOut,
|
||
LevelName,
|
||
Metric,
|
||
Module,
|
||
PatientCard,
|
||
PatientListItem,
|
||
SeriesPoint,
|
||
)
|
||
from app.core.rules import color, patient_status
|
||
from app.core.types import AlertLevel
|
||
from app.db import repo
|
||
from app.db.clinical import Alert, DailyCheckin, Dose, PatientProfile, Prescription, SafetyPlan, ScaleResult
|
||
from app.db.identity import Patient
|
||
from app.integrations.base import Prescription as PrescriptionOut
|
||
from app.services.anonymous_care import display_name as anonymous_name
|
||
|
||
COLOR_ORDER: dict[str, int] = {"red": 0, "yellow": 1, "grey": 2, "green": 3}
|
||
_MODULES = frozenset(get_args(Module))
|
||
_METRICS = frozenset(get_args(Metric))
|
||
_SCALE_LABELS = {"phq9": "PHQ-9", "pcl5": "PCL-5", "gad7": "GAD-7", "auditc": "AUDIT-C", "dar5": "DAR-5"}
|
||
|
||
# Daily check-in buckets → numbers for charts. Sleep: bucket midpoints in hours ("<4" plotted as 3, ">8" as 9).
|
||
SLEEP_HOURS = {"<4": 3.0, "4-6": 5.0, "6-8": 7.0, ">8": 9.0}
|
||
# Alcohol: 0 = none, 1 = 1–2 drinks, 2 = more (same coding as app/llm/summary.py expects).
|
||
ALCOHOL_LEVEL = {"none": 0.0, "1-2": 1.0, "more": 2.0}
|
||
|
||
# Trend: the last two complete results of one scale. A change smaller than about half of the minimal clinically
|
||
# important difference (PHQ-9: 5 points, PCL-5: 10 points) is reported as "flat" so noise does not flip arrows.
|
||
TREND_MIN_CHANGE = {"phq9": 3, "pcl5": 5}
|
||
|
||
CARD_DAYS = 60 # how far back the card series / dose log reach
|
||
|
||
|
||
# --------------------------------------------------------------------------- discontinuation risk index
|
||
#
|
||
# Rule-based index (NOT a trained model) of the risk that the patient drops the therapy, 0–100, for sorting the
|
||
# list and for the "talk about adherence" hint. Each factor is a deterministic check over the clinical DB with a
|
||
# fixed weight; the score is the capped sum. Weights reflect the known predictors of early antidepressant
|
||
# discontinuation (non-adherence, side effects in the first weeks, expressed intent to stop, low mood, alcohol):
|
||
#
|
||
# factor weight label for the doctor
|
||
# missed/skipped doses on >= 3 of the last 7 days 30 "пропуски приёма N/7 дн."
|
||
# ... on 1–2 of the last 7 days 15 "пропуски приёма N/7 дн."
|
||
# side effects reported >= 3 days in a row 20 "побочные эффекты N дн. подряд"
|
||
# ... 1–2 days in a row 10 "побочные эффекты N дн. подряд"
|
||
# intent to stop medication voiced in last 14 days 25 "хотел бросить (день N)"
|
||
# open, unacknowledged quit_intent alert +15 "не разобран сигнал «хочет бросить»"
|
||
# mood <= 3 for >= 3 days in a row 15 "настроение ≤ 3 N дн. подряд"
|
||
# alcohol on >= 3 of the last 7 days 15 "алкоголь N дн. из 7"
|
||
# ... on 1–2 of the last 7 days 5 "алкоголь N дн. из 7"
|
||
#
|
||
# Level: >= 60 high, >= 30 medium, otherwise low. "Day" = calendar day in the clinic time zone; streaks end today
|
||
# if today's check-in exists, otherwise yesterday (same anchor as app.core.rules), and a day without a check-in
|
||
# breaks a streak — we never assume a symptom the patient did not report.
|
||
|
||
RISK_WINDOW_DAYS = 7
|
||
QUIT_WINDOW_DAYS = 14
|
||
MOOD_LOW = 3
|
||
W_MISSED_MANY, W_MISSED_SOME = 30, 15
|
||
W_SIDE_EFFECTS_LONG, W_SIDE_EFFECTS_SHORT = 20, 10
|
||
W_QUIT_INTENT, W_QUIT_ALERT_OPEN = 25, 15
|
||
W_MOOD_LOW = 15
|
||
W_ALCOHOL_MANY, W_ALCOHOL_SOME = 15, 5
|
||
STREAK_DAYS = 3
|
||
RISK_HIGH, RISK_MEDIUM = 60, 30
|
||
|
||
|
||
@dataclass
|
||
class PatientData:
|
||
"""Everything the list item and the card are computed from (one patient, loaded once)."""
|
||
|
||
profile: PatientProfile
|
||
checkins: Sequence[DailyCheckin]
|
||
results: Sequence[ScaleResult]
|
||
doses: Sequence[Dose]
|
||
prescriptions: Sequence[Prescription]
|
||
alerts: Sequence[Alert] # newest first
|
||
contact_requested_at: datetime | None = None
|
||
|
||
|
||
async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> PatientData:
|
||
token = profile.token
|
||
doses = (await session.scalars(select(Dose).where(Dose.token == token).order_by(Dose.planned_at))).all()
|
||
rx = (
|
||
await session.scalars(select(Prescription).where(Prescription.token == token).order_by(Prescription.id))
|
||
).all()
|
||
dialog = await repo.get_dialog_state(session, token)
|
||
requests = (dialog.data.get("contact_requests") or []) if dialog else []
|
||
contact_at = datetime.fromisoformat(requests[-1]["ts"]) if requests else None
|
||
return PatientData(
|
||
profile=profile,
|
||
checkins=await repo.list_checkins(session, token),
|
||
results=await repo.list_scale_results(session, token),
|
||
doses=doses,
|
||
prescriptions=rx,
|
||
alerts=await repo.list_alerts(session, token=token),
|
||
contact_requested_at=contact_at,
|
||
)
|
||
|
||
|
||
def _local(ts: datetime, now: datetime) -> date:
|
||
return ts.astimezone(now.tzinfo).date()
|
||
|
||
|
||
def _streak_days(checkins: Iterable[DailyCheckin], today: date, pred: Any) -> int:
|
||
"""Length of the run of consecutive check-in days satisfying `pred`, ending at the anchor day."""
|
||
by_day = {c.day: c for c in checkins if c.day <= today}
|
||
day = today if today in by_day else today - timedelta(days=1)
|
||
n = 0
|
||
while (c := by_day.get(day)) is not None and pred(c):
|
||
n += 1
|
||
day -= timedelta(days=1)
|
||
return n
|
||
|
||
|
||
def _therapy_day(profile: PatientProfile, day: date) -> int:
|
||
return (day - profile.therapy_start).days if profile.therapy_start else 0
|
||
|
||
|
||
def _missed_days(d: PatientData, now: datetime, first: date) -> set[date]:
|
||
# Same sources as rule missed_doses_7d: dose records and the check-in answer "did not take".
|
||
days = {_local(x.planned_at, now) for x in d.doses if x.status in ("missed", "skipped") and x.planned_at <= now}
|
||
days |= {c.day for c in d.checkins if c.med_taken is False}
|
||
return {x for x in days if first <= x <= now.date()}
|
||
|
||
|
||
def discontinuation_risk(d: PatientData, now: datetime) -> DiscontinuationRisk:
|
||
"""Deterministic discontinuation-risk index; see the weight table above."""
|
||
today = now.date()
|
||
first = today - timedelta(days=RISK_WINDOW_DAYS - 1)
|
||
score = 0
|
||
factors: list[str] = []
|
||
|
||
missed = len(_missed_days(d, now, first))
|
||
if missed:
|
||
score += W_MISSED_MANY if missed >= STREAK_DAYS else W_MISSED_SOME
|
||
factors.append(f"пропуски приёма {missed}/{RISK_WINDOW_DAYS} дн.")
|
||
|
||
side = _streak_days(d.checkins, today, lambda c: bool(c.side_effects))
|
||
if side:
|
||
score += W_SIDE_EFFECTS_LONG if side >= STREAK_DAYS else W_SIDE_EFFECTS_SHORT
|
||
factors.append(f"побочные эффекты {side} дн. подряд")
|
||
|
||
quit_since = today - timedelta(days=QUIT_WINDOW_DAYS - 1)
|
||
quit_days = [c.day for c in d.checkins if c.quit_intent and quit_since <= c.day <= today]
|
||
if quit_days:
|
||
score += W_QUIT_INTENT
|
||
factors.append(f"хотел бросить (день {_therapy_day(d.profile, max(quit_days))})")
|
||
if any(a.code == "quit_intent" and a.acked_at is None for a in d.alerts):
|
||
score += W_QUIT_ALERT_OPEN
|
||
factors.append("не разобран сигнал «хочет бросить»")
|
||
|
||
low = _streak_days(d.checkins, today, lambda c: c.mood is not None and c.mood <= MOOD_LOW)
|
||
if low >= STREAK_DAYS:
|
||
score += W_MOOD_LOW
|
||
factors.append(f"настроение ≤ {MOOD_LOW} {low} дн. подряд")
|
||
|
||
drinking = sum(1 for c in d.checkins if first <= c.day <= today and c.alcohol_bucket not in (None, "none"))
|
||
if drinking:
|
||
score += W_ALCOHOL_MANY if drinking >= STREAK_DAYS else W_ALCOHOL_SOME
|
||
factors.append(f"алкоголь {drinking} дн. из {RISK_WINDOW_DAYS}")
|
||
|
||
score = min(score, 100)
|
||
level: Literal["low", "medium", "high"] = (
|
||
"high" if score >= RISK_HIGH else "medium" if score >= RISK_MEDIUM else "low"
|
||
)
|
||
return DiscontinuationRisk(score=score, level=level, factors=factors)
|
||
|
||
|
||
# --------------------------------------------------------------------------- list item
|
||
|
||
|
||
def _complete(d: PatientData, scale: str) -> list[ScaleResult]:
|
||
return [r for r in d.results if r.scale == scale and r.complete] # repo returns them oldest first
|
||
|
||
|
||
def trend(d: PatientData) -> Literal["up", "down", "flat", "unknown"]:
|
||
"""Direction of symptom scores (up = worse) from the scale with the most recent pair of results."""
|
||
pairs = [(s, rs[-2:]) for s in TREND_MIN_CHANGE if len(rs := _complete(d, s)) >= 2]
|
||
if not pairs:
|
||
return "unknown"
|
||
scale, (prev, last) = max(pairs, key=lambda p: p[1][-1].ts)
|
||
delta = last.total - prev.total
|
||
if abs(delta) < TREND_MIN_CHANGE[scale]:
|
||
return "flat"
|
||
return "up" if delta > 0 else "down"
|
||
|
||
|
||
def adherence_7d(d: PatientData, now: datetime) -> float | None:
|
||
"""Taken / planned doses of the last 7 days; doses still `pending` (not resolved yet) are not counted."""
|
||
since = now - timedelta(days=RISK_WINDOW_DAYS)
|
||
resolved = [x for x in d.doses if since < x.planned_at <= now and x.status != "pending"]
|
||
if not resolved:
|
||
return None
|
||
return round(sum(1 for x in resolved if x.status == "taken") / len(resolved), 2)
|
||
|
||
|
||
def _drug_label(rx: Prescription) -> str:
|
||
return f"{rx.drug} {rx.dose}".strip()
|
||
|
||
|
||
def _active_rx(d: PatientData, today: date) -> Prescription | None:
|
||
active = [
|
||
r
|
||
for r in d.prescriptions
|
||
if (r.start_date is None or r.start_date <= today) and (r.end_date is None or r.end_date >= today)
|
||
]
|
||
return (active or list(d.prescriptions) or [None])[-1]
|
||
|
||
|
||
def _last_event(d: PatientData, now: datetime) -> str:
|
||
alert = d.alerts[0] if d.alerts else None
|
||
scales = [r for r in d.results if r.complete and r.scale in _SCALE_LABELS]
|
||
scale = scales[-1] if scales else None
|
||
if d.contact_requested_at is not None and all(
|
||
d.contact_requested_at >= ts for ts in [x.ts for x in (alert, scale) if x is not None]
|
||
):
|
||
return "Пациент просит врача связаться"
|
||
if scale is not None and (alert is None or scale.ts > alert.ts):
|
||
day = _therapy_day(d.profile, _local(scale.ts, now))
|
||
return f"{_SCALE_LABELS[scale.scale]} = {scale.total} (день {day})"
|
||
return alert.reason if alert is not None else ""
|
||
|
||
|
||
def _age(patient: Patient | None, profile: PatientProfile, today: date) -> int:
|
||
birth_year = (patient.birth_year if patient else None) or profile.birth_year
|
||
return today.year - birth_year if birth_year else 0
|
||
|
||
|
||
def list_item(d: PatientData, patient: Patient | None, now: datetime) -> PatientListItem:
|
||
p = d.profile
|
||
today = now.date()
|
||
open_alerts = [a for a in d.alerts if a.acked_at is None]
|
||
# ADR-006: the colour is derived from open alert levels (the source of truth), not from the cached column.
|
||
status = patient_status(AlertLevel[a.level.upper()] for a in open_alerts)
|
||
phq9, pcl5 = _complete(d, "phq9"), _complete(d, "pcl5")
|
||
rx = _active_rx(d, today)
|
||
return PatientListItem(
|
||
token=p.token,
|
||
display_name=patient.display_name if patient else anonymous_name(p.token),
|
||
age=_age(patient, p, today),
|
||
modules=[cast(Module, m) for m in p.modules or [] if m in _MODULES],
|
||
drug=_drug_label(rx) if rx else None,
|
||
therapy_day=_therapy_day(p, today),
|
||
next_visit=p.next_visit,
|
||
status_color=status.color,
|
||
top_alert_level=cast(LevelName, status.top_level.name.lower()) if status.top_level is not None else None,
|
||
open_alerts=len(open_alerts),
|
||
phq9_last=phq9[-1].total if phq9 else None,
|
||
pcl5_last=pcl5[-1].total if pcl5 else None,
|
||
trend=trend(d),
|
||
adherence_7d=adherence_7d(d, now),
|
||
discontinuation_risk=discontinuation_risk(d, now),
|
||
last_event=_last_event(d, now),
|
||
)
|
||
|
||
|
||
def sort_key(item: PatientListItem) -> tuple[int, int, str]:
|
||
"""red → yellow → grey → green, then discontinuation risk (desc), then name for a stable order."""
|
||
return (COLOR_ORDER[item.status_color], -item.discontinuation_risk.score, item.display_name)
|
||
|
||
|
||
# --------------------------------------------------------------------------- card
|
||
|
||
|
||
def alert_out(a: Alert, patient_name: str) -> AlertOut:
|
||
level = cast(LevelName, a.level)
|
||
return AlertOut(
|
||
id=a.id,
|
||
token=a.token,
|
||
patient_name=patient_name,
|
||
ts=a.ts,
|
||
level=level,
|
||
color=cast(Color, color([AlertLevel[a.level.upper()]])),
|
||
code=a.code,
|
||
reason=a.reason,
|
||
acked_by=a.acked_by,
|
||
acked_at=a.acked_at,
|
||
)
|
||
|
||
|
||
def series(d: PatientData, now: datetime) -> list[SeriesPoint]:
|
||
since = now.date() - timedelta(days=CARD_DAYS)
|
||
out: list[SeriesPoint] = []
|
||
for c in d.checkins:
|
||
if not since <= c.day <= now.date():
|
||
continue
|
||
if c.mood is not None:
|
||
out.append(SeriesPoint(day=c.day, metric="mood", value=float(c.mood)))
|
||
if c.sleep_bucket in SLEEP_HOURS:
|
||
out.append(SeriesPoint(day=c.day, metric="sleep_hours", value=SLEEP_HOURS[c.sleep_bucket]))
|
||
if c.alcohol_bucket in ALCOHOL_LEVEL:
|
||
out.append(SeriesPoint(day=c.day, metric="alcohol", value=ALCOHOL_LEVEL[c.alcohol_bucket]))
|
||
for r in d.results:
|
||
day = _local(r.ts, now)
|
||
if r.complete and r.scale in _METRICS and since <= day and r.ts <= now:
|
||
out.append(SeriesPoint(day=day, metric=cast(Metric, r.scale), value=float(r.total), scheduled=r.scheduled))
|
||
return sorted(out, key=lambda p: (p.day, p.metric))
|
||
|
||
|
||
def doses(d: PatientData, now: datetime) -> list[DoseOut]:
|
||
labels = {r.id: _drug_label(r) for r in d.prescriptions}
|
||
since = now - timedelta(days=CARD_DAYS)
|
||
return [
|
||
DoseOut(planned_at=x.planned_at, drug=labels.get(x.prescription_id, ""), status=x.status) # type: ignore[arg-type]
|
||
for x in d.doses
|
||
if since <= x.planned_at <= now + timedelta(days=1)
|
||
]
|
||
|
||
|
||
def prescriptions(d: PatientData) -> list[PrescriptionOut]:
|
||
out = []
|
||
for r in d.prescriptions:
|
||
schedule = dict(r.schedule or {})
|
||
out.append(
|
||
PrescriptionOut(
|
||
drug=r.drug,
|
||
dose=r.dose,
|
||
schedule={"times": [str(t) for t in schedule.get("times", [])]},
|
||
instructions=" ".join(str(t) for t in _as_list(schedule.get("instructions"))),
|
||
start_date=r.start_date,
|
||
end_date=r.end_date,
|
||
)
|
||
)
|
||
return out
|
||
|
||
|
||
def _as_list(value: Any) -> list[Any]:
|
||
if value is None:
|
||
return []
|
||
return list(value) if isinstance(value, list) else [value]
|
||
|
||
|
||
def _plan_line(step: Any) -> str:
|
||
# Same rendering as the dialog's safety-plan reply (app/dialog/actions.py).
|
||
if isinstance(step, dict):
|
||
return ": ".join(str(v) for v in step.values() if v)
|
||
return str(step)
|
||
|
||
|
||
async def safety_plan(session: AsyncSession, token: str) -> list[str] | None:
|
||
row = await session.get(SafetyPlan, token)
|
||
return [_plan_line(s) for s in row.steps] if row is not None and row.steps else None
|
||
|
||
|
||
async def build_card(session: AsyncSession, d: PatientData, patient: Patient | None, now: datetime) -> PatientCard:
|
||
"""Card without the summary (the caller adds it: the LLM call must not hold the DB session)."""
|
||
item = list_item(d, patient, now)
|
||
return PatientCard(
|
||
patient=item,
|
||
prescriptions=prescriptions(d),
|
||
series=series(d, now),
|
||
doses=doses(d, now),
|
||
alerts=[alert_out(a, item.display_name) for a in d.alerts],
|
||
safety_plan=await safety_plan(session, d.profile.token),
|
||
)
|