Spaces:
Running
Running
github-actions[bot] commited on
Commit ยท
25943d2
1
Parent(s): a715f01
๐ Auto-deploy backend from GitHub (552770f)
Browse files- main.py +4 -0
- routes/risk_router.py +121 -0
- routes/tutor_checkin.py +143 -0
- services/wri_service.py +98 -0
- tests/test_risk_router.py +91 -0
- tests/test_wri_service.py +119 -0
main.py
CHANGED
|
@@ -100,6 +100,8 @@ from routes.video_routes import router as video_router
|
|
| 100 |
from routes.quiz_battle import router as quiz_battle_router
|
| 101 |
from routes.teacher_materials import router as teacher_materials_router
|
| 102 |
from routes.class_records_router import router as class_records_router
|
|
|
|
|
|
|
| 103 |
|
| 104 |
# Rate limiting (slowapi)
|
| 105 |
try:
|
|
@@ -1089,6 +1091,8 @@ app.include_router(video_router)
|
|
| 1089 |
app.include_router(quiz_battle_router)
|
| 1090 |
app.include_router(teacher_materials_router)
|
| 1091 |
app.include_router(class_records_router)
|
|
|
|
|
|
|
| 1092 |
|
| 1093 |
|
| 1094 |
# โโโ Global Exception Handler โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
|
|
| 100 |
from routes.quiz_battle import router as quiz_battle_router
|
| 101 |
from routes.teacher_materials import router as teacher_materials_router
|
| 102 |
from routes.class_records_router import router as class_records_router
|
| 103 |
+
from routes.risk_router import router as risk_router
|
| 104 |
+
from routes.tutor_checkin import router as tutor_checkin_router
|
| 105 |
|
| 106 |
# Rate limiting (slowapi)
|
| 107 |
try:
|
|
|
|
| 1091 |
app.include_router(quiz_battle_router)
|
| 1092 |
app.include_router(teacher_materials_router)
|
| 1093 |
app.include_router(class_records_router)
|
| 1094 |
+
app.include_router(risk_router)
|
| 1095 |
+
app.include_router(tutor_checkin_router)
|
| 1096 |
|
| 1097 |
|
| 1098 |
# โโโ Global Exception Handler โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
routes/risk_router.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
WRI (Weighted Risk Index) Computation Router.
|
| 3 |
+
|
| 4 |
+
POST /api/risk/compute -> Compute WRI for a single student
|
| 5 |
+
POST /api/risk/compute/batch -> Batch compute WRI for multiple students
|
| 6 |
+
|
| 7 |
+
Prevention-first 5-band classification:
|
| 8 |
+
WRI >= 88 -> safe
|
| 9 |
+
WRI >= 80 -> watch
|
| 10 |
+
WRI >= 75 -> intervene
|
| 11 |
+
WRI >= 68 -> critical
|
| 12 |
+
WRI < 68 -> at_risk
|
| 13 |
+
|
| 14 |
+
Aligned with DepEd DO No. 8, s. 2015 โ 75 is the failing floor, not the trigger.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import logging
|
| 20 |
+
from typing import Any, Dict, List, Optional
|
| 21 |
+
|
| 22 |
+
from fastapi import APIRouter, HTTPException
|
| 23 |
+
from pydantic import BaseModel
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger("mathpulse.risk")
|
| 26 |
+
|
| 27 |
+
router = APIRouter(prefix="/api/risk", tags=["risk"])
|
| 28 |
+
|
| 29 |
+
# Lazy import of wri_service (may not exist yet in dev environments)
|
| 30 |
+
_wri_service = None
|
| 31 |
+
|
| 32 |
+
def _get_wri_service():
|
| 33 |
+
global _wri_service
|
| 34 |
+
if _wri_service is None:
|
| 35 |
+
try:
|
| 36 |
+
from services.wri_service import compute_wri
|
| 37 |
+
_wri_service = compute_wri
|
| 38 |
+
except ImportError:
|
| 39 |
+
# Fallback for when services haven't been set up yet
|
| 40 |
+
_wri_service = None
|
| 41 |
+
return _wri_service
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class RiskComputePayload(BaseModel):
|
| 45 |
+
d: Optional[float] = None
|
| 46 |
+
g: Optional[float] = None
|
| 47 |
+
p: Optional[float] = None
|
| 48 |
+
weights: Dict[str, float] = {"w1": 0.30, "w2": 0.40, "w3": 0.30}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class StudentRiskInput(BaseModel):
|
| 52 |
+
id: str
|
| 53 |
+
d: Optional[float] = None
|
| 54 |
+
g: Optional[float] = None
|
| 55 |
+
p: Optional[float] = None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class BatchRiskPayload(BaseModel):
|
| 59 |
+
students: List[StudentRiskInput]
|
| 60 |
+
weights: Dict[str, float] = {"w1": 0.30, "w2": 0.40, "w3": 0.30}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@router.post("/compute")
|
| 64 |
+
def compute_risk_endpoint(payload: RiskComputePayload):
|
| 65 |
+
"""
|
| 66 |
+
Compute WRI for a single student.
|
| 67 |
+
"""
|
| 68 |
+
compute_fn = _get_wri_service()
|
| 69 |
+
if compute_fn is None:
|
| 70 |
+
raise HTTPException(
|
| 71 |
+
status_code=503,
|
| 72 |
+
detail="WRI service not available. Ensure backend services are initialized."
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
result = compute_fn(
|
| 77 |
+
d=payload.d,
|
| 78 |
+
g=payload.g,
|
| 79 |
+
p=payload.p,
|
| 80 |
+
weights=payload.weights,
|
| 81 |
+
)
|
| 82 |
+
return result
|
| 83 |
+
except ValueError as e:
|
| 84 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@router.post("/compute/batch")
|
| 88 |
+
def compute_risk_batch(payload: BatchRiskPayload):
|
| 89 |
+
"""
|
| 90 |
+
Compute WRI for multiple students in one call.
|
| 91 |
+
Used by Cloud Functions for batch recalculation.
|
| 92 |
+
"""
|
| 93 |
+
compute_fn = _get_wri_service()
|
| 94 |
+
if compute_fn is None:
|
| 95 |
+
raise HTTPException(
|
| 96 |
+
status_code=503,
|
| 97 |
+
detail="WRI service not available."
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
results = []
|
| 101 |
+
for student in payload.students:
|
| 102 |
+
try:
|
| 103 |
+
result = compute_fn(
|
| 104 |
+
d=student.d,
|
| 105 |
+
g=student.g,
|
| 106 |
+
p=student.p,
|
| 107 |
+
weights=payload.weights,
|
| 108 |
+
)
|
| 109 |
+
results.append({
|
| 110 |
+
"id": student.id,
|
| 111 |
+
**result,
|
| 112 |
+
})
|
| 113 |
+
except ValueError as e:
|
| 114 |
+
results.append({
|
| 115 |
+
"id": student.id,
|
| 116 |
+
"wri": None,
|
| 117 |
+
"risk_status": "error",
|
| 118 |
+
"error": str(e),
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
+
return {"results": results}
|
routes/tutor_checkin.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tutor Check-in Engine โ DeepSeek-powered contextual student engagement.
|
| 3 |
+
|
| 4 |
+
POST /api/tutor-checkin -> Generate a personalized check-in message for a student
|
| 5 |
+
based on their recent activity, weak topics, and current risk status.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
import os
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, HTTPException
|
| 13 |
+
from pydantic import BaseModel
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger("mathpulse.tutor_checkin")
|
| 16 |
+
|
| 17 |
+
router = APIRouter(prefix="/api/tutor-checkin", tags=["tutor-checkin"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class TutorCheckinRequest(BaseModel):
|
| 21 |
+
student_id: str
|
| 22 |
+
student_name: Optional[str] = None
|
| 23 |
+
risk_status: Optional[str] = None
|
| 24 |
+
wri: Optional[float] = None
|
| 25 |
+
weak_topics: Optional[list] = None
|
| 26 |
+
recent_activity: Optional[str] = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class TutorCheckinResponse(BaseModel):
|
| 30 |
+
message: str
|
| 31 |
+
tone: str
|
| 32 |
+
suggested_action: Optional[str] = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _build_checkin_prompt(req: TutorCheckinRequest) -> str:
|
| 36 |
+
"""Build a contextual prompt for DeepSeek to generate a tutor check-in."""
|
| 37 |
+
name = req.student_name or "Student"
|
| 38 |
+
status = req.risk_status or "unknown"
|
| 39 |
+
wri = req.wri
|
| 40 |
+
weak = req.weak_topics or []
|
| 41 |
+
activity = req.recent_activity or "No recent activity recorded."
|
| 42 |
+
|
| 43 |
+
weak_topics_str = ", ".join(weak) if weak else "none identified"
|
| 44 |
+
|
| 45 |
+
tone_map = {
|
| 46 |
+
"safe": "encouraging and celebratory",
|
| 47 |
+
"watch": "gentle and supportive",
|
| 48 |
+
"intervene": "caring but firm",
|
| 49 |
+
"critical": "urgent and deeply supportive",
|
| 50 |
+
"at_risk": "emergency-level supportive",
|
| 51 |
+
}
|
| 52 |
+
tone = tone_map.get(status, "supportive")
|
| 53 |
+
|
| 54 |
+
prompt = f"""You are MathPulse AI, a friendly and encouraging math tutor checking in on a student.
|
| 55 |
+
|
| 56 |
+
Student: {name}
|
| 57 |
+
Current risk status: {status}
|
| 58 |
+
WRI score: {wri if wri is not None else 'N/A'}
|
| 59 |
+
Weak topics: {weak_topics_str}
|
| 60 |
+
Recent activity: {activity}
|
| 61 |
+
|
| 62 |
+
Generate a short, personalized check-in message (2-3 sentences max) in a {tone} tone.
|
| 63 |
+
The message should:
|
| 64 |
+
- Acknowledge the student's current situation without being judgmental
|
| 65 |
+
- Offer specific, actionable encouragement
|
| 66 |
+
- Suggest ONE concrete next step they can take right now
|
| 67 |
+
- Be warm, human, and written like a caring tutor, not a robot
|
| 68 |
+
|
| 69 |
+
Return ONLY the message text. No JSON, no markdown, no prefixes."""
|
| 70 |
+
|
| 71 |
+
return prompt
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@router.post("", response_model=TutorCheckinResponse)
|
| 75 |
+
def generate_tutor_checkin(req: TutorCheckinRequest):
|
| 76 |
+
"""
|
| 77 |
+
Generate a DeepSeek-powered tutor check-in message for a student.
|
| 78 |
+
"""
|
| 79 |
+
try:
|
| 80 |
+
# Lazy import to avoid startup dependency issues
|
| 81 |
+
from services.inference_client import InferenceClient
|
| 82 |
+
|
| 83 |
+
client = InferenceClient()
|
| 84 |
+
prompt = _build_checkin_prompt(req)
|
| 85 |
+
|
| 86 |
+
# Call DeepSeek with a short, fast completion
|
| 87 |
+
response = client.generate(
|
| 88 |
+
messages=[{"role": "user", "content": prompt}],
|
| 89 |
+
model="deepseek-chat",
|
| 90 |
+
temperature=0.7,
|
| 91 |
+
max_tokens=150,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
message = response.strip() if response else _fallback_message(req.risk_status)
|
| 95 |
+
|
| 96 |
+
tone_map = {
|
| 97 |
+
"safe": "encouraging",
|
| 98 |
+
"watch": "supportive",
|
| 99 |
+
"intervene": "caring-but-firm",
|
| 100 |
+
"critical": "urgent",
|
| 101 |
+
"at_risk": "emergency",
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
return TutorCheckinResponse(
|
| 105 |
+
message=message,
|
| 106 |
+
tone=tone_map.get(req.risk_status or "", "supportive"),
|
| 107 |
+
suggested_action=_suggest_action(req.risk_status, req.weak_topics),
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
except Exception as e:
|
| 111 |
+
logger.error(f"[TUTOR_CHECKIN] Failed to generate check-in: {e}")
|
| 112 |
+
# Fallback to template-based message
|
| 113 |
+
return TutorCheckinResponse(
|
| 114 |
+
message=_fallback_message(req.risk_status),
|
| 115 |
+
tone="supportive",
|
| 116 |
+
suggested_action=_suggest_action(req.risk_status, req.weak_topics),
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _fallback_message(risk_status: Optional[str]) -> str:
|
| 121 |
+
"""Template-based fallback when DeepSeek is unavailable."""
|
| 122 |
+
fallbacks = {
|
| 123 |
+
"safe": "Great work! You're on track. Keep up the momentum with today's practice.",
|
| 124 |
+
"watch": "I noticed you've been working hard. Let's take a moment to review any tricky concepts together.",
|
| 125 |
+
"intervene": "Your teacher and I are here to help. Let's focus on one topic at a time โ you've got this.",
|
| 126 |
+
"critical": "I'm worried about your progress. Please reach out to your teacher or start a remedial module today.",
|
| 127 |
+
"at_risk": "Your learning path is paused until your teacher reviews your progress. In the meantime, review your completed lessons.",
|
| 128 |
+
}
|
| 129 |
+
return fallbacks.get(risk_status or "", "Keep going! Every problem you solve makes you stronger.")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _suggest_action(risk_status: Optional[str], weak_topics: Optional[list]) -> Optional[str]:
|
| 133 |
+
"""Suggest a concrete next action based on status."""
|
| 134 |
+
if risk_status == "safe":
|
| 135 |
+
return "Try a bonus challenge to stretch your skills."
|
| 136 |
+
if risk_status == "watch":
|
| 137 |
+
return "Review the hint for your last incorrect answer."
|
| 138 |
+
if risk_status == "intervene":
|
| 139 |
+
topic = weak_topics[0] if weak_topics else "your weakest topic"
|
| 140 |
+
return f"Start the remedial module on {topic}."
|
| 141 |
+
if risk_status in ("critical", "at_risk"):
|
| 142 |
+
return "Contact your teacher or start a 1-on-1 review session."
|
| 143 |
+
return None
|
services/wri_service.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
WRI CLASSIFICATION โ Prevention-First 5-Band System
|
| 3 |
+
|
| 4 |
+
This module implements at-risk classification based on DepEd DO No. 8, s. 2015
|
| 5 |
+
(Policy Guidelines on Classroom Assessment for the K to 12 Basic Education Program).
|
| 6 |
+
|
| 7 |
+
Official Passing Grade: 75 (Did Not Meet Expectations = below 75)
|
| 8 |
+
|
| 9 |
+
Prevention-first WRI thresholds (DepEd 75 is the FLOOR, not the trigger):
|
| 10 |
+
- WRI >= 88 โ safe (On Track โ no intervention needed)
|
| 11 |
+
- WRI >= 80 โ watch (Slight decline โ system adjusts difficulty)
|
| 12 |
+
- WRI >= 75 โ intervene (Approaching DepEd threshold โ teacher notified)
|
| 13 |
+
- WRI >= 68 โ critical (Urgent โ structured intervention required)
|
| 14 |
+
- WRI < 68 โ at_risk (Near or below DepEd failing mark)
|
| 15 |
+
|
| 16 |
+
IMPORTANT: WRI is a SUPPORT TOOL, not a replacement for teacher judgment.
|
| 17 |
+
Final academic decisions must still be made by the teacher in accordance
|
| 18 |
+
with official DepEd grading policies.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from typing import Optional
|
| 22 |
+
|
| 23 |
+
DEFAULT_WEIGHTS = {"w1": 0.30, "w2": 0.40, "w3": 0.30}
|
| 24 |
+
WEIGHT_TOLERANCE = 0.001
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def compute_wri(
|
| 28 |
+
d: Optional[float],
|
| 29 |
+
g: Optional[float],
|
| 30 |
+
p: Optional[float],
|
| 31 |
+
weights: dict = None,
|
| 32 |
+
) -> dict:
|
| 33 |
+
"""
|
| 34 |
+
Computes the Weighted Risk Index (WRI) and returns classification.
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
d: Diagnostic baseline score (0-100), set once after initial assessment
|
| 38 |
+
g: External grades average (0-100), from teacher-imported class records
|
| 39 |
+
p: System performance average (0-100), from quiz/activity scores
|
| 40 |
+
weights: w1 (diagnostic), w2 (external), w3 (system) โ must sum to 1.0
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
dict with keys:
|
| 44 |
+
wri: float (rounded to 2 decimal places) or None if D is missing
|
| 45 |
+
risk_status: 'safe' | 'watch' | 'intervene' | 'critical' | 'at_risk' | 'pending_assessment'
|
| 46 |
+
inputs: {'D': float, 'G': float, 'P': float} (actual values used, after defaults)
|
| 47 |
+
g_fallback: bool (True if G defaulted to D)
|
| 48 |
+
p_fallback: bool (True if P defaulted to D)
|
| 49 |
+
"""
|
| 50 |
+
if weights is None:
|
| 51 |
+
weights = DEFAULT_WEIGHTS.copy()
|
| 52 |
+
|
| 53 |
+
w1 = weights.get("w1", DEFAULT_WEIGHTS["w1"])
|
| 54 |
+
w2 = weights.get("w2", DEFAULT_WEIGHTS["w2"])
|
| 55 |
+
w3 = weights.get("w3", DEFAULT_WEIGHTS["w3"])
|
| 56 |
+
|
| 57 |
+
# Validate weights sum to 1.0
|
| 58 |
+
if abs((w1 + w2 + w3) - 1.0) > WEIGHT_TOLERANCE:
|
| 59 |
+
raise ValueError(f"Weights must sum to 1.0, got w1={w1}, w2={w2}, w3={w3}")
|
| 60 |
+
|
| 61 |
+
# Cannot compute without diagnostic baseline
|
| 62 |
+
if d is None:
|
| 63 |
+
return {
|
| 64 |
+
"wri": None,
|
| 65 |
+
"risk_status": "pending_assessment",
|
| 66 |
+
"inputs": {"D": None, "G": g, "P": p},
|
| 67 |
+
"g_fallback": False,
|
| 68 |
+
"p_fallback": False,
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
# Apply defaults: missing G and/or P default to D
|
| 72 |
+
g_fallback = g is None
|
| 73 |
+
p_fallback = p is None
|
| 74 |
+
g_val = g if g is not None else d
|
| 75 |
+
p_val = p if p is not None else d
|
| 76 |
+
|
| 77 |
+
# Compute WRI
|
| 78 |
+
wri = round((w1 * d) + (w2 * g_val) + (w3 * p_val), 2)
|
| 79 |
+
|
| 80 |
+
# 5-band prevention-first classification
|
| 81 |
+
if wri >= 88:
|
| 82 |
+
status = "safe"
|
| 83 |
+
elif wri >= 80:
|
| 84 |
+
status = "watch"
|
| 85 |
+
elif wri >= 75:
|
| 86 |
+
status = "intervene"
|
| 87 |
+
elif wri >= 68:
|
| 88 |
+
status = "critical"
|
| 89 |
+
else:
|
| 90 |
+
status = "at_risk"
|
| 91 |
+
|
| 92 |
+
return {
|
| 93 |
+
"wri": wri,
|
| 94 |
+
"risk_status": status,
|
| 95 |
+
"inputs": {"D": d, "G": g_val, "P": p_val},
|
| 96 |
+
"g_fallback": g_fallback,
|
| 97 |
+
"p_fallback": p_fallback,
|
| 98 |
+
}
|
tests/test_risk_router.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from fastapi.testclient import TestClient
|
| 3 |
+
from unittest.mock import MagicMock
|
| 4 |
+
|
| 5 |
+
from backend import main as main_module
|
| 6 |
+
app = main_module.app
|
| 7 |
+
|
| 8 |
+
# Mock auth verification so protected endpoints can run in tests without Firebase credentials.
|
| 9 |
+
main_module._firebase_ready = True
|
| 10 |
+
main_module._init_firebase_admin = lambda: None
|
| 11 |
+
main_module.firebase_firestore = None
|
| 12 |
+
main_module.firebase_auth = MagicMock()
|
| 13 |
+
main_module.firebase_auth.verify_id_token = MagicMock(
|
| 14 |
+
return_value={
|
| 15 |
+
"uid": "test-teacher-uid",
|
| 16 |
+
"email": "teacher@example.com",
|
| 17 |
+
"role": "teacher",
|
| 18 |
+
}
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
client = TestClient(app, headers={"Authorization": "Bearer test-auth-token"})
|
| 22 |
+
|
| 23 |
+
def test_compute_risk_safe():
|
| 24 |
+
response = client.post("/api/risk/compute", json={
|
| 25 |
+
"d": 90, "g": 90, "p": 90,
|
| 26 |
+
"weights": {"w1": 0.30, "w2": 0.40, "w3": 0.30}
|
| 27 |
+
})
|
| 28 |
+
assert response.status_code == 200
|
| 29 |
+
data = response.json()
|
| 30 |
+
assert data["wri"] == 90.0
|
| 31 |
+
assert data["risk_status"] == "safe"
|
| 32 |
+
|
| 33 |
+
def test_compute_risk_at_risk():
|
| 34 |
+
response = client.post("/api/risk/compute", json={
|
| 35 |
+
"d": 60, "g": 70, "p": 65
|
| 36 |
+
})
|
| 37 |
+
assert response.status_code == 200
|
| 38 |
+
data = response.json()
|
| 39 |
+
assert data["wri"] == 65.5
|
| 40 |
+
assert data["risk_status"] == "at_risk"
|
| 41 |
+
|
| 42 |
+
def test_compute_risk_intervene():
|
| 43 |
+
response = client.post("/api/risk/compute", json={
|
| 44 |
+
"d": 78, "g": 76, "p": 74
|
| 45 |
+
})
|
| 46 |
+
assert response.status_code == 200
|
| 47 |
+
data = response.json()
|
| 48 |
+
assert data["wri"] == 76.0
|
| 49 |
+
assert data["risk_status"] == "intervene"
|
| 50 |
+
|
| 51 |
+
def test_compute_risk_missing_g_uses_d():
|
| 52 |
+
response = client.post("/api/risk/compute", json={
|
| 53 |
+
"d": 70, "g": None, "p": 80
|
| 54 |
+
})
|
| 55 |
+
assert response.status_code == 200
|
| 56 |
+
data = response.json()
|
| 57 |
+
assert data["g_fallback"] is True
|
| 58 |
+
# WRI = 0.3*70 + 0.4*70 + 0.3*80 = 21 + 28 + 24 = 73.0
|
| 59 |
+
assert data["wri"] == 73.0
|
| 60 |
+
assert data["risk_status"] == "critical"
|
| 61 |
+
|
| 62 |
+
def test_compute_risk_no_diagnostic_returns_pending():
|
| 63 |
+
response = client.post("/api/risk/compute", json={
|
| 64 |
+
"d": None, "g": 80, "p": 90
|
| 65 |
+
})
|
| 66 |
+
assert response.status_code == 200
|
| 67 |
+
data = response.json()
|
| 68 |
+
assert data["wri"] is None
|
| 69 |
+
assert data["risk_status"] == "pending_assessment"
|
| 70 |
+
|
| 71 |
+
def test_compute_risk_invalid_weights():
|
| 72 |
+
response = client.post("/api/risk/compute", json={
|
| 73 |
+
"d": 80, "g": 80, "p": 80,
|
| 74 |
+
"weights": {"w1": 0.5, "w2": 0.5, "w3": 0.5}
|
| 75 |
+
})
|
| 76 |
+
assert response.status_code == 400
|
| 77 |
+
|
| 78 |
+
def test_compute_risk_batch():
|
| 79 |
+
response = client.post("/api/risk/compute/batch", json={
|
| 80 |
+
"students": [
|
| 81 |
+
{"id": "s1", "d": 90, "g": 90, "p": 90},
|
| 82 |
+
{"id": "s2", "d": 60, "g": 70, "p": 65},
|
| 83 |
+
]
|
| 84 |
+
})
|
| 85 |
+
assert response.status_code == 200
|
| 86 |
+
data = response.json()
|
| 87 |
+
assert len(data["results"]) == 2
|
| 88 |
+
assert data["results"][0]["id"] == "s1"
|
| 89 |
+
assert data["results"][0]["risk_status"] == "safe"
|
| 90 |
+
assert data["results"][1]["id"] == "s2"
|
| 91 |
+
assert data["results"][1]["risk_status"] == "at_risk"
|
tests/test_wri_service.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from services.wri_service import compute_wri
|
| 3 |
+
|
| 4 |
+
class TestComputeWRI:
|
| 5 |
+
"""TDD: Write failing tests first, then implement."""
|
| 6 |
+
|
| 7 |
+
def test_standard_weights_safe(self):
|
| 8 |
+
"""WRI = 0.3(90) + 0.4(90) + 0.3(90) = 90.0 โ safe (>= 88)"""
|
| 9 |
+
result = compute_wri(d=90, g=90, p=90)
|
| 10 |
+
assert result["wri"] == 90.0
|
| 11 |
+
assert result["risk_status"] == "safe"
|
| 12 |
+
|
| 13 |
+
def test_standard_weights_at_risk(self):
|
| 14 |
+
"""WRI = 0.3(60) + 0.4(70) + 0.3(65) = 18 + 28 + 19.5 = 65.5 โ at_risk"""
|
| 15 |
+
result = compute_wri(d=60, g=70, p=65)
|
| 16 |
+
assert result["wri"] == 65.5
|
| 17 |
+
assert result["risk_status"] == "at_risk"
|
| 18 |
+
|
| 19 |
+
def test_standard_weights_intervene(self):
|
| 20 |
+
"""WRI = 0.3(78) + 0.4(76) + 0.3(74) = 76.0 โ intervene (75-79)"""
|
| 21 |
+
result = compute_wri(d=78, g=76, p=74)
|
| 22 |
+
assert result["wri"] == 76.0
|
| 23 |
+
assert result["risk_status"] == "intervene"
|
| 24 |
+
|
| 25 |
+
def test_missing_g_defaults_to_d(self):
|
| 26 |
+
"""When G is None/missing, it defaults to D value."""
|
| 27 |
+
result = compute_wri(d=70, g=None, p=80)
|
| 28 |
+
assert result["wri"] == 0.3*70 + 0.4*70 + 0.3*80 # G=70 (defaulted from D)
|
| 29 |
+
assert result["g_fallback"] is True
|
| 30 |
+
|
| 31 |
+
def test_missing_p_defaults_to_d(self):
|
| 32 |
+
"""When P is None/missing, it defaults to D value."""
|
| 33 |
+
result = compute_wri(d=75, g=85, p=None)
|
| 34 |
+
assert result["wri"] == 0.3*75 + 0.4*85 + 0.3*75 # P=75 (defaulted from D)
|
| 35 |
+
assert result["p_fallback"] is True
|
| 36 |
+
|
| 37 |
+
def test_missing_g_and_p_both_default_to_d(self):
|
| 38 |
+
"""Both G and P missing โ both default to D."""
|
| 39 |
+
result = compute_wri(d=68, g=None, p=None)
|
| 40 |
+
assert result["wri"] == 0.3*68 + 0.4*68 + 0.3*68 # = 68.0
|
| 41 |
+
assert result["g_fallback"] is True
|
| 42 |
+
assert result["p_fallback"] is True
|
| 43 |
+
|
| 44 |
+
def test_no_diagnostic_returns_none(self):
|
| 45 |
+
"""When D is None โ cannot compute WRI, return pending status."""
|
| 46 |
+
result = compute_wri(d=None, g=80, p=90)
|
| 47 |
+
assert result["wri"] is None
|
| 48 |
+
assert result["risk_status"] == "pending_assessment"
|
| 49 |
+
|
| 50 |
+
def test_invalid_weights_raise_error(self):
|
| 51 |
+
"""Weights that don't sum to 1.0 โ ValueError."""
|
| 52 |
+
with pytest.raises(ValueError, match="Weights must sum to 1.0"):
|
| 53 |
+
compute_wri(d=80, g=80, p=80, weights={"w1": 0.5, "w2": 0.5, "w3": 0.5})
|
| 54 |
+
|
| 55 |
+
def test_weights_close_to_one_are_valid(self):
|
| 56 |
+
"""Allow small floating-point tolerance (abs diff <= 0.001)."""
|
| 57 |
+
result = compute_wri(d=90, g=90, p=90, weights={"w1": 0.333, "w2": 0.333, "w3": 0.334})
|
| 58 |
+
assert result["wri"] == 90.0
|
| 59 |
+
assert result["risk_status"] == "safe"
|
| 60 |
+
|
| 61 |
+
def test_wri_rounds_to_2_decimal_places(self):
|
| 62 |
+
"""WRI result must be rounded to 2 decimal places."""
|
| 63 |
+
result = compute_wri(d=77.777, g=88.888, p=66.666)
|
| 64 |
+
assert result["wri"] == round(0.3*77.777 + 0.4*88.888 + 0.3*66.666, 2)
|
| 65 |
+
|
| 66 |
+
def test_boundary_88_is_safe(self):
|
| 67 |
+
"""Exactly WRI=88 should be classified as safe."""
|
| 68 |
+
result = compute_wri(d=88, g=88, p=88)
|
| 69 |
+
assert result["wri"] == 88.0
|
| 70 |
+
assert result["risk_status"] == "safe"
|
| 71 |
+
|
| 72 |
+
def test_boundary_80_is_watch(self):
|
| 73 |
+
"""Exactly WRI=80 should be classified as watch."""
|
| 74 |
+
result = compute_wri(d=80, g=80, p=80)
|
| 75 |
+
assert result["wri"] == 80.0
|
| 76 |
+
assert result["risk_status"] == "watch"
|
| 77 |
+
|
| 78 |
+
def test_boundary_75_is_intervene(self):
|
| 79 |
+
"""Exactly WRI=75 should be classified as intervene."""
|
| 80 |
+
result = compute_wri(d=75, g=75, p=75)
|
| 81 |
+
assert result["wri"] == 75.0
|
| 82 |
+
assert result["risk_status"] == "intervene"
|
| 83 |
+
|
| 84 |
+
def test_boundary_68_is_critical(self):
|
| 85 |
+
"""Exactly WRI=68 should be classified as critical."""
|
| 86 |
+
result = compute_wri(d=68, g=68, p=68)
|
| 87 |
+
assert result["wri"] == 68.0
|
| 88 |
+
assert result["risk_status"] == "critical"
|
| 89 |
+
|
| 90 |
+
def test_boundary_67_is_at_risk(self):
|
| 91 |
+
"""WRI just below 68 (e.g. 67.99) should be at_risk."""
|
| 92 |
+
result = compute_wri(d=67.99, g=67.99, p=67.99)
|
| 93 |
+
assert result["wri"] == 67.99
|
| 94 |
+
assert result["risk_status"] == "at_risk"
|
| 95 |
+
|
| 96 |
+
def test_custom_weights(self):
|
| 97 |
+
"""Custom weights w1=0.2, w2=0.5, w3=0.3"""
|
| 98 |
+
result = compute_wri(d=70, g=90, p=80, weights={"w1": 0.2, "w2": 0.5, "w3": 0.3})
|
| 99 |
+
expected = 0.2*70 + 0.5*90 + 0.3*80
|
| 100 |
+
assert result["wri"] == round(expected, 2)
|
| 101 |
+
|
| 102 |
+
def test_zero_scores(self):
|
| 103 |
+
"""All zero scores โ WRI = 0 โ at_risk"""
|
| 104 |
+
result = compute_wri(d=0, g=0, p=0)
|
| 105 |
+
assert result["wri"] == 0.0
|
| 106 |
+
assert result["risk_status"] == "at_risk"
|
| 107 |
+
|
| 108 |
+
def test_perfect_scores(self):
|
| 109 |
+
"""All perfect scores โ WRI = 100 โ safe"""
|
| 110 |
+
result = compute_wri(d=100, g=100, p=100)
|
| 111 |
+
assert result["wri"] == 100.0
|
| 112 |
+
assert result["risk_status"] == "safe"
|
| 113 |
+
|
| 114 |
+
def test_result_includes_inputs(self):
|
| 115 |
+
"""Result dict should include the input values used."""
|
| 116 |
+
result = compute_wri(d=80, g=85, p=90)
|
| 117 |
+
assert result["inputs"]["D"] == 80
|
| 118 |
+
assert result["inputs"]["G"] == 85
|
| 119 |
+
assert result["inputs"]["P"] == 90
|