initial commit (backend)
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
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 ""
|
||||
Reference in New Issue
Block a user