2025-12-04 11:09:54 +03:00
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2025-12-06 21:29:41 +03:00
|
|
|
|
from database.models import Vocabulary, WordSource, LanguageLevel, WordTranslation
|
|
|
|
|
|
from typing import List, Optional, Dict
|
2025-12-04 19:40:01 +03:00
|
|
|
|
import re
|
2025-12-04 11:09:54 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class VocabularyService:
|
|
|
|
|
|
"""Сервис для работы со словарным запасом"""
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
async def add_word(
|
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
|
user_id: int,
|
|
|
|
|
|
word_original: str,
|
|
|
|
|
|
word_translation: str,
|
2025-12-04 19:40:01 +03:00
|
|
|
|
source_lang: Optional[str] = None,
|
|
|
|
|
|
translation_lang: Optional[str] = None,
|
2025-12-04 11:09:54 +03:00
|
|
|
|
transcription: Optional[str] = None,
|
|
|
|
|
|
examples: Optional[dict] = None,
|
|
|
|
|
|
category: Optional[str] = None,
|
|
|
|
|
|
difficulty_level: Optional[str] = None,
|
|
|
|
|
|
source: WordSource = WordSource.MANUAL,
|
|
|
|
|
|
notes: Optional[str] = None
|
|
|
|
|
|
) -> Vocabulary:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Добавить слово в словарь пользователя
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
session: Сессия базы данных
|
|
|
|
|
|
user_id: ID пользователя
|
|
|
|
|
|
word_original: Оригинальное слово
|
|
|
|
|
|
word_translation: Перевод
|
|
|
|
|
|
transcription: Транскрипция
|
|
|
|
|
|
examples: Примеры использования
|
|
|
|
|
|
category: Категория слова
|
|
|
|
|
|
difficulty_level: Уровень сложности
|
|
|
|
|
|
source: Источник добавления
|
|
|
|
|
|
notes: Заметки пользователя
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Созданный объект слова
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Преобразование difficulty_level в enum
|
|
|
|
|
|
difficulty_enum = None
|
|
|
|
|
|
if difficulty_level:
|
|
|
|
|
|
try:
|
|
|
|
|
|
difficulty_enum = LanguageLevel[difficulty_level]
|
|
|
|
|
|
except KeyError:
|
|
|
|
|
|
difficulty_enum = None
|
|
|
|
|
|
|
|
|
|
|
|
new_word = Vocabulary(
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
word_original=word_original,
|
|
|
|
|
|
word_translation=word_translation,
|
2025-12-04 19:40:01 +03:00
|
|
|
|
source_lang=source_lang,
|
|
|
|
|
|
translation_lang=translation_lang,
|
2025-12-04 11:09:54 +03:00
|
|
|
|
transcription=transcription,
|
|
|
|
|
|
examples=examples,
|
|
|
|
|
|
category=category,
|
|
|
|
|
|
difficulty_level=difficulty_enum,
|
|
|
|
|
|
source=source,
|
|
|
|
|
|
notes=notes
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
session.add(new_word)
|
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
await session.refresh(new_word)
|
|
|
|
|
|
|
|
|
|
|
|
return new_word
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2025-12-04 19:40:01 +03:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _is_japanese(text: str) -> bool:
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return re.search(r"[\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF]", text) is not None
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _filter_by_learning_lang(words: List[Vocabulary], learning_lang: Optional[str]) -> List[Vocabulary]:
|
|
|
|
|
|
if not learning_lang:
|
|
|
|
|
|
return words
|
|
|
|
|
|
# Если в БД указан source_lang – фильтруем по нему.
|
|
|
|
|
|
with_lang = [w for w in words if getattr(w, 'source_lang', None)]
|
|
|
|
|
|
if with_lang:
|
|
|
|
|
|
return [w for w in words if (w.source_lang or '').lower() == learning_lang.lower()]
|
|
|
|
|
|
# Фолбэк-эвристика для японского, если язык не сохранён
|
|
|
|
|
|
if learning_lang.lower() == 'ja':
|
|
|
|
|
|
return [w for w in words if VocabularyService._is_japanese(w.word_original)]
|
|
|
|
|
|
return [w for w in words if not VocabularyService._is_japanese(w.word_original)]
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2025-12-05 20:15:47 +03:00
|
|
|
|
async def get_user_words(
|
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
|
user_id: int,
|
|
|
|
|
|
limit: int = 50,
|
|
|
|
|
|
offset: int = 0,
|
|
|
|
|
|
learning_lang: Optional[str] = None
|
|
|
|
|
|
) -> List[Vocabulary]:
|
2025-12-04 11:09:54 +03:00
|
|
|
|
"""
|
2025-12-05 20:15:47 +03:00
|
|
|
|
Получить слова пользователя с пагинацией
|
2025-12-04 11:09:54 +03:00
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
session: Сессия базы данных
|
|
|
|
|
|
user_id: ID пользователя
|
|
|
|
|
|
limit: Максимальное количество слов
|
2025-12-05 20:15:47 +03:00
|
|
|
|
offset: Смещение для пагинации
|
2025-12-04 11:09:54 +03:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Список слов пользователя
|
|
|
|
|
|
"""
|
|
|
|
|
|
result = await session.execute(
|
|
|
|
|
|
select(Vocabulary)
|
|
|
|
|
|
.where(Vocabulary.user_id == user_id)
|
|
|
|
|
|
.order_by(Vocabulary.created_at.desc())
|
|
|
|
|
|
)
|
2025-12-04 19:40:01 +03:00
|
|
|
|
words = list(result.scalars().all())
|
|
|
|
|
|
words = VocabularyService._filter_by_learning_lang(words, learning_lang)
|
2025-12-05 20:15:47 +03:00
|
|
|
|
return words[offset:offset + limit]
|
2025-12-04 11:09:54 +03:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2025-12-04 19:40:01 +03:00
|
|
|
|
async def get_words_count(session: AsyncSession, user_id: int, learning_lang: Optional[str] = None) -> int:
|
2025-12-04 11:09:54 +03:00
|
|
|
|
"""
|
|
|
|
|
|
Получить количество слов в словаре пользователя
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
session: Сессия базы данных
|
|
|
|
|
|
user_id: ID пользователя
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Количество слов
|
|
|
|
|
|
"""
|
|
|
|
|
|
result = await session.execute(
|
|
|
|
|
|
select(Vocabulary).where(Vocabulary.user_id == user_id)
|
|
|
|
|
|
)
|
2025-12-04 19:40:01 +03:00
|
|
|
|
words = list(result.scalars().all())
|
|
|
|
|
|
words = VocabularyService._filter_by_learning_lang(words, learning_lang)
|
|
|
|
|
|
return len(words)
|
2025-12-04 11:09:54 +03:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
async def find_word(session: AsyncSession, user_id: int, word: str) -> Optional[Vocabulary]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Найти слово в словаре пользователя
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
session: Сессия базы данных
|
|
|
|
|
|
user_id: ID пользователя
|
|
|
|
|
|
word: Слово для поиска
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Объект слова или None
|
|
|
|
|
|
"""
|
|
|
|
|
|
result = await session.execute(
|
|
|
|
|
|
select(Vocabulary)
|
|
|
|
|
|
.where(Vocabulary.user_id == user_id)
|
|
|
|
|
|
.where(Vocabulary.word_original.ilike(f"%{word}%"))
|
|
|
|
|
|
)
|
|
|
|
|
|
return result.scalar_one_or_none()
|
Добавлены основные функции MVP: тематические подборки, импорт слов, диалоговая практика, напоминания и тест уровня
Новые команды:
- /words [тема] - AI-генерация тематических подборок слов (10 слов по теме с учётом уровня)
- /import - извлечение до 15 ключевых слов из текста (книги, статьи, песни)
- /practice - диалоговая практика с AI в 6 сценариях (ресторан, магазин, путешествие, работа, врач, общение)
- /reminder - настройка ежедневных напоминаний по расписанию
- /level_test - тест из 7 вопросов для определения уровня английского (A1-C2)
Основные изменения:
- AI сервис: добавлены методы generate_thematic_words, extract_words_from_text, start_conversation, continue_conversation, generate_level_test
- Диалоговая практика: исправление ошибок в реальном времени, подсказки, перевод реплик
- Напоминания: APScheduler для ежедневной отправки напоминаний в выбранное время
- Тест уровня: автоматическое определение уровня при регистрации, можно пропустить
- База данных: добавлены поля reminders_enabled, last_reminder_sent
- Vocabulary service: метод get_word_by_original для проверки дубликатов
- Зависимости: apscheduler==3.10.4
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 15:46:02 +03:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
async def get_word_by_original(session: AsyncSession, user_id: int, word: str) -> Optional[Vocabulary]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Получить слово по точному совпадению
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
session: Сессия базы данных
|
|
|
|
|
|
user_id: ID пользователя
|
|
|
|
|
|
word: Слово для поиска (точное совпадение)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Объект слова или None
|
|
|
|
|
|
"""
|
|
|
|
|
|
result = await session.execute(
|
|
|
|
|
|
select(Vocabulary)
|
|
|
|
|
|
.where(Vocabulary.user_id == user_id)
|
|
|
|
|
|
.where(Vocabulary.word_original == word.lower())
|
|
|
|
|
|
)
|
|
|
|
|
|
return result.scalar_one_or_none()
|
2025-12-06 21:29:41 +03:00
|
|
|
|
|
|
|
|
|
|
@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
|