feat: персональные AI модели, оптимизация задач, фильтрация словаря

- Добавлена поддержка персональных AI моделей для каждого пользователя
- Оптимизация создания заданий: батч-запрос к AI вместо N запросов
- Фильтрация слов по языку изучения (source_lang) в словаре
- Удалены неиспользуемые колонки examples и category из vocabulary
- Миграции для ai_model_id и удаления колонок

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-08 16:43:08 +03:00
parent 6138af4e63
commit 16a7df0343
13 changed files with 507 additions and 142 deletions

View File

@@ -1,7 +1,7 @@
.PHONY: help venv install run clean \ .PHONY: help venv install run clean \
docker-up docker-down docker-logs docker-rebuild docker-restart \ 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 \ migrate migrate-down migrate-current migrate-revision \
local-migrate local-migrate-down local-migrate-current \ local-migrate local-migrate-down local-migrate-current \
docker-db docker-db-stop docker-db docker-db-stop
@@ -90,7 +90,13 @@ docker-bot-build:
docker-bot-rebuild: docker-bot-rebuild:
docker-compose stop bot 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 build --no-cache bot
docker-compose up -d bot docker-compose up -d bot

View File

@@ -187,7 +187,7 @@ async def import_single_word(callback: CallbackQuery, state: FSMContext):
# Проверяем, нет ли уже такого слова # Проверяем, нет ли уже такого слова
existing = await VocabularyService.get_word_by_original( 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: if existing:
@@ -195,10 +195,7 @@ async def import_single_word(callback: CallbackQuery, state: FSMContext):
return return
# Добавляем слово # Добавляем слово
learn = user.learning_language if user else 'en'
translation_lang = get_user_translation_lang(user) translation_lang = get_user_translation_lang(user)
ctx = word_data.get('context')
examples = ([{learn: ctx, translation_lang: ''}] if ctx else [])
await VocabularyService.add_word( await VocabularyService.add_word(
session=session, session=session,
@@ -208,10 +205,8 @@ async def import_single_word(callback: CallbackQuery, state: FSMContext):
source_lang=user.learning_language if user else None, source_lang=user.learning_language if user else None,
translation_lang=translation_lang, translation_lang=translation_lang,
transcription=word_data.get('transcription'), transcription=word_data.get('transcription'),
examples=examples, difficulty_level=data.get('level'),
source=WordSource.CONTEXT, source=WordSource.CONTEXT
category='imported',
difficulty_level=data.get('level')
) )
lang = (user.language_interface if user else 'ru') or 'ru' 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: for word_data in words:
# Проверяем, нет ли уже такого слова # Проверяем, нет ли уже такого слова
existing = await VocabularyService.get_word_by_original( 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: if existing:
@@ -243,10 +238,7 @@ async def import_all_words(callback: CallbackQuery, state: FSMContext):
continue continue
# Добавляем слово # Добавляем слово
learn = user.learning_language if user else 'en'
translation_lang = get_user_translation_lang(user) translation_lang = get_user_translation_lang(user)
ctx = word_data.get('context')
examples = ([{learn: ctx, translation_lang: ''}] if ctx else [])
await VocabularyService.add_word( await VocabularyService.add_word(
session=session, session=session,
@@ -256,10 +248,8 @@ async def import_all_words(callback: CallbackQuery, state: FSMContext):
source_lang=user.learning_language if user else None, source_lang=user.learning_language if user else None,
translation_lang=translation_lang, translation_lang=translation_lang,
transcription=word_data.get('transcription'), transcription=word_data.get('transcription'),
examples=examples, difficulty_level=data.get('level'),
source=WordSource.CONTEXT, source=WordSource.CONTEXT
category='imported',
difficulty_level=data.get('level')
) )
added_count += 1 added_count += 1
@@ -478,7 +468,7 @@ async def import_file_all_words(callback: CallbackQuery, state: FSMContext):
for word_data in words: for word_data in words:
# Проверяем, нет ли уже такого слова # Проверяем, нет ли уже такого слова
existing = await VocabularyService.get_word_by_original( 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: if existing:

View File

@@ -180,7 +180,10 @@ async def generate_new_words_tasks(callback: CallbackQuery, state: FSMContext, u
return 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( await state.update_data(
tasks=tasks, tasks=tasks,
@@ -196,26 +199,68 @@ async def generate_new_words_tasks(callback: CallbackQuery, state: FSMContext, u
await show_current_task(callback.message, state) 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 import random
tasks = []
# 1. Определяем типы заданий для всех слов
word_tasks = []
for word in words: 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', '') word_text = word.get('word', '')
translation = word.get('translation', '') translation = word.get('translation', '')
transcription = word.get('transcription', '') transcription = word.get('transcription', '')
example = word.get('example', '') example = word.get('example', '')
example_translation = word.get('example_translation', '') 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': if chosen_type == 'word_translate':
# Перевод слова
translate_prompt = t(lang, 'tasks.translate_to', lang_name=t(lang, f'lang.{translation_lang}')) translate_prompt = t(lang, 'tasks.translate_to', lang_name=t(lang, f'lang.{translation_lang}'))
tasks.append({ tasks.append({
'type': 'translate', 'type': 'translate',
@@ -224,16 +269,12 @@ async def create_tasks_from_words(words: list, task_type: str, lang: str, learni
'correct_answer': translation, 'correct_answer': translation,
'transcription': transcription, 'transcription': transcription,
'example': example, 'example': example,
'example_translation': example_translation 'example_translation': example_translation,
'difficulty_level': level
}) })
elif chosen_type == 'fill_blank': elif chosen_type == 'fill_blank':
# Заполнение пропуска - генерируем предложение через AI sentence_data = ai_results_map.get(i, {})
sentence_data = await ai_service.generate_fill_in_sentence(
word_text,
learning_lang=learning_lang,
translation_lang=translation_lang
)
if translation_lang == 'en': if translation_lang == 'en':
fill_title = "Fill in the blank:" fill_title = "Fill in the blank:"
elif translation_lang == 'ja': elif translation_lang == 'ja':
@@ -243,21 +284,17 @@ async def create_tasks_from_words(words: list, task_type: str, lang: str, learni
tasks.append({ tasks.append({
'type': 'fill_in', 'type': 'fill_in',
'question': f"{fill_title}\n\n<b>{sentence_data['sentence']}</b>\n\n<i>{sentence_data.get('translation', '')}</i>", 'question': f"{fill_title}\n\n<b>{sentence_data.get('sentence', '___')}</b>\n\n<i>{sentence_data.get('translation', '')}</i>",
'word': word_text, 'word': word_text,
'correct_answer': sentence_data['answer'], 'correct_answer': sentence_data.get('answer', word_text),
'transcription': transcription, 'transcription': transcription,
'example': example, 'example': example,
'example_translation': example_translation 'example_translation': example_translation,
'difficulty_level': level
}) })
elif chosen_type == 'sentence_translate': elif chosen_type == 'sentence_translate':
# Перевод предложения - генерируем предложение через AI sentence_data = ai_results_map.get(i, {})
sentence_data = await ai_service.generate_sentence_for_translation(
word_text,
learning_lang=learning_lang,
translation_lang=translation_lang
)
if translation_lang == 'en': if translation_lang == 'en':
sentence_title = "Translate the sentence:" sentence_title = "Translate the sentence:"
word_hint = "Word" word_hint = "Word"
@@ -270,12 +307,13 @@ async def create_tasks_from_words(words: list, task_type: str, lang: str, learni
tasks.append({ tasks.append({
'type': 'sentence_translate', 'type': 'sentence_translate',
'question': f"{sentence_title}\n\n<b>{sentence_data['sentence']}</b>\n\n📝 {word_hint}: <code>{word_text}</code> — {translation}", 'question': f"{sentence_title}\n\n<b>{sentence_data.get('sentence', word_text)}</b>\n\n📝 {word_hint}: <code>{word_text}</code> — {translation}",
'word': word_text, 'word': word_text,
'correct_answer': sentence_data['translation'], 'correct_answer': sentence_data.get('translation', translation),
'transcription': transcription, 'transcription': transcription,
'example': example, 'example': example,
'example_translation': example_translation 'example_translation': example_translation,
'difficulty_level': level
}) })
return tasks return tasks
@@ -468,6 +506,7 @@ async def add_task_word(callback: CallbackQuery, state: FSMContext):
transcription = task.get('transcription', '') transcription = task.get('transcription', '')
example = task.get('example', '') # Пример использования как контекст example = task.get('example', '') # Пример использования как контекст
example_translation = task.get('example_translation', '') # Перевод примера example_translation = task.get('example_translation', '') # Перевод примера
difficulty_level = task.get('difficulty_level') # Уровень сложности
async with async_session_maker() as session: async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) 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 return
lang = get_user_lang(user) 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: if existing:
await callback.answer(t(lang, 'tasks.word_already_exists', word=word), show_alert=True) 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_original=word,
word_translation=translation, word_translation=translation,
source_lang=user.learning_language, source_lang=user.learning_language,
translation_lang=get_user_translation_lang(user), translation_lang=translation_lang,
transcription=transcription, transcription=transcription,
difficulty_level=difficulty_level,
source=WordSource.AI_TASK source=WordSource.AI_TASK
) )

View File

@@ -56,7 +56,7 @@ async def process_word_addition(message: Message, state: FSMContext, word: str):
return 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: if existing_word:
lang = get_user_lang(user) lang = get_user_lang(user)
await message.answer(t(lang, 'add.exists', word=word, translation=existing_word.word_translation)) 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 = ( card_text = (
f"📝 <b>{word_data['word']}</b>\n" f"📝 <b>{word_data['word']}</b>\n"
f"🔊 [{word_data.get('transcription', '')}]\n\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"{t(lang, 'add.level_label')}: {word_data.get('difficulty', 'A1')}"
f"{translations_text}" f"{translations_text}"
f"{t(lang, 'add.confirm_question')}" f"{t(lang, 'add.confirm_question')}"
@@ -153,7 +152,6 @@ async def confirm_add_word(callback: CallbackQuery, state: FSMContext):
source_lang=source_lang, source_lang=source_lang,
translation_lang=translation_lang, translation_lang=translation_lang,
transcription=word_data.get("transcription"), transcription=word_data.get("transcription"),
category=word_data.get("category"),
difficulty_level=word_data.get("difficulty"), difficulty_level=word_data.get("difficulty"),
source=WordSource.MANUAL source=WordSource.MANUAL
) )

View File

@@ -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) user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
# Проверяем, нет ли уже такого слова # Проверяем, нет ли уже такого слова
existing = await VocabularyService.get_word_by_original( 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: if existing:
async with async_session_maker() as session: lang = get_user_lang(user)
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = (user.language_interface if user else 'ru') or 'ru'
await callback.answer(t(lang, 'words.already_exists', word=word_data['word']), show_alert=True) await callback.answer(t(lang, 'words.already_exists', word=word_data['word']), show_alert=True)
return return
# Добавляем слово # Добавляем слово
# Формируем examples с учётом языков
learn = user.learning_language if user else 'en'
translation_lang = get_user_translation_lang(user) translation_lang = get_user_translation_lang(user)
ex = word_data.get('example')
examples = ([{learn: ex, translation_lang: ''}] if ex else [])
await VocabularyService.add_word( await VocabularyService.add_word(
session=session, session=session,
@@ -183,10 +177,8 @@ async def add_single_word(callback: CallbackQuery, state: FSMContext):
source_lang=user.learning_language if user else None, source_lang=user.learning_language if user else None,
translation_lang=translation_lang, translation_lang=translation_lang,
transcription=word_data.get('transcription'), transcription=word_data.get('transcription'),
examples=examples, difficulty_level=data.get('level'),
source=WordSource.SUGGESTED, source=WordSource.SUGGESTED
category=data.get('theme', 'general'),
difficulty_level=data.get('level')
) )
async with async_session_maker() as session: 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() data = await state.get_data()
words = data.get('words', []) words = data.get('words', [])
user_id = data.get('user_id') user_id = data.get('user_id')
theme = data.get('theme', 'general')
added_count = 0 added_count = 0
skipped_count = 0 skipped_count = 0
@@ -213,7 +204,7 @@ async def add_all_words(callback: CallbackQuery, state: FSMContext):
for word_data in words: for word_data in words:
# Проверяем, нет ли уже такого слова # Проверяем, нет ли уже такого слова
existing = await VocabularyService.get_word_by_original( 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: if existing:
@@ -221,10 +212,7 @@ async def add_all_words(callback: CallbackQuery, state: FSMContext):
continue continue
# Добавляем слово # Добавляем слово
learn = user.learning_language if user else 'en'
translation_lang = get_user_translation_lang(user) translation_lang = get_user_translation_lang(user)
ex = word_data.get('example')
examples = ([{learn: ex, translation_lang: ''}] if ex else [])
await VocabularyService.add_word( await VocabularyService.add_word(
session=session, session=session,
@@ -234,10 +222,8 @@ async def add_all_words(callback: CallbackQuery, state: FSMContext):
source_lang=user.learning_language if user else None, source_lang=user.learning_language if user else None,
translation_lang=translation_lang, translation_lang=translation_lang,
transcription=word_data.get('transcription'), transcription=word_data.get('transcription'),
examples=examples, difficulty_level=data.get('level'),
source=WordSource.SUGGESTED, source=WordSource.SUGGESTED
category=theme,
difficulty_level=data.get('level')
) )
added_count += 1 added_count += 1

View File

@@ -72,6 +72,7 @@ class User(Base):
last_reminder_sent: Mapped[Optional[datetime]] = mapped_column(DateTime) last_reminder_sent: Mapped[Optional[datetime]] = mapped_column(DateTime)
streak_days: Mapped[int] = mapped_column(Integer, default=0) streak_days: Mapped[int] = mapped_column(Integer, default=0)
tasks_count: Mapped[int] = mapped_column(Integer, default=5) # Количество заданий (5-15) 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) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
last_active: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=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 языка слова (язык изучения) source_lang: Mapped[Optional[str]] = mapped_column(String(5)) # ISO2 языка слова (язык изучения)
translation_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)) 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)) difficulty_level: Mapped[Optional[LanguageLevel]] = mapped_column(SQLEnum(LanguageLevel))
source: Mapped[WordSource] = mapped_column(SQLEnum(WordSource), default=WordSource.MANUAL) source: Mapped[WordSource] = mapped_column(SQLEnum(WordSource), default=WordSource.MANUAL)
times_reviewed: Mapped[int] = mapped_column(Integer, default=0) times_reviewed: Mapped[int] = mapped_column(Integer, default=0)

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database.models import AIModel, AIProvider from database.models import AIModel, AIProvider, User
from typing import Optional, List from typing import Optional, List, Tuple
# Дефолтная модель если в БД ничего нет # Дефолтная модель если в БД ничего нет
@@ -188,3 +188,81 @@ class AIModelService:
session.add(model) session.add(model)
await session.commit() 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

View File

@@ -54,12 +54,26 @@ class AIService:
self._cached_model: Optional[str] = None self._cached_model: Optional[str] = None
self._cached_provider: Optional[AIProvider] = 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 from services.ai_model_service import AIModelService, DEFAULT_MODEL, DEFAULT_PROVIDER
async with async_session_maker() as session: async with async_session_maker() as session:
if user_id:
# Получаем модель пользователя (или глобальную если не выбрана)
model = await AIModelService.get_user_model(session, user_id)
else:
# Глобальная активная модель
model = await AIModelService.get_active_model(session) model = await AIModelService.get_active_model(session)
if model: if model:
self._cached_model = model.model_name self._cached_model = model.model_name
self._cached_provider = model.provider self._cached_provider = model.provider
@@ -67,9 +81,16 @@ class AIService:
return DEFAULT_MODEL, DEFAULT_PROVIDER return DEFAULT_MODEL, DEFAULT_PROVIDER
async def _make_request(self, messages: list, temperature: float = 0.3) -> dict: async def _make_request(self, messages: list, temperature: float = 0.3, user_id: Optional[int] = None) -> dict:
"""Выполнить запрос к активному AI провайдеру""" """
model_name, provider = await self._get_active_model() Выполнить запрос к активному AI провайдеру.
Args:
messages: Сообщения для API
temperature: Температура генерации
user_id: ID пользователя в БД для получения его модели
"""
model_name, provider = await self._get_active_model(user_id)
if provider == AIProvider.google: if provider == AIProvider.google:
return await self._make_google_request(messages, temperature, model_name) return await self._make_google_request(messages, temperature, model_name)
@@ -160,7 +181,7 @@ class AIService:
response.raise_for_status() response.raise_for_status()
return response.json() 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: Слово для перевода word: Слово для перевода
source_lang: Язык исходного слова (ISO2) source_lang: Язык исходного слова (ISO2)
translation_lang: Язык перевода (ISO2) translation_lang: Язык перевода (ISO2)
user_id: ID пользователя в БД для получения его модели
Returns: Returns:
Dict с переводом, транскрипцией и примерами Dict с переводом, транскрипцией и примерами
@@ -196,7 +218,7 @@ class AIService:
{"role": "user", "content": prompt} {"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 import json
result = json.loads(response_data['choices'][0]['message']['content']) result = json.loads(response_data['choices'][0]['message']['content'])
@@ -220,7 +242,8 @@ class AIService:
word: str, word: str,
source_lang: str = "en", source_lang: str = "en",
translation_lang: str = "ru", translation_lang: str = "ru",
max_translations: int = 3 max_translations: int = 3,
user_id: Optional[int] = None
) -> Dict: ) -> Dict:
""" """
Перевести слово и получить несколько переводов с контекстами Перевести слово и получить несколько переводов с контекстами
@@ -230,6 +253,7 @@ class AIService:
source_lang: Язык исходного слова (ISO2) source_lang: Язык исходного слова (ISO2)
translation_lang: Язык перевода (ISO2) translation_lang: Язык перевода (ISO2)
max_translations: Максимальное количество переводов max_translations: Максимальное количество переводов
user_id: ID пользователя в БД для получения его модели
Returns: Returns:
Dict с переводами, каждый с примером предложения Dict с переводами, каждый с примером предложения
@@ -275,7 +299,7 @@ class AIService:
{"role": "user", "content": prompt} {"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 import json
content = response_data['choices'][0]['message']['content'] content = response_data['choices'][0]['message']['content']
@@ -311,7 +335,8 @@ class AIService:
self, self,
words: List[str], words: List[str],
source_lang: str = "en", source_lang: str = "en",
translation_lang: str = "ru" translation_lang: str = "ru",
user_id: Optional[int] = None
) -> List[Dict]: ) -> List[Dict]:
""" """
Перевести список слов пакетно Перевести список слов пакетно
@@ -320,6 +345,7 @@ class AIService:
words: Список слов для перевода words: Список слов для перевода
source_lang: Язык исходных слов (ISO2) source_lang: Язык исходных слов (ISO2)
translation_lang: Язык перевода (ISO2) translation_lang: Язык перевода (ISO2)
user_id: ID пользователя в БД для получения его модели
Returns: Returns:
List[Dict] с переводами, транскрипциями List[Dict] с переводами, транскрипциями
@@ -361,7 +387,7 @@ class AIService:
{"role": "user", "content": prompt} {"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 import json
content = response_data['choices'][0]['message']['content'] content = response_data['choices'][0]['message']['content']
@@ -393,7 +419,7 @@ class AIService:
# Возвращаем слова без перевода в случае ошибки # Возвращаем слова без перевода в случае ошибки
return [{"word": w, "translation": "", "transcription": ""} for w in words] 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: Вопрос задания question: Вопрос задания
correct_answer: Правильный ответ correct_answer: Правильный ответ
user_answer: Ответ пользователя user_answer: Ответ пользователя
user_id: ID пользователя в БД для получения его модели
Returns: Returns:
Dict с результатом проверки и обратной связью Dict с результатом проверки и обратной связью
@@ -428,7 +455,7 @@ class AIService:
{"role": "user", "content": prompt} {"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 import json
result = json.loads(response_data['choices'][0]['message']['content']) result = json.loads(response_data['choices'][0]['message']['content'])
@@ -443,7 +470,7 @@ class AIService:
"score": 0 "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: Слово (на языке обучения), для которого нужно создать предложение word: Слово (на языке обучения), для которого нужно создать предложение
learning_lang: Язык обучения (ISO2) learning_lang: Язык обучения (ISO2)
translation_lang: Язык перевода предложения (ISO2) translation_lang: Язык перевода предложения (ISO2)
user_id: ID пользователя в БД для получения его модели
Returns: Returns:
Dict с предложением и правильным ответом Dict с предложением и правильным ответом
@@ -475,7 +503,7 @@ class AIService:
{"role": "user", "content": prompt} {"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 import json
result = json.loads(response_data['choices'][0]['message']['content']) result = json.loads(response_data['choices'][0]['message']['content'])
@@ -490,7 +518,7 @@ class AIService:
"translation": f"Мне нравится {word} каждый день." "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: Слово (на языке обучения), которое должно быть в предложении word: Слово (на языке обучения), которое должно быть в предложении
learning_lang: Язык обучения (ISO2) learning_lang: Язык обучения (ISO2)
translation_lang: Язык перевода (ISO2) translation_lang: Язык перевода (ISO2)
user_id: ID пользователя в БД для получения его модели
Returns: Returns:
Dict с предложением и его переводом Dict с предложением и его переводом
@@ -520,7 +549,7 @@ class AIService:
{"role": "user", "content": prompt} {"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 import json
result = json.loads(response_data['choices'][0]['message']['content']) result = json.loads(response_data['choices'][0]['message']['content'])
@@ -535,6 +564,116 @@ class AIService:
"translation": f"Я использую {word} каждый день." "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( async def generate_thematic_words(
self, self,
theme: str, theme: str,
@@ -542,7 +681,8 @@ class AIService:
count: int = 10, count: int = 10,
learning_lang: str = "en", learning_lang: str = "en",
translation_lang: str = "ru", translation_lang: str = "ru",
exclude_words: List[str] = None exclude_words: List[str] = None,
user_id: Optional[int] = None
) -> List[Dict]: ) -> List[Dict]:
""" """
Сгенерировать подборку слов по теме Сгенерировать подборку слов по теме
@@ -554,6 +694,7 @@ class AIService:
learning_lang: Язык изучения learning_lang: Язык изучения
translation_lang: Язык перевода translation_lang: Язык перевода
exclude_words: Список слов для исключения (уже известные) exclude_words: Список слов для исключения (уже известные)
user_id: ID пользователя в БД для получения его модели
Returns: Returns:
Список словарей с информацией о словах Список словарей с информацией о словах
@@ -601,7 +742,7 @@ class AIService:
{"role": "user", "content": prompt} {"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 import json
result = json.loads(response_data['choices'][0]['message']['content']) 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)}") logger.error(f"[AI Error] generate_thematic_words: {type(e).__name__}: {str(e)}")
return [] 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: Args:
text: Текст на английском языке text: Текст на языке изучения
level: Уровень пользователя (A1-C2) level: Уровень пользователя (A1-C2)
max_words: Максимальное количество слов для извлечения max_words: Максимальное количество слов для извлечения
learning_lang: Язык изучения
translation_lang: Язык перевода
user_id: ID пользователя в БД для получения его модели
Returns: Returns:
Список словарей с информацией о словах Список словарей с информацией о словах
@@ -670,7 +814,7 @@ class AIService:
{"role": "user", "content": prompt} {"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 import json
result = json.loads(response_data['choices'][0]['message']['content']) 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)}") logger.error(f"[AI Error] extract_words_from_text: {type(e).__name__}: {str(e)}")
return [] 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 Начать диалоговую практику с AI
Args: Args:
scenario: Сценарий диалога (restaurant, shopping, travel, etc.) scenario: Сценарий диалога (restaurant, shopping, travel, etc.)
level: Уровень пользователя (A1-C2) level: Уровень пользователя (A1-C2)
learning_lang: Язык изучения
translation_lang: Язык перевода
user_id: ID пользователя в БД для получения его модели
Returns: Returns:
Dict с начальной репликой и контекстом Dict с начальной репликой и контекстом
@@ -739,7 +886,7 @@ class AIService:
{"role": "user", "content": prompt} {"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 import json
result = json.loads(response_data['choices'][0]['message']['content']) result = json.loads(response_data['choices'][0]['message']['content'])
@@ -762,7 +909,8 @@ class AIService:
scenario: str, scenario: str,
level: str = "B1", level: str = "B1",
learning_lang: str = "en", learning_lang: str = "en",
translation_lang: str = "ru" translation_lang: str = "ru",
user_id: Optional[int] = None
) -> Dict: ) -> Dict:
""" """
Продолжить диалог и проверить ответ пользователя Продолжить диалог и проверить ответ пользователя
@@ -772,6 +920,9 @@ class AIService:
user_message: Сообщение пользователя user_message: Сообщение пользователя
scenario: Сценарий диалога scenario: Сценарий диалога
level: Уровень пользователя level: Уровень пользователя
learning_lang: Язык изучения
translation_lang: Язык перевода
user_id: ID пользователя в БД для получения его модели
Returns: Returns:
Dict с ответом AI, проверкой и подсказками Dict с ответом AI, проверкой и подсказками
@@ -833,7 +984,7 @@ User: {user_message}
# Добавляем инструкцию для форматирования ответа # Добавляем инструкцию для форматирования ответа
messages.append({"role": "user", "content": prompt}) 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 import json
result = json.loads(response_data['choices'][0]['message']['content']) result = json.loads(response_data['choices'][0]['message']['content'])
@@ -854,12 +1005,13 @@ User: {user_message}
"suggestions": ["Sure!", "Well...", "Actually..."] "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: Args:
learning_language: Язык изучения (en, es, de, fr, ja) learning_language: Язык изучения (en, es, de, fr, ja)
user_id: ID пользователя в БД для получения его модели
Returns: Returns:
Список из 7 вопросов разной сложности Список из 7 вопросов разной сложности
@@ -913,7 +1065,7 @@ User: {user_message}
{"role": "user", "content": prompt} {"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 import json
result = json.loads(response_data['choices'][0]['message']['content']) result = json.loads(response_data['choices'][0]['message']['content'])

View File

@@ -243,7 +243,7 @@ class TaskService:
translation_lang: str = 'ru' translation_lang: str = 'ru'
) -> List[Dict]: ) -> List[Dict]:
""" """
Генерация заданий определённого типа Генерация заданий определённого типа (оптимизировано - 1 запрос к AI)
Args: Args:
session: Сессия базы данных session: Сессия базы данных
@@ -272,9 +272,10 @@ class TaskService:
# Выбираем случайные слова # Выбираем случайные слова
selected_words = random.sample(words, min(count, len(words))) selected_words = random.sample(words, min(count, len(words)))
tasks = [] # 1. Подготовка: определяем типы и собираем данные для всех слов
word_data_list = []
for word in selected_words: for word in selected_words:
# Получаем переводы из таблицы WordTranslation # Получаем переводы
translations_result = await session.execute( translations_result = await session.execute(
select(WordTranslation) select(WordTranslation)
.where(WordTranslation.vocabulary_id == word.id) .where(WordTranslation.vocabulary_id == word.id)
@@ -288,18 +289,57 @@ class TaskService:
else: else:
chosen_type = task_type chosen_type = task_type
# Определяем правильный перевод # Определяем перевод
correct_translation = word.word_translation correct_translation = word.word_translation
if translations: if translations:
primary = next((tr for tr in translations if tr.is_primary), translations[0] if translations else None) primary = next((tr for tr in translations if tr.is_primary), translations[0] if translations else None)
if primary: if primary:
correct_translation = primary.translation 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': if chosen_type == 'word_translate':
# Задание на перевод слова
direction = random.choice(['learn_to_tr', 'tr_to_learn']) direction = random.choice(['learn_to_tr', 'tr_to_learn'])
# Локализация
if translation_lang == 'en': if translation_lang == 'en':
prompt = "Translate the word:" prompt = "Translate the word:"
elif translation_lang == 'ja': elif translation_lang == 'ja':
@@ -328,12 +368,7 @@ class TaskService:
} }
elif chosen_type == 'fill_blank': elif chosen_type == 'fill_blank':
# Задание на заполнение пропуска sentence_data = ai_results_map.get(i, {})
sentence_data = await ai_service.generate_fill_in_sentence(
word.word_original,
learning_lang=learning_lang,
translation_lang=translation_lang
)
if translation_lang == 'en': if translation_lang == 'en':
fill_title = "Fill in the blank:" fill_title = "Fill in the blank:"
@@ -347,21 +382,16 @@ class TaskService:
'word_id': word.id, 'word_id': word.id,
'question': ( 'question': (
f"{fill_title}\n\n" f"{fill_title}\n\n"
f"<b>{sentence_data['sentence']}</b>\n\n" f"<b>{sentence_data.get('sentence', '___')}</b>\n\n"
f"<i>{sentence_data.get('translation', '')}</i>" f"<i>{sentence_data.get('translation', '')}</i>"
), ),
'word': word.word_original, 'word': word.word_original,
'correct_answer': sentence_data['answer'], 'correct_answer': sentence_data.get('answer', word.word_original),
'sentence': sentence_data['sentence'] 'sentence': sentence_data.get('sentence', '___')
} }
elif chosen_type == 'sentence_translate': elif chosen_type == 'sentence_translate':
# Задание на перевод предложения sentence_data = ai_results_map.get(i, {})
sentence_data = await ai_service.generate_sentence_for_translation(
word.word_original,
learning_lang=learning_lang,
translation_lang=translation_lang
)
if translation_lang == 'en': if translation_lang == 'en':
sentence_title = "Translate the sentence:" sentence_title = "Translate the sentence:"
@@ -376,10 +406,10 @@ class TaskService:
task = { task = {
'type': 'sentence_translate', 'type': 'sentence_translate',
'word_id': word.id, 'word_id': word.id,
'question': f"{sentence_title}\n\n<b>{sentence_data['sentence']}</b>\n\n📝 {word_hint}: <code>{word.word_original}</code> — {correct_translation}", 'question': f"{sentence_title}\n\n<b>{sentence_data.get('sentence', word.word_original)}</b>\n\n📝 {word_hint}: <code>{word.word_original}</code> — {correct_translation}",
'word': word.word_original, 'word': word.word_original,
'correct_answer': sentence_data['translation'], 'correct_answer': sentence_data.get('translation', correct_translation),
'sentence': sentence_data['sentence'] 'sentence': sentence_data.get('sentence', word.word_original)
} }
tasks.append(task) tasks.append(task)

View File

@@ -178,3 +178,22 @@ class UserService:
if user: if user:
user.tasks_count = count user.tasks_count = count
await session.commit() 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()

View File

@@ -17,8 +17,6 @@ class VocabularyService:
source_lang: Optional[str] = None, source_lang: Optional[str] = None,
translation_lang: Optional[str] = None, translation_lang: Optional[str] = None,
transcription: Optional[str] = None, transcription: Optional[str] = None,
examples: Optional[dict] = None,
category: Optional[str] = None,
difficulty_level: Optional[str] = None, difficulty_level: Optional[str] = None,
source: WordSource = WordSource.MANUAL, source: WordSource = WordSource.MANUAL,
notes: Optional[str] = None notes: Optional[str] = None
@@ -32,8 +30,6 @@ class VocabularyService:
word_original: Оригинальное слово word_original: Оригинальное слово
word_translation: Перевод word_translation: Перевод
transcription: Транскрипция transcription: Транскрипция
examples: Примеры использования
category: Категория слова
difficulty_level: Уровень сложности difficulty_level: Уровень сложности
source: Источник добавления source: Источник добавления
notes: Заметки пользователя notes: Заметки пользователя
@@ -56,8 +52,6 @@ class VocabularyService:
source_lang=source_lang, source_lang=source_lang,
translation_lang=translation_lang, translation_lang=translation_lang,
transcription=transcription, transcription=transcription,
examples=examples,
category=category,
difficulty_level=difficulty_enum, difficulty_level=difficulty_enum,
source=source, source=source,
notes=notes notes=notes
@@ -138,7 +132,12 @@ class VocabularyService:
return len(words) return len(words)
@staticmethod @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: Сессия базы данных session: Сессия базы данных
user_id: ID пользователя user_id: ID пользователя
word: Слово для поиска word: Слово для поиска
source_lang: Язык изучения для фильтрации (если указан)
Returns: Returns:
Объект слова или None Объект слова или None
""" """
result = await session.execute( query = (
select(Vocabulary) select(Vocabulary)
.where(Vocabulary.user_id == user_id) .where(Vocabulary.user_id == user_id)
.where(Vocabulary.word_original.ilike(f"%{word}%")) .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() return result.scalar_one_or_none()
@staticmethod @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: Сессия базы данных session: Сессия базы данных
user_id: ID пользователя user_id: ID пользователя
word: Слово для поиска (точное совпадение) word: Слово для поиска (точное совпадение)
source_lang: Язык изучения для фильтрации (если указан)
Returns: Returns:
Объект слова или None Объект слова или None
""" """
result = await session.execute( query = (
select(Vocabulary) select(Vocabulary)
.where(Vocabulary.user_id == user_id) .where(Vocabulary.user_id == user_id)
.where(Vocabulary.word_original == word.lower()) .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() return result.scalar_one_or_none()
@staticmethod @staticmethod