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:
@@ -12,6 +12,7 @@ from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.core import clock
|
||||
from app.core.intake_runtime import due_scales, intake_enabled, load_runtime
|
||||
@@ -21,6 +22,7 @@ from app.db.intake import QuestionnaireTask
|
||||
from app.db.session import get_clinical_session
|
||||
from app.dialog.contracts import Event, Reply, get_engine
|
||||
from app.integrations.base import integrations
|
||||
from app.services import monitoring
|
||||
from app.services import questionnaire as service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -163,6 +165,10 @@ async def _run():
|
||||
|
||||
async def start():
|
||||
global _worker
|
||||
try:
|
||||
await monitoring.init_clock_persistence()
|
||||
except (OSError, SQLAlchemyError) as exc:
|
||||
log.error("clock persistence unavailable error=%s", type(exc).__name__)
|
||||
if _worker is None and intake_enabled():
|
||||
_worker = asyncio.create_task(_run())
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Monitoring service (#38): patient snapshot → deterministic rules → persisted alerts (ADR-007).
|
||||
|
||||
`after_data_change(token)` is called by the dialog engine after it commits clinical data (and by the scheduler
|
||||
on silence). It builds a `PatientSnapshot`, runs `app.core.rules.evaluate`, then in one transaction:
|
||||
* writes every `alert`/`crisis` action to `alerts` (a crisis is an `acute` alert) and, for a crisis,
|
||||
an audit record `crisis_acute` (guardrail #6);
|
||||
* applies `enhanced_monitoring` to the profile;
|
||||
* refreshes the cached `status_color` of the profile (ADR-006; the source of truth stays the open alerts).
|
||||
After the commit the doctor is notified through `integrations.notifier` (a crisis also goes to the doctor on
|
||||
duty), and only the *dialog* actions are returned to the engine — it never creates alerts itself.
|
||||
|
||||
A crisis rule whose acute alert is still open stays silent: the crisis script is already running and the
|
||||
doctor already knows, so it is neither restarted nor re-alerted (`rules.evaluate` never suppresses crises).
|
||||
|
||||
The virtual clock offset is persisted in `clock_state` (ADR-005) by `init_clock_persistence()`.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core import clock
|
||||
from app.core.rules import Action, evaluate, patient_status
|
||||
from app.core.types import AlertLevel
|
||||
from app.db import repo
|
||||
from app.db.audit import audit
|
||||
from app.db.session import get_clinical_session
|
||||
from app.integrations.base import integrations
|
||||
from app.services.contracts import register_monitoring
|
||||
from app.services.snapshot import build_snapshot
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SYSTEM_ACTOR = "system"
|
||||
# Notification address of the doctor on duty: acute alerts must reach someone even when the attending doctor
|
||||
# is off (SPEC: crisis → immediate escalation). The notifier maps it to the duty channel.
|
||||
ON_DUTY_DOCTOR = "on-duty"
|
||||
# Persisted by the service itself, never passed to the dialog engine.
|
||||
_SERVICE_ACTIONS = frozenset({"alert", "enhanced_monitoring"})
|
||||
|
||||
|
||||
async def refresh_status(session: AsyncSession, token: str) -> str:
|
||||
"""Recompute and cache the dashboard colour from open alerts (also call it after an ack)."""
|
||||
alerts = await repo.list_alerts(session, token=token, open_only=True)
|
||||
status = patient_status(AlertLevel[a.level.upper()] for a in alerts)
|
||||
await repo.upsert_profile(session, token=token, status_color=status.color)
|
||||
return status.color
|
||||
|
||||
|
||||
def _drop_running_crises(actions: list[Action], open_acute: set[str]) -> list[Action]:
|
||||
"""Silence every action of a crisis rule whose acute alert is still open (see module docstring)."""
|
||||
running = {a.code for a in actions if a.type == "crisis" and a.code in open_acute}
|
||||
return [a for a in actions if a.code not in running]
|
||||
|
||||
|
||||
class RuleMonitoring:
|
||||
async def after_data_change(self, token: str) -> list[Action]:
|
||||
now = clock.now()
|
||||
notify: list[tuple[str, str, str]] = [] # (doctor_id, level, text) — sent after the commit
|
||||
async with get_clinical_session() as s:
|
||||
profile = await repo.get_profile(s, token)
|
||||
snapshot = await build_snapshot(s, token, now, profile=profile)
|
||||
if profile is None or snapshot is None:
|
||||
return []
|
||||
open_acute = {a.code for a in await repo.list_alerts(s, token=token, open_only=True) if a.level == "acute"}
|
||||
actions = _drop_running_crises(evaluate(snapshot), open_acute)
|
||||
for action in actions:
|
||||
if action.type == "enhanced_monitoring":
|
||||
profile.enhanced_monitoring = True
|
||||
if not action.creates_alert or action.level is None:
|
||||
continue
|
||||
alert = await repo.add_alert(
|
||||
s,
|
||||
token=token,
|
||||
level=action.level,
|
||||
code=action.code,
|
||||
reason=action.reason_ru,
|
||||
ts=now,
|
||||
params=action.params,
|
||||
)
|
||||
level = action.level.name.lower()
|
||||
notify.append((profile.doctor_id, level, action.reason_ru))
|
||||
if action.type == "crisis":
|
||||
await audit(
|
||||
s,
|
||||
actor=SYSTEM_ACTOR,
|
||||
action="crisis_acute",
|
||||
token=token,
|
||||
details={"alert_id": alert.id, "code": action.code},
|
||||
ts=now,
|
||||
)
|
||||
notify.append((ON_DUTY_DOCTOR, level, action.reason_ru))
|
||||
await refresh_status(s, token)
|
||||
log.info("monitoring token=%s actions=%s", token, [a.code for a in actions])
|
||||
for doctor_id, level, text in notify:
|
||||
await _notify(doctor_id, level, text, token)
|
||||
return [a for a in actions if a.type not in _SERVICE_ACTIONS]
|
||||
|
||||
async def is_in_crisis(self, token: str) -> bool:
|
||||
async with get_clinical_session() as s:
|
||||
alerts = await repo.list_alerts(s, token=token, open_only=True)
|
||||
return any(a.level == "acute" for a in alerts)
|
||||
|
||||
|
||||
async def _notify(doctor_id: str, level: str, text: str, token: str) -> None:
|
||||
# The alert is already committed and visible on the dashboard; a failing channel must not undo it.
|
||||
try:
|
||||
await integrations.notifier.send(doctor_id, level, text, token) # type: ignore[arg-type]
|
||||
except Exception as exc: # noqa: BLE001 — any channel error
|
||||
log.warning("notify failed token=%s level=%s error=%s", token, level, type(exc).__name__)
|
||||
|
||||
|
||||
register_monitoring(RuleMonitoring)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------- demo clock persistence
|
||||
|
||||
_writes: set[asyncio.Task[None]] = set()
|
||||
|
||||
|
||||
async def _write_offset() -> None:
|
||||
# Writes the offset current at write time, so out-of-order tasks still leave the latest value stored.
|
||||
try:
|
||||
async with get_clinical_session() as s:
|
||||
await repo.save_clock_offset(s, clock.offset().total_seconds())
|
||||
except (OSError, SQLAlchemyError) as exc:
|
||||
log.warning("clock offset not saved error=%s", type(exc).__name__)
|
||||
|
||||
|
||||
def _save_offset(_: timedelta) -> None:
|
||||
"""Sync hook for `clock.register_persistence`: schedules the DB write on the running loop."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
log.warning("clock offset not saved: no running event loop")
|
||||
return
|
||||
task = loop.create_task(_write_offset())
|
||||
_writes.add(task)
|
||||
task.add_done_callback(_writes.discard)
|
||||
|
||||
|
||||
async def flush_clock_writes() -> None:
|
||||
"""Wait for pending offset writes (tests, shutdown)."""
|
||||
if _writes:
|
||||
await asyncio.gather(*list(_writes))
|
||||
|
||||
|
||||
async def init_clock_persistence() -> datetime:
|
||||
"""Restore the virtual clock offset from `clock_state` and save every later change there."""
|
||||
async with get_clinical_session() as s:
|
||||
stored = timedelta(seconds=await repo.load_clock_offset(s))
|
||||
clock.register_persistence(lambda: stored, _save_offset)
|
||||
log.info("clock offset restored seconds=%s", int(stored.total_seconds()))
|
||||
return clock.now()
|
||||
@@ -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