Добавлены методы для работы с настройками авто-модерации, включая получение и установку значений, а также переключение состояний авто-публикации и авто-отклонения. Обновлены соответствующие репозитории и обработчики для интеграции новых функций в админ-панели.
Some checks are pending
CI pipeline / Test & Code Quality (push) Waiting to run

This commit is contained in:
2026-02-28 22:21:29 +03:00
parent b3cdadfd8e
commit 31314c9c9b
12 changed files with 1388 additions and 5 deletions

View File

@@ -31,7 +31,13 @@ from helper_bot.utils.metrics import db_query_time, track_errors, track_time
# Local imports - modular components
from .constants import BUTTON_TEXTS, ERROR_MESSAGES, FSM_STATES
from .decorators import error_handler
from .services import BotSettings, PostService, StickerService, UserService
from .services import (
AutoModerationService,
BotSettings,
PostService,
StickerService,
UserService,
)
# Expose sleep for tests (tests patch helper_bot.handlers.private.private_handlers.sleep)
sleep = asyncio.sleep
@@ -50,7 +56,12 @@ class PrivateHandlers:
self.db = db
self.settings = settings
self.user_service = UserService(db, settings)
self.post_service = PostService(db, settings, s3_storage, scoring_manager)
self.auto_moderation_service = AutoModerationService(
db, settings, scoring_manager, s3_storage
)
self.post_service = PostService(
db, settings, s3_storage, scoring_manager, self.auto_moderation_service
)
self.sticker_service = StickerService(settings)
self.router = Router()

View File

@@ -22,6 +22,7 @@ from helper_bot.utils.helper_func import (
check_username_and_full_name,
determine_anonymity,
get_first_name,
get_publish_text,
get_text_message,
prepare_media_group_from_middlewares,
send_audio_message,
@@ -252,11 +253,13 @@ class PostService:
settings: BotSettings,
s3_storage=None,
scoring_manager=None,
auto_moderation_service: "AutoModerationService" = None,
) -> None:
self.db = db
self.settings = settings
self.s3_storage = s3_storage
self.scoring_manager = scoring_manager
self.auto_moderation = auto_moderation_service
async def _save_media_background(
self, sent_message: types.Message, bot_db: Any, s3_storage
@@ -379,6 +382,200 @@ class PostService:
error_message = "Не удалось рассчитать скоры"
return None, None, None, None, None, error_message
@track_time("_handle_auto_action", "post_service")
@track_errors("post_service", "_handle_auto_action")
async def _handle_auto_action(
self,
auto_action: str,
message: types.Message,
content_type: str,
original_raw_text: str,
first_name: str,
is_anonymous: bool,
rag_score: float,
ml_scores_json: str = None,
album: Union[list, None] = None,
) -> None:
"""
Обрабатывает автоматическое действие (публикация или отклонение).
Args:
auto_action: 'publish' или 'decline'
message: Сообщение пользователя
content_type: Тип контента
original_raw_text: Оригинальный текст поста
first_name: Имя автора
is_anonymous: Флаг анонимности
rag_score: Скор RAG модели
ml_scores_json: JSON со скорами для БД
album: Медиагруппа (если есть)
"""
author_id = message.from_user.id
author_name = message.from_user.full_name or first_name
author_username = message.from_user.username or ""
try:
if auto_action == "publish":
await self._auto_publish(
message=message,
content_type=content_type,
original_raw_text=original_raw_text,
first_name=first_name,
is_anonymous=is_anonymous,
rag_score=rag_score,
ml_scores_json=ml_scores_json,
album=album,
)
else: # decline
await self._auto_decline(message=message, author_id=author_id)
# Логируем действие
if self.auto_moderation:
await self.auto_moderation.log_auto_action(
bot=message.bot,
action=auto_action,
author_id=author_id,
author_name=author_name,
author_username=author_username,
rag_score=rag_score,
post_text=original_raw_text,
)
except Exception as e:
logger.error(
f"PostService: Ошибка авто-{auto_action} для message_id={message.message_id}: {e}"
)
raise
@track_time("_auto_publish", "post_service")
@track_errors("post_service", "_auto_publish")
async def _auto_publish(
self,
message: types.Message,
content_type: str,
original_raw_text: str,
first_name: str,
is_anonymous: bool,
rag_score: float,
ml_scores_json: str = None,
album: Union[list, None] = None,
) -> None:
"""Автоматически публикует пост в канал."""
author_id = message.from_user.id
username = message.from_user.username
# Формируем текст для публикации (без скоров и разметки)
formatted_text = get_publish_text(
original_raw_text, first_name, username, is_anonymous
)
sent_message = None
# Публикуем в зависимости от типа контента
if content_type == "text":
sent_message = await message.bot.send_message(
chat_id=self.settings.main_public,
text=formatted_text,
)
elif content_type == "photo":
sent_message = await message.bot.send_photo(
chat_id=self.settings.main_public,
photo=message.photo[-1].file_id,
caption=formatted_text,
)
elif content_type == "video":
sent_message = await message.bot.send_video(
chat_id=self.settings.main_public,
video=message.video.file_id,
caption=formatted_text,
)
elif content_type == "audio":
sent_message = await message.bot.send_audio(
chat_id=self.settings.main_public,
audio=message.audio.file_id,
caption=formatted_text,
)
elif content_type == "voice":
sent_message = await message.bot.send_voice(
chat_id=self.settings.main_public,
voice=message.voice.file_id,
)
elif content_type == "video_note":
sent_message = await message.bot.send_video_note(
chat_id=self.settings.main_public,
video_note=message.video_note.file_id,
)
elif content_type == "media_group" and album:
# TODO: Реализовать авто-публикацию медиагрупп при необходимости
logger.warning(
"PostService: Авто-публикация медиагрупп пока не поддерживается"
)
return
if sent_message:
# Сохраняем пост в БД со статусом approved
post = TelegramPost(
message_id=sent_message.message_id,
text=original_raw_text,
author_id=author_id,
created_at=int(datetime.now().timestamp()),
is_anonymous=is_anonymous,
status="approved",
)
await self.db.add_post(post)
# Сохраняем скоры если есть
if ml_scores_json:
asyncio.create_task(
self._save_scores_background(sent_message.message_id, ml_scores_json)
)
# Индексируем пост в RAG
if self.scoring_manager and original_raw_text and original_raw_text.strip():
asyncio.create_task(
self._add_submitted_post_background(
original_raw_text, sent_message.message_id, rag_score
)
)
# Уведомляем автора
try:
await message.bot.send_message(
chat_id=author_id,
text="Твой пост был выложен🥰",
)
except Exception as e:
logger.warning(f"PostService: Не удалось уведомить автора {author_id}: {e}")
logger.info(
f"PostService: Пост авто-опубликован в {self.settings.main_public}, "
f"author_id={author_id}, rag_score={rag_score:.2f}"
)
@track_time("_auto_decline", "post_service")
@track_errors("post_service", "_auto_decline")
async def _auto_decline(self, message: types.Message, author_id: int) -> None:
"""Автоматически отклоняет пост."""
# Обучаем RAG на отклоненном посте
if self.scoring_manager:
original_text = message.text or message.caption or ""
if original_text and original_text.strip():
try:
await self.scoring_manager.on_post_declined(original_text)
except Exception as e:
logger.warning(f"PostService: Ошибка обучения RAG на отклоненном посте: {e}")
# Уведомляем автора
try:
await message.bot.send_message(
chat_id=author_id,
text="Твой пост был отклонен😔",
)
except Exception as e:
logger.warning(f"PostService: Не удалось уведомить автора {author_id}: {e}")
logger.info(f"PostService: Пост авто-отклонен, author_id={author_id}")
@track_time("_process_post_background", "post_service")
@track_errors("post_service", "_process_post_background")
async def _process_post_background(
@@ -485,6 +682,33 @@ class PostService:
# Определяем анонимность по исходному тексту (без сообщения об ошибке)
is_anonymous = determine_anonymity(original_raw_text)
# Проверяем авто-модерацию
logger.debug(
f"PostService: Проверка авто-модерации - "
f"auto_moderation={self.auto_moderation is not None}, "
f"rag_score={rag_score}"
)
if self.auto_moderation and rag_score is not None:
auto_action = await self.auto_moderation.check_auto_action(rag_score)
logger.info(
f"PostService: Авто-модерация решение - "
f"rag_score={rag_score:.2f}, action={auto_action}"
)
if auto_action in ("publish", "decline"):
await self._handle_auto_action(
auto_action=auto_action,
message=message,
content_type=content_type,
original_raw_text=original_raw_text,
first_name=first_name,
is_anonymous=is_anonymous,
rag_score=rag_score,
ml_scores_json=ml_scores_json,
album=album,
)
return
markup = get_reply_keyboard_for_post()
sent_message = None
@@ -1128,3 +1352,128 @@ class StickerService:
random_stick_bye = random.choice(name_stick_bye)
random_stick_bye = FSInputFile(path=random_stick_bye)
await message.answer_sticker(random_stick_bye)
class AutoModerationService:
"""
Сервис автоматической модерации постов на основе RAG score.
Автоматически публикует посты с высоким скором и отклоняет с низким.
"""
def __init__(
self,
db: DatabaseProtocol,
settings: BotSettings,
scoring_manager=None,
s3_storage=None,
) -> None:
self.db = db
self.settings = settings
self.scoring_manager = scoring_manager
self.s3_storage = s3_storage
@track_time("check_auto_action", "auto_moderation_service")
async def check_auto_action(self, rag_score: float) -> str:
"""
Проверяет, требуется ли автоматическое действие.
Args:
rag_score: Скор от RAG модели (0.0 - 1.0)
Returns:
'publish' - автопубликация
'decline' - автоотклонение
'manual' - ручная модерация
"""
if rag_score is None:
return "manual"
settings = await self.db.get_auto_moderation_settings()
auto_publish_enabled = settings.get("auto_publish_enabled", False)
auto_decline_enabled = settings.get("auto_decline_enabled", False)
auto_publish_threshold = settings.get("auto_publish_threshold", 0.8)
auto_decline_threshold = settings.get("auto_decline_threshold", 0.4)
logger.info(
f"AutoModeration: Настройки из БД - "
f"publish_enabled={auto_publish_enabled}, decline_enabled={auto_decline_enabled}, "
f"publish_threshold={auto_publish_threshold}, decline_threshold={auto_decline_threshold}, "
f"rag_score={rag_score:.2f}"
)
if auto_publish_enabled and rag_score >= auto_publish_threshold:
logger.info(
f"AutoModeration: score {rag_score:.2f} >= {auto_publish_threshold} → auto_publish"
)
return "publish"
if auto_decline_enabled and rag_score <= auto_decline_threshold:
logger.info(
f"AutoModeration: score {rag_score:.2f} <= {auto_decline_threshold} → auto_decline"
)
return "decline"
return "manual"
@track_time("log_auto_action", "auto_moderation_service")
async def log_auto_action(
self,
bot,
action: str,
author_id: int,
author_name: str,
author_username: str,
rag_score: float,
post_text: str,
) -> None:
"""
Отправляет лог автоматического действия в IMPORTANT_LOGS.
Args:
bot: Экземпляр бота для отправки сообщений
action: Тип действия ('publish' или 'decline')
author_id: ID автора поста
author_name: Имя автора
author_username: Username автора
rag_score: Скор модели
post_text: Текст поста
"""
try:
safe_name = html.escape(author_name or "Без имени")
safe_username = html.escape(author_username or "нет")
truncated_text = post_text[:200] if post_text else ""
if len(post_text or "") > 200:
truncated_text += "..."
safe_text = html.escape(truncated_text)
if action == "publish":
emoji = "🤖"
action_title = "АВТО-ПУБЛИКАЦИЯ"
action_result = "✅ Пост автоматически опубликован"
else:
emoji = "🚫"
action_title = "АВТО-ОТКЛОНЕНИЕ"
action_result = "❌ Пост автоматически отклонён"
message_text = (
f"{emoji} <b>{action_title}</b>\n\n"
f"👤 <b>Автор:</b> {safe_name} (@{safe_username}) | ID: {author_id}\n"
f"📊 <b>RAG Score:</b> {rag_score:.2f}\n\n"
f"📝 <b>Текст поста:</b>\n"
f'"{safe_text}"\n\n'
f"{action_result}"
)
await bot.send_message(
chat_id=self.settings.important_logs,
text=message_text,
parse_mode="HTML",
)
logger.info(
f"AutoModeration: Лог отправлен в IMPORTANT_LOGS ({action})"
)
except Exception as e:
logger.error(f"AutoModeration: Ошибка отправки лога: {e}")