exam-simulator / app.py
Benny-Tang's picture
Update app.py
8cb30dd verified
# app.py
import os
import json
import random
import sqlite3
import gradio as gr
from agents import AnalyzerAgent, CoachAgent, PredictiveAgent
# Config
QUESTIONS_JSON = "questions.json"
DB_FILE = "exam.db"
MEDIA_DIR = "media"
MAX_SLOTS = 10 # number of question rows to display (adjust as needed)
os.makedirs(MEDIA_DIR, exist_ok=True)
# Agents
analyzer = AnalyzerAgent()
coach = CoachAgent()
predictor = PredictiveAgent()
# -------------------------
# DB / JSON helpers
# -------------------------
def load_questions_json():
if not os.path.exists(QUESTIONS_JSON) or os.path.getsize(QUESTIONS_JSON) == 0:
return []
try:
with open(QUESTIONS_JSON, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return []
def init_db_and_sync():
conn = sqlite3.connect(DB_FILE)
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS questions (
id INTEGER PRIMARY KEY,
text TEXT,
choices TEXT,
answer TEXT,
subject TEXT,
paper INTEGER,
year INTEGER,
image TEXT,
source TEXT
)
""")
conn.commit()
conn.close()
sync_json_to_db()
def sync_json_to_db():
questions = load_questions_json()
if not questions:
return
conn = sqlite3.connect(DB_FILE)
cur = conn.cursor()
for q in questions:
try:
qid = int(q.get("id") or 0)
text = q.get("text", "")
choices = json.dumps(q.get("choices", []), ensure_ascii=False)
answer = q.get("answer", "")
subject = q.get("subject", "")
paper = int(q.get("paper", 2) or 2)
year = int(q.get("year", 0) or 0)
image = q.get("image")
source = q.get("source", "")
if qid:
cur.execute("INSERT OR IGNORE INTO questions (id,text,choices,answer,subject,paper,year,image,source) VALUES (?,?,?,?,?,?,?,?,?)",
(qid, text, choices, answer, subject, paper, year, image, source))
else:
cur.execute("INSERT INTO questions (text,choices,answer,subject,paper,year,image,source) VALUES (?,?,?,?,?,?,?,?)",
(text, choices, answer, subject, paper, year, image, source))
except Exception:
continue
conn.commit()
conn.close()
def get_pool(subject, paper=2):
conn = sqlite3.connect(DB_FILE)
cur = conn.cursor()
cur.execute("SELECT id, text, choices, answer, subject, paper, year, image, source FROM questions WHERE subject=? AND paper=?", (subject, paper))
rows = cur.fetchall()
conn.close()
pool = []
for r in rows:
try:
choices = json.loads(r[2]) if r[2] else []
except Exception:
choices = []
pool.append({
"id": r[0],
"text": r[1],
"choices": choices,
"answer": r[3],
"subject": r[4],
"paper": r[5],
"year": r[6],
"image": r[7],
"source": r[8]
})
return pool
# initialize DB & sync once at startup
init_db_and_sync()
# -------------------------
# Exam functions
# -------------------------
def start_exam(subject, num_questions):
"""
Returns values mapping to outputs:
- first MAX_SLOTS strings: markdown question text (include choice lines A/B/C/D)
- next MAX_SLOTS strings: image file paths or empty ""
- next MAX_SLOTS values: dropdown default value (None)
- final output: selected questions (state)
"""
# ensure any new JSON items are synced
sync_json_to_db()
pool = get_pool(subject, paper=2)
if not pool:
# return empty placeholders
markdowns = [f"Q{i+1}: (no question available)" for i in range(MAX_SLOTS)]
images = ["" for _ in range(MAX_SLOTS)]
defaults = [None for _ in range(MAX_SLOTS)]
return markdowns + images + defaults + [[]]
selected = random.sample(pool, min(num_questions, len(pool)))
markdowns = []
images = []
defaults = []
for i in range(MAX_SLOTS):
if i < len(selected):
q = selected[i]
# Build question text + choice lines
text_block = q["text"].strip()
# If choices exist, append them as labeled lines (A., B., ...)
if q.get("choices"):
letters = ["A", "B", "C", "D"]
choice_lines = []
for j, opt in enumerate(q["choices"]):
if j < 4:
# Avoid double-prepending if already includes letter
opt_str = opt.strip()
if opt_str.upper().startswith(tuple([l + "." for l in letters])):
choice_lines.append(opt_str)
else:
choice_lines.append(f"{letters[j]}. {opt_str}")
text_block += "\n\n" + "\n".join(choice_lines)
else:
text_block += "\n\n(A/B/C/D)"
markdowns.append(text_block)
images.append(q.get("image") or "")
defaults.append(None)
else:
markdowns.append("") # blank row
images.append("")
defaults.append(None)
# Return markdowns (MAX) + images (MAX) + defaults (MAX) + [selected]
return markdowns + images + defaults + [selected]
def submit_exam(*args):
"""
args: first MAX_SLOTS dropdown answers (strings or None), last arg = selected questions list
returns: score_text, wrong_details_str, ai_advice_str
"""
if len(args) < MAX_SLOTS + 1:
return "Submission error (bad input format).", "", ""
answers = list(args[:MAX_SLOTS])
selected = args[MAX_SLOTS] or []
total = len(selected)
correct = 0
wrong_list = []
wrong_ids = []
for i, q in enumerate(selected):
ans = (answers[i] or "").strip()
expected = str(q.get("answer") or "").strip()
is_correct = False
if expected == "":
is_correct = False
else:
# If expected is single letter (A/B/C/D)
if len(expected) == 1 and expected.upper() in ["A","B","C","D"]:
if ans.upper() == expected.upper():
is_correct = True
else:
# compare free-text
if ans.lower() == expected.lower():
is_correct = True
if is_correct:
correct += 1
else:
# Prepare readable correct label
corr_display = expected
if expected and len(expected) == 1 and q.get("choices"):
idx = ord(expected.upper()) - ord("A")
if 0 <= idx < len(q["choices"]):
corr_display = f"{expected.upper()}. {q['choices'][idx]}"
wrong_list.append(f"Q{i+1} (id:{q['id']}): Your = {ans or '(no answer)'} | Correct = {corr_display}")
wrong_ids.append(q['id'])
score_text = f"Score: {correct} / {total} (Correct {correct}, Wrong {total - correct})"
# Ask coach agent for advice
try:
context = {"subject": selected[0]["subject"] if total>0 else None,
"total": total, "correct": correct, "wrong": total-correct, "wrong_ids": wrong_ids}
advice = coach.coach(json.dumps(context, ensure_ascii=False))
except Exception as e:
advice = f"(AI coach not available) {e}"
wrong_str = "\n".join(wrong_list) if wrong_list else "All correct!"
return score_text, wrong_str, advice
# -------------------------
# Build UI (side-by-side layout)
# -------------------------
# Prepare subjects list from DB
conn = sqlite3.connect(DB_FILE)
cur = conn.cursor()
cur.execute("SELECT DISTINCT subject FROM questions")
rows = cur.fetchall()
conn.close()
SUBJECTS = [r[0] for r in rows] or ["BM", "English", "Math", "History", "Science", "MoralStudies"]
with gr.Blocks(title="SPM Paper 2 Simulator — Side-by-Side UI") as demo:
gr.Markdown("# 🧾 SPM Paper 2 — Simulator (Form 5)")
gr.Markdown("Each question is displayed with its answer selector to the right (select A/B/C/D). Diagrams (if any) show below the question text.")
with gr.Row():
subject_dd = gr.Dropdown(SUBJECTS, label="Subject", value=SUBJECTS[0])
num_q = gr.Slider(1, MAX_SLOTS, value=10, step=1, label="Number of Questions")
start_btn = gr.Button("Start Simulation")
# Create MAX_SLOTS rows, each row has Markdown (left), Image (below left), Dropdown (right)
q_markdowns = []
q_images = []
q_dropdowns = []
for i in range(MAX_SLOTS):
with gr.Row():
md = gr.Markdown(f"Q{i+1}: (not loaded)")
dd = gr.Dropdown(choices=["A","B","C","D"], label=f"Select answer Q{i+1}", value=None)
img = gr.Image(type="filepath", visible=False)
q_markdowns.append(md)
q_images.append(img)
q_dropdowns.append(dd)
# state to hold selected question dicts
selected_state = gr.State([])
# Outputs mapping: [*q_markdowns, *q_images, *q_dropdowns, selected_state]
start_btn.click(
fn=start_exam,
inputs=[subject_dd, num_q],
outputs=[*q_markdowns, *q_images, *q_dropdowns, selected_state]
)
submit_btn = gr.Button("Submit Answers")
score_out = gr.Textbox(label="Score Summary")
wrong_out = gr.Textbox(label="Wrong Answers", lines=8)
advice_out = gr.Textbox(label="AI Advice", lines=6)
submit_btn.click(
fn=submit_exam,
inputs=[*q_dropdowns, selected_state],
outputs=[score_out, wrong_out, advice_out]
)
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860)