Spaces:
Running
Running
github-actions[bot] commited on
Commit ยท
174b574
1
Parent(s): 25943d2
๐ Auto-deploy backend from GitHub (82c290f)
Browse files- routes/class_records_router.py +407 -45
- routes/quiz_battle.py +134 -1
- routes/teacher_materials.py +77 -3
- services/variance_engine.py +4 -1
- tests/test_teacher_materials.py +1 -1
routes/class_records_router.py
CHANGED
|
@@ -16,9 +16,12 @@ import uuid
|
|
| 16 |
from datetime import datetime
|
| 17 |
from typing import Any, Dict, List, Optional, Tuple
|
| 18 |
|
| 19 |
-
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File
|
| 20 |
from fastapi.responses import StreamingResponse
|
| 21 |
|
|
|
|
|
|
|
|
|
|
| 22 |
logger = logging.getLogger("mathpulse.class_records")
|
| 23 |
|
| 24 |
router = APIRouter(prefix="/api", tags=["class-records"])
|
|
@@ -314,6 +317,9 @@ ALLOWED_UPLOAD_EXTS = {".xlsx", ".csv"}
|
|
| 314 |
async def upload_class_records(
|
| 315 |
request: Request,
|
| 316 |
file: UploadFile = File(...),
|
|
|
|
|
|
|
|
|
|
| 317 |
):
|
| 318 |
user: Any = getattr(request.state, "user", None)
|
| 319 |
if user is None:
|
|
@@ -341,6 +347,14 @@ async def upload_class_records(
|
|
| 341 |
except (ValueError, KeyError, IndexError) as e:
|
| 342 |
raise HTTPException(status_code=400, detail=f"Failed to parse file: {e}")
|
| 343 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
if not students:
|
| 345 |
raise HTTPException(status_code=400, detail="No student data found in file")
|
| 346 |
|
|
@@ -469,10 +483,15 @@ async def upload_class_records(
|
|
| 469 |
|
| 470 |
# โโ Persist to Firestore โโ
|
| 471 |
upload_id = uuid.uuid4().hex
|
| 472 |
-
_persist_upload(teacher_uid, upload_id, metadata, processed, summary)
|
| 473 |
|
| 474 |
section_id = _safe_str(metadata.get("section", "unknown")).replace(" ", "_").lower() or "unknown"
|
| 475 |
-
_persist_students(teacher_uid, section_id, processed, upload_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
|
| 477 |
student_list = []
|
| 478 |
for s in processed:
|
|
@@ -485,10 +504,11 @@ async def upload_class_records(
|
|
| 485 |
"topFactors": s.get("topFactors", []),
|
| 486 |
})
|
| 487 |
|
| 488 |
-
|
| 489 |
"success": True,
|
| 490 |
"message": f"Uploaded {summary['totalStudents']} student records. {summary['atRiskCount']} at-risk, {summary['mediumRiskCount']} medium-risk, {summary['lowRiskCount']} safe.",
|
| 491 |
"uploadId": upload_id,
|
|
|
|
| 492 |
"metadata": {
|
| 493 |
"className": metadata.get("section", ""),
|
| 494 |
"subject": metadata.get("subject", ""),
|
|
@@ -498,6 +518,282 @@ async def upload_class_records(
|
|
| 498 |
"summary": summary,
|
| 499 |
"students": student_list,
|
| 500 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
|
| 502 |
|
| 503 |
# โโโ Parsers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
@@ -630,11 +926,11 @@ def _persist_upload(
|
|
| 630 |
metadata: Dict[str, Any],
|
| 631 |
students: List[Dict[str, Any]],
|
| 632 |
summary: Dict[str, int],
|
| 633 |
-
) ->
|
| 634 |
client = _get_firestore_client()
|
| 635 |
if client is None:
|
| 636 |
logger.warning("Firestore unavailable; class records not persisted")
|
| 637 |
-
return
|
| 638 |
|
| 639 |
try:
|
| 640 |
upload_doc = {
|
|
@@ -656,66 +952,132 @@ def _persist_upload(
|
|
| 656 |
upload_ref.set(upload_doc)
|
| 657 |
|
| 658 |
logger.info(f"Upload record persisted: {teacher_uid}/{upload_id} ({len(students)} students)")
|
|
|
|
| 659 |
except Exception as e:
|
| 660 |
logger.error(f"Failed to persist upload record: {e}")
|
|
|
|
| 661 |
|
| 662 |
|
| 663 |
-
def
|
| 664 |
teacher_uid: str,
|
| 665 |
section_id: str,
|
| 666 |
students: List[Dict[str, Any]],
|
| 667 |
-
upload_id: str,
|
| 668 |
) -> None:
|
|
|
|
| 669 |
client = _get_firestore_client()
|
| 670 |
if client is None:
|
|
|
|
| 671 |
return
|
| 672 |
|
| 673 |
-
|
| 674 |
section_ref = client.collection("classRecords").document(teacher_uid).collection("sections").document(section_id)
|
| 675 |
-
count = 0
|
| 676 |
|
| 677 |
for s in students:
|
| 678 |
-
lrn = s.get("lrn"
|
| 679 |
if not lrn:
|
| 680 |
continue
|
| 681 |
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
"firstName": s.get("firstName", ""),
|
| 686 |
-
"sex": s.get("sex", ""),
|
| 687 |
-
"wwScores": s.get("wwScores", []),
|
| 688 |
-
"ptScores": s.get("ptScores", []),
|
| 689 |
-
"qaScore": s.get("qaScore"),
|
| 690 |
-
"qaMax": s.get("qaMax"),
|
| 691 |
-
"absences": s.get("absences", 0),
|
| 692 |
-
"remarks": s.get("remarks", ""),
|
| 693 |
-
"wwTotal": s.get("wwTotal", 0),
|
| 694 |
-
"ptTotal": s.get("ptTotal", 0),
|
| 695 |
-
"qaGrade": s.get("qaGrade", 0),
|
| 696 |
-
"initialGrade": s.get("initialGrade", 0),
|
| 697 |
-
"transmutedGrade": s.get("transmutedGrade", 0),
|
| 698 |
-
"flags": s.get("flags", []),
|
| 699 |
-
"riskLevel": s.get("riskLevel", "safe"),
|
| 700 |
-
"uploadId": upload_id,
|
| 701 |
-
"updatedAt": datetime.utcnow().isoformat(),
|
| 702 |
-
}
|
| 703 |
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 707 |
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
|
|
|
|
|
|
|
|
|
| 714 |
|
| 715 |
-
if count % 500 != 0:
|
| 716 |
try:
|
| 717 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 718 |
except Exception as e:
|
| 719 |
-
logger.error(f"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 720 |
|
| 721 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
from datetime import datetime
|
| 17 |
from typing import Any, Dict, List, Optional, Tuple
|
| 18 |
|
| 19 |
+
from fastapi import APIRouter, Form, HTTPException, Query, Request, UploadFile, File
|
| 20 |
from fastapi.responses import StreamingResponse
|
| 21 |
|
| 22 |
+
from services.inference_client import call_hf_chat_async
|
| 23 |
+
from services.wri_service import compute_wri
|
| 24 |
+
|
| 25 |
logger = logging.getLogger("mathpulse.class_records")
|
| 26 |
|
| 27 |
router = APIRouter(prefix="/api", tags=["class-records"])
|
|
|
|
| 317 |
async def upload_class_records(
|
| 318 |
request: Request,
|
| 319 |
file: UploadFile = File(...),
|
| 320 |
+
subject: Optional[str] = Form(None),
|
| 321 |
+
quarter: Optional[str] = Form(None),
|
| 322 |
+
gradeLevel: Optional[str] = Form(None),
|
| 323 |
):
|
| 324 |
user: Any = getattr(request.state, "user", None)
|
| 325 |
if user is None:
|
|
|
|
| 347 |
except (ValueError, KeyError, IndexError) as e:
|
| 348 |
raise HTTPException(status_code=400, detail=f"Failed to parse file: {e}")
|
| 349 |
|
| 350 |
+
# Override metadata with form field values if provided
|
| 351 |
+
if subject:
|
| 352 |
+
metadata["subject"] = subject
|
| 353 |
+
if quarter:
|
| 354 |
+
metadata["quarter"] = quarter
|
| 355 |
+
if gradeLevel:
|
| 356 |
+
metadata["section"] = gradeLevel
|
| 357 |
+
|
| 358 |
if not students:
|
| 359 |
raise HTTPException(status_code=400, detail="No student data found in file")
|
| 360 |
|
|
|
|
| 483 |
|
| 484 |
# โโ Persist to Firestore โโ
|
| 485 |
upload_id = uuid.uuid4().hex
|
| 486 |
+
upload_persisted = _persist_upload(teacher_uid, upload_id, metadata, processed, summary)
|
| 487 |
|
| 488 |
section_id = _safe_str(metadata.get("section", "unknown")).replace(" ", "_").lower() or "unknown"
|
| 489 |
+
students_persisted = _persist_students(teacher_uid, section_id, processed, upload_id)
|
| 490 |
+
|
| 491 |
+
persisted = upload_persisted and students_persisted
|
| 492 |
+
|
| 493 |
+
# D1+D3: Trigger WRI recompute for each student + cross-reference LRN โ user account
|
| 494 |
+
_trigger_wri_recompute(teacher_uid, section_id, processed)
|
| 495 |
|
| 496 |
student_list = []
|
| 497 |
for s in processed:
|
|
|
|
| 504 |
"topFactors": s.get("topFactors", []),
|
| 505 |
})
|
| 506 |
|
| 507 |
+
response: Dict[str, Any] = {
|
| 508 |
"success": True,
|
| 509 |
"message": f"Uploaded {summary['totalStudents']} student records. {summary['atRiskCount']} at-risk, {summary['mediumRiskCount']} medium-risk, {summary['lowRiskCount']} safe.",
|
| 510 |
"uploadId": upload_id,
|
| 511 |
+
"persisted": persisted,
|
| 512 |
"metadata": {
|
| 513 |
"className": metadata.get("section", ""),
|
| 514 |
"subject": metadata.get("subject", ""),
|
|
|
|
| 518 |
"summary": summary,
|
| 519 |
"students": student_list,
|
| 520 |
}
|
| 521 |
+
if not persisted:
|
| 522 |
+
response["warning"] = "Data not saved โ Firestore unavailable"
|
| 523 |
+
return response
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
# โโโ ENDPOINT 3: POST /api/class-records/intervention-plan โโโโโโโโโโโโโโโโโโ
|
| 527 |
+
|
| 528 |
+
@router.post("/class-records/intervention-plan")
|
| 529 |
+
async def create_intervention_plan(request: Request):
|
| 530 |
+
"""Generate a 3-step intervention plan for an at-risk student."""
|
| 531 |
+
user: Any = getattr(request.state, "user", None)
|
| 532 |
+
if user is None:
|
| 533 |
+
raise HTTPException(status_code=401, detail="Authentication required")
|
| 534 |
+
if user.role not in {"teacher", "admin"}:
|
| 535 |
+
raise HTTPException(status_code=403, detail="Only teachers and admins can create intervention plans")
|
| 536 |
+
|
| 537 |
+
try:
|
| 538 |
+
body = await request.json()
|
| 539 |
+
except Exception:
|
| 540 |
+
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
| 541 |
+
|
| 542 |
+
lrn = body.get("lrn")
|
| 543 |
+
subject = body.get("subject")
|
| 544 |
+
quarter = body.get("quarter")
|
| 545 |
+
risk_factors = body.get("riskFactors", [])
|
| 546 |
+
|
| 547 |
+
if not lrn or not subject or not quarter:
|
| 548 |
+
raise HTTPException(status_code=400, detail="Missing required fields: lrn, subject, quarter")
|
| 549 |
+
|
| 550 |
+
prompt = f"""You are an experienced Filipino senior high school teacher following DepEd guidelines.
|
| 551 |
+
|
| 552 |
+
Student LRN: {lrn}
|
| 553 |
+
Subject: {subject}
|
| 554 |
+
Quarter: {quarter}
|
| 555 |
+
Identified Risk Factors: {', '.join(risk_factors) if risk_factors else 'None specified'}
|
| 556 |
+
|
| 557 |
+
Create a concise, actionable 3-step intervention plan for this at-risk student.
|
| 558 |
+
Each step should be specific, measurable, and appropriate for the DepEd context.
|
| 559 |
+
|
| 560 |
+
Format your response as:
|
| 561 |
+
PLAN: <brief overall plan description>
|
| 562 |
+
STRATEGIES:
|
| 563 |
+
1. <first strategy>
|
| 564 |
+
2. <second strategy>
|
| 565 |
+
3. <third strategy>"""
|
| 566 |
+
|
| 567 |
+
try:
|
| 568 |
+
response_text = await call_hf_chat_async(
|
| 569 |
+
messages=[{"role": "user", "content": prompt}],
|
| 570 |
+
max_tokens=500,
|
| 571 |
+
temperature=0.7,
|
| 572 |
+
task_type="intervention_plan"
|
| 573 |
+
)
|
| 574 |
+
|
| 575 |
+
# Parse the response
|
| 576 |
+
plan = ""
|
| 577 |
+
strategies: List[str] = []
|
| 578 |
+
|
| 579 |
+
lines = response_text.strip().split("\n")
|
| 580 |
+
in_strategies = False
|
| 581 |
+
|
| 582 |
+
for line in lines:
|
| 583 |
+
line = line.strip()
|
| 584 |
+
if line.upper().startswith("PLAN:"):
|
| 585 |
+
plan = line[5:].strip()
|
| 586 |
+
elif line.upper().startswith("STRATEGIES:"):
|
| 587 |
+
in_strategies = True
|
| 588 |
+
elif in_strategies and line:
|
| 589 |
+
# Remove leading numbers and dots
|
| 590 |
+
cleaned = re.sub(r"^\d+\.\s*", "", line)
|
| 591 |
+
if cleaned:
|
| 592 |
+
strategies.append(cleaned)
|
| 593 |
+
|
| 594 |
+
# Ensure we have at least 3 strategies
|
| 595 |
+
while len(strategies) < 3:
|
| 596 |
+
strategies.append("Continue monitoring student progress and adjust interventions as needed")
|
| 597 |
+
|
| 598 |
+
return {
|
| 599 |
+
"plan": plan or f"Intervention plan for {subject} - {quarter}",
|
| 600 |
+
"strategies": strategies[:3]
|
| 601 |
+
}
|
| 602 |
+
except Exception as e:
|
| 603 |
+
logger.error(f"Failed to generate intervention plan: {e}")
|
| 604 |
+
raise HTTPException(status_code=500, detail=f"Failed to generate intervention plan: {str(e)}")
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
# โโโ ENDPOINT 4: GET /api/class-records โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 608 |
+
|
| 609 |
+
@router.get("/class-records")
|
| 610 |
+
async def get_class_records(
|
| 611 |
+
request: Request,
|
| 612 |
+
limit: int = Query(20, ge=1, le=100),
|
| 613 |
+
after: Optional[str] = Query(None),
|
| 614 |
+
):
|
| 615 |
+
"""Get paginated class record uploads for the authenticated teacher."""
|
| 616 |
+
user: Any = getattr(request.state, "user", None)
|
| 617 |
+
if user is None:
|
| 618 |
+
raise HTTPException(status_code=401, detail="Authentication required")
|
| 619 |
+
if user.role not in {"teacher", "admin"}:
|
| 620 |
+
raise HTTPException(status_code=403, detail="Only teachers and admins can view class records")
|
| 621 |
+
|
| 622 |
+
teacher_uid = user.uid
|
| 623 |
+
client = _get_firestore_client()
|
| 624 |
+
|
| 625 |
+
if client is None:
|
| 626 |
+
return {"uploads": [], "hasMore": False}
|
| 627 |
+
|
| 628 |
+
try:
|
| 629 |
+
uploads_ref = client.collection("classRecords").document(teacher_uid).collection("uploads")
|
| 630 |
+
query = uploads_ref.order_by("uploadedAt", direction="DESCENDING").limit(limit + 1)
|
| 631 |
+
|
| 632 |
+
if after:
|
| 633 |
+
# Get the document to start after
|
| 634 |
+
after_doc = uploads_ref.document(after).get()
|
| 635 |
+
if after_doc.exists:
|
| 636 |
+
query = query.start_after(after_doc)
|
| 637 |
+
|
| 638 |
+
docs = query.stream()
|
| 639 |
+
uploads: List[Dict[str, Any]] = []
|
| 640 |
+
|
| 641 |
+
for doc in docs:
|
| 642 |
+
data = doc.to_dict()
|
| 643 |
+
if data:
|
| 644 |
+
uploads.append({
|
| 645 |
+
"uploadId": data.get("uploadId", doc.id),
|
| 646 |
+
"uploadedAt": data.get("uploadedAt", ""),
|
| 647 |
+
"studentCount": data.get("studentCount", 0),
|
| 648 |
+
"summary": data.get("summary", {}),
|
| 649 |
+
"metadata": {
|
| 650 |
+
"section": data.get("section", ""),
|
| 651 |
+
"subject": data.get("subject", ""),
|
| 652 |
+
"quarter": data.get("quarter", ""),
|
| 653 |
+
"schoolYear": data.get("schoolYear", ""),
|
| 654 |
+
},
|
| 655 |
+
})
|
| 656 |
+
|
| 657 |
+
has_more = len(uploads) > limit
|
| 658 |
+
if has_more:
|
| 659 |
+
uploads = uploads[:limit]
|
| 660 |
+
|
| 661 |
+
return {"uploads": uploads, "hasMore": has_more}
|
| 662 |
+
except Exception as e:
|
| 663 |
+
logger.error(f"Failed to fetch class records: {e}")
|
| 664 |
+
return {"uploads": [], "hasMore": False}
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
# โโโ ENDPOINT 5: GET /api/class-records/{uploadId}/students โโโโโโโโโโโโโโโโโโ
|
| 668 |
+
|
| 669 |
+
@router.get("/class-records/{uploadId}/students")
|
| 670 |
+
async def get_upload_students(
|
| 671 |
+
request: Request,
|
| 672 |
+
uploadId: str,
|
| 673 |
+
limit: int = Query(100, ge=1, le=500),
|
| 674 |
+
after: Optional[str] = Query(None),
|
| 675 |
+
):
|
| 676 |
+
user: Any = getattr(request.state, "user", None)
|
| 677 |
+
if user is None:
|
| 678 |
+
raise HTTPException(status_code=401, detail="Authentication required")
|
| 679 |
+
if user.role not in {"teacher", "admin"}:
|
| 680 |
+
raise HTTPException(status_code=403, detail="Only teachers and admins can view student records")
|
| 681 |
+
|
| 682 |
+
teacher_uid = user.uid
|
| 683 |
+
client = _get_firestore_client()
|
| 684 |
+
if client is None:
|
| 685 |
+
raise HTTPException(status_code=503, detail="Firestore unavailable")
|
| 686 |
+
|
| 687 |
+
upload_ref = client.collection("classRecords").document(teacher_uid).collection("uploads").document(uploadId)
|
| 688 |
+
upload_snap = upload_ref.get()
|
| 689 |
+
if not upload_snap.exists:
|
| 690 |
+
raise HTTPException(status_code=404, detail="Upload not found")
|
| 691 |
+
|
| 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()
|
| 706 |
+
if data:
|
| 707 |
+
students.append(data)
|
| 708 |
+
|
| 709 |
+
has_more = len(students) > limit
|
| 710 |
+
if has_more:
|
| 711 |
+
students = students[:limit]
|
| 712 |
+
|
| 713 |
+
return {"uploadId": uploadId, "sectionId": section_id, "students": students, "hasMore": has_more}
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
# โโโ ENDPOINT 6: POST /api/class-records/{uploadId}/ai-report โโโโโโโโโโโโโโโโ
|
| 717 |
+
|
| 718 |
+
@router.post("/class-records/{uploadId}/ai-report")
|
| 719 |
+
async def generate_ai_class_report(request: Request, uploadId: str):
|
| 720 |
+
import json as _json
|
| 721 |
+
|
| 722 |
+
user: Any = getattr(request.state, "user", None)
|
| 723 |
+
if user is None:
|
| 724 |
+
raise HTTPException(status_code=401, detail="Authentication required")
|
| 725 |
+
if user.role not in {"teacher", "admin"}:
|
| 726 |
+
raise HTTPException(status_code=403, detail="Only teachers and admins can generate reports")
|
| 727 |
+
|
| 728 |
+
teacher_uid = user.uid
|
| 729 |
+
client = _get_firestore_client()
|
| 730 |
+
if client is None:
|
| 731 |
+
raise HTTPException(status_code=503, detail="Firestore unavailable")
|
| 732 |
+
|
| 733 |
+
upload_ref = client.collection("classRecords").document(teacher_uid).collection("uploads").document(uploadId)
|
| 734 |
+
upload_snap = upload_ref.get()
|
| 735 |
+
if not upload_snap.exists:
|
| 736 |
+
raise HTTPException(status_code=404, detail="Upload not found")
|
| 737 |
+
|
| 738 |
+
upload_data = upload_snap.to_dict()
|
| 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)
|
| 747 |
+
if total == 0:
|
| 748 |
+
return {"error": "No students found", "classAverage": 0, "distribution": [], "atRiskPct": 0, "recommendations": []}
|
| 749 |
+
|
| 750 |
+
grades = [s.get("transmutedGrade", 0) for s in all_students if s.get("transmutedGrade") is not None]
|
| 751 |
+
class_avg = round(sum(grades) / len(grades), 2) if grades else 0
|
| 752 |
+
|
| 753 |
+
excellent = sum(1 for g in grades if g >= 90)
|
| 754 |
+
very_satisfactory = sum(1 for g in grades if 85 <= g < 90)
|
| 755 |
+
satisfactory = sum(1 for g in grades if 80 <= g < 85)
|
| 756 |
+
fairly_satisfactory = sum(1 for g in grades if 75 <= g < 80)
|
| 757 |
+
did_not_meet = sum(1 for g in grades if g < 75)
|
| 758 |
+
|
| 759 |
+
distribution = [
|
| 760 |
+
{"label": "Excellent (90-100)", "count": excellent, "pct": round(excellent / total * 100, 1) if total else 0},
|
| 761 |
+
{"label": "Very Satisfactory (85-89)", "count": very_satisfactory, "pct": round(very_satisfactory / total * 100, 1) if total else 0},
|
| 762 |
+
{"label": "Satisfactory (80-84)", "count": satisfactory, "pct": round(satisfactory / total * 100, 1) if total else 0},
|
| 763 |
+
{"label": "Fairly Satisfactory (75-79)", "count": fairly_satisfactory, "pct": round(fairly_satisfactory / total * 100, 1) if total else 0},
|
| 764 |
+
{"label": "Did Not Meet (<75)", "count": did_not_meet, "pct": round(did_not_meet / total * 100, 1) if total else 0},
|
| 765 |
+
]
|
| 766 |
+
at_risk_pct = round(did_not_meet / total * 100, 1) if total else 0
|
| 767 |
+
|
| 768 |
+
prompt = (
|
| 769 |
+
"You are a DepEd school analyst. Given this class grade distribution for "
|
| 770 |
+
f"{metadata.get('section','')} {metadata.get('subject','')} Q{metadata.get('quarter','')}, "
|
| 771 |
+
"write a brief Quarterly Assessment Report. "
|
| 772 |
+
f"Class avg: {class_avg}. Distribution: Excellent={excellent}, VS={very_satisfactory}, "
|
| 773 |
+
f"S={satisfactory}, FS={fairly_satisfactory}, DNME={did_not_meet}. "
|
| 774 |
+
'Write 2 specific actionable recommendations. Return JSON: {"recommendations": ["rec1", "rec2"]}'
|
| 775 |
+
)
|
| 776 |
+
|
| 777 |
+
try:
|
| 778 |
+
response_text = await call_hf_chat_async(
|
| 779 |
+
messages=[{"role": "user", "content": prompt}],
|
| 780 |
+
max_tokens=400, temperature=0.5, task_type="class_report"
|
| 781 |
+
)
|
| 782 |
+
try:
|
| 783 |
+
parsed = _json.loads(response_text)
|
| 784 |
+
recommendations = parsed.get("recommendations", [])
|
| 785 |
+
except Exception:
|
| 786 |
+
recommendations = [r.strip() for r in response_text.split('\n') if len(r.strip()) > 20][:2]
|
| 787 |
+
except Exception as e:
|
| 788 |
+
logger.error(f"AI class report failed: {e}")
|
| 789 |
+
recommendations = []
|
| 790 |
+
|
| 791 |
+
return {
|
| 792 |
+
"classAverage": class_avg,
|
| 793 |
+
"distribution": distribution,
|
| 794 |
+
"atRiskPct": at_risk_pct,
|
| 795 |
+
"recommendations": recommendations,
|
| 796 |
+
}
|
| 797 |
|
| 798 |
|
| 799 |
# โโโ Parsers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
|
|
| 926 |
metadata: Dict[str, Any],
|
| 927 |
students: List[Dict[str, Any]],
|
| 928 |
summary: Dict[str, int],
|
| 929 |
+
) -> bool:
|
| 930 |
client = _get_firestore_client()
|
| 931 |
if client is None:
|
| 932 |
logger.warning("Firestore unavailable; class records not persisted")
|
| 933 |
+
return False
|
| 934 |
|
| 935 |
try:
|
| 936 |
upload_doc = {
|
|
|
|
| 952 |
upload_ref.set(upload_doc)
|
| 953 |
|
| 954 |
logger.info(f"Upload record persisted: {teacher_uid}/{upload_id} ({len(students)} students)")
|
| 955 |
+
return True
|
| 956 |
except Exception as e:
|
| 957 |
logger.error(f"Failed to persist upload record: {e}")
|
| 958 |
+
return False
|
| 959 |
|
| 960 |
|
| 961 |
+
def _trigger_wri_recompute(
|
| 962 |
teacher_uid: str,
|
| 963 |
section_id: str,
|
| 964 |
students: List[Dict[str, Any]],
|
|
|
|
| 965 |
) -> None:
|
| 966 |
+
"""After class records persist, recompute WRI for each student and cross-reference LRN โ user account."""
|
| 967 |
client = _get_firestore_client()
|
| 968 |
if client is None:
|
| 969 |
+
logger.warning("WRI recompute skipped: Firestore unavailable")
|
| 970 |
return
|
| 971 |
|
| 972 |
+
users_ref = client.collection("users")
|
| 973 |
section_ref = client.collection("classRecords").document(teacher_uid).collection("sections").document(section_id)
|
|
|
|
| 974 |
|
| 975 |
for s in students:
|
| 976 |
+
lrn = (s.get("lrn") or "").strip()
|
| 977 |
if not lrn:
|
| 978 |
continue
|
| 979 |
|
| 980 |
+
transmuted = s.get("transmutedGrade")
|
| 981 |
+
if transmuted is None:
|
| 982 |
+
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 983 |
|
| 984 |
+
try:
|
| 985 |
+
result = compute_wri(d=None, g=transmuted, p=None)
|
| 986 |
+
wri_value = result.get("wri")
|
| 987 |
+
risk_status = result.get("risk_status", "pending_assessment")
|
| 988 |
+
except Exception as e:
|
| 989 |
+
logger.error(f"WRI computation failed for LRN {lrn}: {e}")
|
| 990 |
+
continue
|
| 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:
|
| 1015 |
+
logger.error(f"LRN cross-reference failed for LRN {lrn}: {e}")
|
| 1016 |
+
|
| 1017 |
+
|
| 1018 |
+
def _persist_students(
|
| 1019 |
+
teacher_uid: str,
|
| 1020 |
+
section_id: str,
|
| 1021 |
+
students: List[Dict[str, Any]],
|
| 1022 |
+
upload_id: str,
|
| 1023 |
+
) -> bool:
|
| 1024 |
+
client = _get_firestore_client()
|
| 1025 |
+
if client is None:
|
| 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:
|
| 1033 |
+
for s in students:
|
| 1034 |
+
lrn = s.get("lrn", "")
|
| 1035 |
+
if not lrn:
|
| 1036 |
+
continue
|
| 1037 |
+
|
| 1038 |
+
student_doc = {
|
| 1039 |
+
"lrn": lrn,
|
| 1040 |
+
"lastName": s.get("lastName", ""),
|
| 1041 |
+
"firstName": s.get("firstName", ""),
|
| 1042 |
+
"sex": s.get("sex", ""),
|
| 1043 |
+
"wwScores": s.get("wwScores", []),
|
| 1044 |
+
"ptScores": s.get("ptScores", []),
|
| 1045 |
+
"qaScore": s.get("qaScore"),
|
| 1046 |
+
"qaMax": s.get("qaMax"),
|
| 1047 |
+
"absences": s.get("absences", 0),
|
| 1048 |
+
"remarks": s.get("remarks", ""),
|
| 1049 |
+
"wwTotal": s.get("wwTotal", 0),
|
| 1050 |
+
"ptTotal": s.get("ptTotal", 0),
|
| 1051 |
+
"qaGrade": s.get("qaGrade", 0),
|
| 1052 |
+
"initialGrade": s.get("initialGrade", 0),
|
| 1053 |
+
"transmutedGrade": s.get("transmutedGrade", 0),
|
| 1054 |
+
"flags": s.get("flags", []),
|
| 1055 |
+
"riskLevel": s.get("riskLevel", "safe"),
|
| 1056 |
+
"uploadId": upload_id,
|
| 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 |
+
|
| 1064 |
+
if count % 500 == 0:
|
| 1065 |
+
try:
|
| 1066 |
+
batch.commit()
|
| 1067 |
+
except Exception as e:
|
| 1068 |
+
logger.error(f"Batch commit failed at {count} students: {e}")
|
| 1069 |
+
return False
|
| 1070 |
+
batch = client.batch()
|
| 1071 |
+
|
| 1072 |
+
if count % 500 != 0:
|
| 1073 |
+
try:
|
| 1074 |
+
batch.commit()
|
| 1075 |
+
except Exception as e:
|
| 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}")
|
| 1083 |
+
return False
|
routes/quiz_battle.py
CHANGED
|
@@ -11,7 +11,7 @@ import os
|
|
| 11 |
from typing import List, Optional, Dict, Any
|
| 12 |
from datetime import datetime, timezone
|
| 13 |
|
| 14 |
-
from fastapi import APIRouter, Request, HTTPException, Depends
|
| 15 |
from pydantic import BaseModel, Field
|
| 16 |
|
| 17 |
from rag.pdf_ingestion import ingest_pdf, IngestionResult
|
|
@@ -90,6 +90,20 @@ class BankStatusResponse(BaseModel):
|
|
| 90 |
pdfs: List[BankStatusItem]
|
| 91 |
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
# โโ Helper โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 94 |
|
| 95 |
def _get_current_user(request: Request):
|
|
@@ -236,3 +250,122 @@ async def bank_status(
|
|
| 236 |
))
|
| 237 |
|
| 238 |
return BankStatusResponse(pdfs=pdfs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
from typing import List, Optional, Dict, Any
|
| 12 |
from datetime import datetime, timezone
|
| 13 |
|
| 14 |
+
from fastapi import APIRouter, Request, HTTPException, Depends, Query
|
| 15 |
from pydantic import BaseModel, Field
|
| 16 |
|
| 17 |
from rag.pdf_ingestion import ingest_pdf, IngestionResult
|
|
|
|
| 90 |
pdfs: List[BankStatusItem]
|
| 91 |
|
| 92 |
|
| 93 |
+
class QuizBattleResultItem(BaseModel):
|
| 94 |
+
studentId: str
|
| 95 |
+
studentName: str
|
| 96 |
+
totalMatches: int
|
| 97 |
+
wins: int
|
| 98 |
+
averageScore: float
|
| 99 |
+
lastPlayedAt: str
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class QuizBattleResultsResponse(BaseModel):
|
| 103 |
+
results: List[QuizBattleResultItem]
|
| 104 |
+
hasMore: bool
|
| 105 |
+
|
| 106 |
+
|
| 107 |
# โโ Helper โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 108 |
|
| 109 |
def _get_current_user(request: Request):
|
|
|
|
| 250 |
))
|
| 251 |
|
| 252 |
return BankStatusResponse(pdfs=pdfs)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
@router.get("/results", response_model=QuizBattleResultsResponse)
|
| 256 |
+
async def get_quiz_battle_results(
|
| 257 |
+
request: Request,
|
| 258 |
+
classId: Optional[str] = Query(None, description="Filter by class section id"),
|
| 259 |
+
limit: int = Query(20, ge=1, le=100, description="Number of results to return"),
|
| 260 |
+
after: Optional[str] = Query(None, description="Pagination cursor (document id)"),
|
| 261 |
+
):
|
| 262 |
+
"""
|
| 263 |
+
Get aggregated quiz battle results per student.
|
| 264 |
+
|
| 265 |
+
Only teachers and admins can access this endpoint.
|
| 266 |
+
Supports pagination with `after` cursor and filtering by `classId`.
|
| 267 |
+
"""
|
| 268 |
+
user = _get_current_user(request)
|
| 269 |
+
if user.role not in ("teacher", "admin"):
|
| 270 |
+
raise HTTPException(status_code=403, detail="Teacher or admin access required")
|
| 271 |
+
|
| 272 |
+
if not firebase_firestore:
|
| 273 |
+
raise HTTPException(status_code=503, detail="Firestore not available")
|
| 274 |
+
|
| 275 |
+
try:
|
| 276 |
+
db = firebase_firestore.client()
|
| 277 |
+
|
| 278 |
+
# Try quizBattleResults collection first; fallback to battles
|
| 279 |
+
results: List[QuizBattleResultItem] = []
|
| 280 |
+
collection_name = "quizBattleResults"
|
| 281 |
+
|
| 282 |
+
# Build query
|
| 283 |
+
query = db.collection(collection_name).limit(limit + 1)
|
| 284 |
+
|
| 285 |
+
if classId:
|
| 286 |
+
# Filter by classSectionId or sectionId (support both field names)
|
| 287 |
+
query = query.where("classSectionId", "==", classId)
|
| 288 |
+
|
| 289 |
+
if after:
|
| 290 |
+
# Pagination cursor
|
| 291 |
+
query = query.start_after({"studentId": after})
|
| 292 |
+
|
| 293 |
+
docs = query.stream()
|
| 294 |
+
|
| 295 |
+
for doc in docs:
|
| 296 |
+
data = doc.to_dict()
|
| 297 |
+
if not data:
|
| 298 |
+
continue
|
| 299 |
+
results.append(QuizBattleResultItem(
|
| 300 |
+
studentId=data.get("studentId", doc.id),
|
| 301 |
+
studentName=data.get("studentName", "Unknown"),
|
| 302 |
+
totalMatches=data.get("totalMatches", 0),
|
| 303 |
+
wins=data.get("wins", 0),
|
| 304 |
+
averageScore=data.get("averageScore", 0.0),
|
| 305 |
+
lastPlayedAt=data.get("lastPlayedAt", ""),
|
| 306 |
+
))
|
| 307 |
+
|
| 308 |
+
# If no results and collection might not exist, try battles collection
|
| 309 |
+
if not results:
|
| 310 |
+
battles_query = db.collection("battles").limit(limit + 1)
|
| 311 |
+
if classId:
|
| 312 |
+
battles_query = battles_query.where("classSectionId", "==", classId)
|
| 313 |
+
if after:
|
| 314 |
+
battles_query = battles_query.start_after({"studentId": after})
|
| 315 |
+
|
| 316 |
+
battle_docs = battles_query.stream()
|
| 317 |
+
student_stats: Dict[str, Dict[str, Any]] = {}
|
| 318 |
+
|
| 319 |
+
for doc in battle_docs:
|
| 320 |
+
data = doc.to_dict()
|
| 321 |
+
if not data:
|
| 322 |
+
continue
|
| 323 |
+
# Aggregate per student from battle documents
|
| 324 |
+
players = data.get("players", [])
|
| 325 |
+
for player in players:
|
| 326 |
+
sid = player.get("studentId")
|
| 327 |
+
if not sid:
|
| 328 |
+
continue
|
| 329 |
+
if sid not in student_stats:
|
| 330 |
+
student_stats[sid] = {
|
| 331 |
+
"studentId": sid,
|
| 332 |
+
"studentName": player.get("studentName", "Unknown"),
|
| 333 |
+
"totalMatches": 0,
|
| 334 |
+
"wins": 0,
|
| 335 |
+
"totalScore": 0,
|
| 336 |
+
"lastPlayedAt": data.get("endedAt", ""),
|
| 337 |
+
}
|
| 338 |
+
student_stats[sid]["totalMatches"] += 1
|
| 339 |
+
if player.get("isWinner"):
|
| 340 |
+
student_stats[sid]["wins"] += 1
|
| 341 |
+
student_stats[sid]["totalScore"] += player.get("score", 0)
|
| 342 |
+
|
| 343 |
+
for sid, stats in list(student_stats.items())[:limit]:
|
| 344 |
+
avg_score = (
|
| 345 |
+
stats["totalScore"] / stats["totalMatches"]
|
| 346 |
+
if stats["totalMatches"] > 0
|
| 347 |
+
else 0.0
|
| 348 |
+
)
|
| 349 |
+
results.append(QuizBattleResultItem(
|
| 350 |
+
studentId=stats["studentId"],
|
| 351 |
+
studentName=stats["studentName"],
|
| 352 |
+
totalMatches=stats["totalMatches"],
|
| 353 |
+
wins=stats["wins"],
|
| 354 |
+
averageScore=round(avg_score, 2),
|
| 355 |
+
lastPlayedAt=stats["lastPlayedAt"],
|
| 356 |
+
))
|
| 357 |
+
|
| 358 |
+
has_more = len(results) > limit
|
| 359 |
+
if has_more:
|
| 360 |
+
results = results[:limit]
|
| 361 |
+
|
| 362 |
+
return QuizBattleResultsResponse(
|
| 363 |
+
results=results,
|
| 364 |
+
hasMore=has_more,
|
| 365 |
+
)
|
| 366 |
+
|
| 367 |
+
except Exception as e:
|
| 368 |
+
raise HTTPException(
|
| 369 |
+
status_code=500,
|
| 370 |
+
detail=f"Failed to fetch quiz battle results: {str(e)}",
|
| 371 |
+
)
|
routes/teacher_materials.py
CHANGED
|
@@ -338,10 +338,9 @@ Respond with JSON only, no markdown or extra text."""
|
|
| 338 |
{"role": "user", "content": user_prompt},
|
| 339 |
]
|
| 340 |
|
| 341 |
-
# Call DeepSeek via inference_client (test-friendly patch target)
|
| 342 |
-
from services.inference_client import call_hf_chat_async # type: ignore[import-not-found]
|
| 343 |
-
|
| 344 |
try:
|
|
|
|
|
|
|
| 345 |
raw_response = await call_hf_chat_async(
|
| 346 |
messages=messages,
|
| 347 |
max_tokens=4096,
|
|
@@ -597,3 +596,78 @@ async def upload_teacher_material(
|
|
| 597 |
message="An unexpected error occurred during processing.",
|
| 598 |
error=str(e),
|
| 599 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
{"role": "user", "content": user_prompt},
|
| 339 |
]
|
| 340 |
|
|
|
|
|
|
|
|
|
|
| 341 |
try:
|
| 342 |
+
# Call DeepSeek via inference_client (test-friendly patch target)
|
| 343 |
+
from services.inference_client import call_hf_chat_async # type: ignore[import-not-found]
|
| 344 |
raw_response = await call_hf_chat_async(
|
| 345 |
messages=messages,
|
| 346 |
max_tokens=4096,
|
|
|
|
| 596 |
message="An unexpected error occurred during processing.",
|
| 597 |
error=str(e),
|
| 598 |
)
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
# โโโ ENDPOINT 2: POST /api/teacher-materials/{moduleId}/generate-quiz โโโโโโ
|
| 602 |
+
|
| 603 |
+
import json as _json
|
| 604 |
+
|
| 605 |
+
@router.post("/{moduleId}/generate-quiz")
|
| 606 |
+
async def generate_module_quiz(request: Request, moduleId: str):
|
| 607 |
+
user: Any = getattr(request.state, "user", None)
|
| 608 |
+
if user is None:
|
| 609 |
+
raise HTTPException(status_code=401, detail="Authentication required")
|
| 610 |
+
if user.role not in {"teacher", "admin"}:
|
| 611 |
+
raise HTTPException(status_code=403, detail="Only teachers and admins can generate quizzes")
|
| 612 |
+
|
| 613 |
+
client = _get_firestore_client()
|
| 614 |
+
if client is None:
|
| 615 |
+
raise HTTPException(status_code=503, detail="Firestore unavailable")
|
| 616 |
+
|
| 617 |
+
module_ref = client.collection("modules").document(moduleId)
|
| 618 |
+
module_snap = module_ref.get()
|
| 619 |
+
if not module_snap.exists:
|
| 620 |
+
raise HTTPException(status_code=404, detail="Module not found")
|
| 621 |
+
|
| 622 |
+
module_data = module_snap.to_dict()
|
| 623 |
+
title = module_data.get("title", "")
|
| 624 |
+
grade_level = module_data.get("gradeLevel", "")
|
| 625 |
+
subject = module_data.get("subject", "")
|
| 626 |
+
quarter = module_data.get("quarter", "")
|
| 627 |
+
sections = module_data.get("sections", [])
|
| 628 |
+
objectives = module_data.get("learningObjectives", [])
|
| 629 |
+
|
| 630 |
+
module_text = f"Title: {title}\nGrade: {grade_level}\nSubject: {subject}\nQuarter: {quarter}\n\n"
|
| 631 |
+
module_text += "Learning Objectives:\n" + "\n".join(objectives[:5]) + "\n\n" if objectives else ""
|
| 632 |
+
for s in sections[:3]:
|
| 633 |
+
module_text += f"Section: {s.get('title','')}\n{s.get('content','')[:800]}\n\n"
|
| 634 |
+
|
| 635 |
+
prompt = (
|
| 636 |
+
"You are a DepEd math teacher. Based on the following curriculum module content, "
|
| 637 |
+
"generate 10 multiple-choice quiz questions aligned to DepEd K-12 competencies "
|
| 638 |
+
f"for {grade_level} {subject}. Each question must have 4 choices (A-D), one correct answer, "
|
| 639 |
+
"and a brief explanation.\n\n"
|
| 640 |
+
f"MODULE CONTENT:\n{module_text[:3000]}\n\n"
|
| 641 |
+
'Return JSON: {"questions": [{"question": "...", "choices": {"A":"...","B":"...","C":"...","D":"..."}, "correct": "A", "explanation": "...", "competencyCode": "..."}]}'
|
| 642 |
+
)
|
| 643 |
+
|
| 644 |
+
try:
|
| 645 |
+
response_text = await call_hf_chat_async(
|
| 646 |
+
messages=[{"role": "user", "content": prompt}],
|
| 647 |
+
max_tokens=2000, temperature=0.7, task_type="quiz_generation"
|
| 648 |
+
)
|
| 649 |
+
parsed = _json.loads(response_text)
|
| 650 |
+
questions = parsed.get("questions", [])
|
| 651 |
+
except Exception as e:
|
| 652 |
+
logger.error(f"Quiz generation failed: {e}")
|
| 653 |
+
questions = []
|
| 654 |
+
|
| 655 |
+
quiz_id = uuid.uuid4().hex
|
| 656 |
+
quiz_doc = {
|
| 657 |
+
"quizId": quiz_id,
|
| 658 |
+
"moduleId": moduleId,
|
| 659 |
+
"teacherId": user.uid,
|
| 660 |
+
"gradeLevel": grade_level,
|
| 661 |
+
"subject": subject,
|
| 662 |
+
"quarter": quarter,
|
| 663 |
+
"questions": questions,
|
| 664 |
+
"createdAt": datetime.utcnow().isoformat(),
|
| 665 |
+
"source": "teacher_module",
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
try:
|
| 669 |
+
client.collection("quizzes").document(quiz_id).set(quiz_doc)
|
| 670 |
+
except Exception as e:
|
| 671 |
+
logger.error(f"Failed to persist quiz: {e}")
|
| 672 |
+
|
| 673 |
+
return {"quizId": quiz_id, "questions": questions}
|
services/variance_engine.py
CHANGED
|
@@ -6,11 +6,14 @@ with pure-Python fallback for choice shuffling.
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import json
|
|
|
|
| 9 |
import random
|
| 10 |
import re
|
| 11 |
from typing import List, Dict
|
| 12 |
|
| 13 |
from services.ai_client import get_deepseek_client, CHAT_MODEL
|
|
|
|
|
|
|
| 14 |
from services.question_bank_service import get_cached_session, cache_session_questions
|
| 15 |
|
| 16 |
|
|
@@ -102,7 +105,7 @@ Do NOT change "topic", "difficulty", "grade_level", or "source_chunk_id"."""
|
|
| 102 |
raise ValueError("Missing required fields in varied question")
|
| 103 |
|
| 104 |
except Exception as e:
|
| 105 |
-
|
| 106 |
varied_questions = _fallback_shuffle(questions, seed)
|
| 107 |
|
| 108 |
# 4. Cache for 24 hours
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import json
|
| 9 |
+
import logging
|
| 10 |
import random
|
| 11 |
import re
|
| 12 |
from typing import List, Dict
|
| 13 |
|
| 14 |
from services.ai_client import get_deepseek_client, CHAT_MODEL
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("mathpulse.variance_engine")
|
| 17 |
from services.question_bank_service import get_cached_session, cache_session_questions
|
| 18 |
|
| 19 |
|
|
|
|
| 105 |
raise ValueError("Missing required fields in varied question")
|
| 106 |
|
| 107 |
except Exception as e:
|
| 108 |
+
logger.warning(f"DeepSeek variance failed, falling back to shuffle: {e}")
|
| 109 |
varied_questions = _fallback_shuffle(questions, seed)
|
| 110 |
|
| 111 |
# 4. Cache for 24 hours
|
tests/test_teacher_materials.py
CHANGED
|
@@ -187,7 +187,7 @@ class TestTeacherMaterialsFileValidation:
|
|
| 187 |
with (
|
| 188 |
patch("routes.teacher_materials._parse_uploaded_file", return_value=("text", 100, {})),
|
| 189 |
patch("routes.teacher_materials._retrieve_rag_context", return_value=[]),
|
| 190 |
-
patch("routes.teacher_materials._generate_teacher_module", return_value=
|
| 191 |
):
|
| 192 |
response = client.post(
|
| 193 |
"/api/teacher-materials/upload",
|
|
|
|
| 187 |
with (
|
| 188 |
patch("routes.teacher_materials._parse_uploaded_file", return_value=("text", 100, {})),
|
| 189 |
patch("routes.teacher_materials._retrieve_rag_context", return_value=[]),
|
| 190 |
+
patch("routes.teacher_materials._generate_teacher_module", return_value={"moduleId": "test-pdf", "title": "Test PDF Module", "sections": [], "practiceQuestions": []}),
|
| 191 |
):
|
| 192 |
response = client.post(
|
| 193 |
"/api/teacher-materials/upload",
|