# 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)