release: dev → main (сдача хакатона) #106

Merged
Eleboba merged 128 commits from dev into main 2026-10-04 23:00:56 +03:00
4 changed files with 169 additions and 8 deletions
Showing only changes of commit ade93d959b - Show all commits
+1
View File
@@ -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
+10 -5
View File
@@ -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:
+86 -3
View File
@@ -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(
+72
View File
@@ -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)