"""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), )