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