"""Patient snapshot for the rules engine, assembled from the clinical DB (ADR-007). Maps storage names to `app.core.rules` names (`sleep_bucket` → `sleep_hours_bucket`, `alcohol_bucket` → `alcohol`), derives age from `birth_year` and the therapy day from `therapy_start`. Time comes in as `as_of` (from `app.core.clock`), never from the wall clock. Must not import `app.db.identity` (guardrail #5). """ from datetime import datetime, timedelta from typing import Any, get_args from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.rules import ( CssrsRisk, DailyCheckin, DoseRecord, Module, PatientSnapshot, ScalePoint, ) from app.db import repo from app.db.clinical import Dose, Message, PatientProfile # Risk screens are stored as scale results but reach the rules as `cssrs_risk`, not as scale points. CSSRS_SCALES = frozenset({"cssrs_screen", "cssrs"}) # A C-SSRS result counts as the current risk for this long (same window as PHQ-9 item 9 and the dialog's # C-SSRS cooldown): one old "high" must not restart the crisis protocol after the doctor has acted on it. CSSRS_RECENT = timedelta(hours=24) # Unknown sex/age fall back to the more cautious option: lower AUDIT-C cut-off, young-adult monitoring. _UNKNOWN_SEX = "f" _UNKNOWN_AGE = 0 _MODULES = frozenset(get_args(Module)) _CSSRS_RISKS = frozenset(get_args(CssrsRisk)) def _cssrs_risk(results: list[Any], as_of: datetime) -> CssrsRisk | None: recent = [r for r in results if r.scale in CSSRS_SCALES and as_of - CSSRS_RECENT < r.ts <= as_of] if not recent: return None severity = recent[-1].severity return severity if severity in _CSSRS_RISKS else None async def build_snapshot( session: AsyncSession, token: str, as_of: datetime, *, profile: PatientProfile | None = None ) -> PatientSnapshot | None: """Snapshot of everything the rules may look at; None if the patient has no clinical profile.""" profile = profile or await repo.get_profile(session, token) if profile is None: return None checkins = [ DailyCheckin( day=c.day, mood=c.mood, med_taken=c.med_taken, sleep_hours_bucket=c.sleep_bucket, # type: ignore[arg-type] alcohol=c.alcohol_bucket, # type: ignore[arg-type] side_effects=list(c.side_effects or []), quit_intent=bool(c.quit_intent), nightmares=c.nightmares, ) for c in await repo.list_checkins(session, token) ] results = [r for r in await repo.list_scale_results(session, token) if r.complete] doses = (await session.scalars(select(Dose).where(Dose.token == token).order_by(Dose.planned_at))).all() last_in = await session.scalar( select(func.max(Message.ts)).where(Message.token == token, Message.direction == "in") ) state = await repo.get_dialog_state(session, token) facts = (state.data.get("facts") or {}) if state is not None else {} return PatientSnapshot( token=token, as_of=as_of, age=as_of.year - profile.birth_year if profile.birth_year else _UNKNOWN_AGE, sex=profile.sex if profile.sex in ("m", "f") else _UNKNOWN_SEX, # type: ignore[arg-type] modules={m for m in profile.modules or [] if m in _MODULES}, # type: ignore[misc] therapy_day=(as_of.date() - profile.therapy_start).days if profile.therapy_start else 0, checkins=checkins, scale_results=[ ScalePoint(scale=r.scale, ts=r.ts, total=r.total, item9=r.item9, scheduled=r.scheduled) for r in results if r.scale not in CSSRS_SCALES ], doses=[DoseRecord(planned_at=d.planned_at, status=d.status) for d in doses], # type: ignore[arg-type] cssrs_risk=_cssrs_risk(results, as_of), weapon_access=facts.get("weapon_access"), last_patient_message_at=last_in, open_alert_codes=await repo.open_alert_codes(session, token), )