CI / ruff · pytest (pull_request) Successful in 1m26s
- setup() at package import (idempotent, replaces only base.py defaults): MisMock, ConnectCodeIdentity, GrlsExcerpts, CompositeNotifier; outbox stays MemoryOutbox - MisMock: clinical DB prescriptions, fallback data/meds/mis_prescriptions.json (synthetic, by token) - ConnectCodeIdentity: one-time connect code via identity_repo, 15 min grace for the double check in one connect flow (Telegram /start + engine `new` state) - GrlsExcerpts: synthetic paraphrased instruction excerpts for sertraline, paroxetine, escitalopram, fluoxetine, mirtazapine, prazosin - CompositeNotifier: in-memory feed (recent(since_id)) + Telegram doctor chat, PII-free, never raises - test_llm_med_qa: default-registry test now patches NoDrugInfo explicitly Refs #14 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
324 lines
14 KiB
Python
324 lines
14 KiB
Python
"""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("Пациент #—")
|