chore(devops): деплой на сервер + GigaChat для чат-агента + без 500 при ошибках провайдера #80
@@ -35,6 +35,7 @@ class Settings(BaseSettings):
|
|||||||
openrouter_api_key: str = ""
|
openrouter_api_key: str = ""
|
||||||
openrouter_model: str = "google/gemini-2.5-flash-lite"
|
openrouter_model: str = "google/gemini-2.5-flash-lite"
|
||||||
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
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_timeout_s: float = 25.0
|
||||||
chat_agent_max_steps: int = 5
|
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.core.types import ScrubbedText
|
||||||
from app.llm.agent.prompts import SYSTEM_PROMPT
|
from app.llm.agent.prompts import SYSTEM_PROMPT
|
||||||
from app.llm.agent.providers import (
|
from app.llm.agent.providers import (
|
||||||
|
GigaChatToolProvider,
|
||||||
MockAgentProvider,
|
MockAgentProvider,
|
||||||
OpenRouterProvider,
|
OpenRouterProvider,
|
||||||
ProviderError,
|
ProviderError,
|
||||||
@@ -49,7 +50,7 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
|
|||||||
s = settings or get_settings()
|
s = settings or get_settings()
|
||||||
mode = s.chat_agent
|
mode = s.chat_agent
|
||||||
if mode == "auto":
|
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 mode == "openrouter":
|
||||||
if not s.openrouter_api_key:
|
if not s.openrouter_api_key:
|
||||||
log.warning("chat agent: openrouter selected without OPENROUTER_API_KEY — disabled")
|
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":
|
if mode == "mock":
|
||||||
return MockAgentProvider()
|
return MockAgentProvider()
|
||||||
if mode == "gigachat":
|
if mode == "gigachat":
|
||||||
# TODO(GigaChat): function calling via the `gigachat` package (functions=..., message.function_call).
|
if not s.gigachat_credentials:
|
||||||
log.warning("chat agent: gigachat provider not implemented yet — disabled")
|
log.warning("chat agent: gigachat selected without GIGACHAT_CREDENTIALS — disabled")
|
||||||
return None
|
return None
|
||||||
|
return GigaChatToolProvider(s)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _chat_with_retry(provider: ToolChatProvider, messages: list[dict[str, Any]]):
|
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)."""
|
"""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:
|
try:
|
||||||
return await provider.chat(wire, TOOL_SPECS)
|
return await provider.chat(wire, TOOL_SPECS)
|
||||||
except ProviderError as exc:
|
except ProviderError as exc:
|
||||||
@@ -139,6 +141,9 @@ async def answer_chat(
|
|||||||
except (ProviderError, TimeoutError) as exc:
|
except (ProviderError, TimeoutError) as exc:
|
||||||
log.error("chat agent provider=%s failed: %s %s", provider.name, type(exc).__name__, exc)
|
log.error("chat agent provider=%s failed: %s %s", provider.name, type(exc).__name__, exc)
|
||||||
return None
|
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:
|
def _finish(text: str, ctx: ToolContext, provider: str) -> AgentAnswer | None:
|
||||||
|
|||||||
@@ -68,9 +68,10 @@ class OpenRouterProvider:
|
|||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
# Provider error bodies are generic (no patient text) — log the message to make failures diagnosable.
|
# Provider error bodies are generic (no patient text) — log the message to make failures diagnosable.
|
||||||
try:
|
try:
|
||||||
detail = str(resp.json().get("error", {}).get("message", ""))[:200]
|
err = resp.json().get("error", "")
|
||||||
except ValueError:
|
detail = str(err.get("message", "") if isinstance(err, dict) else err)[:200]
|
||||||
detail = ""
|
except (ValueError, AttributeError):
|
||||||
|
detail = resp.text[:200]
|
||||||
log.error("openrouter http %s: %s", resp.status_code, detail)
|
log.error("openrouter http %s: %s", resp.status_code, detail)
|
||||||
raise ProviderError(f"http {resp.status_code}")
|
raise ProviderError(f"http {resp.status_code}")
|
||||||
try:
|
try:
|
||||||
@@ -90,6 +91,88 @@ class OpenRouterProvider:
|
|||||||
return AssistantTurn(content=msg.get("content"), tool_calls=calls)
|
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
|
# ------------------------------------------------------------------------------------------------ offline mock
|
||||||
|
|
||||||
_DRUG_WORDS = re.compile(
|
_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 ans is not None and "Алкоголь" in ans.text
|
||||||
assert sent[0]["tool_choice"] == "required" and "tool_choice" not in sent[1]
|
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
|
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