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:
15
rag/functions/__init__.py
Normal file
15
rag/functions/__init__.py
Normal 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 – генерация по шаблону.
|
||||
"""
|
||||
66
rag/functions/check_consistency.py
Normal file
66
rag/functions/check_consistency.py
Normal 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 "Ошибка при проверке противоречий."
|
||||
135
rag/functions/check_spelling.py
Normal file
135
rag/functions/check_spelling.py
Normal 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
|
||||
68
rag/functions/critique_answer.py
Normal file
68
rag/functions/critique_answer.py
Normal 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 # При ошибке пропускаем проверку
|
||||
76
rag/functions/expand_query.py
Normal file
76
rag/functions/expand_query.py
Normal 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
|
||||
84
rag/functions/extract_metrics.py
Normal file
84
rag/functions/extract_metrics.py
Normal 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 []
|
||||
169
rag/functions/file_processor.py
Normal file
169
rag/functions/file_processor.py
Normal 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]}")
|
||||
180
rag/functions/generate_document.py
Normal file
180
rag/functions/generate_document.py
Normal 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()
|
||||
89
rag/functions/intent_classify.py
Normal file
89
rag/functions/intent_classify.py
Normal 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"
|
||||
80
rag/functions/rerank_context.py
Normal file
80
rag/functions/rerank_context.py
Normal 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
|
||||
77
rag/functions/summarize_document.py
Normal file
77
rag/functions/summarize_document.py
Normal 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 "Не удалось создать суммаризацию."
|
||||
Reference in New Issue
Block a user