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:
2025-12-06 21:29:41 +03:00
parent 63e2615243
commit d937b37a3b
10 changed files with 543 additions and 30 deletions

View File

@@ -109,6 +109,18 @@ async def start_new_words_tasks(callback: CallbackQuery, state: FSMContext):
# Показываем индикатор загрузки # Показываем индикатор загрузки
await callback.message.edit_text(t(lang, 'tasks.generating_new')) await callback.message.edit_text(t(lang, 'tasks.generating_new'))
# Получаем слова для исключения:
# 1. Все слова из словаря пользователя
vocab_words = await VocabularyService.get_all_user_word_strings(
session, user.id, learning_lang=user.learning_language
)
# 2. Слова из предыдущих заданий new_words, на которые ответили правильно
correct_task_words = await TaskService.get_correctly_answered_words(
session, user.id
)
# Объединяем списки исключений
exclude_words = list(set(vocab_words + correct_task_words))
# Генерируем новые слова через AI # Генерируем новые слова через AI
words = await ai_service.generate_thematic_words( words = await ai_service.generate_thematic_words(
theme="random everyday vocabulary", theme="random everyday vocabulary",
@@ -116,6 +128,7 @@ async def start_new_words_tasks(callback: CallbackQuery, state: FSMContext):
count=5, count=5,
learning_lang=user.learning_language, learning_lang=user.learning_language,
translation_lang=user.language_interface, translation_lang=user.language_interface,
exclude_words=exclude_words if exclude_words else None,
) )
if not words: if not words:
@@ -132,7 +145,9 @@ async def start_new_words_tasks(callback: CallbackQuery, state: FSMContext):
'question': f"{translate_prompt}: {word.get('word', '')}", 'question': f"{translate_prompt}: {word.get('word', '')}",
'word': word.get('word', ''), 'word': word.get('word', ''),
'correct_answer': word.get('translation', ''), 'correct_answer': word.get('translation', ''),
'transcription': word.get('transcription', '') 'transcription': word.get('transcription', ''),
'example': word.get('example', ''), # Пример на изучаемом языке
'example_translation': word.get('example_translation', '') # Перевод примера
}) })
await state.update_data( await state.update_data(
@@ -226,6 +241,16 @@ async def process_answer(message: Message, state: FSMContext):
if feedback: if feedback:
result_text += f"💬 {feedback}\n\n" result_text += f"💬 {feedback}\n\n"
# Показываем пример использования если есть
example = task.get('example', '')
example_translation = task.get('example_translation', '')
if example:
result_text += f"📖 {t(lang, 'tasks.example_label')}:\n"
result_text += f"<i>{example}</i>\n"
if example_translation:
result_text += f"<i>({example_translation})</i>\n"
result_text += "\n"
# Сохраняем результат в БД # Сохраняем результат в БД
async with async_session_maker() as session: async with async_session_maker() as session:
await TaskService.save_task_result( await TaskService.save_task_result(
@@ -298,6 +323,8 @@ async def add_task_word(callback: CallbackQuery, state: FSMContext):
word = task.get('word', '') word = task.get('word', '')
translation = task.get('correct_answer', '') translation = task.get('correct_answer', '')
transcription = task.get('transcription', '') transcription = task.get('transcription', '')
example = task.get('example', '') # Пример использования как контекст
example_translation = task.get('example_translation', '') # Перевод примера
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)
@@ -316,7 +343,7 @@ async def add_task_word(callback: CallbackQuery, state: FSMContext):
return return
# Добавляем слово в словарь # Добавляем слово в словарь
await VocabularyService.add_word( new_word = await VocabularyService.add_word(
session=session, session=session,
user_id=user.id, user_id=user.id,
word_original=word, word_original=word,
@@ -327,6 +354,18 @@ async def add_task_word(callback: CallbackQuery, state: FSMContext):
source=WordSource.AI_TASK source=WordSource.AI_TASK
) )
# Сохраняем перевод в таблицу word_translations
await VocabularyService.add_translations_bulk(
session=session,
vocabulary_id=new_word.id,
translations=[{
'translation': translation,
'context': example if example else None,
'context_translation': example_translation if example_translation else None,
'is_primary': True
}]
)
await callback.answer(t(lang, 'tasks.word_added', word=word), show_alert=True) await callback.answer(t(lang, 'tasks.word_added', word=word), show_alert=True)

View File

@@ -69,33 +69,47 @@ async def process_word_addition(message: Message, state: FSMContext, word: str):
lang = (user.language_interface if user else 'ru') or 'ru' lang = (user.language_interface if user else 'ru') or 'ru'
processing_msg = await message.answer(t(lang, 'add.searching')) processing_msg = await message.answer(t(lang, 'add.searching'))
# Получаем перевод через AI # Получаем перевод через AI (с несколькими значениями)
async with async_session_maker() as session: async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id) user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
source_lang = user.learning_language if user else 'en' source_lang = user.learning_language if user else 'en'
ui_lang = user.language_interface if user else 'ru' ui_lang = user.language_interface if user else 'ru'
word_data = await ai_service.translate_word(word, source_lang=source_lang, translation_lang=ui_lang) word_data = await ai_service.translate_word_with_contexts(
word, source_lang=source_lang, translation_lang=ui_lang, max_translations=3
)
# Удаляем сообщение о загрузке # Удаляем сообщение о загрузке
await processing_msg.delete() await processing_msg.delete()
# Формируем примеры # Формируем текст с переводами
examples_text = "" translations = word_data.get("translations", [])
if word_data.get("examples"): translations_text = ""
examples_text = "\n\n" + t(lang, 'add.examples_header') + "\n"
for idx, example in enumerate(word_data["examples"][:2], 1): if translations:
src = example.get(source_lang) or example.get('en') or example.get('ru') or '' # Основной перевод для backward compatibility
tr = example.get(ui_lang) or example.get('ru') or example.get('en') or '' primary = next((tr for tr in translations if tr.get('is_primary')), translations[0])
examples_text += f"{idx}. {src}\n <i>{tr}</i>\n" word_data['translation'] = primary.get('translation', '')
translations_text = "\n\n" + t(lang, 'add.translations_header') + "\n"
for idx, tr in enumerate(translations, 1):
marker = "" if tr.get('is_primary') else ""
translations_text += f"{idx}. {marker}<b>{tr.get('translation', '')}</b>\n"
if tr.get('context'):
translations_text += f" <i>«{tr.get('context', '')}»</i>\n"
if tr.get('context_translation'):
translations_text += f" <i>({tr.get('context_translation', '')})</i>\n"
translations_text += "\n"
else:
# Fallback если нет переводов
word_data['translation'] = 'Ошибка перевода'
# Отправляем карточку слова # Отправляем карточку слова
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.translation_label')}: {word_data['translation']}\n"
f"{t(lang, 'add.category_label')}: {word_data.get('category', '')}\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"{examples_text}\n\n" f"{translations_text}"
f"{t(lang, 'add.confirm_question')}" f"{t(lang, 'add.confirm_question')}"
) )
@@ -130,7 +144,7 @@ async def confirm_add_word(callback: CallbackQuery, state: FSMContext):
ui_lang = user.language_interface if user else 'ru' ui_lang = user.language_interface if user else 'ru'
# Добавляем слово в базу # Добавляем слово в базу
await VocabularyService.add_word( new_word = await VocabularyService.add_word(
session, session,
user_id=user_id, user_id=user_id,
word_original=word_data["word"], word_original=word_data["word"],
@@ -138,12 +152,20 @@ async def confirm_add_word(callback: CallbackQuery, state: FSMContext):
source_lang=source_lang, source_lang=source_lang,
translation_lang=ui_lang, translation_lang=ui_lang,
transcription=word_data.get("transcription"), transcription=word_data.get("transcription"),
examples={"examples": word_data.get("examples", [])},
category=word_data.get("category"), category=word_data.get("category"),
difficulty_level=word_data.get("difficulty"), difficulty_level=word_data.get("difficulty"),
source=WordSource.MANUAL source=WordSource.MANUAL
) )
# Сохраняем переводы с контекстами в отдельную таблицу
translations = word_data.get("translations", [])
if translations:
await VocabularyService.add_translations_bulk(
session,
vocabulary_id=new_word.id,
translations=translations
)
# Получаем общее количество слов # Получаем общее количество слов
words_count = await VocabularyService.get_words_count(session, user_id, learning_lang=user.learning_language) words_count = await VocabularyService.get_words_count(session, user_id, learning_lang=user.learning_language)
lang = ui_lang or 'ru' lang = ui_lang or 'ru'

View File

@@ -93,6 +93,19 @@ class Vocabulary(Base):
notes: Mapped[Optional[str]] = mapped_column(String(500)) # Заметки пользователя notes: Mapped[Optional[str]] = mapped_column(String(500)) # Заметки пользователя
class WordTranslation(Base):
"""Модель перевода слова с контекстом"""
__tablename__ = "word_translations"
id: Mapped[int] = mapped_column(primary_key=True)
vocabulary_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
translation: Mapped[str] = mapped_column(String(255), nullable=False)
context: Mapped[Optional[str]] = mapped_column(String(500)) # Пример предложения
context_translation: Mapped[Optional[str]] = mapped_column(String(500)) # Перевод примера
is_primary: Mapped[bool] = mapped_column(Boolean, default=False) # Основной перевод
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class Task(Base): class Task(Base):
"""Модель задания""" """Модель задания"""
__tablename__ = "tasks" __tablename__ = "tasks"

View File

@@ -58,6 +58,7 @@
"prompt": "Send the word you want to add:\nFor example: <code>/add elephant</code>\n\nOr just send the word without a command!", "prompt": "Send the word you want to add:\nFor example: <code>/add elephant</code>\n\nOr just send the word without a command!",
"searching": "⏳ Looking up translation and examples...", "searching": "⏳ Looking up translation and examples...",
"examples_header": "<b>Examples:</b>", "examples_header": "<b>Examples:</b>",
"translations_header": "<b>Translations:</b>",
"translation_label": "Translation", "translation_label": "Translation",
"category_label": "Category", "category_label": "Category",
"level_label": "Level", "level_label": "Level",
@@ -132,6 +133,7 @@
"add_word_btn": " Add word", "add_word_btn": " Add word",
"word_added": "✅ Word '{word}' added to vocabulary!", "word_added": "✅ Word '{word}' added to vocabulary!",
"word_already_exists": "Word '{word}' is already in vocabulary", "word_already_exists": "Word '{word}' is already in vocabulary",
"example_label": "Example",
"cancelled": "Cancelled. You can return to tasks with /task.", "cancelled": "Cancelled. You can return to tasks with /task.",
"finish_title": "{emoji} <b>Task finished!</b>", "finish_title": "{emoji} <b>Task finished!</b>",
"correct_of": "Correct answers: <b>{correct}</b> of {total}", "correct_of": "Correct answers: <b>{correct}</b> of {total}",

View File

@@ -58,6 +58,7 @@
"prompt": "追加したい単語を送ってください:\n例: <code>/add elephant</code>\n\nコマンドなしで単語だけ送ってもOKです", "prompt": "追加したい単語を送ってください:\n例: <code>/add elephant</code>\n\nコマンドなしで単語だけ送ってもOKです",
"searching": "⏳ 翻訳と例を検索中...", "searching": "⏳ 翻訳と例を検索中...",
"examples_header": "<b>例文:</b>", "examples_header": "<b>例文:</b>",
"translations_header": "<b>翻訳:</b>",
"translation_label": "翻訳", "translation_label": "翻訳",
"category_label": "カテゴリー", "category_label": "カテゴリー",
"level_label": "レベル", "level_label": "レベル",
@@ -124,6 +125,7 @@
"add_word_btn": " 単語を追加", "add_word_btn": " 単語を追加",
"word_added": "✅ 単語 '{word}' を単語帳に追加しました!", "word_added": "✅ 単語 '{word}' を単語帳に追加しました!",
"word_already_exists": "単語 '{word}' はすでに単語帳にあります", "word_already_exists": "単語 '{word}' はすでに単語帳にあります",
"example_label": "例文",
"cancelled": "キャンセルしました。/task で課題に戻れます。", "cancelled": "キャンセルしました。/task で課題に戻れます。",
"finish_title": "{emoji} <b>課題が終了しました!</b>", "finish_title": "{emoji} <b>課題が終了しました!</b>",
"correct_of": "正解数: <b>{correct}</b> / {total}", "correct_of": "正解数: <b>{correct}</b> / {total}",

View File

@@ -58,6 +58,7 @@
"prompt": "Отправь слово, которое хочешь добавить:\nНапример: <code>/add elephant</code>\n\nИли просто отправь слово без команды!", "prompt": "Отправь слово, которое хочешь добавить:\nНапример: <code>/add elephant</code>\n\nИли просто отправь слово без команды!",
"searching": "⏳ Ищу перевод и примеры...", "searching": "⏳ Ищу перевод и примеры...",
"examples_header": "<b>Примеры:</b>", "examples_header": "<b>Примеры:</b>",
"translations_header": "<b>Переводы:</b>",
"translation_label": "Перевод", "translation_label": "Перевод",
"category_label": "Категория", "category_label": "Категория",
"level_label": "Уровень", "level_label": "Уровень",
@@ -132,6 +133,7 @@
"add_word_btn": " Добавить слово", "add_word_btn": " Добавить слово",
"word_added": "✅ Слово '{word}' добавлено в словарь!", "word_added": "✅ Слово '{word}' добавлено в словарь!",
"word_already_exists": "Слово '{word}' уже в словаре", "word_already_exists": "Слово '{word}' уже в словаре",
"example_label": "Пример",
"cancelled": "Отменено. Можешь вернуться к заданиям командой /task.", "cancelled": "Отменено. Можешь вернуться к заданиям командой /task.",
"finish_title": "{emoji} <b>Задание завершено!</b>", "finish_title": "{emoji} <b>Задание завершено!</b>",
"correct_of": "Правильных ответов: <b>{correct}</b> из {total}", "correct_of": "Правильных ответов: <b>{correct}</b> из {total}",

View File

@@ -0,0 +1,37 @@
"""Add word_translations table for multiple translations with context
Revision ID: 20251206_word_translations
Revises: 20251205_wordsource_ai_task
Create Date: 2025-12-06
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '20251206_word_translations'
down_revision: Union[str, None] = '20251205_wordsource_ai_task'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'word_translations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('vocabulary_id', sa.Integer(), nullable=False),
sa.Column('translation', sa.String(255), nullable=False),
sa.Column('context', sa.String(500), nullable=True),
sa.Column('context_translation', sa.String(500), nullable=True),
sa.Column('is_primary', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_word_translations_vocabulary_id', 'word_translations', ['vocabulary_id'])
def downgrade() -> None:
op.drop_index('ix_word_translations_vocabulary_id', table_name='word_translations')
op.drop_table('word_translations')

View File

@@ -111,6 +111,98 @@ class AIService:
"difficulty": "A1" "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( async def translate_words_batch(
self, self,
words: List[str], words: List[str],
@@ -294,7 +386,15 @@ class AIService:
"translation": f"Мне нравится {word} каждый день." "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: Тема для подборки слов theme: Тема для подборки слов
level: Уровень сложности (A1-C2) level: Уровень сложности (A1-C2)
count: Количество слов count: Количество слов
learning_lang: Язык изучения
translation_lang: Язык перевода
exclude_words: Список слов для исключения (уже известные)
Returns: 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: Верни ответ в формате JSON:
{{ {{
"theme": "{theme}", "theme": "{theme}",
@@ -316,7 +432,8 @@ class AIService:
"word": "слово на {learning_lang}", "word": "слово на {learning_lang}",
"translation": "перевод на {translation_lang}", "translation": "перевод на {translation_lang}",
"transcription": "транскрипция в IPA (если применимо)", "transcription": "транскрипция в IPA (если применимо)",
"example": "пример использования на {learning_lang}" "example": "пример использования на {learning_lang}",
"example_translation": "перевод примера на {translation_lang}"
}} }}
] ]
}} }}
@@ -339,9 +456,21 @@ class AIService:
import json import json
result = json.loads(response_data['choices'][0]['message']['content']) result = json.loads(response_data['choices'][0]['message']['content'])
words_count = len(result.get('words', [])) words = result.get('words', [])
logger.info(f"[GPT Response] generate_thematic_words: success, generated {words_count} words")
return 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: except Exception as e:
logger.error(f"[GPT Error] generate_thematic_words: {type(e).__name__}: {str(e)}") logger.error(f"[GPT Error] generate_thematic_words: {type(e).__name__}: {str(e)}")

View File

@@ -4,7 +4,7 @@ from typing import List, Dict, Optional
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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 from services.ai_service import ai_service
@@ -107,8 +107,54 @@ class TaskService:
tasks = [] tasks = []
for word in selected_words: 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': if task_type == 'translate':
# Задание на перевод между языком обучения и языком перевода # Задание на перевод между языком обучения и языком перевода
@@ -122,21 +168,31 @@ class TaskService:
else: else:
prompt = "Переведи слово:" 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': if direction == 'learn_to_tr':
task = { task = {
'type': f'translate_to_{translation_lang}', 'type': f'translate_to_{translation_lang}',
'word_id': word.id, 'word_id': word.id,
'question': f"{prompt} <b>{word.word_original}</b>", 'question': f"{prompt} <b>{word.word_original}</b>",
'word': word.word_original, 'word': word.word_original,
'correct_answer': word.word_translation, 'correct_answer': correct_translation,
'transcription': word.transcription 'transcription': word.transcription,
# Все допустимые ответы для проверки
'all_translations': [tr.translation for tr in translations] if translations else [correct_translation]
} }
else: else:
task = { task = {
'type': f'translate_to_{learning_lang}', 'type': f'translate_to_{learning_lang}',
'word_id': word.id, 'word_id': word.id,
'question': f"{prompt} <b>{word.word_translation}</b>", 'question': f"{prompt} <b>{correct_translation}</b>",
'word': word.word_translation, 'word': correct_translation,
'correct_answer': word.word_original, 'correct_answer': word.word_original,
'transcription': word.transcription 'transcription': word.transcription
} }
@@ -285,3 +341,34 @@ class TaskService:
'correct_tasks': correct_tasks, 'correct_tasks': correct_tasks,
'accuracy': accuracy '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))

View File

@@ -1,7 +1,7 @@
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Vocabulary, WordSource, LanguageLevel from database.models import Vocabulary, WordSource, LanguageLevel, WordTranslation
from typing import List, Optional from typing import List, Optional, Dict
import re import re
@@ -176,3 +176,183 @@ class VocabularyService:
.where(Vocabulary.word_original == word.lower()) .where(Vocabulary.word_original == word.lower())
) )
return result.scalar_one_or_none() 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