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

View File

@@ -1,7 +0,0 @@
# -*- coding: utf-8 -*-
"""
Пакет commands команды XMPP-ботов (каждая команда — отдельный класс).
Команды реализуют пользовательский интерфейс:
- info, help, learn, kb, stats, expert, reset, template, create, global_remove.
Регистрация команд осуществляется через registry.py.
"""

View File

@@ -1,77 +0,0 @@
# -*- coding: utf-8 -*-
"""
Базовый класс для всех команд бота.
Реализует:
- алиасы (альтернативные имена команд)
- проверку прав доступа (admin)
- парсинг аргументов (через core.utils.arg_parser)
- метод справки get_help()
- кэширование результатов (опционально)
Изменения для групповой работы:
- метод execute теперь принимает дополнительный параметр room_jid (None для личных чатов).
- Команды, которым нужен контекст комнаты, могут его использовать.
"""
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Dict, Any, Tuple, List, Optional
import time
from core.utils.arg_parser import parse_args # путь изменён: arg_parser перенесён в utils
if TYPE_CHECKING:
from core.xmpp.client import Bot # обновлённый импорт
class Command(ABC):
"""Абстрактный базовый класс команды. Все команды должны наследоваться от него."""
# Основное имя команды (например "!info")
name: str = None
# Альтернативные имена (например ["!help", "!справка"])
aliases: List[str] = []
# Необходимые права: None любой, "admin" только администраторы
permission: Optional[str] = None
# Время жизни кэша в секундах (0 не кэшировать)
cache_ttl: int = 0
def __init__(self, bot: 'Bot'):
self.bot = bot
# Кэш: ключ -> (время, результат)
self._cache: Dict[str, Tuple[float, Any]] = {}
@abstractmethod
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
"""
Выполнить команду.
:param msg: объект сообщения slixmpp (для ответа)
:param args: полная строка аргументов (включая имя команды)
:param user_jid: JID пользователя (без ресурса)
:param room_jid: JID комнаты, если команда вызвана в групповом чате; иначе None
"""
pass
def get_help(self) -> str:
"""Возвращает строку справки для команды. Переопределяется в дочерних классах."""
return "Нет описания."
def check_permission(self, user_jid: str) -> bool:
"""Проверяет, имеет ли пользователь право на выполнение команды."""
if self.permission is None:
return True
if self.permission == "admin":
return user_jid in self.bot.config.admin_jids
return False
def get_cache_key(self, user_jid: str, args: str) -> str:
"""Формирует ключ для кэширования (по умолчанию user_jid)."""
return user_jid
def parse_args(self, args_str: str) -> Tuple[str, Dict[str, Any], List[str]]:
"""Разбирает строку аргументов на команду, флаги и позиционные аргументы."""
return parse_args(args_str)
def _cache_result(self, key: str, result: str):
"""Сохраняет результат в кэш (вызывается из команды)."""
self._cache[key] = (time.time(), result)

View File

@@ -1,60 +0,0 @@
# -*- coding: utf-8 -*-
from core.commands.base import Command
from core.functions.generate_document import generate_document_from_template
import asyncio
import logging
logger = logging.getLogger(__name__)
class CreateCommand(Command):
name = "!create"
aliases = ["/create"]
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
if not room_jid:
reply = msg.reply("⚠️ Команда !create работает только в комнатах.")
if reply:
reply.send()
return
clean_args = args.strip()
if clean_args.startswith(self.name):
clean_args = clean_args[len(self.name):].lstrip()
name = clean_args.strip()
if name.startswith('"') and name.endswith('"'):
name = name[1:-1]
if not name:
reply = msg.reply("Укажите название шаблона: !create \"Название\"")
if reply:
reply.send()
return
template_info = await self.bot.db.get_template(room_jid, name)
if not template_info:
reply = msg.reply(f"❌ Шаблон '{name}' не найден.")
if reply:
reply.send()
return
reply = msg.reply("🔄 Начинаю генерацию документа. Это может занять несколько минут. Я сообщу, когда будет готово.")
if reply:
reply.send()
asyncio.create_task(
generate_document_from_template(
bot=self.bot,
msg=msg,
room_jid=room_jid,
template_path=template_info['file_path'],
template_name=name,
user_jid=user_jid
)
)
def get_help(self) -> str:
return (
"Генерирует документ по шаблону из базы знаний. "
"Использование: `!create \"Название шаблона\"` (только в комнате)"
)

View File

@@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
"""
Команда !global_remove удаление документа из глобальной базы знаний по его названию.
Только для администраторов, работает только в личном чате.
Изменения для PostgreSQL (asyncpg):
- Замена курсоров на прямые запросы conn.fetch / conn.execute.
- Использование параметризованных запросов с $1, $2 и ANY($1) для массовых операций.
- Удаление синтаксиса, специфичного для MySQL/MariaDB.
"""
import logging
from core.commands.base import Command
logger = logging.getLogger(__name__)
class GlobalRemoveCommand(Command):
"""Удаляет глобальный документ по точному совпадению source_name."""
name = "!global_remove"
aliases = ["/global_remove"]
permission = "admin"
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
"""
Алгоритм:
1. Проверка прав администратора.
2. Проверка, что команда вызвана в личном чате (room_jid is None).
3. Разбор аргументов, извлечение названия документа (в кавычках или без).
4. Поиск всех глобальных документов с таким source_name.
5. Удаление найденных документов из Qdrant (по doc_id).
6. Удаление записей из таблиц document_access и documents в PostgreSQL.
"""
# 1. Проверка прав администратора
if not self.check_permission(user_jid):
reply = msg.reply("У вас нет прав на удаление глобальных документов.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить сообщение о недостатке прав для global_remove")
return
# 2. Команда работает только в личном чате
if room_jid:
reply = msg.reply("⚠️ Команда !global_remove работает только в личном чате.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить сообщение об ограничении !global_remove")
return
# 3. Разбор аргументов команды
_, flags, pos = self.parse_args(args)
if not pos:
reply = msg.reply("❌ Укажите название документа для удаления. Пример: `!global_remove \"ГОСТ Р 12345\"`")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить сообщение о необходимости указать название документа")
return
source_name = " ".join(pos).strip()
# Удаляем возможные кавычки, если пользователь заключил название в них
if source_name.startswith('"') and source_name.endswith('"'):
source_name = source_name[1:-1]
# 4. Поиск глобальных документов с указанным названием
async with self.bot.db.pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id FROM documents WHERE is_global = TRUE AND source_name = $1",
source_name
)
doc_ids = [row['id'] for row in rows]
if not doc_ids:
reply = msg.reply(f"📭 Глобальный документ с названием '{source_name}' не найден.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить сообщение о ненайденном глобальном документе")
return
# 5. Удаление из Qdrant (по каждому doc_id)
for doc_id in doc_ids:
self.bot.qdrant.delete_by_doc_id(doc_id)
logger.debug(f"Удалён из Qdrant doc_id={doc_id}")
# 6. Удаление из PostgreSQL (сначала связанные права доступа, затем сам документ)
async with self.bot.db.pool.acquire() as conn:
# Удаляем записи о доступе (если есть)
await conn.execute("DELETE FROM document_access WHERE doc_id = ANY($1)", doc_ids)
# Удаляем сами документы
await conn.execute("DELETE FROM documents WHERE id = ANY($1)", doc_ids)
logger.info(f"Администратор {user_jid} удалил глобальный документ '{source_name}' (doc_ids: {doc_ids})")
reply = msg.reply(f"✅ Глобальный документ '{source_name}' удалён из базы знаний.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить подтверждение удаления глобального документа")
def get_help(self) -> str:
return (
"Удаляет глобальный документ по названию (админ, только в личном чате). "
"Использование: `!global_remove \"Название документа\"`"
)

53
bots/commands/help.py Normal file
View File

@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
"""
Команда !help показывает справку по всем командам или конкретной команде.
Алиасы: !справка, /help, /справка.
"""
import logging
from core.commands.base import Command
logger = logging.getLogger(__name__)
class HelpCommand(Command):
name = "!help"
aliases = ["!справка", "/help", "/справка"]
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
"""
Если указано имя команды показывает справку по ней.
Иначе список всех доступных команд.
"""
_, flags, pos = self.parse_args(args)
if pos:
cmd_name = pos[0].lower()
cmd_obj = self.bot.commands.get(cmd_name)
if cmd_obj:
help_text = cmd_obj.get_help()
reply = msg.reply(help_text)
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить справку по команде")
else:
reply = msg.reply(f"❓ Команда '{cmd_name}' не найдена. Напишите `!help` для списка команд.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить сообщение о неизвестной команде")
else:
# Общая справка
lines = ["**Доступные команды:**"]
# Сортируем по имени команды
for name, cmd in sorted(self.bot.commands.items()):
if name == cmd.name: # только основные имена (без алиасов)
lines.append(f"{cmd.name} {cmd.get_help().splitlines()[0]}")
reply = msg.reply("\n".join(lines))
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить общий список команд")
def get_help(self) -> str:
return "Показывает справку. Использование: `!help` или `!help команда`"

51
bots/commands/info.py Normal file
View File

@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
"""
Команда !info показывает краткую справку по всем командам бота.
Не зависит от комнаты всегда одна и та же.
"""
import logging
from core.commands.base import Command
logger = logging.getLogger(__name__)
class InfoCommand(Command):
name = "!info"
aliases = ["/info", "!справкараткая"]
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
"""
Отправляет пользователю краткую справку.
Параметр room_jid игнорируется.
"""
help_text = (
"📚 **Управление знаниями:**\n"
"!learn [--global] [--title \"Название\"] включить обучение (следующий файл/текст/ссылка добавятся в БЗ)\n"
"!stop_learn выключить обучение\n"
"!clear очистить ЛИЧНУЮ БЗ (в группе БЗ комнаты)\n"
"!global_clear очистить ГЛОБАЛЬНУЮ БЗ (админ)\n"
"!global_remove \"Название\" удалить глобальный документ по названию (админ)\n"
"!kb полный список документов в БЗ (личные и глобальные; в группе документы комнаты)\n"
"!reset сбросить историю диалога\n\n"
"💬 **Диалог:**\n"
"!stats статистика работы бота (админ)\n"
"!status статус сервисов (админ)\n"
"!clean очистить временные файлы (админ)\n\n"
"🔬 **Экспертные команды:**\n"
"!summary суммаризация последнего документа\n"
"!metrics извлечь KPI из всей базы знаний (кэш 5 мин)\n\n"
"🌐 **Обучение по ссылкам (в режиме `!learn`):**\n"
"https://example.com [--recursive] [--depth N] [--max M] рекурсивная загрузка сайта\n\n"
"❓ **Справка:**\n"
"!help подробный список команд с синтаксисом\n"
"!help команда справка по конкретной команде"
)
reply = msg.reply(help_text)
if reply:
reply.send() # <-- УБРАЛИ await
else:
logger.error("Не удалось отправить справку info")
def get_help(self) -> str:
return "Показывает краткую справку. Использование: `!info`"

131
bots/commands/kb.py Normal file
View File

@@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
"""
Команды управления базой знаний: !clear, !global_clear, !kb.
Поддержка комнат:
- !clear в комнате очищает комнатную БЗ (требует прав администратора бота).
- !kb в комнате показывает документы комнаты + глобальные.
"""
import logging
from core.commands.base import Command
logger = logging.getLogger(__name__)
class ClearCommand(Command):
name = "!clear"
aliases = ["/clear"]
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
"""
Очищает базу знаний:
- В личном чате личную БЗ пользователя.
- В групповом чате БЗ комнаты (требует прав администратора бота).
"""
if room_jid:
# Для очистки комнатной БЗ нужны права администратора бота
if user_jid not in self.bot.config.admin_jids:
reply = msg.reply("⛔ Только администраторы бота могут очищать комнатную БЗ.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить сообщение о правах на очистку комнаты")
return
await self.bot.kb.clear_room_kb(room_jid)
reply = msg.reply(f"🗑 Комнатная БЗ {room_jid} очищена.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить подтверждение очистки комнатной БЗ")
else:
await self.bot.kb.clear_user_kb(user_jid)
reply = msg.reply("🗑 Ваша ЛИЧНАЯ база знаний очищена.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить подтверждение очистки личной БЗ")
def get_help(self) -> str:
return "Очищает вашу личную базу знаний. В групповом чате очищает БЗ комнаты (требует прав администратора бота)."
class GlobalClearCommand(Command):
name = "!global_clear"
aliases = ["/global_clear"]
permission = "admin"
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
"""
Очищает глобальную базу знаний (только админ, только в личном чате).
В комнате команда не работает.
"""
if not self.check_permission(user_jid):
reply = msg.reply("⛔ Нет прав.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить сообщение о недостатке прав (kb)")
return
if room_jid:
reply = msg.reply("⚠️ Команда !global_clear работает только в личном чате.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить сообщение об ограничении !global_clear")
return
await self.bot.kb.clear_global_kb()
reply = msg.reply("🌐 ГЛОБАЛЬНАЯ база знаний очищена.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить подтверждение очистки глобальной БЗ")
def get_help(self) -> str:
return "Очищает глобальную базу знаний (админ, только в личном чате)."
class KbCommand(Command):
name = "!kb"
aliases = ["/kb"]
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
"""
Показывает список документов в базе знаний:
- В личном чате: личные + глобальные.
- В групповом чате: документы комнаты + глобальные.
"""
sources = await self.bot.kb.get_sources_with_type(user_jid, room_jid)
if not sources:
if room_jid:
reply = msg.reply("📭 База знаний этой комнаты пуста.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить сообщение о пустой комнатной БЗ")
else:
reply = msg.reply("📭 Ваша личная база знаний пуста.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить сообщение о пустой личной БЗ")
return
global_docs = [s['source_name'] for s in sources if s['is_global']]
other_docs = [s['source_name'] for s in sources if not s['is_global']]
lines = []
if global_docs:
lines.append("🌍 **Глобальные документы:**")
lines.extend(f"{name}" for name in global_docs)
if other_docs:
if room_jid:
lines.append(f"🏠 **Документы комнаты {room_jid}:**")
else:
lines.append("👤 **Ваши личные документы:**")
lines.extend(f"{name}" for name in other_docs)
reply = msg.reply("\n".join(lines))
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить список документов БЗ")
def get_help(self) -> str:
return "Показывает список документов в базе знаний (личные/комнатные + глобальные)."

128
bots/commands/learn.py Normal file
View File

@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""
Команды управления режимом обучения: !learn, !stop_learn, !global_learn.
Поддерживают флаги --global и --title.
Изменения для групповой работы:
- В групповом чате !learn не нужен для файлов (они индексируются автоматически),
но может использоваться для ссылок (если пользователь хочет добавить ссылку в БЗ комнаты).
- Режим обучения в комнате сохраняется с привязкой к комнате.
"""
import logging
from core.commands.base import Command
logger = logging.getLogger(__name__)
class LearnCommand(Command):
name = "!learn"
aliases = ["/learn", "!дуфкт", "/дуфкт", "/учись"]
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
"""
Включает режим обучения. В личном чате для личной БЗ (или глобальной с флагом --global).
В групповом чате для комнатной БЗ (флаг --global игнорируется).
"""
_, flags, _ = self.parse_args(args)
is_global = 'global' in flags
title = flags.get('title')
# Проверка прав на глобальное обучение (только в личном чате)
if is_global and room_jid is None:
if user_jid not in self.bot.config.admin_jids:
reply = msg.reply("У вас нет прав на глобальное обучение.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить сообщение о недостатке прав на глобальное обучение")
return
if room_jid:
# В групповом чате режим обучения для комнаты (только ссылки, файлы и так автоматом)
mode = "room"
# Сохраняем режим с указанием комнаты
self.bot.learning_mode[user_jid] = (mode, room_jid)
if title:
reply = msg.reply(f"🎓 Режим обучения ВКЛЮЧЁН для КОМНАТНОЙ БЗ (--title \"{title}\"). При следующей ссылке будет использован заголовок.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить подтверждение обучения для комнаты с title")
else:
reply = msg.reply(f"🎓 Режим обучения ВКЛЮЧЁН для КОМНАТНОЙ БЗ. Теперь можно отправить ссылку она добавится в БЗ комнаты.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить подтверждение обучения для комнаты")
else:
# Личный чат
mode = "global" if is_global else "personal"
self.bot.learning_mode[user_jid] = (mode, None) # храним кортеж для единообразия
if title:
reply = msg.reply(f"🎓 Режим обучения ВКЛЮЧЁН для {'ГЛОБАЛЬНОЙ' if is_global else 'ЛИЧНОЙ'} БЗ (--title \"{title}\").")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить подтверждение обучения с title")
else:
reply = msg.reply(f"🎓 Режим обучения ВКЛЮЧЁН для {'ГЛОБАЛЬНОЙ' if is_global else 'ЛИЧНОЙ'} БЗ.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить подтверждение обучения")
def get_help(self) -> str:
return "Включает режим обучения (следующий файл/ссылка добавятся в БЗ). В личном чате личная/глобальная, в групповом комнатная. Флаги: --global (только личный), --title \"Название\"."
class StopLearnCommand(Command):
name = "!stop_learn"
aliases = ["/stop_learn", "/стоп", "!стоп", "!stop"]
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
"""Выключает режим обучения для пользователя (в любом контексте)."""
self.bot.learning_mode.pop(user_jid, None)
reply = msg.reply("✅ Режим обучения ВЫКЛЮЧЕН.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить подтверждение выключения обучения")
def get_help(self) -> str:
return "Выключает режим обучения."
class GlobalLearnCommand(Command):
name = "!global_learn"
aliases = ["/global_learn"]
permission = "admin"
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
"""
Включает глобальное обучение (только в личном чате, для админов).
В групповом чате команда не работает.
"""
if not self.check_permission(user_jid):
reply = msg.reply("⛔ Нет прав.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить сообщение о недостатке прав (learn)")
return
if room_jid:
reply = msg.reply("⚠️ Команда !global_learn работает только в личном чате.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить сообщение об ограничении !global_learn")
return
self.bot.learning_mode[user_jid] = ("global", None)
reply = msg.reply("🌐 Режим ГЛОБАЛЬНОГО обучения включён.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить подтверждение глобального обучения")
def get_help(self) -> str:
return "Включает режим глобального обучения (админ, только в личном чате)."

35
bots/commands/other.py Normal file
View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
"""
Прочие команды: !reset сброс истории диалога.
Сбрасывает историю в зависимости от контекста: в личном чате личную историю,
в групповом историю комнаты.
"""
import logging
from core.commands.base import Command
logger = logging.getLogger(__name__)
class ResetCommand(Command):
name = "!reset"
aliases = ["/reset", "!сброс", "/сброс"]
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
if room_jid:
await self.bot.db.clear_room_history(room_jid)
reply = msg.reply(f"🧹 История диалога комнаты {room_jid} сброшена.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить подтверждение сброса истории комнаты")
else:
await self.bot.db.clear_user_history(user_jid)
reply = msg.reply("🧹 Ваша личная история диалога сброшена.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить подтверждение сброса личной истории")
def get_help(self) -> str:
return "Сбрасывает историю диалога (личную или комнатную)."

63
bots/commands/registry.py Normal file
View File

@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""
Реестр команд связывает имя команды с объектом-обработчиком.
Поддерживает алиасы и подсказки при неизвестной команде.
"""
import difflib
from typing import Dict, List
# Импортируем все классы команд
from core.commands.info import InfoCommand
from core.commands.help import HelpCommand
from core.commands.learn import LearnCommand, StopLearnCommand, GlobalLearnCommand
from core.commands.kb import ClearCommand, GlobalClearCommand, KbCommand
from core.commands.stats import StatsCommand, StatusCommand, CleanCommand
from core.commands.expert import SummaryCommand, MetricsCommand
from core.commands.other import ResetCommand
from core.commands.global_remove import GlobalRemoveCommand
from core.commands.template import TemplateSaveCommand, TemplateListCommand, TemplateDeleteCommand
from core.commands.create import CreateCommand
def register_commands(bot) -> Dict[str, object]:
"""
Создаёт и возвращает словарь: имя_команды -> объект команды.
Включает основные имена и все алиасы.
"""
all_commands = [
InfoCommand(bot),
HelpCommand(bot),
LearnCommand(bot),
StopLearnCommand(bot),
GlobalLearnCommand(bot),
ClearCommand(bot),
GlobalClearCommand(bot),
KbCommand(bot),
StatsCommand(bot),
StatusCommand(bot),
CleanCommand(bot),
ResetCommand(bot),
SummaryCommand(bot),
MetricsCommand(bot),
GlobalRemoveCommand(bot),
TemplateSaveCommand(bot),
TemplateListCommand(bot),
TemplateDeleteCommand(bot),
CreateCommand(bot),
]
cmd_map = {}
for cmd in all_commands:
if cmd.name:
cmd_map[cmd.name.lower()] = cmd
for alias in cmd.aliases:
cmd_map[alias.lower()] = cmd
# Сохраняем список основных имён для подсказок (используется в message_handler)
bot._command_names = list(set(cmd.name for cmd in all_commands if cmd.name))
return cmd_map
def suggest_command(unknown_cmd: str, known_commands: List[str]) -> str:
"""Возвращает ближайшее известное имя команды (или None), используя difflib."""
matches = difflib.get_close_matches(unknown_cmd, known_commands, n=1, cutoff=0.6)
return matches[0] if matches else None

166
bots/commands/stats.py Normal file
View File

@@ -0,0 +1,166 @@
# -*- coding: utf-8 -*-
"""
Административные команды: !stats, !status, !clean.
- !stats статистика работы бота (сообщения, файлы, AI-запросы, ошибки, uptime).
- !status проверка состояния сервисов (GigaChat, PostgreSQL, Qdrant, диск).
- !clean очистка временной директории.
Изменения для PostgreSQL:
- Замена `conn.ping()` на `conn.execute("SELECT 1")` в команде !status.
- Обновлены тексты сообщений (MariaDB → PostgreSQL).
"""
import os
import shutil
import logging
from datetime import datetime
from core.commands.base import Command
logger = logging.getLogger(__name__)
class StatsCommand(Command):
"""Показывает статистику работы бота (только для администраторов)."""
name = "!stats"
aliases = ["/stats", "!статистика", "/статистика", "!стат", "/стат"]
permission = "admin"
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
"""
Выводит:
- uptime бота
- количество обработанных сообщений
- количество AI-запросов
- количество обработанных файлов
- количество ошибок
- количество документов в базе знаний (с учётом контекста)
"""
if not self.check_permission(user_jid):
reply = msg.reply("⛔ Нет прав.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить сообщение о недостатке прав (stats)")
return
# Время работы бота
uptime = datetime.now() - self.bot.start_time
# Получение метрик из MetricsHandler
stats = self.bot.metrics_handler.get()
# Количество доступных документов в БЗ (личные/комнатные + глобальные)
sources = await self.bot.kb.get_available_sources(user_jid, room_jid)
response = (
f"📊 **Статистика бота {self.bot.config.name}**\n"
f"⏱ Uptime: {str(uptime).split('.')[0]}\n"
f"💬 Сообщений: {stats.get('messages_total', 0)}\n"
f"🤖 AI запросов: {stats.get('ai_requests', 0)}\n"
f"📁 Файлов обработано: {stats.get('files_processed', 0)}\n"
f"❌ Ошибок: {stats.get('errors_total', 0)}\n"
f"📚 Документов в БЗ: {len(sources)}"
)
reply = msg.reply(response)
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить статистику")
def get_help(self) -> str:
return "Показывает статистику работы бота (админ)."
class StatusCommand(Command):
"""Проверяет состояние всех внешних сервисов (административная команда)."""
name = "!status"
aliases = ["/status", "!статус", "/статус"]
permission = "admin"
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
if not self.check_permission(user_jid):
reply = msg.reply("⛔ Нет прав.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить сообщение о недостатке прав (stats)")
return
# --- GigaChat ---
try:
# Простой запрос для проверки доступности API
await self.bot.giga.chat([], "ping", system_prompt=None, file_id=None, temperature=0.1)
ai_status = "✅ GigaChat Online"
except Exception as e:
ai_status = f"❌ GigaChat Offline: {e}"
# --- PostgreSQL (asyncpg) ---
try:
async with self.bot.db.pool.acquire() as conn:
# В asyncpg нет метода ping(), используем SELECT 1
await conn.execute("SELECT 1")
db_status = "✅ PostgreSQL Online"
except Exception as e:
db_status = f"❌ PostgreSQL Error: {e}"
# --- Qdrant ---
try:
self.bot.qdrant.client.get_collection(self.bot.qdrant.collection_name)
qd_status = "✅ Qdrant Online"
except Exception as e:
qd_status = f"❌ Qdrant Error: {e}"
# --- Дисковое пространство (временная директория) ---
total, used, free = shutil.disk_usage(self.bot.config.temp_dir)
disk_info = f"💾 Диск: {used // (2**20)}MB / {total // (2**20)}MB"
full_status = f"🛰 **СТАТУС БОТА**\n{ai_status}\n{db_status}\n{qd_status}\n{disk_info}"
reply = msg.reply(full_status)
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить статус")
def get_help(self) -> str:
return "Проверяет состояние сервисов (GigaChat, PostgreSQL, Qdrant, диск) админ."
class CleanCommand(Command):
"""Очищает временную директорию бота (temp_dir)."""
name = "!clean"
aliases = ["/clean", "!очистка", "/очистка"]
permission = "admin"
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
if not self.check_permission(user_jid):
reply = msg.reply("⛔ Нет прав.")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить сообщение о недостатке прав (stats)")
return
count = 0
temp_dir = self.bot.config.temp_dir
for f in os.listdir(temp_dir):
try:
p = os.path.join(temp_dir, f)
if os.path.isfile(p):
os.unlink(p)
elif os.path.isdir(p):
shutil.rmtree(p)
count += 1
except Exception:
pass
reply = msg.reply(f"🧹 Удалено объектов из temp_dir: {count}")
if reply:
reply.send() # исправлено
else:
logger.error("Не удалось отправить отчёт об очистке")
def get_help(self) -> str:
return "Очищает временную директорию бота (админ)."

132
bots/commands/template.py Normal file
View File

@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
import os
import hashlib
import shutil
import logging
from core.commands.base import Command
logger = logging.getLogger(__name__)
class TemplateSaveCommand(Command):
name = "!template_save"
aliases = ["/template_save"]
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
if not room_jid:
reply = msg.reply("⚠️ Команда !template_save работает только в комнатах.")
if reply:
reply.send()
return
if args.startswith(self.name):
args = args[len(self.name):].lstrip()
name = args.strip()
if name.startswith('"') and name.endswith('"'):
name = name[1:-1]
if not name:
reply = msg.reply("Укажите название шаблона: !template_save \"Название\"")
if reply:
reply.send()
return
key = (user_jid, room_jid) if room_jid is not None else user_jid
original_path = self.bot._last_file_path.get(key)
if not original_path or not os.path.exists(original_path):
reply = msg.reply("❌ Нет загруженного файла. Сначала отправьте DOCX-шаблон.")
if reply:
reply.send()
return
if not original_path.lower().endswith('.docx'):
reply = msg.reply("❌ Шаблон должен быть в формате .docx.")
if reply:
reply.send()
return
with open(original_path, 'rb') as f:
file_hash = hashlib.sha256(f.read()).hexdigest()
templates_dir = self.bot.config.data_dir / "templates"
templates_dir.mkdir(parents=True, exist_ok=True)
safe_room = room_jid.replace('/', '_').replace('@', '_')
dest_name = f"{safe_room}_{name}.docx"
dest_path = templates_dir / dest_name
shutil.copy2(original_path, dest_path)
await self.bot.db.save_template(room_jid, name, str(dest_path), file_hash)
reply = msg.reply(f"✅ Шаблон '{name}' сохранён.")
if reply:
reply.send()
def get_help(self) -> str:
return (
"Сохраняет текущий DOCX-файл как шаблон с указанным именем. "
"Использование: `!template_save \"Имя шаблона\"` (только в комнате)"
)
class TemplateListCommand(Command):
name = "!template_list"
aliases = ["/template_list"]
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
if not room_jid:
reply = msg.reply("⚠️ Команда !template_list работает только в комнатах.")
if reply:
reply.send()
return
if args.startswith(self.name):
args = args[len(self.name):].lstrip()
templates = await self.bot.db.list_templates(room_jid)
if not templates:
reply = msg.reply("📭 В этой комнате нет сохранённых шаблонов.")
else:
lines = ["📋 Шаблоны комнаты:"] + [f"{t}" for t in templates]
reply = msg.reply("\n".join(lines))
if reply:
reply.send()
def get_help(self) -> str:
return (
"Показывает список сохранённых шаблонов в текущей комнате. "
"Использование: `!template_list` (только в комнате)"
)
class TemplateDeleteCommand(Command):
name = "!template_delete"
aliases = ["/template_delete"]
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
if not room_jid:
reply = msg.reply("⚠️ Команда !template_delete работает только в комнатах.")
if reply:
reply.send()
return
if args.startswith(self.name):
args = args[len(self.name):].lstrip()
name = args.strip()
if name.startswith('"') and name.endswith('"'):
name = name[1:-1]
if not name:
reply = msg.reply("Укажите название шаблона: !template_delete \"Название\"")
if reply:
reply.send()
return
if await self.bot.db.delete_template(room_jid, name):
reply = msg.reply(f"🗑 Шаблон '{name}' удалён.")
else:
reply = msg.reply(f"❌ Шаблон '{name}' не найден.")
if reply:
reply.send()
def get_help(self) -> str:
return (
"Удаляет шаблон по имени. "
"Использование: `!template_delete \"Имя шаблона\"` (только в комнате)"
)

11
bots/handlers/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
"""
Пакет handlers обработчики XMPP-событий для тонких XMPP-клиентов.
Содержит:
- MetricsHandler счётчики статистики,
- MessageHandler маршрутизация входящих сообщений,
- FileHandler скачивание файлов,
- RoomHandler управление MUC-комнатами,
- SubscriptionHandler подписки в ростере,
- HealthHandler периодические задачи (очистка, healthcheck).
"""

View File

@@ -0,0 +1,241 @@
# -*- coding: utf-8 -*-
"""
Обработчик входящих XMPP сообщений (личных и групповых).
Использует RAGClient для отправки запросов на RAG-сервер.
ИСТОРИЯ НЕ ПЕРЕДАЁТСЯ сервер хранит её самостоятельно.
"""
import logging
import re
import asyncio
from core.utils.layout_converter import convert_layout
from core.commands.registry import suggest_command
logger = logging.getLogger(__name__)
class MessageHandler:
"""
Хендлер, привязанный к Bot. Вызывается при событии "message".
"""
def __init__(self, bot):
self.bot = bot
async def _wait_for_queue(self):
"""Ожидает создания очереди индексации (защита от гонки)."""
while self.bot.indexing_queue is None:
logger.debug("Очередь индексации ещё не создана, ожидание...")
await asyncio.sleep(0.5)
async def handle(self, msg):
"""
Основная точка входа для всех сообщений.
"""
# Определяем тип: личный чат (chat/normal) или групповой (groupchat)
if msg['type'] not in ('chat', 'normal', 'groupchat'):
return
user_jid = msg['from'].bare
# Не обрабатываем сообщения от самого бота
if msg['type'] == 'groupchat':
if msg['from'].resource == self.bot.boundjid.user:
return
else:
if msg['from'] == self.bot.boundjid:
return
# Извлекаем room_jid для групповых чатов (None для личных)
room_jid = msg['from'].bare if msg['type'] == 'groupchat' else None
# Проверка OMEMO (сквозное шифрование не поддерживается)
if self.bot.is_message_encrypted(msg):
reply = msg.reply(
"🔒 Я получил зашифрованное сообщение (OMEMO), но не могу его расшифровать. "
"Пожалуйста, отключите сквозное шифрование."
)
if reply:
reply.send()
return
# Извлечение тела сообщения и URL файла (из OOB)
raw_body = msg['body'].strip() if msg['body'] else ""
file_url = msg['oob']['url'] if msg['oob'] else None
# Если OOB отсутствует, пытаемся извлечь URL из тела сообщения
if not file_url and raw_body:
url_match = re.search(r'https?://[^\s<>"\'\)\]]+', raw_body)
if url_match:
file_url = url_match.group(0)
logger.info(f"Извлечена ссылка из тела сообщения (fallback OOB): {file_url}")
if not raw_body and not file_url:
return
# Для групповых чатов проверяем, упомянут ли бот
if msg['type'] == 'groupchat':
is_command = raw_body.startswith(('!', '/'))
has_file = file_url is not None
if not (is_command or has_file):
if not self._is_mentioned(msg, raw_body):
return
# Исправление раскладки клавиатуры (русская ↔ английская)
body = convert_layout(raw_body)
if body != raw_body:
reply = msg.reply(f"🔄 Исправлено: {body}")
if reply:
reply.send()
# Метрика: инкремент счётчика сообщений
self.bot.metrics_handler.increment("messages_total")
logger.info(f"Сообщение от {user_jid} (room={room_jid}): {body[:50]}...")
# ---- ИСТОРИЯ БОЛЬШЕ НЕ СОХРАНЯЕТСЯ ЛОКАЛЬНО ----
# Вся история хранится на сервере. Клиент не занимается этим.
# Обработка команд (начинаются с ! или /)
if body.startswith(('!', '/')):
await self._handle_command(msg, body, user_jid, room_jid)
return
# Режим обучения (текст без файла) только в личных чатах
mode_info = self.bot.learning_mode.get(user_jid)
if mode_info and not file_url and room_jid is None:
if isinstance(mode_info, tuple):
mode, saved_room = mode_info
else:
mode = mode_info
saved_room = None
await self._handle_learning_text(msg, user_jid, mode, body, room_jid)
return
# Обработка файла (если есть URL)
is_command = body.startswith(('!', '/'))
is_surgical_keyword = any(kw in body.lower() for kw in self.bot.config.surgical_keywords)
if file_url and not (is_command or is_surgical_keyword):
await self.bot.file_handler.handle_file(msg, file_url, user_jid, room_jid)
return
# ---- RAG-ЗАПРОС (БЕЗ ИСТОРИИ) ----
# История НЕ ПЕРЕДАЁТСЯ в запросе. Сервер сам её получит из БД.
key = (user_jid, room_jid) if room_jid is not None else user_jid
result = await self.bot.rag_client.query(
query=body,
user_jid=user_jid,
room_jid=room_jid,
prompts=self.bot.prompts,
last_file_path=self.bot._last_file_path.get(key),
last_file_text=self.bot.last_file_texts.get(user_jid)
)
answer = result.get('answer', '⚠️ Не удалось получить ответ.')
# Если в ответе есть файловые теги, обрабатываем их
if self.bot.config.surgical_replace and any(tag in answer for tag in ("[SURGICAL_REPLACE]", "[REWRITE]", "[FILE]")):
await self.bot.handle_ai_file_request(msg, answer, user_jid, room_jid, body=body)
else:
reply = msg.reply(answer)
if reply:
reply.send()
else:
logger.error("Не удалось отправить ответ RAG (без тегов)")
# ---- ИСТОРИЯ БОЛЬШЕ НЕ СОХРАНЯЕТСЯ ЛОКАЛЬНО ----
# await self.bot.db.add_history(...) # УБРАНО!
# ==========================================================
# Вспомогательные методы
# ==========================================================
def _is_mentioned(self, msg, body: str) -> bool:
"""
Проверяет, упомянут ли бот в групповом чате.
"""
my_nick = self.bot.plugin['xep_0045'].our_nicks.get(msg['from'].bare, self.bot.boundjid.user)
mention_jid = self.bot.boundjid.user.lower()
mention_word = self.bot.config.mention_keyword.lower()
return (mention_jid in body.lower() or
my_nick.lower() in body.lower() or
mention_word in body.lower()) or body.startswith(('!', '/'))
async def _handle_command(self, msg, body: str, user_jid: str, room_jid: str):
"""Обрабатывает команды (начинаются с ! или /)."""
cmd_name = body.split()[0].lower()
if cmd_name in self.bot.commands:
await self.bot.commands[cmd_name].execute(msg, body, user_jid, room_jid)
else:
suggestion = suggest_command(cmd_name, self.bot._command_names)
if suggestion:
reply = msg.reply(
f"❓ Неизвестная команда. Может быть, вы имели в виду `{suggestion}`? "
f"Напишите `!help` для списка команд."
)
if reply:
reply.send()
else:
reply = msg.reply("❓ Неизвестная команда. Напишите `!help` для списка команд.")
if reply:
reply.send()
async def _handle_learning_text(self, msg, user_jid: str, mode: str, body: str, room_jid: str):
"""
Обрабатывает текстовые сообщения в режиме обучения (только в личных чатах).
Поддерживает ссылки (URL) с флагами --recursive, --depth, --max.
"""
is_global = (mode == 'global')
title = None
# Парсим --title "Название" (если есть)
match = re.search(r'--title "([^"]+)"', body)
if match:
title = match.group(1)
body = re.sub(r'--title "[^"]+"', '', body).strip()
# Проверяем, является ли тело ссылкой
if body.startswith(('http://', 'https://')):
parts = body.split(maxsplit=1)
url = parts[0]
flags_str = parts[1] if len(parts) > 1 else ""
recursive = '--recursive' in flags_str or '-r' in flags_str
depth = 1
max_pages = self.bot.config.ws_max_pages
depth_match = re.search(r'--depth\s+(\d+)', flags_str)
if depth_match:
depth = int(depth_match.group(1))
max_match = re.search(r'--max\s+(\d+)', flags_str)
if max_match:
max_pages = int(max_match.group(1))
target_room = None
if mode == 'room' and room_jid is not None:
target_room = room_jid
# Создаём задачу для фонового индексатора
task = {
"type": "url",
"user_jid": user_jid,
"room_jid": target_room,
"url": url,
"title": title,
"is_global": is_global,
"recursive": recursive,
"depth": depth,
"max_pages": max_pages,
}
await self._wait_for_queue()
await self.bot.indexing_queue.put(task)
scope = "глобальную" if is_global else ("комнатную" if target_room else "личную")
reply = msg.reply(f"🌐 Ссылка '{url}' принята в обработку для {scope} БЗ.")
if reply:
reply.send()
return
# Если не ссылка сообщаем, что текст не индексируется
reply = msg.reply("В режиме обучения можно отправлять файлы или ссылки. Текст не индексируется.")
if reply:
reply.send()

6
bots/workers/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
"""
Пакет workers фоновые асинхронные воркеры.
Содержит:
- IndexingWorker индексация файлов и URL через HTTP-запросы к RAG-серверу.
"""

View File

@@ -0,0 +1,324 @@
# -*- coding: utf-8 -*-
"""
Фоновый воркер (worker) для индексации файлов и URL через RAG-сервер.
Читает задачи из asyncio.Queue и выполняет:
- Для URL: скачивание, извлечение текста, индексация через HTTP-запрос к серверу.
- Для файлов: извлечение текста, обработка изображений/аудио, индексация через HTTP-запрос.
Все операции с БД и Qdrant выполняются на сервере. Воркер только отправляет данные.
"""
import asyncio
import logging
import os
import hashlib
import shutil
from datetime import datetime
logger = logging.getLogger(__name__)
class IndexingWorker:
"""
Фоновый воркер для индексации.
Получает задачи из очереди и выполняет их, отправляя данные на RAG-сервер.
"""
def __init__(self, bot):
"""
Аргументы:
bot: экземпляр Bot (содержит rag_client, config, file_handler и состояния)
"""
self.bot = bot
logger.info("IndexingWorker инициализирован (режим: HTTP-клиент RAG-сервера)")
async def run(self):
"""Бесконечный цикл обработки задач из очереди."""
while True:
task = await self.bot.indexing_queue.get()
try:
if task.get("type") == "url":
await self._process_url(task)
else:
await self._process_file(task)
except Exception as e:
logger.exception(f"Ошибка в воркере при обработке задачи: {e}")
self.bot.metrics_handler.increment("errors_total")
finally:
self.bot.indexing_queue.task_done()
# ==========================================================
# Обработка URL
# ==========================================================
async def _process_url(self, task):
"""
Обрабатывает URL-задачу.
- Одиночный URL: загружает страницу/PDF, извлекает текст, индексирует через сервер.
- Рекурсивный обход: через crawl_url, затем индексирует каждую страницу.
"""
from core.utils.web_utils import fetch_any_url, crawl_url
url = task["url"]
user_jid = task["user_jid"]
room_jid = task.get("room_jid")
custom_title = task.get("title")
is_global = task.get("is_global", False)
recursive = task.get("recursive", False)
depth = task.get("depth", 1)
max_pages = task.get("max_pages", self.bot.config.ws_max_pages)
try:
if recursive:
pages = await crawl_url(url, max_depth=depth, max_pages=max_pages, domain_restrict=True)
if not pages:
await self._send_message(
user_jid,
f"Не удалось загрузить ни одной страницы с {url}",
room_jid
)
return
total = 0
for page_url, page_title, page_text in pages:
if page_text:
doc_title = custom_title if custom_title else (page_title if page_title else page_url)
# Отправляем на сервер для индексации
result = await self.bot.rag_client.index_document(
file_name=page_url,
file_text=page_text,
user_jid=user_jid,
room_jid=room_jid,
is_global=is_global,
title=doc_title,
metadata={"url": page_url}
)
if result.get('doc_id'):
total += 1
else:
logger.error(f"Ошибка индексации {page_url}: {result.get('error')}")
await self._send_message(
user_jid,
f"✅ Проиндексировано {total} страниц (рекурсивно).",
room_jid
)
else:
title, text = await fetch_any_url(url)
if text:
doc_title = custom_title if custom_title else (title if title else url)
result = await self.bot.rag_client.index_document(
file_name=url,
file_text=text,
user_jid=user_jid,
room_jid=room_jid,
is_global=is_global,
title=doc_title,
metadata={"url": url}
)
if result.get('doc_id'):
scope = "глобальную" if is_global else ("комнату" if room_jid else "личную")
await self._send_message(
user_jid,
f"✅ Страница '{doc_title}' добавлена в {scope} БЗ.",
room_jid
)
else:
await self._send_message(
user_jid,
f"❌ Ошибка индексации {url}: {result.get('error')}",
room_jid
)
else:
await self._send_message(
user_jid,
f"Не удалось загрузить или извлечь текст из {url}",
room_jid
)
except Exception as e:
logger.exception(f"Ошибка индексации URL {url}: {e}")
await self._send_message(
user_jid,
f"❌ Ошибка при обработке URL: {e}",
room_jid
)
# ==========================================================
# Обработка файлов
# ==========================================================
async def _process_file(self, task):
"""
Обрабатывает задачу с файлом:
1. Извлекает текст (синхронно в потоке).
2. Обрабатывает изображения/аудио (Vision/распознавание) если есть.
3. Сохраняет постоянную копию в data/ (локально, для истории и отладки).
4. Отправляет текст и метаданные на RAG-сервер для индексации.
5. Удаляет временный файл.
"""
user_jid = task["user_jid"]
room_jid = task.get("room_jid")
file_path = task["file_path"]
original_fname = task["original_fname"]
title = task.get("title")
is_global = task.get("is_global", False)
mode = task.get("mode")
ext = task.get("ext", "").lower()
try:
# --- 1. Извлечение текста (синхронная операция в потоке) ---
result = await asyncio.to_thread(self.bot.files.process_any_file, file_path)
if isinstance(result, tuple) and len(result) == 2:
first, second = result
if isinstance(first, list):
files_list = first
total_count = second
else:
files_list = [(original_fname, first)] if first else []
total_count = second
else:
files_list = [(original_fname, result)] if result else []
total_count = 1 if result else 0
if total_count == 0:
await self._send_message(
user_jid,
f"⚠️ Не удалось извлечь текст из {original_fname}.",
room_jid
)
return
# Сохраняем текст для локального использования (например, !summary)
full_text = "\n\n".join([t for _, t in files_list if t])
self.bot.last_file_texts[user_jid] = full_text
# --- 2. Сохранение постоянной копии в data/ (локально) ---
# Это нужно для команд !summary и хирургической замены, которые работают с файлом локально.
# В будущем можно перенести и их на сервер, но пока оставляем.
with open(file_path, 'rb') as f:
file_hash = hashlib.sha256(f.read()).hexdigest()
short_hash = file_hash[:12]
safe_user = user_jid.replace('@', '_at_').replace('/', '_')
safe_fname = original_fname.replace('/', '_').replace('\\', '_')
dest_filename = f"user_{short_hash}_{safe_user}_{safe_fname}"
dest_path = self.bot.config.data_dir / dest_filename
if not dest_path.exists():
shutil.copy2(file_path, dest_path)
logger.debug(f"Создана постоянная копия: {dest_path}")
else:
logger.debug(f"Постоянная копия уже существует: {dest_path}")
key = (user_jid, room_jid) if room_jid is not None else user_jid
self.bot._last_file_path[key] = str(dest_path)
# --- 3. Специальная обработка: изображения (Vision API) ---
# Если это изображение и включена опция vision, отправляем на анализ.
# Это можно делать отдельно, не блокируя индексацию.
if ext in ('.jpg', '.jpeg', '.png', '.bmp', '.tiff') and self.bot.config.vision:
file_id = await self.bot.giga.upload_file(file_path) # giga всё ещё локально? нужно будет перенести и это.
# Это временно, пока мы не вынесли всё на сервер. Пока оставим как есть.
if file_id:
answer = await self.bot.giga.chat(
history=[],
query="Проанализируй это изображение. Если есть текст прочитай его.",
system_prompt=None,
file_id=file_id,
temperature=self.bot.config.ai_temperature
)
await self._send_message(user_jid, answer, room_jid)
else:
await self._send_message(user_jid, "⚠️ Не удалось передать изображение в GigaChat.", room_jid)
# После обработки изображения мы не индексируем его как документ (обычно OCR делает это),
# но можно и проиндексировать текст. Здесь мы просто возвращаемся.
return
# --- 4. Специальная обработка: аудио (голос → текст) ---
# Аналогично, используем локальный SaluteSpeech, пока не вынесли.
if ext in ('.ogg', '.wav', '.mp3', '.amr', '.m4a') and self.bot.config.voice_recognition:
first_text = files_list[0][1] if files_list else ""
if "--- РАСПОЗНАННЫЙ ГОЛОС ---" in first_text:
voice_text = first_text.split("--- РАСПОЗНАННЫЙ ГОЛОС ---")[1].strip()
await self._send_message(user_jid, f"🎤 Распознано: {voice_text}", room_jid)
# Можно также ответить на голос, но это отдельный сценарий.
# Мы не индексируем аудио как документ (разве что текст распознавания).
# Если хотим индексировать текст голоса, нужно передать его как документ.
# Например, можно создать виртуальный документ с текстом.
else:
await self._send_message(user_jid, f"⚠️ {first_text}", room_jid)
return
# --- 5. Индексация документов через RAG-сервер ---
# Отправляем на сервер каждый извлечённый файл (включая вложенные из архивов)
if mode in ('personal', 'global') or (mode == 'room' and room_jid):
saved = 0
for sub_name, sub_text in files_list:
if sub_text:
result = await self.bot.rag_client.index_document(
file_name=sub_name,
file_text=sub_text,
user_jid=user_jid,
room_jid=room_jid,
is_global=is_global,
title=title,
# file_hash=file_hash, # можно передать, если нужно
update_if_exists=True
)
if result.get('doc_id'):
saved += 1
else:
logger.error(f"Ошибка индексации {sub_name}: {result.get('error')}")
if saved:
if room_jid:
scope = f"комнату {room_jid}"
elif is_global:
scope = "глобальную"
else:
scope = "личную"
await self._send_message(
user_jid,
f"🎓 {saved} файлов добавлено в {scope} БЗ.",
room_jid
)
else:
await self._send_message(
user_jid,
f"⚠️ Не удалось проиндексировать {original_fname}. Проверьте логи.",
room_jid
)
else:
# Если режим обучения не включён (или не совпадает), просто показываем текст
preview = full_text[:500] + ("..." if len(full_text) > 500 else "")
await self._send_message(
user_jid,
f"📄 Файл `{original_fname}` прочитан.\n\n{preview}",
room_jid
)
except Exception as e:
logger.exception(f"Ошибка при обработке файла {original_fname}: {e}")
self.bot.metrics_handler.increment("errors_total")
await self._send_message(
user_jid,
f"❌ Ошибка при обработке файла '{original_fname}'. Проверьте логи.",
room_jid
)
finally:
# Удаляем временный файл (постоянная копия уже сохранена)
try:
if os.path.exists(file_path):
os.unlink(file_path)
logger.debug(f"Временный файл удалён: {file_path}")
except Exception as e:
logger.error(f"Не удалось удалить временный файл {file_path}: {e}")
# ==========================================================
# Вспомогательные методы
# ==========================================================
async def _send_message(self, user_jid: str, body: str, room_jid: str = None):
"""
Отправляет сообщение пользователю (в личный чат или комнату).
Используется для уведомлений о результатах индексации.
"""
if room_jid:
self.bot.send_message(mto=room_jid, mbody=body, mtype='groupchat')
else:
self.bot.send_message(mto=user_jid, mbody=body, mtype='chat')

7
bots/xmpp/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
"""
Пакет xmpp XMPP-клиент для тонких ботов-профилей.
Содержит класс Bot, наследующий slixmpp.ClientXMPP.
Бот подключается к ejabberd, обрабатывает команды и файлы,
и отправляет RAG-запросы через HTTP-клиент (RAGClient) в RAG-сервер.
"""

180
bots/xmpp/client.py Normal file
View File

@@ -0,0 +1,180 @@
# -*- coding: utf-8 -*-
"""
XMPP клиент главный класс Bot.
Теперь это тонкий клиент, который только общается с RAG-сервером.
Не содержит БД, Qdrant, GigaChat всё на сервере.
"""
import asyncio
import logging
from datetime import datetime
from slixmpp import ClientXMPP
from core.utils.config_loader import BotConfig
from core.services.file_service import FileService
from core.handlers.metrics_handler import MetricsHandler
from core.rag_client import RAGClient
from core.commands.registry import register_commands
from core.handlers.message_handler import MessageHandler
from core.handlers.file_handler import FileHandler
from core.handlers.room_handler import RoomHandler
from core.handlers.subscription_handler import SubscriptionHandler
from core.handlers.health_handler import HealthHandler
from core.workers.indexing_worker import IndexingWorker
from core.functions.file_processor import handle_ai_file_request as _handle_ai_file_request
logger = logging.getLogger(__name__)
class Bot(ClientXMPP):
"""
Основной класс XMPP бота.
Тонкий клиент: вся RAG-логика на сервере.
"""
def __init__(self, config: BotConfig):
# Полный JID: user@domain/resource
full_jid = f"{config.jid}/{config.xmpp_resource}"
super().__init__(full_jid, config.xmpp_password)
self.config = config
# ----- 1. Локальные сервисы (только для файлов и метрик) -----
# FileService нужен для извлечения текста из файлов перед отправкой на сервер
self.files = FileService(config)
# Метрики (счётчики сообщений, ошибок и т.д.)
self.metrics_handler = MetricsHandler()
# ----- 2. Загрузка промптов (остаются локально) -----
# Промпты загружаются из файлов и передаются в каждом запросе к серверу.
self.ai_prompts = {}
prompt_files = {
'system': config.system_prompt_file,
'synthesis': config.synthesis_prompt_file,
'quality': config.quality_prompt_file,
'rerank': config.rerank_prompt_file,
'expand': config.expand_prompt_file,
'intent': config.intent_prompt_file,
'metrics': config.metrics_prompt_file,
'summary': config.summary_prompt_file,
'consistency': config.consistency_prompt_file,
'critique': config.critique_prompt_file,
'spellcheck': config.spellcheck_prompt_file,
'generate_document': config.generate_document_prompt_file,
}
for name, path in prompt_files.items():
try:
with open(path, 'r', encoding='utf-8') as f:
self.ai_prompts[name] = f.read()
logger.debug(f"Загружен промпт {name} из {path}")
except Exception as e:
logger.error(f"Не удалось загрузить промпт {name} из {path}: {e}")
self.ai_prompts[name] = ""
# Сохраняем промпты как атрибут для передачи в запросах
self.prompts = self.ai_prompts
# ----- 3. HTTP-клиент для RAG-сервера -----
# Адрес сервера берём из конфига (bot.conf) или используем значение по умолчанию
rag_server_url = getattr(config, 'rag_server_url', 'http://localhost:8080')
self.rag_client = RAGClient(server_url=rag_server_url)
# ----- 4. Команды и состояния -----
# Регистрируем все команды бота (из core/commands/)
self.commands = register_commands(self)
self._command_names = [cmd.name for cmd in self.commands.values() if cmd.name]
# Временные состояния (хранятся в памяти)
self.learning_mode = {} # user_jid -> (mode, room_jid)
self.last_file_texts = {} # user_jid -> текст последнего файла
self._last_file_path = {} # key -> путь к последнему файлу
self.indexing_queue = None # очередь для фоновой индексации
self.rooms = [] # комнаты, в которых бот состоит (загружается позже)
# ----- 5. Хендлеры -----
self.message_handler = MessageHandler(self)
self.file_handler = FileHandler(self)
self.room_handler = RoomHandler(self)
self.subscription_handler = SubscriptionHandler(self)
self.health_handler = HealthHandler(self)
self.indexing_worker = IndexingWorker(self)
# ----- 6. XMPP плагины -----
self.register_plugin('xep_0030') # Service Discovery
self.register_plugin('xep_0199') # Ping
self.register_plugin('xep_0045') # Multi-User Chat
self.register_plugin('xep_0054') # VCard
self.register_plugin('xep_0172') # User Nickname
self.register_plugin('xep_0066') # Out of Band Data
self.register_plugin('xep_0363') # HTTP File Upload
# ----- 7. Обработчики событий slixmpp -----
self.roster_ready = asyncio.Event()
self.add_event_handler("session_start", self.session_start)
self.add_event_handler("message", self.message_handler.handle)
self.add_event_handler("groupchat_invite", self.room_handler.accept_invite)
self.add_event_handler("roster_subscription_request", self.subscription_handler.handle_subscription)
self.add_event_handler('roster_update', self._on_roster_ready)
self.add_event_handler("muc::kicked", self.room_handler.on_kicked)
self.add_event_handler("muc::banned", self.room_handler.on_banned)
# Время старта бота (для команды !stats)
self.start_time = datetime.now()
def _on_roster_ready(self, *args):
"""Вызывается, когда ростер загружен."""
self.roster_ready.set()
def is_message_encrypted(self, msg) -> bool:
"""Проверяет, зашифровано ли сообщение (OMEMO). Заглушка."""
return False
async def handle_ai_file_request(self, msg, ai_response: str, user_jid: str, room_jid: str = None, body: str = None):
"""
Обрабатывает теги [SURGICAL_REPLACE], [REWRITE], [FILE] из ответа AI.
"""
await _handle_ai_file_request(self, msg, ai_response, user_jid, room_jid, body=body)
async def session_start(self, event):
"""
Обработчик успешного подключения и авторизации.
"""
# Устанавливаем статус присутствия
self.send_presence(pshow='chat', pstatus=self.config.status_message)
self.get_roster()
logger.info(f"Бот {self.config.name} запущен, JID: {self.boundjid}")
# Ждём загрузки ростера (максимум 10 секунд)
try:
await asyncio.wait_for(self.roster_ready.wait(), timeout=10.0)
except asyncio.TimeoutError:
logger.warning("Таймаут ожидания ростера")
# Отправляем запросы на подписку администраторам
for admin_jid in self.config.admin_jids:
if admin_jid in self.roster:
item = self.roster[admin_jid]
if item and item.get('subscription') in ('both', 'from'):
continue
self.send_presence(pto=admin_jid, ptype='subscribe')
logger.info(f"Отправлен запрос подписки для администратора {admin_jid}")
# Создаём очередь индексации и запускаем фоновый воркер
self.indexing_queue = asyncio.Queue()
asyncio.create_task(self.indexing_worker.run())
# Загружаем комнаты из конфига или БД. Пока оставляем пустым.
# Бот будет входить в комнаты только по приглашениям.
self.rooms = []
# Входим в комнаты (если есть)
for room in self.rooms:
try:
await self.plugin['xep_0045'].join_muc(room, self.boundjid.user)
logger.info(f"Вошли в комнату {room}")
except Exception as e:
logger.error(f"Ошибка входа в комнату {room}: {e}")
# Запускаем фоновые задачи:
# - Переподключение к комнатам
# - Очистка временных файлов
asyncio.create_task(self.room_handler.rejoin_loop())
asyncio.create_task(self.health_handler.cleanup_loop())