feat(backend): monitoring service — snapshot, rules, alerts, status colour, clock persistence
CI / ruff · pytest (pull_request) Successful in 1m11s
CI / ruff · pytest (pull_request) Successful in 1m11s
- 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>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
"""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),
|
||||
)
|
||||
Reference in New Issue
Block a user