github-actions[bot] commited on
Commit
10e98ed
ยท
1 Parent(s): 01e41e5

๐Ÿš€ Auto-deploy backend from GitHub (49ee1f9)

Browse files
Files changed (3) hide show
  1. main.py +2 -0
  2. routes/admin_routes.py +80 -0
  3. routes/diagnostic.py +195 -0
main.py CHANGED
@@ -406,6 +406,7 @@ ROLE_POLICIES: Dict[str, Set[str]] = {
406
  "/api/calculator/evaluate": ALL_APP_ROLES,
407
  "/api/diagnostic/generate": ALL_APP_ROLES,
408
  "/api/diagnostic/submit": ALL_APP_ROLES,
 
409
  "/api/student/competency-analysis": TEACHER_OR_ADMIN,
410
  "/api/risk/train-model": ADMIN_ONLY,
411
  "/api/predict-risk/enhanced": TEACHER_OR_ADMIN,
@@ -430,6 +431,7 @@ ROLE_POLICIES: Dict[str, Set[str]] = {
430
  "/api/admin/model-config": ADMIN_ONLY,
431
  "/api/admin/upload-pdf": ADMIN_ONLY,
432
  "/api/admin/reingest-pdf": ADMIN_ONLY,
 
433
  "/api/admin/model-config/profile": ADMIN_ONLY,
434
  "/api/admin/model-config/override": ADMIN_ONLY,
435
  "/api/admin/model-config/reset": ADMIN_ONLY,
 
406
  "/api/calculator/evaluate": ALL_APP_ROLES,
407
  "/api/diagnostic/generate": ALL_APP_ROLES,
408
  "/api/diagnostic/submit": ALL_APP_ROLES,
409
+ "/api/diagnostic/analyze": ALL_APP_ROLES,
410
  "/api/student/competency-analysis": TEACHER_OR_ADMIN,
411
  "/api/risk/train-model": ADMIN_ONLY,
412
  "/api/predict-risk/enhanced": TEACHER_OR_ADMIN,
 
431
  "/api/admin/model-config": ADMIN_ONLY,
432
  "/api/admin/upload-pdf": ADMIN_ONLY,
433
  "/api/admin/reingest-pdf": ADMIN_ONLY,
434
+ "/api/admin/delete-file": ADMIN_ONLY,
435
  "/api/admin/model-config/profile": ADMIN_ONLY,
436
  "/api/admin/model-config/override": ADMIN_ONLY,
437
  "/api/admin/model-config/reset": ADMIN_ONLY,
routes/admin_routes.py CHANGED
@@ -124,3 +124,83 @@ async def reingest_pdf(
124
  except Exception as e:
125
  logger.error(f"Failed to reingest: {e}")
126
  raise HTTPException(status_code=500, detail=f"Failed to reingest: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  except Exception as e:
125
  logger.error(f"Failed to reingest: {e}")
126
  raise HTTPException(status_code=500, detail=f"Failed to reingest: {e}")
127
+
128
+
129
+ class DeleteFileRequest(BaseModel):
130
+ fileId: str
131
+ collection: str # 'courseMaterials' or 'classRecordImports'
132
+
133
+
134
+ @router.post("/delete-file")
135
+ async def delete_uploaded_file(
136
+ req: DeleteFileRequest,
137
+ request: Request,
138
+ _admin=Depends(require_admin),
139
+ ):
140
+ """Delete an uploaded file and its associated data."""
141
+ import firebase_admin
142
+ from firebase_admin import firestore as fs
143
+
144
+ if req.collection not in ("courseMaterials", "classRecordImports"):
145
+ raise HTTPException(status_code=400, detail="Invalid collection")
146
+
147
+ try:
148
+ client = fs.client()
149
+ doc_ref = client.collection(req.collection).document(req.fileId)
150
+ doc_snap = doc_ref.get()
151
+
152
+ if not doc_snap.exists:
153
+ raise HTTPException(status_code=404, detail="File not found")
154
+
155
+ doc_data = doc_snap.to_dict() or {}
156
+
157
+ # Delete associated normalizedClassRecords for class record imports
158
+ if req.collection == "classRecordImports":
159
+ teacher_id = doc_data.get("teacherId", "")
160
+ class_section_id = doc_data.get("classSectionId", "")
161
+ if teacher_id:
162
+ norm_query = client.collection("normalizedClassRecords").where(
163
+ "teacherId", "==", teacher_id
164
+ )
165
+ if class_section_id:
166
+ norm_query = norm_query.where("classSectionId", "==", class_section_id)
167
+ norm_docs = norm_query.stream()
168
+ batch = client.batch()
169
+ count = 0
170
+ for norm_doc in norm_docs:
171
+ batch.delete(norm_doc.reference)
172
+ count += 1
173
+ if count >= 400:
174
+ batch.commit()
175
+ batch = client.batch()
176
+ count = 0
177
+ if count > 0:
178
+ batch.commit()
179
+
180
+ # Delete the main document
181
+ doc_ref.delete()
182
+
183
+ # Audit log
184
+ audit_fn = _get_audit_logger()
185
+ if audit_fn:
186
+ try:
187
+ import asyncio
188
+ asyncio.create_task(audit_fn(
189
+ action="DELETE_UPLOADED_FILE",
190
+ actor_uid=_admin.uid,
191
+ actor_name=getattr(_admin, "name", "Unknown"),
192
+ actor_email=getattr(_admin, "email", ""),
193
+ actor_role=_admin.role,
194
+ description=f"Deleted {req.collection}/{req.fileId} ({doc_data.get('fileName', 'unknown')})",
195
+ route="/api/admin/delete-file",
196
+ module="admin",
197
+ ))
198
+ except Exception:
199
+ pass
200
+
201
+ return {"success": True, "message": "File and associated data deleted."}
202
+ except HTTPException:
203
+ raise
204
+ except Exception as e:
205
+ logger.error(f"Failed to delete file: {e}")
206
+ raise HTTPException(status_code=500, detail=f"Failed to delete file: {e}")
routes/diagnostic.py CHANGED
@@ -795,3 +795,198 @@ async def submit_diagnostic(request: DiagnosticSubmitRequest, req: Request):
795
  badge_unlocked="first_assessment",
796
  redirect_to="/dashboard",
797
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
795
  badge_unlocked="first_assessment",
796
  redirect_to="/dashboard",
797
  )
798
+
799
+
800
+ # โ”€โ”€โ”€ AI-Powered Diagnostic Analysis โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
801
+
802
+
803
+ class DiagnosticAnalysisRequest(BaseModel):
804
+ user_id: str
805
+
806
+
807
+ class DiagnosticAnalysisResponse(BaseModel):
808
+ success: bool
809
+ analysis: Dict[str, Any]
810
+
811
+
812
+ @router.post("/analyze", response_model=DiagnosticAnalysisResponse)
813
+ async def analyze_diagnostic(request: DiagnosticAnalysisRequest, req: Request):
814
+ """Generate AI-powered in-depth analysis of diagnostic results."""
815
+ user = getattr(req.state, "user", None)
816
+ if not user or not getattr(user, "uid", None):
817
+ raise HTTPException(status_code=401, detail="Authentication required")
818
+
819
+ try:
820
+ firestore_client = fs.client()
821
+ except Exception:
822
+ raise HTTPException(status_code=503, detail="Database unavailable")
823
+
824
+ # Fetch diagnostic results
825
+ results_doc = firestore_client.collection("diagnosticResults").document(request.user_id).get()
826
+ if not results_doc.exists:
827
+ raise HTTPException(status_code=404, detail="No diagnostic results found")
828
+
829
+ results_data = results_doc.to_dict() or {}
830
+ responses = results_data.get("responses", [])
831
+ domain_scores = results_data.get("domainScores", {})
832
+ risk_profile = results_data.get("riskProfile", {})
833
+ test_id = results_data.get("testId", "")
834
+
835
+ # Fetch question texts from session
836
+ question_texts: Dict[str, str] = {}
837
+ if test_id:
838
+ session_doc = firestore_client.collection("diagnosticSessions").document(test_id).get()
839
+ if session_doc.exists:
840
+ session_data = session_doc.to_dict() or {}
841
+ for q in session_data.get("questions", []):
842
+ question_texts[q.get("question_id", "")] = q.get("question_text", "")
843
+
844
+ # Build prompt for AI analysis
845
+ total_time = sum(r.get("time_spent_seconds", 0) for r in responses)
846
+ total_correct = sum(1 for r in responses if r.get("is_correct"))
847
+ total_items = len(responses)
848
+
849
+ question_details = []
850
+ for i, r in enumerate(responses, 1):
851
+ q_text = question_texts.get(r.get("question_id", ""), f"Question {i}")
852
+ time_s = r.get("time_spent_seconds", 0)
853
+ question_details.append(
854
+ f"Q{i} [{r.get('domain','')}/{r.get('topic','')}] "
855
+ f"(difficulty={r.get('difficulty','')}, bloom={r.get('bloom_level','')}) "
856
+ f"{'โœ“' if r.get('is_correct') else 'โœ—'} "
857
+ f"time={time_s}s | "
858
+ f"Question: {q_text[:120]}"
859
+ )
860
+
861
+ domain_summary = []
862
+ for domain, scores in domain_scores.items():
863
+ domain_summary.append(f" {domain}: {scores.get('correct',0)}/{scores.get('total',0)} ({scores.get('percentage',0)}%) - {scores.get('mastery_level','')}")
864
+
865
+ prompt = f"""You are an expert math education analyst. Analyze this student's diagnostic assessment results and provide deep, actionable insights.
866
+
867
+ ASSESSMENT DATA:
868
+ - Score: {total_correct}/{total_items} ({round(total_correct/total_items*100,1) if total_items else 0}%)
869
+ - Total time: {total_time}s (avg {round(total_time/total_items,1) if total_items else 0}s per question)
870
+ - Risk level: {risk_profile.get('overall_risk', 'unknown')}
871
+
872
+ DOMAIN SCORES:
873
+ {chr(10).join(domain_summary)}
874
+
875
+ PER-QUESTION BREAKDOWN:
876
+ {chr(10).join(question_details)}
877
+
878
+ Provide your analysis as JSON with this exact structure:
879
+ {{
880
+ "overall_summary": "2-3 sentence summary of performance",
881
+ "time_analysis": {{
882
+ "pattern": "description of timing patterns (rushed, deliberate, inconsistent, etc.)",
883
+ "fast_questions": ["topics where student answered very quickly"],
884
+ "slow_questions": ["topics where student took longest"],
885
+ "insight": "what timing reveals about confidence and understanding"
886
+ }},
887
+ "strength_areas": [
888
+ {{"domain": "...", "detail": "specific strength description"}}
889
+ ],
890
+ "weakness_areas": [
891
+ {{"domain": "...", "detail": "specific weakness description", "priority": "high/medium/low"}}
892
+ ],
893
+ "answer_patterns": {{
894
+ "description": "patterns in how student answered (guessing on hard, consistent errors in topic, etc.)",
895
+ "common_mistakes": ["list of mistake patterns"],
896
+ "positive_patterns": ["list of positive patterns"]
897
+ }},
898
+ "recommendations": [
899
+ {{"action": "specific recommendation", "reason": "why this helps", "priority": 1}}
900
+ ],
901
+ "difficulty_analysis": {{
902
+ "easy_performance": "how they did on easy questions",
903
+ "medium_performance": "how they did on medium questions",
904
+ "hard_performance": "how they did on hard questions"
905
+ }}
906
+ }}
907
+
908
+ Return ONLY valid JSON, no markdown fences."""
909
+
910
+ try:
911
+ from main import call_hf_chat_async
912
+ raw = await call_hf_chat_async(
913
+ [{"role": "user", "content": prompt}],
914
+ max_tokens=1500,
915
+ temperature=0.3,
916
+ task_type="analytics",
917
+ )
918
+
919
+ # Parse JSON from response
920
+ cleaned = raw.strip()
921
+ if cleaned.startswith("```"):
922
+ cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned[3:]
923
+ if cleaned.endswith("```"):
924
+ cleaned = cleaned[:-3]
925
+ cleaned = cleaned.strip()
926
+
927
+ analysis = json.loads(cleaned)
928
+ except (json.JSONDecodeError, Exception) as e:
929
+ logger.warning(f"[diagnostic/analyze] AI parse failed: {e}, using fallback")
930
+ # Fallback: generate basic analysis without AI
931
+ analysis = _build_fallback_analysis(responses, domain_scores, risk_profile)
932
+
933
+ return DiagnosticAnalysisResponse(success=True, analysis=analysis)
934
+
935
+
936
+ def _build_fallback_analysis(
937
+ responses: List[Dict[str, Any]],
938
+ domain_scores: Dict[str, Any],
939
+ risk_profile: Dict[str, Any],
940
+ ) -> Dict[str, Any]:
941
+ """Build a basic analysis when AI is unavailable."""
942
+ total_time = sum(r.get("time_spent_seconds", 0) for r in responses)
943
+ total_items = len(responses)
944
+ avg_time = round(total_time / total_items, 1) if total_items else 0
945
+ total_correct = sum(1 for r in responses if r.get("is_correct"))
946
+
947
+ # Find fast/slow questions
948
+ times = [(r.get("topic", ""), r.get("time_spent_seconds", 0), r.get("is_correct", False)) for r in responses]
949
+ times_sorted = sorted(times, key=lambda x: x[1])
950
+ fast = [t[0] for t in times_sorted[:3] if t[0]]
951
+ slow = [t[0] for t in times_sorted[-3:] if t[0]]
952
+
953
+ # Strengths/weaknesses from domain scores
954
+ strengths = [{"domain": d, "detail": f"Scored {s.get('percentage',0)}%"} for d, s in domain_scores.items() if s.get("percentage", 0) >= 70]
955
+ weaknesses = [{"domain": d, "detail": f"Scored {s.get('percentage',0)}%", "priority": "high" if s.get("percentage", 0) < 50 else "medium"} for d, s in domain_scores.items() if s.get("percentage", 0) < 70]
956
+
957
+ # Difficulty breakdown
958
+ easy = [r for r in responses if r.get("difficulty") == "easy"]
959
+ medium = [r for r in responses if r.get("difficulty") == "medium"]
960
+ hard = [r for r in responses if r.get("difficulty") == "hard"]
961
+
962
+ def pct(items: list) -> str:
963
+ if not items:
964
+ return "No questions"
965
+ correct = sum(1 for i in items if i.get("is_correct"))
966
+ return f"{correct}/{len(items)} correct ({round(correct/len(items)*100)}%)"
967
+
968
+ return {
969
+ "overall_summary": f"Scored {total_correct}/{total_items} ({round(total_correct/total_items*100) if total_items else 0}%) with an average of {avg_time}s per question. Risk level: {risk_profile.get('overall_risk', 'unknown')}.",
970
+ "time_analysis": {
971
+ "pattern": "deliberate" if avg_time > 60 else "moderate" if avg_time > 30 else "quick",
972
+ "fast_questions": fast,
973
+ "slow_questions": slow,
974
+ "insight": f"Average response time of {avg_time}s per question.",
975
+ },
976
+ "strength_areas": strengths,
977
+ "weakness_areas": weaknesses,
978
+ "answer_patterns": {
979
+ "description": "Analysis based on response data.",
980
+ "common_mistakes": [f"Errors in {d}" for d, s in domain_scores.items() if s.get("percentage", 0) < 60],
981
+ "positive_patterns": [f"Strong in {d}" for d, s in domain_scores.items() if s.get("percentage", 0) >= 70],
982
+ },
983
+ "recommendations": [
984
+ {"action": f"Focus on {w['domain']}", "reason": w["detail"], "priority": i + 1}
985
+ for i, w in enumerate(weaknesses[:3])
986
+ ],
987
+ "difficulty_analysis": {
988
+ "easy_performance": pct(easy),
989
+ "medium_performance": pct(medium),
990
+ "hard_performance": pct(hard),
991
+ },
992
+ }