Files
filwordspl-backend/routers/orchestrator.py
T
2026-09-20 11:45:58 +00:00

286 lines
10 KiB
Python

"""
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 ""