feat: restructure menu and add file import

- Consolidate "Add word" menu with submenu (Manual, Thematic, Import)
- Add file import support (.txt, .md) with AI batch translation
- Add vocabulary pagination with navigation buttons
- Add "Add word" button in tasks for new words mode
- Fix undefined variables bug in vocabulary confirm handler
- Add localization keys for add_menu in ru/en/ja

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-05 20:15:47 +03:00
parent 2097950c60
commit 63e2615243
12 changed files with 883 additions and 47 deletions

View File

@@ -1,4 +1,5 @@
from aiogram import Router, F import re
from aiogram import Router, F, Bot
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
@@ -14,6 +15,11 @@ from utils.levels import get_user_level_for_language
router = Router() router = Router()
# Поддерживаемые расширения файлов
SUPPORTED_EXTENSIONS = {'.txt', '.md'}
# Разделители между словом и переводом
WORD_SEPARATORS = re.compile(r'\s*[-–—:=\t]\s*')
class ImportStates(StatesGroup): class ImportStates(StatesGroup):
"""Состояния для импорта слов из текста""" """Состояния для импорта слов из текста"""
@@ -271,3 +277,230 @@ async def close_import(callback: CallbackQuery, state: FSMContext):
await callback.message.delete() await callback.message.delete()
await state.clear() await state.clear()
await callback.answer() await callback.answer()
def parse_word_line(line: str) -> dict | None:
"""
Парсит строку формата 'слово - перевод' или 'слово : перевод'
Или просто 'слово' (без перевода)
Возвращает dict с word и translation (может быть None) или None если пустая строка
"""
line = line.strip()
if not line or line.startswith('#'): # Пропускаем пустые и комментарии
return None
# Пробуем разделить по разделителям
parts = WORD_SEPARATORS.split(line, maxsplit=1)
if len(parts) == 2:
word = parts[0].strip()
translation = parts[1].strip()
if word and translation:
return {'word': word, 'translation': translation}
# Если разделителя нет — это просто слово без перевода
word = line.strip()
if word:
return {'word': word, 'translation': None}
return None
def parse_file_content(content: str) -> tuple[list[dict], bool]:
"""
Парсит содержимое файла и возвращает список слов
Возвращает (words, needs_translation) — нужен ли перевод через AI
"""
words = []
seen = set() # Для дедупликации
needs_translation = False
for line in content.split('\n'):
parsed = parse_word_line(line)
if parsed and parsed['word'].lower() not in seen:
words.append(parsed)
seen.add(parsed['word'].lower())
if parsed['translation'] is None:
needs_translation = True
return words, needs_translation
@router.message(F.document)
async def handle_file_import(message: Message, state: FSMContext, bot: Bot):
"""Обработка файлов для импорта слов"""
document = message.document
# Проверяем расширение файла
file_name = document.file_name or ''
file_ext = ''
if '.' in file_name:
file_ext = '.' + file_name.rsplit('.', 1)[-1].lower()
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
if not user:
await message.answer(t('ru', 'common.start_first'))
return
lang = get_user_lang(user)
if file_ext not in SUPPORTED_EXTENSIONS:
await message.answer(t(lang, 'import_file.unsupported_format'))
return
# Проверяем размер файла (макс 1MB)
if document.file_size > 1024 * 1024:
await message.answer(t(lang, 'import_file.too_large'))
return
# Скачиваем файл
try:
file = await bot.get_file(document.file_id)
file_content = await bot.download_file(file.file_path)
content = file_content.read().decode('utf-8')
except UnicodeDecodeError:
await message.answer(t(lang, 'import_file.encoding_error'))
return
except Exception:
await message.answer(t(lang, 'import_file.download_error'))
return
# Парсим содержимое
words, needs_translation = parse_file_content(content)
if not words:
await message.answer(t(lang, 'import_file.no_words_found'))
return
# Ограничиваем количество слов
max_words = 50 if needs_translation else 100
if len(words) > max_words:
words = words[:max_words]
await message.answer(t(lang, 'import_file.truncated', n=max_words))
# Если нужен перевод — отправляем в AI
if needs_translation:
processing_msg = await message.answer(t(lang, 'import_file.translating'))
# Получаем переводы от AI
words_to_translate = [w['word'] for w in words]
translations = await ai_service.translate_words_batch(
words=words_to_translate,
source_lang=user.learning_language,
translation_lang=user.language_interface
)
await processing_msg.delete()
# Обновляем слова переводами
if isinstance(translations, list):
for i, word_data in enumerate(words):
if i < len(translations):
tr = translations[i]
word_data['translation'] = tr.get('translation', '')
word_data['transcription'] = tr.get('transcription', '')
if tr.get('reading'): # Фуригана для японского
word_data['reading'] = tr.get('reading')
else:
# Если AI вернул не список — пробуем сопоставить по слову
for word_data in words:
word_data['translation'] = ''
word_data['transcription'] = ''
# Сохраняем данные в состоянии и показываем слова
await state.update_data(
words=words,
user_id=user.id,
level=get_user_level_for_language(user)
)
await state.set_state(ImportStates.viewing_words)
await show_file_words(message, words, lang)
async def show_file_words(message: Message, words: list, lang: str):
"""Показать слова из файла с кнопками для добавления"""
# Показываем первые 20 слов в сообщении
display_words = words[:20]
text = t(lang, 'import_file.found_header', n=len(words)) + "\n\n"
for idx, word_data in enumerate(display_words, 1):
word = word_data['word']
translation = word_data.get('translation', '')
transcription = word_data.get('transcription', '')
line = f"{idx}. <b>{word}</b>"
if transcription:
line += f" [{transcription}]"
if translation:
line += f"{translation}"
text += line + "\n"
if len(words) > 20:
text += f"\n<i>...и ещё {len(words) - 20} слов</i>\n"
text += "\n" + t(lang, 'import_file.choose_action')
# Кнопки действий
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=t(lang, 'import_file.add_all_btn', n=len(words)),
callback_data="import_file_all"
)],
[InlineKeyboardButton(
text=t(lang, 'words.close_btn'),
callback_data="close_import"
)]
])
await message.answer(text, reply_markup=keyboard)
@router.callback_query(F.data == "import_file_all", ImportStates.viewing_words)
async def import_file_all_words(callback: CallbackQuery, state: FSMContext):
"""Добавить все слова из файла"""
await callback.answer()
data = await state.get_data()
words = data.get('words', [])
user_id = data.get('user_id')
added_count = 0
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(
session, user_id, word_data['word']
)
if existing:
skipped_count += 1
continue
# Добавляем слово
await VocabularyService.add_word(
session=session,
user_id=user_id,
word_original=word_data['word'],
word_translation=word_data.get('translation', ''),
source_lang=user.learning_language if user else None,
translation_lang=user.language_interface if user else None,
transcription=word_data.get('transcription'),
source=WordSource.IMPORT
)
added_count += 1
lang = get_user_lang(user)
result_text = t(lang, 'import.added_count', n=added_count)
if skipped_count > 0:
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()

View File

@@ -30,10 +30,6 @@ def main_menu_keyboard(lang: str = 'ru') -> ReplyKeyboardMarkup:
KeyboardButton(text=t(lang, "menu.task")), KeyboardButton(text=t(lang, "menu.task")),
KeyboardButton(text=t(lang, "menu.practice")), KeyboardButton(text=t(lang, "menu.practice")),
], ],
[
KeyboardButton(text=t(lang, "menu.words")),
KeyboardButton(text=t(lang, "menu.import")),
],
[ [
KeyboardButton(text=t(lang, "menu.stats")), KeyboardButton(text=t(lang, "menu.stats")),
KeyboardButton(text=t(lang, "menu.settings")), KeyboardButton(text=t(lang, "menu.settings")),
@@ -100,12 +96,109 @@ def _menu_match(key: str):
@router.message(_menu_match('menu.add')) @router.message(_menu_match('menu.add'))
async def btn_add_pressed(message: Message, state: FSMContext): async def btn_add_pressed(message: Message, state: FSMContext):
from bot.handlers.vocabulary import AddWordStates """Показать меню добавления слов"""
async with async_session_maker() as session: async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id) user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
lang = (user.language_interface if user else 'ru') or 'ru' lang = (user.language_interface if user else 'ru') or 'ru'
await message.answer(t(lang, 'add.prompt'))
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'add_menu.manual'), callback_data="add_manual")],
[InlineKeyboardButton(text=t(lang, 'add_menu.thematic'), callback_data="add_thematic")],
[InlineKeyboardButton(text=t(lang, 'add_menu.import'), callback_data="add_import")]
])
await message.answer(t(lang, 'add_menu.title'), reply_markup=keyboard)
@router.callback_query(F.data == "add_manual")
async def add_manual_callback(callback: CallbackQuery, state: FSMContext):
"""Добавить слово вручную"""
await callback.answer()
from bot.handlers.vocabulary import AddWordStates
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 state.set_state(AddWordStates.waiting_for_word) await state.set_state(AddWordStates.waiting_for_word)
await callback.message.edit_text(t(lang, 'add.prompt'))
@router.callback_query(F.data == "add_thematic")
async def add_thematic_callback(callback: CallbackQuery):
"""Тематические слова"""
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'
# Показываем подсказку по использованию /words
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')
)
# Популярные темы как кнопки
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text=t(lang, 'words.topic_travel'), callback_data="words_travel"),
InlineKeyboardButton(text=t(lang, 'words.topic_food'), callback_data="words_food")
],
[
InlineKeyboardButton(text=t(lang, 'words.topic_work'), callback_data="words_work"),
InlineKeyboardButton(text=t(lang, 'words.topic_technology'), callback_data="words_technology")
],
[InlineKeyboardButton(text="⬅️ " + t(lang, 'settings.back'), callback_data="back_to_add_menu")]
])
await callback.message.edit_text(text, reply_markup=keyboard)
@router.callback_query(F.data.startswith("words_"))
async def words_topic_callback(callback: CallbackQuery, state: FSMContext):
"""Генерация слов по теме"""
topic = callback.data.replace("words_", "")
await callback.answer()
await callback.message.delete()
from bot.handlers.words import generate_words_for_theme
await generate_words_for_theme(callback.message, state, topic, callback.from_user.id)
@router.callback_query(F.data == "add_import")
async def add_import_callback(callback: CallbackQuery):
"""Показать меню импорта"""
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'
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'import_menu.from_text'), callback_data="import_from_text")],
[InlineKeyboardButton(text=t(lang, 'import_menu.from_file'), callback_data="import_from_file")],
[InlineKeyboardButton(text="⬅️ " + t(lang, 'settings.back'), callback_data="back_to_add_menu")]
])
await callback.message.edit_text(t(lang, 'import_menu.title'), reply_markup=keyboard)
@router.callback_query(F.data == "back_to_add_menu")
async def back_to_add_menu_callback(callback: CallbackQuery):
"""Вернуться в меню добавления"""
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'
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'add_menu.manual'), callback_data="add_manual")],
[InlineKeyboardButton(text=t(lang, 'add_menu.thematic'), callback_data="add_thematic")],
[InlineKeyboardButton(text=t(lang, 'add_menu.import'), callback_data="add_import")]
])
await callback.message.edit_text(t(lang, 'add_menu.title'), reply_markup=keyboard)
@router.message(_menu_match('menu.vocab')) @router.message(_menu_match('menu.vocab'))
@@ -128,8 +221,51 @@ async def btn_practice_pressed(message: Message, state: FSMContext):
@router.message(_menu_match('menu.import')) @router.message(_menu_match('menu.import'))
async def btn_import_pressed(message: Message, state: FSMContext): async def btn_import_pressed(message: Message, state: FSMContext):
from bot.handlers.import_text import cmd_import """Показать меню импорта"""
await cmd_import(message, state) 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'
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'import_menu.from_text'), callback_data="import_from_text")],
[InlineKeyboardButton(text=t(lang, 'import_menu.from_file'), callback_data="import_from_file")]
])
await message.answer(t(lang, 'import_menu.title'), reply_markup=keyboard)
@router.callback_query(F.data == "import_from_text")
async def import_from_text_callback(callback: CallbackQuery, state: FSMContext):
"""Импорт из текста"""
await callback.answer()
from bot.handlers.import_text import ImportStates
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
if not user:
await callback.message.edit_text(t('ru', 'common.start_first'))
return
lang = user.language_interface or 'ru'
await state.set_state(ImportStates.waiting_for_text)
await callback.message.edit_text(
t(lang, 'import.title') + "\n\n" +
t(lang, 'import.desc') + "\n\n" +
t(lang, 'import.can_send') + "\n\n" +
t(lang, 'import.cancel_hint')
)
@router.callback_query(F.data == "import_from_file")
async def import_from_file_callback(callback: CallbackQuery):
"""Импорт из файла"""
await callback.answer()
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = (user.language_interface if user else 'ru') or 'ru'
await callback.message.edit_text(t(lang, 'import_menu.file_hint'))
@router.message(_menu_match('menu.stats')) @router.message(_menu_match('menu.stats'))

View File

@@ -5,23 +5,27 @@ from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from database.db import async_session_maker from database.db import async_session_maker
from database.models import WordSource
from services.user_service import UserService from services.user_service import UserService
from services.task_service import TaskService from services.task_service import TaskService
from services.vocabulary_service import VocabularyService
from services.ai_service import ai_service from services.ai_service import ai_service
from utils.i18n import t from utils.i18n import t, get_user_lang
from utils.levels import get_user_level_for_language
router = Router() router = Router()
class TaskStates(StatesGroup): class TaskStates(StatesGroup):
"""Состояния для прохождения заданий""" """Состояния для прохождения заданий"""
choosing_mode = State()
doing_tasks = State() doing_tasks = State()
waiting_for_answer = State() waiting_for_answer = State()
@router.message(Command("task")) @router.message(Command("task"))
async def cmd_task(message: Message, state: FSMContext): async def cmd_task(message: Message, state: FSMContext):
"""Обработчик команды /task""" """Обработчик команды /task — показываем меню выбора режима"""
async with async_session_maker() as session: async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id) user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
@@ -29,7 +33,39 @@ async def cmd_task(message: Message, state: FSMContext):
await message.answer(t('ru', 'common.start_first')) await message.answer(t('ru', 'common.start_first'))
return return
# Генерируем задания разных типов lang = get_user_lang(user)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=t(lang, 'tasks.mode_vocabulary'),
callback_data="task_mode_vocabulary"
)],
[InlineKeyboardButton(
text=t(lang, 'tasks.mode_new_words'),
callback_data="task_mode_new"
)]
])
await state.update_data(user_id=user.id)
await state.set_state(TaskStates.choosing_mode)
await message.answer(t(lang, 'tasks.choose_mode'), reply_markup=keyboard)
@router.callback_query(F.data == "task_mode_vocabulary", TaskStates.choosing_mode)
async def start_vocabulary_tasks(callback: CallbackQuery, state: FSMContext):
"""Начать задания по словам из словаря"""
await callback.answer()
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
if not user:
await callback.message.edit_text(t('ru', 'common.start_first'))
return
lang = get_user_lang(user)
# Генерируем задания по словам из словаря
tasks = await TaskService.generate_mixed_tasks( tasks = await TaskService.generate_mixed_tasks(
session, user.id, count=5, session, user.id, count=5,
learning_lang=user.learning_language, learning_lang=user.learning_language,
@@ -37,7 +73,8 @@ async def cmd_task(message: Message, state: FSMContext):
) )
if not tasks: if not tasks:
await message.answer(t(user.language_interface or 'ru', 'tasks.no_words')) await callback.message.edit_text(t(lang, 'tasks.no_words'))
await state.clear()
return return
# Сохраняем задания в состоянии # Сохраняем задания в состоянии
@@ -45,12 +82,70 @@ async def cmd_task(message: Message, state: FSMContext):
tasks=tasks, tasks=tasks,
current_task_index=0, current_task_index=0,
correct_count=0, correct_count=0,
user_id=user.id user_id=user.id,
mode='vocabulary'
) )
await state.set_state(TaskStates.doing_tasks) await state.set_state(TaskStates.doing_tasks)
# Показываем первое задание await callback.message.delete()
await show_current_task(message, state) await show_current_task(callback.message, state)
@router.callback_query(F.data == "task_mode_new", TaskStates.choosing_mode)
async def start_new_words_tasks(callback: CallbackQuery, state: FSMContext):
"""Начать задания с новыми словами"""
await callback.answer()
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
if not user:
await callback.message.edit_text(t('ru', 'common.start_first'))
return
lang = get_user_lang(user)
level = get_user_level_for_language(user)
# Показываем индикатор загрузки
await callback.message.edit_text(t(lang, 'tasks.generating_new'))
# Генерируем новые слова через AI
words = await ai_service.generate_thematic_words(
theme="random everyday vocabulary",
level=level,
count=5,
learning_lang=user.learning_language,
translation_lang=user.language_interface,
)
if not words:
await callback.message.edit_text(t(lang, 'tasks.generate_failed'))
await state.clear()
return
# Преобразуем слова в задания
tasks = []
translate_prompt = t(lang, 'tasks.translate_to', lang_name=t(lang, f'lang.{user.language_interface}'))
for word in words:
tasks.append({
'type': 'translate',
'question': f"{translate_prompt}: {word.get('word', '')}",
'word': word.get('word', ''),
'correct_answer': word.get('translation', ''),
'transcription': word.get('transcription', '')
})
await state.update_data(
tasks=tasks,
current_task_index=0,
correct_count=0,
user_id=user.id,
mode='new_words'
)
await state.set_state(TaskStates.doing_tasks)
await callback.message.delete()
await show_current_task(callback.message, state)
async def show_current_task(message: Message, state: FSMContext): async def show_current_task(message: Message, state: FSMContext):
@@ -162,10 +257,18 @@ async def process_answer(message: Message, state: FSMContext):
) )
# Показываем результат и кнопку "Далее" # Показываем результат и кнопку "Далее"
keyboard = InlineKeyboardMarkup(inline_keyboard=[ mode = data.get('mode')
[InlineKeyboardButton(text=t(lang, 'tasks.next_btn'), callback_data="next_task")], buttons = [[InlineKeyboardButton(text=t(lang, 'tasks.next_btn'), callback_data="next_task")]]
[InlineKeyboardButton(text=t(lang, 'tasks.stop_btn'), callback_data="stop_tasks")]
]) # Для режима new_words добавляем кнопку "Добавить слово"
if mode == 'new_words':
buttons.append([InlineKeyboardButton(
text=t(lang, 'tasks.add_word_btn'),
callback_data=f"add_task_word_{current_index}"
)])
buttons.append([InlineKeyboardButton(text=t(lang, 'tasks.stop_btn'), callback_data="stop_tasks")])
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
await message.answer(result_text, reply_markup=keyboard) await message.answer(result_text, reply_markup=keyboard)
# После показа результата ждём нажатия кнопки переключаемся в состояние doing_tasks # После показа результата ждём нажатия кнопки переключаемся в состояние doing_tasks
@@ -180,6 +283,53 @@ async def next_task(callback: CallbackQuery, state: FSMContext):
await callback.answer() await callback.answer()
@router.callback_query(F.data.startswith("add_task_word_"), TaskStates.doing_tasks)
async def add_task_word(callback: CallbackQuery, state: FSMContext):
"""Добавить слово из задания в словарь"""
task_index = int(callback.data.split("_")[-1])
data = await state.get_data()
tasks = data.get('tasks', [])
if task_index >= len(tasks):
await callback.answer()
return
task = tasks[task_index]
word = task.get('word', '')
translation = task.get('correct_answer', '')
transcription = task.get('transcription', '')
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()
return
lang = get_user_lang(user)
# Проверяем, есть ли слово уже в словаре
existing = await VocabularyService.get_word_by_original(session, user.id, word)
if existing:
await callback.answer(t(lang, 'tasks.word_already_exists', word=word), show_alert=True)
return
# Добавляем слово в словарь
await VocabularyService.add_word(
session=session,
user_id=user.id,
word_original=word,
word_translation=translation,
source_lang=user.learning_language,
translation_lang=user.language_interface,
transcription=transcription,
source=WordSource.AI_TASK
)
await callback.answer(t(lang, 'tasks.word_added', word=word), show_alert=True)
@router.callback_query(F.data == "stop_tasks", TaskStates.doing_tasks) @router.callback_query(F.data == "stop_tasks", TaskStates.doing_tasks)
async def stop_tasks_callback(callback: CallbackQuery, state: FSMContext): async def stop_tasks_callback(callback: CallbackQuery, state: FSMContext):
"""Остановить выполнение заданий через кнопку""" """Остановить выполнение заданий через кнопку"""

View File

@@ -124,6 +124,11 @@ async def confirm_add_word(callback: CallbackQuery, state: FSMContext):
user_id = data.get("user_id") user_id = data.get("user_id")
async with async_session_maker() as session: async with async_session_maker() as session:
# Получаем пользователя для языков
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
source_lang = user.learning_language if user else 'en'
ui_lang = user.language_interface if user else 'ru'
# Добавляем слово в базу # Добавляем слово в базу
await VocabularyService.add_word( await VocabularyService.add_word(
session, session,
@@ -140,12 +145,9 @@ async def confirm_add_word(callback: CallbackQuery, state: FSMContext):
) )
# Получаем общее количество слов # Получаем общее количество слов
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) words_count = await VocabularyService.get_words_count(session, user_id, learning_lang=user.learning_language)
lang = ui_lang or 'ru'
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( await callback.message.edit_text(
t(lang, 'add.added_success', word=word_data['word'], count=words_count) t(lang, 'add.added_success', word=word_data['word'], count=words_count)
) )
@@ -164,29 +166,55 @@ async def cancel_add_word(callback: CallbackQuery, state: FSMContext):
await callback.answer() await callback.answer()
WORDS_PER_PAGE = 10
@router.message(Command("vocabulary")) @router.message(Command("vocabulary"))
async def cmd_vocabulary(message: Message): async def cmd_vocabulary(message: Message):
"""Обработчик команды /vocabulary""" """Обработчик команды /vocabulary"""
await show_vocabulary_page(message, page=0)
async def show_vocabulary_page(message_or_callback, page: int = 0, edit: bool = False):
"""Показать страницу словаря"""
# Определяем, это Message или CallbackQuery
# В CallbackQuery from_user — это пользователь, а message.from_user — бот
user_id = message_or_callback.from_user.id
async with async_session_maker() as session: async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id) user = await UserService.get_user_by_telegram_id(session, user_id)
if not user: if not user:
await message.answer(t('ru', 'common.start_first')) if edit:
await message_or_callback.message.edit_text(t('ru', 'common.start_first'))
else:
await message_or_callback.answer(t('ru', 'common.start_first'))
return return
# Получаем слова пользователя # Получаем слова с пагинацией
words = await VocabularyService.get_user_words(session, user.id, limit=10, learning_lang=user.learning_language) offset = page * WORDS_PER_PAGE
words = await VocabularyService.get_user_words(
session, user.id,
limit=WORDS_PER_PAGE,
offset=offset,
learning_lang=user.learning_language
)
total_count = await VocabularyService.get_words_count(session, user.id, learning_lang=user.learning_language) total_count = await VocabularyService.get_words_count(session, user.id, learning_lang=user.learning_language)
if not words: lang = get_user_lang(user)
lang = (user.language_interface if user else 'ru') or 'ru'
await message.answer(t(lang, 'vocab.empty')) if not words and page == 0:
if edit:
await message_or_callback.message.edit_text(t(lang, 'vocab.empty'))
else:
await message_or_callback.answer(t(lang, 'vocab.empty'))
return return
# Формируем список слов # Формируем список слов
lang = (user.language_interface if user else 'ru') or 'ru' total_pages = (total_count + WORDS_PER_PAGE - 1) // WORDS_PER_PAGE
words_list = t(lang, 'vocab.header') + "\n\n" words_list = t(lang, 'vocab.header') + "\n\n"
for idx, word in enumerate(words, 1):
for idx, word in enumerate(words, start=offset + 1):
progress = "" progress = ""
if word.times_reviewed > 0: if word.times_reviewed > 0:
accuracy = int((word.correct_answers / word.times_reviewed) * 100) accuracy = int((word.correct_answers / word.times_reviewed) * 100)
@@ -197,9 +225,49 @@ async def cmd_vocabulary(message: Message):
f" 🔊 [{word.transcription or ''}]{progress}\n\n" f" 🔊 [{word.transcription or ''}]{progress}\n\n"
) )
if total_count > 10: words_list += t(lang, 'vocab.page_info', page=page + 1, total=total_pages, count=total_count)
words_list += "\n" + t(lang, 'vocab.shown_last', n=total_count)
else:
words_list += "\n" + t(lang, 'vocab.total', n=total_count)
await message.answer(words_list) # Кнопки пагинации
buttons = []
nav_row = []
if page > 0:
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"vocab_page_{page - 1}"))
nav_row.append(InlineKeyboardButton(text=f"{page + 1}/{total_pages}", callback_data="vocab_noop"))
if page < total_pages - 1:
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"vocab_page_{page + 1}"))
if nav_row:
buttons.append(nav_row)
buttons.append([InlineKeyboardButton(text=t(lang, 'vocab.close_btn'), callback_data="vocab_close")])
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
if edit:
await message_or_callback.message.edit_text(words_list, reply_markup=keyboard)
else:
await message_or_callback.answer(words_list, reply_markup=keyboard)
@router.callback_query(F.data.startswith("vocab_page_"))
async def vocab_page_callback(callback: CallbackQuery):
"""Переключение страницы словаря"""
page = int(callback.data.split("_")[-1])
await callback.answer()
await show_vocabulary_page(callback, page=page, edit=True)
@router.callback_query(F.data == "vocab_noop")
async def vocab_noop_callback(callback: CallbackQuery):
"""Пустой callback для кнопки с номером страницы"""
await callback.answer()
@router.callback_query(F.data == "vocab_close")
async def vocab_close_callback(callback: CallbackQuery):
"""Закрыть словарь"""
await callback.message.delete()
await callback.answer()

View File

@@ -45,6 +45,13 @@ async def cmd_words(message: Message, state: FSMContext):
return return
theme = command_parts[1].strip() theme = command_parts[1].strip()
await generate_words_for_theme(message, state, theme, message.from_user.id)
async def generate_words_for_theme(message: Message, state: FSMContext, theme: str, user_id: int):
"""Генерация слов по теме (вызывается из cmd_words и callback)"""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, user_id)
# Показываем индикатор генерации # Показываем индикатор генерации
lang = user.language_interface or 'ru' lang = user.language_interface or 'ru'

View File

@@ -45,6 +45,7 @@ class WordSource(str, enum.Enum):
CONTEXT = "context" # Из контекста диалога CONTEXT = "context" # Из контекста диалога
IMPORT = "import" # Импорт из текста IMPORT = "import" # Импорт из текста
ERROR = "error" # Из ошибок в заданиях ERROR = "error" # Из ошибок в заданиях
AI_TASK = "ai_task" # Из AI-задания
class User(Base): class User(Base):

View File

@@ -5,15 +5,32 @@
"task": "🧠 Task", "task": "🧠 Task",
"practice": "💬 Practice", "practice": "💬 Practice",
"words": "🎯 Thematic words", "words": "🎯 Thematic words",
"import": "📖 Import from text", "import": "📖 Import",
"stats": "📊 Stats", "stats": "📊 Stats",
"settings": "⚙️ Settings", "settings": "⚙️ Settings",
"below": "Main menu below ⤵️" "below": "Main menu below ⤵️"
}, },
"add_menu": {
"title": " <b>Add words</b>\n\nChoose method:",
"manual": "📝 Manual",
"thematic": "🎯 Thematic words",
"import": "📖 Import"
},
"import_menu": {
"title": "📖 <b>Import words</b>\n\nChoose import method:",
"from_text": "📝 From text",
"from_file": "📄 From file (.txt, .md)",
"file_hint": "📄 <b>Import from file</b>\n\nSend a .txt or .md file with your words.\n\n<b>Formats:</b>\n• One word per line (AI will translate)\n• <code>word - translation</code>\n• <code>word : translation</code>"
},
"common": { "common": {
"start_first": "First run /start to register", "start_first": "First run /start to register",
"translation": "Translation" "translation": "Translation"
}, },
"lang": {
"ru": "Russian",
"en": "English",
"ja": "Japanese"
},
"import": { "import": {
"title": "📖 <b>Import words from text</b>", "title": "📖 <b>Import words from text</b>",
"desc": "Send me text in your learning language, and I will extract useful words to study.", "desc": "Send me text in your learning language, and I will extract useful words to study.",
@@ -56,7 +73,9 @@
"header": "<b>📚 Your vocabulary:</b>", "header": "<b>📚 Your vocabulary:</b>",
"accuracy_inline": "({n}% accuracy)", "accuracy_inline": "({n}% accuracy)",
"shown_last": "<i>Showing last 10 of {n} words</i>", "shown_last": "<i>Showing last 10 of {n} words</i>",
"total": "<i>Total words: {n}</i>" "total": "<i>Total words: {n}</i>",
"page_info": "\n📖 Page {page} of {total} • Total words: {count}",
"close_btn": "❌ Close"
}, },
"practice": { "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:", "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:",
@@ -92,6 +111,12 @@
"go_words_hint": "Use /words [topic] for word sets" "go_words_hint": "Use /words [topic] for word sets"
}, },
"tasks": { "tasks": {
"choose_mode": "🧠 <b>Choose task mode:</b>",
"mode_vocabulary": "📚 Words from vocabulary",
"mode_new_words": "✨ New words",
"generating_new": "🔄 Generating new words...",
"generate_failed": "❌ Failed to generate words. Try again later.",
"translate_to": "Translate to {lang_name}",
"no_words": "📚 You don't have words to practice yet!\n\nAdd some words with /add and come back.", "no_words": "📚 You don't have words to practice yet!\n\nAdd some words with /add and come back.",
"stopped": "Exercises stopped. Use /task to start again.", "stopped": "Exercises stopped. Use /task to start again.",
"finished": "Exercises finished. Use /task to start again.", "finished": "Exercises finished. Use /task to start again.",
@@ -104,6 +129,9 @@
"right_answer": "Right answer", "right_answer": "Right answer",
"next_btn": "➡️ Next task", "next_btn": "➡️ Next task",
"stop_btn": "🔚 Stop", "stop_btn": "🔚 Stop",
"add_word_btn": " Add word",
"word_added": "✅ Word '{word}' added to vocabulary!",
"word_already_exists": "Word '{word}' is already in vocabulary",
"cancelled": "Cancelled. You can return to tasks with /task.", "cancelled": "Cancelled. You can return to tasks with /task.",
"finish_title": "{emoji} <b>Task finished!</b>", "finish_title": "{emoji} <b>Task finished!</b>",
"correct_of": "Correct answers: <b>{correct}</b> of {total}", "correct_of": "Correct answers: <b>{correct}</b> of {total}",
@@ -219,6 +247,18 @@
"import_extra": { "import_extra": {
"cancelled": "❌ Import cancelled." "cancelled": "❌ Import cancelled."
}, },
"import_file": {
"unsupported_format": "❌ Unsupported file format.\n\nSupported: .txt, .md\n\nFile format:\n<code>word - translation</code>\n<code>word : translation</code>",
"too_large": "❌ File is too large (max 1 MB)",
"encoding_error": "❌ Encoding error. Make sure the file is UTF-8",
"download_error": "❌ Failed to download file. Try again",
"no_words_found": "❌ No words found in file.\n\nMake sure the format is correct:\n<code>word - translation</code>\n<code>word : translation</code>",
"truncated": "⚠️ File contains more than {n} words. Importing first {n}.",
"found_header": "📄 <b>Words found in file: {n}</b>",
"choose_action": "Choose action:",
"add_all_btn": "✅ Add all ({n})",
"translating": "🔄 Translating words with AI..."
},
"level_test_extra": { "level_test_extra": {
"generating": "🔄 Generating questions...", "generating": "🔄 Generating questions...",
"generate_failed": "❌ Failed to generate test. Try later or use /settings to set level manually.", "generate_failed": "❌ Failed to generate test. Try later or use /settings to set level manually.",

View File

@@ -5,15 +5,32 @@
"task": "🧠 課題", "task": "🧠 課題",
"practice": "💬 練習", "practice": "💬 練習",
"words": "🎯 テーマ別単語", "words": "🎯 テーマ別単語",
"import": "📖 テキストからインポート", "import": "📖 インポート",
"stats": "📊 統計", "stats": "📊 統計",
"settings": "⚙️ 設定", "settings": "⚙️ 設定",
"below": "メインメニューは下にあります ⤵️" "below": "メインメニューは下にあります ⤵️"
}, },
"add_menu": {
"title": " <b>単語を追加</b>\n\n方法を選択:",
"manual": "📝 手動",
"thematic": "🎯 テーマ別単語",
"import": "📖 インポート"
},
"import_menu": {
"title": "📖 <b>単語のインポート</b>\n\nインポート方法を選択:",
"from_text": "📝 テキストから",
"from_file": "📄 ファイルから (.txt, .md)",
"file_hint": "📄 <b>ファイルからインポート</b>\n\n単語が入った .txt または .md ファイルを送信してください。\n\n<b>形式:</b>\n• 1行に1単語AIが翻訳\n• <code>単語 - 翻訳</code>\n• <code>単語 : 翻訳</code>"
},
"common": { "common": {
"start_first": "まず /start を実行してください", "start_first": "まず /start を実行してください",
"translation": "翻訳" "translation": "翻訳"
}, },
"lang": {
"ru": "ロシア語",
"en": "英語",
"ja": "日本語"
},
"import": { "import": {
"title": "📖 <b>テキストから単語をインポート</b>", "title": "📖 <b>テキストから単語をインポート</b>",
"desc": "学習言語のテキストを送ってください。学習に役立つ単語を抽出します。", "desc": "学習言語のテキストを送ってください。学習に役立つ単語を抽出します。",
@@ -56,7 +73,9 @@
"header": "<b>📚 あなたの単語帳:</b>", "header": "<b>📚 あなたの単語帳:</b>",
"accuracy_inline": "(正答率 {n}%)", "accuracy_inline": "(正答率 {n}%)",
"shown_last": "<i>{n} 語のうち最新の10語を表示</i>", "shown_last": "<i>{n} 語のうち最新の10語を表示</i>",
"total": "<i>合計: {n} 語</i>" "total": "<i>合計: {n} 語</i>",
"page_info": "\n📖 {page} / {total} ページ • 合計: {count} 語",
"close_btn": "❌ 閉じる"
}, },
"practice": { "practice": {
"start_text": "💬 <b>AIとの会話練習</b>\n\nシナリオを選んでください\n\n• AIが相手役を務めます\n• 英語でやり取りできます\n• 間違いをAIが指摘します\n• 終了するには /stop を使用\n\nシナリオを選択", "start_text": "💬 <b>AIとの会話練習</b>\n\nシナリオを選んでください\n\n• AIが相手役を務めます\n• 英語でやり取りできます\n• 間違いをAIが指摘します\n• 終了するには /stop を使用\n\nシナリオを選択",
@@ -84,6 +103,12 @@
"go_words_hint": "/words [テーマ] で単語セットを取得できます" "go_words_hint": "/words [テーマ] で単語セットを取得できます"
}, },
"tasks": { "tasks": {
"choose_mode": "🧠 <b>課題モードを選択:</b>",
"mode_vocabulary": "📚 単語帳から",
"mode_new_words": "✨ 新しい単語",
"generating_new": "🔄 新しい単語を生成中...",
"generate_failed": "❌ 単語の生成に失敗しました。後でもう一度お試しください。",
"translate_to": "{lang_name}に翻訳",
"no_words": "📚 まだ練習用の単語がありません!\n\n/add で単語を追加してから戻ってきてください。", "no_words": "📚 まだ練習用の単語がありません!\n\n/add で単語を追加してから戻ってきてください。",
"stopped": "課題を停止しました。/task で再開できます。", "stopped": "課題を停止しました。/task で再開できます。",
"finished": "課題が完了しました。/task で新しく始めましょう。", "finished": "課題が完了しました。/task で新しく始めましょう。",
@@ -96,6 +121,9 @@
"right_answer": "正解", "right_answer": "正解",
"next_btn": "➡️ 次へ", "next_btn": "➡️ 次へ",
"stop_btn": "🔚 停止", "stop_btn": "🔚 停止",
"add_word_btn": " 単語を追加",
"word_added": "✅ 単語 '{word}' を単語帳に追加しました!",
"word_already_exists": "単語 '{word}' はすでに単語帳にあります",
"cancelled": "キャンセルしました。/task で課題に戻れます。", "cancelled": "キャンセルしました。/task で課題に戻れます。",
"finish_title": "{emoji} <b>課題が終了しました!</b>", "finish_title": "{emoji} <b>課題が終了しました!</b>",
"correct_of": "正解数: <b>{correct}</b> / {total}", "correct_of": "正解数: <b>{correct}</b> / {total}",
@@ -211,6 +239,18 @@
"import_extra": { "import_extra": {
"cancelled": "❌ インポートを中止しました。" "cancelled": "❌ インポートを中止しました。"
}, },
"import_file": {
"unsupported_format": "❌ サポートされていないファイル形式です。\n\n対応形式: .txt, .md\n\nファイル形式:\n<code>単語 - 翻訳</code>\n<code>単語 : 翻訳</code>",
"too_large": "❌ ファイルが大きすぎます最大1MB",
"encoding_error": "❌ エンコードエラー。UTF-8であることを確認してください",
"download_error": "❌ ファイルのダウンロードに失敗しました。もう一度お試しください",
"no_words_found": "❌ ファイル内に単語が見つかりません。\n\n正しい形式か確認してください:\n<code>単語 - 翻訳</code>\n<code>単語 : 翻訳</code>",
"truncated": "⚠️ ファイルには{n}語以上あります。最初の{n}語をインポートします。",
"found_header": "📄 <b>ファイル内の単語: {n}</b>",
"choose_action": "アクションを選択:",
"add_all_btn": "✅ すべて追加 ({n})",
"translating": "🔄 AIで翻訳中..."
},
"level_test_extra": { "level_test_extra": {
"generating": "🔄 質問を生成しています...", "generating": "🔄 質問を生成しています...",
"generate_failed": "❌ テストの生成に失敗しました。後でもう一度試すか、/settings でレベルを手動設定してください。", "generate_failed": "❌ テストの生成に失敗しました。後でもう一度試すか、/settings でレベルを手動設定してください。",

View File

@@ -5,15 +5,32 @@
"task": "🧠 Задание", "task": "🧠 Задание",
"practice": "💬 Практика", "practice": "💬 Практика",
"words": "🎯 Тематические слова", "words": "🎯 Тематические слова",
"import": "📖 Импорт из текста", "import": "📖 Импорт",
"stats": "📊 Статистика", "stats": "📊 Статистика",
"settings": "⚙️ Настройки", "settings": "⚙️ Настройки",
"below": "Главное меню доступно ниже ⤵️" "below": "Главное меню доступно ниже ⤵️"
}, },
"add_menu": {
"title": " <b>Добавление слов</b>\n\nВыберите способ:",
"manual": "📝 Вручную",
"thematic": "🎯 Тематические слова",
"import": "📖 Импорт"
},
"import_menu": {
"title": "📖 <b>Импорт слов</b>\n\nВыберите способ импорта:",
"from_text": "📝 Из текста",
"from_file": "📄 Из файла (.txt, .md)",
"file_hint": "📄 <b>Импорт из файла</b>\n\nОтправьте файл .txt или .md с вашими словами.\n\n<b>Форматы:</b>\n• По одному слову на строку (AI переведёт)\n• <code>слово - перевод</code>\n• <code>слово : перевод</code>"
},
"common": { "common": {
"start_first": "Сначала запусти бота командой /start", "start_first": "Сначала запусти бота командой /start",
"translation": "Перевод" "translation": "Перевод"
}, },
"lang": {
"ru": "русский",
"en": "английский",
"ja": "японский"
},
"import": { "import": {
"title": "📖 <b>Импорт слов из текста</b>", "title": "📖 <b>Импорт слов из текста</b>",
"desc": "Отправь мне текст на выбранном языке обучения, и я извлеку из него полезные слова для изучения.", "desc": "Отправь мне текст на выбранном языке обучения, и я извлеку из него полезные слова для изучения.",
@@ -56,7 +73,9 @@
"header": "<b>📚 Твой словарь:</b>", "header": "<b>📚 Твой словарь:</b>",
"accuracy_inline": "({n}% точность)", "accuracy_inline": "({n}% точность)",
"shown_last": "<i>Показаны последние 10 из {n} слов</i>", "shown_last": "<i>Показаны последние 10 из {n} слов</i>",
"total": "<i>Всего слов: {n}</i>" "total": "<i>Всего слов: {n}</i>",
"page_info": "\n📖 Страница {page} из {total} • Всего слов: {count}",
"close_btn": "❌ Закрыть"
}, },
"practice": { "practice": {
"start_text": "💬 <b>Диалоговая практика с AI</b>\n\nВыбери сценарий для разговора:\n\n• AI будет играть роль собеседника\n• Ты можешь общаться на английском\n• AI будет исправлять твои ошибки\n• Используй /stop для завершения диалога\n\nВыбери сценарий:", "start_text": "💬 <b>Диалоговая практика с AI</b>\n\nВыбери сценарий для разговора:\n\n• AI будет играть роль собеседника\n• Ты можешь общаться на английском\n• AI будет исправлять твои ошибки\n• Используй /stop для завершения диалога\n\nВыбери сценарий:",
@@ -92,6 +111,12 @@
"go_words_hint": "Используй /words [тема] для подборки слов" "go_words_hint": "Используй /words [тема] для подборки слов"
}, },
"tasks": { "tasks": {
"choose_mode": "🧠 <b>Выбери режим заданий:</b>",
"mode_vocabulary": "📚 Слова из словаря",
"mode_new_words": "✨ Новые слова",
"generating_new": "🔄 Генерирую новые слова...",
"generate_failed": "❌ Не удалось сгенерировать слова. Попробуй позже.",
"translate_to": "Переведи на {lang_name}",
"no_words": "📚 У тебя пока нет слов для практики!\n\nДобавь несколько слов командой /add, а затем возвращайся.", "no_words": "📚 У тебя пока нет слов для практики!\n\nДобавь несколько слов командой /add, а затем возвращайся.",
"stopped": "Задания остановлены. Используй /task, чтобы начать заново.", "stopped": "Задания остановлены. Используй /task, чтобы начать заново.",
"finished": "Задания завершены. Используй /task, чтобы начать заново.", "finished": "Задания завершены. Используй /task, чтобы начать заново.",
@@ -104,6 +129,9 @@
"right_answer": "Правильный ответ", "right_answer": "Правильный ответ",
"next_btn": "➡️ Следующее задание", "next_btn": "➡️ Следующее задание",
"stop_btn": "🔚 Завершить", "stop_btn": "🔚 Завершить",
"add_word_btn": " Добавить слово",
"word_added": "✅ Слово '{word}' добавлено в словарь!",
"word_already_exists": "Слово '{word}' уже в словаре",
"cancelled": "Отменено. Можешь вернуться к заданиям командой /task.", "cancelled": "Отменено. Можешь вернуться к заданиям командой /task.",
"finish_title": "{emoji} <b>Задание завершено!</b>", "finish_title": "{emoji} <b>Задание завершено!</b>",
"correct_of": "Правильных ответов: <b>{correct}</b> из {total}", "correct_of": "Правильных ответов: <b>{correct}</b> из {total}",
@@ -219,6 +247,18 @@
"import_extra": { "import_extra": {
"cancelled": "❌ Импорт отменён." "cancelled": "❌ Импорт отменён."
}, },
"import_file": {
"unsupported_format": "❌ Неподдерживаемый формат файла.\n\nПоддерживаются: .txt, .md\n\nФормат файла:\n<code>слово - перевод</code>\n<code>слово : перевод</code>",
"too_large": "❌ Файл слишком большой (макс. 1 МБ)",
"encoding_error": "❌ Ошибка кодировки. Убедитесь, что файл в UTF-8",
"download_error": "❌ Не удалось загрузить файл. Попробуйте ещё раз",
"no_words_found": "❌ Не найдено слов в файле.\n\nУбедитесь, что формат правильный:\n<code>слово - перевод</code>\n<code>слово : перевод</code>",
"truncated": "⚠️ Файл содержит больше {n} слов. Импортируем первые {n}.",
"found_header": "📄 <b>Найдено слов в файле: {n}</b>",
"choose_action": "Выберите действие:",
"add_all_btn": "✅ Добавить все ({n})",
"translating": "🔄 Перевожу слова через AI..."
},
"level_test_extra": { "level_test_extra": {
"generating": "🔄 Генерирую вопросы...", "generating": "🔄 Генерирую вопросы...",
"generate_failed": "❌ Не удалось сгенерировать тест. Попробуй позже или используй /settings для ручной установки уровня.", "generate_failed": "❌ Не удалось сгенерировать тест. Попробуй позже или используй /settings для ручной установки уровня.",

View File

@@ -0,0 +1,28 @@
"""add ai_task value to wordsource enum
Revision ID: 20251205_wordsource_ai_task
Revises: 20251205_levels_by_lang
Create Date: 2025-12-05
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = '20251205_wordsource_ai_task'
down_revision = '20251205_levels_by_lang'
branch_labels = None
depends_on = None
def upgrade():
# Добавляем новое значение в enum wordsource
# SQLAlchemy отправляет имя enum (uppercase), а не значение
op.execute("ALTER TYPE wordsource ADD VALUE IF NOT EXISTS 'AI_TASK'")
def downgrade():
# PostgreSQL не поддерживает удаление значений из enum напрямую
# Для отката нужно пересоздать enum, что сложно и опасно
# Оставляем значение в enum (оно просто не будет использоваться)
pass

View File

@@ -111,6 +111,92 @@ class AIService:
"difficulty": "A1" "difficulty": "A1"
} }
async def translate_words_batch(
self,
words: List[str],
source_lang: str = "en",
translation_lang: str = "ru"
) -> List[Dict]:
"""
Перевести список слов пакетно
Args:
words: Список слов для перевода
source_lang: Язык исходных слов (ISO2)
translation_lang: Язык перевода (ISO2)
Returns:
List[Dict] с переводами, транскрипциями
"""
if not words:
return []
words_list = "\n".join(f"- {w}" for w in words[:50]) # Максимум 50 слов за раз
# Добавляем инструкцию для фуриганы если японский
furigana_instruction = ""
if source_lang == "ja":
furigana_instruction = '\n "reading": "чтение хираганой (только для кандзи)",'
prompt = f"""Переведи следующие слова/фразы с языка {source_lang} на {translation_lang}:
{words_list}
Верни ответ строго в формате JSON массива:
[
{{
"word": "исходное слово",
"translation": "перевод",
"transcription": "транскрипция (IPA или ромадзи для японского)",{furigana_instruction}
}},
...
]
Важно:
- Верни только JSON массив, без дополнительного текста
- Сохрани порядок слов как в исходном списке
- Для каждого слова укажи точный перевод и транскрипцию"""
try:
logger.info(f"[GPT Request] translate_words_batch: {len(words)} words, {source_lang} -> {translation_lang}")
messages = [
{"role": "system", "content": "Ты - помощник для изучения языков. Отвечай только в формате JSON."},
{"role": "user", "content": prompt}
]
response_data = await self._make_openai_request(messages, temperature=0.3)
import json
content = response_data['choices'][0]['message']['content']
# Убираем markdown обёртку если есть
if content.startswith('```'):
content = content.split('\n', 1)[1] if '\n' in content else content[3:]
if content.endswith('```'):
content = content[:-3]
content = content.strip()
result = json.loads(content)
# Если вернулся dict с ключом типа "words" или "translations" — извлекаем список
if isinstance(result, dict):
for key in ['words', 'translations', 'result', 'data']:
if key in result and isinstance(result[key], list):
result = result[key]
break
if not isinstance(result, list):
logger.warning(f"[GPT Warning] translate_words_batch: unexpected format, got {type(result)}")
return [{"word": w, "translation": "", "transcription": ""} for w in words]
logger.info(f"[GPT Response] translate_words_batch: success, got {len(result)} translations")
return result
except Exception as e:
logger.error(f"[GPT Error] translate_words_batch: {type(e).__name__}: {str(e)}")
# Возвращаем слова без перевода в случае ошибки
return [{"word": w, "translation": "", "transcription": ""} for w in words]
async def check_answer(self, question: str, correct_answer: str, user_answer: str) -> Dict: async def check_answer(self, question: str, correct_answer: str, user_answer: str) -> Dict:
""" """
Проверить ответ пользователя с помощью ИИ Проверить ответ пользователя с помощью ИИ

View File

@@ -90,14 +90,21 @@ class VocabularyService:
return [w for w in words if not VocabularyService._is_japanese(w.word_original)] return [w for w in words if not VocabularyService._is_japanese(w.word_original)]
@staticmethod @staticmethod
async def get_user_words(session: AsyncSession, user_id: int, limit: int = 50, learning_lang: Optional[str] = None) -> List[Vocabulary]: async def get_user_words(
session: AsyncSession,
user_id: int,
limit: int = 50,
offset: int = 0,
learning_lang: Optional[str] = None
) -> List[Vocabulary]:
""" """
Получить все слова пользователя Получить слова пользователя с пагинацией
Args: Args:
session: Сессия базы данных session: Сессия базы данных
user_id: ID пользователя user_id: ID пользователя
limit: Максимальное количество слов limit: Максимальное количество слов
offset: Смещение для пагинации
Returns: Returns:
Список слов пользователя Список слов пользователя
@@ -109,7 +116,7 @@ class VocabularyService:
) )
words = list(result.scalars().all()) words = list(result.scalars().all())
words = VocabularyService._filter_by_learning_lang(words, learning_lang) words = VocabularyService._filter_by_learning_lang(words, learning_lang)
return words[:limit] return words[offset:offset + limit]
@staticmethod @staticmethod
async def get_words_count(session: AsyncSession, user_id: int, learning_lang: Optional[str] = None) -> int: async def get_words_count(session: AsyncSession, user_id: int, learning_lang: Optional[str] = None) -> int: