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:
2025-12-05 14:30:24 +03:00
parent 8bf3504d8d
commit 99deaafcbf
17 changed files with 983 additions and 308 deletions

View File

@@ -9,7 +9,8 @@ from database.models import WordSource
from services.user_service import UserService from services.user_service import UserService
from services.vocabulary_service import VocabularyService from services.vocabulary_service import VocabularyService
from services.ai_service import ai_service 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() router = Router()
@@ -44,7 +45,10 @@ async def cmd_import(message: Message, state: FSMContext):
async def cancel_import(message: Message, state: FSMContext): async def cancel_import(message: Message, state: FSMContext):
"""Отмена импорта""" """Отмена импорта"""
await state.clear() 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) @router.message(ImportStates.waiting_for_text)
@@ -52,12 +56,16 @@ async def process_text(message: Message, state: FSMContext):
"""Обработка текста от пользователя""" """Обработка текста от пользователя"""
text = message.text.strip() 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: if len(text) < 50:
await message.answer(t('ru', 'import.too_short')) await message.answer(t(lang, 'import.too_short'))
return return
if len(text) > 3000: if len(text) > 3000:
await message.answer(t('ru', 'import.too_long')) await message.answer(t(lang, 'import.too_long'))
return return
async with async_session_maker() as session: 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')) processing_msg = await message.answer(t(user.language_interface or 'ru', 'import.processing'))
# Извлекаем слова через AI # Извлекаем слова через AI
current_level = get_user_level_for_language(user)
words = await ai_service.extract_words_from_text( words = await ai_service.extract_words_from_text(
text=text, text=text,
level=user.level.value, level=current_level,
max_words=15, max_words=15,
learning_lang=user.learning_language, learning_lang=user.learning_language,
translation_lang=user.language_interface, translation_lang=user.language_interface,
@@ -87,7 +96,7 @@ async def process_text(message: Message, state: FSMContext):
words=words, words=words,
user_id=user.id, user_id=user.id,
original_text=text, original_text=text,
level=user.level.name level=current_level
) )
await state.set_state(ImportStates.viewing_words) await state.set_state(ImportStates.viewing_words)

View File

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

View File

@@ -7,7 +7,8 @@ from aiogram.fsm.state import State, StatesGroup
from database.db import async_session_maker from database.db import async_session_maker
from services.user_service import UserService from services.user_service import UserService
from services.ai_service import ai_service 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() router = Router()
@@ -15,18 +16,17 @@ router = Router()
class PracticeStates(StatesGroup): class PracticeStates(StatesGroup):
"""Состояния для диалоговой практики""" """Состояния для диалоговой практики"""
choosing_scenario = State() choosing_scenario = State()
entering_custom_scenario = State()
in_conversation = State() in_conversation = State()
# Доступные сценарии # Доступные сценарии (ключи для i18n)
SCENARIOS = { SCENARIO_KEYS = ["restaurant", "shopping", "travel", "work", "doctor", "casual"]
"restaurant": {"ru": "🍽️ Ресторан", "en": "🍽️ Restaurant", "ja": "🍽️ レストラン"},
"shopping": {"ru": "🛍️ Магазин", "en": "🛍️ Shopping", "ja": "🛍️ ショッピング"},
"travel": {"ru": "✈️ Путешествие","en": "✈️ Travel", "ja": "✈️ 旅行"}, def get_scenario_name(lang: str, scenario: str) -> str:
"work": {"ru": "💼 Работа", "en": "💼 Work", "ja": "💼 仕事"}, """Получить локализованное название сценария"""
"doctor": {"ru": "🏥 Врач", "en": "🏥 Doctor", "ja": "🏥 医者"}, return t(lang, f'practice.scenario.{scenario}')
"casual": {"ru": "💬 Общение", "en": "💬 Casual", "ja": "💬 会話"}
}
@router.message(Command("practice")) @router.message(Command("practice"))
@@ -39,23 +39,167 @@ async def cmd_practice(message: Message, state: FSMContext):
await message.answer(t('ru', 'common.start_first')) await message.answer(t('ru', 'common.start_first'))
return return
lang = get_user_lang(user)
# Показываем выбор сценария # Показываем выбор сценария
keyboard = [] keyboard = []
lang = user.language_interface or 'ru' for scenario_id in SCENARIO_KEYS:
for scenario_id, names in SCENARIOS.items():
keyboard.append([ keyboard.append([
InlineKeyboardButton( InlineKeyboardButton(
text=names.get(lang, names.get('ru')), text=get_scenario_name(lang, scenario_id),
callback_data=f"scenario_{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) 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 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) @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: async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) 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' 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( await state.update_data(
scenario=scenario, scenario=scenario,
scenario_name=SCENARIOS[scenario], scenario_name=scenario,
conversation_history=[], conversation_history=[],
message_count=0 message_count=0
) )
@@ -110,7 +254,7 @@ async def start_scenario(callback: CallbackQuery, state: FSMContext):
ai_msg = f"{ai_msg} ({fg})" ai_msg = f"{ai_msg} ({fg})"
text = ( 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"📝 <i>{conversation_start.get('context', '')}</i>\n\n"
f"<b>AI:</b> {ai_msg}\n\n" f"<b>AI:</b> {ai_msg}\n\n"
f"{t(ui_lang, 'practice.hints')}\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) 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) @router.message(Command("stop"), PracticeStates.in_conversation)
async def stop_practice(message: Message, state: FSMContext): async def stop_practice(message: Message, state: FSMContext):
"""Завершить диалоговую практику""" """Завершить диалоговую практику"""
@@ -161,10 +316,9 @@ async def stop_practice(message: Message, state: FSMContext):
end_text = ( end_text = (
t(lang, 'practice.end_title') + "\n\n" + t(lang, 'practice.end_title') + "\n\n" +
t(lang, 'practice.end_exchanged', n=message_count) + "\n\n" + t(lang, 'practice.end_exchanged', n=message_count) + "\n\n" +
t(lang, 'practice.end_keep') + "\n" + t(lang, 'practice.end_keep')
t(lang, 'practice.end_hint')
) )
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) @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 = ( end_text = (
t(lang, 'practice.end_title') + "\n\n" + t(lang, 'practice.end_title') + "\n\n" +
t(lang, 'practice.end_exchanged', n=message_count) + "\n\n" + t(lang, 'practice.end_exchanged', n=message_count) + "\n\n" +
t(lang, 'practice.end_keep') + "\n" + t(lang, 'practice.end_keep')
t(lang, 'practice.end_hint')
) )
await callback.message.answer(end_text) await callback.message.answer(end_text, reply_markup=get_end_keyboard(lang))
await callback.answer() 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) @router.message(PracticeStates.in_conversation)
async def handle_conversation(message: Message, state: FSMContext): async def handle_conversation(message: Message, state: FSMContext):
"""Обработка сообщений в диалоге""" """Обработка сообщений в диалоге"""

View File

@@ -4,52 +4,39 @@ from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKe
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from database.db import async_session_maker from database.db import async_session_maker
from database.models import LanguageLevel
from bot.handlers.start import main_menu_keyboard from bot.handlers.start import main_menu_keyboard
from services.user_service import UserService 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() 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: def get_settings_keyboard(user) -> InlineKeyboardMarkup:
"""Создать клавиатуру настроек""" """Создать клавиатуру настроек"""
is_en = _is_en(user) lang = get_user_lang(user)
is_ja = _is_ja(user) ui_lang_code = getattr(user, 'language_interface', 'ru') or 'ru'
current_level = get_user_level_for_language(user)
keyboard = InlineKeyboardMarkup(inline_keyboard=[ keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton( [InlineKeyboardButton(
text=( text=t(lang, 'settings.level_prefix') + f"{current_level}",
"📊 Level: " if is_en else ("📊 レベル: " if is_ja else "📊 Уровень: ")
) + f"{user.level.value}",
callback_data="settings_level" callback_data="settings_level"
)], )],
[InlineKeyboardButton( [InlineKeyboardButton(
text=( text=t(lang, 'settings.learning_prefix') + user.learning_language.upper(),
"🎯 Learning language: " if is_en else ("🎯 学習言語: " if is_ja else "🎯 Язык изучения: ")
) + (user.learning_language.upper()),
callback_data="settings_learning" callback_data="settings_learning"
)], )],
[InlineKeyboardButton( [InlineKeyboardButton(
text=( text=t(lang, 'settings.interface_prefix') + t(lang, f'settings.lang_name.{ui_lang_code}'),
"🌐 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 "🇷🇺 Русский")),
callback_data="settings_language" callback_data="settings_language"
)], )],
[InlineKeyboardButton( [InlineKeyboardButton(
text=("❌ Close" if is_en else ("❌ 閉じる" if is_ja else "❌ Закрыть")), text=t(lang, 'settings.close'),
callback_data="settings_close" callback_data="settings_close"
)] )]
]) ])
@@ -57,75 +44,49 @@ def get_settings_keyboard(user) -> InlineKeyboardMarkup:
def get_level_keyboard(user=None) -> InlineKeyboardMarkup: def get_level_keyboard(user=None) -> InlineKeyboardMarkup:
"""Клавиатура выбора уровня""" """Клавиатура выбора уровня (CEFR или JLPT в зависимости от языка изучения)"""
lang = getattr(user, 'language_interface', 'ru') if user is not None else 'ru' lang = get_user_lang(user)
if lang == 'en': learning_lang = getattr(user, 'learning_language', 'en') or 'en'
levels = [ available_levels = get_available_levels(learning_lang)
("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"),
]
keyboard = [] keyboard = []
for level_name, callback_data in levels: for level in available_levels:
keyboard.append([InlineKeyboardButton(text=level_name, callback_data=callback_data)]) # Ключ локализации: 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=t(lang, 'settings.back'), callback_data="settings_back")])
keyboard.append([InlineKeyboardButton(text=back_label, callback_data="settings_back")])
return InlineKeyboardMarkup(inline_keyboard=keyboard) return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_language_keyboard(user=None) -> InlineKeyboardMarkup: def get_language_keyboard(user=None) -> InlineKeyboardMarkup:
"""Клавиатура выбора языка интерфейса""" """Клавиатура выбора языка интерфейса"""
lang = getattr(user, 'language_interface', 'ru') if user is not None else 'ru' lang = get_user_lang(user)
back = "⬅️ Back" if lang == 'en' else ("⬅️ 戻る" if lang == 'ja' else "⬅️ Назад")
keyboard = InlineKeyboardMarkup(inline_keyboard=[ keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🇷🇺 Русский", callback_data="set_lang_ru")], [InlineKeyboardButton(text=t(lang, 'settings.lang_name.ru'), callback_data="set_lang_ru")],
[InlineKeyboardButton(text="🇬🇧 English", callback_data="set_lang_en")], [InlineKeyboardButton(text=t(lang, 'settings.lang_name.en'), callback_data="set_lang_en")],
[InlineKeyboardButton(text="🇯🇵 日本語", callback_data="set_lang_ja")], [InlineKeyboardButton(text=t(lang, 'settings.lang_name.ja'), callback_data="set_lang_ja")],
[InlineKeyboardButton(text=back, callback_data="settings_back")] [InlineKeyboardButton(text=t(lang, 'settings.back'), callback_data="settings_back")]
]) ])
return keyboard return keyboard
def get_learning_language_keyboard(user=None) -> InlineKeyboardMarkup: def get_learning_language_keyboard(user=None) -> InlineKeyboardMarkup:
"""Клавиатура выбора языка изучения""" """Клавиатура выбора языка изучения"""
lang = getattr(user, 'language_interface', 'ru') if user is not None else 'ru' lang = get_user_lang(user)
back = "⬅️ Back" if lang == 'en' else ("⬅️ 戻る" if lang == 'ja' else "⬅️ Назад")
# Пары (код -> подпись)
options = [ options = [
("en", "🇬🇧 English" if lang != 'ja' else "🇬🇧 英語"), ("en", t(lang, 'settings.learning_lang.en')),
("es", "🇪🇸 Spanish" if lang == 'en' else ("🇪🇸 スペイン語" if lang == 'ja' else "🇪🇸 Испанский")), ("es", t(lang, 'settings.learning_lang.es')),
("de", "🇩🇪 German" if lang == 'en' else ("🇩🇪 ドイツ語" if lang == 'ja' else "🇩🇪 Немецкий")), ("de", t(lang, 'settings.learning_lang.de')),
("fr", "🇫🇷 French" if lang == 'en' else ("🇫🇷 フランス語" if lang == 'ja' else "🇫🇷 Французский")), ("fr", t(lang, 'settings.learning_lang.fr')),
("ja", "🇯🇵 Japanese" if lang == 'en' else ("🇯🇵 日本語" if lang == 'ja' else "🇯🇵 Японский")), ("ja", t(lang, 'settings.learning_lang.ja')),
] ]
keyboard = [[InlineKeyboardButton(text=label, callback_data=f"set_learning_{code}")] for code, label in options] 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) 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) user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
if not user: if not user:
await message.answer("Сначала запусти бота командой /start") await message.answer(t('ru', 'common.start_first'))
return return
lang = getattr(user, 'language_interface', 'ru') lang = get_user_lang(user)
title = "⚙️ <b>Settings</b>\n\n" if lang == 'en' else ("⚙️ <b>設定</b>\n\n" if lang == 'ja' else "⚙️ <b>Настройки</b>\n\n") ui_lang_code = getattr(user, 'language_interface', 'ru') or 'ru'
level_label = "📊 English level: " if lang == 'en' else ("📊 英語レベル: " if lang == 'ja' else "📊 Уровень английского: ") lang_value = t(lang, f'settings.lang_name.{ui_lang_code}')
lang_label = "🌐 Interface language: " if lang == 'en' else ("🌐 インターフェース言語: " if lang == 'ja' else "🌐 Язык интерфейса: ") current_level = get_user_level_for_language(user)
lang_value = 'English' if lang == 'en' else ('日本語' if lang == 'ja' else ('Русский' if user.language_interface == 'ru' else 'English')) settings_text = (
footer = "Choose what to change:" if lang == 'en' else ("変更したい項目を選択:" if lang == 'ja' else "Выбери, что хочешь изменить:") t(lang, 'settings.title') +
settings_text = f"{title}{level_label}<b>{user.level.value}</b>\n{lang_label}<b>{lang_value}</b>\n\n{footer}" t(lang, 'settings.level_prefix') + f"<b>{current_level}</b>\n" +
t(lang, 'settings.interface_prefix') + f"<b>{lang_value}</b>\n\n" +
t(lang, 'settings.choose')
)
await message.answer(settings_text, reply_markup=get_settings_keyboard(user)) 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: async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = getattr(user, 'language_interface', 'ru') if user else 'ru' lang = get_user_lang(user)
title = "📊 <b>Choose your English level:</b>\n\n" if lang == 'en' else ("📊 <b>英語レベルを選択:</b>\n\n" if lang == 'ja' else "📊 <b>Выбери свой уровень английского:</b>\n\n") learning_lang = getattr(user, 'learning_language', 'en') or 'en'
body = ( level_system = get_level_system(learning_lang)
"<b>A1-A2</b> - Beginner\n<b>B1-B2</b> - Intermediate\n<b>C1-C2</b> - Advanced\n\n" if lang == 'en' else ( # Выбираем правильное описание групп уровней
"<b>A1-A2</b> - 初級\n<b>B1-B2</b> - 中級\n<b>C1-C2</b> - 上級\n\n" if lang == 'ja' else groups_key = 'settings.jlpt_groups' if level_system == 'jlpt' else 'settings.level_groups'
"<b>A1-A2</b> - Начинающий\n<b>B1-B2</b> - Средний\n<b>C1-C2</b> - Продвинутый\n\n" 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))
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))
await callback.answer() await callback.answer()
@@ -172,9 +134,8 @@ async def settings_learning(callback: CallbackQuery):
"""Показать выбор языка изучения""" """Показать выбор языка изучения"""
async with async_session_maker() as session: async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = getattr(user, 'language_interface', 'ru') if user else 'ru' lang = get_user_lang(user)
title = "🎯 <b>Select learning language:</b>\n\n" if lang == 'en' else ("🎯 <b>学習言語を選択:</b>\n\n" if lang == 'ja' else "🎯 <b>Выбери язык изучения:</b>\n\n") await callback.message.edit_text(t(lang, 'settings.learning_title'), reply_markup=get_learning_language_keyboard(user))
await callback.message.edit_text(title, reply_markup=get_learning_language_keyboard(user))
await callback.answer() 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) user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
if user: if user:
await UserService.update_user_learning_language(session, user.id, code) await UserService.update_user_learning_language(session, user.id, code)
lang = getattr(user, 'language_interface', 'ru') lang = get_user_lang(user)
if lang == 'en': text = t(lang, 'settings.learning_changed', code=code.upper())
text = f"✅ Learning language: <b>{code.upper()}</b>"
back = "⬅️ Back to settings"
elif lang == 'ja':
text = f"✅ 学習言語: <b>{code.upper()}</b>"
back = "⬅️ 設定に戻る"
else:
text = f"✅ Язык изучения: <b>{code.upper()}</b>"
back = "⬅️ К настройкам"
await callback.message.edit_text( await callback.message.edit_text(
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() await callback.answer()
@router.callback_query(F.data.startswith("set_level_")) @router.callback_query(F.data.startswith("set_level_"))
async def set_level(callback: CallbackQuery): async def set_level(callback: CallbackQuery):
"""Установить уровень""" """Установить уровень (CEFR или JLPT)"""
level_str = callback.data.split("_")[-1] # A1, A2, B1, B2, C1, C2 level_str = callback.data.split("_")[-1] # A1, A2, B1, B2, C1, C2 или N5, N4, N3, N2, N1
async with async_session_maker() as session: async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
if user: if user:
# Обновляем уровень # Передаём строковый уровень, UserService сам разберётся с системой
await UserService.update_user_level(session, user.id, LanguageLevel[level_str]) await UserService.update_user_level(session, user.id, level_str)
lang = getattr(user, 'language_interface', 'ru') lang = get_user_lang(user)
changed = "✅ Level changed to " if lang == 'en' else ("✅ レベルが変更されました: " if lang == 'ja' else "✅ Уровень изменен на ") msg = t(lang, 'settings.level_changed', level=level_str) + t(lang, 'settings.level_changed_hint')
msg = changed + f"<b>{level_str}</b>\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 "⬅️ К настройкам")
await callback.message.edit_text( await callback.message.edit_text(
msg, 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() await callback.answer()
@@ -232,11 +183,9 @@ async def settings_language(callback: CallbackQuery):
"""Показать выбор языка""" """Показать выбор языка"""
async with async_session_maker() as session: async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = getattr(user, 'language_interface', 'ru') if user else 'ru' lang = get_user_lang(user)
title = "🌐 <b>Select interface language:</b>\n\n" if lang == 'en' else ("🌐 <b>インターフェース言語を選択:</b>\n\n" if lang == 'ja' else "🌐 <b>Выбери язык интерфейса:</b>\n\n")
desc = "This will change the language of bot messages." if lang == 'en' else ("ボットの表示言語が変更されます。" if lang == 'ja' else "Это изменит язык всех сообщений бота.")
await callback.message.edit_text( await callback.message.edit_text(
title + desc, t(lang, 'settings.lang_title') + t(lang, 'settings.lang_desc'),
reply_markup=get_language_keyboard(user) reply_markup=get_language_keyboard(user)
) )
await callback.answer() await callback.answer()
@@ -245,29 +194,21 @@ async def settings_language(callback: CallbackQuery):
@router.callback_query(F.data.startswith("set_lang_")) @router.callback_query(F.data.startswith("set_lang_"))
async def set_language(callback: CallbackQuery): 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: async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
if user: if user:
await UserService.update_user_language(session, user.id, lang) await UserService.update_user_language(session, user.id, new_lang)
if lang == 'en': # Используем новый язык для сообщений
text = "✅ Interface language: <b>English</b>" text = t(new_lang, 'settings.lang_changed')
back = "⬅️ Back"
elif lang == 'ja':
text = "✅ インターフェース言語: <b>日本語</b>"
back = "⬅️ 戻る"
else:
text = "✅ Язык интерфейса: <b>Русский</b>"
back = "⬅️ К настройкам"
await callback.message.edit_text( await callback.message.edit_text(
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(t(new_lang, 'settings.menu_updated'), reply_markup=main_menu_keyboard(new_lang))
await callback.message.answer(menu_updated, reply_markup=main_menu_keyboard(lang))
await callback.answer() 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) user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
if user: if user:
lang = getattr(user, 'language_interface', 'ru') lang = get_user_lang(user)
title = "⚙️ <b>Settings</b>\n\n" if lang == 'en' else ("⚙️ <b>設定</b>\n\n" if lang == 'ja' else "⚙️ <b>Настройки</b>\n\n") ui_lang_code = getattr(user, 'language_interface', 'ru') or 'ru'
level_label = "📊 English level: " if lang == 'en' else ("📊 英語レベル: " if lang == 'ja' else "📊 Уровень английского: ") lang_value = t(lang, f'settings.lang_name.{ui_lang_code}')
lang_label = "🌐 Interface language: " if lang == 'en' else ("🌐 インターフェース言語: " if lang == 'ja' else "🌐 Язык интерфейса: ") current_level = get_user_level_for_language(user)
lang_value = 'English' if user.language_interface == 'en' else ('日本語' if user.language_interface == 'ja' else 'Русский') settings_text = (
footer = "Choose what to change:" if lang == 'en' else ("変更したい項目を選択:" if lang == 'ja' else "Выбери, что хочешь изменить:") t(lang, 'settings.title') +
settings_text = f"{title}{level_label}<b>{user.level.value}</b>\n{lang_label}<b>{lang_value}</b>\n\n{footer}" t(lang, 'settings.level_prefix') + f"<b>{current_level}</b>\n" +
t(lang, 'settings.interface_prefix') + f"<b>{lang_value}</b>\n\n" +
t(lang, 'settings.choose')
)
await callback.message.edit_text(settings_text, reply_markup=get_settings_keyboard(user)) await callback.message.edit_text(settings_text, reply_markup=get_settings_keyboard(user))

View File

@@ -13,6 +13,7 @@ from aiogram.fsm.context import FSMContext
from database.db import async_session_maker from database.db import async_session_maker
from services.user_service import UserService from services.user_service import UserService
from utils.i18n import t from utils.i18n import t
from utils.levels import get_user_level_for_language
router = Router() router = Router()
@@ -185,10 +186,11 @@ async def pick_theme_from_menu(callback: CallbackQuery, state: FSMContext):
return return
lang = (user.language_interface or 'ru') 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)) generating = await callback.message.answer(t(lang, 'words.generating', theme=theme))
words = await ai_service.generate_thematic_words( words = await ai_service.generate_thematic_words(
theme=theme, theme=theme,
level=user.level.value, level=current_level,
count=10, count=10,
learning_lang=user.learning_language, learning_lang=user.learning_language,
translation_lang=user.language_interface, translation_lang=user.language_interface,
@@ -200,7 +202,7 @@ async def pick_theme_from_menu(callback: CallbackQuery, state: FSMContext):
return return
# Сохраняем в состояние как в /words # Сохраняем в состояние как в /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 state.set_state(WordsStates.viewing_words)
await show_words_list(callback.message, words, theme) await show_words_list(callback.message, words, theme)

View File

@@ -37,7 +37,7 @@ async def cmd_task(message: Message, state: FSMContext):
) )
if not tasks: 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 return
# Сохраняем задания в состоянии # Сохраняем задания в состоянии

View File

@@ -9,7 +9,7 @@ from database.models import WordSource
from services.user_service import UserService from services.user_service import UserService
from services.vocabulary_service import VocabularyService from services.vocabulary_service import VocabularyService
from services.ai_service import ai_service from services.ai_service import ai_service
from utils.i18n import t from utils.i18n import t, get_user_lang
router = Router() 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) existing_word = await VocabularyService.find_word(session, user.id, word)
if existing_word: if existing_word:
await message.answer( lang = get_user_lang(user)
f"Слово '<b>{word}</b>' уже есть в твоём словаре!\n" await message.answer(t(lang, 'add.exists', word=word, translation=existing_word.word_translation))
f"Перевод: {existing_word.word_translation}"
)
await state.clear() await state.clear()
return return

View File

@@ -10,6 +10,7 @@ from services.user_service import UserService
from services.vocabulary_service import VocabularyService from services.vocabulary_service import VocabularyService
from services.ai_service import ai_service from services.ai_service import ai_service
from utils.i18n import t from utils.i18n import t
from utils.levels import get_user_level_for_language
router = Router() 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)) generating_msg = await message.answer(t(lang, 'words.generating', theme=theme))
# Генерируем слова через AI # Генерируем слова через AI
current_level = get_user_level_for_language(user)
words = await ai_service.generate_thematic_words( words = await ai_service.generate_thematic_words(
theme=theme, theme=theme,
level=user.level.value, level=current_level,
count=10, count=10,
learning_lang=user.learning_language, learning_lang=user.learning_language,
translation_lang=user.language_interface, translation_lang=user.language_interface,
@@ -69,7 +71,7 @@ async def cmd_words(message: Message, state: FSMContext):
theme=theme, theme=theme,
words=words, words=words,
user_id=user.id, user_id=user.id,
level=user.level.name level=current_level
) )
await state.set_state(WordsStates.viewing_words) await state.set_state(WordsStates.viewing_words)

View File

@@ -12,7 +12,7 @@ class Base(DeclarativeBase):
class LanguageLevel(str, enum.Enum): class LanguageLevel(str, enum.Enum):
"""Уровни владения языком""" """Уровни владения языком (CEFR)"""
A1 = "A1" A1 = "A1"
A2 = "A2" A2 = "A2"
B1 = "B1" B1 = "B1"
@@ -21,6 +21,23 @@ class LanguageLevel(str, enum.Enum):
C2 = "C2" 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): class WordSource(str, enum.Enum):
"""Источник добавления слова""" """Источник добавления слова"""
MANUAL = "manual" # Ручное добавление MANUAL = "manual" # Ручное добавление
@@ -40,6 +57,7 @@ class User(Base):
language_interface: Mapped[str] = mapped_column(String(2), default="ru") # ru/en language_interface: Mapped[str] = mapped_column(String(2), default="ru") # ru/en
learning_language: Mapped[str] = mapped_column(String(2), default="en") # en learning_language: Mapped[str] = mapped_column(String(2), default="en") # en
level: Mapped[LanguageLevel] = mapped_column(SQLEnum(LanguageLevel), default=LanguageLevel.A1) 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") timezone: Mapped[str] = mapped_column(String(50), default="UTC")
daily_task_time: Mapped[Optional[str]] = mapped_column(String(5)) # HH:MM daily_task_time: Mapped[Optional[str]] = mapped_column(String(5)) # HH:MM
reminders_enabled: Mapped[bool] = mapped_column(Boolean, default=False) reminders_enabled: Mapped[bool] = mapped_column(Boolean, default=False)

View File

@@ -81,7 +81,15 @@
"end_keep": "Great job! Keep practicing.", "end_keep": "Great job! Keep practicing.",
"end_hint": "Use /practice to start a new dialogue.", "end_hint": "Use /practice to start a new dialogue.",
"translation_unavailable": "Translation unavailable", "translation_unavailable": "Translation unavailable",
"translation_already": "Translation already shown" "translation_already": "Translation already shown",
"custom_scenario_btn": "✏️ Custom scenario",
"custom_scenario_prompt": "✏️ <b>Describe your scenario</b>\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": { "tasks": {
"no_words": "📚 You don't have words to practice yet!\n\nAdd some words with /add and come back.", "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", "cancelled": "❌ Test cancelled",
"q_header": "❓ <b>Question {i} of {n}</b>" "q_header": "❓ <b>Question {i} of {n}</b>"
}, },
"settings": {
"title": "⚙️ <b>Settings</b>\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": "📊 <b>Choose your level:</b>\n\n",
"level_groups": "<b>A1-A2</b> - Beginner\n<b>B1-B2</b> - Intermediate\n<b>C1-C2</b> - 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": "<b>N5-N4</b> - Beginner\n<b>N3</b> - Intermediate\n<b>N2-N1</b> - Advanced\n\n",
"level_changed": "✅ Level changed to <b>{level}</b>\n\n",
"level_changed_hint": "You will now receive words and tasks matching your level!",
"lang_title": "🌐 <b>Select interface language:</b>\n\n",
"lang_desc": "This will change the language of bot messages.",
"lang_changed": "✅ Interface language: <b>English</b>",
"learning_title": "🎯 <b>Select learning language:</b>\n\n",
"learning_changed": "✅ Learning language: <b>{code}</b>",
"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: <b>{answer}</b>",
"result_title": "🎉 <b>Test completed!</b>\n\n",
"results_header": "📊 Results:\n",
"correct_count": "Correct answers: <b>{correct}</b> of {total}\n",
"accuracy": "Accuracy: <b>{accuracy}%</b>\n\n",
"your_level": "🎯 Your level: <b>{level}</b>\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": { "words": {
"generating": "🔄 Generating words for topic '{theme}'...", "generating": "🔄 Generating words for topic '{theme}'...",
"generate_failed": "❌ Failed to generate words. Please try again later.", "generate_failed": "❌ Failed to generate words. Please try again later.",

View File

@@ -73,7 +73,15 @@
"end_keep": "素晴らしい!練習を続けましょう。", "end_keep": "素晴らしい!練習を続けましょう。",
"end_hint": "/practice で新しい会話を始められます。", "end_hint": "/practice で新しい会話を始められます。",
"translation_unavailable": "翻訳は利用できません", "translation_unavailable": "翻訳は利用できません",
"translation_already": "翻訳はすでに表示されています" "translation_already": "翻訳はすでに表示されています",
"custom_scenario_btn": "✏️ カスタムシナリオ",
"custom_scenario_prompt": "✏️ <b>シナリオを入力してください</b>\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": { "tasks": {
"no_words": "📚 まだ練習用の単語がありません!\n\n/add で単語を追加してから戻ってきてください。", "no_words": "📚 まだ練習用の単語がありません!\n\n/add で単語を追加してから戻ってきてください。",
@@ -151,6 +159,87 @@
"cancelled": "❌ テストを中止しました", "cancelled": "❌ テストを中止しました",
"q_header": "❓ <b>{n}問中 {i} 問目</b>" "q_header": "❓ <b>{n}問中 {i} 問目</b>"
}, },
"settings": {
"title": "⚙️ <b>設定</b>\n\n",
"level_prefix": "📊 レベル: ",
"learning_prefix": "🎯 学習言語: ",
"interface_prefix": "🌐 インターフェース言語: ",
"choose": "変更したい項目を選択:",
"close": "❌ 閉じる",
"back": "⬅️ 戻る",
"back_to_settings": "⬅️ 設定に戻る",
"level_title": "📊 <b>レベルを選択:</b>\n\n",
"level_groups": "<b>A1-A2</b> - 初級\n<b>B1-B2</b> - 中級\n<b>C1-C2</b> - 上級\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": "<b>N5-N4</b> - 初級\n<b>N3</b> - 中級\n<b>N2-N1</b> - 上級\n\n",
"level_changed": "✅ レベルが変更されました: <b>{level}</b>\n\n",
"level_changed_hint": "これからレベルに合った単語と課題が出題されます!",
"lang_title": "🌐 <b>インターフェース言語を選択:</b>\n\n",
"lang_desc": "ボットの表示言語が変更されます。",
"lang_changed": "✅ インターフェース言語: <b>日本語</b>",
"learning_title": "🎯 <b>学習言語を選択:</b>\n\n",
"learning_changed": "✅ 学習言語: <b>{code}</b>",
"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": "正解: <b>{answer}</b>",
"result_title": "🎉 <b>テスト完了!</b>\n\n",
"results_header": "📊 結果:\n",
"correct_count": "正解数: <b>{correct}</b> / {total}\n",
"accuracy": "正答率: <b>{accuracy}%</b>\n\n",
"your_level": "🎯 あなたのレベル: <b>{level}</b>\n",
"level_set_hint": "これから課題や教材があなたのレベルに合わせて出題されます!\n/settings でいつでもレベルを変更できます",
"level_desc": {
"A1": "初級 - 基本的なフレーズを理解し、自己紹介ができる",
"A2": "初級(上) - 簡単なトピックでコミュニケーションできる",
"B1": "中級 - 慣れた話題で会話を続けられる",
"B2": "中級(上) - ほとんどの状況で流暢に話せる",
"C1": "上級 - 言語を柔軟かつ効果的に使える",
"C2": "ネイティブ - ネイティブレベルの言語力",
"N5": "基礎 - ひらがな、カタカナ、基本漢字を理解できる",
"N4": "初級 - 日常会話を理解できる",
"N3": "中級 - 一般的な文章や会話を理解できる",
"N2": "上級 - ほとんどのコンテンツを理解できる",
"N1": "流暢 - 日本語を完全に習得している"
}
},
"words": { "words": {
"generating": "🔄 テーマ『{theme}』の単語を生成中...", "generating": "🔄 テーマ『{theme}』の単語を生成中...",
"generate_failed": "❌ 単語の生成に失敗しました。後でもう一度お試しください。", "generate_failed": "❌ 単語の生成に失敗しました。後でもう一度お試しください。",

View File

@@ -81,7 +81,15 @@
"end_keep": "Отличная работа! Продолжай практиковаться.", "end_keep": "Отличная работа! Продолжай практиковаться.",
"end_hint": "Используй /practice для нового диалога.", "end_hint": "Используй /practice для нового диалога.",
"translation_unavailable": "Перевод недоступен", "translation_unavailable": "Перевод недоступен",
"translation_already": "Перевод уже показан" "translation_already": "Перевод уже показан",
"custom_scenario_btn": "✏️ Свой сценарий",
"custom_scenario_prompt": "✏️ <b>Опиши свой сценарий</b>\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": { "tasks": {
"no_words": "📚 У тебя пока нет слов для практики!\n\nДобавь несколько слов командой /add, а затем возвращайся.", "no_words": "📚 У тебя пока нет слов для практики!\n\nДобавь несколько слов командой /add, а затем возвращайся.",
@@ -159,6 +167,87 @@
"cancelled": "❌ Тест отменён", "cancelled": "❌ Тест отменён",
"q_header": "❓ <b>Вопрос {i} из {n}</b>" "q_header": "❓ <b>Вопрос {i} из {n}</b>"
}, },
"settings": {
"title": "⚙️ <b>Настройки</b>\n\n",
"level_prefix": "📊 Уровень: ",
"learning_prefix": "🎯 Язык изучения: ",
"interface_prefix": "🌐 Язык интерфейса: ",
"choose": "Выбери, что хочешь изменить:",
"close": "❌ Закрыть",
"back": "⬅️ Назад",
"back_to_settings": "⬅️ К настройкам",
"level_title": "📊 <b>Выбери свой уровень:</b>\n\n",
"level_groups": "<b>A1-A2</b> - Начинающий\n<b>B1-B2</b> - Средний\n<b>C1-C2</b> - Продвинутый\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": "<b>N5-N4</b> - Начинающий\n<b>N3</b> - Средний\n<b>N2-N1</b> - Продвинутый\n\n",
"level_changed": "✅ Уровень изменен на <b>{level}</b>\n\n",
"level_changed_hint": "Теперь ты будешь получать слова и задания, соответствующие твоему уровню!",
"lang_title": "🌐 <b>Выбери язык интерфейса:</b>\n\n",
"lang_desc": "Это изменит язык всех сообщений бота.",
"lang_changed": "✅ Язык интерфейса: <b>Русский</b>",
"learning_title": "🎯 <b>Выбери язык изучения:</b>\n\n",
"learning_changed": "✅ Язык изучения: <b>{code}</b>",
"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": "Правильный ответ: <b>{answer}</b>",
"result_title": "🎉 <b>Тест завершён!</b>\n\n",
"results_header": "📊 Результаты:\n",
"correct_count": "Правильных ответов: <b>{correct}</b> из {total}\n",
"accuracy": "Точность: <b>{accuracy}%</b>\n\n",
"your_level": "🎯 Твой уровень: <b>{level}</b>\n",
"level_set_hint": "Теперь задания и материалы будут подбираться под твой уровень!\nТы можешь изменить уровень в любое время через /settings",
"level_desc": {
"A1": "Начальный - понимаешь основные фразы и можешь представиться",
"A2": "Элементарный - можешь общаться на простые темы",
"B1": "Средний - можешь поддержать беседу на знакомые темы",
"B2": "Выше среднего - свободно общаешься в большинстве ситуаций",
"C1": "Продвинутый - используешь язык гибко и эффективно",
"C2": "Профессиональный - владеешь языком на уровне носителя",
"N5": "Базовый - понимаешь хирагану, катакану и базовые кандзи",
"N4": "Начальный - понимаешь повседневные разговоры",
"N3": "Средний - понимаешь обычные тексты и разговоры",
"N2": "Продвинутый - понимаешь большинство контента",
"N1": "Свободный - полное владение японским языком"
}
},
"words": { "words": {
"generating": "🔄 Генерирую подборку слов по теме '{theme}'...", "generating": "🔄 Генерирую подборку слов по теме '{theme}'...",
"generate_failed": "❌ Не удалось сгенерировать подборку. Попробуй позже.", "generate_failed": "❌ Не удалось сгенерировать подборку. Попробуй позже.",

View File

@@ -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')

View File

@@ -490,42 +490,62 @@ User: {user_message}
"suggestions": ["Sure!", "Well...", "Actually..."] "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: Returns:
Список из 7 вопросов разной сложности Список из 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: Верни ответ в формате JSON:
{ {{
"questions": [ "questions": [
{ {{
"question": "текст вопроса на английском", "question": "текст вопроса на изучаемом языке",
"question_ru": "перевод вопроса на русский", "question_ru": "перевод вопроса на русский",
"options": ["вариант A", "вариант B", "вариант C", "вариант D"], "options": ["вариант A", "вариант B", "вариант C", "вариант D"],
"correct": 0, "correct": 0,
"level": "A1" "level": "{level_example}"
} }}
] ]
} }}
Требования: Требования:
- Вопросы 1-2: уровень A1 (базовый) {levels_req}
- Вопросы 3-4: уровень A2-B1 (элементарный-средний)
- Вопросы 5-6: уровень B2-C1 (продвинутый)
- Вопрос 7: уровень C2 (профессиональный)
- Каждый вопрос с 4 вариантами ответа - Каждый вопрос с 4 вариантами ответа
- correct - индекс правильного ответа (0-3) - correct - индекс правильного ответа (0-3)
- Вопросы на грамматику, лексику и понимание""" - Вопросы на грамматику, лексику и понимание"""
try: 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 = [ messages = [
{"role": "system", "content": "Ты - эксперт по тестированию уровня английского языка. Создавай объективные тесты."}, {"role": "system", "content": system_msg},
{"role": "user", "content": prompt} {"role": "user", "content": prompt}
] ]
@@ -540,6 +560,12 @@ User: {user_message}
except Exception as e: except Exception as e:
logger.error(f"[GPT Error] generate_level_test: {type(e).__name__}: {str(e)}, using fallback questions") logger.error(f"[GPT Error] generate_level_test: {type(e).__name__}: {str(e)}, using fallback questions")
# Fallback с базовыми вопросами # Fallback с базовыми вопросами
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 [ return [
{ {
"question": "What is your name?", "question": "What is your name?",
@@ -592,6 +618,60 @@ User: {user_message}
} }
] ]
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"
}
]
# Глобальный экземпляр сервиса # Глобальный экземпляр сервиса
ai_service = AIService() ai_service = AIService()

View File

@@ -2,6 +2,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database.models import User, LanguageLevel from database.models import User, LanguageLevel
from typing import Optional from typing import Optional
from utils.levels import set_user_level_for_language, get_default_level
class UserService: class UserService:
@@ -59,14 +60,15 @@ class UserService:
return result.scalar_one_or_none() return result.scalar_one_or_none()
@staticmethod @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: Args:
session: Сессия базы данных session: Сессия базы данных
user_id: ID пользователя user_id: ID пользователя
level: Новый уровень level: Новый уровень (строка, например "B1" или "N4")
language: Язык (если None, берётся learning_language пользователя)
""" """
result = await session.execute( result = await session.execute(
select(User).where(User.id == user_id) select(User).where(User.id == user_id)
@@ -74,7 +76,11 @@ class UserService:
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user: 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() await session.commit()
@staticmethod @staticmethod

View File

@@ -31,6 +31,11 @@ def _resolve_key(data: Dict[str, Any], dotted_key: str) -> Any:
return cur 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: def t(lang: str, key: str, **kwargs) -> str:
"""Translate key for given lang; fallback to ru and to key itself. """Translate key for given lang; fallback to ru and to key itself.

98
utils/levels.py Normal file
View File

@@ -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()}"