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