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:
+35
-30
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user