feat(i18n): localize start/help/menu, practice, words, import, reminder, vocabulary, tasks/stats for RU/EN/JA; add JSON-based i18n helper\n\nfeat(lang): support learning/translation languages across AI flows; hide translations with buttons; store examples per lang\n\nfeat(vocab): add source_lang and translation_lang to Vocabulary, unique constraint (user_id, source_lang, word_original); filter /vocabulary by user.learning_language\n\nchore(migrations): add Alembic setup + migration to add vocab lang columns; env.py reads app settings and supports asyncpg URLs\n\nfix(words/import): pass learning_lang + translation_lang everywhere; fix menu themes generation\n\nfeat(settings): add learning language selector; update main menu on language change

This commit is contained in:
2025-12-04 19:40:01 +03:00
parent 6223351ccf
commit 472771229f
22 changed files with 1587 additions and 471 deletions

View File

@@ -56,27 +56,27 @@ class AIService:
response.raise_for_status()
return response.json()
async def translate_word(self, word: str, target_lang: str = "ru") -> Dict:
async def translate_word(self, word: str, source_lang: str = "en", translation_lang: str = "ru") -> Dict:
"""
Перевести слово и получить дополнительную информацию
Args:
word: Слово для перевода
target_lang: Язык перевода (по умолчанию русский)
source_lang: Язык исходного слова (ISO2)
translation_lang: Язык перевода (ISO2)
Returns:
Dict с переводом, транскрипцией и примерами
"""
prompt = f"""Переведи английское слово/фразу "{word}" на русский язык.
prompt = f"""Переведи слово/фразу "{word}" с языка {source_lang} на {translation_lang}.
Верни ответ строго в формате JSON:
{{
"word": "{word}",
"translation": "перевод",
"transcription": "транскрипция в IPA",
"word": "исходное слово на {source_lang}",
"translation": "перевод на {translation_lang}",
"transcription": "транскрипция в IPA (если применимо)",
"examples": [
{{"en": "пример на английском", "ru": "перевод примера"}},
{{"en": "ещё один пример", "ru": "перевод примера"}}
{{"{source_lang}": "пример на языке обучения", "{translation_lang}": "перевод примера"}}
],
"category": "категория слова (работа, еда, путешествия и т.д.)",
"difficulty": "уровень сложности (A1/A2/B1/B2/C1/C2)"
@@ -85,10 +85,10 @@ class AIService:
Важно: верни только JSON, без дополнительного текста."""
try:
logger.info(f"[GPT Request] translate_word: word='{word}', target_lang='{target_lang}'")
logger.info(f"[GPT Request] translate_word: word='{word}', source='{source_lang}', to='{translation_lang}'")
messages = [
{"role": "system", "content": "Ты - помощник для изучения английского языка. Отвечай только в формате JSON."},
{"role": "system", "content": "Ты - помощник для изучения языков. Отвечай только в формате JSON."},
{"role": "user", "content": prompt}
]
@@ -161,33 +161,35 @@ class AIService:
"score": 0
}
async def generate_fill_in_sentence(self, word: str) -> Dict:
async def generate_fill_in_sentence(self, word: str, learning_lang: str = "en", translation_lang: str = "ru") -> Dict:
"""
Сгенерировать предложение с пропуском для заданного слова
Args:
word: Слово, для которого нужно создать предложение
word: Слово (на языке обучения), для которого нужно создать предложение
learning_lang: Язык обучения (ISO2)
translation_lang: Язык перевода предложения (ISO2)
Returns:
Dict с предложением и правильным ответом
"""
prompt = f"""Создай предложение на английском языке, используя слово "{word}".
prompt = f"""Создай предложение на языке {learning_lang}, используя слово "{word}".
Замени это слово на пропуск "___".
Верни ответ в формате JSON:
{{
"sentence": "предложение с пропуском ___",
"answer": "{word}",
"translation": "перевод предложения на русский"
"translation": "перевод предложения на {translation_lang}"
}}
Предложение должно быть простым и естественным. Контекст должен четко подсказывать правильное слово."""
try:
logger.info(f"[GPT Request] generate_fill_in_sentence: word='{word}'")
logger.info(f"[GPT Request] generate_fill_in_sentence: word='{word}', lang='{learning_lang}', to='{translation_lang}'")
messages = [
{"role": "system", "content": "Ты - преподаватель английского языка. Создавай простые и понятные упражнения."},
{"role": "system", "content": "Ты - преподаватель иностранных языков. Создавай простые и понятные упражнения."},
{"role": "user", "content": prompt}
]
@@ -206,7 +208,7 @@ class AIService:
"translation": f"Мне нравится {word} каждый день."
}
async def generate_thematic_words(self, theme: str, level: str = "B1", count: int = 10) -> List[Dict]:
async def generate_thematic_words(self, theme: str, level: str = "B1", count: int = 10, learning_lang: str = "en", translation_lang: str = "ru") -> List[Dict]:
"""
Сгенерировать подборку слов по теме
@@ -218,17 +220,17 @@ class AIService:
Returns:
Список словарей с информацией о словах
"""
prompt = f"""Создай подборку из {count} английских слов по теме "{theme}" для уровня {level}.
prompt = f"""Создай подборку из {count} слов на языке {learning_lang} по теме "{theme}" для уровня {level}. Переводы дай на {translation_lang}.
Верни ответ в формате JSON:
{{
"theme": "{theme}",
"words": [
{{
"word": "английское слово",
"translation": "перевод на русский",
"transcription": "транскрипция в IPA",
"example": "пример использования на английском"
"word": "слово на {learning_lang}",
"translation": "перевод на {translation_lang}",
"transcription": "транскрипция в IPA (если применимо)",
"example": "пример использования на {learning_lang}"
}}
]
}}
@@ -240,10 +242,10 @@ class AIService:
- Разнообразными (существительные, глаголы, прилагательные)"""
try:
logger.info(f"[GPT Request] generate_thematic_words: theme='{theme}', level='{level}', count={count}")
logger.info(f"[GPT Request] generate_thematic_words: theme='{theme}', level='{level}', count={count}, learn='{learning_lang}', to='{translation_lang}'")
messages = [
{"role": "system", "content": "Ты - преподаватель английского языка. Подбирай полезные и актуальные слова."},
{"role": "system", "content": "Ты - преподаватель иностранных языков. Подбирай полезные и актуальные слова."},
{"role": "user", "content": prompt}
]
@@ -259,7 +261,7 @@ class AIService:
logger.error(f"[GPT Error] generate_thematic_words: {type(e).__name__}: {str(e)}")
return []
async def extract_words_from_text(self, text: str, level: str = "B1", max_words: int = 15) -> List[Dict]:
async def extract_words_from_text(self, text: str, level: str = "B1", max_words: int = 15, learning_lang: str = "en", translation_lang: str = "ru") -> List[Dict]:
"""
Извлечь ключевые слова из текста для изучения
@@ -271,7 +273,7 @@ class AIService:
Returns:
Список словарей с информацией о словах
"""
prompt = f"""Проанализируй следующий английский текст и извлеки из него до {max_words} самых полезных слов для изучения на уровне {level}.
prompt = f"""Проанализируй следующий текст на языке {learning_lang} и извлеки из него до {max_words} самых полезных слов для изучения на уровне {level}. Переводы дай на {translation_lang}.
Текст:
{text}
@@ -280,10 +282,10 @@ class AIService:
{{
"words": [
{{
"word": "английское слово (в базовой форме)",
"translation": "перевод на русский",
"transcription": "транскрипция в IPA",
"context": "предложение из текста, где используется это слово"
"word": "слово на {learning_lang} (в базовой форме)",
"translation": "перевод на {translation_lang}",
"transcription": "транскрипция в IPA (если применимо)",
"context": "предложение из текста на {learning_lang}, где используется это слово"
}}
]
}}
@@ -297,10 +299,10 @@ class AIService:
try:
text_preview = text[:100] + "..." if len(text) > 100 else text
logger.info(f"[GPT Request] extract_words_from_text: text_length={len(text)}, level='{level}', max_words={max_words}")
logger.info(f"[GPT Request] extract_words_from_text: text_length={len(text)}, level='{level}', max_words={max_words}, learn='{learning_lang}', to='{translation_lang}'")
messages = [
{"role": "system", "content": "Ты - преподаватель английского языка. Помогаешь извлекать полезные слова для изучения из текстов."},
{"role": "system", "content": "Ты - преподаватель иностранных языков. Помогаешь извлекать полезные слова для изучения из текстов."},
{"role": "user", "content": prompt}
]
@@ -316,7 +318,7 @@ class AIService:
logger.error(f"[GPT Error] extract_words_from_text: {type(e).__name__}: {str(e)}")
return []
async def start_conversation(self, scenario: str, level: str = "B1") -> Dict:
async def start_conversation(self, scenario: str, level: str = "B1", learning_lang: str = "en", translation_lang: str = "ru") -> Dict:
"""
Начать диалоговую практику с AI
@@ -338,14 +340,14 @@ class AIService:
scenario_desc = scenarios.get(scenario, "повседневный разговор")
prompt = f"""Ты - собеседник для практики английского языка уровня {level}.
Начни диалог в сценарии: {scenario_desc}.
prompt = f"""Ты - собеседник для практики языка {learning_lang} уровня {level}.
Начни диалог в сценарии: {scenario_desc} на {learning_lang}.
Верни ответ в формате JSON:
{{
"message": "твоя первая реплика на английском",
"translation": "перевод на русский",
"context": "краткое описание ситуации на русском",
"message": "твоя первая реплика на {learning_lang}",
"translation": "перевод на {translation_lang}",
"context": "краткое описание ситуации на {translation_lang}",
"suggestions": ["подсказка 1", "подсказка 2", "подсказка 3"]
}}
@@ -356,10 +358,10 @@ class AIService:
- Подсказки должны помочь пользователю ответить"""
try:
logger.info(f"[GPT Request] start_conversation: scenario='{scenario}', level='{level}'")
logger.info(f"[GPT Request] start_conversation: scenario='{scenario}', level='{level}', learn='{learning_lang}', to='{translation_lang}'")
messages = [
{"role": "system", "content": "Ты - дружелюбный собеседник для практики английского. Веди естественный диалог."},
{"role": "system", "content": "Ты - дружелюбный собеседник для практики иностранных языков. Веди естественный диалог."},
{"role": "user", "content": prompt}
]
@@ -384,7 +386,9 @@ class AIService:
conversation_history: List[Dict],
user_message: str,
scenario: str,
level: str = "B1"
level: str = "B1",
learning_lang: str = "en",
translation_lang: str = "ru"
) -> Dict:
"""
Продолжить диалог и проверить ответ пользователя
@@ -404,7 +408,7 @@ class AIService:
for msg in conversation_history[-6:] # Последние 6 сообщений
])
prompt = f"""Ты ведешь диалог на английском языке уровня {level} в сценарии "{scenario}".
prompt = f"""Ты ведешь диалог на языке {learning_lang} уровня {level} в сценарии "{scenario}".
История диалога:
{history_text}
@@ -412,8 +416,8 @@ User: {user_message}
Верни ответ в формате JSON:
{{
"response": "твой ответ на английском",
"translation": "перевод твоего ответа на русский",
"response": "твой ответ на {learning_lang}",
"translation": "перевод твоего ответа на {translation_lang}",
"feedback": {{
"has_errors": true/false,
"corrections": "исправления ошибок пользователя (если есть)",
@@ -429,11 +433,11 @@ User: {user_message}
- Используй лексику уровня {level}"""
try:
logger.info(f"[GPT Request] continue_conversation: scenario='{scenario}', level='{level}', history_length={len(conversation_history)}")
logger.info(f"[GPT Request] continue_conversation: scenario='{scenario}', level='{level}', history_length={len(conversation_history)}, learn='{learning_lang}', to='{translation_lang}'")
# Формируем сообщения для API
messages = [
{"role": "system", "content": f"Ты - дружелюбный собеседник для практики английского языка уровня {level}. Веди естественный диалог и помогай исправлять ошибки."}
{"role": "system", "content": f"Ты - дружелюбный собеседник для практики языка {learning_lang} уровня {level}. Веди естественный диалог и помогай исправлять ошибки."}
]
# Добавляем историю

View File

@@ -75,7 +75,9 @@ class TaskService:
async def generate_mixed_tasks(
session: AsyncSession,
user_id: int,
count: int = 5
count: int = 5,
learning_lang: str = 'en',
translation_lang: str = 'ru'
) -> List[Dict]:
"""
Генерация заданий разных типов (переводы + заполнение пропусков)
@@ -109,23 +111,31 @@ class TaskService:
task_type = random.choice(['translate', 'fill_in'])
if task_type == 'translate':
# Задание на перевод
direction = random.choice(['en_to_ru', 'ru_to_en'])
# Задание на перевод между языком обучения и языком перевода
direction = random.choice(['learn_to_tr', 'tr_to_learn'])
if direction == 'en_to_ru':
# Локализация фразы "Переведи слово"
if translation_lang == 'en':
prompt = "Translate the word:"
elif translation_lang == 'ja':
prompt = "単語を訳してください:"
else:
prompt = "Переведи слово:"
if direction == 'learn_to_tr':
task = {
'type': 'translate_to_ru',
'type': f'translate_to_{translation_lang}',
'word_id': word.id,
'question': f"Переведи слово: <b>{word.word_original}</b>",
'question': f"{prompt} <b>{word.word_original}</b>",
'word': word.word_original,
'correct_answer': word.word_translation,
'transcription': word.transcription
}
else:
task = {
'type': 'translate_to_en',
'type': f'translate_to_{learning_lang}',
'word_id': word.id,
'question': f"Переведи слово: <b>{word.word_translation}</b>",
'question': f"{prompt} <b>{word.word_translation}</b>",
'word': word.word_translation,
'correct_answer': word.word_original,
'transcription': word.transcription
@@ -133,13 +143,25 @@ class TaskService:
else:
# Задание на заполнение пропуска
# Генерируем предложение с пропуском через AI
sentence_data = await ai_service.generate_fill_in_sentence(word.word_original)
sentence_data = await ai_service.generate_fill_in_sentence(
word.word_original,
learning_lang=learning_lang,
translation_lang=translation_lang
)
# Локализация заголовка
if translation_lang == 'en':
fill_title = "Fill in the blank in the sentence:"
elif translation_lang == 'ja':
fill_title = "文の空欄を埋めてください:"
else:
fill_title = "Заполни пропуск в предложении:"
task = {
'type': 'fill_in',
'word_id': word.id,
'question': (
f"Заполни пропуск в предложении:\n\n"
f"{fill_title}\n\n"
f"<b>{sentence_data['sentence']}</b>\n\n"
f"<i>{sentence_data.get('translation', '')}</i>"
),

View File

@@ -95,3 +95,22 @@ class UserService:
if user:
user.language_interface = language
await session.commit()
@staticmethod
async def update_user_learning_language(session: AsyncSession, user_id: int, language: str):
"""
Обновить язык изучения пользователя
Args:
session: Сессия базы данных
user_id: ID пользователя
language: Новый язык изучения (ISO2)
"""
result = await session.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if user:
user.learning_language = language
await session.commit()

View File

@@ -2,6 +2,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Vocabulary, WordSource, LanguageLevel
from typing import List, Optional
import re
class VocabularyService:
@@ -13,6 +14,8 @@ class VocabularyService:
user_id: int,
word_original: str,
word_translation: str,
source_lang: Optional[str] = None,
translation_lang: Optional[str] = None,
transcription: Optional[str] = None,
examples: Optional[dict] = None,
category: Optional[str] = None,
@@ -50,6 +53,8 @@ class VocabularyService:
user_id=user_id,
word_original=word_original,
word_translation=word_translation,
source_lang=source_lang,
translation_lang=translation_lang,
transcription=transcription,
examples=examples,
category=category,
@@ -65,7 +70,27 @@ class VocabularyService:
return new_word
@staticmethod
async def get_user_words(session: AsyncSession, user_id: int, limit: int = 50) -> List[Vocabulary]:
@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
async def get_user_words(session: AsyncSession, user_id: int, limit: int = 50, learning_lang: Optional[str] = None) -> List[Vocabulary]:
"""
Получить все слова пользователя
@@ -81,12 +106,13 @@ class VocabularyService:
select(Vocabulary)
.where(Vocabulary.user_id == user_id)
.order_by(Vocabulary.created_at.desc())
.limit(limit)
)
return list(result.scalars().all())
words = list(result.scalars().all())
words = VocabularyService._filter_by_learning_lang(words, learning_lang)
return words[:limit]
@staticmethod
async def get_words_count(session: AsyncSession, user_id: int) -> int:
async def get_words_count(session: AsyncSession, user_id: int, learning_lang: Optional[str] = None) -> int:
"""
Получить количество слов в словаре пользователя
@@ -100,7 +126,9 @@ class VocabularyService:
result = await session.execute(
select(Vocabulary).where(Vocabulary.user_id == user_id)
)
return len(list(result.scalars().all()))
words = list(result.scalars().all())
words = VocabularyService._filter_by_learning_lang(words, learning_lang)
return len(words)
@staticmethod
async def find_word(session: AsyncSession, user_id: int, word: str) -> Optional[Vocabulary]: