feat: JLPT levels for Japanese, custom practice scenarios, UI improvements
- Add separate level systems: CEFR (A1-C2) for European languages, JLPT (N5-N1) for Japanese - Store levels per language in new `levels_by_language` JSON field - Add custom scenario option in AI practice mode - Show action buttons after practice ends (new dialogue, tasks, words) - Fix level display across all handlers to use correct level system - Add Alembic migration for levels_by_language field - Update all locale files (ru, en, ja) with new keys 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,8 @@ 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
|
||||
from utils.i18n import t, get_user_lang
|
||||
from utils.levels import get_user_level_for_language
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -15,18 +16,17 @@ router = Router()
|
||||
class PracticeStates(StatesGroup):
|
||||
"""Состояния для диалоговой практики"""
|
||||
choosing_scenario = State()
|
||||
entering_custom_scenario = State()
|
||||
in_conversation = State()
|
||||
|
||||
|
||||
# Доступные сценарии
|
||||
SCENARIOS = {
|
||||
"restaurant": {"ru": "🍽️ Ресторан", "en": "🍽️ Restaurant", "ja": "🍽️ レストラン"},
|
||||
"shopping": {"ru": "🛍️ Магазин", "en": "🛍️ Shopping", "ja": "🛍️ ショッピング"},
|
||||
"travel": {"ru": "✈️ Путешествие","en": "✈️ Travel", "ja": "✈️ 旅行"},
|
||||
"work": {"ru": "💼 Работа", "en": "💼 Work", "ja": "💼 仕事"},
|
||||
"doctor": {"ru": "🏥 Врач", "en": "🏥 Doctor", "ja": "🏥 医者"},
|
||||
"casual": {"ru": "💬 Общение", "en": "💬 Casual", "ja": "💬 会話"}
|
||||
}
|
||||
# Доступные сценарии (ключи для 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}')
|
||||
|
||||
|
||||
@router.message(Command("practice"))
|
||||
@@ -39,23 +39,167 @@ async def cmd_practice(message: Message, state: FSMContext):
|
||||
await message.answer(t('ru', 'common.start_first'))
|
||||
return
|
||||
|
||||
lang = get_user_lang(user)
|
||||
|
||||
# Показываем выбор сценария
|
||||
keyboard = []
|
||||
lang = user.language_interface or 'ru'
|
||||
for scenario_id, names in SCENARIOS.items():
|
||||
for scenario_id in SCENARIO_KEYS:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=names.get(lang, names.get('ru')),
|
||||
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=user.level.value)
|
||||
await state.update_data(user_id=user.id, level=get_user_level_for_language(user))
|
||||
await state.set_state(PracticeStates.choosing_scenario)
|
||||
|
||||
await message.answer(t(user.language_interface or 'ru', 'practice.start_text'), reply_markup=reply_markup)
|
||||
await message.answer(t(lang, 'practice.start_text'), reply_markup=reply_markup)
|
||||
|
||||
|
||||
@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
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -69,7 +213,7 @@ async def start_scenario(callback: CallbackQuery, state: FSMContext):
|
||||
# Определяем языки пользователя
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
ui_lang = (user.language_interface if user else 'ru') or 'ru'
|
||||
ui_lang = get_user_lang(user)
|
||||
learn_lang = (user.learning_language if user else 'en') or 'en'
|
||||
|
||||
# Удаляем клавиатуру
|
||||
@@ -91,7 +235,7 @@ async def start_scenario(callback: CallbackQuery, state: FSMContext):
|
||||
# Сохраняем данные в состоянии
|
||||
await state.update_data(
|
||||
scenario=scenario,
|
||||
scenario_name=SCENARIOS[scenario],
|
||||
scenario_name=scenario,
|
||||
conversation_history=[],
|
||||
message_count=0
|
||||
)
|
||||
@@ -110,7 +254,7 @@ async def start_scenario(callback: CallbackQuery, state: FSMContext):
|
||||
ai_msg = f"{ai_msg} ({fg})"
|
||||
|
||||
text = (
|
||||
f"<b>{SCENARIOS[scenario].get(ui_lang, SCENARIOS[scenario]['ru'])}</b>\n\n"
|
||||
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"
|
||||
@@ -148,6 +292,17 @@ async def start_scenario(callback: CallbackQuery, state: FSMContext):
|
||||
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):
|
||||
"""Завершить диалоговую практику"""
|
||||
@@ -161,10 +316,9 @@ async def stop_practice(message: Message, state: FSMContext):
|
||||
end_text = (
|
||||
t(lang, 'practice.end_title') + "\n\n" +
|
||||
t(lang, 'practice.end_exchanged', n=message_count) + "\n\n" +
|
||||
t(lang, 'practice.end_keep') + "\n" +
|
||||
t(lang, 'practice.end_hint')
|
||||
t(lang, 'practice.end_keep')
|
||||
)
|
||||
await message.answer(end_text)
|
||||
await message.answer(end_text, reply_markup=get_end_keyboard(lang))
|
||||
|
||||
|
||||
@router.callback_query(F.data == "stop_practice", PracticeStates.in_conversation)
|
||||
@@ -182,13 +336,72 @@ async def stop_practice_callback(callback: CallbackQuery, state: FSMContext):
|
||||
end_text = (
|
||||
t(lang, 'practice.end_title') + "\n\n" +
|
||||
t(lang, 'practice.end_exchanged', n=message_count) + "\n\n" +
|
||||
t(lang, 'practice.end_keep') + "\n" +
|
||||
t(lang, 'practice.end_hint')
|
||||
t(lang, 'practice.end_keep')
|
||||
)
|
||||
await callback.message.answer(end_text)
|
||||
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):
|
||||
"""Обработка сообщений в диалоге"""
|
||||
|
||||
Reference in New Issue
Block a user