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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user