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

View File

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

View File

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

View File

@@ -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"📝 <b>{word_data['word']}</b>\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
)

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)
# Проверяем, нет ли уже такого слова
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

View File

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

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

View File

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

View File

@@ -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"<b>{sentence_data['sentence']}</b>\n\n"
f"<b>{sentence_data.get('sentence', '___')}</b>\n\n"
f"<i>{sentence_data.get('translation', '')}</i>"
),
'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<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,
'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)

View File

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

View File

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