Benny-Tang commited on
Commit
c0ddda3
·
verified ·
1 Parent(s): 6a667fd

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +169 -271
app.py CHANGED
@@ -1,283 +1,181 @@
 
1
  import os
2
- import re
3
- import json
4
  import random
5
- import subprocess
6
- import gradio as gr
7
-
8
- from agents import AnalyzerAgent, CoachAgent, PredictiveAgent
9
- from ocr_agent import OcrAgent
10
-
11
- # Paths
12
- DATA_DIR = "data"
13
- QUESTIONS_FILE = "questions.json"
14
-
15
- # Ensure data dir exists
16
- os.makedirs(DATA_DIR, exist_ok=True)
17
-
18
- # Agents
19
- analyzer = AnalyzerAgent()
20
- coach_agent = CoachAgent()
21
- predictor = PredictiveAgent()
22
- ocr_agent = OcrAgent()
23
-
24
-
25
- def load_question_bank():
26
- """Load merged question bank safely; return [] if file missing/invalid."""
27
- if not os.path.exists(QUESTIONS_FILE):
28
- return []
29
- try:
30
- with open(QUESTIONS_FILE, "r", encoding="utf-8") as f:
31
- content = f.read().strip()
32
- return json.loads(content) if content else []
33
- except Exception:
34
- return []
35
-
36
-
37
- QUESTION_BANK = load_question_bank()
38
-
39
-
40
- # ---------------- Merge helper ----------------
41
- def merge_questions():
42
- """Run merge_questions.py to rebuild questions.json and reload in memory."""
43
- try:
44
- subprocess.run(["python", "merge_questions.py"], check=True)
45
- global QUESTION_BANK
46
- QUESTION_BANK = load_question_bank()
47
- return True, "Merge successful"
48
- except subprocess.CalledProcessError as e:
49
- return False, f"Merge failed: {e}"
50
-
51
-
52
- # ---------------- OCR / Upload ----------------
53
- def auto_detect_from_filename(path):
54
- """Try to detect year and subject (lowercase subject token) from filename.
55
- Matches patterns like: spm_2018_bm.pdf or spm-2019-math.pdf etc."""
56
- if not path:
57
- return None, None
58
- fname = os.path.basename(path)
59
- m = re.search(r"spm[_\-]?(\d{4})[_\-]?([A-Za-z]+)", fname, re.IGNORECASE)
60
- if not m:
61
- return None, None
62
- year = m.group(1)
63
- subj = m.group(2).lower()
64
- return year, subj
65
-
66
-
67
- SUBJECT_DISPLAY_ORDER = ["BM", "English", "Math", "History", "Science", "MoralStudies",
68
- "Accounting", "Economics", "Business"]
69
-
70
-
71
- def normalize_display_subject(token):
72
- """Return display subject label (capitalized BM / English / Math / MoralStudies, etc.)."""
73
- if not token:
74
- return "BM"
75
- t = token.strip().lower()
76
- mapping = {
77
- "bm": "BM",
78
- "bahasa": "BM",
79
- "bahasamelayu": "BM",
80
- "english": "English",
81
- "eng": "English",
82
- "math": "Math",
83
- "mathematics": "Math",
84
- "history": "History",
85
- "sejarah": "History",
86
- "science": "Science",
87
- "moral": "MoralStudies",
88
- "moralstudies": "MoralStudies",
89
- "accounting": "Accounting",
90
- "economics": "Economics",
91
- "business": "Business",
92
- }
93
- return mapping.get(t, token.capitalize())
94
-
95
-
96
- def subject_token_from_display(display_subj):
97
- """Convert display subject (BM, English) to token used in filenames (lowercase)."""
98
- if not display_subj:
99
- return "bm"
100
- dsp = display_subj.strip()
101
- return dsp.lower()
102
-
103
-
104
- def process_pdf_and_merge(file_path, display_subject, year):
105
  """
106
- - Run OCR -> write data/spm_{year}_{subject}.json + scheme file.
107
- - Auto-run merge_questions.py to create/refresh questions.json
108
  """
109
- if not file_path:
110
- return "No file provided."
111
-
112
- subj_token = subject_token_from_display(display_subject)
113
- # call OCR agent to extract and write files
114
- try:
115
- out_qfile, out_scheme = ocr_agent.extract_questions_to_files(pdf_path=file_path,
116
- year=str(year),
117
- subject_token=subj_token,
118
- out_dir=DATA_DIR)
119
- except Exception as e:
120
- return f"❌ OCR failed: {e}"
121
-
122
- ok, msg = merge_questions()
123
- if ok:
124
- return f"✅ OCR saved {out_qfile} and {out_scheme}. Merge result: {msg}"
125
- else:
126
- return f"⚠️ OCR saved {out_qfile} and {out_scheme}. Merge result: {msg}"
127
-
128
-
129
- # ---------------- Exam logic ----------------
130
- def generate_exam(subject_display, num_questions, include_predicted):
 
 
 
 
 
 
 
 
 
 
 
 
131
  """
132
- Returns (exam_data (list), status_message, exam_data) to store exam_data in state.
133
- exam_data items: {id:int, text:str, choices:list, topics:list, source:str}
134
  """
135
- # internal lookup subject key stored in questions.json is "Form5_<DisplaySubject>" e.g., Form5_BM
136
- subj_key = f"Form5_{subject_display}"
137
- pool = [q for q in QUESTION_BANK if q.get("subject") == subj_key]
138
-
139
- predicted_questions = []
140
- if include_predicted:
141
- # ask predictor to generate predictions using the current bank (so trend info is used)
142
- predicted_questions = predictor.generate_predictions(level="Form5",
143
- subject=subject_display,
144
- n=8,
145
- question_bank=QUESTION_BANK)
146
-
147
- combined = pool + predicted_questions
148
- if not combined:
149
- return [], f"No questions available for {subject_display}. Upload papers (2018–2024) first.", []
150
-
151
- random.shuffle(combined)
152
- selected = combined[:min(num_questions, len(combined))]
153
-
154
- # Standardize output shape (do not expose 'correct_answer' for predicted? we include it,
155
- # but the UI can show choices; predicted questions have correct_answer set by predictor)
156
- exam_data = []
157
- for q in selected:
158
- # ensure minimal fields exist
159
- exam_data.append({
160
- "id": q.get("id"),
161
- "text": q.get("text"),
162
- "choices": q.get("choices", []),
163
- "topics": q.get("topics", []),
164
- "source": q.get("source", "pastpaper")
165
- })
166
-
167
- return exam_data, f"Prepared {len(exam_data)} questions (includes {len(predicted_questions)} predicted)" , exam_data
168
-
169
-
170
- def submit_exam_answers(answers_json, exam_data, subject_display):
171
  """
172
- answers_json: dict mapping question id (string) -> answer string (the answer text or choice text)
173
- exam_data: list (from start)
174
- We grade only questions where a correct_answer exists (not None).
 
175
  """
176
- if not exam_data:
177
- return "No exam data found.", {}, {}, {}, gr.update(visible=False), gr.update(visible=True)
178
-
179
- correct = 0
180
- graded = 0
181
- per_question = {}
182
-
183
- for q in exam_data:
184
- qid = q.get("id")
185
- k = str(qid)
186
- user_ans = answers_json.get(k)
187
- # find canonical correct_answer: for past paper, from QUESTION_BANK; for predicted, from q itself if present
188
- correct_ans = None
189
- if q.get("source") == "predicted":
190
- # predicted question object may include a 'correct_answer'
191
- # in our design predictor attaches 'correct_answer' to predicted questions
192
- # but it's still probabilistic (has 'confidence' field)
193
- # q (from exam_data) did not include correct_answer (we stripped), so find from QUESTION_BANK? Not present
194
- # We need to find original predicted object predictor returns dicts; but since predicted questions were not saved to QUESTION_BANK,
195
- # the simple way: during generate_exam we should have kept the predicted correct_answer in the exam_data object.
196
- # To keep things robust, first attempt to find a matching question in QUESTION_BANK (unlikely),
197
- # then try to see if exam_data contains 'correct_answer' directly (shouldn't in UI). We'll assume predicted questions include correct_answer in exam_data if they are to be graded.
198
- correct_ans = q.get("correct_answer") # may be None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  else:
200
- # pastpaper: find in QUESTION_BANK by id
201
- orig = next((item for item in QUESTION_BANK if item.get("id") == qid), None)
202
- if orig:
203
- correct_ans = orig.get("correct_answer")
204
-
205
- per_question[str(qid)] = {"user": user_ans, "correct": correct_ans, "topics": q.get("topics", [])}
206
-
207
- if correct_ans is not None:
208
- graded += 1
209
- # compare string-normalized answers
210
- if user_ans is not None and str(user_ans).strip() == str(correct_ans).strip():
211
- correct += 1
212
-
213
- score = round(100 * correct / graded, 2) if graded > 0 else "N/A (no answer keys available)"
214
-
215
- analysis = analyzer.analyze(per_question)
216
- coach = coach_agent.coach(analysis, "Form5", subject_display)
217
- pred_summary = predictor.summary(level="Form5", subject=subject_display, question_bank=QUESTION_BANK)
218
-
219
- return (
220
- f"Your Score: {score}%",
221
- analysis,
222
- coach,
223
- pred_summary,
224
- gr.update(visible=False),
225
- gr.update(visible=True)
226
- )
227
-
228
-
229
- # ----------------- UI -----------------
230
- with gr.Blocks() as demo:
231
- gr.Markdown("## SPM Exam Simulator — Form 5 (Past papers 2018–2024) with AI Predictions & OCR")
232
-
233
- with gr.Tab("Upload Papers (OCR → JSON → Merge)"):
234
- pdf_file = gr.File(label="Upload SPM PDF (filename like spm_2018_bm.pdf helps auto-detect)",
235
- type="filepath")
236
- subject_dropdown = gr.Dropdown(choices=SUBJECT_DISPLAY_ORDER, value="BM", label="Subject (override)")
237
- year_dropdown = gr.Dropdown(choices=[str(y) for y in range(2018, 2025)], value="2018", label="Year")
238
- process_btn = gr.Button("Process PDF → JSON + Merge")
239
- ocr_status = gr.Textbox(label="Status", interactive=False)
240
-
241
- # When a file is uploaded, auto-fill subject/year fields
242
- def prefill(file_path):
243
- if not file_path:
244
- return "BM", "2018"
245
- year, subj_token = auto_detect_from_filename(file_path)
246
- subj_display = normalize_display_subject(subj_token) if subj_token else "BM"
247
- return subj_display, year if year else "2018"
248
-
249
- pdf_file.change(fn=prefill, inputs=[pdf_file], outputs=[subject_dropdown, year_dropdown])
250
- process_btn.click(fn=process_pdf_and_merge,
251
- inputs=[pdf_file, subject_dropdown, year_dropdown],
252
- outputs=[ocr_status])
253
-
254
- with gr.Tab("Exam Simulator"):
255
- subject_sel = gr.Dropdown(choices=["BM", "English", "Math", "History", "Science", "MoralStudies",
256
- "Accounting", "Economics", "Business"],
257
- value="Math", label="Subject")
258
- num_q = gr.Slider(minimum=5, maximum=50, step=5, value=10, label="Number of Questions")
259
- include_pred = gr.Checkbox(value=True, label="Include AI-predicted questions (in-memory only)")
260
- start_btn = gr.Button("Start Exam")
261
- exam_state = gr.State() # will store exam_data (list)
262
-
263
- exam_display = gr.JSON(label="Exam Questions (read-only)")
264
- start_btn.click(fn=generate_exam,
265
- inputs=[subject_sel, num_q, include_pred],
266
- outputs=[exam_display, gr.Textbox(label="Status"), exam_state])
267
-
268
- with gr.Tab("Submit & Results"):
269
- answers_input = gr.JSON(label="Your Answers (JSON dictionary: {\"<id>\": \"<choice text>\"})")
270
- submit_btn = gr.Button("Submit Answers")
271
- score_out = gr.Textbox(label="Score")
272
- analysis_out = gr.JSON(label="Weakness Analysis")
273
- coach_out = gr.JSON(label="Study Coach")
274
- pred_out = gr.JSON(label="Predictions Summary")
275
-
276
- submit_btn.click(fn=submit_exam_answers,
277
- inputs=[answers_input, gr.State(), subject_sel, ],
278
- outputs=[score_out, analysis_out, coach_out, pred_out, gr.update(), gr.update()])
279
-
280
- demo.launch()
281
 
282
 
283
 
 
1
+ # agents.py
2
  import os
 
 
3
  import random
4
+ from collections import Counter
5
+ from typing import List, Dict, Any
6
+
7
+ # Accept both env var names for backward compatibility
8
+ GLM_API_KEY = os.getenv("ZHIPUAI_API_KEY") or os.getenv("zhipuai_api_key")
9
+
10
+
11
+ class AnalyzerAgent:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  """
13
+ Produces topic-level accuracy and weak-topic recommendations.
14
+ Input: per_question dict {qid: {"user":..., "correct":..., "topics":[...]}}
15
  """
16
+ def analyze(self, per_question: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
17
+ topic_stats = {}
18
+ for qid, info in per_question.items():
19
+ topics = info.get("topics") or []
20
+ user = info.get("user")
21
+ correct = info.get("correct")
22
+ is_correct = (correct is not None and user is not None and str(user).strip() == str(correct).strip())
23
+ for t in topics:
24
+ if t not in topic_stats:
25
+ topic_stats[t] = {"correct": 0, "total": 0}
26
+ topic_stats[t]["total"] += 1
27
+ if is_correct:
28
+ topic_stats[t]["correct"] += 1
29
+
30
+ topic_accuracy = {}
31
+ weak_topics = []
32
+ for t, stats in topic_stats.items():
33
+ total = stats["total"]
34
+ correct = stats["correct"]
35
+ acc = round((correct / total) * 100, 2) if total > 0 else 0.0
36
+ topic_accuracy[t] = {"accuracy_percent": acc, "total": total}
37
+ if total >= 3 and acc < 65.0:
38
+ weak_topics.append(t)
39
+
40
+ recommendation = "Focus on: " + ", ".join(weak_topics) if weak_topics else "No major weak topics detected."
41
+
42
+ return {
43
+ "topic_accuracy": topic_accuracy,
44
+ "weak_topics": weak_topics,
45
+ "recommendation": recommendation
46
+ }
47
+
48
+
49
+ class CoachAgent:
50
  """
51
+ Short actionable coaching guidance for Form5 SPM students.
 
52
  """
53
+ def coach(self, analysis: Dict[str, Any], level: str, subject: str) -> Dict[str, Any]:
54
+ weak = analysis.get("weak_topics", [])
55
+ if not weak:
56
+ tips = [
57
+ "Keep revising key topics and time yourself on mock papers.",
58
+ "Review incorrect solutions and understand each step.",
59
+ "Do a mixed-topic mock weekly to build stamina."
60
+ ]
61
+ else:
62
+ tips = [
63
+ f"Spend 20–30 minutes daily on {weak[0]} (split into focused tasks).",
64
+ "Solve short targeted questions and check worked solutions.",
65
+ "Teach a concept to someone else — it stabilizes understanding."
66
+ ]
67
+
68
+ practice = []
69
+ for i, t in enumerate(weak[:3], start=1):
70
+ practice.append({
71
+ "text": f"Short practice prompt on {t}: (write/solve one short item)",
72
+ "topic": t
73
+ })
74
+
75
+ return {"tips": tips, "study_plan": "20 min/day for weak topics + weekly mock", "practice": practice}
76
+
77
+
78
+ class PredictiveAgent:
 
 
 
 
 
 
 
 
 
 
79
  """
80
+ Generates heuristic or LLM-based predicted Form5 questions (in-memory only).
81
+ Public methods:
82
+ - predict(subject, level, count) -> list of question dicts
83
+ - summary(level, subject) -> dict
84
  """
85
+
86
+ def __init__(self):
87
+ self.api_key = GLM_API_KEY
88
+
89
+ def _top_topics_from_bank(self, question_bank: List[Dict], subject_display: str, top_k=6):
90
+ subj_key = f"Form5_{subject_display}"
91
+ counter = Counter()
92
+ total = 0
93
+ for q in question_bank:
94
+ if q.get("subject") != subj_key:
95
+ continue
96
+ total += 1
97
+ for t in q.get("topics", []):
98
+ counter[t] += 1
99
+ if total == 0:
100
+ return []
101
+ return [t for t, _ in counter.most_common(top_k)]
102
+
103
+ def predict(self, subject: str, level: str = "Form5", count: int = 5) -> List[Dict]:
104
+ """
105
+ Return `count` predicted MCQs. If no GLM key present, produce conservative heuristic items.
106
+ Predictions have id >= 900000, source='predicted', and may include 'confidence'.
107
+ """
108
+ preds = []
109
+ base = 900000
110
+ # fallback topics per subject
111
+ fallback_topics = {
112
+ "BM": ["perbendaharaan_kata", "tatabahasa"],
113
+ "English": ["vocabulary", "grammar"],
114
+ "Math": ["algebra", "geometry"],
115
+ "History": ["events", "dates"],
116
+ "Science": ["physics", "chemistry"],
117
+ "MoralStudies": ["ethics", "values"]
118
+ }
119
+ topics = fallback_topics.get(subject, ["general"])
120
+
121
+ # Try to use a simple LLM call if API key present (non-blocking, conservative)
122
+ # NOTE: We keep the interface simple: if GLM unavailable or fails, fall back to heuristics.
123
+ if self.api_key:
124
+ try:
125
+ # Placeholder: implement GLM call here if you provide endpoint details.
126
+ # For now, fall back to heuristics to avoid runtime dependency.
127
+ raise RuntimeError("GLM call not implemented in this environment")
128
+ except Exception:
129
+ pass
130
+
131
+ # Heuristic generation
132
+ for i in range(count):
133
+ t = topics[i % len(topics)]
134
+ q = self._heuristic_question(subject, t, idx=i + 1)
135
+ q["id"] = base + i
136
+ q["source"] = "predicted"
137
+ q["confidence"] = round(random.uniform(0.35, 0.75), 2)
138
+ preds.append(q)
139
+ return preds
140
+
141
+ def _heuristic_question(self, subject: str, topic: str, idx: int) -> Dict:
142
+ # provide realistic-looking stems & 4 choices tailored by subject
143
+ if subject == "BM":
144
+ stem = f"Pilih sinonim bagi perkataan 'gembira'."
145
+ choices = ["Sedih", "Gembira", "Marah", "Letih"]
146
+ correct = "Gembira"
147
+ elif subject == "English":
148
+ stem = "Choose the correct synonym for 'happy'."
149
+ choices = ["Sad", "Joyful", "Angry", "Tired"]
150
+ correct = "Joyful"
151
+ elif subject == "Math":
152
+ stem = "If 2x + 3 = 11, what is x?"
153
+ choices = ["2", "3", "4", "5"]
154
+ correct = "4"
155
+ elif subject == "Science":
156
+ stem = "What is the SI unit of force?"
157
+ choices = ["Joule", "Newton", "Pascal", "Watt"]
158
+ correct = "Newton"
159
+ elif subject == "History":
160
+ stem = "Which year is associated with Malayan independence?"
161
+ choices = ["1945", "1957", "1963", "1975"]
162
+ correct = "1957"
163
+ elif subject == "MoralStudies":
164
+ stem = "Which value best represents mutual respect?"
165
+ choices = ["Greed", "Respect", "Laziness", "Selfishness"]
166
+ correct = "Respect"
167
  else:
168
+ stem = f"Practice predicted question on {topic}."
169
+ choices = ["A", "B", "C", "D"]
170
+ correct = "A"
171
+
172
+ return {"text": stem, "choices": choices, "correct_answer": correct, "topics": [topic], "difficulty": 3}
173
+
174
+ def summary(self, level: str, subject: str, question_bank: List[Dict] = None) -> Dict:
175
+ # Provide simple summary: top topics from bank if available
176
+ topics = self._top_topics_from_bank(question_bank or [], subject) if question_bank else []
177
+ return {"level": level, "subject": subject, "top_topics": topics, "note": "Predictions are practice-oriented heuristics."}
178
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
 
180
 
181