feat(integrations): demo adapters for МИС, connect-code identity, ГРЛС excerpts, doctor notifier
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:
2026-10-04 12:36:07 +03:00
co-authored by Claude Opus 5.5
parent 53818ac76c
commit 9362eb6ecc
14 changed files with 1240 additions and 3 deletions
+323
View File
@@ -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("Пациент #—")
+4 -3
View File
@@ -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