v0.7.1: Универсальный интерфейс AI-провайдеров с поддержкой инструментов
- Добавлен базовый класс BaseAIProvider с единым интерфейсом - Реализованы QwenCodeProvider и GigaChatProvider - AIProviderManager использует единый процесс с инструментами - Любой AI-провайдер теперь может работать с инструментами - Поддержка process_with_tools для всех провайдеров Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
7a186e777a
commit
1c66bc4c01
|
|
@ -5,6 +5,8 @@ AI Provider Manager - управление переключением между
|
||||||
Поддерживаемые провайдеры:
|
Поддерживаемые провайдеры:
|
||||||
- qwen: Qwen Code CLI (основной)
|
- qwen: Qwen Code CLI (основной)
|
||||||
- gigachat: GigaChat API (Сбер)
|
- gigachat: GigaChat API (Сбер)
|
||||||
|
|
||||||
|
Использует единый интерфейс BaseAIProvider для всех провайдеров.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -12,6 +14,8 @@ from typing import Optional, Dict, Any, Callable, List
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
from bot.base_ai_provider import BaseAIProvider, ProviderResponse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -36,17 +40,44 @@ class AIProviderManager:
|
||||||
Менеджер управления AI-провайдерами.
|
Менеджер управления AI-провайдерами.
|
||||||
|
|
||||||
Позволяет переключаться между провайдерами и выполнять запросы
|
Позволяет переключаться между провайдерами и выполнять запросы
|
||||||
через активного провайдера.
|
через активного провайдера с поддержкой инструментов.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, qwen_manager=None, gigachat_provider=None):
|
def __init__(self, qwen_manager=None, gigachat_provider=None):
|
||||||
self._qwen_manager = qwen_manager
|
self._qwen_manager = qwen_manager
|
||||||
self._gigachat_provider = gigachat_provider
|
self._gigachat_provider = gigachat_provider
|
||||||
self._provider_status: Dict[str, bool] = {}
|
self._provider_status: Dict[str, bool] = {}
|
||||||
|
self._providers: Dict[str, BaseAIProvider] = {}
|
||||||
|
self._tools_registry: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
# Инициализируем провайдеров
|
||||||
|
self._init_providers()
|
||||||
|
|
||||||
# Проверяем доступность провайдеров при инициализации
|
# Проверяем доступность провайдеров при инициализации
|
||||||
self._check_provider_status()
|
self._check_provider_status()
|
||||||
|
|
||||||
|
def _init_providers(self):
|
||||||
|
"""Инициализировать AI-провайдеров."""
|
||||||
|
# Qwen Code Provider
|
||||||
|
if self._qwen_manager:
|
||||||
|
from bot.providers.qwen_provider import QwenCodeProvider
|
||||||
|
self._providers[AIProvider.QWEN.value] = QwenCodeProvider(self._qwen_manager)
|
||||||
|
logger.info("Qwen Code Provider инициализирован")
|
||||||
|
|
||||||
|
# GigaChat Provider
|
||||||
|
if self._gigachat_provider:
|
||||||
|
from bot.providers.gigachat_provider import GigaChatProvider
|
||||||
|
self._providers[AIProvider.GIGACHAT.value] = GigaChatProvider(self._gigachat_provider)
|
||||||
|
logger.info("GigaChat Provider инициализирован")
|
||||||
|
|
||||||
|
def set_tools_registry(self, tools_registry: Dict[str, Any]):
|
||||||
|
"""Установить реестр инструментов для всех провайдеров."""
|
||||||
|
self._tools_registry = tools_registry
|
||||||
|
|
||||||
|
def get_provider(self, provider_id: str) -> Optional[BaseAIProvider]:
|
||||||
|
"""Получить экземпляр провайдера."""
|
||||||
|
return self._providers.get(provider_id)
|
||||||
|
|
||||||
def _check_provider_status(self):
|
def _check_provider_status(self):
|
||||||
"""Проверка доступности провайдеров."""
|
"""Проверка доступности провайдеров."""
|
||||||
# Проверяем Qwen
|
# Проверяем Qwen
|
||||||
|
|
@ -134,10 +165,11 @@ class AIProviderManager:
|
||||||
on_output: Optional[Callable[[str], Any]] = None,
|
on_output: Optional[Callable[[str], Any]] = None,
|
||||||
on_chunk: Optional[Callable[[str], Any]] = None,
|
on_chunk: Optional[Callable[[str], Any]] = None,
|
||||||
on_event: Optional[Callable[[Any], Any]] = None,
|
on_event: Optional[Callable[[Any], Any]] = None,
|
||||||
context: Optional[Dict] = None
|
context: Optional[List[Dict[str, str]]] = None,
|
||||||
|
use_tools: bool = True
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Выполнить запрос через указанного провайдера.
|
Выполнить запрос через указанного провайдера с поддержкой инструментов.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
provider_id: ID провайдера
|
provider_id: ID провайдера
|
||||||
|
|
@ -147,7 +179,8 @@ class AIProviderManager:
|
||||||
on_output: Callback для вывода
|
on_output: Callback для вывода
|
||||||
on_chunk: Callback для потокового вывода
|
on_chunk: Callback для потокового вывода
|
||||||
on_event: Callback для событий
|
on_event: Callback для событий
|
||||||
context: Дополнительный контекст
|
context: История диалога
|
||||||
|
use_tools: Использовать ли инструменты
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict с результатом:
|
Dict с результатом:
|
||||||
|
|
@ -157,74 +190,41 @@ class AIProviderManager:
|
||||||
- provider: str
|
- provider: str
|
||||||
- metadata: dict
|
- metadata: dict
|
||||||
"""
|
"""
|
||||||
|
provider = self._providers.get(provider_id)
|
||||||
|
|
||||||
|
if not provider:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Провайдер {provider_id} не найден",
|
||||||
|
"provider": provider_id
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if provider_id == AIProvider.QWEN.value:
|
# Используем универсальный метод process_with_tools
|
||||||
if not self._qwen_manager:
|
response = await provider.process_with_tools(
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": "Qwen менеджер не инициализирован",
|
|
||||||
"provider": provider_id
|
|
||||||
}
|
|
||||||
|
|
||||||
# Выполняем через Qwen
|
|
||||||
result = await self._qwen_manager.run_task(
|
|
||||||
user_id=user_id,
|
|
||||||
task=prompt,
|
|
||||||
on_output=on_output or (lambda x: None),
|
|
||||||
on_oauth_url=lambda x: None,
|
|
||||||
use_system_prompt=False,
|
|
||||||
on_chunk=on_chunk,
|
|
||||||
on_event=on_event
|
|
||||||
)
|
|
||||||
|
|
||||||
# Извлекаем текст из результата
|
|
||||||
import re
|
|
||||||
text_matches = re.findall(r'"text":"([^"]+)"', result)
|
|
||||||
content = " ".join(text_matches).replace("\\n", "\n") if text_matches else result
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"content": content,
|
|
||||||
"provider": provider_id,
|
|
||||||
"metadata": {"raw_result": result}
|
|
||||||
}
|
|
||||||
|
|
||||||
elif provider_id == AIProvider.GIGACHAT.value:
|
|
||||||
if not self._gigachat_provider:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": "GigaChat провайдер не инициализирован",
|
|
||||||
"provider": provider_id
|
|
||||||
}
|
|
||||||
|
|
||||||
# Выполняем через GigaChat
|
|
||||||
result = await self._gigachat_provider.chat(
|
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
on_chunk=on_chunk
|
context=context,
|
||||||
|
tools_registry=self._tools_registry if use_tools else None,
|
||||||
|
on_chunk=on_chunk,
|
||||||
|
user_id=user_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.get("success"):
|
if response.success:
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"content": result.get("content", ""),
|
"content": response.message.content if response.message else "",
|
||||||
"provider": provider_id,
|
"provider": provider_id,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"model": result.get("model", "GigaChat-Pro"),
|
"provider_name": response.provider_name,
|
||||||
"usage": result.get("usage", {})
|
"usage": response.usage,
|
||||||
|
"tool_calls": len(response.message.tool_calls) if response.message and response.message.tool_calls else 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": result.get("error", "Неизвестная ошибка GigaChat"),
|
"error": response.error,
|
||||||
"provider": provider_id
|
|
||||||
}
|
|
||||||
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": f"Неизвестный провайдер: {provider_id}",
|
|
||||||
"provider": provider_id
|
"provider": provider_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,300 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Base AI Provider Protocol - универсальный интерфейс для всех AI-провайдеров.
|
||||||
|
|
||||||
|
Определяет общий протокол который должен реализовать каждый AI-провайдер
|
||||||
|
для работы с инструментами (tools).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional, Dict, Any, Callable, List, AsyncGenerator
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCallStatus(Enum):
|
||||||
|
"""Статус выполнения инструмента."""
|
||||||
|
SUCCESS = "success"
|
||||||
|
ERROR = "error"
|
||||||
|
PENDING = "pending"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolCall:
|
||||||
|
"""Вызов инструмента."""
|
||||||
|
tool_name: str
|
||||||
|
tool_args: Dict[str, Any]
|
||||||
|
tool_call_id: Optional[str] = None
|
||||||
|
status: ToolCallStatus = ToolCallStatus.PENDING
|
||||||
|
result: Optional[Any] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AIMessage:
|
||||||
|
"""Сообщение от AI-провайдера."""
|
||||||
|
content: str
|
||||||
|
tool_calls: List[ToolCall] = field(default_factory=list)
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
is_streaming: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProviderResponse:
|
||||||
|
"""Ответ от AI-провайдера."""
|
||||||
|
success: bool
|
||||||
|
message: Optional[AIMessage] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
provider_name: str = ""
|
||||||
|
usage: Optional[Dict[str, Any]] = None
|
||||||
|
raw_response: Optional[Any] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAIProvider(ABC):
|
||||||
|
"""
|
||||||
|
Базовый класс для всех AI-провайдеров.
|
||||||
|
|
||||||
|
Каждый провайдер (Qwen, GigaChat, OpenAI, etc.) должен реализовать
|
||||||
|
этот интерфейс для поддержки инструментов и единого формата ответов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def provider_name(self) -> str:
|
||||||
|
"""Название провайдера (например, 'Qwen Code', 'GigaChat')."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def supports_tools(self) -> bool:
|
||||||
|
"""Поддерживает ли провайдер инструменты нативно."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def supports_streaming(self) -> bool:
|
||||||
|
"""Поддерживает ли провайдер потоковый вывод."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def chat(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
system_prompt: Optional[str] = None,
|
||||||
|
context: Optional[List[Dict[str, str]]] = None,
|
||||||
|
tools: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
on_chunk: Optional[Callable[[str], Any]] = None,
|
||||||
|
**kwargs
|
||||||
|
) -> ProviderResponse:
|
||||||
|
"""
|
||||||
|
Отправить запрос AI-провайдеру.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: Запрос пользователя
|
||||||
|
system_prompt: Системный промпт
|
||||||
|
context: История диалога
|
||||||
|
tools: Доступные инструменты (схема)
|
||||||
|
on_chunk: Callback для потокового вывода
|
||||||
|
**kwargs: Дополнительные параметры
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProviderResponse с ответом и возможными вызовами инструментов
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def execute_tool(
|
||||||
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
tool_args: Dict[str, Any],
|
||||||
|
tool_call_id: Optional[str] = None,
|
||||||
|
**kwargs
|
||||||
|
) -> ToolCall:
|
||||||
|
"""
|
||||||
|
Выполнить инструмент (если провайдер поддерживает нативно).
|
||||||
|
|
||||||
|
Для провайдеров без нативной поддержки инструментов,
|
||||||
|
этот метод может быть заглушкой.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: Имя инструмента
|
||||||
|
tool_args: Аргументы инструмента
|
||||||
|
tool_call_id: ID вызова
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ToolCall с результатом выполнения
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Проверить доступность провайдера."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_tools_schema(self, tools_registry: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Получить схему инструментов для промпта.
|
||||||
|
|
||||||
|
По умолчанию возвращает описание всех доступных инструментов.
|
||||||
|
Провайдеры могут переопределить для кастомизации.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tools_registry: Словарь инструментов {name: tool_instance}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список схем инструментов
|
||||||
|
"""
|
||||||
|
schema = []
|
||||||
|
for name, tool in tools_registry.items():
|
||||||
|
if hasattr(tool, 'get_schema'):
|
||||||
|
schema.append(tool.get_schema())
|
||||||
|
elif hasattr(tool, 'description'):
|
||||||
|
schema.append({
|
||||||
|
"name": name,
|
||||||
|
"description": tool.description,
|
||||||
|
"parameters": getattr(tool, 'parameters', {})
|
||||||
|
})
|
||||||
|
return schema
|
||||||
|
|
||||||
|
async def process_with_tools(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
system_prompt: Optional[str] = None,
|
||||||
|
context: Optional[List[Dict[str, str]]] = None,
|
||||||
|
tools_registry: Optional[Dict[str, Any]] = None,
|
||||||
|
on_chunk: Optional[Callable[[str], Any]] = None,
|
||||||
|
max_iterations: int = 5,
|
||||||
|
**kwargs
|
||||||
|
) -> ProviderResponse:
|
||||||
|
"""
|
||||||
|
Универсальный метод для обработки запросов с инструментами.
|
||||||
|
|
||||||
|
Реализует цикл:
|
||||||
|
1. Отправить запрос провайдеру
|
||||||
|
2. Если есть вызовы инструментов - выполнить их
|
||||||
|
3. Отправить результаты обратно провайдеру
|
||||||
|
4. Повторить пока не будет финального ответа
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: Запрос пользователя
|
||||||
|
system_prompt: Системный промпт
|
||||||
|
context: История диалога
|
||||||
|
tools_registry: Словарь инструментов
|
||||||
|
on_chunk: Callback для потокового вывода
|
||||||
|
max_iterations: Максимум итераций цикла
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProviderResponse с финальным ответом
|
||||||
|
"""
|
||||||
|
if not tools_registry:
|
||||||
|
# Без инструментов - простой запрос
|
||||||
|
return await self.chat(
|
||||||
|
prompt=prompt,
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
context=context,
|
||||||
|
on_chunk=on_chunk,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
if context:
|
||||||
|
messages.extend(context)
|
||||||
|
|
||||||
|
messages.append({"role": "user", "content": prompt})
|
||||||
|
|
||||||
|
tools_schema = self.get_tools_schema(tools_registry) if self.supports_tools else None
|
||||||
|
|
||||||
|
for iteration in range(max_iterations):
|
||||||
|
# Отправляем запрос провайдеру
|
||||||
|
response = await self.chat(
|
||||||
|
prompt=None, # Уже в messages
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
context=messages if iteration == 0 else None,
|
||||||
|
tools=tools_schema,
|
||||||
|
on_chunk=on_chunk,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
if not response.success:
|
||||||
|
return response
|
||||||
|
|
||||||
|
message = response.message
|
||||||
|
if not message:
|
||||||
|
return ProviderResponse(
|
||||||
|
success=False,
|
||||||
|
error="Пустой ответ от провайдера",
|
||||||
|
provider_name=self.provider_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Если нет вызовов инструментов - возвращаем ответ
|
||||||
|
if not message.tool_calls:
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Выполняем инструменты
|
||||||
|
tool_results = []
|
||||||
|
for tool_call in message.tool_calls:
|
||||||
|
if tool_call.tool_name in tools_registry:
|
||||||
|
tool = tools_registry[tool_call.tool_name]
|
||||||
|
try:
|
||||||
|
if hasattr(tool, 'execute'):
|
||||||
|
result = await tool.execute(
|
||||||
|
**tool_call.tool_args,
|
||||||
|
user_id=kwargs.get('user_id')
|
||||||
|
)
|
||||||
|
elif hasattr(tool, '__call__'):
|
||||||
|
result = await tool(**tool_call.tool_args)
|
||||||
|
else:
|
||||||
|
result = f"Инструмент {tool_call.tool_name} не имеет метода execute"
|
||||||
|
|
||||||
|
tool_call.result = result
|
||||||
|
tool_call.status = ToolCallStatus.SUCCESS
|
||||||
|
except Exception as e:
|
||||||
|
tool_call.error = str(e)
|
||||||
|
tool_call.status = ToolCallStatus.ERROR
|
||||||
|
result = f"Ошибка: {e}"
|
||||||
|
|
||||||
|
tool_results.append({
|
||||||
|
"tool": tool_call.tool_name,
|
||||||
|
"args": tool_call.tool_args,
|
||||||
|
"result": result,
|
||||||
|
"status": tool_call.status.value
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
tool_call.error = f"Инструмент {tool_call.tool_name} не найден"
|
||||||
|
tool_call.status = ToolCallStatus.ERROR
|
||||||
|
tool_results.append({
|
||||||
|
"tool": tool_call.tool_name,
|
||||||
|
"error": tool_call.error
|
||||||
|
})
|
||||||
|
|
||||||
|
# Добавляем результаты в контекст для следующей итерации
|
||||||
|
messages.append({
|
||||||
|
"role": "assistant",
|
||||||
|
"content": message.content,
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": tc.tool_call_id,
|
||||||
|
"name": tc.tool_name,
|
||||||
|
"arguments": tc.tool_args
|
||||||
|
}
|
||||||
|
for tc in message.tool_calls
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
messages.append({
|
||||||
|
"role": "tool",
|
||||||
|
"content": str(tool_results)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Обновляем системный промпт для следующей итерации
|
||||||
|
system_prompt = system_prompt or ""
|
||||||
|
|
||||||
|
# Достигли максимума итераций
|
||||||
|
return ProviderResponse(
|
||||||
|
success=True,
|
||||||
|
message=AIMessage(
|
||||||
|
content=message.content + "\n\n[Достигнут максимум итераций выполнения инструментов]",
|
||||||
|
metadata={"iterations": max_iterations}
|
||||||
|
),
|
||||||
|
provider_name=self.provider_name,
|
||||||
|
usage=response.usage
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
"""
|
||||||
|
AI Providers - адаптеры для различных AI-провайдеров.
|
||||||
|
|
||||||
|
Каждый провайдер реализует интерфейс BaseAIProvider для единой работы
|
||||||
|
с инструментами и контекстом.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from bot.providers.qwen_provider import QwenCodeProvider
|
||||||
|
from bot.providers.gigachat_provider import GigaChatProvider
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"QwenCodeProvider",
|
||||||
|
"GigaChatProvider",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,292 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
GigaChat AI Provider - адаптер GigaChat для работы с инструментами.
|
||||||
|
|
||||||
|
Реализует интерфейс BaseAIProvider для единой работы с инструментами
|
||||||
|
независимо от AI-провайдера.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict, Any, Callable, List
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
from bot.base_ai_provider import (
|
||||||
|
BaseAIProvider,
|
||||||
|
ProviderResponse,
|
||||||
|
AIMessage,
|
||||||
|
ToolCall,
|
||||||
|
ToolCallStatus,
|
||||||
|
)
|
||||||
|
from bot.tools.gigachat_tool import GigaChatTool, GigaChatMessage, GigaChatConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GigaChatProvider(BaseAIProvider):
|
||||||
|
"""
|
||||||
|
GigaChat AI Provider с поддержкой инструментов.
|
||||||
|
|
||||||
|
Использует эвристики для извлечения вызовов инструментов из текста,
|
||||||
|
так как GigaChat не поддерживает нативные tool calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[GigaChatConfig] = None):
|
||||||
|
self._tool = GigaChatTool(config)
|
||||||
|
self._available: Optional[bool] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def provider_name(self) -> str:
|
||||||
|
return "GigaChat"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_tools(self) -> bool:
|
||||||
|
# GigaChat не поддерживает нативные tool calls
|
||||||
|
# Но мы эмулируем через парсинг текста
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_streaming(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Проверить доступность GigaChat."""
|
||||||
|
if self._available is not None:
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
# Проверяем наличие токенов
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
client_id = os.getenv("GIGACHAT_CLIENT_ID")
|
||||||
|
client_secret = os.getenv("GIGACHAT_CLIENT_SECRET")
|
||||||
|
|
||||||
|
self._available = bool(client_id and client_secret)
|
||||||
|
|
||||||
|
if not self._available:
|
||||||
|
logger.warning("GigaChat недоступен: не настроены GIGACHAT_CLIENT_ID или GIGACHAT_CLIENT_SECRET")
|
||||||
|
else:
|
||||||
|
logger.info("GigaChat доступен")
|
||||||
|
except Exception as e:
|
||||||
|
self._available = False
|
||||||
|
logger.error(f"Ошибка проверки доступности GigaChat: {e}")
|
||||||
|
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
def get_tools_schema(self, tools_registry: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Получить схему инструментов для промпта.
|
||||||
|
|
||||||
|
Формирует описание инструментов в формате понятном GigaChat.
|
||||||
|
"""
|
||||||
|
schema = []
|
||||||
|
for name, tool in tools_registry.items():
|
||||||
|
if hasattr(tool, 'get_schema'):
|
||||||
|
tool_schema = tool.get_schema()
|
||||||
|
schema.append({
|
||||||
|
"name": name,
|
||||||
|
"description": tool_schema.get("description", ""),
|
||||||
|
"parameters": tool_schema.get("parameters", {})
|
||||||
|
})
|
||||||
|
elif hasattr(tool, 'description'):
|
||||||
|
schema.append({
|
||||||
|
"name": name,
|
||||||
|
"description": tool.description,
|
||||||
|
"parameters": getattr(tool, 'parameters', {})
|
||||||
|
})
|
||||||
|
|
||||||
|
return schema
|
||||||
|
|
||||||
|
def _build_tools_prompt(self, tools_schema: List[Dict[str, Any]]) -> str:
|
||||||
|
"""
|
||||||
|
Построить текстовое описание инструментов для промпта.
|
||||||
|
|
||||||
|
GigaChat не поддерживает нативные tool calls, поэтому описываем
|
||||||
|
инструменты в тексте и просим модель использовать специальный формат.
|
||||||
|
"""
|
||||||
|
if not tools_schema:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
prompt_parts = [
|
||||||
|
"\n\n🛠️ ДОСТУПНЫЕ ИНСТРУМЕНТЫ:",
|
||||||
|
"Ты можешь использовать следующие инструменты. Для вызова инструмента используй формат:",
|
||||||
|
"```tool",
|
||||||
|
'{"name": "имя_инструмента", "arguments": {аргументы}}',
|
||||||
|
'```',
|
||||||
|
"",
|
||||||
|
"Список инструментов:"
|
||||||
|
]
|
||||||
|
|
||||||
|
for tool in tools_schema:
|
||||||
|
name = tool.get("name", "unknown")
|
||||||
|
desc = tool.get("description", "Нет описания")
|
||||||
|
params = tool.get("parameters", {})
|
||||||
|
|
||||||
|
prompt_parts.append(f"\n**{name}**")
|
||||||
|
prompt_parts.append(f"Описание: {desc}")
|
||||||
|
if params:
|
||||||
|
prompt_parts.append(f"Параметры: {json.dumps(params, ensure_ascii=False)}")
|
||||||
|
|
||||||
|
prompt_parts.extend([
|
||||||
|
"",
|
||||||
|
"После вызова инструмента ты получишь результат и сможешь продолжить ответ."
|
||||||
|
])
|
||||||
|
|
||||||
|
return "\n".join(prompt_parts)
|
||||||
|
|
||||||
|
def _parse_tool_calls(self, content: str) -> List[ToolCall]:
|
||||||
|
"""
|
||||||
|
Извлечь вызовы инструментов из текста ответа.
|
||||||
|
|
||||||
|
Ищет блоки вида:
|
||||||
|
```tool
|
||||||
|
{"name": "ssh_tool", "arguments": {"command": "df -h"}}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
tool_calls = []
|
||||||
|
|
||||||
|
# Ищем блоки ```tool {...}```
|
||||||
|
pattern = r'```tool\s*\n({.*?})\s*\n```'
|
||||||
|
matches = re.findall(pattern, content, re.DOTALL)
|
||||||
|
|
||||||
|
for match in matches:
|
||||||
|
try:
|
||||||
|
tool_data = json.loads(match)
|
||||||
|
tool_name = tool_data.get("name")
|
||||||
|
tool_args = tool_data.get("arguments", {})
|
||||||
|
|
||||||
|
if tool_name:
|
||||||
|
tool_calls.append(ToolCall(
|
||||||
|
tool_name=tool_name,
|
||||||
|
tool_args=tool_args,
|
||||||
|
tool_call_id=f"gc_{len(tool_calls)}"
|
||||||
|
))
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"Ошибка парсинга tool call: {e}")
|
||||||
|
|
||||||
|
return tool_calls
|
||||||
|
|
||||||
|
def _remove_tool_blocks(self, content: str) -> str:
|
||||||
|
"""Удалить блоки вызовов инструментов из текста."""
|
||||||
|
pattern = r'```tool\s*\n\{.*?\}\s*\n```'
|
||||||
|
return re.sub(pattern, '', content, flags=re.DOTALL).strip()
|
||||||
|
|
||||||
|
async def chat(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
system_prompt: Optional[str] = None,
|
||||||
|
context: Optional[List[Dict[str, str]]] = None,
|
||||||
|
tools: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
on_chunk: Optional[Callable[[str], Any]] = None,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
**kwargs
|
||||||
|
) -> ProviderResponse:
|
||||||
|
"""
|
||||||
|
Отправить запрос GigaChat.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: Запрос пользователя
|
||||||
|
system_prompt: Системный промпт
|
||||||
|
context: История диалога
|
||||||
|
tools: Доступные инструменты (схема)
|
||||||
|
on_chunk: Callback для потокового вывода (не используется)
|
||||||
|
user_id: ID пользователя
|
||||||
|
**kwargs: Дополнительные параметры
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProviderResponse с ответом и возможными вызовами инструментов
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Формируем системный промпт с инструментами
|
||||||
|
full_system_prompt = system_prompt or ""
|
||||||
|
|
||||||
|
if tools:
|
||||||
|
tools_prompt = self._build_tools_prompt(tools)
|
||||||
|
full_system_prompt += tools_prompt
|
||||||
|
|
||||||
|
# Формируем сообщения
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
if full_system_prompt:
|
||||||
|
messages.append(GigaChatMessage(role="system", content=full_system_prompt))
|
||||||
|
|
||||||
|
if context:
|
||||||
|
for msg in context:
|
||||||
|
role = msg.get("role", "user")
|
||||||
|
content = msg.get("content", "")
|
||||||
|
if role in ("user", "assistant", "system"):
|
||||||
|
messages.append(GigaChatMessage(role=role, content=content))
|
||||||
|
|
||||||
|
if prompt:
|
||||||
|
messages.append(GigaChatMessage(role="user", content=prompt))
|
||||||
|
|
||||||
|
# Выполняем запрос
|
||||||
|
result = await self._tool.chat(
|
||||||
|
messages=messages,
|
||||||
|
user_id=str(user_id) if user_id else None,
|
||||||
|
temperature=kwargs.get("temperature", 0.7),
|
||||||
|
max_tokens=kwargs.get("max_tokens", 2000),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.get("content"):
|
||||||
|
if result.get("error"):
|
||||||
|
return ProviderResponse(
|
||||||
|
success=False,
|
||||||
|
error=result["error"],
|
||||||
|
provider_name=self.provider_name
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return ProviderResponse(
|
||||||
|
success=False,
|
||||||
|
error="Пустой ответ от GigaChat",
|
||||||
|
provider_name=self.provider_name
|
||||||
|
)
|
||||||
|
|
||||||
|
content = result["content"]
|
||||||
|
|
||||||
|
# Парсим вызовы инструментов
|
||||||
|
tool_calls = self._parse_tool_calls(content)
|
||||||
|
|
||||||
|
# Очищаем контент от блоков инструментов
|
||||||
|
clean_content = self._remove_tool_blocks(content)
|
||||||
|
|
||||||
|
return ProviderResponse(
|
||||||
|
success=True,
|
||||||
|
message=AIMessage(
|
||||||
|
content=clean_content,
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
metadata={
|
||||||
|
"model": result.get("model", "GigaChat"),
|
||||||
|
"usage": result.get("usage", {})
|
||||||
|
}
|
||||||
|
),
|
||||||
|
provider_name=self.provider_name,
|
||||||
|
usage=result.get("usage")
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка GigaChat провайдера: {e}")
|
||||||
|
return ProviderResponse(
|
||||||
|
success=False,
|
||||||
|
error=str(e),
|
||||||
|
provider_name=self.provider_name
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute_tool(
|
||||||
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
tool_args: Dict[str, Any],
|
||||||
|
tool_call_id: Optional[str] = None,
|
||||||
|
**kwargs
|
||||||
|
) -> ToolCall:
|
||||||
|
"""
|
||||||
|
Выполнить инструмент (заглушка).
|
||||||
|
|
||||||
|
GigaChat не выполняет инструменты напрямую - это делает
|
||||||
|
AIProviderManager через process_with_tools.
|
||||||
|
"""
|
||||||
|
return ToolCall(
|
||||||
|
tool_name=tool_name,
|
||||||
|
tool_args=tool_args,
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
status=ToolCallStatus.PENDING
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Qwen Code AI Provider - адаптер Qwen Code для работы с инструментами.
|
||||||
|
|
||||||
|
Реализует интерфейс BaseAIProvider для единой работы с инструментами
|
||||||
|
независимо от AI-провайдера.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
from typing import Optional, Dict, Any, Callable, List
|
||||||
|
|
||||||
|
from bot.base_ai_provider import (
|
||||||
|
BaseAIProvider,
|
||||||
|
ProviderResponse,
|
||||||
|
AIMessage,
|
||||||
|
ToolCall,
|
||||||
|
ToolCallStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class QwenCodeProvider(BaseAIProvider):
|
||||||
|
"""
|
||||||
|
Qwen Code AI Provider с нативной поддержкой инструментов.
|
||||||
|
|
||||||
|
Использует Qwen Code CLI с потоковым выводом и парсингом tool calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, qwen_manager):
|
||||||
|
self._qwen_manager = qwen_manager
|
||||||
|
|
||||||
|
@property
|
||||||
|
def provider_name(self) -> str:
|
||||||
|
return "Qwen Code"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_tools(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_streaming(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Qwen Code всегда доступен (локальный CLI)."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _parse_qwen_result(self, raw_result: str) -> tuple[str, List[ToolCall]]:
|
||||||
|
"""
|
||||||
|
Распарсить результат от Qwen Code.
|
||||||
|
|
||||||
|
Извлекает текст и вызовы инструментов из stream-json вывода.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(content, tool_calls)
|
||||||
|
"""
|
||||||
|
content_parts = []
|
||||||
|
tool_calls = []
|
||||||
|
|
||||||
|
# Пытаемся распарсить JSON
|
||||||
|
try:
|
||||||
|
# Qwen может возвращать как单个 JSON так и несколько JSON lines
|
||||||
|
lines = raw_result.strip().split('\n')
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Пробуем распарсить как JSON
|
||||||
|
try:
|
||||||
|
data = json.loads(line)
|
||||||
|
|
||||||
|
# Обрабатываем разные типы событий
|
||||||
|
event_type = data.get('type') or data.get('event_type')
|
||||||
|
|
||||||
|
if event_type == 'assistant' or 'content' in data:
|
||||||
|
content = data.get('content') or data.get('message', '')
|
||||||
|
if content:
|
||||||
|
content_parts.append(content)
|
||||||
|
|
||||||
|
# Ищем tool calls
|
||||||
|
if 'tool_calls' in data or 'tool_use' in data:
|
||||||
|
tool_calls_data = data.get('tool_calls') or data.get('tool_use', [])
|
||||||
|
for tc in tool_calls_data:
|
||||||
|
tool_calls.append(ToolCall(
|
||||||
|
tool_name=tc.get('name') or tc.get('tool_name', ''),
|
||||||
|
tool_args=tc.get('arguments') or tc.get('args', {}),
|
||||||
|
tool_call_id=tc.get('id') or tc.get('tool_call_id')
|
||||||
|
))
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Не JSON - считаем текстом
|
||||||
|
if line.strip():
|
||||||
|
content_parts.append(line)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Ошибка парсинга Qwen результата: {e}")
|
||||||
|
|
||||||
|
# Фоллбэк: ищем текст в кавычках
|
||||||
|
text_matches = re.findall(r'"text":"([^"]+)"', raw_result)
|
||||||
|
if text_matches:
|
||||||
|
content_parts.extend(text_matches)
|
||||||
|
|
||||||
|
# Собираем контент
|
||||||
|
content = ' '.join(content_parts).replace('\\n', '\n').strip()
|
||||||
|
|
||||||
|
return content, tool_calls
|
||||||
|
|
||||||
|
async def chat(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
system_prompt: Optional[str] = None,
|
||||||
|
context: Optional[List[Dict[str, str]]] = None,
|
||||||
|
tools: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
on_chunk: Optional[Callable[[str], Any]] = None,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
**kwargs
|
||||||
|
) -> ProviderResponse:
|
||||||
|
"""
|
||||||
|
Отправить запрос Qwen Code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: Запрос пользователя
|
||||||
|
system_prompt: Системный промпт
|
||||||
|
context: История диалога
|
||||||
|
tools: Доступные инструменты (схема) - пока не используется
|
||||||
|
on_chunk: Callback для потокового вывода
|
||||||
|
user_id: ID пользователя
|
||||||
|
**kwargs: Дополнительные параметры
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProviderResponse с ответом и возможными вызовами инструментов
|
||||||
|
"""
|
||||||
|
if not self._qwen_manager:
|
||||||
|
return ProviderResponse(
|
||||||
|
success=False,
|
||||||
|
error="Qwen менеджер не инициализирован",
|
||||||
|
provider_name=self.provider_name
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_id is None:
|
||||||
|
return ProviderResponse(
|
||||||
|
success=False,
|
||||||
|
error="user_id обязателен для Qwen Code",
|
||||||
|
provider_name=self.provider_name
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Формируем полный промпт
|
||||||
|
full_prompt = prompt or ""
|
||||||
|
|
||||||
|
if system_prompt and kwargs.get('use_system_prompt', True):
|
||||||
|
full_prompt = f"{system_prompt}\n\n{full_prompt}"
|
||||||
|
|
||||||
|
# Добавляем контекст если есть
|
||||||
|
if context:
|
||||||
|
context_text = "\n".join([
|
||||||
|
f"{msg.get('role', 'user')}: {msg.get('content', '')}"
|
||||||
|
for msg in context
|
||||||
|
])
|
||||||
|
full_prompt = f"{context_text}\n\n{full_prompt}"
|
||||||
|
|
||||||
|
# Выполняем через Qwen Manager
|
||||||
|
output_buffer = []
|
||||||
|
|
||||||
|
def on_output(text: str):
|
||||||
|
output_buffer.append(text)
|
||||||
|
|
||||||
|
def on_chunk_wrapper(text: str):
|
||||||
|
if on_chunk:
|
||||||
|
on_chunk(text)
|
||||||
|
|
||||||
|
result = await self._qwen_manager.run_task(
|
||||||
|
user_id=user_id,
|
||||||
|
task=full_prompt,
|
||||||
|
on_output=on_output,
|
||||||
|
on_oauth_url=lambda x: None,
|
||||||
|
use_system_prompt=False, # Уже добавили в full_prompt
|
||||||
|
on_chunk=on_chunk_wrapper,
|
||||||
|
on_event=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Парсим результат
|
||||||
|
content, tool_calls = self._parse_qwen_result(result)
|
||||||
|
|
||||||
|
if not content and not tool_calls:
|
||||||
|
# Если ничего не распарсили, возвращаем сырой результат
|
||||||
|
content = result
|
||||||
|
|
||||||
|
return ProviderResponse(
|
||||||
|
success=True,
|
||||||
|
message=AIMessage(
|
||||||
|
content=content,
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
metadata={"raw_result": result}
|
||||||
|
),
|
||||||
|
provider_name=self.provider_name
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка Qwen Code провайдера: {e}")
|
||||||
|
return ProviderResponse(
|
||||||
|
success=False,
|
||||||
|
error=str(e),
|
||||||
|
provider_name=self.provider_name
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute_tool(
|
||||||
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
tool_args: Dict[str, Any],
|
||||||
|
tool_call_id: Optional[str] = None,
|
||||||
|
**kwargs
|
||||||
|
) -> ToolCall:
|
||||||
|
"""
|
||||||
|
Выполнить инструмент (заглушка).
|
||||||
|
|
||||||
|
Qwen Code не выполняет инструменты напрямую - это делает
|
||||||
|
AIProviderManager через process_with_tools.
|
||||||
|
"""
|
||||||
|
return ToolCall(
|
||||||
|
tool_name=tool_name,
|
||||||
|
tool_args=tool_args,
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
status=ToolCallStatus.PENDING
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue