From 72a63eeda5a53bd2bc04a74df7b0984e17a472b0 Mon Sep 17 00:00:00 2001 From: "mamonov.ep" Date: Thu, 4 Dec 2025 15:46:02 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D1=8B=D0=B5?= =?UTF-8?q?=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8=20MVP:=20=D1=82?= =?UTF-8?q?=D0=B5=D0=BC=D0=B0=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=D0=BA=D0=B8,=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20=D1=81=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?,=20=D0=B4=D0=B8=D0=B0=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BF=D1=80=D0=B0=D0=BA=D1=82=D0=B8=D0=BA=D0=B0,=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=BF=D0=BE=D0=BC=D0=B8=D0=BD=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B8=20=D1=82=D0=B5=D1=81=D1=82=20=D1=83=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BD=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новые команды: - /words [тема] - AI-генерация тематических подборок слов (10 слов по теме с учётом уровня) - /import - извлечение до 15 ключевых слов из текста (книги, статьи, песни) - /practice - диалоговая практика с AI в 6 сценариях (ресторан, магазин, путешествие, работа, врач, общение) - /reminder - настройка ежедневных напоминаний по расписанию - /level_test - тест из 7 вопросов для определения уровня английского (A1-C2) Основные изменения: - AI сервис: добавлены методы generate_thematic_words, extract_words_from_text, start_conversation, continue_conversation, generate_level_test - Диалоговая практика: исправление ошибок в реальном времени, подсказки, перевод реплик - Напоминания: APScheduler для ежедневной отправки напоминаний в выбранное время - Тест уровня: автоматическое определение уровня при регистрации, можно пропустить - База данных: добавлены поля reminders_enabled, last_reminder_sent - Vocabulary service: метод get_word_by_original для проверки дубликатов - Зависимости: apscheduler==3.10.4 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 80 ++++++-- bot/handlers/import_text.py | 248 +++++++++++++++++++++++ bot/handlers/level_test.py | 264 ++++++++++++++++++++++++ bot/handlers/practice.py | 228 +++++++++++++++++++++ bot/handlers/reminder.py | 172 ++++++++++++++++ bot/handlers/start.py | 60 +++++- bot/handlers/words.py | 215 ++++++++++++++++++++ database/models.py | 2 + main.py | 18 +- requirements.txt | 1 + services/ai_service.py | 355 +++++++++++++++++++++++++++++++++ services/reminder_service.py | 141 +++++++++++++ services/vocabulary_service.py | 20 ++ 13 files changed, 1781 insertions(+), 23 deletions(-) create mode 100644 bot/handlers/import_text.py create mode 100644 bot/handlers/level_test.py create mode 100644 bot/handlers/practice.py create mode 100644 bot/handlers/reminder.py create mode 100644 bot/handlers/words.py create mode 100644 services/reminder_service.py diff --git a/README.md b/README.md index d2b9d07..728d9ec 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,25 @@ ## Возможности +- 🎯 Автоматический тест определения уровня (A1-C2) - 📚 Управление словарным запасом с автоматическим переводом через AI -- ✍️ Интерактивные задания на перевод слов с проверкой через AI +- 📖 Тематические подборки и извлечение слов из текстов +- ✍️ Интерактивные задания 3-х типов с проверкой через AI +- 💬 Диалоговая практика с ИИ в разных сценариях - 📊 Статистика прогресса и точность ответов - 🔄 Умные повторения (spaced repetition) -- 💬 Диалоговая практика с ИИ (в разработке) +- ⏰ Ежедневные напоминания по расписанию +- ⚙️ Адаптивная сложность под твой уровень ## Текущая версия (MVP) **Реализовано:** - ✅ `/start` - регистрация и приветствие пользователя +- ✅ `/level_test` - тест для определения уровня английского (7 вопросов, автоматическое определение A1-C2) - ✅ `/add [слово]` - добавление слов в словарь с AI-переводом, транскрипцией и примерами +- ✅ `/words [тема]` - AI-генерация тематических подборок слов (travel, food, work и т.д.) +- ✅ `/import` - извлечение слов из текста с помощью AI (книги, статьи, тексты песен) +- ✅ `/practice` - диалоговая практика с AI (6 сценариев: ресторан, магазин, путешествие, работа, врач, общение) - ✅ `/vocabulary` - просмотр личного словаря (последние 10 слов) - ✅ `/task` - интерактивные задания 3-х типов: - Перевод EN→RU @@ -22,10 +30,14 @@ - Заполнение пропусков в предложениях - ✅ `/stats` - детальная статистика обучения - ✅ `/settings` - настройки пользователя (уровень A1-C2, язык интерфейса) +- ✅ `/reminder` - ежедневные напоминания о практике (настройка времени и включение/выключение) - ✅ `/help` - справка по командам - ✅ Проверка ответов через AI с детальной обратной связью - ✅ AI-генерация предложений для практики в контексте +- ✅ Исправление ошибок в реальном времени во время диалога +- ✅ Автоматический тест уровня при первом запуске - ✅ Отслеживание прогресса по каждому слову +- ✅ Автоматические ежедневные напоминания по расписанию - ✅ База данных (PostgreSQL) для хранения данных - ✅ Docker-развёртывание (полное и только БД) @@ -216,29 +228,71 @@ bot_tg_language/ ### Команды бота - `/start` - Начать работу с ботом +- `/level_test` - Пройти тест определения уровня - `/add [слово]` - Добавить слово в словарь +- `/words [тема]` - Получить тематическую подборку слов +- `/import` - Извлечь слова из текста +- `/practice` - Диалоговая практика с AI - `/vocabulary` - Посмотреть свой словарь +- `/task` - Получить интерактивное задание +- `/stats` - Просмотреть статистику +- `/settings` - Настройки бота +- `/reminder` - Настроить ежедневные напоминания - `/help` - Показать справку ### Пример использования 1. Запустите бота: `/start` -2. Добавьте слово: `/add elephant` -3. Бот переведёт слово через AI и предложит добавить в словарь -4. Подтвердите добавление -5. Просмотрите словарь: `/vocabulary` +2. Пройдите тест уровня (или пропустите) +3. Добавьте слово: `/add elephant` +4. Бот переведёт слово через AI и предложит добавить в словарь +5. Подтвердите добавление +6. Получите тематическую подборку: `/words travel` +7. Выберите слова для добавления из предложенного списка +8. Просмотрите словарь: `/vocabulary` +9. Выполните задание: `/task` +10. Настройте напоминания: `/reminder` и установите время (например, 09:00 UTC) + +### Тест определения уровня + +При первом запуске бот предложит пройти тест: +- **7 вопросов** разной сложности (A1 → C2) +- **Автоматическое определение** уровня по результатам +- **2-3 минуты** на прохождение +- Можно пропустить и пройти позже: `/level_test` +- Или установить уровень вручную в `/settings` + +Тест помогает подобрать задания и материалы под твой реальный уровень! + +### Настройка напоминаний + +Команда `/reminder` позволяет настроить ежедневные напоминания: +- Установите время в формате HH:MM (UTC) +- Включите/выключите напоминания +- Бот будет отправлять напоминание каждый день в указанное время +- **Важно**: время указывается в UTC (МСК = UTC + 3) ## Roadmap См. [TZ.md](TZ.md) для полного технического задания. -**Следующие этапы:** -- [ ] Ежедневные задания с разными типами упражнений -- [ ] Тематические подборки слов -- [ ] Импорт слов из текста -- [ ] Диалоговая практика с AI -- [ ] Статистика и прогресс -- [ ] Spaced repetition алгоритм +**MVP завершён! 🎉** + +Все основные функции реализованы: +- [x] Ежедневные задания с разными типами упражнений +- [x] Тематические подборки слов +- [x] Импорт слов из текста +- [x] Диалоговая практика с AI +- [x] Статистика и прогресс +- [x] Spaced repetition алгоритм (базовая версия) +- [x] Напоминания и ежедневные задания по расписанию + +**Следующие улучшения:** +- [ ] Экспорт словаря (PDF, Anki, CSV) +- [ ] Голосовые сообщения для практики произношения +- [ ] Групповые челленджи и лидерборды +- [ ] Gamification (стрики, достижения, уровни) +- [ ] Расширенная аналитика с графиками ## Cloudflare AI Gateway (опционально) diff --git a/bot/handlers/import_text.py b/bot/handlers/import_text.py new file mode 100644 index 0000000..e82b85e --- /dev/null +++ b/bot/handlers/import_text.py @@ -0,0 +1,248 @@ +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup + +from database.db import async_session_maker +from database.models import WordSource +from services.user_service import UserService +from services.vocabulary_service import VocabularyService +from services.ai_service import ai_service + +router = Router() + + +class ImportStates(StatesGroup): + """Состояния для импорта слов из текста""" + waiting_for_text = State() + viewing_words = State() + + +@router.message(Command("import")) +async def cmd_import(message: Message, state: FSMContext): + """Обработчик команды /import""" + 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("Сначала запусти бота командой /start") + return + + await state.set_state(ImportStates.waiting_for_text) + await message.answer( + "📖 Импорт слов из текста\n\n" + "Отправь мне текст на английском языке, и я извлеку из него " + "полезные слова для изучения.\n\n" + "Можно отправить:\n" + "• Отрывок из книги или статьи\n" + "• Текст песни\n" + "• Описание чего-либо\n" + "• Любой интересный текст\n\n" + "Отправь /cancel для отмены." + ) + + +@router.message(Command("cancel"), ImportStates.waiting_for_text) +async def cancel_import(message: Message, state: FSMContext): + """Отмена импорта""" + await state.clear() + await message.answer("❌ Импорт отменён.") + + +@router.message(ImportStates.waiting_for_text) +async def process_text(message: Message, state: FSMContext): + """Обработка текста от пользователя""" + text = message.text.strip() + + if len(text) < 50: + await message.answer( + "⚠️ Текст слишком короткий. Отправь текст минимум из 50 символов.\n" + "Или используй /cancel для отмены." + ) + return + + if len(text) > 3000: + await message.answer( + "⚠️ Текст слишком длинный (максимум 3000 символов).\n" + "Отправь текст покороче или используй /cancel для отмены." + ) + return + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + + # Показываем индикатор обработки + processing_msg = await message.answer("🔄 Анализирую текст и извлекаю слова...") + + # Извлекаем слова через AI + words = await ai_service.extract_words_from_text( + text=text, + level=user.level.value, + max_words=15 + ) + + await processing_msg.delete() + + if not words: + await message.answer( + "❌ Не удалось извлечь слова из текста. Попробуй другой текст или повтори позже." + ) + await state.clear() + return + + # Сохраняем данные в состоянии + await state.update_data( + words=words, + user_id=user.id, + original_text=text + ) + await state.set_state(ImportStates.viewing_words) + + # Показываем извлечённые слова + await show_extracted_words(message, words) + + +async def show_extracted_words(message: Message, words: list): + """Показать извлечённые слова с кнопками для добавления""" + + text = f"📚 Найдено слов: {len(words)}\n\n" + + for idx, word_data in enumerate(words, 1): + text += ( + f"{idx}. {word_data['word']} " + f"[{word_data.get('transcription', '')}]\n" + f" {word_data['translation']}\n" + ) + + if word_data.get('context'): + # Укорачиваем контекст, если он слишком длинный + context = word_data['context'] + if len(context) > 80: + context = context[:77] + "..." + text += f" «{context}»\n" + + text += "\n" + + text += "Выбери слова, которые хочешь добавить в словарь:" + + # Создаем кнопки для каждого слова (по 2 в ряд) + keyboard = [] + for idx, word_data in enumerate(words): + button = InlineKeyboardButton( + text=f"➕ {word_data['word']}", + callback_data=f"import_word_{idx}" + ) + + # Добавляем по 2 кнопки в ряд + if len(keyboard) == 0 or len(keyboard[-1]) == 2: + keyboard.append([button]) + else: + keyboard[-1].append(button) + + # Кнопка "Добавить все" + keyboard.append([ + InlineKeyboardButton(text="✅ Добавить все", callback_data="import_all_words") + ]) + + # Кнопка "Закрыть" + keyboard.append([ + InlineKeyboardButton(text="❌ Закрыть", callback_data="close_import") + ]) + + reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard) + await message.answer(text, reply_markup=reply_markup) + + +@router.callback_query(F.data.startswith("import_word_"), ImportStates.viewing_words) +async def import_single_word(callback: CallbackQuery, state: FSMContext): + """Добавить одно слово из импорта""" + word_index = int(callback.data.split("_")[2]) + + data = await state.get_data() + words = data.get('words', []) + user_id = data.get('user_id') + + if word_index >= len(words): + await callback.answer("❌ Ошибка: слово не найдено") + return + + word_data = words[word_index] + + async with async_session_maker() as session: + # Проверяем, нет ли уже такого слова + existing = await VocabularyService.get_word_by_original( + session, user_id, word_data['word'] + ) + + if existing: + await callback.answer(f"Слово '{word_data['word']}' уже в словаре", show_alert=True) + return + + # Добавляем слово + await VocabularyService.add_word( + session=session, + user_id=user_id, + word_original=word_data['word'], + word_translation=word_data['translation'], + transcription=word_data.get('transcription'), + examples=[{"en": word_data.get('context', ''), "ru": ""}] if word_data.get('context') else [], + source=WordSource.CONTEXT, + category='imported', + difficulty_level=None + ) + + await callback.answer(f"✅ Слово '{word_data['word']}' добавлено в словарь") + + +@router.callback_query(F.data == "import_all_words", ImportStates.viewing_words) +async def import_all_words(callback: CallbackQuery, state: FSMContext): + """Добавить все слова из импорта""" + data = await state.get_data() + words = data.get('words', []) + user_id = data.get('user_id') + + added_count = 0 + skipped_count = 0 + + async with async_session_maker() as session: + for word_data in words: + # Проверяем, нет ли уже такого слова + existing = await VocabularyService.get_word_by_original( + session, user_id, word_data['word'] + ) + + if existing: + skipped_count += 1 + continue + + # Добавляем слово + await VocabularyService.add_word( + session=session, + user_id=user_id, + word_original=word_data['word'], + word_translation=word_data['translation'], + transcription=word_data.get('transcription'), + examples=[{"en": word_data.get('context', ''), "ru": ""}] if word_data.get('context') else [], + source=WordSource.CONTEXT, + category='imported', + difficulty_level=None + ) + added_count += 1 + + result_text = f"✅ Добавлено слов: {added_count}" + if skipped_count > 0: + result_text += f"\n⚠️ Пропущено (уже в словаре): {skipped_count}" + + await callback.message.edit_reply_markup(reply_markup=None) + await callback.message.answer(result_text) + await state.clear() + await callback.answer() + + +@router.callback_query(F.data == "close_import", ImportStates.viewing_words) +async def close_import(callback: CallbackQuery, state: FSMContext): + """Закрыть импорт""" + await callback.message.delete() + await state.clear() + await callback.answer() diff --git a/bot/handlers/level_test.py b/bot/handlers/level_test.py new file mode 100644 index 0000000..5d36ca1 --- /dev/null +++ b/bot/handlers/level_test.py @@ -0,0 +1,264 @@ +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup + +from database.db import async_session_maker +from database.models import LanguageLevel +from services.user_service import UserService +from services.ai_service import ai_service + +router = Router() + + +class LevelTestStates(StatesGroup): + """Состояния для прохождения теста уровня""" + taking_test = State() + + +@router.message(Command("level_test")) +async def cmd_level_test(message: Message, state: FSMContext): + """Обработчик команды /level_test""" + await start_level_test(message, state) + + +async def start_level_test(message: Message, state: FSMContext): + """Начать тест определения уровня""" + # Показываем описание теста + await message.answer( + "📊 Тест определения уровня\n\n" + "Этот короткий тест поможет определить твой уровень английского.\n\n" + "📋 Тест включает 7 вопросов:\n" + "• Грамматика\n" + "• Лексика\n" + "• Понимание\n\n" + "⏱ Займёт около 2-3 минут\n\n" + "Готов начать?" + ) + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="✅ Начать тест", callback_data="start_test")], + [InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_test")] + ]) + + await message.answer("Нажми кнопку когда будешь готов:", reply_markup=keyboard) + + +@router.callback_query(F.data == "cancel_test") +async def cancel_test(callback: CallbackQuery, state: FSMContext): + """Отменить тест""" + await state.clear() + await callback.message.delete() + await callback.message.answer("❌ Тест отменён") + await callback.answer() + + +@router.callback_query(F.data == "start_test") +async def begin_test(callback: CallbackQuery, state: FSMContext): + """Начать прохождение теста""" + await callback.message.delete() + + # Показываем индикатор загрузки + loading_msg = await callback.message.answer("🔄 Генерирую вопросы...") + + # Генерируем тест через AI + questions = await ai_service.generate_level_test() + + await loading_msg.delete() + + if not questions: + await callback.message.answer( + "❌ Не удалось сгенерировать тест. Попробуй позже или используй /settings для ручной установки уровня." + ) + await state.clear() + await callback.answer() + return + + # Сохраняем данные в состоянии + await state.update_data( + questions=questions, + current_question=0, + correct_answers=0, + answers=[] # Для отслеживания ответов по уровням + ) + await state.set_state(LevelTestStates.taking_test) + + # Показываем первый вопрос + await show_question(callback.message, state) + await callback.answer() + + +async def show_question(message: Message, state: FSMContext): + """Показать текущий вопрос""" + data = await state.get_data() + questions = data.get('questions', []) + current_idx = data.get('current_question', 0) + + if current_idx >= len(questions): + # Тест завершён + await finish_test(message, state) + return + + question = questions[current_idx] + + # Формируем текст вопроса + text = ( + f"❓ Вопрос {current_idx + 1} из {len(questions)}\n\n" + f"{question['question']}\n" + f"{question.get('question_ru', '')}\n\n" + ) + + # Создаем кнопки с вариантами ответа + keyboard = [] + letters = ['A', 'B', 'C', 'D'] + for idx, option in enumerate(question['options']): + keyboard.append([ + InlineKeyboardButton( + text=f"{letters[idx]}) {option}", + callback_data=f"answer_{idx}" + ) + ]) + + reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard) + await message.answer(text, reply_markup=reply_markup) + + +@router.callback_query(F.data.startswith("answer_"), LevelTestStates.taking_test) +async def process_answer(callback: CallbackQuery, state: FSMContext): + """Обработать ответ на вопрос""" + answer_idx = int(callback.data.split("_")[1]) + + data = await state.get_data() + questions = data.get('questions', []) + current_idx = data.get('current_question', 0) + correct_answers = data.get('correct_answers', 0) + answers = data.get('answers', []) + + question = questions[current_idx] + is_correct = (answer_idx == question['correct']) + + # Сохраняем результат + if is_correct: + correct_answers += 1 + + # Сохраняем ответ с уровнем вопроса + answers.append({ + 'level': question['level'], + 'correct': is_correct + }) + + # Показываем результат + if is_correct: + result_text = "✅ Правильно!" + else: + correct_option = question['options'][question['correct']] + result_text = f"❌ Неправильно\nПравильный ответ: {correct_option}" + + await callback.message.edit_text( + f"❓ Вопрос {current_idx + 1} из {len(questions)}\n\n" + f"{result_text}" + ) + + # Переходим к следующему вопросу + await state.update_data( + current_question=current_idx + 1, + correct_answers=correct_answers, + answers=answers + ) + + # Небольшая пауза перед следующим вопросом + import asyncio + await asyncio.sleep(1.5) + + await show_question(callback.message, state) + await callback.answer() + + +async def finish_test(message: Message, state: FSMContext): + """Завершить тест и определить уровень""" + data = await state.get_data() + questions = data.get('questions', []) + correct_answers = data.get('correct_answers', 0) + answers = data.get('answers', []) + + total = len(questions) + accuracy = int((correct_answers / total) * 100) if total > 0 else 0 + + # Определяем уровень на основе правильных ответов по уровням + level = determine_level(answers) + + # Сохраняем уровень в базе данных + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.chat.id) + if user: + user.level = level + await session.commit() + + # Описания уровней + level_descriptions = { + "A1": "Начальный - понимаешь основные фразы и можешь представиться", + "A2": "Элементарный - можешь общаться на простые темы", + "B1": "Средний - можешь поддержать беседу на знакомые темы", + "B2": "Выше среднего - свободно общаешься в большинстве ситуаций", + "C1": "Продвинутый - используешь язык гибко и эффективно", + "C2": "Профессиональный - владеешь языком на уровне носителя" + } + + await state.clear() + + result_text = ( + f"🎉 Тест завершён!\n\n" + f"📊 Результаты:\n" + f"Правильных ответов: {correct_answers} из {total}\n" + f"Точность: {accuracy}%\n\n" + f"🎯 Твой уровень: {level.value}\n" + f"{level_descriptions.get(level.value, '')}\n\n" + f"Теперь задания и материалы будут подбираться под твой уровень!\n" + f"Ты можешь изменить уровень в любое время через /settings" + ) + + await message.answer(result_text) + + +def determine_level(answers: list) -> LanguageLevel: + """ + Определить уровень на основе ответов + + Args: + answers: Список ответов с уровнями + + Returns: + Определённый уровень + """ + # Подсчитываем правильные ответы по уровням + level_stats = { + 'A1': {'correct': 0, 'total': 0}, + 'A2': {'correct': 0, 'total': 0}, + 'B1': {'correct': 0, 'total': 0}, + 'B2': {'correct': 0, 'total': 0}, + 'C1': {'correct': 0, 'total': 0}, + 'C2': {'correct': 0, 'total': 0} + } + + for answer in answers: + level = answer['level'] + if level in level_stats: + level_stats[level]['total'] += 1 + if answer['correct']: + level_stats[level]['correct'] += 1 + + # Определяем уровень: ищем последний уровень, где правильно >= 50% + levels_order = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] + determined_level = 'A1' + + for level in levels_order: + if level_stats[level]['total'] > 0: + accuracy = level_stats[level]['correct'] / level_stats[level]['total'] + if accuracy >= 0.5: # 50% и выше + determined_level = level + else: + # Если не прошёл этот уровень, останавливаемся + break + + return LanguageLevel[determined_level] diff --git a/bot/handlers/practice.py b/bot/handlers/practice.py new file mode 100644 index 0000000..d730932 --- /dev/null +++ b/bot/handlers/practice.py @@ -0,0 +1,228 @@ +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery +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 + +router = Router() + + +class PracticeStates(StatesGroup): + """Состояния для диалоговой практики""" + choosing_scenario = State() + in_conversation = State() + + +# Доступные сценарии +SCENARIOS = { + "restaurant": "🍽️ Ресторан", + "shopping": "🛍️ Магазин", + "travel": "✈️ Путешествие", + "work": "💼 Работа", + "doctor": "🏥 Врач", + "casual": "💬 Общение" +} + + +@router.message(Command("practice")) +async def cmd_practice(message: Message, state: FSMContext): + """Обработчик команды /practice""" + 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("Сначала запусти бота командой /start") + return + + # Показываем выбор сценария + keyboard = [] + for scenario_id, scenario_name in SCENARIOS.items(): + keyboard.append([ + InlineKeyboardButton( + text=scenario_name, + callback_data=f"scenario_{scenario_id}" + ) + ]) + + reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard) + + await state.update_data(user_id=user.id, level=user.level.value) + await state.set_state(PracticeStates.choosing_scenario) + + await message.answer( + "💬 Диалоговая практика с AI\n\n" + "Выбери сценарий для разговора:\n\n" + "• AI будет играть роль собеседника\n" + "• Ты можешь общаться на английском\n" + "• AI будет исправлять твои ошибки\n" + "• Используй /stop для завершения диалога\n\n" + "Выбери сценарий:", + reply_markup=reply_markup + ) + + +@router.callback_query(F.data.startswith("scenario_"), PracticeStates.choosing_scenario) +async def start_scenario(callback: CallbackQuery, state: FSMContext): + """Начать диалог с выбранным сценарием""" + scenario = callback.data.split("_")[1] + data = await state.get_data() + level = data.get('level', 'B1') + + # Удаляем клавиатуру + await callback.message.edit_reply_markup(reply_markup=None) + + # Показываем индикатор + thinking_msg = await callback.message.answer("🤔 AI готовится к диалогу...") + + # Начинаем диалог + conversation_start = await ai_service.start_conversation(scenario, level) + + await thinking_msg.delete() + + # Сохраняем данные в состоянии + await state.update_data( + scenario=scenario, + scenario_name=SCENARIOS[scenario], + conversation_history=[], + message_count=0 + ) + await state.set_state(PracticeStates.in_conversation) + + # Формируем сообщение + text = ( + f"{SCENARIOS[scenario]}\n\n" + f"📝 {conversation_start.get('context', '')}\n\n" + f"AI: {conversation_start.get('message', '')}\n" + f"({conversation_start.get('translation', '')})\n\n" + "💡 Подсказки:\n" + ) + + for suggestion in conversation_start.get('suggestions', []): + text += f"• {suggestion}\n" + + text += "\n📝 Напиши свой ответ на английском или используй /stop для завершения" + + # Кнопки управления + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="💡 Показать подсказки", callback_data="show_hints")], + [InlineKeyboardButton(text="🔚 Завершить диалог", callback_data="stop_practice")] + ]) + + await callback.message.answer(text, reply_markup=keyboard) + await callback.answer() + + +@router.message(Command("stop"), PracticeStates.in_conversation) +async def stop_practice(message: Message, state: FSMContext): + """Завершить диалоговую практику""" + data = await state.get_data() + message_count = data.get('message_count', 0) + + await state.clear() + await message.answer( + f"✅ Диалог завершён!\n\n" + f"Сообщений обменено: {message_count}\n\n" + "Отличная работа! Продолжай практиковаться.\n" + "Используй /practice для нового диалога." + ) + + +@router.callback_query(F.data == "stop_practice", PracticeStates.in_conversation) +async def stop_practice_callback(callback: CallbackQuery, state: FSMContext): + """Завершить диалог через кнопку""" + data = await state.get_data() + message_count = data.get('message_count', 0) + + await callback.message.delete() + await state.clear() + + await callback.message.answer( + f"✅ Диалог завершён!\n\n" + f"Сообщений обменено: {message_count}\n\n" + "Отличная работа! Продолжай практиковаться.\n" + "Используй /practice для нового диалога." + ) + await callback.answer() + + +@router.message(PracticeStates.in_conversation) +async def handle_conversation(message: Message, state: FSMContext): + """Обработка сообщений в диалоге""" + user_message = message.text.strip() + + if not user_message: + await message.answer("Напиши что-нибудь на английском или используй /stop для завершения") + return + + data = await state.get_data() + conversation_history = data.get('conversation_history', []) + scenario = data.get('scenario', 'casual') + level = data.get('level', 'B1') + message_count = data.get('message_count', 0) + + # Показываем индикатор + thinking_msg = await message.answer("🤔 AI думает...") + + # Добавляем сообщение пользователя в историю + conversation_history.append({ + "role": "user", + "content": user_message + }) + + # Получаем ответ от AI + ai_response = await ai_service.continue_conversation( + conversation_history=conversation_history, + user_message=user_message, + scenario=scenario, + level=level + ) + + await thinking_msg.delete() + + # Добавляем ответ AI в историю + conversation_history.append({ + "role": "assistant", + "content": ai_response.get('response', '') + }) + + # Обновляем состояние + message_count += 1 + await state.update_data( + conversation_history=conversation_history, + message_count=message_count + ) + + # Формируем ответ + text = "" + + # Показываем feedback, если есть ошибки + feedback = ai_response.get('feedback', {}) + if feedback.get('has_errors') and feedback.get('corrections'): + text += f"⚠️ Исправления:\n{feedback['corrections']}\n\n" + + if feedback.get('comment'): + text += f"💬 {feedback['comment']}\n\n" + + # Ответ AI + text += ( + f"AI: {ai_response.get('response', '')}\n" + f"({ai_response.get('translation', '')})\n\n" + ) + + # Подсказки + suggestions = ai_response.get('suggestions', []) + if suggestions: + text += "💡 Подсказки:\n" + for suggestion in suggestions[:3]: + text += f"• {suggestion}\n" + + # Кнопки + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔚 Завершить диалог", callback_data="stop_practice")] + ]) + + await message.answer(text, reply_markup=keyboard) diff --git a/bot/handlers/reminder.py b/bot/handlers/reminder.py new file mode 100644 index 0000000..8cafb4d --- /dev/null +++ b/bot/handlers/reminder.py @@ -0,0 +1,172 @@ +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery +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 + +router = Router() + + +class ReminderStates(StatesGroup): + """Состояния для настройки напоминаний""" + waiting_for_time = State() + + +@router.message(Command("reminder")) +async def cmd_reminder(message: Message): + """Обработчик команды /reminder""" + 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("Сначала запусти бота командой /start") + return + + # Формируем текст + status = "✅ Включены" if user.reminders_enabled else "❌ Выключены" + time_text = user.daily_task_time if user.daily_task_time else "Не установлено" + + text = ( + f"⏰ Напоминания\n\n" + f"Статус: {status}\n" + f"Время: {time_text} UTC\n\n" + f"Напоминания помогут не забывать о ежедневной практике.\n" + f"Бот будет присылать сообщение в выбранное время каждый день." + ) + + # Создаем кнопки + keyboard = [] + + if user.reminders_enabled: + keyboard.append([ + InlineKeyboardButton(text="❌ Выключить", callback_data="reminder_disable") + ]) + else: + keyboard.append([ + InlineKeyboardButton(text="✅ Включить", callback_data="reminder_enable") + ]) + + keyboard.append([ + InlineKeyboardButton(text="⏰ Изменить время", callback_data="reminder_set_time") + ]) + + reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard) + await message.answer(text, reply_markup=reply_markup) + + +@router.callback_query(F.data == "reminder_enable") +async def enable_reminders(callback: CallbackQuery): + """Включить напоминания""" + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + + if not user.daily_task_time: + await callback.answer( + "Сначала установи время напоминаний!", + show_alert=True + ) + return + + user.reminders_enabled = True + await session.commit() + + await callback.answer("✅ Напоминания включены!") + await callback.message.edit_text( + f"✅ Напоминания включены!\n\n" + f"Время: {user.daily_task_time} UTC\n\n" + f"Ты будешь получать ежедневные напоминания о практике." + ) + + +@router.callback_query(F.data == "reminder_disable") +async def disable_reminders(callback: CallbackQuery): + """Выключить напоминания""" + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + + user.reminders_enabled = False + await session.commit() + + await callback.answer("❌ Напоминания выключены") + await callback.message.edit_text( + "❌ Напоминания выключены\n\n" + "Используй /reminder чтобы включить их снова." + ) + + +@router.callback_query(F.data == "reminder_set_time") +async def set_reminder_time_prompt(callback: CallbackQuery, state: FSMContext): + """Запросить время для напоминаний""" + await state.set_state(ReminderStates.waiting_for_time) + + await callback.message.edit_text( + "⏰ Установка времени напоминаний\n\n" + "Отправь время в формате HH:MM (UTC)\n\n" + "Примеры:\n" + "• 09:00 - 9 утра по UTC\n" + "• 18:30 - 18:30 по UTC\n" + "• 20:00 - 8 вечера по UTC\n\n" + "💡 UTC = МСК - 3 часа\n" + "(если хочешь 12:00 по МСК, введи 09:00)\n\n" + "Отправь /cancel для отмены" + ) + await callback.answer() + + +@router.message(Command("cancel"), ReminderStates.waiting_for_time) +async def cancel_set_time(message: Message, state: FSMContext): + """Отменить установку времени""" + await state.clear() + await message.answer("❌ Установка времени отменена") + + +@router.message(ReminderStates.waiting_for_time) +async def process_reminder_time(message: Message, state: FSMContext): + """Обработать введённое время""" + time_str = message.text.strip() + + # Валидация формата HH:MM + try: + parts = time_str.split(':') + if len(parts) != 2: + raise ValueError() + + hour, minute = int(parts[0]), int(parts[1]) + + if not (0 <= hour <= 23 and 0 <= minute <= 59): + raise ValueError() + + # Формат OK + formatted_time = f"{hour:02d}:{minute:02d}" + + except: + await message.answer( + "❌ Неверный формат времени!\n\n" + "Используй формат HH:MM (например, 09:00 или 18:30)\n" + "Или отправь /cancel для отмены" + ) + return + + # Сохраняем время + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + + user.daily_task_time = formatted_time + + # Автоматически включаем напоминания + user.reminders_enabled = True + + await session.commit() + + await state.clear() + + await message.answer( + f"✅ Время установлено!\n\n" + f"Напоминания: {formatted_time} UTC\n" + f"Статус: Включены\n\n" + f"Ты будешь получать ежедневные напоминания о практике.\n" + f"Используй /reminder для изменения настроек." + ) diff --git a/bot/handlers/start.py b/bot/handlers/start.py index ed22caa..5975188 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -1,6 +1,6 @@ from aiogram import Router, F from aiogram.filters import CommandStart, Command -from aiogram.types import Message +from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery from aiogram.fsm.context import FSMContext from database.db import async_session_maker @@ -24,16 +24,33 @@ async def cmd_start(message: Message, state: FSMContext): await message.answer( f"👋 Привет, {message.from_user.first_name}!\n\n" f"Я бот для изучения английского языка. Помогу тебе:\n" - f"📚 Пополнять словарный запас\n" - f"✍️ Выполнять ежедневные задания\n" - f"💬 Практиковать язык в диалоге\n\n" + f"📚 Пополнять словарный запас (ручное/тематическое/из текста)\n" + f"✍️ Выполнять интерактивные задания\n" + f"💬 Практиковать язык в диалоге с AI\n" + f"📊 Отслеживать свой прогресс\n\n" f"Основные команды:\n" f"/add [слово] - добавить слово в словарь\n" + f"/words [тема] - тематическая подборка\n" f"/vocabulary - мой словарь\n" f"/task - получить задание\n" + f"/practice - диалог с AI\n" f"/stats - статистика\n" + f"/settings - настройки\n" f"/help - справка\n\n" - f"Давай начнём! Отправь мне слово, которое хочешь выучить, или используй команду /add" + ) + + # Предлагаем пройти тест уровня + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📊 Пройти тест уровня", callback_data="offer_level_test")], + [InlineKeyboardButton(text="➡️ Пропустить", callback_data="skip_level_test")] + ]) + + await message.answer( + "🎯 Определим твой уровень?\n\n" + "Короткий тест (7 вопросов) поможет подобрать задания под твой уровень.\n" + "Это займёт 2-3 минуты.\n\n" + "Или можешь пропустить и установить уровень вручную позже в /settings", + reply_markup=keyboard ) else: # Существующий пользователь @@ -42,6 +59,7 @@ async def cmd_start(message: Message, state: FSMContext): f"Готов продолжить обучение?\n" f"/vocabulary - посмотреть словарь\n" f"/task - получить задание\n" + f"/practice - практика диалога\n" f"/stats - статистика" ) @@ -54,13 +72,39 @@ async def cmd_help(message: Message): "Управление словарём:\n" "/add [слово] - добавить слово в словарь\n" "/vocabulary - просмотр словаря\n" + "/words [тема] - тематическая подборка слов\n" "/import - импортировать слова из текста\n\n" "Обучение:\n" - "/task - получить задание\n" - "/practice - практика с ИИ\n\n" + "/task - получить задание (перевод, заполнение пропусков)\n" + "/practice - диалоговая практика с ИИ (6 сценариев)\n\n" "Статистика:\n" "/stats - твой прогресс\n\n" "Настройки:\n" - "/settings - настройки бота\n\n" + "/settings - настройки бота\n" + "/reminder - ежедневные напоминания\n\n" "Ты также можешь просто отправить мне слово, и я предложу добавить его в словарь!" ) + + +@router.callback_query(F.data == "offer_level_test") +async def offer_level_test_callback(callback: CallbackQuery, state: FSMContext): + """Начать тест уровня из приветствия""" + from bot.handlers.level_test import start_level_test + await callback.message.delete() + await start_level_test(callback.message, state) + await callback.answer() + + +@router.callback_query(F.data == "skip_level_test") +async def skip_level_test_callback(callback: CallbackQuery): + """Пропустить тест уровня""" + await callback.message.edit_text( + "✅ Хорошо!\n\n" + "Ты можешь пройти тест позже командой /level_test\n" + "или установить уровень вручную в /settings\n\n" + "Давай начнём! Попробуй:\n" + "• /words travel - тематическая подборка\n" + "• /practice - диалог с AI\n" + "• /add hello - добавить слово" + ) + await callback.answer() diff --git a/bot/handlers/words.py b/bot/handlers/words.py new file mode 100644 index 0000000..28f4408 --- /dev/null +++ b/bot/handlers/words.py @@ -0,0 +1,215 @@ +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup + +from database.db import async_session_maker +from database.models import WordSource +from services.user_service import UserService +from services.vocabulary_service import VocabularyService +from services.ai_service import ai_service + +router = Router() + + +class WordsStates(StatesGroup): + """Состояния для работы с тематическими подборками""" + viewing_words = State() + + +@router.message(Command("words")) +async def cmd_words(message: Message, state: FSMContext): + """Обработчик команды /words [тема]""" + 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("Сначала запусти бота командой /start") + return + + # Извлекаем тему из команды + command_parts = message.text.split(maxsplit=1) + + if len(command_parts) < 2: + await message.answer( + "📚 Тематические подборки слов\n\n" + "Используй: /words [тема]\n\n" + "Примеры:\n" + "• /words travel - путешествия\n" + "• /words food - еда\n" + "• /words work - работа\n" + "• /words nature - природа\n" + "• /words technology - технологии\n\n" + "Я сгенерирую 10 слов по теме, подходящих для твоего уровня!" + ) + return + + theme = command_parts[1].strip() + + # Показываем индикатор генерации + generating_msg = await message.answer(f"🔄 Генерирую подборку слов по теме '{theme}'...") + + # Генерируем слова через AI + words = await ai_service.generate_thematic_words( + theme=theme, + level=user.level.value, + count=10 + ) + + await generating_msg.delete() + + if not words: + await message.answer( + "❌ Не удалось сгенерировать подборку. Попробуй другую тему или повтори позже." + ) + return + + # Сохраняем данные в состоянии + await state.update_data( + theme=theme, + words=words, + user_id=user.id + ) + await state.set_state(WordsStates.viewing_words) + + # Показываем подборку + await show_words_list(message, words, theme) + + +async def show_words_list(message: Message, words: list, theme: str): + """Показать список слов с кнопками для добавления""" + + text = f"📚 Подборка слов: {theme}\n\n" + + for idx, word_data in enumerate(words, 1): + text += ( + f"{idx}. {word_data['word']} " + f"[{word_data.get('transcription', '')}]\n" + f" {word_data['translation']}\n" + f" {word_data.get('example', '')}\n\n" + ) + + text += "Выбери слова, которые хочешь добавить в словарь:" + + # Создаем кнопки для каждого слова (по 2 в ряд) + keyboard = [] + for idx, word_data in enumerate(words): + button = InlineKeyboardButton( + text=f"➕ {word_data['word']}", + callback_data=f"add_word_{idx}" + ) + + # Добавляем по 2 кнопки в ряд + if len(keyboard) == 0 or len(keyboard[-1]) == 2: + keyboard.append([button]) + else: + keyboard[-1].append(button) + + # Кнопка "Добавить все" + keyboard.append([ + InlineKeyboardButton(text="✅ Добавить все", callback_data="add_all_words") + ]) + + # Кнопка "Закрыть" + keyboard.append([ + InlineKeyboardButton(text="❌ Закрыть", callback_data="close_words") + ]) + + reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard) + await message.answer(text, reply_markup=reply_markup) + + +@router.callback_query(F.data.startswith("add_word_"), WordsStates.viewing_words) +async def add_single_word(callback: CallbackQuery, state: FSMContext): + """Добавить одно слово из подборки""" + word_index = int(callback.data.split("_")[2]) + + data = await state.get_data() + words = data.get('words', []) + user_id = data.get('user_id') + + if word_index >= len(words): + await callback.answer("❌ Ошибка: слово не найдено") + return + + word_data = words[word_index] + + async with async_session_maker() as session: + # Проверяем, нет ли уже такого слова + existing = await VocabularyService.get_word_by_original( + session, user_id, word_data['word'] + ) + + if existing: + await callback.answer(f"Слово '{word_data['word']}' уже в словаре", show_alert=True) + return + + # Добавляем слово + await VocabularyService.add_word( + session=session, + user_id=user_id, + word_original=word_data['word'], + word_translation=word_data['translation'], + transcription=word_data.get('transcription'), + examples=[{"en": word_data.get('example', ''), "ru": ""}] if word_data.get('example') else [], + source=WordSource.SUGGESTED, + category=data.get('theme', 'general'), + difficulty=None + ) + + await callback.answer(f"✅ Слово '{word_data['word']}' добавлено в словарь") + + +@router.callback_query(F.data == "add_all_words", WordsStates.viewing_words) +async def add_all_words(callback: CallbackQuery, state: FSMContext): + """Добавить все слова из подборки""" + data = await state.get_data() + words = data.get('words', []) + user_id = data.get('user_id') + theme = data.get('theme', 'general') + + added_count = 0 + skipped_count = 0 + + async with async_session_maker() as session: + for word_data in words: + # Проверяем, нет ли уже такого слова + existing = await VocabularyService.get_word_by_original( + session, user_id, word_data['word'] + ) + + if existing: + skipped_count += 1 + continue + + # Добавляем слово + await VocabularyService.add_word( + session=session, + user_id=user_id, + word_original=word_data['word'], + word_translation=word_data['translation'], + transcription=word_data.get('transcription'), + examples=[{"en": word_data.get('example', ''), "ru": ""}] if word_data.get('example') else [], + source=WordSource.SUGGESTED, + category=theme, + difficulty=None + ) + added_count += 1 + + result_text = f"✅ Добавлено слов: {added_count}" + if skipped_count > 0: + result_text += f"\n⚠️ Пропущено (уже в словаре): {skipped_count}" + + await callback.message.edit_reply_markup(reply_markup=None) + await callback.message.answer(result_text) + await state.clear() + await callback.answer() + + +@router.callback_query(F.data == "close_words", WordsStates.viewing_words) +async def close_words(callback: CallbackQuery, state: FSMContext): + """Закрыть подборку слов""" + await callback.message.delete() + await state.clear() + await callback.answer() diff --git a/database/models.py b/database/models.py index 1b3f9a5..ea420c6 100644 --- a/database/models.py +++ b/database/models.py @@ -42,6 +42,8 @@ class User(Base): level: Mapped[LanguageLevel] = mapped_column(SQLEnum(LanguageLevel), default=LanguageLevel.A1) timezone: Mapped[str] = mapped_column(String(50), default="UTC") daily_task_time: Mapped[Optional[str]] = mapped_column(String(5)) # HH:MM + reminders_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + last_reminder_sent: Mapped[Optional[datetime]] = mapped_column(DateTime) streak_days: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) last_active: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/main.py b/main.py index c7e139a..6296477 100644 --- a/main.py +++ b/main.py @@ -6,8 +6,9 @@ from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode from config.settings import settings -from bot.handlers import start, vocabulary, tasks, settings as settings_handler +from bot.handlers import start, vocabulary, tasks, settings as settings_handler, words, import_text, practice, reminder, level_test from database.db import init_db +from services.reminder_service import init_reminder_service async def main(): @@ -27,16 +28,29 @@ async def main(): # Регистрация роутеров dp.include_router(start.router) + dp.include_router(level_test.router) dp.include_router(vocabulary.router) dp.include_router(tasks.router) dp.include_router(settings_handler.router) + dp.include_router(words.router) + dp.include_router(import_text.router) + dp.include_router(practice.router) + dp.include_router(reminder.router) # Инициализация базы данных await init_db() + # Инициализация и запуск сервиса напоминаний + reminder_service = init_reminder_service(bot) + reminder_service.start() + # Запуск бота logging.info("Бот запущен") - await dp.start_polling(bot) + try: + await dp.start_polling(bot) + finally: + # Остановка планировщика при завершении + reminder_service.shutdown() if __name__ == '__main__': diff --git a/requirements.txt b/requirements.txt index 673e3d5..d645cd2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ python-dotenv==1.0.1 openai==1.57.3 pydantic>=2.4.1,<2.10 pydantic-settings==2.6.1 +apscheduler==3.10.4 diff --git a/services/ai_service.py b/services/ai_service.py index e31757d..078e67f 100644 --- a/services/ai_service.py +++ b/services/ai_service.py @@ -171,6 +171,361 @@ class AIService: "translation": f"Мне нравится {word} каждый день." } + async def generate_thematic_words(self, theme: str, level: str = "B1", count: int = 10) -> List[Dict]: + """ + Сгенерировать подборку слов по теме + + Args: + theme: Тема для подборки слов + level: Уровень сложности (A1-C2) + count: Количество слов + + Returns: + Список словарей с информацией о словах + """ + prompt = f"""Создай подборку из {count} английских слов по теме "{theme}" для уровня {level}. + +Верни ответ в формате JSON: +{{ + "theme": "{theme}", + "words": [ + {{ + "word": "английское слово", + "translation": "перевод на русский", + "transcription": "транскрипция в IPA", + "example": "пример использования на английском" + }} + ] +}} + +Слова должны быть: +- Полезными и часто используемыми +- Соответствовать уровню {level} +- Связаны с темой "{theme}" +- Разнообразными (существительные, глаголы, прилагательные)""" + + try: + response = await self.client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": "Ты - преподаватель английского языка. Подбирай полезные и актуальные слова."}, + {"role": "user", "content": prompt} + ], + temperature=0.7, + response_format={"type": "json_object"} + ) + + import json + result = json.loads(response.choices[0].message.content) + return result.get('words', []) + + except Exception as e: + return [] + + async def extract_words_from_text(self, text: str, level: str = "B1", max_words: int = 15) -> List[Dict]: + """ + Извлечь ключевые слова из текста для изучения + + Args: + text: Текст на английском языке + level: Уровень пользователя (A1-C2) + max_words: Максимальное количество слов для извлечения + + Returns: + Список словарей с информацией о словах + """ + prompt = f"""Проанализируй следующий английский текст и извлеки из него до {max_words} самых полезных слов для изучения на уровне {level}. + +Текст: +{text} + +Верни ответ в формате JSON: +{{ + "words": [ + {{ + "word": "английское слово (в базовой форме)", + "translation": "перевод на русский", + "transcription": "транскрипция в IPA", + "context": "предложение из текста, где используется это слово" + }} + ] +}} + +Критерии отбора слов: +- Выбирай самые важные и полезные слова из текста +- Слова должны быть интересны для уровня {level} +- Не включай простейшие слова (a, the, is, и т.д.) +- Слова должны быть в базовой форме (инфинитив для глаголов, ед.число для существительных) +- Разнообразие: существительные, глаголы, прилагательные, устойчивые выражения""" + + try: + response = await self.client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": "Ты - преподаватель английского языка. Помогаешь извлекать полезные слова для изучения из текстов."}, + {"role": "user", "content": prompt} + ], + temperature=0.5, + response_format={"type": "json_object"} + ) + + import json + result = json.loads(response.choices[0].message.content) + return result.get('words', []) + + except Exception as e: + return [] + + async def start_conversation(self, scenario: str, level: str = "B1") -> Dict: + """ + Начать диалоговую практику с AI + + Args: + scenario: Сценарий диалога (restaurant, shopping, travel, etc.) + level: Уровень пользователя (A1-C2) + + Returns: + Dict с начальной репликой и контекстом + """ + scenarios = { + "restaurant": "ресторан - заказ еды", + "shopping": "магазин - покупка одежды", + "travel": "аэропорт/отель - путешествие", + "work": "офис - рабочая встреча", + "doctor": "клиника - визит к врачу", + "casual": "повседневный разговор" + } + + scenario_desc = scenarios.get(scenario, "повседневный разговор") + + prompt = f"""Ты - собеседник для практики английского языка уровня {level}. +Начни диалог в сценарии: {scenario_desc}. + +Верни ответ в формате JSON: +{{ + "message": "твоя первая реплика на английском", + "translation": "перевод на русский", + "context": "краткое описание ситуации на русском", + "suggestions": ["подсказка 1", "подсказка 2", "подсказка 3"] +}} + +Требования: +- Говори естественно, используй уровень {level} +- Создай интересную ситуацию +- Задай вопрос или начни разговор +- Подсказки должны помочь пользователю ответить""" + + try: + response = await self.client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": "Ты - дружелюбный собеседник для практики английского. Веди естественный диалог."}, + {"role": "user", "content": prompt} + ], + temperature=0.8, + response_format={"type": "json_object"} + ) + + import json + result = json.loads(response.choices[0].message.content) + return result + + except Exception as e: + return { + "message": "Hello! How are you today?", + "translation": "Привет! Как дела сегодня?", + "context": "Повседневный разговор", + "suggestions": ["I'm fine, thank you!", "Good, and you?", "Not bad!"] + } + + async def continue_conversation( + self, + conversation_history: List[Dict], + user_message: str, + scenario: str, + level: str = "B1" + ) -> Dict: + """ + Продолжить диалог и проверить ответ пользователя + + Args: + conversation_history: История диалога + user_message: Сообщение пользователя + scenario: Сценарий диалога + level: Уровень пользователя + + Returns: + Dict с ответом AI, проверкой и подсказками + """ + # Формируем историю для контекста + history_text = "\n".join([ + f"{'AI' if msg['role'] == 'assistant' else 'User'}: {msg['content']}" + for msg in conversation_history[-6:] # Последние 6 сообщений + ]) + + prompt = f"""Ты ведешь диалог на английском языке уровня {level} в сценарии "{scenario}". + +История диалога: +{history_text} +User: {user_message} + +Верни ответ в формате JSON: +{{ + "response": "твой ответ на английском", + "translation": "перевод твоего ответа на русский", + "feedback": {{ + "has_errors": true/false, + "corrections": "исправления ошибок пользователя (если есть)", + "comment": "краткий комментарий об ответе пользователя" + }}, + "suggestions": ["подсказка 1 для следующего ответа", "подсказка 2"] +}} + +Требования: +- Продолжай естественный диалог +- Если у пользователя есть грамматические или лексические ошибки, укажи их в corrections +- Будь дружелюбным и поддерживающим +- Используй лексику уровня {level}""" + + try: + # Формируем сообщения для API + messages = [ + {"role": "system", "content": f"Ты - дружелюбный собеседник для практики английского языка уровня {level}. Веди естественный диалог и помогай исправлять ошибки."} + ] + + # Добавляем историю + for msg in conversation_history[-6:]: + messages.append(msg) + + # Добавляем текущее сообщение пользователя + messages.append({"role": "user", "content": user_message}) + + # Добавляем инструкцию для форматирования ответа + messages.append({"role": "user", "content": prompt}) + + response = await self.client.chat.completions.create( + model="gpt-4o-mini", + messages=messages, + temperature=0.8, + response_format={"type": "json_object"} + ) + + import json + result = json.loads(response.choices[0].message.content) + return result + + except Exception as e: + return { + "response": "I see. Tell me more about that.", + "translation": "Понятно. Расскажи мне больше об этом.", + "feedback": { + "has_errors": False, + "corrections": "", + "comment": "Good!" + }, + "suggestions": ["Sure!", "Well...", "Actually..."] + } + + async def generate_level_test(self) -> List[Dict]: + """ + Сгенерировать тест для определения уровня английского + + Returns: + Список из 7 вопросов разной сложности + """ + prompt = """Создай тест из 7 вопросов для определения уровня английского языка (A1-C2). + +Верни ответ в формате JSON: +{ + "questions": [ + { + "question": "текст вопроса на английском", + "question_ru": "перевод вопроса на русский", + "options": ["вариант A", "вариант B", "вариант C", "вариант D"], + "correct": 0, + "level": "A1" + } + ] +} + +Требования: +- Вопросы 1-2: уровень A1 (базовый) +- Вопросы 3-4: уровень A2-B1 (элементарный-средний) +- Вопросы 5-6: уровень B2-C1 (продвинутый) +- Вопрос 7: уровень C2 (профессиональный) +- Каждый вопрос с 4 вариантами ответа +- correct - индекс правильного ответа (0-3) +- Вопросы на грамматику, лексику и понимание""" + + try: + response = await self.client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": "Ты - эксперт по тестированию уровня английского языка. Создавай объективные тесты."}, + {"role": "user", "content": prompt} + ], + temperature=0.7, + response_format={"type": "json_object"} + ) + + import json + result = json.loads(response.choices[0].message.content) + return result.get('questions', []) + + except Exception as e: + # Fallback с базовыми вопросами + return [ + { + "question": "What is your name?", + "question_ru": "Как тебя зовут?", + "options": ["My name is", "I am name", "Name my is", "Is name my"], + "correct": 0, + "level": "A1" + }, + { + "question": "I ___ to school every day.", + "question_ru": "Я ___ в школу каждый день.", + "options": ["go", "goes", "going", "went"], + "correct": 0, + "level": "A1" + }, + { + "question": "She ___ been to Paris twice.", + "question_ru": "Она ___ в Париже дважды.", + "options": ["have", "has", "had", "having"], + "correct": 1, + "level": "A2" + }, + { + "question": "If I ___ rich, I would travel the world.", + "question_ru": "Если бы я был богат, я бы путешествовал по миру.", + "options": ["am", "was", "were", "be"], + "correct": 2, + "level": "B1" + }, + { + "question": "The project ___ by next Monday.", + "question_ru": "Проект ___ к следующему понедельнику.", + "options": ["will complete", "will be completed", "completes", "is completing"], + "correct": 1, + "level": "B2" + }, + { + "question": "Had I known about the meeting, I ___ attended.", + "question_ru": "Если бы я знал о встрече, я бы посетил.", + "options": ["would have", "will have", "would", "will"], + "correct": 0, + "level": "C1" + }, + { + "question": "The nuances of his argument were so ___ that few could grasp them.", + "question_ru": "Нюансы его аргумента были настолько ___, что немногие могли их понять.", + "options": ["subtle", "obvious", "simple", "clear"], + "correct": 0, + "level": "C2" + } + ] + # Глобальный экземпляр сервиса ai_service = AIService() diff --git a/services/reminder_service.py b/services/reminder_service.py new file mode 100644 index 0000000..b4b6889 --- /dev/null +++ b/services/reminder_service.py @@ -0,0 +1,141 @@ +import logging +from datetime import datetime, timedelta +from typing import List +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.db import async_session_maker + +logger = logging.getLogger(__name__) + + +class ReminderService: + """Сервис для управления напоминаниями""" + + def __init__(self, bot): + self.bot = bot + self.scheduler = AsyncIOScheduler() + + def start(self): + """Запустить планировщик""" + # Проверяем напоминания каждые 5 минут + self.scheduler.add_job( + self.check_and_send_reminders, + trigger='interval', + minutes=5, + id='check_reminders', + replace_existing=True + ) + + self.scheduler.start() + logger.info("Планировщик напоминаний запущен") + + def shutdown(self): + """Остановить планировщик""" + self.scheduler.shutdown() + logger.info("Планировщик напоминаний остановлен") + + async def check_and_send_reminders(self): + """Проверить и отправить напоминания пользователям""" + try: + async with async_session_maker() as session: + # Получаем всех пользователей с включенными напоминаниями + result = await session.execute( + select(User).where( + User.reminders_enabled == True, + User.daily_task_time.isnot(None) + ) + ) + users = list(result.scalars().all()) + + current_time = datetime.utcnow() + + for user in users: + if await self._should_send_reminder(user, current_time): + await self._send_reminder(user, session) + + except Exception as e: + logger.error(f"Ошибка при проверке напоминаний: {e}") + + async def _should_send_reminder(self, user: User, current_time: datetime) -> bool: + """ + Проверить, нужно ли отправлять напоминание пользователю + + Args: + user: Пользователь + current_time: Текущее время (UTC) + + Returns: + True если нужно отправить + """ + if not user.daily_task_time: + return False + + # Парсим время напоминания (формат HH:MM) + try: + hour, minute = map(int, user.daily_task_time.split(':')) + except: + return False + + # Создаем datetime для времени напоминания сегодня (UTC) + reminder_time = current_time.replace(hour=hour, minute=minute, second=0, microsecond=0) + + # Проверяем, не отправляли ли мы уже напоминание сегодня + if user.last_reminder_sent: + last_sent_date = user.last_reminder_sent.date() + current_date = current_time.date() + + # Если уже отправляли сегодня, не отправляем снова + if last_sent_date == current_date: + return False + + # Проверяем, наступило ли время напоминания (с погрешностью 5 минут) + time_diff = abs((current_time - reminder_time).total_seconds()) + + return time_diff < 300 # 5 минут в секундах + + async def _send_reminder(self, user: User, session: AsyncSession): + """ + Отправить напоминание пользователю + + Args: + user: Пользователь + session: Сессия базы данных + """ + try: + message_text = ( + "⏰ Время для практики!\n\n" + "Не забудь потренироваться сегодня:\n" + "• /task - выполни задания\n" + "• /practice - попрактикуй диалог\n" + "• /words - добавь новые слова\n\n" + "💪 Регулярная практика - ключ к успеху!" + ) + + await self.bot.send_message( + chat_id=user.telegram_id, + text=message_text + ) + + # Обновляем время последнего напоминания + user.last_reminder_sent = datetime.utcnow() + await session.commit() + + logger.info(f"Напоминание отправлено пользователю {user.telegram_id}") + + except Exception as e: + logger.error(f"Ошибка при отправке напоминания пользователю {user.telegram_id}: {e}") + + +# Глобальный экземпляр сервиса (будет инициализирован в main.py) +reminder_service: ReminderService = None + + +def init_reminder_service(bot): + """Инициализировать сервис напоминаний""" + global reminder_service + reminder_service = ReminderService(bot) + return reminder_service diff --git a/services/vocabulary_service.py b/services/vocabulary_service.py index 2ecd651..4246620 100644 --- a/services/vocabulary_service.py +++ b/services/vocabulary_service.py @@ -121,3 +121,23 @@ class VocabularyService: .where(Vocabulary.word_original.ilike(f"%{word}%")) ) return result.scalar_one_or_none() + + @staticmethod + async def get_word_by_original(session: AsyncSession, user_id: int, word: str) -> Optional[Vocabulary]: + """ + Получить слово по точному совпадению + + Args: + session: Сессия базы данных + user_id: ID пользователя + word: Слово для поиска (точное совпадение) + + Returns: + Объект слова или None + """ + result = await session.execute( + select(Vocabulary) + .where(Vocabulary.user_id == user_id) + .where(Vocabulary.word_original == word.lower()) + ) + return result.scalar_one_or_none()