""" Orchestrator — мозг чат-учителя. Выбирает следующий урок для пользователя на основе: 1. retention_status = 'overdue' или 'needs_review' → повторение 2. mastery_score < 75 → повторение слабого навыка 3. Иначе → следующий урок по порядку Никакого AI — только детерминированная логика. """ import logging from typing import Optional, Dict, Any logger = logging.getLogger(__name__) async def get_next_lesson(conn, user_id: int) -> Dict[str, Any]: """ Выбирает следующий урок для пользователя. Возвращает: { "type": "new" | "review" | "test", "lesson": {...} | None, "reason": str, "skill_tag": str | None } """ # Получаем текущий уровень пользователя current_level = await conn.fetchval(""" SELECT current_level FROM users WHERE telegram_id = $1 """, user_id) or "A1" # ============ ШАГ 1: Проверяем retention_status ============ # Если есть навыки, которые давно не повторяли — повторяем их overdue_skill = await conn.fetchrow(""" SELECT skill_tag, mastery_score, days_since_last_attempt, retention_status FROM user_skill_mastery WHERE user_id = $1 AND retention_status IN ('overdue', 'needs_review') AND attempts_count >= 2 ORDER BY days_since_last_attempt DESC LIMIT 1 """, user_id) if overdue_skill: lesson = await conn.fetchrow(""" SELECT l.lesson_id, l.topic_id, l.skill_tag, l.title_pl, l.title_ru, l.title_uk, l.title_en FROM learning_lessons l WHERE l.skill_tag = $1 AND l.is_active = TRUE ORDER BY l.order_number LIMIT 1 """, overdue_skill["skill_tag"]) if lesson: logger.info(f"Orchestrator: review (overdue) for user={user_id}, skill={overdue_skill['skill_tag']}") return { "type": "review", "lesson": dict(lesson), "reason": "overdue", "skill_tag": overdue_skill["skill_tag"], } # ============ ШАГ 2: Проверяем mastery < 75 ============ weak_skill = await conn.fetchrow(""" SELECT skill_tag, mastery_score, repeated_error_count, trend FROM user_skill_mastery WHERE user_id = $1 AND mastery_score < 75 AND attempts_count >= 2 ORDER BY mastery_score ASC LIMIT 1 """, user_id) if weak_skill: lesson = await conn.fetchrow(""" SELECT l.lesson_id, l.topic_id, l.skill_tag, l.title_pl, l.title_ru, l.title_uk, l.title_en FROM learning_lessons l WHERE l.skill_tag = $1 AND l.is_active = TRUE ORDER BY l.order_number LIMIT 1 """, weak_skill["skill_tag"]) if lesson: logger.info(f"Orchestrator: review (weak) for user={user_id}, skill={weak_skill['skill_tag']}, mastery={weak_skill['mastery_score']}") return { "type": "review", "lesson": dict(lesson), "reason": "weak_mastery", "skill_tag": weak_skill["skill_tag"], } # ============ ШАГ 3: Проверяем, нужно ли пройти тест темы ============ # Если пользователь завершил все уроки темы, но не прошёл тест topic_for_test = await conn.fetchrow(""" SELECT tp.topic_id, tp.lessons_total, tp.lessons_completed, tp.test_completed FROM user_topic_progress tp WHERE tp.user_id = $1 AND tp.lessons_completed >= tp.lessons_total AND tp.lessons_total > 0 AND tp.test_completed = FALSE ORDER BY tp.topic_id LIMIT 1 """, user_id) if topic_for_test: logger.info(f"Orchestrator: test for user={user_id}, topic={topic_for_test['topic_id']}") return { "type": "test", "lesson": None, "reason": "topic_completed_no_test", "topic_id": topic_for_test["topic_id"], } # ============ ШАГ 4: Следующий урок по порядку ============ # Находим первый непройденный урок в текущем уровне next_lesson = await conn.fetchrow(""" SELECT l.lesson_id, l.topic_id, l.skill_tag, l.title_pl, l.title_ru, l.title_uk, l.title_en, t.topic_id as topic_id, t.order_number as topic_order FROM learning_lessons l JOIN learning_topics t ON l.topic_id = t.topic_id WHERE t.level_code = $1 AND l.is_active = TRUE AND l.lesson_id NOT IN ( SELECT lesson_id FROM user_lesson_progress WHERE user_id = $2 AND completed = TRUE ) ORDER BY t.order_number, l.order_number LIMIT 1 """, current_level, user_id) if next_lesson: logger.info(f"Orchestrator: new lesson for user={user_id}, lesson={next_lesson['lesson_id']}") return { "type": "new", "lesson": dict(next_lesson), "reason": "next_in_order", "skill_tag": next_lesson["skill_tag"], } # ============ ШАГ 5: Всё пройдено ============ logger.info(f"Orchestrator: all completed for user={user_id}") return { "type": "completed", "lesson": None, "reason": "all_completed", "skill_tag": None, } async def get_lesson_by_id(conn, lesson_id: str) -> Optional[Dict[str, Any]]: """Получает урок по ID со всеми данными: объяснение, примеры, вопросы.""" lesson = await conn.fetchrow(""" SELECT * FROM learning_lessons WHERE lesson_id = $1 AND is_active = TRUE """, lesson_id) if not lesson: return None examples = await conn.fetch(""" SELECT * FROM lesson_examples WHERE lesson_id = $1 ORDER BY order_number """, lesson_id) questions = await conn.fetch(""" SELECT * FROM learning_questions WHERE lesson_id = $1 AND is_test_question = FALSE ORDER BY id """, lesson_id) questions_data = [] for q in questions: options = await conn.fetch(""" SELECT * FROM question_options WHERE question_id = $1 """, q["question_id"]) questions_data.append({ "question": dict(q), "options": [dict(o) for o in options], }) return { "lesson": dict(lesson), "examples": [dict(e) for e in examples], "questions": questions_data, } async def get_lesson_questions(conn, lesson_id: str) -> list: """Получает только вопросы урока (без объяснения).""" questions = await conn.fetch(""" SELECT * FROM learning_questions WHERE lesson_id = $1 AND is_test_question = FALSE ORDER BY id """, lesson_id) questions_data = [] for q in questions: options = await conn.fetch(""" SELECT * FROM question_options WHERE question_id = $1 """, q["question_id"]) questions_data.append({ "question": dict(q), "options": [dict(o) for o in options], }) return questions_data async def get_test_questions(conn, topic_id: str) -> list: """Получает вопросы финального теста по теме.""" questions = await conn.fetch(""" SELECT * FROM learning_questions WHERE topic_id = $1 AND is_test_question = TRUE ORDER BY test_order """, topic_id) questions_data = [] for q in questions: options = await conn.fetch(""" SELECT * FROM question_options WHERE question_id = $1 """, q["question_id"]) questions_data.append({ "question": dict(q), "options": [dict(o) for o in options], }) return questions_data async def get_random_reaction(conn, reaction_type: str, lang: str) -> str: """Получает случайную реакцию из банка по типу.""" row = await conn.fetchrow(""" SELECT text_pl, text_ru, text_uk, text_en FROM teacher_reactions WHERE reaction_type = $1 ORDER BY RANDOM() LIMIT 1 """, reaction_type) if not row: # Запасные реакции, если таблица пуста fallbacks = { "correct": { "pl": "Świetnie!", "ru": "Отлично!", "uk": "Чудово!", "en": "Great!" }, "almost": { "pl": "Prawie!", "ru": "Почти!", "uk": "Майже!", "en": "Almost!" }, "wrong": { "pl": "Nie tym razem.", "ru": "Не в этот раз.", "uk": "Не цього разу.", "en": "Not this time." }, } return fallbacks.get(reaction_type, {}).get(lang, "OK") field = f"text_{lang}" return row.get(field) or row.get("text_en") or "OK" async def get_wrong_because_explanation(conn, wrong_because: str, lang: str) -> str: """Получает объяснение ошибки по тегу wrong_because.""" row = await conn.fetchrow(""" SELECT explanation_pl, explanation_ru, explanation_uk, explanation_en FROM wrong_because_explanations WHERE wrong_because = $1 """, wrong_because) if not row: return "" field = f"explanation_{lang}" return row.get(field) or row.get("explanation_en") or ""