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:
@@ -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 [
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import User
|
||||
from database.models import User, JLPT_LANGUAGES
|
||||
from database.db import async_session_maker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -30,9 +30,26 @@ class ReminderService:
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# Генерация слов дня в 00:00 UTC
|
||||
self.scheduler.add_job(
|
||||
self.generate_daily_words,
|
||||
trigger=CronTrigger(hour=0, minute=0, timezone='UTC'),
|
||||
id='generate_words_of_day',
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
self.scheduler.start()
|
||||
logger.info("Планировщик напоминаний запущен")
|
||||
|
||||
async def generate_daily_words(self):
|
||||
"""Генерация слов дня для всех уровней"""
|
||||
try:
|
||||
from services.wordofday_service import wordofday_service
|
||||
results = await wordofday_service.generate_all_words_for_today()
|
||||
logger.info(f"Слова дня сгенерированы: {results}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка генерации слов дня: {e}")
|
||||
|
||||
def shutdown(self):
|
||||
"""Остановить планировщик"""
|
||||
self.scheduler.shutdown()
|
||||
@@ -97,6 +114,17 @@ class ReminderService:
|
||||
|
||||
return time_diff < 300 # 5 минут в секундах
|
||||
|
||||
async def _get_user_level(self, user: User) -> str:
|
||||
"""Получить уровень пользователя для текущего языка изучения"""
|
||||
# Сначала проверяем levels_by_language
|
||||
if user.levels_by_language and user.learning_language in user.levels_by_language:
|
||||
return user.levels_by_language[user.learning_language]
|
||||
|
||||
# Иначе используем общий уровень
|
||||
if user.learning_language in JLPT_LANGUAGES:
|
||||
return "N5" # Дефолтный JLPT уровень
|
||||
return user.level.value if user.level else "A1"
|
||||
|
||||
async def _send_reminder(self, user: User, session: AsyncSession):
|
||||
"""
|
||||
Отправить напоминание пользователю
|
||||
@@ -106,18 +134,37 @@ class ReminderService:
|
||||
session: Сессия базы данных
|
||||
"""
|
||||
try:
|
||||
message_text = (
|
||||
"⏰ <b>Время для практики!</b>\n\n"
|
||||
"Не забудь потренироваться сегодня:\n"
|
||||
"• /task - выполни задания\n"
|
||||
"• /practice - попрактикуй диалог\n"
|
||||
"• /words - добавь новые слова\n\n"
|
||||
"💪 Регулярная практика - ключ к успеху!"
|
||||
from services.wordofday_service import wordofday_service
|
||||
from utils.i18n import t
|
||||
|
||||
lang = user.language_interface or "ru"
|
||||
|
||||
# Получаем слово дня для пользователя
|
||||
level = await self._get_user_level(user)
|
||||
word_of_day = await wordofday_service.get_word_of_day(
|
||||
learning_lang=user.learning_language,
|
||||
level=level
|
||||
)
|
||||
|
||||
# Формируем сообщение
|
||||
message_parts = [t(lang, "reminder.daily_title") + "\n"]
|
||||
|
||||
# Добавляем слово дня если есть
|
||||
if word_of_day:
|
||||
word_text = await wordofday_service.format_word_for_user(
|
||||
word_of_day,
|
||||
translation_lang=user.translation_language or user.language_interface,
|
||||
ui_lang=lang
|
||||
)
|
||||
message_parts.append(f"{t(lang, 'reminder.daily_wod')}\n{word_text}\n")
|
||||
|
||||
message_parts.append(t(lang, "reminder.daily_tips"))
|
||||
message_parts.append(f"\n{t(lang, 'reminder.daily_motivation')}")
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text
|
||||
text="\n".join(message_parts),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Обновляем время последнего напоминания
|
||||
|
||||
227
services/wordofday_service.py
Normal file
227
services/wordofday_service.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""Сервис генерации слова дня"""
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.db import async_session_maker
|
||||
from database.models import WordOfDay, LanguageLevel, JLPTLevel, JLPT_LANGUAGES
|
||||
from services.ai_service import ai_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Уровни для каждого языка
|
||||
CEFR_LEVELS = [level.value for level in LanguageLevel] # A1-C2
|
||||
JLPT_LEVELS = [level.value for level in JLPTLevel] # N5-N1
|
||||
|
||||
# Языки для генерации
|
||||
LEARNING_LANGUAGES = ["en", "ja"]
|
||||
|
||||
|
||||
class WordOfDayService:
|
||||
"""Сервис для генерации и получения слова дня"""
|
||||
|
||||
async def generate_all_words_for_today(self) -> Dict[str, int]:
|
||||
"""
|
||||
Генерация слов дня для всех языков и уровней.
|
||||
Вызывается в 00:00 UTC.
|
||||
|
||||
Returns:
|
||||
Dict с количеством сгенерированных слов по языкам
|
||||
"""
|
||||
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
results = {"en": 0, "ja": 0, "errors": 0}
|
||||
|
||||
async with async_session_maker() as session:
|
||||
for lang in LEARNING_LANGUAGES:
|
||||
levels = JLPT_LEVELS if lang in JLPT_LANGUAGES else CEFR_LEVELS
|
||||
|
||||
for level in levels:
|
||||
try:
|
||||
# Проверяем, не сгенерировано ли уже
|
||||
existing = await self._get_word_for_date(
|
||||
session, today, lang, level
|
||||
)
|
||||
if existing:
|
||||
logger.debug(
|
||||
f"Слово дня уже существует: {lang}/{level}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Получаем список недавних слов для исключения
|
||||
excluded = await self._get_recent_words(session, lang, level, days=30)
|
||||
|
||||
# Генерируем слово
|
||||
word_data = await ai_service.generate_word_of_day(
|
||||
level=level,
|
||||
learning_lang=lang,
|
||||
translation_lang="ru", # Базовый перевод на русский
|
||||
excluded_words=excluded
|
||||
)
|
||||
|
||||
if word_data:
|
||||
word_of_day = WordOfDay(
|
||||
word=word_data.get("word", ""),
|
||||
transcription=word_data.get("transcription"),
|
||||
translation=word_data.get("translation", ""),
|
||||
examples=word_data.get("examples"),
|
||||
synonyms=word_data.get("synonyms"),
|
||||
etymology=word_data.get("etymology"),
|
||||
learning_lang=lang,
|
||||
level=level,
|
||||
date=today
|
||||
)
|
||||
session.add(word_of_day)
|
||||
await session.commit()
|
||||
results[lang] += 1
|
||||
logger.info(
|
||||
f"Сгенерировано слово дня: {word_data.get('word')} "
|
||||
f"({lang}/{level})"
|
||||
)
|
||||
else:
|
||||
results["errors"] += 1
|
||||
logger.warning(
|
||||
f"Не удалось сгенерировать слово для {lang}/{level}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
results["errors"] += 1
|
||||
logger.error(
|
||||
f"Ошибка генерации слова для {lang}/{level}: {e}"
|
||||
)
|
||||
|
||||
total = results["en"] + results["ja"]
|
||||
logger.info(
|
||||
f"Генерация слов дня завершена: всего={total}, "
|
||||
f"en={results['en']}, ja={results['ja']}, ошибок={results['errors']}"
|
||||
)
|
||||
return results
|
||||
|
||||
async def get_word_of_day(
|
||||
self,
|
||||
learning_lang: str,
|
||||
level: str,
|
||||
target_date: Optional[datetime] = None
|
||||
) -> Optional[WordOfDay]:
|
||||
"""
|
||||
Получить слово дня для языка и уровня.
|
||||
|
||||
Args:
|
||||
learning_lang: Язык изучения (en/ja)
|
||||
level: Уровень (A1-C2 или N5-N1)
|
||||
target_date: Дата (по умолчанию сегодня)
|
||||
|
||||
Returns:
|
||||
WordOfDay или None
|
||||
"""
|
||||
if target_date is None:
|
||||
target_date = datetime.utcnow().replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
else:
|
||||
target_date = target_date.replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
return await self._get_word_for_date(
|
||||
session, target_date, learning_lang, level
|
||||
)
|
||||
|
||||
async def _get_word_for_date(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
target_date: datetime,
|
||||
learning_lang: str,
|
||||
level: str
|
||||
) -> Optional[WordOfDay]:
|
||||
"""Получить слово из БД для конкретной даты"""
|
||||
result = await session.execute(
|
||||
select(WordOfDay).where(
|
||||
and_(
|
||||
WordOfDay.date == target_date,
|
||||
WordOfDay.learning_lang == learning_lang,
|
||||
WordOfDay.level == level
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_recent_words(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
learning_lang: str,
|
||||
level: str,
|
||||
days: int = 30
|
||||
) -> List[str]:
|
||||
"""Получить список недавних слов для исключения"""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
result = await session.execute(
|
||||
select(WordOfDay.word).where(
|
||||
and_(
|
||||
WordOfDay.learning_lang == learning_lang,
|
||||
WordOfDay.level == level,
|
||||
WordOfDay.date >= cutoff_date
|
||||
)
|
||||
)
|
||||
)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
async def format_word_for_user(
|
||||
self,
|
||||
word: WordOfDay,
|
||||
translation_lang: str = "ru",
|
||||
ui_lang: str = None
|
||||
) -> str:
|
||||
"""
|
||||
Форматировать слово дня для отображения пользователю.
|
||||
|
||||
Args:
|
||||
word: Объект WordOfDay
|
||||
translation_lang: Язык перевода для пользователя
|
||||
ui_lang: Язык интерфейса (для локализации заголовков)
|
||||
|
||||
Returns:
|
||||
Отформатированная строка
|
||||
"""
|
||||
from utils.i18n import t
|
||||
|
||||
lang = ui_lang or translation_lang or "ru"
|
||||
lines = []
|
||||
|
||||
# Заголовок со словом
|
||||
if word.transcription:
|
||||
lines.append(f"📚 <b>{word.word}</b> [{word.transcription}]")
|
||||
else:
|
||||
lines.append(f"📚 <b>{word.word}</b>")
|
||||
|
||||
# Перевод
|
||||
lines.append(f"📝 {word.translation}")
|
||||
|
||||
# Синонимы
|
||||
if word.synonyms:
|
||||
lines.append(f"\n🔄 <b>{t(lang, 'wod.synonyms')}:</b> {word.synonyms}")
|
||||
|
||||
# Примеры
|
||||
if word.examples:
|
||||
lines.append(f"\n📖 <b>{t(lang, 'wod.examples')}:</b>")
|
||||
for i, example in enumerate(word.examples[:3], 1):
|
||||
sentence = example.get("sentence", "")
|
||||
translation = example.get("translation", "")
|
||||
lines.append(f" {i}. {sentence}")
|
||||
if translation:
|
||||
lines.append(f" <i>{translation}</i>")
|
||||
|
||||
# Этимология/интересный факт
|
||||
if word.etymology:
|
||||
lines.append(f"\n💡 {word.etymology}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# Глобальный экземпляр сервиса
|
||||
wordofday_service = WordOfDayService()
|
||||
Reference in New Issue
Block a user