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>
136 lines
5.3 KiB
Python
136 lines
5.3 KiB
Python
"""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)
|