github-actions[bot] commited on
Commit
25943d2
ยท
1 Parent(s): a715f01

๐Ÿš€ Auto-deploy backend from GitHub (552770f)

Browse files
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