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