Spaces:
Running
Running
github-actions[bot] commited on
Commit ยท
10e98ed
1
Parent(s): 01e41e5
๐ Auto-deploy backend from GitHub (49ee1f9)
Browse files- main.py +2 -0
- routes/admin_routes.py +80 -0
- 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 |
+
}
|