diff --git a/app/dialog/actions.py b/app/dialog/actions.py index 6143e37..5ffb3d8 100644 --- a/app/dialog/actions.py +++ b/app/dialog/actions.py @@ -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") diff --git a/app/dialog/flows/meds.py b/app/dialog/flows/meds.py new file mode 100644 index 0000000..f848ed0 --- /dev/null +++ b/app/dialog/flows/meds.py @@ -0,0 +1,405 @@ +"""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) diff --git a/app/dialog/flows/veteran_daily.py b/app/dialog/flows/veteran_daily.py new file mode 100644 index 0000000..8cd8873 --- /dev/null +++ b/app/dialog/flows/veteran_daily.py @@ -0,0 +1,480 @@ +"""Daily check-in and scheduled-scale intros (#37, ADR-002, CLINICAL_MODULES §1–2, case «мониторинг после +обращения»). + +Check-in = core (mood 0–10, dose taken) + the module question (veteran: sleep hours + nightmares; alcohol for +veterans and for anyone with a positive AUDIT-C) + free text: + + system:daily_checkin / menu «Как я сегодня» + ──▶ daily.mood buttons 0–10 (a bare number is accepted too) + ──▶ daily.med only with a prescription and no dose answer today (the reminder may have got it) + ──▶ daily.sleep <4 / 4–6 / 6–8 / >8 h ┐ module veteran + ──▶ daily.nightmares yes / no ┘ + ──▶ daily.alcohol none / 1–2 / more (veteran or AUDIT-C positive) + ──▶ daily.text «Как вы? Пару слов» (skip button) → extract_checkin on the SCRUBBED text + ──▶ [daily.alcohol_amount] alcohol mentioned in the text but not asked by button → amount buttons + ──▶ idle upsert done, `notify_monitoring` → rules (lane B) → dialog actions + +Data algorithm (medical data, jury requirement): + * Every button answer is written to `daily_checkins` immediately (`repo.upsert_checkin` merges non-None + fields), so an interrupted check-in (crisis, the patient walking away) still leaves today's mood on record — + the "mood ≤ 3 three days in a row" rule must not miss a day because the free text was never written. + * Button answers are the patient's own and always win over the LLM: from the free text the LLM may only add + side effects, the quit intent and fields that were not asked by buttons today. + * The LLM never decides anything here: alerts and follow-ups (delayed-onset explanation, unscheduled PHQ-9, + alcohol interaction, visit offer) come from the rules through `MonitoringService` (ADR-007). + * A free-text message classified POSSIBLE by the safety layer is taken over by the engine (C-SSRS). When the + screen returns to `daily.text` the message counts as the answer: the check-in is closed, not asked again. + * A check-in left unfinished is dropped on the next system event of a later day. + +Also here: friendly intros for scheduled scales (pcl5, phq9, dar5, auditc) — the engine starts `scale_due` +scales without an intro, so an exact `scale.` enter hook adds one and delegates to the generic scale hook. +""" + +import re +from dataclasses import replace +from datetime import datetime +from functools import lru_cache +from pathlib import Path +from typing import Any + +import yaml +from pydantic import BaseModel, ConfigDict + +from app.config import get_settings +from app.core.intake_runtime import intake_enabled +from app.core.rules import load_rules +from app.db import repo +from app.dialog.contracts import Button, Event, Reply +from app.dialog.flows import intake +from app.dialog.flows.core import menu_reply +from app.dialog.flows.meds import ( + answer_question, + checkin_of, + current_prescription, + leave_to_idle, + load_meds_texts, + record_dose, + record_side_effects, + run_trigger_inline, + save_checkin, + side_effect_reply, +) +from app.dialog.fsm import Ctx, Step, enter_hook_for, on_enter, state, trigger +from app.dialog.scale_session import PREFIX as SCALE_PREFIX +from app.dialog.scale_session import active_scale, scale_state + +PREFIX = "daily" +STEPS = ("mood", "med", "sleep", "nightmares", "alcohol", "text") +ALCOHOL_AMOUNT = "alcohol_amount" +SLEEP_BUCKETS = ("<4", "4-6", "6-8", ">8") +ALCOHOL_BUCKETS = ("none", "1-2", "more") +_NUMBER = re.compile(r"^\s*(\d{1,2})\s*$") + + +# ------------------------------------------------------------------------------------------- texts + + +class _Strict(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class DailySettings(_Strict): + sleep_modules: list[str] + alcohol_daily_modules: list[str] + intro_scales: list[str] + + +class Question(_Strict): + question: str + + +class OptionsQuestion(_Strict): + question: str + options: dict[str, str] + + +class YesNoQuestion(_Strict): + question: str + yes: str + no: str + + +class AlcoholQuestion(_Strict): + question: str + clarify: str + options: dict[str, str] + + +class TextQuestion(_Strict): + question: str + skip: str + + +class CheckinTexts(_Strict): + start: str + mood: Question + sleep: OptionsQuestion + nightmares: YesNoQuestion + alcohol: AlcoholQuestion + text: TextQuestion + buttons_hint: str + done: str + already_done: str + + +class VisitTexts(_Strict): + requested: str + + +class ScaleIntros(_Strict): + scheduled: dict[str, str] + unscheduled: dict[str, str] + + +class DailyTexts(_Strict): + status: str + settings: DailySettings + checkin: CheckinTexts + visit: VisitTexts + scale_intros: ScaleIntros + + +def daily_texts_path() -> Path: + return get_settings().config_dir / "texts" / "veteran_daily.yaml" + + +@lru_cache +def load_daily_texts(path: Path | None = None) -> DailyTexts: + path = path or daily_texts_path() + return DailyTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) + + +# ------------------------------------------------------------------------------------------- step logic + + +def _today(ctx: Ctx) -> str: + return ctx.now.date().isoformat() + + +def _ck(ctx: Ctx) -> dict[str, Any]: + return dict(ctx.data.get("checkin") or {}) + + +async def _modules(ctx: Ctx) -> set[str]: + profile = await ctx.profile() + return set(profile.modules or []) if profile is not None else set() + + +async def _auditc_positive(ctx: Ctx) -> bool: + """Latest AUDIT-C at or above the sex-specific cut-off of rule `auditc_positive` (one threshold, one place).""" + results = await repo.list_scale_results(ctx.session, ctx.token, "auditc") + if not results: + return False + rule = next((r for r in load_rules().rules if r.id == "auditc_positive"), None) + if rule is None: + return False + profile = await ctx.profile() + key = "threshold_f" if profile is not None and profile.sex == "f" else "threshold_m" + return results[-1].total >= int(rule.params[key]) + + +async def _applicable(ctx: Ctx, step: str) -> bool: + settings = load_daily_texts().settings + if step in ("mood", "text"): + return True + if step == "med": + today = await checkin_of(ctx, ctx.now.date()) + return (today is None or today.med_taken is None) and await current_prescription(ctx) is not None + modules = await _modules(ctx) + if step in ("sleep", "nightmares"): + return bool(modules & set(settings.sleep_modules)) + if step == "alcohol": + return bool(modules & set(settings.alcohol_daily_modules)) or await _auditc_positive(ctx) + return False + + +async def _next_step(ctx: Ctx, after: str | None) -> str: + rest = STEPS if after is None else STEPS[STEPS.index(after) + 1 :] + for step in rest: + if await _applicable(ctx, step): + return step + return "text" + + +def _buttons(step: str, options: list[tuple[str, str]]) -> list[list[Button]]: + return [[Button(label=label, data=f"{PREFIX}:{step}:{value}")] for value, label in options] + + +async def _question(ctx: Ctx, step: str, note: str = "") -> Reply: + t = load_daily_texts().checkin + buttons: list[list[Button]] + if step == "mood": + text = t.mood.question + # Two rows (0–5, 6–10): 11 buttons in one column are hard to use on a phone. + buttons = [ + [Button(label=str(v), data=f"{PREFIX}:mood:{v}") for v in range(6)], + [Button(label=str(v), data=f"{PREFIX}:mood:{v}") for v in range(6, 11)], + ] + elif step == "med": + m = load_meds_texts().checkin + rx = await current_prescription(ctx) + text = m.question.format(drug=rx.drug) if rx is not None else m.question_no_drug + buttons = _buttons(step, [("yes", m.yes), ("no", m.no), ("later", m.later)]) + elif step == "sleep": + text = t.sleep.question + buttons = _buttons(step, [(b, t.sleep.options[b]) for b in SLEEP_BUCKETS]) + elif step == "nightmares": + text = t.nightmares.question + buttons = _buttons(step, [("yes", t.nightmares.yes), ("no", t.nightmares.no)]) + elif step in ("alcohol", ALCOHOL_AMOUNT): + text = t.alcohol.question if step == "alcohol" else t.alcohol.clarify + buttons = _buttons(step, [(b, t.alcohol.options[b]) for b in ALCOHOL_BUCKETS]) + else: + text = t.text.question + buttons = _buttons("text", [("skip", t.text.skip)]) + return Reply(text="\n\n".join(p for p in (note, text) if p), buttons=buttons, kind="question") + + +async def start_checkin(ctx: Ctx, *, from_menu: bool) -> Step: + if ctx.data.get("checkin_done") == _today(ctx): + # The scheduler may fire twice; the menu button gets a friendly note instead of a second check-in. + return Step(replies=[menu_reply(ctx, load_daily_texts().checkin.already_done)]) if from_menu else Step() + first = await _next_step(ctx, None) + ck = {"day": _today(ctx), "answered": [], "started_at": ctx.now.isoformat()} + return Step( + replies=[Reply(text=load_daily_texts().checkin.start)], + next_state=f"{PREFIX}.{first}", + data={"checkin": ck}, + ) + + +def _finish(ctx: Ctx, replies: list[Reply] | None = None) -> Step: + done = Reply(text=load_daily_texts().checkin.done) + return Step( + replies=[done, *(replies or [])], + next_state="idle", + data={"checkin": None, "checkin_done": _today(ctx)}, + notify_monitoring=True, + ) + + +def _consumed_by_safety(ctx: Ctx, ck: dict[str, Any]) -> bool: + """The free-text answer was taken over by the safety layer (C-SSRS screen) after the question was asked.""" + asked = ck.get("text_asked_at") + if not asked: + return False + for event in reversed(ctx.data.get("safety_events") or []): + if event.get("state") == f"{PREFIX}.text" and datetime.fromisoformat(event["ts"]) >= datetime.fromisoformat( + asked + ): + return True + return False + + +@on_enter(PREFIX) +async def _enter(ctx: Ctx) -> Step: + step = ctx.state.split(".", 1)[1] if "." in ctx.state else "" + ck = _ck(ctx) + if not ck or ck.get("day") != _today(ctx): + return Step(next_state="idle", data={"checkin": None}) + if step == "text": + if _consumed_by_safety(ctx, ck): + return _finish(ctx) + return Step( + replies=[await _question(ctx, step)], data={"checkin": {**ck, "text_asked_at": ctx.now.isoformat()}} + ) + return Step(replies=[await _question(ctx, step)]) + + +async def _advance(ctx: Ctx, step: str, ck: dict[str, Any]) -> Step: + ck = {**ck, "answered": [*ck.get("answered", []), step]} + if step == ALCOHOL_AMOUNT: + return _finish(ctx) + nxt = await _next_step(ctx, step) + return Step(next_state=f"{PREFIX}.{nxt}", data={"checkin": ck}) + + +async def _record_button(ctx: Ctx, step: str, value: str) -> bool: + """Store one button answer right away. False = value not valid for this step (stale/forged button).""" + if step == "mood" and value.isdigit() and 0 <= int(value) <= 10: + await save_checkin(ctx, mood=int(value)) + elif step == "med" and value in ("yes", "no", "later"): + if value != "later": # «Ещё нет» is not a missed dose: nothing is recorded yet + await record_dose(ctx, value == "yes", status=None if value == "yes" else "missed") + elif step == "sleep" and value in SLEEP_BUCKETS: + await save_checkin(ctx, sleep_bucket=value) + elif step == "nightmares" and value in ("yes", "no"): + await save_checkin(ctx, nightmares=value == "yes") + elif step in ("alcohol", ALCOHOL_AMOUNT) and value in ALCOHOL_BUCKETS: + await save_checkin(ctx, alcohol_bucket=value) + else: + return False + return True + + +async def _free_text(ctx: Ctx, ck: dict[str, Any]) -> Step: + assert ctx.text is not None + facts = await ctx.gateway.extract_checkin(ctx.text) + answered = set(ck.get("answered", [])) + fields: dict[str, Any] = {"free_text_scrubbed": ctx.text} + replies: list[Reply] = [] + clarify_alcohol = False + if facts is not None: + if facts.quit_intent: + fields["quit_intent"] = True + # Buttons win: the LLM only fills what was not asked today. + if "med" not in answered and facts.med_taken is not None: + today = await checkin_of(ctx, ctx.now.date()) + if today is None or today.med_taken is None: + fields["med_taken"] = facts.med_taken + if "sleep" not in answered and facts.sleep_hours_bucket is not None: + fields["sleep_bucket"] = facts.sleep_hours_bucket + if "nightmares" not in answered and facts.nightmares is not None: + fields["nightmares"] = facts.nightmares + if "alcohol" not in answered and facts.alcohol is not None: + fields["alcohol_bucket"] = facts.alcohol # provisional; the amount buttons below refine it + clarify_alcohol = facts.alcohol != "none" + if facts.side_effects: + await record_side_effects(ctx, [str(c) for c in facts.side_effects]) + # One topic per message: when the text also carries a quit intent or alcohol, the rules answer + # those (delayed onset / interaction); the side-effect excerpt would only bury that message. + if not facts.quit_intent and facts.alcohol in (None, "none"): + replies.append(await side_effect_reply(ctx, [str(c) for c in facts.side_effects])) + await save_checkin(ctx, **fields) + if clarify_alcohol: + return Step(replies=replies, next_state=f"{PREFIX}.{ALCOHOL_AMOUNT}", data={"checkin": ck}) + return _finish(ctx, replies) + + +@state(PREFIX) +async def _handle(ctx: Ctx, event: Event) -> Step: + step = ctx.state.split(".", 1)[1] if "." in ctx.state else "" + ck = _ck(ctx) + if not ck or ck.get("day") != _today(ctx): + # Unfinished check-in from an earlier day: drop it and handle the event as idle would. + return await leave_to_idle(ctx, event, {"checkin": None}) + + if event.type == "button" and event.button: + parts = event.button.split(":") + if len(parts) == 3 and parts[0] == PREFIX and parts[1] == step: + if step == "text" and parts[2] == "skip": + return _finish(ctx) + if await _record_button(ctx, step, parts[2]): + return await _advance(ctx, step, ck) + if parts[0] == "med": + # A reminder button pressed mid check-in: answer it, then repeat the current question. + inline = await run_trigger_inline(ctx, f"button:{event.button}", event) + if inline is not None: + return inline if inline.next_state else inline.then(Step(replies=[await _question(ctx, step)])) + if parts[0] in (PREFIX, "scale"): + return Step(replies=[await _question(ctx, step)]) # stale button of an earlier question + return await leave_to_idle(ctx, event, {"checkin": None}) + + if event.type == "system" and event.system: + if event.system == "daily_checkin": + return Step(replies=[await _question(ctx, step)]) + inline = await run_trigger_inline(ctx, f"system:{event.system}", event) + if inline is None: + return Step() + return inline if inline.next_state else inline.then(Step(replies=[await _question(ctx, step)])) + + if event.type == "text" and ctx.text is not None: + if step == "text": + return await _free_text(ctx, ck) + number = _NUMBER.match(ctx.text) + if step == "mood" and number and await _record_button(ctx, "mood", number.group(1)): + return await _advance(ctx, step, ck) + return Step(replies=[await _question(ctx, step, note=load_daily_texts().checkin.buttons_hint)]) + + return Step(replies=[await _question(ctx, step)]) + + +# ------------------------------------------------------------------------------------------- idle triggers + + +@trigger("system:daily_checkin", replace=True) +async def _daily_checkin(ctx: Ctx, event: Event) -> Step: + if intake_enabled(): # the intake diary (#53, feature flag) replaces this check-in when released + return await intake.checkin(ctx, event) + return await start_checkin(ctx, from_menu=False) + + +@trigger("button:menu:checkin", replace=True) +async def _menu_checkin(ctx: Ctx, event: Event) -> Step: + if intake_enabled(): + return await intake.checkin(ctx, event) + return await start_checkin(ctx, from_menu=True) + + +@trigger("button:menu:visit", replace=True) +async def _visit(ctx: Ctx, event: Event) -> Step: + # «Записаться раньше» (rule action suggest_visit): no online booking yet — the request is a fact for lane B + # (#38) and the dashboard, like `contact_requests`. No alert is created here (ADR-007). + requests = [*(ctx.data.get("visit_requests") or []), {"ts": ctx.now.isoformat()}][-20:] + return Step( + replies=[Reply(text=load_daily_texts().visit.requested)], + data={"visit_requests": requests}, + notify_monitoring=True, + ) + + +@trigger("text:idle", replace=True) +async def _idle_text(ctx: Ctx, event: Event) -> Step: + """Free text outside the check-in: drug question → instruction excerpt; side effects / quit intent / alcohol + → today's check-in (the rules react); otherwise the neutral support phrase with the menu.""" + assert ctx.text is not None + facts = await ctx.gateway.extract_checkin(ctx.text) + if facts is None: + return Step(replies=[menu_reply(ctx, await ctx.gateway.phrase("support"))]) + fields: dict[str, Any] = {} + if facts.quit_intent: + fields["quit_intent"] = True + if facts.alcohol is not None and facts.alcohol != "none": + today = await checkin_of(ctx, ctx.now.date()) + if today is None or today.alcohol_bucket in (None, "none"): + fields["alcohol_bucket"] = facts.alcohol + text: str | None = None + if facts.side_effects: + codes = [str(c) for c in facts.side_effects] + await record_side_effects(ctx, codes) + if not facts.quit_intent: + text = (await side_effect_reply(ctx, codes)).text + if fields: + await save_checkin(ctx, **fields) + if text is None and facts.med_question and not facts.quit_intent: + text = await answer_question(ctx, await current_prescription(ctx)) + if text is None: + text = await ctx.gateway.phrase("support") + return Step(replies=[menu_reply(ctx, text)], notify_monitoring=bool(fields or facts.side_effects)) + + +# ------------------------------------------------------------------------------------------- scale intros + + +async def _scale_intro(ctx: Ctx) -> Step: + """Add the friendly intro of a scheduled (or rule-triggered) scale, then run the generic scale enter hook. + + Onboarding sessions (#36) and sessions started with their own intro are left untouched. + """ + scale_id = active_scale(ctx.state) + sessions = dict(ctx.data.get("scales") or {}) + session = sessions.get(scale_id) if scale_id else None + if ( + scale_id is not None + and session is not None + and session.get("idx") == 0 + and not session.get("intro_shown") + and not session.get("intro") + and ".onboarding" not in str(session.get("return_state", "")) + ): + intros = load_daily_texts().scale_intros + intro = (intros.scheduled if session.get("scheduled", True) else intros.unscheduled).get(scale_id) + if intro: + sessions[scale_id] = {**session, "intro": intro} + ctx = replace(ctx, data={**ctx.data, "scales": sessions}) + generic = enter_hook_for(SCALE_PREFIX) + return await generic(ctx) if generic is not None else Step() + + +for _scale_id in load_daily_texts().settings.intro_scales: + on_enter(scale_state(_scale_id))(_scale_intro) diff --git a/config/texts/meds.yaml b/config/texts/meds.yaml new file mode 100644 index 0000000..e22194f --- /dev/null +++ b/config/texts/meds.yaml @@ -0,0 +1,98 @@ +# Тексты приёма препарата (issue #37): напоминание, пропуск, побочные эффекты, вопрос о лекарстве, ответы +# на действия правил med_info (отсроченный эффект, алкоголь, побочные эффекты). +# СТАТУС: черновик, утверждает психиатр (#8). +# +# Правила текстов: без диагнозов и названий расстройств; без советов по дозам и схеме приёма (решает врач); +# не использовать слова, которые звучат как постановка на учёт или слежка (проверяется тестом). Ответы о +# препарате берутся только из выдержки инструкции ГРЛС (app/llm/med_qa.py); тексты ниже — обрамление и +# запасные варианты, если выдержки нет. + +status: "черновик, утверждает психиатр (#8)" + +reminder: + # {drug}, {dose} — из назначения врача (МНН и дозировка как в назначении, без изменений). + text: "Время принять {drug} {dose}. Отметьте, пожалуйста, как получилось." + text_no_drug: "Время принять препарат, назначенный врачом. Отметьте, пожалуйста, как получилось." + taken: "Принял(а)" + skip: "Пропущу" + side_effects: "Есть побочные эффекты" + +taken_ack: "Отлично, отметил приём. Спасибо!" +# Кнопка от напоминания, которое уже неактуально (например, вчерашнего). +stale: "Это напоминание уже неактуально. Если хотите отметить самочувствие — нажмите «Как я сегодня»." + +skip: + question: "Понял, отметил. Подскажите, что помешало? Это поможет вашему врачу." + reasons: + forgot: "Забыл(а)" + side_effects: "Из-за побочных эффектов" + quit: "Решил(а) бросить" + other: "Другое" + ack: + forgot: "Понял, бывает. Завтра напомню в обычное время." + quit: "Спасибо, что честно сказали — это важно, и решать это лучше вместе с врачом." + other: "Понял, отметил. Если захотите рассказать подробнее — просто напишите." + +side_effects: + question: "Что беспокоит? Выберите вариант или «Другое», чтобы описать своими словами." + options: + nausea: "Тошнота" + insomnia: "Бессонница" + headache: "Головная боль" + dizziness: "Головокружение" + agitation: "Беспокойство" + other: "Другое" + # Короткий вопрос к инструкции препарата (med_qa, раздел «побочные эффекты») для выбранного эффекта. + questions: + nausea: "тошнота после приёма, когда пройдёт" + insomnia: "бессонница, нарушения сна" + headache: "головная боль" + dizziness: "головокружение" + agitation: "беспокойство, тревога" + describe: "Опишите, пожалуйста, коротко, что вы заметили." + ack: "Спасибо, отметил. Ваш врач увидит это в вашей карточке." + # Тот же эффект был и вчера: инструкцию второй раз не повторяем. + repeat: "Понимаю, это неприятно. Я отмечаю это каждый день, и ваш врач увидит, если не проходит." + ask_dose: "А препарат сегодня приняли?" + dose_yes: "Да, принял(а)" + dose_no: "Нет" + +# Ежедневный чек-ин: если сегодня ещё нет отметки о приёме. +checkin: + question: "Сегодня приняли {drug}?" + question_no_drug: "Сегодня приняли назначенный препарат?" + "yes": "Да" + "no": "Нет" + later: "Ещё нет" + +question: + prompt: "Напишите ваш вопрос о препарате. Я отвечаю только по официальной инструкции, а если ответа в ней нет — подскажу обратиться к врачу." + no_prescription: "Я не нашёл в карточке назначенного препарата. Пожалуйста, уточните вопрос у вашего врача." + +# Ответы на действие правила med_info. Ответ = lead + выдержка из инструкции (med_qa по разделу section, +# вопрос question) + notice. Если выдержки нет — lead + fallback + notice. +med_info: + delayed_effect: + section: onset + question: "когда начнёт действовать препарат, нет эффекта, только хуже" + lead: "Понимаю, что сейчас тяжело." + fallback: "Эффект таких препаратов обычно наступает не сразу, а через 2–4 недели регулярного приёма. Пожалуйста, не прекращайте приём самостоятельно." + notice: "Я сообщил вашему врачу — он свяжется с вами, чтобы обсудить, как вы себя чувствуете." + alcohol_interaction: + section: alcohol + question: "можно ли алкоголь вместе с препаратом, сон" + lead: "Спасибо, что честно рассказали." + fallback: "В инструкции к препарату есть предупреждение о совместном приёме с алкоголем, а алкоголь сам по себе ухудшает сон." + notice: "Я передал это вашему врачу." + nausea: + section: side_effects + question: "тошнота после приёма, когда пройдёт" + lead: "" + fallback: "Тошнота часто встречается в первые дни приёма и обычно проходит сама." + notice: "Если станет хуже — напишите мне, я передам врачу." + side_effects: + section: side_effects + question: "побочные эффекты в начале приёма" + lead: "" + fallback: "Спасибо, что сообщили о самочувствии. Я отмечу это для вашего врача." + notice: "Если станет хуже — напишите мне, я передам врачу." diff --git a/config/texts/veteran_daily.yaml b/config/texts/veteran_daily.yaml new file mode 100644 index 0000000..c12f832 --- /dev/null +++ b/config/texts/veteran_daily.yaml @@ -0,0 +1,62 @@ +# Тексты ежедневного чек-ина и плановых шкал (issue #37, ADR-002, CLINICAL_MODULES §1–2). +# Ядро (настроение + приём) задаётся всем; вопрос про сон и кошмары — модулю veteran; алкоголь — см. settings. +# СТАТУС: черновик, утверждает психиатр (#8). +# +# Правила текстов: без диагнозов и названий расстройств; без советов по дозам; пациент не видит баллов шкал; +# не использовать слова, которые звучат как постановка на учёт или слежка (проверяется тестом). + +status: "черновик, утверждает психиатр (#8)" + +settings: + # Модули, которым задаётся вопрос про сон и кошмары (modules.yaml: daily_question = sleep_nightmares). + sleep_modules: [veteran] + # Ежедневный вопрос об алкоголе: модули, где он задаётся всегда (у ветеранов алкоголь — частый способ + # «заглушить» бессонницу и фактор риска, CLINICAL_MODULES §1); остальным — при положительном AUDIT-C + # (порог берётся из правила auditc_positive в config/rules.yaml, ADR-002). + alcohol_daily_modules: [veteran] + # Плановые шкалы, перед которыми показывается дружелюбное вступление (см. scale_intros). + intro_scales: [pcl5, phq9, dar5, auditc] + +checkin: + start: "Добрый день! Короткая отметка о самочувствии — это займёт меньше минуты." + mood: + question: "Как ваше настроение сегодня по шкале от 0 до 10? 0 — очень плохо, 10 — отлично." + sleep: + question: "Сколько часов удалось поспать прошлой ночью?" + options: + "<4": "Меньше 4 ч" + "4-6": "4–6 ч" + "6-8": "6–8 ч" + ">8": "Больше 8 ч" + nightmares: + question: "Снились ли кошмары или тяжёлые сны?" + "yes": "Да" + "no": "Нет" + alcohol: + question: "Выпивали ли вы алкоголь за последние сутки?" + clarify: "Вы упомянули алкоголь. Сколько примерно выпили за последние сутки?" + options: + none: "Не пил(а)" + "1-2": "1–2 порции" + more: "Больше" + text: + question: "Как вы? Можно пару слов — что было за день, что беспокоит." + skip: "Пропустить" + buttons_hint: "Выберите, пожалуйста, вариант кнопкой." + done: "Спасибо, записал. Если что-то изменится — пишите в любое время." + already_done: "Сегодня вы уже отмечались, спасибо! Если хотите что-то добавить — просто напишите." + +visit: + # Нажата кнопка «Записаться к врачу раньше» (действие правила suggest_visit). Онлайн-записи пока нет — + # просьба передаётся врачу как факт в карточке. + requested: "Передал врачу просьбу о более раннем визите. С вами свяжутся, чтобы договориться о времени. Если станет хуже до визита — пишите мне или звоните 112." + +# Дружелюбное вступление перед шкалой (параметр intro у start_scale). Пациент не видит баллов. +scale_intros: + scheduled: + pcl5: "Раз в две недели я прошу ответить на вопросы о том, как беспокоят тяжёлые воспоминания и их последствия. Это займёт 3–4 минуты. Так ваш врач увидит, что меняется со временем." + phq9: "Пора для короткого опроса о настроении и силах за последние две недели — 9 вопросов. Отвечайте так, как есть, правильных ответов нет." + dar5: "Несколько вопросов о раздражительности и вспышках гнева. После службы это бывает у многих, и врачу важно видеть, как это меняется." + auditc: "Три коротких вопроса об алкоголе. Отвечайте как есть: это нужно врачу, чтобы лечение было безопасным." + unscheduled: + phq9: "Последние дни настроение ниже обычного. Давайте я задам несколько вопросов — так ваш врач лучше поймёт, как вы сейчас." diff --git a/tests/test_dialog_meds.py b/tests/test_dialog_meds.py new file mode 100644 index 0000000..cc48de2 --- /dev/null +++ b/tests/test_dialog_meds.py @@ -0,0 +1,372 @@ +"""Medication flow and check-in units (#37): reminder buttons, skip reasons, side effects, medication question, +med_info rule reactions, check-in edge cases, text policy of the YAML files.""" + +from collections.abc import AsyncIterator +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +import pytest +import yaml +from sqlalchemy import select + +from app.config import Settings, get_settings +from app.core import clock +from app.core.rules import Action +from app.db import repo, session +from app.db.bootstrap import init_db +from app.db.clinical import Dose, Prescription +from app.dialog.contracts import Event, Reply +from app.dialog.engine import Engine +from app.dialog.flows.meds import load_meds_texts +from app.dialog.flows.veteran_daily import load_daily_texts +from app.dialog.fsm import Ctx, enter_hook_for +from app.dialog.scale_session import start_scale +from app.dialog.texts import load_dialog_texts +from app.integrations.base import DrugSection, integrations +from app.llm import med_qa as med_qa_module +from app.llm.gateway import LLMGateway +from app.llm.med_qa import fallback_text +from app.llm.mock import MockTasks + +TOKEN = "m" * 32 +MEM = "sqlite+aiosqlite:///:memory:" +NOW = datetime(2026, 10, 7, 8, 0) +MEDS = load_meds_texts() +DAILY = load_daily_texts() +SECTIONS = { + "side_effects": "Тошнота обычно возникает в первые дни приёма и проходит в течение 1–2 недель.", + "onset": "Улучшение обычно отмечается через 2–4 недели регулярного приёма.", + "alcohol": "Совместный приём с алкоголем не рекомендуется.", + "administration": "Препарат принимают один раз в сутки, утром или вечером, независимо от еды.", +} + + +class FakeDrugInfo: + async def sections(self, inn: str) -> list[DrugSection]: + return [DrugSection(inn=inn, section=k, text=v, source="синтетика") for k, v in SECTIONS.items()] + + +class ScriptedMonitoring: + def __init__(self) -> None: + self.calls = 0 + self.actions: list[Action] = [] + + async def after_data_change(self, token: str) -> list[Action]: + self.calls += 1 + out, self.actions = self.actions, [] + return out + + async def is_in_crisis(self, token: str) -> bool: + return False + + +@pytest.fixture +async def mon(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[ScriptedMonitoring]: + monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True)) + monkeypatch.setattr(integrations, "drug_info", FakeDrugInfo()) + monkeypatch.setattr(med_qa_module, "get_gateway", lambda: LLMGateway(MockTasks())) + clock.set_now(NOW) + await session.configure(identity_url=MEM, clinical_url=MEM) + await init_db() + async with session.get_clinical_session() as s: + await repo.upsert_profile( + s, token=TOKEN, doctor_id="doc", modules=["veteran"], sex="m", birth_year=1990, therapy_start=NOW.date() + ) + s.add(Prescription(token=TOKEN, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]})) + await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True}, now=clock.now()) + yield ScriptedMonitoring() + clock.reset() + await session.configure() + + +@pytest.fixture +def engine(mon: ScriptedMonitoring) -> Engine: + return Engine(gateway=LLMGateway(MockTasks()), monitoring=mon) + + +async def press(e: Engine, data: str) -> list[Reply]: + return await e.handle(TOKEN, Event(type="button", button=data)) + + +async def text(e: Engine, t: str) -> list[Reply]: + return await e.handle(TOKEN, Event(type="text", text=t)) + + +async def system(e: Engine, kind: str, **params: Any) -> list[Reply]: + return await e.handle(TOKEN, Event(type="system", system=kind, params=params)) # type: ignore[arg-type] + + +def buttons(replies: list[Reply]) -> list[str]: + return [b.data for r in replies for row in r.buttons for b in row] + + +async def get_state() -> tuple[str, dict[str, Any]]: + async with session.get_clinical_session() as s: + row = await repo.get_dialog_state(s, TOKEN) + assert row is not None + return row.state, dict(row.data) + + +async def checkin() -> Any: + async with session.get_clinical_session() as s: + rows = await repo.list_checkins(s, TOKEN) + return rows[-1] if rows else None + + +async def doses() -> list[Dose]: + async with session.get_clinical_session() as s: + return list((await s.scalars(select(Dose).where(Dose.token == TOKEN).order_by(Dose.id))).all()) + + +# ----------------------------------------------------------------------------------------------- reminder + + +async def test_reminder_without_dose_id_creates_dose_and_taken(engine: Engine, mon: ScriptedMonitoring) -> None: + r = await system(engine, "med_reminder") + assert r[0].text == MEDS.reminder.text.format(drug="сертралин", dose="50 мг") + assert buttons(r) == ["med:taken", "med:skip", "med:side_effects"] + [dose] = await doses() + assert dose.status == "pending" + r = await press(engine, "med:taken") + assert r[0].text == MEDS.taken_ack and mon.calls == 1 + [dose] = await doses() + assert dose.status == "taken" and dose.answered_at is not None + assert (await checkin()).med_taken is True + + +async def test_stale_reminder_button(engine: Engine) -> None: + await system(engine, "med_reminder") + clock.advance(days=1) + r = await press(engine, "med:taken") + assert r[0].text == MEDS.stale and await checkin() is None + + +@pytest.mark.parametrize("reason", ["forgot", "other"]) +async def test_skip_simple_reasons(engine: Engine, reason: str) -> None: + await system(engine, "med_reminder") + r = await press(engine, "med:skip") + assert buttons(r) == [f"med:skip_reason:{x}" for x in ("forgot", "side_effects", "quit", "other")] + r = await press(engine, f"med:skip_reason:{reason}") + assert r[0].text == MEDS.skip.ack[reason] + c = await checkin() + assert c.med_taken is False and not c.quit_intent + assert [d.status for d in await doses()] == ["skipped"] + + +async def test_skip_quit_sets_intent_and_explains(engine: Engine, mon: ScriptedMonitoring) -> None: + await system(engine, "med_reminder") + await press(engine, "med:skip") + mon.actions = [Action(type="med_info", code="quit_intent", params={"topic": "delayed_effect"})] + r = await press(engine, "med:skip_reason:quit") + assert r[0].text == MEDS.skip.ack["quit"] + assert "2–4 недели" in r[1].text and MEDS.med_info["delayed_effect"].notice in r[1].text + assert (await checkin()).quit_intent is True + + +async def test_skip_because_of_side_effects_asks_which(engine: Engine) -> None: + await system(engine, "med_reminder") + await press(engine, "med:skip") + r = await press(engine, "med:skip_reason:side_effects") + assert "med:se:nausea" in buttons(r) and "med:se:other" in buttons(r) + + +async def test_side_effect_button(engine: Engine, mon: ScriptedMonitoring) -> None: + await system(engine, "med_reminder") + r = await press(engine, "med:side_effects") + assert buttons(r) == [f"med:se:{c}" for c in ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")] + r = await press(engine, "med:se:nausea") + assert "проходит в течение 1–2 недель" in r[0].text # instruction excerpt + assert r[1].text == MEDS.side_effects.ask_dose and buttons(r[1:]) == ["med:dose:yes", "med:dose:no"] + assert (await checkin()).side_effects == ["nausea"] + assert (await doses())[0].side_effects == "nausea" + r = await press(engine, "med:dose:yes") + assert (await checkin()).med_taken is True and (await doses())[0].status == "taken" + + +async def test_side_effect_other_free_text(engine: Engine) -> None: + await system(engine, "med_reminder") + await press(engine, "med:side_effects") + r = await press(engine, "med:se:other") + assert r[0].text == MEDS.side_effects.describe and (await get_state())[0] == "meds.side_effect_text" + r = await text(engine, "голова болит весь день") + assert (await checkin()).side_effects == ["headache"] and (await get_state())[0] == "idle" + await press(engine, "med:side_effects") + await press(engine, "med:se:other") + await text(engine, "какое-то странное ощущение") + assert (await checkin()).side_effects == ["headache", "other"] + + +async def test_same_side_effect_as_yesterday_gets_support(engine: Engine) -> None: + async with session.get_clinical_session() as s: + await repo.upsert_checkin( + s, token=TOKEN, day=clock.today() - timedelta(days=1), now=clock.now(), side_effects=["nausea"] + ) + await system(engine, "med_reminder") + r = await press(engine, "med:se:nausea") + assert r[0].text == MEDS.side_effects.repeat + + +# ----------------------------------------------------------------------------------------------- question menu + + +async def test_med_question_menu(engine: Engine) -> None: + r = await press(engine, "menu:med_question") + assert r[0].text == MEDS.question.prompt and (await get_state())[0] == "meds.question" + r = await text(engine, "можно ли пить алкоголь?") + assert "Совместный приём с алкоголем не рекомендуется" in r[0].text and (await get_state())[0] == "idle" + await press(engine, "menu:med_question") + r = await text(engine, "можно увеличить дозу до 100 мг?") + assert r[0].text == fallback_text() + + +async def test_med_question_menu_left_by_button(engine: Engine) -> None: + await press(engine, "menu:med_question") + r = await press(engine, "menu:checkin") + assert r[0].text == DAILY.checkin.start and (await get_state())[0] == "daily.mood" + + +async def test_med_question_without_prescription(engine: Engine) -> None: + async with session.get_clinical_session() as s: + for row in await s.scalars(select(Prescription)): + await s.delete(row) + r = await press(engine, "menu:med_question") + assert r[0].text == MEDS.question.no_prescription and (await get_state())[0] == "idle" + + +async def test_med_info_without_excerpt_uses_fixed_text( + engine: Engine, mon: ScriptedMonitoring, monkeypatch: pytest.MonkeyPatch +) -> None: + class Empty: + async def sections(self, inn: str) -> list[DrugSection]: + return [] + + monkeypatch.setattr(integrations, "drug_info", Empty()) + mon.actions = [Action(type="med_info", code="alcohol_3d", params={"topic": "alcohol_interaction"})] + r = await press(engine, "menu:visit") # any data-writing step runs monitoring + spec = MEDS.med_info["alcohol_interaction"] + assert r[0].text == DAILY.visit.requested + assert r[1].text == "\n\n".join([spec.lead, spec.fallback, spec.notice]) + assert (await get_state())[1]["visit_requests"] + + +async def test_idle_free_text_med_question_and_side_effect(engine: Engine, mon: ScriptedMonitoring) -> None: + r = await text(engine, "когда подействуют таблетки?") + assert "2–4 недели" in r[0].text and "menu:checkin" in buttons(r) + r = await text(engine, "подташнивает после таблеток") + assert "проходит в течение 1–2 недель" in r[0].text and (await checkin()).side_effects == ["nausea"] + assert mon.calls == 1 + + +# ----------------------------------------------------------------------------------------------- check-in edges + + +async def test_checkin_asks_dose_when_not_answered_and_typed_mood(engine: Engine) -> None: + await system(engine, "daily_checkin") + r = await text(engine, "отлично") + assert DAILY.checkin.buttons_hint in r[0].text and (await get_state())[0] == "daily.mood" + r = await text(engine, "7") + assert r[0].text == MEDS.checkin.question.format(drug="сертралин") + await press(engine, "daily:med:no") + c = await checkin() + assert (c.mood, c.med_taken) == (7, False) + assert (await get_state())[0] == "daily.sleep" + + +async def test_reminder_during_checkin_keeps_checkin(engine: Engine) -> None: + await system(engine, "daily_checkin") + r = await system(engine, "med_reminder") + assert buttons(r[:1]) == ["med:taken", "med:skip", "med:side_effects"] and r[-1].text == DAILY.checkin.mood.question + r = await press(engine, "med:taken") + assert r[0].text == MEDS.taken_ack and (await get_state())[0] == "daily.mood" + await press(engine, "daily:mood:5") + assert (await get_state())[0] == "daily.sleep" # dose already answered → no med question + + +async def test_unfinished_checkin_dropped_next_day(engine: Engine) -> None: + await system(engine, "daily_checkin") + await press(engine, "daily:mood:2") + clock.advance(days=1) + r = await system(engine, "daily_checkin") + assert r[0].text == DAILY.checkin.start and (await get_state())[0] == "daily.mood" + + +async def test_checkin_once_a_day(engine: Engine) -> None: + await system(engine, "daily_checkin") + for b in ("daily:mood:6", "daily:med:yes", "daily:sleep:6-8", "daily:nightmares:no", "daily:alcohol:none"): + await press(engine, b) + await press(engine, "daily:text:skip") + assert await system(engine, "daily_checkin") == [] + r = await press(engine, "menu:checkin") + assert r[0].text == DAILY.checkin.already_done + + +async def test_alcohol_clarify_for_non_veteran(engine: Engine) -> None: + async with session.get_clinical_session() as s: + await repo.upsert_profile(s, token=TOKEN, modules=["depression"]) + await system(engine, "daily_checkin") + await press(engine, "daily:mood:5") + r = await press(engine, "daily:med:yes") + assert buttons(r) == ["daily:text:skip"] # no sleep / alcohol questions outside the veteran module + r = await text(engine, "вечером выпил пива с друзьями") + assert r[-1].text == DAILY.checkin.alcohol.clarify and (await get_state())[0] == "daily.alcohol_amount" + r = await press(engine, "daily:alcohol_amount:more") + assert r[0].text == DAILY.checkin.done and (await checkin()).alcohol_bucket == "more" + + +async def test_buttons_win_over_llm(engine: Engine) -> None: + await system(engine, "daily_checkin") + for b in ("daily:mood:5", "daily:med:yes", "daily:sleep:6-8", "daily:nightmares:no", "daily:alcohol:none"): + await press(engine, b) + await text(engine, "почти не спал, кошмары, брошу таблетки") + c = await checkin() + assert (c.sleep_bucket, c.nightmares, c.alcohol_bucket, c.quit_intent) == ("6-8", False, "none", True) + assert c.free_text_scrubbed + + +async def test_scale_intro_not_added_to_onboarding(engine: Engine, mon: ScriptedMonitoring) -> None: + await system(engine, "scale_due", scale="dar5", scheduled=True) + state, data = await get_state() + assert state == "scale.dar5" and data["scales"]["dar5"]["intro"] == DAILY.scale_intros.scheduled["dar5"] + + async with session.get_clinical_session() as s: + ctx = Ctx( + token=TOKEN, + state="idle", + data={}, + now=clock.now(), + session=s, + gateway=LLMGateway(MockTasks()), + monitoring=mon, + texts=load_dialog_texts(), + ) + step = await start_scale(ctx, "pcl5", return_state="veteran.onboarding.phq9") + ctx.data.update(step.data) + ctx.state = "scale.pcl5" + hook = enter_hook_for("scale.pcl5") + assert hook is not None + entered = await hook(ctx) + assert DAILY.scale_intros.scheduled["pcl5"] not in entered.replies[0].text + + +# ----------------------------------------------------------------------------------------------- text policy + + +def _strings(node: Any) -> list[str]: + if isinstance(node, str): + return [node] + if isinstance(node, dict): + return [s for v in node.values() for s in _strings(v)] + if isinstance(node, list): + return [s for v in node for s in _strings(v)] + return [] + + +@pytest.mark.parametrize("name", ["meds.yaml", "veteran_daily.yaml"]) +def test_texts_policy(name: str) -> None: + path: Path = get_settings().config_dir / "texts" / name + raw = path.read_text(encoding="utf-8") + assert "черновик, утверждает психиатр (#8)" in raw.splitlines()[2] + values = " ".join(_strings(yaml.safe_load(raw))).lower() + for word in ("учёт", "учет", "контрол", "надзор", "диагноз", "птср", "депресси", "увеличьте доз", "мг"): + assert word not in values, (name, word) diff --git a/tests/test_dialog_veteran_days.py b/tests/test_dialog_veteran_days.py new file mode 100644 index 0000000..bd73b02 --- /dev/null +++ b/tests/test_dialog_veteran_days.py @@ -0,0 +1,388 @@ +"""Acceptance: 13 days of the veteran Andrey (docs/product/01_demo_persona.md) through the real dialog engine. + +Andrey is set up post-onboarding directly in the DBs (no dependency on #36): modules veteran + depression, +therapy day 0, sertraline 50 mg at 08:00, consent. Each day: the dose reminder (as the scheduler would send it) +→ button; the daily check-in → buttons + the free text from the table. + +Lane B's MonitoringService (#38) is replaced by a double that builds a `PatientSnapshot` from the clinical DB, +runs the REAL `app.core.rules.evaluate`, persists alert/crisis actions (so dedup works) and returns the dialog +actions — the same split as the frozen contract (ADR-007). Drug instruction excerpts are synthetic (FakeDrugInfo). +""" + +from collections.abc import AsyncIterator +from datetime import date, datetime, timedelta +from typing import Any + +import pytest +from sqlalchemy import func, select + +from app.config import Settings +from app.core import clock +from app.core.rules import Action, DailyCheckin, DoseRecord, PatientSnapshot, ScalePoint, evaluate, patient_status +from app.core.scales import load_scale +from app.core.types import AlertLevel +from app.db import identity_repo, repo, session +from app.db.bootstrap import init_db +from app.db.clinical import Dose, Message, Prescription +from app.dialog.contracts import Event, Reply +from app.dialog.engine import Engine +from app.dialog.flows.meds import load_meds_texts +from app.dialog.flows.veteran_daily import load_daily_texts +from app.integrations.base import DrugSection, integrations +from app.llm import med_qa as med_qa_module +from app.llm.gateway import LLMGateway +from app.llm.mock import MockTasks +from app.services import contracts as monitoring_contracts + +TOKEN = "e" * 32 +MEM = "sqlite+aiosqlite:///:memory:" +DAY0 = datetime(2026, 10, 5, 8, 0) # visit day; reminders at 08:00, check-ins at 20:00 (Europe/Moscow) +SRC = "ГРЛС: тестовая выдержка (синтетика)" +SERTRALINE = { + "side_effects": ( + "Наиболее частые нежелательные реакции в начале терапии: тошнота, диарея, сухость во рту. " + "Тошнота обычно возникает в первые дни приёма и проходит в течение 1–2 недель. " + "Для уменьшения тошноты препарат принимают после еды." + ), + "onset": ( + "Терапевтический эффект развивается постепенно. " + "Улучшение обычно отмечается через 2–4 недели регулярного приёма. " + "Суточная доза может увеличиваться врачом до 200 мг." + ), + "alcohol": ( + "Совместный приём с алкоголем не рекомендуется. " + "Алкоголь может ухудшать сон и настроение и усиливать побочные эффекты." + ), +} + +# Persona table: day → (dose taken, mood, sleep, nightmares, alcohol, free text). "" = skip the free text. +TABLE: dict[int, tuple[bool, int, str, bool, str, str]] = { + 1: (True, 4, "4-6", True, "none", ""), + 2: (True, 4, "4-6", True, "none", "подташнивает с утра"), + 3: (True, 4, "4-6", True, "none", "опять тошнит"), + 4: (True, 3, "4-6", False, "none", "тошнота не проходит"), + 5: (False, 3, "4-6", False, "none", "от таблеток только хуже, брошу"), + 6: (True, 4, "6-8", False, "none", "врач позвонил, продолжаю"), + 7: (True, 4, "6-8", False, "none", ""), + 8: (True, 4, "<4", True, "1-2", "снится одно и то же"), + 9: (True, 4, "<4", True, "more", ""), + 10: (True, 4, "<4", True, "more", "без пива не могу уснуть"), + 11: (True, 3, "4-6", False, "1-2", ""), + 12: (True, 3, "4-6", False, "none", ""), + 13: (True, 2, "<4", True, "none", "всё бессмысленно, устал от всего"), +} +# Expected dashboard colour at the end of each day (persona: 🟢🟢🟢🟡🟡🟢🟢🟢🟢🟡🟡🟡🔴). +COLORS = ["green"] * 3 + ["yellow"] * 2 + ["green"] * 4 + ["yellow"] * 3 + ["red"] + + +class FakeDrugInfo: + async def sections(self, inn: str) -> list[DrugSection]: + if inn != "сертралин": + return [] + return [DrugSection(inn=inn, section=k, text=v, source=SRC) for k, v in SERTRALINE.items()] + + +class RuleMonitoring: + """Test double of lane B's MonitoringService: real rules over a snapshot of the clinical DB.""" + + def __init__(self) -> None: + self.fired: list[str] = [] + + async def _snapshot(self, s: Any, token: str) -> PatientSnapshot: + now = clock.now() + profile = await repo.get_profile(s, token) + assert profile is not None and profile.therapy_start is not None and profile.birth_year is not None + checkins = [ + DailyCheckin( + day=c.day, + mood=c.mood, + med_taken=c.med_taken, + sleep_hours_bucket=c.sleep_bucket, # type: ignore[arg-type] + alcohol=c.alcohol_bucket, # type: ignore[arg-type] + side_effects=list(c.side_effects or []), + quit_intent=bool(c.quit_intent), + nightmares=c.nightmares, + ) + for c in await repo.list_checkins(s, token) + ] + results = [r for r in await repo.list_scale_results(s, token) if r.complete] + doses = (await s.scalars(select(Dose).where(Dose.token == token, Dose.status != "pending"))).all() + cssrs = [r for r in results if r.scale == "cssrs_screen"] + last_in = await s.scalar(select(func.max(Message.ts)).where(Message.token == token, Message.direction == "in")) + return PatientSnapshot( + token=token, + as_of=now, + age=now.year - profile.birth_year, + sex=profile.sex, # type: ignore[arg-type] + modules=set(profile.modules), # type: ignore[arg-type] + therapy_day=(now.date() - profile.therapy_start).days, + checkins=checkins, + scale_results=[ + ScalePoint(scale=r.scale, ts=r.ts, total=r.total, item9=r.item9, scheduled=r.scheduled) + for r in results + if r.scale != "cssrs_screen" + ], + doses=[DoseRecord(planned_at=d.planned_at, status=d.status) for d in doses], # type: ignore[arg-type] + cssrs_risk=cssrs[-1].severity if cssrs else None, # type: ignore[arg-type] + weapon_access=False, + last_patient_message_at=last_in, + open_alert_codes=await repo.open_alert_codes(s, token), + ) + + async def after_data_change(self, token: str) -> list[Action]: + async with session.get_clinical_session() as s: + actions = evaluate(await self._snapshot(s, token)) + for a in actions: + if a.creates_alert: + assert a.level is not None + await repo.add_alert( + s, token=token, level=a.level, code=a.code, reason=a.reason_ru, ts=clock.now(), params=a.params + ) + self.fired += [a.code for a in actions] + return [a for a in actions if a.type != "alert"] + + async def is_in_crisis(self, token: str) -> bool: + async with session.get_clinical_session() as s: + alerts = await repo.list_alerts(s, token=token, open_only=True) + return any(a.level == "acute" for a in alerts) + + +# ----------------------------------------------------------------------------------------------- fixtures + + +@pytest.fixture +async def andrey(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[RuleMonitoring]: + monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True)) + monkeypatch.setattr(integrations, "drug_info", FakeDrugInfo()) + monkeypatch.setattr(med_qa_module, "get_gateway", lambda: LLMGateway(MockTasks())) + clock.set_now(DAY0) + await session.configure(identity_url=MEM, clinical_url=MEM) + await init_db() + now = clock.now() + async with session.get_identity_session() as s: + await identity_repo.create_patient( + s, + token=TOKEN, + display_name="Андрей С. (синтетика)", + snils="000-000-000 00", + clinic_id="c1", + doctor_id="doc-smirnova", + now=now, + birth_year=1992, + sex="m", + ) + await identity_repo.record_consent(s, token=TOKEN, version="v1", now=now) + async with session.get_clinical_session() as s: + await repo.upsert_profile( + s, + token=TOKEN, + doctor_id="doc-smirnova", + modules=["veteran", "depression"], + therapy_start=now.date(), + next_visit=now.date() + timedelta(days=28), + birth_year=1992, + sex="m", + ) + s.add( + Prescription( + token=TOKEN, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}, start_date=now.date() + ) + ) + screening = now - timedelta(days=3) # day −3 screening (persona) + for scale, total in (("pcptsd5", 4), ("pcl5", 46), ("phq9", 14), ("auditc", 3)): + await repo.add_scale_result( + s, + token=TOKEN, + scale=scale, + total=total, + severity="-", + complete=True, + ts=screening, + item9=0 if scale == "phq9" else None, + ) + await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=now) + mon = RuleMonitoring() + monitoring_contracts.get_monitoring() # import lane B's module first if it exists, then override it + previous = monitoring_contracts._factory + monitoring_contracts.register_monitoring(lambda: mon) + yield mon + monitoring_contracts.register_monitoring(previous) + clock.reset() + await session.configure() + + +# ----------------------------------------------------------------------------------------------- helpers + + +class Andrey: + def __init__(self) -> None: + self.engine = Engine(gateway=LLMGateway(MockTasks())) # monitoring resolved via register_monitoring + + async def text(self, t: str) -> list[Reply]: + return await self.engine.handle(TOKEN, Event(type="text", text=t)) + + async def press(self, data: str) -> list[Reply]: + return await self.engine.handle(TOKEN, Event(type="button", button=data)) + + async def system(self, kind: str, **params: Any) -> list[Reply]: + return await self.engine.handle(TOKEN, Event(type="system", system=kind, params=params)) # type: ignore[arg-type] + + async def answer_scale(self, scale_id: str, values: list[int]) -> list[Reply]: + out: list[Reply] = [] + for item, value in zip(load_scale(scale_id).items, values, strict=True): + out += await self.press(f"scale:{scale_id}:{item.id}:{value}") + return out + + +def buttons(replies: list[Reply]) -> list[str]: + return [b.data for r in replies for row in r.buttons for b in row] + + +def joined(replies: list[Reply]) -> str: + return "\n".join(r.text for r in replies) + + +async def state() -> tuple[str, dict[str, Any]]: + async with session.get_clinical_session() as s: + row = await repo.get_dialog_state(s, TOKEN) + assert row is not None + return row.state, dict(row.data) + + +async def open_alerts() -> list[Any]: + async with session.get_clinical_session() as s: + return list(await repo.list_alerts(s, token=TOKEN, open_only=True)) + + +async def ack_all() -> None: + async with session.get_clinical_session() as s: + for a in await repo.list_alerts(s, token=TOKEN, open_only=True): + await repo.ack_alert(s, alert_id=a.id, doctor_id="doc-smirnova", ts=clock.now()) + + +async def new_dose(day: date) -> int: + """What the scheduler (#13) does before sending the reminder: a planned dose row.""" + async with session.get_clinical_session() as s: + rx = await s.scalar(select(Prescription).where(Prescription.token == TOKEN)) + assert rx is not None + row = Dose(token=TOKEN, prescription_id=rx.id, planned_at=clock.now(), status="pending") + s.add(row) + await s.flush() + return row.id + + +# ----------------------------------------------------------------------------------------------- the 13 days + + +async def test_andrey_13_days(andrey: RuleMonitoring) -> None: + a = Andrey() + meds = load_meds_texts() + daily = load_daily_texts() + log: dict[int, list[Reply]] = {} + colors: list[str] = [] + + for day in range(1, 14): + taken, mood, sleep, nightmares, alcohol, free_text = TABLE[day] + clock.set_now(DAY0 + timedelta(days=day)) + if day == 6: + await ack_all() # the doctor called Andrey after the day-5 alerts and acknowledged them + r: list[Reply] = [] + + # --- 08:00 dose reminder + dose_id = await new_dose(clock.today()) + reminder = await a.system("med_reminder", dose_id=dose_id, drug="сертралин", dose="50 мг") + assert "сертралин 50 мг" in reminder[0].text + assert buttons(reminder) == ["med:taken", "med:skip", "med:side_effects"] + if taken: + r += await a.press("med:taken") + else: + skip = await a.press("med:skip") + assert "med:skip_reason:quit" in buttons(skip) + r += skip + await a.press("med:skip_reason:quit") + + # --- day 7: scheduled scales (PCL-5 = 44, PHQ-9 = 13, item 9 = 0) with friendly intros + if day == 7: + first = await a.system("scale_due", scale="pcl5", scheduled=True) + assert daily.scale_intros.scheduled["pcl5"] in first[0].text + await a.system("scale_due", scale="phq9", scheduled=True) # queued behind PCL-5 + after_pcl = await a.answer_scale("pcl5", [2] * 16 + [3] * 4) + assert daily.scale_intros.scheduled["phq9"] in after_pcl[-1].text + await a.answer_scale("phq9", [2] * 5 + [1] * 3 + [0]) + assert (await state())[0] == "idle" + + # --- 20:00 daily check-in + clock.set_now(DAY0 + timedelta(days=day, hours=12)) + start = await a.system("daily_checkin") + assert start[0].text == daily.checkin.start and buttons(start)[0] == "daily:mood:0" + r += await a.press(f"daily:mood:{mood}") # dose already answered by the reminder → no med question + assert "daily:sleep:<4" in buttons(r[-1:]) + r += await a.press(f"daily:sleep:{sleep}") + r += await a.press(f"daily:nightmares:{'yes' if nightmares else 'no'}") + r += await a.press(f"daily:alcohol:{alcohol}") + assert buttons(r[-1:]) == ["daily:text:skip"] + r += await a.text(free_text) if free_text else await a.press("daily:text:skip") + log[day] = r + if day == 10: # «записаться раньше» offered by rule alcohol_3d + visit = await a.press("menu:visit") + assert visit[0].text == daily.visit.requested and (await state())[1]["visit_requests"] + + if day == 13: + break # the crisis path is checked below + assert (await state())[0] == "idle" + colors.append(patient_status(AlertLevel[x.level.upper()] for x in await open_alerts()).color) + + # Day 2: first nausea → instruction excerpt (med_qa, section side_effects), not generated advice. + assert "По инструкции к препарату" in joined(log[2]) and "проходит в течение 1–2 недель" in joined(log[2]) + # Day 3: same effect again → short support, the instruction is not repeated. + assert meds.side_effects.repeat in joined(log[3]) and "По инструкции" not in joined(log[3]) + # Day 5: «решил бросить» → rule quit_intent → delayed onset + do not stop on your own + doctor notified. + day5 = joined(log[5]) + assert "2–4 недели" in day5 and "не прекращайте" in day5 and meds.med_info["delayed_effect"].notice in day5 + assert "200 мг" not in day5 # dosing sentences of the instruction are never quoted + # Day 10: sleep < 4 h ×3 + alcohol ×3 → interaction info + «записаться раньше». + day10 = joined(log[10]) + assert "Совместный приём с алкоголем не рекомендуется" in day10 + assert buttons(log[10])[-1] == "menu:visit" + # Day 11: alerts still open → the rule stays silent (no repeated interaction text). + assert "Совместный приём с алкоголем" not in joined(log[11]) and "menu:visit" not in buttons(log[11]) + assert colors == COLORS[:12] + + # Day 13: «всё бессмысленно» → POSSIBLE → C-SSRS screen (buttons only) took over the free text. + day13 = log[13] + assert day13[-1].kind == "question" and "scale:cssrs_screen:cssrs_1:0" in buttons(day13[-1:]) + r = await a.answer_scale("cssrs_screen", [0] * 6) + # Back in the check-in: the message counts as the answer → check-in closed → mood ≤ 3 three days → + # unscheduled PHQ-9 with its own intro. + assert daily.checkin.done in joined(r) and daily.scale_intros.unscheduled["phq9"] in r[-1].text + st, data = await state() + assert st == "scale.phq9" and data["scales"]["phq9"]["scheduled"] is False + r = await a.answer_scale("phq9", [2] * 8 + [2]) # total 18, item 9 = 2 + assert any(x.kind == "crisis" for x in r) and any(x.kind == "safety_plan" for x in r) + st, data = await state() + assert data["crisis"]["open"] is True and st in ("crisis", "scale.cssrs_screen") + acute = [x for x in await open_alerts() if x.level == "acute"] + assert [x.code for x in acute] == ["phq9_item9"] + assert patient_status(AlertLevel[x.level.upper()] for x in await open_alerts()).color == COLORS[12] + + # Stored check-ins match the persona table. + async with session.get_clinical_session() as s: + rows = {c.day: c for c in await repo.list_checkins(s, TOKEN)} + doses = (await s.scalars(select(Dose).where(Dose.token == TOKEN).order_by(Dose.id))).all() + phq = list(await repo.list_scale_results(s, TOKEN, "phq9")) + for day, (taken, mood, sleep, nightmares, alcohol, _text) in TABLE.items(): + c = rows[(DAY0 + timedelta(days=day)).date()] + assert (c.med_taken, c.mood, c.sleep_bucket, c.nightmares, c.alcohol_bucket) == ( + taken, + mood, + sleep, + nightmares, + alcohol, + ), day + for day in (2, 3, 4): + assert "nausea" in rows[(DAY0 + timedelta(days=day)).date()].side_effects + assert rows[(DAY0 + timedelta(days=5)).date()].quit_intent is True + assert rows[(DAY0 + timedelta(days=13)).date()].free_text_scrubbed is None # taken over by the C-SSRS + assert [d.status for d in doses] == ["skipped" if d == 5 else "taken" for d in range(1, 14)] + assert [(p.total, p.item9, p.scheduled) for p in phq] == [(14, 0, True), (13, 0, True), (18, 2, False)] + for code in ("side_effect_3d", "quit_intent", "sleep_lt4_3n", "alcohol_3d", "mood_low_3d", "phq9_item9"): + assert code in andrey.fired, code