286 lines
10 KiB
Python
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 "" |