CI / ruff · pytest (pull_request) Successful in 2m15s
OpenRouter returns 403 «Access denied by security policy» from the RF server, and parsing that string error crashed the request (500 instead of the fallback answer). Error parsing is robust now and any agent exception degrades to the extractive answer. GigaChat tool provider (GigaChat-2-Pro by default) converts the agent's OpenAI-style history to function_call / role=function; auto mode prefers GigaChat when credentials are set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
272 lines
12 KiB
Python
272 lines
12 KiB
Python
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
|
|
|
|
|
|
async def test_check_defaults_to_current_prescription(sertraline):
|
|
out = json.loads(await run_tool(ToolContext(token=ANDREY), "check_drug_for_patient", {"drug": "мои таблетки"}))
|
|
assert out["found"] and out["inn"] == "сертралин"
|
|
assert "лактоз" in out["intolerance_check"] and "нет" in out["intolerance_check"]
|
|
assert any("Алкоголь" in t for t in out["general_interactions"])
|
|
|
|
|
|
async def test_empty_final_answer_is_nudged(sertraline):
|
|
replies = iter(
|
|
[
|
|
{"role": "assistant", "content": None, "tool_calls": _CALLS},
|
|
{"role": "assistant", "content": ""},
|
|
{"role": "assistant", "content": "Алкоголь во время лечения не рекомендуется. Обсудите с врачом."},
|
|
]
|
|
)
|
|
sent = []
|
|
|
|
def handler(request):
|
|
sent.append(json.loads(request.content))
|
|
return httpx.Response(200, json={"choices": [{"message": next(replies)}]})
|
|
|
|
provider = OpenRouterProvider(_settings(), client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
|
|
ans = await agent.answer_chat(ANDREY, scrub("пиво можно?"), provider=provider, settings=_settings())
|
|
assert ans is not None and "Алкоголь" in ans.text
|
|
assert sent[0]["tool_choice"] == "required" and "tool_choice" not in sent[1]
|
|
assert "сертралин" in sent[0]["messages"][0]["content"] # patient brief in the system prompt
|
|
|
|
|
|
async def test_region_block_string_error_degrades():
|
|
# Seen live from the RF server: 403 {"success": false, "error": "Access denied by security policy."}
|
|
client = httpx.AsyncClient(
|
|
transport=httpx.MockTransport(lambda r: httpx.Response(403, json={"success": False, "error": "Access denied"}))
|
|
)
|
|
provider = OpenRouterProvider(_settings(), client=client)
|
|
assert await agent.answer_chat(ANDREY, scrub("вопрос"), provider=provider, settings=_settings()) is None
|
|
|
|
|
|
async def test_unexpected_provider_bug_degrades():
|
|
class Broken:
|
|
name = "broken"
|
|
|
|
async def chat(self, messages, tools):
|
|
raise AttributeError("boom")
|
|
|
|
assert await agent.answer_chat(ANDREY, scrub("вопрос"), provider=Broken(), settings=_settings()) is None
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------- gigachat
|
|
|
|
|
|
class _FakeGiga:
|
|
"""Mimics gigachat.GigaChat.achat: first a function_call, then the final text."""
|
|
|
|
def __init__(self):
|
|
self.chats = []
|
|
|
|
async def achat(self, chat):
|
|
from types import SimpleNamespace as NS
|
|
|
|
self.chats.append(chat)
|
|
if len(self.chats) == 1:
|
|
fc = NS(name="check_drug_for_patient", arguments={"drug": "ибупрофен"})
|
|
msg = NS(content="", function_call=fc)
|
|
else:
|
|
msg = NS(
|
|
content="Ибупрофен вместе с сертралином повышает риск кровотечений. Обсудите с врачом.",
|
|
function_call=None,
|
|
)
|
|
return NS(choices=[NS(message=msg)], usage=NS(total_tokens=42))
|
|
|
|
|
|
async def test_gigachat_tool_loop(sertraline):
|
|
from app.llm.agent.providers import GigaChatToolProvider
|
|
|
|
fake = _FakeGiga()
|
|
s = Settings(_env_file=None, chat_agent="gigachat", gigachat_credentials="x", chat_agent_timeout_s=5.0)
|
|
ans = await agent.answer_chat(
|
|
ANDREY, scrub("Можно ибупрофен?"), provider=GigaChatToolProvider(s, client=fake), settings=s
|
|
)
|
|
assert ans is not None and "кровотеч" in ans.text and ans.provider == "gigachat"
|
|
second = fake.chats[1]
|
|
roles = [str(m.role.value if hasattr(m.role, "value") else m.role) for m in second.messages]
|
|
assert roles == ["system", "user", "assistant", "function"]
|
|
assert second.messages[2].function_call.name == "check_drug_for_patient"
|
|
assert second.messages[3].name == "check_drug_for_patient"
|
|
assert {f.name for f in second.functions} == {
|
|
"search_guidelines",
|
|
"get_patient_record",
|
|
"get_drug_info",
|
|
"check_drug_for_patient",
|
|
}
|
|
|
|
|
|
def test_build_provider_prefers_gigachat():
|
|
from app.llm.agent.providers import GigaChatToolProvider
|
|
|
|
s = Settings(_env_file=None, chat_agent="auto", gigachat_credentials="x", openrouter_api_key="y")
|
|
assert isinstance(agent.build_provider(s), GigaChatToolProvider)
|