chore(devops): деплой на сервер + GigaChat для чат-агента + без 500 при ошибках провайдера #80
@@ -35,6 +35,7 @@ class Settings(BaseSettings):
|
||||
openrouter_api_key: str = ""
|
||||
openrouter_model: str = "google/gemini-2.5-flash-lite"
|
||||
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
||||
gigachat_agent_model: str = "GigaChat-2-Pro" # function calling quality matters more than price here
|
||||
chat_agent_timeout_s: float = 25.0
|
||||
chat_agent_max_steps: int = 5
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ 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,
|
||||
@@ -49,7 +50,7 @@ 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"
|
||||
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")
|
||||
@@ -58,15 +59,16 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
|
||||
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")
|
||||
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 != "mock" else messages
|
||||
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:
|
||||
@@ -139,6 +141,9 @@ async def answer_chat(
|
||||
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:
|
||||
|
||||
@@ -68,9 +68,10 @@ class OpenRouterProvider:
|
||||
if resp.status_code != 200:
|
||||
# Provider error bodies are generic (no patient text) — log the message to make failures diagnosable.
|
||||
try:
|
||||
detail = str(resp.json().get("error", {}).get("message", ""))[:200]
|
||||
except ValueError:
|
||||
detail = ""
|
||||
err = resp.json().get("error", "")
|
||||
detail = str(err.get("message", "") if isinstance(err, dict) else err)[:200]
|
||||
except (ValueError, AttributeError):
|
||||
detail = resp.text[:200]
|
||||
log.error("openrouter http %s: %s", resp.status_code, detail)
|
||||
raise ProviderError(f"http {resp.status_code}")
|
||||
try:
|
||||
@@ -90,6 +91,88 @@ class OpenRouterProvider:
|
||||
return AssistantTurn(content=msg.get("content"), tool_calls=calls)
|
||||
|
||||
|
||||
class GigaChatToolProvider:
|
||||
"""GigaChat function calling (RF provider, 152-FZ compliant). Converts the OpenAI-style history the agent keeps:
|
||||
assistant `tool_calls` → `function_call`, `tool` results → role `function` with the function name.
|
||||
GigaChat returns at most one function call per turn — the agent loop handles that naturally."""
|
||||
|
||||
name = "gigachat"
|
||||
|
||||
def __init__(self, settings: Settings, client: Any | None = None) -> None:
|
||||
self._settings = settings
|
||||
self._client = client
|
||||
|
||||
def _get_client(self) -> Any:
|
||||
if self._client is None:
|
||||
from gigachat import GigaChat
|
||||
|
||||
s = self._settings
|
||||
self._client = GigaChat(
|
||||
credentials=s.gigachat_credentials,
|
||||
scope=s.gigachat_scope,
|
||||
model=s.gigachat_agent_model,
|
||||
verify_ssl_certs=s.gigachat_verify_ssl,
|
||||
timeout=s.chat_agent_timeout_s,
|
||||
)
|
||||
return self._client
|
||||
|
||||
@staticmethod
|
||||
def to_messages(messages: list[dict[str, Any]]) -> list[Any]:
|
||||
from gigachat.models import FunctionCall, Messages, MessagesRole
|
||||
|
||||
out = []
|
||||
for m in messages:
|
||||
role = m["role"]
|
||||
if role == "system":
|
||||
out.append(Messages(role=MessagesRole.SYSTEM, content=m["content"]))
|
||||
elif role == "user":
|
||||
out.append(Messages(role=MessagesRole.USER, content=m["content"]))
|
||||
elif role == "assistant":
|
||||
calls = m.get("tool_calls") or []
|
||||
for c in calls or [None]:
|
||||
fc = None
|
||||
if c is not None:
|
||||
fc = FunctionCall(name=c["function"]["name"], arguments=json.loads(c["function"]["arguments"]))
|
||||
out.append(Messages(role=MessagesRole.ASSISTANT, content=m.get("content") or "", function_call=fc))
|
||||
elif role == "tool":
|
||||
out.append(Messages(role=MessagesRole.FUNCTION, name=m.get("name"), content=m["content"]))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def to_functions(tools: list[dict[str, Any]]) -> list[Any]:
|
||||
from gigachat.models import Function
|
||||
|
||||
return [Function.model_validate(t["function"]) for t in tools]
|
||||
|
||||
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
|
||||
from gigachat.models import Chat
|
||||
|
||||
chat = Chat(
|
||||
messages=self.to_messages(messages),
|
||||
functions=self.to_functions(tools),
|
||||
function_call="auto",
|
||||
temperature=0.1,
|
||||
max_tokens=900,
|
||||
)
|
||||
try:
|
||||
resp = await self._get_client().achat(chat)
|
||||
except Exception as exc: # noqa: BLE001 — network/auth/HTTP errors of the SDK
|
||||
raise ProviderError(type(exc).__name__) from exc
|
||||
msg = resp.choices[0].message
|
||||
calls = []
|
||||
if msg.function_call is not None:
|
||||
args = msg.function_call.arguments or {}
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
calls.append(ToolCall(id=msg.function_call.name, name=msg.function_call.name, arguments=args))
|
||||
usage = getattr(resp, "usage", None)
|
||||
log.info("gigachat agent ok calls=%d tokens=%s", len(calls), getattr(usage, "total_tokens", None))
|
||||
return AssistantTurn(content=msg.content or None, tool_calls=calls)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ offline mock
|
||||
|
||||
_DRUG_WORDS = re.compile(
|
||||
|
||||
@@ -197,3 +197,75 @@ async def test_empty_final_answer_is_nudged(sertraline):
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user