Spaces:
Sleeping
Sleeping
work-sejal commited on
Commit ·
70ea7be
1
Parent(s): 319fe8e
Deploy AI service with FastAPI
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +12 -0
- Dockerfile +13 -0
- app/__init__.py +1 -0
- app/api/__init__.py +1 -0
- app/api/v2/__init__.py +1 -0
- app/api/v2/answer_evaluation.py +36 -0
- app/api/v2/bloom.py +35 -0
- app/api/v2/class_insights.py +27 -0
- app/api/v2/dependencies.py +98 -0
- app/api/v2/health.py +55 -0
- app/api/v2/lo_tagging.py +36 -0
- app/api/v2/mastery.py +28 -0
- app/api/v2/monitoring.py +30 -0
- app/api/v2/recommendations.py +31 -0
- app/api/v2/risk.py +28 -0
- app/api/v2/student_profile.py +25 -0
- app/api/v2/teacher_feedback.py +38 -0
- app/app/__init__.py +1 -0
- app/app/api/__init__.py +1 -0
- app/app/api/v2/__init__.py +1 -0
- app/app/api/v2/answer_evaluation.py +36 -0
- app/app/api/v2/bloom.py +35 -0
- app/app/api/v2/class_insights.py +27 -0
- app/app/api/v2/dependencies.py +98 -0
- app/app/api/v2/health.py +55 -0
- app/app/api/v2/lo_tagging.py +36 -0
- app/app/api/v2/mastery.py +28 -0
- app/app/api/v2/monitoring.py +30 -0
- app/app/api/v2/recommendations.py +31 -0
- app/app/api/v2/risk.py +28 -0
- app/app/api/v2/student_profile.py +25 -0
- app/app/api/v2/teacher_feedback.py +38 -0
- app/app/core/__init__.py +1 -0
- app/app/core/config.py +29 -0
- app/app/core/constants.py +30 -0
- app/app/core/exceptions.py +26 -0
- app/app/data/__init__.py +1 -0
- app/app/data/loader.py +124 -0
- app/app/data/split_manager.py +67 -0
- app/app/data/validator.py +481 -0
- app/app/main.py +200 -0
- app/app/models/__init__.py +5 -0
- app/app/models/registry.py +156 -0
- app/app/monitoring/__init__.py +5 -0
- app/app/monitoring/prediction_logger.py +137 -0
- app/app/schemas/__init__.py +1 -0
- app/app/schemas/answer_evaluation.py +36 -0
- app/app/schemas/bloom.py +29 -0
- app/app/schemas/class_insights.py +34 -0
- app/app/schemas/health.py +43 -0
.gitignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.env
|
| 4 |
+
.env.local
|
| 5 |
+
*.log
|
| 6 |
+
.pytest_cache/
|
| 7 |
+
.vscode/
|
| 8 |
+
.idea/
|
| 9 |
+
*.swp
|
| 10 |
+
*.swo
|
| 11 |
+
*~
|
| 12 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
RUN apt-get update && apt-get install -y gcc g++ curl && rm -rf /var/lib/apt/lists/*
|
| 4 |
+
COPY requirements.txt .
|
| 5 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 6 |
+
COPY app/ ./app/
|
| 7 |
+
COPY training/ ./training/
|
| 8 |
+
RUN mkdir -p /data/artifacts/{models,feedback,reports,metrics}
|
| 9 |
+
ENV MODEL_ARTIFACT_DIR=/data/artifacts/models
|
| 10 |
+
ENV DATASET_DIR=/data/learning_outcome_os_dataset_v2
|
| 11 |
+
ENV LOG_LEVEL=INFO
|
| 12 |
+
EXPOSE 7860
|
| 13 |
+
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:7860", "--timeout", "120", "app.main:app"]
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# AI_Services_V2 app package
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# API package
|
app/api/v2/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# API v2 package
|
app/api/v2/answer_evaluation.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Answer evaluation router.
|
| 2 |
+
|
| 3 |
+
POST /ai/v2/evaluate-answer — scores a subjective answer and flags for teacher review.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends
|
| 7 |
+
|
| 8 |
+
from app.api.v2.dependencies import get_answer_evaluation_service
|
| 9 |
+
from app.schemas.answer_evaluation import AnswerEvaluationRequest, AnswerEvaluationResponse
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/ai/v2", tags=["inference"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.post(
|
| 15 |
+
"/evaluate-answer",
|
| 16 |
+
response_model=AnswerEvaluationResponse,
|
| 17 |
+
summary="Evaluate Answer",
|
| 18 |
+
description="Score a subjective answer and flag for teacher review.",
|
| 19 |
+
)
|
| 20 |
+
async def evaluate_answer(
|
| 21 |
+
request: AnswerEvaluationRequest,
|
| 22 |
+
service=Depends(get_answer_evaluation_service),
|
| 23 |
+
) -> AnswerEvaluationResponse:
|
| 24 |
+
"""Score a student's subjective answer against a rubric."""
|
| 25 |
+
return service.predict(
|
| 26 |
+
question_id=request.question_id,
|
| 27 |
+
question_text=request.question_text,
|
| 28 |
+
student_answer=request.student_answer,
|
| 29 |
+
model_answer=request.model_answer,
|
| 30 |
+
rubric=request.rubric,
|
| 31 |
+
max_marks=request.max_marks,
|
| 32 |
+
grade=request.grade,
|
| 33 |
+
subject=request.subject,
|
| 34 |
+
lo_id=request.lo_id,
|
| 35 |
+
bloom_level=request.bloom_level,
|
| 36 |
+
)
|
app/api/v2/bloom.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bloom classification API router.
|
| 2 |
+
|
| 3 |
+
POST /ai/v2/classify-bloom — classifies a question's cognitive level
|
| 4 |
+
according to Bloom's taxonomy.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends
|
| 8 |
+
|
| 9 |
+
from app.api.v2.dependencies import get_bloom_service
|
| 10 |
+
from app.schemas.bloom import BloomRequest, BloomResponse
|
| 11 |
+
from app.services.bloom_service import BloomService
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/ai/v2", tags=["inference"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.post(
|
| 17 |
+
"/classify-bloom",
|
| 18 |
+
response_model=BloomResponse,
|
| 19 |
+
summary="Classify Bloom Level",
|
| 20 |
+
description=(
|
| 21 |
+
"Classify a question's cognitive level according to Bloom's taxonomy. "
|
| 22 |
+
"Returns the predicted Bloom level with confidence and an explanation."
|
| 23 |
+
),
|
| 24 |
+
)
|
| 25 |
+
async def classify_bloom(
|
| 26 |
+
request: BloomRequest,
|
| 27 |
+
service: BloomService = Depends(get_bloom_service),
|
| 28 |
+
) -> BloomResponse:
|
| 29 |
+
"""Classify a question's Bloom taxonomy level."""
|
| 30 |
+
return service.predict(
|
| 31 |
+
question_text=request.question_text,
|
| 32 |
+
grade=request.grade,
|
| 33 |
+
subject=request.subject,
|
| 34 |
+
question_type=request.question_type,
|
| 35 |
+
)
|
app/api/v2/class_insights.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Class insights router.
|
| 2 |
+
|
| 3 |
+
GET /ai/v2/class-insights/{class_id} — returns class-level weak LOs,
|
| 4 |
+
at-risk counts, and recommended interventions.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends, Query
|
| 8 |
+
|
| 9 |
+
from app.api.v2.dependencies import get_class_insights_service
|
| 10 |
+
from app.schemas.class_insights import ClassInsightsResponse
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/ai/v2", tags=["inference"])
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@router.get(
|
| 16 |
+
"/class-insights/{class_id}",
|
| 17 |
+
response_model=ClassInsightsResponse,
|
| 18 |
+
summary="Get Class Insights",
|
| 19 |
+
description="Return class-level insights including weakest LOs, at-risk students, and interventions.",
|
| 20 |
+
)
|
| 21 |
+
async def get_class_insights(
|
| 22 |
+
class_id: str,
|
| 23 |
+
subject: str | None = Query(None, description="Optional subject filter"),
|
| 24 |
+
service=Depends(get_class_insights_service),
|
| 25 |
+
) -> ClassInsightsResponse:
|
| 26 |
+
"""Retrieve class-level insights for a teacher."""
|
| 27 |
+
return service.get_insights(class_id=class_id, subject=subject)
|
app/api/v2/dependencies.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI dependency providers for service instances.
|
| 2 |
+
|
| 3 |
+
Each provider retrieves the service from request.app.state.services (the
|
| 4 |
+
ServiceContainer attached during application lifespan startup).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from fastapi import Request
|
| 13 |
+
|
| 14 |
+
from app.data.loader import DatasetLoader
|
| 15 |
+
from app.models.registry import ModelRegistry
|
| 16 |
+
from app.monitoring.prediction_logger import PredictionLogger
|
| 17 |
+
from app.services.explanation_service import ExplanationService
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class ServiceContainer:
|
| 22 |
+
"""Holds all service instances initialized at startup.
|
| 23 |
+
|
| 24 |
+
Services that have not yet been implemented are typed as Any and
|
| 25 |
+
set to None until their implementation tasks are completed.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
registry: ModelRegistry
|
| 29 |
+
loader: DatasetLoader
|
| 30 |
+
explainer: ExplanationService
|
| 31 |
+
pred_logger: PredictionLogger
|
| 32 |
+
|
| 33 |
+
# Inference services — set to None until implemented (tasks 4.x, 5.x, 6.x)
|
| 34 |
+
lo_tagging: Any = None
|
| 35 |
+
bloom: Any = None
|
| 36 |
+
mastery: Any = None
|
| 37 |
+
risk: Any = None
|
| 38 |
+
recommendation: Any = None
|
| 39 |
+
answer_evaluation: Any = None
|
| 40 |
+
student_profile: Any = None
|
| 41 |
+
class_insights: Any = None
|
| 42 |
+
teacher_feedback: Any = None
|
| 43 |
+
monitoring: Any = None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def get_service_container(request: Request) -> ServiceContainer:
|
| 47 |
+
"""Retrieve the full ServiceContainer from app state."""
|
| 48 |
+
return request.app.state.services
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def get_lo_tagging_service(request: Request) -> Any:
|
| 52 |
+
"""Provide the LO tagging service instance."""
|
| 53 |
+
return request.app.state.services.lo_tagging
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def get_bloom_service(request: Request) -> Any:
|
| 57 |
+
"""Provide the Bloom classification service instance."""
|
| 58 |
+
return request.app.state.services.bloom
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def get_mastery_service(request: Request) -> Any:
|
| 62 |
+
"""Provide the mastery prediction service instance."""
|
| 63 |
+
return request.app.state.services.mastery
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def get_risk_service(request: Request) -> Any:
|
| 67 |
+
"""Provide the risk prediction service instance."""
|
| 68 |
+
return request.app.state.services.risk
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def get_recommendation_service(request: Request) -> Any:
|
| 72 |
+
"""Provide the recommendation service instance."""
|
| 73 |
+
return request.app.state.services.recommendation
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def get_answer_evaluation_service(request: Request) -> Any:
|
| 77 |
+
"""Provide the answer evaluation service instance."""
|
| 78 |
+
return request.app.state.services.answer_evaluation
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def get_student_profile_service(request: Request) -> Any:
|
| 82 |
+
"""Provide the student profile service instance."""
|
| 83 |
+
return request.app.state.services.student_profile
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def get_class_insights_service(request: Request) -> Any:
|
| 87 |
+
"""Provide the class insights service instance."""
|
| 88 |
+
return request.app.state.services.class_insights
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def get_teacher_feedback_service(request: Request) -> Any:
|
| 92 |
+
"""Provide the teacher feedback service instance."""
|
| 93 |
+
return request.app.state.services.teacher_feedback
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def get_monitoring_service(request: Request) -> Any:
|
| 97 |
+
"""Provide the monitoring service instance."""
|
| 98 |
+
return request.app.state.services.monitoring
|
app/api/v2/health.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Health and data summary API endpoints.
|
| 2 |
+
|
| 3 |
+
Provides service health status and cached dataset summary information.
|
| 4 |
+
These endpoints are lightweight and respond quickly regardless of model state.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, Request
|
| 10 |
+
|
| 11 |
+
from app.core.config import settings
|
| 12 |
+
from app.schemas.health import DataSummaryResponse, HealthResponse
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/ai/v2", tags=["health"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get(
|
| 20 |
+
"/health",
|
| 21 |
+
response_model=HealthResponse,
|
| 22 |
+
summary="Service health check",
|
| 23 |
+
description="Returns service status, version, and model loading state. Responds within 200ms.",
|
| 24 |
+
)
|
| 25 |
+
async def health_check() -> HealthResponse:
|
| 26 |
+
"""Service health status. Must respond < 200ms."""
|
| 27 |
+
return HealthResponse(
|
| 28 |
+
status="ok",
|
| 29 |
+
service=settings.ai_service_name,
|
| 30 |
+
version=settings.ai_service_version,
|
| 31 |
+
models_loaded=False,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.get(
|
| 36 |
+
"/data/summary",
|
| 37 |
+
response_model=DataSummaryResponse,
|
| 38 |
+
summary="Dataset summary",
|
| 39 |
+
description="Returns cached dataset metadata including table counts and validation status.",
|
| 40 |
+
)
|
| 41 |
+
async def data_summary(request: Request) -> DataSummaryResponse:
|
| 42 |
+
"""Dataset status and table counts (cached at startup)."""
|
| 43 |
+
cached_metadata: dict = getattr(request.app.state, "dataset_metadata", None) or {
|
| 44 |
+
"version": "unknown",
|
| 45 |
+
"table_counts": {},
|
| 46 |
+
"validation_status": "not_run",
|
| 47 |
+
"total_issues": 0,
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
return DataSummaryResponse(
|
| 51 |
+
dataset_version=cached_metadata.get("version", "unknown"),
|
| 52 |
+
table_counts=cached_metadata.get("table_counts", {}),
|
| 53 |
+
validation_status=cached_metadata.get("validation_status", "not_run"),
|
| 54 |
+
total_issues=cached_metadata.get("total_issues", 0),
|
| 55 |
+
)
|
app/api/v2/lo_tagging.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LO tagging API router.
|
| 2 |
+
|
| 3 |
+
POST /ai/v2/tag-learning-outcome — predicts the most relevant learning outcome
|
| 4 |
+
for a given question with confidence and explanation.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends
|
| 8 |
+
|
| 9 |
+
from app.api.v2.dependencies import get_lo_tagging_service
|
| 10 |
+
from app.schemas.lo_tagging import LOTaggingRequest, LOTaggingResponse
|
| 11 |
+
from app.services.lo_tagging_service import LOTaggingService
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/ai/v2", tags=["inference"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.post(
|
| 17 |
+
"/tag-learning-outcome",
|
| 18 |
+
response_model=LOTaggingResponse,
|
| 19 |
+
summary="Tag Learning Outcome",
|
| 20 |
+
description=(
|
| 21 |
+
"Predict the most relevant learning outcome for a given question. "
|
| 22 |
+
"Returns top-k predictions with confidence scores and an explanation."
|
| 23 |
+
),
|
| 24 |
+
)
|
| 25 |
+
async def tag_learning_outcome(
|
| 26 |
+
request: LOTaggingRequest,
|
| 27 |
+
service: LOTaggingService = Depends(get_lo_tagging_service),
|
| 28 |
+
) -> LOTaggingResponse:
|
| 29 |
+
"""Predict the most relevant learning outcome for a question."""
|
| 30 |
+
return service.predict(
|
| 31 |
+
question_text=request.question_text,
|
| 32 |
+
grade=request.grade,
|
| 33 |
+
subject=request.subject,
|
| 34 |
+
chapter=request.chapter,
|
| 35 |
+
top_k=request.top_k,
|
| 36 |
+
)
|
app/api/v2/mastery.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mastery prediction API router.
|
| 2 |
+
|
| 3 |
+
Exposes POST /ai/v2/predict-mastery for per-student per-LO mastery inference.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends
|
| 7 |
+
|
| 8 |
+
from app.api.v2.dependencies import get_mastery_service
|
| 9 |
+
from app.schemas.mastery import MasteryRequest, MasteryResponse
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/ai/v2", tags=["inference"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.post(
|
| 15 |
+
"/predict-mastery",
|
| 16 |
+
response_model=MasteryResponse,
|
| 17 |
+
summary="Predict student mastery for a learning outcome",
|
| 18 |
+
description=(
|
| 19 |
+
"Returns mastery score, label, confidence, reasons, and recommended action "
|
| 20 |
+
"for a given student and learning outcome. Uses ML model with rule-based fallback."
|
| 21 |
+
),
|
| 22 |
+
)
|
| 23 |
+
async def predict_mastery(
|
| 24 |
+
request: MasteryRequest,
|
| 25 |
+
service=Depends(get_mastery_service),
|
| 26 |
+
) -> MasteryResponse:
|
| 27 |
+
"""Predict mastery score and label for a student-LO pair."""
|
| 28 |
+
return service.predict(student_id=request.student_id, lo_id=request.lo_id)
|
app/api/v2/monitoring.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Monitoring API router.
|
| 2 |
+
|
| 3 |
+
GET /ai/v2/monitoring/model-health — returns status of all loaded models
|
| 4 |
+
with metrics and prediction stats.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends
|
| 8 |
+
|
| 9 |
+
from app.api.v2.dependencies import get_monitoring_service
|
| 10 |
+
from app.schemas.monitoring import MonitoringResponse
|
| 11 |
+
from app.services.monitoring_service import MonitoringService
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/ai/v2", tags=["monitoring"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.get(
|
| 17 |
+
"/monitoring/model-health",
|
| 18 |
+
response_model=MonitoringResponse,
|
| 19 |
+
summary="Model Health",
|
| 20 |
+
description=(
|
| 21 |
+
"Return status of all loaded models with metrics and prediction stats. "
|
| 22 |
+
"Reports model version, last trained date, key metrics, "
|
| 23 |
+
"and prediction counts for the last 24 hours."
|
| 24 |
+
),
|
| 25 |
+
)
|
| 26 |
+
async def get_model_health(
|
| 27 |
+
service: MonitoringService = Depends(get_monitoring_service),
|
| 28 |
+
) -> MonitoringResponse:
|
| 29 |
+
"""Return health status of all registered models."""
|
| 30 |
+
return service.get_model_health()
|
app/api/v2/recommendations.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Recommendations API router.
|
| 2 |
+
|
| 3 |
+
Exposes GET /ai/v2/recommendations/{student_id} for content recommendations.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends, Query
|
| 7 |
+
|
| 8 |
+
from app.api.v2.dependencies import get_recommendation_service
|
| 9 |
+
from app.schemas.recommendations import RecommendationResponse
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/ai/v2", tags=["inference"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get(
|
| 15 |
+
"/recommendations/{student_id}",
|
| 16 |
+
response_model=RecommendationResponse,
|
| 17 |
+
summary="Get content recommendations for a student",
|
| 18 |
+
description=(
|
| 19 |
+
"Returns ranked content recommendations based on student weaknesses. "
|
| 20 |
+
"Optionally filter by subject and limit results. Uses ML model with "
|
| 21 |
+
"knowledge-graph fallback."
|
| 22 |
+
),
|
| 23 |
+
)
|
| 24 |
+
async def get_recommendations(
|
| 25 |
+
student_id: str,
|
| 26 |
+
subject: str | None = Query(None, description="Filter recommendations by subject"),
|
| 27 |
+
limit: int = Query(5, ge=1, le=20, description="Maximum number of recommendations"),
|
| 28 |
+
service=Depends(get_recommendation_service),
|
| 29 |
+
) -> RecommendationResponse:
|
| 30 |
+
"""Get ranked content recommendations for a student."""
|
| 31 |
+
return service.predict(student_id=student_id, subject=subject, limit=limit)
|
app/api/v2/risk.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Risk prediction API router.
|
| 2 |
+
|
| 3 |
+
Exposes GET /ai/v2/risk/{student_id} for student risk inference.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends
|
| 7 |
+
|
| 8 |
+
from app.api.v2.dependencies import get_risk_service
|
| 9 |
+
from app.schemas.risk import RiskResponse
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/ai/v2", tags=["inference"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get(
|
| 15 |
+
"/risk/{student_id}",
|
| 16 |
+
response_model=RiskResponse,
|
| 17 |
+
summary="Predict student risk level",
|
| 18 |
+
description=(
|
| 19 |
+
"Returns risk score, 4-class label, reasons, and recommended intervention "
|
| 20 |
+
"for a given student. Uses ML model with rule-based fallback."
|
| 21 |
+
),
|
| 22 |
+
)
|
| 23 |
+
async def predict_risk(
|
| 24 |
+
student_id: str,
|
| 25 |
+
service=Depends(get_risk_service),
|
| 26 |
+
) -> RiskResponse:
|
| 27 |
+
"""Predict risk score and label for a student."""
|
| 28 |
+
return service.predict(student_id=student_id)
|
app/api/v2/student_profile.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Student profile router.
|
| 2 |
+
|
| 3 |
+
GET /ai/v2/student-profile/{student_id} — returns a digital twin aggregation.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends
|
| 7 |
+
|
| 8 |
+
from app.api.v2.dependencies import get_student_profile_service
|
| 9 |
+
from app.schemas.student_profile import StudentProfileResponse
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/ai/v2", tags=["inference"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get(
|
| 15 |
+
"/student-profile/{student_id}",
|
| 16 |
+
response_model=StudentProfileResponse,
|
| 17 |
+
summary="Get Student Profile",
|
| 18 |
+
description="Return a digital twin aggregation for a student.",
|
| 19 |
+
)
|
| 20 |
+
async def get_student_profile(
|
| 21 |
+
student_id: str,
|
| 22 |
+
service=Depends(get_student_profile_service),
|
| 23 |
+
) -> StudentProfileResponse:
|
| 24 |
+
"""Retrieve the aggregated student profile (digital twin)."""
|
| 25 |
+
return service.get_profile(student_id=student_id)
|
app/api/v2/teacher_feedback.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Teacher feedback API router.
|
| 2 |
+
|
| 3 |
+
POST /ai/v2/teacher-feedback — stores teacher feedback on a prediction
|
| 4 |
+
for future retraining.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends
|
| 8 |
+
|
| 9 |
+
from app.api.v2.dependencies import get_teacher_feedback_service
|
| 10 |
+
from app.schemas.teacher_feedback import TeacherFeedbackRequest, TeacherFeedbackResponse
|
| 11 |
+
from app.services.teacher_feedback_service import TeacherFeedbackService
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/ai/v2", tags=["feedback"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.post(
|
| 17 |
+
"/teacher-feedback",
|
| 18 |
+
response_model=TeacherFeedbackResponse,
|
| 19 |
+
summary="Submit Teacher Feedback",
|
| 20 |
+
description=(
|
| 21 |
+
"Store teacher feedback on a prediction for future retraining. "
|
| 22 |
+
"Feedback types: accept, edit, reject. "
|
| 23 |
+
"If feedback_type is 'edit', corrected_label is required."
|
| 24 |
+
),
|
| 25 |
+
)
|
| 26 |
+
async def submit_teacher_feedback(
|
| 27 |
+
request: TeacherFeedbackRequest,
|
| 28 |
+
service: TeacherFeedbackService = Depends(get_teacher_feedback_service),
|
| 29 |
+
) -> TeacherFeedbackResponse:
|
| 30 |
+
"""Store teacher feedback referencing a prediction."""
|
| 31 |
+
return service.store_feedback(
|
| 32 |
+
teacher_id=request.teacher_id,
|
| 33 |
+
prediction_id=request.prediction_id,
|
| 34 |
+
feedback_type=request.feedback_type,
|
| 35 |
+
corrected_label=request.corrected_label,
|
| 36 |
+
rating=request.rating,
|
| 37 |
+
comment=request.comment,
|
| 38 |
+
)
|
app/app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# AI_Services_V2 app package
|
app/app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# API package
|
app/app/api/v2/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# API v2 package
|
app/app/api/v2/answer_evaluation.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Answer evaluation router.
|
| 2 |
+
|
| 3 |
+
POST /ai/v2/evaluate-answer — scores a subjective answer and flags for teacher review.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends
|
| 7 |
+
|
| 8 |
+
from app.api.v2.dependencies import get_answer_evaluation_service
|
| 9 |
+
from app.schemas.answer_evaluation import AnswerEvaluationRequest, AnswerEvaluationResponse
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/ai/v2", tags=["inference"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.post(
|
| 15 |
+
"/evaluate-answer",
|
| 16 |
+
response_model=AnswerEvaluationResponse,
|
| 17 |
+
summary="Evaluate Answer",
|
| 18 |
+
description="Score a subjective answer and flag for teacher review.",
|
| 19 |
+
)
|
| 20 |
+
async def evaluate_answer(
|
| 21 |
+
request: AnswerEvaluationRequest,
|
| 22 |
+
service=Depends(get_answer_evaluation_service),
|
| 23 |
+
) -> AnswerEvaluationResponse:
|
| 24 |
+
"""Score a student's subjective answer against a rubric."""
|
| 25 |
+
return service.predict(
|
| 26 |
+
question_id=request.question_id,
|
| 27 |
+
question_text=request.question_text,
|
| 28 |
+
student_answer=request.student_answer,
|
| 29 |
+
model_answer=request.model_answer,
|
| 30 |
+
rubric=request.rubric,
|
| 31 |
+
max_marks=request.max_marks,
|
| 32 |
+
grade=request.grade,
|
| 33 |
+
subject=request.subject,
|
| 34 |
+
lo_id=request.lo_id,
|
| 35 |
+
bloom_level=request.bloom_level,
|
| 36 |
+
)
|
app/app/api/v2/bloom.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bloom classification API router.
|
| 2 |
+
|
| 3 |
+
POST /ai/v2/classify-bloom — classifies a question's cognitive level
|
| 4 |
+
according to Bloom's taxonomy.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends
|
| 8 |
+
|
| 9 |
+
from app.api.v2.dependencies import get_bloom_service
|
| 10 |
+
from app.schemas.bloom import BloomRequest, BloomResponse
|
| 11 |
+
from app.services.bloom_service import BloomService
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/ai/v2", tags=["inference"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.post(
|
| 17 |
+
"/classify-bloom",
|
| 18 |
+
response_model=BloomResponse,
|
| 19 |
+
summary="Classify Bloom Level",
|
| 20 |
+
description=(
|
| 21 |
+
"Classify a question's cognitive level according to Bloom's taxonomy. "
|
| 22 |
+
"Returns the predicted Bloom level with confidence and an explanation."
|
| 23 |
+
),
|
| 24 |
+
)
|
| 25 |
+
async def classify_bloom(
|
| 26 |
+
request: BloomRequest,
|
| 27 |
+
service: BloomService = Depends(get_bloom_service),
|
| 28 |
+
) -> BloomResponse:
|
| 29 |
+
"""Classify a question's Bloom taxonomy level."""
|
| 30 |
+
return service.predict(
|
| 31 |
+
question_text=request.question_text,
|
| 32 |
+
grade=request.grade,
|
| 33 |
+
subject=request.subject,
|
| 34 |
+
question_type=request.question_type,
|
| 35 |
+
)
|
app/app/api/v2/class_insights.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Class insights router.
|
| 2 |
+
|
| 3 |
+
GET /ai/v2/class-insights/{class_id} — returns class-level weak LOs,
|
| 4 |
+
at-risk counts, and recommended interventions.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends, Query
|
| 8 |
+
|
| 9 |
+
from app.api.v2.dependencies import get_class_insights_service
|
| 10 |
+
from app.schemas.class_insights import ClassInsightsResponse
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/ai/v2", tags=["inference"])
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@router.get(
|
| 16 |
+
"/class-insights/{class_id}",
|
| 17 |
+
response_model=ClassInsightsResponse,
|
| 18 |
+
summary="Get Class Insights",
|
| 19 |
+
description="Return class-level insights including weakest LOs, at-risk students, and interventions.",
|
| 20 |
+
)
|
| 21 |
+
async def get_class_insights(
|
| 22 |
+
class_id: str,
|
| 23 |
+
subject: str | None = Query(None, description="Optional subject filter"),
|
| 24 |
+
service=Depends(get_class_insights_service),
|
| 25 |
+
) -> ClassInsightsResponse:
|
| 26 |
+
"""Retrieve class-level insights for a teacher."""
|
| 27 |
+
return service.get_insights(class_id=class_id, subject=subject)
|
app/app/api/v2/dependencies.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI dependency providers for service instances.
|
| 2 |
+
|
| 3 |
+
Each provider retrieves the service from request.app.state.services (the
|
| 4 |
+
ServiceContainer attached during application lifespan startup).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from fastapi import Request
|
| 13 |
+
|
| 14 |
+
from app.data.loader import DatasetLoader
|
| 15 |
+
from app.models.registry import ModelRegistry
|
| 16 |
+
from app.monitoring.prediction_logger import PredictionLogger
|
| 17 |
+
from app.services.explanation_service import ExplanationService
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class ServiceContainer:
|
| 22 |
+
"""Holds all service instances initialized at startup.
|
| 23 |
+
|
| 24 |
+
Services that have not yet been implemented are typed as Any and
|
| 25 |
+
set to None until their implementation tasks are completed.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
registry: ModelRegistry
|
| 29 |
+
loader: DatasetLoader
|
| 30 |
+
explainer: ExplanationService
|
| 31 |
+
pred_logger: PredictionLogger
|
| 32 |
+
|
| 33 |
+
# Inference services — set to None until implemented (tasks 4.x, 5.x, 6.x)
|
| 34 |
+
lo_tagging: Any = None
|
| 35 |
+
bloom: Any = None
|
| 36 |
+
mastery: Any = None
|
| 37 |
+
risk: Any = None
|
| 38 |
+
recommendation: Any = None
|
| 39 |
+
answer_evaluation: Any = None
|
| 40 |
+
student_profile: Any = None
|
| 41 |
+
class_insights: Any = None
|
| 42 |
+
teacher_feedback: Any = None
|
| 43 |
+
monitoring: Any = None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def get_service_container(request: Request) -> ServiceContainer:
|
| 47 |
+
"""Retrieve the full ServiceContainer from app state."""
|
| 48 |
+
return request.app.state.services
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def get_lo_tagging_service(request: Request) -> Any:
|
| 52 |
+
"""Provide the LO tagging service instance."""
|
| 53 |
+
return request.app.state.services.lo_tagging
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def get_bloom_service(request: Request) -> Any:
|
| 57 |
+
"""Provide the Bloom classification service instance."""
|
| 58 |
+
return request.app.state.services.bloom
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def get_mastery_service(request: Request) -> Any:
|
| 62 |
+
"""Provide the mastery prediction service instance."""
|
| 63 |
+
return request.app.state.services.mastery
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def get_risk_service(request: Request) -> Any:
|
| 67 |
+
"""Provide the risk prediction service instance."""
|
| 68 |
+
return request.app.state.services.risk
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def get_recommendation_service(request: Request) -> Any:
|
| 72 |
+
"""Provide the recommendation service instance."""
|
| 73 |
+
return request.app.state.services.recommendation
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def get_answer_evaluation_service(request: Request) -> Any:
|
| 77 |
+
"""Provide the answer evaluation service instance."""
|
| 78 |
+
return request.app.state.services.answer_evaluation
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def get_student_profile_service(request: Request) -> Any:
|
| 82 |
+
"""Provide the student profile service instance."""
|
| 83 |
+
return request.app.state.services.student_profile
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def get_class_insights_service(request: Request) -> Any:
|
| 87 |
+
"""Provide the class insights service instance."""
|
| 88 |
+
return request.app.state.services.class_insights
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def get_teacher_feedback_service(request: Request) -> Any:
|
| 92 |
+
"""Provide the teacher feedback service instance."""
|
| 93 |
+
return request.app.state.services.teacher_feedback
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def get_monitoring_service(request: Request) -> Any:
|
| 97 |
+
"""Provide the monitoring service instance."""
|
| 98 |
+
return request.app.state.services.monitoring
|
app/app/api/v2/health.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Health and data summary API endpoints.
|
| 2 |
+
|
| 3 |
+
Provides service health status and cached dataset summary information.
|
| 4 |
+
These endpoints are lightweight and respond quickly regardless of model state.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, Request
|
| 10 |
+
|
| 11 |
+
from app.core.config import settings
|
| 12 |
+
from app.schemas.health import DataSummaryResponse, HealthResponse
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/ai/v2", tags=["health"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get(
|
| 20 |
+
"/health",
|
| 21 |
+
response_model=HealthResponse,
|
| 22 |
+
summary="Service health check",
|
| 23 |
+
description="Returns service status, version, and model loading state. Responds within 200ms.",
|
| 24 |
+
)
|
| 25 |
+
async def health_check() -> HealthResponse:
|
| 26 |
+
"""Service health status. Must respond < 200ms."""
|
| 27 |
+
return HealthResponse(
|
| 28 |
+
status="ok",
|
| 29 |
+
service=settings.ai_service_name,
|
| 30 |
+
version=settings.ai_service_version,
|
| 31 |
+
models_loaded=False,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.get(
|
| 36 |
+
"/data/summary",
|
| 37 |
+
response_model=DataSummaryResponse,
|
| 38 |
+
summary="Dataset summary",
|
| 39 |
+
description="Returns cached dataset metadata including table counts and validation status.",
|
| 40 |
+
)
|
| 41 |
+
async def data_summary(request: Request) -> DataSummaryResponse:
|
| 42 |
+
"""Dataset status and table counts (cached at startup)."""
|
| 43 |
+
cached_metadata: dict = getattr(request.app.state, "dataset_metadata", None) or {
|
| 44 |
+
"version": "unknown",
|
| 45 |
+
"table_counts": {},
|
| 46 |
+
"validation_status": "not_run",
|
| 47 |
+
"total_issues": 0,
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
return DataSummaryResponse(
|
| 51 |
+
dataset_version=cached_metadata.get("version", "unknown"),
|
| 52 |
+
table_counts=cached_metadata.get("table_counts", {}),
|
| 53 |
+
validation_status=cached_metadata.get("validation_status", "not_run"),
|
| 54 |
+
total_issues=cached_metadata.get("total_issues", 0),
|
| 55 |
+
)
|
app/app/api/v2/lo_tagging.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LO tagging API router.
|
| 2 |
+
|
| 3 |
+
POST /ai/v2/tag-learning-outcome — predicts the most relevant learning outcome
|
| 4 |
+
for a given question with confidence and explanation.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends
|
| 8 |
+
|
| 9 |
+
from app.api.v2.dependencies import get_lo_tagging_service
|
| 10 |
+
from app.schemas.lo_tagging import LOTaggingRequest, LOTaggingResponse
|
| 11 |
+
from app.services.lo_tagging_service import LOTaggingService
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/ai/v2", tags=["inference"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.post(
|
| 17 |
+
"/tag-learning-outcome",
|
| 18 |
+
response_model=LOTaggingResponse,
|
| 19 |
+
summary="Tag Learning Outcome",
|
| 20 |
+
description=(
|
| 21 |
+
"Predict the most relevant learning outcome for a given question. "
|
| 22 |
+
"Returns top-k predictions with confidence scores and an explanation."
|
| 23 |
+
),
|
| 24 |
+
)
|
| 25 |
+
async def tag_learning_outcome(
|
| 26 |
+
request: LOTaggingRequest,
|
| 27 |
+
service: LOTaggingService = Depends(get_lo_tagging_service),
|
| 28 |
+
) -> LOTaggingResponse:
|
| 29 |
+
"""Predict the most relevant learning outcome for a question."""
|
| 30 |
+
return service.predict(
|
| 31 |
+
question_text=request.question_text,
|
| 32 |
+
grade=request.grade,
|
| 33 |
+
subject=request.subject,
|
| 34 |
+
chapter=request.chapter,
|
| 35 |
+
top_k=request.top_k,
|
| 36 |
+
)
|
app/app/api/v2/mastery.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mastery prediction API router.
|
| 2 |
+
|
| 3 |
+
Exposes POST /ai/v2/predict-mastery for per-student per-LO mastery inference.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends
|
| 7 |
+
|
| 8 |
+
from app.api.v2.dependencies import get_mastery_service
|
| 9 |
+
from app.schemas.mastery import MasteryRequest, MasteryResponse
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/ai/v2", tags=["inference"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.post(
|
| 15 |
+
"/predict-mastery",
|
| 16 |
+
response_model=MasteryResponse,
|
| 17 |
+
summary="Predict student mastery for a learning outcome",
|
| 18 |
+
description=(
|
| 19 |
+
"Returns mastery score, label, confidence, reasons, and recommended action "
|
| 20 |
+
"for a given student and learning outcome. Uses ML model with rule-based fallback."
|
| 21 |
+
),
|
| 22 |
+
)
|
| 23 |
+
async def predict_mastery(
|
| 24 |
+
request: MasteryRequest,
|
| 25 |
+
service=Depends(get_mastery_service),
|
| 26 |
+
) -> MasteryResponse:
|
| 27 |
+
"""Predict mastery score and label for a student-LO pair."""
|
| 28 |
+
return service.predict(student_id=request.student_id, lo_id=request.lo_id)
|
app/app/api/v2/monitoring.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Monitoring API router.
|
| 2 |
+
|
| 3 |
+
GET /ai/v2/monitoring/model-health — returns status of all loaded models
|
| 4 |
+
with metrics and prediction stats.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends
|
| 8 |
+
|
| 9 |
+
from app.api.v2.dependencies import get_monitoring_service
|
| 10 |
+
from app.schemas.monitoring import MonitoringResponse
|
| 11 |
+
from app.services.monitoring_service import MonitoringService
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/ai/v2", tags=["monitoring"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.get(
|
| 17 |
+
"/monitoring/model-health",
|
| 18 |
+
response_model=MonitoringResponse,
|
| 19 |
+
summary="Model Health",
|
| 20 |
+
description=(
|
| 21 |
+
"Return status of all loaded models with metrics and prediction stats. "
|
| 22 |
+
"Reports model version, last trained date, key metrics, "
|
| 23 |
+
"and prediction counts for the last 24 hours."
|
| 24 |
+
),
|
| 25 |
+
)
|
| 26 |
+
async def get_model_health(
|
| 27 |
+
service: MonitoringService = Depends(get_monitoring_service),
|
| 28 |
+
) -> MonitoringResponse:
|
| 29 |
+
"""Return health status of all registered models."""
|
| 30 |
+
return service.get_model_health()
|
app/app/api/v2/recommendations.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Recommendations API router.
|
| 2 |
+
|
| 3 |
+
Exposes GET /ai/v2/recommendations/{student_id} for content recommendations.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends, Query
|
| 7 |
+
|
| 8 |
+
from app.api.v2.dependencies import get_recommendation_service
|
| 9 |
+
from app.schemas.recommendations import RecommendationResponse
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/ai/v2", tags=["inference"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get(
|
| 15 |
+
"/recommendations/{student_id}",
|
| 16 |
+
response_model=RecommendationResponse,
|
| 17 |
+
summary="Get content recommendations for a student",
|
| 18 |
+
description=(
|
| 19 |
+
"Returns ranked content recommendations based on student weaknesses. "
|
| 20 |
+
"Optionally filter by subject and limit results. Uses ML model with "
|
| 21 |
+
"knowledge-graph fallback."
|
| 22 |
+
),
|
| 23 |
+
)
|
| 24 |
+
async def get_recommendations(
|
| 25 |
+
student_id: str,
|
| 26 |
+
subject: str | None = Query(None, description="Filter recommendations by subject"),
|
| 27 |
+
limit: int = Query(5, ge=1, le=20, description="Maximum number of recommendations"),
|
| 28 |
+
service=Depends(get_recommendation_service),
|
| 29 |
+
) -> RecommendationResponse:
|
| 30 |
+
"""Get ranked content recommendations for a student."""
|
| 31 |
+
return service.predict(student_id=student_id, subject=subject, limit=limit)
|
app/app/api/v2/risk.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Risk prediction API router.
|
| 2 |
+
|
| 3 |
+
Exposes GET /ai/v2/risk/{student_id} for student risk inference.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends
|
| 7 |
+
|
| 8 |
+
from app.api.v2.dependencies import get_risk_service
|
| 9 |
+
from app.schemas.risk import RiskResponse
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/ai/v2", tags=["inference"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get(
|
| 15 |
+
"/risk/{student_id}",
|
| 16 |
+
response_model=RiskResponse,
|
| 17 |
+
summary="Predict student risk level",
|
| 18 |
+
description=(
|
| 19 |
+
"Returns risk score, 4-class label, reasons, and recommended intervention "
|
| 20 |
+
"for a given student. Uses ML model with rule-based fallback."
|
| 21 |
+
),
|
| 22 |
+
)
|
| 23 |
+
async def predict_risk(
|
| 24 |
+
student_id: str,
|
| 25 |
+
service=Depends(get_risk_service),
|
| 26 |
+
) -> RiskResponse:
|
| 27 |
+
"""Predict risk score and label for a student."""
|
| 28 |
+
return service.predict(student_id=student_id)
|
app/app/api/v2/student_profile.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Student profile router.
|
| 2 |
+
|
| 3 |
+
GET /ai/v2/student-profile/{student_id} — returns a digital twin aggregation.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends
|
| 7 |
+
|
| 8 |
+
from app.api.v2.dependencies import get_student_profile_service
|
| 9 |
+
from app.schemas.student_profile import StudentProfileResponse
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/ai/v2", tags=["inference"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get(
|
| 15 |
+
"/student-profile/{student_id}",
|
| 16 |
+
response_model=StudentProfileResponse,
|
| 17 |
+
summary="Get Student Profile",
|
| 18 |
+
description="Return a digital twin aggregation for a student.",
|
| 19 |
+
)
|
| 20 |
+
async def get_student_profile(
|
| 21 |
+
student_id: str,
|
| 22 |
+
service=Depends(get_student_profile_service),
|
| 23 |
+
) -> StudentProfileResponse:
|
| 24 |
+
"""Retrieve the aggregated student profile (digital twin)."""
|
| 25 |
+
return service.get_profile(student_id=student_id)
|
app/app/api/v2/teacher_feedback.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Teacher feedback API router.
|
| 2 |
+
|
| 3 |
+
POST /ai/v2/teacher-feedback — stores teacher feedback on a prediction
|
| 4 |
+
for future retraining.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends
|
| 8 |
+
|
| 9 |
+
from app.api.v2.dependencies import get_teacher_feedback_service
|
| 10 |
+
from app.schemas.teacher_feedback import TeacherFeedbackRequest, TeacherFeedbackResponse
|
| 11 |
+
from app.services.teacher_feedback_service import TeacherFeedbackService
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/ai/v2", tags=["feedback"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.post(
|
| 17 |
+
"/teacher-feedback",
|
| 18 |
+
response_model=TeacherFeedbackResponse,
|
| 19 |
+
summary="Submit Teacher Feedback",
|
| 20 |
+
description=(
|
| 21 |
+
"Store teacher feedback on a prediction for future retraining. "
|
| 22 |
+
"Feedback types: accept, edit, reject. "
|
| 23 |
+
"If feedback_type is 'edit', corrected_label is required."
|
| 24 |
+
),
|
| 25 |
+
)
|
| 26 |
+
async def submit_teacher_feedback(
|
| 27 |
+
request: TeacherFeedbackRequest,
|
| 28 |
+
service: TeacherFeedbackService = Depends(get_teacher_feedback_service),
|
| 29 |
+
) -> TeacherFeedbackResponse:
|
| 30 |
+
"""Store teacher feedback referencing a prediction."""
|
| 31 |
+
return service.store_feedback(
|
| 32 |
+
teacher_id=request.teacher_id,
|
| 33 |
+
prediction_id=request.prediction_id,
|
| 34 |
+
feedback_type=request.feedback_type,
|
| 35 |
+
corrected_label=request.corrected_label,
|
| 36 |
+
rating=request.rating,
|
| 37 |
+
comment=request.comment,
|
| 38 |
+
)
|
app/app/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Core package
|
app/app/core/config.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Centralized configuration system using pydantic-settings.
|
| 2 |
+
|
| 3 |
+
All environment-driven settings are defined here. No module outside this file
|
| 4 |
+
should read os.environ directly.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Settings(BaseSettings):
|
| 11 |
+
"""Application settings loaded from environment variables and .env file."""
|
| 12 |
+
|
| 13 |
+
ai_service_name: str = "LearningOutcomeOS-AI-V2"
|
| 14 |
+
ai_service_version: str = "2.0.0"
|
| 15 |
+
dataset_dir: str = "../learning_outcome_os_dataset_v2"
|
| 16 |
+
model_artifact_dir: str = "../artifacts/models"
|
| 17 |
+
metrics_dir: str = "../artifacts/metrics"
|
| 18 |
+
reports_dir: str = "../artifacts/reports"
|
| 19 |
+
log_level: str = "INFO"
|
| 20 |
+
seed: int = 20260520
|
| 21 |
+
low_confidence_threshold: float = 0.55
|
| 22 |
+
enable_explainability: bool = True
|
| 23 |
+
log_salt: str = "change-me-in-prod"
|
| 24 |
+
feedback_dir: str = "./artifacts/feedback"
|
| 25 |
+
|
| 26 |
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
settings = Settings()
|
app/app/core/constants.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application-wide constants.
|
| 2 |
+
|
| 3 |
+
These values define the authoritative label sets and mappings used across
|
| 4 |
+
inference services, schemas, and validation. They must match the data contract
|
| 5 |
+
exactly and should never be redefined elsewhere in the codebase.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
MASTERY_LABEL_MAP: dict[int, str] = {
|
| 9 |
+
0: "weak",
|
| 10 |
+
1: "developing",
|
| 11 |
+
2: "proficient",
|
| 12 |
+
3: "mastered",
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
BLOOM_LEVELS: list[str] = [
|
| 16 |
+
"Remember",
|
| 17 |
+
"Understand",
|
| 18 |
+
"Apply",
|
| 19 |
+
"Analyze",
|
| 20 |
+
"Evaluate",
|
| 21 |
+
"Create",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
RISK_LABELS: list[str] = ["low", "medium", "high", "critical"]
|
| 25 |
+
|
| 26 |
+
FEEDBACK_TYPES: list[str] = ["accept", "edit", "reject"]
|
| 27 |
+
|
| 28 |
+
VALID_SUBJECTS: list[str] = ["Mathematics", "Science", "Social Science"]
|
| 29 |
+
|
| 30 |
+
VALID_GRADES: list[int] = [6, 7, 8]
|
app/app/core/exceptions.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Custom exception classes for the AI Services V2 layer."""
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class DatasetError(Exception):
|
| 5 |
+
"""Raised when dataset operations fail (missing file, schema mismatch)."""
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class TrainingError(Exception):
|
| 9 |
+
"""Raised when a model training pipeline fails.
|
| 10 |
+
|
| 11 |
+
Attributes:
|
| 12 |
+
model_name: The name of the model whose training failed.
|
| 13 |
+
message: Human-readable description of the failure.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
def __init__(self, message: str, model_name: str) -> None:
|
| 17 |
+
self.model_name = model_name
|
| 18 |
+
super().__init__(f"[{model_name}] {message}")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class ModelNotLoadedError(Exception):
|
| 22 |
+
"""Raised when inference is attempted on an unloaded model."""
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class EntityNotFoundError(Exception):
|
| 26 |
+
"""Raised when a referenced entity (student, LO, class) doesn't exist."""
|
app/app/data/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Data package
|
app/app/data/loader.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dataset loader for CSV tables and metadata.
|
| 2 |
+
|
| 3 |
+
Provides a single abstraction for loading any CSV table or the dataset
|
| 4 |
+
metadata JSON from the configured dataset directory. All path resolution
|
| 5 |
+
is relative to the dataset_dir parameter — no hardcoded paths.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import logging
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import pandas as pd
|
| 13 |
+
|
| 14 |
+
from app.core.exceptions import DatasetError
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class DatasetLoader:
|
| 20 |
+
"""Loads CSV tables and metadata from the dataset directory.
|
| 21 |
+
|
| 22 |
+
All file paths are resolved relative to the dataset_dir provided at
|
| 23 |
+
construction time. Table names are accepted with or without the .csv
|
| 24 |
+
extension. No columns are renamed, dropped, or aliased during loading.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(self, dataset_dir: str | Path) -> None:
|
| 28 |
+
self._dataset_dir = Path(dataset_dir).resolve()
|
| 29 |
+
|
| 30 |
+
@property
|
| 31 |
+
def dataset_dir(self) -> Path:
|
| 32 |
+
"""The resolved dataset directory path."""
|
| 33 |
+
return self._dataset_dir
|
| 34 |
+
|
| 35 |
+
def load_table(self, table_name: str) -> pd.DataFrame:
|
| 36 |
+
"""Load a CSV table by name and return it as a DataFrame.
|
| 37 |
+
|
| 38 |
+
Accepts table names with or without the .csv extension
|
| 39 |
+
(e.g., "learning_outcomes" or "learning_outcomes.csv").
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
table_name: Name of the CSV table to load.
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
A pandas DataFrame with the table contents.
|
| 46 |
+
|
| 47 |
+
Raises:
|
| 48 |
+
DatasetError: If the CSV file does not exist at the resolved path.
|
| 49 |
+
"""
|
| 50 |
+
path = self.get_table_path(table_name)
|
| 51 |
+
|
| 52 |
+
if not path.exists():
|
| 53 |
+
raise DatasetError(
|
| 54 |
+
f"Table file not found: {path}"
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
df = pd.read_csv(path)
|
| 59 |
+
except Exception as exc:
|
| 60 |
+
raise DatasetError(
|
| 61 |
+
f"Failed to read table '{table_name}' at {path}: {exc}"
|
| 62 |
+
) from exc
|
| 63 |
+
|
| 64 |
+
logger.info("Loaded table '%s' — %d rows, %d columns", table_name, len(df), len(df.columns))
|
| 65 |
+
return df
|
| 66 |
+
|
| 67 |
+
def load_metadata(self) -> dict:
|
| 68 |
+
"""Load and parse dataset_metadata.json from the dataset directory.
|
| 69 |
+
|
| 70 |
+
Returns:
|
| 71 |
+
A dictionary with the parsed JSON content.
|
| 72 |
+
|
| 73 |
+
Raises:
|
| 74 |
+
DatasetError: If the metadata file does not exist or cannot be parsed.
|
| 75 |
+
"""
|
| 76 |
+
path = self._dataset_dir / "dataset_metadata.json"
|
| 77 |
+
|
| 78 |
+
if not path.exists():
|
| 79 |
+
raise DatasetError(
|
| 80 |
+
f"Metadata file not found: {path}"
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
try:
|
| 84 |
+
with open(path, encoding="utf-8") as f:
|
| 85 |
+
metadata = json.load(f)
|
| 86 |
+
except json.JSONDecodeError as exc:
|
| 87 |
+
raise DatasetError(
|
| 88 |
+
f"Failed to parse metadata JSON at {path}: {exc}"
|
| 89 |
+
) from exc
|
| 90 |
+
except Exception as exc:
|
| 91 |
+
raise DatasetError(
|
| 92 |
+
f"Failed to read metadata file at {path}: {exc}"
|
| 93 |
+
) from exc
|
| 94 |
+
|
| 95 |
+
logger.info("Loaded dataset metadata from %s", path)
|
| 96 |
+
return metadata
|
| 97 |
+
|
| 98 |
+
def list_tables(self) -> list[str]:
|
| 99 |
+
"""Return a list of available CSV table names without extension.
|
| 100 |
+
|
| 101 |
+
Scans the dataset directory for .csv files and returns their
|
| 102 |
+
stem names (e.g., "learning_outcomes", "student_profiles").
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
A sorted list of table name strings without the .csv extension.
|
| 106 |
+
"""
|
| 107 |
+
csv_files = sorted(self._dataset_dir.glob("*.csv"))
|
| 108 |
+
return [f.stem for f in csv_files]
|
| 109 |
+
|
| 110 |
+
def get_table_path(self, table_name: str) -> Path:
|
| 111 |
+
"""Resolve the full path for a table name.
|
| 112 |
+
|
| 113 |
+
Handles table names with or without the .csv extension.
|
| 114 |
+
Does NOT verify that the file exists.
|
| 115 |
+
|
| 116 |
+
Args:
|
| 117 |
+
table_name: Name of the table (with or without .csv extension).
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
The resolved Path to the CSV file.
|
| 121 |
+
"""
|
| 122 |
+
if not table_name.endswith(".csv"):
|
| 123 |
+
table_name = f"{table_name}.csv"
|
| 124 |
+
return self._dataset_dir / table_name
|
app/app/data/split_manager.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Split manager for separating DataFrames by train_split column."""
|
| 2 |
+
|
| 3 |
+
from typing import ClassVar
|
| 4 |
+
|
| 5 |
+
import pandas as pd
|
| 6 |
+
|
| 7 |
+
from app.core.exceptions import DatasetError
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class SplitManager:
|
| 11 |
+
"""Separates DataFrames by train_split column into train/validation/test."""
|
| 12 |
+
|
| 13 |
+
VALID_SPLITS: ClassVar[set[str]] = {"train", "validation", "test"}
|
| 14 |
+
|
| 15 |
+
def get_splits(
|
| 16 |
+
self, df: pd.DataFrame, split_col: str = "train_split"
|
| 17 |
+
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
| 18 |
+
"""Return (train_df, val_df, test_df) as new DataFrame copies.
|
| 19 |
+
|
| 20 |
+
Raises DatasetError if split_col missing or contains invalid values.
|
| 21 |
+
"""
|
| 22 |
+
if split_col not in df.columns:
|
| 23 |
+
raise DatasetError(
|
| 24 |
+
f"Column '{split_col}' not found in DataFrame. "
|
| 25 |
+
f"Available columns: {list(df.columns)}. "
|
| 26 |
+
f"This table may not be a training table."
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
unique_values = set(df[split_col].dropna().unique())
|
| 30 |
+
invalid_values = unique_values - self.VALID_SPLITS
|
| 31 |
+
|
| 32 |
+
if invalid_values:
|
| 33 |
+
raise DatasetError(
|
| 34 |
+
f"Column '{split_col}' contains invalid values: {sorted(invalid_values)}. "
|
| 35 |
+
f"Only {sorted(self.VALID_SPLITS)} are allowed."
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# Check for null/empty values in the split column
|
| 39 |
+
null_count = df[split_col].isna().sum()
|
| 40 |
+
if null_count > 0:
|
| 41 |
+
raise DatasetError(
|
| 42 |
+
f"Column '{split_col}' contains {null_count} null value(s). "
|
| 43 |
+
f"All rows must have a valid split assignment."
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
empty_count = (df[split_col] == "").sum()
|
| 47 |
+
if empty_count > 0:
|
| 48 |
+
raise DatasetError(
|
| 49 |
+
f"Column '{split_col}' contains {empty_count} empty string value(s). "
|
| 50 |
+
f"All rows must have a valid split assignment from {sorted(self.VALID_SPLITS)}."
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# Filter by column value only — no random sampling, shuffling, or sklearn splitters
|
| 54 |
+
train_df = df[df[split_col] == "train"].copy()
|
| 55 |
+
val_df = df[df[split_col] == "validation"].copy()
|
| 56 |
+
test_df = df[df[split_col] == "test"].copy()
|
| 57 |
+
|
| 58 |
+
# Verify row conservation
|
| 59 |
+
total_split_rows = len(train_df) + len(val_df) + len(test_df)
|
| 60 |
+
if total_split_rows != len(df):
|
| 61 |
+
raise DatasetError(
|
| 62 |
+
f"Row conservation violated: split rows ({total_split_rows}) "
|
| 63 |
+
f"!= original rows ({len(df)}). "
|
| 64 |
+
f"This indicates data corruption or unexpected split values."
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
return train_df, val_df, test_df
|
app/app/data/validator.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dataset validator for integrity checks.
|
| 2 |
+
|
| 3 |
+
Validates dataset integrity against metadata expectations: table presence,
|
| 4 |
+
row counts, null values, foreign key relationships, split columns, and
|
| 5 |
+
target label columns. Uses the collect-all-errors pattern — reports every
|
| 6 |
+
issue in one pass rather than failing fast.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import pandas as pd
|
| 15 |
+
|
| 16 |
+
from app.core.exceptions import DatasetError
|
| 17 |
+
from app.data.loader import DatasetLoader
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# Tables that must contain a train_split column with exactly {train, validation, test}
|
| 23 |
+
TRAINING_TABLES: list[str] = [
|
| 24 |
+
"training_lo_tagging",
|
| 25 |
+
"training_bloom_classification",
|
| 26 |
+
"training_risk_prediction",
|
| 27 |
+
"training_mastery_prediction",
|
| 28 |
+
"training_answer_scoring",
|
| 29 |
+
"training_recommendation_outcomes",
|
| 30 |
+
"learning_outcomes",
|
| 31 |
+
"questions",
|
| 32 |
+
"student_profiles",
|
| 33 |
+
"student_attempts",
|
| 34 |
+
"mastery_profiles",
|
| 35 |
+
"engagement_logs",
|
| 36 |
+
"risk_profiles",
|
| 37 |
+
"recommendations",
|
| 38 |
+
"content_catalog",
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
# Foreign key relationships: (child_table, child_column, parent_table, parent_column)
|
| 42 |
+
FOREIGN_KEY_RELATIONSHIPS: list[tuple[str, str, str, str]] = [
|
| 43 |
+
("student_attempts", "student_id", "student_profiles", "student_id"),
|
| 44 |
+
("student_attempts", "question_id", "questions", "question_id"),
|
| 45 |
+
("questions", "lo_id", "learning_outcomes", "lo_id"),
|
| 46 |
+
("lo_dependencies", "lo_id", "learning_outcomes", "lo_id"),
|
| 47 |
+
("lo_dependencies", "prerequisite_lo_id", "learning_outcomes", "lo_id"),
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
# Target columns that must exist in their respective tables
|
| 51 |
+
TARGET_COLUMNS: list[tuple[str, str]] = [
|
| 52 |
+
("training_lo_tagging", "lo_id"),
|
| 53 |
+
("training_bloom_classification", "bloom_level"),
|
| 54 |
+
("training_mastery_prediction", "mastery_label"),
|
| 55 |
+
("training_risk_prediction", "risk_label"),
|
| 56 |
+
("training_risk_prediction", "risk_level"),
|
| 57 |
+
("training_answer_scoring", "teacher_marks"),
|
| 58 |
+
("training_recommendation_outcomes", "clicked"),
|
| 59 |
+
("training_recommendation_outcomes", "is_completed"),
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
VALID_SPLITS: set[str] = {"train", "validation", "test"}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@dataclass
|
| 66 |
+
class ValidationIssue:
|
| 67 |
+
"""A single validation issue found during dataset checks."""
|
| 68 |
+
|
| 69 |
+
check: str
|
| 70 |
+
table: str
|
| 71 |
+
column: str | None
|
| 72 |
+
message: str
|
| 73 |
+
severity: str # "error" | "warning"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@dataclass
|
| 77 |
+
class ValidationReport:
|
| 78 |
+
"""Aggregated result of all validation checks."""
|
| 79 |
+
|
| 80 |
+
timestamp: datetime
|
| 81 |
+
passed: bool
|
| 82 |
+
issues: list[ValidationIssue] = field(default_factory=list)
|
| 83 |
+
checks_run: int = 0
|
| 84 |
+
checks_passed: int = 0
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class DatasetValidator:
|
| 88 |
+
"""Validates dataset integrity against metadata expectations.
|
| 89 |
+
|
| 90 |
+
Uses the collect-all-errors pattern: every check runs to completion
|
| 91 |
+
and all issues are aggregated into a single report.
|
| 92 |
+
"""
|
| 93 |
+
|
| 94 |
+
def __init__(self, loader: DatasetLoader, metadata: dict) -> None:
|
| 95 |
+
self._loader = loader
|
| 96 |
+
self._metadata = metadata
|
| 97 |
+
|
| 98 |
+
def check_table_presence(self) -> list[ValidationIssue]:
|
| 99 |
+
"""Verify all expected CSV files from metadata are present.
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
List of ValidationIssue for any missing tables.
|
| 103 |
+
"""
|
| 104 |
+
issues: list[ValidationIssue] = []
|
| 105 |
+
table_counts = self._metadata.get("table_counts", {})
|
| 106 |
+
|
| 107 |
+
for table_file in table_counts:
|
| 108 |
+
table_name = table_file.replace(".csv", "")
|
| 109 |
+
path = self._loader.get_table_path(table_name)
|
| 110 |
+
if not path.exists():
|
| 111 |
+
issues.append(
|
| 112 |
+
ValidationIssue(
|
| 113 |
+
check="table_presence",
|
| 114 |
+
table=table_name,
|
| 115 |
+
column=None,
|
| 116 |
+
message=f"Expected CSV file not found: {path}",
|
| 117 |
+
severity="error",
|
| 118 |
+
)
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
return issues
|
| 122 |
+
|
| 123 |
+
def check_row_counts(self) -> list[ValidationIssue]:
|
| 124 |
+
"""Compare actual row counts against metadata expected counts.
|
| 125 |
+
|
| 126 |
+
Returns:
|
| 127 |
+
List of ValidationIssue for any row count mismatches.
|
| 128 |
+
"""
|
| 129 |
+
issues: list[ValidationIssue] = []
|
| 130 |
+
table_counts = self._metadata.get("table_counts", {})
|
| 131 |
+
|
| 132 |
+
for table_file, expected_count in table_counts.items():
|
| 133 |
+
table_name = table_file.replace(".csv", "")
|
| 134 |
+
try:
|
| 135 |
+
df = self._loader.load_table(table_name)
|
| 136 |
+
except DatasetError:
|
| 137 |
+
# Table missing — already reported by check_table_presence
|
| 138 |
+
continue
|
| 139 |
+
|
| 140 |
+
actual_count = len(df)
|
| 141 |
+
if actual_count != expected_count:
|
| 142 |
+
issues.append(
|
| 143 |
+
ValidationIssue(
|
| 144 |
+
check="row_count",
|
| 145 |
+
table=table_name,
|
| 146 |
+
column=None,
|
| 147 |
+
message=(
|
| 148 |
+
f"Row count mismatch: expected {expected_count}, "
|
| 149 |
+
f"got {actual_count}"
|
| 150 |
+
),
|
| 151 |
+
severity="error",
|
| 152 |
+
)
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
return issues
|
| 156 |
+
|
| 157 |
+
def check_null_values(self) -> list[ValidationIssue]:
|
| 158 |
+
"""Confirm no CSV file contains any null values.
|
| 159 |
+
|
| 160 |
+
Returns:
|
| 161 |
+
List of ValidationIssue for any columns with null values.
|
| 162 |
+
"""
|
| 163 |
+
issues: list[ValidationIssue] = []
|
| 164 |
+
table_counts = self._metadata.get("table_counts", {})
|
| 165 |
+
|
| 166 |
+
for table_file in table_counts:
|
| 167 |
+
table_name = table_file.replace(".csv", "")
|
| 168 |
+
try:
|
| 169 |
+
df = self._loader.load_table(table_name)
|
| 170 |
+
except DatasetError:
|
| 171 |
+
continue
|
| 172 |
+
|
| 173 |
+
null_counts = df.isnull().sum()
|
| 174 |
+
for col, count in null_counts.items():
|
| 175 |
+
if count > 0:
|
| 176 |
+
issues.append(
|
| 177 |
+
ValidationIssue(
|
| 178 |
+
check="null_check",
|
| 179 |
+
table=table_name,
|
| 180 |
+
column=str(col),
|
| 181 |
+
message=f"Column '{col}' has {count} null value(s)",
|
| 182 |
+
severity="error",
|
| 183 |
+
)
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
return issues
|
| 187 |
+
|
| 188 |
+
def check_foreign_keys(self) -> list[ValidationIssue]:
|
| 189 |
+
"""Validate all defined foreign key relationships.
|
| 190 |
+
|
| 191 |
+
Checks that all values in child columns are present in the
|
| 192 |
+
corresponding parent column.
|
| 193 |
+
|
| 194 |
+
Returns:
|
| 195 |
+
List of ValidationIssue for any FK violations.
|
| 196 |
+
"""
|
| 197 |
+
issues: list[ValidationIssue] = []
|
| 198 |
+
|
| 199 |
+
for child_table, child_col, parent_table, parent_col in FOREIGN_KEY_RELATIONSHIPS:
|
| 200 |
+
try:
|
| 201 |
+
child_df = self._loader.load_table(child_table)
|
| 202 |
+
parent_df = self._loader.load_table(parent_table)
|
| 203 |
+
except DatasetError:
|
| 204 |
+
# Tables missing — already reported by check_table_presence
|
| 205 |
+
continue
|
| 206 |
+
|
| 207 |
+
if child_col not in child_df.columns:
|
| 208 |
+
issues.append(
|
| 209 |
+
ValidationIssue(
|
| 210 |
+
check="foreign_key",
|
| 211 |
+
table=child_table,
|
| 212 |
+
column=child_col,
|
| 213 |
+
message=(
|
| 214 |
+
f"FK column '{child_col}' not found in table '{child_table}'"
|
| 215 |
+
),
|
| 216 |
+
severity="error",
|
| 217 |
+
)
|
| 218 |
+
)
|
| 219 |
+
continue
|
| 220 |
+
|
| 221 |
+
if parent_col not in parent_df.columns:
|
| 222 |
+
issues.append(
|
| 223 |
+
ValidationIssue(
|
| 224 |
+
check="foreign_key",
|
| 225 |
+
table=parent_table,
|
| 226 |
+
column=parent_col,
|
| 227 |
+
message=(
|
| 228 |
+
f"Referenced column '{parent_col}' not found in "
|
| 229 |
+
f"table '{parent_table}'"
|
| 230 |
+
),
|
| 231 |
+
severity="error",
|
| 232 |
+
)
|
| 233 |
+
)
|
| 234 |
+
continue
|
| 235 |
+
|
| 236 |
+
child_values = set(child_df[child_col].dropna().unique())
|
| 237 |
+
parent_values = set(parent_df[parent_col].dropna().unique())
|
| 238 |
+
orphans = child_values - parent_values
|
| 239 |
+
|
| 240 |
+
if orphans:
|
| 241 |
+
sample = sorted(str(v) for v in list(orphans)[:5])
|
| 242 |
+
issues.append(
|
| 243 |
+
ValidationIssue(
|
| 244 |
+
check="foreign_key",
|
| 245 |
+
table=child_table,
|
| 246 |
+
column=child_col,
|
| 247 |
+
message=(
|
| 248 |
+
f"FK violation: {len(orphans)} value(s) in "
|
| 249 |
+
f"'{child_table}.{child_col}' not found in "
|
| 250 |
+
f"'{parent_table}.{parent_col}'. "
|
| 251 |
+
f"Sample: {sample}"
|
| 252 |
+
),
|
| 253 |
+
severity="error",
|
| 254 |
+
)
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
return issues
|
| 258 |
+
|
| 259 |
+
def check_split_presence(self) -> list[ValidationIssue]:
|
| 260 |
+
"""Verify training tables have train_split column with exactly {train, validation, test}.
|
| 261 |
+
|
| 262 |
+
Returns:
|
| 263 |
+
List of ValidationIssue for any split column problems.
|
| 264 |
+
"""
|
| 265 |
+
issues: list[ValidationIssue] = []
|
| 266 |
+
|
| 267 |
+
for table_name in TRAINING_TABLES:
|
| 268 |
+
try:
|
| 269 |
+
df = self._loader.load_table(table_name)
|
| 270 |
+
except DatasetError:
|
| 271 |
+
continue
|
| 272 |
+
|
| 273 |
+
if "train_split" not in df.columns:
|
| 274 |
+
issues.append(
|
| 275 |
+
ValidationIssue(
|
| 276 |
+
check="split_presence",
|
| 277 |
+
table=table_name,
|
| 278 |
+
column="train_split",
|
| 279 |
+
message=(
|
| 280 |
+
f"Training table '{table_name}' is missing "
|
| 281 |
+
f"'train_split' column"
|
| 282 |
+
),
|
| 283 |
+
severity="error",
|
| 284 |
+
)
|
| 285 |
+
)
|
| 286 |
+
continue
|
| 287 |
+
|
| 288 |
+
actual_splits = set(df["train_split"].dropna().unique())
|
| 289 |
+
|
| 290 |
+
if actual_splits != VALID_SPLITS:
|
| 291 |
+
missing = VALID_SPLITS - actual_splits
|
| 292 |
+
extra = actual_splits - VALID_SPLITS
|
| 293 |
+
parts = []
|
| 294 |
+
if missing:
|
| 295 |
+
parts.append(f"missing splits: {sorted(missing)}")
|
| 296 |
+
if extra:
|
| 297 |
+
parts.append(f"unexpected splits: {sorted(extra)}")
|
| 298 |
+
issues.append(
|
| 299 |
+
ValidationIssue(
|
| 300 |
+
check="split_presence",
|
| 301 |
+
table=table_name,
|
| 302 |
+
column="train_split",
|
| 303 |
+
message=(
|
| 304 |
+
f"Split values mismatch in '{table_name}': "
|
| 305 |
+
f"{'; '.join(parts)}. "
|
| 306 |
+
f"Expected exactly {{train, validation, test}}, "
|
| 307 |
+
f"got {sorted(actual_splits)}"
|
| 308 |
+
),
|
| 309 |
+
severity="error",
|
| 310 |
+
)
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
return issues
|
| 314 |
+
|
| 315 |
+
def check_target_labels(self) -> list[ValidationIssue]:
|
| 316 |
+
"""Verify target columns exist in their respective training tables.
|
| 317 |
+
|
| 318 |
+
Returns:
|
| 319 |
+
List of ValidationIssue for any missing target columns.
|
| 320 |
+
"""
|
| 321 |
+
issues: list[ValidationIssue] = []
|
| 322 |
+
|
| 323 |
+
for table_name, column_name in TARGET_COLUMNS:
|
| 324 |
+
try:
|
| 325 |
+
df = self._loader.load_table(table_name)
|
| 326 |
+
except DatasetError:
|
| 327 |
+
continue
|
| 328 |
+
|
| 329 |
+
if column_name not in df.columns:
|
| 330 |
+
issues.append(
|
| 331 |
+
ValidationIssue(
|
| 332 |
+
check="target_labels",
|
| 333 |
+
table=table_name,
|
| 334 |
+
column=column_name,
|
| 335 |
+
message=(
|
| 336 |
+
f"Target column '{column_name}' not found in "
|
| 337 |
+
f"table '{table_name}'"
|
| 338 |
+
),
|
| 339 |
+
severity="error",
|
| 340 |
+
)
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
return issues
|
| 344 |
+
|
| 345 |
+
def run_all(self) -> ValidationReport:
|
| 346 |
+
"""Execute all validation checks and aggregate results.
|
| 347 |
+
|
| 348 |
+
Uses the collect-all-errors pattern: every check runs regardless
|
| 349 |
+
of whether previous checks found issues.
|
| 350 |
+
|
| 351 |
+
Returns:
|
| 352 |
+
A ValidationReport with all issues and pass/fail status.
|
| 353 |
+
"""
|
| 354 |
+
all_issues: list[ValidationIssue] = []
|
| 355 |
+
checks_run = 0
|
| 356 |
+
checks_passed = 0
|
| 357 |
+
|
| 358 |
+
checks = [
|
| 359 |
+
("table_presence", self.check_table_presence),
|
| 360 |
+
("row_counts", self.check_row_counts),
|
| 361 |
+
("null_values", self.check_null_values),
|
| 362 |
+
("foreign_keys", self.check_foreign_keys),
|
| 363 |
+
("split_presence", self.check_split_presence),
|
| 364 |
+
("target_labels", self.check_target_labels),
|
| 365 |
+
]
|
| 366 |
+
|
| 367 |
+
for check_name, check_fn in checks:
|
| 368 |
+
checks_run += 1
|
| 369 |
+
try:
|
| 370 |
+
issues = check_fn()
|
| 371 |
+
all_issues.extend(issues)
|
| 372 |
+
if not issues:
|
| 373 |
+
checks_passed += 1
|
| 374 |
+
logger.info(
|
| 375 |
+
"Check '%s': %s (%d issue(s))",
|
| 376 |
+
check_name,
|
| 377 |
+
"PASS" if not issues else "FAIL",
|
| 378 |
+
len(issues),
|
| 379 |
+
)
|
| 380 |
+
except Exception as exc:
|
| 381 |
+
all_issues.append(
|
| 382 |
+
ValidationIssue(
|
| 383 |
+
check=check_name,
|
| 384 |
+
table="",
|
| 385 |
+
column=None,
|
| 386 |
+
message=f"Check raised unexpected error: {exc}",
|
| 387 |
+
severity="error",
|
| 388 |
+
)
|
| 389 |
+
)
|
| 390 |
+
logger.error("Check '%s' raised an exception: %s", check_name, exc)
|
| 391 |
+
|
| 392 |
+
report = ValidationReport(
|
| 393 |
+
timestamp=datetime.now(timezone.utc),
|
| 394 |
+
passed=len(all_issues) == 0,
|
| 395 |
+
issues=all_issues,
|
| 396 |
+
checks_run=checks_run,
|
| 397 |
+
checks_passed=checks_passed,
|
| 398 |
+
)
|
| 399 |
+
|
| 400 |
+
logger.info(
|
| 401 |
+
"Validation complete: %d/%d checks passed, %d total issue(s)",
|
| 402 |
+
checks_passed,
|
| 403 |
+
checks_run,
|
| 404 |
+
len(all_issues),
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
return report
|
| 408 |
+
|
| 409 |
+
def write_report(self, report: ValidationReport, output_path: Path) -> None:
|
| 410 |
+
"""Write a markdown validation report to the specified path.
|
| 411 |
+
|
| 412 |
+
Creates parent directories if they don't exist. The report includes
|
| 413 |
+
a timestamp, overall pass/fail status, and details for each issue.
|
| 414 |
+
|
| 415 |
+
Args:
|
| 416 |
+
report: The ValidationReport to write.
|
| 417 |
+
output_path: Path where the markdown report will be written.
|
| 418 |
+
"""
|
| 419 |
+
output_path = Path(output_path)
|
| 420 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 421 |
+
|
| 422 |
+
lines: list[str] = []
|
| 423 |
+
lines.append("# Dataset Validation Report")
|
| 424 |
+
lines.append("")
|
| 425 |
+
lines.append(f"**Timestamp:** {report.timestamp.isoformat()}")
|
| 426 |
+
lines.append(f"**Status:** {'PASSED ✓' if report.passed else 'FAILED ✗'}")
|
| 427 |
+
lines.append(f"**Checks Run:** {report.checks_run}")
|
| 428 |
+
lines.append(f"**Checks Passed:** {report.checks_passed}")
|
| 429 |
+
lines.append(f"**Total Issues:** {len(report.issues)}")
|
| 430 |
+
lines.append("")
|
| 431 |
+
|
| 432 |
+
# Summary table of checks
|
| 433 |
+
lines.append("## Check Summary")
|
| 434 |
+
lines.append("")
|
| 435 |
+
lines.append("| Check | Status |")
|
| 436 |
+
lines.append("|-------|--------|")
|
| 437 |
+
|
| 438 |
+
check_names = [
|
| 439 |
+
"table_presence",
|
| 440 |
+
"row_count",
|
| 441 |
+
"null_check",
|
| 442 |
+
"foreign_key",
|
| 443 |
+
"split_presence",
|
| 444 |
+
"target_labels",
|
| 445 |
+
]
|
| 446 |
+
check_display = {
|
| 447 |
+
"table_presence": "Table Presence",
|
| 448 |
+
"row_count": "Row Counts",
|
| 449 |
+
"null_check": "Null Values",
|
| 450 |
+
"foreign_key": "Foreign Keys",
|
| 451 |
+
"split_presence": "Split Presence",
|
| 452 |
+
"target_labels": "Target Labels",
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
failed_checks = {issue.check for issue in report.issues}
|
| 456 |
+
for check in check_names:
|
| 457 |
+
status = "✗ FAIL" if check in failed_checks else "✓ PASS"
|
| 458 |
+
display = check_display.get(check, check)
|
| 459 |
+
lines.append(f"| {display} | {status} |")
|
| 460 |
+
|
| 461 |
+
lines.append("")
|
| 462 |
+
|
| 463 |
+
if report.issues:
|
| 464 |
+
lines.append("## Issues")
|
| 465 |
+
lines.append("")
|
| 466 |
+
for i, issue in enumerate(report.issues, 1):
|
| 467 |
+
col_info = f" (column: `{issue.column}`)" if issue.column else ""
|
| 468 |
+
lines.append(
|
| 469 |
+
f"{i}. **[{issue.severity.upper()}]** `{issue.table}`{col_info}: "
|
| 470 |
+
f"{issue.message}"
|
| 471 |
+
)
|
| 472 |
+
lines.append("")
|
| 473 |
+
else:
|
| 474 |
+
lines.append("## Result")
|
| 475 |
+
lines.append("")
|
| 476 |
+
lines.append("All validation checks passed. Dataset is ready for use.")
|
| 477 |
+
lines.append("")
|
| 478 |
+
|
| 479 |
+
content = "\n".join(lines)
|
| 480 |
+
output_path.write_text(content, encoding="utf-8")
|
| 481 |
+
logger.info("Validation report written to %s", output_path)
|
app/app/main.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application entry point.
|
| 2 |
+
|
| 3 |
+
Configures the app instance, middleware, exception handlers, and startup hooks.
|
| 4 |
+
Launch with: uvicorn app.main:app
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
import uuid
|
| 9 |
+
from contextlib import asynccontextmanager
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from fastapi import FastAPI, Request
|
| 13 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
+
from fastapi.responses import JSONResponse
|
| 15 |
+
|
| 16 |
+
from app.api.v2.answer_evaluation import router as answer_evaluation_router
|
| 17 |
+
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
|
| 24 |
+
from app.api.v2.recommendations import router as recommendations_router
|
| 25 |
+
from app.api.v2.risk import router as risk_router
|
| 26 |
+
from app.api.v2.student_profile import router as student_profile_router
|
| 27 |
+
from app.api.v2.teacher_feedback import router as teacher_feedback_router
|
| 28 |
+
from app.core.config import settings
|
| 29 |
+
from app.core.exceptions import DatasetError, EntityNotFoundError, ModelNotLoadedError
|
| 30 |
+
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
|
| 40 |
+
from app.services.recommendation_service import RecommendationService
|
| 41 |
+
from app.services.risk_service import RiskService
|
| 42 |
+
from app.services.student_profile_service import StudentProfileService
|
| 43 |
+
from app.services.teacher_feedback_service import TeacherFeedbackService
|
| 44 |
+
|
| 45 |
+
logger = logging.getLogger(__name__)
|
| 46 |
+
|
| 47 |
+
# Internal development origins — no wildcard "*" in production.
|
| 48 |
+
ALLOWED_ORIGINS = [
|
| 49 |
+
"http://localhost:3000",
|
| 50 |
+
"http://localhost:8000",
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _cache_dataset_metadata(app: FastAPI) -> None:
|
| 55 |
+
"""Load and cache dataset metadata for the data summary endpoint."""
|
| 56 |
+
try:
|
| 57 |
+
loader = DatasetLoader(settings.dataset_dir)
|
| 58 |
+
metadata = loader.load_metadata()
|
| 59 |
+
|
| 60 |
+
table_counts: dict[str, int] = metadata.get("table_counts", {})
|
| 61 |
+
|
| 62 |
+
app.state.dataset_metadata = {
|
| 63 |
+
"version": metadata.get("version", "unknown"),
|
| 64 |
+
"table_counts": table_counts,
|
| 65 |
+
"validation_status": "not_run",
|
| 66 |
+
"total_issues": 0,
|
| 67 |
+
}
|
| 68 |
+
logger.info("Dataset metadata cached successfully — %d tables", len(table_counts))
|
| 69 |
+
except DatasetError as exc:
|
| 70 |
+
logger.warning("Failed to cache dataset metadata: %s", exc)
|
| 71 |
+
app.state.dataset_metadata = {
|
| 72 |
+
"version": "unknown",
|
| 73 |
+
"table_counts": {},
|
| 74 |
+
"validation_status": "not_run",
|
| 75 |
+
"total_issues": 0,
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@asynccontextmanager
|
| 80 |
+
async def lifespan(app: FastAPI):
|
| 81 |
+
"""Application lifespan: startup and shutdown hooks."""
|
| 82 |
+
# 1. Cache dataset metadata (existing)
|
| 83 |
+
_cache_dataset_metadata(app)
|
| 84 |
+
|
| 85 |
+
# 2. Initialize model registry and load all models
|
| 86 |
+
registry = ModelRegistry(settings.model_artifact_dir)
|
| 87 |
+
registry.load_all()
|
| 88 |
+
|
| 89 |
+
# 3. Initialize shared components
|
| 90 |
+
loader = DatasetLoader(settings.dataset_dir)
|
| 91 |
+
explainer = ExplanationService()
|
| 92 |
+
pred_logger = PredictionLogger(
|
| 93 |
+
log_dir=Path(settings.metrics_dir),
|
| 94 |
+
salt=settings.log_salt,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# 4. Initialize service container with DI
|
| 98 |
+
app.state.services = ServiceContainer(
|
| 99 |
+
registry=registry,
|
| 100 |
+
loader=loader,
|
| 101 |
+
explainer=explainer,
|
| 102 |
+
pred_logger=pred_logger,
|
| 103 |
+
lo_tagging=LOTaggingService(registry, loader, explainer, pred_logger),
|
| 104 |
+
bloom=BloomService(registry, loader, explainer, pred_logger),
|
| 105 |
+
mastery=MasteryService(registry, loader, explainer, pred_logger),
|
| 106 |
+
risk=RiskService(registry, loader, explainer, pred_logger),
|
| 107 |
+
recommendation=RecommendationService(registry, loader, explainer, pred_logger),
|
| 108 |
+
answer_evaluation=AnswerEvaluationService(registry, loader, explainer, pred_logger),
|
| 109 |
+
student_profile=StudentProfileService(loader),
|
| 110 |
+
class_insights=ClassInsightsService(loader),
|
| 111 |
+
teacher_feedback=TeacherFeedbackService(),
|
| 112 |
+
monitoring=MonitoringService(registry, pred_logger),
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
logger.info("Service container initialized — model registry and shared components ready")
|
| 116 |
+
|
| 117 |
+
yield
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
app = FastAPI(
|
| 121 |
+
title=settings.ai_service_name,
|
| 122 |
+
version=settings.ai_service_version,
|
| 123 |
+
lifespan=lifespan,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# CORS middleware — internal origins only, no wildcard "*".
|
| 127 |
+
app.add_middleware(
|
| 128 |
+
CORSMiddleware,
|
| 129 |
+
allow_origins=ALLOWED_ORIGINS,
|
| 130 |
+
allow_credentials=True,
|
| 131 |
+
allow_methods=["*"],
|
| 132 |
+
allow_headers=["*"],
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
# Include routers
|
| 136 |
+
app.include_router(health_router)
|
| 137 |
+
app.include_router(lo_tagging_router)
|
| 138 |
+
app.include_router(bloom_router)
|
| 139 |
+
app.include_router(mastery_router)
|
| 140 |
+
app.include_router(risk_router)
|
| 141 |
+
app.include_router(recommendations_router)
|
| 142 |
+
app.include_router(answer_evaluation_router)
|
| 143 |
+
app.include_router(student_profile_router)
|
| 144 |
+
app.include_router(class_insights_router)
|
| 145 |
+
app.include_router(teacher_feedback_router)
|
| 146 |
+
app.include_router(monitoring_router)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# --- Exception Handlers ---
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@app.exception_handler(DatasetError)
|
| 153 |
+
async def dataset_error_handler(request: Request, exc: DatasetError) -> JSONResponse:
|
| 154 |
+
"""Handle DatasetError — returns 500 with INTERNAL error code."""
|
| 155 |
+
request_id = str(uuid.uuid4())
|
| 156 |
+
logger.error("DatasetError [request_id=%s]: %s", request_id, exc)
|
| 157 |
+
return JSONResponse(
|
| 158 |
+
status_code=500,
|
| 159 |
+
content={
|
| 160 |
+
"error": {
|
| 161 |
+
"code": "INTERNAL",
|
| 162 |
+
"message": str(exc),
|
| 163 |
+
"request_id": request_id,
|
| 164 |
+
}
|
| 165 |
+
},
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
@app.exception_handler(ModelNotLoadedError)
|
| 170 |
+
async def model_not_loaded_handler(request: Request, exc: ModelNotLoadedError) -> JSONResponse:
|
| 171 |
+
"""Handle ModelNotLoadedError — returns 503 with MODEL_NOT_LOADED error code."""
|
| 172 |
+
request_id = str(uuid.uuid4())
|
| 173 |
+
logger.error("ModelNotLoadedError [request_id=%s]: %s", request_id, exc)
|
| 174 |
+
return JSONResponse(
|
| 175 |
+
status_code=503,
|
| 176 |
+
content={
|
| 177 |
+
"error": {
|
| 178 |
+
"code": "MODEL_NOT_LOADED",
|
| 179 |
+
"message": str(exc),
|
| 180 |
+
"request_id": request_id,
|
| 181 |
+
}
|
| 182 |
+
},
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@app.exception_handler(EntityNotFoundError)
|
| 187 |
+
async def entity_not_found_handler(request: Request, exc: EntityNotFoundError) -> JSONResponse:
|
| 188 |
+
"""Handle EntityNotFoundError — returns 404 with NOT_FOUND error code."""
|
| 189 |
+
request_id = str(uuid.uuid4())
|
| 190 |
+
logger.warning("EntityNotFoundError [request_id=%s]: %s", request_id, exc)
|
| 191 |
+
return JSONResponse(
|
| 192 |
+
status_code=404,
|
| 193 |
+
content={
|
| 194 |
+
"error": {
|
| 195 |
+
"code": "NOT_FOUND",
|
| 196 |
+
"message": str(exc),
|
| 197 |
+
"request_id": request_id,
|
| 198 |
+
}
|
| 199 |
+
},
|
| 200 |
+
)
|
app/app/models/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Models package — model registry and artifact management."""
|
| 2 |
+
|
| 3 |
+
from app.models.registry import ModelRegistry, REGISTERED_MODELS
|
| 4 |
+
|
| 5 |
+
__all__ = ["ModelRegistry", "REGISTERED_MODELS"]
|
app/app/models/registry.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model Registry for loading, caching, and serving trained model artifacts.
|
| 2 |
+
|
| 3 |
+
Supports eager loading at startup and lazy loading on first access.
|
| 4 |
+
Reports per-model health status.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
import joblib
|
| 13 |
+
|
| 14 |
+
from app.core.exceptions import ModelNotLoadedError
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
# All registered model names and their artifact subdirectory names
|
| 19 |
+
REGISTERED_MODELS: list[str] = [
|
| 20 |
+
"lo_tagger",
|
| 21 |
+
"bloom_classifier",
|
| 22 |
+
"mastery_model",
|
| 23 |
+
"risk_model",
|
| 24 |
+
"answer_scorer",
|
| 25 |
+
"recommender",
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ModelRegistry:
|
| 30 |
+
"""Loads and manages trained model artifacts.
|
| 31 |
+
|
| 32 |
+
Supports eager loading at startup and lazy loading on first access.
|
| 33 |
+
Reports per-model health status.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
def __init__(self, artifact_dir: str | Path) -> None:
|
| 37 |
+
self._artifact_dir = Path(artifact_dir)
|
| 38 |
+
self._models: dict[str, dict[str, Any]] = {}
|
| 39 |
+
self._status: dict[str, str] = {} # "loaded" | "not_loaded" | "error"
|
| 40 |
+
self._metadata: dict[str, dict] = {} # metrics.json content per model
|
| 41 |
+
|
| 42 |
+
# Initialize all registered models as not_loaded
|
| 43 |
+
for model_name in REGISTERED_MODELS:
|
| 44 |
+
self._status[model_name] = "not_loaded"
|
| 45 |
+
|
| 46 |
+
def load_all(self) -> None:
|
| 47 |
+
"""Eagerly load all model artifacts from artifact_dir subdirectories."""
|
| 48 |
+
for model_name in REGISTERED_MODELS:
|
| 49 |
+
self._load_model(model_name)
|
| 50 |
+
|
| 51 |
+
def _load_model(self, model_name: str) -> None:
|
| 52 |
+
"""Load a single model's artifacts from its subdirectory.
|
| 53 |
+
|
| 54 |
+
On failure, sets status to 'error' and logs the exception without crashing.
|
| 55 |
+
"""
|
| 56 |
+
model_dir = self._artifact_dir / model_name
|
| 57 |
+
|
| 58 |
+
if not model_dir.exists():
|
| 59 |
+
logger.warning(
|
| 60 |
+
"Model directory not found for '%s': %s", model_name, model_dir
|
| 61 |
+
)
|
| 62 |
+
self._status[model_name] = "not_loaded"
|
| 63 |
+
return
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
model_data: dict[str, Any] = {}
|
| 67 |
+
|
| 68 |
+
# Required: model.joblib
|
| 69 |
+
model_path = model_dir / "model.joblib"
|
| 70 |
+
if not model_path.exists():
|
| 71 |
+
logger.warning(
|
| 72 |
+
"model.joblib not found for '%s' at %s", model_name, model_path
|
| 73 |
+
)
|
| 74 |
+
self._status[model_name] = "not_loaded"
|
| 75 |
+
return
|
| 76 |
+
|
| 77 |
+
model_data["model"] = joblib.load(model_path)
|
| 78 |
+
|
| 79 |
+
# Optional: vectorizer.joblib
|
| 80 |
+
vectorizer_path = model_dir / "vectorizer.joblib"
|
| 81 |
+
if vectorizer_path.exists():
|
| 82 |
+
model_data["vectorizer"] = joblib.load(vectorizer_path)
|
| 83 |
+
|
| 84 |
+
# Optional: label_encoder.joblib
|
| 85 |
+
label_encoder_path = model_dir / "label_encoder.joblib"
|
| 86 |
+
if label_encoder_path.exists():
|
| 87 |
+
model_data["label_encoder"] = joblib.load(label_encoder_path)
|
| 88 |
+
|
| 89 |
+
# Optional: feature_columns.json
|
| 90 |
+
feature_columns_path = model_dir / "feature_columns.json"
|
| 91 |
+
if feature_columns_path.exists():
|
| 92 |
+
with open(feature_columns_path, "r", encoding="utf-8") as f:
|
| 93 |
+
model_data["feature_columns"] = json.load(f)
|
| 94 |
+
|
| 95 |
+
# Optional: metrics.json (stored as metadata)
|
| 96 |
+
metrics_path = model_dir / "metrics.json"
|
| 97 |
+
if metrics_path.exists():
|
| 98 |
+
with open(metrics_path, "r", encoding="utf-8") as f:
|
| 99 |
+
self._metadata[model_name] = json.load(f)
|
| 100 |
+
|
| 101 |
+
self._models[model_name] = model_data
|
| 102 |
+
self._status[model_name] = "loaded"
|
| 103 |
+
logger.info("Model '%s' loaded successfully from %s", model_name, model_dir)
|
| 104 |
+
|
| 105 |
+
except Exception:
|
| 106 |
+
logger.exception("Failed to load model '%s' from %s", model_name, model_dir)
|
| 107 |
+
self._status[model_name] = "error"
|
| 108 |
+
|
| 109 |
+
def get_model(self, model_name: str) -> dict[str, Any]:
|
| 110 |
+
"""Retrieve a loaded model by name. Triggers lazy load if not yet loaded.
|
| 111 |
+
|
| 112 |
+
Returns a dict containing 'model' and optional 'vectorizer',
|
| 113 |
+
'label_encoder', 'feature_columns' keys.
|
| 114 |
+
|
| 115 |
+
Raises ModelNotLoadedError if artifact is missing/corrupted after load attempt.
|
| 116 |
+
"""
|
| 117 |
+
if model_name not in self._status:
|
| 118 |
+
raise ModelNotLoadedError(
|
| 119 |
+
f"Model '{model_name}' is not registered in the registry."
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# Lazy loading: attempt to load if not yet loaded
|
| 123 |
+
if self._status[model_name] == "not_loaded":
|
| 124 |
+
self._load_model(model_name)
|
| 125 |
+
|
| 126 |
+
if self._status[model_name] != "loaded":
|
| 127 |
+
raise ModelNotLoadedError(
|
| 128 |
+
f"Model '{model_name}' is not available (status: {self._status[model_name]})."
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
return self._models[model_name]
|
| 132 |
+
|
| 133 |
+
def get_status(self, model_name: str) -> str:
|
| 134 |
+
"""Return 'loaded', 'not_loaded', or 'error' for a given model."""
|
| 135 |
+
return self._status.get(model_name, "not_loaded")
|
| 136 |
+
|
| 137 |
+
def get_metadata(self, model_name: str) -> dict | None:
|
| 138 |
+
"""Return metrics.json content for a model, or None if not available."""
|
| 139 |
+
return self._metadata.get(model_name)
|
| 140 |
+
|
| 141 |
+
def get_all_status(self) -> dict[str, dict]:
|
| 142 |
+
"""Return status + metadata for all registered models."""
|
| 143 |
+
result: dict[str, dict] = {}
|
| 144 |
+
for model_name in REGISTERED_MODELS:
|
| 145 |
+
entry: dict[str, Any] = {
|
| 146 |
+
"status": self._status.get(model_name, "not_loaded"),
|
| 147 |
+
}
|
| 148 |
+
metadata = self._metadata.get(model_name)
|
| 149 |
+
if metadata:
|
| 150 |
+
entry["metadata"] = metadata
|
| 151 |
+
result[model_name] = entry
|
| 152 |
+
return result
|
| 153 |
+
|
| 154 |
+
def is_loaded(self, model_name: str) -> bool:
|
| 155 |
+
"""Check if a model is loaded and ready for inference."""
|
| 156 |
+
return self._status.get(model_name) == "loaded"
|
app/app/monitoring/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Monitoring package
|
| 2 |
+
|
| 3 |
+
from app.monitoring.prediction_logger import PredictionLogger
|
| 4 |
+
|
| 5 |
+
__all__ = ["PredictionLogger"]
|
app/app/monitoring/prediction_logger.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prediction logger for monitoring and drift detection.
|
| 2 |
+
|
| 3 |
+
Logs every prediction to a JSONL file with anonymized student IDs.
|
| 4 |
+
Logging is synchronous but lightweight (append to file).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import hashlib
|
| 8 |
+
import json
|
| 9 |
+
import logging
|
| 10 |
+
from datetime import datetime, timezone
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
# Fields that must never appear in prediction logs (PII protection)
|
| 16 |
+
_PII_FIELDS = {"student_name", "student_answer", "parent_contact"}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class PredictionLogger:
|
| 20 |
+
"""Logs predictions to JSONL for monitoring and drift detection.
|
| 21 |
+
|
| 22 |
+
Logging is synchronous but lightweight (append to file).
|
| 23 |
+
Student IDs are anonymized via stable hash before logging.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def __init__(self, log_dir: Path, salt: str) -> None:
|
| 27 |
+
self._log_dir = Path(log_dir)
|
| 28 |
+
self._log_dir.mkdir(parents=True, exist_ok=True)
|
| 29 |
+
self._log_path = self._log_dir / "prediction_logs.jsonl"
|
| 30 |
+
self._salt = salt
|
| 31 |
+
|
| 32 |
+
def log(
|
| 33 |
+
self,
|
| 34 |
+
prediction_id: str,
|
| 35 |
+
model_name: str,
|
| 36 |
+
model_version: str,
|
| 37 |
+
endpoint: str,
|
| 38 |
+
input_summary: dict,
|
| 39 |
+
output: dict,
|
| 40 |
+
source: str,
|
| 41 |
+
latency_ms: float,
|
| 42 |
+
) -> None:
|
| 43 |
+
"""Append a prediction log entry. Non-blocking, fire-and-forget."""
|
| 44 |
+
try:
|
| 45 |
+
# Anonymize student_id in input_summary if present
|
| 46 |
+
safe_input = self._sanitize_input_summary(input_summary)
|
| 47 |
+
|
| 48 |
+
entry = {
|
| 49 |
+
"prediction_id": prediction_id,
|
| 50 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 51 |
+
"model_name": model_name,
|
| 52 |
+
"model_version": model_version,
|
| 53 |
+
"endpoint": endpoint,
|
| 54 |
+
"input_summary": safe_input,
|
| 55 |
+
"output": output,
|
| 56 |
+
"source": source,
|
| 57 |
+
"latency_ms": latency_ms,
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
with open(self._log_path, "a", encoding="utf-8") as f:
|
| 61 |
+
f.write(json.dumps(entry) + "\n")
|
| 62 |
+
|
| 63 |
+
except Exception:
|
| 64 |
+
# Fire-and-forget: never let logging failures propagate
|
| 65 |
+
logger.exception("Failed to write prediction log entry")
|
| 66 |
+
|
| 67 |
+
def _anonymize_student_id(self, student_id: str) -> str:
|
| 68 |
+
"""SHA-256 hash with salt, truncated to 16 chars."""
|
| 69 |
+
return hashlib.sha256(
|
| 70 |
+
f"{self._salt}:{student_id}".encode()
|
| 71 |
+
).hexdigest()[:16]
|
| 72 |
+
|
| 73 |
+
def _sanitize_input_summary(self, input_summary: dict) -> dict:
|
| 74 |
+
"""Remove PII fields and anonymize student_id in input summary."""
|
| 75 |
+
safe = {}
|
| 76 |
+
for key, value in input_summary.items():
|
| 77 |
+
if key in _PII_FIELDS:
|
| 78 |
+
continue
|
| 79 |
+
if key == "student_id" and isinstance(value, str):
|
| 80 |
+
safe["student_id"] = self._anonymize_student_id(value)
|
| 81 |
+
else:
|
| 82 |
+
safe[key] = value
|
| 83 |
+
return safe
|
| 84 |
+
|
| 85 |
+
def get_recent_stats(self, model_name: str, hours: int = 24) -> dict:
|
| 86 |
+
"""Return prediction count and avg confidence for last N hours."""
|
| 87 |
+
count = 0
|
| 88 |
+
total_confidence = 0.0
|
| 89 |
+
|
| 90 |
+
if not self._log_path.exists():
|
| 91 |
+
return {"prediction_count": 0, "avg_confidence": None}
|
| 92 |
+
|
| 93 |
+
cutoff = datetime.now(timezone.utc).timestamp() - (hours * 3600)
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
with open(self._log_path, "r", encoding="utf-8") as f:
|
| 97 |
+
for line in f:
|
| 98 |
+
line = line.strip()
|
| 99 |
+
if not line:
|
| 100 |
+
continue
|
| 101 |
+
try:
|
| 102 |
+
entry = json.loads(line)
|
| 103 |
+
except json.JSONDecodeError:
|
| 104 |
+
continue
|
| 105 |
+
|
| 106 |
+
if entry.get("model_name") != model_name:
|
| 107 |
+
continue
|
| 108 |
+
|
| 109 |
+
# Parse timestamp and check if within window
|
| 110 |
+
ts_str = entry.get("timestamp", "")
|
| 111 |
+
try:
|
| 112 |
+
ts = datetime.fromisoformat(ts_str).timestamp()
|
| 113 |
+
except (ValueError, TypeError):
|
| 114 |
+
continue
|
| 115 |
+
|
| 116 |
+
if ts < cutoff:
|
| 117 |
+
continue
|
| 118 |
+
|
| 119 |
+
count += 1
|
| 120 |
+
# Extract confidence from output
|
| 121 |
+
output = entry.get("output", {})
|
| 122 |
+
confidence = output.get("confidence")
|
| 123 |
+
if confidence is not None:
|
| 124 |
+
try:
|
| 125 |
+
total_confidence += float(confidence)
|
| 126 |
+
except (ValueError, TypeError):
|
| 127 |
+
pass
|
| 128 |
+
|
| 129 |
+
except Exception:
|
| 130 |
+
logger.exception("Failed to read prediction log for stats")
|
| 131 |
+
return {"prediction_count": 0, "avg_confidence": None}
|
| 132 |
+
|
| 133 |
+
avg_confidence = (total_confidence / count) if count > 0 else None
|
| 134 |
+
return {
|
| 135 |
+
"prediction_count": count,
|
| 136 |
+
"avg_confidence": avg_confidence,
|
| 137 |
+
}
|
app/app/schemas/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Schemas package
|
app/app/schemas/answer_evaluation.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic schemas for the answer evaluation endpoint."""
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class AnswerEvaluationRequest(BaseModel):
|
| 7 |
+
"""Request model for POST /ai/v2/evaluate-answer."""
|
| 8 |
+
|
| 9 |
+
model_config = ConfigDict(extra="forbid")
|
| 10 |
+
|
| 11 |
+
question_id: str = Field(..., min_length=1, max_length=50)
|
| 12 |
+
question_text: str = Field(..., min_length=5, max_length=2000)
|
| 13 |
+
student_answer: str = Field(..., min_length=1, max_length=5000)
|
| 14 |
+
model_answer: str = Field(..., min_length=1, max_length=5000)
|
| 15 |
+
rubric: str = Field(..., min_length=1, max_length=2000)
|
| 16 |
+
max_marks: int = Field(..., ge=1, le=100)
|
| 17 |
+
grade: int = Field(..., ge=6, le=8)
|
| 18 |
+
subject: str
|
| 19 |
+
lo_id: str = Field(..., min_length=1, max_length=50)
|
| 20 |
+
bloom_level: str
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class AnswerEvaluationResponse(BaseModel):
|
| 24 |
+
"""Response model for POST /ai/v2/evaluate-answer."""
|
| 25 |
+
|
| 26 |
+
prediction_id: str
|
| 27 |
+
model_version: str
|
| 28 |
+
source: str
|
| 29 |
+
confidence: float = Field(..., ge=0.0, le=1.0)
|
| 30 |
+
timestamp: str
|
| 31 |
+
predicted_marks: float = Field(..., ge=0.0)
|
| 32 |
+
max_marks: int
|
| 33 |
+
concepts_covered: list[str]
|
| 34 |
+
missing_points: list[str]
|
| 35 |
+
feedback: str
|
| 36 |
+
teacher_review_required: bool # always True in V2
|
app/app/schemas/bloom.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic schemas for the Bloom classification endpoint.
|
| 2 |
+
|
| 3 |
+
POST /ai/v2/classify-bloom
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class BloomRequest(BaseModel):
|
| 10 |
+
"""Request model for Bloom taxonomy classification."""
|
| 11 |
+
|
| 12 |
+
model_config = ConfigDict(extra="forbid")
|
| 13 |
+
|
| 14 |
+
question_text: str = Field(..., min_length=5, max_length=2000)
|
| 15 |
+
grade: int = Field(..., ge=6, le=8)
|
| 16 |
+
subject: str
|
| 17 |
+
question_type: str = Field(..., min_length=1, max_length=50)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class BloomResponse(BaseModel):
|
| 21 |
+
"""Response model for Bloom taxonomy classification."""
|
| 22 |
+
|
| 23 |
+
prediction_id: str
|
| 24 |
+
model_version: str
|
| 25 |
+
source: str # "model" | "fallback_rule_based"
|
| 26 |
+
confidence: float = Field(..., ge=0.0, le=1.0)
|
| 27 |
+
timestamp: str
|
| 28 |
+
bloom_level: str # one of BLOOM_LEVELS
|
| 29 |
+
reason: str
|
app/app/schemas/class_insights.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic schemas for the class insights endpoint."""
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class WeakLOItem(BaseModel):
|
| 7 |
+
"""A single weak learning outcome entry in class insights."""
|
| 8 |
+
|
| 9 |
+
lo_id: str
|
| 10 |
+
title: str
|
| 11 |
+
average_mastery: float = Field(..., ge=0.0, le=1.0)
|
| 12 |
+
affected_students_count: int = Field(..., ge=0)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class InterventionItem(BaseModel):
|
| 16 |
+
"""A recommended intervention for a group of students."""
|
| 17 |
+
|
| 18 |
+
type: str
|
| 19 |
+
reason: str
|
| 20 |
+
students: list[str] # student_id list only, NO names
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ClassInsightsResponse(BaseModel):
|
| 24 |
+
"""Response model for GET /ai/v2/class-insights/{class_id}."""
|
| 25 |
+
|
| 26 |
+
class_id: str
|
| 27 |
+
subject: str | None
|
| 28 |
+
model_version: str
|
| 29 |
+
timestamp: str
|
| 30 |
+
weakest_learning_outcomes: list[WeakLOItem]
|
| 31 |
+
at_risk_students_count: int = Field(..., ge=0)
|
| 32 |
+
at_risk_students: list[str] # student_id only
|
| 33 |
+
recommended_interventions: list[InterventionItem]
|
| 34 |
+
lesson_plan_suggestion: str | None
|
app/app/schemas/health.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic response schemas for health and data summary endpoints."""
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, ConfigDict
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class HealthResponse(BaseModel):
|
| 7 |
+
"""Response model for GET /ai/v2/health."""
|
| 8 |
+
|
| 9 |
+
model_config = ConfigDict(extra="forbid")
|
| 10 |
+
|
| 11 |
+
status: str
|
| 12 |
+
service: str
|
| 13 |
+
version: str
|
| 14 |
+
models_loaded: bool
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class DataSummaryResponse(BaseModel):
|
| 18 |
+
"""Response model for GET /ai/v2/data/summary."""
|
| 19 |
+
|
| 20 |
+
model_config = ConfigDict(extra="forbid")
|
| 21 |
+
|
| 22 |
+
dataset_version: str
|
| 23 |
+
table_counts: dict[str, int]
|
| 24 |
+
validation_status: str
|
| 25 |
+
total_issues: int
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ErrorDetail(BaseModel):
|
| 29 |
+
"""Detail object within the error envelope."""
|
| 30 |
+
|
| 31 |
+
model_config = ConfigDict(extra="forbid")
|
| 32 |
+
|
| 33 |
+
code: str
|
| 34 |
+
message: str
|
| 35 |
+
request_id: str
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class ErrorResponse(BaseModel):
|
| 39 |
+
"""Standard error envelope for all API error responses."""
|
| 40 |
+
|
| 41 |
+
model_config = ConfigDict(extra="forbid")
|
| 42 |
+
|
| 43 |
+
error: ErrorDetail
|