Редактировать rag_orchestrator.py
This commit is contained in:
@@ -1,43 +1,24 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Главный оркестратор RAG-пайплайна.
|
Главный оркестратор RAG-пайплайна (фасад).
|
||||||
Принимает запрос пользователя, получает историю из БД, выполняет все этапы:
|
Координирует работу менеджеров: HistoryManager, IntentRouter, QueryProcessor, IndexingManager.
|
||||||
классификацию, расширение, поиск, переранжирование, синтез, самокритику.
|
Принимает запрос пользователя, обрабатывает его и возвращает ответ.
|
||||||
Сохраняет историю в БД.
|
|
||||||
|
|
||||||
Этот модуль не зависит от XMPP и может использоваться как в ботах,
|
|
||||||
так и в отдельном RAG-ядре (API-сервисе).
|
|
||||||
|
|
||||||
Улучшено управление токенами: теперь история и контекст обрезаются с учётом
|
|
||||||
лимитов модели, резервирования для ответа и промптов.
|
|
||||||
|
|
||||||
ДОБАВЛЕНО:
|
|
||||||
- Иерархическое резюмирование истории диалога.
|
|
||||||
- Иерархическое резюмирование больших документов при индексации.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from typing import Optional, Dict, List, Any
|
from typing import Optional, Dict, List, Any
|
||||||
|
|
||||||
# Импорт сервисов и функций
|
|
||||||
from core.services.postgres_service import PostgresService
|
from core.services.postgres_service import PostgresService
|
||||||
from core.services.qdrant_service import QdrantService
|
from core.services.qdrant_service import QdrantService
|
||||||
from core.services.embedding_service import EmbeddingService
|
from core.services.embedding_service import EmbeddingService
|
||||||
from core.services.kb_service import KBService
|
from core.services.kb_service import KBService
|
||||||
from core.services.giga_client import GigaClient
|
from core.services.giga_client import GigaClient
|
||||||
from core.services.file_service import FileService
|
from core.services.file_service import FileService
|
||||||
|
from core.history_manager import HistoryManager
|
||||||
|
from core.intent_router import IntentRouter
|
||||||
|
from core.query_processor import QueryProcessor
|
||||||
|
from core.indexing_manager import IndexingManager
|
||||||
from core.functions.intent_classify import classify_intent
|
from core.functions.intent_classify import classify_intent
|
||||||
from core.functions.expand_query import expand_query
|
|
||||||
from core.functions.extract_metrics import extract_metrics
|
|
||||||
from core.functions.summarize_document import summarize_document
|
|
||||||
from core.functions.check_consistency import check_consistency
|
|
||||||
from core.functions.critique_answer import critique_answer
|
|
||||||
from core.functions.rerank_context import rerank_context
|
|
||||||
from core.functions.check_spelling import check_spelling
|
|
||||||
from core.functions.hierarchical_summarize import hierarchical_summarize
|
|
||||||
from core.utils.text_utils import count_tokens
|
from core.utils.text_utils import count_tokens
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -46,7 +27,8 @@ logger = logging.getLogger(__name__)
|
|||||||
class RAGOrchestrator:
|
class RAGOrchestrator:
|
||||||
"""
|
"""
|
||||||
Оркестратор RAG-пайплайна.
|
Оркестратор RAG-пайплайна.
|
||||||
Содержит ссылки на все сервисы и выполняет полный цикл обработки запроса.
|
Содержит ссылки на все сервисы и менеджеры.
|
||||||
|
Предоставляет два основных метода: process_query и index_document.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -64,12 +46,12 @@ class RAGOrchestrator:
|
|||||||
Инициализация оркестратора.
|
Инициализация оркестратора.
|
||||||
|
|
||||||
Аргументы:
|
Аргументы:
|
||||||
db: сервис PostgreSQL (для истории и метаданных)
|
db: сервис PostgreSQL
|
||||||
qdrant: сервис Qdrant (векторный поиск)
|
qdrant: сервис Qdrant
|
||||||
embedding: сервис эмбеддингов (GigaChat)
|
embedding: сервис эмбеддингов
|
||||||
kb: сервис базы знаний (индексация, поиск)
|
kb: сервис базы знаний
|
||||||
giga: клиент GigaChat (генерация)
|
giga: клиент GigaChat
|
||||||
files: сервис файлов (извлечение текста)
|
files: сервис файлов
|
||||||
config: объект конфигурации (BotConfig)
|
config: объект конфигурации (BotConfig)
|
||||||
default_prompts: словарь промптов по умолчанию
|
default_prompts: словарь промптов по умолчанию
|
||||||
"""
|
"""
|
||||||
@@ -81,10 +63,41 @@ class RAGOrchestrator:
|
|||||||
self.files = files
|
self.files = files
|
||||||
self.config = config
|
self.config = config
|
||||||
self.default_prompts = default_prompts or {}
|
self.default_prompts = default_prompts or {}
|
||||||
logger.info("RAGOrchestrator инициализирован")
|
|
||||||
|
# Инициализация менеджеров
|
||||||
|
self.history_manager = HistoryManager(
|
||||||
|
db=db,
|
||||||
|
giga=giga,
|
||||||
|
config=config,
|
||||||
|
default_prompts=self.default_prompts
|
||||||
|
)
|
||||||
|
|
||||||
|
self.intent_router = IntentRouter(
|
||||||
|
giga=giga,
|
||||||
|
kb=kb,
|
||||||
|
files=files,
|
||||||
|
config=config,
|
||||||
|
default_prompts=self.default_prompts
|
||||||
|
)
|
||||||
|
|
||||||
|
self.query_processor = QueryProcessor(
|
||||||
|
giga=giga,
|
||||||
|
kb=kb,
|
||||||
|
config=config,
|
||||||
|
default_prompts=self.default_prompts
|
||||||
|
)
|
||||||
|
|
||||||
|
self.indexing_manager = IndexingManager(
|
||||||
|
kb=kb,
|
||||||
|
giga=giga,
|
||||||
|
config=config,
|
||||||
|
default_prompts=self.default_prompts
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("RAGOrchestrator инициализирован с менеджерами")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Вспомогательный метод для расчёта токенов с резервированием
|
# Вспомогательный метод для расчёта токенов
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _prepare_prompt_parts(
|
def _prepare_prompt_parts(
|
||||||
@@ -92,111 +105,42 @@ class RAGOrchestrator:
|
|||||||
synthesis_template: str,
|
synthesis_template: str,
|
||||||
system_prompt: Optional[str],
|
system_prompt: Optional[str],
|
||||||
query: str,
|
query: str,
|
||||||
context: str,
|
|
||||||
max_total_tokens: int = 8192,
|
max_total_tokens: int = 8192,
|
||||||
reserved_for_answer: int = 1000,
|
reserved_for_answer: int = 1000,
|
||||||
reserved_for_overhead: int = 200
|
reserved_for_overhead: int = 200
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Подготавливает части промта и обрезает историю/контекст по токенам.
|
Подсчитывает токены в статичных частях промта и вычисляет,
|
||||||
|
сколько токенов остаётся для истории и контекста.
|
||||||
|
|
||||||
Возвращает словарь с ключами:
|
Возвращает словарь с ключами:
|
||||||
- history: отфильтрованная история (список сообщений)
|
- available_for_history_and_context: int
|
||||||
- context: отфильтрованный контекст (строка)
|
- prompt_tokens: int
|
||||||
- prompt_tokens: количество токенов в промте (без истории и контекста)
|
- system_tokens: int
|
||||||
- total_used: общее использованное количество токенов
|
- synthesis_tokens: int
|
||||||
- available_for_history_and_context: сколько токенов выделено для истории и контекста
|
- query_tokens: int
|
||||||
"""
|
"""
|
||||||
# 1. Подсчёт токенов в статичных частях промта
|
|
||||||
system_tokens = count_tokens(system_prompt) if system_prompt else 0
|
system_tokens = count_tokens(system_prompt) if system_prompt else 0
|
||||||
synthesis_tokens = count_tokens(synthesis_template)
|
synthesis_tokens = count_tokens(synthesis_template)
|
||||||
query_tokens = count_tokens(query)
|
query_tokens = count_tokens(query)
|
||||||
|
|
||||||
# 2. Резервирование
|
|
||||||
prompt_tokens = system_tokens + synthesis_tokens + query_tokens
|
prompt_tokens = system_tokens + synthesis_tokens + query_tokens
|
||||||
available_for_history_and_context = (
|
|
||||||
max_total_tokens
|
|
||||||
- prompt_tokens
|
|
||||||
- reserved_for_answer
|
|
||||||
- reserved_for_overhead
|
|
||||||
)
|
|
||||||
|
|
||||||
if available_for_history_and_context <= 0:
|
available = max_total_tokens - prompt_tokens - reserved_for_answer - reserved_for_overhead
|
||||||
|
if available < 0:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Недостаточно токенов для истории и контекста: {available_for_history_and_context}. "
|
f"Недостаточно токенов для истории и контекста: {available}. "
|
||||||
f"Увеличьте max_total_tokens или уменьшите размер промптов."
|
f"Увеличьте max_total_tokens или уменьшите размер промптов."
|
||||||
)
|
)
|
||||||
available_for_history_and_context = max(available_for_history_and_context, 100)
|
available = max(available, 100)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"available_for_history_and_context": available_for_history_and_context,
|
"available_for_history_and_context": available,
|
||||||
"prompt_tokens": prompt_tokens,
|
"prompt_tokens": prompt_tokens,
|
||||||
"system_tokens": system_tokens,
|
"system_tokens": system_tokens,
|
||||||
"synthesis_tokens": synthesis_tokens,
|
"synthesis_tokens": synthesis_tokens,
|
||||||
"query_tokens": query_tokens,
|
"query_tokens": query_tokens,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Метод для сжатия больших документов при индексации
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _compress_document_if_needed(self, text: str, file_name: str) -> str:
|
|
||||||
"""
|
|
||||||
Если текст превышает порог (max_tokens_for_document), сжимает его
|
|
||||||
с помощью иерархического резюмирования.
|
|
||||||
Возвращает либо исходный текст, либо сжатый.
|
|
||||||
"""
|
|
||||||
# Читаем настройки из конфига
|
|
||||||
summarization_config = getattr(self.config, 'summarization', {})
|
|
||||||
enabled = summarization_config.get('enable_hierarchical_summarization', True)
|
|
||||||
if not enabled:
|
|
||||||
return text
|
|
||||||
|
|
||||||
max_tokens = summarization_config.get('max_tokens_for_document', 8000)
|
|
||||||
target_tokens = summarization_config.get('target_tokens_after_summary', 3000)
|
|
||||||
chunk_size = summarization_config.get('chunk_size_tokens', 500)
|
|
||||||
max_depth = summarization_config.get('max_depth', 2)
|
|
||||||
|
|
||||||
# Подсчитываем токены
|
|
||||||
text_tokens = count_tokens(text)
|
|
||||||
if text_tokens <= max_tokens:
|
|
||||||
logger.debug(f"Документ '{file_name}' ({text_tokens} токенов) не требует сжатия")
|
|
||||||
return text
|
|
||||||
|
|
||||||
logger.info(f"Документ '{file_name}' ({text_tokens} токенов) превышает лимит {max_tokens}, применяем иерархическое резюмирование")
|
|
||||||
|
|
||||||
# Загружаем промпт для резюмирования
|
|
||||||
summary_prompt = self.default_prompts.get('hierarchical_summary', '')
|
|
||||||
if not summary_prompt:
|
|
||||||
try:
|
|
||||||
prompt_path = self.config.prompts_dir / 'hierarchical_summary.txt'
|
|
||||||
with open(prompt_path, 'r', encoding='utf-8') as f:
|
|
||||||
summary_prompt = f.read()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Не удалось загрузить промпт hierarchical_summary: {e}, используем стандартный")
|
|
||||||
summary_prompt = (
|
|
||||||
"Ты — профессиональный реферант. Кратко изложи суть текста, сохранив ключевые факты, цифры и логические связи.\n"
|
|
||||||
"Объём: не более 30% от исходного, но не менее 2 предложений.\n"
|
|
||||||
"Стиль: деловой, нейтральный, без оценок.\n\n"
|
|
||||||
"ТЕКСТ:\n{text}\n\nРЕЗЮМЕ:"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
compressed = await hierarchical_summarize(
|
|
||||||
text=text,
|
|
||||||
giga=self.giga,
|
|
||||||
prompt_template=summary_prompt,
|
|
||||||
target_tokens=target_tokens,
|
|
||||||
chunk_size_tokens=chunk_size,
|
|
||||||
max_depth=max_depth,
|
|
||||||
temperature=getattr(self.config, 'ai_temperature', 0.1)
|
|
||||||
)
|
|
||||||
logger.info(f"Документ '{file_name}' сжат с {text_tokens} до {count_tokens(compressed)} токенов")
|
|
||||||
return compressed
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка при сжатии документа '{file_name}': {e}, используем исходный текст")
|
|
||||||
return text
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Основной метод обработки запроса
|
# Основной метод обработки запроса
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -215,27 +159,21 @@ class RAGOrchestrator:
|
|||||||
Основной метод обработки запроса.
|
Основной метод обработки запроса.
|
||||||
|
|
||||||
ИСТОРИЯ ДИАЛОГА ПОЛУЧАЕТСЯ ИЗ БД, а не из запроса.
|
ИСТОРИЯ ДИАЛОГА ПОЛУЧАЕТСЯ ИЗ БД, а не из запроса.
|
||||||
Это обеспечивает единый контекст для всех клиентов (XMPP, Telegram и т.д.).
|
Это обеспечивает единый контекст для всех клиентов.
|
||||||
|
|
||||||
Аргументы:
|
Аргументы:
|
||||||
query (str): текст запроса пользователя
|
query: текст запроса пользователя
|
||||||
user_jid (str): JID пользователя (без ресурса)
|
user_jid: JID пользователя (без ресурса)
|
||||||
room_jid (Optional[str]): JID комнаты (None для личного чата)
|
room_jid: JID комнаты (None для личного чата)
|
||||||
prompts (Optional[Dict[str, str]]): словарь промптов для текущего запроса.
|
prompts: словарь промптов (если None, используются default_prompts)
|
||||||
Если не передан, используются default_prompts.
|
intent_override: принудительное переопределение намерения
|
||||||
intent_override (Optional[str]): принудительное переопределение намерения
|
last_file_path: путь к последнему загруженному файлу
|
||||||
last_file_path (Optional[str]): путь к последнему загруженному файлу
|
last_file_text: текст последнего загруженного файла
|
||||||
last_file_text (Optional[str]): текст последнего загруженного файла
|
|
||||||
|
|
||||||
Возвращает:
|
Возвращает:
|
||||||
Dict[str, Any]: словарь с ключами:
|
Словарь с ключами: answer, intent, context, sources, confidence, error
|
||||||
- answer (str): итоговый ответ
|
|
||||||
- intent (str): распознанное намерение
|
|
||||||
- context (str): использованный контекст (для отладки)
|
|
||||||
- sources (List[str]): список источников
|
|
||||||
- confidence (float): оценка уверенности (если есть)
|
|
||||||
"""
|
"""
|
||||||
# ----- 1. Подготовка промптов -----
|
# 1. Подготовка промптов
|
||||||
if prompts is None:
|
if prompts is None:
|
||||||
prompts = self.default_prompts.copy()
|
prompts = self.default_prompts.copy()
|
||||||
synthesis_template = prompts.get('synthesis', '')
|
synthesis_template = prompts.get('synthesis', '')
|
||||||
@@ -243,10 +181,7 @@ class RAGOrchestrator:
|
|||||||
synthesis_template = "{context}\n\n{query}\n\nОтвет:"
|
synthesis_template = "{context}\n\n{query}\n\nОтвет:"
|
||||||
system_prompt = prompts.get('system', None)
|
system_prompt = prompts.get('system', None)
|
||||||
|
|
||||||
# ----- 2. Получение истории диалога из БД (необрезанной) -----
|
# 2. Расчёт лимитов токенов
|
||||||
history = await self.db.get_history(user_jid, room_jid, limit=100)
|
|
||||||
|
|
||||||
# ----- 3. Расчёт лимитов токенов -----
|
|
||||||
max_model_tokens = getattr(self.config, 'max_model_tokens', 8192)
|
max_model_tokens = getattr(self.config, 'max_model_tokens', 8192)
|
||||||
reserved_for_answer = getattr(self.config, 'reserved_for_answer_tokens', 1000)
|
reserved_for_answer = getattr(self.config, 'reserved_for_answer_tokens', 1000)
|
||||||
reserved_for_overhead = getattr(self.config, 'reserved_for_overhead_tokens', 200)
|
reserved_for_overhead = getattr(self.config, 'reserved_for_overhead_tokens', 200)
|
||||||
@@ -255,81 +190,25 @@ class RAGOrchestrator:
|
|||||||
synthesis_template=synthesis_template,
|
synthesis_template=synthesis_template,
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
query=query,
|
query=query,
|
||||||
context="",
|
|
||||||
max_total_tokens=max_model_tokens,
|
max_total_tokens=max_model_tokens,
|
||||||
reserved_for_answer=reserved_for_answer,
|
reserved_for_answer=reserved_for_answer,
|
||||||
reserved_for_overhead=reserved_for_overhead
|
reserved_for_overhead=reserved_for_overhead
|
||||||
)
|
)
|
||||||
available_for_history_and_context = token_info["available_for_history_and_context"]
|
available_for_history_and_context = token_info["available_for_history_and_context"]
|
||||||
|
logger.debug(f"Доступно для истории и контекста: {available_for_history_and_context} токенов")
|
||||||
|
|
||||||
logger.debug(
|
# 3. Получение истории из БД
|
||||||
f"Доступно для истории и контекста: {available_for_history_and_context} токенов"
|
raw_history = await self.history_manager.get_history(user_jid, room_jid, limit=100)
|
||||||
|
|
||||||
|
# 4. Сжатие истории, если она слишком длинная
|
||||||
|
max_history_tokens = min(available_for_history_and_context // 2, 2000)
|
||||||
|
formatted_history = await self.history_manager.compress_history_if_needed(
|
||||||
|
raw_history,
|
||||||
|
max_tokens=max_history_tokens,
|
||||||
|
prompt_template=prompts.get('hierarchical_summary', '')
|
||||||
)
|
)
|
||||||
|
|
||||||
# ----- 4. Сжатие истории (иерархическое резюмирование) -----
|
# 5. Классификация намерений
|
||||||
max_history_tokens = min(available_for_history_and_context // 2, 2000)
|
|
||||||
|
|
||||||
# Если история слишком длинная, сжимаем её иерархически
|
|
||||||
history_text = "\n".join([f"{rec['role']}: {rec['content']}" for rec in history])
|
|
||||||
if count_tokens(history_text) > max_history_tokens:
|
|
||||||
logger.info(
|
|
||||||
f"История слишком длинная ({count_tokens(history_text)} токенов), "
|
|
||||||
f"применяем иерархическое резюмирование"
|
|
||||||
)
|
|
||||||
summary_prompt = prompts.get('hierarchical_summary', '')
|
|
||||||
if not summary_prompt:
|
|
||||||
try:
|
|
||||||
prompt_path = self.config.prompts_dir / 'hierarchical_summary.txt'
|
|
||||||
with open(prompt_path, 'r', encoding='utf-8') as f:
|
|
||||||
summary_prompt = f.read()
|
|
||||||
except Exception:
|
|
||||||
summary_prompt = (
|
|
||||||
"Кратко изложи суть диалога, сохранив ключевые факты и вопросы.\n\n"
|
|
||||||
"ТЕКСТ:\n{text}"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
compressed_history = await hierarchical_summarize(
|
|
||||||
text=history_text,
|
|
||||||
giga=self.giga,
|
|
||||||
prompt_template=summary_prompt,
|
|
||||||
target_tokens=max_history_tokens,
|
|
||||||
chunk_size_tokens=500,
|
|
||||||
max_depth=2,
|
|
||||||
temperature=getattr(self.config, 'ai_temperature', 0.1)
|
|
||||||
)
|
|
||||||
formatted_history = [
|
|
||||||
{"role": "system", "content": "Сжатая история диалога:\n" + compressed_history}
|
|
||||||
]
|
|
||||||
logger.info(
|
|
||||||
f"История сжата с {count_tokens(history_text)} до {count_tokens(compressed_history)} токенов"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка при иерархическом резюмировании истории: {e}")
|
|
||||||
# Fallback: простая обрезка
|
|
||||||
truncated_history = []
|
|
||||||
total_tokens = 0
|
|
||||||
for record in reversed(history):
|
|
||||||
tokens = count_tokens(record['content'])
|
|
||||||
if total_tokens + tokens <= max_history_tokens:
|
|
||||||
truncated_history.append(record)
|
|
||||||
total_tokens += tokens
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
truncated_history.reverse()
|
|
||||||
formatted_history = [
|
|
||||||
{"role": rec['role'], "content": rec['content']}
|
|
||||||
for rec in truncated_history
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
# Если история укладывается, форматируем последние сообщения
|
|
||||||
# Берём максимум 20 сообщений, чтобы не перегружать
|
|
||||||
truncated_history = history[-20:]
|
|
||||||
formatted_history = [
|
|
||||||
{"role": rec['role'], "content": rec['content']}
|
|
||||||
for rec in truncated_history
|
|
||||||
]
|
|
||||||
|
|
||||||
# ----- 5. Классификация намерений -----
|
|
||||||
intent = intent_override
|
intent = intent_override
|
||||||
if intent is None and getattr(self.config, 'enable_intent_classification', True):
|
if intent is None and getattr(self.config, 'enable_intent_classification', True):
|
||||||
intent_prompt = prompts.get('intent', '')
|
intent_prompt = prompts.get('intent', '')
|
||||||
@@ -345,320 +224,72 @@ class RAGOrchestrator:
|
|||||||
else:
|
else:
|
||||||
intent = intent or "GENERAL"
|
intent = intent or "GENERAL"
|
||||||
|
|
||||||
# ----- 6. Принудительная установка SURGICAL по ключевым словам -----
|
# 6. Принудительная установка SURGICAL по ключевым словам
|
||||||
keywords = getattr(self.config, 'surgical_keywords', [])
|
keywords = getattr(self.config, 'surgical_keywords', [])
|
||||||
if any(kw in query.lower() for kw in keywords) and last_file_path:
|
if any(kw in query.lower() for kw in keywords) and last_file_path:
|
||||||
intent = "SURGICAL"
|
intent = "SURGICAL"
|
||||||
logger.info(f"Принудительный Intent: SURGICAL (есть файл и ключевые слова)")
|
logger.info(f"Принудительный Intent: SURGICAL (есть файл и ключевые слова)")
|
||||||
|
|
||||||
# ----- 7. Обработка специализированных намерений -----
|
# 7. Маршрутизация специализированных намерений
|
||||||
|
router_result = await self.intent_router.route(
|
||||||
|
intent=intent,
|
||||||
|
query=query,
|
||||||
|
user_jid=user_jid,
|
||||||
|
room_jid=room_jid,
|
||||||
|
prompts=prompts,
|
||||||
|
last_file_path=last_file_path,
|
||||||
|
last_file_text=last_file_text,
|
||||||
|
history=formatted_history,
|
||||||
|
system_prompt=system_prompt
|
||||||
|
)
|
||||||
|
|
||||||
answer = None
|
answer = None
|
||||||
context = None
|
context = ""
|
||||||
ctx_for_critique = None
|
|
||||||
synthesis_template_for_critique = None
|
|
||||||
|
|
||||||
# --- 7.1. METRICS ---
|
|
||||||
if intent == "METRICS":
|
|
||||||
context = await self.kb.find_relevant_info(
|
|
||||||
query, user_jid, room_jid,
|
|
||||||
top_k=getattr(self.config, 'rag_metrics_top_k', 30)
|
|
||||||
)
|
|
||||||
if not context:
|
|
||||||
answer = "Не найдено данных для извлечения метрик."
|
|
||||||
else:
|
|
||||||
metrics_prompt = prompts.get('metrics', '')
|
|
||||||
metrics = await extract_metrics(
|
|
||||||
giga=self.giga,
|
|
||||||
context=context,
|
|
||||||
prompt_text=metrics_prompt,
|
|
||||||
bot_config=self.config
|
|
||||||
)
|
|
||||||
if metrics:
|
|
||||||
lines = [f"- {m.get('metric_name')}: {m.get('value')} {m.get('unit', '')}" for m in metrics[:10]]
|
|
||||||
answer = "📊 **Извлечённые метрики:**\n" + "\n".join(lines)
|
|
||||||
else:
|
|
||||||
answer = "Не удалось извлечь метрики."
|
|
||||||
|
|
||||||
# --- 7.2. SUMMARY ---
|
|
||||||
elif intent == "SUMMARY":
|
|
||||||
if not last_file_text:
|
|
||||||
answer = "Нет документа для суммаризации."
|
|
||||||
else:
|
|
||||||
summary_prompt = prompts.get('summary', '')
|
|
||||||
answer = await summarize_document(
|
|
||||||
giga=self.giga,
|
|
||||||
text=last_file_text,
|
|
||||||
title="Ваш документ",
|
|
||||||
prompt_text=summary_prompt,
|
|
||||||
bot_config=self.config
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- 7.3. CONTRADICTION ---
|
|
||||||
elif intent == "CONTRADICTION":
|
|
||||||
context = await self.kb.find_relevant_info(
|
|
||||||
query, user_jid, room_jid,
|
|
||||||
top_k=getattr(self.config, 'rag_contradiction_top_k', 10)
|
|
||||||
)
|
|
||||||
if not context:
|
|
||||||
answer = "Недостаточно данных для проверки противоречий."
|
|
||||||
else:
|
|
||||||
chunks = [c.strip() for c in context.split("\n\n") if c.strip()]
|
|
||||||
if len(chunks) < 2:
|
|
||||||
answer = "Недостаточно фрагментов."
|
|
||||||
else:
|
|
||||||
consistency_prompt = prompts.get('consistency', '')
|
|
||||||
consistency = await check_consistency(
|
|
||||||
giga=self.giga,
|
|
||||||
chunks=chunks,
|
|
||||||
query=query,
|
|
||||||
prompt_text=consistency_prompt,
|
|
||||||
bot_config=self.config
|
|
||||||
)
|
|
||||||
if "[CONFLICT]" in consistency:
|
|
||||||
answer = f"⚠️ **Обнаружены противоречия:**\n{consistency}"
|
|
||||||
else:
|
|
||||||
answer = "✅ Противоречий не обнаружено."
|
|
||||||
|
|
||||||
# --- 7.4. TEMPLATE_FILL ---
|
|
||||||
elif intent == "TEMPLATE_FILL":
|
|
||||||
template_text = last_file_text or ""
|
|
||||||
if not template_text and last_file_path and os.path.exists(last_file_path):
|
|
||||||
result = await asyncio.to_thread(self.files.process_any_file, last_file_path)
|
|
||||||
if isinstance(result, tuple) and len(result) == 2:
|
|
||||||
template_text = result[0]
|
|
||||||
else:
|
|
||||||
template_text = str(result)
|
|
||||||
if not template_text:
|
|
||||||
answer = "❌ Нет шаблона документа для заполнения. Загрузите файл .docx."
|
|
||||||
else:
|
|
||||||
truncated_template = template_text[:5000]
|
|
||||||
search_query = f"{query}\n{truncated_template}"
|
|
||||||
context = await self.kb.find_relevant_info(search_query, user_jid, room_jid, top_k=15)
|
|
||||||
fill_prompt = prompts.get('generate_document', '')
|
|
||||||
if not fill_prompt:
|
|
||||||
fill_prompt = "Заполни плейсхолдеры, используя базу знаний."
|
|
||||||
prompt = (
|
|
||||||
f"Перед тобой шаблон документа. Заполни плейсхолдеры, используя ТОЛЬКО базу знаний.\n\n"
|
|
||||||
f"[ТЕКСТ ШАБЛОНА]:\n{truncated_template}\n\n"
|
|
||||||
f"[ДАННЫЕ ИЗ БАЗЫ ЗНАНИЙ]:\n{context}\n\n"
|
|
||||||
f"Инструкция по заполнению:\n{fill_prompt}\n\n"
|
|
||||||
f"Формат ответа: [SURGICAL_REPLACE]\nинструкция_из_шаблона ||| текст_из_БЗ\n[/SURGICAL_REPLACE]"
|
|
||||||
)
|
|
||||||
answer = await self.giga.chat(
|
|
||||||
history=formatted_history,
|
|
||||||
query=prompt,
|
|
||||||
system_prompt=system_prompt,
|
|
||||||
file_id=None,
|
|
||||||
temperature=getattr(self.config, 'ai_temperature', 0.1)
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- 7.5. SPELLCHECK ---
|
|
||||||
elif intent == "SPELLCHECK":
|
|
||||||
if room_jid is not None:
|
|
||||||
answer = "⚠️ Проверка орфографии доступна только в личном чате."
|
|
||||||
else:
|
|
||||||
if not last_file_text or not last_file_path:
|
|
||||||
answer = "❌ Нет документа для проверки. Сначала отправьте файл .docx."
|
|
||||||
elif not last_file_path.lower().endswith('.docx'):
|
|
||||||
answer = "❌ Проверка орфографии поддерживается только для файлов .docx."
|
|
||||||
else:
|
|
||||||
answer = "⚠️ Проверка орфографии требует доработки (передача промптов)."
|
|
||||||
|
|
||||||
# --- 7.6. GREETING ---
|
|
||||||
elif intent == "GREETING":
|
|
||||||
answer = await self.giga.chat(
|
|
||||||
history=formatted_history,
|
|
||||||
query=query,
|
|
||||||
system_prompt=system_prompt,
|
|
||||||
file_id=None,
|
|
||||||
temperature=getattr(self.config, 'ai_temperature', 0.1)
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- 7.7. SURGICAL ---
|
|
||||||
elif intent == "SURGICAL":
|
|
||||||
if not last_file_path:
|
|
||||||
answer = "❌ Нет загруженного документа для замены."
|
|
||||||
else:
|
|
||||||
import re
|
|
||||||
q = re.sub(r'\s+', ' ', query.lower()).strip()
|
|
||||||
m = re.search(r'замени\s+(.+?)\s+на\s+(.+)', q)
|
|
||||||
if not m:
|
|
||||||
m = re.search(r'заменить\s+(.+?)\s+на\s+(.+)', q)
|
|
||||||
if not m:
|
|
||||||
answer = "❌ Не удалось распознать, что на что заменять. Используйте формат: замени слово на слово"
|
|
||||||
else:
|
|
||||||
old_word = m.group(1).strip()
|
|
||||||
new_word = m.group(2).strip()
|
|
||||||
try:
|
|
||||||
from mawo_pymorphy3 import create_analyzer
|
|
||||||
morph = create_analyzer()
|
|
||||||
old_forms = set()
|
|
||||||
parsed_old = morph.parse(old_word)[0]
|
|
||||||
old_forms.add(old_word)
|
|
||||||
old_forms.add(parsed_old.normal_form)
|
|
||||||
cases = ['nomn', 'gent', 'datv', 'accs', 'ablt', 'loct']
|
|
||||||
numbers = ['sing', 'plur']
|
|
||||||
for number in numbers:
|
|
||||||
for case in cases:
|
|
||||||
inflected = parsed_old.inflect({case, number})
|
|
||||||
if inflected:
|
|
||||||
old_forms.add(inflected.word)
|
|
||||||
replacements = {}
|
|
||||||
for old_form in old_forms:
|
|
||||||
parsed_old_form = morph.parse(old_form)[0]
|
|
||||||
tags = set()
|
|
||||||
if parsed_old_form.tag.case:
|
|
||||||
tags.add(parsed_old_form.tag.case)
|
|
||||||
if parsed_old_form.tag.number:
|
|
||||||
tags.add(parsed_old_form.tag.number)
|
|
||||||
if parsed_old_form.tag.gender:
|
|
||||||
tags.add(parsed_old_form.tag.gender)
|
|
||||||
parsed_new = morph.parse(new_word)[0]
|
|
||||||
new_form = parsed_new.inflect(tags)
|
|
||||||
replacements[old_form] = new_form.word if new_form else new_word
|
|
||||||
replacements[old_word] = new_word
|
|
||||||
replacements[parsed_old.normal_form] = new_word
|
|
||||||
|
|
||||||
new_path = await asyncio.to_thread(self.files.surgical_replace, last_file_path, replacements)
|
|
||||||
if new_path:
|
|
||||||
answer = f"Замена '{old_word}' → '{new_word}' выполнена. Файл сохранён: {new_path}"
|
|
||||||
else:
|
|
||||||
answer = f"❌ Ошибка при замене '{old_word}' → '{new_word}'."
|
|
||||||
except ImportError:
|
|
||||||
answer = "❌ Библиотека mawo-pymorphy3 не установлена. Установите её для морфологической замены."
|
|
||||||
|
|
||||||
# ----- 8. Обычный RAG (для GENERAL, FACT, PROCEDURE, COMPARISON, CALCULATION) -----
|
|
||||||
if answer is None:
|
|
||||||
# 8.1. Расширение запроса
|
|
||||||
expanded = await expand_query(
|
|
||||||
giga=self.giga,
|
|
||||||
query=query,
|
|
||||||
prompt_text=prompts.get('expand', ''),
|
|
||||||
bot_config=self.config
|
|
||||||
)
|
|
||||||
search_query = expanded if expanded and expanded != query else query
|
|
||||||
|
|
||||||
# 8.2. Поиск релевантного контекста в базе знаний
|
|
||||||
context = await self.kb.find_relevant_info(
|
|
||||||
search_query, user_jid, room_jid,
|
|
||||||
top_k=getattr(self.config, 'rag_default_top_k', 30)
|
|
||||||
)
|
|
||||||
logger.info(f"Найден контекст длиной {len(context)} символов (room={room_jid})")
|
|
||||||
|
|
||||||
# 8.3. Обрезка контекста по токенам
|
|
||||||
# Оставшиеся токены после истории и промптов – используем для контекста
|
|
||||||
max_context_tokens = available_for_history_and_context - sum(count_tokens(msg['content']) for msg in formatted_history)
|
|
||||||
max_context_tokens = max(max_context_tokens, 0)
|
|
||||||
|
|
||||||
if max_context_tokens > 0 and context:
|
|
||||||
context_tokens = count_tokens(context)
|
|
||||||
if context_tokens > max_context_tokens:
|
|
||||||
logger.warning(
|
|
||||||
f"Контекст слишком длинный ({context_tokens} токенов), обрезаем до {max_context_tokens}"
|
|
||||||
)
|
|
||||||
max_context_chars = int(max_context_tokens * 3.5)
|
|
||||||
if max_context_chars > 0:
|
|
||||||
context = context[:max_context_chars]
|
|
||||||
else:
|
|
||||||
context = ""
|
|
||||||
|
|
||||||
# 8.4. Переранжирование контекста
|
|
||||||
rerank_min_length = getattr(self.config, 'rerank_min_length', 5000)
|
|
||||||
if intent != "FACT" and len(context) > rerank_min_length:
|
|
||||||
context = await rerank_context(
|
|
||||||
bot=None,
|
|
||||||
query=query,
|
|
||||||
context=context,
|
|
||||||
prompt_text=None,
|
|
||||||
bot_config=self.config
|
|
||||||
)
|
|
||||||
|
|
||||||
# 8.5. Синтез ответа с добавлением цепочки рассуждений (CoT)
|
|
||||||
synthesis_template = synthesis_template
|
|
||||||
if intent in ("CALCULATION", "PROCEDURE"):
|
|
||||||
cot_instruction = (
|
|
||||||
"\n\nПожалуйста, покажи пошаговое решение перед итоговым ответом. "
|
|
||||||
"Опиши каждый шаг вычислений или действий в логической последовательности. "
|
|
||||||
"После всех шагов дай итоговый ответ."
|
|
||||||
)
|
|
||||||
synthesis_template += cot_instruction
|
|
||||||
|
|
||||||
full_query = synthesis_template.format(context=context, query=query)
|
|
||||||
logger.debug(f"Полный запрос к GigaChat (первые 500 символов): {full_query[:500]}")
|
|
||||||
|
|
||||||
# 8.6. Генерация ответа
|
|
||||||
answer = await self.giga.chat(
|
|
||||||
history=formatted_history,
|
|
||||||
query=full_query,
|
|
||||||
system_prompt=system_prompt,
|
|
||||||
file_id=None,
|
|
||||||
temperature=getattr(self.config, 'ai_temperature', 0.1)
|
|
||||||
)
|
|
||||||
|
|
||||||
ctx_for_critique = context
|
|
||||||
synthesis_template_for_critique = synthesis_template
|
|
||||||
|
|
||||||
# ----- 9. Самокритика (если включена) -----
|
|
||||||
if (getattr(self.config, 'enable_self_critique', False) and
|
|
||||||
intent not in ("METRICS", "SUMMARY", "CONTRADICTION") and
|
|
||||||
ctx_for_critique is not None and
|
|
||||||
answer is not None):
|
|
||||||
critique_prompt = prompts.get('critique', '')
|
|
||||||
if critique_prompt:
|
|
||||||
logger.debug("Запуск самокритики")
|
|
||||||
is_ok = await critique_answer(
|
|
||||||
giga=self.giga,
|
|
||||||
query=query,
|
|
||||||
context=ctx_for_critique,
|
|
||||||
answer=answer,
|
|
||||||
prompt_text=critique_prompt,
|
|
||||||
bot_config=self.config
|
|
||||||
)
|
|
||||||
if not is_ok:
|
|
||||||
logger.warning("Ответ не прошёл самокритику, перегенерация")
|
|
||||||
full_query_retry = synthesis_template_for_critique.format(
|
|
||||||
context=ctx_for_critique,
|
|
||||||
query=query
|
|
||||||
)
|
|
||||||
answer = await self.giga.chat(
|
|
||||||
history=formatted_history,
|
|
||||||
query=full_query_retry,
|
|
||||||
system_prompt=system_prompt,
|
|
||||||
file_id=None,
|
|
||||||
temperature=getattr(self.config, 'ai_temperature', 0.1)
|
|
||||||
)
|
|
||||||
if not await critique_answer(
|
|
||||||
giga=self.giga,
|
|
||||||
query=query,
|
|
||||||
context=ctx_for_critique,
|
|
||||||
answer=answer,
|
|
||||||
prompt_text=critique_prompt,
|
|
||||||
bot_config=self.config
|
|
||||||
):
|
|
||||||
answer = "⚠️ Извините, я не уверен в точности ответа. Проверьте данные."
|
|
||||||
|
|
||||||
# ----- 10. Сохранение истории диалога в БД -----
|
|
||||||
await self.db.add_history(user_jid, "user", query, room_jid)
|
|
||||||
if answer:
|
|
||||||
await self.db.add_history(user_jid, "assistant", answer, room_jid)
|
|
||||||
|
|
||||||
# ----- 11. Извлечение источников из контекста -----
|
|
||||||
sources = []
|
sources = []
|
||||||
if context:
|
|
||||||
for match in re.finditer(r'\[источник:\s*([^\]]+)\]', context):
|
|
||||||
sources.append(match.group(1))
|
|
||||||
|
|
||||||
# ----- 12. Формирование результата -----
|
if router_result is not None:
|
||||||
|
# Намерение обработано маршрутизатором
|
||||||
|
answer = router_result.get("answer")
|
||||||
|
context = router_result.get("context", "")
|
||||||
|
sources = router_result.get("sources", [])
|
||||||
|
else:
|
||||||
|
# Обычный RAG-пайплайн (GENERAL, FACT, PROCEDURE, COMPARISON, CALCULATION)
|
||||||
|
# Вычисляем, сколько токенов осталось для контекста после истории
|
||||||
|
history_tokens = sum(count_tokens(msg['content']) for msg in formatted_history)
|
||||||
|
available_for_context = available_for_history_and_context - history_tokens
|
||||||
|
available_for_context = max(available_for_context, 0)
|
||||||
|
|
||||||
|
processor_result = await self.query_processor.process(
|
||||||
|
query=query,
|
||||||
|
user_jid=user_jid,
|
||||||
|
room_jid=room_jid,
|
||||||
|
prompts=prompts,
|
||||||
|
intent=intent,
|
||||||
|
history=formatted_history,
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
available_tokens_for_context=available_for_context
|
||||||
|
)
|
||||||
|
answer = processor_result.get("answer")
|
||||||
|
context = processor_result.get("context", "")
|
||||||
|
sources = processor_result.get("sources", [])
|
||||||
|
|
||||||
|
# 8. Сохранение истории диалога в БД
|
||||||
|
await self.history_manager.save_message(user_jid, "user", query, room_jid)
|
||||||
|
if answer:
|
||||||
|
await self.history_manager.save_message(user_jid, "assistant", answer, room_jid)
|
||||||
|
|
||||||
|
# 9. Формирование результата
|
||||||
return {
|
return {
|
||||||
"answer": answer,
|
"answer": answer or "⚠️ Не удалось сгенерировать ответ.",
|
||||||
"intent": intent,
|
"intent": intent,
|
||||||
"context": context,
|
"context": context,
|
||||||
"sources": list(set(sources)),
|
"sources": sources,
|
||||||
"confidence": None
|
"confidence": None,
|
||||||
|
"error": None
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Индексация документа (с возможным сжатием)
|
# Индексация документа
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def index_document(
|
async def index_document(
|
||||||
@@ -674,23 +305,36 @@ class RAGOrchestrator:
|
|||||||
update_if_exists: bool = True
|
update_if_exists: bool = True
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Индексация документа в базу знаний.
|
Индексирует документ в базу знаний.
|
||||||
Перед индексацией применяет иерархическое резюмирование,
|
Делегирует работу IndexingManager.
|
||||||
если документ слишком большой.
|
|
||||||
"""
|
|
||||||
# Сжимаем документ, если он слишком большой
|
|
||||||
compressed_text = await self._compress_document_if_needed(file_text, file_name)
|
|
||||||
|
|
||||||
# Передаём сжатый текст в сервис базы знаний
|
Аргументы:
|
||||||
doc_id, chunk_count = await self.kb.add_document(
|
file_name: исходное имя файла
|
||||||
file_name=file_name,
|
file_text: извлечённый текст
|
||||||
file_text=compressed_text,
|
user_jid: JID владельца
|
||||||
user_jid=user_jid,
|
room_jid: JID комнаты (None для личного)
|
||||||
is_global=is_global,
|
is_global: глобальный ли документ
|
||||||
title=title,
|
title: отображаемое название
|
||||||
metadata=metadata,
|
metadata: дополнительные метаданные
|
||||||
room_jid=room_jid,
|
file_hash: SHA-256 хеш содержимого
|
||||||
file_hash=file_hash,
|
update_if_exists: заменять ли существующий документ в комнате
|
||||||
update_if_exists=update_if_exists
|
|
||||||
)
|
Возвращает:
|
||||||
return {"doc_id": doc_id, "chunk_count": chunk_count}
|
Словарь с ключами doc_id, chunk_count, error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
doc_id, chunk_count = await self.indexing_manager.index_document(
|
||||||
|
file_name=file_name,
|
||||||
|
file_text=file_text,
|
||||||
|
user_jid=user_jid,
|
||||||
|
room_jid=room_jid,
|
||||||
|
is_global=is_global,
|
||||||
|
title=title,
|
||||||
|
metadata=metadata,
|
||||||
|
file_hash=file_hash,
|
||||||
|
update_if_exists=update_if_exists
|
||||||
|
)
|
||||||
|
return {"doc_id": doc_id, "chunk_count": chunk_count, "error": None}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка индексации документа {file_name}: {e}", exc_info=True)
|
||||||
|
return {"doc_id": None, "chunk_count": 0, "error": str(e)}
|
||||||
Reference in New Issue
Block a user