Merge pull request 'feat(bot): чат-агент — RAG + карта ЕМИАС + проверка препаратов' (#72) from feat/chat-agent into dev
CI / ruff · pytest (push) Successful in 2m4s
CI / ruff · pytest (push) Successful in 2m4s
This commit was merged in pull request #72.
This commit is contained in:
@@ -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=
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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, и что врач будет уведомлён."""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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: "желатин"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user