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
+50 -3
View File
@@ -2,6 +2,10 @@
Pipeline for one incoming event (SPEC §3, guardrails #1, #2, #3):
0. abandoned step the FSM sits in a question step (check-in, scale) the patient left more than
`settings.abandon_after_hours` ago → back to idle first (stored answers stay; never in
crisis, never in an onboarding chain), then the event is handled as usual.
text event
1. scrub() raw text → ScrubbedText. Raw text is never stored, logged or sent anywhere.
2. consent gate no valid consent → no LLM, nothing stored; only the local crisis lexicon runs
@@ -25,7 +29,7 @@ Logs carry only the token, states and event codes (guardrail #8).
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timedelta
from typing import Any
from weakref import WeakValueDictionary
@@ -45,7 +49,16 @@ from app.dialog.contracts import Event, Reply, register_engine
from app.dialog.crisis import crisis_open, no_consent_crisis_text, open_crisis
from app.dialog.flows import load_flows
from app.dialog.flows.core import close_crisis, revoke
from app.dialog.fsm import NO_CONSENT_STATES, RESTING_STATES, Ctx, Step, apply_data, enter_hook_for, handler_for
from app.dialog.fsm import (
NO_CONSENT_STATES,
RESTING_STATES,
Ctx,
Step,
apply_data,
enter_hook_for,
handler_for,
is_abandonable,
)
from app.dialog.texts import DialogTexts, load_dialog_texts
from app.llm.gateway import LLMGateway, get_gateway
from app.services import questionnaire as intake_service
@@ -58,6 +71,8 @@ MAX_ENTER_CHAIN = 8
_patient_locks = WeakValueDictionary()
# DB unreachable: answer safely instead of failing the channel (connectivity errors only — bugs must surface).
_DB_DOWN = (OSError, OperationalError, InterfaceError)
# dialog_state.data key: when the patient last acted in (or the FSM entered) the current step — abandon timeout.
STEP_AT = "step_at"
@dataclass
@@ -109,6 +124,8 @@ class Engine:
state_before = ctx.state
run = _Run()
if self._abandoned(ctx):
await self._apply(ctx, self._abandon(ctx), run)
await self._apply(ctx, await self._route(ctx, event), run)
if run.notify:
@@ -126,6 +143,9 @@ class Engine:
await repo.add_message(
session, token=token, direction="out", text=scrub(reply.text), ts=ctx.now, fsm_state=ctx.state
)
if event.type != "system" or ctx.state != state_before:
# System events the step ignores (reminders inside a scale) do not count as the patient being there.
ctx.data[STEP_AT] = ctx.now.isoformat()
await self._save_state(ctx)
task_id = event.params.get("questionnaire_task")
if event.type == "system" and task_id and run.replies:
@@ -145,6 +165,24 @@ class Engine:
)
return run.replies
def _abandoned(self, ctx: Ctx) -> bool:
"""The current question step was left unanswered longer than `abandon_after_hours` (app.core.clock)."""
if intake_enabled() or not _consented(ctx) or ctx.state in RESTING_STATES or crisis_open(ctx.data):
return False
hours = ctx.texts.settings.abandon_after_hours
last = ctx.data.get(STEP_AT)
if not hours or not last or ctx.now - datetime.fromisoformat(last) < timedelta(hours=hours):
return False
return is_abandonable(ctx)
def _abandon(self, ctx: Ctx) -> Step:
"""Drop the unfinished step's progress (answers already stored in item_responses / daily_checkins stay;
an unfinished scale gets no scale_results row) and rest in idle so queued scales may start."""
log.info("dialog step abandoned token=%s state=%s", ctx.token, ctx.state)
scale_id = scale_session.active_scale(ctx.state)
scales = {k: v for k, v in (ctx.data.get("scales") or {}).items() if k != scale_id}
return Step(next_state="idle", data={"checkin": None, "scales": scales or None})
async def _route(self, ctx: Ctx, event: Event) -> Step:
consented = _consented(ctx)
if event.type == "text":
@@ -307,10 +345,19 @@ class Engine:
if not pending or ctx.state not in RESTING_STATES:
return Step()
screen = ctx.texts.settings.risk_screen_scale
dropped: dict[str, Any] = {}
if any(p["scale"] == screen for p in pending) and await scale_session.recent_risk_screen(ctx, screen):
# Item 9 / rule phq9_item9 / scale_due asked for a C-SSRS the patient completed within the cooldown:
# do not repeat the same suicide screen (#56). Crisis script and alerts are unaffected.
log.info("dialog risk screen skipped (cooldown) token=%s", ctx.token)
pending = [p for p in pending if p["scale"] != screen]
dropped = {"pending_scales": pending or None}
if not pending:
return Step(data=dropped)
if ctx.state == "crisis":
pending_screen = [p for p in pending if p["scale"] == screen]
if not pending_screen:
return Step()
return Step(data=dropped)
item = pending_screen[0]
else:
item = pending[0]
+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
+16 -31
View File
@@ -24,14 +24,14 @@ Data algorithm (medical data, jury requirement):
alcohol interaction, visit offer) come from the rules through `MonitoringService` (ADR-007).
* A free-text message classified POSSIBLE by the safety layer is taken over by the engine (C-SSRS). When the
screen returns to `daily.text` the message counts as the answer: the check-in is closed, not asked again.
* A check-in left unfinished is dropped on the next system event of a later day.
* A check-in left unfinished is dropped on the next event of a later day, or after
`settings.abandon_after_hours` of silence (engine abandon timeout, #56) — answers given so far stay stored.
Also here: friendly intros for scheduled scales (pcl5, phq9, dar5, auditc) — the engine starts `scale_due`
scales without an intro, so an exact `scale.<id>` enter hook adds one and delegates to the generic scale hook.
Also here: friendly intros for scheduled scales (pcl5, phq9, dar5, auditc), registered with
`register_scale_intro` so the engine's `scale_due` / rule-triggered sessions show them.
"""
import re
from dataclasses import replace
from datetime import datetime
from functools import lru_cache
from pathlib import Path
@@ -59,9 +59,8 @@ from app.dialog.flows.meds import (
save_checkin,
side_effect_reply,
)
from app.dialog.fsm import Ctx, Step, enter_hook_for, on_enter, state, trigger
from app.dialog.scale_session import PREFIX as SCALE_PREFIX
from app.dialog.scale_session import active_scale, scale_state
from app.dialog.fsm import Ctx, Step, abandonable, on_enter, state, trigger
from app.dialog.scale_session import register_scale_intro
PREFIX = "daily"
STEPS = ("mood", "med", "sleep", "nightmares", "alcohol", "text")
@@ -346,6 +345,9 @@ async def _free_text(ctx: Ctx, ck: dict[str, Any]) -> Step:
return _finish(ctx, replies)
abandonable(PREFIX)
@state(PREFIX)
async def _handle(ctx: Ctx, event: Event) -> Step:
step = ctx.state.split(".", 1)[1] if "." in ctx.state else ""
@@ -451,30 +453,13 @@ async def _idle_text(ctx: Ctx, event: Event) -> Step:
# ------------------------------------------------------------------------------------------- scale intros
async def _scale_intro(ctx: Ctx) -> Step:
"""Add the friendly intro of a scheduled (or rule-triggered) scale, then run the generic scale enter hook.
Onboarding sessions (#36) and sessions started with their own intro are left untouched.
"""
scale_id = active_scale(ctx.state)
sessions = dict(ctx.data.get("scales") or {})
session = sessions.get(scale_id) if scale_id else None
if (
scale_id is not None
and session is not None
and session.get("idx") == 0
and not session.get("intro_shown")
and not session.get("intro")
and ".onboarding" not in str(session.get("return_state", ""))
):
intros = load_daily_texts().scale_intros
intro = (intros.scheduled if session.get("scheduled", True) else intros.unscheduled).get(scale_id)
if intro:
sessions[scale_id] = {**session, "intro": intro}
ctx = replace(ctx, data={**ctx.data, "scales": sessions})
generic = enter_hook_for(SCALE_PREFIX)
return await generic(ctx) if generic is not None else Step()
def _scale_intro(ctx: Ctx, scale_id: str, scheduled: bool, return_state: str) -> str | None:
"""Friendly intro of a scheduled (or rule-triggered) scale. Onboarding sessions (#36) have their own texts."""
if ".onboarding" in return_state:
return None
intros = load_daily_texts().scale_intros
return (intros.scheduled if scheduled else intros.unscheduled).get(scale_id)
for _scale_id in load_daily_texts().settings.intro_scales:
on_enter(scale_state(_scale_id))(_scale_intro)
register_scale_intro(_scale_id, _scale_intro)
+57 -4
View File
@@ -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:
+57 -5
View File
@@ -15,13 +15,22 @@ Algorithm per item:
4. Every answer is stored immediately (`item_responses`), so a session interrupted by a crisis keeps its data.
5. After each answer the scale is scored deterministically (`app.core.scales.score`). If a crisis flag from
`config/texts/dialog.yaml: settings.crisis_flags` fires (PHQ-9 item 9 ≥ 1, C-SSRS high) the session ends at
once: partial result saved, fixed crisis script, C-SSRS queued (unless this *is* the C-SSRS). A positive
item 9 is acted upon even in a half-finished questionnaire.
once: partial result saved, fixed crisis script, C-SSRS queued (unless this *is* the C-SSRS, or a C-SSRS was
completed less than `settings.cssrs_cooldown_hours` ago — the patient is not asked the same suicide screen
twice in a day; the crisis script and the alert still happen). A positive item 9 is acted upon even in a
half-finished questionnaire.
6. On completion: `scale_results` row (total, severity, item 9, scheduled flag, flags) → the engine commits
and calls `MonitoringService.after_data_change()` (rules decide alerts — ADR-007) → back to `return_state`.
The patient never sees a score or a severity label: that would read as a diagnosis.
Intros (#56): `start_scale(intro=None)` looks the intro up in a registry filled by flows
(`register_scale_intro("phq9", fn_or_text)`), so scales started by the engine (`scale_due`, rule actions) get
the flow's friendly intro without the flow hooking `scale.<id>` itself.
"""
from collections.abc import Callable
from dataclasses import replace
from datetime import timedelta
from typing import Any
from app.core.privacy import scrub
@@ -29,10 +38,28 @@ from app.core.scales import Item, ScaleDef, ScaleResult, load_scale, score
from app.db import repo
from app.dialog.contracts import Button, Event, Reply
from app.dialog.crisis import crisis_open, open_crisis
from app.dialog.fsm import Ctx, Step, on_enter, state
from app.dialog.fsm import RESTING_STATES, Ctx, Step, abandonable, is_abandonable, on_enter, state
PREFIX = "scale"
# (ctx, scale_id, scheduled, return_state) → intro text or None (no intro in this situation).
IntroFn = Callable[[Ctx, str, bool, str], str | None]
_intros: dict[str, str | IntroFn] = {}
def register_scale_intro(scale_id: str, intro: str | IntroFn, *, replace: bool = False) -> None:
"""Register the intro shown before `scale_id` when a session is started without an explicit `intro`."""
if scale_id in _intros and not replace:
raise ValueError(f"intro of scale '{scale_id}' already registered (use replace=True to override)")
_intros[scale_id] = intro
def scale_intro(ctx: Ctx, scale_id: str, *, scheduled: bool, return_state: str) -> str | None:
intro = _intros.get(scale_id)
if intro is None or isinstance(intro, str):
return intro or None
return intro(ctx, scale_id, scheduled, return_state) or None
def scale_state(scale_id: str) -> str:
return f"{PREFIX}.{scale_id}"
@@ -56,9 +83,11 @@ async def start_scale(
"""Begin a session of `scale_id`; returns a Step moving the FSM to `scale.<id>`.
`scheduled=False` marks an unscheduled administration (triggered by a rule or a safety signal).
`intro` — optional extra text shown before the scale instruction.
`intro` — optional extra text shown before the scale instruction; None → the registered intro, if any.
"""
scale = load_scale(scale_id) # fail fast on an unknown id
if intro is None:
intro = scale_intro(ctx, scale_id, scheduled=scheduled, return_state=return_state)
sessions = dict(ctx.data.get("scales") or {})
sessions[scale_id] = {
"idx": 0,
@@ -98,6 +127,28 @@ def _session(ctx: Ctx) -> tuple[ScaleDef, dict[str, Any]] | None:
return load_scale(scale_id), session
def _abandon_allowed(ctx: Ctx) -> bool:
"""A silent scale session may be abandoned only if what it returns to may be (idle, a check-in step) —
never an onboarding chain, which would otherwise be skipped."""
found = _session(ctx)
if found is None:
return True
back = str(found[1].get("return_state") or "idle")
if back in RESTING_STATES:
return True
return back != ctx.state and is_abandonable(replace(ctx, state=back))
abandonable(PREFIX, _abandon_allowed)
async def recent_risk_screen(ctx: Ctx, screen: str) -> bool:
"""A complete risk screen (C-SSRS) less than `cssrs_cooldown_hours` ago (scale_results, any trigger)."""
since = ctx.now - timedelta(hours=ctx.texts.settings.cssrs_cooldown_hours)
results = await repo.list_scale_results(ctx.session, ctx.token, screen)
return any(r.complete and r.ts >= since for r in results)
@on_enter(PREFIX)
async def _enter(ctx: Ctx) -> Step:
found = _session(ctx)
@@ -231,6 +282,7 @@ async def _finish(
else:
crisis = open_crisis(ctx, categories=[], source=f"scale:{scale.id}")
step = done.then(crisis)
if scale.id != risk_screen:
# Same-day repeat guard: a C-SSRS completed within the cooldown already answered the screening question.
if scale.id != risk_screen and not await recent_risk_screen(ctx, risk_screen):
step.data["pending_scales"] = add_pending(ctx.data, risk_screen, scheduled=False)
return step
+1
View File
@@ -19,6 +19,7 @@ class DialogSettings(_Strict):
max_safety_events: int = 50
crisis_flags: list[str] = Field(default_factory=list)
risk_screen_scale: str = "cssrs_screen"
abandon_after_hours: float = 6
class Commands(_Strict):