diff --git a/app/config.py b/app/config.py index 0f0069c..35d10e4 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/llm/agent/__init__.py b/app/llm/agent/__init__.py index dc0386d..7641a7f 100644 --- a/app/llm/agent/__init__.py +++ b/app/llm/agent/__init__.py @@ -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") - return None + 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: diff --git a/app/llm/agent/providers.py b/app/llm/agent/providers.py index 27524ba..5ef6fb2 100644 --- a/app/llm/agent/providers.py +++ b/app/llm/agent/providers.py @@ -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( diff --git a/tests/test_llm_agent.py b/tests/test_llm_agent.py index daabcc7..3752f15 100644 --- a/tests/test_llm_agent.py +++ b/tests/test_llm_agent.py @@ -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)