""" progress.py — Student progress engine. Handles: - recording attempts - updating mastery scores (decay + boost) - streak tracking - computing weak/strong topics - generating weekly report - generating personalised study plan - next difficulty recommendation (adaptive) """ from datetime import datetime, date, timedelta from database import db import math # ───────────────────────────────────────── # MASTERY FORMULA # mastery = correct / total (weighted) # decay = 0.95 per day inactive # boost = +0.05 per consecutive correct # range = [0.0, 1.0] # ───────────────────────────────────────── DECAY_PER_DAY = 0.95 BOOST_ON_CORRECT = 0.05 MIN_MASTERY = 0.0 MAX_MASTERY = 1.0 def record_attempt( student_id: int, question: str, topic: str, error_type: str, response: str, difficulty: int, mode: str = "text", explain_level: str = "normal", ) -> int: """Insert attempt row, update mastery + error log + streak. Returns attempt id.""" with db() as conn: cur = conn.execute( """INSERT INTO attempts (student_id, question, topic, error_type, response, difficulty, mode, explain_level) VALUES (?,?,?,?,?,?,?,?)""", (student_id, question, topic, error_type, response, difficulty, mode, explain_level), ) attempt_id = cur.lastrowid # ── update mastery ── conn.execute( """INSERT INTO topic_mastery (student_id, topic, attempts, correct, mastery_score) VALUES (?,?,1,0,0.0) ON CONFLICT(student_id, topic) DO UPDATE SET attempts = attempts + 1, last_updated = datetime('now')""", (student_id, topic), ) # treat "None detected" errors as correct is_correct = error_type in (None, "", "None detected") if is_correct: conn.execute( """UPDATE topic_mastery SET correct = correct + 1, mastery_score = MIN(1.0, mastery_score + ?), last_updated = datetime('now') WHERE student_id=? AND topic=?""", (BOOST_ON_CORRECT, student_id, topic), ) else: # error: log it and slightly decrease mastery conn.execute( """INSERT INTO error_log (student_id, topic, error_type, count, last_seen) VALUES (?,?,?,1,datetime('now')) ON CONFLICT(student_id, topic, error_type) DO UPDATE SET count = count + 1, last_seen = datetime('now')""", (student_id, topic, error_type), ) conn.execute( """UPDATE topic_mastery SET mastery_score = MAX(0.0, mastery_score - 0.03), last_updated = datetime('now') WHERE student_id=? AND topic=?""", (student_id, topic), ) # ── streak ── row = conn.execute( "SELECT last_active FROM students WHERE id=?", (student_id,) ).fetchone() today_str = date.today().isoformat() last = row["last_active"] if row else None new_streak_sql = "streak + 1" if last != today_str else "streak" conn.execute( f"UPDATE students SET last_active=?, streak={new_streak_sql} WHERE id=?", (today_str, student_id), ) return attempt_id def get_next_difficulty(student_id: int, topic: str, current: int = 5) -> int: """Adaptive difficulty: harder if mastery > 0.7, easier if < 0.35.""" with db() as conn: row = conn.execute( "SELECT mastery_score, attempts FROM topic_mastery WHERE student_id=? AND topic=?", (student_id, topic), ).fetchone() if not row or row["attempts"] < 3: return current # not enough data yet score = row["mastery_score"] if score >= 0.70: return min(10, current + 1) elif score <= 0.35: return max(1, current - 1) return current def get_student_stats(student_id: int) -> dict: """Full stats object for dashboard.""" with db() as conn: student = conn.execute( "SELECT * FROM students WHERE id=?", (student_id,) ).fetchone() if not student: return {} mastery_rows = conn.execute( "SELECT topic, attempts, correct, mastery_score FROM topic_mastery WHERE student_id=? ORDER BY mastery_score DESC", (student_id,), ).fetchall() error_rows = conn.execute( "SELECT topic, error_type, count FROM error_log WHERE student_id=? ORDER BY count DESC LIMIT 10", (student_id,), ).fetchall() total_attempts = conn.execute( "SELECT COUNT(*) as n FROM attempts WHERE student_id=?", (student_id,) ).fetchone()["n"] # attempts last 7 days grouped by day daily = conn.execute( """SELECT date(created_at) as day, COUNT(*) as n FROM attempts WHERE student_id=? AND created_at >= date('now','-6 days') GROUP BY day ORDER BY day""", (student_id,), ).fetchall() # topic distribution topic_dist = conn.execute( """SELECT topic, COUNT(*) as n FROM attempts WHERE student_id=? GROUP BY topic ORDER BY n DESC""", (student_id,), ).fetchall() mastery = [dict(r) for r in mastery_rows] errors = [dict(r) for r in error_rows] weak = [m for m in mastery if m["mastery_score"] < 0.4 and m["attempts"] >= 2] strong = [m for m in mastery if m["mastery_score"] >= 0.7] overall_accuracy = ( sum(m["correct"] for m in mastery) / max(1, sum(m["attempts"] for m in mastery)) ) * 100 return { "student": dict(student), "total_attempts": total_attempts, "overall_accuracy": round(overall_accuracy, 1), "mastery": mastery, "weak_topics": weak, "strong_topics": strong, "top_errors": errors, "daily_activity": [dict(r) for r in daily], "topic_distribution": [dict(r) for r in topic_dist], } def get_weekly_report(student_id: int) -> dict: """Generate human-readable weekly progress report.""" with db() as conn: rows = conn.execute( """SELECT topic, error_type, created_at FROM attempts WHERE student_id=? AND created_at >= date('now','-7 days') ORDER BY created_at""", (student_id,), ).fetchall() prev_rows = conn.execute( """SELECT COUNT(*) as n FROM attempts WHERE student_id=? AND created_at >= date('now','-14 days') AND created_at < date('now','-7 days')""", (student_id,), ).fetchone() this_week = len(rows) last_week = prev_rows["n"] if prev_rows else 0 growth_pct = round(((this_week - last_week) / max(1, last_week)) * 100) topic_counts: dict[str, int] = {} for r in rows: topic_counts[r["topic"]] = topic_counts.get(r["topic"], 0) + 1 most_practiced = max(topic_counts, key=topic_counts.get) if topic_counts else "—" stats = get_student_stats(student_id) insights = [] if stats.get("weak_topics"): wt = stats["weak_topics"][0]["topic"] insights.append(f"Focus area this week: {wt} needs more practice.") if stats.get("strong_topics"): st = stats["strong_topics"][0]["topic"] insights.append(f"Great progress in {st} — keep it up!") if growth_pct > 0: insights.append(f"You solved {growth_pct}% more questions than last week!") elif growth_pct < 0: insights.append("Try to solve a few more questions this week to maintain momentum.") return { "questions_this_week": this_week, "questions_last_week": last_week, "growth_pct": growth_pct, "most_practiced": most_practiced, "insights": insights, "accuracy": stats.get("overall_accuracy", 0), "streak": stats.get("student", {}).get("streak", 0), } def generate_study_plan(student_id: int) -> list[dict]: """Return ordered list of recommended topics + question counts.""" stats = get_student_stats(student_id) plan = [] for w in stats.get("weak_topics", [])[:3]: plan.append({ "topic": w["topic"], "priority": "high", "reason": f"Mastery only {round(w['mastery_score']*100)}% — needs work", "questions": 5, }) for m in stats.get("mastery", []): if 0.4 <= m["mastery_score"] < 0.7 and len(plan) < 5: plan.append({ "topic": m["topic"], "priority": "medium", "reason": f"Mastery at {round(m['mastery_score']*100)}% — almost there", "questions": 3, }) if not plan: plan.append({ "topic": "General STEM", "priority": "normal", "reason": "Start solving questions to unlock your personalised plan", "questions": 5, }) return plan