Spaces:
Running
Running
| """ | |
| MathPulse AI - FastAPI Backend | |
| AI-powered math tutoring backend using Hugging Face models. | |
| - meta-llama/Meta-Llama-3-8B-Instruct for chat, learning paths, insights, and quiz generation | |
| (via HF Serverless Inference API) | |
| - facebook/bart-large-mnli for student risk classification | |
| - Multi-method verification system for math accuracy | |
| - AI-powered Quiz Maker with Bloom's Taxonomy integration | |
| - Symbolic math calculator via SymPy | |
| - Analytics and automation engine modules | |
| Auto-deployed to HuggingFace Spaces via GitHub Actions. | |
| """ | |
| import os | |
| import io | |
| import re | |
| import json | |
| import math | |
| import logging | |
| import traceback | |
| from typing import List, Optional, Dict, Any | |
| from collections import Counter | |
| from fastapi import FastAPI, UploadFile, File, HTTPException, Query, Request | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import JSONResponse | |
| from pydantic import BaseModel, Field, validator | |
| from starlette.middleware.base import BaseHTTPMiddleware | |
| import asyncio | |
| import time | |
| import uuid | |
| import requests as http_requests | |
| import uvicorn | |
| # Event-driven automation engine | |
| from automation_engine import ( | |
| automation_engine, | |
| DiagnosticCompletionPayload, | |
| QuizSubmissionPayload, | |
| StudentEnrollmentPayload, | |
| DataImportPayload, | |
| ContentUpdatePayload, | |
| AutomationResult, | |
| ) | |
| # ML-powered analytics module | |
| from analytics import ( | |
| # Request/Response models | |
| CompetencyAnalysisRequest, | |
| CompetencyAnalysis, | |
| CompetencyAnalysisResponse, | |
| TopicRecommendation, | |
| TopicRecommendationRequest, | |
| TopicRecommendationResponse, | |
| EnhancedRiskPrediction, | |
| EnhancedRiskRequest, | |
| RiskTrainRequest, | |
| RiskTrainResponse, | |
| CalibrateDifficultyRequest, | |
| CalibrateDifficultyResponse, | |
| AdaptiveQuizRequest as AdaptiveQuizSelectRequest, | |
| AdaptiveQuizResponse, | |
| StudentSummaryResponse, | |
| ClassInsightsRequest, | |
| ClassInsightsResponse, | |
| MockDataRequest, | |
| RefreshCacheResponse, | |
| # Core functions | |
| compute_competency_analysis, | |
| predict_risk_enhanced, | |
| train_risk_model, | |
| calibrate_question_difficulty, | |
| select_adaptive_quiz, | |
| recommend_topics, | |
| get_student_summary, | |
| get_class_insights, | |
| generate_mock_student_data, | |
| refresh_all_caches, | |
| # Helpers | |
| fetch_student_quiz_history, | |
| fetch_student_engagement_metrics, | |
| fetch_topic_dependencies, | |
| store_competency_analysis, | |
| store_question_difficulty, | |
| # Config | |
| RISK_MODEL_PATH, | |
| COMPETENCY_THRESHOLDS, | |
| MIN_QUIZ_ATTEMPTS_FOR_COMPETENCY, | |
| ) | |
| # โโโ Configuration โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger("mathpulse") | |
| HF_TOKEN = os.environ.get("HF_TOKEN", os.environ.get("HUGGING_FACE_API_TOKEN", "")) | |
| # Temporarily using Meta-Llama-3-8B-Instruct via HF Serverless Inference API | |
| # because Qwen/Qwen2.5-Math-7B-Instruct is provider-only (not available on | |
| # HF serverless). Swap this back once a provider is configured or the model | |
| # becomes serverless-compatible. | |
| HF_MATH_MODEL_ID = os.getenv("HF_MATH_MODEL_ID", "meta-llama/Meta-Llama-3-8B-Instruct") | |
| # Alias kept so automation_engine.py (which imports CHAT_MODEL) keeps working. | |
| CHAT_MODEL = HF_MATH_MODEL_ID | |
| RISK_MODEL = "facebook/bart-large-mnli" | |
| VERIFICATION_SAMPLES = 3 # Number of samples for self-consistency checking | |
| if not HF_TOKEN: | |
| logger.warning( | |
| "HF_TOKEN is not set. AI features will fail. " | |
| "On HF Spaces this is injected automatically as a secret." | |
| ) | |
| # โโโ FastAPI App โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| app = FastAPI( | |
| title="MathPulse AI API", | |
| description="AI-powered math tutoring and student analytics backend", | |
| version="1.0.0", | |
| ) | |
| # โโโ Middleware: Request ID + Logging + Timeout โโโโโโโโโโโโโโโโ | |
| REQUEST_TIMEOUT_SECONDS = 120 # 2 minutes for AI-heavy endpoints | |
| class RequestMiddleware(BaseHTTPMiddleware): | |
| """Adds request-ID header, logs requests, and enforces timeouts.""" | |
| async def dispatch(self, request: Request, call_next): | |
| request_id = str(uuid.uuid4())[:8] | |
| start = time.time() | |
| # Attach request_id for downstream logging | |
| request.state.request_id = request_id | |
| logger.info(f"[{request_id}] {request.method} {request.url.path}") | |
| try: | |
| response = await asyncio.wait_for( | |
| call_next(request), | |
| timeout=REQUEST_TIMEOUT_SECONDS, | |
| ) | |
| duration = round(time.time() - start, 3) | |
| response.headers["X-Request-ID"] = request_id | |
| response.headers["X-Response-Time"] = f"{duration}s" | |
| logger.info(f"[{request_id}] {response.status_code} in {duration}s") | |
| return response | |
| except asyncio.TimeoutError: | |
| duration = round(time.time() - start, 3) | |
| logger.error(f"[{request_id}] TIMEOUT after {duration}s on {request.url.path}") | |
| return JSONResponse( | |
| status_code=504, | |
| content={ | |
| "detail": f"Request timed out after {REQUEST_TIMEOUT_SECONDS}s", | |
| "requestId": request_id, | |
| }, | |
| headers={"X-Request-ID": request_id}, | |
| ) | |
| except Exception as exc: | |
| duration = round(time.time() - start, 3) | |
| logger.error(f"[{request_id}] Unhandled error after {duration}s: {exc}") | |
| return JSONResponse( | |
| status_code=500, | |
| content={ | |
| "detail": "Internal server error", | |
| "requestId": request_id, | |
| }, | |
| headers={"X-Request-ID": request_id}, | |
| ) | |
| app.add_middleware(RequestMiddleware) | |
| # โโโ Global Exception Handler โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async def http_exception_handler(request: Request, exc: HTTPException): | |
| request_id = getattr(request.state, "request_id", "unknown") | |
| logger.error(f"[{request_id}] HTTPException {exc.status_code}: {exc.detail}") | |
| return JSONResponse( | |
| status_code=exc.status_code, | |
| content={ | |
| "detail": exc.detail, | |
| "status": exc.status_code, | |
| "requestId": request_id, | |
| }, | |
| headers={"X-Request-ID": request_id}, | |
| ) | |
| async def global_exception_handler(request: Request, exc: Exception): | |
| request_id = getattr(request.state, "request_id", "unknown") | |
| logger.error(f"[{request_id}] Unhandled: {type(exc).__name__}: {exc}\n{traceback.format_exc()}") | |
| return JSONResponse( | |
| status_code=500, | |
| content={ | |
| "detail": "An unexpected error occurred. Please try again.", | |
| "error": type(exc).__name__, | |
| "requestId": request_id, | |
| }, | |
| headers={"X-Request-ID": request_id}, | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # โโโ Hugging Face Clients โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # InferenceClient is kept only for zero-shot classification (BART). | |
| from huggingface_hub import InferenceClient | |
| _zsc_client: Optional[InferenceClient] = None | |
| def get_client() -> InferenceClient: | |
| """Get or initialize the HuggingFace InferenceClient (used for zero-shot classification only).""" | |
| global _zsc_client | |
| if _zsc_client is None: | |
| if not HF_TOKEN: | |
| raise HTTPException( | |
| status_code=500, | |
| detail="HF_TOKEN not configured. Set the HF_TOKEN environment variable.", | |
| ) | |
| for attempt in range(3): | |
| try: | |
| _zsc_client = InferenceClient( | |
| token=HF_TOKEN, | |
| timeout=60, | |
| ) | |
| logger.info("HF InferenceClient initialized (for zero-shot classification)") | |
| break | |
| except Exception as e: | |
| logger.warning(f"HF client init attempt {attempt + 1} failed: {e}") | |
| if attempt == 2: | |
| raise HTTPException( | |
| status_code=503, | |
| detail="Failed to initialize AI model client after 3 attempts.", | |
| ) | |
| time.sleep(2 ** attempt) | |
| assert _zsc_client is not None | |
| return _zsc_client | |
| # โโโ HF Serverless Chat Helper (requests-based) โโโโโโโโโโโโโโโ | |
| def _strip_repetition(text: str, min_chunk: int = 40) -> str: | |
| """Remove repeated blocks from model output (a common issue with smaller LLMs).""" | |
| lines = text.split("\n") | |
| seen_blocks: list[str] = [] | |
| result_lines: list[str] = [] | |
| i = 0 | |
| while i < len(lines): | |
| # Try to match a block of 2-4 lines that repeats | |
| matched = False | |
| for blen in (4, 3, 2): | |
| if i + blen > len(lines): | |
| continue | |
| block = "\n".join(lines[i : i + blen]).strip() | |
| if len(block) < min_chunk: | |
| continue | |
| if block in seen_blocks: | |
| # Skip this repeated block | |
| i += blen | |
| matched = True | |
| break | |
| seen_blocks.append(block) | |
| if not matched: | |
| result_lines.append(lines[i]) | |
| i += 1 | |
| return "\n".join(result_lines).strip() | |
| def call_hf_chat( | |
| messages: List[Dict[str, str]], | |
| *, | |
| max_tokens: int = 2048, | |
| temperature: float = 0.2, | |
| top_p: float = 0.9, | |
| repetition_penalty: float = 1.15, | |
| model: Optional[str] = None, | |
| timeout: int = 90, | |
| ) -> str: | |
| """ | |
| Call the HF Serverless Inference API (OpenAI-compatible chat endpoint) | |
| using plain ``requests``. Retries up to 3 times on 503 (model loading). | |
| """ | |
| if not HF_TOKEN: | |
| raise RuntimeError("HF_TOKEN is not set") | |
| target_model = model or HF_MATH_MODEL_ID | |
| url = "https://router.huggingface.co/v1/chat/completions" | |
| headers = { | |
| "Authorization": f"Bearer {HF_TOKEN}", | |
| "Content-Type": "application/json", | |
| } | |
| payload = { | |
| "model": target_model, | |
| "messages": messages, | |
| "max_tokens": max_tokens, | |
| "temperature": temperature, | |
| "top_p": top_p, | |
| "repetition_penalty": repetition_penalty, | |
| } | |
| for attempt in range(3): | |
| resp = http_requests.post(url, headers=headers, json=payload, timeout=timeout) | |
| if resp.status_code == 503 and attempt < 2: | |
| logger.warning(f"HF chat 503 (model loading), retry {attempt + 1}/3") | |
| time.sleep(3) | |
| continue | |
| if resp.status_code != 200: | |
| raise RuntimeError(f"HF Inference error {resp.status_code}: {resp.text}") | |
| data = resp.json() | |
| # OpenAI-compatible format: {"choices": [{"message": {"content": "..."}}]} | |
| choices = data.get("choices", []) | |
| if choices: | |
| raw = (choices[0].get("message", {}).get("content", "") or "").strip() | |
| return _strip_repetition(raw) | |
| raise RuntimeError(f"Unexpected HF response format: {data}") | |
| raise RuntimeError("HF Inference failed after retries") | |
| # โโโ Math Tutor Prompt & Wrapper โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def build_math_tutor_prompt(question: str) -> str: | |
| """Build a structured math-tutor prompt for the LLM.""" | |
| return f"""SYSTEM: | |
| You are MathPulse Tutor, a precise and patient math tutor for Filipino senior high school STEM students. | |
| Your job is to: | |
| 1) Understand the student's math question (algebra, functions, graphs, trigonometry, analytic geometry, basic calculus, statistics, or word problems). | |
| 2) Solve the problem step by step, explaining each transformation in simple language. | |
| 3) Show all important equations clearly and avoid skipping algebra steps unless obvious to a Grade 11โ12 STEM student. | |
| 4) At the end, restate the final answer explicitly (e.g., "Final answer: x = 3"). | |
| 5) If the question is ambiguous or missing information, ask a short clarifying question first instead of guessing. | |
| 6) If the student makes a mistake, point it out gently, explain why it is wrong, and show the correct method. | |
| 7) Never invent new notation or definitions; use standard high-school math notation only. | |
| 8) When there are multiple possible methods, briefly mention alternatives but pick one main method and follow it consistently. | |
| 9) If the computation is long, summarize intermediate results so the student does not get lost. | |
| 10) If the answer depends on approximations, specify whether the result is exact or rounded (and to how many decimal places). | |
| Speak in clear, concise English. Use short paragraphs and LaTeX-style math when helpful (e.g., x^2 + 3x + 2 = 0). | |
| If the user question is not about math, politely say that you can only help with math-related questions. | |
| USER: | |
| Student question: | |
| {question} | |
| """ | |
| def call_math_tutor_llm(question: str) -> str: | |
| """Convenience wrapper: call the HF serverless model with the MathPulse tutor prompt via chat completions.""" | |
| prompt = build_math_tutor_prompt(question) | |
| messages = [{"role": "user", "content": prompt}] | |
| return call_hf_chat(messages, max_tokens=512, temperature=0.2, top_p=0.9) | |
| # โโโ Request/Response Models โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| class ChatMessage(BaseModel): | |
| role: str = Field(..., description="'user' or 'assistant'") | |
| content: str | |
| class ChatRequest(BaseModel): | |
| message: str | |
| history: List[ChatMessage] = Field(default_factory=list) | |
| userId: Optional[str] = None | |
| verify: bool = Field(default=False, description="Enable self-consistency verification for math answers") | |
| class ChatResponse(BaseModel): | |
| response: str | |
| verified: Optional[bool] = None | |
| confidence: Optional[str] = None | |
| warning: Optional[str] = None | |
| class StudentRiskData(BaseModel): | |
| engagementScore: float = Field(..., ge=0, le=100) | |
| avgQuizScore: float = Field(..., ge=0, le=100) | |
| attendance: float = Field(..., ge=0, le=100) | |
| assignmentCompletion: float = Field(..., ge=0, le=100) | |
| class RiskPrediction(BaseModel): | |
| riskLevel: str | |
| confidence: float | |
| analysis: dict | |
| class BatchRiskRequest(BaseModel): | |
| students: List[StudentRiskData] | |
| class LearningPathRequest(BaseModel): | |
| weaknesses: List[str] | |
| gradeLevel: str | |
| learningStyle: Optional[str] = "visual" | |
| class LearningPathResponse(BaseModel): | |
| learningPath: str | |
| class StudentInsightData(BaseModel): | |
| name: str | |
| engagementScore: float | |
| avgQuizScore: float | |
| attendance: float | |
| riskLevel: str | |
| class DailyInsightRequest(BaseModel): | |
| students: List[StudentInsightData] | |
| class DailyInsightResponse(BaseModel): | |
| insight: str | |
| class VerificationResult(BaseModel): | |
| verified: bool | |
| confidence: str | |
| response: str | |
| warning: Optional[str] = None | |
| class CodeVerificationResult(BaseModel): | |
| verified: bool | |
| code: str | |
| output: str | |
| error: Optional[str] = None | |
| class LLMJudgeResult(BaseModel): | |
| correct: bool | |
| issues: List[str] | |
| confidence: float | |
| class VerifySolutionRequest(BaseModel): | |
| problem: str | |
| solution: str | |
| class VerifySolutionResponse(BaseModel): | |
| overall_verified: bool | |
| aggregated_confidence: float | |
| self_consistency: Optional[VerificationResult] = None | |
| code_verification: Optional[CodeVerificationResult] = None | |
| llm_judge: Optional[LLMJudgeResult] = None | |
| warnings: List[str] = Field(default_factory=list) | |
| # โโโ Routes โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async def health_check(): | |
| return {"status": "healthy", "models": {"chat": CHAT_MODEL, "risk": RISK_MODEL}} | |
| async def root(): | |
| return { | |
| "name": "MathPulse AI API", | |
| "version": "1.0.0", | |
| "docs": "/docs", | |
| "health": "/health", | |
| } | |
| # โโโ AI Chat Tutor โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| MATH_TUTOR_SYSTEM_PROMPT = """You are MathPulse AI, a friendly and concise expert math tutor for students. | |
| Problem-Solving Protocol: | |
| 1. Restate the problem briefly. | |
| 2. Solve step by step, showing each equation clearly. | |
| 3. State the final answer with a label like "**Final Answer: x = 5**". | |
| 4. Verify once at the end by substituting back (do NOT repeat verification steps). | |
| Rules: | |
| - Be concise โ aim for under 200 words. | |
| - Use math notation where helpful (xยฒ, โ, ฯ). | |
| - Never repeat yourself. Once a step is shown, move forward. | |
| - If unsure, say so rather than guessing. | |
| - Encourage the student briefly at the end. | |
| - If the question is not about math, politely say you can only help with math.""" | |
| async def chat_tutor(request: ChatRequest): | |
| """AI Math Tutor powered by HF Serverless Inference (Meta-Llama-3-8B-Instruct)""" | |
| try: | |
| messages = [{"role": "system", "content": MATH_TUTOR_SYSTEM_PROMPT}] | |
| # Add conversation history | |
| for msg in request.history[-10:]: # Keep last 10 messages for context window | |
| messages.append({"role": msg.role, "content": msg.content}) | |
| # Add current message | |
| messages.append({"role": "user", "content": request.message}) | |
| # Call HF serverless with retry (handled inside call_hf_chat) | |
| try: | |
| answer = call_hf_chat(messages, max_tokens=1024, temperature=0.3, top_p=0.9) | |
| except Exception as hf_err: | |
| logger.error(f"HF chat failed: {hf_err}") | |
| raise HTTPException( | |
| status_code=502, | |
| detail="AI model service is temporarily unavailable. Please try again.", | |
| ) | |
| # Optional self-consistency verification | |
| if request.verify: | |
| logger.info("Running self-consistency verification for chat response") | |
| verification = await verify_math_response(request.message, messages) | |
| return ChatResponse( | |
| response=verification["response"], | |
| verified=verification["verified"], | |
| confidence=verification["confidence"], | |
| warning=verification.get("warning"), | |
| ) | |
| return ChatResponse(response=answer) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Chat error: {e}") | |
| raise HTTPException(status_code=500, detail=f"Chat service error: {str(e)}") | |
| # โโโ Verification System โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def _extract_final_answer(text: str) -> Optional[str]: | |
| """Extract the final numeric/symbolic answer from a math response.""" | |
| # Try to find explicitly labeled final answers | |
| patterns = [ | |
| r"\*\*Final Answer[:\s]*(.+?)\*\*", | |
| r"Final Answer[:\s]*(.+?)[\n\.]", | |
| r"(?:the answer is|= )\s*(.+?)[\n\.\s]", | |
| r"\\boxed\{(.+?)\}", | |
| ] | |
| for pat in patterns: | |
| match = re.search(pat, text, re.IGNORECASE) | |
| if match: | |
| return match.group(1).strip().rstrip(".") | |
| # Fallback: last line with an equals sign | |
| for line in reversed(text.strip().splitlines()): | |
| if "=" in line: | |
| parts = line.split("=") | |
| return parts[-1].strip().rstrip(".") | |
| return None | |
| async def verify_math_response( | |
| problem: str, base_messages: List[Dict[str, Any]] | |
| ) -> Dict[str, Any]: | |
| """ | |
| Self-consistency verification: generate multiple responses to the same | |
| math problem and check if the final answers agree. | |
| Returns dict with 'verified' (bool), 'confidence' (str), and 'response'. | |
| """ | |
| responses: List[str] = [] | |
| answers: List[Optional[str]] = [] | |
| logger.info(f"Generating {VERIFICATION_SAMPLES} responses for self-consistency check") | |
| for i in range(VERIFICATION_SAMPLES): | |
| try: | |
| text = call_hf_chat(base_messages, max_tokens=2048, temperature=0.7, top_p=0.9) | |
| responses.append(text) | |
| answers.append(_extract_final_answer(text)) | |
| logger.info(f" Sample {i+1} answer: {answers[-1]}") | |
| except Exception as e: | |
| logger.warning(f" Sample {i+1} failed: {e}") | |
| responses.append("") | |
| answers.append(None) | |
| # Check agreement among non-None answers | |
| valid_answers = [a for a in answers if a is not None] | |
| if not valid_answers: | |
| return { | |
| "verified": False, | |
| "confidence": "low", | |
| "response": responses[0] if responses else "", | |
| "warning": "Could not extract answers for verification.", | |
| } | |
| counter = Counter(valid_answers) | |
| most_common_answer, most_common_count = counter.most_common(1)[0] | |
| agreement_ratio = most_common_count / len(valid_answers) | |
| if agreement_ratio >= 1.0: | |
| confidence = "high" | |
| verified = True | |
| elif agreement_ratio >= 0.6: | |
| confidence = "medium" | |
| verified = True | |
| else: | |
| confidence = "low" | |
| verified = False | |
| # Pick the response whose answer matches the majority | |
| best_response = responses[0] | |
| for resp, ans in zip(responses, answers): | |
| if ans == most_common_answer and resp: | |
| best_response = resp | |
| break | |
| result: Dict[str, Any] = { | |
| "verified": verified, | |
| "confidence": confidence, | |
| "response": best_response, | |
| } | |
| if not verified: | |
| result["warning"] = ( | |
| f"Self-consistency check failed: answers did not converge " | |
| f"({len(set(valid_answers))} distinct answers from {len(valid_answers)} samples). " | |
| f"This answer may be unreliable." | |
| ) | |
| logger.info(f"Self-consistency result: verified={verified}, confidence={confidence}") | |
| return result | |
| async def verify_with_code(problem: str, solution: str) -> Dict[str, Any]: | |
| """ | |
| Ask the model to generate Python verification code for a math solution, | |
| execute it safely, and return the verification result. | |
| """ | |
| prompt = f"""Given this math problem and its proposed solution, write a short Python script that numerically verifies the answer. | |
| **Problem:** {problem} | |
| **Proposed Solution:** {solution} | |
| Rules: | |
| - Use only the Python standard library and the `math` module. | |
| - The script must print EXACTLY one line: either "VERIFIED" if the solution is correct, or "FAILED: <reason>" if it is wrong. | |
| - Do NOT use input(), networking, file I/O, or any system calls. | |
| - Keep the script under 30 lines. | |
| Respond with ONLY the Python code, no markdown fences, no explanation.""" | |
| try: | |
| raw_code = call_hf_chat( | |
| messages=[ | |
| { | |
| "role": "system", | |
| "content": "You are a Python code generator. Output only valid Python code, nothing else.", | |
| }, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| max_tokens=800, | |
| temperature=0.1, | |
| ) | |
| # Strip markdown code fences if present | |
| code = re.sub(r"^```(?:python)?\s*\n?", "", raw_code.strip()) | |
| code = re.sub(r"\n?```\s*$", "", code) | |
| code = code.strip() | |
| if not code: | |
| return {"verified": False, "code": "", "output": "", "error": "No code generated"} | |
| logger.info(f"Executing verification code:\n{code}") | |
| # Execute safely with restricted builtins | |
| import math as _math | |
| safe_globals: Dict[str, Any] = { | |
| "__builtins__": { | |
| "print": print, | |
| "range": range, | |
| "len": len, | |
| "abs": abs, | |
| "round": round, | |
| "int": int, | |
| "float": float, | |
| "str": str, | |
| "sum": sum, | |
| "min": min, | |
| "max": max, | |
| "enumerate": enumerate, | |
| "zip": zip, | |
| "map": map, | |
| "list": list, | |
| "tuple": tuple, | |
| "dict": dict, | |
| "set": set, | |
| "sorted": sorted, | |
| "pow": pow, | |
| "isinstance": isinstance, | |
| "True": True, | |
| "False": False, | |
| "None": None, | |
| }, | |
| "math": _math, | |
| } | |
| # Capture stdout | |
| import contextlib | |
| stdout_capture = io.StringIO() | |
| try: | |
| with contextlib.redirect_stdout(stdout_capture): | |
| exec(code, safe_globals) # noqa: S102 | |
| output = stdout_capture.getvalue().strip() | |
| except Exception as exec_err: | |
| output = "" | |
| return { | |
| "verified": False, | |
| "code": code, | |
| "output": "", | |
| "error": f"Code execution error: {str(exec_err)}", | |
| } | |
| verified = output.upper().startswith("VERIFIED") | |
| logger.info(f"Code verification output: {output}") | |
| return { | |
| "verified": verified, | |
| "code": code, | |
| "output": output, | |
| "error": None, | |
| } | |
| except Exception as e: | |
| logger.error(f"Code verification error: {e}") | |
| return {"verified": False, "code": "", "output": "", "error": str(e)} | |
| async def llm_judge_verification(problem: str, solution: str) -> Dict[str, Any]: | |
| """ | |
| Use a second LLM call with low temperature to judge whether a math | |
| solution is correct. Checks formula usage, calculations, and logic. | |
| Returns dict with 'correct' (bool), 'issues' (list), 'confidence' (float). | |
| """ | |
| prompt = f"""You are a meticulous math verification expert. Your job is to verify whether the following solution to a math problem is mathematically correct. | |
| **Problem:** {problem} | |
| **Solution to verify:** | |
| {solution} | |
| Carefully check: | |
| 1. Are the correct formulas and theorems applied? | |
| 2. Is every arithmetic calculation accurate? | |
| 3. Is the logical reasoning valid at each step? | |
| 4. Is the final answer correct and in the right units? | |
| Respond with ONLY a JSON object (no markdown, no explanation outside the JSON): | |
| {{ | |
| "correct": true or false, | |
| "issues": ["list of specific errors or concerns, empty if correct"], | |
| "confidence": 0.0 to 1.0 | |
| }}""" | |
| try: | |
| raw = call_hf_chat( | |
| messages=[ | |
| { | |
| "role": "system", | |
| "content": "You are a mathematical verification judge. Respond ONLY with valid JSON.", | |
| }, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| max_tokens=500, | |
| temperature=0.1, | |
| ) | |
| # Extract JSON from response | |
| json_start = raw.find("{") | |
| json_end = raw.rfind("}") + 1 | |
| if json_start >= 0 and json_end > json_start: | |
| parsed = json.loads(raw[json_start:json_end]) | |
| else: | |
| logger.warning(f"LLM judge returned non-JSON: {raw[:200]}") | |
| return {"correct": False, "issues": ["Could not parse judge response"], "confidence": 0.0} | |
| judge_result = { | |
| "correct": bool(parsed.get("correct", False)), | |
| "issues": list(parsed.get("issues", [])), | |
| "confidence": float(parsed.get("confidence", 0.0)), | |
| } | |
| logger.info(f"LLM judge result: correct={judge_result['correct']}, confidence={judge_result['confidence']}") | |
| return judge_result | |
| except Exception as e: | |
| logger.error(f"LLM judge error: {e}\n{traceback.format_exc()}") | |
| return {"correct": False, "issues": [f"Judge error: {str(e)}"], "confidence": 0.0} | |
| # โโโ Verification Endpoint โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async def verify_solution(request: VerifySolutionRequest): | |
| """ | |
| Run all 3 verification methods on a problem+solution pair: | |
| 1. Self-consistency (multiple samples) | |
| 2. Code-based verification | |
| 3. LLM judge review | |
| Returns aggregated confidence and per-method results. | |
| """ | |
| try: | |
| logger.info(f"Running full verification for problem: {request.problem[:80]}...") | |
| warnings: List[str] = [] | |
| # Build messages for self-consistency check | |
| messages = [ | |
| {"role": "system", "content": MATH_TUTOR_SYSTEM_PROMPT}, | |
| {"role": "user", "content": request.problem}, | |
| ] | |
| # 1. Self-consistency check | |
| try: | |
| sc_result = await verify_math_response(request.problem, messages) | |
| sc_model = VerificationResult( | |
| verified=sc_result["verified"], | |
| confidence=sc_result["confidence"], | |
| response=sc_result["response"], | |
| warning=sc_result.get("warning"), | |
| ) | |
| if sc_result.get("warning"): | |
| warnings.append(f"Self-consistency: {sc_result['warning']}") | |
| except Exception as e: | |
| logger.error(f"Self-consistency verification failed: {e}") | |
| sc_model = VerificationResult( | |
| verified=False, confidence="low", response="", warning=str(e) | |
| ) | |
| warnings.append(f"Self-consistency check failed: {str(e)}") | |
| # 2. Code verification | |
| try: | |
| cv_result = await verify_with_code(request.problem, request.solution) | |
| cv_model = CodeVerificationResult( | |
| verified=cv_result["verified"], | |
| code=cv_result.get("code", ""), | |
| output=cv_result.get("output", ""), | |
| error=cv_result.get("error"), | |
| ) | |
| if cv_result.get("error"): | |
| warnings.append(f"Code verification: {cv_result['error']}") | |
| except Exception as e: | |
| logger.error(f"Code verification failed: {e}") | |
| cv_model = CodeVerificationResult( | |
| verified=False, code="", output="", error=str(e) | |
| ) | |
| warnings.append(f"Code verification failed: {str(e)}") | |
| # 3. LLM judge | |
| try: | |
| lj_result = await llm_judge_verification(request.problem, request.solution) | |
| lj_model = LLMJudgeResult( | |
| correct=lj_result["correct"], | |
| issues=lj_result["issues"], | |
| confidence=lj_result["confidence"], | |
| ) | |
| if lj_result["issues"]: | |
| warnings.append(f"LLM judge issues: {'; '.join(lj_result['issues'])}") | |
| except Exception as e: | |
| logger.error(f"LLM judge verification failed: {e}") | |
| lj_model = LLMJudgeResult(correct=False, issues=[str(e)], confidence=0.0) | |
| warnings.append(f"LLM judge failed: {str(e)}") | |
| # Aggregate confidence score (0.0 - 1.0) | |
| scores: List[float] = [] | |
| # Self-consistency score | |
| sc_score_map = {"high": 1.0, "medium": 0.6, "low": 0.2} | |
| scores.append(sc_score_map.get(sc_model.confidence, 0.2)) | |
| # Code verification score | |
| scores.append(1.0 if cv_model.verified else 0.0) | |
| # LLM judge score | |
| scores.append(lj_model.confidence if lj_model.correct else (1.0 - lj_model.confidence) * 0.3) | |
| aggregated = round(sum(scores) / len(scores), 3) if scores else 0.0 | |
| overall_verified = aggregated >= 0.6 | |
| logger.info( | |
| f"Verification complete: overall_verified={overall_verified}, " | |
| f"aggregated_confidence={aggregated}" | |
| ) | |
| return VerifySolutionResponse( | |
| overall_verified=overall_verified, | |
| aggregated_confidence=aggregated, | |
| self_consistency=sc_model, | |
| code_verification=cv_model, | |
| llm_judge=lj_model, | |
| warnings=warnings, | |
| ) | |
| except Exception as e: | |
| logger.error(f"Verify solution error: {e}\n{traceback.format_exc()}") | |
| raise HTTPException(status_code=500, detail=f"Verification error: {str(e)}") | |
| # โโโ Student Risk Classification (facebook/bart-large-mnli) โโโ | |
| RISK_LABELS = [ | |
| "high risk of failing", | |
| "medium academic risk", | |
| "low risk academically stable", | |
| ] | |
| RISK_MAPPING = { | |
| "high risk of failing": "High", | |
| "medium academic risk": "Medium", | |
| "low risk academically stable": "Low", | |
| } | |
| async def predict_risk(student_data: StudentRiskData): | |
| """Student risk prediction using facebook/bart-large-mnli zero-shot classification""" | |
| try: | |
| hf = get_client() | |
| text = ( | |
| f"Student academic performance summary: " | |
| f"Engagement score is {student_data.engagementScore:.0f}%. " | |
| f"Average quiz score is {student_data.avgQuizScore:.0f}%. " | |
| f"Attendance rate is {student_data.attendance:.0f}%. " | |
| f"Assignment completion rate is {student_data.assignmentCompletion:.0f}%." | |
| ) | |
| # Retry HF inference with backoff | |
| result = None | |
| last_err: Optional[Exception] = None | |
| for attempt in range(3): | |
| try: | |
| result = hf.zero_shot_classification( | |
| text=text, | |
| candidate_labels=RISK_LABELS, | |
| model=RISK_MODEL, | |
| multi_label=False, | |
| ) | |
| last_err = None | |
| break | |
| except Exception as hf_err: | |
| last_err = hf_err | |
| logger.warning(f"HF risk prediction attempt {attempt + 1} failed: {hf_err}") | |
| if attempt < 2: | |
| await asyncio.sleep(2 ** attempt) | |
| if last_err is not None or result is None: | |
| logger.error(f"HF risk prediction failed after 3 attempts: {last_err}") | |
| raise HTTPException( | |
| status_code=502, | |
| detail="Risk prediction model is temporarily unavailable.", | |
| ) | |
| # result is list[ZeroShotClassificationOutputElement] sorted by score desc | |
| top = result[0] | |
| top_label = top.label | |
| top_score = top.score | |
| risk_level = RISK_MAPPING.get(top_label, "Medium") | |
| return RiskPrediction( | |
| riskLevel=risk_level, | |
| confidence=round(float(top_score), 4), | |
| analysis={ | |
| "labels": [el.label for el in result], | |
| "scores": [round(el.score, 4) for el in result], | |
| }, | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Risk prediction error: {e}") | |
| raise HTTPException(status_code=500, detail=f"Risk prediction error: {str(e)}") | |
| async def predict_risk_batch(request: BatchRiskRequest): | |
| """Batch risk prediction for multiple students""" | |
| results = [] | |
| for student in request.students: | |
| try: | |
| result = await predict_risk(student) | |
| results.append(result) | |
| except Exception: | |
| results.append( | |
| RiskPrediction(riskLevel="Medium", confidence=0.0, analysis={"labels": [], "scores": []}) | |
| ) | |
| return results | |
| # โโโ Learning Path Generation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async def generate_learning_path(request: LearningPathRequest): | |
| """Generate AI-powered personalized learning path""" | |
| try: | |
| prompt = f"""Generate a personalized math learning path for a student with these details: | |
| - Weak Topics: {', '.join(request.weaknesses)} | |
| - Grade Level: {request.gradeLevel} | |
| - Learning Style: {request.learningStyle or 'visual'} | |
| Create a structured learning path with 5-7 specific activities. For each activity provide: | |
| 1. Activity title | |
| 2. Brief description (1-2 sentences) | |
| 3. Estimated duration | |
| 4. Type (video, practice, quiz, reading, interactive) | |
| Format as a numbered list. Be specific to the math topics mentioned.""" | |
| messages = [ | |
| { | |
| "role": "system", | |
| "content": "You are an educational curriculum expert specializing in mathematics. Create clear, actionable learning paths.", | |
| }, | |
| {"role": "user", "content": prompt}, | |
| ] | |
| try: | |
| content = call_hf_chat(messages, max_tokens=1500, temperature=0.7) | |
| except Exception as hf_err: | |
| logger.error(f"HF learning-path failed: {hf_err}") | |
| raise HTTPException( | |
| status_code=502, | |
| detail="Learning path generation is temporarily unavailable.", | |
| ) | |
| return LearningPathResponse(learningPath=content) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Learning path error: {e}") | |
| raise HTTPException(status_code=500, detail=f"Learning path error: {str(e)}") | |
| # โโโ Daily AI Insights โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async def daily_insight(request: DailyInsightRequest): | |
| """Generate daily AI insights for teacher dashboard""" | |
| try: | |
| students = request.students | |
| total = len(students) | |
| if total == 0: | |
| return DailyInsightResponse(insight="No student data available for analysis.") | |
| avg_engagement = sum(s.engagementScore for s in students) / total | |
| avg_quiz = sum(s.avgQuizScore for s in students) / total | |
| avg_attendance = sum(s.attendance for s in students) / total | |
| high_risk = sum(1 for s in students if s.riskLevel == "High") | |
| medium_risk = sum(1 for s in students if s.riskLevel == "Medium") | |
| prompt = f"""Analyze this classroom data and provide actionable insights for a math teacher: | |
| Classroom Summary: | |
| - Total Students: {total} | |
| - Average Engagement: {avg_engagement:.1f}% | |
| - Average Quiz Score: {avg_quiz:.1f}% | |
| - Average Attendance: {avg_attendance:.1f}% | |
| - High-Risk Students: {high_risk} | |
| - Medium-Risk Students: {medium_risk} | |
| - Low-Risk Students: {total - high_risk - medium_risk} | |
| Provide: | |
| 1. A brief overall assessment (2-3 sentences) | |
| 2. 3-4 specific, actionable recommendations for the teacher | |
| 3. One positive observation to highlight | |
| Keep the response under 200 words. Be specific and practical.""" | |
| messages = [ | |
| { | |
| "role": "system", | |
| "content": "You are an educational data analyst providing insights to math teachers. Be specific, actionable, and encouraging.", | |
| }, | |
| {"role": "user", "content": prompt}, | |
| ] | |
| try: | |
| content = call_hf_chat(messages, max_tokens=800, temperature=0.7) | |
| except Exception as hf_err: | |
| logger.error(f"HF daily-insight failed: {hf_err}") | |
| raise HTTPException( | |
| status_code=502, | |
| detail="AI insight generation is temporarily unavailable.", | |
| ) | |
| return DailyInsightResponse(insight=content) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Daily insight error: {e}") | |
| raise HTTPException(status_code=500, detail=f"Daily insight error: {str(e)}") | |
| # โโโ Smart Document Upload โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async def upload_class_records(file: UploadFile = File(...)): | |
| """Upload and parse class records (CSV, Excel, PDF) with AI column detection""" | |
| try: | |
| import pandas as pd # type: ignore[import-not-found] | |
| filename = file.filename or "" | |
| contents = await file.read() | |
| df = None | |
| if filename.endswith(".csv"): | |
| df = pd.read_csv(io.BytesIO(contents)) | |
| elif filename.endswith((".xlsx", ".xls")): | |
| import openpyxl | |
| df = pd.read_excel(io.BytesIO(contents)) | |
| elif filename.endswith(".pdf"): | |
| import pdfplumber | |
| with pdfplumber.open(io.BytesIO(contents)) as pdf: | |
| tables = [] | |
| for page in pdf.pages: | |
| page_tables = page.extract_tables() | |
| if page_tables: | |
| tables.extend(page_tables) | |
| if tables and len(tables[0]) > 1: | |
| df = pd.DataFrame(tables[0][1:], columns=tables[0][0]) | |
| else: | |
| raise HTTPException(status_code=400, detail="No tables found in PDF") | |
| else: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Unsupported file format: {filename}. Use .csv, .xlsx, or .pdf", | |
| ) | |
| if df is None or df.empty: | |
| raise HTTPException(status_code=400, detail="No data found in uploaded file") | |
| # AI-powered column mapping | |
| columns_text = ", ".join(df.columns.tolist()) | |
| prompt = f"""I have a spreadsheet with these columns: {columns_text} | |
| Map each column to one of these standard fields (respond as JSON only): | |
| - name (student full name) | |
| - studentId (student ID number) | |
| - email (email address) | |
| - engagementScore (engagement percentage) | |
| - avgQuizScore (average quiz/test score) | |
| - attendance (attendance percentage) | |
| If a column doesn't match any field, skip it. Respond ONLY with a JSON object mapping original column names to field names. Example: {{"Student Name": "name", "ID": "studentId"}}""" | |
| mapping_text = call_hf_chat( | |
| messages=[{"role": "user", "content": prompt}], | |
| max_tokens=300, | |
| temperature=0.1, | |
| ) | |
| # Extract JSON from response | |
| try: | |
| # Try to find JSON in the response | |
| json_start = mapping_text.find("{") | |
| json_end = mapping_text.rfind("}") + 1 | |
| if json_start >= 0 and json_end > json_start: | |
| column_mapping = json.loads(mapping_text[json_start:json_end]) | |
| else: | |
| column_mapping = {} | |
| except json.JSONDecodeError: | |
| column_mapping = {} | |
| # Apply mapping and extract student data | |
| students = [] | |
| for _, row in df.iterrows(): | |
| student = {} | |
| for orig_col, field in column_mapping.items(): | |
| if orig_col in df.columns: | |
| val = row[orig_col] | |
| student[field] = str(val) if pd.notna(val) else "" | |
| # Ensure numeric fields | |
| for numeric_field in ["engagementScore", "avgQuizScore", "attendance"]: | |
| if numeric_field in student: | |
| try: | |
| student[numeric_field] = float(student[numeric_field].replace("%", "")) | |
| except (ValueError, AttributeError): | |
| student[numeric_field] = 0.0 | |
| if student.get("name"): | |
| students.append(student) | |
| return { | |
| "success": True, | |
| "students": students, | |
| "columnMapping": column_mapping, | |
| "totalRows": len(students), | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Upload error: {e}") | |
| raise HTTPException(status_code=500, detail=f"File upload error: {str(e)}") | |
| # โโโ Quiz Maker Models โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| VALID_QUESTION_TYPES = [ | |
| "identification", | |
| "enumeration", | |
| "multiple_choice", | |
| "word_problem", | |
| "equation_based", | |
| ] | |
| VALID_BLOOM_LEVELS = ["remember", "understand", "apply", "analyze"] | |
| VALID_DIFFICULTY_LEVELS = ["easy", "medium", "hard"] | |
| class QuizGenerationRequest(BaseModel): | |
| topics: List[str] = Field(..., min_length=1, description="Specific math topics to cover") | |
| gradeLevel: str = Field(..., description="Student grade level (e.g., 'Grade 7', 'Grade 10', 'College')") | |
| numQuestions: int = Field(default=10, ge=1, le=50, description="Number of questions to generate") | |
| questionTypes: List[str] = Field( | |
| default=["multiple_choice", "identification", "word_problem"], | |
| description="Types of questions to include", | |
| ) | |
| includeGraphs: bool = Field(default=False, description="Include graph-based identification questions") | |
| difficultyDistribution: Dict[str, int] = Field( | |
| default={"easy": 30, "medium": 50, "hard": 20}, | |
| description="Percentage distribution per difficulty level", | |
| ) | |
| bloomLevels: List[str] = Field( | |
| default=["remember", "understand", "apply", "analyze"], | |
| description="Bloom's Taxonomy cognitive levels", | |
| ) | |
| excludeTopics: List[str] = Field( | |
| default_factory=list, | |
| description="Topics the class is already competent in โ these will be excluded", | |
| ) | |
| def validate_question_types(cls, v: str) -> str: | |
| if v not in VALID_QUESTION_TYPES: | |
| raise ValueError(f"Invalid question type '{v}'. Must be one of: {VALID_QUESTION_TYPES}") | |
| return v | |
| def validate_bloom_levels(cls, v: str) -> str: | |
| if v not in VALID_BLOOM_LEVELS: | |
| raise ValueError(f"Invalid Bloom level '{v}'. Must be one of: {VALID_BLOOM_LEVELS}") | |
| return v | |
| def validate_difficulty_distribution(cls, v: Dict[str, int]) -> Dict[str, int]: | |
| for key in v: | |
| if key not in VALID_DIFFICULTY_LEVELS: | |
| raise ValueError(f"Invalid difficulty key '{key}'. Must be one of: {VALID_DIFFICULTY_LEVELS}") | |
| total = sum(v.values()) | |
| if total != 100: | |
| raise ValueError(f"Difficulty distribution percentages must sum to 100, got {total}") | |
| return v | |
| class QuizQuestion(BaseModel): | |
| questionType: str | |
| question: str | |
| correctAnswer: str | |
| options: Optional[List[str]] = None | |
| bloomLevel: str | |
| difficulty: str | |
| topic: str | |
| points: int | |
| explanation: str | |
| class QuizResponse(BaseModel): | |
| questions: List[QuizQuestion] | |
| totalPoints: int | |
| metadata: Dict[str, Any] | |
| class StudentCompetencyRequest(BaseModel): | |
| studentId: str = Field(..., description="Firebase user ID of the student") | |
| quizHistory: Optional[List[Dict[str, Any]]] = Field( | |
| default_factory=list, | |
| description="Student quiz history โ list of {topic, score, total, timeTaken}", | |
| ) | |
| class TopicCompetency(BaseModel): | |
| topic: str | |
| efficiencyScore: float = Field(..., ge=0, le=100) | |
| competencyLevel: str | |
| perspective: str | |
| class StudentCompetencyResponse(BaseModel): | |
| studentId: str | |
| competencies: List[TopicCompetency] | |
| recommendedTopics: List[str] | |
| excludeTopics: List[str] | |
| class CalculatorRequest(BaseModel): | |
| expression: str = Field(..., min_length=1, max_length=500, description="Mathematical expression to evaluate") | |
| class CalculatorResponse(BaseModel): | |
| expression: str | |
| result: str | |
| steps: List[str] | |
| simplified: Optional[str] = None | |
| latex: Optional[str] = None | |
| # โโโ Quiz Topics Database (SHS Grade 11-12 Only) โโโโโโโโโโโโโ | |
| MATH_TOPICS_BY_GRADE: Dict[str, Dict[str, List[str]]] = { | |
| "Grade 11": { | |
| "General Mathematics - Functions and Their Graphs": [ | |
| "Functions and Relations", "Evaluating Functions", "Operations on Functions", | |
| "Composite Functions", "Inverse Functions", "Rational Functions", | |
| "Exponential Functions", "Logarithmic Functions", | |
| ], | |
| "General Mathematics - Business Mathematics": [ | |
| "Simple Interest", "Compound Interest", "Annuities", | |
| "Loans and Amortization", "Stocks and Bonds", | |
| ], | |
| "General Mathematics - Logic": [ | |
| "Propositions and Connectives", "Truth Tables", | |
| "Logical Equivalence", "Valid Arguments and Fallacies", | |
| ], | |
| "Statistics and Probability - Random Variables": [ | |
| "Random Variables", "Discrete Probability Distributions", | |
| "Mean and Variance of Discrete RV", | |
| ], | |
| "Statistics and Probability - Normal Distribution": [ | |
| "Normal Distribution", "Standard Normal Distribution and Z-scores", | |
| "Areas Under the Normal Curve", | |
| ], | |
| "Statistics and Probability - Sampling and Estimation": [ | |
| "Sampling Distributions", "Central Limit Theorem", | |
| "Point Estimation", "Confidence Intervals", | |
| ], | |
| "Statistics and Probability - Hypothesis Testing": [ | |
| "Hypothesis Testing Concepts", "T-test", "Z-test", | |
| "Correlation and Regression", | |
| ], | |
| }, | |
| "Grade 12": { | |
| "Pre-Calculus - Analytic Geometry": [ | |
| "Conic Sections - Parabola", "Conic Sections - Ellipse", | |
| "Conic Sections - Hyperbola", "Conic Sections - Circle", | |
| "Systems of Nonlinear Equations", | |
| ], | |
| "Pre-Calculus - Series and Induction": [ | |
| "Sequences and Series", "Arithmetic Sequences", "Geometric Sequences", | |
| "Mathematical Induction", "Binomial Theorem", | |
| ], | |
| "Pre-Calculus - Trigonometry": [ | |
| "Angles and Unit Circle", "Trigonometric Functions", | |
| "Trigonometric Identities", "Sum and Difference Formulas", | |
| "Inverse Trigonometric Functions", "Polar Coordinates", | |
| ], | |
| "Basic Calculus - Limits": [ | |
| "Limits of Functions", "Limit Theorems", "One-Sided Limits", | |
| "Infinite Limits and Limits at Infinity", "Continuity of Functions", | |
| ], | |
| "Basic Calculus - Derivatives": [ | |
| "Definition of the Derivative", "Differentiation Rules", "Chain Rule", | |
| "Implicit Differentiation", "Higher-Order Derivatives", "Related Rates", | |
| "Extrema and the First Derivative Test", | |
| "Concavity and the Second Derivative Test", "Optimization Problems", | |
| ], | |
| "Basic Calculus - Integration": [ | |
| "Antiderivatives and Indefinite Integrals", | |
| "Definite Integrals and the FTC", | |
| "Integration by Substitution", "Area Under a Curve", | |
| ], | |
| }, | |
| } | |
| # โโโ Quiz Generation System Prompt โโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| QUIZ_GENERATION_SYSTEM_PROMPT = """You are an expert math quiz generator for MathPulse AI, an educational platform. | |
| PURPOSE: | |
| You are creating supplemental math assessments to support classroom learning, not replace teacher instruction. | |
| BLOOM'S TAXONOMY FRAMEWORK: | |
| Generate questions following Bloom's Taxonomy levels to ensure comprehensive skill evaluation: | |
| - Remember (recall): Recall facts, definitions, or formulas | |
| - Understand (explain): Explain concepts in own words, interpret data | |
| - Apply (use): Use formulas/methods to solve problems in new contexts | |
| - Analyze (examine): Break down complex problems, compare approaches, identify patterns | |
| QUESTION TYPES: | |
| - Identification: Define or identify mathematical concepts, properties, or theorems | |
| - Enumeration: List steps in a process, properties of a shape, or related concepts | |
| - Multiple Choice: Standard multiple-choice with 4 options (one correct) | |
| - Word Problem: Real-world context-based problems relatable to students' experiences | |
| - Equation-Based: Solve equations, manipulate expressions, prove identities | |
| GRAPH QUESTIONS (when requested): | |
| - Use ONLY identification-type questions about graphs | |
| - Ask students to identify key features: intercepts, slopes, vertex locations, asymptotes, domain/range, transformations | |
| - Describe the graph in text (do NOT attempt to render images) | |
| - Format: "Given a graph of [description with key coordinates]... Identify [feature]." | |
| - Note: Graph questions use identification format as graphing is challenging for students | |
| GUIDELINES: | |
| - Make questions context-based and relatable to students' real-world experiences | |
| - Generate clear, unambiguous questions with definitive correct answers | |
| - For each question, provide a detailed step-by-step explanation | |
| - Ensure mathematical accuracy โ verify all answers | |
| - Match difficulty to the specified level (easy, medium, hard) | |
| - Distribute Bloom's Taxonomy levels as evenly as possible across the quiz | |
| RESPONSE FORMAT: | |
| Respond ONLY with a valid JSON array of question objects. No markdown, no explanation outside: | |
| [ | |
| { | |
| "questionType": "multiple_choice", | |
| "question": "...", | |
| "correctAnswer": "...", | |
| "options": ["A) ...", "B) ...", "C) ...", "D) ..."], | |
| "bloomLevel": "apply", | |
| "difficulty": "medium", | |
| "topic": "Linear Equations", | |
| "points": 3, | |
| "explanation": "Step 1: ... Step 2: ... Therefore the answer is ..." | |
| } | |
| ] | |
| Points by difficulty: easy=1, medium=3, hard=5. | |
| For non-multiple-choice questions, omit the "options" field or set to null. | |
| """ | |
| # โโโ Quiz Generation Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def _distribute_questions( | |
| num_questions: int, | |
| difficulty_distribution: Dict[str, int], | |
| bloom_levels: List[str], | |
| question_types: List[str], | |
| ) -> List[Dict[str, str]]: | |
| """ | |
| Pre-compute the distribution of questions by difficulty, Bloom level, | |
| and question type so the LLM prompt can be very specific. | |
| """ | |
| distribution: List[Dict[str, str]] = [] | |
| # Compute counts per difficulty | |
| difficulty_counts: Dict[str, int] = {} | |
| remaining = num_questions | |
| for i, (diff, pct) in enumerate(difficulty_distribution.items()): | |
| if i == len(difficulty_distribution) - 1: | |
| difficulty_counts[diff] = remaining | |
| else: | |
| count = max(1, round(num_questions * pct / 100)) | |
| count = min(count, remaining) | |
| difficulty_counts[diff] = count | |
| remaining -= count | |
| idx = 0 | |
| for diff, count in difficulty_counts.items(): | |
| for j in range(count): | |
| bloom = bloom_levels[idx % len(bloom_levels)] | |
| qtype = question_types[idx % len(question_types)] | |
| distribution.append({ | |
| "difficulty": diff, | |
| "bloomLevel": bloom, | |
| "questionType": qtype, | |
| }) | |
| idx += 1 | |
| return distribution | |
| def _parse_quiz_json(raw: str) -> List[Dict[str, Any]]: | |
| """Robustly extract a JSON array of quiz questions from LLM output.""" | |
| # Try direct parse | |
| cleaned = raw.strip() | |
| # Remove markdown fences | |
| cleaned = re.sub(r"^```(?:json)?\s*\n?", "", cleaned) | |
| cleaned = re.sub(r"\n?```\s*$", "", cleaned) | |
| cleaned = cleaned.strip() | |
| # Try to find array brackets | |
| arr_start = cleaned.find("[") | |
| arr_end = cleaned.rfind("]") + 1 | |
| if arr_start >= 0 and arr_end > arr_start: | |
| try: | |
| return json.loads(cleaned[arr_start:arr_end]) | |
| except json.JSONDecodeError: | |
| pass | |
| # Fallback: try parsing individual objects | |
| objects: List[Dict[str, Any]] = [] | |
| for match in re.finditer(r"\{[^{}]+\}", cleaned, re.DOTALL): | |
| try: | |
| obj = json.loads(match.group()) | |
| if "question" in obj: | |
| objects.append(obj) | |
| except json.JSONDecodeError: | |
| continue | |
| return objects | |
| def _validate_quiz_questions( | |
| questions: List[Dict[str, Any]], | |
| distribution: List[Dict[str, str]], | |
| ) -> List[QuizQuestion]: | |
| """Validate and normalise each question from the LLM response.""" | |
| validated: List[QuizQuestion] = [] | |
| points_map = {"easy": 1, "medium": 3, "hard": 5} | |
| for i, q in enumerate(questions): | |
| dist = distribution[i] if i < len(distribution) else {} | |
| question_type = q.get("questionType", dist.get("questionType", "identification")) | |
| if question_type not in VALID_QUESTION_TYPES: | |
| question_type = "identification" | |
| difficulty = q.get("difficulty", dist.get("difficulty", "medium")) | |
| if difficulty not in VALID_DIFFICULTY_LEVELS: | |
| difficulty = "medium" | |
| bloom_level = q.get("bloomLevel", dist.get("bloomLevel", "understand")) | |
| if bloom_level not in VALID_BLOOM_LEVELS: | |
| bloom_level = "understand" | |
| options = q.get("options") if question_type == "multiple_choice" else None | |
| validated.append(QuizQuestion( | |
| questionType=question_type, | |
| question=q.get("question", ""), | |
| correctAnswer=str(q.get("correctAnswer", "")), | |
| options=options, | |
| bloomLevel=bloom_level, | |
| difficulty=difficulty, | |
| topic=q.get("topic", "General"), | |
| points=q.get("points", points_map.get(difficulty, 3)), | |
| explanation=q.get("explanation", "No explanation provided."), | |
| )) | |
| return validated | |
| # โโโ Quiz Generation Endpoints โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async def generate_quiz(request: QuizGenerationRequest): | |
| """ | |
| Generate an AI-powered quiz via HF Serverless Inference. | |
| Supports Bloom's Taxonomy integration, multiple question types, | |
| and graph-based identification questions. | |
| """ | |
| try: | |
| # Filter out excluded topics | |
| effective_topics = [t for t in request.topics if t not in request.excludeTopics] | |
| if not effective_topics: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="All requested topics are in the exclude list. Please provide at least one topic to cover.", | |
| ) | |
| # Pre-compute question distribution | |
| distribution = _distribute_questions( | |
| request.numQuestions, | |
| request.difficultyDistribution, | |
| request.bloomLevels, | |
| request.questionTypes, | |
| ) | |
| # Build per-question specifications | |
| spec_lines: List[str] = [] | |
| for i, d in enumerate(distribution): | |
| topic = effective_topics[i % len(effective_topics)] | |
| graph_note = "" | |
| if request.includeGraphs and d["questionType"] == "identification": | |
| graph_note = " (GRAPH-BASED: describe a graph and ask the student to identify a feature)" | |
| spec_lines.append( | |
| f"Q{i+1}: type={d['questionType']}, difficulty={d['difficulty']}, " | |
| f"bloom={d['bloomLevel']}, topic={topic}{graph_note}" | |
| ) | |
| graph_instruction = "" | |
| if request.includeGraphs: | |
| graph_instruction = ( | |
| "\n\nGRAPH QUESTIONS: For any identification questions, make them graph-based. " | |
| "Describe the graph verbally (e.g., 'Given a parabola with vertex at (2,3) opening upward...') " | |
| "and ask the student to identify key features such as intercepts, axis of symmetry, " | |
| "slopes, asymptotes, domain, range, or transformations. " | |
| "Do NOT attempt to render an actual image." | |
| ) | |
| prompt = f"""Generate exactly {request.numQuestions} math quiz questions for {request.gradeLevel} students. | |
| Topics to cover: {', '.join(effective_topics)} | |
| Question specifications: | |
| {chr(10).join(spec_lines)} | |
| {graph_instruction} | |
| Remember: | |
| - Points: easy=1, medium=3, hard=5 | |
| - Each question must have a step-by-step explanation | |
| - Multiple choice must have exactly 4 options | |
| - All math must be accurate | |
| - Make problems relatable to students' real-world experiences""" | |
| messages = [ | |
| {"role": "system", "content": QUIZ_GENERATION_SYSTEM_PROMPT}, | |
| {"role": "user", "content": prompt}, | |
| ] | |
| logger.info(f"Generating quiz: {request.numQuestions} questions, topics={effective_topics}") | |
| # Scale max_tokens based on requested questions โ each question needs ~400-600 tokens | |
| max_tokens = min(16384, max(4096, request.numQuestions * 600)) | |
| # Use longer HTTP timeout for quiz generation (scales with question count) | |
| http_timeout = max(90, request.numQuestions * 12) | |
| parsed_questions: List[Dict[str, Any]] = [] | |
| raw_content = "" # Will be set inside the loop | |
| max_attempts = 2 # Retry once if LLM generates too few questions | |
| for attempt in range(max_attempts): | |
| raw_content = call_hf_chat( | |
| messages, max_tokens=max_tokens, temperature=0.3, top_p=0.9, | |
| timeout=http_timeout, | |
| ) | |
| logger.info(f"Raw quiz response length: {len(raw_content)} chars (attempt {attempt + 1})") | |
| parsed_questions = _parse_quiz_json(raw_content) | |
| if not parsed_questions: | |
| logger.error(f"Failed to parse quiz JSON (attempt {attempt + 1}). Raw content:\n{raw_content[:500]}") | |
| if attempt < max_attempts - 1: | |
| logger.info("Retrying quiz generation...") | |
| continue | |
| raise HTTPException( | |
| status_code=500, | |
| detail="Failed to parse quiz questions from AI response. Please try again.", | |
| ) | |
| # If we got at least 70% of requested questions, accept the result | |
| if len(parsed_questions) >= request.numQuestions * 0.7: | |
| break | |
| # Otherwise retry with a stronger nudge | |
| if attempt < max_attempts - 1: | |
| logger.warning( | |
| f"LLM generated only {len(parsed_questions)}/{request.numQuestions} questions " | |
| f"(attempt {attempt + 1}). Retrying with reinforced prompt..." | |
| ) | |
| # Add an assistant + user turn to push the LLM harder | |
| messages = [ | |
| {"role": "system", "content": QUIZ_GENERATION_SYSTEM_PROMPT}, | |
| {"role": "user", "content": prompt}, | |
| {"role": "assistant", "content": raw_content}, | |
| { | |
| "role": "user", | |
| "content": ( | |
| f"You only generated {len(parsed_questions)} questions but I need " | |
| f"exactly {request.numQuestions}. Please generate ALL " | |
| f"{request.numQuestions} questions in a single JSON array. " | |
| f"Do not stop early." | |
| ), | |
| }, | |
| ] | |
| # Warn if the LLM still generated fewer questions than requested | |
| if len(parsed_questions) < request.numQuestions: | |
| logger.warning( | |
| f"LLM generated {len(parsed_questions)}/{request.numQuestions} questions " | |
| f"after {max_attempts} attempts (raw length={len(raw_content)} chars)." | |
| ) | |
| validated = _validate_quiz_questions(parsed_questions, distribution) | |
| total_points = sum(q.points for q in validated) | |
| # Build metadata | |
| topic_counts: Dict[str, int] = {} | |
| difficulty_counts: Dict[str, int] = {} | |
| bloom_counts: Dict[str, int] = {} | |
| for q in validated: | |
| topic_counts[q.topic] = topic_counts.get(q.topic, 0) + 1 | |
| difficulty_counts[q.difficulty] = difficulty_counts.get(q.difficulty, 0) + 1 | |
| bloom_counts[q.bloomLevel] = bloom_counts.get(q.bloomLevel, 0) + 1 | |
| metadata: Dict[str, Any] = { | |
| "topicsCovered": topic_counts, | |
| "difficultyBreakdown": difficulty_counts, | |
| "bloomTaxonomyDistribution": bloom_counts, | |
| "questionTypeBreakdown": dict(Counter(q.questionType for q in validated)), | |
| "gradeLevel": request.gradeLevel, | |
| "totalQuestions": len(validated), | |
| "includesGraphQuestions": request.includeGraphs, | |
| "supplementalPurpose": ( | |
| "This quiz is designed to supplement classroom instruction, " | |
| "not replace teacher-led learning." | |
| ), | |
| "bloomTaxonomyRationale": ( | |
| "Ensures questions assess different cognitive levels from basic recall " | |
| "to complex analysis, providing comprehensive skill evaluation." | |
| ), | |
| "recommendedTeacherActions": [ | |
| "Review questions before assigning to students", | |
| "Use difficulty breakdown to identify areas needing re-teaching", | |
| "Focus on topics where students score below 60%", | |
| "Use Bloom analysis to ensure higher-order thinking is practiced", | |
| ], | |
| } | |
| if request.includeGraphs: | |
| metadata["graphQuestionNote"] = ( | |
| "Graph questions use identification format as graphing is " | |
| "challenging for students. Graphs are described in text." | |
| ) | |
| logger.info(f"Quiz generated: {len(validated)} questions, {total_points} total points") | |
| return QuizResponse( | |
| questions=validated, | |
| totalPoints=total_points, | |
| metadata=metadata, | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Quiz generation error: {e}\n{traceback.format_exc()}") | |
| raise HTTPException(status_code=500, detail=f"Quiz generation error: {str(e)}") | |
| async def preview_quiz(request: QuizGenerationRequest): | |
| """ | |
| Generate a 3-question preview quiz for teachers to verify AI question | |
| quality before assigning a full quiz to students. | |
| """ | |
| # Override to produce only 3 questions | |
| request.numQuestions = 3 | |
| return await generate_quiz(request) | |
| async def get_quiz_topics(gradeLevel: Optional[str] = None): | |
| """ | |
| Return structured list of SHS math topics organised by grade level. | |
| Only Grade 11 and Grade 12 are supported. | |
| If gradeLevel is provided, return topics for that grade only. | |
| """ | |
| if gradeLevel: | |
| key = gradeLevel.strip() | |
| # Try exact match first | |
| if key in MATH_TOPICS_BY_GRADE: | |
| return {"gradeLevel": key, "topics": MATH_TOPICS_BY_GRADE[key]} | |
| # Case-insensitive match | |
| for k, v in MATH_TOPICS_BY_GRADE.items(): | |
| if k.lower() == key.lower(): | |
| return {"gradeLevel": k, "topics": v} | |
| raise HTTPException( | |
| status_code=404, | |
| detail=f"Grade level '{gradeLevel}' not found. Available: {list(MATH_TOPICS_BY_GRADE.keys())}", | |
| ) | |
| # Return all SHS topics organized by grade | |
| return { | |
| "gradeLevels": list(MATH_TOPICS_BY_GRADE.keys()), | |
| "allTopics": MATH_TOPICS_BY_GRADE, | |
| } | |
| # โโโ Student Competency Assessment โโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async def student_competency(request: StudentCompetencyRequest): | |
| """ | |
| Assess a student's competency per topic based on their quiz history. | |
| Returns efficiency scores, competency levels, and recommendations. | |
| """ | |
| try: | |
| history = request.quizHistory or [] | |
| if not history: | |
| # No history โ return empty competency with recommendation to start | |
| return StudentCompetencyResponse( | |
| studentId=request.studentId, | |
| competencies=[], | |
| recommendedTopics=["Start with foundational topics to build a learning profile"], | |
| excludeTopics=[], | |
| ) | |
| # Aggregate scores per topic | |
| topic_data: Dict[str, List[Dict[str, Any]]] = {} | |
| for entry in history: | |
| topic = entry.get("topic", "Unknown") | |
| if topic not in topic_data: | |
| topic_data[topic] = [] | |
| topic_data[topic].append(entry) | |
| # Compute competency per topic | |
| competencies: List[TopicCompetency] = [] | |
| recommended: List[str] = [] | |
| exclude: List[str] = [] | |
| for topic, entries in topic_data.items(): | |
| scores = [e.get("score", 0) / max(e.get("total", 1), 1) * 100 for e in entries] | |
| avg_score = sum(scores) / len(scores) if scores else 0 | |
| # Factor in time efficiency (faster with correct answers = more efficient) | |
| time_factors = [] | |
| for e in entries: | |
| if e.get("timeTaken") and e.get("total"): | |
| time_per_q = e["timeTaken"] / e["total"] | |
| # Normalise: < 30s per question = efficient, > 120s = slow | |
| efficiency = max(0, min(100, 100 - (time_per_q - 30) * (100 / 90))) | |
| time_factors.append(efficiency) | |
| time_efficiency = sum(time_factors) / len(time_factors) if time_factors else 50 | |
| efficiency_score = round(avg_score * 0.7 + time_efficiency * 0.3, 1) | |
| if efficiency_score >= 85: | |
| level = "advanced" | |
| perspective = f"Student demonstrates strong mastery of {topic}. Consistently scores well with efficient problem-solving." | |
| exclude.append(topic) | |
| elif efficiency_score >= 65: | |
| level = "proficient" | |
| perspective = f"Student has solid understanding of {topic} but may benefit from challenging practice problems." | |
| elif efficiency_score >= 40: | |
| level = "developing" | |
| perspective = f"Student shows foundational knowledge of {topic} but needs more practice to build fluency." | |
| recommended.append(topic) | |
| else: | |
| level = "beginner" | |
| perspective = f"Student is still building understanding of {topic}. Recommend focused review and guided practice." | |
| recommended.insert(0, topic) # High-priority | |
| competencies.append(TopicCompetency( | |
| topic=topic, | |
| efficiencyScore=efficiency_score, | |
| competencyLevel=level, | |
| perspective=perspective, | |
| )) | |
| # If the AI is available, enhance perspectives | |
| if competencies: | |
| try: | |
| summary = ", ".join( | |
| f"{c.topic}: {c.competencyLevel} ({c.efficiencyScore}%)" | |
| for c in competencies | |
| ) | |
| ai_prompt = f"""Based on this student competency profile, provide a brief (2-3 sentence) overall assessment: | |
| {summary} | |
| Focus on actionable recommendations. Be encouraging yet honest.""" | |
| overall_perspective = call_hf_chat( | |
| messages=[ | |
| {"role": "system", "content": "You are an educational assessment expert. Be concise and supportive."}, | |
| {"role": "user", "content": ai_prompt}, | |
| ], | |
| max_tokens=200, | |
| temperature=0.3, | |
| ) | |
| if overall_perspective: | |
| # Add to recommended as a note | |
| recommended.append(f"AI Insight: {overall_perspective.strip()}") | |
| except Exception as e: | |
| logger.warning(f"AI competency enhancement failed: {e}") | |
| competencies.sort(key=lambda c: c.efficiencyScore) | |
| return StudentCompetencyResponse( | |
| studentId=request.studentId, | |
| competencies=competencies, | |
| recommendedTopics=recommended, | |
| excludeTopics=exclude, | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Student competency error: {e}\n{traceback.format_exc()}") | |
| raise HTTPException(status_code=500, detail=f"Competency assessment error: {str(e)}") | |
| # โโโ Calculator / Symbolic Math โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # Allowed names for safe expression evaluation via SymPy | |
| _SAFE_SYMPY_NAMES: Optional[Dict[str, Any]] = None | |
| def _get_sympy_safe_dict() -> Dict[str, Any]: | |
| """Lazily build allowlist of SymPy names for safe eval.""" | |
| global _SAFE_SYMPY_NAMES | |
| if _SAFE_SYMPY_NAMES is not None: | |
| return _SAFE_SYMPY_NAMES | |
| import sympy # type: ignore[import-untyped] | |
| _SAFE_SYMPY_NAMES = { | |
| # Symbols | |
| "x": sympy.Symbol("x"), | |
| "y": sympy.Symbol("y"), | |
| "z": sympy.Symbol("z"), | |
| "t": sympy.Symbol("t"), | |
| "n": sympy.Symbol("n"), | |
| # Constants | |
| "pi": sympy.pi, | |
| "e": sympy.E, | |
| "E": sympy.E, | |
| "I": sympy.I, | |
| "oo": sympy.oo, | |
| "inf": sympy.oo, | |
| # Functions | |
| "sin": sympy.sin, | |
| "cos": sympy.cos, | |
| "tan": sympy.tan, | |
| "asin": sympy.asin, | |
| "acos": sympy.acos, | |
| "atan": sympy.atan, | |
| "sinh": sympy.sinh, | |
| "cosh": sympy.cosh, | |
| "tanh": sympy.tanh, | |
| "log": sympy.log, | |
| "ln": sympy.log, | |
| "exp": sympy.exp, | |
| "sqrt": sympy.sqrt, | |
| "Abs": sympy.Abs, | |
| "abs": sympy.Abs, | |
| "factorial": sympy.factorial, | |
| "binomial": sympy.binomial, | |
| "ceiling": sympy.ceiling, | |
| "floor": sympy.floor, | |
| # Operations | |
| "diff": sympy.diff, | |
| "integrate": sympy.integrate, | |
| "limit": sympy.limit, | |
| "solve": sympy.solve, | |
| "simplify": sympy.simplify, | |
| "expand": sympy.expand, | |
| "factor": sympy.factor, | |
| "Rational": sympy.Rational, | |
| "Matrix": sympy.Matrix, | |
| "Sum": sympy.Sum, | |
| "Product": sympy.Product, | |
| "Derivative": sympy.Derivative, | |
| "Integral": sympy.Integral, | |
| "Limit": sympy.Limit, | |
| } | |
| return _SAFE_SYMPY_NAMES | |
| _DANGEROUS_PATTERNS = re.compile( | |
| r"(__\w+__|import\s|exec\s*\(|eval\s*\(|open\s*\(|os\.|sys\.|subprocess|shutil|__builtins__|globals|locals|compile|getattr|setattr|delattr)", | |
| re.IGNORECASE, | |
| ) | |
| async def calculator_evaluate(request: CalculatorRequest): | |
| """ | |
| Evaluate a mathematical expression symbolically using SymPy. | |
| Supports arithmetic, algebra, trigonometry, and calculus. | |
| """ | |
| try: | |
| import sympy # type: ignore[import-untyped] | |
| expr_str = request.expression.strip() | |
| # Safety validation | |
| if _DANGEROUS_PATTERNS.search(expr_str): | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Expression contains disallowed patterns. Only mathematical expressions are permitted.", | |
| ) | |
| if len(expr_str) > 500: | |
| raise HTTPException(status_code=400, detail="Expression too long (max 500 characters).") | |
| safe_dict = _get_sympy_safe_dict() | |
| steps: List[str] = [f"Input expression: {expr_str}"] | |
| # Parse expression | |
| try: | |
| parsed = sympy.sympify(expr_str, locals=safe_dict) | |
| steps.append(f"Parsed as: {parsed}") | |
| except Exception as parse_err: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Could not parse expression: {str(parse_err)}", | |
| ) | |
| # Simplify | |
| simplified = sympy.simplify(parsed) | |
| if simplified != parsed: | |
| steps.append(f"Simplified: {simplified}") | |
| # Try numeric evaluation | |
| try: | |
| numeric = float(simplified.evalf()) | |
| if numeric == int(numeric): | |
| result_str = str(int(numeric)) | |
| else: | |
| result_str = str(round(numeric, 10)) | |
| steps.append(f"Numerical result: {result_str}") | |
| except Exception: | |
| result_str = str(simplified) | |
| steps.append(f"Symbolic result: {result_str}") | |
| # LaTeX representation | |
| try: | |
| latex_str = sympy.latex(simplified) | |
| except Exception: | |
| latex_str = None | |
| return CalculatorResponse( | |
| expression=expr_str, | |
| result=result_str, | |
| steps=steps, | |
| simplified=str(simplified) if simplified != parsed else None, | |
| latex=latex_str, | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Calculator error: {e}\n{traceback.format_exc()}") | |
| raise HTTPException(status_code=500, detail=f"Calculator error: {str(e)}") | |
| # โโโ ML-Powered Student Analytics Endpoints โโโโโโโโโโโโโโโโโโ | |
| async def student_competency_analysis(request: CompetencyAnalysisRequest): | |
| """ | |
| Analyse student competency per topic using IRT (Item Response Theory). | |
| Calculates efficiency scores, mastery percentages, learning velocity, | |
| and theta (ability) estimates. | |
| """ | |
| try: | |
| logger.info(f"Competency analysis requested for student {request.studentId}") | |
| # Fetch quiz history from Firestore | |
| quiz_history = await fetch_student_quiz_history(request.studentId) | |
| result = await compute_competency_analysis( | |
| student_id=request.studentId, | |
| quiz_history=quiz_history, | |
| topic_filter=request.topicId, | |
| ) | |
| # Store results if successful | |
| if result.status == "success": | |
| await store_competency_analysis( | |
| request.studentId, | |
| { | |
| "analyses": [a.dict() for a in result.analyses], | |
| "overallCompetency": result.overallCompetency, | |
| "thetaEstimate": result.thetaEstimate, | |
| }, | |
| ) | |
| return result | |
| except Exception as e: | |
| logger.error(f"Competency analysis error: {e}\n{traceback.format_exc()}") | |
| raise HTTPException(status_code=500, detail=f"Competency analysis error: {str(e)}") | |
| async def train_risk_classification_model(request: RiskTrainRequest): | |
| """ | |
| Train a supervised ML model (XGBoost/Random Forest) for student risk prediction. | |
| Admin-only endpoint. Collects historical data from Firestore, trains the model, | |
| and saves it to disk. | |
| """ | |
| try: | |
| logger.info(f"Risk model training requested (forceRetrain={request.forceRetrain})") | |
| result = await train_risk_model(force_retrain=request.forceRetrain) | |
| return result | |
| except Exception as e: | |
| logger.error(f"Risk model training error: {e}\n{traceback.format_exc()}") | |
| raise HTTPException(status_code=500, detail=f"Model training error: {str(e)}") | |
| async def predict_risk_ml(data: EnhancedRiskRequest): | |
| """ | |
| Enhanced student risk prediction using trained ML model with SHAP explanations. | |
| Falls back to rule-based heuristics if no trained model is available. | |
| Returns risk probabilities for all classes and top contributing factors. | |
| """ | |
| try: | |
| logger.info(f"Enhanced risk prediction for student {data.studentId}") | |
| result = await predict_risk_enhanced(data) | |
| return result | |
| except Exception as e: | |
| logger.error(f"Enhanced risk prediction error: {e}\n{traceback.format_exc()}") | |
| raise HTTPException(status_code=500, detail=f"Risk prediction error: {str(e)}") | |
| async def calibrate_quiz_difficulty(request: CalibrateDifficultyRequest): | |
| """ | |
| Calculate IRT difficulty parameters for a question based on student responses. | |
| Uses 3-Parameter Logistic model to estimate difficulty (b), discrimination (a), | |
| and guessing (c) parameters. | |
| """ | |
| try: | |
| logger.info(f"Calibrating difficulty for question {request.questionId}") | |
| result = await calibrate_question_difficulty(request) | |
| return result | |
| except ValueError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| except Exception as e: | |
| logger.error(f"Difficulty calibration error: {e}\n{traceback.format_exc()}") | |
| raise HTTPException(status_code=500, detail=f"Calibration error: {str(e)}") | |
| async def adaptive_quiz_selection(request: AdaptiveQuizSelectRequest): | |
| """ | |
| Select questions adaptively based on student ability level using IRT. | |
| Adjusts difficulty distribution to target ~70-75% success rate. | |
| Uses student competency data to personalize quiz difficulty. | |
| """ | |
| try: | |
| logger.info(f"Adaptive quiz selection for student {request.studentId}, topic {request.topicId}") | |
| result = await select_adaptive_quiz(request) | |
| return result | |
| except Exception as e: | |
| logger.error(f"Adaptive quiz selection error: {e}\n{traceback.format_exc()}") | |
| raise HTTPException(status_code=500, detail=f"Adaptive selection error: {str(e)}") | |
| async def recommend_learning_topics(request: TopicRecommendationRequest): | |
| """ | |
| Recommend topics for a student based on competency gaps, prerequisites, | |
| recency of practice, and peer performance patterns. | |
| Returns ranked list with reasoning and estimated time to mastery. | |
| """ | |
| try: | |
| logger.info(f"Topic recommendation for student {request.studentId}") | |
| result = await recommend_topics(request) | |
| return result | |
| except Exception as e: | |
| logger.error(f"Topic recommendation error: {e}\n{traceback.format_exc()}") | |
| raise HTTPException(status_code=500, detail=f"Recommendation error: {str(e)}") | |
| async def student_analytics_summary(studentId: str = Query(..., description="Firebase user ID")): | |
| """ | |
| Aggregate all ML-powered metrics for a student: | |
| competency distribution, risk assessment, recommendations, | |
| learning velocity trends, efficiency scores, predicted performance, | |
| and engagement pattern analysis. | |
| """ | |
| try: | |
| logger.info(f"Student summary requested for {studentId}") | |
| result = await get_student_summary(studentId) | |
| return result | |
| except Exception as e: | |
| logger.error(f"Student summary error: {e}\n{traceback.format_exc()}") | |
| raise HTTPException(status_code=500, detail=f"Analytics error: {str(e)}") | |
| async def class_analytics_insights(request: ClassInsightsRequest): | |
| """ | |
| Aggregate class-wide ML analytics for teacher dashboards. | |
| Includes risk distribution, common weak topics, learning velocity, | |
| engagement patterns, and intervention recommendations. | |
| """ | |
| try: | |
| logger.info(f"Class insights requested by teacher {request.teacherId}") | |
| result = await get_class_insights(request) | |
| return result | |
| except Exception as e: | |
| logger.error(f"Class insights error: {e}\n{traceback.format_exc()}") | |
| raise HTTPException(status_code=500, detail=f"Class insights error: {str(e)}") | |
| async def refresh_analytics_cache(): | |
| """ | |
| Force clear and refresh all ML analytics caches. | |
| Use when student data has been updated and fresh analysis is needed. | |
| """ | |
| try: | |
| result = refresh_all_caches() | |
| logger.info("Analytics caches refreshed") | |
| return result | |
| except Exception as e: | |
| logger.error(f"Cache refresh error: {e}") | |
| raise HTTPException(status_code=500, detail=f"Cache refresh error: {str(e)}") | |
| async def generate_mock_data(request: MockDataRequest): | |
| """ | |
| Generate realistic mock student data for testing ML features. | |
| Development/testing endpoint only. | |
| Generates students with varied archetypes: perfect, struggling, | |
| inconsistent, improving, declining, and average performers. | |
| """ | |
| try: | |
| logger.info(f"Generating mock data: {request.numStudents} students, {request.numQuizzes} quizzes") | |
| data = generate_mock_student_data( | |
| num_students=request.numStudents, | |
| num_quizzes=request.numQuizzes, | |
| seed=request.seed, | |
| ) | |
| return data | |
| except Exception as e: | |
| logger.error(f"Mock data generation error: {e}") | |
| raise HTTPException(status_code=500, detail=f"Mock data error: {str(e)}") | |
| async def get_analytics_config(): | |
| """Return current ML analytics configuration parameters.""" | |
| return { | |
| "riskModelPath": RISK_MODEL_PATH, | |
| "riskModelExists": os.path.exists(RISK_MODEL_PATH), | |
| "competencyThresholds": COMPETENCY_THRESHOLDS, | |
| "minQuizAttemptsForCompetency": MIN_QUIZ_ATTEMPTS_FOR_COMPETENCY, | |
| "cacheTTLSeconds": 3600, | |
| "topicPrerequisites": fetch_topic_dependencies(), | |
| } | |
| # โโโ Topic Mastery Analytics โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # SHS topic data for fallback/mock generation | |
| _SHS_TOPICS = { | |
| "gen-math": { | |
| "name": "General Mathematics", | |
| "topics": [ | |
| ("Functions and Relations", "Functions and Their Graphs"), | |
| ("Evaluating Functions", "Functions and Their Graphs"), | |
| ("Operations on Functions", "Functions and Their Graphs"), | |
| ("Composite Functions", "Functions and Their Graphs"), | |
| ("Inverse Functions", "Functions and Their Graphs"), | |
| ("Rational Functions", "Functions and Their Graphs"), | |
| ("Exponential Functions", "Functions and Their Graphs"), | |
| ("Logarithmic Functions", "Functions and Their Graphs"), | |
| ("Simple Interest", "Business Mathematics"), | |
| ("Compound Interest", "Business Mathematics"), | |
| ("Annuities", "Business Mathematics"), | |
| ("Loans and Amortization", "Business Mathematics"), | |
| ("Stocks and Bonds", "Business Mathematics"), | |
| ("Propositions and Connectives", "Logic"), | |
| ("Truth Tables", "Logic"), | |
| ("Logical Equivalence", "Logic"), | |
| ("Valid Arguments and Fallacies", "Logic"), | |
| ], | |
| }, | |
| "stats-prob": { | |
| "name": "Statistics and Probability", | |
| "topics": [ | |
| ("Random Variables", "Random Variables"), | |
| ("Discrete Probability Distributions", "Random Variables"), | |
| ("Mean and Variance of Discrete RV", "Random Variables"), | |
| ("Normal Distribution", "Normal Distribution"), | |
| ("Standard Normal Distribution and Z-scores", "Normal Distribution"), | |
| ("Areas Under the Normal Curve", "Normal Distribution"), | |
| ("Sampling Distributions", "Sampling and Estimation"), | |
| ("Central Limit Theorem", "Sampling and Estimation"), | |
| ("Point Estimation", "Sampling and Estimation"), | |
| ("Confidence Intervals", "Sampling and Estimation"), | |
| ("Hypothesis Testing Concepts", "Hypothesis Testing"), | |
| ("T-test", "Hypothesis Testing"), | |
| ("Z-test", "Hypothesis Testing"), | |
| ("Correlation and Regression", "Correlation and Regression"), | |
| ], | |
| }, | |
| "pre-calc": { | |
| "name": "Pre-Calculus", | |
| "topics": [ | |
| ("Conic Sections - Parabola", "Analytic Geometry"), | |
| ("Conic Sections - Ellipse", "Analytic Geometry"), | |
| ("Conic Sections - Hyperbola", "Analytic Geometry"), | |
| ("Conic Sections - Circle", "Analytic Geometry"), | |
| ("Systems of Nonlinear Equations", "Analytic Geometry"), | |
| ("Sequences and Series", "Series and Induction"), | |
| ("Arithmetic Sequences", "Series and Induction"), | |
| ("Geometric Sequences", "Series and Induction"), | |
| ("Mathematical Induction", "Series and Induction"), | |
| ("Binomial Theorem", "Series and Induction"), | |
| ("Angles and Unit Circle", "Trigonometry"), | |
| ("Trigonometric Functions", "Trigonometry"), | |
| ("Trigonometric Identities", "Trigonometry"), | |
| ("Sum and Difference Formulas", "Trigonometry"), | |
| ("Inverse Trigonometric Functions", "Trigonometry"), | |
| ("Polar Coordinates", "Trigonometry"), | |
| ], | |
| }, | |
| "basic-calc": { | |
| "name": "Basic Calculus", | |
| "topics": [ | |
| ("Limits of Functions", "Limits"), | |
| ("Limit Theorems", "Limits"), | |
| ("One-Sided Limits", "Limits"), | |
| ("Infinite Limits and Limits at Infinity", "Limits"), | |
| ("Continuity of Functions", "Limits"), | |
| ("Definition of the Derivative", "Derivatives"), | |
| ("Differentiation Rules", "Derivatives"), | |
| ("Chain Rule", "Derivatives"), | |
| ("Implicit Differentiation", "Derivatives"), | |
| ("Higher-Order Derivatives", "Derivatives"), | |
| ("Related Rates", "Derivatives"), | |
| ("Extrema and the First Derivative Test", "Derivatives"), | |
| ("Concavity and the Second Derivative Test", "Derivatives"), | |
| ("Optimization Problems", "Derivatives"), | |
| ("Antiderivatives and Indefinite Integrals", "Integration"), | |
| ("Definite Integrals and the FTC", "Integration"), | |
| ("Integration by Substitution", "Integration"), | |
| ("Area Under a Curve", "Integration"), | |
| ], | |
| }, | |
| } | |
| async def topic_mastery_analytics( | |
| teacherId: str = Query(..., description="Teacher UID"), | |
| classId: Optional[str] = Query(None, description="Optional class ID filter"), | |
| ): | |
| """ | |
| Aggregate per-topic mastery statistics for a teacher's class. | |
| Returns topic-level averages, attempt counts, and mastery status. | |
| """ | |
| import random | |
| try: | |
| # In production, this would query Firestore quiz submissions. | |
| # For now, generate realistic mock data seeded by teacherId. | |
| rng = random.Random(hash(teacherId)) | |
| total_students = 30 | |
| topics_out = [] | |
| mastered_count = 0 | |
| needs_attention_count = 0 | |
| excluded_count = 0 | |
| for subj_id, subj_data in _SHS_TOPICS.items(): | |
| for topic_name, unit in subj_data["topics"]: | |
| attempted = rng.randint(3, total_students) | |
| class_avg = round(rng.uniform(35, 95), 1) | |
| above_85 = int(attempted * (rng.uniform(0.6, 0.95) if class_avg >= 85 else rng.uniform(0.05, 0.35))) | |
| mastery_pct = round((above_85 / total_students) * 100, 1) | |
| if attempted < 3: | |
| status = "no_data" | |
| elif mastery_pct >= 75: | |
| status = "mastered" | |
| mastered_count += 1 | |
| elif class_avg >= 60: | |
| status = "on_track" | |
| else: | |
| status = "needs_attention" | |
| needs_attention_count += 1 | |
| topics_out.append({ | |
| "topicName": topic_name, | |
| "subjectId": subj_id, | |
| "unit": unit, | |
| "classAverage": class_avg, | |
| "studentsAttempted": attempted, | |
| "totalStudents": total_students, | |
| "studentsAbove85": above_85, | |
| "masteryPercentage": mastery_pct, | |
| "masteryStatus": status, | |
| "isExcluded": False, | |
| }) | |
| return { | |
| "topics": topics_out, | |
| "summary": { | |
| "totalTopicsTracked": len(topics_out), | |
| "masteredCount": mastered_count, | |
| "needsAttentionCount": needs_attention_count, | |
| "excludedCount": excluded_count, | |
| }, | |
| } | |
| except Exception as e: | |
| logger.error(f"Topic mastery analytics error: {e}") | |
| raise HTTPException(status_code=500, detail=f"Topic mastery error: {str(e)}") | |
| # โโโ Automation Engine Endpoints โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async def automation_diagnostic_completed(payload: DiagnosticCompletionPayload): | |
| """ | |
| Trigger automation pipeline after a student completes the diagnostic. | |
| Classifies risk per subject, generates learning path, creates | |
| remedial quizzes, and produces teacher intervention recommendations. | |
| """ | |
| try: | |
| logger.info(f"Automation trigger: diagnostic_completed for {payload.studentId}") | |
| result = await automation_engine.handle_diagnostic_completion(payload) | |
| return result | |
| except Exception as e: | |
| logger.error(f"Automation diagnostic error: {e}\n{traceback.format_exc()}") | |
| raise HTTPException(status_code=500, detail=f"Automation error: {str(e)}") | |
| async def automation_quiz_submitted(payload: QuizSubmissionPayload): | |
| """ | |
| Trigger automation after any quiz / assessment submission. | |
| Recalculates risk for the subject and determines status changes. | |
| """ | |
| try: | |
| logger.info(f"Automation trigger: quiz_submitted by {payload.studentId}") | |
| result = await automation_engine.handle_quiz_submission(payload) | |
| return result | |
| except Exception as e: | |
| logger.error(f"Automation quiz error: {e}\n{traceback.format_exc()}") | |
| raise HTTPException(status_code=500, detail=f"Automation error: {str(e)}") | |
| async def automation_student_enrolled(payload: StudentEnrollmentPayload): | |
| """ | |
| Trigger automation when a new student account is created. | |
| Initialises progress tracking, gamification, and flags diagnostic as pending. | |
| """ | |
| try: | |
| logger.info(f"Automation trigger: student_enrolled for {payload.studentId}") | |
| result = await automation_engine.handle_student_enrollment(payload) | |
| return result | |
| except Exception as e: | |
| logger.error(f"Automation enrollment error: {e}\n{traceback.format_exc()}") | |
| raise HTTPException(status_code=500, detail=f"Automation error: {str(e)}") | |
| async def automation_data_imported(payload: DataImportPayload): | |
| """ | |
| Trigger automation after a teacher uploads external data. | |
| Recalculates risk for all affected students and flags status changes. | |
| """ | |
| try: | |
| logger.info(f"Automation trigger: data_imported by teacher {payload.teacherId}") | |
| result = await automation_engine.handle_data_import(payload) | |
| return result | |
| except Exception as e: | |
| logger.error(f"Automation import error: {e}\n{traceback.format_exc()}") | |
| raise HTTPException(status_code=500, detail=f"Automation error: {str(e)}") | |
| async def automation_content_updated(payload: ContentUpdatePayload): | |
| """ | |
| Trigger automation after admin CRUD on curriculum content. | |
| Logs the change and notifies affected teachers. | |
| """ | |
| try: | |
| logger.info(f"Automation trigger: content_updated by admin {payload.adminId}") | |
| result = await automation_engine.handle_content_update(payload) | |
| return result | |
| except Exception as e: | |
| logger.error(f"Automation content error: {e}\n{traceback.format_exc()}") | |
| raise HTTPException(status_code=500, detail=f"Automation error: {str(e)}") | |
| # โโโ Main โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| if __name__ == "__main__": | |
| port = int(os.environ.get("PORT", 7860)) | |
| uvicorn.run("main:app", host="0.0.0.0", port=port, reload=False) | |