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:
+13
-3
@@ -1,14 +1,24 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.db import session
|
||||
from app.db.bootstrap import init_db
|
||||
from app.dialog.contracts import Event, get_engine
|
||||
from app.main import app
|
||||
from app.services.contracts import get_monitoring
|
||||
|
||||
MEM = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
async def test_stubs_are_usable() -> None:
|
||||
replies = await get_engine().handle("tok", Event(type="text", text="привет"))
|
||||
assert replies and replies[0].text
|
||||
assert await get_monitoring().after_data_change("tok") == []
|
||||
await session.configure(identity_url=MEM, clinical_url=MEM)
|
||||
await init_db()
|
||||
try:
|
||||
replies = await get_engine().handle("tok", Event(type="text", text="привет"))
|
||||
assert replies and replies[0].text
|
||||
assert await get_monitoring().after_data_change("tok") == [] # unknown patient → no actions
|
||||
assert await get_monitoring().is_in_crisis("tok") is False
|
||||
finally:
|
||||
await session.configure()
|
||||
|
||||
|
||||
def test_api_fixture_routes() -> None:
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"""MonitoringService (#38): snapshot from the clinical DB → real rules → alerts, colour cache, audit, notify."""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import Settings
|
||||
from app.core import clock
|
||||
from app.core.types import ScrubbedText
|
||||
from app.db import repo, session
|
||||
from app.db.audit import AuditLog
|
||||
from app.db.bootstrap import init_db
|
||||
from app.db.clinical import Dose, Prescription
|
||||
from app.integrations.base import MemoryNotifier, integrations
|
||||
from app.services import contracts, monitoring
|
||||
from app.services.snapshot import build_snapshot
|
||||
|
||||
TOKEN = "a" * 32
|
||||
DOCTOR = "doc-smirnova"
|
||||
MEM = "sqlite+aiosqlite:///:memory:"
|
||||
DAY0 = datetime(2026, 10, 5, 8, 0)
|
||||
|
||||
# Persona (docs/product/01_demo_persona.md): day → (dose taken, mood, sleep, nightmares, alcohol, side effects,
|
||||
# quit intent). Same table as tests/test_dialog_veteran_days.py, written straight to the DB.
|
||||
TABLE: dict[int, tuple[bool, int, str, bool, str, list[str], bool]] = {
|
||||
1: (True, 4, "4-6", True, "none", [], False),
|
||||
2: (True, 4, "4-6", True, "none", ["nausea"], False),
|
||||
3: (True, 4, "4-6", True, "none", ["nausea"], False),
|
||||
4: (True, 3, "4-6", False, "none", ["nausea"], False),
|
||||
5: (False, 3, "4-6", False, "none", [], True),
|
||||
6: (True, 4, "6-8", False, "none", [], False),
|
||||
7: (True, 4, "6-8", False, "none", [], False),
|
||||
8: (True, 4, "<4", True, "1-2", [], False),
|
||||
9: (True, 4, "<4", True, "more", [], False),
|
||||
10: (True, 4, "<4", True, "more", [], False),
|
||||
11: (True, 3, "4-6", False, "1-2", [], False),
|
||||
12: (True, 3, "4-6", False, "none", [], False),
|
||||
13: (True, 2, "<4", True, "none", [], False),
|
||||
}
|
||||
COLORS = ["green"] * 3 + ["yellow"] * 2 + ["green"] * 4 + ["yellow"] * 3 + ["red"]
|
||||
|
||||
|
||||
class FailingNotifier:
|
||||
async def send(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise ConnectionError("channel down")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[MemoryNotifier]:
|
||||
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
|
||||
notifier = MemoryNotifier()
|
||||
monkeypatch.setattr(integrations, "notifier", notifier)
|
||||
clock.set_now(DAY0)
|
||||
await session.configure(identity_url=MEM, clinical_url=MEM)
|
||||
await init_db()
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.upsert_profile(
|
||||
s,
|
||||
token=TOKEN,
|
||||
doctor_id=DOCTOR,
|
||||
modules=["veteran", "depression"],
|
||||
therapy_start=clock.today(),
|
||||
birth_year=1992,
|
||||
sex="m",
|
||||
)
|
||||
s.add(Prescription(token=TOKEN, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}))
|
||||
for scale, total in (("pcl5", 46), ("phq9", 14), ("auditc", 3)):
|
||||
await repo.add_scale_result(
|
||||
s,
|
||||
token=TOKEN,
|
||||
scale=scale,
|
||||
total=total,
|
||||
severity="-",
|
||||
complete=True,
|
||||
ts=clock.now() - timedelta(days=3),
|
||||
item9=0 if scale == "phq9" else None,
|
||||
)
|
||||
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={}, now=clock.now())
|
||||
yield notifier
|
||||
clock.register_persistence(None, None)
|
||||
clock.reset()
|
||||
await session.configure()
|
||||
|
||||
|
||||
async def scale(
|
||||
name: str, total: int, *, item9: int | None = None, severity: str = "-", scheduled: bool = True
|
||||
) -> None:
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.add_scale_result(
|
||||
s,
|
||||
token=TOKEN,
|
||||
scale=name,
|
||||
total=total,
|
||||
severity=severity,
|
||||
complete=True,
|
||||
ts=clock.now(),
|
||||
item9=item9,
|
||||
scheduled=scheduled,
|
||||
)
|
||||
|
||||
|
||||
async def color() -> str | None:
|
||||
async with session.get_clinical_session() as s:
|
||||
profile = await repo.get_profile(s, TOKEN)
|
||||
assert profile is not None
|
||||
return profile.status_color
|
||||
|
||||
|
||||
async def ack_all() -> None:
|
||||
async with session.get_clinical_session() as s:
|
||||
for a in await repo.list_alerts(s, token=TOKEN, open_only=True):
|
||||
await repo.ack_alert(s, alert_id=a.id, doctor_id=DOCTOR, ts=clock.now())
|
||||
await monitoring.refresh_status(s, TOKEN)
|
||||
|
||||
|
||||
async def live_day(day: int) -> None:
|
||||
"""What the dialog writes on one persona day: the dose, an incoming message and the evening check-in."""
|
||||
taken, mood, sleep, nightmares, alcohol, effects, quit_intent = TABLE[day]
|
||||
clock.set_now(DAY0 + timedelta(days=day))
|
||||
async with session.get_clinical_session() as s:
|
||||
rx = await s.scalar(select(Prescription))
|
||||
assert rx is not None
|
||||
s.add(Dose(token=TOKEN, prescription_id=rx.id, planned_at=clock.now(), status="taken" if taken else "skipped"))
|
||||
await repo.add_message(s, token=TOKEN, direction="in", text=ScrubbedText("..."), ts=clock.now())
|
||||
await repo.upsert_checkin(
|
||||
s,
|
||||
token=TOKEN,
|
||||
day=clock.today(),
|
||||
now=clock.now(),
|
||||
mood=mood,
|
||||
med_taken=taken,
|
||||
sleep_bucket=sleep,
|
||||
alcohol_bucket=alcohol,
|
||||
nightmares=nightmares,
|
||||
side_effects=effects,
|
||||
quit_intent=quit_intent,
|
||||
)
|
||||
|
||||
|
||||
async def test_service_is_registered() -> None:
|
||||
assert isinstance(contracts.get_monitoring(), monitoring.RuleMonitoring)
|
||||
|
||||
|
||||
async def test_andrey_13_days(db: MemoryNotifier) -> None:
|
||||
mon = monitoring.RuleMonitoring()
|
||||
colors: list[str | None] = []
|
||||
returned: dict[int, list[str]] = {}
|
||||
for day in range(1, 14):
|
||||
if day == 6:
|
||||
await ack_all() # the doctor called Andrey after the day-5 alerts
|
||||
await live_day(day)
|
||||
if day == 7:
|
||||
await scale("pcl5", 44)
|
||||
await scale("phq9", 13, item9=0)
|
||||
actions = await mon.after_data_change(TOKEN)
|
||||
if day == 13:
|
||||
assert [a.code for a in actions] == ["mood_low_3d"] # dialog action: unscheduled PHQ-9
|
||||
await scale("cssrs_screen", 0, severity="none", scheduled=False)
|
||||
await scale("phq9", 18, item9=2, scheduled=False)
|
||||
actions += await mon.after_data_change(TOKEN)
|
||||
returned[day] = [a.type for a in actions]
|
||||
colors.append(await color())
|
||||
|
||||
assert colors == COLORS
|
||||
assert "med_info" in returned[5] and "alert" not in returned[5] # alerts are persisted, not returned
|
||||
assert {"med_info", "suggest_visit"} <= set(returned[10])
|
||||
assert {"crisis", "show_safety_plan", "schedule_scale"} <= set(returned[13])
|
||||
async with session.get_clinical_session() as s:
|
||||
alerts = list(await repo.list_alerts(s, token=TOKEN))
|
||||
crisis_audit = (await s.scalars(select(AuditLog).where(AuditLog.action == "crisis_acute"))).all()
|
||||
codes = [a.code for a in reversed(alerts)]
|
||||
assert codes == [
|
||||
"side_effect_3d",
|
||||
"quit_intent",
|
||||
"sleep_lt4_3n",
|
||||
"alcohol_3d",
|
||||
"phq9_item9", # same evaluation: YAML order
|
||||
"phq9_rise",
|
||||
]
|
||||
assert len(crisis_audit) == 1 and crisis_audit[0].token == TOKEN and crisis_audit[0].actor == "system"
|
||||
acute = [(d, lvl) for d, lvl, _text, _tok in db.sent if lvl == "acute"]
|
||||
assert acute == [(DOCTOR, "acute"), (monitoring.ON_DUTY_DOCTOR, "acute")]
|
||||
assert all(tok == TOKEN for *_rest, tok in db.sent)
|
||||
assert await mon.is_in_crisis(TOKEN)
|
||||
|
||||
|
||||
async def test_running_crisis_is_not_restarted(db: MemoryNotifier) -> None:
|
||||
mon = monitoring.RuleMonitoring()
|
||||
await scale("phq9", 18, item9=1)
|
||||
first = await mon.after_data_change(TOKEN)
|
||||
assert any(a.type == "crisis" for a in first)
|
||||
clock.advance(hours=1)
|
||||
again = await mon.after_data_change(TOKEN)
|
||||
assert not any(a.code == "phq9_item9" for a in again)
|
||||
async with session.get_clinical_session() as s:
|
||||
assert [a.code for a in await repo.list_alerts(s, token=TOKEN, open_only=True) if a.level == "acute"] == [
|
||||
"phq9_item9"
|
||||
]
|
||||
await ack_all()
|
||||
assert not await mon.is_in_crisis(TOKEN)
|
||||
assert await color() == "green"
|
||||
|
||||
|
||||
async def test_old_cssrs_high_does_not_restart_crisis(db: MemoryNotifier) -> None:
|
||||
mon = monitoring.RuleMonitoring()
|
||||
await scale("cssrs_screen", 4, severity="high", scheduled=False)
|
||||
assert any(a.type == "crisis" for a in await mon.after_data_change(TOKEN))
|
||||
await ack_all()
|
||||
clock.advance(days=2)
|
||||
assert not any(a.type == "crisis" for a in await mon.after_data_change(TOKEN))
|
||||
|
||||
|
||||
async def test_weapon_access_from_dialog_facts(db: MemoryNotifier) -> None:
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.set_dialog_state(
|
||||
s, token=TOKEN, state="idle", data={"facts": {"weapon_access": True}}, now=clock.now()
|
||||
)
|
||||
snap = await build_snapshot(s, TOKEN, clock.now())
|
||||
assert snap is not None and snap.weapon_access is True
|
||||
await scale("cssrs_screen", 3, severity="moderate", scheduled=False)
|
||||
actions = await monitoring.RuleMonitoring().after_data_change(TOKEN)
|
||||
assert {a.code for a in actions if a.type == "crisis"} == {"weapon_access_risk"}
|
||||
plan = next(a for a in actions if a.type == "show_safety_plan")
|
||||
assert plan.params["block"] == "means_restriction"
|
||||
assert await color() == "red"
|
||||
|
||||
|
||||
async def test_snapshot_mapping_and_unknown_patient(db: MemoryNotifier) -> None:
|
||||
clock.set_now(DAY0 + timedelta(days=4))
|
||||
await live_day(4)
|
||||
async with session.get_clinical_session() as s:
|
||||
snap = await build_snapshot(s, TOKEN, clock.now())
|
||||
assert await build_snapshot(s, "f" * 32, clock.now()) is None
|
||||
assert snap is not None
|
||||
assert (snap.age, snap.sex, snap.therapy_day, snap.modules) == (34, "m", 4, {"veteran", "depression"})
|
||||
c = snap.checkins[-1]
|
||||
assert (c.sleep_hours_bucket, c.alcohol, c.side_effects) == ("4-6", "none", ["nausea"])
|
||||
assert [p.scale for p in snap.scale_results] == ["pcl5", "phq9", "auditc"]
|
||||
assert snap.last_patient_message_at is not None and snap.last_patient_message_at.date() == clock.today()
|
||||
assert await monitoring.RuleMonitoring().after_data_change("f" * 32) == []
|
||||
|
||||
|
||||
async def test_young_adult_enhanced_monitoring(db: MemoryNotifier) -> None:
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.upsert_profile(s, token=TOKEN, birth_year=2004)
|
||||
actions = await monitoring.RuleMonitoring().after_data_change(TOKEN)
|
||||
assert not any(a.type == "enhanced_monitoring" for a in actions) # applied by the service itself
|
||||
async with session.get_clinical_session() as s:
|
||||
profile = await repo.get_profile(s, TOKEN)
|
||||
assert profile is not None and profile.enhanced_monitoring is True
|
||||
assert not await repo.list_alerts(s, token=TOKEN)
|
||||
assert await color() == "green"
|
||||
|
||||
|
||||
async def test_notifier_failure_keeps_alert(db: MemoryNotifier, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(integrations, "notifier", FailingNotifier())
|
||||
await scale("pcl5", 55)
|
||||
await monitoring.RuleMonitoring().after_data_change(TOKEN)
|
||||
async with session.get_clinical_session() as s:
|
||||
assert [(a.code, a.level) for a in await repo.list_alerts(s, token=TOKEN)] == [("pcl5_severe", "high")]
|
||||
assert await color() == "yellow"
|
||||
|
||||
|
||||
async def test_clock_offset_persists(db: MemoryNotifier) -> None:
|
||||
clock.reset()
|
||||
await monitoring.init_clock_persistence()
|
||||
clock.advance(days=3)
|
||||
await monitoring.flush_clock_writes()
|
||||
async with session.get_clinical_session() as s:
|
||||
assert await repo.load_clock_offset(s) == pytest.approx(timedelta(days=3).total_seconds())
|
||||
|
||||
# "Restart": in-memory offset lost, restored from clock_state.
|
||||
clock.register_persistence(None, None)
|
||||
clock.reset()
|
||||
await monitoring.init_clock_persistence()
|
||||
assert clock.offset() == timedelta(days=3)
|
||||
Reference in New Issue
Block a user