feat(bot): tool-using chat agent over RAG, ЕМИАС record and drug-safety reference
CI / ruff · pytest (pull_request) Successful in 2m10s

Free-text treatment and medication questions go to an LLM agent with read-only tools bound to the current
patient: guideline search (RAG, doses stripped), ЕМИАС record mock (diagnoses, comorbidities, intolerances,
other specialists' drugs, current prescription), drug info (composition, contraindications, interactions)
and a deterministic patient-specific check. Providers: OpenRouter (temporary, synthetic data only) and an
offline mock; GigaChat to follow. Scrubbed input only, forbidden-output filter, any failure falls back to
the existing extractive RAG / instruction answers. Off unless OPENROUTER_API_KEY or CHAT_AGENT=mock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 14:39:16 +03:00
co-authored by Claude Opus 5.5
parent 2f1f8a8f24
commit 6dda5f9b30
14 changed files with 1253 additions and 2 deletions
+5
View File
@@ -6,6 +6,11 @@ GIGACHAT_CREDENTIALS=
GIGACHAT_SCOPE=GIGACHAT_API_PERS
GIGACHAT_MODEL=GigaChat
# --- Chat agent: auto | off | mock | openrouter | gigachat. OpenRouter = temporary, synthetic data only.
CHAT_AGENT=auto
OPENROUTER_API_KEY=
OPENROUTER_MODEL=anthropic/claude-sonnet-4.5
# --- Pseudonymisation key: 32+ random bytes, hex. Generate: python -c "import secrets;print(secrets.token_hex(32))"
PSEUDO_KEY=
+10
View File
@@ -28,6 +28,16 @@ class Settings(BaseSettings):
gigachat_model: str = "GigaChat"
gigachat_verify_ssl: bool = False
# Chat agent (tool-using LLM over RAG + patient record). `auto` = OpenRouter if a key is set, else off
# (extractive RAG / instruction answers stay). OpenRouter is a TEMPORARY prototype provider on synthetic
# data only — prod must use GigaChat (152-FZ, foreign services are forbidden for real PII).
chat_agent: Literal["auto", "off", "mock", "openrouter", "gigachat"] = "auto"
openrouter_api_key: str = ""
openrouter_model: str = "anthropic/claude-sonnet-4.5"
openrouter_base_url: str = "https://openrouter.ai/api/v1"
chat_agent_timeout_s: float = 25.0
chat_agent_max_steps: int = 5
# Telegram is used only for the prototype on synthetic data; prod channel is MAX / Gosuslugi.
tg_bot_token: str = ""
doctor_notify_chat_id: int | None = None
+6 -1
View File
@@ -21,7 +21,7 @@ from app.config import get_settings
from app.dialog.contracts import Event, Reply
from app.dialog.flows.core import menu_reply
from app.dialog.fsm import Ctx, Step, abandonable, handler_for, state, trigger
from app.llm import rag
from app.llm import agent, rag
from app.llm.med_qa import DOSE_QUESTION
log = logging.getLogger(__name__)
@@ -74,6 +74,11 @@ async def knowledge_answer(ctx: Ctx, *, explicit: bool) -> str | None:
if DOSE_QUESTION.search(ctx.text.lower()):
log.info("kb: dose question redirected to doctor")
return load_knowledge_texts().dose_redirect
# Tool-using agent first (RAG + ЕМИАС record + drug-safety check); disabled/failed → extractive RAG below.
smart = await agent.answer_chat(ctx.token, ctx.text)
if smart is not None:
log.info("kb: agent answered provider=%s tools=%s", smart.provider, ",".join(smart.tools))
return agent.render(smart)
reply = await rag.answer(ctx.text, audience="patient")
if reply is not None:
log.info("kb: answered docs=%s", ",".join(c.doc for c in reply.citations))
+7 -1
View File
@@ -42,7 +42,8 @@ from app.dialog.contracts import Button, Event, Reply
from app.dialog.flows.core import menu_reply
from app.dialog.fsm import Ctx, Step, abandonable, get_trigger, handler_for, state, trigger, trigger_payload
from app.integrations.base import integrations
from app.llm.med_qa import fallback_text, med_qa
from app.llm import agent
from app.llm.med_qa import DOSE_QUESTION, fallback_text, med_qa
QUESTION_STATE = "meds.question"
SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text"
@@ -435,6 +436,11 @@ async def answer_question(ctx: Ctx, rx: Rx | None) -> str:
"""Answer from the instruction excerpt only (SPEC §7 п.5); dose questions and gaps → «уточните у врача»."""
if rx is None or ctx.text is None:
return load_meds_texts().question.no_prescription
if DOSE_QUESTION.search(ctx.text.lower()) is None:
# Agent: instruction + ЕМИАС record (other drugs, conditions, intolerances) + guidelines; else excerpt only.
smart = await agent.answer_chat(ctx.token, ctx.text)
if smart is not None:
return agent.render(smart)
answer = await med_qa(rx.drug, ctx.text)
return answer.text if answer is not None else fallback_text()
+94
View File
@@ -0,0 +1,94 @@
"""ЕМИАС medical record mock: diagnoses, comorbidities, allergies and other specialists' prescriptions.
Prod: the regional МИС / ЕГИСЗ adapter replaces `EmiasRecordSource` (same `get(token)` contract). Demo: synthetic
records from `data/emias/records.yaml` keyed by synthetic SNILS, converted to tokens with `pseudo_id()` at load
time — so the record handed to the chat agent carries no SNILS, name or phone (guardrail #1). The current
psychiatric prescription comes from the МИС mock (`integrations.prescriptions`).
"""
import logging
from functools import lru_cache
from pathlib import Path
import yaml
from pydantic import BaseModel, Field
from app.config import get_settings
from app.core.privacy import pseudo_id
from app.integrations.base import integrations
log = logging.getLogger(__name__)
class Diagnosis(BaseModel):
code: str
name: str
class Condition(BaseModel):
name: str
tags: list[str] = Field(default_factory=list)
class OtherMedication(BaseModel):
drug: str
how: str = ""
prescribed_by: str = ""
classes: list[str] = Field(default_factory=list)
class CurrentPrescription(BaseModel):
drug: str
dose: str
instructions: str = ""
class PatientRecord(BaseModel):
"""Clinical-only view of the record (no identifiers): what the chat agent may see."""
diagnoses: list[Diagnosis] = Field(default_factory=list)
conditions: list[Condition] = Field(default_factory=list)
allergies: list[Condition] = Field(default_factory=list)
other_medications: list[OtherMedication] = Field(default_factory=list)
pregnancy: dict[str, str] | None = None
current_prescriptions: list[CurrentPrescription] = Field(default_factory=list)
def default_records_path() -> Path:
return get_settings().data_dir / "emias" / "records.yaml"
@lru_cache
def _load(path: Path) -> dict[str, PatientRecord]:
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
out: dict[str, PatientRecord] = {}
for snils, rec in (raw.get("records") or {}).items():
out[pseudo_id(snils)] = PatientRecord.model_validate(rec)
return out
class EmiasRecordSource:
def __init__(self, path: Path | None = None) -> None:
self._path = path
async def get(self, token: str) -> PatientRecord | None:
try:
base = _load(self._path or default_records_path()).get(token)
except (OSError, yaml.YAMLError, ValueError) as exc:
log.error("emias records unavailable: %s", type(exc).__name__)
base = None
record = base.model_copy(deep=True) if base is not None else PatientRecord()
try:
rx = await integrations.prescriptions.get_prescriptions(token)
except Exception as exc: # noqa: BLE001 — the МИС being down must not break the chat
log.error("prescriptions unavailable: %s", type(exc).__name__)
rx = []
record.current_prescriptions = [
CurrentPrescription(drug=p.drug, dose=p.dose, instructions=p.instructions) for p in rx
]
if base is None and not rx:
return None
return record
emias_records = EmiasRecordSource()
+135
View File
@@ -0,0 +1,135 @@
"""Chat agent: tool-using LLM over the knowledge base (RAG), the ЕМИАС record and the drug-safety reference.
from app.llm.agent import answer_chat
reply = await answer_chat(token, scrubbed_question) # None → caller falls back to extractive RAG / med_qa
Guarantees (on top of what the dialog engine already did — scrub, consent, safety check; ACUTE never gets here):
* only scrubbed text goes to the provider (`assert_scrubbed`), tool results carry no identifiers;
* tools are bound to the current token server-side; read-only;
* the final text passes the gateway's forbidden-output filter (diagnoses, dose/drug advice); a violation or any
provider error → None, so the patient gets the safe extractive answer instead;
* logs carry tool names and counts only.
"""
import asyncio
import json
import logging
from typing import Any
from pydantic import BaseModel
from app.config import Settings, get_settings
from app.core.types import ScrubbedText
from app.llm.agent.prompts import SYSTEM_PROMPT
from app.llm.agent.providers import (
MockAgentProvider,
OpenRouterProvider,
ProviderError,
ToolChatProvider,
)
from app.llm.agent.tools import TOOL_SPECS, ToolContext, run_tool
from app.llm.gateway import is_forbidden
from app.llm.guard import assert_scrubbed
log = logging.getLogger(__name__)
MAX_ANSWER_CHARS = 1200
_ALLOWED_MSG_KEYS = {"role", "content", "tool_calls", "tool_call_id"}
class AgentAnswer(BaseModel):
text: str
sources: list[str]
tools: list[str]
provider: str
def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
s = settings or get_settings()
mode = s.chat_agent
if mode == "auto":
mode = "openrouter" if s.openrouter_api_key else "off"
if mode == "openrouter":
if not s.openrouter_api_key:
log.warning("chat agent: openrouter selected without OPENROUTER_API_KEY — disabled")
return None
return OpenRouterProvider(s)
if mode == "mock":
return MockAgentProvider()
if mode == "gigachat":
# TODO(GigaChat): function calling via the `gigachat` package (functions=..., message.function_call).
log.warning("chat agent: gigachat provider not implemented yet — disabled")
return None
return None
def _wire(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Strip internal keys (tool `name`) before sending."""
return [{k: v for k, v in m.items() if k in _ALLOWED_MSG_KEYS} for m in messages]
async def answer_chat(
token: str, question: ScrubbedText, *, provider: ToolChatProvider | None = None, settings: Settings | None = None
) -> AgentAnswer | None:
s = settings or get_settings()
provider = provider or build_provider(s)
if provider is None:
return None
try:
assert_scrubbed(question)
except ValueError:
log.warning("chat agent: unscrubbed input rejected")
return None
ctx = ToolContext(token=token)
messages: list[dict[str, Any]] = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": str(question)},
]
try:
async with asyncio.timeout(s.chat_agent_timeout_s * 2):
for _ in range(s.chat_agent_max_steps):
turn = await provider.chat(_wire(messages) if provider.name != "mock" else messages, TOOL_SPECS)
if not turn.tool_calls:
return _finish(turn.content or "", ctx, provider.name)
messages.append(
{
"role": "assistant",
"content": turn.content,
"tool_calls": [
{
"id": c.id,
"type": "function",
"function": {"name": c.name, "arguments": json.dumps(c.arguments, ensure_ascii=False)},
}
for c in turn.tool_calls
],
}
)
for c in turn.tool_calls:
result = await run_tool(ctx, c.name, c.arguments)
messages.append({"role": "tool", "tool_call_id": c.id, "name": c.name, "content": result})
log.warning("chat agent: step limit reached tools=%s", ",".join(ctx.used))
return None
except (ProviderError, TimeoutError) as exc:
log.error("chat agent provider=%s failed: %s", provider.name, type(exc).__name__ or "timeout")
return None
def _finish(text: str, ctx: ToolContext, provider: str) -> AgentAnswer | None:
text = text.strip()
if not text:
return None
if is_forbidden(text):
log.warning("chat agent: forbidden output filtered provider=%s", provider)
return None
if len(text) > MAX_ANSWER_CHARS:
text = text[:MAX_ANSWER_CHARS].rsplit(" ", 1)[0] + "…"
log.info("chat agent ok provider=%s tools=%s sources=%d", provider, ",".join(ctx.used), len(ctx.sources))
return AgentAnswer(text=text, sources=ctx.sources[:4], tools=ctx.used, provider=provider)
def render(answer: AgentAnswer) -> str:
"""Patient-facing text: answer + short sources footer."""
if not answer.sources:
return answer.text
return f"{answer.text}\n\nИсточники: " + "; ".join(answer.sources)
+59
View File
@@ -0,0 +1,59 @@
"""Try the chat agent from the shell (synthetic personas only):
uv run python -m app.llm.agent "Можно мне ибупрофен вместе с моими таблетками?" --persona andrey
CHAT_AGENT=mock uv run python -m app.llm.agent "Есть ли лактоза в сертралине?"
Prescriptions come from the МИС mock (DB if reachable, else data/meds/mis_prescriptions.json); the question is
scrubbed before it reaches any provider.
"""
import argparse
import asyncio
import logging
from app.config import get_settings
from app.core.privacy import pseudo_id, scrub
from app.integrations.base import Prescription, integrations
from app.llm.agent import answer_chat, build_provider, render
PERSONAS = { # synthetic SNILS from scripts/seed_demo.py → current psychiatric prescription
"andrey": ("900-000-013 34", "сертралин"),
"dmitry": ("900-000-041 01", "сертралин"),
"sergey": ("900-000-037 02", "пароксетин"),
"nikolay": ("900-000-052 03", "сертралин"),
"anna": ("900-000-023 04", "флуоксетин"),
"pavel": ("900-000-045 07", "эсциталопрам"),
}
class _FixedRx:
def __init__(self, drug: str) -> None:
self.drug = drug
async def get_prescriptions(self, token: str) -> list[Prescription]:
return [Prescription(drug=self.drug, dose="по назначению врача")]
async def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("question")
p.add_argument("--persona", default="andrey", choices=sorted(PERSONAS))
p.add_argument("-v", "--verbose", action="store_true")
a = p.parse_args()
logging.basicConfig(level=logging.INFO if a.verbose else logging.WARNING)
snils, drug = PERSONAS[a.persona]
integrations.prescriptions = _FixedRx(drug) # CLI runs without the DB
provider = build_provider()
if provider is None:
raise SystemExit(
f"agent disabled (CHAT_AGENT={get_settings().chat_agent}); set OPENROUTER_API_KEY or CHAT_AGENT=mock"
)
ans = await answer_chat(pseudo_id(snils), scrub(a.question), provider=provider)
if ans is None:
raise SystemExit("no answer (provider error, filtered output or nothing found) — chat falls back to RAG")
print(render(ans))
print(f"\n[provider={ans.provider} tools={', '.join(ans.tools)}]")
if __name__ == "__main__":
asyncio.run(main())
+147
View File
@@ -0,0 +1,147 @@
"""Deterministic drug-safety lookup: composition, contraindications, interactions, patient-specific findings.
The LLM never decides whether a drug is safe for the patient (ADR-007 spirit): it calls `check_patient` and
retells the findings. Matching is by tags shared with the ЕМИАС record (`data/emias/records.yaml`):
condition tags → `cautions[].when_condition`, allergy tags → `composition.excipient_tags`, other medication
classes → `interactions[].with_class`. Source data: `data/meds/drug_safety.yaml` (prototype retelling of ГРЛС).
"""
from functools import lru_cache
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, Field
from app.config import get_settings
from app.integrations.emias_record import PatientRecord
Severity = Literal["high", "medium", "low"]
class Caution(BaseModel):
when_condition: str
severity: Severity
text: str
class Interaction(BaseModel):
with_class: str
severity: Severity
text: str
class Composition(BaseModel):
form: str
active: str
excipients: list[str] = Field(default_factory=list)
excipient_tags: list[str] = Field(default_factory=list)
note: str = ""
class DrugEntry(BaseModel):
inn: str
aliases: list[str] = Field(default_factory=list)
drug_class: str = ""
classes: list[str] = Field(default_factory=list) # classes this drug belongs to (e.g. nsaid, ssri)
contraindications: list[str] = Field(default_factory=list)
cautions: list[Caution] = Field(default_factory=list)
interactions: list[Interaction] = Field(default_factory=list)
composition: Composition | None = None
pregnancy: str = ""
source: str = ""
class Finding(BaseModel):
severity: Severity
kind: Literal["condition", "allergy", "interaction", "pregnancy", "contraindication"]
text: str
class SafetyDb(BaseModel):
drugs: dict[str, DrugEntry]
allergy_labels: dict[str, str] = Field(default_factory=dict)
def _path() -> Path:
return get_settings().data_dir / "meds" / "drug_safety.yaml"
@lru_cache
def load_db(path: Path | None = None) -> SafetyDb:
raw = yaml.safe_load((path or _path()).read_text(encoding="utf-8"))
classes_of: dict[str, list[str]] = {}
for cls, members in (raw.get("drug_classes") or {}).items():
for m in members:
classes_of.setdefault(m, []).append(cls)
drugs: dict[str, DrugEntry] = {}
for inn, d in (raw.get("drugs") or {}).items():
entry = DrugEntry(
inn=inn,
aliases=d.get("aliases", []),
drug_class=d.get("class", ""),
classes=sorted(set(d.get("classes", [])) | set(classes_of.get(inn, []))),
contraindications=d.get("contraindications", []),
cautions=d.get("cautions", []),
interactions=d.get("interactions", []),
composition=d.get("composition"),
pregnancy=d.get("pregnancy", ""),
source=d.get("source", ""),
)
drugs[inn] = entry
return SafetyDb(drugs=drugs, allergy_labels=raw.get("allergy_labels") or {})
def find_drug(name: str, db: SafetyDb | None = None) -> DrugEntry | None:
"""By INN or alias, case-insensitive; "сертралин 50 мг" → сертралин."""
db = db or load_db()
words = [w.strip(".,!?;:«»()") for w in name.lower().replace("ё", "е").split()]
for entry in db.drugs.values():
names = {entry.inn, *(a.lower() for a in entry.aliases)}
# Russian case endings: "сертралине", "ибупрофена" — match on the stem (name minus the last letter).
if any(w in names or any(len(n) > 4 and w.startswith(n[:-1]) for n in names) for w in words):
return entry
return None
def check_patient(drug: DrugEntry, record: PatientRecord, db: SafetyDb | None = None) -> list[Finding]:
"""Patient-specific findings for `drug`, most severe first. Empty list = nothing found in the reference —
which is NOT a statement that the drug is safe (the answer must say so)."""
db = db or load_db()
out: list[Finding] = []
cond_tags = {t for c in record.conditions for t in c.tags}
for c in drug.cautions:
if c.when_condition in cond_tags:
out.append(Finding(severity=c.severity, kind="condition", text=c.text))
# Excipients vs allergies / intolerances.
allergy_tags = {t for a in record.allergies for t in a.tags}
if drug.composition is not None:
for tag in sorted(allergy_tags & set(drug.composition.excipient_tags)):
label = db.allergy_labels.get(tag, tag)
out.append(
Finding(
severity="high", kind="allergy", text=f"В составе есть {label}, а в карте отмечена непереносимость."
)
)
# Interactions with the patient's other medicines and with the current psychiatric prescription.
other_classes: dict[str, str] = {}
for m in record.other_medications:
for cls in m.classes:
other_classes[cls] = m.drug
for rx in record.current_prescriptions:
entry = find_drug(rx.drug, db)
if entry is not None and entry.inn != drug.inn:
for cls in entry.classes:
other_classes.setdefault(cls, rx.drug)
for i in drug.interactions:
if i.with_class in other_classes:
out.append(
Finding(
severity=i.severity, kind="interaction", text=f"{i.text} (в карте: {other_classes[i.with_class]})."
)
)
if record.pregnancy and drug.pregnancy:
out.append(Finding(severity="medium", kind="pregnancy", text=drug.pregnancy))
order = {"high": 0, "medium": 1, "low": 2}
out.sort(key=lambda f: order[f.severity])
return out
+26
View File
@@ -0,0 +1,26 @@
"""System prompt of the chat agent (Russian). Prohibitions follow SPEC §7; grounding: answer only from tools."""
SYSTEM_PROMPT = """Ты — помощник пациента в сервисе сопровождения лечения между визитами к врачу. Пациент лечится у
психиатра; ты отвечаешь на его вопросы о состоянии, лечении и лекарствах.
Как работать:
1. Сначала собери данные инструментами. Для вопросов о лекарствах, сочетаниях, составе, аллергиях всегда
вызывай get_patient_record и check_drug_for_patient (для каждого упомянутого препарата и, если вопрос
о «моих таблетках», для текущего назначения). Для общих вопросов о лечении вызывай search_guidelines.
2. Отвечай ТОЛЬКО на основе результатов инструментов. Если в них нет ответа — честно скажи, что в
рекомендациях и инструкции этого нет, и предложи уточнить у лечащего врача. Ничего не додумывай.
3. Если check_drug_for_patient вернул находки — назови их простыми словами и посоветуй обсудить с врачом
до приёма. Пустой список не значит «безопасно».
4. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату»,
«в вашей карте указано».
Запрещено:
- ставить или называть диагнозы («у вас депрессия»), даже если они есть в карте;
- назначать, отменять препараты, менять дозы, советовать «примите/не принимайте» — решение всегда за врачом;
- называть дозировки в миллиграммах;
- проводить психотерапию; обещать конфиденциальность сверх реальной;
- выдумывать факты, которых нет в результатах инструментов.
Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков.
Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон
доверия 8-800-2000-122, и что врач будет уведомлён."""
+176
View File
@@ -0,0 +1,176 @@
"""Tool-calling chat providers for the agent.
* `OpenRouterProvider` — OpenAI-compatible `/chat/completions` with `tools`. TEMPORARY for the prototype on
synthetic data: OpenRouter is a foreign service, forbidden for real PII (152-FZ art. 18 p. 5). Replace with
GigaChat (function calling) before any real patient — same `ToolChatProvider` contract.
* `MockAgentProvider` — offline, deterministic: plans tool calls from keywords and composes an answer from the
tool results. Keeps the demo working without network (ADR-004).
"""
import json
import logging
import re
from typing import Any, Protocol
import httpx
from pydantic import BaseModel, Field
from app.config import Settings
log = logging.getLogger(__name__)
class ToolCall(BaseModel):
id: str
name: str
arguments: dict[str, Any] = Field(default_factory=dict)
class AssistantTurn(BaseModel):
content: str | None = None
tool_calls: list[ToolCall] = Field(default_factory=list)
class ToolChatProvider(Protocol):
name: str
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn: ...
class ProviderError(RuntimeError):
pass
class OpenRouterProvider:
name = "openrouter"
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
self._key = settings.openrouter_api_key
self._model = settings.openrouter_model
self._url = settings.openrouter_base_url.rstrip("/") + "/chat/completions"
self._timeout = settings.chat_agent_timeout_s
self._client = client
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
body = {"model": self._model, "messages": messages, "tools": tools, "temperature": 0.1, "max_tokens": 900}
headers = {"Authorization": f"Bearer {self._key}", "X-Title": "PTSRaiAgent prototype (synthetic data)"}
try:
if self._client is not None:
resp = await self._client.post(self._url, json=body, headers=headers, timeout=self._timeout)
else:
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(self._url, json=body, headers=headers)
except httpx.HTTPError as exc:
raise ProviderError(type(exc).__name__) from exc
if resp.status_code != 200:
raise ProviderError(f"http {resp.status_code}")
try:
msg = resp.json()["choices"][0]["message"]
except (ValueError, KeyError, IndexError) as exc:
raise ProviderError("bad response") from exc
calls = []
for c in msg.get("tool_calls") or []:
fn = c.get("function") or {}
try:
args = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
args = {}
calls.append(ToolCall(id=c.get("id") or fn.get("name", "call"), name=fn.get("name", ""), arguments=args))
usage = resp.json().get("usage") or {}
log.info("openrouter ok calls=%d tokens=%s", len(calls), usage.get("total_tokens"))
return AssistantTurn(content=msg.get("content"), tool_calls=calls)
# ------------------------------------------------------------------------------------------------ offline mock
_DRUG_WORDS = re.compile(
r"\b(сертралин\w*|пароксетин\w*|эсциталопрам\w*|флуоксетин\w*|ибупрофен\w*|нурофен\w*|золофт\w*)", re.I
)
_MED_HINT = re.compile(r"(таблет|лекарств|препарат|состав|можно ли|совмест|вместе|противопоказ|аллерг|лактоз)", re.I)
class MockAgentProvider:
"""Plans: record → (drug check for each mentioned drug, or the current prescription for medicine questions)
→ guidelines search; then writes a short answer strictly from the tool results."""
name = "mock"
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
question = next(m["content"] for m in reversed(messages) if m["role"] == "user")
done = [m for m in messages if m["role"] == "tool"]
if not done:
drugs = [m.group(1).lower() for m in _DRUG_WORDS.finditer(question)]
calls = [ToolCall(id="c0", name="get_patient_record")]
calls += [
ToolCall(id=f"d{i}", name="check_drug_for_patient", arguments={"drug": d}) for i, d in enumerate(drugs)
]
if drugs and re.search(r"состав|лактоз|желатин|аллерг|непереносим", question, re.I):
calls += [
ToolCall(id=f"i{i}", name="get_drug_info", arguments={"drug": d}) for i, d in enumerate(drugs)
]
calls.append(ToolCall(id="s0", name="search_guidelines", arguments={"query": question}))
return AssistantTurn(tool_calls=calls)
mine = re.search(r"мо(и|ими|их|ей|я|ё) (таблет|лекарств|препарат)", question, re.I)
checked = [m for m in done if m.get("name") == "check_drug_for_patient"]
if (not checked or mine) and len(checked) < 3 and _MED_HINT.search(question):
record = _tool_json(done, "get_patient_record")
rx = (record or {}).get("current_prescriptions") or []
checked_args = {str(json.loads(m["content"]).get("inn", "")) for m in checked}
rx = [r for r in rx if not any(r["drug"].lower().startswith(c[:-1]) for c in checked_args if c)]
if rx:
return AssistantTurn(
tool_calls=[ToolCall(id="d9", name="check_drug_for_patient", arguments={"drug": rx[0]["drug"]})]
)
return AssistantTurn(content=_compose(done))
def _tool_json(done: list[dict[str, Any]], name: str) -> dict[str, Any] | None:
for m in done:
if m.get("name") == name:
try:
return json.loads(m["content"])
except json.JSONDecodeError:
return None
return None
def _compose(done: list[dict[str, Any]]) -> str:
parts: list[str] = []
seen: set[str] = set()
for m in done:
if m.get("name") != "check_drug_for_patient":
continue
data = json.loads(m["content"])
if not data.get("found") or data["inn"] in seen:
continue
seen.add(data["inn"])
findings = data.get("findings") or []
if findings:
parts.append(f"По вашей карте и инструкции к препарату «{data['inn']}» есть важное:")
parts += [f"• {f['text']}" for f in findings[:3]]
else:
parts.append(f"В справочнике не нашлось особых ограничений по «{data['inn']}» с учётом вашей карты.")
for m in done:
if m.get("name") != "get_drug_info":
continue
data = json.loads(m["content"])
comp = data.get("composition") if data.get("found") else None
if comp:
record = _tool_json(done, "get_patient_record") or {}
allergies = record.get("allergies") or []
if allergies:
names = ", ".join(a["name"] for a in allergies)
hit = {t for a in allergies for t in a.get("tags", [])} & set(comp.get("excipient_tags", []))
verdict = "это вещество есть" if hit else "этого вещества нет, но состав зависит от производителя"
parts.append(f"В вашей карте: {names}. В примере состава «{data['inn']}» {verdict}.")
excipients = ", ".join(comp["excipients"])
parts.append(f"Состав ({comp['form']}): {comp['active']}; вспомогательные: {excipients}. {comp['note']}")
search = _tool_json(done, "search_guidelines") or {}
results = search.get("results") or []
if results and not parts: # medicine findings already answer the question; a KR quote would dilute it
text = results[0]["text"].strip()
parts.append(f"В клинических рекомендациях: «{text[:400]}{'…' if len(text) > 400 else ''}»")
if not parts:
return ""
parts.append("Любые изменения в лечении обсуждайте с вашим врачом.")
return "\n".join(parts)
+179
View File
@@ -0,0 +1,179 @@
"""Read-only tools of the chat agent. Every tool is bound to the current patient's token on the server side:
the model never passes a token, so it cannot read another patient's data (medical secrecy, 323-FZ art. 13).
Tool results contain clinical data only (no name, SNILS, phone) — they are sent to the LLM provider.
"""
import json
import logging
import re
from dataclasses import dataclass, field
from typing import Any
from app.integrations.emias_record import PatientRecord, emias_records
from app.llm.agent.drug_safety import check_patient, find_drug, load_db
from app.llm.rag import retrieve
from app.llm.rag.index import sentences
log = logging.getLogger(__name__)
RAG_TOP_K = 4
PASSAGE_CHARS = 700
# Patients never get dosages (SPEC §7): sentences with doses are dropped before the text reaches the LLM.
_DOSE = re.compile(r"\d\s*(?:-|–)?\s*\d*\s*(?:мг|mg|мкг|г/сут|таблетк)|\bдоз(?:а|у|ы|е|ой|ировк)", re.I)
TOOL_SPECS: list[dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "search_guidelines",
"description": (
"Поиск по базе знаний: клинические рекомендации Минздрава РФ (депрессия 2024, ПТСР 2023). "
"Возвращает фрагменты с источником и страницей. Используй для любых общих вопросов о состоянии, "
"лечении, сроках действия терапии, побочных эффектах, отмене, образе жизни."
),
"parameters": {
"type": "object",
"properties": {"query": {"type": "string", "description": "Поисковый запрос на русском"}},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "get_patient_record",
"description": (
"Медицинская карта текущего пациента из ЕМИАС: диагнозы (МКБ-10), сопутствующие заболевания, "
"аллергии и непереносимости, назначения других врачей и текущее назначение психиатра. "
"Вызывай, когда ответ зависит от особенностей пациента или его лекарств."
),
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "get_drug_info",
"description": (
"Справка о препарате по инструкции ГРЛС: состав (действующее и вспомогательные вещества), "
"противопоказания, взаимодействия, беременность. Аргумент — название препарата (МНН или торговое)."
),
"parameters": {
"type": "object",
"properties": {"drug": {"type": "string"}},
"required": ["drug"],
},
},
},
{
"type": "function",
"function": {
"name": "check_drug_for_patient",
"description": (
"Детерминированная проверка препарата для текущего пациента: сопутствующие заболевания, "
"непереносимости по составу, взаимодействия с другими его лекарствами. Используй перед любым "
"ответом о том, подходит ли пациенту препарат или сочетание препаратов."
),
"parameters": {
"type": "object",
"properties": {"drug": {"type": "string"}},
"required": ["drug"],
},
},
},
]
@dataclass
class ToolContext:
token: str
sources: list[str] = field(default_factory=list) # citations collected for the answer footer
used: list[str] = field(default_factory=list)
_record: PatientRecord | None = None
_record_loaded: bool = False
async def record(self) -> PatientRecord | None:
if not self._record_loaded:
self._record = await emias_records.get(self.token)
self._record_loaded = True
return self._record
def cite(self, label: str) -> None:
if label and label not in self.sources:
self.sources.append(label)
async def run_tool(ctx: ToolContext, name: str, args: dict[str, Any]) -> str:
"""Execute a tool; always returns a JSON string (errors included) so the model can recover."""
ctx.used.append(name)
log.info("agent tool=%s", name) # no arguments: they may echo patient text
try:
if name == "search_guidelines":
return _json(_search(ctx, str(args.get("query", ""))))
if name == "get_patient_record":
rec = await ctx.record()
if rec is None:
return _json({"found": False})
ctx.cite("ЕМИАС: медицинская карта")
return _json({"found": True, **rec.model_dump()})
if name == "get_drug_info":
return _json(_drug_info(ctx, str(args.get("drug", ""))))
if name == "check_drug_for_patient":
return _json(await _check(ctx, str(args.get("drug", ""))))
return _json({"error": f"unknown tool {name}"})
except Exception as exc: # noqa: BLE001 — a tool failure must not crash the chat
log.error("agent tool=%s failed: %s", name, type(exc).__name__)
return _json({"error": "tool failed"})
def _search(ctx: ToolContext, query: str) -> dict[str, Any]:
hits = retrieve(query, audience="patient", top_k=RAG_TOP_K) if query.strip() else []
out = []
for h in hits:
label = h.chunk.citation.strip("[]")
ctx.cite(label)
text = " ".join(x for x in sentences(h.passage) if not _DOSE.search(x))[:PASSAGE_CHARS]
if text:
out.append({"source": label, "section": h.chunk.section, "text": text})
return {"results": out, "found": bool(out)}
def _drug_info(ctx: ToolContext, name: str) -> dict[str, Any]:
entry = find_drug(name)
if entry is None:
return {"found": False, "drug": name}
ctx.cite(entry.source)
return {
"found": True,
"inn": entry.inn,
"class": entry.drug_class,
"composition": entry.composition.model_dump() if entry.composition else None,
"contraindications": entry.contraindications,
"interactions": [i.text for i in entry.interactions],
"pregnancy": entry.pregnancy,
"source": entry.source,
}
async def _check(ctx: ToolContext, name: str) -> dict[str, Any]:
entry = find_drug(name)
if entry is None:
return {"found": False, "drug": name, "note": "Препарата нет в справочнике — отвечать «уточните у врача»."}
rec = await ctx.record()
if rec is None:
return {"found": True, "inn": entry.inn, "record": False, "findings": []}
ctx.cite(entry.source)
ctx.cite("ЕМИАС: медицинская карта")
findings = check_patient(entry, rec, load_db())
return {
"found": True,
"inn": entry.inn,
"record": True,
"findings": [f.model_dump() for f in findings],
"note": "Пустой список не означает, что препарат безопасен: решение о приёме принимает врач.",
}
def _json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False)
+92
View File
@@ -0,0 +1,92 @@
# Мок электронной медицинской карты ЕМИАС (интеграция ЕМИАС/ЕГИСЗ — адаптер, SPEC §9). ТОЛЬКО СИНТЕТИКА.
# Ключ — синтетический СНИЛС из scripts/seed_demo.py; в рантайме превращается в токен через pseudo_id(), так что
# в чат-агента не попадают ни СНИЛС, ни ФИО: только клинические поля ниже.
#
# Поля:
# diagnoses — диагнозы МКБ-10 из карты (ставит врач; агент их не озвучивает как «диагноз», только учитывает)
# conditions — сопутствующие состояния и анамнез; `tags` сверяются с data/meds/drug_safety.yaml
# allergies — аллергии и непереносимости; `tags` сверяются с составом препаратов (вспомогательные вещества)
# other_medications — назначения других специалистов; `classes` сверяются со взаимодействиями
# pregnancy — для женщин: беременность/лактация (null — нет данных)
# Текущее психотропное назначение приходит из таблицы prescriptions (мок МИС, app/integrations/mis_mock.py).
records:
"900-000-013 34": # Андрей С., 34, ветеран СВО — герой демо
diagnoses:
- {code: "F43.1", name: "Посттравматическое стрессовое расстройство"}
- {code: "F32.1", name: "Депрессивный эпизод средней степени"}
- {code: "T93.2", name: "Последствия осколочного ранения нижней конечности"}
conditions:
- {name: "Язвенная болезнь желудка в анамнезе (2019), ремиссия", tags: [ulcer_history]}
- {name: "Хронический болевой синдром после ранения", tags: [chronic_pain]}
allergies:
- {name: "Непереносимость лактозы", tags: [lactose]}
other_medications:
- {drug: "ибупрофен", how: "по требованию при боли", prescribed_by: "хирург", classes: [nsaid]}
pregnancy: null
"900-000-041 01": # Дмитрий В., 41, ветеран
diagnoses:
- {code: "F43.1", name: "Посттравматическое стрессовое расстройство"}
conditions:
- {name: "Артериальная гипертензия", tags: [hypertension]}
allergies: []
other_medications:
- {drug: "амлодипин", how: "ежедневно", prescribed_by: "терапевт", classes: [antihypertensive]}
pregnancy: null
"900-000-037 02": # Сергей П., 37, ветеран
diagnoses:
- {code: "F43.1", name: "Посттравматическое стрессовое расстройство"}
conditions:
- {name: "Мигрень", tags: [migraine]}
allergies: []
other_medications:
- {drug: "суматриптан", how: "при приступе мигрени", prescribed_by: "невролог", classes: [triptan]}
pregnancy: null
"900-000-052 03": # Николай Ф., 52, ветеран
diagnoses:
- {code: "F43.1", name: "Посттравматическое стрессовое расстройство"}
- {code: "F10.1", name: "Пагубное употребление алкоголя"}
conditions:
- {name: "Фибрилляция предсердий", tags: [atrial_fibrillation]}
allergies: []
other_medications:
- {drug: "варфарин", how: "ежедневно под контролем МНО", prescribed_by: "кардиолог", classes: [anticoagulant]}
pregnancy: null
"900-000-023 04": # Анна Л., 23
diagnoses:
- {code: "F32.1", name: "Депрессивный эпизод средней степени"}
conditions: []
allergies: []
other_medications: []
pregnancy: {status: "не беременна, планирует беременность в течение года"}
"900-000-034 05": # Ирина К., 34
diagnoses:
- {code: "F32.1", name: "Депрессивный эпизод средней степени"}
conditions: []
allergies: []
other_medications: []
pregnancy: null
"900-000-029 06": # Ольга Н., 29
diagnoses:
- {code: "F32.1", name: "Депрессивный эпизод средней степени"}
- {code: "F41.1", name: "Генерализованное тревожное расстройство"}
conditions: []
allergies: []
other_medications: []
pregnancy: null
"900-000-045 07": # Павел Р., 45
diagnoses:
- {code: "F33.4", name: "Рекуррентное депрессивное расстройство, ремиссия"}
conditions:
- {name: "Эпилепсия, ремиссия 5 лет", tags: [epilepsy]}
allergies: []
other_medications:
- {drug: "вальпроевая кислота", how: "ежедневно", prescribed_by: "невролог", classes: [anticonvulsant]}
pregnancy: null
+146
View File
@@ -0,0 +1,146 @@
# Структурированный справочник безопасности препаратов для чат-агента (состав, противопоказания, взаимодействия).
# ПРОТОТИП: краткий пересказ инструкций по медицинскому применению (ГРЛС), сверить с действующей инструкцией и
# утвердить у психиатра (#8). Состав (вспомогательные вещества) зависит от производителя — здесь пример для
# формы из назначения; агент обязан это оговаривать.
#
# Теги совпадают с data/emias/records.yaml:
# condition tags (conditions[].tags) → cautions[].when_condition
# allergy tags (allergies[].tags) → composition.excipient_tags
# medicine class (other_medications.classes) → interactions[].with_class
# Детерминированная проверка — app/llm/agent/drug_safety.py; LLM только пересказывает её результат.
common_ssri: &ssri
contraindications:
- "Повышенная чувствительность к компонентам препарата."
- "Одновременный приём ингибиторов МАО и 14 дней после их отмены (риск серотонинового синдрома)."
cautions:
- when_condition: ulcer_history
severity: high
text: "Язвенная болезнь в анамнезе: СИОЗС повышают риск желудочно-кишечных кровотечений, особенно вместе с НПВС или антикоагулянтами."
- when_condition: epilepsy
severity: medium
text: "Эпилепсия: препарат применяют с осторожностью, при появлении судорог его отменяет врач."
- when_condition: bipolar
severity: high
text: "Биполярное расстройство: возможен переход в маниакальную фазу, нужен контроль врача."
interactions:
- with_class: nsaid
severity: high
text: "НПВС (ибупрофен, диклофенак, кеторолак и др.) вместе с СИОЗС повышают риск кровотечений, прежде всего желудочно-кишечных."
- with_class: anticoagulant
severity: high
text: "Антикоагулянты (варфарин и др.): возрастает риск кровотечений, нужен более частый контроль свёртываемости."
- with_class: triptan
severity: medium
text: "Триптаны (суматриптан и др.): описан риск серотонинового синдрома, о сочетании должен знать врач."
- with_class: serotonergic_opioid
severity: high
text: "Трамадол и другие серотонинергические опиоиды: риск серотонинового синдрома и судорог."
- with_class: maoi
severity: high
text: "Ингибиторы МАО: сочетание противопоказано."
- with_class: alcohol
severity: medium
text: "Алкоголь во время лечения не рекомендуется: ухудшает сон и настроение, усиливает сонливость и головокружение."
pregnancy: "При беременности или её планировании решение о продолжении лечения принимает врач."
drugs:
сертралин:
aliases: [sertraline, золофт, zoloft]
class: "антидепрессант, СИОЗС"
<<: *ssri
contraindications:
- "Повышенная чувствительность к компонентам препарата."
- "Одновременный приём ингибиторов МАО и 14 дней после их отмены."
- "Одновременный приём пимозида."
composition:
form: "таблетки, покрытые плёночной оболочкой"
active: "сертралина гидрохлорид"
excipients: [кальция гидрофосфат, целлюлоза микрокристаллическая, гипролоза, карбоксиметилкрахмал натрия, магния стеарат, гипромеллоза, титана диоксид, макрогол, полисорбат 80]
excipient_tags: []
note: "Пример состава оригинального препарата; у дженериков состав может отличаться (в части из них есть лактоза) — сверить с инструкцией на упаковке."
source: "ГРЛС, инструкция по медицинскому применению сертралина (пересказ для прототипа)"
пароксетин:
aliases: [paroxetine, паксил, paxil, рексетин]
class: "антидепрессант, СИОЗС"
<<: *ssri
contraindications:
- "Повышенная чувствительность к компонентам препарата."
- "Одновременный приём ингибиторов МАО и 14 дней после их отмены."
- "Одновременный приём тиоридазина или пимозида."
composition:
form: "таблетки, покрытые плёночной оболочкой"
active: "пароксетина гидрохлорида гемигидрат"
excipients: [кальция гидрофосфата дигидрат, карбоксиметилкрахмал натрия, магния стеарат, гипромеллоза, титана диоксид, макрогол, полисорбат 80]
excipient_tags: []
note: "Пример состава; у дженериков возможны отличия (в том числе лактоза) — сверить с инструкцией на упаковке."
pregnancy: "При беременности или её планировании решение принимает врач; для пароксетина в инструкции отдельно указан риск для плода в первом триместре."
source: "ГРЛС, инструкция по медицинскому применению пароксетина (пересказ для прототипа)"
эсциталопрам:
aliases: [escitalopram, ципралекс, cipralex, селектра]
class: "антидепрессант, СИОЗС"
<<: *ssri
contraindications:
- "Повышенная чувствительность к компонентам препарата."
- "Одновременный приём ингибиторов МАО и 14 дней после их отмены."
- "Удлинение интервала QT (врождённое или приобретённое) и приём препаратов, удлиняющих QT."
composition:
form: "таблетки, покрытые плёночной оболочкой"
active: "эсциталопрама оксалат"
excipients: [целлюлоза микрокристаллическая, кремния диоксид коллоидный, тальк, кроскармеллоза натрия, магния стеарат, гипромеллоза, макрогол, титана диоксид]
excipient_tags: []
note: "Пример состава оригинального препарата; у дженериков состав может отличаться — сверить с инструкцией на упаковке."
source: "ГРЛС, инструкция по медицинскому применению эсциталопрама (пересказ для прототипа)"
флуоксетин:
aliases: [fluoxetine, прозак, prozac, профлузак]
class: "антидепрессант, СИОЗС"
<<: *ssri
contraindications:
- "Повышенная чувствительность к компонентам препарата."
- "Одновременный приём ингибиторов МАО и 5 недель после отмены флуоксетина перед их назначением."
- "Одновременный приём тиоридазина или пимозида."
composition:
form: "капсулы"
active: "флуоксетина гидрохлорид"
excipients: [крахмал кукурузный, диметикон, желатин (оболочка капсулы), титана диоксид, красители]
excipient_tags: [gelatin]
note: "Пример состава; у разных производителей отличается (в части капсул есть лактоза) — сверить с инструкцией на упаковке."
source: "ГРЛС, инструкция по медицинскому применению флуоксетина (пересказ для прототипа)"
ибупрофен:
aliases: [ibuprofen, нурофен, nurofen, миг]
class: "нестероидный противовоспалительный препарат (НПВС)"
classes: [nsaid]
contraindications:
- "Эрозивно-язвенные поражения желудка и кишечника в стадии обострения, желудочно-кишечное кровотечение."
- "Повышенная чувствительность к компонентам, «аспириновая» астма."
cautions:
- when_condition: ulcer_history
severity: high
text: "Язвенная болезнь в анамнезе: НПВС применяют с осторожностью из-за риска обострения и кровотечения."
interactions:
- with_class: ssri
severity: high
text: "Вместе с антидепрессантами группы СИОЗС повышается риск кровотечений."
- with_class: anticoagulant
severity: high
text: "С антикоагулянтами возрастает риск кровотечений."
composition:
form: "таблетки, покрытые оболочкой"
active: "ибупрофен"
excipients: [кроскармеллоза натрия, натрия лаурилсульфат, натрия цитрат, стеариновая кислота, кремния диоксид коллоидный, сахароза, тальк]
excipient_tags: []
note: "Состав зависит от производителя — сверить с инструкцией на упаковке."
source: "ГРЛС, инструкция по медицинскому применению ибупрофена (пересказ для прототипа)"
# Классы препаратов из назначений психиатра (для проверки «другой препарат × текущая терапия»).
drug_classes:
ssri: [сертралин, пароксетин, эсциталопрам, флуоксетин, циталопрам, флувоксамин]
# Подписи тегов непереносимостей для ответа пациенту.
allergy_labels:
lactose: "лактоза"
gelatin: "желатин"
+171
View File
@@ -0,0 +1,171 @@
import json
import httpx
import pytest
from app.config import Settings
from app.core.privacy import pseudo_id, scrub
from app.core.types import ScrubbedText
from app.integrations.base import Prescription, integrations
from app.integrations.emias_record import emias_records
from app.llm import agent
from app.llm.agent.drug_safety import check_patient, find_drug
from app.llm.agent.providers import MockAgentProvider, OpenRouterProvider
from app.llm.agent.tools import ToolContext, run_tool
ANDREY = pseudo_id("900-000-013 34")
ANNA = pseudo_id("900-000-023 04")
SERGEY = pseudo_id("900-000-037 02")
class _Rx:
def __init__(self, drug: str) -> None:
self.drug = drug
async def get_prescriptions(self, token: str) -> list[Prescription]:
return [Prescription(drug=self.drug, dose="50 мг", instructions="утром после еды")]
@pytest.fixture
def sertraline(monkeypatch):
monkeypatch.setattr(integrations, "prescriptions", _Rx("сертралин"))
# ----------------------------------------------------------------------------------------------- drug safety
@pytest.mark.parametrize("name", ["сертралин", "сертралине", "Золофт", "сертралин 50 мг", "sertraline"])
def test_find_drug_forms(name):
assert find_drug(name).inn == "сертралин"
def test_find_drug_unknown():
assert find_drug("аспирин") is None
async def test_andrey_sertraline_findings(sertraline):
rec = await emias_records.get(ANDREY)
findings = check_patient(find_drug("сертралин"), rec)
kinds = {(f.kind, f.severity) for f in findings}
assert ("condition", "high") in kinds # ulcer history
assert any(f.kind == "interaction" and "ибупрофен" in f.text for f in findings)
async def test_ibuprofen_sees_current_ssri(sertraline):
rec = await emias_records.get(ANDREY)
findings = check_patient(find_drug("ибупрофен"), rec)
assert any("СИОЗС" in f.text and "сертралин" in f.text for f in findings)
async def test_pregnancy_and_triptan(monkeypatch):
monkeypatch.setattr(integrations, "prescriptions", _Rx("флуоксетин"))
anna = await emias_records.get(ANNA)
assert any(f.kind == "pregnancy" for f in check_patient(find_drug("флуоксетин"), anna))
monkeypatch.setattr(integrations, "prescriptions", _Rx("пароксетин"))
sergey = await emias_records.get(SERGEY)
assert any("Триптаны" in f.text for f in check_patient(find_drug("пароксетин"), sergey))
# ----------------------------------------------------------------------------------------------------- tools
async def test_record_tool_has_no_identifiers(sertraline):
out = await run_tool(ToolContext(token=ANDREY), "get_patient_record", {})
data = json.loads(out)
assert data["found"] and data["current_prescriptions"][0]["drug"] == "сертралин"
assert "900-000" not in out and "Андрей" not in out
async def test_search_tool_drops_doses():
out = json.loads(await run_tool(ToolContext(token=ANDREY), "search_guidelines", {"query": "дозы СИОЗС при ПТСР"}))
for r in out["results"]:
assert " мг" not in r["text"]
async def test_unknown_tool_is_json_error():
assert "error" in json.loads(await run_tool(ToolContext(token=ANDREY), "rm_rf", {}))
# ------------------------------------------------------------------------------------------------ agent loop
def _settings(**kw) -> Settings:
base = {"openrouter_api_key": "test", "chat_agent": "openrouter", "chat_agent_timeout_s": 5.0}
return Settings(_env_file=None, **{**base, **kw})
def _fake_openrouter(final_text: str, calls: list[dict]) -> tuple[OpenRouterProvider, list[dict]]:
sent: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
sent.append(body)
if len(sent) == 1:
msg = {"role": "assistant", "content": None, "tool_calls": calls}
else:
msg = {"role": "assistant", "content": final_text}
return httpx.Response(200, json={"choices": [{"message": msg}], "usage": {"total_tokens": 10}})
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
return OpenRouterProvider(_settings(), client=client), sent
_CALLS = [
{"id": "a", "type": "function", "function": {"name": "get_patient_record", "arguments": "{}"}},
{
"id": "b",
"type": "function",
"function": {"name": "check_drug_for_patient", "arguments": '{"drug": "ибупрофен"}'},
},
]
async def test_agent_openrouter_tool_loop(sertraline):
text = (
"В вашей карте отмечена язвенная болезнь, а ибупрофен с сертралином повышает риск кровотечений. "
"Обсудите с врачом."
)
provider, sent = _fake_openrouter(text, _CALLS)
ans = await agent.answer_chat(
ANDREY, scrub("Можно ибупрофен? Мой телефон 89161234567"), provider=provider, settings=_settings()
)
assert ans is not None and ans.text == text
assert ans.tools == ["get_patient_record", "check_drug_for_patient"]
assert any("ЕМИАС" in s for s in ans.sources)
first, second = sent
assert "89161234567" not in json.dumps(first, ensure_ascii=False) # scrubbed before the provider
tool_msgs = [m for m in second["messages"] if m["role"] == "tool"]
assert len(tool_msgs) == 2 and all("name" not in m for m in tool_msgs)
assert first["tools"] and first["model"]
async def test_forbidden_output_rejected(sertraline):
provider, _ = _fake_openrouter("Увеличьте дозу сертралина вдвое.", _CALLS)
assert await agent.answer_chat(ANDREY, scrub("что делать"), provider=provider, settings=_settings()) is None
async def test_provider_error_returns_none():
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(500)))
provider = OpenRouterProvider(_settings(), client=client)
assert await agent.answer_chat(ANDREY, scrub("вопрос"), provider=provider, settings=_settings()) is None
async def test_unscrubbed_input_never_sent():
provider, sent = _fake_openrouter("ok", [])
assert await agent.answer_chat(ANDREY, ScrubbedText("звоните 8 916 123-45-67"), provider=provider) is None
assert sent == []
async def test_mock_agent_answers_offline(sertraline):
ans = await agent.answer_chat(
ANDREY, scrub("Можно ли мне ибупрофен вместе с моими таблетками?"), provider=MockAgentProvider()
)
assert ans is not None and "кровотеч" in ans.text and ans.provider == "mock"
def test_build_provider_modes():
assert agent.build_provider(Settings(_env_file=None, chat_agent="auto")) is None
assert isinstance(agent.build_provider(_settings(chat_agent="auto")), OpenRouterProvider)
assert isinstance(agent.build_provider(Settings(_env_file=None, chat_agent="mock")), MockAgentProvider)
assert agent.build_provider(Settings(_env_file=None, chat_agent="openrouter")) is None # no key
assert agent.build_provider(Settings(_env_file=None, chat_agent="off")) is None