From f38ff2f18e507dbcc650738eac7610e380214ee5 Mon Sep 17 00:00:00 2001 From: "mamonov.ep" Date: Tue, 9 Dec 2025 15:05:38 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BC=D0=B8=D0=BD=D0=B8-=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D0=B8,=20=D1=81=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=20=D0=B4=D0=BD=D1=8F,=20=D0=BC=D0=B5=D0=BD=D1=8E=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=BA=D1=82=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлены мини-истории для чтения с выбором жанра и вопросами - Кнопка показа/скрытия перевода истории - Количество вопросов берётся из настроек пользователя - Слово дня генерируется глобально в 00:00 UTC - Кнопка "Практика" открывает меню выбора режима - Убран автоматический create_all при запуске (только миграции) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Makefile | 1 - README.md | 8 +- bot/handlers/exercises.py | 415 +++++++++++ bot/handlers/practice.py | 57 +- bot/handlers/start.py | 91 ++- bot/handlers/stories.py | 688 ++++++++++++++++++ bot/handlers/vocabulary.py | 227 +++++- bot/handlers/wordofday.py | 139 ++++ data/__init__.py | 1 + data/grammar_rules.py | 321 ++++++++ database/models.py | 49 ++ locales/en.json | 101 ++- locales/ja.json | 101 ++- locales/ru.json | 101 ++- main.py | 18 +- .../versions/20251209_add_mini_stories.py | 55 ++ .../20251209_add_story_translation.py | 26 + .../versions/20251209_add_word_of_day.py | 50 ++ services/ai_service.py | 462 +++++++++++- services/reminder_service.py | 67 +- services/wordofday_service.py | 227 ++++++ ИДЕИ.txt | 3 - 22 files changed, 3131 insertions(+), 77 deletions(-) create mode 100644 bot/handlers/exercises.py create mode 100644 bot/handlers/stories.py create mode 100644 bot/handlers/wordofday.py create mode 100644 data/__init__.py create mode 100644 data/grammar_rules.py create mode 100644 migrations/versions/20251209_add_mini_stories.py create mode 100644 migrations/versions/20251209_add_story_translation.py create mode 100644 migrations/versions/20251209_add_word_of_day.py create mode 100644 services/wordofday_service.py delete mode 100644 ИДЕИ.txt diff --git a/Makefile b/Makefile index 1fd7563..2c74c19 100644 --- a/Makefile +++ b/Makefile @@ -91,7 +91,6 @@ docker-bot-build: docker-bot-rebuild: docker-compose stop bot docker-compose rm bot - docker-compose build --no-cache bot docker-compose up -d bot docker-bot-rebuild-full: diff --git a/README.md b/README.md index 1829b51..b865daf 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,11 @@ bot_tg_language/ - [x] Убрать переводы текстов (скрыть перевод в упражнениях/диалогах/тестах) - [x] Добавлены английский и японский в локализацию интерфейса - [x] Добавлены языки для обучения +- [x] Добавить возможность иметь словам несколько переводов +- [x] Добавить возможность импорта слов из файлов (txt, md) +- [x] Добавить импорт нескольких слов (bulk-импорт) +- [x] Добавлена механика "Слова дня" +- [x] Добавлена механика "Мини истории" **Следующие улучшения:** - [ ] Экспорт словаря (PDF, Anki, CSV) @@ -296,11 +301,8 @@ bot_tg_language/ - [ ] Групповые челленджи и лидерборды - [ ] Gamification (стрики, достижения, уровни) - [ ] Расширенная аналитика с графиками -- [ ] Добавить импорт нескольких слов (bulk-импорт) - [ ] Создание задач на выбранные слова (из словаря/подборок) -- [ ] Добавить возможность иметь словам несколько переводов - [ ] Изменить словарь: оставить только слова и добавить возможность получать инфо о словах -- [ ] Добавить возможность импорта слов из файлов ## Cloudflare AI Gateway (опционально) diff --git a/bot/handlers/exercises.py b/bot/handlers/exercises.py new file mode 100644 index 0000000..c45c505 --- /dev/null +++ b/bot/handlers/exercises.py @@ -0,0 +1,415 @@ +"""Handler для грамматических упражнений.""" + +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup + +from database.db import async_session_maker +from services.user_service import UserService +from services.ai_service import ai_service +from utils.i18n import t, get_user_lang, get_user_translation_lang +from utils.levels import get_user_level_for_language +from data.grammar_rules import get_topics_for_current_level_only, get_grammar_topics_for_level + +router = Router() + +# Количество тем на одной странице +TOPICS_PER_PAGE = 6 + + +class ExercisesStates(StatesGroup): + """Состояния для грамматических упражнений.""" + choosing_topic = State() + viewing_rule = State() + doing_exercise = State() + waiting_answer = State() + + +@router.message(Command("exercises")) +async def cmd_exercises(message: Message, state: FSMContext): + """Обработчик команды /exercises.""" + await show_exercises_menu(message, state, telegram_id=message.from_user.id) + + +async def show_exercises_menu(message: Message, state: FSMContext, page: int = 0, telegram_id: int = None): + """Показать меню выбора темы упражнений.""" + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, telegram_id) + + if not user: + await message.answer(t('ru', 'common.start_first')) + return + + lang = get_user_lang(user) + learning_lang = user.learning_language or 'en' + level = get_user_level_for_language(user) + + # Получаем темы для текущего уровня + topics = get_topics_for_current_level_only(learning_lang, level) + + if not topics: + await message.answer(t(lang, 'exercises.no_topics')) + return + + # Пагинация + total_pages = (len(topics) + TOPICS_PER_PAGE - 1) // TOPICS_PER_PAGE + start_idx = page * TOPICS_PER_PAGE + end_idx = start_idx + TOPICS_PER_PAGE + page_topics = topics[start_idx:end_idx] + + # Сохраняем в состоянии + await state.update_data( + topics=[t for t in topics], # Все темы + page=page, + level=level, + learning_lang=learning_lang, + user_id=user.id + ) + await state.set_state(ExercisesStates.choosing_topic) + + # Формируем текст + text = ( + t(lang, 'exercises.title') + "\n\n" + + t(lang, 'exercises.your_level', level=level) + "\n\n" + + t(lang, 'exercises.choose_topic') + ) + + # Формируем клавиатуру + keyboard = [] + + for topic in page_topics: + # Используем русское название если интерфейс на русском, иначе английское + if lang == 'ru' and topic.get('name_ru'): + btn_text = topic['name_ru'] + else: + btn_text = topic['name'] + + keyboard.append([ + InlineKeyboardButton( + text=btn_text, + callback_data=f"exercise_topic_{topic['id']}" + ) + ]) + + # Навигация по страницам + nav_row = [] + if page > 0: + nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"exercises_page_{page - 1}")) + if total_pages > 1: + nav_row.append(InlineKeyboardButton(text=f"{page + 1}/{total_pages}", callback_data="exercises_noop")) + if page < total_pages - 1: + nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"exercises_page_{page + 1}")) + + if nav_row: + keyboard.append(nav_row) + + # Кнопка закрыть + keyboard.append([ + InlineKeyboardButton(text=t(lang, 'exercises.close_btn'), callback_data="exercises_close") + ]) + + reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard) + await message.answer(text, reply_markup=reply_markup) + + +@router.callback_query(F.data.startswith("exercises_page_")) +async def exercises_page_callback(callback: CallbackQuery, state: FSMContext): + """Переключение страницы тем.""" + page = int(callback.data.split("_")[-1]) + await callback.answer() + + # Удаляем старое сообщение и показываем новое + await callback.message.delete() + await show_exercises_menu(callback.message, state, page=page, telegram_id=callback.from_user.id) + + +@router.callback_query(F.data == "exercises_noop") +async def exercises_noop_callback(callback: CallbackQuery): + """Пустой callback для кнопки с номером страницы.""" + await callback.answer() + + +@router.callback_query(F.data == "exercises_close") +async def exercises_close_callback(callback: CallbackQuery, state: FSMContext): + """Закрыть меню упражнений.""" + await callback.message.delete() + await state.clear() + await callback.answer() + + +@router.callback_query(F.data.startswith("exercise_topic_")) +async def exercise_topic_callback(callback: CallbackQuery, state: FSMContext): + """Выбор темы для упражнения - показываем правило.""" + await callback.answer() + topic_id = callback.data.replace("exercise_topic_", "") + + data = await state.get_data() + topics = data.get('topics', []) + level = data.get('level', 'A1') + learning_lang = data.get('learning_lang', 'en') + user_id = data.get('user_id') + + # Находим выбранную тему + topic = next((tp for tp in topics if tp['id'] == topic_id), None) + if not topic: + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) if user else 'ru' + await callback.message.edit_text(t(lang, 'exercises.generate_failed')) + return + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + + lang = get_user_lang(user) if user else 'ru' + + # Показываем индикатор генерации правила + await callback.message.edit_text(t(lang, 'exercises.generating_rule')) + + # Генерируем объяснение правила + rule_text = await ai_service.generate_grammar_rule( + topic_name=topic['name'], + topic_description=topic.get('description', ''), + level=level, + learning_lang=learning_lang, + ui_lang=lang, + user_id=user_id + ) + + # Сохраняем данные темы в состоянии + await state.update_data( + current_topic=topic, + rule_text=rule_text + ) + await state.set_state(ExercisesStates.viewing_rule) + + # Показываем правило с кнопкой "Начать упражнения" + topic_display = topic.get('name_ru', topic['name']) if lang == 'ru' else topic['name'] + text = f"📖 {topic_display}\n\n{rule_text}" + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=t(lang, 'exercises.start_btn'), callback_data="exercises_start_tasks")], + [InlineKeyboardButton(text=t(lang, 'exercises.back_btn'), callback_data="exercises_back_to_topics")] + ]) + + await callback.message.edit_text(text, reply_markup=keyboard) + + +@router.callback_query(F.data == "exercises_start_tasks", ExercisesStates.viewing_rule) +async def start_exercises_callback(callback: CallbackQuery, state: FSMContext): + """Начать упражнения после просмотра правила.""" + await callback.answer() + + data = await state.get_data() + topic = data.get('current_topic', {}) + level = data.get('level', 'A1') + learning_lang = data.get('learning_lang', 'en') + 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) + + lang = get_user_lang(user) if user else 'ru' + translation_lang = get_user_translation_lang(user) if user else 'ru' + tasks_count = getattr(user, 'tasks_count', 5) or 5 + + # Показываем индикатор генерации + await callback.message.edit_text(t(lang, 'exercises.generating')) + + # Генерируем упражнения + exercises = await ai_service.generate_grammar_exercise( + topic_id=topic.get('id', ''), + topic_name=topic.get('name', ''), + topic_description=topic.get('description', ''), + level=level, + learning_lang=learning_lang, + translation_lang=translation_lang, + count=tasks_count, + user_id=user_id + ) + + if not exercises: + await callback.message.edit_text(t(lang, 'exercises.generate_failed')) + return + + # Сохраняем упражнения в состоянии + await state.update_data( + exercises=exercises, + current_exercise=0, + correct_count=0, + topic_name=topic.get('name', ''), + topic_name_ru=topic.get('name_ru', topic.get('name', '')) + ) + await state.set_state(ExercisesStates.waiting_answer) + + # Показываем первое упражнение + await show_exercise(callback.message, state, lang, edit=True) + + +async def show_exercise(message: Message, state: FSMContext, lang: str, edit: bool = False): + """Показать текущее упражнение.""" + data = await state.get_data() + exercises = data.get('exercises', []) + current = data.get('current_exercise', 0) + topic_name = data.get('topic_name_ru' if lang == 'ru' else 'topic_name', '') + + if current >= len(exercises): + # Все упражнения завершены + await show_results(message, state, lang, edit) + return + + exercise = exercises[current] + + text = ( + t(lang, 'exercises.task_header', topic=topic_name) + "\n\n" + + f"{current + 1}/{len(exercises)}\n\n" + + t(lang, 'exercises.instruction') + "\n\n" + + f"📝 {exercise.get('sentence', '')}\n\n" + + f"💬 {exercise.get('translation', '')}\n\n" + + f"💡 {exercise.get('hint', '')}\n\n" + + t(lang, 'exercises.write_answer') + ) + + # Кнопки + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=t(lang, 'exercises.back_btn'), callback_data="exercises_back_to_topics")], + [InlineKeyboardButton(text=t(lang, 'exercises.close_btn'), callback_data="exercises_close")] + ]) + + if edit: + await message.edit_text(text, reply_markup=keyboard) + else: + await message.answer(text, reply_markup=keyboard) + + +@router.callback_query(F.data == "exercises_back_to_topics") +async def back_to_topics_callback(callback: CallbackQuery, state: FSMContext): + """Вернуться к выбору темы.""" + await callback.answer() + await callback.message.delete() + + data = await state.get_data() + page = data.get('page', 0) + + await show_exercises_menu(callback.message, state, page=page, telegram_id=callback.from_user.id) + + +@router.message(ExercisesStates.waiting_answer) +async def process_answer(message: Message, state: FSMContext): + """Обработка ответа пользователя.""" + user_answer = message.text.strip().lower() + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + + lang = get_user_lang(user) if user else 'ru' + + data = await state.get_data() + exercises = data.get('exercises', []) + current = data.get('current_exercise', 0) + correct_count = data.get('correct_count', 0) + + if current >= len(exercises): + return + + exercise = exercises[current] + correct_answer = exercise.get('correct_answer', '').strip().lower() + + # Проверяем ответ + is_correct = user_answer == correct_answer + + if is_correct: + correct_count += 1 + result_text = t(lang, 'exercises.correct') + "\n\n" + else: + result_text = ( + t(lang, 'exercises.incorrect') + "\n\n" + + t(lang, 'exercises.your_answer', answer=message.text) + "\n" + + t(lang, 'exercises.right_answer', answer=exercise.get('correct_answer', '')) + "\n\n" + ) + + # Добавляем объяснение + result_text += t(lang, 'exercises.explanation', text=exercise.get('explanation', '')) + + # Обновляем состояние + await state.update_data( + current_exercise=current + 1, + correct_count=correct_count + ) + + # Кнопка "Следующее" или "Результаты" + if current + 1 < len(exercises): + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=t(lang, 'exercises.next_btn'), callback_data="exercises_next")] + ]) + else: + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=t(lang, 'exercises.results_btn'), callback_data="exercises_finish")] + ]) + + await message.answer(result_text, reply_markup=keyboard) + + +@router.callback_query(F.data == "exercises_next") +async def next_exercise_callback(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) + + lang = get_user_lang(user) if user else 'ru' + + await show_exercise(callback.message, state, lang, edit=True) + + +@router.callback_query(F.data == "exercises_finish") +async def finish_exercises_callback(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) + + lang = get_user_lang(user) if user else 'ru' + + # Убираем кнопку с предыдущего сообщения (оставляем фидбэк видимым) + await callback.message.edit_reply_markup(reply_markup=None) + # Показываем результаты новым сообщением + await show_results(callback.message, state, lang, edit=False) + + +async def show_results(message: Message, state: FSMContext, lang: str, edit: bool = False): + """Показать результаты упражнений.""" + data = await state.get_data() + exercises = data.get('exercises', []) + correct_count = data.get('correct_count', 0) + total = len(exercises) + topic_name = data.get('topic_name_ru' if lang == 'ru' else 'topic_name', '') + + text = ( + f"🎉 {topic_name}\n\n" + + t(lang, 'exercises.score', correct=correct_count, total=total) + ) + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=t(lang, 'exercises.back_btn'), callback_data="exercises_restart")], + [InlineKeyboardButton(text=t(lang, 'exercises.close_btn'), callback_data="exercises_close")] + ]) + + if edit: + await message.edit_text(text, reply_markup=keyboard) + else: + await message.answer(text, reply_markup=keyboard) + + await state.clear() + + +@router.callback_query(F.data == "exercises_restart") +async def restart_exercises_callback(callback: CallbackQuery, state: FSMContext): + """Вернуться к выбору темы после завершения.""" + await callback.answer() + await callback.message.delete() + await show_exercises_menu(callback.message, state, page=0, telegram_id=callback.from_user.id) diff --git a/bot/handlers/practice.py b/bot/handlers/practice.py index 4c76ee9..9c23ff6 100644 --- a/bot/handlers/practice.py +++ b/bot/handlers/practice.py @@ -29,6 +29,40 @@ def get_scenario_name(lang: str, scenario: str) -> str: return t(lang, f'practice.scenario.{scenario}') +async def show_practice_menu(message: Message, telegram_id: int, edit: bool = False): + """Показать меню выбора сценария практики""" + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, telegram_id) + + if not user: + await message.answer(t('ru', 'common.start_first')) + return + + lang = get_user_lang(user) + + keyboard = [] + for scenario_id in SCENARIO_KEYS: + keyboard.append([ + InlineKeyboardButton( + text=get_scenario_name(lang, scenario_id), + callback_data=f"scenario_{scenario_id}" + ) + ]) + keyboard.append([ + InlineKeyboardButton( + text=t(lang, 'practice.custom_scenario_btn'), + callback_data="scenario_custom" + ) + ]) + + reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard) + + if edit: + await message.edit_text(t(lang, 'practice.start_text'), reply_markup=reply_markup) + else: + await message.answer(t(lang, 'practice.start_text'), reply_markup=reply_markup) + + @router.message(Command("practice")) async def cmd_practice(message: Message, state: FSMContext): """Обработчик команды /practice""" @@ -39,31 +73,10 @@ async def cmd_practice(message: Message, state: FSMContext): await message.answer(t('ru', 'common.start_first')) return - lang = get_user_lang(user) - - # Показываем выбор сценария - keyboard = [] - for scenario_id in SCENARIO_KEYS: - keyboard.append([ - InlineKeyboardButton( - text=get_scenario_name(lang, scenario_id), - callback_data=f"scenario_{scenario_id}" - ) - ]) - # Кнопка для своего сценария - keyboard.append([ - InlineKeyboardButton( - text=t(lang, 'practice.custom_scenario_btn'), - callback_data="scenario_custom" - ) - ]) - - reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard) - await state.update_data(user_id=user.id, level=get_user_level_for_language(user)) await state.set_state(PracticeStates.choosing_scenario) - await message.answer(t(lang, 'practice.start_text'), reply_markup=reply_markup) + await show_practice_menu(message, message.from_user.id, edit=False) @router.callback_query(F.data == "scenario_custom", PracticeStates.choosing_scenario) diff --git a/bot/handlers/start.py b/bot/handlers/start.py index 3591457..e43ff9e 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -13,7 +13,7 @@ from aiogram.fsm.state import State, StatesGroup from database.db import async_session_maker from services.user_service import UserService -from utils.i18n import t, get_user_translation_lang +from utils.i18n import t, get_user_translation_lang, get_user_lang from utils.levels import get_user_level_for_language router = Router() @@ -57,16 +57,19 @@ def main_menu_keyboard(lang: str = 'ru') -> ReplyKeyboardMarkup: return ReplyKeyboardMarkup( resize_keyboard=True, keyboard=[ - [ - KeyboardButton(text=t(lang, "menu.add")), - KeyboardButton(text=t(lang, "menu.vocab")), - ], [ KeyboardButton(text=t(lang, "menu.task")), KeyboardButton(text=t(lang, "menu.practice")), ], [ + KeyboardButton(text=t(lang, "menu.exercises")), + KeyboardButton(text=t(lang, "menu.vocab")), + ], + [ + KeyboardButton(text=t(lang, "menu.add")), KeyboardButton(text=t(lang, "menu.stats")), + ], + [ KeyboardButton(text=t(lang, "menu.settings")), ], ], @@ -326,8 +329,74 @@ async def btn_task_pressed(message: Message, state: FSMContext): @router.message(_menu_match('menu.practice')) async def btn_practice_pressed(message: Message, state: FSMContext): - from bot.handlers.practice import cmd_practice - await cmd_practice(message, state) + """Показать меню практики""" + await state.clear() + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + lang = get_user_lang(user) if user else 'ru' + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=f"📖 {t(lang, 'practice_menu.stories')}", + callback_data="practice_stories" + )], + [InlineKeyboardButton( + text=f"💬 {t(lang, 'practice_menu.ai_chat')}", + callback_data="practice_ai" + )], + ]) + + await message.answer( + f"💬 {t(lang, 'practice_menu.title')}\n\n{t(lang, 'practice_menu.choose')}", + reply_markup=keyboard + ) + + +@router.callback_query(F.data == "practice_stories") +async def practice_stories_callback(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) + lang = get_user_lang(user) if user else 'ru' + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text=f"🗣 {t(lang, 'story.genre.dialogue')}", callback_data="story_genre_dialogue"), + InlineKeyboardButton(text=f"📰 {t(lang, 'story.genre.news')}", callback_data="story_genre_news"), + ], + [ + InlineKeyboardButton(text=f"🎭 {t(lang, 'story.genre.story')}", callback_data="story_genre_story"), + InlineKeyboardButton(text=f"📧 {t(lang, 'story.genre.letter')}", callback_data="story_genre_letter"), + ], + [ + InlineKeyboardButton(text=f"🍳 {t(lang, 'story.genre.recipe')}", callback_data="story_genre_recipe"), + ], + ]) + + await callback.message.edit_text( + f"📖 {t(lang, 'story.title')}\n\n{t(lang, 'story.choose_genre')}", + reply_markup=keyboard + ) + + +@router.callback_query(F.data == "practice_ai") +async def practice_ai_callback(callback: CallbackQuery, state: FSMContext): + """Переход к AI практике""" + await callback.answer() + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + if user: + from bot.handlers.practice import PracticeStates + from utils.levels import get_user_level_for_language + await state.update_data(user_id=user.id, level=get_user_level_for_language(user)) + await state.set_state(PracticeStates.choosing_scenario) + + from bot.handlers.practice import show_practice_menu + await show_practice_menu(callback.message, callback.from_user.id, edit=True) @router.message(_menu_match('menu.import')) @@ -391,6 +460,14 @@ async def btn_settings_pressed(message: Message): await cmd_settings(message) + +@router.message(_menu_match('menu.exercises')) +async def btn_exercises_pressed(message: Message, state: FSMContext): + """Показать меню грамматических упражнений.""" + from bot.handlers.exercises import show_exercises_menu + await show_exercises_menu(message, state, telegram_id=message.from_user.id) + + @router.message(_menu_match('menu.words')) async def btn_words_pressed(message: Message, state: FSMContext): """Подсказать про тематические слова и показать быстрые темы.""" diff --git a/bot/handlers/stories.py b/bot/handlers/stories.py new file mode 100644 index 0000000..c187dc0 --- /dev/null +++ b/bot/handlers/stories.py @@ -0,0 +1,688 @@ +"""Handler для мини-историй (Reading Practice).""" + +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup + +from database.db import async_session_maker +from database.models import MiniStory, StoryGenre, WordSource +from services.user_service import UserService +from services.vocabulary_service import VocabularyService +from services.ai_service import ai_service +from utils.i18n import t, get_user_lang, get_user_translation_lang +from utils.levels import get_user_level_for_language + +router = Router() + + +class StoryStates(StatesGroup): + """Состояния для чтения истории""" + reading = State() # Чтение истории + questions = State() # Ответы на вопросы + + +GENRE_EMOJI = { + "dialogue": "🗣", + "news": "📰", + "story": "🎭", + "letter": "📧", + "recipe": "🍳" +} + + +def get_genre_keyboard(lang: str) -> InlineKeyboardMarkup: + """Клавиатура выбора жанра""" + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text=f"🗣 {t(lang, 'story.genre.dialogue')}", + callback_data="story_genre_dialogue" + ), + InlineKeyboardButton( + text=f"📰 {t(lang, 'story.genre.news')}", + callback_data="story_genre_news" + ), + ], + [ + InlineKeyboardButton( + text=f"🎭 {t(lang, 'story.genre.story')}", + callback_data="story_genre_story" + ), + InlineKeyboardButton( + text=f"📧 {t(lang, 'story.genre.letter')}", + callback_data="story_genre_letter" + ), + ], + [ + InlineKeyboardButton( + text=f"🍳 {t(lang, 'story.genre.recipe')}", + callback_data="story_genre_recipe" + ), + ], + ]) + + +def format_story_text(story: MiniStory, lang: str, show_translation: bool = False) -> str: + """Форматировать текст истории""" + emoji = GENRE_EMOJI.get(story.genre.value, "📖") + + text = f"{emoji} {story.title}\n" + text += f"{t(lang, 'story.level')}: {story.level} • {story.word_count} {t(lang, 'story.words')}\n" + text += "─" * 20 + "\n\n" + text += story.content + + if show_translation and story.translation: + text += "\n\n" + "─" * 20 + text += f"\n\n🌐 {t(lang, 'story.translation')}:\n\n" + text += f"{story.translation}" + + text += "\n\n" + "─" * 20 + + return text + + +def get_story_keyboard(story_id: int, lang: str, show_translation: bool = False) -> InlineKeyboardMarkup: + """Клавиатура под историей""" + # Кнопка перевода - показать или скрыть + if show_translation: + translation_btn = InlineKeyboardButton( + text=f"🌐 {t(lang, 'story.hide_translation')}", + callback_data=f"story_hide_translation_{story_id}" + ) + else: + translation_btn = InlineKeyboardButton( + text=f"🌐 {t(lang, 'story.show_translation')}", + callback_data=f"story_show_translation_{story_id}" + ) + + return InlineKeyboardMarkup(inline_keyboard=[ + [translation_btn], + [ + InlineKeyboardButton( + text=f"📝 {t(lang, 'story.questions_btn')}", + callback_data=f"story_questions_{story_id}" + ), + ], + [ + InlineKeyboardButton( + text=f"📚 {t(lang, 'story.vocab_btn')}", + callback_data=f"story_vocab_{story_id}" + ), + ], + [ + InlineKeyboardButton( + text=f"🔄 {t(lang, 'story.new_btn')}", + callback_data="story_new" + ), + ], + ]) + + +@router.message(Command("story")) +async def cmd_story(message: Message, state: FSMContext): + """Обработчик команды /story""" + await state.clear() + + 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) + + text = f"📖 {t(lang, 'story.title')}\n\n" + text += t(lang, 'story.choose_genre') + + await message.answer(text, reply_markup=get_genre_keyboard(lang)) + + +@router.callback_query(F.data == "story_new") +async def story_new_callback(callback: CallbackQuery, state: FSMContext): + """Показать выбор жанра для новой истории""" + await callback.answer() + await state.clear() + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) + + text = f"📖 {t(lang, 'story.title')}\n\n" + text += t(lang, 'story.choose_genre') + + await callback.message.edit_text(text, reply_markup=get_genre_keyboard(lang)) + + +@router.callback_query(F.data.startswith("story_genre_")) +async def story_genre_callback(callback: CallbackQuery, state: FSMContext): + """Генерация истории выбранного жанра""" + await callback.answer() + + genre = callback.data.replace("story_genre_", "") + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) + learning_lang = user.learning_language or 'en' + level = get_user_level_for_language(user) + translation_lang = get_user_translation_lang(user) + + # Показываем индикатор генерации + await callback.message.edit_text(t(lang, 'story.generating')) + + # Получаем количество вопросов из настроек + tasks_count = getattr(user, 'tasks_count', 5) or 5 + + # Генерируем историю + story_data = await ai_service.generate_mini_story( + genre=genre, + level=level, + learning_lang=learning_lang, + translation_lang=translation_lang, + user_id=user.id, + num_questions=tasks_count + ) + + if not story_data: + await callback.message.edit_text( + t(lang, 'story.failed'), + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=f"🔄 {t(lang, 'story.try_again')}", + callback_data="story_new" + )] + ]) + ) + return + + # Сохраняем историю в БД + story = MiniStory( + user_id=user.id, + title=story_data.get('title', 'Story'), + content=story_data.get('content', ''), + translation=story_data.get('translation', ''), + genre=StoryGenre(genre), + learning_lang=learning_lang, + level=level, + word_count=story_data.get('word_count', 0), + vocabulary=story_data.get('vocabulary', []), + questions=story_data.get('questions', []) + ) + session.add(story) + await session.commit() + await session.refresh(story) + + # Сохраняем ID истории в состоянии + await state.update_data(story_id=story.id) + await state.set_state(StoryStates.reading) + + # Показываем историю + text = format_story_text(story, lang) + await callback.message.edit_text( + text, + reply_markup=get_story_keyboard(story.id, lang) + ) + + +@router.callback_query(F.data.startswith("story_show_translation_")) +async def story_show_translation_callback(callback: CallbackQuery): + """Показать перевод истории""" + await callback.answer() + + story_id = int(callback.data.replace("story_show_translation_", "")) + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) + + story = await session.get(MiniStory, story_id) + if not story: + await callback.answer(t(lang, 'story.not_found'), show_alert=True) + return + + text = format_story_text(story, lang, show_translation=True) + await callback.message.edit_text( + text, + reply_markup=get_story_keyboard(story.id, lang, show_translation=True) + ) + + +@router.callback_query(F.data.startswith("story_hide_translation_")) +async def story_hide_translation_callback(callback: CallbackQuery): + """Скрыть перевод истории""" + await callback.answer() + + story_id = int(callback.data.replace("story_hide_translation_", "")) + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) + + story = await session.get(MiniStory, story_id) + if not story: + await callback.answer(t(lang, 'story.not_found'), show_alert=True) + return + + text = format_story_text(story, lang, show_translation=False) + await callback.message.edit_text( + text, + reply_markup=get_story_keyboard(story.id, lang, show_translation=False) + ) + + +@router.callback_query(F.data.startswith("story_vocab_")) +async def story_vocab_callback(callback: CallbackQuery): + """Показать словарь истории""" + await callback.answer() + + story_id = int(callback.data.replace("story_vocab_", "")) + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) + + story = await session.get(MiniStory, story_id) + if not story: + await callback.answer(t(lang, 'story.not_found'), show_alert=True) + return + + vocabulary = story.vocabulary or [] + + if not vocabulary: + await callback.answer(t(lang, 'story.no_vocab'), show_alert=True) + return + + # Формируем текст со словами + text = f"📚 {t(lang, 'story.vocabulary')}\n\n" + + keyboard_buttons = [] + for i, word_data in enumerate(vocabulary[:10]): + word = word_data.get('word', '') + translation = word_data.get('translation', '') + transcription = word_data.get('transcription', '') + + if transcription: + text += f"• {word} [{transcription}] — {translation}\n" + else: + text += f"• {word} — {translation}\n" + + # Кнопка добавления слова + keyboard_buttons.append([ + InlineKeyboardButton( + text=f"➕ {word}", + callback_data=f"story_addword_{story_id}_{i}" + ) + ]) + + # Кнопка "Добавить все" + keyboard_buttons.append([ + InlineKeyboardButton( + text=f"➕ {t(lang, 'story.add_all')}", + callback_data=f"story_addall_{story_id}" + ) + ]) + + # Кнопка назад + keyboard_buttons.append([ + InlineKeyboardButton( + text=f"⬅️ {t(lang, 'story.back')}", + callback_data=f"story_back_{story_id}" + ) + ]) + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + ) + + +@router.callback_query(F.data.startswith("story_addword_")) +async def story_addword_callback(callback: CallbackQuery): + """Добавить одно слово из истории""" + parts = callback.data.split("_") + story_id = int(parts[2]) + word_idx = int(parts[3]) + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) + + story = await session.get(MiniStory, story_id) + if not story or not story.vocabulary: + await callback.answer(t(lang, 'story.not_found'), show_alert=True) + return + + if word_idx >= len(story.vocabulary): + await callback.answer(t(lang, 'story.word_not_found'), show_alert=True) + return + + word_data = story.vocabulary[word_idx] + word = word_data.get('word', '') + translation = word_data.get('translation', '') + transcription = word_data.get('transcription') + + # Проверяем, нет ли уже + existing = await VocabularyService.get_word_by_original( + session, user.id, word, source_lang=story.learning_lang + ) + + if existing: + await callback.answer(t(lang, 'words.already_exists', word=word), show_alert=True) + return + + # Добавляем слово + translation_lang = get_user_translation_lang(user) + await VocabularyService.add_word( + session=session, + user_id=user.id, + word_original=word, + word_translation=translation, + source_lang=story.learning_lang, + translation_lang=translation_lang, + transcription=transcription, + difficulty_level=story.level, + source=WordSource.IMPORT + ) + await session.commit() + + await callback.answer(t(lang, 'story.word_added', word=word), show_alert=True) + + +@router.callback_query(F.data.startswith("story_addall_")) +async def story_addall_callback(callback: CallbackQuery): + """Добавить все слова из истории""" + story_id = int(callback.data.replace("story_addall_", "")) + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) + + story = await session.get(MiniStory, story_id) + if not story or not story.vocabulary: + await callback.answer(t(lang, 'story.not_found'), show_alert=True) + return + + translation_lang = get_user_translation_lang(user) + added = 0 + + for word_data in story.vocabulary: + word = word_data.get('word', '') + translation = word_data.get('translation', '') + transcription = word_data.get('transcription') + + # Проверяем дубликаты + existing = await VocabularyService.get_word_by_original( + session, user.id, word, source_lang=story.learning_lang + ) + + if not existing: + await VocabularyService.add_word( + session=session, + user_id=user.id, + word_original=word, + word_translation=translation, + source_lang=story.learning_lang, + translation_lang=translation_lang, + transcription=transcription, + difficulty_level=story.level, + source=WordSource.IMPORT + ) + added += 1 + + await session.commit() + await callback.answer(t(lang, 'story.words_added', n=added), show_alert=True) + + +@router.callback_query(F.data.startswith("story_back_")) +async def story_back_callback(callback: CallbackQuery): + """Вернуться к истории""" + await callback.answer() + + story_id = int(callback.data.replace("story_back_", "")) + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) + + story = await session.get(MiniStory, story_id) + if not story: + await callback.message.edit_text(t(lang, 'story.not_found')) + return + + text = format_story_text(story, lang) + await callback.message.edit_text( + text, + reply_markup=get_story_keyboard(story.id, lang) + ) + + +@router.callback_query(F.data.startswith("story_questions_")) +async def story_questions_callback(callback: CallbackQuery, state: FSMContext): + """Показать вопросы по истории""" + await callback.answer() + + story_id = int(callback.data.replace("story_questions_", "")) + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) + + story = await session.get(MiniStory, story_id) + if not story: + await callback.message.edit_text(t(lang, 'story.not_found')) + return + + questions = story.questions or [] + if not questions: + await callback.answer(t(lang, 'story.no_questions'), show_alert=True) + return + + # Сохраняем состояние + await state.update_data( + story_id=story_id, + current_question=0, + correct_answers=0, + total_questions=len(questions) + ) + await state.set_state(StoryStates.questions) + + # Показываем первый вопрос + await show_question(callback.message, story, 0, lang, edit=True) + + +async def show_question(message: Message, story: MiniStory, q_idx: int, lang: str, edit: bool = False): + """Показать вопрос""" + questions = story.questions or [] + + if q_idx >= len(questions): + return + + q = questions[q_idx] + total = len(questions) + + text = f"📝 {t(lang, 'story.question')} {q_idx + 1}/{total}\n\n" + text += f"{q.get('question', '')}\n" + + # Кнопки с вариантами ответов + options = q.get('options', []) + keyboard_buttons = [] + + for i, option in enumerate(options): + keyboard_buttons.append([ + InlineKeyboardButton( + text=option, + callback_data=f"story_answer_{story.id}_{q_idx}_{i}" + ) + ]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + + if edit: + await message.edit_text(text, reply_markup=keyboard) + else: + await message.answer(text, reply_markup=keyboard) + + +@router.callback_query(F.data.startswith("story_answer_")) +async def story_answer_callback(callback: CallbackQuery, state: FSMContext): + """Обработка ответа на вопрос""" + parts = callback.data.split("_") + story_id = int(parts[2]) + q_idx = int(parts[3]) + answer_idx = int(parts[4]) + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) + + story = await session.get(MiniStory, story_id) + if not story: + await callback.answer(t(lang, 'story.not_found'), show_alert=True) + return + + questions = story.questions or [] + if q_idx >= len(questions): + await callback.answer(t(lang, 'story.question_not_found'), show_alert=True) + return + + q = questions[q_idx] + correct = q.get('correct', 0) + options = q.get('options', []) + + # Получаем данные состояния + data = await state.get_data() + correct_answers = data.get('correct_answers', 0) + total_questions = data.get('total_questions', len(questions)) + + # Проверяем ответ + is_correct = (answer_idx == correct) + if is_correct: + correct_answers += 1 + + # Показываем результат ответа + text = f"📝 {t(lang, 'story.question')} {q_idx + 1}/{total_questions}\n\n" + text += f"{q.get('question', '')}\n\n" + + for i, option in enumerate(options): + if i == correct: + text += f"✅ {option}\n" + elif i == answer_idx and not is_correct: + text += f"❌ {option}\n" + else: + text += f"○ {option}\n" + + if is_correct: + text += f"\n{t(lang, 'story.correct')}" + await callback.answer("✅", show_alert=False) + else: + text += f"\n{t(lang, 'story.incorrect')}" + await callback.answer("❌", show_alert=False) + + # Обновляем состояние + await state.update_data(correct_answers=correct_answers) + + # Следующий вопрос или результаты + next_q_idx = q_idx + 1 + + if next_q_idx < total_questions: + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=f"➡️ {t(lang, 'story.next_question')}", + callback_data=f"story_nextq_{story_id}_{next_q_idx}" + )] + ]) + else: + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=f"📊 {t(lang, 'story.show_results')}", + callback_data=f"story_results_{story_id}_{correct_answers}" + )] + ]) + + await callback.message.edit_text(text, reply_markup=keyboard) + + +@router.callback_query(F.data.startswith("story_nextq_")) +async def story_nextq_callback(callback: CallbackQuery, state: FSMContext): + """Следующий вопрос""" + await callback.answer() + + parts = callback.data.split("_") + story_id = int(parts[2]) + q_idx = int(parts[3]) + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) + + story = await session.get(MiniStory, story_id) + if not story: + await callback.message.edit_text(t(lang, 'story.not_found')) + return + + await show_question(callback.message, story, q_idx, lang, edit=True) + + +@router.callback_query(F.data.startswith("story_results_")) +async def story_results_callback(callback: CallbackQuery, state: FSMContext): + """Показать результаты""" + await callback.answer() + await state.clear() + + parts = callback.data.split("_") + story_id = int(parts[2]) + correct_answers = int(parts[3]) + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) + + story = await session.get(MiniStory, story_id) + if not story: + await callback.message.edit_text(t(lang, 'story.not_found')) + return + + total = len(story.questions or []) + + # Обновляем статус истории + story.is_completed = True + story.correct_answers = correct_answers + await session.commit() + + # Определяем эмодзи по результату + percentage = (correct_answers / total * 100) if total > 0 else 0 + if percentage >= 80: + emoji = "🎉" + comment = t(lang, 'story.result_excellent') + elif percentage >= 50: + emoji = "👍" + comment = t(lang, 'story.result_good') + else: + emoji = "📚" + comment = t(lang, 'story.result_practice') + + text = f"{emoji} {t(lang, 'story.results_title')}\n\n" + text += f"📖 {story.title}\n\n" + text += f"{t(lang, 'story.correct_answers')}: {correct_answers}/{total}\n" + text += f"{t(lang, 'story.accuracy')}: {percentage:.0f}%\n\n" + text += f"{comment}" + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text=f"📚 {t(lang, 'story.vocab_btn')}", + callback_data=f"story_vocab_{story_id}" + ), + ], + [ + InlineKeyboardButton( + text=f"🔄 {t(lang, 'story.new_btn')}", + callback_data="story_new" + ), + ], + ]) + + await callback.message.edit_text(text, reply_markup=keyboard) diff --git a/bot/handlers/vocabulary.py b/bot/handlers/vocabulary.py index 522c2ea..e8d7099 100644 --- a/bot/handlers/vocabulary.py +++ b/bot/handlers/vocabulary.py @@ -18,12 +18,13 @@ class AddWordStates(StatesGroup): """Состояния для добавления слова""" waiting_for_confirmation = State() waiting_for_word = State() + viewing_batch = State() # Просмотр списка слов для batch-добавления @router.message(Command("add")) async def cmd_add(message: Message, state: FSMContext): - """Обработчик команды /add [слово]""" - # Получаем слово из команды + """Обработчик команды /add [слово] или /add [слово1, слово2, ...]""" + # Получаем слово(а) из команды parts = message.text.split(maxsplit=1) if len(parts) < 2: @@ -34,15 +35,32 @@ async def cmd_add(message: Message, state: FSMContext): await state.set_state(AddWordStates.waiting_for_word) return - word = parts[1].strip() - await process_word_addition(message, state, word) + text = parts[1].strip() + + # Проверяем, есть ли несколько слов (через запятую) + if ',' in text: + words = [w.strip() for w in text.split(',') if w.strip()] + if len(words) > 1: + await process_batch_addition(message, state, words) + return + + # Одно слово - стандартная обработка + await process_word_addition(message, state, text) @router.message(AddWordStates.waiting_for_word) async def process_word_input(message: Message, state: FSMContext): - """Обработка ввода слова""" - word = message.text.strip() - await process_word_addition(message, state, word) + """Обработка ввода слова или нескольких слов""" + text = message.text.strip() + + # Проверяем, есть ли несколько слов (через запятую) + if ',' in text: + words = [w.strip() for w in text.split(',') if w.strip()] + if len(words) > 1: + await process_batch_addition(message, state, words) + return + + await process_word_addition(message, state, text) async def process_word_addition(message: Message, state: FSMContext, word: str): @@ -188,6 +206,201 @@ async def cancel_add_word(callback: CallbackQuery, state: FSMContext): await callback.answer() +# === Batch добавление нескольких слов === + +async def process_batch_addition(message: Message, state: FSMContext, words: list[str]): + """Обработка добавления нескольких слов""" + 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 len(words) > 20: + words = words[:20] + await message.answer(t(lang, 'add_batch.truncated', n=20)) + + # Показываем индикатор загрузки + processing_msg = await message.answer(t(lang, 'add_batch.translating', n=len(words))) + + # Получаем переводы через AI batch-методом + source_lang = user.learning_language or 'en' + translation_lang = get_user_translation_lang(user) + + translated_words = await ai_service.translate_words_batch( + words=words, + source_lang=source_lang, + translation_lang=translation_lang, + user_id=user.id + ) + + await processing_msg.delete() + + if not translated_words: + await message.answer(t(lang, 'add_batch.failed')) + return + + # Сохраняем данные в состоянии + await state.update_data( + batch_words=translated_words, + user_id=user.id + ) + await state.set_state(AddWordStates.viewing_batch) + + # Показываем список слов + await show_batch_words(message, translated_words, lang) + + +async def show_batch_words(message: Message, words: list, lang: str): + """Показать список слов для batch-добавления""" + text = t(lang, 'add_batch.header', n=len(words)) + "\n\n" + + for idx, word_data in enumerate(words, 1): + word = word_data.get('word', '') + translation = word_data.get('translation', '') + transcription = word_data.get('transcription', '') + + line = f"{idx}. {word}" + if transcription: + line += f" [{transcription}]" + line += f"\n {translation}\n" + text += line + + text += "\n" + t(lang, 'add_batch.choose') + + # Создаем кнопки для каждого слова (по 2 в ряд) + keyboard = [] + for idx, word_data in enumerate(words): + button = InlineKeyboardButton( + text=f"➕ {word_data.get('word', '')[:15]}", + callback_data=f"batch_word_{idx}" + ) + if len(keyboard) == 0 or len(keyboard[-1]) == 2: + keyboard.append([button]) + else: + keyboard[-1].append(button) + + # Кнопка "Добавить все" + keyboard.append([ + InlineKeyboardButton(text=t(lang, 'words.add_all_btn'), callback_data="batch_add_all") + ]) + + # Кнопка "Закрыть" + keyboard.append([ + InlineKeyboardButton(text=t(lang, 'words.close_btn'), callback_data="batch_close") + ]) + + reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard) + await message.answer(text, reply_markup=reply_markup) + + +@router.callback_query(F.data.startswith("batch_word_"), AddWordStates.viewing_batch) +async def batch_add_single(callback: CallbackQuery, state: FSMContext): + """Добавить одно слово из batch""" + await callback.answer() + word_index = int(callback.data.split("_")[2]) + + data = await state.get_data() + words = data.get('batch_words', []) + user_id = data.get('user_id') + + if word_index >= len(words): + return + + word_data = words[word_index] + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) + + # Проверяем, нет ли уже такого слова + existing = await VocabularyService.get_word_by_original( + session, user_id, word_data.get('word', ''), source_lang=user.learning_language + ) + + if existing: + await callback.answer(t(lang, 'words.already_exists', word=word_data.get('word', '')), show_alert=True) + return + + # Добавляем слово + translation_lang = get_user_translation_lang(user) + + await VocabularyService.add_word( + session=session, + user_id=user_id, + word_original=word_data.get('word', ''), + word_translation=word_data.get('translation', ''), + source_lang=user.learning_language, + translation_lang=translation_lang, + transcription=word_data.get('transcription'), + source=WordSource.MANUAL + ) + + await callback.message.answer(t(lang, 'words.added_single', word=word_data.get('word', ''))) + + +@router.callback_query(F.data == "batch_add_all", AddWordStates.viewing_batch) +async def batch_add_all(callback: CallbackQuery, state: FSMContext): + """Добавить все слова из batch""" + await callback.answer() + + data = await state.get_data() + words = data.get('batch_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.get('word', ''), source_lang=user.learning_language + ) + + if existing: + skipped_count += 1 + continue + + # Добавляем слово + translation_lang = get_user_translation_lang(user) + + await VocabularyService.add_word( + session=session, + user_id=user_id, + word_original=word_data.get('word', ''), + word_translation=word_data.get('translation', ''), + source_lang=user.learning_language, + translation_lang=translation_lang, + transcription=word_data.get('transcription'), + source=WordSource.MANUAL + ) + 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() + + +@router.callback_query(F.data == "batch_close", AddWordStates.viewing_batch) +async def batch_close(callback: CallbackQuery, state: FSMContext): + """Закрыть batch добавление""" + await callback.message.delete() + await state.clear() + await callback.answer() + + WORDS_PER_PAGE = 10 diff --git a/bot/handlers/wordofday.py b/bot/handlers/wordofday.py new file mode 100644 index 0000000..51fa4d5 --- /dev/null +++ b/bot/handlers/wordofday.py @@ -0,0 +1,139 @@ +"""Handler для функции 'Слово дня'.""" + +from datetime import datetime + +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton + +from database.db import async_session_maker +from database.models import WordOfDay, WordSource +from services.user_service import UserService +from services.vocabulary_service import VocabularyService +from services.wordofday_service import wordofday_service +from utils.i18n import t, get_user_lang, get_user_translation_lang +from utils.levels import get_user_level_for_language + +router = Router() + + +def format_word_of_day(wod: WordOfDay, lang: str) -> str: + """Форматировать слово дня для отображения.""" + date_str = wod.date.strftime("%d.%m.%Y") + + text = f"🌅 {t(lang, 'wod.title')} — {date_str}\n\n" + text += f"📝 {wod.word}\n" + + if wod.transcription: + text += f"🔊 [{wod.transcription}]\n" + + text += f"\n💬 {wod.translation}\n" + + # Примеры + if wod.examples: + text += f"\n📖 {t(lang, 'wod.examples')}:\n" + for ex in wod.examples[:2]: + sentence = ex.get('sentence', '') + translation = ex.get('translation', '') + text += f"• {sentence}\n" + if translation: + text += f" ({translation})\n" + + # Синонимы + if wod.synonyms: + text += f"\n🔗 {t(lang, 'wod.synonyms')}: {wod.synonyms}\n" + + # Этимология/интересный факт + if wod.etymology: + text += f"\n💡 {wod.etymology}\n" + + return text + + +@router.message(Command("wordofday")) +async def cmd_wordofday(message: Message): + """Обработчик команды /wordofday.""" + 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) + learning_lang = user.learning_language or 'en' + level = get_user_level_for_language(user) + + # Получаем слово дня из глобальной таблицы + wod = await wordofday_service.get_word_of_day( + learning_lang=learning_lang, + level=level + ) + + if not wod: + # Слово ещё не сгенерировано - показываем сообщение + await message.answer(t(lang, 'wod.not_available')) + return + + # Форматируем и отправляем + text = format_word_of_day(wod, lang) + + # Кнопка добавления в словарь + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=t(lang, 'wod.add_btn'), + callback_data=f"wod_add_{wod.id}" + )] + ]) + + await message.answer(text, reply_markup=keyboard) + + +@router.callback_query(F.data.startswith("wod_add_")) +async def wod_add_callback(callback: CallbackQuery): + """Добавить слово дня в словарь.""" + await callback.answer() + + wod_id = int(callback.data.replace("wod_add_", "")) + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) + + # Получаем слово дня + wod = await session.get(WordOfDay, wod_id) + if not wod: + await callback.answer(t(lang, 'wod.not_found'), show_alert=True) + return + + # Проверяем, нет ли уже в словаре + existing = await VocabularyService.get_word_by_original( + session, user.id, wod.word, source_lang=wod.learning_lang + ) + + if existing: + await callback.answer(t(lang, 'words.already_exists', word=wod.word), show_alert=True) + return + + # Добавляем в словарь + translation_lang = get_user_translation_lang(user) + + await VocabularyService.add_word( + session=session, + user_id=user.id, + word_original=wod.word, + word_translation=wod.translation, + source_lang=wod.learning_lang, + translation_lang=translation_lang, + transcription=wod.transcription, + difficulty_level=wod.level, + source=WordSource.SUGGESTED + ) + + await session.commit() + + # Обновляем сообщение + text = format_word_of_day(wod, lang) + text += f"\n✅ {t(lang, 'wod.added')}" + + await callback.message.edit_text(text, reply_markup=None) diff --git a/data/__init__.py b/data/__init__.py new file mode 100644 index 0000000..aaf8b62 --- /dev/null +++ b/data/__init__.py @@ -0,0 +1 @@ +# Data module for grammar rules and other static data diff --git a/data/grammar_rules.py b/data/grammar_rules.py new file mode 100644 index 0000000..a862e8a --- /dev/null +++ b/data/grammar_rules.py @@ -0,0 +1,321 @@ +""" +Грамматические правила для упражнений по уровням CEFR (английский) и JLPT (японский). +Данные основаны на официальных требованиях CEFR и JLPT. +""" + +# Английский язык - CEFR уровни +ENGLISH_GRAMMAR_RULES = { + "A1": [ + {"id": "present_simple", "name": "Present Simple", "name_ru": "Простое настоящее время", "description": "I work, he works"}, + {"id": "present_continuous", "name": "Present Continuous", "name_ru": "Настоящее длительное", "description": "I am working"}, + {"id": "present_perfect", "name": "Present Perfect", "name_ru": "Настоящее совершенное", "description": "I have worked"}, + {"id": "past_simple", "name": "Past Simple", "name_ru": "Простое прошедшее", "description": "I worked, I went"}, + {"id": "irregular_verbs", "name": "Irregular verbs", "name_ru": "Неправильные глаголы", "description": "go-went-gone, see-saw-seen"}, + {"id": "future_will", "name": "Future with will", "name_ru": "Будущее с will", "description": "I will work"}, + {"id": "be_verb", "name": "Verb 'to be'", "name_ru": "Глагол to be", "description": "am, is, are"}, + {"id": "have_got", "name": "Have got", "name_ru": "Have got", "description": "I have got / I've got"}, + {"id": "there_is_are", "name": "There is/are", "name_ru": "There is/are", "description": "There is a book, there are books"}, + {"id": "articles_basic", "name": "Articles (basic)", "name_ru": "Артикли (базовые)", "description": "a, an, the"}, + {"id": "possessive_adj", "name": "Possessive adjectives", "name_ru": "Притяжательные прил.", "description": "my, your, his, her"}, + {"id": "object_pronouns", "name": "Object pronouns", "name_ru": "Объектные местоимения", "description": "me, you, him, her"}, + {"id": "demonstratives", "name": "Demonstratives", "name_ru": "Указательные мест.", "description": "this, that, these, those"}, + {"id": "countable_uncountable", "name": "Countable/Uncountable", "name_ru": "Исчисл./неисчисляемые", "description": "many/much, some/any"}, + {"id": "imperatives", "name": "Imperatives", "name_ru": "Повелительное накл.", "description": "Go!, Don't go!"}, + {"id": "can_ability", "name": "Can (ability)", "name_ru": "Can (способность)", "description": "I can swim"}, + {"id": "prepositions_place", "name": "Prepositions of place", "name_ru": "Предлоги места", "description": "in, on, at, under"}, + {"id": "prepositions_time", "name": "Prepositions of time", "name_ru": "Предлоги времени", "description": "in, on, at (time)"}, + {"id": "adjective_order", "name": "Adjective order", "name_ru": "Порядок прилагательных", "description": "a big red car"}, + {"id": "adverbs_frequency", "name": "Adverbs of frequency", "name_ru": "Наречия частотности", "description": "always, usually, never"}, + {"id": "how_much_many", "name": "How much/many", "name_ru": "How much/many", "description": "How much/How many"}, + ], + "A2": [ + {"id": "past_continuous", "name": "Past Continuous", "name_ru": "Прошедшее длительное", "description": "I was working"}, + {"id": "present_perfect_ever", "name": "Present Perfect + ever/never", "name_ru": "Present Perfect + ever/never", "description": "Have you ever...?"}, + {"id": "going_to", "name": "Going to (future)", "name_ru": "Going to (будущее)", "description": "I'm going to work"}, + {"id": "comparatives", "name": "Comparatives", "name_ru": "Сравнительная степень", "description": "bigger, more expensive"}, + {"id": "superlatives", "name": "Superlatives", "name_ru": "Превосходная степень", "description": "the biggest, the most"}, + {"id": "must_obligation", "name": "Must (obligation)", "name_ru": "Must (обязательство)", "description": "You must go"}, + {"id": "should_advice", "name": "Should (advice)", "name_ru": "Should (совет)", "description": "You should go"}, + {"id": "have_to", "name": "Have to", "name_ru": "Have to", "description": "I have to work"}, + {"id": "could_past", "name": "Could (past ability)", "name_ru": "Could (прошлая способ.)", "description": "I could swim when I was 5"}, + {"id": "would_like", "name": "Would like", "name_ru": "Would like", "description": "I would like to..."}, + {"id": "first_conditional", "name": "First Conditional", "name_ru": "Условные 1 типа", "description": "If it rains, I will stay"}, + {"id": "gerund_verb", "name": "Gerund after verbs", "name_ru": "Герундий после глаголов", "description": "I enjoy swimming"}, + {"id": "infinitive_verb", "name": "Infinitive after verbs", "name_ru": "Инфинитив после глаг.", "description": "I want to go"}, + {"id": "adverbs_manner", "name": "Adverbs of manner", "name_ru": "Наречия образа действия", "description": "quickly, slowly"}, + {"id": "possessive_pronouns", "name": "Possessive pronouns", "name_ru": "Притяжательные мест.", "description": "mine, yours, his"}, + {"id": "relative_who_which", "name": "Relative clauses (who/which)", "name_ru": "Относительные (who/which)", "description": "The man who..."}, + {"id": "too_enough", "name": "Too/Enough", "name_ru": "Too/Enough", "description": "too big, big enough"}, + ], + "B1": [ + {"id": "past_perfect", "name": "Past Perfect", "name_ru": "Прошедшее совершенное", "description": "I had worked"}, + {"id": "present_perfect_cont", "name": "Present Perfect Continuous", "name_ru": "Наст. соверш. длит.", "description": "I have been working"}, + {"id": "future_continuous", "name": "Future Continuous", "name_ru": "Будущее длительное", "description": "I will be working"}, + {"id": "second_conditional", "name": "Second Conditional", "name_ru": "Условные 2 типа", "description": "If I had money, I would buy"}, + {"id": "passive_simple", "name": "Passive Voice (simple)", "name_ru": "Страдательный залог", "description": "The book was written"}, + {"id": "reported_speech", "name": "Reported Speech", "name_ru": "Косвенная речь", "description": "He said that..."}, + {"id": "used_to", "name": "Used to", "name_ru": "Used to", "description": "I used to play"}, + {"id": "modals_possibility", "name": "Modals of possibility", "name_ru": "Модальные (возможность)", "description": "might, may, could"}, + {"id": "relative_clauses_def", "name": "Defining relative clauses", "name_ru": "Определяющие придат.", "description": "The book that I read"}, + {"id": "question_tags", "name": "Question tags", "name_ru": "Разделительные вопросы", "description": "You like it, don't you?"}, + {"id": "so_neither", "name": "So/Neither", "name_ru": "So/Neither", "description": "So do I, Neither do I"}, + {"id": "phrasal_verbs_basic", "name": "Phrasal verbs (basic)", "name_ru": "Фразовые глаголы", "description": "get up, look for"}, + {"id": "wish_present", "name": "Wish + past simple", "name_ru": "Wish (настоящее)", "description": "I wish I had..."}, + ], + "B2": [ + {"id": "past_perfect_cont", "name": "Past Perfect Continuous", "name_ru": "Прошедшее соверш. длит.", "description": "I had been working"}, + {"id": "future_perfect", "name": "Future Perfect", "name_ru": "Будущее совершенное", "description": "I will have finished"}, + {"id": "future_perfect_cont", "name": "Future Perfect Continuous", "name_ru": "Будущее соверш. длит.", "description": "I will have been working"}, + {"id": "third_conditional", "name": "Third Conditional", "name_ru": "Условные 3 типа", "description": "If I had known, I would have..."}, + {"id": "mixed_conditionals", "name": "Mixed Conditionals", "name_ru": "Смешанные условные", "description": "If I had studied, I would be..."}, + {"id": "passive_all_tenses", "name": "Passive (all tenses)", "name_ru": "Страд. залог (все времена)", "description": "is being done, had been done"}, + {"id": "causative_have", "name": "Causative (have/get)", "name_ru": "Каузатив", "description": "I had my hair cut"}, + {"id": "relative_non_defining", "name": "Non-defining relatives", "name_ru": "Неопред. придаточные", "description": "My sister, who lives..."}, + {"id": "inversion", "name": "Inversion", "name_ru": "Инверсия", "description": "Never have I seen..."}, + {"id": "wish_past", "name": "Wish + past perfect", "name_ru": "Wish (прошедшее)", "description": "I wish I had done..."}, + {"id": "modals_deduction", "name": "Modals of deduction", "name_ru": "Модальные (вывод)", "description": "must be, can't be, might have"}, + {"id": "emphasis_cleft", "name": "Cleft sentences", "name_ru": "Расщепленные предл.", "description": "It was John who..."}, + {"id": "participle_clauses", "name": "Participle clauses", "name_ru": "Причастные обороты", "description": "Having finished, I left"}, + ], + "C1": [ + {"id": "subjunctive", "name": "Subjunctive mood", "name_ru": "Сослагательное накл.", "description": "I suggest that he go..."}, + {"id": "inversion_negative", "name": "Inversion (negative adverbials)", "name_ru": "Инверсия (отриц.)", "description": "Rarely do I..."}, + {"id": "ellipsis", "name": "Ellipsis", "name_ru": "Эллипсис", "description": "I can and will do it"}, + {"id": "fronting", "name": "Fronting", "name_ru": "Фронтинг", "description": "Strange though it may seem"}, + {"id": "nominal_relative", "name": "Nominal relative clauses", "name_ru": "Номинальные придат.", "description": "What I need is..."}, + {"id": "advanced_passives", "name": "Advanced passives", "name_ru": "Сложный страд. залог", "description": "It is said that..."}, + {"id": "discourse_markers", "name": "Discourse markers", "name_ru": "Дискурсивные маркеры", "description": "nevertheless, furthermore"}, + ], + "C2": [ + {"id": "advanced_inversion", "name": "Advanced inversion", "name_ru": "Продвинутая инверсия", "description": "So surprised was I that..."}, + {"id": "complex_passives", "name": "Complex passive structures", "name_ru": "Сложные страд. структуры", "description": "He is believed to have..."}, + {"id": "idiomatic_subjunctive", "name": "Idiomatic subjunctive", "name_ru": "Идиом. сослагательное", "description": "Be that as it may..."}, + {"id": "advanced_conditionals", "name": "Advanced conditionals", "name_ru": "Продвинутые условные", "description": "Were it not for..."}, + {"id": "nuanced_modals", "name": "Nuanced modals", "name_ru": "Нюансы модальных", "description": "could well be, might just"}, + ], +} + +# Японский язык - JLPT уровни (N5-N1) +JAPANESE_GRAMMAR_RULES = { + "N5": [ + # Частицы + {"id": "particle_wa", "name": "Частица は (тема)", "name_ru": "Частица は (тема)", "description": "わたしは学生です"}, + {"id": "particle_ga", "name": "Частица が (подлежащее)", "name_ru": "Частица が (подлежащее)", "description": "ねこがいます"}, + {"id": "particle_wo", "name": "Частица を (объект)", "name_ru": "Частица を (объект)", "description": "りんごをたべます"}, + {"id": "particle_ni", "name": "Частица に (направление/время)", "name_ru": "Частица に (направление/время)", "description": "がっこうにいきます、7じにおきます"}, + {"id": "particle_de", "name": "Частица で (место/средство)", "name_ru": "Частица で (место/средство)", "description": "でんしゃでいきます"}, + {"id": "particle_he", "name": "Частица へ (направление)", "name_ru": "Частица へ (направление)", "description": "にほんへいきます"}, + {"id": "particle_to", "name": "Частица と (и/с)", "name_ru": "Частица と (и/с)", "description": "ともだちといきます"}, + {"id": "particle_mo", "name": "Частица も (тоже)", "name_ru": "Частица も (тоже)", "description": "わたしもいきます"}, + {"id": "particle_no", "name": "Частица の (притяжание)", "name_ru": "Частица の (притяжание)", "description": "わたしのほん"}, + {"id": "particle_ka", "name": "Частица か (вопрос)", "name_ru": "Частица か (вопрос)", "description": "これはなんですか"}, + {"id": "particle_ne_yo", "name": "Частицы ね/よ", "name_ru": "Частицы ね/よ", "description": "いいですね、いきますよ"}, + # Глаголы + {"id": "verb_masu", "name": "ます-форма глаголов", "name_ru": "ます-форма глаголов", "description": "たべます、のみます"}, + {"id": "verb_te", "name": "て-форма глаголов", "name_ru": "て-форма глаголов", "description": "たべて、のんで、いって"}, + {"id": "verb_ta", "name": "た-форма (прошедшее)", "name_ru": "た-форма (прошедшее)", "description": "たべた、のんだ"}, + {"id": "verb_nai", "name": "ない-форма (отрицание)", "name_ru": "ない-форма (отрицание)", "description": "たべない、のまない"}, + {"id": "verb_te_kudasai", "name": "てください (просьба)", "name_ru": "てください (просьба)", "description": "たべてください"}, + {"id": "verb_te_imasu", "name": "ています (длительное)", "name_ru": "ています (длительное)", "description": "たべています"}, + {"id": "verb_tai", "name": "たい (хочу)", "name_ru": "たい (хочу)", "description": "たべたい、いきたい"}, + {"id": "verb_mashou", "name": "ましょう (предложение)", "name_ru": "ましょう (предложение)", "description": "いきましょう"}, + # Прилагательные + {"id": "adj_i", "name": "い-прилагательные", "name_ru": "い-прилагательные", "description": "おおきい、ちいさい、あたらしい"}, + {"id": "adj_na", "name": "な-прилагательные", "name_ru": "な-прилагательные", "description": "きれいな、しずかな、げんきな"}, + {"id": "adj_past", "name": "Прошедшее время прил.", "name_ru": "Прошедшее время прилагательных", "description": "おおきかった、しずかだった"}, + {"id": "adj_negative", "name": "Отрицание прилагательных", "name_ru": "Отрицание прилагательных", "description": "おおきくない、しずかじゃない"}, + # Базовые конструкции + {"id": "desu_da", "name": "です/だ (связка)", "name_ru": "です/だ (связка)", "description": "がくせいです"}, + {"id": "aru_iru", "name": "ある/いる (существование)", "name_ru": "ある/いる (существование)", "description": "ほんがあります、ねこがいます"}, + {"id": "kara_made", "name": "から/まで (от/до)", "name_ru": "から/まで (от/до)", "description": "9じから5じまで"}, + {"id": "kara_reason", "name": "から (причина)", "name_ru": "から (причина)", "description": "あついからまどをあけます"}, + {"id": "ga_hoshii", "name": "がほしい (хочу что-то)", "name_ru": "がほしい (хочу что-то)", "description": "みずがほしいです"}, + {"id": "counters_basic", "name": "Счётные слова (базовые)", "name_ru": "Счётные слова (базовые)", "description": "ひとつ、ふたり、さんぼん"}, + ], + "N4": [ + # Формы глаголов + {"id": "potential", "name": "Потенциальная форма", "name_ru": "Потенциальная форма (できる)", "description": "たべられる、のめる、できる"}, + {"id": "volitional", "name": "Волитивная форма (よう)", "name_ru": "Волитивная форма (よう)", "description": "たべよう、いこう"}, + {"id": "imperative", "name": "Повелительная форма", "name_ru": "Повелительная форма", "description": "たべろ、いけ、するな"}, + {"id": "passive", "name": "Страдательный залог", "name_ru": "Страдательный залог (られる)", "description": "たべられる、よまれる"}, + {"id": "causative", "name": "Каузатив (させる)", "name_ru": "Каузатив (させる)", "description": "たべさせる、いかせる"}, + # Условные формы + {"id": "cond_ba", "name": "Условие ~ば", "name_ru": "Условие ~ば", "description": "たべれば、いけば"}, + {"id": "cond_tara", "name": "Условие ~たら", "name_ru": "Условие ~たら", "description": "たべたら、いったら"}, + {"id": "cond_nara", "name": "Условие ~なら", "name_ru": "Условие ~なら", "description": "いくなら、たかいなら"}, + {"id": "cond_to", "name": "Условие ~と", "name_ru": "Условие ~と", "description": "ボタンをおすとドアがあく"}, + # Грамматические конструкции + {"id": "te_kara", "name": "てから (после того как)", "name_ru": "てから (после того как)", "description": "たべてからでかけます"}, + {"id": "te_shimau", "name": "てしまう (завершение/сожаление)", "name_ru": "てしまう (завершение/сожаление)", "description": "たべてしまった、わすれてしまった"}, + {"id": "te_oku", "name": "ておく (заранее)", "name_ru": "ておく (заранее)", "description": "よやくしておきます"}, + {"id": "te_aru", "name": "てある (результат)", "name_ru": "てある (результат)", "description": "まどがあけてある"}, + {"id": "te_miru", "name": "てみる (попробовать)", "name_ru": "てみる (попробовать)", "description": "たべてみる"}, + {"id": "te_iku_kuru", "name": "ていく/てくる", "name_ru": "ていく/てくる", "description": "もっていく、かってくる"}, + {"id": "te_ageru_morau_kureru", "name": "てあげる/もらう/くれる", "name_ru": "てあげる/もらう/くれる", "description": "おしえてあげる"}, + {"id": "you_ni_naru", "name": "ようになる (стать)", "name_ru": "ようになる (стать)", "description": "はなせるようになった"}, + {"id": "you_ni_suru", "name": "ようにする (стараться)", "name_ru": "ようにする (стараться)", "description": "はやくねるようにする"}, + {"id": "sou_appearance", "name": "そう (выглядит)", "name_ru": "そう (выглядит)", "description": "おいしそう、ふりそう"}, + {"id": "sou_hearsay", "name": "そうだ (говорят)", "name_ru": "そうだ (говорят)", "description": "あめがふるそうだ"}, + {"id": "rashii", "name": "らしい (похоже)", "name_ru": "らしい (похоже/типично)", "description": "かれはびょうきらしい"}, + {"id": "noni", "name": "のに (хотя)", "name_ru": "のに (хотя)", "description": "いったのにいなかった"}, + {"id": "tame_ni", "name": "ために (для/из-за)", "name_ru": "ために (для/из-за)", "description": "けんこうのためにうんどうする"}, + {"id": "node", "name": "ので (потому что)", "name_ru": "ので (потому что)", "description": "あめなのでいかない"}, + ], + "N3": [ + # Сложные формы глаголов + {"id": "causative_passive", "name": "Каузатив-пассив", "name_ru": "Каузатив-пассив (させられる)", "description": "たべさせられる"}, + {"id": "passive_adversative", "name": "Адверсатив (страдание)", "name_ru": "Адверсативный пассив", "description": "あめにふられた"}, + # Грамматические конструкции + {"id": "wake", "name": "わけ (смысл/причина)", "name_ru": "わけ (смысл/причина)", "description": "いくわけにはいかない"}, + {"id": "wake_da", "name": "わけだ (выходит что)", "name_ru": "わけだ (выходит что)", "description": "だからつかれたわけだ"}, + {"id": "hazu", "name": "はず (должно быть)", "name_ru": "はず (должно быть)", "description": "もうついたはずだ"}, + {"id": "beki", "name": "べき (следует)", "name_ru": "べき (следует)", "description": "いくべきだ"}, + {"id": "koto_ni_naru", "name": "ことになる (решено)", "name_ru": "ことになる (решено)", "description": "にほんにいくことになった"}, + {"id": "koto_ni_suru", "name": "ことにする (решить)", "name_ru": "ことにする (решить)", "description": "やめることにした"}, + {"id": "koto_ga_aru", "name": "ことがある (бывает/опыт)", "name_ru": "ことがある (бывает/опыт)", "description": "いったことがある"}, + {"id": "koto_ga_dekiru", "name": "ことができる (можно)", "name_ru": "ことができる (можно)", "description": "およぐことができる"}, + {"id": "mono", "name": "もの/もん (ведь)", "name_ru": "もの/もん (ведь)", "description": "だってすきなんだもん"}, + {"id": "bakari", "name": "ばかり (только/недавно)", "name_ru": "ばかり (только/недавно)", "description": "きたばかり、ゲームばかり"}, + {"id": "to_iu", "name": "という (называемый)", "name_ru": "という (называемый)", "description": "さくらというはな"}, + {"id": "you_da", "name": "ようだ (похоже)", "name_ru": "ようだ (похоже)", "description": "かれはつかれているようだ"}, + {"id": "mitai", "name": "みたい (как будто)", "name_ru": "みたい (как будто)", "description": "ゆめみたい"}, + {"id": "tsumori", "name": "つもり (намерение)", "name_ru": "つもり (намерение)", "description": "いくつもりだ"}, + {"id": "tokoro", "name": "ところ (момент)", "name_ru": "ところ (момент)", "description": "いまでかけるところ"}, + {"id": "aida", "name": "間/間に (пока)", "name_ru": "間/間に (пока)", "description": "ねているあいだに"}, + {"id": "nagara", "name": "ながら (одновременно)", "name_ru": "ながら (одновременно)", "description": "あるきながらはなす"}, + {"id": "toki", "name": "とき (когда)", "name_ru": "とき (когда)", "description": "いくとき、いったとき"}, + {"id": "ba_ii", "name": "ばいい (достаточно)", "name_ru": "ばいい (достаточно)", "description": "いけばいい"}, + {"id": "tara_ii", "name": "たらいい (лучше бы)", "name_ru": "たらいい (лучше бы)", "description": "いったらいいのに"}, + # Выражения + {"id": "te_hoshii", "name": "てほしい (хочу чтобы)", "name_ru": "てほしい (хочу чтобы)", "description": "きてほしい"}, + {"id": "te_naranai", "name": "てならない (очень)", "name_ru": "てならない (очень)", "description": "うれしくてならない"}, + {"id": "te_tamaranai", "name": "てたまらない (невыносимо)", "name_ru": "てたまらない (невыносимо)", "description": "あつくてたまらない"}, + ], + "N2": [ + # Продвинутая грамматика + {"id": "zaru_wo_enai", "name": "ざるを得ない (вынужден)", "name_ru": "ざるを得ない (вынужден)", "description": "いかざるをえない"}, + {"id": "nai_wake_ni_wa_ikanai", "name": "ないわけにはいかない", "name_ru": "ないわけにはいかない", "description": "いかないわけにはいかない"}, + {"id": "ppoi", "name": "っぽい (похожий на)", "name_ru": "っぽい (похожий на)", "description": "こどもっぽい、あきっぽい"}, + {"id": "gachi", "name": "がち (склонен)", "name_ru": "がち (склонен)", "description": "びょうきがち、わすれがち"}, + {"id": "gimi", "name": "ぎみ (слегка)", "name_ru": "ぎみ (слегка)", "description": "つかれぎみ、かぜぎみ"}, + {"id": "kke", "name": "っけ (кажется/вспомнил)", "name_ru": "っけ (кажется/вспомнил)", "description": "なんだっけ"}, + {"id": "dokoro_ka", "name": "どころか (не то что)", "name_ru": "どころか (не то что)", "description": "かんたんどころかむずかしい"}, + {"id": "koso", "name": "こそ (именно)", "name_ru": "こそ (именно)", "description": "こちらこそ、いまこそ"}, + {"id": "sae", "name": "さえ (даже)", "name_ru": "さえ (даже)", "description": "こどもでさえしっている"}, + {"id": "sae_ba", "name": "さえ~ば (если только)", "name_ru": "さえ~ば (если только)", "description": "おかねさえあればいい"}, + {"id": "shika_nai", "name": "しかない (только/приходится)", "name_ru": "しかない (только/приходится)", "description": "いくしかない"}, + {"id": "to_wa_kagiranai", "name": "とは限らない (не обязательно)", "name_ru": "とは限らない (не обязательно)", "description": "たかいとはかぎらない"}, + {"id": "mono_nara", "name": "ものなら (если бы)", "name_ru": "ものなら (если бы)", "description": "できるものならやってみろ"}, + {"id": "mono_da", "name": "ものだ (так уж устроено)", "name_ru": "ものだ (так уж устроено)", "description": "むかしはよくいったものだ"}, + {"id": "mono_no", "name": "ものの (хотя)", "name_ru": "ものの (хотя)", "description": "かったものの、つかわない"}, + # Формальный стиль + {"id": "ni_tsuite", "name": "について (о/насчёт)", "name_ru": "について (о/насчёт)", "description": "にほんについて"}, + {"id": "ni_totte", "name": "にとって (для кого)", "name_ru": "にとって (для кого)", "description": "わたしにとって"}, + {"id": "ni_yotte", "name": "によって (в зависимости)", "name_ru": "によって (в зависимости)", "description": "ひとによってちがう"}, + {"id": "ni_oite", "name": "において (в/на)", "name_ru": "において (в/на)", "description": "かいぎにおいて"}, + {"id": "ni_kanshite", "name": "に関して (касательно)", "name_ru": "に関して (касательно)", "description": "じけんにかんして"}, + {"id": "ni_okeru", "name": "における (в контексте)", "name_ru": "における (в контексте)", "description": "にほんにおけるもんだい"}, + {"id": "to_shite", "name": "として (в качестве)", "name_ru": "として (в качестве)", "description": "がくせいとして"}, + {"id": "keigo_sonkei", "name": "Уважительное кейго", "name_ru": "Уважительное кейго (尊敬語)", "description": "いらっしゃる、おっしゃる"}, + {"id": "keigo_kenjou", "name": "Скромное кейго", "name_ru": "Скромное кейго (謙譲語)", "description": "いたす、もうす、まいる"}, + ], + "N1": [ + # Литературные и архаичные формы + {"id": "de_aru", "name": "である (письм. связка)", "name_ru": "である (письменная связка)", "description": "これは本である"}, + {"id": "taru", "name": "たる (книжный)", "name_ru": "たる (книжный)", "description": "いだいたる、どうどうたる"}, + {"id": "beshi", "name": "べし (книжный долг)", "name_ru": "べし (книжный)", "description": "しるべし、ゆくべし"}, + {"id": "nari", "name": "なり (книжный)", "name_ru": "なり (книжный)", "description": "なんなりと、きくなり"}, + {"id": "gotoku", "name": "ごとく/ごとし (подобно)", "name_ru": "ごとく/ごとし (подобно)", "description": "みずのごとく"}, + {"id": "zukunme", "name": "ずくめ (сплошь)", "name_ru": "ずくめ (сплошь)", "description": "くろずくめ、いいことずくめ"}, + # Сложные конструкции + {"id": "made_mo_nai", "name": "までもない (не нужно)", "name_ru": "までもない (не нужно)", "description": "いうまでもない"}, + {"id": "ni_taeru", "name": "に耐える (выдерживать)", "name_ru": "に耐える (выдерживать)", "description": "ひひょうにたえる"}, + {"id": "ni_taenai", "name": "に堪えない (не выдержать)", "name_ru": "に堪えない (не выдержать)", "description": "みるにたえない"}, + {"id": "wo_motte", "name": "をもって (посредством)", "name_ru": "をもって (посредством)", "description": "せいいをもって"}, + {"id": "wo_kagiri_ni", "name": "を限りに (начиная с)", "name_ru": "を限りに (начиная с)", "description": "きょうをかぎりに"}, + {"id": "wo_yogi_naku_sareru", "name": "を余儀なくされる", "name_ru": "を余儀なくされる", "description": "へんこうをよぎなくされる"}, + {"id": "wo_mono_tomo_shinai", "name": "をものともしない", "name_ru": "をものともしない", "description": "きけんをものともしない"}, + {"id": "ikan", "name": "いかん (формальный)", "name_ru": "いかん (формальный)", "description": "けっかいかんでは"}, + {"id": "ikan_ni_yotte", "name": "いかんによって", "name_ru": "いかんによって", "description": "どりょくいかんによって"}, + {"id": "ni_sokushite", "name": "に即して (в соответствии)", "name_ru": "に即して (в соответствии)", "description": "じじつにそくして"}, + {"id": "ni_hoka_naranai", "name": "にほかならない (не что иное)", "name_ru": "にほかならない (не что иное)", "description": "これはあいにほかならない"}, + {"id": "tomo_arou", "name": "ともあろう (несмотря на статус)", "name_ru": "ともあろう (несмотря на статус)", "description": "せんせいともあろうひとが"}, + {"id": "tomo_naku", "name": "ともなく (без особого)", "name_ru": "ともなく (без особого)", "description": "みるともなくみる"}, + {"id": "to_iedo_mo", "name": "といえども (даже если)", "name_ru": "といえども (даже если)", "description": "せんもんかといえども"}, + {"id": "nagaramo", "name": "ながらも (хотя и)", "name_ru": "ながらも (хотя и)", "description": "ざんねんながらも"}, + {"id": "tomo_sureba", "name": "ともすれば (порой)", "name_ru": "ともすれば (порой)", "description": "ともすればわすれがち"}, + ], +} + + +def get_grammar_topics_for_level(learning_lang: str, level: str) -> list: + """ + Получить список грамматических тем для уровня. + + Args: + learning_lang: Язык изучения ('en' или 'ja') + level: Уровень пользователя (A1-C2 для английского, N5-N1 для японского) + + Returns: + Список словарей с грамматическими темами + """ + if learning_lang == 'en': + rules = ENGLISH_GRAMMAR_RULES + # Для английского включаем текущий уровень и все ниже + level_order = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] + elif learning_lang == 'ja': + rules = JAPANESE_GRAMMAR_RULES + # Для японского N5 - самый низкий, N1 - самый высокий + level_order = ['N5', 'N4', 'N3', 'N2', 'N1'] + else: + return [] + + # Нормализуем уровень + level_upper = level.upper() + if level_upper not in level_order: + # Если уровень не распознан, возвращаем базовый + level_upper = level_order[0] + + # Собираем темы для текущего и всех предыдущих уровней + result = [] + for lvl in level_order: + if lvl in rules: + for topic in rules[lvl]: + topic_copy = topic.copy() + topic_copy['level'] = lvl + result.append(topic_copy) + if lvl == level_upper: + break + + return result + + +def get_topics_for_current_level_only(learning_lang: str, level: str) -> list: + """ + Получить список грамматических тем ТОЛЬКО для текущего уровня. + + Args: + learning_lang: Язык изучения ('en' или 'ja') + level: Уровень пользователя + + Returns: + Список словарей с грамматическими темами только для текущего уровня + """ + if learning_lang == 'en': + rules = ENGLISH_GRAMMAR_RULES + elif learning_lang == 'ja': + rules = JAPANESE_GRAMMAR_RULES + else: + return [] + + level_upper = level.upper() + if level_upper not in rules: + return [] + + result = [] + for topic in rules[level_upper]: + topic_copy = topic.copy() + topic_copy['level'] = level_upper + result.append(topic_copy) + + return result diff --git a/database/models.py b/database/models.py index 6623882..1d890df 100644 --- a/database/models.py +++ b/database/models.py @@ -139,3 +139,52 @@ class AIModel(Base): display_name: Mapped[str] = mapped_column(String(100), nullable=False) # Название для отображения is_active: Mapped[bool] = mapped_column(Boolean, default=False) # Только одна модель активна created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class StoryGenre(str, enum.Enum): + """Жанры мини-историй""" + dialogue = "dialogue" # 🗣 Диалоги + news = "news" # 📰 Новости + story = "story" # 🎭 Истории + letter = "letter" # 📧 Письма + recipe = "recipe" # 🍳 Рецепты + + +class MiniStory(Base): + """Модель мини-истории для чтения""" + __tablename__ = "mini_stories" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + content: Mapped[str] = mapped_column(String(5000), nullable=False) # Текст истории + translation: Mapped[Optional[str]] = mapped_column(String(5000), nullable=True) # Перевод истории + genre: Mapped[StoryGenre] = mapped_column(SQLEnum(StoryGenre), nullable=False) + learning_lang: Mapped[str] = mapped_column(String(5), nullable=False) # en/ja + level: Mapped[str] = mapped_column(String(5), nullable=False) # A1-C2 или N5-N1 + word_count: Mapped[int] = mapped_column(Integer, default=0) # Количество слов + vocabulary: Mapped[Optional[dict]] = mapped_column(JSON) # [{word, translation, transcription}] + questions: Mapped[Optional[dict]] = mapped_column(JSON) # [{question, options[], correct}] + is_completed: Mapped[bool] = mapped_column(Boolean, default=False) + correct_answers: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class WordOfDay(Base): + """Модель слова дня (глобальная для всех пользователей по уровню)""" + __tablename__ = "word_of_day" + __table_args__ = ( + UniqueConstraint("date", "learning_lang", "level", name="uq_wod_date_lang_level"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + word: Mapped[str] = mapped_column(String(255), nullable=False) + transcription: Mapped[Optional[str]] = mapped_column(String(255)) + translation: Mapped[str] = mapped_column(String(500), nullable=False) + examples: Mapped[Optional[dict]] = mapped_column(JSON) # [{sentence, translation}] + synonyms: Mapped[Optional[str]] = mapped_column(String(500)) # Синонимы через запятую + etymology: Mapped[Optional[str]] = mapped_column(String(500)) # Этимология/интересный факт + learning_lang: Mapped[str] = mapped_column(String(5), nullable=False, index=True) # en/ja + level: Mapped[str] = mapped_column(String(5), nullable=False, index=True) # A1-C2 или N5-N1 + date: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True) # Дата слова (только дата, без времени) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/locales/en.json b/locales/en.json index 1954976..debffc3 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,15 +1,34 @@ { "menu": { + "wordofday": "🌅 Word of Day", "add": "➕ Add word", "vocab": "📚 Vocabulary", "task": "🧠 Task", "practice": "💬 Practice", + "exercises": "📖 Exercises", "words": "🎯 Thematic words", "import": "📖 Import", "stats": "📊 Stats", "settings": "⚙️ Settings", "below": "Main menu below ⤵️" }, + "practice_menu": { + "title": "Practice", + "choose": "Choose practice mode:", + "stories": "Mini Stories", + "ai_chat": "AI Chat" + }, + "wod": { + "title": "Word of the Day", + "generating": "🔄 Generating word of the day...", + "failed": "❌ Failed to generate word of the day. Try again later.", + "not_available": "🕐 Word of the day is not ready yet.\n\nWords are generated daily at 00:00 UTC.\nTry again later!", + "examples": "Examples", + "synonyms": "Synonyms", + "add_btn": "➕ Add to vocabulary", + "added": "Added to vocabulary!", + "not_found": "Word not found" + }, "add_menu": { "title": "➕ Add words\n\nChoose method:", "manual": "📝 Manual", @@ -55,7 +74,7 @@ "skip_msg": "✅ Okay!\n\nYou can take the test later with /level_test\nor set level manually in /settings\n\nLet's start! Try:\n• /words travel - thematic words\n• /practice - AI dialogue\n• /add hello - add a word" }, "add": { - "prompt": "Send the word you want to add:\nFor example: /add elephant\n\nOr just send the word without a command!", + "prompt": "Send the word you want to add:\n• Single word: /add elephant\n• Multiple: /add apple, banana, orange\n\nOr just send the word without a command!", "searching": "⏳ Looking up translation and examples...", "examples_header": "Examples:", "translations_header": "Translations:", @@ -69,6 +88,13 @@ "added_success": "✅ Word '{word}' added!\n\nTotal words in vocabulary: {count}\n\nKeep adding new words or use /task to practice!", "cancelled": "Cancelled. You can add another word with /add" }, + "add_batch": { + "header": "📝 Words to add ({n}):", + "translating": "⏳ Translating {n} words...", + "choose": "Select words to add or add all at once:", + "truncated": "⚠️ Too many words. Showing first {n}.", + "failed": "❌ Failed to get translations. Try again later." + }, "vocab": { "empty": "📚 Your vocabulary is empty!\n\nAdd your first word with /add or just send me a word.", "header": "📚 Your vocabulary:", @@ -191,7 +217,54 @@ "invalid_format": "❌ Invalid time format!\n\nUse HH:MM (e.g., 09:00 or 18:30)\nOr send /cancel to abort", "time_set_title": "✅ Time set!", "status_on_line": "Status: Enabled", - "use_settings": "Use /reminder to change settings." + "use_settings": "Use /reminder to change settings.", + "daily_title": "⏰ Time to practice!", + "daily_wod": "🌅 Word of the Day:", + "daily_tips": "Don't forget to practice today:\n• /task - complete tasks\n• /practice - practice dialogue\n• /words - add new words", + "daily_motivation": "💪 Regular practice is the key to success!" + }, + "story": { + "title": "Mini Stories", + "choose_genre": "Choose a story genre:", + "genre": { + "dialogue": "Dialogues", + "news": "News", + "story": "Stories", + "letter": "Letters", + "recipe": "Recipes" + }, + "generating": "🔄 Generating story...", + "failed": "❌ Failed to generate story. Try again.", + "try_again": "Try again", + "level": "Level", + "words": "words", + "questions_btn": "Questions", + "vocab_btn": "Vocabulary", + "new_btn": "New story", + "back": "Back", + "not_found": "Story not found", + "no_vocab": "No vocabulary words", + "no_questions": "No questions", + "vocabulary": "Story Vocabulary", + "add_all": "Add all", + "word_added": "✅ Word '{word}' added!", + "words_added": "✅ Added words: {n}", + "word_not_found": "Word not found", + "question": "Question", + "question_not_found": "Question not found", + "correct": "✅ Correct!", + "incorrect": "❌ Incorrect", + "next_question": "Next question", + "show_results": "Results", + "results_title": "Results", + "correct_answers": "Correct answers", + "accuracy": "Accuracy", + "result_excellent": "Excellent! You understood the text well.", + "result_good": "Good job! You understood most of the text.", + "result_practice": "Try reading the story more carefully.", + "translation": "Translation", + "show_translation": "Show translation", + "hide_translation": "Hide translation" }, "level_test": { "show_translation_btn": "👁️ Show question translation", @@ -330,5 +403,29 @@ "err_not_found": "❌ Error: word not found", "already_exists": "The word '{word}' is already in your vocabulary", "added_single": "✅ Word '{word}' added to vocabulary" + }, + "exercises": { + "title": "📖 Grammar Exercises", + "choose_topic": "Choose a topic for exercises:", + "your_level": "Your level: {level}", + "generating_rule": "🔄 Generating grammar explanation...", + "generating": "🔄 Generating exercises...", + "generate_failed": "❌ Failed to generate exercise. Please try again later.", + "start_btn": "▶️ Start exercises", + "task_header": "📝 Exercise: {topic}", + "instruction": "Fill in the blanks with the correct form:", + "check_btn": "✅ Check", + "next_btn": "➡️ Next", + "results_btn": "📊 Results", + "back_btn": "⬅️ Back to topics", + "close_btn": "❌ Close", + "correct": "✅ Correct!", + "incorrect": "❌ Incorrect", + "your_answer": "Your answer: {answer}", + "right_answer": "Correct answer: {answer}", + "explanation": "💡 {text}", + "score": "Score: {correct} of {total}", + "no_topics": "No topics available for your level yet.", + "write_answer": "Write your answer:" } } diff --git a/locales/ja.json b/locales/ja.json index 95e773d..20412f9 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1,15 +1,34 @@ { "menu": { + "wordofday": "🌅 今日の単語", "add": "➕ 単語を追加", "vocab": "📚 単語帳", "task": "🧠 課題", "practice": "💬 練習", + "exercises": "📖 文法練習", "words": "🎯 テーマ別単語", "import": "📖 インポート", "stats": "📊 統計", "settings": "⚙️ 設定", "below": "メインメニューは下にあります ⤵️" }, + "practice_menu": { + "title": "練習", + "choose": "練習モードを選択:", + "stories": "ミニストーリー", + "ai_chat": "AIとの会話" + }, + "wod": { + "title": "今日の単語", + "generating": "🔄 今日の単語を生成中...", + "failed": "❌ 今日の単語の生成に失敗しました。後でもう一度お試しください。", + "not_available": "🕐 今日の単語はまだ準備中です。\n\n単語は毎日UTC 00:00に生成されます。\n後でもう一度お試しください!", + "examples": "例文", + "synonyms": "類義語", + "add_btn": "➕ 単語帳に追加", + "added": "単語帳に追加しました!", + "not_found": "単語が見つかりません" + }, "add_menu": { "title": "➕ 単語を追加\n\n方法を選択:", "manual": "📝 手動", @@ -55,7 +74,7 @@ "skip_msg": "✅ わかりました!\n\n/level_test で後からテストを受けるか、/settings でレベルを設定できます。\n\nはじめましょう!おすすめ:\n• /words travel - テーマ別単語\n• /practice - AIとの会話\n• /add hello - 単語を追加" }, "add": { - "prompt": "追加したい単語を送ってください:\n例: /add elephant\n\nコマンドなしで単語だけ送ってもOKです!", + "prompt": "追加したい単語を送ってください:\n• 1語: /add elephant\n• 複数: /add apple, banana, orange\n\nコマンドなしで単語だけ送ってもOKです!", "searching": "⏳ 翻訳と例を検索中...", "examples_header": "例文:", "translations_header": "翻訳:", @@ -69,6 +88,13 @@ "added_success": "✅ 単語 '{word}' を追加しました!\n\n単語帳の総数: {count}\n\nさらに追加するか、/task で練習しましょう!", "cancelled": "キャンセルしました。/add で別の単語を追加できます" }, + "add_batch": { + "header": "📝 追加する単語 ({n}):", + "translating": "⏳ {n} 語を翻訳中...", + "choose": "追加する単語を選ぶか、一括で追加してください:", + "truncated": "⚠️ 単語が多すぎます。最初の {n} 語を表示。", + "failed": "❌ 翻訳の取得に失敗しました。後でもう一度お試しください。" + }, "vocab": { "empty": "📚 単語帳はまだ空です!\n\n/add で最初の単語を追加するか、単語を直接送ってください。", "header": "📚 あなたの単語帳:", @@ -183,7 +209,54 @@ "invalid_format": "❌ 時間の形式が正しくありません!\n\nHH:MM(例: 09:00 / 18:30)形式を使用してください\nまたは /cancel で中止", "time_set_title": "✅ 時間を設定しました!", "status_on_line": "ステータス: 有効", - "use_settings": "/reminder で設定を変更できます。" + "use_settings": "/reminder で設定を変更できます。", + "daily_title": "⏰ 練習の時間です!", + "daily_wod": "🌅 今日の単語:", + "daily_tips": "今日も練習を忘れずに:\n• /task - 課題を解く\n• /practice - 会話練習\n• /words - 新しい単語を追加", + "daily_motivation": "💪 継続は力なり!" + }, + "story": { + "title": "ミニストーリー", + "choose_genre": "ストーリーのジャンルを選択:", + "genre": { + "dialogue": "会話", + "news": "ニュース", + "story": "物語", + "letter": "手紙", + "recipe": "レシピ" + }, + "generating": "🔄 ストーリーを生成中...", + "failed": "❌ ストーリーの生成に失敗しました。もう一度お試しください。", + "try_again": "もう一度試す", + "level": "レベル", + "words": "単語", + "questions_btn": "質問", + "vocab_btn": "単語帳", + "new_btn": "新しいストーリー", + "back": "戻る", + "not_found": "ストーリーが見つかりません", + "no_vocab": "単語がありません", + "no_questions": "質問がありません", + "vocabulary": "ストーリーの単語", + "add_all": "すべて追加", + "word_added": "✅ 「{word}」を追加しました!", + "words_added": "✅ {n}単語を追加しました", + "word_not_found": "単語が見つかりません", + "question": "質問", + "question_not_found": "質問が見つかりません", + "correct": "✅ 正解!", + "incorrect": "❌ 不正解", + "next_question": "次の質問", + "show_results": "結果", + "results_title": "結果", + "correct_answers": "正解数", + "accuracy": "正解率", + "result_excellent": "素晴らしい!テキストをよく理解できました。", + "result_good": "よくできました!大部分を理解できました。", + "result_practice": "もう一度注意深く読んでみてください。", + "translation": "翻訳", + "show_translation": "翻訳を表示", + "hide_translation": "翻訳を隠す" }, "level_test": { "show_translation_btn": "👁️ 質問の翻訳を表示", @@ -322,5 +395,29 @@ "err_not_found": "❌ エラー: 単語が見つかりません", "already_exists": "単語 '{word}' はすでに単語帳にあります", "added_single": "✅ 単語 '{word}' を単語帳に追加しました" + }, + "exercises": { + "title": "📖 文法練習", + "choose_topic": "練習するトピックを選択してください:", + "your_level": "あなたのレベル: {level}", + "generating_rule": "🔄 文法説明を生成中...", + "generating": "🔄 練習問題を生成中...", + "generate_failed": "❌ 練習問題の生成に失敗しました。後でもう一度お試しください。", + "start_btn": "▶️ 練習を開始", + "task_header": "📝 練習: {topic}", + "instruction": "正しい形式で空欄を埋めてください:", + "check_btn": "✅ 確認", + "next_btn": "➡️ 次へ", + "results_btn": "📊 結果", + "back_btn": "⬅️ トピックに戻る", + "close_btn": "❌ 閉じる", + "correct": "✅ 正解!", + "incorrect": "❌ 不正解", + "your_answer": "あなたの回答: {answer}", + "right_answer": "正解: {answer}", + "explanation": "💡 {text}", + "score": "スコア: {total}問中{correct}問正解", + "no_topics": "あなたのレベルで利用可能なトピックはまだありません。", + "write_answer": "回答を入力してください:" } } diff --git a/locales/ru.json b/locales/ru.json index c75fe6c..3da6b08 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1,15 +1,34 @@ { "menu": { + "wordofday": "🌅 Слово дня", "add": "➕ Добавить слово", "vocab": "📚 Словарь", "task": "🧠 Задание", "practice": "💬 Практика", + "exercises": "📖 Упражнения", "words": "🎯 Тематические слова", "import": "📖 Импорт", "stats": "📊 Статистика", "settings": "⚙️ Настройки", "below": "Главное меню доступно ниже ⤵️" }, + "practice_menu": { + "title": "Практика", + "choose": "Выбери режим практики:", + "stories": "Мини-истории", + "ai_chat": "Диалог с AI" + }, + "wod": { + "title": "Слово дня", + "generating": "🔄 Генерирую слово дня...", + "failed": "❌ Не удалось сгенерировать слово дня. Попробуй позже.", + "not_available": "🕐 Слово дня ещё не готово.\n\nСлова генерируются ежедневно в 00:00 UTC.\nПопробуй позже!", + "examples": "Примеры", + "synonyms": "Синонимы", + "add_btn": "➕ Добавить в словарь", + "added": "Добавлено в словарь!", + "not_found": "Слово не найдено" + }, "add_menu": { "title": "➕ Добавление слов\n\nВыберите способ:", "manual": "📝 Вручную", @@ -55,7 +74,7 @@ "skip_msg": "✅ Хорошо!\n\nТы можешь пройти тест позже командой /level_test\nили установить уровень вручную в /settings\n\nДавай начнём! Попробуй:\n• /words travel - тематическая подборка\n• /practice - диалог с AI\n• /add hello - добавить слово" }, "add": { - "prompt": "Отправь слово, которое хочешь добавить:\nНапример: /add elephant\n\nИли просто отправь слово без команды!", + "prompt": "Отправь слово, которое хочешь добавить:\n• Одно слово: /add elephant\n• Несколько: /add apple, banana, orange\n\nИли просто отправь слово без команды!", "searching": "⏳ Ищу перевод и примеры...", "examples_header": "Примеры:", "translations_header": "Переводы:", @@ -69,6 +88,13 @@ "added_success": "✅ Слово '{word}' добавлено!\n\nВсего слов в словаре: {count}\n\nПродолжай добавлять новые слова или используй /task для практики!", "cancelled": "Отменено. Можешь добавить другое слово командой /add" }, + "add_batch": { + "header": "📝 Слова для добавления ({n}):", + "translating": "⏳ Перевожу {n} слов...", + "choose": "Выбери слова для добавления или добавь все сразу:", + "truncated": "⚠️ Слишком много слов. Показаны первые {n}.", + "failed": "❌ Не удалось получить переводы. Попробуй позже." + }, "vocab": { "empty": "📚 Твой словарь пока пуст!\n\nДобавь первое слово командой /add или просто отправь мне слово.", "header": "📚 Твой словарь:", @@ -180,7 +206,54 @@ "invalid_format": "❌ Неверный формат времени!\n\nИспользуй формат HH:MM (например, 09:00 или 18:30)\nИли отправь /cancel для отмены", "time_set_title": "✅ Время установлено!", "status_on_line": "Статус: Включены", - "use_settings": "Используй /reminder для изменения настроек." + "use_settings": "Используй /reminder для изменения настроек.", + "daily_title": "⏰ Время для практики!", + "daily_wod": "🌅 Слово дня:", + "daily_tips": "Не забудь потренироваться сегодня:\n• /task - выполни задания\n• /practice - попрактикуй диалог\n• /words - добавь новые слова", + "daily_motivation": "💪 Регулярная практика - ключ к успеху!" + }, + "story": { + "title": "Мини-истории", + "choose_genre": "Выбери жанр истории:", + "genre": { + "dialogue": "Диалоги", + "news": "Новости", + "story": "Рассказы", + "letter": "Письма", + "recipe": "Рецепты" + }, + "generating": "🔄 Генерирую историю...", + "failed": "❌ Не удалось сгенерировать историю. Попробуй ещё раз.", + "try_again": "Попробовать снова", + "level": "Уровень", + "words": "слов", + "questions_btn": "Вопросы", + "vocab_btn": "Словарь", + "new_btn": "Новая история", + "back": "Назад", + "not_found": "История не найдена", + "no_vocab": "Нет слов для изучения", + "no_questions": "Нет вопросов", + "vocabulary": "Словарь истории", + "add_all": "Добавить все", + "word_added": "✅ Слово '{word}' добавлено!", + "words_added": "✅ Добавлено слов: {n}", + "word_not_found": "Слово не найдено", + "question": "Вопрос", + "question_not_found": "Вопрос не найден", + "correct": "✅ Правильно!", + "incorrect": "❌ Неправильно", + "next_question": "Следующий вопрос", + "show_results": "Результаты", + "results_title": "Результаты", + "correct_answers": "Правильных ответов", + "accuracy": "Точность", + "result_excellent": "Отличный результат! Ты хорошо понял текст.", + "result_good": "Хорошо! Большую часть текста ты понял.", + "result_practice": "Попробуй перечитать историю внимательнее.", + "translation": "Перевод", + "show_translation": "Показать перевод", + "hide_translation": "Скрыть перевод" }, "stats": { "header": "📊 Твоя статистика", @@ -330,5 +403,29 @@ "err_not_found": "❌ Ошибка: слово не найдено", "already_exists": "Слово '{word}' уже в словаре", "added_single": "✅ Слово '{word}' добавлено в словарь" + }, + "exercises": { + "title": "📖 Грамматические упражнения", + "choose_topic": "Выбери тему для упражнения:", + "your_level": "Твой уровень: {level}", + "generating_rule": "🔄 Генерирую объяснение правила...", + "generating": "🔄 Генерирую упражнения...", + "generate_failed": "❌ Не удалось сгенерировать упражнение. Попробуй позже.", + "start_btn": "▶️ Начать упражнения", + "task_header": "📝 Упражнение: {topic}", + "instruction": "Заполни пропуски правильной формой:", + "check_btn": "✅ Проверить", + "next_btn": "➡️ Следующее", + "results_btn": "📊 Результаты", + "back_btn": "⬅️ К темам", + "close_btn": "❌ Закрыть", + "correct": "✅ Правильно!", + "incorrect": "❌ Неправильно", + "your_answer": "Твой ответ: {answer}", + "right_answer": "Правильный ответ: {answer}", + "explanation": "💡 {text}", + "score": "Результат: {correct} из {total}", + "no_topics": "Для твоего уровня пока нет доступных тем.", + "write_answer": "Напиши свой ответ:" } } diff --git a/main.py b/main.py index d049ec2..f37642a 100644 --- a/main.py +++ b/main.py @@ -7,8 +7,7 @@ from aiogram.enums import ParseMode from aiogram.types import BotCommand from config.settings import settings -from bot.handlers import start, vocabulary, tasks, settings as settings_handler, words, import_text, practice, reminder, level_test, admin -from database.db import init_db +from bot.handlers import start, vocabulary, tasks, settings as settings_handler, words, import_text, practice, reminder, level_test, admin, exercises, wordofday, stories from services.reminder_service import init_reminder_service @@ -30,15 +29,14 @@ async def main(): # Команды бота для меню Telegram await bot.set_my_commands([ BotCommand(command="start", description="Запустить бота"), - BotCommand(command="add", description="Добавить слово"), - BotCommand(command="words", description="Тематическая подборка слов"), - BotCommand(command="import", description="Импорт слов из текста"), - BotCommand(command="vocabulary", description="Мой словарь"), BotCommand(command="task", description="Задания"), BotCommand(command="practice", description="Диалог с AI"), + BotCommand(command="story", description="Мини-истории"), + BotCommand(command="add", description="Добавить слово"), + BotCommand(command="words", description="Тематическая подборка слов"), + BotCommand(command="vocabulary", description="Мой словарь"), BotCommand(command="stats", description="Статистика"), BotCommand(command="settings", description="Настройки"), - BotCommand(command="reminder", description="Напоминания"), BotCommand(command="help", description="Справка"), ]) @@ -51,11 +49,13 @@ async def main(): dp.include_router(words.router) dp.include_router(import_text.router) dp.include_router(practice.router) + dp.include_router(exercises.router) + dp.include_router(wordofday.router) + dp.include_router(stories.router) dp.include_router(reminder.router) dp.include_router(admin.router) - # Инициализация базы данных - await init_db() + # База данных инициализируется через Alembic миграции (make local-migrate) # Инициализация и запуск сервиса напоминаний reminder_service = init_reminder_service(bot) diff --git a/migrations/versions/20251209_add_mini_stories.py b/migrations/versions/20251209_add_mini_stories.py new file mode 100644 index 0000000..b4e192a --- /dev/null +++ b/migrations/versions/20251209_add_mini_stories.py @@ -0,0 +1,55 @@ +"""Add mini_stories table + +Revision ID: 20251209_mini_stories +Revises: 20251209_word_of_day +Create Date: 2024-12-09 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '20251209_mini_stories' +down_revision: Union[str, None] = '20251209_word_of_day' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Удаляем старую таблицу и enum если существуют + op.execute("DROP TABLE IF EXISTS mini_stories CASCADE") + op.execute("DROP TYPE IF EXISTS storygenre CASCADE") + + # Создаём enum через raw SQL + op.execute("CREATE TYPE storygenre AS ENUM ('dialogue', 'news', 'story', 'letter', 'recipe')") + + # Создаём таблицу используя postgresql.ENUM с create_type=False + story_genre = postgresql.ENUM('dialogue', 'news', 'story', 'letter', 'recipe', name='storygenre', create_type=False) + + op.create_table( + 'mini_stories', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('content', sa.String(length=5000), nullable=False), + sa.Column('genre', story_genre, nullable=False), + sa.Column('learning_lang', sa.String(length=5), nullable=False), + sa.Column('level', sa.String(length=5), nullable=False), + sa.Column('word_count', sa.Integer(), nullable=True, server_default='0'), + sa.Column('vocabulary', sa.JSON(), nullable=True), + sa.Column('questions', sa.JSON(), nullable=True), + sa.Column('is_completed', sa.Boolean(), nullable=True, server_default='false'), + sa.Column('correct_answers', sa.Integer(), nullable=True, server_default='0'), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_mini_stories_user_id', 'mini_stories', ['user_id'], unique=False) + + +def downgrade() -> None: + op.drop_index('ix_mini_stories_user_id', table_name='mini_stories') + op.drop_table('mini_stories') + op.execute("DROP TYPE IF EXISTS storygenre CASCADE") diff --git a/migrations/versions/20251209_add_story_translation.py b/migrations/versions/20251209_add_story_translation.py new file mode 100644 index 0000000..4baf83c --- /dev/null +++ b/migrations/versions/20251209_add_story_translation.py @@ -0,0 +1,26 @@ +"""Add translation field to mini_stories + +Revision ID: 20251209_story_translation +Revises: 20251209_mini_stories +Create Date: 2025-12-09 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '20251209_story_translation' +down_revision: Union[str, None] = '20251209_mini_stories' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('mini_stories', sa.Column('translation', sa.String(5000), nullable=True)) + + +def downgrade() -> None: + op.drop_column('mini_stories', 'translation') diff --git a/migrations/versions/20251209_add_word_of_day.py b/migrations/versions/20251209_add_word_of_day.py new file mode 100644 index 0000000..6ee4f47 --- /dev/null +++ b/migrations/versions/20251209_add_word_of_day.py @@ -0,0 +1,50 @@ +"""Add word_of_day table (global by level) + +Revision ID: 20251209_word_of_day +Revises: 20251208_user_ai_model +Create Date: 2024-12-09 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '20251209_word_of_day' +down_revision: Union[str, None] = '20251208_user_ai_model' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Удаляем старую таблицу если существует (была с user_id) + op.execute("DROP TABLE IF EXISTS word_of_day CASCADE") + + op.create_table( + 'word_of_day', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('word', sa.String(length=255), nullable=False), + sa.Column('transcription', sa.String(length=255), nullable=True), + sa.Column('translation', sa.String(length=500), nullable=False), + sa.Column('examples', sa.JSON(), nullable=True), + sa.Column('synonyms', sa.String(length=500), nullable=True), + sa.Column('etymology', sa.String(length=500), nullable=True), + sa.Column('learning_lang', sa.String(length=5), nullable=False), + sa.Column('level', sa.String(length=5), nullable=False), + sa.Column('date', sa.DateTime(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('date', 'learning_lang', 'level', name='uq_wod_date_lang_level') + ) + op.create_index('ix_word_of_day_learning_lang', 'word_of_day', ['learning_lang'], unique=False) + op.create_index('ix_word_of_day_level', 'word_of_day', ['level'], unique=False) + op.create_index('ix_word_of_day_date', 'word_of_day', ['date'], unique=False) + + +def downgrade() -> None: + op.drop_index('ix_word_of_day_date', table_name='word_of_day') + op.drop_index('ix_word_of_day_level', table_name='word_of_day') + op.drop_index('ix_word_of_day_learning_lang', table_name='word_of_day') + op.drop_table('word_of_day') diff --git a/services/ai_service.py b/services/ai_service.py index c3ad4bd..434c66e 100644 --- a/services/ai_service.py +++ b/services/ai_service.py @@ -54,6 +54,40 @@ class AIService: self._cached_model: Optional[str] = None self._cached_provider: Optional[AIProvider] = None + def _markdown_to_html(self, text: str) -> str: + """Конвертировать markdown форматирование в HTML для Telegram.""" + import re + # **bold** -> bold + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + # *italic* -> italic (но не внутри уже конвертированных тегов) + text = re.sub(r'(?)\*([^*]+?)\*(?![^<]*\1', text) + # Убираем оставшиеся одиночные * в начале строк (списки) + text = re.sub(r'^\*\s+', '• ', text, flags=re.MULTILINE) + return text + + def _strip_markdown_code_block(self, text: str) -> str: + """Удалить markdown обёртку ```json ... ``` из текста.""" + import re + text = text.strip() + + # Паттерн для ```json ... ``` или просто ``` ... ``` + pattern = r'^```(?:json)?\s*\n?(.*?)\n?```$' + match = re.match(pattern, text, re.DOTALL) + if match: + return match.group(1).strip() + + # Альтернативный способ - если начинается с ``` но паттерн не сработал + if text.startswith('```'): + lines = text.split('\n') + # Убираем первую строку (```json или ```) + lines = lines[1:] + # Убираем последнюю строку если это ``` + if lines and lines[-1].strip() == '```': + lines = lines[:-1] + return '\n'.join(lines).strip() + + return text + async def _get_active_model(self, user_id: Optional[int] = None) -> tuple[str, AIProvider]: """ Получить активную модель и провайдера из БД. @@ -136,15 +170,8 @@ class AIService: # Конвертируем ответ Google в формат OpenAI для совместимости text = data["candidates"][0]["content"]["parts"][0]["text"] - # Убираем markdown обёртку если есть (```json ... ```) - if text.startswith('```'): - lines = text.split('\n') - # Убираем первую строку (```json) и последнюю (```) - if lines[-1].strip() == '```': - lines = lines[1:-1] - else: - lines = lines[1:] - text = '\n'.join(lines) + # Убираем markdown обёртку если есть (```json ... ``` или ```...```) + text = self._strip_markdown_code_block(text) return { "choices": [{ @@ -1080,6 +1107,215 @@ User: {user_message} return self._get_jlpt_fallback_questions() return self._get_cefr_fallback_questions() + async def generate_grammar_rule( + self, + topic_name: str, + topic_description: str, + level: str, + learning_lang: str = "en", + ui_lang: str = "ru", + user_id: Optional[int] = None + ) -> str: + """ + Генерация объяснения грамматического правила. + + Args: + topic_name: Название темы (например, "Present Simple") + topic_description: Описание темы (например, "I work, he works") + level: Уровень пользователя (A1-C2 или N5-N1) + learning_lang: Язык изучения + ui_lang: Язык интерфейса для объяснения + user_id: ID пользователя в БД + + Returns: + Текст с объяснением правила + """ + if learning_lang == "ja": + language_name = "японского" + else: + language_name = "английского" + + prompt = f"""Объясни грамматическое правило "{topic_name}" ({topic_description}) для изучающих {language_name} язык. + +Уровень ученика: {level} +Язык объяснения: {ui_lang} + +Требования: +- Объяснение должно быть кратким и понятным (3-5 предложений) +- Приведи формулу/структуру правила +- Дай 2-3 примера с переводом +- Упомяни типичные ошибки (если есть) +- Адаптируй сложность под уровень {level} + +ВАЖНО - форматирование для Telegram (используй ТОЛЬКО HTML теги, НЕ markdown): +- жирный текст для важного (НЕ **жирный**) +- курсив для примеров (НЕ *курсив*) +- НЕ используй звёздочки *, НЕ используй markdown +- Можно использовать эмодзи""" + + try: + logger.info(f"[AI Request] generate_grammar_rule: topic='{topic_name}', level='{level}'") + + messages = [ + {"role": "system", "content": f"Ты - опытный преподаватель {language_name} языка. Объясняй правила просто и понятно."}, + {"role": "user", "content": prompt} + ] + + # Для этого запроса не используем JSON mode + model_name, provider = await self._get_active_model(user_id) + + if provider == AIProvider.google: + response_data = await self._make_google_request_text(messages, temperature=0.5, model=model_name) + else: + response_data = await self._make_openai_request_text(messages, temperature=0.5, model=model_name) + + rule_text = response_data['choices'][0]['message']['content'] + # Конвертируем markdown в HTML на случай если AI использовал звёздочки + rule_text = self._markdown_to_html(rule_text) + logger.info(f"[AI Response] generate_grammar_rule: success, {len(rule_text)} chars") + return rule_text + + except Exception as e: + logger.error(f"[AI Error] generate_grammar_rule: {type(e).__name__}: {str(e)}") + return f"📖 {topic_name}\n\n{topic_description}\n\nИзучите это правило и приступайте к упражнениям." + + async def _make_google_request_text(self, messages: list, temperature: float = 0.3, model: str = "gemini-2.0-flash-lite") -> dict: + """Запрос к Google без JSON mode (для текстовых ответов)""" + url = f"{self.google_base_url}/models/{model}:generateContent" + + contents = [] + for msg in messages: + role = msg["role"] + content = msg["content"] + if role == "system": + contents.insert(0, {"role": "user", "parts": [{"text": f"[System instruction]: {content}"}]}) + elif role == "user": + contents.append({"role": "user", "parts": [{"text": content}]}) + elif role == "assistant": + contents.append({"role": "model", "parts": [{"text": content}]}) + + payload = { + "contents": contents, + "generationConfig": {"temperature": temperature} + } + + headers = { + "Content-Type": "application/json", + "x-goog-api-key": self.google_api_key + } + + response = await self.http_client.post(url, headers=headers, json=payload) + response.raise_for_status() + data = response.json() + + text = data["candidates"][0]["content"]["parts"][0]["text"] + return {"choices": [{"message": {"content": text}}]} + + async def _make_openai_request_text(self, messages: list, temperature: float = 0.3, model: str = "gpt-4o-mini") -> dict: + """Запрос к OpenAI без JSON mode (для текстовых ответов)""" + url = f"{self.openai_base_url}/chat/completions" + + headers = { + "Authorization": f"Bearer {self.openai_api_key}", + "Content-Type": "application/json" + } + + payload = { + "model": model, + "messages": messages, + "temperature": temperature + } + + response = await self.http_client.post(url, headers=headers, json=payload) + response.raise_for_status() + return response.json() + + async def generate_grammar_exercise( + self, + topic_id: str, + topic_name: str, + topic_description: str, + level: str, + learning_lang: str = "en", + translation_lang: str = "ru", + count: int = 3, + user_id: Optional[int] = None + ) -> List[Dict]: + """ + Генерация грамматических упражнений по теме. + + Args: + topic_id: ID темы (например, "present_simple") + topic_name: Название темы (например, "Present Simple") + topic_description: Описание темы (например, "I work, he works") + level: Уровень пользователя (A1-C2 или N5-N1) + learning_lang: Язык изучения + translation_lang: Язык перевода + count: Количество упражнений + user_id: ID пользователя в БД для получения его модели + + Returns: + Список упражнений + """ + if learning_lang == "ja": + language_name = "японском" + else: + language_name = "английском" + + prompt = f"""Создай {count} грамматических упражнения на тему "{topic_name}" ({topic_description}). + +Уровень: {level} +Язык: {language_name} +Язык перевода: {translation_lang} + +Верни ответ в формате JSON: +{{ + "exercises": [ + {{ + "sentence": "предложение с пропуском ___ на {learning_lang}", + "translation": "ПОЛНЫЙ перевод предложения на {translation_lang} (без пропусков, с правильным ответом)", + "correct_answer": "правильный ответ для пропуска", + "hint": "краткая подсказка на {translation_lang} (1-2 слова)", + "explanation": "объяснение правила на {translation_lang} (1-2 предложения)" + }} + ] +}} + +Требования: +- Предложения должны быть естественными и полезными +- Пропуск обозначай как ___ +- ВАЖНО: translation должен быть ПОЛНЫМ переводом готового предложения (без пропусков), чтобы ученик понимал смысл +- Подсказка должна направлять к ответу, но не содержать его +- Объяснение должно быть понятным для уровня {level} +- Сложность должна соответствовать уровню {level}""" + + try: + logger.info(f"[AI Request] generate_grammar_exercise: topic='{topic_name}', level='{level}'") + + messages = [ + {"role": "system", "content": f"Ты - преподаватель {language_name} языка. Создавай качественные упражнения. Отвечай только JSON."}, + {"role": "user", "content": prompt} + ] + + response_data = await self._make_request(messages, temperature=0.7, user_id=user_id) + + import json + result = json.loads(response_data['choices'][0]['message']['content']) + exercises = result.get('exercises', []) + logger.info(f"[AI Response] generate_grammar_exercise: success, {len(exercises)} exercises generated") + return exercises + + except Exception as e: + logger.error(f"[AI Error] generate_grammar_exercise: {type(e).__name__}: {str(e)}") + # Fallback с простым упражнением + return [{ + "sentence": f"Example sentence with ___ ({topic_name})", + "translation": "Пример предложения", + "correct_answer": "answer", + "hint": "hint", + "explanation": f"This exercise is about {topic_name}." + }] + def _get_cefr_fallback_questions(self) -> List[Dict]: """Fallback вопросы для CEFR (английский и европейские языки)""" return [ @@ -1134,6 +1370,214 @@ User: {user_message} } ] + async def generate_word_of_day( + self, + level: str, + learning_lang: str = "en", + translation_lang: str = "ru", + excluded_words: List[str] = None, + user_id: Optional[int] = None + ) -> Optional[Dict]: + """ + Генерация слова дня. + + Args: + level: Уровень пользователя (A1-C2 или N5-N1) + learning_lang: Язык изучения + translation_lang: Язык перевода + excluded_words: Список слов для исключения (уже были) + user_id: ID пользователя для выбора модели + + Returns: + Dict с полями: word, transcription, translation, examples, synonyms, etymology + """ + language_names = { + "en": "английский", + "ja": "японский" + } + language_name = language_names.get(learning_lang, "английский") + + translation_names = { + "ru": "русский", + "en": "английский", + "ja": "японский" + } + translation_name = translation_names.get(translation_lang, "русский") + + excluded_str = "" + if excluded_words: + excluded_str = f"\n\nНЕ используй эти слова (уже были): {', '.join(excluded_words[:20])}" + + prompt = f"""Сгенерируй интересное "слово дня" для изучающего {language_name} язык на уровне {level}. + +Требования: +- Слово должно быть полезным и интересным +- Подходящее для уровня {level} +- НЕ слишком простое и НЕ слишком сложное +- Желательно с интересной этимологией или фактом{excluded_str} + +Верни JSON: +{{ + "word": "слово на {language_name}", + "transcription": "транскрипция (IPA для английского, хирагана для японского)", + "translation": "перевод на {translation_name}", + "examples": [ + {{"sentence": "пример предложения", "translation": "перевод примера"}}, + {{"sentence": "второй пример", "translation": "перевод"}} + ], + "synonyms": "синоним1, синоним2, синоним3", + "etymology": "краткий интересный факт о слове или его происхождении (1-2 предложения)" +}}""" + + try: + logger.info(f"[AI Request] generate_word_of_day: level='{level}', lang='{learning_lang}'") + + messages = [ + {"role": "system", "content": "Ты - опытный лингвист, который подбирает интересные слова для изучения."}, + {"role": "user", "content": prompt} + ] + + model_name, provider = await self._get_active_model(user_id) + + if provider == AIProvider.google: + response_data = await self._make_google_request(messages, temperature=0.8, model=model_name) + else: + response_data = await self._make_openai_request(messages, temperature=0.8, model=model_name) + + content = response_data['choices'][0]['message']['content'] + content = self._strip_markdown_code_block(content) + result = json.loads(content) + + logger.info(f"[AI Response] generate_word_of_day: word='{result.get('word', 'N/A')}'") + return result + + except Exception as e: + logger.error(f"[AI Error] generate_word_of_day: {type(e).__name__}: {str(e)}") + return None + + async def generate_mini_story( + self, + genre: str, + level: str, + learning_lang: str = "en", + translation_lang: str = "ru", + user_id: Optional[int] = None, + num_questions: int = 5 + ) -> Optional[Dict]: + """ + Генерация мини-истории для чтения. + + Args: + genre: Жанр (dialogue, news, story, letter, recipe) + level: Уровень (A1-C2 или N5-N1) + learning_lang: Язык истории + translation_lang: Язык переводов + user_id: ID пользователя для выбора модели + num_questions: Количество вопросов (из настроек пользователя) + + Returns: + Dict с полями: title, content, vocabulary, questions, word_count + """ + import json + + language_names = { + "en": "английский", + "ja": "японский" + } + language_name = language_names.get(learning_lang, "английский") + + translation_names = { + "ru": "русский", + "en": "английский", + "ja": "японский" + } + translation_name = translation_names.get(translation_lang, "русский") + + genre_descriptions = { + "dialogue": "разговорный диалог между людьми", + "news": "короткая новостная статья", + "story": "художественный рассказ с сюжетом", + "letter": "email или письмо", + "recipe": "рецепт блюда с инструкциями" + } + genre_desc = genre_descriptions.get(genre, "короткий рассказ") + + # Определяем длину текста по уровню + word_counts = { + "A1": "50-80", "N5": "30-50", + "A2": "80-120", "N4": "50-80", + "B1": "120-180", "N3": "80-120", + "B2": "180-250", "N2": "120-180", + "C1": "250-350", "N1": "180-250", + "C2": "300-400" + } + word_range = word_counts.get(level, "100-150") + + # Генерируем примеры вопросов для промпта + questions_examples = [] + for i in range(num_questions): + questions_examples.append(f''' {{ + "question": "Вопрос {i + 1} на понимание на {translation_name}", + "options": ["вариант 1", "вариант 2", "вариант 3"], + "correct": {i % 3} + }}''') + questions_json = ",\n".join(questions_examples) + + prompt = f"""Создай {genre_desc} на {language_name} языке для уровня {level}. + +Требования: +- Длина: {word_range} слов +- Используй лексику и грамматику подходящую для уровня {level} +- История должна быть интересной и законченной +- Выдели 5-8 ключевых слов которые могут быть новыми для изучающего +- Добавь полный перевод текста на {translation_name} язык + +Верни JSON: +{{ + "title": "Название истории на {language_name}", + "content": "Полный текст истории", + "translation": "Полный перевод истории на {translation_name}", + "vocabulary": [ + {{"word": "слово", "translation": "перевод на {translation_name}", "transcription": "транскрипция"}}, + ... + ], + "questions": [ +{questions_json} + ], + "word_count": число_слов_в_тексте +}} + +Важно: +- Создай ровно {num_questions} вопросов на понимание текста +- У каждого вопроса ровно 3 варианта ответа +- correct — индекс правильного ответа (0, 1 или 2)""" + + try: + logger.info(f"[AI Request] generate_mini_story: genre='{genre}', level='{level}', lang='{learning_lang}'") + + messages = [ + {"role": "system", "content": "Ты - автор адаптированных текстов для изучающих иностранные языки."}, + {"role": "user", "content": prompt} + ] + + model_name, provider = await self._get_active_model(user_id) + + if provider == AIProvider.google: + response_data = await self._make_google_request(messages, temperature=0.8, model=model_name) + else: + response_data = await self._make_openai_request(messages, temperature=0.8, model=model_name) + + content = response_data['choices'][0]['message']['content'] + content = self._strip_markdown_code_block(content) + result = json.loads(content) + + logger.info(f"[AI Response] generate_mini_story: title='{result.get('title', 'N/A')}', words={result.get('word_count', 0)}") + return result + + except Exception as e: + logger.error(f"[AI Error] generate_mini_story: {type(e).__name__}: {str(e)}") + return None + def _get_jlpt_fallback_questions(self) -> List[Dict]: """Fallback вопросы для JLPT (японский)""" return [ diff --git a/services/reminder_service.py b/services/reminder_service.py index b4b6889..7207162 100644 --- a/services/reminder_service.py +++ b/services/reminder_service.py @@ -1,12 +1,12 @@ import logging from datetime import datetime, timedelta -from typing import List +from typing import List, Optional from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from database.models import User +from database.models import User, JLPT_LANGUAGES from database.db import async_session_maker logger = logging.getLogger(__name__) @@ -30,9 +30,26 @@ class ReminderService: replace_existing=True ) + # Генерация слов дня в 00:00 UTC + self.scheduler.add_job( + self.generate_daily_words, + trigger=CronTrigger(hour=0, minute=0, timezone='UTC'), + id='generate_words_of_day', + replace_existing=True + ) + self.scheduler.start() logger.info("Планировщик напоминаний запущен") + async def generate_daily_words(self): + """Генерация слов дня для всех уровней""" + try: + from services.wordofday_service import wordofday_service + results = await wordofday_service.generate_all_words_for_today() + logger.info(f"Слова дня сгенерированы: {results}") + except Exception as e: + logger.error(f"Ошибка генерации слов дня: {e}") + def shutdown(self): """Остановить планировщик""" self.scheduler.shutdown() @@ -97,6 +114,17 @@ class ReminderService: return time_diff < 300 # 5 минут в секундах + async def _get_user_level(self, user: User) -> str: + """Получить уровень пользователя для текущего языка изучения""" + # Сначала проверяем levels_by_language + if user.levels_by_language and user.learning_language in user.levels_by_language: + return user.levels_by_language[user.learning_language] + + # Иначе используем общий уровень + if user.learning_language in JLPT_LANGUAGES: + return "N5" # Дефолтный JLPT уровень + return user.level.value if user.level else "A1" + async def _send_reminder(self, user: User, session: AsyncSession): """ Отправить напоминание пользователю @@ -106,18 +134,37 @@ class ReminderService: session: Сессия базы данных """ try: - message_text = ( - "⏰ Время для практики!\n\n" - "Не забудь потренироваться сегодня:\n" - "• /task - выполни задания\n" - "• /practice - попрактикуй диалог\n" - "• /words - добавь новые слова\n\n" - "💪 Регулярная практика - ключ к успеху!" + from services.wordofday_service import wordofday_service + from utils.i18n import t + + lang = user.language_interface or "ru" + + # Получаем слово дня для пользователя + level = await self._get_user_level(user) + word_of_day = await wordofday_service.get_word_of_day( + learning_lang=user.learning_language, + level=level ) + # Формируем сообщение + message_parts = [t(lang, "reminder.daily_title") + "\n"] + + # Добавляем слово дня если есть + if word_of_day: + word_text = await wordofday_service.format_word_for_user( + word_of_day, + translation_lang=user.translation_language or user.language_interface, + ui_lang=lang + ) + message_parts.append(f"{t(lang, 'reminder.daily_wod')}\n{word_text}\n") + + message_parts.append(t(lang, "reminder.daily_tips")) + message_parts.append(f"\n{t(lang, 'reminder.daily_motivation')}") + await self.bot.send_message( chat_id=user.telegram_id, - text=message_text + text="\n".join(message_parts), + parse_mode="HTML" ) # Обновляем время последнего напоминания diff --git a/services/wordofday_service.py b/services/wordofday_service.py new file mode 100644 index 0000000..1df63ab --- /dev/null +++ b/services/wordofday_service.py @@ -0,0 +1,227 @@ +"""Сервис генерации слова дня""" +import logging +from datetime import datetime, date +from typing import Optional, Dict, List + +from sqlalchemy import select, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from database.db import async_session_maker +from database.models import WordOfDay, LanguageLevel, JLPTLevel, JLPT_LANGUAGES +from services.ai_service import ai_service + +logger = logging.getLogger(__name__) + +# Уровни для каждого языка +CEFR_LEVELS = [level.value for level in LanguageLevel] # A1-C2 +JLPT_LEVELS = [level.value for level in JLPTLevel] # N5-N1 + +# Языки для генерации +LEARNING_LANGUAGES = ["en", "ja"] + + +class WordOfDayService: + """Сервис для генерации и получения слова дня""" + + async def generate_all_words_for_today(self) -> Dict[str, int]: + """ + Генерация слов дня для всех языков и уровней. + Вызывается в 00:00 UTC. + + Returns: + Dict с количеством сгенерированных слов по языкам + """ + today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + results = {"en": 0, "ja": 0, "errors": 0} + + async with async_session_maker() as session: + for lang in LEARNING_LANGUAGES: + levels = JLPT_LEVELS if lang in JLPT_LANGUAGES else CEFR_LEVELS + + for level in levels: + try: + # Проверяем, не сгенерировано ли уже + existing = await self._get_word_for_date( + session, today, lang, level + ) + if existing: + logger.debug( + f"Слово дня уже существует: {lang}/{level}" + ) + continue + + # Получаем список недавних слов для исключения + excluded = await self._get_recent_words(session, lang, level, days=30) + + # Генерируем слово + word_data = await ai_service.generate_word_of_day( + level=level, + learning_lang=lang, + translation_lang="ru", # Базовый перевод на русский + excluded_words=excluded + ) + + if word_data: + word_of_day = WordOfDay( + word=word_data.get("word", ""), + transcription=word_data.get("transcription"), + translation=word_data.get("translation", ""), + examples=word_data.get("examples"), + synonyms=word_data.get("synonyms"), + etymology=word_data.get("etymology"), + learning_lang=lang, + level=level, + date=today + ) + session.add(word_of_day) + await session.commit() + results[lang] += 1 + logger.info( + f"Сгенерировано слово дня: {word_data.get('word')} " + f"({lang}/{level})" + ) + else: + results["errors"] += 1 + logger.warning( + f"Не удалось сгенерировать слово для {lang}/{level}" + ) + + except Exception as e: + results["errors"] += 1 + logger.error( + f"Ошибка генерации слова для {lang}/{level}: {e}" + ) + + total = results["en"] + results["ja"] + logger.info( + f"Генерация слов дня завершена: всего={total}, " + f"en={results['en']}, ja={results['ja']}, ошибок={results['errors']}" + ) + return results + + async def get_word_of_day( + self, + learning_lang: str, + level: str, + target_date: Optional[datetime] = None + ) -> Optional[WordOfDay]: + """ + Получить слово дня для языка и уровня. + + Args: + learning_lang: Язык изучения (en/ja) + level: Уровень (A1-C2 или N5-N1) + target_date: Дата (по умолчанию сегодня) + + Returns: + WordOfDay или None + """ + if target_date is None: + target_date = datetime.utcnow().replace( + hour=0, minute=0, second=0, microsecond=0 + ) + else: + target_date = target_date.replace( + hour=0, minute=0, second=0, microsecond=0 + ) + + async with async_session_maker() as session: + return await self._get_word_for_date( + session, target_date, learning_lang, level + ) + + async def _get_word_for_date( + self, + session: AsyncSession, + target_date: datetime, + learning_lang: str, + level: str + ) -> Optional[WordOfDay]: + """Получить слово из БД для конкретной даты""" + result = await session.execute( + select(WordOfDay).where( + and_( + WordOfDay.date == target_date, + WordOfDay.learning_lang == learning_lang, + WordOfDay.level == level + ) + ) + ) + return result.scalar_one_or_none() + + async def _get_recent_words( + self, + session: AsyncSession, + learning_lang: str, + level: str, + days: int = 30 + ) -> List[str]: + """Получить список недавних слов для исключения""" + from datetime import timedelta + + cutoff_date = datetime.utcnow() - timedelta(days=days) + result = await session.execute( + select(WordOfDay.word).where( + and_( + WordOfDay.learning_lang == learning_lang, + WordOfDay.level == level, + WordOfDay.date >= cutoff_date + ) + ) + ) + return [row[0] for row in result.fetchall()] + + async def format_word_for_user( + self, + word: WordOfDay, + translation_lang: str = "ru", + ui_lang: str = None + ) -> str: + """ + Форматировать слово дня для отображения пользователю. + + Args: + word: Объект WordOfDay + translation_lang: Язык перевода для пользователя + ui_lang: Язык интерфейса (для локализации заголовков) + + Returns: + Отформатированная строка + """ + from utils.i18n import t + + lang = ui_lang or translation_lang or "ru" + lines = [] + + # Заголовок со словом + if word.transcription: + lines.append(f"📚 {word.word} [{word.transcription}]") + else: + lines.append(f"📚 {word.word}") + + # Перевод + lines.append(f"📝 {word.translation}") + + # Синонимы + if word.synonyms: + lines.append(f"\n🔄 {t(lang, 'wod.synonyms')}: {word.synonyms}") + + # Примеры + if word.examples: + lines.append(f"\n📖 {t(lang, 'wod.examples')}:") + for i, example in enumerate(word.examples[:3], 1): + sentence = example.get("sentence", "") + translation = example.get("translation", "") + lines.append(f" {i}. {sentence}") + if translation: + lines.append(f" {translation}") + + # Этимология/интересный факт + if word.etymology: + lines.append(f"\n💡 {word.etymology}") + + return "\n".join(lines) + + +# Глобальный экземпляр сервиса +wordofday_service = WordOfDayService() diff --git a/ИДЕИ.txt b/ИДЕИ.txt deleted file mode 100644 index 73b9310..0000000 --- a/ИДЕИ.txt +++ /dev/null @@ -1,3 +0,0 @@ -Сделать задачки с помощью голосовых, человек должен написать что услышал, человек должен записать голосовой слова -Сделать задачки с картинками, человек должен написать что изоображено на картинке -Сделать задания по темам тип времён или неправильных глаголов на английском \ No newline at end of file