From badad0a5294ee772cbe025255c953a520cfe79ff Mon Sep 17 00:00:00 2001 From: "mamonov.ep" Date: Wed, 10 Dec 2025 11:00:30 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20batch-=D0=B3=D0=B5=D0=BD=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=D0=BB=D0=BE=D0=B2=20=D0=B4?= =?UTF-8?q?=D0=BD=D1=8F,=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20"=D0=A1?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D0=BE=20=D0=B4=D0=BD=D1=8F"=20=D0=B2=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Оптимизирована генерация слов дня: 2 запроса к AI вместо 11 - Добавлена кнопка "Слово дня" в /stats для быстрого доступа - Локализация для ru/en/ja 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .DS_Store | Bin 8196 -> 6148 bytes bot/handlers/tasks.py | 49 +++++++++++++++++- locales/en.json | 3 +- locales/ja.json | 3 +- locales/ru.json | 3 +- services/ai_service.py | 90 +++++++++++++++++----------------- services/wordofday_service.py | 70 +++++++++++++++----------- 7 files changed, 141 insertions(+), 77 deletions(-) diff --git a/.DS_Store b/.DS_Store index dc14a65f11e7be109049c43082ad02acde23ecbd..b9c8e34fbe798f0430b475070d5f8147358b73ad 100644 GIT binary patch delta 107 zcmZp1XfcprU|?W$DortDU=RQ@Ie-{Mvv5r;6q~50$SAxqU^g?P@Ma!?v&@q@MdT(n t2rOpj;1Fa6DgpulZXn?bQophAJM(0I8BdUI1}2C}Aj26p$Mei#1^~Dq5YGSr delta 128 zcmZoMXmOBWU|?W$DortDU;r^WfEYvza8E20o2aMAD6%nNH}hr%jz7$c**Q2SHn1>? zZ02D(%go8aBEcfUBFZ8$`5&7Y8-pWIliuWW?3$a^d7d+KNpJ)Ct{`(Z3vzsCp3E=e TIoY3wgM$%b1;gfeo;l0_9bXt> diff --git a/bot/handlers/tasks.py b/bot/handlers/tasks.py index bf87916..f2ca9b1 100644 --- a/bot/handlers/tasks.py +++ b/bot/handlers/tasks.py @@ -663,4 +663,51 @@ async def cmd_stats(message: Message): else: stats_text += t(lang, 'stats.hint_keep_practice') - await message.answer(stats_text) + # Кнопка "Слово дня" + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=t(lang, 'stats.word_of_day_btn'), + callback_data="stats_word_of_day" + )] + ]) + + await message.answer(stats_text, reply_markup=keyboard) + + +@router.callback_query(F.data == "stats_word_of_day") +async def stats_word_of_day(callback: CallbackQuery): + """Показать слово дня из статистики""" + await callback.answer() + + from services.wordofday_service import wordofday_service + from bot.handlers.wordofday import format_word_of_day + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + + if not user: + return + + lang = get_user_lang(user) + learning_lang = user.learning_language or 'en' + level = get_user_level_for_language(user) + + wod = await wordofday_service.get_word_of_day( + learning_lang=learning_lang, + level=level + ) + + if not wod: + await callback.message.answer(t(lang, 'wod.not_available')) + return + + text = format_word_of_day(wod, lang) + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=t(lang, 'wod.add_btn'), + callback_data=f"wod_add_{wod.id}" + )] + ]) + + await callback.message.answer(text, reply_markup=keyboard) diff --git a/locales/en.json b/locales/en.json index debffc3..b95dc94 100644 --- a/locales/en.json +++ b/locales/en.json @@ -187,7 +187,8 @@ "accuracy": "🎯 Accuracy: {n}%", "hint_add_words": "Add words with /add to start learning!", "hint_first_task": "Do your first task with /task!", - "hint_keep_practice": "Keep practicing! 💪" + "hint_keep_practice": "Keep practicing! 💪", + "word_of_day_btn": "🌅 Word of the Day" }, "reminder": { "title": "⏰ Reminders", diff --git a/locales/ja.json b/locales/ja.json index 20412f9..6828e75 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -179,7 +179,8 @@ "accuracy": "🎯 正答率: {n}%", "hint_add_words": "/add で単語を追加して学習を始めましょう!", "hint_first_task": "/task で最初の課題をやってみましょう!", - "hint_keep_practice": "練習を続けましょう! 💪" + "hint_keep_practice": "練習を続けましょう! 💪", + "word_of_day_btn": "🌅 今日の単語" }, "reminder": { "title": "⏰ リマインダー", diff --git a/locales/ru.json b/locales/ru.json index 3da6b08..24edbe5 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -264,7 +264,8 @@ "accuracy": "🎯 Точность: {n}%", "hint_add_words": "Добавь слова командой /add чтобы начать обучение!", "hint_first_task": "Выполни первое задание командой /task!", - "hint_keep_practice": "Продолжай практиковаться! 💪" + "hint_keep_practice": "Продолжай практиковаться! 💪", + "word_of_day_btn": "🌅 Слово дня" }, "level_test": { "show_translation_btn": "👁️ Показать перевод вопроса", diff --git a/services/ai_service.py b/services/ai_service.py index cd9a84a..45f1bab 100644 --- a/services/ai_service.py +++ b/services/ai_service.py @@ -1371,74 +1371,76 @@ User: {user_message} } ] - async def generate_word_of_day( + async def generate_words_of_day_batch( self, - level: str, - learning_lang: str = "en", + language: str, + levels: List[str], translation_lang: str = "ru", - excluded_words: List[str] = None, - user_id: Optional[int] = None - ) -> Optional[Dict]: + excluded_words: Dict[str, List[str]] = None + ) -> Optional[Dict[str, Dict]]: """ - Генерация слова дня. + Генерация слов дня для всех уровней одного языка за один запрос. Args: - level: Уровень пользователя (A1-C2 или N5-N1) - learning_lang: Язык изучения + language: Язык изучения (en/ja) + levels: Список уровней (A1-C2 или N5-N1) translation_lang: Язык перевода - excluded_words: Список слов для исключения (уже были) - user_id: ID пользователя для выбора модели + excluded_words: Dict {level: [excluded_words]} для исключения Returns: - Dict с полями: word, transcription, translation, examples, synonyms, etymology + Dict {level: word_data} или None при ошибке """ - language_names = { - "en": "английский", - "ja": "японский" - } - language_name = language_names.get(learning_lang, "английский") + language_names = {"en": "английский", "ja": "японский"} + language_name = language_names.get(language, "английский") - translation_names = { - "ru": "русский", - "en": "английский", - "ja": "японский" - } + translation_names = {"ru": "русский", "en": "английский", "ja": "японский"} translation_name = translation_names.get(translation_lang, "русский") - excluded_str = "" + # Формируем список исключений по уровням + excluded_info = "" if excluded_words: - excluded_str = f"\n\nНЕ используй эти слова (уже были): {', '.join(excluded_words[:20])}" + excluded_parts = [] + for level, words in excluded_words.items(): + if words: + excluded_parts.append(f"- {level}: {', '.join(words[:15])}") + if excluded_parts: + excluded_info = "\n\nНЕ используй эти слова (уже были недавно):\n" + "\n".join(excluded_parts) - prompt = f"""Сгенерируй интересное "слово дня" для изучающего {language_name} язык на уровне {level}. + levels_str = ", ".join(levels) -Требования: + prompt = f"""Сгенерируй "слово дня" для изучающих {language_name} язык на каждом из уровней: {levels_str}. + +Требования для каждого слова: - Слово должно быть полезным и интересным -- Подходящее для уровня {level} -- НЕ слишком простое и НЕ слишком сложное -- Желательно с интересной этимологией или фактом{excluded_str} +- Строго соответствовать указанному уровню сложности +- Желательно с интересной этимологией или фактом +- Все слова должны быть РАЗНЫМИ{excluded_info} -Верни JSON: +Верни JSON объект, где ключи - уровни ({levels_str}): {{ - "word": "слово на {language_name}", - "transcription": "транскрипция (IPA для английского, хирагана для японского)", - "translation": "перевод на {translation_name}", - "examples": [ - {{"sentence": "пример предложения", "translation": "перевод примера"}}, - {{"sentence": "второй пример", "translation": "перевод"}} - ], - "synonyms": "синоним1, синоним2, синоним3", - "etymology": "краткий интересный факт о слове или его происхождении (1-2 предложения)" + "{levels[0]}": {{ + "word": "слово на {language_name}", + "transcription": "транскрипция (IPA для английского, хирагана для японского)", + "translation": "перевод на {translation_name}", + "examples": [ + {{"sentence": "пример предложения", "translation": "перевод примера"}}, + {{"sentence": "второй пример", "translation": "перевод"}} + ], + "synonyms": "синоним1, синоним2", + "etymology": "краткий интересный факт (1-2 предложения)" + }}, + ... (для каждого уровня) }}""" try: - logger.info(f"[AI Request] generate_word_of_day: level='{level}', lang='{learning_lang}'") + logger.info(f"[AI Request] generate_words_of_day_batch: lang='{language}', levels={levels}") messages = [ - {"role": "system", "content": "Ты - опытный лингвист, который подбирает интересные слова для изучения."}, + {"role": "system", "content": "Ты - опытный лингвист. Отвечай только валидным JSON без markdown."}, {"role": "user", "content": prompt} ] - model_name, provider = await self._get_active_model(user_id) + model_name, provider = await self._get_active_model(None) if provider == AIProvider.google: response_data = await self._make_google_request(messages, temperature=0.8, model=model_name) @@ -1449,11 +1451,11 @@ User: {user_message} content = self._strip_markdown_code_block(content) result = json.loads(content) - logger.info(f"[AI Response] generate_word_of_day: word='{result.get('word', 'N/A')}'") + logger.info(f"[AI Response] generate_words_of_day_batch: generated {len(result)} words") return result except Exception as e: - logger.error(f"[AI Error] generate_word_of_day: {type(e).__name__}: {str(e)}") + logger.error(f"[AI Error] generate_words_of_day_batch: {type(e).__name__}: {str(e)}") return None async def generate_mini_story( diff --git a/services/wordofday_service.py b/services/wordofday_service.py index 1df63ab..ef58281 100644 --- a/services/wordofday_service.py +++ b/services/wordofday_service.py @@ -27,6 +27,7 @@ class WordOfDayService: """ Генерация слов дня для всех языков и уровней. Вызывается в 00:00 UTC. + Использует batch-генерацию: 1 запрос на язык вместо 1 запроса на уровень. Returns: Dict с количеством сгенерированных слов по языкам @@ -38,30 +39,44 @@ class WordOfDayService: for lang in LEARNING_LANGUAGES: levels = JLPT_LEVELS if lang in JLPT_LANGUAGES else CEFR_LEVELS + # Определяем какие уровни ещё не сгенерированы + levels_to_generate = [] for level in levels: - try: - # Проверяем, не сгенерировано ли уже - existing = await self._get_word_for_date( - session, today, lang, level - ) - if existing: - logger.debug( - f"Слово дня уже существует: {lang}/{level}" - ) - continue + existing = await self._get_word_for_date(session, today, lang, level) + if not existing: + levels_to_generate.append(level) + else: + logger.debug(f"Слово дня уже существует: {lang}/{level}") - # Получаем список недавних слов для исключения - excluded = await self._get_recent_words(session, lang, level, days=30) + if not levels_to_generate: + logger.info(f"Все слова для {lang} уже сгенерированы") + continue - # Генерируем слово - word_data = await ai_service.generate_word_of_day( - level=level, - learning_lang=lang, - translation_lang="ru", # Базовый перевод на русский - excluded_words=excluded - ) + # Собираем исключения для каждого уровня + excluded_words = {} + for level in levels_to_generate: + excluded_words[level] = await self._get_recent_words( + session, lang, level, days=30 + ) - if word_data: + # Генерируем все слова одним запросом + try: + batch_result = await ai_service.generate_words_of_day_batch( + language=lang, + levels=levels_to_generate, + translation_lang="ru", + excluded_words=excluded_words + ) + + if not batch_result: + results["errors"] += len(levels_to_generate) + logger.error(f"Не удалось сгенерировать слова для {lang}") + continue + + # Сохраняем каждое слово + for level in levels_to_generate: + word_data = batch_result.get(level) + if word_data and word_data.get("word"): word_of_day = WordOfDay( word=word_data.get("word", ""), transcription=word_data.get("transcription"), @@ -74,7 +89,6 @@ class WordOfDayService: date=today ) session.add(word_of_day) - await session.commit() results[lang] += 1 logger.info( f"Сгенерировано слово дня: {word_data.get('word')} " @@ -82,15 +96,13 @@ class WordOfDayService: ) else: results["errors"] += 1 - logger.warning( - f"Не удалось сгенерировать слово для {lang}/{level}" - ) + logger.warning(f"Нет данных для {lang}/{level} в ответе AI") - except Exception as e: - results["errors"] += 1 - logger.error( - f"Ошибка генерации слова для {lang}/{level}: {e}" - ) + await session.commit() + + except Exception as e: + results["errors"] += len(levels_to_generate) + logger.error(f"Ошибка batch-генерации для {lang}: {e}") total = results["en"] + results["ja"] logger.info(