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:
+57
-4
@@ -22,6 +22,14 @@ Name resolution: an exact registration wins, otherwise the longest registered do
|
||||
`@trigger("system:daily_checkin")`, `@trigger("text:idle")`) let flows take over what the core `idle` state does
|
||||
with menu buttons, system events and free text; the core registers defaults, a flow overrides with
|
||||
`replace=True`.
|
||||
|
||||
Prefix triggers carry data in the button (#56): `@trigger("button:med:taken:*")` handles every button whose data
|
||||
starts with "med:taken:"; the rest of the data ("med:taken:42" → "42") reaches the handler as
|
||||
`trigger_payload(event)`. An exact key always wins over a prefix, a longer prefix over a shorter one.
|
||||
|
||||
Abandoned steps (#56): a flow marks its question states with `abandonable("daily")`; if the patient stays silent
|
||||
in such a state longer than `settings.abandon_after_hours`, the engine returns the FSM to `idle` before
|
||||
handling the next event (answers already stored stay stored).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -103,6 +111,11 @@ EnterHook = Callable[[Ctx], Awaitable[Step]]
|
||||
_handlers: dict[str, Handler] = {}
|
||||
_enter_hooks: dict[str, EnterHook] = {}
|
||||
_triggers: dict[str, Handler] = {}
|
||||
_prefix_triggers: dict[str, Handler] = {} # "button:med:taken:" → handler (registered as "button:med:taken:*")
|
||||
_abandonable: dict[str, Callable[[Ctx], bool] | None] = {}
|
||||
|
||||
PREFIX_WILDCARD = "*"
|
||||
PAYLOAD_PARAM = "trigger_payload"
|
||||
|
||||
|
||||
def state(name: str) -> Callable[[Handler], Handler]:
|
||||
@@ -133,18 +146,45 @@ def trigger(key: str, *, replace: bool = False) -> Callable[[Handler], Handler]:
|
||||
"""Register a handler for an idle-menu button / system event / idle free text.
|
||||
|
||||
Keys: "button:<Button.data>", "system:<SystemEventKind>", "text:idle". The core registers defaults;
|
||||
flows pass `replace=True` to take one over.
|
||||
flows pass `replace=True` to take one over. A key ending in ":*" is a prefix trigger (see module doc).
|
||||
"""
|
||||
table, name = _triggers, key
|
||||
if key.endswith(":" + PREFIX_WILDCARD):
|
||||
table, name = _prefix_triggers, key[: -len(PREFIX_WILDCARD)]
|
||||
|
||||
def deco(fn: Handler) -> Handler:
|
||||
if key in _triggers and not replace:
|
||||
if name in table and not replace:
|
||||
raise ValueError(f"trigger '{key}' already registered (use replace=True to override)")
|
||||
_triggers[key] = fn
|
||||
table[name] = fn
|
||||
return fn
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
def trigger_payload(event: Event) -> str | None:
|
||||
"""Data after the matched prefix of a prefix trigger ("med:taken:42" → "42"); None for exact triggers."""
|
||||
value = event.params.get(PAYLOAD_PARAM)
|
||||
return str(value) if value is not None else None
|
||||
|
||||
|
||||
def abandonable(prefix: str, when: Callable[[Ctx], bool] | None = None) -> None:
|
||||
"""Mark states `prefix` / `prefix.*` as question steps the engine may abandon after a long silence.
|
||||
|
||||
`when(ctx)` may veto it for a particular situation (e.g. a scale that is part of an onboarding).
|
||||
"""
|
||||
if prefix in _abandonable:
|
||||
raise ValueError(f"abandonable '{prefix}' already registered")
|
||||
_abandonable[prefix] = when
|
||||
|
||||
|
||||
def is_abandonable(ctx: Ctx) -> bool:
|
||||
key = _resolve(ctx.state, _abandonable)
|
||||
if key is None:
|
||||
return False
|
||||
when = _abandonable[key]
|
||||
return when is None or when(ctx)
|
||||
|
||||
|
||||
def _resolve(name: str, table: dict[str, Any]) -> str | None:
|
||||
candidate = name
|
||||
while candidate:
|
||||
@@ -167,7 +207,20 @@ def enter_hook_for(name: str) -> EnterHook | None:
|
||||
|
||||
|
||||
def get_trigger(key: str) -> Handler | None:
|
||||
return _triggers.get(key)
|
||||
"""Exact trigger, else the longest matching prefix trigger (wrapped so it sees the payload)."""
|
||||
exact = _triggers.get(key)
|
||||
if exact is not None:
|
||||
return exact
|
||||
matches = [p for p in _prefix_triggers if key.startswith(p) and len(key) > len(p)]
|
||||
if not matches:
|
||||
return None
|
||||
prefix = max(matches, key=len)
|
||||
fn, payload = _prefix_triggers[prefix], key[len(prefix) :]
|
||||
|
||||
async def with_payload(ctx: Ctx, event: Event) -> Step:
|
||||
return await fn(ctx, event.model_copy(update={"params": {**event.params, PAYLOAD_PARAM: payload}}))
|
||||
|
||||
return with_payload
|
||||
|
||||
|
||||
def is_registered(name: str) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user