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
+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)