diff --git a/.env.example b/.env.example index 92e37f2..870e55a 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,7 @@ STT_MODEL=vosk # Russian LLM (optional) GIGACHAT_CREDENTIALS= YANDEX_API_KEY= +YANDEX_FOLDER_ID= # Database DATABASE_URL=sqlite+aiosqlite:///./valera.db diff --git a/config/config.py b/config/config.py index 46bc135..e2e2447 100644 --- a/config/config.py +++ b/config/config.py @@ -27,6 +27,7 @@ class Settings(BaseSettings): gigachat_credentials: Optional[str] = None yandex_api_key: Optional[str] = None + yandex_folder_id: Optional[str] = None database_url: str = "sqlite+aiosqlite:///./valera.db" diff --git a/src/bot/main.py b/src/bot/main.py index cd477ce..47fd570 100644 --- a/src/bot/main.py +++ b/src/bot/main.py @@ -44,10 +44,12 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): "/help - Показать эту справку\n" "/mode confirm - Режим с подтверждением\n" "/mode auto - Автономный режим\n" - "/use qwen|open - Выбрать инструмент\n" + "/use qwen|open|gigachat|yandex - Выбрать инструмент\n" "/cancel - Отменить текущее действие\n" "/qwen <текст> - Задать вопрос qwen-code\n" "/open <текст> - Задать вопрос opencode\n" + "/gigachat <текст> - Задать вопрос Gigachat\n" + "/yandex <текст> - Задать вопрос YandexGPT\n" "/forget - Очистить историю чата\n" "/remind <текст> <время> - Создать напоминание\n" "/stt on|off - Включить/выключить распознавание речи\n\n" @@ -79,16 +81,21 @@ async def mode_command(update: Update, context: ContextTypes.DEFAULT_TYPE): async def use_command(update: Update, context: ContextTypes.DEFAULT_TYPE): if not context.args: current = orchestrator.get_default_tool() - await update.message.reply_text(f"Текущий инструмент: {current}") + available = ", ".join(orchestrator.get_available_tools()) + await update.message.reply_text( + f"Текущий инструмент: {current}\n" + f"Доступные: {available}" + ) return tool = context.args[0].lower() - if tool in ["qwen", "open"]: - tool = "qwen" if tool == "qwen" else "opencode" + if tool in ["qwen", "open", "gigachat", "yandex"]: + tool_map = {"qwen": "qwen", "open": "opencode", "gigachat": "gigachat", "yandex": "yandex"} + tool = tool_map.get(tool, tool) orchestrator.set_default_tool(tool) await update.message.reply_text(f"Инструмент изменён на {tool}") else: - await update.message.reply_text("Использование: /use qwen | open") + await update.message.reply_text("Использование: /use qwen | open | gigachat | yandex") async def cancel_command(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -224,6 +231,72 @@ async def open_command(update: Update, context: ContextTypes.DEFAULT_TYPE): await execute_tool_query(update, "opencode", prompt) +async def gigachat_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + prompt = " ".join(context.args) + if not prompt: + await update.message.reply_text("Использование: /gigachat <текст>") + return + + chat_id = update.effective_chat.id + mode = chat_state.get_mode(chat_id) + + if mode == ChatMode.CONFIRM: + keyboard = [ + [ + InlineKeyboardButton("Да", callback_data="confirm_yes"), + InlineKeyboardButton("Нет", callback_data="confirm_no") + ] + ] + reply_markup = InlineKeyboardMarkup(keyboard) + + chat_state.set_waiting_confirmation(chat_id, True, { + "type": "tool", + "tool": "gigachat", + "prompt": prompt + }) + + await update.message.reply_text( + f"Выполнить запрос к Gigachat?\n\n{prompt[:200]}...", + reply_markup=reply_markup + ) + else: + await update.message.reply_text("Думаю...") + await execute_tool_query(update, "gigachat", prompt) + + +async def yandex_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + prompt = " ".join(context.args) + if not prompt: + await update.message.reply_text("Использование: /yandex <текст>") + return + + chat_id = update.effective_chat.id + mode = chat_state.get_mode(chat_id) + + if mode == ChatMode.CONFIRM: + keyboard = [ + [ + InlineKeyboardButton("Да", callback_data="confirm_yes"), + InlineKeyboardButton("Нет", callback_data="confirm_no") + ] + ] + reply_markup = InlineKeyboardMarkup(keyboard) + + chat_state.set_waiting_confirmation(chat_id, True, { + "type": "tool", + "tool": "yandex", + "prompt": prompt + }) + + await update.message.reply_text( + f"Выполнить запрос к YandexGPT?\n\n{prompt[:200]}...", + reply_markup=reply_markup + ) + else: + await update.message.reply_text("Думаю...") + await execute_tool_query(update, "yandex", prompt) + + async def forget_command(update: Update, context: ContextTypes.DEFAULT_TYPE): chat_id = update.effective_chat.id orchestrator.memory.clear_chat(chat_id) @@ -349,6 +422,8 @@ def main(): application.add_handler(CommandHandler("stt", stt_command)) application.add_handler(CommandHandler("qwen", qwen_command)) application.add_handler(CommandHandler("open", open_command)) + application.add_handler(CommandHandler("gigachat", gigachat_command)) + application.add_handler(CommandHandler("yandex", yandex_command)) application.add_handler(CommandHandler("forget", forget_command)) application.add_handler(CommandHandler("remind", remind_command)) application.add_handler(CallbackQueryHandler(confirm_callback)) diff --git a/src/tools/orchestrator.py b/src/tools/orchestrator.py index fa709c0..f64bc98 100644 --- a/src/tools/orchestrator.py +++ b/src/tools/orchestrator.py @@ -2,6 +2,7 @@ import logging from typing import Tuple, Optional from config.config import get_settings from src.tools.tool_runner import ToolRunner +from src.tools.russian_llm import GigachatProvider, YandexGPTProvider from src.memory.memory import Memory logger = logging.getLogger(__name__) @@ -19,8 +20,12 @@ class Orchestrator: self.default_tool = settings.default_tool self.tool_limits = { "qwen": {"failed": 0, "max_failures": 3}, - "opencode": {"failed": 0, "max_failures": 3} + "opencode": {"failed": 0, "max_failures": 3}, + "gigachat": {"failed": 0, "max_failures": 3}, + "yandex": {"failed": 0, "max_failures": 3} } + self.gigachat = GigachatProvider() + self.yandex = YandexGPTProvider() def _check_rate_limit_error(self, result: str) -> bool: rate_limit_keywords = [ @@ -45,19 +50,24 @@ class Orchestrator: full_prompt = self._build_prompt(prompt, chat_id) - result, success = await self.tool_runner.run_tool(selected_tool, full_prompt) - - if not success and self._check_rate_limit_error(result): - logger.warning(f"Лимит превышен для {selected_tool}, пробую другой инструмент") + if selected_tool == "gigachat": + result, success = await self.gigachat.ask(full_prompt) + elif selected_tool == "yandex": + result, success = await self.yandex.ask(full_prompt) + else: + result, success = await self.tool_runner.run_tool(selected_tool, full_prompt) - self.tool_limits[selected_tool]["failed"] += 1 - - if self.tool_limits[selected_tool]["failed"] >= self.tool_limits[selected_tool]["max_failures"]: - alt_tool = "opencode" if selected_tool == "qwen" else "qwen" - logger.info(f"Переключаюсь на {alt_tool}") + if not success and self._check_rate_limit_error(result): + logger.warning(f"Лимит превышен для {selected_tool}, пробую другой инструмент") - result, success = await self.tool_runner.run_tool(alt_tool, full_prompt) - selected_tool = alt_tool + self.tool_limits[selected_tool]["failed"] += 1 + + if self.tool_limits[selected_tool]["failed"] >= self.tool_limits[selected_tool]["max_failures"]: + alt_tool = "opencode" if selected_tool == "qwen" else "qwen" + logger.info(f"Переключаюсь на {alt_tool}") + + result, success = await self.tool_runner.run_tool(alt_tool, full_prompt) + selected_tool = alt_tool if success: self.tool_limits[selected_tool]["failed"] = 0 @@ -68,9 +78,17 @@ class Orchestrator: return result, success def set_default_tool(self, tool: str): - if tool in ["qwen", "opencode"]: + if tool in ["qwen", "opencode", "gigachat", "yandex"]: self.default_tool = tool logger.info(f"Инструмент по умолчанию изменён на {tool}") def get_default_tool(self) -> str: return self.default_tool + + def get_available_tools(self) -> list: + tools = ["qwen", "opencode"] + if settings.gigachat_credentials: + tools.append("gigachat") + if settings.yandex_api_key: + tools.append("yandex") + return tools diff --git a/src/tools/russian_llm.py b/src/tools/russian_llm.py new file mode 100644 index 0000000..428bddf --- /dev/null +++ b/src/tools/russian_llm.py @@ -0,0 +1,113 @@ +import logging +import requests +from typing import Tuple, Optional +from config.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + + +class GigachatProvider: + def __init__(self): + self.credentials = settings.gigachat_credentials + self.api_url = "https://ngw.devices.sberbank.ru:9443/api/v2" + self.token = None + + def _get_token(self) -> Optional[str]: + if not self.credentials: + return None + + try: + response = requests.post( + self.api_url + "/oauth", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + "RqUID": "valera-bot", + "Authorization": f"Basic {self.credentials}" + }, + data={"scope": "GIGACHAT_API_PERS"}, + verify=False + ) + if response.status_code == 200: + return response.json().get("access_token") + except Exception as e: + logger.error(f"Ошибка получения токена Gigachat: {e}") + return None + + async def ask(self, prompt: str) -> Tuple[str, bool]: + if not self.credentials: + return "Gigachat не настроен. Укажите GIGACHAT_CREDENTIALS в .env", False + + self.token = self._get_token() + if not self.token: + return "Не удалось получить токен Gigachat", False + + try: + response = requests.post( + self.api_url + "/chat/completions", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": f"Bearer {self.token}" + }, + json={ + "model": "GigaChat", + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.7 + }, + verify=False, + timeout=60 + ) + + if response.status_code == 200: + result = response.json() + return result["choices"][0]["message"]["content"], True + else: + return f"Ошибка API: {response.status_code}", False + + except Exception as e: + logger.error(f"Ошибка запроса к Gigachat: {e}") + return f"Ошибка: {str(e)}", False + + +class YandexGPTProvider: + def __init__(self): + self.api_key = settings.yandex_api_key + self.folder_id = settings.yandex_folder_id + + async def ask(self, prompt: str) -> Tuple[str, bool]: + if not self.api_key: + return "YandexGPT не настроен. Укажите YANDEX_API_KEY в .env", False + + try: + url = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion" + + headers = { + "Content-Type": "application/json", + "Authorization": f"Api-Key {self.api_key}" + } + + data = { + "modelUri": f"gpt://{self.folder_id}/yandexgpt", + "completionOptions": { + "stream": False, + "temperature": 0.7, + "maxTokens": 2000 + }, + "messages": [ + {"role": "user", "text": prompt} + ] + } + + response = requests.post(url, json=data, headers=headers, timeout=60) + + if response.status_code == 200: + result = response.json() + return result["result"]["alternatives"][0]["message"]["text"], True + else: + return f"Ошибка API: {response.status_code}", False + + except Exception as e: + logger.error(f"Ошибка запроса к YandexGPT: {e}") + return f"Ошибка: {str(e)}", False