feat(bot): veteran daily check-in, medication reminders and rule reactions

Daily check-in (mood → dose → sleep/nightmares → alcohol → free text with
extract_checkin, buttons win over the LLM, progressive upsert of
daily_checkins), medication reminder flow (taken / skip with reason /
side effects → doses + daily_checkins), medication question menu via
med_qa, med_info rule reactions from the drug instruction excerpt,
«записаться раньше» request recorded for lane B, friendly intros for
scheduled scales. actions.py: additive med_info handler hook.

Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 18:45:53 +03:00
co-authored by Claude Opus 5.5
parent 4c6e150162
commit 2b13d54ed7
5 changed files with 1061 additions and 4 deletions
+16 -4
View File
@@ -6,7 +6,7 @@ are ignored here. While a crisis is open only crisis-relevant actions (crisis, s
the patient: no medication tips or visit offers in the middle of a crisis.
"""
from collections.abc import Iterable
from collections.abc import Awaitable, Callable, Iterable
from typing import Any
from app.core.rules import Action
@@ -20,6 +20,16 @@ from app.dialog.scale_session import active_scale, add_pending
_MED_INFO_INTENTS = {"delayed_effect": "quit_intent", "alcohol_interaction": "alcohol_info", "nausea": "nausea_info"}
_CRISIS_SAFE = {"crisis", "show_safety_plan", "schedule_scale"}
# Optional `med_info` answerer registered by a flow (`flows/meds.py`, #37): answers from the drug instruction
# (med_qa). Returning None falls back to the phrase template below.
MedInfoHandler = Callable[[Ctx, Action], Awaitable[Reply | None]]
_med_info_handler: MedInfoHandler | None = None
def register_med_info_handler(handler: MedInfoHandler | None) -> None:
global _med_info_handler
_med_info_handler = handler
def _plan_line(step: Any) -> str:
if isinstance(step, dict):
@@ -57,9 +67,11 @@ async def execute(ctx: Ctx, actions: Iterable[Action]) -> Step:
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")]))
reply = await _med_info_handler(ctx, action) if _med_info_handler is not None else None
if reply is None:
intent = _MED_INFO_INTENTS.get(str(action.params.get("topic")), "default")
reply = Reply(text=await ctx.gateway.phrase(intent, {}), kind="info")
step = step.then(Step(replies=[reply]))
elif action.type == "suggest_visit":
text = await ctx.gateway.phrase("book_visit_offer", {})
button = Button(label=ctx.texts.visit.button, data="menu:visit")