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:
1
Makefile
1
Makefile
@@ -91,7 +91,6 @@ docker-bot-build:
|
|||||||
docker-bot-rebuild:
|
docker-bot-rebuild:
|
||||||
docker-compose stop bot
|
docker-compose stop bot
|
||||||
docker-compose rm bot
|
docker-compose rm bot
|
||||||
docker-compose build --no-cache bot
|
|
||||||
docker-compose up -d bot
|
docker-compose up -d bot
|
||||||
|
|
||||||
docker-bot-rebuild-full:
|
docker-bot-rebuild-full:
|
||||||
|
|||||||
@@ -289,6 +289,11 @@ bot_tg_language/
|
|||||||
- [x] Убрать переводы текстов (скрыть перевод в упражнениях/диалогах/тестах)
|
- [x] Убрать переводы текстов (скрыть перевод в упражнениях/диалогах/тестах)
|
||||||
- [x] Добавлены английский и японский в локализацию интерфейса
|
- [x] Добавлены английский и японский в локализацию интерфейса
|
||||||
- [x] Добавлены языки для обучения
|
- [x] Добавлены языки для обучения
|
||||||
|
- [x] Добавить возможность иметь словам несколько переводов
|
||||||
|
- [x] Добавить возможность импорта слов из файлов (txt, md)
|
||||||
|
- [x] Добавить импорт нескольких слов (bulk-импорт)
|
||||||
|
- [x] Добавлена механика "Слова дня"
|
||||||
|
- [x] Добавлена механика "Мини истории"
|
||||||
|
|
||||||
**Следующие улучшения:**
|
**Следующие улучшения:**
|
||||||
- [ ] Экспорт словаря (PDF, Anki, CSV)
|
- [ ] Экспорт словаря (PDF, Anki, CSV)
|
||||||
@@ -296,11 +301,8 @@ bot_tg_language/
|
|||||||
- [ ] Групповые челленджи и лидерборды
|
- [ ] Групповые челленджи и лидерборды
|
||||||
- [ ] Gamification (стрики, достижения, уровни)
|
- [ ] Gamification (стрики, достижения, уровни)
|
||||||
- [ ] Расширенная аналитика с графиками
|
- [ ] Расширенная аналитика с графиками
|
||||||
- [ ] Добавить импорт нескольких слов (bulk-импорт)
|
|
||||||
- [ ] Создание задач на выбранные слова (из словаря/подборок)
|
- [ ] Создание задач на выбранные слова (из словаря/подборок)
|
||||||
- [ ] Добавить возможность иметь словам несколько переводов
|
|
||||||
- [ ] Изменить словарь: оставить только слова и добавить возможность получать инфо о словах
|
- [ ] Изменить словарь: оставить только слова и добавить возможность получать инфо о словах
|
||||||
- [ ] Добавить возможность импорта слов из файлов
|
|
||||||
|
|
||||||
## Cloudflare AI Gateway (опционально)
|
## Cloudflare AI Gateway (опционально)
|
||||||
|
|
||||||
|
|||||||
415
bot/handlers/exercises.py
Normal file
415
bot/handlers/exercises.py
Normal 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)
|
||||||
@@ -29,6 +29,40 @@ def get_scenario_name(lang: str, scenario: str) -> str:
|
|||||||
return t(lang, f'practice.scenario.{scenario}')
|
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"))
|
@router.message(Command("practice"))
|
||||||
async def cmd_practice(message: Message, state: FSMContext):
|
async def cmd_practice(message: Message, state: FSMContext):
|
||||||
"""Обработчик команды /practice"""
|
"""Обработчик команды /practice"""
|
||||||
@@ -39,31 +73,10 @@ async def cmd_practice(message: Message, state: FSMContext):
|
|||||||
await message.answer(t('ru', 'common.start_first'))
|
await message.answer(t('ru', 'common.start_first'))
|
||||||
return
|
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.update_data(user_id=user.id, level=get_user_level_for_language(user))
|
||||||
await state.set_state(PracticeStates.choosing_scenario)
|
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)
|
@router.callback_query(F.data == "scenario_custom", PracticeStates.choosing_scenario)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from aiogram.fsm.state import State, StatesGroup
|
|||||||
|
|
||||||
from database.db import async_session_maker
|
from database.db import async_session_maker
|
||||||
from services.user_service import UserService
|
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
|
from utils.levels import get_user_level_for_language
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
@@ -57,16 +57,19 @@ def main_menu_keyboard(lang: str = 'ru') -> ReplyKeyboardMarkup:
|
|||||||
return ReplyKeyboardMarkup(
|
return ReplyKeyboardMarkup(
|
||||||
resize_keyboard=True,
|
resize_keyboard=True,
|
||||||
keyboard=[
|
keyboard=[
|
||||||
[
|
|
||||||
KeyboardButton(text=t(lang, "menu.add")),
|
|
||||||
KeyboardButton(text=t(lang, "menu.vocab")),
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
KeyboardButton(text=t(lang, "menu.task")),
|
KeyboardButton(text=t(lang, "menu.task")),
|
||||||
KeyboardButton(text=t(lang, "menu.practice")),
|
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.stats")),
|
||||||
|
],
|
||||||
|
[
|
||||||
KeyboardButton(text=t(lang, "menu.settings")),
|
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'))
|
@router.message(_menu_match('menu.practice'))
|
||||||
async def btn_practice_pressed(message: Message, state: FSMContext):
|
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'))
|
@router.message(_menu_match('menu.import'))
|
||||||
@@ -391,6 +460,14 @@ async def btn_settings_pressed(message: Message):
|
|||||||
await cmd_settings(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'))
|
@router.message(_menu_match('menu.words'))
|
||||||
async def btn_words_pressed(message: Message, state: FSMContext):
|
async def btn_words_pressed(message: Message, state: FSMContext):
|
||||||
"""Подсказать про тематические слова и показать быстрые темы."""
|
"""Подсказать про тематические слова и показать быстрые темы."""
|
||||||
|
|||||||
688
bot/handlers/stories.py
Normal file
688
bot/handlers/stories.py
Normal 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)
|
||||||
@@ -18,12 +18,13 @@ class AddWordStates(StatesGroup):
|
|||||||
"""Состояния для добавления слова"""
|
"""Состояния для добавления слова"""
|
||||||
waiting_for_confirmation = State()
|
waiting_for_confirmation = State()
|
||||||
waiting_for_word = State()
|
waiting_for_word = State()
|
||||||
|
viewing_batch = State() # Просмотр списка слов для batch-добавления
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("add"))
|
@router.message(Command("add"))
|
||||||
async def cmd_add(message: Message, state: FSMContext):
|
async def cmd_add(message: Message, state: FSMContext):
|
||||||
"""Обработчик команды /add [слово]"""
|
"""Обработчик команды /add [слово] или /add [слово1, слово2, ...]"""
|
||||||
# Получаем слово из команды
|
# Получаем слово(а) из команды
|
||||||
parts = message.text.split(maxsplit=1)
|
parts = message.text.split(maxsplit=1)
|
||||||
|
|
||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
@@ -34,15 +35,32 @@ async def cmd_add(message: Message, state: FSMContext):
|
|||||||
await state.set_state(AddWordStates.waiting_for_word)
|
await state.set_state(AddWordStates.waiting_for_word)
|
||||||
return
|
return
|
||||||
|
|
||||||
word = parts[1].strip()
|
text = parts[1].strip()
|
||||||
await process_word_addition(message, state, word)
|
|
||||||
|
# Проверяем, есть ли несколько слов (через запятую)
|
||||||
|
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)
|
@router.message(AddWordStates.waiting_for_word)
|
||||||
async def process_word_input(message: Message, state: FSMContext):
|
async def process_word_input(message: Message, state: FSMContext):
|
||||||
"""Обработка ввода слова"""
|
"""Обработка ввода слова или нескольких слов"""
|
||||||
word = message.text.strip()
|
text = message.text.strip()
|
||||||
await process_word_addition(message, state, word)
|
|
||||||
|
# Проверяем, есть ли несколько слов (через запятую)
|
||||||
|
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):
|
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()
|
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
|
WORDS_PER_PAGE = 10
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
139
bot/handlers/wordofday.py
Normal file
139
bot/handlers/wordofday.py
Normal 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
1
data/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Data module for grammar rules and other static data
|
||||||
321
data/grammar_rules.py
Normal file
321
data/grammar_rules.py
Normal 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
|
||||||
@@ -139,3 +139,52 @@ class AIModel(Base):
|
|||||||
display_name: Mapped[str] = mapped_column(String(100), nullable=False) # Название для отображения
|
display_name: Mapped[str] = mapped_column(String(100), nullable=False) # Название для отображения
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=False) # Только одна модель активна
|
is_active: Mapped[bool] = mapped_column(Boolean, default=False) # Только одна модель активна
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
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)
|
||||||
|
|||||||
101
locales/en.json
101
locales/en.json
@@ -1,15 +1,34 @@
|
|||||||
{
|
{
|
||||||
"menu": {
|
"menu": {
|
||||||
|
"wordofday": "🌅 Word of Day",
|
||||||
"add": "➕ Add word",
|
"add": "➕ Add word",
|
||||||
"vocab": "📚 Vocabulary",
|
"vocab": "📚 Vocabulary",
|
||||||
"task": "🧠 Task",
|
"task": "🧠 Task",
|
||||||
"practice": "💬 Practice",
|
"practice": "💬 Practice",
|
||||||
|
"exercises": "📖 Exercises",
|
||||||
"words": "🎯 Thematic words",
|
"words": "🎯 Thematic words",
|
||||||
"import": "📖 Import",
|
"import": "📖 Import",
|
||||||
"stats": "📊 Stats",
|
"stats": "📊 Stats",
|
||||||
"settings": "⚙️ Settings",
|
"settings": "⚙️ Settings",
|
||||||
"below": "Main menu below ⤵️"
|
"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": {
|
"add_menu": {
|
||||||
"title": "➕ <b>Add words</b>\n\nChoose method:",
|
"title": "➕ <b>Add words</b>\n\nChoose method:",
|
||||||
"manual": "📝 Manual",
|
"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"
|
"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": {
|
"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...",
|
"searching": "⏳ Looking up translation and examples...",
|
||||||
"examples_header": "<b>Examples:</b>",
|
"examples_header": "<b>Examples:</b>",
|
||||||
"translations_header": "<b>Translations:</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!",
|
"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"
|
"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": {
|
"vocab": {
|
||||||
"empty": "📚 Your vocabulary is empty!\n\nAdd your first word with /add or just send me a word.",
|
"empty": "📚 Your vocabulary is empty!\n\nAdd your first word with /add or just send me a word.",
|
||||||
"header": "<b>📚 Your vocabulary:</b>",
|
"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",
|
"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>",
|
"time_set_title": "✅ <b>Time set!</b>",
|
||||||
"status_on_line": "Status: <b>Enabled</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": {
|
"level_test": {
|
||||||
"show_translation_btn": "👁️ Show question translation",
|
"show_translation_btn": "👁️ Show question translation",
|
||||||
@@ -330,5 +403,29 @@
|
|||||||
"err_not_found": "❌ Error: word not found",
|
"err_not_found": "❌ Error: word not found",
|
||||||
"already_exists": "The word '{word}' is already in your vocabulary",
|
"already_exists": "The word '{word}' is already in your vocabulary",
|
||||||
"added_single": "✅ Word '{word}' added to 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:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
101
locales/ja.json
101
locales/ja.json
@@ -1,15 +1,34 @@
|
|||||||
{
|
{
|
||||||
"menu": {
|
"menu": {
|
||||||
|
"wordofday": "🌅 今日の単語",
|
||||||
"add": "➕ 単語を追加",
|
"add": "➕ 単語を追加",
|
||||||
"vocab": "📚 単語帳",
|
"vocab": "📚 単語帳",
|
||||||
"task": "🧠 課題",
|
"task": "🧠 課題",
|
||||||
"practice": "💬 練習",
|
"practice": "💬 練習",
|
||||||
|
"exercises": "📖 文法練習",
|
||||||
"words": "🎯 テーマ別単語",
|
"words": "🎯 テーマ別単語",
|
||||||
"import": "📖 インポート",
|
"import": "📖 インポート",
|
||||||
"stats": "📊 統計",
|
"stats": "📊 統計",
|
||||||
"settings": "⚙️ 設定",
|
"settings": "⚙️ 設定",
|
||||||
"below": "メインメニューは下にあります ⤵️"
|
"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": {
|
"add_menu": {
|
||||||
"title": "➕ <b>単語を追加</b>\n\n方法を選択:",
|
"title": "➕ <b>単語を追加</b>\n\n方法を選択:",
|
||||||
"manual": "📝 手動",
|
"manual": "📝 手動",
|
||||||
@@ -55,7 +74,7 @@
|
|||||||
"skip_msg": "✅ わかりました!\n\n/level_test で後からテストを受けるか、/settings でレベルを設定できます。\n\nはじめましょう!おすすめ:\n• /words travel - テーマ別単語\n• /practice - AIとの会話\n• /add hello - 単語を追加"
|
"skip_msg": "✅ わかりました!\n\n/level_test で後からテストを受けるか、/settings でレベルを設定できます。\n\nはじめましょう!おすすめ:\n• /words travel - テーマ別単語\n• /practice - AIとの会話\n• /add hello - 単語を追加"
|
||||||
},
|
},
|
||||||
"add": {
|
"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": "⏳ 翻訳と例を検索中...",
|
"searching": "⏳ 翻訳と例を検索中...",
|
||||||
"examples_header": "<b>例文:</b>",
|
"examples_header": "<b>例文:</b>",
|
||||||
"translations_header": "<b>翻訳:</b>",
|
"translations_header": "<b>翻訳:</b>",
|
||||||
@@ -69,6 +88,13 @@
|
|||||||
"added_success": "✅ 単語 '<b>{word}</b>' を追加しました!\n\n単語帳の総数: {count}\n\nさらに追加するか、/task で練習しましょう!",
|
"added_success": "✅ 単語 '<b>{word}</b>' を追加しました!\n\n単語帳の総数: {count}\n\nさらに追加するか、/task で練習しましょう!",
|
||||||
"cancelled": "キャンセルしました。/add で別の単語を追加できます"
|
"cancelled": "キャンセルしました。/add で別の単語を追加できます"
|
||||||
},
|
},
|
||||||
|
"add_batch": {
|
||||||
|
"header": "📝 <b>追加する単語 ({n}):</b>",
|
||||||
|
"translating": "⏳ {n} 語を翻訳中...",
|
||||||
|
"choose": "追加する単語を選ぶか、一括で追加してください:",
|
||||||
|
"truncated": "⚠️ 単語が多すぎます。最初の {n} 語を表示。",
|
||||||
|
"failed": "❌ 翻訳の取得に失敗しました。後でもう一度お試しください。"
|
||||||
|
},
|
||||||
"vocab": {
|
"vocab": {
|
||||||
"empty": "📚 単語帳はまだ空です!\n\n/add で最初の単語を追加するか、単語を直接送ってください。",
|
"empty": "📚 単語帳はまだ空です!\n\n/add で最初の単語を追加するか、単語を直接送ってください。",
|
||||||
"header": "<b>📚 あなたの単語帳:</b>",
|
"header": "<b>📚 あなたの単語帳:</b>",
|
||||||
@@ -183,7 +209,54 @@
|
|||||||
"invalid_format": "❌ 時間の形式が正しくありません!\n\n<b>HH:MM</b>(例: 09:00 / 18:30)形式を使用してください\nまたは /cancel で中止",
|
"invalid_format": "❌ 時間の形式が正しくありません!\n\n<b>HH:MM</b>(例: 09:00 / 18:30)形式を使用してください\nまたは /cancel で中止",
|
||||||
"time_set_title": "✅ <b>時間を設定しました!</b>",
|
"time_set_title": "✅ <b>時間を設定しました!</b>",
|
||||||
"status_on_line": "ステータス: <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": {
|
"level_test": {
|
||||||
"show_translation_btn": "👁️ 質問の翻訳を表示",
|
"show_translation_btn": "👁️ 質問の翻訳を表示",
|
||||||
@@ -322,5 +395,29 @@
|
|||||||
"err_not_found": "❌ エラー: 単語が見つかりません",
|
"err_not_found": "❌ エラー: 単語が見つかりません",
|
||||||
"already_exists": "単語 '{word}' はすでに単語帳にあります",
|
"already_exists": "単語 '{word}' はすでに単語帳にあります",
|
||||||
"added_single": "✅ 単語 '{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": "回答を入力してください:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
101
locales/ru.json
101
locales/ru.json
@@ -1,15 +1,34 @@
|
|||||||
{
|
{
|
||||||
"menu": {
|
"menu": {
|
||||||
|
"wordofday": "🌅 Слово дня",
|
||||||
"add": "➕ Добавить слово",
|
"add": "➕ Добавить слово",
|
||||||
"vocab": "📚 Словарь",
|
"vocab": "📚 Словарь",
|
||||||
"task": "🧠 Задание",
|
"task": "🧠 Задание",
|
||||||
"practice": "💬 Практика",
|
"practice": "💬 Практика",
|
||||||
|
"exercises": "📖 Упражнения",
|
||||||
"words": "🎯 Тематические слова",
|
"words": "🎯 Тематические слова",
|
||||||
"import": "📖 Импорт",
|
"import": "📖 Импорт",
|
||||||
"stats": "📊 Статистика",
|
"stats": "📊 Статистика",
|
||||||
"settings": "⚙️ Настройки",
|
"settings": "⚙️ Настройки",
|
||||||
"below": "Главное меню доступно ниже ⤵️"
|
"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": {
|
"add_menu": {
|
||||||
"title": "➕ <b>Добавление слов</b>\n\nВыберите способ:",
|
"title": "➕ <b>Добавление слов</b>\n\nВыберите способ:",
|
||||||
"manual": "📝 Вручную",
|
"manual": "📝 Вручную",
|
||||||
@@ -55,7 +74,7 @@
|
|||||||
"skip_msg": "✅ Хорошо!\n\nТы можешь пройти тест позже командой /level_test\nили установить уровень вручную в /settings\n\nДавай начнём! Попробуй:\n• /words travel - тематическая подборка\n• /practice - диалог с AI\n• /add hello - добавить слово"
|
"skip_msg": "✅ Хорошо!\n\nТы можешь пройти тест позже командой /level_test\nили установить уровень вручную в /settings\n\nДавай начнём! Попробуй:\n• /words travel - тематическая подборка\n• /practice - диалог с AI\n• /add hello - добавить слово"
|
||||||
},
|
},
|
||||||
"add": {
|
"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": "⏳ Ищу перевод и примеры...",
|
"searching": "⏳ Ищу перевод и примеры...",
|
||||||
"examples_header": "<b>Примеры:</b>",
|
"examples_header": "<b>Примеры:</b>",
|
||||||
"translations_header": "<b>Переводы:</b>",
|
"translations_header": "<b>Переводы:</b>",
|
||||||
@@ -69,6 +88,13 @@
|
|||||||
"added_success": "✅ Слово '<b>{word}</b>' добавлено!\n\nВсего слов в словаре: {count}\n\nПродолжай добавлять новые слова или используй /task для практики!",
|
"added_success": "✅ Слово '<b>{word}</b>' добавлено!\n\nВсего слов в словаре: {count}\n\nПродолжай добавлять новые слова или используй /task для практики!",
|
||||||
"cancelled": "Отменено. Можешь добавить другое слово командой /add"
|
"cancelled": "Отменено. Можешь добавить другое слово командой /add"
|
||||||
},
|
},
|
||||||
|
"add_batch": {
|
||||||
|
"header": "📝 <b>Слова для добавления ({n}):</b>",
|
||||||
|
"translating": "⏳ Перевожу {n} слов...",
|
||||||
|
"choose": "Выбери слова для добавления или добавь все сразу:",
|
||||||
|
"truncated": "⚠️ Слишком много слов. Показаны первые {n}.",
|
||||||
|
"failed": "❌ Не удалось получить переводы. Попробуй позже."
|
||||||
|
},
|
||||||
"vocab": {
|
"vocab": {
|
||||||
"empty": "📚 Твой словарь пока пуст!\n\nДобавь первое слово командой /add или просто отправь мне слово.",
|
"empty": "📚 Твой словарь пока пуст!\n\nДобавь первое слово командой /add или просто отправь мне слово.",
|
||||||
"header": "<b>📚 Твой словарь:</b>",
|
"header": "<b>📚 Твой словарь:</b>",
|
||||||
@@ -180,7 +206,54 @@
|
|||||||
"invalid_format": "❌ Неверный формат времени!\n\nИспользуй формат <b>HH:MM</b> (например, 09:00 или 18:30)\nИли отправь /cancel для отмены",
|
"invalid_format": "❌ Неверный формат времени!\n\nИспользуй формат <b>HH:MM</b> (например, 09:00 или 18:30)\nИли отправь /cancel для отмены",
|
||||||
"time_set_title": "✅ <b>Время установлено!</b>",
|
"time_set_title": "✅ <b>Время установлено!</b>",
|
||||||
"status_on_line": "Статус: <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": {
|
"stats": {
|
||||||
"header": "📊 <b>Твоя статистика</b>",
|
"header": "📊 <b>Твоя статистика</b>",
|
||||||
@@ -330,5 +403,29 @@
|
|||||||
"err_not_found": "❌ Ошибка: слово не найдено",
|
"err_not_found": "❌ Ошибка: слово не найдено",
|
||||||
"already_exists": "Слово '{word}' уже в словаре",
|
"already_exists": "Слово '{word}' уже в словаре",
|
||||||
"added_single": "✅ Слово '{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
18
main.py
@@ -7,8 +7,7 @@ from aiogram.enums import ParseMode
|
|||||||
from aiogram.types import BotCommand
|
from aiogram.types import BotCommand
|
||||||
|
|
||||||
from config.settings import settings
|
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 bot.handlers import start, vocabulary, tasks, settings as settings_handler, words, import_text, practice, reminder, level_test, admin, exercises, wordofday, stories
|
||||||
from database.db import init_db
|
|
||||||
from services.reminder_service import init_reminder_service
|
from services.reminder_service import init_reminder_service
|
||||||
|
|
||||||
|
|
||||||
@@ -30,15 +29,14 @@ async def main():
|
|||||||
# Команды бота для меню Telegram
|
# Команды бота для меню Telegram
|
||||||
await bot.set_my_commands([
|
await bot.set_my_commands([
|
||||||
BotCommand(command="start", description="Запустить бота"),
|
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="task", description="Задания"),
|
||||||
BotCommand(command="practice", description="Диалог с AI"),
|
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="stats", description="Статистика"),
|
||||||
BotCommand(command="settings", description="Настройки"),
|
BotCommand(command="settings", description="Настройки"),
|
||||||
BotCommand(command="reminder", description="Напоминания"),
|
|
||||||
BotCommand(command="help", description="Справка"),
|
BotCommand(command="help", description="Справка"),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -51,11 +49,13 @@ async def main():
|
|||||||
dp.include_router(words.router)
|
dp.include_router(words.router)
|
||||||
dp.include_router(import_text.router)
|
dp.include_router(import_text.router)
|
||||||
dp.include_router(practice.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(reminder.router)
|
||||||
dp.include_router(admin.router)
|
dp.include_router(admin.router)
|
||||||
|
|
||||||
# Инициализация базы данных
|
# База данных инициализируется через Alembic миграции (make local-migrate)
|
||||||
await init_db()
|
|
||||||
|
|
||||||
# Инициализация и запуск сервиса напоминаний
|
# Инициализация и запуск сервиса напоминаний
|
||||||
reminder_service = init_reminder_service(bot)
|
reminder_service = init_reminder_service(bot)
|
||||||
|
|||||||
55
migrations/versions/20251209_add_mini_stories.py
Normal file
55
migrations/versions/20251209_add_mini_stories.py
Normal 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")
|
||||||
26
migrations/versions/20251209_add_story_translation.py
Normal file
26
migrations/versions/20251209_add_story_translation.py
Normal 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')
|
||||||
50
migrations/versions/20251209_add_word_of_day.py
Normal file
50
migrations/versions/20251209_add_word_of_day.py
Normal 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')
|
||||||
@@ -54,6 +54,40 @@ class AIService:
|
|||||||
self._cached_model: Optional[str] = None
|
self._cached_model: Optional[str] = None
|
||||||
self._cached_provider: Optional[AIProvider] = 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]:
|
async def _get_active_model(self, user_id: Optional[int] = None) -> tuple[str, AIProvider]:
|
||||||
"""
|
"""
|
||||||
Получить активную модель и провайдера из БД.
|
Получить активную модель и провайдера из БД.
|
||||||
@@ -136,15 +170,8 @@ class AIService:
|
|||||||
# Конвертируем ответ Google в формат OpenAI для совместимости
|
# Конвертируем ответ Google в формат OpenAI для совместимости
|
||||||
text = data["candidates"][0]["content"]["parts"][0]["text"]
|
text = data["candidates"][0]["content"]["parts"][0]["text"]
|
||||||
|
|
||||||
# Убираем markdown обёртку если есть (```json ... ```)
|
# Убираем markdown обёртку если есть (```json ... ``` или ```...```)
|
||||||
if text.startswith('```'):
|
text = self._strip_markdown_code_block(text)
|
||||||
lines = text.split('\n')
|
|
||||||
# Убираем первую строку (```json) и последнюю (```)
|
|
||||||
if lines[-1].strip() == '```':
|
|
||||||
lines = lines[1:-1]
|
|
||||||
else:
|
|
||||||
lines = lines[1:]
|
|
||||||
text = '\n'.join(lines)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"choices": [{
|
"choices": [{
|
||||||
@@ -1080,6 +1107,215 @@ User: {user_message}
|
|||||||
return self._get_jlpt_fallback_questions()
|
return self._get_jlpt_fallback_questions()
|
||||||
return self._get_cefr_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]:
|
def _get_cefr_fallback_questions(self) -> List[Dict]:
|
||||||
"""Fallback вопросы для CEFR (английский и европейские языки)"""
|
"""Fallback вопросы для CEFR (английский и европейские языки)"""
|
||||||
return [
|
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]:
|
def _get_jlpt_fallback_questions(self) -> List[Dict]:
|
||||||
"""Fallback вопросы для JLPT (японский)"""
|
"""Fallback вопросы для JLPT (японский)"""
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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
|
from database.db import async_session_maker
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -30,9 +30,26 @@ class ReminderService:
|
|||||||
replace_existing=True
|
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()
|
self.scheduler.start()
|
||||||
logger.info("Планировщик напоминаний запущен")
|
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):
|
def shutdown(self):
|
||||||
"""Остановить планировщик"""
|
"""Остановить планировщик"""
|
||||||
self.scheduler.shutdown()
|
self.scheduler.shutdown()
|
||||||
@@ -97,6 +114,17 @@ class ReminderService:
|
|||||||
|
|
||||||
return time_diff < 300 # 5 минут в секундах
|
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):
|
async def _send_reminder(self, user: User, session: AsyncSession):
|
||||||
"""
|
"""
|
||||||
Отправить напоминание пользователю
|
Отправить напоминание пользователю
|
||||||
@@ -106,18 +134,37 @@ class ReminderService:
|
|||||||
session: Сессия базы данных
|
session: Сессия базы данных
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
message_text = (
|
from services.wordofday_service import wordofday_service
|
||||||
"⏰ <b>Время для практики!</b>\n\n"
|
from utils.i18n import t
|
||||||
"Не забудь потренироваться сегодня:\n"
|
|
||||||
"• /task - выполни задания\n"
|
lang = user.language_interface or "ru"
|
||||||
"• /practice - попрактикуй диалог\n"
|
|
||||||
"• /words - добавь новые слова\n\n"
|
# Получаем слово дня для пользователя
|
||||||
"💪 Регулярная практика - ключ к успеху!"
|
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(
|
await self.bot.send_message(
|
||||||
chat_id=user.telegram_id,
|
chat_id=user.telegram_id,
|
||||||
text=message_text
|
text="\n".join(message_parts),
|
||||||
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Обновляем время последнего напоминания
|
# Обновляем время последнего напоминания
|
||||||
|
|||||||
227
services/wordofday_service.py
Normal file
227
services/wordofday_service.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
"""Сервис генерации слова дня"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Optional, Dict, List
|
||||||
|
|
||||||
|
from sqlalchemy import select, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.db import async_session_maker
|
||||||
|
from database.models import WordOfDay, LanguageLevel, JLPTLevel, JLPT_LANGUAGES
|
||||||
|
from services.ai_service import ai_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Уровни для каждого языка
|
||||||
|
CEFR_LEVELS = [level.value for level in LanguageLevel] # A1-C2
|
||||||
|
JLPT_LEVELS = [level.value for level in JLPTLevel] # N5-N1
|
||||||
|
|
||||||
|
# Языки для генерации
|
||||||
|
LEARNING_LANGUAGES = ["en", "ja"]
|
||||||
|
|
||||||
|
|
||||||
|
class WordOfDayService:
|
||||||
|
"""Сервис для генерации и получения слова дня"""
|
||||||
|
|
||||||
|
async def generate_all_words_for_today(self) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Генерация слов дня для всех языков и уровней.
|
||||||
|
Вызывается в 00:00 UTC.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict с количеством сгенерированных слов по языкам
|
||||||
|
"""
|
||||||
|
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
results = {"en": 0, "ja": 0, "errors": 0}
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
for lang in LEARNING_LANGUAGES:
|
||||||
|
levels = JLPT_LEVELS if lang in JLPT_LANGUAGES else CEFR_LEVELS
|
||||||
|
|
||||||
|
for level in levels:
|
||||||
|
try:
|
||||||
|
# Проверяем, не сгенерировано ли уже
|
||||||
|
existing = await self._get_word_for_date(
|
||||||
|
session, today, lang, level
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
logger.debug(
|
||||||
|
f"Слово дня уже существует: {lang}/{level}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Получаем список недавних слов для исключения
|
||||||
|
excluded = await self._get_recent_words(session, lang, level, days=30)
|
||||||
|
|
||||||
|
# Генерируем слово
|
||||||
|
word_data = await ai_service.generate_word_of_day(
|
||||||
|
level=level,
|
||||||
|
learning_lang=lang,
|
||||||
|
translation_lang="ru", # Базовый перевод на русский
|
||||||
|
excluded_words=excluded
|
||||||
|
)
|
||||||
|
|
||||||
|
if word_data:
|
||||||
|
word_of_day = WordOfDay(
|
||||||
|
word=word_data.get("word", ""),
|
||||||
|
transcription=word_data.get("transcription"),
|
||||||
|
translation=word_data.get("translation", ""),
|
||||||
|
examples=word_data.get("examples"),
|
||||||
|
synonyms=word_data.get("synonyms"),
|
||||||
|
etymology=word_data.get("etymology"),
|
||||||
|
learning_lang=lang,
|
||||||
|
level=level,
|
||||||
|
date=today
|
||||||
|
)
|
||||||
|
session.add(word_of_day)
|
||||||
|
await session.commit()
|
||||||
|
results[lang] += 1
|
||||||
|
logger.info(
|
||||||
|
f"Сгенерировано слово дня: {word_data.get('word')} "
|
||||||
|
f"({lang}/{level})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
results["errors"] += 1
|
||||||
|
logger.warning(
|
||||||
|
f"Не удалось сгенерировать слово для {lang}/{level}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
results["errors"] += 1
|
||||||
|
logger.error(
|
||||||
|
f"Ошибка генерации слова для {lang}/{level}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
total = results["en"] + results["ja"]
|
||||||
|
logger.info(
|
||||||
|
f"Генерация слов дня завершена: всего={total}, "
|
||||||
|
f"en={results['en']}, ja={results['ja']}, ошибок={results['errors']}"
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def get_word_of_day(
|
||||||
|
self,
|
||||||
|
learning_lang: str,
|
||||||
|
level: str,
|
||||||
|
target_date: Optional[datetime] = None
|
||||||
|
) -> Optional[WordOfDay]:
|
||||||
|
"""
|
||||||
|
Получить слово дня для языка и уровня.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
learning_lang: Язык изучения (en/ja)
|
||||||
|
level: Уровень (A1-C2 или N5-N1)
|
||||||
|
target_date: Дата (по умолчанию сегодня)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WordOfDay или None
|
||||||
|
"""
|
||||||
|
if target_date is None:
|
||||||
|
target_date = datetime.utcnow().replace(
|
||||||
|
hour=0, minute=0, second=0, microsecond=0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
target_date = target_date.replace(
|
||||||
|
hour=0, minute=0, second=0, microsecond=0
|
||||||
|
)
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
return await self._get_word_for_date(
|
||||||
|
session, target_date, learning_lang, level
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_word_for_date(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
target_date: datetime,
|
||||||
|
learning_lang: str,
|
||||||
|
level: str
|
||||||
|
) -> Optional[WordOfDay]:
|
||||||
|
"""Получить слово из БД для конкретной даты"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(WordOfDay).where(
|
||||||
|
and_(
|
||||||
|
WordOfDay.date == target_date,
|
||||||
|
WordOfDay.learning_lang == learning_lang,
|
||||||
|
WordOfDay.level == level
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def _get_recent_words(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
learning_lang: str,
|
||||||
|
level: str,
|
||||||
|
days: int = 30
|
||||||
|
) -> List[str]:
|
||||||
|
"""Получить список недавних слов для исключения"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||||
|
result = await session.execute(
|
||||||
|
select(WordOfDay.word).where(
|
||||||
|
and_(
|
||||||
|
WordOfDay.learning_lang == learning_lang,
|
||||||
|
WordOfDay.level == level,
|
||||||
|
WordOfDay.date >= cutoff_date
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [row[0] for row in result.fetchall()]
|
||||||
|
|
||||||
|
async def format_word_for_user(
|
||||||
|
self,
|
||||||
|
word: WordOfDay,
|
||||||
|
translation_lang: str = "ru",
|
||||||
|
ui_lang: str = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Форматировать слово дня для отображения пользователю.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
word: Объект WordOfDay
|
||||||
|
translation_lang: Язык перевода для пользователя
|
||||||
|
ui_lang: Язык интерфейса (для локализации заголовков)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Отформатированная строка
|
||||||
|
"""
|
||||||
|
from utils.i18n import t
|
||||||
|
|
||||||
|
lang = ui_lang or translation_lang or "ru"
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Заголовок со словом
|
||||||
|
if word.transcription:
|
||||||
|
lines.append(f"📚 <b>{word.word}</b> [{word.transcription}]")
|
||||||
|
else:
|
||||||
|
lines.append(f"📚 <b>{word.word}</b>")
|
||||||
|
|
||||||
|
# Перевод
|
||||||
|
lines.append(f"📝 {word.translation}")
|
||||||
|
|
||||||
|
# Синонимы
|
||||||
|
if word.synonyms:
|
||||||
|
lines.append(f"\n🔄 <b>{t(lang, 'wod.synonyms')}:</b> {word.synonyms}")
|
||||||
|
|
||||||
|
# Примеры
|
||||||
|
if word.examples:
|
||||||
|
lines.append(f"\n📖 <b>{t(lang, 'wod.examples')}:</b>")
|
||||||
|
for i, example in enumerate(word.examples[:3], 1):
|
||||||
|
sentence = example.get("sentence", "")
|
||||||
|
translation = example.get("translation", "")
|
||||||
|
lines.append(f" {i}. {sentence}")
|
||||||
|
if translation:
|
||||||
|
lines.append(f" <i>{translation}</i>")
|
||||||
|
|
||||||
|
# Этимология/интересный факт
|
||||||
|
if word.etymology:
|
||||||
|
lines.append(f"\n💡 {word.etymology}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# Глобальный экземпляр сервиса
|
||||||
|
wordofday_service = WordOfDayService()
|
||||||
3
ИДЕИ.txt
3
ИДЕИ.txt
@@ -1,3 +0,0 @@
|
|||||||
Сделать задачки с помощью голосовых, человек должен написать что услышал, человек должен записать голосовой слова
|
|
||||||
Сделать задачки с картинками, человек должен написать что изоображено на картинке
|
|
||||||
Сделать задания по темам тип времён или неправильных глаголов на английском
|
|
||||||
Reference in New Issue
Block a user