File size: 9,801 Bytes
be412e4
bd2bcf3
 
69d5318
665dfc5
bd2bcf3
69d5318
bd2bcf3
 
8cb30dd
be412e4
665dfc5
be412e4
8cb30dd
bd2bcf3
be412e4
 
 
665dfc5
 
 
 
8cb30dd
 
 
be412e4
 
bd2bcf3
 
be412e4
bd2bcf3
665dfc5
bd2bcf3
 
8cb30dd
665dfc5
 
 
 
 
 
8cb30dd
665dfc5
 
 
 
be412e4
 
 
 
665dfc5
be412e4
 
 
 
 
 
 
 
 
665dfc5
8cb30dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
665dfc5
 
 
8cb30dd
665dfc5
 
be412e4
665dfc5
 
8cb30dd
be412e4
 
 
 
 
8cb30dd
be412e4
 
 
 
 
 
 
 
 
665dfc5
8cb30dd
665dfc5
8cb30dd
 
665dfc5
8cb30dd
 
 
665dfc5
be412e4
8cb30dd
 
 
 
 
be412e4
8cb30dd
be412e4
 
8cb30dd
be412e4
8cb30dd
 
 
 
 
be412e4
 
8cb30dd
be412e4
 
8cb30dd
be412e4
 
 
8cb30dd
be412e4
8cb30dd
be412e4
 
8cb30dd
 
be412e4
8cb30dd
 
 
 
be412e4
8cb30dd
 
be412e4
8cb30dd
be412e4
 
8cb30dd
be412e4
8cb30dd
be412e4
8cb30dd
be412e4
8cb30dd
 
be412e4
 
 
8cb30dd
 
be412e4
 
8cb30dd
be412e4
 
 
 
 
8cb30dd
 
 
be412e4
 
 
 
 
 
 
665dfc5
8cb30dd
 
be412e4
 
 
8cb30dd
 
be412e4
 
 
8cb30dd
be412e4
8cb30dd
 
be412e4
 
 
8cb30dd
 
 
be412e4
8cb30dd
be412e4
8cb30dd
be412e4
8cb30dd
 
 
be412e4
8cb30dd
be412e4
8cb30dd
 
665dfc5
8cb30dd
 
 
 
be412e4
 
 
 
 
8cb30dd
be412e4
8cb30dd
be412e4
8cb30dd
be412e4
 
8cb30dd
be412e4
8cb30dd
be412e4
8cb30dd
 
 
 
be412e4
8cb30dd
 
 
 
 
 
 
 
be412e4
 
 
 
8cb30dd
be412e4
 
 
8cb30dd
be412e4
 
 
 
 
 
 
 
 
8cb30dd
be412e4
 
 
 
 
b8a81b4
4680861
b8a81b4
722325d
54aef41
c0ddda3
94e0d2f
 
713f902
 
8fc3b6b
8368215
079930c
30ace27
dc8d23a
3241dd2
60c7eef
aacb252
bf6ca70
8f96aba
209ff26
0d8204f
bd2bcf3
665dfc5
8cb30dd
75deb9d
6c2f82a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# 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)