fix(bot): close dialog engine gaps (C-SSRS repeat, scale intros, button payloads, step timeout)
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:
2026-10-03 19:00:47 +03:00
co-authored by Claude Opus 5.5
parent 2b7cbb8c2f
commit d06720fdab
10 changed files with 733 additions and 139 deletions
+35 -30
View File
@@ -101,6 +101,17 @@ def buttons(replies: list[Reply]) -> list[str]:
return [b.data for r in replies for row in r.buttons for b in row]
def kinds(replies: list[Reply]) -> list[str]:
"""Button data without the dose payload (#56): "med:taken:7" → "med:taken"."""
return [d.rsplit(":", 1)[0] if d.startswith("med:") else d for d in buttons(replies)]
async def tap(e: Engine, replies: list[Reply], kind: str) -> list[Reply]:
"""Press the button of `replies` whose data (without payload) is `kind` — as the patient would."""
[data] = [d for d in buttons(replies) if d.startswith("med:") and d.rsplit(":", 1)[0] == kind]
return await press(e, data)
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)
@@ -125,10 +136,10 @@ async def doses() -> list[Dose]:
async def test_reminder_without_dose_id_creates_dose_and_taken(engine: Engine, mon: ScriptedMonitoring) -> None:
r = await system(engine, "med_reminder")
assert r[0].text == MEDS.reminder.text.format(drug="сертралин", dose="50 мг")
assert buttons(r) == ["med:taken", "med:skip", "med:side_effects"]
[dose] = await doses()
assert buttons(r) == [f"med:taken:{dose.id}", f"med:skip:{dose.id}", f"med:side_effects:{dose.id}"]
assert dose.status == "pending"
r = await press(engine, "med:taken")
r = await tap(engine, r, "med:taken")
assert r[0].text == MEDS.taken_ack and mon.calls == 1
[dose] = await doses()
assert dose.status == "taken" and dose.answered_at is not None
@@ -136,18 +147,17 @@ async def test_reminder_without_dose_id_creates_dose_and_taken(engine: Engine, m
async def test_stale_reminder_button(engine: Engine) -> None:
await system(engine, "med_reminder")
reminder = await system(engine, "med_reminder")
clock.advance(days=1)
r = await press(engine, "med:taken")
r = await tap(engine, reminder, "med:taken")
assert r[0].text == MEDS.stale and await checkin() is None
@pytest.mark.parametrize("reason", ["forgot", "other"])
async def test_skip_simple_reasons(engine: Engine, reason: str) -> None:
await system(engine, "med_reminder")
r = await press(engine, "med:skip")
assert buttons(r) == [f"med:skip_reason:{x}" for x in ("forgot", "side_effects", "quit", "other")]
r = await press(engine, f"med:skip_reason:{reason}")
r = await tap(engine, await system(engine, "med_reminder"), "med:skip")
assert kinds(r) == [f"med:skip_reason:{x}" for x in ("forgot", "side_effects", "quit", "other")]
r = await tap(engine, r, f"med:skip_reason:{reason}")
assert r[0].text == MEDS.skip.ack[reason]
c = await checkin()
assert c.med_taken is False and not c.quit_intent
@@ -155,44 +165,39 @@ async def test_skip_simple_reasons(engine: Engine, reason: str) -> None:
async def test_skip_quit_sets_intent_and_explains(engine: Engine, mon: ScriptedMonitoring) -> None:
await system(engine, "med_reminder")
await press(engine, "med:skip")
skip = await tap(engine, await system(engine, "med_reminder"), "med:skip")
mon.actions = [Action(type="med_info", code="quit_intent", params={"topic": "delayed_effect"})]
r = await press(engine, "med:skip_reason:quit")
r = await tap(engine, skip, "med:skip_reason:quit")
assert r[0].text == MEDS.skip.ack["quit"]
assert "2–4 недели" in r[1].text and MEDS.med_info["delayed_effect"].notice in r[1].text
assert (await checkin()).quit_intent is True
async def test_skip_because_of_side_effects_asks_which(engine: Engine) -> None:
await system(engine, "med_reminder")
await press(engine, "med:skip")
r = await press(engine, "med:skip_reason:side_effects")
assert "med:se:nausea" in buttons(r) and "med:se:other" in buttons(r)
skip = await tap(engine, await system(engine, "med_reminder"), "med:skip")
r = await tap(engine, skip, "med:skip_reason:side_effects")
assert "med:se:nausea" in kinds(r) and "med:se:other" in kinds(r)
async def test_side_effect_button(engine: Engine, mon: ScriptedMonitoring) -> None:
await system(engine, "med_reminder")
r = await press(engine, "med:side_effects")
assert buttons(r) == [f"med:se:{c}" for c in ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")]
r = await press(engine, "med:se:nausea")
r = await tap(engine, await system(engine, "med_reminder"), "med:side_effects")
assert kinds(r) == [f"med:se:{c}" for c in ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")]
r = await tap(engine, r, "med:se:nausea")
assert "проходит в течение 1–2 недель" in r[0].text # instruction excerpt
assert r[1].text == MEDS.side_effects.ask_dose and buttons(r[1:]) == ["med:dose:yes", "med:dose:no"]
assert r[1].text == MEDS.side_effects.ask_dose and kinds(r[1:]) == ["med:dose:yes", "med:dose:no"]
assert (await checkin()).side_effects == ["nausea"]
assert (await doses())[0].side_effects == "nausea"
r = await press(engine, "med:dose:yes")
r = await tap(engine, r, "med:dose:yes")
assert (await checkin()).med_taken is True and (await doses())[0].status == "taken"
async def test_side_effect_other_free_text(engine: Engine) -> None:
await system(engine, "med_reminder")
await press(engine, "med:side_effects")
r = await press(engine, "med:se:other")
reminder = await system(engine, "med_reminder")
r = await tap(engine, await tap(engine, reminder, "med:side_effects"), "med:se:other")
assert r[0].text == MEDS.side_effects.describe and (await get_state())[0] == "meds.side_effect_text"
r = await text(engine, "голова болит весь день")
assert (await checkin()).side_effects == ["headache"] and (await get_state())[0] == "idle"
await press(engine, "med:side_effects")
await press(engine, "med:se:other")
await tap(engine, await tap(engine, reminder, "med:side_effects"), "med:se:other")
await text(engine, "какое-то странное ощущение")
assert (await checkin()).side_effects == ["headache", "other"]
@@ -202,8 +207,8 @@ async def test_same_side_effect_as_yesterday_gets_support(engine: Engine) -> Non
await repo.upsert_checkin(
s, token=TOKEN, day=clock.today() - timedelta(days=1), now=clock.now(), side_effects=["nausea"]
)
await system(engine, "med_reminder")
r = await press(engine, "med:se:nausea")
se = await tap(engine, await system(engine, "med_reminder"), "med:side_effects")
r = await tap(engine, se, "med:se:nausea")
assert r[0].text == MEDS.side_effects.repeat
@@ -276,8 +281,8 @@ async def test_checkin_asks_dose_when_not_answered_and_typed_mood(engine: Engine
async def test_reminder_during_checkin_keeps_checkin(engine: Engine) -> None:
await system(engine, "daily_checkin")
r = await system(engine, "med_reminder")
assert buttons(r[:1]) == ["med:taken", "med:skip", "med:side_effects"] and r[-1].text == DAILY.checkin.mood.question
r = await press(engine, "med:taken")
assert kinds(r[:1]) == ["med:taken", "med:skip", "med:side_effects"] and r[-1].text == DAILY.checkin.mood.question
r = await tap(engine, r, "med:taken")
assert r[0].text == MEDS.taken_ack and (await get_state())[0] == "daily.mood"
await press(engine, "daily:mood:5")
assert (await get_state())[0] == "daily.sleep" # dose already answered → no med question