From eb666ec9bce7451c2af264c706ca27a39574e0ac Mon Sep 17 00:00:00 2001 From: "mamonov.ep" Date: Mon, 8 Dec 2025 15:16:24 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BC=D1=83=D0=BB=D1=8C=D1=82=D0=B8-?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B0=D0=B9=D0=B4=D0=B5=D1=80=20AI,=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D1=82=D0=B8=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B4=D0=B0=D0=BD=D0=B8=D0=B9,=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=20=D0=BA=D0=BE=D0=BB?= =?UTF-8?q?=D0=B8=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлена поддержка нескольких AI провайдеров (OpenAI, Google Gemini) - Добавлена админ-панель (/admin) для переключения AI моделей - Добавлен AIModelService для управления моделями в БД - Добавлен выбор типа заданий (микс, перевод слов, подстановка, перевод предложений) - Добавлена настройка количества заданий (5-15) - ai_service динамически выбирает провайдера на основе активной модели - Обработка ограничений моделей (temperature, response_format) - Очистка markdown обёртки из ответов Gemini 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .env.example | 7 + Makefile | 18 +- bot/handlers/admin.py | 102 +++++++ bot/handlers/settings.py | 58 ++++ bot/handlers/tasks.py | 279 +++++++++++++----- config/settings.py | 4 + database/models.py | 19 ++ locales/en.json | 9 + locales/ja.json | 9 + locales/ru.json | 9 + main.py | 3 +- migrations/versions/20251208_add_ai_models.py | 46 +++ .../versions/20251208_add_tasks_count.py | 28 ++ services/ai_model_service.py | 190 ++++++++++++ services/ai_service.py | 259 ++++++++++++---- services/task_service.py | 162 +++++++++- services/user_service.py | 22 ++ 17 files changed, 1095 insertions(+), 129 deletions(-) create mode 100644 bot/handlers/admin.py create mode 100644 migrations/versions/20251208_add_ai_models.py create mode 100644 migrations/versions/20251208_add_tasks_count.py create mode 100644 services/ai_model_service.py diff --git a/.env.example b/.env.example index 5696fd4..169602e 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,10 @@ BOT_TOKEN=your_telegram_bot_token_here # OpenAI API Key OPENAI_API_KEY=your_openai_api_key_here +# Google AI Studio API Key (для Gemini моделей) +# Получить: https://aistudio.google.com/apikey +GOOGLE_API_KEY=your_google_api_key_here + # Cloudflare AI Gateway (опционально, для кэширования и мониторинга) # Получить Account ID: https://dash.cloudflare.com/ -> AI -> AI Gateway CLOUDFLARE_ACCOUNT_ID=4c714ccd1433cf82279ac6e1278bcb8f @@ -20,3 +24,6 @@ DB_PORT=15433 # Settings DEBUG=True + +# Admin IDs (Telegram user IDs через запятую, для команды /admin) +ADMIN_IDS=123456789 diff --git a/Makefile b/Makefile index c61ba4d..6665219 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,7 @@ docker-up docker-down docker-logs docker-rebuild docker-restart \ docker-bot-restart docker-bot-rebuild docker-bot-build \ migrate migrate-down migrate-current migrate-revision \ + local-migrate local-migrate-down local-migrate-current \ docker-db docker-db-stop help: @@ -22,12 +23,17 @@ help: @echo " make docker-bot-build - Собрать образ бота" @echo " make docker-bot-rebuild - Пересобрать и поднять только бот" @echo "" - @echo "Миграции Alembic:" + @echo "Миграции Alembic (Docker):" @echo " make migrate - Применить все миграции (upgrade head)" @echo " make migrate-down - Откатить одну миграцию (downgrade -1)" @echo " make migrate-current - Показать текущую ревизию" @echo " make migrate-revision m=\"msg\" - Создать пустую ревизию с сообщением" @echo "" + @echo "Миграции Alembic (локально):" + @echo " make local-migrate - Применить все миграции локально" + @echo " make local-migrate-down - Откатить одну миграцию локально" + @echo " make local-migrate-current - Показать текущую ревизию локально" + @echo "" @echo "База данных:" @echo " make docker-db - Запустить только БД (для локальной разработки)" @echo " make docker-db-stop - Остановить БД" @@ -102,6 +108,16 @@ migrate-revision: fi docker-compose exec bot alembic revision -m "$(m)" +# ------- Локальные миграции Alembic (без Docker) ------- +local-migrate: + .venv/bin/alembic upgrade head + +local-migrate-down: + .venv/bin/alembic downgrade -1 + +local-migrate-current: + .venv/bin/alembic current + docker-db: @echo "🐘 Запуск PostgreSQL для локальной разработки..." @if [ ! -f .env ]; then \ diff --git a/bot/handlers/admin.py b/bot/handlers/admin.py new file mode 100644 index 0000000..83e2487 --- /dev/null +++ b/bot/handlers/admin.py @@ -0,0 +1,102 @@ +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton + +from config.settings import settings +from database.db import async_session_maker +from database.models import AIProvider +from services.ai_model_service import AIModelService + +router = Router() + + +def get_admin_ids() -> set: + """Получить множество ID админов""" + if not settings.admin_ids: + return set() + return set(int(x.strip()) for x in settings.admin_ids.split(",") if x.strip()) + + +def is_admin(user_id: int) -> bool: + """Проверить, является ли пользователь админом""" + return user_id in get_admin_ids() + + +async def get_model_keyboard() -> InlineKeyboardMarkup: + """Создать клавиатуру выбора AI модели""" + async with async_session_maker() as session: + models = await AIModelService.get_all_models(session) + + keyboard = [] + for model in models: + marker = "✓ " if model.is_active else "" + provider_emoji = "🟢" if model.provider == AIProvider.openai else "🔵" + keyboard.append([InlineKeyboardButton( + text=f"{marker}{provider_emoji} {model.display_name}", + callback_data=f"admin_model_{model.id}" + )]) + + keyboard.append([InlineKeyboardButton(text="❌ Закрыть", callback_data="admin_close")]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +@router.message(Command("admin")) +async def cmd_admin(message: Message): + """Админская панель""" + if not is_admin(message.from_user.id): + return + + # Убеждаемся что дефолтные модели созданы + async with async_session_maker() as session: + await AIModelService.ensure_default_models(session) + active_model = await AIModelService.get_active_model(session) + + active_name = active_model.display_name if active_model else "Не выбрана" + + text = ( + "🔧 Админ-панель\n\n" + f"🤖 Текущая AI модель: {active_name}\n\n" + "Выберите модель для генерации:" + ) + + keyboard = await get_model_keyboard() + await message.answer(text, reply_markup=keyboard) + + +@router.callback_query(F.data.startswith("admin_model_")) +async def set_admin_model(callback: CallbackQuery): + """Установить AI модель""" + if not is_admin(callback.from_user.id): + await callback.answer("⛔ Доступ запрещен", show_alert=True) + return + + model_id = int(callback.data.split("_")[-1]) + + async with async_session_maker() as session: + success = await AIModelService.set_active_model(session, model_id) + + if success: + active_model = await AIModelService.get_active_model(session) + await callback.answer(f"✅ Модель изменена: {active_model.display_name}") + + text = ( + "🔧 Админ-панель\n\n" + f"🤖 Текущая AI модель: {active_model.display_name}\n\n" + "Выберите модель для генерации:" + ) + else: + await callback.answer("❌ Ошибка при смене модели", show_alert=True) + text = "🔧 Админ-панель\n\n❌ Ошибка при смене модели" + + keyboard = await get_model_keyboard() + await callback.message.edit_text(text, reply_markup=keyboard) + + +@router.callback_query(F.data == "admin_close") +async def admin_close(callback: CallbackQuery): + """Закрыть админ-панель""" + if not is_admin(callback.from_user.id): + return + + await callback.message.delete() + await callback.answer() diff --git a/bot/handlers/settings.py b/bot/handlers/settings.py index aa8e1aa..d4a0e1a 100644 --- a/bot/handlers/settings.py +++ b/bot/handlers/settings.py @@ -28,6 +28,7 @@ def get_settings_keyboard(user) -> InlineKeyboardMarkup: ui_lang_code = getattr(user, 'language_interface', 'ru') or 'ru' translation_lang_code = get_translation_language(user) current_level = get_user_level_for_language(user) + tasks_count = getattr(user, 'tasks_count', 5) or 5 keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton( text=t(lang, 'settings.level_prefix') + f"{current_level}", @@ -45,6 +46,10 @@ def get_settings_keyboard(user) -> InlineKeyboardMarkup: text=t(lang, 'settings.translation_prefix') + t(lang, f'settings.lang_name.{translation_lang_code}'), callback_data="settings_translation" )], + [InlineKeyboardButton( + text=t(lang, 'settings.tasks_count_prefix') + str(tasks_count), + callback_data="settings_tasks_count" + )], [InlineKeyboardButton( text=t(lang, 'settings.close'), callback_data="settings_close" @@ -113,6 +118,26 @@ def get_learning_language_keyboard(user=None) -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=keyboard) +def get_tasks_count_keyboard(user=None) -> InlineKeyboardMarkup: + """Клавиатура выбора количества заданий""" + lang = get_user_lang(user) + current_count = getattr(user, 'tasks_count', 5) or 5 + + # Создаём кнопки для каждого значения (5, 7, 10, 12, 15) + counts = [5, 7, 10, 12, 15] + keyboard = [] + + for count in counts: + marker = "✓ " if count == current_count else "" + keyboard.append([InlineKeyboardButton( + text=f"{marker}{count}", + callback_data=f"set_tasks_count_{count}" + )]) + + keyboard.append([InlineKeyboardButton(text=t(lang, 'settings.back'), callback_data="settings_back")]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + @router.message(Command("settings")) async def cmd_settings(message: Message): """Обработчик команды /settings""" @@ -270,6 +295,39 @@ async def set_translation_language(callback: CallbackQuery): await callback.answer() +@router.callback_query(F.data == "settings_tasks_count") +async def settings_tasks_count(callback: CallbackQuery): + """Показать выбор количества заданий""" + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + lang = get_user_lang(user) + await callback.message.edit_text( + t(lang, 'settings.tasks_count_title') + t(lang, 'settings.tasks_count_desc'), + reply_markup=get_tasks_count_keyboard(user) + ) + await callback.answer() + + +@router.callback_query(F.data.startswith("set_tasks_count_")) +async def set_tasks_count(callback: CallbackQuery): + """Установить количество заданий""" + new_count = int(callback.data.split("_")[-1]) + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + + if user: + await UserService.update_user_tasks_count(session, user.id, new_count) + lang = get_user_lang(user) + text = t(lang, 'settings.tasks_count_changed', count=new_count) + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=t(lang, 'settings.back'), callback_data="settings_back")]]) + ) + + await callback.answer() + + @router.callback_query(F.data == "settings_back") async def settings_back(callback: CallbackQuery): """Вернуться к настройкам""" diff --git a/bot/handlers/tasks.py b/bot/handlers/tasks.py index f50a2d6..39fb241 100644 --- a/bot/handlers/tasks.py +++ b/bot/handlers/tasks.py @@ -19,10 +19,25 @@ router = Router() class TaskStates(StatesGroup): """Состояния для прохождения заданий""" choosing_mode = State() + choosing_type = State() # Выбор типа заданий doing_tasks = State() waiting_for_answer = State() +# Типы заданий +TASK_TYPES = ['mix', 'word_translate', 'fill_blank', 'sentence_translate'] + + +def get_task_type_keyboard(lang: str) -> InlineKeyboardMarkup: + """Клавиатура выбора типа заданий""" + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=t(lang, 'tasks.type_mix'), callback_data="task_type_mix")], + [InlineKeyboardButton(text=t(lang, 'tasks.type_word_translate'), callback_data="task_type_word_translate")], + [InlineKeyboardButton(text=t(lang, 'tasks.type_fill_blank'), callback_data="task_type_fill_blank")], + [InlineKeyboardButton(text=t(lang, 'tasks.type_sentence_translate'), callback_data="task_type_sentence_translate")], + ]) + + @router.message(Command("task")) async def cmd_task(message: Message, state: FSMContext): """Обработчик команды /task — показываем меню выбора режима""" @@ -52,8 +67,8 @@ async def cmd_task(message: Message, state: FSMContext): @router.callback_query(F.data == "task_mode_vocabulary", TaskStates.choosing_mode) -async def start_vocabulary_tasks(callback: CallbackQuery, state: FSMContext): - """Начать задания по словам из словаря""" +async def choose_vocabulary_task_type(callback: CallbackQuery, state: FSMContext): + """Показать выбор типа заданий для режима vocabulary""" await callback.answer() async with async_session_maker() as session: @@ -65,37 +80,25 @@ async def start_vocabulary_tasks(callback: CallbackQuery, state: FSMContext): lang = get_user_lang(user) - # Генерируем задания по словам из словаря - tasks = await TaskService.generate_mixed_tasks( - session, user.id, count=5, - learning_lang=user.learning_language, - translation_lang=get_user_translation_lang(user), + # Сохраняем режим и переходим к выбору типа + await state.update_data(user_id=user.id, mode='vocabulary') + await state.set_state(TaskStates.choosing_type) + + await callback.message.edit_text( + t(lang, 'tasks.choose_type'), + reply_markup=get_task_type_keyboard(lang) ) - if not tasks: - await callback.message.edit_text(t(lang, 'tasks.no_words')) - await state.clear() - return - # Сохраняем задания в состоянии - await state.update_data( - tasks=tasks, - current_task_index=0, - correct_count=0, - user_id=user.id, - mode='vocabulary' - ) - await state.set_state(TaskStates.doing_tasks) - - await callback.message.delete() - await show_current_task(callback.message, state) - - -@router.callback_query(F.data == "task_mode_new", TaskStates.choosing_mode) -async def start_new_words_tasks(callback: CallbackQuery, state: FSMContext): - """Начать задания с новыми словами""" +@router.callback_query(F.data.startswith("task_type_"), TaskStates.choosing_type) +async def start_tasks_with_type(callback: CallbackQuery, state: FSMContext): + """Начать задания выбранного типа""" await callback.answer() + task_type = callback.data.replace("task_type_", "") # mix, word_translate, fill_blank, sentence_translate + data = await state.get_data() + mode = data.get('mode', 'vocabulary') + async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) @@ -104,64 +107,202 @@ async def start_new_words_tasks(callback: CallbackQuery, state: FSMContext): return lang = get_user_lang(user) - level = get_user_level_for_language(user) - # Показываем индикатор загрузки - await callback.message.edit_text(t(lang, 'tasks.generating_new')) + # Получаем количество заданий из настроек пользователя + tasks_count = getattr(user, 'tasks_count', 5) or 5 - # Получаем слова для исключения: - # 1. Все слова из словаря пользователя + if mode == 'vocabulary': + # Генерируем задания по словам из словаря + tasks = await TaskService.generate_tasks_by_type( + session, user.id, count=tasks_count, + task_type=task_type, + learning_lang=user.learning_language, + translation_lang=get_user_translation_lang(user), + ) + + if not tasks: + await callback.message.edit_text(t(lang, 'tasks.no_words')) + await state.clear() + return + + # Сохраняем задания в состоянии + await state.update_data( + tasks=tasks, + current_task_index=0, + correct_count=0, + user_id=user.id, + mode='vocabulary', + task_type=task_type + ) + await state.set_state(TaskStates.doing_tasks) + + await callback.message.delete() + await show_current_task(callback.message, state) + + else: + # Режим new_words - генерируем новые слова + await generate_new_words_tasks(callback, state, user, task_type) + + +async def generate_new_words_tasks(callback: CallbackQuery, state: FSMContext, user, task_type: str): + """Генерация заданий с новыми словами""" + lang = get_user_lang(user) + level = get_user_level_for_language(user) + tasks_count = getattr(user, 'tasks_count', 5) or 5 + + # Показываем индикатор загрузки + await callback.message.edit_text(t(lang, 'tasks.generating_new')) + + async with async_session_maker() as session: + # Получаем слова для исключения vocab_words = await VocabularyService.get_all_user_word_strings( session, user.id, learning_lang=user.learning_language ) - # 2. Слова из предыдущих заданий new_words, на которые ответили правильно correct_task_words = await TaskService.get_correctly_answered_words( session, user.id ) - # Объединяем списки исключений exclude_words = list(set(vocab_words + correct_task_words)) - # Генерируем новые слова через AI - translation_lang = get_user_translation_lang(user) - words = await ai_service.generate_thematic_words( - theme="random everyday vocabulary", - level=level, - count=5, - learning_lang=user.learning_language, - translation_lang=translation_lang, - exclude_words=exclude_words if exclude_words else None, - ) + # Генерируем новые слова через AI + translation_lang = get_user_translation_lang(user) + words = await ai_service.generate_thematic_words( + theme="random everyday vocabulary", + level=level, + count=tasks_count, + learning_lang=user.learning_language, + translation_lang=translation_lang, + exclude_words=exclude_words if exclude_words else None, + ) - if not words: - await callback.message.edit_text(t(lang, 'tasks.generate_failed')) - await state.clear() - return + if not words: + await callback.message.edit_text(t(lang, 'tasks.generate_failed')) + await state.clear() + return - # Преобразуем слова в задания - tasks = [] - translate_prompt = t(lang, 'tasks.translate_to', lang_name=t(lang, f'lang.{translation_lang}')) - for word in words: + # Преобразуем слова в задания нужного типа + tasks = await create_tasks_from_words(words, task_type, lang, user.learning_language, translation_lang) + + await state.update_data( + tasks=tasks, + current_task_index=0, + correct_count=0, + user_id=user.id, + mode='new_words', + task_type=task_type + ) + await state.set_state(TaskStates.doing_tasks) + + await callback.message.delete() + await show_current_task(callback.message, state) + + +async def create_tasks_from_words(words: list, task_type: str, lang: str, learning_lang: str, translation_lang: str) -> list: + """Создать задания из списка слов в зависимости от типа""" + import random + tasks = [] + + for word in words: + word_text = word.get('word', '') + translation = word.get('translation', '') + transcription = word.get('transcription', '') + example = word.get('example', '') + example_translation = word.get('example_translation', '') + + if task_type == 'mix': + # Случайный тип + chosen_type = random.choice(['word_translate', 'fill_blank', 'sentence_translate']) + else: + chosen_type = task_type + + if chosen_type == 'word_translate': + # Перевод слова + translate_prompt = t(lang, 'tasks.translate_to', lang_name=t(lang, f'lang.{translation_lang}')) tasks.append({ 'type': 'translate', - 'question': f"{translate_prompt}: {word.get('word', '')}", - 'word': word.get('word', ''), - 'correct_answer': word.get('translation', ''), - 'transcription': word.get('transcription', ''), - 'example': word.get('example', ''), # Пример на изучаемом языке - 'example_translation': word.get('example_translation', '') # Перевод примера + 'question': f"{translate_prompt}: {word_text}", + 'word': word_text, + 'correct_answer': translation, + 'transcription': transcription, + 'example': example, + 'example_translation': example_translation }) - await state.update_data( - tasks=tasks, - current_task_index=0, - correct_count=0, - user_id=user.id, - mode='new_words' - ) - await state.set_state(TaskStates.doing_tasks) + elif chosen_type == 'fill_blank': + # Заполнение пропуска - генерируем предложение через AI + sentence_data = await ai_service.generate_fill_in_sentence( + word_text, + learning_lang=learning_lang, + translation_lang=translation_lang + ) + if translation_lang == 'en': + fill_title = "Fill in the blank:" + elif translation_lang == 'ja': + fill_title = "空欄を埋めてください:" + else: + fill_title = "Заполни пропуск:" - await callback.message.delete() - await show_current_task(callback.message, state) + tasks.append({ + 'type': 'fill_in', + 'question': f"{fill_title}\n\n{sentence_data['sentence']}\n\n{sentence_data.get('translation', '')}", + 'word': word_text, + 'correct_answer': sentence_data['answer'], + 'transcription': transcription, + 'example': example, + 'example_translation': example_translation + }) + + elif chosen_type == 'sentence_translate': + # Перевод предложения - генерируем предложение через AI + sentence_data = await ai_service.generate_sentence_for_translation( + word_text, + learning_lang=learning_lang, + translation_lang=translation_lang + ) + if translation_lang == 'en': + sentence_title = "Translate the sentence:" + word_hint = "Word" + elif translation_lang == 'ja': + sentence_title = "文を翻訳してください:" + word_hint = "単語" + else: + sentence_title = "Переведи предложение:" + word_hint = "Слово" + + tasks.append({ + 'type': 'sentence_translate', + 'question': f"{sentence_title}\n\n{sentence_data['sentence']}\n\n📝 {word_hint}: {word_text} — {translation}", + 'word': word_text, + 'correct_answer': sentence_data['translation'], + 'transcription': transcription, + 'example': example, + 'example_translation': example_translation + }) + + return tasks + + +@router.callback_query(F.data == "task_mode_new", TaskStates.choosing_mode) +async def choose_new_words_task_type(callback: CallbackQuery, state: FSMContext): + """Показать выбор типа заданий для режима new_words""" + await callback.answer() + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + + if not user: + await callback.message.edit_text(t('ru', 'common.start_first')) + return + + lang = get_user_lang(user) + + # Сохраняем режим и переходим к выбору типа + await state.update_data(user_id=user.id, mode='new_words') + await state.set_state(TaskStates.choosing_type) + + await callback.message.edit_text( + t(lang, 'tasks.choose_type'), + reply_markup=get_task_type_keyboard(lang) + ) async def show_current_task(message: Message, state: FSMContext): diff --git a/config/settings.py b/config/settings.py index 1102dc1..9692e44 100644 --- a/config/settings.py +++ b/config/settings.py @@ -10,6 +10,9 @@ class Settings(BaseSettings): # OpenAI openai_api_key: str + # Google AI (Gemini) + google_api_key: str = "" + # Cloudflare AI Gateway (опционально) cloudflare_account_id: str = "" cloudflare_gateway_id: str = "gpt" @@ -23,6 +26,7 @@ class Settings(BaseSettings): # App settings debug: bool = False + admin_ids: str = "" # Список ID админов через запятую (например "123456789,987654321") model_config = SettingsConfigDict( env_file='.env', diff --git a/database/models.py b/database/models.py index 88bb6ae..4157285 100644 --- a/database/models.py +++ b/database/models.py @@ -48,6 +48,12 @@ class WordSource(str, enum.Enum): AI_TASK = "ai_task" # Из AI-задания +class AIProvider(str, enum.Enum): + """Провайдеры AI моделей""" + openai = "openai" + google = "google" + + class User(Base): """Модель пользователя""" __tablename__ = "users" @@ -65,6 +71,7 @@ class User(Base): 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) + tasks_count: Mapped[int] = mapped_column(Integer, default=5) # Количество заданий (5-15) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) last_active: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -121,3 +128,15 @@ class Task(Base): ai_feedback: Mapped[Optional[str]] = mapped_column(String(1000)) completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class AIModel(Base): + """Модель AI моделей для генерации""" + __tablename__ = "ai_models" + + id: Mapped[int] = mapped_column(primary_key=True) + provider: Mapped[AIProvider] = mapped_column(SQLEnum(AIProvider), nullable=False) # openai / google + model_name: Mapped[str] = mapped_column(String(100), nullable=False) # gpt-4o-mini, gemini-2.5-flash-lite + display_name: Mapped[str] = mapped_column(String(100), nullable=False) # Название для отображения + is_active: Mapped[bool] = mapped_column(Boolean, default=False) # Только одна модель активна + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/locales/en.json b/locales/en.json index 4d1c1c5..1954976 100644 --- a/locales/en.json +++ b/locales/en.json @@ -115,6 +115,11 @@ "choose_mode": "🧠 Choose task mode:", "mode_vocabulary": "📚 Words from vocabulary", "mode_new_words": "✨ New words", + "choose_type": "📋 Choose task type:", + "type_mix": "🎲 Mix (all types)", + "type_word_translate": "📝 Word translation", + "type_fill_blank": "✏️ Fill in the blank", + "type_sentence_translate": "📖 Sentence translation", "generating_new": "🔄 Generating new words...", "generate_failed": "❌ Failed to generate words. Try again later.", "translate_to": "Translate to {lang_name}", @@ -236,6 +241,10 @@ "translation_title": "💬 Select translation language:\n\n", "translation_desc": "Words will be translated to this language.\nThis can differ from interface language.", "translation_changed": "✅ Translation language: {lang_name}", + "tasks_count_prefix": "🔢 Tasks: ", + "tasks_count_title": "🔢 Number of tasks:\n\n", + "tasks_count_desc": "How many tasks to generate at once.\nMinimum 5, maximum 15.", + "tasks_count_changed": "✅ Number of tasks: {count}", "menu_updated": "Main menu updated ⤵️", "lang_name": { "ru": "🇷🇺 Русский", diff --git a/locales/ja.json b/locales/ja.json index 25b3fcc..95e773d 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -107,6 +107,11 @@ "choose_mode": "🧠 課題モードを選択:", "mode_vocabulary": "📚 単語帳から", "mode_new_words": "✨ 新しい単語", + "choose_type": "📋 課題の種類を選択:", + "type_mix": "🎲 ミックス(全種類)", + "type_word_translate": "📝 単語翻訳", + "type_fill_blank": "✏️ 穴埋め", + "type_sentence_translate": "📖 文翻訳", "generating_new": "🔄 新しい単語を生成中...", "generate_failed": "❌ 単語の生成に失敗しました。後でもう一度お試しください。", "translate_to": "{lang_name}に翻訳", @@ -228,6 +233,10 @@ "translation_title": "💬 翻訳言語を選択:\n\n", "translation_desc": "単語はこの言語に翻訳されます。\nインターフェース言語と異なる設定が可能です。", "translation_changed": "✅ 翻訳言語: {lang_name}", + "tasks_count_prefix": "🔢 課題数: ", + "tasks_count_title": "🔢 課題数:\n\n", + "tasks_count_desc": "一度に生成する課題数。\n最小5、最大15。", + "tasks_count_changed": "✅ 課題数: {count}", "menu_updated": "メインメニューを更新しました ⤵️", "lang_name": { "ru": "🇷🇺 Русский", diff --git a/locales/ru.json b/locales/ru.json index 5a1dcb2..c75fe6c 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -115,6 +115,11 @@ "choose_mode": "🧠 Выбери режим заданий:", "mode_vocabulary": "📚 Слова из словаря", "mode_new_words": "✨ Новые слова", + "choose_type": "📋 Выбери тип заданий:", + "type_mix": "🎲 Микс (все типы)", + "type_word_translate": "📝 Перевод слов", + "type_fill_blank": "✏️ Подстановка слова", + "type_sentence_translate": "📖 Перевод предложений", "generating_new": "🔄 Генерирую новые слова...", "generate_failed": "❌ Не удалось сгенерировать слова. Попробуй позже.", "translate_to": "Переведи на {lang_name}", @@ -236,6 +241,10 @@ "translation_title": "💬 Выбери язык перевода:\n\n", "translation_desc": "На этот язык будут переводиться слова.\nЭто может отличаться от языка интерфейса.", "translation_changed": "✅ Язык перевода: {lang_name}", + "tasks_count_prefix": "🔢 Заданий: ", + "tasks_count_title": "🔢 Количество заданий:\n\n", + "tasks_count_desc": "Сколько заданий генерировать за один раз.\nМинимум 5, максимум 15.", + "tasks_count_changed": "✅ Количество заданий: {count}", "menu_updated": "Клавиатура обновлена ⤵️", "lang_name": { "ru": "🇷🇺 Русский", diff --git a/main.py b/main.py index e265822..d049ec2 100644 --- a/main.py +++ b/main.py @@ -7,7 +7,7 @@ from aiogram.enums import ParseMode from aiogram.types import BotCommand from config.settings import settings -from bot.handlers import start, vocabulary, tasks, settings as settings_handler, words, import_text, practice, reminder, level_test +from bot.handlers import start, vocabulary, tasks, settings as settings_handler, words, import_text, practice, reminder, level_test, admin from database.db import init_db from services.reminder_service import init_reminder_service @@ -52,6 +52,7 @@ async def main(): dp.include_router(import_text.router) dp.include_router(practice.router) dp.include_router(reminder.router) + dp.include_router(admin.router) # Инициализация базы данных await init_db() diff --git a/migrations/versions/20251208_add_ai_models.py b/migrations/versions/20251208_add_ai_models.py new file mode 100644 index 0000000..a036b3a --- /dev/null +++ b/migrations/versions/20251208_add_ai_models.py @@ -0,0 +1,46 @@ +"""Add ai_models table + +Revision ID: 20251208_ai_models +Revises: 20251208_tasks_count +Create Date: 2024-12-08 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = '20251208_ai_models' +down_revision: Union[str, None] = '20251208_tasks_count' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Создаём таблицу ai_models (enum aiprovider создаётся автоматически SQLAlchemy) + op.create_table( + 'ai_models', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('provider', postgresql.ENUM('openai', 'google', name='aiprovider', create_type=False), nullable=False), + sa.Column('model_name', sa.String(length=100), nullable=False), + sa.Column('display_name', sa.String(length=100), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=True, server_default='false'), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + + # Добавляем дефолтные модели + op.execute(""" + INSERT INTO ai_models (provider, model_name, display_name, is_active, created_at) + VALUES + ('openai', 'gpt-4o-mini', 'GPT-4o Mini', true, NOW()), + ('openai', 'gpt-5-nano', 'GPT-5 Nano', false, NOW()), + ('google', 'gemini-2.5-flash-lite', 'Gemini 2.5 Flash Lite', false, NOW()) + """) + + +def downgrade() -> None: + op.drop_table('ai_models') diff --git a/migrations/versions/20251208_add_tasks_count.py b/migrations/versions/20251208_add_tasks_count.py new file mode 100644 index 0000000..ebb7c9b --- /dev/null +++ b/migrations/versions/20251208_add_tasks_count.py @@ -0,0 +1,28 @@ +"""Add tasks_count field to users + +Revision ID: 20251208_tasks_count +Revises: 20251207_translation_language +Create Date: 2024-12-08 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '20251208_tasks_count' +down_revision: Union[str, None] = '20251207_translation_language' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('users', sa.Column('tasks_count', sa.Integer(), nullable=True, server_default='5')) + # Установим дефолт для существующих записей + op.execute("UPDATE users SET tasks_count = 5 WHERE tasks_count IS NULL") + + +def downgrade() -> None: + op.drop_column('users', 'tasks_count') diff --git a/services/ai_model_service.py b/services/ai_model_service.py new file mode 100644 index 0000000..8cdd44d --- /dev/null +++ b/services/ai_model_service.py @@ -0,0 +1,190 @@ +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession +from database.models import AIModel, AIProvider +from typing import Optional, List + + +# Дефолтная модель если в БД ничего нет +DEFAULT_MODEL = "gpt-4o-mini" +DEFAULT_PROVIDER = AIProvider.openai + + +class AIModelService: + """Сервис для работы с AI моделями""" + + @staticmethod + async def get_active_model(session: AsyncSession) -> Optional[AIModel]: + """ + Получить активную AI модель + + Returns: + AIModel или None если нет активной модели + """ + result = await session.execute( + select(AIModel).where(AIModel.is_active == True) + ) + return result.scalar_one_or_none() + + @staticmethod + async def get_active_model_name(session: AsyncSession) -> str: + """ + Получить название активной модели + + Returns: + Название модели (например "gpt-4o-mini") или дефолтное + """ + model = await AIModelService.get_active_model(session) + if model: + return model.model_name + return DEFAULT_MODEL + + @staticmethod + async def get_active_provider(session: AsyncSession) -> AIProvider: + """ + Получить провайдера активной модели + + Returns: + AIProvider (OPENAI или GOOGLE) + """ + model = await AIModelService.get_active_model(session) + if model: + return model.provider + return DEFAULT_PROVIDER + + @staticmethod + async def get_all_models(session: AsyncSession) -> List[AIModel]: + """ + Получить все доступные модели + + Returns: + Список всех моделей + """ + result = await session.execute( + select(AIModel).order_by(AIModel.provider, AIModel.model_name) + ) + return list(result.scalars().all()) + + @staticmethod + async def set_active_model(session: AsyncSession, model_id: int) -> bool: + """ + Установить активную модель по ID + + Args: + model_id: ID модели для активации + + Returns: + True если успешно, False если модель не найдена + """ + # Проверяем существование модели + result = await session.execute( + select(AIModel).where(AIModel.id == model_id) + ) + model = result.scalar_one_or_none() + + if not model: + return False + + # Деактивируем все модели + await session.execute( + update(AIModel).values(is_active=False) + ) + + # Активируем выбранную + model.is_active = True + await session.commit() + return True + + @staticmethod + async def set_active_model_by_name(session: AsyncSession, model_name: str) -> bool: + """ + Установить активную модель по названию + + Args: + model_name: Название модели (например "gpt-4o-mini") + + Returns: + True если успешно, False если модель не найдена + """ + result = await session.execute( + select(AIModel).where(AIModel.model_name == model_name) + ) + model = result.scalar_one_or_none() + + if not model: + return False + + # Деактивируем все модели + await session.execute( + update(AIModel).values(is_active=False) + ) + + # Активируем выбранную + model.is_active = True + await session.commit() + return True + + @staticmethod + async def create_model( + session: AsyncSession, + provider: AIProvider, + model_name: str, + display_name: str, + is_active: bool = False + ) -> AIModel: + """ + Создать новую модель + + Args: + provider: Провайдер (OPENAI, GOOGLE) + model_name: Техническое название модели + display_name: Отображаемое название + is_active: Активна ли модель + + Returns: + Созданная модель + """ + # Если активируем новую модель, деактивируем остальные + if is_active: + await session.execute( + update(AIModel).values(is_active=False) + ) + + model = AIModel( + provider=provider, + model_name=model_name, + display_name=display_name, + is_active=is_active + ) + session.add(model) + await session.commit() + await session.refresh(model) + return model + + @staticmethod + async def ensure_default_models(session: AsyncSession): + """ + Создать дефолтные модели если их нет в БД + """ + result = await session.execute(select(AIModel)) + existing = list(result.scalars().all()) + + if existing: + return # Модели уже есть + + # Создаём дефолтные модели + default_models = [ + (AIProvider.openai, "gpt-4o-mini", "GPT-4o Mini", True), + (AIProvider.openai, "gpt-5-nano", "GPT-5 Nano", False), + (AIProvider.google, "gemini-2.5-flash-lite", "Gemini 2.5 Flash Lite", False), + ] + + for provider, name, display, active in default_models: + model = AIModel( + provider=provider, + model_name=name, + display_name=display, + is_active=active + ) + session.add(model) + + await session.commit() diff --git a/services/ai_service.py b/services/ai_service.py index bab7307..c49cb6f 100644 --- a/services/ai_service.py +++ b/services/ai_service.py @@ -2,56 +2,160 @@ import logging import httpx from openai import AsyncOpenAI from config.settings import settings -from typing import Dict, List +from database.db import async_session_maker +from database.models import AIProvider +from typing import Dict, List, Optional logger = logging.getLogger(__name__) class AIService: - """Сервис для работы с OpenAI API через Cloudflare Gateway""" + """Сервис для работы с AI API (OpenAI и Google)""" def __init__(self): - self.api_key = settings.openai_api_key + self.openai_api_key = settings.openai_api_key + self.google_api_key = settings.google_api_key # Проверяем, настроен ли Cloudflare AI Gateway if settings.cloudflare_account_id: # Используем Cloudflare AI Gateway с прямыми HTTP запросами - self.base_url = ( + self.openai_base_url = ( f"https://gateway.ai.cloudflare.com/v1/" f"{settings.cloudflare_account_id}/" f"{settings.cloudflare_gateway_id}/" f"openai" ) self.use_cloudflare = True - logger.info(f"AI Service initialized with Cloudflare Gateway: {self.base_url}") + logger.info(f"AI Service initialized with Cloudflare Gateway: {self.openai_base_url}") else: # Прямое подключение к OpenAI - self.base_url = "https://api.openai.com/v1" + self.openai_base_url = "https://api.openai.com/v1" self.use_cloudflare = False logger.info("AI Service initialized with direct OpenAI connection") + # Google Gemini API URL (через Cloudflare Gateway или напрямую) + if settings.cloudflare_account_id: + self.google_base_url = ( + f"https://gateway.ai.cloudflare.com/v1/" + f"{settings.cloudflare_account_id}/" + f"{settings.cloudflare_gateway_id}/" + f"google-ai-studio/v1" + ) + else: + self.google_base_url = "https://generativelanguage.googleapis.com/v1beta" + # HTTP клиент для всех запросов self.http_client = httpx.AsyncClient( timeout=httpx.Timeout(60.0, connect=10.0), limits=httpx.Limits(max_keepalive_connections=5, max_connections=10) ) - async def _make_openai_request(self, messages: list, temperature: float = 0.3, model: str = "gpt-4o-mini") -> dict: - """Выполнить запрос к OpenAI API (через Cloudflare или напрямую)""" - url = f"{self.base_url}/chat/completions" + # Кеш активной модели (обновляется при запросах) + self._cached_model: Optional[str] = None + self._cached_provider: Optional[AIProvider] = None + + async def _get_active_model(self) -> tuple[str, AIProvider]: + """Получить активную модель и провайдера из БД""" + from services.ai_model_service import AIModelService, DEFAULT_MODEL, DEFAULT_PROVIDER + + async with async_session_maker() as session: + model = await AIModelService.get_active_model(session) + if model: + self._cached_model = model.model_name + self._cached_provider = model.provider + return model.model_name, model.provider + + return DEFAULT_MODEL, DEFAULT_PROVIDER + + async def _make_request(self, messages: list, temperature: float = 0.3) -> dict: + """Выполнить запрос к активному AI провайдеру""" + model_name, provider = await self._get_active_model() + + if provider == AIProvider.google: + return await self._make_google_request(messages, temperature, model_name) + else: + return await self._make_openai_request(messages, temperature, model_name) + + async def _make_google_request(self, messages: list, temperature: float = 0.3, model: str = "gemini-2.5-flash-lite") -> dict: + """Выполнить запрос к Google Gemini API (через Cloudflare Gateway или напрямую)""" + url = f"{self.google_base_url}/models/{model}:generateContent" + + # Конвертируем формат сообщений OpenAI в формат Google + # System message добавляем как первое user сообщение + contents = [] + + for msg in messages: + role = msg["role"] + content = msg["content"] + + if role == "system": + # Добавляем system как user сообщение в начало + contents.insert(0, {"role": "user", "parts": [{"text": f"[System instruction]: {content}"}]}) + elif role == "user": + contents.append({"role": "user", "parts": [{"text": content}]}) + elif role == "assistant": + contents.append({"role": "model", "parts": [{"text": content}]}) + + payload = { + "contents": contents, + "generationConfig": { + "temperature": temperature + } + } headers = { - "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "x-goog-api-key": self.google_api_key + } + + response = await self.http_client.post(url, headers=headers, json=payload) + response.raise_for_status() + data = response.json() + + # Конвертируем ответ Google в формат OpenAI для совместимости + text = data["candidates"][0]["content"]["parts"][0]["text"] + + # Убираем markdown обёртку если есть (```json ... ```) + if text.startswith('```'): + lines = text.split('\n') + # Убираем первую строку (```json) и последнюю (```) + if lines[-1].strip() == '```': + lines = lines[1:-1] + else: + lines = lines[1:] + text = '\n'.join(lines) + + return { + "choices": [{ + "message": { + "content": text + } + }] + } + + async def _make_openai_request(self, messages: list, temperature: float = 0.3, model: str = "gpt-4o-mini") -> dict: + """Выполнить запрос к OpenAI API (через Cloudflare или напрямую)""" + url = f"{self.openai_base_url}/chat/completions" + + headers = { + "Authorization": f"Bearer {self.openai_api_key}", "Content-Type": "application/json" } payload = { "model": model, - "messages": messages, - "temperature": temperature, - "response_format": {"type": "json_object"} + "messages": messages } + # Модели с ограничениями (не поддерживают temperature и json mode) + limited_models = {"gpt-5-nano", "o1", "o1-mini", "o1-preview", "o3-mini"} + + if model not in limited_models: + payload["temperature"] = temperature + + # JSON mode + payload["response_format"] = {"type": "json_object"} + response = await self.http_client.post(url, headers=headers, json=payload) response.raise_for_status() return response.json() @@ -85,22 +189,22 @@ class AIService: Важно: верни только JSON, без дополнительного текста.""" try: - logger.info(f"[GPT Request] translate_word: word='{word}', source='{source_lang}', to='{translation_lang}'") + logger.info(f"[AI Request] translate_word: word='{word}', source='{source_lang}', to='{translation_lang}'") messages = [ {"role": "system", "content": "Ты - помощник для изучения языков. Отвечай только в формате JSON."}, {"role": "user", "content": prompt} ] - response_data = await self._make_openai_request(messages, temperature=0.3) + response_data = await self._make_request(messages, temperature=0.3) import json result = json.loads(response_data['choices'][0]['message']['content']) - logger.info(f"[GPT Response] translate_word: success, translation='{result.get('translation', 'N/A')}'") + logger.info(f"[AI Response] translate_word: success, translation='{result.get('translation', 'N/A')}'") return result except Exception as e: - logger.error(f"[GPT Error] translate_word: {type(e).__name__}: {str(e)}") + logger.error(f"[AI Error] translate_word: {type(e).__name__}: {str(e)}") # Fallback в случае ошибки return { "word": word, @@ -164,14 +268,14 @@ class AIService: - Верни только JSON, без дополнительного текста""" try: - logger.info(f"[GPT Request] translate_word_with_contexts: word='{word}', source='{source_lang}', to='{translation_lang}'") + logger.info(f"[AI Request] translate_word_with_contexts: word='{word}', source='{source_lang}', to='{translation_lang}'") messages = [ {"role": "system", "content": "Ты - помощник для изучения языков. Отвечай только в формате JSON."}, {"role": "user", "content": prompt} ] - response_data = await self._make_openai_request(messages, temperature=0.3) + response_data = await self._make_request(messages, temperature=0.3) import json content = response_data['choices'][0]['message']['content'] @@ -184,11 +288,11 @@ class AIService: result = json.loads(content) translations_count = len(result.get('translations', [])) - logger.info(f"[GPT Response] translate_word_with_contexts: success, {translations_count} translations") + logger.info(f"[AI Response] translate_word_with_contexts: success, {translations_count} translations") return result except Exception as e: - logger.error(f"[GPT Error] translate_word_with_contexts: {type(e).__name__}: {str(e)}") + logger.error(f"[AI Error] translate_word_with_contexts: {type(e).__name__}: {str(e)}") # Fallback в случае ошибки return { "word": word, @@ -250,14 +354,14 @@ class AIService: - Для каждого слова укажи точный перевод и транскрипцию""" try: - logger.info(f"[GPT Request] translate_words_batch: {len(words)} words, {source_lang} -> {translation_lang}") + logger.info(f"[AI Request] translate_words_batch: {len(words)} words, {source_lang} -> {translation_lang}") messages = [ {"role": "system", "content": "Ты - помощник для изучения языков. Отвечай только в формате JSON."}, {"role": "user", "content": prompt} ] - response_data = await self._make_openai_request(messages, temperature=0.3) + response_data = await self._make_request(messages, temperature=0.3) import json content = response_data['choices'][0]['message']['content'] @@ -278,14 +382,14 @@ class AIService: break if not isinstance(result, list): - logger.warning(f"[GPT Warning] translate_words_batch: unexpected format, got {type(result)}") + logger.warning(f"[AI Warning] translate_words_batch: unexpected format, got {type(result)}") return [{"word": w, "translation": "", "transcription": ""} for w in words] - logger.info(f"[GPT Response] translate_words_batch: success, got {len(result)} translations") + logger.info(f"[AI Response] translate_words_batch: success, got {len(result)} translations") return result except Exception as e: - logger.error(f"[GPT Error] translate_words_batch: {type(e).__name__}: {str(e)}") + logger.error(f"[AI Error] translate_words_batch: {type(e).__name__}: {str(e)}") # Возвращаем слова без перевода в случае ошибки return [{"word": w, "translation": "", "transcription": ""} for w in words] @@ -317,22 +421,22 @@ class AIService: Учитывай возможные вариации ответа. Если смысл передан правильно, даже с небольшими грамматическими неточностями, засчитывай ответ.""" try: - logger.info(f"[GPT Request] check_answer: user_answer='{user_answer[:30]}...'") + logger.info(f"[AI Request] check_answer: user_answer='{user_answer[:30]}...'") messages = [ {"role": "system", "content": "Ты - преподаватель английского языка. Проверяй ответы справедливо, учитывая контекст."}, {"role": "user", "content": prompt} ] - response_data = await self._make_openai_request(messages, temperature=0.3) + response_data = await self._make_request(messages, temperature=0.3) import json result = json.loads(response_data['choices'][0]['message']['content']) - logger.info(f"[GPT Response] check_answer: is_correct={result.get('is_correct', False)}, score={result.get('score', 0)}") + logger.info(f"[AI Response] check_answer: is_correct={result.get('is_correct', False)}, score={result.get('score', 0)}") return result except Exception as e: - logger.error(f"[GPT Error] check_answer: {type(e).__name__}: {str(e)}") + logger.error(f"[AI Error] check_answer: {type(e).__name__}: {str(e)}") return { "is_correct": False, "feedback": "Ошибка проверки ответа", @@ -364,28 +468,73 @@ class AIService: Предложение должно быть простым и естественным. Контекст должен четко подсказывать правильное слово.""" try: - logger.info(f"[GPT Request] generate_fill_in_sentence: word='{word}', lang='{learning_lang}', to='{translation_lang}'") + logger.info(f"[AI Request] generate_fill_in_sentence: word='{word}', lang='{learning_lang}', to='{translation_lang}'") messages = [ {"role": "system", "content": "Ты - преподаватель иностранных языков. Создавай простые и понятные упражнения."}, {"role": "user", "content": prompt} ] - response_data = await self._make_openai_request(messages, temperature=0.7) + response_data = await self._make_request(messages, temperature=0.7) import json result = json.loads(response_data['choices'][0]['message']['content']) - logger.info(f"[GPT Response] generate_fill_in_sentence: success") + logger.info(f"[AI Response] generate_fill_in_sentence: success") return result except Exception as e: - logger.error(f"[GPT Error] generate_fill_in_sentence: {type(e).__name__}: {str(e)}") + logger.error(f"[AI Error] generate_fill_in_sentence: {type(e).__name__}: {str(e)}") return { "sentence": f"I like to ___ every day.", "answer": word, "translation": f"Мне нравится {word} каждый день." } + async def generate_sentence_for_translation(self, word: str, learning_lang: str = "en", translation_lang: str = "ru") -> Dict: + """ + Сгенерировать предложение для перевода, содержащее заданное слово + + Args: + word: Слово (на языке обучения), которое должно быть в предложении + learning_lang: Язык обучения (ISO2) + translation_lang: Язык перевода (ISO2) + + Returns: + Dict с предложением и его переводом + """ + prompt = f"""Создай простое предложение на языке {learning_lang}, используя слово "{word}". + +Верни ответ в формате JSON: +{{ + "sentence": "предложение на {learning_lang} со словом {word}", + "translation": "перевод предложения на {translation_lang}" +}} + +Предложение должно быть простым (5-10 слов), естественным и подходящим для изучения языка.""" + + try: + logger.info(f"[AI Request] generate_sentence_for_translation: word='{word}', lang='{learning_lang}', to='{translation_lang}'") + + messages = [ + {"role": "system", "content": "Ты - преподаватель иностранных языков. Создавай простые и понятные примеры для практики перевода."}, + {"role": "user", "content": prompt} + ] + + response_data = await self._make_request(messages, temperature=0.7) + + import json + result = json.loads(response_data['choices'][0]['message']['content']) + logger.info(f"[AI Response] generate_sentence_for_translation: success") + return result + + except Exception as e: + logger.error(f"[AI Error] generate_sentence_for_translation: {type(e).__name__}: {str(e)}") + # Fallback - простое предложение + return { + "sentence": f"I use {word} every day.", + "translation": f"Я использую {word} каждый день." + } + async def generate_thematic_words( self, theme: str, @@ -445,14 +594,14 @@ class AIService: - Разнообразными (существительные, глаголы, прилагательные)""" try: - logger.info(f"[GPT Request] generate_thematic_words: theme='{theme}', level='{level}', count={count}, learn='{learning_lang}', to='{translation_lang}'") + logger.info(f"[AI Request] generate_thematic_words: theme='{theme}', level='{level}', count={count}, learn='{learning_lang}', to='{translation_lang}'") messages = [ {"role": "system", "content": "Ты - преподаватель иностранных языков. Подбирай полезные и актуальные слова."}, {"role": "user", "content": prompt} ] - response_data = await self._make_openai_request(messages, temperature=0.7) + response_data = await self._make_request(messages, temperature=0.7) import json result = json.loads(response_data['choices'][0]['message']['content']) @@ -466,14 +615,14 @@ class AIService: ] filtered_count = len(words) - len(filtered_words) if filtered_count > 0: - logger.info(f"[GPT Response] generate_thematic_words: filtered out {filtered_count} excluded words") + logger.info(f"[AI Response] generate_thematic_words: filtered out {filtered_count} excluded words") words = filtered_words - logger.info(f"[GPT Response] generate_thematic_words: success, generated {len(words)} words") + logger.info(f"[AI Response] generate_thematic_words: success, generated {len(words)} words") return words except Exception as e: - logger.error(f"[GPT Error] generate_thematic_words: {type(e).__name__}: {str(e)}") + logger.error(f"[AI Error] generate_thematic_words: {type(e).__name__}: {str(e)}") return [] async def extract_words_from_text(self, text: str, level: str = "B1", max_words: int = 15, learning_lang: str = "en", translation_lang: str = "ru") -> List[Dict]: @@ -514,23 +663,23 @@ class AIService: try: text_preview = text[:100] + "..." if len(text) > 100 else text - logger.info(f"[GPT Request] extract_words_from_text: text_length={len(text)}, level='{level}', max_words={max_words}, learn='{learning_lang}', to='{translation_lang}'") + logger.info(f"[AI Request] extract_words_from_text: text_length={len(text)}, level='{level}', max_words={max_words}, learn='{learning_lang}', to='{translation_lang}'") messages = [ {"role": "system", "content": "Ты - преподаватель иностранных языков. Помогаешь извлекать полезные слова для изучения из текстов."}, {"role": "user", "content": prompt} ] - response_data = await self._make_openai_request(messages, temperature=0.5) + response_data = await self._make_request(messages, temperature=0.5) import json result = json.loads(response_data['choices'][0]['message']['content']) words_count = len(result.get('words', [])) - logger.info(f"[GPT Response] extract_words_from_text: success, extracted {words_count} words") + logger.info(f"[AI Response] extract_words_from_text: success, extracted {words_count} words") return result.get('words', []) except Exception as e: - logger.error(f"[GPT Error] extract_words_from_text: {type(e).__name__}: {str(e)}") + logger.error(f"[AI Error] extract_words_from_text: {type(e).__name__}: {str(e)}") return [] async def start_conversation(self, scenario: str, level: str = "B1", learning_lang: str = "en", translation_lang: str = "ru") -> Dict: @@ -583,22 +732,22 @@ class AIService: - Подсказки должны помочь пользователю ответить""" try: - logger.info(f"[GPT Request] start_conversation: scenario='{scenario}', level='{level}', learn='{learning_lang}', to='{translation_lang}'") + logger.info(f"[AI Request] start_conversation: scenario='{scenario}', level='{level}', learn='{learning_lang}', to='{translation_lang}'") messages = [ {"role": "system", "content": "Ты - дружелюбный собеседник для практики иностранных языков. Веди естественный диалог."}, {"role": "user", "content": prompt} ] - response_data = await self._make_openai_request(messages, temperature=0.8) + response_data = await self._make_request(messages, temperature=0.8) import json result = json.loads(response_data['choices'][0]['message']['content']) - logger.info(f"[GPT Response] start_conversation: success, scenario='{scenario}'") + logger.info(f"[AI Response] start_conversation: success, scenario='{scenario}'") return result except Exception as e: - logger.error(f"[GPT Error] start_conversation: {type(e).__name__}: {str(e)}") + logger.error(f"[AI Error] start_conversation: {type(e).__name__}: {str(e)}") return { "message": "Hello! How are you today?", "translation": "Привет! Как дела сегодня?", @@ -667,7 +816,7 @@ User: {user_message} - Используй лексику уровня {level}""" try: - logger.info(f"[GPT Request] continue_conversation: scenario='{scenario}', level='{level}', history_length={len(conversation_history)}, learn='{learning_lang}', to='{translation_lang}'") + logger.info(f"[AI Request] continue_conversation: scenario='{scenario}', level='{level}', history_length={len(conversation_history)}, learn='{learning_lang}', to='{translation_lang}'") # Формируем сообщения для API messages = [ @@ -684,16 +833,16 @@ User: {user_message} # Добавляем инструкцию для форматирования ответа messages.append({"role": "user", "content": prompt}) - response_data = await self._make_openai_request(messages, temperature=0.8) + response_data = await self._make_request(messages, temperature=0.8) import json result = json.loads(response_data['choices'][0]['message']['content']) has_errors = result.get('feedback', {}).get('has_errors', False) - logger.info(f"[GPT Response] continue_conversation: success, has_errors={has_errors}") + logger.info(f"[AI Response] continue_conversation: success, has_errors={has_errors}") return result except Exception as e: - logger.error(f"[GPT Error] continue_conversation: {type(e).__name__}: {str(e)}") + logger.error(f"[AI Error] continue_conversation: {type(e).__name__}: {str(e)}") return { "response": "I see. Tell me more about that.", "translation": "Понятно. Расскажи мне больше об этом.", @@ -756,7 +905,7 @@ User: {user_message} - Вопросы на грамматику, лексику и понимание""" try: - logger.info(f"[GPT Request] generate_level_test: generating 7 questions for {learning_language}") + logger.info(f"[AI Request] generate_level_test: generating 7 questions for {learning_language}") system_msg = f"Ты - эксперт по тестированию уровня {language_name} языка. Создавай объективные тесты." messages = [ @@ -764,16 +913,16 @@ User: {user_message} {"role": "user", "content": prompt} ] - response_data = await self._make_openai_request(messages, temperature=0.7) + response_data = await self._make_request(messages, temperature=0.7) import json result = json.loads(response_data['choices'][0]['message']['content']) questions_count = len(result.get('questions', [])) - logger.info(f"[GPT Response] generate_level_test: success, generated {questions_count} questions") + logger.info(f"[AI Response] generate_level_test: success, generated {questions_count} questions") return result.get('questions', []) except Exception as e: - logger.error(f"[GPT Error] generate_level_test: {type(e).__name__}: {str(e)}, using fallback questions") + logger.error(f"[AI Error] generate_level_test: {type(e).__name__}: {str(e)}, using fallback questions") # Fallback с базовыми вопросами if learning_language == "ja": return self._get_jlpt_fallback_questions() diff --git a/services/task_service.py b/services/task_service.py index 70cd646..85c52b5 100644 --- a/services/task_service.py +++ b/services/task_service.py @@ -15,7 +15,8 @@ class TaskService: async def generate_translation_tasks( session: AsyncSession, user_id: int, - count: int = 5 + count: int = 5, + learning_lang: str = 'en' ) -> List[Dict]: """ Генерация заданий на перевод слов @@ -28,10 +29,11 @@ class TaskService: Returns: Список заданий """ - # Получаем слова пользователя + # Получаем слова пользователя на изучаемом языке result = await session.execute( select(Vocabulary) .where(Vocabulary.user_id == user_id) + .where(Vocabulary.source_lang == learning_lang) .order_by(Vocabulary.last_reviewed.asc().nullsfirst()) .limit(count * 2) # Берем больше, чтобы было из чего выбрать ) @@ -90,10 +92,11 @@ class TaskService: Returns: Список заданий разных типов """ - # Получаем слова пользователя + # Получаем слова пользователя на изучаемом языке result = await session.execute( select(Vocabulary) .where(Vocabulary.user_id == user_id) + .where(Vocabulary.source_lang == learning_lang) .order_by(Vocabulary.last_reviewed.asc().nullsfirst()) .limit(count * 2) ) @@ -230,6 +233,159 @@ class TaskService: return tasks + @staticmethod + async def generate_tasks_by_type( + session: AsyncSession, + user_id: int, + count: int = 5, + task_type: str = 'mix', + learning_lang: str = 'en', + translation_lang: str = 'ru' + ) -> List[Dict]: + """ + Генерация заданий определённого типа + + Args: + session: Сессия базы данных + user_id: ID пользователя + count: Количество заданий + task_type: Тип заданий (mix, word_translate, fill_blank, sentence_translate) + learning_lang: Язык обучения + translation_lang: Язык перевода + + Returns: + Список заданий + """ + # Получаем слова пользователя на изучаемом языке + result = await session.execute( + select(Vocabulary) + .where(Vocabulary.user_id == user_id) + .where(Vocabulary.source_lang == learning_lang) + .order_by(Vocabulary.last_reviewed.asc().nullsfirst()) + .limit(count * 2) + ) + words = list(result.scalars().all()) + + if not words: + return [] + + # Выбираем случайные слова + selected_words = random.sample(words, min(count, len(words))) + + tasks = [] + for word in selected_words: + # Получаем переводы из таблицы WordTranslation + translations_result = await session.execute( + select(WordTranslation) + .where(WordTranslation.vocabulary_id == word.id) + .order_by(WordTranslation.is_primary.desc()) + ) + translations = list(translations_result.scalars().all()) + + # Определяем тип задания + if task_type == 'mix': + chosen_type = random.choice(['word_translate', 'fill_blank', 'sentence_translate']) + else: + chosen_type = task_type + + # Определяем правильный перевод + correct_translation = word.word_translation + if translations: + primary = next((tr for tr in translations if tr.is_primary), translations[0] if translations else None) + if primary: + correct_translation = primary.translation + + if chosen_type == 'word_translate': + # Задание на перевод слова + direction = random.choice(['learn_to_tr', 'tr_to_learn']) + + # Локализация + if translation_lang == 'en': + prompt = "Translate the word:" + elif translation_lang == 'ja': + prompt = "単語を訳してください:" + else: + prompt = "Переведи слово:" + + if direction == 'learn_to_tr': + task = { + 'type': f'translate_to_{translation_lang}', + 'word_id': word.id, + 'question': f"{prompt} {word.word_original}", + 'word': word.word_original, + 'correct_answer': correct_translation, + 'transcription': word.transcription, + 'all_translations': [tr.translation for tr in translations] if translations else [correct_translation] + } + else: + task = { + 'type': f'translate_to_{learning_lang}', + 'word_id': word.id, + 'question': f"{prompt} {correct_translation}", + 'word': correct_translation, + 'correct_answer': word.word_original, + 'transcription': word.transcription + } + + elif chosen_type == 'fill_blank': + # Задание на заполнение пропуска + sentence_data = await ai_service.generate_fill_in_sentence( + word.word_original, + learning_lang=learning_lang, + translation_lang=translation_lang + ) + + if translation_lang == 'en': + fill_title = "Fill in the blank:" + elif translation_lang == 'ja': + fill_title = "空欄を埋めてください:" + else: + fill_title = "Заполни пропуск:" + + task = { + 'type': 'fill_in', + 'word_id': word.id, + 'question': ( + f"{fill_title}\n\n" + f"{sentence_data['sentence']}\n\n" + f"{sentence_data.get('translation', '')}" + ), + 'word': word.word_original, + 'correct_answer': sentence_data['answer'], + 'sentence': sentence_data['sentence'] + } + + elif chosen_type == 'sentence_translate': + # Задание на перевод предложения + sentence_data = await ai_service.generate_sentence_for_translation( + word.word_original, + learning_lang=learning_lang, + translation_lang=translation_lang + ) + + if translation_lang == 'en': + sentence_title = "Translate the sentence:" + word_hint = "Word" + elif translation_lang == 'ja': + sentence_title = "文を翻訳してください:" + word_hint = "単語" + else: + sentence_title = "Переведи предложение:" + word_hint = "Слово" + + task = { + 'type': 'sentence_translate', + 'word_id': word.id, + 'question': f"{sentence_title}\n\n{sentence_data['sentence']}\n\n📝 {word_hint}: {word.word_original} — {correct_translation}", + 'word': word.word_original, + 'correct_answer': sentence_data['translation'], + 'sentence': sentence_data['sentence'] + } + + tasks.append(task) + + return tasks + @staticmethod async def save_task_result( session: AsyncSession, diff --git a/services/user_service.py b/services/user_service.py index 5ba0f86..03bee0c 100644 --- a/services/user_service.py +++ b/services/user_service.py @@ -156,3 +156,25 @@ class UserService: if user: user.translation_language = language await session.commit() + + @staticmethod + async def update_user_tasks_count(session: AsyncSession, user_id: int, count: int): + """ + Обновить количество заданий пользователя + + Args: + session: Сессия базы данных + user_id: ID пользователя + count: Количество заданий (5-15) + """ + # Валидация диапазона + count = max(5, min(15, count)) + + result = await session.execute( + select(User).where(User.id == user_id) + ) + user = result.scalar_one_or_none() + + if user: + user.tasks_count = count + await session.commit()