From 472771229f6a75d44a1e585d93f02b5b2152ed8a Mon Sep 17 00:00:00 2001 From: "mamonov.ep" Date: Thu, 4 Dec 2025 19:40:01 +0300 Subject: [PATCH] feat(i18n): localize start/help/menu, practice, words, import, reminder, vocabulary, tasks/stats for RU/EN/JA; add JSON-based i18n helper\n\nfeat(lang): support learning/translation languages across AI flows; hide translations with buttons; store examples per lang\n\nfeat(vocab): add source_lang and translation_lang to Vocabulary, unique constraint (user_id, source_lang, word_original); filter /vocabulary by user.learning_language\n\nchore(migrations): add Alembic setup + migration to add vocab lang columns; env.py reads app settings and supports asyncpg URLs\n\nfix(words/import): pass learning_lang + translation_lang everywhere; fix menu themes generation\n\nfeat(settings): add learning language selector; update main menu on language change --- README.md | 14 +- alembic.ini | 36 +++ bot/handlers/import_text.py | 81 ++++--- bot/handlers/level_test.py | 78 ++++-- bot/handlers/practice.py | 166 +++++++++---- bot/handlers/reminder.py | 81 ++++--- bot/handlers/settings.py | 229 +++++++++++++----- bot/handlers/start.py | 186 ++++++-------- bot/handlers/tasks.py | 103 +++++--- bot/handlers/vocabulary.py | 82 ++++--- bot/handlers/words.py | 85 +++++-- database/models.py | 7 +- locales/en.json | 183 ++++++++++++++ locales/ja.json | 175 +++++++++++++ locales/ru.json | 183 ++++++++++++++ migrations/env.py | 93 +++++++ .../20251204_add_vocab_lang_fields.py | 30 +++ services/ai_service.py | 96 ++++---- services/task_service.py | 42 +++- services/user_service.py | 19 ++ services/vocabulary_service.py | 38 ++- utils/i18n.py | 51 ++++ 22 files changed, 1587 insertions(+), 471 deletions(-) create mode 100644 alembic.ini create mode 100644 locales/en.json create mode 100644 locales/ja.json create mode 100644 locales/ru.json create mode 100644 migrations/env.py create mode 100644 migrations/versions/20251204_add_vocab_lang_fields.py create mode 100644 utils/i18n.py diff --git a/README.md b/README.md index 1284e5e..0c51f76 100644 --- a/README.md +++ b/README.md @@ -286,19 +286,19 @@ bot_tg_language/ - [x] Статистика и прогресс - [x] Spaced repetition алгоритм (базовая версия) - [x] Напоминания и ежедневные задания по расписанию +- [x] Убрать переводы текстов (скрыть перевод в упражнениях/диалогах/тестах) **Следующие улучшения:** - [ ] Экспорт словаря (PDF, Anki, CSV) - [ ] Голосовые сообщения для практики произношения - [ ] Групповые челленджи и лидерборды - [ ] Gamification (стрики, достижения, уровни) - - [ ] Расширенная аналитика с графиками - - [ ] Убрать переводы текстов (скрыть перевод в упражнениях/диалогах/тестах) - - [ ] Добавить импорт нескольких слов (bulk-импорт) - - [ ] Создание задач на выбранные слова (из словаря/подборок) - - [ ] Добавить возможность иметь словам несколько переводов - - [ ] Изменить словарь: оставить только слова и добавить возможность получать инфо о словах - - [ ] Добавить возможность импорта слов из файлов +- [ ] Расширенная аналитика с графиками +- [ ] Добавить импорт нескольких слов (bulk-импорт) +- [ ] Создание задач на выбранные слова (из словаря/подборок) +- [ ] Добавить возможность иметь словам несколько переводов +- [ ] Изменить словарь: оставить только слова и добавить возможность получать инфо о словах +- [ ] Добавить возможность импорта слов из файлов ## Cloudflare AI Gateway (опционально) diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..c818d31 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = migrations +sqlalchemy.url = postgresql://botuser:botpassword@localhost:5432/language_bot + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/bot/handlers/import_text.py b/bot/handlers/import_text.py index 98f2dc2..e146dc3 100644 --- a/bot/handlers/import_text.py +++ b/bot/handlers/import_text.py @@ -9,6 +9,7 @@ from database.models import WordSource from services.user_service import UserService from services.vocabulary_service import VocabularyService from services.ai_service import ai_service +from utils.i18n import t router = Router() @@ -26,20 +27,16 @@ async def cmd_import(message: Message, state: FSMContext): user = await UserService.get_user_by_telegram_id(session, message.from_user.id) if not user: - await message.answer("Сначала запусти бота командой /start") + await message.answer(t('ru', 'common.start_first')) return await state.set_state(ImportStates.waiting_for_text) + lang = user.language_interface or 'ru' await message.answer( - "📖 Импорт слов из текста\n\n" - "Отправь мне текст на английском языке, и я извлеку из него " - "полезные слова для изучения.\n\n" - "Можно отправить:\n" - "• Отрывок из книги или статьи\n" - "• Текст песни\n" - "• Описание чего-либо\n" - "• Любой интересный текст\n\n" - "Отправь /cancel для отмены." + t(lang, 'import.title') + "\n\n" + + t(lang, 'import.desc') + "\n\n" + + t(lang, 'import.can_send') + "\n\n" + + t(lang, 'import.cancel_hint') ) @@ -56,38 +53,32 @@ async def process_text(message: Message, state: FSMContext): text = message.text.strip() if len(text) < 50: - await message.answer( - "⚠️ Текст слишком короткий. Отправь текст минимум из 50 символов.\n" - "Или используй /cancel для отмены." - ) + await message.answer(t('ru', 'import.too_short')) return if len(text) > 3000: - await message.answer( - "⚠️ Текст слишком длинный (максимум 3000 символов).\n" - "Отправь текст покороче или используй /cancel для отмены." - ) + await message.answer(t('ru', 'import.too_long')) return async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, message.from_user.id) # Показываем индикатор обработки - processing_msg = await message.answer("🔄 Анализирую текст и извлекаю слова...") + processing_msg = await message.answer(t(user.language_interface or 'ru', 'import.processing')) # Извлекаем слова через AI words = await ai_service.extract_words_from_text( text=text, level=user.level.value, - max_words=15 + max_words=15, + learning_lang=user.learning_language, + translation_lang=user.language_interface, ) await processing_msg.delete() if not words: - await message.answer( - "❌ Не удалось извлечь слова из текста. Попробуй другой текст или повтори позже." - ) + await message.answer(t(user.language_interface or 'ru', 'import.failed')) await state.clear() return @@ -107,7 +98,10 @@ async def process_text(message: Message, state: FSMContext): async def show_extracted_words(message: Message, words: list): """Показать извлечённые слова с кнопками для добавления""" - text = f"📚 Найдено слов: {len(words)}\n\n" + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + lang = (user.language_interface if user else 'ru') or 'ru' + text = t(lang, 'import.found_header', n=len(words)) + "\n\n" for idx, word_data in enumerate(words, 1): text += ( @@ -125,7 +119,7 @@ async def show_extracted_words(message: Message, words: list): text += "\n" - text += "Выбери слова, которые хочешь добавить в словарь:" + text += t(lang, 'words.choose') # Создаем кнопки для каждого слова (по 2 в ряд) keyboard = [] @@ -143,12 +137,12 @@ async def show_extracted_words(message: Message, words: list): # Кнопка "Добавить все" keyboard.append([ - InlineKeyboardButton(text="✅ Добавить все", callback_data="import_all_words") + InlineKeyboardButton(text=t(lang, 'words.add_all_btn'), callback_data="import_all_words") ]) # Кнопка "Закрыть" keyboard.append([ - InlineKeyboardButton(text="❌ Закрыть", callback_data="close_import") + InlineKeyboardButton(text=t(lang, 'words.close_btn'), callback_data="close_import") ]) reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -158,6 +152,8 @@ async def show_extracted_words(message: Message, words: list): @router.callback_query(F.data.startswith("import_word_"), ImportStates.viewing_words) async def import_single_word(callback: CallbackQuery, state: FSMContext): """Добавить одно слово из импорта""" + # Отвечаем сразу, операция может занять время + await callback.answer() word_index = int(callback.data.split("_")[2]) data = await state.get_data() @@ -171,6 +167,7 @@ async def import_single_word(callback: CallbackQuery, state: FSMContext): word_data = words[word_index] async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) # Проверяем, нет ли уже такого слова existing = await VocabularyService.get_word_by_original( session, user_id, word_data['word'] @@ -181,24 +178,34 @@ async def import_single_word(callback: CallbackQuery, state: FSMContext): return # Добавляем слово + learn = user.learning_language if user else 'en' + ui = user.language_interface if user else 'ru' + ctx = word_data.get('context') + examples = ([{learn: ctx, ui: ''}] if ctx else []) + await VocabularyService.add_word( session=session, user_id=user_id, word_original=word_data['word'], word_translation=word_data['translation'], + source_lang=user.learning_language if user else None, + translation_lang=user.language_interface if user else None, transcription=word_data.get('transcription'), - examples=[{"en": word_data.get('context', ''), "ru": ""}] if word_data.get('context') else [], + examples=examples, source=WordSource.CONTEXT, category='imported', difficulty_level=data.get('level') ) - await callback.answer(f"✅ Слово '{word_data['word']}' добавлено в словарь") + lang = (user.language_interface if user else 'ru') or 'ru' + await callback.message.answer(t(lang, 'import.added_single', word=word_data['word'])) @router.callback_query(F.data == "import_all_words", ImportStates.viewing_words) async def import_all_words(callback: CallbackQuery, state: FSMContext): """Добавить все слова из импорта""" + # Сразу отвечаем, так как операция может занять заметное время + await callback.answer() data = await state.get_data() words = data.get('words', []) user_id = data.get('user_id') @@ -207,6 +214,7 @@ async def import_all_words(callback: CallbackQuery, state: FSMContext): skipped_count = 0 async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) for word_data in words: # Проверяем, нет ли уже такого слова existing = await VocabularyService.get_word_by_original( @@ -218,27 +226,34 @@ async def import_all_words(callback: CallbackQuery, state: FSMContext): continue # Добавляем слово + learn = user.learning_language if user else 'en' + ui = user.language_interface if user else 'ru' + ctx = word_data.get('context') + examples = ([{learn: ctx, ui: ''}] if ctx else []) + await VocabularyService.add_word( session=session, user_id=user_id, word_original=word_data['word'], word_translation=word_data['translation'], + source_lang=user.learning_language if user else None, + translation_lang=user.language_interface if user else None, transcription=word_data.get('transcription'), - examples=[{"en": word_data.get('context', ''), "ru": ""}] if word_data.get('context') else [], + examples=examples, source=WordSource.CONTEXT, category='imported', difficulty_level=data.get('level') ) added_count += 1 - result_text = f"✅ Добавлено слов: {added_count}" + lang = (user.language_interface if user else 'ru') or 'ru' + result_text = t(lang, 'import.added_count', n=added_count) if skipped_count > 0: - result_text += f"\n⚠️ Пропущено (уже в словаре): {skipped_count}" + result_text += "\n" + t(lang, 'import.skipped_count', n=skipped_count) await callback.message.edit_reply_markup(reply_markup=None) await callback.message.answer(result_text) await state.clear() - await callback.answer() @router.callback_query(F.data == "close_import", ImportStates.viewing_words) diff --git a/bot/handlers/level_test.py b/bot/handlers/level_test.py index 5d36ca1..3f8df83 100644 --- a/bot/handlers/level_test.py +++ b/bot/handlers/level_test.py @@ -26,23 +26,17 @@ async def cmd_level_test(message: Message, state: FSMContext): async def start_level_test(message: Message, state: FSMContext): """Начать тест определения уровня""" # Показываем описание теста - await message.answer( - "📊 Тест определения уровня\n\n" - "Этот короткий тест поможет определить твой уровень английского.\n\n" - "📋 Тест включает 7 вопросов:\n" - "• Грамматика\n" - "• Лексика\n" - "• Понимание\n\n" - "⏱ Займёт около 2-3 минут\n\n" - "Готов начать?" - ) + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + lang = (user.language_interface if user else 'ru') or 'ru' + await message.answer(t(lang, 'level_test.intro')) keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="✅ Начать тест", callback_data="start_test")], - [InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_test")] + [InlineKeyboardButton(text=t(lang, 'level_test.start_btn'), callback_data="start_test")], + [InlineKeyboardButton(text=t(lang, 'level_test.cancel_btn'), callback_data="cancel_test")] ]) - await message.answer("Нажми кнопку когда будешь готов:", reply_markup=keyboard) + await message.answer(t(lang, 'level_test.press_button'), reply_markup=keyboard) @router.callback_query(F.data == "cancel_test") @@ -50,13 +44,18 @@ async def cancel_test(callback: CallbackQuery, state: FSMContext): """Отменить тест""" await state.clear() await callback.message.delete() - await callback.message.answer("❌ Тест отменён") + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = (user.language_interface if user else 'ru') or 'ru' + await callback.message.answer(t(lang, 'level_test.cancelled')) await callback.answer() @router.callback_query(F.data == "start_test") async def begin_test(callback: CallbackQuery, state: FSMContext): """Начать прохождение теста""" + # Сразу отвечаем на callback, чтобы избежать истечения таймаута + await callback.answer() await callback.message.delete() # Показываем индикатор загрузки @@ -86,7 +85,6 @@ async def begin_test(callback: CallbackQuery, state: FSMContext): # Показываем первый вопрос await show_question(callback.message, state) - await callback.answer() async def show_question(message: Message, state: FSMContext): @@ -103,10 +101,14 @@ async def show_question(message: Message, state: FSMContext): question = questions[current_idx] # Формируем текст вопроса + # Язык интерфейса + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + lang = (user.language_interface if user else 'ru') or 'ru' + text = ( - f"❓ Вопрос {current_idx + 1} из {len(questions)}\n\n" + t(lang, 'level_test.q_header', i=current_idx + 1, n=len(questions)) + "\n\n" + f"{question['question']}\n" - f"{question.get('question_ru', '')}\n\n" ) # Создаем кнопки с вариантами ответа @@ -120,10 +122,52 @@ async def show_question(message: Message, state: FSMContext): ) ]) + # Кнопка для показа перевода вопроса (локализованная) + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.chat.id) + from utils.i18n import t + lang = (user.language_interface if user else 'ru') or 'ru' + keyboard.append([ + InlineKeyboardButton(text=t(lang, 'level_test.show_translation_btn'), callback_data=f"show_qtr_{current_idx}") + ]) + reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard) await message.answer(text, reply_markup=reply_markup) +@router.callback_query(F.data.startswith("show_qtr_"), LevelTestStates.taking_test) +async def show_question_translation(callback: CallbackQuery, state: FSMContext): + """Показать перевод текущего вопроса""" + try: + idx = int(callback.data.split("_")[-1]) + except Exception: + await callback.answer("Перевод недоступен", show_alert=True) + return + + data = await state.get_data() + questions = data.get('questions', []) + if not (0 <= idx < len(questions)): + await callback.answer("Перевод недоступен", show_alert=True) + return + + ru = questions[idx].get('question_ru') or "Перевод недоступен" + + # Вставляем перевод в текущий текст сообщения + orig = callback.message.text or "" + marker = "Перевод вопроса:" + if marker in orig: + await callback.answer("Перевод уже показан") + return + + new_text = f"{orig}\n{marker} {ru}" + try: + await callback.message.edit_text(new_text, reply_markup=callback.message.reply_markup) + except Exception: + # Запасной путь, если редактирование невозможно + await callback.message.answer(f"{marker} {ru}") + await callback.answer() + + @router.callback_query(F.data.startswith("answer_"), LevelTestStates.taking_test) async def process_answer(callback: CallbackQuery, state: FSMContext): """Обработать ответ на вопрос""" diff --git a/bot/handlers/practice.py b/bot/handlers/practice.py index d730932..8c020ce 100644 --- a/bot/handlers/practice.py +++ b/bot/handlers/practice.py @@ -7,6 +7,7 @@ from aiogram.fsm.state import State, StatesGroup from database.db import async_session_maker from services.user_service import UserService from services.ai_service import ai_service +from utils.i18n import t router = Router() @@ -19,12 +20,12 @@ class PracticeStates(StatesGroup): # Доступные сценарии SCENARIOS = { - "restaurant": "🍽️ Ресторан", - "shopping": "🛍️ Магазин", - "travel": "✈️ Путешествие", - "work": "💼 Работа", - "doctor": "🏥 Врач", - "casual": "💬 Общение" + "restaurant": {"ru": "🍽️ Ресторан", "en": "🍽️ Restaurant", "ja": "🍽️ レストラン"}, + "shopping": {"ru": "🛍️ Магазин", "en": "🛍️ Shopping", "ja": "🛍️ ショッピング"}, + "travel": {"ru": "✈️ Путешествие","en": "✈️ Travel", "ja": "✈️ 旅行"}, + "work": {"ru": "💼 Работа", "en": "💼 Work", "ja": "💼 仕事"}, + "doctor": {"ru": "🏥 Врач", "en": "🏥 Doctor", "ja": "🏥 医者"}, + "casual": {"ru": "💬 Общение", "en": "💬 Casual", "ja": "💬 会話"} } @@ -35,15 +36,16 @@ async def cmd_practice(message: Message, state: FSMContext): user = await UserService.get_user_by_telegram_id(session, message.from_user.id) if not user: - await message.answer("Сначала запусти бота командой /start") + await message.answer(t('ru', 'common.start_first')) return # Показываем выбор сценария keyboard = [] - for scenario_id, scenario_name in SCENARIOS.items(): + lang = user.language_interface or 'ru' + for scenario_id, names in SCENARIOS.items(): keyboard.append([ InlineKeyboardButton( - text=scenario_name, + text=names.get(lang, names.get('ru')), callback_data=f"scenario_{scenario_id}" ) ]) @@ -53,33 +55,36 @@ async def cmd_practice(message: Message, state: FSMContext): await state.update_data(user_id=user.id, level=user.level.value) await state.set_state(PracticeStates.choosing_scenario) - await message.answer( - "💬 Диалоговая практика с AI\n\n" - "Выбери сценарий для разговора:\n\n" - "• AI будет играть роль собеседника\n" - "• Ты можешь общаться на английском\n" - "• AI будет исправлять твои ошибки\n" - "• Используй /stop для завершения диалога\n\n" - "Выбери сценарий:", - reply_markup=reply_markup - ) + await message.answer(t(user.language_interface or 'ru', 'practice.start_text'), reply_markup=reply_markup) @router.callback_query(F.data.startswith("scenario_"), PracticeStates.choosing_scenario) async def start_scenario(callback: CallbackQuery, state: FSMContext): """Начать диалог с выбранным сценарием""" + # Отвечаем сразу на callback, дальнейшие операции могут занять время + await callback.answer() scenario = callback.data.split("_")[1] 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, callback.from_user.id) + ui_lang = (user.language_interface if user else 'ru') or 'ru' + learn_lang = (user.learning_language if user else 'en') or 'en' # Удаляем клавиатуру await callback.message.edit_reply_markup(reply_markup=None) # Показываем индикатор - thinking_msg = await callback.message.answer("🤔 AI готовится к диалогу...") + thinking_msg = await callback.message.answer(t(ui_lang, 'practice.thinking_prepare')) # Начинаем диалог - conversation_start = await ai_service.start_conversation(scenario, level) + conversation_start = await ai_service.start_conversation( + scenario, + level, + learning_lang=learn_lang, + translation_lang=ui_lang + ) await thinking_msg.delete() @@ -92,28 +97,30 @@ async def start_scenario(callback: CallbackQuery, state: FSMContext): ) await state.set_state(PracticeStates.in_conversation) - # Формируем сообщение + # Формируем сообщение (перевод скрыт, доступен по кнопке) text = ( - f"{SCENARIOS[scenario]}\n\n" + f"{SCENARIOS[scenario].get(ui_lang, SCENARIOS[scenario]['ru'])}\n\n" f"📝 {conversation_start.get('context', '')}\n\n" - f"AI: {conversation_start.get('message', '')}\n" - f"({conversation_start.get('translation', '')})\n\n" - "💡 Подсказки:\n" + f"AI: {conversation_start.get('message', '')}\n\n" + f"{t(ui_lang, 'practice.hints')}\n" ) for suggestion in conversation_start.get('suggestions', []): text += f"• {suggestion}\n" - text += "\n📝 Напиши свой ответ на английском или используй /stop для завершения" + text += t(ui_lang, 'practice.write_or_stop') + + # Сохраняем перевод под индексом 0 + translations = {0: conversation_start.get('translation', '')} + await state.update_data(translations=translations) # Кнопки управления keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="💡 Показать подсказки", callback_data="show_hints")], - [InlineKeyboardButton(text="🔚 Завершить диалог", callback_data="stop_practice")] + [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 callback.message.answer(text, reply_markup=keyboard) - await callback.answer() @router.message(Command("stop"), PracticeStates.in_conversation) @@ -123,12 +130,16 @@ async def stop_practice(message: Message, state: FSMContext): message_count = data.get('message_count', 0) await state.clear() - await message.answer( - f"✅ Диалог завершён!\n\n" - f"Сообщений обменено: {message_count}\n\n" - "Отличная работа! Продолжай практиковаться.\n" - "Используй /practice для нового диалога." + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + lang = (user.language_interface if user else 'ru') or 'ru' + end_text = ( + t(lang, 'practice.end_title') + "\n\n" + + t(lang, 'practice.end_exchanged', n=message_count) + "\n\n" + + t(lang, 'practice.end_keep') + "\n" + + t(lang, 'practice.end_hint') ) + await message.answer(end_text) @router.callback_query(F.data == "stop_practice", PracticeStates.in_conversation) @@ -140,12 +151,16 @@ async def stop_practice_callback(callback: CallbackQuery, state: FSMContext): await callback.message.delete() await state.clear() - await callback.message.answer( - f"✅ Диалог завершён!\n\n" - f"Сообщений обменено: {message_count}\n\n" - "Отличная работа! Продолжай практиковаться.\n" - "Используй /practice для нового диалога." + 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' + end_text = ( + t(lang, 'practice.end_title') + "\n\n" + + t(lang, 'practice.end_exchanged', n=message_count) + "\n\n" + + t(lang, 'practice.end_keep') + "\n" + + t(lang, 'practice.end_hint') ) + await callback.message.answer(end_text) await callback.answer() @@ -155,7 +170,9 @@ async def handle_conversation(message: Message, state: FSMContext): user_message = message.text.strip() if not user_message: - await message.answer("Напиши что-нибудь на английском или используй /stop для завершения") + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + await message.answer(t((user.language_interface if user else 'ru') or 'ru', 'practice.empty_prompt')) return data = await state.get_data() @@ -165,7 +182,7 @@ async def handle_conversation(message: Message, state: FSMContext): message_count = data.get('message_count', 0) # Показываем индикатор - thinking_msg = await message.answer("🤔 AI думает...") + thinking_msg = await message.answer(t((user2.language_interface if user2 else 'ru') or 'ru', 'practice.thinking')) # Добавляем сообщение пользователя в историю conversation_history.append({ @@ -173,12 +190,20 @@ async def handle_conversation(message: Message, state: FSMContext): "content": user_message }) + # Определяем языки пользователя для ответа + async with async_session_maker() as session: + user2 = await UserService.get_user_by_telegram_id(session, message.from_user.id) + ui_lang2 = (user2.language_interface if user2 else 'ru') or 'ru' + learn_lang2 = (user2.learning_language if user2 else 'en') or 'en' + # Получаем ответ от AI ai_response = await ai_service.continue_conversation( conversation_history=conversation_history, user_message=user_message, scenario=scenario, - level=level + level=level, + learning_lang=learn_lang2, + translation_lang=ui_lang2 ) await thinking_msg.delete() @@ -196,33 +221,76 @@ async def handle_conversation(message: Message, state: FSMContext): message_count=message_count ) - # Формируем ответ + # Формируем ответ (перевод скрыт, доступен по кнопке) + # Язык пользователя для текста text = "" # Показываем feedback, если есть ошибки feedback = ai_response.get('feedback', {}) if feedback.get('has_errors') and feedback.get('corrections'): - text += f"⚠️ Исправления:\n{feedback['corrections']}\n\n" + text += f"⚠️ {t(ui_lang2, 'practice.corrections')}\n{feedback['corrections']}\n\n" if feedback.get('comment'): text += f"💬 {feedback['comment']}\n\n" # Ответ AI text += ( - f"AI: {ai_response.get('response', '')}\n" - f"({ai_response.get('translation', '')})\n\n" + f"AI: {ai_response.get('response', '')}\n\n" ) # Подсказки suggestions = ai_response.get('suggestions', []) if suggestions: - text += "💡 Подсказки:\n" + text += t(ui_lang2, 'practice.hints') + "\n" for suggestion in suggestions[:3]: text += f"• {suggestion}\n" + # Сохраняем перевод под новым индексом + translations = data.get('translations', {}) or {} + this_idx = message_count # после инкремента это текущий номер сообщения AI + translations[this_idx] = ai_response.get('translation', '') + await state.update_data(translations=translations) + # Кнопки keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🔚 Завершить диалог", callback_data="stop_practice")] + [InlineKeyboardButton(text=t(lang2, 'practice.show_translation_btn'), callback_data=f"show_tr_{this_idx}")], + [InlineKeyboardButton(text=t(lang2, 'practice.stop_btn'), callback_data="stop_practice")] ]) await message.answer(text, reply_markup=keyboard) + + +@router.callback_query(F.data.startswith("show_tr_"), PracticeStates.in_conversation) +async def show_translation(callback: CallbackQuery, state: FSMContext): + """Показать перевод для конкретного сообщения AI""" + try: + idx = int(callback.data.split("_")[-1]) + except Exception: + await callback.answer(t((user.language_interface if user else 'ru') or 'ru', 'practice.translation_unavailable'), show_alert=True) + return + + data = await state.get_data() + translations = data.get('translations', {}) or {} + tr_text = translations.get(idx) + if not tr_text: + await callback.answer(t((user.language_interface if user else 'ru') or 'ru', 'practice.translation_unavailable'), show_alert=True) + return + + # Вставляем перевод в существующее сообщение + orig = callback.message.text or "" + # Определяем язык + 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' + marker = t(lang, 'common.translation') + ":" + if marker in orig: + await callback.answer(t(lang, 'practice.translation_already')) + return + + new_text = f"{orig}\n{marker} {tr_text}" + try: + await callback.message.edit_text(new_text, reply_markup=callback.message.reply_markup) + except Exception: + # Если не удалось отредактировать (например, старое сообщение), отправим отдельным сообщением как запасной вариант + await callback.message.answer(f"{marker} {tr_text}") + await callback.answer() diff --git a/bot/handlers/reminder.py b/bot/handlers/reminder.py index 8cafb4d..4c49582 100644 --- a/bot/handlers/reminder.py +++ b/bot/handlers/reminder.py @@ -6,6 +6,7 @@ from aiogram.fsm.state import State, StatesGroup from database.db import async_session_maker from services.user_service import UserService +from utils.i18n import t router = Router() @@ -22,19 +23,19 @@ async def cmd_reminder(message: Message): user = await UserService.get_user_by_telegram_id(session, message.from_user.id) if not user: - await message.answer("Сначала запусти бота командой /start") + await message.answer(t('ru', 'common.start_first')) return - # Формируем текст - status = "✅ Включены" if user.reminders_enabled else "❌ Выключены" - time_text = user.daily_task_time if user.daily_task_time else "Не установлено" + lang = (user.language_interface if user else 'ru') or 'ru' + status = t(lang, 'reminder.status_on') if user.reminders_enabled else t(lang, 'reminder.status_off') + time_text = user.daily_task_time if user.daily_task_time else t(lang, 'reminder.time_not_set') text = ( - f"⏰ Напоминания\n\n" - f"Статус: {status}\n" - f"Время: {time_text} UTC\n\n" - f"Напоминания помогут не забывать о ежедневной практике.\n" - f"Бот будет присылать сообщение в выбранное время каждый день." + t(lang, 'reminder.title') + "\n\n" + + t(lang, 'reminder.status_line', status=status) + "\n" + + t(lang, 'reminder.time_line', time=time_text) + "\n\n" + + t(lang, 'reminder.desc1') + "\n" + + t(lang, 'reminder.desc2') ) # Создаем кнопки @@ -42,15 +43,15 @@ async def cmd_reminder(message: Message): if user.reminders_enabled: keyboard.append([ - InlineKeyboardButton(text="❌ Выключить", callback_data="reminder_disable") + InlineKeyboardButton(text=t(lang, 'reminder.btn_disable'), callback_data="reminder_disable") ]) else: keyboard.append([ - InlineKeyboardButton(text="✅ Включить", callback_data="reminder_enable") + InlineKeyboardButton(text=t(lang, 'reminder.btn_enable'), callback_data="reminder_enable") ]) keyboard.append([ - InlineKeyboardButton(text="⏰ Изменить время", callback_data="reminder_set_time") + InlineKeyboardButton(text=t(lang, 'reminder.btn_change_time'), callback_data="reminder_set_time") ]) reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -65,7 +66,7 @@ async def enable_reminders(callback: CallbackQuery): if not user.daily_task_time: await callback.answer( - "Сначала установи время напоминаний!", + t(user.language_interface or 'ru', 'reminder.set_time_first'), show_alert=True ) return @@ -73,11 +74,12 @@ async def enable_reminders(callback: CallbackQuery): user.reminders_enabled = True await session.commit() - await callback.answer("✅ Напоминания включены!") + lang = (user.language_interface if user else 'ru') or 'ru' + await callback.answer(t(lang, 'reminder.enabled_toast')) await callback.message.edit_text( - f"✅ Напоминания включены!\n\n" - f"Время: {user.daily_task_time} UTC\n\n" - f"Ты будешь получать ежедневные напоминания о практике." + t(lang, 'reminder.enabled_title') + "\n\n" + + t(lang, 'reminder.time_line', time=user.daily_task_time) + "\n\n" + + t(lang, 'reminder.enabled_desc') ) @@ -90,10 +92,11 @@ async def disable_reminders(callback: CallbackQuery): user.reminders_enabled = False await session.commit() - await callback.answer("❌ Напоминания выключены") + lang = (user.language_interface if user else 'ru') or 'ru' + await callback.answer(t(lang, 'reminder.disabled_toast')) await callback.message.edit_text( - "❌ Напоминания выключены\n\n" - "Используй /reminder чтобы включить их снова." + t(lang, 'reminder.disabled_title') + "\n\n" + + t(lang, 'reminder.disabled_desc') ) @@ -102,16 +105,15 @@ async def set_reminder_time_prompt(callback: CallbackQuery, state: FSMContext): """Запросить время для напоминаний""" await state.set_state(ReminderStates.waiting_for_time) + 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.edit_text( - "⏰ Установка времени напоминаний\n\n" - "Отправь время в формате HH:MM (UTC)\n\n" - "Примеры:\n" - "• 09:00 - 9 утра по UTC\n" - "• 18:30 - 18:30 по UTC\n" - "• 20:00 - 8 вечера по UTC\n\n" - "💡 UTC = МСК - 3 часа\n" - "(если хочешь 12:00 по МСК, введи 09:00)\n\n" - "Отправь /cancel для отмены" + t(lang, 'reminder.set_title') + "\n\n" + + t(lang, 'reminder.set_desc') + "\n\n" + + t(lang, 'reminder.set_examples') + "\n\n" + + t(lang, 'reminder.set_utc_hint') + "\n\n" + + t(lang, 'reminder.cancel_hint') ) await callback.answer() @@ -120,7 +122,7 @@ async def set_reminder_time_prompt(callback: CallbackQuery, state: FSMContext): async def cancel_set_time(message: Message, state: FSMContext): """Отменить установку времени""" await state.clear() - await message.answer("❌ Установка времени отменена") + await message.answer(t('ru', 'reminder.cancelled')) @router.message(ReminderStates.waiting_for_time) @@ -143,11 +145,7 @@ async def process_reminder_time(message: Message, state: FSMContext): formatted_time = f"{hour:02d}:{minute:02d}" except: - await message.answer( - "❌ Неверный формат времени!\n\n" - "Используй формат HH:MM (например, 09:00 или 18:30)\n" - "Или отправь /cancel для отмены" - ) + await message.answer(t('ru', 'reminder.invalid_format')) return # Сохраняем время @@ -163,10 +161,13 @@ async def process_reminder_time(message: Message, state: FSMContext): await state.clear() + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + lang = (user.language_interface if user else 'ru') or 'ru' await message.answer( - f"✅ Время установлено!\n\n" - f"Напоминания: {formatted_time} UTC\n" - f"Статус: Включены\n\n" - f"Ты будешь получать ежедневные напоминания о практике.\n" - f"Используй /reminder для изменения настроек." + t(lang, 'reminder.time_set_title') + "\n\n" + + t(lang, 'reminder.time_line', time=formatted_time) + "\n" + + t(lang, 'reminder.status_on_line') + "\n\n" + + t(lang, 'reminder.enabled_desc') + "\n" + + t(lang, 'reminder.use_settings') ) diff --git a/bot/handlers/settings.py b/bot/handlers/settings.py index 66fa7c7..a7ef57c 100644 --- a/bot/handlers/settings.py +++ b/bot/handlers/settings.py @@ -5,60 +5,130 @@ from aiogram.fsm.context import FSMContext from database.db import async_session_maker from database.models import LanguageLevel +from bot.handlers.start import main_menu_keyboard from services.user_service import UserService router = Router() +def _is_en(user) -> bool: + try: + return (getattr(user, 'language_interface', 'ru') or 'ru') == 'en' + except Exception: + return False + + +def _is_ja(user) -> bool: + try: + return (getattr(user, 'language_interface', 'ru') or 'ru') == 'ja' + except Exception: + return False + + def get_settings_keyboard(user) -> InlineKeyboardMarkup: """Создать клавиатуру настроек""" + is_en = _is_en(user) + is_ja = _is_ja(user) keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton( - text=f"📊 Уровень: {user.level.value}", + text=( + "📊 Level: " if is_en else ("📊 レベル: " if is_ja else "📊 Уровень: ") + ) + f"{user.level.value}", callback_data="settings_level" )], [InlineKeyboardButton( - text=f"🌐 Язык интерфейса: {'🇷🇺 Русский' if user.language_interface == 'ru' else '🇬🇧 English'}", + text=( + "🎯 Learning language: " if is_en else ("🎯 学習言語: " if is_ja else "🎯 Язык изучения: ") + ) + (user.learning_language.upper()), + callback_data="settings_learning" + )], + [InlineKeyboardButton( + text=( + "🌐 Interface language: " if is_en else ("🌐 インターフェース言語: " if is_ja else "🌐 Язык интерфейса: ") + ) + ("🇬🇧 English" if getattr(user, 'language_interface', 'ru') == 'en' else ("🇯🇵 日本語" if getattr(user, 'language_interface', 'ru') == 'ja' else "🇷🇺 Русский")), callback_data="settings_language" )], [InlineKeyboardButton( - text="❌ Закрыть", + text=("❌ Close" if is_en else ("❌ 閉じる" if is_ja else "❌ Закрыть")), callback_data="settings_close" )] ]) return keyboard -def get_level_keyboard() -> InlineKeyboardMarkup: +def get_level_keyboard(user=None) -> InlineKeyboardMarkup: """Клавиатура выбора уровня""" - 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"), - ] + lang = getattr(user, 'language_interface', 'ru') if user is not None else 'ru' + if lang == 'en': + levels = [ + ("A1 - Beginner", "set_level_A1"), + ("A2 - Elementary", "set_level_A2"), + ("B1 - Intermediate", "set_level_B1"), + ("B2 - Upper-intermediate", "set_level_B2"), + ("C1 - Advanced", "set_level_C1"), + ("C2 - Proficient", "set_level_C2"), + ] + elif lang == 'ja': + levels = [ + ("A1 - 初級", "set_level_A1"), + ("A2 - 初級(上)", "set_level_A2"), + ("B1 - 中級", "set_level_B1"), + ("B2 - 中級(上)", "set_level_B2"), + ("C1 - 上級", "set_level_C1"), + ("C2 - ネイティブ", "set_level_C2"), + ] + else: + levels = [ + ("A1 - Начальный", "set_level_A1"), + ("A2 - Элементарный", "set_level_A2"), + ("B1 - Средний", "set_level_B1"), + ("B2 - Выше среднего", "set_level_B2"), + ("C1 - Продвинутый", "set_level_C1"), + ("C2 - Профессиональный", "set_level_C2"), + ] keyboard = [] for level_name, callback_data in levels: keyboard.append([InlineKeyboardButton(text=level_name, callback_data=callback_data)]) - keyboard.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="settings_back")]) + back_label = "⬅️ Back" if lang == 'en' else ("⬅️ 戻る" if lang == 'ja' else "⬅️ Назад") + keyboard.append([InlineKeyboardButton(text=back_label, callback_data="settings_back")]) return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_language_keyboard() -> InlineKeyboardMarkup: +def get_language_keyboard(user=None) -> InlineKeyboardMarkup: """Клавиатура выбора языка интерфейса""" + lang = getattr(user, 'language_interface', 'ru') if user is not None else 'ru' + back = "⬅️ Back" if lang == 'en' else ("⬅️ 戻る" if lang == 'ja' else "⬅️ Назад") keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="🇷🇺 Русский", callback_data="set_lang_ru")], - [InlineKeyboardButton(text="🇬🇧 English (скоро)", callback_data="set_lang_en")], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="settings_back")] + [InlineKeyboardButton(text="🇬🇧 English", callback_data="set_lang_en")], + [InlineKeyboardButton(text="🇯🇵 日本語", callback_data="set_lang_ja")], + [InlineKeyboardButton(text=back, callback_data="settings_back")] ]) return keyboard +def get_learning_language_keyboard(user=None) -> InlineKeyboardMarkup: + """Клавиатура выбора языка изучения""" + lang = getattr(user, 'language_interface', 'ru') if user is not None else 'ru' + back = "⬅️ Back" if lang == 'en' else ("⬅️ 戻る" if lang == 'ja' else "⬅️ Назад") + + # Пары (код -> подпись) + options = [ + ("en", "🇬🇧 English" if lang != 'ja' else "🇬🇧 英語"), + ("es", "🇪🇸 Spanish" if lang == 'en' else ("🇪🇸 スペイン語" if lang == 'ja' else "🇪🇸 Испанский")), + ("de", "🇩🇪 German" if lang == 'en' else ("🇩🇪 ドイツ語" if lang == 'ja' else "🇩🇪 Немецкий")), + ("fr", "🇫🇷 French" if lang == 'en' else ("🇫🇷 フランス語" if lang == 'ja' else "🇫🇷 Французский")), + ("ja", "🇯🇵 Japanese" if lang == 'en' else ("🇯🇵 日本語" if lang == 'ja' else "🇯🇵 Японский")), + ] + + keyboard = [[InlineKeyboardButton(text=label, callback_data=f"set_learning_{code}")] for code, label in options] + keyboard.append([InlineKeyboardButton(text=back, callback_data="settings_back")]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + @router.message(Command("settings")) async def cmd_settings(message: Message): """Обработчик команды /settings""" @@ -69,12 +139,13 @@ async def cmd_settings(message: Message): await message.answer("Сначала запусти бота командой /start") return - settings_text = ( - "⚙️ Настройки\n\n" - f"📊 Уровень английского: {user.level.value}\n" - f"🌐 Язык интерфейса: {'Русский' if user.language_interface == 'ru' else 'English'}\n\n" - "Выбери, что хочешь изменить:" - ) + lang = getattr(user, 'language_interface', 'ru') + title = "⚙️ Settings\n\n" if lang == 'en' else ("⚙️ 設定\n\n" if lang == 'ja' else "⚙️ Настройки\n\n") + level_label = "📊 English level: " if lang == 'en' else ("📊 英語レベル: " if lang == 'ja' else "📊 Уровень английского: ") + lang_label = "🌐 Interface language: " if lang == 'en' else ("🌐 インターフェース言語: " if lang == 'ja' else "🌐 Язык интерфейса: ") + lang_value = 'English' if lang == 'en' else ('日本語' if lang == 'ja' else ('Русский' if user.language_interface == 'ru' else 'English')) + footer = "Choose what to change:" if lang == 'en' else ("変更したい項目を選択:" if lang == 'ja' else "Выбери, что хочешь изменить:") + settings_text = f"{title}{level_label}{user.level.value}\n{lang_label}{lang_value}\n\n{footer}" await message.answer(settings_text, reply_markup=get_settings_keyboard(user)) @@ -82,14 +153,53 @@ async def cmd_settings(message: Message): @router.callback_query(F.data == "settings_level") async def settings_level(callback: CallbackQuery): """Показать выбор уровня""" - await callback.message.edit_text( - "📊 Выбери свой уровень английского:\n\n" - "A1-A2 - Начинающий\n" - "B1-B2 - Средний\n" - "C1-C2 - Продвинутый\n\n" - "Это влияет на сложность предлагаемых слов и заданий.", - reply_markup=get_level_keyboard() - ) + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = getattr(user, 'language_interface', 'ru') if user else 'ru' + title = "📊 Choose your English level:\n\n" if lang == 'en' else ("📊 英語レベルを選択:\n\n" if lang == 'ja' else "📊 Выбери свой уровень английского:\n\n") + body = ( + "A1-A2 - Beginner\nB1-B2 - Intermediate\nC1-C2 - Advanced\n\n" if lang == 'en' else ( + "A1-A2 - 初級\nB1-B2 - 中級\nC1-C2 - 上級\n\n" if lang == 'ja' else + "A1-A2 - Начинающий\nB1-B2 - Средний\nC1-C2 - Продвинутый\n\n" + )) + tail = "This affects difficulty of suggested words and tasks." if lang == 'en' else ("これは提案される単語や課題の難易度に影響します。" if lang == 'ja' else "Это влияет на сложность предлагаемых слов и заданий.") + await callback.message.edit_text(title + body + tail, reply_markup=get_level_keyboard(user)) + await callback.answer() + + +@router.callback_query(F.data == "settings_learning") +async def settings_learning(callback: CallbackQuery): + """Показать выбор языка изучения""" + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = getattr(user, 'language_interface', 'ru') if user else 'ru' + title = "🎯 Select learning language:\n\n" if lang == 'en' else ("🎯 学習言語を選択:\n\n" if lang == 'ja' else "🎯 Выбери язык изучения:\n\n") + await callback.message.edit_text(title, reply_markup=get_learning_language_keyboard(user)) + await callback.answer() + + +@router.callback_query(F.data.startswith("set_learning_")) +async def set_learning_language(callback: CallbackQuery): + """Установить язык изучения""" + code = callback.data.split("_")[-1] + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + if user: + await UserService.update_user_learning_language(session, user.id, code) + lang = getattr(user, 'language_interface', 'ru') + if lang == 'en': + text = f"✅ Learning language: {code.upper()}" + back = "⬅️ Back to settings" + elif lang == 'ja': + text = f"✅ 学習言語: {code.upper()}" + back = "⬅️ 設定に戻る" + else: + text = f"✅ Язык изучения: {code.upper()}" + back = "⬅️ К настройкам" + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=back, callback_data="settings_back")]]) + ) await callback.answer() @@ -105,12 +215,13 @@ async def set_level(callback: CallbackQuery): # Обновляем уровень await UserService.update_user_level(session, user.id, LanguageLevel[level_str]) + lang = getattr(user, 'language_interface', 'ru') + changed = "✅ Level changed to " if lang == 'en' else ("✅ レベルが変更されました: " if lang == 'ja' else "✅ Уровень изменен на ") + msg = changed + f"{level_str}\n\n" + ("You will now receive words and tasks matching your level!" if lang == 'en' else ("これからレベルに合った単語と課題が出題されます!" if lang == 'ja' else "Теперь ты будешь получать слова и задания, соответствующие твоему уровню!")) + back = "⬅️ Back to settings" if lang == 'en' else ("⬅️ 設定に戻る" if lang == 'ja' else "⬅️ К настройкам") await callback.message.edit_text( - f"✅ Уровень изменен на {level_str}\n\n" - "Теперь ты будешь получать слова и задания, соответствующие твоему уровню!", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="⬅️ К настройкам", callback_data="settings_back")] - ]) + msg, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=back, callback_data="settings_back")]]) ) await callback.answer() @@ -119,10 +230,14 @@ async def set_level(callback: CallbackQuery): @router.callback_query(F.data == "settings_language") async def settings_language(callback: CallbackQuery): """Показать выбор языка""" + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = getattr(user, 'language_interface', 'ru') if user else 'ru' + title = "🌐 Select interface language:\n\n" if lang == 'en' else ("🌐 インターフェース言語を選択:\n\n" if lang == 'ja' else "🌐 Выбери язык интерфейса:\n\n") + desc = "This will change the language of bot messages." if lang == 'en' else ("ボットの表示言語が変更されます。" if lang == 'ja' else "Это изменит язык всех сообщений бота.") await callback.message.edit_text( - "🌐 Выбери язык интерфейса:\n\n" - "Это изменит язык всех сообщений бота.", - reply_markup=get_language_keyboard() + title + desc, + reply_markup=get_language_keyboard(user) ) await callback.answer() @@ -130,24 +245,29 @@ async def settings_language(callback: CallbackQuery): @router.callback_query(F.data.startswith("set_lang_")) async def set_language(callback: CallbackQuery): """Установить язык""" - lang = callback.data.split("_")[-1] # ru или en - - if lang == "en": - await callback.answer("Английский интерфейс скоро будет доступен! 🚧", show_alert=True) - return + lang = callback.data.split("_")[-1] # ru | en | ja async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) if user: await UserService.update_user_language(session, user.id, lang) - + if lang == 'en': + text = "✅ Interface language: English" + back = "⬅️ Back" + elif lang == 'ja': + text = "✅ インターフェース言語: 日本語" + back = "⬅️ 戻る" + else: + text = "✅ Язык интерфейса: Русский" + back = "⬅️ К настройкам" await callback.message.edit_text( - f"✅ Язык интерфейса: {'Русский' if lang == 'ru' else 'English'}", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="⬅️ К настройкам", callback_data="settings_back")] - ]) + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=back, callback_data="settings_back")]]) ) + # Обновляем клавиатуру чата на выбранный язык + menu_updated = "Main menu updated ⤵️" if lang == 'en' else ("メインメニューを更新しました ⤵️" if lang == 'ja' else "Клавиатура обновлена ⤵️") + await callback.message.answer(menu_updated, reply_markup=main_menu_keyboard(lang)) await callback.answer() @@ -159,12 +279,13 @@ async def settings_back(callback: CallbackQuery): user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) if user: - settings_text = ( - "⚙️ Настройки\n\n" - f"📊 Уровень английского: {user.level.value}\n" - f"🌐 Язык интерфейса: {'Русский' if user.language_interface == 'ru' else 'English'}\n\n" - "Выбери, что хочешь изменить:" - ) + lang = getattr(user, 'language_interface', 'ru') + title = "⚙️ Settings\n\n" if lang == 'en' else ("⚙️ 設定\n\n" if lang == 'ja' else "⚙️ Настройки\n\n") + level_label = "📊 English level: " if lang == 'en' else ("📊 英語レベル: " if lang == 'ja' else "📊 Уровень английского: ") + lang_label = "🌐 Interface language: " if lang == 'en' else ("🌐 インターフェース言語: " if lang == 'ja' else "🌐 Язык интерфейса: ") + lang_value = 'English' if user.language_interface == 'en' else ('日本語' if user.language_interface == 'ja' else 'Русский') + footer = "Choose what to change:" if lang == 'en' else ("変更したい項目を選択:" if lang == 'ja' else "Выбери, что хочешь изменить:") + settings_text = f"{title}{level_label}{user.level.value}\n{lang_label}{lang_value}\n\n{footer}" await callback.message.edit_text(settings_text, reply_markup=get_settings_keyboard(user)) diff --git a/bot/handlers/start.py b/bot/handlers/start.py index dd7861f..a72f9b8 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -12,40 +12,30 @@ from aiogram.fsm.context import FSMContext from database.db import async_session_maker from services.user_service import UserService +from utils.i18n import t router = Router() -# Тексты кнопок главного меню -BTN_ADD = "➕ Добавить слово" -BTN_VOCAB = "📚 Словарь" -BTN_TASK = "🧠 Задание" -BTN_PRACTICE = "💬 Практика" -BTN_WORDS = "🎯 Тематические слова" -BTN_IMPORT = "📖 Импорт из текста" -BTN_STATS = "📊 Статистика" -BTN_SETTINGS = "⚙️ Настройки" - - -def main_menu_keyboard() -> ReplyKeyboardMarkup: +def main_menu_keyboard(lang: str = 'ru') -> ReplyKeyboardMarkup: """Клавиатура с основными командами (кнопки отправляют команды).""" return ReplyKeyboardMarkup( resize_keyboard=True, keyboard=[ [ - KeyboardButton(text=BTN_ADD), - KeyboardButton(text=BTN_VOCAB), + KeyboardButton(text=t(lang, "menu.add")), + KeyboardButton(text=t(lang, "menu.vocab")), ], [ - KeyboardButton(text=BTN_TASK), - KeyboardButton(text=BTN_PRACTICE), + KeyboardButton(text=t(lang, "menu.task")), + KeyboardButton(text=t(lang, "menu.practice")), ], [ - KeyboardButton(text=BTN_WORDS), - KeyboardButton(text=BTN_IMPORT), + KeyboardButton(text=t(lang, "menu.words")), + KeyboardButton(text=t(lang, "menu.import")), ], [ - KeyboardButton(text=BTN_STATS), - KeyboardButton(text=BTN_SETTINGS), + KeyboardButton(text=t(lang, "menu.stats")), + KeyboardButton(text=t(lang, "menu.settings")), ], ], ) @@ -66,129 +56,112 @@ async def cmd_start(message: Message, state: FSMContext): username=message.from_user.username ) + lang = (user.language_interface or 'ru') + if is_new_user: # Новый пользователь await message.answer( - f"👋 Привет, {message.from_user.first_name}!\n\n" - f"Я бот для изучения английского языка. Помогу тебе:\n" - f"📚 Пополнять словарный запас (ручное/тематическое/из текста)\n" - f"✍️ Выполнять интерактивные задания\n" - f"💬 Практиковать язык в диалоге с AI\n" - f"📊 Отслеживать свой прогресс\n\n" - f"Команды:\n" - f"• /add [слово] - добавить слово\n" - f"• /words [тема] - тематическая подборка\n" - f"• /import - импорт из текста\n" - f"• /vocabulary - мой словарь\n" - f"• /task - задания\n" - f"• /practice - диалог с AI\n" - f"• /stats - статистика\n" - f"• /settings - настройки\n" - f"• /reminder - напоминания\n" - f"• /help - полная справка", - reply_markup=main_menu_keyboard(), + t(lang, "start.new_intro", first_name=message.from_user.first_name), + reply_markup=main_menu_keyboard(lang), ) # Предлагаем пройти тест уровня keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="📊 Пройти тест уровня", callback_data="offer_level_test")], - [InlineKeyboardButton(text="➡️ Пропустить", callback_data="skip_level_test")] + [InlineKeyboardButton(text=t(lang, 'start.offer_btn'), callback_data="offer_level_test")], + [InlineKeyboardButton(text=t(lang, 'start.skip_btn'), callback_data="skip_level_test")] ]) - await message.answer( - "🎯 Определим твой уровень?\n\n" - "Короткий тест (7 вопросов) поможет подобрать задания под твой уровень.\n" - "Это займёт 2-3 минуты.\n\n" - "Или можешь пропустить и установить уровень вручную позже в /settings", - reply_markup=keyboard - ) + await message.answer(t(lang, "start.offer_test"), reply_markup=keyboard) else: # Существующий пользователь await message.answer( - f"С возвращением, {message.from_user.first_name}! 👋\n\n" - f"Готов продолжить обучение?\n\n" - f"Быстрый доступ:\n" - f"• /vocabulary - посмотреть словарь\n" - f"• /task - получить задание\n" - f"• /practice - практика диалога\n" - f"• /words [тема] - тематическая подборка\n" - f"• /stats - статистика\n" - f"• /help - все команды", - reply_markup=main_menu_keyboard(), + t(lang, "start.return", first_name=message.from_user.first_name), + reply_markup=main_menu_keyboard(lang), ) @router.message(Command("menu")) async def cmd_menu(message: Message): """Показать клавиатуру с основными командами.""" - await message.answer("Главное меню доступно ниже ⤵️", reply_markup=main_menu_keyboard()) + # Определяем язык пользователя + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + lang = (user.language_interface if user else 'ru') or 'ru' + await message.answer(t(lang, "menu.below"), reply_markup=main_menu_keyboard(lang)) # Обработчики кнопок главного меню (по тексту) -@router.message(F.text == BTN_ADD) +def _menu_match(key: str): + labels = {t('ru', key), t('en', key), t('ja', key)} + return lambda m: m.text in labels + + +@router.message(_menu_match('menu.add')) async def btn_add_pressed(message: Message, state: FSMContext): from bot.handlers.vocabulary import AddWordStates - await message.answer( - "Отправь слово, которое хочешь добавить:\n" - "Например: /add elephant\n\n" - "Или просто отправь слово без команды!" - ) + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + lang = (user.language_interface if user else 'ru') or 'ru' + await message.answer(t(lang, 'add.prompt')) await state.set_state(AddWordStates.waiting_for_word) -@router.message(F.text == BTN_VOCAB) +@router.message(_menu_match('menu.vocab')) async def btn_vocab_pressed(message: Message): from bot.handlers.vocabulary import cmd_vocabulary await cmd_vocabulary(message) -@router.message(F.text == BTN_TASK) +@router.message(_menu_match('menu.task')) async def btn_task_pressed(message: Message, state: FSMContext): from bot.handlers.tasks import cmd_task await cmd_task(message, state) -@router.message(F.text == BTN_PRACTICE) +@router.message(_menu_match('menu.practice')) async def btn_practice_pressed(message: Message, state: FSMContext): from bot.handlers.practice import cmd_practice await cmd_practice(message, state) -@router.message(F.text == BTN_IMPORT) +@router.message(_menu_match('menu.import')) async def btn_import_pressed(message: Message, state: FSMContext): from bot.handlers.import_text import cmd_import await cmd_import(message, state) -@router.message(F.text == BTN_STATS) +@router.message(_menu_match('menu.stats')) async def btn_stats_pressed(message: Message): from bot.handlers.tasks import cmd_stats await cmd_stats(message) -@router.message(F.text == BTN_SETTINGS) +@router.message(_menu_match('menu.settings')) async def btn_settings_pressed(message: Message): from bot.handlers.settings import cmd_settings await cmd_settings(message) -@router.message(F.text == BTN_WORDS) +@router.message(_menu_match('menu.words')) async def btn_words_pressed(message: Message, state: FSMContext): """Подсказать про тематические слова и показать быстрые темы.""" from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + lang = (user.language_interface if user else 'ru') or 'ru' text = ( - "📚 Тематические подборки слов\n\n" - "Используй: /words [тема]\n\n" - "Популярные темы:" + t(lang, 'words.help_title') + "\n\n" + + t(lang, 'words.help_usage') + "\n\n" + + t(lang, 'words.popular') ) keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="✈️ Путешествия", callback_data="menu_theme_travel")], - [InlineKeyboardButton(text="🍔 Еда", callback_data="menu_theme_food")], - [InlineKeyboardButton(text="💼 Работа", callback_data="menu_theme_work")], - [InlineKeyboardButton(text="🌿 Природа", callback_data="menu_theme_nature")], - [InlineKeyboardButton(text="💻 Технологии", callback_data="menu_theme_technology")], + [InlineKeyboardButton(text=t(lang, 'words.topic_travel'), callback_data="menu_theme_travel")], + [InlineKeyboardButton(text=t(lang, 'words.topic_food'), callback_data="menu_theme_food")], + [InlineKeyboardButton(text=t(lang, 'words.topic_work'), callback_data="menu_theme_work")], + [InlineKeyboardButton(text=t(lang, 'words.topic_nature'), callback_data="menu_theme_nature")], + [InlineKeyboardButton(text=t(lang, 'words.topic_technology'), callback_data="menu_theme_technology")], ]) await message.answer(text, reply_markup=keyboard) @@ -201,21 +174,29 @@ async def pick_theme_from_menu(callback: CallbackQuery, state: FSMContext): from services.ai_service import ai_service from bot.handlers.words import show_words_list, WordsStates + # Сразу отвечаем на callback, чтобы избежать таймаута + await callback.answer() theme = callback.data.split("menu_theme_")[-1] 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.answer("Сначала /start", show_alert=True) + await callback.answer(t('ru', 'common.start_first'), show_alert=True) return - generating = await callback.message.answer(f"🔄 Генерирую подборку слов по теме '{theme}'...") - words = await ai_service.generate_thematic_words(theme=theme, level=user.level.value, count=10) + lang = (user.language_interface or 'ru') + generating = await callback.message.answer(t(lang, 'words.generating', theme=theme)) + words = await ai_service.generate_thematic_words( + theme=theme, + level=user.level.value, + count=10, + learning_lang=user.learning_language, + translation_lang=user.language_interface, + ) await generating.delete() if not words: - await callback.message.answer("❌ Не удалось сгенерировать подборку. Попробуй позже.") - await callback.answer() + await callback.message.answer(t(lang, 'words.generate_failed')) return # Сохраняем в состояние как в /words @@ -223,30 +204,16 @@ async def pick_theme_from_menu(callback: CallbackQuery, state: FSMContext): await state.set_state(WordsStates.viewing_words) await show_words_list(callback.message, words, theme) - await callback.answer() @router.message(Command("help")) async def cmd_help(message: Message): """Обработчик команды /help""" - await message.answer( - "📖 Справка по командам:\n\n" - "Управление словарём:\n" - "• /add [слово] - добавить слово в словарь\n" - "• /vocabulary - просмотр словаря\n" - "• /words [тема] - тематическая подборка слов\n" - "• /import - импортировать слова из текста\n\n" - "Обучение:\n" - "• /task - задание (перевод, заполнение пропусков)\n" - "• /practice - диалог с ИИ (6 сценариев)\n" - "• /level_test - тест определения уровня\n\n" - "Статистика:\n" - "• /stats - твой прогресс\n\n" - "Настройки:\n" - "• /settings - уровень и язык\n" - "• /reminder - ежедневные напоминания\n\n" - "💡 Ты также можешь просто отправить мне слово, и я предложу добавить его в словарь!" - ) + # Определяем язык пользователя + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + lang = (user.language_interface if user else 'ru') or 'ru' + await message.answer(t(lang, "start.help")) @router.callback_query(F.data == "offer_level_test") @@ -261,13 +228,8 @@ async def offer_level_test_callback(callback: CallbackQuery, state: FSMContext): @router.callback_query(F.data == "skip_level_test") async def skip_level_test_callback(callback: CallbackQuery): """Пропустить тест уровня""" - await callback.message.edit_text( - "✅ Хорошо!\n\n" - "Ты можешь пройти тест позже командой /level_test\n" - "или установить уровень вручную в /settings\n\n" - "Давай начнём! Попробуй:\n" - "• /words travel - тематическая подборка\n" - "• /practice - диалог с AI\n" - "• /add hello - добавить слово" - ) + 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.edit_text(t(lang, 'start.skip_msg')) await callback.answer() diff --git a/bot/handlers/tasks.py b/bot/handlers/tasks.py index 0402974..0626726 100644 --- a/bot/handlers/tasks.py +++ b/bot/handlers/tasks.py @@ -8,6 +8,7 @@ from database.db import async_session_maker from services.user_service import UserService from services.task_service import TaskService from services.ai_service import ai_service +from utils.i18n import t router = Router() @@ -25,17 +26,18 @@ async def cmd_task(message: Message, state: FSMContext): user = await UserService.get_user_by_telegram_id(session, message.from_user.id) if not user: - await message.answer("Сначала запусти бота командой /start") + await message.answer(t('ru', 'common.start_first')) return # Генерируем задания разных типов - tasks = await TaskService.generate_mixed_tasks(session, user.id, count=5) + tasks = await TaskService.generate_mixed_tasks( + session, user.id, count=5, + learning_lang=user.learning_language, + translation_lang=user.language_interface, + ) if not tasks: - await message.answer( - "📚 У тебя пока нет слов для практики!\n\n" - "Добавь несколько слов командой /add, а затем возвращайся." - ) + await message.answer(t(user.level.value and (user.language_interface or 'ru') or 'ru', 'tasks.no_words')) return # Сохраняем задания в состоянии @@ -64,15 +66,20 @@ async def show_current_task(message: Message, state: FSMContext): task = tasks[current_index] + # Определяем язык пользователя + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + lang = (user.language_interface if user else 'ru') or 'ru' + task_text = ( - f"📝 Задание {current_index + 1} из {len(tasks)}\n\n" + t(lang, 'tasks.header', i=current_index + 1, n=len(tasks)) + "\n\n" + f"{task['question']}\n" ) if task.get('transcription'): task_text += f"🔊 [{task['transcription']}]\n" - task_text += f"\n💡 Напиши свой ответ:" + task_text += t(lang, 'tasks.write_answer') await state.set_state(TaskStates.waiting_for_answer) await message.answer(task_text) @@ -92,7 +99,12 @@ async def process_answer(message: Message, state: FSMContext): task = tasks[current_index] # Показываем индикатор проверки - checking_msg = await message.answer("⏳ Проверяю ответ...") + # Язык пользователя + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + lang = (user.language_interface if user else 'ru') or 'ru' + + checking_msg = await message.answer(t(lang, 'tasks.checking')) # Проверяем ответ через AI check_result = await ai_service.check_answer( @@ -108,13 +120,13 @@ async def process_answer(message: Message, state: FSMContext): # Формируем ответ if is_correct: - result_text = f"✅ Правильно!\n\n" + result_text = t(lang, 'tasks.correct') + "\n\n" correct_count += 1 else: - result_text = f"❌ Неправильно\n\n" + result_text = t(lang, 'tasks.incorrect') + "\n\n" - result_text += f"Твой ответ: {user_answer}\n" - result_text += f"Правильный ответ: {task['correct_answer']}\n\n" + result_text += f"{t(lang, 'tasks.your_answer')}: {user_answer}\n" + result_text += f"{t(lang, 'tasks.right_answer')}: {task['correct_answer']}\n\n" if feedback: result_text += f"💬 {feedback}\n\n" @@ -151,8 +163,8 @@ async def process_answer(message: Message, state: FSMContext): # Показываем результат и кнопку "Далее" keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="➡️ Следующее задание", callback_data="next_task")], - [InlineKeyboardButton(text="🔚 Завершить", callback_data="stop_tasks")] + [InlineKeyboardButton(text=t(lang, 'tasks.next_btn'), callback_data="next_task")], + [InlineKeyboardButton(text=t(lang, 'tasks.stop_btn'), callback_data="stop_tasks")] ]) await message.answer(result_text, reply_markup=keyboard) @@ -173,7 +185,10 @@ async def stop_tasks_callback(callback: CallbackQuery, state: FSMContext): """Остановить выполнение заданий через кнопку""" await state.clear() await callback.message.edit_reply_markup(reply_markup=None) - await callback.message.answer("Задания завершены. Используй /task, чтобы начать заново.") + 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, 'tasks.finished')) await callback.answer() @@ -182,7 +197,10 @@ async def stop_tasks_callback(callback: CallbackQuery, state: FSMContext): async def stop_tasks(message: Message, state: FSMContext): """Остановить выполнение заданий командой /stop""" await state.clear() - await message.answer("Задания остановлены. Используй /task, чтобы начать заново.") + # Определяем язык пользователя + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + await message.answer(t((user.language_interface if user else 'ru') or 'ru', 'tasks.stopped')) @router.message(Command("cancel"), TaskStates.doing_tasks) @@ -190,7 +208,10 @@ async def stop_tasks(message: Message, state: FSMContext): async def cancel_tasks(message: Message, state: FSMContext): """Отмена выполнения заданий командой /cancel""" await state.clear() - await message.answer("Отменено. Можешь вернуться к заданиям командой /task.") + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + lang = (user.language_interface if user else 'ru') or 'ru' + await message.answer(t(lang, 'tasks.cancelled')) async def finish_tasks(message: Message, state: FSMContext): @@ -205,24 +226,29 @@ async def finish_tasks(message: Message, state: FSMContext): # Определяем эмодзи на основе результата if accuracy >= 90: emoji = "🏆" - comment = "Отличный результат!" + comment_key = 'excellent' elif accuracy >= 70: emoji = "👍" - comment = "Хорошая работа!" + comment_key = 'good' elif accuracy >= 50: emoji = "📚" - comment = "Неплохо, продолжай практиковаться!" + comment_key = 'average' else: emoji = "💪" - comment = "Повтори эти слова еще раз!" + comment_key = 'poor' + + # Язык пользователя + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + lang = (user.language_interface if user else 'ru') or 'ru' result_text = ( - f"{emoji} Задание завершено!\n\n" - f"Правильных ответов: {correct_count} из {total_count}\n" - f"Точность: {accuracy}%\n\n" - f"{comment}\n\n" - f"Используй /task для нового задания\n" - f"Используй /stats для просмотра статистики" + t(lang, 'tasks.finish_title', emoji=emoji) + "\n\n" + + t(lang, 'tasks.correct_of', correct=correct_count, total=total_count) + "\n" + + t(lang, 'tasks.accuracy', accuracy=accuracy) + "\n\n" + + t(lang, f"tasks.comment.{comment_key}") + "\n\n" + + t(lang, 'tasks.use_task') + "\n" + + t(lang, 'tasks.use_stats') ) await state.clear() @@ -236,26 +262,27 @@ async def cmd_stats(message: Message): user = await UserService.get_user_by_telegram_id(session, message.from_user.id) if not user: - await message.answer("Сначала запусти бота командой /start") + await message.answer(t('ru', 'common.start_first')) return # Получаем статистику stats = await TaskService.get_user_stats(session, user.id) + lang = (user.language_interface if user else 'ru') or 'ru' stats_text = ( - f"📊 Твоя статистика\n\n" - f"📚 Слов в словаре: {stats['total_words']}\n" - f"📖 Слов изучено: {stats['reviewed_words']}\n" - f"✍️ Заданий выполнено: {stats['total_tasks']}\n" - f"✅ Правильных ответов: {stats['correct_tasks']}\n" - f"🎯 Точность: {stats['accuracy']}%\n\n" + t(lang, 'stats.header') + "\n\n" + + t(lang, 'stats.total_words', n=stats['total_words']) + "\n" + + t(lang, 'stats.studied_words', n=stats['reviewed_words']) + "\n" + + t(lang, 'stats.total_tasks', n=stats['total_tasks']) + "\n" + + t(lang, 'stats.correct_tasks', n=stats['correct_tasks']) + "\n" + + t(lang, 'stats.accuracy', n=stats['accuracy']) + "\n\n" ) if stats['total_words'] == 0: - stats_text += "Добавь слова командой /add чтобы начать обучение!" + stats_text += t(lang, 'stats.hint_add_words') elif stats['total_tasks'] == 0: - stats_text += "Выполни первое задание командой /task!" + stats_text += t(lang, 'stats.hint_first_task') else: - stats_text += "Продолжай практиковаться! 💪" + stats_text += t(lang, 'stats.hint_keep_practice') await message.answer(stats_text) diff --git a/bot/handlers/vocabulary.py b/bot/handlers/vocabulary.py index b833530..2ef6072 100644 --- a/bot/handlers/vocabulary.py +++ b/bot/handlers/vocabulary.py @@ -9,6 +9,7 @@ from database.models import WordSource from services.user_service import UserService from services.vocabulary_service import VocabularyService from services.ai_service import ai_service +from utils.i18n import t router = Router() @@ -26,11 +27,10 @@ async def cmd_add(message: Message, state: FSMContext): parts = message.text.split(maxsplit=1) if len(parts) < 2: - await message.answer( - "Отправь слово, которое хочешь добавить:\n" - "Например: /add elephant\n\n" - "Или просто отправь слово без команды!" - ) + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + lang = (user.language_interface if user else 'ru') or 'ru' + await message.answer(t(lang, 'add.prompt')) await state.set_state(AddWordStates.waiting_for_word) return @@ -52,7 +52,7 @@ async def process_word_addition(message: Message, state: FSMContext, word: str): user = await UserService.get_user_by_telegram_id(session, message.from_user.id) if not user: - await message.answer("Сначала запусти бота командой /start") + await message.answer(t('ru', 'common.start_first')) return # Проверяем, есть ли уже такое слово @@ -66,10 +66,17 @@ async def process_word_addition(message: Message, state: FSMContext, word: str): return # Показываем индикатор загрузки - processing_msg = await message.answer("⏳ Ищу перевод и примеры...") + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + lang = (user.language_interface if user else 'ru') or 'ru' + processing_msg = await message.answer(t(lang, 'add.searching')) # Получаем перевод через AI - word_data = await ai_service.translate_word(word) + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + source_lang = user.learning_language if user else 'en' + ui_lang = user.language_interface if user else 'ru' + word_data = await ai_service.translate_word(word, source_lang=source_lang, translation_lang=ui_lang) # Удаляем сообщение о загрузке await processing_msg.delete() @@ -77,26 +84,28 @@ async def process_word_addition(message: Message, state: FSMContext, word: str): # Формируем примеры examples_text = "" if word_data.get("examples"): - examples_text = "\n\nПримеры:\n" + examples_text = "\n\n" + t(lang, 'add.examples_header') + "\n" for idx, example in enumerate(word_data["examples"][:2], 1): - examples_text += f"{idx}. {example['en']}\n {example['ru']}\n" + src = example.get(source_lang) or example.get('en') or example.get('ru') or '' + tr = example.get(ui_lang) or example.get('ru') or example.get('en') or '' + examples_text += f"{idx}. {src}\n {tr}\n" # Отправляем карточку слова card_text = ( f"📝 {word_data['word']}\n" f"🔊 [{word_data.get('transcription', '')}]\n\n" - f"🇷🇺 {word_data['translation']}\n" - f"📂 Категория: {word_data.get('category', 'общая')}\n" - f"📊 Уровень: {word_data.get('difficulty', 'A1')}" + f"{t(lang, 'add.translation_label')}: {word_data['translation']}\n" + f"{t(lang, 'add.category_label')}: {word_data.get('category', '')}\n" + f"{t(lang, 'add.level_label')}: {word_data.get('difficulty', 'A1')}" f"{examples_text}\n\n" - f"Добавить это слово в словарь?" + f"{t(lang, 'add.confirm_question')}" ) # Создаём inline-кнопки keyboard = InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="✅ Добавить", callback_data=f"add_word_confirm"), - InlineKeyboardButton(text="❌ Отмена", callback_data="add_word_cancel") + InlineKeyboardButton(text=t(lang, 'add.btn_add'), callback_data=f"add_word_confirm"), + InlineKeyboardButton(text=t(lang, 'add.btn_cancel'), callback_data="add_word_cancel") ] ]) @@ -110,6 +119,8 @@ async def process_word_addition(message: Message, state: FSMContext, word: str): @router.callback_query(F.data == "add_word_confirm", AddWordStates.waiting_for_confirmation) async def confirm_add_word(callback: CallbackQuery, state: FSMContext): """Подтверждение добавления слова""" + # Отвечаем сразу, запись в БД и подсчёт могут занять время + await callback.answer() data = await state.get_data() word_data = data.get("word_data") user_id = data.get("user_id") @@ -121,6 +132,8 @@ async def confirm_add_word(callback: CallbackQuery, state: FSMContext): user_id=user_id, word_original=word_data["word"], word_translation=word_data["translation"], + source_lang=source_lang, + translation_lang=ui_lang, transcription=word_data.get("transcription"), examples={"examples": word_data.get("examples", [])}, category=word_data.get("category"), @@ -129,22 +142,26 @@ async def confirm_add_word(callback: CallbackQuery, state: FSMContext): ) # Получаем общее количество слов - words_count = await VocabularyService.get_words_count(session, user_id) + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + words_count = await VocabularyService.get_words_count(session, user_id, learning_lang=user.learning_language) + 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.edit_text( - f"✅ Слово '{word_data['word']}' добавлено в твой словарь!\n\n" - f"Всего слов в словаре: {words_count}\n\n" - f"Продолжай добавлять новые слова или используй /task для практики!" + t(lang, 'add.added_success', word=word_data['word'], count=words_count) ) await state.clear() - await callback.answer() @router.callback_query(F.data == "add_word_cancel") async def cancel_add_word(callback: CallbackQuery, state: FSMContext): """Отмена добавления слова""" - await callback.message.edit_text("Отменено. Можешь добавить другое слово командой /add") + 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.edit_text(t(lang, 'add.cancelled')) await state.clear() await callback.answer() @@ -156,27 +173,26 @@ async def cmd_vocabulary(message: Message): user = await UserService.get_user_by_telegram_id(session, message.from_user.id) if not user: - await message.answer("Сначала запусти бота командой /start") + await message.answer(t('ru', 'common.start_first')) return # Получаем слова пользователя - words = await VocabularyService.get_user_words(session, user.id, limit=10) - total_count = await VocabularyService.get_words_count(session, user.id) + words = await VocabularyService.get_user_words(session, user.id, limit=10, learning_lang=user.learning_language) + total_count = await VocabularyService.get_words_count(session, user.id, learning_lang=user.learning_language) if not words: - await message.answer( - "📚 Твой словарь пока пуст!\n\n" - "Добавь первое слово командой /add или просто отправь мне слово." - ) + lang = (user.language_interface if user else 'ru') or 'ru' + await message.answer(t(lang, 'vocab.empty')) return # Формируем список слов - words_list = "📚 Твой словарь:\n\n" + lang = (user.language_interface if user else 'ru') or 'ru' + words_list = t(lang, 'vocab.header') + "\n\n" for idx, word in enumerate(words, 1): progress = "" if word.times_reviewed > 0: accuracy = int((word.correct_answers / word.times_reviewed) * 100) - progress = f" ({accuracy}% точность)" + progress = " " + t(lang, 'vocab.accuracy_inline', n=accuracy) words_list += ( f"{idx}. {word.word_original} — {word.word_translation}\n" @@ -184,8 +200,8 @@ async def cmd_vocabulary(message: Message): ) if total_count > 10: - words_list += f"\nПоказаны последние 10 из {total_count} слов" + words_list += "\n" + t(lang, 'vocab.shown_last', n=total_count) else: - words_list += f"\nВсего слов: {total_count}" + words_list += "\n" + t(lang, 'vocab.total', n=total_count) await message.answer(words_list) diff --git a/bot/handlers/words.py b/bot/handlers/words.py index d4a6e54..8fa6ad0 100644 --- a/bot/handlers/words.py +++ b/bot/handlers/words.py @@ -9,6 +9,7 @@ from database.models import WordSource from services.user_service import UserService from services.vocabulary_service import VocabularyService from services.ai_service import ai_service +from utils.i18n import t router = Router() @@ -25,44 +26,42 @@ async def cmd_words(message: Message, state: FSMContext): user = await UserService.get_user_by_telegram_id(session, message.from_user.id) if not user: - await message.answer("Сначала запусти бота командой /start") + await message.answer(t('ru', 'common.start_first')) return # Извлекаем тему из команды command_parts = message.text.split(maxsplit=1) if len(command_parts) < 2: - await message.answer( - "📚 Тематические подборки слов\n\n" - "Используй: /words [тема]\n\n" - "Примеры:\n" - "• /words travel - путешествия\n" - "• /words food - еда\n" - "• /words work - работа\n" - "• /words nature - природа\n" - "• /words technology - технологии\n\n" - "Я сгенерирую 10 слов по теме, подходящих для твоего уровня!" + lang = user.language_interface or 'ru' + help_text = ( + t(lang, 'words.help_title') + "\n\n" + + t(lang, 'words.help_usage') + "\n\n" + + t(lang, 'words.help_examples') + "\n\n" + + t(lang, 'words.help_note') ) + await message.answer(help_text) return theme = command_parts[1].strip() # Показываем индикатор генерации - generating_msg = await message.answer(f"🔄 Генерирую подборку слов по теме '{theme}'...") + lang = user.language_interface or 'ru' + generating_msg = await message.answer(t(lang, 'words.generating', theme=theme)) # Генерируем слова через AI words = await ai_service.generate_thematic_words( theme=theme, level=user.level.value, - count=10 + count=10, + learning_lang=user.learning_language, + translation_lang=user.language_interface, ) await generating_msg.delete() if not words: - await message.answer( - "❌ Не удалось сгенерировать подборку. Попробуй другую тему или повтори позже." - ) + await message.answer(t(lang, 'words.generate_failed')) return # Сохраняем данные в состоянии @@ -81,7 +80,12 @@ async def cmd_words(message: Message, state: FSMContext): async def show_words_list(message: Message, words: list, theme: str): """Показать список слов с кнопками для добавления""" - text = f"📚 Подборка слов: {theme}\n\n" + # Определяем язык интерфейса для заголовка/подсказок + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + lang = (user.language_interface if user else 'ru') or 'ru' + + text = t(lang, 'words.header', theme=theme) + "\n\n" for idx, word_data in enumerate(words, 1): text += ( @@ -91,7 +95,7 @@ async def show_words_list(message: Message, words: list, theme: str): f" {word_data.get('example', '')}\n\n" ) - text += "Выбери слова, которые хочешь добавить в словарь:" + text += t(lang, 'words.choose') # Создаем кнопки для каждого слова (по 2 в ряд) keyboard = [] @@ -109,12 +113,12 @@ async def show_words_list(message: Message, words: list, theme: str): # Кнопка "Добавить все" keyboard.append([ - InlineKeyboardButton(text="✅ Добавить все", callback_data="add_all_words") + InlineKeyboardButton(text=t(lang, 'words.add_all_btn'), callback_data="add_all_words") ]) # Кнопка "Закрыть" keyboard.append([ - InlineKeyboardButton(text="❌ Закрыть", callback_data="close_words") + InlineKeyboardButton(text=t(lang, 'words.close_btn'), callback_data="close_words") ]) reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -124,6 +128,8 @@ async def show_words_list(message: Message, words: list, theme: str): @router.callback_query(F.data.startswith("add_word_"), WordsStates.viewing_words) async def add_single_word(callback: CallbackQuery, state: FSMContext): """Добавить одно слово из подборки""" + # Отвечаем сразу, операция может занять время + await callback.answer() word_index = int(callback.data.split("_")[2]) data = await state.get_data() @@ -131,40 +137,60 @@ async def add_single_word(callback: CallbackQuery, state: FSMContext): user_id = data.get('user_id') if word_index >= len(words): - 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.answer(t(lang, 'words.err_not_found')) return word_data = words[word_index] async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) # Проверяем, нет ли уже такого слова existing = await VocabularyService.get_word_by_original( session, user_id, word_data['word'] ) if existing: - await callback.answer(f"Слово '{word_data['word']}' уже в словаре", show_alert=True) + 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.answer(t(lang, 'words.already_exists', word=word_data['word']), show_alert=True) return # Добавляем слово + # Формируем examples с учётом языков + learn = user.learning_language if user else 'en' + ui = user.language_interface if user else 'ru' + ex = word_data.get('example') + examples = ([{learn: ex, ui: ''}] if ex else []) + await VocabularyService.add_word( session=session, user_id=user_id, word_original=word_data['word'], word_translation=word_data['translation'], + source_lang=user.learning_language if user else None, + translation_lang=user.language_interface if user else None, transcription=word_data.get('transcription'), - examples=[{"en": word_data.get('example', ''), "ru": ""}] if word_data.get('example') else [], + examples=examples, source=WordSource.SUGGESTED, category=data.get('theme', 'general'), difficulty_level=data.get('level') ) - await callback.answer(f"✅ Слово '{word_data['word']}' добавлено в словарь") + 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, 'words.added_single', word=word_data['word'])) @router.callback_query(F.data == "add_all_words", WordsStates.viewing_words) async def add_all_words(callback: CallbackQuery, state: FSMContext): """Добавить все слова из подборки""" + # Сразу отвечаем на callback, так как добавление может занять время + await callback.answer() data = await state.get_data() words = data.get('words', []) user_id = data.get('user_id') @@ -174,6 +200,7 @@ async def add_all_words(callback: CallbackQuery, state: FSMContext): skipped_count = 0 async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) for word_data in words: # Проверяем, нет ли уже такого слова existing = await VocabularyService.get_word_by_original( @@ -185,13 +212,20 @@ async def add_all_words(callback: CallbackQuery, state: FSMContext): continue # Добавляем слово + learn = user.learning_language if user else 'en' + ui = user.language_interface if user else 'ru' + ex = word_data.get('example') + examples = ([{learn: ex, ui: ''}] if ex else []) + await VocabularyService.add_word( session=session, user_id=user_id, word_original=word_data['word'], word_translation=word_data['translation'], + source_lang=user.learning_language if user else None, + translation_lang=user.language_interface if user else None, transcription=word_data.get('transcription'), - examples=[{"en": word_data.get('example', ''), "ru": ""}] if word_data.get('example') else [], + examples=examples, source=WordSource.SUGGESTED, category=theme, difficulty_level=data.get('level') @@ -205,7 +239,6 @@ async def add_all_words(callback: CallbackQuery, state: FSMContext): await callback.message.edit_reply_markup(reply_markup=None) await callback.message.answer(result_text) await state.clear() - await callback.answer() @router.callback_query(F.data == "close_words", WordsStates.viewing_words) diff --git a/database/models.py b/database/models.py index ea420c6..a5d19f7 100644 --- a/database/models.py +++ b/database/models.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Optional -from sqlalchemy import String, BigInteger, DateTime, Integer, Boolean, JSON, Enum as SQLEnum +from sqlalchemy import String, BigInteger, DateTime, Integer, Boolean, JSON, Enum as SQLEnum, UniqueConstraint from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column import enum @@ -52,11 +52,16 @@ class User(Base): class Vocabulary(Base): """Модель словарного запаса""" __tablename__ = "vocabulary" + __table_args__ = ( + UniqueConstraint("user_id", "source_lang", "word_original", name="uq_vocab_user_lang_word"), + ) id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) word_original: Mapped[str] = mapped_column(String(255), nullable=False) word_translation: Mapped[str] = mapped_column(String(255), nullable=False) + source_lang: Mapped[Optional[str]] = mapped_column(String(5)) # ISO2 языка слова (язык изучения) + translation_lang: Mapped[Optional[str]] = mapped_column(String(5)) # ISO2 языка перевода (обычно язык интерфейса) transcription: Mapped[Optional[str]] = mapped_column(String(255)) examples: Mapped[Optional[dict]] = mapped_column(JSON) # JSON массив примеров category: Mapped[Optional[str]] = mapped_column(String(100)) diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..40621fb --- /dev/null +++ b/locales/en.json @@ -0,0 +1,183 @@ +{ + "menu": { + "add": "➕ Add word", + "vocab": "📚 Vocabulary", + "task": "🧠 Task", + "practice": "💬 Practice", + "words": "🎯 Thematic words", + "import": "📖 Import from text", + "stats": "📊 Stats", + "settings": "⚙️ Settings", + "below": "Main menu below ⤵️" + }, + "common": { + "start_first": "First run /start to register", + "translation": "Translation" + }, + "import": { + "title": "📖 Import words from text", + "desc": "Send me text in your learning language, and I will extract useful words to study.", + "can_send": "You may send:\n• A passage from a book or article\n• A song lyric\n• A description\n• Any interesting text", + "cancel_hint": "Send /cancel to abort.", + "too_short": "⚠️ Text is too short. Send at least 50 characters.\nOr use /cancel to abort.", + "too_long": "⚠️ Text is too long (max 3000 chars).\nSend a shorter text or use /cancel to abort.", + "processing": "🔄 Analyzing text and extracting words...", + "failed": "❌ Failed to extract words. Try another text or later.", + "found_header": "📚 Found words: {n}", + "added_single": "✅ Word '{word}' added to vocabulary", + "added_count": "✅ Added words: {n}", + "skipped_count": "⚠️ Skipped (already in vocabulary): {n}" + }, + "start": { + "new_intro": "👋 Hi, {first_name}!\n\nI'm a bot to learn English. I will help you to:\n📚 Grow your vocabulary (manual/thematic/from text)\n✍️ Do interactive exercises\n💬 Practice conversation with AI\n📊 Track your progress\n\nCommands:\n• /add [word] - add a word\n• /words [topic] - thematic selection\n• /import - import from text\n• /vocabulary - my dictionary\n• /task - exercises\n• /practice - AI dialogue\n• /stats - statistics\n• /settings - settings\n• /reminder - reminders\n• /help - full help", + "offer_test": "🎯 Shall we determine your level?\n\nA short test (7 questions) will tailor tasks to your level.\nIt takes about 2–3 minutes.\n\nOr skip and set level later in /settings", + "return": "Welcome back, {first_name}! 👋\n\nReady to continue?\n\nQuick access:\n• /vocabulary - view dictionary\n• /task - get an exercise\n• /practice - dialogue practice\n• /words [topic] - thematic words\n• /stats - statistics\n• /help - all commands", + "help": "📖 Commands help:\n\nVocabulary:\n• /add [word] - add to dictionary\n• /vocabulary - view dictionary\n• /words [topic] - thematic words\n• /import - import from text\n\nLearning:\n• /task - exercise (translate, fill gaps)\n• /practice - AI dialogue (6 scenarios)\n• /level_test - level test\n\nStats:\n• /stats - your progress\n\nSettings:\n• /settings - level and language\n• /reminder - daily reminders\n\n💡 You can also just send me a word to add it!", + "offer_btn": "📊 Take level test", + "skip_btn": "➡️ Skip", + "skip_msg": "✅ Okay!\n\nYou can take the test later with /level_test\nor set level manually in /settings\n\nLet's start! Try:\n• /words travel - thematic words\n• /practice - AI dialogue\n• /add hello - add a word" + }, + "add": { + "prompt": "Send the word you want to add:\nFor example: /add elephant\n\nOr just send the word without a command!", + "searching": "⏳ Looking up translation and examples...", + "examples_header": "Examples:", + "translation_label": "Translation", + "category_label": "Category", + "level_label": "Level", + "confirm_question": "Add this word to your vocabulary?", + "btn_add": "✅ Add", + "btn_cancel": "❌ Cancel", + "exists": "The word '{word}' is already in your vocabulary!\nTranslation: {translation}", + "added_success": "✅ Word '{word}' added!\n\nTotal words in vocabulary: {count}\n\nKeep adding new words or use /task to practice!", + "cancelled": "Cancelled. You can add another word with /add" + }, + "vocab": { + "empty": "📚 Your vocabulary is empty!\n\nAdd your first word with /add or just send me a word.", + "header": "📚 Your vocabulary:", + "accuracy_inline": "({n}% accuracy)", + "shown_last": "Showing last 10 of {n} words", + "total": "Total words: {n}" + }, + "practice": { + "start_text": "💬 Dialogue practice with AI\n\nChoose a scenario:\n\n• AI will play a role\n• You can chat in English\n• AI will correct your mistakes\n• Use /stop to finish\n\nPick a scenario:", + "hints": "💡 Hints:", + "write_or_stop": "\n📝 Write your answer in English or use /stop to finish", + "show_translation_btn": "👁️ Show translation", + "stop_btn": "🔚 End dialogue", + "scenario": { + "restaurant": "🍽️ Restaurant", + "shopping": "🛍️ Shopping", + "travel": "✈️ Travel", + "work": "💼 Work", + "doctor": "🏥 Doctor", + "casual": "💬 Casual" + }, + "thinking_prepare": "🤔 AI is preparing the dialogue...", + "empty_prompt": "Write something in the learning language or use /stop to finish", + "thinking": "🤔 AI is thinking...", + "corrections": "Corrections:", + "end_title": "✅ Dialogue finished!", + "end_exchanged": "Messages exchanged: {n}", + "end_keep": "Great job! Keep practicing.", + "end_hint": "Use /practice to start a new dialogue.", + "translation_unavailable": "Translation unavailable", + "translation_already": "Translation already shown" + }, + "tasks": { + "no_words": "📚 You don't have words to practice yet!\n\nAdd some words with /add and come back.", + "stopped": "Exercises stopped. Use /task to start again.", + "finished": "Exercises finished. Use /task to start again.", + "header": "📝 Task {i} of {n}", + "write_answer": "\n💡 Write your answer:", + "checking": "⏳ Checking answer...", + "correct": "✅ Correct!", + "incorrect": "❌ Incorrect", + "your_answer": "Your answer", + "right_answer": "Right answer", + "next_btn": "➡️ Next task", + "stop_btn": "🔚 Stop", + "cancelled": "Cancelled. You can return to tasks with /task.", + "finish_title": "{emoji} Task finished!", + "correct_of": "Correct answers: {correct} of {total}", + "accuracy": "Accuracy: {accuracy}%", + "use_task": "Use /task to start a new one", + "use_stats": "Use /stats to view statistics", + "comment": { + "excellent": "Excellent result!", + "good": "Good job!", + "average": "Not bad, keep practicing!", + "poor": "Review these words again!" + } + }, + "stats": { + "header": "📊 Your stats", + "total_words": "📚 Words in vocabulary: {n}", + "studied_words": "📖 Words studied: {n}", + "total_tasks": "✍️ Tasks completed: {n}", + "correct_tasks": "✅ Correct answers: {n}", + "accuracy": "🎯 Accuracy: {n}%", + "hint_add_words": "Add words with /add to start learning!", + "hint_first_task": "Do your first task with /task!", + "hint_keep_practice": "Keep practicing! 💪" + }, + "reminder": { + "title": "⏰ Reminders", + "status_on": "✅ Enabled", + "status_off": "❌ Disabled", + "time_not_set": "Not set", + "status_line": "Status: {status}", + "time_line": "Time: {time} UTC", + "desc1": "Reminders help you keep up with daily practice.", + "desc2": "The bot will send a message at the chosen time every day.", + "btn_enable": "✅ Enable", + "btn_disable": "❌ Disable", + "btn_change_time": "⏰ Change time", + "set_time_first": "Please set the reminder time first!", + "enabled_toast": "✅ Reminders enabled!", + "enabled_title": "✅ Reminders enabled!", + "enabled_desc": "You will receive daily practice reminders.", + "disabled_toast": "❌ Reminders disabled", + "disabled_title": "❌ Reminders disabled", + "disabled_desc": "Use /reminder to enable them again.", + "set_title": "⏰ Set reminder time", + "set_desc": "Send time in format HH:MM (UTC)", + "set_examples": "Examples:\n• 09:00 - 9 AM UTC\n• 18:30 - 6:30 PM UTC\n• 20:00 - 8 PM UTC", + "set_utc_hint": "💡 UTC = local offset may apply", + "cancel_hint": "Send /cancel to abort", + "cancelled": "❌ Time setup cancelled", + "invalid_format": "❌ Invalid time format!\n\nUse HH:MM (e.g., 09:00 or 18:30)\nOr send /cancel to abort", + "time_set_title": "✅ Time set!", + "status_on_line": "Status: Enabled", + "use_settings": "Use /reminder to change settings." + }, + "level_test": { + "show_translation_btn": "👁️ Show question translation", + "intro": "📊 Level placement test\n\nThis short test will help determine your English level.\n\n📋 The test has 7 questions:\n• Grammar\n• Vocabulary\n• Comprehension\n\n⏱ Takes about 2–3 minutes\n\nReady to start?", + "start_btn": "✅ Start test", + "cancel_btn": "❌ Cancel", + "press_button": "Press the button when you're ready:", + "cancelled": "❌ Test cancelled", + "q_header": "❓ Question {i} of {n}" + }, + "words": { + "generating": "🔄 Generating words for topic '{theme}'...", + "generate_failed": "❌ Failed to generate words. Please try again later.", + "header": "📚 Word set: {theme}", + "choose": "Choose words to add to your vocabulary:", + "add_all_btn": "✅ Add all", + "close_btn": "❌ Close", + "help_title": "📚 Thematic word sets", + "help_usage": "Use: /words [topic]", + "help_examples": "Examples:\n• /words travel - travel\n• /words food - food\n• /words work - work\n• /words nature - nature\n• /words technology - technology", + "help_note": "I will generate 10 words for the topic tailored to your level!", + "popular": "Popular topics:", + "topic_travel": "✈️ Travel", + "topic_food": "🍔 Food", + "topic_work": "💼 Work", + "topic_nature": "🌿 Nature", + "topic_technology": "💻 Technology", + "err_not_found": "❌ Error: word not found", + "already_exists": "The word '{word}' is already in your vocabulary", + "added_single": "✅ Word '{word}' added to vocabulary" + } +} diff --git a/locales/ja.json b/locales/ja.json new file mode 100644 index 0000000..2b50b29 --- /dev/null +++ b/locales/ja.json @@ -0,0 +1,175 @@ +{ + "menu": { + "add": "➕ 単語を追加", + "vocab": "📚 単語帳", + "task": "🧠 課題", + "practice": "💬 練習", + "words": "🎯 テーマ別単語", + "import": "📖 テキストからインポート", + "stats": "📊 統計", + "settings": "⚙️ 設定", + "below": "メインメニューは下にあります ⤵️" + }, + "common": { + "start_first": "まず /start を実行してください", + "translation": "翻訳" + }, + "import": { + "title": "📖 テキストから単語をインポート", + "desc": "学習言語のテキストを送ってください。学習に役立つ単語を抽出します。", + "can_send": "送れるもの:\n• 本や記事の一節\n• 歌詞\n• 説明文\n• 気になるテキスト", + "cancel_hint": "/cancel で中止できます。", + "too_short": "⚠️ テキストが短すぎます。50文字以上で送ってください。\n/cancel で中止できます。", + "too_long": "⚠️ テキストが長すぎます(最大3000文字)。\n短くして送るか、/cancel を使ってください。", + "processing": "🔄 テキストを分析して単語を抽出しています...", + "failed": "❌ 単語の抽出に失敗しました。別のテキストか、後でもう一度お試しください。", + "found_header": "📚 見つかった単語: {n}", + "added_single": "✅ 単語 '{word}' を単語帳に追加しました", + "added_count": "✅ 追加した単語: {n}", + "skipped_count": "⚠️ スキップ(既に単語帳にあり): {n}" + }, + "start": { + "new_intro": "👋 こんにちは、{first_name} さん!\n\n私は英語学習を手助けするボットです。以下のことができます:\n📚 語彙を増やす(手動/テーマ別/テキストから)\n✍️ インタラクティブ課題に取り組む\n💬 AIとの会話練習\n📊 進捗を記録\n\nコマンド:\n• /add [word] - 単語を追加\n• /words [topic] - テーマ別単語\n• /import - テキストからインポート\n• /vocabulary - 単語帳\n• /task - 課題\n• /practice - 会話練習\n• /stats - 統計\n• /settings - 設定\n• /reminder - リマインダー\n• /help - ヘルプ", + "offer_test": "🎯 レベル診断を行いますか?\n\n短いテスト(7問)であなたのレベルに合った課題を用意します。\n所要時間は約2〜3分です。\n\nまたは /settings から後で設定できます。", + "return": "おかえりなさい、{first_name} さん! 👋\n\n学習を続けましょうか?\n\nクイックアクセス:\n• /vocabulary - 単語帳を見る\n• /task - 課題を受ける\n• /practice - 会話練習\n• /words [topic] - テーマ別単語\n• /stats - 統計\n• /help - すべてのコマンド", + "help": "📖 コマンド一覧:\n\n語彙:\n• /add [word] - 単語を追加\n• /vocabulary - 単語帳\n• /words [topic] - テーマ別単語\n• /import - テキストからインポート\n\n学習:\n• /task - 課題(翻訳/穴埋め など)\n• /practice - AIとの会話(6シナリオ)\n• /level_test - レベル診断\n\n統計:\n• /stats - 進捗状況\n\n設定:\n• /settings - レベルと言語\n• /reminder - 毎日のリマインダー\n\n💡 単語を送るだけでも、追加を提案します!", + "offer_btn": "📊 レベル診断を受ける", + "skip_btn": "➡️ スキップ", + "skip_msg": "✅ わかりました!\n\n/level_test で後からテストを受けるか、/settings でレベルを設定できます。\n\nはじめましょう!おすすめ:\n• /words travel - テーマ別単語\n• /practice - AIとの会話\n• /add hello - 単語を追加" + }, + "add": { + "prompt": "追加したい単語を送ってください:\n例: /add elephant\n\nコマンドなしで単語だけ送ってもOKです!", + "searching": "⏳ 翻訳と例を検索中...", + "examples_header": "例文:", + "translation_label": "翻訳", + "category_label": "カテゴリー", + "level_label": "レベル", + "confirm_question": "この単語を単語帳に追加しますか?", + "btn_add": "✅ 追加", + "btn_cancel": "❌ キャンセル", + "exists": "単語 '{word}' はすでに単語帳にあります!\n翻訳: {translation}", + "added_success": "✅ 単語 '{word}' を追加しました!\n\n単語帳の総数: {count}\n\nさらに追加するか、/task で練習しましょう!", + "cancelled": "キャンセルしました。/add で別の単語を追加できます" + }, + "vocab": { + "empty": "📚 単語帳はまだ空です!\n\n/add で最初の単語を追加するか、単語を直接送ってください。", + "header": "📚 あなたの単語帳:", + "accuracy_inline": "(正答率 {n}%)", + "shown_last": "{n} 語のうち最新の10語を表示", + "total": "合計: {n} 語" + }, + "practice": { + "start_text": "💬 AIとの会話練習\n\nシナリオを選んでください:\n\n• AIが相手役を務めます\n• 英語でやり取りできます\n• 間違いをAIが指摘します\n• 終了するには /stop を使用\n\nシナリオを選択:", + "hints": "💡 ヒント:", + "write_or_stop": "\n📝 英語で返信するか、/stop で終了できます", + "show_translation_btn": "👁️ 翻訳を表示", + "stop_btn": "🔚 会話を終了", + "thinking_prepare": "🤔 AI が会話の準備中...", + "empty_prompt": "学習言語で入力するか、/stop で終了できます", + "thinking": "🤔 AI が考えています...", + "corrections": "修正:", + "end_title": "✅ 会話を終了しました!", + "end_exchanged": "やり取りしたメッセージ数: {n}", + "end_keep": "素晴らしい!練習を続けましょう。", + "end_hint": "/practice で新しい会話を始められます。", + "translation_unavailable": "翻訳は利用できません", + "translation_already": "翻訳はすでに表示されています" + }, + "tasks": { + "no_words": "📚 まだ練習用の単語がありません!\n\n/add で単語を追加してから戻ってきてください。", + "stopped": "課題を停止しました。/task で再開できます。", + "finished": "課題が完了しました。/task で新しく始めましょう。", + "header": "📝 {n}問中 {i} 問目", + "write_answer": "\n💡 回答を入力してください:", + "checking": "⏳ 回答を確認中...", + "correct": "✅ 正解!", + "incorrect": "❌ 不正解", + "your_answer": "あなたの回答", + "right_answer": "正解", + "next_btn": "➡️ 次へ", + "stop_btn": "🔚 停止", + "cancelled": "キャンセルしました。/task で課題に戻れます。", + "finish_title": "{emoji} 課題が終了しました!", + "correct_of": "正解数: {correct} / {total}", + "accuracy": "正答率: {accuracy}%", + "use_task": "/task で新しい課題を開始", + "use_stats": "/stats で統計を表示", + "comment": { + "excellent": "素晴らしい結果です!", + "good": "よくできました!", + "average": "悪くありません。練習を続けましょう!", + "poor": "もう一度見直しましょう!" + } + }, + "stats": { + "header": "📊 統計", + "total_words": "📚 単語帳の単語数: {n}", + "studied_words": "📖 学習済みの単語: {n}", + "total_tasks": "✍️ 完了した課題: {n}", + "correct_tasks": "✅ 正解数: {n}", + "accuracy": "🎯 正答率: {n}%", + "hint_add_words": "/add で単語を追加して学習を始めましょう!", + "hint_first_task": "/task で最初の課題をやってみましょう!", + "hint_keep_practice": "練習を続けましょう! 💪" + }, + "reminder": { + "title": "⏰ リマインダー", + "status_on": "✅ 有効", + "status_off": "❌ 無効", + "time_not_set": "未設定", + "status_line": "ステータス: {status}", + "time_line": "時間: {time} UTC", + "desc1": "リマインダーは毎日の学習を忘れないように役立ちます。", + "desc2": "ボットは毎日、設定した時間にメッセージを送信します。", + "btn_enable": "✅ 有効にする", + "btn_disable": "❌ 無効にする", + "btn_change_time": "⏰ 時間を変更", + "set_time_first": "まずリマインダーの時間を設定してください!", + "enabled_toast": "✅ リマインダーを有効にしました!", + "enabled_title": "✅ リマインダーが有効になりました!", + "enabled_desc": "毎日、練習のリマインダーが届きます。", + "disabled_toast": "❌ リマインダーを無効にしました", + "disabled_title": "❌ リマインダーは無効です", + "disabled_desc": "/reminder で再度有効にできます。", + "set_title": "⏰ リマインダーの時間設定", + "set_desc": "HH:MM(UTC)形式で時間を送ってください", + "set_examples": "例:\n• 09:00 - UTCの午前9時\n• 18:30 - UTCの午後6時30分\n• 20:00 - UTCの午後8時", + "set_utc_hint": "💡 UTC = お住まいのタイムゾーンに合わせて換算してください", + "cancel_hint": "/cancel で中止できます", + "cancelled": "❌ 時間設定を中止しました", + "invalid_format": "❌ 時間の形式が正しくありません!\n\nHH:MM(例: 09:00 / 18:30)形式を使用してください\nまたは /cancel で中止", + "time_set_title": "✅ 時間を設定しました!", + "status_on_line": "ステータス: 有効", + "use_settings": "/reminder で設定を変更できます。" + }, + "level_test": { + "show_translation_btn": "👁️ 質問の翻訳を表示", + "intro": "📊 レベル判定テスト\n\n短いテストで英語レベルを判定します。\n\n📋 全7問:\n• 文法\n• 語彙\n• 読解\n\n⏱ 所要時間は約2〜3分\n\n準備はいいですか?", + "start_btn": "✅ テストを開始", + "cancel_btn": "❌ キャンセル", + "press_button": "準備ができたらボタンを押してください:", + "cancelled": "❌ テストを中止しました", + "q_header": "❓ {n}問中 {i} 問目" + }, + "words": { + "generating": "🔄 テーマ『{theme}』の単語を生成中...", + "generate_failed": "❌ 単語の生成に失敗しました。後でもう一度お試しください。", + "header": "📚 単語セット: {theme}", + "choose": "単語帳に追加する単語を選択してください:", + "add_all_btn": "✅ すべて追加", + "close_btn": "❌ 閉じる", + "help_title": "📚 テーマ別単語", + "help_usage": "使い方: /words [テーマ]", + "help_examples": "例:\n• /words travel - 旅行\n• /words food - 食べ物\n• /words work - 仕事\n• /words nature - 自然\n• /words technology - テクノロジー", + "help_note": "レベルに合わせて10語を生成します!", + "popular": "人気のテーマ:", + "topic_travel": "✈️ 旅行", + "topic_food": "🍔 食べ物", + "topic_work": "💼 仕事", + "topic_nature": "🌿 自然", + "topic_technology": "💻 テクノロジー", + "err_not_found": "❌ エラー: 単語が見つかりません", + "already_exists": "単語 '{word}' はすでに単語帳にあります", + "added_single": "✅ 単語 '{word}' を単語帳に追加しました" + } +} diff --git a/locales/ru.json b/locales/ru.json new file mode 100644 index 0000000..a7ddd3c --- /dev/null +++ b/locales/ru.json @@ -0,0 +1,183 @@ +{ + "menu": { + "add": "➕ Добавить слово", + "vocab": "📚 Словарь", + "task": "🧠 Задание", + "practice": "💬 Практика", + "words": "🎯 Тематические слова", + "import": "📖 Импорт из текста", + "stats": "📊 Статистика", + "settings": "⚙️ Настройки", + "below": "Главное меню доступно ниже ⤵️" + }, + "common": { + "start_first": "Сначала запусти бота командой /start", + "translation": "Перевод" + }, + "import": { + "title": "📖 Импорт слов из текста", + "desc": "Отправь мне текст на выбранном языке обучения, и я извлеку из него полезные слова для изучения.", + "can_send": "Можно отправить:\n• Отрывок из книги или статьи\n• Текст песни\n• Описание чего-либо\n• Любой интересный текст", + "cancel_hint": "Отправь /cancel для отмены.", + "too_short": "⚠️ Текст слишком короткий. Отправь текст минимум из 50 символов.\nИли используй /cancel для отмены.", + "too_long": "⚠️ Текст слишком длинный (максимум 3000 символов).\nОтправь текст покороче или используй /cancel для отмены.", + "processing": "🔄 Анализирую текст и извлекаю слова...", + "failed": "❌ Не удалось извлечь слова из текста. Попробуй другой текст или повтори позже.", + "found_header": "📚 Найдено слов: {n}", + "added_single": "✅ Слово '{word}' добавлено в словарь", + "added_count": "✅ Добавлено слов: {n}", + "skipped_count": "⚠️ Пропущено (уже в словаре): {n}" + }, + "start": { + "new_intro": "👋 Привет, {first_name}!\n\nЯ бот для изучения английского языка. Помогу тебе:\n📚 Пополнять словарный запас (ручное/тематическое/из текста)\n✍️ Выполнять интерактивные задания\n💬 Практиковать язык в диалоге с AI\n📊 Отслеживать свой прогресс\n\nКоманды:\n• /add [слово] - добавить слово\n• /words [тема] - тематическая подборка\n• /import - импорт из текста\n• /vocabulary - мой словарь\n• /task - задания\n• /practice - диалог с AI\n• /stats - статистика\n• /settings - настройки\n• /reminder - напоминания\n• /help - полная справка", + "offer_test": "🎯 Определим твой уровень?\n\nКороткий тест (7 вопросов) поможет подобрать задания под твой уровень.\nЭто займёт 2-3 минуты.\n\nИли можешь пропустить и установить уровень вручную позже в /settings", + "return": "С возвращением, {first_name}! 👋\n\nГотов продолжить обучение?\n\nБыстрый доступ:\n• /vocabulary - посмотреть словарь\n• /task - получить задание\n• /practice - практика диалога\n• /words [тема] - тематическая подборка\n• /stats - статистика\n• /help - все команды", + "help": "📖 Справка по командам:\n\nУправление словарём:\n• /add [слово] - добавить слово в словарь\n• /vocabulary - просмотр словаря\n• /words [тема] - тематическая подборка слов\n• /import - импортировать слова из текста\n\nОбучение:\n• /task - задание (перевод, заполнение пропусков)\n• /practice - диалог с ИИ (6 сценариев)\n• /level_test - тест определения уровня\n\nСтатистика:\n• /stats - твой прогресс\n\nНастройки:\n• /settings - уровень и язык\n• /reminder - ежедневные напоминания\n\n💡 Ты также можешь просто отправить мне слово, и я предложу добавить его в словарь!", + "offer_btn": "📊 Пройти тест уровня", + "skip_btn": "➡️ Пропустить", + "skip_msg": "✅ Хорошо!\n\nТы можешь пройти тест позже командой /level_test\nили установить уровень вручную в /settings\n\nДавай начнём! Попробуй:\n• /words travel - тематическая подборка\n• /practice - диалог с AI\n• /add hello - добавить слово" + }, + "add": { + "prompt": "Отправь слово, которое хочешь добавить:\nНапример: /add elephant\n\nИли просто отправь слово без команды!", + "searching": "⏳ Ищу перевод и примеры...", + "examples_header": "Примеры:", + "translation_label": "Перевод", + "category_label": "Категория", + "level_label": "Уровень", + "confirm_question": "Добавить это слово в словарь?", + "btn_add": "✅ Добавить", + "btn_cancel": "❌ Отмена", + "exists": "Слово '{word}' уже есть в твоём словаре!\nПеревод: {translation}", + "added_success": "✅ Слово '{word}' добавлено!\n\nВсего слов в словаре: {count}\n\nПродолжай добавлять новые слова или используй /task для практики!", + "cancelled": "Отменено. Можешь добавить другое слово командой /add" + }, + "vocab": { + "empty": "📚 Твой словарь пока пуст!\n\nДобавь первое слово командой /add или просто отправь мне слово.", + "header": "📚 Твой словарь:", + "accuracy_inline": "({n}% точность)", + "shown_last": "Показаны последние 10 из {n} слов", + "total": "Всего слов: {n}" + }, + "practice": { + "start_text": "💬 Диалоговая практика с AI\n\nВыбери сценарий для разговора:\n\n• AI будет играть роль собеседника\n• Ты можешь общаться на английском\n• AI будет исправлять твои ошибки\n• Используй /stop для завершения диалога\n\nВыбери сценарий:", + "hints": "💡 Подсказки:", + "write_or_stop": "\n📝 Напиши свой ответ на английском или используй /stop для завершения", + "show_translation_btn": "👁️ Показать перевод", + "stop_btn": "🔚 Завершить диалог", + "scenario": { + "restaurant": "🍽️ Ресторан", + "shopping": "🛍️ Магазин", + "travel": "✈️ Путешествие", + "work": "💼 Работа", + "doctor": "🏥 Врач", + "casual": "💬 Общение" + }, + "thinking_prepare": "🤔 AI готовится к диалогу...", + "empty_prompt": "Напиши что-нибудь на языке обучения или используй /stop для завершения", + "thinking": "🤔 AI думает...", + "corrections": "Исправления:", + "end_title": "✅ Диалог завершён!", + "end_exchanged": "Сообщений обменено: {n}", + "end_keep": "Отличная работа! Продолжай практиковаться.", + "end_hint": "Используй /practice для нового диалога.", + "translation_unavailable": "Перевод недоступен", + "translation_already": "Перевод уже показан" + }, + "tasks": { + "no_words": "📚 У тебя пока нет слов для практики!\n\nДобавь несколько слов командой /add, а затем возвращайся.", + "stopped": "Задания остановлены. Используй /task, чтобы начать заново.", + "finished": "Задания завершены. Используй /task, чтобы начать заново.", + "header": "📝 Задание {i} из {n}", + "write_answer": "\n💡 Напиши свой ответ:", + "checking": "⏳ Проверяю ответ...", + "correct": "✅ Правильно!", + "incorrect": "❌ Неправильно", + "your_answer": "Твой ответ", + "right_answer": "Правильный ответ", + "next_btn": "➡️ Следующее задание", + "stop_btn": "🔚 Завершить", + "cancelled": "Отменено. Можешь вернуться к заданиям командой /task.", + "finish_title": "{emoji} Задание завершено!", + "correct_of": "Правильных ответов: {correct} из {total}", + "accuracy": "Точность: {accuracy}%", + "use_task": "Используй /task для нового задания", + "use_stats": "Используй /stats для просмотра статистики", + "comment": { + "excellent": "Отличный результат!", + "good": "Хорошая работа!", + "average": "Неплохо, продолжай практиковаться!", + "poor": "Повтори эти слова еще раз!" + } + }, + "reminder": { + "title": "⏰ Напоминания", + "status_on": "✅ Включены", + "status_off": "❌ Выключены", + "time_not_set": "Не установлено", + "status_line": "Статус: {status}", + "time_line": "Время: {time} UTC", + "desc1": "Напоминания помогут не забывать о ежедневной практике.", + "desc2": "Бот будет присылать сообщение в выбранное время каждый день.", + "btn_enable": "✅ Включить", + "btn_disable": "❌ Выключить", + "btn_change_time": "⏰ Изменить время", + "set_time_first": "Сначала установи время напоминаний!", + "enabled_toast": "✅ Напоминания включены!", + "enabled_title": "✅ Напоминания включены!", + "enabled_desc": "Ты будешь получать ежедневные напоминания о практике.", + "disabled_toast": "❌ Напоминания выключены", + "disabled_title": "❌ Напоминания выключены", + "disabled_desc": "Используй /reminder чтобы включить их снова.", + "set_title": "⏰ Установка времени напоминаний", + "set_desc": "Отправь время в формате HH:MM (UTC)", + "set_examples": "Примеры:\n• 09:00 - 9 утра по UTC\n• 18:30 - 18:30 по UTC\n• 20:00 - 8 вечера по UTC", + "set_utc_hint": "💡 UTC = МСК - 3 часа\n(если хочешь 12:00 по МСК, введи 09:00)", + "cancel_hint": "Отправь /cancel для отмены", + "cancelled": "❌ Установка времени отменена", + "invalid_format": "❌ Неверный формат времени!\n\nИспользуй формат HH:MM (например, 09:00 или 18:30)\nИли отправь /cancel для отмены", + "time_set_title": "✅ Время установлено!", + "status_on_line": "Статус: Включены", + "use_settings": "Используй /reminder для изменения настроек." + }, + "stats": { + "header": "📊 Твоя статистика", + "total_words": "📚 Слов в словаре: {n}", + "studied_words": "📖 Слов изучено: {n}", + "total_tasks": "✍️ Заданий выполнено: {n}", + "correct_tasks": "✅ Правильных ответов: {n}", + "accuracy": "🎯 Точность: {n}%", + "hint_add_words": "Добавь слова командой /add чтобы начать обучение!", + "hint_first_task": "Выполни первое задание командой /task!", + "hint_keep_practice": "Продолжай практиковаться! 💪" + }, + "level_test": { + "show_translation_btn": "👁️ Показать перевод вопроса", + "intro": "📊 Тест определения уровня\n\nЭтот короткий тест поможет определить твой уровень английского.\n\n📋 Тест включает 7 вопросов:\n• Грамматика\n• Лексика\n• Понимание\n\n⏱ Займёт около 2-3 минут\n\nГотов начать?", + "start_btn": "✅ Начать тест", + "cancel_btn": "❌ Отмена", + "press_button": "Нажми кнопку когда будешь готов:", + "cancelled": "❌ Тест отменён", + "q_header": "❓ Вопрос {i} из {n}" + }, + "words": { + "generating": "🔄 Генерирую подборку слов по теме '{theme}'...", + "generate_failed": "❌ Не удалось сгенерировать подборку. Попробуй позже.", + "header": "📚 Подборка слов: {theme}", + "choose": "Выбери слова, которые хочешь добавить в словарь:", + "add_all_btn": "✅ Добавить все", + "close_btn": "❌ Закрыть", + "help_title": "📚 Тематические подборки слов", + "help_usage": "Используй: /words [тема]", + "help_examples": "Примеры:\n• /words travel - путешествия\n• /words food - еда\n• /words work - работа\n• /words nature - природа\n• /words technology - технологии", + "help_note": "Я сгенерирую 10 слов по теме, подходящих для твоего уровня!", + "popular": "Популярные темы:", + "topic_travel": "✈️ Путешествия", + "topic_food": "🍔 Еда", + "topic_work": "💼 Работа", + "topic_nature": "🌿 Природа", + "topic_technology": "💻 Технологии", + "err_not_found": "❌ Ошибка: слово не найдено", + "already_exists": "Слово '{word}' уже в словаре", + "added_single": "✅ Слово '{word}' добавлено в словарь" + } +} diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..f6c6993 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,93 @@ +from alembic import context +from sqlalchemy import engine_from_config, pool +from sqlalchemy.ext.asyncio import create_async_engine +from logging.config import fileConfig +import sys, os + +# Ensure project root is on sys.path for importing config.settings +PROJECT_ROOT = os.path.dirname(os.path.dirname(__file__)) +if PROJECT_ROOT not in sys.path: + sys.path.append(PROJECT_ROOT) + +try: + from config.settings import settings +except Exception: + settings = None + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = None # not used, explicit migrations only + + +def _get_urls(): + """Derive a sync SQLAlchemy URL from app settings (async -> sync).""" + async_url = None + sync_url = None + if settings and getattr(settings, 'database_url', None): + async_url = settings.database_url + sync_url = async_url + if async_url.startswith("postgresql+asyncpg://"): + sync_url = async_url.replace("postgresql+asyncpg://", "postgresql://", 1) + return async_url, sync_url + + +def run_migrations_offline(): + async_url, sync_url = _get_urls() + url = sync_url or config.get_main_option("sqlalchemy.url") + if url: + config.set_main_option("sqlalchemy.url", url) + context.configure( + url=url, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + async_url, sync_url = _get_urls() + # If we have an async URL, run migrations via async engine + if async_url and async_url.startswith("postgresql+asyncpg://"): + async def do_run_migrations(): + connectable = create_async_engine(async_url, poolclass=pool.NullPool) + async with connectable.connect() as connection: + def sync_migrations(conn): + context.configure(connection=conn) + with context.begin_transaction(): + context.run_migrations() + + await connection.run_sync(sync_migrations) + + import asyncio + asyncio.run(do_run_migrations()) + return + + # Fallback to sync engine (e.g., if sync URL is provided) + url = sync_url or config.get_main_option("sqlalchemy.url") + if url: + config.set_main_option("sqlalchemy.url", url) + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/versions/20251204_add_vocab_lang_fields.py b/migrations/versions/20251204_add_vocab_lang_fields.py new file mode 100644 index 0000000..1e7edb0 --- /dev/null +++ b/migrations/versions/20251204_add_vocab_lang_fields.py @@ -0,0 +1,30 @@ +"""add source_lang and translation_lang to vocabulary + +Revision ID: 20251204_add_vocab_lang +Revises: +Create Date: 2025-12-04 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20251204_add_vocab_lang' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('vocabulary', sa.Column('source_lang', sa.String(length=5), nullable=True)) + op.add_column('vocabulary', sa.Column('translation_lang', sa.String(length=5), nullable=True)) + # Create unique constraint for (user_id, source_lang, word_original) + op.create_unique_constraint('uq_vocab_user_lang_word', 'vocabulary', ['user_id', 'source_lang', 'word_original']) + + +def downgrade(): + op.drop_constraint('uq_vocab_user_lang_word', 'vocabulary', type_='unique') + op.drop_column('vocabulary', 'translation_lang') + op.drop_column('vocabulary', 'source_lang') + diff --git a/services/ai_service.py b/services/ai_service.py index d78bfb6..77170c0 100644 --- a/services/ai_service.py +++ b/services/ai_service.py @@ -56,27 +56,27 @@ class AIService: response.raise_for_status() return response.json() - async def translate_word(self, word: str, target_lang: str = "ru") -> Dict: + async def translate_word(self, word: str, source_lang: str = "en", translation_lang: str = "ru") -> Dict: """ Перевести слово и получить дополнительную информацию Args: word: Слово для перевода - target_lang: Язык перевода (по умолчанию русский) + source_lang: Язык исходного слова (ISO2) + translation_lang: Язык перевода (ISO2) Returns: Dict с переводом, транскрипцией и примерами """ - prompt = f"""Переведи английское слово/фразу "{word}" на русский язык. + prompt = f"""Переведи слово/фразу "{word}" с языка {source_lang} на {translation_lang}. Верни ответ строго в формате JSON: {{ - "word": "{word}", - "translation": "перевод", - "transcription": "транскрипция в IPA", + "word": "исходное слово на {source_lang}", + "translation": "перевод на {translation_lang}", + "transcription": "транскрипция в IPA (если применимо)", "examples": [ - {{"en": "пример на английском", "ru": "перевод примера"}}, - {{"en": "ещё один пример", "ru": "перевод примера"}} + {{"{source_lang}": "пример на языке обучения", "{translation_lang}": "перевод примера"}} ], "category": "категория слова (работа, еда, путешествия и т.д.)", "difficulty": "уровень сложности (A1/A2/B1/B2/C1/C2)" @@ -85,10 +85,10 @@ class AIService: Важно: верни только JSON, без дополнительного текста.""" try: - logger.info(f"[GPT Request] translate_word: word='{word}', target_lang='{target_lang}'") + logger.info(f"[GPT Request] translate_word: word='{word}', source='{source_lang}', to='{translation_lang}'") messages = [ - {"role": "system", "content": "Ты - помощник для изучения английского языка. Отвечай только в формате JSON."}, + {"role": "system", "content": "Ты - помощник для изучения языков. Отвечай только в формате JSON."}, {"role": "user", "content": prompt} ] @@ -161,33 +161,35 @@ class AIService: "score": 0 } - async def generate_fill_in_sentence(self, word: str) -> Dict: + async def generate_fill_in_sentence(self, word: str, learning_lang: str = "en", translation_lang: str = "ru") -> Dict: """ Сгенерировать предложение с пропуском для заданного слова Args: - word: Слово, для которого нужно создать предложение + word: Слово (на языке обучения), для которого нужно создать предложение + learning_lang: Язык обучения (ISO2) + translation_lang: Язык перевода предложения (ISO2) Returns: Dict с предложением и правильным ответом """ - prompt = f"""Создай предложение на английском языке, используя слово "{word}". + prompt = f"""Создай предложение на языке {learning_lang}, используя слово "{word}". Замени это слово на пропуск "___". Верни ответ в формате JSON: {{ "sentence": "предложение с пропуском ___", "answer": "{word}", - "translation": "перевод предложения на русский" + "translation": "перевод предложения на {translation_lang}" }} Предложение должно быть простым и естественным. Контекст должен четко подсказывать правильное слово.""" try: - logger.info(f"[GPT Request] generate_fill_in_sentence: word='{word}'") + logger.info(f"[GPT Request] generate_fill_in_sentence: word='{word}', lang='{learning_lang}', to='{translation_lang}'") messages = [ - {"role": "system", "content": "Ты - преподаватель английского языка. Создавай простые и понятные упражнения."}, + {"role": "system", "content": "Ты - преподаватель иностранных языков. Создавай простые и понятные упражнения."}, {"role": "user", "content": prompt} ] @@ -206,7 +208,7 @@ class AIService: "translation": f"Мне нравится {word} каждый день." } - async def generate_thematic_words(self, theme: str, level: str = "B1", count: int = 10) -> List[Dict]: + async def generate_thematic_words(self, theme: str, level: str = "B1", count: int = 10, learning_lang: str = "en", translation_lang: str = "ru") -> List[Dict]: """ Сгенерировать подборку слов по теме @@ -218,17 +220,17 @@ class AIService: Returns: Список словарей с информацией о словах """ - prompt = f"""Создай подборку из {count} английских слов по теме "{theme}" для уровня {level}. + prompt = f"""Создай подборку из {count} слов на языке {learning_lang} по теме "{theme}" для уровня {level}. Переводы дай на {translation_lang}. Верни ответ в формате JSON: {{ "theme": "{theme}", "words": [ {{ - "word": "английское слово", - "translation": "перевод на русский", - "transcription": "транскрипция в IPA", - "example": "пример использования на английском" + "word": "слово на {learning_lang}", + "translation": "перевод на {translation_lang}", + "transcription": "транскрипция в IPA (если применимо)", + "example": "пример использования на {learning_lang}" }} ] }} @@ -240,10 +242,10 @@ class AIService: - Разнообразными (существительные, глаголы, прилагательные)""" try: - logger.info(f"[GPT Request] generate_thematic_words: theme='{theme}', level='{level}', count={count}") + logger.info(f"[GPT Request] generate_thematic_words: theme='{theme}', level='{level}', count={count}, learn='{learning_lang}', to='{translation_lang}'") messages = [ - {"role": "system", "content": "Ты - преподаватель английского языка. Подбирай полезные и актуальные слова."}, + {"role": "system", "content": "Ты - преподаватель иностранных языков. Подбирай полезные и актуальные слова."}, {"role": "user", "content": prompt} ] @@ -259,7 +261,7 @@ class AIService: logger.error(f"[GPT Error] generate_thematic_words: {type(e).__name__}: {str(e)}") return [] - async def extract_words_from_text(self, text: str, level: str = "B1", max_words: int = 15) -> List[Dict]: + async def extract_words_from_text(self, text: str, level: str = "B1", max_words: int = 15, learning_lang: str = "en", translation_lang: str = "ru") -> List[Dict]: """ Извлечь ключевые слова из текста для изучения @@ -271,7 +273,7 @@ class AIService: Returns: Список словарей с информацией о словах """ - prompt = f"""Проанализируй следующий английский текст и извлеки из него до {max_words} самых полезных слов для изучения на уровне {level}. + prompt = f"""Проанализируй следующий текст на языке {learning_lang} и извлеки из него до {max_words} самых полезных слов для изучения на уровне {level}. Переводы дай на {translation_lang}. Текст: {text} @@ -280,10 +282,10 @@ class AIService: {{ "words": [ {{ - "word": "английское слово (в базовой форме)", - "translation": "перевод на русский", - "transcription": "транскрипция в IPA", - "context": "предложение из текста, где используется это слово" + "word": "слово на {learning_lang} (в базовой форме)", + "translation": "перевод на {translation_lang}", + "transcription": "транскрипция в IPA (если применимо)", + "context": "предложение из текста на {learning_lang}, где используется это слово" }} ] }} @@ -297,10 +299,10 @@ class AIService: try: text_preview = text[:100] + "..." if len(text) > 100 else text - logger.info(f"[GPT Request] extract_words_from_text: text_length={len(text)}, level='{level}', max_words={max_words}") + logger.info(f"[GPT Request] extract_words_from_text: text_length={len(text)}, level='{level}', max_words={max_words}, learn='{learning_lang}', to='{translation_lang}'") messages = [ - {"role": "system", "content": "Ты - преподаватель английского языка. Помогаешь извлекать полезные слова для изучения из текстов."}, + {"role": "system", "content": "Ты - преподаватель иностранных языков. Помогаешь извлекать полезные слова для изучения из текстов."}, {"role": "user", "content": prompt} ] @@ -316,7 +318,7 @@ class AIService: logger.error(f"[GPT Error] extract_words_from_text: {type(e).__name__}: {str(e)}") return [] - async def start_conversation(self, scenario: str, level: str = "B1") -> Dict: + async def start_conversation(self, scenario: str, level: str = "B1", learning_lang: str = "en", translation_lang: str = "ru") -> Dict: """ Начать диалоговую практику с AI @@ -338,14 +340,14 @@ class AIService: scenario_desc = scenarios.get(scenario, "повседневный разговор") - prompt = f"""Ты - собеседник для практики английского языка уровня {level}. -Начни диалог в сценарии: {scenario_desc}. + prompt = f"""Ты - собеседник для практики языка {learning_lang} уровня {level}. +Начни диалог в сценарии: {scenario_desc} на {learning_lang}. Верни ответ в формате JSON: {{ - "message": "твоя первая реплика на английском", - "translation": "перевод на русский", - "context": "краткое описание ситуации на русском", + "message": "твоя первая реплика на {learning_lang}", + "translation": "перевод на {translation_lang}", + "context": "краткое описание ситуации на {translation_lang}", "suggestions": ["подсказка 1", "подсказка 2", "подсказка 3"] }} @@ -356,10 +358,10 @@ class AIService: - Подсказки должны помочь пользователю ответить""" try: - logger.info(f"[GPT Request] start_conversation: scenario='{scenario}', level='{level}'") + logger.info(f"[GPT Request] start_conversation: scenario='{scenario}', level='{level}', learn='{learning_lang}', to='{translation_lang}'") messages = [ - {"role": "system", "content": "Ты - дружелюбный собеседник для практики английского. Веди естественный диалог."}, + {"role": "system", "content": "Ты - дружелюбный собеседник для практики иностранных языков. Веди естественный диалог."}, {"role": "user", "content": prompt} ] @@ -384,7 +386,9 @@ class AIService: conversation_history: List[Dict], user_message: str, scenario: str, - level: str = "B1" + level: str = "B1", + learning_lang: str = "en", + translation_lang: str = "ru" ) -> Dict: """ Продолжить диалог и проверить ответ пользователя @@ -404,7 +408,7 @@ class AIService: for msg in conversation_history[-6:] # Последние 6 сообщений ]) - prompt = f"""Ты ведешь диалог на английском языке уровня {level} в сценарии "{scenario}". + prompt = f"""Ты ведешь диалог на языке {learning_lang} уровня {level} в сценарии "{scenario}". История диалога: {history_text} @@ -412,8 +416,8 @@ User: {user_message} Верни ответ в формате JSON: {{ - "response": "твой ответ на английском", - "translation": "перевод твоего ответа на русский", + "response": "твой ответ на {learning_lang}", + "translation": "перевод твоего ответа на {translation_lang}", "feedback": {{ "has_errors": true/false, "corrections": "исправления ошибок пользователя (если есть)", @@ -429,11 +433,11 @@ User: {user_message} - Используй лексику уровня {level}""" try: - logger.info(f"[GPT Request] continue_conversation: scenario='{scenario}', level='{level}', history_length={len(conversation_history)}") + logger.info(f"[GPT Request] continue_conversation: scenario='{scenario}', level='{level}', history_length={len(conversation_history)}, learn='{learning_lang}', to='{translation_lang}'") # Формируем сообщения для API messages = [ - {"role": "system", "content": f"Ты - дружелюбный собеседник для практики английского языка уровня {level}. Веди естественный диалог и помогай исправлять ошибки."} + {"role": "system", "content": f"Ты - дружелюбный собеседник для практики языка {learning_lang} уровня {level}. Веди естественный диалог и помогай исправлять ошибки."} ] # Добавляем историю diff --git a/services/task_service.py b/services/task_service.py index 8e97f87..8d262d7 100644 --- a/services/task_service.py +++ b/services/task_service.py @@ -75,7 +75,9 @@ class TaskService: async def generate_mixed_tasks( session: AsyncSession, user_id: int, - count: int = 5 + count: int = 5, + learning_lang: str = 'en', + translation_lang: str = 'ru' ) -> List[Dict]: """ Генерация заданий разных типов (переводы + заполнение пропусков) @@ -109,23 +111,31 @@ class TaskService: task_type = random.choice(['translate', 'fill_in']) if task_type == 'translate': - # Задание на перевод - direction = random.choice(['en_to_ru', 'ru_to_en']) + # Задание на перевод между языком обучения и языком перевода + direction = random.choice(['learn_to_tr', 'tr_to_learn']) - if direction == 'en_to_ru': + # Локализация фразы "Переведи слово" + if translation_lang == 'en': + prompt = "Translate the word:" + elif translation_lang == 'ja': + prompt = "単語を訳してください:" + else: + prompt = "Переведи слово:" + + if direction == 'learn_to_tr': task = { - 'type': 'translate_to_ru', + 'type': f'translate_to_{translation_lang}', 'word_id': word.id, - 'question': f"Переведи слово: {word.word_original}", + 'question': f"{prompt} {word.word_original}", 'word': word.word_original, 'correct_answer': word.word_translation, 'transcription': word.transcription } else: task = { - 'type': 'translate_to_en', + 'type': f'translate_to_{learning_lang}', 'word_id': word.id, - 'question': f"Переведи слово: {word.word_translation}", + 'question': f"{prompt} {word.word_translation}", 'word': word.word_translation, 'correct_answer': word.word_original, 'transcription': word.transcription @@ -133,13 +143,25 @@ class TaskService: else: # Задание на заполнение пропуска # Генерируем предложение с пропуском через AI - sentence_data = await ai_service.generate_fill_in_sentence(word.word_original) + sentence_data = await ai_service.generate_fill_in_sentence( + word.word_original, + learning_lang=learning_lang, + translation_lang=translation_lang + ) + + # Локализация заголовка + if translation_lang == 'en': + fill_title = "Fill in the blank in the sentence:" + elif translation_lang == 'ja': + fill_title = "文の空欄を埋めてください:" + else: + fill_title = "Заполни пропуск в предложении:" task = { 'type': 'fill_in', 'word_id': word.id, 'question': ( - f"Заполни пропуск в предложении:\n\n" + f"{fill_title}\n\n" f"{sentence_data['sentence']}\n\n" f"{sentence_data.get('translation', '')}" ), diff --git a/services/user_service.py b/services/user_service.py index 668901e..6c428a9 100644 --- a/services/user_service.py +++ b/services/user_service.py @@ -95,3 +95,22 @@ class UserService: if user: user.language_interface = language await session.commit() + + @staticmethod + async def update_user_learning_language(session: AsyncSession, user_id: int, language: str): + """ + Обновить язык изучения пользователя + + Args: + session: Сессия базы данных + user_id: ID пользователя + language: Новый язык изучения (ISO2) + """ + result = await session.execute( + select(User).where(User.id == user_id) + ) + user = result.scalar_one_or_none() + + if user: + user.learning_language = language + await session.commit() diff --git a/services/vocabulary_service.py b/services/vocabulary_service.py index 4246620..ee8a85f 100644 --- a/services/vocabulary_service.py +++ b/services/vocabulary_service.py @@ -2,6 +2,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from database.models import Vocabulary, WordSource, LanguageLevel from typing import List, Optional +import re class VocabularyService: @@ -13,6 +14,8 @@ class VocabularyService: user_id: int, word_original: str, word_translation: str, + source_lang: Optional[str] = None, + translation_lang: Optional[str] = None, transcription: Optional[str] = None, examples: Optional[dict] = None, category: Optional[str] = None, @@ -50,6 +53,8 @@ class VocabularyService: user_id=user_id, word_original=word_original, word_translation=word_translation, + source_lang=source_lang, + translation_lang=translation_lang, transcription=transcription, examples=examples, category=category, @@ -65,7 +70,27 @@ class VocabularyService: return new_word @staticmethod - async def get_user_words(session: AsyncSession, user_id: int, limit: int = 50) -> List[Vocabulary]: + @staticmethod + def _is_japanese(text: str) -> bool: + if not text: + return False + return re.search(r"[\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF]", text) is not None + + @staticmethod + def _filter_by_learning_lang(words: List[Vocabulary], learning_lang: Optional[str]) -> List[Vocabulary]: + if not learning_lang: + return words + # Если в БД указан source_lang – фильтруем по нему. + with_lang = [w for w in words if getattr(w, 'source_lang', None)] + if with_lang: + return [w for w in words if (w.source_lang or '').lower() == learning_lang.lower()] + # Фолбэк-эвристика для японского, если язык не сохранён + if learning_lang.lower() == 'ja': + return [w for w in words if VocabularyService._is_japanese(w.word_original)] + return [w for w in words if not VocabularyService._is_japanese(w.word_original)] + + @staticmethod + async def get_user_words(session: AsyncSession, user_id: int, limit: int = 50, learning_lang: Optional[str] = None) -> List[Vocabulary]: """ Получить все слова пользователя @@ -81,12 +106,13 @@ class VocabularyService: select(Vocabulary) .where(Vocabulary.user_id == user_id) .order_by(Vocabulary.created_at.desc()) - .limit(limit) ) - return list(result.scalars().all()) + words = list(result.scalars().all()) + words = VocabularyService._filter_by_learning_lang(words, learning_lang) + return words[:limit] @staticmethod - async def get_words_count(session: AsyncSession, user_id: int) -> int: + async def get_words_count(session: AsyncSession, user_id: int, learning_lang: Optional[str] = None) -> int: """ Получить количество слов в словаре пользователя @@ -100,7 +126,9 @@ class VocabularyService: result = await session.execute( select(Vocabulary).where(Vocabulary.user_id == user_id) ) - return len(list(result.scalars().all())) + words = list(result.scalars().all()) + words = VocabularyService._filter_by_learning_lang(words, learning_lang) + return len(words) @staticmethod async def find_word(session: AsyncSession, user_id: int, word: str) -> Optional[Vocabulary]: diff --git a/utils/i18n.py b/utils/i18n.py new file mode 100644 index 0000000..61d48eb --- /dev/null +++ b/utils/i18n.py @@ -0,0 +1,51 @@ +import json +from pathlib import Path +from functools import lru_cache +from typing import Any, Dict + + +FALLBACK_LANG = "ru" + + +@lru_cache(maxsize=16) +def _load_lang(lang: str) -> Dict[str, Any]: + base_dir = Path(__file__).resolve().parents[1] / "locales" + file_path = base_dir / f"{lang}.json" + if not file_path.exists(): + # fallback to default + if lang != FALLBACK_LANG: + return _load_lang(FALLBACK_LANG) + return {} + try: + return json.loads(file_path.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _resolve_key(data: Dict[str, Any], dotted_key: str) -> Any: + cur: Any = data + for part in dotted_key.split("."): + if not isinstance(cur, dict) or part not in cur: + return None + cur = cur[part] + return cur + + +def t(lang: str, key: str, **kwargs) -> str: + """Translate key for given lang; fallback to ru and to key itself. + + Supports dotted keys and str.format(**kwargs) placeholders. + """ + data = _load_lang(lang or FALLBACK_LANG) + value = _resolve_key(data, key) + if value is None and lang != FALLBACK_LANG: + value = _resolve_key(_load_lang(FALLBACK_LANG), key) + if value is None: + value = key # last resort: return the key + try: + if isinstance(value, str) and kwargs: + return value.format(**kwargs) + except Exception: + pass + return value if isinstance(value, str) else str(value) +