feat(i18n): localize start/help/menu, practice, words, import, reminder, vocabulary, tasks/stats for RU/EN/JA; add JSON-based i18n helper\n\nfeat(lang): support learning/translation languages across AI flows; hide translations with buttons; store examples per lang\n\nfeat(vocab): add source_lang and translation_lang to Vocabulary, unique constraint (user_id, source_lang, word_original); filter /vocabulary by user.learning_language\n\nchore(migrations): add Alembic setup + migration to add vocab lang columns; env.py reads app settings and supports asyncpg URLs\n\nfix(words/import): pass learning_lang + translation_lang everywhere; fix menu themes generation\n\nfeat(settings): add learning language selector; update main menu on language change

This commit is contained in:
2025-12-04 19:40:01 +03:00
parent 6223351ccf
commit 472771229f
22 changed files with 1587 additions and 471 deletions

View File

@@ -286,19 +286,19 @@ bot_tg_language/
- [x] Статистика и прогресс
- [x] Spaced repetition алгоритм (базовая версия)
- [x] Напоминания и ежедневные задания по расписанию
- [x] Убрать переводы текстов (скрыть перевод в упражнениях/диалогах/тестах)
**Следующие улучшения:**
- [ ] Экспорт словаря (PDF, Anki, CSV)
- [ ] Голосовые сообщения для практики произношения
- [ ] Групповые челленджи и лидерборды
- [ ] Gamification (стрики, достижения, уровни)
- [ ] Расширенная аналитика с графиками
- [ ] Убрать переводы текстов (скрыть перевод в упражнениях/диалогах/тестах)
- [ ] Добавить импорт нескольких слов (bulk-импорт)
- [ ] Создание задач на выбранные слова (из словаря/подборок)
- [ ] Добавить возможность иметь словам несколько переводов
- [ ] Изменить словарь: оставить только слова и добавить возможность получать инфо о словах
- [ ] Добавить возможность импорта слов из файлов
- [ ] Расширенная аналитика с графиками
- [ ] Добавить импорт нескольких слов (bulk-импорт)
- [ ] Создание задач на выбранные слова (из словаря/подборок)
- [ ] Добавить возможность иметь словам несколько переводов
- [ ] Изменить словарь: оставить только слова и добавить возможность получать инфо о словах
- [ ] Добавить возможность импорта слов из файлов
## Cloudflare AI Gateway (опционально)

36
alembic.ini Normal file
View File

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

View File

@@ -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(
"📖 <b>Импорт слов из текста</b>\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"📚 <b>Найдено слов: {len(words)}</b>\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"✅ Добавлено слов: <b>{added_count}</b>"
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)

View File

@@ -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(
"📊 <b>Тест определения уровня</b>\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"❓ <b>Вопрос {current_idx + 1} из {len(questions)}</b>\n\n"
t(lang, 'level_test.q_header', i=current_idx + 1, n=len(questions)) + "\n\n" +
f"<b>{question['question']}</b>\n"
f"<i>{question.get('question_ru', '')}</i>\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} <i>{ru}</i>"
try:
await callback.message.edit_text(new_text, reply_markup=callback.message.reply_markup)
except Exception:
# Запасной путь, если редактирование невозможно
await callback.message.answer(f"{marker} <i>{ru}</i>")
await callback.answer()
@router.callback_query(F.data.startswith("answer_"), LevelTestStates.taking_test)
async def process_answer(callback: CallbackQuery, state: FSMContext):
"""Обработать ответ на вопрос"""

View File

@@ -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(
"💬 <b>Диалоговая практика с AI</b>\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"<b>{SCENARIOS[scenario]}</b>\n\n"
f"<b>{SCENARIOS[scenario].get(ui_lang, SCENARIOS[scenario]['ru'])}</b>\n\n"
f"📝 <i>{conversation_start.get('context', '')}</i>\n\n"
f"<b>AI:</b> {conversation_start.get('message', '')}\n"
f"<i>({conversation_start.get('translation', '')})</i>\n\n"
"💡 <b>Подсказки:</b>\n"
f"<b>AI:</b> {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"✅ <b>Диалог завершён!</b>\n\n"
f"Сообщений обменено: <b>{message_count}</b>\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"✅ <b>Диалог завершён!</b>\n\n"
f"Сообщений обменено: <b>{message_count}</b>\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"⚠️ <b>Исправления:</b>\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"<b>AI:</b> {ai_response.get('response', '')}\n"
f"<i>({ai_response.get('translation', '')})</i>\n\n"
f"<b>AI:</b> {ai_response.get('response', '')}\n\n"
)
# Подсказки
suggestions = ai_response.get('suggestions', [])
if suggestions:
text += "💡 <b>Подсказки:</b>\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} <i>{tr_text}</i>"
try:
await callback.message.edit_text(new_text, reply_markup=callback.message.reply_markup)
except Exception:
# Если не удалось отредактировать (например, старое сообщение), отправим отдельным сообщением как запасной вариант
await callback.message.answer(f"{marker} <i>{tr_text}</i>")
await callback.answer()

View File

@@ -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"⏰ <b>Напоминания</b>\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"✅ <b>Напоминания включены!</b>\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(
"❌ <b>Напоминания выключены</b>\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(
"⏰ <b>Установка времени напоминаний</b>\n\n"
"Отправь время в формате <b>HH:MM</b> (UTC)\n\n"
"Примеры:\n"
"• <code>09:00</code> - 9 утра по UTC\n"
"• <code>18:30</code> - 18:30 по UTC\n"
"• <code>20:00</code> - 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"
"Используй формат <b>HH:MM</b> (например, 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"✅ <b>Время установлено!</b>\n\n"
f"Напоминания: <b>{formatted_time} UTC</b>\n"
f"Статус: <b>Включены</b>\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')
)

View File

@@ -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 = (
"⚙️ <b>Настройки</b>\n\n"
f"📊 Уровень английского: <b>{user.level.value}</b>\n"
f"🌐 Язык интерфейса: <b>{'Русский' if user.language_interface == 'ru' else 'English'}</b>\n\n"
"Выбери, что хочешь изменить:"
)
lang = getattr(user, 'language_interface', 'ru')
title = "⚙️ <b>Settings</b>\n\n" if lang == 'en' else ("⚙️ <b>設定</b>\n\n" if lang == 'ja' else "⚙️ <b>Настройки</b>\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}<b>{user.level.value}</b>\n{lang_label}<b>{lang_value}</b>\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(
"📊 <b>Выбери свой уровень английского:</b>\n\n"
"<b>A1-A2</b> - Начинающий\n"
"<b>B1-B2</b> - Средний\n"
"<b>C1-C2</b> - Продвинутый\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 = "📊 <b>Choose your English level:</b>\n\n" if lang == 'en' else ("📊 <b>英語レベルを選択:</b>\n\n" if lang == 'ja' else "📊 <b>Выбери свой уровень английского:</b>\n\n")
body = (
"<b>A1-A2</b> - Beginner\n<b>B1-B2</b> - Intermediate\n<b>C1-C2</b> - Advanced\n\n" if lang == 'en' else (
"<b>A1-A2</b> - 初級\n<b>B1-B2</b> - 中級\n<b>C1-C2</b> - 上級\n\n" if lang == 'ja' else
"<b>A1-A2</b> - Начинающий\n<b>B1-B2</b> - Средний\n<b>C1-C2</b> - Продвинутый\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 = "🎯 <b>Select learning language:</b>\n\n" if lang == 'en' else ("🎯 <b>学習言語を選択:</b>\n\n" if lang == 'ja' else "🎯 <b>Выбери язык изучения:</b>\n\n")
await callback.message.edit_text(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: <b>{code.upper()}</b>"
back = "⬅️ Back to settings"
elif lang == 'ja':
text = f"✅ 学習言語: <b>{code.upper()}</b>"
back = "⬅️ 設定に戻る"
else:
text = f"✅ Язык изучения: <b>{code.upper()}</b>"
back = "⬅️ К настройкам"
await callback.message.edit_text(
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"<b>{level_str}</b>\n\n" + ("You will now receive words and tasks matching your level!" if lang == 'en' else ("これからレベルに合った単語と課題が出題されます!" if lang == 'ja' else "Теперь ты будешь получать слова и задания, соответствующие твоему уровню!"))
back = "⬅️ Back to settings" if lang == 'en' else ("⬅️ 設定に戻る" if lang == 'ja' else "⬅️ К настройкам")
await callback.message.edit_text(
f"✅ Уровень изменен на <b>{level_str}</b>\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 = "🌐 <b>Select interface language:</b>\n\n" if lang == 'en' else ("🌐 <b>インターフェース言語を選択:</b>\n\n" if lang == 'ja' else "🌐 <b>Выбери язык интерфейса:</b>\n\n")
desc = "This will change the language of bot messages." if lang == 'en' else ("ボットの表示言語が変更されます。" if lang == 'ja' else "Это изменит язык всех сообщений бота.")
await callback.message.edit_text(
"🌐 <b>Выбери язык интерфейса:</b>\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: <b>English</b>"
back = "⬅️ Back"
elif lang == 'ja':
text = "✅ インターフェース言語: <b>日本語</b>"
back = "⬅️ 戻る"
else:
text = "✅ Язык интерфейса: <b>Русский</b>"
back = "⬅️ К настройкам"
await callback.message.edit_text(
f"✅ Язык интерфейса: <b>{'Русский' if lang == 'ru' else 'English'}</b>",
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 = (
"⚙️ <b>Настройки</b>\n\n"
f"📊 Уровень английского: <b>{user.level.value}</b>\n"
f"🌐 Язык интерфейса: <b>{'Русский' if user.language_interface == 'ru' else 'English'}</b>\n\n"
"Выбери, что хочешь изменить:"
)
lang = getattr(user, 'language_interface', 'ru')
title = "⚙️ <b>Settings</b>\n\n" if lang == 'en' else ("⚙️ <b>設定</b>\n\n" if lang == 'ja' else "⚙️ <b>Настройки</b>\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}<b>{user.level.value}</b>\n{lang_label}<b>{lang_value}</b>\n\n{footer}"
await callback.message.edit_text(settings_text, reply_markup=get_settings_keyboard(user))

View File

@@ -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"<b>Команды:</b>\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(
"🎯 <b>Определим твой уровень?</b>\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"<b>Быстрый доступ:</b>\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"
"Например: <code>/add elephant</code>\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 = (
"📚 <b>Тематические подборки слов</b>\n\n"
"Используй: <code>/words [тема]</code>\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(
"<b>📖 Справка по командам:</b>\n\n"
"<b>Управление словарём:</b>\n"
"• /add [слово] - добавить слово в словарь\n"
"• /vocabulary - просмотр словаря\n"
"• /words [тема] - тематическая подборка слов\n"
"• /import - импортировать слова из текста\n\n"
"<b>Обучение:</b>\n"
"• /task - задание (перевод, заполнение пропусков)\n"
"• /practice - диалог с ИИ (6 сценариев)\n"
"• /level_test - тест определения уровня\n\n"
"<b>Статистика:</b>\n"
"• /stats - твой прогресс\n\n"
"<b>Настройки:</b>\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()

View File

@@ -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"📝 <b>Задание {current_index + 1} из {len(tasks)}</b>\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"✅ <b>Правильно!</b>\n\n"
result_text = t(lang, 'tasks.correct') + "\n\n"
correct_count += 1
else:
result_text = f"❌ <b>Неправильно</b>\n\n"
result_text = t(lang, 'tasks.incorrect') + "\n\n"
result_text += f"Твой ответ: <i>{user_answer}</i>\n"
result_text += f"Правильный ответ: <b>{task['correct_answer']}</b>\n\n"
result_text += f"{t(lang, 'tasks.your_answer')}: <i>{user_answer}</i>\n"
result_text += f"{t(lang, 'tasks.right_answer')}: <b>{task['correct_answer']}</b>\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} <b>Задание завершено!</b>\n\n"
f"Правильных ответов: <b>{correct_count}</b> из {total_count}\n"
f"Точность: <b>{accuracy}%</b>\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"📊 <b>Твоя статистика</b>\n\n"
f"📚 Слов в словаре: <b>{stats['total_words']}</b>\n"
f"📖 Слов изучено: <b>{stats['reviewed_words']}</b>\n"
f"✍️ Заданий выполнено: <b>{stats['total_tasks']}</b>\n"
f"✅ Правильных ответов: <b>{stats['correct_tasks']}</b>\n"
f"🎯 Точность: <b>{stats['accuracy']}%</b>\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)

View File

@@ -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"
"Например: <code>/add elephant</code>\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<b>Примеры:</b>\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 <i>{example['ru']}</i>\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 <i>{tr}</i>\n"
# Отправляем карточку слова
card_text = (
f"📝 <b>{word_data['word']}</b>\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"✅ Слово '<b>{word_data['word']}</b>' добавлено в твой словарь!\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 = "<b>📚 Твой словарь:</b>\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}. <b>{word.word_original}</b> — {word.word_translation}\n"
@@ -184,8 +200,8 @@ async def cmd_vocabulary(message: Message):
)
if total_count > 10:
words_list += f"\n<i>Показаны последние 10 из {total_count} слов</i>"
words_list += "\n" + t(lang, 'vocab.shown_last', n=total_count)
else:
words_list += f"\n<i>Всего слов: {total_count}</i>"
words_list += "\n" + t(lang, 'vocab.total', n=total_count)
await message.answer(words_list)

View File

@@ -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(
"📚 <b>Тематические подборки слов</b>\n\n"
"Используй: <code>/words [тема]</code>\n\n"
"Примеры:\n"
"• <code>/words travel</code> - путешествия\n"
"• <code>/words food</code> - еда\n"
"• <code>/words work</code> - работа\n"
"• <code>/words nature</code> - природа\n"
"• <code>/words technology</code> - технологии\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"📚 <b>Подборка слов: {theme}</b>\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" <i>{word_data.get('example', '')}</i>\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)

View File

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

183
locales/en.json Normal file
View File

@@ -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": "📖 <b>Import words from text</b>",
"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": "📚 <b>Found words: {n}</b>",
"added_single": "✅ Word '{word}' added to vocabulary",
"added_count": "✅ Added words: <b>{n}</b>",
"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\n<b>Commands:</b>\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": "🎯 <b>Shall we determine your level?</b>\n\nA short test (7 questions) will tailor tasks to your level.\nIt takes about 23 minutes.\n\nOr skip and set level later in /settings",
"return": "Welcome back, {first_name}! 👋\n\nReady to continue?\n\n<b>Quick access:</b>\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": "<b>📖 Commands help:</b>\n\n<b>Vocabulary:</b>\n• /add [word] - add to dictionary\n• /vocabulary - view dictionary\n• /words [topic] - thematic words\n• /import - import from text\n\n<b>Learning:</b>\n• /task - exercise (translate, fill gaps)\n• /practice - AI dialogue (6 scenarios)\n• /level_test - level test\n\n<b>Stats:</b>\n• /stats - your progress\n\n<b>Settings:</b>\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: <code>/add elephant</code>\n\nOr just send the word without a command!",
"searching": "⏳ Looking up translation and examples...",
"examples_header": "<b>Examples:</b>",
"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 '<b>{word}</b>' is already in your vocabulary!\nTranslation: {translation}",
"added_success": "✅ Word '<b>{word}</b>' 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": "<b>📚 Your vocabulary:</b>",
"accuracy_inline": "({n}% accuracy)",
"shown_last": "<i>Showing last 10 of {n} words</i>",
"total": "<i>Total words: {n}</i>"
},
"practice": {
"start_text": "💬 <b>Dialogue practice with AI</b>\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": "💡 <b>Hints:</b>",
"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": "<b>Corrections:</b>",
"end_title": "✅ <b>Dialogue finished!</b>",
"end_exchanged": "Messages exchanged: <b>{n}</b>",
"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": "📝 <b>Task {i} of {n}</b>",
"write_answer": "\n💡 Write your answer:",
"checking": "⏳ Checking answer...",
"correct": "✅ <b>Correct!</b>",
"incorrect": "❌ <b>Incorrect</b>",
"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} <b>Task finished!</b>",
"correct_of": "Correct answers: <b>{correct}</b> of {total}",
"accuracy": "Accuracy: <b>{accuracy}%</b>",
"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": "📊 <b>Your stats</b>",
"total_words": "📚 Words in vocabulary: <b>{n}</b>",
"studied_words": "📖 Words studied: <b>{n}</b>",
"total_tasks": "✍️ Tasks completed: <b>{n}</b>",
"correct_tasks": "✅ Correct answers: <b>{n}</b>",
"accuracy": "🎯 Accuracy: <b>{n}%</b>",
"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": "⏰ <b>Reminders</b>",
"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": "✅ <b>Reminders enabled!</b>",
"enabled_desc": "You will receive daily practice reminders.",
"disabled_toast": "❌ Reminders disabled",
"disabled_title": "❌ <b>Reminders disabled</b>",
"disabled_desc": "Use /reminder to enable them again.",
"set_title": "⏰ <b>Set reminder time</b>",
"set_desc": "Send time in format <b>HH:MM</b> (UTC)",
"set_examples": "Examples:\n• <code>09:00</code> - 9 AM UTC\n• <code>18:30</code> - 6:30 PM UTC\n• <code>20:00</code> - 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 <b>HH:MM</b> (e.g., 09:00 or 18:30)\nOr send /cancel to abort",
"time_set_title": "✅ <b>Time set!</b>",
"status_on_line": "Status: <b>Enabled</b>",
"use_settings": "Use /reminder to change settings."
},
"level_test": {
"show_translation_btn": "👁️ Show question translation",
"intro": "📊 <b>Level placement test</b>\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 23 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": "❓ <b>Question {i} of {n}</b>"
},
"words": {
"generating": "🔄 Generating words for topic '{theme}'...",
"generate_failed": "❌ Failed to generate words. Please try again later.",
"header": "📚 <b>Word set: {theme}</b>",
"choose": "Choose words to add to your vocabulary:",
"add_all_btn": "✅ Add all",
"close_btn": "❌ Close",
"help_title": "📚 <b>Thematic word sets</b>",
"help_usage": "Use: <code>/words [topic]</code>",
"help_examples": "Examples:\n• <code>/words travel</code> - travel\n• <code>/words food</code> - food\n• <code>/words work</code> - work\n• <code>/words nature</code> - nature\n• <code>/words technology</code> - 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"
}
}

175
locales/ja.json Normal file
View File

@@ -0,0 +1,175 @@
{
"menu": {
"add": " 単語を追加",
"vocab": "📚 単語帳",
"task": "🧠 課題",
"practice": "💬 練習",
"words": "🎯 テーマ別単語",
"import": "📖 テキストからインポート",
"stats": "📊 統計",
"settings": "⚙️ 設定",
"below": "メインメニューは下にあります ⤵️"
},
"common": {
"start_first": "まず /start を実行してください",
"translation": "翻訳"
},
"import": {
"title": "📖 <b>テキストから単語をインポート</b>",
"desc": "学習言語のテキストを送ってください。学習に役立つ単語を抽出します。",
"can_send": "送れるもの:\n• 本や記事の一節\n• 歌詞\n• 説明文\n• 気になるテキスト",
"cancel_hint": "/cancel で中止できます。",
"too_short": "⚠️ テキストが短すぎます。50文字以上で送ってください。\n/cancel で中止できます。",
"too_long": "⚠️ テキストが長すぎます最大3000文字。\n短くして送るか、/cancel を使ってください。",
"processing": "🔄 テキストを分析して単語を抽出しています...",
"failed": "❌ 単語の抽出に失敗しました。別のテキストか、後でもう一度お試しください。",
"found_header": "📚 <b>見つかった単語: {n}</b>",
"added_single": "✅ 単語 '{word}' を単語帳に追加しました",
"added_count": "✅ 追加した単語: <b>{n}</b>",
"skipped_count": "⚠️ スキップ(既に単語帳にあり): {n}"
},
"start": {
"new_intro": "👋 こんにちは、{first_name} さん!\n\n私は英語学習を手助けするボットです。以下のことができます\n📚 語彙を増やす(手動/テーマ別/テキストから)\n✍ インタラクティブ課題に取り組む\n💬 AIとの会話練習\n📊 進捗を記録\n\n<b>コマンド:</b>\n• /add [word] - 単語を追加\n• /words [topic] - テーマ別単語\n• /import - テキストからインポート\n• /vocabulary - 単語帳\n• /task - 課題\n• /practice - 会話練習\n• /stats - 統計\n• /settings - 設定\n• /reminder - リマインダー\n• /help - ヘルプ",
"offer_test": "🎯 <b>レベル診断を行いますか?</b>\n\n短いテスト7問であなたのレベルに合った課題を用意します。\n所要時間は約2〜3分です。\n\nまたは /settings から後で設定できます。",
"return": "おかえりなさい、{first_name} さん! 👋\n\n学習を続けましょうか\n\n<b>クイックアクセス:</b>\n• /vocabulary - 単語帳を見る\n• /task - 課題を受ける\n• /practice - 会話練習\n• /words [topic] - テーマ別単語\n• /stats - 統計\n• /help - すべてのコマンド",
"help": "<b>📖 コマンド一覧:</b>\n\n<b>語彙:</b>\n• /add [word] - 単語を追加\n• /vocabulary - 単語帳\n• /words [topic] - テーマ別単語\n• /import - テキストからインポート\n\n<b>学習:</b>\n• /task - 課題(翻訳/穴埋め など)\n• /practice - AIとの会話6シナリオ\n• /level_test - レベル診断\n\n<b>統計:</b>\n• /stats - 進捗状況\n\n<b>設定:</b>\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例: <code>/add elephant</code>\n\nコマンドなしで単語だけ送ってもOKです",
"searching": "⏳ 翻訳と例を検索中...",
"examples_header": "<b>例文:</b>",
"translation_label": "翻訳",
"category_label": "カテゴリー",
"level_label": "レベル",
"confirm_question": "この単語を単語帳に追加しますか?",
"btn_add": "✅ 追加",
"btn_cancel": "❌ キャンセル",
"exists": "単語 '<b>{word}</b>' はすでに単語帳にあります!\n翻訳: {translation}",
"added_success": "✅ 単語 '<b>{word}</b>' を追加しました!\n\n単語帳の総数: {count}\n\nさらに追加するか、/task で練習しましょう!",
"cancelled": "キャンセルしました。/add で別の単語を追加できます"
},
"vocab": {
"empty": "📚 単語帳はまだ空です!\n\n/add で最初の単語を追加するか、単語を直接送ってください。",
"header": "<b>📚 あなたの単語帳:</b>",
"accuracy_inline": "(正答率 {n}%)",
"shown_last": "<i>{n} 語のうち最新の10語を表示</i>",
"total": "<i>合計: {n} 語</i>"
},
"practice": {
"start_text": "💬 <b>AIとの会話練習</b>\n\nシナリオを選んでください\n\n• AIが相手役を務めます\n• 英語でやり取りできます\n• 間違いをAIが指摘します\n• 終了するには /stop を使用\n\nシナリオを選択",
"hints": "💡 <b>ヒント:</b>",
"write_or_stop": "\n📝 英語で返信するか、/stop で終了できます",
"show_translation_btn": "👁️ 翻訳を表示",
"stop_btn": "🔚 会話を終了",
"thinking_prepare": "🤔 AI が会話の準備中...",
"empty_prompt": "学習言語で入力するか、/stop で終了できます",
"thinking": "🤔 AI が考えています...",
"corrections": "<b>修正:</b>",
"end_title": "✅ <b>会話を終了しました!</b>",
"end_exchanged": "やり取りしたメッセージ数: <b>{n}</b>",
"end_keep": "素晴らしい!練習を続けましょう。",
"end_hint": "/practice で新しい会話を始められます。",
"translation_unavailable": "翻訳は利用できません",
"translation_already": "翻訳はすでに表示されています"
},
"tasks": {
"no_words": "📚 まだ練習用の単語がありません!\n\n/add で単語を追加してから戻ってきてください。",
"stopped": "課題を停止しました。/task で再開できます。",
"finished": "課題が完了しました。/task で新しく始めましょう。",
"header": "📝 <b>{n}問中 {i} 問目</b>",
"write_answer": "\n💡 回答を入力してください:",
"checking": "⏳ 回答を確認中...",
"correct": "✅ <b>正解!</b>",
"incorrect": "❌ <b>不正解</b>",
"your_answer": "あなたの回答",
"right_answer": "正解",
"next_btn": "➡️ 次へ",
"stop_btn": "🔚 停止",
"cancelled": "キャンセルしました。/task で課題に戻れます。",
"finish_title": "{emoji} <b>課題が終了しました!</b>",
"correct_of": "正解数: <b>{correct}</b> / {total}",
"accuracy": "正答率: <b>{accuracy}%</b>",
"use_task": "/task で新しい課題を開始",
"use_stats": "/stats で統計を表示",
"comment": {
"excellent": "素晴らしい結果です!",
"good": "よくできました!",
"average": "悪くありません。練習を続けましょう!",
"poor": "もう一度見直しましょう!"
}
},
"stats": {
"header": "📊 <b>統計</b>",
"total_words": "📚 単語帳の単語数: <b>{n}</b>",
"studied_words": "📖 学習済みの単語: <b>{n}</b>",
"total_tasks": "✍️ 完了した課題: <b>{n}</b>",
"correct_tasks": "✅ 正解数: <b>{n}</b>",
"accuracy": "🎯 正答率: <b>{n}%</b>",
"hint_add_words": "/add で単語を追加して学習を始めましょう!",
"hint_first_task": "/task で最初の課題をやってみましょう!",
"hint_keep_practice": "練習を続けましょう! 💪"
},
"reminder": {
"title": "⏰ <b>リマインダー</b>",
"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": "✅ <b>リマインダーが有効になりました!</b>",
"enabled_desc": "毎日、練習のリマインダーが届きます。",
"disabled_toast": "❌ リマインダーを無効にしました",
"disabled_title": "❌ <b>リマインダーは無効です</b>",
"disabled_desc": "/reminder で再度有効にできます。",
"set_title": "⏰ <b>リマインダーの時間設定</b>",
"set_desc": "<b>HH:MM</b>UTC形式で時間を送ってください",
"set_examples": "例:\n• <code>09:00</code> - UTCの午前9時\n• <code>18:30</code> - UTCの午後6時30分\n• <code>20:00</code> - UTCの午後8時",
"set_utc_hint": "💡 UTC = お住まいのタイムゾーンに合わせて換算してください",
"cancel_hint": "/cancel で中止できます",
"cancelled": "❌ 時間設定を中止しました",
"invalid_format": "❌ 時間の形式が正しくありません!\n\n<b>HH:MM</b>(例: 09:00 / 18:30形式を使用してください\nまたは /cancel で中止",
"time_set_title": "✅ <b>時間を設定しました!</b>",
"status_on_line": "ステータス: <b>有効</b>",
"use_settings": "/reminder で設定を変更できます。"
},
"level_test": {
"show_translation_btn": "👁️ 質問の翻訳を表示",
"intro": "📊 <b>レベル判定テスト</b>\n\n短いテストで英語レベルを判定します。\n\n📋 全7問:\n• 文法\n• 語彙\n• 読解\n\n⏱ 所要時間は約2〜3分\n\n準備はいいですか",
"start_btn": "✅ テストを開始",
"cancel_btn": "❌ キャンセル",
"press_button": "準備ができたらボタンを押してください:",
"cancelled": "❌ テストを中止しました",
"q_header": "❓ <b>{n}問中 {i} 問目</b>"
},
"words": {
"generating": "🔄 テーマ『{theme}』の単語を生成中...",
"generate_failed": "❌ 単語の生成に失敗しました。後でもう一度お試しください。",
"header": "📚 <b>単語セット: {theme}</b>",
"choose": "単語帳に追加する単語を選択してください:",
"add_all_btn": "✅ すべて追加",
"close_btn": "❌ 閉じる",
"help_title": "📚 <b>テーマ別単語</b>",
"help_usage": "使い方: <code>/words [テーマ]</code>",
"help_examples": "例:\n• <code>/words travel</code> - 旅行\n• <code>/words food</code> - 食べ物\n• <code>/words work</code> - 仕事\n• <code>/words nature</code> - 自然\n• <code>/words technology</code> - テクノロジー",
"help_note": "レベルに合わせて10語を生成します",
"popular": "人気のテーマ:",
"topic_travel": "✈️ 旅行",
"topic_food": "🍔 食べ物",
"topic_work": "💼 仕事",
"topic_nature": "🌿 自然",
"topic_technology": "💻 テクノロジー",
"err_not_found": "❌ エラー: 単語が見つかりません",
"already_exists": "単語 '{word}' はすでに単語帳にあります",
"added_single": "✅ 単語 '{word}' を単語帳に追加しました"
}
}

183
locales/ru.json Normal file
View File

@@ -0,0 +1,183 @@
{
"menu": {
"add": " Добавить слово",
"vocab": "📚 Словарь",
"task": "🧠 Задание",
"practice": "💬 Практика",
"words": "🎯 Тематические слова",
"import": "📖 Импорт из текста",
"stats": "📊 Статистика",
"settings": "⚙️ Настройки",
"below": "Главное меню доступно ниже ⤵️"
},
"common": {
"start_first": "Сначала запусти бота командой /start",
"translation": "Перевод"
},
"import": {
"title": "📖 <b>Импорт слов из текста</b>",
"desc": "Отправь мне текст на выбранном языке обучения, и я извлеку из него полезные слова для изучения.",
"can_send": "Можно отправить:\n• Отрывок из книги или статьи\n• Текст песни\n• Описание чего-либо\n• Любой интересный текст",
"cancel_hint": "Отправь /cancel для отмены.",
"too_short": "⚠️ Текст слишком короткий. Отправь текст минимум из 50 символов.\nИли используй /cancel для отмены.",
"too_long": "⚠️ Текст слишком длинный (максимум 3000 символов).\nОтправь текст покороче или используй /cancel для отмены.",
"processing": "🔄 Анализирую текст и извлекаю слова...",
"failed": "❌ Не удалось извлечь слова из текста. Попробуй другой текст или повтори позже.",
"found_header": "📚 <b>Найдено слов: {n}</b>",
"added_single": "✅ Слово '{word}' добавлено в словарь",
"added_count": "✅ Добавлено слов: <b>{n}</b>",
"skipped_count": "⚠️ Пропущено (уже в словаре): {n}"
},
"start": {
"new_intro": "👋 Привет, {first_name}!\n\nЯ бот для изучения английского языка. Помогу тебе:\n📚 Пополнять словарный запас (ручное/тематическое/из текста)\n✍ Выполнять интерактивные задания\n💬 Практиковать язык в диалоге с AI\n📊 Отслеживать свой прогресс\n\n<b>Команды:</b>\n• /add [слово] - добавить слово\n• /words [тема] - тематическая подборка\n• /import - импорт из текста\n• /vocabulary - мой словарь\n• /task - задания\n• /practice - диалог с AI\n• /stats - статистика\n• /settings - настройки\n• /reminder - напоминания\n• /help - полная справка",
"offer_test": "🎯 <b>Определим твой уровень?</b>\n\nКороткий тест (7 вопросов) поможет подобрать задания под твой уровень.\nЭто займёт 2-3 минуты.\n\nИли можешь пропустить и установить уровень вручную позже в /settings",
"return": "С возвращением, {first_name}! 👋\n\nГотов продолжить обучение?\n\n<b>Быстрый доступ:</b>\n• /vocabulary - посмотреть словарь\n• /task - получить задание\n• /practice - практика диалога\n• /words [тема] - тематическая подборка\n• /stats - статистика\n• /help - все команды",
"help": "<b>📖 Справка по командам:</b>\n\n<b>Управление словарём:</b>\n• /add [слово] - добавить слово в словарь\n• /vocabulary - просмотр словаря\n• /words [тема] - тематическая подборка слов\n• /import - импортировать слова из текста\n\n<b>Обучение:</b>\n• /task - задание (перевод, заполнение пропусков)\n• /practice - диалог с ИИ (6 сценариев)\n• /level_test - тест определения уровня\n\n<b>Статистика:</b>\n• /stats - твой прогресс\n\n<b>Настройки:</b>\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Например: <code>/add elephant</code>\n\nИли просто отправь слово без команды!",
"searching": "⏳ Ищу перевод и примеры...",
"examples_header": "<b>Примеры:</b>",
"translation_label": "Перевод",
"category_label": "Категория",
"level_label": "Уровень",
"confirm_question": "Добавить это слово в словарь?",
"btn_add": "✅ Добавить",
"btn_cancel": "❌ Отмена",
"exists": "Слово '<b>{word}</b>' уже есть в твоём словаре!\nПеревод: {translation}",
"added_success": "✅ Слово '<b>{word}</b>' добавлено!\n\nВсего слов в словаре: {count}\n\nПродолжай добавлять новые слова или используй /task для практики!",
"cancelled": "Отменено. Можешь добавить другое слово командой /add"
},
"vocab": {
"empty": "📚 Твой словарь пока пуст!\n\nДобавь первое слово командой /add или просто отправь мне слово.",
"header": "<b>📚 Твой словарь:</b>",
"accuracy_inline": "({n}% точность)",
"shown_last": "<i>Показаны последние 10 из {n} слов</i>",
"total": "<i>Всего слов: {n}</i>"
},
"practice": {
"start_text": "💬 <b>Диалоговая практика с AI</b>\n\nВыбери сценарий для разговора:\n\n• AI будет играть роль собеседника\n• Ты можешь общаться на английском\n• AI будет исправлять твои ошибки\n• Используй /stop для завершения диалога\n\nВыбери сценарий:",
"hints": "💡 <b>Подсказки:</b>",
"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": "<b>Исправления:</b>",
"end_title": "✅ <b>Диалог завершён!</b>",
"end_exchanged": "Сообщений обменено: <b>{n}</b>",
"end_keep": "Отличная работа! Продолжай практиковаться.",
"end_hint": "Используй /practice для нового диалога.",
"translation_unavailable": "Перевод недоступен",
"translation_already": "Перевод уже показан"
},
"tasks": {
"no_words": "📚 У тебя пока нет слов для практики!\n\nДобавь несколько слов командой /add, а затем возвращайся.",
"stopped": "Задания остановлены. Используй /task, чтобы начать заново.",
"finished": "Задания завершены. Используй /task, чтобы начать заново.",
"header": "📝 <b>Задание {i} из {n}</b>",
"write_answer": "\n💡 Напиши свой ответ:",
"checking": "⏳ Проверяю ответ...",
"correct": "✅ <b>Правильно!</b>",
"incorrect": "❌ <b>Неправильно</b>",
"your_answer": "Твой ответ",
"right_answer": "Правильный ответ",
"next_btn": "➡️ Следующее задание",
"stop_btn": "🔚 Завершить",
"cancelled": "Отменено. Можешь вернуться к заданиям командой /task.",
"finish_title": "{emoji} <b>Задание завершено!</b>",
"correct_of": "Правильных ответов: <b>{correct}</b> из {total}",
"accuracy": "Точность: <b>{accuracy}%</b>",
"use_task": "Используй /task для нового задания",
"use_stats": "Используй /stats для просмотра статистики",
"comment": {
"excellent": "Отличный результат!",
"good": "Хорошая работа!",
"average": "Неплохо, продолжай практиковаться!",
"poor": "Повтори эти слова еще раз!"
}
},
"reminder": {
"title": "⏰ <b>Напоминания</b>",
"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": "✅ <b>Напоминания включены!</b>",
"enabled_desc": "Ты будешь получать ежедневные напоминания о практике.",
"disabled_toast": "❌ Напоминания выключены",
"disabled_title": "❌ <b>Напоминания выключены</b>",
"disabled_desc": "Используй /reminder чтобы включить их снова.",
"set_title": "⏰ <b>Установка времени напоминаний</b>",
"set_desc": "Отправь время в формате <b>HH:MM</b> (UTC)",
"set_examples": "Примеры:\n• <code>09:00</code> - 9 утра по UTC\n• <code>18:30</code> - 18:30 по UTC\n• <code>20:00</code> - 8 вечера по UTC",
"set_utc_hint": "💡 UTC = МСК - 3 часа\n(если хочешь 12:00 по МСК, введи 09:00)",
"cancel_hint": "Отправь /cancel для отмены",
"cancelled": "❌ Установка времени отменена",
"invalid_format": "❌ Неверный формат времени!\n\nИспользуй формат <b>HH:MM</b> (например, 09:00 или 18:30)\nИли отправь /cancel для отмены",
"time_set_title": "✅ <b>Время установлено!</b>",
"status_on_line": "Статус: <b>Включены</b>",
"use_settings": "Используй /reminder для изменения настроек."
},
"stats": {
"header": "📊 <b>Твоя статистика</b>",
"total_words": "📚 Слов в словаре: <b>{n}</b>",
"studied_words": "📖 Слов изучено: <b>{n}</b>",
"total_tasks": "✍️ Заданий выполнено: <b>{n}</b>",
"correct_tasks": "✅ Правильных ответов: <b>{n}</b>",
"accuracy": "🎯 Точность: <b>{n}%</b>",
"hint_add_words": "Добавь слова командой /add чтобы начать обучение!",
"hint_first_task": "Выполни первое задание командой /task!",
"hint_keep_practice": "Продолжай практиковаться! 💪"
},
"level_test": {
"show_translation_btn": "👁️ Показать перевод вопроса",
"intro": "📊 <b>Тест определения уровня</b>\n\nЭтот короткий тест поможет определить твой уровень английского.\n\n📋 Тест включает 7 вопросов:\n• Грамматика\n• Лексика\n• Понимание\n\n⏱ Займёт около 2-3 минут\n\nГотов начать?",
"start_btn": "✅ Начать тест",
"cancel_btn": "❌ Отмена",
"press_button": "Нажми кнопку когда будешь готов:",
"cancelled": "❌ Тест отменён",
"q_header": "❓ <b>Вопрос {i} из {n}</b>"
},
"words": {
"generating": "🔄 Генерирую подборку слов по теме '{theme}'...",
"generate_failed": "❌ Не удалось сгенерировать подборку. Попробуй позже.",
"header": "📚 <b>Подборка слов: {theme}</b>",
"choose": "Выбери слова, которые хочешь добавить в словарь:",
"add_all_btn": "✅ Добавить все",
"close_btn": "❌ Закрыть",
"help_title": "📚 <b>Тематические подборки слов</b>",
"help_usage": "Используй: <code>/words [тема]</code>",
"help_examples": "Примеры:\n• <code>/words travel</code> - путешествия\n• <code>/words food</code> - еда\n• <code>/words work</code> - работа\n• <code>/words nature</code> - природа\n• <code>/words technology</code> - технологии",
"help_note": "Я сгенерирую 10 слов по теме, подходящих для твоего уровня!",
"popular": "Популярные темы:",
"topic_travel": "✈️ Путешествия",
"topic_food": "🍔 Еда",
"topic_work": "💼 Работа",
"topic_nature": "🌿 Природа",
"topic_technology": "💻 Технологии",
"err_not_found": "❌ Ошибка: слово не найдено",
"already_exists": "Слово '{word}' уже в словаре",
"added_single": "✅ Слово '{word}' добавлено в словарь"
}
}

93
migrations/env.py Normal file
View File

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

View File

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

View File

@@ -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}. Веди естественный диалог и помогай исправлять ошибки."}
]
# Добавляем историю

View File

@@ -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"Переведи слово: <b>{word.word_original}</b>",
'question': f"{prompt} <b>{word.word_original}</b>",
'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"Переведи слово: <b>{word.word_translation}</b>",
'question': f"{prompt} <b>{word.word_translation}</b>",
'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"<b>{sentence_data['sentence']}</b>\n\n"
f"<i>{sentence_data.get('translation', '')}</i>"
),

View File

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

View File

@@ -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]:

51
utils/i18n.py Normal file
View File

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