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:
@@ -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
|
||||
|
||||
18
Makefile
18
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 \
|
||||
|
||||
102
bot/handlers/admin.py
Normal file
102
bot/handlers/admin.py
Normal 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()
|
||||
@@ -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):
|
||||
"""Вернуться к настройкам"""
|
||||
|
||||
@@ -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,9 +80,42 @@ async def start_vocabulary_tasks(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
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(
|
||||
session, user.id, count=5,
|
||||
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),
|
||||
)
|
||||
@@ -83,42 +131,36 @@ async def start_vocabulary_tasks(callback: CallbackQuery, state: FSMContext):
|
||||
current_task_index=0,
|
||||
correct_count=0,
|
||||
user_id=user.id,
|
||||
mode='vocabulary'
|
||||
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)
|
||||
|
||||
@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)
|
||||
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'))
|
||||
|
||||
# Получаем слова для исключения:
|
||||
# 1. Все слова из словаря пользователя
|
||||
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
|
||||
@@ -126,7 +168,7 @@ async def start_new_words_tasks(callback: CallbackQuery, state: FSMContext):
|
||||
words = await ai_service.generate_thematic_words(
|
||||
theme="random everyday vocabulary",
|
||||
level=level,
|
||||
count=5,
|
||||
count=tasks_count,
|
||||
learning_lang=user.learning_language,
|
||||
translation_lang=translation_lang,
|
||||
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()
|
||||
return
|
||||
|
||||
# Преобразуем слова в задания
|
||||
tasks = []
|
||||
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', '') # Перевод примера
|
||||
})
|
||||
# Преобразуем слова в задания нужного типа
|
||||
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'
|
||||
mode='new_words',
|
||||
task_type=task_type
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""Показать текущее задание"""
|
||||
data = await state.get_data()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -115,6 +115,11 @@
|
||||
"choose_mode": "🧠 <b>Choose task mode:</b>",
|
||||
"mode_vocabulary": "📚 Words from vocabulary",
|
||||
"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...",
|
||||
"generate_failed": "❌ Failed to generate words. Try again later.",
|
||||
"translate_to": "Translate to {lang_name}",
|
||||
@@ -236,6 +241,10 @@
|
||||
"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_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 ⤵️",
|
||||
"lang_name": {
|
||||
"ru": "🇷🇺 Русский",
|
||||
|
||||
@@ -107,6 +107,11 @@
|
||||
"choose_mode": "🧠 <b>課題モードを選択:</b>",
|
||||
"mode_vocabulary": "📚 単語帳から",
|
||||
"mode_new_words": "✨ 新しい単語",
|
||||
"choose_type": "📋 <b>課題の種類を選択:</b>",
|
||||
"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": "💬 <b>翻訳言語を選択:</b>\n\n",
|
||||
"translation_desc": "単語はこの言語に翻訳されます。\nインターフェース言語と異なる設定が可能です。",
|
||||
"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": "メインメニューを更新しました ⤵️",
|
||||
"lang_name": {
|
||||
"ru": "🇷🇺 Русский",
|
||||
|
||||
@@ -115,6 +115,11 @@
|
||||
"choose_mode": "🧠 <b>Выбери режим заданий:</b>",
|
||||
"mode_vocabulary": "📚 Слова из словаря",
|
||||
"mode_new_words": "✨ Новые слова",
|
||||
"choose_type": "📋 <b>Выбери тип заданий:</b>",
|
||||
"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": "💬 <b>Выбери язык перевода:</b>\n\n",
|
||||
"translation_desc": "На этот язык будут переводиться слова.\nЭто может отличаться от языка интерфейса.",
|
||||
"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": "Клавиатура обновлена ⤵️",
|
||||
"lang_name": {
|
||||
"ru": "🇷🇺 Русский",
|
||||
|
||||
3
main.py
3
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()
|
||||
|
||||
46
migrations/versions/20251208_add_ai_models.py
Normal file
46
migrations/versions/20251208_add_ai_models.py
Normal 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')
|
||||
28
migrations/versions/20251208_add_tasks_count.py
Normal file
28
migrations/versions/20251208_add_tasks_count.py
Normal 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')
|
||||
190
services/ai_model_service.py
Normal file
190
services/ai_model_service.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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} <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
|
||||
async def save_task_result(
|
||||
session: AsyncSession,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user