Files
PTSRaiAgent/app/api/doctor_views.py
ElebobaandClaude Opus 5.5 25728c4b31
CI / ruff · pytest (pull_request) Successful in 1m25s
feat(backend): API stage 1 on real data instead of fixtures
Doctor list/card/alerts/ack/connect, dialog events/outbox/history and demo
controls now read the identity and clinical DBs. Rule-based discontinuation
risk index, trend, adherence; every doctor read is audited.

Refs #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:34:40 +03:00

369 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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),
)