Spaces:
Sleeping
Sleeping
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 +12 -0
- app/api/v2/knowledge_graph.py +122 -0
- app/api/v2/learning_path.py +75 -0
- app/main.py +9 -0
- app/schemas/knowledge_graph.py +57 -0
- app/schemas/learning_path.py +66 -0
- app/services/adaptive_learning_path_service.py +401 -0
- app/services/knowledge_graph_service.py +318 -0
- requirements.txt +3 -0
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
|