diff --git a/Makefile b/Makefile
index e50b9e5..1fd7563 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,7 @@
.PHONY: help venv install run clean \
docker-up docker-down docker-logs docker-rebuild docker-restart \
- docker-bot-restart docker-bot-rebuild docker-bot-build \
+ docker-bot-restart docker-bot-rebuild docker-bot-build docker-bot-rebuild-full \
migrate migrate-down migrate-current migrate-revision \
local-migrate local-migrate-down local-migrate-current \
docker-db docker-db-stop
@@ -90,7 +90,13 @@ docker-bot-build:
docker-bot-rebuild:
docker-compose stop bot
- docker-compose rm -f bot
+ docker-compose rm bot
+ docker-compose build --no-cache bot
+ docker-compose up -d bot
+
+docker-bot-rebuild-full:
+ docker-compose stop bot
+ docker-compose rm -rf bot
docker-compose build --no-cache bot
docker-compose up -d bot
diff --git a/bot/handlers/import_text.py b/bot/handlers/import_text.py
index 5b71dec..0304f77 100644
--- a/bot/handlers/import_text.py
+++ b/bot/handlers/import_text.py
@@ -187,7 +187,7 @@ async def import_single_word(callback: CallbackQuery, state: FSMContext):
# Проверяем, нет ли уже такого слова
existing = await VocabularyService.get_word_by_original(
- session, user_id, word_data['word']
+ session, user_id, word_data['word'], source_lang=user.learning_language
)
if existing:
@@ -195,10 +195,7 @@ async def import_single_word(callback: CallbackQuery, state: FSMContext):
return
# Добавляем слово
- learn = user.learning_language if user else 'en'
translation_lang = get_user_translation_lang(user)
- ctx = word_data.get('context')
- examples = ([{learn: ctx, translation_lang: ''}] if ctx else [])
await VocabularyService.add_word(
session=session,
@@ -208,10 +205,8 @@ async def import_single_word(callback: CallbackQuery, state: FSMContext):
source_lang=user.learning_language if user else None,
translation_lang=translation_lang,
transcription=word_data.get('transcription'),
- examples=examples,
- source=WordSource.CONTEXT,
- category='imported',
- difficulty_level=data.get('level')
+ difficulty_level=data.get('level'),
+ source=WordSource.CONTEXT
)
lang = (user.language_interface if user else 'ru') or 'ru'
@@ -235,7 +230,7 @@ async def import_all_words(callback: CallbackQuery, state: FSMContext):
for word_data in words:
# Проверяем, нет ли уже такого слова
existing = await VocabularyService.get_word_by_original(
- session, user_id, word_data['word']
+ session, user_id, word_data['word'], source_lang=user.learning_language
)
if existing:
@@ -243,10 +238,7 @@ async def import_all_words(callback: CallbackQuery, state: FSMContext):
continue
# Добавляем слово
- learn = user.learning_language if user else 'en'
translation_lang = get_user_translation_lang(user)
- ctx = word_data.get('context')
- examples = ([{learn: ctx, translation_lang: ''}] if ctx else [])
await VocabularyService.add_word(
session=session,
@@ -256,10 +248,8 @@ async def import_all_words(callback: CallbackQuery, state: FSMContext):
source_lang=user.learning_language if user else None,
translation_lang=translation_lang,
transcription=word_data.get('transcription'),
- examples=examples,
- source=WordSource.CONTEXT,
- category='imported',
- difficulty_level=data.get('level')
+ difficulty_level=data.get('level'),
+ source=WordSource.CONTEXT
)
added_count += 1
@@ -478,7 +468,7 @@ async def import_file_all_words(callback: CallbackQuery, state: FSMContext):
for word_data in words:
# Проверяем, нет ли уже такого слова
existing = await VocabularyService.get_word_by_original(
- session, user_id, word_data['word']
+ session, user_id, word_data['word'], source_lang=user.learning_language
)
if existing:
diff --git a/bot/handlers/tasks.py b/bot/handlers/tasks.py
index 39fb241..bf872c4 100644
--- a/bot/handlers/tasks.py
+++ b/bot/handlers/tasks.py
@@ -180,7 +180,10 @@ async def generate_new_words_tasks(callback: CallbackQuery, state: FSMContext, u
return
# Преобразуем слова в задания нужного типа
- tasks = await create_tasks_from_words(words, task_type, lang, user.learning_language, translation_lang)
+ tasks = await create_tasks_from_words(
+ words, task_type, lang, user.learning_language, translation_lang,
+ level=level
+ )
await state.update_data(
tasks=tasks,
@@ -196,26 +199,68 @@ async def generate_new_words_tasks(callback: CallbackQuery, state: FSMContext, u
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:
- """Создать задания из списка слов в зависимости от типа"""
+async def create_tasks_from_words(
+ words: list,
+ task_type: str,
+ lang: str,
+ learning_lang: str,
+ translation_lang: str,
+ level: str = None
+) -> list:
+ """Создать задания из списка слов в зависимости от типа (оптимизировано - 1 запрос к AI)"""
import random
- tasks = []
+ # 1. Определяем типы заданий для всех слов
+ word_tasks = []
for word in words:
+ if task_type == 'mix':
+ chosen_type = random.choice(['word_translate', 'fill_blank', 'sentence_translate'])
+ else:
+ chosen_type = task_type
+ word_tasks.append({
+ 'word_data': word,
+ 'chosen_type': chosen_type
+ })
+
+ # 2. Собираем задания, требующие генерации предложений
+ ai_tasks = []
+ ai_task_indices = [] # Индексы в word_tasks для сопоставления результатов
+
+ for i, wt in enumerate(word_tasks):
+ if wt['chosen_type'] in ('fill_blank', 'sentence_translate'):
+ ai_tasks.append({
+ 'word': wt['word_data'].get('word', ''),
+ 'task_type': wt['chosen_type']
+ })
+ ai_task_indices.append(i)
+
+ # 3. Один запрос к AI для всех предложений (если нужно)
+ ai_results = []
+ if ai_tasks:
+ ai_results = await ai_service.generate_task_sentences_batch(
+ ai_tasks,
+ learning_lang=learning_lang,
+ translation_lang=translation_lang
+ )
+
+ # Создаём маппинг: индекс в word_tasks -> результат AI
+ ai_results_map = {}
+ for idx, result in zip(ai_task_indices, ai_results):
+ ai_results_map[idx] = result
+
+ # 4. Собираем финальные задания
+ tasks = []
+ for i, wt in enumerate(word_tasks):
+ word = wt['word_data']
+ chosen_type = wt['chosen_type']
+
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',
@@ -224,16 +269,12 @@ async def create_tasks_from_words(words: list, task_type: str, lang: str, learni
'correct_answer': translation,
'transcription': transcription,
'example': example,
- 'example_translation': example_translation
+ 'example_translation': example_translation,
+ 'difficulty_level': level
})
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
- )
+ sentence_data = ai_results_map.get(i, {})
if translation_lang == 'en':
fill_title = "Fill in the blank:"
elif translation_lang == 'ja':
@@ -243,21 +284,17 @@ async def create_tasks_from_words(words: list, task_type: str, lang: str, learni
tasks.append({
'type': 'fill_in',
- 'question': f"{fill_title}\n\n{sentence_data['sentence']}\n\n{sentence_data.get('translation', '')}",
+ 'question': f"{fill_title}\n\n{sentence_data.get('sentence', '___')}\n\n{sentence_data.get('translation', '')}",
'word': word_text,
- 'correct_answer': sentence_data['answer'],
+ 'correct_answer': sentence_data.get('answer', word_text),
'transcription': transcription,
'example': example,
- 'example_translation': example_translation
+ 'example_translation': example_translation,
+ 'difficulty_level': level
})
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
- )
+ sentence_data = ai_results_map.get(i, {})
if translation_lang == 'en':
sentence_title = "Translate the sentence:"
word_hint = "Word"
@@ -270,12 +307,13 @@ async def create_tasks_from_words(words: list, task_type: str, lang: str, learni
tasks.append({
'type': 'sentence_translate',
- 'question': f"{sentence_title}\n\n{sentence_data['sentence']}\n\n📝 {word_hint}: {word_text} — {translation}",
+ 'question': f"{sentence_title}\n\n{sentence_data.get('sentence', word_text)}\n\n📝 {word_hint}: {word_text} — {translation}",
'word': word_text,
- 'correct_answer': sentence_data['translation'],
+ 'correct_answer': sentence_data.get('translation', translation),
'transcription': transcription,
'example': example,
- 'example_translation': example_translation
+ 'example_translation': example_translation,
+ 'difficulty_level': level
})
return tasks
@@ -468,6 +506,7 @@ async def add_task_word(callback: CallbackQuery, state: FSMContext):
transcription = task.get('transcription', '')
example = task.get('example', '') # Пример использования как контекст
example_translation = task.get('example_translation', '') # Перевод примера
+ difficulty_level = task.get('difficulty_level') # Уровень сложности
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
@@ -477,9 +516,10 @@ async def add_task_word(callback: CallbackQuery, state: FSMContext):
return
lang = get_user_lang(user)
+ translation_lang = get_user_translation_lang(user)
# Проверяем, есть ли слово уже в словаре
- existing = await VocabularyService.get_word_by_original(session, user.id, word)
+ existing = await VocabularyService.get_word_by_original(session, user.id, word, source_lang=user.learning_language)
if existing:
await callback.answer(t(lang, 'tasks.word_already_exists', word=word), show_alert=True)
@@ -492,8 +532,9 @@ async def add_task_word(callback: CallbackQuery, state: FSMContext):
word_original=word,
word_translation=translation,
source_lang=user.learning_language,
- translation_lang=get_user_translation_lang(user),
+ translation_lang=translation_lang,
transcription=transcription,
+ difficulty_level=difficulty_level,
source=WordSource.AI_TASK
)
diff --git a/bot/handlers/vocabulary.py b/bot/handlers/vocabulary.py
index 27123f0..dd7d575 100644
--- a/bot/handlers/vocabulary.py
+++ b/bot/handlers/vocabulary.py
@@ -56,7 +56,7 @@ async def process_word_addition(message: Message, state: FSMContext, word: str):
return
# Проверяем, есть ли уже такое слово
- existing_word = await VocabularyService.find_word(session, user.id, word)
+ existing_word = await VocabularyService.find_word(session, user.id, word, source_lang=user.learning_language)
if existing_word:
lang = get_user_lang(user)
await message.answer(t(lang, 'add.exists', word=word, translation=existing_word.word_translation))
@@ -107,7 +107,6 @@ async def process_word_addition(message: Message, state: FSMContext, word: str):
card_text = (
f"📝 {word_data['word']}\n"
f"🔊 [{word_data.get('transcription', '')}]\n\n"
- f"{t(lang, 'add.category_label')}: {word_data.get('category', '')}\n"
f"{t(lang, 'add.level_label')}: {word_data.get('difficulty', 'A1')}"
f"{translations_text}"
f"{t(lang, 'add.confirm_question')}"
@@ -153,7 +152,6 @@ async def confirm_add_word(callback: CallbackQuery, state: FSMContext):
source_lang=source_lang,
translation_lang=translation_lang,
transcription=word_data.get("transcription"),
- category=word_data.get("category"),
difficulty_level=word_data.get("difficulty"),
source=WordSource.MANUAL
)
diff --git a/bot/handlers/words.py b/bot/handlers/words.py
index 222abc6..9fe5c38 100644
--- a/bot/handlers/words.py
+++ b/bot/handlers/words.py
@@ -158,22 +158,16 @@ async def add_single_word(callback: CallbackQuery, state: FSMContext):
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
# Проверяем, нет ли уже такого слова
existing = await VocabularyService.get_word_by_original(
- session, user_id, word_data['word']
+ session, user_id, word_data['word'], source_lang=user.learning_language
)
if existing:
- async with async_session_maker() as session:
- user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
- lang = (user.language_interface if user else 'ru') or 'ru'
+ lang = get_user_lang(user)
await callback.answer(t(lang, 'words.already_exists', word=word_data['word']), show_alert=True)
return
# Добавляем слово
- # Формируем examples с учётом языков
- learn = user.learning_language if user else 'en'
translation_lang = get_user_translation_lang(user)
- ex = word_data.get('example')
- examples = ([{learn: ex, translation_lang: ''}] if ex else [])
await VocabularyService.add_word(
session=session,
@@ -183,10 +177,8 @@ async def add_single_word(callback: CallbackQuery, state: FSMContext):
source_lang=user.learning_language if user else None,
translation_lang=translation_lang,
transcription=word_data.get('transcription'),
- examples=examples,
- source=WordSource.SUGGESTED,
- category=data.get('theme', 'general'),
- difficulty_level=data.get('level')
+ difficulty_level=data.get('level'),
+ source=WordSource.SUGGESTED
)
async with async_session_maker() as session:
@@ -203,7 +195,6 @@ async def add_all_words(callback: CallbackQuery, state: FSMContext):
data = await state.get_data()
words = data.get('words', [])
user_id = data.get('user_id')
- theme = data.get('theme', 'general')
added_count = 0
skipped_count = 0
@@ -213,7 +204,7 @@ async def add_all_words(callback: CallbackQuery, state: FSMContext):
for word_data in words:
# Проверяем, нет ли уже такого слова
existing = await VocabularyService.get_word_by_original(
- session, user_id, word_data['word']
+ session, user_id, word_data['word'], source_lang=user.learning_language
)
if existing:
@@ -221,10 +212,7 @@ async def add_all_words(callback: CallbackQuery, state: FSMContext):
continue
# Добавляем слово
- learn = user.learning_language if user else 'en'
translation_lang = get_user_translation_lang(user)
- ex = word_data.get('example')
- examples = ([{learn: ex, translation_lang: ''}] if ex else [])
await VocabularyService.add_word(
session=session,
@@ -234,10 +222,8 @@ async def add_all_words(callback: CallbackQuery, state: FSMContext):
source_lang=user.learning_language if user else None,
translation_lang=translation_lang,
transcription=word_data.get('transcription'),
- examples=examples,
- source=WordSource.SUGGESTED,
- category=theme,
- difficulty_level=data.get('level')
+ difficulty_level=data.get('level'),
+ source=WordSource.SUGGESTED
)
added_count += 1
diff --git a/database/models.py b/database/models.py
index 4157285..6623882 100644
--- a/database/models.py
+++ b/database/models.py
@@ -72,6 +72,7 @@ class User(Base):
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)
+ ai_model_id: Mapped[Optional[int]] = mapped_column(Integer, default=None) # ID выбранной AI модели (NULL = глобальная)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
last_active: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -90,8 +91,6 @@ class Vocabulary(Base):
source_lang: Mapped[Optional[str]] = mapped_column(String(5)) # ISO2 языка слова (язык изучения)
translation_lang: Mapped[Optional[str]] = mapped_column(String(5)) # ISO2 языка перевода (обычно язык интерфейса)
transcription: Mapped[Optional[str]] = mapped_column(String(255))
- examples: Mapped[Optional[dict]] = mapped_column(JSON) # JSON массив примеров
- category: Mapped[Optional[str]] = mapped_column(String(100))
difficulty_level: Mapped[Optional[LanguageLevel]] = mapped_column(SQLEnum(LanguageLevel))
source: Mapped[WordSource] = mapped_column(SQLEnum(WordSource), default=WordSource.MANUAL)
times_reviewed: Mapped[int] = mapped_column(Integer, default=0)
diff --git a/migrations/versions/20251208_rm_examples_category.py b/migrations/versions/20251208_rm_examples_category.py
new file mode 100644
index 0000000..34fd591
--- /dev/null
+++ b/migrations/versions/20251208_rm_examples_category.py
@@ -0,0 +1,28 @@
+"""Remove examples and category columns from vocabulary
+
+Revision ID: 20251208_rm_examples_category
+Revises: 20251208_ai_models
+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_rm_examples_category'
+down_revision: Union[str, None] = '20251208_ai_models'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.drop_column('vocabulary', 'examples')
+ op.drop_column('vocabulary', 'category')
+
+
+def downgrade() -> None:
+ op.add_column('vocabulary', sa.Column('category', sa.String(length=100), nullable=True))
+ op.add_column('vocabulary', sa.Column('examples', sa.JSON(), nullable=True))
diff --git a/migrations/versions/20251208_user_ai_model.py b/migrations/versions/20251208_user_ai_model.py
new file mode 100644
index 0000000..a324908
--- /dev/null
+++ b/migrations/versions/20251208_user_ai_model.py
@@ -0,0 +1,26 @@
+"""Add ai_model_id to users
+
+Revision ID: 20251208_user_ai_model
+Revises: 20251208_rm_examples_category
+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_user_ai_model'
+down_revision: Union[str, None] = '20251208_rm_examples_category'
+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('ai_model_id', sa.Integer(), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column('users', 'ai_model_id')
diff --git a/services/ai_model_service.py b/services/ai_model_service.py
index 8cdd44d..3083d4c 100644
--- a/services/ai_model_service.py
+++ b/services/ai_model_service.py
@@ -1,7 +1,7 @@
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
-from database.models import AIModel, AIProvider
-from typing import Optional, List
+from database.models import AIModel, AIProvider, User
+from typing import Optional, List, Tuple
# Дефолтная модель если в БД ничего нет
@@ -188,3 +188,81 @@ class AIModelService:
session.add(model)
await session.commit()
+
+ @staticmethod
+ async def get_user_model(session: AsyncSession, user_id: int) -> Optional[AIModel]:
+ """
+ Получить AI модель пользователя.
+ Если у пользователя не выбрана модель, возвращает глобальную активную.
+
+ Args:
+ user_id: ID пользователя в БД
+
+ Returns:
+ AIModel или None
+ """
+ # Получаем пользователя
+ result = await session.execute(
+ select(User).where(User.id == user_id)
+ )
+ user = result.scalar_one_or_none()
+
+ if user and user.ai_model_id:
+ # У пользователя выбрана своя модель
+ model_result = await session.execute(
+ select(AIModel).where(AIModel.id == user.ai_model_id)
+ )
+ model = model_result.scalar_one_or_none()
+ if model:
+ return model
+
+ # Fallback на глобальную активную модель
+ return await AIModelService.get_active_model(session)
+
+ @staticmethod
+ async def get_user_model_info(session: AsyncSession, user_id: int) -> Tuple[str, AIProvider]:
+ """
+ Получить название модели и провайдера для пользователя.
+
+ Args:
+ user_id: ID пользователя в БД
+
+ Returns:
+ Tuple[model_name, provider]
+ """
+ model = await AIModelService.get_user_model(session, user_id)
+ if model:
+ return model.model_name, model.provider
+ return DEFAULT_MODEL, DEFAULT_PROVIDER
+
+ @staticmethod
+ async def set_user_model(session: AsyncSession, user_id: int, model_id: Optional[int]) -> bool:
+ """
+ Установить AI модель для пользователя.
+
+ Args:
+ user_id: ID пользователя в БД
+ model_id: ID модели или None для сброса на глобальную
+
+ Returns:
+ True если успешно
+ """
+ result = await session.execute(
+ select(User).where(User.id == user_id)
+ )
+ user = result.scalar_one_or_none()
+
+ if not user:
+ return False
+
+ # Проверяем существование модели если указан ID
+ if model_id is not None:
+ model_result = await session.execute(
+ select(AIModel).where(AIModel.id == model_id)
+ )
+ if not model_result.scalar_one_or_none():
+ return False
+
+ user.ai_model_id = model_id
+ await session.commit()
+ return True
diff --git a/services/ai_service.py b/services/ai_service.py
index c49cb6f..c3ad4bd 100644
--- a/services/ai_service.py
+++ b/services/ai_service.py
@@ -54,12 +54,26 @@ class AIService:
self._cached_model: Optional[str] = None
self._cached_provider: Optional[AIProvider] = None
- async def _get_active_model(self) -> tuple[str, AIProvider]:
- """Получить активную модель и провайдера из БД"""
+ async def _get_active_model(self, user_id: Optional[int] = None) -> tuple[str, AIProvider]:
+ """
+ Получить активную модель и провайдера из БД.
+
+ Args:
+ user_id: ID пользователя в БД (не telegram_id). Если указан, берёт модель пользователя.
+
+ Returns:
+ tuple[model_name, provider]
+ """
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 user_id:
+ # Получаем модель пользователя (или глобальную если не выбрана)
+ model = await AIModelService.get_user_model(session, user_id)
+ else:
+ # Глобальная активная модель
+ model = await AIModelService.get_active_model(session)
+
if model:
self._cached_model = model.model_name
self._cached_provider = model.provider
@@ -67,9 +81,16 @@ class AIService:
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()
+ async def _make_request(self, messages: list, temperature: float = 0.3, user_id: Optional[int] = None) -> dict:
+ """
+ Выполнить запрос к активному AI провайдеру.
+
+ Args:
+ messages: Сообщения для API
+ temperature: Температура генерации
+ user_id: ID пользователя в БД для получения его модели
+ """
+ model_name, provider = await self._get_active_model(user_id)
if provider == AIProvider.google:
return await self._make_google_request(messages, temperature, model_name)
@@ -160,7 +181,7 @@ class AIService:
response.raise_for_status()
return response.json()
- async def translate_word(self, word: str, source_lang: str = "en", translation_lang: str = "ru") -> Dict:
+ async def translate_word(self, word: str, source_lang: str = "en", translation_lang: str = "ru", user_id: Optional[int] = None) -> Dict:
"""
Перевести слово и получить дополнительную информацию
@@ -168,6 +189,7 @@ class AIService:
word: Слово для перевода
source_lang: Язык исходного слова (ISO2)
translation_lang: Язык перевода (ISO2)
+ user_id: ID пользователя в БД для получения его модели
Returns:
Dict с переводом, транскрипцией и примерами
@@ -196,7 +218,7 @@ class AIService:
{"role": "user", "content": prompt}
]
- response_data = await self._make_request(messages, temperature=0.3)
+ response_data = await self._make_request(messages, temperature=0.3, user_id=user_id)
import json
result = json.loads(response_data['choices'][0]['message']['content'])
@@ -220,7 +242,8 @@ class AIService:
word: str,
source_lang: str = "en",
translation_lang: str = "ru",
- max_translations: int = 3
+ max_translations: int = 3,
+ user_id: Optional[int] = None
) -> Dict:
"""
Перевести слово и получить несколько переводов с контекстами
@@ -230,6 +253,7 @@ class AIService:
source_lang: Язык исходного слова (ISO2)
translation_lang: Язык перевода (ISO2)
max_translations: Максимальное количество переводов
+ user_id: ID пользователя в БД для получения его модели
Returns:
Dict с переводами, каждый с примером предложения
@@ -275,7 +299,7 @@ class AIService:
{"role": "user", "content": prompt}
]
- response_data = await self._make_request(messages, temperature=0.3)
+ response_data = await self._make_request(messages, temperature=0.3, user_id=user_id)
import json
content = response_data['choices'][0]['message']['content']
@@ -311,7 +335,8 @@ class AIService:
self,
words: List[str],
source_lang: str = "en",
- translation_lang: str = "ru"
+ translation_lang: str = "ru",
+ user_id: Optional[int] = None
) -> List[Dict]:
"""
Перевести список слов пакетно
@@ -320,6 +345,7 @@ class AIService:
words: Список слов для перевода
source_lang: Язык исходных слов (ISO2)
translation_lang: Язык перевода (ISO2)
+ user_id: ID пользователя в БД для получения его модели
Returns:
List[Dict] с переводами, транскрипциями
@@ -361,7 +387,7 @@ class AIService:
{"role": "user", "content": prompt}
]
- response_data = await self._make_request(messages, temperature=0.3)
+ response_data = await self._make_request(messages, temperature=0.3, user_id=user_id)
import json
content = response_data['choices'][0]['message']['content']
@@ -393,7 +419,7 @@ class AIService:
# Возвращаем слова без перевода в случае ошибки
return [{"word": w, "translation": "", "transcription": ""} for w in words]
- async def check_answer(self, question: str, correct_answer: str, user_answer: str) -> Dict:
+ async def check_answer(self, question: str, correct_answer: str, user_answer: str, user_id: Optional[int] = None) -> Dict:
"""
Проверить ответ пользователя с помощью ИИ
@@ -401,6 +427,7 @@ class AIService:
question: Вопрос задания
correct_answer: Правильный ответ
user_answer: Ответ пользователя
+ user_id: ID пользователя в БД для получения его модели
Returns:
Dict с результатом проверки и обратной связью
@@ -428,7 +455,7 @@ class AIService:
{"role": "user", "content": prompt}
]
- response_data = await self._make_request(messages, temperature=0.3)
+ response_data = await self._make_request(messages, temperature=0.3, user_id=user_id)
import json
result = json.loads(response_data['choices'][0]['message']['content'])
@@ -443,7 +470,7 @@ class AIService:
"score": 0
}
- async def generate_fill_in_sentence(self, word: str, learning_lang: str = "en", translation_lang: str = "ru") -> Dict:
+ async def generate_fill_in_sentence(self, word: str, learning_lang: str = "en", translation_lang: str = "ru", user_id: Optional[int] = None) -> Dict:
"""
Сгенерировать предложение с пропуском для заданного слова
@@ -451,6 +478,7 @@ class AIService:
word: Слово (на языке обучения), для которого нужно создать предложение
learning_lang: Язык обучения (ISO2)
translation_lang: Язык перевода предложения (ISO2)
+ user_id: ID пользователя в БД для получения его модели
Returns:
Dict с предложением и правильным ответом
@@ -475,7 +503,7 @@ class AIService:
{"role": "user", "content": prompt}
]
- response_data = await self._make_request(messages, temperature=0.7)
+ response_data = await self._make_request(messages, temperature=0.7, user_id=user_id)
import json
result = json.loads(response_data['choices'][0]['message']['content'])
@@ -490,7 +518,7 @@ class AIService:
"translation": f"Мне нравится {word} каждый день."
}
- async def generate_sentence_for_translation(self, word: str, learning_lang: str = "en", translation_lang: str = "ru") -> Dict:
+ async def generate_sentence_for_translation(self, word: str, learning_lang: str = "en", translation_lang: str = "ru", user_id: Optional[int] = None) -> Dict:
"""
Сгенерировать предложение для перевода, содержащее заданное слово
@@ -498,6 +526,7 @@ class AIService:
word: Слово (на языке обучения), которое должно быть в предложении
learning_lang: Язык обучения (ISO2)
translation_lang: Язык перевода (ISO2)
+ user_id: ID пользователя в БД для получения его модели
Returns:
Dict с предложением и его переводом
@@ -520,7 +549,7 @@ class AIService:
{"role": "user", "content": prompt}
]
- response_data = await self._make_request(messages, temperature=0.7)
+ response_data = await self._make_request(messages, temperature=0.7, user_id=user_id)
import json
result = json.loads(response_data['choices'][0]['message']['content'])
@@ -535,6 +564,116 @@ class AIService:
"translation": f"Я использую {word} каждый день."
}
+ async def generate_task_sentences_batch(
+ self,
+ tasks_data: List[Dict],
+ learning_lang: str = "en",
+ translation_lang: str = "ru",
+ user_id: Optional[int] = None
+ ) -> List[Dict]:
+ """
+ Батч-генерация предложений для заданий за один запрос к AI.
+
+ Args:
+ tasks_data: Список словарей с информацией о заданиях:
+ [{"word": "run", "task_type": "fill_blank"}, {"word": "eat", "task_type": "sentence_translate"}]
+ learning_lang: Язык обучения
+ translation_lang: Язык перевода
+ user_id: ID пользователя в БД для получения его модели
+
+ Returns:
+ Список результатов в том же порядке
+ """
+ if not tasks_data:
+ return []
+
+ # Формируем описание заданий для промпта
+ tasks_description = []
+ for i, task in enumerate(tasks_data):
+ word = task.get('word', '')
+ task_type = task.get('task_type', '')
+
+ if task_type == 'fill_blank':
+ tasks_description.append(
+ f'{i + 1}. Слово "{word}" - создай предложение с пропуском (замени слово на ___)'
+ )
+ elif task_type == 'sentence_translate':
+ tasks_description.append(
+ f'{i + 1}. Слово "{word}" - создай простое предложение для перевода'
+ )
+
+ if not tasks_description:
+ return []
+
+ prompt = f"""Создай предложения на языке {learning_lang} для следующих заданий:
+
+{chr(10).join(tasks_description)}
+
+Верни ответ в формате JSON:
+{{
+ "results": [
+ {{
+ "sentence": "предложение (с ___ для fill_blank)",
+ "answer": "слово для пропуска (только для fill_blank)",
+ "translation": "перевод на {translation_lang}"
+ }}
+ ]
+}}
+
+Важно:
+- Для fill_blank: замени целевое слово на ___, укажи answer
+- Для sentence_translate: просто предложение со словом, answer не нужен
+- Предложения должны быть простыми (5-10 слов)
+- Контекст должен подсказывать правильное слово
+- Верни результаты В ТОМ ЖЕ ПОРЯДКЕ что и задания"""
+
+ try:
+ logger.info(f"[AI Request] generate_task_sentences_batch: {len(tasks_data)} tasks, lang='{learning_lang}'")
+
+ messages = [
+ {"role": "system", "content": "Ты - преподаватель иностранных языков. Создавай простые и понятные упражнения."},
+ {"role": "user", "content": prompt}
+ ]
+
+ response_data = await self._make_request(messages, temperature=0.7, user_id=user_id)
+
+ import json
+ result = json.loads(response_data['choices'][0]['message']['content'])
+ results = result.get('results', [])
+
+ logger.info(f"[AI Response] generate_task_sentences_batch: got {len(results)} results")
+
+ # Дополняем результаты до нужного количества если AI вернул меньше
+ while len(results) < len(tasks_data):
+ task = tasks_data[len(results)]
+ word = task.get('word', 'word')
+ results.append({
+ "sentence": f"I use {word} every day." if task.get('task_type') != 'fill_blank' else f"I like to ___ every day.",
+ "answer": word,
+ "translation": f"Fallback предложение"
+ })
+
+ return results
+
+ except Exception as e:
+ logger.error(f"[AI Error] generate_task_sentences_batch: {type(e).__name__}: {str(e)}")
+ # Fallback - простые предложения для всех заданий
+ results = []
+ for task in tasks_data:
+ word = task.get('word', 'word')
+ if task.get('task_type') == 'fill_blank':
+ results.append({
+ "sentence": f"I like to ___ every day.",
+ "answer": word,
+ "translation": f"Мне нравится {word} каждый день."
+ })
+ else:
+ results.append({
+ "sentence": f"I use {word} every day.",
+ "translation": f"Я использую {word} каждый день."
+ })
+ return results
+
async def generate_thematic_words(
self,
theme: str,
@@ -542,7 +681,8 @@ class AIService:
count: int = 10,
learning_lang: str = "en",
translation_lang: str = "ru",
- exclude_words: List[str] = None
+ exclude_words: List[str] = None,
+ user_id: Optional[int] = None
) -> List[Dict]:
"""
Сгенерировать подборку слов по теме
@@ -554,6 +694,7 @@ class AIService:
learning_lang: Язык изучения
translation_lang: Язык перевода
exclude_words: Список слов для исключения (уже известные)
+ user_id: ID пользователя в БД для получения его модели
Returns:
Список словарей с информацией о словах
@@ -601,7 +742,7 @@ class AIService:
{"role": "user", "content": prompt}
]
- response_data = await self._make_request(messages, temperature=0.7)
+ response_data = await self._make_request(messages, temperature=0.7, user_id=user_id)
import json
result = json.loads(response_data['choices'][0]['message']['content'])
@@ -625,14 +766,17 @@ class AIService:
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]:
+ async def extract_words_from_text(self, text: str, level: str = "B1", max_words: int = 15, learning_lang: str = "en", translation_lang: str = "ru", user_id: Optional[int] = None) -> List[Dict]:
"""
Извлечь ключевые слова из текста для изучения
Args:
- text: Текст на английском языке
+ text: Текст на языке изучения
level: Уровень пользователя (A1-C2)
max_words: Максимальное количество слов для извлечения
+ learning_lang: Язык изучения
+ translation_lang: Язык перевода
+ user_id: ID пользователя в БД для получения его модели
Returns:
Список словарей с информацией о словах
@@ -670,7 +814,7 @@ class AIService:
{"role": "user", "content": prompt}
]
- response_data = await self._make_request(messages, temperature=0.5)
+ response_data = await self._make_request(messages, temperature=0.5, user_id=user_id)
import json
result = json.loads(response_data['choices'][0]['message']['content'])
@@ -682,13 +826,16 @@ class AIService:
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:
+ async def start_conversation(self, scenario: str, level: str = "B1", learning_lang: str = "en", translation_lang: str = "ru", user_id: Optional[int] = None) -> Dict:
"""
Начать диалоговую практику с AI
Args:
scenario: Сценарий диалога (restaurant, shopping, travel, etc.)
level: Уровень пользователя (A1-C2)
+ learning_lang: Язык изучения
+ translation_lang: Язык перевода
+ user_id: ID пользователя в БД для получения его модели
Returns:
Dict с начальной репликой и контекстом
@@ -739,7 +886,7 @@ class AIService:
{"role": "user", "content": prompt}
]
- response_data = await self._make_request(messages, temperature=0.8)
+ response_data = await self._make_request(messages, temperature=0.8, user_id=user_id)
import json
result = json.loads(response_data['choices'][0]['message']['content'])
@@ -762,7 +909,8 @@ class AIService:
scenario: str,
level: str = "B1",
learning_lang: str = "en",
- translation_lang: str = "ru"
+ translation_lang: str = "ru",
+ user_id: Optional[int] = None
) -> Dict:
"""
Продолжить диалог и проверить ответ пользователя
@@ -772,6 +920,9 @@ class AIService:
user_message: Сообщение пользователя
scenario: Сценарий диалога
level: Уровень пользователя
+ learning_lang: Язык изучения
+ translation_lang: Язык перевода
+ user_id: ID пользователя в БД для получения его модели
Returns:
Dict с ответом AI, проверкой и подсказками
@@ -833,7 +984,7 @@ User: {user_message}
# Добавляем инструкцию для форматирования ответа
messages.append({"role": "user", "content": prompt})
- response_data = await self._make_request(messages, temperature=0.8)
+ response_data = await self._make_request(messages, temperature=0.8, user_id=user_id)
import json
result = json.loads(response_data['choices'][0]['message']['content'])
@@ -854,12 +1005,13 @@ User: {user_message}
"suggestions": ["Sure!", "Well...", "Actually..."]
}
- async def generate_level_test(self, learning_language: str = "en") -> List[Dict]:
+ async def generate_level_test(self, learning_language: str = "en", user_id: Optional[int] = None) -> List[Dict]:
"""
Сгенерировать тест для определения уровня языка
Args:
learning_language: Язык изучения (en, es, de, fr, ja)
+ user_id: ID пользователя в БД для получения его модели
Returns:
Список из 7 вопросов разной сложности
@@ -913,7 +1065,7 @@ User: {user_message}
{"role": "user", "content": prompt}
]
- response_data = await self._make_request(messages, temperature=0.7)
+ response_data = await self._make_request(messages, temperature=0.7, user_id=user_id)
import json
result = json.loads(response_data['choices'][0]['message']['content'])
diff --git a/services/task_service.py b/services/task_service.py
index 85c52b5..319810d 100644
--- a/services/task_service.py
+++ b/services/task_service.py
@@ -243,7 +243,7 @@ class TaskService:
translation_lang: str = 'ru'
) -> List[Dict]:
"""
- Генерация заданий определённого типа
+ Генерация заданий определённого типа (оптимизировано - 1 запрос к AI)
Args:
session: Сессия базы данных
@@ -272,9 +272,10 @@ class TaskService:
# Выбираем случайные слова
selected_words = random.sample(words, min(count, len(words)))
- tasks = []
+ # 1. Подготовка: определяем типы и собираем данные для всех слов
+ word_data_list = []
for word in selected_words:
- # Получаем переводы из таблицы WordTranslation
+ # Получаем переводы
translations_result = await session.execute(
select(WordTranslation)
.where(WordTranslation.vocabulary_id == word.id)
@@ -288,18 +289,57 @@ class TaskService:
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
+ word_data_list.append({
+ 'word': word,
+ 'translations': translations,
+ 'correct_translation': correct_translation,
+ 'chosen_type': chosen_type
+ })
+
+ # 2. Собираем задания, требующие AI
+ ai_tasks = []
+ ai_task_indices = []
+
+ for i, wd in enumerate(word_data_list):
+ if wd['chosen_type'] in ('fill_blank', 'sentence_translate'):
+ ai_tasks.append({
+ 'word': wd['word'].word_original,
+ 'task_type': wd['chosen_type']
+ })
+ ai_task_indices.append(i)
+
+ # 3. Один запрос к AI
+ ai_results = []
+ if ai_tasks:
+ ai_results = await ai_service.generate_task_sentences_batch(
+ ai_tasks,
+ learning_lang=learning_lang,
+ translation_lang=translation_lang
+ )
+
+ # Маппинг результатов
+ ai_results_map = {}
+ for idx, result in zip(ai_task_indices, ai_results):
+ ai_results_map[idx] = result
+
+ # 4. Собираем финальные задания
+ tasks = []
+ for i, wd in enumerate(word_data_list):
+ word = wd['word']
+ translations = wd['translations']
+ correct_translation = wd['correct_translation']
+ chosen_type = wd['chosen_type']
+
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':
@@ -328,12 +368,7 @@ class TaskService:
}
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
- )
+ sentence_data = ai_results_map.get(i, {})
if translation_lang == 'en':
fill_title = "Fill in the blank:"
@@ -347,21 +382,16 @@ class TaskService:
'word_id': word.id,
'question': (
f"{fill_title}\n\n"
- f"{sentence_data['sentence']}\n\n"
+ f"{sentence_data.get('sentence', '___')}\n\n"
f"{sentence_data.get('translation', '')}"
),
'word': word.word_original,
- 'correct_answer': sentence_data['answer'],
- 'sentence': sentence_data['sentence']
+ 'correct_answer': sentence_data.get('answer', word.word_original),
+ 'sentence': sentence_data.get('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
- )
+ sentence_data = ai_results_map.get(i, {})
if translation_lang == 'en':
sentence_title = "Translate the sentence:"
@@ -376,10 +406,10 @@ class TaskService:
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}",
+ 'question': f"{sentence_title}\n\n{sentence_data.get('sentence', word.word_original)}\n\n📝 {word_hint}: {word.word_original} — {correct_translation}",
'word': word.word_original,
- 'correct_answer': sentence_data['translation'],
- 'sentence': sentence_data['sentence']
+ 'correct_answer': sentence_data.get('translation', correct_translation),
+ 'sentence': sentence_data.get('sentence', word.word_original)
}
tasks.append(task)
diff --git a/services/user_service.py b/services/user_service.py
index 03bee0c..86e6e8c 100644
--- a/services/user_service.py
+++ b/services/user_service.py
@@ -178,3 +178,22 @@ class UserService:
if user:
user.tasks_count = count
await session.commit()
+
+ @staticmethod
+ async def update_user_ai_model(session: AsyncSession, user_id: int, model_id: Optional[int]):
+ """
+ Обновить AI модель пользователя
+
+ Args:
+ session: Сессия базы данных
+ user_id: ID пользователя
+ model_id: ID модели или None для глобальной
+ """
+ result = await session.execute(
+ select(User).where(User.id == user_id)
+ )
+ user = result.scalar_one_or_none()
+
+ if user:
+ user.ai_model_id = model_id
+ await session.commit()
diff --git a/services/vocabulary_service.py b/services/vocabulary_service.py
index 9c88bf1..88fce2c 100644
--- a/services/vocabulary_service.py
+++ b/services/vocabulary_service.py
@@ -17,8 +17,6 @@ class VocabularyService:
source_lang: Optional[str] = None,
translation_lang: Optional[str] = None,
transcription: Optional[str] = None,
- examples: Optional[dict] = None,
- category: Optional[str] = None,
difficulty_level: Optional[str] = None,
source: WordSource = WordSource.MANUAL,
notes: Optional[str] = None
@@ -32,8 +30,6 @@ class VocabularyService:
word_original: Оригинальное слово
word_translation: Перевод
transcription: Транскрипция
- examples: Примеры использования
- category: Категория слова
difficulty_level: Уровень сложности
source: Источник добавления
notes: Заметки пользователя
@@ -56,8 +52,6 @@ class VocabularyService:
source_lang=source_lang,
translation_lang=translation_lang,
transcription=transcription,
- examples=examples,
- category=category,
difficulty_level=difficulty_enum,
source=source,
notes=notes
@@ -138,7 +132,12 @@ class VocabularyService:
return len(words)
@staticmethod
- async def find_word(session: AsyncSession, user_id: int, word: str) -> Optional[Vocabulary]:
+ async def find_word(
+ session: AsyncSession,
+ user_id: int,
+ word: str,
+ source_lang: Optional[str] = None
+ ) -> Optional[Vocabulary]:
"""
Найти слово в словаре пользователя
@@ -146,19 +145,28 @@ class VocabularyService:
session: Сессия базы данных
user_id: ID пользователя
word: Слово для поиска
+ source_lang: Язык изучения для фильтрации (если указан)
Returns:
Объект слова или None
"""
- result = await session.execute(
+ query = (
select(Vocabulary)
.where(Vocabulary.user_id == user_id)
.where(Vocabulary.word_original.ilike(f"%{word}%"))
)
+ if source_lang:
+ query = query.where(Vocabulary.source_lang == source_lang.lower())
+ result = await session.execute(query)
return result.scalar_one_or_none()
@staticmethod
- async def get_word_by_original(session: AsyncSession, user_id: int, word: str) -> Optional[Vocabulary]:
+ async def get_word_by_original(
+ session: AsyncSession,
+ user_id: int,
+ word: str,
+ source_lang: Optional[str] = None
+ ) -> Optional[Vocabulary]:
"""
Получить слово по точному совпадению
@@ -166,15 +174,19 @@ class VocabularyService:
session: Сессия базы данных
user_id: ID пользователя
word: Слово для поиска (точное совпадение)
+ source_lang: Язык изучения для фильтрации (если указан)
Returns:
Объект слова или None
"""
- result = await session.execute(
+ query = (
select(Vocabulary)
.where(Vocabulary.user_id == user_id)
.where(Vocabulary.word_original == word.lower())
)
+ if source_lang:
+ query = query.where(Vocabulary.source_lang == source_lang.lower())
+ result = await session.execute(query)
return result.scalar_one_or_none()
@staticmethod