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:
+50
-3
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user