feat: мини-игры, premium подписка, улучшенные контексты

Мини-игры (/games):
- Speed Round: 10 раундов, 10 секунд на ответ, очки за скорость
- Match Pairs: 5 слов + 5 переводов, соединить пары

Premium-функции:
- Поля is_premium и premium_until для пользователей
- AI режим проверки ответов (учитывает синонимы)
- Batch проверка всех ответов одним запросом

Улучшения:
- Примеры использования для всех добавляемых слов
- Разбиение переводов по запятой на отдельные записи
- Полные предложения в контекстах (без ___)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-10 19:42:10 +03:00
parent b74ea2170c
commit adc8a6bf8e
18 changed files with 1819 additions and 34 deletions

View File

@@ -398,6 +398,8 @@ class AIService:
"word": "исходное слово",
"translation": "перевод",
"transcription": "транскрипция (IPA или ромадзи для японского)",{furigana_instruction}
"example": "короткий пример использования на {source_lang}",
"example_translation": "перевод примера на {translation_lang}"
}},
...
]
@@ -405,7 +407,7 @@ class AIService:
Важно:
- Верни только JSON массив, без дополнительного текста
- Сохрани порядок слов как в исходном списке
- Для каждого слова укажи точный перевод и транскрипцию"""
- Для каждого слова укажи точный перевод, транскрипцию и короткий пример"""
try:
logger.info(f"[AI Request] translate_words_batch: {len(words)} words, {source_lang} -> {translation_lang}")
@@ -498,6 +500,156 @@ class AIService:
"score": 0
}
async def check_translation(
self,
word: str,
correct_translation: str,
user_answer: str,
source_lang: str = "en",
target_lang: str = "ru",
user_id: Optional[int] = None
) -> Dict:
"""
Проверить перевод слова с помощью ИИ (для мини-игр)
Args:
word: Оригинальное слово
correct_translation: Эталонный перевод
user_answer: Ответ пользователя
source_lang: Язык оригинального слова
target_lang: Язык перевода
user_id: ID пользователя в БД
Returns:
Dict с результатом проверки
"""
prompt = f"""Проверь перевод слова.
Слово ({source_lang}): {word}
Эталонный перевод ({target_lang}): {correct_translation}
Ответ пользователя: {user_answer}
Определи, правильный ли перевод пользователя. Учитывай:
- Синонимы и близкие по смыслу слова
- Разные формы слова (единственное/множественное число)
- Небольшие опечатки
Верни JSON:
{{
"is_correct": true/false,
"feedback": "краткое пояснение (почему верно/неверно, какой вариант лучше)"
}}"""
try:
logger.info(f"[AI Request] check_translation: word='{word}', user='{user_answer}'")
messages = [
{"role": "system", "content": "Ты - лингвист, проверяющий переводы. Будь справедлив и учитывай синонимы."},
{"role": "user", "content": prompt}
]
response_data = await self._make_request(messages, temperature=0.2, user_id=user_id)
result = json.loads(response_data['choices'][0]['message']['content'])
logger.info(f"[AI Response] check_translation: is_correct={result.get('is_correct', False)}")
return result
except Exception as e:
logger.error(f"[AI Error] check_translation: {type(e).__name__}: {str(e)}")
# В случае ошибки делаем простое сравнение
is_correct = user_answer.lower().strip() == correct_translation.lower().strip()
return {
"is_correct": is_correct,
"feedback": ""
}
async def check_translations_batch(
self,
answers: List[Dict],
source_lang: str = "en",
target_lang: str = "ru",
user_id: Optional[int] = None
) -> List[Dict]:
"""
Проверить несколько переводов одним запросом (для мини-игр)
Args:
answers: Список словарей с ключами: word, correct_translation, user_answer
source_lang: Язык оригинальных слов
target_lang: Язык перевода
user_id: ID пользователя в БД
Returns:
Список словарей с результатами проверки
"""
if not answers:
return []
# Формируем список для проверки
answers_text = ""
for i, ans in enumerate(answers, 1):
answers_text += f"{i}. {ans['word']} → эталон: {ans['correct_translation']} | ответ: {ans['user_answer']}\n"
prompt = f"""Проверь переводы слов с {source_lang} на {target_lang}.
{answers_text}
Для каждого слова определи, правильный ли перевод. Учитывай:
- Синонимы и близкие по смыслу слова
- Разные формы слова
- Небольшие опечатки
Верни JSON массив:
[
{{"index": 1, "is_correct": true/false, "feedback": "краткое пояснение", "user_answer_meaning": "что означает ответ пользователя на {source_lang}, если это валидное слово"}},
...
]
user_answer_meaning - переведи ответ пользователя обратно на {source_lang}, чтобы показать что он на самом деле написал. Если ответ бессмысленный - оставь пустым."""
try:
logger.info(f"[AI Request] check_translations_batch: {len(answers)} answers")
messages = [
{"role": "system", "content": "Ты - лингвист, проверяющий переводы. Будь справедлив и учитывай синонимы. Отвечай только JSON массивом."},
{"role": "user", "content": prompt}
]
response_data = await self._make_request(messages, temperature=0.2, user_id=user_id)
result = json.loads(response_data['choices'][0]['message']['content'])
logger.info(f"[AI Response] check_translations_batch: {len(result)} results")
# Преобразуем в удобный формат
results_map = {r['index']: r for r in result}
final_results = []
for i, ans in enumerate(answers, 1):
if i in results_map:
final_results.append({
'is_correct': results_map[i].get('is_correct', False),
'feedback': results_map[i].get('feedback', ''),
'user_answer_meaning': results_map[i].get('user_answer_meaning', '')
})
else:
# Fallback на простое сравнение
is_correct = ans['user_answer'].lower().strip() == ans['correct_translation'].lower().strip()
final_results.append({'is_correct': is_correct, 'feedback': '', 'user_answer_meaning': ''})
return final_results
except Exception as e:
logger.error(f"[AI Error] check_translations_batch: {type(e).__name__}: {str(e)}")
# В случае ошибки делаем простое сравнение для всех
return [
{
'is_correct': ans['user_answer'].lower().strip() == ans['correct_translation'].lower().strip(),
'feedback': '',
'user_answer_meaning': ''
}
for ans in answers
]
async def generate_fill_in_sentence(self, word: str, learning_lang: str = "en", translation_lang: str = "ru", user_id: Optional[int] = None) -> Dict:
"""
Сгенерировать предложение с пропуском для заданного слова
@@ -649,15 +801,17 @@ class AIService:
"results": [
{{
"sentence": "предложение (с ___ для fill_blank)",
"full_sentence": "полное предложение БЕЗ пропуска",
"answer": "слово для пропуска (только для fill_blank)",
"translation": "перевод на {translation_lang}"
"translation": "ПОЛНЫЙ перевод предложения на {translation_lang} (БЕЗ пропусков, БЕЗ слов на {learning_lang})"
}}
]
}}
Важно:
- Для fill_blank: замени целевое слово на ___, укажи answer
- Для fill_blank: замени целевое слово на ___, укажи answer и full_sentence
- Для sentence_translate: просто предложение со словом, answer не нужен
- translation должен быть ПОЛНЫМ переводом на {translation_lang}, без ___ и без слов на {learning_lang}
- Предложения должны быть простыми (5-10 слов)
- Контекст должен подсказывать правильное слово{furigana_instruction}
- Верни результаты В ТОМ ЖЕ ПОРЯДКЕ что и задания"""
@@ -842,7 +996,8 @@ class AIService:
"word": "слово на {learning_lang} (в базовой форме)",
"translation": "перевод на {translation_lang}",
"transcription": "транскрипция в IPA (для английского) или хирагана (для японского)",
"context": "предложение из текста на {learning_lang}, где используется это слово"
"example": "предложение из текста на {learning_lang}, где используется это слово",
"example_translation": "перевод этого предложения на {translation_lang}"
}}
]
}}

View File

@@ -1,8 +1,11 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Vocabulary, WordSource, LanguageLevel, WordTranslation
from typing import List, Optional, Dict
import random
import re
from typing import List, Optional, Dict
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Vocabulary, WordSource, LanguageLevel, WordTranslation
class VocabularyService:
@@ -263,6 +266,61 @@ class VocabularyService:
return new_translation
@staticmethod
async def add_translation_split(
session: AsyncSession,
vocabulary_id: int,
translation: str,
context: Optional[str] = None,
context_translation: Optional[str] = None,
is_primary: bool = True
) -> List[WordTranslation]:
"""
Добавить перевод(ы) к слову, разбивая строку по разделителям.
Если translation содержит несколько переводов через запятую или точку с запятой,
каждый перевод добавляется отдельной записью.
Args:
session: Сессия базы данных
vocabulary_id: ID слова в словаре
translation: Перевод (может содержать несколько через запятую)
context: Пример использования на языке изучения
context_translation: Перевод примера
is_primary: Является ли первый перевод основным
Returns:
Список созданных переводов
"""
import re
# Разбиваем по запятой или точке с запятой
parts = re.split(r'[,;]\s*', translation)
# Очищаем и фильтруем пустые
translations = [p.strip() for p in parts if p.strip()]
if not translations:
return []
created = []
for i, tr in enumerate(translations):
new_translation = WordTranslation(
vocabulary_id=vocabulary_id,
translation=tr,
context=context if i == 0 else None, # Контекст только для первого перевода
context_translation=context_translation if i == 0 else None,
is_primary=(is_primary and i == 0) # Только первый - основной
)
session.add(new_translation)
created.append(new_translation)
await session.commit()
for t in created:
await session.refresh(t)
return created
@staticmethod
async def add_translations_bulk(
session: AsyncSession,
@@ -368,3 +426,103 @@ class VocabularyService:
await session.commit()
return True
return False
@staticmethod
async def get_user_word_count(session: AsyncSession, user_id: int) -> int:
"""
Получить количество слов пользователя
Args:
session: Сессия базы данных
user_id: ID пользователя
Returns:
Количество слов
"""
result = await session.execute(
select(func.count(Vocabulary.id)).where(Vocabulary.user_id == user_id)
)
return result.scalar() or 0
@staticmethod
async def get_random_words_for_game(
session: AsyncSession,
user_id: int,
count: int = 10,
learning_lang: Optional[str] = None
) -> List[Vocabulary]:
"""
Получить случайные слова для мини-игры
Args:
session: Сессия базы данных
user_id: ID пользователя
count: Количество слов
learning_lang: Язык изучения для фильтрации
Returns:
Список случайных слов
"""
result = await session.execute(
select(Vocabulary).where(Vocabulary.user_id == user_id)
)
words = list(result.scalars().all())
if learning_lang:
words = VocabularyService._filter_by_learning_lang(words, learning_lang)
# Перемешиваем и берём нужное количество
random.shuffle(words)
return words[:count]
@staticmethod
async def get_random_words_with_translations(
session: AsyncSession,
user_id: int,
count: int = 10,
learning_lang: Optional[str] = None
) -> List[dict]:
"""
Получить случайные слова для мини-игры вместе со всеми переводами
Args:
session: Сессия базы данных
user_id: ID пользователя
count: Количество слов
learning_lang: Язык изучения для фильтрации
Returns:
Список словарей с информацией о словах и их переводах
"""
# Получаем слова
words = await VocabularyService.get_random_words_for_game(
session, user_id, count, learning_lang
)
result = []
for word in words:
# Получаем все переводы для слова
translations = await VocabularyService.get_word_translations(session, word.id)
# Собираем все варианты перевода
all_translations = []
# Основной перевод из vocabulary
if word.word_translation:
all_translations.append(word.word_translation.lower().strip())
# Переводы из word_translations
for tr in translations:
tr_text = tr.translation.lower().strip()
if tr_text not in all_translations:
all_translations.append(tr_text)
result.append({
'id': word.id,
'word': word.word_original,
'translation': word.word_translation, # Основной перевод для отображения
'all_translations': all_translations, # Все варианты для проверки
'transcription': word.transcription
})
return result