diff --git a/bot/handlers/tasks.py b/bot/handlers/tasks.py index d85a283..c124112 100644 --- a/bot/handlers/tasks.py +++ b/bot/handlers/tasks.py @@ -109,6 +109,18 @@ async def start_new_words_tasks(callback: CallbackQuery, state: FSMContext): # Показываем индикатор загрузки 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 words = await ai_service.generate_thematic_words( theme="random everyday vocabulary", @@ -116,6 +128,7 @@ async def start_new_words_tasks(callback: CallbackQuery, state: FSMContext): count=5, learning_lang=user.learning_language, translation_lang=user.language_interface, + exclude_words=exclude_words if exclude_words else None, ) if not words: @@ -132,7 +145,9 @@ async def start_new_words_tasks(callback: CallbackQuery, state: FSMContext): 'question': f"{translate_prompt}: {word.get('word', '')}", 'word': word.get('word', ''), '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( @@ -226,6 +241,16 @@ async def process_answer(message: Message, state: FSMContext): if feedback: 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"{example}\n" + if example_translation: + result_text += f"({example_translation})\n" + result_text += "\n" + # Сохраняем результат в БД async with async_session_maker() as session: await TaskService.save_task_result( @@ -298,6 +323,8 @@ async def add_task_word(callback: CallbackQuery, state: FSMContext): word = task.get('word', '') translation = task.get('correct_answer', '') transcription = task.get('transcription', '') + example = task.get('example', '') # Пример использования как контекст + example_translation = task.get('example_translation', '') # Перевод примера async with async_session_maker() as session: 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 # Добавляем слово в словарь - await VocabularyService.add_word( + new_word = await VocabularyService.add_word( session=session, user_id=user.id, word_original=word, @@ -327,6 +354,18 @@ async def add_task_word(callback: CallbackQuery, state: FSMContext): 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) diff --git a/bot/handlers/vocabulary.py b/bot/handlers/vocabulary.py index fd57762..e4ff2c7 100644 --- a/bot/handlers/vocabulary.py +++ b/bot/handlers/vocabulary.py @@ -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' processing_msg = await message.answer(t(lang, 'add.searching')) - # Получаем перевод через AI + # Получаем перевод через AI (с несколькими значениями) async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, message.from_user.id) source_lang = user.learning_language if user else 'en' 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() - # Формируем примеры - examples_text = "" - if word_data.get("examples"): - examples_text = "\n\n" + t(lang, 'add.examples_header') + "\n" - for idx, example in enumerate(word_data["examples"][:2], 1): - src = example.get(source_lang) or example.get('en') or example.get('ru') or '' - tr = example.get(ui_lang) or example.get('ru') or example.get('en') or '' - examples_text += f"{idx}. {src}\n {tr}\n" + # Формируем текст с переводами + translations = word_data.get("translations", []) + translations_text = "" + + if translations: + # Основной перевод для backward compatibility + primary = next((tr for tr in translations if tr.get('is_primary')), translations[0]) + 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}{tr.get('translation', '')}\n" + if tr.get('context'): + translations_text += f" «{tr.get('context', '')}»\n" + if tr.get('context_translation'): + translations_text += f" ({tr.get('context_translation', '')})\n" + translations_text += "\n" + else: + # Fallback если нет переводов + word_data['translation'] = 'Ошибка перевода' # Отправляем карточку слова card_text = ( f"📝 {word_data['word']}\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.level_label')}: {word_data.get('difficulty', 'A1')}" - f"{examples_text}\n\n" + f"{translations_text}" 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' # Добавляем слово в базу - await VocabularyService.add_word( + new_word = await VocabularyService.add_word( session, user_id=user_id, word_original=word_data["word"], @@ -138,12 +152,20 @@ async def confirm_add_word(callback: CallbackQuery, state: FSMContext): source_lang=source_lang, translation_lang=ui_lang, transcription=word_data.get("transcription"), - examples={"examples": word_data.get("examples", [])}, category=word_data.get("category"), difficulty_level=word_data.get("difficulty"), 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) lang = ui_lang or 'ru' diff --git a/database/models.py b/database/models.py index e231986..bd69cba 100644 --- a/database/models.py +++ b/database/models.py @@ -93,6 +93,19 @@ class Vocabulary(Base): 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): """Модель задания""" __tablename__ = "tasks" diff --git a/locales/en.json b/locales/en.json index caa8d1f..e379810 100644 --- a/locales/en.json +++ b/locales/en.json @@ -58,6 +58,7 @@ "prompt": "Send the word you want to add:\nFor example: /add elephant\n\nOr just send the word without a command!", "searching": "⏳ Looking up translation and examples...", "examples_header": "Examples:", + "translations_header": "Translations:", "translation_label": "Translation", "category_label": "Category", "level_label": "Level", @@ -132,6 +133,7 @@ "add_word_btn": "➕ Add word", "word_added": "✅ Word '{word}' added to vocabulary!", "word_already_exists": "Word '{word}' is already in vocabulary", + "example_label": "Example", "cancelled": "Cancelled. You can return to tasks with /task.", "finish_title": "{emoji} Task finished!", "correct_of": "Correct answers: {correct} of {total}", diff --git a/locales/ja.json b/locales/ja.json index 72eddc4..020fdb2 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -58,6 +58,7 @@ "prompt": "追加したい単語を送ってください:\n例: /add elephant\n\nコマンドなしで単語だけ送ってもOKです!", "searching": "⏳ 翻訳と例を検索中...", "examples_header": "例文:", + "translations_header": "翻訳:", "translation_label": "翻訳", "category_label": "カテゴリー", "level_label": "レベル", @@ -124,6 +125,7 @@ "add_word_btn": "➕ 単語を追加", "word_added": "✅ 単語 '{word}' を単語帳に追加しました!", "word_already_exists": "単語 '{word}' はすでに単語帳にあります", + "example_label": "例文", "cancelled": "キャンセルしました。/task で課題に戻れます。", "finish_title": "{emoji} 課題が終了しました!", "correct_of": "正解数: {correct} / {total}", diff --git a/locales/ru.json b/locales/ru.json index 7a9c749..fde3b04 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -58,6 +58,7 @@ "prompt": "Отправь слово, которое хочешь добавить:\nНапример: /add elephant\n\nИли просто отправь слово без команды!", "searching": "⏳ Ищу перевод и примеры...", "examples_header": "Примеры:", + "translations_header": "Переводы:", "translation_label": "Перевод", "category_label": "Категория", "level_label": "Уровень", @@ -132,6 +133,7 @@ "add_word_btn": "➕ Добавить слово", "word_added": "✅ Слово '{word}' добавлено в словарь!", "word_already_exists": "Слово '{word}' уже в словаре", + "example_label": "Пример", "cancelled": "Отменено. Можешь вернуться к заданиям командой /task.", "finish_title": "{emoji} Задание завершено!", "correct_of": "Правильных ответов: {correct} из {total}", diff --git a/migrations/versions/20251206_add_word_translations.py b/migrations/versions/20251206_add_word_translations.py new file mode 100644 index 0000000..468a636 --- /dev/null +++ b/migrations/versions/20251206_add_word_translations.py @@ -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') diff --git a/services/ai_service.py b/services/ai_service.py index c9602ad..bab7307 100644 --- a/services/ai_service.py +++ b/services/ai_service.py @@ -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)}") diff --git a/services/task_service.py b/services/task_service.py index 8d262d7..70cd646 100644 --- a/services/task_service.py +++ b/services/task_service.py @@ -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"«{selected_tr.context}»\n\n" + f"{word.word_original} = ?" + ), + '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} {word.word_original}", '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} {word.word_translation}", - 'word': word.word_translation, + 'question': f"{prompt} {correct_translation}", + '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)) diff --git a/services/vocabulary_service.py b/services/vocabulary_service.py index a77f317..9c88bf1 100644 --- a/services/vocabulary_service.py +++ b/services/vocabulary_service.py @@ -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