Update 86 files

- /rag/commands/expert.py
- /core/services/__init__.py
- /core/services/embedding_service.py
- /core/services/file_service.py
- /core/services/giga_client.py
- /core/services/kb_service.py
- /core/services/postgres_service.py
- /core/services/qdrant_service.py
- /core/services/reranker_service.py
- /core/functions/__init__.py
- /core/functions/check_consistency.py
- /core/functions/check_spelling.py
- /core/functions/critique_answer.py
- /core/functions/expand_query.py
- /core/functions/extract_metrics.py
- /core/functions/file_processor.py
- /core/functions/generate_document.py
- /core/functions/intent_classify.py
- /core/functions/rerank_context.py
- /core/functions/summarize_document.py
- /core/utils/__init__.py
- /core/utils/arg_parser.py
- /core/utils/config_loader.py
- /core/utils/layout_converter.py
- /core/utils/logger.py
- /core/utils/text_utils.py
- /core/utils/web_utils.py
- /bots/commands/__init__.py
- /bots/commands/base.py
- /bots/commands/create.py
- /core/commands/global_remove.py
- /core/commands/help.py
- /core/commands/info.py
- /core/commands/kb.py
- /core/commands/learn.py
- /core/commands/other.py
- /core/commands/registry.py
- /core/commands/stats.py
- /core/commands/template.py
- /core/handlers/__init__.py
- /core/handlers/message_handler.py
- /core/workers/__init__.py
- /core/workers/indexing_worker.py
- /core/xmpp/__init__.py
- /core/xmpp/client.py
- /bots/commands/global_remove.py
- /bots/commands/help.py
- /bots/commands/info.py
- /bots/commands/kb.py
- /bots/commands/registry.py
- /bots/commands/other.py
- /bots/commands/template.py
- /bots/commands/learn.py
- /bots/commands/stats.py
- /bots/handlers/message_handler.py
- /bots/handlers/__init__.py
- /bots/workers/__init__.py
- /bots/workers/indexing_worker.py
- /bots/xmpp/__init__.py
- /bots/xmpp/client.py
- /rag/services/qdrant_service.py
- /rag/services/giga_client.py
- /rag/services/embedding_service.py
- /rag/services/reranker_service.py
- /rag/services/__init__.py
- /rag/services/file_service.py
- /rag/services/postgres_service.py
- /rag/services/kb_service.py
- /rag/functions/check_spelling.py
- /rag/functions/__init__.py
- /rag/functions/extract_metrics.py
- /rag/functions/generate_document.py
- /rag/functions/expand_query.py
- /rag/functions/critique_answer.py
- /rag/functions/file_processor.py
- /rag/functions/check_consistency.py
- /rag/functions/summarize_document.py
- /rag/functions/rerank_context.py
- /rag/functions/intent_classify.py
- /rag/utils/__init__.py
- /rag/utils/layout_converter.py
- /rag/utils/text_utils.py
- /rag/utils/arg_parser.py
- /rag/utils/config_loader.py
- /rag/utils/logger.py
- /rag/utils/web_utils.py
This commit is contained in:
Markov Andrey
2026-06-30 10:33:28 +00:00
parent ea0d42de9c
commit 63900feece
45 changed files with 0 additions and 265 deletions

15
rag/functions/__init__.py Normal file
View File

@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
"""
Пакет functions независимые чистые функции без состояния.
Каждая функция решает одну конкретную задачу:
- intent_classify классификация намерений,
- expand_query расширение запроса,
- extract_metrics извлечение KPI,
- summarize_document суммаризация,
- check_consistency проверка противоречий,
- check_spelling проверка орфографии,
- critique_answer самокритика,
- rerank_context переранжирование контекста,
- file_processor отправка файлов и хирургическая замена,
- generate_document генерация по шаблону.
"""

View File

@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
"""
Модуль проверки противоречий между несколькими фрагментами текста.
Анализирует логические, числовые и терминологические расхождения.
АДАПТАЦИЯ: теперь функция принимает промпт как аргумент.
"""
import logging
from typing import List
from core.services.giga_client import GigaClient
logger = logging.getLogger(__name__)
async def check_consistency(
giga: GigaClient,
chunks: List[str],
query: str,
prompt_text: str,
bot_config,
) -> str:
"""
Проверяет, есть ли противоречия между переданными фрагментами.
Аргументы:
giga (GigaClient): клиент GigaChat
chunks (List[str]): список текстовых фрагментов (минимум 2)
query (str): исходный вопрос пользователя
prompt_text (str): содержимое промпта проверки согласованности
bot_config (BotConfig): объект конфигурации
Возвращает:
str: "[OK]" или "[CONFLICT] ..." с описанием
"""
if len(chunks) < 2:
return "Недостаточно фрагментов для проверки (нужно минимум 2)."
if not prompt_text:
logger.warning("Промпт проверки согласованности не загружен")
return "Промпт проверки согласованности не загружен."
cons_cfg = getattr(bot_config, 'consistency', {})
temperature = cons_cfg.get('temperature', 0.1)
max_fragments = cons_cfg.get('max_fragments', 5)
if len(chunks) > max_fragments:
logger.debug(f"Ограничиваем количество фрагментов с {len(chunks)} до {max_fragments}")
chunks = chunks[:max_fragments]
combined = "\n\n---\n\n".join(
[f"Фрагмент {i+1}:\n{chunk}" for i, chunk in enumerate(chunks)]
)
full_prompt = f"{prompt_text}\n\nЗапрос: {query}\n\nФрагменты:\n{combined}"
try:
response = await giga.chat(
history=[],
query=full_prompt,
system_prompt=None,
file_id=None,
temperature=temperature
)
return response.strip()
except Exception as e:
logger.error(f"Ошибка проверки согласованности: {e}", exc_info=True)
return "Ошибка при проверке противоречий."

View File

@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
"""
Модуль проверки орфографии и пунктуации в тексте документа.
Разбивает документ на чанки, обрабатывает каждый чанк через GigaChat,
собирает словарь замен (старое -> новое) и список изменений.
АДАПТАЦИЯ: теперь функция принимает промпт и конфиг как аргументы.
"""
import asyncio
import logging
import re
from core.utils.text_utils import split_into_chunks
from core.services.giga_client import GigaClient
logger = logging.getLogger(__name__)
async def check_spelling(
giga: GigaClient,
original_text: str,
prompt_text: str,
config,
) -> tuple[dict, list[str]]:
"""
Проверяет орфографию документа, возвращает замены и список изменений.
Аргументы:
giga (GigaClient): клиент GigaChat
original_text (str): исходный текст документа
prompt_text (str): содержимое промпта проверки орфографии
config (BotConfig): объект конфигурации
Возвращает:
tuple: (словарь_замен, список_изменений_в_виде_строк)
В случае, если парсинг изменений не удался, возвращается:
({"_raw": сырой_ответ_LLM}, ["Сообщение для пользователя"])
"""
if not prompt_text:
logger.error("Промпт проверки орфографии не загружен")
return {}, ["❌ Промпт проверки орфографии не загружен."]
# Параметры чанкинга из конфига
chunk_size_tokens = config.chunk_size_tokens
overlap_tokens = config.overlap_tokens
approx_chunk_chars = config.chunking_approx_chunk_chars
approx_overlap_chars = config.chunking_approx_overlap_chars
# Разбиваем текст на чанки
chunks = split_into_chunks(
original_text,
chunk_size_tokens=chunk_size_tokens,
overlap_tokens=overlap_tokens,
approx_chunk_chars=approx_chunk_chars,
approx_overlap_chars=approx_overlap_chars,
strategy="recursive_split_by_sentences"
)
logger.info(f"Разбито на {len(chunks)} чанков для проверки орфографии")
all_replacements = {}
all_changes = []
temperature = getattr(config, 'spellcheck_temperature', 0.1)
# Обрабатываем каждый чанк
for idx, chunk in enumerate(chunks):
logger.info(f"Обработка чанка {idx+1}/{len(chunks)}")
full_prompt = f"{prompt_text}\n\nТЕКСТ ДЛЯ ПРОВЕРКИ (часть {idx+1} из {len(chunks)}):\n{chunk}"
try:
response = await giga.chat(
history=[],
query=full_prompt,
system_prompt=None,
file_id=None,
temperature=temperature
)
corrected_match = re.search(r'\[CORRECTED\](.*?)\[/CORRECTED\]', response, re.DOTALL)
changes_match = re.search(r'\[CHANGES\](.*?)\[/CHANGES\]', response, re.DOTALL)
if changes_match:
changes_text = changes_match.group(1).strip()
for line in changes_text.split('\n'):
line = line.strip()
if not line or line.startswith('#'):
continue
patterns = [
r'было\s*["«](.+?)["»]\s*[→-]>\s*стало\s*["«](.+?)["»]',
r'было\s*:\s*["«](.+?)["»]\s*[→-]>\s*стало\s*:\s*["«](.+?)["»]',
r'было\s*["«](.+?)["»]\s*[→-]>\s*(.+)',
r'было\s*:\s*(.+?)\s*[→-]>\s*стало\s*:\s*(.+)',
r'было\s*(.+?)\s*[→-]>\s*стало\s*(.+)'
]
match = None
for pattern in patterns:
match = re.search(pattern, line, re.IGNORECASE)
if match:
old, new = match.group(1).strip(), match.group(2).strip()
break
if match:
all_replacements[old] = new
all_changes.append(f'было "{old}" → стало "{new}"')
else:
all_changes.append(line)
# Если список изменений не распарсился, но есть исправленный текст
if corrected_match and (not changes_match or not changes_match.group(1).strip()):
logger.warning(f"Не удалось распарсить список изменений для чанка {idx+1}, возвращаем сырой ответ")
return {"_raw": response}, [
f"⚠️ Не удалось распарсить список изменений (чанк {idx+1}). Вот сырой ответ LLM:\n{response}"
]
except Exception as e:
logger.error(f"Ошибка при проверке чанка {idx+1}: {e}", exc_info=True)
all_changes.append(f"⚠️ Ошибка в части {idx+1}: {e}")
await asyncio.sleep(0.5)
# Удаляем дубликаты
unique_replacements = {}
unique_changes = []
seen = set()
for old, new in all_replacements.items():
key = f"{old}{new}"
if key not in seen:
seen.add(key)
unique_replacements[old] = new
for change in all_changes:
if change not in seen:
seen.add(change)
unique_changes.append(change)
return unique_replacements, unique_changes

View File

@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
"""
Модуль самокритики: оценивает качество сгенерированного ответа.
Проверяет соответствие источнику, отсутствие галлюцинаций, соблюдение стиля и логику.
АДАПТАЦИЯ: теперь функция принимает промпт как аргумент.
"""
import logging
import re
from core.services.giga_client import GigaClient
logger = logging.getLogger(__name__)
async def critique_answer(
giga: GigaClient,
query: str,
context: str,
answer: str,
prompt_text: str,
bot_config,
) -> bool:
"""
Оценивает ответ, возвращает True, если ответ приемлем, иначе False.
Аргументы:
giga (GigaClient): клиент GigaChat
query (str): исходный вопрос пользователя
context (str): контекст из базы знаний
answer (str): сгенерированный ответ
prompt_text (str): содержимое промпта самокритики
bot_config (BotConfig): объект конфигурации
Возвращает:
bool: True если ответ хороший, False если есть замечания
"""
if not prompt_text:
logger.debug("Промпт самокритики не загружен, пропускаем проверку")
return True
crit_cfg = getattr(bot_config, 'critique', {})
temperature = crit_cfg.get('temperature', 0.1)
if isinstance(temperature, dict):
temperature = 0.1
full_prompt = f"{prompt_text}\n\nВопрос: {query}\nКонтекст:\n{context}\nОтвет:\n{answer}"
try:
response = await giga.chat(
history=[],
query=full_prompt,
system_prompt=None,
file_id=None,
temperature=temperature
)
verdict = response.strip()
if re.search(r'\[OK\]|ПРИЕМЛЕМ', verdict, re.IGNORECASE):
return True
elif re.search(r'\[ISSUES\]', verdict, re.IGNORECASE):
return False
else:
logger.warning(f"Самокритика вернула неопределённый ответ: {verdict[:200]}")
return True
except Exception as e:
logger.error(f"Ошибка самокритики: {e}", exc_info=True)
return True # При ошибке пропускаем проверку

View File

@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
"""
Модуль расширения запроса ключевыми словами.
Добавляет синонимы, аббревиатуры и связанные термины для увеличения релевантности.
АДАПТАЦИЯ: теперь функция принимает промпт как аргумент.
"""
import logging
import time
from core.services.giga_client import GigaClient
logger = logging.getLogger(__name__)
_expand_cache = {}
_CACHE_TTL = 300
async def expand_query(
giga: GigaClient,
query: str,
prompt_text: str,
bot_config,
) -> str:
"""
Расширяет исходный запрос пользователя набором ключевых слов.
Аргументы:
giga (GigaClient): клиент GigaChat
query (str): исходный запрос
prompt_text (str): содержимое промпта расширения
bot_config (BotConfig): объект конфигурации
Возвращает:
str: расширенный запрос или исходный, если расширение не удалось
"""
if not prompt_text:
logger.debug("Промпт расширения пуст, возвращаем исходный запрос")
return query
cache_key = query.strip().lower()
now = time.time()
if cache_key in _expand_cache:
cached_time, cached_result = _expand_cache[cache_key]
if now - cached_time < _CACHE_TTL:
logger.debug(f"Кэш расширения: '{cache_key}' -> '{cached_result}'")
return cached_result
else:
del _expand_cache[cache_key]
expand_cfg = getattr(bot_config, 'expand', {})
temperature = expand_cfg.get('temperature', 0.1)
if isinstance(temperature, dict):
temperature = 0.1
prompt = prompt_text.format(query=query)
try:
response = await giga.chat(
history=[],
query=prompt,
system_prompt=None,
file_id=None,
temperature=temperature
)
expanded = response.strip()
if expanded and expanded != query:
logger.debug(f"Расширенный запрос: {expanded}")
_expand_cache[cache_key] = (now, expanded)
return expanded
else:
_expand_cache[cache_key] = (now, query)
return query
except Exception as e:
logger.error(f"Ошибка расширения запроса: {e}", exc_info=True)
return query

View File

@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
"""
Модуль извлечения метрик (KPI) из текста.
Использует промпт metrics_extract.txt.
АДАПТАЦИЯ: теперь функция принимает промпт как аргумент.
"""
import json
import logging
from typing import List, Dict
from core.services.giga_client import GigaClient
logger = logging.getLogger(__name__)
async def extract_metrics(
giga: GigaClient,
context: str,
prompt_text: str,
bot_config,
) -> List[Dict]:
"""
Извлекает числовые показатели, KPI, статистические величины из контекста.
Аргументы:
giga (GigaClient): клиент GigaChat
context (str): текст для анализа
prompt_text (str): содержимое промпта для извлечения метрик
bot_config (BotConfig): объект конфигурации
Возвращает:
List[Dict]: список метрик с полями metric_name, value, unit, period, source_fragment
"""
if not prompt_text:
logger.warning("Промпт извлечения метрик пуст")
return []
if not context or len(context) < 20:
logger.debug("Контекст слишком короткий для извлечения метрик")
return []
metrics_cfg = getattr(bot_config, 'metrics', {})
temperature = metrics_cfg.get('temperature', 0.1)
if isinstance(temperature, dict):
temperature = 0.1
full_prompt = f"{prompt_text}\n\nТекст для анализа:\n{context}"
try:
response = await giga.chat(
history=[],
query=full_prompt,
system_prompt=None,
file_id=None,
temperature=temperature
)
raw_answer = response.strip()
# Пытаемся распарсить JSON
try:
metrics = json.loads(raw_answer)
if isinstance(metrics, list):
return metrics[:15]
except json.JSONDecodeError:
# Fallback: построчный разбор
logger.debug("Не удалось распарсить JSON, пробуем построчный разбор")
lines = raw_answer.split('\n')
result = []
for line in lines:
if ':' in line:
parts = line.split(':', 1)
result.append({
"metric_name": parts[0].strip(),
"value": parts[1].strip(),
"unit": "",
"period": "",
"source_fragment": ""
})
return result
return []
except Exception as e:
logger.error(f"Ошибка извлечения метрик: {e}", exc_info=True)
return []

View File

@@ -0,0 +1,169 @@
# -*- coding: utf-8 -*-
"""
Обработка файлов: отправка пользователю через HTTP Upload и хирургическая замена в DOCX.
Функции: send_file_to_user, handle_ai_file_request.
АДАПТАЦИЯ: эти функции уже работают через HTTP (загружают файлы в ejabberd).
Они не требуют изменений, так как работают с локальными файлами.
Но я добавлю комментарии для ясности.
"""
import asyncio
import os
import re
import shutil
import time
import random
import string
import logging
logger = logging.getLogger(__name__)
async def send_file_to_user(bot, msg, file_path, prefix="", suffix=None):
"""
Отправляет файл пользователю через HTTP Upload (XEP-0363).
Аргументы:
bot: экземпляр бота
msg: объект XMPP-сообщения
file_path: путь к файлу на диске
prefix: текст перед ссылкой
suffix: суффикс для имени файла
Возвращает:
bool: True при успехе, False при ошибке
"""
if not os.path.exists(file_path):
logger.error(f"Файл не найден: {file_path}")
return False
original_name = os.path.basename(file_path)
name, ext = os.path.splitext(original_name)
# Санитизация суффикса (только буквы, цифры, подчёркивание, дефис)
if suffix:
suffix = re.sub(r'[^a-zA-Zа-яА-ЯёЁ0-9_-]', '', suffix)
suffix = suffix.strip('.')
if not suffix or suffix == '_':
suffix = '_sanitized'
if not suffix.startswith('_'):
suffix = '_' + suffix
new_name = f"{name}{suffix}{ext}"
new_path = os.path.join(os.path.dirname(file_path), new_name)
os.rename(file_path, new_path)
file_path = new_path
try:
# Загружаем файл через XEP-0363 (HTTP Upload)
result = await bot.plugin['xep_0363'].upload_file(file_path)
if isinstance(result, dict):
file_url = result.get('get_url', result.get('url', str(result)))
else:
file_url = str(result)
except Exception as e:
logger.error(f"Ошибка загрузки файла через XEP-0363: {e}", exc_info=True)
error_reply = msg.reply("Не удалось загрузить файл. Проверьте настройки HTTP Upload.")
if error_reply:
await error_reply.send()
return False
reply = msg.reply(f"{prefix}\n📄 {file_url}")
if reply:
reply.send()
else:
logger.error("Не удалось создать reply для ссылки на файл")
return True
async def handle_ai_file_request(bot, msg, ai_response, user_jid, room_jid=None, body=None, suffix=None):
"""
Обрабатывает теги [SURGICAL_REPLACE], [REWRITE], [FILE] из ответа AI.
Поддерживает три режима:
- SURGICAL: замена текста в существующем DOCX
- REWRITE: создание нового DOCX из текста
- FILE: создание нового DOCX (общий случай)
"""
key = (user_jid, room_jid) if room_jid is not None else user_jid
surgical_match = re.search(r'\[SURGICAL_REPLACE\](.*?)\[/SURGICAL_REPLACE\]', ai_response, re.DOTALL)
rewrite_match = re.search(r'\[REWRITE\](.*?)\[/REWRITE\]', ai_response, re.DOTALL)
file_match = re.search(r'\[FILE\](.*?)\[/FILE\]', ai_response, re.DOTALL)
# ---- SURGICAL_REPLACE ----
if surgical_match:
content = surgical_match.group(1).strip()
replacements = {}
for line in content.split('\n'):
line = line.strip()
if '|||' in line:
old, new = line.split('|||', 1)
replacements[old.strip()] = new.strip()
if not replacements:
reply = msg.reply("⚠️ Не найдено пар 'старое ||| новое' в теге SURGICAL_REPLACE.")
if reply:
reply.send()
return
original_path = bot._last_file_path.get(key)
if not original_path or not os.path.exists(original_path):
reply = msg.reply("❌ Нет загруженного документа для замены. Сначала отправьте файл (без !learn).")
if reply:
reply.send()
return
new_path = await asyncio.to_thread(bot.files.surgical_replace, original_path, replacements)
if new_path:
await send_file_to_user(bot, msg, new_path, prefix="✅ Замена выполнена:", suffix="surgical")
try:
os.unlink(new_path)
except Exception as e:
logger.warning(f"Не удалось удалить временный файл {new_path}: {e}")
else:
reply = msg.reply("❌ Ошибка при хирургической замене. Проверьте формат документа.")
if reply:
reply.send()
return
# ---- REWRITE ----
if rewrite_match:
content = rewrite_match.group(1).strip()
title = "Сгенерированный документ"
title_match = re.search(r'\[TITLE\](.*?)\[/TITLE\]', ai_response, re.DOTALL)
if title_match:
title = title_match.group(1).strip()
new_path = await asyncio.to_thread(bot.files.create_docx, content, title, user_jid, mode="REWRITE")
if new_path:
await send_file_to_user(bot, msg, new_path, prefix="📄 Документ создан:", suffix="rewrite")
try:
os.unlink(new_path)
except Exception as e:
logger.warning(f"Не удалось удалить временный файл {new_path}: {e}")
else:
reply = msg.reply("Не удалось создать документ.")
if reply:
reply.send()
return
# ---- FILE ----
if file_match:
content = file_match.group(1).strip()
title = "Файл"
title_match = re.search(r'\[TITLE\](.*?)\[/TITLE\]', ai_response, re.DOTALL)
if title_match:
title = title_match.group(1).strip()
new_path = await asyncio.to_thread(bot.files.create_docx, content, title, user_jid, mode="FILE")
if new_path:
await send_file_to_user(bot, msg, new_path, prefix="📄 Файл создан:", suffix="file")
try:
os.unlink(new_path)
except Exception as e:
logger.warning(f"Не удалось удалить временный файл {new_path}: {e}")
else:
reply = msg.reply("Не удалось создать файл.")
if reply:
reply.send()
return
logger.debug(f"Ответ AI не содержит тегов файлов: {ai_response[:200]}")

View File

@@ -0,0 +1,180 @@
# -*- coding: utf-8 -*-
"""
Модуль генерации документа по шаблону.
Заполняет плейсхолдеры {{...}} на основе данных из базы знаний.
АДАПТАЦИЯ: теперь функция принимает промпт и конфиг как аргументы.
"""
import re
import asyncio
import os
import logging
import shutil
from docx import Document
from core.functions.file_processor import send_file_to_user
logger = logging.getLogger(__name__)
async def generate_document_from_template(
bot,
msg,
room_jid,
template_path,
template_name,
user_jid,
giga,
kb,
config,
prompt_text,
temperature=0.1,
allow_public=False
):
"""
Фоновая задача: парсит шаблон, ищет плейсхолдеры {{...}},
для каждого делает контекстный поиск в БЗ комнаты, генерирует значение через LLM,
заменяет в копии шаблона и отправляет готовый файл в комнату.
Аргументы:
bot: экземпляр бота
msg: объект XMPP-сообщения
room_jid: JID комнаты
template_path: путь к шаблону
template_name: имя шаблона
user_jid: JID пользователя
giga: клиент GigaChat
kb: сервис базы знаний
config: объект конфигурации
prompt_text: содержимое промпта для генерации
temperature: температура генерации
allow_public: разрешено ли использовать общие знания
"""
logger.info(f"🚀 [ГЕНЕРАЦИЯ] Запущена для шаблона '{template_name}' в комнате {room_jid}")
try:
doc = Document(template_path)
# Собираем все плейсхолдеры и их контекст
placeholders_info = {}
for paragraph in doc.paragraphs:
for match in re.finditer(r'\{\{([^}]+)\}\}', paragraph.text):
ph = match.group(1)
if ph not in placeholders_info:
placeholders_info[ph] = paragraph.text
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for match in re.finditer(r'\{\{([^}]+)\}\}', cell.text):
ph = match.group(1)
if ph not in placeholders_info:
placeholders_info[ph] = cell.text
placeholders = set(placeholders_info.keys())
logger.info(f"🔍 [ГЕНЕРАЦИЯ] Найдено плейсхолдеров: {len(placeholders)}")
if not placeholders:
reply = msg.reply(f"В шаблоне '{template_name}' нет плейсхолдеров {{...}}.")
if reply:
reply.send()
return
generated = {}
for ph, surrounding in placeholders_info.items():
logger.info(f"📖 [ГЕНЕРАЦИЯ] Обработка плейсхолдера '{ph}'")
# Формируем поисковый запрос
search_query = f"{ph} {surrounding[:100]}"
context = await kb.find_relevant_info(search_query, user_jid, room_jid, top_k=20)
if not context and allow_public:
logger.info(f"⚠️ [ГЕНЕРАЦИЯ] Контекст для '{ph}' не найден, используем общие знания")
prompt = f"Ты — эксперт. Ответь на вопрос: {ph}. Дай краткое значение, без пояснений."
try:
response = await giga.chat(
history=[],
query=prompt,
system_prompt=None,
file_id=None,
temperature=temperature
)
generated[ph] = response.strip()
continue
except Exception as e:
logger.error(f"❌ [ГЕНЕРАЦИЯ] Ошибка LLM для '{ph}': {e}")
generated[ph] = "[ошибка]"
continue
if not context:
logger.warning(f"⚠️ [ГЕНЕРАЦИЯ] Нет контекста для '{ph}', и общие знания запрещены")
generated[ph] = "не определено"
continue
# Генерация значения на основе контекста
prompt = prompt_text.format(ph=ph, context=context)
try:
response = await giga.chat(
history=[],
query=prompt,
system_prompt=None,
file_id=None,
temperature=temperature
)
cleaned = re.sub(r'^```\w*\n?|```$', '', response.strip())
generated[ph] = cleaned
logger.info(f"✅ [ГЕНЕРАЦИЯ] Значение для '{ph}': {generated[ph][:100]}")
except Exception as e:
logger.error(f"❌ [ГЕНЕРАЦИЯ] Ошибка LLM для '{ph}': {e}", exc_info=True)
generated[ph] = "[ошибка]"
# Замена плейсхолдеров в копии документа
temp_dir = config.temp_dir
new_path = temp_dir / f"generated_{template_name}_{int(asyncio.get_event_loop().time())}.docx"
shutil.copy2(template_path, new_path)
new_doc = Document(new_path)
def replace_in_paragraph(paragraph, replacements):
full_text = ''.join(run.text for run in paragraph.runs)
modified = False
for key, value in replacements.items():
placeholder = f"{{{{{key}}}}}"
if placeholder in full_text:
for run in paragraph.runs:
if placeholder in run.text:
run.text = run.text.replace(placeholder, value)
modified = True
if not modified:
for key, value in replacements.items():
placeholder = f"{{{{{key}}}}}"
if placeholder in paragraph.text:
paragraph.text = paragraph.text.replace(placeholder, value)
def walk_document(doc_obj, replacements):
for paragraph in doc_obj.paragraphs:
replace_in_paragraph(paragraph, replacements)
for table in doc_obj.tables:
for row in table.rows:
for cell in row.cells:
for paragraph in cell.paragraphs:
replace_in_paragraph(paragraph, replacements)
walk_document(new_doc, generated)
new_doc.save(new_path)
await send_file_to_user(bot, msg, str(new_path), prefix=f"📄 Сгенерированный документ по шаблону '{template_name}':", suffix="generated")
try:
os.unlink(new_path)
except Exception as e:
logger.warning(f"Не удалось удалить временный файл {new_path}: {e}")
logger.info(f"🏁 [ГЕНЕРАЦИЯ] Завершено для шаблона '{template_name}'")
except Exception as e:
logger.exception(f"💥 [ГЕНЕРАЦИЯ] Критическая ошибка: {e}")
if msg:
reply = msg.reply(f"❌ Ошибка при генерации документа: {e}")
if reply:
reply.send()

View File

@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""
Модуль классификации намерений пользователя.
Определяет тип запроса: METRICS, SUMMARY, CONTRADICTION, SURGICAL, GREETING и др.
АДАПТАЦИЯ: теперь функция принимает промпт как аргумент, а не использует глобальный.
"""
import logging
import time
from core.services.giga_client import GigaClient
logger = logging.getLogger(__name__)
# Список всех допустимых кодов намерений
VALID_INTENTS = {
"METRICS", "SUMMARY", "CONTRADICTION", "FACT", "PROCEDURE",
"COMPARISON", "CALCULATION", "SURGICAL", "GREETING", "TEMPLATE_FILL",
"SPELLCHECK"
}
# Кэш для классификации (in-memory)
_intent_cache = {}
_CACHE_TTL = 300 # 5 минут
async def classify_intent(
giga: GigaClient,
query: str,
prompt_text: str,
bot_config,
) -> str:
"""
Классифицирует намерение пользователя на основе текста запроса.
Аргументы:
giga (GigaClient): клиент GigaChat
query (str): исходный запрос пользователя
prompt_text (str): содержимое промпта классификации (передаётся из оркестратора)
bot_config (BotConfig): объект конфигурации бота
Возвращает:
str: один из кодов VALID_INTENTS или "GENERAL"
"""
if not prompt_text:
logger.warning("Промпт классификации пуст, возвращаем GENERAL")
return "GENERAL"
# Проверка кэша
cache_key = query.strip().lower()
now = time.time()
if cache_key in _intent_cache:
cached_time, cached_result = _intent_cache[cache_key]
if now - cached_time < _CACHE_TTL:
logger.debug(f"Кэш классификации: '{cache_key}' -> {cached_result}")
return cached_result
else:
del _intent_cache[cache_key]
# Получаем температуру из конфига
intent_cfg = getattr(bot_config, 'intent', {})
temperature = intent_cfg.get('temperature', 0.1)
if isinstance(temperature, dict):
temperature = 0.1
# Формируем полный промпт
full_prompt = f"{prompt_text}\n\nВопрос: {query}"
try:
response = await giga.chat(
history=[],
query=full_prompt,
system_prompt=None,
file_id=None,
temperature=temperature
)
answer = response.strip().upper()
if answer in VALID_INTENTS:
logger.debug(f"Классификация успешна: {answer}")
_intent_cache[cache_key] = (now, answer)
return answer
else:
logger.debug(f"Неизвестный ответ классификатора: {answer}, возвращаем GENERAL")
return "GENERAL"
except Exception as e:
logger.error(f"Ошибка классификации намерения: {e}", exc_info=True)
return "GENERAL"

View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
"""
Модуль переранжирования контекста: отбирает только те фрагменты,
которые действительно релевантны вопросу.
Использует локальный кросс-энкодер.
АДАПТАЦИЯ: теперь функция принимает bot (для доступа к конфигу) и использует RerankerService.
"""
import logging
from core.services.reranker_service import RerankerService
logger = logging.getLogger(__name__)
_reranker = None
def get_reranker(bot_config) -> RerankerService:
"""Возвращает глобальный экземпляр RerankerService (ленивая инициализация)."""
global _reranker
if _reranker is None:
model_name = getattr(bot_config, 'rerank_model', None)
device = getattr(bot_config, 'rerank_device', None)
_reranker = RerankerService(model_name=model_name, device=device)
logger.info(f"RerankerService инициализирован (модель: {model_name or 'default'})")
return _reranker
async def rerank_context(
bot,
query: str,
context: str,
prompt_text: str = None, # сохранён для обратной совместимости, но не используется
bot_config=None,
) -> str:
"""
Переранжирует контекст, оставляя только релевантные фрагменты.
Использует кросс-энкодер вместо LLM.
Аргументы:
bot: экземпляр Bot (для доступа к конфигу) — может быть None, если передан bot_config
query: исходный запрос пользователя
context: исходный контекст (абзацы, разделённые двойными переносами строки "\n\n")
prompt_text: не используется
bot_config: объект конфигурации (если bot не передан)
Возвращает:
str: отфильтрованный контекст
"""
if not context:
return context
# Получаем конфиг либо из bot, либо из bot_config
if bot is not None and hasattr(bot, 'config'):
config = bot.config
elif bot_config is not None:
config = bot_config
else:
logger.error("Ни bot, ни bot_config не переданы в rerank_context")
return context
rerank_cfg = getattr(config, 'rerank', {})
min_length = rerank_cfg.get('min_length', 5000)
# Если контекст короткий, переранжирование не требуется
if len(context) < min_length:
logger.debug(f"Контекст слишком короткий ({len(context)} < {min_length}), пропускаем переранжирование")
return context
# Получаем сервис переранжирования
reranker = get_reranker(config)
try:
top_k = rerank_cfg.get('top_k', None)
ranked_context = reranker.rerank_context(query, context, top_k=top_k)
logger.debug(f"Переранжирование выполнено: исходный контекст {len(context)} символов")
return ranked_context
except Exception as e:
logger.error(f"Ошибка при переранжировании: {e}. Возвращаем исходный контекст.")
return context

View File

@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""
Модуль суммаризации документа.
Создаёт краткий пересказ, сохраняя ключевые факты и цифры.
АДАПТАЦИЯ: теперь функция принимает промпт как аргумент.
"""
import logging
from core.services.giga_client import GigaClient
logger = logging.getLogger(__name__)
async def summarize_document(
giga: GigaClient,
text: str,
title: str,
prompt_text: str,
bot_config,
) -> str:
"""
Суммаризирует документ.
Аргументы:
giga (GigaClient): клиент GigaChat
text (str): полный текст документа
title (str): название документа
prompt_text (str): содержимое промпта суммаризации
bot_config (BotConfig): объект конфигурации
Возвращает:
str: краткое изложение документа или сообщение об ошибке
"""
if not prompt_text:
logger.error("Промпт суммаризации не загружен")
return "Не удалось загрузить промпт для суммаризации."
summary_cfg = getattr(bot_config, 'summary', {})
temperature = summary_cfg.get('temperature', 0.1)
max_chars = summary_cfg.get('max_chars', 8000)
# Обрезаем текст, если он слишком длинный
if len(text) > max_chars:
# Обрезаем по границам предложений (используем razdel)
try:
from razdel import sentenize
sentences = [sent.text for sent in sentenize(text)]
except ImportError:
import re
sentences = re.split(r'(?<=[.!?])\s+', text)
truncated = []
current_len = 0
for sent in sentences:
if current_len + len(sent) <= max_chars:
truncated.append(sent)
current_len += len(sent)
else:
break
text = ' '.join(truncated)
logger.warning(f"Текст обрезан до {max_chars} символов по границам предложений")
full_prompt = f"{prompt_text}\n\nНазвание документа: {title}\nТекст:\n{text}"
try:
response = await giga.chat(
history=[],
query=full_prompt,
system_prompt=None,
file_id=None,
temperature=temperature
)
return response.strip()
except Exception as e:
logger.error(f"Ошибка суммаризации: {e}", exc_info=True)
return "Не удалось создать суммаризацию."