# rag/functions/react.py """ Цикл ReAct (Reasoning + Acting) для вызова внешних инструментов. Модель может генерировать мысль и действие, вызывать инструмент, получать результат и продолжать, пока не будет готов финальный ответ. Улучшен fallback-парсинг JSON: - Если модель возвращает невалидный JSON, пытается извлечь JSON-объект из текста. - Если JSON не найден, ищет поля "final_answer" или "action" через регулярные выражения. """ import json import logging import re from typing import List, Dict, Any, Callable, Awaitable, Optional from rag.services.giga_client import GigaClient from rag.config_models import AppConfig logger = logging.getLogger(__name__) def _extract_json_from_text(text: str) -> Optional[Dict[str, Any]]: """ Пытается извлечь JSON-объект из текста, используя регулярное выражение для поиска фигурных скобок. Если найден валидный JSON, возвращает его, иначе None. """ # Ищем блок текста между первой открывающей и последней закрывающей фигурной скобкой # Жадный поиск, чтобы захватить весь объект match = re.search(r'(\{.*\})', text, re.DOTALL) if match: try: return json.loads(match.group(1)) except json.JSONDecodeError: pass return None async def react_loop( query: str, history: List[Dict[str, str]], tools: List[Dict[str, Any]], config: AppConfig, giga: GigaClient, prompts: Dict[str, str], max_iterations: int = 5, ) -> str: """ Выполняет ReAct-цикл для генерации ответа с использованием внешних инструментов. Аргументы: query: текущий запрос пользователя. history: история диалога (список сообщений с role и content). tools: список описаний инструментов. Каждый элемент: { "name": str, "description": str, "parameters": dict, # JSON-схема параметров "handler": Callable[..., Awaitable[str]], # асинхронная функция } config: конфигурация. giga: клиент GigaChat. prompts: словарь с промптами (ключ 'react_system'). max_iterations: максимальное количество итераций. Возвращает: str: финальный ответ. Исключения: RuntimeError: если превышен лимит итераций или возникла критическая ошибка. """ system_prompt_template = prompts.get("react_system") if not system_prompt_template: raise ValueError("Промпт react_system не найден") tools_desc = [] for tool in tools: tools_desc.append(f"- {tool['name']}: {tool['description']} (параметры: {tool.get('parameters', {})})") tools_description = "\n".join(tools_desc) system_prompt = system_prompt_template.format(tools_description=tools_description) internal_history = history.copy() if history else [] if not internal_history or internal_history[-1].get("role") != "user" or internal_history[-1].get("content") != query: internal_history.append({"role": "user", "content": query}) iteration = 0 while iteration < max_iterations: iteration += 1 logger.debug(f"ReAct итерация {iteration}") messages = [{"role": "system", "content": system_prompt}] + internal_history messages.append({"role": "user", "content": "Выведи JSON с действием или финальным ответом."}) response = await giga.chat( history=messages, query="", system_prompt=None, file_id=None, temperature=getattr(config, 'react_temperature', 0.3), ) data = None # ---- Попытка 1: стандартный парсинг JSON ---- try: data = json.loads(response.strip()) except json.JSONDecodeError: logger.debug("Невалидный JSON, пробуем извлечь JSON из текста") # ---- Попытка 2: извлечь JSON из текста ---- data = _extract_json_from_text(response) # ---- Если JSON всё ещё не получен, пробуем регулярки для ключевых полей ---- if data is None: logger.debug("Не удалось извлечь JSON, пытаемся найти ключевые поля через regex") final_answer_match = re.search(r'"final_answer"\s*:\s*"([^"]+)"', response) if final_answer_match: return final_answer_match.group(1) action_match = re.search(r'"action"\s*:\s*"([^"]+)"', response) action_input_match = re.search(r'"action_input"\s*:\s*({[^}]*})', response) if action_match and action_input_match: try: action_input = json.loads(action_input_match.group(1)) data = {"action": action_match.group(1), "action_input": action_input} except json.JSONDecodeError: # Если action_input невалидный JSON, попробуем извлечь как строку action_input_str_match = re.search(r'"action_input"\s*:\s*"([^"]+)"', response) if action_input_str_match: data = {"action": action_match.group(1), "action_input": action_input_str_match.group(1)} elif action_match: # Если только action, без action_input, пробуем как есть data = {"action": action_match.group(1), "action_input": {}} # Если данные всё ещё не получены — ошибка if data is None: logger.error(f"Не удалось распарсить ответ модели: {response[:200]}") internal_history.append({"role": "assistant", "content": response}) internal_history.append({"role": "user", "content": "Не удалось распознать действие или финальный ответ. Пожалуйста, выведи JSON с 'action' или 'final_answer'."}) continue # ---- Проверяем наличие финального ответа ---- if "final_answer" in data: return data["final_answer"] # ---- Проверяем наличие действия ---- action = data.get("action") action_input = data.get("action_input", {}) if not action: internal_history.append({"role": "assistant", "content": response}) internal_history.append({"role": "user", "content": "Не удалось распознать действие или финальный ответ. Пожалуйста, выведи JSON с 'action' или 'final_answer'."}) continue # ---- Находим обработчик инструмента ---- handler = None for tool in tools: if tool["name"] == action: handler = tool.get("handler") break if handler is None: error_msg = f"Инструмент '{action}' не найден." logger.warning(error_msg) internal_history.append({"role": "assistant", "content": response}) internal_history.append({"role": "user", "content": f"Ошибка: {error_msg}. Попробуй другой инструмент или дай финальный ответ."}) continue # ---- Вызываем обработчик ---- try: result = await handler(action_input) except Exception as e: result = f"Ошибка при вызове инструмента '{action}': {str(e)}" logger.exception(result) internal_history.append({"role": "assistant", "content": response}) internal_history.append({"role": "tool", "content": result}) raise RuntimeError(f"Превышено максимальное число итераций ({max_iterations}) без получения финального ответа.")