Files
PTSRaiAgent/app/services/snapshot.py
T
ElebobaandClaude Opus 5.5 4c0836a1be
CI / ruff · pytest (pull_request) Successful in 1m11s
feat(backend): monitoring service — snapshot, rules, alerts, status colour, clock persistence
- app/services/snapshot.py: PatientSnapshot from the clinical DB (field mapping, age, therapy day,
  recent C-SSRS risk, weapon access from dialog facts)
- app/services/monitoring.py: RuleMonitoring registered via register_monitoring; persists alert/crisis
  actions, crisis_acute audit, enhanced_monitoring flag, cached status_color, notifies the doctor (and
  the doctor on duty for crises) after commit; a running crisis is not restarted
- demo clock offset persisted in clock_state, restored on scheduler start
- tests: Andrey's 13 days through the service → G G G Y Y G G G G Y Y Y R

Refs #38

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

94 lines
3.9 KiB
Python

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