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=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

View File

@@ -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
View File

@@ -0,0 +1,102 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from config.settings import settings
from database.db import async_session_maker
from database.models import AIProvider
from services.ai_model_service import AIModelService
router = Router()
def get_admin_ids() -> set:
"""Получить множество ID админов"""
if not settings.admin_ids:
return set()
return set(int(x.strip()) for x in settings.admin_ids.split(",") if x.strip())
def is_admin(user_id: int) -> bool:
"""Проверить, является ли пользователь админом"""
return user_id in get_admin_ids()
async def get_model_keyboard() -> InlineKeyboardMarkup:
"""Создать клавиатуру выбора AI модели"""
async with async_session_maker() as session:
models = await AIModelService.get_all_models(session)
keyboard = []
for model in models:
marker = "" if model.is_active else ""
provider_emoji = "🟢" if model.provider == AIProvider.openai else "🔵"
keyboard.append([InlineKeyboardButton(
text=f"{marker}{provider_emoji} {model.display_name}",
callback_data=f"admin_model_{model.id}"
)])
keyboard.append([InlineKeyboardButton(text="❌ Закрыть", callback_data="admin_close")])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@router.message(Command("admin"))
async def cmd_admin(message: Message):
"""Админская панель"""
if not is_admin(message.from_user.id):
return
# Убеждаемся что дефолтные модели созданы
async with async_session_maker() as session:
await AIModelService.ensure_default_models(session)
active_model = await AIModelService.get_active_model(session)
active_name = active_model.display_name if active_model else "Не выбрана"
text = (
"🔧 <b>Админ-панель</b>\n\n"
f"🤖 Текущая AI модель: <b>{active_name}</b>\n\n"
"Выберите модель для генерации:"
)
keyboard = await get_model_keyboard()
await message.answer(text, reply_markup=keyboard)
@router.callback_query(F.data.startswith("admin_model_"))
async def set_admin_model(callback: CallbackQuery):
"""Установить AI модель"""
if not is_admin(callback.from_user.id):
await callback.answer("⛔ Доступ запрещен", show_alert=True)
return
model_id = int(callback.data.split("_")[-1])
async with async_session_maker() as session:
success = await AIModelService.set_active_model(session, model_id)
if success:
active_model = await AIModelService.get_active_model(session)
await callback.answer(f"✅ Модель изменена: {active_model.display_name}")
text = (
"🔧 <b>Админ-панель</b>\n\n"
f"🤖 Текущая AI модель: <b>{active_model.display_name}</b>\n\n"
"Выберите модель для генерации:"
)
else:
await callback.answer("❌ Ошибка при смене модели", show_alert=True)
text = "🔧 <b>Админ-панель</b>\n\n❌ Ошибка при смене модели"
keyboard = await get_model_keyboard()
await callback.message.edit_text(text, reply_markup=keyboard)
@router.callback_query(F.data == "admin_close")
async def admin_close(callback: CallbackQuery):
"""Закрыть админ-панель"""
if not is_admin(callback.from_user.id):
return
await callback.message.delete()
await callback.answer()

View File

@@ -28,6 +28,7 @@ def get_settings_keyboard(user) -> InlineKeyboardMarkup:
ui_lang_code = getattr(user, 'language_interface', 'ru') or 'ru'
translation_lang_code = get_translation_language(user)
current_level = get_user_level_for_language(user)
tasks_count = getattr(user, 'tasks_count', 5) or 5
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=t(lang, 'settings.level_prefix') + f"{current_level}",
@@ -45,6 +46,10 @@ def get_settings_keyboard(user) -> InlineKeyboardMarkup:
text=t(lang, 'settings.translation_prefix') + t(lang, f'settings.lang_name.{translation_lang_code}'),
callback_data="settings_translation"
)],
[InlineKeyboardButton(
text=t(lang, 'settings.tasks_count_prefix') + str(tasks_count),
callback_data="settings_tasks_count"
)],
[InlineKeyboardButton(
text=t(lang, 'settings.close'),
callback_data="settings_close"
@@ -113,6 +118,26 @@ def get_learning_language_keyboard(user=None) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_tasks_count_keyboard(user=None) -> InlineKeyboardMarkup:
"""Клавиатура выбора количества заданий"""
lang = get_user_lang(user)
current_count = getattr(user, 'tasks_count', 5) or 5
# Создаём кнопки для каждого значения (5, 7, 10, 12, 15)
counts = [5, 7, 10, 12, 15]
keyboard = []
for count in counts:
marker = "" if count == current_count else ""
keyboard.append([InlineKeyboardButton(
text=f"{marker}{count}",
callback_data=f"set_tasks_count_{count}"
)])
keyboard.append([InlineKeyboardButton(text=t(lang, 'settings.back'), callback_data="settings_back")])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@router.message(Command("settings"))
async def cmd_settings(message: Message):
"""Обработчик команды /settings"""
@@ -270,6 +295,39 @@ async def set_translation_language(callback: CallbackQuery):
await callback.answer()
@router.callback_query(F.data == "settings_tasks_count")
async def settings_tasks_count(callback: CallbackQuery):
"""Показать выбор количества заданий"""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user)
await callback.message.edit_text(
t(lang, 'settings.tasks_count_title') + t(lang, 'settings.tasks_count_desc'),
reply_markup=get_tasks_count_keyboard(user)
)
await callback.answer()
@router.callback_query(F.data.startswith("set_tasks_count_"))
async def set_tasks_count(callback: CallbackQuery):
"""Установить количество заданий"""
new_count = int(callback.data.split("_")[-1])
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
if user:
await UserService.update_user_tasks_count(session, user.id, new_count)
lang = get_user_lang(user)
text = t(lang, 'settings.tasks_count_changed', count=new_count)
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=t(lang, 'settings.back'), callback_data="settings_back")]])
)
await callback.answer()
@router.callback_query(F.data == "settings_back")
async def settings_back(callback: CallbackQuery):
"""Вернуться к настройкам"""

View File

@@ -19,10 +19,25 @@ router = Router()
class TaskStates(StatesGroup):
"""Состояния для прохождения заданий"""
choosing_mode = State()
choosing_type = State() # Выбор типа заданий
doing_tasks = State()
waiting_for_answer = State()
# Типы заданий
TASK_TYPES = ['mix', 'word_translate', 'fill_blank', 'sentence_translate']
def get_task_type_keyboard(lang: str) -> InlineKeyboardMarkup:
"""Клавиатура выбора типа заданий"""
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'tasks.type_mix'), callback_data="task_type_mix")],
[InlineKeyboardButton(text=t(lang, 'tasks.type_word_translate'), callback_data="task_type_word_translate")],
[InlineKeyboardButton(text=t(lang, 'tasks.type_fill_blank'), callback_data="task_type_fill_blank")],
[InlineKeyboardButton(text=t(lang, 'tasks.type_sentence_translate'), callback_data="task_type_sentence_translate")],
])
@router.message(Command("task"))
async def cmd_task(message: Message, state: FSMContext):
"""Обработчик команды /task — показываем меню выбора режима"""
@@ -52,8 +67,8 @@ async def cmd_task(message: Message, state: FSMContext):
@router.callback_query(F.data == "task_mode_vocabulary", TaskStates.choosing_mode)
async def start_vocabulary_tasks(callback: CallbackQuery, state: FSMContext):
"""Начать задания по словам из словаря"""
async def choose_vocabulary_task_type(callback: CallbackQuery, state: FSMContext):
"""Показать выбор типа заданий для режима vocabulary"""
await callback.answer()
async with async_session_maker() as session:
@@ -65,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()

View File

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

View File

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

View File

@@ -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": "🇷🇺 Русский",

View File

@@ -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": "🇷🇺 Русский",

View File

@@ -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": "🇷🇺 Русский",

View File

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

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

View File

@@ -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,

View File

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