tanmmayyy commited on
Commit
73633b5
·
1 Parent(s): 9b89ab7

Initial commit — MCQ Generator with T5 + NER + WordNet

Browse files
.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")