github-actions[bot] commited on
Commit
8a85a30
ยท
1 Parent(s): a187357

deploy: backend from 694f8ea

Browse files
main.py CHANGED
@@ -1160,23 +1160,10 @@ class RequestMiddleware(BaseHTTPMiddleware):
1160
  },
1161
  headers={"X-Request-ID": request_id},
1162
  )
1163
- except Exception as exc:
1164
- duration = round(time.time() - start, 3)
1165
- logger.error(f"[{request_id}] Unhandled error after {duration}s: {exc}")
1166
- return JSONResponse(
1167
- status_code=500,
1168
- content={
1169
- "detail": "Internal server error",
1170
- "error": type(exc).__name__,
1171
- "message": str(exc),
1172
- "requestId": request_id,
1173
- },
1174
- headers={"X-Request-ID": request_id},
1175
- )
1176
 
1177
 
1178
- app.add_middleware(RequestMiddleware)
1179
  app.add_middleware(AuthMiddleware)
 
1180
 
1181
  # Set up rate limiting with slowapi
1182
  if HAS_RATE_LIMITING and setup_rate_limiting: # type: ignore[truthy-function]
 
1160
  },
1161
  headers={"X-Request-ID": request_id},
1162
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
1163
 
1164
 
 
1165
  app.add_middleware(AuthMiddleware)
1166
+ app.add_middleware(RequestMiddleware)
1167
 
1168
  # Set up rate limiting with slowapi
1169
  if HAS_RATE_LIMITING and setup_rate_limiting: # type: ignore[truthy-function]
middleware/rate_limiter.py CHANGED
@@ -8,6 +8,8 @@ from fastapi import Request
8
  from slowapi import Limiter
9
  from slowapi.errors import RateLimitExceeded as SlowAPIRateLimitExceeded
10
 
 
 
11
  logger = logging.getLogger("mathpulse.ratelimit")
12
 
13
  # Environment-based configuration with defaults
 
8
  from slowapi import Limiter
9
  from slowapi.errors import RateLimitExceeded as SlowAPIRateLimitExceeded
10
 
11
+ RateLimitExceeded = SlowAPIRateLimitExceeded
12
+
13
  logger = logging.getLogger("mathpulse.ratelimit")
14
 
15
  # Environment-based configuration with defaults
routes/admin_routes.py CHANGED
@@ -1,7 +1,12 @@
1
- from typing import Optional
 
 
 
 
 
 
2
  from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form, BackgroundTasks
3
  from pydantic import BaseModel
4
- import logging
5
 
6
  from rag.firebase_storage_loader import _init_firebase_storage, PDF_METADATA
7
  from scripts.ingest_from_storage import ingest_from_firebase_storage
@@ -21,6 +26,13 @@ logger = logging.getLogger("mathpulse.admin")
21
 
22
  router = APIRouter(prefix="/api/admin", tags=["admin"])
23
 
 
 
 
 
 
 
 
24
  def require_admin(request: Request):
25
  user = getattr(request.state, "user", None)
26
  if user is None:
@@ -33,8 +45,161 @@ class ReingestRequest(BaseModel):
33
  subjectId: Optional[str] = None
34
  storagePath: Optional[str] = None
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  @router.post("/upload-pdf")
37
  async def upload_pdf(
 
38
  subjectId: str = Form(...),
39
  subjectName: str = Form(...),
40
  semester: int = Form(...),
@@ -71,9 +236,9 @@ async def upload_pdf(
71
  "quarter": quarter
72
  }
73
 
74
- # Reingest
75
  try:
76
- ingest_from_firebase_storage(force_reindex=True)
77
  except Exception as e:
78
  logger.error(f"Failed to trigger reingestion: {e}")
79
 
@@ -102,19 +267,6 @@ async def upload_pdf(
102
  "storageUrl": storage_url
103
  }
104
 
105
- def _run_reingestion_task():
106
- try:
107
- logger.info("Starting background curriculum reingestion from Firebase Storage...")
108
- ingest_from_firebase_storage(force_reindex=True)
109
- from scripts.upload_vectorstore_to_firebase import upload_directory, _init_firebase_storage, VECTORSTORE_SOURCE_DIR, REMOTE_PREFIX
110
- _, bucket = _init_firebase_storage()
111
- if bucket is not None:
112
- upload_directory(VECTORSTORE_SOURCE_DIR, bucket, REMOTE_PREFIX)
113
- logger.info("Background curriculum reingestion complete.")
114
- except Exception as exc:
115
- logger.error(f"Background reingestion failed: {exc}")
116
-
117
-
118
  @router.post("/reingest-pdf")
119
  async def reingest_pdf(
120
  background_tasks: BackgroundTasks,
@@ -122,10 +274,46 @@ async def reingest_pdf(
122
  _admin=Depends(require_admin)
123
  ):
124
  try:
125
- background_tasks.add_task(_run_reingestion_task)
126
- import asyncio
 
 
 
127
  audit_fn = _get_audit_logger()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  if audit_fn:
 
129
  asyncio.create_task(audit_fn(
130
  action="REINGEST_RAG_KNOWLEDGE",
131
  actor_uid=_admin.uid,
@@ -136,7 +324,12 @@ async def reingest_pdf(
136
  route="/api/admin/reingest-pdf",
137
  module="admin"
138
  ))
139
- return {"success": True, "message": "Remote re-ingestion started in background. Check RAG Manager in a few minutes."}
 
 
 
 
 
140
  except Exception as e:
141
  logger.error(f"Failed to trigger reingestion: {e}")
142
  raise HTTPException(status_code=500, detail=f"Failed to trigger reingestion: {e}")
 
1
+ import os
2
+ import json
3
+ import logging
4
+ import urllib.request
5
+ import urllib.error
6
+ from datetime import datetime, timezone
7
+ from typing import Optional, Dict, Any
8
  from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form, BackgroundTasks
9
  from pydantic import BaseModel
 
10
 
11
  from rag.firebase_storage_loader import _init_firebase_storage, PDF_METADATA
12
  from scripts.ingest_from_storage import ingest_from_firebase_storage
 
26
 
27
  router = APIRouter(prefix="/api/admin", tags=["admin"])
28
 
29
+ REINGESTION_STATUS: Dict[str, Any] = {
30
+ "status": "idle",
31
+ "last_run": None,
32
+ "message": None,
33
+ "mode": None
34
+ }
35
+
36
  def require_admin(request: Request):
37
  user = getattr(request.state, "user", None)
38
  if user is None:
 
45
  subjectId: Optional[str] = None
46
  storagePath: Optional[str] = None
47
 
48
+ def trigger_github_curriculum_workflow(token: str, ref: str = "main", force: bool = True) -> bool:
49
+ """Uses standard library urllib.request to POST to GitHub Actions workflow dispatch endpoint."""
50
+ url = "https://api.github.com/repos/Deign86/MATHPULSE-AI/actions/workflows/ingest-curriculum.yml/dispatches"
51
+ payload = {
52
+ "ref": ref,
53
+ "inputs": {
54
+ "force_reindex": force,
55
+ "upload_to_firebase": True,
56
+ }
57
+ }
58
+ data = json.dumps(payload).encode("utf-8")
59
+ headers = {
60
+ "Accept": "application/vnd.github+json",
61
+ "Authorization": f"Bearer {token}",
62
+ "X-GitHub-Api-Version": "2022-11-28",
63
+ "User-Agent": "MathPulseAI-Admin",
64
+ "Content-Type": "application/json",
65
+ }
66
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
67
+ try:
68
+ with urllib.request.urlopen(req, timeout=10) as response:
69
+ if response.status == 204:
70
+ logger.info("Successfully triggered GitHub Actions curriculum ingestion workflow.")
71
+ return True
72
+ logger.warning(f"Unexpected status from GitHub Actions dispatch: {response.status}")
73
+ return False
74
+ except urllib.error.HTTPError as exc:
75
+ logger.error(f"GitHub Actions dispatch HTTP error {exc.code}: {exc.reason} - {exc.read().decode('utf-8', errors='ignore')}")
76
+ return False
77
+ except Exception as exc:
78
+ logger.error(f"Failed to trigger GitHub Actions workflow: {exc}")
79
+ return False
80
+
81
+ def run_cloud_ingestion_and_upload():
82
+ """
83
+ Runs ingest_from_firebase_storage(force_reindex=True),
84
+ uploads vectorstore files to Firebase Storage via upload_directory and upload_vectorstore,
85
+ and updates REINGESTION_STATUS.
86
+ """
87
+ global REINGESTION_STATUS
88
+ REINGESTION_STATUS["status"] = "running"
89
+ REINGESTION_STATUS["last_run"] = datetime.now(timezone.utc).isoformat()
90
+ REINGESTION_STATUS["mode"] = "background_tasks"
91
+ REINGESTION_STATUS["message"] = "Remote re-ingestion from Firebase Storage in progress..."
92
+ logger.info("Starting background cloud curriculum reingestion and upload...")
93
+
94
+ try:
95
+ # Step 1: Run ingestion
96
+ ingest_from_firebase_storage(force_reindex=True)
97
+ logger.info("Curriculum ingestion from Firebase Storage completed successfully.")
98
+
99
+ upload_errors = []
100
+
101
+ # Step 2: Upload vectorstore directory via scripts.upload_vectorstore_to_firebase.upload_directory
102
+ try:
103
+ upload_dir_fn = None
104
+ init_storage_fn = None
105
+ vec_source_dir = None
106
+ remote_pfx = "vectorstore/"
107
+
108
+ try:
109
+ from scripts.upload_vectorstore_to_firebase import (
110
+ upload_directory as _upload_dir,
111
+ _init_firebase_storage as _init_storage,
112
+ VECTORSTORE_SOURCE_DIR as _source_dir,
113
+ REMOTE_PREFIX as _pfx,
114
+ )
115
+ upload_dir_fn = _upload_dir
116
+ init_storage_fn = _init_storage
117
+ vec_source_dir = _source_dir
118
+ remote_pfx = _pfx
119
+ except ImportError:
120
+ try:
121
+ from backend.scripts.upload_vectorstore_to_firebase import (
122
+ upload_directory as _upload_dir,
123
+ _init_firebase_storage as _init_storage,
124
+ VECTORSTORE_SOURCE_DIR as _source_dir,
125
+ REMOTE_PREFIX as _pfx,
126
+ )
127
+ upload_dir_fn = _upload_dir
128
+ init_storage_fn = _init_storage
129
+ vec_source_dir = _source_dir
130
+ remote_pfx = _pfx
131
+ except ImportError as imp_err:
132
+ logger.warning(f"Could not import upload_vectorstore_to_firebase: {imp_err}")
133
+
134
+ if upload_dir_fn and init_storage_fn:
135
+ _, bucket = init_storage_fn()
136
+ if bucket is not None:
137
+ if vec_source_dir is None:
138
+ from pathlib import Path
139
+ vec_source_dir = Path("datasets/vectorstore")
140
+ uploaded, skipped = upload_dir_fn(vec_source_dir, bucket, remote_pfx)
141
+ logger.info(f"Vectorstore directory uploaded: {uploaded} files uploaded, {skipped} skipped.")
142
+ else:
143
+ logger.warning("Firebase Storage bucket not initialized; skipping upload_directory.")
144
+ except Exception as e:
145
+ logger.error(f"Error during vectorstore directory upload: {e}")
146
+ upload_errors.append(f"directory_upload: {e}")
147
+
148
+ # Step 3: Upload vectorstore archive via scripts.upload_vectorstore.upload_vectorstore
149
+ try:
150
+ upload_vec_fn = None
151
+ try:
152
+ from scripts.upload_vectorstore import upload_vectorstore as _upload_vec
153
+ upload_vec_fn = _upload_vec
154
+ except ImportError:
155
+ try:
156
+ from backend.scripts.upload_vectorstore import upload_vectorstore as _upload_vec
157
+ upload_vec_fn = _upload_vec
158
+ except ImportError:
159
+ import sys
160
+ from pathlib import Path
161
+ repo_root = str(Path(__file__).resolve().parents[2])
162
+ if repo_root not in sys.path:
163
+ sys.path.insert(0, repo_root)
164
+ try:
165
+ from scripts.upload_vectorstore import upload_vectorstore as _upload_vec
166
+ upload_vec_fn = _upload_vec
167
+ except ImportError as imp_err:
168
+ logger.warning(f"Could not import upload_vectorstore: {imp_err}")
169
+
170
+ if upload_vec_fn:
171
+ success = upload_vec_fn()
172
+ if success:
173
+ logger.info("Vectorstore archive upload completed successfully.")
174
+ else:
175
+ logger.warning("Vectorstore archive upload returned False.")
176
+ upload_errors.append("zip_upload_failed")
177
+ except Exception as e:
178
+ logger.error(f"Error during vectorstore archive upload: {e}")
179
+ upload_errors.append(f"zip_upload: {e}")
180
+
181
+ if upload_errors:
182
+ REINGESTION_STATUS["status"] = "completed_with_warnings"
183
+ REINGESTION_STATUS["message"] = f"Ingestion finished, but upload had warnings: {'; '.join(upload_errors)}"
184
+ else:
185
+ REINGESTION_STATUS["status"] = "completed"
186
+ REINGESTION_STATUS["message"] = "Remote re-ingestion and vectorstore upload completed successfully."
187
+ logger.info("Cloud curriculum reingestion process finished.")
188
+
189
+ except Exception as exc:
190
+ logger.error(f"Background cloud reingestion failed: {exc}", exc_info=True)
191
+ REINGESTION_STATUS["status"] = "failed"
192
+ REINGESTION_STATUS["message"] = f"Re-ingestion failed: {str(exc)}"
193
+
194
+ _run_reingestion_task = run_cloud_ingestion_and_upload
195
+
196
+ @router.get("/reingest-status")
197
+ async def get_reingest_status(_admin=Depends(require_admin)):
198
+ return REINGESTION_STATUS
199
+
200
  @router.post("/upload-pdf")
201
  async def upload_pdf(
202
+ background_tasks: BackgroundTasks,
203
  subjectId: str = Form(...),
204
  subjectName: str = Form(...),
205
  semester: int = Form(...),
 
236
  "quarter": quarter
237
  }
238
 
239
+ # Reingest in background
240
  try:
241
+ background_tasks.add_task(run_cloud_ingestion_and_upload)
242
  except Exception as e:
243
  logger.error(f"Failed to trigger reingestion: {e}")
244
 
 
267
  "storageUrl": storage_url
268
  }
269
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  @router.post("/reingest-pdf")
271
  async def reingest_pdf(
272
  background_tasks: BackgroundTasks,
 
274
  _admin=Depends(require_admin)
275
  ):
276
  try:
277
+ github_token = os.getenv("GITHUB_PAT") or os.getenv("GITHUB_TOKEN")
278
+ dispatched_gh = False
279
+ if github_token:
280
+ dispatched_gh = trigger_github_curriculum_workflow(token=github_token)
281
+
282
  audit_fn = _get_audit_logger()
283
+
284
+ if dispatched_gh:
285
+ REINGESTION_STATUS["status"] = "running"
286
+ REINGESTION_STATUS["mode"] = "github_actions"
287
+ REINGESTION_STATUS["last_run"] = datetime.now(timezone.utc).isoformat()
288
+ REINGESTION_STATUS["message"] = "Remote re-ingestion dispatched to GitHub Actions runner."
289
+
290
+ if audit_fn:
291
+ import asyncio
292
+ asyncio.create_task(audit_fn(
293
+ action="REINGEST_RAG_KNOWLEDGE",
294
+ actor_uid=_admin.uid,
295
+ actor_name=_admin.name if hasattr(_admin, "name") else "Unknown",
296
+ actor_email=_admin.email if hasattr(_admin, "email") else "",
297
+ actor_role=_admin.role,
298
+ description="Triggered remote cloud reingestion of the RAG knowledge base via GitHub Actions",
299
+ route="/api/admin/reingest-pdf",
300
+ module="admin"
301
+ ))
302
+
303
+ return {
304
+ "success": True,
305
+ "message": "Remote re-ingestion dispatched to GitHub Actions runner.",
306
+ "execution_mode": "github_actions"
307
+ }
308
+
309
+ REINGESTION_STATUS["status"] = "running"
310
+ REINGESTION_STATUS["mode"] = "background_tasks"
311
+ REINGESTION_STATUS["last_run"] = datetime.now(timezone.utc).isoformat()
312
+ REINGESTION_STATUS["message"] = "Remote re-ingestion started in the cloud."
313
+ background_tasks.add_task(run_cloud_ingestion_and_upload)
314
+
315
  if audit_fn:
316
+ import asyncio
317
  asyncio.create_task(audit_fn(
318
  action="REINGEST_RAG_KNOWLEDGE",
319
  actor_uid=_admin.uid,
 
324
  route="/api/admin/reingest-pdf",
325
  module="admin"
326
  ))
327
+
328
+ return {
329
+ "success": True,
330
+ "message": "Remote re-ingestion started in the cloud.",
331
+ "execution_mode": "background_tasks"
332
+ }
333
  except Exception as e:
334
  logger.error(f"Failed to trigger reingestion: {e}")
335
  raise HTTPException(status_code=500, detail=f"Failed to trigger reingestion: {e}")
routes/at_risk_resolution.py CHANGED
@@ -13,7 +13,7 @@ from fastapi import APIRouter, HTTPException
13
  from pydantic import BaseModel, Field
14
 
15
  from services.ai_client import CHAT_MODEL
16
- from services.deepseek_client import is_enabled, rag_grounded_completion, parse_json_response
17
  from rag.curriculum_rag import (
18
  retrieve_curriculum_context,
19
  format_retrieved_chunks,
 
13
  from pydantic import BaseModel, Field
14
 
15
  from services.ai_client import CHAT_MODEL
16
+ from services.inference_client import is_enabled, rag_grounded_completion, parse_json_response
17
  from rag.curriculum_rag import (
18
  retrieve_curriculum_context,
19
  format_retrieved_chunks,
routes/class_analytics_routes.py CHANGED
@@ -53,7 +53,7 @@ async def get_class_students(
53
  )[:10]
54
  elif filter == "needs_attention":
55
  students = sorted(
56
- [s for s in students if s.risk_level in ("High Risk", "Critical", "Unassessed")],
57
  key=lambda s: s.avg_score,
58
  )
59
 
 
53
  )[:10]
54
  elif filter == "needs_attention":
55
  students = sorted(
56
+ [s for s in students if s.risk_level in ("intervene", "critical", "at_risk")],
57
  key=lambda s: s.avg_score,
58
  )
59
 
routes/class_records_router.py CHANGED
@@ -692,14 +692,23 @@ async def get_upload_students(
692
  upload_data = upload_snap.to_dict()
693
  section_id = (upload_data.get("section") or "unknown").replace(" ", "_").lower()
694
 
695
- students_ref = client.collection("classRecords").document(teacher_uid).collection("sections").document(section_id).collection("students")
696
  query_ref = students_ref.limit(limit + 1)
697
  if after:
698
  after_doc = students_ref.document(after).get()
699
  if after_doc.exists:
700
  query_ref = query_ref.start_after(after_doc)
701
 
702
- docs = query_ref.stream()
 
 
 
 
 
 
 
 
 
703
  students: List[Dict[str, Any]] = []
704
  for d in docs:
705
  data = d.to_dict()
@@ -739,8 +748,10 @@ async def generate_ai_class_report(request: Request, uploadId: str):
739
  section_id = (upload_data.get("section") or "unknown").replace(" ", "_").lower()
740
  metadata = upload_data.get("metadata", {})
741
 
742
- students_ref = client.collection("classRecords").document(teacher_uid).collection("sections").document(section_id).collection("students")
743
- docs = students_ref.stream()
 
 
744
  all_students = [d.to_dict() for d in docs if d.to_dict()]
745
 
746
  total = len(all_students)
@@ -991,24 +1002,28 @@ def _trigger_wri_recompute(
991
 
992
  try:
993
  student_ref = section_ref.collection("students").document(lrn)
994
- student_ref.set({
995
- "wriScore": wri_value,
996
  "wriRiskBand": risk_status,
997
  "wriComputedAt": datetime.utcnow().isoformat(),
998
- }, merge=True)
 
 
 
999
  except Exception as e:
1000
  logger.error(f"Failed to write WRI to classRecords for LRN {lrn}: {e}")
1001
 
1002
  try:
1003
  matched = users_ref.where("lrn", "==", lrn).limit(1).stream()
1004
  for user_doc in matched:
1005
- user_doc.reference.set({
1006
  "latestGrade": transmuted,
1007
  "wriExternalGrade": transmuted,
1008
- "wriScore": wri_value,
1009
- "wriRiskBand": risk_status,
1010
  "wriUpdatedAt": datetime.utcnow().isoformat(),
1011
- }, merge=True)
 
 
 
 
1012
  logger.info(f"LRN {lrn} matched to user {user_doc.id}: grade={transmuted}, WRI={wri_value}")
1013
  break
1014
  except Exception as e:
@@ -1026,7 +1041,7 @@ def _persist_students(
1026
  return False
1027
 
1028
  batch = client.batch()
1029
- section_ref = client.collection("classRecords").document(teacher_uid).collection("sections").document(section_id)
1030
  count = 0
1031
 
1032
  try:
@@ -1057,7 +1072,7 @@ def _persist_students(
1057
  "updatedAt": datetime.utcnow().isoformat(),
1058
  }
1059
 
1060
- student_ref = section_ref.collection("students").document(lrn)
1061
  batch.set(student_ref, student_doc, merge=True)
1062
  count += 1
1063
 
@@ -1076,7 +1091,7 @@ def _persist_students(
1076
  logger.error(f"Final batch commit failed: {e}")
1077
  return False
1078
 
1079
- logger.info(f"Persisted {count} students to classRecords/{teacher_uid}/sections/{section_id}")
1080
  return True
1081
  except Exception as e:
1082
  logger.error(f"Failed to persist students: {e}")
 
692
  upload_data = upload_snap.to_dict()
693
  section_id = (upload_data.get("section") or "unknown").replace(" ", "_").lower()
694
 
695
+ students_ref = upload_ref.collection("students")
696
  query_ref = students_ref.limit(limit + 1)
697
  if after:
698
  after_doc = students_ref.document(after).get()
699
  if after_doc.exists:
700
  query_ref = query_ref.start_after(after_doc)
701
 
702
+ docs = list(query_ref.stream())
703
+ if not docs:
704
+ legacy_ref = client.collection("classRecords").document(teacher_uid).collection("sections").document(section_id).collection("students")
705
+ query_ref = legacy_ref.limit(limit + 1)
706
+ if after:
707
+ after_doc = legacy_ref.document(after).get()
708
+ if after_doc.exists:
709
+ query_ref = query_ref.start_after(after_doc)
710
+ docs = list(query_ref.stream())
711
+
712
  students: List[Dict[str, Any]] = []
713
  for d in docs:
714
  data = d.to_dict()
 
748
  section_id = (upload_data.get("section") or "unknown").replace(" ", "_").lower()
749
  metadata = upload_data.get("metadata", {})
750
 
751
+ students_ref = upload_ref.collection("students")
752
+ docs = list(students_ref.stream())
753
+ if not docs:
754
+ docs = list(client.collection("classRecords").document(teacher_uid).collection("sections").document(section_id).collection("students").stream())
755
  all_students = [d.to_dict() for d in docs if d.to_dict()]
756
 
757
  total = len(all_students)
 
1002
 
1003
  try:
1004
  student_ref = section_ref.collection("students").document(lrn)
1005
+ student_data: Dict[str, Any] = {
 
1006
  "wriRiskBand": risk_status,
1007
  "wriComputedAt": datetime.utcnow().isoformat(),
1008
+ }
1009
+ if wri_value is not None:
1010
+ student_data["wriScore"] = wri_value
1011
+ student_ref.set(student_data, merge=True)
1012
  except Exception as e:
1013
  logger.error(f"Failed to write WRI to classRecords for LRN {lrn}: {e}")
1014
 
1015
  try:
1016
  matched = users_ref.where("lrn", "==", lrn).limit(1).stream()
1017
  for user_doc in matched:
1018
+ user_payload: Dict[str, Any] = {
1019
  "latestGrade": transmuted,
1020
  "wriExternalGrade": transmuted,
 
 
1021
  "wriUpdatedAt": datetime.utcnow().isoformat(),
1022
+ }
1023
+ if wri_value is not None:
1024
+ user_payload["wriScore"] = wri_value
1025
+ user_payload["wriRiskBand"] = risk_status
1026
+ user_doc.reference.set(user_payload, merge=True)
1027
  logger.info(f"LRN {lrn} matched to user {user_doc.id}: grade={transmuted}, WRI={wri_value}")
1028
  break
1029
  except Exception as e:
 
1041
  return False
1042
 
1043
  batch = client.batch()
1044
+ upload_ref = client.collection("classRecords").document(teacher_uid).collection("uploads").document(upload_id)
1045
  count = 0
1046
 
1047
  try:
 
1072
  "updatedAt": datetime.utcnow().isoformat(),
1073
  }
1074
 
1075
+ student_ref = upload_ref.collection("students").document(lrn)
1076
  batch.set(student_ref, student_doc, merge=True)
1077
  count += 1
1078
 
 
1091
  logger.error(f"Final batch commit failed: {e}")
1092
  return False
1093
 
1094
+ logger.info(f"Persisted {count} students to classRecords/{teacher_uid}/uploads/{upload_id}/students")
1095
  return True
1096
  except Exception as e:
1097
  logger.error(f"Failed to persist students: {e}")
routes/deepseek_rag_routes.py CHANGED
@@ -13,7 +13,7 @@ from pydantic import BaseModel, Field
13
  from fastapi import APIRouter
14
 
15
  from services.ai_client import REASONER_MODEL, CHAT_MODEL
16
- from services.deepseek_client import is_enabled, rag_grounded_completion, parse_json_response
17
  from rag.curriculum_rag import (
18
  retrieve_curriculum_context,
19
  build_analysis_curriculum_context,
 
13
  from fastapi import APIRouter
14
 
15
  from services.ai_client import REASONER_MODEL, CHAT_MODEL
16
+ from services.inference_client import is_enabled, rag_grounded_completion, parse_json_response
17
  from rag.curriculum_rag import (
18
  retrieve_curriculum_context,
19
  build_analysis_curriculum_context,
routes/pipeline_routes.py CHANGED
@@ -100,7 +100,7 @@ async def recompute_profile(student_id: str, background_tasks: BackgroundTasks,
100
  # Trigger a synthetic diagnostic event to force full recompute
101
  event = StudentActivityEvent(
102
  student_id=student_id,
103
- event_type="session",
104
  event_data={"event": "force_recompute"},
105
  occurred_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat(),
106
  class_id="",
 
100
  # Trigger a synthetic diagnostic event to force full recompute
101
  event = StudentActivityEvent(
102
  student_id=student_id,
103
+ event_type="force_recompute",
104
  event_data={"event": "force_recompute"},
105
  occurred_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat(),
106
  class_id="",
services/class_analytics_engine.py CHANGED
@@ -46,7 +46,7 @@ class StudentAnalyticsSummary(BaseModel):
46
  avg_score: float = 0.0
47
  quiz_attempt_count: int = 0
48
  last_active: Optional[str] = None
49
- risk_level: Literal["Low Risk", "Medium Risk", "High Risk", "Critical", "Unassessed"] = "Unassessed"
50
  engagement_level: Literal["Low", "Medium", "High"] = "Low"
51
  weakest_topic: Optional[str] = None
52
  accuracy_by_topic: Dict[str, float] = Field(default_factory=dict)
@@ -188,6 +188,7 @@ class ClassAnalyticsEngine:
188
  topic_perf = self._compute_topic_performance(student_summaries)
189
 
190
  # Risk distribution
 
191
  risk_dist = {"safe": 0, "watch": 0, "intervene": 0, "critical": 0, "at_risk": 0, "pending_assessment": 0}
192
  for s in student_summaries:
193
  # Prefer stored WRI status from managedStudents if available
@@ -198,7 +199,7 @@ class ClassAnalyticsEngine:
198
  stored_status = ms_doc.to_dict().get("riskStatus")
199
  except Exception:
200
  pass
201
- status = stored_status if stored_status in risk_dist else s.risk_level
202
  risk_dist[status] = risk_dist.get(status, 0) + 1
203
 
204
  # Generate AI insights
@@ -442,11 +443,12 @@ Completion Rate: {completion_rate:.1f}%
442
  Participation Rate: {participation_rate:.1f}%
443
 
444
  Risk Distribution:
445
- - Critical: {risk_dist.get('Critical', 0)} students
446
- - High Risk: {risk_dist.get('High Risk', 0)} students
447
- - Medium Risk: {risk_dist.get('Medium Risk', 0)} students
448
- - Low Risk: {risk_dist.get('Low Risk', 0)} students
449
- - Unassessed: {risk_dist.get('Unassessed', 0)} students
 
450
 
451
  Topic Performance (class accuracy):
452
  {topic_lines}
@@ -501,12 +503,12 @@ Be specific to Filipino K-12 DepEd context. If data is limited, acknowledge it a
501
  return ClassInsights(
502
  class_id=class_id,
503
  generated_at=_now_iso(),
504
- class_summary=f"Class has {student_count} students with an average score of {class_average:.0f}%. {risk_dist.get('Unassessed', 0)} students have not yet taken any quizzes.",
505
  top_weak_topics=weak_topics,
506
  recommended_actions=[
507
  "Encourage unassessed students to complete their first quiz.",
508
  "Review struggling topics in the next class session.",
509
- "Schedule one-on-one check-ins with Critical risk students.",
510
  ],
511
  class_strengths="Students are enrolled and the platform is ready for use." if class_average < 50 else f"Class maintains a {class_average:.0f}% average.",
512
  risk_distribution=risk_dist,
 
46
  avg_score: float = 0.0
47
  quiz_attempt_count: int = 0
48
  last_active: Optional[str] = None
49
+ risk_level: Literal["safe", "watch", "intervene", "critical", "at_risk", "pending_assessment"] = "pending_assessment"
50
  engagement_level: Literal["Low", "Medium", "High"] = "Low"
51
  weakest_topic: Optional[str] = None
52
  accuracy_by_topic: Dict[str, float] = Field(default_factory=dict)
 
188
  topic_perf = self._compute_topic_performance(student_summaries)
189
 
190
  # Risk distribution
191
+ from services.wri_service import normalize_risk_band
192
  risk_dist = {"safe": 0, "watch": 0, "intervene": 0, "critical": 0, "at_risk": 0, "pending_assessment": 0}
193
  for s in student_summaries:
194
  # Prefer stored WRI status from managedStudents if available
 
199
  stored_status = ms_doc.to_dict().get("riskStatus")
200
  except Exception:
201
  pass
202
+ status = normalize_risk_band(stored_status) if stored_status else s.risk_level
203
  risk_dist[status] = risk_dist.get(status, 0) + 1
204
 
205
  # Generate AI insights
 
443
  Participation Rate: {participation_rate:.1f}%
444
 
445
  Risk Distribution:
446
+ - Safe: {risk_dist.get('safe', 0)} students
447
+ - Watch: {risk_dist.get('watch', 0)} students
448
+ - Intervene: {risk_dist.get('intervene', 0)} students
449
+ - Critical: {risk_dist.get('critical', 0)} students
450
+ - At Risk: {risk_dist.get('at_risk', 0)} students
451
+ - Pending Assessment: {risk_dist.get('pending_assessment', 0)} students
452
 
453
  Topic Performance (class accuracy):
454
  {topic_lines}
 
503
  return ClassInsights(
504
  class_id=class_id,
505
  generated_at=_now_iso(),
506
+ class_summary=f"Class has {student_count} students with an average score of {class_average:.0f}%. {risk_dist.get('pending_assessment', 0)} students have not yet taken any quizzes.",
507
  top_weak_topics=weak_topics,
508
  recommended_actions=[
509
  "Encourage unassessed students to complete their first quiz.",
510
  "Review struggling topics in the next class session.",
511
+ "Schedule one-on-one check-ins with critical / at-risk students.",
512
  ],
513
  class_strengths="Students are enrolled and the platform is ready for use." if class_average < 50 else f"Class maintains a {class_average:.0f}% average.",
514
  risk_distribution=risk_dist,
services/deepseek_client.py DELETED
@@ -1,87 +0,0 @@
1
- """
2
- RAG-grounded DeepSeek client wrapper.
3
-
4
- All calls go through `rag_grounded_completion()` which enforces:
5
- - DEEPSEEK_ENABLED feature flag check
6
- - Retry with exponential backoff on 429
7
- - Token usage logging
8
- """
9
-
10
- import os
11
- import time
12
- import json
13
- import logging
14
- from typing import Optional
15
-
16
- from services.ai_client import get_deepseek_client, CHAT_MODEL, REASONER_MODEL, RateLimitError
17
-
18
- logger = logging.getLogger(__name__)
19
-
20
- DEEPSEEK_ENABLED = os.getenv("DEEPSEEK_ENABLED", "true").lower() in ("true", "1", "yes")
21
- MAX_RETRIES = 3
22
- BACKOFF_DELAYS = [2, 4, 8]
23
-
24
-
25
- def is_enabled() -> bool:
26
- return DEEPSEEK_ENABLED
27
-
28
-
29
- def rag_grounded_completion(
30
- model: str,
31
- system_prompt: str,
32
- user_prompt: str,
33
- temperature: float = 0.2,
34
- ) -> Optional[str]:
35
- """
36
- Call DeepSeek with retry on 429. Returns response text or None if disabled/failed.
37
- Logs token usage per call.
38
- """
39
- if not DEEPSEEK_ENABLED:
40
- logger.info("[DEEPSEEK] Disabled via DEEPSEEK_ENABLED flag, skipping.")
41
- return None
42
-
43
- client = get_deepseek_client()
44
-
45
- for attempt in range(MAX_RETRIES):
46
- try:
47
- response = client.chat.completions.create(
48
- model=model,
49
- messages=[
50
- {"role": "system", "content": system_prompt},
51
- {"role": "user", "content": user_prompt},
52
- ],
53
- temperature=temperature,
54
- )
55
- usage = response.usage
56
- if usage:
57
- logger.info(
58
- "[DEEPSEEK] model=%s prompt_tokens=%d completion_tokens=%d total=%d",
59
- model, usage.prompt_tokens, usage.completion_tokens, usage.total_tokens,
60
- )
61
- return response.choices[0].message.content or ""
62
- except RateLimitError:
63
- delay = BACKOFF_DELAYS[attempt] if attempt < len(BACKOFF_DELAYS) else 8
64
- logger.warning("[DEEPSEEK] 429 rate limited, retry %d/%d in %ds", attempt + 1, MAX_RETRIES, delay)
65
- time.sleep(delay)
66
- except Exception as e:
67
- logger.error("[DEEPSEEK] Call failed: %s", e)
68
- return None
69
-
70
- logger.error("[DEEPSEEK] All %d retries exhausted.", MAX_RETRIES)
71
- return None
72
-
73
-
74
- def parse_json_response(text: Optional[str]) -> Optional[dict]:
75
- """Attempt to parse JSON from DeepSeek response, handling markdown fences."""
76
- if not text:
77
- return None
78
- cleaned = text.strip()
79
- if cleaned.startswith("```"):
80
- lines = cleaned.split("\n")
81
- lines = [l for l in lines if not l.strip().startswith("```")]
82
- cleaned = "\n".join(lines)
83
- try:
84
- return json.loads(cleaned)
85
- except json.JSONDecodeError:
86
- logger.warning("[DEEPSEEK] Failed to parse JSON response")
87
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/inference_client.py CHANGED
@@ -675,30 +675,6 @@ class InferenceClient:
675
  return self.interactive_timeout_sec
676
  return self.background_timeout_sec
677
 
678
- def _messages_to_prompt(self, messages: List[Dict[str, str]]) -> str:
679
- parts: List[str] = []
680
- for msg in messages:
681
- role = (msg.get("role") or "user").strip().lower()
682
- content = (msg.get("content") or "").strip()
683
- if not content or role in {"tool", "function"}:
684
- continue
685
- prefix = "USER"
686
- if role == "system":
687
- prefix = "SYSTEM"
688
- elif role == "assistant":
689
- prefix = "ASSISTANT"
690
- parts.append(f"{prefix}:\n{content}")
691
- parts.append("ASSISTANT:")
692
- return "\n\n".join(parts)
693
-
694
- def _latest_user_message(self, messages: List[Dict[str, str]]) -> str:
695
- for msg in reversed(messages):
696
- role = (msg.get("role") or "").strip().lower()
697
- content = (msg.get("content") or "").strip()
698
- if role == "user" and content:
699
- return content
700
- return self._messages_to_prompt(messages)
701
-
702
  def _call_deepseek(self, req: InferenceRequest, fallback_depth: int) -> str:
703
  """Call DeepSeek API with OpenAI-compatible chat completions."""
704
  if not self.ds_api_key:
@@ -769,12 +745,6 @@ class InferenceClient:
769
  fallback_depth=fallback_depth,
770
  route=route,
771
  )
772
- self._record_attempt(
773
- task_type=task_type,
774
- provider="deepseek",
775
- route=route,
776
- fallback_depth=fallback_depth,
777
- )
778
  self._record_completion(latency_ms=latency_ms)
779
  self._bump_metric("requests_ok", 1)
780
  return text
@@ -881,155 +851,75 @@ class InferenceClient:
881
 
882
  raise RuntimeError(f"DeepSeek call failed after {max_retries} attempts")
883
 
884
- def _call_local_space(self, req: InferenceRequest, *, provider: str, route: str, fallback_depth: int) -> str:
885
- target_model = req.model or self.default_model
886
- url = f"{self.local_space_url.rstrip('/')}{self.local_generate_path}"
887
-
888
- prompt = self._messages_to_prompt(req.messages)
889
- payload: Dict[str, object] = {
890
- "data": [
891
- prompt,
892
- [],
893
- req.temperature,
894
- req.top_p,
895
- req.max_new_tokens,
896
- ]
897
- }
898
- headers = {"Content-Type": "application/json"}
899
 
900
- timeout = self._timeout_for(req, provider)
 
 
901
 
902
- self._record_attempt(
903
- task_type=req.task_type,
904
- provider=provider,
905
- route=route,
906
- fallback_depth=fallback_depth,
907
- )
908
- start = time.perf_counter()
909
 
910
- try:
911
- resp = requests.post(url, headers=headers, json=payload, timeout=timeout)
912
- except Exception as exc:
913
- latency_ms = (time.perf_counter() - start) * 1000
914
- log_model_call(
915
- LOGGER,
916
- provider=provider,
917
- model=target_model,
918
- endpoint=url,
919
- latency_ms=latency_ms,
920
- input_tokens=None,
921
- output_tokens=None,
922
- status="error",
923
- error_class=exc.__class__.__name__,
924
- error_message=str(exc),
925
- task_type=req.task_type,
926
- request_tag=req.request_tag,
927
- retry_attempt=1,
928
- fallback_depth=fallback_depth,
929
- route=route,
930
- )
931
- self._bump_metric("requests_error", 1)
932
- raise
933
-
934
- latency_ms = (time.perf_counter() - start) * 1000
935
- self._bump_bucket("status_code_counts", str(resp.status_code), 1)
936
-
937
- if resp.status_code != 200:
938
- self._bump_metric("requests_error", 1)
939
- raise RuntimeError(f"Local Space error {resp.status_code}: {resp.text}")
940
-
941
- data = resp.json()
942
- event_id = data.get("event_id")
943
- if not event_id:
944
- return self._extract_text(data)
945
-
946
- result_url = f"{self.local_space_url.rstrip('/')}/gradio_api/call/generate/{event_id}"
947
- result_resp = requests.get(result_url, timeout=req.timeout_sec or self.local_timeout_sec)
948
- if result_resp.status_code != 200:
949
- raise RuntimeError(f"Local Space result error {result_resp.status_code}: {result_resp.text}")
950
-
951
- line_data = None
952
- for line in result_resp.text.splitlines():
953
- if line.startswith("data:"):
954
- line_data = line.split("data:", 1)[1].strip()
955
-
956
- if not line_data:
957
- raise RuntimeError("Local Space result stream missing data")
958
-
959
- parsed = json.loads(line_data)
960
- output_payload = parsed if isinstance(parsed, dict) else {"data": parsed}
961
- text = self._extract_text(output_payload)
962
- log_model_call(
963
- LOGGER,
964
- provider=provider,
965
- model=target_model,
966
- endpoint=url,
967
- latency_ms=latency_ms,
968
- input_tokens=None,
969
- output_tokens=None,
970
- status="ok",
971
- task_type=req.task_type,
972
- request_tag=req.request_tag,
973
- retry_attempt=1,
974
- fallback_depth=fallback_depth,
975
- route=route,
976
- )
977
- self._bump_metric("requests_ok", 1)
978
- return text
979
-
980
- def _extract_text(self, data: object) -> str:
981
- """Extract clean text from inference response, stripping JSON artifacts."""
982
- if isinstance(data, list) and data:
983
- first = data[0]
984
- if isinstance(first, dict):
985
- val = (first.get("generated_text") or "").strip()
986
- if val:
987
- return self._clean_response_text(val)
988
-
989
- if isinstance(data, dict):
990
- direct = (data.get("generated_text") or "").strip()
991
- if direct:
992
- return self._clean_response_text(direct)
993
-
994
- choices = data.get("choices", [])
995
- if choices:
996
- message = choices[0].get("message", {})
997
- msg = (message.get("content") or "").strip()
998
- if msg:
999
- return self._clean_response_text(msg)
1000
- reasoning = (message.get("reasoning") or "").strip()
1001
- if reasoning:
1002
- return self._clean_response_text(reasoning)
1003
 
1004
- generic_data = data.get("data")
1005
- if isinstance(generic_data, list) and generic_data:
1006
- first = generic_data[0]
1007
- if isinstance(first, str) and first.strip():
1008
- return self._clean_response_text(first.strip())
1009
 
1010
- raise RuntimeError(f"Unexpected inference response format: {data}")
 
 
 
 
 
 
 
 
 
1011
 
1012
- def _clean_response_text(self, text: str) -> str:
1013
- """Strip JSON braces, template artifacts, and whitespace from response text."""
1014
- text = text.strip()
1015
 
1016
- if text.startswith("{") and text.endswith("}"):
1017
- try:
1018
- parsed = json.loads(text)
1019
- if isinstance(parsed, dict):
1020
- if "content" in parsed:
1021
- text = str(parsed["content"]).strip()
1022
- elif "text" in parsed:
1023
- text = str(parsed["text"]).strip()
1024
- except json.JSONDecodeError:
1025
- text = text.strip("{}")
1026
-
1027
- if text.startswith("```json") or text.startswith("```"):
1028
- text = re.sub(r"^```(?:json)?", "", text).strip()
1029
- if text.endswith("```"):
1030
- text = text[:-3].strip()
1031
-
1032
- return text.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1033
 
1034
 
1035
  def create_default_client(firestore_client: Optional[Any] = None) -> InferenceClient:
 
675
  return self.interactive_timeout_sec
676
  return self.background_timeout_sec
677
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
678
  def _call_deepseek(self, req: InferenceRequest, fallback_depth: int) -> str:
679
  """Call DeepSeek API with OpenAI-compatible chat completions."""
680
  if not self.ds_api_key:
 
745
  fallback_depth=fallback_depth,
746
  route=route,
747
  )
 
 
 
 
 
 
748
  self._record_completion(latency_ms=latency_ms)
749
  self._bump_metric("requests_ok", 1)
750
  return text
 
851
 
852
  raise RuntimeError(f"DeepSeek call failed after {max_retries} attempts")
853
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
854
 
855
+ DEEPSEEK_ENABLED = os.getenv("DEEPSEEK_ENABLED", "true").lower() in ("true", "1", "yes")
856
+ _MAX_RETRIES = 3
857
+ _BACKOFF_DELAYS = [2, 4, 8]
858
 
 
 
 
 
 
 
 
859
 
860
+ def is_enabled() -> bool:
861
+ return DEEPSEEK_ENABLED
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
862
 
 
 
 
 
 
863
 
864
+ def rag_grounded_completion(
865
+ model: str,
866
+ system_prompt: str,
867
+ user_prompt: str,
868
+ temperature: float = 0.2,
869
+ ) -> Optional[str]:
870
+ """Call DeepSeek with retry on 429. Returns response text or None if disabled/failed."""
871
+ if not is_enabled():
872
+ LOGGER.info("[DEEPSEEK] Disabled via DEEPSEEK_ENABLED flag, skipping.")
873
+ return None
874
 
875
+ client = get_deepseek_client()
 
 
876
 
877
+ for attempt in range(_MAX_RETRIES):
878
+ try:
879
+ response = client.chat.completions.create(
880
+ model=model,
881
+ messages=[
882
+ {"role": "system", "content": system_prompt},
883
+ {"role": "user", "content": user_prompt},
884
+ ],
885
+ temperature=temperature,
886
+ )
887
+ usage = response.usage
888
+ if usage:
889
+ LOGGER.info(
890
+ "[DEEPSEEK] model=%s prompt_tokens=%d completion_tokens=%d total=%d",
891
+ model,
892
+ usage.prompt_tokens,
893
+ usage.completion_tokens,
894
+ usage.total_tokens,
895
+ )
896
+ return response.choices[0].message.content or ""
897
+ except RateLimitError:
898
+ delay = _BACKOFF_DELAYS[attempt] if attempt < len(_BACKOFF_DELAYS) else 8
899
+ LOGGER.warning("[DEEPSEEK] 429 rate limited, retry %d/%d in %ds", attempt + 1, _MAX_RETRIES, delay)
900
+ time.sleep(delay)
901
+ except Exception as e:
902
+ LOGGER.error("[DEEPSEEK] Call failed: %s", e)
903
+ return None
904
+
905
+ LOGGER.error("[DEEPSEEK] All %d retries exhausted.", _MAX_RETRIES)
906
+ return None
907
+
908
+
909
+ def parse_json_response(text: Optional[str]) -> Optional[dict]:
910
+ """Attempt to parse JSON from DeepSeek response, handling markdown fences."""
911
+ if not text:
912
+ return None
913
+ cleaned = text.strip()
914
+ if cleaned.startswith("```"):
915
+ lines = cleaned.split("\n")
916
+ lines = [l for l in lines if not l.strip().startswith("```")]
917
+ cleaned = "\n".join(lines)
918
+ try:
919
+ return json.loads(cleaned)
920
+ except json.JSONDecodeError:
921
+ LOGGER.warning("[DEEPSEEK] Failed to parse JSON response")
922
+ return None
923
 
924
 
925
  def create_default_client(firestore_client: Optional[Any] = None) -> InferenceClient:
services/intervention_engine.py CHANGED
@@ -67,7 +67,7 @@ class InterventionPlan(BaseModel):
67
  student_name: str = ""
68
  grade_level: str = ""
69
  section: str = ""
70
- risk_level: Literal["Low Risk", "Medium Risk", "High Risk", "Critical", "Unassessed"] = "Unassessed"
71
  avg_score: float = 0.0
72
  engagement_level: Literal["Low", "Medium", "High"] = "Low"
73
  last_active: Optional[str] = None
@@ -83,17 +83,19 @@ class InterventionPlan(BaseModel):
83
 
84
  # โ”€โ”€โ”€ Risk & Engagement Classification โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
85
 
86
- def _classify_risk(avg_score: float, quiz_count: int, days_since_active: Optional[int]) -> str:
 
87
  if quiz_count == 0:
88
- return "Unassessed"
89
- engagement_low = (days_since_active is None or days_since_active > 7) or quiz_count < 3
90
- if avg_score < 50 and engagement_low:
91
- return "Critical"
92
- if avg_score < 60 or (avg_score < 75 and engagement_low):
93
- return "High Risk"
94
- if avg_score < 75:
95
- return "Medium Risk"
96
- return "Low Risk"
 
97
 
98
 
99
  def _classify_engagement(days_since_active: Optional[int], recent_quiz_count: int, lessons_completed: int = 0) -> str:
@@ -127,9 +129,18 @@ class InterventionEngine:
127
  logger.error("Firestore client unavailable")
128
  return InterventionPlan(student_id=student_id, generated_at=_now_iso())
129
 
130
- # Fetch student data from managedStudents
 
 
 
 
 
 
 
 
 
131
  student_data = await self._fetch_student_data(db, student_id)
132
- if not student_data:
133
  return InterventionPlan(
134
  student_id=student_id,
135
  student_name="Unknown",
@@ -138,67 +149,107 @@ class InterventionEngine:
138
  next_steps_summary="Assign a diagnostic quiz to begin intervention planning.",
139
  )
140
 
141
- # Fetch quiz attempts
142
- quiz_attempts = await self._fetch_quiz_attempts(db, student_id, student_data)
 
 
 
 
 
 
143
 
144
- # Compute metrics
145
  now = datetime.now(timezone.utc)
146
- quiz_count = len(quiz_attempts)
147
- avg_score = 0.0
148
- accuracy_by_topic: Dict[str, List[float]] = {}
149
-
150
- if quiz_count > 0:
151
- scores = [float(q.get("score", 0)) for q in quiz_attempts]
152
- avg_score = sum(scores) / len(scores)
153
-
154
- for attempt in quiz_attempts:
155
- topic = self._extract_topic(attempt)
156
- if topic:
157
- if topic not in accuracy_by_topic:
158
- accuracy_by_topic[topic] = []
159
- accuracy_by_topic[topic].append(float(attempt.get("score", 0)))
160
-
161
- topic_avgs = {t: round(sum(s) / len(s), 1) for t, s in accuracy_by_topic.items() if s}
162
- weak_topics = [t for t, s in sorted(topic_avgs.items(), key=lambda x: x[1]) if s < 70][:5]
163
- strong_topics = [t for t, s in topic_avgs.items() if s >= 70]
164
- weakest_topic = weak_topics[0] if weak_topics else student_data.get("weakestTopic", "Foundational Skills")
165
- if weakest_topic == "N/A":
166
- weakest_topic = "Foundational Skills"
167
-
168
- # Last active
169
- days_since_active = None
170
- last_active_str = None
171
- last_active_ts = student_data.get("lastActive")
172
- if last_active_ts:
173
- try:
174
- if hasattr(last_active_ts, "seconds"):
175
- last_dt = datetime.fromtimestamp(last_active_ts.seconds, tz=timezone.utc)
176
- else:
177
- last_dt = last_active_ts
178
- last_active_str = last_dt.isoformat()
179
- days_since_active = (now - last_dt).days
180
- except Exception:
181
- pass
182
-
183
- # Recent quiz count (last 14 days)
184
- recent_count = sum(1 for q in quiz_attempts if self._is_recent(q, now, 14))
185
-
186
- # Fetch lessons completed from progress doc
187
- lessons_completed = 0
188
- for lookup_id in [student_id, student_data.get("accountUid")]:
189
- if not lookup_id:
190
- continue
191
- try:
192
- pdoc = db.collection("progress").document(lookup_id).get()
193
- if pdoc.exists:
194
- lessons_completed = pdoc.to_dict().get("totalLessonsCompleted", 0)
195
- break
196
- except Exception:
197
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
 
199
- risk_level = _classify_risk(avg_score, quiz_count, days_since_active)
200
  engagement = _classify_engagement(days_since_active, recent_count, lessons_completed)
201
 
 
202
  # Generate AI insights
203
  insights = await self._generate_insights(
204
  grade_level=student_data.get("gradeLevel", student_data.get("grade", "11")),
@@ -405,7 +456,7 @@ Return as JSON:
405
  topic_avgs = kwargs["topic_avgs"]
406
 
407
  style_hint = "shorter steps (5-8 min), gamified" if engagement == "Low" else "standard pacing (10-15 min)"
408
- estimated_days = 5 if risk_level == "Critical" else 7
409
 
410
  prompt = f"""Create a personalized intervention learning path for a Filipino K-12 math student.
411
 
 
67
  student_name: str = ""
68
  grade_level: str = ""
69
  section: str = ""
70
+ risk_level: str = "pending_assessment"
71
  avg_score: float = 0.0
72
  engagement_level: Literal["Low", "Medium", "High"] = "Low"
73
  last_active: Optional[str] = None
 
83
 
84
  # โ”€โ”€โ”€ Risk & Engagement Classification โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
85
 
86
+ def _classify_risk(avg_score: float, quiz_count: int, days_since_active: Optional[int] = None) -> str:
87
+ """Canonical 5-band DepEd classification when WRI is not directly available."""
88
  if quiz_count == 0:
89
+ return "pending_assessment"
90
+ if avg_score >= 88:
91
+ return "safe"
92
+ if avg_score >= 80:
93
+ return "watch"
94
+ if avg_score >= 75:
95
+ return "intervene"
96
+ if avg_score >= 68:
97
+ return "critical"
98
+ return "at_risk"
99
 
100
 
101
  def _classify_engagement(days_since_active: Optional[int], recent_quiz_count: int, lessons_completed: int = 0) -> str:
 
129
  logger.error("Firestore client unavailable")
130
  return InterventionPlan(student_id=student_id, generated_at=_now_iso())
131
 
132
+ # Check student_profiles directly first to minimize collection roundtrips
133
+ profile_data = None
134
+ try:
135
+ pdoc = db.collection("student_profiles").document(student_id).get()
136
+ if pdoc.exists:
137
+ profile_data = pdoc.to_dict()
138
+ except Exception as e:
139
+ logger.debug(f"Error reading student_profiles/{student_id}: {e}")
140
+
141
+ # Fetch student data from managedStudents if needed
142
  student_data = await self._fetch_student_data(db, student_id)
143
+ if not student_data and not profile_data:
144
  return InterventionPlan(
145
  student_id=student_id,
146
  student_name="Unknown",
 
149
  next_steps_summary="Assign a diagnostic quiz to begin intervention planning.",
150
  )
151
 
152
+ if not student_data:
153
+ student_data = {
154
+ "id": student_id,
155
+ "name": profile_data.get("display_name") or profile_data.get("name", "Unknown"),
156
+ "gradeLevel": profile_data.get("grade_level", profile_data.get("grade", "11")),
157
+ "section": profile_data.get("section", ""),
158
+ **profile_data,
159
+ }
160
 
 
161
  now = datetime.now(timezone.utc)
162
+ qp = (profile_data or {}).get("quiz_performance", {})
163
+ diag = (profile_data or {}).get("diagnostic", {})
164
+ ce = (profile_data or {}).get("content_engagement", {})
165
+ eng = (profile_data or {}).get("engagement", {})
166
+
167
+ # If profile_data already has quiz performance, use it directly to save roundtrips
168
+ if qp.get("total_attempts", 0) > 0 or qp.get("accuracy_by_topic"):
169
+ quiz_count = qp.get("total_attempts", 0)
170
+ avg_score = float(qp.get("avg_score_all_time") or profile_data.get("system_performance_avg") or 0.0)
171
+ topic_avgs = {t: float(s) for t, s in qp.get("accuracy_by_topic", {}).items()}
172
+ weak_topics = [t for t, s in sorted(topic_avgs.items(), key=lambda x: x[1]) if s < 70][:5]
173
+ if not weak_topics and qp.get("lowest_accuracy_topics"):
174
+ weak_topics = qp.get("lowest_accuracy_topics")[:5]
175
+ strong_topics = [t for t, s in topic_avgs.items() if s >= 70]
176
+ if not strong_topics and qp.get("highest_accuracy_topics"):
177
+ strong_topics = qp.get("highest_accuracy_topics")[:3]
178
+ weakest_topic = weak_topics[0] if weak_topics else student_data.get("weakestTopic", "Foundational Skills")
179
+ lessons_completed = ce.get("lessons_completed", 0)
180
+ days_since_active = eng.get("days_since_last_active")
181
+ last_active_str = eng.get("last_active_at")
182
+ recent_count = len(qp.get("recent_attempts", []))
183
+ else:
184
+ # Fallback: fetch quiz attempts and progress doc
185
+ quiz_attempts = await self._fetch_quiz_attempts(db, student_id, student_data)
186
+ quiz_count = len(quiz_attempts)
187
+ avg_score = 0.0
188
+ accuracy_by_topic: Dict[str, List[float]] = {}
189
+
190
+ if quiz_count > 0:
191
+ scores = [float(q.get("score", 0)) for q in quiz_attempts]
192
+ avg_score = sum(scores) / len(scores)
193
+
194
+ for attempt in quiz_attempts:
195
+ topic = self._extract_topic(attempt)
196
+ if topic:
197
+ if topic not in accuracy_by_topic:
198
+ accuracy_by_topic[topic] = []
199
+ accuracy_by_topic[topic].append(float(attempt.get("score", 0)))
200
+
201
+ topic_avgs = {t: round(sum(s) / len(s), 1) for t, s in accuracy_by_topic.items() if s}
202
+ weak_topics = [t for t, s in sorted(topic_avgs.items(), key=lambda x: x[1]) if s < 70][:5]
203
+ strong_topics = [t for t, s in topic_avgs.items() if s >= 70]
204
+ weakest_topic = weak_topics[0] if weak_topics else student_data.get("weakestTopic", "Foundational Skills")
205
+ if weakest_topic == "N/A":
206
+ weakest_topic = "Foundational Skills"
207
+
208
+ # Last active
209
+ days_since_active = None
210
+ last_active_str = None
211
+ last_active_ts = student_data.get("lastActive")
212
+ if last_active_ts:
213
+ try:
214
+ if hasattr(last_active_ts, "seconds"):
215
+ last_dt = datetime.fromtimestamp(last_active_ts.seconds, tz=timezone.utc)
216
+ else:
217
+ last_dt = last_active_ts
218
+ last_active_str = last_dt.isoformat()
219
+ days_since_active = (now - last_dt).days
220
+ except Exception:
221
+ pass
222
+
223
+ recent_count = sum(1 for q in quiz_attempts if self._is_recent(q, now, 14))
224
+
225
+ lessons_completed = 0
226
+ for lookup_id in [student_id, student_data.get("accountUid")]:
227
+ if not lookup_id:
228
+ continue
229
+ try:
230
+ pdoc = db.collection("progress").document(lookup_id).get()
231
+ if pdoc.exists:
232
+ lessons_completed = pdoc.to_dict().get("totalLessonsCompleted", 0)
233
+ break
234
+ except Exception:
235
+ pass
236
+
237
+ # Canonical risk classification: read canonical risk_status or wriRiskBand
238
+ raw_risk = (
239
+ (profile_data or {}).get("risk_status")
240
+ or (profile_data or {}).get("wriRiskBand")
241
+ or student_data.get("riskStatus")
242
+ or student_data.get("wriRiskBand")
243
+ )
244
+ if raw_risk:
245
+ from services.wri_service import normalize_risk_band
246
+ risk_level = normalize_risk_band(raw_risk)
247
+ else:
248
+ risk_level = _classify_risk(avg_score, quiz_count, days_since_active)
249
 
 
250
  engagement = _classify_engagement(days_since_active, recent_count, lessons_completed)
251
 
252
+
253
  # Generate AI insights
254
  insights = await self._generate_insights(
255
  grade_level=student_data.get("gradeLevel", student_data.get("grade", "11")),
 
456
  topic_avgs = kwargs["topic_avgs"]
457
 
458
  style_hint = "shorter steps (5-8 min), gamified" if engagement == "Low" else "standard pacing (10-15 min)"
459
+ estimated_days = 5 if risk_level == "critical" else 7
460
 
461
  prompt = f"""Create a personalized intervention learning path for a Filipino K-12 math student.
462
 
services/question_bank_service.py CHANGED
@@ -30,10 +30,18 @@ async def get_questions_for_battle(
30
 
31
  Uses Firestore random_seed field for pseudo-random ordering.
32
  If fewer than `count` questions exist, returns all available.
 
 
 
33
  """
34
  db = _get_db()
35
- collection_path = f"question_bank/{grade_level}/{topic}/questions"
36
- collection_ref = db.collection(collection_path)
 
 
 
 
 
37
 
38
  # Pseudo-random query using random_seed >= random threshold
39
  threshold = random.random()
@@ -57,13 +65,8 @@ async def get_questions_for_battle(
57
  docs.extend(list(fallback_query.stream()))
58
 
59
  questions = [doc.to_dict() for doc in docs]
60
- # Ensure all required fields are present
61
- valid_questions = []
62
- for q in questions:
63
- if q and all(k in q for k in ("question", "choices", "correct_answer", "difficulty")):
64
- valid_questions.append(q)
65
-
66
- return valid_questions
67
 
68
 
69
  async def cache_session_questions(
@@ -73,25 +76,22 @@ async def cache_session_questions(
73
  grade_level: int,
74
  topic: str,
75
  ) -> None:
76
- """Cache varied questions for a battle session with 24-hour TTL."""
 
 
 
 
77
  db = _get_db()
78
  session_ref = db.collection("quiz_battle_sessions").document(session_id)
79
-
80
  session_ref.set({
81
  "player_ids": player_ids,
82
  "grade_level": grade_level,
83
  "topic": topic,
 
84
  "created_at": firestore.SERVER_TIMESTAMP,
85
  "variance_cached_until": datetime.now(timezone.utc) + timedelta(hours=24),
86
  })
87
 
88
- # Write questions to subcollection
89
- batch = db.batch()
90
- for idx, q in enumerate(questions):
91
- q_ref = session_ref.collection("questions").document(str(idx))
92
- batch.set(q_ref, q)
93
- batch.commit()
94
-
95
 
96
  async def get_cached_session(session_id: str) -> Optional[List[Dict]]:
97
  """
@@ -115,9 +115,7 @@ async def get_cached_session(session_id: str) -> Optional[List[Dict]]:
115
  cached_until = datetime.fromtimestamp(cached_until.timestamp(), tz=timezone.utc)
116
 
117
  if cached_until > datetime.now(timezone.utc):
118
- # Return cached questions
119
- q_docs = db.collection("quiz_battle_sessions").document(session_id).collection("questions").stream()
120
- questions = [doc.to_dict() for doc in q_docs]
121
  return questions if questions else None
122
 
123
  return None
 
30
 
31
  Uses Firestore random_seed field for pseudo-random ordering.
32
  If fewer than `count` questions exist, returns all available.
33
+
34
+ Firestore path: question_bank/{grade_level}/topics/{topic}/questions/{docId}
35
+ (alternating collection/document segments โ€” 5 segments total).
36
  """
37
  db = _get_db()
38
+ collection_ref = (
39
+ db.collection("question_bank")
40
+ .document(str(grade_level))
41
+ .collection("topics")
42
+ .document(topic)
43
+ .collection("questions")
44
+ )
45
 
46
  # Pseudo-random query using random_seed >= random threshold
47
  threshold = random.random()
 
65
  docs.extend(list(fallback_query.stream()))
66
 
67
  questions = [doc.to_dict() for doc in docs]
68
+ required_fields = ("question", "choices", "correct_answer", "difficulty")
69
+ return [q for q in questions if q and all(k in q for k in required_fields)]
 
 
 
 
 
70
 
71
 
72
  async def cache_session_questions(
 
76
  grade_level: int,
77
  topic: str,
78
  ) -> None:
79
+ """Cache varied questions for a battle session with 24-hour TTL.
80
+
81
+ Questions are inlined as a list field in the root session document
82
+ (1 Firestore write vs. N subcollection batch writes).
83
+ """
84
  db = _get_db()
85
  session_ref = db.collection("quiz_battle_sessions").document(session_id)
 
86
  session_ref.set({
87
  "player_ids": player_ids,
88
  "grade_level": grade_level,
89
  "topic": topic,
90
+ "questions": questions,
91
  "created_at": firestore.SERVER_TIMESTAMP,
92
  "variance_cached_until": datetime.now(timezone.utc) + timedelta(hours=24),
93
  })
94
 
 
 
 
 
 
 
 
95
 
96
  async def get_cached_session(session_id: str) -> Optional[List[Dict]]:
97
  """
 
115
  cached_until = datetime.fromtimestamp(cached_until.timestamp(), tz=timezone.utc)
116
 
117
  if cached_until > datetime.now(timezone.utc):
118
+ questions: List[Dict] = data.get("questions") or []
 
 
119
  return questions if questions else None
120
 
121
  return None
services/student_intelligence_pipeline.py CHANGED
@@ -43,7 +43,7 @@ def _get_db():
43
 
44
  class StudentActivityEvent(BaseModel):
45
  student_id: str
46
- event_type: Literal["diagnostic", "quiz", "battle", "lesson", "module", "session"]
47
  event_data: Dict[str, Any] = Field(default_factory=dict)
48
  occurred_at: str # ISO string
49
  class_id: str = ""
@@ -314,6 +314,17 @@ class StudentIntelligencePipeline:
314
  eng["last_active_at"] = event.occurred_at
315
  eng["days_since_last_active"] = 0
316
 
 
 
 
 
 
 
 
 
 
 
 
317
  # โ”€โ”€โ”€ P computation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
318
 
319
  def _compute_system_performance_avg(
@@ -438,8 +449,8 @@ class StudentIntelligencePipeline:
438
  def _should_regenerate_ai(self, event: StudentActivityEvent, profile: Dict, result: ProfileUpdateResult) -> bool:
439
  if event.event_type == "session":
440
  return False
441
- if event.event_type == "diagnostic":
442
- return True
443
  if result.risk_status_changed:
444
  return True
445
  ai_ctx = profile.get("ai_context", {})
 
43
 
44
  class StudentActivityEvent(BaseModel):
45
  student_id: str
46
+ event_type: Literal["diagnostic", "quiz", "battle", "lesson", "module", "session", "force_recompute", "backfill"]
47
  event_data: Dict[str, Any] = Field(default_factory=dict)
48
  occurred_at: str # ISO string
49
  class_id: str = ""
 
314
  eng["last_active_at"] = event.occurred_at
315
  eng["days_since_last_active"] = 0
316
 
317
+ elif event.event_type in ("force_recompute", "backfill"):
318
+ if "diagnostic_score" in ed:
319
+ profile["diagnostic_score"] = ed["diagnostic_score"]
320
+ profile.setdefault("diagnostic", {})["overall_score"] = ed["diagnostic_score"]
321
+ if "external_grades_avg" in ed:
322
+ profile["external_grades_avg"] = ed["external_grades_avg"]
323
+ if "system_performance_avg" in ed:
324
+ profile["system_performance_avg"] = ed["system_performance_avg"]
325
+ if "wri_weights" in ed:
326
+ profile["wri_weights"] = ed["wri_weights"]
327
+
328
  # โ”€โ”€โ”€ P computation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
329
 
330
  def _compute_system_performance_avg(
 
449
  def _should_regenerate_ai(self, event: StudentActivityEvent, profile: Dict, result: ProfileUpdateResult) -> bool:
450
  if event.event_type == "session":
451
  return False
452
+ if event.event_type in ("diagnostic", "force_recompute", "backfill"):
453
+ return bool(result.risk_status_changed or not profile.get("ai_context", {}).get("generated_at"))
454
  if result.risk_status_changed:
455
  return True
456
  ai_ctx = profile.get("ai_context", {})
services/wri_service.py CHANGED
@@ -23,6 +23,34 @@ from typing import Optional
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],
 
23
  DEFAULT_WEIGHTS = {"w1": 0.30, "w2": 0.40, "w3": 0.30}
24
  WEIGHT_TOLERANCE = 0.001
25
 
26
+ CANONICAL_RISK_BANDS = ("safe", "watch", "intervene", "critical", "at_risk")
27
+ CANONICAL_RISK_STATUSES = ("safe", "watch", "intervene", "critical", "at_risk", "pending_assessment")
28
+
29
+
30
+ def normalize_risk_band(band: Optional[str]) -> str:
31
+ """Normalize legacy or mixed-case risk status strings to canonical 5-band WRI format."""
32
+ if not band:
33
+ return "pending_assessment"
34
+ normalized = band.strip().lower().replace("-", "_").replace(" ", "_")
35
+ if normalized in CANONICAL_RISK_STATUSES:
36
+ return normalized
37
+ tier_map = {
38
+ "low": "safe",
39
+ "low_risk": "safe",
40
+ "on_track": "safe",
41
+ "moderate": "watch",
42
+ "medium": "watch",
43
+ "medium_risk": "watch",
44
+ "high": "intervene",
45
+ "high_risk": "intervene",
46
+ "urgent": "critical",
47
+ "failing": "at_risk",
48
+ "unassessed": "pending_assessment",
49
+ "pending": "pending_assessment",
50
+ }
51
+ return tier_map.get(normalized, "pending_assessment")
52
+
53
+
54
 
55
  def compute_wri(
56
  d: Optional[float],
tests/test_admin_reingest.py ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for Admin Curriculum Reingest & Upload Endpoints.
3
+
4
+ Validates:
5
+ 1. POST /api/admin/reingest-pdf non-blocking execution with BackgroundTasks
6
+ (returns 200 OK, success=True, execution_mode="background_tasks", schedules run_cloud_ingestion_and_upload).
7
+ 2. POST /api/admin/reingest-pdf with GitHub workflow dispatch
8
+ (mocking urllib.request.urlopen returning HTTP 204 when GITHUB_PAT or GITHUB_TOKEN is set;
9
+ returns execution_mode="github_actions").
10
+ 3. POST /api/admin/upload-pdf non-blocking behavior
11
+ (re-ingestion scheduled via BackgroundTasks rather than running synchronously).
12
+ 4. GET /api/admin/reingest-status returns current REINGESTION_STATUS.
13
+ 5. Role-based access control (RBAC) enforcement on all endpoints.
14
+ """
15
+
16
+ from unittest.mock import AsyncMock, MagicMock, patch
17
+
18
+ import pytest
19
+ from fastapi import BackgroundTasks
20
+ from fastapi.testclient import TestClient
21
+
22
+ import main as main_module
23
+ from main import app
24
+ from routes.admin_routes import PDF_METADATA, REINGESTION_STATUS
25
+
26
+
27
+ @pytest.fixture
28
+ def admin_auth():
29
+ orig_ready = getattr(main_module, "_firebase_ready", False)
30
+ orig_init = getattr(main_module, "_init_firebase_admin", None)
31
+ orig_auth = main_module.firebase_auth
32
+ orig_verify = getattr(main_module.firebase_auth, "verify_id_token", None) if main_module.firebase_auth else None
33
+
34
+ main_module._firebase_ready = True
35
+ main_module._init_firebase_admin = lambda: None
36
+ if not main_module.firebase_auth:
37
+ main_module.firebase_auth = MagicMock()
38
+ main_module.firebase_auth.verify_id_token = MagicMock(return_value={
39
+ "uid": "admin-test-uid",
40
+ "email": "admin@test.mathpulse.ai",
41
+ "name": "Admin Tester",
42
+ "role": "admin",
43
+ })
44
+
45
+ yield
46
+
47
+ main_module._firebase_ready = orig_ready
48
+ main_module._init_firebase_admin = orig_init
49
+ main_module.firebase_auth = orig_auth
50
+ if orig_verify and main_module.firebase_auth:
51
+ main_module.firebase_auth.verify_id_token = orig_verify
52
+
53
+
54
+ @pytest.fixture
55
+ def client(admin_auth):
56
+ return TestClient(app, headers={"Authorization": "Bearer admin-token"})
57
+
58
+
59
+ @pytest.fixture(autouse=True)
60
+ def reset_reingestion_state():
61
+ REINGESTION_STATUS.clear()
62
+ REINGESTION_STATUS.update({
63
+ "status": "idle",
64
+ "last_run": None,
65
+ "message": None,
66
+ "mode": None,
67
+ })
68
+ yield
69
+ REINGESTION_STATUS.clear()
70
+ REINGESTION_STATUS.update({
71
+ "status": "idle",
72
+ "last_run": None,
73
+ "message": None,
74
+ "mode": None,
75
+ })
76
+
77
+
78
+ @pytest.fixture(autouse=True)
79
+ def mock_audit_logger():
80
+ mock_logger = AsyncMock()
81
+ with patch("routes.admin_routes._get_audit_logger", return_value=mock_logger):
82
+ yield mock_logger
83
+
84
+
85
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
86
+ # 1. POST /api/admin/reingest-pdf tests
87
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
88
+
89
+
90
+ class TestReingestPdf:
91
+ """Tests for POST /api/admin/reingest-pdf endpoint."""
92
+
93
+ def test_reingest_pdf_background_tasks_execution(self, client, monkeypatch):
94
+ """Verify non-blocking execution with BackgroundTasks when no GitHub token is present."""
95
+ monkeypatch.delenv("GITHUB_PAT", raising=False)
96
+ monkeypatch.delenv("GITHUB_TOKEN", raising=False)
97
+
98
+ with patch.object(BackgroundTasks, "add_task") as mock_add_task, \
99
+ patch("routes.admin_routes.run_cloud_ingestion_and_upload") as mock_ingest:
100
+ response = client.post("/api/admin/reingest-pdf", json={})
101
+
102
+ assert response.status_code == 200
103
+ data = response.json()
104
+ assert data["success"] is True
105
+ assert data["execution_mode"] == "background_tasks"
106
+ assert "Remote re-ingestion started in the cloud." in data["message"]
107
+
108
+ mock_add_task.assert_called_once()
109
+ assert mock_add_task.call_args[0][0] == mock_ingest
110
+
111
+ assert REINGESTION_STATUS["status"] == "running"
112
+ assert REINGESTION_STATUS["mode"] == "background_tasks"
113
+ assert REINGESTION_STATUS["last_run"] is not None
114
+
115
+ def test_reingest_pdf_background_tasks_invokes_task(self, client, monkeypatch):
116
+ """Verify that run_cloud_ingestion_and_upload is scheduled and invoked by BackgroundTasks."""
117
+ monkeypatch.delenv("GITHUB_PAT", raising=False)
118
+ monkeypatch.delenv("GITHUB_TOKEN", raising=False)
119
+
120
+ with patch("routes.admin_routes.run_cloud_ingestion_and_upload") as mock_ingest:
121
+ response = client.post("/api/admin/reingest-pdf", json={"subjectId": "general_mathematics"})
122
+
123
+ assert response.status_code == 200
124
+ data = response.json()
125
+ assert data["success"] is True
126
+ assert data["execution_mode"] == "background_tasks"
127
+ mock_ingest.assert_called_once()
128
+
129
+ def test_reingest_pdf_github_actions_dispatch_with_pat(self, client, monkeypatch):
130
+ """Verify GitHub Actions workflow dispatch when GITHUB_PAT is set and HTTP 204 returned."""
131
+ monkeypatch.setenv("GITHUB_PAT", "ghp_mock_pat_token_test_12345")
132
+ monkeypatch.delenv("GITHUB_TOKEN", raising=False)
133
+
134
+ mock_response = MagicMock()
135
+ mock_response.status = 204
136
+ mock_cm = MagicMock()
137
+ mock_cm.__enter__.return_value = mock_response
138
+ mock_cm.__exit__.return_value = None
139
+
140
+ with patch("urllib.request.urlopen", return_value=mock_cm) as mock_urlopen, \
141
+ patch("routes.admin_routes.run_cloud_ingestion_and_upload") as mock_ingest:
142
+ response = client.post("/api/admin/reingest-pdf", json={})
143
+
144
+ assert response.status_code == 200
145
+ data = response.json()
146
+ assert data["success"] is True
147
+ assert data["execution_mode"] == "github_actions"
148
+ assert "dispatched to GitHub Actions runner" in data["message"]
149
+
150
+ mock_ingest.assert_not_called()
151
+ mock_urlopen.assert_called_once()
152
+ req = mock_urlopen.call_args[0][0]
153
+ assert "workflows/ingest-curriculum.yml/dispatches" in req.full_url
154
+ assert req.headers.get("Authorization") == "Bearer ghp_mock_pat_token_test_12345"
155
+
156
+ assert REINGESTION_STATUS["status"] == "running"
157
+ assert REINGESTION_STATUS["mode"] == "github_actions"
158
+ assert REINGESTION_STATUS["last_run"] is not None
159
+
160
+ def test_reingest_pdf_github_actions_dispatch_with_token(self, client, monkeypatch):
161
+ """Verify GitHub Actions workflow dispatch when GITHUB_TOKEN is set."""
162
+ monkeypatch.delenv("GITHUB_PAT", raising=False)
163
+ monkeypatch.setenv("GITHUB_TOKEN", "ghs_mock_token_test_67890")
164
+
165
+ mock_response = MagicMock()
166
+ mock_response.status = 204
167
+ mock_cm = MagicMock()
168
+ mock_cm.__enter__.return_value = mock_response
169
+ mock_cm.__exit__.return_value = None
170
+
171
+ with patch("urllib.request.urlopen", return_value=mock_cm) as mock_urlopen, \
172
+ patch("routes.admin_routes.run_cloud_ingestion_and_upload") as mock_ingest:
173
+ response = client.post("/api/admin/reingest-pdf", json={})
174
+
175
+ assert response.status_code == 200
176
+ data = response.json()
177
+ assert data["success"] is True
178
+ assert data["execution_mode"] == "github_actions"
179
+ mock_ingest.assert_not_called()
180
+
181
+ req = mock_urlopen.call_args[0][0]
182
+ assert req.headers.get("Authorization") == "Bearer ghs_mock_token_test_67890"
183
+
184
+ def test_reingest_pdf_fallback_to_background_tasks_on_dispatch_failure(self, client, monkeypatch):
185
+ """Verify graceful fallback to background_tasks if GitHub Actions dispatch returns non-204."""
186
+ monkeypatch.setenv("GITHUB_PAT", "ghp_mock_pat_token_test_12345")
187
+
188
+ mock_response = MagicMock()
189
+ mock_response.status = 500
190
+ mock_cm = MagicMock()
191
+ mock_cm.__enter__.return_value = mock_response
192
+ mock_cm.__exit__.return_value = None
193
+
194
+ with patch("urllib.request.urlopen", return_value=mock_cm), \
195
+ patch("routes.admin_routes.run_cloud_ingestion_and_upload") as mock_ingest:
196
+ response = client.post("/api/admin/reingest-pdf", json={})
197
+
198
+ assert response.status_code == 200
199
+ data = response.json()
200
+ assert data["success"] is True
201
+ assert data["execution_mode"] == "background_tasks"
202
+ mock_ingest.assert_called_once()
203
+ assert REINGESTION_STATUS["mode"] == "background_tasks"
204
+
205
+
206
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
207
+ # 2. POST /api/admin/upload-pdf tests
208
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
209
+
210
+
211
+ class TestUploadPdf:
212
+ """Tests for POST /api/admin/upload-pdf endpoint non-blocking behavior."""
213
+
214
+ def test_upload_pdf_non_blocking_background_task(self, client):
215
+ """Verify PDF upload writes to Firebase Storage and schedules ingestion via BackgroundTasks."""
216
+ mock_blob = MagicMock()
217
+ mock_bucket = MagicMock()
218
+ mock_bucket.name = "mathpulse-ai-2026.firebasestorage.app"
219
+ mock_bucket.blob.return_value = mock_blob
220
+
221
+ with patch("routes.admin_routes._init_firebase_storage", return_value=(None, mock_bucket)), \
222
+ patch("routes.admin_routes.run_cloud_ingestion_and_upload") as mock_ingest:
223
+ form_data = {
224
+ "subjectId": "general_mathematics",
225
+ "subjectName": "General Mathematics",
226
+ "semester": 1,
227
+ "quarter": 1,
228
+ }
229
+ files = {
230
+ "file": ("SSHS_GM_Q1_Module1.pdf", b"%PDF-1.4 test module content binary stream", "application/pdf")
231
+ }
232
+
233
+ response = client.post("/api/admin/upload-pdf", data=form_data, files=files)
234
+
235
+ assert response.status_code == 200
236
+ data = response.json()
237
+ assert data["success"] is True
238
+ assert data["chunkCount"] == 0
239
+ assert data["subjectId"] == "general_mathematics"
240
+ expected_storage_path = "curriculum/general_mathematics/SSHS_GM_Q1_Module1.pdf"
241
+ assert data["storageUrl"] == f"gs://mathpulse-ai-2026.firebasestorage.app/{expected_storage_path}"
242
+
243
+ mock_bucket.blob.assert_called_once_with(expected_storage_path)
244
+ mock_blob.upload_from_string.assert_called_once_with(
245
+ b"%PDF-1.4 test module content binary stream",
246
+ content_type="application/pdf",
247
+ )
248
+ mock_ingest.assert_called_once()
249
+
250
+ assert expected_storage_path in PDF_METADATA
251
+ assert PDF_METADATA[expected_storage_path] == {
252
+ "subject": "General Mathematics",
253
+ "subjectId": "general_mathematics",
254
+ "type": "uploaded_module",
255
+ "semester": 1,
256
+ "quarter": 1,
257
+ }
258
+
259
+ def test_upload_pdf_rejects_non_pdf_file(self, client):
260
+ """Verify rejection of non-PDF files with 400 Bad Request."""
261
+ form_data = {
262
+ "subjectId": "general_mathematics",
263
+ "subjectName": "General Mathematics",
264
+ "semester": 1,
265
+ "quarter": 1,
266
+ }
267
+ files = {
268
+ "file": ("curriculum_guide.docx", b"PK\x03\x04 mock docx data", "application/vnd.openxmlformats")
269
+ }
270
+
271
+ response = client.post("/api/admin/upload-pdf", data=form_data, files=files)
272
+ assert response.status_code == 400
273
+ assert "Only PDF files are allowed." in response.json()["detail"]
274
+
275
+ def test_upload_pdf_storage_not_initialized(self, client):
276
+ """Verify 500 error when Firebase storage is unavailable."""
277
+ with patch("routes.admin_routes._init_firebase_storage", return_value=(None, None)):
278
+ form_data = {
279
+ "subjectId": "general_mathematics",
280
+ "subjectName": "General Mathematics",
281
+ "semester": 1,
282
+ "quarter": 1,
283
+ }
284
+ files = {
285
+ "file": ("test.pdf", b"%PDF-1.4 sample", "application/pdf")
286
+ }
287
+ response = client.post("/api/admin/upload-pdf", data=form_data, files=files)
288
+ assert response.status_code == 500
289
+ assert "Firebase storage is not initialized." in response.json()["detail"]
290
+
291
+
292
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
293
+ # 3. GET /api/admin/reingest-status tests
294
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
295
+
296
+
297
+ class TestReingestStatus:
298
+ """Tests for GET /api/admin/reingest-status endpoint."""
299
+
300
+ def test_get_reingest_status_idle(self, client):
301
+ """Verify initial/idle status is returned accurately."""
302
+ response = client.get("/api/admin/reingest-status")
303
+ assert response.status_code == 200
304
+ data = response.json()
305
+ assert data["status"] == "idle"
306
+ assert data["last_run"] is None
307
+ assert data["message"] is None
308
+ assert data["mode"] is None
309
+
310
+ def test_get_reingest_status_reflects_active_reingestion(self, client):
311
+ """Verify that updated reingestion status is reflected in GET response."""
312
+ REINGESTION_STATUS["status"] = "running"
313
+ REINGESTION_STATUS["mode"] = "github_actions"
314
+ REINGESTION_STATUS["message"] = "Dispatched workflow to GitHub Actions"
315
+ REINGESTION_STATUS["last_run"] = "2026-09-07T13:00:00+00:00"
316
+
317
+ response = client.get("/api/admin/reingest-status")
318
+ assert response.status_code == 200
319
+ data = response.json()
320
+ assert data["status"] == "running"
321
+ assert data["mode"] == "github_actions"
322
+ assert data["message"] == "Dispatched workflow to GitHub Actions"
323
+ assert data["last_run"] == "2026-09-07T13:00:00+00:00"
324
+
325
+
326
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
327
+ # 4. Authentication & RBAC Enforcement tests
328
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
329
+
330
+
331
+ class TestAdminReingestAuth:
332
+ """Tests role-based access control (RBAC) on admin reingestion endpoints."""
333
+
334
+ def test_reingest_pdf_unauthorized(self):
335
+ """Missing auth header returns 401."""
336
+ unauth_client = TestClient(app)
337
+ response = unauth_client.post("/api/admin/reingest-pdf", json={})
338
+ assert response.status_code in {401, 403}
339
+
340
+ def test_reingest_pdf_forbidden_for_student(self):
341
+ """Student role returns 403 Forbidden."""
342
+ main_module._firebase_ready = True
343
+ main_module._init_firebase_admin = lambda: None
344
+ if not main_module.firebase_auth:
345
+ main_module.firebase_auth = MagicMock()
346
+ mock_claims = {
347
+ "uid": "student-uid",
348
+ "email": "student@mathpulse.ai",
349
+ "role": "student",
350
+ }
351
+ with patch.object(main_module.firebase_auth, "verify_id_token", return_value=mock_claims):
352
+ student_client = TestClient(app, headers={"Authorization": "Bearer student-token"})
353
+ response = student_client.post("/api/admin/reingest-pdf", json={})
354
+ assert response.status_code == 403
355
+
356
+ def test_upload_pdf_unauthorized(self):
357
+ """Upload without token returns 401."""
358
+ unauth_client = TestClient(app)
359
+ response = unauth_client.post("/api/admin/upload-pdf")
360
+ assert response.status_code in {401, 403}
361
+
362
+ def test_upload_pdf_forbidden_for_student(self):
363
+ """Upload with student token returns 403."""
364
+ main_module._firebase_ready = True
365
+ main_module._init_firebase_admin = lambda: None
366
+ if not main_module.firebase_auth:
367
+ main_module.firebase_auth = MagicMock()
368
+ mock_claims = {
369
+ "uid": "student-uid",
370
+ "email": "student@mathpulse.ai",
371
+ "role": "student",
372
+ }
373
+ with patch.object(main_module.firebase_auth, "verify_id_token", return_value=mock_claims):
374
+ student_client = TestClient(app, headers={"Authorization": "Bearer student-token"})
375
+ response = student_client.post("/api/admin/upload-pdf")
376
+ assert response.status_code == 403
377
+
378
+ def test_get_reingest_status_forbidden_for_student(self):
379
+ """Status endpoint with student token returns 403."""
380
+ main_module._firebase_ready = True
381
+ main_module._init_firebase_admin = lambda: None
382
+ if not main_module.firebase_auth:
383
+ main_module.firebase_auth = MagicMock()
384
+ mock_claims = {
385
+ "uid": "student-uid",
386
+ "email": "student@mathpulse.ai",
387
+ "role": "student",
388
+ }
389
+ with patch.object(main_module.firebase_auth, "verify_id_token", return_value=mock_claims):
390
+ student_client = TestClient(app, headers={"Authorization": "Bearer student-token"})
391
+ response = student_client.get("/api/admin/reingest-status")
392
+ assert response.status_code == 403
393
+
tests/test_quiz_battle.py CHANGED
@@ -125,16 +125,24 @@ class TestQuestionBankService:
125
  "difficulty": "easy",
126
  "random_seed": 0.5,
127
  }
128
- mock_collection = MagicMock()
129
- mock_collection.where.return_value.order_by.return_value.limit.return_value.stream.return_value = [mock_doc]
130
- mock_collection.where.return_value.order_by.return_value.limit.return_value.stream.return_value = [mock_doc]
131
- mock_db.return_value.collection.return_value = mock_collection
 
 
 
 
 
 
 
132
 
133
  from services.question_bank_service import get_questions_for_battle
134
  questions = await get_questions_for_battle(8, "linear_equations", 1)
135
  assert len(questions) == 1
136
  assert questions[0]["question"] == "What is 2+2?"
137
 
 
138
  @pytest.mark.asyncio
139
  async def test_cache_session_questions(self):
140
  """Cache questions for 24 hours."""
 
125
  "difficulty": "easy",
126
  "random_seed": 0.5,
127
  }
128
+ # New path: db.collection("question_bank").document(grade).collection("topics").document(topic).collection("questions")
129
+ # MagicMock auto-chains, so navigate to the questions collection via the chained call
130
+ mock_questions_col = (
131
+ mock_db.return_value
132
+ .collection.return_value # "question_bank"
133
+ .document.return_value # grade_level doc
134
+ .collection.return_value # "topics"
135
+ .document.return_value # topic doc
136
+ .collection.return_value # "questions"
137
+ )
138
+ mock_questions_col.where.return_value.order_by.return_value.limit.return_value.stream.return_value = [mock_doc]
139
 
140
  from services.question_bank_service import get_questions_for_battle
141
  questions = await get_questions_for_battle(8, "linear_equations", 1)
142
  assert len(questions) == 1
143
  assert questions[0]["question"] == "What is 2+2?"
144
 
145
+
146
  @pytest.mark.asyncio
147
  async def test_cache_session_questions(self):
148
  """Cache questions for 24 hours."""