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:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
"""Обработка сообщений в диалоге"""
|
"""Обработка сообщений в диалоге"""
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
# Сохраняем задания в состоянии
|
# Сохраняем задания в состоянии
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "❌ 単語の生成に失敗しました。後でもう一度お試しください。",
|
||||||
|
|||||||
@@ -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": "❌ Не удалось сгенерировать подборку. Попробуй позже.",
|
||||||
|
|||||||
27
migrations/versions/20251205_add_levels_by_language.py
Normal file
27
migrations/versions/20251205_add_levels_by_language.py
Normal 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')
|
||||||
@@ -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,57 +560,117 @@ 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 с базовыми вопросами
|
||||||
return [
|
if learning_language == "ja":
|
||||||
{
|
return self._get_jlpt_fallback_questions()
|
||||||
"question": "What is your name?",
|
return self._get_cefr_fallback_questions()
|
||||||
"question_ru": "Как тебя зовут?",
|
|
||||||
"options": ["My name is", "I am name", "Name my is", "Is name my"],
|
def _get_cefr_fallback_questions(self) -> List[Dict]:
|
||||||
"correct": 0,
|
"""Fallback вопросы для CEFR (английский и европейские языки)"""
|
||||||
"level": "A1"
|
return [
|
||||||
},
|
{
|
||||||
{
|
"question": "What is your name?",
|
||||||
"question": "I ___ to school every day.",
|
"question_ru": "Как тебя зовут?",
|
||||||
"question_ru": "Я ___ в школу каждый день.",
|
"options": ["My name is", "I am name", "Name my is", "Is name my"],
|
||||||
"options": ["go", "goes", "going", "went"],
|
"correct": 0,
|
||||||
"correct": 0,
|
"level": "A1"
|
||||||
"level": "A1"
|
},
|
||||||
},
|
{
|
||||||
{
|
"question": "I ___ to school every day.",
|
||||||
"question": "She ___ been to Paris twice.",
|
"question_ru": "Я ___ в школу каждый день.",
|
||||||
"question_ru": "Она ___ в Париже дважды.",
|
"options": ["go", "goes", "going", "went"],
|
||||||
"options": ["have", "has", "had", "having"],
|
"correct": 0,
|
||||||
"correct": 1,
|
"level": "A1"
|
||||||
"level": "A2"
|
},
|
||||||
},
|
{
|
||||||
{
|
"question": "She ___ been to Paris twice.",
|
||||||
"question": "If I ___ rich, I would travel the world.",
|
"question_ru": "Она ___ в Париже дважды.",
|
||||||
"question_ru": "Если бы я был богат, я бы путешествовал по миру.",
|
"options": ["have", "has", "had", "having"],
|
||||||
"options": ["am", "was", "were", "be"],
|
"correct": 1,
|
||||||
"correct": 2,
|
"level": "A2"
|
||||||
"level": "B1"
|
},
|
||||||
},
|
{
|
||||||
{
|
"question": "If I ___ rich, I would travel the world.",
|
||||||
"question": "The project ___ by next Monday.",
|
"question_ru": "Если бы я был богат, я бы путешествовал по миру.",
|
||||||
"question_ru": "Проект ___ к следующему понедельнику.",
|
"options": ["am", "was", "were", "be"],
|
||||||
"options": ["will complete", "will be completed", "completes", "is completing"],
|
"correct": 2,
|
||||||
"correct": 1,
|
"level": "B1"
|
||||||
"level": "B2"
|
},
|
||||||
},
|
{
|
||||||
{
|
"question": "The project ___ by next Monday.",
|
||||||
"question": "Had I known about the meeting, I ___ attended.",
|
"question_ru": "Проект ___ к следующему понедельнику.",
|
||||||
"question_ru": "Если бы я знал о встрече, я бы посетил.",
|
"options": ["will complete", "will be completed", "completes", "is completing"],
|
||||||
"options": ["would have", "will have", "would", "will"],
|
"correct": 1,
|
||||||
"correct": 0,
|
"level": "B2"
|
||||||
"level": "C1"
|
},
|
||||||
},
|
{
|
||||||
{
|
"question": "Had I known about the meeting, I ___ attended.",
|
||||||
"question": "The nuances of his argument were so ___ that few could grasp them.",
|
"question_ru": "Если бы я знал о встрече, я бы посетил.",
|
||||||
"question_ru": "Нюансы его аргумента были настолько ___, что немногие могли их понять.",
|
"options": ["would have", "will have", "would", "will"],
|
||||||
"options": ["subtle", "obvious", "simple", "clear"],
|
"correct": 0,
|
||||||
"correct": 0,
|
"level": "C1"
|
||||||
"level": "C2"
|
},
|
||||||
}
|
{
|
||||||
]
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# Глобальный экземпляр сервиса
|
# Глобальный экземпляр сервиса
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
98
utils/levels.py
Normal 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()}"
|
||||||
Reference in New Issue
Block a user