From 9f62b3579899fb75455bc7ba57e6144e2d5e04e8 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 9 Oct 2026 21:17:06 +0200 Subject: [PATCH] feat: persist dev bot logs --- IDEA.MD | 2 ++ README.md | 5 ++++- bot.py | 25 ++++++++++++++++++++++++- tests/test_writer_features.py | 9 ++++++++- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/IDEA.MD b/IDEA.MD index e6da146..2636f29 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -65,6 +65,8 @@ Telegram-бот, который по запросу пользователя г - Coolify проект: `Social Post Writer Bot`, окружение `development`. - Coolify приложение: `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`) на `spb-easy-dale`. - SQLite: `/app/data/social_writer.sqlite3`; persistent volume `d32u5zcq2g4odmdpq9hw91yn-social-post-writer-dev-data` монтируется в `/app/data`. +- Важно при поиске логов: приложение запущено на сервере `spb-easy-dale` (`31.129.103.242`), который может отличаться от хоста управления Coolify. Ориентируйся на поле Server у приложения; `docker ps` и `docker logs --since 24h ` выполняй на сервере приложения, имя контейнера начинается с `d32u5zcq2g4odmdpq9hw91yn-`. Не делай вывод об отсутствии контейнера по проверке только хоста Coolify. +- После деплоя файл приложения `/app/data/bot.log` и его ротационные копии хранятся на persistent volume выше и переживают пересоздание контейнера. Пока logging-изменение не задеплоено, используй `docker logs` и API-журнал `api_call_logs` в SQLite. API-логи сохраняются в `/app/data/social_writer.sqlite3` на том же volume. - Секреты хранятся в переменных окружения приложения Coolify; не сохраняются в репозитории. - `DEPLOY_ENV` включает dev-поведение только при точном значении `development`; production, пустое и неизвестное значение отключают его. `BOT_ENV` отдельно выбирает Telegram Bot API. `DEV_CHAT_ID` используется для уведомления о версии; версия в файле `VERSION` (сейчас `0.1.0+30`). - Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`. diff --git a/README.md b/README.md index 6067458..0bd09c9 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,10 @@ SUBSCRIPTION_PRICE_STARS = 100 ## Данные и хранилище -- SQLite: `/app/data/social_writer.sqlite3` (persistent Coolify volume). +- **SQLite:** `/app/data/social_writer.sqlite3` (persistent Coolify volume). +- **Постоянное хранилище dev:** volume `d32u5zcq2g4odmdpq9hw91yn-social-post-writer-dev-data` смонтирован в `/app/data` контейнера на сервере Coolify `spb-easy-dale` (`31.129.103.242`). Сервер приложения и сервер управления Coolify могут быть разными: сначала смотри назначенный приложению Server в записи Coolify, затем проверяй контейнер именно на нём. Не ищи контейнер только на хосте Coolify. +- **Где смотреть ошибки:** подключись к `spb-easy-dale`, найди контейнер по имени `d32u5zcq2g4odmdpq9hw91yn-*` командой `docker ps`, затем смотри его stdout/stderr через `docker logs --since 24h `. После деплоя ротационный файл приложения будет доступен как `/app/data/bot.log` (до 5 MiB и 3 архива); он сохраняется в указанном persistent volume при пересоздании контейнера. До деплоя изменения логирования ещё не действуют. +- **API-журнал ошибки:** открой `/app/data/social_writer.sqlite3`, таблицу `api_call_logs`; записи связываются по `operation_id` и времени. Файл/БД находятся в volume на сервере приложения, не в локальном checkout и не на сервере Coolify, если это другой хост. - **Per-user данные:** профиль аккаунта/ToV, текстовые обучающие заметки, образцы постов со скриншотов, история сгенерированных постов и идеи. В «Обучении» текст/голос/скриншоты накапливают и обновляют профиль, а не запускают генерацию. История диалогов и исходные изображения **не сохраняются**. - **Пустые ответы APInex:** при пустом `content` бот делает до двух повторов; третий запрос добавляет `reasoning_effort: low`. Если ответ всё ещё пустой, операция завершается ошибкой и каждая попытка попадает в API-журнал. Ошибки HTTP автоматически не повторяются. APInex документирует `low`, но не режим отключения reasoning; поддержка параметра зависит от модели. - **Починка формата JSON:** при синтаксически невалидном JSON выполняется не более одной отдельной попытки модели `APINEX_RESPONSE_REPAIR_MODEL`; затем исходные проверки структуры/схемы запускаются заново. Ошибки API, пустые ответы и JSON с неверной схемой автоматически не исправляются. В Coolify development задана `APINEX_RESPONSE_REPAIR_MODEL`. diff --git a/bot.py b/bot.py index 556f73c..9d42d21 100644 --- a/bot.py +++ b/bot.py @@ -2,10 +2,12 @@ import asyncio import html import io import logging +from logging.handlers import RotatingFileHandler import os import random import re import sqlite3 +import sys from contextlib import asynccontextmanager, nullcontext from pathlib import Path @@ -55,7 +57,23 @@ from user_store import ( subscription_row, ) -logging.basicConfig(level=logging.INFO) +def _configure_logging() -> None: + handlers: list[logging.Handler] = [logging.StreamHandler()] + try: + log_path = Path(os.getenv("BOT_LOG_PATH", "/app/data/bot.log")) + log_path.parent.mkdir(parents=True, exist_ok=True) + handlers.append(RotatingFileHandler( + log_path, + maxBytes=5 * 1024 * 1024, + backupCount=3, + encoding="utf-8", + )) + except OSError as exc: + print(f"File logging unavailable ({type(exc).__name__}); using container logs", file=sys.stderr) + logging.basicConfig(level=logging.INFO, handlers=handlers) + + +_configure_logging() logger = logging.getLogger(__name__) VERSION = Path(__file__).with_name("VERSION").read_text(encoding="utf-8").strip() BOT_ENV = os.getenv("BOT_ENV", "test").strip().lower() @@ -618,6 +636,11 @@ async def _check_subscription(message: Message) -> bool: async def _generate_post_from_training(message: Message) -> None: + with diagnostic_operation(): + await _generate_post_from_training_inner(message) + + +async def _generate_post_from_training_inner(message: Message) -> None: if message.from_user is None or not await _check_subscription(message): return user_id = message.from_user.id diff --git a/tests/test_writer_features.py b/tests/test_writer_features.py index 99befef..732d89d 100644 --- a/tests/test_writer_features.py +++ b/tests/test_writer_features.py @@ -491,9 +491,14 @@ class GenerationClassificationRoutingTests(unittest.IsolatedAsyncioTestCase): async def test_generate_post_uses_training_material_without_intent_classifier(self): message = self.make_message() status = AsyncMock() + operation_ids = [] async def run_in_thread(function, *args): return function(*args) + async def generate_with_operation(*args, **kwargs): + operation_ids.append(apinex_client.current_operation_id()) + return ["A", "B", "C"] + with ( patch.object(bot, "_check_subscription", new=AsyncMock(return_value=True)), patch.object(bot, "is_user_trained", return_value=True), @@ -502,13 +507,15 @@ class GenerationClassificationRoutingTests(unittest.IsolatedAsyncioTestCase): patch.object(bot, "get_post_samples", return_value=["Открыли террасу"]), patch.object(bot, "get_training_notes", return_value=["Работаем в Калининграде"]), patch.object(bot.OperationStatus, "start", new=AsyncMock(return_value=status)), - patch.object(bot, "generate_posts", new=AsyncMock(return_value=["A", "B", "C"])) as generate, + patch.object(bot, "generate_posts", new=AsyncMock(side_effect=generate_with_operation)) as generate, patch.object(bot, "_send_three_posts", new=AsyncMock()) as send_posts, patch.object(bot, "classify_intent", new=AsyncMock()) as classify, ): await bot._generate_post_from_training(cast(Any, message)) classify.assert_not_awaited() + self.assertEqual(len(operation_ids), 1) + self.assertTrue(operation_ids[0]) self.assertIsNotNone(generate.await_args) args = generate.await_args.args self.assertEqual(args[0]["intent"], "write_post")