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
+120 -60
View File
@@ -14,8 +14,12 @@ Everything the patient answers is written to the clinical DB (`doses`, `daily_ch
engine to run monitoring (`notify_monitoring`): the rules (lane B, ADR-007) decide alerts and further actions
(quit intent → delayed-onset explanation, missed doses → caring contact). Nothing here decides risk.
Reminder buttons carry fixed data ("med:taken", ...) so they are plain idle triggers; the dose they refer to is
kept in `dialog_state.data["med_pending"]` ({dose_id, drug, dose, day}) — a button from an earlier day is stale.
Reminder buttons carry the dose they refer to (#56): "med:taken:<ref>", "med:skip:<ref>",
"med:side_effects:<ref>" and the follow-up buttons likewise ("med:se:<code>:<ref>", "med:skip_reason:<r>:<ref>",
"med:dose:yes:<ref>"), matched by prefix triggers. `<ref>` is the `doses.id` of the reminder or, when there is
no dose row (prescription only known to the МИС), the reminder's ISO date. A button of an earlier day's dose is
stale (friendly note, nothing recorded); so are the old payload-less buttons ("med:taken") of earlier releases.
Check-in answers without a button (daily.med step, free text) go to today's planned dose, if any.
"""
from datetime import date, timedelta
@@ -36,7 +40,7 @@ from app.db.clinical import Prescription as PrescriptionRow
from app.dialog.actions import register_med_info_handler
from app.dialog.contracts import Button, Event, Reply
from app.dialog.flows.core import menu_reply
from app.dialog.fsm import Ctx, Step, get_trigger, handler_for, state, trigger
from app.dialog.fsm import Ctx, Step, abandonable, get_trigger, handler_for, state, trigger, trigger_payload
from app.integrations.base import integrations
from app.llm.med_qa import fallback_text, med_qa
@@ -158,36 +162,55 @@ async def save_checkin(ctx: Ctx, **fields: Any) -> DailyCheckin:
return await repo.upsert_checkin(ctx.session, token=ctx.token, day=ctx.now.date(), now=ctx.now, **fields)
def pending_dose(ctx: Ctx) -> dict[str, Any] | None:
"""Today's reminder the patient is answering, or None (no reminder / reminder from an earlier day)."""
pending = ctx.data.get("med_pending")
if isinstance(pending, dict) and pending.get("day") == ctx.now.date().isoformat():
return pending
return None
def dose_ref(dose_id: int | None, day: date) -> str:
"""Button payload naming a dose: its row id, or the reminder day when there is no row."""
return str(dose_id) if dose_id is not None else day.isoformat()
async def _dose_row(ctx: Ctx, pending: dict[str, Any] | None) -> Dose | None:
if not pending or pending.get("dose_id") is None:
return None
row = await ctx.session.get(Dose, pending["dose_id"])
return row if row is not None and row.token == ctx.token else None
async def todays_dose(ctx: Ctx) -> Dose | None:
"""Latest dose row planned for today (local day of `ctx.now`) — target of answers given without a button."""
rows = await ctx.session.scalars(select(Dose).where(Dose.token == ctx.token).order_by(Dose.id.desc()).limit(20))
today = ctx.now.date()
return next((r for r in rows if r.planned_at.astimezone(ctx.now.tzinfo).date() == today), None)
async def record_dose(ctx: Ctx, taken: bool, *, status: str | None = None) -> None:
"""Dose answer → `doses` row of today's reminder (if any) + `daily_checkins.med_taken` (rules read both)."""
row = await _dose_row(ctx, pending_dose(ctx))
async def resolve_ref(ctx: Ctx, ref: str | None) -> tuple[bool, Dose | None]:
"""(fresh, dose row) for a button payload. Not fresh: missing/foreign/unknown ref or a dose of another day."""
if not ref:
return False, None
if ref.isdigit():
row = await ctx.session.get(Dose, int(ref))
if row is None or row.token != ctx.token:
return False, None
return row.planned_at.astimezone(ctx.now.tzinfo).date() == ctx.now.date(), row
try:
return date.fromisoformat(ref) == ctx.now.date(), None
except ValueError:
return False, None
async def _dose_row(ctx: Ctx, ref: str | None) -> Dose | None:
if ref is None:
return await todays_dose(ctx)
return (await resolve_ref(ctx, ref))[1]
async def record_dose(ctx: Ctx, taken: bool, *, status: str | None = None, ref: str | None = None) -> None:
"""Dose answer → `doses` row (`ref` from the button, else today's) + `daily_checkins.med_taken` (rules read
both)."""
row = await _dose_row(ctx, ref)
if row is not None:
row.status = status or ("taken" if taken else "skipped")
row.answered_at = ctx.now
await save_checkin(ctx, med_taken=taken)
async def record_side_effects(ctx: Ctx, codes: list[str]) -> None:
async def record_side_effects(ctx: Ctx, codes: list[str], *, ref: str | None = None) -> None:
"""Merge side-effect codes into today's check-in (rule `side_effect_3d` counts days with any code)."""
today = await checkin_of(ctx, ctx.now.date())
merged = list(dict.fromkeys([*(today.side_effects if today and today.side_effects else []), *codes]))
await save_checkin(ctx, side_effects=merged)
row = await _dose_row(ctx, pending_dose(ctx))
row = await _dose_row(ctx, ref)
if row is not None:
row.side_effects = ",".join(c for c in dict.fromkeys([*(row.side_effects or "").split(","), *codes]) if c)
@@ -228,12 +251,12 @@ async def run_trigger_inline(ctx: Ctx, key: str, event: Event) -> Step | None:
# ------------------------------------------------------------------------------------------- reminder
def _reminder_buttons() -> list[list[Button]]:
def _reminder_buttons(ref: str) -> list[list[Button]]:
t = load_meds_texts().reminder
return [
[Button(label=t.taken, data="med:taken")],
[Button(label=t.skip, data="med:skip")],
[Button(label=t.side_effects, data="med:side_effects")],
[Button(label=t.taken, data=f"med:taken:{ref}")],
[Button(label=t.skip, data=f"med:skip:{ref}")],
[Button(label=t.side_effects, data=f"med:side_effects:{ref}")],
]
@@ -253,43 +276,68 @@ async def _med_reminder(ctx: Ctx, event: Event) -> Step:
ctx.session.add(row)
await ctx.session.flush()
dose_id = row.id
pending = {"dose_id": dose_id, "drug": drug, "dose": dose, "day": ctx.now.date().isoformat()}
text = t.text.format(drug=drug, dose=dose).replace(" ", " ") if drug else t.text_no_drug
return Step(replies=[Reply(text=text, buttons=_reminder_buttons(), kind="question")], data={"med_pending": pending})
reply = Reply(text=text, buttons=_reminder_buttons(dose_ref(dose_id, ctx.now.date())), kind="question")
return Step(replies=[reply], data={"med_pending": None}) # key of earlier releases: the button carries it now
def _stale(ctx: Ctx) -> Step | None:
return None if pending_dose(ctx) is not None else Step(replies=[Reply(text=load_meds_texts().stale)])
def _stale_reply() -> Step:
return Step(replies=[Reply(text=load_meds_texts().stale)])
@trigger("button:med:taken")
async def _taken(ctx: Ctx, event: Event) -> Step:
if (stale := _stale(ctx)) is not None:
return stale
await record_dose(ctx, True)
async def _legacy(ctx: Ctx, event: Event) -> Step:
"""Payload-less medication button of an earlier release: the dose is unknown → treat as stale."""
return _stale_reply()
def _with_dose(fn: Any) -> Any:
"""Prefix-trigger handler wrapper: resolve the dose payload, answer stale buttons without recording."""
async def handler(ctx: Ctx, event: Event) -> Step:
ref = trigger_payload(event)
fresh, _row = await resolve_ref(ctx, ref)
if not fresh:
return _stale_reply()
return await fn(ctx, event, ref)
return handler
def med_trigger(name: str) -> Any:
"""Register `button:med:<name>:*` (with the dose payload) plus the legacy payload-less key as stale."""
def deco(fn: Any) -> Any:
trigger(f"button:med:{name}:*")(_with_dose(fn))
trigger(f"button:med:{name}")(_legacy)
return fn
return deco
@med_trigger("taken")
async def _taken(ctx: Ctx, event: Event, ref: str) -> Step:
await record_dose(ctx, True, ref=ref)
return Step(replies=[Reply(text=load_meds_texts().taken_ack)], notify_monitoring=True)
@trigger("button:med:skip")
async def _skip(ctx: Ctx, event: Event) -> Step:
if (stale := _stale(ctx)) is not None:
return stale
await record_dose(ctx, False, status="skipped")
@med_trigger("skip")
async def _skip(ctx: Ctx, event: Event, ref: str) -> Step:
await record_dose(ctx, False, status="skipped", ref=ref)
t = load_meds_texts().skip
buttons = [[Button(label=t.reasons[r], data=f"med:skip_reason:{r}")] for r in SKIP_REASONS]
buttons = [[Button(label=t.reasons[r], data=f"med:skip_reason:{r}:{ref}")] for r in SKIP_REASONS]
return Step(replies=[Reply(text=t.question, buttons=buttons, kind="question")], notify_monitoring=True)
def _side_effect_question() -> Reply:
def _side_effect_question(ref: str) -> Reply:
t = load_meds_texts().side_effects
buttons = [[Button(label=t.options[c], data=f"med:se:{c}")] for c in SIDE_EFFECT_CODES]
buttons = [[Button(label=t.options[c], data=f"med:se:{c}:{ref}")] for c in SIDE_EFFECT_CODES]
return Reply(text=t.question, buttons=buttons, kind="question")
def _skip_reason(reason: str) -> Any:
async def handler(ctx: Ctx, event: Event) -> Step:
async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
if reason == "side_effects":
return Step(replies=[_side_effect_question()])
return Step(replies=[_side_effect_question(ref)])
if reason == "quit":
# «Решил бросить» is the same signal as "брошу" in free text: rule `quit_intent` reacts (alert +
# delayed-onset explanation through the med_info action).
@@ -301,53 +349,62 @@ def _skip_reason(reason: str) -> Any:
for _reason in SKIP_REASONS:
trigger(f"button:med:skip_reason:{_reason}")(_skip_reason(_reason))
med_trigger(f"skip_reason:{_reason}")(_skip_reason(_reason))
@trigger("button:med:side_effects")
async def _side_effects(ctx: Ctx, event: Event) -> Step:
return Step(replies=[_side_effect_question()])
@med_trigger("side_effects")
async def _side_effects(ctx: Ctx, event: Event, ref: str) -> Step:
return Step(replies=[_side_effect_question(ref)])
async def _after_side_effects(ctx: Ctx, codes: list[str]) -> Step:
await record_side_effects(ctx, codes)
async def _after_side_effects(ctx: Ctx, codes: list[str], ref: str | None) -> Step:
if ref is None:
row = await todays_dose(ctx)
ref = dose_ref(row.id if row is not None else None, ctx.now.date())
await record_side_effects(ctx, codes, ref=ref)
replies = [await side_effect_reply(ctx, codes)]
today = await checkin_of(ctx, ctx.now.date())
if (today is None or today.med_taken is None) and await current_prescription(ctx) is not None:
t = load_meds_texts().side_effects
buttons = [[Button(label=t.dose_yes, data="med:dose:yes")], [Button(label=t.dose_no, data="med:dose:no")]]
buttons = [
[Button(label=t.dose_yes, data=f"med:dose:yes:{ref}")],
[Button(label=t.dose_no, data=f"med:dose:no:{ref}")],
]
replies.append(Reply(text=t.ask_dose, buttons=buttons, kind="question"))
return Step(replies=replies, next_state="idle", notify_monitoring=True)
return Step(replies=replies, next_state="idle", data={"med_se_ref": None}, notify_monitoring=True)
def _side_effect_code(code: str) -> Any:
async def handler(ctx: Ctx, event: Event) -> Step:
async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
if code == "other":
return Step(
replies=[Reply(text=load_meds_texts().side_effects.describe)], next_state=SIDE_EFFECT_TEXT_STATE
replies=[Reply(text=load_meds_texts().side_effects.describe)],
next_state=SIDE_EFFECT_TEXT_STATE,
data={"med_se_ref": ref},
)
return await _after_side_effects(ctx, [code])
return await _after_side_effects(ctx, [code], ref)
return handler
for _code in SIDE_EFFECT_CODES:
trigger(f"button:med:se:{_code}")(_side_effect_code(_code))
med_trigger(f"se:{_code}")(_side_effect_code(_code))
@state(SIDE_EFFECT_TEXT_STATE)
async def _side_effect_text(ctx: Ctx, event: Event) -> Step:
if event.type != "text" or ctx.text is None:
return await leave_to_idle(ctx, event)
return await leave_to_idle(ctx, event, {"med_se_ref": None})
facts = await ctx.gateway.extract_checkin(ctx.text)
# Store only normalised codes, never the description itself (it stays in the scrubbed message log).
codes = [str(c) for c in facts.side_effects] if facts is not None and facts.side_effects else ["other"]
return await _after_side_effects(ctx, codes)
ref = ctx.data.get("med_se_ref")
return await _after_side_effects(ctx, codes, str(ref) if ref else None)
def _dose_answer(taken: bool) -> Any:
async def handler(ctx: Ctx, event: Event) -> Step:
await record_dose(ctx, taken, status=None if taken else "missed")
async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
await record_dose(ctx, taken, status=None if taken else "missed", ref=ref)
return Step(
replies=[Reply(text=load_meds_texts().taken_ack if taken else load_meds_texts().skip.ack["other"])],
notify_monitoring=True,
@@ -356,8 +413,11 @@ def _dose_answer(taken: bool) -> Any:
return handler
trigger("button:med:dose:yes")(_dose_answer(True))
trigger("button:med:dose:no")(_dose_answer(False))
med_trigger("dose:yes")(_dose_answer(True))
med_trigger("dose:no")(_dose_answer(False))
# Free-text waiting states of this flow: a long-forgotten question must not swallow tomorrow's message.
abandonable("meds")
# ------------------------------------------------------------------------------------------- question menu