Initial commit: Telegram CLI Bot with AI memory, tools and multi-provider support

This commit is contained in:
mirivlad
2026-03-16 03:47:15 +08:00
commit 6b8bd8d7a6
69 changed files with 20857 additions and 0 deletions
+14
View File
@@ -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",
]
+519
View File
@@ -0,0 +1,519 @@
#!/usr/bin/env python3
"""
GigaChat AI Provider - адаптер GigaChat для работы с инструментами.
Реализует интерфейс BaseAIProvider для единой работы с инструментами
независимо от AI-провайдера.
Использует нативный GigaChat Function Calling API:
https://developers.sber.ru/docs/ru/gigachat/guides/functions/overview
"""
import logging
from typing import Optional, Dict, Any, Callable, List
import json
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 с нативной поддержкой function calling.
Использует официальный GigaChat Function Calling API вместо
эмуляции через текстовые блоки.
"""
def __init__(self, config: Optional[GigaChatConfig] = None):
self._tool = GigaChatTool(config)
self._available: Optional[bool] = None
self._functions_state_id: Optional[str] = None
@property
def provider_name(self) -> str:
return "GigaChat"
@property
def supports_tools(self) -> bool:
# GigaChat поддерживает нативные function 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_error(self) -> Optional[str]:
"""Получить последнюю ошибку."""
if self._available is False:
return "GigaChat недоступен: проверьте GIGACHAT_CLIENT_ID и GIGACHAT_CLIENT_SECRET"
return None
def get_functions_schema(self, tools_registry: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
Получить схему функций для GigaChat API в правильном формате.
Формат GigaChat:
{
"name": "function_name",
"description": "Описание функции",
"parameters": {
"type": "object",
"properties": {...},
"required": [...]
},
"return_parameters": {...} # опционально
}
"""
schema = []
if tools_registry is None:
return schema
# Обрабатываем разные типы tools_registry
items = []
if hasattr(tools_registry, 'get_all') and callable(getattr(tools_registry, 'get_all')):
items = list(tools_registry.get_all().items())
elif isinstance(tools_registry, dict):
items = list(tools_registry.items())
elif hasattr(tools_registry, 'tools'):
items = list(tools_registry.tools.items()) if isinstance(tools_registry.tools, dict) else []
for name, tool in items:
if hasattr(tool, 'get_schema'):
tool_schema = tool.get_schema()
# Преобразуем в формат GigaChat с гарантией наличия properties
parameters = tool_schema.get("parameters", {})
if not parameters:
parameters = {"type": "object", "properties": {}}
elif "properties" not in parameters:
parameters["properties"] = {}
giga_schema = {
"name": name,
"description": tool_schema.get("description", ""),
"parameters": parameters
}
# Добавляем return_parameters если есть
if hasattr(tool, 'get_return_schema'):
giga_schema["return_parameters"] = tool.get_return_schema()
schema.append(giga_schema)
elif hasattr(tool, 'description'):
schema.append({
"name": name,
"description": tool.description,
"parameters": {"type": "object", "properties": {}} # Пустая но валидная схема
})
logger.info(f"📋 GigaChat functions schema: {[f['name'] for f in schema]}")
return schema
def _parse_function_call(self, function_call: Dict[str, Any]) -> ToolCall:
"""
Преобразовать function_call из ответа GigaChat в ToolCall.
GigaChat возвращает:
{
"name": "function_name",
"arguments": {"arg1": "value1", ...}
}
"""
try:
# Аргументы могут быть строкой JSON или уже dict
args = function_call.get("arguments", {})
if isinstance(args, str):
args = json.loads(args)
except (json.JSONDecodeError, TypeError) as e:
logger.warning(f"Ошибка парсинга аргументов function_call: {e}")
args = {}
return ToolCall(
tool_name=function_call.get("name", "unknown"),
tool_args=args,
tool_call_id=function_call.get("name", "fc_0") # Используем name как ID
)
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:
"""
Обработка запросов с function calling для GigaChat.
Использует нативный GigaChat Function Calling API:
1. Отправляем запрос с functions массивом
2. Получаем function_call из ответа
3. Выполняем инструмент
4. Отправляем результат с role: "function"
5. Повторяем пока не будет финального ответа
Формат сообщений:
- user: {"role": "user", "content": "..."}
- assistant: {"role": "assistant", "function_call": {...}}
- function: {"role": "function", "name": "...", "content": "..."}
"""
if not tools_registry:
return await self.chat(
prompt=prompt,
system_prompt=system_prompt,
context=context,
on_chunk=on_chunk,
**kwargs
)
# Формируем базовые сообщения
messages = []
# Добавляем системный промпт если есть
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
# Добавляем контекст (историю диалога)
if context:
for msg in context:
role = msg.get("role")
# Пропускаем system messages — они уже добавлены
if role == "system":
continue
# Преобразуем tool messages в function messages
if role == "tool":
role = "function"
if role in ("user", "assistant", "function"):
messages.append({
"role": role,
"content": msg.get("content", ""),
"name": msg.get("name") # Для function messages
})
# Добавляем текущий запрос пользователя
if prompt:
messages.append({"role": "user", "content": prompt})
# Получаем схему функций
functions = self.get_functions_schema(tools_registry) if self.supports_tools else None
logger.info(f"🔍 GigaChat process_with_tools: {len(messages)} сообщений, {len(functions) if functions else 0} функций")
for iteration in range(max_iterations):
logger.info(f"🔄 Итерация {iteration + 1}/{max_iterations}")
# Логируем сообщения перед отправкой
for i, msg in enumerate(messages[-3:]): # Последние 3 сообщения
content_preview = msg.get("content", "")[:100]
logger.info(f" 📨 [{i}] role={msg.get('role')}, content='{content_preview}...'")
# Отправляем запрос с functions
response = await self._chat_with_functions(
messages=messages,
functions=functions,
user_id=kwargs.get('user_id'),
temperature=kwargs.get("temperature", 0.7),
max_tokens=kwargs.get("max_tokens", 2000),
)
if not response.get("success"):
return ProviderResponse(
success=False,
error=response.get("error", "Неизвестная ошибка"),
provider_name=self.provider_name
)
# Проверяем наличие function_call
function_call = response.get("function_call")
content = response.get("content", "")
logger.info(f"📬 Ответ GigaChat: content_len={len(content) if content else 0}, function_call={function_call is not None}")
# Если нет function_call — возвращаем финальный ответ
if not function_call:
return ProviderResponse(
success=True,
message=AIMessage(
content=content,
tool_calls=[],
metadata={
"model": response.get("model", "GigaChat"),
"usage": response.get("usage", {}),
"functions_state_id": response.get("functions_state_id")
}
),
provider_name=self.provider_name,
usage=response.get("usage")
)
# Есть function_call — парсим и выполняем инструмент
tool_call = self._parse_function_call(function_call)
logger.info(f"🛠️ Function call: {tool_call.tool_name}({tool_call.tool_args})")
# Выполняем инструмент
if hasattr(tools_registry, 'get'):
tool = tools_registry.get(tool_call.tool_name)
elif isinstance(tools_registry, dict):
tool = tools_registry.get(tool_call.tool_name)
else:
tool = None
if tool is not None:
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:
logger.exception(f"Ошибка выполнения инструмента {tool_call.tool_name}: {e}")
tool_call.error = str(e)
tool_call.status = ToolCallStatus.ERROR
result = {"error": str(e)}
else:
tool_call.error = f"Инструмент {tool_call.tool_name} не найден"
tool_call.status = ToolCallStatus.ERROR
result = {"error": tool_call.error}
# Сериализуем результат
if hasattr(result, 'to_dict'):
result_dict = result.to_dict()
elif isinstance(result, dict):
result_dict = result
else:
result_dict = {"result": str(result)}
result_json = json.dumps(result_dict, ensure_ascii=False)
# Добавляем assistant message с function_call
messages.append({
"role": "assistant",
"content": "", # Пустой content при function_call
"function_call": function_call
})
# Добавляем function message с результатом
messages.append({
"role": "function",
"name": tool_call.tool_name,
"content": result_json
})
logger.info(f"✅ Добавлен function result: {tool_call.tool_name}, result_len={len(result_json)}")
# Сохраняем functions_state_id для следующей итерации
if response.get("functions_state_id"):
self._functions_state_id = response["functions_state_id"]
# Достигли максимума итераций
return ProviderResponse(
success=True,
message=AIMessage(
content=content + "\n\n[Достигнут максимум итераций выполнения функций]",
metadata={"iterations": max_iterations}
),
provider_name=self.provider_name,
usage=response.get("usage")
)
async def _chat_with_functions(
self,
messages: List[Dict[str, Any]],
functions: Optional[List[Dict[str, Any]]] = None,
user_id: Optional[int] = None,
temperature: float = 0.7,
max_tokens: int = 2000,
) -> Dict[str, Any]:
"""
Отправить запрос в GigaChat API с поддержкой function calling.
Возвращает:
{
"success": bool,
"content": str,
"function_call": {"name": str, "arguments": dict} или None,
"model": str,
"usage": dict,
"functions_state_id": str или None
}
"""
try:
# Формируем сообщения в формате GigaChat
gc_messages = []
for msg in messages:
gc_msg = {"role": msg["role"], "content": msg.get("content", "")}
if msg.get("name"):
gc_msg["name"] = msg["name"]
if msg.get("function_call"):
gc_msg["function_call"] = msg["function_call"]
gc_messages.append(gc_msg)
# Выполняем запрос через GigaChatTool
result = await self._tool.chat_with_functions(
messages=gc_messages,
functions=functions,
user_id=str(user_id) if user_id else None,
temperature=temperature,
max_tokens=max_tokens,
)
# Извлекаем function_call из ответа
function_call = None
if result.get("choices"):
choice = result["choices"][0]
message = choice.get("message", {})
function_call = message.get("function_call")
return {
"success": True,
"content": result.get("content", ""),
"function_call": function_call,
"model": result.get("model", "GigaChat"),
"usage": result.get("usage", {}),
"functions_state_id": result.get("functions_state_id")
}
except Exception as e:
logger.exception(f"Ошибка _chat_with_functions: {e}")
return {
"success": False,
"error": str(e),
"function_call": None
}
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 (без function calling).
Используется когда tools не переданы.
"""
try:
# Формируем сообщения
messages = []
if system_prompt:
messages.append(GigaChatMessage(role="system", content=system_prompt))
if context:
for msg in context:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
continue
if role in ("user", "assistant"):
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"]
return ProviderResponse(
success=True,
message=AIMessage(
content=content,
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:
"""
Выполнить инструмент (заглушка).
Инструменты выполняются через process_with_tools.
"""
return ToolCall(
tool_name=tool_name,
tool_args=tool_args,
tool_call_id=tool_call_id,
status=ToolCallStatus.PENDING
)
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env python3
"""
Opencode AI Provider - интеграция с opencode CLI.
Использует opencode run для выполнения задач с бесплатными моделями:
- opencode/minimax-m2.5-free
- opencode/big-pickle
- opencode/gpt-5-nano
Поддерживает RAG через память бота.
"""
import os
import re
import asyncio
import logging
import subprocess
from pathlib import Path
from typing import Optional, Dict, Any, Callable, List
from dataclasses import dataclass, field
from bot.base_ai_provider import (
BaseAIProvider,
ProviderResponse,
AIMessage,
ToolCall,
ToolCallStatus,
)
logger = logging.getLogger(__name__)
OPENCODE_BIN = os.environ.get("OPENCODE_BIN", "/home/mirivlad/.opencode/bin/opencode")
AVAILABLE_MODELS = {
"minimax": "opencode/minimax-m2.5-free",
"big_pickle": "opencode/big-pickle",
"gpt5": "opencode/gpt-5-nano",
}
DEFAULT_MODEL = "minimax"
@dataclass
class OpencodeSession:
"""Сессия пользователя с opencode."""
user_id: int
model: str = DEFAULT_MODEL
history: List[Dict[str, str]] = field(default_factory=list)
class OpencodeProvider(BaseAIProvider):
"""
Opencode AI Provider.
Использует opencode CLI для генерации ответов.
Поддерживает несколько бесплатных моделей.
"""
def __init__(self):
self._sessions: Dict[int, OpencodeSession] = {}
self._default_model = DEFAULT_MODEL
self._initialized = False
@property
def provider_name(self) -> str:
return "Opencode"
@property
def supports_tools(self) -> bool:
return True
@property
def supports_streaming(self) -> bool:
return False
def is_available(self) -> bool:
"""Проверка доступности opencode CLI."""
return Path(OPENCODE_BIN).exists()
def get_session(self, user_id: int) -> OpencodeSession:
"""Получить или создать сессию пользователя."""
if user_id not in self._sessions:
self._sessions[user_id] = OpencodeSession(
user_id=user_id,
model=self._default_model
)
return self._sessions[user_id]
def set_model(self, user_id: int, model_key: str):
"""Установить модель для пользователя."""
session = self.get_session(user_id)
if model_key in AVAILABLE_MODELS:
session.model = AVAILABLE_MODELS[model_key]
logger.info(f"User {user_id} switched to model: {session.model}")
def get_model(self, user_id: int) -> str:
"""Получить текущую модель пользователя (полное имя)."""
session = self.get_session(user_id)
# Возвращаем полное имя модели из AVAILABLE_MODELS
return AVAILABLE_MODELS.get(session.model, session.model)
def get_available_models(self) -> Dict[str, str]:
"""Получить список доступных моделей."""
return AVAILABLE_MODELS.copy()
def _build_context(
self,
system_prompt: Optional[str],
context: Optional[List[Dict[str, str]]],
memory_context: str = ""
) -> str:
"""Собрать полный контекст для opencode."""
parts = []
if system_prompt:
parts.append(f"=== SYSTEM PROMPT ===\n{system_prompt}")
if memory_context:
parts.append(f"=== MEMORY CONTEXT ===\n{memory_context}")
if context:
history_text = "\n".join([
f"{msg.get('role', 'user')}: {msg.get('content', '')}"
for msg in context
if msg.get('role') != 'system'
])
if history_text:
parts.append(f"=== CONVERSATION HISTORY ===\n{history_text}")
return "\n\n".join(parts)
async def _run_opencode(
self,
prompt: str,
model: str,
on_chunk: Optional[Callable[[str], Any]] = None
) -> str:
"""
Выполнить запрос через opencode CLI.
Args:
prompt: Запрос пользователя
model: Модель для использования
on_chunk: Callback для потокового вывода (не используется)
Returns:
Ответ от opencode
"""
try:
logger.info(f"Opencode _run_opencode: model={model}, prompt_len={len(prompt) if prompt else 0}")
# Используем stdin для передачи промпта
cmd = [
OPENCODE_BIN,
"run",
"-m", model
]
logger.info(f"Running opencode cmd: {cmd}")
# Кодируем промпт для stdin
prompt_bytes = prompt.encode('utf-8') if prompt else b''
process = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=str(Path.home()),
)
# Отправляем промпт в stdin
stdout, _ = await asyncio.wait_for(
process.communicate(input=prompt_bytes),
timeout=120.0
)
full_output = stdout.decode('utf-8', errors='replace')
# Очищаем от ANSI кодов и служебных символов
full_output = self._clean_output(full_output)
return full_output
except asyncio.TimeoutError:
logger.error("Opencode timeout")
return "⏱️ Таймаут выполнения (2 минуты)"
except Exception as e:
logger.error(f"Opencode error: {e}")
return f"❌ Ошибка opencode: {str(e)}"
def _clean_output(self, output: str) -> str:
"""Очистить вывод от служебных символов."""
# Убираем ANSI escape последовательности
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
output = ansi_escape.sub('', output)
# Убираем служебные строки
lines = output.split('\n')
cleaned_lines = []
for line in lines:
# Пропускаем служебные строки
if any(x in line.lower() for x in ['build', 'minimax', 'gpt', 'elapsed', 'rss', 'bun v']):
continue
if line.startswith('>'):
continue
if not line.strip():
continue
cleaned_lines.append(line)
return "\n".join(cleaned_lines).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,
memory_context: Optional[str] = None,
**kwargs
) -> ProviderResponse:
"""
Отправить запрос к Opencode.
Args:
prompt: Запрос пользователя
system_prompt: Системный промпт
context: История диалога
tools: Доступные инструменты (схема) - пока не используется
on_chunk: Callback для потокового вывода
user_id: ID пользователя
memory_context: Контекст из памяти бота
Returns:
ProviderResponse с ответом
"""
if not self.is_available():
return ProviderResponse(
success=False,
error="Opencode CLI не найден",
provider_name=self.provider_name
)
if user_id is None:
return ProviderResponse(
success=False,
error="user_id обязателен для Opencode",
provider_name=self.provider_name
)
try:
# Получаем текущую модель
model = self.get_model(user_id)
logger.info(f"Opencode: user_id={user_id}, model={model}, session={self._sessions.get(user_id)}")
# Собираем контекст
full_context = self._build_context(
system_prompt=system_prompt,
context=context,
memory_context=memory_context or ""
)
# Формируем полный промпт
# Когда prompt=None (из process_with_tools), используем контекст напрямую
if prompt is None:
full_prompt = full_context if full_context else ""
elif full_context:
full_prompt = f"{full_context}\n\n=== CURRENT REQUEST ===\n{prompt}"
else:
full_prompt = prompt
# Добавляем информацию об инструментах если есть
if tools:
tools_info = self._format_tools_for_prompt(tools)
full_prompt = f"{full_prompt}\n\n=== AVAILABLE TOOLS ===\n{tools_info}"
logger.info(f"Opencode request (model={model}): {str(prompt)[:50] if prompt else 'from context'}...")
# Выполняем запрос
result = await self._run_opencode(
prompt=full_prompt,
model=model,
on_chunk=on_chunk
)
if not result:
result = "⚠️ Пустой ответ от Opencode"
return ProviderResponse(
success=True,
message=AIMessage(
content=result,
metadata={"model": model}
),
provider_name=self.provider_name
)
except Exception as e:
logger.error(f"Opencode provider error: {e}")
return ProviderResponse(
success=False,
error=str(e),
provider_name=self.provider_name
)
def _format_tools_for_prompt(self, tools: List[Dict[str, Any]]) -> str:
"""Форматировать инструменты для промпта."""
if not tools:
return ""
lines = ["У тебя есть следующие инструменты:\n"]
for tool in tools:
name = tool.get('name', 'unknown')
desc = tool.get('description', 'Нет описания')
params = tool.get('parameters', {})
lines.append(f"- {name}: {desc}")
if params:
props = params.get('properties', {})
if props:
lines.append(f" Параметры: {', '.join(props.keys())}")
return "\n".join(lines)
async def execute_tool(
self,
tool_name: str,
tool_args: Dict[str, Any],
tool_call_id: Optional[str] = None,
**kwargs
) -> ToolCall:
"""Выполнить инструмент (заглушка)."""
return ToolCall(
tool_name=tool_name,
tool_args=tool_args,
tool_call_id=tool_call_id,
status=ToolCallStatus.PENDING
)
# Глобальный экземпляр
opencode_provider = OpencodeProvider()
+268
View File
@@ -0,0 +1,268 @@
#!/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
# Список инструментов бота - только их пропускаем
ALLOWED_TOOLS = {
'ddgs_tool',
'rss_tool',
'ssh_tool',
'cron_tool',
'file_system_tool',
'telegram_web_tool',
}
def _parse_qwen_result(self, raw_result: str) -> tuple[str, List[ToolCall]]:
"""
Распарсить результат от Qwen Code.
Извлекает текст и вызовы инструментов из stream-json вывода.
Формат stream-json от Qwen Code:
{"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}
{"type":"assistant","message":{"content":[{"type":"tool_use","name":"ssh_tool","args":{...}}]}}
{"type":"result","result":"...","duration_ms":1234}
Returns:
(content, tool_calls)
"""
content_parts = []
tool_calls = []
# Пытаемся распарсить JSON lines
try:
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')
if event_type == 'assistant':
message = data.get('message', {})
content_list = message.get('content', [])
# Обрабатываем только если content - это список
if isinstance(content_list, list):
for content_item in content_list:
if isinstance(content_item, dict):
if content_item.get('type') == 'text':
text_content = content_item.get('text', '')
logger.debug(f"Text chunk: {text_content[:50]}...")
content_parts.append(text_content)
elif content_item.get('type') == 'tool_use':
# Извлекаем tool call
tool_name = content_item.get('name', '')
tool_args = content_item.get('args', {})
tool_call_id = content_item.get('id', None)
# 🔥 ФИЛЬТР: пропускаем только инструменты бота
if tool_name not in self.ALLOWED_TOOLS:
logger.warning(f"⚠️ Игнорируем MCP инструмент Qwen Code: {tool_name}")
continue
logger.info(f"Обнаружен tool_use: {tool_name}")
tool_calls.append(ToolCall(
tool_name=tool_name,
tool_args=tool_args,
tool_call_id=tool_call_id,
status=ToolCallStatus.PENDING
))
elif event_type == 'result':
# Result event может содержать финальный текст
result_text = data.get('result', '')
if result_text:
content_parts.append(result_text)
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([t.replace('\\n', '\n') for t in text_matches])
# Собираем контент
content = ''.join(content_parts).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)
async def on_chunk_wrapper(text: str):
if on_chunk:
await 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
)