feat: batch-генерация слов дня, кнопка "Слово дня" в статистике

- Оптимизирована генерация слов дня: 2 запроса к AI вместо 11
- Добавлена кнопка "Слово дня" в /stats для быстрого доступа
- Локализация для ru/en/ja

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-10 11:00:30 +03:00
parent aa7121a1af
commit badad0a529
7 changed files with 141 additions and 77 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -663,4 +663,51 @@ async def cmd_stats(message: Message):
else: else:
stats_text += t(lang, 'stats.hint_keep_practice') 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)

View File

@@ -187,7 +187,8 @@
"accuracy": "🎯 Accuracy: <b>{n}%</b>", "accuracy": "🎯 Accuracy: <b>{n}%</b>",
"hint_add_words": "Add words with /add to start learning!", "hint_add_words": "Add words with /add to start learning!",
"hint_first_task": "Do your first task with /task!", "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": { "reminder": {
"title": "⏰ <b>Reminders</b>", "title": "⏰ <b>Reminders</b>",

View File

@@ -179,7 +179,8 @@
"accuracy": "🎯 正答率: <b>{n}%</b>", "accuracy": "🎯 正答率: <b>{n}%</b>",
"hint_add_words": "/add で単語を追加して学習を始めましょう!", "hint_add_words": "/add で単語を追加して学習を始めましょう!",
"hint_first_task": "/task で最初の課題をやってみましょう!", "hint_first_task": "/task で最初の課題をやってみましょう!",
"hint_keep_practice": "練習を続けましょう! 💪" "hint_keep_practice": "練習を続けましょう! 💪",
"word_of_day_btn": "🌅 今日の単語"
}, },
"reminder": { "reminder": {
"title": "⏰ <b>リマインダー</b>", "title": "⏰ <b>リマインダー</b>",

View File

@@ -264,7 +264,8 @@
"accuracy": "🎯 Точность: <b>{n}%</b>", "accuracy": "🎯 Точность: <b>{n}%</b>",
"hint_add_words": "Добавь слова командой /add чтобы начать обучение!", "hint_add_words": "Добавь слова командой /add чтобы начать обучение!",
"hint_first_task": "Выполни первое задание командой /task!", "hint_first_task": "Выполни первое задание командой /task!",
"hint_keep_practice": "Продолжай практиковаться! 💪" "hint_keep_practice": "Продолжай практиковаться! 💪",
"word_of_day_btn": "🌅 Слово дня"
}, },
"level_test": { "level_test": {
"show_translation_btn": "👁️ Показать перевод вопроса", "show_translation_btn": "👁️ Показать перевод вопроса",

View File

@@ -1371,74 +1371,76 @@ User: {user_message}
} }
] ]
async def generate_word_of_day( async def generate_words_of_day_batch(
self, self,
level: str, language: str,
learning_lang: str = "en", levels: List[str],
translation_lang: str = "ru", translation_lang: str = "ru",
excluded_words: List[str] = None, excluded_words: Dict[str, List[str]] = None
user_id: Optional[int] = None ) -> Optional[Dict[str, Dict]]:
) -> Optional[Dict]:
""" """
Генерация слова дня. Генерация слов дня для всех уровней одного языка за один запрос.
Args: Args:
level: Уровень пользователя (A1-C2 или N5-N1) language: Язык изучения (en/ja)
learning_lang: Язык изучения levels: Список уровней (A1-C2 или N5-N1)
translation_lang: Язык перевода translation_lang: Язык перевода
excluded_words: Список слов для исключения (уже были) excluded_words: Dict {level: [excluded_words]} для исключения
user_id: ID пользователя для выбора модели
Returns: Returns:
Dict с полями: word, transcription, translation, examples, synonyms, etymology Dict {level: word_data} или None при ошибке
""" """
language_names = { language_names = {"en": "английский", "ja": "японский"}
"en": "английский", language_name = language_names.get(language, "английский")
"ja": "японский"
}
language_name = language_names.get(learning_lang, "английский")
translation_names = { translation_names = {"ru": "русский", "en": "английский", "ja": "японский"}
"ru": "русский",
"en": "английский",
"ja": "японский"
}
translation_name = translation_names.get(translation_lang, "русский") translation_name = translation_names.get(translation_lang, "русский")
excluded_str = "" # Формируем список исключений по уровням
excluded_info = ""
if excluded_words: 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}", "{levels[0]}": {{
"transcription": "транскрипция (IPA для английского, хирагана для японского)", "word": "слово на {language_name}",
"translation": "перевод на {translation_name}", "transcription": "транскрипция (IPA для английского, хирагана для японского)",
"examples": [ "translation": "перевод на {translation_name}",
{{"sentence": "пример предложения", "translation": "перевод примера"}}, "examples": [
{{"sentence": "второй пример", "translation": "перевод"}} {{"sentence": "пример предложения", "translation": "перевод примера"}},
], {{"sentence": "второй пример", "translation": "перевод"}}
"synonyms": "синоним1, синоним2, синоним3", ],
"etymology": "краткий интересный факт о слове или его происхождении (1-2 предложения)" "synonyms": "синоним1, синоним2",
"etymology": "краткий интересный факт (1-2 предложения)"
}},
... (для каждого уровня)
}}""" }}"""
try: 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 = [ messages = [
{"role": "system", "content": "Ты - опытный лингвист, который подбирает интересные слова для изучения."}, {"role": "system", "content": "Ты - опытный лингвист. Отвечай только валидным JSON без markdown."},
{"role": "user", "content": prompt} {"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: if provider == AIProvider.google:
response_data = await self._make_google_request(messages, temperature=0.8, model=model_name) 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) content = self._strip_markdown_code_block(content)
result = json.loads(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 return result
except Exception as e: 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 return None
async def generate_mini_story( async def generate_mini_story(

View File

@@ -27,6 +27,7 @@ class WordOfDayService:
""" """
Генерация слов дня для всех языков и уровней. Генерация слов дня для всех языков и уровней.
Вызывается в 00:00 UTC. Вызывается в 00:00 UTC.
Использует batch-генерацию: 1 запрос на язык вместо 1 запроса на уровень.
Returns: Returns:
Dict с количеством сгенерированных слов по языкам Dict с количеством сгенерированных слов по языкам
@@ -38,30 +39,44 @@ class WordOfDayService:
for lang in LEARNING_LANGUAGES: for lang in LEARNING_LANGUAGES:
levels = JLPT_LEVELS if lang in JLPT_LANGUAGES else CEFR_LEVELS levels = JLPT_LEVELS if lang in JLPT_LANGUAGES else CEFR_LEVELS
# Определяем какие уровни ещё не сгенерированы
levels_to_generate = []
for level in levels: for level in levels:
try: existing = await self._get_word_for_date(session, today, lang, level)
# Проверяем, не сгенерировано ли уже if not existing:
existing = await self._get_word_for_date( levels_to_generate.append(level)
session, today, lang, level else:
) logger.debug(f"Слово дня уже существует: {lang}/{level}")
if existing:
logger.debug(
f"Слово дня уже существует: {lang}/{level}"
)
continue
# Получаем список недавних слов для исключения if not levels_to_generate:
excluded = await self._get_recent_words(session, lang, level, days=30) logger.info(f"Все слова для {lang} уже сгенерированы")
continue
# Генерируем слово # Собираем исключения для каждого уровня
word_data = await ai_service.generate_word_of_day( excluded_words = {}
level=level, for level in levels_to_generate:
learning_lang=lang, excluded_words[level] = await self._get_recent_words(
translation_lang="ru", # Базовый перевод на русский session, lang, level, days=30
excluded_words=excluded )
)
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_of_day = WordOfDay(
word=word_data.get("word", ""), word=word_data.get("word", ""),
transcription=word_data.get("transcription"), transcription=word_data.get("transcription"),
@@ -74,7 +89,6 @@ class WordOfDayService:
date=today date=today
) )
session.add(word_of_day) session.add(word_of_day)
await session.commit()
results[lang] += 1 results[lang] += 1
logger.info( logger.info(
f"Сгенерировано слово дня: {word_data.get('word')} " f"Сгенерировано слово дня: {word_data.get('word')} "
@@ -82,15 +96,13 @@ class WordOfDayService:
) )
else: else:
results["errors"] += 1 results["errors"] += 1
logger.warning( logger.warning(f"Нет данных для {lang}/{level} в ответе AI")
f"Не удалось сгенерировать слово для {lang}/{level}"
)
except Exception as e: await session.commit()
results["errors"] += 1
logger.error( except Exception as e:
f"Ошибка генерации слова для {lang}/{level}: {e}" results["errors"] += len(levels_to_generate)
) logger.error(f"Ошибка batch-генерации для {lang}: {e}")
total = results["en"] + results["ja"] total = results["en"] + results["ja"]
logger.info( logger.info(