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>
167 lines
7.1 KiB
Python
167 lines
7.1 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 (
|
|
GigaChatToolProvider,
|
|
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
|
|
NUDGE = "Сформулируй ответ пациенту по результатам инструментов выше (3–6 предложений)."
|
|
_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 = "gigachat" if s.gigachat_credentials else "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":
|
|
if not s.gigachat_credentials:
|
|
log.warning("chat agent: gigachat selected without GIGACHAT_CREDENTIALS — disabled")
|
|
return None
|
|
return GigaChatToolProvider(s)
|
|
return None
|
|
|
|
|
|
async def _chat_with_retry(provider: ToolChatProvider, messages: list[dict[str, Any]]):
|
|
"""One retry: OpenRouter occasionally fails a single call (seen live as intermittent ProviderError)."""
|
|
wire = _wire(messages) if provider.name == "openrouter" else messages # gigachat needs the tool `name`
|
|
try:
|
|
return await provider.chat(wire, TOOL_SPECS)
|
|
except ProviderError as exc:
|
|
log.warning("chat agent provider=%s retry after %s", provider.name, exc)
|
|
return await provider.chat(wire, TOOL_SPECS)
|
|
|
|
|
|
async def _patient_brief(ctx: ToolContext) -> str:
|
|
"""One line of context so the model knows what «мои таблетки» means (drug names only, no identifiers)."""
|
|
rec = await ctx.record()
|
|
if rec is None or not rec.current_prescriptions:
|
|
return "\n\nТекущее назначение психиатра: нет данных."
|
|
drugs = ", ".join(p.drug for p in rec.current_prescriptions)
|
|
return f"\n\nТекущее назначение психиатра: {drugs}. «Мои таблетки» = это назначение."
|
|
|
|
|
|
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 + await _patient_brief(ctx)},
|
|
{"role": "user", "content": str(question)},
|
|
]
|
|
nudged = False
|
|
try:
|
|
async with asyncio.timeout(s.chat_agent_timeout_s * 2):
|
|
for _ in range(s.chat_agent_max_steps):
|
|
turn = await _chat_with_retry(provider, messages)
|
|
if not turn.tool_calls:
|
|
if not (turn.content or "").strip() and not nudged:
|
|
# Some models (Gemini Flash) occasionally end with an empty message after tool results.
|
|
nudged = True
|
|
messages.append({"role": "user", "content": NUDGE})
|
|
continue
|
|
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 %s", provider.name, type(exc).__name__, exc)
|
|
return None
|
|
except Exception: # noqa: BLE001 — the agent is optional: any bug must degrade to the extractive answer, not a 500
|
|
log.exception("chat agent provider=%s crashed", provider.name)
|
|
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)
|