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