diff --git a/.cursor/rules/database-patterns.md b/.cursor/rules/database-patterns.md
index 05918c3..313ca64 100644
--- a/.cursor/rules/database-patterns.md
+++ b/.cursor/rules/database-patterns.md
@@ -112,6 +112,106 @@ class UserRepository(DatabaseConnection):
## Миграции
-- SQL миграции в `database/schema.sql`
-- Python скрипты для миграций в `scripts/`
-- Всегда проверяйте существование таблиц перед созданием: `CREATE TABLE IF NOT EXISTS`
+### Обзор
+
+Система миграций автоматически отслеживает и применяет изменения схемы БД. Миграции хранятся в `scripts/` и применяются автоматически при деплое.
+
+### Создание миграции
+
+1. **Создайте файл** в `scripts/` с понятным именем (например, `add_user_email_column.py`)
+2. **Обязательные требования:**
+ - Функция `async def main(db_path: str)`
+ - Использует `aiosqlite` для работы с БД
+ - **Идемпотентна** - можно запускать несколько раз без ошибок
+ - Проверяет текущее состояние перед применением изменений
+
+3. **Пример структуры:**
+
+```python
+#!/usr/bin/env python3
+import argparse
+import asyncio
+import os
+import sys
+from pathlib import Path
+
+project_root = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(project_root))
+
+import aiosqlite
+from logs.custom_logger import logger
+
+DEFAULT_DB_PATH = "database/tg-bot-database.db"
+
+
+async def main(db_path: str) -> None:
+ """Основная функция миграции."""
+ db_path = os.path.abspath(db_path)
+ if not os.path.exists(db_path):
+ logger.error(f"База данных не найдена: {db_path}")
+ return
+
+ async with aiosqlite.connect(db_path) as conn:
+ await conn.execute("PRAGMA foreign_keys = ON")
+
+ # Проверяем текущее состояние
+ cursor = await conn.execute("PRAGMA table_info(users)")
+ columns = await cursor.fetchall()
+
+ # Проверяем, нужно ли применять изменения
+ column_exists = any(col[1] == "email" for col in columns)
+
+ if not column_exists:
+ await conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
+ await conn.commit()
+ logger.info("Колонка email добавлена")
+ else:
+ logger.info("Колонка email уже существует")
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Добавление колонки email")
+ parser.add_argument(
+ "--db",
+ default=os.environ.get("DATABASE_PATH", DEFAULT_DB_PATH),
+ help="Путь к БД",
+ )
+ args = parser.parse_args()
+ asyncio.run(main(args.db))
+```
+
+### Применение миграций
+
+**Локально:**
+```bash
+python3 scripts/apply_migrations.py --dry-run # проверить
+python3 scripts/apply_migrations.py # применить
+```
+
+**В продакшене:** Применяются автоматически при деплое через CI/CD (перед перезапуском контейнера).
+
+### Важные правила
+
+1. **Идемпотентность** - всегда проверяйте состояние перед изменением:
+ ```python
+ # ✅ Правильно
+ cursor = await conn.execute("PRAGMA table_info(users)")
+ columns = await cursor.fetchall()
+ if not any(col[1] == "email" for col in columns):
+ await conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
+
+ # ❌ Неправильно - упадет при повторном запуске
+ await conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
+ ```
+
+2. **Порядок применения** - миграции применяются в алфавитном порядке по имени файла
+
+3. **Исключения** - следующие скрипты не считаются миграциями:
+ - `apply_migrations.py`, `backfill_migrations.py`, `test_s3_connection.py`, `voice_cleanup.py`
+
+### Регистрация существующих миграций
+
+Если миграции уже применены, но не зарегистрированы:
+```bash
+python3 scripts/backfill_migrations.py # зарегистрировать все существующие
+```
diff --git a/.cursor/rules/error-handling.md b/.cursor/rules/error-handling.md
index 8ca866c..f995d49 100644
--- a/.cursor/rules/error-handling.md
+++ b/.cursor/rules/error-handling.md
@@ -110,12 +110,63 @@ logger.error(f"Критическая ошибка: {e}", exc_info=True)
### Уровни логирования
-- `logger.debug()` - отладочная информация
-- `logger.info()` - информационные сообщения о работе
-- `logger.warning()` - предупреждения о потенциальных проблемах
-- `logger.error()` - ошибки, требующие внимания
+- `logger.debug()` - отладочная информация (детали выполнения, промежуточные значения, HTTP запросы(не используется в проекте))
+- `logger.info()` - информационные сообщения о работе (успешные операции, важные события)
+- `logger.warning()` - предупреждения о потенциальных проблемах (некритичные ошибки, таймауты)
+- `logger.error()` - ошибки, требующие внимания (исключения, сбои)
- `logger.critical()` - критические ошибки
+### Паттерн логирования в сервисах
+
+При работе с внешними API и сервисами используйте следующий паттерн:
+
+```python
+from logs.custom_logger import logger
+
+class ApiClient:
+ async def calculate_score(self, text: str) -> Score:
+ # Логируем начало операции (debug)
+ logger.debug(f"ApiClient: Отправка запроса на расчет скора (text_preview='{text[:50]}')")
+
+ try:
+ response = await self._client.post(url, json=data)
+
+ # Логируем статус ответа (debug)
+ logger.debug(f"ApiClient: Получен ответ (status={response.status_code})")
+
+ # Обрабатываем ответ
+ if response.status_code == 200:
+ result = response.json()
+ # Логируем успешный результат (info)
+ logger.info(f"ApiClient: Скор успешно получен (score={result['score']:.4f})")
+ return result
+ else:
+ # Логируем ошибку (error)
+ logger.error(f"ApiClient: Ошибка API (status={response.status_code})")
+ raise ApiError(f"Ошибка API: {response.status_code}")
+
+ except httpx.TimeoutException:
+ # Логируем таймаут (error)
+ logger.error(f"ApiClient: Таймаут запроса (>{timeout}с)")
+ raise
+ except httpx.RequestError as e:
+ # Логируем ошибку подключения (error)
+ logger.error(f"ApiClient: Ошибка подключения: {e}")
+ raise
+ except Exception as e:
+ # Логируем неожиданные ошибки (error)
+ logger.error(f"ApiClient: Неожиданная ошибка: {e}", exc_info=True)
+ raise
+```
+
+**Принципы:**
+- `logger.debug()` - для деталей выполнения (URL, параметры запроса, статус ответа)
+- `logger.info()` - для успешных операций с важными результатами
+- `logger.warning()` - для некритичных проблем (валидация, таймауты в неважных операциях)
+- `logger.error()` - для всех ошибок перед пробросом исключения
+- Всегда логируйте ошибки перед `raise`
+- Используйте `exc_info=True` для критических ошибок
+
## Метрики ошибок
Декоратор `@track_errors` автоматически отслеживает ошибки:
diff --git a/.cursor/rules/middleware-patterns.mdc b/.cursor/rules/middleware-patterns.mdc
new file mode 100644
index 0000000..a730a37
--- /dev/null
+++ b/.cursor/rules/middleware-patterns.mdc
@@ -0,0 +1,8 @@
+---
+name: middleware-patterns
+description: This is a new rule
+---
+
+# Overview
+
+Insert overview text here. The agent will only see this should they choose to apply the rule.
diff --git a/.gitignore b/.gitignore
index 690367e..6cc702c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -92,4 +92,7 @@ venv.bak/
# Other files
voice_users/
-files/
\ No newline at end of file
+files/
+
+# ML models and vectors cache
+data/
\ No newline at end of file
diff --git a/:memory: b/:memory:
new file mode 100644
index 0000000..159e90a
Binary files /dev/null and b/:memory: differ
diff --git a/Dockerfile b/Dockerfile
index 0c36fe8..c120d14 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,13 +3,12 @@
###########################################
FROM python:3.11.9-alpine as builder
-# Устанавливаем инструменты для компиляции + linux-headers для psutil
+# Устанавливаем инструменты для компиляции (если нужны для некоторых пакетов)
RUN apk add --no-cache \
gcc \
- g++ \
musl-dev \
- python3-dev \
- linux-headers # ← ЭТО КРИТИЧЕСКИ ВАЖНО ДЛЯ psutil
+ libffi-dev \
+ && rm -rf /var/cache/apk/*
WORKDIR /app
COPY requirements.txt .
@@ -23,27 +22,22 @@ RUN pip install --no-cache-dir --target /install -r requirements.txt
###########################################
FROM python:3.11.9-alpine as runtime
-# Минимальные рантайм-зависимости
-RUN apk add --no-cache \
- libstdc++ \
- sqlite-libs
-
# Создаем пользователя
RUN addgroup -g 1001 deploy && adduser -D -u 1001 -G deploy deploy
WORKDIR /app
# Копируем зависимости
-COPY --from=builder --chown=1001:1001 /install /usr/local/lib/python3.11/site-packages
+COPY --from=builder --chown=deploy:deploy /install /usr/local/lib/python3.11/site-packages
# Создаем структуру папок
RUN mkdir -p database logs voice_users && \
- chown -R 1001:1001 /app
+ chown -R deploy:deploy /app
# Копируем исходный код
-COPY --chown=1001:1001 . .
+COPY --chown=deploy:deploy . .
-USER 1001
+USER deploy
# Healthcheck
HEALTHCHECK --interval=30s --timeout=15s --start-period=10s --retries=5 \
diff --git a/database/__init__.py b/database/__init__.py
index 319b3bb..731bfbd 100644
--- a/database/__init__.py
+++ b/database/__init__.py
@@ -9,13 +9,12 @@
- async_db: основной класс AsyncBotDB
"""
-from .models import (
- User, BlacklistUser, UserMessage, TelegramPost, PostContent,
- MessageContentLink, Admin, Migration, AudioMessage, AudioListenRecord, AudioModerate
-)
-from .repository_factory import RepositoryFactory
-from .base import DatabaseConnection
from .async_db import AsyncBotDB
+from .base import DatabaseConnection
+from .models import (Admin, AudioListenRecord, AudioMessage, AudioModerate,
+ BlacklistUser, MessageContentLink, Migration, PostContent,
+ TelegramPost, User, UserMessage)
+from .repository_factory import RepositoryFactory
# Для обратной совместимости экспортируем старый интерфейс
__all__ = [
diff --git a/database/async_db.py b/database/async_db.py
index 035b6e1..e5f74d8 100644
--- a/database/async_db.py
+++ b/database/async_db.py
@@ -1,11 +1,11 @@
-import aiosqlite
from datetime import datetime
-from typing import Optional, List, Dict, Any, Tuple
+from typing import Any, Dict, List, Optional, Tuple
+
+import aiosqlite
+from database.models import (Admin, AudioMessage, BlacklistHistoryRecord,
+ BlacklistUser, PostContent, TelegramPost, User,
+ UserMessage)
from database.repository_factory import RepositoryFactory
-from database.models import (
- User, BlacklistUser, BlacklistHistoryRecord, UserMessage, TelegramPost, PostContent,
- Admin, AudioMessage
-)
class AsyncBotDB:
@@ -210,6 +210,23 @@ class AsyncBotDB:
return await self.factory.posts.update_status_for_media_group_by_helper_id(
helper_message_id, status
)
+
+ # Методы для ML Scoring
+ async def get_post_text_by_message_id(self, message_id: int) -> Optional[str]:
+ """Получает текст поста по message_id."""
+ return await self.factory.posts.get_post_text_by_message_id(message_id)
+
+ async def update_ml_scores(self, message_id: int, ml_scores_json: str) -> bool:
+ """Обновляет ML-скоры для поста."""
+ return await self.factory.posts.update_ml_scores(message_id, ml_scores_json)
+
+ async def get_approved_posts_texts(self, limit: int = 1000) -> List[str]:
+ """Получает тексты одобренных постов для обучения RAG."""
+ return await self.factory.posts.get_approved_posts_texts(limit)
+
+ async def get_declined_posts_texts(self, limit: int = 1000) -> List[str]:
+ """Получает тексты отклоненных постов для обучения RAG."""
+ return await self.factory.posts.get_declined_posts_texts(limit)
# Методы для работы с черным списком
async def set_user_blacklist(
@@ -403,25 +420,9 @@ class AsyncBotDB:
await self.factory.audio.delete_audio_record_by_file_name(file_name)
# Методы для миграций
- async def get_migration_version(self) -> int:
- """Получение текущей версии миграции."""
- return await self.factory.migrations.get_migration_version()
-
- async def get_current_version(self) -> Optional[int]:
- """Возвращает текущую последнюю версию миграции."""
- return await self.factory.migrations.get_current_version()
-
- async def update_version(self, new_version: int, script_name: str):
- """Обновляет версию миграций в таблице migrations."""
- await self.factory.migrations.update_version(new_version, script_name)
-
async def create_table(self, sql_script: str):
"""Создает таблицу в базе. Используется в миграциях."""
- await self.factory.migrations.create_table(sql_script)
-
- async def update_migration_version(self, version: int, script_name: str):
- """Обновление версии миграции."""
- await self.factory.migrations.update_version(version, script_name)
+ await self.factory.migrations.create_table_from_sql(sql_script)
# Методы для voice bot welcome tracking
async def check_voice_bot_welcome_received(self, user_id: int) -> bool:
diff --git a/database/base.py b/database/base.py
index 0048b5c..4ede01a 100644
--- a/database/base.py
+++ b/database/base.py
@@ -1,6 +1,7 @@
import os
-import aiosqlite
from typing import Optional
+
+import aiosqlite
from logs.custom_logger import logger
diff --git a/database/models.py b/database/models.py
index 4f719d9..bfbb030 100644
--- a/database/models.py
+++ b/database/models.py
@@ -1,6 +1,6 @@
from dataclasses import dataclass
from datetime import datetime
-from typing import Optional, List
+from typing import List, Optional
@dataclass
@@ -89,9 +89,8 @@ class Admin:
@dataclass
class Migration:
"""Модель миграции."""
- version: int
script_name: str
- created_at: Optional[str] = None
+ applied_at: Optional[str] = None
@dataclass
diff --git a/database/repositories/__init__.py b/database/repositories/__init__.py
index 867b4bf..6b165d2 100644
--- a/database/repositories/__init__.py
+++ b/database/repositories/__init__.py
@@ -9,17 +9,20 @@
- post_repository: работа с постами
- admin_repository: работа с администраторами
- audio_repository: работа с аудио
+- migration_repository: работа с миграциями БД
"""
-from .user_repository import UserRepository
-from .blacklist_repository import BlacklistRepository
-from .blacklist_history_repository import BlacklistHistoryRepository
-from .message_repository import MessageRepository
-from .post_repository import PostRepository
from .admin_repository import AdminRepository
from .audio_repository import AudioRepository
+from .blacklist_history_repository import BlacklistHistoryRepository
+from .blacklist_repository import BlacklistRepository
+from .message_repository import MessageRepository
+from .migration_repository import MigrationRepository
+from .post_repository import PostRepository
+from .user_repository import UserRepository
__all__ = [
'UserRepository', 'BlacklistRepository', 'BlacklistHistoryRepository',
- 'MessageRepository', 'PostRepository', 'AdminRepository', 'AudioRepository'
+ 'MessageRepository', 'PostRepository', 'AdminRepository', 'AudioRepository',
+ 'MigrationRepository'
]
diff --git a/database/repositories/admin_repository.py b/database/repositories/admin_repository.py
index cb78c76..b696ac7 100644
--- a/database/repositories/admin_repository.py
+++ b/database/repositories/admin_repository.py
@@ -1,4 +1,5 @@
from typing import Optional
+
from database.base import DatabaseConnection
from database.models import Admin
diff --git a/database/repositories/audio_repository.py b/database/repositories/audio_repository.py
index 2da52ed..1c2a301 100644
--- a/database/repositories/audio_repository.py
+++ b/database/repositories/audio_repository.py
@@ -1,7 +1,8 @@
-from typing import Optional, List, Dict, Any
-from database.base import DatabaseConnection
-from database.models import AudioMessage, AudioListenRecord, AudioModerate
from datetime import datetime
+from typing import Any, Dict, List, Optional
+
+from database.base import DatabaseConnection
+from database.models import AudioListenRecord, AudioMessage, AudioModerate
class AudioRepository(DatabaseConnection):
diff --git a/database/repositories/blacklist_history_repository.py b/database/repositories/blacklist_history_repository.py
index e0914a0..14f95e8 100644
--- a/database/repositories/blacklist_history_repository.py
+++ b/database/repositories/blacklist_history_repository.py
@@ -1,4 +1,5 @@
from typing import Optional
+
from database.base import DatabaseConnection
from database.models import BlacklistHistoryRecord
diff --git a/database/repositories/blacklist_repository.py b/database/repositories/blacklist_repository.py
index d66d514..6559645 100644
--- a/database/repositories/blacklist_repository.py
+++ b/database/repositories/blacklist_repository.py
@@ -1,4 +1,5 @@
-from typing import Optional, List, Dict
+from typing import Dict, List, Optional
+
from database.base import DatabaseConnection
from database.models import BlacklistUser
diff --git a/database/repositories/message_repository.py b/database/repositories/message_repository.py
index 8589d2c..d52a6c4 100644
--- a/database/repositories/message_repository.py
+++ b/database/repositories/message_repository.py
@@ -1,5 +1,6 @@
from datetime import datetime
from typing import Optional
+
from database.base import DatabaseConnection
from database.models import UserMessage
diff --git a/database/repositories/migration_repository.py b/database/repositories/migration_repository.py
new file mode 100644
index 0000000..6dcb67d
--- /dev/null
+++ b/database/repositories/migration_repository.py
@@ -0,0 +1,78 @@
+"""Репозиторий для работы с миграциями базы данных."""
+import aiosqlite
+from database.base import DatabaseConnection
+
+
+class MigrationRepository(DatabaseConnection):
+ """Репозиторий для управления миграциями базы данных."""
+
+ async def create_table(self):
+ """Создает таблицу migrations, если она не существует."""
+ query = """
+ CREATE TABLE IF NOT EXISTS migrations (
+ id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ script_name TEXT NOT NULL UNIQUE,
+ applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
+ )
+ """
+ await self._execute_query(query)
+ self.logger.info("Таблица migrations создана или уже существует")
+
+ async def get_applied_migrations(self) -> list[str]:
+ """Возвращает список имен примененных скриптов миграций."""
+ conn = None
+ try:
+ conn = await self._get_connection()
+ cursor = await conn.execute("SELECT script_name FROM migrations ORDER BY applied_at")
+ rows = await cursor.fetchall()
+ await cursor.close()
+ return [row[0] for row in rows]
+ except Exception as e:
+ self.logger.error(f"Ошибка при получении списка миграций: {e}")
+ raise
+ finally:
+ if conn:
+ await conn.close()
+
+ async def is_migration_applied(self, script_name: str) -> bool:
+ """Проверяет, применена ли миграция."""
+ conn = None
+ try:
+ conn = await self._get_connection()
+ cursor = await conn.execute(
+ "SELECT COUNT(*) FROM migrations WHERE script_name = ?",
+ (script_name,)
+ )
+ row = await cursor.fetchone()
+ await cursor.close()
+ return row[0] > 0 if row else False
+ except Exception as e:
+ self.logger.error(f"Ошибка при проверке миграции {script_name}: {e}")
+ raise
+ finally:
+ if conn:
+ await conn.close()
+
+ async def mark_migration_applied(self, script_name: str) -> None:
+ """Отмечает миграцию как примененную."""
+ conn = None
+ try:
+ conn = await self._get_connection()
+ await conn.execute(
+ "INSERT INTO migrations (script_name) VALUES (?)",
+ (script_name,)
+ )
+ await conn.commit()
+ self.logger.info(f"Миграция {script_name} отмечена как примененная")
+ except aiosqlite.IntegrityError:
+ self.logger.warning(f"Миграция {script_name} уже была применена ранее")
+ except Exception as e:
+ self.logger.error(f"Ошибка при отметке миграции {script_name}: {e}")
+ raise
+ finally:
+ if conn:
+ await conn.close()
+
+ async def create_table_from_sql(self, sql_script: str) -> None:
+ """Создает таблицу из SQL скрипта. Используется в миграциях."""
+ await self._execute_query(sql_script)
diff --git a/database/repositories/post_repository.py b/database/repositories/post_repository.py
index 79627a2..e819cb6 100644
--- a/database/repositories/post_repository.py
+++ b/database/repositories/post_repository.py
@@ -1,7 +1,8 @@
from datetime import datetime
-from typing import Optional, List, Tuple
+from typing import List, Optional, Tuple
+
from database.base import DatabaseConnection
-from database.models import TelegramPost, PostContent, MessageContentLink
+from database.models import MessageContentLink, PostContent, TelegramPost
class PostRepository(DatabaseConnection):
@@ -356,3 +357,108 @@ class PostRepository(DatabaseConnection):
post_content = await self._execute_query_with_result(query, (published_message_id,))
self.logger.info(f"Получен контент опубликованного поста: {len(post_content)} элементов для published_message_id={published_message_id}")
return post_content
+
+ # ============================================
+ # Методы для работы с ML-скорингом
+ # ============================================
+
+ async def update_ml_scores(self, message_id: int, ml_scores_json: str) -> bool:
+ """
+ Обновляет ML-скоры для поста.
+
+ Args:
+ message_id: ID сообщения в группе модерации
+ ml_scores_json: JSON строка со скорами
+
+ Returns:
+ True если обновлено успешно
+ """
+ try:
+ query = "UPDATE post_from_telegram_suggest SET ml_scores = ? WHERE message_id = ?"
+ await self._execute_query(query, (ml_scores_json, message_id))
+ self.logger.info(f"ML-скоры обновлены для message_id={message_id}")
+ return True
+ except Exception as e:
+ self.logger.error(f"Ошибка обновления ML-скоров для message_id={message_id}: {e}")
+ return False
+
+ async def get_ml_scores_by_message_id(self, message_id: int) -> Optional[str]:
+ """
+ Получает ML-скоры для поста.
+
+ Args:
+ message_id: ID сообщения
+
+ Returns:
+ JSON строка со скорами или None
+ """
+ query = "SELECT ml_scores FROM post_from_telegram_suggest WHERE message_id = ?"
+ rows = await self._execute_query_with_result(query, (message_id,))
+ if rows and rows[0][0]:
+ return rows[0][0]
+ return None
+
+ async def get_post_text_by_message_id(self, message_id: int) -> Optional[str]:
+ """
+ Получает текст поста по message_id.
+
+ Args:
+ message_id: ID сообщения
+
+ Returns:
+ Текст поста или None
+ """
+ query = "SELECT text FROM post_from_telegram_suggest WHERE message_id = ?"
+ rows = await self._execute_query_with_result(query, (message_id,))
+ if rows and rows[0][0]:
+ return rows[0][0]
+ return None
+
+ async def get_approved_posts_texts(self, limit: int = 1000) -> List[str]:
+ """
+ Получает тексты опубликованных постов для обучения RAG.
+
+ Args:
+ limit: Максимальное количество постов
+
+ Returns:
+ Список текстов
+ """
+ query = """
+ SELECT text FROM post_from_telegram_suggest
+ WHERE status = 'approved'
+ AND text IS NOT NULL
+ AND text != ''
+ AND text != '^'
+ ORDER BY created_at DESC
+ LIMIT ?
+ """
+ rows = await self._execute_query_with_result(query, (limit,))
+ texts = [row[0] for row in rows if row[0]]
+ self.logger.info(f"Получено {len(texts)} опубликованных постов для обучения")
+ return texts
+
+ async def get_declined_posts_texts(self, limit: int = 1000) -> List[str]:
+ """
+ Получает тексты отклоненных постов для обучения RAG.
+
+ Args:
+ limit: Максимальное количество постов
+
+ Returns:
+ Список текстов
+ """
+ query = """
+ SELECT text FROM post_from_telegram_suggest
+ WHERE status = 'declined'
+ AND text IS NOT NULL
+ AND text != ''
+ AND text != '^'
+ ORDER BY created_at DESC
+ LIMIT ?
+ """
+ rows = await self._execute_query_with_result(query, (limit,))
+ texts = [row[0] for row in rows if row[0]]
+ self.logger.info(f"Получено {len(texts)} отклоненных постов для обучения")
+ return texts
+
diff --git a/database/repositories/user_repository.py b/database/repositories/user_repository.py
index 0e7a117..b87ee02 100644
--- a/database/repositories/user_repository.py
+++ b/database/repositories/user_repository.py
@@ -1,5 +1,6 @@
from datetime import datetime
-from typing import Optional, List, Dict, Any
+from typing import Any, Dict, List, Optional
+
from database.base import DatabaseConnection
from database.models import User
diff --git a/database/repository_factory.py b/database/repository_factory.py
index 154e611..3d08e77 100644
--- a/database/repository_factory.py
+++ b/database/repository_factory.py
@@ -1,11 +1,14 @@
from typing import Optional
-from database.repositories.user_repository import UserRepository
-from database.repositories.blacklist_repository import BlacklistRepository
-from database.repositories.blacklist_history_repository import BlacklistHistoryRepository
-from database.repositories.message_repository import MessageRepository
-from database.repositories.post_repository import PostRepository
+
from database.repositories.admin_repository import AdminRepository
from database.repositories.audio_repository import AudioRepository
+from database.repositories.blacklist_history_repository import \
+ BlacklistHistoryRepository
+from database.repositories.blacklist_repository import BlacklistRepository
+from database.repositories.message_repository import MessageRepository
+from database.repositories.migration_repository import MigrationRepository
+from database.repositories.post_repository import PostRepository
+from database.repositories.user_repository import UserRepository
class RepositoryFactory:
@@ -20,6 +23,7 @@ class RepositoryFactory:
self._post_repo: Optional[PostRepository] = None
self._admin_repo: Optional[AdminRepository] = None
self._audio_repo: Optional[AudioRepository] = None
+ self._migration_repo: Optional[MigrationRepository] = None
@property
def users(self) -> UserRepository:
@@ -70,8 +74,16 @@ class RepositoryFactory:
self._audio_repo = AudioRepository(self.db_path)
return self._audio_repo
+ @property
+ def migrations(self) -> MigrationRepository:
+ """Возвращает репозиторий миграций."""
+ if self._migration_repo is None:
+ self._migration_repo = MigrationRepository(self.db_path)
+ return self._migration_repo
+
async def create_all_tables(self):
"""Создает все таблицы в базе данных."""
+ await self.migrations.create_table() # Сначала создаем таблицу миграций
await self.users.create_tables()
await self.blacklist.create_tables()
await self.blacklist_history.create_tables()
diff --git a/database/schema.sql b/database/schema.sql
index 6fd16c5..b36a7b2 100644
--- a/database/schema.sql
+++ b/database/schema.sql
@@ -126,6 +126,13 @@ CREATE TABLE IF NOT EXISTS audio_moderate (
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
);
+-- Database migrations tracking
+CREATE TABLE IF NOT EXISTS migrations (
+ id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ script_name TEXT NOT NULL UNIQUE,
+ applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
+);
+
-- Create indexes for better performance
-- Optimized index for user_audio_listens - only user_id for "show all audio listened by user X"
CREATE INDEX IF NOT EXISTS idx_user_audio_listens_user_id ON user_audio_listens(user_id);
diff --git a/env.example b/env.example
index dbab9a9..9350527 100644
--- a/env.example
+++ b/env.example
@@ -35,3 +35,19 @@ METRICS_PORT=8080
# Logging
LOG_LEVEL=INFO
LOG_RETENTION_DAYS=30
+
+# ML Scoring - RAG API
+# Включает оценку постов через внешний RAG API сервис
+RAG_ENABLED=false
+RAG_API_URL=http://xx.xxx.xx.xx/api/v1
+RAG_API_KEY=your_rag_api_key_here
+RAG_API_TIMEOUT=30
+RAG_TEST_MODE=false
+
+# ML Scoring - DeepSeek API
+# Включает оценку постов через DeepSeek API
+DEEPSEEK_ENABLED=false
+DEEPSEEK_API_KEY=your_deepseek_api_key_here
+DEEPSEEK_API_URL=https://api.deepseek.com/v1/chat/completions
+DEEPSEEK_MODEL=deepseek-chat
+DEEPSEEK_TIMEOUT=30
diff --git a/helper_bot/handlers/admin/__init__.py b/helper_bot/handlers/admin/__init__.py
index 29f53ec..af092ad 100644
--- a/helper_bot/handlers/admin/__init__.py
+++ b/helper_bot/handlers/admin/__init__.py
@@ -1,20 +1,10 @@
from .admin_handlers import admin_router
from .dependencies import AdminAccessMiddleware, BotDB, Settings
-from .services import AdminService, User, BannedUser
-from .exceptions import (
- AdminError,
- AdminAccessDeniedError,
- UserNotFoundError,
- InvalidInputError,
- UserAlreadyBannedError
-)
-from .utils import (
- return_to_admin_menu,
- handle_admin_error,
- format_user_info,
- format_ban_confirmation,
- escape_html
-)
+from .exceptions import (AdminAccessDeniedError, AdminError, InvalidInputError,
+ UserAlreadyBannedError, UserNotFoundError)
+from .services import AdminService, BannedUser, User
+from .utils import (escape_html, format_ban_confirmation, format_user_info,
+ handle_admin_error, return_to_admin_menu)
__all__ = [
'admin_router',
diff --git a/helper_bot/handlers/admin/admin_handlers.py b/helper_bot/handlers/admin/admin_handlers.py
index 9d8d8f4..a0aeb03 100644
--- a/helper_bot/handlers/admin/admin_handlers.py
+++ b/helper_bot/handlers/admin/admin_handlers.py
@@ -1,36 +1,25 @@
-from aiogram import Router, types, F
-from aiogram.filters import Command, StateFilter, MagicData
+from aiogram import F, Router, types
+from aiogram.filters import Command, MagicData, StateFilter
from aiogram.fsm.context import FSMContext
-
from helper_bot.filters.main import ChatTypeFilter
-from helper_bot.keyboards.keyboards import (
- get_reply_keyboard_admin,
- create_keyboard_with_pagination,
- create_keyboard_for_ban_days,
- create_keyboard_for_approve_ban,
- create_keyboard_for_ban_reason
-)
from helper_bot.handlers.admin.dependencies import AdminAccessMiddleware
+from helper_bot.handlers.admin.exceptions import (InvalidInputError,
+ UserAlreadyBannedError)
from helper_bot.handlers.admin.services import AdminService
-from helper_bot.handlers.admin.exceptions import (
- UserAlreadyBannedError,
- InvalidInputError
-)
-from helper_bot.handlers.admin.utils import (
- return_to_admin_menu,
- handle_admin_error,
- format_user_info,
- format_ban_confirmation,
- escape_html
-)
-from logs.custom_logger import logger
-
+from helper_bot.handlers.admin.utils import (escape_html,
+ format_ban_confirmation,
+ format_user_info,
+ handle_admin_error,
+ return_to_admin_menu)
+from helper_bot.keyboards.keyboards import (create_keyboard_for_approve_ban,
+ create_keyboard_for_ban_days,
+ create_keyboard_for_ban_reason,
+ create_keyboard_with_pagination,
+ get_reply_keyboard_admin)
+from helper_bot.utils.base_dependency_factory import get_global_instance
# Local imports - metrics
-from helper_bot.utils.metrics import (
- track_time,
- track_errors,
- db_query_time
-)
+from helper_bot.utils.metrics import db_query_time, track_errors, track_time
+from logs.custom_logger import logger
# Создаем роутер с middleware для проверки доступа
admin_router = Router()
@@ -149,6 +138,93 @@ async def get_banned_users(
await handle_admin_error(message, e, state, "get_banned_users")
+@admin_router.message(
+ ChatTypeFilter(chat_type=["private"]),
+ StateFilter("ADMIN"),
+ F.text == '📊 ML Статистика'
+)
+@track_time("get_ml_stats", "admin_handlers")
+@track_errors("admin_handlers", "get_ml_stats")
+async def get_ml_stats(
+ message: types.Message,
+ state: FSMContext,
+ **kwargs
+ ):
+ """Получение статистики ML-скоринга"""
+ try:
+ logger.info(f"Запрос ML статистики от пользователя: {message.from_user.full_name}")
+
+ bdf = get_global_instance()
+ scoring_manager = bdf.get_scoring_manager()
+
+ if not scoring_manager:
+ await message.answer("📊 ML Scoring отключен\n\nДля включения установите RAG_ENABLED=true или DEEPSEEK_ENABLED=true в .env")
+ return
+
+ stats = await scoring_manager.get_stats()
+
+ # Формируем текст статистики
+ lines = ["📊 ML Scoring Статистика\n"]
+
+ # RAG статистика
+ if "rag" in stats:
+ rag = stats["rag"]
+ lines.append("🤖 RAG API:")
+
+ # Проверяем, есть ли данные из API (новый контракт содержит model_loaded и vector_store)
+ if "model_loaded" in rag or "vector_store" in rag:
+ # Данные из API /stats
+ if "model_loaded" in rag:
+ model_loaded = rag.get('model_loaded', False)
+ lines.append(f" • Модель загружена: {'✅' if model_loaded else '❌'}")
+ if "model_name" in rag:
+ lines.append(f" • Модель: {rag.get('model_name', 'N/A')}")
+ if "device" in rag:
+ lines.append(f" • Устройство: {rag.get('device', 'N/A')}")
+
+ # Статистика из vector_store
+ if "vector_store" in rag:
+ vector_store = rag["vector_store"]
+ positive_count = vector_store.get("positive_count", 0)
+ negative_count = vector_store.get("negative_count", 0)
+ total_count = vector_store.get("total_count", 0)
+
+ lines.append(f" • Положительных примеров: {positive_count}")
+ lines.append(f" • Отрицательных примеров: {negative_count}")
+ lines.append(f" • Всего примеров: {total_count}")
+
+ if "vector_dim" in vector_store:
+ lines.append(f" • Размерность векторов: {vector_store.get('vector_dim', 'N/A')}")
+ if "max_examples" in vector_store:
+ lines.append(f" • Макс. примеров: {vector_store.get('max_examples', 'N/A')}")
+ else:
+ # Fallback на синхронные данные (если API недоступен)
+ lines.append(f" • API URL: {rag.get('api_url', 'N/A')}")
+ if "enabled" in rag:
+ lines.append(f" • Статус: {'✅ Включен' if rag.get('enabled') else '❌ Отключен'}")
+
+ lines.append("")
+
+ # DeepSeek статистика
+ if "deepseek" in stats:
+ ds = stats["deepseek"]
+ lines.append("🔮 DeepSeek API:")
+ lines.append(f" • Статус: {'✅ Включен' if ds.get('enabled') else '❌ Отключен'}")
+ lines.append(f" • Модель: {ds.get('model', 'N/A')}")
+ lines.append(f" • Таймаут: {ds.get('timeout', 'N/A')}с")
+ lines.append("")
+
+ # Если ничего не включено
+ if "rag" not in stats and "deepseek" not in stats:
+ lines.append("⚠️ Ни один сервис не настроен")
+
+ await message.answer("\n".join(lines), parse_mode="HTML")
+
+ except Exception as e:
+ logger.error(f"Ошибка получения ML статистики: {e}")
+ await message.answer(f"❌ Ошибка получения статистики: {str(e)}")
+
+
# ============================================================================
# ХЕНДЛЕРЫ ПРОЦЕССА БАНА
# ============================================================================
diff --git a/helper_bot/handlers/admin/constants.py b/helper_bot/handlers/admin/constants.py
index 9fddf58..490caff 100644
--- a/helper_bot/handlers/admin/constants.py
+++ b/helper_bot/handlers/admin/constants.py
@@ -1,6 +1,6 @@
"""Constants for admin handlers"""
-from typing import Final, Dict
+from typing import Dict, Final
# Admin button texts
ADMIN_BUTTON_TEXTS: Final[Dict[str, str]] = {
diff --git a/helper_bot/handlers/admin/dependencies.py b/helper_bot/handlers/admin/dependencies.py
index 0b4293b..0a4cd9e 100644
--- a/helper_bot/handlers/admin/dependencies.py
+++ b/helper_bot/handlers/admin/dependencies.py
@@ -1,11 +1,12 @@
-from typing import Dict, Any
+from typing import Any, Dict
+
try:
from typing import Annotated
except ImportError:
from typing_extensions import Annotated
+
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
-
from helper_bot.utils.base_dependency_factory import get_global_instance
from helper_bot.utils.helper_func import check_access
from logs.custom_logger import logger
diff --git a/helper_bot/handlers/admin/rate_limit_handlers.py b/helper_bot/handlers/admin/rate_limit_handlers.py
index 9fd3b21..3c73c6a 100644
--- a/helper_bot/handlers/admin/rate_limit_handlers.py
+++ b/helper_bot/handlers/admin/rate_limit_handlers.py
@@ -1,22 +1,20 @@
"""
Обработчики команд для мониторинга rate limiting
"""
-from aiogram import Router, types, F
+from aiogram import F, Router, types
from aiogram.filters import Command, MagicData
from aiogram.fsm.context import FSMContext
from aiogram.types import FSInputFile
-
from helper_bot.filters.main import ChatTypeFilter
-from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
-from helper_bot.utils.rate_limit_monitor import rate_limit_monitor, get_rate_limit_summary
-from helper_bot.utils.rate_limit_metrics import update_rate_limit_gauges, get_rate_limit_metrics_summary
-from logs.custom_logger import logger
-
+from helper_bot.middlewares.dependencies_middleware import \
+ DependenciesMiddleware
# Local imports - metrics
-from helper_bot.utils.metrics import (
- track_time,
- track_errors
-)
+from helper_bot.utils.metrics import track_errors, track_time
+from helper_bot.utils.rate_limit_metrics import (
+ get_rate_limit_metrics_summary, update_rate_limit_gauges)
+from helper_bot.utils.rate_limit_monitor import (get_rate_limit_summary,
+ rate_limit_monitor)
+from logs.custom_logger import logger
class RateLimitHandlers:
diff --git a/helper_bot/handlers/admin/services.py b/helper_bot/handlers/admin/services.py
index df3f3eb..7b92973 100644
--- a/helper_bot/handlers/admin/services.py
+++ b/helper_bot/handlers/admin/services.py
@@ -1,15 +1,14 @@
-from typing import List, Optional
from datetime import datetime
+from typing import List, Optional
-from helper_bot.utils.helper_func import add_days_to_date, get_banned_users_buttons, get_banned_users_list
-from helper_bot.handlers.admin.exceptions import UserAlreadyBannedError, InvalidInputError
-from logs.custom_logger import logger
-
+from helper_bot.handlers.admin.exceptions import (InvalidInputError,
+ UserAlreadyBannedError)
+from helper_bot.utils.helper_func import (add_days_to_date,
+ get_banned_users_buttons,
+ get_banned_users_list)
# Local imports - metrics
-from helper_bot.utils.metrics import (
- track_time,
- track_errors
-)
+from helper_bot.utils.metrics import track_errors, track_time
+from logs.custom_logger import logger
class User:
diff --git a/helper_bot/handlers/admin/utils.py b/helper_bot/handlers/admin/utils.py
index 1738e29..292dd2b 100644
--- a/helper_bot/handlers/admin/utils.py
+++ b/helper_bot/handlers/admin/utils.py
@@ -1,10 +1,10 @@
import html
from typing import Optional
+
from aiogram import types
from aiogram.fsm.context import FSMContext
-
-from helper_bot.keyboards.keyboards import get_reply_keyboard_admin
from helper_bot.handlers.admin.exceptions import AdminError
+from helper_bot.keyboards.keyboards import get_reply_keyboard_admin
from logs.custom_logger import logger
diff --git a/helper_bot/handlers/callback/__init__.py b/helper_bot/handlers/callback/__init__.py
index 6bb7d74..b06d41a 100644
--- a/helper_bot/handlers/callback/__init__.py
+++ b/helper_bot/handlers/callback/__init__.py
@@ -1,10 +1,9 @@
from .callback_handlers import callback_router
-from .services import PostPublishService, BanService
-from .exceptions import UserBlockedBotError, PostNotFoundError, UserNotFoundError, PublishError, BanError
-from .constants import (
- CALLBACK_PUBLISH, CALLBACK_DECLINE, CALLBACK_BAN, CALLBACK_UNLOCK,
- CALLBACK_RETURN, CALLBACK_PAGE
-)
+from .constants import (CALLBACK_BAN, CALLBACK_DECLINE, CALLBACK_PAGE,
+ CALLBACK_PUBLISH, CALLBACK_RETURN, CALLBACK_UNLOCK)
+from .exceptions import (BanError, PostNotFoundError, PublishError,
+ UserBlockedBotError, UserNotFoundError)
+from .services import BanService, PostPublishService
__all__ = [
'callback_router',
diff --git a/helper_bot/handlers/callback/callback_handlers.py b/helper_bot/handlers/callback/callback_handlers.py
index 198a9b2..3d06760 100644
--- a/helper_bot/handlers/callback/callback_handlers.py
+++ b/helper_bot/handlers/callback/callback_handlers.py
@@ -1,37 +1,34 @@
import html
-import traceback
import time
+import traceback
from datetime import datetime
-from aiogram import Router, F
-from aiogram.types import CallbackQuery
-from aiogram.fsm.context import FSMContext
+from aiogram import F, Router
from aiogram.filters import MagicData
-
-from helper_bot.handlers.voice.constants import CALLBACK_SAVE, CALLBACK_DELETE
-from helper_bot.handlers.voice.services import AudioFileService
-from helper_bot.keyboards.keyboards import create_keyboard_with_pagination, get_reply_keyboard_admin, \
- create_keyboard_for_ban_reason
-from helper_bot.utils.helper_func import get_banned_users_list, get_banned_users_buttons
+from aiogram.fsm.context import FSMContext
+from aiogram.types import CallbackQuery
from helper_bot.handlers.admin.utils import format_user_info
+from helper_bot.handlers.voice.constants import CALLBACK_DELETE, CALLBACK_SAVE
+from helper_bot.handlers.voice.services import AudioFileService
+from helper_bot.keyboards.keyboards import (create_keyboard_for_ban_reason,
+ create_keyboard_with_pagination,
+ get_reply_keyboard_admin)
from helper_bot.utils.base_dependency_factory import get_global_instance
-from .dependency_factory import get_post_publish_service, get_ban_service
-from .exceptions import UserBlockedBotError, PostNotFoundError, UserNotFoundError, PublishError, BanError
-from .constants import (
- CALLBACK_PUBLISH, CALLBACK_DECLINE, CALLBACK_BAN, CALLBACK_UNLOCK,
- CALLBACK_RETURN, CALLBACK_PAGE, MESSAGE_PUBLISHED, MESSAGE_DECLINED,
- MESSAGE_USER_BANNED, MESSAGE_USER_UNLOCKED, MESSAGE_ERROR,
- ERROR_BOT_BLOCKED
-)
+from helper_bot.utils.helper_func import (get_banned_users_buttons,
+ get_banned_users_list)
+# Local imports - metrics
+from helper_bot.utils.metrics import (db_query_time, track_errors,
+ track_file_operations, track_time)
from logs.custom_logger import logger
-# Local imports - metrics
-from helper_bot.utils.metrics import (
- track_time,
- track_errors,
- db_query_time,
- track_file_operations
-)
+from .constants import (CALLBACK_BAN, CALLBACK_DECLINE, CALLBACK_PAGE,
+ CALLBACK_PUBLISH, CALLBACK_RETURN, CALLBACK_UNLOCK,
+ ERROR_BOT_BLOCKED, MESSAGE_DECLINED, MESSAGE_ERROR,
+ MESSAGE_PUBLISHED, MESSAGE_USER_BANNED,
+ MESSAGE_USER_UNLOCKED)
+from .dependency_factory import get_ban_service, get_post_publish_service
+from .exceptions import (BanError, PostNotFoundError, PublishError,
+ UserBlockedBotError, UserNotFoundError)
callback_router = Router()
diff --git a/helper_bot/handlers/callback/constants.py b/helper_bot/handlers/callback/constants.py
index 02dbd95..1ce2b75 100644
--- a/helper_bot/handlers/callback/constants.py
+++ b/helper_bot/handlers/callback/constants.py
@@ -1,4 +1,4 @@
-from typing import Final, Dict
+from typing import Dict, Final
# Callback data constants
CALLBACK_PUBLISH = "publish"
diff --git a/helper_bot/handlers/callback/dependency_factory.py b/helper_bot/handlers/callback/dependency_factory.py
index d175608..ec3f563 100644
--- a/helper_bot/handlers/callback/dependency_factory.py
+++ b/helper_bot/handlers/callback/dependency_factory.py
@@ -1,10 +1,11 @@
from typing import Callable
+
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.fsm.context import FSMContext
-
from helper_bot.utils.base_dependency_factory import get_global_instance
-from .services import PostPublishService, BanService
+
+from .services import BanService, PostPublishService
def get_post_publish_service() -> PostPublishService:
@@ -14,7 +15,8 @@ def get_post_publish_service() -> PostPublishService:
db = bdf.get_db()
settings = bdf.settings
s3_storage = bdf.get_s3_storage()
- return PostPublishService(None, db, settings, s3_storage)
+ scoring_manager = bdf.get_scoring_manager()
+ return PostPublishService(None, db, settings, s3_storage, scoring_manager)
def get_ban_service() -> BanService:
diff --git a/helper_bot/handlers/callback/services.py b/helper_bot/handlers/callback/services.py
index 671c1b5..51d0337 100644
--- a/helper_bot/handlers/callback/services.py
+++ b/helper_bot/handlers/callback/services.py
@@ -1,45 +1,41 @@
-from datetime import datetime, timedelta
import html
-from typing import Dict, Any
+from datetime import datetime, timedelta
+from typing import Any, Dict
-from aiogram import Bot
-from aiogram import types
+from aiogram import Bot, types
from aiogram.types import CallbackQuery
-
-from helper_bot.utils.helper_func import (
- send_text_message, send_photo_message, send_video_message,
- send_video_note_message, send_audio_message, send_voice_message,
- send_media_group_to_channel, delete_user_blacklist, get_text_message
-)
from helper_bot.keyboards.keyboards import create_keyboard_for_ban_reason
-from .exceptions import (
- UserBlockedBotError, PostNotFoundError, UserNotFoundError,
- PublishError, BanError
-)
-from .constants import (
- CONTENT_TYPE_TEXT, CONTENT_TYPE_PHOTO, CONTENT_TYPE_VIDEO,
- CONTENT_TYPE_VIDEO_NOTE, CONTENT_TYPE_AUDIO, CONTENT_TYPE_VOICE,
- CONTENT_TYPE_MEDIA_GROUP, MESSAGE_POST_PUBLISHED, MESSAGE_POST_DECLINED,
- MESSAGE_USER_BANNED_SPAM, ERROR_BOT_BLOCKED
-)
+from helper_bot.utils.helper_func import (delete_user_blacklist,
+ get_text_message, send_audio_message,
+ send_media_group_to_channel,
+ send_photo_message,
+ send_text_message,
+ send_video_message,
+ send_video_note_message,
+ send_voice_message)
+# Local imports - metrics
+from helper_bot.utils.metrics import (db_query_time, track_errors,
+ track_media_processing, track_time)
from logs.custom_logger import logger
-# Local imports - metrics
-from helper_bot.utils.metrics import (
- track_media_processing,
- track_time,
- track_errors,
- db_query_time
-)
+from .constants import (CONTENT_TYPE_AUDIO, CONTENT_TYPE_MEDIA_GROUP,
+ CONTENT_TYPE_PHOTO, CONTENT_TYPE_TEXT,
+ CONTENT_TYPE_VIDEO, CONTENT_TYPE_VIDEO_NOTE,
+ CONTENT_TYPE_VOICE, ERROR_BOT_BLOCKED,
+ MESSAGE_POST_DECLINED, MESSAGE_POST_PUBLISHED,
+ MESSAGE_USER_BANNED_SPAM)
+from .exceptions import (BanError, PostNotFoundError, PublishError,
+ UserBlockedBotError, UserNotFoundError)
class PostPublishService:
- def __init__(self, bot: Bot, db, settings: Dict[str, Any], s3_storage=None):
+ def __init__(self, bot: Bot, db, settings: Dict[str, Any], s3_storage=None, scoring_manager=None):
# bot может быть None - в этом случае используем бота из контекста сообщения
self.bot = bot
self.db = db
self.settings = settings
self.s3_storage = s3_storage
+ self.scoring_manager = scoring_manager
self.group_for_posts = settings['Telegram']['group_for_posts']
self.main_public = settings['Telegram']['main_public']
self.important_logs = settings['Telegram']['important_logs']
@@ -397,6 +393,9 @@ class PostPublishService:
async def _decline_single_post(self, call: CallbackQuery) -> None:
"""Отклонение одиночного поста"""
author_id = await self._get_author_id(call.message.message_id)
+
+ # Обучаем RAG на отклоненном посте перед удалением
+ await self._train_on_declined(call.message.message_id)
updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "declined")
if updated_rows == 0:
@@ -490,6 +489,9 @@ class PostPublishService:
@track_errors("post_publish_service", "_delete_post_and_notify_author")
async def _delete_post_and_notify_author(self, call: CallbackQuery, author_id: int) -> None:
"""Удаление поста и уведомление автора"""
+ # Получаем текст поста для обучения RAG перед удалением
+ await self._train_on_published(call.message.message_id)
+
await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id)
try:
@@ -498,6 +500,32 @@ class PostPublishService:
if str(e) == ERROR_BOT_BLOCKED:
raise UserBlockedBotError("Пользователь заблокировал бота")
raise
+
+ async def _train_on_published(self, message_id: int) -> None:
+ """Обучает RAG на опубликованном посте."""
+ if not self.scoring_manager:
+ return
+
+ try:
+ text = await self.db.get_post_text_by_message_id(message_id)
+ if text and text.strip() and text != "^":
+ await self.scoring_manager.on_post_published(text)
+ logger.debug(f"RAG обучен на опубликованном посте: {message_id}")
+ except Exception as e:
+ logger.error(f"Ошибка обучения RAG на опубликованном посте {message_id}: {e}")
+
+ async def _train_on_declined(self, message_id: int) -> None:
+ """Обучает RAG на отклоненном посте."""
+ if not self.scoring_manager:
+ return
+
+ try:
+ text = await self.db.get_post_text_by_message_id(message_id)
+ if text and text.strip() and text != "^":
+ await self.scoring_manager.on_post_declined(text)
+ logger.debug(f"RAG обучен на отклоненном посте: {message_id}")
+ except Exception as e:
+ logger.error(f"Ошибка обучения RAG на отклоненном посте {message_id}: {e}")
@track_time("_delete_media_group_and_notify_author", "post_publish_service")
@track_errors("post_publish_service", "_delete_media_group_and_notify_author")
@@ -589,6 +617,20 @@ class BanService:
ban_author=ban_author_id,
)
+ # Обновляем статус поста на declined
+ if call.message.text == CONTENT_TYPE_MEDIA_GROUP:
+ # Для медиагруппы обновляем статус по helper_message_id
+ updated_rows = await self.db.update_status_for_media_group_by_helper_id(
+ call.message.message_id, "declined"
+ )
+ if updated_rows == 0:
+ logger.warning(f"Не удалось обновить статус медиагруппы helper_message_id={call.message.message_id} на 'declined'")
+ else:
+ # Для одиночного поста обновляем статус по message_id
+ updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "declined")
+ if updated_rows == 0:
+ logger.warning(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'declined'")
+
await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id)
date_str = (current_date + timedelta(days=7)).strftime("%d.%m.%Y %H:%M")
diff --git a/helper_bot/handlers/group/__init__.py b/helper_bot/handlers/group/__init__.py
index 7c42b67..c78eba7 100644
--- a/helper_bot/handlers/group/__init__.py
+++ b/helper_bot/handlers/group/__init__.py
@@ -1,28 +1,13 @@
"""Group handlers package for Telegram bot"""
# Local imports - main components
-from .group_handlers import (
- group_router,
- create_group_handlers,
- GroupHandlers
-)
-
-# Local imports - services
-from .services import (
- AdminReplyService,
- DatabaseProtocol
-)
-
# Local imports - constants and utilities
-from .constants import (
- FSM_STATES,
- ERROR_MESSAGES
-)
-from .exceptions import (
- NoReplyToMessageError,
- UserNotFoundError
-)
+from .constants import ERROR_MESSAGES, FSM_STATES
from .decorators import error_handler
+from .exceptions import NoReplyToMessageError, UserNotFoundError
+from .group_handlers import GroupHandlers, create_group_handlers, group_router
+# Local imports - services
+from .services import AdminReplyService, DatabaseProtocol
__all__ = [
# Main components
diff --git a/helper_bot/handlers/group/constants.py b/helper_bot/handlers/group/constants.py
index 8f16169..96446f2 100644
--- a/helper_bot/handlers/group/constants.py
+++ b/helper_bot/handlers/group/constants.py
@@ -1,6 +1,6 @@
"""Constants for group handlers"""
-from typing import Final, Dict
+from typing import Dict, Final
# FSM States
FSM_STATES: Final[Dict[str, str]] = {
diff --git a/helper_bot/handlers/group/decorators.py b/helper_bot/handlers/group/decorators.py
index e408969..8cb0d3a 100644
--- a/helper_bot/handlers/group/decorators.py
+++ b/helper_bot/handlers/group/decorators.py
@@ -6,7 +6,6 @@ from typing import Any, Callable
# Third-party imports
from aiogram import types
-
# Local imports
from logs.custom_logger import logger
@@ -22,7 +21,8 @@ def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
try:
message = next((arg for arg in args if isinstance(arg, types.Message)), None)
if message and hasattr(message, 'bot'):
- from helper_bot.utils.base_dependency_factory import get_global_instance
+ from helper_bot.utils.base_dependency_factory import \
+ get_global_instance
bdf = get_global_instance()
important_logs = bdf.settings['Telegram']['important_logs']
await message.bot.send_message(
diff --git a/helper_bot/handlers/group/group_handlers.py b/helper_bot/handlers/group/group_handlers.py
index 4e82227..8d14db8 100644
--- a/helper_bot/handlers/group/group_handlers.py
+++ b/helper_bot/handlers/group/group_handlers.py
@@ -3,26 +3,20 @@
# Third-party imports
from aiogram import Router, types
from aiogram.fsm.context import FSMContext
-
# Local imports - filters
from database.async_db import AsyncBotDB
from helper_bot.filters.main import ChatTypeFilter
-
-# Local imports - modular components
-from .constants import FSM_STATES, ERROR_MESSAGES
-from .services import AdminReplyService
-from .decorators import error_handler
-from .exceptions import UserNotFoundError
-
+# Local imports - metrics
+from helper_bot.utils.metrics import metrics, track_errors, track_time
# Local imports - utilities
from logs.custom_logger import logger
-# Local imports - metrics
-from helper_bot.utils.metrics import (
- metrics,
- track_time,
- track_errors
-)
+# Local imports - modular components
+from .constants import ERROR_MESSAGES, FSM_STATES
+from .decorators import error_handler
+from .exceptions import UserNotFoundError
+from .services import AdminReplyService
+
class GroupHandlers:
"""Main handler class for group messages"""
@@ -102,8 +96,8 @@ def init_legacy_router():
"""Initialize legacy router with global dependencies"""
global group_router
- from helper_bot.utils.base_dependency_factory import get_global_instance
from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat
+ from helper_bot.utils.base_dependency_factory import get_global_instance
bdf = get_global_instance()
#TODO: поменять архитектуру и подключить правильный BotDB
diff --git a/helper_bot/handlers/group/services.py b/helper_bot/handlers/group/services.py
index 81c94c2..320f466 100644
--- a/helper_bot/handlers/group/services.py
+++ b/helper_bot/handlers/group/services.py
@@ -1,22 +1,17 @@
"""Service classes for group handlers"""
# Standard library imports
-from typing import Protocol, Optional
+from typing import Optional, Protocol
# Third-party imports
from aiogram import types
-
# Local imports
from helper_bot.utils.helper_func import send_text_message
-from .exceptions import NoReplyToMessageError, UserNotFoundError
+# Local imports - metrics
+from helper_bot.utils.metrics import db_query_time, track_errors, track_time
from logs.custom_logger import logger
-# Local imports - metrics
-from helper_bot.utils.metrics import (
- track_time,
- track_errors,
- db_query_time
-)
+from .exceptions import NoReplyToMessageError, UserNotFoundError
class DatabaseProtocol(Protocol):
diff --git a/helper_bot/handlers/private/__init__.py b/helper_bot/handlers/private/__init__.py
index b1bea9f..a8e44f7 100644
--- a/helper_bot/handlers/private/__init__.py
+++ b/helper_bot/handlers/private/__init__.py
@@ -1,27 +1,13 @@
"""Private handlers package for Telegram bot"""
# Local imports - main components
-from .private_handlers import (
- private_router,
- create_private_handlers,
- PrivateHandlers
-)
-
-# Local imports - services
-from .services import (
- BotSettings,
- UserService,
- PostService,
- StickerService
-)
-
# Local imports - constants and utilities
-from .constants import (
- FSM_STATES,
- BUTTON_TEXTS,
- ERROR_MESSAGES
-)
+from .constants import BUTTON_TEXTS, ERROR_MESSAGES, FSM_STATES
from .decorators import error_handler
+from .private_handlers import (PrivateHandlers, create_private_handlers,
+ private_router)
+# Local imports - services
+from .services import BotSettings, PostService, StickerService, UserService
__all__ = [
# Main components
diff --git a/helper_bot/handlers/private/constants.py b/helper_bot/handlers/private/constants.py
index 344e69b..09ee96f 100644
--- a/helper_bot/handlers/private/constants.py
+++ b/helper_bot/handlers/private/constants.py
@@ -1,6 +1,6 @@
"""Constants for private handlers"""
-from typing import Final, Dict
+from typing import Dict, Final
# FSM States
FSM_STATES: Final[Dict[str, str]] = {
diff --git a/helper_bot/handlers/private/decorators.py b/helper_bot/handlers/private/decorators.py
index 3b7e4b2..2905664 100644
--- a/helper_bot/handlers/private/decorators.py
+++ b/helper_bot/handlers/private/decorators.py
@@ -6,7 +6,6 @@ from typing import Any, Callable
# Third-party imports
from aiogram import types
-
# Local imports
from logs.custom_logger import logger
@@ -22,7 +21,8 @@ def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
try:
message = next((arg for arg in args if isinstance(arg, types.Message)), None)
if message and hasattr(message, 'bot'):
- from helper_bot.utils.base_dependency_factory import get_global_instance
+ from helper_bot.utils.base_dependency_factory import \
+ get_global_instance
bdf = get_global_instance()
important_logs = bdf.settings['Telegram']['important_logs']
await message.bot.send_message(
diff --git a/helper_bot/handlers/private/private_handlers.py b/helper_bot/handlers/private/private_handlers.py
index 1974cee..f34af93 100644
--- a/helper_bot/handlers/private/private_handlers.py
+++ b/helper_bot/handlers/private/private_handlers.py
@@ -5,37 +5,28 @@ import asyncio
from datetime import datetime
# Third-party imports
-from aiogram import types, Router, F
+from aiogram import F, Router, types
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
-
# Local imports - filters and middlewares
from database.async_db import AsyncBotDB
from helper_bot.filters.main import ChatTypeFilter
+# Local imports - utilities
+from helper_bot.keyboards import (get_reply_keyboard,
+ get_reply_keyboard_for_post)
+from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat
from helper_bot.middlewares.album_middleware import AlbumMiddleware
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
-
-# Local imports - utilities
-from helper_bot.keyboards import get_reply_keyboard, get_reply_keyboard_for_post
-from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat
from helper_bot.utils import messages
-from helper_bot.utils.helper_func import (
- get_first_name,
- update_user_info,
- check_user_emoji
-)
-
+from helper_bot.utils.helper_func import (check_user_emoji, get_first_name,
+ update_user_info)
# Local imports - metrics
-from helper_bot.utils.metrics import (
- track_time,
- track_errors,
- db_query_time
-)
+from helper_bot.utils.metrics import db_query_time, track_errors, track_time
# Local imports - modular components
-from .constants import FSM_STATES, BUTTON_TEXTS, ERROR_MESSAGES
-from .services import BotSettings, UserService, PostService, StickerService
+from .constants import BUTTON_TEXTS, ERROR_MESSAGES, FSM_STATES
from .decorators import error_handler
+from .services import BotSettings, PostService, StickerService, UserService
# Expose sleep for tests (tests patch helper_bot.handlers.private.private_handlers.sleep)
sleep = asyncio.sleep
@@ -44,11 +35,11 @@ sleep = asyncio.sleep
class PrivateHandlers:
"""Main handler class for private messages"""
- def __init__(self, db: AsyncBotDB, settings: BotSettings, s3_storage=None):
+ def __init__(self, db: AsyncBotDB, settings: BotSettings, s3_storage=None, scoring_manager=None):
self.db = db
self.settings = settings
self.user_service = UserService(db, settings)
- self.post_service = PostService(db, settings, s3_storage)
+ self.post_service = PostService(db, settings, s3_storage, scoring_manager)
self.sticker_service = StickerService(settings)
self.router = Router()
@@ -156,43 +147,39 @@ class PrivateHandlers:
@track_errors("private_handlers", "suggest_router")
@track_time("suggest_router", "private_handlers")
async def suggest_router(self, message: types.Message, state: FSMContext, album: list = None, **kwargs):
- """Handle post submission in suggest state"""
+ """Handle post submission in suggest state - сразу отвечает пользователю, обработка в фоне"""
+ # Сразу отвечаем пользователю
+ markup_for_user = await get_reply_keyboard(self.db, message.from_user.id)
+ success_send_message = messages.get_message(get_first_name(message), 'SUCCESS_SEND_MESSAGE')
+ await message.answer(success_send_message, reply_markup=markup_for_user)
+ await state.set_state(FSM_STATES["START"])
+
# Проверяем, есть ли механизм для получения полной медиагруппы (для медиагрупп)
album_getter = kwargs.get("album_getter")
- if album_getter and message.media_group_id:
- # Это медиагруппа - сразу отвечаем пользователю, обработку делаем в фоне
- markup_for_user = await get_reply_keyboard(self.db, message.from_user.id)
- success_send_message = messages.get_message(get_first_name(message), 'SUCCESS_SEND_MESSAGE')
- await message.answer(success_send_message, reply_markup=markup_for_user)
- await state.set_state(FSM_STATES["START"])
-
- # В фоне ждем полную медиагруппу и обрабатываем пост
- async def process_media_group_background():
- try:
- # Ждем полную медиагруппу
+ # В фоне обрабатываем пост
+ async def process_post_background():
+ try:
+ # Обновляем активность пользователя
+ await self.user_service.update_user_activity(message.from_user.id)
+
+ # Логируем сообщение (только для одиночных сообщений, не медиагрупп)
+ if message.media_group_id is None:
+ await self.user_service.log_user_message(message)
+
+ # Для медиагрупп ждем полную медиагруппу
+ if album_getter and message.media_group_id:
full_album = await album_getter.get_album(timeout=10.0)
- if not full_album:
- return
-
- # Обрабатываем пост с полной медиагруппой
- await self.user_service.update_user_activity(message.from_user.id)
- await self.post_service.process_post(message, full_album)
- except Exception as e:
- from logs.custom_logger import logger
- logger.error(f"Ошибка при фоновой обработке медиагруппы: {e}")
-
- asyncio.create_task(process_media_group_background())
- else:
- # Обычное сообщение или медиагруппа уже собрана - обрабатываем синхронно
- await self.user_service.update_user_activity(message.from_user.id)
- if message.media_group_id is None:
- await self.user_service.log_user_message(message)
- await self.post_service.process_post(message, album)
- markup_for_user = await get_reply_keyboard(self.db, message.from_user.id)
- success_send_message = messages.get_message(get_first_name(message), 'SUCCESS_SEND_MESSAGE')
- await message.answer(success_send_message, reply_markup=markup_for_user)
- await state.set_state(FSM_STATES["START"])
+ if full_album:
+ await self.post_service.process_post(message, full_album)
+ else:
+ # Обычное сообщение или медиагруппа уже собрана
+ await self.post_service.process_post(message, album)
+ except Exception as e:
+ from logs.custom_logger import logger
+ logger.error(f"Ошибка при фоновой обработке поста: {e}")
+
+ asyncio.create_task(process_post_background())
@error_handler
@track_errors("private_handlers", "stickers")
@@ -249,18 +236,24 @@ class PrivateHandlers:
# Factory function to create handlers with dependencies
-def create_private_handlers(db: AsyncBotDB, settings: BotSettings, s3_storage=None) -> PrivateHandlers:
+def create_private_handlers(db: AsyncBotDB, settings: BotSettings, s3_storage=None, scoring_manager=None) -> PrivateHandlers:
"""Create private handlers instance with dependencies"""
- return PrivateHandlers(db, settings, s3_storage)
+ return PrivateHandlers(db, settings, s3_storage, scoring_manager)
# Legacy router for backward compatibility
private_router = Router()
+# Флаг инициализации для защиты от повторного вызова
+_legacy_router_initialized = False
+
# Initialize with global dependencies (for backward compatibility)
def init_legacy_router():
"""Initialize legacy router with global dependencies"""
- global private_router
+ global private_router, _legacy_router_initialized
+
+ if _legacy_router_initialized:
+ return
from helper_bot.utils.base_dependency_factory import get_global_instance
@@ -278,11 +271,13 @@ def init_legacy_router():
db = bdf.get_db()
s3_storage = bdf.get_s3_storage()
- handlers = create_private_handlers(db, settings, s3_storage)
+ scoring_manager = bdf.get_scoring_manager()
+ handlers = create_private_handlers(db, settings, s3_storage, scoring_manager)
# Instead of trying to copy handlers, we'll use the new router directly
# This maintains backward compatibility while using the new architecture
private_router = handlers.router
+ _legacy_router_initialized = True
# Initialize legacy router
init_legacy_router()
diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py
index ec233c3..5943d34 100644
--- a/helper_bot/handlers/private/services.py
+++ b/helper_bot/handlers/private/services.py
@@ -1,46 +1,31 @@
"""Service classes for private handlers"""
# Standard library imports
-import random
import asyncio
import html
+import random
+from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
-from typing import Dict, Callable, Any, Protocol, Union
-from dataclasses import dataclass
+from typing import Any, Callable, Dict, Protocol, Union
# Third-party imports
from aiogram import types
from aiogram.types import FSInputFile
from database.models import TelegramPost, User
-from logs.custom_logger import logger
-
+from helper_bot.keyboards import get_reply_keyboard_for_post
# Local imports - utilities
from helper_bot.utils.helper_func import (
- get_first_name,
- get_text_message,
- determine_anonymity,
- send_text_message,
- send_photo_message,
- send_media_group_message_to_private_chat,
- prepare_media_group_from_middlewares,
- send_video_message,
- send_video_note_message,
- send_audio_message,
- send_voice_message,
- add_in_db_media,
- check_username_and_full_name
-)
-from helper_bot.keyboards import get_reply_keyboard_for_post
-
+ add_in_db_media, check_username_and_full_name, determine_anonymity,
+ get_first_name, get_text_message, prepare_media_group_from_middlewares,
+ send_audio_message, send_media_group_message_to_private_chat,
+ send_photo_message, send_text_message, send_video_message,
+ send_video_note_message, send_voice_message)
# Local imports - metrics
-from helper_bot.utils.metrics import (
- track_time,
- track_errors,
- db_query_time,
- track_media_processing,
- track_file_operations
-)
+from helper_bot.utils.metrics import (db_query_time, track_errors,
+ track_file_operations,
+ track_media_processing, track_time)
+from logs.custom_logger import logger
class DatabaseProtocol(Protocol):
@@ -89,7 +74,8 @@ class UserService:
"""Ensure user exists in database, create if needed with metrics tracking"""
user_id = message.from_user.id
full_name = message.from_user.full_name
- username = message.from_user.username or "private_username"
+ # Сохраняем только реальный username, если его нет - сохраняем None/пустую строку
+ username = message.from_user.username
first_name = get_first_name(message)
is_bot = message.from_user.is_bot
language_code = message.from_user.language_code
@@ -100,7 +86,7 @@ class UserService:
user_id=user_id,
first_name=first_name,
full_name=full_name,
- username=username,
+ username=username, # Может быть None - это нормально
is_bot=is_bot,
language_code=language_code,
emoji="",
@@ -119,6 +105,7 @@ class UserService:
if is_need_update:
await self.db.update_user_info(user_id, username, full_name)
safe_full_name = html.escape(full_name) if full_name else "Неизвестный пользователь"
+ # Для отображения используем подстановочное значение, но в БД сохраняем только реальный username
safe_username = html.escape(username) if username else "Без никнейма"
await message.answer(
@@ -143,10 +130,11 @@ class UserService:
class PostService:
"""Service for post-related operations"""
- def __init__(self, db: DatabaseProtocol, settings: BotSettings, s3_storage=None) -> None:
+ def __init__(self, db: DatabaseProtocol, settings: BotSettings, s3_storage=None, scoring_manager=None) -> None:
self.db = db
self.settings = settings
self.s3_storage = s3_storage
+ self.scoring_manager = scoring_manager
async def _save_media_background(self, sent_message: types.Message, bot_db: Any, s3_storage) -> None:
"""Сохраняет медиа в фоне, чтобы не блокировать ответ пользователю"""
@@ -157,18 +145,282 @@ class PostService:
except Exception as e:
logger.error(f"_save_media_background: Ошибка при сохранении медиа для поста {sent_message.message_id}: {e}")
+ async def _get_scores(self, text: str) -> tuple:
+ """
+ Получает скоры для текста поста.
+
+ Returns:
+ Tuple (deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json)
+ """
+ if not self.scoring_manager or not text or not text.strip():
+ return None, None, None, None, None
+
+ try:
+ scores = await self.scoring_manager.score_post(text)
+
+ # Формируем JSON для сохранения в БД
+ import json
+ ml_scores_json = json.dumps(scores.to_json_dict()) if scores.has_any_score() else None
+
+ # Получаем данные от RAG
+ rag_confidence = scores.rag.confidence if scores.rag else None
+ rag_score_pos_only = scores.rag.metadata.get("rag_score_pos_only") if scores.rag else None
+
+ return scores.deepseek_score, scores.rag_score, rag_confidence, rag_score_pos_only, ml_scores_json
+ except Exception as e:
+ logger.error(f"PostService: Ошибка получения скоров: {e}")
+ return None, None, None, None, None
+
+ async def _save_scores_background(self, message_id: int, ml_scores_json: str) -> None:
+ """Сохраняет скоры в БД в фоне."""
+ if ml_scores_json:
+ try:
+ await self.db.update_ml_scores(message_id, ml_scores_json)
+ except Exception as e:
+ logger.error(f"PostService: Ошибка сохранения скоров для {message_id}: {e}")
+
+ async def _get_scores_with_error_handling(self, text: str) -> tuple:
+ """
+ Получает скоры для текста поста с обработкой ошибок.
+
+ Returns:
+ Tuple (deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json, error_message)
+ error_message будет None если все ок, или строка с описанием ошибки
+ """
+ if not self.scoring_manager:
+ # Скоры выключены в .env - это нормально
+ return None, None, None, None, None, None
+
+ if not text or not text.strip():
+ return None, None, None, None, None, None
+
+ try:
+ scores = await self.scoring_manager.score_post(text)
+
+ # Формируем JSON для сохранения в БД
+ import json
+ ml_scores_json = json.dumps(scores.to_json_dict()) if scores.has_any_score() else None
+
+ # Получаем данные от RAG
+ rag_confidence = scores.rag.confidence if scores.rag else None
+ rag_score_pos_only = scores.rag.metadata.get("rag_score_pos_only") if scores.rag else None
+
+ return scores.deepseek_score, scores.rag_score, rag_confidence, rag_score_pos_only, ml_scores_json, None
+ except Exception as e:
+ logger.error(f"PostService: Ошибка получения скоров: {e}")
+ # Возвращаем частичные скоры если есть, или сообщение об ошибке
+ error_message = "Не удалось рассчитать скоры"
+ return None, None, None, None, None, error_message
+
+ @track_time("_process_post_background", "post_service")
+ @track_errors("post_service", "_process_post_background")
+ async def _process_post_background(
+ self,
+ message: types.Message,
+ first_name: str,
+ content_type: str,
+ album: Union[list, None] = None
+ ) -> None:
+ """
+ Обрабатывает пост в фоне: получает скоры, отправляет в группу модерации, сохраняет в БД.
+
+ Args:
+ message: Сообщение от пользователя
+ first_name: Имя пользователя
+ content_type: Тип контента ('text', 'photo', 'video', 'audio', 'voice', 'video_note', 'media_group')
+ album: Список сообщений медиагруппы (только для media_group)
+ """
+ try:
+ # Определяем исходный текст для скоринга и определения анонимности
+ original_raw_text = ""
+ if content_type == "text":
+ original_raw_text = message.text or ""
+ elif content_type == "media_group":
+ original_raw_text = album[0].caption or "" if album and album[0].caption else ""
+ else:
+ original_raw_text = message.caption or ""
+
+ # Получаем скоры с обработкой ошибок
+ deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json, error_message = \
+ await self._get_scores_with_error_handling(original_raw_text)
+
+ # Формируем текст для поста (с сообщением об ошибке если есть)
+ text_for_post = original_raw_text
+ if error_message:
+ # Для текстовых постов добавляем в конец текста
+ if content_type == "text":
+ text_for_post = f"{original_raw_text}\n\n⚠️ {error_message}"
+ # Для медиа добавляем в caption
+ elif content_type in ("photo", "video", "audio") and original_raw_text:
+ text_for_post = f"{original_raw_text}\n\n⚠️ {error_message}"
+
+ # Формируем текст/caption с учетом скоров
+ post_text = ""
+ if text_for_post or content_type == "text":
+ post_text = get_text_message(
+ text_for_post.lower() if text_for_post else "",
+ first_name,
+ message.from_user.username,
+ deepseek_score=deepseek_score,
+ rag_score=rag_score,
+ rag_confidence=rag_confidence,
+ rag_score_pos_only=rag_score_pos_only,
+ )
+
+ # Определяем анонимность по исходному тексту (без сообщения об ошибке)
+ is_anonymous = determine_anonymity(original_raw_text)
+
+ markup = get_reply_keyboard_for_post()
+ sent_message = None
+
+ # Отправляем пост в группу модерации в зависимости от типа
+ if content_type == "text":
+ sent_message = await send_text_message(
+ self.settings.group_for_posts, message, post_text, markup
+ )
+ elif content_type == "photo":
+ sent_message = await send_photo_message(
+ self.settings.group_for_posts, message, message.photo[-1].file_id, post_text, markup
+ )
+ elif content_type == "video":
+ sent_message = await send_video_message(
+ self.settings.group_for_posts, message, message.video.file_id, post_text, markup
+ )
+ elif content_type == "audio":
+ sent_message = await send_audio_message(
+ self.settings.group_for_posts, message, message.audio.file_id, post_text, markup
+ )
+ elif content_type == "voice":
+ sent_message = await send_voice_message(
+ self.settings.group_for_posts, message, message.voice.file_id, markup
+ )
+ elif content_type == "video_note":
+ sent_message = await send_video_note_message(
+ self.settings.group_for_posts, message, message.video_note.file_id, markup
+ )
+ elif content_type == "media_group":
+ # Для медиагруппы используем специальную обработку
+ # Передаем ml_scores_json для сохранения в БД
+ await self._process_media_group_background(
+ message, album, first_name, post_text, is_anonymous, original_raw_text, ml_scores_json
+ )
+ return
+ else:
+ logger.error(f"PostService: Неподдерживаемый тип контента: {content_type}")
+ return
+
+ if not sent_message:
+ logger.error(f"PostService: Не удалось отправить пост типа {content_type}")
+ return
+
+ # Сохраняем пост в БД (сохраняем исходный текст, без сообщения об ошибке)
+ post = TelegramPost(
+ message_id=sent_message.message_id,
+ text=original_raw_text,
+ author_id=message.from_user.id,
+ created_at=int(datetime.now().timestamp()),
+ is_anonymous=is_anonymous
+ )
+ await self.db.add_post(post)
+
+ # Сохраняем медиа и скоры в фоне
+ if content_type in ("photo", "video", "audio", "voice", "video_note"):
+ asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
+
+ if ml_scores_json:
+ asyncio.create_task(self._save_scores_background(sent_message.message_id, ml_scores_json))
+
+ except Exception as e:
+ logger.error(f"PostService: Критическая ошибка в _process_post_background для {content_type}: {e}")
+
+ async def _process_media_group_background(
+ self,
+ message: types.Message,
+ album: list,
+ first_name: str,
+ post_caption: str,
+ is_anonymous: bool,
+ original_raw_text: str,
+ ml_scores_json: str = None
+ ) -> None:
+ """Обрабатывает медиагруппу в фоне"""
+ try:
+ media_group = await prepare_media_group_from_middlewares(album, post_caption)
+
+ media_group_message_ids = await send_media_group_message_to_private_chat(
+ self.settings.group_for_posts, message, media_group, self.db, None, self.s3_storage
+ )
+
+ main_post_id = media_group_message_ids[-1]
+
+ main_post = TelegramPost(
+ message_id=main_post_id,
+ text=original_raw_text,
+ author_id=message.from_user.id,
+ created_at=int(datetime.now().timestamp()),
+ is_anonymous=is_anonymous
+ )
+ await self.db.add_post(main_post)
+
+ # Сохраняем скоры в фоне (если они были получены)
+ if ml_scores_json:
+ asyncio.create_task(self._save_scores_background(main_post_id, ml_scores_json))
+
+ for msg_id in media_group_message_ids:
+ await self.db.add_message_link(main_post_id, msg_id)
+
+ await asyncio.sleep(0.2)
+
+ markup = get_reply_keyboard_for_post()
+ helper_message = await send_text_message(
+ self.settings.group_for_posts,
+ message,
+ "^",
+ markup
+ )
+ helper_message_id = helper_message.message_id
+
+ helper_post = TelegramPost(
+ message_id=helper_message_id,
+ text="^",
+ author_id=message.from_user.id,
+ helper_text_message_id=main_post_id,
+ created_at=int(datetime.now().timestamp())
+ )
+ await self.db.add_post(helper_post)
+
+ await self.db.update_helper_message(
+ message_id=main_post_id,
+ helper_message_id=helper_message_id
+ )
+ except Exception as e:
+ logger.error(f"PostService: Ошибка в _process_media_group_background: {e}")
+
@track_time("handle_text_post", "post_service")
@track_errors("post_service", "handle_text_post")
@db_query_time("handle_text_post", "posts", "insert")
async def handle_text_post(self, message: types.Message, first_name: str) -> None:
"""Handle text post submission"""
- post_text = get_text_message(message.text.lower(), first_name, message.from_user.username)
+ raw_text = message.text or ""
+
+ # Получаем скоры для текста
+ deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_text)
+
+ # Формируем текст с учетом скоров
+ post_text = get_text_message(
+ message.text.lower(),
+ first_name,
+ message.from_user.username,
+ deepseek_score=deepseek_score,
+ rag_score=rag_score,
+ rag_confidence=rag_confidence,
+ rag_score_pos_only=rag_score_pos_only,
+ )
markup = get_reply_keyboard_for_post()
sent_message = await send_text_message(self.settings.group_for_posts, message, post_text, markup)
- # Сохраняем сырой текст и определяем анонимность
- raw_text = message.text or ""
+ # Определяем анонимность
is_anonymous = determine_anonymity(raw_text)
post = TelegramPost(
@@ -179,23 +431,39 @@ class PostService:
is_anonymous=is_anonymous
)
await self.db.add_post(post)
+
+ # Сохраняем скоры в фоне
+ if ml_scores_json:
+ asyncio.create_task(self._save_scores_background(sent_message.message_id, ml_scores_json))
@track_time("handle_photo_post", "post_service")
@track_errors("post_service", "handle_photo_post")
@db_query_time("handle_photo_post", "posts", "insert")
async def handle_photo_post(self, message: types.Message, first_name: str) -> None:
"""Handle photo post submission"""
+ raw_caption = message.caption or ""
+
+ # Получаем скоры для текста
+ deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_caption)
+
post_caption = ""
if message.caption:
- post_caption = get_text_message(message.caption.lower(), first_name, message.from_user.username)
+ post_caption = get_text_message(
+ message.caption.lower(),
+ first_name,
+ message.from_user.username,
+ deepseek_score=deepseek_score,
+ rag_score=rag_score,
+ rag_confidence=rag_confidence,
+ rag_score_pos_only=rag_score_pos_only,
+ )
markup = get_reply_keyboard_for_post()
sent_message = await send_photo_message(
self.settings.group_for_posts, message, message.photo[-1].file_id, post_caption, markup
)
- # Сохраняем сырой caption и определяем анонимность
- raw_caption = message.caption or ""
+ # Определяем анонимность
is_anonymous = determine_anonymity(raw_caption)
post = TelegramPost(
@@ -206,25 +474,40 @@ class PostService:
is_anonymous=is_anonymous
)
await self.db.add_post(post)
- # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
+
+ # Сохраняем медиа и скоры в фоне
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
+ if ml_scores_json:
+ asyncio.create_task(self._save_scores_background(sent_message.message_id, ml_scores_json))
@track_time("handle_video_post", "post_service")
@track_errors("post_service", "handle_video_post")
@db_query_time("handle_video_post", "posts", "insert")
async def handle_video_post(self, message: types.Message, first_name: str) -> None:
"""Handle video post submission"""
+ raw_caption = message.caption or ""
+
+ # Получаем скоры для текста
+ deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_caption)
+
post_caption = ""
if message.caption:
- post_caption = get_text_message(message.caption.lower(), first_name, message.from_user.username)
+ post_caption = get_text_message(
+ message.caption.lower(),
+ first_name,
+ message.from_user.username,
+ deepseek_score=deepseek_score,
+ rag_score=rag_score,
+ rag_confidence=rag_confidence,
+ rag_score_pos_only=rag_score_pos_only,
+ )
markup = get_reply_keyboard_for_post()
sent_message = await send_video_message(
self.settings.group_for_posts, message, message.video.file_id, post_caption, markup
)
- # Сохраняем сырой caption и определяем анонимность
- raw_caption = message.caption or ""
+ # Определяем анонимность
is_anonymous = determine_anonymity(raw_caption)
post = TelegramPost(
@@ -235,8 +518,11 @@ class PostService:
is_anonymous=is_anonymous
)
await self.db.add_post(post)
- # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
+
+ # Сохраняем медиа и скоры в фоне
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
+ if ml_scores_json:
+ asyncio.create_task(self._save_scores_background(sent_message.message_id, ml_scores_json))
@track_time("handle_video_note_post", "post_service")
@track_errors("post_service", "handle_video_note_post")
@@ -268,17 +554,29 @@ class PostService:
@db_query_time("handle_audio_post", "posts", "insert")
async def handle_audio_post(self, message: types.Message, first_name: str) -> None:
"""Handle audio post submission"""
+ raw_caption = message.caption or ""
+
+ # Получаем скоры для текста
+ deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_caption)
+
post_caption = ""
if message.caption:
- post_caption = get_text_message(message.caption.lower(), first_name, message.from_user.username)
+ post_caption = get_text_message(
+ message.caption.lower(),
+ first_name,
+ message.from_user.username,
+ deepseek_score=deepseek_score,
+ rag_score=rag_score,
+ rag_confidence=rag_confidence,
+ rag_score_pos_only=rag_score_pos_only,
+ )
markup = get_reply_keyboard_for_post()
sent_message = await send_audio_message(
self.settings.group_for_posts, message, message.audio.file_id, post_caption, markup
)
- # Сохраняем сырой caption и определяем анонимность
- raw_caption = message.caption or ""
+ # Определяем анонимность
is_anonymous = determine_anonymity(raw_caption)
post = TelegramPost(
@@ -289,8 +587,11 @@ class PostService:
is_anonymous=is_anonymous
)
await self.db.add_post(post)
- # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
+
+ # Сохраняем медиа и скоры в фоне
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
+ if ml_scores_json:
+ asyncio.create_task(self._save_scores_background(sent_message.message_id, ml_scores_json))
@track_time("handle_voice_post", "post_service")
@track_errors("post_service", "handle_voice_post")
@@ -325,10 +626,23 @@ class PostService:
"""Handle media group post submission"""
post_caption = " "
raw_caption = ""
+ ml_scores_json = None
if album and album[0].caption:
raw_caption = album[0].caption or ""
- post_caption = get_text_message(album[0].caption.lower(), first_name, message.from_user.username)
+
+ # Получаем скоры для текста
+ deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_caption)
+
+ post_caption = get_text_message(
+ album[0].caption.lower(),
+ first_name,
+ message.from_user.username,
+ deepseek_score=deepseek_score,
+ rag_score=rag_score,
+ rag_confidence=rag_confidence,
+ rag_score_pos_only=rag_score_pos_only,
+ )
is_anonymous = determine_anonymity(raw_caption)
media_group = await prepare_media_group_from_middlewares(album, post_caption)
@@ -348,6 +662,10 @@ class PostService:
)
await self.db.add_post(main_post)
+ # Сохраняем скоры в фоне
+ if ml_scores_json:
+ asyncio.create_task(self._save_scores_background(main_post_id, ml_scores_json))
+
for msg_id in media_group_message_ids:
await self.db.add_message_link(main_post_id, msg_id)
@@ -380,30 +698,19 @@ class PostService:
@track_errors("post_service", "process_post")
@track_media_processing("media_group")
async def process_post(self, message: types.Message, album: Union[list, None] = None) -> None:
- """Process post based on content type"""
+ """
+ Запускает обработку поста в фоне.
+ Не блокирует выполнение - сразу возвращает управление.
+ """
first_name = get_first_name(message)
- if message.media_group_id is not None:
- await self.handle_media_group_post(message, album, first_name)
- return
+ # Определяем тип контента
+ content_type = "media_group" if message.media_group_id is not None else message.content_type
- content_handlers: Dict[str, Callable] = {
- 'text': lambda: self.handle_text_post(message, first_name),
- 'photo': lambda: self.handle_photo_post(message, first_name),
- 'video': lambda: self.handle_video_post(message, first_name),
- 'video_note': lambda: self.handle_video_note_post(message),
- 'audio': lambda: self.handle_audio_post(message, first_name),
- 'voice': lambda: self.handle_voice_post(message)
- }
-
- handler = content_handlers.get(message.content_type)
- if handler:
- await handler()
- else:
- from .constants import ERROR_MESSAGES
- await message.bot.send_message(
- message.chat.id, ERROR_MESSAGES["UNSUPPORTED_CONTENT"]
- )
+ # Запускаем фоновую обработку
+ asyncio.create_task(
+ self._process_post_background(message, first_name, content_type, album)
+ )
class StickerService:
diff --git a/helper_bot/handlers/voice/cleanup_utils.py b/helper_bot/handlers/voice/cleanup_utils.py
index 228ea58..1c4d004 100644
--- a/helper_bot/handlers/voice/cleanup_utils.py
+++ b/helper_bot/handlers/voice/cleanup_utils.py
@@ -1,12 +1,13 @@
"""
Утилиты для очистки и диагностики проблем с голосовыми файлами
"""
-import os
import asyncio
+import os
from pathlib import Path
from typing import List, Tuple
-from logs.custom_logger import logger
+
from helper_bot.handlers.voice.constants import VOICE_USERS_DIR
+from logs.custom_logger import logger
class VoiceFileCleanupUtils:
diff --git a/helper_bot/handlers/voice/constants.py b/helper_bot/handlers/voice/constants.py
index aca69e8..18e5060 100644
--- a/helper_bot/handlers/voice/constants.py
+++ b/helper_bot/handlers/voice/constants.py
@@ -1,4 +1,4 @@
-from typing import Final, Dict
+from typing import Dict, Final
# Voice bot constants
VOICE_BOT_NAME = "voice"
diff --git a/helper_bot/handlers/voice/services.py b/helper_bot/handlers/voice/services.py
index a0b3922..d08ff59 100644
--- a/helper_bot/handlers/voice/services.py
+++ b/helper_bot/handlers/voice/services.py
@@ -1,26 +1,26 @@
-import random
import asyncio
-import traceback
import os
+import random
+import traceback
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Tuple
from aiogram.types import FSInputFile
-
-from helper_bot.handlers.voice.exceptions import VoiceMessageError, AudioProcessingError, DatabaseError, FileOperationError
-from helper_bot.handlers.voice.constants import (
- VOICE_USERS_DIR, STICK_DIR, STICK_PATTERN, STICKER_DELAY,
- MESSAGE_DELAY_1, MESSAGE_DELAY_2, MESSAGE_DELAY_3, MESSAGE_DELAY_4
-)
+from helper_bot.handlers.voice.constants import (MESSAGE_DELAY_1,
+ MESSAGE_DELAY_2,
+ MESSAGE_DELAY_3,
+ MESSAGE_DELAY_4, STICK_DIR,
+ STICK_PATTERN, STICKER_DELAY,
+ VOICE_USERS_DIR)
+from helper_bot.handlers.voice.exceptions import (AudioProcessingError,
+ DatabaseError,
+ FileOperationError,
+ VoiceMessageError)
+# Local imports - metrics
+from helper_bot.utils.metrics import db_query_time, track_errors, track_time
from logs.custom_logger import logger
-# Local imports - metrics
-from helper_bot.utils.metrics import (
- track_time,
- track_errors,
- db_query_time
-)
class VoiceMessage:
"""Модель голосового сообщения"""
diff --git a/helper_bot/handlers/voice/utils.py b/helper_bot/handlers/voice/utils.py
index d3d7a9f..ea64bfe 100644
--- a/helper_bot/handlers/voice/utils.py
+++ b/helper_bot/handlers/voice/utils.py
@@ -1,16 +1,12 @@
-import time
import html
+import time
from datetime import datetime
from typing import Optional
from helper_bot.handlers.voice.exceptions import DatabaseError
+from helper_bot.utils.metrics import db_query_time, track_errors, track_time
from logs.custom_logger import logger
-from helper_bot.utils.metrics import (
- track_time,
- track_errors,
- db_query_time
-)
def format_time_ago(date_from_db: str) -> Optional[str]:
"""Форматировать время с момента последней записи"""
diff --git a/helper_bot/handlers/voice/voice_handler.py b/helper_bot/handlers/voice/voice_handler.py
index 78e1020..f3ed377 100644
--- a/helper_bot/handlers/voice/voice_handler.py
+++ b/helper_bot/handlers/voice/voice_handler.py
@@ -2,33 +2,31 @@ import asyncio
from datetime import datetime
from pathlib import Path
-from aiogram import Router, types, F
-from aiogram.filters import Command, StateFilter, MagicData
+from aiogram import F, Router, types
+from aiogram.filters import Command, MagicData, StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.types import FSInputFile
-
from helper_bot.filters.main import ChatTypeFilter
-from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
-from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
-
-from helper_bot.utils import messages
-from helper_bot.utils.helper_func import get_first_name, update_user_info, check_user_emoji, send_voice_message
-from logs.custom_logger import logger
+from helper_bot.handlers.private.constants import BUTTON_TEXTS, FSM_STATES
from helper_bot.handlers.voice.constants import *
from helper_bot.handlers.voice.services import VoiceBotService
-from helper_bot.handlers.voice.utils import get_last_message_text, validate_voice_message, get_user_emoji_safe
-from helper_bot.keyboards.keyboards import get_main_keyboard, get_reply_keyboard_for_voice
+from helper_bot.handlers.voice.utils import (get_last_message_text,
+ get_user_emoji_safe,
+ validate_voice_message)
from helper_bot.keyboards import get_reply_keyboard
-from helper_bot.handlers.private.constants import FSM_STATES
-from helper_bot.handlers.private.constants import BUTTON_TEXTS
-
+from helper_bot.keyboards.keyboards import (get_main_keyboard,
+ get_reply_keyboard_for_voice)
+from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
+from helper_bot.middlewares.dependencies_middleware import \
+ DependenciesMiddleware
+from helper_bot.utils import messages
+from helper_bot.utils.helper_func import (check_user_emoji, get_first_name,
+ send_voice_message, update_user_info)
# Local imports - metrics
-from helper_bot.utils.metrics import (
- track_time,
- track_errors,
- db_query_time,
- track_file_operations
-)
+from helper_bot.utils.metrics import (db_query_time, track_errors,
+ track_file_operations, track_time)
+from logs.custom_logger import logger
+
class VoiceHandlers:
def __init__(self, db, settings):
diff --git a/helper_bot/keyboards/__init__.py b/helper_bot/keyboards/__init__.py
index 583ba47..868f745 100644
--- a/helper_bot/keyboards/__init__.py
+++ b/helper_bot/keyboards/__init__.py
@@ -1 +1 @@
-from .keyboards import get_reply_keyboard_for_post, get_reply_keyboard
+from .keyboards import get_reply_keyboard, get_reply_keyboard_for_post
diff --git a/helper_bot/keyboards/keyboards.py b/helper_bot/keyboards/keyboards.py
index d941e12..3fd4f3c 100644
--- a/helper_bot/keyboards/keyboards.py
+++ b/helper_bot/keyboards/keyboards.py
@@ -1,11 +1,7 @@
from aiogram import types
-from aiogram.utils.keyboard import ReplyKeyboardBuilder, InlineKeyboardBuilder
-
+from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
# Local imports - metrics
-from helper_bot.utils.metrics import (
- track_time,
- track_errors
-)
+from helper_bot.utils.metrics import track_errors, track_time
def get_reply_keyboard_for_post():
@@ -51,6 +47,9 @@ def get_reply_keyboard_admin():
)
builder.row(
types.KeyboardButton(text="Разбан (список)"),
+ types.KeyboardButton(text="📊 ML Статистика")
+ )
+ builder.row(
types.KeyboardButton(text="Вернуться в бота")
)
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
diff --git a/helper_bot/main.py b/helper_bot/main.py
index 3f7a800..a85ee0a 100644
--- a/helper_bot/main.py
+++ b/helper_bot/main.py
@@ -1,21 +1,24 @@
+import asyncio
+import logging
+from typing import Optional
+
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.fsm.strategy import FSMStrategy
-import logging
-import asyncio
-from typing import Optional
-
from helper_bot.handlers.admin import admin_router
from helper_bot.handlers.callback import callback_router
from helper_bot.handlers.group import group_router
from helper_bot.handlers.private import private_router
from helper_bot.handlers.voice import VoiceHandlers
-from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
-from helper_bot.middlewares.metrics_middleware import MetricsMiddleware, ErrorMetricsMiddleware
+from helper_bot.middlewares.dependencies_middleware import \
+ DependenciesMiddleware
+from helper_bot.middlewares.metrics_middleware import (ErrorMetricsMiddleware,
+ MetricsMiddleware)
from helper_bot.middlewares.rate_limit_middleware import RateLimitMiddleware
-from helper_bot.server_prometheus import start_metrics_server, stop_metrics_server
+from helper_bot.server_prometheus import (start_metrics_server,
+ stop_metrics_server)
async def start_bot_with_retry(bot: Bot, dp: Dispatcher, max_retries: int = 5, base_delay: float = 1.0):
@@ -63,11 +66,22 @@ async def start_bot(bdf):
# Middleware уже добавлены на уровне dispatcher
dp.include_routers(admin_router, private_router, callback_router, group_router, voice_router)
+ # Получаем scoring_manager для использования в shutdown
+ scoring_manager = bdf.get_scoring_manager()
+
# Добавляем обработчик завершения для корректного закрытия
@dp.shutdown()
async def on_shutdown():
logging.info("Bot shutdown initiated, cleaning up resources...")
try:
+ # Закрываем ресурсы ScoringManager
+ if scoring_manager:
+ try:
+ await scoring_manager.close()
+ logging.info("ScoringManager закрыт")
+ except Exception as e:
+ logging.error(f"Ошибка закрытия ScoringManager: {e}")
+
await bot.session.close()
logging.info("Bot session closed successfully")
except Exception as e:
@@ -94,6 +108,14 @@ async def start_bot(bdf):
logging.error(f"❌ Ошибка запуска бота: {e}")
raise
finally:
+ # Закрываем ресурсы ScoringManager перед завершением (на случай если shutdown не сработал)
+ if scoring_manager:
+ try:
+ await scoring_manager.close()
+ logging.info("ScoringManager закрыт в finally")
+ except Exception as e:
+ logging.error(f"Ошибка закрытия ScoringManager в finally: {e}")
+
# Останавливаем метрики сервер при завершении
try:
await stop_metrics_server()
diff --git a/helper_bot/middlewares/album_middleware.py b/helper_bot/middlewares/album_middleware.py
index e190955..57e5ce7 100644
--- a/helper_bot/middlewares/album_middleware.py
+++ b/helper_bot/middlewares/album_middleware.py
@@ -1,5 +1,5 @@
import asyncio
-from typing import Any, Dict, Union, List, Optional
+from typing import Any, Dict, List, Optional, Union
from aiogram import BaseMiddleware
from aiogram.types import Message
diff --git a/helper_bot/middlewares/blacklist_middleware.py b/helper_bot/middlewares/blacklist_middleware.py
index 20aad08..4bcb92d 100644
--- a/helper_bot/middlewares/blacklist_middleware.py
+++ b/helper_bot/middlewares/blacklist_middleware.py
@@ -1,9 +1,9 @@
-from typing import Dict, Any
import html
from datetime import datetime
+from typing import Any, Dict
from aiogram import BaseMiddleware, types
-from aiogram.types import TelegramObject, Message, CallbackQuery
+from aiogram.types import CallbackQuery, Message, TelegramObject
from helper_bot.utils.base_dependency_factory import get_global_instance
from logs.custom_logger import logger
diff --git a/helper_bot/middlewares/dependencies_middleware.py b/helper_bot/middlewares/dependencies_middleware.py
index 3a9f3de..a0329b2 100644
--- a/helper_bot/middlewares/dependencies_middleware.py
+++ b/helper_bot/middlewares/dependencies_middleware.py
@@ -1,7 +1,7 @@
from typing import Any, Dict
+
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
-
from helper_bot.utils.base_dependency_factory import get_global_instance
from logs.custom_logger import logger
diff --git a/helper_bot/middlewares/metrics_middleware.py b/helper_bot/middlewares/metrics_middleware.py
index 2cda059..6acc4a4 100644
--- a/helper_bot/middlewares/metrics_middleware.py
+++ b/helper_bot/middlewares/metrics_middleware.py
@@ -3,25 +3,29 @@ Enhanced Metrics middleware for aiogram 3.x.
Automatically collects ALL available metrics for comprehensive monitoring.
"""
-from typing import Any, Awaitable, Callable, Dict, Union, Optional
-from aiogram import BaseMiddleware
-from aiogram.types import TelegramObject, Message, CallbackQuery
-from aiogram.enums import ChatType
-import time
-import logging
import asyncio
+import logging
+import time
+from typing import Any, Awaitable, Callable, Dict, Optional, Union
+
+from aiogram import BaseMiddleware
+from aiogram.enums import ChatType
+from aiogram.types import CallbackQuery, Message, TelegramObject
+
from ..utils.metrics import metrics
# Import button command mapping
try:
- from ..handlers.private.constants import BUTTON_COMMAND_MAPPING
+ from ..handlers.admin.constants import (ADMIN_BUTTON_COMMAND_MAPPING,
+ ADMIN_COMMANDS)
from ..handlers.callback.constants import CALLBACK_COMMAND_MAPPING
- from ..handlers.admin.constants import ADMIN_BUTTON_COMMAND_MAPPING, ADMIN_COMMANDS
- from ..handlers.voice.constants import (
- BUTTON_COMMAND_MAPPING as VOICE_BUTTON_COMMAND_MAPPING,
- COMMAND_MAPPING as VOICE_COMMAND_MAPPING,
+ from ..handlers.private.constants import BUTTON_COMMAND_MAPPING
+ from ..handlers.voice.constants import \
+ BUTTON_COMMAND_MAPPING as VOICE_BUTTON_COMMAND_MAPPING
+ from ..handlers.voice.constants import \
CALLBACK_COMMAND_MAPPING as VOICE_CALLBACK_COMMAND_MAPPING
- )
+ from ..handlers.voice.constants import \
+ COMMAND_MAPPING as VOICE_COMMAND_MAPPING
except ImportError:
# Fallback if constants not available
BUTTON_COMMAND_MAPPING = {}
diff --git a/helper_bot/middlewares/rate_limit_middleware.py b/helper_bot/middlewares/rate_limit_middleware.py
index 3312ac2..bc59898 100644
--- a/helper_bot/middlewares/rate_limit_middleware.py
+++ b/helper_bot/middlewares/rate_limit_middleware.py
@@ -1,13 +1,14 @@
"""
Middleware для автоматического применения rate limiting ко всем входящим сообщениям
"""
-from typing import Callable, Dict, Any, Awaitable, Union
-from aiogram import BaseMiddleware
-from aiogram.types import Message, CallbackQuery, InlineQuery, ChatMemberUpdated, Update
-from aiogram.exceptions import TelegramRetryAfter, TelegramAPIError
-from logs.custom_logger import logger
+from typing import Any, Awaitable, Callable, Dict, Union
+from aiogram import BaseMiddleware
+from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
+from aiogram.types import (CallbackQuery, ChatMemberUpdated, InlineQuery,
+ Message, Update)
from helper_bot.utils.rate_limiter import telegram_rate_limiter
+from logs.custom_logger import logger
class RateLimitMiddleware(BaseMiddleware):
diff --git a/helper_bot/server_prometheus.py b/helper_bot/server_prometheus.py
index 8c427aa..7255fd5 100644
--- a/helper_bot/server_prometheus.py
+++ b/helper_bot/server_prometheus.py
@@ -5,8 +5,10 @@ Provides /metrics endpoint and health check for the bot.
"""
import asyncio
-from aiohttp import web
from typing import Optional
+
+from aiohttp import web
+
from .utils.metrics import metrics
# Импортируем логгер из проекта
diff --git a/helper_bot/services/__init__.py b/helper_bot/services/__init__.py
new file mode 100644
index 0000000..50a732e
--- /dev/null
+++ b/helper_bot/services/__init__.py
@@ -0,0 +1,5 @@
+"""
+Сервисы приложения.
+
+Содержит бизнес-логику, не связанную напрямую с handlers.
+"""
diff --git a/helper_bot/services/scoring/__init__.py b/helper_bot/services/scoring/__init__.py
new file mode 100644
index 0000000..6a1d156
--- /dev/null
+++ b/helper_bot/services/scoring/__init__.py
@@ -0,0 +1,34 @@
+"""
+Сервисы для ML-скоринга постов.
+
+Включает:
+- RagApiClient - HTTP клиент для внешнего RAG API сервиса
+- DeepSeekService - интеграция с DeepSeek API
+- ScoringManager - объединение всех сервисов скоринга
+"""
+
+from .base import CombinedScore, ScoringResult, ScoringServiceProtocol
+from .deepseek_service import DeepSeekService
+from .exceptions import (DeepSeekAPIError, InsufficientExamplesError,
+ ModelNotLoadedError, ScoringError, TextTooShortError,
+ VectorStoreError)
+from .rag_client import RagApiClient
+from .scoring_manager import ScoringManager
+
+__all__ = [
+ # Базовые классы
+ "ScoringResult",
+ "ScoringServiceProtocol",
+ "CombinedScore",
+ # Исключения
+ "ScoringError",
+ "ModelNotLoadedError",
+ "VectorStoreError",
+ "DeepSeekAPIError",
+ "InsufficientExamplesError",
+ "TextTooShortError",
+ # Сервисы
+ "RagApiClient",
+ "DeepSeekService",
+ "ScoringManager",
+]
diff --git a/helper_bot/services/scoring/base.py b/helper_bot/services/scoring/base.py
new file mode 100644
index 0000000..0848468
--- /dev/null
+++ b/helper_bot/services/scoring/base.py
@@ -0,0 +1,155 @@
+"""
+Базовые классы и протоколы для сервисов скоринга.
+"""
+
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Any, Dict, Optional, Protocol
+
+
+@dataclass
+class ScoringResult:
+ """
+ Результат оценки поста от одного сервиса.
+
+ Attributes:
+ score: Оценка от 0.0 до 1.0 (вероятность публикации)
+ source: Источник оценки ("deepseek", "rag", etc.)
+ model: Название используемой модели
+ confidence: Уверенность в оценке (опционально)
+ timestamp: Время получения оценки
+ metadata: Дополнительные данные
+ """
+ score: float
+ source: str
+ model: str
+ confidence: Optional[float] = None
+ timestamp: int = field(default_factory=lambda: int(datetime.now().timestamp()))
+ metadata: Dict[str, Any] = field(default_factory=dict)
+
+ def __post_init__(self):
+ """Валидация score в диапазоне [0.0, 1.0]."""
+ if not 0.0 <= self.score <= 1.0:
+ raise ValueError(f"Score должен быть в диапазоне [0.0, 1.0], получено: {self.score}")
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Преобразует результат в словарь для сохранения в JSON."""
+ result = {
+ "score": round(self.score, 4),
+ "model": self.model,
+ "ts": self.timestamp,
+ }
+ if self.confidence is not None:
+ result["confidence"] = round(self.confidence, 4)
+ if self.metadata:
+ result["metadata"] = self.metadata
+ return result
+
+ @classmethod
+ def from_dict(cls, source: str, data: Dict[str, Any]) -> "ScoringResult":
+ """Создает ScoringResult из словаря."""
+ return cls(
+ score=data["score"],
+ source=source,
+ model=data.get("model", "unknown"),
+ confidence=data.get("confidence"),
+ timestamp=data.get("ts", int(datetime.now().timestamp())),
+ metadata=data.get("metadata", {}),
+ )
+
+
+@dataclass
+class CombinedScore:
+ """
+ Объединенный результат от всех сервисов скоринга.
+
+ Attributes:
+ deepseek: Результат от DeepSeek API (None если отключен/ошибка)
+ rag: Результат от RAG сервиса (None если отключен/ошибка)
+ errors: Словарь с ошибками по источникам
+ """
+ deepseek: Optional[ScoringResult] = None
+ rag: Optional[ScoringResult] = None
+ errors: Dict[str, str] = field(default_factory=dict)
+
+ @property
+ def deepseek_score(self) -> Optional[float]:
+ """Возвращает только числовой скор от DeepSeek."""
+ return self.deepseek.score if self.deepseek else None
+
+ @property
+ def rag_score(self) -> Optional[float]:
+ """Возвращает только числовой скор от RAG."""
+ return self.rag.score if self.rag else None
+
+ def to_json_dict(self) -> Dict[str, Any]:
+ """
+ Преобразует в словарь для сохранения в ml_scores колонку.
+
+ Формат:
+ {
+ "deepseek": {"score": 0.75, "model": "...", "ts": ...},
+ "rag": {"score": 0.90, "model": "...", "ts": ...}
+ }
+ """
+ result = {}
+ if self.deepseek:
+ result["deepseek"] = self.deepseek.to_dict()
+ if self.rag:
+ result["rag"] = self.rag.to_dict()
+ return result
+
+ def has_any_score(self) -> bool:
+ """Проверяет, есть ли хотя бы один успешный скор."""
+ return self.deepseek is not None or self.rag is not None
+
+
+class ScoringServiceProtocol(Protocol):
+ """
+ Протокол для сервисов скоринга.
+
+ Любой сервис скоринга должен реализовывать эти методы.
+ """
+
+ @property
+ def source_name(self) -> str:
+ """Возвращает имя источника ("deepseek", "rag", etc.)."""
+ ...
+
+ @property
+ def is_enabled(self) -> bool:
+ """Проверяет, включен ли сервис."""
+ ...
+
+ async def calculate_score(self, text: str) -> ScoringResult:
+ """
+ Рассчитывает скор для текста поста.
+
+ Args:
+ text: Текст поста для оценки
+
+ Returns:
+ ScoringResult с оценкой
+
+ Raises:
+ ScoringError: При ошибке расчета
+ """
+ ...
+
+ async def add_positive_example(self, text: str) -> None:
+ """
+ Добавляет текст как положительный пример (опубликованный пост).
+
+ Args:
+ text: Текст опубликованного поста
+ """
+ ...
+
+ async def add_negative_example(self, text: str) -> None:
+ """
+ Добавляет текст как отрицательный пример (отклоненный пост).
+
+ Args:
+ text: Текст отклоненного поста
+ """
+ ...
diff --git a/helper_bot/services/scoring/deepseek_service.py b/helper_bot/services/scoring/deepseek_service.py
new file mode 100644
index 0000000..3bd9ecf
--- /dev/null
+++ b/helper_bot/services/scoring/deepseek_service.py
@@ -0,0 +1,357 @@
+"""
+DeepSeek API сервис для скоринга постов.
+
+Использует DeepSeek API для семантической оценки релевантности поста.
+"""
+
+import asyncio
+import json
+from typing import List, Optional
+
+import httpx
+from helper_bot.utils.metrics import track_errors, track_time
+from logs.custom_logger import logger
+
+from .base import ScoringResult
+from .exceptions import DeepSeekAPIError, ScoringError, TextTooShortError
+
+
+class DeepSeekService:
+ """
+ Сервис для оценки постов через DeepSeek API.
+
+ Отправляет текст поста в DeepSeek с промптом для оценки
+ и получает числовой скор релевантности.
+
+ Attributes:
+ api_key: API ключ DeepSeek
+ api_url: URL API эндпоинта
+ model: Название модели
+ timeout: Таймаут запроса в секундах
+ """
+
+ # Промпт для оценки поста
+ SCORING_PROMPT = """Роль: Ты — строгий и внимательный модератор сообщества в социальной сети, ориентированного на знакомства между людьми. Твоя задача — оценить, можно ли опубликовать пост, основываясь на четких правилах.
+
+Контекст группы: Это группа для поиска и знакомства с людьми. Пользователи могут искать кого угодно: случайно увиденных на улице, в транспорте, в кафе, старых знакомых, новых друзей или пару. Это главная и единственная цель группы.
+
+---
+
+ПРАВИЛА ЗАПРЕТА (пост НЕ ДОЛЖЕН быть опубликован, если содержит это):
+
+1. Запрещенные законом тематики: Любые призывы, обсуждение или поиск чего-либо незаконного (наркотики, оружие, мошенничество, насилие и т.д.).
+2. Поиск и утеря животных, найденные предметы: Запрещены посты про потерявшихся/найденных кошек, собак, хомяков, а также про потерянные/найденные телефоны, ключи, сумки и т.п.
+3. Конкуренция (Дайвинчик): Любое упоминание группы/проекта/чата "Дайвинчик" или любых других групп-конкурентов. Запрещены призывы переходить в другие сообщества.
+4. Сбор больших компаний и групп: Запрещены посты с целью собрать большую тусовку, компанию, группу для похода, вечеринки, игры и т.д. (например, "собираем команду для футбола", "кто хочет на квартиру?").
+5. Организация чатов и других сообществ: Запрещено создание или реклама сторонних чатов, каналов, групп в телеграме, дискорде и т.п.
+
+---
+
+ПРАВИЛА РАЗРЕШЕНИЯ (пост МОЖЕТ быть опубликован, если):
+
+· Цель — найти конкретного человека или познакомиться с кем-то новым.
+· Формат: Описание человека, обстоятельств встречи, примет, места и времени. Или прямой призыв к знакомству.
+· Примеры ДОПУСТИМЫХ постов (ориентируйся на них):
+ · "мальчики нефоры/патлатые, гоу знакомиться😻 анон"
+ · "ищу девочку, ехала на 21 автобусе примерно в 15:20. села на детской поликлинике и вышла в заречье вся в черной одежде и с черным баулом"
+ · "ищу мальчика ехали на 35 автобусе часов в 7 вечера я была с девочками,у нас с тобой еще куртки одинаковые ,я рядом с тобой сидела,напиши в комментарии если у тебя нету девочки. анон админу любви."
+
+---
+
+ИНСТРУКЦИЯ ПО ОЦЕНКЕ:
+
+Проанализируй полученный пост и присвой ему итоговый Вес (Score) от 0.0 до 1.0, где:
+
+· 1.0 — Пост полностью соответствует правилам. Цель — найти/познакомиться с человеком. Ничего из списка запретов не нарушено. Можно публиковать.
+· 0.0 — Пост категорически нарушает правила. Содержит явные признаки одного или нескольких пунктов из списка запрета. Публиковать НЕЛЬЗЯ.
+· 0.2 - 0.8 — Пост находится в "серой зоне". Присваивай промежуточный вес, оценивая степень риска и соответствия цели группы.
+ · Ближе к 0.2: Сильно сомнительный пост, есть явные признаки запрещенной темы (например, упоминание "собраться компанией", косвенная реклама другого места).
+ · 0.5: Нейтральный или неочевидный пост. Нужно проверить, нет ли скрытого смысла, нарушающего правила.
+ · Ближе к 0.8: В целом допустимый пост, но с небольшими странностями или двусмысленностями, не нарушающими правила напрямую.
+---
+{text}
+---
+
+Ответь ТОЛЬКО числом от 0.0 до 1.0, без дополнительных объяснений.
+Пример ответа: 0.75"""
+
+ DEFAULT_API_URL = "https://api.deepseek.com/v1/chat/completions"
+ DEFAULT_MODEL = "deepseek-chat"
+
+ def __init__(
+ self,
+ api_key: Optional[str] = None,
+ api_url: Optional[str] = None,
+ model: Optional[str] = None,
+ timeout: int = 30,
+ enabled: bool = True,
+ min_text_length: int = 3,
+ max_retries: int = 3,
+ ):
+ """
+ Инициализация DeepSeek сервиса.
+
+ Args:
+ api_key: API ключ DeepSeek
+ api_url: URL API эндпоинта
+ model: Название модели
+ timeout: Таймаут запроса в секундах
+ enabled: Включен ли сервис
+ min_text_length: Минимальная длина текста для обработки
+ max_retries: Максимальное количество повторных попыток
+ """
+ self.api_key = api_key
+ self.api_url = api_url or self.DEFAULT_API_URL
+ self.model = model or self.DEFAULT_MODEL
+ self.timeout = timeout
+ self._enabled = enabled and bool(api_key)
+ self.min_text_length = min_text_length
+ self.max_retries = max_retries
+
+ # HTTP клиент (создается лениво)
+ self._client: Optional[httpx.AsyncClient] = None
+
+ if not api_key and enabled:
+ logger.warning("DeepSeekService: API ключ не указан, сервис отключен")
+ self._enabled = False
+
+ logger.info(
+ f"DeepSeekService инициализирован "
+ f"(model={self.model}, enabled={self._enabled})"
+ )
+
+ @property
+ def source_name(self) -> str:
+ """Имя источника для результатов."""
+ return "deepseek"
+
+ @property
+ def is_enabled(self) -> bool:
+ """Проверяет, включен ли сервис."""
+ return self._enabled
+
+ async def _get_client(self) -> httpx.AsyncClient:
+ """Получает или создает HTTP клиент."""
+ if self._client is None:
+ self._client = httpx.AsyncClient(
+ timeout=httpx.Timeout(self.timeout),
+ headers={
+ "Authorization": f"Bearer {self.api_key}",
+ "Content-Type": "application/json",
+ },
+ )
+ return self._client
+
+ async def close(self) -> None:
+ """Закрывает HTTP клиент."""
+ if self._client:
+ await self._client.aclose()
+ self._client = None
+
+ def _clean_text(self, text: str) -> str:
+ """Очищает текст от лишних символов."""
+ if not text:
+ return ""
+
+ # Удаляем лишние пробелы и переносы строк
+ clean = " ".join(text.split())
+
+ # Удаляем служебные символы
+ if clean == "^":
+ return ""
+
+ return clean.strip()
+
+ def _parse_score_response(self, response_text: str) -> float:
+ """
+ Парсит ответ от DeepSeek и извлекает скор.
+
+ Args:
+ response_text: Текст ответа от API
+
+ Returns:
+ Числовой скор от 0.0 до 1.0
+
+ Raises:
+ DeepSeekAPIError: Если не удалось распарсить ответ
+ """
+ try:
+ # Пытаемся найти число в ответе
+ text = response_text.strip()
+
+ # Убираем возможные обрамления
+ text = text.strip('"\'`')
+
+ # Пробуем распарсить как число
+ score = float(text)
+
+ # Ограничиваем диапазон
+ score = max(0.0, min(1.0, score))
+
+ return score
+
+ except ValueError:
+ # Пробуем найти число в тексте
+ import re
+ matches = re.findall(r'0\.\d+|1\.0|0|1', text)
+ if matches:
+ score = float(matches[0])
+ return max(0.0, min(1.0, score))
+
+ logger.error(f"DeepSeekService: Не удалось распарсить ответ: {response_text}")
+ raise DeepSeekAPIError(f"Не удалось распарсить скор из ответа: {response_text}")
+
+ @track_time("calculate_score", "deepseek_service")
+ @track_errors("deepseek_service", "calculate_score")
+ async def calculate_score(self, text: str) -> ScoringResult:
+ """
+ Рассчитывает скор для текста поста через DeepSeek API.
+
+ Args:
+ text: Текст поста для оценки
+
+ Returns:
+ ScoringResult с оценкой
+
+ Raises:
+ ScoringError: При ошибке расчета
+ """
+ if not self._enabled:
+ raise ScoringError("DeepSeek сервис отключен")
+
+ # Очищаем текст
+ clean_text = self._clean_text(text)
+
+ if len(clean_text) < self.min_text_length:
+ raise TextTooShortError(
+ f"Текст слишком короткий (минимум {self.min_text_length} символов)"
+ )
+
+ # Формируем промпт
+ prompt = self.SCORING_PROMPT.format(text=clean_text)
+
+ # Выполняем запрос с повторными попытками
+ last_error = None
+ for attempt in range(self.max_retries):
+ try:
+ score = await self._make_api_request(prompt)
+
+ return ScoringResult(
+ score=score,
+ source=self.source_name,
+ model=self.model,
+ metadata={
+ "text_length": len(clean_text),
+ "attempt": attempt + 1,
+ },
+ )
+
+ except DeepSeekAPIError as e:
+ last_error = e
+ logger.warning(
+ f"DeepSeekService: Попытка {attempt + 1}/{self.max_retries} "
+ f"не удалась: {e}"
+ )
+ if attempt < self.max_retries - 1:
+ # Экспоненциальная задержка
+ await asyncio.sleep(2 ** attempt)
+
+ raise ScoringError(f"Все попытки запроса к DeepSeek API не удались: {last_error}")
+
+ async def _make_api_request(self, prompt: str) -> float:
+ """
+ Выполняет запрос к DeepSeek API.
+
+ Args:
+ prompt: Промпт для отправки
+
+ Returns:
+ Числовой скор от 0.0 до 1.0
+
+ Raises:
+ DeepSeekAPIError: При ошибке API
+ """
+ client = await self._get_client()
+
+ payload = {
+ "model": self.model,
+ "messages": [
+ {
+ "role": "user",
+ "content": prompt,
+ }
+ ],
+ "temperature": 0.1, # Низкая температура для детерминированности
+ "max_tokens": 10, # Ожидаем только число
+ }
+
+ try:
+ response = await client.post(self.api_url, json=payload)
+ response.raise_for_status()
+
+ data = response.json()
+
+ # Извлекаем ответ
+ if "choices" not in data or not data["choices"]:
+ raise DeepSeekAPIError("Пустой ответ от API")
+
+ response_text = data["choices"][0]["message"]["content"]
+
+ # Парсим скор
+ score = self._parse_score_response(response_text)
+
+ logger.debug(f"DeepSeekService: Получен скор {score} для текста")
+ return score
+
+ except httpx.HTTPStatusError as e:
+ error_msg = f"HTTP ошибка {e.response.status_code}"
+ try:
+ error_data = e.response.json()
+ if "error" in error_data:
+ error_msg = error_data["error"].get("message", error_msg)
+ except Exception:
+ pass
+ raise DeepSeekAPIError(error_msg)
+
+ except httpx.TimeoutException:
+ raise DeepSeekAPIError(f"Таймаут запроса ({self.timeout}s)")
+
+ except Exception as e:
+ raise DeepSeekAPIError(f"Ошибка запроса: {e}")
+
+ async def add_positive_example(self, text: str) -> None:
+ """
+ Добавляет текст как положительный пример.
+
+ Для DeepSeek не требуется хранить примеры - оценка выполняется
+ на основе промпта. Метод существует для совместимости с протоколом.
+
+ Args:
+ text: Текст опубликованного поста
+ """
+ # DeepSeek не использует примеры для обучения
+ # Промпт уже содержит критерии оценки
+ pass
+
+ async def add_negative_example(self, text: str) -> None:
+ """
+ Добавляет текст как отрицательный пример.
+
+ Для DeepSeek не требуется хранить примеры - оценка выполняется
+ на основе промпта. Метод существует для совместимости с протоколом.
+
+ Args:
+ text: Текст отклоненного поста
+ """
+ # DeepSeek не использует примеры для обучения
+ pass
+
+ def get_stats(self) -> dict:
+ """Возвращает статистику сервиса."""
+ return {
+ "enabled": self._enabled,
+ "model": self.model,
+ "api_url": self.api_url,
+ "timeout": self.timeout,
+ "max_retries": self.max_retries,
+ }
diff --git a/helper_bot/services/scoring/exceptions.py b/helper_bot/services/scoring/exceptions.py
new file mode 100644
index 0000000..8af309c
--- /dev/null
+++ b/helper_bot/services/scoring/exceptions.py
@@ -0,0 +1,33 @@
+"""
+Исключения для сервисов скоринга.
+"""
+
+
+class ScoringError(Exception):
+ """Базовое исключение для ошибок скоринга."""
+ pass
+
+
+class ModelNotLoadedError(ScoringError):
+ """Модель не загружена или недоступна."""
+ pass
+
+
+class VectorStoreError(ScoringError):
+ """Ошибка при работе с хранилищем векторов."""
+ pass
+
+
+class DeepSeekAPIError(ScoringError):
+ """Ошибка при обращении к DeepSeek API."""
+ pass
+
+
+class InsufficientExamplesError(ScoringError):
+ """Недостаточно примеров для расчета скора."""
+ pass
+
+
+class TextTooShortError(ScoringError):
+ """Текст слишком короткий для векторизации."""
+ pass
diff --git a/helper_bot/services/scoring/rag_client.py b/helper_bot/services/scoring/rag_client.py
new file mode 100644
index 0000000..689e5e6
--- /dev/null
+++ b/helper_bot/services/scoring/rag_client.py
@@ -0,0 +1,313 @@
+"""
+HTTP клиент для взаимодействия с внешним RAG сервисом.
+
+Использует REST API для получения скоров и отправки примеров.
+"""
+
+from typing import Any, Dict, Optional
+
+import httpx
+from helper_bot.utils.metrics import track_errors, track_time
+from logs.custom_logger import logger
+
+from .base import ScoringResult
+from .exceptions import (InsufficientExamplesError, ScoringError,
+ TextTooShortError)
+
+
+class RagApiClient:
+ """
+ HTTP клиент для взаимодействия с внешним RAG сервисом.
+
+ Использует REST API для:
+ - Получения скоров постов (POST /api/v1/score)
+ - Отправки положительных примеров (POST /api/v1/examples/positive)
+ - Отправки отрицательных примеров (POST /api/v1/examples/negative)
+ - Получения статистики (GET /api/v1/stats)
+
+ Attributes:
+ api_url: Базовый URL API сервиса
+ api_key: API ключ для аутентификации
+ timeout: Таймаут запросов в секундах
+ test_mode: Включен ли тестовый режим (добавляет заголовок X-Test-Mode: true)
+ enabled: Включен ли клиент
+ """
+
+ def __init__(
+ self,
+ api_url: str,
+ api_key: str,
+ timeout: int = 30,
+ test_mode: bool = False,
+ enabled: bool = True,
+ ):
+ """
+ Инициализация клиента.
+
+ Args:
+ api_url: Базовый URL API (например, http://хх.ххх.ххх.хх/api/v1)
+ api_key: API ключ для аутентификации
+ timeout: Таймаут запросов в секундах
+ test_mode: Включен ли тестовый режим (добавляет заголовок X-Test-Mode: true к запросам examples)
+ enabled: Включен ли клиент
+ """
+ # Убираем trailing slash если есть
+ self.api_url = api_url.rstrip('/')
+ self.api_key = api_key
+ self.timeout = timeout
+ self.test_mode = test_mode
+ self._enabled = enabled
+
+ # Создаем HTTP клиент
+ self._client = httpx.AsyncClient(
+ timeout=httpx.Timeout(timeout),
+ headers={
+ "X-API-Key": api_key,
+ "Content-Type": "application/json",
+ }
+ )
+
+ logger.info(f"RagApiClient инициализирован (url={self.api_url}, enabled={enabled})")
+
+ @property
+ def source_name(self) -> str:
+ """Имя источника для результатов."""
+ return "rag"
+
+ @property
+ def is_enabled(self) -> bool:
+ """Проверяет, включен ли клиент."""
+ return self._enabled
+
+ async def close(self) -> None:
+ """Закрывает HTTP клиент."""
+ await self._client.aclose()
+
+ @track_time("calculate_score", "rag_client")
+ @track_errors("rag_client", "calculate_score")
+ async def calculate_score(self, text: str) -> ScoringResult:
+ """
+ Рассчитывает скор для текста поста через API.
+
+ Args:
+ text: Текст поста для оценки
+
+ Returns:
+ ScoringResult с оценкой
+
+ Raises:
+ ScoringError: При ошибке расчета
+ InsufficientExamplesError: Если недостаточно примеров
+ TextTooShortError: Если текст слишком короткий
+ """
+ if not self._enabled:
+ raise ScoringError("RAG API клиент отключен")
+
+ if not text or not text.strip():
+ raise TextTooShortError("Текст пустой")
+
+ try:
+ response = await self._client.post(
+ f"{self.api_url}/score",
+ json={"text": text.strip()}
+ )
+
+ # Обрабатываем различные статусы
+ if response.status_code == 400:
+ try:
+ error_data = response.json()
+ error_msg = error_data.get("detail", "Неизвестная ошибка")
+ except Exception:
+ error_msg = response.text or "Неизвестная ошибка"
+
+ logger.warning(f"RagApiClient: Ошибка валидации запроса: {error_msg}")
+
+ if "недостаточно" in error_msg.lower() or "insufficient" in error_msg.lower():
+ raise InsufficientExamplesError(error_msg)
+ if "коротк" in error_msg.lower() or "short" in error_msg.lower():
+ raise TextTooShortError(error_msg)
+ raise ScoringError(f"Ошибка валидации: {error_msg}")
+
+ if response.status_code == 401:
+ logger.error("RagApiClient: Ошибка аутентификации: неверный API ключ")
+ raise ScoringError("Ошибка аутентификации: неверный API ключ")
+
+ if response.status_code == 404:
+ logger.error("RagApiClient: RAG API endpoint не найден")
+ raise ScoringError("RAG API endpoint не найден")
+
+ if response.status_code >= 500:
+ logger.error(f"RagApiClient: Ошибка сервера RAG API: {response.status_code}")
+ raise ScoringError(f"Ошибка сервера RAG API: {response.status_code}")
+
+ # Проверяем успешный статус
+ if response.status_code != 200:
+ response.raise_for_status()
+
+ data = response.json()
+
+ # Парсим ответ
+ score = float(data.get("rag_score", 0.0))
+ confidence = float(data.get("rag_confidence", 0.0)) if data.get("rag_confidence") is not None else None
+
+ # Форматируем confidence для логирования
+ confidence_str = f"{confidence:.4f}" if confidence is not None else "None"
+
+ logger.info(
+ f"RagApiClient: Скор успешно получен "
+ f"(score={score:.4f}, confidence={confidence_str})"
+ )
+
+ return ScoringResult(
+ score=score,
+ source=self.source_name,
+ model=data.get("meta", {}).get("model", "rag-service"),
+ confidence=confidence,
+ metadata={
+ "rag_score_pos_only": float(data.get("rag_score_pos_only", 0.0)) if data.get("rag_score_pos_only") is not None else None,
+ "positive_examples": data.get("meta", {}).get("positive_examples"),
+ "negative_examples": data.get("meta", {}).get("negative_examples"),
+ }
+ )
+
+ except httpx.TimeoutException:
+ logger.error(f"RagApiClient: Таймаут запроса к RAG API (>{self.timeout}с)")
+ raise ScoringError(f"Таймаут запроса к RAG API (>{self.timeout}с)")
+ except httpx.RequestError as e:
+ logger.error(f"RagApiClient: Ошибка подключения к RAG API: {e}")
+ raise ScoringError(f"Ошибка подключения к RAG API: {e}")
+ except (KeyError, ValueError, TypeError) as e:
+ logger.error(f"RagApiClient: Ошибка парсинга ответа: {e}, response: {response.text if 'response' in locals() else 'N/A'}")
+ raise ScoringError(f"Ошибка парсинга ответа от RAG API: {e}")
+ except InsufficientExamplesError:
+ raise
+ except TextTooShortError:
+ raise
+ except ScoringError:
+ # Уже залогированные ошибки (401, 404, 500, таймауты и т.д.) - просто пробрасываем
+ raise
+ except Exception as e:
+ # Только действительно неожиданные ошибки логируем здесь
+ logger.error(f"RagApiClient: Неожиданная ошибка при расчете скора: {e}", exc_info=True)
+ raise ScoringError(f"Неожиданная ошибка: {e}")
+
+ @track_time("add_positive_example", "rag_client")
+ async def add_positive_example(self, text: str) -> None:
+ """
+ Добавляет текст как положительный пример (опубликованный пост).
+
+ Args:
+ text: Текст опубликованного поста
+ """
+ if not self._enabled:
+ return
+
+ if not text or not text.strip():
+ return
+
+ try:
+ # Формируем заголовки (добавляем X-Test-Mode если включен тестовый режим)
+ headers = {}
+ if self.test_mode:
+ headers["X-Test-Mode"] = "true"
+
+ response = await self._client.post(
+ f"{self.api_url}/examples/positive",
+ json={"text": text.strip()},
+ headers=headers
+ )
+
+ if response.status_code == 200 or response.status_code == 201:
+ logger.info("RagApiClient: Положительный пример успешно добавлен")
+ elif response.status_code == 400:
+ logger.warning(f"RagApiClient: Ошибка валидации при добавлении положительного примера: {response.text}")
+ else:
+ logger.warning(f"RagApiClient: Неожиданный статус при добавлении положительного примера: {response.status_code}")
+
+ except httpx.TimeoutException:
+ logger.warning(f"RagApiClient: Таймаут при добавлении положительного примера")
+ except httpx.RequestError as e:
+ logger.warning(f"RagApiClient: Ошибка подключения при добавлении положительного примера: {e}")
+ except Exception as e:
+ logger.error(f"RagApiClient: Ошибка добавления положительного примера: {e}")
+
+ @track_time("add_negative_example", "rag_client")
+ async def add_negative_example(self, text: str) -> None:
+ """
+ Добавляет текст как отрицательный пример (отклоненный пост).
+
+ Args:
+ text: Текст отклоненного поста
+ """
+ if not self._enabled:
+ return
+
+ if not text or not text.strip():
+ return
+
+ try:
+ # Формируем заголовки (добавляем X-Test-Mode если включен тестовый режим)
+ headers = {}
+ if self.test_mode:
+ headers["X-Test-Mode"] = "true"
+
+ response = await self._client.post(
+ f"{self.api_url}/examples/negative",
+ json={"text": text.strip()},
+ headers=headers
+ )
+
+ if response.status_code == 200 or response.status_code == 201:
+ logger.info("RagApiClient: Отрицательный пример успешно добавлен")
+ elif response.status_code == 400:
+ logger.warning(f"RagApiClient: Ошибка валидации при добавлении отрицательного примера: {response.text}")
+ else:
+ logger.warning(f"RagApiClient: Неожиданный статус при добавлении отрицательного примера: {response.status_code}")
+
+ except httpx.TimeoutException:
+ logger.warning(f"RagApiClient: Таймаут при добавлении отрицательного примера")
+ except httpx.RequestError as e:
+ logger.warning(f"RagApiClient: Ошибка подключения при добавлении отрицательного примера: {e}")
+ except Exception as e:
+ logger.error(f"RagApiClient: Ошибка добавления отрицательного примера: {e}")
+
+ async def get_stats(self) -> Dict[str, Any]:
+ """
+ Получает статистику от RAG API через endpoint /stats.
+
+ Returns:
+ Словарь со статистикой или пустой словарь при ошибке
+ """
+ if not self._enabled:
+ return {}
+
+ try:
+ response = await self._client.get(f"{self.api_url}/stats")
+
+ if response.status_code == 200:
+ return response.json()
+ else:
+ logger.warning(f"RagApiClient: Неожиданный статус при получении статистики: {response.status_code}")
+ return {}
+
+ except httpx.TimeoutException:
+ logger.warning(f"RagApiClient: Таймаут при получении статистики")
+ return {}
+ except httpx.RequestError as e:
+ logger.warning(f"RagApiClient: Ошибка подключения при получении статистики: {e}")
+ return {}
+ except Exception as e:
+ logger.error(f"RagApiClient: Ошибка получения статистики: {e}")
+ return {}
+
+ def get_stats_sync(self) -> Dict[str, Any]:
+ """
+ Синхронная версия get_stats для использования в get_stats() ScoringManager.
+
+ Внимание: Это заглушка, реальная статистика будет получена асинхронно.
+ """
+ return {
+ "enabled": self._enabled,
+ "api_url": self.api_url,
+ "timeout": self.timeout,
+ }
diff --git a/helper_bot/services/scoring/scoring_manager.py b/helper_bot/services/scoring/scoring_manager.py
new file mode 100644
index 0000000..6c03035
--- /dev/null
+++ b/helper_bot/services/scoring/scoring_manager.py
@@ -0,0 +1,223 @@
+"""
+Менеджер для объединения всех сервисов скоринга.
+
+Координирует работу RagApiClient и DeepSeekService,
+выполняет параллельные запросы и агрегирует результаты.
+"""
+
+import asyncio
+from typing import Optional
+
+from helper_bot.utils.metrics import track_errors, track_time
+from logs.custom_logger import logger
+
+from .base import CombinedScore, ScoringResult
+from .deepseek_service import DeepSeekService
+from .exceptions import (InsufficientExamplesError, ScoringError,
+ TextTooShortError)
+from .rag_client import RagApiClient
+
+
+class ScoringManager:
+ """
+ Менеджер для управления всеми сервисами скоринга.
+
+ Объединяет RagApiClient и DeepSeekService, выполняет параллельные
+ запросы и агрегирует результаты в единый CombinedScore.
+
+ Attributes:
+ rag_client: HTTP клиент для RAG API
+ deepseek_service: Сервис DeepSeek API
+ """
+
+ def __init__(
+ self,
+ rag_client: Optional[RagApiClient] = None,
+ deepseek_service: Optional[DeepSeekService] = None,
+ ):
+ """
+ Инициализация менеджера.
+
+ Args:
+ rag_client: HTTP клиент для RAG API (создается автоматически если не передан)
+ deepseek_service: Сервис DeepSeek (создается автоматически если не передан)
+ """
+ self.rag_client = rag_client
+ self.deepseek_service = deepseek_service
+
+ logger.info(
+ f"ScoringManager инициализирован "
+ f"(rag={rag_client is not None and rag_client.is_enabled}, "
+ f"deepseek={deepseek_service is not None and deepseek_service.is_enabled})"
+ )
+
+ @property
+ def is_any_enabled(self) -> bool:
+ """Проверяет, включен ли хотя бы один сервис."""
+ rag_enabled = self.rag_client is not None and self.rag_client.is_enabled
+ deepseek_enabled = self.deepseek_service is not None and self.deepseek_service.is_enabled
+ return rag_enabled or deepseek_enabled
+
+ @track_time("score_post", "scoring_manager")
+ @track_errors("scoring_manager", "score_post")
+ async def score_post(self, text: str) -> CombinedScore:
+ """
+ Рассчитывает скоры для текста поста от всех сервисов.
+
+ Выполняет запросы параллельно для минимизации задержки.
+
+ Args:
+ text: Текст поста для оценки
+
+ Returns:
+ CombinedScore с результатами от всех сервисов
+ """
+ result = CombinedScore()
+
+ if not text or not text.strip():
+ logger.debug("ScoringManager: Пустой текст, пропускаем скоринг")
+ return result
+
+ # Собираем задачи для параллельного выполнения
+ tasks = []
+ task_names = []
+
+ # RAG API клиент
+ if self.rag_client and self.rag_client.is_enabled:
+ tasks.append(self._get_rag_score(text))
+ task_names.append("rag")
+
+ # DeepSeek сервис
+ if self.deepseek_service and self.deepseek_service.is_enabled:
+ tasks.append(self._get_deepseek_score(text))
+ task_names.append("deepseek")
+
+ if not tasks:
+ logger.debug("ScoringManager: Нет активных сервисов для скоринга")
+ return result
+
+ # Выполняем параллельно
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+
+ # Обрабатываем результаты
+ for name, res in zip(task_names, results):
+ if isinstance(res, Exception):
+ error_msg = str(res)
+ result.errors[name] = error_msg
+ # Ошибки уже залогированы в сервисах, здесь только предупреждение
+ logger.warning(f"ScoringManager: Ошибка от {name}: {error_msg}")
+ elif res is not None:
+ if name == "rag":
+ result.rag = res
+ elif name == "deepseek":
+ result.deepseek = res
+
+ logger.info(
+ f"ScoringManager: Скоринг завершен "
+ f"(rag={result.rag_score}, deepseek={result.deepseek_score})"
+ )
+
+ return result
+
+ async def _get_rag_score(self, text: str) -> Optional[ScoringResult]:
+ """Получает скор от RAG API."""
+ try:
+ return await self.rag_client.calculate_score(text)
+ except InsufficientExamplesError:
+ # Недостаточно примеров - это не ошибка, просто нет данных
+ logger.info("ScoringManager: RAG - недостаточно примеров")
+ return None
+ except TextTooShortError:
+ # Текст слишком короткий - пропускаем
+ logger.debug("ScoringManager: RAG - текст слишком короткий")
+ return None
+ except Exception as e:
+ # Ошибки уже залогированы в RagApiClient, здесь только пробрасываем
+ raise
+
+ async def _get_deepseek_score(self, text: str) -> Optional[ScoringResult]:
+ """Получает скор от DeepSeek сервиса."""
+ try:
+ return await self.deepseek_service.calculate_score(text)
+ except TextTooShortError:
+ # Текст слишком короткий - пропускаем
+ logger.debug("ScoringManager: DeepSeek - текст слишком короткий")
+ return None
+ except Exception as e:
+ # Ошибки уже залогированы в DeepSeekService, здесь только пробрасываем
+ raise
+
+ @track_time("on_post_published", "scoring_manager")
+ async def on_post_published(self, text: str) -> None:
+ """
+ Вызывается при публикации поста.
+
+ Добавляет текст как положительный пример для обучения RAG.
+
+ Args:
+ text: Текст опубликованного поста
+ """
+ if not text or not text.strip():
+ return
+
+ tasks = []
+
+ if self.rag_client and self.rag_client.is_enabled:
+ tasks.append(self.rag_client.add_positive_example(text))
+
+ if self.deepseek_service and self.deepseek_service.is_enabled:
+ tasks.append(self.deepseek_service.add_positive_example(text))
+
+ if tasks:
+ await asyncio.gather(*tasks, return_exceptions=True)
+ logger.info("ScoringManager: Добавлен положительный пример")
+
+ @track_time("on_post_declined", "scoring_manager")
+ async def on_post_declined(self, text: str) -> None:
+ """
+ Вызывается при отклонении поста.
+
+ Добавляет текст как отрицательный пример для обучения RAG.
+
+ Args:
+ text: Текст отклоненного поста
+ """
+ if not text or not text.strip():
+ return
+
+ tasks = []
+
+ if self.rag_client and self.rag_client.is_enabled:
+ tasks.append(self.rag_client.add_negative_example(text))
+
+ if self.deepseek_service and self.deepseek_service.is_enabled:
+ tasks.append(self.deepseek_service.add_negative_example(text))
+
+ if tasks:
+ await asyncio.gather(*tasks, return_exceptions=True)
+ logger.info("ScoringManager: Добавлен отрицательный пример")
+
+
+ async def close(self) -> None:
+ """Закрывает ресурсы всех сервисов."""
+ if self.deepseek_service:
+ await self.deepseek_service.close()
+
+ if self.rag_client:
+ await self.rag_client.close()
+
+ async def get_stats(self) -> dict:
+ """Возвращает статистику всех сервисов."""
+ stats = {
+ "any_enabled": self.is_any_enabled,
+ }
+
+ if self.rag_client:
+ # Получаем статистику асинхронно от API
+ rag_stats = await self.rag_client.get_stats()
+ stats["rag"] = rag_stats if rag_stats else self.rag_client.get_stats_sync()
+
+ if self.deepseek_service:
+ stats["deepseek"] = self.deepseek_service.get_stats()
+
+ return stats
diff --git a/helper_bot/utils/auto_unban_scheduler.py b/helper_bot/utils/auto_unban_scheduler.py
index a1e2533..25be88d 100644
--- a/helper_bot/utils/auto_unban_scheduler.py
+++ b/helper_bot/utils/auto_unban_scheduler.py
@@ -1,18 +1,14 @@
import asyncio
-from datetime import datetime, timezone, timedelta
+from datetime import datetime, timedelta, timezone
from typing import Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
-
from helper_bot.utils.base_dependency_factory import get_global_instance
from logs.custom_logger import logger
-from .metrics import (
- track_time,
- track_errors,
- db_query_time
-)
+from .metrics import db_query_time, track_errors, track_time
+
class AutoUnbanScheduler:
"""
diff --git a/helper_bot/utils/base_dependency_factory.py b/helper_bot/utils/base_dependency_factory.py
index 7959b34..f50c079 100644
--- a/helper_bot/utils/base_dependency_factory.py
+++ b/helper_bot/utils/base_dependency_factory.py
@@ -1,10 +1,11 @@
import os
import sys
from typing import Optional
-from dotenv import load_dotenv
from database.async_db import AsyncBotDB
+from dotenv import load_dotenv
from helper_bot.utils.s3_storage import S3StorageService
+from logs.custom_logger import logger
class BaseDependencyFactory:
@@ -15,6 +16,7 @@ class BaseDependencyFactory:
load_dotenv(env_path)
self.settings = {}
+ self._project_dir = project_dir
database_path = os.getenv('DATABASE_PATH', 'database/tg-bot-database.db')
if not os.path.isabs(database_path):
@@ -24,6 +26,9 @@ class BaseDependencyFactory:
self._load_settings_from_env()
self._init_s3_storage()
+
+ # ScoringManager инициализируется лениво
+ self._scoring_manager = None
def _load_settings_from_env(self):
"""Загружает настройки из переменных окружения."""
@@ -59,6 +64,22 @@ class BaseDependencyFactory:
'bucket_name': os.getenv('S3_BUCKET_NAME', ''),
'region': os.getenv('S3_REGION', 'us-east-1')
}
+
+ # Настройки ML-скоринга
+ self.settings['Scoring'] = {
+ # RAG API
+ 'rag_enabled': self._parse_bool(os.getenv('RAG_ENABLED', 'false')),
+ 'rag_api_url': os.getenv('RAG_API_URL', ''),
+ 'rag_api_key': os.getenv('RAG_API_KEY', ''),
+ 'rag_api_timeout': self._parse_int(os.getenv('RAG_API_TIMEOUT', '30')),
+ 'rag_test_mode': self._parse_bool(os.getenv('RAG_TEST_MODE', 'false')),
+ # DeepSeek
+ 'deepseek_enabled': self._parse_bool(os.getenv('DEEPSEEK_ENABLED', 'false')),
+ 'deepseek_api_key': os.getenv('DEEPSEEK_API_KEY', ''),
+ 'deepseek_api_url': os.getenv('DEEPSEEK_API_URL', 'https://api.deepseek.com/v1/chat/completions'),
+ 'deepseek_model': os.getenv('DEEPSEEK_MODEL', 'deepseek-chat'),
+ 'deepseek_timeout': self._parse_int(os.getenv('DEEPSEEK_TIMEOUT', '30')),
+ }
def _init_s3_storage(self):
"""Инициализирует S3StorageService если S3 включен."""
@@ -84,6 +105,13 @@ class BaseDependencyFactory:
return int(value)
except (ValueError, TypeError):
return 0
+
+ def _parse_float(self, value: str) -> float:
+ """Парсит строковое значение в float."""
+ try:
+ return float(value)
+ except (ValueError, TypeError):
+ return 0.0
def get_settings(self):
return self.settings
@@ -95,6 +123,79 @@ class BaseDependencyFactory:
def get_s3_storage(self) -> Optional[S3StorageService]:
"""Возвращает S3StorageService если S3 включен, иначе None."""
return self.s3_storage
+
+ def _init_scoring_manager(self):
+ """
+ Инициализирует ScoringManager с RAG API клиентом и DeepSeek сервисом.
+
+ Вызывается лениво при первом обращении к get_scoring_manager().
+ """
+ from helper_bot.services.scoring import (DeepSeekService, RagApiClient,
+ ScoringManager)
+
+ scoring_config = self.settings['Scoring']
+
+ # Инициализация RAG API клиента
+ rag_client = None
+ if scoring_config['rag_enabled']:
+ api_url = scoring_config['rag_api_url']
+ api_key = scoring_config['rag_api_key']
+
+ if not api_url or not api_key:
+ logger.warning("RAG включен, но не указаны RAG_API_URL или RAG_API_KEY")
+ else:
+ rag_client = RagApiClient(
+ api_url=api_url,
+ api_key=api_key,
+ timeout=scoring_config['rag_api_timeout'],
+ test_mode=scoring_config['rag_test_mode'],
+ enabled=True,
+ )
+ logger.info(f"RagApiClient инициализирован: {api_url} (test_mode={scoring_config['rag_test_mode']})")
+
+ # Инициализация DeepSeek сервиса
+ deepseek_service = None
+ if scoring_config['deepseek_enabled'] and scoring_config['deepseek_api_key']:
+ deepseek_service = DeepSeekService(
+ api_key=scoring_config['deepseek_api_key'],
+ api_url=scoring_config['deepseek_api_url'],
+ model=scoring_config['deepseek_model'],
+ timeout=scoring_config['deepseek_timeout'],
+ enabled=True,
+ )
+ logger.info(f"DeepSeekService инициализирован: {scoring_config['deepseek_model']}")
+
+ # Создаем менеджер
+ self._scoring_manager = ScoringManager(
+ rag_client=rag_client,
+ deepseek_service=deepseek_service,
+ )
+
+ return self._scoring_manager
+
+ def get_scoring_manager(self):
+ """
+ Возвращает ScoringManager для ML-скоринга постов.
+
+ Инициализируется лениво при первом вызове.
+
+ Returns:
+ ScoringManager или None если скоринг полностью отключен
+ """
+ if self._scoring_manager is None:
+ scoring_config = self.settings.get('Scoring', {})
+
+ # Проверяем, включен ли хотя бы один сервис
+ rag_enabled = scoring_config.get('rag_enabled', False)
+ deepseek_enabled = scoring_config.get('deepseek_enabled', False)
+
+ if not rag_enabled and not deepseek_enabled:
+ logger.info("Scoring полностью отключен (RAG и DeepSeek disabled)")
+ return None
+
+ self._init_scoring_manager()
+
+ return self._scoring_manager
_global_instance = None
diff --git a/helper_bot/utils/helper_func.py b/helper_bot/utils/helper_func.py
index 8159de9..2350a47 100644
--- a/helper_bot/utils/helper_func.py
+++ b/helper_bot/utils/helper_func.py
@@ -1,12 +1,12 @@
+import asyncio
import html
import os
import random
-import time
import tempfile
-import asyncio
+import time
from datetime import datetime, timedelta
from time import sleep
-from typing import List, Dict, Any, Optional, TYPE_CHECKING, Union
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
try:
import emoji as _emoji_lib
@@ -16,20 +16,16 @@ except ImportError:
_emoji_lib_available = False
from aiogram import types
-from aiogram.types import InputMediaPhoto, FSInputFile, InputMediaVideo, InputMediaAudio, InputMediaDocument
-
-from helper_bot.utils.base_dependency_factory import BaseDependencyFactory, get_global_instance
-from logs.custom_logger import logger
+from aiogram.types import (FSInputFile, InputMediaAudio, InputMediaDocument,
+ InputMediaPhoto, InputMediaVideo)
from database.models import TelegramPost
+from helper_bot.utils.base_dependency_factory import (BaseDependencyFactory,
+ get_global_instance)
+from logs.custom_logger import logger
# Local imports - metrics
-from .metrics import (
- track_time,
- track_errors,
- db_query_time,
- track_media_processing,
- track_file_operations,
-)
+from .metrics import (db_query_time, track_errors, track_file_operations,
+ track_media_processing, track_time)
bdf = get_global_instance()
#TODO: поменять архитектуру и подключить правильный BotDB
@@ -115,7 +111,16 @@ def determine_anonymity(post_text: str) -> bool:
return False
-def get_text_message(post_text: str, first_name: str, username: str = None, is_anonymous: Optional[bool] = None):
+def get_text_message(
+ post_text: str,
+ first_name: str,
+ username: str = None,
+ is_anonymous: Optional[bool] = None,
+ deepseek_score: Optional[float] = None,
+ rag_score: Optional[float] = None,
+ rag_confidence: Optional[float] = None,
+ rag_score_pos_only: Optional[float] = None,
+):
"""
Форматирует текст сообщения для публикации в зависимости от наличия ключевых слов "анон" и "неанон"
или переданного параметра is_anonymous.
@@ -125,6 +130,10 @@ def get_text_message(post_text: str, first_name: str, username: str = None, is_a
first_name: Имя автора поста
username: Юзернейм автора поста (может быть None)
is_anonymous: Флаг анонимности (True - анонимно, False - не анонимно, None - legacy, определяется по тексту)
+ deepseek_score: Скор от DeepSeek API (0.0-1.0, опционально)
+ rag_score: Скор от RAG/ruBERT neg/pos (0.0-1.0, опционально)
+ rag_confidence: Уверенность RAG модели (0.0-1.0, зависит от количества примеров)
+ rag_score_pos_only: Скор RAG только по положительным примерам (0.0-1.0, опционально)
Returns:
str: - Сформированный текст сообщения.
@@ -141,21 +150,37 @@ def get_text_message(post_text: str, first_name: str, username: str = None, is_a
else:
author_info = f"{first_name} (Ник не указан)"
+ # Формируем базовый текст
# Если передан is_anonymous, используем его, иначе определяем по тексту (legacy)
- # TODO: Уверен можно укоротить
if is_anonymous is not None:
if is_anonymous:
- return f'{safe_post_text}\n\nПост опубликован анонимно'
+ final_text = f'{safe_post_text}\n\nПост опубликован анонимно'
else:
- return f'{safe_post_text}\n\nАвтор поста: {author_info}'
+ final_text = f'{safe_post_text}\n\nАвтор поста: {author_info}'
else:
# Legacy: определяем по тексту
if "неанон" in post_text or "не анон" in post_text:
- return f'{safe_post_text}\n\nАвтор поста: {author_info}'
+ final_text = f'{safe_post_text}\n\nАвтор поста: {author_info}'
elif "анон" in post_text:
- return f'{safe_post_text}\n\nПост опубликован анонимно'
+ final_text = f'{safe_post_text}\n\nПост опубликован анонимно'
else:
- return f'{safe_post_text}\n\nАвтор поста: {author_info}'
+ final_text = f'{safe_post_text}\n\nАвтор поста: {author_info}'
+
+ # Добавляем блок со скорами если есть
+ if deepseek_score is not None or rag_score is not None or rag_score_pos_only is not None:
+ scores_lines = ["\n📊 Уверенность в одобрении:"]
+ if deepseek_score is not None:
+ scores_lines.append(f"DeepSeek: {deepseek_score:.2f}")
+ if rag_score is not None:
+ rag_line = f"RAG neg/pos: {rag_score:.2f}"
+ if rag_confidence is not None:
+ rag_line += f" (уверенность: {rag_confidence:.0%})"
+ scores_lines.append(rag_line)
+ if rag_score_pos_only is not None:
+ scores_lines.append(f"RAG pos only: {rag_score_pos_only:.2f}")
+ final_text += "\n" + "\n".join(scores_lines)
+
+ return final_text
@track_time("download_file", "helper_func")
@track_errors("helper_func", "download_file")
@@ -653,7 +678,7 @@ async def send_media_group_to_channel(bot, chat_id: int, post_content: List, pos
@track_errors("helper_func", "send_text_message")
async def send_text_message(chat_id, message: types.Message, post_text: str, markup: types.ReplyKeyboardMarkup = None):
from .rate_limiter import send_with_rate_limit
-
+
# Экранируем post_text для безопасного использования в HTML
safe_post_text = html.escape(str(post_text)) if post_text else ""
diff --git a/helper_bot/utils/messages.py b/helper_bot/utils/messages.py
index 57bab43..462b570 100644
--- a/helper_bot/utils/messages.py
+++ b/helper_bot/utils/messages.py
@@ -1,12 +1,7 @@
import html
# Local imports - metrics
-from .metrics import (
- metrics,
- track_time,
- track_errors
-)
-
+from .metrics import metrics, track_errors, track_time
constants = {
'HELLO_MESSAGE': "Привет, username!👋🏼&Меня зовут Виби, я бот канала 'Влюбленный Бийск'❤🤖"
diff --git a/helper_bot/utils/metrics.py b/helper_bot/utils/metrics.py
index 280da5e..60a4336 100644
--- a/helper_bot/utils/metrics.py
+++ b/helper_bot/utils/metrics.py
@@ -3,14 +3,16 @@ Metrics module for Telegram bot monitoring with Prometheus.
Provides predefined metrics for bot commands, errors, performance, and user activity.
"""
-from typing import Dict, Any, Optional
-from prometheus_client import Counter, Histogram, Gauge, generate_latest, CONTENT_TYPE_LATEST
-from prometheus_client.core import CollectorRegistry
-import time
-import os
-from functools import wraps
import asyncio
+import os
+import time
from contextlib import asynccontextmanager
+from functools import wraps
+from typing import Any, Dict, Optional
+
+from prometheus_client import (CONTENT_TYPE_LATEST, Counter, Gauge, Histogram,
+ generate_latest)
+from prometheus_client.core import CollectorRegistry
# Метрики rate limiter теперь создаются в основном классе
@@ -387,7 +389,7 @@ class BotMetrics:
"""Update rate limit gauge metrics."""
try:
from .rate_limit_monitor import rate_limit_monitor
-
+
# Обновляем количество активных чатов
self.rate_limit_active_chats.set(len(rate_limit_monitor.stats))
diff --git a/helper_bot/utils/rate_limit_monitor.py b/helper_bot/utils/rate_limit_monitor.py
index 1abb4c3..4a9c6b9 100644
--- a/helper_bot/utils/rate_limit_monitor.py
+++ b/helper_bot/utils/rate_limit_monitor.py
@@ -2,9 +2,10 @@
Мониторинг и статистика rate limiting
"""
import time
-from typing import Dict, List, Optional
-from dataclasses import dataclass, field
from collections import defaultdict, deque
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional
+
from logs.custom_logger import logger
diff --git a/helper_bot/utils/rate_limiter.py b/helper_bot/utils/rate_limiter.py
index f67cc8c..25a8891 100644
--- a/helper_bot/utils/rate_limiter.py
+++ b/helper_bot/utils/rate_limiter.py
@@ -3,10 +3,12 @@ Rate limiter для предотвращения Flood control ошибок в T
"""
import asyncio
import time
-from typing import Dict, Optional, Any, Callable
from dataclasses import dataclass
-from aiogram.exceptions import TelegramRetryAfter, TelegramAPIError
+from typing import Any, Callable, Dict, Optional
+
+from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
from logs.custom_logger import logger
+
from .metrics import metrics
@@ -182,7 +184,9 @@ class TelegramRateLimiter:
# Глобальный экземпляр rate limiter
-from helper_bot.config.rate_limit_config import get_rate_limit_config, RateLimitSettings
+from helper_bot.config.rate_limit_config import (RateLimitSettings,
+ get_rate_limit_config)
+
def _create_rate_limit_config(settings: RateLimitSettings) -> RateLimitConfig:
"""Создает RateLimitConfig из RateLimitSettings"""
diff --git a/helper_bot/utils/s3_storage.py b/helper_bot/utils/s3_storage.py
index 090fa61..9a7512f 100644
--- a/helper_bot/utils/s3_storage.py
+++ b/helper_bot/utils/s3_storage.py
@@ -1,11 +1,12 @@
"""
Сервис для работы с S3 хранилищем.
"""
-import aioboto3
import os
import tempfile
-from typing import Optional
from pathlib import Path
+from typing import Optional
+
+import aioboto3
from logs.custom_logger import logger
diff --git a/helper_bot/utils/state.py b/helper_bot/utils/state.py
index 4e953db..add2b42 100644
--- a/helper_bot/utils/state.py
+++ b/helper_bot/utils/state.py
@@ -1,4 +1,4 @@
-from aiogram.fsm.state import StatesGroup, State
+from aiogram.fsm.state import State, StatesGroup
class StateUser(StatesGroup):
diff --git a/logs/custom_logger.py b/logs/custom_logger.py
index 9f1f351..03a57f3 100644
--- a/logs/custom_logger.py
+++ b/logs/custom_logger.py
@@ -1,6 +1,7 @@
import datetime
import os
import sys
+
from loguru import logger
# Remove default handler
diff --git a/requirements.txt b/requirements.txt
index 4efef4f..c2a5b89 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -30,4 +30,7 @@ typing_extensions~=4.12.2
emoji~=2.8.0
# S3 Storage (для хранения медиафайлов опубликованных постов)
-aioboto3>=12.0.0
\ No newline at end of file
+aioboto3>=12.0.0
+
+# HTTP клиент для RAG API
+httpx>=0.24.0
\ No newline at end of file
diff --git a/run_helper.py b/run_helper.py
index b58b847..178857f 100644
--- a/run_helper.py
+++ b/run_helper.py
@@ -1,8 +1,8 @@
import asyncio
import os
-import sys
import signal
import sqlite3
+import sys
# Ensure project root is on sys.path for module resolution
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -10,12 +10,11 @@ if CURRENT_DIR not in sys.path:
sys.path.insert(0, CURRENT_DIR)
from helper_bot.main import start_bot
-from helper_bot.utils.base_dependency_factory import get_global_instance
from helper_bot.utils.auto_unban_scheduler import get_auto_unban_scheduler
+from helper_bot.utils.base_dependency_factory import get_global_instance
from logs.custom_logger import logger
-
async def main():
"""Основная функция запуска"""
@@ -69,7 +68,8 @@ async def main():
# Останавливаем планировщик метрик
try:
- from helper_bot.utils.metrics_scheduler import stop_metrics_scheduler
+ from helper_bot.utils.metrics_scheduler import \
+ stop_metrics_scheduler
stop_metrics_scheduler()
logger.info("Планировщик метрик остановлен")
except Exception as e:
diff --git a/scripts/add_ban_author_column_to_blacklist.py b/scripts/add_ban_author_column_to_blacklist.py
deleted file mode 100644
index 3513a77..0000000
--- a/scripts/add_ban_author_column_to_blacklist.py
+++ /dev/null
@@ -1,68 +0,0 @@
-#!/usr/bin/env python3
-"""
-Скрипт миграции для добавления колонки ban_author в таблицу blacklist.
-Колонка хранит user_id администратора, инициировавшего бан.
-"""
-import argparse
-import asyncio
-import os
-import sys
-from pathlib import Path
-
-import aiosqlite
-
-project_root = Path(__file__).resolve().parent.parent
-sys.path.insert(0, str(project_root))
-
-from logs.custom_logger import logger # noqa: E402
-
-DEFAULT_DB_PATH = "database/tg-bot-database.db"
-
-
-def _column_exists(rows: list, name: str) -> bool:
- """PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk)."""
- for row in rows:
- if row[1] == name:
- return True
- return False
-
-
-async def main(db_path: str) -> None:
- db_path = os.path.abspath(db_path)
- if not os.path.exists(db_path):
- logger.error("База данных не найдена: %s", db_path)
- print(f"Ошибка: база данных не найдена: {db_path}")
- return
-
- async with aiosqlite.connect(db_path) as conn:
- await conn.execute("PRAGMA foreign_keys = ON")
-
- # Проверяем наличие колонки ban_author
- cursor = await conn.execute("PRAGMA table_info(blacklist)")
- rows = await cursor.fetchall()
- await cursor.close()
-
- if not _column_exists(rows, "ban_author"):
- logger.info("Добавление колонки ban_author в blacklist")
- await conn.execute(
- "ALTER TABLE blacklist "
- "ADD COLUMN ban_author INTEGER REFERENCES our_users (user_id) ON DELETE SET NULL"
- )
- await conn.commit()
- print("Колонка ban_author добавлена в таблицу blacklist.")
- else:
- print("Колонка ban_author уже существует в таблице blacklist.")
-
-
-if __name__ == "__main__":
- parser = argparse.ArgumentParser(
- description="Добавление колонки ban_author в blacklist"
- )
- parser.add_argument(
- "--db",
- default=os.environ.get("DB_PATH", DEFAULT_DB_PATH),
- help="Путь к БД (или DB_PATH)",
- )
- args = parser.parse_args()
- asyncio.run(main(args.db))
-
diff --git a/scripts/add_is_anonymous_column.py b/scripts/add_is_anonymous_column.py
deleted file mode 100755
index 6e631fe..0000000
--- a/scripts/add_is_anonymous_column.py
+++ /dev/null
@@ -1,138 +0,0 @@
-#!/usr/bin/env python3
-"""
-Скрипт миграции для добавления колонки is_anonymous в таблицу post_from_telegram_suggest.
-Для существующих записей определяет is_anonymous на основе текста или устанавливает NULL.
-"""
-import argparse
-import asyncio
-import os
-import sys
-from pathlib import Path
-
-project_root = Path(__file__).resolve().parent.parent
-sys.path.insert(0, str(project_root))
-
-import aiosqlite
-
-from logs.custom_logger import logger
-from helper_bot.utils.helper_func import determine_anonymity
-
-DEFAULT_DB_PATH = "database/tg-bot-database.db"
-
-
-def _column_exists(rows: list, name: str) -> bool:
- """PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk)."""
- for row in rows:
- if row[1] == name:
- return True
- return False
-
-
-async def main(db_path: str) -> None:
- db_path = os.path.abspath(db_path)
- if not os.path.exists(db_path):
- logger.error("База данных не найдена: %s", db_path)
- print(f"Ошибка: база данных не найдена: {db_path}")
- return
-
- async with aiosqlite.connect(db_path) as conn:
- await conn.execute("PRAGMA foreign_keys = ON")
-
- # Проверяем наличие колонки is_anonymous
- cursor = await conn.execute(
- "PRAGMA table_info(post_from_telegram_suggest)"
- )
- rows = await cursor.fetchall()
- await cursor.close()
-
- if not _column_exists(rows, "is_anonymous"):
- logger.info("Добавление колонки is_anonymous в post_from_telegram_suggest")
- await conn.execute(
- "ALTER TABLE post_from_telegram_suggest "
- "ADD COLUMN is_anonymous INTEGER"
- )
- await conn.commit()
- print("Колонка is_anonymous добавлена.")
- else:
- print("Колонка is_anonymous уже существует.")
-
- # Получаем все записи с текстом для обновления
- cursor = await conn.execute(
- "SELECT message_id, text FROM post_from_telegram_suggest WHERE text IS NOT NULL"
- )
- posts = await cursor.fetchall()
- await cursor.close()
-
- updated_count = 0
- null_count = 0
-
- # Обновляем каждую запись
- for message_id, text in posts:
- try:
- # Определяем is_anonymous на основе текста
- # Если текст пустой или None, устанавливаем NULL (legacy)
- if not text or not text.strip():
- is_anonymous = None
- else:
- is_anonymous = determine_anonymity(text)
-
- # Преобразуем bool в int для SQLite (True -> 1, False -> 0, None -> None)
- is_anonymous_int = None if is_anonymous is None else (1 if is_anonymous else 0)
-
- await conn.execute(
- "UPDATE post_from_telegram_suggest SET is_anonymous = ? WHERE message_id = ?",
- (is_anonymous_int, message_id)
- )
-
- if is_anonymous is not None:
- updated_count += 1
- else:
- null_count += 1
-
- except Exception as e:
- logger.error(f"Ошибка при обработке поста message_id={message_id}: {e}")
- # В случае ошибки устанавливаем NULL
- await conn.execute(
- "UPDATE post_from_telegram_suggest SET is_anonymous = NULL WHERE message_id = ?",
- (message_id,)
- )
- null_count += 1
-
- # Обновляем записи без текста (устанавливаем NULL)
- cursor = await conn.execute(
- "SELECT COUNT(*) FROM post_from_telegram_suggest WHERE text IS NULL"
- )
- row = await cursor.fetchone()
- posts_without_text = row[0] if row else 0
- await cursor.close()
-
- if posts_without_text > 0:
- await conn.execute(
- "UPDATE post_from_telegram_suggest SET is_anonymous = NULL WHERE text IS NULL"
- )
- null_count += posts_without_text
-
- await conn.commit()
-
- total_updated = updated_count + null_count
- logger.info(
- f"Миграция завершена. Обновлено записей: {total_updated} "
- f"(определено: {updated_count}, установлено NULL: {null_count})"
- )
- print(f"Миграция завершена.")
- print(f"Обновлено записей: {total_updated}")
- print(f" - Определено is_anonymous: {updated_count}")
- print(f" - Установлено NULL: {null_count}")
-
-
-if __name__ == "__main__":
- parser = argparse.ArgumentParser(
- description="Добавление колонки is_anonymous в post_from_telegram_suggest"
- )
- parser.add_argument(
- "--db",
- default=os.environ.get("DB_PATH", DEFAULT_DB_PATH),
- help="Путь к БД (или DB_PATH)",
- )
- args = parser.parse_args()
- asyncio.run(main(args.db))
diff --git a/scripts/add_ml_scores_columns.py b/scripts/add_ml_scores_columns.py
new file mode 100644
index 0000000..78e237e
--- /dev/null
+++ b/scripts/add_ml_scores_columns.py
@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+"""
+Миграция: Добавление колонки для ML-скоринга постов.
+
+Добавляет:
+- ml_scores (TEXT/JSON) - JSON с результатами оценки от разных моделей
+
+Структура ml_scores:
+{
+ "deepseek": {"score": 0.75, "model": "deepseek-chat", "ts": 1706198400},
+ "rag": {"score": 0.90, "model": "rubert-base-cased", "ts": 1706198400}
+}
+"""
+import argparse
+import asyncio
+import os
+import sys
+from pathlib import Path
+
+# Добавляем корень проекта в путь
+project_root = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(project_root))
+
+import aiosqlite
+
+# Пытаемся импортировать logger, если не получается - используем стандартный
+try:
+ from logs.custom_logger import logger
+except ImportError:
+ import logging
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
+ logger = logging.getLogger(__name__)
+
+DEFAULT_DB_PATH = "database/tg-bot-database.db"
+
+
+async def column_exists(conn: aiosqlite.Connection, table: str, column: str) -> bool:
+ """Проверяет существование колонки в таблице."""
+ cursor = await conn.execute(f"PRAGMA table_info({table})")
+ columns = await cursor.fetchall()
+ return any(col[1] == column for col in columns)
+
+
+async def main(db_path: str) -> None:
+ """
+ Основная функция миграции.
+
+ Добавляет колонку ml_scores в таблицу post_from_telegram_suggest.
+ Миграция идемпотентна - можно запускать повторно без ошибок.
+ """
+ db_path = os.path.abspath(db_path)
+
+ if not os.path.exists(db_path):
+ logger.error(f"База данных не найдена: {db_path}")
+ return
+
+ async with aiosqlite.connect(db_path) as conn:
+ await conn.execute("PRAGMA foreign_keys = ON")
+
+ # Проверяем и добавляем колонку ml_scores
+ if not await column_exists(conn, "post_from_telegram_suggest", "ml_scores"):
+ await conn.execute(
+ "ALTER TABLE post_from_telegram_suggest ADD COLUMN ml_scores TEXT"
+ )
+ logger.info("Колонка ml_scores добавлена в post_from_telegram_suggest")
+ else:
+ logger.info("Колонка ml_scores уже существует")
+
+ await conn.commit()
+ logger.info("Миграция add_ml_scores_columns завершена успешно")
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="Добавление колонки ml_scores для ML-скоринга"
+ )
+ parser.add_argument(
+ "--db",
+ default=os.environ.get("DATABASE_PATH", DEFAULT_DB_PATH),
+ help="Путь к БД",
+ )
+ args = parser.parse_args()
+ asyncio.run(main(args.db))
diff --git a/scripts/add_published_posts_support.py b/scripts/add_published_posts_support.py
deleted file mode 100755
index 341c1e6..0000000
--- a/scripts/add_published_posts_support.py
+++ /dev/null
@@ -1,166 +0,0 @@
-#!/usr/bin/env python3
-"""
-Скрипт миграции для добавления поддержки опубликованных постов:
-1. Добавляет колонку published_message_id в таблицу post_from_telegram_suggest
-2. Создает таблицу published_post_content для хранения медиафайлов опубликованных постов
-3. Создает индексы для производительности
-"""
-import argparse
-import asyncio
-import os
-import sys
-from pathlib import Path
-
-project_root = Path(__file__).resolve().parent.parent
-sys.path.insert(0, str(project_root))
-
-import aiosqlite
-
-from logs.custom_logger import logger
-
-DEFAULT_DB_PATH = "database/tg-bot-database.db"
-
-
-def _column_exists(rows: list, name: str) -> bool:
- """Проверяет существование колонки в таблице.
- PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk)."""
- for row in rows:
- if row[1] == name:
- return True
- return False
-
-
-
-
-async def main(db_path: str, dry_run: bool = False) -> None:
- """Выполняет миграцию БД для поддержки опубликованных постов."""
- db_path = os.path.abspath(db_path)
- if not os.path.exists(db_path):
- logger.error("База данных не найдена: %s", db_path)
- print(f"Ошибка: база данных не найдена: {db_path}")
- return
-
- async with aiosqlite.connect(db_path) as conn:
- await conn.execute("PRAGMA foreign_keys = ON")
-
- changes_made = []
-
- # 1. Проверяем и добавляем колонку published_message_id
- cursor = await conn.execute(
- "PRAGMA table_info(post_from_telegram_suggest)"
- )
- rows = await cursor.fetchall()
- await cursor.close()
-
- if not _column_exists(rows, "published_message_id"):
- if dry_run:
- print("DRY RUN: Будет добавлена колонка published_message_id в post_from_telegram_suggest")
- changes_made.append("Добавление колонки published_message_id")
- else:
- logger.info("Добавление колонки published_message_id в post_from_telegram_suggest")
- await conn.execute(
- "ALTER TABLE post_from_telegram_suggest "
- "ADD COLUMN published_message_id INTEGER"
- )
- await conn.commit()
- print("✓ Колонка published_message_id добавлена в post_from_telegram_suggest")
- changes_made.append("Добавлена колонка published_message_id")
- else:
- print("✓ Колонка published_message_id уже существует в post_from_telegram_suggest")
-
- # 2. Проверяем и создаем таблицу published_post_content
- cursor = await conn.execute(
- "SELECT name FROM sqlite_master WHERE type='table' AND name='published_post_content'"
- )
- table_exists = await cursor.fetchone()
- await cursor.close()
-
- if not table_exists:
- if dry_run:
- print("DRY RUN: Будет создана таблица published_post_content")
- changes_made.append("Создание таблицы published_post_content")
- else:
- logger.info("Создание таблицы published_post_content")
- await conn.execute("""
- CREATE TABLE IF NOT EXISTS published_post_content (
- published_message_id INTEGER NOT NULL,
- content_name TEXT NOT NULL,
- content_type TEXT,
- published_at INTEGER NOT NULL,
- PRIMARY KEY (published_message_id, content_name)
- )
- """)
- await conn.commit()
- print("✓ Таблица published_post_content создана")
- changes_made.append("Создана таблица published_post_content")
- else:
- print("✓ Таблица published_post_content уже существует")
-
- # 3. Проверяем и создаем индексы
- indexes = [
- ("idx_published_post_content_message_id",
- "CREATE INDEX IF NOT EXISTS idx_published_post_content_message_id "
- "ON published_post_content(published_message_id)"),
- ("idx_post_from_telegram_suggest_published",
- "CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_published "
- "ON post_from_telegram_suggest(published_message_id)")
- ]
-
- for index_name, index_sql in indexes:
- cursor = await conn.execute(
- "SELECT name FROM sqlite_master WHERE type='index' AND name=?",
- (index_name,)
- )
- index_exists = await cursor.fetchone()
- await cursor.close()
-
- if not index_exists:
- if dry_run:
- print(f"DRY RUN: Будет создан индекс {index_name}")
- changes_made.append(f"Создание индекса {index_name}")
- else:
- logger.info(f"Создание индекса {index_name}")
- await conn.execute(index_sql)
- await conn.commit()
- print(f"✓ Индекс {index_name} создан")
- changes_made.append(f"Создан индекс {index_name}")
- else:
- print(f"✓ Индекс {index_name} уже существует")
-
- # Финальная статистика
- if dry_run:
- if changes_made:
- print("\n" + "="*60)
- print("DRY RUN: Следующие изменения будут выполнены:")
- for change in changes_made:
- print(f" - {change}")
- print("="*60)
- else:
- print("\n✓ Все необходимые изменения уже применены. Ничего делать не нужно.")
- else:
- if changes_made:
- logger.info(f"Миграция завершена. Выполнено изменений: {len(changes_made)}")
- print(f"\n✓ Миграция завершена успешно!")
- print(f"Выполнено изменений: {len(changes_made)}")
- for change in changes_made:
- print(f" - {change}")
- else:
- print("\n✓ Все необходимые изменения уже применены. Ничего делать не нужно.")
-
-
-if __name__ == "__main__":
- parser = argparse.ArgumentParser(
- description="Добавление поддержки опубликованных постов в БД"
- )
- parser.add_argument(
- "--db",
- default=os.environ.get("DB_PATH", DEFAULT_DB_PATH),
- help="Путь к БД (или переменная окружения DB_PATH)",
- )
- parser.add_argument(
- "--dry-run",
- action="store_true",
- help="Показать что будет сделано без выполнения изменений",
- )
- args = parser.parse_args()
- asyncio.run(main(args.db, dry_run=args.dry_run))
diff --git a/scripts/apply_migrations.py b/scripts/apply_migrations.py
new file mode 100644
index 0000000..aff54d3
--- /dev/null
+++ b/scripts/apply_migrations.py
@@ -0,0 +1,241 @@
+#!/usr/bin/env python3
+"""
+Скрипт для автоматического применения миграций базы данных.
+
+Сканирует папку scripts/ и применяет все новые миграции, которые еще не были применены.
+"""
+import argparse
+import asyncio
+import importlib.util
+import os
+import subprocess
+import sys
+from pathlib import Path
+from typing import List, Tuple
+
+# Исключаем служебные скрипты из миграций
+EXCLUDED_SCRIPTS = {
+ 'apply_migrations.py',
+ 'test_s3_connection.py',
+ 'voice_cleanup.py',
+}
+
+DEFAULT_DB_PATH = "database/tg-bot-database.db"
+
+
+def get_migration_scripts(scripts_dir: Path) -> List[Tuple[str, Path]]:
+ """
+ Получает список скриптов миграций из папки scripts.
+
+ Возвращает список кортежей (имя_файла, путь_к_файлу), отсортированный по имени файла.
+ """
+ scripts = []
+ for script_file in sorted(scripts_dir.glob("*.py")):
+ if script_file.name not in EXCLUDED_SCRIPTS:
+ scripts.append((script_file.name, script_file))
+ return scripts
+
+
+async def is_migration_script(script_path: Path) -> bool:
+ """
+ Проверяет, является ли скрипт миграцией.
+
+ Миграция должна иметь функцию main() с параметром db_path.
+ """
+ try:
+ spec = importlib.util.spec_from_file_location("migration_script", script_path)
+ if spec is None or spec.loader is None:
+ return False
+
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+
+ # Проверяем наличие функции main
+ if hasattr(module, 'main'):
+ import inspect
+ sig = inspect.signature(module.main)
+ # Проверяем, что функция принимает db_path
+ params = list(sig.parameters.keys())
+ return 'db_path' in params
+ return False
+ except Exception:
+ # Если не удалось проверить, считаем что это не миграция
+ return False
+
+
+async def apply_migration(script_path: Path, db_path: str) -> bool:
+ """
+ Применяет миграцию, запуская скрипт.
+
+ Returns:
+ True если миграция применена успешно, False в противном случае.
+ """
+ script_name = script_path.name
+
+ try:
+ # Запускаем скрипт как отдельный процесс
+ result = subprocess.run(
+ [sys.executable, str(script_path), "--db", db_path],
+ cwd=script_path.parent.parent,
+ capture_output=True,
+ text=True,
+ timeout=300 # 5 минут максимум на миграцию
+ )
+
+ if result.returncode == 0:
+ if result.stdout:
+ print(f" {result.stdout.strip()}")
+ return True
+ else:
+ print(f" ❌ Ошибка:")
+ if result.stdout:
+ print(f" STDOUT: {result.stdout}")
+ if result.stderr:
+ print(f" STDERR: {result.stderr}")
+ return False
+
+ except subprocess.TimeoutExpired:
+ print(f" ❌ Превышен лимит времени (5 минут)")
+ return False
+ except Exception as e:
+ print(f" ❌ Ошибка: {e}")
+ return False
+
+
+async def main(db_path: str, dry_run: bool = False) -> None:
+ """
+ Основная функция для применения миграций.
+
+ Args:
+ db_path: Путь к базе данных
+ dry_run: Если True, только показывает какие миграции будут применены
+ """
+ # Импортируем зависимости только когда они действительно нужны
+ project_root = Path(__file__).resolve().parent.parent
+ sys.path.insert(0, str(project_root))
+
+ # Проверяем наличие необходимых зависимостей
+ try:
+ import aiosqlite
+ except ImportError:
+ print("❌ Ошибка: модуль aiosqlite не установлен.")
+ print("💡 Установите зависимости: pip install -r requirements.txt")
+ sys.exit(1)
+
+ # Импортируем logger
+ try:
+ from logs.custom_logger import logger
+ except ImportError:
+ import logging
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
+ logger = logging.getLogger(__name__)
+
+ # Импортируем MigrationRepository напрямую из файла
+ migration_repo_path = project_root / "database" / "repositories" / "migration_repository.py"
+ if not migration_repo_path.exists():
+ print(f"❌ Файл migration_repository.py не найден: {migration_repo_path}")
+ sys.exit(1)
+
+ spec = importlib.util.spec_from_file_location("migration_repository", migration_repo_path)
+ if spec is None or spec.loader is None:
+ print("❌ Не удалось загрузить модуль migration_repository")
+ sys.exit(1)
+
+ migration_module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(migration_module)
+ MigrationRepository = migration_module.MigrationRepository
+
+ db_path = os.path.abspath(db_path)
+ if not os.path.exists(db_path):
+ logger.error(f"База данных не найдена: {db_path}")
+ print(f"❌ Ошибка: база данных не найдена: {db_path}")
+ return
+
+ scripts_dir = project_root / "scripts"
+ if not scripts_dir.exists():
+ logger.error(f"Папка scripts не найдена: {scripts_dir}")
+ print(f"❌ Ошибка: папка scripts не найдена: {scripts_dir}")
+ return
+
+ # Инициализируем репозиторий миграций напрямую
+ migration_repo = MigrationRepository(db_path)
+ await migration_repo.create_table()
+
+ # Получаем список примененных миграций
+ applied_migrations = await migration_repo.get_applied_migrations()
+ logger.info(f"Примененных миграций: {len(applied_migrations)}")
+
+ # Получаем все скрипты миграций
+ all_scripts = get_migration_scripts(scripts_dir)
+
+ # Фильтруем только миграции
+ migration_scripts = []
+ for script_name, script_path in all_scripts:
+ if await is_migration_script(script_path):
+ migration_scripts.append((script_name, script_path))
+ else:
+ logger.debug(f"Скрипт {script_name} не является миграцией, пропускаем")
+
+ # Находим новые миграции
+ new_migrations = [
+ (name, path) for name, path in migration_scripts
+ if name not in applied_migrations
+ ]
+
+ if not new_migrations:
+ print("✅ Все миграции уже применены")
+ logger.info("Новых миграций не найдено")
+ return
+
+ print(f"📋 Найдено новых миграций: {len(new_migrations)}")
+ for name, _ in new_migrations:
+ print(f" - {name}")
+
+ if dry_run:
+ print("\n🔍 DRY RUN: миграции не будут применены")
+ return
+
+ # Применяем миграции по порядку
+ print("\n🚀 Применение миграций...")
+ failed_migrations = []
+
+ for script_name, script_path in new_migrations:
+ print(f"📝 {script_name}...", end=" ", flush=True)
+ success = await apply_migration(script_path, db_path)
+ if success:
+ # Отмечаем миграцию как примененную
+ await migration_repo.mark_migration_applied(script_name)
+ print("✅")
+ else:
+ failed_migrations.append(script_name)
+ print("❌")
+ logger.error(f"Не удалось применить миграцию: {script_name}")
+ # Прерываем выполнение при ошибке
+ print(f"\n⚠️ Прерывание: миграция {script_name} завершилась с ошибкой")
+ break
+
+ if failed_migrations:
+ print(f"\n❌ Не удалось применить {len(failed_migrations)} миграций:")
+ for name in failed_migrations:
+ print(f" - {name}")
+ sys.exit(1)
+ else:
+ print(f"\n✅ Все миграции применены успешно ({len(new_migrations)} шт.)")
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="Применение миграций базы данных"
+ )
+ parser.add_argument(
+ "--db",
+ default=os.environ.get("DATABASE_PATH", DEFAULT_DB_PATH),
+ help="Путь к БД (или DATABASE_PATH из env)",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Показать какие миграции будут применены без фактического применения",
+ )
+ args = parser.parse_args()
+ asyncio.run(main(args.db, args.dry_run))
diff --git a/scripts/backfill_post_status_legacy.py b/scripts/backfill_post_status_legacy.py
deleted file mode 100644
index 1b7580a..0000000
--- a/scripts/backfill_post_status_legacy.py
+++ /dev/null
@@ -1,82 +0,0 @@
-#!/usr/bin/env python3
-"""
-Скрипт для проставления status='legacy' всем существующим записям в post_from_telegram_suggest.
-Добавляет колонку status, если её нет, затем обновляет все строки.
-"""
-import argparse
-import asyncio
-import os
-import sys
-from pathlib import Path
-
-project_root = Path(__file__).resolve().parent.parent
-sys.path.insert(0, str(project_root))
-
-import aiosqlite
-
-from logs.custom_logger import logger
-
-DEFAULT_DB_PATH = "database/tg-bot-database.db"
-
-
-def _column_exists(rows: list, name: str) -> bool:
- """PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk)."""
- for row in rows:
- if row[1] == name:
- return True
- return False
-
-
-async def main(db_path: str) -> None:
- db_path = os.path.abspath(db_path)
- if not os.path.exists(db_path):
- logger.error("База данных не найдена: %s", db_path)
- print(f"Ошибка: база данных не найдена: {db_path}")
- return
-
- async with aiosqlite.connect(db_path) as conn:
- await conn.execute("PRAGMA foreign_keys = ON")
-
- # Проверяем наличие колонки status
- cursor = await conn.execute(
- "PRAGMA table_info(post_from_telegram_suggest)"
- )
- rows = await cursor.fetchall()
- await cursor.close()
-
- if not _column_exists(rows, "status"):
- logger.info("Добавление колонки status в post_from_telegram_suggest")
- await conn.execute(
- "ALTER TABLE post_from_telegram_suggest "
- "ADD COLUMN status TEXT NOT NULL DEFAULT 'suggest'"
- )
- await conn.commit()
- print("Колонка status добавлена.")
- else:
- print("Колонка status уже существует.")
-
- # Обновляем все существующие записи на legacy
- await conn.execute(
- "UPDATE post_from_telegram_suggest SET status = 'legacy'"
- )
- await conn.commit()
- cursor = await conn.execute("SELECT changes()")
- row = await cursor.fetchone()
- updated = row[0] if row else 0
- await cursor.close()
-
- logger.info("Обновлено записей в post_from_telegram_suggest: %d", updated)
- print(f"Обновлено записей: {updated}")
-
-
-if __name__ == "__main__":
- parser = argparse.ArgumentParser(
- description="Backfill status='legacy' для post_from_telegram_suggest"
- )
- parser.add_argument(
- "--db",
- default=os.environ.get("DB_PATH", DEFAULT_DB_PATH),
- help="Путь к БД (или DB_PATH)",
- )
- args = parser.parse_args()
- asyncio.run(main(args.db))
diff --git a/scripts/clean_post_text.py b/scripts/clean_post_text.py
deleted file mode 100755
index 2e1b1a3..0000000
--- a/scripts/clean_post_text.py
+++ /dev/null
@@ -1,148 +0,0 @@
-#!/usr/bin/env python3
-"""
-Скрипт для приведения текста постов к "сырому" виду.
-Удаляет форматирование, добавленное функцией get_text_message(), оставляя только исходный текст.
-"""
-import argparse
-import asyncio
-import html
-import os
-import re
-import sys
-from pathlib import Path
-
-project_root = Path(__file__).resolve().parent.parent
-sys.path.insert(0, str(project_root))
-
-import aiosqlite
-
-from logs.custom_logger import logger
-
-DEFAULT_DB_PATH = "database/tg-bot-database.db"
-
-# Паттерны для определения форматированного текста
-PREFIX = "Пост из ТГ:\n"
-ANONYMOUS_SUFFIX = "\n\nПост опубликован анонимно"
-AUTHOR_SUFFIX_PATTERN = re.compile(r"\n\nАвтор поста: .+$")
-
-
-def extract_raw_text(formatted_text: str) -> str:
- """
- Извлекает сырой текст из форматированного текста поста.
-
- Args:
- formatted_text: Форматированный текст поста
-
- Returns:
- str: Сырой текст или исходный текст, если форматирование не обнаружено
- """
- if not formatted_text:
- return ""
-
- # Проверяем, начинается ли текст с префикса
- if not formatted_text.startswith(PREFIX):
- # Текст уже в сыром виде или имеет другой формат
- return formatted_text
-
- # Извлекаем текст после префикса
- text_after_prefix = formatted_text[len(PREFIX):]
-
- # Проверяем, заканчивается ли текст на "Пост опубликован анонимно"
- if text_after_prefix.endswith(ANONYMOUS_SUFFIX):
- raw_text = text_after_prefix[:-len(ANONYMOUS_SUFFIX)]
- # Проверяем, заканчивается ли текст на "Автор поста: ..."
- elif AUTHOR_SUFFIX_PATTERN.search(text_after_prefix):
- raw_text = AUTHOR_SUFFIX_PATTERN.sub("", text_after_prefix)
- else:
- # Не удалось определить формат, возвращаем текст без префикса
- raw_text = text_after_prefix
-
- # Декодируем HTML-экранирование
- raw_text = html.unescape(raw_text)
-
- return raw_text
-
-
-async def main(db_path: str, dry_run: bool = False) -> None:
- db_path = os.path.abspath(db_path)
- if not os.path.exists(db_path):
- logger.error("База данных не найдена: %s", db_path)
- print(f"Ошибка: база данных не найдена: {db_path}")
- return
-
- async with aiosqlite.connect(db_path) as conn:
- await conn.execute("PRAGMA foreign_keys = ON")
-
- # Получаем все записи с текстом
- cursor = await conn.execute(
- "SELECT message_id, text FROM post_from_telegram_suggest WHERE text IS NOT NULL AND text != ''"
- )
- posts = await cursor.fetchall()
- await cursor.close()
-
- updated_count = 0
- skipped_count = 0
- error_count = 0
-
- print(f"Найдено записей для обработки: {len(posts)}")
- if dry_run:
- print("РЕЖИМ ПРОВЕРКИ (dry-run): изменения не будут сохранены")
-
- # Обрабатываем каждую запись
- for message_id, formatted_text in posts:
- try:
- # Извлекаем сырой текст
- raw_text = extract_raw_text(formatted_text)
-
- # Проверяем, изменился ли текст
- if raw_text == formatted_text:
- skipped_count += 1
- continue
-
- if dry_run:
- print(f"\n[DRY-RUN] message_id={message_id}:")
- print(f" Было: {formatted_text[:100]}...")
- print(f" Станет: {raw_text[:100]}...")
- else:
- # Обновляем запись
- await conn.execute(
- "UPDATE post_from_telegram_suggest SET text = ? WHERE message_id = ?",
- (raw_text, message_id)
- )
- updated_count += 1
-
- except Exception as e:
- logger.error(f"Ошибка при обработке поста message_id={message_id}: {e}")
- error_count += 1
-
- if not dry_run:
- await conn.commit()
-
- total_processed = updated_count + skipped_count + error_count
- logger.info(
- f"Обработка завершена. Всего записей: {total_processed}, "
- f"обновлено: {updated_count}, пропущено: {skipped_count}, ошибок: {error_count}"
- )
- print(f"\nОбработка завершена:")
- print(f" - Всего записей: {total_processed}")
- print(f" - Обновлено: {updated_count}")
- print(f" - Пропущено (уже в сыром виде): {skipped_count}")
- print(f" - Ошибок: {error_count}")
-
-
-if __name__ == "__main__":
- parser = argparse.ArgumentParser(
- description="Приведение текста постов к 'сырому' виду"
- )
- parser.add_argument(
- "--db",
- default=os.environ.get("DB_PATH", DEFAULT_DB_PATH),
- help="Путь к БД (или DB_PATH)",
- )
- parser.add_argument(
- "--dry-run",
- action="store_true",
- help="Режим проверки без сохранения изменений",
- )
- args = parser.parse_args()
- asyncio.run(main(args.db, args.dry_run))
diff --git a/scripts/create_blacklist_history_table.py b/scripts/create_blacklist_history_table.py
deleted file mode 100644
index 5c1c517..0000000
--- a/scripts/create_blacklist_history_table.py
+++ /dev/null
@@ -1,94 +0,0 @@
-#!/usr/bin/env python3
-"""
-Скрипт миграции для создания таблицы blacklist_history.
-Таблица хранит историю всех операций бана/разбана пользователей.
-"""
-import argparse
-import asyncio
-import os
-import sys
-from pathlib import Path
-
-project_root = Path(__file__).resolve().parent.parent
-sys.path.insert(0, str(project_root))
-
-import aiosqlite
-
-from logs.custom_logger import logger
-
-DEFAULT_DB_PATH = "database/tg-bot-database.db"
-
-
-def _table_exists(rows: list, table_name: str) -> bool:
- """Проверяет существование таблицы по результатам PRAGMA table_list."""
- for row in rows:
- if row[1] == table_name: # name column
- return True
- return False
-
-
-async def main(db_path: str) -> None:
- db_path = os.path.abspath(db_path)
- if not os.path.exists(db_path):
- logger.error("База данных не найдена: %s", db_path)
- print(f"Ошибка: база данных не найдена: {db_path}")
- return
-
- async with aiosqlite.connect(db_path) as conn:
- await conn.execute("PRAGMA foreign_keys = ON")
-
- # Проверяем наличие таблицы blacklist_history
- cursor = await conn.execute(
- "SELECT name FROM sqlite_master WHERE type='table' AND name='blacklist_history'"
- )
- rows = await cursor.fetchall()
- await cursor.close()
-
- if not rows:
- logger.info("Создание таблицы blacklist_history")
-
- # Создаем таблицу
- await conn.execute("""
- CREATE TABLE IF NOT EXISTS blacklist_history (
- id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- message_for_user TEXT,
- date_ban INTEGER NOT NULL,
- date_unban INTEGER,
- ban_author INTEGER,
- created_at INTEGER DEFAULT (strftime('%s', 'now')),
- updated_at INTEGER DEFAULT (strftime('%s', 'now')),
- FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE,
- FOREIGN KEY (ban_author) REFERENCES our_users(user_id) ON DELETE SET NULL
- )
- """)
-
- # Создаем индексы
- await conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_blacklist_history_user_id ON blacklist_history(user_id)"
- )
- await conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_ban ON blacklist_history(date_ban)"
- )
- await conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_unban ON blacklist_history(date_unban)"
- )
-
- await conn.commit()
- logger.info("Таблица blacklist_history и индексы успешно созданы")
- print("Таблица blacklist_history и индексы успешно созданы.")
- else:
- print("Таблица blacklist_history уже существует.")
-
-
-if __name__ == "__main__":
- parser = argparse.ArgumentParser(
- description="Создание таблицы blacklist_history для истории банов/разбанов"
- )
- parser.add_argument(
- "--db",
- default=os.environ.get("DB_PATH", DEFAULT_DB_PATH),
- help="Путь к БД (или DB_PATH)",
- )
- args = parser.parse_args()
- asyncio.run(main(args.db))
diff --git a/scripts/drop_vector_hash_column.py b/scripts/drop_vector_hash_column.py
new file mode 100644
index 0000000..4d0bdd5
--- /dev/null
+++ b/scripts/drop_vector_hash_column.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+"""
+Миграция: Удаление колонки vector_hash из таблицы post_from_telegram_suggest.
+
+Колонка больше не нужна, т.к. RAG сервис вынесен в отдельный микросервис
+и хранит векторы самостоятельно.
+
+SQLite не поддерживает DROP COLUMN напрямую (до версии 3.35.0),
+поэтому используем пересоздание таблицы.
+"""
+import argparse
+import asyncio
+import os
+import sys
+from pathlib import Path
+
+# Добавляем корень проекта в путь
+project_root = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(project_root))
+
+import aiosqlite
+
+# Пытаемся импортировать logger, если не получается - используем стандартный
+try:
+ from logs.custom_logger import logger
+except ImportError:
+ import logging
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
+ logger = logging.getLogger(__name__)
+
+DEFAULT_DB_PATH = "database/tg-bot-database.db"
+
+
+async def column_exists(conn: aiosqlite.Connection, table: str, column: str) -> bool:
+ """Проверяет существование колонки в таблице."""
+ cursor = await conn.execute(f"PRAGMA table_info({table})")
+ columns = await cursor.fetchall()
+ return any(col[1] == column for col in columns)
+
+
+async def get_sqlite_version(conn: aiosqlite.Connection) -> tuple:
+ """Возвращает версию SQLite."""
+ cursor = await conn.execute("SELECT sqlite_version()")
+ version_str = (await cursor.fetchone())[0]
+ return tuple(map(int, version_str.split('.')))
+
+
+async def main(db_path: str) -> None:
+ """
+ Удаляет колонку vector_hash из таблицы post_from_telegram_suggest.
+ """
+ db_path = os.path.abspath(db_path)
+
+ if not os.path.exists(db_path):
+ logger.error(f"База данных не найдена: {db_path}")
+ return
+
+ async with aiosqlite.connect(db_path) as conn:
+ # Проверяем существует ли колонка
+ if not await column_exists(conn, "post_from_telegram_suggest", "vector_hash"):
+ logger.info("Колонка vector_hash не существует, миграция не требуется")
+ return
+
+ # Проверяем версию SQLite
+ version = await get_sqlite_version(conn)
+ logger.info(f"Версия SQLite: {'.'.join(map(str, version))}")
+
+ # SQLite 3.35.0+ поддерживает DROP COLUMN
+ if version >= (3, 35, 0):
+ logger.info("Используем ALTER TABLE DROP COLUMN")
+ await conn.execute(
+ "ALTER TABLE post_from_telegram_suggest DROP COLUMN vector_hash"
+ )
+ else:
+ # Для старых версий пересоздаём таблицу
+ logger.info("Используем пересоздание таблицы (SQLite < 3.35.0)")
+
+ # Получаем список колонок без vector_hash
+ cursor = await conn.execute("PRAGMA table_info(post_from_telegram_suggest)")
+ columns = await cursor.fetchall()
+ column_names = [col[1] for col in columns if col[1] != "vector_hash"]
+ columns_str = ", ".join(column_names)
+
+ logger.info(f"Колонки для сохранения: {columns_str}")
+
+ # Пересоздаём таблицу
+ await conn.execute("BEGIN TRANSACTION")
+ try:
+ # Создаём временную таблицу
+ await conn.execute(
+ f"CREATE TABLE post_from_telegram_suggest_backup AS "
+ f"SELECT {columns_str} FROM post_from_telegram_suggest"
+ )
+
+ # Удаляем старую таблицу
+ await conn.execute("DROP TABLE post_from_telegram_suggest")
+
+ # Переименовываем временную
+ await conn.execute(
+ "ALTER TABLE post_from_telegram_suggest_backup "
+ "RENAME TO post_from_telegram_suggest"
+ )
+
+ await conn.execute("COMMIT")
+ except Exception as e:
+ await conn.execute("ROLLBACK")
+ raise e
+
+ await conn.commit()
+ logger.info("Колонка vector_hash успешно удалена")
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="Удаление колонки vector_hash из post_from_telegram_suggest"
+ )
+ parser.add_argument(
+ "--db",
+ default=os.environ.get("DATABASE_PATH", DEFAULT_DB_PATH),
+ help="Путь к БД",
+ )
+ args = parser.parse_args()
+ asyncio.run(main(args.db))
diff --git a/scripts/migrate_blacklist_to_history.py b/scripts/migrate_blacklist_to_history.py
deleted file mode 100644
index cc302ba..0000000
--- a/scripts/migrate_blacklist_to_history.py
+++ /dev/null
@@ -1,125 +0,0 @@
-#!/usr/bin/env python3
-"""
-Скрипт миграции для переноса записей из blacklist в blacklist_history.
-Переносит все существующие записи из таблицы blacklist в таблицу blacklist_history.
-"""
-import argparse
-import asyncio
-import os
-import sys
-from pathlib import Path
-from datetime import datetime
-
-project_root = Path(__file__).resolve().parent.parent
-sys.path.insert(0, str(project_root))
-
-import aiosqlite
-
-from logs.custom_logger import logger
-
-DEFAULT_DB_PATH = "database/tg-bot-database.db"
-
-
-async def main(db_path: str) -> None:
- db_path = os.path.abspath(db_path)
- if not os.path.exists(db_path):
- logger.error("База данных не найдена: %s", db_path)
- print(f"Ошибка: база данных не найдена: {db_path}")
- return
-
- async with aiosqlite.connect(db_path) as conn:
- await conn.execute("PRAGMA foreign_keys = ON")
-
- # Проверяем наличие таблицы blacklist_history
- cursor = await conn.execute(
- "SELECT name FROM sqlite_master WHERE type='table' AND name='blacklist_history'"
- )
- rows = await cursor.fetchall()
- await cursor.close()
-
- if not rows:
- logger.error("Таблица blacklist_history не найдена. Сначала запустите create_blacklist_history_table.py")
- print("Ошибка: таблица blacklist_history не найдена. Сначала запустите create_blacklist_history_table.py")
- return
-
- # Получаем все записи из blacklist
- cursor = await conn.execute(
- "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist"
- )
- blacklist_records = await cursor.fetchall()
- await cursor.close()
-
- if not blacklist_records:
- print("В таблице blacklist нет записей для переноса.")
- logger.info("В таблице blacklist нет записей для переноса")
- return
-
- logger.info("Найдено записей в blacklist для переноса: %d", len(blacklist_records))
- print(f"Найдено записей в blacklist для переноса: {len(blacklist_records)}")
-
- # Получаем текущее время в Unix timestamp
- current_time = int(datetime.now().timestamp())
-
- # Переносим записи в blacklist_history
- migrated_count = 0
- skipped_count = 0
-
- for record in blacklist_records:
- user_id, message_for_user, date_to_unban, created_at, ban_author = record
-
- # Проверяем, нет ли уже записи для этого user_id с таким же date_ban
- # (чтобы избежать дубликатов при повторном запуске)
- date_ban = created_at if created_at is not None else current_time
-
- check_cursor = await conn.execute(
- "SELECT id FROM blacklist_history WHERE user_id = ? AND date_ban = ?",
- (user_id, date_ban)
- )
- existing = await check_cursor.fetchone()
- await check_cursor.close()
-
- if existing:
- logger.debug("Запись для user_id=%d с date_ban=%d уже существует, пропускаем", user_id, date_ban)
- skipped_count += 1
- continue
-
- # Вставляем запись в blacklist_history
- await conn.execute(
- """
- INSERT INTO blacklist_history
- (user_id, message_for_user, date_ban, date_unban, ban_author, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?)
- """,
- (
- user_id,
- message_for_user,
- date_ban,
- date_to_unban,
- ban_author,
- created_at if created_at is not None else current_time,
- current_time
- )
- )
- migrated_count += 1
-
- await conn.commit()
-
- logger.info(
- "Миграция завершена. Перенесено записей: %d, пропущено (дубликаты): %d",
- migrated_count,
- skipped_count
- )
- print(f"Миграция завершена. Перенесено записей: {migrated_count}, пропущено (дубликаты): {skipped_count}")
-
-
-if __name__ == "__main__":
- parser = argparse.ArgumentParser(
- description="Перенос записей из blacklist в blacklist_history"
- )
- parser.add_argument(
- "--db",
- default=os.environ.get("DB_PATH", DEFAULT_DB_PATH),
- help="Путь к БД (или DB_PATH)",
- )
- args = parser.parse_args()
- asyncio.run(main(args.db))
diff --git a/scripts/test_s3_connection.py b/scripts/test_s3_connection.py
index fe37fc7..66abe03 100755
--- a/scripts/test_s3_connection.py
+++ b/scripts/test_s3_connection.py
@@ -13,6 +13,7 @@ sys.path.insert(0, str(project_root))
# Загружаем .env файл
from dotenv import load_dotenv
+
env_path = os.path.join(project_root, '.env')
if os.path.exists(env_path):
load_dotenv(env_path)
diff --git a/scripts/voice_cleanup.py b/scripts/voice_cleanup.py
index 992fe57..7bb89d4 100644
--- a/scripts/voice_cleanup.py
+++ b/scripts/voice_cleanup.py
@@ -3,8 +3,8 @@
Скрипт для диагностики и очистки проблем с голосовыми файлами
"""
import asyncio
-import sys
import os
+import sys
from pathlib import Path
# Добавляем корневую директорию проекта в путь
diff --git a/tests/conftest.py b/tests/conftest.py
index fdf84e5..702d2b2 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,11 +1,11 @@
-import pytest
import asyncio
import os
import sys
-from unittest.mock import Mock, AsyncMock, patch
-from aiogram.types import Message, User, Chat
-from aiogram.fsm.context import FSMContext
+from unittest.mock import AsyncMock, Mock, patch
+import pytest
+from aiogram.fsm.context import FSMContext
+from aiogram.types import Chat, Message, User
from database.async_db import AsyncBotDB
# Импортируем моки в самом начале
diff --git a/tests/conftest_message_repository.py b/tests/conftest_message_repository.py
index f206a97..573943f 100644
--- a/tests/conftest_message_repository.py
+++ b/tests/conftest_message_repository.py
@@ -1,9 +1,10 @@
-import pytest
-import tempfile
import os
+import tempfile
from datetime import datetime
-from database.repositories.message_repository import MessageRepository
+
+import pytest
from database.models import UserMessage
+from database.repositories.message_repository import MessageRepository
@pytest.fixture(scope="session")
diff --git a/tests/conftest_post_repository.py b/tests/conftest_post_repository.py
index 3fcfec0..fca1784 100644
--- a/tests/conftest_post_repository.py
+++ b/tests/conftest_post_repository.py
@@ -1,11 +1,12 @@
-import pytest
import asyncio
import os
import tempfile
from datetime import datetime
-from unittest.mock import Mock, AsyncMock
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+from database.models import MessageContentLink, PostContent, TelegramPost
from database.repositories.post_repository import PostRepository
-from database.models import TelegramPost, PostContent, MessageContentLink
@pytest.fixture(scope="session")
diff --git a/tests/mocks.py b/tests/mocks.py
index 927fbac..4833698 100644
--- a/tests/mocks.py
+++ b/tests/mocks.py
@@ -1,10 +1,11 @@
"""
Моки для тестового окружения
"""
-import sys
import os
+import sys
from unittest.mock import Mock, patch
+
# Патчим загрузку настроек до импорта модулей
def setup_test_mocks():
"""Настройка моков для тестов"""
diff --git a/tests/test_admin_repository.py b/tests/test_admin_repository.py
index 0881f4a..033be96 100644
--- a/tests/test_admin_repository.py
+++ b/tests/test_admin_repository.py
@@ -1,10 +1,10 @@
-import pytest
-from unittest.mock import Mock, AsyncMock, patch, MagicMock
-from datetime import datetime
import time
+from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
-from database.repositories.admin_repository import AdminRepository
+import pytest
from database.models import Admin
+from database.repositories.admin_repository import AdminRepository
class TestAdminRepository:
diff --git a/tests/test_async_db.py b/tests/test_async_db.py
index 81b3f8a..e3ca0a6 100644
--- a/tests/test_async_db.py
+++ b/tests/test_async_db.py
@@ -1,5 +1,6 @@
+from unittest.mock import AsyncMock, Mock, patch
+
import pytest
-from unittest.mock import Mock, AsyncMock, patch
from database.async_db import AsyncBotDB
diff --git a/tests/test_audio_file_service.py b/tests/test_audio_file_service.py
index 4d82343..5452f47 100644
--- a/tests/test_audio_file_service.py
+++ b/tests/test_audio_file_service.py
@@ -1,10 +1,11 @@
-import pytest
-from unittest.mock import Mock, AsyncMock, patch, MagicMock, mock_open
-from datetime import datetime
import time
+from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch
+import pytest
+from helper_bot.handlers.voice.exceptions import (DatabaseError,
+ FileOperationError)
from helper_bot.handlers.voice.services import AudioFileService
-from helper_bot.handlers.voice.exceptions import FileOperationError, DatabaseError
@pytest.fixture
diff --git a/tests/test_audio_repository.py b/tests/test_audio_repository.py
index 56f6bcb..37fef5a 100644
--- a/tests/test_audio_repository.py
+++ b/tests/test_audio_repository.py
@@ -1,10 +1,10 @@
-import pytest
-from unittest.mock import Mock, AsyncMock, patch, MagicMock
-from datetime import datetime
import time
+from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
+import pytest
+from database.models import AudioListenRecord, AudioMessage, AudioModerate
from database.repositories.audio_repository import AudioRepository
-from database.models import AudioMessage, AudioListenRecord, AudioModerate
class TestAudioRepository:
diff --git a/tests/test_audio_repository_schema.py b/tests/test_audio_repository_schema.py
index ad0596e..b7428ea 100644
--- a/tests/test_audio_repository_schema.py
+++ b/tests/test_audio_repository_schema.py
@@ -1,8 +1,8 @@
-import pytest
-from unittest.mock import Mock, AsyncMock, patch, MagicMock
-from datetime import datetime
import time
+from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
+import pytest
from database.repositories.audio_repository import AudioRepository
diff --git a/tests/test_auto_unban_integration.py b/tests/test_auto_unban_integration.py
index 5113d92..8078d0d 100644
--- a/tests/test_auto_unban_integration.py
+++ b/tests/test_auto_unban_integration.py
@@ -1,9 +1,9 @@
-import pytest
-import sqlite3
import os
-from datetime import datetime, timezone, timedelta
-from unittest.mock import Mock, patch, AsyncMock
+import sqlite3
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, Mock, patch
+import pytest
from helper_bot.utils.auto_unban_scheduler import AutoUnbanScheduler
@@ -155,8 +155,9 @@ class TestAutoUnbanIntegration:
}
# Создаем реальный экземпляр базы данных с тестовым файлом
- from database.async_db import AsyncBotDB
import os
+
+ from database.async_db import AsyncBotDB
mock_factory.database = AsyncBotDB(test_db_path)
return mock_factory
diff --git a/tests/test_auto_unban_scheduler.py b/tests/test_auto_unban_scheduler.py
index 2d79976..294cfb2 100644
--- a/tests/test_auto_unban_scheduler.py
+++ b/tests/test_auto_unban_scheduler.py
@@ -1,9 +1,10 @@
-import pytest
import asyncio
-from datetime import datetime, timezone, timedelta
-from unittest.mock import Mock, patch, AsyncMock
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, Mock, patch
-from helper_bot.utils.auto_unban_scheduler import AutoUnbanScheduler, get_auto_unban_scheduler
+import pytest
+from helper_bot.utils.auto_unban_scheduler import (AutoUnbanScheduler,
+ get_auto_unban_scheduler)
class TestAutoUnbanScheduler:
diff --git a/tests/test_blacklist_history_repository.py b/tests/test_blacklist_history_repository.py
index b0ceca6..9ca7cba 100644
--- a/tests/test_blacklist_history_repository.py
+++ b/tests/test_blacklist_history_repository.py
@@ -1,10 +1,11 @@
-import pytest
-from unittest.mock import Mock, AsyncMock, patch
-from datetime import datetime
import time
+from datetime import datetime
+from unittest.mock import AsyncMock, Mock, patch
-from database.repositories.blacklist_history_repository import BlacklistHistoryRepository
+import pytest
from database.models import BlacklistHistoryRecord
+from database.repositories.blacklist_history_repository import \
+ BlacklistHistoryRepository
class TestBlacklistHistoryRepository:
diff --git a/tests/test_blacklist_repository.py b/tests/test_blacklist_repository.py
index ae4bc21..f1cbf88 100644
--- a/tests/test_blacklist_repository.py
+++ b/tests/test_blacklist_repository.py
@@ -1,10 +1,10 @@
-import pytest
-from unittest.mock import Mock, AsyncMock, patch, MagicMock
-from datetime import datetime
import time
+from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
-from database.repositories.blacklist_repository import BlacklistRepository
+import pytest
from database.models import BlacklistUser
+from database.repositories.blacklist_repository import BlacklistRepository
class TestBlacklistRepository:
diff --git a/tests/test_callback_handlers.py b/tests/test_callback_handlers.py
index 5831aa1..1b80c65 100644
--- a/tests/test_callback_handlers.py
+++ b/tests/test_callback_handlers.py
@@ -1,13 +1,11 @@
-import pytest
-from unittest.mock import Mock, AsyncMock, patch, MagicMock
-from datetime import datetime
import time
+from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
+import pytest
from helper_bot.handlers.callback.callback_handlers import (
- save_voice_message,
- delete_voice_message
-)
-from helper_bot.handlers.voice.constants import CALLBACK_SAVE, CALLBACK_DELETE
+ delete_voice_message, save_voice_message)
+from helper_bot.handlers.voice.constants import CALLBACK_DELETE, CALLBACK_SAVE
@pytest.fixture
diff --git a/tests/test_improved_media_processing.py b/tests/test_improved_media_processing.py
index 7c2b642..d39fac0 100644
--- a/tests/test_improved_media_processing.py
+++ b/tests/test_improved_media_processing.py
@@ -2,18 +2,15 @@
Тесты для улучшенных методов обработки медиа
"""
-import pytest
import os
import tempfile
-from unittest.mock import Mock, AsyncMock, patch, MagicMock
-from aiogram import types
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
+import pytest
+from aiogram import types
from helper_bot.utils.helper_func import (
- download_file,
- add_in_db_media,
- add_in_db_media_mediagroup,
- send_media_group_message_to_private_chat
-)
+ add_in_db_media, add_in_db_media_mediagroup, download_file,
+ send_media_group_message_to_private_chat)
class TestDownloadFile:
diff --git a/tests/test_keyboards_and_filters.py b/tests/test_keyboards_and_filters.py
index 3af0aee..f6e25d2 100644
--- a/tests/test_keyboards_and_filters.py
+++ b/tests/test_keyboards_and_filters.py
@@ -1,16 +1,15 @@
-import pytest
-from unittest.mock import Mock, patch, AsyncMock
-from aiogram.types import ReplyKeyboardMarkup, KeyboardButton, InlineKeyboardMarkup, InlineKeyboardButton
+from unittest.mock import AsyncMock, Mock, patch
-from helper_bot.keyboards.keyboards import (
- get_reply_keyboard,
- get_reply_keyboard_admin,
- get_reply_keyboard_for_post,
- get_reply_keyboard_leave_chat,
- create_keyboard_with_pagination
-)
-from helper_bot.filters.main import ChatTypeFilter
+import pytest
+from aiogram.types import (InlineKeyboardButton, InlineKeyboardMarkup,
+ KeyboardButton, ReplyKeyboardMarkup)
from database.async_db import AsyncBotDB
+from helper_bot.filters.main import ChatTypeFilter
+from helper_bot.keyboards.keyboards import (create_keyboard_with_pagination,
+ get_reply_keyboard,
+ get_reply_keyboard_admin,
+ get_reply_keyboard_for_post,
+ get_reply_keyboard_leave_chat)
class TestKeyboards:
@@ -112,7 +111,7 @@ class TestKeyboards:
assert isinstance(keyboard, ReplyKeyboardMarkup)
assert keyboard.keyboard is not None
- assert len(keyboard.keyboard) == 2 # Две строки
+ assert len(keyboard.keyboard) == 3 # Три строки
# Проверяем первую строку (3 кнопки)
first_row = keyboard.keyboard[0]
@@ -125,7 +124,12 @@ class TestKeyboards:
second_row = keyboard.keyboard[1]
assert len(second_row) == 2
assert second_row[0].text == "Разбан (список)"
- assert second_row[1].text == "Вернуться в бота"
+ assert second_row[1].text == "📊 ML Статистика"
+
+ # Проверяем третью строку (1 кнопка)
+ third_row = keyboard.keyboard[2]
+ assert len(third_row) == 1
+ assert third_row[0].text == "Вернуться в бота"
def test_get_reply_keyboard_for_post(self):
"""Тест клавиатуры для постов"""
diff --git a/tests/test_message_repository.py b/tests/test_message_repository.py
index f17ea72..a9d4a85 100644
--- a/tests/test_message_repository.py
+++ b/tests/test_message_repository.py
@@ -1,9 +1,10 @@
-import pytest
import asyncio
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
-from database.repositories.message_repository import MessageRepository
+
+import pytest
from database.models import UserMessage
+from database.repositories.message_repository import MessageRepository
class TestMessageRepository:
diff --git a/tests/test_message_repository_integration.py b/tests/test_message_repository_integration.py
index 9a49ca2..d52b650 100644
--- a/tests/test_message_repository_integration.py
+++ b/tests/test_message_repository_integration.py
@@ -1,10 +1,11 @@
-import pytest
import asyncio
-import tempfile
import os
+import tempfile
from datetime import datetime
-from database.repositories.message_repository import MessageRepository
+
+import pytest
from database.models import UserMessage
+from database.repositories.message_repository import MessageRepository
class TestMessageRepositoryIntegration:
diff --git a/tests/test_post_repository.py b/tests/test_post_repository.py
index 441ec3d..867a3be 100644
--- a/tests/test_post_repository.py
+++ b/tests/test_post_repository.py
@@ -1,9 +1,10 @@
-import pytest
import asyncio
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from database.models import MessageContentLink, PostContent, TelegramPost
from database.repositories.post_repository import PostRepository
-from database.models import TelegramPost, PostContent, MessageContentLink
class TestPostRepository:
diff --git a/tests/test_post_repository_integration.py b/tests/test_post_repository_integration.py
index 5b6139b..c6c21b6 100644
--- a/tests/test_post_repository_integration.py
+++ b/tests/test_post_repository_integration.py
@@ -1,10 +1,11 @@
-import pytest
import asyncio
import os
import tempfile
from datetime import datetime
+
+import pytest
+from database.models import MessageContentLink, PostContent, TelegramPost
from database.repositories.post_repository import PostRepository
-from database.models import TelegramPost, PostContent, MessageContentLink
class TestPostRepositoryIntegration:
diff --git a/tests/test_post_service.py b/tests/test_post_service.py
index bc77238..03bcba3 100644
--- a/tests/test_post_service.py
+++ b/tests/test_post_service.py
@@ -1,12 +1,12 @@
"""Tests for PostService"""
-import pytest
-from unittest.mock import Mock, AsyncMock, MagicMock, patch
from datetime import datetime
-from aiogram import types
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
-from helper_bot.handlers.private.services import PostService, BotSettings
+import pytest
+from aiogram import types
from database.models import TelegramPost, User
+from helper_bot.handlers.private.services import BotSettings, PostService
class TestPostService:
diff --git a/tests/test_rate_limiter.py b/tests/test_rate_limiter.py
index 1a0916c..9ffbc80 100644
--- a/tests/test_rate_limiter.py
+++ b/tests/test_rate_limiter.py
@@ -3,19 +3,18 @@
"""
import asyncio
import time
-import pytest
from unittest.mock import AsyncMock, MagicMock, patch
-from helper_bot.utils.rate_limiter import (
- RateLimitConfig,
- ChatRateLimiter,
- GlobalRateLimiter,
- RetryHandler,
- TelegramRateLimiter,
- send_with_rate_limit
-)
-from helper_bot.utils.rate_limit_monitor import RateLimitMonitor, RateLimitStats, record_rate_limit_request
-from helper_bot.config.rate_limit_config import RateLimitSettings, get_rate_limit_config
+import pytest
+from helper_bot.config.rate_limit_config import (RateLimitSettings,
+ get_rate_limit_config)
+from helper_bot.utils.rate_limit_monitor import (RateLimitMonitor,
+ RateLimitStats,
+ record_rate_limit_request)
+from helper_bot.utils.rate_limiter import (ChatRateLimiter, GlobalRateLimiter,
+ RateLimitConfig, RetryHandler,
+ TelegramRateLimiter,
+ send_with_rate_limit)
class TestRateLimitConfig:
diff --git a/tests/test_refactored_admin_handlers.py b/tests/test_refactored_admin_handlers.py
index cc6d794..1823375 100644
--- a/tests/test_refactored_admin_handlers.py
+++ b/tests/test_refactored_admin_handlers.py
@@ -1,14 +1,12 @@
+from unittest.mock import AsyncMock, Mock, patch
+
import pytest
-from unittest.mock import Mock, AsyncMock, patch
from aiogram import types
from aiogram.fsm.context import FSMContext
-
-from helper_bot.handlers.admin.services import AdminService, User, BannedUser
-from helper_bot.handlers.admin.exceptions import (
- UserNotFoundError,
- UserAlreadyBannedError,
- InvalidInputError
-)
+from helper_bot.handlers.admin.exceptions import (InvalidInputError,
+ UserAlreadyBannedError,
+ UserNotFoundError)
+from helper_bot.handlers.admin.services import AdminService, BannedUser, User
class TestAdminService:
diff --git a/tests/test_refactored_group_handlers.py b/tests/test_refactored_group_handlers.py
index 41335f2..c3d4cf6 100644
--- a/tests/test_refactored_group_handlers.py
+++ b/tests/test_refactored_group_handlers.py
@@ -1,16 +1,16 @@
"""Tests for refactored group handlers"""
+from unittest.mock import AsyncMock, MagicMock, Mock
+
import pytest
-from unittest.mock import Mock, AsyncMock, MagicMock
from aiogram import types
from aiogram.fsm.context import FSMContext
-
-from helper_bot.handlers.group.group_handlers import (
- create_group_handlers, GroupHandlers
-)
+from helper_bot.handlers.group.constants import ERROR_MESSAGES, FSM_STATES
+from helper_bot.handlers.group.exceptions import (NoReplyToMessageError,
+ UserNotFoundError)
+from helper_bot.handlers.group.group_handlers import (GroupHandlers,
+ create_group_handlers)
from helper_bot.handlers.group.services import AdminReplyService
-from helper_bot.handlers.group.exceptions import NoReplyToMessageError, UserNotFoundError
-from helper_bot.handlers.group.constants import FSM_STATES, ERROR_MESSAGES
class TestGroupHandlers:
diff --git a/tests/test_refactored_private_handlers.py b/tests/test_refactored_private_handlers.py
index 0e11999..64ed9c6 100644
--- a/tests/test_refactored_private_handlers.py
+++ b/tests/test_refactored_private_handlers.py
@@ -1,15 +1,14 @@
"""Tests for refactored private handlers"""
+from unittest.mock import AsyncMock, MagicMock, Mock
+
import pytest
-from unittest.mock import Mock, AsyncMock, MagicMock
from aiogram import types
from aiogram.fsm.context import FSMContext
-
+from helper_bot.handlers.private.constants import BUTTON_TEXTS, FSM_STATES
from helper_bot.handlers.private.private_handlers import (
- create_private_handlers, PrivateHandlers
-)
+ PrivateHandlers, create_private_handlers)
from helper_bot.handlers.private.services import BotSettings
-from helper_bot.handlers.private.constants import FSM_STATES, BUTTON_TEXTS
class TestPrivateHandlers:
diff --git a/tests/test_scoring_services.py b/tests/test_scoring_services.py
new file mode 100644
index 0000000..d827390
--- /dev/null
+++ b/tests/test_scoring_services.py
@@ -0,0 +1,395 @@
+"""
+Тесты для сервисов ML-скоринга постов.
+"""
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+# Импорты для тестирования базовых классов
+from helper_bot.services.scoring.base import CombinedScore, ScoringResult
+from helper_bot.services.scoring.exceptions import (InsufficientExamplesError,
+ ScoringError,
+ TextTooShortError)
+
+
+class TestScoringResult:
+ """Тесты для ScoringResult."""
+
+ def test_create_valid_score(self):
+ """Тест создания валидного результата."""
+ result = ScoringResult(
+ score=0.75,
+ source="rag",
+ model="test-model",
+ )
+ assert result.score == 0.75
+ assert result.source == "rag"
+ assert result.model == "test-model"
+
+ def test_score_validation_lower_bound(self):
+ """Тест валидации нижней границы скора."""
+ with pytest.raises(ValueError):
+ ScoringResult(score=-0.1, source="test", model="test")
+
+ def test_score_validation_upper_bound(self):
+ """Тест валидации верхней границы скора."""
+ with pytest.raises(ValueError):
+ ScoringResult(score=1.1, source="test", model="test")
+
+ def test_to_dict(self):
+ """Тест преобразования в словарь."""
+ result = ScoringResult(
+ score=0.7534,
+ source="rag",
+ model="test-model",
+ confidence=0.85,
+ timestamp=1234567890,
+ )
+ d = result.to_dict()
+
+ assert d["score"] == 0.7534 # Округлено до 4 знаков
+ assert d["model"] == "test-model"
+ assert d["ts"] == 1234567890
+ assert d["confidence"] == 0.85
+
+ def test_from_dict(self):
+ """Тест создания из словаря."""
+ data = {
+ "score": 0.75,
+ "model": "test-model",
+ "ts": 1234567890,
+ "confidence": 0.9,
+ }
+ result = ScoringResult.from_dict("rag", data)
+
+ assert result.score == 0.75
+ assert result.source == "rag"
+ assert result.model == "test-model"
+ assert result.timestamp == 1234567890
+ assert result.confidence == 0.9
+
+
+class TestCombinedScore:
+ """Тесты для CombinedScore."""
+
+ def test_empty_combined_score(self):
+ """Тест пустого объединенного скора."""
+ score = CombinedScore()
+
+ assert score.deepseek is None
+ assert score.rag is None
+ assert score.deepseek_score is None
+ assert score.rag_score is None
+ assert not score.has_any_score()
+
+ def test_combined_score_with_rag(self):
+ """Тест объединенного скора с RAG."""
+ rag_result = ScoringResult(score=0.8, source="rag", model="rubert")
+ score = CombinedScore(rag=rag_result)
+
+ assert score.rag_score == 0.8
+ assert score.deepseek_score is None
+ assert score.has_any_score()
+
+ def test_combined_score_with_both(self):
+ """Тест объединенного скора с обоими сервисами."""
+ rag_result = ScoringResult(score=0.8, source="rag", model="rubert")
+ deepseek_result = ScoringResult(score=0.7, source="deepseek", model="deepseek-chat")
+ score = CombinedScore(rag=rag_result, deepseek=deepseek_result)
+
+ assert score.rag_score == 0.8
+ assert score.deepseek_score == 0.7
+ assert score.has_any_score()
+
+ def test_to_json_dict(self):
+ """Тест преобразования в JSON словарь."""
+ rag_result = ScoringResult(score=0.8, source="rag", model="rubert", timestamp=123)
+ deepseek_result = ScoringResult(score=0.7, source="deepseek", model="deepseek-chat", timestamp=456)
+ score = CombinedScore(rag=rag_result, deepseek=deepseek_result)
+
+ d = score.to_json_dict()
+
+ assert "rag" in d
+ assert "deepseek" in d
+ assert d["rag"]["score"] == 0.8
+ assert d["deepseek"]["score"] == 0.7
+
+ # Проверяем что это валидный JSON
+ json_str = json.dumps(d)
+ assert json_str
+
+
+class TestVectorStore:
+ """Тесты для VectorStore (требует numpy)."""
+
+ @pytest.fixture
+ def vector_store(self):
+ """Создает VectorStore для тестов."""
+ try:
+ import numpy as np
+ from helper_bot.services.scoring.vector_store import VectorStore
+ return VectorStore(vector_dim=768, max_examples=100)
+ except ImportError:
+ pytest.skip("numpy не установлен")
+
+ def test_add_positive_example(self, vector_store):
+ """Тест добавления положительного примера."""
+ import numpy as np
+
+ vector = np.random.randn(768).astype(np.float32)
+ result = vector_store.add_positive(vector, "hash1")
+
+ assert result is True
+ assert vector_store.positive_count == 1
+
+ def test_add_duplicate_example(self, vector_store):
+ """Тест добавления дубликата."""
+ import numpy as np
+
+ vector = np.random.randn(768).astype(np.float32)
+ vector_store.add_positive(vector, "hash1")
+ result = vector_store.add_positive(vector, "hash1") # Дубликат
+
+ assert result is False
+ assert vector_store.positive_count == 1
+
+ def test_max_examples_limit(self, vector_store):
+ """Тест ограничения максимального количества примеров."""
+ import numpy as np
+
+ # Добавляем больше чем max_examples
+ for i in range(150):
+ vector = np.random.randn(768).astype(np.float32)
+ vector_store.add_positive(vector, f"hash_{i}")
+
+ assert vector_store.positive_count == 100 # max_examples
+
+ def test_calculate_similarity_no_examples(self, vector_store):
+ """Тест расчета скора без примеров."""
+ import numpy as np
+
+ vector = np.random.randn(768).astype(np.float32)
+
+ with pytest.raises(InsufficientExamplesError):
+ vector_store.calculate_similarity_score(vector)
+
+ def test_calculate_similarity_with_examples(self, vector_store):
+ """Тест расчета скора с примерами."""
+ import numpy as np
+
+ # Добавляем положительные примеры
+ for i in range(10):
+ vector = np.random.randn(768).astype(np.float32)
+ vector_store.add_positive(vector, f"pos_{i}")
+
+ # Добавляем отрицательные примеры
+ for i in range(10):
+ vector = np.random.randn(768).astype(np.float32)
+ vector_store.add_negative(vector, f"neg_{i}")
+
+ # Рассчитываем скор для нового вектора
+ test_vector = np.random.randn(768).astype(np.float32)
+ score, confidence = vector_store.calculate_similarity_score(test_vector)
+
+ assert 0.0 <= score <= 1.0
+ assert 0.0 <= confidence <= 1.0
+
+ def test_compute_text_hash(self, vector_store):
+ """Тест вычисления хеша текста."""
+ from helper_bot.services.scoring.vector_store import VectorStore
+
+ hash1 = VectorStore.compute_text_hash("Привет мир")
+ hash2 = VectorStore.compute_text_hash("Привет мир")
+ hash3 = VectorStore.compute_text_hash("Другой текст")
+
+ assert hash1 == hash2
+ assert hash1 != hash3
+
+
+class TestDeepSeekService:
+ """Тесты для DeepSeekService."""
+
+ @pytest.fixture
+ def deepseek_service(self):
+ """Создает DeepSeekService для тестов."""
+ from helper_bot.services.scoring.deepseek_service import \
+ DeepSeekService
+ return DeepSeekService(
+ api_key="test_key",
+ enabled=True,
+ timeout=5,
+ )
+
+ def test_service_disabled_without_key(self):
+ """Тест отключения сервиса без API ключа."""
+ from helper_bot.services.scoring.deepseek_service import \
+ DeepSeekService
+ service = DeepSeekService(api_key=None, enabled=True)
+
+ assert service.is_enabled is False
+
+ def test_parse_score_response_valid(self, deepseek_service):
+ """Тест парсинга валидного ответа."""
+ assert deepseek_service._parse_score_response("0.75") == 0.75
+ assert deepseek_service._parse_score_response("0.5") == 0.5
+ assert deepseek_service._parse_score_response("1.0") == 1.0
+ assert deepseek_service._parse_score_response("0") == 0.0
+
+ def test_parse_score_response_with_quotes(self, deepseek_service):
+ """Тест парсинга ответа с кавычками."""
+ assert deepseek_service._parse_score_response('"0.75"') == 0.75
+ assert deepseek_service._parse_score_response("'0.8'") == 0.8
+
+ def test_parse_score_response_with_text(self, deepseek_service):
+ """Тест парсинга ответа с текстом."""
+ # Сервис должен найти число в тексте
+ assert deepseek_service._parse_score_response("Score: 0.75") == 0.75
+
+ def test_clean_text(self, deepseek_service):
+ """Тест очистки текста."""
+ assert deepseek_service._clean_text(" hello world ") == "hello world"
+ assert deepseek_service._clean_text("^") == ""
+ assert deepseek_service._clean_text("") == ""
+
+ @pytest.mark.asyncio
+ async def test_calculate_score_disabled(self):
+ """Тест расчета скора при отключенном сервисе."""
+ from helper_bot.services.scoring.deepseek_service import \
+ DeepSeekService
+ service = DeepSeekService(api_key=None, enabled=False)
+
+ with pytest.raises(ScoringError):
+ await service.calculate_score("Test text")
+
+ @pytest.mark.asyncio
+ async def test_calculate_score_short_text(self, deepseek_service):
+ """Тест расчета скора для короткого текста."""
+ with pytest.raises(TextTooShortError):
+ await deepseek_service.calculate_score("ab")
+
+
+class TestScoringManager:
+ """Тесты для ScoringManager."""
+
+ @pytest.fixture
+ def mock_rag_service(self):
+ """Создает мок RAG сервиса."""
+ mock = AsyncMock()
+ mock.is_enabled = True
+ mock.calculate_score = AsyncMock(return_value=ScoringResult(
+ score=0.8,
+ source="rag",
+ model="rubert",
+ ))
+ mock.add_positive_example = AsyncMock()
+ mock.add_negative_example = AsyncMock()
+ return mock
+
+ @pytest.fixture
+ def mock_deepseek_service(self):
+ """Создает мок DeepSeek сервиса."""
+ mock = AsyncMock()
+ mock.is_enabled = True
+ mock.calculate_score = AsyncMock(return_value=ScoringResult(
+ score=0.7,
+ source="deepseek",
+ model="deepseek-chat",
+ ))
+ mock.add_positive_example = AsyncMock()
+ mock.add_negative_example = AsyncMock()
+ return mock
+
+ @pytest.mark.asyncio
+ async def test_score_post_both_services(self, mock_rag_service, mock_deepseek_service):
+ """Тест скоринга с обоими сервисами."""
+ from helper_bot.services.scoring.scoring_manager import ScoringManager
+
+ manager = ScoringManager(
+ rag_client=mock_rag_service,
+ deepseek_service=mock_deepseek_service,
+ )
+
+ result = await manager.score_post("Тестовый пост")
+
+ assert result.rag_score == 0.8
+ assert result.deepseek_score == 0.7
+ assert result.has_any_score()
+
+ @pytest.mark.asyncio
+ async def test_score_post_rag_only(self, mock_rag_service):
+ """Тест скоринга только с RAG."""
+ from helper_bot.services.scoring.scoring_manager import ScoringManager
+
+ manager = ScoringManager(
+ rag_client=mock_rag_service,
+ deepseek_service=None,
+ )
+
+ result = await manager.score_post("Тестовый пост")
+
+ assert result.rag_score == 0.8
+ assert result.deepseek_score is None
+
+ @pytest.mark.asyncio
+ async def test_score_post_empty_text(self, mock_rag_service):
+ """Тест скоринга пустого текста."""
+ from helper_bot.services.scoring.scoring_manager import ScoringManager
+
+ manager = ScoringManager(rag_client=mock_rag_service)
+
+ result = await manager.score_post("")
+
+ assert not result.has_any_score()
+ mock_rag_service.calculate_score.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_score_post_service_error(self, mock_rag_service, mock_deepseek_service):
+ """Тест обработки ошибки сервиса."""
+ from helper_bot.services.scoring.scoring_manager import ScoringManager
+
+ # RAG выбрасывает ошибку
+ mock_rag_service.calculate_score = AsyncMock(side_effect=Exception("Test error"))
+
+ manager = ScoringManager(
+ rag_client=mock_rag_service,
+ deepseek_service=mock_deepseek_service,
+ )
+
+ result = await manager.score_post("Тестовый пост")
+
+ # DeepSeek должен вернуть результат
+ assert result.deepseek_score == 0.7
+ # RAG должен быть None с ошибкой
+ assert result.rag_score is None
+ assert "rag" in result.errors
+
+ @pytest.mark.asyncio
+ async def test_on_post_published(self, mock_rag_service, mock_deepseek_service):
+ """Тест обучения на опубликованном посте."""
+ from helper_bot.services.scoring.scoring_manager import ScoringManager
+
+ manager = ScoringManager(
+ rag_client=mock_rag_service,
+ deepseek_service=mock_deepseek_service,
+ )
+
+ await manager.on_post_published("Опубликованный пост")
+
+ mock_rag_service.add_positive_example.assert_called_once_with("Опубликованный пост")
+ mock_deepseek_service.add_positive_example.assert_called_once_with("Опубликованный пост")
+
+ @pytest.mark.asyncio
+ async def test_on_post_declined(self, mock_rag_service, mock_deepseek_service):
+ """Тест обучения на отклоненном посте."""
+ from helper_bot.services.scoring.scoring_manager import ScoringManager
+
+ manager = ScoringManager(
+ rag_client=mock_rag_service,
+ deepseek_service=mock_deepseek_service,
+ )
+
+ await manager.on_post_declined("Отклоненный пост")
+
+ mock_rag_service.add_negative_example.assert_called_once_with("Отклоненный пост")
+ mock_deepseek_service.add_negative_example.assert_called_once_with("Отклоненный пост")
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 12651fc..2953237 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,39 +1,24 @@
-import pytest
-from unittest.mock import Mock, patch, AsyncMock
-from datetime import datetime
import os
+from datetime import datetime
+from unittest.mock import AsyncMock, Mock, patch
-from helper_bot.utils.helper_func import (
- get_first_name,
- get_text_message,
- determine_anonymity,
- check_username_and_full_name,
- safe_html_escape,
- download_file,
- prepare_media_group_from_middlewares,
- add_in_db_media_mediagroup,
- add_in_db_media,
- send_media_group_message_to_private_chat,
- send_media_group_to_channel,
- send_text_message,
- send_photo_message,
- send_video_message,
- send_video_note_message,
- send_audio_message,
- send_voice_message,
- check_access,
- add_days_to_date,
- get_banned_users_list,
- get_banned_users_buttons,
- delete_user_blacklist,
- update_user_info,
- check_user_emoji,
- get_random_emoji
-)
-from helper_bot.utils.messages import get_message
-from helper_bot.utils.base_dependency_factory import BaseDependencyFactory, get_global_instance
+import helper_bot.utils.messages as messages # Import for patching constants
+import pytest
from database.async_db import AsyncBotDB
-import helper_bot.utils.messages as messages # Import for patching constants
+from helper_bot.utils.base_dependency_factory import (BaseDependencyFactory,
+ get_global_instance)
+from helper_bot.utils.helper_func import (
+ add_days_to_date, add_in_db_media, add_in_db_media_mediagroup,
+ check_access, check_user_emoji, check_username_and_full_name,
+ delete_user_blacklist, determine_anonymity, download_file,
+ get_banned_users_buttons, get_banned_users_list, get_first_name,
+ get_random_emoji, get_text_message, prepare_media_group_from_middlewares,
+ safe_html_escape, send_audio_message,
+ send_media_group_message_to_private_chat, send_media_group_to_channel,
+ send_photo_message, send_text_message, send_video_message,
+ send_video_note_message, send_voice_message, update_user_info)
+from helper_bot.utils.messages import get_message
+
class TestHelperFunctions:
"""Тесты для вспомогательных функций"""
diff --git a/tests/test_voice_bot_architecture.py b/tests/test_voice_bot_architecture.py
index f1f80f4..f0ca934 100644
--- a/tests/test_voice_bot_architecture.py
+++ b/tests/test_voice_bot_architecture.py
@@ -1,11 +1,14 @@
-import pytest
-from unittest.mock import Mock, AsyncMock, patch
from datetime import datetime
from pathlib import Path
+from unittest.mock import AsyncMock, Mock, patch
+import pytest
+from helper_bot.handlers.voice.exceptions import (AudioProcessingError,
+ VoiceMessageError)
from helper_bot.handlers.voice.services import VoiceBotService
-from helper_bot.handlers.voice.exceptions import VoiceMessageError, AudioProcessingError
-from helper_bot.handlers.voice.utils import get_last_message_text, validate_voice_message, get_user_emoji_safe
+from helper_bot.handlers.voice.utils import (get_last_message_text,
+ get_user_emoji_safe,
+ validate_voice_message)
class TestVoiceBotService:
diff --git a/tests/test_voice_constants.py b/tests/test_voice_constants.py
index 8a13c31..b6f7ba4 100644
--- a/tests/test_voice_constants.py
+++ b/tests/test_voice_constants.py
@@ -1,21 +1,14 @@
import pytest
-from helper_bot.handlers.voice.constants import (
- BUTTON_COMMAND_MAPPING,
- COMMAND_MAPPING,
- CALLBACK_COMMAND_MAPPING,
- VOICE_BOT_NAME,
- STATE_START,
- STATE_STANDUP_WRITE,
- BTN_SPEAK,
- BTN_LISTEN,
- CMD_START,
- CMD_HELP,
- CMD_RESTART,
- CMD_EMOJI,
- CMD_REFRESH,
- CALLBACK_SAVE,
- CALLBACK_DELETE
-)
+from helper_bot.handlers.voice.constants import (BTN_LISTEN, BTN_SPEAK,
+ BUTTON_COMMAND_MAPPING,
+ CALLBACK_COMMAND_MAPPING,
+ CALLBACK_DELETE,
+ CALLBACK_SAVE, CMD_EMOJI,
+ CMD_HELP, CMD_REFRESH,
+ CMD_RESTART, CMD_START,
+ COMMAND_MAPPING,
+ STATE_STANDUP_WRITE,
+ STATE_START, VOICE_BOT_NAME)
class TestVoiceConstants:
diff --git a/tests/test_voice_exceptions.py b/tests/test_voice_exceptions.py
index d60667d..c10cb8d 100644
--- a/tests/test_voice_exceptions.py
+++ b/tests/test_voice_exceptions.py
@@ -1,9 +1,7 @@
import pytest
-from helper_bot.handlers.voice.exceptions import (
- VoiceMessageError,
- AudioProcessingError,
- VoiceBotError
-)
+from helper_bot.handlers.voice.exceptions import (AudioProcessingError,
+ VoiceBotError,
+ VoiceMessageError)
class TestVoiceExceptions:
diff --git a/tests/test_voice_handler.py b/tests/test_voice_handler.py
index 46c5f5c..3f5556d 100644
--- a/tests/test_voice_handler.py
+++ b/tests/test_voice_handler.py
@@ -1,10 +1,11 @@
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
+
import pytest
-from unittest.mock import Mock, AsyncMock, patch, MagicMock
from aiogram import types
from aiogram.fsm.context import FSMContext
-
+from helper_bot.handlers.voice.constants import (STATE_STANDUP_WRITE,
+ STATE_START)
from helper_bot.handlers.voice.voice_handler import VoiceHandlers
-from helper_bot.handlers.voice.constants import STATE_START, STATE_STANDUP_WRITE
class TestVoiceHandler:
diff --git a/tests/test_voice_services.py b/tests/test_voice_services.py
index 2af3043..0853244 100644
--- a/tests/test_voice_services.py
+++ b/tests/test_voice_services.py
@@ -1,10 +1,11 @@
-import pytest
-from unittest.mock import Mock, AsyncMock, patch, MagicMock
-from pathlib import Path
from datetime import datetime
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
+import pytest
+from helper_bot.handlers.voice.exceptions import (AudioProcessingError,
+ VoiceMessageError)
from helper_bot.handlers.voice.services import VoiceBotService
-from helper_bot.handlers.voice.exceptions import VoiceMessageError, AudioProcessingError
class TestVoiceBotService:
diff --git a/tests/test_voice_utils.py b/tests/test_voice_utils.py
index 9b2053e..4949090 100644
--- a/tests/test_voice_utils.py
+++ b/tests/test_voice_utils.py
@@ -1,15 +1,12 @@
-import pytest
-from unittest.mock import Mock, patch
from datetime import datetime, timedelta
-from aiogram import types
+from unittest.mock import Mock, patch
-from helper_bot.handlers.voice.utils import (
- get_last_message_text,
- validate_voice_message,
- get_user_emoji_safe,
- format_time_ago,
- plural_time
-)
+import pytest
+from aiogram import types
+from helper_bot.handlers.voice.utils import (format_time_ago,
+ get_last_message_text,
+ get_user_emoji_safe, plural_time,
+ validate_voice_message)
class TestVoiceUtils:
@@ -120,7 +117,7 @@ class TestVoiceUtils:
def test_format_time_ago_minutes(self):
"""Тест форматирования времени в минутах"""
from datetime import datetime, timedelta
-
+
# Создаем дату 30 минут назад
test_date = (datetime.now() - timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S")
@@ -133,7 +130,7 @@ class TestVoiceUtils:
def test_format_time_ago_hours(self):
"""Тест форматирования времени в часах"""
from datetime import datetime, timedelta
-
+
# Создаем дату 2 часа назад
test_date = (datetime.now() - timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S")
@@ -145,7 +142,7 @@ class TestVoiceUtils:
def test_format_time_ago_days(self):
"""Тест форматирования времени в днях"""
from datetime import datetime, timedelta
-
+
# Создаем дату 3 дня назад
test_date = (datetime.now() - timedelta(days=3)).strftime("%Y-%m-%d %H:%M:%S")