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:
@@ -5,9 +5,10 @@ from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
from database.db import async_session_maker
|
||||
from database.models import LanguageLevel
|
||||
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_level_system, get_available_levels, CEFR_LEVELS, JLPT_LEVELS
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -58,28 +59,31 @@ async def begin_test(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.answer()
|
||||
await callback.message.delete()
|
||||
|
||||
# Показываем индикатор загрузки
|
||||
loading_msg = await callback.message.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)
|
||||
learning_lang = getattr(user, 'learning_language', 'en') or 'en'
|
||||
|
||||
# Генерируем тест через AI
|
||||
questions = await ai_service.generate_level_test()
|
||||
# Показываем индикатор загрузки
|
||||
loading_msg = await callback.message.answer(t(lang, 'level_test_extra.generating'))
|
||||
|
||||
# Генерируем тест через AI с учётом языка изучения
|
||||
questions = await ai_service.generate_level_test(learning_lang)
|
||||
|
||||
await loading_msg.delete()
|
||||
|
||||
if not questions:
|
||||
await callback.message.answer(
|
||||
"❌ Не удалось сгенерировать тест. Попробуй позже или используй /settings для ручной установки уровня."
|
||||
)
|
||||
await callback.message.answer(t(lang, 'level_test_extra.generate_failed'))
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Сохраняем данные в состоянии
|
||||
# Сохраняем данные в состоянии (включая язык для определения системы уровней)
|
||||
await state.update_data(
|
||||
questions=questions,
|
||||
current_question=0,
|
||||
correct_answers=0,
|
||||
answers=[] # Для отслеживания ответов по уровням
|
||||
answers=[], # Для отслеживания ответов по уровням
|
||||
learning_language=learning_lang
|
||||
)
|
||||
await state.set_state(LevelTestStates.taking_test)
|
||||
|
||||
@@ -138,32 +142,35 @@ async def show_question(message: Message, state: FSMContext):
|
||||
@router.callback_query(F.data.startswith("show_qtr_"), LevelTestStates.taking_test)
|
||||
async def show_question_translation(callback: CallbackQuery, state: FSMContext):
|
||||
"""Показать перевод текущего вопроса"""
|
||||
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)
|
||||
|
||||
try:
|
||||
idx = int(callback.data.split("_")[-1])
|
||||
except Exception:
|
||||
await callback.answer("Перевод недоступен", show_alert=True)
|
||||
await callback.answer(t(lang, 'level_test_extra.translation_unavailable'), show_alert=True)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
questions = data.get('questions', [])
|
||||
if not (0 <= idx < len(questions)):
|
||||
await callback.answer("Перевод недоступен", show_alert=True)
|
||||
await callback.answer(t(lang, 'level_test_extra.translation_unavailable'), show_alert=True)
|
||||
return
|
||||
|
||||
ru = questions[idx].get('question_ru') or "Перевод недоступен"
|
||||
ru = questions[idx].get('question_ru') or t(lang, 'level_test_extra.translation_unavailable')
|
||||
|
||||
# Вставляем перевод в текущий текст сообщения
|
||||
orig = callback.message.text or ""
|
||||
marker = "Перевод вопроса:"
|
||||
marker = t(lang, 'level_test_extra.translation_marker')
|
||||
if marker in orig:
|
||||
await callback.answer("Перевод уже показан")
|
||||
await callback.answer(t(lang, 'level_test_extra.translation_already'))
|
||||
return
|
||||
|
||||
new_text = f"{orig}\n{marker} <i>{ru}</i>"
|
||||
try:
|
||||
await callback.message.edit_text(new_text, reply_markup=callback.message.reply_markup)
|
||||
except Exception:
|
||||
# Запасной путь, если редактирование невозможно
|
||||
await callback.message.answer(f"{marker} <i>{ru}</i>")
|
||||
await callback.answer()
|
||||
|
||||
@@ -171,6 +178,10 @@ async def show_question_translation(callback: CallbackQuery, state: FSMContext):
|
||||
@router.callback_query(F.data.startswith("answer_"), LevelTestStates.taking_test)
|
||||
async def process_answer(callback: CallbackQuery, state: FSMContext):
|
||||
"""Обработать ответ на вопрос"""
|
||||
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)
|
||||
|
||||
answer_idx = int(callback.data.split("_")[1])
|
||||
|
||||
data = await state.get_data()
|
||||
@@ -194,14 +205,14 @@ async def process_answer(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
# Показываем результат
|
||||
if is_correct:
|
||||
result_text = "✅ Правильно!"
|
||||
result_text = t(lang, 'level_test_extra.correct')
|
||||
else:
|
||||
correct_option = question['options'][question['correct']]
|
||||
result_text = f"❌ Неправильно\nПравильный ответ: <b>{correct_option}</b>"
|
||||
result_text = t(lang, 'level_test_extra.incorrect') + "\n" + t(lang, 'level_test_extra.correct_answer', answer=correct_option)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"❓ <b>Вопрос {current_idx + 1} из {len(questions)}</b>\n\n"
|
||||
f"{result_text}"
|
||||
t(lang, 'level_test.q_header', i=current_idx + 1, n=len(questions)) + "\n\n" +
|
||||
result_text
|
||||
)
|
||||
|
||||
# Переходим к следующему вопросу
|
||||
@@ -225,65 +236,61 @@ async def finish_test(message: Message, state: FSMContext):
|
||||
questions = data.get('questions', [])
|
||||
correct_answers = data.get('correct_answers', 0)
|
||||
answers = data.get('answers', [])
|
||||
learning_lang = data.get('learning_language', 'en')
|
||||
|
||||
total = len(questions)
|
||||
accuracy = int((correct_answers / total) * 100) if total > 0 else 0
|
||||
|
||||
# Определяем уровень на основе правильных ответов по уровням
|
||||
level = determine_level(answers)
|
||||
level = determine_level(answers, learning_lang)
|
||||
|
||||
# Сохраняем уровень в базе данных
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, message.chat.id)
|
||||
if user:
|
||||
user.level = level
|
||||
await session.commit()
|
||||
await UserService.update_user_level(session, user.id, level, learning_lang)
|
||||
|
||||
# Описания уровней
|
||||
level_descriptions = {
|
||||
"A1": "Начальный - понимаешь основные фразы и можешь представиться",
|
||||
"A2": "Элементарный - можешь общаться на простые темы",
|
||||
"B1": "Средний - можешь поддержать беседу на знакомые темы",
|
||||
"B2": "Выше среднего - свободно общаешься в большинстве ситуаций",
|
||||
"C1": "Продвинутый - используешь язык гибко и эффективно",
|
||||
"C2": "Профессиональный - владеешь языком на уровне носителя"
|
||||
}
|
||||
lang = get_user_lang(user)
|
||||
level_desc = t(lang, f'level_test_extra.level_desc.{level}')
|
||||
|
||||
await state.clear()
|
||||
|
||||
result_text = (
|
||||
f"🎉 <b>Тест завершён!</b>\n\n"
|
||||
f"📊 Результаты:\n"
|
||||
f"Правильных ответов: <b>{correct_answers}</b> из {total}\n"
|
||||
f"Точность: <b>{accuracy}%</b>\n\n"
|
||||
f"🎯 Твой уровень: <b>{level.value}</b>\n"
|
||||
f"<i>{level_descriptions.get(level.value, '')}</i>\n\n"
|
||||
f"Теперь задания и материалы будут подбираться под твой уровень!\n"
|
||||
f"Ты можешь изменить уровень в любое время через /settings"
|
||||
t(lang, 'level_test_extra.result_title') +
|
||||
t(lang, 'level_test_extra.results_header') +
|
||||
t(lang, 'level_test_extra.correct_count', correct=correct_answers, total=total) +
|
||||
t(lang, 'level_test_extra.accuracy', accuracy=accuracy) +
|
||||
t(lang, 'level_test_extra.your_level', level=level) +
|
||||
f"<i>{level_desc}</i>\n\n" +
|
||||
t(lang, 'level_test_extra.level_set_hint')
|
||||
)
|
||||
|
||||
await message.answer(result_text)
|
||||
|
||||
|
||||
def determine_level(answers: list) -> LanguageLevel:
|
||||
def determine_level(answers: list, learning_language: str = "en") -> str:
|
||||
"""
|
||||
Определить уровень на основе ответов
|
||||
|
||||
Args:
|
||||
answers: Список ответов с уровнями
|
||||
learning_language: Язык изучения для выбора системы уровней
|
||||
|
||||
Returns:
|
||||
Определённый уровень
|
||||
Определённый уровень (строка: A1-C2 или N5-N1)
|
||||
"""
|
||||
# Выбираем систему уровней
|
||||
level_system = get_level_system(learning_language)
|
||||
|
||||
if level_system == "jlpt":
|
||||
levels_order = JLPT_LEVELS # ["N5", "N4", "N3", "N2", "N1"]
|
||||
default_level = "N5"
|
||||
else:
|
||||
levels_order = CEFR_LEVELS # ["A1", "A2", "B1", "B2", "C1", "C2"]
|
||||
default_level = "A1"
|
||||
|
||||
# Подсчитываем правильные ответы по уровням
|
||||
level_stats = {
|
||||
'A1': {'correct': 0, 'total': 0},
|
||||
'A2': {'correct': 0, 'total': 0},
|
||||
'B1': {'correct': 0, 'total': 0},
|
||||
'B2': {'correct': 0, 'total': 0},
|
||||
'C1': {'correct': 0, 'total': 0},
|
||||
'C2': {'correct': 0, 'total': 0}
|
||||
}
|
||||
level_stats = {level: {'correct': 0, 'total': 0} for level in levels_order}
|
||||
|
||||
for answer in answers:
|
||||
level = answer['level']
|
||||
@@ -293,8 +300,7 @@ def determine_level(answers: list) -> LanguageLevel:
|
||||
level_stats[level]['correct'] += 1
|
||||
|
||||
# Определяем уровень: ищем последний уровень, где правильно >= 50%
|
||||
levels_order = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']
|
||||
determined_level = 'A1'
|
||||
determined_level = default_level
|
||||
|
||||
for level in levels_order:
|
||||
if level_stats[level]['total'] > 0:
|
||||
@@ -305,4 +311,4 @@ def determine_level(answers: list) -> LanguageLevel:
|
||||
# Если не прошёл этот уровень, останавливаемся
|
||||
break
|
||||
|
||||
return LanguageLevel[determined_level]
|
||||
return determined_level
|
||||
|
||||
Reference in New Issue
Block a user