Spaces:
Sleeping
Sleeping
Upload 12 files
Browse files- src/__init__.py +0 -0
- src/agentic_optimizer.py +413 -0
- src/config.py +113 -0
- src/curriculum_analyzer.py +127 -0
- src/curriculum_optimizer.py +654 -0
- src/inspect_graph.py +88 -0
- src/interactive_visualizer.py +400 -0
- src/neu_graph_analyzed_clean.pkl +3 -0
- src/neu_scraper.py +235 -0
- src/prompts.py +48 -0
- src/requirements (1).txt +13 -0
- src/run.py +158 -0
src/__init__.py
ADDED
|
File without changes
|
src/agentic_optimizer.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agentic Curriculum Optimizer
|
| 3 |
+
Runs 100% locally, no API costs
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import sqlite3
|
| 8 |
+
import networkx as nx
|
| 9 |
+
import numpy as np
|
| 10 |
+
from dataclasses import dataclass, asdict
|
| 11 |
+
from typing import Dict, List, Tuple, Optional
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
import pickle
|
| 14 |
+
import torch
|
| 15 |
+
from sentence_transformers import SentenceTransformer
|
| 16 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
|
| 17 |
+
import schedule
|
| 18 |
+
import time
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class StudentProfile:
|
| 22 |
+
student_id: str
|
| 23 |
+
completed_courses: List[str]
|
| 24 |
+
current_gpa: float
|
| 25 |
+
interests: List[str]
|
| 26 |
+
career_goals: str
|
| 27 |
+
learning_style: str
|
| 28 |
+
time_commitment: int
|
| 29 |
+
preferred_difficulty: str
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class PlanFeedback:
|
| 33 |
+
student_id: str
|
| 34 |
+
plan_id: str
|
| 35 |
+
timestamp: datetime
|
| 36 |
+
actual_gpa: float
|
| 37 |
+
difficulty_rating: int # 1-5
|
| 38 |
+
satisfaction: int # 1-5
|
| 39 |
+
completed_courses: List[str]
|
| 40 |
+
dropped_courses: List[str]
|
| 41 |
+
|
| 42 |
+
class CurriculumAgent:
|
| 43 |
+
"""
|
| 44 |
+
Autonomous agent that:
|
| 45 |
+
1. Monitors student progress
|
| 46 |
+
2. Adapts recommendations based on feedback
|
| 47 |
+
3. Proactively suggests adjustments
|
| 48 |
+
4. Learns from outcomes
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
def __init__(self, db_path="curriculum_agent.db"):
|
| 52 |
+
self.db_path = db_path
|
| 53 |
+
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 54 |
+
|
| 55 |
+
# Models (local, no API)
|
| 56 |
+
self.embedder = SentenceTransformer('all-MiniLM-L6-v2') # Smaller for local
|
| 57 |
+
self.graph = None
|
| 58 |
+
self.courses = {}
|
| 59 |
+
|
| 60 |
+
# Initialize database for memory
|
| 61 |
+
self._init_database()
|
| 62 |
+
|
| 63 |
+
# Agent state
|
| 64 |
+
self.active_plans = {}
|
| 65 |
+
self.feedback_history = []
|
| 66 |
+
|
| 67 |
+
def _init_database(self):
|
| 68 |
+
"""Create tables for agent memory"""
|
| 69 |
+
conn = sqlite3.connect(self.db_path)
|
| 70 |
+
c = conn.cursor()
|
| 71 |
+
|
| 72 |
+
# Student profiles
|
| 73 |
+
c.execute('''CREATE TABLE IF NOT EXISTS students
|
| 74 |
+
(id TEXT PRIMARY KEY,
|
| 75 |
+
profile TEXT,
|
| 76 |
+
created_at TIMESTAMP)''')
|
| 77 |
+
|
| 78 |
+
# Generated plans
|
| 79 |
+
c.execute('''CREATE TABLE IF NOT EXISTS plans
|
| 80 |
+
(id TEXT PRIMARY KEY,
|
| 81 |
+
student_id TEXT,
|
| 82 |
+
plan_data TEXT,
|
| 83 |
+
created_at TIMESTAMP,
|
| 84 |
+
performance_score REAL)''')
|
| 85 |
+
|
| 86 |
+
# Feedback for learning
|
| 87 |
+
c.execute('''CREATE TABLE IF NOT EXISTS feedback
|
| 88 |
+
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 89 |
+
plan_id TEXT,
|
| 90 |
+
student_id TEXT,
|
| 91 |
+
feedback_data TEXT,
|
| 92 |
+
timestamp TIMESTAMP)''')
|
| 93 |
+
|
| 94 |
+
# Agent learning patterns
|
| 95 |
+
c.execute('''CREATE TABLE IF NOT EXISTS patterns
|
| 96 |
+
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 97 |
+
pattern_type TEXT,
|
| 98 |
+
pattern_data TEXT,
|
| 99 |
+
success_rate REAL,
|
| 100 |
+
discovered_at TIMESTAMP)''')
|
| 101 |
+
|
| 102 |
+
conn.commit()
|
| 103 |
+
conn.close()
|
| 104 |
+
|
| 105 |
+
def perceive(self) -> Dict:
|
| 106 |
+
"""
|
| 107 |
+
PERCEPTION: Gather information about environment
|
| 108 |
+
"""
|
| 109 |
+
perceptions = {
|
| 110 |
+
"active_students": self._get_active_students(),
|
| 111 |
+
"recent_feedback": self._get_recent_feedback(),
|
| 112 |
+
"course_updates": self._check_course_updates(),
|
| 113 |
+
"success_patterns": self._analyze_success_patterns()
|
| 114 |
+
}
|
| 115 |
+
return perceptions
|
| 116 |
+
|
| 117 |
+
def decide(self, perceptions: Dict) -> List[Dict]:
|
| 118 |
+
"""
|
| 119 |
+
DECISION: Determine what actions to take
|
| 120 |
+
"""
|
| 121 |
+
decisions = []
|
| 122 |
+
|
| 123 |
+
# Decision 1: Which students need plan updates?
|
| 124 |
+
for student_id in perceptions["active_students"]:
|
| 125 |
+
if self._needs_plan_update(student_id, perceptions):
|
| 126 |
+
decisions.append({
|
| 127 |
+
"action": "update_plan",
|
| 128 |
+
"student_id": student_id,
|
| 129 |
+
"reason": "Poor performance feedback"
|
| 130 |
+
})
|
| 131 |
+
|
| 132 |
+
# Decision 2: Identify at-risk students
|
| 133 |
+
at_risk = self._identify_at_risk_students(perceptions["recent_feedback"])
|
| 134 |
+
for student_id in at_risk:
|
| 135 |
+
decisions.append({
|
| 136 |
+
"action": "intervention",
|
| 137 |
+
"student_id": student_id,
|
| 138 |
+
"reason": "Risk of dropping out"
|
| 139 |
+
})
|
| 140 |
+
|
| 141 |
+
# Decision 3: Optimize based on patterns
|
| 142 |
+
if perceptions["success_patterns"]:
|
| 143 |
+
decisions.append({
|
| 144 |
+
"action": "update_algorithm",
|
| 145 |
+
"patterns": perceptions["success_patterns"]
|
| 146 |
+
})
|
| 147 |
+
|
| 148 |
+
return decisions
|
| 149 |
+
|
| 150 |
+
def act(self, decisions: List[Dict]) -> List[Dict]:
|
| 151 |
+
"""
|
| 152 |
+
ACTION: Execute decisions
|
| 153 |
+
"""
|
| 154 |
+
results = []
|
| 155 |
+
|
| 156 |
+
for decision in decisions:
|
| 157 |
+
if decision["action"] == "update_plan":
|
| 158 |
+
new_plan = self._regenerate_plan(decision["student_id"])
|
| 159 |
+
results.append({
|
| 160 |
+
"action": "plan_updated",
|
| 161 |
+
"student_id": decision["student_id"],
|
| 162 |
+
"plan": new_plan
|
| 163 |
+
})
|
| 164 |
+
|
| 165 |
+
elif decision["action"] == "intervention":
|
| 166 |
+
intervention = self._create_intervention(decision["student_id"])
|
| 167 |
+
results.append({
|
| 168 |
+
"action": "intervention_created",
|
| 169 |
+
"student_id": decision["student_id"],
|
| 170 |
+
"intervention": intervention
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
elif decision["action"] == "update_algorithm":
|
| 174 |
+
self._update_planning_algorithm(decision["patterns"])
|
| 175 |
+
results.append({
|
| 176 |
+
"action": "algorithm_updated",
|
| 177 |
+
"patterns_applied": len(decision["patterns"])
|
| 178 |
+
})
|
| 179 |
+
|
| 180 |
+
return results
|
| 181 |
+
|
| 182 |
+
def learn(self, results: List[Dict]):
|
| 183 |
+
"""
|
| 184 |
+
LEARNING: Update knowledge based on outcomes
|
| 185 |
+
"""
|
| 186 |
+
conn = sqlite3.connect(self.db_path)
|
| 187 |
+
c = conn.cursor()
|
| 188 |
+
|
| 189 |
+
for result in results:
|
| 190 |
+
if result["action"] == "plan_updated":
|
| 191 |
+
# Track plan performance
|
| 192 |
+
self._track_plan_performance(result["student_id"], result["plan"])
|
| 193 |
+
|
| 194 |
+
elif result["action"] == "intervention_created":
|
| 195 |
+
# Monitor intervention effectiveness
|
| 196 |
+
self._monitor_intervention(result["student_id"], result["intervention"])
|
| 197 |
+
|
| 198 |
+
# Discover new patterns
|
| 199 |
+
patterns = self._discover_patterns()
|
| 200 |
+
for pattern in patterns:
|
| 201 |
+
c.execute("INSERT INTO patterns (pattern_type, pattern_data, success_rate, discovered_at) VALUES (?, ?, ?, ?)",
|
| 202 |
+
(pattern["type"], json.dumps(pattern["data"]), pattern["success_rate"], datetime.now()))
|
| 203 |
+
|
| 204 |
+
conn.commit()
|
| 205 |
+
conn.close()
|
| 206 |
+
|
| 207 |
+
def run_autonomous_cycle(self):
|
| 208 |
+
"""
|
| 209 |
+
Main agent loop - runs continuously
|
| 210 |
+
"""
|
| 211 |
+
while True:
|
| 212 |
+
print(f"\n[{datetime.now()}] Agent Cycle Starting...")
|
| 213 |
+
|
| 214 |
+
# 1. PERCEIVE
|
| 215 |
+
perceptions = self.perceive()
|
| 216 |
+
print(f"Perceptions: {len(perceptions['active_students'])} active students")
|
| 217 |
+
|
| 218 |
+
# 2. DECIDE
|
| 219 |
+
decisions = self.decide(perceptions)
|
| 220 |
+
print(f"Decisions: {len(decisions)} actions to take")
|
| 221 |
+
|
| 222 |
+
# 3. ACT
|
| 223 |
+
results = self.act(decisions)
|
| 224 |
+
print(f"Results: {len(results)} actions completed")
|
| 225 |
+
|
| 226 |
+
# 4. LEARN
|
| 227 |
+
self.learn(results)
|
| 228 |
+
print("Learning cycle complete")
|
| 229 |
+
|
| 230 |
+
# Wait before next cycle (in production, this could be daily)
|
| 231 |
+
time.sleep(60) # Run every minute for demo
|
| 232 |
+
|
| 233 |
+
# --- Helper Methods ---
|
| 234 |
+
|
| 235 |
+
def _get_active_students(self) -> List[str]:
|
| 236 |
+
"""Get list of active students"""
|
| 237 |
+
conn = sqlite3.connect(self.db_path)
|
| 238 |
+
c = conn.cursor()
|
| 239 |
+
c.execute("SELECT id FROM students")
|
| 240 |
+
students = [row[0] for row in c.fetchall()]
|
| 241 |
+
conn.close()
|
| 242 |
+
return students
|
| 243 |
+
|
| 244 |
+
def _get_recent_feedback(self) -> List[Dict]:
|
| 245 |
+
"""Get recent feedback"""
|
| 246 |
+
conn = sqlite3.connect(self.db_path)
|
| 247 |
+
c = conn.cursor()
|
| 248 |
+
c.execute("SELECT feedback_data FROM feedback ORDER BY timestamp DESC LIMIT 10")
|
| 249 |
+
feedback = [json.loads(row[0]) for row in c.fetchall()]
|
| 250 |
+
conn.close()
|
| 251 |
+
return feedback
|
| 252 |
+
|
| 253 |
+
def _check_course_updates(self) -> Dict:
|
| 254 |
+
"""Check for course changes (mock for demo)"""
|
| 255 |
+
return {"updated_courses": [], "new_prerequisites": {}}
|
| 256 |
+
|
| 257 |
+
def _analyze_success_patterns(self) -> List[Dict]:
|
| 258 |
+
"""Identify successful patterns"""
|
| 259 |
+
conn = sqlite3.connect(self.db_path)
|
| 260 |
+
c = conn.cursor()
|
| 261 |
+
c.execute("SELECT pattern_data, success_rate FROM patterns WHERE success_rate > 0.7")
|
| 262 |
+
patterns = [{"data": json.loads(row[0]), "success_rate": row[1]} for row in c.fetchall()]
|
| 263 |
+
conn.close()
|
| 264 |
+
return patterns
|
| 265 |
+
|
| 266 |
+
def _needs_plan_update(self, student_id: str, perceptions: Dict) -> bool:
|
| 267 |
+
"""Determine if student needs plan update"""
|
| 268 |
+
# Check if recent feedback shows issues
|
| 269 |
+
for feedback in perceptions["recent_feedback"]:
|
| 270 |
+
if feedback.get("student_id") == student_id:
|
| 271 |
+
if feedback.get("satisfaction", 5) < 3:
|
| 272 |
+
return True
|
| 273 |
+
return False
|
| 274 |
+
|
| 275 |
+
def _identify_at_risk_students(self, feedback: List[Dict]) -> List[str]:
|
| 276 |
+
"""Identify students at risk"""
|
| 277 |
+
at_risk = []
|
| 278 |
+
for fb in feedback:
|
| 279 |
+
if fb.get("difficulty_rating", 0) > 4 or fb.get("dropped_courses", []):
|
| 280 |
+
at_risk.append(fb.get("student_id"))
|
| 281 |
+
return at_risk
|
| 282 |
+
|
| 283 |
+
def _regenerate_plan(self, student_id: str) -> Dict:
|
| 284 |
+
"""Generate new plan for student"""
|
| 285 |
+
# This would use your existing optimizer
|
| 286 |
+
return {"plan": "new_optimized_plan", "adjustments": ["reduced_difficulty"]}
|
| 287 |
+
|
| 288 |
+
def _create_intervention(self, student_id: str) -> Dict:
|
| 289 |
+
"""Create intervention plan"""
|
| 290 |
+
return {
|
| 291 |
+
"type": "academic_support",
|
| 292 |
+
"recommendations": ["tutoring", "reduced_courseload", "advisor_meeting"]
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
def _update_planning_algorithm(self, patterns: List[Dict]):
|
| 296 |
+
"""Update planning based on learned patterns"""
|
| 297 |
+
# This would adjust your optimizer's weights/rules
|
| 298 |
+
print(f"Updating algorithm with {len(patterns)} patterns")
|
| 299 |
+
|
| 300 |
+
def _track_plan_performance(self, student_id: str, plan: Dict):
|
| 301 |
+
"""Track how well plans perform"""
|
| 302 |
+
conn = sqlite3.connect(self.db_path)
|
| 303 |
+
c = conn.cursor()
|
| 304 |
+
c.execute("UPDATE plans SET performance_score = ? WHERE student_id = ?",
|
| 305 |
+
(0.0, student_id)) # Would calculate actual score
|
| 306 |
+
conn.commit()
|
| 307 |
+
conn.close()
|
| 308 |
+
|
| 309 |
+
def _monitor_intervention(self, student_id: str, intervention: Dict):
|
| 310 |
+
"""Monitor intervention effectiveness"""
|
| 311 |
+
print(f"Monitoring intervention for {student_id}")
|
| 312 |
+
|
| 313 |
+
def _discover_patterns(self) -> List[Dict]:
|
| 314 |
+
"""Discover new patterns from data"""
|
| 315 |
+
# Example: Find that students who take CS2500 before CS2510 do better
|
| 316 |
+
patterns = []
|
| 317 |
+
|
| 318 |
+
# Analyze database for patterns
|
| 319 |
+
conn = sqlite3.connect(self.db_path)
|
| 320 |
+
c = conn.cursor()
|
| 321 |
+
|
| 322 |
+
# Example pattern discovery
|
| 323 |
+
c.execute("""
|
| 324 |
+
SELECT COUNT(*) FROM feedback
|
| 325 |
+
WHERE feedback_data LIKE '%CS2500%CS2510%'
|
| 326 |
+
AND json_extract(feedback_data, '$.satisfaction') > 4
|
| 327 |
+
""")
|
| 328 |
+
|
| 329 |
+
result = c.fetchone()
|
| 330 |
+
if result and result[0] > 5: # If pattern appears frequently
|
| 331 |
+
patterns.append({
|
| 332 |
+
"type": "course_sequence",
|
| 333 |
+
"data": {"sequence": ["CS2500", "CS2510"]},
|
| 334 |
+
"success_rate": 0.85
|
| 335 |
+
})
|
| 336 |
+
|
| 337 |
+
conn.close()
|
| 338 |
+
return patterns
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
class LocalAgentRunner:
|
| 342 |
+
"""
|
| 343 |
+
Manages the agent without external dependencies
|
| 344 |
+
"""
|
| 345 |
+
|
| 346 |
+
def __init__(self, curriculum_data_path: str):
|
| 347 |
+
self.agent = CurriculumAgent()
|
| 348 |
+
|
| 349 |
+
# Load curriculum data
|
| 350 |
+
with open(curriculum_data_path, 'rb') as f:
|
| 351 |
+
graph = pickle.load(f)
|
| 352 |
+
self.agent.graph = graph
|
| 353 |
+
self.agent.courses = dict(graph.nodes(data=True))
|
| 354 |
+
|
| 355 |
+
def add_student(self, profile: StudentProfile) -> str:
|
| 356 |
+
"""Add a student to track"""
|
| 357 |
+
conn = sqlite3.connect(self.agent.db_path)
|
| 358 |
+
c = conn.cursor()
|
| 359 |
+
|
| 360 |
+
student_id = f"STU_{datetime.now().timestamp()}"
|
| 361 |
+
c.execute("INSERT INTO students (id, profile, created_at) VALUES (?, ?, ?)",
|
| 362 |
+
(student_id, json.dumps(asdict(profile)), datetime.now()))
|
| 363 |
+
|
| 364 |
+
conn.commit()
|
| 365 |
+
conn.close()
|
| 366 |
+
|
| 367 |
+
return student_id
|
| 368 |
+
|
| 369 |
+
def submit_feedback(self, feedback: PlanFeedback):
|
| 370 |
+
"""Submit feedback for learning"""
|
| 371 |
+
conn = sqlite3.connect(self.agent.db_path)
|
| 372 |
+
c = conn.cursor()
|
| 373 |
+
|
| 374 |
+
c.execute("INSERT INTO feedback (plan_id, student_id, feedback_data, timestamp) VALUES (?, ?, ?, ?)",
|
| 375 |
+
(feedback.plan_id, feedback.student_id, json.dumps(asdict(feedback)), feedback.timestamp))
|
| 376 |
+
|
| 377 |
+
conn.commit()
|
| 378 |
+
conn.close()
|
| 379 |
+
|
| 380 |
+
def start_agent(self):
|
| 381 |
+
"""Start the autonomous agent"""
|
| 382 |
+
print("Starting Curriculum Agent...")
|
| 383 |
+
print("Agent will monitor students and adapt plans automatically")
|
| 384 |
+
print("Press Ctrl+C to stop")
|
| 385 |
+
|
| 386 |
+
try:
|
| 387 |
+
self.agent.run_autonomous_cycle()
|
| 388 |
+
except KeyboardInterrupt:
|
| 389 |
+
print("\nAgent stopped")
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
# Example usage
|
| 393 |
+
if __name__ == "__main__":
|
| 394 |
+
# Initialize agent
|
| 395 |
+
runner = LocalAgentRunner("neu_graph_analyzed_clean.pkl")
|
| 396 |
+
|
| 397 |
+
# Add a test student
|
| 398 |
+
student = StudentProfile(
|
| 399 |
+
student_id="test_001",
|
| 400 |
+
completed_courses=["CS1800", "CS2500"],
|
| 401 |
+
current_gpa=3.5,
|
| 402 |
+
interests=["AI", "Machine Learning"],
|
| 403 |
+
career_goals="ML Engineer",
|
| 404 |
+
learning_style="Visual",
|
| 405 |
+
time_commitment=40,
|
| 406 |
+
preferred_difficulty="moderate"
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
student_id = runner.add_student(student)
|
| 410 |
+
print(f"Added student: {student_id}")
|
| 411 |
+
|
| 412 |
+
# Start autonomous agent
|
| 413 |
+
runner.start_agent()
|
src/config.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration for different compute environments
|
| 3 |
+
Switch between configs based on available hardware
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
class Config:
|
| 10 |
+
"""Base configuration"""
|
| 11 |
+
# Data paths
|
| 12 |
+
CURRICULUM_DATA = "neu_graph_analyzed_clean.pkl"
|
| 13 |
+
AGENT_DB = "curriculum_agent.db"
|
| 14 |
+
|
| 15 |
+
# Model settings (override in subclasses)
|
| 16 |
+
LLM_MODEL = None
|
| 17 |
+
EMBEDDING_MODEL = None
|
| 18 |
+
DEVICE = None
|
| 19 |
+
QUANTIZATION = None
|
| 20 |
+
|
| 21 |
+
# Agent settings
|
| 22 |
+
AGENT_CYCLE_MINUTES = 60
|
| 23 |
+
MAX_COURSES_PER_SEMESTER = 4
|
| 24 |
+
|
| 25 |
+
@classmethod
|
| 26 |
+
def get_device(cls):
|
| 27 |
+
if cls.DEVICE == "auto":
|
| 28 |
+
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 29 |
+
return torch.device(cls.DEVICE)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class H200Config(Config):
|
| 33 |
+
"""Config for H200 GPU (80GB)"""
|
| 34 |
+
LLM_MODEL = "meta-llama/Llama-3.1-70B-Instruct"
|
| 35 |
+
EMBEDDING_MODEL = "BAAI/bge-large-en-v1.5"
|
| 36 |
+
DEVICE = "cuda"
|
| 37 |
+
QUANTIZATION = None # No need to quantize with 80GB
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class ColabConfig(Config):
|
| 41 |
+
"""Config for Google Colab T4 (16GB)"""
|
| 42 |
+
LLM_MODEL = "meta-llama/Llama-3.1-8B-Instruct"
|
| 43 |
+
EMBEDDING_MODEL = "BAAI/bge-base-en-v1.5"
|
| 44 |
+
DEVICE = "cuda"
|
| 45 |
+
QUANTIZATION = "4bit"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class LocalGPUConfig(Config):
|
| 49 |
+
"""Config for local GPU (8-12GB)"""
|
| 50 |
+
LLM_MODEL = "mistralai/Mistral-7B-Instruct-v0.2"
|
| 51 |
+
EMBEDDING_MODEL = "sentence-transformers/all-mpnet-base-v2"
|
| 52 |
+
DEVICE = "cuda"
|
| 53 |
+
QUANTIZATION = "4bit"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class CPUConfig(Config):
|
| 57 |
+
"""Config for CPU only (no GPU)"""
|
| 58 |
+
LLM_MODEL = "microsoft/phi-2" # 2.7B params
|
| 59 |
+
EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2" # 22M params
|
| 60 |
+
DEVICE = "cpu"
|
| 61 |
+
QUANTIZATION = None
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class MinimalConfig(Config):
|
| 65 |
+
"""Minimal config for testing/development"""
|
| 66 |
+
LLM_MODEL = None # No LLM, just embeddings
|
| 67 |
+
EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
| 68 |
+
DEVICE = "cpu"
|
| 69 |
+
QUANTIZATION = None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def get_config():
|
| 73 |
+
"""
|
| 74 |
+
Auto-detect best configuration
|
| 75 |
+
"""
|
| 76 |
+
# Check environment variable first
|
| 77 |
+
env_config = os.environ.get("CURRICULUM_CONFIG", "").lower()
|
| 78 |
+
|
| 79 |
+
if env_config == "h200":
|
| 80 |
+
return H200Config
|
| 81 |
+
elif env_config == "colab":
|
| 82 |
+
return ColabConfig
|
| 83 |
+
elif env_config == "cpu":
|
| 84 |
+
return CPUConfig
|
| 85 |
+
elif env_config == "minimal":
|
| 86 |
+
return MinimalConfig
|
| 87 |
+
|
| 88 |
+
# Auto-detect based on hardware
|
| 89 |
+
if torch.cuda.is_available():
|
| 90 |
+
gpu_mem = torch.cuda.get_device_properties(0).total_memory / 1e9 # GB
|
| 91 |
+
|
| 92 |
+
if gpu_mem > 70:
|
| 93 |
+
print(f"Detected high-end GPU ({gpu_mem:.1f}GB), using H200Config")
|
| 94 |
+
return H200Config
|
| 95 |
+
elif gpu_mem > 14:
|
| 96 |
+
print(f"Detected mid-range GPU ({gpu_mem:.1f}GB), using ColabConfig")
|
| 97 |
+
return ColabConfig
|
| 98 |
+
else:
|
| 99 |
+
print(f"Detected small GPU ({gpu_mem:.1f}GB), using LocalGPUConfig")
|
| 100 |
+
return LocalGPUConfig
|
| 101 |
+
else:
|
| 102 |
+
print("No GPU detected, using CPUConfig")
|
| 103 |
+
return CPUConfig
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# Usage example
|
| 107 |
+
if __name__ == "__main__":
|
| 108 |
+
config = get_config()
|
| 109 |
+
print(f"Selected config: {config.__name__}")
|
| 110 |
+
print(f"LLM: {config.LLM_MODEL}")
|
| 111 |
+
print(f"Embedder: {config.EMBEDDING_MODEL}")
|
| 112 |
+
print(f"Device: {config.DEVICE}")
|
| 113 |
+
print(f"Quantization: {config.QUANTIZATION}")
|
src/curriculum_analyzer.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Curriculum Analyzer and Data Enrichment Tool (with Pre-filtering)
|
| 3 |
+
Analyzes, CLEANS, and enriches scraped NEU curriculum data.
|
| 4 |
+
"""
|
| 5 |
+
import pickle
|
| 6 |
+
import json
|
| 7 |
+
import argparse
|
| 8 |
+
import networkx as nx
|
| 9 |
+
import re
|
| 10 |
+
from collections import defaultdict
|
| 11 |
+
|
| 12 |
+
def get_course_level(cid):
|
| 13 |
+
"""Extracts the numerical part of a course ID for level checking."""
|
| 14 |
+
match = re.search(r'\d+', cid)
|
| 15 |
+
return int(match.group(0)) if match else 9999
|
| 16 |
+
|
| 17 |
+
class CurriculumAnalyzer:
|
| 18 |
+
def __init__(self, graph_path, courses_path):
|
| 19 |
+
self.graph_path = graph_path
|
| 20 |
+
self.courses_path = courses_path
|
| 21 |
+
self.graph = None
|
| 22 |
+
self.courses = None
|
| 23 |
+
self.load_data()
|
| 24 |
+
|
| 25 |
+
def load_data(self):
|
| 26 |
+
print("📚 Loading raw curriculum data...")
|
| 27 |
+
try:
|
| 28 |
+
with open(self.graph_path, 'rb') as f:
|
| 29 |
+
self.graph = pickle.load(f)
|
| 30 |
+
with open(self.courses_path, 'rb') as f:
|
| 31 |
+
self.courses = pickle.load(f)
|
| 32 |
+
|
| 33 |
+
# Merge course metadata into the graph nodes
|
| 34 |
+
for course_id, course_data in self.courses.items():
|
| 35 |
+
if self.graph.has_node(course_id):
|
| 36 |
+
self.graph.nodes[course_id].update(course_data)
|
| 37 |
+
|
| 38 |
+
print(f"✅ Loaded raw data with {self.graph.number_of_nodes()} courses.")
|
| 39 |
+
except FileNotFoundError as e:
|
| 40 |
+
print(f"❌ Error: Data file not found. {e}")
|
| 41 |
+
exit(1)
|
| 42 |
+
|
| 43 |
+
def pre_filter_graph(self):
|
| 44 |
+
"""
|
| 45 |
+
Permanently removes irrelevant courses from the graph.
|
| 46 |
+
This is the most important step for creating logical plans.
|
| 47 |
+
"""
|
| 48 |
+
print("\n🧹 Pre-filtering graph to remove irrelevant courses...")
|
| 49 |
+
|
| 50 |
+
# Define what subjects are considered relevant for a tech-focused degree
|
| 51 |
+
RELEVANT_SUBJECTS = {
|
| 52 |
+
"CS", "DS", "CY",
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
nodes_to_remove = []
|
| 56 |
+
for node, data in self.graph.nodes(data=True):
|
| 57 |
+
subject = data.get('subject')
|
| 58 |
+
level = get_course_level(node)
|
| 59 |
+
|
| 60 |
+
# Mark for removal if subject is irrelevant OR it's a grad course (>= 5000)
|
| 61 |
+
if subject not in RELEVANT_SUBJECTS or level >= 5000:
|
| 62 |
+
nodes_to_remove.append(node)
|
| 63 |
+
|
| 64 |
+
self.graph.remove_nodes_from(nodes_to_remove)
|
| 65 |
+
print(f"✅ Graph filtered. Removed {len(nodes_to_remove)} irrelevant courses. Remaining: {self.graph.number_of_nodes()}")
|
| 66 |
+
|
| 67 |
+
def calculate_and_add_complexity(self):
|
| 68 |
+
"""Calculates complexity scores for the remaining courses."""
|
| 69 |
+
print("\n🧮 Calculating complexity scores for filtered graph...")
|
| 70 |
+
if not self.graph.nodes():
|
| 71 |
+
return
|
| 72 |
+
|
| 73 |
+
foundation_courses = [n for n, d in self.graph.in_degree() if d == 0]
|
| 74 |
+
|
| 75 |
+
complexity_scores = {}
|
| 76 |
+
for node in self.graph.nodes():
|
| 77 |
+
# Calculate depth (longest path from a foundation course)
|
| 78 |
+
depth = 0
|
| 79 |
+
if foundation_courses:
|
| 80 |
+
paths = [nx.shortest_path_length(self.graph, source, node)
|
| 81 |
+
for source in foundation_courses if nx.has_path(self.graph, source, node)]
|
| 82 |
+
if paths:
|
| 83 |
+
depth = max(paths) # Use max path for a better sense of progression
|
| 84 |
+
|
| 85 |
+
in_deg = self.graph.in_degree(node)
|
| 86 |
+
out_deg = self.graph.out_degree(node)
|
| 87 |
+
|
| 88 |
+
# Formula: (prereqs * 10) + (unlocks * 5) + (depth * 3)
|
| 89 |
+
score = (in_deg * 10) + (out_deg * 5) + (depth * 3)
|
| 90 |
+
complexity_scores[node] = {
|
| 91 |
+
'complexity': score,
|
| 92 |
+
'depth': depth,
|
| 93 |
+
'prereq_count': in_deg,
|
| 94 |
+
'unlocks_count': out_deg
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
nx.set_node_attributes(self.graph, complexity_scores)
|
| 98 |
+
print("✅ Complexity scores calculated and added.")
|
| 99 |
+
|
| 100 |
+
def save_enriched_graph(self, output_path):
|
| 101 |
+
"""Saves the final, clean, and enriched graph."""
|
| 102 |
+
print(f"\n💾 Saving CLEAN and enriched graph to {output_path}...")
|
| 103 |
+
with open(output_path, 'wb') as f:
|
| 104 |
+
pickle.dump(self.graph, f)
|
| 105 |
+
print("✅ Graph saved.")
|
| 106 |
+
|
| 107 |
+
def main(args):
|
| 108 |
+
"""Main execution flow."""
|
| 109 |
+
analyzer = CurriculumAnalyzer(args.graph, args.courses)
|
| 110 |
+
|
| 111 |
+
# Run the new cleaning step first!
|
| 112 |
+
analyzer.pre_filter_graph()
|
| 113 |
+
|
| 114 |
+
analyzer.calculate_and_add_complexity()
|
| 115 |
+
|
| 116 |
+
analyzer.save_enriched_graph(args.output_graph)
|
| 117 |
+
|
| 118 |
+
print("\n✨ Analysis and cleaning complete!")
|
| 119 |
+
print(f"➡️ In the Streamlit app, upload the new clean file: '{args.output_graph}'")
|
| 120 |
+
|
| 121 |
+
if __name__ == "__main__":
|
| 122 |
+
parser = argparse.ArgumentParser(description="NEU Curriculum Analyzer and Data Enrichment Tool")
|
| 123 |
+
parser.add_argument('--graph', required=True, help="Path to the RAW curriculum graph from the scraper.")
|
| 124 |
+
parser.add_argument('--courses', required=True, help="Path to the RAW courses data from the scraper.")
|
| 125 |
+
parser.add_argument('--output-graph', default='neu_graph_analyzed_clean.pkl', help="Path to save the new CLEANED and enriched graph.")
|
| 126 |
+
args = parser.parse_args()
|
| 127 |
+
main(args)
|
src/curriculum_optimizer.py
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fixed Hybrid Curriculum Optimizer
|
| 3 |
+
Actually personalizes plans based on student profile
|
| 4 |
+
"""
|
| 5 |
+
import torch
|
| 6 |
+
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
|
| 7 |
+
from sentence_transformers import SentenceTransformer, util
|
| 8 |
+
import networkx as nx
|
| 9 |
+
import numpy as np
|
| 10 |
+
from typing import Dict, List, Set, Tuple
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
import re
|
| 13 |
+
import json
|
| 14 |
+
import random
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class StudentProfile:
|
| 18 |
+
completed_courses: List[str]
|
| 19 |
+
time_commitment: int
|
| 20 |
+
preferred_difficulty: str
|
| 21 |
+
career_goals: str
|
| 22 |
+
interests: List[str]
|
| 23 |
+
current_gpa: float = 3.5
|
| 24 |
+
learning_style: str = "Visual"
|
| 25 |
+
|
| 26 |
+
class HybridOptimizer:
|
| 27 |
+
"""
|
| 28 |
+
Fixed optimizer that actually personalizes plans
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
# Core sequences by track - now with difficulty tiers
|
| 32 |
+
TRACK_SEQUENCES = {
|
| 33 |
+
"ai_ml": {
|
| 34 |
+
"foundations": ["CS1800", "CS2500", "CS2510", "CS2800"],
|
| 35 |
+
"core_easy": ["CS3000", "CS3500", "DS3000"],
|
| 36 |
+
"core_medium": ["CS3000", "CS3500", "CS3200", "DS3000"],
|
| 37 |
+
"core_hard": ["CS3000", "CS3500", "CS3200", "CS3650", "DS3000"],
|
| 38 |
+
"specialized_easy": ["CS4100", "DS4400"],
|
| 39 |
+
"specialized_medium": ["CS4100", "DS4400", "CS4120"],
|
| 40 |
+
"specialized_hard": ["CS4100", "DS4400", "CS4120", "CS4180", "DS4440"],
|
| 41 |
+
"math": ["MATH1341", "MATH1342", "MATH2331", "MATH3081"]
|
| 42 |
+
},
|
| 43 |
+
"systems": {
|
| 44 |
+
"foundations": ["CS1800", "CS2500", "CS2510", "CS2800"],
|
| 45 |
+
"core_easy": ["CS3000", "CS3500", "CS3650"],
|
| 46 |
+
"core_medium": ["CS3000", "CS3500", "CS3650", "CS3700"],
|
| 47 |
+
"core_hard": ["CS3000", "CS3500", "CS3650", "CS3700", "CS4700"],
|
| 48 |
+
"specialized_easy": ["CS4730", "CS4750"],
|
| 49 |
+
"specialized_medium": ["CS4730", "CS4750", "CS4770"],
|
| 50 |
+
"specialized_hard": ["CS4730", "CS4750", "CS4770", "CS4400"],
|
| 51 |
+
"math": ["MATH1341", "MATH1342"]
|
| 52 |
+
},
|
| 53 |
+
"security": {
|
| 54 |
+
"foundations": ["CS1800", "CS2500", "CS2510", "CS2800"],
|
| 55 |
+
"core_easy": ["CS3000", "CS3650", "CY2550"],
|
| 56 |
+
"core_medium": ["CS3000", "CS3650", "CS3700", "CY2550"],
|
| 57 |
+
"core_hard": ["CS3000", "CS3650", "CS3700", "CY2550", "CY3740"],
|
| 58 |
+
"specialized_easy": ["CY4740", "CY4760"],
|
| 59 |
+
"specialized_medium": ["CY4740", "CY4760", "CY4770"],
|
| 60 |
+
"specialized_hard": ["CY4740", "CY4760", "CY4770", "CS4700"],
|
| 61 |
+
"math": ["MATH1342", "MATH3527"]
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
def __init__(self):
|
| 66 |
+
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 67 |
+
|
| 68 |
+
# Use smaller model for efficiency
|
| 69 |
+
self.model_name = "meta-llama/Llama-3.1-8B-Instruct"
|
| 70 |
+
self.embedding_model_name = 'BAAI/bge-large-en-v1.5'
|
| 71 |
+
|
| 72 |
+
self.llm = None
|
| 73 |
+
self.tokenizer = None
|
| 74 |
+
self.embedding_model = None
|
| 75 |
+
self.curriculum_graph = None
|
| 76 |
+
self.courses = {}
|
| 77 |
+
|
| 78 |
+
def load_models(self):
|
| 79 |
+
"""Load embedding model and optionally LLM"""
|
| 80 |
+
print("Loading embedding model...")
|
| 81 |
+
self.embedding_model = SentenceTransformer(self.embedding_model_name, device=self.device)
|
| 82 |
+
|
| 83 |
+
def load_llm(self):
|
| 84 |
+
"""Load LLM separately for when needed"""
|
| 85 |
+
if self.device.type == 'cuda' and self.llm is None:
|
| 86 |
+
print("Loading LLM for intelligent planning...")
|
| 87 |
+
quant_config = BitsAndBytesConfig(
|
| 88 |
+
load_in_4bit=True,
|
| 89 |
+
bnb_4bit_quant_type="nf4",
|
| 90 |
+
bnb_4bit_compute_dtype=torch.bfloat16
|
| 91 |
+
)
|
| 92 |
+
self.tokenizer = AutoTokenizer.from_pretrained(self.model_name)
|
| 93 |
+
self.tokenizer.pad_token = self.tokenizer.eos_token
|
| 94 |
+
self.llm = AutoModelForCausalLM.from_pretrained(
|
| 95 |
+
self.model_name,
|
| 96 |
+
quantization_config=quant_config,
|
| 97 |
+
device_map="auto"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
def load_data(self, graph: nx.DiGraph):
|
| 101 |
+
"""Load and preprocess curriculum data"""
|
| 102 |
+
self.curriculum_graph = graph
|
| 103 |
+
self.courses = dict(graph.nodes(data=True))
|
| 104 |
+
|
| 105 |
+
# Filter valid courses
|
| 106 |
+
self.valid_courses = []
|
| 107 |
+
course_texts = []
|
| 108 |
+
|
| 109 |
+
for cid, data in self.courses.items():
|
| 110 |
+
# Skip labs/recitations
|
| 111 |
+
name = data.get('name', '')
|
| 112 |
+
if any(skip in name for skip in ['Lab', 'Recitation', 'Seminar', 'Practicum']):
|
| 113 |
+
continue
|
| 114 |
+
|
| 115 |
+
# Skip grad level
|
| 116 |
+
if self._get_level(cid) >= 5000:
|
| 117 |
+
continue
|
| 118 |
+
|
| 119 |
+
self.valid_courses.append(cid)
|
| 120 |
+
course_texts.append(f"{name} {data.get('description', '')}")
|
| 121 |
+
|
| 122 |
+
# Precompute embeddings
|
| 123 |
+
print(f"Computing embeddings for {len(self.valid_courses)} courses...")
|
| 124 |
+
self.course_embeddings = self.embedding_model.encode(
|
| 125 |
+
course_texts,
|
| 126 |
+
convert_to_tensor=True,
|
| 127 |
+
show_progress_bar=True
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
def generate_llm_plan(self, student: StudentProfile) -> Dict:
|
| 131 |
+
"""Generate AI-powered plan with LLM course selection"""
|
| 132 |
+
print("--- Generating AI-Optimized Plan ---")
|
| 133 |
+
|
| 134 |
+
# Ensure LLM is loaded
|
| 135 |
+
self.load_llm()
|
| 136 |
+
|
| 137 |
+
if not self.llm:
|
| 138 |
+
print("LLM not available, falling back to enhanced rule-based plan")
|
| 139 |
+
return self.generate_enhanced_rule_plan(student)
|
| 140 |
+
|
| 141 |
+
# Step 1: Identify track
|
| 142 |
+
track = self._identify_track(student)
|
| 143 |
+
print(f"Identified track: {track}")
|
| 144 |
+
|
| 145 |
+
# Step 2: Get LLM-suggested courses
|
| 146 |
+
llm_suggestions = self._get_llm_course_suggestions(student, track)
|
| 147 |
+
|
| 148 |
+
# Step 3: Build plan using LLM suggestions + rules
|
| 149 |
+
plan = self._build_llm_guided_plan(student, track, llm_suggestions)
|
| 150 |
+
|
| 151 |
+
# Step 4: Generate explanation
|
| 152 |
+
explanation = self._generate_explanation(student, plan, track, "AI-optimized")
|
| 153 |
+
|
| 154 |
+
return self._finalize_plan(plan, explanation)
|
| 155 |
+
|
| 156 |
+
def generate_simple_plan(self, student: StudentProfile) -> Dict:
|
| 157 |
+
"""Generate rule-based plan that considers student preferences"""
|
| 158 |
+
print("--- Generating Enhanced Rule-Based Plan ---")
|
| 159 |
+
return self.generate_enhanced_rule_plan(student)
|
| 160 |
+
|
| 161 |
+
def generate_enhanced_rule_plan(self, student: StudentProfile) -> Dict:
|
| 162 |
+
"""Enhanced rule-based plan that actually uses student profile"""
|
| 163 |
+
|
| 164 |
+
# Step 1: Identify track
|
| 165 |
+
track = self._identify_track(student)
|
| 166 |
+
|
| 167 |
+
# Step 2: Adjust plan based on student preferences
|
| 168 |
+
difficulty_level = self._map_difficulty(student.preferred_difficulty)
|
| 169 |
+
courses_per_semester = self._calculate_course_load(student.time_commitment)
|
| 170 |
+
|
| 171 |
+
# Step 3: Get semantic scores for electives
|
| 172 |
+
semantic_scores = self._compute_semantic_scores(student)
|
| 173 |
+
|
| 174 |
+
# Step 4: Build personalized deterministic plan
|
| 175 |
+
plan = self._build_personalized_plan(
|
| 176 |
+
student, track, difficulty_level,
|
| 177 |
+
courses_per_semester, semantic_scores
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
# Step 5: Generate explanation
|
| 181 |
+
explanation = f"Personalized {track} track ({difficulty_level} difficulty, {courses_per_semester} courses/semester)"
|
| 182 |
+
|
| 183 |
+
return self._finalize_plan(plan, explanation)
|
| 184 |
+
|
| 185 |
+
def _get_llm_course_suggestions(self, student: StudentProfile, track: str) -> List[str]:
|
| 186 |
+
"""Use LLM to suggest personalized course priorities"""
|
| 187 |
+
|
| 188 |
+
# Get available specialized courses for this track
|
| 189 |
+
track_courses = self.TRACK_SEQUENCES.get(track, self.TRACK_SEQUENCES["ai_ml"])
|
| 190 |
+
all_specialized = (
|
| 191 |
+
track_courses.get("specialized_easy", []) +
|
| 192 |
+
track_courses.get("specialized_medium", []) +
|
| 193 |
+
track_courses.get("specialized_hard", [])
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
# Create course options text
|
| 197 |
+
course_options = []
|
| 198 |
+
for cid in all_specialized[:10]: # Limit to avoid token limits
|
| 199 |
+
if cid in self.courses:
|
| 200 |
+
name = self.courses[cid].get('name', cid)
|
| 201 |
+
desc = self.courses[cid].get('description', '')[:100]
|
| 202 |
+
course_options.append(f"{cid}: {name} - {desc}")
|
| 203 |
+
|
| 204 |
+
prompt = f"""You are a curriculum advisor. Given this student profile, rank the TOP 5 most relevant courses from the options below.
|
| 205 |
+
|
| 206 |
+
Student Profile:
|
| 207 |
+
- Career Goal: {student.career_goals}
|
| 208 |
+
- Interests: {', '.join(student.interests)}
|
| 209 |
+
- Time Commitment: {student.time_commitment} hours/week
|
| 210 |
+
- Preferred Difficulty: {student.preferred_difficulty}
|
| 211 |
+
- Current GPA: {student.current_gpa}
|
| 212 |
+
|
| 213 |
+
Available Courses:
|
| 214 |
+
{chr(10).join(course_options)}
|
| 215 |
+
|
| 216 |
+
Return ONLY the top 5 course IDs in order of priority, one per line. Example:
|
| 217 |
+
CS4100
|
| 218 |
+
DS4400
|
| 219 |
+
CS4120
|
| 220 |
+
CS4180
|
| 221 |
+
DS4440"""
|
| 222 |
+
|
| 223 |
+
try:
|
| 224 |
+
inputs = self.tokenizer(prompt, return_tensors="pt", truncation=True, max_length=2048).to(self.device)
|
| 225 |
+
|
| 226 |
+
with torch.no_grad():
|
| 227 |
+
outputs = self.llm.generate(
|
| 228 |
+
**inputs,
|
| 229 |
+
max_new_tokens=100,
|
| 230 |
+
temperature=0.3,
|
| 231 |
+
do_sample=True,
|
| 232 |
+
pad_token_id=self.tokenizer.eos_token_id
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
response = self.tokenizer.decode(outputs[0][len(inputs['input_ids'][0]):], skip_special_tokens=True)
|
| 236 |
+
|
| 237 |
+
# Extract course IDs
|
| 238 |
+
suggested_courses = []
|
| 239 |
+
for line in response.strip().split('\n'):
|
| 240 |
+
line = line.strip()
|
| 241 |
+
# Look for course ID pattern
|
| 242 |
+
match = re.search(r'([A-Z]{2,4}\d{4})', line)
|
| 243 |
+
if match:
|
| 244 |
+
suggested_courses.append(match.group(1))
|
| 245 |
+
|
| 246 |
+
return suggested_courses[:5]
|
| 247 |
+
|
| 248 |
+
except Exception as e:
|
| 249 |
+
print(f"LLM suggestion failed: {e}")
|
| 250 |
+
return all_specialized[:5] # Fallback
|
| 251 |
+
|
| 252 |
+
def _build_llm_guided_plan(
|
| 253 |
+
self,
|
| 254 |
+
student: StudentProfile,
|
| 255 |
+
track: str,
|
| 256 |
+
llm_suggestions: List[str]
|
| 257 |
+
) -> Dict:
|
| 258 |
+
"""Build plan using LLM suggestions for specialized courses"""
|
| 259 |
+
|
| 260 |
+
completed = set(student.completed_courses)
|
| 261 |
+
plan = {}
|
| 262 |
+
track_courses = self.TRACK_SEQUENCES.get(track, self.TRACK_SEQUENCES["ai_ml"])
|
| 263 |
+
|
| 264 |
+
# Create personalized course queue
|
| 265 |
+
difficulty_level = self._map_difficulty(student.preferred_difficulty)
|
| 266 |
+
courses_per_semester = self._calculate_course_load(student.time_commitment)
|
| 267 |
+
|
| 268 |
+
required_queue = (
|
| 269 |
+
track_courses["foundations"] +
|
| 270 |
+
track_courses.get(f"core_{difficulty_level}", track_courses["core_easy"])
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
# Add LLM-suggested specialized courses
|
| 274 |
+
specialized_queue = llm_suggestions[:3] # Top 3 suggestions
|
| 275 |
+
|
| 276 |
+
# Build semester plan
|
| 277 |
+
for sem_num in range(1, 9):
|
| 278 |
+
year = ((sem_num - 1) // 2) + 1
|
| 279 |
+
is_fall = (sem_num % 2) == 1
|
| 280 |
+
|
| 281 |
+
available = self._get_available_courses(completed, year)
|
| 282 |
+
selected = []
|
| 283 |
+
|
| 284 |
+
# Priority 1: Required courses
|
| 285 |
+
for course in required_queue[:]:
|
| 286 |
+
if course in available and len(selected) < courses_per_semester:
|
| 287 |
+
selected.append(course)
|
| 288 |
+
required_queue.remove(course)
|
| 289 |
+
available.remove(course)
|
| 290 |
+
|
| 291 |
+
# Priority 2: LLM-suggested specialized courses
|
| 292 |
+
for course in specialized_queue[:]:
|
| 293 |
+
if course in available and len(selected) < courses_per_semester:
|
| 294 |
+
selected.append(course)
|
| 295 |
+
specialized_queue.remove(course)
|
| 296 |
+
available.remove(course)
|
| 297 |
+
|
| 298 |
+
# Priority 3: Math courses (early years)
|
| 299 |
+
if year <= 2:
|
| 300 |
+
for math_course in track_courses.get("math", []):
|
| 301 |
+
if math_course in available and len(selected) < courses_per_semester:
|
| 302 |
+
selected.append(math_course)
|
| 303 |
+
available.remove(math_course)
|
| 304 |
+
|
| 305 |
+
# Priority 4: High-scoring electives
|
| 306 |
+
if len(selected) < courses_per_semester and available:
|
| 307 |
+
semantic_scores = self._compute_semantic_scores(student)
|
| 308 |
+
remaining_electives = [(cid, self._score_elective(cid, semantic_scores, completed))
|
| 309 |
+
for cid in available]
|
| 310 |
+
remaining_electives.sort(key=lambda x: x[1], reverse=True)
|
| 311 |
+
|
| 312 |
+
for cid, score in remaining_electives:
|
| 313 |
+
if len(selected) >= courses_per_semester:
|
| 314 |
+
break
|
| 315 |
+
selected.append(cid)
|
| 316 |
+
|
| 317 |
+
# Add to plan
|
| 318 |
+
if selected:
|
| 319 |
+
year_key = f"year_{year}"
|
| 320 |
+
if year_key not in plan:
|
| 321 |
+
plan[year_key] = {}
|
| 322 |
+
|
| 323 |
+
sem_type = 'fall' if is_fall else 'spring'
|
| 324 |
+
plan[year_key][sem_type] = selected[:courses_per_semester]
|
| 325 |
+
completed.update(selected)
|
| 326 |
+
|
| 327 |
+
return plan
|
| 328 |
+
|
| 329 |
+
def _build_personalized_plan(
|
| 330 |
+
self,
|
| 331 |
+
student: StudentProfile,
|
| 332 |
+
track: str,
|
| 333 |
+
difficulty_level: str,
|
| 334 |
+
courses_per_semester: int,
|
| 335 |
+
semantic_scores: Dict[str, float]
|
| 336 |
+
) -> Dict:
|
| 337 |
+
"""Build plan considering student preferences"""
|
| 338 |
+
|
| 339 |
+
completed = set(student.completed_courses)
|
| 340 |
+
plan = {}
|
| 341 |
+
track_courses = self.TRACK_SEQUENCES.get(track, self.TRACK_SEQUENCES["ai_ml"])
|
| 342 |
+
|
| 343 |
+
# Create difficulty-appropriate course sequence
|
| 344 |
+
required_queue = (
|
| 345 |
+
track_courses["foundations"] +
|
| 346 |
+
track_courses.get(f"core_{difficulty_level}", track_courses["core_easy"]) +
|
| 347 |
+
track_courses.get(f"specialized_{difficulty_level}", track_courses["specialized_easy"])[:2]
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
for sem_num in range(1, 9):
|
| 351 |
+
year = ((sem_num - 1) // 2) + 1
|
| 352 |
+
is_fall = (sem_num % 2) == 1
|
| 353 |
+
|
| 354 |
+
available = self._get_available_courses(completed, year)
|
| 355 |
+
selected = []
|
| 356 |
+
|
| 357 |
+
# Apply GPA-based difficulty adjustment
|
| 358 |
+
if student.current_gpa < 3.0 and difficulty_level == "hard":
|
| 359 |
+
# Reduce course load for struggling students
|
| 360 |
+
max_courses = min(courses_per_semester, 3)
|
| 361 |
+
elif student.current_gpa > 3.7 and student.time_commitment > 35:
|
| 362 |
+
# Allow higher course load for high achievers with time
|
| 363 |
+
max_courses = min(courses_per_semester + 1, 5)
|
| 364 |
+
else:
|
| 365 |
+
max_courses = courses_per_semester
|
| 366 |
+
|
| 367 |
+
# Priority 1: Required courses from queue
|
| 368 |
+
for course in required_queue[:]:
|
| 369 |
+
if course in available and len(selected) < max_courses:
|
| 370 |
+
selected.append(course)
|
| 371 |
+
required_queue.remove(course)
|
| 372 |
+
available.remove(course)
|
| 373 |
+
|
| 374 |
+
# Priority 2: Math requirements (adjusted by difficulty)
|
| 375 |
+
if year <= 2:
|
| 376 |
+
math_courses = track_courses.get("math", [])
|
| 377 |
+
if difficulty_level == "easy":
|
| 378 |
+
math_courses = math_courses[:2] # Fewer math courses for easy track
|
| 379 |
+
|
| 380 |
+
for math_course in math_courses:
|
| 381 |
+
if math_course in available and len(selected) < max_courses:
|
| 382 |
+
selected.append(math_course)
|
| 383 |
+
available.remove(math_course)
|
| 384 |
+
|
| 385 |
+
# Priority 3: Interest-aligned electives
|
| 386 |
+
if len(selected) < max_courses and available:
|
| 387 |
+
# Score and sort remaining courses
|
| 388 |
+
elective_scores = [
|
| 389 |
+
(cid, self._score_elective_personalized(cid, semantic_scores, student, completed))
|
| 390 |
+
for cid in available
|
| 391 |
+
]
|
| 392 |
+
elective_scores.sort(key=lambda x: x[1], reverse=True)
|
| 393 |
+
|
| 394 |
+
for cid, score in elective_scores:
|
| 395 |
+
if len(selected) >= max_courses:
|
| 396 |
+
break
|
| 397 |
+
selected.append(cid)
|
| 398 |
+
|
| 399 |
+
# Add to plan
|
| 400 |
+
if selected:
|
| 401 |
+
year_key = f"year_{year}"
|
| 402 |
+
if year_key not in plan:
|
| 403 |
+
plan[year_key] = {}
|
| 404 |
+
|
| 405 |
+
sem_type = 'fall' if is_fall else 'spring'
|
| 406 |
+
plan[year_key][sem_type] = selected[:max_courses]
|
| 407 |
+
completed.update(selected)
|
| 408 |
+
|
| 409 |
+
return plan
|
| 410 |
+
|
| 411 |
+
def _map_difficulty(self, preferred_difficulty: str) -> str:
|
| 412 |
+
"""Map UI difficulty to internal levels"""
|
| 413 |
+
mapping = {
|
| 414 |
+
"easy": "easy",
|
| 415 |
+
"moderate": "medium",
|
| 416 |
+
"challenging": "hard"
|
| 417 |
+
}
|
| 418 |
+
return mapping.get(preferred_difficulty.lower(), "medium")
|
| 419 |
+
|
| 420 |
+
def _calculate_course_load(self, time_commitment: int) -> int:
|
| 421 |
+
"""Calculate courses per semester based on time commitment"""
|
| 422 |
+
if time_commitment < 20:
|
| 423 |
+
return 3 # Part-time
|
| 424 |
+
elif time_commitment < 30:
|
| 425 |
+
return 4 # Standard
|
| 426 |
+
elif time_commitment < 40:
|
| 427 |
+
return 4 # Standard-heavy
|
| 428 |
+
else:
|
| 429 |
+
return 4 # Max (prerequisites limit anyway)
|
| 430 |
+
|
| 431 |
+
def _score_elective_personalized(
|
| 432 |
+
self,
|
| 433 |
+
course_id: str,
|
| 434 |
+
semantic_scores: Dict[str, float],
|
| 435 |
+
student: StudentProfile,
|
| 436 |
+
completed: Set[str]
|
| 437 |
+
) -> float:
|
| 438 |
+
"""Enhanced elective scoring with personalization"""
|
| 439 |
+
|
| 440 |
+
score = 0.0
|
| 441 |
+
|
| 442 |
+
# Semantic alignment (40%)
|
| 443 |
+
score += semantic_scores.get(course_id, 0) * 0.4
|
| 444 |
+
|
| 445 |
+
# Unlocks future courses (20%)
|
| 446 |
+
if course_id in self.curriculum_graph:
|
| 447 |
+
unlocks = len(list(self.curriculum_graph.successors(course_id)))
|
| 448 |
+
score += min(unlocks / 5, 1.0) * 0.2
|
| 449 |
+
|
| 450 |
+
# Subject relevance (15%)
|
| 451 |
+
subject = self.courses.get(course_id, {}).get('subject', '')
|
| 452 |
+
subject_scores = {"CS": 1.0, "DS": 0.9, "IS": 0.6, "MATH": 0.7, "CY": 0.8}
|
| 453 |
+
score += subject_scores.get(subject, 0.3) * 0.15
|
| 454 |
+
|
| 455 |
+
# Difficulty preference alignment (15%)
|
| 456 |
+
course_level = self._get_level(course_id)
|
| 457 |
+
if student.preferred_difficulty == "easy" and course_level < 3000:
|
| 458 |
+
score += 0.15
|
| 459 |
+
elif student.preferred_difficulty == "moderate" and 3000 <= course_level < 4000:
|
| 460 |
+
score += 0.15
|
| 461 |
+
elif student.preferred_difficulty == "challenging" and course_level >= 4000:
|
| 462 |
+
score += 0.15
|
| 463 |
+
|
| 464 |
+
# GPA-based difficulty adjustment (10%)
|
| 465 |
+
if student.current_gpa > 3.5 and course_level >= 4000:
|
| 466 |
+
score += 0.1 # High achievers get bonus for advanced courses
|
| 467 |
+
elif student.current_gpa < 3.0 and course_level < 3000:
|
| 468 |
+
score += 0.1 # Struggling students get bonus for foundational courses
|
| 469 |
+
|
| 470 |
+
return score
|
| 471 |
+
|
| 472 |
+
def _identify_track(self, student: StudentProfile) -> str:
|
| 473 |
+
"""Use embeddings to identify best track"""
|
| 474 |
+
|
| 475 |
+
profile_text = f"{student.career_goals} {' '.join(student.interests)}"
|
| 476 |
+
profile_emb = self.embedding_model.encode(profile_text, convert_to_tensor=True)
|
| 477 |
+
|
| 478 |
+
track_descriptions = {
|
| 479 |
+
"ai_ml": "artificial intelligence machine learning deep learning neural networks data science NLP computer vision LLM",
|
| 480 |
+
"systems": "operating systems distributed systems networks compilers databases performance optimization backend",
|
| 481 |
+
"security": "cybersecurity cryptography penetration testing security vulnerabilities network security ethical hacking"
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
best_track = "ai_ml"
|
| 485 |
+
best_score = -1
|
| 486 |
+
|
| 487 |
+
for track, description in track_descriptions.items():
|
| 488 |
+
track_emb = self.embedding_model.encode(description, convert_to_tensor=True)
|
| 489 |
+
score = float(util.cos_sim(profile_emb, track_emb))
|
| 490 |
+
if score > best_score:
|
| 491 |
+
best_score = score
|
| 492 |
+
best_track = track
|
| 493 |
+
|
| 494 |
+
return best_track
|
| 495 |
+
|
| 496 |
+
def _compute_semantic_scores(self, student: StudentProfile) -> Dict[str, float]:
|
| 497 |
+
"""Compute semantic alignment for all courses"""
|
| 498 |
+
|
| 499 |
+
query_text = f"{student.career_goals} {' '.join(student.interests)}"
|
| 500 |
+
query_emb = self.embedding_model.encode(query_text, convert_to_tensor=True)
|
| 501 |
+
|
| 502 |
+
similarities = util.cos_sim(query_emb, self.course_embeddings)[0]
|
| 503 |
+
|
| 504 |
+
scores = {}
|
| 505 |
+
for idx, cid in enumerate(self.valid_courses):
|
| 506 |
+
scores[cid] = float(similarities[idx])
|
| 507 |
+
|
| 508 |
+
return scores
|
| 509 |
+
|
| 510 |
+
def _get_available_courses(self, completed: Set[str], year: int) -> List[str]:
|
| 511 |
+
"""Get schedulable courses with year restrictions"""
|
| 512 |
+
|
| 513 |
+
available = []
|
| 514 |
+
max_level = 2999 if year == 1 else 3999 if year == 2 else 9999
|
| 515 |
+
|
| 516 |
+
for cid in self.valid_courses:
|
| 517 |
+
if cid in completed:
|
| 518 |
+
continue
|
| 519 |
+
|
| 520 |
+
if self._get_level(cid) > max_level:
|
| 521 |
+
continue
|
| 522 |
+
|
| 523 |
+
# Check prerequisites
|
| 524 |
+
if cid in self.curriculum_graph:
|
| 525 |
+
prereqs = set(self.curriculum_graph.predecessors(cid))
|
| 526 |
+
if not prereqs.issubset(completed):
|
| 527 |
+
continue
|
| 528 |
+
|
| 529 |
+
available.append(cid)
|
| 530 |
+
|
| 531 |
+
return available
|
| 532 |
+
|
| 533 |
+
def _score_elective(
|
| 534 |
+
self,
|
| 535 |
+
course_id: str,
|
| 536 |
+
semantic_scores: Dict[str, float],
|
| 537 |
+
completed: Set[str]
|
| 538 |
+
) -> float:
|
| 539 |
+
"""Basic elective scoring"""
|
| 540 |
+
|
| 541 |
+
score = 0.0
|
| 542 |
+
|
| 543 |
+
# Semantic alignment (50%)
|
| 544 |
+
score += semantic_scores.get(course_id, 0) * 0.5
|
| 545 |
+
|
| 546 |
+
# Unlocks future courses (30%)
|
| 547 |
+
if course_id in self.curriculum_graph:
|
| 548 |
+
unlocks = len(list(self.curriculum_graph.successors(course_id)))
|
| 549 |
+
score += min(unlocks / 5, 1.0) * 0.3
|
| 550 |
+
|
| 551 |
+
# Subject relevance (20%)
|
| 552 |
+
subject = self.courses.get(course_id, {}).get('subject', '')
|
| 553 |
+
subject_scores = {"CS": 1.0, "DS": 0.9, "IS": 0.6, "MATH": 0.7, "CY": 0.8}
|
| 554 |
+
score += subject_scores.get(subject, 0.3) * 0.2
|
| 555 |
+
|
| 556 |
+
return score
|
| 557 |
+
|
| 558 |
+
def _generate_explanation(self, student: StudentProfile, plan: Dict, track: str, plan_type: str) -> str:
|
| 559 |
+
"""Generate explanation using LLM if available"""
|
| 560 |
+
|
| 561 |
+
if not self.llm:
|
| 562 |
+
return f"{plan_type} {track} track plan for {student.career_goals}"
|
| 563 |
+
|
| 564 |
+
# Count courses
|
| 565 |
+
total_courses = sum(
|
| 566 |
+
len(plan.get(f"year_{y}", {}).get(sem, []))
|
| 567 |
+
for y in range(1, 5)
|
| 568 |
+
for sem in ["fall", "spring"]
|
| 569 |
+
)
|
| 570 |
+
|
| 571 |
+
prompt = f"""Explain this curriculum plan in 1-2 sentences:
|
| 572 |
+
Plan Type: {plan_type}
|
| 573 |
+
Track: {track}
|
| 574 |
+
Student Goal: {student.career_goals}
|
| 575 |
+
Interests: {', '.join(student.interests[:2])}
|
| 576 |
+
Difficulty: {student.preferred_difficulty}
|
| 577 |
+
Time Commitment: {student.time_commitment}h/week
|
| 578 |
+
Total Courses: {total_courses}
|
| 579 |
+
|
| 580 |
+
Be specific about how the plan matches their preferences."""
|
| 581 |
+
|
| 582 |
+
try:
|
| 583 |
+
inputs = self.tokenizer(prompt, return_tensors="pt", truncation=True).to(self.device)
|
| 584 |
+
|
| 585 |
+
with torch.no_grad():
|
| 586 |
+
outputs = self.llm.generate(
|
| 587 |
+
**inputs,
|
| 588 |
+
max_new_tokens=150,
|
| 589 |
+
temperature=0.7,
|
| 590 |
+
do_sample=True,
|
| 591 |
+
pad_token_id=self.tokenizer.eos_token_id
|
| 592 |
+
)
|
| 593 |
+
|
| 594 |
+
explanation = self.tokenizer.decode(outputs[0][len(inputs['input_ids'][0]):], skip_special_tokens=True)
|
| 595 |
+
return explanation.strip()
|
| 596 |
+
|
| 597 |
+
except Exception as e:
|
| 598 |
+
print(f"Explanation generation failed: {e}")
|
| 599 |
+
return f"{plan_type} {track} track plan optimized for {student.career_goals}"
|
| 600 |
+
|
| 601 |
+
def _get_level(self, course_id: str) -> int:
|
| 602 |
+
"""Extract course level"""
|
| 603 |
+
match = re.search(r'\d+', course_id)
|
| 604 |
+
return int(match.group()) if match else 9999
|
| 605 |
+
|
| 606 |
+
def _finalize_plan(self, plan: Dict, explanation: str) -> Dict:
|
| 607 |
+
"""Add structure and metrics to plan"""
|
| 608 |
+
|
| 609 |
+
structured = {"reasoning": explanation}
|
| 610 |
+
|
| 611 |
+
# Ensure all years present
|
| 612 |
+
for year in range(1, 5):
|
| 613 |
+
year_key = f"year_{year}"
|
| 614 |
+
if year_key not in plan:
|
| 615 |
+
plan[year_key] = {}
|
| 616 |
+
|
| 617 |
+
structured[year_key] = {
|
| 618 |
+
"fall": plan[year_key].get("fall", []),
|
| 619 |
+
"spring": plan[year_key].get("spring", []),
|
| 620 |
+
"summer": "co-op" if year in [2, 3] else []
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
# Calculate complexity metrics
|
| 624 |
+
complexities = []
|
| 625 |
+
for year_key in structured:
|
| 626 |
+
if year_key.startswith("year_"):
|
| 627 |
+
for sem in ["fall", "spring"]:
|
| 628 |
+
courses = structured[year_key].get(sem, [])
|
| 629 |
+
if courses:
|
| 630 |
+
sem_complexity = sum(
|
| 631 |
+
self.courses.get(c, {}).get('complexity', 50)
|
| 632 |
+
for c in courses
|
| 633 |
+
)
|
| 634 |
+
complexities.append(sem_complexity)
|
| 635 |
+
|
| 636 |
+
structured["complexity_analysis"] = {
|
| 637 |
+
"average_semester_complexity": float(np.mean(complexities)) if complexities else 0,
|
| 638 |
+
"peak_semester_complexity": float(np.max(complexities)) if complexities else 0,
|
| 639 |
+
"total_complexity": float(np.sum(complexities)) if complexities else 0,
|
| 640 |
+
"balance_score (std_dev)": float(np.std(complexities)) if complexities else 0
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
return {"pathway": structured}
|
| 644 |
+
|
| 645 |
+
# Backward compatibility wrapper
|
| 646 |
+
class CurriculumOptimizer(HybridOptimizer):
|
| 647 |
+
"""Compatibility wrapper"""
|
| 648 |
+
|
| 649 |
+
def __init__(self):
|
| 650 |
+
super().__init__()
|
| 651 |
+
|
| 652 |
+
def generate_plan(self, student: StudentProfile) -> Dict:
|
| 653 |
+
"""Default plan generation - uses enhanced rules"""
|
| 654 |
+
return self.generate_enhanced_rule_plan(student)
|
src/inspect_graph.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pickle
|
| 2 |
+
import networkx as nx
|
| 3 |
+
import argparse
|
| 4 |
+
|
| 5 |
+
def inspect_graph(graph_path: str):
|
| 6 |
+
"""
|
| 7 |
+
Loads a curriculum graph and runs diagnostic checks to verify its integrity.
|
| 8 |
+
"""
|
| 9 |
+
try:
|
| 10 |
+
with open(graph_path, 'rb') as f:
|
| 11 |
+
graph = pickle.load(f)
|
| 12 |
+
print(f"✅ Successfully loaded graph '{graph_path}'")
|
| 13 |
+
print(f" - Total Courses (Nodes): {graph.number_of_nodes()}")
|
| 14 |
+
print(f" - Prerequisite Links (Edges): {graph.number_of_edges()}")
|
| 15 |
+
except FileNotFoundError:
|
| 16 |
+
print(f"❌ ERROR: File not found at '{graph_path}'. Please check the path.")
|
| 17 |
+
return
|
| 18 |
+
except Exception as e:
|
| 19 |
+
print(f"❌ ERROR: Could not load or parse the pickle file. Reason: {e}")
|
| 20 |
+
return
|
| 21 |
+
|
| 22 |
+
print("\n--- 🧐 DIAGNOSTIC CHECKS ---")
|
| 23 |
+
|
| 24 |
+
# --- Check 1: Critical Prerequisite Links ---
|
| 25 |
+
print("\n## 1. Verifying Critical Prerequisite Links...")
|
| 26 |
+
critical_links = [
|
| 27 |
+
("CS1800", "CS2800"), # Discrete -> Logic & Comp
|
| 28 |
+
("CS2500", "CS2510"), # Fundies 1 -> Fundies 2
|
| 29 |
+
("CS2510", "CS3500"), # Fundies 2 -> OOD
|
| 30 |
+
("CS2510", "CS3000") # Fundies 2 -> Algorithms
|
| 31 |
+
]
|
| 32 |
+
all_links_ok = True
|
| 33 |
+
for prereq, course in critical_links:
|
| 34 |
+
if graph.has_node(prereq) and graph.has_node(course):
|
| 35 |
+
if graph.has_edge(prereq, course):
|
| 36 |
+
print(f" [PASS] Prerequisite link exists: {prereq} -> {course}")
|
| 37 |
+
else:
|
| 38 |
+
print(f" [FAIL] CRITICAL LINK MISSING: The graph has no link from {prereq} to {course}.")
|
| 39 |
+
all_links_ok = False
|
| 40 |
+
else:
|
| 41 |
+
print(f" [WARN] One or both courses in link {prereq} -> {course} are not in the graph.")
|
| 42 |
+
all_links_ok = False
|
| 43 |
+
|
| 44 |
+
if all_links_ok:
|
| 45 |
+
print(" -> All critical prerequisite links seem to be intact.")
|
| 46 |
+
|
| 47 |
+
# --- Check 2: Foundational Courses ---
|
| 48 |
+
print("\n## 2. Analyzing Foundational Courses (courses with no prerequisites)...")
|
| 49 |
+
foundations = [n for n, d in graph.in_degree() if d == 0]
|
| 50 |
+
if foundations:
|
| 51 |
+
print(f" Found {len(foundations)} foundational courses.")
|
| 52 |
+
cs_foundations = [c for c in foundations if c.startswith("CS")]
|
| 53 |
+
if cs_foundations:
|
| 54 |
+
print(f" -> Foundational CS courses: {', '.join(cs_foundations[:5])}...")
|
| 55 |
+
else:
|
| 56 |
+
print(" [WARN] No foundational courses with a 'CS' prefix were found. This is unusual.")
|
| 57 |
+
else:
|
| 58 |
+
print(" [FAIL] No foundational courses found. The graph may have a cycle or is structured incorrectly.")
|
| 59 |
+
|
| 60 |
+
# --- Check 3: Key Course Inspection ---
|
| 61 |
+
print("\n## 3. Inspecting Key Courses...")
|
| 62 |
+
courses_to_inspect = ["CS2500", "CS2510", "CS3500"]
|
| 63 |
+
for course_id in courses_to_inspect:
|
| 64 |
+
if graph.has_node(course_id):
|
| 65 |
+
prereqs = list(graph.predecessors(course_id))
|
| 66 |
+
unlocks = list(graph.successors(course_id))
|
| 67 |
+
print(f"\n - Course: {course_id} ({graph.nodes[course_id].get('name', 'N/A')})")
|
| 68 |
+
print(f" - Prerequisites (What it needs): {prereqs or 'None'}")
|
| 69 |
+
print(f" - Unlocks (What it leads to): {unlocks or 'None'}")
|
| 70 |
+
else:
|
| 71 |
+
print(f"\n - Course: {course_id} -> [NOT FOUND IN GRAPH]")
|
| 72 |
+
|
| 73 |
+
print("\n--- ախ DIAGNOSIS ---")
|
| 74 |
+
if not all_links_ok:
|
| 75 |
+
print("Your graph is missing critical prerequisite information.")
|
| 76 |
+
print("The planner cannot create a logical schedule without these links.")
|
| 77 |
+
print("This issue likely originates in `neu_scraper.py` or how it parses prerequisite data from the API.")
|
| 78 |
+
else:
|
| 79 |
+
print("The graph structure for critical courses appears to be correct.")
|
| 80 |
+
print("If plans are still illogical, the issue may lie in the complexity/depth attributes or the planner's sorting logic.")
|
| 81 |
+
|
| 82 |
+
if __name__ == "__main__":
|
| 83 |
+
parser = argparse.ArgumentParser(description="Curriculum Graph Diagnostic Tool")
|
| 84 |
+
# CORRECTED: Use a variable name for the argument
|
| 85 |
+
parser.add_argument("graph_path", help="Path to the .pkl graph file to inspect.")
|
| 86 |
+
args = parser.parse_args()
|
| 87 |
+
# CORRECTED: Use the correct variable to access the argument
|
| 88 |
+
inspect_graph(args.graph_path)
|
src/interactive_visualizer.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Interactive Curriculum Visualizer - FIXED VERSION
|
| 3 |
+
Creates CurricularAnalytics-style network graphs
|
| 4 |
+
"""
|
| 5 |
+
import streamlit as st
|
| 6 |
+
import networkx as nx
|
| 7 |
+
import plotly.graph_objects as go
|
| 8 |
+
import pickle
|
| 9 |
+
import json
|
| 10 |
+
from typing import Dict, List, Tuple
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
class CurriculumVisualizer:
|
| 14 |
+
"""
|
| 15 |
+
Creates interactive curriculum dependency graphs
|
| 16 |
+
Similar to CurricularAnalytics.org
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
def __init__(self, graph: nx.DiGraph):
|
| 20 |
+
self.graph = graph
|
| 21 |
+
self.courses = dict(graph.nodes(data=True))
|
| 22 |
+
self.positions = None
|
| 23 |
+
self.layers = None
|
| 24 |
+
|
| 25 |
+
def calculate_metrics(self, course_id: str) -> Dict:
|
| 26 |
+
"""Calculate blocking factor, delay factor, centrality"""
|
| 27 |
+
|
| 28 |
+
# Blocking Factor: courses this blocks
|
| 29 |
+
blocking = len(list(nx.descendants(self.graph, course_id)))
|
| 30 |
+
|
| 31 |
+
# Correctly calculate the delay factor by finding the longest path
|
| 32 |
+
delay = 0
|
| 33 |
+
sinks = [n for n, d in self.graph.out_degree() if d == 0]
|
| 34 |
+
|
| 35 |
+
max_len = 0
|
| 36 |
+
for sink in sinks:
|
| 37 |
+
try:
|
| 38 |
+
paths = list(nx.all_simple_paths(self.graph, source=course_id, target=sink))
|
| 39 |
+
if paths:
|
| 40 |
+
current_max = max(len(p) for p in paths)
|
| 41 |
+
if current_max > max_len:
|
| 42 |
+
max_len = current_max
|
| 43 |
+
except (nx.NetworkXNoPath, nx.NodeNotFound):
|
| 44 |
+
continue
|
| 45 |
+
delay = max_len
|
| 46 |
+
|
| 47 |
+
# Centrality: betweenness centrality
|
| 48 |
+
centrality_dict = nx.betweenness_centrality(self.graph)
|
| 49 |
+
centrality = centrality_dict.get(course_id, 0) * 100
|
| 50 |
+
|
| 51 |
+
# Complexity (from your analyzer)
|
| 52 |
+
complexity = self.courses[course_id].get('complexity', 0)
|
| 53 |
+
|
| 54 |
+
return {
|
| 55 |
+
'blocking': blocking,
|
| 56 |
+
'delay': delay,
|
| 57 |
+
'centrality': round(centrality, 1),
|
| 58 |
+
'complexity': complexity
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
def create_hierarchical_layout(self) -> Dict:
|
| 62 |
+
"""Create semester-based layout like CurricularAnalytics"""
|
| 63 |
+
|
| 64 |
+
# Topological sort to get course ordering
|
| 65 |
+
try:
|
| 66 |
+
topo_order = list(nx.topological_sort(self.graph))
|
| 67 |
+
except nx.NetworkXError:
|
| 68 |
+
# Has cycles, use DFS order
|
| 69 |
+
topo_order = list(nx.dfs_preorder_nodes(self.graph))
|
| 70 |
+
|
| 71 |
+
# Calculate depth for each node (semester level)
|
| 72 |
+
depths = {}
|
| 73 |
+
for node in topo_order:
|
| 74 |
+
predecessors = list(self.graph.predecessors(node))
|
| 75 |
+
if not predecessors:
|
| 76 |
+
depths[node] = 0
|
| 77 |
+
else:
|
| 78 |
+
depths[node] = max(depths.get(p, 0) for p in predecessors) + 1
|
| 79 |
+
|
| 80 |
+
# Group by depth (semester)
|
| 81 |
+
layers = {}
|
| 82 |
+
for node, depth in depths.items():
|
| 83 |
+
if depth not in layers:
|
| 84 |
+
layers[depth] = []
|
| 85 |
+
layers[depth].append(node)
|
| 86 |
+
|
| 87 |
+
# Create positions
|
| 88 |
+
positions = {}
|
| 89 |
+
max_width = max(len(nodes) for nodes in layers.values()) if layers else 1
|
| 90 |
+
|
| 91 |
+
for depth, nodes in layers.items():
|
| 92 |
+
width = len(nodes)
|
| 93 |
+
spacing = 2.0 / (width + 1) if width > 0 else 1
|
| 94 |
+
|
| 95 |
+
for i, node in enumerate(nodes):
|
| 96 |
+
x = (i + 1) * spacing - 1 # Center around 0
|
| 97 |
+
y = -depth * 2 # Vertical spacing
|
| 98 |
+
positions[node] = (x, y)
|
| 99 |
+
|
| 100 |
+
self.positions = positions
|
| 101 |
+
self.layers = layers
|
| 102 |
+
return positions
|
| 103 |
+
|
| 104 |
+
def create_interactive_plot(self, highlight_path: List[str] = None) -> go.Figure:
|
| 105 |
+
"""Create Plotly interactive network graph"""
|
| 106 |
+
|
| 107 |
+
if not self.positions:
|
| 108 |
+
self.create_hierarchical_layout()
|
| 109 |
+
|
| 110 |
+
# Create edge traces
|
| 111 |
+
edge_traces = []
|
| 112 |
+
|
| 113 |
+
for edge in self.graph.edges():
|
| 114 |
+
if edge[0] not in self.positions or edge[1] not in self.positions:
|
| 115 |
+
continue
|
| 116 |
+
x0, y0 = self.positions[edge[0]]
|
| 117 |
+
x1, y1 = self.positions[edge[1]]
|
| 118 |
+
|
| 119 |
+
# Check if edge is on critical path
|
| 120 |
+
is_critical = False
|
| 121 |
+
if highlight_path and edge[0] in highlight_path and edge[1] in highlight_path:
|
| 122 |
+
try:
|
| 123 |
+
idx0 = highlight_path.index(edge[0])
|
| 124 |
+
idx1 = highlight_path.index(edge[1])
|
| 125 |
+
is_critical = idx1 == idx0 + 1
|
| 126 |
+
except ValueError:
|
| 127 |
+
is_critical = False
|
| 128 |
+
|
| 129 |
+
edge_trace = go.Scatter(
|
| 130 |
+
x=[x0, x1, None],
|
| 131 |
+
y=[y0, y1, None],
|
| 132 |
+
mode='lines',
|
| 133 |
+
line=dict(
|
| 134 |
+
width=3 if is_critical else 1,
|
| 135 |
+
color='red' if is_critical else '#888'
|
| 136 |
+
),
|
| 137 |
+
hoverinfo='none',
|
| 138 |
+
showlegend=False
|
| 139 |
+
)
|
| 140 |
+
edge_traces.append(edge_trace)
|
| 141 |
+
|
| 142 |
+
# Create node trace
|
| 143 |
+
node_x = []
|
| 144 |
+
node_y = []
|
| 145 |
+
node_text = []
|
| 146 |
+
node_color = []
|
| 147 |
+
node_size = []
|
| 148 |
+
|
| 149 |
+
for node in self.graph.nodes():
|
| 150 |
+
if node not in self.positions:
|
| 151 |
+
continue
|
| 152 |
+
x, y = self.positions[node]
|
| 153 |
+
node_x.append(x)
|
| 154 |
+
node_y.append(y)
|
| 155 |
+
|
| 156 |
+
# Get course info
|
| 157 |
+
course_data = self.courses.get(node, {})
|
| 158 |
+
metrics = self.calculate_metrics(node)
|
| 159 |
+
|
| 160 |
+
# Create hover text
|
| 161 |
+
hover_text = f"""
|
| 162 |
+
<b>{node}: {course_data.get('name', 'Unknown')}</b><br>
|
| 163 |
+
Credits: {course_data.get('credits', 4)}<br>
|
| 164 |
+
<br><b>Metrics:</b><br>
|
| 165 |
+
Complexity: {metrics['complexity']}<br>
|
| 166 |
+
Blocking Factor: {metrics['blocking']}<br>
|
| 167 |
+
Delay Factor: {metrics['delay']}<br>
|
| 168 |
+
Centrality: {metrics['centrality']}<br>
|
| 169 |
+
Prerequisites: {', '.join(self.graph.predecessors(node)) or 'None'}
|
| 170 |
+
"""
|
| 171 |
+
node_text.append(hover_text)
|
| 172 |
+
|
| 173 |
+
# Color by complexity
|
| 174 |
+
node_color.append(metrics['complexity'])
|
| 175 |
+
|
| 176 |
+
# Size by blocking factor
|
| 177 |
+
node_size.append(15 + metrics['blocking'] * 2)
|
| 178 |
+
|
| 179 |
+
node_trace = go.Scatter(
|
| 180 |
+
x=node_x,
|
| 181 |
+
y=node_y,
|
| 182 |
+
mode='markers+text',
|
| 183 |
+
text=[node for node in self.graph.nodes() if node in self.positions],
|
| 184 |
+
textposition="top center",
|
| 185 |
+
textfont=dict(size=10),
|
| 186 |
+
hovertext=node_text,
|
| 187 |
+
hoverinfo='text',
|
| 188 |
+
marker=dict(
|
| 189 |
+
showscale=True,
|
| 190 |
+
colorscale='Viridis',
|
| 191 |
+
size=node_size,
|
| 192 |
+
color=node_color,
|
| 193 |
+
colorbar=dict(
|
| 194 |
+
thickness=15,
|
| 195 |
+
title=dict(text="Complexity", side="right"),
|
| 196 |
+
xanchor="left"
|
| 197 |
+
),
|
| 198 |
+
line=dict(width=2, color='white')
|
| 199 |
+
)
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
# Create figure
|
| 203 |
+
fig = go.Figure(data=edge_traces + [node_trace])
|
| 204 |
+
|
| 205 |
+
# FIXED: Updated layout with proper title syntax (no more titlefont_size)
|
| 206 |
+
fig.update_layout(
|
| 207 |
+
title=dict(
|
| 208 |
+
text="Interactive Curriculum Map",
|
| 209 |
+
font=dict(size=20)
|
| 210 |
+
),
|
| 211 |
+
showlegend=False,
|
| 212 |
+
hovermode='closest',
|
| 213 |
+
margin=dict(b=0, l=0, r=0, t=40),
|
| 214 |
+
xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
|
| 215 |
+
yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
|
| 216 |
+
plot_bgcolor='white',
|
| 217 |
+
height=800
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
return fig
|
| 221 |
+
|
| 222 |
+
def find_critical_path(self) -> List[str]:
|
| 223 |
+
"""Find the longest path (critical path) in curriculum"""
|
| 224 |
+
|
| 225 |
+
if not nx.is_directed_acyclic_graph(self.graph):
|
| 226 |
+
return []
|
| 227 |
+
|
| 228 |
+
# Find all paths from sources to sinks
|
| 229 |
+
sources = [n for n in self.graph.nodes() if self.graph.in_degree(n) == 0]
|
| 230 |
+
sinks = [n for n in self.graph.nodes() if self.graph.out_degree(n) == 0]
|
| 231 |
+
|
| 232 |
+
longest_path = []
|
| 233 |
+
max_length = 0
|
| 234 |
+
|
| 235 |
+
for source in sources:
|
| 236 |
+
for sink in sinks:
|
| 237 |
+
try:
|
| 238 |
+
paths = list(nx.all_simple_paths(self.graph, source, sink))
|
| 239 |
+
for path in paths:
|
| 240 |
+
if len(path) > max_length:
|
| 241 |
+
max_length = len(path)
|
| 242 |
+
longest_path = path
|
| 243 |
+
except nx.NetworkXNoPath:
|
| 244 |
+
continue
|
| 245 |
+
|
| 246 |
+
return longest_path
|
| 247 |
+
|
| 248 |
+
def export_to_curricular_analytics_format(self, plan: Dict) -> Dict:
|
| 249 |
+
"""Export plan in CurricularAnalytics JSON format"""
|
| 250 |
+
|
| 251 |
+
ca_format = {
|
| 252 |
+
"curriculum": {
|
| 253 |
+
"name": "Generated Curriculum",
|
| 254 |
+
"courses": [],
|
| 255 |
+
"dependencies": []
|
| 256 |
+
},
|
| 257 |
+
"metrics": {}
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
# Add courses
|
| 261 |
+
for course_id in self.graph.nodes():
|
| 262 |
+
course_data = self.courses.get(course_id, {})
|
| 263 |
+
metrics = self.calculate_metrics(course_id)
|
| 264 |
+
|
| 265 |
+
ca_format["curriculum"]["courses"].append({
|
| 266 |
+
"id": course_id,
|
| 267 |
+
"name": course_data.get('name', ''),
|
| 268 |
+
"credits": course_data.get('credits', 4),
|
| 269 |
+
"complexity": metrics['complexity'],
|
| 270 |
+
"blocking_factor": metrics['blocking'],
|
| 271 |
+
"delay_factor": metrics['delay'],
|
| 272 |
+
"centrality": metrics['centrality']
|
| 273 |
+
})
|
| 274 |
+
|
| 275 |
+
# Add dependencies
|
| 276 |
+
for edge in self.graph.edges():
|
| 277 |
+
ca_format["curriculum"]["dependencies"].append({
|
| 278 |
+
"source": edge[0],
|
| 279 |
+
"target": edge[1],
|
| 280 |
+
"type": "prerequisite"
|
| 281 |
+
})
|
| 282 |
+
|
| 283 |
+
return ca_format
|
| 284 |
+
|
| 285 |
+
def run_visualizer():
|
| 286 |
+
"""Streamlit app for visualization"""
|
| 287 |
+
|
| 288 |
+
st.set_page_config(page_title="Curriculum Visualizer", layout="wide")
|
| 289 |
+
st.title("🗺️ Interactive Curriculum Visualizer")
|
| 290 |
+
|
| 291 |
+
# Sidebar
|
| 292 |
+
with st.sidebar:
|
| 293 |
+
st.header("Controls")
|
| 294 |
+
|
| 295 |
+
# File upload
|
| 296 |
+
uploaded_file = st.file_uploader("Upload curriculum graph", type=['pkl'])
|
| 297 |
+
|
| 298 |
+
# Display options
|
| 299 |
+
show_critical = st.checkbox("Highlight Critical Path", value=True)
|
| 300 |
+
show_metrics = st.checkbox("Show Metrics Panel", value=True)
|
| 301 |
+
|
| 302 |
+
# Filter options
|
| 303 |
+
st.subheader("Filter Courses")
|
| 304 |
+
min_complexity = st.slider("Min Complexity", 0, 200, 0)
|
| 305 |
+
subjects = st.multiselect("Subjects", ["CS", "DS", "MATH", "IS", "CY"])
|
| 306 |
+
|
| 307 |
+
# Main content
|
| 308 |
+
if uploaded_file:
|
| 309 |
+
# Load graph
|
| 310 |
+
graph = pickle.load(uploaded_file)
|
| 311 |
+
visualizer = CurriculumVisualizer(graph)
|
| 312 |
+
|
| 313 |
+
# Apply filters
|
| 314 |
+
if subjects:
|
| 315 |
+
nodes_to_keep = [
|
| 316 |
+
n for n in graph.nodes()
|
| 317 |
+
if graph.nodes[n].get('subject') in subjects
|
| 318 |
+
]
|
| 319 |
+
filtered_graph = graph.subgraph(nodes_to_keep).copy()
|
| 320 |
+
visualizer = CurriculumVisualizer(filtered_graph)
|
| 321 |
+
|
| 322 |
+
# Create visualization
|
| 323 |
+
col1, col2 = st.columns([3, 1] if show_metrics else [1])
|
| 324 |
+
|
| 325 |
+
with col1:
|
| 326 |
+
# Find critical path
|
| 327 |
+
critical_path = []
|
| 328 |
+
if show_critical:
|
| 329 |
+
critical_path = visualizer.find_critical_path()
|
| 330 |
+
if critical_path:
|
| 331 |
+
st.info(f"Critical Path: {' → '.join(critical_path[:5])}...")
|
| 332 |
+
|
| 333 |
+
# Create and display plot
|
| 334 |
+
fig = visualizer.create_interactive_plot(critical_path)
|
| 335 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 336 |
+
|
| 337 |
+
if show_metrics:
|
| 338 |
+
with col2:
|
| 339 |
+
st.subheader("📊 Curriculum Metrics")
|
| 340 |
+
|
| 341 |
+
# Overall metrics
|
| 342 |
+
total_courses = visualizer.graph.number_of_nodes()
|
| 343 |
+
total_prereqs = visualizer.graph.number_of_edges()
|
| 344 |
+
|
| 345 |
+
st.metric("Total Courses", total_courses)
|
| 346 |
+
st.metric("Total Prerequisites", total_prereqs)
|
| 347 |
+
if total_courses > 0:
|
| 348 |
+
st.metric("Avg Prerequisites", f"{total_prereqs/total_courses:.1f}")
|
| 349 |
+
|
| 350 |
+
st.divider()
|
| 351 |
+
|
| 352 |
+
# Most complex courses
|
| 353 |
+
st.subheader("Most Complex Courses")
|
| 354 |
+
complexities = []
|
| 355 |
+
for node in visualizer.graph.nodes():
|
| 356 |
+
metrics = visualizer.calculate_metrics(node)
|
| 357 |
+
complexities.append((node, metrics['complexity']))
|
| 358 |
+
|
| 359 |
+
complexities.sort(key=lambda x: x[1], reverse=True)
|
| 360 |
+
|
| 361 |
+
for course, complexity in complexities[:5]:
|
| 362 |
+
name = visualizer.courses.get(course, {}).get('name', course)
|
| 363 |
+
st.write(f"**{course}**: {name}")
|
| 364 |
+
st.progress(min(complexity/200, 1.0) if complexity else 0.0)
|
| 365 |
+
|
| 366 |
+
# Export button
|
| 367 |
+
st.divider()
|
| 368 |
+
if st.button("Export to CA Format"):
|
| 369 |
+
ca_json = visualizer.export_to_curricular_analytics_format({})
|
| 370 |
+
st.download_button(
|
| 371 |
+
"Download JSON",
|
| 372 |
+
json.dumps(ca_json, indent=2),
|
| 373 |
+
"curriculum_analytics.json",
|
| 374 |
+
"application/json"
|
| 375 |
+
)
|
| 376 |
+
else:
|
| 377 |
+
# Demo/instruction
|
| 378 |
+
st.info("Upload a curriculum graph file to visualize")
|
| 379 |
+
|
| 380 |
+
with st.expander("About this Visualizer"):
|
| 381 |
+
st.write("""
|
| 382 |
+
This tool creates interactive curriculum dependency graphs similar to CurricularAnalytics.org.
|
| 383 |
+
|
| 384 |
+
**Features:**
|
| 385 |
+
- Hierarchical layout by semester level
|
| 386 |
+
- Color coding by complexity
|
| 387 |
+
- Node size by blocking factor
|
| 388 |
+
- Critical path highlighting
|
| 389 |
+
- Interactive hover details
|
| 390 |
+
- Export to CurricularAnalytics format
|
| 391 |
+
|
| 392 |
+
**Metrics Calculated:**
|
| 393 |
+
- **Blocking Factor**: Number of courses this prerequisite blocks
|
| 394 |
+
- **Delay Factor**: Length of longest path through this course
|
| 395 |
+
- **Centrality**: Importance in the curriculum network
|
| 396 |
+
- **Complexity**: Combined metric from all factors
|
| 397 |
+
""")
|
| 398 |
+
|
| 399 |
+
if __name__ == "__main__":
|
| 400 |
+
run_visualizer()
|
src/neu_graph_analyzed_clean.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5a06bd2aea94fbc0bb21db9f16fcb4cf201046f764cbd97f78eadd3ad5d73a4a
|
| 3 |
+
size 173978
|
src/neu_scraper.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
NEU Course Catalog Scraper using SearchNEU GraphQL API (With Proper Pagination)
|
| 3 |
+
|
| 4 |
+
Fetches ALL courses for given subjects using first/offset pagination.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python neu_scraper.py --term 202510 --subjects CS DS IS CY --prefix neu_api
|
| 8 |
+
"""
|
| 9 |
+
import requests
|
| 10 |
+
import pickle
|
| 11 |
+
import networkx as nx
|
| 12 |
+
import time
|
| 13 |
+
import logging
|
| 14 |
+
from typing import List, Dict, Set, Any
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
|
| 17 |
+
# Configure logging
|
| 18 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
class NEUGraphQLScraper:
|
| 22 |
+
def __init__(self, term_id: str, api_url: str = "https://searchneu.com/graphql"):
|
| 23 |
+
self.term_id = term_id
|
| 24 |
+
self.api_url = api_url
|
| 25 |
+
self.headers = {"Content-Type": "application/json"}
|
| 26 |
+
self.courses_data_cache: Dict[str, Dict] = {}
|
| 27 |
+
self.all_course_ids: Set[str] = set()
|
| 28 |
+
self.graph = nx.DiGraph()
|
| 29 |
+
|
| 30 |
+
def get_all_courses_by_subject(self, subject: str, batch_size: int = 100) -> List[Dict]:
|
| 31 |
+
"""Fetch ALL courses for a specific subject via GraphQL with pagination."""
|
| 32 |
+
all_courses = []
|
| 33 |
+
offset = 0
|
| 34 |
+
page = 1
|
| 35 |
+
|
| 36 |
+
while True:
|
| 37 |
+
query = """
|
| 38 |
+
query searchQuery($termId: String!, $query: String!, $first: Int, $offset: Int) {
|
| 39 |
+
search(termId: $termId, query: $query, first: $first, offset: $offset) {
|
| 40 |
+
totalCount
|
| 41 |
+
nodes {
|
| 42 |
+
__typename
|
| 43 |
+
... on ClassOccurrence {
|
| 44 |
+
subject
|
| 45 |
+
classId
|
| 46 |
+
name
|
| 47 |
+
desc
|
| 48 |
+
prereqs
|
| 49 |
+
coreqs
|
| 50 |
+
minCredits
|
| 51 |
+
maxCredits
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
"""
|
| 57 |
+
variables = {
|
| 58 |
+
"termId": self.term_id,
|
| 59 |
+
"query": subject,
|
| 60 |
+
"first": batch_size,
|
| 61 |
+
"offset": offset
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
resp = requests.post(self.api_url, json={"query": query, "variables": variables}, headers=self.headers)
|
| 66 |
+
resp.raise_for_status()
|
| 67 |
+
data = resp.json()
|
| 68 |
+
|
| 69 |
+
if "errors" in data:
|
| 70 |
+
logger.error(f"GraphQL errors for subject {subject}: {data['errors']}")
|
| 71 |
+
break
|
| 72 |
+
|
| 73 |
+
search_data = data.get("data", {}).get("search", {})
|
| 74 |
+
nodes = search_data.get("nodes", [])
|
| 75 |
+
|
| 76 |
+
# Extract ClassOccurrence nodes
|
| 77 |
+
page_courses = [c for c in nodes if c.get("__typename") == "ClassOccurrence"]
|
| 78 |
+
all_courses.extend(page_courses)
|
| 79 |
+
|
| 80 |
+
logger.info(f"Page {page}: Found {len(page_courses)} courses, Total so far: {len(all_courses)}")
|
| 81 |
+
|
| 82 |
+
# Check if we've reached the end
|
| 83 |
+
if len(page_courses) < batch_size:
|
| 84 |
+
break
|
| 85 |
+
|
| 86 |
+
offset += batch_size
|
| 87 |
+
page += 1
|
| 88 |
+
|
| 89 |
+
# Add a small delay to avoid overwhelming the API
|
| 90 |
+
time.sleep(0.1)
|
| 91 |
+
|
| 92 |
+
except Exception as e:
|
| 93 |
+
logger.error(f"Error fetching page {page} for subject {subject}: {e}")
|
| 94 |
+
break
|
| 95 |
+
|
| 96 |
+
logger.info(f"Total courses found for {subject}: {len(all_courses)}")
|
| 97 |
+
return all_courses
|
| 98 |
+
|
| 99 |
+
def get_course_data_by_id(self, subject: str, classId: str) -> Dict:
|
| 100 |
+
"""Fetch a specific course by its subject and classId."""
|
| 101 |
+
query = """
|
| 102 |
+
query searchQuery($termId: String!, $query: String!) {
|
| 103 |
+
search(termId: $termId, query: $query) {
|
| 104 |
+
nodes {
|
| 105 |
+
__typename
|
| 106 |
+
... on ClassOccurrence {
|
| 107 |
+
subject
|
| 108 |
+
classId
|
| 109 |
+
name
|
| 110 |
+
desc
|
| 111 |
+
prereqs
|
| 112 |
+
coreqs
|
| 113 |
+
minCredits
|
| 114 |
+
maxCredits
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
"""
|
| 120 |
+
variables = {"termId": self.term_id, "query": f"{subject}{classId}"}
|
| 121 |
+
try:
|
| 122 |
+
resp = requests.post(self.api_url, json={"query": query, "variables": variables}, headers=self.headers)
|
| 123 |
+
resp.raise_for_status()
|
| 124 |
+
data = resp.json()
|
| 125 |
+
|
| 126 |
+
nodes = data.get("data", {}).get("search", {}).get("nodes", [])
|
| 127 |
+
for c in nodes:
|
| 128 |
+
if c.get("subject") == subject and c.get("classId") == classId:
|
| 129 |
+
return c
|
| 130 |
+
return {}
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.error(f"Error fetching course {subject}{classId}: {e}")
|
| 133 |
+
return {}
|
| 134 |
+
|
| 135 |
+
def _recursive_parse_prereqs(self, prereq_obj: Any) -> Set[str]:
|
| 136 |
+
"""Extract course IDs from nested prereq/coreq structures."""
|
| 137 |
+
ids = set()
|
| 138 |
+
if not isinstance(prereq_obj, dict):
|
| 139 |
+
return ids
|
| 140 |
+
|
| 141 |
+
# Handle direct course references (the actual structure we see)
|
| 142 |
+
if "classId" in prereq_obj and "subject" in prereq_obj:
|
| 143 |
+
ids.add(f"{prereq_obj['subject']}{prereq_obj['classId']}")
|
| 144 |
+
return ids
|
| 145 |
+
|
| 146 |
+
# Handle logical operators (and/or) with nested values
|
| 147 |
+
if prereq_obj.get("type") in ["and", "or"]:
|
| 148 |
+
for val in prereq_obj.get("values", []):
|
| 149 |
+
ids |= self._recursive_parse_prereqs(val)
|
| 150 |
+
|
| 151 |
+
# Handle nested values in other structures
|
| 152 |
+
elif "values" in prereq_obj:
|
| 153 |
+
for val in prereq_obj.get("values", []):
|
| 154 |
+
ids |= self._recursive_parse_prereqs(val)
|
| 155 |
+
|
| 156 |
+
return ids
|
| 157 |
+
|
| 158 |
+
def scrape_full_catalog(self, subjects: List[str]):
|
| 159 |
+
"""Scrape all courses for the given subjects."""
|
| 160 |
+
logger.info(f"Fetching complete catalog for subjects: {subjects}")
|
| 161 |
+
|
| 162 |
+
all_courses = []
|
| 163 |
+
for subject in subjects:
|
| 164 |
+
logger.info(f"Fetching courses for subject: {subject}")
|
| 165 |
+
courses = self.get_all_courses_by_subject(subject)
|
| 166 |
+
all_courses.extend(courses)
|
| 167 |
+
|
| 168 |
+
# Add a small delay to be respectful to the API
|
| 169 |
+
time.sleep(0.5)
|
| 170 |
+
|
| 171 |
+
# Cache all courses
|
| 172 |
+
for c in all_courses:
|
| 173 |
+
cid = f"{c['subject']}{c['classId']}"
|
| 174 |
+
self.courses_data_cache[cid] = c
|
| 175 |
+
self.all_course_ids.add(cid)
|
| 176 |
+
|
| 177 |
+
logger.info(f"Discovered {len(all_courses)} total courses in catalog")
|
| 178 |
+
|
| 179 |
+
def build_graph(self):
|
| 180 |
+
"""Build NetworkX graph from scraped course data and requisites."""
|
| 181 |
+
logger.info("Building course graph")
|
| 182 |
+
|
| 183 |
+
# Add all courses as nodes
|
| 184 |
+
for cid, cdata in self.courses_data_cache.items():
|
| 185 |
+
self.graph.add_node(cid, **{
|
| 186 |
+
"name": cdata.get("name", ""),
|
| 187 |
+
"subject": cdata.get("subject", ""),
|
| 188 |
+
"classId": cdata.get("classId", ""),
|
| 189 |
+
"description": cdata.get("desc", ""), # Corrected from 'desc'
|
| 190 |
+
"minCredits": cdata.get("minCredits", 0),
|
| 191 |
+
"maxCredits": cdata.get("maxCredits", 0)
|
| 192 |
+
})
|
| 193 |
+
|
| 194 |
+
# Add edges ONLY for prerequisites
|
| 195 |
+
for cid, cdata in self.courses_data_cache.items():
|
| 196 |
+
prereqs = cdata.get("prereqs", {})
|
| 197 |
+
if prereqs:
|
| 198 |
+
prereq_ids = self._recursive_parse_prereqs(prereqs)
|
| 199 |
+
for pid in prereq_ids:
|
| 200 |
+
if pid in self.graph:
|
| 201 |
+
self.graph.add_edge(pid, cid, relationship="prerequisite")
|
| 202 |
+
|
| 203 |
+
def save_data(self, prefix: str):
|
| 204 |
+
"""Save graph and courses to pickle files with timestamp."""
|
| 205 |
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 206 |
+
gfile = f"{prefix}_graph_{ts}.pkl"
|
| 207 |
+
cfile = f"{prefix}_courses_{ts}.pkl"
|
| 208 |
+
|
| 209 |
+
with open(gfile, "wb") as gf:
|
| 210 |
+
pickle.dump(self.graph, gf)
|
| 211 |
+
with open(cfile, "wb") as cf:
|
| 212 |
+
pickle.dump(self.courses_data_cache, cf)
|
| 213 |
+
|
| 214 |
+
logger.info(f"Data saved: {gfile}, {cfile}")
|
| 215 |
+
|
| 216 |
+
# Also save some stats
|
| 217 |
+
logger.info(f"Graph stats: {self.graph.number_of_nodes()} nodes, {self.graph.number_of_edges()} edges")
|
| 218 |
+
|
| 219 |
+
def main():
|
| 220 |
+
import argparse
|
| 221 |
+
parser = argparse.ArgumentParser(description="Full NEU API Catalog Scraper")
|
| 222 |
+
parser.add_argument("--term", required=True, help="Term ID e.g. 202510")
|
| 223 |
+
parser.add_argument("--subjects", nargs="+", required=True, help="Subjects to scrape (e.g., CS DS IS CY)")
|
| 224 |
+
parser.add_argument("--prefix", default="neu_api", help="Output prefix")
|
| 225 |
+
parser.add_argument("--batch-size", type=int, default=100, help="Number of courses per page")
|
| 226 |
+
args = parser.parse_args()
|
| 227 |
+
|
| 228 |
+
scraper = NEUGraphQLScraper(term_id=args.term)
|
| 229 |
+
scraper.scrape_full_catalog(args.subjects)
|
| 230 |
+
scraper.build_graph()
|
| 231 |
+
scraper.save_data(args.prefix)
|
| 232 |
+
logger.info("Scraping complete.")
|
| 233 |
+
|
| 234 |
+
if __name__ == "__main__":
|
| 235 |
+
main()
|
src/prompts.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def get_semester_selection_prompt(profile_str: str, courses_str: str, num_courses: int = 4) -> str:
|
| 2 |
+
"""Generate optimized prompt for LLM course selection"""
|
| 3 |
+
|
| 4 |
+
return f"""You are an expert academic advisor for computer science students.
|
| 5 |
+
|
| 6 |
+
TASK: Select exactly {num_courses} courses for the upcoming semester.
|
| 7 |
+
|
| 8 |
+
STUDENT PROFILE:
|
| 9 |
+
{profile_str}
|
| 10 |
+
|
| 11 |
+
AVAILABLE COURSES:
|
| 12 |
+
{courses_str}
|
| 13 |
+
|
| 14 |
+
SELECTION CRITERIA:
|
| 15 |
+
1. Prerequisites must be satisfied (from completed courses list)
|
| 16 |
+
2. Prioritize courses that align with student's career goals
|
| 17 |
+
3. Balance workload - mix harder and easier courses
|
| 18 |
+
4. Consider logical progression (foundations before advanced)
|
| 19 |
+
5. Focus on CS, DS, IS courses for AI/ML career path
|
| 20 |
+
|
| 21 |
+
OUTPUT FORMAT (must be valid JSON):
|
| 22 |
+
{{
|
| 23 |
+
"courses": ["COURSE_ID_1", "COURSE_ID_2", "COURSE_ID_3", "COURSE_ID_4"],
|
| 24 |
+
"reasoning": "One sentence explaining the selection"
|
| 25 |
+
}}
|
| 26 |
+
|
| 27 |
+
Return ONLY the JSON object, no other text."""
|
| 28 |
+
|
| 29 |
+
def get_plan_optimization_prompt(student_profile: dict, available_courses: list, semester_num: int) -> str:
|
| 30 |
+
"""Generate prompt for full degree plan optimization"""
|
| 31 |
+
|
| 32 |
+
return f"""Create semester {semester_num} schedule for an AI/ML-focused student.
|
| 33 |
+
|
| 34 |
+
COMPLETED: {', '.join(student_profile.get('completed_courses', []))}
|
| 35 |
+
GOAL: {student_profile.get('career_goals', 'AI Engineer')}
|
| 36 |
+
INTERESTS: {', '.join(student_profile.get('interests', []))}
|
| 37 |
+
|
| 38 |
+
MUST FOLLOW RULES:
|
| 39 |
+
- Take foundations first: CS1800, CS2500, CS2510, CS2800, CS3000, CS3500
|
| 40 |
+
- Year 1: Focus on 1000-2000 level courses
|
| 41 |
+
- Year 2: Add 3000 level courses
|
| 42 |
+
- Year 3-4: Include 4000+ level courses
|
| 43 |
+
- Avoid labs, recitations, seminars (they're auto-enrolled)
|
| 44 |
+
|
| 45 |
+
AVAILABLE COURSES:
|
| 46 |
+
{chr(10).join(available_courses[:20])}
|
| 47 |
+
|
| 48 |
+
OUTPUT: JSON with "courses" array (4 course IDs) and "reasoning" string."""
|
src/requirements (1).txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask
|
| 2 |
+
Flask-CORS
|
| 3 |
+
PyMuPDF
|
| 4 |
+
sentence-transformers
|
| 5 |
+
faiss-cpu
|
| 6 |
+
numpy
|
| 7 |
+
langchain
|
| 8 |
+
langchain_community
|
| 9 |
+
requests # To communicate with Ollama API
|
| 10 |
+
python-dotenv # Optional, for managing environment variables like OLLAMA_BASE_URL
|
| 11 |
+
scikit-learn # For cosine similarity if needed outside FAISS
|
| 12 |
+
nltk # For sentence tokenization
|
| 13 |
+
|
src/run.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Unified runner that works on any hardware
|
| 4 |
+
Automatically adapts to available resources
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
import argparse
|
| 9 |
+
from config import get_config
|
| 10 |
+
|
| 11 |
+
def run_streamlit_app():
|
| 12 |
+
"""Run the standard Streamlit UI"""
|
| 13 |
+
import streamlit.web.cli as stcli
|
| 14 |
+
sys.argv = ["streamlit", "run", "ui.py"]
|
| 15 |
+
sys.exit(stcli.main())
|
| 16 |
+
|
| 17 |
+
def run_agent_mode():
|
| 18 |
+
"""Run the autonomous agent"""
|
| 19 |
+
from agentic_optimizer import LocalAgentRunner, StudentProfile
|
| 20 |
+
|
| 21 |
+
print("Starting Agentic Mode...")
|
| 22 |
+
runner = LocalAgentRunner("neu_graph_analyzed_clean.pkl")
|
| 23 |
+
|
| 24 |
+
# Demo: Add a test student
|
| 25 |
+
student = StudentProfile(
|
| 26 |
+
student_id="demo",
|
| 27 |
+
completed_courses=["CS1800", "CS2500"],
|
| 28 |
+
current_gpa=3.5,
|
| 29 |
+
interests=["AI", "Machine Learning"],
|
| 30 |
+
career_goals="ML Engineer",
|
| 31 |
+
learning_style="Visual",
|
| 32 |
+
time_commitment=40,
|
| 33 |
+
preferred_difficulty="moderate"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
student_id = runner.add_student(student)
|
| 37 |
+
print(f"Tracking student: {student_id}")
|
| 38 |
+
|
| 39 |
+
# Start agent
|
| 40 |
+
runner.start_agent()
|
| 41 |
+
|
| 42 |
+
def run_api_server():
|
| 43 |
+
"""Run as REST API server"""
|
| 44 |
+
from fastapi import FastAPI, HTTPException
|
| 45 |
+
from pydantic import BaseModel
|
| 46 |
+
import uvicorn
|
| 47 |
+
import pickle
|
| 48 |
+
|
| 49 |
+
# Load optimizer
|
| 50 |
+
from curriculum_optimizer import HybridOptimizer, StudentProfile
|
| 51 |
+
|
| 52 |
+
app = FastAPI(title="Curriculum Optimizer API")
|
| 53 |
+
|
| 54 |
+
# Load model once
|
| 55 |
+
optimizer = HybridOptimizer()
|
| 56 |
+
optimizer.load_models()
|
| 57 |
+
|
| 58 |
+
with open("neu_graph_analyzed_clean.pkl", 'rb') as f:
|
| 59 |
+
graph = pickle.load(f)
|
| 60 |
+
optimizer.load_data(graph)
|
| 61 |
+
|
| 62 |
+
class PlanRequest(BaseModel):
|
| 63 |
+
completed_courses: list
|
| 64 |
+
gpa: float = 3.5
|
| 65 |
+
interests: list
|
| 66 |
+
career_goals: str
|
| 67 |
+
learning_style: str = "Visual"
|
| 68 |
+
time_commitment: int = 40
|
| 69 |
+
preferred_difficulty: str = "moderate"
|
| 70 |
+
|
| 71 |
+
@app.post("/generate_plan")
|
| 72 |
+
async def generate_plan(request: PlanRequest):
|
| 73 |
+
profile = StudentProfile(
|
| 74 |
+
completed_courses=request.completed_courses,
|
| 75 |
+
current_gpa=request.gpa,
|
| 76 |
+
interests=request.interests,
|
| 77 |
+
career_goals=request.career_goals,
|
| 78 |
+
learning_style=request.learning_style,
|
| 79 |
+
time_commitment=request.time_commitment,
|
| 80 |
+
preferred_difficulty=request.preferred_difficulty
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
plan = optimizer.generate_plan(profile)
|
| 84 |
+
return plan
|
| 85 |
+
|
| 86 |
+
@app.get("/health")
|
| 87 |
+
async def health():
|
| 88 |
+
return {"status": "healthy", "device": str(optimizer.device)}
|
| 89 |
+
|
| 90 |
+
print("Starting API server on http://localhost:8000")
|
| 91 |
+
print("API docs at http://localhost:8000/docs")
|
| 92 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
| 93 |
+
|
| 94 |
+
def test_hardware():
|
| 95 |
+
"""Test what hardware is available"""
|
| 96 |
+
import torch
|
| 97 |
+
|
| 98 |
+
print("=" * 60)
|
| 99 |
+
print("HARDWARE TEST")
|
| 100 |
+
print("=" * 60)
|
| 101 |
+
|
| 102 |
+
if torch.cuda.is_available():
|
| 103 |
+
print(f"✓ CUDA available")
|
| 104 |
+
print(f" Device: {torch.cuda.get_device_name(0)}")
|
| 105 |
+
print(f" Memory: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f}GB")
|
| 106 |
+
print(f" Compute: {torch.cuda.get_device_properties(0).major}.{torch.cuda.get_device_properties(0).minor}")
|
| 107 |
+
else:
|
| 108 |
+
print("✗ No CUDA (CPU only)")
|
| 109 |
+
|
| 110 |
+
print(f"\nSelected Config: {get_config().__name__}")
|
| 111 |
+
config = get_config()
|
| 112 |
+
print(f" LLM: {config.LLM_MODEL or 'None (embeddings only)'}")
|
| 113 |
+
print(f" Embedder: {config.EMBEDDING_MODEL}")
|
| 114 |
+
print(f" Quantization: {config.QUANTIZATION or 'None'}")
|
| 115 |
+
|
| 116 |
+
print("\nRecommended mode based on hardware:")
|
| 117 |
+
if torch.cuda.is_available() and torch.cuda.get_device_properties(0).total_memory > 10e9:
|
| 118 |
+
print(" → Use 'streamlit' or 'agent' mode (full features)")
|
| 119 |
+
else:
|
| 120 |
+
print(" → Use 'api' mode (lightweight)")
|
| 121 |
+
|
| 122 |
+
def main():
|
| 123 |
+
parser = argparse.ArgumentParser(description="Curriculum Optimizer Runner")
|
| 124 |
+
parser.add_argument(
|
| 125 |
+
"mode",
|
| 126 |
+
choices=["streamlit", "agent", "api", "test"],
|
| 127 |
+
help="Run mode: streamlit (UI), agent (autonomous), api (REST server), test (hardware test)"
|
| 128 |
+
)
|
| 129 |
+
parser.add_argument(
|
| 130 |
+
"--config",
|
| 131 |
+
choices=["h200", "colab", "local", "cpu", "minimal"],
|
| 132 |
+
help="Force specific configuration"
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
args = parser.parse_args()
|
| 136 |
+
|
| 137 |
+
# Set config if specified
|
| 138 |
+
if args.config:
|
| 139 |
+
import os
|
| 140 |
+
os.environ["CURRICULUM_CONFIG"] = args.config
|
| 141 |
+
|
| 142 |
+
# Run selected mode
|
| 143 |
+
if args.mode == "streamlit":
|
| 144 |
+
run_streamlit_app()
|
| 145 |
+
elif args.mode == "agent":
|
| 146 |
+
run_agent_mode()
|
| 147 |
+
elif args.mode == "api":
|
| 148 |
+
run_api_server()
|
| 149 |
+
elif args.mode == "test":
|
| 150 |
+
test_hardware()
|
| 151 |
+
|
| 152 |
+
if __name__ == "__main__":
|
| 153 |
+
if len(sys.argv) == 1:
|
| 154 |
+
# No arguments - run hardware test
|
| 155 |
+
test_hardware()
|
| 156 |
+
print("\nUsage: python run.py [streamlit|agent|api|test]")
|
| 157 |
+
else:
|
| 158 |
+
main()
|