atz21 commited on
Commit
7884d2d
·
verified ·
1 Parent(s): 9dcc575

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +90 -61
app.py CHANGED
@@ -1,15 +1,21 @@
1
  import os
 
 
 
 
 
 
2
  import gradio as gr
3
  import google.generativeai as genai
4
  from markdown_pdf import MarkdownPdf, Section
5
- import subprocess
 
6
 
7
  # ---------- PROMPTS ----------
8
  PROMPTS = {
9
  "ALIGNMENT_PROMPT": {
10
  "role": "system",
11
  "content": """Developer: Align QP, MS, and AS into structured JSON format.
12
-
13
  ## Instructions:
14
  - Each question must include:
15
  - `id` (question/sub-question number, e.g., "1", "2.a")
@@ -19,39 +25,22 @@ PROMPTS = {
19
  - `as` (student’s steps, numerical values, and notes)
20
  - Include `total_verification` in MS showing explicit mark breakdown.
21
  - The structure must be **valid JSON only**.
22
-
23
  ## Example JSON:
24
  {
25
  "questions": [
26
  {
27
  "id": "1",
28
- "qp": "Ramiro walks to work each morning. During the first minute he walks 80 metres. In each subsequent minute he walks 90% of the distance walked during the previous minute.\\nThe distance between his house and work is 660 metres. Ramiro leaves his house at 08:00 and has to be at work by 08:15.\\nExplain why he will not be at work on time.",
29
  "total_marks": 7,
30
  "ms": {
31
  "marks": [
32
- { "id": "M1_1", "desc": "Recognise that the distance each minute forms a geometric sequence; show r = 0.9 (method mark)." },
33
- { "id": "M1_2", "desc": "Recognise that total distance is the sum of a geometric sequence and give the sum formula (method mark)." },
34
- { "id": "M1_3", "desc": "List at least 5 correct terms of the GP (method mark)." },
35
- { "id": "A1_list", "desc": "List all 15 correct terms (accuracy mark)." },
36
- { "id": "M1_4", "desc": "Attempt to find S_15 (method mark)." },
37
- { "id": "A1_sum", "desc": "Correct numerical value for S_15 ≈ 635.287 (accuracy mark)." },
38
- { "id": "R1", "desc": "Conclude: since S < 660, he will not be there on time (requires preceding A mark)." }
39
  ],
40
- "total_verification": "M1 + M1 + M1 + A1 + M1 + A1 + R1 = 7"
41
  },
42
  "as": {
43
- "steps": [
44
- "90% of 80 = 72 (2nd minute).",
45
- "90% of 72 = 64.8 (3rd minute).",
46
- "Sequence shown: 80, 72, 64.8, 58.32.",
47
- "r = 72/80 = 0.9 ; also 64.8/72 = 0.9.",
48
- "u_n = u_1 * r^(n-1).",
49
- "S_n = u_1 * (r^n - 1)/(r - 1).",
50
- "S_15 = 80 * (0.9^15 - 1)/(0.9 - 1).",
51
- "S_15 = 635.29 (approx)."
52
- ],
53
- "numeric_S15": 635.29,
54
- "notes": "Student found r and used the sum formula correctly, listed only 4 terms, got S15 ≈ 635.29 but did not explicitly state the final conclusion."
55
  }
56
  }
57
  ]
@@ -61,7 +50,6 @@ PROMPTS = {
61
  "GRADING_PROMPT": {
62
  "role": "system",
63
  "content": """Developer: You are an official examiner. Apply the following grading rules precisely.
64
-
65
  ### Abbreviations:
66
  - **M**: Marks for Method
67
  - **A**: Marks for Accuracy/Answer
@@ -69,7 +57,6 @@ PROMPTS = {
69
  - **AG**: Answer given in question—no marks
70
  - **FT**: Follow Through marks (if error carried forward correctly)
71
  - **MR**: Deduct for misread (once only)
72
-
73
  ---
74
  ## Grading Instructions
75
  1. Award marks using official annotations (e.g., M1, A2).
@@ -79,51 +66,41 @@ PROMPTS = {
79
  5. Apply FT where appropriate.
80
  6. Use proper notation: M1A0, A1, etc.
81
  7. Any lost mark: use red `<span style="color:red">M0</span>` and make Reason red.
82
-
83
  ---
84
  ## Output Format
85
  Produce two sections per question/sub-question:
86
-
87
  ---
88
- ## Question X (and sub-question if applicable)
89
-
90
  ### Markscheme vs Student Answer
91
  | Mark ID | Markscheme Expectation | Student’s Response | Awarded |
92
  |---------|------------------------|--------------------|---------|
93
- | M1_1 | Recognise GP, r=0.9 | "r = 72/80 = 0.9" | M1 |
94
- | M1_2 | Sum formula for GP | "S_n = u1(r^n-1)/(r-1)" | M1 |
95
- | A1_list | 15 terms listed | Only 4 terms shown | <span style="color:red">A0</span> |
96
- | … | … | … | … |
97
-
98
  ➡️ **Total: 6/7**
99
-
100
  ---
101
-
102
  ### Examiner’s Report
103
  At the very end, provide a summary table:
104
-
105
  | Question Number | Marks | Remark |
106
  |-----------------|-------|--------|
107
  | 1 | 6/7 | C |
108
- | 2.a | 9/9 | A |
109
-
110
  Then show total clearly:
111
- `Total: 15/16`"""
112
  }
113
  }
114
 
115
  # -------------------- CONFIG --------------------
116
  genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
117
 
118
- # ---------- HELPER: Save to PDF ----------
 
 
119
  def save_as_pdf(text, filename="output.pdf"):
120
  pdf = MarkdownPdf()
121
  pdf.add_section(Section(text, toc=False))
122
  pdf.save(filename)
123
  return filename
124
 
125
- # ---------- HELPER: Compress PDF ----------
126
  def compress_pdf(input_path, output_path=None, max_size=20*1024*1024):
 
127
  if output_path is None:
128
  base, ext = os.path.splitext(input_path)
129
  output_path = f"{base}_compressed{ext}"
@@ -141,27 +118,72 @@ def compress_pdf(input_path, output_path=None, max_size=20*1024*1024):
141
  ]
142
  subprocess.run(gs_cmd, check=True)
143
  if os.path.getsize(output_path) <= max_size:
144
- print(f"✅ Compressed {input_path} → {output_path}")
145
  return output_path
146
  else:
147
- print(f"⚠️ Compression failed to reduce below {max_size/1024/1024} MB")
148
  return input_path
149
- except Exception as e:
150
- print(f"⚠️ Compression error: {e}")
151
  return input_path
152
 
153
- # ---------- HELPER: Create Model with Fallback ----------
154
  def create_model():
155
  try:
156
- print("⚡ Using gemini-2.5-pro model")
157
  return genai.GenerativeModel("gemini-2.5-pro", generation_config={"temperature": 0})
158
  except Exception:
159
- print("⚡ Falling back to gemini-2.5-flash model")
160
  return genai.GenerativeModel("gemini-2.5-flash", generation_config={"temperature": 0})
161
 
162
- # ---------- PIPELINE: ALIGN + GRADE ----------
163
- def align_and_grade(qp_file, ms_file, ans_file):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  try:
 
165
  qp_file = compress_pdf(qp_file, "qp_compressed.pdf")
166
  ms_file = compress_pdf(ms_file, "ms_compressed.pdf")
167
  ans_file = compress_pdf(ans_file, "ans_compressed.pdf")
@@ -172,7 +194,7 @@ def align_and_grade(qp_file, ms_file, ans_file):
172
 
173
  model = create_model()
174
 
175
- # ---------------- STEP 1: ALIGN (JSON only) ----------------
176
  resp = model.generate_content([
177
  PROMPTS["ALIGNMENT_PROMPT"]["content"],
178
  qp_uploaded,
@@ -183,7 +205,7 @@ def align_and_grade(qp_file, ms_file, ans_file):
183
  if not json_output and resp.candidates:
184
  json_output = resp.candidates[0].content.parts[0].text
185
 
186
- # ---------------- STEP 2: GRADING (Markdown + PDF) ----------------
187
  response = model.generate_content([
188
  PROMPTS["GRADING_PROMPT"]["content"],
189
  json_output
@@ -195,21 +217,27 @@ def align_and_grade(qp_file, ms_file, ans_file):
195
  base_name = os.path.splitext(os.path.basename(ans_file))[0]
196
  grading_pdf_path = save_as_pdf(grading, f"{base_name}_graded.pdf")
197
 
198
- # Return JSON (alignment), Markdown grading, and PDF
199
- return json_output, grading, grading_pdf_path
 
 
 
 
 
200
 
201
  except Exception as e:
202
- return f"❌ Error: {e}", None, None
203
 
204
  # ---------- GRADIO APP ----------
205
- with gr.Blocks(title="LeadIB AI Grading (Alignment + Auto-Grading)") as demo:
206
- gr.Markdown("## 📘 LeadIB AI Grading\nUpload **Question Paper**, **Markscheme**, and **Student Answer Sheet**.\nThe system will first align into JSON, then auto-grade with detailed feedback.")
207
 
208
  with gr.Row():
209
  qp_file = gr.File(label="📄 Upload Question Paper (PDF)")
210
  ms_file = gr.File(label="📄 Upload Markscheme (PDF)")
211
  ans_file = gr.File(label="📝 Upload Student Answer Sheet (PDF)")
212
 
 
213
  run_button = gr.Button("🚀 Run Alignment + Grading")
214
 
215
  with gr.Row():
@@ -217,11 +245,12 @@ with gr.Blocks(title="LeadIB AI Grading (Alignment + Auto-Grading)") as demo:
217
  grading_output = gr.Textbox(label="📝 Step 2: Grading (Markdown)", lines=20)
218
 
219
  grading_pdf = gr.File(label="📥 Download Grading PDF")
 
220
 
221
  run_button.click(
222
  fn=align_and_grade,
223
- inputs=[qp_file, ms_file, ans_file],
224
- outputs=[json_output, grading_output, grading_pdf]
225
  )
226
 
227
  if __name__ == "__main__":
 
1
  import os
2
+ import re
3
+ import json
4
+ import subprocess
5
+ import cv2
6
+ import numpy as np
7
+ import img2pdf
8
  import gradio as gr
9
  import google.generativeai as genai
10
  from markdown_pdf import MarkdownPdf, Section
11
+ from pdf2image import convert_from_path
12
+ from PIL import Image
13
 
14
  # ---------- PROMPTS ----------
15
  PROMPTS = {
16
  "ALIGNMENT_PROMPT": {
17
  "role": "system",
18
  "content": """Developer: Align QP, MS, and AS into structured JSON format.
 
19
  ## Instructions:
20
  - Each question must include:
21
  - `id` (question/sub-question number, e.g., "1", "2.a")
 
25
  - `as` (student’s steps, numerical values, and notes)
26
  - Include `total_verification` in MS showing explicit mark breakdown.
27
  - The structure must be **valid JSON only**.
 
28
  ## Example JSON:
29
  {
30
  "questions": [
31
  {
32
  "id": "1",
33
+ "qp": "Ramiro walks to work each morning...",
34
  "total_marks": 7,
35
  "ms": {
36
  "marks": [
37
+ { "id": "M1_1", "desc": "Recognise GP (r=0.9)" }
 
 
 
 
 
 
38
  ],
39
+ "total_verification": "M1 + A1 = 2"
40
  },
41
  "as": {
42
+ "steps": ["..."],
43
+ "notes": "..."
 
 
 
 
 
 
 
 
 
 
44
  }
45
  }
46
  ]
 
50
  "GRADING_PROMPT": {
51
  "role": "system",
52
  "content": """Developer: You are an official examiner. Apply the following grading rules precisely.
 
53
  ### Abbreviations:
54
  - **M**: Marks for Method
55
  - **A**: Marks for Accuracy/Answer
 
57
  - **AG**: Answer given in question—no marks
58
  - **FT**: Follow Through marks (if error carried forward correctly)
59
  - **MR**: Deduct for misread (once only)
 
60
  ---
61
  ## Grading Instructions
62
  1. Award marks using official annotations (e.g., M1, A2).
 
66
  5. Apply FT where appropriate.
67
  6. Use proper notation: M1A0, A1, etc.
68
  7. Any lost mark: use red `<span style="color:red">M0</span>` and make Reason red.
 
69
  ---
70
  ## Output Format
71
  Produce two sections per question/sub-question:
 
72
  ---
73
+ ## Question X
 
74
  ### Markscheme vs Student Answer
75
  | Mark ID | Markscheme Expectation | Student’s Response | Awarded |
76
  |---------|------------------------|--------------------|---------|
77
+ | M1_1 | Recognise GP | "r=0.9" | M1 |
 
 
 
 
78
  ➡️ **Total: 6/7**
 
79
  ---
 
80
  ### Examiner’s Report
81
  At the very end, provide a summary table:
 
82
  | Question Number | Marks | Remark |
83
  |-----------------|-------|--------|
84
  | 1 | 6/7 | C |
 
 
85
  Then show total clearly:
86
+ `Total: 6/7`"""
87
  }
88
  }
89
 
90
  # -------------------- CONFIG --------------------
91
  genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
92
 
93
+ GRID_ROWS, GRID_COLS = 20, 14 # grid for imprint placement
94
+
95
+ # ---------- HELPERS ----------
96
  def save_as_pdf(text, filename="output.pdf"):
97
  pdf = MarkdownPdf()
98
  pdf.add_section(Section(text, toc=False))
99
  pdf.save(filename)
100
  return filename
101
 
 
102
  def compress_pdf(input_path, output_path=None, max_size=20*1024*1024):
103
+ """Compress PDF only if larger than max_size (20MB default)."""
104
  if output_path is None:
105
  base, ext = os.path.splitext(input_path)
106
  output_path = f"{base}_compressed{ext}"
 
118
  ]
119
  subprocess.run(gs_cmd, check=True)
120
  if os.path.getsize(output_path) <= max_size:
 
121
  return output_path
122
  else:
 
123
  return input_path
124
+ except Exception:
 
125
  return input_path
126
 
 
127
  def create_model():
128
  try:
 
129
  return genai.GenerativeModel("gemini-2.5-pro", generation_config={"temperature": 0})
130
  except Exception:
 
131
  return genai.GenerativeModel("gemini-2.5-flash", generation_config={"temperature": 0})
132
 
133
+ # ---------- Extract marks per question ----------
134
+ def extract_marks_from_grading(grading_text):
135
+ grading_json = {"grading": []}
136
+ # Split by question sections
137
+ question_blocks = re.split(r"## Question\s+", grading_text)
138
+ for block in question_blocks[1:]: # skip intro
139
+ # Extract question ID (like "1(a)" or "2.b")
140
+ q_match = re.match(r"([\d\.a-zA-Z\(\)]+)", block.strip())
141
+ if not q_match:
142
+ continue
143
+ q_id = q_match.group(1).strip()
144
+
145
+ # Find awarded marks in that block
146
+ awarded = re.findall(r"\b(M\d+|A\d+|R\d+|M0|A0|R0)\b", block)
147
+
148
+ grading_json["grading"].append({
149
+ "question": q_id,
150
+ "marks_awarded": awarded
151
+ })
152
+
153
+ return grading_json
154
+
155
+ # ---------- Imprinting Logic ----------
156
+ def imprint_marks(pdf_path, grading_json, output_pdf):
157
+ pages = convert_from_path(pdf_path, dpi=200)
158
+ annotated_pages = []
159
+
160
+ for idx, page in enumerate(pages):
161
+ img = np.array(page.convert("RGB"))
162
+ img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
163
+
164
+ y_offset = 100 # baseline vertical offset
165
+ for g in grading_json["grading"]:
166
+ marks_text = ",".join(g["marks_awarded"])
167
+ # Simple placement: stack vertically
168
+ cv2.putText(img, f"{g['question']}: {marks_text}",
169
+ (50, y_offset),
170
+ cv2.FONT_HERSHEY_SIMPLEX,
171
+ 1.2, (0, 0, 255), 3, cv2.LINE_AA)
172
+ y_offset += 50
173
+
174
+ annotated_path = f"annotated_{idx+1}.png"
175
+ cv2.imwrite(annotated_path, img)
176
+ annotated_pages.append(annotated_path)
177
+
178
+ with open(output_pdf, "wb") as f:
179
+ f.write(img2pdf.convert(annotated_pages))
180
+
181
+ return compress_pdf(output_pdf)
182
+
183
+ # ---------- PIPELINE ----------
184
+ def align_and_grade(qp_file, ms_file, ans_file, imprint=False):
185
  try:
186
+ # Compress only if >20MB
187
  qp_file = compress_pdf(qp_file, "qp_compressed.pdf")
188
  ms_file = compress_pdf(ms_file, "ms_compressed.pdf")
189
  ans_file = compress_pdf(ans_file, "ans_compressed.pdf")
 
194
 
195
  model = create_model()
196
 
197
+ # ---- Step 1: ALIGN (JSON only)
198
  resp = model.generate_content([
199
  PROMPTS["ALIGNMENT_PROMPT"]["content"],
200
  qp_uploaded,
 
205
  if not json_output and resp.candidates:
206
  json_output = resp.candidates[0].content.parts[0].text
207
 
208
+ # ---- Step 2: GRADING (Markdown)
209
  response = model.generate_content([
210
  PROMPTS["GRADING_PROMPT"]["content"],
211
  json_output
 
217
  base_name = os.path.splitext(os.path.basename(ans_file))[0]
218
  grading_pdf_path = save_as_pdf(grading, f"{base_name}_graded.pdf")
219
 
220
+ # ---- Step 3 (Optional): Imprint marks on answer PDF ----
221
+ imprint_pdf_path = None
222
+ if imprint:
223
+ grading_json = extract_marks_from_grading(grading)
224
+ imprint_pdf_path = imprint_marks(ans_file, grading_json, f"{base_name}_imprinted.pdf")
225
+
226
+ return json_output, grading, grading_pdf_path, imprint_pdf_path
227
 
228
  except Exception as e:
229
+ return f"❌ Error: {e}", None, None, None
230
 
231
  # ---------- GRADIO APP ----------
232
+ with gr.Blocks(title="LeadIB AI Grading (Alignment + Auto-Grading + Imprint)") as demo:
233
+ gr.Markdown("## 📘 LeadIB AI Grading\nUpload **Question Paper**, **Markscheme**, and **Student Answer Sheet**.\nSystem aligns grades optionally imprints marks.")
234
 
235
  with gr.Row():
236
  qp_file = gr.File(label="📄 Upload Question Paper (PDF)")
237
  ms_file = gr.File(label="📄 Upload Markscheme (PDF)")
238
  ans_file = gr.File(label="📝 Upload Student Answer Sheet (PDF)")
239
 
240
+ imprint_toggle = gr.Checkbox(label="✍ Imprint Marks on Student Answer Sheet", value=False)
241
  run_button = gr.Button("🚀 Run Alignment + Grading")
242
 
243
  with gr.Row():
 
245
  grading_output = gr.Textbox(label="📝 Step 2: Grading (Markdown)", lines=20)
246
 
247
  grading_pdf = gr.File(label="📥 Download Grading PDF")
248
+ imprint_pdf = gr.File(label="📥 Download Imprinted PDF (Optional)")
249
 
250
  run_button.click(
251
  fn=align_and_grade,
252
+ inputs=[qp_file, ms_file, ans_file, imprint_toggle],
253
+ outputs=[json_output, grading_output, grading_pdf, imprint_pdf]
254
  )
255
 
256
  if __name__ == "__main__":