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)
+