feat: мини-игры, premium подписка, улучшенные контексты

Мини-игры (/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 <noreply@anthropic.com>
This commit is contained in:
2025-12-10 19:42:10 +03:00
parent b74ea2170c
commit adc8a6bf8e
18 changed files with 1819 additions and 34 deletions

View File

@@ -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)

903
bot/handlers/minigames.py Normal file
View File

@@ -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"⚡ <b>{t(lang, 'minigames.speed_round.name')}</b>\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"<code>{word}</code>"
if transcription:
word_display += f"\n<i>[{transcription}]</i>"
text = (
f"⚡ <b>{t(lang, 'minigames.speed_round.round', current=current_round + 1, total=len(game_words))}</b>\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')}: <b>{score}</b>"
)
# Сохраняем время начала раунда
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"<code>{word}</code>"
if transcription:
word_display += f"\n<i>[{transcription}]</i>"
if remaining > 0:
# Обновляем прогресс-бар
text = (
f"⚡ <b>{t(lang, 'minigames.speed_round.round', current=current_round + 1, total=len(game_words))}</b>\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')}: <b>{score}</b>"
)
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"⏰ <b>{t(lang, 'minigames.speed_round.time_up')}</b>\n\n"
f"{word_display}\n\n"
f"{t(lang, 'minigames.speed_round.correct_was')}: <b>{word_data['translation']}</b>",
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"✅ <b>{t(lang, 'minigames.speed_round.correct')}</b>\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"❌ <b>{t(lang, 'minigames.speed_round.wrong')}</b>\n\n"
f"{t(lang, 'minigames.speed_round.correct_was')}: <b>{correct_answer}</b>"
)
# Сохраняем результат раунда (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" <i>({result['ai_feedback']})</i>"
answers_text += "\n"
else:
user_ans = result['user_answer'] or ""
answers_text += f"{result['word']} → <s>{user_ans}</s>"
# Показываем значение ответа пользователя (что он на самом деле написал)
if use_ai_check and result.get('user_answer_meaning'):
answers_text += f" <i>= {result['user_answer_meaning']}</i>"
answers_text += f" ({result['correct_answer']})"
if use_ai_check and result.get('ai_feedback'):
answers_text += f" <i>({result['ai_feedback']})</i>"
answers_text += "\n"
# Добавляем пометку об AI проверке
ai_badge = "🤖 " if use_ai_check else ""
text = (
f"{emoji} <b>{t(lang, 'minigames.speed_round.finished')}</b>\n\n"
f"🏆 {t(lang, 'minigames.speed_round.final_score')}: <b>{score}</b>\n"
f"{t(lang, 'minigames.speed_round.correct_answers')}: <b>{correct_count}/{total}</b>\n"
f"🎯 {t(lang, 'minigames.speed_round.accuracy')}: <b>{accuracy}%</b>\n\n"
f"📋 {ai_badge}<b>{t(lang, 'minigames.speed_round.answers_list')}:</b>\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"🎯 <b>{t(lang, 'minigames.match_game.name')}</b>\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"🎯 <b>{t(lang, 'minigames.match_game.title')}</b>\n\n"
# Формируем две колонки
text += f"<b>{t(lang, 'minigames.match_game.words_col')}:</b>\n"
for num, word in words_list:
text += f" {num}. {word}\n"
text += f"\n<b>{t(lang, 'minigames.match_game.translations_col')}:</b>\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']} → <s>{r['user_letter']}</s> ({r['correct_letter']}. {r['translation']})\n"
text = (
f"{emoji} <b>{t(lang, 'minigames.match_game.finished')}</b>\n\n"
f"🏆 {t(lang, 'minigames.match_game.score')}: <b>{score}</b>\n"
f"{t(lang, 'minigames.match_game.correct_pairs')}: <b>{correct_count}/{total}</b>\n"
f"{t(lang, 'minigames.match_game.time')}: <b>{time_taken:.1f}</b> {t(lang, 'minigames.speed_round.seconds')}\n\n"
f"📋 <b>{t(lang, 'minigames.match_game.results')}:</b>\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)

View File

@@ -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):
"""Подсказать про тематические слова и показать быстрые темы."""

View File

@@ -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()

View File

@@ -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<b>{sentence_data.get('sentence', '___')}</b>\n\n<i>{sentence_data.get('translation', '')}</i>",
'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<b>{sentence_data.get('sentence', word_text)}</b>\n\n📝 {word_hint}: <code>{word_text}</code> — {translation}",
'question': f"{sentence_title}\n\n<b>{full_sentence}</b>\n\n📝 {word_hint}: <code>{word_text}</code> — {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)

View File

@@ -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()
# Обновляем сообщение

View File

@@ -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'

View File

@@ -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)

View File

@@ -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": "🎮 <b>Mini Games</b>\n\nChoose a game:",
"start_btn": "Start",
"back_btn": "Back",
"play_again": "Play again",
"speed_round": {
"name": "Speed Translation",
"rules": "📋 <b>Rules:</b>\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": "⚠️ <b>AI Check Mode</b>\n\nIn this mode AI will check all answers <b>after the game ends</b>.\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": "📋 <b>Rules:</b>\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!"
}
}
}
}

View File

@@ -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": "🎮 <b>ミニゲーム</b>\n\nゲームを選んでください:",
"start_btn": "開始",
"back_btn": "戻る",
"play_again": "もう一度",
"speed_round": {
"name": "スピード翻訳",
"rules": "📋 <b>ルール:</b>\n\n• 単語帳から単語が表示されます\n• {time}秒以内に回答してください\n• できるだけ早く翻訳を入力\n• 速いほどポイントが高い!\n\n🎯 ラウンド数: {count}",
"mode_ai": "AIチェックモード",
"mode_simple": "シンプルモード",
"premium_hint": "サブスクリプションがあります!回答チェックモードを選択してください。",
"ai_mode_warning": "⚠️ <b>AIチェックモード</b>\n\nこのモードでは、AIが<b>ゲーム終了後</b>にすべての回答をチェックします。\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": "📋 <b>ルール:</b>\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": "💪 練習を続けましょう!"
}
}
}
}

View File

@@ -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": "🎮 <b>Мини-игры</b>\n\nВыбери игру:",
"start_btn": "Начать",
"back_btn": "Назад",
"play_again": "Играть ещё",
"speed_round": {
"name": "Быстрый перевод",
"rules": "📋 <b>Правила:</b>\n\n• Появляется слово из твоего словаря\n• У тебя {time} секунд на ответ\n• Напиши перевод как можно быстрее\n• Чем быстрее — тем больше очков!\n\n🎯 Раундов: {count}",
"mode_ai": "С проверкой AI",
"mode_simple": "Простой режим",
"premium_hint": "У тебя есть подписка! Выбери режим проверки ответов.",
"ai_mode_warning": "⚠️ <b>Режим AI проверки</b>\n\nВ этом режиме AI проверит все ответы <b>после окончания игры</b>.\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": "📋 <b>Правила:</b>\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": "💪 Продолжай практиковаться!"
}
}
}
}

View File

@@ -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)

View File

@@ -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')

View File

@@ -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"))

View File

@@ -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())

View File

@@ -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}"
}}
]
}}

View File

@@ -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

72
versions/v1.5.0.md Normal file
View File

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