Merge pull request #14 from KerradKerridi/dev-12
Release Notes: dev-12
This commit was merged in pull request #14.
This commit is contained in:
@@ -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 # зарегистрировать все существующие
|
||||
```
|
||||
|
||||
@@ -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` автоматически отслеживает ошибки:
|
||||
|
||||
8
.cursor/rules/middleware-patterns.mdc
Normal file
8
.cursor/rules/middleware-patterns.mdc
Normal file
@@ -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.
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -92,4 +92,7 @@ venv.bak/
|
||||
|
||||
# Other files
|
||||
voice_users/
|
||||
files/
|
||||
files/
|
||||
|
||||
# ML models and vectors cache
|
||||
data/
|
||||
20
Dockerfile
20
Dockerfile
@@ -3,13 +3,12 @@
|
||||
###########################################
|
||||
FROM python:3.11.9-alpine as builder
|
||||
|
||||
# Устанавливаем инструменты для компиляции + linux-headers для psutil
|
||||
# Устанавливаем инструменты для компиляции (если нужны для некоторых пакетов)
|
||||
RUN apk add --no-cache \
|
||||
gcc \
|
||||
g++ \
|
||||
musl-dev \
|
||||
python3-dev \
|
||||
linux-headers # ← ЭТО КРИТИЧЕСКИ ВАЖНО ДЛЯ psutil
|
||||
libffi-dev \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
@@ -23,27 +22,22 @@ RUN pip install --no-cache-dir --target /install -r requirements.txt
|
||||
###########################################
|
||||
FROM python:3.11.9-alpine as runtime
|
||||
|
||||
# Минимальные рантайм-зависимости
|
||||
RUN apk add --no-cache \
|
||||
libstdc++ \
|
||||
sqlite-libs
|
||||
|
||||
# Создаем пользователя
|
||||
RUN addgroup -g 1001 deploy && adduser -D -u 1001 -G deploy deploy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем зависимости
|
||||
COPY --from=builder --chown=1001:1001 /install /usr/local/lib/python3.11/site-packages
|
||||
COPY --from=builder --chown=deploy:deploy /install /usr/local/lib/python3.11/site-packages
|
||||
|
||||
# Создаем структуру папок
|
||||
RUN mkdir -p database logs voice_users && \
|
||||
chown -R 1001:1001 /app
|
||||
chown -R deploy:deploy /app
|
||||
|
||||
# Копируем исходный код
|
||||
COPY --chown=1001:1001 . .
|
||||
COPY --chown=deploy:deploy . .
|
||||
|
||||
USER 1001
|
||||
USER deploy
|
||||
|
||||
# Healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=15s --start-period=10s --retries=5 \
|
||||
|
||||
@@ -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__ = [
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import aiosqlite
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import aiosqlite
|
||||
from database.models import (Admin, AudioMessage, BlacklistHistoryRecord,
|
||||
BlacklistUser, PostContent, TelegramPost, User,
|
||||
UserMessage)
|
||||
from database.repository_factory import RepositoryFactory
|
||||
from database.models import (
|
||||
User, BlacklistUser, BlacklistHistoryRecord, UserMessage, TelegramPost, PostContent,
|
||||
Admin, AudioMessage
|
||||
)
|
||||
|
||||
|
||||
class AsyncBotDB:
|
||||
@@ -210,6 +210,23 @@ class AsyncBotDB:
|
||||
return await self.factory.posts.update_status_for_media_group_by_helper_id(
|
||||
helper_message_id, status
|
||||
)
|
||||
|
||||
# Методы для ML Scoring
|
||||
async def get_post_text_by_message_id(self, message_id: int) -> Optional[str]:
|
||||
"""Получает текст поста по message_id."""
|
||||
return await self.factory.posts.get_post_text_by_message_id(message_id)
|
||||
|
||||
async def update_ml_scores(self, message_id: int, ml_scores_json: str) -> bool:
|
||||
"""Обновляет ML-скоры для поста."""
|
||||
return await self.factory.posts.update_ml_scores(message_id, ml_scores_json)
|
||||
|
||||
async def get_approved_posts_texts(self, limit: int = 1000) -> List[str]:
|
||||
"""Получает тексты одобренных постов для обучения RAG."""
|
||||
return await self.factory.posts.get_approved_posts_texts(limit)
|
||||
|
||||
async def get_declined_posts_texts(self, limit: int = 1000) -> List[str]:
|
||||
"""Получает тексты отклоненных постов для обучения RAG."""
|
||||
return await self.factory.posts.get_declined_posts_texts(limit)
|
||||
|
||||
# Методы для работы с черным списком
|
||||
async def set_user_blacklist(
|
||||
@@ -403,25 +420,9 @@ class AsyncBotDB:
|
||||
await self.factory.audio.delete_audio_record_by_file_name(file_name)
|
||||
|
||||
# Методы для миграций
|
||||
async def get_migration_version(self) -> int:
|
||||
"""Получение текущей версии миграции."""
|
||||
return await self.factory.migrations.get_migration_version()
|
||||
|
||||
async def get_current_version(self) -> Optional[int]:
|
||||
"""Возвращает текущую последнюю версию миграции."""
|
||||
return await self.factory.migrations.get_current_version()
|
||||
|
||||
async def update_version(self, new_version: int, script_name: str):
|
||||
"""Обновляет версию миграций в таблице migrations."""
|
||||
await self.factory.migrations.update_version(new_version, script_name)
|
||||
|
||||
async def create_table(self, sql_script: str):
|
||||
"""Создает таблицу в базе. Используется в миграциях."""
|
||||
await self.factory.migrations.create_table(sql_script)
|
||||
|
||||
async def update_migration_version(self, version: int, script_name: str):
|
||||
"""Обновление версии миграции."""
|
||||
await self.factory.migrations.update_version(version, script_name)
|
||||
await self.factory.migrations.create_table_from_sql(sql_script)
|
||||
|
||||
# Методы для voice bot welcome tracking
|
||||
async def check_voice_bot_welcome_received(self, user_id: int) -> bool:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import aiosqlite
|
||||
from typing import Optional
|
||||
|
||||
import aiosqlite
|
||||
from logs.custom_logger import logger
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Optional
|
||||
|
||||
from database.base import DatabaseConnection
|
||||
from database.models import Admin
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Optional
|
||||
|
||||
from database.base import DatabaseConnection
|
||||
from database.models import BlacklistHistoryRecord
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from database.base import DatabaseConnection
|
||||
from database.models import UserMessage
|
||||
|
||||
|
||||
78
database/repositories/migration_repository.py
Normal file
78
database/repositories/migration_repository.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Репозиторий для работы с миграциями базы данных."""
|
||||
import aiosqlite
|
||||
from database.base import DatabaseConnection
|
||||
|
||||
|
||||
class MigrationRepository(DatabaseConnection):
|
||||
"""Репозиторий для управления миграциями базы данных."""
|
||||
|
||||
async def create_table(self):
|
||||
"""Создает таблицу migrations, если она не существует."""
|
||||
query = """
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
script_name TEXT NOT NULL UNIQUE,
|
||||
applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
)
|
||||
"""
|
||||
await self._execute_query(query)
|
||||
self.logger.info("Таблица migrations создана или уже существует")
|
||||
|
||||
async def get_applied_migrations(self) -> list[str]:
|
||||
"""Возвращает список имен примененных скриптов миграций."""
|
||||
conn = None
|
||||
try:
|
||||
conn = await self._get_connection()
|
||||
cursor = await conn.execute("SELECT script_name FROM migrations ORDER BY applied_at")
|
||||
rows = await cursor.fetchall()
|
||||
await cursor.close()
|
||||
return [row[0] for row in rows]
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка при получении списка миграций: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
|
||||
async def is_migration_applied(self, script_name: str) -> bool:
|
||||
"""Проверяет, применена ли миграция."""
|
||||
conn = None
|
||||
try:
|
||||
conn = await self._get_connection()
|
||||
cursor = await conn.execute(
|
||||
"SELECT COUNT(*) FROM migrations WHERE script_name = ?",
|
||||
(script_name,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
await cursor.close()
|
||||
return row[0] > 0 if row else False
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка при проверке миграции {script_name}: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
|
||||
async def mark_migration_applied(self, script_name: str) -> None:
|
||||
"""Отмечает миграцию как примененную."""
|
||||
conn = None
|
||||
try:
|
||||
conn = await self._get_connection()
|
||||
await conn.execute(
|
||||
"INSERT INTO migrations (script_name) VALUES (?)",
|
||||
(script_name,)
|
||||
)
|
||||
await conn.commit()
|
||||
self.logger.info(f"Миграция {script_name} отмечена как примененная")
|
||||
except aiosqlite.IntegrityError:
|
||||
self.logger.warning(f"Миграция {script_name} уже была применена ранее")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка при отметке миграции {script_name}: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
|
||||
async def create_table_from_sql(self, sql_script: str) -> None:
|
||||
"""Создает таблицу из SQL скрипта. Используется в миграциях."""
|
||||
await self._execute_query(sql_script)
|
||||
@@ -1,7 +1,8 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Tuple
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from database.base import DatabaseConnection
|
||||
from database.models import TelegramPost, PostContent, MessageContentLink
|
||||
from database.models import MessageContentLink, PostContent, TelegramPost
|
||||
|
||||
|
||||
class PostRepository(DatabaseConnection):
|
||||
@@ -356,3 +357,108 @@ class PostRepository(DatabaseConnection):
|
||||
post_content = await self._execute_query_with_result(query, (published_message_id,))
|
||||
self.logger.info(f"Получен контент опубликованного поста: {len(post_content)} элементов для published_message_id={published_message_id}")
|
||||
return post_content
|
||||
|
||||
# ============================================
|
||||
# Методы для работы с ML-скорингом
|
||||
# ============================================
|
||||
|
||||
async def update_ml_scores(self, message_id: int, ml_scores_json: str) -> bool:
|
||||
"""
|
||||
Обновляет ML-скоры для поста.
|
||||
|
||||
Args:
|
||||
message_id: ID сообщения в группе модерации
|
||||
ml_scores_json: JSON строка со скорами
|
||||
|
||||
Returns:
|
||||
True если обновлено успешно
|
||||
"""
|
||||
try:
|
||||
query = "UPDATE post_from_telegram_suggest SET ml_scores = ? WHERE message_id = ?"
|
||||
await self._execute_query(query, (ml_scores_json, message_id))
|
||||
self.logger.info(f"ML-скоры обновлены для message_id={message_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка обновления ML-скоров для message_id={message_id}: {e}")
|
||||
return False
|
||||
|
||||
async def get_ml_scores_by_message_id(self, message_id: int) -> Optional[str]:
|
||||
"""
|
||||
Получает ML-скоры для поста.
|
||||
|
||||
Args:
|
||||
message_id: ID сообщения
|
||||
|
||||
Returns:
|
||||
JSON строка со скорами или None
|
||||
"""
|
||||
query = "SELECT ml_scores FROM post_from_telegram_suggest WHERE message_id = ?"
|
||||
rows = await self._execute_query_with_result(query, (message_id,))
|
||||
if rows and rows[0][0]:
|
||||
return rows[0][0]
|
||||
return None
|
||||
|
||||
async def get_post_text_by_message_id(self, message_id: int) -> Optional[str]:
|
||||
"""
|
||||
Получает текст поста по message_id.
|
||||
|
||||
Args:
|
||||
message_id: ID сообщения
|
||||
|
||||
Returns:
|
||||
Текст поста или None
|
||||
"""
|
||||
query = "SELECT text FROM post_from_telegram_suggest WHERE message_id = ?"
|
||||
rows = await self._execute_query_with_result(query, (message_id,))
|
||||
if rows and rows[0][0]:
|
||||
return rows[0][0]
|
||||
return None
|
||||
|
||||
async def get_approved_posts_texts(self, limit: int = 1000) -> List[str]:
|
||||
"""
|
||||
Получает тексты опубликованных постов для обучения RAG.
|
||||
|
||||
Args:
|
||||
limit: Максимальное количество постов
|
||||
|
||||
Returns:
|
||||
Список текстов
|
||||
"""
|
||||
query = """
|
||||
SELECT text FROM post_from_telegram_suggest
|
||||
WHERE status = 'approved'
|
||||
AND text IS NOT NULL
|
||||
AND text != ''
|
||||
AND text != '^'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
rows = await self._execute_query_with_result(query, (limit,))
|
||||
texts = [row[0] for row in rows if row[0]]
|
||||
self.logger.info(f"Получено {len(texts)} опубликованных постов для обучения")
|
||||
return texts
|
||||
|
||||
async def get_declined_posts_texts(self, limit: int = 1000) -> List[str]:
|
||||
"""
|
||||
Получает тексты отклоненных постов для обучения RAG.
|
||||
|
||||
Args:
|
||||
limit: Максимальное количество постов
|
||||
|
||||
Returns:
|
||||
Список текстов
|
||||
"""
|
||||
query = """
|
||||
SELECT text FROM post_from_telegram_suggest
|
||||
WHERE status = 'declined'
|
||||
AND text IS NOT NULL
|
||||
AND text != ''
|
||||
AND text != '^'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
rows = await self._execute_query_with_result(query, (limit,))
|
||||
texts = [row[0] for row in rows if row[0]]
|
||||
self.logger.info(f"Получено {len(texts)} отклоненных постов для обучения")
|
||||
return texts
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
16
env.example
16
env.example
@@ -35,3 +35,19 @@ METRICS_PORT=8080
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
LOG_RETENTION_DAYS=30
|
||||
|
||||
# ML Scoring - RAG API
|
||||
# Включает оценку постов через внешний RAG API сервис
|
||||
RAG_ENABLED=false
|
||||
RAG_API_URL=http://xx.xxx.xx.xx/api/v1
|
||||
RAG_API_KEY=your_rag_api_key_here
|
||||
RAG_API_TIMEOUT=30
|
||||
RAG_TEST_MODE=false
|
||||
|
||||
# ML Scoring - DeepSeek API
|
||||
# Включает оценку постов через DeepSeek API
|
||||
DEEPSEEK_ENABLED=false
|
||||
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
||||
DEEPSEEK_API_URL=https://api.deepseek.com/v1/chat/completions
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
DEEPSEEK_TIMEOUT=30
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,36 +1,25 @@
|
||||
from aiogram import Router, types, F
|
||||
from aiogram.filters import Command, StateFilter, MagicData
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.filters import Command, MagicData, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
from helper_bot.filters.main import ChatTypeFilter
|
||||
from helper_bot.keyboards.keyboards import (
|
||||
get_reply_keyboard_admin,
|
||||
create_keyboard_with_pagination,
|
||||
create_keyboard_for_ban_days,
|
||||
create_keyboard_for_approve_ban,
|
||||
create_keyboard_for_ban_reason
|
||||
)
|
||||
from helper_bot.handlers.admin.dependencies import AdminAccessMiddleware
|
||||
from helper_bot.handlers.admin.exceptions import (InvalidInputError,
|
||||
UserAlreadyBannedError)
|
||||
from helper_bot.handlers.admin.services import AdminService
|
||||
from helper_bot.handlers.admin.exceptions import (
|
||||
UserAlreadyBannedError,
|
||||
InvalidInputError
|
||||
)
|
||||
from helper_bot.handlers.admin.utils import (
|
||||
return_to_admin_menu,
|
||||
handle_admin_error,
|
||||
format_user_info,
|
||||
format_ban_confirmation,
|
||||
escape_html
|
||||
)
|
||||
from logs.custom_logger import logger
|
||||
|
||||
from helper_bot.handlers.admin.utils import (escape_html,
|
||||
format_ban_confirmation,
|
||||
format_user_info,
|
||||
handle_admin_error,
|
||||
return_to_admin_menu)
|
||||
from helper_bot.keyboards.keyboards import (create_keyboard_for_approve_ban,
|
||||
create_keyboard_for_ban_days,
|
||||
create_keyboard_for_ban_reason,
|
||||
create_keyboard_with_pagination,
|
||||
get_reply_keyboard_admin)
|
||||
from helper_bot.utils.base_dependency_factory import get_global_instance
|
||||
# Local imports - metrics
|
||||
from helper_bot.utils.metrics import (
|
||||
track_time,
|
||||
track_errors,
|
||||
db_query_time
|
||||
)
|
||||
from helper_bot.utils.metrics import db_query_time, track_errors, track_time
|
||||
from logs.custom_logger import logger
|
||||
|
||||
# Создаем роутер с middleware для проверки доступа
|
||||
admin_router = Router()
|
||||
@@ -149,6 +138,93 @@ async def get_banned_users(
|
||||
await handle_admin_error(message, e, state, "get_banned_users")
|
||||
|
||||
|
||||
@admin_router.message(
|
||||
ChatTypeFilter(chat_type=["private"]),
|
||||
StateFilter("ADMIN"),
|
||||
F.text == '📊 ML Статистика'
|
||||
)
|
||||
@track_time("get_ml_stats", "admin_handlers")
|
||||
@track_errors("admin_handlers", "get_ml_stats")
|
||||
async def get_ml_stats(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
**kwargs
|
||||
):
|
||||
"""Получение статистики ML-скоринга"""
|
||||
try:
|
||||
logger.info(f"Запрос ML статистики от пользователя: {message.from_user.full_name}")
|
||||
|
||||
bdf = get_global_instance()
|
||||
scoring_manager = bdf.get_scoring_manager()
|
||||
|
||||
if not scoring_manager:
|
||||
await message.answer("📊 ML Scoring отключен\n\nДля включения установите RAG_ENABLED=true или DEEPSEEK_ENABLED=true в .env")
|
||||
return
|
||||
|
||||
stats = await scoring_manager.get_stats()
|
||||
|
||||
# Формируем текст статистики
|
||||
lines = ["📊 <b>ML Scoring Статистика</b>\n"]
|
||||
|
||||
# RAG статистика
|
||||
if "rag" in stats:
|
||||
rag = stats["rag"]
|
||||
lines.append("🤖 <b>RAG API:</b>")
|
||||
|
||||
# Проверяем, есть ли данные из API (новый контракт содержит model_loaded и vector_store)
|
||||
if "model_loaded" in rag or "vector_store" in rag:
|
||||
# Данные из API /stats
|
||||
if "model_loaded" in rag:
|
||||
model_loaded = rag.get('model_loaded', False)
|
||||
lines.append(f" • Модель загружена: {'✅' if model_loaded else '❌'}")
|
||||
if "model_name" in rag:
|
||||
lines.append(f" • Модель: {rag.get('model_name', 'N/A')}")
|
||||
if "device" in rag:
|
||||
lines.append(f" • Устройство: {rag.get('device', 'N/A')}")
|
||||
|
||||
# Статистика из vector_store
|
||||
if "vector_store" in rag:
|
||||
vector_store = rag["vector_store"]
|
||||
positive_count = vector_store.get("positive_count", 0)
|
||||
negative_count = vector_store.get("negative_count", 0)
|
||||
total_count = vector_store.get("total_count", 0)
|
||||
|
||||
lines.append(f" • Положительных примеров: {positive_count}")
|
||||
lines.append(f" • Отрицательных примеров: {negative_count}")
|
||||
lines.append(f" • Всего примеров: {total_count}")
|
||||
|
||||
if "vector_dim" in vector_store:
|
||||
lines.append(f" • Размерность векторов: {vector_store.get('vector_dim', 'N/A')}")
|
||||
if "max_examples" in vector_store:
|
||||
lines.append(f" • Макс. примеров: {vector_store.get('max_examples', 'N/A')}")
|
||||
else:
|
||||
# Fallback на синхронные данные (если API недоступен)
|
||||
lines.append(f" • API URL: {rag.get('api_url', 'N/A')}")
|
||||
if "enabled" in rag:
|
||||
lines.append(f" • Статус: {'✅ Включен' if rag.get('enabled') else '❌ Отключен'}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# DeepSeek статистика
|
||||
if "deepseek" in stats:
|
||||
ds = stats["deepseek"]
|
||||
lines.append("🔮 <b>DeepSeek API:</b>")
|
||||
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)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ХЕНДЛЕРЫ ПРОЦЕССА БАНА
|
||||
# ============================================================================
|
||||
|
||||
@@ -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]] = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Final, Dict
|
||||
from typing import Dict, Final
|
||||
|
||||
# Callback data constants
|
||||
CALLBACK_PUBLISH = "publish"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from typing import Callable
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
from helper_bot.utils.base_dependency_factory import get_global_instance
|
||||
from .services import PostPublishService, BanService
|
||||
|
||||
from .services import BanService, PostPublishService
|
||||
|
||||
|
||||
def get_post_publish_service() -> PostPublishService:
|
||||
@@ -14,7 +15,8 @@ def get_post_publish_service() -> PostPublishService:
|
||||
db = bdf.get_db()
|
||||
settings = bdf.settings
|
||||
s3_storage = bdf.get_s3_storage()
|
||||
return PostPublishService(None, db, settings, s3_storage)
|
||||
scoring_manager = bdf.get_scoring_manager()
|
||||
return PostPublishService(None, db, settings, s3_storage, scoring_manager)
|
||||
|
||||
|
||||
def get_ban_service() -> BanService:
|
||||
|
||||
@@ -1,45 +1,41 @@
|
||||
from datetime import datetime, timedelta
|
||||
import html
|
||||
from typing import Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram import types
|
||||
from aiogram import Bot, types
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from helper_bot.utils.helper_func import (
|
||||
send_text_message, send_photo_message, send_video_message,
|
||||
send_video_note_message, send_audio_message, send_voice_message,
|
||||
send_media_group_to_channel, delete_user_blacklist, get_text_message
|
||||
)
|
||||
from helper_bot.keyboards.keyboards import create_keyboard_for_ban_reason
|
||||
from .exceptions import (
|
||||
UserBlockedBotError, PostNotFoundError, UserNotFoundError,
|
||||
PublishError, BanError
|
||||
)
|
||||
from .constants import (
|
||||
CONTENT_TYPE_TEXT, CONTENT_TYPE_PHOTO, CONTENT_TYPE_VIDEO,
|
||||
CONTENT_TYPE_VIDEO_NOTE, CONTENT_TYPE_AUDIO, CONTENT_TYPE_VOICE,
|
||||
CONTENT_TYPE_MEDIA_GROUP, MESSAGE_POST_PUBLISHED, MESSAGE_POST_DECLINED,
|
||||
MESSAGE_USER_BANNED_SPAM, ERROR_BOT_BLOCKED
|
||||
)
|
||||
from helper_bot.utils.helper_func import (delete_user_blacklist,
|
||||
get_text_message, send_audio_message,
|
||||
send_media_group_to_channel,
|
||||
send_photo_message,
|
||||
send_text_message,
|
||||
send_video_message,
|
||||
send_video_note_message,
|
||||
send_voice_message)
|
||||
# Local imports - metrics
|
||||
from helper_bot.utils.metrics import (db_query_time, track_errors,
|
||||
track_media_processing, track_time)
|
||||
from logs.custom_logger import logger
|
||||
|
||||
# Local imports - metrics
|
||||
from helper_bot.utils.metrics import (
|
||||
track_media_processing,
|
||||
track_time,
|
||||
track_errors,
|
||||
db_query_time
|
||||
)
|
||||
from .constants import (CONTENT_TYPE_AUDIO, CONTENT_TYPE_MEDIA_GROUP,
|
||||
CONTENT_TYPE_PHOTO, CONTENT_TYPE_TEXT,
|
||||
CONTENT_TYPE_VIDEO, CONTENT_TYPE_VIDEO_NOTE,
|
||||
CONTENT_TYPE_VOICE, ERROR_BOT_BLOCKED,
|
||||
MESSAGE_POST_DECLINED, MESSAGE_POST_PUBLISHED,
|
||||
MESSAGE_USER_BANNED_SPAM)
|
||||
from .exceptions import (BanError, PostNotFoundError, PublishError,
|
||||
UserBlockedBotError, UserNotFoundError)
|
||||
|
||||
|
||||
class PostPublishService:
|
||||
def __init__(self, bot: Bot, db, settings: Dict[str, Any], s3_storage=None):
|
||||
def __init__(self, bot: Bot, db, settings: Dict[str, Any], s3_storage=None, scoring_manager=None):
|
||||
# bot может быть None - в этом случае используем бота из контекста сообщения
|
||||
self.bot = bot
|
||||
self.db = db
|
||||
self.settings = settings
|
||||
self.s3_storage = s3_storage
|
||||
self.scoring_manager = scoring_manager
|
||||
self.group_for_posts = settings['Telegram']['group_for_posts']
|
||||
self.main_public = settings['Telegram']['main_public']
|
||||
self.important_logs = settings['Telegram']['important_logs']
|
||||
@@ -397,6 +393,9 @@ class PostPublishService:
|
||||
async def _decline_single_post(self, call: CallbackQuery) -> None:
|
||||
"""Отклонение одиночного поста"""
|
||||
author_id = await self._get_author_id(call.message.message_id)
|
||||
|
||||
# Обучаем RAG на отклоненном посте перед удалением
|
||||
await self._train_on_declined(call.message.message_id)
|
||||
|
||||
updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "declined")
|
||||
if updated_rows == 0:
|
||||
@@ -490,6 +489,9 @@ class PostPublishService:
|
||||
@track_errors("post_publish_service", "_delete_post_and_notify_author")
|
||||
async def _delete_post_and_notify_author(self, call: CallbackQuery, author_id: int) -> None:
|
||||
"""Удаление поста и уведомление автора"""
|
||||
# Получаем текст поста для обучения RAG перед удалением
|
||||
await self._train_on_published(call.message.message_id)
|
||||
|
||||
await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id)
|
||||
|
||||
try:
|
||||
@@ -498,6 +500,32 @@ class PostPublishService:
|
||||
if str(e) == ERROR_BOT_BLOCKED:
|
||||
raise UserBlockedBotError("Пользователь заблокировал бота")
|
||||
raise
|
||||
|
||||
async def _train_on_published(self, message_id: int) -> None:
|
||||
"""Обучает RAG на опубликованном посте."""
|
||||
if not self.scoring_manager:
|
||||
return
|
||||
|
||||
try:
|
||||
text = await self.db.get_post_text_by_message_id(message_id)
|
||||
if text and text.strip() and text != "^":
|
||||
await self.scoring_manager.on_post_published(text)
|
||||
logger.debug(f"RAG обучен на опубликованном посте: {message_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обучения RAG на опубликованном посте {message_id}: {e}")
|
||||
|
||||
async def _train_on_declined(self, message_id: int) -> None:
|
||||
"""Обучает RAG на отклоненном посте."""
|
||||
if not self.scoring_manager:
|
||||
return
|
||||
|
||||
try:
|
||||
text = await self.db.get_post_text_by_message_id(message_id)
|
||||
if text and text.strip() and text != "^":
|
||||
await self.scoring_manager.on_post_declined(text)
|
||||
logger.debug(f"RAG обучен на отклоненном посте: {message_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обучения RAG на отклоненном посте {message_id}: {e}")
|
||||
|
||||
@track_time("_delete_media_group_and_notify_author", "post_publish_service")
|
||||
@track_errors("post_publish_service", "_delete_media_group_and_notify_author")
|
||||
@@ -589,6 +617,20 @@ class BanService:
|
||||
ban_author=ban_author_id,
|
||||
)
|
||||
|
||||
# Обновляем статус поста на declined
|
||||
if call.message.text == CONTENT_TYPE_MEDIA_GROUP:
|
||||
# Для медиагруппы обновляем статус по helper_message_id
|
||||
updated_rows = await self.db.update_status_for_media_group_by_helper_id(
|
||||
call.message.message_id, "declined"
|
||||
)
|
||||
if updated_rows == 0:
|
||||
logger.warning(f"Не удалось обновить статус медиагруппы helper_message_id={call.message.message_id} на 'declined'")
|
||||
else:
|
||||
# Для одиночного поста обновляем статус по message_id
|
||||
updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "declined")
|
||||
if updated_rows == 0:
|
||||
logger.warning(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'declined'")
|
||||
|
||||
await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id)
|
||||
|
||||
date_str = (current_date + timedelta(days=7)).strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]] = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]] = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -5,37 +5,28 @@ import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
# Third-party imports
|
||||
from aiogram import types, Router, F
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
# Local imports - filters and middlewares
|
||||
from database.async_db import AsyncBotDB
|
||||
from helper_bot.filters.main import ChatTypeFilter
|
||||
# Local imports - utilities
|
||||
from helper_bot.keyboards import (get_reply_keyboard,
|
||||
get_reply_keyboard_for_post)
|
||||
from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat
|
||||
from helper_bot.middlewares.album_middleware import AlbumMiddleware
|
||||
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
|
||||
|
||||
# Local imports - utilities
|
||||
from helper_bot.keyboards import get_reply_keyboard, get_reply_keyboard_for_post
|
||||
from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat
|
||||
from helper_bot.utils import messages
|
||||
from helper_bot.utils.helper_func import (
|
||||
get_first_name,
|
||||
update_user_info,
|
||||
check_user_emoji
|
||||
)
|
||||
|
||||
from helper_bot.utils.helper_func import (check_user_emoji, get_first_name,
|
||||
update_user_info)
|
||||
# Local imports - metrics
|
||||
from helper_bot.utils.metrics import (
|
||||
track_time,
|
||||
track_errors,
|
||||
db_query_time
|
||||
)
|
||||
from helper_bot.utils.metrics import db_query_time, track_errors, track_time
|
||||
|
||||
# Local imports - modular components
|
||||
from .constants import FSM_STATES, BUTTON_TEXTS, ERROR_MESSAGES
|
||||
from .services import BotSettings, UserService, PostService, StickerService
|
||||
from .constants import BUTTON_TEXTS, ERROR_MESSAGES, FSM_STATES
|
||||
from .decorators import error_handler
|
||||
from .services import BotSettings, PostService, StickerService, UserService
|
||||
|
||||
# Expose sleep for tests (tests patch helper_bot.handlers.private.private_handlers.sleep)
|
||||
sleep = asyncio.sleep
|
||||
@@ -44,11 +35,11 @@ sleep = asyncio.sleep
|
||||
class PrivateHandlers:
|
||||
"""Main handler class for private messages"""
|
||||
|
||||
def __init__(self, db: AsyncBotDB, settings: BotSettings, s3_storage=None):
|
||||
def __init__(self, db: AsyncBotDB, settings: BotSettings, s3_storage=None, scoring_manager=None):
|
||||
self.db = db
|
||||
self.settings = settings
|
||||
self.user_service = UserService(db, settings)
|
||||
self.post_service = PostService(db, settings, s3_storage)
|
||||
self.post_service = PostService(db, settings, s3_storage, scoring_manager)
|
||||
self.sticker_service = StickerService(settings)
|
||||
|
||||
self.router = Router()
|
||||
@@ -156,43 +147,39 @@ class PrivateHandlers:
|
||||
@track_errors("private_handlers", "suggest_router")
|
||||
@track_time("suggest_router", "private_handlers")
|
||||
async def suggest_router(self, message: types.Message, state: FSMContext, album: list = None, **kwargs):
|
||||
"""Handle post submission in suggest state"""
|
||||
"""Handle post submission in suggest state - сразу отвечает пользователю, обработка в фоне"""
|
||||
# Сразу отвечаем пользователю
|
||||
markup_for_user = await get_reply_keyboard(self.db, message.from_user.id)
|
||||
success_send_message = messages.get_message(get_first_name(message), 'SUCCESS_SEND_MESSAGE')
|
||||
await message.answer(success_send_message, reply_markup=markup_for_user)
|
||||
await state.set_state(FSM_STATES["START"])
|
||||
|
||||
# Проверяем, есть ли механизм для получения полной медиагруппы (для медиагрупп)
|
||||
album_getter = kwargs.get("album_getter")
|
||||
|
||||
if album_getter and message.media_group_id:
|
||||
# Это медиагруппа - сразу отвечаем пользователю, обработку делаем в фоне
|
||||
markup_for_user = await get_reply_keyboard(self.db, message.from_user.id)
|
||||
success_send_message = messages.get_message(get_first_name(message), 'SUCCESS_SEND_MESSAGE')
|
||||
await message.answer(success_send_message, reply_markup=markup_for_user)
|
||||
await state.set_state(FSM_STATES["START"])
|
||||
|
||||
# В фоне ждем полную медиагруппу и обрабатываем пост
|
||||
async def process_media_group_background():
|
||||
try:
|
||||
# Ждем полную медиагруппу
|
||||
# В фоне обрабатываем пост
|
||||
async def process_post_background():
|
||||
try:
|
||||
# Обновляем активность пользователя
|
||||
await self.user_service.update_user_activity(message.from_user.id)
|
||||
|
||||
# Логируем сообщение (только для одиночных сообщений, не медиагрупп)
|
||||
if message.media_group_id is None:
|
||||
await self.user_service.log_user_message(message)
|
||||
|
||||
# Для медиагрупп ждем полную медиагруппу
|
||||
if album_getter and message.media_group_id:
|
||||
full_album = await album_getter.get_album(timeout=10.0)
|
||||
if not full_album:
|
||||
return
|
||||
|
||||
# Обрабатываем пост с полной медиагруппой
|
||||
await self.user_service.update_user_activity(message.from_user.id)
|
||||
await self.post_service.process_post(message, full_album)
|
||||
except Exception as e:
|
||||
from logs.custom_logger import logger
|
||||
logger.error(f"Ошибка при фоновой обработке медиагруппы: {e}")
|
||||
|
||||
asyncio.create_task(process_media_group_background())
|
||||
else:
|
||||
# Обычное сообщение или медиагруппа уже собрана - обрабатываем синхронно
|
||||
await self.user_service.update_user_activity(message.from_user.id)
|
||||
if message.media_group_id is None:
|
||||
await self.user_service.log_user_message(message)
|
||||
await self.post_service.process_post(message, album)
|
||||
markup_for_user = await get_reply_keyboard(self.db, message.from_user.id)
|
||||
success_send_message = messages.get_message(get_first_name(message), 'SUCCESS_SEND_MESSAGE')
|
||||
await message.answer(success_send_message, reply_markup=markup_for_user)
|
||||
await state.set_state(FSM_STATES["START"])
|
||||
if full_album:
|
||||
await self.post_service.process_post(message, full_album)
|
||||
else:
|
||||
# Обычное сообщение или медиагруппа уже собрана
|
||||
await self.post_service.process_post(message, album)
|
||||
except Exception as e:
|
||||
from logs.custom_logger import logger
|
||||
logger.error(f"Ошибка при фоновой обработке поста: {e}")
|
||||
|
||||
asyncio.create_task(process_post_background())
|
||||
|
||||
@error_handler
|
||||
@track_errors("private_handlers", "stickers")
|
||||
@@ -249,18 +236,24 @@ class PrivateHandlers:
|
||||
|
||||
|
||||
# Factory function to create handlers with dependencies
|
||||
def create_private_handlers(db: AsyncBotDB, settings: BotSettings, s3_storage=None) -> PrivateHandlers:
|
||||
def create_private_handlers(db: AsyncBotDB, settings: BotSettings, s3_storage=None, scoring_manager=None) -> PrivateHandlers:
|
||||
"""Create private handlers instance with dependencies"""
|
||||
return PrivateHandlers(db, settings, s3_storage)
|
||||
return PrivateHandlers(db, settings, s3_storage, scoring_manager)
|
||||
|
||||
|
||||
# Legacy router for backward compatibility
|
||||
private_router = Router()
|
||||
|
||||
# Флаг инициализации для защиты от повторного вызова
|
||||
_legacy_router_initialized = False
|
||||
|
||||
# Initialize with global dependencies (for backward compatibility)
|
||||
def init_legacy_router():
|
||||
"""Initialize legacy router with global dependencies"""
|
||||
global private_router
|
||||
global private_router, _legacy_router_initialized
|
||||
|
||||
if _legacy_router_initialized:
|
||||
return
|
||||
|
||||
from helper_bot.utils.base_dependency_factory import get_global_instance
|
||||
|
||||
@@ -278,11 +271,13 @@ def init_legacy_router():
|
||||
|
||||
db = bdf.get_db()
|
||||
s3_storage = bdf.get_s3_storage()
|
||||
handlers = create_private_handlers(db, settings, s3_storage)
|
||||
scoring_manager = bdf.get_scoring_manager()
|
||||
handlers = create_private_handlers(db, settings, s3_storage, scoring_manager)
|
||||
|
||||
# Instead of trying to copy handlers, we'll use the new router directly
|
||||
# This maintains backward compatibility while using the new architecture
|
||||
private_router = handlers.router
|
||||
_legacy_router_initialized = True
|
||||
|
||||
# Initialize legacy router
|
||||
init_legacy_router()
|
||||
|
||||
@@ -1,46 +1,31 @@
|
||||
"""Service classes for private handlers"""
|
||||
|
||||
# Standard library imports
|
||||
import random
|
||||
import asyncio
|
||||
import html
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Callable, Any, Protocol, Union
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, Protocol, Union
|
||||
|
||||
# Third-party imports
|
||||
from aiogram import types
|
||||
from aiogram.types import FSInputFile
|
||||
from database.models import TelegramPost, User
|
||||
from logs.custom_logger import logger
|
||||
|
||||
from helper_bot.keyboards import get_reply_keyboard_for_post
|
||||
# Local imports - utilities
|
||||
from helper_bot.utils.helper_func import (
|
||||
get_first_name,
|
||||
get_text_message,
|
||||
determine_anonymity,
|
||||
send_text_message,
|
||||
send_photo_message,
|
||||
send_media_group_message_to_private_chat,
|
||||
prepare_media_group_from_middlewares,
|
||||
send_video_message,
|
||||
send_video_note_message,
|
||||
send_audio_message,
|
||||
send_voice_message,
|
||||
add_in_db_media,
|
||||
check_username_and_full_name
|
||||
)
|
||||
from helper_bot.keyboards import get_reply_keyboard_for_post
|
||||
|
||||
add_in_db_media, check_username_and_full_name, determine_anonymity,
|
||||
get_first_name, get_text_message, prepare_media_group_from_middlewares,
|
||||
send_audio_message, send_media_group_message_to_private_chat,
|
||||
send_photo_message, send_text_message, send_video_message,
|
||||
send_video_note_message, send_voice_message)
|
||||
# Local imports - metrics
|
||||
from helper_bot.utils.metrics import (
|
||||
track_time,
|
||||
track_errors,
|
||||
db_query_time,
|
||||
track_media_processing,
|
||||
track_file_operations
|
||||
)
|
||||
from helper_bot.utils.metrics import (db_query_time, track_errors,
|
||||
track_file_operations,
|
||||
track_media_processing, track_time)
|
||||
from logs.custom_logger import logger
|
||||
|
||||
|
||||
class DatabaseProtocol(Protocol):
|
||||
@@ -89,7 +74,8 @@ class UserService:
|
||||
"""Ensure user exists in database, create if needed with metrics tracking"""
|
||||
user_id = message.from_user.id
|
||||
full_name = message.from_user.full_name
|
||||
username = message.from_user.username or "private_username"
|
||||
# Сохраняем только реальный username, если его нет - сохраняем None/пустую строку
|
||||
username = message.from_user.username
|
||||
first_name = get_first_name(message)
|
||||
is_bot = message.from_user.is_bot
|
||||
language_code = message.from_user.language_code
|
||||
@@ -100,7 +86,7 @@ class UserService:
|
||||
user_id=user_id,
|
||||
first_name=first_name,
|
||||
full_name=full_name,
|
||||
username=username,
|
||||
username=username, # Может быть None - это нормально
|
||||
is_bot=is_bot,
|
||||
language_code=language_code,
|
||||
emoji="",
|
||||
@@ -119,6 +105,7 @@ class UserService:
|
||||
if is_need_update:
|
||||
await self.db.update_user_info(user_id, username, full_name)
|
||||
safe_full_name = html.escape(full_name) if full_name else "Неизвестный пользователь"
|
||||
# Для отображения используем подстановочное значение, но в БД сохраняем только реальный username
|
||||
safe_username = html.escape(username) if username else "Без никнейма"
|
||||
|
||||
await message.answer(
|
||||
@@ -143,10 +130,11 @@ class UserService:
|
||||
class PostService:
|
||||
"""Service for post-related operations"""
|
||||
|
||||
def __init__(self, db: DatabaseProtocol, settings: BotSettings, s3_storage=None) -> None:
|
||||
def __init__(self, db: DatabaseProtocol, settings: BotSettings, s3_storage=None, scoring_manager=None) -> None:
|
||||
self.db = db
|
||||
self.settings = settings
|
||||
self.s3_storage = s3_storage
|
||||
self.scoring_manager = scoring_manager
|
||||
|
||||
async def _save_media_background(self, sent_message: types.Message, bot_db: Any, s3_storage) -> None:
|
||||
"""Сохраняет медиа в фоне, чтобы не блокировать ответ пользователю"""
|
||||
@@ -157,18 +145,282 @@ class PostService:
|
||||
except Exception as e:
|
||||
logger.error(f"_save_media_background: Ошибка при сохранении медиа для поста {sent_message.message_id}: {e}")
|
||||
|
||||
async def _get_scores(self, text: str) -> tuple:
|
||||
"""
|
||||
Получает скоры для текста поста.
|
||||
|
||||
Returns:
|
||||
Tuple (deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json)
|
||||
"""
|
||||
if not self.scoring_manager or not text or not text.strip():
|
||||
return None, None, None, None, None
|
||||
|
||||
try:
|
||||
scores = await self.scoring_manager.score_post(text)
|
||||
|
||||
# Формируем JSON для сохранения в БД
|
||||
import json
|
||||
ml_scores_json = json.dumps(scores.to_json_dict()) if scores.has_any_score() else None
|
||||
|
||||
# Получаем данные от RAG
|
||||
rag_confidence = scores.rag.confidence if scores.rag else None
|
||||
rag_score_pos_only = scores.rag.metadata.get("rag_score_pos_only") if scores.rag else None
|
||||
|
||||
return scores.deepseek_score, scores.rag_score, rag_confidence, rag_score_pos_only, ml_scores_json
|
||||
except Exception as e:
|
||||
logger.error(f"PostService: Ошибка получения скоров: {e}")
|
||||
return None, None, None, None, None
|
||||
|
||||
async def _save_scores_background(self, message_id: int, ml_scores_json: str) -> None:
|
||||
"""Сохраняет скоры в БД в фоне."""
|
||||
if ml_scores_json:
|
||||
try:
|
||||
await self.db.update_ml_scores(message_id, ml_scores_json)
|
||||
except Exception as e:
|
||||
logger.error(f"PostService: Ошибка сохранения скоров для {message_id}: {e}")
|
||||
|
||||
async def _get_scores_with_error_handling(self, text: str) -> tuple:
|
||||
"""
|
||||
Получает скоры для текста поста с обработкой ошибок.
|
||||
|
||||
Returns:
|
||||
Tuple (deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json, error_message)
|
||||
error_message будет None если все ок, или строка с описанием ошибки
|
||||
"""
|
||||
if not self.scoring_manager:
|
||||
# Скоры выключены в .env - это нормально
|
||||
return None, None, None, None, None, None
|
||||
|
||||
if not text or not text.strip():
|
||||
return None, None, None, None, None, None
|
||||
|
||||
try:
|
||||
scores = await self.scoring_manager.score_post(text)
|
||||
|
||||
# Формируем JSON для сохранения в БД
|
||||
import json
|
||||
ml_scores_json = json.dumps(scores.to_json_dict()) if scores.has_any_score() else None
|
||||
|
||||
# Получаем данные от RAG
|
||||
rag_confidence = scores.rag.confidence if scores.rag else None
|
||||
rag_score_pos_only = scores.rag.metadata.get("rag_score_pos_only") if scores.rag else None
|
||||
|
||||
return scores.deepseek_score, scores.rag_score, rag_confidence, rag_score_pos_only, ml_scores_json, None
|
||||
except Exception as e:
|
||||
logger.error(f"PostService: Ошибка получения скоров: {e}")
|
||||
# Возвращаем частичные скоры если есть, или сообщение об ошибке
|
||||
error_message = "Не удалось рассчитать скоры"
|
||||
return None, None, None, None, None, error_message
|
||||
|
||||
@track_time("_process_post_background", "post_service")
|
||||
@track_errors("post_service", "_process_post_background")
|
||||
async def _process_post_background(
|
||||
self,
|
||||
message: types.Message,
|
||||
first_name: str,
|
||||
content_type: str,
|
||||
album: Union[list, None] = None
|
||||
) -> None:
|
||||
"""
|
||||
Обрабатывает пост в фоне: получает скоры, отправляет в группу модерации, сохраняет в БД.
|
||||
|
||||
Args:
|
||||
message: Сообщение от пользователя
|
||||
first_name: Имя пользователя
|
||||
content_type: Тип контента ('text', 'photo', 'video', 'audio', 'voice', 'video_note', 'media_group')
|
||||
album: Список сообщений медиагруппы (только для media_group)
|
||||
"""
|
||||
try:
|
||||
# Определяем исходный текст для скоринга и определения анонимности
|
||||
original_raw_text = ""
|
||||
if content_type == "text":
|
||||
original_raw_text = message.text or ""
|
||||
elif content_type == "media_group":
|
||||
original_raw_text = album[0].caption or "" if album and album[0].caption else ""
|
||||
else:
|
||||
original_raw_text = message.caption or ""
|
||||
|
||||
# Получаем скоры с обработкой ошибок
|
||||
deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json, error_message = \
|
||||
await self._get_scores_with_error_handling(original_raw_text)
|
||||
|
||||
# Формируем текст для поста (с сообщением об ошибке если есть)
|
||||
text_for_post = original_raw_text
|
||||
if error_message:
|
||||
# Для текстовых постов добавляем в конец текста
|
||||
if content_type == "text":
|
||||
text_for_post = f"{original_raw_text}\n\n⚠️ {error_message}"
|
||||
# Для медиа добавляем в caption
|
||||
elif content_type in ("photo", "video", "audio") and original_raw_text:
|
||||
text_for_post = f"{original_raw_text}\n\n⚠️ {error_message}"
|
||||
|
||||
# Формируем текст/caption с учетом скоров
|
||||
post_text = ""
|
||||
if text_for_post or content_type == "text":
|
||||
post_text = get_text_message(
|
||||
text_for_post.lower() if text_for_post else "",
|
||||
first_name,
|
||||
message.from_user.username,
|
||||
deepseek_score=deepseek_score,
|
||||
rag_score=rag_score,
|
||||
rag_confidence=rag_confidence,
|
||||
rag_score_pos_only=rag_score_pos_only,
|
||||
)
|
||||
|
||||
# Определяем анонимность по исходному тексту (без сообщения об ошибке)
|
||||
is_anonymous = determine_anonymity(original_raw_text)
|
||||
|
||||
markup = get_reply_keyboard_for_post()
|
||||
sent_message = None
|
||||
|
||||
# Отправляем пост в группу модерации в зависимости от типа
|
||||
if content_type == "text":
|
||||
sent_message = await send_text_message(
|
||||
self.settings.group_for_posts, message, post_text, markup
|
||||
)
|
||||
elif content_type == "photo":
|
||||
sent_message = await send_photo_message(
|
||||
self.settings.group_for_posts, message, message.photo[-1].file_id, post_text, markup
|
||||
)
|
||||
elif content_type == "video":
|
||||
sent_message = await send_video_message(
|
||||
self.settings.group_for_posts, message, message.video.file_id, post_text, markup
|
||||
)
|
||||
elif content_type == "audio":
|
||||
sent_message = await send_audio_message(
|
||||
self.settings.group_for_posts, message, message.audio.file_id, post_text, markup
|
||||
)
|
||||
elif content_type == "voice":
|
||||
sent_message = await send_voice_message(
|
||||
self.settings.group_for_posts, message, message.voice.file_id, markup
|
||||
)
|
||||
elif content_type == "video_note":
|
||||
sent_message = await send_video_note_message(
|
||||
self.settings.group_for_posts, message, message.video_note.file_id, markup
|
||||
)
|
||||
elif content_type == "media_group":
|
||||
# Для медиагруппы используем специальную обработку
|
||||
# Передаем ml_scores_json для сохранения в БД
|
||||
await self._process_media_group_background(
|
||||
message, album, first_name, post_text, is_anonymous, original_raw_text, ml_scores_json
|
||||
)
|
||||
return
|
||||
else:
|
||||
logger.error(f"PostService: Неподдерживаемый тип контента: {content_type}")
|
||||
return
|
||||
|
||||
if not sent_message:
|
||||
logger.error(f"PostService: Не удалось отправить пост типа {content_type}")
|
||||
return
|
||||
|
||||
# Сохраняем пост в БД (сохраняем исходный текст, без сообщения об ошибке)
|
||||
post = TelegramPost(
|
||||
message_id=sent_message.message_id,
|
||||
text=original_raw_text,
|
||||
author_id=message.from_user.id,
|
||||
created_at=int(datetime.now().timestamp()),
|
||||
is_anonymous=is_anonymous
|
||||
)
|
||||
await self.db.add_post(post)
|
||||
|
||||
# Сохраняем медиа и скоры в фоне
|
||||
if content_type in ("photo", "video", "audio", "voice", "video_note"):
|
||||
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
|
||||
|
||||
if ml_scores_json:
|
||||
asyncio.create_task(self._save_scores_background(sent_message.message_id, ml_scores_json))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PostService: Критическая ошибка в _process_post_background для {content_type}: {e}")
|
||||
|
||||
async def _process_media_group_background(
|
||||
self,
|
||||
message: types.Message,
|
||||
album: list,
|
||||
first_name: str,
|
||||
post_caption: str,
|
||||
is_anonymous: bool,
|
||||
original_raw_text: str,
|
||||
ml_scores_json: str = None
|
||||
) -> None:
|
||||
"""Обрабатывает медиагруппу в фоне"""
|
||||
try:
|
||||
media_group = await prepare_media_group_from_middlewares(album, post_caption)
|
||||
|
||||
media_group_message_ids = await send_media_group_message_to_private_chat(
|
||||
self.settings.group_for_posts, message, media_group, self.db, None, self.s3_storage
|
||||
)
|
||||
|
||||
main_post_id = media_group_message_ids[-1]
|
||||
|
||||
main_post = TelegramPost(
|
||||
message_id=main_post_id,
|
||||
text=original_raw_text,
|
||||
author_id=message.from_user.id,
|
||||
created_at=int(datetime.now().timestamp()),
|
||||
is_anonymous=is_anonymous
|
||||
)
|
||||
await self.db.add_post(main_post)
|
||||
|
||||
# Сохраняем скоры в фоне (если они были получены)
|
||||
if ml_scores_json:
|
||||
asyncio.create_task(self._save_scores_background(main_post_id, ml_scores_json))
|
||||
|
||||
for msg_id in media_group_message_ids:
|
||||
await self.db.add_message_link(main_post_id, msg_id)
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
markup = get_reply_keyboard_for_post()
|
||||
helper_message = await send_text_message(
|
||||
self.settings.group_for_posts,
|
||||
message,
|
||||
"^",
|
||||
markup
|
||||
)
|
||||
helper_message_id = helper_message.message_id
|
||||
|
||||
helper_post = TelegramPost(
|
||||
message_id=helper_message_id,
|
||||
text="^",
|
||||
author_id=message.from_user.id,
|
||||
helper_text_message_id=main_post_id,
|
||||
created_at=int(datetime.now().timestamp())
|
||||
)
|
||||
await self.db.add_post(helper_post)
|
||||
|
||||
await self.db.update_helper_message(
|
||||
message_id=main_post_id,
|
||||
helper_message_id=helper_message_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"PostService: Ошибка в _process_media_group_background: {e}")
|
||||
|
||||
@track_time("handle_text_post", "post_service")
|
||||
@track_errors("post_service", "handle_text_post")
|
||||
@db_query_time("handle_text_post", "posts", "insert")
|
||||
async def handle_text_post(self, message: types.Message, first_name: str) -> None:
|
||||
"""Handle text post submission"""
|
||||
post_text = get_text_message(message.text.lower(), first_name, message.from_user.username)
|
||||
raw_text = message.text or ""
|
||||
|
||||
# Получаем скоры для текста
|
||||
deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_text)
|
||||
|
||||
# Формируем текст с учетом скоров
|
||||
post_text = get_text_message(
|
||||
message.text.lower(),
|
||||
first_name,
|
||||
message.from_user.username,
|
||||
deepseek_score=deepseek_score,
|
||||
rag_score=rag_score,
|
||||
rag_confidence=rag_confidence,
|
||||
rag_score_pos_only=rag_score_pos_only,
|
||||
)
|
||||
markup = get_reply_keyboard_for_post()
|
||||
|
||||
sent_message = await send_text_message(self.settings.group_for_posts, message, post_text, markup)
|
||||
|
||||
# Сохраняем сырой текст и определяем анонимность
|
||||
raw_text = message.text or ""
|
||||
# Определяем анонимность
|
||||
is_anonymous = determine_anonymity(raw_text)
|
||||
|
||||
post = TelegramPost(
|
||||
@@ -179,23 +431,39 @@ class PostService:
|
||||
is_anonymous=is_anonymous
|
||||
)
|
||||
await self.db.add_post(post)
|
||||
|
||||
# Сохраняем скоры в фоне
|
||||
if ml_scores_json:
|
||||
asyncio.create_task(self._save_scores_background(sent_message.message_id, ml_scores_json))
|
||||
|
||||
@track_time("handle_photo_post", "post_service")
|
||||
@track_errors("post_service", "handle_photo_post")
|
||||
@db_query_time("handle_photo_post", "posts", "insert")
|
||||
async def handle_photo_post(self, message: types.Message, first_name: str) -> None:
|
||||
"""Handle photo post submission"""
|
||||
raw_caption = message.caption or ""
|
||||
|
||||
# Получаем скоры для текста
|
||||
deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_caption)
|
||||
|
||||
post_caption = ""
|
||||
if message.caption:
|
||||
post_caption = get_text_message(message.caption.lower(), first_name, message.from_user.username)
|
||||
post_caption = get_text_message(
|
||||
message.caption.lower(),
|
||||
first_name,
|
||||
message.from_user.username,
|
||||
deepseek_score=deepseek_score,
|
||||
rag_score=rag_score,
|
||||
rag_confidence=rag_confidence,
|
||||
rag_score_pos_only=rag_score_pos_only,
|
||||
)
|
||||
|
||||
markup = get_reply_keyboard_for_post()
|
||||
sent_message = await send_photo_message(
|
||||
self.settings.group_for_posts, message, message.photo[-1].file_id, post_caption, markup
|
||||
)
|
||||
|
||||
# Сохраняем сырой caption и определяем анонимность
|
||||
raw_caption = message.caption or ""
|
||||
# Определяем анонимность
|
||||
is_anonymous = determine_anonymity(raw_caption)
|
||||
|
||||
post = TelegramPost(
|
||||
@@ -206,25 +474,40 @@ class PostService:
|
||||
is_anonymous=is_anonymous
|
||||
)
|
||||
await self.db.add_post(post)
|
||||
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
|
||||
|
||||
# Сохраняем медиа и скоры в фоне
|
||||
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
|
||||
if ml_scores_json:
|
||||
asyncio.create_task(self._save_scores_background(sent_message.message_id, ml_scores_json))
|
||||
|
||||
@track_time("handle_video_post", "post_service")
|
||||
@track_errors("post_service", "handle_video_post")
|
||||
@db_query_time("handle_video_post", "posts", "insert")
|
||||
async def handle_video_post(self, message: types.Message, first_name: str) -> None:
|
||||
"""Handle video post submission"""
|
||||
raw_caption = message.caption or ""
|
||||
|
||||
# Получаем скоры для текста
|
||||
deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_caption)
|
||||
|
||||
post_caption = ""
|
||||
if message.caption:
|
||||
post_caption = get_text_message(message.caption.lower(), first_name, message.from_user.username)
|
||||
post_caption = get_text_message(
|
||||
message.caption.lower(),
|
||||
first_name,
|
||||
message.from_user.username,
|
||||
deepseek_score=deepseek_score,
|
||||
rag_score=rag_score,
|
||||
rag_confidence=rag_confidence,
|
||||
rag_score_pos_only=rag_score_pos_only,
|
||||
)
|
||||
|
||||
markup = get_reply_keyboard_for_post()
|
||||
sent_message = await send_video_message(
|
||||
self.settings.group_for_posts, message, message.video.file_id, post_caption, markup
|
||||
)
|
||||
|
||||
# Сохраняем сырой caption и определяем анонимность
|
||||
raw_caption = message.caption or ""
|
||||
# Определяем анонимность
|
||||
is_anonymous = determine_anonymity(raw_caption)
|
||||
|
||||
post = TelegramPost(
|
||||
@@ -235,8 +518,11 @@ class PostService:
|
||||
is_anonymous=is_anonymous
|
||||
)
|
||||
await self.db.add_post(post)
|
||||
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
|
||||
|
||||
# Сохраняем медиа и скоры в фоне
|
||||
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
|
||||
if ml_scores_json:
|
||||
asyncio.create_task(self._save_scores_background(sent_message.message_id, ml_scores_json))
|
||||
|
||||
@track_time("handle_video_note_post", "post_service")
|
||||
@track_errors("post_service", "handle_video_note_post")
|
||||
@@ -268,17 +554,29 @@ class PostService:
|
||||
@db_query_time("handle_audio_post", "posts", "insert")
|
||||
async def handle_audio_post(self, message: types.Message, first_name: str) -> None:
|
||||
"""Handle audio post submission"""
|
||||
raw_caption = message.caption or ""
|
||||
|
||||
# Получаем скоры для текста
|
||||
deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_caption)
|
||||
|
||||
post_caption = ""
|
||||
if message.caption:
|
||||
post_caption = get_text_message(message.caption.lower(), first_name, message.from_user.username)
|
||||
post_caption = get_text_message(
|
||||
message.caption.lower(),
|
||||
first_name,
|
||||
message.from_user.username,
|
||||
deepseek_score=deepseek_score,
|
||||
rag_score=rag_score,
|
||||
rag_confidence=rag_confidence,
|
||||
rag_score_pos_only=rag_score_pos_only,
|
||||
)
|
||||
|
||||
markup = get_reply_keyboard_for_post()
|
||||
sent_message = await send_audio_message(
|
||||
self.settings.group_for_posts, message, message.audio.file_id, post_caption, markup
|
||||
)
|
||||
|
||||
# Сохраняем сырой caption и определяем анонимность
|
||||
raw_caption = message.caption or ""
|
||||
# Определяем анонимность
|
||||
is_anonymous = determine_anonymity(raw_caption)
|
||||
|
||||
post = TelegramPost(
|
||||
@@ -289,8 +587,11 @@ class PostService:
|
||||
is_anonymous=is_anonymous
|
||||
)
|
||||
await self.db.add_post(post)
|
||||
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
|
||||
|
||||
# Сохраняем медиа и скоры в фоне
|
||||
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
|
||||
if ml_scores_json:
|
||||
asyncio.create_task(self._save_scores_background(sent_message.message_id, ml_scores_json))
|
||||
|
||||
@track_time("handle_voice_post", "post_service")
|
||||
@track_errors("post_service", "handle_voice_post")
|
||||
@@ -325,10 +626,23 @@ class PostService:
|
||||
"""Handle media group post submission"""
|
||||
post_caption = " "
|
||||
raw_caption = ""
|
||||
ml_scores_json = None
|
||||
|
||||
if album and album[0].caption:
|
||||
raw_caption = album[0].caption or ""
|
||||
post_caption = get_text_message(album[0].caption.lower(), first_name, message.from_user.username)
|
||||
|
||||
# Получаем скоры для текста
|
||||
deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_caption)
|
||||
|
||||
post_caption = get_text_message(
|
||||
album[0].caption.lower(),
|
||||
first_name,
|
||||
message.from_user.username,
|
||||
deepseek_score=deepseek_score,
|
||||
rag_score=rag_score,
|
||||
rag_confidence=rag_confidence,
|
||||
rag_score_pos_only=rag_score_pos_only,
|
||||
)
|
||||
|
||||
is_anonymous = determine_anonymity(raw_caption)
|
||||
media_group = await prepare_media_group_from_middlewares(album, post_caption)
|
||||
@@ -348,6 +662,10 @@ class PostService:
|
||||
)
|
||||
await self.db.add_post(main_post)
|
||||
|
||||
# Сохраняем скоры в фоне
|
||||
if ml_scores_json:
|
||||
asyncio.create_task(self._save_scores_background(main_post_id, ml_scores_json))
|
||||
|
||||
for msg_id in media_group_message_ids:
|
||||
await self.db.add_message_link(main_post_id, msg_id)
|
||||
|
||||
@@ -380,30 +698,19 @@ class PostService:
|
||||
@track_errors("post_service", "process_post")
|
||||
@track_media_processing("media_group")
|
||||
async def process_post(self, message: types.Message, album: Union[list, None] = None) -> None:
|
||||
"""Process post based on content type"""
|
||||
"""
|
||||
Запускает обработку поста в фоне.
|
||||
Не блокирует выполнение - сразу возвращает управление.
|
||||
"""
|
||||
first_name = get_first_name(message)
|
||||
|
||||
if message.media_group_id is not None:
|
||||
await self.handle_media_group_post(message, album, first_name)
|
||||
return
|
||||
# Определяем тип контента
|
||||
content_type = "media_group" if message.media_group_id is not None else message.content_type
|
||||
|
||||
content_handlers: Dict[str, Callable] = {
|
||||
'text': lambda: self.handle_text_post(message, first_name),
|
||||
'photo': lambda: self.handle_photo_post(message, first_name),
|
||||
'video': lambda: self.handle_video_post(message, first_name),
|
||||
'video_note': lambda: self.handle_video_note_post(message),
|
||||
'audio': lambda: self.handle_audio_post(message, first_name),
|
||||
'voice': lambda: self.handle_voice_post(message)
|
||||
}
|
||||
|
||||
handler = content_handlers.get(message.content_type)
|
||||
if handler:
|
||||
await handler()
|
||||
else:
|
||||
from .constants import ERROR_MESSAGES
|
||||
await message.bot.send_message(
|
||||
message.chat.id, ERROR_MESSAGES["UNSUPPORTED_CONTENT"]
|
||||
)
|
||||
# Запускаем фоновую обработку
|
||||
asyncio.create_task(
|
||||
self._process_post_background(message, first_name, content_type, album)
|
||||
)
|
||||
|
||||
|
||||
class StickerService:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Final, Dict
|
||||
from typing import Dict, Final
|
||||
|
||||
# Voice bot constants
|
||||
VOICE_BOT_NAME = "voice"
|
||||
|
||||
@@ -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:
|
||||
"""Модель голосового сообщения"""
|
||||
|
||||
@@ -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]:
|
||||
"""Форматировать время с момента последней записи"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1 +1 @@
|
||||
from .keyboards import get_reply_keyboard_for_post, get_reply_keyboard
|
||||
from .keyboards import get_reply_keyboard, get_reply_keyboard_for_post
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
from aiogram import types
|
||||
from aiogram.utils.keyboard import ReplyKeyboardBuilder, InlineKeyboardBuilder
|
||||
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
|
||||
# Local imports - metrics
|
||||
from helper_bot.utils.metrics import (
|
||||
track_time,
|
||||
track_errors
|
||||
)
|
||||
from helper_bot.utils.metrics import track_errors, track_time
|
||||
|
||||
|
||||
def get_reply_keyboard_for_post():
|
||||
@@ -51,6 +47,9 @@ def get_reply_keyboard_admin():
|
||||
)
|
||||
builder.row(
|
||||
types.KeyboardButton(text="Разбан (список)"),
|
||||
types.KeyboardButton(text="📊 ML Статистика")
|
||||
)
|
||||
builder.row(
|
||||
types.KeyboardButton(text="Вернуться в бота")
|
||||
)
|
||||
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiogram.fsm.strategy import FSMStrategy
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
from helper_bot.handlers.admin import admin_router
|
||||
from helper_bot.handlers.callback import callback_router
|
||||
from helper_bot.handlers.group import group_router
|
||||
from helper_bot.handlers.private import private_router
|
||||
from helper_bot.handlers.voice import VoiceHandlers
|
||||
from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
|
||||
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
|
||||
from helper_bot.middlewares.metrics_middleware import MetricsMiddleware, ErrorMetricsMiddleware
|
||||
from helper_bot.middlewares.dependencies_middleware import \
|
||||
DependenciesMiddleware
|
||||
from helper_bot.middlewares.metrics_middleware import (ErrorMetricsMiddleware,
|
||||
MetricsMiddleware)
|
||||
from helper_bot.middlewares.rate_limit_middleware import RateLimitMiddleware
|
||||
from helper_bot.server_prometheus import start_metrics_server, stop_metrics_server
|
||||
from helper_bot.server_prometheus import (start_metrics_server,
|
||||
stop_metrics_server)
|
||||
|
||||
|
||||
async def start_bot_with_retry(bot: Bot, dp: Dispatcher, max_retries: int = 5, base_delay: float = 1.0):
|
||||
@@ -63,11 +66,22 @@ async def start_bot(bdf):
|
||||
# Middleware уже добавлены на уровне dispatcher
|
||||
dp.include_routers(admin_router, private_router, callback_router, group_router, voice_router)
|
||||
|
||||
# Получаем scoring_manager для использования в shutdown
|
||||
scoring_manager = bdf.get_scoring_manager()
|
||||
|
||||
# Добавляем обработчик завершения для корректного закрытия
|
||||
@dp.shutdown()
|
||||
async def on_shutdown():
|
||||
logging.info("Bot shutdown initiated, cleaning up resources...")
|
||||
try:
|
||||
# Закрываем ресурсы ScoringManager
|
||||
if scoring_manager:
|
||||
try:
|
||||
await scoring_manager.close()
|
||||
logging.info("ScoringManager закрыт")
|
||||
except Exception as e:
|
||||
logging.error(f"Ошибка закрытия ScoringManager: {e}")
|
||||
|
||||
await bot.session.close()
|
||||
logging.info("Bot session closed successfully")
|
||||
except Exception as e:
|
||||
@@ -94,6 +108,14 @@ async def start_bot(bdf):
|
||||
logging.error(f"❌ Ошибка запуска бота: {e}")
|
||||
raise
|
||||
finally:
|
||||
# Закрываем ресурсы ScoringManager перед завершением (на случай если shutdown не сработал)
|
||||
if scoring_manager:
|
||||
try:
|
||||
await scoring_manager.close()
|
||||
logging.info("ScoringManager закрыт в finally")
|
||||
except Exception as e:
|
||||
logging.error(f"Ошибка закрытия ScoringManager в finally: {e}")
|
||||
|
||||
# Останавливаем метрики сервер при завершении
|
||||
try:
|
||||
await stop_metrics_server()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
# Импортируем логгер из проекта
|
||||
|
||||
5
helper_bot/services/__init__.py
Normal file
5
helper_bot/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Сервисы приложения.
|
||||
|
||||
Содержит бизнес-логику, не связанную напрямую с handlers.
|
||||
"""
|
||||
34
helper_bot/services/scoring/__init__.py
Normal file
34
helper_bot/services/scoring/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Сервисы для ML-скоринга постов.
|
||||
|
||||
Включает:
|
||||
- RagApiClient - HTTP клиент для внешнего RAG API сервиса
|
||||
- DeepSeekService - интеграция с DeepSeek API
|
||||
- ScoringManager - объединение всех сервисов скоринга
|
||||
"""
|
||||
|
||||
from .base import CombinedScore, ScoringResult, ScoringServiceProtocol
|
||||
from .deepseek_service import DeepSeekService
|
||||
from .exceptions import (DeepSeekAPIError, InsufficientExamplesError,
|
||||
ModelNotLoadedError, ScoringError, TextTooShortError,
|
||||
VectorStoreError)
|
||||
from .rag_client import RagApiClient
|
||||
from .scoring_manager import ScoringManager
|
||||
|
||||
__all__ = [
|
||||
# Базовые классы
|
||||
"ScoringResult",
|
||||
"ScoringServiceProtocol",
|
||||
"CombinedScore",
|
||||
# Исключения
|
||||
"ScoringError",
|
||||
"ModelNotLoadedError",
|
||||
"VectorStoreError",
|
||||
"DeepSeekAPIError",
|
||||
"InsufficientExamplesError",
|
||||
"TextTooShortError",
|
||||
# Сервисы
|
||||
"RagApiClient",
|
||||
"DeepSeekService",
|
||||
"ScoringManager",
|
||||
]
|
||||
155
helper_bot/services/scoring/base.py
Normal file
155
helper_bot/services/scoring/base.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Базовые классы и протоколы для сервисов скоринга.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional, Protocol
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScoringResult:
|
||||
"""
|
||||
Результат оценки поста от одного сервиса.
|
||||
|
||||
Attributes:
|
||||
score: Оценка от 0.0 до 1.0 (вероятность публикации)
|
||||
source: Источник оценки ("deepseek", "rag", etc.)
|
||||
model: Название используемой модели
|
||||
confidence: Уверенность в оценке (опционально)
|
||||
timestamp: Время получения оценки
|
||||
metadata: Дополнительные данные
|
||||
"""
|
||||
score: float
|
||||
source: str
|
||||
model: str
|
||||
confidence: Optional[float] = None
|
||||
timestamp: int = field(default_factory=lambda: int(datetime.now().timestamp()))
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Валидация score в диапазоне [0.0, 1.0]."""
|
||||
if not 0.0 <= self.score <= 1.0:
|
||||
raise ValueError(f"Score должен быть в диапазоне [0.0, 1.0], получено: {self.score}")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Преобразует результат в словарь для сохранения в JSON."""
|
||||
result = {
|
||||
"score": round(self.score, 4),
|
||||
"model": self.model,
|
||||
"ts": self.timestamp,
|
||||
}
|
||||
if self.confidence is not None:
|
||||
result["confidence"] = round(self.confidence, 4)
|
||||
if self.metadata:
|
||||
result["metadata"] = self.metadata
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, source: str, data: Dict[str, Any]) -> "ScoringResult":
|
||||
"""Создает ScoringResult из словаря."""
|
||||
return cls(
|
||||
score=data["score"],
|
||||
source=source,
|
||||
model=data.get("model", "unknown"),
|
||||
confidence=data.get("confidence"),
|
||||
timestamp=data.get("ts", int(datetime.now().timestamp())),
|
||||
metadata=data.get("metadata", {}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CombinedScore:
|
||||
"""
|
||||
Объединенный результат от всех сервисов скоринга.
|
||||
|
||||
Attributes:
|
||||
deepseek: Результат от DeepSeek API (None если отключен/ошибка)
|
||||
rag: Результат от RAG сервиса (None если отключен/ошибка)
|
||||
errors: Словарь с ошибками по источникам
|
||||
"""
|
||||
deepseek: Optional[ScoringResult] = None
|
||||
rag: Optional[ScoringResult] = None
|
||||
errors: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def deepseek_score(self) -> Optional[float]:
|
||||
"""Возвращает только числовой скор от DeepSeek."""
|
||||
return self.deepseek.score if self.deepseek else None
|
||||
|
||||
@property
|
||||
def rag_score(self) -> Optional[float]:
|
||||
"""Возвращает только числовой скор от RAG."""
|
||||
return self.rag.score if self.rag else None
|
||||
|
||||
def to_json_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Преобразует в словарь для сохранения в ml_scores колонку.
|
||||
|
||||
Формат:
|
||||
{
|
||||
"deepseek": {"score": 0.75, "model": "...", "ts": ...},
|
||||
"rag": {"score": 0.90, "model": "...", "ts": ...}
|
||||
}
|
||||
"""
|
||||
result = {}
|
||||
if self.deepseek:
|
||||
result["deepseek"] = self.deepseek.to_dict()
|
||||
if self.rag:
|
||||
result["rag"] = self.rag.to_dict()
|
||||
return result
|
||||
|
||||
def has_any_score(self) -> bool:
|
||||
"""Проверяет, есть ли хотя бы один успешный скор."""
|
||||
return self.deepseek is not None or self.rag is not None
|
||||
|
||||
|
||||
class ScoringServiceProtocol(Protocol):
|
||||
"""
|
||||
Протокол для сервисов скоринга.
|
||||
|
||||
Любой сервис скоринга должен реализовывать эти методы.
|
||||
"""
|
||||
|
||||
@property
|
||||
def source_name(self) -> str:
|
||||
"""Возвращает имя источника ("deepseek", "rag", etc.)."""
|
||||
...
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
"""Проверяет, включен ли сервис."""
|
||||
...
|
||||
|
||||
async def calculate_score(self, text: str) -> ScoringResult:
|
||||
"""
|
||||
Рассчитывает скор для текста поста.
|
||||
|
||||
Args:
|
||||
text: Текст поста для оценки
|
||||
|
||||
Returns:
|
||||
ScoringResult с оценкой
|
||||
|
||||
Raises:
|
||||
ScoringError: При ошибке расчета
|
||||
"""
|
||||
...
|
||||
|
||||
async def add_positive_example(self, text: str) -> None:
|
||||
"""
|
||||
Добавляет текст как положительный пример (опубликованный пост).
|
||||
|
||||
Args:
|
||||
text: Текст опубликованного поста
|
||||
"""
|
||||
...
|
||||
|
||||
async def add_negative_example(self, text: str) -> None:
|
||||
"""
|
||||
Добавляет текст как отрицательный пример (отклоненный пост).
|
||||
|
||||
Args:
|
||||
text: Текст отклоненного поста
|
||||
"""
|
||||
...
|
||||
357
helper_bot/services/scoring/deepseek_service.py
Normal file
357
helper_bot/services/scoring/deepseek_service.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
DeepSeek API сервис для скоринга постов.
|
||||
|
||||
Использует DeepSeek API для семантической оценки релевантности поста.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
from helper_bot.utils.metrics import track_errors, track_time
|
||||
from logs.custom_logger import logger
|
||||
|
||||
from .base import ScoringResult
|
||||
from .exceptions import DeepSeekAPIError, ScoringError, TextTooShortError
|
||||
|
||||
|
||||
class DeepSeekService:
|
||||
"""
|
||||
Сервис для оценки постов через DeepSeek API.
|
||||
|
||||
Отправляет текст поста в DeepSeek с промптом для оценки
|
||||
и получает числовой скор релевантности.
|
||||
|
||||
Attributes:
|
||||
api_key: API ключ DeepSeek
|
||||
api_url: URL API эндпоинта
|
||||
model: Название модели
|
||||
timeout: Таймаут запроса в секундах
|
||||
"""
|
||||
|
||||
# Промпт для оценки поста
|
||||
SCORING_PROMPT = """Роль: Ты — строгий и внимательный модератор сообщества в социальной сети, ориентированного на знакомства между людьми. Твоя задача — оценить, можно ли опубликовать пост, основываясь на четких правилах.
|
||||
|
||||
Контекст группы: Это группа для поиска и знакомства с людьми. Пользователи могут искать кого угодно: случайно увиденных на улице, в транспорте, в кафе, старых знакомых, новых друзей или пару. Это главная и единственная цель группы.
|
||||
|
||||
---
|
||||
|
||||
ПРАВИЛА ЗАПРЕТА (пост НЕ ДОЛЖЕН быть опубликован, если содержит это):
|
||||
|
||||
1. Запрещенные законом тематики: Любые призывы, обсуждение или поиск чего-либо незаконного (наркотики, оружие, мошенничество, насилие и т.д.).
|
||||
2. Поиск и утеря животных, найденные предметы: Запрещены посты про потерявшихся/найденных кошек, собак, хомяков, а также про потерянные/найденные телефоны, ключи, сумки и т.п.
|
||||
3. Конкуренция (Дайвинчик): Любое упоминание группы/проекта/чата "Дайвинчик" или любых других групп-конкурентов. Запрещены призывы переходить в другие сообщества.
|
||||
4. Сбор больших компаний и групп: Запрещены посты с целью собрать большую тусовку, компанию, группу для похода, вечеринки, игры и т.д. (например, "собираем команду для футбола", "кто хочет на квартиру?").
|
||||
5. Организация чатов и других сообществ: Запрещено создание или реклама сторонних чатов, каналов, групп в телеграме, дискорде и т.п.
|
||||
|
||||
---
|
||||
|
||||
ПРАВИЛА РАЗРЕШЕНИЯ (пост МОЖЕТ быть опубликован, если):
|
||||
|
||||
· Цель — найти конкретного человека или познакомиться с кем-то новым.
|
||||
· Формат: Описание человека, обстоятельств встречи, примет, места и времени. Или прямой призыв к знакомству.
|
||||
· Примеры ДОПУСТИМЫХ постов (ориентируйся на них):
|
||||
· "мальчики нефоры/патлатые, гоу знакомиться😻 анон"
|
||||
· "ищу девочку, ехала на 21 автобусе примерно в 15:20. села на детской поликлинике и вышла в заречье вся в черной одежде и с черным баулом"
|
||||
· "ищу мальчика ехали на 35 автобусе часов в 7 вечера я была с девочками,у нас с тобой еще куртки одинаковые ,я рядом с тобой сидела,напиши в комментарии если у тебя нету девочки. анон админу любви."
|
||||
|
||||
---
|
||||
|
||||
ИНСТРУКЦИЯ ПО ОЦЕНКЕ:
|
||||
|
||||
Проанализируй полученный пост и присвой ему итоговый Вес (Score) от 0.0 до 1.0, где:
|
||||
|
||||
· 1.0 — Пост полностью соответствует правилам. Цель — найти/познакомиться с человеком. Ничего из списка запретов не нарушено. Можно публиковать.
|
||||
· 0.0 — Пост категорически нарушает правила. Содержит явные признаки одного или нескольких пунктов из списка запрета. Публиковать НЕЛЬЗЯ.
|
||||
· 0.2 - 0.8 — Пост находится в "серой зоне". Присваивай промежуточный вес, оценивая степень риска и соответствия цели группы.
|
||||
· Ближе к 0.2: Сильно сомнительный пост, есть явные признаки запрещенной темы (например, упоминание "собраться компанией", косвенная реклама другого места).
|
||||
· 0.5: Нейтральный или неочевидный пост. Нужно проверить, нет ли скрытого смысла, нарушающего правила.
|
||||
· Ближе к 0.8: В целом допустимый пост, но с небольшими странностями или двусмысленностями, не нарушающими правила напрямую.
|
||||
---
|
||||
{text}
|
||||
---
|
||||
|
||||
Ответь ТОЛЬКО числом от 0.0 до 1.0, без дополнительных объяснений.
|
||||
Пример ответа: 0.75"""
|
||||
|
||||
DEFAULT_API_URL = "https://api.deepseek.com/v1/chat/completions"
|
||||
DEFAULT_MODEL = "deepseek-chat"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
api_url: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
timeout: int = 30,
|
||||
enabled: bool = True,
|
||||
min_text_length: int = 3,
|
||||
max_retries: int = 3,
|
||||
):
|
||||
"""
|
||||
Инициализация DeepSeek сервиса.
|
||||
|
||||
Args:
|
||||
api_key: API ключ DeepSeek
|
||||
api_url: URL API эндпоинта
|
||||
model: Название модели
|
||||
timeout: Таймаут запроса в секундах
|
||||
enabled: Включен ли сервис
|
||||
min_text_length: Минимальная длина текста для обработки
|
||||
max_retries: Максимальное количество повторных попыток
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.api_url = api_url or self.DEFAULT_API_URL
|
||||
self.model = model or self.DEFAULT_MODEL
|
||||
self.timeout = timeout
|
||||
self._enabled = enabled and bool(api_key)
|
||||
self.min_text_length = min_text_length
|
||||
self.max_retries = max_retries
|
||||
|
||||
# HTTP клиент (создается лениво)
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
if not api_key and enabled:
|
||||
logger.warning("DeepSeekService: API ключ не указан, сервис отключен")
|
||||
self._enabled = False
|
||||
|
||||
logger.info(
|
||||
f"DeepSeekService инициализирован "
|
||||
f"(model={self.model}, enabled={self._enabled})"
|
||||
)
|
||||
|
||||
@property
|
||||
def source_name(self) -> str:
|
||||
"""Имя источника для результатов."""
|
||||
return "deepseek"
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
"""Проверяет, включен ли сервис."""
|
||||
return self._enabled
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Получает или создает HTTP клиент."""
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout),
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Закрывает HTTP клиент."""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
def _clean_text(self, text: str) -> str:
|
||||
"""Очищает текст от лишних символов."""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# Удаляем лишние пробелы и переносы строк
|
||||
clean = " ".join(text.split())
|
||||
|
||||
# Удаляем служебные символы
|
||||
if clean == "^":
|
||||
return ""
|
||||
|
||||
return clean.strip()
|
||||
|
||||
def _parse_score_response(self, response_text: str) -> float:
|
||||
"""
|
||||
Парсит ответ от DeepSeek и извлекает скор.
|
||||
|
||||
Args:
|
||||
response_text: Текст ответа от API
|
||||
|
||||
Returns:
|
||||
Числовой скор от 0.0 до 1.0
|
||||
|
||||
Raises:
|
||||
DeepSeekAPIError: Если не удалось распарсить ответ
|
||||
"""
|
||||
try:
|
||||
# Пытаемся найти число в ответе
|
||||
text = response_text.strip()
|
||||
|
||||
# Убираем возможные обрамления
|
||||
text = text.strip('"\'`')
|
||||
|
||||
# Пробуем распарсить как число
|
||||
score = float(text)
|
||||
|
||||
# Ограничиваем диапазон
|
||||
score = max(0.0, min(1.0, score))
|
||||
|
||||
return score
|
||||
|
||||
except ValueError:
|
||||
# Пробуем найти число в тексте
|
||||
import re
|
||||
matches = re.findall(r'0\.\d+|1\.0|0|1', text)
|
||||
if matches:
|
||||
score = float(matches[0])
|
||||
return max(0.0, min(1.0, score))
|
||||
|
||||
logger.error(f"DeepSeekService: Не удалось распарсить ответ: {response_text}")
|
||||
raise DeepSeekAPIError(f"Не удалось распарсить скор из ответа: {response_text}")
|
||||
|
||||
@track_time("calculate_score", "deepseek_service")
|
||||
@track_errors("deepseek_service", "calculate_score")
|
||||
async def calculate_score(self, text: str) -> ScoringResult:
|
||||
"""
|
||||
Рассчитывает скор для текста поста через DeepSeek API.
|
||||
|
||||
Args:
|
||||
text: Текст поста для оценки
|
||||
|
||||
Returns:
|
||||
ScoringResult с оценкой
|
||||
|
||||
Raises:
|
||||
ScoringError: При ошибке расчета
|
||||
"""
|
||||
if not self._enabled:
|
||||
raise ScoringError("DeepSeek сервис отключен")
|
||||
|
||||
# Очищаем текст
|
||||
clean_text = self._clean_text(text)
|
||||
|
||||
if len(clean_text) < self.min_text_length:
|
||||
raise TextTooShortError(
|
||||
f"Текст слишком короткий (минимум {self.min_text_length} символов)"
|
||||
)
|
||||
|
||||
# Формируем промпт
|
||||
prompt = self.SCORING_PROMPT.format(text=clean_text)
|
||||
|
||||
# Выполняем запрос с повторными попытками
|
||||
last_error = None
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
score = await self._make_api_request(prompt)
|
||||
|
||||
return ScoringResult(
|
||||
score=score,
|
||||
source=self.source_name,
|
||||
model=self.model,
|
||||
metadata={
|
||||
"text_length": len(clean_text),
|
||||
"attempt": attempt + 1,
|
||||
},
|
||||
)
|
||||
|
||||
except DeepSeekAPIError as e:
|
||||
last_error = e
|
||||
logger.warning(
|
||||
f"DeepSeekService: Попытка {attempt + 1}/{self.max_retries} "
|
||||
f"не удалась: {e}"
|
||||
)
|
||||
if attempt < self.max_retries - 1:
|
||||
# Экспоненциальная задержка
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
|
||||
raise ScoringError(f"Все попытки запроса к DeepSeek API не удались: {last_error}")
|
||||
|
||||
async def _make_api_request(self, prompt: str) -> float:
|
||||
"""
|
||||
Выполняет запрос к DeepSeek API.
|
||||
|
||||
Args:
|
||||
prompt: Промпт для отправки
|
||||
|
||||
Returns:
|
||||
Числовой скор от 0.0 до 1.0
|
||||
|
||||
Raises:
|
||||
DeepSeekAPIError: При ошибке API
|
||||
"""
|
||||
client = await self._get_client()
|
||||
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
}
|
||||
],
|
||||
"temperature": 0.1, # Низкая температура для детерминированности
|
||||
"max_tokens": 10, # Ожидаем только число
|
||||
}
|
||||
|
||||
try:
|
||||
response = await client.post(self.api_url, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Извлекаем ответ
|
||||
if "choices" not in data or not data["choices"]:
|
||||
raise DeepSeekAPIError("Пустой ответ от API")
|
||||
|
||||
response_text = data["choices"][0]["message"]["content"]
|
||||
|
||||
# Парсим скор
|
||||
score = self._parse_score_response(response_text)
|
||||
|
||||
logger.debug(f"DeepSeekService: Получен скор {score} для текста")
|
||||
return score
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_msg = f"HTTP ошибка {e.response.status_code}"
|
||||
try:
|
||||
error_data = e.response.json()
|
||||
if "error" in error_data:
|
||||
error_msg = error_data["error"].get("message", error_msg)
|
||||
except Exception:
|
||||
pass
|
||||
raise DeepSeekAPIError(error_msg)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
raise DeepSeekAPIError(f"Таймаут запроса ({self.timeout}s)")
|
||||
|
||||
except Exception as e:
|
||||
raise DeepSeekAPIError(f"Ошибка запроса: {e}")
|
||||
|
||||
async def add_positive_example(self, text: str) -> None:
|
||||
"""
|
||||
Добавляет текст как положительный пример.
|
||||
|
||||
Для DeepSeek не требуется хранить примеры - оценка выполняется
|
||||
на основе промпта. Метод существует для совместимости с протоколом.
|
||||
|
||||
Args:
|
||||
text: Текст опубликованного поста
|
||||
"""
|
||||
# DeepSeek не использует примеры для обучения
|
||||
# Промпт уже содержит критерии оценки
|
||||
pass
|
||||
|
||||
async def add_negative_example(self, text: str) -> None:
|
||||
"""
|
||||
Добавляет текст как отрицательный пример.
|
||||
|
||||
Для DeepSeek не требуется хранить примеры - оценка выполняется
|
||||
на основе промпта. Метод существует для совместимости с протоколом.
|
||||
|
||||
Args:
|
||||
text: Текст отклоненного поста
|
||||
"""
|
||||
# DeepSeek не использует примеры для обучения
|
||||
pass
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""Возвращает статистику сервиса."""
|
||||
return {
|
||||
"enabled": self._enabled,
|
||||
"model": self.model,
|
||||
"api_url": self.api_url,
|
||||
"timeout": self.timeout,
|
||||
"max_retries": self.max_retries,
|
||||
}
|
||||
33
helper_bot/services/scoring/exceptions.py
Normal file
33
helper_bot/services/scoring/exceptions.py
Normal file
@@ -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
|
||||
313
helper_bot/services/scoring/rag_client.py
Normal file
313
helper_bot/services/scoring/rag_client.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
HTTP клиент для взаимодействия с внешним RAG сервисом.
|
||||
|
||||
Использует REST API для получения скоров и отправки примеров.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
from helper_bot.utils.metrics import track_errors, track_time
|
||||
from logs.custom_logger import logger
|
||||
|
||||
from .base import ScoringResult
|
||||
from .exceptions import (InsufficientExamplesError, ScoringError,
|
||||
TextTooShortError)
|
||||
|
||||
|
||||
class RagApiClient:
|
||||
"""
|
||||
HTTP клиент для взаимодействия с внешним RAG сервисом.
|
||||
|
||||
Использует REST API для:
|
||||
- Получения скоров постов (POST /api/v1/score)
|
||||
- Отправки положительных примеров (POST /api/v1/examples/positive)
|
||||
- Отправки отрицательных примеров (POST /api/v1/examples/negative)
|
||||
- Получения статистики (GET /api/v1/stats)
|
||||
|
||||
Attributes:
|
||||
api_url: Базовый URL API сервиса
|
||||
api_key: API ключ для аутентификации
|
||||
timeout: Таймаут запросов в секундах
|
||||
test_mode: Включен ли тестовый режим (добавляет заголовок X-Test-Mode: true)
|
||||
enabled: Включен ли клиент
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_url: str,
|
||||
api_key: str,
|
||||
timeout: int = 30,
|
||||
test_mode: bool = False,
|
||||
enabled: bool = True,
|
||||
):
|
||||
"""
|
||||
Инициализация клиента.
|
||||
|
||||
Args:
|
||||
api_url: Базовый URL API (например, http://хх.ххх.ххх.хх/api/v1)
|
||||
api_key: API ключ для аутентификации
|
||||
timeout: Таймаут запросов в секундах
|
||||
test_mode: Включен ли тестовый режим (добавляет заголовок X-Test-Mode: true к запросам examples)
|
||||
enabled: Включен ли клиент
|
||||
"""
|
||||
# Убираем trailing slash если есть
|
||||
self.api_url = api_url.rstrip('/')
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.test_mode = test_mode
|
||||
self._enabled = enabled
|
||||
|
||||
# Создаем HTTP клиент
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(timeout),
|
||||
headers={
|
||||
"X-API-Key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"RagApiClient инициализирован (url={self.api_url}, enabled={enabled})")
|
||||
|
||||
@property
|
||||
def source_name(self) -> str:
|
||||
"""Имя источника для результатов."""
|
||||
return "rag"
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
"""Проверяет, включен ли клиент."""
|
||||
return self._enabled
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Закрывает HTTP клиент."""
|
||||
await self._client.aclose()
|
||||
|
||||
@track_time("calculate_score", "rag_client")
|
||||
@track_errors("rag_client", "calculate_score")
|
||||
async def calculate_score(self, text: str) -> ScoringResult:
|
||||
"""
|
||||
Рассчитывает скор для текста поста через API.
|
||||
|
||||
Args:
|
||||
text: Текст поста для оценки
|
||||
|
||||
Returns:
|
||||
ScoringResult с оценкой
|
||||
|
||||
Raises:
|
||||
ScoringError: При ошибке расчета
|
||||
InsufficientExamplesError: Если недостаточно примеров
|
||||
TextTooShortError: Если текст слишком короткий
|
||||
"""
|
||||
if not self._enabled:
|
||||
raise ScoringError("RAG API клиент отключен")
|
||||
|
||||
if not text or not text.strip():
|
||||
raise TextTooShortError("Текст пустой")
|
||||
|
||||
try:
|
||||
response = await self._client.post(
|
||||
f"{self.api_url}/score",
|
||||
json={"text": text.strip()}
|
||||
)
|
||||
|
||||
# Обрабатываем различные статусы
|
||||
if response.status_code == 400:
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_msg = error_data.get("detail", "Неизвестная ошибка")
|
||||
except Exception:
|
||||
error_msg = response.text or "Неизвестная ошибка"
|
||||
|
||||
logger.warning(f"RagApiClient: Ошибка валидации запроса: {error_msg}")
|
||||
|
||||
if "недостаточно" in error_msg.lower() or "insufficient" in error_msg.lower():
|
||||
raise InsufficientExamplesError(error_msg)
|
||||
if "коротк" in error_msg.lower() or "short" in error_msg.lower():
|
||||
raise TextTooShortError(error_msg)
|
||||
raise ScoringError(f"Ошибка валидации: {error_msg}")
|
||||
|
||||
if response.status_code == 401:
|
||||
logger.error("RagApiClient: Ошибка аутентификации: неверный API ключ")
|
||||
raise ScoringError("Ошибка аутентификации: неверный API ключ")
|
||||
|
||||
if response.status_code == 404:
|
||||
logger.error("RagApiClient: RAG API endpoint не найден")
|
||||
raise ScoringError("RAG API endpoint не найден")
|
||||
|
||||
if response.status_code >= 500:
|
||||
logger.error(f"RagApiClient: Ошибка сервера RAG API: {response.status_code}")
|
||||
raise ScoringError(f"Ошибка сервера RAG API: {response.status_code}")
|
||||
|
||||
# Проверяем успешный статус
|
||||
if response.status_code != 200:
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Парсим ответ
|
||||
score = float(data.get("rag_score", 0.0))
|
||||
confidence = float(data.get("rag_confidence", 0.0)) if data.get("rag_confidence") is not None else None
|
||||
|
||||
# Форматируем confidence для логирования
|
||||
confidence_str = f"{confidence:.4f}" if confidence is not None else "None"
|
||||
|
||||
logger.info(
|
||||
f"RagApiClient: Скор успешно получен "
|
||||
f"(score={score:.4f}, confidence={confidence_str})"
|
||||
)
|
||||
|
||||
return ScoringResult(
|
||||
score=score,
|
||||
source=self.source_name,
|
||||
model=data.get("meta", {}).get("model", "rag-service"),
|
||||
confidence=confidence,
|
||||
metadata={
|
||||
"rag_score_pos_only": float(data.get("rag_score_pos_only", 0.0)) if data.get("rag_score_pos_only") is not None else None,
|
||||
"positive_examples": data.get("meta", {}).get("positive_examples"),
|
||||
"negative_examples": data.get("meta", {}).get("negative_examples"),
|
||||
}
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error(f"RagApiClient: Таймаут запроса к RAG API (>{self.timeout}с)")
|
||||
raise ScoringError(f"Таймаут запроса к RAG API (>{self.timeout}с)")
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"RagApiClient: Ошибка подключения к RAG API: {e}")
|
||||
raise ScoringError(f"Ошибка подключения к RAG API: {e}")
|
||||
except (KeyError, ValueError, TypeError) as e:
|
||||
logger.error(f"RagApiClient: Ошибка парсинга ответа: {e}, response: {response.text if 'response' in locals() else 'N/A'}")
|
||||
raise ScoringError(f"Ошибка парсинга ответа от RAG API: {e}")
|
||||
except InsufficientExamplesError:
|
||||
raise
|
||||
except TextTooShortError:
|
||||
raise
|
||||
except ScoringError:
|
||||
# Уже залогированные ошибки (401, 404, 500, таймауты и т.д.) - просто пробрасываем
|
||||
raise
|
||||
except Exception as e:
|
||||
# Только действительно неожиданные ошибки логируем здесь
|
||||
logger.error(f"RagApiClient: Неожиданная ошибка при расчете скора: {e}", exc_info=True)
|
||||
raise ScoringError(f"Неожиданная ошибка: {e}")
|
||||
|
||||
@track_time("add_positive_example", "rag_client")
|
||||
async def add_positive_example(self, text: str) -> None:
|
||||
"""
|
||||
Добавляет текст как положительный пример (опубликованный пост).
|
||||
|
||||
Args:
|
||||
text: Текст опубликованного поста
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
if not text or not text.strip():
|
||||
return
|
||||
|
||||
try:
|
||||
# Формируем заголовки (добавляем X-Test-Mode если включен тестовый режим)
|
||||
headers = {}
|
||||
if self.test_mode:
|
||||
headers["X-Test-Mode"] = "true"
|
||||
|
||||
response = await self._client.post(
|
||||
f"{self.api_url}/examples/positive",
|
||||
json={"text": text.strip()},
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if response.status_code == 200 or response.status_code == 201:
|
||||
logger.info("RagApiClient: Положительный пример успешно добавлен")
|
||||
elif response.status_code == 400:
|
||||
logger.warning(f"RagApiClient: Ошибка валидации при добавлении положительного примера: {response.text}")
|
||||
else:
|
||||
logger.warning(f"RagApiClient: Неожиданный статус при добавлении положительного примера: {response.status_code}")
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(f"RagApiClient: Таймаут при добавлении положительного примера")
|
||||
except httpx.RequestError as e:
|
||||
logger.warning(f"RagApiClient: Ошибка подключения при добавлении положительного примера: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"RagApiClient: Ошибка добавления положительного примера: {e}")
|
||||
|
||||
@track_time("add_negative_example", "rag_client")
|
||||
async def add_negative_example(self, text: str) -> None:
|
||||
"""
|
||||
Добавляет текст как отрицательный пример (отклоненный пост).
|
||||
|
||||
Args:
|
||||
text: Текст отклоненного поста
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
if not text or not text.strip():
|
||||
return
|
||||
|
||||
try:
|
||||
# Формируем заголовки (добавляем X-Test-Mode если включен тестовый режим)
|
||||
headers = {}
|
||||
if self.test_mode:
|
||||
headers["X-Test-Mode"] = "true"
|
||||
|
||||
response = await self._client.post(
|
||||
f"{self.api_url}/examples/negative",
|
||||
json={"text": text.strip()},
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if response.status_code == 200 or response.status_code == 201:
|
||||
logger.info("RagApiClient: Отрицательный пример успешно добавлен")
|
||||
elif response.status_code == 400:
|
||||
logger.warning(f"RagApiClient: Ошибка валидации при добавлении отрицательного примера: {response.text}")
|
||||
else:
|
||||
logger.warning(f"RagApiClient: Неожиданный статус при добавлении отрицательного примера: {response.status_code}")
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(f"RagApiClient: Таймаут при добавлении отрицательного примера")
|
||||
except httpx.RequestError as e:
|
||||
logger.warning(f"RagApiClient: Ошибка подключения при добавлении отрицательного примера: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"RagApiClient: Ошибка добавления отрицательного примера: {e}")
|
||||
|
||||
async def get_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает статистику от RAG API через endpoint /stats.
|
||||
|
||||
Returns:
|
||||
Словарь со статистикой или пустой словарь при ошибке
|
||||
"""
|
||||
if not self._enabled:
|
||||
return {}
|
||||
|
||||
try:
|
||||
response = await self._client.get(f"{self.api_url}/stats")
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
logger.warning(f"RagApiClient: Неожиданный статус при получении статистики: {response.status_code}")
|
||||
return {}
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(f"RagApiClient: Таймаут при получении статистики")
|
||||
return {}
|
||||
except httpx.RequestError as e:
|
||||
logger.warning(f"RagApiClient: Ошибка подключения при получении статистики: {e}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"RagApiClient: Ошибка получения статистики: {e}")
|
||||
return {}
|
||||
|
||||
def get_stats_sync(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Синхронная версия get_stats для использования в get_stats() ScoringManager.
|
||||
|
||||
Внимание: Это заглушка, реальная статистика будет получена асинхронно.
|
||||
"""
|
||||
return {
|
||||
"enabled": self._enabled,
|
||||
"api_url": self.api_url,
|
||||
"timeout": self.timeout,
|
||||
}
|
||||
223
helper_bot/services/scoring/scoring_manager.py
Normal file
223
helper_bot/services/scoring/scoring_manager.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
Менеджер для объединения всех сервисов скоринга.
|
||||
|
||||
Координирует работу RagApiClient и DeepSeekService,
|
||||
выполняет параллельные запросы и агрегирует результаты.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
from helper_bot.utils.metrics import track_errors, track_time
|
||||
from logs.custom_logger import logger
|
||||
|
||||
from .base import CombinedScore, ScoringResult
|
||||
from .deepseek_service import DeepSeekService
|
||||
from .exceptions import (InsufficientExamplesError, ScoringError,
|
||||
TextTooShortError)
|
||||
from .rag_client import RagApiClient
|
||||
|
||||
|
||||
class ScoringManager:
|
||||
"""
|
||||
Менеджер для управления всеми сервисами скоринга.
|
||||
|
||||
Объединяет RagApiClient и DeepSeekService, выполняет параллельные
|
||||
запросы и агрегирует результаты в единый CombinedScore.
|
||||
|
||||
Attributes:
|
||||
rag_client: HTTP клиент для RAG API
|
||||
deepseek_service: Сервис DeepSeek API
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rag_client: Optional[RagApiClient] = None,
|
||||
deepseek_service: Optional[DeepSeekService] = None,
|
||||
):
|
||||
"""
|
||||
Инициализация менеджера.
|
||||
|
||||
Args:
|
||||
rag_client: HTTP клиент для RAG API (создается автоматически если не передан)
|
||||
deepseek_service: Сервис DeepSeek (создается автоматически если не передан)
|
||||
"""
|
||||
self.rag_client = rag_client
|
||||
self.deepseek_service = deepseek_service
|
||||
|
||||
logger.info(
|
||||
f"ScoringManager инициализирован "
|
||||
f"(rag={rag_client is not None and rag_client.is_enabled}, "
|
||||
f"deepseek={deepseek_service is not None and deepseek_service.is_enabled})"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_any_enabled(self) -> bool:
|
||||
"""Проверяет, включен ли хотя бы один сервис."""
|
||||
rag_enabled = self.rag_client is not None and self.rag_client.is_enabled
|
||||
deepseek_enabled = self.deepseek_service is not None and self.deepseek_service.is_enabled
|
||||
return rag_enabled or deepseek_enabled
|
||||
|
||||
@track_time("score_post", "scoring_manager")
|
||||
@track_errors("scoring_manager", "score_post")
|
||||
async def score_post(self, text: str) -> CombinedScore:
|
||||
"""
|
||||
Рассчитывает скоры для текста поста от всех сервисов.
|
||||
|
||||
Выполняет запросы параллельно для минимизации задержки.
|
||||
|
||||
Args:
|
||||
text: Текст поста для оценки
|
||||
|
||||
Returns:
|
||||
CombinedScore с результатами от всех сервисов
|
||||
"""
|
||||
result = CombinedScore()
|
||||
|
||||
if not text or not text.strip():
|
||||
logger.debug("ScoringManager: Пустой текст, пропускаем скоринг")
|
||||
return result
|
||||
|
||||
# Собираем задачи для параллельного выполнения
|
||||
tasks = []
|
||||
task_names = []
|
||||
|
||||
# RAG API клиент
|
||||
if self.rag_client and self.rag_client.is_enabled:
|
||||
tasks.append(self._get_rag_score(text))
|
||||
task_names.append("rag")
|
||||
|
||||
# DeepSeek сервис
|
||||
if self.deepseek_service and self.deepseek_service.is_enabled:
|
||||
tasks.append(self._get_deepseek_score(text))
|
||||
task_names.append("deepseek")
|
||||
|
||||
if not tasks:
|
||||
logger.debug("ScoringManager: Нет активных сервисов для скоринга")
|
||||
return result
|
||||
|
||||
# Выполняем параллельно
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Обрабатываем результаты
|
||||
for name, res in zip(task_names, results):
|
||||
if isinstance(res, Exception):
|
||||
error_msg = str(res)
|
||||
result.errors[name] = error_msg
|
||||
# Ошибки уже залогированы в сервисах, здесь только предупреждение
|
||||
logger.warning(f"ScoringManager: Ошибка от {name}: {error_msg}")
|
||||
elif res is not None:
|
||||
if name == "rag":
|
||||
result.rag = res
|
||||
elif name == "deepseek":
|
||||
result.deepseek = res
|
||||
|
||||
logger.info(
|
||||
f"ScoringManager: Скоринг завершен "
|
||||
f"(rag={result.rag_score}, deepseek={result.deepseek_score})"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def _get_rag_score(self, text: str) -> Optional[ScoringResult]:
|
||||
"""Получает скор от RAG API."""
|
||||
try:
|
||||
return await self.rag_client.calculate_score(text)
|
||||
except InsufficientExamplesError:
|
||||
# Недостаточно примеров - это не ошибка, просто нет данных
|
||||
logger.info("ScoringManager: RAG - недостаточно примеров")
|
||||
return None
|
||||
except TextTooShortError:
|
||||
# Текст слишком короткий - пропускаем
|
||||
logger.debug("ScoringManager: RAG - текст слишком короткий")
|
||||
return None
|
||||
except Exception as e:
|
||||
# Ошибки уже залогированы в RagApiClient, здесь только пробрасываем
|
||||
raise
|
||||
|
||||
async def _get_deepseek_score(self, text: str) -> Optional[ScoringResult]:
|
||||
"""Получает скор от DeepSeek сервиса."""
|
||||
try:
|
||||
return await self.deepseek_service.calculate_score(text)
|
||||
except TextTooShortError:
|
||||
# Текст слишком короткий - пропускаем
|
||||
logger.debug("ScoringManager: DeepSeek - текст слишком короткий")
|
||||
return None
|
||||
except Exception as e:
|
||||
# Ошибки уже залогированы в DeepSeekService, здесь только пробрасываем
|
||||
raise
|
||||
|
||||
@track_time("on_post_published", "scoring_manager")
|
||||
async def on_post_published(self, text: str) -> None:
|
||||
"""
|
||||
Вызывается при публикации поста.
|
||||
|
||||
Добавляет текст как положительный пример для обучения RAG.
|
||||
|
||||
Args:
|
||||
text: Текст опубликованного поста
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return
|
||||
|
||||
tasks = []
|
||||
|
||||
if self.rag_client and self.rag_client.is_enabled:
|
||||
tasks.append(self.rag_client.add_positive_example(text))
|
||||
|
||||
if self.deepseek_service and self.deepseek_service.is_enabled:
|
||||
tasks.append(self.deepseek_service.add_positive_example(text))
|
||||
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
logger.info("ScoringManager: Добавлен положительный пример")
|
||||
|
||||
@track_time("on_post_declined", "scoring_manager")
|
||||
async def on_post_declined(self, text: str) -> None:
|
||||
"""
|
||||
Вызывается при отклонении поста.
|
||||
|
||||
Добавляет текст как отрицательный пример для обучения RAG.
|
||||
|
||||
Args:
|
||||
text: Текст отклоненного поста
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return
|
||||
|
||||
tasks = []
|
||||
|
||||
if self.rag_client and self.rag_client.is_enabled:
|
||||
tasks.append(self.rag_client.add_negative_example(text))
|
||||
|
||||
if self.deepseek_service and self.deepseek_service.is_enabled:
|
||||
tasks.append(self.deepseek_service.add_negative_example(text))
|
||||
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
logger.info("ScoringManager: Добавлен отрицательный пример")
|
||||
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Закрывает ресурсы всех сервисов."""
|
||||
if self.deepseek_service:
|
||||
await self.deepseek_service.close()
|
||||
|
||||
if self.rag_client:
|
||||
await self.rag_client.close()
|
||||
|
||||
async def get_stats(self) -> dict:
|
||||
"""Возвращает статистику всех сервисов."""
|
||||
stats = {
|
||||
"any_enabled": self.is_any_enabled,
|
||||
}
|
||||
|
||||
if self.rag_client:
|
||||
# Получаем статистику асинхронно от API
|
||||
rag_stats = await self.rag_client.get_stats()
|
||||
stats["rag"] = rag_stats if rag_stats else self.rag_client.get_stats_sync()
|
||||
|
||||
if self.deepseek_service:
|
||||
stats["deepseek"] = self.deepseek_service.get_stats()
|
||||
|
||||
return stats
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from database.async_db import AsyncBotDB
|
||||
from dotenv import load_dotenv
|
||||
from helper_bot.utils.s3_storage import S3StorageService
|
||||
from logs.custom_logger import logger
|
||||
|
||||
|
||||
class BaseDependencyFactory:
|
||||
@@ -15,6 +16,7 @@ class BaseDependencyFactory:
|
||||
load_dotenv(env_path)
|
||||
|
||||
self.settings = {}
|
||||
self._project_dir = project_dir
|
||||
|
||||
database_path = os.getenv('DATABASE_PATH', 'database/tg-bot-database.db')
|
||||
if not os.path.isabs(database_path):
|
||||
@@ -24,6 +26,9 @@ class BaseDependencyFactory:
|
||||
|
||||
self._load_settings_from_env()
|
||||
self._init_s3_storage()
|
||||
|
||||
# ScoringManager инициализируется лениво
|
||||
self._scoring_manager = None
|
||||
|
||||
def _load_settings_from_env(self):
|
||||
"""Загружает настройки из переменных окружения."""
|
||||
@@ -59,6 +64,22 @@ class BaseDependencyFactory:
|
||||
'bucket_name': os.getenv('S3_BUCKET_NAME', ''),
|
||||
'region': os.getenv('S3_REGION', 'us-east-1')
|
||||
}
|
||||
|
||||
# Настройки ML-скоринга
|
||||
self.settings['Scoring'] = {
|
||||
# RAG API
|
||||
'rag_enabled': self._parse_bool(os.getenv('RAG_ENABLED', 'false')),
|
||||
'rag_api_url': os.getenv('RAG_API_URL', ''),
|
||||
'rag_api_key': os.getenv('RAG_API_KEY', ''),
|
||||
'rag_api_timeout': self._parse_int(os.getenv('RAG_API_TIMEOUT', '30')),
|
||||
'rag_test_mode': self._parse_bool(os.getenv('RAG_TEST_MODE', 'false')),
|
||||
# DeepSeek
|
||||
'deepseek_enabled': self._parse_bool(os.getenv('DEEPSEEK_ENABLED', 'false')),
|
||||
'deepseek_api_key': os.getenv('DEEPSEEK_API_KEY', ''),
|
||||
'deepseek_api_url': os.getenv('DEEPSEEK_API_URL', 'https://api.deepseek.com/v1/chat/completions'),
|
||||
'deepseek_model': os.getenv('DEEPSEEK_MODEL', 'deepseek-chat'),
|
||||
'deepseek_timeout': self._parse_int(os.getenv('DEEPSEEK_TIMEOUT', '30')),
|
||||
}
|
||||
|
||||
def _init_s3_storage(self):
|
||||
"""Инициализирует S3StorageService если S3 включен."""
|
||||
@@ -84,6 +105,13 @@ class BaseDependencyFactory:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
def _parse_float(self, value: str) -> float:
|
||||
"""Парсит строковое значение в float."""
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
def get_settings(self):
|
||||
return self.settings
|
||||
@@ -95,6 +123,79 @@ class BaseDependencyFactory:
|
||||
def get_s3_storage(self) -> Optional[S3StorageService]:
|
||||
"""Возвращает S3StorageService если S3 включен, иначе None."""
|
||||
return self.s3_storage
|
||||
|
||||
def _init_scoring_manager(self):
|
||||
"""
|
||||
Инициализирует ScoringManager с RAG API клиентом и DeepSeek сервисом.
|
||||
|
||||
Вызывается лениво при первом обращении к get_scoring_manager().
|
||||
"""
|
||||
from helper_bot.services.scoring import (DeepSeekService, RagApiClient,
|
||||
ScoringManager)
|
||||
|
||||
scoring_config = self.settings['Scoring']
|
||||
|
||||
# Инициализация RAG API клиента
|
||||
rag_client = None
|
||||
if scoring_config['rag_enabled']:
|
||||
api_url = scoring_config['rag_api_url']
|
||||
api_key = scoring_config['rag_api_key']
|
||||
|
||||
if not api_url or not api_key:
|
||||
logger.warning("RAG включен, но не указаны RAG_API_URL или RAG_API_KEY")
|
||||
else:
|
||||
rag_client = RagApiClient(
|
||||
api_url=api_url,
|
||||
api_key=api_key,
|
||||
timeout=scoring_config['rag_api_timeout'],
|
||||
test_mode=scoring_config['rag_test_mode'],
|
||||
enabled=True,
|
||||
)
|
||||
logger.info(f"RagApiClient инициализирован: {api_url} (test_mode={scoring_config['rag_test_mode']})")
|
||||
|
||||
# Инициализация DeepSeek сервиса
|
||||
deepseek_service = None
|
||||
if scoring_config['deepseek_enabled'] and scoring_config['deepseek_api_key']:
|
||||
deepseek_service = DeepSeekService(
|
||||
api_key=scoring_config['deepseek_api_key'],
|
||||
api_url=scoring_config['deepseek_api_url'],
|
||||
model=scoring_config['deepseek_model'],
|
||||
timeout=scoring_config['deepseek_timeout'],
|
||||
enabled=True,
|
||||
)
|
||||
logger.info(f"DeepSeekService инициализирован: {scoring_config['deepseek_model']}")
|
||||
|
||||
# Создаем менеджер
|
||||
self._scoring_manager = ScoringManager(
|
||||
rag_client=rag_client,
|
||||
deepseek_service=deepseek_service,
|
||||
)
|
||||
|
||||
return self._scoring_manager
|
||||
|
||||
def get_scoring_manager(self):
|
||||
"""
|
||||
Возвращает ScoringManager для ML-скоринга постов.
|
||||
|
||||
Инициализируется лениво при первом вызове.
|
||||
|
||||
Returns:
|
||||
ScoringManager или None если скоринг полностью отключен
|
||||
"""
|
||||
if self._scoring_manager is None:
|
||||
scoring_config = self.settings.get('Scoring', {})
|
||||
|
||||
# Проверяем, включен ли хотя бы один сервис
|
||||
rag_enabled = scoring_config.get('rag_enabled', False)
|
||||
deepseek_enabled = scoring_config.get('deepseek_enabled', False)
|
||||
|
||||
if not rag_enabled and not deepseek_enabled:
|
||||
logger.info("Scoring полностью отключен (RAG и DeepSeek disabled)")
|
||||
return None
|
||||
|
||||
self._init_scoring_manager()
|
||||
|
||||
return self._scoring_manager
|
||||
|
||||
|
||||
_global_instance = None
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import asyncio
|
||||
import html
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import tempfile
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from time import sleep
|
||||
from typing import List, Dict, Any, Optional, TYPE_CHECKING, Union
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
|
||||
|
||||
try:
|
||||
import emoji as _emoji_lib
|
||||
@@ -16,20 +16,16 @@ except ImportError:
|
||||
_emoji_lib_available = False
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.types import InputMediaPhoto, FSInputFile, InputMediaVideo, InputMediaAudio, InputMediaDocument
|
||||
|
||||
from helper_bot.utils.base_dependency_factory import BaseDependencyFactory, get_global_instance
|
||||
from logs.custom_logger import logger
|
||||
from aiogram.types import (FSInputFile, InputMediaAudio, InputMediaDocument,
|
||||
InputMediaPhoto, InputMediaVideo)
|
||||
from database.models import TelegramPost
|
||||
from helper_bot.utils.base_dependency_factory import (BaseDependencyFactory,
|
||||
get_global_instance)
|
||||
from logs.custom_logger import logger
|
||||
|
||||
# Local imports - metrics
|
||||
from .metrics import (
|
||||
track_time,
|
||||
track_errors,
|
||||
db_query_time,
|
||||
track_media_processing,
|
||||
track_file_operations,
|
||||
)
|
||||
from .metrics import (db_query_time, track_errors, track_file_operations,
|
||||
track_media_processing, track_time)
|
||||
|
||||
bdf = get_global_instance()
|
||||
#TODO: поменять архитектуру и подключить правильный BotDB
|
||||
@@ -115,7 +111,16 @@ def determine_anonymity(post_text: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def get_text_message(post_text: str, first_name: str, username: str = None, is_anonymous: Optional[bool] = None):
|
||||
def get_text_message(
|
||||
post_text: str,
|
||||
first_name: str,
|
||||
username: str = None,
|
||||
is_anonymous: Optional[bool] = None,
|
||||
deepseek_score: Optional[float] = None,
|
||||
rag_score: Optional[float] = None,
|
||||
rag_confidence: Optional[float] = None,
|
||||
rag_score_pos_only: Optional[float] = None,
|
||||
):
|
||||
"""
|
||||
Форматирует текст сообщения для публикации в зависимости от наличия ключевых слов "анон" и "неанон"
|
||||
или переданного параметра is_anonymous.
|
||||
@@ -125,6 +130,10 @@ def get_text_message(post_text: str, first_name: str, username: str = None, is_a
|
||||
first_name: Имя автора поста
|
||||
username: Юзернейм автора поста (может быть None)
|
||||
is_anonymous: Флаг анонимности (True - анонимно, False - не анонимно, None - legacy, определяется по тексту)
|
||||
deepseek_score: Скор от DeepSeek API (0.0-1.0, опционально)
|
||||
rag_score: Скор от RAG/ruBERT neg/pos (0.0-1.0, опционально)
|
||||
rag_confidence: Уверенность RAG модели (0.0-1.0, зависит от количества примеров)
|
||||
rag_score_pos_only: Скор RAG только по положительным примерам (0.0-1.0, опционально)
|
||||
|
||||
Returns:
|
||||
str: - Сформированный текст сообщения.
|
||||
@@ -141,21 +150,37 @@ def get_text_message(post_text: str, first_name: str, username: str = None, is_a
|
||||
else:
|
||||
author_info = f"{first_name} (Ник не указан)"
|
||||
|
||||
# Формируем базовый текст
|
||||
# Если передан is_anonymous, используем его, иначе определяем по тексту (legacy)
|
||||
# TODO: Уверен можно укоротить
|
||||
if is_anonymous is not None:
|
||||
if is_anonymous:
|
||||
return f'{safe_post_text}\n\nПост опубликован анонимно'
|
||||
final_text = f'{safe_post_text}\n\nПост опубликован анонимно'
|
||||
else:
|
||||
return f'{safe_post_text}\n\nАвтор поста: {author_info}'
|
||||
final_text = f'{safe_post_text}\n\nАвтор поста: {author_info}'
|
||||
else:
|
||||
# Legacy: определяем по тексту
|
||||
if "неанон" in post_text or "не анон" in post_text:
|
||||
return f'{safe_post_text}\n\nАвтор поста: {author_info}'
|
||||
final_text = f'{safe_post_text}\n\nАвтор поста: {author_info}'
|
||||
elif "анон" in post_text:
|
||||
return f'{safe_post_text}\n\nПост опубликован анонимно'
|
||||
final_text = f'{safe_post_text}\n\nПост опубликован анонимно'
|
||||
else:
|
||||
return f'{safe_post_text}\n\nАвтор поста: {author_info}'
|
||||
final_text = f'{safe_post_text}\n\nАвтор поста: {author_info}'
|
||||
|
||||
# Добавляем блок со скорами если есть
|
||||
if deepseek_score is not None or rag_score is not None or rag_score_pos_only is not None:
|
||||
scores_lines = ["\n📊 Уверенность в одобрении:"]
|
||||
if deepseek_score is not None:
|
||||
scores_lines.append(f"DeepSeek: {deepseek_score:.2f}")
|
||||
if rag_score is not None:
|
||||
rag_line = f"RAG neg/pos: {rag_score:.2f}"
|
||||
if rag_confidence is not None:
|
||||
rag_line += f" (уверенность: {rag_confidence:.0%})"
|
||||
scores_lines.append(rag_line)
|
||||
if rag_score_pos_only is not None:
|
||||
scores_lines.append(f"RAG pos only: {rag_score_pos_only:.2f}")
|
||||
final_text += "\n" + "\n".join(scores_lines)
|
||||
|
||||
return final_text
|
||||
|
||||
@track_time("download_file", "helper_func")
|
||||
@track_errors("helper_func", "download_file")
|
||||
@@ -653,7 +678,7 @@ async def send_media_group_to_channel(bot, chat_id: int, post_content: List, pos
|
||||
@track_errors("helper_func", "send_text_message")
|
||||
async def send_text_message(chat_id, message: types.Message, post_text: str, markup: types.ReplyKeyboardMarkup = None):
|
||||
from .rate_limiter import send_with_rate_limit
|
||||
|
||||
|
||||
# Экранируем post_text для безопасного использования в HTML
|
||||
safe_post_text = html.escape(str(post_text)) if post_text else ""
|
||||
|
||||
|
||||
@@ -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!👋🏼&Меня зовут Виби, я бот канала 'Влюбленный Бийск'❤🤖"
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from aiogram.fsm.state import StatesGroup, State
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class StateUser(StatesGroup):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
import os
|
||||
import sys
|
||||
|
||||
from loguru import logger
|
||||
|
||||
# Remove default handler
|
||||
|
||||
@@ -30,4 +30,7 @@ typing_extensions~=4.12.2
|
||||
emoji~=2.8.0
|
||||
|
||||
# S3 Storage (для хранения медиафайлов опубликованных постов)
|
||||
aioboto3>=12.0.0
|
||||
aioboto3>=12.0.0
|
||||
|
||||
# HTTP клиент для RAG API
|
||||
httpx>=0.24.0
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
83
scripts/add_ml_scores_columns.py
Normal file
83
scripts/add_ml_scores_columns.py
Normal file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Миграция: Добавление колонки для ML-скоринга постов.
|
||||
|
||||
Добавляет:
|
||||
- ml_scores (TEXT/JSON) - JSON с результатами оценки от разных моделей
|
||||
|
||||
Структура ml_scores:
|
||||
{
|
||||
"deepseek": {"score": 0.75, "model": "deepseek-chat", "ts": 1706198400},
|
||||
"rag": {"score": 0.90, "model": "rubert-base-cased", "ts": 1706198400}
|
||||
}
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем корень проекта в путь
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
import aiosqlite
|
||||
|
||||
# Пытаемся импортировать logger, если не получается - используем стандартный
|
||||
try:
|
||||
from logs.custom_logger import logger
|
||||
except ImportError:
|
||||
import logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_DB_PATH = "database/tg-bot-database.db"
|
||||
|
||||
|
||||
async def column_exists(conn: aiosqlite.Connection, table: str, column: str) -> bool:
|
||||
"""Проверяет существование колонки в таблице."""
|
||||
cursor = await conn.execute(f"PRAGMA table_info({table})")
|
||||
columns = await cursor.fetchall()
|
||||
return any(col[1] == column for col in columns)
|
||||
|
||||
|
||||
async def main(db_path: str) -> None:
|
||||
"""
|
||||
Основная функция миграции.
|
||||
|
||||
Добавляет колонку ml_scores в таблицу post_from_telegram_suggest.
|
||||
Миграция идемпотентна - можно запускать повторно без ошибок.
|
||||
"""
|
||||
db_path = os.path.abspath(db_path)
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
logger.error(f"База данных не найдена: {db_path}")
|
||||
return
|
||||
|
||||
async with aiosqlite.connect(db_path) as conn:
|
||||
await conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
# Проверяем и добавляем колонку ml_scores
|
||||
if not await column_exists(conn, "post_from_telegram_suggest", "ml_scores"):
|
||||
await conn.execute(
|
||||
"ALTER TABLE post_from_telegram_suggest ADD COLUMN ml_scores TEXT"
|
||||
)
|
||||
logger.info("Колонка ml_scores добавлена в post_from_telegram_suggest")
|
||||
else:
|
||||
logger.info("Колонка ml_scores уже существует")
|
||||
|
||||
await conn.commit()
|
||||
logger.info("Миграция add_ml_scores_columns завершена успешно")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Добавление колонки ml_scores для ML-скоринга"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=os.environ.get("DATABASE_PATH", DEFAULT_DB_PATH),
|
||||
help="Путь к БД",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(args.db))
|
||||
@@ -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))
|
||||
241
scripts/apply_migrations.py
Normal file
241
scripts/apply_migrations.py
Normal file
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
123
scripts/drop_vector_hash_column.py
Normal file
123
scripts/drop_vector_hash_column.py
Normal file
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
Скрипт для диагностики и очистки проблем с голосовыми файлами
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем корневую директорию проекта в путь
|
||||
|
||||
@@ -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
|
||||
|
||||
# Импортируем моки в самом начале
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""
|
||||
Моки для тестового окружения
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
|
||||
# Патчим загрузку настроек до импорта модулей
|
||||
def setup_test_mocks():
|
||||
"""Настройка моков для тестов"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user