From e2b1353408f9708f09201a8271954b0ed13f2649 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 25 Jan 2026 23:17:09 +0300 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=D0=B0=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=91=D0=94=20=D0=B8=20CI/CD=20=D0=BF=D0=B0=D0=B9=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D0=B9=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Создана система отслеживания миграций (MigrationRepository, таблица migrations) - Добавлен скрипт apply_migrations.py для автоматического применения миграций - Созданы CI/CD пайплайны (.github/workflows/ci.yml, deploy.yml) - Обновлена документация по миграциям в database-patterns.md - Миграции применяются автоматически при деплое в продакшн --- .cursor/rules/database-patterns.md | 106 +++++- .cursor/rules/middleware-patterns.mdc | 8 + .github/workflows/ci.yml | 93 ++++++ .github/workflows/deploy.yml | 307 ++++++++++++++++++ :memory: | Bin 0 -> 4096 bytes database/__init__.py | 11 +- database/async_db.py | 30 +- database/base.py | 3 +- database/models.py | 5 +- database/repositories/__init__.py | 15 +- database/repositories/admin_repository.py | 1 + database/repositories/audio_repository.py | 7 +- .../blacklist_history_repository.py | 1 + database/repositories/blacklist_repository.py | 3 +- database/repositories/message_repository.py | 1 + database/repositories/migration_repository.py | 79 +++++ database/repositories/post_repository.py | 5 +- database/repositories/user_repository.py | 3 +- database/repository_factory.py | 22 +- database/schema.sql | 7 + helper_bot/handlers/admin/__init__.py | 20 +- helper_bot/handlers/admin/admin_handlers.py | 44 +-- helper_bot/handlers/admin/constants.py | 2 +- helper_bot/handlers/admin/dependencies.py | 5 +- .../handlers/admin/rate_limit_handlers.py | 20 +- helper_bot/handlers/admin/services.py | 17 +- helper_bot/handlers/admin/utils.py | 4 +- helper_bot/handlers/callback/__init__.py | 11 +- .../handlers/callback/callback_handlers.py | 47 ++- helper_bot/handlers/callback/constants.py | 2 +- .../handlers/callback/dependency_factory.py | 5 +- helper_bot/handlers/callback/services.py | 49 ++- helper_bot/handlers/group/__init__.py | 25 +- helper_bot/handlers/group/constants.py | 2 +- helper_bot/handlers/group/decorators.py | 4 +- helper_bot/handlers/group/group_handlers.py | 24 +- helper_bot/handlers/group/services.py | 13 +- helper_bot/handlers/private/__init__.py | 24 +- helper_bot/handlers/private/constants.py | 2 +- helper_bot/handlers/private/decorators.py | 4 +- .../handlers/private/private_handlers.py | 29 +- helper_bot/handlers/private/services.py | 41 +-- helper_bot/handlers/voice/cleanup_utils.py | 5 +- helper_bot/handlers/voice/constants.py | 2 +- helper_bot/handlers/voice/services.py | 28 +- helper_bot/handlers/voice/utils.py | 8 +- helper_bot/handlers/voice/voice_handler.py | 38 +-- helper_bot/keyboards/__init__.py | 2 +- helper_bot/keyboards/keyboards.py | 8 +- helper_bot/main.py | 17 +- helper_bot/middlewares/album_middleware.py | 2 +- .../middlewares/blacklist_middleware.py | 4 +- .../middlewares/dependencies_middleware.py | 2 +- helper_bot/middlewares/metrics_middleware.py | 28 +- .../middlewares/rate_limit_middleware.py | 11 +- helper_bot/server_prometheus.py | 4 +- helper_bot/utils/auto_unban_scheduler.py | 10 +- helper_bot/utils/base_dependency_factory.py | 2 +- helper_bot/utils/helper_func.py | 26 +- helper_bot/utils/messages.py | 7 +- helper_bot/utils/metrics.py | 16 +- helper_bot/utils/rate_limit_monitor.py | 5 +- helper_bot/utils/rate_limiter.py | 10 +- helper_bot/utils/s3_storage.py | 5 +- helper_bot/utils/state.py | 2 +- logs/custom_logger.py | 1 + run_helper.py | 8 +- scripts/add_ban_author_column_to_blacklist.py | 68 ---- scripts/add_is_anonymous_column.py | 138 -------- scripts/add_published_posts_support.py | 166 ---------- scripts/apply_migrations.py | 241 ++++++++++++++ scripts/backfill_post_status_legacy.py | 82 ----- scripts/clean_post_text.py | 148 --------- scripts/create_blacklist_history_table.py | 94 ------ scripts/migrate_blacklist_to_history.py | 125 ------- scripts/test_s3_connection.py | 1 + scripts/voice_cleanup.py | 2 +- tests/conftest.py | 8 +- tests/conftest_message_repository.py | 7 +- tests/conftest_post_repository.py | 7 +- tests/mocks.py | 3 +- tests/test_admin_repository.py | 8 +- tests/test_async_db.py | 3 +- tests/test_audio_file_service.py | 9 +- tests/test_audio_repository.py | 8 +- tests/test_audio_repository_schema.py | 6 +- tests/test_auto_unban_integration.py | 11 +- tests/test_auto_unban_scheduler.py | 9 +- tests/test_blacklist_history_repository.py | 9 +- tests/test_blacklist_repository.py | 8 +- tests/test_callback_handlers.py | 12 +- tests/test_improved_media_processing.py | 13 +- tests/test_keyboards_and_filters.py | 21 +- tests/test_message_repository.py | 5 +- tests/test_message_repository_integration.py | 7 +- tests/test_post_repository.py | 5 +- tests/test_post_repository_integration.py | 5 +- tests/test_post_service.py | 8 +- tests/test_rate_limiter.py | 21 +- tests/test_refactored_admin_handlers.py | 14 +- tests/test_refactored_group_handlers.py | 14 +- tests/test_refactored_private_handlers.py | 9 +- tests/test_utils.py | 51 +-- tests/test_voice_bot_architecture.py | 11 +- tests/test_voice_constants.py | 27 +- tests/test_voice_exceptions.py | 8 +- tests/test_voice_handler.py | 7 +- tests/test_voice_services.py | 9 +- tests/test_voice_utils.py | 23 +- 109 files changed, 1342 insertions(+), 1441 deletions(-) create mode 100644 .cursor/rules/middleware-patterns.mdc create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 :memory: create mode 100644 database/repositories/migration_repository.py delete mode 100644 scripts/add_ban_author_column_to_blacklist.py delete mode 100755 scripts/add_is_anonymous_column.py delete mode 100755 scripts/add_published_posts_support.py create mode 100644 scripts/apply_migrations.py delete mode 100644 scripts/backfill_post_status_legacy.py delete mode 100755 scripts/clean_post_text.py delete mode 100644 scripts/create_blacklist_history_table.py delete mode 100644 scripts/migrate_blacklist_to_history.py 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/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/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..783144c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,93 @@ +name: CI pipeline + +on: + push: + branches: [ 'dev-*', 'feature-*' ] + pull_request: + branches: [ 'dev-*', 'feature-*', 'main' ] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + name: Test & Code Quality + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Code formatting check (Black) + run: | + echo "🔍 Checking code formatting with Black..." + black --check . || (echo "❌ Code formatting issues found. Run 'black .' to fix." && exit 1) + + - name: Import sorting check (isort) + run: | + echo "🔍 Checking import sorting with isort..." + isort --check-only . || (echo "❌ Import sorting issues found. Run 'isort .' to fix." && exit 1) + + - name: Linting (flake8) - Critical errors + run: | + echo "🔍 Running flake8 linter (critical errors only)..." + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics || true + + - name: Linting (flake8) - Warnings + run: | + echo "🔍 Running flake8 linter (warnings)..." + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics || true + continue-on-error: true + + - name: Run tests + run: | + echo "🧪 Running tests..." + python -m pytest tests/ -v --tb=short + + - name: Send test success notification + if: success() + uses: appleboy/telegram-action@v1.0.0 + with: + to: ${{ secrets.TELEGRAM_CHAT_ID }} + token: ${{ secrets.TELEGRAM_BOT_TOKEN }} + message: | + ✅ CI Tests Passed + + 📦 Repository: telegram-helper-bot + 🌿 Branch: ${{ github.ref_name }} + 📝 Commit: ${{ github.sha }} + 👤 Author: ${{ github.actor }} + + ✅ All tests passed! Code quality checks completed successfully. + + 🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + continue-on-error: true + + - name: Send test failure notification + if: failure() + uses: appleboy/telegram-action@v1.0.0 + with: + to: ${{ secrets.TELEGRAM_CHAT_ID }} + token: ${{ secrets.TELEGRAM_BOT_TOKEN }} + message: | + ❌ CI Tests Failed + + 📦 Repository: telegram-helper-bot + 🌿 Branch: ${{ github.ref_name }} + 📝 Commit: ${{ github.sha }} + 👤 Author: ${{ github.actor }} + + ❌ Tests failed! Deployment blocked. Please fix the issues and try again. + + 🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + continue-on-error: true diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..70e5df4 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,307 @@ +name: Deploy to Production + +on: + push: + branches: [ main ] + workflow_dispatch: + inputs: + action: + description: 'Action to perform' + required: true + type: choice + options: + - deploy + - rollback + rollback_commit: + description: 'Commit hash to rollback to (optional, uses last successful if empty)' + required: false + type: string + +jobs: + deploy: + runs-on: ubuntu-latest + name: Deploy to Production + if: | + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'deploy') + concurrency: + group: production-deploy-telegram-helper-bot + cancel-in-progress: false + environment: + name: production + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + + - name: Deploy to server + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }} + username: ${{ vars.SERVER_USER || secrets.SERVER_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + port: ${{ vars.SSH_PORT || secrets.SSH_PORT || 22 }} + script: | + set -e + export TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}" + export TELEGRAM_TEST_BOT_TOKEN="${{ secrets.TELEGRAM_TEST_BOT_TOKEN }}" + + echo "🚀 Starting deployment to production..." + + cd /home/prod + + # Сохраняем информацию о коммите + CURRENT_COMMIT=$(git rev-parse HEAD) + COMMIT_MESSAGE=$(git log -1 --pretty=format:"%s" || echo "Unknown") + COMMIT_AUTHOR=$(git log -1 --pretty=format:"%an" || echo "Unknown") + TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S") + + echo "📝 Current commit: $CURRENT_COMMIT" + echo "📝 Commit message: $COMMIT_MESSAGE" + echo "📝 Author: $COMMIT_AUTHOR" + + # Записываем в историю деплоев + HISTORY_FILE="/home/prod/.deploy_history_telegram_helper_bot.txt" + HISTORY_SIZE="${DEPLOY_HISTORY_SIZE:-10}" + echo "${TIMESTAMP}|${CURRENT_COMMIT}|${COMMIT_MESSAGE}|${COMMIT_AUTHOR}|deploying" >> "$HISTORY_FILE" + tail -n "$HISTORY_SIZE" "$HISTORY_FILE" > "${HISTORY_FILE}.tmp" && mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE" + + # Обновляем код + echo "📥 Pulling latest changes from main..." + sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true + cd /home/prod/bots/telegram-helper-bot + git fetch origin main + git reset --hard origin/main + sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true + + NEW_COMMIT=$(git rev-parse HEAD) + echo "✅ Code updated: $CURRENT_COMMIT → $NEW_COMMIT" + + # Применяем миграции БД перед перезапуском контейнера + echo "🔄 Applying database migrations..." + DB_PATH="/home/prod/bots/telegram-helper-bot/database/tg-bot-database.db" + + if [ -f "$DB_PATH" ]; then + cd /home/prod/bots/telegram-helper-bot + python3 scripts/apply_migrations.py --db "$DB_PATH" || { + echo "❌ Ошибка при применении миграций!" + exit 1 + } + echo "✅ Миграции применены успешно" + else + echo "⚠️ База данных не найдена, пропускаем миграции (будет создана при первом запуске)" + fi + + # Валидация docker-compose + echo "🔍 Validating docker-compose configuration..." + cd /home/prod + docker-compose config > /dev/null || exit 1 + echo "✅ docker-compose.yml is valid" + + # Проверка дискового пространства + MIN_FREE_GB=5 + AVAILABLE_SPACE=$(df -BG /home/prod 2>/dev/null | tail -1 | awk '{print $4}' | sed 's/G//' || echo "0") + echo "💾 Available disk space: ${AVAILABLE_SPACE}GB" + + if [ "$AVAILABLE_SPACE" -lt "$MIN_FREE_GB" ]; then + echo "⚠️ Insufficient disk space! Cleaning up Docker resources..." + docker system prune -f --volumes || true + fi + + # Пересобираем и перезапускаем контейнер бота + echo "🔨 Rebuilding and restarting telegram-bot container..." + cd /home/prod + + export TELEGRAM_BOT_TOKEN TELEGRAM_TEST_BOT_TOKEN + docker-compose stop telegram-bot || true + docker-compose build --pull telegram-bot + docker-compose up -d telegram-bot + + echo "✅ Telegram bot container rebuilt and started" + + # Ждем немного и проверяем healthcheck + echo "⏳ Waiting for container to start..." + sleep 10 + + if docker ps | grep -q bots_telegram_bot; then + echo "✅ Container is running" + else + echo "❌ Container failed to start!" + docker logs bots_telegram_bot --tail 50 || true + exit 1 + fi + + - name: Update deploy history + if: always() + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }} + username: ${{ vars.SERVER_USER || secrets.SERVER_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + port: ${{ vars.SSH_PORT || secrets.SSH_PORT || 22 }} + script: | + HISTORY_FILE="/home/prod/.deploy_history_telegram_helper_bot.txt" + + if [ -f "$HISTORY_FILE" ]; then + DEPLOY_STATUS="failed" + if [ "${{ job.status }}" = "success" ]; then + DEPLOY_STATUS="success" + fi + + sed -i '$s/|deploying$/|'"$DEPLOY_STATUS"'/' "$HISTORY_FILE" + echo "✅ Deploy history updated: $DEPLOY_STATUS" + fi + + - name: Send deployment notification + if: always() + uses: appleboy/telegram-action@v1.0.0 + with: + to: ${{ secrets.TELEGRAM_CHAT_ID }} + token: ${{ secrets.TELEGRAM_BOT_TOKEN }} + message: | + ${{ job.status == 'success' && '✅' || '❌' }} Deployment: ${{ job.status }} + + 📦 Repository: telegram-helper-bot + 🌿 Branch: main + 📝 Commit: ${{ github.event.pull_request.merge_commit_sha || github.sha }} + 👤 Author: ${{ github.event.pull_request.user.login || github.actor }} + ${{ github.event.pull_request.number && format('🔀 PR: #{0}', github.event.pull_request.number) || '' }} + + ${{ job.status == 'success' && '✅ Deployment successful! Container restarted with migrations applied.' || '❌ Deployment failed! Check logs for details.' }} + + 🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + continue-on-error: true + + rollback: + runs-on: ubuntu-latest + name: Rollback to Previous Version + if: | + github.event_name == 'workflow_dispatch' && + github.event.inputs.action == 'rollback' + environment: + name: production + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + + - name: Rollback on server + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }} + username: ${{ vars.SERVER_USER || secrets.SERVER_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + port: ${{ vars.SSH_PORT || secrets.SSH_PORT || 22 }} + script: | + set -e + export TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}" + export TELEGRAM_TEST_BOT_TOKEN="${{ secrets.TELEGRAM_TEST_BOT_TOKEN }}" + + echo "🔄 Starting rollback..." + + cd /home/prod + + # Определяем коммит для отката + ROLLBACK_COMMIT="${{ github.event.inputs.rollback_commit }}" + HISTORY_FILE="/home/prod/.deploy_history_telegram_helper_bot.txt" + + if [ -z "$ROLLBACK_COMMIT" ]; then + echo "📝 No commit specified, finding last successful deploy..." + if [ -f "$HISTORY_FILE" ]; then + ROLLBACK_COMMIT=$(grep "|success$" "$HISTORY_FILE" | tail -1 | cut -d'|' -f2 || echo "") + fi + + if [ -z "$ROLLBACK_COMMIT" ]; then + echo "❌ No successful deploy found in history!" + echo "💡 Please specify commit hash manually or check deploy history" + exit 1 + fi + fi + + echo "📝 Rolling back to commit: $ROLLBACK_COMMIT" + + # Проверяем, что коммит существует + cd /home/prod/bots/telegram-helper-bot + if ! git cat-file -e "$ROLLBACK_COMMIT" 2>/dev/null; then + echo "❌ Commit $ROLLBACK_COMMIT not found!" + exit 1 + fi + + # Сохраняем текущий коммит + CURRENT_COMMIT=$(git rev-parse HEAD) + COMMIT_MESSAGE=$(git log -1 --pretty=format:"%s" "$ROLLBACK_COMMIT" || echo "Rollback") + TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S") + + echo "📝 Current commit: $CURRENT_COMMIT" + echo "📝 Target commit: $ROLLBACK_COMMIT" + echo "📝 Commit message: $COMMIT_MESSAGE" + + # Исправляем права перед откатом + sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true + + # Откатываем код + echo "🔄 Rolling back code..." + git fetch origin main + git reset --hard "$ROLLBACK_COMMIT" + + # Исправляем права после отката + sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true + + echo "✅ Code rolled back: $CURRENT_COMMIT → $ROLLBACK_COMMIT" + + # Валидация docker-compose + echo "🔍 Validating docker-compose configuration..." + cd /home/prod + docker-compose config > /dev/null || exit 1 + echo "✅ docker-compose.yml is valid" + + # Проверка дискового пространства + MIN_FREE_GB=5 + AVAILABLE_SPACE=$(df -BG /home/prod 2>/dev/null | tail -1 | awk '{print $4}' | sed 's/G//' || echo "0") + echo "💾 Available disk space: ${AVAILABLE_SPACE}GB" + + if [ "$AVAILABLE_SPACE" -lt "$MIN_FREE_GB" ]; then + echo "⚠️ Insufficient disk space! Cleaning up Docker resources..." + docker system prune -f --volumes || true + fi + + # Пересобираем и перезапускаем контейнер + echo "🔨 Rebuilding and restarting telegram-bot container..." + cd /home/prod + + export TELEGRAM_BOT_TOKEN TELEGRAM_TEST_BOT_TOKEN + docker-compose stop telegram-bot || true + docker-compose build --pull telegram-bot + docker-compose up -d telegram-bot + + echo "✅ Telegram bot container rebuilt and started" + + # Записываем в историю + echo "${TIMESTAMP}|${ROLLBACK_COMMIT}|Rollback to: ${COMMIT_MESSAGE}|github-actions|rolled_back" >> "$HISTORY_FILE" + HISTORY_SIZE="${DEPLOY_HISTORY_SIZE:-10}" + tail -n "$HISTORY_SIZE" "$HISTORY_FILE" > "${HISTORY_FILE}.tmp" && mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE" + + echo "✅ Rollback completed successfully" + + - name: Send rollback notification + if: always() + uses: appleboy/telegram-action@v1.0.0 + with: + to: ${{ secrets.TELEGRAM_CHAT_ID }} + token: ${{ secrets.TELEGRAM_BOT_TOKEN }} + message: | + ${{ job.status == 'success' && '🔄' || '❌' }} Rollback: ${{ job.status }} + + 📦 Repository: telegram-helper-bot + 🌿 Branch: main + 📝 Rolled back to: ${{ github.event.inputs.rollback_commit || 'Last successful commit' }} + 👤 Triggered by: ${{ github.actor }} + + ${{ job.status == 'success' && '✅ Rollback completed successfully! Services restored to previous version.' || '❌ Rollback failed! Check logs for details.' }} + + 🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + continue-on-error: true diff --git a/:memory: b/:memory: new file mode 100644 index 0000000000000000000000000000000000000000..159e90adb13561c0365c22e76084c658383a62f1 GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYFvV#S79dK(-m98b?E5 nGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nC=3Arqf-Z% literal 0 HcmV?d00001 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..e5e3403 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: @@ -403,25 +403,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..5406416 --- /dev/null +++ b/database/repositories/migration_repository.py @@ -0,0 +1,79 @@ +"""Репозиторий для работы с миграциями базы данных.""" +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..daa4265 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): 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/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..50d041a 100644 --- a/helper_bot/handlers/admin/admin_handlers.py +++ b/helper_bot/handlers/admin/admin_handlers.py @@ -1,36 +1,24 @@ -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) # 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() 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..c6cdbb4 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: diff --git a/helper_bot/handlers/callback/services.py b/helper_bot/handlers/callback/services.py index 671c1b5..e72d347 100644 --- a/helper_bot/handlers/callback/services.py +++ b/helper_bot/handlers/callback/services.py @@ -1,36 +1,31 @@ -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: 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..f9f2646 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 diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py index ec233c3..8f0c151 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): 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..aeac9b8 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(): diff --git a/helper_bot/main.py b/helper_bot/main.py index 3f7a800..0c1da15 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): 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/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..fb2681b 100644 --- a/helper_bot/utils/base_dependency_factory.py +++ b/helper_bot/utils/base_dependency_factory.py @@ -1,9 +1,9 @@ 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 diff --git a/helper_bot/utils/helper_func.py b/helper_bot/utils/helper_func.py index 8159de9..4412cef 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 @@ -653,7 +649,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/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_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/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..539fa8a 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: 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_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") -- 2.49.1 From 7f6f0f028cb3a6cc0a8f9ab70effd4f3d9475128 Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 26 Jan 2026 18:40:38 +0300 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20ML-=D1=81=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=D0=B0=20=D1=81=20=D0=B8=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC=20?= =?UTF-8?q?RAG=20=D0=B8=20DeepSeek?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Обновлен Dockerfile для установки необходимых зависимостей. - Добавлены новые переменные окружения для настройки ML-скоринга в env.example. - Реализованы методы для получения и обновления ML-скоров в AsyncBotDB и PostRepository. - Обновлены обработчики публикации постов для интеграции ML-скоринга. - Добавлен новый обработчик для получения статистики ML-скоринга в админ-панели. - Обновлены функции для форматирования сообщений с учетом ML-скоров. --- .github/workflows/deploy.yml | 5 +- Dockerfile | 36 +- database/async_db.py | 17 + database/repositories/post_repository.py | 123 +++++ env.example | 17 + helper_bot/handlers/admin/admin_handlers.py | 64 +++ .../handlers/callback/dependency_factory.py | 3 +- helper_bot/handlers/callback/services.py | 35 +- .../handlers/private/private_handlers.py | 20 +- helper_bot/handlers/private/services.py | 148 ++++- helper_bot/keyboards/keyboards.py | 3 + helper_bot/main.py | 16 + helper_bot/services/__init__.py | 5 + helper_bot/services/scoring/__init__.py | 42 ++ helper_bot/services/scoring/base.py | 155 ++++++ .../services/scoring/deepseek_service.py | 358 +++++++++++++ helper_bot/services/scoring/exceptions.py | 33 ++ helper_bot/services/scoring/rag_service.py | 507 ++++++++++++++++++ .../services/scoring/scoring_manager.py | 242 +++++++++ helper_bot/services/scoring/vector_store.py | 399 ++++++++++++++ helper_bot/utils/base_dependency_factory.py | 123 +++++ helper_bot/utils/helper_func.py | 43 +- requirements.txt | 8 +- scripts/add_ml_scores_columns.py | 93 ++++ tests/test_scoring_services.py | 390 ++++++++++++++ 25 files changed, 2833 insertions(+), 52 deletions(-) create mode 100644 helper_bot/services/__init__.py create mode 100644 helper_bot/services/scoring/__init__.py create mode 100644 helper_bot/services/scoring/base.py create mode 100644 helper_bot/services/scoring/deepseek_service.py create mode 100644 helper_bot/services/scoring/exceptions.py create mode 100644 helper_bot/services/scoring/rag_service.py create mode 100644 helper_bot/services/scoring/scoring_manager.py create mode 100644 helper_bot/services/scoring/vector_store.py create mode 100644 scripts/add_ml_scores_columns.py create mode 100644 tests/test_scoring_services.py diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 70e5df4..d23b853 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -165,9 +165,8 @@ jobs: 📦 Repository: telegram-helper-bot 🌿 Branch: main - 📝 Commit: ${{ github.event.pull_request.merge_commit_sha || github.sha }} - 👤 Author: ${{ github.event.pull_request.user.login || github.actor }} - ${{ github.event.pull_request.number && format('🔀 PR: #{0}', github.event.pull_request.number) || '' }} + 📝 Commit: ${{ github.sha }} + 👤 Author: ${{ github.actor }} ${{ job.status == 'success' && '✅ Deployment successful! Container restarted with migrations applied.' || '❌ Deployment failed! Check logs for details.' }} diff --git a/Dockerfile b/Dockerfile index 0c36fe8..c41ab93 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,14 @@ ########################################### # Этап 1: Сборщик (Builder) ########################################### -FROM python:3.11.9-alpine as builder +FROM python:3.11.9-slim as builder -# Устанавливаем инструменты для компиляции + linux-headers для psutil -RUN apk add --no-cache \ +# Устанавливаем инструменты для компиляции +RUN apt-get update && apt-get install --no-install-recommends -y \ gcc \ g++ \ - musl-dev \ python3-dev \ - linux-headers # ← ЭТО КРИТИЧЕСКИ ВАЖНО ДЛЯ psutil + && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY requirements.txt . @@ -21,29 +20,34 @@ RUN pip install --no-cache-dir --target /install -r requirements.txt ########################################### # Этап 2: Финальный образ (Runtime) ########################################### -FROM python:3.11.9-alpine as runtime +FROM python:3.11.9-slim as runtime # Минимальные рантайм-зависимости -RUN apk add --no-cache \ - libstdc++ \ - sqlite-libs +RUN apt-get update && apt-get install --no-install-recommends -y \ + libgomp1 \ + && rm -rf /var/lib/apt/lists/* # Создаем пользователя -RUN addgroup -g 1001 deploy && adduser -D -u 1001 -G deploy deploy +RUN groupadd -g 1001 deploy && useradd -r -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 +# Создаем структуру папок (включая директории для ML моделей) +RUN mkdir -p database logs voice_users data/models && \ + chown -R deploy:deploy /app + +# Устанавливаем переменные для HuggingFace (кеш моделей внутри /app) +ENV HF_HOME=/app/data/models +ENV TRANSFORMERS_CACHE=/app/data/models +ENV HF_HUB_CACHE=/app/data/models # Копируем исходный код -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/async_db.py b/database/async_db.py index e5e3403..e5f74d8 100644 --- a/database/async_db.py +++ b/database/async_db.py @@ -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( diff --git a/database/repositories/post_repository.py b/database/repositories/post_repository.py index daa4265..cae5ede 100644 --- a/database/repositories/post_repository.py +++ b/database/repositories/post_repository.py @@ -357,3 +357,126 @@ 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 + + async def update_vector_hash(self, message_id: int, vector_hash: str) -> bool: + """ + Обновляет хеш вектора для поста (для кеширования). + + Args: + message_id: ID сообщения + vector_hash: Хеш вектора + + Returns: + True если обновлено успешно + """ + try: + query = "UPDATE post_from_telegram_suggest SET vector_hash = ? WHERE message_id = ?" + await self._execute_query(query, (vector_hash, message_id)) + return True + except Exception as e: + self.logger.error(f"Ошибка обновления vector_hash для message_id={message_id}: {e}") + return False diff --git a/env.example b/env.example index dbab9a9..ea06d24 100644 --- a/env.example +++ b/env.example @@ -35,3 +35,20 @@ METRICS_PORT=8080 # Logging LOG_LEVEL=INFO LOG_RETENTION_DAYS=30 + +# ML Scoring - RAG (ruBERT) +# Включает локальное векторное сравнение с использованием ruBERT +RAG_ENABLED=false +RAG_MODEL=DeepPavlov/rubert-base-cased +RAG_CACHE_DIR=data/models +RAG_VECTORS_PATH=data/vectors.npz +RAG_MAX_EXAMPLES=10000 +RAG_SCORE_MULTIPLIER=5 + +# 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/admin_handlers.py b/helper_bot/handlers/admin/admin_handlers.py index 50d041a..31ed534 100644 --- a/helper_bot/handlers/admin/admin_handlers.py +++ b/helper_bot/handlers/admin/admin_handlers.py @@ -16,6 +16,7 @@ from helper_bot.keyboards.keyboards import (create_keyboard_for_approve_ban, 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 db_query_time, track_errors, track_time from logs.custom_logger import logger @@ -137,6 +138,69 @@ 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 = scoring_manager.get_stats() + + # Формируем текст статистики + lines = ["📊 ML Scoring Статистика\n"] + + # RAG статистика + if "rag" in stats: + rag = stats["rag"] + lines.append("🤖 RAG (ruBERT):") + lines.append(f" • Статус: {'✅ Включен' if rag.get('enabled') else '❌ Отключен'}") + lines.append(f" • Модель: {rag.get('model_name', 'N/A')}") + lines.append(f" • Модель загружена: {'✅' if rag.get('model_loaded') else '❌'}") + + vs = rag.get("vector_store", {}) + lines.append(f" • Положительных примеров: {vs.get('positive_count', 0)}") + lines.append(f" • Отрицательных примеров: {vs.get('negative_count', 0)}") + lines.append(f" • Всего примеров: {vs.get('total_count', 0)}") + lines.append(f" • Макс. примеров: {vs.get('max_examples', 'N/A')}") + 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/callback/dependency_factory.py b/helper_bot/handlers/callback/dependency_factory.py index c6cdbb4..ec3f563 100644 --- a/helper_bot/handlers/callback/dependency_factory.py +++ b/helper_bot/handlers/callback/dependency_factory.py @@ -15,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 e72d347..4620e7f 100644 --- a/helper_bot/handlers/callback/services.py +++ b/helper_bot/handlers/callback/services.py @@ -29,12 +29,13 @@ from .exceptions import (BanError, PostNotFoundError, PublishError, 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'] @@ -392,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: @@ -485,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: @@ -493,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") diff --git a/helper_bot/handlers/private/private_handlers.py b/helper_bot/handlers/private/private_handlers.py index f9f2646..af01457 100644 --- a/helper_bot/handlers/private/private_handlers.py +++ b/helper_bot/handlers/private/private_handlers.py @@ -35,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() @@ -240,18 +240,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 @@ -269,11 +275,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 8f0c151..904dd60 100644 --- a/helper_bot/handlers/private/services.py +++ b/helper_bot/handlers/private/services.py @@ -128,10 +128,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: """Сохраняет медиа в фоне, чтобы не блокировать ответ пользователю""" @@ -142,18 +143,65 @@ 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("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}") + @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( @@ -164,23 +212,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( @@ -191,25 +255,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( @@ -220,8 +299,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") @@ -253,17 +335,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( @@ -274,8 +368,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") @@ -310,10 +407,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) @@ -333,6 +443,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) diff --git a/helper_bot/keyboards/keyboards.py b/helper_bot/keyboards/keyboards.py index aeac9b8..3fd4f3c 100644 --- a/helper_bot/keyboards/keyboards.py +++ b/helper_bot/keyboards/keyboards.py @@ -47,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 0c1da15..b710d47 100644 --- a/helper_bot/main.py +++ b/helper_bot/main.py @@ -78,6 +78,22 @@ async def start_bot(bdf): await bot.delete_webhook(drop_pending_updates=True) + # Загружаем примеры для RAG из базы данных + scoring_manager = bdf.get_scoring_manager() + if scoring_manager and scoring_manager.rag_service and scoring_manager.rag_service.is_enabled: + try: + db = bdf.get_db() + positive_texts = await db.get_approved_posts_texts(limit=5000) + negative_texts = await db.get_declined_posts_texts(limit=5000) + + if positive_texts or negative_texts: + await scoring_manager.load_examples_from_db(positive_texts, negative_texts) + logging.info(f"RAG: Загружено {len(positive_texts)} положительных и {len(negative_texts)} отрицательных примеров") + else: + logging.warning("RAG: Нет примеров в базе данных для загрузки") + except Exception as e: + logging.error(f"Ошибка загрузки примеров для RAG: {e}") + # Запускаем HTTP сервер для метрик параллельно с ботом metrics_host = bdf.settings.get('Metrics', {}).get('host', '0.0.0.0') metrics_port = bdf.settings.get('Metrics', {}).get('port', 8080) 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..a56b7fe --- /dev/null +++ b/helper_bot/services/scoring/__init__.py @@ -0,0 +1,42 @@ +""" +Сервисы для ML-скоринга постов. + +Включает: +- RAGService - локальное векторное сравнение с ruBERT +- DeepSeekService - интеграция с DeepSeek API +- ScoringManager - объединение всех сервисов скоринга +- VectorStore - in-memory хранилище векторов +""" + +from .base import ScoringResult, ScoringServiceProtocol, CombinedScore +from .exceptions import ( + ScoringError, + ModelNotLoadedError, + VectorStoreError, + DeepSeekAPIError, + InsufficientExamplesError, + TextTooShortError, +) +from .vector_store import VectorStore +from .rag_service import RAGService +from .deepseek_service import DeepSeekService +from .scoring_manager import ScoringManager + +__all__ = [ + # Базовые классы + "ScoringResult", + "ScoringServiceProtocol", + "CombinedScore", + # Исключения + "ScoringError", + "ModelNotLoadedError", + "VectorStoreError", + "DeepSeekAPIError", + "InsufficientExamplesError", + "TextTooShortError", + # Сервисы + "VectorStore", + "RAGService", + "DeepSeekService", + "ScoringManager", +] diff --git a/helper_bot/services/scoring/base.py b/helper_bot/services/scoring/base.py new file mode 100644 index 0000000..748afa2 --- /dev/null +++ b/helper_bot/services/scoring/base.py @@ -0,0 +1,155 @@ +""" +Базовые классы и протоколы для сервисов скоринга. +""" + +from dataclasses import dataclass, field +from typing import Optional, Protocol, Dict, Any +from datetime import datetime + + +@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..de45835 --- /dev/null +++ b/helper_bot/services/scoring/deepseek_service.py @@ -0,0 +1,358 @@ +""" +DeepSeek API сервис для скоринга постов. + +Использует DeepSeek API для семантической оценки релевантности поста. +""" + +import asyncio +import json +from typing import Optional, List + +import httpx + +from logs.custom_logger import logger +from helper_bot.utils.metrics import track_time, track_errors + +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_service.py b/helper_bot/services/scoring/rag_service.py new file mode 100644 index 0000000..0c02272 --- /dev/null +++ b/helper_bot/services/scoring/rag_service.py @@ -0,0 +1,507 @@ +""" +RAG сервис для скоринга постов с использованием ruBERT. + +Использует модель DeepPavlov/rubert-base-cased для создания эмбеддингов +и сравнивает их с эталонными примерами через VectorStore. +""" + +import asyncio +from typing import Optional, List + +import numpy as np + +from logs.custom_logger import logger +from helper_bot.utils.metrics import track_time, track_errors + +from .base import ScoringResult +from .vector_store import VectorStore +from .exceptions import ( + ModelNotLoadedError, + ScoringError, + InsufficientExamplesError, + TextTooShortError, +) + + +class RAGService: + """ + RAG сервис для оценки постов на основе векторного сходства. + + Использует ruBERT для создания эмбеддингов текста и сравнивает + их с эталонными примерами (опубликованные vs отклоненные посты). + + Attributes: + model_name: Название модели HuggingFace + vector_store: Хранилище векторов + min_text_length: Минимальная длина текста для обработки + """ + + # Название модели по умолчанию + DEFAULT_MODEL = "DeepPavlov/rubert-base-cased" + + def __init__( + self, + model_name: Optional[str] = None, + vector_store: Optional[VectorStore] = None, + cache_dir: Optional[str] = None, + enabled: bool = True, + min_text_length: int = 3, + ): + """ + Инициализация RAG сервиса. + + Args: + model_name: Название модели HuggingFace (по умолчанию ruBERT) + vector_store: Хранилище векторов (создается автоматически если не передано) + cache_dir: Директория для кеширования модели + enabled: Включен ли сервис + min_text_length: Минимальная длина текста для обработки + """ + self.model_name = model_name or self.DEFAULT_MODEL + self.cache_dir = cache_dir + self._enabled = enabled + self.min_text_length = min_text_length + + # Модель и токенизатор загружаются лениво + self._model = None + self._tokenizer = None + self._model_loaded = False + + # Хранилище векторов + self.vector_store = vector_store or VectorStore() + + logger.info(f"RAGService инициализирован (model={self.model_name}, enabled={enabled})") + + @property + def source_name(self) -> str: + """Имя источника для результатов.""" + return "rag" + + @property + def is_enabled(self) -> bool: + """Проверяет, включен ли сервис.""" + return self._enabled + + @property + def is_model_loaded(self) -> bool: + """Проверяет, загружена ли модель.""" + return self._model_loaded + + async def load_model(self) -> None: + """ + Загружает модель и токенизатор. + + Выполняется асинхронно в отдельном потоке чтобы не блокировать event loop. + """ + if self._model_loaded: + return + + if not self._enabled: + logger.warning("RAGService: Сервис отключен, модель не загружается") + return + + logger.info(f"RAGService: Загрузка модели {self.model_name}...") + + try: + # Загрузка в отдельном потоке + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._load_model_sync) + + self._model_loaded = True + logger.info(f"RAGService: Модель {self.model_name} успешно загружена") + + except Exception as e: + logger.error(f"RAGService: Ошибка загрузки модели: {e}") + raise ModelNotLoadedError(f"Не удалось загрузить модель {self.model_name}: {e}") + + def _load_model_sync(self) -> None: + """Синхронная загрузка модели (вызывается в executor).""" + logger.info("RAGService: Начало _load_model_sync, импорт transformers...") + from transformers import AutoTokenizer, AutoModel + import torch + + # Определяем устройство + self._device = "cuda" if torch.cuda.is_available() else "cpu" + logger.info(f"RAGService: Устройство определено: {self._device}") + + # Загружаем токенизатор + logger.info(f"RAGService: Загрузка токенизатора из {self.model_name}...") + self._tokenizer = AutoTokenizer.from_pretrained( + self.model_name, + cache_dir=self.cache_dir, + ) + logger.info("RAGService: Токенизатор загружен") + + # Загружаем модель + logger.info(f"RAGService: Загрузка модели из {self.model_name} (это может занять несколько минут)...") + self._model = AutoModel.from_pretrained( + self.model_name, + cache_dir=self.cache_dir, + ) + logger.info("RAGService: Модель загружена, перенос на устройство...") + self._model.to(self._device) + self._model.eval() # Режим инференса + + logger.info(f"RAGService: Модель готова на устройстве: {self._device}") + + def _get_embedding_sync(self, text: str) -> np.ndarray: + """ + Получает эмбеддинг текста (синхронно). + + Использует [CLS] токен как представление всего текста. + + Args: + text: Текст для векторизации + + Returns: + Numpy массив с эмбеддингом (768 измерений для ruBERT) + """ + import torch + + # Токенизация с ограничением длины + inputs = self._tokenizer( + text, + return_tensors="pt", + truncation=True, + max_length=512, + padding=True, + ) + inputs = {k: v.to(self._device) for k, v in inputs.items()} + + # Получаем эмбеддинг + with torch.no_grad(): + outputs = self._model(**inputs) + # Используем [CLS] токен (первый токен) + embedding = outputs.last_hidden_state[:, 0, :].cpu().numpy() + + return embedding.flatten() + + def _get_embeddings_batch_sync(self, texts: List[str], batch_size: int = 16) -> List[np.ndarray]: + """ + Получает эмбеддинги для батча текстов (синхронно). + + Обрабатывает тексты пачками для эффективного использования GPU/CPU. + + Args: + texts: Список текстов для векторизации + batch_size: Размер батча (по умолчанию 16) + + Returns: + Список numpy массивов с эмбеддингами + """ + import torch + + all_embeddings = [] + + for i in range(0, len(texts), batch_size): + batch_texts = texts[i:i + batch_size] + + # Токенизация батча + inputs = self._tokenizer( + batch_texts, + return_tensors="pt", + truncation=True, + max_length=512, + padding=True, + ) + inputs = {k: v.to(self._device) for k, v in inputs.items()} + + # Получаем эмбеддинги + with torch.no_grad(): + outputs = self._model(**inputs) + # [CLS] токен для каждого текста в батче + batch_embeddings = outputs.last_hidden_state[:, 0, :].cpu().numpy() + + # Разбиваем на отдельные эмбеддинги + for j in range(len(batch_texts)): + all_embeddings.append(batch_embeddings[j]) + + if i > 0 and i % (batch_size * 10) == 0: + logger.info(f"RAGService: Обработано {i}/{len(texts)} текстов") + + return all_embeddings + + async def get_embeddings_batch(self, texts: List[str], batch_size: int = 16) -> List[np.ndarray]: + """ + Получает эмбеддинги для батча текстов (асинхронно). + + Args: + texts: Список текстов для векторизации + batch_size: Размер батча + + Returns: + Список numpy массивов с эмбеддингами + """ + if not self._model_loaded: + await self.load_model() + + if not self._model_loaded: + raise ModelNotLoadedError("Модель не загружена") + + # Очищаем тексты + clean_texts = [self._clean_text(text) for text in texts] + + # Выполняем батч-обработку в thread pool + loop = asyncio.get_event_loop() + embeddings = await loop.run_in_executor( + None, + self._get_embeddings_batch_sync, + clean_texts, + batch_size, + ) + + return embeddings + + async def get_embedding(self, text: str) -> np.ndarray: + """ + Получает эмбеддинг текста (асинхронно). + + Args: + text: Текст для векторизации + + Returns: + Numpy массив с эмбеддингом + + Raises: + ModelNotLoadedError: Если модель не загружена + TextTooShortError: Если текст слишком короткий + """ + if not self._model_loaded: + await self.load_model() + + if not self._model_loaded: + raise ModelNotLoadedError("Модель не загружена") + + # Очищаем текст + clean_text = self._clean_text(text) + + if len(clean_text) < self.min_text_length: + raise TextTooShortError( + f"Текст слишком короткий (минимум {self.min_text_length} символов)" + ) + + # Выполняем в отдельном потоке + loop = asyncio.get_event_loop() + embedding = await loop.run_in_executor( + None, + self._get_embedding_sync, + clean_text + ) + + return embedding + + def _clean_text(self, text: str) -> str: + """Очищает текст от лишних символов.""" + if not text: + return "" + + # Удаляем лишние пробелы и переносы строк + clean = " ".join(text.split()) + + # Удаляем служебные символы (например "^" для helper сообщений) + if clean == "^": + return "" + + return clean.strip() + + @track_time("calculate_score", "rag_service") + @track_errors("rag_service", "calculate_score") + async def calculate_score(self, text: str) -> ScoringResult: + """ + Рассчитывает скор для текста поста. + + Args: + text: Текст поста для оценки + + Returns: + ScoringResult с оценкой + + Raises: + ScoringError: При ошибке расчета + """ + if not self._enabled: + raise ScoringError("RAG сервис отключен") + + try: + # Получаем эмбеддинг текста + embedding = await self.get_embedding(text) + + # Логируем первые элементы вектора для отладки + logger.info( + f"RAGService: embedding[:3]={embedding[:3].tolist()}, " + f"text_preview='{text[:30]}'" + ) + + # Рассчитываем скор через VectorStore + score, confidence, score_pos_only = self.vector_store.calculate_similarity_score(embedding) + + return ScoringResult( + score=score, + source=self.source_name, + model=self.model_name, + confidence=confidence, + metadata={ + "positive_examples": self.vector_store.positive_count, + "negative_examples": self.vector_store.negative_count, + "score_pos_only": score_pos_only, # Для сравнения + }, + ) + + except InsufficientExamplesError: + # Не достаточно примеров - возвращаем нейтральный скор + logger.warning("RAGService: Недостаточно примеров для расчета скора") + raise + + except TextTooShortError: + logger.warning(f"RAGService: Текст слишком короткий для оценки") + raise + + except Exception as e: + logger.error(f"RAGService: Ошибка расчета скора: {e}") + raise ScoringError(f"Ошибка расчета скора: {e}") + + @track_time("add_positive_example", "rag_service") + async def add_positive_example(self, text: str) -> None: + """ + Добавляет текст как положительный пример (опубликованный пост). + + Args: + text: Текст опубликованного поста + """ + if not self._enabled: + return + + try: + clean_text = self._clean_text(text) + if len(clean_text) < self.min_text_length: + logger.debug("RAGService: Текст слишком короткий для примера, пропускаем") + return + + # Получаем эмбеддинг + embedding = await self.get_embedding(clean_text) + + # Вычисляем хеш для дедупликации + text_hash = VectorStore.compute_text_hash(clean_text) + + # Добавляем в хранилище + added = self.vector_store.add_positive(embedding, text_hash) + + if added: + logger.info(f"RAGService: Добавлен положительный пример") + + except Exception as e: + logger.error(f"RAGService: Ошибка добавления положительного примера: {e}") + + @track_time("add_negative_example", "rag_service") + async def add_negative_example(self, text: str) -> None: + """ + Добавляет текст как отрицательный пример (отклоненный пост). + + Args: + text: Текст отклоненного поста + """ + if not self._enabled: + return + + try: + clean_text = self._clean_text(text) + if len(clean_text) < self.min_text_length: + logger.debug("RAGService: Текст слишком короткий для примера, пропускаем") + return + + # Получаем эмбеддинг + embedding = await self.get_embedding(clean_text) + + # Вычисляем хеш для дедупликации + text_hash = VectorStore.compute_text_hash(clean_text) + + # Добавляем в хранилище + added = self.vector_store.add_negative(embedding, text_hash) + + if added: + logger.info(f"RAGService: Добавлен отрицательный пример") + + except Exception as e: + logger.error(f"RAGService: Ошибка добавления отрицательного примера: {e}") + + async def load_examples_from_db( + self, + positive_texts: list[str], + negative_texts: list[str], + batch_size: int = 16, + ) -> None: + """ + Загружает примеры из базы данных с батч-обработкой. + + Используется при запуске бота для восстановления VectorStore. + Батч-обработка ускоряет загрузку в 10-20 раз. + + Args: + positive_texts: Список текстов опубликованных постов + negative_texts: Список текстов отклоненных постов + batch_size: Размер батча для обработки (по умолчанию 16) + """ + if not self._enabled: + return + + logger.info( + f"RAGService: Загрузка примеров из БД с батч-обработкой " + f"(positive: {len(positive_texts)}, negative: {len(negative_texts)}, batch_size: {batch_size})" + ) + + # Убеждаемся что модель загружена + await self.load_model() + + import time + start_time = time.time() + + # Фильтруем и очищаем положительные тексты + if positive_texts: + clean_positive = [] + positive_hashes = [] + for text in positive_texts: + clean_text = self._clean_text(text) + if len(clean_text) >= self.min_text_length: + clean_positive.append(clean_text) + positive_hashes.append(VectorStore.compute_text_hash(clean_text)) + + if clean_positive: + logger.info(f"RAGService: Обработка {len(clean_positive)} положительных примеров батчами...") + positive_embeddings = await self.get_embeddings_batch(clean_positive, batch_size) + self.vector_store.add_positive_batch(positive_embeddings, positive_hashes) + + # Фильтруем и очищаем отрицательные тексты + if negative_texts: + clean_negative = [] + negative_hashes = [] + for text in negative_texts: + clean_text = self._clean_text(text) + if len(clean_text) >= self.min_text_length: + clean_negative.append(clean_text) + negative_hashes.append(VectorStore.compute_text_hash(clean_text)) + + if clean_negative: + logger.info(f"RAGService: Обработка {len(clean_negative)} отрицательных примеров батчами...") + negative_embeddings = await self.get_embeddings_batch(clean_negative, batch_size) + self.vector_store.add_negative_batch(negative_embeddings, negative_hashes) + + elapsed = time.time() - start_time + logger.info( + f"RAGService: Загрузка завершена за {elapsed:.1f} сек " + f"(positive: {self.vector_store.positive_count}, " + f"negative: {self.vector_store.negative_count})" + ) + + def save_vectors(self) -> None: + """Сохраняет векторы на диск.""" + if self.vector_store.storage_path: + self.vector_store.save_to_disk() + + def get_stats(self) -> dict: + """Возвращает статистику сервиса.""" + return { + "enabled": self._enabled, + "model_name": self.model_name, + "model_loaded": self._model_loaded, + "vector_store": self.vector_store.get_stats(), + } diff --git a/helper_bot/services/scoring/scoring_manager.py b/helper_bot/services/scoring/scoring_manager.py new file mode 100644 index 0000000..1a9b7b3 --- /dev/null +++ b/helper_bot/services/scoring/scoring_manager.py @@ -0,0 +1,242 @@ +""" +Менеджер для объединения всех сервисов скоринга. + +Координирует работу RAGService и DeepSeekService, +выполняет параллельные запросы и агрегирует результаты. +""" + +import asyncio +from typing import Optional, List + +from logs.custom_logger import logger +from helper_bot.utils.metrics import track_time, track_errors + +from .base import CombinedScore, ScoringResult +from .rag_service import RAGService +from .deepseek_service import DeepSeekService +from .vector_store import VectorStore +from .exceptions import ScoringError, InsufficientExamplesError, TextTooShortError + + +class ScoringManager: + """ + Менеджер для управления всеми сервисами скоринга. + + Объединяет RAGService и DeepSeekService, выполняет параллельные + запросы и агрегирует результаты в единый CombinedScore. + + Attributes: + rag_service: Сервис RAG с ruBERT + deepseek_service: Сервис DeepSeek API + """ + + def __init__( + self, + rag_service: Optional[RAGService] = None, + deepseek_service: Optional[DeepSeekService] = None, + ): + """ + Инициализация менеджера. + + Args: + rag_service: Сервис RAG (создается автоматически если не передан) + deepseek_service: Сервис DeepSeek (создается автоматически если не передан) + """ + self.rag_service = rag_service + self.deepseek_service = deepseek_service + + logger.info( + f"ScoringManager инициализирован " + f"(rag={rag_service is not None and rag_service.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_service is not None and self.rag_service.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 сервис + if self.rag_service and self.rag_service.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 сервиса.""" + try: + return await self.rag_service.calculate_score(text) + except InsufficientExamplesError: + # Недостаточно примеров - это не ошибка, просто нет данных + logger.info("ScoringManager: RAG - недостаточно примеров") + return None + except TextTooShortError: + # Текст слишком короткий - пропускаем + logger.debug("ScoringManager: RAG - текст слишком короткий") + return None + except Exception as e: + logger.error(f"ScoringManager: RAG ошибка: {e}") + 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: + logger.error(f"ScoringManager: DeepSeek ошибка: {e}") + 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_service and self.rag_service.is_enabled: + tasks.append(self.rag_service.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_service and self.rag_service.is_enabled: + tasks.append(self.rag_service.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 load_examples_from_db( + self, + positive_texts: List[str], + negative_texts: List[str], + ) -> None: + """ + Загружает примеры из базы данных при запуске бота. + + Args: + positive_texts: Список текстов опубликованных постов + negative_texts: Список текстов отклоненных постов + """ + if self.rag_service and self.rag_service.is_enabled: + await self.rag_service.load_examples_from_db( + positive_texts, + negative_texts + ) + + def save_vectors(self) -> None: + """Сохраняет векторы RAG на диск.""" + if self.rag_service: + self.rag_service.save_vectors() + + async def close(self) -> None: + """Закрывает ресурсы всех сервисов.""" + if self.deepseek_service: + await self.deepseek_service.close() + + # Сохраняем векторы перед закрытием + self.save_vectors() + + def get_stats(self) -> dict: + """Возвращает статистику всех сервисов.""" + stats = { + "any_enabled": self.is_any_enabled, + } + + if self.rag_service: + stats["rag"] = self.rag_service.get_stats() + + if self.deepseek_service: + stats["deepseek"] = self.deepseek_service.get_stats() + + return stats diff --git a/helper_bot/services/scoring/vector_store.py b/helper_bot/services/scoring/vector_store.py new file mode 100644 index 0000000..a0381b3 --- /dev/null +++ b/helper_bot/services/scoring/vector_store.py @@ -0,0 +1,399 @@ +""" +In-memory хранилище векторов на numpy. + +Хранит векторные представления постов для быстрого сравнения. +Поддерживает персистентность через сохранение/загрузку с диска. +""" + +import hashlib +import os +from pathlib import Path +from typing import Optional, Tuple, List +import threading + +import numpy as np + +from logs.custom_logger import logger +from .exceptions import VectorStoreError, InsufficientExamplesError + + +class VectorStore: + """ + In-memory хранилище векторов для RAG. + + Хранит отдельно положительные (опубликованные) и отрицательные (отклоненные) + примеры. Использует косинусное сходство для расчета скора. + + Attributes: + vector_dim: Размерность векторов (768 для ruBERT) + max_examples: Максимальное количество примеров каждого типа + """ + + def __init__( + self, + vector_dim: int = 768, + max_examples: int = 10000, + storage_path: Optional[str] = None, + score_multiplier: float = 5.0, + ): + """ + Инициализация хранилища. + + Args: + vector_dim: Размерность векторов + max_examples: Максимальное количество примеров каждого типа + storage_path: Путь для сохранения/загрузки векторов (опционально) + score_multiplier: Множитель для усиления разницы в скорах + """ + self.vector_dim = vector_dim + self.max_examples = max_examples + self.storage_path = storage_path + self.score_multiplier = score_multiplier + + # Инициализируем пустые массивы + # Используем список для динамического добавления, потом конвертируем в numpy + self._positive_vectors: list = [] + self._negative_vectors: list = [] + self._positive_hashes: list = [] # Хеши текстов для дедупликации + self._negative_hashes: list = [] + + # Lock для потокобезопасности + self._lock = threading.Lock() + + # Пытаемся загрузить сохраненные векторы + if storage_path and os.path.exists(storage_path): + self._load_from_disk() + + @property + def positive_count(self) -> int: + """Количество положительных примеров.""" + return len(self._positive_vectors) + + @property + def negative_count(self) -> int: + """Количество отрицательных примеров.""" + return len(self._negative_vectors) + + @property + def total_count(self) -> int: + """Общее количество примеров.""" + return self.positive_count + self.negative_count + + @staticmethod + def compute_text_hash(text: str) -> str: + """Вычисляет хеш текста для дедупликации.""" + return hashlib.md5(text.encode('utf-8')).hexdigest() + + def _normalize_vector(self, vector: np.ndarray) -> np.ndarray: + """Нормализует вектор для косинусного сходства.""" + norm = np.linalg.norm(vector) + if norm == 0: + return vector + return vector / norm + + def add_positive(self, vector: np.ndarray, text_hash: Optional[str] = None) -> bool: + """ + Добавляет положительный пример (опубликованный пост). + + Args: + vector: Векторное представление текста + text_hash: Хеш текста для дедупликации (опционально) + + Returns: + True если добавлен, False если дубликат или превышен лимит + """ + with self._lock: + # Проверяем дубликат по хешу + if text_hash and text_hash in self._positive_hashes: + logger.debug(f"VectorStore: Пропуск дубликата положительного примера") + return False + + # Проверяем лимит + if len(self._positive_vectors) >= self.max_examples: + # Удаляем самый старый пример (FIFO) + self._positive_vectors.pop(0) + self._positive_hashes.pop(0) + logger.debug("VectorStore: Удален старый положительный пример (лимит)") + + # Нормализуем и добавляем + normalized = self._normalize_vector(vector) + self._positive_vectors.append(normalized) + if text_hash: + self._positive_hashes.append(text_hash) + + logger.info(f"VectorStore: Добавлен положительный пример (всего: {self.positive_count})") + return True + + def add_positive_batch( + self, + vectors: List[np.ndarray], + text_hashes: Optional[List[str]] = None + ) -> int: + """ + Добавляет батч положительных примеров. + + Args: + vectors: Список векторов + text_hashes: Список хешей текстов для дедупликации + + Returns: + Количество добавленных примеров + """ + if text_hashes is None: + text_hashes = [None] * len(vectors) + + added = 0 + with self._lock: + for vector, text_hash in zip(vectors, text_hashes): + # Проверяем дубликат по хешу + if text_hash and text_hash in self._positive_hashes: + continue + + # Проверяем лимит + if len(self._positive_vectors) >= self.max_examples: + self._positive_vectors.pop(0) + self._positive_hashes.pop(0) + + # Нормализуем и добавляем + normalized = self._normalize_vector(vector) + self._positive_vectors.append(normalized) + if text_hash: + self._positive_hashes.append(text_hash) + added += 1 + + logger.info(f"VectorStore: Добавлено {added} положительных примеров батчем (всего: {self.positive_count})") + return added + + def add_negative(self, vector: np.ndarray, text_hash: Optional[str] = None) -> bool: + """ + Добавляет отрицательный пример (отклоненный пост). + + Args: + vector: Векторное представление текста + text_hash: Хеш текста для дедупликации (опционально) + + Returns: + True если добавлен, False если дубликат или превышен лимит + """ + with self._lock: + # Проверяем дубликат по хешу + if text_hash and text_hash in self._negative_hashes: + logger.debug(f"VectorStore: Пропуск дубликата отрицательного примера") + return False + + # Проверяем лимит + if len(self._negative_vectors) >= self.max_examples: + # Удаляем самый старый пример (FIFO) + self._negative_vectors.pop(0) + self._negative_hashes.pop(0) + logger.debug("VectorStore: Удален старый отрицательный пример (лимит)") + + # Нормализуем и добавляем + normalized = self._normalize_vector(vector) + self._negative_vectors.append(normalized) + if text_hash: + self._negative_hashes.append(text_hash) + + logger.info(f"VectorStore: Добавлен отрицательный пример (всего: {self.negative_count})") + return True + + def add_negative_batch( + self, + vectors: List[np.ndarray], + text_hashes: Optional[List[str]] = None + ) -> int: + """ + Добавляет батч отрицательных примеров. + + Args: + vectors: Список векторов + text_hashes: Список хешей текстов для дедупликации + + Returns: + Количество добавленных примеров + """ + if text_hashes is None: + text_hashes = [None] * len(vectors) + + added = 0 + with self._lock: + for vector, text_hash in zip(vectors, text_hashes): + # Проверяем дубликат по хешу + if text_hash and text_hash in self._negative_hashes: + continue + + # Проверяем лимит + if len(self._negative_vectors) >= self.max_examples: + self._negative_vectors.pop(0) + self._negative_hashes.pop(0) + + # Нормализуем и добавляем + normalized = self._normalize_vector(vector) + self._negative_vectors.append(normalized) + if text_hash: + self._negative_hashes.append(text_hash) + added += 1 + + logger.info(f"VectorStore: Добавлено {added} отрицательных примеров батчем (всего: {self.negative_count})") + return added + + def calculate_similarity_score(self, vector: np.ndarray) -> Tuple[float, float]: + """ + Рассчитывает скор на основе сходства с примерами. + + Алгоритм: + 1. Вычисляем среднее косинусное сходство с положительными примерами + 2. Вычисляем среднее косинусное сходство с отрицательными примерами + 3. Финальный скор = pos_sim / (pos_sim + neg_sim + eps) + + Args: + vector: Векторное представление нового поста + + Returns: + Tuple (score, confidence): + - score: Оценка от 0.0 до 1.0 + - confidence: Уверенность (зависит от количества примеров) + + Raises: + InsufficientExamplesError: Если недостаточно примеров + """ + with self._lock: + if self.positive_count == 0: + raise InsufficientExamplesError( + "Нет положительных примеров для сравнения" + ) + + # Нормализуем входной вектор + normalized = self._normalize_vector(vector) + + # Конвертируем в numpy массивы для быстрых вычислений + pos_matrix = np.array(self._positive_vectors) + + # Косинусное сходство с положительными примерами + # Для нормализованных векторов это просто скалярное произведение + pos_similarities = np.dot(pos_matrix, normalized) + pos_sim = float(np.mean(pos_similarities)) + + # Косинусное сходство с отрицательными примерами + if self.negative_count > 0: + neg_matrix = np.array(self._negative_vectors) + neg_similarities = np.dot(neg_matrix, normalized) + neg_sim = float(np.mean(neg_similarities)) + else: + # Если нет отрицательных примеров, используем нейтральное значение + neg_sim = pos_sim # Нейтральный скор = 0.5 + + # === Вариант 1: neg/pos (разница между положительными и отрицательными) === + diff = pos_sim - neg_sim + score_neg_pos = 0.5 + (diff * self.score_multiplier) + score_neg_pos = max(0.0, min(1.0, score_neg_pos)) + + # === Вариант 2: pos only (только положительные, топ-k ближайших) === + # Берём топ-5 ближайших положительных примеров + top_k = min(5, len(pos_similarities)) + top_k_sim = float(np.mean(np.sort(pos_similarities)[-top_k:])) + # Нормализуем: 0.85 -> 0.0, 0.95 -> 1.0 (типичный диапазон для BERT) + score_pos_only = (top_k_sim - 0.85) / 0.10 + score_pos_only = max(0.0, min(1.0, score_pos_only)) + + # Основной скор — neg/pos (можно будет переключить позже) + score = score_neg_pos + + # Confidence зависит от количества примеров (100% при 1000 примерах) + total_examples = self.positive_count + self.negative_count + confidence = min(1.0, total_examples / 1000) + + logger.info( + f"VectorStore: pos_sim={pos_sim:.4f}, neg_sim={neg_sim:.4f}, " + f"top_k_sim={top_k_sim:.4f}, score_neg_pos={score_neg_pos:.4f}, " + f"score_pos_only={score_pos_only:.4f}" + ) + + return score, confidence, score_pos_only + + def save_to_disk(self, path: Optional[str] = None) -> None: + """ + Сохраняет векторы на диск. + + Args: + path: Путь для сохранения (если не указан, используется storage_path) + """ + save_path = path or self.storage_path + if not save_path: + raise VectorStoreError("Путь для сохранения не указан") + + with self._lock: + # Создаем директорию если нужно + Path(save_path).parent.mkdir(parents=True, exist_ok=True) + + # Сохраняем в npz формате + np.savez_compressed( + save_path, + positive_vectors=np.array(self._positive_vectors) if self._positive_vectors else np.array([]), + negative_vectors=np.array(self._negative_vectors) if self._negative_vectors else np.array([]), + positive_hashes=np.array(self._positive_hashes, dtype=object), + negative_hashes=np.array(self._negative_hashes, dtype=object), + vector_dim=self.vector_dim, + max_examples=self.max_examples, + ) + + logger.info( + f"VectorStore: Сохранено на диск ({self.positive_count} pos, " + f"{self.negative_count} neg): {save_path}" + ) + + def _load_from_disk(self) -> None: + """Загружает векторы с диска.""" + if not self.storage_path or not os.path.exists(self.storage_path): + return + + try: + with self._lock: + data = np.load(self.storage_path, allow_pickle=True) + + # Загружаем векторы + pos_vectors = data.get('positive_vectors', np.array([])) + neg_vectors = data.get('negative_vectors', np.array([])) + + if pos_vectors.size > 0: + self._positive_vectors = list(pos_vectors) + if neg_vectors.size > 0: + self._negative_vectors = list(neg_vectors) + + # Загружаем хеши + pos_hashes = data.get('positive_hashes', np.array([])) + neg_hashes = data.get('negative_hashes', np.array([])) + + if pos_hashes.size > 0: + self._positive_hashes = list(pos_hashes) + if neg_hashes.size > 0: + self._negative_hashes = list(neg_hashes) + + logger.info( + f"VectorStore: Загружено с диска ({self.positive_count} pos, " + f"{self.negative_count} neg): {self.storage_path}" + ) + + except Exception as e: + logger.error(f"VectorStore: Ошибка загрузки с диска: {e}") + # Продолжаем с пустым хранилищем + + def clear(self) -> None: + """Очищает все векторы.""" + with self._lock: + self._positive_vectors.clear() + self._negative_vectors.clear() + self._positive_hashes.clear() + self._negative_hashes.clear() + logger.info("VectorStore: Хранилище очищено") + + def get_stats(self) -> dict: + """Возвращает статистику хранилища.""" + return { + "positive_count": self.positive_count, + "negative_count": self.negative_count, + "total_count": self.total_count, + "vector_dim": self.vector_dim, + "max_examples": self.max_examples, + "storage_path": self.storage_path, + } diff --git a/helper_bot/utils/base_dependency_factory.py b/helper_bot/utils/base_dependency_factory.py index fb2681b..82a0660 100644 --- a/helper_bot/utils/base_dependency_factory.py +++ b/helper_bot/utils/base_dependency_factory.py @@ -5,6 +5,7 @@ from typing import Optional 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,23 @@ class BaseDependencyFactory: 'bucket_name': os.getenv('S3_BUCKET_NAME', ''), 'region': os.getenv('S3_REGION', 'us-east-1') } + + # Настройки ML-скоринга + self.settings['Scoring'] = { + # RAG (ruBERT) + 'rag_enabled': self._parse_bool(os.getenv('RAG_ENABLED', 'false')), + 'rag_model': os.getenv('RAG_MODEL', 'DeepPavlov/rubert-base-cased'), + 'rag_cache_dir': os.getenv('RAG_CACHE_DIR', 'data/models'), + 'rag_vectors_path': os.getenv('RAG_VECTORS_PATH', 'data/vectors.npz'), + 'rag_max_examples': self._parse_int(os.getenv('RAG_MAX_EXAMPLES', '10000')), + 'rag_score_multiplier': self._parse_float(os.getenv('RAG_SCORE_MULTIPLIER', '5.0')), + # 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 +106,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 +124,100 @@ class BaseDependencyFactory: def get_s3_storage(self) -> Optional[S3StorageService]: """Возвращает S3StorageService если S3 включен, иначе None.""" return self.s3_storage + + def _init_scoring_manager(self): + """ + Инициализирует ScoringManager с RAG и DeepSeek сервисами. + + Вызывается лениво при первом обращении к get_scoring_manager(). + """ + from helper_bot.services.scoring import ( + ScoringManager, + RAGService, + DeepSeekService, + VectorStore, + ) + + scoring_config = self.settings['Scoring'] + + # Инициализация RAG сервиса + rag_service = None + if scoring_config['rag_enabled']: + # Путь к векторам + vectors_path = scoring_config['rag_vectors_path'] + if not os.path.isabs(vectors_path): + vectors_path = os.path.join(self._project_dir, vectors_path) + + # Путь к кешу моделей + cache_dir = scoring_config['rag_cache_dir'] + if not os.path.isabs(cache_dir): + cache_dir = os.path.join(self._project_dir, cache_dir) + + # Создаем директории если нужно + os.makedirs(os.path.dirname(vectors_path), exist_ok=True) + os.makedirs(cache_dir, exist_ok=True) + + # Создаем VectorStore + vector_store = VectorStore( + vector_dim=768, # ruBERT dimension + max_examples=scoring_config['rag_max_examples'], + storage_path=vectors_path, + score_multiplier=scoring_config['rag_score_multiplier'], + ) + + # Создаем RAGService + rag_service = RAGService( + model_name=scoring_config['rag_model'], + vector_store=vector_store, + cache_dir=cache_dir, + enabled=True, + ) + + logger.info(f"RAGService инициализирован: {scoring_config['rag_model']}") + + # Инициализация 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_service=rag_service, + 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 4412cef..2350a47 100644 --- a/helper_bot/utils/helper_func.py +++ b/helper_bot/utils/helper_func.py @@ -111,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. @@ -121,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: - Сформированный текст сообщения. @@ -137,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") diff --git a/requirements.txt b/requirements.txt index 4efef4f..968c3f3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,4 +30,10 @@ 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 + +# ML Scoring (для оценки вероятности публикации постов) +numpy>=1.24.0 +transformers>=4.30.0 +torch>=2.0.0 +httpx>=0.24.0 \ No newline at end of file diff --git a/scripts/add_ml_scores_columns.py b/scripts/add_ml_scores_columns.py new file mode 100644 index 0000000..a7c23ff --- /dev/null +++ b/scripts/add_ml_scores_columns.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Миграция: Добавление колонок для ML-скоринга постов. + +Добавляет: +- ml_scores (TEXT/JSON) - JSON с результатами оценки от разных моделей +- vector_hash (TEXT) - хеш текста для кеширования векторов + +Структура 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 и 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: + 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 уже существует") + + # Проверяем и добавляем колонку vector_hash + if not await column_exists(conn, "post_from_telegram_suggest", "vector_hash"): + await conn.execute( + "ALTER TABLE post_from_telegram_suggest ADD COLUMN vector_hash TEXT" + ) + logger.info("Колонка vector_hash добавлена в post_from_telegram_suggest") + else: + logger.info("Колонка vector_hash уже существует") + + await conn.commit() + logger.info("Миграция add_ml_scores_columns завершена успешно") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Добавление колонок ml_scores и vector_hash для 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/tests/test_scoring_services.py b/tests/test_scoring_services.py new file mode 100644 index 0000000..048796b --- /dev/null +++ b/tests/test_scoring_services.py @@ -0,0 +1,390 @@ +""" +Тесты для сервисов ML-скоринга постов. +""" + +import json +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +# Импорты для тестирования базовых классов +from helper_bot.services.scoring.base import ScoringResult, CombinedScore +from helper_bot.services.scoring.exceptions import ( + ScoringError, + InsufficientExamplesError, + 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", + )) + 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", + )) + 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_service=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_service=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_service=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_service=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_service=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_service=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("Отклоненный пост") -- 2.49.1 From feee7f010c71b5d7b194f5e1de3b25f8b2311365 Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 26 Jan 2026 22:03:15 +0300 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D0=B8=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=D1=8B=20ML-=D1=81=D0=BA=D0=BE=D1=80=D0=B8=D0=BD=D0=B3?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BD=D0=B0=20RAG=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Обновлен Dockerfile для использования Alpine вместо Slim, улучшая размер образа. - Удален устаревший RAGService и добавлен RagApiClient для работы с внешним RAG API. - Обновлены переменные окружения в env.example для настройки нового RAG API. - Обновлен ScoringManager для интеграции с RagApiClient. - Упрощена структура проекта, удалены ненужные файлы и зависимости, связанные с векторным хранилищем. - Обновлены обработчики и функции для работы с новым API, включая получение статистики и обработку ошибок. --- .cursor/rules/error-handling.md | 59 +- .gitignore | 5 +- Dockerfile | 30 +- database/repositories/post_repository.py | 18 - env.example | 13 +- helper_bot/handlers/admin/admin_handlers.py | 24 +- helper_bot/handlers/private/services.py | 2 +- helper_bot/main.py | 35 +- helper_bot/services/scoring/__init__.py | 9 +- helper_bot/services/scoring/rag_client.py | 311 +++++++++++ helper_bot/services/scoring/rag_service.py | 507 ------------------ .../services/scoring/scoring_manager.py | 76 +-- helper_bot/services/scoring/vector_store.py | 399 -------------- helper_bot/utils/base_dependency_factory.py | 65 +-- requirements.txt | 5 +- scripts/add_ml_scores_columns.py | 16 +- scripts/drop_vector_hash_column.py | 123 +++++ 17 files changed, 602 insertions(+), 1095 deletions(-) create mode 100644 helper_bot/services/scoring/rag_client.py delete mode 100644 helper_bot/services/scoring/rag_service.py delete mode 100644 helper_bot/services/scoring/vector_store.py create mode 100644 scripts/drop_vector_hash_column.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/.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/Dockerfile b/Dockerfile index c41ab93..c120d14 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,14 @@ ########################################### # Этап 1: Сборщик (Builder) ########################################### -FROM python:3.11.9-slim as builder +FROM python:3.11.9-alpine as builder -# Устанавливаем инструменты для компиляции -RUN apt-get update && apt-get install --no-install-recommends -y \ +# Устанавливаем инструменты для компиляции (если нужны для некоторых пакетов) +RUN apk add --no-cache \ gcc \ - g++ \ - python3-dev \ - && rm -rf /var/lib/apt/lists/* + musl-dev \ + libffi-dev \ + && rm -rf /var/cache/apk/* WORKDIR /app COPY requirements.txt . @@ -20,30 +20,20 @@ RUN pip install --no-cache-dir --target /install -r requirements.txt ########################################### # Этап 2: Финальный образ (Runtime) ########################################### -FROM python:3.11.9-slim as runtime - -# Минимальные рантайм-зависимости -RUN apt-get update && apt-get install --no-install-recommends -y \ - libgomp1 \ - && rm -rf /var/lib/apt/lists/* +FROM python:3.11.9-alpine as runtime # Создаем пользователя -RUN groupadd -g 1001 deploy && useradd -r -u 1001 -g deploy deploy +RUN addgroup -g 1001 deploy && adduser -D -u 1001 -G deploy deploy WORKDIR /app # Копируем зависимости COPY --from=builder --chown=deploy:deploy /install /usr/local/lib/python3.11/site-packages -# Создаем структуру папок (включая директории для ML моделей) -RUN mkdir -p database logs voice_users data/models && \ +# Создаем структуру папок +RUN mkdir -p database logs voice_users && \ chown -R deploy:deploy /app -# Устанавливаем переменные для HuggingFace (кеш моделей внутри /app) -ENV HF_HOME=/app/data/models -ENV TRANSFORMERS_CACHE=/app/data/models -ENV HF_HUB_CACHE=/app/data/models - # Копируем исходный код COPY --chown=deploy:deploy . . diff --git a/database/repositories/post_repository.py b/database/repositories/post_repository.py index cae5ede..e819cb6 100644 --- a/database/repositories/post_repository.py +++ b/database/repositories/post_repository.py @@ -462,21 +462,3 @@ class PostRepository(DatabaseConnection): self.logger.info(f"Получено {len(texts)} отклоненных постов для обучения") return texts - async def update_vector_hash(self, message_id: int, vector_hash: str) -> bool: - """ - Обновляет хеш вектора для поста (для кеширования). - - Args: - message_id: ID сообщения - vector_hash: Хеш вектора - - Returns: - True если обновлено успешно - """ - try: - query = "UPDATE post_from_telegram_suggest SET vector_hash = ? WHERE message_id = ?" - await self._execute_query(query, (vector_hash, message_id)) - return True - except Exception as e: - self.logger.error(f"Ошибка обновления vector_hash для message_id={message_id}: {e}") - return False diff --git a/env.example b/env.example index ea06d24..9350527 100644 --- a/env.example +++ b/env.example @@ -36,14 +36,13 @@ METRICS_PORT=8080 LOG_LEVEL=INFO LOG_RETENTION_DAYS=30 -# ML Scoring - RAG (ruBERT) -# Включает локальное векторное сравнение с использованием ruBERT +# ML Scoring - RAG API +# Включает оценку постов через внешний RAG API сервис RAG_ENABLED=false -RAG_MODEL=DeepPavlov/rubert-base-cased -RAG_CACHE_DIR=data/models -RAG_VECTORS_PATH=data/vectors.npz -RAG_MAX_EXAMPLES=10000 -RAG_SCORE_MULTIPLIER=5 +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 diff --git a/helper_bot/handlers/admin/admin_handlers.py b/helper_bot/handlers/admin/admin_handlers.py index 31ed534..89159a5 100644 --- a/helper_bot/handlers/admin/admin_handlers.py +++ b/helper_bot/handlers/admin/admin_handlers.py @@ -161,7 +161,7 @@ async def get_ml_stats( await message.answer("📊 ML Scoring отключен\n\nДля включения установите RAG_ENABLED=true или DEEPSEEK_ENABLED=true в .env") return - stats = scoring_manager.get_stats() + stats = await scoring_manager.get_stats() # Формируем текст статистики lines = ["📊 ML Scoring Статистика\n"] @@ -169,16 +169,22 @@ async def get_ml_stats( # RAG статистика if "rag" in stats: rag = stats["rag"] - lines.append("🤖 RAG (ruBERT):") + lines.append("🤖 RAG API:") lines.append(f" • Статус: {'✅ Включен' if rag.get('enabled') else '❌ Отключен'}") - lines.append(f" • Модель: {rag.get('model_name', 'N/A')}") - lines.append(f" • Модель загружена: {'✅' if rag.get('model_loaded') else '❌'}") + lines.append(f" • API URL: {rag.get('api_url', 'N/A')}") + + # Статистика из API (если доступна) + if "positive_examples" in rag or "negative_examples" in rag: + lines.append(f" • Положительных примеров: {rag.get('positive_examples', 0)}") + lines.append(f" • Отрицательных примеров: {rag.get('negative_examples', 0)}") + lines.append(f" • Всего примеров: {rag.get('total_examples', rag.get('positive_examples', 0) + rag.get('negative_examples', 0))}") + + # Модель из API (если доступна) + if "model_loaded" in rag: + lines.append(f" • Модель загружена: {'✅' if rag.get('model_loaded') else '❌'}") + if "model_name" in rag: + lines.append(f" • Модель: {rag.get('model_name', 'N/A')}") - vs = rag.get("vector_store", {}) - lines.append(f" • Положительных примеров: {vs.get('positive_count', 0)}") - lines.append(f" • Отрицательных примеров: {vs.get('negative_count', 0)}") - lines.append(f" • Всего примеров: {vs.get('total_count', 0)}") - lines.append(f" • Макс. примеров: {vs.get('max_examples', 'N/A')}") lines.append("") # DeepSeek статистика diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py index 904dd60..3fc4582 100644 --- a/helper_bot/handlers/private/services.py +++ b/helper_bot/handlers/private/services.py @@ -162,7 +162,7 @@ class PostService: # Получаем данные от RAG rag_confidence = scores.rag.confidence if scores.rag else None - rag_score_pos_only = scores.rag.metadata.get("score_pos_only") 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: diff --git a/helper_bot/main.py b/helper_bot/main.py index b710d47..a85ee0a 100644 --- a/helper_bot/main.py +++ b/helper_bot/main.py @@ -66,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: @@ -78,22 +89,6 @@ async def start_bot(bdf): await bot.delete_webhook(drop_pending_updates=True) - # Загружаем примеры для RAG из базы данных - scoring_manager = bdf.get_scoring_manager() - if scoring_manager and scoring_manager.rag_service and scoring_manager.rag_service.is_enabled: - try: - db = bdf.get_db() - positive_texts = await db.get_approved_posts_texts(limit=5000) - negative_texts = await db.get_declined_posts_texts(limit=5000) - - if positive_texts or negative_texts: - await scoring_manager.load_examples_from_db(positive_texts, negative_texts) - logging.info(f"RAG: Загружено {len(positive_texts)} положительных и {len(negative_texts)} отрицательных примеров") - else: - logging.warning("RAG: Нет примеров в базе данных для загрузки") - except Exception as e: - logging.error(f"Ошибка загрузки примеров для RAG: {e}") - # Запускаем HTTP сервер для метрик параллельно с ботом metrics_host = bdf.settings.get('Metrics', {}).get('host', '0.0.0.0') metrics_port = bdf.settings.get('Metrics', {}).get('port', 8080) @@ -113,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/services/scoring/__init__.py b/helper_bot/services/scoring/__init__.py index a56b7fe..b0eb339 100644 --- a/helper_bot/services/scoring/__init__.py +++ b/helper_bot/services/scoring/__init__.py @@ -2,10 +2,9 @@ Сервисы для ML-скоринга постов. Включает: -- RAGService - локальное векторное сравнение с ruBERT +- RagApiClient - HTTP клиент для внешнего RAG API сервиса - DeepSeekService - интеграция с DeepSeek API - ScoringManager - объединение всех сервисов скоринга -- VectorStore - in-memory хранилище векторов """ from .base import ScoringResult, ScoringServiceProtocol, CombinedScore @@ -17,8 +16,7 @@ from .exceptions import ( InsufficientExamplesError, TextTooShortError, ) -from .vector_store import VectorStore -from .rag_service import RAGService +from .rag_client import RagApiClient from .deepseek_service import DeepSeekService from .scoring_manager import ScoringManager @@ -35,8 +33,7 @@ __all__ = [ "InsufficientExamplesError", "TextTooShortError", # Сервисы - "VectorStore", - "RAGService", + "RagApiClient", "DeepSeekService", "ScoringManager", ] diff --git a/helper_bot/services/scoring/rag_client.py b/helper_bot/services/scoring/rag_client.py new file mode 100644 index 0000000..fc35a14 --- /dev/null +++ b/helper_bot/services/scoring/rag_client.py @@ -0,0 +1,311 @@ +""" +HTTP клиент для взаимодействия с внешним RAG сервисом. + +Использует REST API для получения скоров и отправки примеров. +""" + +from typing import Optional, Dict, Any +import httpx +from logs.custom_logger import logger +from helper_bot.utils.metrics import track_time, track_errors + +from .base import ScoringResult +from .exceptions import ScoringError, InsufficientExamplesError, 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. + + 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/rag_service.py b/helper_bot/services/scoring/rag_service.py deleted file mode 100644 index 0c02272..0000000 --- a/helper_bot/services/scoring/rag_service.py +++ /dev/null @@ -1,507 +0,0 @@ -""" -RAG сервис для скоринга постов с использованием ruBERT. - -Использует модель DeepPavlov/rubert-base-cased для создания эмбеддингов -и сравнивает их с эталонными примерами через VectorStore. -""" - -import asyncio -from typing import Optional, List - -import numpy as np - -from logs.custom_logger import logger -from helper_bot.utils.metrics import track_time, track_errors - -from .base import ScoringResult -from .vector_store import VectorStore -from .exceptions import ( - ModelNotLoadedError, - ScoringError, - InsufficientExamplesError, - TextTooShortError, -) - - -class RAGService: - """ - RAG сервис для оценки постов на основе векторного сходства. - - Использует ruBERT для создания эмбеддингов текста и сравнивает - их с эталонными примерами (опубликованные vs отклоненные посты). - - Attributes: - model_name: Название модели HuggingFace - vector_store: Хранилище векторов - min_text_length: Минимальная длина текста для обработки - """ - - # Название модели по умолчанию - DEFAULT_MODEL = "DeepPavlov/rubert-base-cased" - - def __init__( - self, - model_name: Optional[str] = None, - vector_store: Optional[VectorStore] = None, - cache_dir: Optional[str] = None, - enabled: bool = True, - min_text_length: int = 3, - ): - """ - Инициализация RAG сервиса. - - Args: - model_name: Название модели HuggingFace (по умолчанию ruBERT) - vector_store: Хранилище векторов (создается автоматически если не передано) - cache_dir: Директория для кеширования модели - enabled: Включен ли сервис - min_text_length: Минимальная длина текста для обработки - """ - self.model_name = model_name or self.DEFAULT_MODEL - self.cache_dir = cache_dir - self._enabled = enabled - self.min_text_length = min_text_length - - # Модель и токенизатор загружаются лениво - self._model = None - self._tokenizer = None - self._model_loaded = False - - # Хранилище векторов - self.vector_store = vector_store or VectorStore() - - logger.info(f"RAGService инициализирован (model={self.model_name}, enabled={enabled})") - - @property - def source_name(self) -> str: - """Имя источника для результатов.""" - return "rag" - - @property - def is_enabled(self) -> bool: - """Проверяет, включен ли сервис.""" - return self._enabled - - @property - def is_model_loaded(self) -> bool: - """Проверяет, загружена ли модель.""" - return self._model_loaded - - async def load_model(self) -> None: - """ - Загружает модель и токенизатор. - - Выполняется асинхронно в отдельном потоке чтобы не блокировать event loop. - """ - if self._model_loaded: - return - - if not self._enabled: - logger.warning("RAGService: Сервис отключен, модель не загружается") - return - - logger.info(f"RAGService: Загрузка модели {self.model_name}...") - - try: - # Загрузка в отдельном потоке - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, self._load_model_sync) - - self._model_loaded = True - logger.info(f"RAGService: Модель {self.model_name} успешно загружена") - - except Exception as e: - logger.error(f"RAGService: Ошибка загрузки модели: {e}") - raise ModelNotLoadedError(f"Не удалось загрузить модель {self.model_name}: {e}") - - def _load_model_sync(self) -> None: - """Синхронная загрузка модели (вызывается в executor).""" - logger.info("RAGService: Начало _load_model_sync, импорт transformers...") - from transformers import AutoTokenizer, AutoModel - import torch - - # Определяем устройство - self._device = "cuda" if torch.cuda.is_available() else "cpu" - logger.info(f"RAGService: Устройство определено: {self._device}") - - # Загружаем токенизатор - logger.info(f"RAGService: Загрузка токенизатора из {self.model_name}...") - self._tokenizer = AutoTokenizer.from_pretrained( - self.model_name, - cache_dir=self.cache_dir, - ) - logger.info("RAGService: Токенизатор загружен") - - # Загружаем модель - logger.info(f"RAGService: Загрузка модели из {self.model_name} (это может занять несколько минут)...") - self._model = AutoModel.from_pretrained( - self.model_name, - cache_dir=self.cache_dir, - ) - logger.info("RAGService: Модель загружена, перенос на устройство...") - self._model.to(self._device) - self._model.eval() # Режим инференса - - logger.info(f"RAGService: Модель готова на устройстве: {self._device}") - - def _get_embedding_sync(self, text: str) -> np.ndarray: - """ - Получает эмбеддинг текста (синхронно). - - Использует [CLS] токен как представление всего текста. - - Args: - text: Текст для векторизации - - Returns: - Numpy массив с эмбеддингом (768 измерений для ruBERT) - """ - import torch - - # Токенизация с ограничением длины - inputs = self._tokenizer( - text, - return_tensors="pt", - truncation=True, - max_length=512, - padding=True, - ) - inputs = {k: v.to(self._device) for k, v in inputs.items()} - - # Получаем эмбеддинг - with torch.no_grad(): - outputs = self._model(**inputs) - # Используем [CLS] токен (первый токен) - embedding = outputs.last_hidden_state[:, 0, :].cpu().numpy() - - return embedding.flatten() - - def _get_embeddings_batch_sync(self, texts: List[str], batch_size: int = 16) -> List[np.ndarray]: - """ - Получает эмбеддинги для батча текстов (синхронно). - - Обрабатывает тексты пачками для эффективного использования GPU/CPU. - - Args: - texts: Список текстов для векторизации - batch_size: Размер батча (по умолчанию 16) - - Returns: - Список numpy массивов с эмбеддингами - """ - import torch - - all_embeddings = [] - - for i in range(0, len(texts), batch_size): - batch_texts = texts[i:i + batch_size] - - # Токенизация батча - inputs = self._tokenizer( - batch_texts, - return_tensors="pt", - truncation=True, - max_length=512, - padding=True, - ) - inputs = {k: v.to(self._device) for k, v in inputs.items()} - - # Получаем эмбеддинги - with torch.no_grad(): - outputs = self._model(**inputs) - # [CLS] токен для каждого текста в батче - batch_embeddings = outputs.last_hidden_state[:, 0, :].cpu().numpy() - - # Разбиваем на отдельные эмбеддинги - for j in range(len(batch_texts)): - all_embeddings.append(batch_embeddings[j]) - - if i > 0 and i % (batch_size * 10) == 0: - logger.info(f"RAGService: Обработано {i}/{len(texts)} текстов") - - return all_embeddings - - async def get_embeddings_batch(self, texts: List[str], batch_size: int = 16) -> List[np.ndarray]: - """ - Получает эмбеддинги для батча текстов (асинхронно). - - Args: - texts: Список текстов для векторизации - batch_size: Размер батча - - Returns: - Список numpy массивов с эмбеддингами - """ - if not self._model_loaded: - await self.load_model() - - if not self._model_loaded: - raise ModelNotLoadedError("Модель не загружена") - - # Очищаем тексты - clean_texts = [self._clean_text(text) for text in texts] - - # Выполняем батч-обработку в thread pool - loop = asyncio.get_event_loop() - embeddings = await loop.run_in_executor( - None, - self._get_embeddings_batch_sync, - clean_texts, - batch_size, - ) - - return embeddings - - async def get_embedding(self, text: str) -> np.ndarray: - """ - Получает эмбеддинг текста (асинхронно). - - Args: - text: Текст для векторизации - - Returns: - Numpy массив с эмбеддингом - - Raises: - ModelNotLoadedError: Если модель не загружена - TextTooShortError: Если текст слишком короткий - """ - if not self._model_loaded: - await self.load_model() - - if not self._model_loaded: - raise ModelNotLoadedError("Модель не загружена") - - # Очищаем текст - clean_text = self._clean_text(text) - - if len(clean_text) < self.min_text_length: - raise TextTooShortError( - f"Текст слишком короткий (минимум {self.min_text_length} символов)" - ) - - # Выполняем в отдельном потоке - loop = asyncio.get_event_loop() - embedding = await loop.run_in_executor( - None, - self._get_embedding_sync, - clean_text - ) - - return embedding - - def _clean_text(self, text: str) -> str: - """Очищает текст от лишних символов.""" - if not text: - return "" - - # Удаляем лишние пробелы и переносы строк - clean = " ".join(text.split()) - - # Удаляем служебные символы (например "^" для helper сообщений) - if clean == "^": - return "" - - return clean.strip() - - @track_time("calculate_score", "rag_service") - @track_errors("rag_service", "calculate_score") - async def calculate_score(self, text: str) -> ScoringResult: - """ - Рассчитывает скор для текста поста. - - Args: - text: Текст поста для оценки - - Returns: - ScoringResult с оценкой - - Raises: - ScoringError: При ошибке расчета - """ - if not self._enabled: - raise ScoringError("RAG сервис отключен") - - try: - # Получаем эмбеддинг текста - embedding = await self.get_embedding(text) - - # Логируем первые элементы вектора для отладки - logger.info( - f"RAGService: embedding[:3]={embedding[:3].tolist()}, " - f"text_preview='{text[:30]}'" - ) - - # Рассчитываем скор через VectorStore - score, confidence, score_pos_only = self.vector_store.calculate_similarity_score(embedding) - - return ScoringResult( - score=score, - source=self.source_name, - model=self.model_name, - confidence=confidence, - metadata={ - "positive_examples": self.vector_store.positive_count, - "negative_examples": self.vector_store.negative_count, - "score_pos_only": score_pos_only, # Для сравнения - }, - ) - - except InsufficientExamplesError: - # Не достаточно примеров - возвращаем нейтральный скор - logger.warning("RAGService: Недостаточно примеров для расчета скора") - raise - - except TextTooShortError: - logger.warning(f"RAGService: Текст слишком короткий для оценки") - raise - - except Exception as e: - logger.error(f"RAGService: Ошибка расчета скора: {e}") - raise ScoringError(f"Ошибка расчета скора: {e}") - - @track_time("add_positive_example", "rag_service") - async def add_positive_example(self, text: str) -> None: - """ - Добавляет текст как положительный пример (опубликованный пост). - - Args: - text: Текст опубликованного поста - """ - if not self._enabled: - return - - try: - clean_text = self._clean_text(text) - if len(clean_text) < self.min_text_length: - logger.debug("RAGService: Текст слишком короткий для примера, пропускаем") - return - - # Получаем эмбеддинг - embedding = await self.get_embedding(clean_text) - - # Вычисляем хеш для дедупликации - text_hash = VectorStore.compute_text_hash(clean_text) - - # Добавляем в хранилище - added = self.vector_store.add_positive(embedding, text_hash) - - if added: - logger.info(f"RAGService: Добавлен положительный пример") - - except Exception as e: - logger.error(f"RAGService: Ошибка добавления положительного примера: {e}") - - @track_time("add_negative_example", "rag_service") - async def add_negative_example(self, text: str) -> None: - """ - Добавляет текст как отрицательный пример (отклоненный пост). - - Args: - text: Текст отклоненного поста - """ - if not self._enabled: - return - - try: - clean_text = self._clean_text(text) - if len(clean_text) < self.min_text_length: - logger.debug("RAGService: Текст слишком короткий для примера, пропускаем") - return - - # Получаем эмбеддинг - embedding = await self.get_embedding(clean_text) - - # Вычисляем хеш для дедупликации - text_hash = VectorStore.compute_text_hash(clean_text) - - # Добавляем в хранилище - added = self.vector_store.add_negative(embedding, text_hash) - - if added: - logger.info(f"RAGService: Добавлен отрицательный пример") - - except Exception as e: - logger.error(f"RAGService: Ошибка добавления отрицательного примера: {e}") - - async def load_examples_from_db( - self, - positive_texts: list[str], - negative_texts: list[str], - batch_size: int = 16, - ) -> None: - """ - Загружает примеры из базы данных с батч-обработкой. - - Используется при запуске бота для восстановления VectorStore. - Батч-обработка ускоряет загрузку в 10-20 раз. - - Args: - positive_texts: Список текстов опубликованных постов - negative_texts: Список текстов отклоненных постов - batch_size: Размер батча для обработки (по умолчанию 16) - """ - if not self._enabled: - return - - logger.info( - f"RAGService: Загрузка примеров из БД с батч-обработкой " - f"(positive: {len(positive_texts)}, negative: {len(negative_texts)}, batch_size: {batch_size})" - ) - - # Убеждаемся что модель загружена - await self.load_model() - - import time - start_time = time.time() - - # Фильтруем и очищаем положительные тексты - if positive_texts: - clean_positive = [] - positive_hashes = [] - for text in positive_texts: - clean_text = self._clean_text(text) - if len(clean_text) >= self.min_text_length: - clean_positive.append(clean_text) - positive_hashes.append(VectorStore.compute_text_hash(clean_text)) - - if clean_positive: - logger.info(f"RAGService: Обработка {len(clean_positive)} положительных примеров батчами...") - positive_embeddings = await self.get_embeddings_batch(clean_positive, batch_size) - self.vector_store.add_positive_batch(positive_embeddings, positive_hashes) - - # Фильтруем и очищаем отрицательные тексты - if negative_texts: - clean_negative = [] - negative_hashes = [] - for text in negative_texts: - clean_text = self._clean_text(text) - if len(clean_text) >= self.min_text_length: - clean_negative.append(clean_text) - negative_hashes.append(VectorStore.compute_text_hash(clean_text)) - - if clean_negative: - logger.info(f"RAGService: Обработка {len(clean_negative)} отрицательных примеров батчами...") - negative_embeddings = await self.get_embeddings_batch(clean_negative, batch_size) - self.vector_store.add_negative_batch(negative_embeddings, negative_hashes) - - elapsed = time.time() - start_time - logger.info( - f"RAGService: Загрузка завершена за {elapsed:.1f} сек " - f"(positive: {self.vector_store.positive_count}, " - f"negative: {self.vector_store.negative_count})" - ) - - def save_vectors(self) -> None: - """Сохраняет векторы на диск.""" - if self.vector_store.storage_path: - self.vector_store.save_to_disk() - - def get_stats(self) -> dict: - """Возвращает статистику сервиса.""" - return { - "enabled": self._enabled, - "model_name": self.model_name, - "model_loaded": self._model_loaded, - "vector_store": self.vector_store.get_stats(), - } diff --git a/helper_bot/services/scoring/scoring_manager.py b/helper_bot/services/scoring/scoring_manager.py index 1a9b7b3..fa23dcb 100644 --- a/helper_bot/services/scoring/scoring_manager.py +++ b/helper_bot/services/scoring/scoring_manager.py @@ -1,20 +1,19 @@ """ Менеджер для объединения всех сервисов скоринга. -Координирует работу RAGService и DeepSeekService, +Координирует работу RagApiClient и DeepSeekService, выполняет параллельные запросы и агрегирует результаты. """ import asyncio -from typing import Optional, List +from typing import Optional from logs.custom_logger import logger from helper_bot.utils.metrics import track_time, track_errors from .base import CombinedScore, ScoringResult -from .rag_service import RAGService +from .rag_client import RagApiClient from .deepseek_service import DeepSeekService -from .vector_store import VectorStore from .exceptions import ScoringError, InsufficientExamplesError, TextTooShortError @@ -22,39 +21,39 @@ class ScoringManager: """ Менеджер для управления всеми сервисами скоринга. - Объединяет RAGService и DeepSeekService, выполняет параллельные + Объединяет RagApiClient и DeepSeekService, выполняет параллельные запросы и агрегирует результаты в единый CombinedScore. Attributes: - rag_service: Сервис RAG с ruBERT + rag_client: HTTP клиент для RAG API deepseek_service: Сервис DeepSeek API """ def __init__( self, - rag_service: Optional[RAGService] = None, + rag_client: Optional[RagApiClient] = None, deepseek_service: Optional[DeepSeekService] = None, ): """ Инициализация менеджера. Args: - rag_service: Сервис RAG (создается автоматически если не передан) + rag_client: HTTP клиент для RAG API (создается автоматически если не передан) deepseek_service: Сервис DeepSeek (создается автоматически если не передан) """ - self.rag_service = rag_service + self.rag_client = rag_client self.deepseek_service = deepseek_service logger.info( f"ScoringManager инициализирован " - f"(rag={rag_service is not None and rag_service.is_enabled}, " + 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_service is not None and self.rag_service.is_enabled + 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 @@ -82,8 +81,8 @@ class ScoringManager: tasks = [] task_names = [] - # RAG сервис - if self.rag_service and self.rag_service.is_enabled: + # RAG API клиент + if self.rag_client and self.rag_client.is_enabled: tasks.append(self._get_rag_score(text)) task_names.append("rag") @@ -104,6 +103,7 @@ class ScoringManager: 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": @@ -119,9 +119,9 @@ class ScoringManager: return result async def _get_rag_score(self, text: str) -> Optional[ScoringResult]: - """Получает скор от RAG сервиса.""" + """Получает скор от RAG API.""" try: - return await self.rag_service.calculate_score(text) + return await self.rag_client.calculate_score(text) except InsufficientExamplesError: # Недостаточно примеров - это не ошибка, просто нет данных logger.info("ScoringManager: RAG - недостаточно примеров") @@ -131,7 +131,7 @@ class ScoringManager: logger.debug("ScoringManager: RAG - текст слишком короткий") return None except Exception as e: - logger.error(f"ScoringManager: RAG ошибка: {e}") + # Ошибки уже залогированы в RagApiClient, здесь только пробрасываем raise async def _get_deepseek_score(self, text: str) -> Optional[ScoringResult]: @@ -143,7 +143,7 @@ class ScoringManager: logger.debug("ScoringManager: DeepSeek - текст слишком короткий") return None except Exception as e: - logger.error(f"ScoringManager: DeepSeek ошибка: {e}") + # Ошибки уже залогированы в DeepSeekService, здесь только пробрасываем raise @track_time("on_post_published", "scoring_manager") @@ -161,8 +161,8 @@ class ScoringManager: tasks = [] - if self.rag_service and self.rag_service.is_enabled: - tasks.append(self.rag_service.add_positive_example(text)) + 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)) @@ -186,8 +186,8 @@ class ScoringManager: tasks = [] - if self.rag_service and self.rag_service.is_enabled: - tasks.append(self.rag_service.add_negative_example(text)) + 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)) @@ -196,45 +196,25 @@ class ScoringManager: await asyncio.gather(*tasks, return_exceptions=True) logger.info("ScoringManager: Добавлен отрицательный пример") - async def load_examples_from_db( - self, - positive_texts: List[str], - negative_texts: List[str], - ) -> None: - """ - Загружает примеры из базы данных при запуске бота. - - Args: - positive_texts: Список текстов опубликованных постов - negative_texts: Список текстов отклоненных постов - """ - if self.rag_service and self.rag_service.is_enabled: - await self.rag_service.load_examples_from_db( - positive_texts, - negative_texts - ) - - def save_vectors(self) -> None: - """Сохраняет векторы RAG на диск.""" - if self.rag_service: - self.rag_service.save_vectors() async def close(self) -> None: """Закрывает ресурсы всех сервисов.""" if self.deepseek_service: await self.deepseek_service.close() - # Сохраняем векторы перед закрытием - self.save_vectors() + if self.rag_client: + await self.rag_client.close() - def get_stats(self) -> dict: + async def get_stats(self) -> dict: """Возвращает статистику всех сервисов.""" stats = { "any_enabled": self.is_any_enabled, } - if self.rag_service: - stats["rag"] = self.rag_service.get_stats() + 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() diff --git a/helper_bot/services/scoring/vector_store.py b/helper_bot/services/scoring/vector_store.py deleted file mode 100644 index a0381b3..0000000 --- a/helper_bot/services/scoring/vector_store.py +++ /dev/null @@ -1,399 +0,0 @@ -""" -In-memory хранилище векторов на numpy. - -Хранит векторные представления постов для быстрого сравнения. -Поддерживает персистентность через сохранение/загрузку с диска. -""" - -import hashlib -import os -from pathlib import Path -from typing import Optional, Tuple, List -import threading - -import numpy as np - -from logs.custom_logger import logger -from .exceptions import VectorStoreError, InsufficientExamplesError - - -class VectorStore: - """ - In-memory хранилище векторов для RAG. - - Хранит отдельно положительные (опубликованные) и отрицательные (отклоненные) - примеры. Использует косинусное сходство для расчета скора. - - Attributes: - vector_dim: Размерность векторов (768 для ruBERT) - max_examples: Максимальное количество примеров каждого типа - """ - - def __init__( - self, - vector_dim: int = 768, - max_examples: int = 10000, - storage_path: Optional[str] = None, - score_multiplier: float = 5.0, - ): - """ - Инициализация хранилища. - - Args: - vector_dim: Размерность векторов - max_examples: Максимальное количество примеров каждого типа - storage_path: Путь для сохранения/загрузки векторов (опционально) - score_multiplier: Множитель для усиления разницы в скорах - """ - self.vector_dim = vector_dim - self.max_examples = max_examples - self.storage_path = storage_path - self.score_multiplier = score_multiplier - - # Инициализируем пустые массивы - # Используем список для динамического добавления, потом конвертируем в numpy - self._positive_vectors: list = [] - self._negative_vectors: list = [] - self._positive_hashes: list = [] # Хеши текстов для дедупликации - self._negative_hashes: list = [] - - # Lock для потокобезопасности - self._lock = threading.Lock() - - # Пытаемся загрузить сохраненные векторы - if storage_path and os.path.exists(storage_path): - self._load_from_disk() - - @property - def positive_count(self) -> int: - """Количество положительных примеров.""" - return len(self._positive_vectors) - - @property - def negative_count(self) -> int: - """Количество отрицательных примеров.""" - return len(self._negative_vectors) - - @property - def total_count(self) -> int: - """Общее количество примеров.""" - return self.positive_count + self.negative_count - - @staticmethod - def compute_text_hash(text: str) -> str: - """Вычисляет хеш текста для дедупликации.""" - return hashlib.md5(text.encode('utf-8')).hexdigest() - - def _normalize_vector(self, vector: np.ndarray) -> np.ndarray: - """Нормализует вектор для косинусного сходства.""" - norm = np.linalg.norm(vector) - if norm == 0: - return vector - return vector / norm - - def add_positive(self, vector: np.ndarray, text_hash: Optional[str] = None) -> bool: - """ - Добавляет положительный пример (опубликованный пост). - - Args: - vector: Векторное представление текста - text_hash: Хеш текста для дедупликации (опционально) - - Returns: - True если добавлен, False если дубликат или превышен лимит - """ - with self._lock: - # Проверяем дубликат по хешу - if text_hash and text_hash in self._positive_hashes: - logger.debug(f"VectorStore: Пропуск дубликата положительного примера") - return False - - # Проверяем лимит - if len(self._positive_vectors) >= self.max_examples: - # Удаляем самый старый пример (FIFO) - self._positive_vectors.pop(0) - self._positive_hashes.pop(0) - logger.debug("VectorStore: Удален старый положительный пример (лимит)") - - # Нормализуем и добавляем - normalized = self._normalize_vector(vector) - self._positive_vectors.append(normalized) - if text_hash: - self._positive_hashes.append(text_hash) - - logger.info(f"VectorStore: Добавлен положительный пример (всего: {self.positive_count})") - return True - - def add_positive_batch( - self, - vectors: List[np.ndarray], - text_hashes: Optional[List[str]] = None - ) -> int: - """ - Добавляет батч положительных примеров. - - Args: - vectors: Список векторов - text_hashes: Список хешей текстов для дедупликации - - Returns: - Количество добавленных примеров - """ - if text_hashes is None: - text_hashes = [None] * len(vectors) - - added = 0 - with self._lock: - for vector, text_hash in zip(vectors, text_hashes): - # Проверяем дубликат по хешу - if text_hash and text_hash in self._positive_hashes: - continue - - # Проверяем лимит - if len(self._positive_vectors) >= self.max_examples: - self._positive_vectors.pop(0) - self._positive_hashes.pop(0) - - # Нормализуем и добавляем - normalized = self._normalize_vector(vector) - self._positive_vectors.append(normalized) - if text_hash: - self._positive_hashes.append(text_hash) - added += 1 - - logger.info(f"VectorStore: Добавлено {added} положительных примеров батчем (всего: {self.positive_count})") - return added - - def add_negative(self, vector: np.ndarray, text_hash: Optional[str] = None) -> bool: - """ - Добавляет отрицательный пример (отклоненный пост). - - Args: - vector: Векторное представление текста - text_hash: Хеш текста для дедупликации (опционально) - - Returns: - True если добавлен, False если дубликат или превышен лимит - """ - with self._lock: - # Проверяем дубликат по хешу - if text_hash and text_hash in self._negative_hashes: - logger.debug(f"VectorStore: Пропуск дубликата отрицательного примера") - return False - - # Проверяем лимит - if len(self._negative_vectors) >= self.max_examples: - # Удаляем самый старый пример (FIFO) - self._negative_vectors.pop(0) - self._negative_hashes.pop(0) - logger.debug("VectorStore: Удален старый отрицательный пример (лимит)") - - # Нормализуем и добавляем - normalized = self._normalize_vector(vector) - self._negative_vectors.append(normalized) - if text_hash: - self._negative_hashes.append(text_hash) - - logger.info(f"VectorStore: Добавлен отрицательный пример (всего: {self.negative_count})") - return True - - def add_negative_batch( - self, - vectors: List[np.ndarray], - text_hashes: Optional[List[str]] = None - ) -> int: - """ - Добавляет батч отрицательных примеров. - - Args: - vectors: Список векторов - text_hashes: Список хешей текстов для дедупликации - - Returns: - Количество добавленных примеров - """ - if text_hashes is None: - text_hashes = [None] * len(vectors) - - added = 0 - with self._lock: - for vector, text_hash in zip(vectors, text_hashes): - # Проверяем дубликат по хешу - if text_hash and text_hash in self._negative_hashes: - continue - - # Проверяем лимит - if len(self._negative_vectors) >= self.max_examples: - self._negative_vectors.pop(0) - self._negative_hashes.pop(0) - - # Нормализуем и добавляем - normalized = self._normalize_vector(vector) - self._negative_vectors.append(normalized) - if text_hash: - self._negative_hashes.append(text_hash) - added += 1 - - logger.info(f"VectorStore: Добавлено {added} отрицательных примеров батчем (всего: {self.negative_count})") - return added - - def calculate_similarity_score(self, vector: np.ndarray) -> Tuple[float, float]: - """ - Рассчитывает скор на основе сходства с примерами. - - Алгоритм: - 1. Вычисляем среднее косинусное сходство с положительными примерами - 2. Вычисляем среднее косинусное сходство с отрицательными примерами - 3. Финальный скор = pos_sim / (pos_sim + neg_sim + eps) - - Args: - vector: Векторное представление нового поста - - Returns: - Tuple (score, confidence): - - score: Оценка от 0.0 до 1.0 - - confidence: Уверенность (зависит от количества примеров) - - Raises: - InsufficientExamplesError: Если недостаточно примеров - """ - with self._lock: - if self.positive_count == 0: - raise InsufficientExamplesError( - "Нет положительных примеров для сравнения" - ) - - # Нормализуем входной вектор - normalized = self._normalize_vector(vector) - - # Конвертируем в numpy массивы для быстрых вычислений - pos_matrix = np.array(self._positive_vectors) - - # Косинусное сходство с положительными примерами - # Для нормализованных векторов это просто скалярное произведение - pos_similarities = np.dot(pos_matrix, normalized) - pos_sim = float(np.mean(pos_similarities)) - - # Косинусное сходство с отрицательными примерами - if self.negative_count > 0: - neg_matrix = np.array(self._negative_vectors) - neg_similarities = np.dot(neg_matrix, normalized) - neg_sim = float(np.mean(neg_similarities)) - else: - # Если нет отрицательных примеров, используем нейтральное значение - neg_sim = pos_sim # Нейтральный скор = 0.5 - - # === Вариант 1: neg/pos (разница между положительными и отрицательными) === - diff = pos_sim - neg_sim - score_neg_pos = 0.5 + (diff * self.score_multiplier) - score_neg_pos = max(0.0, min(1.0, score_neg_pos)) - - # === Вариант 2: pos only (только положительные, топ-k ближайших) === - # Берём топ-5 ближайших положительных примеров - top_k = min(5, len(pos_similarities)) - top_k_sim = float(np.mean(np.sort(pos_similarities)[-top_k:])) - # Нормализуем: 0.85 -> 0.0, 0.95 -> 1.0 (типичный диапазон для BERT) - score_pos_only = (top_k_sim - 0.85) / 0.10 - score_pos_only = max(0.0, min(1.0, score_pos_only)) - - # Основной скор — neg/pos (можно будет переключить позже) - score = score_neg_pos - - # Confidence зависит от количества примеров (100% при 1000 примерах) - total_examples = self.positive_count + self.negative_count - confidence = min(1.0, total_examples / 1000) - - logger.info( - f"VectorStore: pos_sim={pos_sim:.4f}, neg_sim={neg_sim:.4f}, " - f"top_k_sim={top_k_sim:.4f}, score_neg_pos={score_neg_pos:.4f}, " - f"score_pos_only={score_pos_only:.4f}" - ) - - return score, confidence, score_pos_only - - def save_to_disk(self, path: Optional[str] = None) -> None: - """ - Сохраняет векторы на диск. - - Args: - path: Путь для сохранения (если не указан, используется storage_path) - """ - save_path = path or self.storage_path - if not save_path: - raise VectorStoreError("Путь для сохранения не указан") - - with self._lock: - # Создаем директорию если нужно - Path(save_path).parent.mkdir(parents=True, exist_ok=True) - - # Сохраняем в npz формате - np.savez_compressed( - save_path, - positive_vectors=np.array(self._positive_vectors) if self._positive_vectors else np.array([]), - negative_vectors=np.array(self._negative_vectors) if self._negative_vectors else np.array([]), - positive_hashes=np.array(self._positive_hashes, dtype=object), - negative_hashes=np.array(self._negative_hashes, dtype=object), - vector_dim=self.vector_dim, - max_examples=self.max_examples, - ) - - logger.info( - f"VectorStore: Сохранено на диск ({self.positive_count} pos, " - f"{self.negative_count} neg): {save_path}" - ) - - def _load_from_disk(self) -> None: - """Загружает векторы с диска.""" - if not self.storage_path or not os.path.exists(self.storage_path): - return - - try: - with self._lock: - data = np.load(self.storage_path, allow_pickle=True) - - # Загружаем векторы - pos_vectors = data.get('positive_vectors', np.array([])) - neg_vectors = data.get('negative_vectors', np.array([])) - - if pos_vectors.size > 0: - self._positive_vectors = list(pos_vectors) - if neg_vectors.size > 0: - self._negative_vectors = list(neg_vectors) - - # Загружаем хеши - pos_hashes = data.get('positive_hashes', np.array([])) - neg_hashes = data.get('negative_hashes', np.array([])) - - if pos_hashes.size > 0: - self._positive_hashes = list(pos_hashes) - if neg_hashes.size > 0: - self._negative_hashes = list(neg_hashes) - - logger.info( - f"VectorStore: Загружено с диска ({self.positive_count} pos, " - f"{self.negative_count} neg): {self.storage_path}" - ) - - except Exception as e: - logger.error(f"VectorStore: Ошибка загрузки с диска: {e}") - # Продолжаем с пустым хранилищем - - def clear(self) -> None: - """Очищает все векторы.""" - with self._lock: - self._positive_vectors.clear() - self._negative_vectors.clear() - self._positive_hashes.clear() - self._negative_hashes.clear() - logger.info("VectorStore: Хранилище очищено") - - def get_stats(self) -> dict: - """Возвращает статистику хранилища.""" - return { - "positive_count": self.positive_count, - "negative_count": self.negative_count, - "total_count": self.total_count, - "vector_dim": self.vector_dim, - "max_examples": self.max_examples, - "storage_path": self.storage_path, - } diff --git a/helper_bot/utils/base_dependency_factory.py b/helper_bot/utils/base_dependency_factory.py index 82a0660..db71fa0 100644 --- a/helper_bot/utils/base_dependency_factory.py +++ b/helper_bot/utils/base_dependency_factory.py @@ -67,13 +67,12 @@ class BaseDependencyFactory: # Настройки ML-скоринга self.settings['Scoring'] = { - # RAG (ruBERT) + # RAG API 'rag_enabled': self._parse_bool(os.getenv('RAG_ENABLED', 'false')), - 'rag_model': os.getenv('RAG_MODEL', 'DeepPavlov/rubert-base-cased'), - 'rag_cache_dir': os.getenv('RAG_CACHE_DIR', 'data/models'), - 'rag_vectors_path': os.getenv('RAG_VECTORS_PATH', 'data/vectors.npz'), - 'rag_max_examples': self._parse_int(os.getenv('RAG_MAX_EXAMPLES', '10000')), - 'rag_score_multiplier': self._parse_float(os.getenv('RAG_SCORE_MULTIPLIER', '5.0')), + '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', ''), @@ -127,53 +126,35 @@ class BaseDependencyFactory: def _init_scoring_manager(self): """ - Инициализирует ScoringManager с RAG и DeepSeek сервисами. + Инициализирует ScoringManager с RAG API клиентом и DeepSeek сервисом. Вызывается лениво при первом обращении к get_scoring_manager(). """ from helper_bot.services.scoring import ( ScoringManager, - RAGService, + RagApiClient, DeepSeekService, - VectorStore, ) scoring_config = self.settings['Scoring'] - # Инициализация RAG сервиса - rag_service = None + # Инициализация RAG API клиента + rag_client = None if scoring_config['rag_enabled']: - # Путь к векторам - vectors_path = scoring_config['rag_vectors_path'] - if not os.path.isabs(vectors_path): - vectors_path = os.path.join(self._project_dir, vectors_path) + api_url = scoring_config['rag_api_url'] + api_key = scoring_config['rag_api_key'] - # Путь к кешу моделей - cache_dir = scoring_config['rag_cache_dir'] - if not os.path.isabs(cache_dir): - cache_dir = os.path.join(self._project_dir, cache_dir) - - # Создаем директории если нужно - os.makedirs(os.path.dirname(vectors_path), exist_ok=True) - os.makedirs(cache_dir, exist_ok=True) - - # Создаем VectorStore - vector_store = VectorStore( - vector_dim=768, # ruBERT dimension - max_examples=scoring_config['rag_max_examples'], - storage_path=vectors_path, - score_multiplier=scoring_config['rag_score_multiplier'], - ) - - # Создаем RAGService - rag_service = RAGService( - model_name=scoring_config['rag_model'], - vector_store=vector_store, - cache_dir=cache_dir, - enabled=True, - ) - - logger.info(f"RAGService инициализирован: {scoring_config['rag_model']}") + 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 @@ -189,7 +170,7 @@ class BaseDependencyFactory: # Создаем менеджер self._scoring_manager = ScoringManager( - rag_service=rag_service, + rag_client=rag_client, deepseek_service=deepseek_service, ) diff --git a/requirements.txt b/requirements.txt index 968c3f3..c2a5b89 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,8 +32,5 @@ emoji~=2.8.0 # S3 Storage (для хранения медиафайлов опубликованных постов) aioboto3>=12.0.0 -# ML Scoring (для оценки вероятности публикации постов) -numpy>=1.24.0 -transformers>=4.30.0 -torch>=2.0.0 +# HTTP клиент для RAG API httpx>=0.24.0 \ No newline at end of file diff --git a/scripts/add_ml_scores_columns.py b/scripts/add_ml_scores_columns.py index a7c23ff..78e237e 100644 --- a/scripts/add_ml_scores_columns.py +++ b/scripts/add_ml_scores_columns.py @@ -1,10 +1,9 @@ #!/usr/bin/env python3 """ -Миграция: Добавление колонок для ML-скоринга постов. +Миграция: Добавление колонки для ML-скоринга постов. Добавляет: - ml_scores (TEXT/JSON) - JSON с результатами оценки от разных моделей -- vector_hash (TEXT) - хеш текста для кеширования векторов Структура ml_scores: { @@ -46,7 +45,7 @@ async def main(db_path: str) -> None: """ Основная функция миграции. - Добавляет колонки ml_scores и vector_hash в таблицу post_from_telegram_suggest. + Добавляет колонку ml_scores в таблицу post_from_telegram_suggest. Миграция идемпотентна - можно запускать повторно без ошибок. """ db_path = os.path.abspath(db_path) @@ -67,22 +66,13 @@ async def main(db_path: str) -> None: else: logger.info("Колонка ml_scores уже существует") - # Проверяем и добавляем колонку vector_hash - if not await column_exists(conn, "post_from_telegram_suggest", "vector_hash"): - await conn.execute( - "ALTER TABLE post_from_telegram_suggest ADD COLUMN vector_hash TEXT" - ) - logger.info("Колонка vector_hash добавлена в post_from_telegram_suggest") - else: - logger.info("Колонка vector_hash уже существует") - await conn.commit() logger.info("Миграция add_ml_scores_columns завершена успешно") if __name__ == "__main__": parser = argparse.ArgumentParser( - description="Добавление колонок ml_scores и vector_hash для ML-скоринга" + description="Добавление колонки ml_scores для ML-скоринга" ) parser.add_argument( "--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)) -- 2.49.1 From be8af704ba2286c02dc46f748dd05195c1b72beb Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 26 Jan 2026 22:40:05 +0300 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=B7=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BE=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B0=D0=BD=D0=B8=D1=8F=20PR=20=D0=B2=20Telegram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Реализована возможность получения тела последнего объединенного PR по коммиту в GitHub Actions. - Добавлен шаг для отправки описания PR в важные логи через Telegram. - Обновлены тесты для проверки нового функционала и улучшения логики обработки сообщений. --- .github/workflows/deploy.yml | 50 +++++++++++++++++++ database/repositories/migration_repository.py | 1 - helper_bot/services/scoring/__init__.py | 15 ++---- helper_bot/services/scoring/base.py | 2 +- .../services/scoring/deepseek_service.py | 5 +- helper_bot/services/scoring/rag_client.py | 8 +-- .../services/scoring/scoring_manager.py | 7 +-- helper_bot/utils/base_dependency_factory.py | 7 +-- tests/test_keyboards_and_filters.py | 9 +++- tests/test_scoring_services.py | 43 +++++++++------- 10 files changed, 100 insertions(+), 47 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d23b853..d26d3cd 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -172,6 +172,56 @@ jobs: 🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} continue-on-error: true + + - name: Get PR body from merged PR + if: job.status == 'success' && github.event_name == 'push' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "🔍 Searching for merged PR associated with commit ${{ github.sha }}..." + + # Находим последний мерженный PR для main ветки по merge commit SHA + COMMIT_SHA="${{ github.sha }}" + PR_NUMBER=$(gh pr list --state merged --base main --limit 10 --json number,mergeCommit --jq ".[] | select(.mergeCommit.oid == \"$COMMIT_SHA\") | .number" | head -1) + + # Если не нашли по merge commit, ищем последний мерженный PR + if [ -z "$PR_NUMBER" ]; then + echo "⚠️ PR not found by merge commit, trying to get latest merged PR..." + PR_NUMBER=$(gh pr list --state merged --base main --limit 1 --json number --jq '.[0].number') + fi + + if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then + echo "✅ Found PR #$PR_NUMBER" + PR_BODY=$(gh pr view $PR_NUMBER --json body --jq '.body // ""') + + if [ -n "$PR_BODY" ] && [ "$PR_BODY" != "null" ]; then + echo "PR_BODY<> $GITHUB_ENV + echo "$PR_BODY" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV + echo "✅ PR body extracted successfully" + else + echo "⚠️ PR body is empty" + fi + else + echo "⚠️ No merged PR found for this commit" + fi + continue-on-error: true + + - name: Send PR body to important logs + if: job.status == 'success' && github.event_name == 'push' && env.PR_BODY != '' + uses: appleboy/telegram-action@v1.0.0 + with: + to: ${{ secrets.IMPORTANT_LOGS_CHAT }} + token: ${{ secrets.TELEGRAM_BOT_TOKEN }} + message: | + 📋 Pull Request Description (PR #${{ env.PR_NUMBER }}): + + ${{ env.PR_BODY }} + + 🔗 PR: ${{ github.server_url }}/${{ github.repository }}/pull/${{ env.PR_NUMBER }} + 📝 Commit: ${{ github.sha }} + continue-on-error: true rollback: runs-on: ubuntu-latest diff --git a/database/repositories/migration_repository.py b/database/repositories/migration_repository.py index 5406416..6dcb67d 100644 --- a/database/repositories/migration_repository.py +++ b/database/repositories/migration_repository.py @@ -1,6 +1,5 @@ """Репозиторий для работы с миграциями базы данных.""" import aiosqlite - from database.base import DatabaseConnection diff --git a/helper_bot/services/scoring/__init__.py b/helper_bot/services/scoring/__init__.py index b0eb339..6a1d156 100644 --- a/helper_bot/services/scoring/__init__.py +++ b/helper_bot/services/scoring/__init__.py @@ -7,17 +7,12 @@ - ScoringManager - объединение всех сервисов скоринга """ -from .base import ScoringResult, ScoringServiceProtocol, CombinedScore -from .exceptions import ( - ScoringError, - ModelNotLoadedError, - VectorStoreError, - DeepSeekAPIError, - InsufficientExamplesError, - TextTooShortError, -) -from .rag_client import RagApiClient +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__ = [ diff --git a/helper_bot/services/scoring/base.py b/helper_bot/services/scoring/base.py index 748afa2..0848468 100644 --- a/helper_bot/services/scoring/base.py +++ b/helper_bot/services/scoring/base.py @@ -3,8 +3,8 @@ """ from dataclasses import dataclass, field -from typing import Optional, Protocol, Dict, Any from datetime import datetime +from typing import Any, Dict, Optional, Protocol @dataclass diff --git a/helper_bot/services/scoring/deepseek_service.py b/helper_bot/services/scoring/deepseek_service.py index de45835..3bd9ecf 100644 --- a/helper_bot/services/scoring/deepseek_service.py +++ b/helper_bot/services/scoring/deepseek_service.py @@ -6,12 +6,11 @@ DeepSeek API сервис для скоринга постов. import asyncio import json -from typing import Optional, List +from typing import List, Optional import httpx - +from helper_bot.utils.metrics import track_errors, track_time from logs.custom_logger import logger -from helper_bot.utils.metrics import track_time, track_errors from .base import ScoringResult from .exceptions import DeepSeekAPIError, ScoringError, TextTooShortError diff --git a/helper_bot/services/scoring/rag_client.py b/helper_bot/services/scoring/rag_client.py index fc35a14..18bb279 100644 --- a/helper_bot/services/scoring/rag_client.py +++ b/helper_bot/services/scoring/rag_client.py @@ -4,13 +4,15 @@ HTTP клиент для взаимодействия с внешним RAG се Использует REST API для получения скоров и отправки примеров. """ -from typing import Optional, Dict, Any +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 helper_bot.utils.metrics import track_time, track_errors from .base import ScoringResult -from .exceptions import ScoringError, InsufficientExamplesError, TextTooShortError +from .exceptions import (InsufficientExamplesError, ScoringError, + TextTooShortError) class RagApiClient: diff --git a/helper_bot/services/scoring/scoring_manager.py b/helper_bot/services/scoring/scoring_manager.py index fa23dcb..6c03035 100644 --- a/helper_bot/services/scoring/scoring_manager.py +++ b/helper_bot/services/scoring/scoring_manager.py @@ -8,13 +8,14 @@ import asyncio from typing import Optional +from helper_bot.utils.metrics import track_errors, track_time from logs.custom_logger import logger -from helper_bot.utils.metrics import track_time, track_errors from .base import CombinedScore, ScoringResult -from .rag_client import RagApiClient from .deepseek_service import DeepSeekService -from .exceptions import ScoringError, InsufficientExamplesError, TextTooShortError +from .exceptions import (InsufficientExamplesError, ScoringError, + TextTooShortError) +from .rag_client import RagApiClient class ScoringManager: diff --git a/helper_bot/utils/base_dependency_factory.py b/helper_bot/utils/base_dependency_factory.py index db71fa0..f50c079 100644 --- a/helper_bot/utils/base_dependency_factory.py +++ b/helper_bot/utils/base_dependency_factory.py @@ -130,11 +130,8 @@ class BaseDependencyFactory: Вызывается лениво при первом обращении к get_scoring_manager(). """ - from helper_bot.services.scoring import ( - ScoringManager, - RagApiClient, - DeepSeekService, - ) + from helper_bot.services.scoring import (DeepSeekService, RagApiClient, + ScoringManager) scoring_config = self.settings['Scoring'] diff --git a/tests/test_keyboards_and_filters.py b/tests/test_keyboards_and_filters.py index 539fa8a..f6e25d2 100644 --- a/tests/test_keyboards_and_filters.py +++ b/tests/test_keyboards_and_filters.py @@ -111,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] @@ -124,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_scoring_services.py b/tests/test_scoring_services.py index 048796b..d827390 100644 --- a/tests/test_scoring_services.py +++ b/tests/test_scoring_services.py @@ -3,16 +3,14 @@ """ import json -import pytest from unittest.mock import AsyncMock, MagicMock, patch +import pytest # Импорты для тестирования базовых классов -from helper_bot.services.scoring.base import ScoringResult, CombinedScore -from helper_bot.services.scoring.exceptions import ( - ScoringError, - InsufficientExamplesError, - TextTooShortError, -) +from helper_bot.services.scoring.base import CombinedScore, ScoringResult +from helper_bot.services.scoring.exceptions import (InsufficientExamplesError, + ScoringError, + TextTooShortError) class TestScoringResult: @@ -159,7 +157,7 @@ class TestVectorStore: 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) @@ -179,7 +177,7 @@ class TestVectorStore: 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) @@ -215,7 +213,8 @@ class TestDeepSeekService: @pytest.fixture def deepseek_service(self): """Создает DeepSeekService для тестов.""" - from helper_bot.services.scoring.deepseek_service import DeepSeekService + from helper_bot.services.scoring.deepseek_service import \ + DeepSeekService return DeepSeekService( api_key="test_key", enabled=True, @@ -224,7 +223,8 @@ class TestDeepSeekService: def test_service_disabled_without_key(self): """Тест отключения сервиса без API ключа.""" - from helper_bot.services.scoring.deepseek_service import DeepSeekService + from helper_bot.services.scoring.deepseek_service import \ + DeepSeekService service = DeepSeekService(api_key=None, enabled=True) assert service.is_enabled is False @@ -255,7 +255,8 @@ class TestDeepSeekService: @pytest.mark.asyncio async def test_calculate_score_disabled(self): """Тест расчета скора при отключенном сервисе.""" - from helper_bot.services.scoring.deepseek_service import DeepSeekService + from helper_bot.services.scoring.deepseek_service import \ + DeepSeekService service = DeepSeekService(api_key=None, enabled=False) with pytest.raises(ScoringError): @@ -281,6 +282,8 @@ class TestScoringManager: source="rag", model="rubert", )) + mock.add_positive_example = AsyncMock() + mock.add_negative_example = AsyncMock() return mock @pytest.fixture @@ -293,6 +296,8 @@ class TestScoringManager: source="deepseek", model="deepseek-chat", )) + mock.add_positive_example = AsyncMock() + mock.add_negative_example = AsyncMock() return mock @pytest.mark.asyncio @@ -301,7 +306,7 @@ class TestScoringManager: from helper_bot.services.scoring.scoring_manager import ScoringManager manager = ScoringManager( - rag_service=mock_rag_service, + rag_client=mock_rag_service, deepseek_service=mock_deepseek_service, ) @@ -317,7 +322,7 @@ class TestScoringManager: from helper_bot.services.scoring.scoring_manager import ScoringManager manager = ScoringManager( - rag_service=mock_rag_service, + rag_client=mock_rag_service, deepseek_service=None, ) @@ -331,7 +336,7 @@ class TestScoringManager: """Тест скоринга пустого текста.""" from helper_bot.services.scoring.scoring_manager import ScoringManager - manager = ScoringManager(rag_service=mock_rag_service) + manager = ScoringManager(rag_client=mock_rag_service) result = await manager.score_post("") @@ -342,12 +347,12 @@ class TestScoringManager: 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_service=mock_rag_service, + rag_client=mock_rag_service, deepseek_service=mock_deepseek_service, ) @@ -365,7 +370,7 @@ class TestScoringManager: from helper_bot.services.scoring.scoring_manager import ScoringManager manager = ScoringManager( - rag_service=mock_rag_service, + rag_client=mock_rag_service, deepseek_service=mock_deepseek_service, ) @@ -380,7 +385,7 @@ class TestScoringManager: from helper_bot.services.scoring.scoring_manager import ScoringManager manager = ScoringManager( - rag_service=mock_rag_service, + rag_client=mock_rag_service, deepseek_service=mock_deepseek_service, ) -- 2.49.1 From 5d7b0515545dca19937c42a334a8b2097c84c0e2 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 27 Jan 2026 22:10:04 +0300 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=D1=83=D0=BB=D1=83=D1=87=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=D1=81=D1=82=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0=D0=B3=D1=80=D1=83=D0=BF=D0=BF=20?= =?UTF-8?q?=D1=81=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=D0=BC=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D0=B0=20"de?= =?UTF-8?q?clined"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Реализовано обновление статуса постов на "declined" для одиночных сообщений и медиагрупп. - Оптимизирована фоновая обработка постов, включая получение и обработку ML-скоров. - Обновлены обработчики для немедленного ответа пользователю при отправке постов. - Добавлены логирование ошибок для улучшения отладки. --- helper_bot/handlers/callback/services.py | 14 + .../handlers/private/private_handlers.py | 62 ++--- helper_bot/handlers/private/services.py | 254 ++++++++++++++++-- 3 files changed, 274 insertions(+), 56 deletions(-) diff --git a/helper_bot/handlers/callback/services.py b/helper_bot/handlers/callback/services.py index 4620e7f..51d0337 100644 --- a/helper_bot/handlers/callback/services.py +++ b/helper_bot/handlers/callback/services.py @@ -617,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/private/private_handlers.py b/helper_bot/handlers/private/private_handlers.py index af01457..f34af93 100644 --- a/helper_bot/handlers/private/private_handlers.py +++ b/helper_bot/handlers/private/private_handlers.py @@ -147,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") diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py index 3fc4582..5943d34 100644 --- a/helper_bot/handlers/private/services.py +++ b/helper_bot/handlers/private/services.py @@ -74,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 @@ -85,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="", @@ -104,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( @@ -177,6 +179,223 @@ class PostService: 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") @@ -479,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: -- 2.49.1 From 7d173e3474a0186399541bdd557bfb121bdcc8d1 Mon Sep 17 00:00:00 2001 From: Andrey Date: Wed, 28 Jan 2026 00:23:37 +0300 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20=D1=83=D0=BB=D1=83=D1=87=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8=20RAG=20API=20=D0=B2=20=D0=B0=D0=B4=D0=BC=D0=B8?= =?UTF-8?q?=D0=BD-=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлена проверка наличия данных из API для отображения статуса модели и статистики векторного хранилища. - Реализован fallback на синхронные данные, если API недоступен. - Обновлено описание метода получения статистики в RagApiClient для уточнения использования endpoint /stats. --- helper_bot/handlers/admin/admin_handlers.py | 45 +++++++++++++++------ helper_bot/services/scoring/rag_client.py | 2 +- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/helper_bot/handlers/admin/admin_handlers.py b/helper_bot/handlers/admin/admin_handlers.py index 89159a5..add7321 100644 --- a/helper_bot/handlers/admin/admin_handlers.py +++ b/helper_bot/handlers/admin/admin_handlers.py @@ -170,20 +170,41 @@ async def get_ml_stats( if "rag" in stats: rag = stats["rag"] lines.append("🤖 RAG API:") - lines.append(f" • Статус: {'✅ Включен' if rag.get('enabled') else '❌ Отключен'}") - lines.append(f" • API URL: {rag.get('api_url', 'N/A')}") - # Статистика из API (если доступна) - if "positive_examples" in rag or "negative_examples" in rag: - lines.append(f" • Положительных примеров: {rag.get('positive_examples', 0)}") - lines.append(f" • Отрицательных примеров: {rag.get('negative_examples', 0)}") - lines.append(f" • Всего примеров: {rag.get('total_examples', rag.get('positive_examples', 0) + rag.get('negative_examples', 0))}") + # Проверяем, есть ли данные из API статуса (по наличию model_loaded или vector_store) + has_api_data = "model_loaded" in rag or "vector_store" in rag - # Модель из API (если доступна) - if "model_loaded" in rag: - lines.append(f" • Модель загружена: {'✅' if rag.get('model_loaded') else '❌'}") - if "model_name" in rag: - lines.append(f" • Модель: {rag.get('model_name', 'N/A')}") + if has_api_data: + # Данные из API статуса + # Модель из API + 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", positive_count + negative_count) + + 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("") diff --git a/helper_bot/services/scoring/rag_client.py b/helper_bot/services/scoring/rag_client.py index 18bb279..689e5e6 100644 --- a/helper_bot/services/scoring/rag_client.py +++ b/helper_bot/services/scoring/rag_client.py @@ -273,7 +273,7 @@ class RagApiClient: async def get_stats(self) -> Dict[str, Any]: """ - Получает статистику от RAG API. + Получает статистику от RAG API через endpoint /stats. Returns: Словарь со статистикой или пустой словарь при ошибке -- 2.49.1 From a949f7e7db4d63b7c7ada89539027054810987ae Mon Sep 17 00:00:00 2001 From: Andrey Date: Wed, 28 Jan 2026 00:29:09 +0300 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BC?= =?UTF-8?q?=D0=B5=D1=89=D0=B5=D0=BD=D1=8B=20CI/CD=20=D0=BF=D0=B0=D0=B9?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D0=B9=D0=BD=D1=8B=20=D0=B2=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=82=D0=BA=D1=83=20dev-13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 93 --------- .github/workflows/deploy.yml | 356 ----------------------------------- 2 files changed, 449 deletions(-) delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 783144c..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: CI pipeline - -on: - push: - branches: [ 'dev-*', 'feature-*' ] - pull_request: - branches: [ 'dev-*', 'feature-*', 'main' ] - workflow_dispatch: - -jobs: - test: - runs-on: ubuntu-latest - name: Test & Code Quality - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python 3.11 - uses: actions/setup-python@v5 - with: - python-version: '3.11' - cache: 'pip' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -r requirements-dev.txt - - - name: Code formatting check (Black) - run: | - echo "🔍 Checking code formatting with Black..." - black --check . || (echo "❌ Code formatting issues found. Run 'black .' to fix." && exit 1) - - - name: Import sorting check (isort) - run: | - echo "🔍 Checking import sorting with isort..." - isort --check-only . || (echo "❌ Import sorting issues found. Run 'isort .' to fix." && exit 1) - - - name: Linting (flake8) - Critical errors - run: | - echo "🔍 Running flake8 linter (critical errors only)..." - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics || true - - - name: Linting (flake8) - Warnings - run: | - echo "🔍 Running flake8 linter (warnings)..." - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics || true - continue-on-error: true - - - name: Run tests - run: | - echo "🧪 Running tests..." - python -m pytest tests/ -v --tb=short - - - name: Send test success notification - if: success() - uses: appleboy/telegram-action@v1.0.0 - with: - to: ${{ secrets.TELEGRAM_CHAT_ID }} - token: ${{ secrets.TELEGRAM_BOT_TOKEN }} - message: | - ✅ CI Tests Passed - - 📦 Repository: telegram-helper-bot - 🌿 Branch: ${{ github.ref_name }} - 📝 Commit: ${{ github.sha }} - 👤 Author: ${{ github.actor }} - - ✅ All tests passed! Code quality checks completed successfully. - - 🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - continue-on-error: true - - - name: Send test failure notification - if: failure() - uses: appleboy/telegram-action@v1.0.0 - with: - to: ${{ secrets.TELEGRAM_CHAT_ID }} - token: ${{ secrets.TELEGRAM_BOT_TOKEN }} - message: | - ❌ CI Tests Failed - - 📦 Repository: telegram-helper-bot - 🌿 Branch: ${{ github.ref_name }} - 📝 Commit: ${{ github.sha }} - 👤 Author: ${{ github.actor }} - - ❌ Tests failed! Deployment blocked. Please fix the issues and try again. - - 🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - continue-on-error: true diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index d26d3cd..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,356 +0,0 @@ -name: Deploy to Production - -on: - push: - branches: [ main ] - workflow_dispatch: - inputs: - action: - description: 'Action to perform' - required: true - type: choice - options: - - deploy - - rollback - rollback_commit: - description: 'Commit hash to rollback to (optional, uses last successful if empty)' - required: false - type: string - -jobs: - deploy: - runs-on: ubuntu-latest - name: Deploy to Production - if: | - github.event_name == 'push' || - (github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'deploy') - concurrency: - group: production-deploy-telegram-helper-bot - cancel-in-progress: false - environment: - name: production - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: main - - - name: Deploy to server - uses: appleboy/ssh-action@v1.0.0 - with: - host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }} - username: ${{ vars.SERVER_USER || secrets.SERVER_USER }} - key: ${{ secrets.SSH_PRIVATE_KEY }} - port: ${{ vars.SSH_PORT || secrets.SSH_PORT || 22 }} - script: | - set -e - export TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}" - export TELEGRAM_TEST_BOT_TOKEN="${{ secrets.TELEGRAM_TEST_BOT_TOKEN }}" - - echo "🚀 Starting deployment to production..." - - cd /home/prod - - # Сохраняем информацию о коммите - CURRENT_COMMIT=$(git rev-parse HEAD) - COMMIT_MESSAGE=$(git log -1 --pretty=format:"%s" || echo "Unknown") - COMMIT_AUTHOR=$(git log -1 --pretty=format:"%an" || echo "Unknown") - TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S") - - echo "📝 Current commit: $CURRENT_COMMIT" - echo "📝 Commit message: $COMMIT_MESSAGE" - echo "📝 Author: $COMMIT_AUTHOR" - - # Записываем в историю деплоев - HISTORY_FILE="/home/prod/.deploy_history_telegram_helper_bot.txt" - HISTORY_SIZE="${DEPLOY_HISTORY_SIZE:-10}" - echo "${TIMESTAMP}|${CURRENT_COMMIT}|${COMMIT_MESSAGE}|${COMMIT_AUTHOR}|deploying" >> "$HISTORY_FILE" - tail -n "$HISTORY_SIZE" "$HISTORY_FILE" > "${HISTORY_FILE}.tmp" && mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE" - - # Обновляем код - echo "📥 Pulling latest changes from main..." - sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true - cd /home/prod/bots/telegram-helper-bot - git fetch origin main - git reset --hard origin/main - sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true - - NEW_COMMIT=$(git rev-parse HEAD) - echo "✅ Code updated: $CURRENT_COMMIT → $NEW_COMMIT" - - # Применяем миграции БД перед перезапуском контейнера - echo "🔄 Applying database migrations..." - DB_PATH="/home/prod/bots/telegram-helper-bot/database/tg-bot-database.db" - - if [ -f "$DB_PATH" ]; then - cd /home/prod/bots/telegram-helper-bot - python3 scripts/apply_migrations.py --db "$DB_PATH" || { - echo "❌ Ошибка при применении миграций!" - exit 1 - } - echo "✅ Миграции применены успешно" - else - echo "⚠️ База данных не найдена, пропускаем миграции (будет создана при первом запуске)" - fi - - # Валидация docker-compose - echo "🔍 Validating docker-compose configuration..." - cd /home/prod - docker-compose config > /dev/null || exit 1 - echo "✅ docker-compose.yml is valid" - - # Проверка дискового пространства - MIN_FREE_GB=5 - AVAILABLE_SPACE=$(df -BG /home/prod 2>/dev/null | tail -1 | awk '{print $4}' | sed 's/G//' || echo "0") - echo "💾 Available disk space: ${AVAILABLE_SPACE}GB" - - if [ "$AVAILABLE_SPACE" -lt "$MIN_FREE_GB" ]; then - echo "⚠️ Insufficient disk space! Cleaning up Docker resources..." - docker system prune -f --volumes || true - fi - - # Пересобираем и перезапускаем контейнер бота - echo "🔨 Rebuilding and restarting telegram-bot container..." - cd /home/prod - - export TELEGRAM_BOT_TOKEN TELEGRAM_TEST_BOT_TOKEN - docker-compose stop telegram-bot || true - docker-compose build --pull telegram-bot - docker-compose up -d telegram-bot - - echo "✅ Telegram bot container rebuilt and started" - - # Ждем немного и проверяем healthcheck - echo "⏳ Waiting for container to start..." - sleep 10 - - if docker ps | grep -q bots_telegram_bot; then - echo "✅ Container is running" - else - echo "❌ Container failed to start!" - docker logs bots_telegram_bot --tail 50 || true - exit 1 - fi - - - name: Update deploy history - if: always() - uses: appleboy/ssh-action@v1.0.0 - with: - host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }} - username: ${{ vars.SERVER_USER || secrets.SERVER_USER }} - key: ${{ secrets.SSH_PRIVATE_KEY }} - port: ${{ vars.SSH_PORT || secrets.SSH_PORT || 22 }} - script: | - HISTORY_FILE="/home/prod/.deploy_history_telegram_helper_bot.txt" - - if [ -f "$HISTORY_FILE" ]; then - DEPLOY_STATUS="failed" - if [ "${{ job.status }}" = "success" ]; then - DEPLOY_STATUS="success" - fi - - sed -i '$s/|deploying$/|'"$DEPLOY_STATUS"'/' "$HISTORY_FILE" - echo "✅ Deploy history updated: $DEPLOY_STATUS" - fi - - - name: Send deployment notification - if: always() - uses: appleboy/telegram-action@v1.0.0 - with: - to: ${{ secrets.TELEGRAM_CHAT_ID }} - token: ${{ secrets.TELEGRAM_BOT_TOKEN }} - message: | - ${{ job.status == 'success' && '✅' || '❌' }} Deployment: ${{ job.status }} - - 📦 Repository: telegram-helper-bot - 🌿 Branch: main - 📝 Commit: ${{ github.sha }} - 👤 Author: ${{ github.actor }} - - ${{ job.status == 'success' && '✅ Deployment successful! Container restarted with migrations applied.' || '❌ Deployment failed! Check logs for details.' }} - - 🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - continue-on-error: true - - - name: Get PR body from merged PR - if: job.status == 'success' && github.event_name == 'push' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - echo "🔍 Searching for merged PR associated with commit ${{ github.sha }}..." - - # Находим последний мерженный PR для main ветки по merge commit SHA - COMMIT_SHA="${{ github.sha }}" - PR_NUMBER=$(gh pr list --state merged --base main --limit 10 --json number,mergeCommit --jq ".[] | select(.mergeCommit.oid == \"$COMMIT_SHA\") | .number" | head -1) - - # Если не нашли по merge commit, ищем последний мерженный PR - if [ -z "$PR_NUMBER" ]; then - echo "⚠️ PR not found by merge commit, trying to get latest merged PR..." - PR_NUMBER=$(gh pr list --state merged --base main --limit 1 --json number --jq '.[0].number') - fi - - if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then - echo "✅ Found PR #$PR_NUMBER" - PR_BODY=$(gh pr view $PR_NUMBER --json body --jq '.body // ""') - - if [ -n "$PR_BODY" ] && [ "$PR_BODY" != "null" ]; then - echo "PR_BODY<> $GITHUB_ENV - echo "$PR_BODY" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV - echo "✅ PR body extracted successfully" - else - echo "⚠️ PR body is empty" - fi - else - echo "⚠️ No merged PR found for this commit" - fi - continue-on-error: true - - - name: Send PR body to important logs - if: job.status == 'success' && github.event_name == 'push' && env.PR_BODY != '' - uses: appleboy/telegram-action@v1.0.0 - with: - to: ${{ secrets.IMPORTANT_LOGS_CHAT }} - token: ${{ secrets.TELEGRAM_BOT_TOKEN }} - message: | - 📋 Pull Request Description (PR #${{ env.PR_NUMBER }}): - - ${{ env.PR_BODY }} - - 🔗 PR: ${{ github.server_url }}/${{ github.repository }}/pull/${{ env.PR_NUMBER }} - 📝 Commit: ${{ github.sha }} - continue-on-error: true - - rollback: - runs-on: ubuntu-latest - name: Rollback to Previous Version - if: | - github.event_name == 'workflow_dispatch' && - github.event.inputs.action == 'rollback' - environment: - name: production - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: main - - - name: Rollback on server - uses: appleboy/ssh-action@v1.0.0 - with: - host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }} - username: ${{ vars.SERVER_USER || secrets.SERVER_USER }} - key: ${{ secrets.SSH_PRIVATE_KEY }} - port: ${{ vars.SSH_PORT || secrets.SSH_PORT || 22 }} - script: | - set -e - export TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}" - export TELEGRAM_TEST_BOT_TOKEN="${{ secrets.TELEGRAM_TEST_BOT_TOKEN }}" - - echo "🔄 Starting rollback..." - - cd /home/prod - - # Определяем коммит для отката - ROLLBACK_COMMIT="${{ github.event.inputs.rollback_commit }}" - HISTORY_FILE="/home/prod/.deploy_history_telegram_helper_bot.txt" - - if [ -z "$ROLLBACK_COMMIT" ]; then - echo "📝 No commit specified, finding last successful deploy..." - if [ -f "$HISTORY_FILE" ]; then - ROLLBACK_COMMIT=$(grep "|success$" "$HISTORY_FILE" | tail -1 | cut -d'|' -f2 || echo "") - fi - - if [ -z "$ROLLBACK_COMMIT" ]; then - echo "❌ No successful deploy found in history!" - echo "💡 Please specify commit hash manually or check deploy history" - exit 1 - fi - fi - - echo "📝 Rolling back to commit: $ROLLBACK_COMMIT" - - # Проверяем, что коммит существует - cd /home/prod/bots/telegram-helper-bot - if ! git cat-file -e "$ROLLBACK_COMMIT" 2>/dev/null; then - echo "❌ Commit $ROLLBACK_COMMIT not found!" - exit 1 - fi - - # Сохраняем текущий коммит - CURRENT_COMMIT=$(git rev-parse HEAD) - COMMIT_MESSAGE=$(git log -1 --pretty=format:"%s" "$ROLLBACK_COMMIT" || echo "Rollback") - TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S") - - echo "📝 Current commit: $CURRENT_COMMIT" - echo "📝 Target commit: $ROLLBACK_COMMIT" - echo "📝 Commit message: $COMMIT_MESSAGE" - - # Исправляем права перед откатом - sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true - - # Откатываем код - echo "🔄 Rolling back code..." - git fetch origin main - git reset --hard "$ROLLBACK_COMMIT" - - # Исправляем права после отката - sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true - - echo "✅ Code rolled back: $CURRENT_COMMIT → $ROLLBACK_COMMIT" - - # Валидация docker-compose - echo "🔍 Validating docker-compose configuration..." - cd /home/prod - docker-compose config > /dev/null || exit 1 - echo "✅ docker-compose.yml is valid" - - # Проверка дискового пространства - MIN_FREE_GB=5 - AVAILABLE_SPACE=$(df -BG /home/prod 2>/dev/null | tail -1 | awk '{print $4}' | sed 's/G//' || echo "0") - echo "💾 Available disk space: ${AVAILABLE_SPACE}GB" - - if [ "$AVAILABLE_SPACE" -lt "$MIN_FREE_GB" ]; then - echo "⚠️ Insufficient disk space! Cleaning up Docker resources..." - docker system prune -f --volumes || true - fi - - # Пересобираем и перезапускаем контейнер - echo "🔨 Rebuilding and restarting telegram-bot container..." - cd /home/prod - - export TELEGRAM_BOT_TOKEN TELEGRAM_TEST_BOT_TOKEN - docker-compose stop telegram-bot || true - docker-compose build --pull telegram-bot - docker-compose up -d telegram-bot - - echo "✅ Telegram bot container rebuilt and started" - - # Записываем в историю - echo "${TIMESTAMP}|${ROLLBACK_COMMIT}|Rollback to: ${COMMIT_MESSAGE}|github-actions|rolled_back" >> "$HISTORY_FILE" - HISTORY_SIZE="${DEPLOY_HISTORY_SIZE:-10}" - tail -n "$HISTORY_SIZE" "$HISTORY_FILE" > "${HISTORY_FILE}.tmp" && mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE" - - echo "✅ Rollback completed successfully" - - - name: Send rollback notification - if: always() - uses: appleboy/telegram-action@v1.0.0 - with: - to: ${{ secrets.TELEGRAM_CHAT_ID }} - token: ${{ secrets.TELEGRAM_BOT_TOKEN }} - message: | - ${{ job.status == 'success' && '🔄' || '❌' }} Rollback: ${{ job.status }} - - 📦 Repository: telegram-helper-bot - 🌿 Branch: main - 📝 Rolled back to: ${{ github.event.inputs.rollback_commit || 'Last successful commit' }} - 👤 Triggered by: ${{ github.actor }} - - ${{ job.status == 'success' && '✅ Rollback completed successfully! Services restored to previous version.' || '❌ Rollback failed! Check logs for details.' }} - - 🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - continue-on-error: true -- 2.49.1 From 35767c289c6bdb5cee456bd7e4781cbd7c4d84ec Mon Sep 17 00:00:00 2001 From: Andrey Date: Wed, 28 Jan 2026 01:02:21 +0300 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B8=D0=B7=20RAG=20?= =?UTF-8?q?API=20=D0=B2=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Упрощена логика проверки наличия данных из API, убраны лишние переменные. - Обновлен расчет общего количества примеров для корректного отображения статистики. --- helper_bot/handlers/admin/admin_handlers.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/helper_bot/handlers/admin/admin_handlers.py b/helper_bot/handlers/admin/admin_handlers.py index add7321..a0aeb03 100644 --- a/helper_bot/handlers/admin/admin_handlers.py +++ b/helper_bot/handlers/admin/admin_handlers.py @@ -171,12 +171,9 @@ async def get_ml_stats( rag = stats["rag"] lines.append("🤖 RAG API:") - # Проверяем, есть ли данные из API статуса (по наличию model_loaded или vector_store) - has_api_data = "model_loaded" in rag or "vector_store" in rag - - if has_api_data: - # Данные из API статуса - # Модель из 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 '❌'}") @@ -190,7 +187,7 @@ async def get_ml_stats( 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", positive_count + negative_count) + total_count = vector_store.get("total_count", 0) lines.append(f" • Положительных примеров: {positive_count}") lines.append(f" • Отрицательных примеров: {negative_count}") -- 2.49.1