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