feat(bot): dialog engine — scrub → safety → FSM → monitoring, consent, age gate, crisis
CI / ruff · pytest (pull_request) Successful in 25s
CI / ruff · pytest (pull_request) Successful in 25s
- Engine.handle registered via register_engine; lexicon-first safety (no LLM on ACUTE), fixed crisis script once per open crisis, POSSIBLE → C-SSRS screen - FSM registry (@state/@on_enter/@trigger) with auto-loaded flows; core flow: new → consent → age_gate → module onboarding → idle, crisis, consent_revoked - reusable scale session (buttons, extract_item + clarify, item 9 / C-SSRS buttons-only, crisis flags), execution of monitoring dialog actions, texts in config/texts/dialog.yaml Refs #10 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
"""Reusable scale session: asks a questionnaire item by item inside the dialog (SPEC §6 "Сбор шкалы в диалоге").
|
||||
|
||||
Usage from a flow (#36/#37):
|
||||
|
||||
step = await start_scale(ctx, "pcl5", return_state="veteran.onboarding.phq9", scheduled=True)
|
||||
return step # FSM goes to "scale.pcl5"; its enter hook asks item 1; on completion → return_state
|
||||
|
||||
Algorithm per item:
|
||||
1. The item is shown with one button per answer option from `config/scales/<id>.yaml`.
|
||||
2. A button press is taken as is (source="button").
|
||||
3. Free text is accepted only for items without `buttons_only` (PHQ-9 item 9 and the whole C-SSRS are
|
||||
buttons-only: suicidal-ideation answers are never interpreted by the LLM — SPEC §6, ADR-002). The scrubbed
|
||||
text goes to `gateway.extract_item`; `None` (failure / confidence < 0.5 / out-of-range score) → a clarifying
|
||||
question with the same buttons, nothing is stored (source="llm" only for a confident extraction).
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.core.privacy import scrub
|
||||
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
|
||||
|
||||
PREFIX = "scale"
|
||||
|
||||
|
||||
def scale_state(scale_id: str) -> str:
|
||||
return f"{PREFIX}.{scale_id}"
|
||||
|
||||
|
||||
def active_scale(state_name: str) -> str | None:
|
||||
return state_name.split(".", 1)[1] if state_name.startswith(PREFIX + ".") else None
|
||||
|
||||
|
||||
def add_pending(data: dict[str, Any], scale_id: str, scheduled: bool) -> list[dict[str, Any]]:
|
||||
"""Queue a scale to start at the next resting state (idle/crisis); duplicates are ignored."""
|
||||
pending = list(data.get("pending_scales") or [])
|
||||
if scale_id not in {p["scale"] for p in pending}:
|
||||
pending.append({"scale": scale_id, "scheduled": scheduled})
|
||||
return pending
|
||||
|
||||
|
||||
async def start_scale(
|
||||
ctx: Ctx, scale_id: str, *, return_state: str, scheduled: bool = True, intro: str | None = None
|
||||
) -> Step:
|
||||
"""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.
|
||||
"""
|
||||
scale = load_scale(scale_id) # fail fast on an unknown id
|
||||
sessions = dict(ctx.data.get("scales") or {})
|
||||
sessions[scale_id] = {
|
||||
"idx": 0,
|
||||
"answers": {},
|
||||
"return_state": return_state,
|
||||
"scheduled": scheduled,
|
||||
"intro": intro,
|
||||
"intro_shown": False,
|
||||
}
|
||||
target = scale_state(scale_id)
|
||||
if ctx.state == target: # restart in place: the enter hook would not run
|
||||
sessions[scale_id]["intro_shown"] = True
|
||||
return Step(replies=[_question(ctx, scale, sessions[scale_id], intro=True)], data={"scales": sessions})
|
||||
return Step(next_state=target, data={"scales": sessions})
|
||||
|
||||
|
||||
def _question(ctx: Ctx, scale: ScaleDef, session: dict[str, Any], *, intro: bool = False, note: str = "") -> Reply:
|
||||
idx = session["idx"]
|
||||
item = scale.items[idx]
|
||||
parts: list[str] = []
|
||||
if note:
|
||||
parts.append(note)
|
||||
if intro:
|
||||
if session.get("intro"):
|
||||
parts.append(session["intro"])
|
||||
parts.append(ctx.texts.scale.intro.format(count=len(scale.items), instruction=scale.instruction or "").strip())
|
||||
parts.append(ctx.texts.scale.question.format(n=idx + 1, total=len(scale.items), text=item.text))
|
||||
buttons = [[Button(label=o.label, data=f"{PREFIX}:{scale.id}:{item.id}:{o.value}")] for o in item.options]
|
||||
return Reply(text="\n\n".join(parts), buttons=buttons, kind="question")
|
||||
|
||||
|
||||
def _session(ctx: Ctx) -> tuple[ScaleDef, dict[str, Any]] | None:
|
||||
scale_id = active_scale(ctx.state)
|
||||
session = (ctx.data.get("scales") or {}).get(scale_id) if scale_id else None
|
||||
if scale_id is None or session is None:
|
||||
return None
|
||||
return load_scale(scale_id), session
|
||||
|
||||
|
||||
@on_enter(PREFIX)
|
||||
async def _enter(ctx: Ctx) -> Step:
|
||||
found = _session(ctx)
|
||||
if found is None:
|
||||
return Step(next_state="idle")
|
||||
scale, session = found
|
||||
show_intro = session["idx"] == 0 and not session.get("intro_shown")
|
||||
updated = {**session, "intro_shown": True}
|
||||
sessions = {**(ctx.data.get("scales") or {}), scale.id: updated}
|
||||
return Step(replies=[_question(ctx, scale, updated, intro=show_intro)], data={"scales": sessions})
|
||||
|
||||
|
||||
def _parse_button(data: str, scale: ScaleDef, item: Item) -> int | None:
|
||||
parts = data.split(":")
|
||||
if len(parts) != 4 or parts[0] != PREFIX or parts[1] != scale.id or parts[2] != item.id:
|
||||
return None # stale button from an earlier question
|
||||
try:
|
||||
value = int(parts[3])
|
||||
except ValueError:
|
||||
return None
|
||||
return value if value in {o.value for o in item.options} else None
|
||||
|
||||
|
||||
@state(PREFIX)
|
||||
async def _handle(ctx: Ctx, event: Event) -> Step:
|
||||
found = _session(ctx)
|
||||
if found is None:
|
||||
return Step(next_state="idle")
|
||||
scale, session = found
|
||||
item = scale.items[session["idx"]]
|
||||
|
||||
if event.type == "button" and event.button:
|
||||
value = _parse_button(event.button, scale, item)
|
||||
if value is not None:
|
||||
return await _record(ctx, scale, session, item, value, source="button")
|
||||
return Step(replies=[_question(ctx, scale, session)])
|
||||
|
||||
if event.type == "text" and ctx.text is not None:
|
||||
if item.buttons_only:
|
||||
# Safety items are never LLM-interpreted: ask again for a button.
|
||||
return Step(replies=[_question(ctx, scale, session, note=ctx.texts.scale.buttons_only)])
|
||||
extracted = await ctx.gateway.extract_item(item.id, item.text, item.max_value, ctx.text)
|
||||
if extracted is None or extracted.score not in {o.value for o in item.options}:
|
||||
return Step(replies=[_question(ctx, scale, session, note=ctx.texts.scale.clarify)])
|
||||
return await _record(
|
||||
ctx,
|
||||
scale,
|
||||
session,
|
||||
item,
|
||||
extracted.score,
|
||||
source="llm",
|
||||
confidence=extracted.confidence,
|
||||
evidence=extracted.evidence,
|
||||
)
|
||||
|
||||
if event.type == "system":
|
||||
return Step() # reminders etc. do not interrupt a questionnaire
|
||||
return Step(replies=[_question(ctx, scale, session)])
|
||||
|
||||
|
||||
async def _record(
|
||||
ctx: Ctx,
|
||||
scale: ScaleDef,
|
||||
session: dict[str, Any],
|
||||
item: Item,
|
||||
value: int,
|
||||
*,
|
||||
source: str,
|
||||
confidence: float | None = None,
|
||||
evidence: str = "",
|
||||
) -> Step:
|
||||
await repo.add_item_response(
|
||||
ctx.session,
|
||||
token=ctx.token,
|
||||
scale=scale.id,
|
||||
item_id=item.id,
|
||||
score=value,
|
||||
source=source,
|
||||
ts=ctx.now,
|
||||
confidence=confidence,
|
||||
# Evidence is a quote of already scrubbed text; scrub again so nothing unscrubbed can be stored.
|
||||
evidence=scrub(evidence) if evidence else None,
|
||||
)
|
||||
answers = {**session["answers"], item.id: value}
|
||||
profile = await ctx.profile()
|
||||
sex = profile.sex if profile is not None and profile.sex in ("m", "f") else None
|
||||
result = score(scale.id, answers, sex=sex) # type: ignore[arg-type]
|
||||
crisis_flags = [f for f in result.flags if f in ctx.texts.settings.crisis_flags]
|
||||
if crisis_flags or session["idx"] + 1 >= len(scale.items):
|
||||
return await _finish(ctx, scale, session, answers, result, crisis_flags)
|
||||
updated = {**session, "answers": answers, "idx": session["idx"] + 1}
|
||||
sessions = {**(ctx.data.get("scales") or {}), scale.id: updated}
|
||||
return Step(replies=[_question(ctx, scale, updated)], data={"scales": sessions})
|
||||
|
||||
|
||||
async def _finish(
|
||||
ctx: Ctx,
|
||||
scale: ScaleDef,
|
||||
session: dict[str, Any],
|
||||
answers: dict[str, int],
|
||||
result: ScaleResult,
|
||||
crisis_flags: list[str],
|
||||
) -> Step:
|
||||
await repo.add_scale_result(
|
||||
ctx.session,
|
||||
token=ctx.token,
|
||||
scale=scale.id,
|
||||
total=result.total,
|
||||
severity=result.severity_code,
|
||||
complete=result.complete,
|
||||
ts=ctx.now,
|
||||
# PHQ-9 item 9 is stored separately: any value >= 1 escalates regardless of the total.
|
||||
item9=answers.get("phq9_9") if scale.id == "phq9" else None,
|
||||
scheduled=bool(session.get("scheduled", True)),
|
||||
flags={f: True for f in result.flags},
|
||||
)
|
||||
sessions = {k: v for k, v in (ctx.data.get("scales") or {}).items() if k != scale.id}
|
||||
done = Step(
|
||||
replies=[Reply(text=ctx.texts.scale.done)],
|
||||
next_state=session["return_state"],
|
||||
data={"scales": sessions},
|
||||
notify_monitoring=True,
|
||||
)
|
||||
if not crisis_flags:
|
||||
return done
|
||||
|
||||
# Crisis path decided by the scale's own flags (deterministic), not by the LLM.
|
||||
risk_screen = ctx.texts.settings.risk_screen_scale
|
||||
if crisis_open(ctx.data):
|
||||
crisis = Step(next_state="crisis")
|
||||
else:
|
||||
crisis = open_crisis(ctx, categories=[], source=f"scale:{scale.id}")
|
||||
step = done.then(crisis)
|
||||
if scale.id != risk_screen:
|
||||
step.data["pending_scales"] = add_pending(ctx.data, risk_screen, scheduled=False)
|
||||
return step
|
||||
Reference in New Issue
Block a user