feat: multiple translations with context, improved task examples
- Add WordTranslation model for storing multiple translations per word - AI generates translations with example sentences and their translations - Show example usage after answering tasks (learning + interface language) - Save translations to word_translations table when adding words from tasks - Improve word exclusion in new_words mode (stronger prompt + client filtering) - Add migration for word_translations table 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -111,6 +111,98 @@ class AIService:
|
||||
"difficulty": "A1"
|
||||
}
|
||||
|
||||
async def translate_word_with_contexts(
|
||||
self,
|
||||
word: str,
|
||||
source_lang: str = "en",
|
||||
translation_lang: str = "ru",
|
||||
max_translations: int = 3
|
||||
) -> Dict:
|
||||
"""
|
||||
Перевести слово и получить несколько переводов с контекстами
|
||||
|
||||
Args:
|
||||
word: Слово для перевода
|
||||
source_lang: Язык исходного слова (ISO2)
|
||||
translation_lang: Язык перевода (ISO2)
|
||||
max_translations: Максимальное количество переводов
|
||||
|
||||
Returns:
|
||||
Dict с переводами, каждый с примером предложения
|
||||
"""
|
||||
prompt = f"""Переведи слово/фразу "{word}" с языка {source_lang} на {translation_lang}.
|
||||
|
||||
Если у слова есть несколько значений в разных контекстах, дай до {max_translations} разных переводов.
|
||||
Для каждого перевода дай пример предложения, показывающий это значение.
|
||||
|
||||
Верни ответ строго в формате JSON:
|
||||
{{
|
||||
"word": "исходное слово на {source_lang}",
|
||||
"transcription": "транскрипция в IPA (если применимо)",
|
||||
"category": "основная категория слова",
|
||||
"difficulty": "уровень сложности (A1/A2/B1/B2/C1/C2)",
|
||||
"translations": [
|
||||
{{
|
||||
"translation": "перевод 1 на {translation_lang}",
|
||||
"context": "пример предложения на {source_lang}, показывающий это значение",
|
||||
"context_translation": "перевод примера на {translation_lang}",
|
||||
"is_primary": true
|
||||
}},
|
||||
{{
|
||||
"translation": "перевод 2 на {translation_lang} (если есть другое значение)",
|
||||
"context": "пример предложения на {source_lang}",
|
||||
"context_translation": "перевод примера на {translation_lang}",
|
||||
"is_primary": false
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
Важно:
|
||||
- Первый перевод должен быть самым распространённым (is_primary: true)
|
||||
- Давай разные переводы только если слово реально имеет разные значения
|
||||
- Примеры должны чётко показывать конкретное значение слова
|
||||
- Верни только JSON, без дополнительного текста"""
|
||||
|
||||
try:
|
||||
logger.info(f"[GPT Request] translate_word_with_contexts: word='{word}', source='{source_lang}', to='{translation_lang}'")
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "Ты - помощник для изучения языков. Отвечай только в формате JSON."},
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
|
||||
response_data = await self._make_openai_request(messages, temperature=0.3)
|
||||
|
||||
import json
|
||||
content = response_data['choices'][0]['message']['content']
|
||||
# Убираем markdown обёртку если есть
|
||||
if content.startswith('```'):
|
||||
content = content.split('\n', 1)[1] if '\n' in content else content[3:]
|
||||
if content.endswith('```'):
|
||||
content = content[:-3]
|
||||
content = content.strip()
|
||||
|
||||
result = json.loads(content)
|
||||
translations_count = len(result.get('translations', []))
|
||||
logger.info(f"[GPT Response] translate_word_with_contexts: success, {translations_count} translations")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[GPT Error] translate_word_with_contexts: {type(e).__name__}: {str(e)}")
|
||||
# Fallback в случае ошибки
|
||||
return {
|
||||
"word": word,
|
||||
"transcription": "",
|
||||
"category": "unknown",
|
||||
"difficulty": "A1",
|
||||
"translations": [{
|
||||
"translation": "Ошибка перевода",
|
||||
"context": "",
|
||||
"context_translation": "",
|
||||
"is_primary": True
|
||||
}]
|
||||
}
|
||||
|
||||
async def translate_words_batch(
|
||||
self,
|
||||
words: List[str],
|
||||
@@ -294,7 +386,15 @@ class AIService:
|
||||
"translation": f"Мне нравится {word} каждый день."
|
||||
}
|
||||
|
||||
async def generate_thematic_words(self, theme: str, level: str = "B1", count: int = 10, learning_lang: str = "en", translation_lang: str = "ru") -> List[Dict]:
|
||||
async def generate_thematic_words(
|
||||
self,
|
||||
theme: str,
|
||||
level: str = "B1",
|
||||
count: int = 10,
|
||||
learning_lang: str = "en",
|
||||
translation_lang: str = "ru",
|
||||
exclude_words: List[str] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Сгенерировать подборку слов по теме
|
||||
|
||||
@@ -302,12 +402,28 @@ class AIService:
|
||||
theme: Тема для подборки слов
|
||||
level: Уровень сложности (A1-C2)
|
||||
count: Количество слов
|
||||
learning_lang: Язык изучения
|
||||
translation_lang: Язык перевода
|
||||
exclude_words: Список слов для исключения (уже известные)
|
||||
|
||||
Returns:
|
||||
Список словарей с информацией о словах
|
||||
"""
|
||||
prompt = f"""Создай подборку из {count} слов на языке {learning_lang} по теме "{theme}" для уровня {level}. Переводы дай на {translation_lang}.
|
||||
exclude_instruction = ""
|
||||
exclude_words_set = set()
|
||||
if exclude_words:
|
||||
# Ограничиваем список до 100 слов чтобы не раздувать промпт
|
||||
words_sample = exclude_words[:100]
|
||||
exclude_words_set = set(w.lower() for w in exclude_words)
|
||||
exclude_instruction = f"""
|
||||
|
||||
⚠️ ЗАПРЕЩЁННЫЕ СЛОВА (НЕ ИСПОЛЬЗОВАТЬ!):
|
||||
{', '.join(words_sample)}
|
||||
|
||||
Эти слова пользователь уже знает. ОБЯЗАТЕЛЬНО выбери ДРУГИЕ слова!"""
|
||||
|
||||
prompt = f"""Создай подборку из {count} слов на языке {learning_lang} по теме "{theme}" для уровня {level}. Переводы дай на {translation_lang}.
|
||||
{exclude_instruction}
|
||||
Верни ответ в формате JSON:
|
||||
{{
|
||||
"theme": "{theme}",
|
||||
@@ -316,7 +432,8 @@ class AIService:
|
||||
"word": "слово на {learning_lang}",
|
||||
"translation": "перевод на {translation_lang}",
|
||||
"transcription": "транскрипция в IPA (если применимо)",
|
||||
"example": "пример использования на {learning_lang}"
|
||||
"example": "пример использования на {learning_lang}",
|
||||
"example_translation": "перевод примера на {translation_lang}"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
@@ -339,9 +456,21 @@ class AIService:
|
||||
|
||||
import json
|
||||
result = json.loads(response_data['choices'][0]['message']['content'])
|
||||
words_count = len(result.get('words', []))
|
||||
logger.info(f"[GPT Response] generate_thematic_words: success, generated {words_count} words")
|
||||
return result.get('words', [])
|
||||
words = result.get('words', [])
|
||||
|
||||
# Фильтруем слова которые AI мог вернуть несмотря на инструкцию
|
||||
if exclude_words_set:
|
||||
filtered_words = [
|
||||
w for w in words
|
||||
if w.get('word', '').lower() not in exclude_words_set
|
||||
]
|
||||
filtered_count = len(words) - len(filtered_words)
|
||||
if filtered_count > 0:
|
||||
logger.info(f"[GPT Response] generate_thematic_words: filtered out {filtered_count} excluded words")
|
||||
words = filtered_words
|
||||
|
||||
logger.info(f"[GPT Response] generate_thematic_words: success, generated {len(words)} words")
|
||||
return words
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[GPT Error] generate_thematic_words: {type(e).__name__}: {str(e)}")
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import List, Dict, Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Task, Vocabulary
|
||||
from database.models import Task, Vocabulary, WordTranslation
|
||||
from services.ai_service import ai_service
|
||||
|
||||
|
||||
@@ -107,8 +107,54 @@ class TaskService:
|
||||
|
||||
tasks = []
|
||||
for word in selected_words:
|
||||
# Получаем переводы из таблицы WordTranslation
|
||||
translations_result = await session.execute(
|
||||
select(WordTranslation)
|
||||
.where(WordTranslation.vocabulary_id == word.id)
|
||||
.order_by(WordTranslation.is_primary.desc())
|
||||
)
|
||||
translations = list(translations_result.scalars().all())
|
||||
|
||||
# Случайно выбираем тип задания
|
||||
task_type = random.choice(['translate', 'fill_in'])
|
||||
# Если есть переводы с контекстом, добавляем тип 'context_translate'
|
||||
task_types = ['translate', 'fill_in']
|
||||
if translations and any(tr.context for tr in translations):
|
||||
task_types.append('context_translate')
|
||||
|
||||
task_type = random.choice(task_types)
|
||||
|
||||
if task_type == 'context_translate' and translations:
|
||||
# Задание на перевод по контексту
|
||||
# Выбираем случайный перевод с контекстом
|
||||
translations_with_context = [tr for tr in translations if tr.context]
|
||||
if translations_with_context:
|
||||
selected_tr = random.choice(translations_with_context)
|
||||
|
||||
# Локализация фразы
|
||||
if translation_lang == 'en':
|
||||
prompt = "Translate the highlighted word in context:"
|
||||
elif translation_lang == 'ja':
|
||||
prompt = "文脈に合った翻訳を入力してください:"
|
||||
else:
|
||||
prompt = "Переведи выделенное слово в контексте:"
|
||||
|
||||
task = {
|
||||
'type': 'context_translate',
|
||||
'word_id': word.id,
|
||||
'translation_id': selected_tr.id,
|
||||
'question': (
|
||||
f"{prompt}\n\n"
|
||||
f"<i>«{selected_tr.context}»</i>\n\n"
|
||||
f"<b>{word.word_original}</b> = ?"
|
||||
),
|
||||
'word': word.word_original,
|
||||
'correct_answer': selected_tr.translation,
|
||||
'transcription': word.transcription,
|
||||
'context': selected_tr.context,
|
||||
'context_translation': selected_tr.context_translation
|
||||
}
|
||||
tasks.append(task)
|
||||
continue
|
||||
|
||||
if task_type == 'translate':
|
||||
# Задание на перевод между языком обучения и языком перевода
|
||||
@@ -122,21 +168,31 @@ class TaskService:
|
||||
else:
|
||||
prompt = "Переведи слово:"
|
||||
|
||||
# Определяем правильный ответ - берём из таблицы переводов если есть
|
||||
correct_translation = word.word_translation
|
||||
if translations:
|
||||
# Берём основной перевод или первый
|
||||
primary = next((tr for tr in translations if tr.is_primary), translations[0] if translations else None)
|
||||
if primary:
|
||||
correct_translation = primary.translation
|
||||
|
||||
if direction == 'learn_to_tr':
|
||||
task = {
|
||||
'type': f'translate_to_{translation_lang}',
|
||||
'word_id': word.id,
|
||||
'question': f"{prompt} <b>{word.word_original}</b>",
|
||||
'word': word.word_original,
|
||||
'correct_answer': word.word_translation,
|
||||
'transcription': word.transcription
|
||||
'correct_answer': correct_translation,
|
||||
'transcription': word.transcription,
|
||||
# Все допустимые ответы для проверки
|
||||
'all_translations': [tr.translation for tr in translations] if translations else [correct_translation]
|
||||
}
|
||||
else:
|
||||
task = {
|
||||
'type': f'translate_to_{learning_lang}',
|
||||
'word_id': word.id,
|
||||
'question': f"{prompt} <b>{word.word_translation}</b>",
|
||||
'word': word.word_translation,
|
||||
'question': f"{prompt} <b>{correct_translation}</b>",
|
||||
'word': correct_translation,
|
||||
'correct_answer': word.word_original,
|
||||
'transcription': word.transcription
|
||||
}
|
||||
@@ -285,3 +341,34 @@ class TaskService:
|
||||
'correct_tasks': correct_tasks,
|
||||
'accuracy': accuracy
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_correctly_answered_words(
|
||||
session: AsyncSession,
|
||||
user_id: int
|
||||
) -> List[str]:
|
||||
"""
|
||||
Получить список слов, на которые пользователь правильно ответил в заданиях
|
||||
|
||||
Args:
|
||||
session: Сессия базы данных
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Список слов (строки) с правильными ответами
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(Task)
|
||||
.where(Task.user_id == user_id)
|
||||
.where(Task.is_correct == True)
|
||||
)
|
||||
tasks = list(result.scalars().all())
|
||||
|
||||
words = []
|
||||
for task in tasks:
|
||||
if task.content and isinstance(task.content, dict):
|
||||
word = task.content.get('word')
|
||||
if word:
|
||||
words.append(word.lower())
|
||||
|
||||
return list(set(words))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from database.models import Vocabulary, WordSource, LanguageLevel
|
||||
from typing import List, Optional
|
||||
from database.models import Vocabulary, WordSource, LanguageLevel, WordTranslation
|
||||
from typing import List, Optional, Dict
|
||||
import re
|
||||
|
||||
|
||||
@@ -176,3 +176,183 @@ class VocabularyService:
|
||||
.where(Vocabulary.word_original == word.lower())
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_all_user_word_strings(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
learning_lang: Optional[str] = None
|
||||
) -> List[str]:
|
||||
"""
|
||||
Получить список всех слов пользователя (только строки)
|
||||
|
||||
Args:
|
||||
session: Сессия базы данных
|
||||
user_id: ID пользователя
|
||||
learning_lang: Язык изучения для фильтрации
|
||||
|
||||
Returns:
|
||||
Список строк — оригинальных слов
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(Vocabulary)
|
||||
.where(Vocabulary.user_id == user_id)
|
||||
)
|
||||
words = list(result.scalars().all())
|
||||
words = VocabularyService._filter_by_learning_lang(words, learning_lang)
|
||||
return [w.word_original.lower() for w in words]
|
||||
|
||||
# === Методы для работы с переводами ===
|
||||
|
||||
@staticmethod
|
||||
async def add_translation(
|
||||
session: AsyncSession,
|
||||
vocabulary_id: int,
|
||||
translation: str,
|
||||
context: Optional[str] = None,
|
||||
context_translation: Optional[str] = None,
|
||||
is_primary: bool = False
|
||||
) -> WordTranslation:
|
||||
"""
|
||||
Добавить перевод к слову
|
||||
|
||||
Args:
|
||||
session: Сессия базы данных
|
||||
vocabulary_id: ID слова в словаре
|
||||
translation: Перевод
|
||||
context: Пример предложения на языке изучения
|
||||
context_translation: Перевод примера
|
||||
is_primary: Является ли основным переводом
|
||||
|
||||
Returns:
|
||||
Созданный объект перевода
|
||||
"""
|
||||
# Если это основной перевод, снимаем флаг с других
|
||||
if is_primary:
|
||||
result = await session.execute(
|
||||
select(WordTranslation)
|
||||
.where(WordTranslation.vocabulary_id == vocabulary_id)
|
||||
.where(WordTranslation.is_primary == True)
|
||||
)
|
||||
for existing in result.scalars().all():
|
||||
existing.is_primary = False
|
||||
|
||||
new_translation = WordTranslation(
|
||||
vocabulary_id=vocabulary_id,
|
||||
translation=translation,
|
||||
context=context,
|
||||
context_translation=context_translation,
|
||||
is_primary=is_primary
|
||||
)
|
||||
|
||||
session.add(new_translation)
|
||||
await session.commit()
|
||||
await session.refresh(new_translation)
|
||||
|
||||
return new_translation
|
||||
|
||||
@staticmethod
|
||||
async def add_translations_bulk(
|
||||
session: AsyncSession,
|
||||
vocabulary_id: int,
|
||||
translations: List[Dict]
|
||||
) -> List[WordTranslation]:
|
||||
"""
|
||||
Добавить несколько переводов к слову
|
||||
|
||||
Args:
|
||||
session: Сессия базы данных
|
||||
vocabulary_id: ID слова
|
||||
translations: Список словарей с переводами
|
||||
[{"translation": "...", "context": "...", "context_translation": "...", "is_primary": bool}]
|
||||
|
||||
Returns:
|
||||
Список созданных переводов
|
||||
"""
|
||||
created = []
|
||||
for i, tr_data in enumerate(translations):
|
||||
new_translation = WordTranslation(
|
||||
vocabulary_id=vocabulary_id,
|
||||
translation=tr_data.get('translation', ''),
|
||||
context=tr_data.get('context'),
|
||||
context_translation=tr_data.get('context_translation'),
|
||||
is_primary=tr_data.get('is_primary', i == 0) # Первый по умолчанию основной
|
||||
)
|
||||
session.add(new_translation)
|
||||
created.append(new_translation)
|
||||
|
||||
await session.commit()
|
||||
for tr in created:
|
||||
await session.refresh(tr)
|
||||
|
||||
return created
|
||||
|
||||
@staticmethod
|
||||
async def get_word_translations(
|
||||
session: AsyncSession,
|
||||
vocabulary_id: int
|
||||
) -> List[WordTranslation]:
|
||||
"""
|
||||
Получить все переводы слова
|
||||
|
||||
Args:
|
||||
session: Сессия базы данных
|
||||
vocabulary_id: ID слова
|
||||
|
||||
Returns:
|
||||
Список переводов
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(WordTranslation)
|
||||
.where(WordTranslation.vocabulary_id == vocabulary_id)
|
||||
.order_by(WordTranslation.is_primary.desc(), WordTranslation.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@staticmethod
|
||||
async def get_primary_translation(
|
||||
session: AsyncSession,
|
||||
vocabulary_id: int
|
||||
) -> Optional[WordTranslation]:
|
||||
"""
|
||||
Получить основной перевод слова
|
||||
|
||||
Args:
|
||||
session: Сессия базы данных
|
||||
vocabulary_id: ID слова
|
||||
|
||||
Returns:
|
||||
Основной перевод или None
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(WordTranslation)
|
||||
.where(WordTranslation.vocabulary_id == vocabulary_id)
|
||||
.where(WordTranslation.is_primary == True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def delete_translation(
|
||||
session: AsyncSession,
|
||||
translation_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
Удалить перевод
|
||||
|
||||
Args:
|
||||
session: Сессия базы данных
|
||||
translation_id: ID перевода
|
||||
|
||||
Returns:
|
||||
True если удалено, False если не найдено
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(WordTranslation).where(WordTranslation.id == translation_id)
|
||||
)
|
||||
translation = result.scalar_one_or_none()
|
||||
|
||||
if translation:
|
||||
await session.delete(translation)
|
||||
await session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user