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()