From 9362eb6ecca6fc12c7f34250aa57e1c0486149ea Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 12:36:07 +0300 Subject: [PATCH] =?UTF-8?q?feat(integrations):=20demo=20adapters=20for=20?= =?UTF-8?q?=D0=9C=D0=98=D0=A1,=20connect-code=20identity,=20=D0=93=D0=A0?= =?UTF-8?q?=D0=9B=D0=A1=20excerpts,=20doctor=20notifier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/integrations/__init__.py | 51 +++++ app/integrations/connect_code.py | 58 ++++++ app/integrations/grls.py | 80 ++++++++ app/integrations/mis_mock.py | 97 ++++++++++ app/integrations/notify.py | 141 ++++++++++++++ data/meds/grls/escitalopram.yaml | 57 ++++++ data/meds/grls/fluoxetine.yaml | 52 +++++ data/meds/grls/mirtazapine.yaml | 50 +++++ data/meds/grls/paroxetine.yaml | 56 ++++++ data/meds/grls/prazosin.yaml | 49 +++++ data/meds/grls/sertraline.yaml | 57 ++++++ data/meds/mis_prescriptions.json | 165 ++++++++++++++++ tests/test_integrations.py | 323 +++++++++++++++++++++++++++++++ tests/test_llm_med_qa.py | 7 +- 14 files changed, 1240 insertions(+), 3 deletions(-) create mode 100644 app/integrations/connect_code.py create mode 100644 app/integrations/grls.py create mode 100644 app/integrations/mis_mock.py create mode 100644 app/integrations/notify.py create mode 100644 data/meds/grls/escitalopram.yaml create mode 100644 data/meds/grls/fluoxetine.yaml create mode 100644 data/meds/grls/mirtazapine.yaml create mode 100644 data/meds/grls/paroxetine.yaml create mode 100644 data/meds/grls/prazosin.yaml create mode 100644 data/meds/grls/sertraline.yaml create mode 100644 data/meds/mis_prescriptions.json create mode 100644 tests/test_integrations.py diff --git a/app/integrations/__init__.py b/app/integrations/__init__.py index e69de29..d3156ba 100644 --- a/app/integrations/__init__.py +++ b/app/integrations/__init__.py @@ -0,0 +1,51 @@ +"""Demo adapters behind the frozen integration Protocols (SPEC §9, #14). + +`setup()` runs when the package is first imported. Every consumer imports `app.integrations.base`, which +initialises this package first, and `app/main.py` is a frozen contract — so the registry is wired before +any request or job without a startup hook. Setup only constructs objects: data files and DB sessions are +opened lazily on the first call, so importing the package never fails on I/O. + +Registry after setup: +- `prescriptions` — `MisMock`: clinical DB `prescriptions`, then `data/meds/mis_prescriptions.json`; +- `identity` — `ConnectCodeIdentity`: one-time connect code from the doctor (ЕСИА stand-in); +- `drug_info` — `GrlsExcerpts`: `data/meds/grls/*.yaml`; +- `notifier` — `CompositeNotifier`: in-memory dashboard feed + optional Telegram doctor chat; +- `outbox` — unchanged `MemoryOutbox` (the web console drains it; the Telegram channel wraps it at start). +""" + +from app.integrations.base import ( + EmptyPrescriptions, + MemoryNotifier, + NoDrugInfo, + RejectIdentity, + integrations, +) +from app.integrations.connect_code import ConnectCodeIdentity +from app.integrations.grls import GrlsExcerpts +from app.integrations.mis_mock import MisMock +from app.integrations.notify import CompositeNotifier, NotificationFeed + +__all__ = ["CompositeNotifier", "ConnectCodeIdentity", "GrlsExcerpts", "MisMock", "NotificationFeed", "setup"] + +_done = False + + +def setup(*, force: bool = False) -> None: + """Register the demo adapters. Idempotent; never replaces an adapter someone already swapped in + (a test fake, the Telegram outbox), only the in-memory defaults from `base.py` — unless `force`.""" + global _done + if _done and not force: + return + if force or isinstance(integrations.prescriptions, EmptyPrescriptions): + integrations.prescriptions = MisMock() + if force or isinstance(integrations.identity, RejectIdentity): + integrations.identity = ConnectCodeIdentity() + if force or isinstance(integrations.drug_info, NoDrugInfo): + integrations.drug_info = GrlsExcerpts() + # Exact type check: MemoryNotifier subclasses used as test sinks must stay in place. + if force or type(integrations.notifier) is MemoryNotifier: + integrations.notifier = CompositeNotifier() + _done = True + + +setup() diff --git a/app/integrations/connect_code.py b/app/integrations/connect_code.py new file mode 100644 index 0000000..41fa9f7 --- /dev/null +++ b/app/integrations/connect_code.py @@ -0,0 +1,58 @@ +"""ЕСИА stand-in: the patient proves identity with the one-time connect code issued by the doctor. + +The doctor creates the code in «Подключить пациента» (identity DB `connect_codes`); the patient opens the +QR deep link / enters the code. In prod this is ЕСИА OAuth; the `IdentityProvider` Protocol stays the same. +""" + +import logging +from datetime import UTC, timedelta + +from sqlalchemy.exc import SQLAlchemyError + +from app.core import clock +from app.integrations.base import VerifiedPerson + +log = logging.getLogger(__name__) + +# One connect flow verifies the same code twice: the Telegram channel on `/start ` (to bind the chat) +# and then the dialog engine's `new` state (to check the code belongs to this token). A short grace window +# lets the second check pass; after it a used code is rejected like an unknown one. +REDEEM_GRACE = timedelta(minutes=15) + + +class ConnectCodeIdentity: + """`IdentityProvider` over identity-DB connect codes.""" + + def __init__(self, grace: timedelta = REDEEM_GRACE) -> None: + self.grace = grace + + async def verify(self, auth_code: str) -> VerifiedPerson | None: + code = auth_code.strip() + if not code: + return None + # Imported lazily: the integrations package is imported by clinical-side modules, which must not + # pull the identity schema into their import graph (guardrail #5). + from app.db import identity_repo + from app.db.identity import ConnectCode + from app.db.session import get_identity_session + + now = clock.now() + try: + async with get_identity_session() as s: + row = await s.get(ConnectCode, code) + if row is None: + log.info("connect code rejected: unknown") + return None + person = VerifiedPerson(token=row.token, doctor_id=row.doctor_id) + if row.used_at is not None: + used_at = row.used_at if row.used_at.tzinfo else row.used_at.replace(tzinfo=UTC) + if now - used_at <= self.grace: + return person + log.info("connect code rejected: already used") + return None + if await identity_repo.redeem_connect_code(s, code=code, now=now) is None: + return None + return person + except (OSError, SQLAlchemyError) as exc: + log.warning("connect code check failed error=%s", type(exc).__name__) + return None diff --git a/app/integrations/grls.py b/app/integrations/grls.py new file mode 100644 index 0000000..8d8e8b6 --- /dev/null +++ b/app/integrations/grls.py @@ -0,0 +1,80 @@ +"""ГРЛС stand-in: short excerpts of the official drug instruction, per section (SPEC §9, SPEC §7 п.5). + +Each `data/meds/grls/.yaml` holds one drug: + + inn: сертралин + aliases: [sertraline] + sections: + side_effects: {text: "...", source: "ГРЛС, инструкция по медицинскому применению, раздел «...»"} + +Section codes are the ones `app/llm/med_qa.py` understands (side_effects, onset, alcohol, discontinuation, +administration, interactions, overdose). The bot answers drug questions ONLY from these texts, so they are +the single place to verify against the current ГРЛС instruction (see the header of each file). +""" + +import logging +from pathlib import Path + +import yaml +from pydantic import BaseModel, Field, ValidationError + +from app.config import get_settings +from app.integrations.base import DrugSection + +log = logging.getLogger(__name__) + + +class _SectionFile(BaseModel): + text: str + source: str + + +class _DrugFile(BaseModel): + inn: str + aliases: list[str] = Field(default_factory=list) + sections: dict[str, _SectionFile] + + +def default_grls_dir() -> Path: + return get_settings().data_dir / "meds" / "grls" + + +def normalize_inn(name: str) -> str: + """Lookup key: «Сертралин 50 мг», «сертралин», «Sertraline» -> first word, lower case, ё -> е.""" + words = name.strip().lower().replace("ё", "е").split() + return words[0] if words else "" + + +class GrlsExcerpts: + """`DrugInfo` over local YAML excerpts. Files are read lazily on the first lookup and cached.""" + + def __init__(self, directory: Path | None = None) -> None: + self._dir = directory + self._index: dict[str, list[DrugSection]] | None = None + + def _load(self) -> dict[str, list[DrugSection]]: + if self._index is not None: + return self._index + directory = self._dir or default_grls_dir() + index: dict[str, list[DrugSection]] = {} + for path in sorted(directory.glob("*.yaml")): + try: + drug = _DrugFile.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) + except (OSError, yaml.YAMLError, ValidationError) as exc: + log.warning("grls file skipped file=%s error=%s", path.name, type(exc).__name__) + continue + sections = [ + DrugSection(inn=drug.inn, section=code, text=" ".join(s.text.split()), source=s.source) + for code, s in drug.sections.items() + ] + for key in (drug.inn, *drug.aliases): + index[normalize_inn(key)] = sections + self._index = index + return index + + def known(self) -> list[str]: + """INNs with excerpts (for the dashboard / tests).""" + return sorted({s[0].inn for s in self._load().values() if s}) + + async def sections(self, inn: str) -> list[DrugSection]: + return [s.model_copy() for s in self._load().get(normalize_inn(inn), [])] diff --git a/app/integrations/mis_mock.py b/app/integrations/mis_mock.py new file mode 100644 index 0000000..8bde271 --- /dev/null +++ b/app/integrations/mis_mock.py @@ -0,0 +1,97 @@ +"""МИС / ЕГИСЗ stand-in: the patient's prescriptions (SPEC §9). + +Source order: the clinical DB table `prescriptions` (written when the doctor connects a patient or by the +demo seed), then the synthetic catalogue `data/meds/mis_prescriptions.json` keyed by token. In prod this is +a regional МИС query; the Protocol stays the same. +""" + +import json +import logging +from datetime import date +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field, ValidationError +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError + +from app.config import get_settings +from app.integrations.base import Prescription + +log = logging.getLogger(__name__) + + +class _CatalogPatient(BaseModel): + persona: str + tokens: list[str] = Field(default_factory=list) + prescriptions: list[Prescription] + + +class _Catalog(BaseModel): + patients: list[_CatalogPatient] + + +def _from_row(drug: str, dose: str, schedule: dict[str, Any], start: date | None, end: date | None) -> Prescription: + # The table has no instructions column; the doctor's note («утром после еды») may ride in the schedule JSON. + times = {k: [str(x) for x in v] for k, v in schedule.items() if isinstance(v, list)} + return Prescription( + drug=drug, + dose=dose, + schedule=times or {"times": ["08:00"]}, + instructions=str(schedule.get("instructions") or ""), + start_date=start, + end_date=end, + ) + + +def default_catalog_path() -> Path: + return get_settings().data_dir / "meds" / "mis_prescriptions.json" + + +class MisMock: + """`PrescriptionSource`: clinical DB first, synthetic JSON catalogue as the fallback.""" + + def __init__(self, catalog_path: Path | None = None, *, use_db: bool = True) -> None: + self._path = catalog_path + self._use_db = use_db + self._by_token: dict[str, list[Prescription]] | None = None + + def _catalog(self) -> dict[str, list[Prescription]]: + # Lazy: the package is imported before startup, a missing/broken file must not break the import. + if self._by_token is None: + path = self._path or default_catalog_path() + index: dict[str, list[Prescription]] = {} + try: + catalog = _Catalog.model_validate(json.loads(path.read_text(encoding="utf-8"))) + except (OSError, ValueError, ValidationError) as exc: + log.warning("mis_mock catalogue not loaded error=%s", type(exc).__name__) + catalog = _Catalog(patients=[]) + for patient in catalog.patients: + for token in patient.tokens: + index[token] = patient.prescriptions + self._by_token = index + return self._by_token + + async def _from_db(self, token: str) -> list[Prescription]: + from app.db.clinical import Prescription as PrescriptionRow + from app.db.session import get_clinical_session + + async with get_clinical_session() as s: + rows = ( + await s.scalars( + select(PrescriptionRow).where(PrescriptionRow.token == token).order_by(PrescriptionRow.id) + ) + ).all() + return [_from_row(r.drug, r.dose, r.schedule or {}, r.start_date, r.end_date) for r in rows] + + async def get_prescriptions(self, token: str) -> list[Prescription]: + if self._use_db: + try: + found = await self._from_db(token) + except (OSError, SQLAlchemyError) as exc: + # DB down or not migrated: the demo still shows the catalogue prescription. + log.warning("mis_mock db lookup failed error=%s", type(exc).__name__) + found = [] + if found: + return found + return [p.model_copy(deep=True) for p in self._catalog().get(token, [])] diff --git a/app/integrations/notify.py b/app/integrations/notify.py new file mode 100644 index 0000000..29c548f --- /dev/null +++ b/app/integrations/notify.py @@ -0,0 +1,141 @@ +"""Doctor notifications (SPEC §9): in-memory feed for the dashboard + optional Telegram doctor chat. + +The alert itself is already committed to the clinical DB before `send()` is called (the dashboard reads +alerts from there via the API), so every channel here is best effort: failures are logged, never raised. +Messages carry only the level, the rule reason/code and a short pseudonymous patient id — no PII +(guardrail #8); the reason texts come from `config/rules.yaml`, not from patient messages. +""" + +import asyncio +import itertools +import logging +from collections import deque +from collections.abc import Awaitable, Callable +from datetime import datetime +from typing import Any + +from pydantic import BaseModel + +from app.config import get_settings +from app.core import clock +from app.integrations.base import AlertLevelName + +log = logging.getLogger(__name__) + +FEED_SIZE = 500 +SHORT_ID_LEN = 6 +MAX_REASON_CHARS = 200 +TELEGRAM_TIMEOUT_S = 5.0 +# Doctor-facing labels (not patient texts); acute alerts are visually distinct in the chat. +LEVEL_LABELS: dict[str, str] = { + "acute": "КРИТИЧНО", + "high": "Высокий", + "moderate": "Умеренный", + "info": "Инфо", +} + +ChatSender = Callable[[int, str], Awaitable[Any]] + + +def short_id(token: str | None) -> str: + return token[:SHORT_ID_LEN] if token else "—" + + +def format_message(level: str, text: str, token: str | None) -> str: + reason = " ".join(text.split()) + if len(reason) > MAX_REASON_CHARS: + reason = reason[: MAX_REASON_CHARS - 1] + "…" + return f"[{LEVEL_LABELS.get(level, level)}] {reason}\nПациент #{short_id(token)}" + + +class Notification(BaseModel): + id: int + ts: datetime + doctor_id: str + level: str + text: str + token: str | None = None + + +class NotificationFeed: + """Bounded in-memory feed; ids increase monotonically so a poller asks only for `recent(since_id)`.""" + + def __init__(self, size: int = FEED_SIZE) -> None: + self._items: deque[Notification] = deque(maxlen=size) + self._ids = itertools.count(1) + + def add(self, doctor_id: str, level: str, text: str, token: str | None, ts: datetime) -> Notification: + item = Notification(id=next(self._ids), ts=ts, doctor_id=doctor_id, level=level, text=text, token=token) + self._items.append(item) + return item + + def recent(self, since_id: int = 0, doctor_id: str | None = None) -> list[Notification]: + return [n for n in self._items if n.id > since_id and (doctor_id is None or n.doctor_id == doctor_id)] + + +class TelegramDoctorChat: + """Sends to the doctor chat `DOCTOR_NOTIFY_CHAT_ID` through the bot `TG_BOT_TOKEN`. + + The aiogram `Bot` is created on the first message only; with either setting empty the channel is off. + """ + + def __init__(self, chat_id: int | None = None, bot_token: str | None = None, sender: ChatSender | None = None): + settings = get_settings() + self.chat_id = chat_id if chat_id is not None else settings.doctor_notify_chat_id + self._bot_token = bot_token if bot_token is not None else settings.tg_bot_token + self._sender = sender + self._bot: Any = None + + @property + def enabled(self) -> bool: + return self.chat_id is not None and (self._sender is not None or bool(self._bot_token)) + + def _get_sender(self) -> ChatSender: + if self._sender is None: + from aiogram import Bot + + self._bot = Bot(self._bot_token) + self._sender = self._bot.send_message + return self._sender + + async def send(self, level: str, text: str, token: str | None) -> None: + if not self.enabled or self.chat_id is None: + return + try: + await asyncio.wait_for( + self._get_sender()(self.chat_id, format_message(level, text, token)), timeout=TELEGRAM_TIMEOUT_S + ) + except Exception as exc: # noqa: BLE001 — any channel error; the alert is already in the DB + err = type(exc).__name__ + log.warning("doctor chat notify failed level=%s tok=%s error=%s", level, short_id(token), err) + + async def aclose(self) -> None: + if self._bot is not None: + await self._bot.session.close() + self._bot = None + self._sender = None + + +class CompositeNotifier: + """`Notifier`: dashboard feed + Telegram doctor chat (when configured).""" + + def __init__(self, feed: NotificationFeed | None = None, chat: TelegramDoctorChat | None = None) -> None: + self.feed = feed or NotificationFeed() + self._chat = chat + + @property + def chat(self) -> TelegramDoctorChat: + # Lazy: settings are read on the first notification, not when the package is imported. + if self._chat is None: + self._chat = TelegramDoctorChat() + return self._chat + + def recent(self, since_id: int = 0, doctor_id: str | None = None) -> list[Notification]: + return self.feed.recent(since_id, doctor_id) + + async def send(self, doctor_id: str, level: AlertLevelName, text: str, token: str | None = None) -> None: + try: + self.feed.add(doctor_id, level, text, token, clock.now()) + except Exception as exc: # noqa: BLE001 — never break the caller + log.warning("notify feed failed error=%s", type(exc).__name__) + await self.chat.send(level, text, token) diff --git a/data/meds/grls/escitalopram.yaml b/data/meds/grls/escitalopram.yaml new file mode 100644 index 0000000..3815713 --- /dev/null +++ b/data/meds/grls/escitalopram.yaml @@ -0,0 +1,57 @@ +# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru). +# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией. +# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов); +# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py). +inn: эсциталопрам +aliases: [escitalopram, ципралекс, cipralex, селектра] +sections: + side_effects: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»" + text: >- + Наиболее частая нежелательная реакция — тошнота; также возможны головная боль, бессонница или сонливость, + повышенная потливость, диарея, усталость. + Побочные эффекты чаще возникают в первые 1–2 недели лечения и обычно ослабевают или проходят при продолжении + приёма. + Возможны нарушения половой функции: снижение влечения, нарушение эякуляции. + О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу. + onset: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Терапевтический эффект развивается постепенно. + Улучшение обычно отмечается через 2–4 недели регулярного приёма. + Оценивает эффект и решает, нужно ли менять лечение, лечащий врач. + alcohol: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Во время лечения употребление алкоголя не рекомендуется. + Фармакодинамического взаимодействия с алкоголем не ожидается, однако, как и с другими препаратами этой группы, + их одновременный приём не рекомендован. + Алкоголь сам по себе ухудшает сон и настроение. + discontinuation: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Резкое прекращение приёма может вызвать синдром отмены: головокружение, нарушения сна, тревогу, + раздражительность, тошноту, головную боль. + Препарат не следует отменять самостоятельно: отмену проводят постепенно, в течение 1–2 недель или дольше, + по решению и под контролем врача. + Изменять схему лечения может только лечащий врач. + administration: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Препарат принимают один раз в сутки независимо от приёма пищи. + Таблетки принимают ежедневно примерно в одно и то же время. + Дозу и схему приёма определяет лечащий врач. + interactions: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»" + text: >- + Одновременный приём с ингибиторами МАО и пимозидом противопоказан. + Не рекомендуется совместное применение с препаратами, удлиняющими интервал QT. + Совместное применение с другими серотонинергическими средствами (трамадол, триптаны, препараты зверобоя) + повышает риск серотонинового синдрома. + О любых других лекарствах нужно сообщить врачу. + overdose: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»" + text: >- + Симптомы передозировки: головокружение, дрожь, возбуждение, сонливость, тошнота, рвота, учащённое + сердцебиение, нарушения сердечного ритма. + При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112). diff --git a/data/meds/grls/fluoxetine.yaml b/data/meds/grls/fluoxetine.yaml new file mode 100644 index 0000000..10c7b65 --- /dev/null +++ b/data/meds/grls/fluoxetine.yaml @@ -0,0 +1,52 @@ +# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru). +# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией. +# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов); +# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py). +inn: флуоксетин +aliases: [fluoxetine, прозак, prozac] +sections: + side_effects: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»" + text: >- + Наиболее частые нежелательные реакции: тошнота, бессонница, головная боль, нервозность и беспокойство, + снижение аппетита, повышенная потливость, диарея. + Многие из них, в том числе тошнота, выражены в начале лечения и обычно уменьшаются в течение первых недель. + Возможны нарушения половой функции: снижение влечения, нарушение эякуляции. + О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу. + onset: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Терапевтический эффект развивается постепенно. + Улучшение обычно отмечается через 2–4 недели регулярного приёма, иногда позже. + Оценивает эффект и решает, нужно ли менять лечение, лечащий врач. + alcohol: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Во время лечения употребление алкоголя не рекомендуется. + Алкоголь сам по себе ухудшает сон и настроение и может усиливать побочные эффекты. + discontinuation: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Флуоксетин выводится из организма медленно, поэтому симптомы отмены могут появиться не сразу, а через + несколько дней или недель. + При прекращении приёма возможны головокружение, нарушения сна, тревога, раздражительность, тошнота. + Препарат не следует отменять самостоятельно: решение об отмене принимает лечащий врач. + administration: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Препарат принимают внутрь, обычно один раз в сутки утром, независимо от приёма пищи. + Капсулы проглатывают, запивая водой. + Дозу и схему приёма определяет лечащий врач. + interactions: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»" + text: >- + Одновременный приём с ингибиторами МАО, пимозидом и тиоридазином противопоказан; из-за медленного выведения + флуоксетина интервал перед назначением ингибиторов МАО составляет не менее 5 недель. + Совместное применение с другими серотонинергическими средствами (трамадол, триптаны, препараты зверобоя) + повышает риск серотонинового синдрома. + О любых других лекарствах нужно сообщить врачу. + overdose: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»" + text: >- + Симптомы передозировки: тошнота, рвота, возбуждение, учащённое сердцебиение, судороги. + При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112). diff --git a/data/meds/grls/mirtazapine.yaml b/data/meds/grls/mirtazapine.yaml new file mode 100644 index 0000000..ffd3ec4 --- /dev/null +++ b/data/meds/grls/mirtazapine.yaml @@ -0,0 +1,50 @@ +# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru). +# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией. +# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов); +# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py). +inn: миртазапин +aliases: [mirtazapine, ремерон, remeron, каликста] +sections: + side_effects: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»" + text: >- + Наиболее частые нежелательные реакции: сонливость, повышение аппетита и массы тела, сухость во рту, + головокружение, головная боль, отёки. + Сонливость чаще выражена в первые недели лечения и обычно уменьшается при продолжении приёма. + О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу. + onset: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Терапевтический эффект развивается постепенно: первые признаки улучшения обычно появляются через 1–2 недели, + полный эффект — через 2–4 недели регулярного приёма. + Оценивает эффект и решает, нужно ли менять лечение, лечащий врач. + alcohol: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Миртазапин усиливает угнетающее действие алкоголя на нервную систему, поэтому во время лечения от алкоголя + следует воздержаться. + Сочетание с алкоголем усиливает сонливость и замедляет реакцию. + discontinuation: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Резкое прекращение приёма может вызвать симптомы отмены: головокружение, тревогу, возбуждение, головную боль, + тошноту. + Препарат не следует отменять самостоятельно: отмену проводят постепенно, по решению и под контролем врача. + administration: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Препарат обычно принимают один раз в сутки вечером, перед сном, независимо от приёма пищи. + Таблетку проглатывают, не разжёвывая, запивая водой. + Дозу и схему приёма определяет лечащий врач. + interactions: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»" + text: >- + Одновременный приём с ингибиторами МАО противопоказан. + Миртазапин усиливает седативное действие бензодиазепинов и других успокоительных средств. + Совместное применение с другими серотонинергическими средствами повышает риск серотонинового синдрома. + О любых других лекарствах нужно сообщить врачу. + overdose: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»" + text: >- + Симптомы передозировки: выраженная сонливость, дезориентация, учащённое сердцебиение. + При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112). diff --git a/data/meds/grls/paroxetine.yaml b/data/meds/grls/paroxetine.yaml new file mode 100644 index 0000000..fb96e21 --- /dev/null +++ b/data/meds/grls/paroxetine.yaml @@ -0,0 +1,56 @@ +# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru). +# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией. +# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов); +# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py). +inn: пароксетин +aliases: [paroxetine, паксил, paxil, рексетин] +sections: + side_effects: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»" + text: >- + Наиболее частые нежелательные реакции: тошнота, сонливость или бессонница, повышенная потливость, дрожь, + слабость, сухость во рту, запор, снижение аппетита. + Частота и выраженность многих побочных эффектов, в том числе тошноты, уменьшаются при продолжении лечения, + обычно в первые 1–2 недели. + Возможны нарушения половой функции: снижение влечения, нарушение эякуляции. + О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу. + onset: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Терапевтический эффект развивается постепенно. + Улучшение обычно отмечается через 2–4 недели регулярного приёма, а в первые дни самочувствие может не меняться. + Оценивает эффект и решает, нужно ли менять лечение, лечащий врач. + alcohol: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Во время лечения употребление алкоголя не рекомендуется. + Хотя пароксетин не усиливает вредное действие алкоголя на психомоторные функции, их одновременный приём + не рекомендован. + Алкоголь сам по себе ухудшает сон и настроение. + discontinuation: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Резкое прекращение приёма часто вызывает синдром отмены: головокружение, нарушения сна и яркие сновидения, + тревогу, раздражительность, тошноту, потливость, ощущение покалывания. + Препарат не следует отменять самостоятельно: отмену проводят постепенно, в течение нескольких недель, по решению + и под контролем врача. + Изменять схему лечения может только лечащий врач. + administration: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Препарат принимают один раз в сутки утром, во время завтрака. + Таблетку проглатывают целиком, не разжёвывая, запивая водой. + Дозу и схему приёма определяет лечащий врач. + interactions: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»" + text: >- + Одновременный приём с ингибиторами МАО, тиоридазином и пимозидом противопоказан. + Совместное применение с другими серотонинергическими средствами (трамадол, триптаны, препараты зверобоя) + повышает риск серотонинового синдрома. + Пароксетин влияет на обмен ряда препаратов в печени, поэтому о любых других лекарствах нужно сообщить врачу. + overdose: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»" + text: >- + Симптомы передозировки: тошнота, рвота, дрожь, расширение зрачков, сухость во рту, раздражительность, + сонливость. + При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112). diff --git a/data/meds/grls/prazosin.yaml b/data/meds/grls/prazosin.yaml new file mode 100644 index 0000000..d7ad106 --- /dev/null +++ b/data/meds/grls/prazosin.yaml @@ -0,0 +1,49 @@ +# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru). +# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией. +# Празозин зарегистрирован как средство, снижающее артериальное давление; при ночных кошмарах его назначает +# врач вне зарегистрированных показаний — сроки эффекта в инструкции для этого случая не указаны. +# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов); +# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py). +inn: празозин +aliases: [prazosin, минипресс, minipress] +sections: + side_effects: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»" + text: >- + Наиболее частые нежелательные реакции: головокружение, сонливость, головная боль, слабость, сердцебиение, + тошнота. + После первого приёма возможно резкое снижение давления при вставании с головокружением и даже обмороком + («эффект первой дозы»). + Головокружение обычно уменьшается при продолжении лечения; о стойких побочных эффектах нужно сообщить врачу. + onset: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Фармакологические свойства»" + text: >- + Действие на артериальное давление начинается через несколько часов после приёма. + Срок, через который ожидается эффект в вашем случае, определяет лечащий врач. + alcohol: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Во время лечения употребление алкоголя не рекомендуется. + Алкоголь усиливает снижение давления, головокружение и сонливость. + discontinuation: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Препарат не следует прекращать или возобновлять самостоятельно. + После перерыва в приёме снова возможен «эффект первой дозы», поэтому порядок возобновления определяет врач. + administration: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Первый приём рекомендуется вечером перед сном, в положении лёжа. + Вставать с постели после приёма следует медленно, особенно ночью. + Дозу и схему приёма определяет лечащий врач. + interactions: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»" + text: >- + Одновременный приём с другими средствами, снижающими давление, и с препаратами для лечения эректильной + дисфункции усиливает снижение давления. + О любых других лекарствах нужно сообщить врачу. + overdose: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»" + text: >- + Симптомы передозировки: резкое снижение давления, выраженная сонливость, слабость, обморок. + При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112). diff --git a/data/meds/grls/sertraline.yaml b/data/meds/grls/sertraline.yaml new file mode 100644 index 0000000..5ec09d2 --- /dev/null +++ b/data/meds/grls/sertraline.yaml @@ -0,0 +1,57 @@ +# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru). +# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией. +# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов); +# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py). +inn: сертралин +aliases: [sertraline, золофт, zoloft] +sections: + side_effects: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»" + text: >- + Наиболее частые нежелательные реакции в начале лечения: тошнота, диарея, сухость во рту, бессонница или + сонливость, головная боль и головокружение. + Тошнота и другие нежелательные явления начала лечения обычно ослабевают или проходят в течение 1–2 недель. + Возможны нарушения половой функции: снижение влечения, задержка эякуляции. + О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу. + onset: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Терапевтический эффект развивается постепенно. + Первые признаки улучшения могут появиться в течение первой недели, а полный эффект обычно достигается через + 2–4 недели регулярного приёма. + В первые дни, пока эффект ещё не развился, самочувствие может не улучшаться, а побочные эффекты ощущаются + сильнее. + alcohol: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Во время лечения употребление алкоголя не рекомендуется. + Хотя в исследованиях сертралин не усиливал действие алкоголя, их одновременный приём не рекомендован. + Алкоголь сам по себе ухудшает сон и настроение и может усиливать побочные эффекты, например сонливость и + головокружение. + discontinuation: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»" + text: >- + Резкое прекращение приёма может вызвать синдром отмены: головокружение, нарушения сна, тревогу, тошноту, + ощущение покалывания. + Препарат не следует отменять самостоятельно: отмену проводят постепенно, по решению и под контролем врача. + Изменять схему лечения может только лечащий врач. + administration: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»" + text: >- + Препарат принимают внутрь один раз в сутки, утром или вечером, независимо от приёма пищи. + Таблетки принимают ежедневно примерно в одно и то же время. + Дозу и схему приёма определяет лечащий врач. + interactions: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»" + text: >- + Одновременный приём с ингибиторами МАО и пимозидом противопоказан. + Совместное применение с другими серотонинергическими средствами (трамадол, триптаны, препараты зверобоя) + повышает риск серотонинового синдрома. + С антикоагулянтами и обезболивающими из группы НПВП возрастает риск кровотечений. + О любых других лекарствах, включая безрецептурные, нужно сообщить врачу. + overdose: + source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»" + text: >- + Симптомы передозировки: сонливость, тошнота, рвота, учащённое сердцебиение, дрожь, возбуждение, + головокружение. + При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112). diff --git a/data/meds/mis_prescriptions.json b/data/meds/mis_prescriptions.json new file mode 100644 index 0000000..564d600 --- /dev/null +++ b/data/meds/mis_prescriptions.json @@ -0,0 +1,165 @@ +{ + "_comment": "СИНТЕТИКА. Назначения-образцы для mis_mock (заглушка МИС/ЕГИСЗ, SPEC §9). Ключ — токен пациента; источник первого уровня — таблица prescriptions клинической БД, этот файл — запасной. Персоны: docs/product/01_demo_persona.md.", + "patients": [ + { + "persona": "andrey", + "tokens": [ + "tok-andrey" + ], + "prescriptions": [ + { + "drug": "сертралин", + "dose": "50 мг", + "schedule": { + "times": [ + "08:00" + ] + }, + "instructions": "утром после еды", + "start_date": null, + "end_date": null + } + ] + }, + { + "persona": "dmitry", + "tokens": [ + "tok-dmitry" + ], + "prescriptions": [ + { + "drug": "сертралин", + "dose": "100 мг", + "schedule": { + "times": [ + "08:00" + ] + }, + "instructions": "утром после еды", + "start_date": null, + "end_date": null + } + ] + }, + { + "persona": "sergey", + "tokens": [ + "tok-sergey" + ], + "prescriptions": [ + { + "drug": "пароксетин", + "dose": "20 мг", + "schedule": { + "times": [ + "08:00" + ] + }, + "instructions": "утром во время завтрака", + "start_date": null, + "end_date": null + } + ] + }, + { + "persona": "nikolay", + "tokens": [ + "tok-nikolay" + ], + "prescriptions": [ + { + "drug": "сертралин", + "dose": "50 мг", + "schedule": { + "times": [ + "08:00" + ] + }, + "instructions": "утром после еды", + "start_date": null, + "end_date": null + } + ] + }, + { + "persona": "anna", + "tokens": [ + "tok-anna" + ], + "prescriptions": [ + { + "drug": "флуоксетин", + "dose": "20 мг", + "schedule": { + "times": [ + "08:00" + ] + }, + "instructions": "утром", + "start_date": null, + "end_date": null + } + ] + }, + { + "persona": "irina", + "tokens": [ + "tok-irina" + ], + "prescriptions": [ + { + "drug": "эсциталопрам", + "dose": "10 мг", + "schedule": { + "times": [ + "09:00" + ] + }, + "instructions": "утром", + "start_date": null, + "end_date": null + } + ] + }, + { + "persona": "olga", + "tokens": [ + "tok-olga" + ], + "prescriptions": [ + { + "drug": "сертралин", + "dose": "50 мг", + "schedule": { + "times": [ + "08:00" + ] + }, + "instructions": "утром после еды", + "start_date": null, + "end_date": null + } + ] + }, + { + "persona": "pavel", + "tokens": [ + "tok-pavel" + ], + "prescriptions": [ + { + "drug": "эсциталопрам", + "dose": "10 мг", + "schedule": { + "times": [ + "09:00" + ] + }, + "instructions": "утром", + "start_date": null, + "end_date": null + } + ] + } + ] +} diff --git a/tests/test_integrations.py b/tests/test_integrations.py new file mode 100644 index 0000000..16d132d --- /dev/null +++ b/tests/test_integrations.py @@ -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("Пациент #—") diff --git a/tests/test_llm_med_qa.py b/tests/test_llm_med_qa.py index 03b0624..1a58a98 100644 --- a/tests/test_llm_med_qa.py +++ b/tests/test_llm_med_qa.py @@ -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 -- 2.54.0