work-sejal commited on
Commit
c045254
·
1 Parent(s): c327792

Add knowledge graph and adaptive learning path features to HF Space

Browse files
app/api/v2/dependencies.py CHANGED
@@ -41,6 +41,8 @@ class ServiceContainer:
41
  class_insights: Any = None
42
  teacher_feedback: Any = None
43
  monitoring: Any = None
 
 
44
 
45
 
46
  def get_service_container(request: Request) -> ServiceContainer:
@@ -96,3 +98,13 @@ def get_teacher_feedback_service(request: Request) -> Any:
96
  def get_monitoring_service(request: Request) -> Any:
97
  """Provide the monitoring service instance."""
98
  return request.app.state.services.monitoring
 
 
 
 
 
 
 
 
 
 
 
41
  class_insights: Any = None
42
  teacher_feedback: Any = None
43
  monitoring: Any = None
44
+ knowledge_graph: Any = None
45
+ adaptive_learning_path: Any = None
46
 
47
 
48
  def get_service_container(request: Request) -> ServiceContainer:
 
98
  def get_monitoring_service(request: Request) -> Any:
99
  """Provide the monitoring service instance."""
100
  return request.app.state.services.monitoring
101
+
102
+
103
+ def get_knowledge_graph_service(request: Request) -> Any:
104
+ """Provide the knowledge graph service instance."""
105
+ return request.app.state.services.knowledge_graph
106
+
107
+
108
+ def get_adaptive_learning_path_service(request: Request) -> Any:
109
+ """Provide the adaptive learning path service instance."""
110
+ return request.app.state.services.adaptive_learning_path
app/api/v2/knowledge_graph.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Knowledge graph API endpoints."""
2
+
3
+ import logging
4
+ from fastapi import APIRouter, Depends, HTTPException, Query
5
+ from fastapi.responses import JSONResponse
6
+
7
+ from app.core.exceptions import EntityNotFoundError, DatasetError
8
+ from app.schemas.knowledge_graph import KnowledgeGraphResponse
9
+ from app.services.knowledge_graph_service import KnowledgeGraphService
10
+ from app.api.v2.dependencies import get_knowledge_graph_service
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ router = APIRouter(prefix="/ai/v2/knowledge-graph", tags=["knowledge-graph"])
15
+
16
+
17
+ # NOTE: Static-segment routes MUST be declared before dynamic /{lo_id} routes
18
+ # so FastAPI doesn't swallow "path" as a lo_id value.
19
+
20
+
21
+ @router.get(
22
+ "/path/{start_lo}/{target_lo}",
23
+ summary="Get shortest path between two learning outcomes",
24
+ description="Find the shortest dependency path between two learning outcomes in the knowledge graph"
25
+ )
26
+ async def get_learning_path_between_los(
27
+ start_lo: str,
28
+ target_lo: str,
29
+ knowledge_graph_service: KnowledgeGraphService = Depends(get_knowledge_graph_service)
30
+ ) -> JSONResponse:
31
+ """Get learning path between two learning outcomes."""
32
+ try:
33
+ path = knowledge_graph_service.get_learning_path(start_lo, target_lo)
34
+ return JSONResponse(content={
35
+ "start_lo": start_lo,
36
+ "target_lo": target_lo,
37
+ "path": path,
38
+ "steps": len(path)
39
+ })
40
+ except EntityNotFoundError as exc:
41
+ logger.warning("Learning path query failed - entity not found: %s", exc)
42
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
43
+ except Exception as exc:
44
+ logger.error("Learning path query failed: %s", exc)
45
+ raise HTTPException(status_code=500, detail="Internal server error") from exc
46
+
47
+
48
+ @router.get(
49
+ "/{lo_id}/prerequisites",
50
+ summary="Get prerequisites for a learning outcome",
51
+ description="Get all prerequisite learning outcomes for a given LO"
52
+ )
53
+ async def get_prerequisites(
54
+ lo_id: str,
55
+ max_depth: int = Query(default=None, ge=1, le=10, description="Maximum depth to traverse"),
56
+ knowledge_graph_service: KnowledgeGraphService = Depends(get_knowledge_graph_service)
57
+ ) -> JSONResponse:
58
+ """Get prerequisites for a learning outcome."""
59
+ try:
60
+ prerequisites = knowledge_graph_service.get_prerequisites(lo_id, max_depth)
61
+ return JSONResponse(content={
62
+ "lo_id": lo_id,
63
+ "prerequisites": prerequisites,
64
+ "count": len(prerequisites)
65
+ })
66
+ except EntityNotFoundError as exc:
67
+ logger.warning("Prerequisites query failed - entity not found: %s", exc)
68
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
69
+ except Exception as exc:
70
+ logger.error("Prerequisites query failed: %s", exc)
71
+ raise HTTPException(status_code=500, detail="Internal server error") from exc
72
+
73
+
74
+ @router.get(
75
+ "/{lo_id}/successors",
76
+ summary="Get successors for a learning outcome",
77
+ description="Get all successor learning outcomes for a given LO"
78
+ )
79
+ async def get_successors(
80
+ lo_id: str,
81
+ max_depth: int = Query(default=None, ge=1, le=10, description="Maximum depth to traverse"),
82
+ knowledge_graph_service: KnowledgeGraphService = Depends(get_knowledge_graph_service)
83
+ ) -> JSONResponse:
84
+ """Get successors for a learning outcome."""
85
+ try:
86
+ successors = knowledge_graph_service.get_successors(lo_id, max_depth)
87
+ return JSONResponse(content={
88
+ "lo_id": lo_id,
89
+ "successors": successors,
90
+ "count": len(successors)
91
+ })
92
+ except EntityNotFoundError as exc:
93
+ logger.warning("Successors query failed - entity not found: %s", exc)
94
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
95
+ except Exception as exc:
96
+ logger.error("Successors query failed: %s", exc)
97
+ raise HTTPException(status_code=500, detail="Internal server error") from exc
98
+
99
+
100
+ @router.get(
101
+ "/{lo_id}",
102
+ response_model=KnowledgeGraphResponse,
103
+ summary="Get knowledge graph for a learning outcome",
104
+ description="Retrieve full knowledge graph info including prerequisites and successors for a learning outcome"
105
+ )
106
+ async def get_knowledge_graph(
107
+ lo_id: str,
108
+ max_depth: int = Query(default=2, ge=1, le=5, description="Maximum depth to traverse"),
109
+ knowledge_graph_service: KnowledgeGraphService = Depends(get_knowledge_graph_service)
110
+ ) -> KnowledgeGraphResponse:
111
+ """Get knowledge graph information for a learning outcome."""
112
+ try:
113
+ return knowledge_graph_service.get_knowledge_graph(lo_id, max_depth)
114
+ except EntityNotFoundError as exc:
115
+ logger.warning("Knowledge graph query failed - entity not found: %s", exc)
116
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
117
+ except DatasetError as exc:
118
+ logger.error("Knowledge graph query failed - dataset error: %s", exc)
119
+ raise HTTPException(status_code=503, detail="Knowledge graph service unavailable") from exc
120
+ except Exception as exc:
121
+ logger.error("Knowledge graph query failed - unexpected error: %s", exc)
122
+ raise HTTPException(status_code=500, detail="Internal server error") from exc
app/api/v2/learning_path.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Adaptive learning path API endpoints."""
2
+
3
+ import logging
4
+ from fastapi import APIRouter, Depends, HTTPException, Query
5
+
6
+ from app.core.exceptions import EntityNotFoundError, DatasetError
7
+ from app.schemas.learning_path import LearningPathRequest, LearningPathResponse
8
+ from app.services.adaptive_learning_path_service import AdaptiveLearningPathService
9
+ from app.api.v2.dependencies import get_adaptive_learning_path_service
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ router = APIRouter(prefix="/ai/v2/learning-path", tags=["learning-path"])
14
+
15
+
16
+ @router.get(
17
+ "/{student_id}",
18
+ response_model=LearningPathResponse,
19
+ summary="Generate adaptive learning path",
20
+ description="Generate a personalized learning path for a student to reach a target learning outcome"
21
+ )
22
+ async def generate_learning_path(
23
+ student_id: str,
24
+ target_lo_id: str = Query(..., description="Target learning outcome ID"),
25
+ max_steps: int = Query(default=10, ge=1, le=20, description="Maximum number of steps in path"),
26
+ include_mastered: bool = Query(default=False, description="Include already mastered LOs for review"),
27
+ difficulty_preference: str = Query(default="adaptive", description="Difficulty preference: easy, medium, hard, adaptive"),
28
+ learning_path_service: AdaptiveLearningPathService = Depends(get_adaptive_learning_path_service)
29
+ ) -> LearningPathResponse:
30
+ """Generate an adaptive learning path for a student."""
31
+ try:
32
+ request = LearningPathRequest(
33
+ student_id=student_id,
34
+ target_lo_id=target_lo_id,
35
+ max_steps=max_steps,
36
+ include_mastered=include_mastered,
37
+ difficulty_preference=difficulty_preference
38
+ )
39
+
40
+ return learning_path_service.generate_learning_path(request)
41
+
42
+ except EntityNotFoundError as exc:
43
+ logger.warning("Learning path generation failed - entity not found: %s", exc)
44
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
45
+ except DatasetError as exc:
46
+ logger.error("Learning path generation failed - dataset error: %s", exc)
47
+ raise HTTPException(status_code=503, detail="Learning path service unavailable") from exc
48
+ except Exception as exc:
49
+ logger.error("Learning path generation failed - unexpected error: %s", exc)
50
+ raise HTTPException(status_code=500, detail="Internal server error") from exc
51
+
52
+
53
+ @router.post(
54
+ "/",
55
+ response_model=LearningPathResponse,
56
+ summary="Generate adaptive learning path (POST)",
57
+ description="Generate a personalized learning path using POST request body"
58
+ )
59
+ async def generate_learning_path_post(
60
+ request: LearningPathRequest,
61
+ learning_path_service: AdaptiveLearningPathService = Depends(get_adaptive_learning_path_service)
62
+ ) -> LearningPathResponse:
63
+ """Generate an adaptive learning path for a student using POST."""
64
+ try:
65
+ return learning_path_service.generate_learning_path(request)
66
+
67
+ except EntityNotFoundError as exc:
68
+ logger.warning("Learning path generation failed - entity not found: %s", exc)
69
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
70
+ except DatasetError as exc:
71
+ logger.error("Learning path generation failed - dataset error: %s", exc)
72
+ raise HTTPException(status_code=503, detail="Learning path service unavailable") from exc
73
+ except Exception as exc:
74
+ logger.error("Learning path generation failed - unexpected error: %s", exc)
75
+ raise HTTPException(status_code=500, detail="Internal server error") from exc
app/main.py CHANGED
@@ -18,6 +18,8 @@ from app.api.v2.bloom import router as bloom_router
18
  from app.api.v2.class_insights import router as class_insights_router
19
  from app.api.v2.dependencies import ServiceContainer
20
  from app.api.v2.health import router as health_router
 
 
21
  from app.api.v2.lo_tagging import router as lo_tagging_router
22
  from app.api.v2.mastery import router as mastery_router
23
  from app.api.v2.monitoring import router as monitoring_router
@@ -31,9 +33,11 @@ from app.data.loader import DatasetLoader
31
  from app.models.registry import ModelRegistry
32
  from app.monitoring.prediction_logger import PredictionLogger
33
  from app.services.answer_evaluation_service import AnswerEvaluationService
 
34
  from app.services.bloom_service import BloomService
35
  from app.services.class_insights_service import ClassInsightsService
36
  from app.services.explanation_service import ExplanationService
 
37
  from app.services.lo_tagging_service import LOTaggingService
38
  from app.services.mastery_service import MasteryService
39
  from app.services.monitoring_service import MonitoringService
@@ -96,6 +100,7 @@ async def lifespan(app: FastAPI):
96
  )
97
 
98
  # 4. Initialize service container with DI
 
99
  app.state.services = ServiceContainer(
100
  registry=registry,
101
  loader=loader,
@@ -111,6 +116,8 @@ async def lifespan(app: FastAPI):
111
  class_insights=ClassInsightsService(loader),
112
  teacher_feedback=TeacherFeedbackService(),
113
  monitoring=MonitoringService(registry, pred_logger),
 
 
114
  )
115
 
116
  logger.info("Service container initialized — model registry and shared components ready")
@@ -145,6 +152,8 @@ app.include_router(student_profile_router)
145
  app.include_router(class_insights_router)
146
  app.include_router(teacher_feedback_router)
147
  app.include_router(monitoring_router)
 
 
148
 
149
 
150
  # --- Exception Handlers ---
 
18
  from app.api.v2.class_insights import router as class_insights_router
19
  from app.api.v2.dependencies import ServiceContainer
20
  from app.api.v2.health import router as health_router
21
+ from app.api.v2.knowledge_graph import router as knowledge_graph_router
22
+ from app.api.v2.learning_path import router as learning_path_router
23
  from app.api.v2.lo_tagging import router as lo_tagging_router
24
  from app.api.v2.mastery import router as mastery_router
25
  from app.api.v2.monitoring import router as monitoring_router
 
33
  from app.models.registry import ModelRegistry
34
  from app.monitoring.prediction_logger import PredictionLogger
35
  from app.services.answer_evaluation_service import AnswerEvaluationService
36
+ from app.services.adaptive_learning_path_service import AdaptiveLearningPathService
37
  from app.services.bloom_service import BloomService
38
  from app.services.class_insights_service import ClassInsightsService
39
  from app.services.explanation_service import ExplanationService
40
+ from app.services.knowledge_graph_service import KnowledgeGraphService
41
  from app.services.lo_tagging_service import LOTaggingService
42
  from app.services.mastery_service import MasteryService
43
  from app.services.monitoring_service import MonitoringService
 
100
  )
101
 
102
  # 4. Initialize service container with DI
103
+ knowledge_graph_service = KnowledgeGraphService(loader)
104
  app.state.services = ServiceContainer(
105
  registry=registry,
106
  loader=loader,
 
116
  class_insights=ClassInsightsService(loader),
117
  teacher_feedback=TeacherFeedbackService(),
118
  monitoring=MonitoringService(registry, pred_logger),
119
+ knowledge_graph=knowledge_graph_service,
120
+ adaptive_learning_path=AdaptiveLearningPathService(loader, knowledge_graph_service),
121
  )
122
 
123
  logger.info("Service container initialized — model registry and shared components ready")
 
152
  app.include_router(class_insights_router)
153
  app.include_router(teacher_feedback_router)
154
  app.include_router(monitoring_router)
155
+ app.include_router(knowledge_graph_router)
156
+ app.include_router(learning_path_router)
157
 
158
 
159
  # --- Exception Handlers ---
app/schemas/knowledge_graph.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic schemas for knowledge graph endpoints."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class LONode(BaseModel):
7
+ """A learning outcome node in the knowledge graph."""
8
+
9
+ lo_id: str = Field(..., description="Learning outcome ID")
10
+ title: str = Field(..., description="Learning outcome title")
11
+ grade: int = Field(..., ge=6, le=8, description="Grade level")
12
+ subject: str = Field(..., description="Subject name")
13
+ chapter: str = Field(..., description="Chapter name")
14
+ difficulty: str = Field(..., description="Difficulty level")
15
+ bloom_level: str = Field(..., description="Bloom taxonomy level")
16
+
17
+
18
+ class LORelationship(BaseModel):
19
+ """A relationship between learning outcomes."""
20
+
21
+ from_lo_id: str = Field(..., description="Source learning outcome ID")
22
+ to_lo_id: str = Field(..., description="Target learning outcome ID")
23
+ relationship_type: str = Field(..., description="Type of relationship")
24
+ strength: float = Field(..., ge=0.0, le=1.0, description="Relationship strength")
25
+
26
+
27
+ class KnowledgeGraphResponse(BaseModel):
28
+ """Response for knowledge graph queries."""
29
+
30
+ model_config = ConfigDict(extra="forbid")
31
+
32
+ lo_id: str = Field(..., description="Queried learning outcome ID")
33
+ source: str = Field(default="knowledge_graph", description="Data source")
34
+ timestamp: str = Field(..., description="Response timestamp")
35
+
36
+ # Core LO information
37
+ lo_info: LONode = Field(..., description="Learning outcome details")
38
+
39
+ # Graph relationships
40
+ prerequisites: list[LONode] = Field(default_factory=list, description="Prerequisite learning outcomes")
41
+ successors: list[LONode] = Field(default_factory=list, description="Dependent learning outcomes")
42
+
43
+ # Metadata
44
+ prerequisite_count: int = Field(..., ge=0, description="Number of prerequisites")
45
+ successor_count: int = Field(..., ge=0, description="Number of successors")
46
+ depth_from_root: int = Field(..., ge=0, description="Depth in dependency chain")
47
+
48
+
49
+ class KnowledgeGraphRequest(BaseModel):
50
+ """Request for knowledge graph queries."""
51
+
52
+ model_config = ConfigDict(extra="forbid")
53
+
54
+ lo_id: str = Field(..., description="Learning outcome ID to query")
55
+ include_prerequisites: bool = Field(default=True, description="Include prerequisite LOs")
56
+ include_successors: bool = Field(default=True, description="Include successor LOs")
57
+ max_depth: int = Field(default=2, ge=1, le=5, description="Maximum depth to traverse")
app/schemas/learning_path.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic schemas for adaptive learning path endpoints."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class LearningPathStep(BaseModel):
7
+ """A single step in a learning path."""
8
+
9
+ step_number: int = Field(..., ge=1, description="Step order in the path")
10
+ lo_id: str = Field(..., description="Learning outcome ID")
11
+ title: str = Field(..., description="Learning outcome title")
12
+ grade: int = Field(..., ge=6, le=8, description="Grade level")
13
+ subject: str = Field(..., description="Subject name")
14
+ chapter: str = Field(..., description="Chapter name")
15
+ difficulty: str = Field(..., description="Difficulty level")
16
+ bloom_level: str = Field(..., description="Bloom taxonomy level")
17
+
18
+ # Student-specific context
19
+ current_mastery: float = Field(..., ge=0.0, le=1.0, description="Student's current mastery score")
20
+ mastery_label: str = Field(..., description="Student's mastery label")
21
+ is_prerequisite: bool = Field(..., description="Whether this is a prerequisite for the target")
22
+ estimated_study_time: int = Field(..., ge=0, description="Estimated study time in minutes")
23
+
24
+ # Reasoning
25
+ reason: str = Field(..., description="Why this step is recommended")
26
+
27
+
28
+ class LearningPathRequest(BaseModel):
29
+ """Request for adaptive learning path generation."""
30
+
31
+ model_config = ConfigDict(extra="forbid")
32
+
33
+ student_id: str = Field(..., description="Student ID")
34
+ target_lo_id: str = Field(..., description="Target learning outcome ID")
35
+ max_steps: int = Field(default=10, ge=1, le=20, description="Maximum number of steps in path")
36
+ include_mastered: bool = Field(default=False, description="Include already mastered LOs for review")
37
+ difficulty_preference: str = Field(default="adaptive", description="Difficulty preference: easy, medium, hard, adaptive")
38
+
39
+
40
+ class LearningPathResponse(BaseModel):
41
+ """Response for adaptive learning path generation."""
42
+
43
+ model_config = ConfigDict(extra="forbid")
44
+
45
+ student_id: str = Field(..., description="Student ID")
46
+ target_lo_id: str = Field(..., description="Target learning outcome ID")
47
+ model_version: str = Field(default="adaptive_path_v2_baseline_001", description="Service version")
48
+ source: str = Field(default="knowledge_graph_traversal", description="Path generation method")
49
+ timestamp: str = Field(..., description="Response timestamp")
50
+
51
+ # Path details
52
+ learning_path: list[LearningPathStep] = Field(..., description="Ordered learning steps")
53
+ total_steps: int = Field(..., ge=0, description="Total number of steps")
54
+ estimated_total_time: int = Field(..., ge=0, description="Total estimated study time in minutes")
55
+
56
+ # Student context
57
+ current_overall_mastery: float = Field(..., ge=0.0, le=1.0, description="Student's overall mastery")
58
+ weak_prerequisites: list[str] = Field(default_factory=list, description="Weak prerequisite LO IDs")
59
+
60
+ # Path metadata
61
+ path_difficulty: str = Field(..., description="Overall path difficulty")
62
+ completion_probability: float = Field(..., ge=0.0, le=1.0, description="Estimated completion probability")
63
+
64
+ # Recommendations
65
+ next_action: str = Field(..., description="Immediate next action for student")
66
+ teacher_notes: str = Field(..., description="Notes for teacher intervention")
app/services/adaptive_learning_path_service.py ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Adaptive learning path service.
2
+
3
+ Generates personalized learning sequences for students based on their
4
+ current mastery profile, target learning outcomes, and knowledge graph
5
+ dependencies. Uses rule-based path optimization with prerequisite analysis.
6
+ """
7
+
8
+ import logging
9
+ from datetime import datetime, timezone
10
+ from typing import Dict, List, Tuple
11
+
12
+ import pandas as pd
13
+
14
+ from app.core.exceptions import EntityNotFoundError, DatasetError
15
+ from app.data.loader import DatasetLoader
16
+ from app.services.knowledge_graph_service import KnowledgeGraphService
17
+ from app.schemas.learning_path import (
18
+ LearningPathRequest,
19
+ LearningPathResponse,
20
+ LearningPathStep
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class AdaptiveLearningPathService:
27
+ """Generates adaptive learning paths for students.
28
+
29
+ Uses knowledge graph traversal combined with student mastery profiles
30
+ to create personalized learning sequences. Prioritizes weak prerequisites
31
+ and respects dependency chains while considering difficulty progression.
32
+ """
33
+
34
+ def __init__(self, loader: DatasetLoader, knowledge_graph: KnowledgeGraphService) -> None:
35
+ self._loader = loader
36
+ self._knowledge_graph = knowledge_graph
37
+
38
+ # Difficulty to study time mapping (minutes)
39
+ self._difficulty_time_map = {
40
+ "easy": 15,
41
+ "medium": 25,
42
+ "hard": 40
43
+ }
44
+
45
+ # Mastery thresholds
46
+ self._mastery_thresholds = {
47
+ "weak": 0.4,
48
+ "developing": 0.6,
49
+ "proficient": 0.8
50
+ }
51
+
52
+ def generate_learning_path(self, request: LearningPathRequest) -> LearningPathResponse:
53
+ """Generate an adaptive learning path for a student.
54
+
55
+ Algorithm:
56
+ 1. Load student's current mastery profile
57
+ 2. Identify prerequisites for target LO that are weak/missing
58
+ 3. Build ordered learning sequence respecting dependencies
59
+ 4. Add difficulty progression and time estimates
60
+ 5. Generate completion probability and recommendations
61
+
62
+ Args:
63
+ request: Learning path generation request
64
+
65
+ Returns:
66
+ LearningPathResponse with ordered learning steps
67
+
68
+ Raises:
69
+ EntityNotFoundError: If student or target LO not found
70
+ """
71
+ timestamp = datetime.now(timezone.utc).isoformat()
72
+
73
+ # 1. Validate and load student mastery profile
74
+ student_mastery = self._load_student_mastery(request.student_id)
75
+
76
+ # 2. Validate target LO exists
77
+ target_lo_info = self._get_lo_info(request.target_lo_id)
78
+
79
+ # 3. Get all prerequisites for target LO
80
+ all_prerequisites = self._knowledge_graph.get_prerequisites(
81
+ request.target_lo_id,
82
+ max_depth=None
83
+ )
84
+
85
+ # 4. Identify weak prerequisites that need attention
86
+ weak_prerequisites = self._identify_weak_prerequisites(
87
+ all_prerequisites,
88
+ student_mastery,
89
+ request.include_mastered
90
+ )
91
+
92
+ # 5. Build learning path with dependency ordering
93
+ learning_steps = self._build_learning_path(
94
+ weak_prerequisites,
95
+ request.target_lo_id,
96
+ student_mastery,
97
+ request.max_steps,
98
+ request.difficulty_preference
99
+ )
100
+
101
+ # 6. Calculate path metadata
102
+ total_time = sum(step.estimated_study_time for step in learning_steps)
103
+ overall_mastery = self._calculate_overall_mastery(student_mastery)
104
+ path_difficulty = self._determine_path_difficulty(learning_steps)
105
+ completion_probability = self._estimate_completion_probability(
106
+ learning_steps,
107
+ overall_mastery
108
+ )
109
+
110
+ # 7. Generate recommendations
111
+ next_action, teacher_notes = self._generate_recommendations(
112
+ learning_steps,
113
+ weak_prerequisites,
114
+ overall_mastery
115
+ )
116
+
117
+ return LearningPathResponse(
118
+ student_id=request.student_id,
119
+ target_lo_id=request.target_lo_id,
120
+ timestamp=timestamp,
121
+ learning_path=learning_steps,
122
+ total_steps=len(learning_steps),
123
+ estimated_total_time=total_time,
124
+ current_overall_mastery=overall_mastery,
125
+ weak_prerequisites=weak_prerequisites,
126
+ path_difficulty=path_difficulty,
127
+ completion_probability=completion_probability,
128
+ next_action=next_action,
129
+ teacher_notes=teacher_notes
130
+ )
131
+
132
+ def _load_student_mastery(self, student_id: str) -> Dict[str, float]:
133
+ """Load student's mastery profile as a dictionary.
134
+
135
+ Returns:
136
+ Dict mapping lo_id to mastery_score
137
+ """
138
+ try:
139
+ # Validate student exists
140
+ student_profiles = self._loader.load_table("student_profiles")
141
+ if student_id not in student_profiles["student_id"].values:
142
+ raise EntityNotFoundError(f"Student '{student_id}' not found")
143
+
144
+ # Load mastery profiles
145
+ mastery_profiles = self._loader.load_table("mastery_profiles")
146
+ student_mastery = mastery_profiles[
147
+ mastery_profiles["student_id"] == student_id
148
+ ]
149
+
150
+ # Convert to dictionary
151
+ mastery_dict = {}
152
+ for _, row in student_mastery.iterrows():
153
+ lo_id = str(row["lo_id"])
154
+ mastery_score = float(row.get("mastery_score", 0.0))
155
+ mastery_dict[lo_id] = max(0.0, min(1.0, mastery_score))
156
+
157
+ return mastery_dict
158
+
159
+ except Exception as exc:
160
+ logger.error("Failed to load student mastery for %s: %s", student_id, exc)
161
+ raise EntityNotFoundError(f"Unable to load mastery profile for student '{student_id}'") from exc
162
+
163
+ def _get_lo_info(self, lo_id: str) -> Dict:
164
+ """Get learning outcome metadata."""
165
+ try:
166
+ learning_outcomes = self._loader.load_table("learning_outcomes")
167
+ lo_row = learning_outcomes[learning_outcomes["lo_id"] == lo_id]
168
+
169
+ if lo_row.empty:
170
+ raise EntityNotFoundError(f"Learning outcome '{lo_id}' not found")
171
+
172
+ return lo_row.iloc[0].to_dict()
173
+
174
+ except Exception as exc:
175
+ logger.error("Failed to get LO info for %s: %s", lo_id, exc)
176
+ raise EntityNotFoundError(f"Learning outcome '{lo_id}' not found") from exc
177
+
178
+ def _identify_weak_prerequisites(
179
+ self,
180
+ prerequisites: List[str],
181
+ student_mastery: Dict[str, float],
182
+ include_mastered: bool
183
+ ) -> List[str]:
184
+ """Identify prerequisites that need attention based on mastery scores."""
185
+ weak_prerequisites = []
186
+
187
+ for lo_id in prerequisites:
188
+ mastery_score = student_mastery.get(lo_id, 0.0)
189
+
190
+ # Include if weak/developing or if explicitly requested
191
+ if mastery_score < self._mastery_thresholds["proficient"]:
192
+ weak_prerequisites.append(lo_id)
193
+ elif include_mastered and mastery_score >= self._mastery_thresholds["proficient"]:
194
+ weak_prerequisites.append(lo_id)
195
+
196
+ return weak_prerequisites
197
+
198
+ def _build_learning_path(
199
+ self,
200
+ weak_prerequisites: List[str],
201
+ target_lo_id: str,
202
+ student_mastery: Dict[str, float],
203
+ max_steps: int,
204
+ difficulty_preference: str
205
+ ) -> List[LearningPathStep]:
206
+ """Build ordered learning path respecting dependencies."""
207
+ learning_steps = []
208
+
209
+ # Add weak prerequisites in dependency order
210
+ ordered_prerequisites = self._order_by_dependencies(weak_prerequisites)
211
+
212
+ step_number = 1
213
+ for lo_id in ordered_prerequisites[:max_steps-1]: # Reserve one step for target
214
+ step = self._create_learning_step(
215
+ step_number,
216
+ lo_id,
217
+ student_mastery,
218
+ is_prerequisite=True,
219
+ difficulty_preference=difficulty_preference
220
+ )
221
+ learning_steps.append(step)
222
+ step_number += 1
223
+
224
+ # Add target LO as final step if there's room
225
+ if step_number <= max_steps:
226
+ target_step = self._create_learning_step(
227
+ step_number,
228
+ target_lo_id,
229
+ student_mastery,
230
+ is_prerequisite=False,
231
+ difficulty_preference=difficulty_preference
232
+ )
233
+ learning_steps.append(target_step)
234
+
235
+ return learning_steps
236
+
237
+ def _order_by_dependencies(self, lo_ids: List[str]) -> List[str]:
238
+ """Order LOs by dependency chain (prerequisites first)."""
239
+ ordered = []
240
+ remaining = set(lo_ids)
241
+
242
+ while remaining:
243
+ # Find LOs with no remaining prerequisites
244
+ ready_los = []
245
+ for lo_id in remaining:
246
+ prerequisites = self._knowledge_graph.get_prerequisites(lo_id, max_depth=1)
247
+ if not any(prereq in remaining for prereq in prerequisites):
248
+ ready_los.append(lo_id)
249
+
250
+ if not ready_los:
251
+ # Break cycles by taking the first remaining LO
252
+ ready_los = [next(iter(remaining))]
253
+
254
+ # Sort by difficulty (easier first) and add to ordered list
255
+ ready_los.sort(key=lambda lo: self._get_difficulty_score(lo))
256
+ ordered.extend(ready_los)
257
+ remaining -= set(ready_los)
258
+
259
+ return ordered
260
+
261
+ def _create_learning_step(
262
+ self,
263
+ step_number: int,
264
+ lo_id: str,
265
+ student_mastery: Dict[str, float],
266
+ is_prerequisite: bool,
267
+ difficulty_preference: str
268
+ ) -> LearningPathStep:
269
+ """Create a learning path step with metadata."""
270
+ lo_info = self._get_lo_info(lo_id)
271
+ mastery_score = student_mastery.get(lo_id, 0.0)
272
+ mastery_label = self._get_mastery_label(mastery_score)
273
+
274
+ # Estimate study time based on difficulty and current mastery
275
+ difficulty = str(lo_info.get("difficulty", "medium")).lower()
276
+ base_time = self._difficulty_time_map.get(difficulty, 25)
277
+
278
+ # Adjust time based on mastery (less time if already partially mastered)
279
+ time_multiplier = max(0.3, 1.0 - mastery_score)
280
+ estimated_time = int(base_time * time_multiplier)
281
+
282
+ # Generate reason for inclusion
283
+ if is_prerequisite:
284
+ if mastery_score < self._mastery_thresholds["weak"]:
285
+ reason = f"Weak prerequisite (mastery: {mastery_score:.1%}) - needs foundational work"
286
+ elif mastery_score < self._mastery_thresholds["developing"]:
287
+ reason = f"Developing prerequisite (mastery: {mastery_score:.1%}) - needs reinforcement"
288
+ else:
289
+ reason = f"Prerequisite review (mastery: {mastery_score:.1%}) - ensure solid foundation"
290
+ else:
291
+ reason = f"Target learning outcome - current mastery: {mastery_score:.1%}"
292
+
293
+ return LearningPathStep(
294
+ step_number=step_number,
295
+ lo_id=lo_id,
296
+ title=str(lo_info.get("title", "")),
297
+ grade=int(lo_info.get("grade", 6)),
298
+ subject=str(lo_info.get("subject", "")),
299
+ chapter=str(lo_info.get("chapter", "")),
300
+ difficulty=difficulty,
301
+ bloom_level=str(lo_info.get("bloom_level", "Understand")),
302
+ current_mastery=mastery_score,
303
+ mastery_label=mastery_label,
304
+ is_prerequisite=is_prerequisite,
305
+ estimated_study_time=estimated_time,
306
+ reason=reason
307
+ )
308
+
309
+ def _get_difficulty_score(self, lo_id: str) -> int:
310
+ """Get numeric difficulty score for sorting (easier first)."""
311
+ try:
312
+ lo_info = self._get_lo_info(lo_id)
313
+ difficulty = str(lo_info.get("difficulty", "medium")).lower()
314
+ return {"easy": 1, "medium": 2, "hard": 3}.get(difficulty, 2)
315
+ except Exception:
316
+ return 2
317
+
318
+ def _get_mastery_label(self, mastery_score: float) -> str:
319
+ """Convert mastery score to label."""
320
+ if mastery_score < self._mastery_thresholds["weak"]:
321
+ return "weak"
322
+ elif mastery_score < self._mastery_thresholds["developing"]:
323
+ return "developing"
324
+ elif mastery_score < self._mastery_thresholds["proficient"]:
325
+ return "proficient"
326
+ else:
327
+ return "mastered"
328
+
329
+ def _calculate_overall_mastery(self, student_mastery: Dict[str, float]) -> float:
330
+ """Calculate student's overall mastery score."""
331
+ if not student_mastery:
332
+ return 0.0
333
+ return sum(student_mastery.values()) / len(student_mastery)
334
+
335
+ def _determine_path_difficulty(self, learning_steps: List[LearningPathStep]) -> str:
336
+ """Determine overall path difficulty."""
337
+ if not learning_steps:
338
+ return "easy"
339
+
340
+ difficulty_counts = {"easy": 0, "medium": 0, "hard": 0}
341
+ for step in learning_steps:
342
+ difficulty_counts[step.difficulty] += 1
343
+
344
+ # Return the most common difficulty
345
+ return max(difficulty_counts, key=difficulty_counts.get)
346
+
347
+ def _estimate_completion_probability(
348
+ self,
349
+ learning_steps: List[LearningPathStep],
350
+ overall_mastery: float
351
+ ) -> float:
352
+ """Estimate probability of path completion."""
353
+ if not learning_steps:
354
+ return 1.0
355
+
356
+ # Base probability from overall mastery
357
+ base_prob = 0.3 + (overall_mastery * 0.5)
358
+
359
+ # Adjust for path length (longer paths are harder to complete)
360
+ length_penalty = max(0.1, 1.0 - (len(learning_steps) * 0.05))
361
+
362
+ # Adjust for difficulty distribution
363
+ hard_steps = sum(1 for step in learning_steps if step.difficulty == "hard")
364
+ difficulty_penalty = max(0.1, 1.0 - (hard_steps * 0.1))
365
+
366
+ completion_prob = base_prob * length_penalty * difficulty_penalty
367
+ return max(0.1, min(0.95, completion_prob))
368
+
369
+ def _generate_recommendations(
370
+ self,
371
+ learning_steps: List[LearningPathStep],
372
+ weak_prerequisites: List[str],
373
+ overall_mastery: float
374
+ ) -> Tuple[str, str]:
375
+ """Generate next action and teacher notes."""
376
+ if not learning_steps:
377
+ next_action = "No learning path needed - target already mastered"
378
+ teacher_notes = "Student has strong mastery of target and prerequisites"
379
+ return next_action, teacher_notes
380
+
381
+ first_step = learning_steps[0]
382
+
383
+ # Next action
384
+ if first_step.current_mastery < self._mastery_thresholds["weak"]:
385
+ next_action = f"Start with foundational work on {first_step.title}"
386
+ else:
387
+ next_action = f"Begin with {first_step.title} ({first_step.estimated_study_time} min)"
388
+
389
+ # Teacher notes
390
+ weak_count = len(weak_prerequisites)
391
+ if weak_count == 0:
392
+ teacher_notes = "Student is ready for target LO with minimal prerequisite work"
393
+ elif weak_count <= 3:
394
+ teacher_notes = f"Student needs work on {weak_count} prerequisites before target LO"
395
+ else:
396
+ teacher_notes = f"Student has {weak_count} weak prerequisites - consider breaking into smaller goals"
397
+
398
+ if overall_mastery < 0.4:
399
+ teacher_notes += ". Consider additional support or scaffolding."
400
+
401
+ return next_action, teacher_notes
app/services/knowledge_graph_service.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Knowledge graph service for learning outcome dependencies.
2
+
3
+ Builds and maintains a directed acyclic graph (DAG) of learning outcome
4
+ dependencies from lo_dependencies.csv. Provides graph traversal methods
5
+ for prerequisite chains, successor paths, and dependency analysis.
6
+ """
7
+
8
+ import logging
9
+ from datetime import datetime, timezone
10
+ from typing import Dict, List
11
+
12
+ import networkx as nx
13
+ import pandas as pd
14
+
15
+ from app.core.exceptions import EntityNotFoundError, DatasetError
16
+ from app.data.loader import DatasetLoader
17
+ from app.schemas.knowledge_graph import (
18
+ KnowledgeGraphResponse,
19
+ LONode,
20
+ LORelationship
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class KnowledgeGraphService:
27
+ """Manages learning outcome dependency graph and provides traversal methods.
28
+
29
+ Loads lo_dependencies.csv and learning_outcomes.csv to build a NetworkX
30
+ directed graph. Provides methods for finding prerequisites, successors,
31
+ learning paths, and dependency analysis.
32
+ """
33
+
34
+ def __init__(self, loader: DatasetLoader) -> None:
35
+ self._loader = loader
36
+ self._graph: nx.DiGraph | None = None
37
+ self._lo_metadata: Dict[str, Dict] = {}
38
+ self._build_graph()
39
+
40
+ def _build_graph(self) -> None:
41
+ """Build the knowledge graph from dataset tables."""
42
+ try:
43
+ # Load learning outcomes for metadata
44
+ learning_outcomes = self._loader.load_table("learning_outcomes")
45
+ self._lo_metadata = {
46
+ row["lo_id"]: {
47
+ "title": str(row.get("title", "")),
48
+ "grade": int(row.get("grade", 6)),
49
+ "subject": str(row.get("subject", "")),
50
+ "chapter": str(row.get("chapter", "")),
51
+ "difficulty": str(row.get("difficulty", "Medium")).lower(),
52
+ "bloom_level": str(row.get("bloom_level", "Understand"))
53
+ }
54
+ for _, row in learning_outcomes.iterrows()
55
+ }
56
+
57
+ # Load dependencies and build graph
58
+ dependencies = self._loader.load_table("lo_dependencies")
59
+ self._graph = nx.DiGraph()
60
+
61
+ # Add all LO nodes first
62
+ for lo_id in self._lo_metadata.keys():
63
+ self._graph.add_node(lo_id)
64
+
65
+ # Strength column is a string category in this dataset — map to float
66
+ _strength_map = {"weak": 0.33, "medium": 0.66, "strong": 1.0}
67
+
68
+ # Add dependency edges (prerequisite -> dependent)
69
+ for _, row in dependencies.iterrows():
70
+ prerequisite_lo = str(row["prerequisite_lo_id"])
71
+ dependent_lo = str(row["lo_id"])
72
+ relationship_type = str(row.get("relationship_type", "prerequisite"))
73
+ raw_strength = row.get("strength", "medium")
74
+ # Handle both numeric and string strength values
75
+ try:
76
+ strength = float(raw_strength)
77
+ except (ValueError, TypeError):
78
+ strength = _strength_map.get(str(raw_strength).lower(), 0.66)
79
+
80
+ if prerequisite_lo in self._lo_metadata and dependent_lo in self._lo_metadata:
81
+ self._graph.add_edge(
82
+ prerequisite_lo,
83
+ dependent_lo,
84
+ relationship_type=relationship_type,
85
+ strength=strength
86
+ )
87
+
88
+ logger.info(
89
+ "Built knowledge graph: %d nodes, %d edges",
90
+ self._graph.number_of_nodes(),
91
+ self._graph.number_of_edges()
92
+ )
93
+
94
+ except Exception as exc:
95
+ logger.error("Failed to build knowledge graph: %s", exc)
96
+ raise DatasetError(f"Knowledge graph construction failed: {exc}") from exc
97
+
98
+ def get_knowledge_graph(self, lo_id: str, max_depth: int = 2) -> KnowledgeGraphResponse:
99
+ """Get knowledge graph information for a learning outcome.
100
+
101
+ Args:
102
+ lo_id: Learning outcome ID to query
103
+ max_depth: Maximum depth to traverse for prerequisites/successors
104
+
105
+ Returns:
106
+ KnowledgeGraphResponse with LO info and relationships
107
+
108
+ Raises:
109
+ EntityNotFoundError: If lo_id is not found in the graph
110
+ """
111
+ if not self._graph or lo_id not in self._graph:
112
+ raise EntityNotFoundError(f"Learning outcome '{lo_id}' not found in knowledge graph")
113
+
114
+ timestamp = datetime.now(timezone.utc).isoformat()
115
+
116
+ # Get LO metadata
117
+ lo_info = self._create_lo_node(lo_id)
118
+
119
+ # Get prerequisites (nodes that point to this LO)
120
+ prerequisites = self._get_prerequisites(lo_id, max_depth)
121
+
122
+ # Get successors (nodes this LO points to)
123
+ successors = self._get_successors(lo_id, max_depth)
124
+
125
+ # Calculate depth from root (nodes with no predecessors)
126
+ depth_from_root = self._calculate_depth_from_root(lo_id)
127
+
128
+ return KnowledgeGraphResponse(
129
+ lo_id=lo_id,
130
+ timestamp=timestamp,
131
+ lo_info=lo_info,
132
+ prerequisites=prerequisites,
133
+ successors=successors,
134
+ prerequisite_count=len(prerequisites),
135
+ successor_count=len(successors),
136
+ depth_from_root=depth_from_root
137
+ )
138
+
139
+ def get_prerequisites(self, lo_id: str, max_depth: int = None) -> List[str]:
140
+ """Get all prerequisite LO IDs for a given learning outcome.
141
+
142
+ Args:
143
+ lo_id: Learning outcome ID
144
+ max_depth: Maximum depth to traverse (None for all)
145
+
146
+ Returns:
147
+ List of prerequisite LO IDs in dependency order
148
+ """
149
+ if not self._graph or lo_id not in self._graph:
150
+ return []
151
+
152
+ prerequisites = []
153
+ visited = set()
154
+
155
+ def _traverse_prerequisites(current_lo: str, depth: int) -> None:
156
+ if max_depth is not None and depth >= max_depth:
157
+ return
158
+ if current_lo in visited:
159
+ return
160
+
161
+ visited.add(current_lo)
162
+
163
+ # Get direct prerequisites
164
+ for pred in self._graph.predecessors(current_lo):
165
+ if pred not in prerequisites:
166
+ prerequisites.append(pred)
167
+ _traverse_prerequisites(pred, depth + 1)
168
+
169
+ _traverse_prerequisites(lo_id, 0)
170
+ return prerequisites
171
+
172
+ def get_successors(self, lo_id: str, max_depth: int = None) -> List[str]:
173
+ """Get all successor LO IDs for a given learning outcome.
174
+
175
+ Args:
176
+ lo_id: Learning outcome ID
177
+ max_depth: Maximum depth to traverse (None for all)
178
+
179
+ Returns:
180
+ List of successor LO IDs
181
+ """
182
+ if not self._graph or lo_id not in self._graph:
183
+ return []
184
+
185
+ successors = []
186
+ visited = set()
187
+
188
+ def _traverse_successors(current_lo: str, depth: int) -> None:
189
+ if max_depth is not None and depth >= max_depth:
190
+ return
191
+ if current_lo in visited:
192
+ return
193
+
194
+ visited.add(current_lo)
195
+
196
+ # Get direct successors
197
+ for succ in self._graph.successors(current_lo):
198
+ if succ not in successors:
199
+ successors.append(succ)
200
+ _traverse_successors(succ, depth + 1)
201
+
202
+ _traverse_successors(lo_id, 0)
203
+ return successors
204
+
205
+ def get_learning_path(self, start_lo: str, target_lo: str) -> List[str]:
206
+ """Find the shortest learning path between two learning outcomes.
207
+
208
+ Args:
209
+ start_lo: Starting learning outcome ID
210
+ target_lo: Target learning outcome ID
211
+
212
+ Returns:
213
+ List of LO IDs representing the shortest path
214
+
215
+ Raises:
216
+ EntityNotFoundError: If either LO is not found or no path exists
217
+ """
218
+ if not self._graph:
219
+ raise DatasetError("Knowledge graph not initialized")
220
+
221
+ if start_lo not in self._graph:
222
+ raise EntityNotFoundError(f"Start LO '{start_lo}' not found in knowledge graph")
223
+
224
+ if target_lo not in self._graph:
225
+ raise EntityNotFoundError(f"Target LO '{target_lo}' not found in knowledge graph")
226
+
227
+ try:
228
+ # Find shortest path in the directed graph
229
+ path = nx.shortest_path(self._graph, start_lo, target_lo)
230
+ return path
231
+ except nx.NetworkXNoPath:
232
+ # No direct path - check if target is a prerequisite of start
233
+ try:
234
+ reverse_path = nx.shortest_path(self._graph, target_lo, start_lo)
235
+ # Return reverse path (target should be learned first)
236
+ return list(reversed(reverse_path))
237
+ except nx.NetworkXNoPath:
238
+ raise EntityNotFoundError(
239
+ f"No learning path found between '{start_lo}' and '{target_lo}'"
240
+ )
241
+
242
+ def is_prerequisite(self, prerequisite_lo: str, dependent_lo: str) -> bool:
243
+ """Check if one LO is a prerequisite of another.
244
+
245
+ Args:
246
+ prerequisite_lo: Potential prerequisite LO ID
247
+ dependent_lo: Dependent LO ID
248
+
249
+ Returns:
250
+ True if prerequisite_lo is a prerequisite of dependent_lo
251
+ """
252
+ if not self._graph:
253
+ return False
254
+
255
+ return nx.has_path(self._graph, prerequisite_lo, dependent_lo)
256
+
257
+ def get_root_los(self) -> List[str]:
258
+ """Get all root learning outcomes (no prerequisites).
259
+
260
+ Returns:
261
+ List of LO IDs that have no prerequisites
262
+ """
263
+ if not self._graph:
264
+ return []
265
+
266
+ return [node for node in self._graph.nodes() if self._graph.in_degree(node) == 0]
267
+
268
+ def get_leaf_los(self) -> List[str]:
269
+ """Get all leaf learning outcomes (no successors).
270
+
271
+ Returns:
272
+ List of LO IDs that have no successors
273
+ """
274
+ if not self._graph:
275
+ return []
276
+
277
+ return [node for node in self._graph.nodes() if self._graph.out_degree(node) == 0]
278
+
279
+ def _get_prerequisites(self, lo_id: str, max_depth: int) -> List[LONode]:
280
+ """Get prerequisite LO nodes with metadata."""
281
+ prerequisite_ids = self.get_prerequisites(lo_id, max_depth)
282
+ return [self._create_lo_node(lo_id) for lo_id in prerequisite_ids]
283
+
284
+ def _get_successors(self, lo_id: str, max_depth: int) -> List[LONode]:
285
+ """Get successor LO nodes with metadata."""
286
+ successor_ids = self.get_successors(lo_id, max_depth)
287
+ return [self._create_lo_node(lo_id) for lo_id in successor_ids]
288
+
289
+ def _create_lo_node(self, lo_id: str) -> LONode:
290
+ """Create an LONode from LO metadata."""
291
+ metadata = self._lo_metadata.get(lo_id, {})
292
+ return LONode(
293
+ lo_id=lo_id,
294
+ title=metadata.get("title", ""),
295
+ grade=metadata.get("grade", 6),
296
+ subject=metadata.get("subject", ""),
297
+ chapter=metadata.get("chapter", ""),
298
+ difficulty=metadata.get("difficulty", "medium"),
299
+ bloom_level=metadata.get("bloom_level", "Understand")
300
+ )
301
+
302
+ def _calculate_depth_from_root(self, lo_id: str) -> int:
303
+ """Calculate the depth of an LO from root nodes."""
304
+ if not self._graph:
305
+ return 0
306
+
307
+ # Find shortest path from any root to this LO
308
+ root_los = self.get_root_los()
309
+ min_depth = float('inf')
310
+
311
+ for root_lo in root_los:
312
+ try:
313
+ path = nx.shortest_path(self._graph, root_lo, lo_id)
314
+ min_depth = min(min_depth, len(path) - 1)
315
+ except nx.NetworkXNoPath:
316
+ continue
317
+
318
+ return int(min_depth) if min_depth != float('inf') else 0
requirements.txt CHANGED
@@ -15,6 +15,9 @@ pandas==2.2.3
15
  scikit-learn==1.6.1
16
  joblib==1.4.2
17
 
 
 
 
18
  # --- Environment ---
19
  python-dotenv==1.0.1
20
  huggingface-hub==0.17.0
 
15
  scikit-learn==1.6.1
16
  joblib==1.4.2
17
 
18
+ # --- Graph Processing ---
19
+ networkx==3.4.2
20
+
21
  # --- Environment ---
22
  python-dotenv==1.0.1
23
  huggingface-hub==0.17.0