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 [

View File

@@ -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"
)
# Обновляем время последнего напоминания

View 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()