test(bot): Andrey 13-day acceptance scenario and medication flow units
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:
2026-10-03 18:45:53 +03:00
co-authored by Claude Opus 5.5
parent 2b13d54ed7
commit 2972b330b0
2 changed files with 760 additions and 0 deletions
+372
View File
@@ -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)
+388
View File
@@ -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