fix(bot): close dialog engine gaps (C-SSRS repeat, scale intros, button payloads, step timeout)
CI / ruff · pytest (pull_request) Successful in 1m19s
CI / ruff · pytest (pull_request) Successful in 1m19s
- 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>
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
"""Engine gaps found by the 13-day run (#56): no same-day C-SSRS repeat, scale intro registry, prefix triggers
|
||||
with a button payload (medication buttons carry the dose), abandoned-step timeout."""
|
||||
|
||||
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.rules import Action
|
||||
from app.core.scales import load_scale
|
||||
from app.db import repo, session
|
||||
from app.db.bootstrap import init_db
|
||||
from app.db.clinical import Dose, ItemResponse, Prescription
|
||||
from app.dialog import engine as engine_module
|
||||
from app.dialog import fsm, scale_session
|
||||
from app.dialog.contracts import Event, Reply
|
||||
from app.dialog.engine import STEP_AT, Engine
|
||||
from app.dialog.flows.meds import load_meds_texts
|
||||
from app.dialog.flows.veteran_daily import load_daily_texts
|
||||
from app.dialog.fsm import Ctx, Step, get_trigger, is_registered, trigger, trigger_payload
|
||||
from app.dialog.texts import load_dialog_texts
|
||||
from app.llm.gateway import LLMGateway
|
||||
from app.llm.mock import MockTasks
|
||||
|
||||
TOKEN = "g" * 32
|
||||
OTHER = "h" * 32
|
||||
MEM = "sqlite+aiosqlite:///:memory:"
|
||||
NOW = datetime(2026, 10, 7, 9, 0)
|
||||
TEXTS = load_dialog_texts()
|
||||
MEDS = load_meds_texts()
|
||||
DAILY = load_daily_texts()
|
||||
SCREEN = TEXTS.settings.risk_screen_scale
|
||||
|
||||
|
||||
class ScriptedMonitoring:
|
||||
def __init__(self) -> None:
|
||||
self.actions: list[Action] = []
|
||||
|
||||
async def after_data_change(self, token: str) -> list[Action]:
|
||||
out, self.actions = self.actions, []
|
||||
return out
|
||||
|
||||
async def is_in_crisis(self, token: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mon(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[ScriptedMonitoring]:
|
||||
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
|
||||
clock.set_now(NOW)
|
||||
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="doc", modules=["veteran"], sex="m", birth_year=1990, therapy_start=NOW.date()
|
||||
)
|
||||
s.add(Prescription(token=TOKEN, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}))
|
||||
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True}, now=clock.now())
|
||||
yield ScriptedMonitoring()
|
||||
clock.reset()
|
||||
await session.configure()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(mon: ScriptedMonitoring) -> Engine:
|
||||
return Engine(gateway=LLMGateway(MockTasks()), monitoring=mon)
|
||||
|
||||
|
||||
async def press(e: Engine, data: str) -> list[Reply]:
|
||||
return await e.handle(TOKEN, Event(type="button", button=data))
|
||||
|
||||
|
||||
async def text(e: Engine, t: str) -> list[Reply]:
|
||||
return await e.handle(TOKEN, Event(type="text", text=t))
|
||||
|
||||
|
||||
async def system(e: Engine, kind: str, **params: Any) -> list[Reply]:
|
||||
return await e.handle(TOKEN, Event(type="system", system=kind, params=params)) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def answer(e: Engine, scale_id: str, values: list[int]) -> list[Reply]:
|
||||
out: list[Reply] = []
|
||||
for item, value in zip(load_scale(scale_id).items, values, strict=False):
|
||||
out += await press(e, 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]
|
||||
|
||||
|
||||
async def get_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 results(scale: str) -> list[Any]:
|
||||
async with session.get_clinical_session() as s:
|
||||
return list(await repo.list_scale_results(s, TOKEN, scale))
|
||||
|
||||
|
||||
async def checkin() -> Any:
|
||||
async with session.get_clinical_session() as s:
|
||||
rows = await repo.list_checkins(s, TOKEN)
|
||||
return rows[-1] if rows else None
|
||||
|
||||
|
||||
async def doses() -> list[Dose]:
|
||||
async with session.get_clinical_session() as s:
|
||||
return list((await s.scalars(select(Dose).where(Dose.token == TOKEN).order_by(Dose.id))).all())
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------- 1. C-SSRS repeat
|
||||
|
||||
|
||||
async def test_item9_right_after_cssrs_does_not_repeat_screen(engine: Engine, mon: ScriptedMonitoring) -> None:
|
||||
await system(engine, "scale_due", scale=SCREEN, scheduled=False)
|
||||
assert (await get_state())[0] == f"scale.{SCREEN}"
|
||||
await answer(engine, SCREEN, [0] * 6)
|
||||
clock.advance(hours=1)
|
||||
await system(engine, "scale_due", scale="phq9", scheduled=False)
|
||||
# The rule phq9_item9 also asks for the screen — neither path may repeat it within the cooldown.
|
||||
mon.actions = [Action(type="schedule_scale", code="phq9_item9", params={"scale": SCREEN, "scheduled": False})]
|
||||
r = await answer(engine, "phq9", [1] * 8 + [1])
|
||||
assert any(x.kind == "crisis" for x in r) # the fixed crisis script still goes out
|
||||
state, data = await get_state()
|
||||
assert state == "crisis" and data["crisis"]["open"] is True and not data.get("pending_scales")
|
||||
assert len(await results(SCREEN)) == 1
|
||||
|
||||
|
||||
async def test_item9_after_old_cssrs_asks_screen_again(engine: Engine) -> None:
|
||||
await system(engine, "scale_due", scale=SCREEN, scheduled=False)
|
||||
await answer(engine, SCREEN, [0] * 6)
|
||||
clock.advance(hours=TEXTS.settings.cssrs_cooldown_hours + 1)
|
||||
await system(engine, "scale_due", scale="phq9", scheduled=False)
|
||||
r = await answer(engine, "phq9", [1] * 8 + [1])
|
||||
assert any(x.kind == "crisis" for x in r)
|
||||
assert (await get_state())[0] == f"scale.{SCREEN}" and TEXTS.scale.risk_screen_intro in r[-1].text
|
||||
|
||||
|
||||
async def test_unfinished_cssrs_does_not_count(engine: Engine) -> None:
|
||||
await system(engine, "scale_due", scale=SCREEN, scheduled=False)
|
||||
await answer(engine, SCREEN, [0, 0]) # left halfway: no complete result
|
||||
await system(engine, "scale_due", scale="phq9", scheduled=False) # queued behind the screen
|
||||
async with session.get_clinical_session() as s:
|
||||
assert not await scale_session.recent_risk_screen(_ctx(s), SCREEN)
|
||||
|
||||
|
||||
def _ctx(s: Any, state: str = "idle", data: dict[str, Any] | None = None) -> Ctx:
|
||||
return Ctx(
|
||||
token=TOKEN,
|
||||
state=state,
|
||||
data=data or {},
|
||||
now=clock.now(),
|
||||
session=s,
|
||||
gateway=LLMGateway(MockTasks()),
|
||||
monitoring=ScriptedMonitoring(),
|
||||
texts=TEXTS,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------- 2. intros
|
||||
|
||||
|
||||
async def test_registered_intro_text_and_callable(engine: Engine, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
seen: list[tuple[str, bool, str]] = []
|
||||
|
||||
def intro(ctx: Ctx, scale_id: str, scheduled: bool, return_state: str) -> str | None:
|
||||
seen.append((scale_id, scheduled, return_state))
|
||||
return "Вступление к GAD-7"
|
||||
|
||||
monkeypatch.setattr(scale_session, "_intros", dict(scale_session._intros))
|
||||
scale_session.register_scale_intro("gad7", intro)
|
||||
with pytest.raises(ValueError):
|
||||
scale_session.register_scale_intro("gad7", "второе")
|
||||
r = await system(engine, "scale_due", scale="gad7", scheduled=True)
|
||||
assert r[0].text.startswith("Вступление к GAD-7") and seen == [("gad7", True, "idle")]
|
||||
|
||||
scale_session.register_scale_intro("gad7", "Текстовое вступление", replace=True)
|
||||
async with session.get_clinical_session() as s:
|
||||
step = await scale_session.start_scale(_ctx(s), "gad7", return_state="idle")
|
||||
assert step.data["scales"]["gad7"]["intro"] == "Текстовое вступление"
|
||||
explicit = await scale_session.start_scale(_ctx(s), "gad7", return_state="idle", intro="Своё")
|
||||
assert explicit.data["scales"]["gad7"]["intro"] == "Своё"
|
||||
|
||||
|
||||
def test_daily_intros_use_registry_not_enter_hooks() -> None:
|
||||
for scale_id in DAILY.settings.intro_scales:
|
||||
assert not is_registered(f"scale.{scale_id}") # no per-scale enter hooks any more
|
||||
assert scale_id in scale_session._intros
|
||||
|
||||
|
||||
async def test_unscheduled_intro_from_registry(engine: Engine) -> None:
|
||||
r = await system(engine, "scale_due", scale="phq9", scheduled=False)
|
||||
assert DAILY.scale_intros.unscheduled["phq9"] in r[0].text
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------- 3. payload buttons
|
||||
|
||||
|
||||
async def test_prefix_trigger_payload_and_precedence(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(fsm, "_triggers", dict(fsm._triggers))
|
||||
monkeypatch.setattr(fsm, "_prefix_triggers", dict(fsm._prefix_triggers))
|
||||
|
||||
def handler(tag: str) -> Any:
|
||||
async def fn(ctx: Ctx, event: Event) -> Step:
|
||||
return Step(data={"tag": tag, "payload": trigger_payload(event)})
|
||||
|
||||
return fn
|
||||
|
||||
trigger("button:zz:a:*")(handler("short"))
|
||||
trigger("button:zz:a:b:*")(handler("long"))
|
||||
trigger("button:zz:a:exact")(handler("exact"))
|
||||
with pytest.raises(ValueError):
|
||||
trigger("button:zz:a:*")(handler("dup"))
|
||||
|
||||
async def run(key: str) -> dict[str, Any] | None:
|
||||
fn = get_trigger(key)
|
||||
if fn is None:
|
||||
return None
|
||||
return (await fn(None, Event(type="button", button=key.split(":", 1)[1]))).data # type: ignore[arg-type]
|
||||
|
||||
assert await run("button:zz:a:42") == {"tag": "short", "payload": "42"}
|
||||
assert await run("button:zz:a:b:7:x") == {"tag": "long", "payload": "7:x"}
|
||||
assert await run("button:zz:a:exact") == {"tag": "exact", "payload": None}
|
||||
assert await run("button:zz:a:") is None and await run("button:zz:a") is None
|
||||
|
||||
|
||||
async def test_reminder_buttons_carry_dose_and_no_state_key(engine: Engine) -> None:
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.set_dialog_state(
|
||||
s, token=TOKEN, state="idle", data={"consent": True, "med_pending": {"dose_id": 1}}, now=clock.now()
|
||||
)
|
||||
r = await system(engine, "med_reminder")
|
||||
[dose] = await doses()
|
||||
assert buttons(r) == [f"med:taken:{dose.id}", f"med:skip:{dose.id}", f"med:side_effects:{dose.id}"]
|
||||
assert "med_pending" not in (await get_state())[1]
|
||||
skip = await press(engine, f"med:skip:{dose.id}")
|
||||
assert f"med:skip_reason:side_effects:{dose.id}" in buttons(skip)
|
||||
se = await press(engine, f"med:skip_reason:side_effects:{dose.id}")
|
||||
await press(engine, f"med:se:nausea:{dose.id}")
|
||||
[dose] = await doses()
|
||||
assert dose.status == "skipped" and dose.side_effects == "nausea" and "med:se:nausea:" in buttons(se)[0]
|
||||
|
||||
|
||||
async def test_two_reminders_each_button_answers_its_own_dose(engine: Engine) -> None:
|
||||
first = await system(engine, "med_reminder")
|
||||
second = await system(engine, "med_reminder")
|
||||
await press(engine, buttons(first)[0]) # «Принял» of the FIRST reminder, pressed after the second arrived
|
||||
d1, d2 = await doses()
|
||||
assert (d1.status, d2.status) == ("taken", "pending")
|
||||
await press(engine, buttons(second)[1])
|
||||
assert [d.status for d in await doses()] == ["taken", "skipped"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("data", ["med:taken", "med:skip", "med:se:nausea", "med:dose:yes", "med:skip_reason:quit"])
|
||||
async def test_legacy_payloadless_buttons_are_stale(engine: Engine, data: str) -> None:
|
||||
await system(engine, "med_reminder")
|
||||
r = await press(engine, data)
|
||||
assert r[0].text == MEDS.stale and await checkin() is None
|
||||
assert [d.status for d in await doses()] == ["pending"]
|
||||
|
||||
|
||||
async def test_foreign_unknown_and_old_dose_buttons_are_stale(engine: Engine) -> None:
|
||||
async with session.get_clinical_session() as s:
|
||||
rx = Prescription(token=OTHER, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]})
|
||||
s.add(rx)
|
||||
await s.flush()
|
||||
foreign = Dose(token=OTHER, prescription_id=rx.id, planned_at=clock.now(), status="pending")
|
||||
s.add(foreign)
|
||||
await s.flush()
|
||||
foreign_id = foreign.id
|
||||
for data in (f"med:taken:{foreign_id}", "med:taken:9999", "med:taken:garbage", "med:taken:2026-10-06"):
|
||||
assert (await press(engine, data))[0].text == MEDS.stale
|
||||
old = await system(engine, "med_reminder")
|
||||
clock.advance(days=1)
|
||||
assert (await press(engine, buttons(old)[0]))[0].text == MEDS.stale
|
||||
assert await checkin() is None
|
||||
|
||||
|
||||
async def test_reminder_without_dose_row_uses_day_payload(engine: Engine) -> None:
|
||||
async with session.get_clinical_session() as s:
|
||||
for row in await s.scalars(select(Prescription)):
|
||||
await s.delete(row)
|
||||
r = await system(engine, "med_reminder")
|
||||
assert buttons(r)[0] == f"med:taken:{NOW.date().isoformat()}"
|
||||
r = await press(engine, buttons(r)[0])
|
||||
assert r[0].text == MEDS.taken_ack and (await checkin()).med_taken is True and await doses() == []
|
||||
|
||||
|
||||
async def test_checkin_med_step_answers_todays_dose(engine: Engine) -> None:
|
||||
await system(engine, "med_reminder")
|
||||
await system(engine, "daily_checkin")
|
||||
await press(engine, "daily:mood:5")
|
||||
await press(engine, "daily:med:yes")
|
||||
assert [d.status for d in await doses()] == ["taken"]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------- 4. abandon timeout
|
||||
|
||||
|
||||
async def test_abandoned_checkin_returns_to_idle(engine: Engine) -> None:
|
||||
await system(engine, "daily_checkin")
|
||||
await press(engine, "daily:mood:3")
|
||||
assert (await get_state())[0].startswith("daily.")
|
||||
clock.advance(hours=TEXTS.settings.abandon_after_hours + 1)
|
||||
r = await system(engine, "med_reminder")
|
||||
state, data = await get_state()
|
||||
assert state == "idle" and "checkin" not in data
|
||||
assert len(r) == 1 and r[0].text.startswith(MEDS.reminder.text.split("{")[0]) # no check-in question repeated
|
||||
assert (await checkin()).mood == 3 # stored answer survives
|
||||
|
||||
|
||||
async def test_short_pause_keeps_checkin(engine: Engine) -> None:
|
||||
await system(engine, "daily_checkin")
|
||||
clock.advance(hours=TEXTS.settings.abandon_after_hours - 1)
|
||||
r = await press(engine, "daily:mood:6")
|
||||
assert (await get_state())[0] != "idle" and r
|
||||
|
||||
|
||||
async def test_abandoned_scale_lets_queued_scale_start(engine: Engine) -> None:
|
||||
await system(engine, "scale_due", scale="phq9", scheduled=True)
|
||||
await answer(engine, "phq9", [1, 2])
|
||||
clock.advance(hours=4)
|
||||
await system(engine, "scale_due", scale="pcl5", scheduled=True) # queued; system events don't extend the step
|
||||
clock.advance(hours=3)
|
||||
r = await text(engine, "добрый вечер")
|
||||
state, data = await get_state()
|
||||
assert state == "scale.pcl5" and "phq9" not in data["scales"] and not data.get("pending_scales")
|
||||
assert DAILY.scale_intros.scheduled["pcl5"] in r[-1].text
|
||||
assert await results("phq9") == [] # unfinished: no total, no severity
|
||||
async with session.get_clinical_session() as s:
|
||||
stored = (await s.scalars(select(ItemResponse).where(ItemResponse.scale == "phq9"))).all()
|
||||
assert [x.score for x in stored] == [1, 2]
|
||||
|
||||
|
||||
async def test_timeout_never_applies_in_crisis(engine: Engine) -> None:
|
||||
await system(engine, "scale_due", scale="phq9", scheduled=False)
|
||||
await answer(engine, "phq9", [0] * 8 + [2]) # item 9 → crisis + C-SSRS (none done before)
|
||||
state, data = await get_state()
|
||||
assert state == f"scale.{SCREEN}" and data["crisis"]["open"] is True
|
||||
clock.advance(hours=TEXTS.settings.abandon_after_hours * 3)
|
||||
await press(engine, f"scale:{SCREEN}:cssrs_1:0")
|
||||
state, data = await get_state()
|
||||
assert state == f"scale.{SCREEN}" and data["scales"][SCREEN]["idx"] == 1
|
||||
|
||||
|
||||
async def test_timeout_never_skips_onboarding_scale(engine: Engine) -> None:
|
||||
item = load_scale("pcl5").items[0]
|
||||
old = (clock.now() - timedelta(hours=48)).isoformat()
|
||||
session_data = {
|
||||
"idx": 0,
|
||||
"answers": {},
|
||||
"return_state": "veteran.onboarding.phq9",
|
||||
"scheduled": True,
|
||||
"intro": None,
|
||||
"intro_shown": True,
|
||||
}
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.set_dialog_state(
|
||||
s,
|
||||
token=TOKEN,
|
||||
state="scale.pcl5",
|
||||
data={"consent": True, "scales": {"pcl5": session_data}, STEP_AT: old},
|
||||
now=clock.now(),
|
||||
)
|
||||
await press(engine, f"scale:pcl5:{item.id}:1")
|
||||
state, data = await get_state()
|
||||
assert state == "scale.pcl5" and data["scales"]["pcl5"]["idx"] == 1
|
||||
|
||||
|
||||
async def test_timeout_disabled_same_day(engine: Engine, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
clock.set_now(NOW.replace(hour=1))
|
||||
off = TEXTS.model_copy(update={"settings": TEXTS.settings.model_copy(update={"abandon_after_hours": 0})})
|
||||
monkeypatch.setattr(engine_module, "load_dialog_texts", lambda: off)
|
||||
await system(engine, "daily_checkin")
|
||||
clock.advance(hours=20)
|
||||
await press(engine, "daily:mood:4")
|
||||
assert (await get_state())[0] != "idle" and (await checkin()).mood == 4
|
||||
Reference in New Issue
Block a user