Merge pull request 'feat(integrations): адаптеры МИС, код подключения, выдержки ГРЛС, уведомления врача' (#67) from feat/14-integrations into dev
CI / ruff · pytest (push) Successful in 1m29s
CI / ruff · pytest (push) Successful in 1m29s
This commit was merged in pull request #67.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 <code>` (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
|
||||
@@ -0,0 +1,80 @@
|
||||
"""ГРЛС stand-in: short excerpts of the official drug instruction, per section (SPEC §9, SPEC §7 п.5).
|
||||
|
||||
Each `data/meds/grls/<name>.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), [])]
|
||||
@@ -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, [])]
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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