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

View File

@@ -4,6 +4,10 @@ BOT_TOKEN=your_telegram_bot_token_here
# OpenAI API Key # OpenAI API Key
OPENAI_API_KEY=your_openai_api_key_here 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 (опционально, для кэширования и мониторинга) # Cloudflare AI Gateway (опционально, для кэширования и мониторинга)
# Получить Account ID: https://dash.cloudflare.com/ -> AI -> AI Gateway # Получить Account ID: https://dash.cloudflare.com/ -> AI -> AI Gateway
CLOUDFLARE_ACCOUNT_ID=4c714ccd1433cf82279ac6e1278bcb8f CLOUDFLARE_ACCOUNT_ID=4c714ccd1433cf82279ac6e1278bcb8f
@@ -20,3 +24,6 @@ DB_PORT=15433
# Settings # Settings
DEBUG=True DEBUG=True
# Admin IDs (Telegram user IDs через запятую, для команды /admin)
ADMIN_IDS=123456789

View File

@@ -3,6 +3,7 @@
docker-up docker-down docker-logs docker-rebuild docker-restart \ docker-up docker-down docker-logs docker-rebuild docker-restart \
docker-bot-restart docker-bot-rebuild docker-bot-build \ docker-bot-restart docker-bot-rebuild docker-bot-build \
migrate migrate-down migrate-current migrate-revision \ migrate migrate-down migrate-current migrate-revision \
local-migrate local-migrate-down local-migrate-current \
docker-db docker-db-stop docker-db docker-db-stop
help: help:
@@ -22,12 +23,17 @@ help:
@echo " make docker-bot-build - Собрать образ бота" @echo " make docker-bot-build - Собрать образ бота"
@echo " make docker-bot-rebuild - Пересобрать и поднять только бот" @echo " make docker-bot-rebuild - Пересобрать и поднять только бот"
@echo "" @echo ""
@echo "Миграции Alembic:" @echo "Миграции Alembic (Docker):"
@echo " make migrate - Применить все миграции (upgrade head)" @echo " make migrate - Применить все миграции (upgrade head)"
@echo " make migrate-down - Откатить одну миграцию (downgrade -1)" @echo " make migrate-down - Откатить одну миграцию (downgrade -1)"
@echo " make migrate-current - Показать текущую ревизию" @echo " make migrate-current - Показать текущую ревизию"
@echo " make migrate-revision m=\"msg\" - Создать пустую ревизию с сообщением" @echo " make migrate-revision m=\"msg\" - Создать пустую ревизию с сообщением"
@echo "" @echo ""
@echo "Миграции Alembic (локально):"
@echo " make local-migrate - Применить все миграции локально"
@echo " make local-migrate-down - Откатить одну миграцию локально"
@echo " make local-migrate-current - Показать текущую ревизию локально"
@echo ""
@echo "База данных:" @echo "База данных:"
@echo " make docker-db - Запустить только БД (для локальной разработки)" @echo " make docker-db - Запустить только БД (для локальной разработки)"
@echo " make docker-db-stop - Остановить БД" @echo " make docker-db-stop - Остановить БД"
@@ -102,6 +108,16 @@ migrate-revision:
fi fi
docker-compose exec bot alembic revision -m "$(m)" 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: docker-db:
@echo "🐘 Запуск PostgreSQL для локальной разработки..." @echo "🐘 Запуск PostgreSQL для локальной разработки..."
@if [ ! -f .env ]; then \ @if [ ! -f .env ]; then \

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' ui_lang_code = getattr(user, 'language_interface', 'ru') or 'ru'
translation_lang_code = get_translation_language(user) translation_lang_code = get_translation_language(user)
current_level = get_user_level_for_language(user) current_level = get_user_level_for_language(user)
tasks_count = getattr(user, 'tasks_count', 5) or 5
keyboard = InlineKeyboardMarkup(inline_keyboard=[ keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton( [InlineKeyboardButton(
text=t(lang, 'settings.level_prefix') + f"{current_level}", 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}'), text=t(lang, 'settings.translation_prefix') + t(lang, f'settings.lang_name.{translation_lang_code}'),
callback_data="settings_translation" callback_data="settings_translation"
)], )],
[InlineKeyboardButton(
text=t(lang, 'settings.tasks_count_prefix') + str(tasks_count),
callback_data="settings_tasks_count"
)],
[InlineKeyboardButton( [InlineKeyboardButton(
text=t(lang, 'settings.close'), text=t(lang, 'settings.close'),
callback_data="settings_close" callback_data="settings_close"
@@ -113,6 +118,26 @@ def get_learning_language_keyboard(user=None) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=keyboard) 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")) @router.message(Command("settings"))
async def cmd_settings(message: Message): async def cmd_settings(message: Message):
"""Обработчик команды /settings""" """Обработчик команды /settings"""
@@ -270,6 +295,39 @@ async def set_translation_language(callback: CallbackQuery):
await callback.answer() 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") @router.callback_query(F.data == "settings_back")
async def settings_back(callback: CallbackQuery): async def settings_back(callback: CallbackQuery):
"""Вернуться к настройкам""" """Вернуться к настройкам"""

View File

@@ -19,10 +19,25 @@ router = Router()
class TaskStates(StatesGroup): class TaskStates(StatesGroup):
"""Состояния для прохождения заданий""" """Состояния для прохождения заданий"""
choosing_mode = State() choosing_mode = State()
choosing_type = State() # Выбор типа заданий
doing_tasks = State() doing_tasks = State()
waiting_for_answer = 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")) @router.message(Command("task"))
async def cmd_task(message: Message, state: FSMContext): async def cmd_task(message: Message, state: FSMContext):
"""Обработчик команды /task — показываем меню выбора режима""" """Обработчик команды /task — показываем меню выбора режима"""
@@ -52,8 +67,8 @@ async def cmd_task(message: Message, state: FSMContext):
@router.callback_query(F.data == "task_mode_vocabulary", TaskStates.choosing_mode) @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() await callback.answer()
async with async_session_maker() as session: async with async_session_maker() as session:
@@ -65,9 +80,42 @@ async def start_vocabulary_tasks(callback: CallbackQuery, state: FSMContext):
lang = get_user_lang(user) lang = get_user_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)
)
@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)
if not user:
await callback.message.edit_text(t('ru', 'common.start_first'))
return
lang = get_user_lang(user)
# Получаем количество заданий из настроек пользователя
tasks_count = getattr(user, 'tasks_count', 5) or 5
if mode == 'vocabulary':
# Генерируем задания по словам из словаря # Генерируем задания по словам из словаря
tasks = await TaskService.generate_mixed_tasks( tasks = await TaskService.generate_tasks_by_type(
session, user.id, count=5, session, user.id, count=tasks_count,
task_type=task_type,
learning_lang=user.learning_language, learning_lang=user.learning_language,
translation_lang=get_user_translation_lang(user), translation_lang=get_user_translation_lang(user),
) )
@@ -83,42 +131,36 @@ async def start_vocabulary_tasks(callback: CallbackQuery, state: FSMContext):
current_task_index=0, current_task_index=0,
correct_count=0, correct_count=0,
user_id=user.id, user_id=user.id,
mode='vocabulary' mode='vocabulary',
task_type=task_type
) )
await state.set_state(TaskStates.doing_tasks) await state.set_state(TaskStates.doing_tasks)
await callback.message.delete() await callback.message.delete()
await show_current_task(callback.message, state) await show_current_task(callback.message, state)
else:
# Режим new_words - генерируем новые слова
await generate_new_words_tasks(callback, state, user, task_type)
@router.callback_query(F.data == "task_mode_new", TaskStates.choosing_mode)
async def start_new_words_tasks(callback: CallbackQuery, state: FSMContext):
"""Начать задания с новыми словами"""
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
async def generate_new_words_tasks(callback: CallbackQuery, state: FSMContext, user, task_type: str):
"""Генерация заданий с новыми словами"""
lang = get_user_lang(user) lang = get_user_lang(user)
level = get_user_level_for_language(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')) await callback.message.edit_text(t(lang, 'tasks.generating_new'))
# Получаем слова для исключения: async with async_session_maker() as session:
# 1. Все слова из словаря пользователя # Получаем слова для исключения
vocab_words = await VocabularyService.get_all_user_word_strings( vocab_words = await VocabularyService.get_all_user_word_strings(
session, user.id, learning_lang=user.learning_language session, user.id, learning_lang=user.learning_language
) )
# 2. Слова из предыдущих заданий new_words, на которые ответили правильно
correct_task_words = await TaskService.get_correctly_answered_words( correct_task_words = await TaskService.get_correctly_answered_words(
session, user.id session, user.id
) )
# Объединяем списки исключений
exclude_words = list(set(vocab_words + correct_task_words)) exclude_words = list(set(vocab_words + correct_task_words))
# Генерируем новые слова через AI # Генерируем новые слова через AI
@@ -126,7 +168,7 @@ async def start_new_words_tasks(callback: CallbackQuery, state: FSMContext):
words = await ai_service.generate_thematic_words( words = await ai_service.generate_thematic_words(
theme="random everyday vocabulary", theme="random everyday vocabulary",
level=level, level=level,
count=5, count=tasks_count,
learning_lang=user.learning_language, learning_lang=user.learning_language,
translation_lang=translation_lang, translation_lang=translation_lang,
exclude_words=exclude_words if exclude_words else None, exclude_words=exclude_words if exclude_words else None,
@@ -137,26 +179,16 @@ async def start_new_words_tasks(callback: CallbackQuery, state: FSMContext):
await state.clear() await state.clear()
return return
# Преобразуем слова в задания # Преобразуем слова в задания нужного типа
tasks = [] tasks = await create_tasks_from_words(words, task_type, lang, user.learning_language, translation_lang)
translate_prompt = t(lang, 'tasks.translate_to', lang_name=t(lang, f'lang.{translation_lang}'))
for word in words:
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', '') # Перевод примера
})
await state.update_data( await state.update_data(
tasks=tasks, tasks=tasks,
current_task_index=0, current_task_index=0,
correct_count=0, correct_count=0,
user_id=user.id, user_id=user.id,
mode='new_words' mode='new_words',
task_type=task_type
) )
await state.set_state(TaskStates.doing_tasks) await state.set_state(TaskStates.doing_tasks)
@@ -164,6 +196,115 @@ async def start_new_words_tasks(callback: CallbackQuery, state: FSMContext):
await show_current_task(callback.message, state) 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}: <b>{word_text}</b>",
'word': word_text,
'correct_answer': translation,
'transcription': transcription,
'example': example,
'example_translation': example_translation
})
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 = "Заполни пропуск:"
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): async def show_current_task(message: Message, state: FSMContext):
"""Показать текущее задание""" """Показать текущее задание"""
data = await state.get_data() data = await state.get_data()

View File

@@ -10,6 +10,9 @@ class Settings(BaseSettings):
# OpenAI # OpenAI
openai_api_key: str openai_api_key: str
# Google AI (Gemini)
google_api_key: str = ""
# Cloudflare AI Gateway (опционально) # Cloudflare AI Gateway (опционально)
cloudflare_account_id: str = "" cloudflare_account_id: str = ""
cloudflare_gateway_id: str = "gpt" cloudflare_gateway_id: str = "gpt"
@@ -23,6 +26,7 @@ class Settings(BaseSettings):
# App settings # App settings
debug: bool = False debug: bool = False
admin_ids: str = "" # Список ID админов через запятую (например "123456789,987654321")
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file='.env', env_file='.env',

View File

@@ -48,6 +48,12 @@ class WordSource(str, enum.Enum):
AI_TASK = "ai_task" # Из AI-задания AI_TASK = "ai_task" # Из AI-задания
class AIProvider(str, enum.Enum):
"""Провайдеры AI моделей"""
openai = "openai"
google = "google"
class User(Base): class User(Base):
"""Модель пользователя""" """Модель пользователя"""
__tablename__ = "users" __tablename__ = "users"
@@ -65,6 +71,7 @@ class User(Base):
reminders_enabled: Mapped[bool] = mapped_column(Boolean, default=False) reminders_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
last_reminder_sent: Mapped[Optional[datetime]] = mapped_column(DateTime) 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)
tasks_count: Mapped[int] = mapped_column(Integer, default=5) # Количество заданий (5-15)
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)
@@ -121,3 +128,15 @@ class Task(Base):
ai_feedback: Mapped[Optional[str]] = mapped_column(String(1000)) ai_feedback: Mapped[Optional[str]] = mapped_column(String(1000))
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime) completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) 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)

View File

@@ -115,6 +115,11 @@
"choose_mode": "🧠 <b>Choose task mode:</b>", "choose_mode": "🧠 <b>Choose task mode:</b>",
"mode_vocabulary": "📚 Words from vocabulary", "mode_vocabulary": "📚 Words from vocabulary",
"mode_new_words": "✨ New words", "mode_new_words": "✨ New words",
"choose_type": "📋 <b>Choose task type:</b>",
"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...", "generating_new": "🔄 Generating new words...",
"generate_failed": "❌ Failed to generate words. Try again later.", "generate_failed": "❌ Failed to generate words. Try again later.",
"translate_to": "Translate to {lang_name}", "translate_to": "Translate to {lang_name}",
@@ -236,6 +241,10 @@
"translation_title": "💬 <b>Select translation language:</b>\n\n", "translation_title": "💬 <b>Select translation language:</b>\n\n",
"translation_desc": "Words will be translated to this language.\nThis can differ from interface language.", "translation_desc": "Words will be translated to this language.\nThis can differ from interface language.",
"translation_changed": "✅ Translation language: <b>{lang_name}</b>", "translation_changed": "✅ Translation language: <b>{lang_name}</b>",
"tasks_count_prefix": "🔢 Tasks: ",
"tasks_count_title": "🔢 <b>Number of tasks:</b>\n\n",
"tasks_count_desc": "How many tasks to generate at once.\nMinimum 5, maximum 15.",
"tasks_count_changed": "✅ Number of tasks: <b>{count}</b>",
"menu_updated": "Main menu updated ⤵️", "menu_updated": "Main menu updated ⤵️",
"lang_name": { "lang_name": {
"ru": "🇷🇺 Русский", "ru": "🇷🇺 Русский",

View File

@@ -107,6 +107,11 @@
"choose_mode": "🧠 <b>課題モードを選択:</b>", "choose_mode": "🧠 <b>課題モードを選択:</b>",
"mode_vocabulary": "📚 単語帳から", "mode_vocabulary": "📚 単語帳から",
"mode_new_words": "✨ 新しい単語", "mode_new_words": "✨ 新しい単語",
"choose_type": "📋 <b>課題の種類を選択:</b>",
"type_mix": "🎲 ミックス(全種類)",
"type_word_translate": "📝 単語翻訳",
"type_fill_blank": "✏️ 穴埋め",
"type_sentence_translate": "📖 文翻訳",
"generating_new": "🔄 新しい単語を生成中...", "generating_new": "🔄 新しい単語を生成中...",
"generate_failed": "❌ 単語の生成に失敗しました。後でもう一度お試しください。", "generate_failed": "❌ 単語の生成に失敗しました。後でもう一度お試しください。",
"translate_to": "{lang_name}に翻訳", "translate_to": "{lang_name}に翻訳",
@@ -228,6 +233,10 @@
"translation_title": "💬 <b>翻訳言語を選択:</b>\n\n", "translation_title": "💬 <b>翻訳言語を選択:</b>\n\n",
"translation_desc": "単語はこの言語に翻訳されます。\nインターフェース言語と異なる設定が可能です。", "translation_desc": "単語はこの言語に翻訳されます。\nインターフェース言語と異なる設定が可能です。",
"translation_changed": "✅ 翻訳言語: <b>{lang_name}</b>", "translation_changed": "✅ 翻訳言語: <b>{lang_name}</b>",
"tasks_count_prefix": "🔢 課題数: ",
"tasks_count_title": "🔢 <b>課題数:</b>\n\n",
"tasks_count_desc": "一度に生成する課題数。\n最小5、最大15。",
"tasks_count_changed": "✅ 課題数: <b>{count}</b>",
"menu_updated": "メインメニューを更新しました ⤵️", "menu_updated": "メインメニューを更新しました ⤵️",
"lang_name": { "lang_name": {
"ru": "🇷🇺 Русский", "ru": "🇷🇺 Русский",

View File

@@ -115,6 +115,11 @@
"choose_mode": "🧠 <b>Выбери режим заданий:</b>", "choose_mode": "🧠 <b>Выбери режим заданий:</b>",
"mode_vocabulary": "📚 Слова из словаря", "mode_vocabulary": "📚 Слова из словаря",
"mode_new_words": "✨ Новые слова", "mode_new_words": "✨ Новые слова",
"choose_type": "📋 <b>Выбери тип заданий:</b>",
"type_mix": "🎲 Микс (все типы)",
"type_word_translate": "📝 Перевод слов",
"type_fill_blank": "✏️ Подстановка слова",
"type_sentence_translate": "📖 Перевод предложений",
"generating_new": "🔄 Генерирую новые слова...", "generating_new": "🔄 Генерирую новые слова...",
"generate_failed": "❌ Не удалось сгенерировать слова. Попробуй позже.", "generate_failed": "❌ Не удалось сгенерировать слова. Попробуй позже.",
"translate_to": "Переведи на {lang_name}", "translate_to": "Переведи на {lang_name}",
@@ -236,6 +241,10 @@
"translation_title": "💬 <b>Выбери язык перевода:</b>\n\n", "translation_title": "💬 <b>Выбери язык перевода:</b>\n\n",
"translation_desc": "На этот язык будут переводиться слова.\nЭто может отличаться от языка интерфейса.", "translation_desc": "На этот язык будут переводиться слова.\nЭто может отличаться от языка интерфейса.",
"translation_changed": "✅ Язык перевода: <b>{lang_name}</b>", "translation_changed": "✅ Язык перевода: <b>{lang_name}</b>",
"tasks_count_prefix": "🔢 Заданий: ",
"tasks_count_title": "🔢 <b>Количество заданий:</b>\n\n",
"tasks_count_desc": "Сколько заданий генерировать за один раз.\nМинимум 5, максимум 15.",
"tasks_count_changed": "✅ Количество заданий: <b>{count}</b>",
"menu_updated": "Клавиатура обновлена ⤵️", "menu_updated": "Клавиатура обновлена ⤵️",
"lang_name": { "lang_name": {
"ru": "🇷🇺 Русский", "ru": "🇷🇺 Русский",

View File

@@ -7,7 +7,7 @@ from aiogram.enums import ParseMode
from aiogram.types import BotCommand from aiogram.types import BotCommand
from config.settings import settings 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 database.db import init_db
from services.reminder_service import init_reminder_service from services.reminder_service import init_reminder_service
@@ -52,6 +52,7 @@ async def main():
dp.include_router(import_text.router) dp.include_router(import_text.router)
dp.include_router(practice.router) dp.include_router(practice.router)
dp.include_router(reminder.router) dp.include_router(reminder.router)
dp.include_router(admin.router)
# Инициализация базы данных # Инициализация базы данных
await init_db() await init_db()

View File

@@ -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')

View File

@@ -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')

View File

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

View File

@@ -2,56 +2,160 @@ import logging
import httpx import httpx
from openai import AsyncOpenAI from openai import AsyncOpenAI
from config.settings import settings 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__) logger = logging.getLogger(__name__)
class AIService: class AIService:
"""Сервис для работы с OpenAI API через Cloudflare Gateway""" """Сервис для работы с AI API (OpenAI и Google)"""
def __init__(self): 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 # Проверяем, настроен ли Cloudflare AI Gateway
if settings.cloudflare_account_id: if settings.cloudflare_account_id:
# Используем Cloudflare AI Gateway с прямыми HTTP запросами # Используем Cloudflare AI Gateway с прямыми HTTP запросами
self.base_url = ( self.openai_base_url = (
f"https://gateway.ai.cloudflare.com/v1/" f"https://gateway.ai.cloudflare.com/v1/"
f"{settings.cloudflare_account_id}/" f"{settings.cloudflare_account_id}/"
f"{settings.cloudflare_gateway_id}/" f"{settings.cloudflare_gateway_id}/"
f"openai" f"openai"
) )
self.use_cloudflare = True 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: else:
# Прямое подключение к OpenAI # Прямое подключение к OpenAI
self.base_url = "https://api.openai.com/v1" self.openai_base_url = "https://api.openai.com/v1"
self.use_cloudflare = False self.use_cloudflare = False
logger.info("AI Service initialized with direct OpenAI connection") 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 клиент для всех запросов # HTTP клиент для всех запросов
self.http_client = httpx.AsyncClient( self.http_client = httpx.AsyncClient(
timeout=httpx.Timeout(60.0, connect=10.0), timeout=httpx.Timeout(60.0, connect=10.0),
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10) 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 или напрямую)""" self._cached_model: Optional[str] = None
url = f"{self.base_url}/chat/completions" 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 = { 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" "Content-Type": "application/json"
} }
payload = { payload = {
"model": model, "model": model,
"messages": messages, "messages": messages
"temperature": temperature,
"response_format": {"type": "json_object"}
} }
# Модели с ограничениями (не поддерживают 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 = await self.http_client.post(url, headers=headers, json=payload)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
@@ -85,22 +189,22 @@ class AIService:
Важно: верни только JSON, без дополнительного текста.""" Важно: верни только JSON, без дополнительного текста."""
try: 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 = [ messages = [
{"role": "system", "content": "Ты - помощник для изучения языков. Отвечай только в формате JSON."}, {"role": "system", "content": "Ты - помощник для изучения языков. Отвечай только в формате JSON."},
{"role": "user", "content": prompt} {"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 import json
result = json.loads(response_data['choices'][0]['message']['content']) 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 return result
except Exception as e: 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 в случае ошибки # Fallback в случае ошибки
return { return {
"word": word, "word": word,
@@ -164,14 +268,14 @@ class AIService:
- Верни только JSON, без дополнительного текста""" - Верни только JSON, без дополнительного текста"""
try: 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 = [ messages = [
{"role": "system", "content": "Ты - помощник для изучения языков. Отвечай только в формате JSON."}, {"role": "system", "content": "Ты - помощник для изучения языков. Отвечай только в формате JSON."},
{"role": "user", "content": prompt} {"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 import json
content = response_data['choices'][0]['message']['content'] content = response_data['choices'][0]['message']['content']
@@ -184,11 +288,11 @@ class AIService:
result = json.loads(content) result = json.loads(content)
translations_count = len(result.get('translations', [])) 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 return result
except Exception as e: 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 в случае ошибки # Fallback в случае ошибки
return { return {
"word": word, "word": word,
@@ -250,14 +354,14 @@ class AIService:
- Для каждого слова укажи точный перевод и транскрипцию""" - Для каждого слова укажи точный перевод и транскрипцию"""
try: 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 = [ messages = [
{"role": "system", "content": "Ты - помощник для изучения языков. Отвечай только в формате JSON."}, {"role": "system", "content": "Ты - помощник для изучения языков. Отвечай только в формате JSON."},
{"role": "user", "content": prompt} {"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 import json
content = response_data['choices'][0]['message']['content'] content = response_data['choices'][0]['message']['content']
@@ -278,14 +382,14 @@ class AIService:
break break
if not isinstance(result, list): 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] 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 return result
except Exception as e: 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] return [{"word": w, "translation": "", "transcription": ""} for w in words]
@@ -317,22 +421,22 @@ class AIService:
Учитывай возможные вариации ответа. Если смысл передан правильно, даже с небольшими грамматическими неточностями, засчитывай ответ.""" Учитывай возможные вариации ответа. Если смысл передан правильно, даже с небольшими грамматическими неточностями, засчитывай ответ."""
try: 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 = [ messages = [
{"role": "system", "content": "Ты - преподаватель английского языка. Проверяй ответы справедливо, учитывая контекст."}, {"role": "system", "content": "Ты - преподаватель английского языка. Проверяй ответы справедливо, учитывая контекст."},
{"role": "user", "content": prompt} {"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 import json
result = json.loads(response_data['choices'][0]['message']['content']) 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 return result
except Exception as e: 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 { return {
"is_correct": False, "is_correct": False,
"feedback": "Ошибка проверки ответа", "feedback": "Ошибка проверки ответа",
@@ -364,28 +468,73 @@ class AIService:
Предложение должно быть простым и естественным. Контекст должен четко подсказывать правильное слово.""" Предложение должно быть простым и естественным. Контекст должен четко подсказывать правильное слово."""
try: 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 = [ messages = [
{"role": "system", "content": "Ты - преподаватель иностранных языков. Создавай простые и понятные упражнения."}, {"role": "system", "content": "Ты - преподаватель иностранных языков. Создавай простые и понятные упражнения."},
{"role": "user", "content": prompt} {"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 import json
result = json.loads(response_data['choices'][0]['message']['content']) 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 return result
except Exception as e: 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 { return {
"sentence": f"I like to ___ every day.", "sentence": f"I like to ___ every day.",
"answer": word, "answer": word,
"translation": f"Мне нравится {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( async def generate_thematic_words(
self, self,
theme: str, theme: str,
@@ -445,14 +594,14 @@ class AIService:
- Разнообразными (существительные, глаголы, прилагательные)""" - Разнообразными (существительные, глаголы, прилагательные)"""
try: 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 = [ messages = [
{"role": "system", "content": "Ты - преподаватель иностранных языков. Подбирай полезные и актуальные слова."}, {"role": "system", "content": "Ты - преподаватель иностранных языков. Подбирай полезные и актуальные слова."},
{"role": "user", "content": prompt} {"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 import json
result = json.loads(response_data['choices'][0]['message']['content']) result = json.loads(response_data['choices'][0]['message']['content'])
@@ -466,14 +615,14 @@ class AIService:
] ]
filtered_count = len(words) - len(filtered_words) filtered_count = len(words) - len(filtered_words)
if filtered_count > 0: 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 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 return words
except Exception as e: 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 [] 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]: 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: try:
text_preview = text[:100] + "..." if len(text) > 100 else text 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 = [ messages = [
{"role": "system", "content": "Ты - преподаватель иностранных языков. Помогаешь извлекать полезные слова для изучения из текстов."}, {"role": "system", "content": "Ты - преподаватель иностранных языков. Помогаешь извлекать полезные слова для изучения из текстов."},
{"role": "user", "content": prompt} {"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 import json
result = json.loads(response_data['choices'][0]['message']['content']) result = json.loads(response_data['choices'][0]['message']['content'])
words_count = len(result.get('words', [])) 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', []) return result.get('words', [])
except Exception as e: 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 [] return []
async def start_conversation(self, scenario: str, level: str = "B1", learning_lang: str = "en", translation_lang: str = "ru") -> Dict: 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: 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 = [ messages = [
{"role": "system", "content": "Ты - дружелюбный собеседник для практики иностранных языков. Веди естественный диалог."}, {"role": "system", "content": "Ты - дружелюбный собеседник для практики иностранных языков. Веди естественный диалог."},
{"role": "user", "content": prompt} {"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 import json
result = json.loads(response_data['choices'][0]['message']['content']) 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 return result
except Exception as e: 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 { return {
"message": "Hello! How are you today?", "message": "Hello! How are you today?",
"translation": "Привет! Как дела сегодня?", "translation": "Привет! Как дела сегодня?",
@@ -667,7 +816,7 @@ User: {user_message}
- Используй лексику уровня {level}""" - Используй лексику уровня {level}"""
try: 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 # Формируем сообщения для API
messages = [ messages = [
@@ -684,16 +833,16 @@ User: {user_message}
# Добавляем инструкцию для форматирования ответа # Добавляем инструкцию для форматирования ответа
messages.append({"role": "user", "content": prompt}) 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 import json
result = json.loads(response_data['choices'][0]['message']['content']) result = json.loads(response_data['choices'][0]['message']['content'])
has_errors = result.get('feedback', {}).get('has_errors', False) 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 return result
except Exception as e: 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 { return {
"response": "I see. Tell me more about that.", "response": "I see. Tell me more about that.",
"translation": "Понятно. Расскажи мне больше об этом.", "translation": "Понятно. Расскажи мне больше об этом.",
@@ -756,7 +905,7 @@ User: {user_message}
- Вопросы на грамматику, лексику и понимание""" - Вопросы на грамматику, лексику и понимание"""
try: 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} языка. Создавай объективные тесты." system_msg = f"Ты - эксперт по тестированию уровня {language_name} языка. Создавай объективные тесты."
messages = [ messages = [
@@ -764,16 +913,16 @@ User: {user_message}
{"role": "user", "content": prompt} {"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 import json
result = json.loads(response_data['choices'][0]['message']['content']) result = json.loads(response_data['choices'][0]['message']['content'])
questions_count = len(result.get('questions', [])) 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', []) return result.get('questions', [])
except Exception as e: 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 с базовыми вопросами # Fallback с базовыми вопросами
if learning_language == "ja": if learning_language == "ja":
return self._get_jlpt_fallback_questions() return self._get_jlpt_fallback_questions()

View File

@@ -15,7 +15,8 @@ class TaskService:
async def generate_translation_tasks( async def generate_translation_tasks(
session: AsyncSession, session: AsyncSession,
user_id: int, user_id: int,
count: int = 5 count: int = 5,
learning_lang: str = 'en'
) -> List[Dict]: ) -> List[Dict]:
""" """
Генерация заданий на перевод слов Генерация заданий на перевод слов
@@ -28,10 +29,11 @@ class TaskService:
Returns: Returns:
Список заданий Список заданий
""" """
# Получаем слова пользователя # Получаем слова пользователя на изучаемом языке
result = await session.execute( result = await session.execute(
select(Vocabulary) select(Vocabulary)
.where(Vocabulary.user_id == user_id) .where(Vocabulary.user_id == user_id)
.where(Vocabulary.source_lang == learning_lang)
.order_by(Vocabulary.last_reviewed.asc().nullsfirst()) .order_by(Vocabulary.last_reviewed.asc().nullsfirst())
.limit(count * 2) # Берем больше, чтобы было из чего выбрать .limit(count * 2) # Берем больше, чтобы было из чего выбрать
) )
@@ -90,10 +92,11 @@ class TaskService:
Returns: Returns:
Список заданий разных типов Список заданий разных типов
""" """
# Получаем слова пользователя # Получаем слова пользователя на изучаемом языке
result = await session.execute( result = await session.execute(
select(Vocabulary) select(Vocabulary)
.where(Vocabulary.user_id == user_id) .where(Vocabulary.user_id == user_id)
.where(Vocabulary.source_lang == learning_lang)
.order_by(Vocabulary.last_reviewed.asc().nullsfirst()) .order_by(Vocabulary.last_reviewed.asc().nullsfirst())
.limit(count * 2) .limit(count * 2)
) )
@@ -230,6 +233,159 @@ class TaskService:
return tasks 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} <b>{word.word_original}</b>",
'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} <b>{correct_translation}</b>",
'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"<b>{sentence_data['sentence']}</b>\n\n"
f"<i>{sentence_data.get('translation', '')}</i>"
),
'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<b>{sentence_data['sentence']}</b>\n\n📝 {word_hint}: <code>{word.word_original}</code> — {correct_translation}",
'word': word.word_original,
'correct_answer': sentence_data['translation'],
'sentence': sentence_data['sentence']
}
tasks.append(task)
return tasks
@staticmethod @staticmethod
async def save_task_result( async def save_task_result(
session: AsyncSession, session: AsyncSession,

View File

@@ -156,3 +156,25 @@ class UserService:
if user: if user:
user.translation_language = language user.translation_language = language
await session.commit() 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()