"""Demo adapters (#14): МИС mock, connect-code identity, ГРЛС excerpts, doctor notifier, registry setup.""" import json from collections.abc import AsyncIterator from datetime import UTC, date, datetime, timedelta from pathlib import Path import pytest from app.core import clock from app.core.types import ScrubbedText from app.db import identity_repo, repo, session from app.db.bootstrap import init_db from app.db.clinical import Prescription as PrescriptionRow from app.integrations import ( CompositeNotifier, ConnectCodeIdentity, GrlsExcerpts, MisMock, NotificationFeed, setup, ) from app.integrations import notify as notify_module from app.integrations.base import ( EmptyPrescriptions, MemoryNotifier, MemoryOutbox, NoDrugInfo, RejectIdentity, integrations, ) from app.integrations.grls import normalize_inn from app.integrations.notify import TelegramDoctorChat, format_message from app.llm import med_qa as med_qa_module from app.llm.gateway import LLMGateway, is_forbidden from app.llm.med_qa import DOSING_SENTENCE, SECTIONS, med_qa from app.llm.mock import MockTasks MEM = "sqlite+aiosqlite:///:memory:" TOKEN = "a" * 32 T0 = datetime(2026, 10, 1, 9, 0, tzinfo=UTC) GRLS_DIR = Path(__file__).resolve().parents[1] / "data" / "meds" / "grls" CORE_DRUGS = ("сертралин", "пароксетин", "эсциталопрам", "флуоксетин") @pytest.fixture async def dbs() -> AsyncIterator[None]: await session.configure(identity_url=MEM, clinical_url=MEM) await init_db() try: yield finally: await session.configure() # ----------------------------------------------------------------------------------------------- setup def test_setup_registers_demo_adapters() -> None: assert isinstance(integrations.prescriptions, MisMock) assert isinstance(integrations.identity, ConnectCodeIdentity) assert isinstance(integrations.drug_info, GrlsExcerpts) assert isinstance(integrations.notifier, CompositeNotifier) # The outbox stays the in-memory queue the web console drains (Telegram wraps it at start). assert isinstance(integrations.outbox, MemoryOutbox) def test_setup_is_idempotent() -> None: before = (integrations.prescriptions, integrations.identity, integrations.drug_info, integrations.notifier) setup() setup() assert (integrations.prescriptions, integrations.identity, integrations.drug_info, integrations.notifier) == before def test_setup_replaces_only_defaults(monkeypatch: pytest.MonkeyPatch) -> None: fake_notifier = MemoryNotifier() sink = type("Sink", (MemoryNotifier,), {})() monkeypatch.setattr(integrations, "prescriptions", EmptyPrescriptions()) monkeypatch.setattr(integrations, "identity", RejectIdentity()) monkeypatch.setattr(integrations, "drug_info", NoDrugInfo()) monkeypatch.setattr(integrations, "notifier", fake_notifier) setup(force=True) assert isinstance(integrations.prescriptions, MisMock) assert isinstance(integrations.notifier, CompositeNotifier) monkeypatch.setattr(integrations, "notifier", sink) monkeypatch.setattr("app.integrations._done", False) setup() assert integrations.notifier is sink # a swapped-in test sink is kept # --------------------------------------------------------------------------------------------- mis_mock async def test_mis_mock_catalogue_fallback() -> None: mis = MisMock(use_db=False) [rx] = await mis.get_prescriptions("tok-andrey") assert (rx.drug, rx.dose, rx.schedule["times"]) == ("сертралин", "50 мг", ["08:00"]) assert rx.instructions == "утром после еды" assert await mis.get_prescriptions("unknown") == [] async def test_mis_mock_catalogue_drugs_have_excerpts() -> None: raw = json.loads((GRLS_DIR.parent / "mis_prescriptions.json").read_text(encoding="utf-8")) known = set(GrlsExcerpts().known()) assert {rx["drug"] for p in raw["patients"] for rx in p["prescriptions"]} <= known async def test_mis_mock_missing_catalogue_is_empty(tmp_path: Path) -> None: assert await MisMock(tmp_path / "nope.json", use_db=False).get_prescriptions("tok-andrey") == [] async def test_mis_mock_prefers_db(dbs: None) -> None: async with session.get_clinical_session() as s: s.add( PrescriptionRow( token="tok-andrey", drug="эсциталопрам", dose="10 мг", schedule={"times": ["09:00"], "instructions": "утром"}, start_date=date(2026, 10, 1), ) ) [rx] = await MisMock().get_prescriptions("tok-andrey") assert (rx.drug, rx.dose, rx.instructions, rx.start_date) == ("эсциталопрам", "10 мг", "утром", date(2026, 10, 1)) # No DB rows for this token -> catalogue. [rx] = await MisMock().get_prescriptions("tok-sergey") assert rx.drug == "пароксетин" async def test_mis_mock_inside_caller_session_keeps_its_work(dbs: None) -> None: # The dialog calls the adapter while its own clinical session holds flushed changes. async with session.get_clinical_session() as s: await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-smirnova") assert await MisMock().get_prescriptions(TOKEN) == [] async with session.get_clinical_session() as s: assert await repo.get_profile(s, TOKEN) is not None async def test_mis_mock_db_down_falls_back(tmp_path: Path) -> None: await session.configure(clinical_url=f"sqlite+aiosqlite:///{tmp_path}/missing/dir/x.db") try: [rx] = await MisMock().get_prescriptions("tok-andrey") finally: await session.configure() assert rx.drug == "сертралин" # ------------------------------------------------------------------------------------------ connect code async def _code(code: str = "DEMO-0001") -> None: async with session.get_identity_session() as s: await identity_repo.create_connect_code(s, code=code, token=TOKEN, doctor_id="doc-smirnova", now=T0) async def test_connect_code_verifies_once(dbs: None, monkeypatch: pytest.MonkeyPatch) -> None: await _code() ident = ConnectCodeIdentity() monkeypatch.setattr(clock, "now", lambda: T0) person = await ident.verify(" DEMO-0001 ") assert person is not None and (person.token, person.doctor_id) == (TOKEN, "doc-smirnova") # Same connect flow (Telegram /start, then the engine's `new` state) re-checks the code: still valid. monkeypatch.setattr(clock, "now", lambda: T0 + timedelta(minutes=1)) assert await ident.verify("DEMO-0001") == person # Later the used code is rejected. monkeypatch.setattr(clock, "now", lambda: T0 + timedelta(hours=1)) assert await ident.verify("DEMO-0001") is None async def test_connect_code_unknown_or_empty(dbs: None) -> None: assert await ConnectCodeIdentity().verify("NOPE") is None assert await ConnectCodeIdentity().verify(" ") is None async def test_connect_code_db_down(tmp_path: Path) -> None: await session.configure(identity_url=f"sqlite+aiosqlite:///{tmp_path}/missing/dir/x.db") try: assert await ConnectCodeIdentity().verify("DEMO-0001") is None finally: await session.configure() # ------------------------------------------------------------------------------------------------- ГРЛС async def test_grls_core_drugs_have_all_sections() -> None: grls = GrlsExcerpts() for inn in CORE_DRUGS: sections = await grls.sections(inn) assert {s.section for s in sections} == set(SECTIONS), inn assert all(s.inn == inn and s.source.startswith("ГРЛС, инструкция по медицинскому") for s in sections) assert {"миртазапин", "празозин"} <= set(grls.known()) async def test_grls_texts_are_short_safe_and_dose_free() -> None: grls = GrlsExcerpts() for inn in grls.known(): for s in await grls.sections(inn): sentences = [x for x in s.text.replace("! ", ". ").split(". ") if x] assert 1 <= len(sentences) <= 4, (inn, s.section) assert not is_forbidden(s.text), (inn, s.section) if s.section != "administration": assert not DOSING_SENTENCE.search(s.text), (inn, s.section) assert "мг" not in s.text.split(), (inn, s.section) def test_grls_files_carry_verification_header() -> None: for path in GRLS_DIR.glob("*.yaml"): assert "сверить с действующей инструкцией ГРЛС" in path.read_text(encoding="utf-8").splitlines()[0], path async def test_grls_lookup_normalisation() -> None: grls = GrlsExcerpts() assert normalize_inn("Сертралин 50 мг") == "сертралин" assert await grls.sections("Sertraline") == await grls.sections("сертралин 50 мг") assert await grls.sections("аспирин") == [] async def test_grls_bad_files_are_skipped(tmp_path: Path) -> None: (tmp_path / "bad.yaml").write_text("inn: [", encoding="utf-8") (tmp_path / "wrong.yaml").write_text("inn: x\n", encoding="utf-8") assert GrlsExcerpts(tmp_path).known() == [] assert await GrlsExcerpts(tmp_path / "missing").sections("сертралин") == [] # --------------------------------------------------------------- med_qa end-to-end on real sertraline data @pytest.fixture def mock_gateway(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(med_qa_module, "get_gateway", lambda: LLMGateway(MockTasks())) async def test_med_qa_day2_nausea(mock_gateway: None) -> None: answer = await med_qa("сертралин", ScrubbedText("подташнивает с утра")) assert answer is not None and answer.section == "side_effects" assert "тошнот" in answer.text.lower() and "1–2 недель" in answer.text assert "Побочное действие" in answer.source async def test_med_qa_day5_quit(mock_gateway: None) -> None: answer = await med_qa("сертралин", ScrubbedText("от таблеток только хуже, брошу"), topic="quit_intent") assert answer is not None and answer.section == "onset" assert "2–4 недели" in answer.text async def test_med_qa_day10_alcohol(mock_gateway: None) -> None: answer = await med_qa("сертралин", ScrubbedText("без пива не могу уснуть")) assert answer is not None and answer.section == "alcohol" assert "алкогол" in answer.text.lower() and "сон" in answer.text async def test_med_qa_other_drugs_answer_core_topics(mock_gateway: None) -> None: for inn in CORE_DRUGS: for question in ("тошнит после таблетки", "когда начнёт действовать", "можно ли выпить пива", "хочу бросить"): answer = await med_qa(inn, ScrubbedText(question)) assert answer is not None, (inn, question) assert "мг" not in answer.text async def test_med_qa_dose_question_still_goes_to_doctor(mock_gateway: None) -> None: assert await med_qa("сертралин", ScrubbedText("можно увеличить дозу?")) is None # ------------------------------------------------------------------------------------------------ notify class FakeChat: def __init__(self, fail: bool = False) -> None: self.sent: list[tuple[int, str]] = [] self.fail = fail async def __call__(self, chat_id: int, text: str) -> None: if self.fail: raise RuntimeError("telegram down") self.sent.append((chat_id, text)) async def test_feed_ids_are_monotonic_and_filterable(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(clock, "now", lambda: T0) notifier = CompositeNotifier(chat=TelegramDoctorChat(chat_id=None, bot_token="")) await notifier.send("doc-smirnova", "moderate", "alcohol_3d", TOKEN) await notifier.send("doc-other", "high", "sleep_lt4_3n", "b" * 32) await notifier.send("doc-smirnova", "acute", "phq9_item9", TOKEN) items = notifier.recent() assert [n.id for n in items] == [1, 2, 3] and items[0].ts == T0 assert [n.text for n in notifier.recent(since_id=1, doctor_id="doc-smirnova")] == ["phq9_item9"] assert notifier.recent(since_id=3) == [] def test_feed_is_bounded() -> None: feed = NotificationFeed(size=2) for i in range(3): feed.add("d", "info", f"r{i}", None, T0) assert [n.id for n in feed.recent()] == [2, 3] async def test_telegram_doctor_chat_message_has_no_pii() -> None: chat = FakeChat() notifier = CompositeNotifier(chat=TelegramDoctorChat(chat_id=-100, bot_token="", sender=chat)) await notifier.send("doc-smirnova", "acute", "PHQ-9 п.9 = 2 — кризисный протокол", TOKEN) [(chat_id, text)] = chat.sent assert chat_id == -100 assert text == "[КРИТИЧНО] PHQ-9 п.9 = 2 — кризисный протокол\nПациент #aaaaaa" assert TOKEN not in text async def test_telegram_failure_is_swallowed() -> None: notifier = CompositeNotifier(chat=TelegramDoctorChat(chat_id=1, bot_token="", sender=FakeChat(fail=True))) await notifier.send("doc-smirnova", "high", "sleep_lt4_3n", TOKEN) # does not raise assert len(notifier.recent()) == 1 def test_telegram_chat_disabled_without_settings() -> None: assert not TelegramDoctorChat(chat_id=None, bot_token="x").enabled assert not TelegramDoctorChat(chat_id=5, bot_token="").enabled assert TelegramDoctorChat(chat_id=5, bot_token="123:abc").enabled def test_format_message_trims_long_reason() -> None: text = format_message("info", "x" * 500, None) assert len(text.splitlines()[0]) <= notify_module.MAX_REASON_CHARS + len("[Инфо] ") assert text.endswith("Пациент #—")