Добавлены основные функции MVP: тематические подборки, импорт слов, диалоговая практика, напоминания и тест уровня
Новые команды: - /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 <noreply@anthropic.com>
This commit is contained in:
80
README.md
80
README.md
@@ -4,17 +4,25 @@
|
|||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
|
- 🎯 Автоматический тест определения уровня (A1-C2)
|
||||||
- 📚 Управление словарным запасом с автоматическим переводом через AI
|
- 📚 Управление словарным запасом с автоматическим переводом через AI
|
||||||
- ✍️ Интерактивные задания на перевод слов с проверкой через AI
|
- 📖 Тематические подборки и извлечение слов из текстов
|
||||||
|
- ✍️ Интерактивные задания 3-х типов с проверкой через AI
|
||||||
|
- 💬 Диалоговая практика с ИИ в разных сценариях
|
||||||
- 📊 Статистика прогресса и точность ответов
|
- 📊 Статистика прогресса и точность ответов
|
||||||
- 🔄 Умные повторения (spaced repetition)
|
- 🔄 Умные повторения (spaced repetition)
|
||||||
- 💬 Диалоговая практика с ИИ (в разработке)
|
- ⏰ Ежедневные напоминания по расписанию
|
||||||
|
- ⚙️ Адаптивная сложность под твой уровень
|
||||||
|
|
||||||
## Текущая версия (MVP)
|
## Текущая версия (MVP)
|
||||||
|
|
||||||
**Реализовано:**
|
**Реализовано:**
|
||||||
- ✅ `/start` - регистрация и приветствие пользователя
|
- ✅ `/start` - регистрация и приветствие пользователя
|
||||||
|
- ✅ `/level_test` - тест для определения уровня английского (7 вопросов, автоматическое определение A1-C2)
|
||||||
- ✅ `/add [слово]` - добавление слов в словарь с AI-переводом, транскрипцией и примерами
|
- ✅ `/add [слово]` - добавление слов в словарь с AI-переводом, транскрипцией и примерами
|
||||||
|
- ✅ `/words [тема]` - AI-генерация тематических подборок слов (travel, food, work и т.д.)
|
||||||
|
- ✅ `/import` - извлечение слов из текста с помощью AI (книги, статьи, тексты песен)
|
||||||
|
- ✅ `/practice` - диалоговая практика с AI (6 сценариев: ресторан, магазин, путешествие, работа, врач, общение)
|
||||||
- ✅ `/vocabulary` - просмотр личного словаря (последние 10 слов)
|
- ✅ `/vocabulary` - просмотр личного словаря (последние 10 слов)
|
||||||
- ✅ `/task` - интерактивные задания 3-х типов:
|
- ✅ `/task` - интерактивные задания 3-х типов:
|
||||||
- Перевод EN→RU
|
- Перевод EN→RU
|
||||||
@@ -22,10 +30,14 @@
|
|||||||
- Заполнение пропусков в предложениях
|
- Заполнение пропусков в предложениях
|
||||||
- ✅ `/stats` - детальная статистика обучения
|
- ✅ `/stats` - детальная статистика обучения
|
||||||
- ✅ `/settings` - настройки пользователя (уровень A1-C2, язык интерфейса)
|
- ✅ `/settings` - настройки пользователя (уровень A1-C2, язык интерфейса)
|
||||||
|
- ✅ `/reminder` - ежедневные напоминания о практике (настройка времени и включение/выключение)
|
||||||
- ✅ `/help` - справка по командам
|
- ✅ `/help` - справка по командам
|
||||||
- ✅ Проверка ответов через AI с детальной обратной связью
|
- ✅ Проверка ответов через AI с детальной обратной связью
|
||||||
- ✅ AI-генерация предложений для практики в контексте
|
- ✅ AI-генерация предложений для практики в контексте
|
||||||
|
- ✅ Исправление ошибок в реальном времени во время диалога
|
||||||
|
- ✅ Автоматический тест уровня при первом запуске
|
||||||
- ✅ Отслеживание прогресса по каждому слову
|
- ✅ Отслеживание прогресса по каждому слову
|
||||||
|
- ✅ Автоматические ежедневные напоминания по расписанию
|
||||||
- ✅ База данных (PostgreSQL) для хранения данных
|
- ✅ База данных (PostgreSQL) для хранения данных
|
||||||
- ✅ Docker-развёртывание (полное и только БД)
|
- ✅ Docker-развёртывание (полное и только БД)
|
||||||
|
|
||||||
@@ -216,29 +228,71 @@ bot_tg_language/
|
|||||||
### Команды бота
|
### Команды бота
|
||||||
|
|
||||||
- `/start` - Начать работу с ботом
|
- `/start` - Начать работу с ботом
|
||||||
|
- `/level_test` - Пройти тест определения уровня
|
||||||
- `/add [слово]` - Добавить слово в словарь
|
- `/add [слово]` - Добавить слово в словарь
|
||||||
|
- `/words [тема]` - Получить тематическую подборку слов
|
||||||
|
- `/import` - Извлечь слова из текста
|
||||||
|
- `/practice` - Диалоговая практика с AI
|
||||||
- `/vocabulary` - Посмотреть свой словарь
|
- `/vocabulary` - Посмотреть свой словарь
|
||||||
|
- `/task` - Получить интерактивное задание
|
||||||
|
- `/stats` - Просмотреть статистику
|
||||||
|
- `/settings` - Настройки бота
|
||||||
|
- `/reminder` - Настроить ежедневные напоминания
|
||||||
- `/help` - Показать справку
|
- `/help` - Показать справку
|
||||||
|
|
||||||
### Пример использования
|
### Пример использования
|
||||||
|
|
||||||
1. Запустите бота: `/start`
|
1. Запустите бота: `/start`
|
||||||
2. Добавьте слово: `/add elephant`
|
2. Пройдите тест уровня (или пропустите)
|
||||||
3. Бот переведёт слово через AI и предложит добавить в словарь
|
3. Добавьте слово: `/add elephant`
|
||||||
4. Подтвердите добавление
|
4. Бот переведёт слово через AI и предложит добавить в словарь
|
||||||
5. Просмотрите словарь: `/vocabulary`
|
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
|
## Roadmap
|
||||||
|
|
||||||
См. [TZ.md](TZ.md) для полного технического задания.
|
См. [TZ.md](TZ.md) для полного технического задания.
|
||||||
|
|
||||||
**Следующие этапы:**
|
**MVP завершён! 🎉**
|
||||||
- [ ] Ежедневные задания с разными типами упражнений
|
|
||||||
- [ ] Тематические подборки слов
|
Все основные функции реализованы:
|
||||||
- [ ] Импорт слов из текста
|
- [x] Ежедневные задания с разными типами упражнений
|
||||||
- [ ] Диалоговая практика с AI
|
- [x] Тематические подборки слов
|
||||||
- [ ] Статистика и прогресс
|
- [x] Импорт слов из текста
|
||||||
- [ ] Spaced repetition алгоритм
|
- [x] Диалоговая практика с AI
|
||||||
|
- [x] Статистика и прогресс
|
||||||
|
- [x] Spaced repetition алгоритм (базовая версия)
|
||||||
|
- [x] Напоминания и ежедневные задания по расписанию
|
||||||
|
|
||||||
|
**Следующие улучшения:**
|
||||||
|
- [ ] Экспорт словаря (PDF, Anki, CSV)
|
||||||
|
- [ ] Голосовые сообщения для практики произношения
|
||||||
|
- [ ] Групповые челленджи и лидерборды
|
||||||
|
- [ ] Gamification (стрики, достижения, уровни)
|
||||||
|
- [ ] Расширенная аналитика с графиками
|
||||||
|
|
||||||
## Cloudflare AI Gateway (опционально)
|
## Cloudflare AI Gateway (опционально)
|
||||||
|
|
||||||
|
|||||||
248
bot/handlers/import_text.py
Normal file
248
bot/handlers/import_text.py
Normal file
@@ -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(
|
||||||
|
"📖 <b>Импорт слов из текста</b>\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"📚 <b>Найдено слов: {len(words)}</b>\n\n"
|
||||||
|
|
||||||
|
for idx, word_data in enumerate(words, 1):
|
||||||
|
text += (
|
||||||
|
f"{idx}. <b>{word_data['word']}</b> "
|
||||||
|
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" <i>«{context}»</i>\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"✅ Добавлено слов: <b>{added_count}</b>"
|
||||||
|
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()
|
||||||
264
bot/handlers/level_test.py
Normal file
264
bot/handlers/level_test.py
Normal file
@@ -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(
|
||||||
|
"📊 <b>Тест определения уровня</b>\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"❓ <b>Вопрос {current_idx + 1} из {len(questions)}</b>\n\n"
|
||||||
|
f"<b>{question['question']}</b>\n"
|
||||||
|
f"<i>{question.get('question_ru', '')}</i>\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Правильный ответ: <b>{correct_option}</b>"
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"❓ <b>Вопрос {current_idx + 1} из {len(questions)}</b>\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"🎉 <b>Тест завершён!</b>\n\n"
|
||||||
|
f"📊 Результаты:\n"
|
||||||
|
f"Правильных ответов: <b>{correct_answers}</b> из {total}\n"
|
||||||
|
f"Точность: <b>{accuracy}%</b>\n\n"
|
||||||
|
f"🎯 Твой уровень: <b>{level.value}</b>\n"
|
||||||
|
f"<i>{level_descriptions.get(level.value, '')}</i>\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]
|
||||||
228
bot/handlers/practice.py
Normal file
228
bot/handlers/practice.py
Normal file
@@ -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(
|
||||||
|
"💬 <b>Диалоговая практика с AI</b>\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"<b>{SCENARIOS[scenario]}</b>\n\n"
|
||||||
|
f"📝 <i>{conversation_start.get('context', '')}</i>\n\n"
|
||||||
|
f"<b>AI:</b> {conversation_start.get('message', '')}\n"
|
||||||
|
f"<i>({conversation_start.get('translation', '')})</i>\n\n"
|
||||||
|
"💡 <b>Подсказки:</b>\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"✅ <b>Диалог завершён!</b>\n\n"
|
||||||
|
f"Сообщений обменено: <b>{message_count}</b>\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"✅ <b>Диалог завершён!</b>\n\n"
|
||||||
|
f"Сообщений обменено: <b>{message_count}</b>\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"⚠️ <b>Исправления:</b>\n{feedback['corrections']}\n\n"
|
||||||
|
|
||||||
|
if feedback.get('comment'):
|
||||||
|
text += f"💬 {feedback['comment']}\n\n"
|
||||||
|
|
||||||
|
# Ответ AI
|
||||||
|
text += (
|
||||||
|
f"<b>AI:</b> {ai_response.get('response', '')}\n"
|
||||||
|
f"<i>({ai_response.get('translation', '')})</i>\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Подсказки
|
||||||
|
suggestions = ai_response.get('suggestions', [])
|
||||||
|
if suggestions:
|
||||||
|
text += "💡 <b>Подсказки:</b>\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)
|
||||||
172
bot/handlers/reminder.py
Normal file
172
bot/handlers/reminder.py
Normal file
@@ -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"⏰ <b>Напоминания</b>\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"✅ <b>Напоминания включены!</b>\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(
|
||||||
|
"❌ <b>Напоминания выключены</b>\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(
|
||||||
|
"⏰ <b>Установка времени напоминаний</b>\n\n"
|
||||||
|
"Отправь время в формате <b>HH:MM</b> (UTC)\n\n"
|
||||||
|
"Примеры:\n"
|
||||||
|
"• <code>09:00</code> - 9 утра по UTC\n"
|
||||||
|
"• <code>18:30</code> - 18:30 по UTC\n"
|
||||||
|
"• <code>20:00</code> - 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"
|
||||||
|
"Используй формат <b>HH:MM</b> (например, 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"✅ <b>Время установлено!</b>\n\n"
|
||||||
|
f"Напоминания: <b>{formatted_time} UTC</b>\n"
|
||||||
|
f"Статус: <b>Включены</b>\n\n"
|
||||||
|
f"Ты будешь получать ежедневные напоминания о практике.\n"
|
||||||
|
f"Используй /reminder для изменения настроек."
|
||||||
|
)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.filters import CommandStart, Command
|
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 aiogram.fsm.context import FSMContext
|
||||||
|
|
||||||
from database.db import async_session_maker
|
from database.db import async_session_maker
|
||||||
@@ -24,16 +24,33 @@ async def cmd_start(message: Message, state: FSMContext):
|
|||||||
await message.answer(
|
await message.answer(
|
||||||
f"👋 Привет, {message.from_user.first_name}!\n\n"
|
f"👋 Привет, {message.from_user.first_name}!\n\n"
|
||||||
f"Я бот для изучения английского языка. Помогу тебе:\n"
|
f"Я бот для изучения английского языка. Помогу тебе:\n"
|
||||||
f"📚 Пополнять словарный запас\n"
|
f"📚 Пополнять словарный запас (ручное/тематическое/из текста)\n"
|
||||||
f"✍️ Выполнять ежедневные задания\n"
|
f"✍️ Выполнять интерактивные задания\n"
|
||||||
f"💬 Практиковать язык в диалоге\n\n"
|
f"💬 Практиковать язык в диалоге с AI\n"
|
||||||
|
f"📊 Отслеживать свой прогресс\n\n"
|
||||||
f"<b>Основные команды:</b>\n"
|
f"<b>Основные команды:</b>\n"
|
||||||
f"/add [слово] - добавить слово в словарь\n"
|
f"/add [слово] - добавить слово в словарь\n"
|
||||||
|
f"/words [тема] - тематическая подборка\n"
|
||||||
f"/vocabulary - мой словарь\n"
|
f"/vocabulary - мой словарь\n"
|
||||||
f"/task - получить задание\n"
|
f"/task - получить задание\n"
|
||||||
|
f"/practice - диалог с AI\n"
|
||||||
f"/stats - статистика\n"
|
f"/stats - статистика\n"
|
||||||
|
f"/settings - настройки\n"
|
||||||
f"/help - справка\n\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(
|
||||||
|
"🎯 <b>Определим твой уровень?</b>\n\n"
|
||||||
|
"Короткий тест (7 вопросов) поможет подобрать задания под твой уровень.\n"
|
||||||
|
"Это займёт 2-3 минуты.\n\n"
|
||||||
|
"Или можешь пропустить и установить уровень вручную позже в /settings",
|
||||||
|
reply_markup=keyboard
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Существующий пользователь
|
# Существующий пользователь
|
||||||
@@ -42,6 +59,7 @@ async def cmd_start(message: Message, state: FSMContext):
|
|||||||
f"Готов продолжить обучение?\n"
|
f"Готов продолжить обучение?\n"
|
||||||
f"/vocabulary - посмотреть словарь\n"
|
f"/vocabulary - посмотреть словарь\n"
|
||||||
f"/task - получить задание\n"
|
f"/task - получить задание\n"
|
||||||
|
f"/practice - практика диалога\n"
|
||||||
f"/stats - статистика"
|
f"/stats - статистика"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,13 +72,39 @@ async def cmd_help(message: Message):
|
|||||||
"<b>Управление словарём:</b>\n"
|
"<b>Управление словарём:</b>\n"
|
||||||
"/add [слово] - добавить слово в словарь\n"
|
"/add [слово] - добавить слово в словарь\n"
|
||||||
"/vocabulary - просмотр словаря\n"
|
"/vocabulary - просмотр словаря\n"
|
||||||
|
"/words [тема] - тематическая подборка слов\n"
|
||||||
"/import - импортировать слова из текста\n\n"
|
"/import - импортировать слова из текста\n\n"
|
||||||
"<b>Обучение:</b>\n"
|
"<b>Обучение:</b>\n"
|
||||||
"/task - получить задание\n"
|
"/task - получить задание (перевод, заполнение пропусков)\n"
|
||||||
"/practice - практика с ИИ\n\n"
|
"/practice - диалоговая практика с ИИ (6 сценариев)\n\n"
|
||||||
"<b>Статистика:</b>\n"
|
"<b>Статистика:</b>\n"
|
||||||
"/stats - твой прогресс\n\n"
|
"/stats - твой прогресс\n\n"
|
||||||
"<b>Настройки:</b>\n"
|
"<b>Настройки:</b>\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()
|
||||||
|
|||||||
215
bot/handlers/words.py
Normal file
215
bot/handlers/words.py
Normal file
@@ -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(
|
||||||
|
"📚 <b>Тематические подборки слов</b>\n\n"
|
||||||
|
"Используй: <code>/words [тема]</code>\n\n"
|
||||||
|
"Примеры:\n"
|
||||||
|
"• <code>/words travel</code> - путешествия\n"
|
||||||
|
"• <code>/words food</code> - еда\n"
|
||||||
|
"• <code>/words work</code> - работа\n"
|
||||||
|
"• <code>/words nature</code> - природа\n"
|
||||||
|
"• <code>/words technology</code> - технологии\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"📚 <b>Подборка слов: {theme}</b>\n\n"
|
||||||
|
|
||||||
|
for idx, word_data in enumerate(words, 1):
|
||||||
|
text += (
|
||||||
|
f"{idx}. <b>{word_data['word']}</b> "
|
||||||
|
f"[{word_data.get('transcription', '')}]\n"
|
||||||
|
f" {word_data['translation']}\n"
|
||||||
|
f" <i>{word_data.get('example', '')}</i>\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"✅ Добавлено слов: <b>{added_count}</b>"
|
||||||
|
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()
|
||||||
@@ -42,6 +42,8 @@ class User(Base):
|
|||||||
level: Mapped[LanguageLevel] = mapped_column(SQLEnum(LanguageLevel), default=LanguageLevel.A1)
|
level: Mapped[LanguageLevel] = mapped_column(SQLEnum(LanguageLevel), default=LanguageLevel.A1)
|
||||||
timezone: Mapped[str] = mapped_column(String(50), default="UTC")
|
timezone: Mapped[str] = mapped_column(String(50), default="UTC")
|
||||||
daily_task_time: Mapped[Optional[str]] = mapped_column(String(5)) # HH:MM
|
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)
|
streak_days: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
last_active: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
last_active: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|||||||
16
main.py
16
main.py
@@ -6,8 +6,9 @@ from aiogram.client.default import DefaultBotProperties
|
|||||||
from aiogram.enums import ParseMode
|
from aiogram.enums import ParseMode
|
||||||
|
|
||||||
from config.settings import settings
|
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 database.db import init_db
|
||||||
|
from services.reminder_service import init_reminder_service
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
@@ -27,16 +28,29 @@ async def main():
|
|||||||
|
|
||||||
# Регистрация роутеров
|
# Регистрация роутеров
|
||||||
dp.include_router(start.router)
|
dp.include_router(start.router)
|
||||||
|
dp.include_router(level_test.router)
|
||||||
dp.include_router(vocabulary.router)
|
dp.include_router(vocabulary.router)
|
||||||
dp.include_router(tasks.router)
|
dp.include_router(tasks.router)
|
||||||
dp.include_router(settings_handler.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()
|
await init_db()
|
||||||
|
|
||||||
|
# Инициализация и запуск сервиса напоминаний
|
||||||
|
reminder_service = init_reminder_service(bot)
|
||||||
|
reminder_service.start()
|
||||||
|
|
||||||
# Запуск бота
|
# Запуск бота
|
||||||
logging.info("Бот запущен")
|
logging.info("Бот запущен")
|
||||||
|
try:
|
||||||
await dp.start_polling(bot)
|
await dp.start_polling(bot)
|
||||||
|
finally:
|
||||||
|
# Остановка планировщика при завершении
|
||||||
|
reminder_service.shutdown()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ python-dotenv==1.0.1
|
|||||||
openai==1.57.3
|
openai==1.57.3
|
||||||
pydantic>=2.4.1,<2.10
|
pydantic>=2.4.1,<2.10
|
||||||
pydantic-settings==2.6.1
|
pydantic-settings==2.6.1
|
||||||
|
apscheduler==3.10.4
|
||||||
|
|||||||
@@ -171,6 +171,361 @@ class AIService:
|
|||||||
"translation": f"Мне нравится {word} каждый день."
|
"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()
|
ai_service = AIService()
|
||||||
|
|||||||
141
services/reminder_service.py
Normal file
141
services/reminder_service.py
Normal file
@@ -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 = (
|
||||||
|
"⏰ <b>Время для практики!</b>\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
|
||||||
@@ -121,3 +121,23 @@ class VocabularyService:
|
|||||||
.where(Vocabulary.word_original.ilike(f"%{word}%"))
|
.where(Vocabulary.word_original.ilike(f"%{word}%"))
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user