Files
tg_bot_language/bot/handlers/exercises.py
mamonov.ep f38ff2f18e feat: мини-истории, слово дня, меню практики
- Добавлены мини-истории для чтения с выбором жанра и вопросами
- Кнопка показа/скрытия перевода истории
- Количество вопросов берётся из настроек пользователя
- Слово дня генерируется глобально в 00:00 UTC
- Кнопка "Практика" открывает меню выбора режима
- Убран автоматический create_all при запуске (только миграции)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 15:05:38 +03:00

416 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Handler для грамматических упражнений."""
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.ai_service import ai_service
from utils.i18n import t, get_user_lang, get_user_translation_lang
from utils.levels import get_user_level_for_language
from data.grammar_rules import get_topics_for_current_level_only, get_grammar_topics_for_level
router = Router()
# Количество тем на одной странице
TOPICS_PER_PAGE = 6
class ExercisesStates(StatesGroup):
"""Состояния для грамматических упражнений."""
choosing_topic = State()
viewing_rule = State()
doing_exercise = State()
waiting_answer = State()
@router.message(Command("exercises"))
async def cmd_exercises(message: Message, state: FSMContext):
"""Обработчик команды /exercises."""
await show_exercises_menu(message, state, telegram_id=message.from_user.id)
async def show_exercises_menu(message: Message, state: FSMContext, page: int = 0, telegram_id: int = None):
"""Показать меню выбора темы упражнений."""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, telegram_id)
if not user:
await message.answer(t('ru', 'common.start_first'))
return
lang = get_user_lang(user)
learning_lang = user.learning_language or 'en'
level = get_user_level_for_language(user)
# Получаем темы для текущего уровня
topics = get_topics_for_current_level_only(learning_lang, level)
if not topics:
await message.answer(t(lang, 'exercises.no_topics'))
return
# Пагинация
total_pages = (len(topics) + TOPICS_PER_PAGE - 1) // TOPICS_PER_PAGE
start_idx = page * TOPICS_PER_PAGE
end_idx = start_idx + TOPICS_PER_PAGE
page_topics = topics[start_idx:end_idx]
# Сохраняем в состоянии
await state.update_data(
topics=[t for t in topics], # Все темы
page=page,
level=level,
learning_lang=learning_lang,
user_id=user.id
)
await state.set_state(ExercisesStates.choosing_topic)
# Формируем текст
text = (
t(lang, 'exercises.title') + "\n\n" +
t(lang, 'exercises.your_level', level=level) + "\n\n" +
t(lang, 'exercises.choose_topic')
)
# Формируем клавиатуру
keyboard = []
for topic in page_topics:
# Используем русское название если интерфейс на русском, иначе английское
if lang == 'ru' and topic.get('name_ru'):
btn_text = topic['name_ru']
else:
btn_text = topic['name']
keyboard.append([
InlineKeyboardButton(
text=btn_text,
callback_data=f"exercise_topic_{topic['id']}"
)
])
# Навигация по страницам
nav_row = []
if page > 0:
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"exercises_page_{page - 1}"))
if total_pages > 1:
nav_row.append(InlineKeyboardButton(text=f"{page + 1}/{total_pages}", callback_data="exercises_noop"))
if page < total_pages - 1:
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"exercises_page_{page + 1}"))
if nav_row:
keyboard.append(nav_row)
# Кнопка закрыть
keyboard.append([
InlineKeyboardButton(text=t(lang, 'exercises.close_btn'), callback_data="exercises_close")
])
reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
await message.answer(text, reply_markup=reply_markup)
@router.callback_query(F.data.startswith("exercises_page_"))
async def exercises_page_callback(callback: CallbackQuery, state: FSMContext):
"""Переключение страницы тем."""
page = int(callback.data.split("_")[-1])
await callback.answer()
# Удаляем старое сообщение и показываем новое
await callback.message.delete()
await show_exercises_menu(callback.message, state, page=page, telegram_id=callback.from_user.id)
@router.callback_query(F.data == "exercises_noop")
async def exercises_noop_callback(callback: CallbackQuery):
"""Пустой callback для кнопки с номером страницы."""
await callback.answer()
@router.callback_query(F.data == "exercises_close")
async def exercises_close_callback(callback: CallbackQuery, state: FSMContext):
"""Закрыть меню упражнений."""
await callback.message.delete()
await state.clear()
await callback.answer()
@router.callback_query(F.data.startswith("exercise_topic_"))
async def exercise_topic_callback(callback: CallbackQuery, state: FSMContext):
"""Выбор темы для упражнения - показываем правило."""
await callback.answer()
topic_id = callback.data.replace("exercise_topic_", "")
data = await state.get_data()
topics = data.get('topics', [])
level = data.get('level', 'A1')
learning_lang = data.get('learning_lang', 'en')
user_id = data.get('user_id')
# Находим выбранную тему
topic = next((tp for tp in topics if tp['id'] == topic_id), None)
if not topic:
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, 'exercises.generate_failed'))
return
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, 'exercises.generating_rule'))
# Генерируем объяснение правила
rule_text = await ai_service.generate_grammar_rule(
topic_name=topic['name'],
topic_description=topic.get('description', ''),
level=level,
learning_lang=learning_lang,
ui_lang=lang,
user_id=user_id
)
# Сохраняем данные темы в состоянии
await state.update_data(
current_topic=topic,
rule_text=rule_text
)
await state.set_state(ExercisesStates.viewing_rule)
# Показываем правило с кнопкой "Начать упражнения"
topic_display = topic.get('name_ru', topic['name']) if lang == 'ru' else topic['name']
text = f"📖 <b>{topic_display}</b>\n\n{rule_text}"
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'exercises.start_btn'), callback_data="exercises_start_tasks")],
[InlineKeyboardButton(text=t(lang, 'exercises.back_btn'), callback_data="exercises_back_to_topics")]
])
await callback.message.edit_text(text, reply_markup=keyboard)
@router.callback_query(F.data == "exercises_start_tasks", ExercisesStates.viewing_rule)
async def start_exercises_callback(callback: CallbackQuery, state: FSMContext):
"""Начать упражнения после просмотра правила."""
await callback.answer()
data = await state.get_data()
topic = data.get('current_topic', {})
level = data.get('level', 'A1')
learning_lang = data.get('learning_lang', 'en')
user_id = data.get('user_id')
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'
translation_lang = get_user_translation_lang(user) if user else 'ru'
tasks_count = getattr(user, 'tasks_count', 5) or 5
# Показываем индикатор генерации
await callback.message.edit_text(t(lang, 'exercises.generating'))
# Генерируем упражнения
exercises = await ai_service.generate_grammar_exercise(
topic_id=topic.get('id', ''),
topic_name=topic.get('name', ''),
topic_description=topic.get('description', ''),
level=level,
learning_lang=learning_lang,
translation_lang=translation_lang,
count=tasks_count,
user_id=user_id
)
if not exercises:
await callback.message.edit_text(t(lang, 'exercises.generate_failed'))
return
# Сохраняем упражнения в состоянии
await state.update_data(
exercises=exercises,
current_exercise=0,
correct_count=0,
topic_name=topic.get('name', ''),
topic_name_ru=topic.get('name_ru', topic.get('name', ''))
)
await state.set_state(ExercisesStates.waiting_answer)
# Показываем первое упражнение
await show_exercise(callback.message, state, lang, edit=True)
async def show_exercise(message: Message, state: FSMContext, lang: str, edit: bool = False):
"""Показать текущее упражнение."""
data = await state.get_data()
exercises = data.get('exercises', [])
current = data.get('current_exercise', 0)
topic_name = data.get('topic_name_ru' if lang == 'ru' else 'topic_name', '')
if current >= len(exercises):
# Все упражнения завершены
await show_results(message, state, lang, edit)
return
exercise = exercises[current]
text = (
t(lang, 'exercises.task_header', topic=topic_name) + "\n\n" +
f"<b>{current + 1}/{len(exercises)}</b>\n\n" +
t(lang, 'exercises.instruction') + "\n\n" +
f"📝 {exercise.get('sentence', '')}\n\n" +
f"💬 <i>{exercise.get('translation', '')}</i>\n\n" +
f"💡 {exercise.get('hint', '')}\n\n" +
t(lang, 'exercises.write_answer')
)
# Кнопки
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'exercises.back_btn'), callback_data="exercises_back_to_topics")],
[InlineKeyboardButton(text=t(lang, 'exercises.close_btn'), callback_data="exercises_close")]
])
if edit:
await message.edit_text(text, reply_markup=keyboard)
else:
await message.answer(text, reply_markup=keyboard)
@router.callback_query(F.data == "exercises_back_to_topics")
async def back_to_topics_callback(callback: CallbackQuery, state: FSMContext):
"""Вернуться к выбору темы."""
await callback.answer()
await callback.message.delete()
data = await state.get_data()
page = data.get('page', 0)
await show_exercises_menu(callback.message, state, page=page, telegram_id=callback.from_user.id)
@router.message(ExercisesStates.waiting_answer)
async def process_answer(message: Message, state: FSMContext):
"""Обработка ответа пользователя."""
user_answer = message.text.strip().lower()
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
lang = get_user_lang(user) if user else 'ru'
data = await state.get_data()
exercises = data.get('exercises', [])
current = data.get('current_exercise', 0)
correct_count = data.get('correct_count', 0)
if current >= len(exercises):
return
exercise = exercises[current]
correct_answer = exercise.get('correct_answer', '').strip().lower()
# Проверяем ответ
is_correct = user_answer == correct_answer
if is_correct:
correct_count += 1
result_text = t(lang, 'exercises.correct') + "\n\n"
else:
result_text = (
t(lang, 'exercises.incorrect') + "\n\n" +
t(lang, 'exercises.your_answer', answer=message.text) + "\n" +
t(lang, 'exercises.right_answer', answer=exercise.get('correct_answer', '')) + "\n\n"
)
# Добавляем объяснение
result_text += t(lang, 'exercises.explanation', text=exercise.get('explanation', ''))
# Обновляем состояние
await state.update_data(
current_exercise=current + 1,
correct_count=correct_count
)
# Кнопка "Следующее" или "Результаты"
if current + 1 < len(exercises):
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'exercises.next_btn'), callback_data="exercises_next")]
])
else:
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'exercises.results_btn'), callback_data="exercises_finish")]
])
await message.answer(result_text, reply_markup=keyboard)
@router.callback_query(F.data == "exercises_next")
async def next_exercise_callback(callback: CallbackQuery, state: FSMContext):
"""Переход к следующему упражнению."""
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'
await show_exercise(callback.message, state, lang, edit=True)
@router.callback_query(F.data == "exercises_finish")
async def finish_exercises_callback(callback: CallbackQuery, state: FSMContext):
"""Показать результаты."""
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'
# Убираем кнопку с предыдущего сообщения (оставляем фидбэк видимым)
await callback.message.edit_reply_markup(reply_markup=None)
# Показываем результаты новым сообщением
await show_results(callback.message, state, lang, edit=False)
async def show_results(message: Message, state: FSMContext, lang: str, edit: bool = False):
"""Показать результаты упражнений."""
data = await state.get_data()
exercises = data.get('exercises', [])
correct_count = data.get('correct_count', 0)
total = len(exercises)
topic_name = data.get('topic_name_ru' if lang == 'ru' else 'topic_name', '')
text = (
f"🎉 <b>{topic_name}</b>\n\n" +
t(lang, 'exercises.score', correct=correct_count, total=total)
)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'exercises.back_btn'), callback_data="exercises_restart")],
[InlineKeyboardButton(text=t(lang, 'exercises.close_btn'), callback_data="exercises_close")]
])
if edit:
await message.edit_text(text, reply_markup=keyboard)
else:
await message.answer(text, reply_markup=keyboard)
await state.clear()
@router.callback_query(F.data == "exercises_restart")
async def restart_exercises_callback(callback: CallbackQuery, state: FSMContext):
"""Вернуться к выбору темы после завершения."""
await callback.answer()
await callback.message.delete()
await show_exercises_menu(callback.message, state, page=0, telegram_id=callback.from_user.id)