FaizanMirZa77/FormatX
0
1"""2test_service.py3───────────────4Test generation and evaluation service.5 6- MCQ evaluation: exact match on A/B/C/D7- Short answer: sentence-transformers (all-MiniLM-L6-v2) semantic similarity8 + keyword overlap for a combined score9- Grading: A/B/C/D/F based on percentage10- Model loading: lazy singleton — loaded once on first use, cached in memory11"""12 13import re14import logging15from functools import lru_cache16 17log = logging.getLogger("formatx.test")18 19# ── Sentence-transformer model (lazy load) ────────────────────────────────────20 21_model = None22 23def _get_model():24 global _model25 if _model is None:26 log.info("[TEST] Loading sentence-transformers model (first use)...")27 from sentence_transformers import SentenceTransformer28 _model = SentenceTransformer("all-MiniLM-L6-v2")29 log.info("[TEST] Model loaded.")30 return _model31 32 33# ── MCQ evaluation ────────────────────────────────────────────────────────────34 35def evaluate_mcq(user_answer: str, correct_answer: str) -> float:36 """Returns 1.0 if correct, 0.0 if wrong. Case-insensitive."""37 return 1.0 if user_answer.strip().upper() == correct_answer.strip().upper() else 0.038 39 40# ── Short answer evaluation ───────────────────────────────────────────────────41 42def evaluate_short_answer(43 user_answer: str,44 correct_answer: str,45 keywords: str,46) -> float:47 """48 Returns a score between 0.0 and 1.0.49 50 Combined score = 0.6 × semantic_similarity + 0.4 × keyword_overlap51 52 Semantic similarity: cosine similarity between sentence embeddings.53 Keyword overlap: fraction of expected keywords found in user answer.54 """55 if not user_answer or not user_answer.strip():56 return 0.057 58 # ── Semantic similarity ───────────────────────────────────────59 try:60 model = _get_model()61 embeddings = model.encode([user_answer.strip(), correct_answer.strip()])62 from sentence_transformers import util63 sim = float(util.cos_sim(embeddings[0], embeddings[1]))64 sim = max(0.0, min(1.0, sim))65 except Exception as e:66 log.warning(f"[TEST] Semantic similarity failed: {e} — using 0")67 sim = 0.068 69 # ── Keyword overlap ───────────────────────────────────────────70 kw_score = 0.071 if keywords:72 kw_list = [k.strip().lower() for k in keywords.split(",") if k.strip()]73 if kw_list:74 answer_lower = user_answer.lower()75 matched = sum(1 for kw in kw_list if kw in answer_lower)76 kw_score = matched / len(kw_list)77 78 combined = 0.6 * sim + 0.4 * kw_score79 return round(combined, 3)80 81 82# ── Grade computation ─────────────────────────────────────────────────────────83 84def compute_grade(percentage: float) -> str:85 if percentage >= 90: return "A"86 if percentage >= 80: return "B"87 if percentage >= 70: return "C"88 if percentage >= 60: return "D"89 return "F"90 91 92# ── Test evaluation ───────────────────────────────────────────────────────────93 94def evaluate_test(questions: list[dict], answers: dict[str, str]) -> dict:95 """96 Evaluate a submitted test.97 98 questions: list of question dicts from DB99 answers: {str(question_id): user_answer_string}100 101 Returns full result dict with per-question breakdown.102 """103 results = []104 total_marks = 0105 earned_marks = 0.0106 107 for q in questions:108 qid = str(q["id"])109 user_ans = answers.get(qid, "").strip()110 qtype = q["question_type"]111 correct = q["correct_answer"]112 marks = q["marks"]113 total_marks += marks114 115 if qtype == "mcq":116 score_ratio = evaluate_mcq(user_ans, correct)117 is_correct = score_ratio == 1.0118 earned = marks * score_ratio119 feedback = "Correct" if is_correct else f"Incorrect. Correct answer: {correct}"120 else:121 score_ratio = evaluate_short_answer(user_ans, correct, q.get("keywords", ""))122 is_correct = score_ratio >= 0.6 # 60% threshold = passing123 earned = marks * score_ratio124 feedback = (125 f"Good answer (similarity: {round(score_ratio*100)}%)"126 if is_correct else127 f"Partial/incorrect (similarity: {round(score_ratio*100)}%). "128 f"Model answer: {correct[:200]}"129 )130 131 earned_marks += earned132 133 results.append({134 "questionId": q["id"],135 "questionText": q["question_text"],136 "questionType": qtype,137 "userAnswer": user_ans,138 "correctAnswer": correct if qtype == "mcq" else correct[:300],139 "isCorrect": is_correct,140 "marksEarned": round(earned, 2),141 "totalMarks": marks,142 "feedback": feedback,143 })144 145 percentage = round((earned_marks / total_marks * 100) if total_marks > 0 else 0.0, 2)146 grade = compute_grade(percentage)147 148 return {149 "totalMarks": total_marks,150 "obtainedMarks": round(earned_marks, 2),151 "percentage": percentage,152 "grade": grade,153 "results": results,154 }155 