release: dev → main (сдача хакатона) #106
@@ -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
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user