Spaces:
Sleeping
Sleeping
github-actions[bot] commited on
Commit ยท
8a85a30
1
Parent(s): a187357
deploy: backend from 694f8ea
Browse files- main.py +1 -14
- middleware/rate_limiter.py +2 -0
- routes/admin_routes.py +213 -20
- routes/at_risk_resolution.py +1 -1
- routes/class_analytics_routes.py +1 -1
- routes/class_records_router.py +29 -14
- routes/deepseek_rag_routes.py +1 -1
- routes/pipeline_routes.py +1 -1
- services/class_analytics_engine.py +11 -9
- services/deepseek_client.py +0 -87
- services/inference_client.py +62 -172
- services/intervention_engine.py +121 -70
- services/question_bank_service.py +19 -21
- services/student_intelligence_pipeline.py +14 -3
- services/wri_service.py +28 -0
- tests/test_admin_reingest.py +393 -0
- tests/test_quiz_battle.py +12 -4
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 ("
|
| 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 =
|
| 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 =
|
| 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 |
-
|
| 995 |
-
"wriScore": wri_value,
|
| 996 |
"wriRiskBand": risk_status,
|
| 997 |
"wriComputedAt": datetime.utcnow().isoformat(),
|
| 998 |
-
}
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1006 |
"latestGrade": transmuted,
|
| 1007 |
"wriExternalGrade": transmuted,
|
| 1008 |
-
"wriScore": wri_value,
|
| 1009 |
-
"wriRiskBand": risk_status,
|
| 1010 |
"wriUpdatedAt": datetime.utcnow().isoformat(),
|
| 1011 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1030 |
count = 0
|
| 1031 |
|
| 1032 |
try:
|
|
@@ -1057,7 +1072,7 @@ def _persist_students(
|
|
| 1057 |
"updatedAt": datetime.utcnow().isoformat(),
|
| 1058 |
}
|
| 1059 |
|
| 1060 |
-
student_ref =
|
| 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}/
|
| 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.
|
| 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="
|
| 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["
|
| 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
|
| 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 |
-
-
|
| 446 |
-
-
|
| 447 |
-
-
|
| 448 |
-
-
|
| 449 |
-
-
|
|
|
|
| 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('
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 911 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1011 |
|
| 1012 |
-
|
| 1013 |
-
"""Strip JSON braces, template artifacts, and whitespace from response text."""
|
| 1014 |
-
text = text.strip()
|
| 1015 |
|
| 1016 |
-
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
-
|
| 1020 |
-
|
| 1021 |
-
|
| 1022 |
-
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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 "
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
-
# Compute metrics
|
| 145 |
now = datetime.now(timezone.utc)
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
if
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 == "
|
| 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 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 61 |
-
|
| 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 |
-
|
| 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
|
| 442 |
-
return
|
| 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 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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."""
|