From 99deaafcbf77a2f2ba9cff93baeba91c602d4153 Mon Sep 17 00:00:00 2001 From: "mamonov.ep" Date: Fri, 5 Dec 2025 14:30:24 +0300 Subject: [PATCH] feat: JLPT levels for Japanese, custom practice scenarios, UI improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- bot/handlers/import_text.py | 21 +- bot/handlers/level_test.py | 114 ++++---- bot/handlers/practice.py | 261 ++++++++++++++++-- bot/handlers/settings.py | 224 ++++++--------- bot/handlers/start.py | 6 +- bot/handlers/tasks.py | 2 +- bot/handlers/vocabulary.py | 8 +- bot/handlers/words.py | 6 +- database/models.py | 20 +- locales/en.json | 91 +++++- locales/ja.json | 91 +++++- locales/ru.json | 91 +++++- .../20251205_add_levels_by_language.py | 27 ++ services/ai_service.py | 212 +++++++++----- services/user_service.py | 14 +- utils/i18n.py | 5 + utils/levels.py | 98 +++++++ 17 files changed, 983 insertions(+), 308 deletions(-) create mode 100644 migrations/versions/20251205_add_levels_by_language.py create mode 100644 utils/levels.py diff --git a/bot/handlers/import_text.py b/bot/handlers/import_text.py index e146dc3..8e80ddc 100644 --- a/bot/handlers/import_text.py +++ b/bot/handlers/import_text.py @@ -9,7 +9,8 @@ from database.models import WordSource from services.user_service import UserService from services.vocabulary_service import VocabularyService 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() @@ -44,7 +45,10 @@ async def cmd_import(message: Message, state: FSMContext): async def cancel_import(message: Message, state: FSMContext): """Отмена импорта""" await state.clear() - await message.answer("❌ Импорт отменён.") + 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, 'import_extra.cancelled')) @router.message(ImportStates.waiting_for_text) @@ -52,12 +56,16 @@ async def process_text(message: Message, state: FSMContext): """Обработка текста от пользователя""" text = message.text.strip() + 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 len(text) < 50: - await message.answer(t('ru', 'import.too_short')) + await message.answer(t(lang, 'import.too_short')) return if len(text) > 3000: - await message.answer(t('ru', 'import.too_long')) + await message.answer(t(lang, 'import.too_long')) return async with async_session_maker() as session: @@ -67,9 +75,10 @@ async def process_text(message: Message, state: FSMContext): processing_msg = await message.answer(t(user.language_interface or 'ru', 'import.processing')) # Извлекаем слова через AI + current_level = get_user_level_for_language(user) words = await ai_service.extract_words_from_text( text=text, - level=user.level.value, + level=current_level, max_words=15, learning_lang=user.learning_language, translation_lang=user.language_interface, @@ -87,7 +96,7 @@ async def process_text(message: Message, state: FSMContext): words=words, user_id=user.id, original_text=text, - level=user.level.name + level=current_level ) await state.set_state(ImportStates.viewing_words) diff --git a/bot/handlers/level_test.py b/bot/handlers/level_test.py index 3f8df83..fd9e77e 100644 --- a/bot/handlers/level_test.py +++ b/bot/handlers/level_test.py @@ -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} {ru}" try: await callback.message.edit_text(new_text, reply_markup=callback.message.reply_markup) except Exception: - # Запасной путь, если редактирование невозможно await callback.message.answer(f"{marker} {ru}") 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Правильный ответ: {correct_option}" + 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"❓ Вопрос {current_idx + 1} из {len(questions)}\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"🎉 Тест завершён!\n\n" - f"📊 Результаты:\n" - f"Правильных ответов: {correct_answers} из {total}\n" - f"Точность: {accuracy}%\n\n" - f"🎯 Твой уровень: {level.value}\n" - f"{level_descriptions.get(level.value, '')}\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"{level_desc}\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 diff --git a/bot/handlers/practice.py b/bot/handlers/practice.py index 5ae7716..612ef53 100644 --- a/bot/handlers/practice.py +++ b/bot/handlers/practice.py @@ -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"🎭 {custom_scenario}\n\n" + f"📝 {conversation_start.get('context', '')}\n\n" + f"AI: {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"• {learn_text} ({trans_text})\n" + else: + text += f"• {learn_text}\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"{SCENARIOS[scenario].get(ui_lang, SCENARIOS[scenario]['ru'])}\n\n" + f"{get_scenario_name(ui_lang, scenario)}\n\n" f"📝 {conversation_start.get('context', '')}\n\n" f"AI: {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): """Обработка сообщений в диалоге""" diff --git a/bot/handlers/settings.py b/bot/handlers/settings.py index a7ef57c..5411d0f 100644 --- a/bot/handlers/settings.py +++ b/bot/handlers/settings.py @@ -4,52 +4,39 @@ from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKe from aiogram.fsm.context import FSMContext from database.db import async_session_maker -from database.models import LanguageLevel from bot.handlers.start import main_menu_keyboard from services.user_service import UserService +from utils.i18n import t, get_user_lang +from utils.levels import ( + get_user_level_for_language, + get_available_levels, + get_level_system, + get_level_key_for_i18n, +) router = Router() -def _is_en(user) -> bool: - try: - return (getattr(user, 'language_interface', 'ru') or 'ru') == 'en' - except Exception: - return False - - -def _is_ja(user) -> bool: - try: - return (getattr(user, 'language_interface', 'ru') or 'ru') == 'ja' - except Exception: - return False - - def get_settings_keyboard(user) -> InlineKeyboardMarkup: """Создать клавиатуру настроек""" - is_en = _is_en(user) - is_ja = _is_ja(user) + lang = get_user_lang(user) + ui_lang_code = getattr(user, 'language_interface', 'ru') or 'ru' + current_level = get_user_level_for_language(user) keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton( - text=( - "📊 Level: " if is_en else ("📊 レベル: " if is_ja else "📊 Уровень: ") - ) + f"{user.level.value}", + text=t(lang, 'settings.level_prefix') + f"{current_level}", callback_data="settings_level" )], [InlineKeyboardButton( - text=( - "🎯 Learning language: " if is_en else ("🎯 学習言語: " if is_ja else "🎯 Язык изучения: ") - ) + (user.learning_language.upper()), + text=t(lang, 'settings.learning_prefix') + user.learning_language.upper(), callback_data="settings_learning" )], [InlineKeyboardButton( - text=( - "🌐 Interface language: " if is_en else ("🌐 インターフェース言語: " if is_ja else "🌐 Язык интерфейса: ") - ) + ("🇬🇧 English" if getattr(user, 'language_interface', 'ru') == 'en' else ("🇯🇵 日本語" if getattr(user, 'language_interface', 'ru') == 'ja' else "🇷🇺 Русский")), + text=t(lang, 'settings.interface_prefix') + t(lang, f'settings.lang_name.{ui_lang_code}'), callback_data="settings_language" )], [InlineKeyboardButton( - text=("❌ Close" if is_en else ("❌ 閉じる" if is_ja else "❌ Закрыть")), + text=t(lang, 'settings.close'), callback_data="settings_close" )] ]) @@ -57,75 +44,49 @@ def get_settings_keyboard(user) -> InlineKeyboardMarkup: def get_level_keyboard(user=None) -> InlineKeyboardMarkup: - """Клавиатура выбора уровня""" - lang = getattr(user, 'language_interface', 'ru') if user is not None else 'ru' - if lang == 'en': - levels = [ - ("A1 - Beginner", "set_level_A1"), - ("A2 - Elementary", "set_level_A2"), - ("B1 - Intermediate", "set_level_B1"), - ("B2 - Upper-intermediate", "set_level_B2"), - ("C1 - Advanced", "set_level_C1"), - ("C2 - Proficient", "set_level_C2"), - ] - elif lang == 'ja': - levels = [ - ("A1 - 初級", "set_level_A1"), - ("A2 - 初級(上)", "set_level_A2"), - ("B1 - 中級", "set_level_B1"), - ("B2 - 中級(上)", "set_level_B2"), - ("C1 - 上級", "set_level_C1"), - ("C2 - ネイティブ", "set_level_C2"), - ] - else: - levels = [ - ("A1 - Начальный", "set_level_A1"), - ("A2 - Элементарный", "set_level_A2"), - ("B1 - Средний", "set_level_B1"), - ("B2 - Выше среднего", "set_level_B2"), - ("C1 - Продвинутый", "set_level_C1"), - ("C2 - Профессиональный", "set_level_C2"), - ] + """Клавиатура выбора уровня (CEFR или JLPT в зависимости от языка изучения)""" + lang = get_user_lang(user) + learning_lang = getattr(user, 'learning_language', 'en') or 'en' + available_levels = get_available_levels(learning_lang) keyboard = [] - for level_name, callback_data in levels: - keyboard.append([InlineKeyboardButton(text=level_name, callback_data=callback_data)]) + for level in available_levels: + # Ключ локализации: settings.level.a1 или settings.jlpt.n5 + i18n_key = get_level_key_for_i18n(learning_lang, level) + level_name = t(lang, i18n_key) + keyboard.append([InlineKeyboardButton(text=level_name, callback_data=f"set_level_{level}")]) - back_label = "⬅️ Back" if lang == 'en' else ("⬅️ 戻る" if lang == 'ja' else "⬅️ Назад") - keyboard.append([InlineKeyboardButton(text=back_label, callback_data="settings_back")]) + keyboard.append([InlineKeyboardButton(text=t(lang, 'settings.back'), callback_data="settings_back")]) return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_language_keyboard(user=None) -> InlineKeyboardMarkup: """Клавиатура выбора языка интерфейса""" - lang = getattr(user, 'language_interface', 'ru') if user is not None else 'ru' - back = "⬅️ Back" if lang == 'en' else ("⬅️ 戻る" if lang == 'ja' else "⬅️ Назад") + lang = get_user_lang(user) keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🇷🇺 Русский", callback_data="set_lang_ru")], - [InlineKeyboardButton(text="🇬🇧 English", callback_data="set_lang_en")], - [InlineKeyboardButton(text="🇯🇵 日本語", callback_data="set_lang_ja")], - [InlineKeyboardButton(text=back, callback_data="settings_back")] + [InlineKeyboardButton(text=t(lang, 'settings.lang_name.ru'), callback_data="set_lang_ru")], + [InlineKeyboardButton(text=t(lang, 'settings.lang_name.en'), callback_data="set_lang_en")], + [InlineKeyboardButton(text=t(lang, 'settings.lang_name.ja'), callback_data="set_lang_ja")], + [InlineKeyboardButton(text=t(lang, 'settings.back'), callback_data="settings_back")] ]) return keyboard def get_learning_language_keyboard(user=None) -> InlineKeyboardMarkup: """Клавиатура выбора языка изучения""" - lang = getattr(user, 'language_interface', 'ru') if user is not None else 'ru' - back = "⬅️ Back" if lang == 'en' else ("⬅️ 戻る" if lang == 'ja' else "⬅️ Назад") + lang = get_user_lang(user) - # Пары (код -> подпись) options = [ - ("en", "🇬🇧 English" if lang != 'ja' else "🇬🇧 英語"), - ("es", "🇪🇸 Spanish" if lang == 'en' else ("🇪🇸 スペイン語" if lang == 'ja' else "🇪🇸 Испанский")), - ("de", "🇩🇪 German" if lang == 'en' else ("🇩🇪 ドイツ語" if lang == 'ja' else "🇩🇪 Немецкий")), - ("fr", "🇫🇷 French" if lang == 'en' else ("🇫🇷 フランス語" if lang == 'ja' else "🇫🇷 Французский")), - ("ja", "🇯🇵 Japanese" if lang == 'en' else ("🇯🇵 日本語" if lang == 'ja' else "🇯🇵 Японский")), + ("en", t(lang, 'settings.learning_lang.en')), + ("es", t(lang, 'settings.learning_lang.es')), + ("de", t(lang, 'settings.learning_lang.de')), + ("fr", t(lang, 'settings.learning_lang.fr')), + ("ja", t(lang, 'settings.learning_lang.ja')), ] keyboard = [[InlineKeyboardButton(text=label, callback_data=f"set_learning_{code}")] for code, label in options] - keyboard.append([InlineKeyboardButton(text=back, callback_data="settings_back")]) + keyboard.append([InlineKeyboardButton(text=t(lang, 'settings.back'), callback_data="settings_back")]) return InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -136,16 +97,19 @@ async def cmd_settings(message: Message): user = await UserService.get_user_by_telegram_id(session, message.from_user.id) if not user: - await message.answer("Сначала запусти бота командой /start") + await message.answer(t('ru', 'common.start_first')) return - lang = getattr(user, 'language_interface', 'ru') - title = "⚙️ Settings\n\n" if lang == 'en' else ("⚙️ 設定\n\n" if lang == 'ja' else "⚙️ Настройки\n\n") - level_label = "📊 English level: " if lang == 'en' else ("📊 英語レベル: " if lang == 'ja' else "📊 Уровень английского: ") - lang_label = "🌐 Interface language: " if lang == 'en' else ("🌐 インターフェース言語: " if lang == 'ja' else "🌐 Язык интерфейса: ") - lang_value = 'English' if lang == 'en' else ('日本語' if lang == 'ja' else ('Русский' if user.language_interface == 'ru' else 'English')) - footer = "Choose what to change:" if lang == 'en' else ("変更したい項目を選択:" if lang == 'ja' else "Выбери, что хочешь изменить:") - settings_text = f"{title}{level_label}{user.level.value}\n{lang_label}{lang_value}\n\n{footer}" + lang = get_user_lang(user) + ui_lang_code = getattr(user, 'language_interface', 'ru') or 'ru' + lang_value = t(lang, f'settings.lang_name.{ui_lang_code}') + current_level = get_user_level_for_language(user) + settings_text = ( + t(lang, 'settings.title') + + t(lang, 'settings.level_prefix') + f"{current_level}\n" + + t(lang, 'settings.interface_prefix') + f"{lang_value}\n\n" + + t(lang, 'settings.choose') + ) await message.answer(settings_text, reply_markup=get_settings_keyboard(user)) @@ -155,15 +119,13 @@ async def settings_level(callback: CallbackQuery): """Показать выбор уровня""" async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) - lang = getattr(user, 'language_interface', 'ru') if user else 'ru' - title = "📊 Choose your English level:\n\n" if lang == 'en' else ("📊 英語レベルを選択:\n\n" if lang == 'ja' else "📊 Выбери свой уровень английского:\n\n") - body = ( - "A1-A2 - Beginner\nB1-B2 - Intermediate\nC1-C2 - Advanced\n\n" if lang == 'en' else ( - "A1-A2 - 初級\nB1-B2 - 中級\nC1-C2 - 上級\n\n" if lang == 'ja' else - "A1-A2 - Начинающий\nB1-B2 - Средний\nC1-C2 - Продвинутый\n\n" - )) - tail = "This affects difficulty of suggested words and tasks." if lang == 'en' else ("これは提案される単語や課題の難易度に影響します。" if lang == 'ja' else "Это влияет на сложность предлагаемых слов и заданий.") - await callback.message.edit_text(title + body + tail, reply_markup=get_level_keyboard(user)) + lang = get_user_lang(user) + learning_lang = getattr(user, 'learning_language', 'en') or 'en' + level_system = get_level_system(learning_lang) + # Выбираем правильное описание групп уровней + groups_key = 'settings.jlpt_groups' if level_system == 'jlpt' else 'settings.level_groups' + text = t(lang, 'settings.level_title') + t(lang, groups_key) + t(lang, 'settings.level_hint') + await callback.message.edit_text(text, reply_markup=get_level_keyboard(user)) await callback.answer() @@ -172,9 +134,8 @@ async def settings_learning(callback: CallbackQuery): """Показать выбор языка изучения""" async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) - lang = getattr(user, 'language_interface', 'ru') if user else 'ru' - title = "🎯 Select learning language:\n\n" if lang == 'en' else ("🎯 学習言語を選択:\n\n" if lang == 'ja' else "🎯 Выбери язык изучения:\n\n") - await callback.message.edit_text(title, reply_markup=get_learning_language_keyboard(user)) + lang = get_user_lang(user) + await callback.message.edit_text(t(lang, 'settings.learning_title'), reply_markup=get_learning_language_keyboard(user)) await callback.answer() @@ -186,42 +147,32 @@ async def set_learning_language(callback: CallbackQuery): user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) if user: await UserService.update_user_learning_language(session, user.id, code) - lang = getattr(user, 'language_interface', 'ru') - if lang == 'en': - text = f"✅ Learning language: {code.upper()}" - back = "⬅️ Back to settings" - elif lang == 'ja': - text = f"✅ 学習言語: {code.upper()}" - back = "⬅️ 設定に戻る" - else: - text = f"✅ Язык изучения: {code.upper()}" - back = "⬅️ К настройкам" + lang = get_user_lang(user) + text = t(lang, 'settings.learning_changed', code=code.upper()) await callback.message.edit_text( text, - reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=back, callback_data="settings_back")]]) + reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=t(lang, 'settings.back_to_settings'), callback_data="settings_back")]]) ) await callback.answer() @router.callback_query(F.data.startswith("set_level_")) async def set_level(callback: CallbackQuery): - """Установить уровень""" - level_str = callback.data.split("_")[-1] # A1, A2, B1, B2, C1, C2 + """Установить уровень (CEFR или JLPT)""" + level_str = callback.data.split("_")[-1] # A1, A2, B1, B2, C1, C2 или N5, N4, N3, N2, N1 async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) if user: - # Обновляем уровень - await UserService.update_user_level(session, user.id, LanguageLevel[level_str]) + # Передаём строковый уровень, UserService сам разберётся с системой + await UserService.update_user_level(session, user.id, level_str) - lang = getattr(user, 'language_interface', 'ru') - changed = "✅ Level changed to " if lang == 'en' else ("✅ レベルが変更されました: " if lang == 'ja' else "✅ Уровень изменен на ") - msg = changed + f"{level_str}\n\n" + ("You will now receive words and tasks matching your level!" if lang == 'en' else ("これからレベルに合った単語と課題が出題されます!" if lang == 'ja' else "Теперь ты будешь получать слова и задания, соответствующие твоему уровню!")) - back = "⬅️ Back to settings" if lang == 'en' else ("⬅️ 設定に戻る" if lang == 'ja' else "⬅️ К настройкам") + lang = get_user_lang(user) + msg = t(lang, 'settings.level_changed', level=level_str) + t(lang, 'settings.level_changed_hint') await callback.message.edit_text( msg, - reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=back, callback_data="settings_back")]]) + reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=t(lang, 'settings.back_to_settings'), callback_data="settings_back")]]) ) await callback.answer() @@ -232,11 +183,9 @@ async def settings_language(callback: CallbackQuery): """Показать выбор языка""" async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) - lang = getattr(user, 'language_interface', 'ru') if user else 'ru' - title = "🌐 Select interface language:\n\n" if lang == 'en' else ("🌐 インターフェース言語を選択:\n\n" if lang == 'ja' else "🌐 Выбери язык интерфейса:\n\n") - desc = "This will change the language of bot messages." if lang == 'en' else ("ボットの表示言語が変更されます。" if lang == 'ja' else "Это изменит язык всех сообщений бота.") + lang = get_user_lang(user) await callback.message.edit_text( - title + desc, + t(lang, 'settings.lang_title') + t(lang, 'settings.lang_desc'), reply_markup=get_language_keyboard(user) ) await callback.answer() @@ -245,29 +194,21 @@ async def settings_language(callback: CallbackQuery): @router.callback_query(F.data.startswith("set_lang_")) async def set_language(callback: CallbackQuery): """Установить язык""" - lang = callback.data.split("_")[-1] # ru | en | ja + new_lang = callback.data.split("_")[-1] # ru | en | ja async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) if user: - await UserService.update_user_language(session, user.id, lang) - if lang == 'en': - text = "✅ Interface language: English" - back = "⬅️ Back" - elif lang == 'ja': - text = "✅ インターフェース言語: 日本語" - back = "⬅️ 戻る" - else: - text = "✅ Язык интерфейса: Русский" - back = "⬅️ К настройкам" + await UserService.update_user_language(session, user.id, new_lang) + # Используем новый язык для сообщений + text = t(new_lang, 'settings.lang_changed') await callback.message.edit_text( text, - reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=back, callback_data="settings_back")]]) + reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=t(new_lang, 'settings.back'), callback_data="settings_back")]]) ) # Обновляем клавиатуру чата на выбранный язык - menu_updated = "Main menu updated ⤵️" if lang == 'en' else ("メインメニューを更新しました ⤵️" if lang == 'ja' else "Клавиатура обновлена ⤵️") - await callback.message.answer(menu_updated, reply_markup=main_menu_keyboard(lang)) + await callback.message.answer(t(new_lang, 'settings.menu_updated'), reply_markup=main_menu_keyboard(new_lang)) await callback.answer() @@ -279,13 +220,16 @@ async def settings_back(callback: CallbackQuery): user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) if user: - lang = getattr(user, 'language_interface', 'ru') - title = "⚙️ Settings\n\n" if lang == 'en' else ("⚙️ 設定\n\n" if lang == 'ja' else "⚙️ Настройки\n\n") - level_label = "📊 English level: " if lang == 'en' else ("📊 英語レベル: " if lang == 'ja' else "📊 Уровень английского: ") - lang_label = "🌐 Interface language: " if lang == 'en' else ("🌐 インターフェース言語: " if lang == 'ja' else "🌐 Язык интерфейса: ") - lang_value = 'English' if user.language_interface == 'en' else ('日本語' if user.language_interface == 'ja' else 'Русский') - footer = "Choose what to change:" if lang == 'en' else ("変更したい項目を選択:" if lang == 'ja' else "Выбери, что хочешь изменить:") - settings_text = f"{title}{level_label}{user.level.value}\n{lang_label}{lang_value}\n\n{footer}" + lang = get_user_lang(user) + ui_lang_code = getattr(user, 'language_interface', 'ru') or 'ru' + lang_value = t(lang, f'settings.lang_name.{ui_lang_code}') + current_level = get_user_level_for_language(user) + settings_text = ( + t(lang, 'settings.title') + + t(lang, 'settings.level_prefix') + f"{current_level}\n" + + t(lang, 'settings.interface_prefix') + f"{lang_value}\n\n" + + t(lang, 'settings.choose') + ) await callback.message.edit_text(settings_text, reply_markup=get_settings_keyboard(user)) diff --git a/bot/handlers/start.py b/bot/handlers/start.py index a72f9b8..3519c3d 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -13,6 +13,7 @@ from aiogram.fsm.context import FSMContext from database.db import async_session_maker from services.user_service import UserService from utils.i18n import t +from utils.levels import get_user_level_for_language router = Router() @@ -185,10 +186,11 @@ async def pick_theme_from_menu(callback: CallbackQuery, state: FSMContext): return lang = (user.language_interface or 'ru') + current_level = get_user_level_for_language(user) generating = await callback.message.answer(t(lang, 'words.generating', theme=theme)) words = await ai_service.generate_thematic_words( theme=theme, - level=user.level.value, + level=current_level, count=10, learning_lang=user.learning_language, translation_lang=user.language_interface, @@ -200,7 +202,7 @@ async def pick_theme_from_menu(callback: CallbackQuery, state: FSMContext): return # Сохраняем в состояние как в /words - await state.update_data(theme=theme, words=words, user_id=user.id, level=user.level.name) + await state.update_data(theme=theme, words=words, user_id=user.id, level=current_level) await state.set_state(WordsStates.viewing_words) await show_words_list(callback.message, words, theme) diff --git a/bot/handlers/tasks.py b/bot/handlers/tasks.py index 0626726..6fb5c3e 100644 --- a/bot/handlers/tasks.py +++ b/bot/handlers/tasks.py @@ -37,7 +37,7 @@ async def cmd_task(message: Message, state: FSMContext): ) if not tasks: - await message.answer(t(user.level.value and (user.language_interface or 'ru') or 'ru', 'tasks.no_words')) + await message.answer(t(user.language_interface or 'ru', 'tasks.no_words')) return # Сохраняем задания в состоянии diff --git a/bot/handlers/vocabulary.py b/bot/handlers/vocabulary.py index 2ef6072..8f18cec 100644 --- a/bot/handlers/vocabulary.py +++ b/bot/handlers/vocabulary.py @@ -9,7 +9,7 @@ from database.models import WordSource from services.user_service import UserService from services.vocabulary_service import VocabularyService from services.ai_service import ai_service -from utils.i18n import t +from utils.i18n import t, get_user_lang router = Router() @@ -58,10 +58,8 @@ async def process_word_addition(message: Message, state: FSMContext, word: str): # Проверяем, есть ли уже такое слово existing_word = await VocabularyService.find_word(session, user.id, word) if existing_word: - await message.answer( - f"Слово '{word}' уже есть в твоём словаре!\n" - f"Перевод: {existing_word.word_translation}" - ) + lang = get_user_lang(user) + await message.answer(t(lang, 'add.exists', word=word, translation=existing_word.word_translation)) await state.clear() return diff --git a/bot/handlers/words.py b/bot/handlers/words.py index 8fa6ad0..dd42b49 100644 --- a/bot/handlers/words.py +++ b/bot/handlers/words.py @@ -10,6 +10,7 @@ from services.user_service import UserService from services.vocabulary_service import VocabularyService from services.ai_service import ai_service from utils.i18n import t +from utils.levels import get_user_level_for_language router = Router() @@ -50,9 +51,10 @@ async def cmd_words(message: Message, state: FSMContext): generating_msg = await message.answer(t(lang, 'words.generating', theme=theme)) # Генерируем слова через AI + current_level = get_user_level_for_language(user) words = await ai_service.generate_thematic_words( theme=theme, - level=user.level.value, + level=current_level, count=10, learning_lang=user.learning_language, translation_lang=user.language_interface, @@ -69,7 +71,7 @@ async def cmd_words(message: Message, state: FSMContext): theme=theme, words=words, user_id=user.id, - level=user.level.name + level=current_level ) await state.set_state(WordsStates.viewing_words) diff --git a/database/models.py b/database/models.py index a5d19f7..4485b86 100644 --- a/database/models.py +++ b/database/models.py @@ -12,7 +12,7 @@ class Base(DeclarativeBase): class LanguageLevel(str, enum.Enum): - """Уровни владения языком""" + """Уровни владения языком (CEFR)""" A1 = "A1" A2 = "A2" B1 = "B1" @@ -21,6 +21,23 @@ class LanguageLevel(str, enum.Enum): C2 = "C2" +class JLPTLevel(str, enum.Enum): + """Уровни JLPT для японского языка""" + N5 = "N5" # Базовый + N4 = "N4" # Начальный + N3 = "N3" # Средний + N2 = "N2" # Продвинутый + N1 = "N1" # Свободный + + +# Языки, использующие JLPT вместо CEFR +JLPT_LANGUAGES = {"ja"} + +# Дефолтные уровни для разных систем +DEFAULT_CEFR_LEVEL = "A1" +DEFAULT_JLPT_LEVEL = "N5" + + class WordSource(str, enum.Enum): """Источник добавления слова""" MANUAL = "manual" # Ручное добавление @@ -40,6 +57,7 @@ class User(Base): language_interface: Mapped[str] = mapped_column(String(2), default="ru") # ru/en learning_language: Mapped[str] = mapped_column(String(2), default="en") # en level: Mapped[LanguageLevel] = mapped_column(SQLEnum(LanguageLevel), default=LanguageLevel.A1) + levels_by_language: Mapped[Optional[dict]] = mapped_column(JSON, default=None) # {"en": "B1", "ja": "N4"} timezone: Mapped[str] = mapped_column(String(50), default="UTC") daily_task_time: Mapped[Optional[str]] = mapped_column(String(5)) # HH:MM reminders_enabled: Mapped[bool] = mapped_column(Boolean, default=False) diff --git a/locales/en.json b/locales/en.json index 40621fb..9829480 100644 --- a/locales/en.json +++ b/locales/en.json @@ -81,7 +81,15 @@ "end_keep": "Great job! Keep practicing.", "end_hint": "Use /practice to start a new dialogue.", "translation_unavailable": "Translation unavailable", - "translation_already": "Translation already shown" + "translation_already": "Translation already shown", + "custom_scenario_btn": "✏️ Custom scenario", + "custom_scenario_prompt": "✏️ Describe your scenario\n\nWrite a topic or situation for the conversation.\n\nExamples:\n• Job interview for a programmer position\n• Ordering pizza by phone\n• Discussing a movie with a friend\n• Planning a trip to Japan", + "custom_scenario_too_short": "⚠️ Description too short. Write at least a few words about the scenario.", + "new_practice_btn": "🔄 New dialogue", + "to_tasks_btn": "🧠 Tasks", + "to_words_btn": "🎯 Words", + "go_tasks_hint": "Use /task to practice words", + "go_words_hint": "Use /words [topic] for word sets" }, "tasks": { "no_words": "📚 You don't have words to practice yet!\n\nAdd some words with /add and come back.", @@ -159,6 +167,87 @@ "cancelled": "❌ Test cancelled", "q_header": "❓ Question {i} of {n}" }, + "settings": { + "title": "⚙️ Settings\n\n", + "level_prefix": "📊 Level: ", + "learning_prefix": "🎯 Learning language: ", + "interface_prefix": "🌐 Interface language: ", + "choose": "Choose what to change:", + "close": "❌ Close", + "back": "⬅️ Back", + "back_to_settings": "⬅️ Back to settings", + "level_title": "📊 Choose your level:\n\n", + "level_groups": "A1-A2 - Beginner\nB1-B2 - Intermediate\nC1-C2 - Advanced\n\n", + "level_hint": "This affects difficulty of suggested words and tasks.", + "level": { + "a1": "A1 - Beginner", + "a2": "A2 - Elementary", + "b1": "B1 - Intermediate", + "b2": "B2 - Upper-intermediate", + "c1": "C1 - Advanced", + "c2": "C2 - Proficient" + }, + "jlpt": { + "n5": "N5 - Basic", + "n4": "N4 - Elementary", + "n3": "N3 - Intermediate", + "n2": "N2 - Advanced", + "n1": "N1 - Fluent" + }, + "jlpt_groups": "N5-N4 - Beginner\nN3 - Intermediate\nN2-N1 - Advanced\n\n", + "level_changed": "✅ Level changed to {level}\n\n", + "level_changed_hint": "You will now receive words and tasks matching your level!", + "lang_title": "🌐 Select interface language:\n\n", + "lang_desc": "This will change the language of bot messages.", + "lang_changed": "✅ Interface language: English", + "learning_title": "🎯 Select learning language:\n\n", + "learning_changed": "✅ Learning language: {code}", + "menu_updated": "Main menu updated ⤵️", + "lang_name": { + "ru": "🇷🇺 Русский", + "en": "🇬🇧 English", + "ja": "🇯🇵 日本語" + }, + "learning_lang": { + "en": "🇬🇧 English", + "es": "🇪🇸 Spanish", + "de": "🇩🇪 German", + "fr": "🇫🇷 French", + "ja": "🇯🇵 Japanese" + } + }, + "import_extra": { + "cancelled": "❌ Import cancelled." + }, + "level_test_extra": { + "generating": "🔄 Generating questions...", + "generate_failed": "❌ Failed to generate test. Try later or use /settings to set level manually.", + "translation_unavailable": "Translation unavailable", + "translation_marker": "Question translation:", + "translation_already": "Translation already shown", + "correct": "✅ Correct!", + "incorrect": "❌ Incorrect", + "correct_answer": "Correct answer: {answer}", + "result_title": "🎉 Test completed!\n\n", + "results_header": "📊 Results:\n", + "correct_count": "Correct answers: {correct} of {total}\n", + "accuracy": "Accuracy: {accuracy}%\n\n", + "your_level": "🎯 Your level: {level}\n", + "level_set_hint": "Tasks and materials will now be tailored to your level!\nYou can change the level anytime via /settings", + "level_desc": { + "A1": "Beginner - understand basic phrases and can introduce yourself", + "A2": "Elementary - can communicate on simple topics", + "B1": "Intermediate - can maintain conversations on familiar topics", + "B2": "Upper-intermediate - fluent in most situations", + "C1": "Advanced - use language flexibly and effectively", + "C2": "Proficient - mastery at native level", + "N5": "Basic - understand hiragana, katakana and basic kanji", + "N4": "Elementary - understand everyday conversations", + "N3": "Intermediate - understand common texts and conversations", + "N2": "Advanced - understand most content", + "N1": "Fluent - full proficiency in Japanese" + } + }, "words": { "generating": "🔄 Generating words for topic '{theme}'...", "generate_failed": "❌ Failed to generate words. Please try again later.", diff --git a/locales/ja.json b/locales/ja.json index 2b50b29..6402fe1 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -73,7 +73,15 @@ "end_keep": "素晴らしい!練習を続けましょう。", "end_hint": "/practice で新しい会話を始められます。", "translation_unavailable": "翻訳は利用できません", - "translation_already": "翻訳はすでに表示されています" + "translation_already": "翻訳はすでに表示されています", + "custom_scenario_btn": "✏️ カスタムシナリオ", + "custom_scenario_prompt": "✏️ シナリオを入力してください\n\n会話のトピックや状況を書いてください。\n\n例:\n• プログラマーの就職面接\n• 電話でピザを注文\n• 友達と映画について話す\n• 日本旅行の計画", + "custom_scenario_too_short": "⚠️ 説明が短すぎます。シナリオについてもう少し詳しく書いてください。", + "new_practice_btn": "🔄 新しい会話", + "to_tasks_btn": "🧠 課題", + "to_words_btn": "🎯 単語", + "go_tasks_hint": "/task で単語を練習できます", + "go_words_hint": "/words [テーマ] で単語セットを取得できます" }, "tasks": { "no_words": "📚 まだ練習用の単語がありません!\n\n/add で単語を追加してから戻ってきてください。", @@ -151,6 +159,87 @@ "cancelled": "❌ テストを中止しました", "q_header": "❓ {n}問中 {i} 問目" }, + "settings": { + "title": "⚙️ 設定\n\n", + "level_prefix": "📊 レベル: ", + "learning_prefix": "🎯 学習言語: ", + "interface_prefix": "🌐 インターフェース言語: ", + "choose": "変更したい項目を選択:", + "close": "❌ 閉じる", + "back": "⬅️ 戻る", + "back_to_settings": "⬅️ 設定に戻る", + "level_title": "📊 レベルを選択:\n\n", + "level_groups": "A1-A2 - 初級\nB1-B2 - 中級\nC1-C2 - 上級\n\n", + "level_hint": "これは提案される単語や課題の難易度に影響します。", + "level": { + "a1": "A1 - 初級", + "a2": "A2 - 初級(上)", + "b1": "B1 - 中級", + "b2": "B2 - 中級(上)", + "c1": "C1 - 上級", + "c2": "C2 - ネイティブ" + }, + "jlpt": { + "n5": "N5 - 基礎", + "n4": "N4 - 初級", + "n3": "N3 - 中級", + "n2": "N2 - 上級", + "n1": "N1 - 流暢" + }, + "jlpt_groups": "N5-N4 - 初級\nN3 - 中級\nN2-N1 - 上級\n\n", + "level_changed": "✅ レベルが変更されました: {level}\n\n", + "level_changed_hint": "これからレベルに合った単語と課題が出題されます!", + "lang_title": "🌐 インターフェース言語を選択:\n\n", + "lang_desc": "ボットの表示言語が変更されます。", + "lang_changed": "✅ インターフェース言語: 日本語", + "learning_title": "🎯 学習言語を選択:\n\n", + "learning_changed": "✅ 学習言語: {code}", + "menu_updated": "メインメニューを更新しました ⤵️", + "lang_name": { + "ru": "🇷🇺 Русский", + "en": "🇬🇧 English", + "ja": "🇯🇵 日本語" + }, + "learning_lang": { + "en": "🇬🇧 英語", + "es": "🇪🇸 スペイン語", + "de": "🇩🇪 ドイツ語", + "fr": "🇫🇷 フランス語", + "ja": "🇯🇵 日本語" + } + }, + "import_extra": { + "cancelled": "❌ インポートを中止しました。" + }, + "level_test_extra": { + "generating": "🔄 質問を生成しています...", + "generate_failed": "❌ テストの生成に失敗しました。後でもう一度試すか、/settings でレベルを手動設定してください。", + "translation_unavailable": "翻訳は利用できません", + "translation_marker": "質問の翻訳:", + "translation_already": "翻訳はすでに表示されています", + "correct": "✅ 正解!", + "incorrect": "❌ 不正解", + "correct_answer": "正解: {answer}", + "result_title": "🎉 テスト完了!\n\n", + "results_header": "📊 結果:\n", + "correct_count": "正解数: {correct} / {total}\n", + "accuracy": "正答率: {accuracy}%\n\n", + "your_level": "🎯 あなたのレベル: {level}\n", + "level_set_hint": "これから課題や教材があなたのレベルに合わせて出題されます!\n/settings でいつでもレベルを変更できます", + "level_desc": { + "A1": "初級 - 基本的なフレーズを理解し、自己紹介ができる", + "A2": "初級(上) - 簡単なトピックでコミュニケーションできる", + "B1": "中級 - 慣れた話題で会話を続けられる", + "B2": "中級(上) - ほとんどの状況で流暢に話せる", + "C1": "上級 - 言語を柔軟かつ効果的に使える", + "C2": "ネイティブ - ネイティブレベルの言語力", + "N5": "基礎 - ひらがな、カタカナ、基本漢字を理解できる", + "N4": "初級 - 日常会話を理解できる", + "N3": "中級 - 一般的な文章や会話を理解できる", + "N2": "上級 - ほとんどのコンテンツを理解できる", + "N1": "流暢 - 日本語を完全に習得している" + } + }, "words": { "generating": "🔄 テーマ『{theme}』の単語を生成中...", "generate_failed": "❌ 単語の生成に失敗しました。後でもう一度お試しください。", diff --git a/locales/ru.json b/locales/ru.json index a7ddd3c..ba034f2 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -81,7 +81,15 @@ "end_keep": "Отличная работа! Продолжай практиковаться.", "end_hint": "Используй /practice для нового диалога.", "translation_unavailable": "Перевод недоступен", - "translation_already": "Перевод уже показан" + "translation_already": "Перевод уже показан", + "custom_scenario_btn": "✏️ Свой сценарий", + "custom_scenario_prompt": "✏️ Опиши свой сценарий\n\nНапиши тему или ситуацию для разговора.\n\nПримеры:\n• Собеседование на работу программистом\n• Заказ пиццы по телефону\n• Обсуждение фильма с другом\n• Планирование путешествия в Японию", + "custom_scenario_too_short": "⚠️ Слишком короткое описание. Напиши хотя бы несколько слов о сценарии.", + "new_practice_btn": "🔄 Новый диалог", + "to_tasks_btn": "🧠 Задания", + "to_words_btn": "🎯 Слова", + "go_tasks_hint": "Используй /task для тренировки слов", + "go_words_hint": "Используй /words [тема] для подборки слов" }, "tasks": { "no_words": "📚 У тебя пока нет слов для практики!\n\nДобавь несколько слов командой /add, а затем возвращайся.", @@ -159,6 +167,87 @@ "cancelled": "❌ Тест отменён", "q_header": "❓ Вопрос {i} из {n}" }, + "settings": { + "title": "⚙️ Настройки\n\n", + "level_prefix": "📊 Уровень: ", + "learning_prefix": "🎯 Язык изучения: ", + "interface_prefix": "🌐 Язык интерфейса: ", + "choose": "Выбери, что хочешь изменить:", + "close": "❌ Закрыть", + "back": "⬅️ Назад", + "back_to_settings": "⬅️ К настройкам", + "level_title": "📊 Выбери свой уровень:\n\n", + "level_groups": "A1-A2 - Начинающий\nB1-B2 - Средний\nC1-C2 - Продвинутый\n\n", + "level_hint": "Это влияет на сложность предлагаемых слов и заданий.", + "level": { + "a1": "A1 - Начальный", + "a2": "A2 - Элементарный", + "b1": "B1 - Средний", + "b2": "B2 - Выше среднего", + "c1": "C1 - Продвинутый", + "c2": "C2 - Профессиональный" + }, + "jlpt": { + "n5": "N5 - Базовый", + "n4": "N4 - Начальный", + "n3": "N3 - Средний", + "n2": "N2 - Продвинутый", + "n1": "N1 - Свободный" + }, + "jlpt_groups": "N5-N4 - Начинающий\nN3 - Средний\nN2-N1 - Продвинутый\n\n", + "level_changed": "✅ Уровень изменен на {level}\n\n", + "level_changed_hint": "Теперь ты будешь получать слова и задания, соответствующие твоему уровню!", + "lang_title": "🌐 Выбери язык интерфейса:\n\n", + "lang_desc": "Это изменит язык всех сообщений бота.", + "lang_changed": "✅ Язык интерфейса: Русский", + "learning_title": "🎯 Выбери язык изучения:\n\n", + "learning_changed": "✅ Язык изучения: {code}", + "menu_updated": "Клавиатура обновлена ⤵️", + "lang_name": { + "ru": "🇷🇺 Русский", + "en": "🇬🇧 English", + "ja": "🇯🇵 日本語" + }, + "learning_lang": { + "en": "🇬🇧 Английский", + "es": "🇪🇸 Испанский", + "de": "🇩🇪 Немецкий", + "fr": "🇫🇷 Французский", + "ja": "🇯🇵 Японский" + } + }, + "import_extra": { + "cancelled": "❌ Импорт отменён." + }, + "level_test_extra": { + "generating": "🔄 Генерирую вопросы...", + "generate_failed": "❌ Не удалось сгенерировать тест. Попробуй позже или используй /settings для ручной установки уровня.", + "translation_unavailable": "Перевод недоступен", + "translation_marker": "Перевод вопроса:", + "translation_already": "Перевод уже показан", + "correct": "✅ Правильно!", + "incorrect": "❌ Неправильно", + "correct_answer": "Правильный ответ: {answer}", + "result_title": "🎉 Тест завершён!\n\n", + "results_header": "📊 Результаты:\n", + "correct_count": "Правильных ответов: {correct} из {total}\n", + "accuracy": "Точность: {accuracy}%\n\n", + "your_level": "🎯 Твой уровень: {level}\n", + "level_set_hint": "Теперь задания и материалы будут подбираться под твой уровень!\nТы можешь изменить уровень в любое время через /settings", + "level_desc": { + "A1": "Начальный - понимаешь основные фразы и можешь представиться", + "A2": "Элементарный - можешь общаться на простые темы", + "B1": "Средний - можешь поддержать беседу на знакомые темы", + "B2": "Выше среднего - свободно общаешься в большинстве ситуаций", + "C1": "Продвинутый - используешь язык гибко и эффективно", + "C2": "Профессиональный - владеешь языком на уровне носителя", + "N5": "Базовый - понимаешь хирагану, катакану и базовые кандзи", + "N4": "Начальный - понимаешь повседневные разговоры", + "N3": "Средний - понимаешь обычные тексты и разговоры", + "N2": "Продвинутый - понимаешь большинство контента", + "N1": "Свободный - полное владение японским языком" + } + }, "words": { "generating": "🔄 Генерирую подборку слов по теме '{theme}'...", "generate_failed": "❌ Не удалось сгенерировать подборку. Попробуй позже.", diff --git a/migrations/versions/20251205_add_levels_by_language.py b/migrations/versions/20251205_add_levels_by_language.py new file mode 100644 index 0000000..60a145e --- /dev/null +++ b/migrations/versions/20251205_add_levels_by_language.py @@ -0,0 +1,27 @@ +"""add levels_by_language JSON field to users + +Revision ID: 20251205_levels_by_lang +Revises: 20251204_add_vocab_lang +Create Date: 2025-12-05 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSON + + +# revision identifiers, used by Alembic. +revision = '20251205_levels_by_lang' +down_revision = '20251204_add_vocab_lang' +branch_labels = None +depends_on = None + + +def upgrade(): + # Добавляем JSON поле для хранения уровней по языкам + # Формат: {"en": "B1", "ja": "N4", ...} + op.add_column('users', sa.Column('levels_by_language', JSON, nullable=True)) + + +def downgrade(): + op.drop_column('users', 'levels_by_language') diff --git a/services/ai_service.py b/services/ai_service.py index 1b6f578..6945f1b 100644 --- a/services/ai_service.py +++ b/services/ai_service.py @@ -490,42 +490,62 @@ User: {user_message} "suggestions": ["Sure!", "Well...", "Actually..."] } - async def generate_level_test(self) -> List[Dict]: + async def generate_level_test(self, learning_language: str = "en") -> List[Dict]: """ - Сгенерировать тест для определения уровня английского + Сгенерировать тест для определения уровня языка + + Args: + learning_language: Язык изучения (en, es, de, fr, ja) Returns: Список из 7 вопросов разной сложности """ - prompt = """Создай тест из 7 вопросов для определения уровня английского языка (A1-C2). + # Определяем систему уровней и язык для промпта + if learning_language == "ja": + level_system = "JLPT (N5-N1)" + language_name = "японского" + levels_req = """- Вопросы 1-2: уровень N5 (базовый) +- Вопросы 3-4: уровень N4-N3 (элементарный-средний) +- Вопросы 5-6: уровень N2 (продвинутый) +- Вопрос 7: уровень N1 (профессиональный)""" + level_example = "N5" + else: + level_system = "CEFR (A1-C2)" + lang_names = {"en": "английского", "es": "испанского", "de": "немецкого", "fr": "французского"} + language_name = lang_names.get(learning_language, "английского") + levels_req = """- Вопросы 1-2: уровень A1 (базовый) +- Вопросы 3-4: уровень A2-B1 (элементарный-средний) +- Вопросы 5-6: уровень B2-C1 (продвинутый) +- Вопрос 7: уровень C2 (профессиональный)""" + level_example = "A1" + + prompt = f"""Создай тест из 7 вопросов для определения уровня {language_name} языка ({level_system}). Верни ответ в формате JSON: -{ +{{ "questions": [ - { - "question": "текст вопроса на английском", + {{ + "question": "текст вопроса на изучаемом языке", "question_ru": "перевод вопроса на русский", "options": ["вариант A", "вариант B", "вариант C", "вариант D"], "correct": 0, - "level": "A1" - } + "level": "{level_example}" + }} ] -} +}} Требования: -- Вопросы 1-2: уровень A1 (базовый) -- Вопросы 3-4: уровень A2-B1 (элементарный-средний) -- Вопросы 5-6: уровень B2-C1 (продвинутый) -- Вопрос 7: уровень C2 (профессиональный) +{levels_req} - Каждый вопрос с 4 вариантами ответа - correct - индекс правильного ответа (0-3) - Вопросы на грамматику, лексику и понимание""" try: - logger.info(f"[GPT Request] generate_level_test: generating 7 questions") + logger.info(f"[GPT Request] generate_level_test: generating 7 questions for {learning_language}") + system_msg = f"Ты - эксперт по тестированию уровня {language_name} языка. Создавай объективные тесты." messages = [ - {"role": "system", "content": "Ты - эксперт по тестированию уровня английского языка. Создавай объективные тесты."}, + {"role": "system", "content": system_msg}, {"role": "user", "content": prompt} ] @@ -540,57 +560,117 @@ User: {user_message} except Exception as e: logger.error(f"[GPT Error] generate_level_test: {type(e).__name__}: {str(e)}, using fallback questions") # Fallback с базовыми вопросами - return [ - { - "question": "What is your name?", - "question_ru": "Как тебя зовут?", - "options": ["My name is", "I am name", "Name my is", "Is name my"], - "correct": 0, - "level": "A1" - }, - { - "question": "I ___ to school every day.", - "question_ru": "Я ___ в школу каждый день.", - "options": ["go", "goes", "going", "went"], - "correct": 0, - "level": "A1" - }, - { - "question": "She ___ been to Paris twice.", - "question_ru": "Она ___ в Париже дважды.", - "options": ["have", "has", "had", "having"], - "correct": 1, - "level": "A2" - }, - { - "question": "If I ___ rich, I would travel the world.", - "question_ru": "Если бы я был богат, я бы путешествовал по миру.", - "options": ["am", "was", "were", "be"], - "correct": 2, - "level": "B1" - }, - { - "question": "The project ___ by next Monday.", - "question_ru": "Проект ___ к следующему понедельнику.", - "options": ["will complete", "will be completed", "completes", "is completing"], - "correct": 1, - "level": "B2" - }, - { - "question": "Had I known about the meeting, I ___ attended.", - "question_ru": "Если бы я знал о встрече, я бы посетил.", - "options": ["would have", "will have", "would", "will"], - "correct": 0, - "level": "C1" - }, - { - "question": "The nuances of his argument were so ___ that few could grasp them.", - "question_ru": "Нюансы его аргумента были настолько ___, что немногие могли их понять.", - "options": ["subtle", "obvious", "simple", "clear"], - "correct": 0, - "level": "C2" - } - ] + if learning_language == "ja": + return self._get_jlpt_fallback_questions() + return self._get_cefr_fallback_questions() + + def _get_cefr_fallback_questions(self) -> List[Dict]: + """Fallback вопросы для CEFR (английский и европейские языки)""" + return [ + { + "question": "What is your name?", + "question_ru": "Как тебя зовут?", + "options": ["My name is", "I am name", "Name my is", "Is name my"], + "correct": 0, + "level": "A1" + }, + { + "question": "I ___ to school every day.", + "question_ru": "Я ___ в школу каждый день.", + "options": ["go", "goes", "going", "went"], + "correct": 0, + "level": "A1" + }, + { + "question": "She ___ been to Paris twice.", + "question_ru": "Она ___ в Париже дважды.", + "options": ["have", "has", "had", "having"], + "correct": 1, + "level": "A2" + }, + { + "question": "If I ___ rich, I would travel the world.", + "question_ru": "Если бы я был богат, я бы путешествовал по миру.", + "options": ["am", "was", "were", "be"], + "correct": 2, + "level": "B1" + }, + { + "question": "The project ___ by next Monday.", + "question_ru": "Проект ___ к следующему понедельнику.", + "options": ["will complete", "will be completed", "completes", "is completing"], + "correct": 1, + "level": "B2" + }, + { + "question": "Had I known about the meeting, I ___ attended.", + "question_ru": "Если бы я знал о встрече, я бы посетил.", + "options": ["would have", "will have", "would", "will"], + "correct": 0, + "level": "C1" + }, + { + "question": "The nuances of his argument were so ___ that few could grasp them.", + "question_ru": "Нюансы его аргумента были настолько ___, что немногие могли их понять.", + "options": ["subtle", "obvious", "simple", "clear"], + "correct": 0, + "level": "C2" + } + ] + + def _get_jlpt_fallback_questions(self) -> List[Dict]: + """Fallback вопросы для JLPT (японский)""" + return [ + { + "question": "これは ___です。", + "question_ru": "Это ___.", + "options": ["ほん", "本ん", "ぼん", "もと"], + "correct": 0, + "level": "N5" + }, + { + "question": "私は毎日学校に___。", + "question_ru": "Я каждый день хожу в школу.", + "options": ["いきます", "いくます", "いきす", "いきました"], + "correct": 0, + "level": "N5" + }, + { + "question": "昨日、映画を___から、今日は勉強します。", + "question_ru": "Вчера я посмотрел фильм, поэтому сегодня буду учиться.", + "options": ["見た", "見て", "見る", "見ない"], + "correct": 0, + "level": "N4" + }, + { + "question": "この本は読み___です。", + "question_ru": "Эту книгу легко/трудно читать.", + "options": ["やすい", "にくい", "たい", "そう"], + "correct": 0, + "level": "N3" + }, + { + "question": "彼の話を聞く___、涙が出てきた。", + "question_ru": "Слушая его рассказ, у меня потекли слёзы.", + "options": ["につれて", "にしたがって", "とともに", "うちに"], + "correct": 0, + "level": "N2" + }, + { + "question": "その計画は実現不可能と___。", + "question_ru": "Этот план считается невыполнимым.", + "options": ["言わざるを得ない", "言うまでもない", "言いかねない", "言うに及ばない"], + "correct": 0, + "level": "N2" + }, + { + "question": "彼の行動は___に堪えない。", + "question_ru": "Его поведение невозможно понять/вынести.", + "options": ["理解", "批判", "説明", "弁解"], + "correct": 0, + "level": "N1" + } + ] # Глобальный экземпляр сервиса diff --git a/services/user_service.py b/services/user_service.py index 6c428a9..86f8283 100644 --- a/services/user_service.py +++ b/services/user_service.py @@ -2,6 +2,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from database.models import User, LanguageLevel from typing import Optional +from utils.levels import set_user_level_for_language, get_default_level class UserService: @@ -59,14 +60,15 @@ class UserService: return result.scalar_one_or_none() @staticmethod - async def update_user_level(session: AsyncSession, user_id: int, level: LanguageLevel): + async def update_user_level(session: AsyncSession, user_id: int, level: str, language: str = None): """ - Обновить уровень английского пользователя + Обновить уровень пользователя для языка изучения. Args: session: Сессия базы данных user_id: ID пользователя - level: Новый уровень + level: Новый уровень (строка, например "B1" или "N4") + language: Язык (если None, берётся learning_language пользователя) """ result = await session.execute( select(User).where(User.id == user_id) @@ -74,7 +76,11 @@ class UserService: user = result.scalar_one_or_none() if user: - user.level = level + # Сохраняем в JSON для всех языков + set_user_level_for_language(user, level, language) + # Для обратной совместимости обновляем старое поле level (только для CEFR) + if level in ["A1", "A2", "B1", "B2", "C1", "C2"]: + user.level = LanguageLevel[level] await session.commit() @staticmethod diff --git a/utils/i18n.py b/utils/i18n.py index 61d48eb..01cd7e5 100644 --- a/utils/i18n.py +++ b/utils/i18n.py @@ -31,6 +31,11 @@ def _resolve_key(data: Dict[str, Any], dotted_key: str) -> Any: return cur +def get_user_lang(user) -> str: + """Унифицированное получение языка интерфейса пользователя.""" + return (getattr(user, 'language_interface', None) if user else None) or 'ru' + + def t(lang: str, key: str, **kwargs) -> str: """Translate key for given lang; fallback to ru and to key itself. diff --git a/utils/levels.py b/utils/levels.py new file mode 100644 index 0000000..7c048ef --- /dev/null +++ b/utils/levels.py @@ -0,0 +1,98 @@ +""" +Утилиты для работы с уровнями языка (CEFR и JLPT) +""" +from database.models import JLPT_LANGUAGES, DEFAULT_CEFR_LEVEL, DEFAULT_JLPT_LEVEL + + +# Все доступные уровни по системам +CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"] +JLPT_LEVELS = ["N5", "N4", "N3", "N2", "N1"] + + +def get_level_system(learning_language: str) -> str: + """Определить систему уровней для языка""" + return "jlpt" if learning_language in JLPT_LANGUAGES else "cefr" + + +def get_available_levels(learning_language: str) -> list[str]: + """Получить список доступных уровней для языка""" + if learning_language in JLPT_LANGUAGES: + return JLPT_LEVELS + return CEFR_LEVELS + + +def get_default_level(learning_language: str) -> str: + """Получить дефолтный уровень для языка""" + if learning_language in JLPT_LANGUAGES: + return DEFAULT_JLPT_LEVEL + return DEFAULT_CEFR_LEVEL + + +def get_user_level_for_language(user, language: str = None) -> str: + """ + Получить уровень пользователя для конкретного языка. + + Args: + user: Объект пользователя + language: Код языка (если None, берётся learning_language пользователя) + + Returns: + Строка уровня (например "B1" или "N4") + """ + if language is None: + language = user.learning_language or "en" + + # Пытаемся получить из JSON поля + levels = user.levels_by_language or {} + if language in levels: + return levels[language] + + # Fallback на старое поле level (для CEFR языков) + if language not in JLPT_LANGUAGES and user.level: + return user.level.value + + # Возвращаем дефолт + return get_default_level(language) + + +def set_user_level_for_language(user, level: str, language: str = None) -> dict: + """ + Установить уровень пользователя для конкретного языка. + + Args: + user: Объект пользователя + level: Уровень (например "B1" или "N4") + language: Код языка (если None, берётся learning_language пользователя) + + Returns: + Обновлённый словарь levels_by_language + """ + if language is None: + language = user.learning_language or "en" + + # Инициализируем JSON если его нет + if user.levels_by_language is None: + user.levels_by_language = {} + + # Копируем для изменения (SQLAlchemy требует новый объект для JSON) + levels = dict(user.levels_by_language) + levels[language] = level + user.levels_by_language = levels + + return levels + + +def get_level_key_for_i18n(learning_language: str, level: str) -> str: + """ + Получить ключ локализации для уровня. + + Args: + learning_language: Язык изучения + level: Уровень + + Returns: + Ключ для функции t() (например "settings.level.b1" или "settings.jlpt.n4") + """ + if learning_language in JLPT_LANGUAGES: + return f"settings.jlpt.{level.lower()}" + return f"settings.level.{level.lower()}"