From 63e26152437a781bbbe5a0e421306c4d1984bb47 Mon Sep 17 00:00:00 2001 From: "mamonov.ep" Date: Fri, 5 Dec 2025 20:15:47 +0300 Subject: [PATCH] feat: restructure menu and add file import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- bot/handlers/import_text.py | 235 +++++++++++++++++- bot/handlers/start.py | 152 ++++++++++- bot/handlers/tasks.py | 172 ++++++++++++- bot/handlers/vocabulary.py | 104 ++++++-- bot/handlers/words.py | 7 + database/models.py | 1 + locales/en.json | 44 +++- locales/ja.json | 44 +++- locales/ru.json | 44 +++- .../20251205_add_wordsource_ai_task.py | 28 +++ services/ai_service.py | 86 +++++++ services/vocabulary_service.py | 13 +- 12 files changed, 883 insertions(+), 47 deletions(-) create mode 100644 migrations/versions/20251205_add_wordsource_ai_task.py diff --git a/bot/handlers/import_text.py b/bot/handlers/import_text.py index 8e80ddc..28a6241 100644 --- a/bot/handlers/import_text.py +++ b/bot/handlers/import_text.py @@ -1,4 +1,5 @@ -from aiogram import Router, F +import re +from aiogram import Router, F, Bot from aiogram.filters import Command from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery from aiogram.fsm.context import FSMContext @@ -14,6 +15,11 @@ from utils.levels import get_user_level_for_language router = Router() +# Поддерживаемые расширения файлов +SUPPORTED_EXTENSIONS = {'.txt', '.md'} +# Разделители между словом и переводом +WORD_SEPARATORS = re.compile(r'\s*[-–—:=\t]\s*') + class ImportStates(StatesGroup): """Состояния для импорта слов из текста""" @@ -271,3 +277,230 @@ async def close_import(callback: CallbackQuery, state: FSMContext): await callback.message.delete() await state.clear() 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}. {word}" + if transcription: + line += f" [{transcription}]" + if translation: + line += f" — {translation}" + text += line + "\n" + + if len(words) > 20: + text += f"\n...и ещё {len(words) - 20} слов\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() diff --git a/bot/handlers/start.py b/bot/handlers/start.py index 3519c3d..b3cb8a0 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -30,10 +30,6 @@ def main_menu_keyboard(lang: str = 'ru') -> ReplyKeyboardMarkup: KeyboardButton(text=t(lang, "menu.task")), 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.settings")), @@ -100,12 +96,109 @@ def _menu_match(key: str): @router.message(_menu_match('menu.add')) async def btn_add_pressed(message: Message, state: FSMContext): - from bot.handlers.vocabulary import AddWordStates + """Показать меню добавления слов""" 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')) + + 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 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')) @@ -128,8 +221,51 @@ async def btn_practice_pressed(message: Message, state: FSMContext): @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) + """Показать меню импорта""" + 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')) diff --git a/bot/handlers/tasks.py b/bot/handlers/tasks.py index 6fb5c3e..d85a283 100644 --- a/bot/handlers/tasks.py +++ b/bot/handlers/tasks.py @@ -5,23 +5,27 @@ from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from database.db import async_session_maker +from database.models import WordSource from services.user_service import UserService from services.task_service import TaskService +from services.vocabulary_service import VocabularyService 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() class TaskStates(StatesGroup): """Состояния для прохождения заданий""" + choosing_mode = State() doing_tasks = State() waiting_for_answer = State() @router.message(Command("task")) async def cmd_task(message: Message, state: FSMContext): - """Обработчик команды /task""" + """Обработчик команды /task — показываем меню выбора режима""" async with async_session_maker() as session: 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')) 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( session, user.id, count=5, learning_lang=user.learning_language, @@ -37,7 +73,8 @@ async def cmd_task(message: Message, state: FSMContext): ) 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 # Сохраняем задания в состоянии @@ -45,12 +82,70 @@ async def cmd_task(message: Message, state: FSMContext): tasks=tasks, current_task_index=0, correct_count=0, - user_id=user.id + user_id=user.id, + mode='vocabulary' ) await state.set_state(TaskStates.doing_tasks) - # Показываем первое задание - await show_current_task(message, state) + await callback.message.delete() + 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): @@ -162,10 +257,18 @@ async def process_answer(message: Message, state: FSMContext): ) # Показываем результат и кнопку "Далее" - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=t(lang, 'tasks.next_btn'), callback_data="next_task")], - [InlineKeyboardButton(text=t(lang, 'tasks.stop_btn'), callback_data="stop_tasks")] - ]) + mode = data.get('mode') + buttons = [[InlineKeyboardButton(text=t(lang, 'tasks.next_btn'), callback_data="next_task")]] + + # Для режима 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) # После показа результата ждём нажатия кнопки – переключаемся в состояние doing_tasks @@ -180,6 +283,53 @@ async def next_task(callback: CallbackQuery, state: FSMContext): 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) async def stop_tasks_callback(callback: CallbackQuery, state: FSMContext): """Остановить выполнение заданий через кнопку""" diff --git a/bot/handlers/vocabulary.py b/bot/handlers/vocabulary.py index 8f18cec..fd57762 100644 --- a/bot/handlers/vocabulary.py +++ b/bot/handlers/vocabulary.py @@ -124,6 +124,11 @@ async def confirm_add_word(callback: CallbackQuery, state: FSMContext): user_id = data.get("user_id") 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( 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) + 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( 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() +WORDS_PER_PAGE = 10 + + @router.message(Command("vocabulary")) async def cmd_vocabulary(message: Message): """Обработчик команды /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: - 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: - 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 - # Получаем слова пользователя - 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) - if not words: - lang = (user.language_interface if user else 'ru') or 'ru' - await message.answer(t(lang, 'vocab.empty')) + lang = get_user_lang(user) + + 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 # Формируем список слов - 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" - for idx, word in enumerate(words, 1): + + for idx, word in enumerate(words, start=offset + 1): progress = "" if word.times_reviewed > 0: 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" ) - if total_count > 10: - words_list += "\n" + t(lang, 'vocab.shown_last', n=total_count) - else: - words_list += "\n" + t(lang, 'vocab.total', n=total_count) + words_list += t(lang, 'vocab.page_info', page=page + 1, total=total_pages, count=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() diff --git a/bot/handlers/words.py b/bot/handlers/words.py index dd42b49..2df057e 100644 --- a/bot/handlers/words.py +++ b/bot/handlers/words.py @@ -45,6 +45,13 @@ async def cmd_words(message: Message, state: FSMContext): return 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' diff --git a/database/models.py b/database/models.py index 4485b86..e231986 100644 --- a/database/models.py +++ b/database/models.py @@ -45,6 +45,7 @@ class WordSource(str, enum.Enum): CONTEXT = "context" # Из контекста диалога IMPORT = "import" # Импорт из текста ERROR = "error" # Из ошибок в заданиях + AI_TASK = "ai_task" # Из AI-задания class User(Base): diff --git a/locales/en.json b/locales/en.json index 9829480..caa8d1f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -5,15 +5,32 @@ "task": "🧠 Task", "practice": "💬 Practice", "words": "🎯 Thematic words", - "import": "📖 Import from text", + "import": "📖 Import", "stats": "📊 Stats", "settings": "⚙️ Settings", "below": "Main menu below ⤵️" }, + "add_menu": { + "title": "➕ Add words\n\nChoose method:", + "manual": "📝 Manual", + "thematic": "🎯 Thematic words", + "import": "📖 Import" + }, + "import_menu": { + "title": "📖 Import words\n\nChoose import method:", + "from_text": "📝 From text", + "from_file": "📄 From file (.txt, .md)", + "file_hint": "📄 Import from file\n\nSend a .txt or .md file with your words.\n\nFormats:\n• One word per line (AI will translate)\n• word - translation\n• word : translation" + }, "common": { "start_first": "First run /start to register", "translation": "Translation" }, + "lang": { + "ru": "Russian", + "en": "English", + "ja": "Japanese" + }, "import": { "title": "📖 Import words from text", "desc": "Send me text in your learning language, and I will extract useful words to study.", @@ -56,7 +73,9 @@ "header": "📚 Your vocabulary:", "accuracy_inline": "({n}% accuracy)", "shown_last": "Showing last 10 of {n} words", - "total": "Total words: {n}" + "total": "Total words: {n}", + "page_info": "\n📖 Page {page} of {total} • Total words: {count}", + "close_btn": "❌ Close" }, "practice": { "start_text": "💬 Dialogue practice with AI\n\nChoose a scenario:\n\n• AI will play a role\n• You can chat in English\n• AI will correct your mistakes\n• Use /stop to finish\n\nPick a scenario:", @@ -92,6 +111,12 @@ "go_words_hint": "Use /words [topic] for word sets" }, "tasks": { + "choose_mode": "🧠 Choose task mode:", + "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.", "stopped": "Exercises stopped. Use /task to start again.", "finished": "Exercises finished. Use /task to start again.", @@ -104,6 +129,9 @@ "right_answer": "Right answer", "next_btn": "➡️ Next task", "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.", "finish_title": "{emoji} Task finished!", "correct_of": "Correct answers: {correct} of {total}", @@ -219,6 +247,18 @@ "import_extra": { "cancelled": "❌ Import cancelled." }, + "import_file": { + "unsupported_format": "❌ Unsupported file format.\n\nSupported: .txt, .md\n\nFile format:\nword - translation\nword : translation", + "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:\nword - translation\nword : translation", + "truncated": "⚠️ File contains more than {n} words. Importing first {n}.", + "found_header": "📄 Words found in file: {n}", + "choose_action": "Choose action:", + "add_all_btn": "✅ Add all ({n})", + "translating": "🔄 Translating words with AI..." + }, "level_test_extra": { "generating": "🔄 Generating questions...", "generate_failed": "❌ Failed to generate test. Try later or use /settings to set level manually.", diff --git a/locales/ja.json b/locales/ja.json index 6402fe1..72eddc4 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -5,15 +5,32 @@ "task": "🧠 課題", "practice": "💬 練習", "words": "🎯 テーマ別単語", - "import": "📖 テキストからインポート", + "import": "📖 インポート", "stats": "📊 統計", "settings": "⚙️ 設定", "below": "メインメニューは下にあります ⤵️" }, + "add_menu": { + "title": "➕ 単語を追加\n\n方法を選択:", + "manual": "📝 手動", + "thematic": "🎯 テーマ別単語", + "import": "📖 インポート" + }, + "import_menu": { + "title": "📖 単語のインポート\n\nインポート方法を選択:", + "from_text": "📝 テキストから", + "from_file": "📄 ファイルから (.txt, .md)", + "file_hint": "📄 ファイルからインポート\n\n単語が入った .txt または .md ファイルを送信してください。\n\n形式:\n• 1行に1単語(AIが翻訳)\n• 単語 - 翻訳\n• 単語 : 翻訳" + }, "common": { "start_first": "まず /start を実行してください", "translation": "翻訳" }, + "lang": { + "ru": "ロシア語", + "en": "英語", + "ja": "日本語" + }, "import": { "title": "📖 テキストから単語をインポート", "desc": "学習言語のテキストを送ってください。学習に役立つ単語を抽出します。", @@ -56,7 +73,9 @@ "header": "📚 あなたの単語帳:", "accuracy_inline": "(正答率 {n}%)", "shown_last": "{n} 語のうち最新の10語を表示", - "total": "合計: {n} 語" + "total": "合計: {n} 語", + "page_info": "\n📖 {page} / {total} ページ • 合計: {count} 語", + "close_btn": "❌ 閉じる" }, "practice": { "start_text": "💬 AIとの会話練習\n\nシナリオを選んでください:\n\n• AIが相手役を務めます\n• 英語でやり取りできます\n• 間違いをAIが指摘します\n• 終了するには /stop を使用\n\nシナリオを選択:", @@ -84,6 +103,12 @@ "go_words_hint": "/words [テーマ] で単語セットを取得できます" }, "tasks": { + "choose_mode": "🧠 課題モードを選択:", + "mode_vocabulary": "📚 単語帳から", + "mode_new_words": "✨ 新しい単語", + "generating_new": "🔄 新しい単語を生成中...", + "generate_failed": "❌ 単語の生成に失敗しました。後でもう一度お試しください。", + "translate_to": "{lang_name}に翻訳", "no_words": "📚 まだ練習用の単語がありません!\n\n/add で単語を追加してから戻ってきてください。", "stopped": "課題を停止しました。/task で再開できます。", "finished": "課題が完了しました。/task で新しく始めましょう。", @@ -96,6 +121,9 @@ "right_answer": "正解", "next_btn": "➡️ 次へ", "stop_btn": "🔚 停止", + "add_word_btn": "➕ 単語を追加", + "word_added": "✅ 単語 '{word}' を単語帳に追加しました!", + "word_already_exists": "単語 '{word}' はすでに単語帳にあります", "cancelled": "キャンセルしました。/task で課題に戻れます。", "finish_title": "{emoji} 課題が終了しました!", "correct_of": "正解数: {correct} / {total}", @@ -211,6 +239,18 @@ "import_extra": { "cancelled": "❌ インポートを中止しました。" }, + "import_file": { + "unsupported_format": "❌ サポートされていないファイル形式です。\n\n対応形式: .txt, .md\n\nファイル形式:\n単語 - 翻訳\n単語 : 翻訳", + "too_large": "❌ ファイルが大きすぎます(最大1MB)", + "encoding_error": "❌ エンコードエラー。UTF-8であることを確認してください", + "download_error": "❌ ファイルのダウンロードに失敗しました。もう一度お試しください", + "no_words_found": "❌ ファイル内に単語が見つかりません。\n\n正しい形式か確認してください:\n単語 - 翻訳\n単語 : 翻訳", + "truncated": "⚠️ ファイルには{n}語以上あります。最初の{n}語をインポートします。", + "found_header": "📄 ファイル内の単語: {n}", + "choose_action": "アクションを選択:", + "add_all_btn": "✅ すべて追加 ({n})", + "translating": "🔄 AIで翻訳中..." + }, "level_test_extra": { "generating": "🔄 質問を生成しています...", "generate_failed": "❌ テストの生成に失敗しました。後でもう一度試すか、/settings でレベルを手動設定してください。", diff --git a/locales/ru.json b/locales/ru.json index ba034f2..7a9c749 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -5,15 +5,32 @@ "task": "🧠 Задание", "practice": "💬 Практика", "words": "🎯 Тематические слова", - "import": "📖 Импорт из текста", + "import": "📖 Импорт", "stats": "📊 Статистика", "settings": "⚙️ Настройки", "below": "Главное меню доступно ниже ⤵️" }, + "add_menu": { + "title": "➕ Добавление слов\n\nВыберите способ:", + "manual": "📝 Вручную", + "thematic": "🎯 Тематические слова", + "import": "📖 Импорт" + }, + "import_menu": { + "title": "📖 Импорт слов\n\nВыберите способ импорта:", + "from_text": "📝 Из текста", + "from_file": "📄 Из файла (.txt, .md)", + "file_hint": "📄 Импорт из файла\n\nОтправьте файл .txt или .md с вашими словами.\n\nФорматы:\n• По одному слову на строку (AI переведёт)\n• слово - перевод\n• слово : перевод" + }, "common": { "start_first": "Сначала запусти бота командой /start", "translation": "Перевод" }, + "lang": { + "ru": "русский", + "en": "английский", + "ja": "японский" + }, "import": { "title": "📖 Импорт слов из текста", "desc": "Отправь мне текст на выбранном языке обучения, и я извлеку из него полезные слова для изучения.", @@ -56,7 +73,9 @@ "header": "📚 Твой словарь:", "accuracy_inline": "({n}% точность)", "shown_last": "Показаны последние 10 из {n} слов", - "total": "Всего слов: {n}" + "total": "Всего слов: {n}", + "page_info": "\n📖 Страница {page} из {total} • Всего слов: {count}", + "close_btn": "❌ Закрыть" }, "practice": { "start_text": "💬 Диалоговая практика с AI\n\nВыбери сценарий для разговора:\n\n• AI будет играть роль собеседника\n• Ты можешь общаться на английском\n• AI будет исправлять твои ошибки\n• Используй /stop для завершения диалога\n\nВыбери сценарий:", @@ -92,6 +111,12 @@ "go_words_hint": "Используй /words [тема] для подборки слов" }, "tasks": { + "choose_mode": "🧠 Выбери режим заданий:", + "mode_vocabulary": "📚 Слова из словаря", + "mode_new_words": "✨ Новые слова", + "generating_new": "🔄 Генерирую новые слова...", + "generate_failed": "❌ Не удалось сгенерировать слова. Попробуй позже.", + "translate_to": "Переведи на {lang_name}", "no_words": "📚 У тебя пока нет слов для практики!\n\nДобавь несколько слов командой /add, а затем возвращайся.", "stopped": "Задания остановлены. Используй /task, чтобы начать заново.", "finished": "Задания завершены. Используй /task, чтобы начать заново.", @@ -104,6 +129,9 @@ "right_answer": "Правильный ответ", "next_btn": "➡️ Следующее задание", "stop_btn": "🔚 Завершить", + "add_word_btn": "➕ Добавить слово", + "word_added": "✅ Слово '{word}' добавлено в словарь!", + "word_already_exists": "Слово '{word}' уже в словаре", "cancelled": "Отменено. Можешь вернуться к заданиям командой /task.", "finish_title": "{emoji} Задание завершено!", "correct_of": "Правильных ответов: {correct} из {total}", @@ -219,6 +247,18 @@ "import_extra": { "cancelled": "❌ Импорт отменён." }, + "import_file": { + "unsupported_format": "❌ Неподдерживаемый формат файла.\n\nПоддерживаются: .txt, .md\n\nФормат файла:\nслово - перевод\nслово : перевод", + "too_large": "❌ Файл слишком большой (макс. 1 МБ)", + "encoding_error": "❌ Ошибка кодировки. Убедитесь, что файл в UTF-8", + "download_error": "❌ Не удалось загрузить файл. Попробуйте ещё раз", + "no_words_found": "❌ Не найдено слов в файле.\n\nУбедитесь, что формат правильный:\nслово - перевод\nслово : перевод", + "truncated": "⚠️ Файл содержит больше {n} слов. Импортируем первые {n}.", + "found_header": "📄 Найдено слов в файле: {n}", + "choose_action": "Выберите действие:", + "add_all_btn": "✅ Добавить все ({n})", + "translating": "🔄 Перевожу слова через AI..." + }, "level_test_extra": { "generating": "🔄 Генерирую вопросы...", "generate_failed": "❌ Не удалось сгенерировать тест. Попробуй позже или используй /settings для ручной установки уровня.", diff --git a/migrations/versions/20251205_add_wordsource_ai_task.py b/migrations/versions/20251205_add_wordsource_ai_task.py new file mode 100644 index 0000000..aac0d75 --- /dev/null +++ b/migrations/versions/20251205_add_wordsource_ai_task.py @@ -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 diff --git a/services/ai_service.py b/services/ai_service.py index 6945f1b..c9602ad 100644 --- a/services/ai_service.py +++ b/services/ai_service.py @@ -111,6 +111,92 @@ class AIService: "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: """ Проверить ответ пользователя с помощью ИИ diff --git a/services/vocabulary_service.py b/services/vocabulary_service.py index ee8a85f..a77f317 100644 --- a/services/vocabulary_service.py +++ b/services/vocabulary_service.py @@ -90,14 +90,21 @@ class VocabularyService: 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]: + async def get_user_words( + session: AsyncSession, + user_id: int, + limit: int = 50, + offset: int = 0, + learning_lang: Optional[str] = None + ) -> List[Vocabulary]: """ - Получить все слова пользователя + Получить слова пользователя с пагинацией Args: session: Сессия базы данных user_id: ID пользователя limit: Максимальное количество слов + offset: Смещение для пагинации Returns: Список слов пользователя @@ -109,7 +116,7 @@ class VocabularyService: ) words = list(result.scalars().all()) words = VocabularyService._filter_by_learning_lang(words, learning_lang) - return words[:limit] + return words[offset:offset + limit] @staticmethod async def get_words_count(session: AsyncSession, user_id: int, learning_lang: Optional[str] = None) -> int: