Files
fckbot/rag/functions/react.py
2026-06-30 22:41:50 +00:00

178 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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}) без получения финального ответа.")