feat: мини-истории, слово дня, меню практики

- Добавлены мини-истории для чтения с выбором жанра и вопросами
- Кнопка показа/скрытия перевода истории
- Количество вопросов берётся из настроек пользователя
- Слово дня генерируется глобально в 00:00 UTC
- Кнопка "Практика" открывает меню выбора режима
- Убран автоматический create_all при запуске (только миграции)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-09 15:05:38 +03:00
parent 69c651c031
commit f38ff2f18e
22 changed files with 3131 additions and 77 deletions

View File

@@ -54,6 +54,40 @@ class AIService:
self._cached_model: Optional[str] = None
self._cached_provider: Optional[AIProvider] = None
def _markdown_to_html(self, text: str) -> str:
"""Конвертировать markdown форматирование в HTML для Telegram."""
import re
# **bold** -> <b>bold</b>
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
# *italic* -> <i>italic</i> (но не внутри уже конвертированных тегов)
text = re.sub(r'(?<!</[bi]>)\*([^*]+?)\*(?![^<]*</)', r'<i>\1</i>', text)
# Убираем оставшиеся одиночные * в начале строк (списки)
text = re.sub(r'^\*\s+', '', text, flags=re.MULTILINE)
return text
def _strip_markdown_code_block(self, text: str) -> str:
"""Удалить markdown обёртку ```json ... ``` из текста."""
import re
text = text.strip()
# Паттерн для ```json ... ``` или просто ``` ... ```
pattern = r'^```(?:json)?\s*\n?(.*?)\n?```$'
match = re.match(pattern, text, re.DOTALL)
if match:
return match.group(1).strip()
# Альтернативный способ - если начинается с ``` но паттерн не сработал
if text.startswith('```'):
lines = text.split('\n')
# Убираем первую строку (```json или ```)
lines = lines[1:]
# Убираем последнюю строку если это ```
if lines and lines[-1].strip() == '```':
lines = lines[:-1]
return '\n'.join(lines).strip()
return text
async def _get_active_model(self, user_id: Optional[int] = None) -> tuple[str, AIProvider]:
"""
Получить активную модель и провайдера из БД.
@@ -136,15 +170,8 @@ class AIService:
# Конвертируем ответ Google в формат OpenAI для совместимости
text = data["candidates"][0]["content"]["parts"][0]["text"]
# Убираем markdown обёртку если есть (```json ... ```)
if text.startswith('```'):
lines = text.split('\n')
# Убираем первую строку (```json) и последнюю (```)
if lines[-1].strip() == '```':
lines = lines[1:-1]
else:
lines = lines[1:]
text = '\n'.join(lines)
# Убираем markdown обёртку если есть (```json ... ``` или ```...```)
text = self._strip_markdown_code_block(text)
return {
"choices": [{
@@ -1080,6 +1107,215 @@ User: {user_message}
return self._get_jlpt_fallback_questions()
return self._get_cefr_fallback_questions()
async def generate_grammar_rule(
self,
topic_name: str,
topic_description: str,
level: str,
learning_lang: str = "en",
ui_lang: str = "ru",
user_id: Optional[int] = None
) -> str:
"""
Генерация объяснения грамматического правила.
Args:
topic_name: Название темы (например, "Present Simple")
topic_description: Описание темы (например, "I work, he works")
level: Уровень пользователя (A1-C2 или N5-N1)
learning_lang: Язык изучения
ui_lang: Язык интерфейса для объяснения
user_id: ID пользователя в БД
Returns:
Текст с объяснением правила
"""
if learning_lang == "ja":
language_name = "японского"
else:
language_name = "английского"
prompt = f"""Объясни грамматическое правило "{topic_name}" ({topic_description}) для изучающих {language_name} язык.
Уровень ученика: {level}
Язык объяснения: {ui_lang}
Требования:
- Объяснение должно быть кратким и понятным (3-5 предложений)
- Приведи формулу/структуру правила
- Дай 2-3 примера с переводом
- Упомяни типичные ошибки (если есть)
- Адаптируй сложность под уровень {level}
ВАЖНО - форматирование для Telegram (используй ТОЛЬКО HTML теги, НЕ markdown):
- <b>жирный текст</b> для важного (НЕ **жирный**)
- <i>курсив</i> для примеров (НЕ *курсив*)
- НЕ используй звёздочки *, НЕ используй markdown
- Можно использовать эмодзи"""
try:
logger.info(f"[AI Request] generate_grammar_rule: topic='{topic_name}', level='{level}'")
messages = [
{"role": "system", "content": f"Ты - опытный преподаватель {language_name} языка. Объясняй правила просто и понятно."},
{"role": "user", "content": prompt}
]
# Для этого запроса не используем JSON mode
model_name, provider = await self._get_active_model(user_id)
if provider == AIProvider.google:
response_data = await self._make_google_request_text(messages, temperature=0.5, model=model_name)
else:
response_data = await self._make_openai_request_text(messages, temperature=0.5, model=model_name)
rule_text = response_data['choices'][0]['message']['content']
# Конвертируем markdown в HTML на случай если AI использовал звёздочки
rule_text = self._markdown_to_html(rule_text)
logger.info(f"[AI Response] generate_grammar_rule: success, {len(rule_text)} chars")
return rule_text
except Exception as e:
logger.error(f"[AI Error] generate_grammar_rule: {type(e).__name__}: {str(e)}")
return f"📖 <b>{topic_name}</b>\n\n{topic_description}\n\nИзучите это правило и приступайте к упражнениям."
async def _make_google_request_text(self, messages: list, temperature: float = 0.3, model: str = "gemini-2.0-flash-lite") -> dict:
"""Запрос к Google без JSON mode (для текстовых ответов)"""
url = f"{self.google_base_url}/models/{model}:generateContent"
contents = []
for msg in messages:
role = msg["role"]
content = msg["content"]
if role == "system":
contents.insert(0, {"role": "user", "parts": [{"text": f"[System instruction]: {content}"}]})
elif role == "user":
contents.append({"role": "user", "parts": [{"text": content}]})
elif role == "assistant":
contents.append({"role": "model", "parts": [{"text": content}]})
payload = {
"contents": contents,
"generationConfig": {"temperature": temperature}
}
headers = {
"Content-Type": "application/json",
"x-goog-api-key": self.google_api_key
}
response = await self.http_client.post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
text = data["candidates"][0]["content"]["parts"][0]["text"]
return {"choices": [{"message": {"content": text}}]}
async def _make_openai_request_text(self, messages: list, temperature: float = 0.3, model: str = "gpt-4o-mini") -> dict:
"""Запрос к OpenAI без JSON mode (для текстовых ответов)"""
url = f"{self.openai_base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self.openai_api_key}",
"Content-Type": "application/json"
}
payload = {
"model": model,
"messages": messages,
"temperature": temperature
}
response = await self.http_client.post(url, headers=headers, json=payload)
response.raise_for_status()
return response.json()
async def generate_grammar_exercise(
self,
topic_id: str,
topic_name: str,
topic_description: str,
level: str,
learning_lang: str = "en",
translation_lang: str = "ru",
count: int = 3,
user_id: Optional[int] = None
) -> List[Dict]:
"""
Генерация грамматических упражнений по теме.
Args:
topic_id: ID темы (например, "present_simple")
topic_name: Название темы (например, "Present Simple")
topic_description: Описание темы (например, "I work, he works")
level: Уровень пользователя (A1-C2 или N5-N1)
learning_lang: Язык изучения
translation_lang: Язык перевода
count: Количество упражнений
user_id: ID пользователя в БД для получения его модели
Returns:
Список упражнений
"""
if learning_lang == "ja":
language_name = "японском"
else:
language_name = "английском"
prompt = f"""Создай {count} грамматических упражнения на тему "{topic_name}" ({topic_description}).
Уровень: {level}
Язык: {language_name}
Язык перевода: {translation_lang}
Верни ответ в формате JSON:
{{
"exercises": [
{{
"sentence": "предложение с пропуском ___ на {learning_lang}",
"translation": "ПОЛНЫЙ перевод предложения на {translation_lang} (без пропусков, с правильным ответом)",
"correct_answer": "правильный ответ для пропуска",
"hint": "краткая подсказка на {translation_lang} (1-2 слова)",
"explanation": "объяснение правила на {translation_lang} (1-2 предложения)"
}}
]
}}
Требования:
- Предложения должны быть естественными и полезными
- Пропуск обозначай как ___
- ВАЖНО: translation должен быть ПОЛНЫМ переводом готового предложения (без пропусков), чтобы ученик понимал смысл
- Подсказка должна направлять к ответу, но не содержать его
- Объяснение должно быть понятным для уровня {level}
- Сложность должна соответствовать уровню {level}"""
try:
logger.info(f"[AI Request] generate_grammar_exercise: topic='{topic_name}', level='{level}'")
messages = [
{"role": "system", "content": f"Ты - преподаватель {language_name} языка. Создавай качественные упражнения. Отвечай только JSON."},
{"role": "user", "content": prompt}
]
response_data = await self._make_request(messages, temperature=0.7, user_id=user_id)
import json
result = json.loads(response_data['choices'][0]['message']['content'])
exercises = result.get('exercises', [])
logger.info(f"[AI Response] generate_grammar_exercise: success, {len(exercises)} exercises generated")
return exercises
except Exception as e:
logger.error(f"[AI Error] generate_grammar_exercise: {type(e).__name__}: {str(e)}")
# Fallback с простым упражнением
return [{
"sentence": f"Example sentence with ___ ({topic_name})",
"translation": "Пример предложения",
"correct_answer": "answer",
"hint": "hint",
"explanation": f"This exercise is about {topic_name}."
}]
def _get_cefr_fallback_questions(self) -> List[Dict]:
"""Fallback вопросы для CEFR (английский и европейские языки)"""
return [
@@ -1134,6 +1370,214 @@ User: {user_message}
}
]
async def generate_word_of_day(
self,
level: str,
learning_lang: str = "en",
translation_lang: str = "ru",
excluded_words: List[str] = None,
user_id: Optional[int] = None
) -> Optional[Dict]:
"""
Генерация слова дня.
Args:
level: Уровень пользователя (A1-C2 или N5-N1)
learning_lang: Язык изучения
translation_lang: Язык перевода
excluded_words: Список слов для исключения (уже были)
user_id: ID пользователя для выбора модели
Returns:
Dict с полями: word, transcription, translation, examples, synonyms, etymology
"""
language_names = {
"en": "английский",
"ja": "японский"
}
language_name = language_names.get(learning_lang, "английский")
translation_names = {
"ru": "русский",
"en": "английский",
"ja": "японский"
}
translation_name = translation_names.get(translation_lang, "русский")
excluded_str = ""
if excluded_words:
excluded_str = f"\n\nНЕ используй эти слова (уже были): {', '.join(excluded_words[:20])}"
prompt = f"""Сгенерируй интересное "слово дня" для изучающего {language_name} язык на уровне {level}.
Требования:
- Слово должно быть полезным и интересным
- Подходящее для уровня {level}
- НЕ слишком простое и НЕ слишком сложное
- Желательно с интересной этимологией или фактом{excluded_str}
Верни JSON:
{{
"word": "слово на {language_name}",
"transcription": "транскрипция (IPA для английского, хирагана для японского)",
"translation": "перевод на {translation_name}",
"examples": [
{{"sentence": "пример предложения", "translation": "перевод примера"}},
{{"sentence": "второй пример", "translation": "перевод"}}
],
"synonyms": "синоним1, синоним2, синоним3",
"etymology": "краткий интересный факт о слове или его происхождении (1-2 предложения)"
}}"""
try:
logger.info(f"[AI Request] generate_word_of_day: level='{level}', lang='{learning_lang}'")
messages = [
{"role": "system", "content": "Ты - опытный лингвист, который подбирает интересные слова для изучения."},
{"role": "user", "content": prompt}
]
model_name, provider = await self._get_active_model(user_id)
if provider == AIProvider.google:
response_data = await self._make_google_request(messages, temperature=0.8, model=model_name)
else:
response_data = await self._make_openai_request(messages, temperature=0.8, model=model_name)
content = response_data['choices'][0]['message']['content']
content = self._strip_markdown_code_block(content)
result = json.loads(content)
logger.info(f"[AI Response] generate_word_of_day: word='{result.get('word', 'N/A')}'")
return result
except Exception as e:
logger.error(f"[AI Error] generate_word_of_day: {type(e).__name__}: {str(e)}")
return None
async def generate_mini_story(
self,
genre: str,
level: str,
learning_lang: str = "en",
translation_lang: str = "ru",
user_id: Optional[int] = None,
num_questions: int = 5
) -> Optional[Dict]:
"""
Генерация мини-истории для чтения.
Args:
genre: Жанр (dialogue, news, story, letter, recipe)
level: Уровень (A1-C2 или N5-N1)
learning_lang: Язык истории
translation_lang: Язык переводов
user_id: ID пользователя для выбора модели
num_questions: Количество вопросов (из настроек пользователя)
Returns:
Dict с полями: title, content, vocabulary, questions, word_count
"""
import json
language_names = {
"en": "английский",
"ja": "японский"
}
language_name = language_names.get(learning_lang, "английский")
translation_names = {
"ru": "русский",
"en": "английский",
"ja": "японский"
}
translation_name = translation_names.get(translation_lang, "русский")
genre_descriptions = {
"dialogue": "разговорный диалог между людьми",
"news": "короткая новостная статья",
"story": "художественный рассказ с сюжетом",
"letter": "email или письмо",
"recipe": "рецепт блюда с инструкциями"
}
genre_desc = genre_descriptions.get(genre, "короткий рассказ")
# Определяем длину текста по уровню
word_counts = {
"A1": "50-80", "N5": "30-50",
"A2": "80-120", "N4": "50-80",
"B1": "120-180", "N3": "80-120",
"B2": "180-250", "N2": "120-180",
"C1": "250-350", "N1": "180-250",
"C2": "300-400"
}
word_range = word_counts.get(level, "100-150")
# Генерируем примеры вопросов для промпта
questions_examples = []
for i in range(num_questions):
questions_examples.append(f''' {{
"question": "Вопрос {i + 1} на понимание на {translation_name}",
"options": ["вариант 1", "вариант 2", "вариант 3"],
"correct": {i % 3}
}}''')
questions_json = ",\n".join(questions_examples)
prompt = f"""Создай {genre_desc} на {language_name} языке для уровня {level}.
Требования:
- Длина: {word_range} слов
- Используй лексику и грамматику подходящую для уровня {level}
- История должна быть интересной и законченной
- Выдели 5-8 ключевых слов которые могут быть новыми для изучающего
- Добавь полный перевод текста на {translation_name} язык
Верни JSON:
{{
"title": "Название истории на {language_name}",
"content": "Полный текст истории",
"translation": "Полный перевод истории на {translation_name}",
"vocabulary": [
{{"word": "слово", "translation": "перевод на {translation_name}", "transcription": "транскрипция"}},
...
],
"questions": [
{questions_json}
],
"word_count": число_слов_в_тексте
}}
Важно:
- Создай ровно {num_questions} вопросов на понимание текста
- У каждого вопроса ровно 3 варианта ответа
- correct — индекс правильного ответа (0, 1 или 2)"""
try:
logger.info(f"[AI Request] generate_mini_story: genre='{genre}', level='{level}', lang='{learning_lang}'")
messages = [
{"role": "system", "content": "Ты - автор адаптированных текстов для изучающих иностранные языки."},
{"role": "user", "content": prompt}
]
model_name, provider = await self._get_active_model(user_id)
if provider == AIProvider.google:
response_data = await self._make_google_request(messages, temperature=0.8, model=model_name)
else:
response_data = await self._make_openai_request(messages, temperature=0.8, model=model_name)
content = response_data['choices'][0]['message']['content']
content = self._strip_markdown_code_block(content)
result = json.loads(content)
logger.info(f"[AI Response] generate_mini_story: title='{result.get('title', 'N/A')}', words={result.get('word_count', 0)}")
return result
except Exception as e:
logger.error(f"[AI Error] generate_mini_story: {type(e).__name__}: {str(e)}")
return None
def _get_jlpt_fallback_questions(self) -> List[Dict]:
"""Fallback вопросы для JLPT (японский)"""
return [