Arin Sujal commited on
Commit
656cbff
·
unverified ·
1 Parent(s): 527c923

Add files via upload

Browse files
Files changed (2) hide show
  1. backend/models.py +1 -0
  2. backend/quiz_engine.py +102 -25
backend/models.py CHANGED
@@ -525,6 +525,7 @@ class StudentDescriptiveQuestionResult(BaseModel):
525
  key_points_covered: List[str] = Field(default_factory=list)
526
  key_points_missing: List[str] = Field(default_factory=list)
527
  evidence_quote: Optional[str] = None
 
528
 
529
 
530
  class StudentDescriptiveQuizResult(BaseModel):
 
525
  key_points_covered: List[str] = Field(default_factory=list)
526
  key_points_missing: List[str] = Field(default_factory=list)
527
  evidence_quote: Optional[str] = None
528
+ explanation: Optional[str] = None
529
 
530
 
531
  class StudentDescriptiveQuizResult(BaseModel):
backend/quiz_engine.py CHANGED
@@ -98,16 +98,68 @@ def _is_similar_question(q_text: str, existing_q_texts: List[str], threshold: fl
98
  return False
99
 
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  def _is_valid_mcq(q: Any) -> bool:
102
- """Validates that a generated MCQ is complete, non-dummy, and has 4 valid distinct options."""
103
  if not isinstance(q, QuizQuestion) or not q.question or len(q.question.strip()) < 10:
104
  return False
105
- if len(q.options) != 4:
 
 
106
  return False
107
  seen_texts = set()
 
108
  for opt in q.options:
109
  text = opt.text.strip()
110
  opt_id = opt.id.strip().upper()
 
 
 
111
  if len(text) <= 1:
112
  return False
113
  if text.upper() in (opt_id, f"OPTION {opt_id}", "A", "B", "C", "D"):
@@ -116,6 +168,17 @@ def _is_valid_mcq(q: Any) -> bool:
116
  if lower_t in seen_texts:
117
  return False
118
  seen_texts.add(lower_t)
 
 
 
 
 
 
 
 
 
 
 
119
  return True
120
 
121
 
@@ -143,11 +206,13 @@ def _is_valid_truefalse(q: Any) -> bool:
143
 
144
 
145
  def _is_valid_descriptive(q: Any) -> bool:
146
- """Validates that a generated Descriptive question is complete, non-dummy, and has a substantive answer."""
147
  if not isinstance(q, DescriptiveQuestion) or not q.question or len(q.question.strip()) < 10:
148
  return False
149
  if not q.model_answer or len(q.model_answer.strip()) < 15:
150
  return False
 
 
151
  return True
152
 
153
 
@@ -599,7 +664,7 @@ class RAGQuizEngine:
599
  self.gemini_api_key = new_key.strip()
600
  self._init_client()
601
 
602
- def _create_fresh_llm(self):
603
  """Creates a fresh LangChain LLM instance bound to the active event loop to prevent 'Event loop is closed' errors."""
604
  if not self.token:
605
  return None
@@ -609,7 +674,7 @@ class RAGQuizEngine:
609
  repo_id=self.model,
610
  huggingfacehub_api_token=self.token,
611
  task="text-generation",
612
- max_new_tokens=1500,
613
  temperature=0.7
614
  )
615
  return ChatHuggingFace(llm=endpoint)
@@ -643,16 +708,20 @@ class RAGQuizEngine:
643
  anchor = BLOOM_ANCHORS[idx % len(BLOOM_ANCHORS)]
644
  seed = random.randint(1000, 9999999)
645
 
646
- focal_clause = ""
647
  if context_chunks and len(context_chunks) > 0:
648
  focal = context_chunks[idx % len(context_chunks)]
649
- focal_clause = f"\nFOCAL PASSAGE EXCERPT (Base this question on the specific facts or events in this excerpt):\n{focal}\n"
650
 
651
  exclude_clause = ""
652
  if exclude_stems:
653
  clean_stems = [s[:60] for s in exclude_stems[:3]]
654
  exclude_clause = f"\nDO NOT repeat or ask about these already covered topics: {'; '.join(clean_stems)}\n"
655
 
 
 
 
 
 
656
  if is_hindi:
657
  system_prompt = (
658
  f"You are an expert Hindi language and literature (हिन्दी भाषा एवं साहित्य) examiner for "
@@ -672,9 +741,8 @@ class RAGQuizEngine:
672
  + (f"Topic: {topic}\n" if topic else "")
673
  + f"Question #{idx+1} Cognitive Level: {anchor['level']}\n"
674
  f"Entropy Seed: {seed}\n"
675
- f"{focal_clause}"
676
  f"{exclude_clause}\n"
677
- f"Textbook Context:\n{context_text}\n\n"
678
  "Generate exactly 1 high-quality multiple choice question with 4 options, the correct option, and an explanation."
679
  )
680
  else:
@@ -689,7 +757,10 @@ class RAGQuizEngine:
689
  "3. CRITICAL RULE FOR OPTIONS:\n"
690
  " - The 'text' field of each option MUST contain the full descriptive answer statement (e.g. '100 pesos', 'To preserve Lencho faith in God', 'It increases quadratically').\n"
691
  " - NEVER set 'text' to just 'A', 'B', 'C', 'D' or single letters. All 4 option texts must be meaningful and distinct.\n"
692
- "4. Exactly ONE option must be correct. The other 3 must be plausible distractors.\n"
 
 
 
693
  "5. Shuffle the correct option position randomly.\n"
694
  "6. SCIENTIFIC NOTATION: Use clean symbols (e.g. ε₀, μ₀, π, ℏ, °C) and superscripts/subscripts (e.g. 10⁻¹², C² N⁻¹ m⁻², m/s²).\n"
695
  "7. NEVER mention page numbers or figure numbers."
@@ -699,10 +770,9 @@ class RAGQuizEngine:
699
  + (f"Topic: {topic}\n" if topic else "")
700
  + f"Question #{idx+1} Cognitive Level: {anchor['level']}\n"
701
  f"Entropy Seed: {seed}\n"
702
- f"{focal_clause}"
703
  f"{exclude_clause}\n"
704
- f"Textbook Knowledge:\n{context_text}\n\n"
705
- "Generate exactly 1 high-quality multiple choice question with 4 options, the correct option, and an explanation."
706
  )
707
 
708
  messages = [
@@ -723,11 +793,11 @@ class RAGQuizEngine:
723
  ))
724
 
725
  q_val = getattr(raw, "question", None) or (raw.get("question") if isinstance(raw, dict) else "")
726
- corr_val = getattr(raw, "correct_option", None) or (raw.get("correct_option") if isinstance(raw, dict) else "A")
727
  expl_val = getattr(raw, "explanation", None) or (raw.get("explanation") if isinstance(raw, dict) else "")
728
 
729
  q_text = format_scientific_symbols(_sanitize_text(str(q_val)))
730
- corr_opt = str(corr_val).strip().upper()[:1] or "A"
731
  expl = format_scientific_symbols(str(expl_val))
732
 
733
  candidate = QuizQuestion(
@@ -754,7 +824,7 @@ class RAGQuizEngine:
754
 
755
  for r in results:
756
  if isinstance(r, QuizQuestion) and _is_valid_mcq(r):
757
- if not _is_similar_question(r.question, accepted_stems):
758
  valid_questions.append(r)
759
  accepted_stems.append(r.question)
760
  if len(valid_questions) == num_questions:
@@ -772,7 +842,7 @@ class RAGQuizEngine:
772
  topup_results = await asyncio.gather(*topup_tasks, return_exceptions=True)
773
  for r in topup_results:
774
  if isinstance(r, QuizQuestion) and _is_valid_mcq(r):
775
- if not _is_similar_question(r.question, accepted_stems):
776
  valid_questions.append(r)
777
  accepted_stems.append(r.question)
778
  if len(valid_questions) == num_questions:
@@ -788,7 +858,7 @@ class RAGQuizEngine:
788
  final_topup_results = await asyncio.gather(*final_topup_tasks, return_exceptions=True)
789
  for r in final_topup_results:
790
  if isinstance(r, QuizQuestion) and _is_valid_mcq(r):
791
- if not _is_similar_question(r.question, accepted_stems):
792
  valid_questions.append(r)
793
  accepted_stems.append(r.question)
794
  if len(valid_questions) == num_questions:
@@ -1332,8 +1402,9 @@ class RAGQuizEngine:
1332
  "1. Formulate 1 clear, academic descriptive question grounded strictly in the provided textbook context.\n"
1333
  "2. MODEL ANSWER: Provide a thorough, authoritative reference answer that would achieve full marks.\n"
1334
  "3. KEY FACTS: List 3 to 6 distinct, concrete facts, terms, or steps that are mandatory in a correct answer.\n"
1335
- "4. SCIENTIFIC NOTATION: Use clean scientific symbols (e.g. ε₀, μ₀, π, 10⁻¹², m/s²) where applicable.\n"
1336
- "5. ZERO FLUFF: Keep the question direct and unambiguous. Do not reference 'in the passage' or 'according to the text'."
 
1337
  )
1338
  user_prompt = (
1339
  f"Board: {board_id.upper()} | Class: {class_id} | Subject: {subject} | Chapter: {chapter}\n"
@@ -1342,7 +1413,7 @@ class RAGQuizEngine:
1342
  f"Entropy Seed: {seed}\n"
1343
  f"{exclude_clause}\n"
1344
  f"{knowledge_block}\n\n"
1345
- f"Generate exactly 1 descriptive question with model answer and key facts."
1346
  )
1347
 
1348
  messages = [
@@ -1363,7 +1434,10 @@ class RAGQuizEngine:
1363
  q_text = format_scientific_symbols(_sanitize_text(str(q_val)))
1364
  model_ans = format_scientific_symbols(str(ans_val))
1365
  cleaned_facts = [format_scientific_symbols(str(f).strip()) for f in facts_val if str(f).strip()]
1366
- expl = format_scientific_symbols(str(expl_val) or f"Tests understanding of {topic or chapter}.")
 
 
 
1367
 
1368
  candidate = DescriptiveQuestion(
1369
  id=idx + 1,
@@ -3068,7 +3142,8 @@ class RAGQuizEngine:
3068
  feedback="No answer provided.",
3069
  key_points_covered=[],
3070
  key_points_missing=q.key_facts,
3071
- evidence_quote=q.source_context
 
3072
  )
3073
 
3074
  system_prompt = (
@@ -3136,7 +3211,8 @@ class RAGQuizEngine:
3136
  feedback=str(fb) or ("Good answer." if is_correct else "Needs improvement on key details."),
3137
  key_points_covered=[str(c) for c in covered if str(c).strip()],
3138
  key_points_missing=[str(m) for m in missing if str(m).strip()],
3139
- evidence_quote=str(ev) if ev else q.source_context
 
3140
  )
3141
  except Exception as e:
3142
  print(f"[AI Engine] Error grading descriptive question #{q.id}: {e}")
@@ -3154,7 +3230,8 @@ class RAGQuizEngine:
3154
  feedback="Automated grading fallback applied.",
3155
  key_points_covered=[],
3156
  key_points_missing=q.key_facts,
3157
- evidence_quote=q.source_context
 
3158
  )
3159
 
3160
  async def grade_all_answers() -> List[StudentDescriptiveQuestionResult]:
 
98
  return False
99
 
100
 
101
+ def _resolve_mcq_correct_option(corr_val: Any, options: List[MCQOption]) -> Optional[str]:
102
+ """
103
+ Robustly resolves correct option letter ('A', 'B', 'C', 'D') even if LLM returns
104
+ 'Option B', '(C)', 'B.', or the full text of the option statement.
105
+ """
106
+ if not corr_val:
107
+ return None
108
+
109
+ raw_str = str(corr_val).strip()
110
+ upper_str = raw_str.upper()
111
+
112
+ # Direct match: 'A', 'B', 'C', 'D'
113
+ if upper_str in ("A", "B", "C", "D"):
114
+ return upper_str
115
+
116
+ # Prefix/regex match: 'Option B', '(B)', 'B:', 'B)'
117
+ match = re.search(r'(?:OPTION|ANSWER)?\s*[\(\[]?\s*([A-D])\s*[\)\]\:\.\-]?', upper_str)
118
+ if match and len(raw_str) <= 12:
119
+ return match.group(1)
120
+
121
+ # If LLM returned text (e.g. "Newton's first law..."), find the best matching option
122
+ corr_tokens = _normalize_tokens(raw_str)
123
+ if corr_tokens and options:
124
+ best_opt_id = None
125
+ best_score = 0.0
126
+ for opt in options:
127
+ opt_tokens = _normalize_tokens(opt.text)
128
+ if not opt_tokens:
129
+ continue
130
+ inter = len(corr_tokens & opt_tokens)
131
+ union = len(corr_tokens | opt_tokens)
132
+ score = (inter / union) if union > 0 else 0.0
133
+ if score > best_score and score >= 0.40:
134
+ best_score = score
135
+ best_opt_id = opt.id.upper()
136
+ if best_opt_id in ("A", "B", "C", "D"):
137
+ return best_opt_id
138
+
139
+ # Fallback to single character if it is strictly A-D
140
+ first_char = upper_str[:1]
141
+ if first_char in ("A", "B", "C", "D"):
142
+ return first_char
143
+
144
+ return None
145
+
146
+
147
  def _is_valid_mcq(q: Any) -> bool:
148
+ """Validates that a generated MCQ is complete, non-dummy, has valid correct_option, and 4 distinct options."""
149
  if not isinstance(q, QuizQuestion) or not q.question or len(q.question.strip()) < 10:
150
  return False
151
+ if not q.correct_option or q.correct_option.strip().upper() not in ("A", "B", "C", "D"):
152
+ return False
153
+ if not q.options or len(q.options) != 4:
154
  return False
155
  seen_texts = set()
156
+ option_ids = set()
157
  for opt in q.options:
158
  text = opt.text.strip()
159
  opt_id = opt.id.strip().upper()
160
+ if opt_id not in ("A", "B", "C", "D") or opt_id in option_ids:
161
+ return False
162
+ option_ids.add(opt_id)
163
  if len(text) <= 1:
164
  return False
165
  if text.upper() in (opt_id, f"OPTION {opt_id}", "A", "B", "C", "D"):
 
168
  if lower_t in seen_texts:
169
  return False
170
  seen_texts.add(lower_t)
171
+
172
+ # Check pairwise word overlap (Jaccard similarity) between options to catch near-duplicates / rephrasings
173
+ tokens_list = [_normalize_tokens(opt.text) for opt in q.options]
174
+ for i in range(len(tokens_list)):
175
+ for j in range(i + 1, len(tokens_list)):
176
+ t1, t2 = tokens_list[i], tokens_list[j]
177
+ if len(t1) >= 5 and len(t2) >= 5:
178
+ inter = len(t1 & t2)
179
+ union = len(t1 | t2)
180
+ if union > 0 and (inter / union) >= 0.82:
181
+ return False
182
  return True
183
 
184
 
 
206
 
207
 
208
  def _is_valid_descriptive(q: Any) -> bool:
209
+ """Validates that a generated Descriptive question is complete, non-dummy, and has a substantive answer and explanation."""
210
  if not isinstance(q, DescriptiveQuestion) or not q.question or len(q.question.strip()) < 10:
211
  return False
212
  if not q.model_answer or len(q.model_answer.strip()) < 15:
213
  return False
214
+ if not q.explanation or len(q.explanation.strip()) < 5 or q.explanation.strip().lower() in ("none", "null"):
215
+ return False
216
  return True
217
 
218
 
 
664
  self.gemini_api_key = new_key.strip()
665
  self._init_client()
666
 
667
+ def _create_fresh_llm(self, max_new_tokens: int = 700):
668
  """Creates a fresh LangChain LLM instance bound to the active event loop to prevent 'Event loop is closed' errors."""
669
  if not self.token:
670
  return None
 
674
  repo_id=self.model,
675
  huggingfacehub_api_token=self.token,
676
  task="text-generation",
677
+ max_new_tokens=max_new_tokens,
678
  temperature=0.7
679
  )
680
  return ChatHuggingFace(llm=endpoint)
 
708
  anchor = BLOOM_ANCHORS[idx % len(BLOOM_ANCHORS)]
709
  seed = random.randint(1000, 9999999)
710
 
711
+ focal = None
712
  if context_chunks and len(context_chunks) > 0:
713
  focal = context_chunks[idx % len(context_chunks)]
 
714
 
715
  exclude_clause = ""
716
  if exclude_stems:
717
  clean_stems = [s[:60] for s in exclude_stems[:3]]
718
  exclude_clause = f"\nDO NOT repeat or ask about these already covered topics: {'; '.join(clean_stems)}\n"
719
 
720
+ knowledge_block = (
721
+ f"FOCAL TEXTBOOK EXCERPT (Base your question and options strictly on this specific passage):\n{focal}\n"
722
+ if focal else f"Textbook Knowledge:\n{context_text}\n"
723
+ )
724
+
725
  if is_hindi:
726
  system_prompt = (
727
  f"You are an expert Hindi language and literature (हिन्दी भाषा एवं साहित्य) examiner for "
 
741
  + (f"Topic: {topic}\n" if topic else "")
742
  + f"Question #{idx+1} Cognitive Level: {anchor['level']}\n"
743
  f"Entropy Seed: {seed}\n"
 
744
  f"{exclude_clause}\n"
745
+ f"{knowledge_block}\n\n"
746
  "Generate exactly 1 high-quality multiple choice question with 4 options, the correct option, and an explanation."
747
  )
748
  else:
 
757
  "3. CRITICAL RULE FOR OPTIONS:\n"
758
  " - The 'text' field of each option MUST contain the full descriptive answer statement (e.g. '100 pesos', 'To preserve Lencho faith in God', 'It increases quadratically').\n"
759
  " - NEVER set 'text' to just 'A', 'B', 'C', 'D' or single letters. All 4 option texts must be meaningful and distinct.\n"
760
+ "4. ALL 4 OPTIONS MUST BE DISTINCT AND UNIQUE:\n"
761
+ " - Exactly ONE option must be correct. The other 3 must be plausible distractors.\n"
762
+ " - Distractors must NOT be slight rephrasings or synonyms of the correct answer or each other.\n"
763
+ " - Each distractor must represent a completely distinct concept, alternative scientific law/effect, or different outcome.\n"
764
  "5. Shuffle the correct option position randomly.\n"
765
  "6. SCIENTIFIC NOTATION: Use clean symbols (e.g. ε₀, μ₀, π, ℏ, °C) and superscripts/subscripts (e.g. 10⁻¹², C² N⁻¹ m⁻², m/s²).\n"
766
  "7. NEVER mention page numbers or figure numbers."
 
770
  + (f"Topic: {topic}\n" if topic else "")
771
  + f"Question #{idx+1} Cognitive Level: {anchor['level']}\n"
772
  f"Entropy Seed: {seed}\n"
 
773
  f"{exclude_clause}\n"
774
+ f"{knowledge_block}\n\n"
775
+ "Generate exactly 1 high-quality multiple choice question with 4 distinct options, the correct option identifier (A, B, C, or D), and an explanation."
776
  )
777
 
778
  messages = [
 
793
  ))
794
 
795
  q_val = getattr(raw, "question", None) or (raw.get("question") if isinstance(raw, dict) else "")
796
+ corr_val = getattr(raw, "correct_option", None) or (raw.get("correct_option") if isinstance(raw, dict) else "")
797
  expl_val = getattr(raw, "explanation", None) or (raw.get("explanation") if isinstance(raw, dict) else "")
798
 
799
  q_text = format_scientific_symbols(_sanitize_text(str(q_val)))
800
+ corr_opt = _resolve_mcq_correct_option(corr_val, options) or ""
801
  expl = format_scientific_symbols(str(expl_val))
802
 
803
  candidate = QuizQuestion(
 
824
 
825
  for r in results:
826
  if isinstance(r, QuizQuestion) and _is_valid_mcq(r):
827
+ if not _is_similar_question(r.question, accepted_stems, threshold=0.70):
828
  valid_questions.append(r)
829
  accepted_stems.append(r.question)
830
  if len(valid_questions) == num_questions:
 
842
  topup_results = await asyncio.gather(*topup_tasks, return_exceptions=True)
843
  for r in topup_results:
844
  if isinstance(r, QuizQuestion) and _is_valid_mcq(r):
845
+ if not _is_similar_question(r.question, accepted_stems, threshold=0.70):
846
  valid_questions.append(r)
847
  accepted_stems.append(r.question)
848
  if len(valid_questions) == num_questions:
 
858
  final_topup_results = await asyncio.gather(*final_topup_tasks, return_exceptions=True)
859
  for r in final_topup_results:
860
  if isinstance(r, QuizQuestion) and _is_valid_mcq(r):
861
+ if not _is_similar_question(r.question, accepted_stems, threshold=0.70):
862
  valid_questions.append(r)
863
  accepted_stems.append(r.question)
864
  if len(valid_questions) == num_questions:
 
1402
  "1. Formulate 1 clear, academic descriptive question grounded strictly in the provided textbook context.\n"
1403
  "2. MODEL ANSWER: Provide a thorough, authoritative reference answer that would achieve full marks.\n"
1404
  "3. KEY FACTS: List 3 to 6 distinct, concrete facts, terms, or steps that are mandatory in a correct answer.\n"
1405
+ "4. EXPLANATION: Provide a 1-2 sentence educational explanation summarizing the core principle being tested.\n"
1406
+ "5. SCIENTIFIC NOTATION: Use clean scientific symbols (e.g. ε₀, μ₀, π, 10⁻¹², m/s²) where applicable.\n"
1407
+ "6. ZERO FLUFF: Keep the question direct and unambiguous. Do not reference 'in the passage' or 'according to the text'."
1408
  )
1409
  user_prompt = (
1410
  f"Board: {board_id.upper()} | Class: {class_id} | Subject: {subject} | Chapter: {chapter}\n"
 
1413
  f"Entropy Seed: {seed}\n"
1414
  f"{exclude_clause}\n"
1415
  f"{knowledge_block}\n\n"
1416
+ f"Generate exactly 1 descriptive question with model answer, key facts, and educational explanation."
1417
  )
1418
 
1419
  messages = [
 
1434
  q_text = format_scientific_symbols(_sanitize_text(str(q_val)))
1435
  model_ans = format_scientific_symbols(str(ans_val))
1436
  cleaned_facts = [format_scientific_symbols(str(f).strip()) for f in facts_val if str(f).strip()]
1437
+ clean_expl = str(expl_val).strip() if expl_val is not None else ""
1438
+ if not clean_expl or clean_expl.lower() in ("none", "null", ""):
1439
+ clean_expl = f"Tests comprehensive conceptual understanding of {topic or chapter} principles."
1440
+ expl = format_scientific_symbols(clean_expl)
1441
 
1442
  candidate = DescriptiveQuestion(
1443
  id=idx + 1,
 
3142
  feedback="No answer provided.",
3143
  key_points_covered=[],
3144
  key_points_missing=q.key_facts,
3145
+ evidence_quote=q.source_context,
3146
+ explanation=q.explanation
3147
  )
3148
 
3149
  system_prompt = (
 
3211
  feedback=str(fb) or ("Good answer." if is_correct else "Needs improvement on key details."),
3212
  key_points_covered=[str(c) for c in covered if str(c).strip()],
3213
  key_points_missing=[str(m) for m in missing if str(m).strip()],
3214
+ evidence_quote=str(ev) if ev else q.source_context,
3215
+ explanation=q.explanation
3216
  )
3217
  except Exception as e:
3218
  print(f"[AI Engine] Error grading descriptive question #{q.id}: {e}")
 
3230
  feedback="Automated grading fallback applied.",
3231
  key_points_covered=[],
3232
  key_points_missing=q.key_facts,
3233
+ evidence_quote=q.source_context,
3234
+ explanation=q.explanation
3235
  )
3236
 
3237
  async def grade_all_answers() -> List[StudentDescriptiveQuestionResult]: