Benny-Tang commited on
Commit
be412e4
·
verified ·
1 Parent(s): 665dfc5

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +236 -112
app.py CHANGED
@@ -1,167 +1,291 @@
 
1
  import os
2
  import json
3
  import random
4
  import sqlite3
 
5
  import gradio as gr
6
 
7
  from agents import AnalyzerAgent, CoachAgent, PredictiveAgent
 
 
8
 
9
- # Paths
10
- QUESTIONS_FILE = "questions.json"
11
  DB_FILE = "exam.db"
 
 
12
 
13
- # Initialize agents
 
 
14
  analyzer = AnalyzerAgent()
15
  coach = CoachAgent()
16
  predictor = PredictiveAgent()
17
 
18
-
19
- # ---------- Helpers ----------
20
- def load_question_bank():
21
- """Load questions from JSON file."""
22
- if not os.path.exists(QUESTIONS_FILE) or os.path.getsize(QUESTIONS_FILE) == 0:
23
  return []
24
  try:
25
- with open(QUESTIONS_FILE, "r", encoding="utf-8") as f:
26
  return json.load(f)
27
  except Exception:
28
  return []
29
 
30
-
31
  def init_db():
32
- """Setup SQLite if not exists and sync with JSON."""
33
  conn = sqlite3.connect(DB_FILE)
34
  cur = conn.cursor()
35
  cur.execute("""
36
  CREATE TABLE IF NOT EXISTS questions (
37
  id INTEGER PRIMARY KEY,
38
  text TEXT,
39
- choices TEXT,
40
  answer TEXT,
41
  subject TEXT,
42
  paper INTEGER,
43
  year INTEGER,
44
- image TEXT
45
- )""")
 
 
46
  conn.commit()
47
-
48
- # Sync JSON DB
49
- questions = load_question_bank()
 
 
 
 
 
 
 
 
50
  for q in questions:
51
- cur.execute("INSERT OR IGNORE INTO questions VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
52
- (q["id"], q["text"], json.dumps(q["choices"]),
53
- q["answer"], q["subject"], q["paper"], q["year"], q.get("image")))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  conn.commit()
55
  conn.close()
56
 
57
-
58
- def get_questions(subject, paper=2, num_questions=10):
59
- """Fetch random questions from DB."""
60
  conn = sqlite3.connect(DB_FILE)
61
  cur = conn.cursor()
62
- cur.execute("SELECT * FROM questions WHERE subject=? AND paper=?", (subject, paper))
63
  rows = cur.fetchall()
64
  conn.close()
65
-
66
- if not rows:
67
- return []
68
-
69
- selected = random.sample(rows, min(num_questions, len(rows)))
70
  questions = []
71
- for row in selected:
 
 
 
 
 
72
  questions.append({
73
- "id": row[0],
74
- "text": row[1],
75
- "choices": json.loads(row[2]),
76
- "answer": row[3],
77
- "subject": row[4],
78
- "paper": row[5],
79
- "year": row[6],
80
- "image": row[7]
 
81
  })
82
  return questions
83
 
 
 
84
 
85
- # ---------- Exam Logic ----------
86
  def start_exam(subject, num_questions):
87
- """Prepare questions for UI rendering."""
88
- questions = get_questions(subject, paper=2, num_questions=num_questions)
89
- return questions
90
-
91
-
92
- def submit_exam(answers, questions):
93
- """Evaluate submitted answers."""
94
- correct = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  wrong_details = []
96
-
97
- for q, ans in zip(questions, answers):
98
- if ans == q["answer"]:
99
- correct += 1
 
 
 
 
 
 
100
  else:
101
- wrong_details.append(f"Q{q['id']}: Correct answer was {q['answer']}")
102
-
103
- score_text = f"Score: {correct}/{len(questions)}\nCorrect: {correct}, Wrong: {len(questions) - correct}"
104
- analysis = coach.coach("Student got result: " + score_text)
105
- return score_text, "\n".join(wrong_details), analysis
106
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
  # ---------- Gradio UI ----------
109
- with gr.Blocks() as demo:
110
- gr.Markdown("## 📝 SPM Exam Simulator (Form 5)")
111
-
112
- with gr.Tab("Paper 2 Simulator"):
113
- subject_sim = gr.Dropdown(["BM", "English", "Math", "History", "Science", "MoralStudies"], label="Subject")
114
- num_q = gr.Slider(1, 50, step=1, value=10, label="Number of Questions")
115
- run_btn = gr.Button("Start Exam")
116
-
117
- question_state = gr.State()
118
- answer_inputs = []
119
-
120
- exam_area = gr.Column(visible=False)
121
- results_area = gr.Column(visible=False)
122
-
123
- with exam_area:
124
- question_boxes = []
125
- for i in range(50): # max 50
126
- q_text = gr.Markdown(visible=False)
127
- q_img = gr.Image(type="filepath", visible=False)
128
- q_choice = gr.Radio(choices=[], label=f"Answer Q{i+1}", visible=False)
129
- question_boxes.append((q_text, q_img, q_choice))
130
- answer_inputs.append(q_choice)
131
- submit_btn = gr.Button("Submit")
132
-
133
- with results_area:
134
- score_out = gr.Textbox(label="Score")
135
- wrong_out = gr.Textbox(label="Wrong Answers")
136
- advice_out = gr.Textbox(label="AI Advice")
137
-
138
- # Start exam
139
- def render_exam(subject, num):
140
- qs = start_exam(subject, num)
141
- updates = []
142
- for i, q in enumerate(qs):
143
- updates.append(gr.update(value=f"**Q{i+1}. {q['text']}**", visible=True)) # text
144
- updates.append(gr.update(value=q["image"], visible=bool(q["image"]))) # image
145
- updates.append(gr.update(choices=q["choices"], visible=True)) # choices
146
- # Hide unused slots
147
- for j in range(len(qs), 50):
148
- updates.extend([gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)])
149
- return [qs, gr.update(visible=True), *updates]
150
-
151
- run_btn.click(render_exam, [subject_sim, num_q], [question_state, exam_area] + sum(question_boxes, ()))
152
-
153
- # Submit exam
154
- submit_btn.click(
155
- fn=submit_exam,
156
- inputs=[answer_inputs, question_state],
157
- outputs=[score_out, wrong_out, advice_out]
158
- ).then(lambda: gr.update(visible=True), None, results_area)
159
-
160
-
161
- # Init DB before launch
162
- init_db()
163
 
164
- demo.launch(server_name="0.0.0.0", server_port=7860)
165
 
166
 
167
 
 
1
+ # app.py
2
  import os
3
  import json
4
  import random
5
  import sqlite3
6
+ import math
7
  import gradio as gr
8
 
9
  from agents import AnalyzerAgent, CoachAgent, PredictiveAgent
10
+ # no upload in app; ocr_agent used offline
11
+ # from ocr_agent import OcrAgent
12
 
13
+ # Paths & constants
14
+ QUESTIONS_JSON = "questions.json"
15
  DB_FILE = "exam.db"
16
+ MEDIA_DIR = "media"
17
+ MAX_SLOTS = 30 # number of preallocated question slots shown in UI (adjustable)
18
 
19
+ os.makedirs(MEDIA_DIR, exist_ok=True)
20
+
21
+ # Agents
22
  analyzer = AnalyzerAgent()
23
  coach = CoachAgent()
24
  predictor = PredictiveAgent()
25
 
26
+ # ---------- DB & JSON helpers ----------
27
+ def load_questions_json():
28
+ """Load questions.json if present, else return empty list."""
29
+ if not os.path.exists(QUESTIONS_JSON) or os.path.getsize(QUESTIONS_JSON) == 0:
 
30
  return []
31
  try:
32
+ with open(QUESTIONS_JSON, "r", encoding="utf-8") as f:
33
  return json.load(f)
34
  except Exception:
35
  return []
36
 
 
37
  def init_db():
38
+ """Create SQLite DB and ensure questions table exists. Sync JSON -> DB on startup."""
39
  conn = sqlite3.connect(DB_FILE)
40
  cur = conn.cursor()
41
  cur.execute("""
42
  CREATE TABLE IF NOT EXISTS questions (
43
  id INTEGER PRIMARY KEY,
44
  text TEXT,
45
+ choices TEXT, -- JSON-encoded list of strings
46
  answer TEXT,
47
  subject TEXT,
48
  paper INTEGER,
49
  year INTEGER,
50
+ image TEXT,
51
+ source TEXT
52
+ )
53
+ """)
54
  conn.commit()
55
+ conn.close()
56
+ # sync JSON -> DB (safe)
57
+ sync_json_to_db()
58
+
59
+ def sync_json_to_db():
60
+ """Insert or ignore entries from questions.json into DB."""
61
+ questions = load_questions_json()
62
+ if not questions:
63
+ return
64
+ conn = sqlite3.connect(DB_FILE)
65
+ cur = conn.cursor()
66
  for q in questions:
67
+ qid = int(q.get("id", 0) or 0)
68
+ # ensure proper types and defaults
69
+ text = q.get("text", "")
70
+ choices = json.dumps(q.get("choices", []), ensure_ascii=False)
71
+ answer = q.get("answer", "")
72
+ subject = q.get("subject", "")
73
+ paper = int(q.get("paper", 2) or 2)
74
+ year = int(q.get("year", 0) or 0)
75
+ image = q.get("image")
76
+ source = q.get("source", "")
77
+ # insert if id not present
78
+ if qid:
79
+ cur.execute("INSERT OR IGNORE INTO questions (id,text,choices,answer,subject,paper,year,image,source) VALUES (?,?,?,?,?,?,?,?,?)",
80
+ (qid, text, choices, answer, subject, paper, year, image, source))
81
+ else:
82
+ # generate a new id by letting sqlite autoincrement
83
+ cur.execute("INSERT INTO questions (text,choices,answer,subject,paper,year,image,source) VALUES (?,?,?,?,?,?,?,?)",
84
+ (text, choices, answer, subject, paper, year, image, source))
85
  conn.commit()
86
  conn.close()
87
 
88
+ def get_questions_from_db(subject, paper=2):
89
+ """Return all questions for subject & paper as list of dicts."""
 
90
  conn = sqlite3.connect(DB_FILE)
91
  cur = conn.cursor()
92
+ cur.execute("SELECT id, text, choices, answer, subject, paper, year, image, source FROM questions WHERE subject=? AND paper=?", (subject, paper))
93
  rows = cur.fetchall()
94
  conn.close()
 
 
 
 
 
95
  questions = []
96
+ for r in rows:
97
+ choices = []
98
+ try:
99
+ choices = json.loads(r[2]) if r[2] else []
100
+ except Exception:
101
+ choices = []
102
  questions.append({
103
+ "id": r[0],
104
+ "text": r[1],
105
+ "choices": choices,
106
+ "answer": r[3],
107
+ "subject": r[4],
108
+ "paper": r[5],
109
+ "year": r[6],
110
+ "image": r[7],
111
+ "source": r[8]
112
  })
113
  return questions
114
 
115
+ # Initialize DB & sync JSON
116
+ init_db()
117
 
118
+ # ---------- Exam logic ----------
119
  def start_exam(subject, num_questions):
120
+ """
121
+ Returns:
122
+ - markdown_texts: list of length MAX_SLOTS (strings)
123
+ - image_paths: list of length MAX_SLOTS (filepaths or empty string)
124
+ - radio_defaults: list of length MAX_SLOTS (None)
125
+ - state: list of selected question dicts (len=num_questions)
126
+ """
127
+ # ensure latest JSON -> DB
128
+ sync_json_to_db()
129
+
130
+ pool = get_questions_from_db(subject, paper=2)
131
+ if not pool:
132
+ # if empty, return placeholders
133
+ texts = [f"Q{i+1}: (no question)" for i in range(MAX_SLOTS)]
134
+ imgs = ["" for _ in range(MAX_SLOTS)]
135
+ radios = [None for _ in range(MAX_SLOTS)]
136
+ return texts + imgs + radios + [[]]
137
+
138
+ selected = random.sample(pool, min(num_questions, len(pool)))
139
+ # build display text with lettered choices
140
+ markdowns = []
141
+ images = []
142
+ radios = []
143
+ for i in range(MAX_SLOTS):
144
+ if i < len(selected):
145
+ q = selected[i]
146
+ # create question block: text + options lines A/B/C/D
147
+ text_block = q["text"].strip()
148
+ if q.get("choices"):
149
+ # ensure we have up to 4 choices
150
+ opts = q["choices"]
151
+ # normalize: if choices already prefixed with 'A.' etc, keep them
152
+ lines = []
153
+ letters = ["A", "B", "C", "D"]
154
+ for j, opt in enumerate(opts):
155
+ if j < 4:
156
+ # if opt includes 'A.' style, keep raw; else prefix
157
+ if isinstance(opt, str) and opt.strip().upper().startswith(tuple([l + "." for l in letters])):
158
+ lines.append(opt.strip())
159
+ else:
160
+ lines.append(f"{letters[j]}. {opt}")
161
+ text_block += "\n\n" + "\n".join(lines)
162
+ else:
163
+ # no choices present (rare for Paper2) — leave as-is
164
+ text_block += "\n\n(A/B/C/D not available)"
165
+ markdowns.append(text_block)
166
+ images.append(q.get("image") or "")
167
+ radios.append(None)
168
+ else:
169
+ markdowns.append("")
170
+ images.append("")
171
+ radios.append(None)
172
+
173
+ # outputs = markdowns (MAX) + images (MAX) + radios (MAX) + [selected-state]
174
+ return markdowns + images + radios + [selected]
175
+
176
+ def submit_exam(*args):
177
+ """
178
+ args layout: first MAX_SLOTS answers (each None or "A"/"B"/...), last arg is state (selected questions)
179
+ returns: score_text, wrong_details_text, ai_advice_text
180
+ """
181
+ if len(args) < MAX_SLOTS + 1:
182
+ return "⚠️ Submission failed (bad format).", "", ""
183
+
184
+ answers = list(args[:MAX_SLOTS])
185
+ selected = args[MAX_SLOTS] or []
186
+
187
+ total = len(selected)
188
+ correct_cnt = 0
189
  wrong_details = []
190
+ wrong_qids = []
191
+
192
+ for i, q in enumerate(selected):
193
+ ans = (answers[i] or "").strip()
194
+ expected = str(q.get("answer") or "").strip()
195
+ # normalization: expected may be 'A' or full text. Accept either letter match or text match.
196
+ is_correct = False
197
+ if expected == "":
198
+ # no answer key: cannot auto-grade; mark as ungraded (count as wrong for now)
199
+ is_correct = False
200
  else:
201
+ # letter vs text compare
202
+ if len(expected) == 1 and expected.upper() in ["A", "B", "C", "D"]:
203
+ if ans.upper() == expected.upper():
204
+ is_correct = True
205
+ else:
206
+ # expected is likely full text. compare lower-case normalized text
207
+ if ans.lower() == expected.lower() or ans.lower() in q.get("choices", []):
208
+ is_correct = True
209
+
210
+ if is_correct:
211
+ correct_cnt += 1
212
+ else:
213
+ correct_label = expected
214
+ # if expected is a letter, append the text of that choice if available
215
+ if expected and len(expected) == 1 and q.get("choices"):
216
+ idx = ord(expected.upper()) - ord("A")
217
+ if 0 <= idx < len(q["choices"]):
218
+ correct_label = f"{expected.upper()}. {q['choices'][idx]}"
219
+ wrong_details.append(f"Q{i+1} (id:{q['id']}): Your answer: {ans or '(no answer)'} | Correct: {correct_label}")
220
+ wrong_qids.append(q['id'])
221
+
222
+ score_text = f"Score: {correct_cnt} / {total} (Correct {correct_cnt}, Wrong {total - correct_cnt})"
223
+
224
+ # Use ZhipuAI coach to analyze the exam result and give advice
225
+ try:
226
+ # build a short context for coach
227
+ summary = {
228
+ "total": total,
229
+ "correct": correct_cnt,
230
+ "wrong": total - correct_cnt,
231
+ "wrong_qids": wrong_qids,
232
+ "subject": selected[0]["subject"] if total>0 else ""
233
+ }
234
+ advice = coach.coach(json.dumps(summary, ensure_ascii=False))
235
+ except Exception as e:
236
+ advice = f"(AI coach not available) {str(e)}"
237
+
238
+ return score_text, "\n\n".join(wrong_details) or "All correct!", advice
239
 
240
  # ---------- Gradio UI ----------
241
+ subjects_list = []
242
+ # default subjects pulled from DB
243
+ conn = sqlite3.connect(DB_FILE)
244
+ cur = conn.cursor()
245
+ cur.execute("SELECT DISTINCT subject FROM questions")
246
+ rows = cur.fetchall()
247
+ conn.close()
248
+ subjects_list = [r[0] for r in rows] or ["BM", "English", "Math", "History", "Science", "MoralStudies"]
249
+
250
+ with gr.Blocks(title="SPM Paper 2 Simulator") as demo:
251
+ gr.Markdown("# 🧾 SPM Paper 2 — Simulator (Form 5)")
252
+ gr.Markdown("This simulator renders Paper 2 style MCQ (A/B/C/D). Select subject and how many questions you want. Questions may include diagrams which will be shown under the question.")
253
+
254
+ with gr.Row():
255
+ subject_dd = gr.Dropdown(subjects_list, label="Subject", value=subjects_list[0])
256
+ num_q = gr.Slider(1, MAX_SLOTS, value=10, step=1, label="Number of Questions")
257
+
258
+ start_btn = gr.Button("Start Simulation")
259
+
260
+ # Pre-allocate slots for Markdown, Image, Radio — MAX_SLOTS each
261
+ q_texts = [gr.Markdown(f"Q{i+1}: (not loaded)") for i in range(MAX_SLOTS)]
262
+ q_images = [gr.Image(type="filepath", visible=False) for _ in range(MAX_SLOTS)]
263
+ q_radios = [gr.Radio(choices=["A", "B", "C", "D"], label=f"Answer Q{i+1}") for i in range(MAX_SLOTS)]
264
+
265
+ # state to hold selected question dicts
266
+ selected_state = gr.State([])
267
+
268
+ start_btn.click(
269
+ fn=start_exam,
270
+ inputs=[subject_dd, num_q],
271
+ outputs=[*q_texts, *q_images, *q_radios, selected_state]
272
+ )
273
+
274
+ submit_btn = gr.Button("Submit Answers")
275
+ score_out = gr.Textbox(label="Score Summary")
276
+ wrong_out = gr.Textbox(label="Wrong Answers", lines=8)
277
+ advice_out = gr.Textbox(label="AI Advice", lines=6)
278
+
279
+ submit_btn.click(
280
+ fn=submit_exam,
281
+ inputs=[*q_radios, selected_state],
282
+ outputs=[score_out, wrong_out, advice_out]
283
+ )
284
+
285
+ # Launch
286
+ if __name__ == "__main__":
287
+ demo.launch(server_name="0.0.0.0", server_port=7860)
 
 
 
 
 
 
 
288
 
 
289
 
290
 
291