feat: persist dev bot logs

This commit is contained in:
admin committed 2026-10-09 21:17:06 +02:00
1 parent a43ededa1b
commit 9f62b35798
4 files changed
+38 -3

No files matched your search

+2
View File
@@ -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 <container>` выполняй на сервере приложения, имя контейнера начинается с `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`.
+4 -1
View File
@@ -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 <container>`. После деплоя ротационный файл приложения будет доступен как `/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`.
+24 -1
View File
@@ -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
+8 -1
View File
@@ -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")