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

872 lines
40 KiB
Python

"""
Learning Router — система уроков Filwords PL.
Уровни → Темы → Уроки → Финальный тест → Аналитика.
Данные в отдельных файлах: learning_data/level_*.py
"""
import logging
import json
import statistics
from datetime import datetime, timezone
import httpx
import re
from fastapi import APIRouter, Request, HTTPException
from auth import require_auth
from database import get_pool
from config import TOGETHER_API_KEY
from .learning_data import init_learning_data
from .learning_profile import get_weak_skills, get_strong_skills
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/learning", tags=["learning"])
TOGETHER_API_URL = "https://api.together.xyz/v1/chat/completions"
ANALYTICS_MODEL = "MiniMaxAI/MiniMax-M3"
# Доступные функции приложения (для AI — чтобы не галлюцинировал)
AVAILABLE_FEATURES = [
"lessons",
"grammar_rules",
"cheat_sheets",
"word_game",
"sentence_game",
"choose_word",
"word_bubbles",
"pronunciation",
"ai_chat",
]
# Понятные названия разделов для пользователя
AVAILABLE_FEATURES_DESCRIPTION = """
Доступные разделы приложения (называй их ТАК, как они называются в интерфейсе пользователя):
- "Уроки" — уроки и тесты
- "Филворды" — игра в слова (найди слова в сетке букв)
- "Составь предложение" — составление предложений из слов
- "Выбери слово" — выбор правильного перевода
- "Пузыри слов" — сбор слов из пузырей
- "Произношение" — практика произношения
- "Грамматика" — правила и шпаргалки
- "AI-помощник" — чат с ИИ
НИКОГДА не используй технические названия: word_game, sentence_game, choose_word, word_bubbles, ai_chat, lessons, pronunciation, grammar_rules, cheat_sheets.
"""
async def get_skill_names(conn):
"""Загружает понятные названия навыков из БД (title_ru уроков)."""
rows = await conn.fetch("""
SELECT DISTINCT ON (skill_tag) skill_tag, title_ru
FROM learning_lessons
WHERE is_active = TRUE
ORDER BY skill_tag, order_number
""")
return {r["skill_tag"]: r["title_ru"] for r in rows}
async def get_weak_lessons(conn, skill_tags):
"""Находит уроки для перепрохождения по слабым навыкам."""
rows = await conn.fetch("""
SELECT DISTINCT ON (l.skill_tag) l.lesson_id, l.skill_tag, l.title_ru, t.name_ru as topic_name
FROM learning_lessons l
JOIN learning_topics t ON l.topic_id = t.topic_id
WHERE l.skill_tag = ANY($1) AND l.is_active = TRUE
ORDER BY l.skill_tag, l.order_number
""", skill_tags)
return [dict(r) for r in rows]
async def update_skill_mastery(conn, user_id: int, skill_tag: str):
"""Рассчитывает и обновляет мастерство по навыку на основе всех ответов пользователя"""
# Все ответы по навыку с question_type и wrong_because
answers = await conn.fetch("""
SELECT correct, time_taken_ms, hint_used, question_type, wrong_because, is_final_answer, created_at
FROM user_learning_answers
WHERE user_id = $1 AND skill_tag = $2
ORDER BY created_at ASC
""", user_id, skill_tag)
if not answers:
return None
total_attempts = len(answers)
correct_count = sum(1 for a in answers if a["correct"])
accuracy = (correct_count / total_attempts * 100) if total_attempts > 0 else 0
# Последние попытки
recent = answers[-5:]
recent_correct = sum(1 for a in recent if a["correct"])
recent_accuracy = (recent_correct / len(recent) * 100) if recent else 0
# last_3 / last_5 / last_10 accuracy
def calc_last_n(n):
last_n = answers[-n:] if len(answers) >= n else None
if not last_n or len(last_n) < n:
return None
correct = sum(1 for a in last_n if a["correct"])
return round((correct / n * 100), 2)
last_3_accuracy = calc_last_n(3)
last_5_accuracy = calc_last_n(5)
last_10_accuracy = calc_last_n(10)
# Время: среднее, медиана
all_times = [a["time_taken_ms"] for a in answers if a["time_taken_ms"] and a["time_taken_ms"] > 0]
correct_times = [a["time_taken_ms"] for a in answers if a["correct"] and a["time_taken_ms"] and a["time_taken_ms"] > 0]
wrong_times = [a["time_taken_ms"] for a in answers if not a["correct"] and a["time_taken_ms"] and a["time_taken_ms"] > 0]
avg_time_ms = int(sum(correct_times) / len(correct_times)) if correct_times else 0
median_time_ms = int(statistics.median(all_times)) if all_times else 0
median_correct_time_ms = int(statistics.median(correct_times)) if correct_times else 0
median_wrong_time_ms = int(statistics.median(wrong_times)) if wrong_times else 0
# Зависимость от подсказок
hints_used = sum(1 for a in answers if a["hint_used"])
hint_dependency = (hints_used / total_attempts * 100) if total_attempts > 0 else 0
# Разделение recognition / production / application
recognition_answers = [a for a in answers if a["question_type"] == "recognition"]
production_answers = [a for a in answers if a["question_type"] == "production"]
application_answers = [a for a in answers if a["question_type"] == "application"]
recognition_attempts = len(recognition_answers)
recognition_correct = sum(1 for a in recognition_answers if a["correct"])
production_attempts = len(production_answers)
production_correct = sum(1 for a in production_answers if a["correct"])
application_attempts = len(application_answers)
application_correct = sum(1 for a in application_answers if a["correct"])
recognition_score = round((recognition_correct / recognition_attempts * 100), 2) if recognition_attempts > 0 else None
production_score = round((production_correct / production_attempts * 100), 2) if production_attempts > 0 else None
application_score = round((application_correct / application_attempts * 100), 2) if application_attempts > 0 else None
# Passive / Active knowledge
passive_knowledge = recognition_score if recognition_score is not None else 0
active_scores = [s for s in [production_score, application_score] if s is not None]
active_knowledge = round(sum(active_scores) / len(active_scores), 2) if active_scores else 0
# Ошибки
error_count = sum(1 for a in answers if not a["correct"] and a["wrong_because"])
error_types = {}
for a in answers:
if not a["correct"] and a["wrong_because"]:
error_types[a["wrong_because"]] = error_types.get(a["wrong_because"], 0) + 1
repeated_error_count = sum(1 for count in error_types.values() if count >= 2)
# Дней с последней попытки
last_attempt = answers[-1]["created_at"]
if last_attempt:
now = datetime.now(timezone.utc)
if last_attempt.tzinfo is None:
last_attempt = last_attempt.replace(tzinfo=timezone.utc)
days_since = (now - last_attempt).days
else:
days_since = 0
# Мастерство
no_hint_score = max(0, 100 - hint_dependency)
mastery = round(accuracy * 0.4 + recent_accuracy * 0.3 + no_hint_score * 0.3, 2)
# Получаем прошлый mastery для расчёта delta
previous_row = await conn.fetchrow("""
SELECT mastery_score, previous_mastery_score FROM user_skill_mastery
WHERE user_id = $1 AND skill_tag = $2
""", user_id, skill_tag)
previous_mastery = float(previous_row["mastery_score"]) if previous_row and previous_row["mastery_score"] else mastery
mastery_delta = round(mastery - previous_mastery, 2)
# Trend
if total_attempts < 3:
trend = "insufficient_data"
elif last_3_accuracy is not None and last_10_accuracy is not None:
if last_3_accuracy > last_10_accuracy + 5:
trend = "improving"
elif last_3_accuracy < last_10_accuracy - 5:
trend = "declining"
else:
trend = "stable"
elif last_3_accuracy is not None and accuracy is not None:
if last_3_accuracy > accuracy + 5:
trend = "improving"
elif last_3_accuracy < accuracy - 5:
trend = "declining"
else:
trend = "stable"
else:
trend = "stable"
# Retention status
if days_since == 0:
retention_status = "fresh"
elif days_since <= 3:
retention_status = "fresh"
elif days_since <= 7:
retention_status = "aging"
elif days_since <= 14:
retention_status = "needs_review"
else:
retention_status = "overdue"
# Fluency score: скорость + точность + автоматизация
speed_score = 100 if median_correct_time_ms and median_correct_time_ms < 5000 else (50 if median_correct_time_ms and median_correct_time_ms < 15000 else 20)
fluency = round(accuracy * 0.4 + active_knowledge * 0.3 + speed_score * 0.3, 2)
# Уверенность
if len(recent) >= 3:
if all(a["correct"] for a in recent[-3:]):
confidence = min(100, mastery + 15)
elif not any(a["correct"] for a in recent[-3:]):
confidence = max(0, mastery - 20)
else:
confidence = mastery
else:
confidence = mastery
confidence = round(min(100, max(0, confidence)), 2)
# Сохраняем в user_skill_mastery
await conn.execute("""
INSERT INTO user_skill_mastery (
user_id, skill_tag, attempts_count, correct_count,
recognition_attempts, recognition_correct, recognition_score,
production_attempts, production_correct, production_score,
application_attempts, application_correct, application_score,
retention_score, hint_dependency, average_time_ms, median_time_ms,
median_correct_time_ms, median_wrong_time_ms,
recent_accuracy, last_3_accuracy, last_5_accuracy, last_10_accuracy,
long_term_accuracy, mastery_score, previous_mastery_score, mastery_delta,
trend, retention_status, fluency_score, passive_knowledge_score, active_knowledge_score,
confidence, error_count, repeated_error_count, days_since_last_attempt, last_attempt_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, 0, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, NOW())
ON CONFLICT (user_id, skill_tag) DO UPDATE SET
attempts_count = $3,
correct_count = $4,
recognition_attempts = $5,
recognition_correct = $6,
recognition_score = $7,
production_attempts = $8,
production_correct = $9,
production_score = $10,
application_attempts = $11,
application_correct = $12,
application_score = $13,
hint_dependency = $14,
average_time_ms = $15,
median_time_ms = $16,
median_correct_time_ms = $17,
median_wrong_time_ms = $18,
recent_accuracy = $19,
last_3_accuracy = $20,
last_5_accuracy = $21,
last_10_accuracy = $22,
long_term_accuracy = $23,
mastery_score = $24,
previous_mastery_score = $25,
mastery_delta = $26,
trend = $27,
retention_status = $28,
fluency_score = $29,
passive_knowledge_score = $30,
active_knowledge_score = $31,
confidence = $32,
error_count = $33,
repeated_error_count = $34,
days_since_last_attempt = $35,
last_attempt_at = NOW()
""", user_id, skill_tag, total_attempts, correct_count, recognition_attempts, recognition_correct, recognition_score, production_attempts, production_correct, production_score, application_attempts, application_correct, application_score, hint_dependency, avg_time_ms, median_time_ms, median_correct_time_ms, median_wrong_time_ms, recent_accuracy, last_3_accuracy, last_5_accuracy, last_10_accuracy, accuracy, mastery, previous_mastery, mastery_delta, trend, retention_status, fluency, passive_knowledge, active_knowledge, confidence, error_count, repeated_error_count, days_since)
return {
"skill_tag": skill_tag,
"attempts": total_attempts,
"accuracy": round(accuracy, 1),
"recent_accuracy": round(recent_accuracy, 1),
"recognition_score": recognition_score,
"production_score": production_score,
"application_score": application_score,
"mastery": mastery,
"mastery_delta": mastery_delta,
"trend": trend,
"retention_status": retention_status,
"fluency": fluency,
"passive_knowledge": passive_knowledge,
"active_knowledge": active_knowledge,
"confidence": confidence,
}
async def update_topic_progress(conn, user_id: int, topic_id: str):
"""Обновляет прогресс темы: lessons_total, lessons_completed, topic_mastery"""
# Общее количество уроков в теме
lessons_total = await conn.fetchval("""
SELECT COUNT(*) FROM learning_lessons WHERE topic_id = $1 AND is_active = TRUE
""", topic_id) or 0
# Количество завершённых уроков
lessons_completed = await conn.fetchval("""
SELECT COUNT(*) FROM user_lesson_progress WHERE user_id = $1 AND lesson_id IN (
SELECT lesson_id FROM learning_lessons WHERE topic_id = $2
) AND completed = TRUE
""", user_id, topic_id) or 0
# Тестовый результат
test_row = await conn.fetchrow("""
SELECT test_completed, test_score FROM user_topic_progress WHERE user_id = $1 AND topic_id = $2
""", user_id, topic_id)
test_completed = test_row["test_completed"] if test_row else False
test_score = float(test_row["test_score"]) if test_row and test_row["test_score"] else 0
# Среднее мастерство по навыкам темы
skills_avg = await conn.fetchval("""
SELECT COALESCE(AVG(mastery_score), 0) FROM user_skill_mastery
WHERE user_id = $1 AND skill_tag IN (
SELECT DISTINCT skill_tag FROM learning_questions WHERE topic_id = $2
)
""", user_id, topic_id) or 0
# Мастерство темы
if test_completed and test_score > 0:
topic_mastery = round(float(skills_avg) * 0.4 + float(test_score) * 0.6, 2)
else:
topic_mastery = round(float(skills_avg), 2)
# Обновляем
await conn.execute("""
INSERT INTO user_topic_progress (user_id, topic_id, lessons_total, lessons_completed, test_completed, test_score, topic_mastery)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id, topic_id) DO UPDATE SET
lessons_total = $3,
lessons_completed = $4,
test_completed = $5,
test_score = $6,
topic_mastery = $7
""", user_id, topic_id, lessons_total, lessons_completed, test_completed, test_score, topic_mastery)
return {
"lessons_total": lessons_total,
"lessons_completed": lessons_completed,
"test_completed": test_completed,
"test_score": round(test_score, 1),
"topic_mastery": topic_mastery,
}
async def update_ai_context(conn, user_id: int):
"""Обновляет AI-контекст пользователя: сильные/слабые навыки, общее мастерство, прогресс уровня"""
# Получаем текущий уровень
current_level = await conn.fetchval("""
SELECT current_level FROM users WHERE telegram_id = $1
""", user_id) or "A1"
# Все темы пользователя
topics = await conn.fetch("""
SELECT topic_id, topic_mastery, test_completed FROM user_topic_progress WHERE user_id = $1
""", user_id)
completed_topics = sum(1 for t in topics if t["test_completed"])
completed_lessons = await conn.fetchval("""
SELECT COUNT(*) FROM user_lesson_progress WHERE user_id = $1 AND completed = TRUE
""", user_id) or 0
# Всего тем в уровне
level_topics_total = await conn.fetchval("""
SELECT COUNT(*) FROM learning_topics WHERE level_code = $1 AND is_active = TRUE
""", current_level) or 0
# Процент завершения уровня
level_completion_percent = round((completed_topics / level_topics_total * 100), 2) if level_topics_total > 0 else 0
# Общее мастерство
overall_mastery = await conn.fetchval("""
SELECT COALESCE(AVG(topic_mastery), 0) FROM user_topic_progress WHERE user_id = $1
""", user_id) or 0
overall_mastery = float(overall_mastery) if overall_mastery else 0
# Сильные и слабые навыки — используем общий источник
strong_skills = await get_strong_skills(conn, user_id, limit=8)
weak_skills = await get_weak_skills(conn, user_id, limit=5)
strong_skill_tags = [s["skill_tag"] for s in strong_skills]
weak_skill_tags = [s["skill_tag"] for s in weak_skills]
# Частые ошибки
errors = await conn.fetch("""
SELECT wrong_because, COUNT(*) as cnt
FROM user_learning_answers
WHERE user_id = $1 AND wrong_because IS NOT NULL AND correct = FALSE
GROUP BY wrong_because
ORDER BY cnt DESC
LIMIT 5
""", user_id)
recurring_errors = [e["wrong_because"] for e in errors]
# Обновляем ai_user_context
await conn.execute("""
INSERT INTO ai_user_context (user_id, current_level, completed_topics, level_topics_total, level_completion_percent, completed_lessons, overall_mastery, strong_skills, weak_skills, recurring_errors, analytics_enabled, last_analysis_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, TRUE, NOW())
ON CONFLICT (user_id) DO UPDATE SET
current_level = $2,
completed_topics = $3,
level_topics_total = $4,
level_completion_percent = $5,
completed_lessons = $6,
overall_mastery = $7,
strong_skills = $8,
weak_skills = $9,
recurring_errors = $10,
analytics_enabled = TRUE,
last_analysis_at = NOW()
""", user_id, current_level, completed_topics, level_topics_total, level_completion_percent, completed_lessons, round(float(overall_mastery), 2), json.dumps(strong_skill_tags), json.dumps(weak_skill_tags), json.dumps(recurring_errors))
return {
"completed_topics": completed_topics,
"level_topics_total": level_topics_total,
"level_completion_percent": level_completion_percent,
"completed_lessons": completed_lessons,
"overall_mastery": round(float(overall_mastery), 2),
"strong_skills": strong_skill_tags,
"weak_skills": weak_skill_tags,
}
@router.get("/levels")
async def get_learning_levels(request: Request):
"""Получить все уровни обучения"""
await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM learning_levels WHERE is_active = TRUE ORDER BY sort_order")
return {"levels": [dict(row) for row in rows]}
@router.get("/topics")
async def get_topics(request: Request, level: str = "A1"):
"""Получить темы для уровня"""
await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM learning_topics WHERE level_code = $1 AND is_active = TRUE ORDER BY order_number", level)
return {"topics": [dict(row) for row in rows]}
@router.get("/topics/{topic_id}")
async def get_topic(request: Request, topic_id: str):
"""Получить информацию о теме и список уроков"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
topic = await conn.fetchrow("SELECT * FROM learning_topics WHERE topic_id = $1", topic_id)
if not topic:
raise HTTPException(404, "Topic not found")
lessons = await conn.fetch("SELECT * FROM learning_lessons WHERE topic_id = $1 AND is_active = TRUE ORDER BY order_number", topic_id)
progress = await conn.fetchrow("SELECT * FROM user_topic_progress WHERE user_id = $1 AND topic_id = $2", telegram_id, topic_id)
lesson_progress = await conn.fetch("""
SELECT lesson_id, completed FROM user_lesson_progress WHERE user_id = $1
""", telegram_id)
progress_map = {lp["lesson_id"]: lp["completed"] for lp in lesson_progress}
lessons_data = []
for lesson in lessons:
l = dict(lesson)
l["completed"] = progress_map.get(lesson["lesson_id"], False)
lessons_data.append(l)
return {
"topic": dict(topic),
"lessons": lessons_data,
"progress": dict(progress) if progress else None,
}
@router.get("/lessons/{lesson_id}")
async def get_lesson(request: Request, lesson_id: str):
"""Получить урок с примерами и вопросами"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
lesson = await conn.fetchrow("SELECT * FROM learning_lessons WHERE lesson_id = $1", lesson_id)
if not lesson:
raise HTTPException(404, "Lesson not found")
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],
})
progress = await conn.fetchrow("SELECT * FROM user_lesson_progress WHERE user_id = $1 AND lesson_id = $2", telegram_id, lesson_id)
return {
"lesson": dict(lesson),
"examples": [dict(e) for e in examples],
"questions": questions_data,
"progress": dict(progress) if progress else None,
}
@router.get("/test/{topic_id}")
async def get_topic_test(request: Request, topic_id: str):
"""Получить вопросы финального теста"""
await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
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 {"test": questions_data, "total": len(questions_data)}
@router.post("/session/start")
async def start_session(request: Request):
"""Начать новую сессию обучения"""
telegram_id, _ = await require_auth(request)
body = await request.json()
level_code = body.get("level_code", "A1")
topic_id = body.get("topic_id", "")
session_type = body.get("session_type", "lesson")
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("""
INSERT INTO learning_sessions (user_id, level_code, topic_id, session_type)
VALUES ($1, $2, $3, $4) RETURNING id
""", telegram_id, level_code, topic_id, session_type)
return {"session_id": row["id"]}
@router.post("/answer")
async def save_answer(request: Request):
"""Сохранить ответ пользователя"""
telegram_id, _ = await require_auth(request)
body = await request.json()
session_id = body.get("session_id")
question_id = body.get("question_id")
selected_answer = body.get("answer", "")
time_taken_ms = body.get("time_taken_ms", 0)
is_final = body.get("is_final_answer", True)
if not session_id or not question_id:
raise HTTPException(400, "session_id and question_id required")
pool = await get_pool()
async with pool.acquire() as conn:
question = await conn.fetchrow("SELECT * FROM learning_questions WHERE question_id = $1", question_id)
if not question:
raise HTTPException(404, "Question not found")
correct = selected_answer.strip().lower() == (question["correct_answer"] or "").strip().lower()
wrong_because = None
if not correct:
option = await conn.fetchrow("""
SELECT wrong_because FROM question_options
WHERE question_id = $1 AND option_key = $2
""", question_id, selected_answer)
if option:
wrong_because = option["wrong_because"]
session = await conn.fetchrow("SELECT * FROM learning_sessions WHERE id = $1", session_id)
await conn.execute("""
INSERT INTO user_learning_answers (user_id, session_id, level_code, topic_id, lesson_id, question_id, selected_answer, correct, skill_tag, question_type, context_type, difficulty, time_taken_ms, hint_used, wrong_because, is_final_answer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
""", telegram_id, session_id, session["level_code"], session["topic_id"], question["lesson_id"], question_id, selected_answer, correct, question["skill_tag"], question["question_type"], question["context_type"], question["difficulty"], time_taken_ms, False, wrong_because, is_final)
return {"correct": correct, "wrong_because": wrong_because}
@router.post("/session/complete")
async def complete_session(request: Request):
"""Завершить сессию: обновляет прогресс урока/теста, мастерство навыков, прогресс темы и AI-контекст"""
telegram_id, _ = await require_auth(request)
body = await request.json()
session_id = body.get("session_id")
pool = await get_pool()
async with pool.acquire() as conn:
session = await conn.fetchrow("SELECT * FROM learning_sessions WHERE id = $1 AND user_id = $2", session_id, telegram_id)
if not session:
raise HTTPException(404, "Session not found")
await conn.execute("UPDATE learning_sessions SET is_completed = TRUE, completed_at = NOW() WHERE id = $1", session_id)
stats = await conn.fetchrow("""
SELECT COUNT(*) as total, COALESCE(SUM(CASE WHEN correct THEN 1 ELSE 0 END), 0) as correct_count
FROM user_learning_answers WHERE session_id = $1 AND is_final_answer = TRUE
""", session_id)
topic_id = session["topic_id"]
# Обновляем прогресс урока
if session["session_type"] == "lesson":
lesson_row = await conn.fetchrow("""
SELECT DISTINCT lesson_id FROM user_learning_answers WHERE session_id = $1 AND lesson_id IS NOT NULL
""", session_id)
if lesson_row and lesson_row["lesson_id"]:
lesson_id = lesson_row["lesson_id"]
score = (stats["correct_count"] / stats["total"] * 100) if stats["total"] > 0 else 0
await conn.execute("""
INSERT INTO user_lesson_progress (user_id, lesson_id, started_at, completed_at, attempts_count, correct_count, best_score, completed)
VALUES ($1, $2, NOW(), NOW(), 1, $3, $4, TRUE)
ON CONFLICT (user_id, lesson_id) DO UPDATE
SET completed_at = NOW(),
attempts_count = user_lesson_progress.attempts_count + 1,
correct_count = user_lesson_progress.correct_count + $3,
best_score = GREATEST(user_lesson_progress.best_score, $4),
completed = TRUE
""", telegram_id, lesson_id, stats["correct_count"], score)
# Обновляем прогресс темы если это тест
if session["session_type"] == "topic_test":
test_score = (stats["correct_count"] / stats["total"] * 100) if stats["total"] > 0 else 0
await conn.execute("""
INSERT INTO user_topic_progress (user_id, topic_id, test_started, test_completed, test_score, completed_at)
VALUES ($1, $2, TRUE, TRUE, $3, NOW())
ON CONFLICT (user_id, topic_id) DO UPDATE
SET test_completed = TRUE,
test_score = $3,
completed_at = NOW()
""", telegram_id, topic_id, test_score)
# Обновляем мастерство по всем навыкам из этой сессии
skill_tags = await conn.fetch("""
SELECT DISTINCT skill_tag FROM user_learning_answers WHERE session_id = $1
""", session_id)
for st in skill_tags:
await update_skill_mastery(conn, telegram_id, st["skill_tag"])
# Обновляем прогресс темы ТОЛЬКО для lesson и topic_test
if session["session_type"] in ("lesson", "topic_test") and topic_id:
await update_topic_progress(conn, telegram_id, topic_id)
# Обновляем AI-контекст (всегда)
await update_ai_context(conn, telegram_id)
return {"status": "ok", "total": stats["total"], "correct_count": stats["correct_count"]}
@router.get("/progress")
async def get_learning_progress(request: Request):
"""Получить прогресс пользователя по всем темам"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
topics = await conn.fetch("""
SELECT tp.*, up.lessons_completed, up.lessons_total, up.test_completed, up.test_score, up.topic_mastery
FROM learning_topics tp
LEFT JOIN user_topic_progress up ON tp.topic_id = up.topic_id AND up.user_id = $1
ORDER BY tp.level_code, tp.order_number
""", telegram_id)
return {"progress": [dict(t) for t in topics]}
@router.post("/analytics")
async def get_analytics(request: Request):
"""Анализирует результаты пользователя через AI на основе user_skill_mastery и user_topic_progress"""
telegram_id, _ = await require_auth(request)
if not TOGETHER_API_KEY:
raise HTTPException(500, "AI API key not configured")
pool = await get_pool()
async with pool.acquire() as conn:
# Получаем слабые навыки из ОБЩЕГО источника
weak_skills = await get_weak_skills(conn, telegram_id, limit=5)
if not weak_skills:
return {"analytics_enabled": False, "message": "Отличная работа! У тебя нет слабых навыков."}
# Загружаем названия навыков из БД
skill_names = await get_skill_names(conn)
skill_tag_lines = '\n'.join([f'- {tag} → "{name}"' for tag, name in skill_names.items()])
# Находим уроки для перепрохождения
weak_skill_tags = [s["skill_tag"] for s in weak_skills]
weak_lessons = await get_weak_lessons(conn, weak_skill_tags)
weak_lessons_lines = '\n'.join([f'- {l["title_ru"]} (тема: {l["topic_name"]})' for l in weak_lessons])
# Формируем профиль
weak_skills_json = []
for s in weak_skills:
weak_skills_json.append({
"skill": s["skill_tag"],
"mastery": round(float(s["mastery_score"]), 1) if s["mastery_score"] else 0,
"repeated_errors": s["repeated_error_count"] or 0,
"retention": s["retention_status"] or "fresh",
"trend": s["trend"] or "stable",
"recent_accuracy": round(float(s["recent_accuracy"]), 1) if s["recent_accuracy"] else 0,
})
user_profile = {
"weak_skills": weak_skills_json,
"weak_lessons": [dict(l) for l in weak_lessons],
}
system_prompt = f"""Ты — AI-наставник польского языка в приложении Filwords PL.
Проанализируй слабые стороны пользователя и скажи что нужно перепройти.
Понятные названия навыков (используй ИХ в ответе, а не технические ID):
{skill_tag_lines}
ПРАВИЛА:
1. Не используй markdown
2. Отвечай на языке пользователя
3. НЕ говори о сильных сторонах — только о слабых
4. Говори ТОЛЬКО о тех навыках, которые перечислены в weak_skills
5. Для каждого слабого навыка укажи конкретный урок для перепрохождения
6. НАЗВАНИЯ НАВЫКОВ: всегда заменяй технические ID на понятные названия
7. ОБРАЩАЙСЯ К ПОЛЬЗОВАТЕЛЮ НА "ТЫ"
8. НИКОГДА не пиши имена метрик: mastery, recent_accuracy, retention_status и т.п. — только русский перевод
9. НЕ упоминай процент завершения уровня или количество пройденных тем
10. В конце предложи нажать кнопку "Потренировать слабые темы" — она уже есть под этим сообщением."""
user_prompt = f"""Слабые навыки пользователя:
{json.dumps(user_profile, ensure_ascii=False, indent=2)}
Напиши:
1. Какие темы ты не понял (конкретные навыки, понятными названиями)
2. Какие уроки нужно перепройти:
{weak_lessons_lines}
3. Нажми кнопку "Потренировать слабые темы" для практики
В конце напиши: "Готов потренироваться? Нажимай кнопку ниже!\""""
async with httpx.AsyncClient(timeout=90.0) as client:
response = await client.post(
TOGETHER_API_URL,
headers={
"Authorization": f"Bearer {TOGETHER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": ANALYTICS_MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"max_tokens": 1000,
"temperature": 0.7,
},
)
if response.status_code != 200:
raise HTTPException(500, f"AI API error: {response.status_code}")
data = response.json()
ai_response = data["choices"][0]["message"]["content"].strip()
ai_response = ai_response.replace("**", "").replace("*", "").replace("#", "")
# Убираем технические ID в скобках
ai_response = re.sub(r'\s*\([a-z_]+_[a-z_]+\)', '', ai_response)
# Если ответ пустой - запасной текст
if not ai_response or len(ai_response) < 10:
ai_response = "Перепройди уроки по слабым темам. Нажми кнопку ниже, чтобы потренироваться!"
return {
"analytics_enabled": True,
"response": ai_response,
"weak_skills": weak_skills_json,
"has_practice_available": len(weak_skills_json) > 0,
"cta_label": "Потренировать слабые темы",
}
@router.get("/ai/context")
async def get_ai_context(request: Request):
"""Получить AI-контекст пользователя"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
context = await conn.fetchrow("SELECT * FROM ai_user_context WHERE user_id = $1", telegram_id)
if not context:
return {"analytics_enabled": False, "message": "Пройди первую тему, и я смогу начать анализировать твой прогресс."}
return {"context": dict(context)}
@router.post("/practice/start")
async def start_weak_skills_practice(request: Request):
"""Собирает сессию практики из реального банка вопросов по слабым навыкам.
Ноль обращений к LLM - только SQL."""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
weak = await get_weak_skills(conn, telegram_id, limit=5)
if not weak:
return {"has_weak_skills": False, "message": "Нет слабых навыков - отличная работа!"}
skill_tags = [s["skill_tag"] for s in weak]
# Забираем краткое объяснение правила для каждого слабого навыка
explanations = await conn.fetch("""
SELECT DISTINCT ON (skill_tag) skill_tag, explanation_ru, explanation_uk, explanation_en, explanation_pl
FROM learning_lessons
WHERE skill_tag = ANY($1)
ORDER BY skill_tag, order_number
""", skill_tags)
explanations_map = {e["skill_tag"]: dict(e) for e in explanations}
# Вопросы вперемешку по всем слабым навыкам, только не тестовые
questions = await conn.fetch("""
SELECT * FROM learning_questions
WHERE skill_tag = ANY($1) AND is_test_question = FALSE
ORDER BY random()
LIMIT 10
""", skill_tags)
if not questions:
return {"has_weak_skills": True, "questions": [], "message": "Для этих навыков пока нет вопросов в базе."}
# Получаем уровень пользователя
user_level = await conn.fetchval("""
SELECT current_level FROM users WHERE telegram_id = $1
""", telegram_id) or "A1"
session = await conn.fetchrow("""
INSERT INTO learning_sessions (user_id, level_code, topic_id, session_type)
VALUES ($1, $2, '', 'weak_skills_practice') RETURNING id
""", telegram_id, user_level)
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]})
# Загружаем понятные названия навыков из БД
skill_names = await get_skill_names(conn)
return {
"has_weak_skills": True,
"session_id": session["id"],
"explanations": [
{"skill_tag": tag, "name": skill_names.get(tag, tag), **explanations_map.get(tag, {})}
for tag in skill_tags
],
"questions": questions_data,
}