Files
PTSRaiAgent/tests/test_dialog_veteran_days.py
ElebobaandClaude Opus 5.5 d06720fdab
CI / ruff · pytest (pull_request) Successful in 1m19s
fix(bot): close dialog engine gaps (C-SSRS repeat, scale intros, button payloads, step timeout)
- No same-day C-SSRS repeat: a risk screen completed within cssrs_cooldown_hours is not queued again
  after PHQ-9 item 9 >= 1 (scale crisis path, rule schedule_scale, scale_due); crisis script and alerts stay.
- Scale intro registry: register_scale_intro(scale_id, text_or_fn); start_scale(intro=None) uses it.
  veteran_daily migrated from per-scale on_enter hooks.
- Prefix triggers "button:<prefix>:*" with trigger_payload(event); medication buttons carry the dose
  (med:taken:<dose_id>, ...), med_pending dropped; payload-less legacy buttons answer as stale.
- Abandoned-step timeout: settings.abandon_after_hours (default 6 h) returns abandonable states
  (daily.*, meds.*, scale.* not in onboarding) to idle before the next event; never in crisis or with intake on.

Refs #56

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

392 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Acceptance: 13 days of the veteran Andrey (docs/product/01_demo_persona.md) through the real dialog engine.
Andrey is set up post-onboarding directly in the DBs (no dependency on #36): modules veteran + depression,
therapy day 0, sertraline 50 mg at 08:00, consent. Each day: the dose reminder (as the scheduler would send it)
→ button; the daily check-in → buttons + the free text from the table.
Lane B's MonitoringService (#38) is replaced by a double that builds a `PatientSnapshot` from the clinical DB,
runs the REAL `app.core.rules.evaluate`, persists alert/crisis actions (so dedup works) and returns the dialog
actions — the same split as the frozen contract (ADR-007). Drug instruction excerpts are synthetic (FakeDrugInfo).
"""
from collections.abc import AsyncIterator
from datetime import date, datetime, timedelta
from typing import Any
import pytest
from sqlalchemy import func, select
from app.config import Settings
from app.core import clock
from app.core.rules import Action, DailyCheckin, DoseRecord, PatientSnapshot, ScalePoint, evaluate, patient_status
from app.core.scales import load_scale
from app.core.types import AlertLevel
from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Dose, Message, Prescription
from app.dialog.contracts import Event, Reply
from app.dialog.engine import Engine
from app.dialog.flows.meds import load_meds_texts
from app.dialog.flows.veteran_daily import load_daily_texts
from app.integrations.base import DrugSection, integrations
from app.llm import med_qa as med_qa_module
from app.llm.gateway import LLMGateway
from app.llm.mock import MockTasks
from app.services import contracts as monitoring_contracts
TOKEN = "e" * 32
MEM = "sqlite+aiosqlite:///:memory:"
DAY0 = datetime(2026, 10, 5, 8, 0) # visit day; reminders at 08:00, check-ins at 20:00 (Europe/Moscow)
SRC = "ГРЛС: тестовая выдержка (синтетика)"
SERTRALINE = {
"side_effects": (
"Наиболее частые нежелательные реакции в начале терапии: тошнота, диарея, сухость во рту. "
"Тошнота обычно возникает в первые дни приёма и проходит в течение 1–2 недель. "
"Для уменьшения тошноты препарат принимают после еды."
),
"onset": (
"Терапевтический эффект развивается постепенно. "
"Улучшение обычно отмечается через 2–4 недели регулярного приёма. "
"Суточная доза может увеличиваться врачом до 200 мг."
),
"alcohol": (
"Совместный приём с алкоголем не рекомендуется. "
"Алкоголь может ухудшать сон и настроение и усиливать побочные эффекты."
),
}
# Persona table: day → (dose taken, mood, sleep, nightmares, alcohol, free text). "" = skip the free text.
TABLE: dict[int, tuple[bool, int, str, bool, str, str]] = {
1: (True, 4, "4-6", True, "none", ""),
2: (True, 4, "4-6", True, "none", "подташнивает с утра"),
3: (True, 4, "4-6", True, "none", "опять тошнит"),
4: (True, 3, "4-6", False, "none", "тошнота не проходит"),
5: (False, 3, "4-6", False, "none", "от таблеток только хуже, брошу"),
6: (True, 4, "6-8", False, "none", "врач позвонил, продолжаю"),
7: (True, 4, "6-8", False, "none", ""),
8: (True, 4, "<4", True, "1-2", "снится одно и то же"),
9: (True, 4, "<4", True, "more", ""),
10: (True, 4, "<4", True, "more", "без пива не могу уснуть"),
11: (True, 3, "4-6", False, "1-2", ""),
12: (True, 3, "4-6", False, "none", ""),
13: (True, 2, "<4", True, "none", "всё бессмысленно, устал от всего"),
}
# Expected dashboard colour at the end of each day (persona: 🟢🟢🟢🟡🟡🟢🟢🟢🟢🟡🟡🟡🔴).
COLORS = ["green"] * 3 + ["yellow"] * 2 + ["green"] * 4 + ["yellow"] * 3 + ["red"]
class FakeDrugInfo:
async def sections(self, inn: str) -> list[DrugSection]:
if inn != "сертралин":
return []
return [DrugSection(inn=inn, section=k, text=v, source=SRC) for k, v in SERTRALINE.items()]
class RuleMonitoring:
"""Test double of lane B's MonitoringService: real rules over a snapshot of the clinical DB."""
def __init__(self) -> None:
self.fired: list[str] = []
async def _snapshot(self, s: Any, token: str) -> PatientSnapshot:
now = clock.now()
profile = await repo.get_profile(s, token)
assert profile is not None and profile.therapy_start is not None and profile.birth_year is not 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(s, token)
]
results = [r for r in await repo.list_scale_results(s, token) if r.complete]
doses = (await s.scalars(select(Dose).where(Dose.token == token, Dose.status != "pending"))).all()
cssrs = [r for r in results if r.scale == "cssrs_screen"]
last_in = await s.scalar(select(func.max(Message.ts)).where(Message.token == token, Message.direction == "in"))
return PatientSnapshot(
token=token,
as_of=now,
age=now.year - profile.birth_year,
sex=profile.sex, # type: ignore[arg-type]
modules=set(profile.modules), # type: ignore[arg-type]
therapy_day=(now.date() - profile.therapy_start).days,
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 != "cssrs_screen"
],
doses=[DoseRecord(planned_at=d.planned_at, status=d.status) for d in doses], # type: ignore[arg-type]
cssrs_risk=cssrs[-1].severity if cssrs else None, # type: ignore[arg-type]
weapon_access=False,
last_patient_message_at=last_in,
open_alert_codes=await repo.open_alert_codes(s, token),
)
async def after_data_change(self, token: str) -> list[Action]:
async with session.get_clinical_session() as s:
actions = evaluate(await self._snapshot(s, token))
for a in actions:
if a.creates_alert:
assert a.level is not None
await repo.add_alert(
s, token=token, level=a.level, code=a.code, reason=a.reason_ru, ts=clock.now(), params=a.params
)
self.fired += [a.code for a in actions]
return [a for a in actions if a.type != "alert"]
async def is_in_crisis(self, token: str) -> bool:
async with session.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)
# ----------------------------------------------------------------------------------------------- fixtures
@pytest.fixture
async def andrey(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[RuleMonitoring]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
monkeypatch.setattr(integrations, "drug_info", FakeDrugInfo())
monkeypatch.setattr(med_qa_module, "get_gateway", lambda: LLMGateway(MockTasks()))
clock.set_now(DAY0)
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
now = clock.now()
async with session.get_identity_session() as s:
await identity_repo.create_patient(
s,
token=TOKEN,
display_name="Андрей С. (синтетика)",
snils="000-000-000 00",
clinic_id="c1",
doctor_id="doc-smirnova",
now=now,
birth_year=1992,
sex="m",
)
await identity_repo.record_consent(s, token=TOKEN, version="v1", now=now)
async with session.get_clinical_session() as s:
await repo.upsert_profile(
s,
token=TOKEN,
doctor_id="doc-smirnova",
modules=["veteran", "depression"],
therapy_start=now.date(),
next_visit=now.date() + timedelta(days=28),
birth_year=1992,
sex="m",
)
s.add(
Prescription(
token=TOKEN, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}, start_date=now.date()
)
)
screening = now - timedelta(days=3) # day −3 screening (persona)
for scale, total in (("pcptsd5", 4), ("pcl5", 46), ("phq9", 14), ("auditc", 3)):
await repo.add_scale_result(
s,
token=TOKEN,
scale=scale,
total=total,
severity="-",
complete=True,
ts=screening,
item9=0 if scale == "phq9" else None,
)
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=now)
mon = RuleMonitoring()
monitoring_contracts.get_monitoring() # import lane B's module first if it exists, then override it
previous = monitoring_contracts._factory
monitoring_contracts.register_monitoring(lambda: mon)
yield mon
monitoring_contracts.register_monitoring(previous)
clock.reset()
await session.configure()
# ----------------------------------------------------------------------------------------------- helpers
class Andrey:
def __init__(self) -> None:
self.engine = Engine(gateway=LLMGateway(MockTasks())) # monitoring resolved via register_monitoring
async def text(self, t: str) -> list[Reply]:
return await self.engine.handle(TOKEN, Event(type="text", text=t))
async def press(self, data: str) -> list[Reply]:
return await self.engine.handle(TOKEN, Event(type="button", button=data))
async def system(self, kind: str, **params: Any) -> list[Reply]:
return await self.engine.handle(TOKEN, Event(type="system", system=kind, params=params)) # type: ignore[arg-type]
async def answer_scale(self, scale_id: str, values: list[int]) -> list[Reply]:
out: list[Reply] = []
for item, value in zip(load_scale(scale_id).items, values, strict=True):
out += await self.press(f"scale:{scale_id}:{item.id}:{value}")
return out
def buttons(replies: list[Reply]) -> list[str]:
return [b.data for r in replies for row in r.buttons for b in row]
def joined(replies: list[Reply]) -> str:
return "\n".join(r.text for r in replies)
async def state() -> tuple[str, dict[str, Any]]:
async with session.get_clinical_session() as s:
row = await repo.get_dialog_state(s, TOKEN)
assert row is not None
return row.state, dict(row.data)
async def open_alerts() -> list[Any]:
async with session.get_clinical_session() as s:
return list(await repo.list_alerts(s, token=TOKEN, open_only=True))
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="doc-smirnova", ts=clock.now())
async def new_dose(day: date) -> int:
"""What the scheduler (#13) does before sending the reminder: a planned dose row."""
async with session.get_clinical_session() as s:
rx = await s.scalar(select(Prescription).where(Prescription.token == TOKEN))
assert rx is not None
row = Dose(token=TOKEN, prescription_id=rx.id, planned_at=clock.now(), status="pending")
s.add(row)
await s.flush()
return row.id
# ----------------------------------------------------------------------------------------------- the 13 days
async def test_andrey_13_days(andrey: RuleMonitoring) -> None:
a = Andrey()
meds = load_meds_texts()
daily = load_daily_texts()
log: dict[int, list[Reply]] = {}
colors: list[str] = []
for day in range(1, 14):
taken, mood, sleep, nightmares, alcohol, free_text = TABLE[day]
clock.set_now(DAY0 + timedelta(days=day))
if day == 6:
await ack_all() # the doctor called Andrey after the day-5 alerts and acknowledged them
r: list[Reply] = []
# --- 08:00 dose reminder
dose_id = await new_dose(clock.today())
reminder = await a.system("med_reminder", dose_id=dose_id, drug="сертралин", dose="50 мг")
assert "сертралин 50 мг" in reminder[0].text
assert buttons(reminder) == [f"med:taken:{dose_id}", f"med:skip:{dose_id}", f"med:side_effects:{dose_id}"]
if taken:
r += await a.press(f"med:taken:{dose_id}")
else:
skip = await a.press(f"med:skip:{dose_id}")
assert f"med:skip_reason:quit:{dose_id}" in buttons(skip)
r += skip + await a.press(f"med:skip_reason:quit:{dose_id}")
# --- day 7: scheduled scales (PCL-5 = 44, PHQ-9 = 13, item 9 = 0) with friendly intros
if day == 7:
first = await a.system("scale_due", scale="pcl5", scheduled=True)
assert daily.scale_intros.scheduled["pcl5"] in first[0].text
await a.system("scale_due", scale="phq9", scheduled=True) # queued behind PCL-5
after_pcl = await a.answer_scale("pcl5", [2] * 16 + [3] * 4)
assert daily.scale_intros.scheduled["phq9"] in after_pcl[-1].text
await a.answer_scale("phq9", [2] * 5 + [1] * 3 + [0])
assert (await state())[0] == "idle"
# --- 20:00 daily check-in
clock.set_now(DAY0 + timedelta(days=day, hours=12))
start = await a.system("daily_checkin")
assert start[0].text == daily.checkin.start and buttons(start)[0] == "daily:mood:0"
r += await a.press(f"daily:mood:{mood}") # dose already answered by the reminder → no med question
assert "daily:sleep:<4" in buttons(r[-1:])
r += await a.press(f"daily:sleep:{sleep}")
r += await a.press(f"daily:nightmares:{'yes' if nightmares else 'no'}")
r += await a.press(f"daily:alcohol:{alcohol}")
assert buttons(r[-1:]) == ["daily:text:skip"]
r += await a.text(free_text) if free_text else await a.press("daily:text:skip")
log[day] = r
if day == 10: # «записаться раньше» offered by rule alcohol_3d
visit = await a.press("menu:visit")
assert visit[0].text == daily.visit.requested and (await state())[1]["visit_requests"]
if day == 13:
break # the crisis path is checked below
assert (await state())[0] == "idle"
colors.append(patient_status(AlertLevel[x.level.upper()] for x in await open_alerts()).color)
# Day 2: first nausea → instruction excerpt (med_qa, section side_effects), not generated advice.
assert "По инструкции к препарату" in joined(log[2]) and "проходит в течение 1–2 недель" in joined(log[2])
# Day 3: same effect again → short support, the instruction is not repeated.
assert meds.side_effects.repeat in joined(log[3]) and "По инструкции" not in joined(log[3])
# Day 5: «решил бросить» → rule quit_intent → delayed onset + do not stop on your own + doctor notified.
day5 = joined(log[5])
assert "2–4 недели" in day5 and "не прекращайте" in day5 and meds.med_info["delayed_effect"].notice in day5
assert "200 мг" not in day5 # dosing sentences of the instruction are never quoted
# Day 10: sleep < 4 h ×3 + alcohol ×3 → interaction info + «записаться раньше».
day10 = joined(log[10])
assert "Совместный приём с алкоголем не рекомендуется" in day10
assert buttons(log[10])[-1] == "menu:visit"
# Day 11: alerts still open → the rule stays silent (no repeated interaction text).
assert "Совместный приём с алкоголем" not in joined(log[11]) and "menu:visit" not in buttons(log[11])
assert colors == COLORS[:12]
# Day 13: «всё бессмысленно» → POSSIBLE → C-SSRS screen (buttons only) took over the free text.
day13 = log[13]
assert day13[-1].kind == "question" and "scale:cssrs_screen:cssrs_1:0" in buttons(day13[-1:])
r = await a.answer_scale("cssrs_screen", [0] * 6)
# Back in the check-in: the message counts as the answer → check-in closed → mood ≤ 3 three days →
# unscheduled PHQ-9 with its own intro.
assert daily.checkin.done in joined(r) and daily.scale_intros.unscheduled["phq9"] in r[-1].text
st, data = await state()
assert st == "scale.phq9" and data["scales"]["phq9"]["scheduled"] is False
r = await a.answer_scale("phq9", [2] * 8 + [2]) # total 18, item 9 = 2
assert any(x.kind == "crisis" for x in r) and any(x.kind == "safety_plan" for x in r)
st, data = await state()
# C-SSRS was completed minutes ago: item 9 opens the crisis but does not ask the same screen again (#56).
assert data["crisis"]["open"] is True and st == "crisis" and not data.get("pending_scales")
async with session.get_clinical_session() as s:
assert len(await repo.list_scale_results(s, TOKEN, "cssrs_screen")) == 1
acute = [x for x in await open_alerts() if x.level == "acute"]
assert [x.code for x in acute] == ["phq9_item9"]
assert patient_status(AlertLevel[x.level.upper()] for x in await open_alerts()).color == COLORS[12]
# Stored check-ins match the persona table.
async with session.get_clinical_session() as s:
rows = {c.day: c for c in await repo.list_checkins(s, TOKEN)}
doses = (await s.scalars(select(Dose).where(Dose.token == TOKEN).order_by(Dose.id))).all()
phq = list(await repo.list_scale_results(s, TOKEN, "phq9"))
for day, (taken, mood, sleep, nightmares, alcohol, _text) in TABLE.items():
c = rows[(DAY0 + timedelta(days=day)).date()]
assert (c.med_taken, c.mood, c.sleep_bucket, c.nightmares, c.alcohol_bucket) == (
taken,
mood,
sleep,
nightmares,
alcohol,
), day
for day in (2, 3, 4):
assert "nausea" in rows[(DAY0 + timedelta(days=day)).date()].side_effects
assert rows[(DAY0 + timedelta(days=5)).date()].quit_intent is True
assert rows[(DAY0 + timedelta(days=13)).date()].free_text_scrubbed is None # taken over by the C-SSRS
assert [d.status for d in doses] == ["skipped" if d == 5 else "taken" for d in range(1, 14)]
assert [(p.total, p.item9, p.scheduled) for p in phq] == [(14, 0, True), (13, 0, True), (18, 2, False)]
for code in ("side_effect_3d", "quit_intent", "sleep_lt4_3n", "alcohol_3d", "mood_low_3d", "phq9_item9"):
assert code in andrey.fired, code