feat(bot): tool-using chat agent over RAG, ЕМИАС record and drug-safety reference
CI / ruff · pytest (pull_request) Successful in 2m10s
CI / ruff · pytest (pull_request) Successful in 2m10s
Free-text treatment and medication questions go to an LLM agent with read-only tools bound to the current patient: guideline search (RAG, doses stripped), ЕМИАС record mock (diagnoses, comorbidities, intolerances, other specialists' drugs, current prescription), drug info (composition, contraindications, interactions) and a deterministic patient-specific check. Providers: OpenRouter (temporary, synthetic data only) and an offline mock; GigaChat to follow. Scrubbed input only, forbidden-output filter, any failure falls back to the existing extractive RAG / instruction answers. Off unless OPENROUTER_API_KEY or CHAT_AGENT=mock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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