Files changed (1) hide show
  1. app.py +70 -149
app.py CHANGED
@@ -1,4 +1,3 @@
1
- # app.py
2
  import json
3
  import random
4
  import csv
@@ -8,26 +7,16 @@ from pathlib import Path
8
  import gradio as gr
9
 
10
  # ---------- Helpers ----------
11
- def load_questions_from_uploaded_or_repo(uploaded_file, repo_path="questions.json"):
12
- """
13
- Expect JSON of one of the following shapes:
14
- - A list of objects: [{"question":"...","options":["A","B","C","D"],"answer":"A" or 0}, ...]
15
- - Or { "questions": [ ... ] }
16
- The 'answer' can be index (int) or option text (str).
17
- """
18
- data = None
19
  if uploaded_file is not None:
20
- # uploaded_file is a tempfile path
21
  with open(uploaded_file.name, "r", encoding="utf-8") as f:
22
  data = json.load(f)
23
  else:
24
- # fallback to repo_path
25
  if not Path(repo_path).exists():
26
  return []
27
  with open(repo_path, "r", encoding="utf-8") as f:
28
  data = json.load(f)
29
 
30
- # Normalize
31
  if isinstance(data, dict) and "questions" in data:
32
  data = data["questions"]
33
  if not isinstance(data, list):
@@ -35,192 +24,124 @@ def load_questions_from_uploaded_or_repo(uploaded_file, repo_path="questions.jso
35
 
36
  normalized = []
37
  for item in data:
38
- q = {}
39
- q_text = item.get("question") or item.get("q") or item.get("prompt")
40
- opts = item.get("options") or item.get("choices") or item.get("opts")
41
- ans = item.get("answer") or item.get("correct") or item.get("ans")
42
  if q_text is None or not opts:
43
  continue
44
- # ensure options is list of strings
45
  opts = [str(o) for o in opts]
46
- # canonicalize answer to index if possible
47
  answer_index = None
48
- if isinstance(ans, int):
49
- answer_index = ans if 0 <= ans < len(opts) else None
50
  elif isinstance(ans, str):
51
- # try exact match first
52
  if ans in opts:
53
  answer_index = opts.index(ans)
54
  else:
55
- # maybe ans is 'A','B','C' or '1','2' etc.
56
- mapping = {chr(65+i): i for i in range(len(opts))} # A->0, B->1...
57
  if ans.upper() in mapping:
58
  answer_index = mapping[ans.upper()]
59
- else:
60
- try:
61
- idx = int(ans) - 1
62
- if 0 <= idx < len(opts):
63
- answer_index = idx
64
- except:
65
- answer_index = None
66
- # push normalized
67
  normalized.append({
68
- "question": str(q_text),
69
  "options": opts,
70
- "answer_index": answer_index # may be None (we'll treat as ungradable)
71
  })
72
  return normalized
73
 
74
  def sample_questions(q_list, n=100):
75
- if len(q_list) <= n:
76
- return q_list.copy()
77
- return random.sample(q_list, n)
78
 
79
  def score_answers(sampled, answers):
80
- # answers: list of chosen index (or None)
81
- correct = 0
82
- total_gradable = 0
83
  per_q = []
84
  for q, a in zip(sampled, answers):
85
- ai = a
86
  correct_idx = q.get("answer_index")
87
  if correct_idx is None:
88
- # ungradable
89
  is_correct = None
90
  else:
91
- total_gradable += 1
92
- is_correct = (ai == correct_idx)
93
  if is_correct:
94
  correct += 1
95
- per_q.append({
96
- "question": q["question"],
97
- "options": q["options"],
98
- "correct_index": correct_idx,
99
- "chosen_index": ai,
100
- "is_correct": is_correct
101
- })
102
- percentage = (correct / total_gradable * 100) if total_gradable > 0 else 0.0
103
- return correct, total_gradable, percentage, per_q
104
 
105
- def results_to_csv(personal_info, per_q, out_dir="/tmp"):
106
  ts = int(time.time())
107
- fname = f"test_result_{personal_info['name'].replace(' ','_')}_{ts}.csv"
108
- path = os.path.join(out_dir, fname)
109
  os.makedirs(out_dir, exist_ok=True)
110
- with open(path, "w", newline='', encoding="utf-8") as csvfile:
111
- writer = csv.writer(csvfile)
112
- # header
113
- writer.writerow(["Name", personal_info.get("name",""),
114
- "Father's Name", personal_info.get("father",""),
115
- "Contact", personal_info.get("contact","")])
116
  writer.writerow([])
117
- writer.writerow(["Q#", "Question", "Option_1", "Option_2", "Option_3", "Option_4", "Chosen_Index", "Correct_Index", "Is_Correct"])
118
- for i, q in enumerate(per_q, start=1):
119
- opts = q["options"] + [""]* (4 - len(q["options"])) if len(q["options"]) < 4 else q["options"]
120
- writer.writerow([i, q["question"], opts[0], opts[1], opts[2] if len(opts)>2 else "", opts[3] if len(opts)>3 else "", q["chosen_index"], q["correct_index"], q["is_correct"]])
121
  return path
122
 
123
- # ---------- Gradio app ----------
124
  with gr.Blocks(title="Chemical Engineering MCQ Tester") as demo:
125
- gr.Markdown("# Chemical Engineering MCQ Tester\nUpload your JSON question bank (or keep repo file `questions.json`) → Start test → answer 100 randomized MCQs → download results")
126
 
127
  with gr.Row():
128
  with gr.Column(scale=1):
129
- name = gr.Textbox(label="Name", placeholder="Candidate full name", interactive=True)
130
- father = gr.Textbox(label="Father's Name", placeholder="Father's full name", interactive=True)
131
- contact = gr.Textbox(label="Contact Number", placeholder="e.g. +92-300-1234567", interactive=True)
132
- upload = gr.File(label="Upload JSON question bank (optional)", file_count="single", file_types=[".json"])
133
- generate = gr.Button("Start Test (generate 100 random MCQs)")
134
  status = gr.Textbox(label="Status", interactive=False)
135
  with gr.Column(scale=2):
136
- progress_text = gr.Markdown("**Test will appear here after generation.**")
137
  q_number = gr.Markdown("")
138
  q_text = gr.Markdown("")
139
- options_radios = gr.Radio(choices=[], label="Options", value=None)
140
- nav_row = gr.Row()
141
  prev_btn = gr.Button("Previous")
142
  next_btn = gr.Button("Next")
143
  finish_btn = gr.Button("Finish & Grade")
144
- score_out = gr.Textbox(label="Score (percentage)", interactive=False)
145
- download_file = gr.File(label="Download results (CSV)", value=None)
146
 
147
- # App state
148
- sampled_state = gr.State(value=[])
149
- answers_state = gr.State(value=[]) # list of chosen indices
150
- idx_state = gr.State(value=0)
151
 
152
- # Start / Generate
153
  def start_test(uploaded_file, name_v, father_v, contact_v):
154
  q_list = load_questions_from_uploaded_or_repo(uploaded_file)
155
  if not q_list:
156
- return gr.update(status="No valid questions found. Make sure JSON is correct or include a repo 'questions.json'."), [], [], 0, "", "", [], None
157
- sampled = sample_questions(q_list, n=100)
158
  answers = [None] * len(sampled)
159
- # prepare first question display
160
- i = 0
161
- q = sampled[i]
162
- opts = q["options"]
163
- # update status
164
- status_msg = f"Generated {len(sampled)} questions. Good luck, {name_v or 'Candidate'}!"
165
- qnum_md = f"**Question {i+1} of {len(sampled)}**"
166
- qtext_md = f"**{q['question']}**"
167
- return status_msg, sampled, answers, i, qnum_md, qtext_md, opts, None
168
-
169
- generate.click(fn=start_test, inputs=[upload, name, father, contact], outputs=[status, sampled_state, answers_state, idx_state, q_number, q_text, options_radios, download_file])
170
-
171
- # Navigation: record current answer then move index
172
- def save_and_move(sampled, answers, idx, chosen, move, name_v):
173
- # record
174
  if sampled and 0 <= idx < len(sampled):
175
- answers[idx] = None if chosen is None else int(chosen)
176
- # move (move can be -1 or +1 or 0)
177
- new_idx = idx + move
178
- new_idx = max(0, min(new_idx, len(sampled)-1))
179
- # prepare display
180
  q = sampled[new_idx]
181
- opts = q["options"]
182
- qnum_md = f"**Question {new_idx+1} of {len(sampled)}**"
183
- qtext_md = f"**{q['question']}**"
184
- # current selected value for radio is answers[new_idx] if not None
185
- sel = answers[new_idx]
186
- return sampled, answers, new_idx, qnum_md, qtext_md, opts, sel
187
-
188
- prev_btn.click(fn=save_and_move, inputs=[sampled_state, answers_state, idx_state, options_radios, gr.State(-1), name], outputs=[sampled_state, answers_state, idx_state, q_number, q_text, options_radios, options_radios])
189
- next_btn.click(fn=save_and_move, inputs=[sampled_state, answers_state, idx_state, options_radios, gr.State(1), name], outputs=[sampled_state, answers_state, idx_state, q_number, q_text, options_radios, options_radios])
190
-
191
- # Finish & grade
192
- def finish_and_grade(sampled, answers, idx, chosen, name_v, father_v, contact_v):
193
- # save current question answer
194
  if sampled and 0 <= idx < len(sampled):
195
- answers[idx] = None if chosen is None else int(chosen)
196
- # score
197
- correct, total_gradable, percentage, per_q = score_answers(sampled, answers)
198
- personal = {"name": name_v or "", "father": father_v or "", "contact": contact_v or ""}
199
- csv_path = results_to_csv(personal, per_q, out_dir="results")
200
- status_msg = f"Scored {correct} out of {total_gradable} gradable questions. Percentage: {percentage:.2f}%"
201
- return f"{percentage:.2f}%", gr.File.update(value=csv_path), status_msg
202
-
203
- finish_btn.click(fn=finish_and_grade, inputs=[sampled_state, answers_state, idx_state, options_radios, name, father, contact], outputs=[score_out, download_file, status])
204
-
205
- # When a question is shown, radio choices must be numeric indices
206
- # Prepare radio choices converter: When options change, set radio choices to indices with display labels
207
- def options_to_radio_choices(opts):
208
- # build display: "0 — Option text" but we prefer "A) text", "B) text"
209
- labels = []
210
- for i, o in enumerate(opts):
211
- label_letter = chr(65 + i) # A, B, C...
212
- labels.append(f"{i}") # store as numeric string value; display is handled by options text above
213
- return gr.update(choices=labels, value=None)
214
-
215
- options_radios.change(fn=lambda opts: gr.update(), inputs=options_radios, outputs=options_radios)
216
-
217
- # Small tweak: when q_text or options update, show options as visible list for readability
218
- def display_options_for_readability(opts):
219
- if not opts:
220
- return ""
221
- md = "\n".join([f"**{chr(65+i)})** {o}" for i, o in enumerate(opts)])
222
- return md
223
-
224
- options_radios.change(fn=lambda opts: display_options_for_readability(options_radios.value), inputs=[options_radios], outputs=[progress_text])
225
-
226
- demo.launch(server_name="0.0.0.0", share=False)
 
 
1
  import json
2
  import random
3
  import csv
 
7
  import gradio as gr
8
 
9
  # ---------- Helpers ----------
10
+ def load_questions_from_uploaded_or_repo(uploaded_file, repo_path="./questions.json"):
 
 
 
 
 
 
 
11
  if uploaded_file is not None:
 
12
  with open(uploaded_file.name, "r", encoding="utf-8") as f:
13
  data = json.load(f)
14
  else:
 
15
  if not Path(repo_path).exists():
16
  return []
17
  with open(repo_path, "r", encoding="utf-8") as f:
18
  data = json.load(f)
19
 
 
20
  if isinstance(data, dict) and "questions" in data:
21
  data = data["questions"]
22
  if not isinstance(data, list):
 
24
 
25
  normalized = []
26
  for item in data:
27
+ q_text = item.get("question")
28
+ opts = item.get("options")
29
+ ans = item.get("answer")
 
30
  if q_text is None or not opts:
31
  continue
 
32
  opts = [str(o) for o in opts]
 
33
  answer_index = None
34
+ if isinstance(ans, int) and 0 <= ans < len(opts):
35
+ answer_index = ans
36
  elif isinstance(ans, str):
 
37
  if ans in opts:
38
  answer_index = opts.index(ans)
39
  else:
40
+ mapping = {chr(65+i): i for i in range(len(opts))}
 
41
  if ans.upper() in mapping:
42
  answer_index = mapping[ans.upper()]
 
 
 
 
 
 
 
 
43
  normalized.append({
44
+ "question": q_text,
45
  "options": opts,
46
+ "answer_index": answer_index
47
  })
48
  return normalized
49
 
50
  def sample_questions(q_list, n=100):
51
+ return q_list if len(q_list) <= n else random.sample(q_list, n)
 
 
52
 
53
  def score_answers(sampled, answers):
54
+ correct, total = 0, 0
 
 
55
  per_q = []
56
  for q, a in zip(sampled, answers):
 
57
  correct_idx = q.get("answer_index")
58
  if correct_idx is None:
 
59
  is_correct = None
60
  else:
61
+ total += 1
62
+ is_correct = (a == correct_idx)
63
  if is_correct:
64
  correct += 1
65
+ per_q.append({**q, "chosen_index": a, "is_correct": is_correct})
66
+ percentage = (correct / total * 100) if total > 0 else 0
67
+ return correct, total, percentage, per_q
 
 
 
 
 
 
68
 
69
+ def results_to_csv(info, per_q, out_dir="results"):
70
  ts = int(time.time())
71
+ fname = f"test_result_{info['name'].replace(' ','_')}_{ts}.csv"
 
72
  os.makedirs(out_dir, exist_ok=True)
73
+ path = os.path.join(out_dir, fname)
74
+ with open(path, "w", newline='', encoding="utf-8") as f:
75
+ writer = csv.writer(f)
76
+ writer.writerow(["Name", info.get("name",""),
77
+ "Father's Name", info.get("father",""),
78
+ "Contact", info.get("contact","")])
79
  writer.writerow([])
80
+ writer.writerow(["Q#", "Question", "Options", "Chosen", "Correct", "Is_Correct"])
81
+ for i, q in enumerate(per_q, 1):
82
+ writer.writerow([i, q["question"], "; ".join(q["options"]),
83
+ q["chosen_index"], q["answer_index"], q["is_correct"]])
84
  return path
85
 
86
+ # ---------- Gradio UI ----------
87
  with gr.Blocks(title="Chemical Engineering MCQ Tester") as demo:
88
+ gr.Markdown("# Chemical Engineering MCQ Tester")
89
 
90
  with gr.Row():
91
  with gr.Column(scale=1):
92
+ name = gr.Textbox(label="Name")
93
+ father = gr.Textbox(label="Father's Name")
94
+ contact = gr.Textbox(label="Contact Number")
95
+ upload = gr.File(label="Upload JSON question bank (optional)", file_types=[".json"])
96
+ start_btn = gr.Button("Start Test")
97
  status = gr.Textbox(label="Status", interactive=False)
98
  with gr.Column(scale=2):
 
99
  q_number = gr.Markdown("")
100
  q_text = gr.Markdown("")
101
+ options = gr.Radio(choices=[], label="Options")
102
+ nav = gr.Row()
103
  prev_btn = gr.Button("Previous")
104
  next_btn = gr.Button("Next")
105
  finish_btn = gr.Button("Finish & Grade")
106
+ score_out = gr.Textbox(label="Score", interactive=False)
107
+ download_file = gr.File(label="Download results")
108
 
109
+ sampled_state = gr.State([])
110
+ answers_state = gr.State([])
111
+ idx_state = gr.State(0)
 
112
 
 
113
  def start_test(uploaded_file, name_v, father_v, contact_v):
114
  q_list = load_questions_from_uploaded_or_repo(uploaded_file)
115
  if not q_list:
116
+ return "No valid questions found.", [], [], 0, "", "", []
117
+ sampled = sample_questions(q_list, 100)
118
  answers = [None] * len(sampled)
119
+ q = sampled[0]
120
+ return f"Loaded {len(sampled)} questions.", sampled, answers, 0, f"**Q1 of {len(sampled)}**", q["question"], q["options"]
121
+
122
+ start_btn.click(start_test, [upload, name, father, contact],
123
+ [status, sampled_state, answers_state, idx_state, q_number, q_text, options])
124
+
125
+ def navigate(sampled, answers, idx, chosen, move):
 
 
 
 
 
 
 
 
126
  if sampled and 0 <= idx < len(sampled):
127
+ answers[idx] = chosen
128
+ new_idx = max(0, min(idx + move, len(sampled)-1))
 
 
 
129
  q = sampled[new_idx]
130
+ return answers, new_idx, f"**Q{new_idx+1} of {len(sampled)}**", q["question"], q["options"], answers[new_idx]
131
+
132
+ prev_btn.click(navigate, [sampled_state, answers_state, idx_state, options, gr.State(-1)],
133
+ [answers_state, idx_state, q_number, q_text, options, options])
134
+ next_btn.click(navigate, [sampled_state, answers_state, idx_state, options, gr.State(1)],
135
+ [answers_state, idx_state, q_number, q_text, options, options])
136
+
137
+ def finish(sampled, answers, idx, chosen, name_v, father_v, contact_v):
 
 
 
 
 
138
  if sampled and 0 <= idx < len(sampled):
139
+ answers[idx] = chosen
140
+ correct, total, perc, per_q = score_answers(sampled, answers)
141
+ path = results_to_csv({"name":name_v,"father":father_v,"contact":contact_v}, per_q)
142
+ return f"{correct}/{total} ({perc:.2f}%)", path, f"Test completed! Score: {perc:.2f}%"
143
+
144
+ finish_btn.click(finish, [sampled_state, answers_state, idx_state, options, name, father, contact],
145
+ [score_out, download_file, status])
146
+
147
+ demo.launch(server_name="0.0.0.0", server_port=7860)