test(bot): Andrey 13-day acceptance scenario and medication flow units
CI / ruff · pytest (pull_request) Successful in 1m3s
CI / ruff · pytest (pull_request) Successful in 1m3s
Refs #37 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user