230 lines
11 KiB
Python
230 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Менеджер истории диалогов.
|
||
Отвечает за:
|
||
- Получение истории из PostgreSQL (через PostgresService).
|
||
- Сжатие длинной истории с помощью иерархического резюмирования.
|
||
- Форматирование истории в список сообщений для передачи в LLM.
|
||
- Сохранение новых сообщений в историю.
|
||
- Обрезка сообщений до лимита символов.
|
||
- Пагинация при получении истории.
|
||
|
||
Используется в RAGOrchestrator для управления контекстом диалога.
|
||
"""
|
||
|
||
import logging
|
||
from typing import List, Dict, Optional, Any, Tuple
|
||
|
||
from core.services.postgres_service import PostgresService
|
||
from core.services.giga_client import GigaClient
|
||
from core.utils.text_utils import count_tokens
|
||
from core.functions.hierarchical_summarize import hierarchical_summarize
|
||
from core.config_models import AppConfig
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class HistoryManager:
|
||
"""
|
||
Управляет историей диалога для конкретного пользователя/комнаты.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
db: PostgresService,
|
||
giga: GigaClient,
|
||
config: AppConfig,
|
||
default_prompts: Optional[Dict[str, str]] = None
|
||
) -> None:
|
||
"""
|
||
Инициализация менеджера истории.
|
||
|
||
Аргументы:
|
||
db: сервис PostgreSQL (для получения и сохранения истории)
|
||
giga: клиент GigaChat (для сжатия истории)
|
||
config: объект конфигурации (AppConfig)
|
||
default_prompts: словарь промптов по умолчанию (нужен для hierarchical_summary)
|
||
"""
|
||
self.db: PostgresService = db
|
||
self.giga: GigaClient = giga
|
||
self.config: AppConfig = config
|
||
self.default_prompts: Dict[str, str] = default_prompts or {}
|
||
|
||
# ------------------------------------------------------------------
|
||
# Получение и форматирование истории
|
||
# ------------------------------------------------------------------
|
||
|
||
async def get_history(
|
||
self,
|
||
user_jid: str,
|
||
room_jid: Optional[str] = None,
|
||
limit: int = 100,
|
||
offset: int = 0
|
||
) -> List[Dict[str, str]]:
|
||
"""
|
||
Получает историю диалога из БД с поддержкой пагинации.
|
||
|
||
Аргументы:
|
||
user_jid: JID пользователя (без ресурса)
|
||
room_jid: JID комнаты (None для личного чата)
|
||
limit: максимальное количество сообщений для возврата
|
||
offset: смещение (количество пропускаемых сообщений)
|
||
|
||
Возвращает:
|
||
List[Dict[str, str]]: список сообщений с ключами 'role' и 'content'
|
||
"""
|
||
# Так как PostgresService.get_history не поддерживает offset,
|
||
# мы запрашиваем на offset больше записей, а затем обрезаем.
|
||
# Это неэффективно для больших offset, но допустимо для умеренных значений.
|
||
# В будущем следует добавить поддержку offset в PostgresService.
|
||
fetch_limit = limit + offset
|
||
raw_history: List[Dict[str, Any]] = await self.db.get_history(
|
||
user_jid, room_jid, limit=fetch_limit
|
||
)
|
||
# Применяем offset и limit
|
||
if offset > 0:
|
||
raw_history = raw_history[offset:offset + limit]
|
||
else:
|
||
raw_history = raw_history[:limit]
|
||
|
||
# Преобразуем в формат, ожидаемый LLM
|
||
return [
|
||
{"role": rec['role'], "content": rec['content']}
|
||
for rec in raw_history
|
||
]
|
||
|
||
async def compress_history_if_needed(
|
||
self,
|
||
history: List[Dict[str, str]],
|
||
max_tokens: int,
|
||
prompt_template: Optional[str] = None
|
||
) -> List[Dict[str, str]]:
|
||
"""
|
||
Сжимает историю, если она превышает лимит токенов.
|
||
Использует иерархическое резюмирование для сжатия всей истории в одно системное сообщение.
|
||
|
||
Аргументы:
|
||
history: список сообщений (role, content)
|
||
max_tokens: максимальное допустимое количество токенов для истории
|
||
prompt_template: шаблон промпта для резюмирования (если None, используется стандартный)
|
||
|
||
Возвращает:
|
||
List[Dict[str, str]]: либо исходная история (если укладывается в лимит),
|
||
либо сжатая история в виде одного системного сообщения.
|
||
"""
|
||
if not history:
|
||
return []
|
||
|
||
# Подсчитываем общее количество токенов в истории
|
||
history_text: str = "\n".join([f"{msg['role']}: {msg['content']}" for msg in history])
|
||
total_tokens: int = count_tokens(history_text)
|
||
|
||
if total_tokens <= max_tokens:
|
||
logger.debug(f"История укладывается в {max_tokens} токенов (фактически {total_tokens})")
|
||
return history
|
||
|
||
logger.info(
|
||
f"История слишком длинная ({total_tokens} токенов), "
|
||
f"превышает лимит {max_tokens}, применяем сжатие"
|
||
)
|
||
|
||
# Загружаем промпт для иерархического резюмирования
|
||
if prompt_template is None:
|
||
prompt_template = self.default_prompts.get('hierarchical_summary', '')
|
||
if not prompt_template:
|
||
# Пытаемся загрузить из файла, если он есть
|
||
try:
|
||
prompt_path = self.config.prompts_dir_path / 'hierarchical_summary.txt'
|
||
with open(prompt_path, 'r', encoding='utf-8') as f:
|
||
prompt_template = f.read()
|
||
except Exception as e:
|
||
logger.warning(f"Не удалось загрузить промпт hierarchical_summary: {e}")
|
||
# Стандартный промпт
|
||
prompt_template = (
|
||
"Кратко изложи суть диалога, сохранив ключевые факты, вопросы и ответы.\n"
|
||
"Объём: не более 30% от исходного, но не менее 2 предложений.\n"
|
||
"Стиль: нейтральный, без оценок.\n\n"
|
||
"ТЕКСТ ДИАЛОГА:\n{text}\n\nКРАТКОЕ ИЗЛОЖЕНИЕ:"
|
||
)
|
||
|
||
# Параметры сжатия из конфига
|
||
summarization_config: Dict[str, Any] = getattr(self.config, 'summarization', {})
|
||
target_tokens: int = summarization_config.get('target_tokens_after_summary', max_tokens)
|
||
chunk_size: int = summarization_config.get('chunk_size_tokens', 500)
|
||
max_depth: int = summarization_config.get('max_depth', 2)
|
||
temperature: float = getattr(self.config, 'ai_temperature', 0.1)
|
||
|
||
try:
|
||
compressed_text: str = await hierarchical_summarize(
|
||
text=history_text,
|
||
giga=self.giga,
|
||
prompt_template=prompt_template,
|
||
target_tokens=target_tokens,
|
||
chunk_size_tokens=chunk_size,
|
||
max_depth=max_depth,
|
||
temperature=temperature
|
||
)
|
||
logger.info(
|
||
f"История сжата с {total_tokens} до {count_tokens(compressed_text)} токенов"
|
||
)
|
||
# Возвращаем сжатую историю как одно системное сообщение
|
||
return [
|
||
{"role": "system", "content": f"Сжатая история диалога:\n{compressed_text}"}
|
||
]
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при сжатии истории: {e}, выполняем простую обрезку")
|
||
# Fallback: простая обрезка по токенам
|
||
truncated_history: List[Dict[str, str]] = []
|
||
total: int = 0
|
||
for msg in reversed(history):
|
||
tokens: int = count_tokens(msg['content'])
|
||
if total + tokens <= max_tokens:
|
||
truncated_history.append(msg)
|
||
total += tokens
|
||
else:
|
||
break
|
||
truncated_history.reverse()
|
||
return truncated_history
|
||
|
||
async def save_message(
|
||
self,
|
||
user_jid: str,
|
||
role: str,
|
||
content: str,
|
||
room_jid: Optional[str] = None
|
||
) -> None:
|
||
"""
|
||
Сохраняет одно сообщение в историю диалога с обрезкой до лимита символов.
|
||
|
||
Аргументы:
|
||
user_jid: JID пользователя
|
||
role: 'user' или 'assistant'
|
||
content: текст сообщения
|
||
room_jid: JID комнаты (None для личного чата)
|
||
"""
|
||
# Получаем лимит из конфига или используем значение по умолчанию
|
||
max_len: int = getattr(self.config, 'max_history_message_length', 10000)
|
||
if len(content) > max_len:
|
||
content = content[:max_len] + "..."
|
||
logger.debug(f"Сообщение обрезано до {max_len} символов")
|
||
|
||
await self.db.add_history(user_jid, role, content, room_jid)
|
||
logger.debug(f"Сохранено сообщение {role} для {user_jid} (room={room_jid})")
|
||
|
||
async def clear_history(
|
||
self,
|
||
user_jid: str,
|
||
room_jid: Optional[str] = None
|
||
) -> None:
|
||
"""
|
||
Очищает историю диалога (личную или комнатную).
|
||
|
||
Аргументы:
|
||
user_jid: JID пользователя
|
||
room_jid: JID комнаты (None для личной истории)
|
||
"""
|
||
if room_jid:
|
||
await self.db.clear_room_history(room_jid)
|
||
else:
|
||
await self.db.clear_user_history(user_jid)
|
||
logger.info(f"История очищена для {user_jid} (room={room_jid})") |