feat(integrations): адаптеры МИС, код подключения, выдержки ГРЛС, уведомления врача #67

Merged
Eleboba merged 1 commits from feat/14-integrations into dev 2026-10-04 12:38:19 +03:00
14 changed files with 1240 additions and 3 deletions
+51
View File
@@ -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()
+58
View File
@@ -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
+80
View File
@@ -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), [])]
+97
View File
@@ -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, [])]
+141
View File
@@ -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)
+57
View File
@@ -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).
+52
View File
@@ -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).
+50
View File
@@ -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).
+56
View File
@@ -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).
+49
View File
@@ -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).
+57
View File
@@ -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).
+165
View File
@@ -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
}
]
}
]
}
+323
View File
@@ -0,0 +1,323 @@
"""Demo adapters (#14): МИС mock, connect-code identity, ГРЛС excerpts, doctor notifier, registry setup."""
import json
from collections.abc import AsyncIterator
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
import pytest
from app.core import clock
from app.core.types import ScrubbedText
from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Prescription as PrescriptionRow
from app.integrations import (
CompositeNotifier,
ConnectCodeIdentity,
GrlsExcerpts,
MisMock,
NotificationFeed,
setup,
)
from app.integrations import notify as notify_module
from app.integrations.base import (
EmptyPrescriptions,
MemoryNotifier,
MemoryOutbox,
NoDrugInfo,
RejectIdentity,
integrations,
)
from app.integrations.grls import normalize_inn
from app.integrations.notify import TelegramDoctorChat, format_message
from app.llm import med_qa as med_qa_module
from app.llm.gateway import LLMGateway, is_forbidden
from app.llm.med_qa import DOSING_SENTENCE, SECTIONS, med_qa
from app.llm.mock import MockTasks
MEM = "sqlite+aiosqlite:///:memory:"
TOKEN = "a" * 32
T0 = datetime(2026, 10, 1, 9, 0, tzinfo=UTC)
GRLS_DIR = Path(__file__).resolve().parents[1] / "data" / "meds" / "grls"
CORE_DRUGS = ("сертралин", "пароксетин", "эсциталопрам", "флуоксетин")
@pytest.fixture
async def dbs() -> AsyncIterator[None]:
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
try:
yield
finally:
await session.configure()
# ----------------------------------------------------------------------------------------------- setup
def test_setup_registers_demo_adapters() -> None:
assert isinstance(integrations.prescriptions, MisMock)
assert isinstance(integrations.identity, ConnectCodeIdentity)
assert isinstance(integrations.drug_info, GrlsExcerpts)
assert isinstance(integrations.notifier, CompositeNotifier)
# The outbox stays the in-memory queue the web console drains (Telegram wraps it at start).
assert isinstance(integrations.outbox, MemoryOutbox)
def test_setup_is_idempotent() -> None:
before = (integrations.prescriptions, integrations.identity, integrations.drug_info, integrations.notifier)
setup()
setup()
assert (integrations.prescriptions, integrations.identity, integrations.drug_info, integrations.notifier) == before
def test_setup_replaces_only_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
fake_notifier = MemoryNotifier()
sink = type("Sink", (MemoryNotifier,), {})()
monkeypatch.setattr(integrations, "prescriptions", EmptyPrescriptions())
monkeypatch.setattr(integrations, "identity", RejectIdentity())
monkeypatch.setattr(integrations, "drug_info", NoDrugInfo())
monkeypatch.setattr(integrations, "notifier", fake_notifier)
setup(force=True)
assert isinstance(integrations.prescriptions, MisMock)
assert isinstance(integrations.notifier, CompositeNotifier)
monkeypatch.setattr(integrations, "notifier", sink)
monkeypatch.setattr("app.integrations._done", False)
setup()
assert integrations.notifier is sink # a swapped-in test sink is kept
# --------------------------------------------------------------------------------------------- mis_mock
async def test_mis_mock_catalogue_fallback() -> None:
mis = MisMock(use_db=False)
[rx] = await mis.get_prescriptions("tok-andrey")
assert (rx.drug, rx.dose, rx.schedule["times"]) == ("сертралин", "50 мг", ["08:00"])
assert rx.instructions == "утром после еды"
assert await mis.get_prescriptions("unknown") == []
async def test_mis_mock_catalogue_drugs_have_excerpts() -> None:
raw = json.loads((GRLS_DIR.parent / "mis_prescriptions.json").read_text(encoding="utf-8"))
known = set(GrlsExcerpts().known())
assert {rx["drug"] for p in raw["patients"] for rx in p["prescriptions"]} <= known
async def test_mis_mock_missing_catalogue_is_empty(tmp_path: Path) -> None:
assert await MisMock(tmp_path / "nope.json", use_db=False).get_prescriptions("tok-andrey") == []
async def test_mis_mock_prefers_db(dbs: None) -> None:
async with session.get_clinical_session() as s:
s.add(
PrescriptionRow(
token="tok-andrey",
drug="эсциталопрам",
dose="10 мг",
schedule={"times": ["09:00"], "instructions": "утром"},
start_date=date(2026, 10, 1),
)
)
[rx] = await MisMock().get_prescriptions("tok-andrey")
assert (rx.drug, rx.dose, rx.instructions, rx.start_date) == ("эсциталопрам", "10 мг", "утром", date(2026, 10, 1))
# No DB rows for this token -> catalogue.
[rx] = await MisMock().get_prescriptions("tok-sergey")
assert rx.drug == "пароксетин"
async def test_mis_mock_inside_caller_session_keeps_its_work(dbs: None) -> None:
# The dialog calls the adapter while its own clinical session holds flushed changes.
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-smirnova")
assert await MisMock().get_prescriptions(TOKEN) == []
async with session.get_clinical_session() as s:
assert await repo.get_profile(s, TOKEN) is not None
async def test_mis_mock_db_down_falls_back(tmp_path: Path) -> None:
await session.configure(clinical_url=f"sqlite+aiosqlite:///{tmp_path}/missing/dir/x.db")
try:
[rx] = await MisMock().get_prescriptions("tok-andrey")
finally:
await session.configure()
assert rx.drug == "сертралин"
# ------------------------------------------------------------------------------------------ connect code
async def _code(code: str = "DEMO-0001") -> None:
async with session.get_identity_session() as s:
await identity_repo.create_connect_code(s, code=code, token=TOKEN, doctor_id="doc-smirnova", now=T0)
async def test_connect_code_verifies_once(dbs: None, monkeypatch: pytest.MonkeyPatch) -> None:
await _code()
ident = ConnectCodeIdentity()
monkeypatch.setattr(clock, "now", lambda: T0)
person = await ident.verify(" DEMO-0001 ")
assert person is not None and (person.token, person.doctor_id) == (TOKEN, "doc-smirnova")
# Same connect flow (Telegram /start, then the engine's `new` state) re-checks the code: still valid.
monkeypatch.setattr(clock, "now", lambda: T0 + timedelta(minutes=1))
assert await ident.verify("DEMO-0001") == person
# Later the used code is rejected.
monkeypatch.setattr(clock, "now", lambda: T0 + timedelta(hours=1))
assert await ident.verify("DEMO-0001") is None
async def test_connect_code_unknown_or_empty(dbs: None) -> None:
assert await ConnectCodeIdentity().verify("NOPE") is None
assert await ConnectCodeIdentity().verify(" ") is None
async def test_connect_code_db_down(tmp_path: Path) -> None:
await session.configure(identity_url=f"sqlite+aiosqlite:///{tmp_path}/missing/dir/x.db")
try:
assert await ConnectCodeIdentity().verify("DEMO-0001") is None
finally:
await session.configure()
# ------------------------------------------------------------------------------------------------- ГРЛС
async def test_grls_core_drugs_have_all_sections() -> None:
grls = GrlsExcerpts()
for inn in CORE_DRUGS:
sections = await grls.sections(inn)
assert {s.section for s in sections} == set(SECTIONS), inn
assert all(s.inn == inn and s.source.startswith("ГРЛС, инструкция по медицинскому") for s in sections)
assert {"миртазапин", "празозин"} <= set(grls.known())
async def test_grls_texts_are_short_safe_and_dose_free() -> None:
grls = GrlsExcerpts()
for inn in grls.known():
for s in await grls.sections(inn):
sentences = [x for x in s.text.replace("! ", ". ").split(". ") if x]
assert 1 <= len(sentences) <= 4, (inn, s.section)
assert not is_forbidden(s.text), (inn, s.section)
if s.section != "administration":
assert not DOSING_SENTENCE.search(s.text), (inn, s.section)
assert "мг" not in s.text.split(), (inn, s.section)
def test_grls_files_carry_verification_header() -> None:
for path in GRLS_DIR.glob("*.yaml"):
assert "сверить с действующей инструкцией ГРЛС" in path.read_text(encoding="utf-8").splitlines()[0], path
async def test_grls_lookup_normalisation() -> None:
grls = GrlsExcerpts()
assert normalize_inn("Сертралин 50 мг") == "сертралин"
assert await grls.sections("Sertraline") == await grls.sections("сертралин 50 мг")
assert await grls.sections("аспирин") == []
async def test_grls_bad_files_are_skipped(tmp_path: Path) -> None:
(tmp_path / "bad.yaml").write_text("inn: [", encoding="utf-8")
(tmp_path / "wrong.yaml").write_text("inn: x\n", encoding="utf-8")
assert GrlsExcerpts(tmp_path).known() == []
assert await GrlsExcerpts(tmp_path / "missing").sections("сертралин") == []
# --------------------------------------------------------------- med_qa end-to-end on real sertraline data
@pytest.fixture
def mock_gateway(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(med_qa_module, "get_gateway", lambda: LLMGateway(MockTasks()))
async def test_med_qa_day2_nausea(mock_gateway: None) -> None:
answer = await med_qa("сертралин", ScrubbedText("подташнивает с утра"))
assert answer is not None and answer.section == "side_effects"
assert "тошнот" in answer.text.lower() and "1–2 недель" in answer.text
assert "Побочное действие" in answer.source
async def test_med_qa_day5_quit(mock_gateway: None) -> None:
answer = await med_qa("сертралин", ScrubbedText("от таблеток только хуже, брошу"), topic="quit_intent")
assert answer is not None and answer.section == "onset"
assert "2–4 недели" in answer.text
async def test_med_qa_day10_alcohol(mock_gateway: None) -> None:
answer = await med_qa("сертралин", ScrubbedText("без пива не могу уснуть"))
assert answer is not None and answer.section == "alcohol"
assert "алкогол" in answer.text.lower() and "сон" in answer.text
async def test_med_qa_other_drugs_answer_core_topics(mock_gateway: None) -> None:
for inn in CORE_DRUGS:
for question in ("тошнит после таблетки", "когда начнёт действовать", "можно ли выпить пива", "хочу бросить"):
answer = await med_qa(inn, ScrubbedText(question))
assert answer is not None, (inn, question)
assert "мг" not in answer.text
async def test_med_qa_dose_question_still_goes_to_doctor(mock_gateway: None) -> None:
assert await med_qa("сертралин", ScrubbedText("можно увеличить дозу?")) is None
# ------------------------------------------------------------------------------------------------ notify
class FakeChat:
def __init__(self, fail: bool = False) -> None:
self.sent: list[tuple[int, str]] = []
self.fail = fail
async def __call__(self, chat_id: int, text: str) -> None:
if self.fail:
raise RuntimeError("telegram down")
self.sent.append((chat_id, text))
async def test_feed_ids_are_monotonic_and_filterable(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(clock, "now", lambda: T0)
notifier = CompositeNotifier(chat=TelegramDoctorChat(chat_id=None, bot_token=""))
await notifier.send("doc-smirnova", "moderate", "alcohol_3d", TOKEN)
await notifier.send("doc-other", "high", "sleep_lt4_3n", "b" * 32)
await notifier.send("doc-smirnova", "acute", "phq9_item9", TOKEN)
items = notifier.recent()
assert [n.id for n in items] == [1, 2, 3] and items[0].ts == T0
assert [n.text for n in notifier.recent(since_id=1, doctor_id="doc-smirnova")] == ["phq9_item9"]
assert notifier.recent(since_id=3) == []
def test_feed_is_bounded() -> None:
feed = NotificationFeed(size=2)
for i in range(3):
feed.add("d", "info", f"r{i}", None, T0)
assert [n.id for n in feed.recent()] == [2, 3]
async def test_telegram_doctor_chat_message_has_no_pii() -> None:
chat = FakeChat()
notifier = CompositeNotifier(chat=TelegramDoctorChat(chat_id=-100, bot_token="", sender=chat))
await notifier.send("doc-smirnova", "acute", "PHQ-9 п.9 = 2 — кризисный протокол", TOKEN)
[(chat_id, text)] = chat.sent
assert chat_id == -100
assert text == "[КРИТИЧНО] PHQ-9 п.9 = 2 — кризисный протокол\nПациент #aaaaaa"
assert TOKEN not in text
async def test_telegram_failure_is_swallowed() -> None:
notifier = CompositeNotifier(chat=TelegramDoctorChat(chat_id=1, bot_token="", sender=FakeChat(fail=True)))
await notifier.send("doc-smirnova", "high", "sleep_lt4_3n", TOKEN) # does not raise
assert len(notifier.recent()) == 1
def test_telegram_chat_disabled_without_settings() -> None:
assert not TelegramDoctorChat(chat_id=None, bot_token="x").enabled
assert not TelegramDoctorChat(chat_id=5, bot_token="").enabled
assert TelegramDoctorChat(chat_id=5, bot_token="123:abc").enabled
def test_format_message_trims_long_reason() -> None:
text = format_message("info", "x" * 500, None)
assert len(text.splitlines()[0]) <= notify_module.MAX_REASON_CHARS + len("[Инфо] ")
assert text.endswith("Пациент #—")
+4 -3
View File
@@ -3,7 +3,7 @@
import pytest import pytest
from app.core.types import ScrubbedText 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 import med_qa as med_qa_module
from app.llm.gateway import LLMGateway, is_forbidden 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 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 assert await med_qa("сертралин", S("какая погода"), drug_info=info) is None
async def test_default_registry_has_no_data(mock_gateway: None) -> None: async def test_empty_registry_has_no_data(mock_gateway: None, monkeypatch: pytest.MonkeyPatch) -> None:
# Before lane B (#14) registers real excerpts the default NoDrugInfo returns nothing. # 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 assert await med_qa("сертралин", S("тошнит")) is None