ckharche commited on
Commit
a522797
·
verified ·
1 Parent(s): 3ca243a

Upload 12 files

Browse files
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()