Files
PTSRaiAgent/app/dialog/flows/meds.py
T
ElebobaandClaude Opus 5.5 2b13d54ed7 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>
2026-10-03 18:45:53 +03:00

406 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Medication flow (#37, case scenario «управление приёмом лекарств»). Module-agnostic: every patient with a
prescription gets it, whatever the clinical module.
system:med_reminder {dose_id, drug, dose}
──▶ «Принял» → dose taken, check-in med_taken = true
──▶ «Пропущу» → dose skipped, med_taken = false ──▶ reason:
забыл / побочки (→ side effects) / решил бросить (quit_intent) / другое
──▶ «Есть побочные эффекты» → code buttons (тошнота, бессонница, …) or «Другое» → free text
→ check-in side_effects (+ dose.side_effects) → «А препарат сегодня приняли?»
menu «Вопрос о лекарстве" ──▶ meds.question (free text) ──▶ med_qa (instruction excerpt) or «уточните у врача»
rule action med_info (actions.py hook) ──▶ instruction excerpt for the rule topic + fixed notice
Everything the patient answers is written to the clinical DB (`doses`, `daily_checkins`) and the step asks the
engine to run monitoring (`notify_monitoring`): the rules (lane B, ADR-007) decide alerts and further actions
(quit intent → delayed-onset explanation, missed doses → caring contact). Nothing here decides risk.
Reminder buttons carry fixed data ("med:taken", ...) so they are plain idle triggers; the dose they refer to is
kept in `dialog_state.data["med_pending"]` ({dose_id, drug, dose, day}) — a button from an earlier day is stale.
"""
from datetime import date, timedelta
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from app.config import get_settings
from app.core.privacy import scrub
from app.core.rules import Action
from app.db import repo
from app.db.clinical import DailyCheckin, Dose
from app.db.clinical import Prescription as PrescriptionRow
from app.dialog.actions import register_med_info_handler
from app.dialog.contracts import Button, Event, Reply
from app.dialog.flows.core import menu_reply
from app.dialog.fsm import Ctx, Step, get_trigger, handler_for, state, trigger
from app.integrations.base import integrations
from app.llm.med_qa import fallback_text, med_qa
QUESTION_STATE = "meds.question"
SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text"
SIDE_EFFECT_CODES = ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")
SKIP_REASONS = ("forgot", "side_effects", "quit", "other")
# ------------------------------------------------------------------------------------------- texts
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class ReminderTexts(_Strict):
text: str
text_no_drug: str
taken: str
skip: str
side_effects: str
class SkipTexts(_Strict):
question: str
reasons: dict[str, str]
ack: dict[str, str]
class SideEffectTexts(_Strict):
question: str
options: dict[str, str]
questions: dict[str, str]
describe: str
ack: str
repeat: str
ask_dose: str
dose_yes: str
dose_no: str
class CheckinMedTexts(_Strict):
question: str
question_no_drug: str
yes: str
no: str
later: str
class QuestionTexts(_Strict):
prompt: str
no_prescription: str
class MedInfoTopic(_Strict):
section: str # med_qa section code (side_effects | onset | alcohol | ...)
question: str
lead: str = ""
fallback: str
notice: str = ""
class MedsTexts(_Strict):
status: str
reminder: ReminderTexts
taken_ack: str
stale: str
skip: SkipTexts
side_effects: SideEffectTexts
checkin: CheckinMedTexts
question: QuestionTexts
med_info: dict[str, MedInfoTopic]
def meds_texts_path() -> Path:
return get_settings().config_dir / "texts" / "meds.yaml"
@lru_cache
def load_meds_texts(path: Path | None = None) -> MedsTexts:
path = path or meds_texts_path()
return MedsTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
# ------------------------------------------------------------------------------------------- data helpers
class Rx(BaseModel):
"""Active prescription as the dialog needs it (drug INN + dose exactly as the doctor wrote them)."""
id: int | None
drug: str
dose: str
async def current_prescription(ctx: Ctx) -> Rx | None:
"""Latest prescription active today: clinical DB first, then the МИС integration (demo: in-memory)."""
today = ctx.now.date()
rows = await ctx.session.scalars(
select(PrescriptionRow).where(PrescriptionRow.token == ctx.token).order_by(PrescriptionRow.id.desc())
)
for row in rows:
if (row.start_date is None or row.start_date <= today) and (row.end_date is None or row.end_date >= today):
return Rx(id=row.id, drug=row.drug, dose=row.dose)
for p in await integrations.prescriptions.get_prescriptions(ctx.token):
if (p.start_date is None or p.start_date <= today) and (p.end_date is None or p.end_date >= today):
return Rx(id=None, drug=p.drug, dose=p.dose)
return None
async def checkin_of(ctx: Ctx, day: date) -> DailyCheckin | None:
return await ctx.session.scalar(
select(DailyCheckin).where(DailyCheckin.token == ctx.token, DailyCheckin.day == day)
)
async def save_checkin(ctx: Ctx, **fields: Any) -> DailyCheckin:
return await repo.upsert_checkin(ctx.session, token=ctx.token, day=ctx.now.date(), now=ctx.now, **fields)
def pending_dose(ctx: Ctx) -> dict[str, Any] | None:
"""Today's reminder the patient is answering, or None (no reminder / reminder from an earlier day)."""
pending = ctx.data.get("med_pending")
if isinstance(pending, dict) and pending.get("day") == ctx.now.date().isoformat():
return pending
return None
async def _dose_row(ctx: Ctx, pending: dict[str, Any] | None) -> Dose | None:
if not pending or pending.get("dose_id") is None:
return None
row = await ctx.session.get(Dose, pending["dose_id"])
return row if row is not None and row.token == ctx.token else None
async def record_dose(ctx: Ctx, taken: bool, *, status: str | None = None) -> None:
"""Dose answer → `doses` row of today's reminder (if any) + `daily_checkins.med_taken` (rules read both)."""
row = await _dose_row(ctx, pending_dose(ctx))
if row is not None:
row.status = status or ("taken" if taken else "skipped")
row.answered_at = ctx.now
await save_checkin(ctx, med_taken=taken)
async def record_side_effects(ctx: Ctx, codes: list[str]) -> None:
"""Merge side-effect codes into today's check-in (rule `side_effect_3d` counts days with any code)."""
today = await checkin_of(ctx, ctx.now.date())
merged = list(dict.fromkeys([*(today.side_effects if today and today.side_effects else []), *codes]))
await save_checkin(ctx, side_effects=merged)
row = await _dose_row(ctx, pending_dose(ctx))
if row is not None:
row.side_effects = ",".join(c for c in dict.fromkeys([*(row.side_effects or "").split(","), *codes]) if c)
async def side_effect_reply(ctx: Ctx, codes: list[str]) -> Reply:
"""Reaction to newly reported side effects.
Same effect as yesterday → short support (the instruction was already shown; persistence over 3 days is the
rules' business). New effect with a known instruction question → the excerpt of the drug instruction
(med_qa, section side_effects) — never generated advice. Otherwise a plain acknowledgement.
"""
t = load_meds_texts().side_effects
yesterday = await checkin_of(ctx, ctx.now.date() - timedelta(days=1))
if yesterday is not None and set(codes) & set(yesterday.side_effects or []):
return Reply(text=t.repeat, kind="info")
rx = await current_prescription(ctx)
for code in codes:
if code in t.questions and rx is not None:
answer = await med_qa(rx.drug, scrub(t.questions[code]), topic="side_effects")
if answer is not None:
return Reply(text=answer.text, kind="info")
return Reply(text=t.ack, kind="info")
async def leave_to_idle(ctx: Ctx, event: Event, data: dict[str, Any] | None = None) -> Step:
"""Abandon the current sub-flow and let the idle state handle the event (menu button, system event...)."""
step = Step(next_state="idle", data=data or {})
idle = handler_for("idle")
return step.then(await idle(ctx, event)) if idle is not None else step
async def run_trigger_inline(ctx: Ctx, key: str, event: Event) -> Step | None:
"""Run an idle trigger from inside another flow (e.g. a reminder button pressed during the check-in)."""
handler = get_trigger(key)
return await handler(ctx, event) if handler is not None else None
# ------------------------------------------------------------------------------------------- reminder
def _reminder_buttons() -> list[list[Button]]:
t = load_meds_texts().reminder
return [
[Button(label=t.taken, data="med:taken")],
[Button(label=t.skip, data="med:skip")],
[Button(label=t.side_effects, data="med:side_effects")],
]
@trigger("system:med_reminder", replace=True)
async def _med_reminder(ctx: Ctx, event: Event) -> Step:
t = load_meds_texts().reminder
rx = await current_prescription(ctx)
drug = str(event.params.get("drug") or (rx.drug if rx else ""))
dose = str(event.params.get("dose") or (rx.dose if rx else ""))
try:
dose_id: int | None = int(event.params["dose_id"]) # type: ignore[call-overload]
except (KeyError, TypeError, ValueError):
dose_id = None
if dose_id is None and rx is not None and rx.id is not None:
# No scheduler row (demo console without #13): create the planned dose so the answer has a record.
row = Dose(token=ctx.token, prescription_id=rx.id, planned_at=ctx.now, status="pending")
ctx.session.add(row)
await ctx.session.flush()
dose_id = row.id
pending = {"dose_id": dose_id, "drug": drug, "dose": dose, "day": ctx.now.date().isoformat()}
text = t.text.format(drug=drug, dose=dose).replace(" ", " ") if drug else t.text_no_drug
return Step(replies=[Reply(text=text, buttons=_reminder_buttons(), kind="question")], data={"med_pending": pending})
def _stale(ctx: Ctx) -> Step | None:
return None if pending_dose(ctx) is not None else Step(replies=[Reply(text=load_meds_texts().stale)])
@trigger("button:med:taken")
async def _taken(ctx: Ctx, event: Event) -> Step:
if (stale := _stale(ctx)) is not None:
return stale
await record_dose(ctx, True)
return Step(replies=[Reply(text=load_meds_texts().taken_ack)], notify_monitoring=True)
@trigger("button:med:skip")
async def _skip(ctx: Ctx, event: Event) -> Step:
if (stale := _stale(ctx)) is not None:
return stale
await record_dose(ctx, False, status="skipped")
t = load_meds_texts().skip
buttons = [[Button(label=t.reasons[r], data=f"med:skip_reason:{r}")] for r in SKIP_REASONS]
return Step(replies=[Reply(text=t.question, buttons=buttons, kind="question")], notify_monitoring=True)
def _side_effect_question() -> Reply:
t = load_meds_texts().side_effects
buttons = [[Button(label=t.options[c], data=f"med:se:{c}")] for c in SIDE_EFFECT_CODES]
return Reply(text=t.question, buttons=buttons, kind="question")
def _skip_reason(reason: str) -> Any:
async def handler(ctx: Ctx, event: Event) -> Step:
if reason == "side_effects":
return Step(replies=[_side_effect_question()])
if reason == "quit":
# «Решил бросить» is the same signal as "брошу" in free text: rule `quit_intent` reacts (alert +
# delayed-onset explanation through the med_info action).
await save_checkin(ctx, quit_intent=True)
return Step(replies=[Reply(text=load_meds_texts().skip.ack["quit"])], notify_monitoring=True)
return Step(replies=[Reply(text=load_meds_texts().skip.ack[reason])])
return handler
for _reason in SKIP_REASONS:
trigger(f"button:med:skip_reason:{_reason}")(_skip_reason(_reason))
@trigger("button:med:side_effects")
async def _side_effects(ctx: Ctx, event: Event) -> Step:
return Step(replies=[_side_effect_question()])
async def _after_side_effects(ctx: Ctx, codes: list[str]) -> Step:
await record_side_effects(ctx, codes)
replies = [await side_effect_reply(ctx, codes)]
today = await checkin_of(ctx, ctx.now.date())
if (today is None or today.med_taken is None) and await current_prescription(ctx) is not None:
t = load_meds_texts().side_effects
buttons = [[Button(label=t.dose_yes, data="med:dose:yes")], [Button(label=t.dose_no, data="med:dose:no")]]
replies.append(Reply(text=t.ask_dose, buttons=buttons, kind="question"))
return Step(replies=replies, next_state="idle", notify_monitoring=True)
def _side_effect_code(code: str) -> Any:
async def handler(ctx: Ctx, event: Event) -> Step:
if code == "other":
return Step(
replies=[Reply(text=load_meds_texts().side_effects.describe)], next_state=SIDE_EFFECT_TEXT_STATE
)
return await _after_side_effects(ctx, [code])
return handler
for _code in SIDE_EFFECT_CODES:
trigger(f"button:med:se:{_code}")(_side_effect_code(_code))
@state(SIDE_EFFECT_TEXT_STATE)
async def _side_effect_text(ctx: Ctx, event: Event) -> Step:
if event.type != "text" or ctx.text is None:
return await leave_to_idle(ctx, event)
facts = await ctx.gateway.extract_checkin(ctx.text)
# Store only normalised codes, never the description itself (it stays in the scrubbed message log).
codes = [str(c) for c in facts.side_effects] if facts is not None and facts.side_effects else ["other"]
return await _after_side_effects(ctx, codes)
def _dose_answer(taken: bool) -> Any:
async def handler(ctx: Ctx, event: Event) -> Step:
await record_dose(ctx, taken, status=None if taken else "missed")
return Step(
replies=[Reply(text=load_meds_texts().taken_ack if taken else load_meds_texts().skip.ack["other"])],
notify_monitoring=True,
)
return handler
trigger("button:med:dose:yes")(_dose_answer(True))
trigger("button:med:dose:no")(_dose_answer(False))
# ------------------------------------------------------------------------------------------- question menu
@trigger("button:menu:med_question", replace=True)
async def _menu_question(ctx: Ctx, event: Event) -> Step:
t = load_meds_texts().question
if await current_prescription(ctx) is None:
return Step(replies=[menu_reply(ctx, t.no_prescription)])
return Step(replies=[Reply(text=t.prompt)], next_state=QUESTION_STATE)
async def answer_question(ctx: Ctx, rx: Rx | None) -> str:
"""Answer from the instruction excerpt only (SPEC §7 п.5); dose questions and gaps → «уточните у врача»."""
if rx is None or ctx.text is None:
return load_meds_texts().question.no_prescription
answer = await med_qa(rx.drug, ctx.text)
return answer.text if answer is not None else fallback_text()
@state(QUESTION_STATE)
async def _question(ctx: Ctx, event: Event) -> Step:
if event.type != "text" or ctx.text is None:
return await leave_to_idle(ctx, event)
text = await answer_question(ctx, await current_prescription(ctx))
return Step(replies=[menu_reply(ctx, text)], next_state="idle")
# ------------------------------------------------------------------------------------------- rule actions
async def med_info_reply(ctx: Ctx, action: Action) -> Reply | None:
"""`med_info` action of a rule (quit_intent → onset, alcohol_3d → alcohol): instruction excerpt framed by
fixed texts. The rule already decided to say it; this only picks the words. None → engine template."""
spec = load_meds_texts().med_info.get(str(action.params.get("topic")))
if spec is None:
return None
rx = await current_prescription(ctx)
answer = await med_qa(rx.drug, scrub(spec.question), topic=spec.section) if rx is not None else None
body = answer.text if answer is not None else spec.fallback
return Reply(text="\n\n".join(p for p in (spec.lead, body, spec.notice) if p), kind="info")
register_med_info_handler(med_info_reply)