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,73 @@
|
||||
"""Execution of *dialog* actions returned by `MonitoringService.after_data_change()` (ADR-007).
|
||||
|
||||
The rules (lane B, `config/rules.yaml`) decide WHAT happens; this module only turns an already chosen action
|
||||
into replies. `alert` / `enhanced_monitoring` are handled by lane B itself (persisted + doctor notified), so they
|
||||
are ignored here. While a crisis is open only crisis-relevant actions (crisis, safety plan, risk screen) reach
|
||||
the patient: no medication tips or visit offers in the middle of a crisis.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from app.core.rules import Action
|
||||
from app.db.clinical import SafetyPlan
|
||||
from app.dialog.contracts import Button, Reply
|
||||
from app.dialog.crisis import crisis_open, open_crisis
|
||||
from app.dialog.fsm import Ctx, Step
|
||||
from app.dialog.scale_session import active_scale, add_pending
|
||||
|
||||
# med_info topic → phrase intent of `config/texts/phrases.yaml` (the LLM only rephrases the template).
|
||||
_MED_INFO_INTENTS = {"delayed_effect": "quit_intent", "alcohol_interaction": "alcohol_info", "nausea": "nausea_info"}
|
||||
_CRISIS_SAFE = {"crisis", "show_safety_plan", "schedule_scale"}
|
||||
|
||||
|
||||
def _plan_line(step: Any) -> str:
|
||||
if isinstance(step, dict):
|
||||
return ": ".join(str(v) for v in step.values() if v)
|
||||
return str(step)
|
||||
|
||||
|
||||
async def safety_plan_reply(ctx: Ctx, block: str | None = None) -> Reply:
|
||||
"""The plan composed with the doctor (`safety_plans`), or the Stanley–Brown template from config."""
|
||||
row = await ctx.session.get(SafetyPlan, ctx.token)
|
||||
steps = [_plan_line(s) for s in row.steps] if row is not None and row.steps else ctx.texts.safety_plan.template
|
||||
lines = [ctx.texts.safety_plan.title, *(f"{i}. {s}" for i, s in enumerate(steps, 1))]
|
||||
if block == "means_restriction":
|
||||
lines += ["", ctx.texts.safety_plan.means_restriction]
|
||||
return Reply(text="\n".join(lines), kind="safety_plan")
|
||||
|
||||
|
||||
async def execute(ctx: Ctx, actions: Iterable[Action]) -> Step:
|
||||
step = Step()
|
||||
shown_plan = False
|
||||
for action in actions:
|
||||
if crisis_open({**ctx.data, **step.data}) and action.type not in _CRISIS_SAFE:
|
||||
continue
|
||||
if action.type == "crisis":
|
||||
if not crisis_open({**ctx.data, **step.data}):
|
||||
step = step.then(open_crisis(ctx, categories=[], source=f"rule:{action.code}"))
|
||||
elif action.type == "show_safety_plan" and not shown_plan:
|
||||
shown_plan = True
|
||||
step = step.then(Step(replies=[await safety_plan_reply(ctx, action.params.get("block"))]))
|
||||
elif action.type == "schedule_scale" and action.params.get("scale"):
|
||||
scale_id = str(action.params["scale"])
|
||||
if active_scale(step.next_state or ctx.state) == scale_id:
|
||||
continue # already being asked
|
||||
data = {**ctx.data, **step.data}
|
||||
pending = add_pending(data, scale_id, bool(action.params.get("scheduled", False)))
|
||||
step = step.then(Step(data={"pending_scales": pending}))
|
||||
elif action.type == "med_info":
|
||||
intent = _MED_INFO_INTENTS.get(str(action.params.get("topic")), "default")
|
||||
text = await ctx.gateway.phrase(intent, {})
|
||||
step = step.then(Step(replies=[Reply(text=text, kind="info")]))
|
||||
elif action.type == "suggest_visit":
|
||||
text = await ctx.gateway.phrase("book_visit_offer", {})
|
||||
button = Button(label=ctx.texts.visit.button, data="menu:visit")
|
||||
step = step.then(Step(replies=[Reply(text=text, buttons=[[button]], kind="info")]))
|
||||
elif action.type == "reach_out":
|
||||
topic = str(action.params.get("topic", "default"))
|
||||
texts = ctx.texts.reach_out
|
||||
text = {"silence": texts.silence, "missed_doses": texts.missed_doses}.get(topic, texts.default)
|
||||
step = step.then(Step(replies=[Reply(text=text)]))
|
||||
# alert / enhanced_monitoring: lane B persists and notifies; nothing to say to the patient.
|
||||
return step
|
||||
Reference in New Issue
Block a user