release: dev → main (сдача хакатона) #106
@@ -6,6 +6,11 @@ GIGACHAT_CREDENTIALS=
|
|||||||
GIGACHAT_SCOPE=GIGACHAT_API_PERS
|
GIGACHAT_SCOPE=GIGACHAT_API_PERS
|
||||||
GIGACHAT_MODEL=GigaChat
|
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))"
|
# --- Pseudonymisation key: 32+ random bytes, hex. Generate: python -c "import secrets;print(secrets.token_hex(32))"
|
||||||
PSEUDO_KEY=
|
PSEUDO_KEY=
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ class Settings(BaseSettings):
|
|||||||
gigachat_model: str = "GigaChat"
|
gigachat_model: str = "GigaChat"
|
||||||
gigachat_verify_ssl: bool = False
|
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.
|
# Telegram is used only for the prototype on synthetic data; prod channel is MAX / Gosuslugi.
|
||||||
tg_bot_token: str = ""
|
tg_bot_token: str = ""
|
||||||
doctor_notify_chat_id: int | None = None
|
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.contracts import Event, Reply
|
||||||
from app.dialog.flows.core import menu_reply
|
from app.dialog.flows.core import menu_reply
|
||||||
from app.dialog.fsm import Ctx, Step, abandonable, handler_for, state, trigger
|
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
|
from app.llm.med_qa import DOSE_QUESTION
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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()):
|
if DOSE_QUESTION.search(ctx.text.lower()):
|
||||||
log.info("kb: dose question redirected to doctor")
|
log.info("kb: dose question redirected to doctor")
|
||||||
return load_knowledge_texts().dose_redirect
|
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")
|
reply = await rag.answer(ctx.text, audience="patient")
|
||||||
if reply is not None:
|
if reply is not None:
|
||||||
log.info("kb: answered docs=%s", ",".join(c.doc for c in reply.citations))
|
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.flows.core import menu_reply
|
||||||
from app.dialog.fsm import Ctx, Step, abandonable, get_trigger, handler_for, state, trigger, trigger_payload
|
from app.dialog.fsm import Ctx, Step, abandonable, get_trigger, handler_for, state, trigger, trigger_payload
|
||||||
from app.integrations.base import integrations
|
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"
|
QUESTION_STATE = "meds.question"
|
||||||
SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text"
|
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 → «уточните у врача»."""
|
"""Answer from the instruction excerpt only (SPEC §7 п.5); dose questions and gaps → «уточните у врача»."""
|
||||||
if rx is None or ctx.text is None:
|
if rx is None or ctx.text is None:
|
||||||
return load_meds_texts().question.no_prescription
|
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)
|
answer = await med_qa(rx.drug, ctx.text)
|
||||||
return answer.text if answer is not None else fallback_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