Spaces:
Running
Running
| """ | |
| 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 | |