diff --git a/infra/components/bot.py b/infra/components/bot.py index d1b0578..138ed08 100644 --- a/infra/components/bot.py +++ b/infra/components/bot.py @@ -113,7 +113,7 @@ def __init__( runtime=LAMBDA_RUNTIME, architecture=_lambda.Architecture.ARM_64, layers=[shared_layer], - timeout=Duration.seconds(90), + timeout=Duration.seconds(300), memory_size=1024, log_group=logs.LogGroup( self, diff --git a/infra/components/messaging.py b/infra/components/messaging.py index 7b62634..3402bda 100644 --- a/infra/components/messaging.py +++ b/infra/components/messaging.py @@ -34,7 +34,7 @@ def __init__( f"{CONSTRUCT_PREFIX}TimeoutTasksQueue", queue_name=f"{RESOURCE_PREFIX}-timeout-tasks-queue-{env_name}", retention_period=Duration.hours(1), - visibility_timeout=Duration.seconds(60 * 3), + visibility_timeout=Duration.seconds(1800), # >= 6 × Lambda timeout (300 s) receive_message_wait_time=Duration.seconds(20), removal_policy=removal_policy, dead_letter_queue=sqs.DeadLetterQueue( diff --git a/src/bot/core/config.py b/src/bot/core/config.py index 882a93e..ef270a0 100644 --- a/src/bot/core/config.py +++ b/src/bot/core/config.py @@ -88,11 +88,7 @@ def get_deepseek_api_key() -> str | None: MAX_EXPLAIN_MEDIA_BYTES: int = int(os.environ.get("MAX_EXPLAIN_MEDIA_BYTES", str(15 * 1024 * 1024))) # MIME types allowed for automatic document summarization (non-command uploads). DOCUMENT_AUTO_SUMMARY_MIMES: frozenset[str] = frozenset( - { - "application/pdf", - "text/plain", - "text/markdown", - } + {"application/pdf", "text/plain", "text/markdown", "text/x-web-markdown"} ) # ── Chat → language mapping ────────────────────────────────────────────────── diff --git a/src/bot/core/translations.py b/src/bot/core/translations.py index bfb32db..56cc9a4 100644 --- a/src/bot/core/translations.py +++ b/src/bot/core/translations.py @@ -97,12 +97,13 @@ "quizstats_response": ( "🧠 Your Quiz Stats\n" "📍 {chat_title}\n\n" - "🏆 Score: {score} points\n" - "🔥 Streak: {streak} days\n" - "⭐ Best streak: {best_streak} days\n" - "📊 Rank: #{rank} / {total_players} players" + "🗓 This week: {week_score} pts · Rank #{rank} / {total_players} players\n" + "🎖 Season wins: {season_wins}\n" + "──────────────\n" + "⭐ All-time: {total_score} pts\n" + "🔥 Streak: {streak} days current · {best_streak} days best" ), - "quizstats_no_data": "🧠 You haven't answered any quizzes yet. Wait for the next daily quiz!", + "quizstats_no_data": "🧠 No quiz score yet — answer tomorrow's daily quiz to get on the board!", "quizstats_open_private_chat": ( "📬 I couldn't send you a private message.\n" "Please open a chat with me and send /start first, then try /quizstats again." @@ -144,8 +145,9 @@ "genquiz_lambda_not_configured": "❌ Quiz Lambda is not configured.", "genquiz_usage": ( "❌ Usage: /genquiz <topic> [<difficulty> [<lang>]]\n" - "Order: topic → difficulty → language. " - "Defaults: difficulty medium, language from this chat (CHAT_LANG_MAP)." + "Order: topic → difficulty → language.\n" + "Difficulties: easy, medium, hard, expert.\n" + "Defaults: difficulty medium, language from this group's default." ), "genquiz_invalid_lang": "❌ Invalid lang. Choose from: {langs}", "genquiz_invalid_difficulty": "❌ Invalid difficulty. Choose from: {difficulties}", @@ -262,19 +264,21 @@ "voteban_forgiven": ( "💚 Бұғаттаудан бас тартылды\n\n" "🎯 {TARGET} {VOTES_AGAINST} дауыспен ақталды.\n\n" - "👼 Ақтап шыққандар (қарсы дауыс бергендер): {VOTERS_AGAINST}" + "👼 Ақтап шыққандар: {VOTERS_AGAINST}" ), "quizstats_response": ( - "🧠 Сіздің Quiz бойынша нәтижеңіз:\n" + "🧠 Сіздің Quiz статистикаңыз\n" "📍 {chat_title}\n\n" - "🏆 Жалпы ұпай: {score}\n" - "🔥 Үздіксіз streak: {streak} күн\n" - "⭐ Ең ұзақ streak: {best_streak} күн\n" - "📊 Рейтингтегі орныңыз: #{rank} / {total_players} қатысушы" + "🗓 Осы аптада: {week_score} ұпай · Рейтинг #{rank} / {total_players} қатысушы\n" + "🎖 Маусымдағы жеңістер: {season_wins}\n" + "──────────────\n" + "⭐ Барлық уақыт бойынша: {total_score} ұпай\n" + "🔥 Серия (Streak): қазір {streak} күн · рекорд {best_streak} күн" ), - "quizstats_no_data": "🧠 Сіз әлі ешқандай Quiz сұрағына жауап бермепсіз. Келесі сұрақты жіберіп алмаңыз!", + "quizstats_no_data": "🧠 Сіздің ұпайыңыз әлі жоқ — ертеңгі күнделікті сұраққа жауап беріп, рейтингке кіріңіз!", "quizstats_open_private_chat": ( - "📬 Сізге жеке хабарлама жібере алмадым.\n" "Алдымен менімен жеке чат ашып, /start пәрменін жіберіңіз" + "📬 Сізге жеке хабарлама жібере алмадым.\n" + "Алдымен менімен жеке чат ашып, /start пәрменін жіберіңіз, содан соң /quizstats қайта көріңіз." ), "quiz_not_configured": "⚙️ Quiz бұл бот үшін бапталмаған.", "wtf_usage": ( @@ -312,8 +316,9 @@ "genquiz_lambda_not_configured": "❌ Quiz Lambda бапталмаған.", "genquiz_usage": ( "❌ Қолданылуы: /genquiz <тақырып> [<деңгей> [<тіл>]]\n" - "Реті: тақырып → деңгей → тіл. " - "Әдепкі: деңгей medium, тіл чаттан (CHAT_LANG_MAP)." + "Реті: тақырып → деңгей → тіл.\n" + "Деңгейлер: easy, medium, hard, expert.\n" + "Әдепкі: деңгей medium, тіл осы топтың негізгі тілі бойынша." ), "genquiz_invalid_lang": "❌ Тіл қате. Келесілерді таңдаңыз: {langs}", "genquiz_invalid_difficulty": "❌ Деңгей қате. Келесілерді таңдаңыз: {difficulties}", @@ -428,12 +433,13 @@ "quizstats_response": ( "🧠 你的 Quiz 统计\n" "📍 {chat_title}\n\n" - "🏆 积分:{score}\n" - "🔥 连胜:{streak} 天\n" - "⭐ 最佳连胜:{best_streak} 天\n" - "📊 排名:#{rank} / {total_players} 人" + "🗓 本周:{week_score} 分 · 排名 #{rank} / {total_players} 人\n" + "🎖 赛季冠军次数:{season_wins}\n" + "──────────────\n" + "⭐ 历史总分:{total_score} 分\n" + "🔥 连胜:当前 {streak} 天 · 最佳 {best_streak} 天" ), - "quizstats_no_data": "🧠 你还没有答过 Quiz 题目。请等待下一次每日测验!", + "quizstats_no_data": "🧠 暂无积分记录 —— 明天参加每日测验即可上榜!", "quizstats_open_private_chat": ( "📬 我无法给你发送私信。\n" "请先打开与我的私聊并发送 /start,然后再试一次 /quizstats。" ), @@ -467,7 +473,8 @@ "genquiz_usage": ( "❌ 用法:/genquiz <主题> [<难度> [<语言>]]\n" "顺序:主题 → 难度 → 语言。\n" - "默认:难度 medium,语言为本群设置(CHAT_LANG_MAP)。" + "可选难度:easy, medium, hard, expert。\n" + "默认:难度 medium,语言为当前群组的默认语言。" ), "genquiz_invalid_lang": "❌ 语言无效。可选:{langs}", "genquiz_invalid_difficulty": "❌ 难度无效。可选:{difficulties}", @@ -582,12 +589,13 @@ "quizstats_response": ( "🧠 Ваша статистика Quiz\n" "📍 {chat_title}\n\n" - "🏆 Очки: {score}\n" - "🔥 Серия: {streak} дней\n" - "⭐ Лучшая серия: {best_streak} дней\n" - "📊 Ранг: #{rank} / {total_players} игроков" + "🗓 На этой неделе: {week_score} очк. · Ранг #{rank} / {total_players} игроков\n" + "🎖 Побед в сезоне: {season_wins}\n" + "──────────────\n" + "⭐ За всё время: {total_score} очк.\n" + "🔥 Серия: {streak} дн. сейчас · {best_streak} дн. рекорд" ), - "quizstats_no_data": "🧠 Вы еще не отвечали на Quiz. Дождитесь следующего ежедневного вопроса!", + "quizstats_no_data": "🧠 Очков пока нет — ответьте на завтрашний ежедневный вопрос и попадите в рейтинг!", "quizstats_open_private_chat": ( "📬 Я не смог отправить вам личное сообщение.\n" "Сначала откройте со мной личный чат и отправьте /start, затем попробуйте /quizstats снова." @@ -628,8 +636,9 @@ "genquiz_lambda_not_configured": "❌ Quiz Lambda не настроена.", "genquiz_usage": ( "❌ Использование: /genquiz <тема> [<сложность> [<язык>]]\n" - "Порядок: тема → сложность → язык. " - "По умолчанию: сложность medium, язык чата (CHAT_LANG_MAP)." + "Порядок: тема → сложность → язык.\n" + "Сложности: easy, medium, hard, expert.\n" + "По умолчанию: сложность medium, язык по умолчанию для этой группы." ), "genquiz_invalid_lang": "❌ Неверный язык. Выберите из: {langs}", "genquiz_invalid_difficulty": "❌ Неверная сложность. Выберите из: {difficulties}", diff --git a/src/bot/services/ai/gemini_client.py b/src/bot/services/ai/gemini_client.py index 85647f4..74abf7e 100644 --- a/src/bot/services/ai/gemini_client.py +++ b/src/bot/services/ai/gemini_client.py @@ -4,8 +4,11 @@ across all Lambda invocations and chat groups. The "day" matches Gemini/Google: calendar date in America/Los_Angeles (midnight PT reset). -Timeout budget: explain tasks run via SQS Lambda (90 s budget), not API Gateway. -Plain-text explain: read timeout 25 s. Multimodal (large inline payloads): read 90 s. +Timeout budget: explain tasks run via SQS Lambda (300 s budget), not API Gateway. +Plain-text explain: read timeout 25 s. Multimodal (large inline payloads): read 150 s. + +Retry policy: up to 3 retries with exponential back-off (2 s, 4 s, 8 s) for +transient failures (5xx, network/timeout). 429 raises immediately — no retry. """ import base64 @@ -29,7 +32,7 @@ logger = LoggerAdapter(get_logger(__name__), {}) _http = urllib3.PoolManager(maxsize=2, timeout=urllib3.Timeout(connect=3, read=25)) -_http_multimodal = urllib3.PoolManager(maxsize=2, timeout=urllib3.Timeout(connect=3, read=90)) +_http_multimodal = urllib3.PoolManager(maxsize=2, timeout=urllib3.Timeout(connect=3, read=150)) def _thinking_config_for_model(model: str) -> dict[str, Any] | None: @@ -115,9 +118,8 @@ def explain_term(self, term: str, lang: str = "kk", style: WTFPromptStyle = "ang body = json.dumps(payload) headers = {"Content-Type": "application/json"} - # Single retry for transient network/timeout failures. 503 (model globally - # overloaded) breaks immediately — retrying in 1s won't help and just delays fallback. - _retry_delays = (1,) + # 3 retries with exponential back-off; 429 raises immediately without retry. + _RETRY_DELAYS = (2, 4, 8) last_exc: Exception | None = None logger.info( @@ -134,7 +136,7 @@ def explain_term(self, term: str, lang: str = "kk", style: WTFPromptStyle = "ang "term_chars": len(term), }, ) - for attempt, delay in enumerate(_retry_delays): + for attempt in range(len(_RETRY_DELAYS) + 1): try: logger.info( "Gemini explain request started", @@ -147,8 +149,8 @@ def explain_term(self, term: str, lang: str = "kk", style: WTFPromptStyle = "ang extra={"model": self._model, "attempt": attempt + 1, "error": str(exc)}, ) last_exc = GeminiUnavailableError(f"Gemini unreachable: {exc}") - if attempt < len(_retry_delays) - 1: - time.sleep(delay + random.uniform(0, 2)) + if attempt < len(_RETRY_DELAYS): + time.sleep(_RETRY_DELAYS[attempt] + random.uniform(0, 1)) continue if resp.status == 429: @@ -162,10 +164,8 @@ def explain_term(self, term: str, lang: str = "kk", style: WTFPromptStyle = "ang extra={"status": resp.status, "attempt": attempt + 1, "body": body_text[:200]}, ) last_exc = GeminiUnavailableError(f"Gemini API {resp.status}: {body_text[:200]}") - if resp.status == 503: - break # globally overloaded — fall back immediately, don't retry - if attempt < len(_retry_delays) - 1: - time.sleep(delay + random.uniform(0, 2)) + if attempt < len(_RETRY_DELAYS): + time.sleep(_RETRY_DELAYS[attempt] + random.uniform(0, 1)) continue if resp.status >= 400: @@ -261,7 +261,8 @@ def explain_media( url = f"{GEMINI_API_BASE}/{self._model}:generateContent?key={self._api_key}" body = json.dumps(payload) headers = {"Content-Type": "application/json"} - _retry_delays = (1,) + # 3 retries with exponential back-off; 429 raises immediately without retry. + _RETRY_DELAYS = (2, 4, 8) last_exc: Exception | None = None logger.info( @@ -277,7 +278,7 @@ def explain_media( }, ) - for attempt, delay in enumerate(_retry_delays): + for attempt in range(len(_RETRY_DELAYS) + 1): try: resp = _http_multimodal.request( "POST", @@ -292,8 +293,8 @@ def explain_media( extra={"model": self._model, "attempt": attempt + 1, "error": str(exc)}, ) last_exc = GeminiUnavailableError(f"Gemini unreachable: {exc}") - if attempt < len(_retry_delays) - 1: - time.sleep(delay + random.uniform(0, 2)) + if attempt < len(_RETRY_DELAYS): + time.sleep(_RETRY_DELAYS[attempt] + random.uniform(0, 1)) continue if resp.status == 429: @@ -307,10 +308,8 @@ def explain_media( extra={"status": resp.status, "attempt": attempt + 1, "body": body_text[:200]}, ) last_exc = GeminiUnavailableError(f"Gemini API {resp.status}: {body_text[:200]}") - if resp.status == 503: - break - if attempt < len(_retry_delays) - 1: - time.sleep(delay + random.uniform(0, 2)) + if attempt < len(_RETRY_DELAYS): + time.sleep(_RETRY_DELAYS[attempt] + random.uniform(0, 1)) continue if resp.status >= 400: diff --git a/src/bot/services/handlers/commands.py b/src/bot/services/handlers/commands.py index fa492f2..8476a93 100644 --- a/src/bot/services/handlers/commands.py +++ b/src/bot/services/handlers/commands.py @@ -1,7 +1,6 @@ """Simple bot commands: /start, /help, /support, /ping, /stats, /genquiz.""" from core.config import ( - ADMIN_USER_ID, QUIZ_LAMBDA_NAME, VALID_DIFFICULTIES, VALID_LANGS, @@ -115,15 +114,11 @@ def handle_stats(ctx: Context) -> None: def handle_quiz_generate(ctx: Context) -> None: - """Admin-only: generate and send an on-demand quiz poll to the current chat. + """Generate and send an on-demand quiz poll to the current chat (open to all users). Usage: ``/genquiz `` [, ```` [, ````]] — fixed order; omitted difficulty defaults to ``medium``, omitted lang to this chat's default. """ - if ctx.user_id != ADMIN_USER_ID: - react_genquiz_processing(ctx, "🤡") - return - if not QUIZ_LAMBDA_NAME or not ctx.lambda_invoker: react_genquiz_processing(ctx, "🤡") ctx.reply(get_translated_text("genquiz_lambda_not_configured", ctx.lang_code), ctx.message_id) @@ -165,7 +160,6 @@ def handle_quiz_generate(ctx: Context) -> None: "topic": topic, "lang": lang, "difficulty": difficulty, - "include_rpd_footer": True, "reply_to_message_id": ctx.message_id, }, ) diff --git a/src/bot/services/handlers/explain_document.py b/src/bot/services/handlers/explain_document.py index 14f4e8f..a730506 100644 --- a/src/bot/services/handlers/explain_document.py +++ b/src/bot/services/handlers/explain_document.py @@ -63,11 +63,9 @@ def handle_document_auto_summary(ctx: Context) -> None: if not isinstance(doc, dict): return - allowed, reason_key = document_auto_allowed(doc) + allowed, _ = document_auto_allowed(doc) if not allowed: - if reason_key: - _react(ctx, _ERROR_REACTION) - ctx.reply(get_translated_text(reason_key, lang), ctx.message_id) + _react(ctx, _ERROR_REACTION) return file_id = doc.get("file_id") diff --git a/src/bot/services/handlers/quiz.py b/src/bot/services/handlers/quiz.py index 594fa8c..72d8b97 100644 --- a/src/bot/services/handlers/quiz.py +++ b/src/bot/services/handlers/quiz.py @@ -69,7 +69,7 @@ def handle_poll_answer(ctx: Context) -> None: # Look up the quiz record by poll_id quiz_record = ctx.quiz_repo.lookup_poll(poll_id) if not quiz_record: - logger.debug("poll_answer for unknown poll_id, ignoring", extra={"poll_id": poll_id}) + logger.warning("poll_answer for unknown poll_id, ignoring", extra={"poll_id": poll_id}) return chat_id = quiz_record["PK"].replace("QUIZ#", "") @@ -96,14 +96,23 @@ def handle_quizstats(ctx: Context) -> None: user_id = str(ctx.user_id) lang = ctx.lang_code - user_score = ctx.quiz_repo.get_user_score(chat_id, user_id) - if not user_score: - ctx.reply(get_translated_text("quizstats_no_data", lang)) + user_score_raw = ctx.quiz_repo.get_user_score(chat_id, user_id) + if user_score_raw is None: + try: + ctx.send_private_message(get_translated_text("quizstats_no_data", lang)) + ctx.react("👌") + except TelegramAPIError as error: + if error.status == 403: + ctx.reply(get_translated_text("quizstats_open_private_chat", lang), ctx.message_id) + return + raise return - # Calculate rank + user_score = user_score_raw + leaderboard = ctx.quiz_repo.get_leaderboard(chat_id) - rank = 1 + # Unranked users (on the board but not in this week's list) go after the last row + rank = len(leaderboard) + 1 for i, entry in enumerate(leaderboard): if entry.get("SK") == f"USER#{user_id}": rank = i + 1 @@ -117,9 +126,11 @@ def handle_quizstats(ctx: Context) -> None: "quizstats_response", lang, chat_title=chat_title, - score=user_score.get("total_score", 0), - streak=user_score.get("current_streak", 0), - best_streak=user_score.get("best_streak", 0), + week_score=int(user_score.get("week_score", 0)), + season_wins=int(user_score.get("season_wins", 0)), + total_score=int(user_score.get("total_score", 0)), + streak=int(user_score.get("current_streak", 0)), + best_streak=int(user_score.get("best_streak", 0)), rank=rank, total_players=len(leaderboard), ) diff --git a/src/bot/services/handlers/wtf.py b/src/bot/services/handlers/wtf.py index 6e3faed..ded5b24 100644 --- a/src/bot/services/handlers/wtf.py +++ b/src/bot/services/handlers/wtf.py @@ -3,7 +3,7 @@ from __future__ import annotations import time -from typing import Callable, cast +from typing import Any, Callable, cast from core.config import get_chat_lang, get_deepseek_api_key, get_gemini_api_key from core.dispatcher import Context @@ -55,12 +55,21 @@ def _get_task_repo() -> ExplainTaskRepository: return _task_repo +def _reply_message_visible_text(reply: dict[str, Any]) -> str: + """Text body or media caption (channel posts and albums often use caption, not text).""" + for key in ("text", "caption"): + raw = reply.get(key) + if isinstance(raw, str) and raw.strip(): + return raw.strip() + return "" + + def _extract_term(ctx: Context) -> str: parts = ctx.text.split(maxsplit=1) if len(parts) > 1: return parts[1].strip() if ctx.reply_to_message: - return (ctx.reply_to_message.get("text") or "").strip() + return _reply_message_visible_text(ctx.reply_to_message) # External replies (cross-chat forwards) have no reply_to_message; # Telegram instead populates `quote.text` with the selected fragment. quote_text = (ctx.message.get("quote") or {}).get("text") or "" diff --git a/src/bot/webhook.py b/src/bot/webhook.py index adcebf8..1d84abc 100644 --- a/src/bot/webhook.py +++ b/src/bot/webhook.py @@ -17,6 +17,7 @@ from services.repositories.sqs import SQSClient from services.spam.screening_service import SpamScreeningService from services.telegram import TelegramClient +from zerde_common.logging_utils import telegram_update_log_extra logger = LoggerAdapter(get_logger(__name__), {}) @@ -71,12 +72,15 @@ def _handle_api_gateway( try: body = parse_api_gateway_event(event) - logger.info("API Gateway event parsed successfully") except ValueError as e: logger.error("Failed to parse API Gateway event", extra={"error": e}) return create_response(200, {"message": "Invalid request"}) chat_id, chat_type = _extract_chat_context(body) + update_log = telegram_update_log_extra(body) + update_log["chat_id"] = chat_id + update_log["chat_type"] = chat_type + logger.info("Telegram webhook update received", extra=update_log) if chat_type == "private": dispatcher.bot.send_message( diff --git a/src/quiz/main.py b/src/quiz/main.py index e6b1ad7..350f18d 100644 --- a/src/quiz/main.py +++ b/src/quiz/main.py @@ -39,7 +39,6 @@ def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]: chat_id = event.get("chat_id", "") topic = event.get("topic", "programming") difficulty = event.get("difficulty", "medium") - include_rpd_footer = bool(event.get("include_rpd_footer", False)) reply_to_message_id = event.get("reply_to_message_id") if not chat_id: return {"status": "error", "reason": "missing chat_id"} @@ -48,7 +47,6 @@ def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]: lang, topic, difficulty, - include_rpd_footer=include_rpd_footer, reply_to_message_id=reply_to_message_id if isinstance(reply_to_message_id, int) else None, ) diff --git a/src/quiz/services/quiz_sender.py b/src/quiz/services/quiz_sender.py index d5e4ee9..2f4aa46 100644 --- a/src/quiz/services/quiz_sender.py +++ b/src/quiz/services/quiz_sender.py @@ -62,6 +62,7 @@ def send_quiz_poll( options: list[str], correct_option_id: int, explanation: str | None = None, + question_parse_mode: str | None = None, ) -> dict[str, Any] | None: """Send a quiz poll to a chat. Returns the Telegram response result or None on failure.""" url = f"{self._base_url}/sendPoll" @@ -77,6 +78,8 @@ def send_quiz_poll( } if explanation: payload["explanation"] = explanation[:200] # Telegram limit + if question_parse_mode: + payload["question_parse_mode"] = question_parse_mode try: resp = http.request( diff --git a/src/quiz/services/quiz_service.py b/src/quiz/services/quiz_service.py index f722dd7..c5ec4b1 100644 --- a/src/quiz/services/quiz_service.py +++ b/src/quiz/services/quiz_service.py @@ -36,6 +36,21 @@ "aws-dva-c02": "AWS Developer Associate Practice Exam", } +# Topic aliases that /genquiz should serve from the bank instead of AI. +# Keys are lowercased topic strings; values are the banked category name. +_GENQUIZ_TOPIC_TO_BANKED: dict[str, str] = { + "cloud": "cloud", + "aws": "cloud", + "aws-clf": "cloud", + "clf": "cloud", + "clf-c02": "cloud", + "aws-clf-c02": "cloud", + "dva": "cloud", + "aws-dva": "cloud", + "dva-c02": "cloud", + "aws-dva-c02": "cloud", +} + class QuizService: """Orchestrates quiz operations (daily quiz and leaderboards).""" @@ -46,13 +61,6 @@ def __init__(self) -> None: self._sender = QuizSender() self._repo = QuizRepository() - def _rpd_payload(self) -> dict[str, int]: - """Build a response fragment with quiz Gemini RPD counters.""" - remaining, total = self._generator.get_rpd_status() - if remaining is None or total is None: - return {} - return {"rpd_remaining": remaining, "rpd_total": total} - def get_difficulty(self) -> str: """Return the difficulty level for today (Almaty time).""" weekday = datetime.now(_ALMATY_TZ).weekday() @@ -210,6 +218,47 @@ def _pick_banked_question_for_chat(self, category: str, chat_id: str, difficulty self._repo.save_question_queue(category, chat_id, remaining) return None + def _pick_banked_question_for_genquiz(self, category: str, chat_id: str, difficulty: str) -> dict | None: + """Pick the next on-demand question from the bank using a genquiz-specific per-chat queue. + + Uses a different DynamoDB key and shuffle seed from the daily rotation so that + genquiz picks are unlikely to collide with upcoming daily questions. + Daily category queue is never read or written by this method. + """ + sources = _BANKED_CATEGORIES[category] + remaining = self._repo.get_genquiz_question_queue(category, chat_id) + if not remaining: + all_keys = self._repo.get_bank_question_ids(category, sources) + if not all_keys: + return None + # Different seed from daily ("genquiz:" prefix) → different shuffle order + rng = random.Random(f"genquiz:{chat_id}::{len(all_keys)}") + remaining = rng.sample(all_keys, len(all_keys)) + logger.info( + "New genquiz bank round", + extra={"chat_id": chat_id, "category": category, "total": len(remaining)}, + ) + + while remaining: + key = remaining.pop(0) + source, q_uuid = key.split("::", 1) + item = self._repo.get_bank_question(category, source, q_uuid) + if item: + self._repo.save_genquiz_question_queue(category, chat_id, remaining) + return { + "question": item["question"], + "options": list(item["options"]), + "correct_option_index": int(item["correct_option_id"]), + "explanation": item.get("explanation", ""), + "difficulty": difficulty, + "points": DIFFICULTY_POINTS.get(difficulty, 1), + "source_label": _BANK_SOURCE_LABELS.get(source, source), + } + logger.warning("Genquiz bank question missing, skipping", extra={"uuid": q_uuid}) + + self._repo.save_genquiz_question_queue(category, chat_id, remaining) + return None + def process_daily_quiz(self, chat_ids: list[str], lang: str) -> dict: """Generate and send the daily quiz to each chat with independent category rotation.""" if not chat_ids: @@ -224,6 +273,16 @@ def process_daily_quiz(self, chat_ids: list[str], lang: str) -> dict: failed: list[dict] = [] for chat_id in chat_ids: + existing = self._repo.get_today_quiz_record(str(chat_id)) + if existing: + logger.info( + "Daily quiz already sent for this chat today, skipping", + extra={"chat_id": chat_id, "poll_id": existing.get("poll_id")}, + ) + sent_count += 1 + sent_chat_ids.append(str(chat_id)) + continue + category, remaining = self._pick_category_for_chat(str(chat_id)) generated = None used_category = category @@ -333,16 +392,68 @@ def process_daily_quiz(self, chat_ids: list[str], lang: str) -> dict: } def process_on_demand_quiz(self, chat_id: str, lang: str, topic: str, difficulty: str) -> dict: - """Generate and send a single on-demand quiz to one chat.""" + """Generate and send a single on-demand quiz to one chat. + + For topics that map to a banked category (e.g. "cloud", "aws"), questions are drawn + from the question bank using a per-chat genquiz queue that is independent from the + daily rotation. The RPD footer is omitted for bank-sourced questions (no AI used). + """ logger.info( "On-demand quiz requested", extra={"chat_id": chat_id, "topic": topic, "lang": lang, "difficulty": difficulty}, ) + # ── Bank path ──────────────────────────────────────────────────────── + banked_category = _GENQUIZ_TOPIC_TO_BANKED.get(topic.lower().strip()) + if banked_category: + banked = self._pick_banked_question_for_genquiz(banked_category, str(chat_id), difficulty) + if banked: + question = banked + if lang != "en": + translated = self._generator.translate_question(banked, lang) + if translated: + question = translated + else: + logger.warning( + "Genquiz translation failed, using English original", + extra={"chat_id": chat_id, "lang": lang}, + ) + # Prepend source label inline in the question text (no separate announcement) + source_label = question.get("source_label", "") + if source_label: + prefix = f"📚 {source_label}\n\n" + q_text = question["question"][: 300 - len(prefix)] + poll_question = prefix + q_text + else: + poll_question = question["question"] + poll_result = self._sender.send_quiz_poll( + chat_id=chat_id, + question=poll_question, + options=question["options"], + correct_option_id=question["correct_option_index"], + explanation=question.get("explanation"), + question_parse_mode="HTML", + ) + if poll_result: + logger.info( + "Genquiz sent from bank", + extra={"chat_id": chat_id, "category": banked_category, "lang": lang}, + ) + # No rpd_payload — bank questions don't consume AI quota + return {"status": "ok", "sent": 1, "total": 1} + logger.error("Failed to send genquiz poll from bank", extra={"chat_id": chat_id}) + return {"status": "error", "reason": "failed to send poll"} + # Bank exhausted (shouldn't happen in practice) — fall through to AI + logger.warning( + "Genquiz bank empty for topic, falling back to AI", + extra={"chat_id": chat_id, "topic": topic}, + ) + + # ── AI path ────────────────────────────────────────────────────────── question = self._generator.generate_question(topic, lang, difficulty) if not question: logger.error("Failed to generate on-demand question", extra={"topic": topic}) - return {"status": "error", "reason": "no valid question", **self._rpd_payload()} + return {"status": "error", "reason": "no valid question"} poll_result = self._sender.send_quiz_poll( chat_id=chat_id, @@ -353,11 +464,11 @@ def process_on_demand_quiz(self, chat_id: str, lang: str, topic: str, difficulty ) if poll_result: - logger.info("On-demand quiz sent", extra={"chat_id": chat_id, "topic": topic}) - return {"status": "ok", "sent": 1, "total": 1, **self._rpd_payload()} + logger.info("On-demand quiz sent via AI", extra={"chat_id": chat_id, "topic": topic}) + return {"status": "ok", "sent": 1, "total": 1} logger.error("Failed to send on-demand quiz poll", extra={"chat_id": chat_id}) - return {"status": "error", "reason": "failed to send poll", **self._rpd_payload()} + return {"status": "error", "reason": "failed to send poll"} def process_on_demand_quiz_with_feedback( self, @@ -366,15 +477,7 @@ def process_on_demand_quiz_with_feedback( topic: str, difficulty: str, *, - include_rpd_footer: bool, reply_to_message_id: int | None = None, ) -> dict: - """Run on-demand quiz and optionally send RPD footer to chat.""" - result = self.process_on_demand_quiz(chat_id, lang, topic, difficulty) - if include_rpd_footer: - remaining = result.get("rpd_remaining") - total = result.get("rpd_total") - if isinstance(remaining, int) and isinstance(total, int): - footer = get_translated_text("genquiz_rpd_footer", lang, remaining=remaining, total=total) - self._sender.send_message(chat_id, footer, reply_to_message_id=reply_to_message_id) - return result + """Run on-demand quiz (bank or AI path). No RPD footer is sent.""" + return self.process_on_demand_quiz(chat_id, lang, topic, difficulty) diff --git a/src/quiz/services/repository.py b/src/quiz/services/repository.py index 7d90d02..beebe94 100644 --- a/src/quiz/services/repository.py +++ b/src/quiz/services/repository.py @@ -5,7 +5,8 @@ from typing import Any import boto3 -from boto3.dynamodb.conditions import Key +from boto3.dynamodb.conditions import Attr, Key +from botocore.exceptions import ClientError from core.config import TABLE_NAME from core.logger import LoggerAdapter, get_logger @@ -235,6 +236,49 @@ def save_question_queue(self, category: str, chat_id: str, remaining: list[str]) except Exception as e: logger.error("Failed to save question queue", extra={"error": str(e)}) + # ── Genquiz (on-demand) question queue — separate from daily rotation ───── + + def get_genquiz_question_queue(self, category: str, chat_id: str) -> list[str]: + """Read the per-chat on-demand genquiz question queue (independent of daily rotation).""" + try: + resp = self._table.get_item( + Key={"PK": f"META#genquiz_q_queue#{category}#{chat_id}", "SK": "LATEST"}, + ConsistentRead=False, + ) + item = resp.get("Item") + if item and "remaining" in item: + return list(item["remaining"]) + return [] + except Exception as e: + logger.error("Failed to get genquiz question queue", extra={"error": str(e)}) + return [] + + def save_genquiz_question_queue(self, category: str, chat_id: str, remaining: list[str]) -> None: + """Write the per-chat on-demand genquiz question queue.""" + try: + self._table.put_item( + Item={ + "PK": f"META#genquiz_q_queue#{category}#{chat_id}", + "SK": "LATEST", + "remaining": remaining, + } + ) + except Exception as e: + logger.error("Failed to save genquiz question queue", extra={"error": str(e)}) + + def get_today_quiz_record(self, chat_id: str) -> dict[str, Any] | None: + """Return today's quiz record for a chat, or None if not yet sent.""" + today = datetime.now(_ALMATY_TZ).strftime("%Y-%m-%d") + try: + resp = self._table.get_item( + Key={"PK": f"QUIZ#{chat_id}", "SK": f"DATE#{today}"}, + ConsistentRead=True, + ) + return resp.get("Item") + except ClientError as e: + logger.error("Failed to read today quiz record", extra={"chat_id": chat_id, "error": str(e)}) + return None + def save_quiz_record( self, chat_id: str, @@ -248,8 +292,12 @@ def save_quiz_record( message_id: int, difficulty: str = "easy", points: int = 1, - ) -> None: - """Write a daily quiz record for a chat.""" + ) -> bool: + """Write a daily quiz record for a chat. + + Returns True on success, False if a record already exists for today + (idempotent: safe to call on Lambda retry without overwriting a live poll). + """ now = datetime.now(_ALMATY_TZ) today = now.strftime("%Y-%m-%d") ttl = int(time.time()) + (_TTL_DAYS * 86400) @@ -271,9 +319,17 @@ def save_quiz_record( "points": points, "sent_at": now.isoformat(), "ttl": ttl, - } + }, + ConditionExpression=Attr("PK").not_exists(), ) logger.info("Quiz record saved", extra={"chat_id": chat_id, "date": today}) - except Exception as e: + return True + except ClientError as e: + if e.response["Error"]["Code"] == "ConditionalCheckFailedException": + logger.warning( + "Quiz record already exists for today, skipping save", + extra={"chat_id": chat_id, "date": today}, + ) + return False logger.error("Failed to save quiz record", extra={"chat_id": chat_id, "error": str(e)}) raise diff --git a/src/shared/python/zerde_common/logging_utils.py b/src/shared/python/zerde_common/logging_utils.py index e64effe..54806ed 100644 --- a/src/shared/python/zerde_common/logging_utils.py +++ b/src/shared/python/zerde_common/logging_utils.py @@ -7,6 +7,8 @@ _DEFAULT_MAX_EXTRA = 512 _DEFAULT_LLM_PREVIEW = 200 +# CloudWatch log events are capped (~256 KiB); keep serialized Update under this budget. +_DEFAULT_MAX_TELEGRAM_UPDATE_JSON = 200_000 def truncate_log_text(text: str | None, max_chars: int = _DEFAULT_MAX_EXTRA) -> str: @@ -90,3 +92,37 @@ def safe_json_dumps_for_log(obj: Any, max_chars: int = _DEFAULT_MAX_EXTRA) -> st except (TypeError, ValueError) as e: return f"" return truncate_log_text(raw, max_chars) + + +def _bounded_dict_log_extra( + data: dict[str, Any], + field_prefix: str, + *, + max_json_chars: int, +) -> dict[str, Any]: + """Build ``extra`` keys: full dict under ``field_prefix`` if small, else truncated JSON string.""" + extra: dict[str, Any] = {} + try: + raw = json.dumps(data, default=str, ensure_ascii=False) + except (TypeError, ValueError) as e: + extra[field_prefix] = f"" + return extra + extra[f"{field_prefix}_chars"] = len(raw) + if len(raw) <= max_json_chars: + extra[field_prefix] = data + extra[f"{field_prefix}_truncated"] = False + else: + extra[f"{field_prefix}_json"] = raw[: max_json_chars - 60] + "…(truncated)" + extra[f"{field_prefix}_truncated"] = True + return extra + + +def telegram_update_log_extra( + update: dict[str, Any], + *, + max_json_chars: int = _DEFAULT_MAX_TELEGRAM_UPDATE_JSON, +) -> dict[str, Any]: + """Structured ``extra`` for logging a full Telegram Bot API ``Update`` at INFO (size-bounded).""" + out = _bounded_dict_log_extra(update, "telegram_update", max_json_chars=max_json_chars) + out["update_id"] = update.get("update_id") + return out diff --git a/tests/test_explain_enqueue.py b/tests/test_explain_enqueue.py index f985ee0..35d1589 100644 --- a/tests/test_explain_enqueue.py +++ b/tests/test_explain_enqueue.py @@ -42,3 +42,48 @@ def test_sqs_send_failure_releases_reservation( task_repo.release_reservation.assert_called_once_with(424242) assert not task_repo.mark_enqueued.called ctx.reply.assert_called_once() + + +@patch("services.handlers.wtf.get_deepseek_api_key", return_value="sk") +@patch("services.handlers.wtf.get_gemini_api_key", return_value="gk") +@patch("services.handlers.wtf._get_task_repo") +@patch("services.handlers.wtf._get_gemini") +@patch("services.handlers.wtf._get_fallback") +def test_wtf_reply_to_photo_uses_caption_as_term( + _mock_fallback: MagicMock, + _mock_gemini: MagicMock, + mock_get_repo: MagicMock, + _mock_gemini_key: MagicMock, + _mock_deepseek_key: MagicMock, +) -> None: + """Channel auto-forwards often have caption instead of body text on photo posts.""" + task_repo = MagicMock() + task_repo.try_reserve_update.return_value = True + mock_get_repo.return_value = task_repo + + sqs = MagicMock() + ctx = MagicMock() + ctx.text = "/wtf@zerde_kz_bot" + ctx.reply_to_message = { + "message_id": 36737, + "caption": "Ну блять, тест", + "photo": [{"file_id": "AgAC...", "width": 1, "height": 1}], + } + ctx.chat_id = 1 + ctx.message_id = 2 + ctx.update_id = 900001 + ctx.sqs_repo = sqs + ctx.message = {} + ctx.reply = MagicMock() + + with ( + patch("services.handlers.wtf.get_chat_lang", return_value="kk"), + patch("services.handlers.wtf._react_processing"), + patch("services.handlers.wtf._send_typing_once"), + ): + wtf_mod.handle_wtf(ctx) + + sqs.send_explain_task.assert_called_once() + kwargs = sqs.send_explain_task.call_args.kwargs + assert kwargs["term"] == "Ну блять, тест" + task_repo.mark_enqueued.assert_called_once_with(900001) diff --git a/tests/test_quiz_handler.py b/tests/test_quiz_handler.py index 2e3fec3..82c20f2 100644 --- a/tests/test_quiz_handler.py +++ b/tests/test_quiz_handler.py @@ -102,13 +102,15 @@ def test_shows_stats_for_existing_user(self): quiz_repo = MagicMock() quiz_repo.get_user_score.return_value = { "total_score": 10, + "week_score": 4, + "season_wins": 1, "current_streak": 3, "best_streak": 5, } quiz_repo.get_leaderboard.return_value = [ - {"SK": "USER#111", "total_score": 20}, - {"SK": "USER#456", "total_score": 10}, - {"SK": "USER#789", "total_score": 5}, + {"SK": "USER#111", "week_score": 8}, + {"SK": "USER#456", "week_score": 4}, + {"SK": "USER#789", "week_score": 1}, ] ctx = self._make_ctx(-100123, 456, quiz_repo) ctx.bot.get_chat.return_value = { @@ -125,9 +127,11 @@ def test_shows_stats_for_existing_user(self): call_text = ctx.bot.send_message.call_args[0][1] assert "Test Group" in call_text assert "@testgroup" in call_text - assert "10 points" in call_text - assert "3 days" in call_text - assert "#2" in call_text + assert "4 pts" in call_text # week_score + assert "10 pts" in call_text # total_score (all-time) + assert "Season wins: 1" in call_text + assert "3 days" in call_text # streak + assert "#2" in call_text # rank def test_shows_no_data_for_new_user(self): quiz_repo = MagicMock() @@ -138,4 +142,4 @@ def test_shows_no_data_for_new_user(self): ctx.bot.send_message.assert_called_once() call_text = ctx.bot.send_message.call_args[0][1] - assert "haven't answered" in call_text + assert "No quiz score yet" in call_text diff --git a/tests/test_zerde_common_logging_utils.py b/tests/test_zerde_common_logging_utils.py new file mode 100644 index 0000000..e3cd892 --- /dev/null +++ b/tests/test_zerde_common_logging_utils.py @@ -0,0 +1,36 @@ +"""Tests for shared logging helpers.""" + +import json +import os +import sys + +_ROOT = os.path.join(os.path.dirname(__file__), "..") +sys.path.insert(0, os.path.join(_ROOT, "src", "shared", "python")) + +from zerde_common.logging_utils import telegram_update_log_extra # noqa: E402 + + +def test_telegram_update_log_extra_inlines_small_update() -> None: + update = { + "update_id": 42, + "message": { + "message_id": 1, + "text": "/start", + "chat": {"id": -1001, "type": "supergroup"}, + }, + } + extra = telegram_update_log_extra(update) + assert extra["update_id"] == 42 + assert extra["telegram_update"] == update + assert extra["telegram_update_truncated"] is False + assert extra["telegram_update_chars"] == len(json.dumps(update, ensure_ascii=False)) + + +def test_telegram_update_log_extra_truncates_huge_payload() -> None: + long_text = "x" * 500_000 + update = {"update_id": 99, "message": {"text": long_text}} + extra = telegram_update_log_extra(update, max_json_chars=10_000) + assert extra["update_id"] == 99 + assert extra["telegram_update_truncated"] is True + assert "telegram_update_json" in extra + assert len(extra["telegram_update_json"]) <= 10_000