feat: мини-истории, слово дня, меню практики

- Добавлены мини-истории для чтения с выбором жанра и вопросами
- Кнопка показа/скрытия перевода истории
- Количество вопросов берётся из настроек пользователя
- Слово дня генерируется глобально в 00:00 UTC
- Кнопка "Практика" открывает меню выбора режима
- Убран автоматический create_all при запуске (только миграции)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-09 15:05:38 +03:00
parent 69c651c031
commit f38ff2f18e
22 changed files with 3131 additions and 77 deletions

415
bot/handlers/exercises.py Normal file
View File

@@ -0,0 +1,415 @@
"""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)