feat: мульти-провайдер AI, выбор типов заданий, настройка количества

- Добавлена поддержка нескольких 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 <noreply@anthropic.com>
This commit is contained in:
2025-12-08 15:16:24 +03:00
parent 3e5c1be464
commit eb666ec9bc
17 changed files with 1095 additions and 129 deletions

102
bot/handlers/admin.py Normal file
View File

@@ -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 = (
"🔧 <b>Админ-панель</b>\n\n"
f"🤖 Текущая AI модель: <b>{active_name}</b>\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 = (
"🔧 <b>Админ-панель</b>\n\n"
f"🤖 Текущая AI модель: <b>{active_model.display_name}</b>\n\n"
"Выберите модель для генерации:"
)
else:
await callback.answer("❌ Ошибка при смене модели", show_alert=True)
text = "🔧 <b>Админ-панель</b>\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()

View File

@@ -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):
"""Вернуться к настройкам"""

View File

@@ -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}: <b>{word_text}</b>",
'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<b>{sentence_data['sentence']}</b>\n\n<i>{sentence_data.get('translation', '')}</i>",
'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<b>{sentence_data['sentence']}</b>\n\n📝 {word_hint}: <code>{word_text}</code> — {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):