diff --git a/.cursor/implementation-plan-features.md b/.cursor/implementation-plan-features.md new file mode 100644 index 0000000..3189d74 --- /dev/null +++ b/.cursor/implementation-plan-features.md @@ -0,0 +1,428 @@ +# План реализации фич Telegram Helper Bot + +> Документ создан: 28 февраля 2025 +> Ветка: `dev-*` +> Статус: План утверждён + +--- + +## Обзор фич + +1. **Пагинация заблокированных пользователей** — сортировка по дате бана, единая логика текста и кнопок +2. **Обогащение сообщений пользователей админам** — данные о пользователе при обращении в поддержку +3. **Причина бана «Последний пост»** — замена «Спам» на «Последний пост» при быстром бане из поста +4. **Похожие посты за 24ч** — проверка на дубликаты через RAG (threshold >0.9) +5. **Авто-публикация/отклонение по RAG** — задел на будущее (>0.8 publish, <0.4 decline) +6. **ML Scoring Статистика** — восстановить полный вывод (модель, примеры, device) вместо fallback (API URL, статус) + +--- + +## Решения по уточняющим вопросам + +| Вопрос | Решение | +|--------|---------| +| Пагинация | Единый источник данных, единый `items_per_page` | +| message_id при forward | Осознанный workaround (n+1). При переходе на send — использовать `returned_message.message_id` | +| RAG similar | Новый endpoint в RAG. Нужна **отдельная коллекция** для submitted-постов (не позитив/негатив) | +| Скор для авто-решений | Только `rag_score` | +| Ветки | `dev-*` | + +--- + +## Проверка: message_id при forward + +**Telegram Bot API:** `forwardMessage` возвращает объект `Message` — это **новое** сообщение в целевом чате со своим `message_id`. Telegram присваивает `message_id` в целевом чате — он не обязан быть `n+1` от исходного. + +Если всё работает — возможно, бот единственный отправитель в `group_for_message`, и id часто идут подряд. Рекомендация: при переходе на `send_message` обязательно сохранять `message_id` из возвращаемого объекта. + +--- + +## RAG: коллекция для похожих постов + +- **Позитив/негатив** — примеры для скоринга модерации +- **Похожие посты** — сравнение с другими submitted-постами за 24ч + +Endpoint `/similar` должен искать по **отдельной коллекции submitted-постов** (с `created_at`), а не по позитиву/негативу. Нужно: +- Новая коллекция в RAG (например, `posts_submitted`) с полями: `text`, `vector`, `created_at`, `post_id` +- Endpoint `POST /similar`: `{"text": "...", "threshold": 0.9, "hours": 24}` → список похожих постов +- При каждом suggest — добавлять пост в эту коллекцию (или вызывать endpoint RAG для индексации) + +--- + +## Пошаговый план реализации + +### Этап 0: Подготовка + +| Шаг | Действие | +|-----|----------| +| 0.1 | Создать ветку `dev-N` (N — следующий номер) от `main` | +| 0.2 | Убедиться, что локально проходит `make code-quality` (или `isort`, `black`, `flake8`, `pytest`) | + +--- + +### Этап 1: Пагинация заблокированных пользователей + +| Шаг | Действие | +|-----|----------| +| 1.1 | В `BlacklistRepository`: добавить `ORDER BY created_at DESC` в `get_all_users` и `get_all_users_no_limit` | +| 1.2 | Ввести единый `items_per_page = 9` в `create_keyboard_with_pagination` и во всех местах пагинации | +| 1.3 | Создать единую функцию `get_banned_users_data(page, items_per_page)` в `AdminService` или `helper_func`, возвращающую `(message_text, buttons_list)` для заданной страницы | +| 1.4 | Обновить `get_banned_users_list` и `get_banned_users_buttons` — использовать общий источник и пагинацию | +| 1.5 | Обновить `admin_handlers.get_banned_users` и `callback_handlers.change_page` — вызывать единую функцию с `page` | +| 1.6 | Прогнать тесты `test_keyboards_and_filters`, `test_callback_handlers`, `test_admin_handlers` | + +**Затронутые файлы:** +- `database/repositories/blacklist_repository.py` +- `helper_bot/keyboards/keyboards.py` +- `helper_bot/utils/helper_func.py` +- `helper_bot/handlers/admin/services.py` +- `helper_bot/handlers/admin/admin_handlers.py` +- `helper_bot/handlers/callback/callback_handlers.py` + +--- + +### Этап 2: Обогащение сообщений пользователей админам + +| Шаг | Действие | +|-----|----------| +| 2.1 | Добавить в `AsyncBotDB`/репозитории методы: `get_posts_count_by_author`, `get_last_post_by_author`, `get_ban_history_count`, `get_last_ban_info`, `get_user_date_added` | +| 2.2 | Добавить в `BlacklistHistoryRepository` методы для истории банов (количество, последний бан) | +| 2.3 | В `UserService` или отдельном сервисе: метод `format_user_message_for_admins(user_id, message_text)` — собирает текст с данными пользователя | +| 2.4 | В `resend_message_in_group_for_message`: заменить `forward` на `send_message` с обогащённым текстом (имя, ник, id, посты, последний пост, баны, дата регистрации) | +| 2.5 | Сохранять `message_id` из результата `send_message` в `user_messages` (вместо `message.message_id + 1`) | +| 2.6 | Проверить, что `get_user_by_message_id` по-прежнему возвращает `user_id` для ответа админа | +| 2.7 | Обновить/добавить тесты для `resend_message_in_group_for_message` и `AdminReplyService` | + +**Данные для обогащения:** +- Количество постов от пользователя +- Последний пост пользователя (текст) +- Количество банов +- Дата и причина последнего бана (если был) +- Дата создания пользователя в БД (первый контакт с ботом) +- Имя, ник, id пользователя (обязательно при send вместо forward) + +**Шаблон итогового сообщения для админов:** + +```text +👤 От: Иван Петров (@ivan_petrov) | ID: 123456789 + +📊 Постов в базе: 5 +📝 Последний пост: "Привет, хочу поделиться мыслями о..." +📅 В боте с: 15.01.2025 + +🚫 Банов: 2 + Последний: 20.02.2025, причина «Спам», истёк 27.02.2025 + +--- +**Сообщение пользователя:** + +**Почему удалили мой пост?** +``` + +**Правила форматирования:** +- Секции «Банов: 0» и «Последний: …» — показывать только если были баны +- «Последний пост» — обрезать до ~80 символов + «…» если длиннее; если постов нет — «Нет постов» +- Даты в формате `DD.MM.YYYY` или `DD.MM.YYYY HH:MM` для разбана +- Разделитель `---` перед текстом сообщения пользователя + +**Для тестов:** использовать этот шаблон как эталон; проверять наличие всех секций, порядок полей, экранирование HTML в имени/нике/тексте. + +**Затронутые файлы:** +- `database/async_db.py` +- `database/repositories/post_repository.py` +- `database/repositories/blacklist_history_repository.py` +- `database/repositories/user_repository.py` +- `helper_bot/handlers/private/private_handlers.py` +- `helper_bot/handlers/private/services.py` +- `helper_bot/handlers/group/services.py` + +--- + +### Этап 3: Причина бана «Последний пост» + +| Шаг | Действие | +|-----|----------| +| 3.1 | В `callback/services.py` в `ban_user_from_post`: заменить `message_for_user="Спам"` на `message_for_user="Последний пост"` | +| 3.2 | Обновить тесты, где ожидается «Спам» | + +**Затронутые файлы:** +- `helper_bot/handlers/callback/services.py` +- `tests/test_callback_services.py` (если есть) + +--- + +### Этап 4: Похожие посты (RAG + бот) + +**Разделение работ:** +- **RAG сервис** — промпт в `.cursor/prompt-stage-4-similar-posts.md` +- **Подключение в боте** — ниже, раздел 4.2 + +#### 4.0. Текущая архитектура RAG сервиса + +**Путь:** `/Users/andrejkatyhin/Work/PycharmProjects/rag-service` + +| Компонент | Описание | +|-----------|----------| +| **VectorStore** (`app/storage/vector_store.py`) | In-memory хранилище векторов. `_positive_vectors` / `_negative_vectors` — для модерации. Персистентность: `positive_embeddings.npy`, `negative_embeddings.npy` или `vectors.npz`. Косинусное сходство через `np.dot` для нормализованных векторов. | +| **RAGService** (`app/services/rag_service.py`) | Модель `sentence-transformers/all-MiniLM-L12-v2` (384 dim). `get_embedding(text)`, `calculate_score(text)`, `add_positive_example`, `add_negative_example`. | +| **API** (`app/api/routes.py`) | `POST /api/v1/score`, `POST /api/v1/examples/positive`, `POST /api/v1/examples/negative`, `GET /api/v1/stats`, `POST /api/v1/warmup`, `GET/PUT /api/v1/scoring/params`. | +| **Config** (`app/config.py`) | `RAG_VECTORS_PATH`, `RAG_MAX_EXAMPLES`, `vector_dim=384`. | + +**Важно:** Позитив/негатив — отдельные коллекции без `created_at`. Для похожих постов нужна **третья** коллекция с временными метками. + +--- + +#### 4.1. Работы в RAG сервисе + +##### 4.1.1. Расширить VectorStore + +**Файл:** `app/storage/vector_store.py` + +Добавить третью коллекцию для submitted-постов: + +```python +# Новые атрибуты (аналогично _positive_vectors) +self._submitted_vectors: list = [] +self._submitted_hashes: list = [] +self._submitted_created_at: list = [] # Unix timestamps +self._submitted_post_ids: list = [] # post_id из бота +self._submitted_texts: list = [] # текст поста (для возврата в similar) +self._submitted_rag_scores: list = [] # rag_score на момент добавления +``` + +**Новые методы:** + +| Метод | Описание | +|-------|----------| +| `add_submitted(vector, text_hash, created_at, post_id=None, text="", rag_score=None)` | Добавить пост в коллекцию submitted. FIFO при превышении `max_submitted` (новый лимит, например 5000). | +| `find_similar_submitted(vector, threshold, hours)` | Возвращает: `List[dict]` с полями `similarity`, `created_at`, `post_id`, `text`, `rag_score`. Фильтрует по `created_at >= now - hours*3600`. Сравнивает с `_submitted_vectors` через `np.dot`. Возвращает только те, где similarity >= threshold. | + +**Персистентность:** Добавить в `save_to_disk` / `_load_from_disk` сохранение submitted-коллекции. Файл `submitted_embeddings.npz` с полями: `vectors`, `hashes`, `created_at`, `post_ids`, `texts`, `rag_scores`. + +**Конфиг:** Добавить `RAG_MAX_SUBMITTED` (default 5000), `RAG_SUBMITTED_PATH` (путь к файлу submitted). + +##### 4.1.2. Расширить RAGService + +**Файл:** `app/services/rag_service.py` + +| Метод | Описание | +|-------|----------| +| `add_submitted_post(text, post_id=None, rag_score=None)` | Очистить текст, получить embedding, `add_submitted` в vector_store. Сохраняет `text` и `rag_score` для возврата в similar. Вызывается при каждом suggest (бот передаёт rag_score из скоринга). | +| `find_similar_posts(text, threshold=0.9, hours=24)` | Получить embedding, вызвать `vector_store.find_similar_submitted`. Вернуть список похожих с полями: `similarity`, `created_at`, `post_id`, `text`, `rag_score`. | + +##### 4.1.3. Добавить API endpoint + +**Файл:** `app/api/routes.py` + +**POST /api/v1/similar** + +Возвращает: количество похожих постов, текст каждого, similarity (косинусное сходство), присвоенный rag_score на момент добавления. + +```python +# Request +class SimilarRequest(BaseModel): + text: str = Field(..., min_length=1) + threshold: float = Field(default=0.9, ge=0.0, le=1.0) + hours: int = Field(default=24, ge=1, le=168) # 1ч–7дней + +# Response +class SimilarPostItem(BaseModel): + similarity: float # косинусное сходство (0.0–1.0) + created_at: int # Unix timestamp + post_id: Optional[int] = None + text: str # текст похожего поста + rag_score: Optional[float] = None # rag_score на момент добавления + +class SimilarResponse(BaseModel): + similar_count: int + similar_posts: List[SimilarPostItem] +``` + +**POST /api/v1/submitted** + +```python +# Request +class SubmittedRequest(BaseModel): + text: str = Field(..., min_length=1) + post_id: Optional[int] = None + rag_score: Optional[float] = None # для возврата в similar + +# Response +class SubmittedResponse(BaseModel): + success: bool + message: str + submitted_count: int +``` + +**Примечание:** `POST /submitted` вызывается ботом при каждом suggest (после сохранения поста в БД). `POST /similar` вызывается ботом **перед** отправкой в группу модерации — чтобы проверить, есть ли похожие посты за последние сутки. + +##### 4.1.4. Схемы и исключения + +**Файл:** `app/schemas.py` — добавить `SimilarRequest`, `SimilarResponse`, `SimilarPostItem`, `SubmittedRequest`, `SubmittedResponse`. + +**Файл:** `app/exceptions.py` — при необходимости добавить `SubmittedStoreError` (если коллекция пуста и т.п.). + +##### 4.1.5. Автоочистка submitted (опционально) + +В `autosave_loop` или отдельно: периодически удалять из `_submitted_*` записи старше N часов (например, 48), чтобы не раздувать память. + +--- + +#### 4.2. Подключение в Telegram Helper Bot + +> RAG сервис реализуется отдельно (промпт: `.cursor/prompt-stage-4-similar-posts.md`). Ниже — интеграция в бота. + +##### 4.2.1. RagApiClient (`helper_bot/services/scoring/rag_client.py`) + +Добавить методы: + +- `find_similar_posts(text, threshold=0.9, hours=24)` — POST на `{api_url}/similar`, body `{"text": text, "threshold": threshold, "hours": hours}`. Вернуть `SimilarResponse` (или dataclass/dict) или `None` при ошибке. +- `add_submitted_post(text, post_id=None, rag_score=None)` — POST на `{api_url}/submitted`, body `{"text": text, "post_id": post_id, "rag_score": rag_score}`. При ошибке — логировать, не падать. + +Оба метода проверяют `self._enabled` и не делают запросы, если RAG отключён. + +##### 4.2.2. PostService (`helper_bot/handlers/private/services.py`) + +В `_process_post_background` и `_process_media_group_background`: + +**Порядок вызовов:** +1. Получить скоры (`_get_scores_with_error_handling`) — уже есть `rag_score`. +2. **Перед** отправкой: вызвать `find_similar_posts(original_raw_text, 0.9, 24)`. Если RAG недоступен или ошибка — не падать, пропустить. +3. Если `similar_count > 0`: добавить в `post_text` строку `\n\n⚠️ Похожий пост за последние 24ч (совпадение {max_similarity:.0%})`. +4. Отправить пост в группу модерации. +5. Сохранить в БД. +6. **После** успешной отправки: вызвать `add_submitted_post(original_raw_text, sent_message.message_id, rag_score)` — в фоне. `rag_score` из шага 1. + +**Важно:** Проверка similar — **до** добавления текущего поста в submitted. + +##### 4.2.3. Доступ к RagApiClient + +`RagApiClient` создаётся через `ScoringManager` или `BaseDependencyFactory`. PostService должен иметь доступ к `rag_client` (или `scoring_manager`). При необходимости добавить методы в `ScoringManager` как прокси к RAG. + +##### 4.2.4. Обработка ошибок + +При недоступности RAG — не падать, не добавлять предупреждение и не индексировать. + +--- + +#### 4.3. Порядок вызовов в боте + +```text +1. Пользователь отправляет пост +2. PostService._process_post_background: + a) Получить скоры (rag_score, confidence, ...) + b) find_similar_posts(text, 0.9, 24) — есть ли похожие? (возвращает count, text, similarity, rag_score) + c) Если да — добавить предупреждение в post_text + d) Отправить пост в группу модерации + e) Сохранить в БД (add_post) + f) add_submitted_post(text, message_id, rag_score) — индексировать в RAG +``` + +**Важно:** Проверка similar делается **до** добавления текущего поста в submitted, иначе пост будет похож сам на себя. + +--- + +#### 4.4. Затронутые файлы + +| Репозиторий | Файлы | +|-------------|-------| +| **rag-service** | `app/storage/vector_store.py`, `app/services/rag_service.py`, `app/api/routes.py`, `app/schemas.py`, `app/config.py`, `app/main.py` (описание в docs) | +| **telegram-helper-bot** | `helper_bot/services/scoring/rag_client.py`, `helper_bot/handlers/private/services.py` | + +--- + +### Этап 5: Авто-публикация/отклонение (задел на будущее) + +| Шаг | Действие | +|-----|----------| +| 5.1 | В `PostService._process_post_background`: после получения `rag_score` проверять пороги | +| 5.2 | Если `rag_score >= 0.8`: не показывать кнопки модерации, сразу публиковать (или вызывать логику publish) | +| 5.3 | Если `rag_score <= 0.4`: сразу отклонять (decline) | +| 5.4 | Добавить флаги в `.env` (например, `AUTO_PUBLISH_ENABLED`, `AUTO_DECLINE_ENABLED`) — по умолчанию `false` | +| 5.5 | Реализацию оформить как выключенную по умолчанию; включение — через конфиг | + +**Затронутые файлы:** +- `helper_bot/handlers/private/services.py` +- `helper_bot/config/` или `.env` + +--- + +### Этап 5.5: ML Scoring Статистика — восстановить полный вывод + +**Проблема:** Раньше «📊 ML Статистика» показывала детали (модель, device, кол-во примеров, размерность). Теперь только API URL и статус. + +**Причина:** Бот использует fallback (`get_stats_sync`) когда `RagApiClient.get_stats()` возвращает пустой результат. Это происходит при: +- 401/403 (ошибка авторизации) +- таймауте или ошибке соединения +- неверном формате ответа от API + +**Задачи:** + +| Шаг | Действие | +|-----|----------| +| 5.5.1 | Проверить, что RAG API `GET /stats` доступен с бота (сеть, CORS, API key). | +| 5.5.2 | Убедиться, что `RagApiClient.get_stats()` передаёт заголовок `X-API-Key` и корректно обрабатывает 200. | +| 5.5.3 | Проверить контракт ответа: RAG возвращает `model_name`, `model_loaded`, `device`, `vector_store` (positive_count, negative_count, total_count, vector_dim, max_examples). | +| 5.5.4 | При ошибке API — логировать причину (status, body) и при необходимости улучшить fallback-сообщение (например, «API недоступен: …»). | +| 5.5.5 | Добавить тесты для `get_ml_stats` с моком API (успешный ответ и fallback). | + +**Затронутые файлы:** +- `helper_bot/handlers/admin/admin_handlers.py` (get_ml_stats) +- `helper_bot/services/scoring/rag_client.py` (get_stats, get_stats_sync) +- `helper_bot/services/scoring/scoring_manager.py` (get_stats) + +--- + +### Этап 6: Тесты и качество кода + +| Шаг | Действие | +|-----|----------| +| 6.1 | Прогнать все тесты: `pytest tests/ -v` | +| 6.2 | `make code-quality` (или `isort`, `black`, `flake8`) | +| 6.3 | При необходимости обновить моки и фикстуры | + +--- + +### Этап 7: Release Notes и деплой + +| Шаг | Действие | +|-----|----------| +| 7.1 | Создать `docs/RELEASE_NOTES_DEV-N.md` по шаблону из `.cursor/rules/release-notes-template.md` | +| 7.2 | Коммиты в формате: `feat:`, `fix:`, `refactor:` | +| 7.3 | Push в `dev-N` → CI (тесты, code quality) | +| 7.4 | Создать/обновить PR в `main` | +| 7.5 | После мержа — деплой по `deploy.yml` (если настроен в prod) | + +--- + +## Зависимости между этапами + +``` +Этап 0 → Этап 1, 2, 3 (можно параллельно) +Этап 1, 2, 3 → Этап 6 +Этап 4 зависит от RAG (отдельный сервис) +Этап 5 можно делать после 4 или независимо +Этап 5.5 — независимо (можно параллельно с 1–3) +``` + +--- + +## Рекомендуемый порядок реализации + +1. Этап 0 — подготовка +2. Этапы 1, 2, 3 — независимо, можно в любом порядке +3. Этап 4 — после готовности RAG +4. Этап 5 — после 4 или параллельно с 1–3 +5. Этап 5.5 — разобраться с ML Scoring Статистикой (можно параллельно) +6. Этап 6 — перед PR +7. Этап 7 — после ревью и мержа + +--- + +## Ссылки на документацию проекта + +- `.cursor/rules/my-custom-rule.mdc` — общие правила +- `.cursor/rules/architecture.md` — архитектура +- `.cursor/rules/handlers-patterns.md` — паттерны handlers +- `.cursor/rules/release-notes-template.md` — шаблон Release Notes +- `prod/.cursor/rules/my-custom-rule.mdc` — CI/CD, ветки, деплой diff --git a/.cursor/prompt-stage-4-similar-posts.md b/.cursor/prompt-stage-4-similar-posts.md new file mode 100644 index 0000000..61b3b66 --- /dev/null +++ b/.cursor/prompt-stage-4-similar-posts.md @@ -0,0 +1,126 @@ +# Промпт: Реализация Этапа 4 — RAG сервис (похожие посты) + +Скопируй этот промпт нейросети для реализации фичи «похожие посты» в RAG сервисе. + +> Подключение к Telegram боту описано в `.cursor/implementation-plan-features.md` (Этап 4, раздел 4.2). + +--- + +## Задача + +Добавить в RAG сервис третью коллекцию для submitted-постов. Endpoints: +- `POST /similar` — поиск похожих постов за N часов (threshold, text) +- `POST /submitted` — добавление поста в коллекцию (для индексации ботом) + +--- + +## Контекст + +**Путь:** `/Users/andrejkatyhin/Work/PycharmProjects/rag-service` + +RAG уже имеет: +- `VectorStore` с `_positive_vectors` / `_negative_vectors` — для модерации (score) +- `RAGService` с `get_embedding`, `calculate_score`, `add_positive_example`, `add_negative_example` +- API: `POST /score`, `POST /examples/positive`, `POST /examples/negative`, `GET /stats` + +Нужно добавить **третью коллекцию** для submitted-постов (с `created_at`, `text`, `rag_score`). + +--- + +## 1. VectorStore (`app/storage/vector_store.py`) + +Добавь коллекцию submitted: + +```python +self._submitted_vectors: list = [] +self._submitted_hashes: list = [] +self._submitted_created_at: list = [] # Unix timestamps +self._submitted_post_ids: list = [] +self._submitted_texts: list = [] +self._submitted_rag_scores: list = [] +``` + +**Методы:** +- `add_submitted(vector, text_hash, created_at, post_id=None, text="", rag_score=None)` — добавить пост. FIFO при превышении `max_submitted` (новый параметр в конструкторе, default 5000). +- `find_similar_submitted(vector, threshold, hours)` — вернуть `List[dict]` с полями `similarity`, `created_at`, `post_id`, `text`, `rag_score`. Фильтр: `created_at >= now - hours*3600`. Сравнение через `np.dot` (как для positive/negative). Только те, где similarity >= threshold. + +**Персистентность:** Сохранять/загружать submitted в отдельный файл (например, `submitted_embeddings.npz`). Поля: `vectors`, `hashes`, `created_at`, `post_ids`, `texts`, `rag_scores`. Используй `np.array(..., dtype=object)` для строк и `allow_pickle=True` при необходимости. + +--- + +## 2. Config (`app/config.py`) + +Добавь: +- `RAG_MAX_SUBMITTED` (default 5000) +- `RAG_SUBMITTED_PATH` (default `data/vectors/submitted.npz`) + +--- + +## 3. RAGService (`app/services/rag_service.py`) + +- `add_submitted_post(text, post_id=None, rag_score=None)` — очистить текст, получить embedding, вызвать `vector_store.add_submitted` с `created_at=int(time.time())`, `text`, `rag_score`. +- `find_similar_posts(text, threshold=0.9, hours=24)` — получить embedding, вызвать `vector_store.find_similar_submitted`, вернуть результат. + +--- + +## 4. Схемы (`app/schemas.py`) + +```python +class SimilarRequest(BaseModel): + text: str = Field(..., min_length=1) + threshold: float = Field(default=0.9, ge=0.0, le=1.0) + hours: int = Field(default=24, ge=1, le=168) + +class SimilarPostItem(BaseModel): + similarity: float + created_at: int + post_id: Optional[int] = None + text: str + rag_score: Optional[float] = None + +class SimilarResponse(BaseModel): + similar_count: int + similar_posts: List[SimilarPostItem] + +class SubmittedRequest(BaseModel): + text: str = Field(..., min_length=1) + post_id: Optional[int] = None + rag_score: Optional[float] = None + +class SubmittedResponse(BaseModel): + success: bool + message: str + submitted_count: int +``` + +--- + +## 5. API (`app/api/routes.py`) + +- `POST /api/v1/similar` — принять `SimilarRequest`, вызвать `service.find_similar_posts`, вернуть `SimilarResponse`. +- `POST /api/v1/submitted` — принять `SubmittedRequest`, вызвать `service.add_submitted_post`, вернуть `SubmittedResponse`. + +--- + +## 6. Автосохранение + +В `autosave_loop` или при `save_vectors` — сохранять submitted-коллекцию. При загрузке — загружать submitted из файла в `_load_from_disk` или отдельном методе. + +--- + +## Требования + +- Не ломать существующий функционал: score, examples, stats работают как раньше. +- Следовать стилю кода проекта (Black, isort, type hints). +- Добавить тесты для новых методов и endpoints. + +--- + +## Файлы для изменения + +- `app/storage/vector_store.py` +- `app/services/rag_service.py` +- `app/api/routes.py` +- `app/schemas.py` +- `app/config.py` +- `app/main.py` (при необходимости — lifespan для autosave submitted) diff --git a/database/async_db.py b/database/async_db.py index 39bdf94..09ae147 100644 --- a/database/async_db.py +++ b/database/async_db.py @@ -279,6 +279,34 @@ class AsyncBotDB: """Получает тексты отклоненных постов для обучения RAG.""" return await self.factory.posts.get_declined_posts_texts(limit) + async def get_user_posts_stats(self, user_id: int) -> Tuple[int, int, int]: + """ + Получает статистику постов пользователя. + + Returns: + Tuple (approved_count, declined_count, suggest_count) + """ + return await self.factory.posts.get_user_posts_stats(user_id) + + async def get_last_post_by_author(self, user_id: int) -> Optional[str]: + """Получает текст последнего поста пользователя.""" + return await self.factory.posts.get_last_post_by_author(user_id) + + async def get_user_ban_count(self, user_id: int) -> int: + """Получает количество банов пользователя за все время.""" + return await self.factory.blacklist_history.get_ban_count(user_id) + + async def get_last_ban_info( + self, user_id: int + ) -> Optional[Tuple[int, str, Optional[int]]]: + """ + Получает информацию о последнем бане пользователя. + + Returns: + Tuple (date_ban, reason, date_unban) или None + """ + return await self.factory.blacklist_history.get_last_ban_info(user_id) + # Методы для работы с черным списком async def set_user_blacklist( self, @@ -361,7 +389,8 @@ class AsyncBotDB: """Возвращает список пользователей в черном списке с учетом смещения и ограничения.""" users = await self.factory.blacklist.get_all_users(offset, limit) return [ - (user.user_id, user.message_for_user, user.date_to_unban) for user in users + (user.user_id, user.message_for_user, user.date_to_unban, user.created_at) + for user in users ] async def get_blacklist_users_by_id(self, user_id: int) -> Optional[tuple]: diff --git a/database/repositories/blacklist_history_repository.py b/database/repositories/blacklist_history_repository.py index 54685ea..6ee809d 100644 --- a/database/repositories/blacklist_history_repository.py +++ b/database/repositories/blacklist_history_repository.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, Tuple from database.base import DatabaseConnection from database.models import BlacklistHistoryRecord @@ -120,3 +120,55 @@ class BlacklistHistoryRepository(DatabaseConnection): f"Ошибка обновления даты разбана в истории для user_id={user_id}: {str(e)}" ) return False + + async def get_ban_count(self, user_id: int) -> int: + """ + Получает количество банов пользователя за все время. + + Args: + user_id: ID пользователя + + Returns: + Количество банов + """ + query = "SELECT COUNT(*) FROM blacklist_history WHERE user_id = ?" + rows = await self._execute_query_with_result(query, (user_id,)) + row = rows[0] if rows else None + + count = row[0] if row else 0 + self.logger.info(f"Количество банов для user_id={user_id}: {count}") + return count + + async def get_last_ban_info( + self, user_id: int + ) -> Optional[Tuple[int, str, Optional[int]]]: + """ + Получает информацию о последнем бане пользователя. + + Args: + user_id: ID пользователя + + Returns: + Tuple (date_ban, reason, date_unban) или None, если банов не было + """ + query = """ + SELECT date_ban, reason, date_unban FROM blacklist_history + WHERE user_id = ? + ORDER BY date_ban DESC + LIMIT 1 + """ + rows = await self._execute_query_with_result(query, (user_id,)) + row = rows[0] if rows else None + + if row: + date_ban = row[0] + reason = row[1] + date_unban = row[2] + self.logger.info( + f"Последний бан для user_id={user_id}: " + f"date_ban={date_ban}, reason={reason}, date_unban={date_unban}" + ) + return (date_ban, reason, date_unban) + + self.logger.info(f"Банов для user_id={user_id} не найдено") + return None diff --git a/database/repositories/blacklist_repository.py b/database/repositories/blacklist_repository.py index f8d275e..97d2957 100644 --- a/database/repositories/blacklist_repository.py +++ b/database/repositories/blacklist_repository.py @@ -87,13 +87,14 @@ class BlacklistRepository(DatabaseConnection): async def get_all_users( self, offset: int = 0, limit: int = 10 ) -> List[BlacklistUser]: - """Возвращает список пользователей в черном списке.""" + """Возвращает список пользователей в черном списке, отсортированных по дате бана (новые первые).""" query = """ SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist - LIMIT ?, ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? """ - rows = await self._execute_query_with_result(query, (offset, limit)) + rows = await self._execute_query_with_result(query, (limit, offset)) users = [] for row in rows: @@ -113,10 +114,11 @@ class BlacklistRepository(DatabaseConnection): return users async def get_all_users_no_limit(self) -> List[BlacklistUser]: - """Возвращает список всех пользователей в черном списке без лимитов.""" + """Возвращает список всех пользователей в черном списке без лимитов, отсортированных по дате бана (новые первые).""" query = """ SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist + ORDER BY created_at DESC """ rows = await self._execute_query_with_result(query) diff --git a/database/repositories/post_repository.py b/database/repositories/post_repository.py index 37cdea0..f645e67 100644 --- a/database/repositories/post_repository.py +++ b/database/repositories/post_repository.py @@ -545,3 +545,67 @@ class PostRepository(DatabaseConnection): texts = [row[0] for row in rows if row[0]] self.logger.info(f"Получено {len(texts)} отклоненных постов для обучения") return texts + + async def get_user_posts_stats(self, user_id: int) -> Tuple[int, int, int]: + """ + Получает статистику постов пользователя. + + Args: + user_id: ID пользователя + + Returns: + Tuple (approved_count, declined_count, suggest_count) + """ + query = """ + SELECT + SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved, + SUM(CASE WHEN status = 'declined' THEN 1 ELSE 0 END) as declined, + SUM(CASE WHEN status = 'suggest' THEN 1 ELSE 0 END) as suggest + FROM post_from_telegram_suggest + WHERE author_id = ? AND text != '^' + """ + rows = await self._execute_query_with_result(query, (user_id,)) + row = rows[0] if rows else None + + if row: + approved = row[0] or 0 + declined = row[1] or 0 + suggest = row[2] or 0 + self.logger.info( + f"Статистика постов для user_id={user_id}: " + f"approved={approved}, declined={declined}, suggest={suggest}" + ) + return (approved, declined, suggest) + + return (0, 0, 0) + + async def get_last_post_by_author(self, user_id: int) -> Optional[str]: + """ + Получает текст последнего поста пользователя. + + Args: + user_id: ID пользователя + + Returns: + Текст последнего поста или None, если постов нет + """ + query = """ + SELECT text FROM post_from_telegram_suggest + WHERE author_id = ? AND text IS NOT NULL AND text != '' AND text != '^' + ORDER BY created_at DESC + LIMIT 1 + """ + rows = await self._execute_query_with_result(query, (user_id,)) + row = rows[0] if rows else None + + if row: + text = row[0] + self.logger.info( + f"Последний пост для user_id={user_id}: '{text[:50]}...'" + if len(text) > 50 + else f"Последний пост для user_id={user_id}: '{text}'" + ) + return text + + self.logger.info(f"Постов для user_id={user_id} не найдено") + return None diff --git a/helper_bot/handlers/admin/admin_handlers.py b/helper_bot/handlers/admin/admin_handlers.py index 66d0519..c5cd1de 100644 --- a/helper_bot/handlers/admin/admin_handlers.py +++ b/helper_bot/handlers/admin/admin_handlers.py @@ -138,7 +138,7 @@ async def get_banned_users( keyboard = create_keyboard_with_pagination( 1, len(buttons_list), buttons_list, "unlock" ) - await message.answer(text=message_text, reply_markup=keyboard) + await message.answer(text=message_text, reply_markup=keyboard, parse_mode="HTML") else: await message.answer( text="В списке заблокированных пользователей никого нет" @@ -216,9 +216,15 @@ async def get_ml_stats(message: types.Message, state: FSMContext, **kwargs): # 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 '❌ Отключен'}" - ) + if rag.get("enabled"): + lines.append( + f" • Статус: ⚠️ Включен, но API не отвечает" + ) + lines.append( + f" • Проверьте доступность сервиса и API ключ" + ) + else: + lines.append(f" • Статус: ❌ Отключен") lines.append("") diff --git a/helper_bot/handlers/callback/callback_handlers.py b/helper_bot/handlers/callback/callback_handlers.py index d156184..f652ceb 100644 --- a/helper_bot/handlers/callback/callback_handlers.py +++ b/helper_bot/handlers/callback/callback_handlers.py @@ -255,6 +255,8 @@ async def change_page(call: CallbackQuery, bot_db: MagicData("bot_db"), **kwargs logger.info(f"Переход на страницу {page_number}") + items_per_page = 9 + if call.message.text == "Список пользователей которые последними обращались к боту": list_users = await bot_db.get_last_users(30) keyboard = create_keyboard_with_pagination( @@ -266,11 +268,13 @@ async def change_page(call: CallbackQuery, bot_db: MagicData("bot_db"), **kwargs reply_markup=keyboard, ) else: - message_user = await get_banned_users_list(int(page_number) * 7 - 7, bot_db) + offset = (page_number - 1) * items_per_page + message_user = await get_banned_users_list(offset, bot_db) await call.bot.edit_message_text( chat_id=call.message.chat.id, message_id=call.message.message_id, text=message_user, + parse_mode="HTML", ) buttons = await get_banned_users_buttons(bot_db) diff --git a/helper_bot/handlers/callback/services.py b/helper_bot/handlers/callback/services.py index f9aff0d..910d852 100644 --- a/helper_bot/handlers/callback/services.py +++ b/helper_bot/handlers/callback/services.py @@ -8,7 +8,7 @@ from aiogram.types import CallbackQuery from helper_bot.keyboards.keyboards import create_keyboard_for_ban_reason from helper_bot.utils.helper_func import ( delete_user_blacklist, - get_text_message, + get_publish_text, send_audio_message, send_media_group_to_channel, send_photo_message, @@ -137,7 +137,7 @@ class PostPublishService: raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") # Формируем финальный текст с учетом is_anonymous - formatted_text = get_text_message( + formatted_text = get_publish_text( raw_text, user.first_name, user.username, is_anonymous ) @@ -188,7 +188,7 @@ class PostPublishService: raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") # Формируем финальный текст с учетом is_anonymous - formatted_text = get_text_message( + formatted_text = get_publish_text( raw_text, user.first_name, user.username, is_anonymous ) @@ -247,7 +247,7 @@ class PostPublishService: raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") # Формируем финальный текст с учетом is_anonymous - formatted_text = get_text_message( + formatted_text = get_publish_text( raw_text, user.first_name, user.username, is_anonymous ) @@ -340,7 +340,7 @@ class PostPublishService: raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") # Формируем финальный текст с учетом is_anonymous - formatted_text = get_text_message( + formatted_text = get_publish_text( raw_text, user.first_name, user.username, is_anonymous ) @@ -452,7 +452,7 @@ class PostPublishService: f"Пользователь {author_id} не найден в базе данных" ) - formatted_text = get_text_message( + formatted_text = get_publish_text( raw_text, user.first_name, user.username, is_anonymous ) @@ -838,7 +838,7 @@ class BanService: await self.db.set_user_blacklist( user_id=author_id, user_name=None, - message_for_user="Спам", + message_for_user="Последний пост", date_to_unban=date_to_unban, ban_author=ban_author_id, ) diff --git a/helper_bot/handlers/private/private_handlers.py b/helper_bot/handlers/private/private_handlers.py index c8d0f8b..3f47aa2 100644 --- a/helper_bot/handlers/private/private_handlers.py +++ b/helper_bot/handlers/private/private_handlers.py @@ -291,12 +291,33 @@ class PrivateHandlers: """Handle messages in admin chat states""" # User service operations with metrics await self.user_service.update_user_activity(message.from_user.id) - await message.forward(chat_id=self.settings.group_for_message) + + # Формируем обогащённое сообщение для админов + user_id = message.from_user.id + full_name = message.from_user.full_name + username = message.from_user.username + message_text = message.text or "" + + enriched_message = await self.user_service.format_user_message_for_admins( + user_id=user_id, + full_name=full_name, + username=username, + message_text=message_text, + ) + + # Отправляем обогащённое сообщение вместо forward + sent_message = await message.bot.send_message( + chat_id=self.settings.group_for_message, + text=enriched_message, + parse_mode="HTML", + ) current_date = datetime.now() date = int(current_date.timestamp()) + + # Сохраняем message_id из результата send_message await self.db.add_message( - message.text, message.from_user.id, message.message_id + 1, date + message.text, message.from_user.id, sent_message.message_id, date ) question = messages.get_message(get_first_name(message), "QUESTION") diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py index b369484..3a6f1fa 100644 --- a/helper_bot/handlers/private/services.py +++ b/helper_bot/handlers/private/services.py @@ -156,6 +156,92 @@ class UserService: username = message.from_user.username or "Без никнейма" return html.escape(full_name), html.escape(username) + async def format_user_message_for_admins( + self, user_id: int, full_name: str, username: str, message_text: str + ) -> str: + """ + Форматирует сообщение пользователя для отправки админам с обогащёнными данными. + + Args: + user_id: ID пользователя + full_name: Полное имя пользователя + username: Username пользователя (может быть None) + message_text: Текст сообщения пользователя + + Returns: + Отформатированное сообщение для админов + """ + safe_full_name = html.escape(full_name) if full_name else "Неизвестный пользователь" + safe_username = html.escape(username) if username else None + safe_message_text = html.escape(message_text) if message_text else "" + + # Формируем строку с информацией об авторе + if safe_username: + author_info = f"{safe_full_name} (@{safe_username})" + else: + author_info = f"{safe_full_name} (Ник не указан)" + + # Получаем статистику постов + approved, declined, suggest = await self.db.get_user_posts_stats(user_id) + total_posts = approved + declined + suggest + + # Получаем последний пост + last_post = await self.db.get_last_post_by_author(user_id) + if last_post: + if len(last_post) > 80: + last_post_display = f'"{html.escape(last_post[:80])}..."' + else: + last_post_display = f'"{html.escape(last_post)}"' + else: + last_post_display = "Нет постов" + + # Получаем дату регистрации + user_info = await self.db.get_user_by_id(user_id) + if user_info and user_info.date_added: + date_added = datetime.fromtimestamp(user_info.date_added).strftime("%d.%m.%Y") + else: + date_added = "Неизвестно" + + # Получаем информацию о банах + ban_count = await self.db.get_user_ban_count(user_id) + ban_section = "" + if ban_count > 0: + last_ban = await self.db.get_last_ban_info(user_id) + if last_ban: + date_ban, reason, date_unban = last_ban + ban_date_str = datetime.fromtimestamp(date_ban).strftime("%d.%m.%Y") + reason_display = html.escape(reason) if reason else "Не указана" + + if date_unban: + unban_date_str = datetime.fromtimestamp(date_unban).strftime( + "%d.%m.%Y %H:%M" + ) + last_ban_info = ( + f" Последний: {ban_date_str}, причина «{reason_display}», " + f"истёк {unban_date_str}" + ) + else: + last_ban_info = ( + f" Последний: {ban_date_str}, причина «{reason_display}», " + f"активен" + ) + + ban_section = f"\n\n🚫 Банов: {ban_count}\n{last_ban_info}" + + # Формируем итоговое сообщение + formatted_message = ( + f"👤 От: {author_info} | ID: {user_id}\n\n" + f"📊 Постов в базе: {total_posts}\n" + f"📝 Последний пост: {last_post_display}\n" + f"📅 В боте с: {date_added}" + f"{ban_section}\n\n" + f"---\n" + f"Сообщение пользователя:\n\n" + f"{safe_message_text}" + ) + + return formatted_message + class PostService: """Service for post-related operations""" @@ -236,6 +322,18 @@ class PostService: f"PostService: Ошибка сохранения скоров для {message_id}: {e}" ) + async def _add_submitted_post_background( + self, text: str, post_id: int, rag_score: float = None + ) -> None: + """Индексирует пост в RAG submitted collection в фоне.""" + try: + if self.scoring_manager: + await self.scoring_manager.add_submitted_post(text, post_id, rag_score) + except Exception as e: + logger.warning( + f"PostService: Ошибка добавления поста в submitted: {e}" + ) + async def _get_scores_with_error_handling(self, text: str) -> tuple: """ Получает скоры для текста поста с обработкой ошибок. @@ -321,6 +419,37 @@ class PostService: error_message, ) = await self._get_scores_with_error_handling(original_raw_text) + # Проверяем похожие посты (до добавления текущего в submitted) + similar_warning = "" + if self.scoring_manager and original_raw_text and original_raw_text.strip(): + try: + similar_result = await self.scoring_manager.find_similar_posts( + original_raw_text, threshold=0.9, hours=24 + ) + if similar_result and similar_result.similar_count > 0: + # Формируем предупреждение с текстом похожего поста + similar_text = "" + if similar_result.similar_posts: + first_similar = similar_result.similar_posts[0] + if first_similar.text: + truncated_text = first_similar.text[:150] + if len(first_similar.text) > 150: + truncated_text += "..." + similar_text = f'\nТекст поста:\n"{html.escape(truncated_text)}"' + + similar_warning = ( + f"\n\n⚠️ Похожий пост за последние 24ч " + f"(совпадение {similar_result.max_similarity:.0%})" + f"{similar_text}" + ) + logger.info( + f"PostService: Найден похожий пост для message_id={message.message_id}, " + f"similar_count={similar_result.similar_count}, " + f"max_similarity={similar_result.max_similarity:.2%}" + ) + except Exception as e: + logger.warning(f"PostService: Ошибка поиска похожих постов: {e}") + # Формируем текст для поста (с сообщением об ошибке если есть) text_for_post = original_raw_text if error_message: @@ -347,9 +476,11 @@ class PostService: message.from_user.username, deepseek_score=deepseek_score, rag_score=rag_score, - rag_confidence=rag_confidence, - rag_score_pos_only=rag_score_pos_only, + user_id=message.from_user.id, ) + # Добавляем предупреждение о похожем посте + if similar_warning: + post_text += similar_warning # Определяем анонимность по исходному тексту (без сообщения об ошибке) is_anonymous = determine_anonymity(original_raw_text) @@ -401,8 +532,11 @@ class PostService: markup, ) elif content_type == "media_group": + # Добавляем предупреждение о похожем посте в caption медиагруппы + if similar_warning: + post_text += similar_warning # Для медиагруппы используем специальную обработку - # Передаем ml_scores_json для сохранения в БД + # Передаем ml_scores_json и rag_score для сохранения в БД await self._process_media_group_background( message, album, @@ -411,6 +545,7 @@ class PostService: is_anonymous, original_raw_text, ml_scores_json, + rag_score, ) return else: @@ -448,6 +583,14 @@ class PostService: ) ) + # Индексируем пост в RAG submitted collection (после успешной отправки) + if self.scoring_manager and original_raw_text and original_raw_text.strip(): + asyncio.create_task( + self._add_submitted_post_background( + original_raw_text, sent_message.message_id, rag_score + ) + ) + except Exception as e: logger.error( f"PostService: Критическая ошибка в _process_post_background для {content_type}: {e}" @@ -462,6 +605,7 @@ class PostService: is_anonymous: bool, original_raw_text: str, ml_scores_json: str = None, + rag_score: float = None, ) -> None: """Обрабатывает медиагруппу в фоне""" try: @@ -495,6 +639,14 @@ class PostService: self._save_scores_background(main_post_id, ml_scores_json) ) + # Индексируем пост в RAG submitted collection + if self.scoring_manager and original_raw_text and original_raw_text.strip(): + asyncio.create_task( + self._add_submitted_post_background( + original_raw_text, main_post_id, rag_score + ) + ) + for msg_id in media_group_message_ids: await self.db.add_message_link(main_post_id, msg_id) @@ -552,8 +704,7 @@ class PostService: message.from_user.username, deepseek_score=deepseek_score, rag_score=rag_score, - rag_confidence=rag_confidence, - rag_score_pos_only=rag_score_pos_only, + user_id=message.from_user.id, ) markup = get_reply_keyboard_for_post() @@ -611,8 +762,7 @@ class PostService: message.from_user.username, deepseek_score=deepseek_score, rag_score=rag_score, - rag_confidence=rag_confidence, - rag_score_pos_only=rag_score_pos_only, + user_id=message.from_user.id, ) markup = get_reply_keyboard_for_post() @@ -677,8 +827,7 @@ class PostService: message.from_user.username, deepseek_score=deepseek_score, rag_score=rag_score, - rag_confidence=rag_confidence, - rag_score_pos_only=rag_score_pos_only, + user_id=message.from_user.id, ) markup = get_reply_keyboard_for_post() @@ -770,8 +919,7 @@ class PostService: message.from_user.username, deepseek_score=deepseek_score, rag_score=rag_score, - rag_confidence=rag_confidence, - rag_score_pos_only=rag_score_pos_only, + user_id=message.from_user.id, ) markup = get_reply_keyboard_for_post() @@ -869,8 +1017,7 @@ class PostService: message.from_user.username, deepseek_score=deepseek_score, rag_score=rag_score, - rag_confidence=rag_confidence, - rag_score_pos_only=rag_score_pos_only, + user_id=message.from_user.id, ) is_anonymous = determine_anonymity(raw_caption) diff --git a/helper_bot/services/scoring/rag_client.py b/helper_bot/services/scoring/rag_client.py index 513a929..4ebb4ea 100644 --- a/helper_bot/services/scoring/rag_client.py +++ b/helper_bot/services/scoring/rag_client.py @@ -4,7 +4,8 @@ HTTP клиент для взаимодействия с внешним RAG се Использует REST API для получения скоров и отправки примеров. """ -from typing import Any, Dict, Optional +from dataclasses import dataclass +from typing import Any, Dict, List, Optional import httpx @@ -15,6 +16,30 @@ from .base import ScoringResult from .exceptions import InsufficientExamplesError, ScoringError, TextTooShortError +@dataclass +class SimilarPost: + """Данные о похожем посте.""" + + similarity: float + created_at: int + post_id: Optional[int] + text: str + rag_score: Optional[float] + + +@dataclass +class SimilarPostsResult: + """Результат поиска похожих постов.""" + + similar_count: int + similar_posts: List[SimilarPost] + max_similarity: float = 0.0 + + def __post_init__(self): + if self.similar_posts: + self.max_similarity = max(p.similarity for p in self.similar_posts) + + class RagApiClient: """ HTTP клиент для взаимодействия с внешним RAG сервисом. @@ -329,21 +354,39 @@ class RagApiClient: Словарь со статистикой или пустой словарь при ошибке """ if not self._enabled: + logger.debug("RagApiClient: get_stats пропущен - клиент отключен") return {} try: + logger.debug(f"RagApiClient: Запрос статистики от {self.api_url}/stats") response = await self._client.get(f"{self.api_url}/stats") if response.status_code == 200: - return response.json() + data = response.json() + logger.info( + f"RagApiClient: Статистика получена успешно: " + f"model_loaded={data.get('model_loaded')}, " + f"model_name={data.get('model_name')}, " + f"vector_store={data.get('vector_store', {}).get('total_count', 'N/A')} примеров" + ) + return data + elif response.status_code == 401 or response.status_code == 403: + logger.warning( + f"RagApiClient: Ошибка авторизации при получении статистики: " + f"status={response.status_code}, body={response.text[:200]}" + ) + return {} else: logger.warning( - f"RagApiClient: Неожиданный статус при получении статистики: {response.status_code}" + f"RagApiClient: Неожиданный статус при получении статистики: " + f"status={response.status_code}, body={response.text[:200]}" ) return {} except httpx.TimeoutException: - logger.warning(f"RagApiClient: Таймаут при получении статистики") + logger.warning( + f"RagApiClient: Таймаут при получении статистики (timeout={self.timeout}s)" + ) return {} except httpx.RequestError as e: logger.warning( @@ -365,3 +408,135 @@ class RagApiClient: "api_url": self.api_url, "timeout": self.timeout, } + + @track_time("find_similar_posts", "rag_client") + async def find_similar_posts( + self, text: str, threshold: float = 0.9, hours: int = 24 + ) -> Optional[SimilarPostsResult]: + """ + Ищет похожие посты за последние N часов. + + Args: + text: Текст поста для поиска похожих + threshold: Порог схожести (0.0-1.0), по умолчанию 0.9 + hours: За сколько часов искать (1-168), по умолчанию 24 + + Returns: + SimilarPostsResult с информацией о похожих постах или None при ошибке + """ + if not self._enabled: + return None + + if not text or not text.strip(): + return None + + try: + response = await self._client.post( + f"{self.api_url}/similar", + json={"text": text.strip(), "threshold": threshold, "hours": hours}, + ) + + if response.status_code == 200: + data = response.json() + similar_posts = [] + + for post_data in data.get("similar_posts", []): + similar_posts.append( + SimilarPost( + similarity=float(post_data.get("similarity", 0.0)), + created_at=int(post_data.get("created_at", 0)), + post_id=post_data.get("post_id"), + text=post_data.get("text", ""), + rag_score=post_data.get("rag_score"), + ) + ) + + result = SimilarPostsResult( + similar_count=data.get("similar_count", 0), + similar_posts=similar_posts, + ) + + if result.similar_count > 0: + logger.info( + f"RagApiClient: Найдено {result.similar_count} похожих постов " + f"(max_similarity={result.max_similarity:.2%})" + ) + + return result + else: + logger.warning( + f"RagApiClient: Неожиданный статус при поиске похожих постов: " + f"{response.status_code}, body: {response.text}" + ) + return None + + except httpx.TimeoutException: + logger.warning("RagApiClient: Таймаут при поиске похожих постов") + return None + except httpx.RequestError as e: + logger.warning( + f"RagApiClient: Ошибка подключения при поиске похожих постов: {e}" + ) + return None + except Exception as e: + logger.error(f"RagApiClient: Ошибка поиска похожих постов: {e}") + return None + + @track_time("add_submitted_post", "rag_client") + async def add_submitted_post( + self, text: str, post_id: Optional[int] = None, rag_score: Optional[float] = None + ) -> bool: + """ + Добавляет пост в коллекцию submitted для поиска похожих. + + Args: + text: Текст поста + post_id: ID поста (опционально) + rag_score: RAG скор на момент добавления (опционально) + + Returns: + True если пост успешно добавлен + """ + if not self._enabled: + return False + + if not text or not text.strip(): + return False + + try: + payload = {"text": text.strip()} + if post_id is not None: + payload["post_id"] = post_id + if rag_score is not None: + payload["rag_score"] = rag_score + + response = await self._client.post( + f"{self.api_url}/submitted", + json=payload, + ) + + if response.status_code in (200, 201): + data = response.json() + logger.debug( + f"RagApiClient: Пост добавлен в submitted " + f"(post_id={post_id}, submitted_count={data.get('submitted_count', 'N/A')})" + ) + return True + else: + logger.warning( + f"RagApiClient: Неожиданный статус при добавлении в submitted: " + f"{response.status_code}" + ) + return False + + except httpx.TimeoutException: + logger.warning("RagApiClient: Таймаут при добавлении в submitted") + return False + except httpx.RequestError as e: + logger.warning( + f"RagApiClient: Ошибка подключения при добавлении в submitted: {e}" + ) + return False + except Exception as e: + logger.error(f"RagApiClient: Ошибка добавления в submitted: {e}") + return False diff --git a/helper_bot/services/scoring/scoring_manager.py b/helper_bot/services/scoring/scoring_manager.py index 6761176..8a332c8 100644 --- a/helper_bot/services/scoring/scoring_manager.py +++ b/helper_bot/services/scoring/scoring_manager.py @@ -221,3 +221,43 @@ class ScoringManager: stats["deepseek"] = self.deepseek_service.get_stats() return stats + + @track_time("find_similar_posts", "scoring_manager") + async def find_similar_posts( + self, text: str, threshold: float = 0.9, hours: int = 24 + ): + """ + Ищет похожие посты через RAG API. + + Args: + text: Текст для поиска похожих + threshold: Порог схожести (0.0-1.0) + hours: За сколько часов искать + + Returns: + SimilarPostsResult или None + """ + if not self.rag_client or not self.rag_client.is_enabled: + return None + + return await self.rag_client.find_similar_posts(text, threshold, hours) + + @track_time("add_submitted_post", "scoring_manager") + async def add_submitted_post( + self, text: str, post_id: Optional[int] = None, rag_score: Optional[float] = None + ) -> bool: + """ + Добавляет пост в коллекцию submitted для поиска похожих. + + Args: + text: Текст поста + post_id: ID поста (опционально) + rag_score: RAG скор на момент добавления (опционально) + + Returns: + True если успешно добавлен + """ + if not self.rag_client or not self.rag_client.is_enabled: + return False + + return await self.rag_client.add_submitted_post(text, post_id, rag_score) diff --git a/helper_bot/utils/helper_func.py b/helper_bot/utils/helper_func.py index 39aab76..6a677c1 100644 --- a/helper_bot/utils/helper_func.py +++ b/helper_bot/utils/helper_func.py @@ -138,6 +138,52 @@ def determine_anonymity(post_text: str) -> bool: return False +def get_publish_text( + post_text: str, + first_name: str, + username: str = None, + is_anonymous: Optional[bool] = None, +) -> str: + """ + Форматирует текст для финальной публикации в канал. + Только текст поста + подпись автора или анон. + + Args: + post_text: Текст сообщения + first_name: Имя автора поста + username: Юзернейм автора поста (может быть None) + is_anonymous: Флаг анонимности (True - анонимно, False - не анонимно, None - legacy) + + Returns: + str: Текст для публикации в канал + """ + safe_post_text = post_text or "" + safe_first_name = first_name or "Пользователь" + + # Формируем строку с информацией об авторе + if username: + author_info = f"{safe_first_name} @{username}" + else: + author_info = f"{safe_first_name}" + + # Определяем анонимность и формируем финальный текст + if is_anonymous is not None: + if is_anonymous: + final_text = f"{safe_post_text}\n\nПост опубликован анонимно" + else: + final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" + else: + # Legacy: определяем по тексту + if "неанон" in post_text.lower() or "не анон" in post_text.lower(): + final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" + elif "анон" in post_text.lower(): + final_text = f"{safe_post_text}\n\nПост опубликован анонимно" + else: + final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" + + return final_text + + def get_text_message( post_text: str, first_name: str, @@ -147,10 +193,10 @@ def get_text_message( rag_score: Optional[float] = None, rag_confidence: Optional[float] = None, rag_score_pos_only: Optional[float] = None, + user_id: Optional[int] = None, ): """ - Форматирует текст сообщения для публикации в зависимости от наличия ключевых слов "анон" и "неанон" - или переданного параметра is_anonymous. + Форматирует текст сообщения для модерации (с полной информацией об авторе и скорами). Args: post_text: Текст сообщения @@ -161,64 +207,69 @@ def get_text_message( 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, опционально) + user_id: ID пользователя Telegram (опционально) Returns: - str: - Сформированный текст сообщения. + str: - Сформированный текст сообщения для модерации. """ # Экранируем post_text для безопасного использования в HTML safe_post_text = html.escape(str(post_text)) if post_text else "" # Экранируем username для безопасного использования в HTML safe_username = html.escape(username) if username else None + safe_first_name = html.escape(first_name) if first_name else "Пользователь" - # Формируем строку с информацией об авторе + # Формируем шапку с информацией об авторе if safe_username: - author_info = f"{first_name} @{safe_username}" + header = f"👤 От: {safe_first_name} (@{safe_username})" else: - author_info = f"{first_name} (Ник не указан)" + header = f"👤 От: {safe_first_name} (Ник не указан)" - # Формируем базовый текст - # Если передан is_anonymous, используем его, иначе определяем по тексту (legacy) + if user_id: + header += f" | ID: {user_id}" + + # Формируем строку с информацией об авторе для подвала + if safe_username: + author_info = f"{safe_first_name} @{safe_username}" + else: + author_info = f"{safe_first_name} (Ник не указан)" + + # Формируем блок с текстом поста + separator = "=" * 32 + post_block = f"{header}\nТекст поста:\n{separator}\n{safe_post_text}" + + # Определяем анонимность и формируем подвал if is_anonymous is not None: if is_anonymous: - final_text = f"{safe_post_text}\n\nПост опубликован анонимно" + post_block += f"\n\nПост опубликован анонимно" else: - final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" + post_block += f"\n\nАвтор поста: {author_info}" else: # Legacy: определяем по тексту if "неанон" in post_text or "не анон" in post_text: - final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" + post_block += f"\n\nАвтор поста: {author_info}" elif "анон" in post_text: - final_text = f"{safe_post_text}\n\nПост опубликован анонимно" + post_block += f"\n\nПост опубликован анонимно" else: - final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" + post_block += f"\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📊 Уверенность в одобрении:"] + post_block += f"\n{separator}" + + # Добавляем блок со скорами если есть (без RAG pos only и уверенности) + if deepseek_score is not None or rag_score is not None: + scores_lines = ["📊 Уверенность в одобрении:"] if deepseek_score is not None: scores_lines.append(f"DeepSeek: {deepseek_score:.2f}") if rag_score is not None: logger.debug( f"get_text_message: Форматирование rag_score - " f"rag_score={rag_score} (type: {type(rag_score).__name__}), " - f"rag_score_pos_only={rag_score_pos_only} (type: {type(rag_score_pos_only).__name__ if rag_score_pos_only is not None else 'None'}), " - f"rag_confidence={rag_confidence} (type: {type(rag_confidence).__name__ if rag_confidence is not None else 'None'}), " f"formatted_value={rag_score:.2f}" ) - 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) + scores_lines.append(f"RAG neg/pos: {rag_score:.2f}") + post_block += "\n" + "\n".join(scores_lines) - return final_text + return post_block @track_time("download_file", "helper_func") @@ -854,15 +905,14 @@ async def send_text_message( ): from .rate_limiter import send_with_rate_limit - # Экранируем post_text для безопасного использования в HTML - safe_post_text = html.escape(str(post_text)) if post_text else "" - async def _send_message(): if markup is None: - return await message.bot.send_message(chat_id=chat_id, text=safe_post_text) + return await message.bot.send_message( + chat_id=chat_id, text=post_text, parse_mode="HTML" + ) else: return await message.bot.send_message( - chat_id=chat_id, text=safe_post_text, reply_markup=markup + chat_id=chat_id, text=post_text, reply_markup=markup, parse_mode="HTML" ) sent_message = await send_with_rate_limit(_send_message, chat_id) @@ -878,16 +928,17 @@ async def send_photo_message( post_text: str, markup: types.ReplyKeyboardMarkup = None, ): - # Экранируем post_text для безопасного использования в HTML - safe_post_text = html.escape(str(post_text)) if post_text else "" - if markup is None: sent_message = await message.bot.send_photo( - chat_id=chat_id, caption=safe_post_text, photo=photo + chat_id=chat_id, caption=post_text, photo=photo, parse_mode="HTML" ) else: sent_message = await message.bot.send_photo( - chat_id=chat_id, caption=safe_post_text, photo=photo, reply_markup=markup + chat_id=chat_id, + caption=post_text, + photo=photo, + reply_markup=markup, + parse_mode="HTML", ) return sent_message @@ -901,16 +952,17 @@ async def send_video_message( post_text: str = "", markup: types.ReplyKeyboardMarkup = None, ): - # Экранируем post_text для безопасного использования в HTML - safe_post_text = html.escape(str(post_text)) if post_text else "" - if markup is None: sent_message = await message.bot.send_video( - chat_id=chat_id, caption=safe_post_text, video=video + chat_id=chat_id, caption=post_text, video=video, parse_mode="HTML" ) else: sent_message = await message.bot.send_video( - chat_id=chat_id, caption=safe_post_text, video=video, reply_markup=markup + chat_id=chat_id, + caption=post_text, + video=video, + reply_markup=markup, + parse_mode="HTML", ) return sent_message @@ -943,16 +995,17 @@ async def send_audio_message( post_text: str, markup: types.ReplyKeyboardMarkup = None, ): - # Экранируем post_text для безопасного использования в HTML - safe_post_text = html.escape(str(post_text)) if post_text else "" - if markup is None: sent_message = await message.bot.send_audio( - chat_id=chat_id, caption=safe_post_text, audio=audio + chat_id=chat_id, caption=post_text, audio=audio, parse_mode="HTML" ) else: sent_message = await message.bot.send_audio( - chat_id=chat_id, caption=safe_post_text, audio=audio, reply_markup=markup + chat_id=chat_id, + caption=post_text, + audio=audio, + reply_markup=markup, + parse_mode="HTML", ) return sent_message @@ -1012,11 +1065,12 @@ async def get_banned_users_list(offset: int, bot_db): message - текст сообщения user_ids - лист кортежей [(user_name: user_id)] """ - users = await bot_db.get_banned_users_from_db_with_limits(limit=7, offset=offset) + items_per_page = 9 + users = await bot_db.get_banned_users_from_db_with_limits(limit=items_per_page, offset=offset) message = "Список заблокированных пользователей:\n" for user in users: - user_id, ban_reason, unban_date = user + user_id, ban_reason, unban_date, ban_date = user # Получаем имя пользователя из таблицы users username = await bot_db.get_username(user_id) full_name = await bot_db.get_full_name_by_id(user_id) @@ -1028,41 +1082,42 @@ async def get_banned_users_list(offset: int, bot_db): html.escape(str(ban_reason)) if ban_reason else "Причина не указана" ) - # Форматируем дату разбана в человекочитаемый формат - if unban_date: - try: - # Предполагаем, что unban_date это UNIX timestamp - if isinstance(unban_date, (int, float)): - unban_datetime = datetime.fromtimestamp(unban_date) - safe_unban_date = unban_datetime.strftime("%d-%m-%Y %H:%M") - elif isinstance(unban_date, str): - # Если это строка, попытаемся её обработать - try: - # Попробуем преобразовать строку в timestamp - timestamp = int(unban_date) - unban_datetime = datetime.fromtimestamp(timestamp) - safe_unban_date = unban_datetime.strftime("%d-%m-%Y %H:%M") - except (ValueError, TypeError): - # Если не удалось, показываем как есть - safe_unban_date = html.escape(str(unban_date)) - elif hasattr(unban_date, "strftime"): - # Если это datetime объект - safe_unban_date = unban_date.strftime("%d-%m-%Y %H:%M") - else: - # Для всех остальных случаев - safe_unban_date = html.escape(str(unban_date)) - except (ValueError, TypeError, OSError): - # В случае ошибки показываем исходное значение - safe_unban_date = html.escape(str(unban_date)) - else: - safe_unban_date = "Дата не указана" + # Форматируем дату бана в человекочитаемый формат + safe_ban_date = _format_timestamp_to_date(ban_date) - message += f"**Пользователь:** {safe_user_name}\n" - message += f"**Причина бана:** {safe_ban_reason}\n" - message += f"**Дата разбана:** {safe_unban_date}\n\n" + # Форматируем дату разбана в человекочитаемый формат + safe_unban_date = _format_timestamp_to_date(unban_date, default="Навсегда") + + message += f"Пользователь: {safe_user_name}\n" + message += f"Причина бана: {safe_ban_reason}\n" + message += f"Дата бана: {safe_ban_date}\n" + message += f"Дата разбана: {safe_unban_date}\n\n" return message +def _format_timestamp_to_date(timestamp, default: str = "Дата не указана") -> str: + """Форматирует timestamp в читаемую дату.""" + if not timestamp: + return default + try: + if isinstance(timestamp, (int, float)): + dt = datetime.fromtimestamp(timestamp) + return dt.strftime("%d.%m.%Y %H:%M") + elif isinstance(timestamp, str): + try: + ts = int(timestamp) + dt = datetime.fromtimestamp(ts) + return dt.strftime("%d.%m.%Y %H:%M") + except (ValueError, TypeError): + return html.escape(str(timestamp)) + elif hasattr(timestamp, "strftime"): + return timestamp.strftime("%d.%m.%Y %H:%M") + else: + return html.escape(str(timestamp)) + except (ValueError, TypeError, OSError): + return html.escape(str(timestamp)) + + @track_time("get_banned_users_buttons", "helper_func") @track_errors("helper_func", "get_banned_users_buttons") @db_query_time("get_banned_users_buttons", "users", "select") diff --git a/tests/test_blacklist_repository.py b/tests/test_blacklist_repository.py index 97caf4f..5b323a7 100644 --- a/tests/test_blacklist_repository.py +++ b/tests/test_blacklist_repository.py @@ -274,9 +274,9 @@ class TestBlacklistRepository: # Нормализуем SQL запрос (убираем лишние пробелы и переносы строк) actual_query = " ".join(call_args[0][0].split()) - expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist LIMIT ?, ?" + expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist ORDER BY created_at DESC LIMIT ? OFFSET ?" assert actual_query == expected_query - assert call_args[0][1] == (0, 10) + assert call_args[0][1] == (10, 0) # Проверяем логирование blacklist_repository.logger.info.assert_called_once_with( @@ -310,7 +310,7 @@ class TestBlacklistRepository: # Нормализуем SQL запрос (убираем лишние пробелы и переносы строк) actual_query = " ".join(call_args[0][0].split()) - expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist" + expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist ORDER BY created_at DESC" assert actual_query == expected_query # Проверяем, что параметры пустые (без лимитов) assert len(call_args[0]) == 1 # Только SQL запрос, без параметров diff --git a/tests/test_callback_services.py b/tests/test_callback_services.py index 1bfea92..216f6b9 100644 --- a/tests/test_callback_services.py +++ b/tests/test_callback_services.py @@ -85,7 +85,7 @@ class TestPostPublishService: return call @patch("helper_bot.handlers.callback.services.send_text_message") - @patch("helper_bot.handlers.callback.services.get_text_message") + @patch("helper_bot.handlers.callback.services.get_publish_text") async def test_publish_post_text_success( self, mock_get_text, mock_send_text, service, mock_call_text, mock_db ): @@ -214,7 +214,7 @@ class TestPostPublishService: @patch("helper_bot.handlers.callback.services.send_photo_message") @patch("helper_bot.handlers.callback.services.send_text_message") - @patch("helper_bot.handlers.callback.services.get_text_message") + @patch("helper_bot.handlers.callback.services.get_publish_text") async def test_publish_post_photo_success( self, mock_get_text, mock_send_text, mock_send_photo, service, mock_db ): @@ -239,7 +239,7 @@ class TestPostPublishService: @patch("helper_bot.handlers.callback.services.send_video_message") @patch("helper_bot.handlers.callback.services.send_text_message") - @patch("helper_bot.handlers.callback.services.get_text_message") + @patch("helper_bot.handlers.callback.services.get_publish_text") async def test_publish_post_video_success( self, mock_get_text, mock_send_text, mock_send_video, service, mock_db ): @@ -285,7 +285,7 @@ class TestPostPublishService: @patch("helper_bot.handlers.callback.services.send_audio_message") @patch("helper_bot.handlers.callback.services.send_text_message") - @patch("helper_bot.handlers.callback.services.get_text_message") + @patch("helper_bot.handlers.callback.services.get_publish_text") async def test_publish_post_audio_success( self, mock_get_text, mock_send_text, mock_send_audio, service, mock_db ): @@ -499,7 +499,7 @@ class TestPostPublishService: @patch("helper_bot.handlers.callback.services.send_media_group_to_channel") @patch("helper_bot.handlers.callback.services.send_text_message") - @patch("helper_bot.handlers.callback.services.get_text_message") + @patch("helper_bot.handlers.callback.services.get_publish_text") async def test_publish_media_group_success( self, mock_get_text, mock_send_text, mock_send_media, service, mock_db ): diff --git a/tests/test_refactored_private_handlers.py b/tests/test_refactored_private_handlers.py index d36b4e3..515eb2c 100644 --- a/tests/test_refactored_private_handlers.py +++ b/tests/test_refactored_private_handlers.py @@ -29,6 +29,11 @@ class TestPrivateHandlers: db.add_message = AsyncMock() db.update_helper_message = AsyncMock() db.update_user_activity = AsyncMock() + db.get_user_posts_stats = AsyncMock(return_value=(5, 2, 3)) + db.get_last_post_by_author = AsyncMock(return_value="Last post text") + db.get_user_by_id = AsyncMock(return_value=Mock(date_added=1704067200)) + db.get_user_ban_count = AsyncMock(return_value=0) + db.get_last_ban_info = AsyncMock(return_value=None) return db @pytest.fixture @@ -257,6 +262,7 @@ class TestPrivateHandlers: """resend_message_in_group при PRE_CHAT переводит в START и отправляет question.""" handlers = create_private_handlers(mock_db, mock_settings) mock_state.get_state = AsyncMock(return_value=FSM_STATES["PRE_CHAT"]) + mock_message.bot.send_message = AsyncMock(return_value=Mock(message_id=100)) with pytest.MonkeyPatch().context() as m: m.setattr( "helper_bot.handlers.private.private_handlers.get_reply_keyboard", @@ -267,9 +273,7 @@ class TestPrivateHandlers: lambda x, y: "Question?", ) await handlers.resend_message_in_group_for_message(mock_message, mock_state) - mock_message.forward.assert_called_once_with( - chat_id=mock_settings.group_for_message - ) + mock_message.bot.send_message.assert_called_once() mock_state.set_state.assert_called_once_with(FSM_STATES["START"]) @pytest.mark.asyncio @@ -279,6 +283,7 @@ class TestPrivateHandlers: """resend_message_in_group при CHAT оставляет в CHAT и отправляет question с leave markup.""" handlers = create_private_handlers(mock_db, mock_settings) mock_state.get_state = AsyncMock(return_value=FSM_STATES["CHAT"]) + mock_message.bot.send_message = AsyncMock(return_value=Mock(message_id=100)) with pytest.MonkeyPatch().context() as m: m.setattr( "helper_bot.handlers.private.private_handlers.get_reply_keyboard_leave_chat", @@ -289,9 +294,7 @@ class TestPrivateHandlers: lambda x, y: "Question?", ) await handlers.resend_message_in_group_for_message(mock_message, mock_state) - mock_message.forward.assert_called_once_with( - chat_id=mock_settings.group_for_message - ) + mock_message.bot.send_message.assert_called_once() mock_message.answer.assert_called() @pytest.mark.asyncio diff --git a/tests/test_utils.py b/tests/test_utils.py index d511122..b57d253 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -665,7 +665,7 @@ class TestSendMessageFunctions: assert result == mock_sent_message mock_message.bot.send_photo.assert_called_once_with( - chat_id=123, caption="Подпись к фото", photo="photo.jpg" + chat_id=123, caption="Подпись к фото", photo="photo.jpg", parse_mode="HTML" ) @pytest.mark.asyncio @@ -684,7 +684,7 @@ class TestSendMessageFunctions: assert result == mock_sent_message mock_message.bot.send_video.assert_called_once_with( - chat_id=123, caption="Подпись к видео", video="video.mp4" + chat_id=123, caption="Подпись к видео", video="video.mp4", parse_mode="HTML" ) @@ -722,8 +722,9 @@ class TestUtilityFunctions: """Тест получения списка заблокированных пользователей""" mock_db = AsyncMock() mock_db.get_banned_users_from_db_with_limits.return_value = [ - (123, "Spam", 1704067200), # user_id, ban_reason, unban_date (timestamp) - (456, "Violation", 1704153600), + # user_id, ban_reason, unban_date (timestamp), ban_date (timestamp) + (123, "Spam", 1704067200, 1703980800), + (456, "Violation", 1704153600, 1704067200), ] mock_db.get_username.return_value = None mock_db.get_full_name_by_id.return_value = "Test User" @@ -734,18 +735,16 @@ class TestUtilityFunctions: assert "Test User" in result assert "Spam" in result assert "Violation" in result + assert "Дата бана:" in result @pytest.mark.asyncio async def test_get_banned_users_list_with_string_timestamp(self): """Тест получения списка заблокированных пользователей со строковым timestamp""" mock_db = AsyncMock() mock_db.get_banned_users_from_db_with_limits.return_value = [ - ( - 123, - "Spam", - "1704067200", - ), # user_id, ban_reason, unban_date (string timestamp) - (456, "Violation", "1704153600"), + # user_id, ban_reason, unban_date (string timestamp), ban_date (string timestamp) + (123, "Spam", "1704067200", "1703980800"), + (456, "Violation", "1704153600", "1704067200"), ] mock_db.get_username.return_value = None mock_db.get_full_name_by_id.return_value = "Test User" @@ -756,6 +755,7 @@ class TestUtilityFunctions: assert "Test User" in result assert "Spam" in result assert "Violation" in result + assert "Дата бана:" in result @pytest.mark.asyncio async def test_get_banned_users_buttons(self):