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

@@ -91,7 +91,6 @@ docker-bot-build:
docker-bot-rebuild:
docker-compose stop bot
docker-compose rm bot
docker-compose build --no-cache bot
docker-compose up -d bot
docker-bot-rebuild-full:

View File

@@ -289,6 +289,11 @@ bot_tg_language/
- [x] Убрать переводы текстов (скрыть перевод в упражнениях/диалогах/тестах)
- [x] Добавлены английский и японский в локализацию интерфейса
- [x] Добавлены языки для обучения
- [x] Добавить возможность иметь словам несколько переводов
- [x] Добавить возможность импорта слов из файлов (txt, md)
- [x] Добавить импорт нескольких слов (bulk-импорт)
- [x] Добавлена механика "Слова дня"
- [x] Добавлена механика "Мини истории"
**Следующие улучшения:**
- [ ] Экспорт словаря (PDF, Anki, CSV)
@@ -296,11 +301,8 @@ bot_tg_language/
- [ ] Групповые челленджи и лидерборды
- [ ] Gamification (стрики, достижения, уровни)
- [ ] Расширенная аналитика с графиками
- [ ] Добавить импорт нескольких слов (bulk-импорт)
- [ ] Создание задач на выбранные слова (из словаря/подборок)
- [ ] Добавить возможность иметь словам несколько переводов
- [ ] Изменить словарь: оставить только слова и добавить возможность получать инфо о словах
- [ ] Добавить возможность импорта слов из файлов
## Cloudflare AI Gateway (опционально)

415
bot/handlers/exercises.py Normal file
View File

@@ -0,0 +1,415 @@
"""Handler для грамматических упражнений."""
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from database.db import async_session_maker
from services.user_service import UserService
from services.ai_service import ai_service
from utils.i18n import t, get_user_lang, get_user_translation_lang
from utils.levels import get_user_level_for_language
from data.grammar_rules import get_topics_for_current_level_only, get_grammar_topics_for_level
router = Router()
# Количество тем на одной странице
TOPICS_PER_PAGE = 6
class ExercisesStates(StatesGroup):
"""Состояния для грамматических упражнений."""
choosing_topic = State()
viewing_rule = State()
doing_exercise = State()
waiting_answer = State()
@router.message(Command("exercises"))
async def cmd_exercises(message: Message, state: FSMContext):
"""Обработчик команды /exercises."""
await show_exercises_menu(message, state, telegram_id=message.from_user.id)
async def show_exercises_menu(message: Message, state: FSMContext, page: int = 0, telegram_id: int = None):
"""Показать меню выбора темы упражнений."""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, telegram_id)
if not user:
await message.answer(t('ru', 'common.start_first'))
return
lang = get_user_lang(user)
learning_lang = user.learning_language or 'en'
level = get_user_level_for_language(user)
# Получаем темы для текущего уровня
topics = get_topics_for_current_level_only(learning_lang, level)
if not topics:
await message.answer(t(lang, 'exercises.no_topics'))
return
# Пагинация
total_pages = (len(topics) + TOPICS_PER_PAGE - 1) // TOPICS_PER_PAGE
start_idx = page * TOPICS_PER_PAGE
end_idx = start_idx + TOPICS_PER_PAGE
page_topics = topics[start_idx:end_idx]
# Сохраняем в состоянии
await state.update_data(
topics=[t for t in topics], # Все темы
page=page,
level=level,
learning_lang=learning_lang,
user_id=user.id
)
await state.set_state(ExercisesStates.choosing_topic)
# Формируем текст
text = (
t(lang, 'exercises.title') + "\n\n" +
t(lang, 'exercises.your_level', level=level) + "\n\n" +
t(lang, 'exercises.choose_topic')
)
# Формируем клавиатуру
keyboard = []
for topic in page_topics:
# Используем русское название если интерфейс на русском, иначе английское
if lang == 'ru' and topic.get('name_ru'):
btn_text = topic['name_ru']
else:
btn_text = topic['name']
keyboard.append([
InlineKeyboardButton(
text=btn_text,
callback_data=f"exercise_topic_{topic['id']}"
)
])
# Навигация по страницам
nav_row = []
if page > 0:
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"exercises_page_{page - 1}"))
if total_pages > 1:
nav_row.append(InlineKeyboardButton(text=f"{page + 1}/{total_pages}", callback_data="exercises_noop"))
if page < total_pages - 1:
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"exercises_page_{page + 1}"))
if nav_row:
keyboard.append(nav_row)
# Кнопка закрыть
keyboard.append([
InlineKeyboardButton(text=t(lang, 'exercises.close_btn'), callback_data="exercises_close")
])
reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
await message.answer(text, reply_markup=reply_markup)
@router.callback_query(F.data.startswith("exercises_page_"))
async def exercises_page_callback(callback: CallbackQuery, state: FSMContext):
"""Переключение страницы тем."""
page = int(callback.data.split("_")[-1])
await callback.answer()
# Удаляем старое сообщение и показываем новое
await callback.message.delete()
await show_exercises_menu(callback.message, state, page=page, telegram_id=callback.from_user.id)
@router.callback_query(F.data == "exercises_noop")
async def exercises_noop_callback(callback: CallbackQuery):
"""Пустой callback для кнопки с номером страницы."""
await callback.answer()
@router.callback_query(F.data == "exercises_close")
async def exercises_close_callback(callback: CallbackQuery, state: FSMContext):
"""Закрыть меню упражнений."""
await callback.message.delete()
await state.clear()
await callback.answer()
@router.callback_query(F.data.startswith("exercise_topic_"))
async def exercise_topic_callback(callback: CallbackQuery, state: FSMContext):
"""Выбор темы для упражнения - показываем правило."""
await callback.answer()
topic_id = callback.data.replace("exercise_topic_", "")
data = await state.get_data()
topics = data.get('topics', [])
level = data.get('level', 'A1')
learning_lang = data.get('learning_lang', 'en')
user_id = data.get('user_id')
# Находим выбранную тему
topic = next((tp for tp in topics if tp['id'] == topic_id), None)
if not topic:
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user) if user else 'ru'
await callback.message.edit_text(t(lang, 'exercises.generate_failed'))
return
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user) if user else 'ru'
# Показываем индикатор генерации правила
await callback.message.edit_text(t(lang, 'exercises.generating_rule'))
# Генерируем объяснение правила
rule_text = await ai_service.generate_grammar_rule(
topic_name=topic['name'],
topic_description=topic.get('description', ''),
level=level,
learning_lang=learning_lang,
ui_lang=lang,
user_id=user_id
)
# Сохраняем данные темы в состоянии
await state.update_data(
current_topic=topic,
rule_text=rule_text
)
await state.set_state(ExercisesStates.viewing_rule)
# Показываем правило с кнопкой "Начать упражнения"
topic_display = topic.get('name_ru', topic['name']) if lang == 'ru' else topic['name']
text = f"📖 <b>{topic_display}</b>\n\n{rule_text}"
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'exercises.start_btn'), callback_data="exercises_start_tasks")],
[InlineKeyboardButton(text=t(lang, 'exercises.back_btn'), callback_data="exercises_back_to_topics")]
])
await callback.message.edit_text(text, reply_markup=keyboard)
@router.callback_query(F.data == "exercises_start_tasks", ExercisesStates.viewing_rule)
async def start_exercises_callback(callback: CallbackQuery, state: FSMContext):
"""Начать упражнения после просмотра правила."""
await callback.answer()
data = await state.get_data()
topic = data.get('current_topic', {})
level = data.get('level', 'A1')
learning_lang = data.get('learning_lang', 'en')
user_id = data.get('user_id')
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user) if user else 'ru'
translation_lang = get_user_translation_lang(user) if user else 'ru'
tasks_count = getattr(user, 'tasks_count', 5) or 5
# Показываем индикатор генерации
await callback.message.edit_text(t(lang, 'exercises.generating'))
# Генерируем упражнения
exercises = await ai_service.generate_grammar_exercise(
topic_id=topic.get('id', ''),
topic_name=topic.get('name', ''),
topic_description=topic.get('description', ''),
level=level,
learning_lang=learning_lang,
translation_lang=translation_lang,
count=tasks_count,
user_id=user_id
)
if not exercises:
await callback.message.edit_text(t(lang, 'exercises.generate_failed'))
return
# Сохраняем упражнения в состоянии
await state.update_data(
exercises=exercises,
current_exercise=0,
correct_count=0,
topic_name=topic.get('name', ''),
topic_name_ru=topic.get('name_ru', topic.get('name', ''))
)
await state.set_state(ExercisesStates.waiting_answer)
# Показываем первое упражнение
await show_exercise(callback.message, state, lang, edit=True)
async def show_exercise(message: Message, state: FSMContext, lang: str, edit: bool = False):
"""Показать текущее упражнение."""
data = await state.get_data()
exercises = data.get('exercises', [])
current = data.get('current_exercise', 0)
topic_name = data.get('topic_name_ru' if lang == 'ru' else 'topic_name', '')
if current >= len(exercises):
# Все упражнения завершены
await show_results(message, state, lang, edit)
return
exercise = exercises[current]
text = (
t(lang, 'exercises.task_header', topic=topic_name) + "\n\n" +
f"<b>{current + 1}/{len(exercises)}</b>\n\n" +
t(lang, 'exercises.instruction') + "\n\n" +
f"📝 {exercise.get('sentence', '')}\n\n" +
f"💬 <i>{exercise.get('translation', '')}</i>\n\n" +
f"💡 {exercise.get('hint', '')}\n\n" +
t(lang, 'exercises.write_answer')
)
# Кнопки
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'exercises.back_btn'), callback_data="exercises_back_to_topics")],
[InlineKeyboardButton(text=t(lang, 'exercises.close_btn'), callback_data="exercises_close")]
])
if edit:
await message.edit_text(text, reply_markup=keyboard)
else:
await message.answer(text, reply_markup=keyboard)
@router.callback_query(F.data == "exercises_back_to_topics")
async def back_to_topics_callback(callback: CallbackQuery, state: FSMContext):
"""Вернуться к выбору темы."""
await callback.answer()
await callback.message.delete()
data = await state.get_data()
page = data.get('page', 0)
await show_exercises_menu(callback.message, state, page=page, telegram_id=callback.from_user.id)
@router.message(ExercisesStates.waiting_answer)
async def process_answer(message: Message, state: FSMContext):
"""Обработка ответа пользователя."""
user_answer = message.text.strip().lower()
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
lang = get_user_lang(user) if user else 'ru'
data = await state.get_data()
exercises = data.get('exercises', [])
current = data.get('current_exercise', 0)
correct_count = data.get('correct_count', 0)
if current >= len(exercises):
return
exercise = exercises[current]
correct_answer = exercise.get('correct_answer', '').strip().lower()
# Проверяем ответ
is_correct = user_answer == correct_answer
if is_correct:
correct_count += 1
result_text = t(lang, 'exercises.correct') + "\n\n"
else:
result_text = (
t(lang, 'exercises.incorrect') + "\n\n" +
t(lang, 'exercises.your_answer', answer=message.text) + "\n" +
t(lang, 'exercises.right_answer', answer=exercise.get('correct_answer', '')) + "\n\n"
)
# Добавляем объяснение
result_text += t(lang, 'exercises.explanation', text=exercise.get('explanation', ''))
# Обновляем состояние
await state.update_data(
current_exercise=current + 1,
correct_count=correct_count
)
# Кнопка "Следующее" или "Результаты"
if current + 1 < len(exercises):
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'exercises.next_btn'), callback_data="exercises_next")]
])
else:
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'exercises.results_btn'), callback_data="exercises_finish")]
])
await message.answer(result_text, reply_markup=keyboard)
@router.callback_query(F.data == "exercises_next")
async def next_exercise_callback(callback: CallbackQuery, state: FSMContext):
"""Переход к следующему упражнению."""
await callback.answer()
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user) if user else 'ru'
await show_exercise(callback.message, state, lang, edit=True)
@router.callback_query(F.data == "exercises_finish")
async def finish_exercises_callback(callback: CallbackQuery, state: FSMContext):
"""Показать результаты."""
await callback.answer()
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user) if user else 'ru'
# Убираем кнопку с предыдущего сообщения (оставляем фидбэк видимым)
await callback.message.edit_reply_markup(reply_markup=None)
# Показываем результаты новым сообщением
await show_results(callback.message, state, lang, edit=False)
async def show_results(message: Message, state: FSMContext, lang: str, edit: bool = False):
"""Показать результаты упражнений."""
data = await state.get_data()
exercises = data.get('exercises', [])
correct_count = data.get('correct_count', 0)
total = len(exercises)
topic_name = data.get('topic_name_ru' if lang == 'ru' else 'topic_name', '')
text = (
f"🎉 <b>{topic_name}</b>\n\n" +
t(lang, 'exercises.score', correct=correct_count, total=total)
)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'exercises.back_btn'), callback_data="exercises_restart")],
[InlineKeyboardButton(text=t(lang, 'exercises.close_btn'), callback_data="exercises_close")]
])
if edit:
await message.edit_text(text, reply_markup=keyboard)
else:
await message.answer(text, reply_markup=keyboard)
await state.clear()
@router.callback_query(F.data == "exercises_restart")
async def restart_exercises_callback(callback: CallbackQuery, state: FSMContext):
"""Вернуться к выбору темы после завершения."""
await callback.answer()
await callback.message.delete()
await show_exercises_menu(callback.message, state, page=0, telegram_id=callback.from_user.id)

View File

@@ -29,6 +29,40 @@ def get_scenario_name(lang: str, scenario: str) -> str:
return t(lang, f'practice.scenario.{scenario}')
async def show_practice_menu(message: Message, telegram_id: int, edit: bool = False):
"""Показать меню выбора сценария практики"""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, telegram_id)
if not user:
await message.answer(t('ru', 'common.start_first'))
return
lang = get_user_lang(user)
keyboard = []
for scenario_id in SCENARIO_KEYS:
keyboard.append([
InlineKeyboardButton(
text=get_scenario_name(lang, scenario_id),
callback_data=f"scenario_{scenario_id}"
)
])
keyboard.append([
InlineKeyboardButton(
text=t(lang, 'practice.custom_scenario_btn'),
callback_data="scenario_custom"
)
])
reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
if edit:
await message.edit_text(t(lang, 'practice.start_text'), reply_markup=reply_markup)
else:
await message.answer(t(lang, 'practice.start_text'), reply_markup=reply_markup)
@router.message(Command("practice"))
async def cmd_practice(message: Message, state: FSMContext):
"""Обработчик команды /practice"""
@@ -39,31 +73,10 @@ async def cmd_practice(message: Message, state: FSMContext):
await message.answer(t('ru', 'common.start_first'))
return
lang = get_user_lang(user)
# Показываем выбор сценария
keyboard = []
for scenario_id in SCENARIO_KEYS:
keyboard.append([
InlineKeyboardButton(
text=get_scenario_name(lang, scenario_id),
callback_data=f"scenario_{scenario_id}"
)
])
# Кнопка для своего сценария
keyboard.append([
InlineKeyboardButton(
text=t(lang, 'practice.custom_scenario_btn'),
callback_data="scenario_custom"
)
])
reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
await state.update_data(user_id=user.id, level=get_user_level_for_language(user))
await state.set_state(PracticeStates.choosing_scenario)
await message.answer(t(lang, 'practice.start_text'), reply_markup=reply_markup)
await show_practice_menu(message, message.from_user.id, edit=False)
@router.callback_query(F.data == "scenario_custom", PracticeStates.choosing_scenario)

View File

@@ -13,7 +13,7 @@ from aiogram.fsm.state import State, StatesGroup
from database.db import async_session_maker
from services.user_service import UserService
from utils.i18n import t, get_user_translation_lang
from utils.i18n import t, get_user_translation_lang, get_user_lang
from utils.levels import get_user_level_for_language
router = Router()
@@ -57,16 +57,19 @@ def main_menu_keyboard(lang: str = 'ru') -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
resize_keyboard=True,
keyboard=[
[
KeyboardButton(text=t(lang, "menu.add")),
KeyboardButton(text=t(lang, "menu.vocab")),
],
[
KeyboardButton(text=t(lang, "menu.task")),
KeyboardButton(text=t(lang, "menu.practice")),
],
[
KeyboardButton(text=t(lang, "menu.exercises")),
KeyboardButton(text=t(lang, "menu.vocab")),
],
[
KeyboardButton(text=t(lang, "menu.add")),
KeyboardButton(text=t(lang, "menu.stats")),
],
[
KeyboardButton(text=t(lang, "menu.settings")),
],
],
@@ -326,8 +329,74 @@ async def btn_task_pressed(message: Message, state: FSMContext):
@router.message(_menu_match('menu.practice'))
async def btn_practice_pressed(message: Message, state: FSMContext):
from bot.handlers.practice import cmd_practice
await cmd_practice(message, state)
"""Показать меню практики"""
await state.clear()
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
lang = get_user_lang(user) if user else 'ru'
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=f"📖 {t(lang, 'practice_menu.stories')}",
callback_data="practice_stories"
)],
[InlineKeyboardButton(
text=f"💬 {t(lang, 'practice_menu.ai_chat')}",
callback_data="practice_ai"
)],
])
await message.answer(
f"💬 <b>{t(lang, 'practice_menu.title')}</b>\n\n{t(lang, 'practice_menu.choose')}",
reply_markup=keyboard
)
@router.callback_query(F.data == "practice_stories")
async def practice_stories_callback(callback: CallbackQuery, state: FSMContext):
"""Переход к мини-историям"""
await callback.answer()
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user) if user else 'ru'
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text=f"🗣 {t(lang, 'story.genre.dialogue')}", callback_data="story_genre_dialogue"),
InlineKeyboardButton(text=f"📰 {t(lang, 'story.genre.news')}", callback_data="story_genre_news"),
],
[
InlineKeyboardButton(text=f"🎭 {t(lang, 'story.genre.story')}", callback_data="story_genre_story"),
InlineKeyboardButton(text=f"📧 {t(lang, 'story.genre.letter')}", callback_data="story_genre_letter"),
],
[
InlineKeyboardButton(text=f"🍳 {t(lang, 'story.genre.recipe')}", callback_data="story_genre_recipe"),
],
])
await callback.message.edit_text(
f"📖 <b>{t(lang, 'story.title')}</b>\n\n{t(lang, 'story.choose_genre')}",
reply_markup=keyboard
)
@router.callback_query(F.data == "practice_ai")
async def practice_ai_callback(callback: CallbackQuery, state: FSMContext):
"""Переход к AI практике"""
await callback.answer()
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
if user:
from bot.handlers.practice import PracticeStates
from utils.levels import get_user_level_for_language
await state.update_data(user_id=user.id, level=get_user_level_for_language(user))
await state.set_state(PracticeStates.choosing_scenario)
from bot.handlers.practice import show_practice_menu
await show_practice_menu(callback.message, callback.from_user.id, edit=True)
@router.message(_menu_match('menu.import'))
@@ -391,6 +460,14 @@ async def btn_settings_pressed(message: Message):
await cmd_settings(message)
@router.message(_menu_match('menu.exercises'))
async def btn_exercises_pressed(message: Message, state: FSMContext):
"""Показать меню грамматических упражнений."""
from bot.handlers.exercises import show_exercises_menu
await show_exercises_menu(message, state, telegram_id=message.from_user.id)
@router.message(_menu_match('menu.words'))
async def btn_words_pressed(message: Message, state: FSMContext):
"""Подсказать про тематические слова и показать быстрые темы."""

688
bot/handlers/stories.py Normal file
View File

@@ -0,0 +1,688 @@
"""Handler для мини-историй (Reading Practice)."""
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from database.db import async_session_maker
from database.models import MiniStory, StoryGenre, WordSource
from services.user_service import UserService
from services.vocabulary_service import VocabularyService
from services.ai_service import ai_service
from utils.i18n import t, get_user_lang, get_user_translation_lang
from utils.levels import get_user_level_for_language
router = Router()
class StoryStates(StatesGroup):
"""Состояния для чтения истории"""
reading = State() # Чтение истории
questions = State() # Ответы на вопросы
GENRE_EMOJI = {
"dialogue": "🗣",
"news": "📰",
"story": "🎭",
"letter": "📧",
"recipe": "🍳"
}
def get_genre_keyboard(lang: str) -> InlineKeyboardMarkup:
"""Клавиатура выбора жанра"""
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=f"🗣 {t(lang, 'story.genre.dialogue')}",
callback_data="story_genre_dialogue"
),
InlineKeyboardButton(
text=f"📰 {t(lang, 'story.genre.news')}",
callback_data="story_genre_news"
),
],
[
InlineKeyboardButton(
text=f"🎭 {t(lang, 'story.genre.story')}",
callback_data="story_genre_story"
),
InlineKeyboardButton(
text=f"📧 {t(lang, 'story.genre.letter')}",
callback_data="story_genre_letter"
),
],
[
InlineKeyboardButton(
text=f"🍳 {t(lang, 'story.genre.recipe')}",
callback_data="story_genre_recipe"
),
],
])
def format_story_text(story: MiniStory, lang: str, show_translation: bool = False) -> str:
"""Форматировать текст истории"""
emoji = GENRE_EMOJI.get(story.genre.value, "📖")
text = f"{emoji} <b>{story.title}</b>\n"
text += f"<i>{t(lang, 'story.level')}: {story.level}{story.word_count} {t(lang, 'story.words')}</i>\n"
text += "" * 20 + "\n\n"
text += story.content
if show_translation and story.translation:
text += "\n\n" + "" * 20
text += f"\n\n🌐 <b>{t(lang, 'story.translation')}:</b>\n\n"
text += f"<i>{story.translation}</i>"
text += "\n\n" + "" * 20
return text
def get_story_keyboard(story_id: int, lang: str, show_translation: bool = False) -> InlineKeyboardMarkup:
"""Клавиатура под историей"""
# Кнопка перевода - показать или скрыть
if show_translation:
translation_btn = InlineKeyboardButton(
text=f"🌐 {t(lang, 'story.hide_translation')}",
callback_data=f"story_hide_translation_{story_id}"
)
else:
translation_btn = InlineKeyboardButton(
text=f"🌐 {t(lang, 'story.show_translation')}",
callback_data=f"story_show_translation_{story_id}"
)
return InlineKeyboardMarkup(inline_keyboard=[
[translation_btn],
[
InlineKeyboardButton(
text=f"📝 {t(lang, 'story.questions_btn')}",
callback_data=f"story_questions_{story_id}"
),
],
[
InlineKeyboardButton(
text=f"📚 {t(lang, 'story.vocab_btn')}",
callback_data=f"story_vocab_{story_id}"
),
],
[
InlineKeyboardButton(
text=f"🔄 {t(lang, 'story.new_btn')}",
callback_data="story_new"
),
],
])
@router.message(Command("story"))
async def cmd_story(message: Message, state: FSMContext):
"""Обработчик команды /story"""
await state.clear()
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
if not user:
await message.answer(t('ru', 'common.start_first'))
return
lang = get_user_lang(user)
text = f"📖 <b>{t(lang, 'story.title')}</b>\n\n"
text += t(lang, 'story.choose_genre')
await message.answer(text, reply_markup=get_genre_keyboard(lang))
@router.callback_query(F.data == "story_new")
async def story_new_callback(callback: CallbackQuery, state: FSMContext):
"""Показать выбор жанра для новой истории"""
await callback.answer()
await state.clear()
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user)
text = f"📖 <b>{t(lang, 'story.title')}</b>\n\n"
text += t(lang, 'story.choose_genre')
await callback.message.edit_text(text, reply_markup=get_genre_keyboard(lang))
@router.callback_query(F.data.startswith("story_genre_"))
async def story_genre_callback(callback: CallbackQuery, state: FSMContext):
"""Генерация истории выбранного жанра"""
await callback.answer()
genre = callback.data.replace("story_genre_", "")
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user)
learning_lang = user.learning_language or 'en'
level = get_user_level_for_language(user)
translation_lang = get_user_translation_lang(user)
# Показываем индикатор генерации
await callback.message.edit_text(t(lang, 'story.generating'))
# Получаем количество вопросов из настроек
tasks_count = getattr(user, 'tasks_count', 5) or 5
# Генерируем историю
story_data = await ai_service.generate_mini_story(
genre=genre,
level=level,
learning_lang=learning_lang,
translation_lang=translation_lang,
user_id=user.id,
num_questions=tasks_count
)
if not story_data:
await callback.message.edit_text(
t(lang, 'story.failed'),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=f"🔄 {t(lang, 'story.try_again')}",
callback_data="story_new"
)]
])
)
return
# Сохраняем историю в БД
story = MiniStory(
user_id=user.id,
title=story_data.get('title', 'Story'),
content=story_data.get('content', ''),
translation=story_data.get('translation', ''),
genre=StoryGenre(genre),
learning_lang=learning_lang,
level=level,
word_count=story_data.get('word_count', 0),
vocabulary=story_data.get('vocabulary', []),
questions=story_data.get('questions', [])
)
session.add(story)
await session.commit()
await session.refresh(story)
# Сохраняем ID истории в состоянии
await state.update_data(story_id=story.id)
await state.set_state(StoryStates.reading)
# Показываем историю
text = format_story_text(story, lang)
await callback.message.edit_text(
text,
reply_markup=get_story_keyboard(story.id, lang)
)
@router.callback_query(F.data.startswith("story_show_translation_"))
async def story_show_translation_callback(callback: CallbackQuery):
"""Показать перевод истории"""
await callback.answer()
story_id = int(callback.data.replace("story_show_translation_", ""))
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user)
story = await session.get(MiniStory, story_id)
if not story:
await callback.answer(t(lang, 'story.not_found'), show_alert=True)
return
text = format_story_text(story, lang, show_translation=True)
await callback.message.edit_text(
text,
reply_markup=get_story_keyboard(story.id, lang, show_translation=True)
)
@router.callback_query(F.data.startswith("story_hide_translation_"))
async def story_hide_translation_callback(callback: CallbackQuery):
"""Скрыть перевод истории"""
await callback.answer()
story_id = int(callback.data.replace("story_hide_translation_", ""))
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user)
story = await session.get(MiniStory, story_id)
if not story:
await callback.answer(t(lang, 'story.not_found'), show_alert=True)
return
text = format_story_text(story, lang, show_translation=False)
await callback.message.edit_text(
text,
reply_markup=get_story_keyboard(story.id, lang, show_translation=False)
)
@router.callback_query(F.data.startswith("story_vocab_"))
async def story_vocab_callback(callback: CallbackQuery):
"""Показать словарь истории"""
await callback.answer()
story_id = int(callback.data.replace("story_vocab_", ""))
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user)
story = await session.get(MiniStory, story_id)
if not story:
await callback.answer(t(lang, 'story.not_found'), show_alert=True)
return
vocabulary = story.vocabulary or []
if not vocabulary:
await callback.answer(t(lang, 'story.no_vocab'), show_alert=True)
return
# Формируем текст со словами
text = f"📚 <b>{t(lang, 'story.vocabulary')}</b>\n\n"
keyboard_buttons = []
for i, word_data in enumerate(vocabulary[:10]):
word = word_data.get('word', '')
translation = word_data.get('translation', '')
transcription = word_data.get('transcription', '')
if transcription:
text += f"• <b>{word}</b> [{transcription}] — {translation}\n"
else:
text += f"• <b>{word}</b> — {translation}\n"
# Кнопка добавления слова
keyboard_buttons.append([
InlineKeyboardButton(
text=f" {word}",
callback_data=f"story_addword_{story_id}_{i}"
)
])
# Кнопка "Добавить все"
keyboard_buttons.append([
InlineKeyboardButton(
text=f" {t(lang, 'story.add_all')}",
callback_data=f"story_addall_{story_id}"
)
])
# Кнопка назад
keyboard_buttons.append([
InlineKeyboardButton(
text=f"⬅️ {t(lang, 'story.back')}",
callback_data=f"story_back_{story_id}"
)
])
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
)
@router.callback_query(F.data.startswith("story_addword_"))
async def story_addword_callback(callback: CallbackQuery):
"""Добавить одно слово из истории"""
parts = callback.data.split("_")
story_id = int(parts[2])
word_idx = int(parts[3])
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user)
story = await session.get(MiniStory, story_id)
if not story or not story.vocabulary:
await callback.answer(t(lang, 'story.not_found'), show_alert=True)
return
if word_idx >= len(story.vocabulary):
await callback.answer(t(lang, 'story.word_not_found'), show_alert=True)
return
word_data = story.vocabulary[word_idx]
word = word_data.get('word', '')
translation = word_data.get('translation', '')
transcription = word_data.get('transcription')
# Проверяем, нет ли уже
existing = await VocabularyService.get_word_by_original(
session, user.id, word, source_lang=story.learning_lang
)
if existing:
await callback.answer(t(lang, 'words.already_exists', word=word), show_alert=True)
return
# Добавляем слово
translation_lang = get_user_translation_lang(user)
await VocabularyService.add_word(
session=session,
user_id=user.id,
word_original=word,
word_translation=translation,
source_lang=story.learning_lang,
translation_lang=translation_lang,
transcription=transcription,
difficulty_level=story.level,
source=WordSource.IMPORT
)
await session.commit()
await callback.answer(t(lang, 'story.word_added', word=word), show_alert=True)
@router.callback_query(F.data.startswith("story_addall_"))
async def story_addall_callback(callback: CallbackQuery):
"""Добавить все слова из истории"""
story_id = int(callback.data.replace("story_addall_", ""))
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user)
story = await session.get(MiniStory, story_id)
if not story or not story.vocabulary:
await callback.answer(t(lang, 'story.not_found'), show_alert=True)
return
translation_lang = get_user_translation_lang(user)
added = 0
for word_data in story.vocabulary:
word = word_data.get('word', '')
translation = word_data.get('translation', '')
transcription = word_data.get('transcription')
# Проверяем дубликаты
existing = await VocabularyService.get_word_by_original(
session, user.id, word, source_lang=story.learning_lang
)
if not existing:
await VocabularyService.add_word(
session=session,
user_id=user.id,
word_original=word,
word_translation=translation,
source_lang=story.learning_lang,
translation_lang=translation_lang,
transcription=transcription,
difficulty_level=story.level,
source=WordSource.IMPORT
)
added += 1
await session.commit()
await callback.answer(t(lang, 'story.words_added', n=added), show_alert=True)
@router.callback_query(F.data.startswith("story_back_"))
async def story_back_callback(callback: CallbackQuery):
"""Вернуться к истории"""
await callback.answer()
story_id = int(callback.data.replace("story_back_", ""))
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user)
story = await session.get(MiniStory, story_id)
if not story:
await callback.message.edit_text(t(lang, 'story.not_found'))
return
text = format_story_text(story, lang)
await callback.message.edit_text(
text,
reply_markup=get_story_keyboard(story.id, lang)
)
@router.callback_query(F.data.startswith("story_questions_"))
async def story_questions_callback(callback: CallbackQuery, state: FSMContext):
"""Показать вопросы по истории"""
await callback.answer()
story_id = int(callback.data.replace("story_questions_", ""))
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user)
story = await session.get(MiniStory, story_id)
if not story:
await callback.message.edit_text(t(lang, 'story.not_found'))
return
questions = story.questions or []
if not questions:
await callback.answer(t(lang, 'story.no_questions'), show_alert=True)
return
# Сохраняем состояние
await state.update_data(
story_id=story_id,
current_question=0,
correct_answers=0,
total_questions=len(questions)
)
await state.set_state(StoryStates.questions)
# Показываем первый вопрос
await show_question(callback.message, story, 0, lang, edit=True)
async def show_question(message: Message, story: MiniStory, q_idx: int, lang: str, edit: bool = False):
"""Показать вопрос"""
questions = story.questions or []
if q_idx >= len(questions):
return
q = questions[q_idx]
total = len(questions)
text = f"📝 <b>{t(lang, 'story.question')} {q_idx + 1}/{total}</b>\n\n"
text += f"{q.get('question', '')}\n"
# Кнопки с вариантами ответов
options = q.get('options', [])
keyboard_buttons = []
for i, option in enumerate(options):
keyboard_buttons.append([
InlineKeyboardButton(
text=option,
callback_data=f"story_answer_{story.id}_{q_idx}_{i}"
)
])
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
if edit:
await message.edit_text(text, reply_markup=keyboard)
else:
await message.answer(text, reply_markup=keyboard)
@router.callback_query(F.data.startswith("story_answer_"))
async def story_answer_callback(callback: CallbackQuery, state: FSMContext):
"""Обработка ответа на вопрос"""
parts = callback.data.split("_")
story_id = int(parts[2])
q_idx = int(parts[3])
answer_idx = int(parts[4])
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user)
story = await session.get(MiniStory, story_id)
if not story:
await callback.answer(t(lang, 'story.not_found'), show_alert=True)
return
questions = story.questions or []
if q_idx >= len(questions):
await callback.answer(t(lang, 'story.question_not_found'), show_alert=True)
return
q = questions[q_idx]
correct = q.get('correct', 0)
options = q.get('options', [])
# Получаем данные состояния
data = await state.get_data()
correct_answers = data.get('correct_answers', 0)
total_questions = data.get('total_questions', len(questions))
# Проверяем ответ
is_correct = (answer_idx == correct)
if is_correct:
correct_answers += 1
# Показываем результат ответа
text = f"📝 <b>{t(lang, 'story.question')} {q_idx + 1}/{total_questions}</b>\n\n"
text += f"{q.get('question', '')}\n\n"
for i, option in enumerate(options):
if i == correct:
text += f"{option}\n"
elif i == answer_idx and not is_correct:
text += f"{option}\n"
else:
text += f"{option}\n"
if is_correct:
text += f"\n{t(lang, 'story.correct')}"
await callback.answer("", show_alert=False)
else:
text += f"\n{t(lang, 'story.incorrect')}"
await callback.answer("", show_alert=False)
# Обновляем состояние
await state.update_data(correct_answers=correct_answers)
# Следующий вопрос или результаты
next_q_idx = q_idx + 1
if next_q_idx < total_questions:
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=f"➡️ {t(lang, 'story.next_question')}",
callback_data=f"story_nextq_{story_id}_{next_q_idx}"
)]
])
else:
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=f"📊 {t(lang, 'story.show_results')}",
callback_data=f"story_results_{story_id}_{correct_answers}"
)]
])
await callback.message.edit_text(text, reply_markup=keyboard)
@router.callback_query(F.data.startswith("story_nextq_"))
async def story_nextq_callback(callback: CallbackQuery, state: FSMContext):
"""Следующий вопрос"""
await callback.answer()
parts = callback.data.split("_")
story_id = int(parts[2])
q_idx = int(parts[3])
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user)
story = await session.get(MiniStory, story_id)
if not story:
await callback.message.edit_text(t(lang, 'story.not_found'))
return
await show_question(callback.message, story, q_idx, lang, edit=True)
@router.callback_query(F.data.startswith("story_results_"))
async def story_results_callback(callback: CallbackQuery, state: FSMContext):
"""Показать результаты"""
await callback.answer()
await state.clear()
parts = callback.data.split("_")
story_id = int(parts[2])
correct_answers = int(parts[3])
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user)
story = await session.get(MiniStory, story_id)
if not story:
await callback.message.edit_text(t(lang, 'story.not_found'))
return
total = len(story.questions or [])
# Обновляем статус истории
story.is_completed = True
story.correct_answers = correct_answers
await session.commit()
# Определяем эмодзи по результату
percentage = (correct_answers / total * 100) if total > 0 else 0
if percentage >= 80:
emoji = "🎉"
comment = t(lang, 'story.result_excellent')
elif percentage >= 50:
emoji = "👍"
comment = t(lang, 'story.result_good')
else:
emoji = "📚"
comment = t(lang, 'story.result_practice')
text = f"{emoji} <b>{t(lang, 'story.results_title')}</b>\n\n"
text += f"📖 {story.title}\n\n"
text += f"{t(lang, 'story.correct_answers')}: <b>{correct_answers}/{total}</b>\n"
text += f"{t(lang, 'story.accuracy')}: <b>{percentage:.0f}%</b>\n\n"
text += f"<i>{comment}</i>"
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=f"📚 {t(lang, 'story.vocab_btn')}",
callback_data=f"story_vocab_{story_id}"
),
],
[
InlineKeyboardButton(
text=f"🔄 {t(lang, 'story.new_btn')}",
callback_data="story_new"
),
],
])
await callback.message.edit_text(text, reply_markup=keyboard)

View File

@@ -18,12 +18,13 @@ class AddWordStates(StatesGroup):
"""Состояния для добавления слова"""
waiting_for_confirmation = State()
waiting_for_word = State()
viewing_batch = State() # Просмотр списка слов для batch-добавления
@router.message(Command("add"))
async def cmd_add(message: Message, state: FSMContext):
"""Обработчик команды /add [слово]"""
# Получаем слово из команды
"""Обработчик команды /add [слово] или /add [слово1, слово2, ...]"""
# Получаем слово(а) из команды
parts = message.text.split(maxsplit=1)
if len(parts) < 2:
@@ -34,15 +35,32 @@ async def cmd_add(message: Message, state: FSMContext):
await state.set_state(AddWordStates.waiting_for_word)
return
word = parts[1].strip()
await process_word_addition(message, state, word)
text = parts[1].strip()
# Проверяем, есть ли несколько слов (через запятую)
if ',' in text:
words = [w.strip() for w in text.split(',') if w.strip()]
if len(words) > 1:
await process_batch_addition(message, state, words)
return
# Одно слово - стандартная обработка
await process_word_addition(message, state, text)
@router.message(AddWordStates.waiting_for_word)
async def process_word_input(message: Message, state: FSMContext):
"""Обработка ввода слова"""
word = message.text.strip()
await process_word_addition(message, state, word)
"""Обработка ввода слова или нескольких слов"""
text = message.text.strip()
# Проверяем, есть ли несколько слов (через запятую)
if ',' in text:
words = [w.strip() for w in text.split(',') if w.strip()]
if len(words) > 1:
await process_batch_addition(message, state, words)
return
await process_word_addition(message, state, text)
async def process_word_addition(message: Message, state: FSMContext, word: str):
@@ -188,6 +206,201 @@ async def cancel_add_word(callback: CallbackQuery, state: FSMContext):
await callback.answer()
# === Batch добавление нескольких слов ===
async def process_batch_addition(message: Message, state: FSMContext, words: list[str]):
"""Обработка добавления нескольких слов"""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
if not user:
await message.answer(t('ru', 'common.start_first'))
return
lang = get_user_lang(user)
# Ограничиваем количество слов
if len(words) > 20:
words = words[:20]
await message.answer(t(lang, 'add_batch.truncated', n=20))
# Показываем индикатор загрузки
processing_msg = await message.answer(t(lang, 'add_batch.translating', n=len(words)))
# Получаем переводы через AI batch-методом
source_lang = user.learning_language or 'en'
translation_lang = get_user_translation_lang(user)
translated_words = await ai_service.translate_words_batch(
words=words,
source_lang=source_lang,
translation_lang=translation_lang,
user_id=user.id
)
await processing_msg.delete()
if not translated_words:
await message.answer(t(lang, 'add_batch.failed'))
return
# Сохраняем данные в состоянии
await state.update_data(
batch_words=translated_words,
user_id=user.id
)
await state.set_state(AddWordStates.viewing_batch)
# Показываем список слов
await show_batch_words(message, translated_words, lang)
async def show_batch_words(message: Message, words: list, lang: str):
"""Показать список слов для batch-добавления"""
text = t(lang, 'add_batch.header', n=len(words)) + "\n\n"
for idx, word_data in enumerate(words, 1):
word = word_data.get('word', '')
translation = word_data.get('translation', '')
transcription = word_data.get('transcription', '')
line = f"{idx}. <b>{word}</b>"
if transcription:
line += f" [{transcription}]"
line += f"\n {translation}\n"
text += line
text += "\n" + t(lang, 'add_batch.choose')
# Создаем кнопки для каждого слова (по 2 в ряд)
keyboard = []
for idx, word_data in enumerate(words):
button = InlineKeyboardButton(
text=f" {word_data.get('word', '')[:15]}",
callback_data=f"batch_word_{idx}"
)
if len(keyboard) == 0 or len(keyboard[-1]) == 2:
keyboard.append([button])
else:
keyboard[-1].append(button)
# Кнопка "Добавить все"
keyboard.append([
InlineKeyboardButton(text=t(lang, 'words.add_all_btn'), callback_data="batch_add_all")
])
# Кнопка "Закрыть"
keyboard.append([
InlineKeyboardButton(text=t(lang, 'words.close_btn'), callback_data="batch_close")
])
reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
await message.answer(text, reply_markup=reply_markup)
@router.callback_query(F.data.startswith("batch_word_"), AddWordStates.viewing_batch)
async def batch_add_single(callback: CallbackQuery, state: FSMContext):
"""Добавить одно слово из batch"""
await callback.answer()
word_index = int(callback.data.split("_")[2])
data = await state.get_data()
words = data.get('batch_words', [])
user_id = data.get('user_id')
if word_index >= len(words):
return
word_data = words[word_index]
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user)
# Проверяем, нет ли уже такого слова
existing = await VocabularyService.get_word_by_original(
session, user_id, word_data.get('word', ''), source_lang=user.learning_language
)
if existing:
await callback.answer(t(lang, 'words.already_exists', word=word_data.get('word', '')), show_alert=True)
return
# Добавляем слово
translation_lang = get_user_translation_lang(user)
await VocabularyService.add_word(
session=session,
user_id=user_id,
word_original=word_data.get('word', ''),
word_translation=word_data.get('translation', ''),
source_lang=user.learning_language,
translation_lang=translation_lang,
transcription=word_data.get('transcription'),
source=WordSource.MANUAL
)
await callback.message.answer(t(lang, 'words.added_single', word=word_data.get('word', '')))
@router.callback_query(F.data == "batch_add_all", AddWordStates.viewing_batch)
async def batch_add_all(callback: CallbackQuery, state: FSMContext):
"""Добавить все слова из batch"""
await callback.answer()
data = await state.get_data()
words = data.get('batch_words', [])
user_id = data.get('user_id')
added_count = 0
skipped_count = 0
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
for word_data in words:
# Проверяем, нет ли уже такого слова
existing = await VocabularyService.get_word_by_original(
session, user_id, word_data.get('word', ''), source_lang=user.learning_language
)
if existing:
skipped_count += 1
continue
# Добавляем слово
translation_lang = get_user_translation_lang(user)
await VocabularyService.add_word(
session=session,
user_id=user_id,
word_original=word_data.get('word', ''),
word_translation=word_data.get('translation', ''),
source_lang=user.learning_language,
translation_lang=translation_lang,
transcription=word_data.get('transcription'),
source=WordSource.MANUAL
)
added_count += 1
lang = get_user_lang(user)
result_text = t(lang, 'import.added_count', n=added_count)
if skipped_count > 0:
result_text += "\n" + t(lang, 'import.skipped_count', n=skipped_count)
await callback.message.edit_reply_markup(reply_markup=None)
await callback.message.answer(result_text)
await state.clear()
@router.callback_query(F.data == "batch_close", AddWordStates.viewing_batch)
async def batch_close(callback: CallbackQuery, state: FSMContext):
"""Закрыть batch добавление"""
await callback.message.delete()
await state.clear()
await callback.answer()
WORDS_PER_PAGE = 10

139
bot/handlers/wordofday.py Normal file
View File

@@ -0,0 +1,139 @@
"""Handler для функции 'Слово дня'."""
from datetime import datetime
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from database.db import async_session_maker
from database.models import WordOfDay, WordSource
from services.user_service import UserService
from services.vocabulary_service import VocabularyService
from services.wordofday_service import wordofday_service
from utils.i18n import t, get_user_lang, get_user_translation_lang
from utils.levels import get_user_level_for_language
router = Router()
def format_word_of_day(wod: WordOfDay, lang: str) -> str:
"""Форматировать слово дня для отображения."""
date_str = wod.date.strftime("%d.%m.%Y")
text = f"🌅 <b>{t(lang, 'wod.title')}</b> — {date_str}\n\n"
text += f"📝 <b>{wod.word}</b>\n"
if wod.transcription:
text += f"🔊 [{wod.transcription}]\n"
text += f"\n💬 {wod.translation}\n"
# Примеры
if wod.examples:
text += f"\n📖 <b>{t(lang, 'wod.examples')}:</b>\n"
for ex in wod.examples[:2]:
sentence = ex.get('sentence', '')
translation = ex.get('translation', '')
text += f"• <i>{sentence}</i>\n"
if translation:
text += f" ({translation})\n"
# Синонимы
if wod.synonyms:
text += f"\n🔗 <b>{t(lang, 'wod.synonyms')}:</b> {wod.synonyms}\n"
# Этимология/интересный факт
if wod.etymology:
text += f"\n💡 {wod.etymology}\n"
return text
@router.message(Command("wordofday"))
async def cmd_wordofday(message: Message):
"""Обработчик команды /wordofday."""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
if not user:
await message.answer(t('ru', 'common.start_first'))
return
lang = get_user_lang(user)
learning_lang = user.learning_language or 'en'
level = get_user_level_for_language(user)
# Получаем слово дня из глобальной таблицы
wod = await wordofday_service.get_word_of_day(
learning_lang=learning_lang,
level=level
)
if not wod:
# Слово ещё не сгенерировано - показываем сообщение
await message.answer(t(lang, 'wod.not_available'))
return
# Форматируем и отправляем
text = format_word_of_day(wod, lang)
# Кнопка добавления в словарь
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=t(lang, 'wod.add_btn'),
callback_data=f"wod_add_{wod.id}"
)]
])
await message.answer(text, reply_markup=keyboard)
@router.callback_query(F.data.startswith("wod_add_"))
async def wod_add_callback(callback: CallbackQuery):
"""Добавить слово дня в словарь."""
await callback.answer()
wod_id = int(callback.data.replace("wod_add_", ""))
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user)
# Получаем слово дня
wod = await session.get(WordOfDay, wod_id)
if not wod:
await callback.answer(t(lang, 'wod.not_found'), show_alert=True)
return
# Проверяем, нет ли уже в словаре
existing = await VocabularyService.get_word_by_original(
session, user.id, wod.word, source_lang=wod.learning_lang
)
if existing:
await callback.answer(t(lang, 'words.already_exists', word=wod.word), show_alert=True)
return
# Добавляем в словарь
translation_lang = get_user_translation_lang(user)
await VocabularyService.add_word(
session=session,
user_id=user.id,
word_original=wod.word,
word_translation=wod.translation,
source_lang=wod.learning_lang,
translation_lang=translation_lang,
transcription=wod.transcription,
difficulty_level=wod.level,
source=WordSource.SUGGESTED
)
await session.commit()
# Обновляем сообщение
text = format_word_of_day(wod, lang)
text += f"\n✅ <i>{t(lang, 'wod.added')}</i>"
await callback.message.edit_text(text, reply_markup=None)

1
data/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Data module for grammar rules and other static data

321
data/grammar_rules.py Normal file
View File

@@ -0,0 +1,321 @@
"""
Грамматические правила для упражнений по уровням CEFR (английский) и JLPT (японский).
Данные основаны на официальных требованиях CEFR и JLPT.
"""
# Английский язык - CEFR уровни
ENGLISH_GRAMMAR_RULES = {
"A1": [
{"id": "present_simple", "name": "Present Simple", "name_ru": "Простое настоящее время", "description": "I work, he works"},
{"id": "present_continuous", "name": "Present Continuous", "name_ru": "Настоящее длительное", "description": "I am working"},
{"id": "present_perfect", "name": "Present Perfect", "name_ru": "Настоящее совершенное", "description": "I have worked"},
{"id": "past_simple", "name": "Past Simple", "name_ru": "Простое прошедшее", "description": "I worked, I went"},
{"id": "irregular_verbs", "name": "Irregular verbs", "name_ru": "Неправильные глаголы", "description": "go-went-gone, see-saw-seen"},
{"id": "future_will", "name": "Future with will", "name_ru": "Будущее с will", "description": "I will work"},
{"id": "be_verb", "name": "Verb 'to be'", "name_ru": "Глагол to be", "description": "am, is, are"},
{"id": "have_got", "name": "Have got", "name_ru": "Have got", "description": "I have got / I've got"},
{"id": "there_is_are", "name": "There is/are", "name_ru": "There is/are", "description": "There is a book, there are books"},
{"id": "articles_basic", "name": "Articles (basic)", "name_ru": "Артикли (базовые)", "description": "a, an, the"},
{"id": "possessive_adj", "name": "Possessive adjectives", "name_ru": "Притяжательные прил.", "description": "my, your, his, her"},
{"id": "object_pronouns", "name": "Object pronouns", "name_ru": "Объектные местоимения", "description": "me, you, him, her"},
{"id": "demonstratives", "name": "Demonstratives", "name_ru": "Указательные мест.", "description": "this, that, these, those"},
{"id": "countable_uncountable", "name": "Countable/Uncountable", "name_ru": "Исчисл./неисчисляемые", "description": "many/much, some/any"},
{"id": "imperatives", "name": "Imperatives", "name_ru": "Повелительное накл.", "description": "Go!, Don't go!"},
{"id": "can_ability", "name": "Can (ability)", "name_ru": "Can (способность)", "description": "I can swim"},
{"id": "prepositions_place", "name": "Prepositions of place", "name_ru": "Предлоги места", "description": "in, on, at, under"},
{"id": "prepositions_time", "name": "Prepositions of time", "name_ru": "Предлоги времени", "description": "in, on, at (time)"},
{"id": "adjective_order", "name": "Adjective order", "name_ru": "Порядок прилагательных", "description": "a big red car"},
{"id": "adverbs_frequency", "name": "Adverbs of frequency", "name_ru": "Наречия частотности", "description": "always, usually, never"},
{"id": "how_much_many", "name": "How much/many", "name_ru": "How much/many", "description": "How much/How many"},
],
"A2": [
{"id": "past_continuous", "name": "Past Continuous", "name_ru": "Прошедшее длительное", "description": "I was working"},
{"id": "present_perfect_ever", "name": "Present Perfect + ever/never", "name_ru": "Present Perfect + ever/never", "description": "Have you ever...?"},
{"id": "going_to", "name": "Going to (future)", "name_ru": "Going to (будущее)", "description": "I'm going to work"},
{"id": "comparatives", "name": "Comparatives", "name_ru": "Сравнительная степень", "description": "bigger, more expensive"},
{"id": "superlatives", "name": "Superlatives", "name_ru": "Превосходная степень", "description": "the biggest, the most"},
{"id": "must_obligation", "name": "Must (obligation)", "name_ru": "Must (обязательство)", "description": "You must go"},
{"id": "should_advice", "name": "Should (advice)", "name_ru": "Should (совет)", "description": "You should go"},
{"id": "have_to", "name": "Have to", "name_ru": "Have to", "description": "I have to work"},
{"id": "could_past", "name": "Could (past ability)", "name_ru": "Could (прошлая способ.)", "description": "I could swim when I was 5"},
{"id": "would_like", "name": "Would like", "name_ru": "Would like", "description": "I would like to..."},
{"id": "first_conditional", "name": "First Conditional", "name_ru": "Условные 1 типа", "description": "If it rains, I will stay"},
{"id": "gerund_verb", "name": "Gerund after verbs", "name_ru": "Герундий после глаголов", "description": "I enjoy swimming"},
{"id": "infinitive_verb", "name": "Infinitive after verbs", "name_ru": "Инфинитив после глаг.", "description": "I want to go"},
{"id": "adverbs_manner", "name": "Adverbs of manner", "name_ru": "Наречия образа действия", "description": "quickly, slowly"},
{"id": "possessive_pronouns", "name": "Possessive pronouns", "name_ru": "Притяжательные мест.", "description": "mine, yours, his"},
{"id": "relative_who_which", "name": "Relative clauses (who/which)", "name_ru": "Относительные (who/which)", "description": "The man who..."},
{"id": "too_enough", "name": "Too/Enough", "name_ru": "Too/Enough", "description": "too big, big enough"},
],
"B1": [
{"id": "past_perfect", "name": "Past Perfect", "name_ru": "Прошедшее совершенное", "description": "I had worked"},
{"id": "present_perfect_cont", "name": "Present Perfect Continuous", "name_ru": "Наст. соверш. длит.", "description": "I have been working"},
{"id": "future_continuous", "name": "Future Continuous", "name_ru": "Будущее длительное", "description": "I will be working"},
{"id": "second_conditional", "name": "Second Conditional", "name_ru": "Условные 2 типа", "description": "If I had money, I would buy"},
{"id": "passive_simple", "name": "Passive Voice (simple)", "name_ru": "Страдательный залог", "description": "The book was written"},
{"id": "reported_speech", "name": "Reported Speech", "name_ru": "Косвенная речь", "description": "He said that..."},
{"id": "used_to", "name": "Used to", "name_ru": "Used to", "description": "I used to play"},
{"id": "modals_possibility", "name": "Modals of possibility", "name_ru": "Модальные (возможность)", "description": "might, may, could"},
{"id": "relative_clauses_def", "name": "Defining relative clauses", "name_ru": "Определяющие придат.", "description": "The book that I read"},
{"id": "question_tags", "name": "Question tags", "name_ru": "Разделительные вопросы", "description": "You like it, don't you?"},
{"id": "so_neither", "name": "So/Neither", "name_ru": "So/Neither", "description": "So do I, Neither do I"},
{"id": "phrasal_verbs_basic", "name": "Phrasal verbs (basic)", "name_ru": "Фразовые глаголы", "description": "get up, look for"},
{"id": "wish_present", "name": "Wish + past simple", "name_ru": "Wish (настоящее)", "description": "I wish I had..."},
],
"B2": [
{"id": "past_perfect_cont", "name": "Past Perfect Continuous", "name_ru": "Прошедшее соверш. длит.", "description": "I had been working"},
{"id": "future_perfect", "name": "Future Perfect", "name_ru": "Будущее совершенное", "description": "I will have finished"},
{"id": "future_perfect_cont", "name": "Future Perfect Continuous", "name_ru": "Будущее соверш. длит.", "description": "I will have been working"},
{"id": "third_conditional", "name": "Third Conditional", "name_ru": "Условные 3 типа", "description": "If I had known, I would have..."},
{"id": "mixed_conditionals", "name": "Mixed Conditionals", "name_ru": "Смешанные условные", "description": "If I had studied, I would be..."},
{"id": "passive_all_tenses", "name": "Passive (all tenses)", "name_ru": "Страд. залог (все времена)", "description": "is being done, had been done"},
{"id": "causative_have", "name": "Causative (have/get)", "name_ru": "Каузатив", "description": "I had my hair cut"},
{"id": "relative_non_defining", "name": "Non-defining relatives", "name_ru": "Неопред. придаточные", "description": "My sister, who lives..."},
{"id": "inversion", "name": "Inversion", "name_ru": "Инверсия", "description": "Never have I seen..."},
{"id": "wish_past", "name": "Wish + past perfect", "name_ru": "Wish (прошедшее)", "description": "I wish I had done..."},
{"id": "modals_deduction", "name": "Modals of deduction", "name_ru": "Модальные (вывод)", "description": "must be, can't be, might have"},
{"id": "emphasis_cleft", "name": "Cleft sentences", "name_ru": "Расщепленные предл.", "description": "It was John who..."},
{"id": "participle_clauses", "name": "Participle clauses", "name_ru": "Причастные обороты", "description": "Having finished, I left"},
],
"C1": [
{"id": "subjunctive", "name": "Subjunctive mood", "name_ru": "Сослагательное накл.", "description": "I suggest that he go..."},
{"id": "inversion_negative", "name": "Inversion (negative adverbials)", "name_ru": "Инверсия (отриц.)", "description": "Rarely do I..."},
{"id": "ellipsis", "name": "Ellipsis", "name_ru": "Эллипсис", "description": "I can and will do it"},
{"id": "fronting", "name": "Fronting", "name_ru": "Фронтинг", "description": "Strange though it may seem"},
{"id": "nominal_relative", "name": "Nominal relative clauses", "name_ru": "Номинальные придат.", "description": "What I need is..."},
{"id": "advanced_passives", "name": "Advanced passives", "name_ru": "Сложный страд. залог", "description": "It is said that..."},
{"id": "discourse_markers", "name": "Discourse markers", "name_ru": "Дискурсивные маркеры", "description": "nevertheless, furthermore"},
],
"C2": [
{"id": "advanced_inversion", "name": "Advanced inversion", "name_ru": "Продвинутая инверсия", "description": "So surprised was I that..."},
{"id": "complex_passives", "name": "Complex passive structures", "name_ru": "Сложные страд. структуры", "description": "He is believed to have..."},
{"id": "idiomatic_subjunctive", "name": "Idiomatic subjunctive", "name_ru": "Идиом. сослагательное", "description": "Be that as it may..."},
{"id": "advanced_conditionals", "name": "Advanced conditionals", "name_ru": "Продвинутые условные", "description": "Were it not for..."},
{"id": "nuanced_modals", "name": "Nuanced modals", "name_ru": "Нюансы модальных", "description": "could well be, might just"},
],
}
# Японский язык - JLPT уровни (N5-N1)
JAPANESE_GRAMMAR_RULES = {
"N5": [
# Частицы
{"id": "particle_wa", "name": "Частица は (тема)", "name_ru": "Частица は (тема)", "description": "わたしは学生です"},
{"id": "particle_ga", "name": "Частица が (подлежащее)", "name_ru": "Частица が (подлежащее)", "description": "ねこがいます"},
{"id": "particle_wo", "name": "Частица を (объект)", "name_ru": "Частица を (объект)", "description": "りんごをたべます"},
{"id": "particle_ni", "name": "Частица に (направление/время)", "name_ru": "Частица に (направление/время)", "description": "がっこうにいきます、7じにおきます"},
{"id": "particle_de", "name": "Частица で (место/средство)", "name_ru": "Частица で (место/средство)", "description": "でんしゃでいきます"},
{"id": "particle_he", "name": "Частица へ (направление)", "name_ru": "Частица へ (направление)", "description": "にほんへいきます"},
{"id": "particle_to", "name": "Частица と (и/с)", "name_ru": "Частица と (и/с)", "description": "ともだちといきます"},
{"id": "particle_mo", "name": "Частица も (тоже)", "name_ru": "Частица も (тоже)", "description": "わたしもいきます"},
{"id": "particle_no", "name": "Частица の (притяжание)", "name_ru": "Частица の (притяжание)", "description": "わたしのほん"},
{"id": "particle_ka", "name": "Частица か (вопрос)", "name_ru": "Частица か (вопрос)", "description": "これはなんですか"},
{"id": "particle_ne_yo", "name": "Частицы ね/よ", "name_ru": "Частицы ね/よ", "description": "いいですね、いきますよ"},
# Глаголы
{"id": "verb_masu", "name": "ます-форма глаголов", "name_ru": "ます-форма глаголов", "description": "たべます、のみます"},
{"id": "verb_te", "name": "て-форма глаголов", "name_ru": "て-форма глаголов", "description": "たべて、のんで、いって"},
{"id": "verb_ta", "name": "た-форма (прошедшее)", "name_ru": "た-форма (прошедшее)", "description": "たべた、のんだ"},
{"id": "verb_nai", "name": "ない-форма (отрицание)", "name_ru": "ない-форма (отрицание)", "description": "たべない、のまない"},
{"id": "verb_te_kudasai", "name": "てください (просьба)", "name_ru": "てください (просьба)", "description": "たべてください"},
{"id": "verb_te_imasu", "name": "ています (длительное)", "name_ru": "ています (длительное)", "description": "たべています"},
{"id": "verb_tai", "name": "たい (хочу)", "name_ru": "たい (хочу)", "description": "たべたい、いきたい"},
{"id": "verb_mashou", "name": "ましょう (предложение)", "name_ru": "ましょう (предложение)", "description": "いきましょう"},
# Прилагательные
{"id": "adj_i", "name": "い-прилагательные", "name_ru": "い-прилагательные", "description": "おおきい、ちいさい、あたらしい"},
{"id": "adj_na", "name": "な-прилагательные", "name_ru": "な-прилагательные", "description": "きれいな、しずかな、げんきな"},
{"id": "adj_past", "name": "Прошедшее время прил.", "name_ru": "Прошедшее время прилагательных", "description": "おおきかった、しずかだった"},
{"id": "adj_negative", "name": "Отрицание прилагательных", "name_ru": "Отрицание прилагательных", "description": "おおきくない、しずかじゃない"},
# Базовые конструкции
{"id": "desu_da", "name": "です/だ (связка)", "name_ru": "です/だ (связка)", "description": "がくせいです"},
{"id": "aru_iru", "name": "ある/いる (существование)", "name_ru": "ある/いる (существование)", "description": "ほんがあります、ねこがいます"},
{"id": "kara_made", "name": "から/まで (от/до)", "name_ru": "から/まで (от/до)", "description": "9じから5じまで"},
{"id": "kara_reason", "name": "から (причина)", "name_ru": "から (причина)", "description": "あついからまどをあけます"},
{"id": "ga_hoshii", "name": "がほしい (хочу что-то)", "name_ru": "がほしい (хочу что-то)", "description": "みずがほしいです"},
{"id": "counters_basic", "name": "Счётные слова (базовые)", "name_ru": "Счётные слова (базовые)", "description": "ひとつ、ふたり、さんぼん"},
],
"N4": [
# Формы глаголов
{"id": "potential", "name": "Потенциальная форма", "name_ru": "Потенциальная форма (できる)", "description": "たべられる、のめる、できる"},
{"id": "volitional", "name": "Волитивная форма (よう)", "name_ru": "Волитивная форма (よう)", "description": "たべよう、いこう"},
{"id": "imperative", "name": "Повелительная форма", "name_ru": "Повелительная форма", "description": "たべろ、いけ、するな"},
{"id": "passive", "name": "Страдательный залог", "name_ru": "Страдательный залог (られる)", "description": "たべられる、よまれる"},
{"id": "causative", "name": "Каузатив (させる)", "name_ru": "Каузатив (させる)", "description": "たべさせる、いかせる"},
# Условные формы
{"id": "cond_ba", "name": "Условие ~ば", "name_ru": "Условие ~ば", "description": "たべれば、いけば"},
{"id": "cond_tara", "name": "Условие ~たら", "name_ru": "Условие ~たら", "description": "たべたら、いったら"},
{"id": "cond_nara", "name": "Условие ~なら", "name_ru": "Условие ~なら", "description": "いくなら、たかいなら"},
{"id": "cond_to", "name": "Условие ~と", "name_ru": "Условие ~と", "description": "ボタンをおすとドアがあく"},
# Грамматические конструкции
{"id": "te_kara", "name": "てから (после того как)", "name_ru": "てから (после того как)", "description": "たべてからでかけます"},
{"id": "te_shimau", "name": "てしまう (завершение/сожаление)", "name_ru": "てしまう (завершение/сожаление)", "description": "たべてしまった、わすれてしまった"},
{"id": "te_oku", "name": "ておく (заранее)", "name_ru": "ておく (заранее)", "description": "よやくしておきます"},
{"id": "te_aru", "name": "てある (результат)", "name_ru": "てある (результат)", "description": "まどがあけてある"},
{"id": "te_miru", "name": "てみる (попробовать)", "name_ru": "てみる (попробовать)", "description": "たべてみる"},
{"id": "te_iku_kuru", "name": "ていく/てくる", "name_ru": "ていく/てくる", "description": "もっていく、かってくる"},
{"id": "te_ageru_morau_kureru", "name": "てあげる/もらう/くれる", "name_ru": "てあげる/もらう/くれる", "description": "おしえてあげる"},
{"id": "you_ni_naru", "name": "ようになる (стать)", "name_ru": "ようになる (стать)", "description": "はなせるようになった"},
{"id": "you_ni_suru", "name": "ようにする (стараться)", "name_ru": "ようにする (стараться)", "description": "はやくねるようにする"},
{"id": "sou_appearance", "name": "そう (выглядит)", "name_ru": "そう (выглядит)", "description": "おいしそう、ふりそう"},
{"id": "sou_hearsay", "name": "そうだ (говорят)", "name_ru": "そうだ (говорят)", "description": "あめがふるそうだ"},
{"id": "rashii", "name": "らしい (похоже)", "name_ru": "らしい (похоже/типично)", "description": "かれはびょうきらしい"},
{"id": "noni", "name": "のに (хотя)", "name_ru": "のに (хотя)", "description": "いったのにいなかった"},
{"id": "tame_ni", "name": "ために (для/из-за)", "name_ru": "ために (для/из-за)", "description": "けんこうのためにうんどうする"},
{"id": "node", "name": "ので (потому что)", "name_ru": "ので (потому что)", "description": "あめなのでいかない"},
],
"N3": [
# Сложные формы глаголов
{"id": "causative_passive", "name": "Каузатив-пассив", "name_ru": "Каузатив-пассив (させられる)", "description": "たべさせられる"},
{"id": "passive_adversative", "name": "Адверсатив (страдание)", "name_ru": "Адверсативный пассив", "description": "あめにふられた"},
# Грамматические конструкции
{"id": "wake", "name": "わけ (смысл/причина)", "name_ru": "わけ (смысл/причина)", "description": "いくわけにはいかない"},
{"id": "wake_da", "name": "わけだ (выходит что)", "name_ru": "わけだ (выходит что)", "description": "だからつかれたわけだ"},
{"id": "hazu", "name": "はず (должно быть)", "name_ru": "はず (должно быть)", "description": "もうついたはずだ"},
{"id": "beki", "name": "べき (следует)", "name_ru": "べき (следует)", "description": "いくべきだ"},
{"id": "koto_ni_naru", "name": "ことになる (решено)", "name_ru": "ことになる (решено)", "description": "にほんにいくことになった"},
{"id": "koto_ni_suru", "name": "ことにする (решить)", "name_ru": "ことにする (решить)", "description": "やめることにした"},
{"id": "koto_ga_aru", "name": "ことがある (бывает/опыт)", "name_ru": "ことがある (бывает/опыт)", "description": "いったことがある"},
{"id": "koto_ga_dekiru", "name": "ことができる (можно)", "name_ru": "ことができる (можно)", "description": "およぐことができる"},
{"id": "mono", "name": "もの/もん (ведь)", "name_ru": "もの/もん (ведь)", "description": "だってすきなんだもん"},
{"id": "bakari", "name": "ばかり (только/недавно)", "name_ru": "ばかり (только/недавно)", "description": "きたばかり、ゲームばかり"},
{"id": "to_iu", "name": "という (называемый)", "name_ru": "という (называемый)", "description": "さくらというはな"},
{"id": "you_da", "name": "ようだ (похоже)", "name_ru": "ようだ (похоже)", "description": "かれはつかれているようだ"},
{"id": "mitai", "name": "みたい (как будто)", "name_ru": "みたい (как будто)", "description": "ゆめみたい"},
{"id": "tsumori", "name": "つもり (намерение)", "name_ru": "つもり (намерение)", "description": "いくつもりだ"},
{"id": "tokoro", "name": "ところ (момент)", "name_ru": "ところ (момент)", "description": "いまでかけるところ"},
{"id": "aida", "name": "間/間に (пока)", "name_ru": "間/間に (пока)", "description": "ねているあいだに"},
{"id": "nagara", "name": "ながら (одновременно)", "name_ru": "ながら (одновременно)", "description": "あるきながらはなす"},
{"id": "toki", "name": "とき (когда)", "name_ru": "とき (когда)", "description": "いくとき、いったとき"},
{"id": "ba_ii", "name": "ばいい (достаточно)", "name_ru": "ばいい (достаточно)", "description": "いけばいい"},
{"id": "tara_ii", "name": "たらいい (лучше бы)", "name_ru": "たらいい (лучше бы)", "description": "いったらいいのに"},
# Выражения
{"id": "te_hoshii", "name": "てほしい (хочу чтобы)", "name_ru": "てほしい (хочу чтобы)", "description": "きてほしい"},
{"id": "te_naranai", "name": "てならない (очень)", "name_ru": "てならない (очень)", "description": "うれしくてならない"},
{"id": "te_tamaranai", "name": "てたまらない (невыносимо)", "name_ru": "てたまらない (невыносимо)", "description": "あつくてたまらない"},
],
"N2": [
# Продвинутая грамматика
{"id": "zaru_wo_enai", "name": "ざるを得ない (вынужден)", "name_ru": "ざるを得ない (вынужден)", "description": "いかざるをえない"},
{"id": "nai_wake_ni_wa_ikanai", "name": "ないわけにはいかない", "name_ru": "ないわけにはいかない", "description": "いかないわけにはいかない"},
{"id": "ppoi", "name": "っぽい (похожий на)", "name_ru": "っぽい (похожий на)", "description": "こどもっぽい、あきっぽい"},
{"id": "gachi", "name": "がち (склонен)", "name_ru": "がち (склонен)", "description": "びょうきがち、わすれがち"},
{"id": "gimi", "name": "ぎみ (слегка)", "name_ru": "ぎみ (слегка)", "description": "つかれぎみ、かぜぎみ"},
{"id": "kke", "name": "っけ (кажется/вспомнил)", "name_ru": "っけ (кажется/вспомнил)", "description": "なんだっけ"},
{"id": "dokoro_ka", "name": "どころか (не то что)", "name_ru": "どころか (не то что)", "description": "かんたんどころかむずかしい"},
{"id": "koso", "name": "こそ (именно)", "name_ru": "こそ (именно)", "description": "こちらこそ、いまこそ"},
{"id": "sae", "name": "さえ (даже)", "name_ru": "さえ (даже)", "description": "こどもでさえしっている"},
{"id": "sae_ba", "name": "さえ~ば (если только)", "name_ru": "さえ~ば (если только)", "description": "おかねさえあればいい"},
{"id": "shika_nai", "name": "しかない (только/приходится)", "name_ru": "しかない (только/приходится)", "description": "いくしかない"},
{"id": "to_wa_kagiranai", "name": "とは限らない (не обязательно)", "name_ru": "とは限らない (не обязательно)", "description": "たかいとはかぎらない"},
{"id": "mono_nara", "name": "ものなら (если бы)", "name_ru": "ものなら (если бы)", "description": "できるものならやってみろ"},
{"id": "mono_da", "name": "ものだ (так уж устроено)", "name_ru": "ものだ (так уж устроено)", "description": "むかしはよくいったものだ"},
{"id": "mono_no", "name": "ものの (хотя)", "name_ru": "ものの (хотя)", "description": "かったものの、つかわない"},
# Формальный стиль
{"id": "ni_tsuite", "name": "について (о/насчёт)", "name_ru": "について (о/насчёт)", "description": "にほんについて"},
{"id": "ni_totte", "name": "にとって (для кого)", "name_ru": "にとって (для кого)", "description": "わたしにとって"},
{"id": "ni_yotte", "name": "によって (в зависимости)", "name_ru": "によって (в зависимости)", "description": "ひとによってちがう"},
{"id": "ni_oite", "name": "において (в/на)", "name_ru": "において (в/на)", "description": "かいぎにおいて"},
{"id": "ni_kanshite", "name": "に関して (касательно)", "name_ru": "に関して (касательно)", "description": "じけんにかんして"},
{"id": "ni_okeru", "name": "における (в контексте)", "name_ru": "における (в контексте)", "description": "にほんにおけるもんだい"},
{"id": "to_shite", "name": "として (в качестве)", "name_ru": "として (в качестве)", "description": "がくせいとして"},
{"id": "keigo_sonkei", "name": "Уважительное кейго", "name_ru": "Уважительное кейго (尊敬語)", "description": "いらっしゃる、おっしゃる"},
{"id": "keigo_kenjou", "name": "Скромное кейго", "name_ru": "Скромное кейго (謙譲語)", "description": "いたす、もうす、まいる"},
],
"N1": [
# Литературные и архаичные формы
{"id": "de_aru", "name": "である (письм. связка)", "name_ru": "である (письменная связка)", "description": "これは本である"},
{"id": "taru", "name": "たる (книжный)", "name_ru": "たる (книжный)", "description": "いだいたる、どうどうたる"},
{"id": "beshi", "name": "べし (книжный долг)", "name_ru": "べし (книжный)", "description": "しるべし、ゆくべし"},
{"id": "nari", "name": "なり (книжный)", "name_ru": "なり (книжный)", "description": "なんなりと、きくなり"},
{"id": "gotoku", "name": "ごとく/ごとし (подобно)", "name_ru": "ごとく/ごとし (подобно)", "description": "みずのごとく"},
{"id": "zukunme", "name": "ずくめ (сплошь)", "name_ru": "ずくめ (сплошь)", "description": "くろずくめ、いいことずくめ"},
# Сложные конструкции
{"id": "made_mo_nai", "name": "までもない (не нужно)", "name_ru": "までもない (не нужно)", "description": "いうまでもない"},
{"id": "ni_taeru", "name": "に耐える (выдерживать)", "name_ru": "に耐える (выдерживать)", "description": "ひひょうにたえる"},
{"id": "ni_taenai", "name": "に堪えない (не выдержать)", "name_ru": "に堪えない (не выдержать)", "description": "みるにたえない"},
{"id": "wo_motte", "name": "をもって (посредством)", "name_ru": "をもって (посредством)", "description": "せいいをもって"},
{"id": "wo_kagiri_ni", "name": "を限りに (начиная с)", "name_ru": "を限りに (начиная с)", "description": "きょうをかぎりに"},
{"id": "wo_yogi_naku_sareru", "name": "を余儀なくされる", "name_ru": "を余儀なくされる", "description": "へんこうをよぎなくされる"},
{"id": "wo_mono_tomo_shinai", "name": "をものともしない", "name_ru": "をものともしない", "description": "きけんをものともしない"},
{"id": "ikan", "name": "いかん (формальный)", "name_ru": "いかん (формальный)", "description": "けっかいかんでは"},
{"id": "ikan_ni_yotte", "name": "いかんによって", "name_ru": "いかんによって", "description": "どりょくいかんによって"},
{"id": "ni_sokushite", "name": "に即して (в соответствии)", "name_ru": "に即して (в соответствии)", "description": "じじつにそくして"},
{"id": "ni_hoka_naranai", "name": "にほかならない (не что иное)", "name_ru": "にほかならない (не что иное)", "description": "これはあいにほかならない"},
{"id": "tomo_arou", "name": "ともあろう (несмотря на статус)", "name_ru": "ともあろう (несмотря на статус)", "description": "せんせいともあろうひとが"},
{"id": "tomo_naku", "name": "ともなく (без особого)", "name_ru": "ともなく (без особого)", "description": "みるともなくみる"},
{"id": "to_iedo_mo", "name": "といえども (даже если)", "name_ru": "といえども (даже если)", "description": "せんもんかといえども"},
{"id": "nagaramo", "name": "ながらも (хотя и)", "name_ru": "ながらも (хотя и)", "description": "ざんねんながらも"},
{"id": "tomo_sureba", "name": "ともすれば (порой)", "name_ru": "ともすれば (порой)", "description": "ともすればわすれがち"},
],
}
def get_grammar_topics_for_level(learning_lang: str, level: str) -> list:
"""
Получить список грамматических тем для уровня.
Args:
learning_lang: Язык изучения ('en' или 'ja')
level: Уровень пользователя (A1-C2 для английского, N5-N1 для японского)
Returns:
Список словарей с грамматическими темами
"""
if learning_lang == 'en':
rules = ENGLISH_GRAMMAR_RULES
# Для английского включаем текущий уровень и все ниже
level_order = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']
elif learning_lang == 'ja':
rules = JAPANESE_GRAMMAR_RULES
# Для японского N5 - самый низкий, N1 - самый высокий
level_order = ['N5', 'N4', 'N3', 'N2', 'N1']
else:
return []
# Нормализуем уровень
level_upper = level.upper()
if level_upper not in level_order:
# Если уровень не распознан, возвращаем базовый
level_upper = level_order[0]
# Собираем темы для текущего и всех предыдущих уровней
result = []
for lvl in level_order:
if lvl in rules:
for topic in rules[lvl]:
topic_copy = topic.copy()
topic_copy['level'] = lvl
result.append(topic_copy)
if lvl == level_upper:
break
return result
def get_topics_for_current_level_only(learning_lang: str, level: str) -> list:
"""
Получить список грамматических тем ТОЛЬКО для текущего уровня.
Args:
learning_lang: Язык изучения ('en' или 'ja')
level: Уровень пользователя
Returns:
Список словарей с грамматическими темами только для текущего уровня
"""
if learning_lang == 'en':
rules = ENGLISH_GRAMMAR_RULES
elif learning_lang == 'ja':
rules = JAPANESE_GRAMMAR_RULES
else:
return []
level_upper = level.upper()
if level_upper not in rules:
return []
result = []
for topic in rules[level_upper]:
topic_copy = topic.copy()
topic_copy['level'] = level_upper
result.append(topic_copy)
return result

View File

@@ -139,3 +139,52 @@ class AIModel(Base):
display_name: Mapped[str] = mapped_column(String(100), nullable=False) # Название для отображения
is_active: Mapped[bool] = mapped_column(Boolean, default=False) # Только одна модель активна
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class StoryGenre(str, enum.Enum):
"""Жанры мини-историй"""
dialogue = "dialogue" # 🗣 Диалоги
news = "news" # 📰 Новости
story = "story" # 🎭 Истории
letter = "letter" # 📧 Письма
recipe = "recipe" # 🍳 Рецепты
class MiniStory(Base):
"""Модель мини-истории для чтения"""
__tablename__ = "mini_stories"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
content: Mapped[str] = mapped_column(String(5000), nullable=False) # Текст истории
translation: Mapped[Optional[str]] = mapped_column(String(5000), nullable=True) # Перевод истории
genre: Mapped[StoryGenre] = mapped_column(SQLEnum(StoryGenre), nullable=False)
learning_lang: Mapped[str] = mapped_column(String(5), nullable=False) # en/ja
level: Mapped[str] = mapped_column(String(5), nullable=False) # A1-C2 или N5-N1
word_count: Mapped[int] = mapped_column(Integer, default=0) # Количество слов
vocabulary: Mapped[Optional[dict]] = mapped_column(JSON) # [{word, translation, transcription}]
questions: Mapped[Optional[dict]] = mapped_column(JSON) # [{question, options[], correct}]
is_completed: Mapped[bool] = mapped_column(Boolean, default=False)
correct_answers: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class WordOfDay(Base):
"""Модель слова дня (глобальная для всех пользователей по уровню)"""
__tablename__ = "word_of_day"
__table_args__ = (
UniqueConstraint("date", "learning_lang", "level", name="uq_wod_date_lang_level"),
)
id: Mapped[int] = mapped_column(primary_key=True)
word: Mapped[str] = mapped_column(String(255), nullable=False)
transcription: Mapped[Optional[str]] = mapped_column(String(255))
translation: Mapped[str] = mapped_column(String(500), nullable=False)
examples: Mapped[Optional[dict]] = mapped_column(JSON) # [{sentence, translation}]
synonyms: Mapped[Optional[str]] = mapped_column(String(500)) # Синонимы через запятую
etymology: Mapped[Optional[str]] = mapped_column(String(500)) # Этимология/интересный факт
learning_lang: Mapped[str] = mapped_column(String(5), nullable=False, index=True) # en/ja
level: Mapped[str] = mapped_column(String(5), nullable=False, index=True) # A1-C2 или N5-N1
date: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True) # Дата слова (только дата, без времени)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@@ -1,15 +1,34 @@
{
"menu": {
"wordofday": "🌅 Word of Day",
"add": " Add word",
"vocab": "📚 Vocabulary",
"task": "🧠 Task",
"practice": "💬 Practice",
"exercises": "📖 Exercises",
"words": "🎯 Thematic words",
"import": "📖 Import",
"stats": "📊 Stats",
"settings": "⚙️ Settings",
"below": "Main menu below ⤵️"
},
"practice_menu": {
"title": "Practice",
"choose": "Choose practice mode:",
"stories": "Mini Stories",
"ai_chat": "AI Chat"
},
"wod": {
"title": "Word of the Day",
"generating": "🔄 Generating word of the day...",
"failed": "❌ Failed to generate word of the day. Try again later.",
"not_available": "🕐 Word of the day is not ready yet.\n\nWords are generated daily at 00:00 UTC.\nTry again later!",
"examples": "Examples",
"synonyms": "Synonyms",
"add_btn": " Add to vocabulary",
"added": "Added to vocabulary!",
"not_found": "Word not found"
},
"add_menu": {
"title": " <b>Add words</b>\n\nChoose method:",
"manual": "📝 Manual",
@@ -55,7 +74,7 @@
"skip_msg": "✅ Okay!\n\nYou can take the test later with /level_test\nor set level manually in /settings\n\nLet's start! Try:\n• /words travel - thematic words\n• /practice - AI dialogue\n• /add hello - add a word"
},
"add": {
"prompt": "Send the word you want to add:\nFor example: <code>/add elephant</code>\n\nOr just send the word without a command!",
"prompt": "Send the word you want to add:\n• Single word: <code>/add elephant</code>\n• Multiple: <code>/add apple, banana, orange</code>\n\nOr just send the word without a command!",
"searching": "⏳ Looking up translation and examples...",
"examples_header": "<b>Examples:</b>",
"translations_header": "<b>Translations:</b>",
@@ -69,6 +88,13 @@
"added_success": "✅ Word '<b>{word}</b>' added!\n\nTotal words in vocabulary: {count}\n\nKeep adding new words or use /task to practice!",
"cancelled": "Cancelled. You can add another word with /add"
},
"add_batch": {
"header": "📝 <b>Words to add ({n}):</b>",
"translating": "⏳ Translating {n} words...",
"choose": "Select words to add or add all at once:",
"truncated": "⚠️ Too many words. Showing first {n}.",
"failed": "❌ Failed to get translations. Try again later."
},
"vocab": {
"empty": "📚 Your vocabulary is empty!\n\nAdd your first word with /add or just send me a word.",
"header": "<b>📚 Your vocabulary:</b>",
@@ -191,7 +217,54 @@
"invalid_format": "❌ Invalid time format!\n\nUse <b>HH:MM</b> (e.g., 09:00 or 18:30)\nOr send /cancel to abort",
"time_set_title": "✅ <b>Time set!</b>",
"status_on_line": "Status: <b>Enabled</b>",
"use_settings": "Use /reminder to change settings."
"use_settings": "Use /reminder to change settings.",
"daily_title": "⏰ <b>Time to practice!</b>",
"daily_wod": "🌅 <b>Word of the Day:</b>",
"daily_tips": "Don't forget to practice today:\n• /task - complete tasks\n• /practice - practice dialogue\n• /words - add new words",
"daily_motivation": "💪 Regular practice is the key to success!"
},
"story": {
"title": "Mini Stories",
"choose_genre": "Choose a story genre:",
"genre": {
"dialogue": "Dialogues",
"news": "News",
"story": "Stories",
"letter": "Letters",
"recipe": "Recipes"
},
"generating": "🔄 Generating story...",
"failed": "❌ Failed to generate story. Try again.",
"try_again": "Try again",
"level": "Level",
"words": "words",
"questions_btn": "Questions",
"vocab_btn": "Vocabulary",
"new_btn": "New story",
"back": "Back",
"not_found": "Story not found",
"no_vocab": "No vocabulary words",
"no_questions": "No questions",
"vocabulary": "Story Vocabulary",
"add_all": "Add all",
"word_added": "✅ Word '{word}' added!",
"words_added": "✅ Added words: {n}",
"word_not_found": "Word not found",
"question": "Question",
"question_not_found": "Question not found",
"correct": "✅ Correct!",
"incorrect": "❌ Incorrect",
"next_question": "Next question",
"show_results": "Results",
"results_title": "Results",
"correct_answers": "Correct answers",
"accuracy": "Accuracy",
"result_excellent": "Excellent! You understood the text well.",
"result_good": "Good job! You understood most of the text.",
"result_practice": "Try reading the story more carefully.",
"translation": "Translation",
"show_translation": "Show translation",
"hide_translation": "Hide translation"
},
"level_test": {
"show_translation_btn": "👁️ Show question translation",
@@ -330,5 +403,29 @@
"err_not_found": "❌ Error: word not found",
"already_exists": "The word '{word}' is already in your vocabulary",
"added_single": "✅ Word '{word}' added to vocabulary"
},
"exercises": {
"title": "📖 <b>Grammar Exercises</b>",
"choose_topic": "Choose a topic for exercises:",
"your_level": "Your level: <b>{level}</b>",
"generating_rule": "🔄 Generating grammar explanation...",
"generating": "🔄 Generating exercises...",
"generate_failed": "❌ Failed to generate exercise. Please try again later.",
"start_btn": "▶️ Start exercises",
"task_header": "📝 <b>Exercise: {topic}</b>",
"instruction": "Fill in the blanks with the correct form:",
"check_btn": "✅ Check",
"next_btn": "➡️ Next",
"results_btn": "📊 Results",
"back_btn": "⬅️ Back to topics",
"close_btn": "❌ Close",
"correct": "✅ <b>Correct!</b>",
"incorrect": "❌ <b>Incorrect</b>",
"your_answer": "Your answer: {answer}",
"right_answer": "Correct answer: {answer}",
"explanation": "💡 {text}",
"score": "Score: {correct} of {total}",
"no_topics": "No topics available for your level yet.",
"write_answer": "Write your answer:"
}
}

View File

@@ -1,15 +1,34 @@
{
"menu": {
"wordofday": "🌅 今日の単語",
"add": " 単語を追加",
"vocab": "📚 単語帳",
"task": "🧠 課題",
"practice": "💬 練習",
"exercises": "📖 文法練習",
"words": "🎯 テーマ別単語",
"import": "📖 インポート",
"stats": "📊 統計",
"settings": "⚙️ 設定",
"below": "メインメニューは下にあります ⤵️"
},
"practice_menu": {
"title": "練習",
"choose": "練習モードを選択:",
"stories": "ミニストーリー",
"ai_chat": "AIとの会話"
},
"wod": {
"title": "今日の単語",
"generating": "🔄 今日の単語を生成中...",
"failed": "❌ 今日の単語の生成に失敗しました。後でもう一度お試しください。",
"not_available": "🕐 今日の単語はまだ準備中です。\n\n単語は毎日UTC 00:00に生成されます。\n後でもう一度お試しください",
"examples": "例文",
"synonyms": "類義語",
"add_btn": " 単語帳に追加",
"added": "単語帳に追加しました!",
"not_found": "単語が見つかりません"
},
"add_menu": {
"title": " <b>単語を追加</b>\n\n方法を選択:",
"manual": "📝 手動",
@@ -55,7 +74,7 @@
"skip_msg": "✅ わかりました!\n\n/level_test で後からテストを受けるか、/settings でレベルを設定できます。\n\nはじめましょうおすすめ:\n• /words travel - テーマ別単語\n• /practice - AIとの会話\n• /add hello - 単語を追加"
},
"add": {
"prompt": "追加したい単語を送ってください:\n: <code>/add elephant</code>\n\nコマンドなしで単語だけ送ってもOKです",
"prompt": "追加したい単語を送ってください:\n• 1語: <code>/add elephant</code>\n• 複数: <code>/add apple, banana, orange</code>\n\nコマンドなしで単語だけ送ってもOKです",
"searching": "⏳ 翻訳と例を検索中...",
"examples_header": "<b>例文:</b>",
"translations_header": "<b>翻訳:</b>",
@@ -69,6 +88,13 @@
"added_success": "✅ 単語 '<b>{word}</b>' を追加しました!\n\n単語帳の総数: {count}\n\nさらに追加するか、/task で練習しましょう!",
"cancelled": "キャンセルしました。/add で別の単語を追加できます"
},
"add_batch": {
"header": "📝 <b>追加する単語 ({n}):</b>",
"translating": "⏳ {n} 語を翻訳中...",
"choose": "追加する単語を選ぶか、一括で追加してください:",
"truncated": "⚠️ 単語が多すぎます。最初の {n} 語を表示。",
"failed": "❌ 翻訳の取得に失敗しました。後でもう一度お試しください。"
},
"vocab": {
"empty": "📚 単語帳はまだ空です!\n\n/add で最初の単語を追加するか、単語を直接送ってください。",
"header": "<b>📚 あなたの単語帳:</b>",
@@ -183,7 +209,54 @@
"invalid_format": "❌ 時間の形式が正しくありません!\n\n<b>HH:MM</b>(例: 09:00 / 18:30形式を使用してください\nまたは /cancel で中止",
"time_set_title": "✅ <b>時間を設定しました!</b>",
"status_on_line": "ステータス: <b>有効</b>",
"use_settings": "/reminder で設定を変更できます。"
"use_settings": "/reminder で設定を変更できます。",
"daily_title": "⏰ <b>練習の時間です!</b>",
"daily_wod": "🌅 <b>今日の単語:</b>",
"daily_tips": "今日も練習を忘れずに:\n• /task - 課題を解く\n• /practice - 会話練習\n• /words - 新しい単語を追加",
"daily_motivation": "💪 継続は力なり!"
},
"story": {
"title": "ミニストーリー",
"choose_genre": "ストーリーのジャンルを選択:",
"genre": {
"dialogue": "会話",
"news": "ニュース",
"story": "物語",
"letter": "手紙",
"recipe": "レシピ"
},
"generating": "🔄 ストーリーを生成中...",
"failed": "❌ ストーリーの生成に失敗しました。もう一度お試しください。",
"try_again": "もう一度試す",
"level": "レベル",
"words": "単語",
"questions_btn": "質問",
"vocab_btn": "単語帳",
"new_btn": "新しいストーリー",
"back": "戻る",
"not_found": "ストーリーが見つかりません",
"no_vocab": "単語がありません",
"no_questions": "質問がありません",
"vocabulary": "ストーリーの単語",
"add_all": "すべて追加",
"word_added": "✅ 「{word}」を追加しました!",
"words_added": "✅ {n}単語を追加しました",
"word_not_found": "単語が見つかりません",
"question": "質問",
"question_not_found": "質問が見つかりません",
"correct": "✅ 正解!",
"incorrect": "❌ 不正解",
"next_question": "次の質問",
"show_results": "結果",
"results_title": "結果",
"correct_answers": "正解数",
"accuracy": "正解率",
"result_excellent": "素晴らしい!テキストをよく理解できました。",
"result_good": "よくできました!大部分を理解できました。",
"result_practice": "もう一度注意深く読んでみてください。",
"translation": "翻訳",
"show_translation": "翻訳を表示",
"hide_translation": "翻訳を隠す"
},
"level_test": {
"show_translation_btn": "👁️ 質問の翻訳を表示",
@@ -322,5 +395,29 @@
"err_not_found": "❌ エラー: 単語が見つかりません",
"already_exists": "単語 '{word}' はすでに単語帳にあります",
"added_single": "✅ 単語 '{word}' を単語帳に追加しました"
},
"exercises": {
"title": "📖 <b>文法練習</b>",
"choose_topic": "練習するトピックを選択してください:",
"your_level": "あなたのレベル: <b>{level}</b>",
"generating_rule": "🔄 文法説明を生成中...",
"generating": "🔄 練習問題を生成中...",
"generate_failed": "❌ 練習問題の生成に失敗しました。後でもう一度お試しください。",
"start_btn": "▶️ 練習を開始",
"task_header": "📝 <b>練習: {topic}</b>",
"instruction": "正しい形式で空欄を埋めてください:",
"check_btn": "✅ 確認",
"next_btn": "➡️ 次へ",
"results_btn": "📊 結果",
"back_btn": "⬅️ トピックに戻る",
"close_btn": "❌ 閉じる",
"correct": "✅ <b>正解!</b>",
"incorrect": "❌ <b>不正解</b>",
"your_answer": "あなたの回答: {answer}",
"right_answer": "正解: {answer}",
"explanation": "💡 {text}",
"score": "スコア: {total}問中{correct}問正解",
"no_topics": "あなたのレベルで利用可能なトピックはまだありません。",
"write_answer": "回答を入力してください:"
}
}

View File

@@ -1,15 +1,34 @@
{
"menu": {
"wordofday": "🌅 Слово дня",
"add": " Добавить слово",
"vocab": "📚 Словарь",
"task": "🧠 Задание",
"practice": "💬 Практика",
"exercises": "📖 Упражнения",
"words": "🎯 Тематические слова",
"import": "📖 Импорт",
"stats": "📊 Статистика",
"settings": "⚙️ Настройки",
"below": "Главное меню доступно ниже ⤵️"
},
"practice_menu": {
"title": "Практика",
"choose": "Выбери режим практики:",
"stories": "Мини-истории",
"ai_chat": "Диалог с AI"
},
"wod": {
"title": "Слово дня",
"generating": "🔄 Генерирую слово дня...",
"failed": "❌ Не удалось сгенерировать слово дня. Попробуй позже.",
"not_available": "🕐 Слово дня ещё не готово.\n\nСлова генерируются ежедневно в 00:00 UTC.\nПопробуй позже!",
"examples": "Примеры",
"synonyms": "Синонимы",
"add_btn": " Добавить в словарь",
"added": "Добавлено в словарь!",
"not_found": "Слово не найдено"
},
"add_menu": {
"title": " <b>Добавление слов</b>\n\nВыберите способ:",
"manual": "📝 Вручную",
@@ -55,7 +74,7 @@
"skip_msg": "✅ Хорошо!\n\nТы можешь пройти тест позже командой /level_test\nили установить уровень вручную в /settings\n\nДавай начнём! Попробуй:\n• /words travel - тематическая подборка\n• /practice - диалог с AI\n• /add hello - добавить слово"
},
"add": {
"prompt": "Отправь слово, которое хочешь добавить:\nНапример: <code>/add elephant</code>\n\nИли просто отправь слово без команды!",
"prompt": "Отправь слово, которое хочешь добавить:\n• Одно слово: <code>/add elephant</code>\n• Несколько: <code>/add apple, banana, orange</code>\n\nИли просто отправь слово без команды!",
"searching": "⏳ Ищу перевод и примеры...",
"examples_header": "<b>Примеры:</b>",
"translations_header": "<b>Переводы:</b>",
@@ -69,6 +88,13 @@
"added_success": "✅ Слово '<b>{word}</b>' добавлено!\n\nВсего слов в словаре: {count}\n\nПродолжай добавлять новые слова или используй /task для практики!",
"cancelled": "Отменено. Можешь добавить другое слово командой /add"
},
"add_batch": {
"header": "📝 <b>Слова для добавления ({n}):</b>",
"translating": "⏳ Перевожу {n} слов...",
"choose": "Выбери слова для добавления или добавь все сразу:",
"truncated": "⚠️ Слишком много слов. Показаны первые {n}.",
"failed": "❌ Не удалось получить переводы. Попробуй позже."
},
"vocab": {
"empty": "📚 Твой словарь пока пуст!\n\nДобавь первое слово командой /add или просто отправь мне слово.",
"header": "<b>📚 Твой словарь:</b>",
@@ -180,7 +206,54 @@
"invalid_format": "❌ Неверный формат времени!\n\nИспользуй формат <b>HH:MM</b> (например, 09:00 или 18:30)\nИли отправь /cancel для отмены",
"time_set_title": "✅ <b>Время установлено!</b>",
"status_on_line": "Статус: <b>Включены</b>",
"use_settings": "Используй /reminder для изменения настроек."
"use_settings": "Используй /reminder для изменения настроек.",
"daily_title": "⏰ <b>Время для практики!</b>",
"daily_wod": "🌅 <b>Слово дня:</b>",
"daily_tips": "Не забудь потренироваться сегодня:\n• /task - выполни задания\n• /practice - попрактикуй диалог\n• /words - добавь новые слова",
"daily_motivation": "💪 Регулярная практика - ключ к успеху!"
},
"story": {
"title": "Мини-истории",
"choose_genre": "Выбери жанр истории:",
"genre": {
"dialogue": "Диалоги",
"news": "Новости",
"story": "Рассказы",
"letter": "Письма",
"recipe": "Рецепты"
},
"generating": "🔄 Генерирую историю...",
"failed": "❌ Не удалось сгенерировать историю. Попробуй ещё раз.",
"try_again": "Попробовать снова",
"level": "Уровень",
"words": "слов",
"questions_btn": "Вопросы",
"vocab_btn": "Словарь",
"new_btn": "Новая история",
"back": "Назад",
"not_found": "История не найдена",
"no_vocab": "Нет слов для изучения",
"no_questions": "Нет вопросов",
"vocabulary": "Словарь истории",
"add_all": "Добавить все",
"word_added": "✅ Слово '{word}' добавлено!",
"words_added": "✅ Добавлено слов: {n}",
"word_not_found": "Слово не найдено",
"question": "Вопрос",
"question_not_found": "Вопрос не найден",
"correct": "✅ Правильно!",
"incorrect": "❌ Неправильно",
"next_question": "Следующий вопрос",
"show_results": "Результаты",
"results_title": "Результаты",
"correct_answers": "Правильных ответов",
"accuracy": "Точность",
"result_excellent": "Отличный результат! Ты хорошо понял текст.",
"result_good": "Хорошо! Большую часть текста ты понял.",
"result_practice": "Попробуй перечитать историю внимательнее.",
"translation": "Перевод",
"show_translation": "Показать перевод",
"hide_translation": "Скрыть перевод"
},
"stats": {
"header": "📊 <b>Твоя статистика</b>",
@@ -330,5 +403,29 @@
"err_not_found": "❌ Ошибка: слово не найдено",
"already_exists": "Слово '{word}' уже в словаре",
"added_single": "✅ Слово '{word}' добавлено в словарь"
},
"exercises": {
"title": "📖 <b>Грамматические упражнения</b>",
"choose_topic": "Выбери тему для упражнения:",
"your_level": "Твой уровень: <b>{level}</b>",
"generating_rule": "🔄 Генерирую объяснение правила...",
"generating": "🔄 Генерирую упражнения...",
"generate_failed": "❌ Не удалось сгенерировать упражнение. Попробуй позже.",
"start_btn": "▶️ Начать упражнения",
"task_header": "📝 <b>Упражнение: {topic}</b>",
"instruction": "Заполни пропуски правильной формой:",
"check_btn": "✅ Проверить",
"next_btn": "➡️ Следующее",
"results_btn": "📊 Результаты",
"back_btn": "⬅️ К темам",
"close_btn": "❌ Закрыть",
"correct": "✅ <b>Правильно!</b>",
"incorrect": "❌ <b>Неправильно</b>",
"your_answer": "Твой ответ: {answer}",
"right_answer": "Правильный ответ: {answer}",
"explanation": "💡 {text}",
"score": "Результат: {correct} из {total}",
"no_topics": "Для твоего уровня пока нет доступных тем.",
"write_answer": "Напиши свой ответ:"
}
}

18
main.py
View File

@@ -7,8 +7,7 @@ from aiogram.enums import ParseMode
from aiogram.types import BotCommand
from config.settings import settings
from bot.handlers import start, vocabulary, tasks, settings as settings_handler, words, import_text, practice, reminder, level_test, admin
from database.db import init_db
from bot.handlers import start, vocabulary, tasks, settings as settings_handler, words, import_text, practice, reminder, level_test, admin, exercises, wordofday, stories
from services.reminder_service import init_reminder_service
@@ -30,15 +29,14 @@ async def main():
# Команды бота для меню Telegram
await bot.set_my_commands([
BotCommand(command="start", description="Запустить бота"),
BotCommand(command="add", description="Добавить слово"),
BotCommand(command="words", description="Тематическая подборка слов"),
BotCommand(command="import", description="Импорт слов из текста"),
BotCommand(command="vocabulary", description="Мой словарь"),
BotCommand(command="task", description="Задания"),
BotCommand(command="practice", description="Диалог с AI"),
BotCommand(command="story", description="Мини-истории"),
BotCommand(command="add", description="Добавить слово"),
BotCommand(command="words", description="Тематическая подборка слов"),
BotCommand(command="vocabulary", description="Мой словарь"),
BotCommand(command="stats", description="Статистика"),
BotCommand(command="settings", description="Настройки"),
BotCommand(command="reminder", description="Напоминания"),
BotCommand(command="help", description="Справка"),
])
@@ -51,11 +49,13 @@ async def main():
dp.include_router(words.router)
dp.include_router(import_text.router)
dp.include_router(practice.router)
dp.include_router(exercises.router)
dp.include_router(wordofday.router)
dp.include_router(stories.router)
dp.include_router(reminder.router)
dp.include_router(admin.router)
# Инициализация базы данных
await init_db()
# База данных инициализируется через Alembic миграции (make local-migrate)
# Инициализация и запуск сервиса напоминаний
reminder_service = init_reminder_service(bot)

View File

@@ -0,0 +1,55 @@
"""Add mini_stories table
Revision ID: 20251209_mini_stories
Revises: 20251209_word_of_day
Create Date: 2024-12-09
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '20251209_mini_stories'
down_revision: Union[str, None] = '20251209_word_of_day'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Удаляем старую таблицу и enum если существуют
op.execute("DROP TABLE IF EXISTS mini_stories CASCADE")
op.execute("DROP TYPE IF EXISTS storygenre CASCADE")
# Создаём enum через raw SQL
op.execute("CREATE TYPE storygenre AS ENUM ('dialogue', 'news', 'story', 'letter', 'recipe')")
# Создаём таблицу используя postgresql.ENUM с create_type=False
story_genre = postgresql.ENUM('dialogue', 'news', 'story', 'letter', 'recipe', name='storygenre', create_type=False)
op.create_table(
'mini_stories',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('content', sa.String(length=5000), nullable=False),
sa.Column('genre', story_genre, nullable=False),
sa.Column('learning_lang', sa.String(length=5), nullable=False),
sa.Column('level', sa.String(length=5), nullable=False),
sa.Column('word_count', sa.Integer(), nullable=True, server_default='0'),
sa.Column('vocabulary', sa.JSON(), nullable=True),
sa.Column('questions', sa.JSON(), nullable=True),
sa.Column('is_completed', sa.Boolean(), nullable=True, server_default='false'),
sa.Column('correct_answers', sa.Integer(), nullable=True, server_default='0'),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_mini_stories_user_id', 'mini_stories', ['user_id'], unique=False)
def downgrade() -> None:
op.drop_index('ix_mini_stories_user_id', table_name='mini_stories')
op.drop_table('mini_stories')
op.execute("DROP TYPE IF EXISTS storygenre CASCADE")

View File

@@ -0,0 +1,26 @@
"""Add translation field to mini_stories
Revision ID: 20251209_story_translation
Revises: 20251209_mini_stories
Create Date: 2025-12-09
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '20251209_story_translation'
down_revision: Union[str, None] = '20251209_mini_stories'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('mini_stories', sa.Column('translation', sa.String(5000), nullable=True))
def downgrade() -> None:
op.drop_column('mini_stories', 'translation')

View File

@@ -0,0 +1,50 @@
"""Add word_of_day table (global by level)
Revision ID: 20251209_word_of_day
Revises: 20251208_user_ai_model
Create Date: 2024-12-09
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '20251209_word_of_day'
down_revision: Union[str, None] = '20251208_user_ai_model'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Удаляем старую таблицу если существует (была с user_id)
op.execute("DROP TABLE IF EXISTS word_of_day CASCADE")
op.create_table(
'word_of_day',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('word', sa.String(length=255), nullable=False),
sa.Column('transcription', sa.String(length=255), nullable=True),
sa.Column('translation', sa.String(length=500), nullable=False),
sa.Column('examples', sa.JSON(), nullable=True),
sa.Column('synonyms', sa.String(length=500), nullable=True),
sa.Column('etymology', sa.String(length=500), nullable=True),
sa.Column('learning_lang', sa.String(length=5), nullable=False),
sa.Column('level', sa.String(length=5), nullable=False),
sa.Column('date', sa.DateTime(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('date', 'learning_lang', 'level', name='uq_wod_date_lang_level')
)
op.create_index('ix_word_of_day_learning_lang', 'word_of_day', ['learning_lang'], unique=False)
op.create_index('ix_word_of_day_level', 'word_of_day', ['level'], unique=False)
op.create_index('ix_word_of_day_date', 'word_of_day', ['date'], unique=False)
def downgrade() -> None:
op.drop_index('ix_word_of_day_date', table_name='word_of_day')
op.drop_index('ix_word_of_day_level', table_name='word_of_day')
op.drop_index('ix_word_of_day_learning_lang', table_name='word_of_day')
op.drop_table('word_of_day')

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

View File

@@ -1,3 +0,0 @@
Сделать задачки с помощью голосовых, человек должен написать что услышал, человек должен записать голосовой слова
Сделать задачки с картинками, человек должен написать что изоображено на картинке
Сделать задания по темам тип времён или неправильных глаголов на английском