From adc8a6bf8e7dfa863ecfa9aae767ef7299efbe77 Mon Sep 17 00:00:00 2001 From: "mamonov.ep" Date: Wed, 10 Dec 2025 19:42:10 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BC=D0=B8=D0=BD=D0=B8-=D0=B8=D0=B3?= =?UTF-8?q?=D1=80=D1=8B,=20premium=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B0,=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мини-игры (/games): - Speed Round: 10 раундов, 10 секунд на ответ, очки за скорость - Match Pairs: 5 слов + 5 переводов, соединить пары Premium-функции: - Поля is_premium и premium_until для пользователей - AI режим проверки ответов (учитывает синонимы) - Batch проверка всех ответов одним запросом Улучшения: - Примеры использования для всех добавляемых слов - Разбиение переводов по запятой на отдельные записи - Полные предложения в контекстах (без ___) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- bot/handlers/import_text.py | 38 +- bot/handlers/minigames.py | 903 ++++++++++++++++++ bot/handlers/start.py | 14 +- bot/handlers/stories.py | 24 +- bot/handlers/tasks.py | 39 +- bot/handlers/wordofday.py | 19 +- bot/handlers/words.py | 24 +- database/models.py | 2 + locales/en.json | 59 ++ locales/ja.json | 59 ++ locales/ru.json | 59 ++ main.py | 4 +- .../versions/20251210_add_is_premium.py | 29 + .../20251210_rebuild_word_translations.py | 66 ++ scripts/update_word_contexts.py | 113 +++ services/ai_service.py | 163 +++- services/vocabulary_service.py | 166 +++- versions/v1.5.0.md | 72 ++ 18 files changed, 1819 insertions(+), 34 deletions(-) create mode 100644 bot/handlers/minigames.py create mode 100644 migrations/versions/20251210_add_is_premium.py create mode 100644 migrations/versions/20251210_rebuild_word_translations.py create mode 100644 scripts/update_word_contexts.py create mode 100644 versions/v1.5.0.md diff --git a/bot/handlers/import_text.py b/bot/handlers/import_text.py index cee6d7d..3b0c679 100644 --- a/bot/handlers/import_text.py +++ b/bot/handlers/import_text.py @@ -198,7 +198,7 @@ async def import_single_word(callback: CallbackQuery, state: FSMContext): # Добавляем слово translation_lang = get_user_translation_lang(user) - await VocabularyService.add_word( + new_word = await VocabularyService.add_word( session=session, user_id=user_id, word_original=word_data['word'], @@ -210,6 +210,16 @@ async def import_single_word(callback: CallbackQuery, state: FSMContext): source=WordSource.CONTEXT ) + # Добавляем переводы в word_translations (разбиваем по запятой) + await VocabularyService.add_translation_split( + session=session, + vocabulary_id=new_word.id, + translation=word_data['translation'], + context=word_data.get('example') or word_data.get('context'), + context_translation=word_data.get('example_translation') or word_data.get('context_translation'), + is_primary=True + ) + lang = (user.language_interface if user else 'ru') or 'ru' await callback.message.answer(t(lang, 'import.added_single', word=word_data['word'])) @@ -241,7 +251,7 @@ async def import_all_words(callback: CallbackQuery, state: FSMContext): # Добавляем слово translation_lang = get_user_translation_lang(user) - await VocabularyService.add_word( + new_word = await VocabularyService.add_word( session=session, user_id=user_id, word_original=word_data['word'], @@ -252,6 +262,16 @@ async def import_all_words(callback: CallbackQuery, state: FSMContext): difficulty_level=data.get('level'), source=WordSource.CONTEXT ) + + # Добавляем переводы в word_translations (разбиваем по запятой) + await VocabularyService.add_translation_split( + session=session, + vocabulary_id=new_word.id, + translation=word_data['translation'], + context=word_data.get('example') or word_data.get('context'), + context_translation=word_data.get('example_translation') or word_data.get('context_translation'), + is_primary=True + ) added_count += 1 lang = (user.language_interface if user else 'ru') or 'ru' @@ -478,7 +498,7 @@ async def import_file_all_words(callback: CallbackQuery, state: FSMContext): continue # Добавляем слово - await VocabularyService.add_word( + new_word = await VocabularyService.add_word( session=session, user_id=user_id, word_original=word_data['word'], @@ -488,6 +508,18 @@ async def import_file_all_words(callback: CallbackQuery, state: FSMContext): transcription=word_data.get('transcription'), source=WordSource.IMPORT ) + + # Добавляем переводы в word_translations (разбиваем по запятой) + translation = word_data.get('translation', '') + if translation: + await VocabularyService.add_translation_split( + session=session, + vocabulary_id=new_word.id, + translation=translation, + context=word_data.get('example'), + context_translation=word_data.get('example_translation'), + is_primary=True + ) added_count += 1 lang = get_user_lang(user) diff --git a/bot/handlers/minigames.py b/bot/handlers/minigames.py new file mode 100644 index 0000000..882e36d --- /dev/null +++ b/bot/handlers/minigames.py @@ -0,0 +1,903 @@ +"""Обработчики мини-игр""" +import asyncio +import random +from datetime import datetime +from typing import Optional + +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup + +from database.db import async_session_maker +from services.user_service import UserService +from services.vocabulary_service import VocabularyService +from services.ai_service import AIService +from utils.i18n import t, get_user_lang, get_user_translation_lang + +router = Router() +ai_service = AIService() + + +class SpeedRoundStates(StatesGroup): + """Состояния для игры Speed Round""" + playing = State() + waiting_answer = State() + + +class MatchGameStates(StatesGroup): + """Состояния для игры Match (Найди пару)""" + waiting_answer = State() + + +# Константы игры Speed Round +SPEED_ROUND_COUNT = 10 # Количество слов в раунде +SPEED_ROUND_TIME = 10 # Секунд на ответ +POINTS_CORRECT = 100 # Базовые очки за правильный ответ +POINTS_SPEED_BONUS = 10 # Бонус за каждую оставшуюся секунду + +# Константы игры Match +MATCH_WORDS_COUNT = 5 # Количество пар слов +MATCH_TIME_LIMIT = 60 # Секунд на всю игру (0 = без таймера) +MATCH_POINTS_PER_PAIR = 50 # Очки за правильную пару + + +def get_minigames_menu_keyboard(lang: str) -> InlineKeyboardMarkup: + """Клавиатура выбора мини-игры""" + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=f"⚡ {t(lang, 'minigames.speed_round.name')}", + callback_data="minigame_speed_round" + )], + [InlineKeyboardButton( + text=f"🎯 {t(lang, 'minigames.match_game.name')}", + callback_data="minigame_match" + )], + ]) + + +def get_speed_round_start_keyboard(lang: str, is_premium: bool = False) -> InlineKeyboardMarkup: + """Клавиатура начала игры Speed Round""" + buttons = [] + + if is_premium: + # Для премиум-пользователей - выбор режима проверки + buttons.append([ + InlineKeyboardButton( + text=f"🤖 {t(lang, 'minigames.speed_round.mode_ai')}", + callback_data="speed_round_start_ai" + ) + ]) + buttons.append([ + InlineKeyboardButton( + text=f"⚡ {t(lang, 'minigames.speed_round.mode_simple')}", + callback_data="speed_round_start_simple" + ) + ]) + else: + # Для обычных пользователей - только простой режим + buttons.append([ + InlineKeyboardButton( + text=f"▶️ {t(lang, 'minigames.start_btn')}", + callback_data="speed_round_start_simple" + ) + ]) + + buttons.append([ + InlineKeyboardButton( + text=f"⬅️ {t(lang, 'minigames.back_btn')}", + callback_data="minigames_menu" + ) + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def create_progress_bar(remaining: int, total: int = SPEED_ROUND_TIME) -> str: + """Создать прогресс-бар для таймера""" + filled = int((remaining / total) * 10) + empty = 10 - filled + return "█" * filled + "░" * empty + + +@router.message(Command("games")) +async def cmd_games(message: Message, state: FSMContext): + """Команда /games - показать меню мини-игр""" + await state.clear() + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + + if not user: + await message.answer(t('ru', 'common.start_first')) + return + + lang = get_user_lang(user) + + await message.answer( + t(lang, 'minigames.menu_title'), + reply_markup=get_minigames_menu_keyboard(lang) + ) + + +@router.callback_query(F.data == "minigames_menu") +async def minigames_menu_callback(callback: CallbackQuery, state: FSMContext): + """Показать меню мини-игр""" + await callback.answer() + await state.clear() + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) if user else 'ru' + + await callback.message.edit_text( + t(lang, 'minigames.menu_title'), + reply_markup=get_minigames_menu_keyboard(lang) + ) + + +@router.callback_query(F.data == "minigame_speed_round") +async def speed_round_info(callback: CallbackQuery, state: FSMContext): + """Показать информацию о игре Speed Round""" + await callback.answer() + + 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' + + # Проверяем подписку с учётом даты окончания + is_premium = getattr(user, 'is_premium', False) + premium_until = getattr(user, 'premium_until', None) + if is_premium and premium_until and premium_until < datetime.now(): + is_premium = False # Подписка истекла + + # Проверяем, есть ли слова в словаре (для изучаемого языка) + word_count = await VocabularyService.get_words_count(session, user.id, learning_lang=learning_lang) + + if word_count < 5: + await callback.message.edit_text( + t(lang, 'minigames.speed_round.not_enough_words', min=5, current=word_count), + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=f"⬅️ {t(lang, 'minigames.back_btn')}", + callback_data="minigames_menu" + )] + ]) + ) + return + + # Показываем правила игры + rules_text = t(lang, 'minigames.speed_round.rules', + count=SPEED_ROUND_COUNT, + time=SPEED_ROUND_TIME) + + # Добавляем информацию о режимах для премиум-пользователей + if is_premium: + rules_text += f"\n\n✨ {t(lang, 'minigames.speed_round.premium_hint')}" + + await callback.message.edit_text( + f"⚡ {t(lang, 'minigames.speed_round.name')}\n\n{rules_text}", + reply_markup=get_speed_round_start_keyboard(lang, is_premium) + ) + + +@router.callback_query(F.data == "speed_round_start_ai") +async def speed_round_ai_warning(callback: CallbackQuery, state: FSMContext): + """Показать предупреждение о работе AI режима""" + await callback.answer() + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) if user else 'ru' + + # Показываем предупреждение о режиме AI + warning_text = t(lang, 'minigames.speed_round.ai_mode_warning') + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=f"✅ {t(lang, 'minigames.start_btn')}", + callback_data="speed_round_start_ai_confirm" + )], + [InlineKeyboardButton( + text=f"⬅️ {t(lang, 'minigames.back_btn')}", + callback_data="minigame_speed_round" + )] + ]) + + await callback.message.edit_text(warning_text, reply_markup=keyboard) + + +@router.callback_query(F.data.in_({"speed_round_start", "speed_round_start_simple", "speed_round_start_ai_confirm"})) +async def speed_round_start(callback: CallbackQuery, state: FSMContext): + """Начать игру Speed Round""" + await callback.answer() + + # Определяем режим проверки + use_ai_check = callback.data == "speed_round_start_ai_confirm" + + 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' + translation_lang = user.translation_language or user.language_interface or 'ru' + + # Получаем слова из словаря пользователя со всеми переводами + game_words = await VocabularyService.get_random_words_with_translations( + session, user.id, count=SPEED_ROUND_COUNT, learning_lang=learning_lang + ) + + if len(game_words) < 5: + await callback.message.edit_text( + t(lang, 'minigames.speed_round.not_enough_words', min=5, current=len(game_words)) + ) + return + + await state.update_data( + game_words=game_words, + current_round=0, + score=0, + correct_count=0, + results=[], # Список результатов по раундам + user_id=user.id, + lang=lang, + learning_lang=learning_lang, + translation_lang=translation_lang, + use_ai_check=use_ai_check, # Режим проверки + round_start_time=None, + message_id=None + ) + + await state.set_state(SpeedRoundStates.playing) + + # Удаляем старое сообщение и начинаем игру + await callback.message.delete() + await show_speed_round_word(callback.message, state) + + +async def show_speed_round_word(message: Message, state: FSMContext): + """Показать текущее слово в игре""" + data = await state.get_data() + game_words = data.get('game_words', []) + current_round = data.get('current_round', 0) + score = data.get('score', 0) + lang = data.get('lang', 'ru') + + if current_round >= len(game_words): + # Игра окончена + await finish_speed_round(message, state) + return + + word_data = game_words[current_round] + word = word_data['word'] + transcription = word_data.get('transcription', '') + + # Формируем текст + word_display = f"{word}" + if transcription: + word_display += f"\n[{transcription}]" + + text = ( + f"⚡ {t(lang, 'minigames.speed_round.round', current=current_round + 1, total=len(game_words))}\n\n" + f"{word_display}\n\n" + f"⏱ {create_progress_bar(SPEED_ROUND_TIME)} {SPEED_ROUND_TIME} {t(lang, 'minigames.speed_round.seconds')}\n\n" + f"🏆 {t(lang, 'minigames.speed_round.score')}: {score}" + ) + + # Сохраняем время начала раунда + await state.update_data( + round_start_time=datetime.now().timestamp() + ) + await state.set_state(SpeedRoundStates.waiting_answer) + + # Отправляем сообщение + sent_msg = await message.answer(text) + await state.update_data(message_id=sent_msg.message_id, chat_id=message.chat.id) + + # Запускаем таймер + asyncio.create_task(speed_round_timer(message.bot, state, sent_msg.chat.id, sent_msg.message_id)) + + +async def speed_round_timer(bot, state: FSMContext, chat_id: int, message_id: int): + """Таймер для раунда с обновлением прогресс-бара""" + for remaining in range(SPEED_ROUND_TIME - 1, -1, -1): + await asyncio.sleep(1) + + # Проверяем, всё ещё ли мы в состоянии ожидания ответа + current_state = await state.get_state() + if current_state != SpeedRoundStates.waiting_answer: + return + + data = await state.get_data() + if data.get('message_id') != message_id: + return + + game_words = data.get('game_words', []) + current_round = data.get('current_round', 0) + score = data.get('score', 0) + lang = data.get('lang', 'ru') + + if current_round >= len(game_words): + return + + word_data = game_words[current_round] + word = word_data['word'] + transcription = word_data.get('transcription', '') + + word_display = f"{word}" + if transcription: + word_display += f"\n[{transcription}]" + + if remaining > 0: + # Обновляем прогресс-бар + text = ( + f"⚡ {t(lang, 'minigames.speed_round.round', current=current_round + 1, total=len(game_words))}\n\n" + f"{word_display}\n\n" + f"⏱ {create_progress_bar(remaining)} {remaining} {t(lang, 'minigames.speed_round.seconds')}\n\n" + f"🏆 {t(lang, 'minigames.speed_round.score')}: {score}" + ) + + try: + await bot.edit_message_text(text, chat_id=chat_id, message_id=message_id) + except: + pass + else: + # Время вышло + await state.set_state(SpeedRoundStates.playing) + + # Сохраняем результат раунда (время вышло) + results = data.get('results', []) + results.append({ + 'word': word_data['word'], + 'correct_answer': word_data['translation'], + 'user_answer': None, + 'is_correct': False + }) + await state.update_data(current_round=current_round + 1, results=results) + + # Показываем правильный ответ + try: + await bot.edit_message_text( + f"⏰ {t(lang, 'minigames.speed_round.time_up')}\n\n" + f"{word_display}\n\n" + f"✅ {t(lang, 'minigames.speed_round.correct_was')}: {word_data['translation']}", + chat_id=chat_id, + message_id=message_id + ) + except: + pass + + await asyncio.sleep(1.5) + + # Следующий раунд + try: + msg = await bot.send_message(chat_id, "...") + await msg.delete() + # Создаём фиктивный Message для продолжения + from aiogram.types import Chat, User as TgUser + fake_message = Message( + message_id=0, + date=datetime.now(), + chat=Chat(id=chat_id, type="private"), + from_user=None, + text="" + ) + fake_message._bot = bot + await show_speed_round_word(fake_message, state) + except Exception as e: + pass + + +@router.message(SpeedRoundStates.waiting_answer) +async def speed_round_answer(message: Message, state: FSMContext): + """Обработка ответа в игре Speed Round""" + user_answer = message.text.strip() + + data = await state.get_data() + game_words = data.get('game_words', []) + current_round = data.get('current_round', 0) + score = data.get('score', 0) + correct_count = data.get('correct_count', 0) + lang = data.get('lang', 'ru') + round_start_time = data.get('round_start_time', datetime.now().timestamp()) + old_message_id = data.get('message_id') + chat_id = data.get('chat_id') + use_ai_check = data.get('use_ai_check', False) + user_id = data.get('user_id') + learning_lang = data.get('learning_lang', 'en') + translation_lang = data.get('translation_lang', 'ru') + + if current_round >= len(game_words): + return + + word_data = game_words[current_round] + correct_answer = word_data['translation'] + + # Вычисляем время ответа + time_taken = datetime.now().timestamp() - round_start_time + time_remaining = max(0, SPEED_ROUND_TIME - time_taken) + + # Переключаем состояние чтобы таймер остановился + await state.set_state(SpeedRoundStates.playing) + + # Простая проверка по всем вариантам перевода из word_translations + # AI проверка будет в конце игры для всех ответов сразу + user_answer_lower = user_answer.lower().strip() + all_translations = word_data.get('all_translations', [correct_answer.lower()]) + + is_correct = False + for valid_translation in all_translations: + # Проверяем точное совпадение или вхождение + if (user_answer_lower == valid_translation or + user_answer_lower in valid_translation.split(',') or + valid_translation in user_answer_lower): + is_correct = True + break + + # Получаем текущие результаты + results = data.get('results', []) + + if is_correct: + # Начисляем очки + round_points = POINTS_CORRECT + int(time_remaining * POINTS_SPEED_BONUS) + score += round_points + correct_count += 1 + + result_text = ( + f"✅ {t(lang, 'minigames.speed_round.correct')}\n\n" + f"➕ {round_points} {t(lang, 'minigames.speed_round.points')}\n" + f"⏱ {time_taken:.1f} {t(lang, 'minigames.speed_round.seconds')}" + ) + else: + result_text = ( + f"❌ {t(lang, 'minigames.speed_round.wrong')}\n\n" + f"✅ {t(lang, 'minigames.speed_round.correct_was')}: {correct_answer}" + ) + + # Сохраняем результат раунда (AI проверка будет в конце) + results.append({ + 'word': word_data['word'], + 'correct_answer': correct_answer, + 'user_answer': user_answer, + 'is_correct': is_correct, # Предварительный результат + 'time_remaining': time_remaining # Сохраняем для пересчёта очков + }) + + # Обновляем состояние + await state.update_data( + current_round=current_round + 1, + score=score, + correct_count=correct_count, + results=results + ) + + # Удаляем старое сообщение с вопросом + try: + await message.bot.delete_message(chat_id, old_message_id) + except: + pass + + # Показываем результат + await message.answer(result_text) + await asyncio.sleep(1) + + # Следующий раунд + await show_speed_round_word(message, state) + + +async def finish_speed_round(message: Message, state: FSMContext): + """Завершение игры Speed Round""" + data = await state.get_data() + score = data.get('score', 0) + correct_count = data.get('correct_count', 0) + game_words = data.get('game_words', []) + results = data.get('results', []) + lang = data.get('lang', 'ru') + use_ai_check = data.get('use_ai_check', False) + user_id = data.get('user_id') + learning_lang = data.get('learning_lang', 'en') + translation_lang = data.get('translation_lang', 'ru') + total = len(game_words) + + await state.clear() + + # AI проверка в конце игры (один запрос для всех ответов) + if use_ai_check and results: + # Отправляем сообщение о проверке + checking_msg = await message.answer(f"🤖 {t(lang, 'minigames.speed_round.ai_checking')}...") + + # Собираем ответы для проверки (только те, где пользователь ответил) + answers_to_check = [ + { + 'word': r['word'], + 'correct_translation': r['correct_answer'], + 'user_answer': r['user_answer'] + } + for r in results if r['user_answer'] + ] + + if answers_to_check: + # Проверяем все ответы одним запросом + ai_results = await ai_service.check_translations_batch( + answers=answers_to_check, + source_lang=learning_lang, + target_lang=translation_lang, + user_id=user_id + ) + + # Обновляем результаты и пересчитываем очки + ai_idx = 0 + score = 0 + correct_count = 0 + + for result in results: + if result['user_answer']: + # Обновляем результат из AI проверки + ai_result = ai_results[ai_idx] + result['is_correct'] = ai_result['is_correct'] + result['ai_feedback'] = ai_result.get('feedback', '') + result['user_answer_meaning'] = ai_result.get('user_answer_meaning', '') + ai_idx += 1 + + if result['is_correct']: + # Пересчитываем очки + time_remaining = result.get('time_remaining', 0) + round_points = POINTS_CORRECT + int(time_remaining * POINTS_SPEED_BONUS) + score += round_points + correct_count += 1 + else: + # Время вышло - ответ неверный + result['is_correct'] = False + result['ai_feedback'] = '' + result['user_answer_meaning'] = '' + + # Удаляем сообщение о проверке + try: + await checking_msg.delete() + except: + pass + + # Определяем результат + accuracy = int((correct_count / total) * 100) if total > 0 else 0 + + if accuracy >= 90: + emoji = "🏆" + comment = t(lang, 'minigames.speed_round.result.excellent') + elif accuracy >= 70: + emoji = "🎉" + comment = t(lang, 'minigames.speed_round.result.good') + elif accuracy >= 50: + emoji = "👍" + comment = t(lang, 'minigames.speed_round.result.average') + else: + emoji = "💪" + comment = t(lang, 'minigames.speed_round.result.practice') + + # Формируем список ответов + answers_text = "" + for result in results: + if result['is_correct']: + answers_text += f"✅ {result['word']} → {result['correct_answer']}" + if use_ai_check and result.get('ai_feedback'): + answers_text += f" ({result['ai_feedback']})" + answers_text += "\n" + else: + user_ans = result['user_answer'] or "⏰" + answers_text += f"❌ {result['word']} → {user_ans}" + # Показываем значение ответа пользователя (что он на самом деле написал) + if use_ai_check and result.get('user_answer_meaning'): + answers_text += f" = {result['user_answer_meaning']}" + answers_text += f" ({result['correct_answer']})" + if use_ai_check and result.get('ai_feedback'): + answers_text += f" ({result['ai_feedback']})" + answers_text += "\n" + + # Добавляем пометку об AI проверке + ai_badge = "🤖 " if use_ai_check else "" + + text = ( + f"{emoji} {t(lang, 'minigames.speed_round.finished')}\n\n" + f"🏆 {t(lang, 'minigames.speed_round.final_score')}: {score}\n" + f"✅ {t(lang, 'minigames.speed_round.correct_answers')}: {correct_count}/{total}\n" + f"🎯 {t(lang, 'minigames.speed_round.accuracy')}: {accuracy}%\n\n" + f"📋 {ai_badge}{t(lang, 'minigames.speed_round.answers_list')}:\n" + f"{answers_text}\n" + f"{comment}" + ) + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=f"🔄 {t(lang, 'minigames.play_again')}", + callback_data="speed_round_start" + )], + [InlineKeyboardButton( + text=f"⬅️ {t(lang, 'minigames.back_btn')}", + callback_data="minigames_menu" + )], + ]) + + await message.answer(text, reply_markup=keyboard) + + +# ==================== MATCH GAME (Найди пару) ==================== + +def get_match_game_start_keyboard(lang: str) -> InlineKeyboardMarkup: + """Клавиатура начала игры Match""" + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=f"▶️ {t(lang, 'minigames.start_btn')}", + callback_data="match_game_start" + )], + [InlineKeyboardButton( + text=f"⬅️ {t(lang, 'minigames.back_btn')}", + callback_data="minigames_menu" + )] + ]) + + +@router.callback_query(F.data == "minigame_match") +async def match_game_info(callback: CallbackQuery, state: FSMContext): + """Показать информацию о игре Match""" + await callback.answer() + await state.clear() + + 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' + + # Проверяем, есть ли слова в словаре + word_count = await VocabularyService.get_words_count(session, user.id, learning_lang=learning_lang) + + if word_count < MATCH_WORDS_COUNT: + await callback.message.edit_text( + t(lang, 'minigames.match_game.not_enough_words', min=MATCH_WORDS_COUNT, current=word_count), + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=f"⬅️ {t(lang, 'minigames.back_btn')}", + callback_data="minigames_menu" + )] + ]) + ) + return + + # Показываем правила игры + rules_text = t(lang, 'minigames.match_game.rules', count=MATCH_WORDS_COUNT) + + await callback.message.edit_text( + f"🎯 {t(lang, 'minigames.match_game.name')}\n\n{rules_text}", + reply_markup=get_match_game_start_keyboard(lang) + ) + + +@router.callback_query(F.data == "match_game_start") +async def match_game_start(callback: CallbackQuery, state: FSMContext): + """Начать игру Match""" + await callback.answer() + + 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' + + # Получаем случайные слова из словаря + game_words = await VocabularyService.get_random_words_with_translations( + session, user.id, count=MATCH_WORDS_COUNT, learning_lang=learning_lang + ) + + if len(game_words) < MATCH_WORDS_COUNT: + await callback.message.edit_text( + t(lang, 'minigames.match_game.not_enough_words', min=MATCH_WORDS_COUNT, current=len(game_words)) + ) + return + + # Создаём перемешанные списки + words_list = [(i + 1, w['word']) for i, w in enumerate(game_words)] # (номер, слово) + translations_list = [(chr(65 + i), w['translation']) for i, w in enumerate(game_words)] # (буква, перевод) + + # Перемешиваем переводы + random.shuffle(translations_list) + + # Создаём правильные ответы (номер -> буква) + correct_pairs = {} + for i, word_data in enumerate(game_words): + word_num = i + 1 + translation = word_data['translation'] + # Находим букву перевода в перемешанном списке + for letter, trans in translations_list: + if trans == translation: + correct_pairs[word_num] = letter + break + + await state.update_data( + game_words=game_words, + words_list=words_list, + translations_list=translations_list, + correct_pairs=correct_pairs, + user_id=user.id, + lang=lang, + start_time=datetime.now().timestamp() + ) + + await state.set_state(MatchGameStates.waiting_answer) + + # Формируем текст игры + game_text = format_match_game_text(lang, words_list, translations_list) + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=f"❌ {t(lang, 'minigames.match_game.cancel')}", + callback_data="match_game_cancel" + )] + ]) + + await callback.message.edit_text(game_text, reply_markup=keyboard) + + +def format_match_game_text(lang: str, words_list: list, translations_list: list) -> str: + """Форматировать текст игры Match""" + text = f"🎯 {t(lang, 'minigames.match_game.title')}\n\n" + + # Формируем две колонки + text += f"{t(lang, 'minigames.match_game.words_col')}:\n" + for num, word in words_list: + text += f" {num}. {word}\n" + + text += f"\n{t(lang, 'minigames.match_game.translations_col')}:\n" + for letter, translation in translations_list: + text += f" {letter}. {translation}\n" + + text += f"\n💡 {t(lang, 'minigames.match_game.hint')}" + + return text + + +@router.callback_query(F.data == "match_game_cancel") +async def match_game_cancel(callback: CallbackQuery, state: FSMContext): + """Отменить игру Match""" + await callback.answer() + await state.clear() + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) if user else 'ru' + + await callback.message.edit_text( + t(lang, 'minigames.match_game.cancelled'), + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=f"⬅️ {t(lang, 'minigames.back_btn')}", + callback_data="minigames_menu" + )] + ]) + ) + + +@router.message(MatchGameStates.waiting_answer) +async def match_game_answer(message: Message, state: FSMContext): + """Обработка ответа в игре Match""" + user_input = message.text.strip().upper() + + data = await state.get_data() + correct_pairs = data.get('correct_pairs', {}) + words_list = data.get('words_list', []) + translations_list = data.get('translations_list', []) + game_words = data.get('game_words', []) + lang = data.get('lang', 'ru') + start_time = data.get('start_time', datetime.now().timestamp()) + + # Парсим ответ пользователя + # Ожидаемый формат: 1A, 2B, 3C или 1A 2B 3C или 1-A, 2-B + import re + pairs_pattern = r'(\d+)\s*[-]?\s*([A-Z])' + user_pairs_raw = re.findall(pairs_pattern, user_input) + + # Преобразуем в словарь + user_pairs = {} + for num_str, letter in user_pairs_raw: + num = int(num_str) + if 1 <= num <= len(words_list): + user_pairs[num] = letter + + # Проверяем, достаточно ли пар введено + if len(user_pairs) < len(correct_pairs): + await message.answer( + t(lang, 'minigames.match_game.not_all_pairs', + entered=len(user_pairs), + needed=len(correct_pairs)) + ) + return + + # Завершаем игру + await state.clear() + + # Считаем время + time_taken = datetime.now().timestamp() - start_time + + # Проверяем ответы + correct_count = 0 + results = [] + + for num, letter in correct_pairs.items(): + word_data = game_words[num - 1] + user_letter = user_pairs.get(num, '?') + is_correct = user_letter == letter + + if is_correct: + correct_count += 1 + + results.append({ + 'num': num, + 'word': word_data['word'], + 'translation': word_data['translation'], + 'correct_letter': letter, + 'user_letter': user_letter, + 'is_correct': is_correct + }) + + # Вычисляем очки + score = correct_count * MATCH_POINTS_PER_PAIR + total = len(correct_pairs) + accuracy = int((correct_count / total) * 100) if total > 0 else 0 + + # Определяем результат + if accuracy == 100: + emoji = "🏆" + comment = t(lang, 'minigames.match_game.result.perfect') + elif accuracy >= 80: + emoji = "🎉" + comment = t(lang, 'minigames.match_game.result.excellent') + elif accuracy >= 60: + emoji = "👍" + comment = t(lang, 'minigames.match_game.result.good') + else: + emoji = "💪" + comment = t(lang, 'minigames.match_game.result.practice') + + # Формируем список результатов + results_text = "" + for r in results: + if r['is_correct']: + results_text += f"✅ {r['num']}. {r['word']} → {r['correct_letter']}. {r['translation']}\n" + else: + results_text += f"❌ {r['num']}. {r['word']} → {r['user_letter']} ({r['correct_letter']}. {r['translation']})\n" + + text = ( + f"{emoji} {t(lang, 'minigames.match_game.finished')}\n\n" + f"🏆 {t(lang, 'minigames.match_game.score')}: {score}\n" + f"✅ {t(lang, 'minigames.match_game.correct_pairs')}: {correct_count}/{total}\n" + f"⏱ {t(lang, 'minigames.match_game.time')}: {time_taken:.1f} {t(lang, 'minigames.speed_round.seconds')}\n\n" + f"📋 {t(lang, 'minigames.match_game.results')}:\n" + f"{results_text}\n" + f"{comment}" + ) + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=f"🔄 {t(lang, 'minigames.play_again')}", + callback_data="match_game_start" + )], + [InlineKeyboardButton( + text=f"⬅️ {t(lang, 'minigames.back_btn')}", + callback_data="minigames_menu" + )], + ]) + + await message.answer(text, reply_markup=keyboard) diff --git a/bot/handlers/start.py b/bot/handlers/start.py index e43ff9e..37f818d 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -63,13 +63,14 @@ def main_menu_keyboard(lang: str = 'ru') -> ReplyKeyboardMarkup: ], [ KeyboardButton(text=t(lang, "menu.exercises")), + KeyboardButton(text=t(lang, "menu.games")), + ], + [ KeyboardButton(text=t(lang, "menu.vocab")), - ], - [ KeyboardButton(text=t(lang, "menu.add")), - KeyboardButton(text=t(lang, "menu.stats")), ], [ + KeyboardButton(text=t(lang, "menu.stats")), KeyboardButton(text=t(lang, "menu.settings")), ], ], @@ -468,6 +469,13 @@ async def btn_exercises_pressed(message: Message, state: FSMContext): await show_exercises_menu(message, state, telegram_id=message.from_user.id) +@router.message(_menu_match('menu.games')) +async def btn_games_pressed(message: Message, state: FSMContext): + """Показать меню мини-игр.""" + from bot.handlers.minigames import cmd_games + await cmd_games(message, state) + + @router.message(_menu_match('menu.words')) async def btn_words_pressed(message: Message, state: FSMContext): """Подсказать про тематические слова и показать быстрые темы.""" diff --git a/bot/handlers/stories.py b/bot/handlers/stories.py index c187dc0..17e17a6 100644 --- a/bot/handlers/stories.py +++ b/bot/handlers/stories.py @@ -375,7 +375,7 @@ async def story_addword_callback(callback: CallbackQuery): # Добавляем слово translation_lang = get_user_translation_lang(user) - await VocabularyService.add_word( + new_word = await VocabularyService.add_word( session=session, user_id=user.id, word_original=word, @@ -386,6 +386,16 @@ async def story_addword_callback(callback: CallbackQuery): difficulty_level=story.level, source=WordSource.IMPORT ) + + # Добавляем переводы в word_translations (разбиваем по запятой) + await VocabularyService.add_translation_split( + session=session, + vocabulary_id=new_word.id, + translation=translation, + context=word_data.get('example') or word_data.get('context'), + context_translation=word_data.get('example_translation') or word_data.get('context_translation'), + is_primary=True + ) await session.commit() await callback.answer(t(lang, 'story.word_added', word=word), show_alert=True) @@ -419,7 +429,7 @@ async def story_addall_callback(callback: CallbackQuery): ) if not existing: - await VocabularyService.add_word( + new_word = await VocabularyService.add_word( session=session, user_id=user.id, word_original=word, @@ -430,6 +440,16 @@ async def story_addall_callback(callback: CallbackQuery): difficulty_level=story.level, source=WordSource.IMPORT ) + + # Добавляем переводы в word_translations (разбиваем по запятой) + await VocabularyService.add_translation_split( + session=session, + vocabulary_id=new_word.id, + translation=translation, + context=word_data.get('example') or word_data.get('context'), + context_translation=word_data.get('example_translation') or word_data.get('context_translation'), + is_primary=True + ) added += 1 await session.commit() diff --git a/bot/handlers/tasks.py b/bot/handlers/tasks.py index f2ca9b1..5d30f42 100644 --- a/bot/handlers/tasks.py +++ b/bot/handlers/tasks.py @@ -285,14 +285,18 @@ async def create_tasks_from_words( else: fill_title = "Заполни пропуск:" + # Полное предложение для контекста (без пропуска) + full_sentence = sentence_data.get('full_sentence') or sentence_data.get('sentence', '').replace('___', word_text) + tasks.append({ 'type': 'fill_in', 'question': f"{fill_title}\n\n{sentence_data.get('sentence', '___')}\n\n{sentence_data.get('translation', '')}", 'word': word_text, + 'word_translation': translation, # Перевод слова для добавления в словарь 'correct_answer': sentence_data.get('answer', word_text), 'transcription': transcription, - 'example': example, - 'example_translation': example_translation, + 'example': full_sentence, # Полное предложение как пример + 'example_translation': sentence_data.get('translation', ''), 'difficulty_level': level }) @@ -308,14 +312,18 @@ async def create_tasks_from_words( sentence_title = "Переведи предложение:" word_hint = "Слово" + # Полное предложение (без пропуска) для отображения и контекста + full_sentence = sentence_data.get('full_sentence') or sentence_data.get('sentence', '').replace('___', word_text) + tasks.append({ 'type': 'sentence_translate', - 'question': f"{sentence_title}\n\n{sentence_data.get('sentence', word_text)}\n\n📝 {word_hint}: {word_text} — {translation}", + 'question': f"{sentence_title}\n\n{full_sentence}\n\n📝 {word_hint}: {word_text} — {translation}", 'word': word_text, + 'word_translation': translation, # Перевод слова (для добавления в словарь) 'correct_answer': sentence_data.get('translation', translation), 'transcription': transcription, - 'example': example, - 'example_translation': example_translation, + 'example': full_sentence, # Полное предложение как пример + 'example_translation': sentence_data.get('translation', ''), # Перевод предложения 'difficulty_level': level }) @@ -506,12 +514,17 @@ async def add_task_word(callback: CallbackQuery, state: FSMContext): task = tasks[task_index] word = task.get('word', '') - translation = task.get('correct_answer', '') + # Для sentence_translate и fill_in берём word_translation, иначе correct_answer + translation = task.get('word_translation') or task.get('correct_answer', '') transcription = task.get('transcription', '') example = task.get('example', '') # Пример использования как контекст example_translation = task.get('example_translation', '') # Перевод примера difficulty_level = task.get('difficulty_level') # Уровень сложности + # DEBUG: логируем что сохраняем + import logging + logging.info(f"[ADD_WORD] task_type={task.get('type')}, word={word}, example={example}") + async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) @@ -542,16 +555,14 @@ async def add_task_word(callback: CallbackQuery, state: FSMContext): source=WordSource.AI_TASK ) - # Сохраняем перевод в таблицу word_translations - await VocabularyService.add_translations_bulk( + # Сохраняем переводы в таблицу word_translations (разбиваем по запятой) + await VocabularyService.add_translation_split( session=session, vocabulary_id=new_word.id, - translations=[{ - 'translation': translation, - 'context': example if example else None, - 'context_translation': example_translation if example_translation else None, - 'is_primary': True - }] + translation=translation, + context=example if example else None, + context_translation=example_translation if example_translation else None, + is_primary=True ) await callback.answer(t(lang, 'tasks.word_added', word=word), show_alert=True) diff --git a/bot/handlers/wordofday.py b/bot/handlers/wordofday.py index 51fa4d5..6e2fc06 100644 --- a/bot/handlers/wordofday.py +++ b/bot/handlers/wordofday.py @@ -118,7 +118,7 @@ async def wod_add_callback(callback: CallbackQuery): # Добавляем в словарь translation_lang = get_user_translation_lang(user) - await VocabularyService.add_word( + new_word = await VocabularyService.add_word( session=session, user_id=user.id, word_original=wod.word, @@ -130,6 +130,23 @@ async def wod_add_callback(callback: CallbackQuery): source=WordSource.SUGGESTED ) + # Добавляем переводы в word_translations (разбиваем по запятой) + # Берём первый пример из examples если есть + example = None + example_translation = None + if wod.examples and len(wod.examples) > 0: + example = wod.examples[0].get('sentence') + example_translation = wod.examples[0].get('translation') + + await VocabularyService.add_translation_split( + session=session, + vocabulary_id=new_word.id, + translation=wod.translation, + context=example, + context_translation=example_translation, + is_primary=True + ) + await session.commit() # Обновляем сообщение diff --git a/bot/handlers/words.py b/bot/handlers/words.py index eddb3d5..e4b3ef5 100644 --- a/bot/handlers/words.py +++ b/bot/handlers/words.py @@ -170,7 +170,7 @@ async def add_single_word(callback: CallbackQuery, state: FSMContext): # Добавляем слово translation_lang = get_user_translation_lang(user) - await VocabularyService.add_word( + new_word = await VocabularyService.add_word( session=session, user_id=user_id, word_original=word_data['word'], @@ -182,6 +182,16 @@ async def add_single_word(callback: CallbackQuery, state: FSMContext): source=WordSource.SUGGESTED ) + # Добавляем переводы в word_translations (разбиваем по запятой) + await VocabularyService.add_translation_split( + session=session, + vocabulary_id=new_word.id, + translation=word_data['translation'], + context=word_data.get('example'), + context_translation=word_data.get('example_translation'), + is_primary=True + ) + async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) lang = (user.language_interface if user else 'ru') or 'ru' @@ -215,7 +225,7 @@ async def add_all_words(callback: CallbackQuery, state: FSMContext): # Добавляем слово translation_lang = get_user_translation_lang(user) - await VocabularyService.add_word( + new_word = await VocabularyService.add_word( session=session, user_id=user_id, word_original=word_data['word'], @@ -226,6 +236,16 @@ async def add_all_words(callback: CallbackQuery, state: FSMContext): difficulty_level=data.get('level'), source=WordSource.SUGGESTED ) + + # Добавляем переводы в word_translations (разбиваем по запятой) + await VocabularyService.add_translation_split( + session=session, + vocabulary_id=new_word.id, + translation=word_data['translation'], + context=word_data.get('example'), + context_translation=word_data.get('example_translation'), + is_primary=True + ) added_count += 1 lang = (user.language_interface if user else 'ru') or 'ru' diff --git a/database/models.py b/database/models.py index 1d890df..4963068 100644 --- a/database/models.py +++ b/database/models.py @@ -73,6 +73,8 @@ class User(Base): streak_days: Mapped[int] = mapped_column(Integer, default=0) tasks_count: Mapped[int] = mapped_column(Integer, default=5) # Количество заданий (5-15) ai_model_id: Mapped[Optional[int]] = mapped_column(Integer, default=None) # ID выбранной AI модели (NULL = глобальная) + is_premium: Mapped[bool] = mapped_column(Boolean, default=False) # Подписка (AI проверка в играх и др.) + premium_until: Mapped[Optional[datetime]] = mapped_column(DateTime, default=None) # Дата окончания подписки created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) last_active: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/locales/en.json b/locales/en.json index b95dc94..d8d7306 100644 --- a/locales/en.json +++ b/locales/en.json @@ -6,6 +6,7 @@ "task": "🧠 Task", "practice": "💬 Practice", "exercises": "📖 Exercises", + "games": "🎮 Mini Games", "words": "🎯 Thematic words", "import": "📖 Import", "stats": "📊 Stats", @@ -428,5 +429,63 @@ "score": "Score: {correct} of {total}", "no_topics": "No topics available for your level yet.", "write_answer": "Write your answer:" + }, + "minigames": { + "menu_title": "🎮 Mini Games\n\nChoose a game:", + "start_btn": "Start", + "back_btn": "Back", + "play_again": "Play again", + "speed_round": { + "name": "Speed Translation", + "rules": "📋 Rules:\n\n• A word from your vocabulary appears\n• You have {time} seconds to answer\n• Type the translation as fast as you can\n• Faster = more points!\n\n🎯 Rounds: {count}", + "mode_ai": "AI Check Mode", + "mode_simple": "Simple Mode", + "premium_hint": "You have a subscription! Choose answer checking mode.", + "ai_mode_warning": "⚠️ AI Check Mode\n\nIn this mode AI will check all answers after the game ends.\nDuring the game, answers are checked against dictionary.\n\nAI considers synonyms and translation variations.", + "ai_checking": "AI is checking answers", + "not_enough_words": "❌ Not enough words in vocabulary\n\nMinimum: {min} words\nYou have: {current}\n\nAdd words via /add or /words", + "round": "Round {current}/{total}", + "seconds": "sec", + "score": "Score", + "points": "points", + "time_up": "Time's up!", + "correct": "Correct!", + "wrong": "Wrong!", + "correct_was": "Correct answer", + "finished": "Game Over!", + "final_score": "Final score", + "correct_answers": "Correct answers", + "accuracy": "Accuracy", + "answers_list": "Answers", + "result": { + "excellent": "🔥 Excellent! You're a master!", + "good": "👏 Great result! Keep it up!", + "average": "📚 Not bad! Keep practicing.", + "practice": "💪 Don't give up! Practice makes perfect." + } + }, + "match_game": { + "name": "Match Pairs", + "rules": "📋 Rules:\n\n• {count} words and {count} translations will appear shuffled\n• Match the correct pairs\n• Enter answer in format: 1A, 2B, 3C...\n• Time is tracked", + "title": "Match the pairs", + "words_col": "Words", + "translations_col": "Translations", + "hint": "Enter pairs (e.g.: 1C, 2A, 3B, 4D, 5E)", + "cancel": "Cancel", + "cancelled": "❌ Game cancelled", + "not_enough_words": "❌ Not enough words in vocabulary\n\nMinimum: {min} words\nYou have: {current}\n\nAdd words via /add or /words", + "not_all_pairs": "⚠️ Enter all pairs!\n\nEntered: {entered}\nNeeded: {needed}", + "finished": "Game Over!", + "score": "Score", + "correct_pairs": "Correct pairs", + "time": "Time", + "results": "Results", + "result": { + "perfect": "🎯 Perfect! All pairs correct!", + "excellent": "🔥 Excellent! Almost flawless!", + "good": "👍 Good result!", + "practice": "💪 Keep practicing!" + } + } } } diff --git a/locales/ja.json b/locales/ja.json index 6828e75..4988abd 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -6,6 +6,7 @@ "task": "🧠 課題", "practice": "💬 練習", "exercises": "📖 文法練習", + "games": "🎮 ミニゲーム", "words": "🎯 テーマ別単語", "import": "📖 インポート", "stats": "📊 統計", @@ -420,5 +421,63 @@ "score": "スコア: {total}問中{correct}問正解", "no_topics": "あなたのレベルで利用可能なトピックはまだありません。", "write_answer": "回答を入力してください:" + }, + "minigames": { + "menu_title": "🎮 ミニゲーム\n\nゲームを選んでください:", + "start_btn": "開始", + "back_btn": "戻る", + "play_again": "もう一度", + "speed_round": { + "name": "スピード翻訳", + "rules": "📋 ルール:\n\n• 単語帳から単語が表示されます\n• {time}秒以内に回答してください\n• できるだけ早く翻訳を入力\n• 速いほどポイントが高い!\n\n🎯 ラウンド数: {count}", + "mode_ai": "AIチェックモード", + "mode_simple": "シンプルモード", + "premium_hint": "サブスクリプションがあります!回答チェックモードを選択してください。", + "ai_mode_warning": "⚠️ AIチェックモード\n\nこのモードでは、AIがゲーム終了後にすべての回答をチェックします。\nゲーム中は辞書で回答を確認します。\n\nAIは同義語や翻訳のバリエーションを考慮します。", + "not_enough_words": "❌ 単語帳の単語が足りません\n\n最低: {min}語\n現在: {current}語\n\n/add または /words で単語を追加してください", + "round": "ラウンド {current}/{total}", + "seconds": "秒", + "score": "スコア", + "points": "ポイント", + "time_up": "時間切れ!", + "correct": "正解!", + "wrong": "不正解!", + "correct_was": "正解", + "finished": "ゲーム終了!", + "final_score": "最終スコア", + "correct_answers": "正解数", + "accuracy": "正答率", + "ai_checking": "AIが回答をチェック中...", + "answers_list": "回答一覧", + "result": { + "excellent": "🔥 素晴らしい!マスターですね!", + "good": "👏 良い結果です!この調子で!", + "average": "📚 悪くないです!練習を続けましょう。", + "practice": "💪 諦めないで!練習あるのみ!" + } + }, + "match_game": { + "name": "ペアを見つけよう", + "rules": "📋 ルール:\n\n• {count}個の単語と{count}個の翻訳がシャッフルされて表示されます\n• 正しいペアを見つけてください\n• 回答形式: 1A, 2B, 3C...\n• 時間が計測されます", + "title": "ペアを合わせよう", + "words_col": "単語", + "translations_col": "翻訳", + "hint": "ペアを入力してください(例: 1C, 2A, 3B, 4D, 5E)", + "cancel": "キャンセル", + "cancelled": "❌ ゲームがキャンセルされました", + "not_enough_words": "❌ 単語帳の単語が足りません\n\n最低: {min}語\n現在: {current}語\n\n/add または /words で単語を追加してください", + "not_all_pairs": "⚠️ すべてのペアを入力してください!\n\n入力済み: {entered}\n必要: {needed}", + "finished": "ゲーム終了!", + "score": "スコア", + "correct_pairs": "正解ペア数", + "time": "時間", + "results": "結果", + "result": { + "perfect": "🎯 完璧!すべて正解!", + "excellent": "🔥 素晴らしい!ほぼ完璧!", + "good": "👍 良い結果!", + "practice": "💪 練習を続けましょう!" + } + } } } diff --git a/locales/ru.json b/locales/ru.json index 24edbe5..a23b47f 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -6,6 +6,7 @@ "task": "🧠 Задание", "practice": "💬 Практика", "exercises": "📖 Упражнения", + "games": "🎮 Мини-игры", "words": "🎯 Тематические слова", "import": "📖 Импорт", "stats": "📊 Статистика", @@ -428,5 +429,63 @@ "score": "Результат: {correct} из {total}", "no_topics": "Для твоего уровня пока нет доступных тем.", "write_answer": "Напиши свой ответ:" + }, + "minigames": { + "menu_title": "🎮 Мини-игры\n\nВыбери игру:", + "start_btn": "Начать", + "back_btn": "Назад", + "play_again": "Играть ещё", + "speed_round": { + "name": "Быстрый перевод", + "rules": "📋 Правила:\n\n• Появляется слово из твоего словаря\n• У тебя {time} секунд на ответ\n• Напиши перевод как можно быстрее\n• Чем быстрее — тем больше очков!\n\n🎯 Раундов: {count}", + "mode_ai": "С проверкой AI", + "mode_simple": "Простой режим", + "premium_hint": "У тебя есть подписка! Выбери режим проверки ответов.", + "ai_mode_warning": "⚠️ Режим AI проверки\n\nВ этом режиме AI проверит все ответы после окончания игры.\nВо время игры ответы проверяются по словарю.\n\nAI учитывает синонимы и вариации перевода.", + "ai_checking": "AI проверяет ответы", + "not_enough_words": "❌ Недостаточно слов в словаре\n\nМинимум: {min} слов\nУ тебя: {current}\n\nДобавь слова через /add или /words", + "round": "Раунд {current}/{total}", + "seconds": "сек", + "score": "Очки", + "points": "очков", + "time_up": "Время вышло!", + "correct": "Правильно!", + "wrong": "Неправильно!", + "correct_was": "Правильный ответ", + "finished": "Игра окончена!", + "final_score": "Итоговый счёт", + "correct_answers": "Правильных ответов", + "accuracy": "Точность", + "answers_list": "Ответы", + "result": { + "excellent": "🔥 Превосходно! Ты настоящий мастер!", + "good": "👏 Отличный результат! Так держать!", + "average": "📚 Неплохо! Продолжай практиковаться.", + "practice": "💪 Не сдавайся! Повторение — мать учения." + } + }, + "match_game": { + "name": "Найди пару", + "rules": "📋 Правила:\n\n• Появятся {count} слов и {count} переводов вперемешку\n• Нужно правильно соединить пары\n• Введи ответ в формате: 1A, 2B, 3C...\n• Засекается время прохождения", + "title": "Соедини пары", + "words_col": "Слова", + "translations_col": "Переводы", + "hint": "Введи пары (например: 1C, 2A, 3B, 4D, 5E)", + "cancel": "Отмена", + "cancelled": "❌ Игра отменена", + "not_enough_words": "❌ Недостаточно слов в словаре\n\nМинимум: {min} слов\nУ тебя: {current}\n\nДобавь слова через /add или /words", + "not_all_pairs": "⚠️ Введи все пары!\n\nВведено: {entered}\nНужно: {needed}", + "finished": "Игра окончена!", + "score": "Очки", + "correct_pairs": "Правильных пар", + "time": "Время", + "results": "Результаты", + "result": { + "perfect": "🎯 Идеально! Все пары верны!", + "excellent": "🔥 Превосходно! Почти без ошибок!", + "good": "👍 Хороший результат!", + "practice": "💪 Продолжай практиковаться!" + } + } } } diff --git a/main.py b/main.py index f37642a..2b4f315 100644 --- a/main.py +++ b/main.py @@ -7,7 +7,7 @@ from aiogram.enums import ParseMode from aiogram.types import BotCommand from config.settings import settings -from bot.handlers import start, vocabulary, tasks, settings as settings_handler, words, import_text, practice, reminder, level_test, admin, exercises, wordofday, stories +from bot.handlers import start, vocabulary, tasks, settings as settings_handler, words, import_text, practice, reminder, level_test, admin, exercises, wordofday, stories, minigames from services.reminder_service import init_reminder_service @@ -32,6 +32,7 @@ async def main(): BotCommand(command="task", description="Задания"), BotCommand(command="practice", description="Диалог с AI"), BotCommand(command="story", description="Мини-истории"), + BotCommand(command="games", description="Мини-игры"), BotCommand(command="add", description="Добавить слово"), BotCommand(command="words", description="Тематическая подборка слов"), BotCommand(command="vocabulary", description="Мой словарь"), @@ -52,6 +53,7 @@ async def main(): dp.include_router(exercises.router) dp.include_router(wordofday.router) dp.include_router(stories.router) + dp.include_router(minigames.router) dp.include_router(reminder.router) dp.include_router(admin.router) diff --git a/migrations/versions/20251210_add_is_premium.py b/migrations/versions/20251210_add_is_premium.py new file mode 100644 index 0000000..18a8734 --- /dev/null +++ b/migrations/versions/20251210_add_is_premium.py @@ -0,0 +1,29 @@ +"""Add is_premium and premium_until fields to users + +Revision ID: 20251210_is_premium +Revises: 20251209_story_translation +Create Date: 2025-12-10 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '20251210_is_premium' +down_revision: Union[str, None] = '20251209_story_translation' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('users', sa.Column('is_premium', sa.Boolean(), nullable=True, server_default='false')) + op.add_column('users', sa.Column('premium_until', sa.DateTime(), nullable=True)) + op.execute("UPDATE users SET is_premium = false WHERE is_premium IS NULL") + + +def downgrade() -> None: + op.drop_column('users', 'premium_until') + op.drop_column('users', 'is_premium') diff --git a/migrations/versions/20251210_rebuild_word_translations.py b/migrations/versions/20251210_rebuild_word_translations.py new file mode 100644 index 0000000..3dfe145 --- /dev/null +++ b/migrations/versions/20251210_rebuild_word_translations.py @@ -0,0 +1,66 @@ +"""Rebuild word_translations table from vocabulary + +Очищает word_translations и заново заполняет из vocabulary, +разбивая переводы по запятой/точке с запятой на отдельные записи. + +Revision ID: 20251210_rebuild_translations +Revises: 20251210_is_premium +Create Date: 2025-12-10 + +""" +from typing import Sequence, Union +import re + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '20251210_rebuild_translations' +down_revision: Union[str, None] = '20251210_is_premium' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Получаем connection для выполнения SQL + connection = op.get_bind() + + # 1. Очищаем таблицу word_translations + connection.execute(sa.text("DELETE FROM word_translations")) + + # 2. Получаем все слова из vocabulary + result = connection.execute(sa.text( + "SELECT id, word_translation FROM vocabulary WHERE word_translation IS NOT NULL AND word_translation != ''" + )) + + # 3. Для каждого слова разбиваем перевод и вставляем + for row in result: + vocab_id = row[0] + translation_str = row[1] + + if not translation_str: + continue + + # Разбиваем по запятой или точке с запятой + parts = re.split(r'[,;]\s*', translation_str) + translations = [p.strip() for p in parts if p.strip()] + + for i, translation in enumerate(translations): + is_primary = (i == 0) # Первый перевод - основной + connection.execute( + sa.text( + "INSERT INTO word_translations (vocabulary_id, translation, is_primary, created_at) " + "VALUES (:vocab_id, :translation, :is_primary, CURRENT_TIMESTAMP)" + ), + {"vocab_id": vocab_id, "translation": translation, "is_primary": is_primary} + ) + + print(f"Word translations rebuilt successfully") + + +def downgrade() -> None: + # При откате просто очищаем таблицу + # (восстановить старые данные невозможно) + connection = op.get_bind() + connection.execute(sa.text("DELETE FROM word_translations")) diff --git a/scripts/update_word_contexts.py b/scripts/update_word_contexts.py new file mode 100644 index 0000000..4038eb9 --- /dev/null +++ b/scripts/update_word_contexts.py @@ -0,0 +1,113 @@ +""" +Скрипт для обновления контекстов слов через AI. + +Запуск: + python scripts/update_word_contexts.py + +Что делает: +1. Находит слова в word_translations без контекста или с пропуском ___ +2. Генерирует примеры использования через AI +3. Обновляет записи в БД +""" +import asyncio +import sys +import os + +# Добавляем корень проекта в путь +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import select, or_ +from database.db import async_session_maker +from database.models import WordTranslation, Vocabulary +from services.ai_service import AIService + + +async def update_contexts(): + """Обновить контексты для слов""" + ai_service = AIService() + + async with async_session_maker() as session: + # Находим переводы без контекста или с пропуском + result = await session.execute( + select(WordTranslation, Vocabulary) + .join(Vocabulary, WordTranslation.vocabulary_id == Vocabulary.id) + .where( + or_( + WordTranslation.context.is_(None), + WordTranslation.context == '', + WordTranslation.context.contains('___') + ) + ) + .limit(50) # Обрабатываем по 50 за раз + ) + + rows = result.all() + + if not rows: + print("Нет слов для обновления") + return + + print(f"Найдено {len(rows)} слов для обновления контекста") + + # Группируем по языку + words_by_lang = {} + for word_translation, vocabulary in rows: + lang = vocabulary.source_lang or 'en' + trans_lang = vocabulary.translation_lang or 'ru' + key = (lang, trans_lang) + + if key not in words_by_lang: + words_by_lang[key] = [] + + words_by_lang[key].append({ + 'translation_id': word_translation.id, + 'word': vocabulary.word_original, + 'translation': word_translation.translation + }) + + # Генерируем контексты для каждой группы + for (learning_lang, translation_lang), words in words_by_lang.items(): + print(f"\nОбработка {len(words)} слов ({learning_lang} -> {translation_lang})...") + + # Формируем список слов для batch запроса + words_list = [w['word'] for w in words] + + # Генерируем примеры через AI + results = await ai_service.translate_words_batch( + words=words_list, + source_lang=learning_lang, + translation_lang=translation_lang + ) + + # Обновляем записи + updated = 0 + for word_data, ai_result in zip(words, results): + example = ai_result.get('example', '') + example_translation = ai_result.get('example_translation', '') + + if example and '___' not in example: + # Обновляем запись + word_translation = await session.get(WordTranslation, word_data['translation_id']) + if word_translation: + word_translation.context = example + word_translation.context_translation = example_translation + updated += 1 + print(f" ✓ {word_data['word']}: {example}") + + await session.commit() + print(f"Обновлено {updated} из {len(words)} слов") + + +async def main(): + print("=== Обновление контекстов слов ===\n") + + try: + await update_contexts() + print("\n✅ Готово!") + except Exception as e: + print(f"\n❌ Ошибка: {e}") + raise + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/services/ai_service.py b/services/ai_service.py index 977c1a2..f34f9d7 100644 --- a/services/ai_service.py +++ b/services/ai_service.py @@ -398,6 +398,8 @@ class AIService: "word": "исходное слово", "translation": "перевод", "transcription": "транскрипция (IPA или ромадзи для японского)",{furigana_instruction} + "example": "короткий пример использования на {source_lang}", + "example_translation": "перевод примера на {translation_lang}" }}, ... ] @@ -405,7 +407,7 @@ class AIService: Важно: - Верни только JSON массив, без дополнительного текста - Сохрани порядок слов как в исходном списке -- Для каждого слова укажи точный перевод и транскрипцию""" +- Для каждого слова укажи точный перевод, транскрипцию и короткий пример""" try: logger.info(f"[AI Request] translate_words_batch: {len(words)} words, {source_lang} -> {translation_lang}") @@ -498,6 +500,156 @@ class AIService: "score": 0 } + async def check_translation( + self, + word: str, + correct_translation: str, + user_answer: str, + source_lang: str = "en", + target_lang: str = "ru", + user_id: Optional[int] = None + ) -> Dict: + """ + Проверить перевод слова с помощью ИИ (для мини-игр) + + Args: + word: Оригинальное слово + correct_translation: Эталонный перевод + user_answer: Ответ пользователя + source_lang: Язык оригинального слова + target_lang: Язык перевода + user_id: ID пользователя в БД + + Returns: + Dict с результатом проверки + """ + prompt = f"""Проверь перевод слова. + +Слово ({source_lang}): {word} +Эталонный перевод ({target_lang}): {correct_translation} +Ответ пользователя: {user_answer} + +Определи, правильный ли перевод пользователя. Учитывай: +- Синонимы и близкие по смыслу слова +- Разные формы слова (единственное/множественное число) +- Небольшие опечатки + +Верни JSON: +{{ + "is_correct": true/false, + "feedback": "краткое пояснение (почему верно/неверно, какой вариант лучше)" +}}""" + + try: + logger.info(f"[AI Request] check_translation: word='{word}', user='{user_answer}'") + + messages = [ + {"role": "system", "content": "Ты - лингвист, проверяющий переводы. Будь справедлив и учитывай синонимы."}, + {"role": "user", "content": prompt} + ] + + response_data = await self._make_request(messages, temperature=0.2, user_id=user_id) + + result = json.loads(response_data['choices'][0]['message']['content']) + logger.info(f"[AI Response] check_translation: is_correct={result.get('is_correct', False)}") + return result + + except Exception as e: + logger.error(f"[AI Error] check_translation: {type(e).__name__}: {str(e)}") + # В случае ошибки делаем простое сравнение + is_correct = user_answer.lower().strip() == correct_translation.lower().strip() + return { + "is_correct": is_correct, + "feedback": "" + } + + async def check_translations_batch( + self, + answers: List[Dict], + source_lang: str = "en", + target_lang: str = "ru", + user_id: Optional[int] = None + ) -> List[Dict]: + """ + Проверить несколько переводов одним запросом (для мини-игр) + + Args: + answers: Список словарей с ключами: word, correct_translation, user_answer + source_lang: Язык оригинальных слов + target_lang: Язык перевода + user_id: ID пользователя в БД + + Returns: + Список словарей с результатами проверки + """ + if not answers: + return [] + + # Формируем список для проверки + answers_text = "" + for i, ans in enumerate(answers, 1): + answers_text += f"{i}. {ans['word']} → эталон: {ans['correct_translation']} | ответ: {ans['user_answer']}\n" + + prompt = f"""Проверь переводы слов с {source_lang} на {target_lang}. + +{answers_text} + +Для каждого слова определи, правильный ли перевод. Учитывай: +- Синонимы и близкие по смыслу слова +- Разные формы слова +- Небольшие опечатки + +Верни JSON массив: +[ + {{"index": 1, "is_correct": true/false, "feedback": "краткое пояснение", "user_answer_meaning": "что означает ответ пользователя на {source_lang}, если это валидное слово"}}, + ... +] + +user_answer_meaning - переведи ответ пользователя обратно на {source_lang}, чтобы показать что он на самом деле написал. Если ответ бессмысленный - оставь пустым.""" + + try: + logger.info(f"[AI Request] check_translations_batch: {len(answers)} answers") + + messages = [ + {"role": "system", "content": "Ты - лингвист, проверяющий переводы. Будь справедлив и учитывай синонимы. Отвечай только JSON массивом."}, + {"role": "user", "content": prompt} + ] + + response_data = await self._make_request(messages, temperature=0.2, user_id=user_id) + + result = json.loads(response_data['choices'][0]['message']['content']) + logger.info(f"[AI Response] check_translations_batch: {len(result)} results") + + # Преобразуем в удобный формат + results_map = {r['index']: r for r in result} + final_results = [] + + for i, ans in enumerate(answers, 1): + if i in results_map: + final_results.append({ + 'is_correct': results_map[i].get('is_correct', False), + 'feedback': results_map[i].get('feedback', ''), + 'user_answer_meaning': results_map[i].get('user_answer_meaning', '') + }) + else: + # Fallback на простое сравнение + is_correct = ans['user_answer'].lower().strip() == ans['correct_translation'].lower().strip() + final_results.append({'is_correct': is_correct, 'feedback': '', 'user_answer_meaning': ''}) + + return final_results + + except Exception as e: + logger.error(f"[AI Error] check_translations_batch: {type(e).__name__}: {str(e)}") + # В случае ошибки делаем простое сравнение для всех + return [ + { + 'is_correct': ans['user_answer'].lower().strip() == ans['correct_translation'].lower().strip(), + 'feedback': '', + 'user_answer_meaning': '' + } + for ans in answers + ] + async def generate_fill_in_sentence(self, word: str, learning_lang: str = "en", translation_lang: str = "ru", user_id: Optional[int] = None) -> Dict: """ Сгенерировать предложение с пропуском для заданного слова @@ -649,15 +801,17 @@ class AIService: "results": [ {{ "sentence": "предложение (с ___ для fill_blank)", + "full_sentence": "полное предложение БЕЗ пропуска", "answer": "слово для пропуска (только для fill_blank)", - "translation": "перевод на {translation_lang}" + "translation": "ПОЛНЫЙ перевод предложения на {translation_lang} (БЕЗ пропусков, БЕЗ слов на {learning_lang})" }} ] }} Важно: -- Для fill_blank: замени целевое слово на ___, укажи answer +- Для fill_blank: замени целевое слово на ___, укажи answer и full_sentence - Для sentence_translate: просто предложение со словом, answer не нужен +- translation должен быть ПОЛНЫМ переводом на {translation_lang}, без ___ и без слов на {learning_lang} - Предложения должны быть простыми (5-10 слов) - Контекст должен подсказывать правильное слово{furigana_instruction} - Верни результаты В ТОМ ЖЕ ПОРЯДКЕ что и задания""" @@ -842,7 +996,8 @@ class AIService: "word": "слово на {learning_lang} (в базовой форме)", "translation": "перевод на {translation_lang}", "transcription": "транскрипция в IPA (для английского) или хирагана (для японского)", - "context": "предложение из текста на {learning_lang}, где используется это слово" + "example": "предложение из текста на {learning_lang}, где используется это слово", + "example_translation": "перевод этого предложения на {translation_lang}" }} ] }} diff --git a/services/vocabulary_service.py b/services/vocabulary_service.py index 88fce2c..c1a7336 100644 --- a/services/vocabulary_service.py +++ b/services/vocabulary_service.py @@ -1,8 +1,11 @@ -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from database.models import Vocabulary, WordSource, LanguageLevel, WordTranslation -from typing import List, Optional, Dict +import random import re +from typing import List, Optional, Dict + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from database.models import Vocabulary, WordSource, LanguageLevel, WordTranslation class VocabularyService: @@ -263,6 +266,61 @@ class VocabularyService: return new_translation + @staticmethod + async def add_translation_split( + session: AsyncSession, + vocabulary_id: int, + translation: str, + context: Optional[str] = None, + context_translation: Optional[str] = None, + is_primary: bool = True + ) -> List[WordTranslation]: + """ + Добавить перевод(ы) к слову, разбивая строку по разделителям. + + Если translation содержит несколько переводов через запятую или точку с запятой, + каждый перевод добавляется отдельной записью. + + Args: + session: Сессия базы данных + vocabulary_id: ID слова в словаре + translation: Перевод (может содержать несколько через запятую) + context: Пример использования на языке изучения + context_translation: Перевод примера + is_primary: Является ли первый перевод основным + + Returns: + Список созданных переводов + """ + import re + + # Разбиваем по запятой или точке с запятой + parts = re.split(r'[,;]\s*', translation) + + # Очищаем и фильтруем пустые + translations = [p.strip() for p in parts if p.strip()] + + if not translations: + return [] + + created = [] + for i, tr in enumerate(translations): + new_translation = WordTranslation( + vocabulary_id=vocabulary_id, + translation=tr, + context=context if i == 0 else None, # Контекст только для первого перевода + context_translation=context_translation if i == 0 else None, + is_primary=(is_primary and i == 0) # Только первый - основной + ) + session.add(new_translation) + created.append(new_translation) + + await session.commit() + for t in created: + await session.refresh(t) + + return created + @staticmethod async def add_translations_bulk( session: AsyncSession, @@ -368,3 +426,103 @@ class VocabularyService: await session.commit() return True return False + + @staticmethod + async def get_user_word_count(session: AsyncSession, user_id: int) -> int: + """ + Получить количество слов пользователя + + Args: + session: Сессия базы данных + user_id: ID пользователя + + Returns: + Количество слов + """ + result = await session.execute( + select(func.count(Vocabulary.id)).where(Vocabulary.user_id == user_id) + ) + return result.scalar() or 0 + + @staticmethod + async def get_random_words_for_game( + session: AsyncSession, + user_id: int, + count: int = 10, + learning_lang: Optional[str] = None + ) -> List[Vocabulary]: + """ + Получить случайные слова для мини-игры + + Args: + session: Сессия базы данных + user_id: ID пользователя + count: Количество слов + learning_lang: Язык изучения для фильтрации + + Returns: + Список случайных слов + """ + result = await session.execute( + select(Vocabulary).where(Vocabulary.user_id == user_id) + ) + words = list(result.scalars().all()) + + if learning_lang: + words = VocabularyService._filter_by_learning_lang(words, learning_lang) + + # Перемешиваем и берём нужное количество + random.shuffle(words) + return words[:count] + + @staticmethod + async def get_random_words_with_translations( + session: AsyncSession, + user_id: int, + count: int = 10, + learning_lang: Optional[str] = None + ) -> List[dict]: + """ + Получить случайные слова для мини-игры вместе со всеми переводами + + Args: + session: Сессия базы данных + user_id: ID пользователя + count: Количество слов + learning_lang: Язык изучения для фильтрации + + Returns: + Список словарей с информацией о словах и их переводах + """ + # Получаем слова + words = await VocabularyService.get_random_words_for_game( + session, user_id, count, learning_lang + ) + + result = [] + for word in words: + # Получаем все переводы для слова + translations = await VocabularyService.get_word_translations(session, word.id) + + # Собираем все варианты перевода + all_translations = [] + + # Основной перевод из vocabulary + if word.word_translation: + all_translations.append(word.word_translation.lower().strip()) + + # Переводы из word_translations + for tr in translations: + tr_text = tr.translation.lower().strip() + if tr_text not in all_translations: + all_translations.append(tr_text) + + result.append({ + 'id': word.id, + 'word': word.word_original, + 'translation': word.word_translation, # Основной перевод для отображения + 'all_translations': all_translations, # Все варианты для проверки + 'transcription': word.transcription + }) + + return result diff --git a/versions/v1.5.0.md b/versions/v1.5.0.md new file mode 100644 index 0000000..02bea10 --- /dev/null +++ b/versions/v1.5.0.md @@ -0,0 +1,72 @@ +# Версия 1.5.0 + +**Дата:** 2025-12-10 + +## Новые функции + +### Мини-игры +- Добавлен раздел мини-игр (`/games`) +- **Быстрый перевод (Speed Round)** + - 10 раундов, 10 секунд на ответ + - Очки за скорость ответа + - Прогресс-бар таймера в реальном времени + - Фильтрация слов по изучаемому языку + - Список ответов с результатами в конце игры +- **Найди пару (Match Pairs)** + - 5 слов и 5 переводов вперемешку + - Нужно соединить правильные пары (формат: 1A, 2B, 3C...) + - Учёт времени прохождения + - Подсчёт очков за правильные пары +- Кнопка "Мини-игры" в главном меню + +### Система подписки (Premium) +- Добавлено поле `is_premium` для пользователей +- Добавлено поле `premium_until` для даты окончания подписки +- Премиум-пользователи могут выбирать режим проверки в играх: + - **AI режим** - проверка с учётом синонимов и вариаций ответа + - **Простой режим** - точное сравнение с переводами из словаря + +### Улучшенная проверка ответов +- В простом режиме теперь учитываются все переводы слова из таблицы `word_translations` +- Добавлен метод `check_translations_batch` в AI сервис для пакетной проверки всех ответов одним запросом +- AI проверка выполняется в конце игры (оптимизация количества запросов) +- Предупреждение о работе AI режима перед началом игры + +### Унификация добавления слов +- Все способы добавления слов теперь сохраняют перевод в таблицу `word_translations` +- Это обеспечивает корректную работу проверки в мини-играх для всех слов +- Добавлен метод `add_translation_split` - автоматическое разбиение переводов по запятой/точке с запятой на отдельные записи + +## Изменения в базе данных + +### Новые поля в таблице `users` +- `is_premium` (Boolean) - флаг подписки +- `premium_until` (DateTime) - дата окончания подписки + +### Миграция +- `20251210_add_is_premium.py` + +## Новые файлы +- `bot/handlers/minigames.py` - обработчики мини-игр +- `versions/v1.5.0.md` - файл обновления + +## Изменённые файлы +- `database/models.py` - добавлены поля подписки +- `services/ai_service.py` - метод `check_translations_batch` для пакетной проверки +- `services/vocabulary_service.py` - методы `get_random_words_with_translations`, `add_translation_split` +- `bot/handlers/start.py` - кнопка мини-игр в меню +- `bot/handlers/words.py` - добавление в word_translations при добавлении тематических слов +- `bot/handlers/import_text.py` - добавление в word_translations при импорте из текста +- `bot/handlers/stories.py` - добавление в word_translations при добавлении слов из историй +- `bot/handlers/wordofday.py` - добавление в word_translations при добавлении слова дня +- `main.py` - регистрация роутера мини-игр +- `locales/ru.json`, `locales/en.json`, `locales/ja.json` - локализация мини-игр + +## Локализация +Добавлены ключи для мини-игр на всех языках (ru/en/ja): +- Меню мини-игр +- Правила Speed Round +- Правила Match Pairs +- Режимы проверки (AI/простой) +- Предупреждение о работе AI режима +- Результаты и список ответов