Files
tg_bot_language/bot/handlers/practice.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

571 lines
22 KiB
Python
Raw Permalink 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.
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
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
from utils.levels import get_user_level_for_language
router = Router()
class PracticeStates(StatesGroup):
"""Состояния для диалоговой практики"""
choosing_scenario = State()
entering_custom_scenario = State()
in_conversation = State()
# Доступные сценарии (ключи для i18n)
SCENARIO_KEYS = ["restaurant", "shopping", "travel", "work", "doctor", "casual"]
def get_scenario_name(lang: str, scenario: str) -> str:
"""Получить локализованное название сценария"""
return t(lang, f'practice.scenario.{scenario}')
async def show_practice_menu(message: Message, telegram_id: int, edit: bool = False):
"""Показать меню выбора сценария практики"""
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)
keyboard = []
for scenario_id in SCENARIO_KEYS:
keyboard.append([
InlineKeyboardButton(
text=get_scenario_name(lang, scenario_id),
callback_data=f"scenario_{scenario_id}"
)
])
keyboard.append([
InlineKeyboardButton(
text=t(lang, 'practice.custom_scenario_btn'),
callback_data="scenario_custom"
)
])
reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
if edit:
await message.edit_text(t(lang, 'practice.start_text'), reply_markup=reply_markup)
else:
await message.answer(t(lang, 'practice.start_text'), reply_markup=reply_markup)
@router.message(Command("practice"))
async def cmd_practice(message: Message, state: FSMContext):
"""Обработчик команды /practice"""
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
await state.update_data(user_id=user.id, level=get_user_level_for_language(user))
await state.set_state(PracticeStates.choosing_scenario)
await show_practice_menu(message, message.from_user.id, edit=False)
@router.callback_query(F.data == "scenario_custom", PracticeStates.choosing_scenario)
async def request_custom_scenario(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)
await callback.message.edit_text(
t(lang, 'practice.custom_scenario_prompt'),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'settings.back'), callback_data="back_to_scenarios")]
])
)
await state.set_state(PracticeStates.entering_custom_scenario)
@router.callback_query(F.data == "back_to_scenarios", PracticeStates.entering_custom_scenario)
async def back_to_scenarios(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)
keyboard = []
for scenario_id in SCENARIO_KEYS:
keyboard.append([
InlineKeyboardButton(
text=get_scenario_name(lang, scenario_id),
callback_data=f"scenario_{scenario_id}"
)
])
keyboard.append([
InlineKeyboardButton(
text=t(lang, 'practice.custom_scenario_btn'),
callback_data="scenario_custom"
)
])
await callback.message.edit_text(
t(lang, 'practice.start_text'),
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await state.set_state(PracticeStates.choosing_scenario)
@router.message(PracticeStates.entering_custom_scenario)
async def handle_custom_scenario(message: Message, state: FSMContext):
"""Обработка ввода своего сценария"""
custom_scenario = message.text.strip()
if not custom_scenario or len(custom_scenario) < 3:
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)
await message.answer(t(lang, 'practice.custom_scenario_too_short'))
return
data = await state.get_data()
level = data.get('level', 'B1')
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
ui_lang = get_user_lang(user)
learn_lang = (user.learning_language if user else 'en') or 'en'
# Показываем индикатор
thinking_msg = await message.answer(t(ui_lang, 'practice.thinking_prepare'))
# Начинаем диалог с кастомным сценарием
conversation_start = await ai_service.start_conversation(
custom_scenario, # Передаём описание сценария напрямую
level,
learning_lang=learn_lang,
translation_lang=ui_lang,
user_id=user.id if user else None
)
await thinking_msg.delete()
# Сохраняем данные в состоянии
await state.update_data(
scenario=custom_scenario,
scenario_name=custom_scenario,
conversation_history=[],
message_count=0
)
await state.set_state(PracticeStates.in_conversation)
# Формируем сообщение
ai_msg = conversation_start.get('message', '')
if learn_lang.lower() == 'ja':
annotated = conversation_start.get('message_annotated')
if annotated:
ai_msg = annotated
else:
fg = conversation_start.get('furigana')
if fg:
ai_msg = f"{ai_msg} ({fg})"
text = (
f"<b>🎭 {custom_scenario}</b>\n\n"
f"📝 <i>{conversation_start.get('context', '')}</i>\n\n"
f"<b>AI:</b> {ai_msg}\n\n"
f"{t(ui_lang, 'practice.hints')}\n"
)
for suggestion in conversation_start.get('suggestions', []):
if isinstance(suggestion, dict):
if learn_lang.lower() == 'ja':
learn_text = suggestion.get('learn_annotated') or suggestion.get('learn') or ''
else:
learn_text = suggestion.get('learn') or ''
trans_text = suggestion.get('trans') or ''
else:
learn_text = str(suggestion)
trans_text = ''
if trans_text:
text += f"<span class=\"tg-spoiler\">• {learn_text}</span> ({trans_text})\n"
else:
text += f"<span class=\"tg-spoiler\">• {learn_text}</span>\n"
text += t(ui_lang, 'practice.write_or_stop')
# Сохраняем перевод
translations = {0: conversation_start.get('translation', '')}
await state.update_data(translations=translations)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(ui_lang, 'practice.show_translation_btn'), callback_data="show_tr_0")],
[InlineKeyboardButton(text=t(ui_lang, 'practice.stop_btn'), callback_data="stop_practice")]
])
await message.answer(text, reply_markup=keyboard)
@router.callback_query(F.data.startswith("scenario_"), PracticeStates.choosing_scenario)
async def start_scenario(callback: CallbackQuery, state: FSMContext):
"""Начать диалог с выбранным сценарием"""
# Отвечаем сразу на callback, дальнейшие операции могут занять время
await callback.answer()
scenario = callback.data.split("_")[1]
data = await state.get_data()
level = data.get('level', 'B1')
# Определяем языки пользователя
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
ui_lang = get_user_lang(user)
learn_lang = (user.learning_language if user else 'en') or 'en'
# Удаляем клавиатуру
await callback.message.edit_reply_markup(reply_markup=None)
# Показываем индикатор
thinking_msg = await callback.message.answer(t(ui_lang, 'practice.thinking_prepare'))
# Начинаем диалог
conversation_start = await ai_service.start_conversation(
scenario,
level,
learning_lang=learn_lang,
translation_lang=ui_lang,
user_id=user.id if user else None
)
await thinking_msg.delete()
# Сохраняем данные в состоянии
await state.update_data(
scenario=scenario,
scenario_name=scenario,
conversation_history=[],
message_count=0
)
await state.set_state(PracticeStates.in_conversation)
# Формируем сообщение (перевод скрыт, доступен по кнопке)
ai_msg = conversation_start.get('message', '')
if learn_lang.lower() == 'ja':
annotated = conversation_start.get('message_annotated')
if annotated:
ai_msg = annotated
else:
# Фолбэк к старому формату с общим furigana
fg = conversation_start.get('furigana')
if fg:
ai_msg = f"{ai_msg} ({fg})"
text = (
f"<b>{get_scenario_name(ui_lang, scenario)}</b>\n\n"
f"📝 <i>{conversation_start.get('context', '')}</i>\n\n"
f"<b>AI:</b> {ai_msg}\n\n"
f"{t(ui_lang, 'practice.hints')}\n"
)
for suggestion in conversation_start.get('suggestions', []):
if isinstance(suggestion, dict):
# Для японского стараемся брать learn_annotated
if learn_lang.lower() == 'ja':
learn_text = suggestion.get('learn_annotated') or suggestion.get('learn') or ''
else:
learn_text = suggestion.get('learn') or ''
trans_text = suggestion.get('trans') or ''
else:
learn_text = str(suggestion)
trans_text = ''
# В спойлере — язык изучения, в скобках — перевод на язык интерфейса
if trans_text:
text += f"<span class=\"tg-spoiler\">• {learn_text}</span> ({trans_text})\n"
else:
text += f"<span class=\"tg-spoiler\">• {learn_text}</span>\n"
text += t(ui_lang, 'practice.write_or_stop')
# Сохраняем перевод под индексом 0
translations = {0: conversation_start.get('translation', '')}
await state.update_data(translations=translations)
# Кнопки управления
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(ui_lang, 'practice.show_translation_btn'), callback_data="show_tr_0")],
[InlineKeyboardButton(text=t(ui_lang, 'practice.stop_btn'), callback_data="stop_practice")]
])
await callback.message.answer(text, reply_markup=keyboard)
def get_end_keyboard(lang: str) -> InlineKeyboardMarkup:
"""Клавиатура после завершения диалога"""
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'practice.new_practice_btn'), callback_data="new_practice")],
[
InlineKeyboardButton(text=t(lang, 'practice.to_tasks_btn'), callback_data="go_tasks"),
InlineKeyboardButton(text=t(lang, 'practice.to_words_btn'), callback_data="go_words")
]
])
@router.message(Command("stop"), PracticeStates.in_conversation)
async def stop_practice(message: Message, state: FSMContext):
"""Завершить диалоговую практику"""
data = await state.get_data()
message_count = data.get('message_count', 0)
await state.clear()
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
lang = (user.language_interface if user else 'ru') or 'ru'
end_text = (
t(lang, 'practice.end_title') + "\n\n" +
t(lang, 'practice.end_exchanged', n=message_count) + "\n\n" +
t(lang, 'practice.end_keep')
)
await message.answer(end_text, reply_markup=get_end_keyboard(lang))
@router.callback_query(F.data == "stop_practice", PracticeStates.in_conversation)
async def stop_practice_callback(callback: CallbackQuery, state: FSMContext):
"""Завершить диалог через кнопку"""
data = await state.get_data()
message_count = data.get('message_count', 0)
await callback.message.delete()
await state.clear()
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'
end_text = (
t(lang, 'practice.end_title') + "\n\n" +
t(lang, 'practice.end_exchanged', n=message_count) + "\n\n" +
t(lang, 'practice.end_keep')
)
await callback.message.answer(end_text, reply_markup=get_end_keyboard(lang))
await callback.answer()
@router.callback_query(F.data == "new_practice")
async def new_practice_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)
if not user:
await callback.message.edit_text(t('ru', 'common.start_first'))
return
lang = get_user_lang(user)
# Показываем выбор сценария
keyboard = []
for scenario_id in SCENARIO_KEYS:
keyboard.append([
InlineKeyboardButton(
text=get_scenario_name(lang, scenario_id),
callback_data=f"scenario_{scenario_id}"
)
])
keyboard.append([
InlineKeyboardButton(
text=t(lang, 'practice.custom_scenario_btn'),
callback_data="scenario_custom"
)
])
reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
await state.update_data(user_id=user.id, level=get_user_level_for_language(user))
await state.set_state(PracticeStates.choosing_scenario)
await callback.message.edit_text(t(lang, 'practice.start_text'), reply_markup=reply_markup)
@router.callback_query(F.data == "go_tasks")
async def go_tasks_callback(callback: CallbackQuery):
"""Перейти к заданиям"""
await callback.message.delete()
await callback.answer()
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'
await callback.message.answer(t(lang, 'practice.go_tasks_hint'))
@router.callback_query(F.data == "go_words")
async def go_words_callback(callback: CallbackQuery):
"""Перейти к словам"""
await callback.message.delete()
await callback.answer()
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'
await callback.message.answer(t(lang, 'practice.go_words_hint'))
@router.message(PracticeStates.in_conversation)
async def handle_conversation(message: Message, state: FSMContext):
"""Обработка сообщений в диалоге"""
user_message = message.text.strip()
if not user_message:
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
await message.answer(t((user.language_interface if user else 'ru') or 'ru', 'practice.empty_prompt'))
return
data = await state.get_data()
conversation_history = data.get('conversation_history', [])
scenario = data.get('scenario', 'casual')
level = data.get('level', 'B1')
message_count = data.get('message_count', 0)
# Определяем языки пользователя для ответа (до показа индикатора)
async with async_session_maker() as session:
user2 = await UserService.get_user_by_telegram_id(session, message.from_user.id)
ui_lang2 = (user2.language_interface if user2 else 'ru') or 'ru'
learn_lang2 = (user2.learning_language if user2 else 'en') or 'en'
# Показываем индикатор
thinking_msg = await message.answer(t(ui_lang2, 'practice.thinking'))
# Добавляем сообщение пользователя в историю
conversation_history.append({
"role": "user",
"content": user_message
})
# Получаем ответ от AI
ai_response = await ai_service.continue_conversation(
conversation_history=conversation_history,
user_message=user_message,
scenario=scenario,
level=level,
learning_lang=learn_lang2,
translation_lang=ui_lang2,
user_id=user2.id if user2 else None
)
await thinking_msg.delete()
# Добавляем ответ AI в историю
conversation_history.append({
"role": "assistant",
"content": ai_response.get('response', '')
})
# Обновляем состояние
message_count += 1
await state.update_data(
conversation_history=conversation_history,
message_count=message_count
)
# Формируем ответ (перевод скрыт, доступен по кнопке)
# Язык пользователя для текста
text = ""
# Показываем feedback, если есть ошибки
feedback = ai_response.get('feedback', {})
if feedback.get('has_errors') and feedback.get('corrections'):
text += f"⚠️ {t(ui_lang2, 'practice.corrections')}\n{feedback['corrections']}\n\n"
if feedback.get('comment'):
text += f"💬 {feedback['comment']}\n\n"
# Ответ AI (с фуриганой для японского)
resp = ai_response.get('response', '')
if learn_lang2.lower() == 'ja':
annotated = ai_response.get('response_annotated')
if annotated:
resp = annotated
else:
fg = ai_response.get('furigana')
if fg:
resp = f"{resp} ({fg})"
text += f"<b>AI:</b> {resp}\n\n"
# Подсказки
suggestions = ai_response.get('suggestions', [])
if suggestions:
text += t(ui_lang2, 'practice.hints') + "\n"
for suggestion in suggestions[:3]:
if isinstance(suggestion, dict):
if learn_lang2.lower() == 'ja':
learn_text = suggestion.get('learn_annotated') or suggestion.get('learn') or ''
else:
learn_text = suggestion.get('learn') or ''
trans_text = suggestion.get('trans') or ''
else:
learn_text = str(suggestion)
trans_text = ''
if trans_text:
text += f"<span class=\"tg-spoiler\">• {learn_text}</span> ({trans_text})\n"
else:
text += f"<span class=\"tg-spoiler\">• {learn_text}</span>\n"
# Сохраняем перевод под новым индексом
translations = data.get('translations', {}) or {}
this_idx = message_count # после инкремента это текущий номер сообщения AI
translations[this_idx] = ai_response.get('translation', '')
await state.update_data(translations=translations)
# Кнопки
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(ui_lang2, 'practice.show_translation_btn'), callback_data=f"show_tr_{this_idx}")],
[InlineKeyboardButton(text=t(ui_lang2, 'practice.stop_btn'), callback_data="stop_practice")]
])
await message.answer(text, reply_markup=keyboard)
@router.callback_query(F.data.startswith("show_tr_"), PracticeStates.in_conversation)
async def show_translation(callback: CallbackQuery, state: FSMContext):
"""Показать перевод для конкретного сообщения AI"""
# Определяем язык интерфейса пользователя
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'
try:
idx = int(callback.data.split("_")[-1])
except Exception:
await callback.answer(t(lang, 'practice.translation_unavailable'), show_alert=True)
return
data = await state.get_data()
translations = data.get('translations', {}) or {}
tr_text = translations.get(idx)
if not tr_text:
await callback.answer(t(lang, 'practice.translation_unavailable'), show_alert=True)
return
# Вставляем перевод в существующее сообщение
orig = callback.message.text or ""
# Определяем язык (уже вычислен выше)
marker = t(lang, 'common.translation') + ":"
if marker in orig:
await callback.answer(t(lang, 'practice.translation_already'))
return
new_text = f"{orig}\n{marker} <i>{tr_text}</i>"
try:
await callback.message.edit_text(new_text, reply_markup=callback.message.reply_markup)
except Exception:
# Если не удалось отредактировать (например, старое сообщение), отправим отдельным сообщением как запасной вариант
await callback.message.answer(f"{marker} <i>{tr_text}</i>")
await callback.answer()