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:
@@ -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)
|
||||||
|
|||||||
@@ -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>",
|
||||||
|
|||||||
@@ -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>",
|
||||||
|
|||||||
@@ -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": "👁️ Показать перевод вопроса",
|
||||||
|
|||||||
@@ -1371,54 +1371,54 @@ 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}):
|
||||||
{{
|
{{
|
||||||
|
"{levels[0]}": {{
|
||||||
"word": "слово на {language_name}",
|
"word": "слово на {language_name}",
|
||||||
"transcription": "транскрипция (IPA для английского, хирагана для японского)",
|
"transcription": "транскрипция (IPA для английского, хирагана для японского)",
|
||||||
"translation": "перевод на {translation_name}",
|
"translation": "перевод на {translation_name}",
|
||||||
@@ -1426,19 +1426,21 @@ User: {user_message}
|
|||||||
{{"sentence": "пример предложения", "translation": "перевод примера"}},
|
{{"sentence": "пример предложения", "translation": "перевод примера"}},
|
||||||
{{"sentence": "второй пример", "translation": "перевод"}}
|
{{"sentence": "второй пример", "translation": "перевод"}}
|
||||||
],
|
],
|
||||||
"synonyms": "синоним1, синоним2, синоним3",
|
"synonyms": "синоним1, синоним2",
|
||||||
"etymology": "краткий интересный факт о слове или его происхождении (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(
|
||||||
|
|||||||
@@ -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(
|
if not levels_to_generate:
|
||||||
f"Слово дня уже существует: {lang}/{level}"
|
logger.info(f"Все слова для {lang} уже сгенерированы")
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Получаем список недавних слов для исключения
|
# Собираем исключения для каждого уровня
|
||||||
excluded = await self._get_recent_words(session, lang, level, days=30)
|
excluded_words = {}
|
||||||
|
for level in levels_to_generate:
|
||||||
# Генерируем слово
|
excluded_words[level] = await self._get_recent_words(
|
||||||
word_data = await ai_service.generate_word_of_day(
|
session, lang, level, days=30
|
||||||
level=level,
|
|
||||||
learning_lang=lang,
|
|
||||||
translation_lang="ru", # Базовый перевод на русский
|
|
||||||
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}"
|
|
||||||
)
|
await session.commit()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results["errors"] += 1
|
results["errors"] += len(levels_to_generate)
|
||||||
logger.error(
|
logger.error(f"Ошибка batch-генерации для {lang}: {e}")
|
||||||
f"Ошибка генерации слова для {lang}/{level}: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
total = results["en"] + results["ja"]
|
total = results["en"] + results["ja"]
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
Reference in New Issue
Block a user