"""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 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 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() 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), ) 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 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 "Пациент", 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), )