Spaces:
Sleeping
Sleeping
Initial commit — MCQ Generator with T5 + NER + WordNet
Browse files- .gitignore +25 -0
- __init__.py +1 -0
- app/__init__.py +1 -0
- app/components.py +115 -0
- app/main.py +239 -0
- config.py +42 -0
- data/sample_passages.json +27 -0
- requirements.txt +23 -0
- src/distractor_generator.py +154 -0
- src/evaluator.py +132 -0
- src/mcq_builder.py +222 -0
- src/preprocessor.py +165 -0
- src/question_generator.py +180 -0
.gitignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Virtual environment
|
| 2 |
+
myenv/
|
| 3 |
+
venv/
|
| 4 |
+
env/
|
| 5 |
+
|
| 6 |
+
# Model files (too large for GitHub)
|
| 7 |
+
models/
|
| 8 |
+
*.bin
|
| 9 |
+
*.pt
|
| 10 |
+
*.safetensors
|
| 11 |
+
|
| 12 |
+
# Python cache
|
| 13 |
+
__pycache__/
|
| 14 |
+
*.pyc
|
| 15 |
+
*.pyo
|
| 16 |
+
|
| 17 |
+
# HuggingFace cache
|
| 18 |
+
.cache/
|
| 19 |
+
|
| 20 |
+
# OS files
|
| 21 |
+
.DS_Store
|
| 22 |
+
Thumbs.db
|
| 23 |
+
|
| 24 |
+
# Jupyter checkpoints
|
| 25 |
+
.ipynb_checkpoints/
|
__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Makes src/ a Python package
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Makes app/ a Python package
|
app/components.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ─────────────────────────────────────────────
|
| 2 |
+
# app/components.py
|
| 3 |
+
# Reusable Streamlit UI building blocks.
|
| 4 |
+
# Keeps main.py clean and focused on flow logic.
|
| 5 |
+
# ─────────────────────────────────────────────
|
| 6 |
+
|
| 7 |
+
import streamlit as st
|
| 8 |
+
from src.mcq_builder import MCQ
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def render_question_card(mcq: MCQ, index: int) -> str | None:
|
| 12 |
+
"""
|
| 13 |
+
Render a question with labelled radio button options.
|
| 14 |
+
Returns the selected option label ("A"/"B"/"C"/"D") or None.
|
| 15 |
+
"""
|
| 16 |
+
st.markdown(f"### Q{index + 1}. {mcq.question}")
|
| 17 |
+
|
| 18 |
+
# Build labelled options: ["A. Paris", "B. London", ...]
|
| 19 |
+
labelled_options = [f"{chr(65+i)}. {opt}" for i, opt in enumerate(mcq.options)]
|
| 20 |
+
|
| 21 |
+
# Restore previous selection if user came back to this question
|
| 22 |
+
prev_index = st.session_state.user_answers[index]
|
| 23 |
+
default = prev_index if prev_index >= 0 else 0
|
| 24 |
+
|
| 25 |
+
selected = st.radio(
|
| 26 |
+
label = "Select your answer:",
|
| 27 |
+
options = labelled_options,
|
| 28 |
+
index = default,
|
| 29 |
+
key = f"q_{index}",
|
| 30 |
+
label_visibility = "collapsed",
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
# Return just the letter ("A", "B", etc.)
|
| 34 |
+
return selected[0] if selected else None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def render_result_card(result: dict, question_num: int):
|
| 38 |
+
"""
|
| 39 |
+
Render a single question's result with colour coding.
|
| 40 |
+
Green = correct, Red = wrong.
|
| 41 |
+
"""
|
| 42 |
+
is_correct = result["is_correct"]
|
| 43 |
+
icon = "✅" if is_correct else "❌"
|
| 44 |
+
color = "#d4edda" if is_correct else "#f8d7da"
|
| 45 |
+
border = "#28a745" if is_correct else "#dc3545"
|
| 46 |
+
|
| 47 |
+
with st.container():
|
| 48 |
+
st.markdown(
|
| 49 |
+
f"""
|
| 50 |
+
<div style="
|
| 51 |
+
background-color: {color};
|
| 52 |
+
border-left: 4px solid {border};
|
| 53 |
+
padding: 12px 16px;
|
| 54 |
+
border-radius: 6px;
|
| 55 |
+
margin-bottom: 12px;
|
| 56 |
+
">
|
| 57 |
+
<b>{icon} Q{question_num}: {result['question']}</b>
|
| 58 |
+
</div>
|
| 59 |
+
""",
|
| 60 |
+
unsafe_allow_html=True,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
col1, col2 = st.columns(2)
|
| 64 |
+
with col1:
|
| 65 |
+
st.write(f"**Your answer:** {result['your_answer']}")
|
| 66 |
+
with col2:
|
| 67 |
+
if not is_correct:
|
| 68 |
+
st.write(f"**Correct answer:** {result['correct_answer']}")
|
| 69 |
+
else:
|
| 70 |
+
st.write("**Correct!**")
|
| 71 |
+
|
| 72 |
+
with st.expander("See explanation"):
|
| 73 |
+
st.info(result["explanation"])
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def render_score_summary(result: dict):
|
| 77 |
+
"""
|
| 78 |
+
Render the score banner at the top of the results screen.
|
| 79 |
+
"""
|
| 80 |
+
score = result["score"]
|
| 81 |
+
total = result["total"]
|
| 82 |
+
percentage = result["percentage"]
|
| 83 |
+
feedback = result["feedback"]
|
| 84 |
+
|
| 85 |
+
# Choose colour based on score
|
| 86 |
+
if percentage >= 80:
|
| 87 |
+
color = "#d4edda"; border = "#28a745"
|
| 88 |
+
elif percentage >= 60:
|
| 89 |
+
color = "#fff3cd"; border = "#ffc107"
|
| 90 |
+
else:
|
| 91 |
+
color = "#f8d7da"; border = "#dc3545"
|
| 92 |
+
|
| 93 |
+
st.markdown(
|
| 94 |
+
f"""
|
| 95 |
+
<div style="
|
| 96 |
+
background-color: {color};
|
| 97 |
+
border: 2px solid {border};
|
| 98 |
+
border-radius: 10px;
|
| 99 |
+
padding: 20px 24px;
|
| 100 |
+
text-align: center;
|
| 101 |
+
">
|
| 102 |
+
<h2 style="margin:0">{score} / {total}</h2>
|
| 103 |
+
<h4 style="margin:4px 0">{percentage}%</h4>
|
| 104 |
+
<p style="margin:0; color: #555">{feedback}</p>
|
| 105 |
+
</div>
|
| 106 |
+
""",
|
| 107 |
+
unsafe_allow_html=True,
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# Metric columns for quick glance
|
| 111 |
+
st.markdown("<br>", unsafe_allow_html=True)
|
| 112 |
+
c1, c2, c3 = st.columns(3)
|
| 113 |
+
c1.metric("Correct", score)
|
| 114 |
+
c2.metric("Wrong", total - score)
|
| 115 |
+
c3.metric("Score", f"{percentage}%")
|
app/main.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ─────────────────────────────────────────────
|
| 2 |
+
# app/main.py
|
| 3 |
+
# Streamlit UI — the full interactive quiz app.
|
| 4 |
+
#
|
| 5 |
+
# Run with: streamlit run app/main.py
|
| 6 |
+
#
|
| 7 |
+
# Three screens:
|
| 8 |
+
# 1. INPUT → user pastes a passage, picks # of questions
|
| 9 |
+
# 2. QUIZ → one question at a time with radio buttons
|
| 10 |
+
# 3. RESULTS → score + per-question feedback
|
| 11 |
+
# ─────────────────────────────────────────────
|
| 12 |
+
|
| 13 |
+
import streamlit as st
|
| 14 |
+
import sys, os
|
| 15 |
+
|
| 16 |
+
# Make sure we can import from project root
|
| 17 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 18 |
+
|
| 19 |
+
from config import APP_TITLE, APP_ICON, MAX_QUESTIONS
|
| 20 |
+
from src.mcq_builder import build_quiz
|
| 21 |
+
from src.evaluator import score_quiz
|
| 22 |
+
from app.components import render_question_card, render_result_card, render_score_summary
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ─────────────────────────────────────────────
|
| 26 |
+
# PAGE CONFIG — must be first Streamlit call
|
| 27 |
+
# ─────────────────────────────────────────────
|
| 28 |
+
|
| 29 |
+
st.set_page_config(
|
| 30 |
+
page_title = APP_TITLE,
|
| 31 |
+
page_icon = APP_ICON,
|
| 32 |
+
layout = "centered",
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ─────────────────────────────────────────────
|
| 37 |
+
# SESSION STATE INITIALISATION
|
| 38 |
+
# st.session_state persists values across reruns.
|
| 39 |
+
# Think of it as the app's memory.
|
| 40 |
+
# ─────────────────────────────────────────────
|
| 41 |
+
|
| 42 |
+
def init_state():
|
| 43 |
+
defaults = {
|
| 44 |
+
"screen" : "input", # "input" | "quiz" | "results"
|
| 45 |
+
"mcqs" : [], # list of MCQ objects
|
| 46 |
+
"current_q" : 0, # index of current question
|
| 47 |
+
"user_answers" : [], # user's selected option indices
|
| 48 |
+
"quiz_result" : None, # scored result dict
|
| 49 |
+
}
|
| 50 |
+
for key, val in defaults.items():
|
| 51 |
+
if key not in st.session_state:
|
| 52 |
+
st.session_state[key] = val
|
| 53 |
+
|
| 54 |
+
init_state()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ─────────────────────────────────────────────
|
| 58 |
+
# HELPER: reset to start a new quiz
|
| 59 |
+
# ─────────────────────────────────────────────
|
| 60 |
+
|
| 61 |
+
def reset():
|
| 62 |
+
st.session_state.screen = "input"
|
| 63 |
+
st.session_state.mcqs = []
|
| 64 |
+
st.session_state.current_q = 0
|
| 65 |
+
st.session_state.user_answers = []
|
| 66 |
+
st.session_state.quiz_result = None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ─────────────────────────────────────────────
|
| 70 |
+
# SCREEN 1: INPUT
|
| 71 |
+
# User pastes a passage and hits "Generate Quiz"
|
| 72 |
+
# ─────────────────────────────────────────────
|
| 73 |
+
|
| 74 |
+
def screen_input():
|
| 75 |
+
st.title(f"{APP_ICON} {APP_TITLE}")
|
| 76 |
+
st.write("Paste any text passage below to automatically generate a quiz from it.")
|
| 77 |
+
|
| 78 |
+
st.info(
|
| 79 |
+
"**For best results**, use factual passages containing: "
|
| 80 |
+
"**people names, places, dates, organisations, or events.** \n"
|
| 81 |
+
"Try: history, science, geography, biographies. \n"
|
| 82 |
+
"Avoid opinion or purely descriptive text — they lack named facts."
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
st.markdown("---")
|
| 86 |
+
|
| 87 |
+
passage = st.text_area(
|
| 88 |
+
label = "Your passage",
|
| 89 |
+
placeholder = "Paste a paragraph or article here...",
|
| 90 |
+
height = 250,
|
| 91 |
+
help = "Minimum ~5 sentences recommended for best results.",
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
num_questions = st.slider(
|
| 95 |
+
label = "Number of questions",
|
| 96 |
+
min_value = 3,
|
| 97 |
+
max_value = MAX_QUESTIONS,
|
| 98 |
+
value = 5,
|
| 99 |
+
step = 1,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
st.markdown("---")
|
| 103 |
+
|
| 104 |
+
if st.button("Generate Quiz", type="primary", use_container_width=True):
|
| 105 |
+
if not passage or len(passage.split()) < 30:
|
| 106 |
+
st.warning("Please paste a longer passage (at least ~30 words).")
|
| 107 |
+
return
|
| 108 |
+
|
| 109 |
+
with st.spinner("Generating questions... this may take 30–60 seconds on first run."):
|
| 110 |
+
try:
|
| 111 |
+
mcqs = build_quiz(passage, num_questions=num_questions)
|
| 112 |
+
except Exception as e:
|
| 113 |
+
st.error(f"Something went wrong: {e}")
|
| 114 |
+
return
|
| 115 |
+
|
| 116 |
+
if not mcqs:
|
| 117 |
+
st.error("Could not generate questions from this passage. Try a different text.")
|
| 118 |
+
return
|
| 119 |
+
|
| 120 |
+
# Store in session and move to quiz screen
|
| 121 |
+
st.session_state.mcqs = mcqs
|
| 122 |
+
st.session_state.user_answers = [-1] * len(mcqs) # -1 = unanswered
|
| 123 |
+
st.session_state.current_q = 0
|
| 124 |
+
st.session_state.screen = "quiz"
|
| 125 |
+
st.rerun()
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ─────────────────────────────────────────────
|
| 129 |
+
# SCREEN 2: QUIZ
|
| 130 |
+
# One question at a time, with navigation.
|
| 131 |
+
# ─────────────────────────────────────────────
|
| 132 |
+
|
| 133 |
+
def screen_quiz():
|
| 134 |
+
mcqs = st.session_state.mcqs
|
| 135 |
+
current = st.session_state.current_q
|
| 136 |
+
total = len(mcqs)
|
| 137 |
+
mcq = mcqs[current]
|
| 138 |
+
|
| 139 |
+
# Progress bar
|
| 140 |
+
st.progress((current) / total, text=f"Question {current+1} of {total}")
|
| 141 |
+
st.markdown("---")
|
| 142 |
+
|
| 143 |
+
# Render the question card (defined in components.py)
|
| 144 |
+
selected_label = render_question_card(mcq, current)
|
| 145 |
+
|
| 146 |
+
st.markdown("---")
|
| 147 |
+
|
| 148 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 149 |
+
|
| 150 |
+
# Previous button
|
| 151 |
+
with col1:
|
| 152 |
+
if current > 0:
|
| 153 |
+
if st.button("← Previous"):
|
| 154 |
+
st.session_state.current_q -= 1
|
| 155 |
+
st.rerun()
|
| 156 |
+
|
| 157 |
+
# Next / Submit button
|
| 158 |
+
with col3:
|
| 159 |
+
# Convert selected label (A/B/C/D) back to index
|
| 160 |
+
if selected_label:
|
| 161 |
+
selected_index = ord(selected_label) - ord("A")
|
| 162 |
+
st.session_state.user_answers[current] = selected_index
|
| 163 |
+
|
| 164 |
+
if current < total - 1:
|
| 165 |
+
if st.button("Next →", type="primary"):
|
| 166 |
+
if selected_label is None:
|
| 167 |
+
st.warning("Please select an answer before continuing.")
|
| 168 |
+
else:
|
| 169 |
+
st.session_state.current_q += 1
|
| 170 |
+
st.rerun()
|
| 171 |
+
else:
|
| 172 |
+
# Last question — show Submit button
|
| 173 |
+
if st.button("Submit Quiz", type="primary"):
|
| 174 |
+
if selected_label is None:
|
| 175 |
+
st.warning("Please select an answer before submitting.")
|
| 176 |
+
else:
|
| 177 |
+
# Score the quiz
|
| 178 |
+
result = score_quiz(
|
| 179 |
+
st.session_state.mcqs,
|
| 180 |
+
st.session_state.user_answers
|
| 181 |
+
)
|
| 182 |
+
st.session_state.quiz_result = result
|
| 183 |
+
st.session_state.screen = "results"
|
| 184 |
+
st.rerun()
|
| 185 |
+
|
| 186 |
+
# Show quit option
|
| 187 |
+
with col2:
|
| 188 |
+
if st.button("Quit Quiz", help="Return to the input screen"):
|
| 189 |
+
reset()
|
| 190 |
+
st.rerun()
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# ─────────────────────────────────────────────
|
| 194 |
+
# SCREEN 3: RESULTS
|
| 195 |
+
# Score summary + per-question breakdown
|
| 196 |
+
# ─────────────────────────────────────────────
|
| 197 |
+
|
| 198 |
+
def screen_results():
|
| 199 |
+
result = st.session_state.quiz_result
|
| 200 |
+
|
| 201 |
+
st.title("Quiz Complete!")
|
| 202 |
+
st.markdown("---")
|
| 203 |
+
|
| 204 |
+
# Score summary banner
|
| 205 |
+
render_score_summary(result)
|
| 206 |
+
|
| 207 |
+
st.markdown("---")
|
| 208 |
+
st.subheader("Question-by-question breakdown")
|
| 209 |
+
|
| 210 |
+
# Per-question result cards
|
| 211 |
+
for i, r in enumerate(result["results"]):
|
| 212 |
+
render_result_card(r, i + 1)
|
| 213 |
+
|
| 214 |
+
st.markdown("---")
|
| 215 |
+
|
| 216 |
+
col1, col2 = st.columns(2)
|
| 217 |
+
with col1:
|
| 218 |
+
if st.button("Try Another Passage", use_container_width=True):
|
| 219 |
+
reset()
|
| 220 |
+
st.rerun()
|
| 221 |
+
with col2:
|
| 222 |
+
if st.button("Retake Same Quiz", type="primary", use_container_width=True):
|
| 223 |
+
# Reset answers but keep the same MCQs
|
| 224 |
+
st.session_state.user_answers = [-1] * len(st.session_state.mcqs)
|
| 225 |
+
st.session_state.current_q = 0
|
| 226 |
+
st.session_state.screen = "quiz"
|
| 227 |
+
st.rerun()
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
# ─────────────────────────────────────────────
|
| 231 |
+
# ROUTER — picks which screen to show
|
| 232 |
+
# ─────────────────────────────────────────────
|
| 233 |
+
|
| 234 |
+
if st.session_state.screen == "input":
|
| 235 |
+
screen_input()
|
| 236 |
+
elif st.session_state.screen == "quiz":
|
| 237 |
+
screen_quiz()
|
| 238 |
+
elif st.session_state.screen == "results":
|
| 239 |
+
screen_results()
|
config.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ─────────────────────────────────────────────
|
| 2 |
+
# config.py – Central settings for MCQ Generator
|
| 3 |
+
# Change values here to tune the whole project.
|
| 4 |
+
# ─────────────────────────────────────────────
|
| 5 |
+
|
| 6 |
+
# ── Model settings ──────────────────────────
|
| 7 |
+
# T5 model fine-tuned on SQuAD for question generation
|
| 8 |
+
# "highlight" format: answer is wrapped in <hl> tags in the input
|
| 9 |
+
QG_MODEL_NAME = "valhalla/t5-small-qg-hl"
|
| 10 |
+
|
| 11 |
+
# spaCy English model for NLP preprocessing
|
| 12 |
+
SPACY_MODEL = "en_core_web_sm"
|
| 13 |
+
|
| 14 |
+
# ── Pipeline settings ───────────────────────
|
| 15 |
+
# How many top-ranked sentences to pick questions from
|
| 16 |
+
TOP_SENTENCES = 7
|
| 17 |
+
|
| 18 |
+
# Maximum number of MCQs to generate from one passage
|
| 19 |
+
MAX_QUESTIONS = 10
|
| 20 |
+
|
| 21 |
+
# Minimum sentence length (in words) to be considered for a question
|
| 22 |
+
MIN_SENTENCE_LENGTH = 8
|
| 23 |
+
|
| 24 |
+
# Number of wrong options (distractors) per question
|
| 25 |
+
NUM_DISTRACTORS = 3
|
| 26 |
+
|
| 27 |
+
# ── Distractor generation strategy ──────────
|
| 28 |
+
# Order of strategies tried. First one that returns enough distractors wins.
|
| 29 |
+
# Options: "wordnet", "sense2vec", "ner"
|
| 30 |
+
DISTRACTOR_STRATEGIES = ["wordnet", "ner", "sense2vec"]
|
| 31 |
+
|
| 32 |
+
# ── Paths ────────────────────────────────────
|
| 33 |
+
# Path to GloVe vectors file (download separately if using sense2vec)
|
| 34 |
+
# Download: https://nlp.stanford.edu/projects/glove/
|
| 35 |
+
GLOVE_PATH = "models/glove.6B.100d.txt"
|
| 36 |
+
|
| 37 |
+
# Path to sample passages for testing
|
| 38 |
+
SAMPLE_DATA_PATH = "data/sample_passages.json"
|
| 39 |
+
|
| 40 |
+
# ── UI settings ──────────────────────────────
|
| 41 |
+
APP_TITLE = "MCQ Generator"
|
| 42 |
+
APP_ICON = "📝"
|
data/sample_passages.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": 1,
|
| 4 |
+
"topic": "ISRO",
|
| 5 |
+
"text": "The Indian Space Research Organisation (ISRO) was founded in 1969 by Vikram Sarabhai. It is headquartered in Bengaluru, Karnataka. ISRO developed India's first satellite, Aryabhata, which was launched in 1975. The Chandrayaan-1 mission in 2008 discovered water molecules on the Moon. In 2023, Chandrayaan-3 successfully landed near the lunar south pole, making India the fourth country to achieve a Moon landing. The Mars Orbiter Mission, also called Mangalyaan, was launched in 2013 and made India the first Asian country to reach Martian orbit."
|
| 6 |
+
},
|
| 7 |
+
{
|
| 8 |
+
"id": 2,
|
| 9 |
+
"topic": "Photosynthesis",
|
| 10 |
+
"text": "Photosynthesis is a process used by plants and other organisms to convert light energy into chemical energy stored in glucose. It takes place primarily in the chloroplasts of plant cells, which contain a green pigment called chlorophyll. The overall equation for photosynthesis is: carbon dioxide plus water, in the presence of light, produces glucose and oxygen. Photosynthesis has two main stages: the light-dependent reactions and the Calvin cycle. The light-dependent reactions occur in the thylakoid membranes, while the Calvin cycle takes place in the stroma."
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"id": 3,
|
| 14 |
+
"topic": "Mahatma Gandhi",
|
| 15 |
+
"text": "Mahatma Gandhi was born on October 2, 1869, in Porbandar, Gujarat, India. He studied law in London and later worked as a lawyer in South Africa, where he first developed his method of nonviolent resistance called Satyagraha. Returning to India in 1915, Gandhi became a leading figure in the Indian independence movement against British rule. He led major campaigns such as the Non-Cooperation Movement in 1920 and the Salt March in 1930. Gandhi was assassinated on January 30, 1948, by Nathuram Godse. He is widely regarded as the Father of the Nation in India."
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"id": 4,
|
| 19 |
+
"topic": "Artificial Intelligence",
|
| 20 |
+
"text": "Artificial intelligence is the simulation of human intelligence processes by computer systems. Machine learning is a subset of AI that enables systems to learn from data without being explicitly programmed. Deep learning, a subset of machine learning, uses neural networks with many layers to analyse patterns in data. Natural language processing allows computers to understand and generate human language. AI applications include image recognition, speech recognition, autonomous vehicles, and medical diagnosis. The term artificial intelligence was coined by John McCarthy in 1956 at the Dartmouth Conference."
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"id": 5,
|
| 24 |
+
"topic": "Water Cycle",
|
| 25 |
+
"text": "The water cycle, also known as the hydrological cycle, describes the continuous movement of water on, above, and below the Earth's surface. The main processes involved are evaporation, condensation, precipitation, and collection. Evaporation occurs when water from oceans, lakes, and rivers is heated by the sun and turns into water vapour. This vapour rises into the atmosphere and cools, forming clouds through condensation. When clouds become heavy enough, water falls back to Earth as precipitation in the form of rain, snow, or hail. The water then collects in oceans, rivers, and groundwater, and the cycle begins again."
|
| 26 |
+
}
|
| 27 |
+
]
|
requirements.txt
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MCQ Generator — Python dependencies
|
| 2 |
+
# Install with: pip install -r requirements.txt
|
| 3 |
+
|
| 4 |
+
# ── Core NLP ──────────────────────────────────
|
| 5 |
+
spacy==3.7.4
|
| 6 |
+
nltk==3.8.1
|
| 7 |
+
|
| 8 |
+
# ── Transformers (T5 for question generation) ─
|
| 9 |
+
transformers==4.38.2
|
| 10 |
+
torch==2.2.1 # CPU version — change to torch==2.2.1+cu118 for GPU
|
| 11 |
+
|
| 12 |
+
# ── Word Embeddings ───────────────────────────
|
| 13 |
+
gensim==4.3.2 # Word2Vec / GloVe loading
|
| 14 |
+
|
| 15 |
+
# ── ML utilities ─────────────────────────────
|
| 16 |
+
scikit-learn==1.4.1.post1 # TF-IDF vectorizer
|
| 17 |
+
numpy==1.26.4
|
| 18 |
+
|
| 19 |
+
# ── UI ────────────────────────────────────────
|
| 20 |
+
streamlit==1.33.0
|
| 21 |
+
|
| 22 |
+
# ── Utilities ─────────────────────────────────
|
| 23 |
+
pandas==2.2.1 # Useful for data inspection in notebooks
|
src/distractor_generator.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ─────────────────────────────────────────────
|
| 2 |
+
# src/distractor_generator.py (v3)
|
| 3 |
+
# Distractors MUST be the same entity type
|
| 4 |
+
# as the correct answer.
|
| 5 |
+
# e.g. answer=PERSON → distractors are PERSONs
|
| 6 |
+
# answer=DATE → distractors are DATEs
|
| 7 |
+
# ─────────────────────────────────────────────
|
| 8 |
+
|
| 9 |
+
import random
|
| 10 |
+
import sys, os
|
| 11 |
+
|
| 12 |
+
import nltk
|
| 13 |
+
from nltk.corpus import wordnet
|
| 14 |
+
|
| 15 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 16 |
+
from config import NUM_DISTRACTORS
|
| 17 |
+
|
| 18 |
+
nltk.download('wordnet', quiet=True)
|
| 19 |
+
nltk.download('omw-1.4', quiet=True)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def get_same_label_distractors(answer: str, answer_label: str,
|
| 23 |
+
all_entities: list, n: int) -> list:
|
| 24 |
+
"""
|
| 25 |
+
Find entities from the passage that have the SAME NER label as the answer.
|
| 26 |
+
This ensures distractors are always the same type as the answer.
|
| 27 |
+
|
| 28 |
+
all_entities is a list of {"text": str, "label": str} dicts.
|
| 29 |
+
"""
|
| 30 |
+
distractors = []
|
| 31 |
+
seen = {answer.lower()}
|
| 32 |
+
|
| 33 |
+
# First pass: exact same label
|
| 34 |
+
for ent in all_entities:
|
| 35 |
+
if ent["label"] == answer_label and ent["text"].lower() not in seen:
|
| 36 |
+
distractors.append(ent["text"])
|
| 37 |
+
seen.add(ent["text"].lower())
|
| 38 |
+
|
| 39 |
+
return distractors[:n]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def get_wordnet_distractors(answer: str, n: int) -> list:
|
| 43 |
+
"""WordNet hyponym siblings — same semantic category."""
|
| 44 |
+
answer_key = answer.lower().replace(" ", "_")
|
| 45 |
+
distractors = set()
|
| 46 |
+
|
| 47 |
+
synsets = wordnet.synsets(answer_key)
|
| 48 |
+
if not synsets:
|
| 49 |
+
for word in answer.split():
|
| 50 |
+
synsets += wordnet.synsets(word.lower())
|
| 51 |
+
|
| 52 |
+
for synset in synsets[:5]:
|
| 53 |
+
for hypernym in synset.hypernyms():
|
| 54 |
+
for hyponym in hypernym.hyponyms():
|
| 55 |
+
for lemma in hyponym.lemma_names():
|
| 56 |
+
word = lemma.replace("_", " ")
|
| 57 |
+
if word.lower() == answer.lower():
|
| 58 |
+
continue
|
| 59 |
+
if len(word) > 1:
|
| 60 |
+
distractors.add(word.title() if answer[0].isupper() else word)
|
| 61 |
+
if len(distractors) >= n * 3:
|
| 62 |
+
break
|
| 63 |
+
|
| 64 |
+
result = list(distractors)
|
| 65 |
+
random.shuffle(result)
|
| 66 |
+
return result[:n]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def get_distractors(answer: str, all_entities: list,
|
| 70 |
+
passage_doc=None, n: int = NUM_DISTRACTORS) -> list:
|
| 71 |
+
"""
|
| 72 |
+
Main distractor function.
|
| 73 |
+
Strategy:
|
| 74 |
+
1. Same-label entities from the passage (best — contextual + same type)
|
| 75 |
+
2. WordNet siblings (good for common nouns)
|
| 76 |
+
3. Cross-label entities from passage (last resort, still real words)
|
| 77 |
+
Never mixes types if same-label gives enough results.
|
| 78 |
+
"""
|
| 79 |
+
collected = []
|
| 80 |
+
seen = {answer.lower()}
|
| 81 |
+
|
| 82 |
+
def add(candidates):
|
| 83 |
+
for c in candidates:
|
| 84 |
+
if isinstance(c, dict):
|
| 85 |
+
text = c["text"]
|
| 86 |
+
else:
|
| 87 |
+
text = c
|
| 88 |
+
if text.lower() not in seen and text.lower() != answer.lower():
|
| 89 |
+
seen.add(text.lower())
|
| 90 |
+
collected.append(text)
|
| 91 |
+
|
| 92 |
+
# Find the answer's NER label from the entity list
|
| 93 |
+
answer_label = ""
|
| 94 |
+
for ent in all_entities:
|
| 95 |
+
if ent["text"].lower() == answer.lower():
|
| 96 |
+
answer_label = ent["label"]
|
| 97 |
+
break
|
| 98 |
+
# Fuzzy match if exact not found
|
| 99 |
+
if not answer_label:
|
| 100 |
+
for ent in all_entities:
|
| 101 |
+
if answer.lower() in ent["text"].lower():
|
| 102 |
+
answer_label = ent["label"]
|
| 103 |
+
break
|
| 104 |
+
|
| 105 |
+
# Strategy 1: same label from passage
|
| 106 |
+
add(get_same_label_distractors(answer, answer_label, all_entities, n * 2))
|
| 107 |
+
|
| 108 |
+
# Strategy 2: WordNet
|
| 109 |
+
if len(collected) < n:
|
| 110 |
+
add(get_wordnet_distractors(answer, n * 2))
|
| 111 |
+
|
| 112 |
+
# Strategy 3: any other passage entity (cross-label fallback)
|
| 113 |
+
if len(collected) < n:
|
| 114 |
+
add(all_entities) # add() handles dedup
|
| 115 |
+
|
| 116 |
+
# Only if still short, add generic placeholders
|
| 117 |
+
placeholders = ["None of the above", "Cannot be determined", "All of the above"]
|
| 118 |
+
for p in placeholders:
|
| 119 |
+
if len(collected) >= n:
|
| 120 |
+
break
|
| 121 |
+
if p not in collected:
|
| 122 |
+
collected.append(p)
|
| 123 |
+
|
| 124 |
+
return collected[:n]
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
# Simulate entity list from preprocessor
|
| 129 |
+
entities = [
|
| 130 |
+
{"text": "ISRO", "label": "ORG"},
|
| 131 |
+
{"text": "NASA", "label": "ORG"},
|
| 132 |
+
{"text": "ESA", "label": "ORG"},
|
| 133 |
+
{"text": "Vikram Sarabhai", "label": "PERSON"},
|
| 134 |
+
{"text": "Vince McMahon", "label": "PERSON"},
|
| 135 |
+
{"text": "John Cena", "label": "PERSON"},
|
| 136 |
+
{"text": "1969", "label": "DATE"},
|
| 137 |
+
{"text": "1975", "label": "DATE"},
|
| 138 |
+
{"text": "2008", "label": "DATE"},
|
| 139 |
+
{"text": "India", "label": "GPE"},
|
| 140 |
+
{"text": "United States", "label": "GPE"},
|
| 141 |
+
{"text": "China", "label": "GPE"},
|
| 142 |
+
]
|
| 143 |
+
|
| 144 |
+
tests = [
|
| 145 |
+
("Vikram Sarabhai", "PERSON"),
|
| 146 |
+
("1969", "DATE"),
|
| 147 |
+
("India", "GPE"),
|
| 148 |
+
("ISRO", "ORG"),
|
| 149 |
+
]
|
| 150 |
+
|
| 151 |
+
print("=== DISTRACTOR TEST ===\n")
|
| 152 |
+
for answer, label in tests:
|
| 153 |
+
d = get_distractors(answer, entities)
|
| 154 |
+
print(f" Answer ({label:8s}): {answer:20s} → {d}")
|
src/evaluator.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ─────────────────────────────────────────────
|
| 2 |
+
# src/evaluator.py
|
| 3 |
+
# Checks user answers and computes scores.
|
| 4 |
+
# Simple but important — this is what makes
|
| 5 |
+
# the project interactive and demo-worthy.
|
| 6 |
+
# ─────────────────────────────────────────────
|
| 7 |
+
|
| 8 |
+
from src.mcq_builder import MCQ
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# ─────────────────────────────────────────────
|
| 12 |
+
# CHECK A SINGLE ANSWER
|
| 13 |
+
# ─────────────────────────────────────────────
|
| 14 |
+
|
| 15 |
+
def check_answer(mcq: MCQ, user_choice: int) -> bool:
|
| 16 |
+
"""
|
| 17 |
+
Check if the user's selected option index is correct.
|
| 18 |
+
|
| 19 |
+
Parameters:
|
| 20 |
+
mcq : the MCQ object
|
| 21 |
+
user_choice : index 0-3 that the user selected
|
| 22 |
+
|
| 23 |
+
Returns: True if correct, False otherwise
|
| 24 |
+
"""
|
| 25 |
+
return user_choice == mcq.correct_index
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ─────────────────────────────────────────────
|
| 29 |
+
# SCORE A FULL QUIZ
|
| 30 |
+
# ─────────────────────────────────────────────
|
| 31 |
+
|
| 32 |
+
def score_quiz(mcqs: list[MCQ], user_answers: list[int]) -> dict:
|
| 33 |
+
"""
|
| 34 |
+
Score all questions and return a detailed results dict.
|
| 35 |
+
|
| 36 |
+
Parameters:
|
| 37 |
+
mcqs : list of MCQ objects (the quiz)
|
| 38 |
+
user_answers : list of int indices (user's selections, one per MCQ)
|
| 39 |
+
|
| 40 |
+
Returns:
|
| 41 |
+
{
|
| 42 |
+
"score" : 7, ← number correct
|
| 43 |
+
"total" : 10, ← total questions
|
| 44 |
+
"percentage" : 70.0,
|
| 45 |
+
"results" : [ ← per-question details
|
| 46 |
+
{
|
| 47 |
+
"question" : "In what year was ISRO founded?",
|
| 48 |
+
"your_answer" : "1975",
|
| 49 |
+
"correct_answer": "1969",
|
| 50 |
+
"is_correct" : False,
|
| 51 |
+
"explanation" : "ISRO was founded in 1969 by Vikram Sarabhai.",
|
| 52 |
+
},
|
| 53 |
+
...
|
| 54 |
+
]
|
| 55 |
+
}
|
| 56 |
+
"""
|
| 57 |
+
score = 0
|
| 58 |
+
results = []
|
| 59 |
+
|
| 60 |
+
for i, (mcq, user_choice) in enumerate(zip(mcqs, user_answers)):
|
| 61 |
+
is_correct = check_answer(mcq, user_choice)
|
| 62 |
+
if is_correct:
|
| 63 |
+
score += 1
|
| 64 |
+
|
| 65 |
+
results.append({
|
| 66 |
+
"question" : mcq.question,
|
| 67 |
+
"your_answer" : mcq.options[user_choice] if 0 <= user_choice < len(mcq.options) else "No answer",
|
| 68 |
+
"correct_answer" : mcq.correct_answer,
|
| 69 |
+
"is_correct" : is_correct,
|
| 70 |
+
"explanation" : mcq.explanation,
|
| 71 |
+
"all_options" : mcq.options,
|
| 72 |
+
"correct_index" : mcq.correct_index,
|
| 73 |
+
"user_index" : user_choice,
|
| 74 |
+
})
|
| 75 |
+
|
| 76 |
+
total = len(mcqs)
|
| 77 |
+
percentage = round((score / total) * 100, 1) if total > 0 else 0.0
|
| 78 |
+
|
| 79 |
+
# Provide a feedback message based on score
|
| 80 |
+
if percentage >= 80:
|
| 81 |
+
feedback = "Excellent! You have a strong understanding of this passage."
|
| 82 |
+
elif percentage >= 60:
|
| 83 |
+
feedback = "Good effort! Review the explanations for questions you missed."
|
| 84 |
+
elif percentage >= 40:
|
| 85 |
+
feedback = "Fair attempt. Try re-reading the passage and retaking the quiz."
|
| 86 |
+
else:
|
| 87 |
+
feedback = "Keep practising! The explanations below will help you understand."
|
| 88 |
+
|
| 89 |
+
return {
|
| 90 |
+
"score" : score,
|
| 91 |
+
"total" : total,
|
| 92 |
+
"percentage" : percentage,
|
| 93 |
+
"feedback" : feedback,
|
| 94 |
+
"results" : results,
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ─────────────────────────────────────────────
|
| 99 |
+
# QUICK TEST
|
| 100 |
+
# python src/evaluator.py
|
| 101 |
+
# ─────────────────────────────────────────────
|
| 102 |
+
|
| 103 |
+
if __name__ == "__main__":
|
| 104 |
+
# Simulate 3 MCQs without running the full pipeline
|
| 105 |
+
fake_mcqs = [
|
| 106 |
+
MCQ("What year was ISRO founded?",
|
| 107 |
+
["1969", "1975", "1947", "1985"], 0, "1969",
|
| 108 |
+
"ISRO was founded in 1969 by Vikram Sarabhai."),
|
| 109 |
+
MCQ("Who founded ISRO?",
|
| 110 |
+
["Kalam", "Vikram Sarabhai", "Nehru", "Dhawan"], 1, "Vikram Sarabhai",
|
| 111 |
+
"ISRO was founded in 1969 by Vikram Sarabhai."),
|
| 112 |
+
MCQ("What did Chandrayaan-1 discover?",
|
| 113 |
+
["Oxygen", "Iron", "Water molecules", "Helium"], 2, "Water molecules",
|
| 114 |
+
"Chandrayaan-1 discovered water molecules on the Moon."),
|
| 115 |
+
]
|
| 116 |
+
|
| 117 |
+
# Simulate user answers: Q1 correct, Q2 wrong, Q3 correct
|
| 118 |
+
user_answers = [0, 0, 2]
|
| 119 |
+
|
| 120 |
+
result = score_quiz(fake_mcqs, user_answers)
|
| 121 |
+
|
| 122 |
+
print("=== QUIZ RESULTS ===")
|
| 123 |
+
print(f"Score: {result['score']} / {result['total']} ({result['percentage']}%)")
|
| 124 |
+
print(f"Feedback: {result['feedback']}\n")
|
| 125 |
+
for i, r in enumerate(result['results'], 1):
|
| 126 |
+
status = "CORRECT" if r['is_correct'] else "WRONG"
|
| 127 |
+
print(f"Q{i} [{status}] {r['question']}")
|
| 128 |
+
print(f" Your answer : {r['your_answer']}")
|
| 129 |
+
if not r['is_correct']:
|
| 130 |
+
print(f" Correct answer: {r['correct_answer']}")
|
| 131 |
+
print(f" Explanation : {r['explanation'][:80]}...")
|
| 132 |
+
print()
|
src/mcq_builder.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ─────────────────────────────────────────────
|
| 2 |
+
# src/mcq_builder.py (v4)
|
| 3 |
+
# Added strict MCQ quality validation.
|
| 4 |
+
# ─────────────────────────────────────────────
|
| 5 |
+
|
| 6 |
+
import random
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
import sys, os
|
| 9 |
+
|
| 10 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 11 |
+
from config import NUM_DISTRACTORS, MAX_QUESTIONS
|
| 12 |
+
|
| 13 |
+
from src.preprocessor import preprocess
|
| 14 |
+
from src.question_generator import generate_questions
|
| 15 |
+
from src.distractor_generator import get_distractors
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class MCQ:
|
| 20 |
+
question : str
|
| 21 |
+
options : list
|
| 22 |
+
correct_index : int
|
| 23 |
+
correct_answer : str
|
| 24 |
+
explanation : str
|
| 25 |
+
|
| 26 |
+
def display(self):
|
| 27 |
+
print(f"\nQ: {self.question}")
|
| 28 |
+
for i, opt in enumerate(self.options):
|
| 29 |
+
marker = " ✓" if i == self.correct_index else ""
|
| 30 |
+
print(f" {chr(65+i)}. {opt}{marker}")
|
| 31 |
+
print(f" Explanation: {self.explanation[:100]}...")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def are_too_similar(a: str, b: str) -> bool:
|
| 35 |
+
"""
|
| 36 |
+
Check if two option strings are too similar to coexist in the same MCQ.
|
| 37 |
+
Handles cases like "WWE" vs "World Wrestling Entertainment",
|
| 38 |
+
or "ISRO" vs "Indian Space Research Organisation".
|
| 39 |
+
"""
|
| 40 |
+
a_lower = a.lower().strip()
|
| 41 |
+
b_lower = b.lower().strip()
|
| 42 |
+
|
| 43 |
+
# Exact match
|
| 44 |
+
if a_lower == b_lower:
|
| 45 |
+
return True
|
| 46 |
+
|
| 47 |
+
# One is a substring of the other (e.g. "WWE" in "WWE Championship")
|
| 48 |
+
if a_lower in b_lower or b_lower in a_lower:
|
| 49 |
+
return True
|
| 50 |
+
|
| 51 |
+
# Check word overlap ratio — if 60%+ words overlap, too similar
|
| 52 |
+
words_a = set(a_lower.split())
|
| 53 |
+
words_b = set(b_lower.split())
|
| 54 |
+
if not words_a or not words_b:
|
| 55 |
+
return False
|
| 56 |
+
overlap = len(words_a & words_b)
|
| 57 |
+
smaller = min(len(words_a), len(words_b))
|
| 58 |
+
if smaller > 0 and overlap / smaller >= 0.6:
|
| 59 |
+
return True
|
| 60 |
+
|
| 61 |
+
return False
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def deduplicate_options(answer: str, distractors: list) -> list:
|
| 65 |
+
"""
|
| 66 |
+
Remove distractors that are too similar to each other or to the answer.
|
| 67 |
+
Returns a clean list of unique distractors.
|
| 68 |
+
"""
|
| 69 |
+
clean = []
|
| 70 |
+
for d in distractors:
|
| 71 |
+
# Skip if too similar to the correct answer
|
| 72 |
+
if are_too_similar(d, answer):
|
| 73 |
+
continue
|
| 74 |
+
# Skip if too similar to an already-accepted distractor
|
| 75 |
+
if any(are_too_similar(d, accepted) for accepted in clean):
|
| 76 |
+
continue
|
| 77 |
+
clean.append(d)
|
| 78 |
+
return clean
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def is_valid_mcq(question: str, answer: str, options: list) -> tuple:
|
| 82 |
+
"""
|
| 83 |
+
Final quality gate before an MCQ is accepted.
|
| 84 |
+
Returns (is_valid: bool, reason: str).
|
| 85 |
+
"""
|
| 86 |
+
# Answer must appear in options exactly once
|
| 87 |
+
answer_count = sum(1 for o in options if o.lower().strip() == answer.lower().strip())
|
| 88 |
+
if answer_count != 1:
|
| 89 |
+
return False, f"Answer appears {answer_count} times in options"
|
| 90 |
+
|
| 91 |
+
# Must have exactly 4 options
|
| 92 |
+
if len(options) != 4:
|
| 93 |
+
return False, f"Only {len(options)} options"
|
| 94 |
+
|
| 95 |
+
# No two options should be too similar
|
| 96 |
+
for i in range(len(options)):
|
| 97 |
+
for j in range(i + 1, len(options)):
|
| 98 |
+
if are_too_similar(options[i], options[j]):
|
| 99 |
+
return False, f"Options too similar: '{options[i]}' vs '{options[j]}'"
|
| 100 |
+
|
| 101 |
+
# Generic placeholder options are a last resort — skip if more than 1
|
| 102 |
+
generic = {"None of the above", "Cannot be determined",
|
| 103 |
+
"All of the above", "Information not provided"}
|
| 104 |
+
generic_count = sum(1 for o in options if o in generic)
|
| 105 |
+
if generic_count > 1:
|
| 106 |
+
return False, "Too many generic placeholder options"
|
| 107 |
+
|
| 108 |
+
# Question should not just be asking "What is X?" where X is the answer
|
| 109 |
+
q_lower = question.lower()
|
| 110 |
+
a_lower = answer.lower()
|
| 111 |
+
if a_lower in q_lower:
|
| 112 |
+
return False, "Answer already present in question"
|
| 113 |
+
|
| 114 |
+
return True, "OK"
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def build_mcq(question: str, answer: str, distractors: list, explanation: str):
|
| 118 |
+
"""Build and validate one MCQ. Returns MCQ or None if quality check fails."""
|
| 119 |
+
|
| 120 |
+
# Deduplicate distractors against each other and the answer
|
| 121 |
+
clean_distractors = deduplicate_options(answer, distractors)
|
| 122 |
+
|
| 123 |
+
if len(clean_distractors) < 1:
|
| 124 |
+
return None
|
| 125 |
+
|
| 126 |
+
# Pad to 3 if needed (after dedup we might have fewer)
|
| 127 |
+
placeholders = ["None of the above", "Cannot be determined", "All of the above"]
|
| 128 |
+
for p in placeholders:
|
| 129 |
+
if len(clean_distractors) >= NUM_DISTRACTORS:
|
| 130 |
+
break
|
| 131 |
+
if p not in clean_distractors:
|
| 132 |
+
clean_distractors.append(p)
|
| 133 |
+
|
| 134 |
+
options = [answer] + clean_distractors[:NUM_DISTRACTORS]
|
| 135 |
+
random.shuffle(options)
|
| 136 |
+
correct_index = options.index(answer)
|
| 137 |
+
|
| 138 |
+
# Run quality gate
|
| 139 |
+
valid, reason = is_valid_mcq(question, answer, options)
|
| 140 |
+
if not valid:
|
| 141 |
+
print(f" [QC] Rejected MCQ — {reason}: Q='{question[:50]}'")
|
| 142 |
+
return None
|
| 143 |
+
|
| 144 |
+
return MCQ(
|
| 145 |
+
question = question,
|
| 146 |
+
options = options,
|
| 147 |
+
correct_index = correct_index,
|
| 148 |
+
correct_answer = answer,
|
| 149 |
+
explanation = explanation,
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def build_quiz(passage: str, num_questions: int = MAX_QUESTIONS) -> list:
|
| 154 |
+
print(f"\n[Pipeline] Starting for passage ({len(passage)} chars)...")
|
| 155 |
+
|
| 156 |
+
print("[Pipeline] Step 1/3: Preprocessing...")
|
| 157 |
+
prep = preprocess(passage)
|
| 158 |
+
sentence_answers = prep["sentence_answers"]
|
| 159 |
+
all_entities = prep["entities"]
|
| 160 |
+
|
| 161 |
+
if not sentence_answers:
|
| 162 |
+
print("[Pipeline] No suitable sentences found.")
|
| 163 |
+
return []
|
| 164 |
+
|
| 165 |
+
print("[Pipeline] Step 2/3: Generating questions...")
|
| 166 |
+
qa_pairs = generate_questions(sentence_answers)
|
| 167 |
+
|
| 168 |
+
if not qa_pairs:
|
| 169 |
+
print("[Pipeline] No questions generated.")
|
| 170 |
+
return []
|
| 171 |
+
|
| 172 |
+
print(f"[Pipeline] {len(qa_pairs)} candidate question(s) generated.")
|
| 173 |
+
|
| 174 |
+
print("[Pipeline] Step 3/3: Building and validating MCQs...")
|
| 175 |
+
mcqs = []
|
| 176 |
+
|
| 177 |
+
for qa in qa_pairs:
|
| 178 |
+
if len(mcqs) >= num_questions:
|
| 179 |
+
break
|
| 180 |
+
|
| 181 |
+
distractors = get_distractors(
|
| 182 |
+
answer = qa["answer"],
|
| 183 |
+
all_entities = all_entities,
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
mcq = build_mcq(
|
| 187 |
+
question = qa["question"],
|
| 188 |
+
answer = qa["answer"],
|
| 189 |
+
distractors = distractors,
|
| 190 |
+
explanation = qa["sentence"],
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
if mcq is not None:
|
| 194 |
+
mcqs.append(mcq)
|
| 195 |
+
|
| 196 |
+
print(f"[Pipeline] Done. {len(mcqs)} valid MCQ(s) built.")
|
| 197 |
+
|
| 198 |
+
if len(mcqs) == 0:
|
| 199 |
+
print("\n[Pipeline] NOTICE: Could not build valid MCQs from this passage.")
|
| 200 |
+
print(" This usually means the passage lacks specific named facts.")
|
| 201 |
+
print(" Try a factual passage with: people names, places, dates, organisations.")
|
| 202 |
+
|
| 203 |
+
return mcqs
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
if __name__ == "__main__":
|
| 207 |
+
# Test with ISRO passage (factual — should work well)
|
| 208 |
+
passage = """
|
| 209 |
+
The Indian Space Research Organisation (ISRO) was founded in 1969 by Vikram Sarabhai.
|
| 210 |
+
It is headquartered in Bengaluru, Karnataka. ISRO developed India's first satellite,
|
| 211 |
+
Aryabhata, which was launched in 1975. The Chandrayaan-1 mission in 2008 discovered
|
| 212 |
+
water molecules on the Moon. In 2023, Chandrayaan-3 successfully landed near the
|
| 213 |
+
lunar south pole, making India the fourth country to achieve a Moon landing.
|
| 214 |
+
The Mars Orbiter Mission, also called Mangalyaan, was launched in 2013 and made
|
| 215 |
+
India the first Asian country to reach Martian orbit.
|
| 216 |
+
"""
|
| 217 |
+
|
| 218 |
+
mcqs = build_quiz(passage, num_questions=5)
|
| 219 |
+
print("\n========== GENERATED QUIZ ==========")
|
| 220 |
+
for i, mcq in enumerate(mcqs, 1):
|
| 221 |
+
print(f"\n--- Question {i} ---")
|
| 222 |
+
mcq.display()
|
src/preprocessor.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ─────────────────────────────────────────────
|
| 2 |
+
# src/preprocessor.py (v3)
|
| 3 |
+
# ─────────────────────────────────────────────
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
import spacy
|
| 7 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 8 |
+
import numpy as np
|
| 9 |
+
import sys, os
|
| 10 |
+
|
| 11 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 12 |
+
from config import SPACY_MODEL, TOP_SENTENCES, MIN_SENTENCE_LENGTH
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
nlp = spacy.load(SPACY_MODEL)
|
| 16 |
+
except OSError:
|
| 17 |
+
print(f"[ERROR] Run: python -m spacy download {SPACY_MODEL}")
|
| 18 |
+
raise
|
| 19 |
+
|
| 20 |
+
# Only these NER labels make meaningful quiz answers
|
| 21 |
+
GOOD_NER_LABELS = {
|
| 22 |
+
"PERSON", "ORG", "GPE", "LOC",
|
| 23 |
+
"DATE", "EVENT", "WORK_OF_ART",
|
| 24 |
+
"NORP", "FAC", "PRODUCT",
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
# Hard blacklist — never use these as answers
|
| 28 |
+
BLACKLIST = {
|
| 29 |
+
"annual", "various", "many", "several", "some", "other",
|
| 30 |
+
"new", "old", "big", "large", "small", "high", "low",
|
| 31 |
+
"one", "two", "three", "four", "five", "first", "second",
|
| 32 |
+
"today", "yesterday", "now", "then", "later", "also",
|
| 33 |
+
"he", "she", "it", "they", "we", "i", "the", "a", "an",
|
| 34 |
+
"moon", "sun", "earth",
|
| 35 |
+
"india", "america", "china", "russia", "england", "world", # too broad
|
| 36 |
+
"isro", "nasa", "wwe", "un", "who", # abbreviations make circular Qs
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
# Prefer answers with these labels — they make the clearest questions
|
| 40 |
+
HIGH_PRIORITY_LABELS = {"PERSON", "ORG", "GPE", "LOC", "EVENT", "WORK_OF_ART", "FAC", "PRODUCT"}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def extract_sentences(text: str) -> list:
|
| 44 |
+
doc = nlp(text)
|
| 45 |
+
sentences = []
|
| 46 |
+
for sent in doc.sents:
|
| 47 |
+
clean = sent.text.strip()
|
| 48 |
+
word_count = len([t for t in sent if not t.is_space and not t.is_punct])
|
| 49 |
+
if word_count >= MIN_SENTENCE_LENGTH:
|
| 50 |
+
sentences.append(clean)
|
| 51 |
+
return sentences
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def rank_sentences(sentences: list, top_n: int = TOP_SENTENCES) -> list:
|
| 55 |
+
if len(sentences) <= top_n:
|
| 56 |
+
return sentences
|
| 57 |
+
vectorizer = TfidfVectorizer(stop_words='english')
|
| 58 |
+
tfidf_matrix = vectorizer.fit_transform(sentences)
|
| 59 |
+
scores = np.array(tfidf_matrix.sum(axis=1)).flatten()
|
| 60 |
+
top_indices = sorted(np.argsort(scores)[::-1][:top_n])
|
| 61 |
+
return [sentences[i] for i in top_indices]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def is_good_answer(text: str, label: str) -> bool:
|
| 65 |
+
t = text.strip()
|
| 66 |
+
|
| 67 |
+
if len(t) < 2:
|
| 68 |
+
return False
|
| 69 |
+
|
| 70 |
+
# Reject blacklisted words (case-insensitive)
|
| 71 |
+
if t.lower() in BLACKLIST:
|
| 72 |
+
return False
|
| 73 |
+
|
| 74 |
+
# Must be an allowed NER label
|
| 75 |
+
if label not in GOOD_NER_LABELS:
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
# Single lowercase word with no capitals = probably not a proper noun
|
| 79 |
+
if len(t.split()) == 1 and t[0].islower() and not t.isdigit():
|
| 80 |
+
return False
|
| 81 |
+
|
| 82 |
+
# Reject very long phrases (>5 words) — hard to use as MCQ answers
|
| 83 |
+
if len(t.split()) > 5:
|
| 84 |
+
return False
|
| 85 |
+
|
| 86 |
+
return True
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def extract_answer_candidates(sentence: str) -> list:
|
| 90 |
+
"""
|
| 91 |
+
Extract answer candidates from a sentence.
|
| 92 |
+
Returns high-priority entities first, then dates/others.
|
| 93 |
+
Only ONE answer per sentence is ultimately used (the best one).
|
| 94 |
+
"""
|
| 95 |
+
doc = nlp(sentence)
|
| 96 |
+
|
| 97 |
+
high = [] # PERSON, ORG, GPE, etc.
|
| 98 |
+
low = [] # DATE, QUANTITY, etc.
|
| 99 |
+
seen = set()
|
| 100 |
+
|
| 101 |
+
for ent in doc.ents:
|
| 102 |
+
text = ent.text.strip()
|
| 103 |
+
label = ent.label_
|
| 104 |
+
|
| 105 |
+
if not is_good_answer(text, label):
|
| 106 |
+
continue
|
| 107 |
+
if text.lower() in seen:
|
| 108 |
+
continue
|
| 109 |
+
|
| 110 |
+
seen.add(text.lower())
|
| 111 |
+
|
| 112 |
+
if label in HIGH_PRIORITY_LABELS:
|
| 113 |
+
high.append(text)
|
| 114 |
+
else:
|
| 115 |
+
low.append(text)
|
| 116 |
+
|
| 117 |
+
# Return high-priority first, then dates/quantities
|
| 118 |
+
return high + low
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def preprocess(text: str) -> dict:
|
| 122 |
+
text = re.sub(r'\s+', ' ', text).strip()
|
| 123 |
+
all_sentences = extract_sentences(text)
|
| 124 |
+
top_sentences = rank_sentences(all_sentences)
|
| 125 |
+
sentence_answers = {}
|
| 126 |
+
|
| 127 |
+
for sent in top_sentences:
|
| 128 |
+
candidates = extract_answer_candidates(sent)
|
| 129 |
+
if candidates:
|
| 130 |
+
sentence_answers[sent] = candidates
|
| 131 |
+
|
| 132 |
+
doc = nlp(text)
|
| 133 |
+
# Store entities WITH their labels for the distractor generator
|
| 134 |
+
all_entities = []
|
| 135 |
+
seen = set()
|
| 136 |
+
for ent in doc.ents:
|
| 137 |
+
if is_good_answer(ent.text.strip(), ent.label_) and ent.text.lower() not in seen:
|
| 138 |
+
seen.add(ent.text.lower())
|
| 139 |
+
all_entities.append({"text": ent.text.strip(), "label": ent.label_})
|
| 140 |
+
|
| 141 |
+
return {
|
| 142 |
+
"all_sentences" : all_sentences,
|
| 143 |
+
"top_sentences" : top_sentences,
|
| 144 |
+
"sentence_answers" : sentence_answers,
|
| 145 |
+
"entities" : all_entities, # now list of {"text":..,"label":..}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
if __name__ == "__main__":
|
| 150 |
+
sample = """
|
| 151 |
+
The Indian Space Research Organisation (ISRO) was founded in 1969 by Vikram Sarabhai.
|
| 152 |
+
ISRO developed India's first satellite, Aryabhata, which was launched in 1975.
|
| 153 |
+
The Chandrayaan-1 mission in 2008 discovered water molecules on the Moon.
|
| 154 |
+
In 2023, Chandrayaan-3 successfully landed near the lunar south pole, making India
|
| 155 |
+
the fourth country to achieve a Moon landing.
|
| 156 |
+
The Mars Orbiter Mission, also called Mangalyaan, was launched in 2013.
|
| 157 |
+
"""
|
| 158 |
+
result = preprocess(sample)
|
| 159 |
+
print("=== SENTENCE → CANDIDATES ===")
|
| 160 |
+
for sent, ans in result['sentence_answers'].items():
|
| 161 |
+
print(f" Source : {sent[:75]}")
|
| 162 |
+
print(f" Answers: {ans}\n")
|
| 163 |
+
print("=== ALL ENTITIES (for distractors) ===")
|
| 164 |
+
for e in result['entities']:
|
| 165 |
+
print(f" {e['label']:15s} {e['text']}")
|
src/question_generator.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ─────────────────────────────────────────────
|
| 2 |
+
# src/question_generator.py (v4)
|
| 3 |
+
# Key fix: validate that the generated question
|
| 4 |
+
# actually targets the intended answer.
|
| 5 |
+
# Also filters circular questions like
|
| 6 |
+
# "What is the name of X?" when answer IS X.
|
| 7 |
+
# ─────────────────────────────────────────────
|
| 8 |
+
|
| 9 |
+
from transformers import pipeline
|
| 10 |
+
import re
|
| 11 |
+
import sys, os
|
| 12 |
+
|
| 13 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 14 |
+
from config import QG_MODEL_NAME, MAX_QUESTIONS
|
| 15 |
+
|
| 16 |
+
print(f"[INFO] Loading QG model: {QG_MODEL_NAME} ...")
|
| 17 |
+
import warnings
|
| 18 |
+
warnings.filterwarnings("ignore") # suppress HuggingFace FutureWarnings
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
qg_pipeline = pipeline(
|
| 22 |
+
"text2text-generation",
|
| 23 |
+
model = QG_MODEL_NAME,
|
| 24 |
+
tokenizer = QG_MODEL_NAME,
|
| 25 |
+
)
|
| 26 |
+
print("[INFO] Model loaded.")
|
| 27 |
+
except Exception as e:
|
| 28 |
+
print(f"[ERROR] {e}")
|
| 29 |
+
raise
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def highlight_answer(sentence: str, answer: str) -> str:
|
| 33 |
+
"""Wrap answer with <hl> tags for the T5 model."""
|
| 34 |
+
pattern = re.compile(re.escape(answer), re.IGNORECASE)
|
| 35 |
+
result = pattern.sub(f"<hl> {answer} <hl>", sentence, count=1)
|
| 36 |
+
return result
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def answer_is_addressable(question: str, answer: str) -> bool:
|
| 40 |
+
"""
|
| 41 |
+
Check that the question is actually ASKING FOR the answer.
|
| 42 |
+
|
| 43 |
+
Rejects:
|
| 44 |
+
- Circular: answer text appears in the question
|
| 45 |
+
e.g. Q: "What is the name of ISRO?" A: "The Indian Space Research Organisation"
|
| 46 |
+
(ISRO is an abbreviation of the answer — circular)
|
| 47 |
+
- Too vague: question is only 4 words or fewer
|
| 48 |
+
- No question word
|
| 49 |
+
- Answer is a substring of the question
|
| 50 |
+
"""
|
| 51 |
+
q = question.strip()
|
| 52 |
+
a = answer.strip()
|
| 53 |
+
|
| 54 |
+
# Must end with ?
|
| 55 |
+
if not q.endswith("?"):
|
| 56 |
+
return False
|
| 57 |
+
|
| 58 |
+
# Must have a question word
|
| 59 |
+
q_lower = q.lower()
|
| 60 |
+
if not any(q_lower.startswith(w) for w in
|
| 61 |
+
["what", "who", "when", "where", "which", "how", "why"]):
|
| 62 |
+
return False
|
| 63 |
+
|
| 64 |
+
# Must be at least 5 words
|
| 65 |
+
if len(q.split()) < 5:
|
| 66 |
+
return False
|
| 67 |
+
|
| 68 |
+
# Answer must NOT appear verbatim in the question
|
| 69 |
+
if a.lower() in q_lower:
|
| 70 |
+
return False
|
| 71 |
+
|
| 72 |
+
# Check abbreviation trap: if any word in the question is an abbreviation
|
| 73 |
+
# of the answer (e.g. "ISRO" in question, answer is "Indian Space Research...")
|
| 74 |
+
answer_words = [w.lower() for w in a.split() if len(w) > 1]
|
| 75 |
+
abbrev = "".join(w[0] for w in answer_words if w.isalpha())
|
| 76 |
+
if len(abbrev) >= 2 and abbrev.lower() in q_lower:
|
| 77 |
+
return False
|
| 78 |
+
|
| 79 |
+
# Reject questions asking about name/abbreviation — usually circular
|
| 80 |
+
circular_patterns = [
|
| 81 |
+
r"what (is|was|were) the (full |official )?name",
|
| 82 |
+
r"what (does|did) .{1,10} stand for",
|
| 83 |
+
r"what (is|was) the abbreviation",
|
| 84 |
+
r"what (is|was) .{1,15} also (known|called)",
|
| 85 |
+
]
|
| 86 |
+
for pat in circular_patterns:
|
| 87 |
+
if re.search(pat, q_lower):
|
| 88 |
+
return False
|
| 89 |
+
|
| 90 |
+
return True
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def generate_question(sentence: str, answer: str) -> str | None:
|
| 94 |
+
"""
|
| 95 |
+
Generate a question for a (sentence, answer) pair.
|
| 96 |
+
Returns the best valid question string, or None.
|
| 97 |
+
"""
|
| 98 |
+
highlighted = highlight_answer(sentence, answer)
|
| 99 |
+
input_text = f"generate question: {highlighted}"
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
outputs = qg_pipeline(
|
| 103 |
+
input_text,
|
| 104 |
+
max_new_tokens = 64,
|
| 105 |
+
num_beams = 5,
|
| 106 |
+
num_return_sequences = 3,
|
| 107 |
+
early_stopping = True,
|
| 108 |
+
)
|
| 109 |
+
except Exception as e:
|
| 110 |
+
print(f" [QG] Generation error: {e}")
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
for output in outputs:
|
| 114 |
+
q = output["generated_text"].strip()
|
| 115 |
+
if not q.endswith("?"):
|
| 116 |
+
q += "?"
|
| 117 |
+
if answer_is_addressable(q, answer):
|
| 118 |
+
return q
|
| 119 |
+
|
| 120 |
+
return None
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def generate_questions(sentence_answers: dict) -> list:
|
| 124 |
+
"""
|
| 125 |
+
For each (sentence → answer candidates), generate one good question.
|
| 126 |
+
Tries each answer candidate in priority order until one works.
|
| 127 |
+
"""
|
| 128 |
+
results = []
|
| 129 |
+
|
| 130 |
+
for sentence, candidates in sentence_answers.items():
|
| 131 |
+
if len(results) >= MAX_QUESTIONS:
|
| 132 |
+
break
|
| 133 |
+
|
| 134 |
+
generated = False
|
| 135 |
+
for answer in candidates:
|
| 136 |
+
if len(answer.strip()) < 2:
|
| 137 |
+
continue
|
| 138 |
+
|
| 139 |
+
question = generate_question(sentence, answer)
|
| 140 |
+
|
| 141 |
+
if question:
|
| 142 |
+
print(f" [QG] ✓ Q: {question}")
|
| 143 |
+
print(f" A: {answer}")
|
| 144 |
+
results.append({
|
| 145 |
+
"question" : question,
|
| 146 |
+
"answer" : answer,
|
| 147 |
+
"sentence" : sentence,
|
| 148 |
+
})
|
| 149 |
+
generated = True
|
| 150 |
+
break
|
| 151 |
+
else:
|
| 152 |
+
print(f" [QG] ✗ Rejected for answer '{answer}'")
|
| 153 |
+
|
| 154 |
+
if not generated:
|
| 155 |
+
print(f" [QG] — No valid question for: '{sentence[:60]}'")
|
| 156 |
+
|
| 157 |
+
return results
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
if __name__ == "__main__":
|
| 161 |
+
tests = [
|
| 162 |
+
# Good cases — specific named answers
|
| 163 |
+
("ISRO was founded in 1969 by Vikram Sarabhai.", "Vikram Sarabhai"),
|
| 164 |
+
("Aryabhata was India's first satellite, launched in 1975.", "Aryabhata"),
|
| 165 |
+
("The Chandrayaan-1 mission in 2008 discovered water on the Moon.", "2008"),
|
| 166 |
+
("Chandrayaan-3 landed near the lunar south pole in 2023.", "Chandrayaan-3"),
|
| 167 |
+
("The Taj Mahal was built by Shah Jahan in 1632 in Agra.", "Shah Jahan"),
|
| 168 |
+
# Bad cases — should all be rejected
|
| 169 |
+
("The Indian Space Research Organisation (ISRO) was founded in 1969.", "The Indian Space Research Organisation"),
|
| 170 |
+
("ISRO developed India's first satellite.", "India"),
|
| 171 |
+
]
|
| 172 |
+
|
| 173 |
+
print("\n=== QUESTION GENERATION TEST ===\n")
|
| 174 |
+
for sentence, answer in tests:
|
| 175 |
+
q = generate_question(sentence, answer)
|
| 176 |
+
status = "✓" if q else "✗ (rejected)"
|
| 177 |
+
print(f" [{status}]")
|
| 178 |
+
print(f" Sentence: {sentence}")
|
| 179 |
+
print(f" Answer : {answer}")
|
| 180 |
+
print(f" Question: {q}\n")
|