From e7f91e5e5b71a4030a06dc3a83d661e136c76315 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 6 Jun 2026 00:01:44 +0200 Subject: [PATCH] fix: scope quiz logging and bot status --- docker-compose.yml | 6 ++-- promtail/promtail-config.yml | 10 ++++-- telegram-bot/bot.py | 59 ++++++++++++++++++++++++++++++------ 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 4ff377c..9f7cc3d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,7 @@ services: - chroma_data:/app/chroma_data networks: - default - - speech_net + - danvics_speech depends_on: postgres: condition: service_healthy @@ -74,7 +74,7 @@ services: env_file: - ./telegram-bot/.env environment: - - DATABASE_URL=postgresql://pedquiz:@postgres:5432/pedquiz + - DATABASE_URL=postgresql://pedquiz:${POSTGRES_PASSWORD}@postgres:5432/pedquiz - PUBLIC_APP_URL=${APP_URL:-https://pedshub.com} depends_on: postgres: @@ -147,5 +147,5 @@ volumes: promtail_positions: networks: - speech_net: + danvics_speech: external: true diff --git a/promtail/promtail-config.yml b/promtail/promtail-config.yml index c397c00..c132e2c 100644 --- a/promtail/promtail-config.yml +++ b/promtail/promtail-config.yml @@ -13,10 +13,14 @@ scrape_configs: docker_sd_configs: - host: unix:///var/run/docker.sock refresh_interval: 5s + filters: + - name: label + values: ["com.docker.compose.project=quiz"] relabel_configs: - # Keep only quiz-* containers - - source_labels: ['__meta_docker_container_name'] - regex: '.*quiz.*' + # Keep only this compose project; the Docker API filter above prevents + # Promtail from opening non-readable log streams from other stacks. + - source_labels: ['__meta_docker_container_label_com_docker_compose_project'] + regex: 'quiz' action: keep # Extract container name as label - source_labels: ['__meta_docker_container_name'] diff --git a/telegram-bot/bot.py b/telegram-bot/bot.py index 478d89c..87c81bc 100644 --- a/telegram-bot/bot.py +++ b/telegram-bot/bot.py @@ -24,7 +24,7 @@ DATABASE_URL = os.environ["DATABASE_URL"] DEFAULT_QUIZ_SIZE = int(os.getenv("DEFAULT_QUIZ_SIZE", "20")) MAX_QUIZ_SIZE = int(os.getenv("MAX_QUIZ_SIZE", "50")) PUBLIC_APP_URL = os.getenv("PUBLIC_APP_URL", "https://pedshub.com").rstrip("/") -MAX_EXPLANATION_CHARS = int(os.getenv("MAX_EXPLANATION_CHARS", "1800")) +TELEGRAM_MESSAGE_LIMIT = 4096 LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" NUMBER_RE = re.compile(r"^\s*(\d{1,3})\s*$") @@ -269,15 +269,56 @@ def truncate_text(value: str, limit: int) -> str: return value[:limit].rsplit(" ", 1)[0].rstrip() + "..." -def answer_feedback(question: dict[str, Any], chosen_idx: int, correct_idx: int, ok: bool) -> str: +def answer_feedback_header(chosen_idx: int, correct_idx: int, ok: bool) -> str: chosen = LETTERS[chosen_idx] if chosen_idx < len(LETTERS) else "?" correct = LETTERS[correct_idx] if correct_idx < len(LETTERS) else "?" - explanation = truncate_text(question.get("explanation") or "No explanation available.", MAX_EXPLANATION_CHARS) - return "\n".join([ - "Correct." if ok else f"Incorrect. You chose {chosen}; correct answer: {correct}.", - "", - f"Explanation: {html.escape(explanation)}", - ]) + return "Correct." if ok else f"Incorrect. You chose {chosen}; correct answer: {correct}." + + +def split_plain_text(text: str, limit: int) -> list[str]: + if len(text) <= limit: + return [text] + chunks = [] + remaining = text + while remaining: + if len(remaining) <= limit: + chunks.append(remaining) + break + split_at = remaining.rfind("\n\n", 0, limit) + if split_at < limit // 2: + split_at = remaining.rfind("\n", 0, limit) + if split_at < limit // 2: + split_at = remaining.rfind(" ", 0, limit) + if split_at < limit // 2: + split_at = limit + chunks.append(remaining[:split_at].rstrip()) + remaining = remaining[split_at:].lstrip() + return chunks + + +async def send_answer_feedback( + context: ContextTypes.DEFAULT_TYPE, + chat_id: int, + question: dict[str, Any], + chosen_idx: int, + correct_idx: int, + ok: bool, +) -> None: + header = answer_feedback_header(chosen_idx, correct_idx, ok) + explanation = (question.get("explanation") or "No explanation available.").strip() + first_prefix = f"{header}\n\nExplanation: " + next_prefix = "Explanation continued: " + first_limit = TELEGRAM_MESSAGE_LIMIT - len(first_prefix) - 200 + next_limit = TELEGRAM_MESSAGE_LIMIT - len(next_prefix) - 200 + chunks = split_plain_text(explanation, max(1000, first_limit)) + for idx, chunk in enumerate(chunks): + prefix = first_prefix if idx == 0 else next_prefix + if idx > 0 and len(chunk) > next_limit: + # First chunk has a smaller budget because it includes answer feedback. + for subchunk in split_plain_text(chunk, next_limit): + await context.bot.send_message(chat_id, f"{next_prefix}{html.escape(subchunk)}", parse_mode=ParseMode.HTML) + continue + await context.bot.send_message(chat_id, f"{prefix}{html.escape(chunk)}", parse_mode=ParseMode.HTML) def answered_question_text(state: QuizState, question: dict[str, Any], chosen_idx: int, correct_idx: int) -> str: @@ -527,7 +568,7 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - answered_question_text(state, question, chosen_idx, correct_idx), parse_mode=ParseMode.HTML, ) - await context.bot.send_message(chat_id, answer_feedback(question, chosen_idx, correct_idx, ok), parse_mode=ParseMode.HTML) + await send_answer_feedback(context, chat_id, question, chosen_idx, correct_idx, ok) else: await query.edit_message_reply_markup(reply_markup=None) state.index += 1