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 режима
+- Результаты и список ответов