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