feat(integrations): demo adapters for МИС, connect-code identity, ГРЛС excerpts, doctor notifier
CI / ruff · pytest (pull_request) Successful in 1m26s
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>
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
"""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("Пациент #—")
|
||||
@@ -3,7 +3,7 @@
|
||||
import pytest
|
||||
|
||||
from app.core.types import ScrubbedText
|
||||
from app.integrations.base import DrugSection
|
||||
from app.integrations.base import DrugSection, NoDrugInfo, integrations
|
||||
from app.llm import med_qa as med_qa_module
|
||||
from app.llm.gateway import LLMGateway, is_forbidden
|
||||
from app.llm.med_qa import MedAnswer, adds_content, detect_topic, fallback_text, med_qa, select_excerpt
|
||||
@@ -173,8 +173,9 @@ async def test_unknown_drug_and_unknown_topic(info: FakeDrugInfo, mock_gateway:
|
||||
assert await med_qa("сертралин", S("какая погода"), drug_info=info) is None
|
||||
|
||||
|
||||
async def test_default_registry_has_no_data(mock_gateway: None) -> None:
|
||||
# Before lane B (#14) registers real excerpts the default NoDrugInfo returns nothing.
|
||||
async def test_empty_registry_has_no_data(mock_gateway: None, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Without excerpts (base.py default NoDrugInfo; #14 registers real ones at import) nothing is answered.
|
||||
monkeypatch.setattr(integrations, "drug_info", NoDrugInfo())
|
||||
assert await med_qa("сертралин", S("тошнит")) is None
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user