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

๐Ÿš€ Auto-deploy backend from GitHub (82c290f)

Browse files
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
- return {
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
- ) -> None:
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 _persist_students(
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
- batch = client.batch()
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
- student_doc = {
683
- "lrn": lrn,
684
- "lastName": s.get("lastName", ""),
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
- student_ref = section_ref.collection("students").document(lrn)
705
- batch.set(student_ref, student_doc, merge=True)
706
- count += 1
 
 
 
 
707
 
708
- if count % 500 == 0:
709
- try:
710
- batch.commit()
711
- except Exception as e:
712
- logger.error(f"Batch commit failed at {count} students: {e}")
713
- batch = client.batch()
 
 
 
714
 
715
- if count % 500 != 0:
716
  try:
717
- batch.commit()
 
 
 
 
 
 
 
 
 
 
718
  except Exception as e:
719
- logger.error(f"Final batch commit failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
720
 
721
- logger.info(f"Persisted {count} students to classRecords/{teacher_uid}/sections/{section_id}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- print(f"[variance_engine] DeepSeek variance failed, falling back to shuffle: {e}")
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=None),
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",