work-sejal commited on
Commit
70ea7be
·
1 Parent(s): 319fe8e

Deploy AI service with FastAPI

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +12 -0
  2. Dockerfile +13 -0
  3. app/__init__.py +1 -0
  4. app/api/__init__.py +1 -0
  5. app/api/v2/__init__.py +1 -0
  6. app/api/v2/answer_evaluation.py +36 -0
  7. app/api/v2/bloom.py +35 -0
  8. app/api/v2/class_insights.py +27 -0
  9. app/api/v2/dependencies.py +98 -0
  10. app/api/v2/health.py +55 -0
  11. app/api/v2/lo_tagging.py +36 -0
  12. app/api/v2/mastery.py +28 -0
  13. app/api/v2/monitoring.py +30 -0
  14. app/api/v2/recommendations.py +31 -0
  15. app/api/v2/risk.py +28 -0
  16. app/api/v2/student_profile.py +25 -0
  17. app/api/v2/teacher_feedback.py +38 -0
  18. app/app/__init__.py +1 -0
  19. app/app/api/__init__.py +1 -0
  20. app/app/api/v2/__init__.py +1 -0
  21. app/app/api/v2/answer_evaluation.py +36 -0
  22. app/app/api/v2/bloom.py +35 -0
  23. app/app/api/v2/class_insights.py +27 -0
  24. app/app/api/v2/dependencies.py +98 -0
  25. app/app/api/v2/health.py +55 -0
  26. app/app/api/v2/lo_tagging.py +36 -0
  27. app/app/api/v2/mastery.py +28 -0
  28. app/app/api/v2/monitoring.py +30 -0
  29. app/app/api/v2/recommendations.py +31 -0
  30. app/app/api/v2/risk.py +28 -0
  31. app/app/api/v2/student_profile.py +25 -0
  32. app/app/api/v2/teacher_feedback.py +38 -0
  33. app/app/core/__init__.py +1 -0
  34. app/app/core/config.py +29 -0
  35. app/app/core/constants.py +30 -0
  36. app/app/core/exceptions.py +26 -0
  37. app/app/data/__init__.py +1 -0
  38. app/app/data/loader.py +124 -0
  39. app/app/data/split_manager.py +67 -0
  40. app/app/data/validator.py +481 -0
  41. app/app/main.py +200 -0
  42. app/app/models/__init__.py +5 -0
  43. app/app/models/registry.py +156 -0
  44. app/app/monitoring/__init__.py +5 -0
  45. app/app/monitoring/prediction_logger.py +137 -0
  46. app/app/schemas/__init__.py +1 -0
  47. app/app/schemas/answer_evaluation.py +36 -0
  48. app/app/schemas/bloom.py +29 -0
  49. app/app/schemas/class_insights.py +34 -0
  50. 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