Files changed (1) hide show
  1. app.py +354 -76
app.py CHANGED
@@ -15,6 +15,8 @@ from PyPDF2 import PdfReader, PdfWriter
15
  from prompts import QP_MS_TRANSCRIPTION_PROMPT, get_grading_prompt
16
  from supabase import create_client, Client
17
 
 
 
18
  # ---------------- CONFIG ----------------
19
  # Multi-API Key Configuration for handling RESOURCE_EXHAUSTED errors
20
  class GeminiClientManager:
@@ -66,7 +68,9 @@ class GeminiClientManager:
66
  # Initialize the client manager
67
  client_manager = GeminiClientManager()
68
  client = client_manager.get_current_client() # For backward compatibility
69
- GRID_ROWS, GRID_COLS = 20, 14
 
 
70
 
71
  # Supabase configuration
72
  SUPABASE_URL = os.getenv("SUPABASE_URL")
@@ -173,8 +177,6 @@ def process_and_upload_input_files(qp_file_obj, ms_file_obj, ans_file_obj):
173
 
174
  return qp_path, ms_path, ans_path, upload_urls, run_timestamp
175
 
176
-
177
-
178
  # ---------------- HELPERS ----------------
179
  def parse_md_table(md):
180
  """Parse a Markdown table into a list of rows."""
@@ -595,7 +597,7 @@ def merge_pdfs(paths, output_path):
595
  writer.write(f)
596
  return output_path
597
 
598
- def gemini_generate_content(prompt_text, file_upload_obj=None, image_obj=None, model_name="gemini-2.5-pro", fallback_model="gemini-2.5-flash", fallback_model_2="gemini-2.5-flash-lite", file_path=None):
599
  """
600
  Send prompt_text and optionally an uploaded file (or an image object/list) to the model using NEW SDK.
601
  Automatically rotates through available API keys on RESOURCE_EXHAUSTED errors.
@@ -841,10 +843,6 @@ def gemini_generate_content(prompt_text, file_upload_obj=None, image_obj=None, m
841
  # If we exhausted all attempts
842
  raise Exception(f"❌ All {max_attempts} API key(s) exhausted. Please check your quota or try again later.")
843
 
844
-
845
-
846
-
847
-
848
  # ---------------- PARSERS ----------------
849
  def extract_question_ids_from_qpms(text: str):
850
  """Extract question IDs from QP+MS transcript."""
@@ -939,8 +937,6 @@ Graph found in:
939
 
940
  return prompt
941
 
942
-
943
-
944
  def extract_graph_questions_from_ms(text: str):
945
  """Extract graph questions and page numbers from MS transcript."""
946
  clean_text = text.replace("\u00A0", " ").replace("\t", " ")
@@ -1117,66 +1113,340 @@ def check_and_correct_total_marks(grading_text):
1117
  return corrected_report_text, calculated_total_awarded, calculated_total_possible, total_mismatch
1118
 
1119
  # ---------------- MAPPING/IMPRINT HELPERS ----------------
1120
- def ask_gemini_for_mapping_batch(image_paths, grading_json, expected_ids=None, rows=GRID_ROWS, cols=GRID_COLS):
1121
- """
1122
- Send multiple page images together to Gemini for batch mapping processing.
1123
- """
1124
- ids_block = "{NA}"
1125
- if expected_ids:
1126
- ids_block = "{\n" + "\n".join(expected_ids) + "\n}"
1127
-
1128
- prompt = f"""You are an exam marker. Your role is to identify where each question begins on each page.
1129
- The pages are divided into a {rows} x {cols} grid. Each cell has a RUNNING NUMBER label.
1130
- For each question in the grading JSON, return the cell NUMBER where the FIRST STEP of that question begins.
1131
- ⚠ IMPORTANT RULES:
1132
- - Do not place marks inside another question's answer area.
1133
- - Prefer placing the marks in a BLANK cell immediately to the RIGHT of the answer step. If no blank cell is available to the right, then place in a blank cell to the LEFT.
1134
- - Never place marks above or below the answer.
1135
- - Each question should have unique cell number
1136
- - If a question serial number is visible in the answer image, you must mandatorily identify the corresponding question using the grading JSON.
1137
- IMPORTANT: For your help i have provided u questions that u can expect in the images:
1138
- {ids_block}
1139
- Return JSON only, like:
1140
- [{{"page": 1, "question": "1(a)", "cell_number": 15}}, ...]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1141
  Grading JSON:
1142
- {json.dumps(grading_json, indent=2)}"""
1143
 
1144
- images = [Image.open(p) for p in image_paths]
1145
-
1146
- print(f"πŸ“‘ Sending batch mapping request for {len(image_paths)} pages to Gemini...")
1147
-
1148
- try:
1149
- contents = [prompt] + images
1150
- response = client.models.generate_content(
1151
- model="gemini-2.5-flash",
1152
- contents=contents
1153
- )
1154
- raw_text = response.text
1155
- except:
1156
- print("⚠️ Trying fallback model for mapping...")
1157
- contents = [prompt] + images
1158
- response = client.models.generate_content(
1159
- model="gemini-2.5-flash-preview-09-2025",
1160
- contents=contents
1161
- )
1162
- raw_text = response.text
1163
-
1164
- print("πŸ“₯ Batch mapping response (chars):", len(raw_text))
1165
- print("πŸ”Ž Gemini raw batch output:")
1166
- print(raw_text)
1167
-
1168
- try:
1169
- match = re.search(r'(\[.*\])', raw_text, re.DOTALL)
1170
- if match:
1171
- mapping = json.loads(match.group(1))
1172
- print(f"βœ… Parsed Gemini batch mapping for {len(image_paths)} pages")
1173
- return mapping
1174
- else:
1175
- print("❌ Failed to find JSON array in response")
1176
  return []
1177
- except Exception as e:
1178
- print(f"❌ Failed to parse Gemini JSON mapping: {e}")
1179
- return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1180
 
1181
  def normalize_question_id(qid):
1182
  """
@@ -1197,7 +1467,6 @@ def normalize_question_id(qid):
1197
 
1198
  return qid
1199
 
1200
- def imprint_marks_using_mapping(pdf_path, grading_json, output_pdf, expected_ids=None, rows=GRID_ROWS, cols=GRID_COLS):
1201
  """
1202
  Convert PDF to images, create grid-numbered images for batch sending to Gemini,
1203
  then annotate and produce imprinted PDF.
@@ -1308,7 +1577,6 @@ def imprint_marks_using_mapping(pdf_path, grading_json, output_pdf, expected_ids
1308
  compressed = compress_pdf(output_pdf)
1309
  print("πŸ“‘ Imprinted PDF saved to:", compressed)
1310
  return compressed
1311
-
1312
  def extract_pdf_pages_as_images(pdf_path, page_numbers, prefix):
1313
  """
1314
  Extracts unique pages (1-based) from a PDF as images, saves as PNG, returns list of file paths.
@@ -1400,7 +1668,7 @@ def align_and_grade_pipeline(qp_path, ms_path, ans_path, subject="Maths", imprin
1400
 
1401
  print("1.i) Transcribing QP+MS (questions first, then full markscheme, with graph detection)...")
1402
  qpms_prompt = QP_MS_TRANSCRIPTION_PROMPT["content"] + "\nAt the end, also list all questions in the markscheme where a graph is expected, in the format:\nGraph expected in:\n- Question <number> β†’ Page <number>\n(One per line, after ==== MARKSCHEME END ====)"
1403
- qpms_text = gemini_generate_content(qpms_prompt, file_upload_obj=merged_uploaded, model_name="gemini-2.5-flash", fallback_model="gemini-2.5-flash-preview-09-2025", fallback_model_2="gemini-2.5-flash-lite", file_path=merged_qpms_path)
1404
  print("πŸ“„ QP+MS transcription received. Saving debug file: debug_qpms_transcript.txt")
1405
  with open("debug_qpms_transcript.txt", "w", encoding="utf-8") as f:
1406
  f.write(qpms_text)
@@ -1418,7 +1686,7 @@ def align_and_grade_pipeline(qp_path, ms_path, ans_path, subject="Maths", imprin
1418
 
1419
  print("1.ii) Building AS transcription prompt with expected question IDs and graph detection, sending to Gemini...")
1420
  as_prompt = build_as_cot_prompt_with_expected_ids(extracted_ids, qpms_text) + "\nAt the end, also list all answers where a graph is found, in the format:\nGraph found in:\n- Answer <number> β†’ Page <number>\n(One per line, after all answers)"
1421
- as_text = gemini_generate_content(as_prompt, file_upload_obj=ans_uploaded, model_name="gemini-2.5-flash", fallback_model="gemini-2.5-flash-preview-09-2025", fallback_model_2="gemini-2.5-flash-lite", file_path=ans_path)
1422
  print("πŸ“ AS transcription received. Saving debug file: debug_as_transcript.txt")
1423
  with open("debug_as_transcript.txt", "w", encoding="utf-8") as f:
1424
  f.write(as_text)
@@ -1543,16 +1811,26 @@ with gr.Blocks(title="AI Grading (Pandoc + pdflatex)") as demo:
1543
  else:
1544
  return error_msg, "", "", None, None
1545
 
1546
- # Process and upload input files (generates shared timestamp)
1547
- qp_path, ms_path, ans_path, input_urls, run_timestamp = process_and_upload_input_files(
1548
- qp_file_obj, ms_file_obj, ans_file_obj
1549
- )
 
1550
 
1551
- # Run the grading pipeline (pass timestamp to keep all files together)
1552
  qpms_text, as_text, grading_text, grading_pdf_path, imprinted_pdf_path, output_urls = align_and_grade_pipeline(
1553
  qp_path, ms_path, ans_path, subject=subject_choice, imprint=imprint_flag, run_timestamp=run_timestamp
1554
  )
1555
 
 
 
 
 
 
 
 
 
 
1556
  # Build URLs summary
1557
  urls_summary = ""
1558
  if supabase_client:
 
15
  from prompts import QP_MS_TRANSCRIPTION_PROMPT, get_grading_prompt
16
  from supabase import create_client, Client
17
 
18
+ from dotenv import load_dotenv
19
+ load_dotenv()
20
  # ---------------- CONFIG ----------------
21
  # Multi-API Key Configuration for handling RESOURCE_EXHAUSTED errors
22
  class GeminiClientManager:
 
68
  # Initialize the client manager
69
  client_manager = GeminiClientManager()
70
  client = client_manager.get_current_client() # For backward compatibility
71
+ GRID_ROWS, GRID_COLS = 20, 14 # kept for legacy
72
+ N_LINES = 40
73
+ RIGHT_MARGIN = 60
74
 
75
  # Supabase configuration
76
  SUPABASE_URL = os.getenv("SUPABASE_URL")
 
177
 
178
  return qp_path, ms_path, ans_path, upload_urls, run_timestamp
179
 
 
 
180
  # ---------------- HELPERS ----------------
181
  def parse_md_table(md):
182
  """Parse a Markdown table into a list of rows."""
 
597
  writer.write(f)
598
  return output_path
599
 
600
+ def gemini_generate_content(prompt_text, file_upload_obj=None, image_obj=None, model_name="gemini-3.1-pro-preview", fallback_model="gemini-2.5-flash", fallback_model_2="gemini-2.5-flash-lite", file_path=None):
601
  """
602
  Send prompt_text and optionally an uploaded file (or an image object/list) to the model using NEW SDK.
603
  Automatically rotates through available API keys on RESOURCE_EXHAUSTED errors.
 
843
  # If we exhausted all attempts
844
  raise Exception(f"❌ All {max_attempts} API key(s) exhausted. Please check your quota or try again later.")
845
 
 
 
 
 
846
  # ---------------- PARSERS ----------------
847
  def extract_question_ids_from_qpms(text: str):
848
  """Extract question IDs from QP+MS transcript."""
 
937
 
938
  return prompt
939
 
 
 
940
  def extract_graph_questions_from_ms(text: str):
941
  """Extract graph questions and page numbers from MS transcript."""
942
  clean_text = text.replace("\u00A0", " ").replace("\t", " ")
 
1113
  return corrected_report_text, calculated_total_awarded, calculated_total_possible, total_mismatch
1114
 
1115
  # ---------------- MAPPING/IMPRINT HELPERS ----------------
1116
+ def ask_gemini_for_single_page(image_path, page_num, grading_json, expected_ids=None, rows=GRID_ROWS, cols=GRID_COLS):
1117
+ ids_block = "{NA}"
1118
+ if expected_ids:
1119
+ ids_block = "{\n" + "\n".join(expected_ids) + "\n}"
1120
+
1121
+ prompt = f"""You are an experienced mathematics examiner marking a student's handwritten answer sheet.
1122
+
1123
+ The image shows ONE PAGE of the answer sheet.
1124
+ The page has been divided into a {rows} x {cols} grid (rows x columns).
1125
+ Each cell is labelled "row,col" in its top-left corner (e.g. "8,3" means row 8, column 3).
1126
+ Rows are numbered 1 (top) to {rows} (bottom).
1127
+ Columns are numbered 1 (left) to {cols} (right).
1128
+
1129
+ YOUR TASK
1130
+ For EVERY mark listed in the grading JSON, find the EXACT cell that contains
1131
+ the specific handwritten step that mark belongs to, then report that cell as (row, col).
1132
+
1133
+ MARK MEANINGS
1134
+ M1 = method mark -> place beside the line where the METHOD or FORMULA is written
1135
+ A1 = accuracy mark -> place beside the line showing the CORRECT numerical answer
1136
+ A0 = mark NOT awarded -> beside the WRONG or missing answer
1137
+ B1 = independent mark -> beside that specific isolated result
1138
+ R1 = reasoning mark -> beside the conclusion/reasoning sentence
1139
+
1140
+ STRICT RULES
1141
+ 1. Read handwriting TOP-TO-BOTTOM, LEFT-TO-RIGHT.
1142
+ 2. Match marks IN ORDER through marks_awarded: first mark -> first relevant step, second mark -> second step. Do NOT skip steps.
1143
+ 3. Each mark must land on a DIFFERENT cell unless two marks truly refer to the exact same single token.
1144
+ 4. The col must point to where the writing actually IS β€” not the blank page margin.
1145
+ 5. Only include marks whose working appears on THIS page (page {page_num}). If a question is not on this page, omit it entirely.
1146
+ 6. Return ONLY a raw JSON array β€” no markdown, no prose, no code fences.
1147
+
1148
+ Page number : {page_num}
1149
+ Expected IDs: {ids_block}
1150
+ Grading JSON:
1151
+ {json.dumps(grading_json, indent=2)}
1152
+
1153
+ OUTPUT FORMAT
1154
+ [
1155
+ {{"page": {page_num}, "question": "1.b.ii", "mark": "M1", "row": 8, "col": 2}},
1156
+ {{"page": {page_num}, "question": "1.b.ii", "mark": "A1", "row": 11, "col": 4}},
1157
+ ...
1158
+ ]
1159
+ If nothing belongs on this page: []"""
1160
+
1161
+ image = Image.open(image_path)
1162
+ print(f" πŸ“‘ Page {page_num}: querying Gemini ({rows}x{cols} grid)…")
1163
+
1164
+ raw = None
1165
+ for model in ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-1.5-flash"]:
1166
+ try:
1167
+ resp = client_manager.get_current_client().models.generate_content(
1168
+ model=model, contents=[prompt, image])
1169
+ raw = resp.text
1170
+ print(f" βœ… {model} -> {len(raw)} chars")
1171
+ break
1172
+ except Exception as e:
1173
+ print(f" ⚠️ {model}: {e}")
1174
+
1175
+ if not raw:
1176
+ print(f" ❌ All models failed for page {page_num}")
1177
+ return []
1178
+
1179
+ try:
1180
+ cleaned = re.sub(r"```(?:json)?\s*", "", raw).strip().rstrip("`").strip()
1181
+ m = re.search(r"\[.*\]", cleaned, re.DOTALL)
1182
+ if not m:
1183
+ print(f" ❌ No JSON array found. Raw: {raw[:300]}")
1184
+ return []
1185
+ result = json.loads(m.group(0))
1186
+ valid = []
1187
+ for item in result:
1188
+ try:
1189
+ row = int(item.get("row", 0))
1190
+ col = int(item.get("col", 0))
1191
+ except (ValueError, TypeError):
1192
+ continue
1193
+ if not (1 <= row <= rows and 1 <= col <= cols):
1194
+ print(f" ⚠️ Out-of-range cell ({row},{col}) for {item.get('question')} {item.get('mark')}, skipping")
1195
+ continue
1196
+ item["row"] = row
1197
+ item["col"] = col
1198
+ item["page"] = int(item.get("page", page_num))
1199
+ valid.append(item)
1200
+ print(f" βœ… Page {page_num}: {len(valid)} valid placements")
1201
+ return valid
1202
+ except Exception as e:
1203
+ print(f" ❌ Parse error: {e}. Raw: {raw[:300]}")
1204
+ return []
1205
+
1206
+ def imprint_marks_using_mapping(pdf_path, grading_json, output_pdf, expected_ids=None, rows=None, cols=None):
1207
+ MARK_COLOR = (0, 0, 200)
1208
+ MARK_FONT = cv2.FONT_HERSHEY_SIMPLEX
1209
+ MARK_FONT_SCALE = 1.4
1210
+ MARK_FONT_THICK = 2
1211
+ LINE_COLOR = (180, 200, 255)
1212
+
1213
+ def draw_line_overlay(page_pil):
1214
+ img = page_pil.convert("RGB").copy()
1215
+ w, h = img.size
1216
+ draw = ImageDraw.Draw(img)
1217
+ line_h = h / N_LINES
1218
+ label_px = max(10, int(line_h * 0.55))
1219
+ try:
1220
+ font = ImageFont.truetype("arial.ttf", label_px)
1221
+ except Exception:
1222
+ font = ImageFont.load_default()
1223
+ for i in range(N_LINES):
1224
+ y = int(i * line_h)
1225
+ draw.line([(0, y), (w, y)], fill=LINE_COLOR, width=1)
1226
+ label = str(i + 1)
1227
+ bb = draw.textbbox((0, 0), label, font=font)
1228
+ lw, lh = bb[2] - bb[0], bb[3] - bb[1]
1229
+ tx, ty = w - lw - 4, y + 2
1230
+ draw.rectangle([tx - 2, ty - 1, tx + lw + 2, ty + lh + 1], fill=(255, 255, 240))
1231
+ draw.text((tx, ty), label, fill=(80, 80, 180), font=font)
1232
+ tmp = f"__overlay_{int(time.time()*1000)}.png"
1233
+ img.save(tmp, "PNG")
1234
+ return tmp
1235
+
1236
+ def ask_gemini_lines(overlay_path, page_num):
1237
+ prompt = f"""You are an experienced examiner reviewing a student's handwritten answer sheet.
1238
+
1239
+ The image shows ONE PAGE of the answer sheet.
1240
+ The page is divided into {N_LINES} horizontal LINES numbered 1 (top) to {N_LINES} (bottom).
1241
+ Line numbers are printed on the right edge of the image.
1242
+
1243
+ YOUR TASK
1244
+ For every mark listed in the grading JSON below, find which LINE number
1245
+ (1 to {N_LINES}) contains the handwritten work that earned (or missed) that mark,
1246
+ then output that line number.
1247
+
1248
+ RULES
1249
+ 1. Read handwriting top-to-bottom.
1250
+ 2. Match marks in order: first mark -> first relevant step, second -> second, etc.
1251
+ 3. Only include marks whose work appears on THIS page (page {page_num}).
1252
+ 4. Return ONLY a raw JSON array β€” no markdown, no prose.
1253
+
1254
+ Page: {page_num}
1255
  Grading JSON:
1256
+ {json.dumps(grading_json, indent=2)}
1257
 
1258
+ OUTPUT FORMAT:
1259
+ [
1260
+ {{"page": {page_num}, "question": "1a", "mark": "M1", "line": 5}},
1261
+ {{"page": {page_num}, "question": "1a", "mark": "A1", "line": 8}},
1262
+ ...
1263
+ ]
1264
+ If nothing from this grading belongs on this page: []"""
1265
+
1266
+ image = Image.open(overlay_path)
1267
+ print(f" πŸ“‘ Page {page_num}: querying Gemini ({N_LINES} lines)…")
1268
+ raw = None
1269
+ for model in ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-1.5-flash"]:
1270
+ try:
1271
+ resp = client_manager.get_current_client().models.generate_content(
1272
+ model=model, contents=[prompt, image])
1273
+ raw = resp.text
1274
+ print(f" βœ… {model} -> {len(raw)} chars")
1275
+ break
1276
+ except Exception as e:
1277
+ print(f" ⚠️ {model}: {e}")
1278
+ if not raw:
 
 
 
 
 
 
 
 
 
 
 
1279
  return []
1280
+ try:
1281
+ cleaned = re.sub(r"```(?:json)?\s*", "", raw).strip().rstrip("`").strip()
1282
+ m = re.search(r"\[.*\]", cleaned, re.DOTALL)
1283
+ if not m:
1284
+ return []
1285
+ result = json.loads(m.group(0))
1286
+ valid = []
1287
+ for item in result:
1288
+ try:
1289
+ line = int(item.get("line", 0))
1290
+ except (ValueError, TypeError):
1291
+ continue
1292
+ if not (1 <= line <= N_LINES):
1293
+ continue
1294
+ item["line"] = line
1295
+ item["page"] = int(item.get("page", page_num))
1296
+ valid.append(item)
1297
+ print(f" βœ… Page {page_num}: {len(valid)} valid placements")
1298
+ return valid
1299
+ except Exception as e:
1300
+ print(f" ❌ Parse error: {e}")
1301
+ return []
1302
+
1303
+ print("πŸ“„ Converting answer PDF to images for imprinting...")
1304
+ pages = convert_from_path(pdf_path, dpi=200)
1305
+ overlays, ann_paths = [], []
1306
+
1307
+ print(f"πŸ“ Drawing {N_LINES}-line overlays…")
1308
+ for i, page in enumerate(pages):
1309
+ ov = draw_line_overlay(page)
1310
+ overlays.append(ov)
1311
+ print(f" Page {i+1} overlay ready")
1312
+
1313
+ print("πŸ“‘ Querying Gemini for line placements…")
1314
+ all_placements = []
1315
+ for i, ov in enumerate(overlays):
1316
+ pn = i + 1
1317
+ placements = ask_gemini_lines(ov, pn)
1318
+ all_placements.extend(placements)
1319
+ print(f" Page {pn}: {len(placements)} marks placed")
1320
+
1321
+ """
1322
+ SINGLE-LINE MARKS PATCH (fixed font, dynamic margin) for imprint_marks_using_mapping
1323
+ ======================================================================================
1324
+
1325
+ PROBLEM: Marks wrap onto multiple visual lines.
1326
+
1327
+ FIX:
1328
+ 1. Group all marks per line into one string: ["B1","B1","B0"] β†’ "B1 B1 B0"
1329
+ 2. Measure that string's pixel width at the FIXED font scale.
1330
+ 3. Scan the page to find the rightmost dark pixel per line (actual content edge).
1331
+ 4. Calculate required extension = max over all lines of (mark_width + PADDING - free_space).
1332
+ 5. If extension > 0, add a single white strip of that width to the right of the page.
1333
+ 6. Draw each line's string once, right-aligned β€” always on a single line, never scaled.
1334
+
1335
+ HOW TO APPLY:
1336
+ Replace the entire block from:
1337
+ print("πŸ–Š Annotating pages…")
1338
+ up to (but NOT including):
1339
+ print("πŸ“‘ Merging into PDF…")
1340
+ with the code below.
1341
+
1342
+ Add this constant near MARK_COLOR / N_LINES at the top of the function:
1343
+ PADDING = 24 # px gap between rightmost content pixel and marks
1344
+ """
1345
+
1346
+ # ── add near the other constants inside imprint_marks_using_mapping ───────────
1347
+ PADDING = 24 # minimum gap (px) between student content and marks
1348
+
1349
+ # ── annotation ────────────────────────────────────────────────────────────
1350
+ print("πŸ–Š Annotating pages…")
1351
+ for i, page in enumerate(pages):
1352
+ pn = i + 1
1353
+ img_cv = cv2.cvtColor(np.array(page.convert("RGB")), cv2.COLOR_RGB2BGR)
1354
+ h, w = img_cv.shape[:2]
1355
+ line_h = h / N_LINES
1356
+
1357
+ page_marks = [m for m in all_placements if m.get("page") == pn]
1358
+
1359
+ if not page_marks:
1360
+ ann = f"annotated_page_{pn}.png"
1361
+ cv2.imwrite(ann, img_cv)
1362
+ ann_paths.append(ann)
1363
+ print(f" βœ… Page {pn}: no marks, saved unchanged.")
1364
+ continue
1365
+
1366
+ # ── SNAP nearby line numbers together (within Β±1) ─────────────────────
1367
+ # Sort marks by line number
1368
+ page_marks.sort(key=lambda m: m.get("line", 1))
1369
+
1370
+ # Group marks: if next mark's line is within 1 of current group's min,
1371
+ # merge it into the same group β†’ one label per visual "band"
1372
+ from collections import defaultdict
1373
+ groups = [] # list of (representative_line, [mark, mark, ...])
1374
+ for item in page_marks:
1375
+ ln = item.get("line", 1)
1376
+ mark = item.get("mark", "?")
1377
+ # Check if this line fits into the last group (within Β±1)
1378
+ if groups and abs(ln - groups[-1][0]) <= 1:
1379
+ groups[-1][1].append(mark)
1380
+ else:
1381
+ groups.append((ln, [mark]))
1382
+
1383
+ # Build one label per group
1384
+ line_labels = {rep_ln: " ".join(marks) for rep_ln, marks in groups}
1385
+
1386
+ # ── measure rightmost content pixel per line ───────────────────────────
1387
+ gray = cv2.cvtColor(img_cv, cv2.COLOR_BGR2GRAY)
1388
+ THRESHOLD = 230
1389
+ PADDING = 30
1390
+ FONT = cv2.FONT_HERSHEY_SIMPLEX
1391
+ FONT_SCALE = 1.4
1392
+ FONT_THICK = 2
1393
+ COLOR = (0, 0, 200)
1394
+
1395
+ def rightmost_content(li_0):
1396
+ y0 = int(li_0 * line_h)
1397
+ y1 = min(h, int((li_0 + 1) * line_h))
1398
+ band = gray[y0:y1, :]
1399
+ cols = np.where(np.any(band < THRESHOLD, axis=0))[0]
1400
+ return int(cols[-1]) if cols.size else 0
1401
+
1402
+ def label_width(text):
1403
+ (tw, _), _ = cv2.getTextSize(text, FONT, FONT_SCALE, FONT_THICK)
1404
+ return tw
1405
+
1406
+ # ── calculate canvas extension ─────────────────────────────────────────
1407
+ max_ext = 0
1408
+ for ln, label in line_labels.items():
1409
+ cx = rightmost_content(ln - 1)
1410
+ needed = cx + PADDING + label_width(label) + PADDING
1411
+ max_ext = max(max_ext, needed - w)
1412
+
1413
+ if max_ext > 0:
1414
+ strip = np.full((h, int(max_ext), 3), 255, dtype=np.uint8)
1415
+ img_cv = np.hstack([img_cv, strip])
1416
+ print(f" πŸ“ Page {pn}: canvas +{int(max_ext)}px")
1417
+
1418
+ new_w = img_cv.shape[1]
1419
+
1420
+ # ── draw ONE label per group, right-aligned ────────────────────────────
1421
+ for ln, label in line_labels.items():
1422
+ li = ln - 1
1423
+ (tw, th), _ = cv2.getTextSize(label, FONT, FONT_SCALE, FONT_THICK)
1424
+ y0 = int(li * line_h)
1425
+ y1 = min(h, int((li + 1) * line_h))
1426
+ text_y = y0 + (y1 - y0) // 2 + th // 2
1427
+ text_x = max(new_w - PADDING - tw,
1428
+ rightmost_content(li) + PADDING)
1429
+ cv2.putText(img_cv, label, (text_x, text_y),
1430
+ FONT, FONT_SCALE, COLOR, FONT_THICK, cv2.LINE_AA)
1431
+ print(f" πŸ–Š [{pn}] line {ln} | \"{label}\" | x={text_x} y={text_y}")
1432
+
1433
+ ann = f"annotated_page_{pn}.png"
1434
+ cv2.imwrite(ann, img_cv)
1435
+ ann_paths.append(ann)
1436
+ print(f" βœ… Page {pn} saved ({new_w}Γ—{h})")
1437
+
1438
+ # ── saving (outside, unchanged) ────────────────────────────────────────────
1439
+ print("πŸ“‘ Merging into PDF…")
1440
+ with open(output_pdf, "wb") as f:
1441
+ f.write(img2pdf.convert(ann_paths))
1442
+
1443
+ for p in overlays + ann_paths:
1444
+ if os.path.exists(p):
1445
+ os.remove(p)
1446
+
1447
+ compressed = compress_pdf(output_pdf)
1448
+ print("βœ… Imprinted PDF saved to:", compressed)
1449
+ return compressed
1450
 
1451
  def normalize_question_id(qid):
1452
  """
 
1467
 
1468
  return qid
1469
 
 
1470
  """
1471
  Convert PDF to images, create grid-numbered images for batch sending to Gemini,
1472
  then annotate and produce imprinted PDF.
 
1577
  compressed = compress_pdf(output_pdf)
1578
  print("πŸ“‘ Imprinted PDF saved to:", compressed)
1579
  return compressed
 
1580
  def extract_pdf_pages_as_images(pdf_path, page_numbers, prefix):
1581
  """
1582
  Extracts unique pages (1-based) from a PDF as images, saves as PNG, returns list of file paths.
 
1668
 
1669
  print("1.i) Transcribing QP+MS (questions first, then full markscheme, with graph detection)...")
1670
  qpms_prompt = QP_MS_TRANSCRIPTION_PROMPT["content"] + "\nAt the end, also list all questions in the markscheme where a graph is expected, in the format:\nGraph expected in:\n- Question <number> β†’ Page <number>\n(One per line, after ==== MARKSCHEME END ====)"
1671
+ qpms_text = gemini_generate_content(qpms_prompt, file_upload_obj=merged_uploaded, model_name="gemini-2.5-flash", fallback_model="gemini-1.5-flash-8b", fallback_model_2="gemini-2.5-flash-lite", file_path=merged_qpms_path)
1672
  print("πŸ“„ QP+MS transcription received. Saving debug file: debug_qpms_transcript.txt")
1673
  with open("debug_qpms_transcript.txt", "w", encoding="utf-8") as f:
1674
  f.write(qpms_text)
 
1686
 
1687
  print("1.ii) Building AS transcription prompt with expected question IDs and graph detection, sending to Gemini...")
1688
  as_prompt = build_as_cot_prompt_with_expected_ids(extracted_ids, qpms_text) + "\nAt the end, also list all answers where a graph is found, in the format:\nGraph found in:\n- Answer <number> β†’ Page <number>\n(One per line, after all answers)"
1689
+ as_text = gemini_generate_content(as_prompt, file_upload_obj=ans_uploaded, model_name="gemini-2.5-flash", fallback_model="gemini-1.5-flash-8b", fallback_model_2="gemini-2.5-flash-lite", file_path=ans_path)
1690
  print("πŸ“ AS transcription received. Saving debug file: debug_as_transcript.txt")
1691
  with open("debug_as_transcript.txt", "w", encoding="utf-8") as f:
1692
  f.write(as_text)
 
1811
  else:
1812
  return error_msg, "", "", None, None
1813
 
1814
+ # βœ… Only extract local paths β€” do NOT upload yet
1815
+ qp_path = qp_file_obj.name
1816
+ ms_path = ms_file_obj.name
1817
+ ans_path = ans_file_obj.name
1818
+ run_timestamp = str(int(time.time())) # generate timestamp here
1819
 
1820
+ # Run the grading pipeline first
1821
  qpms_text, as_text, grading_text, grading_pdf_path, imprinted_pdf_path, output_urls = align_and_grade_pipeline(
1822
  qp_path, ms_path, ans_path, subject=subject_choice, imprint=imprint_flag, run_timestamp=run_timestamp
1823
  )
1824
 
1825
+ # βœ… Only upload to Supabase if pipeline succeeded (no error string returned)
1826
+ input_urls = {"qp_url": None, "ms_url": None, "ans_url": None}
1827
+ if supabase_client and isinstance(qpms_text, str) and not qpms_text.startswith("❌"):
1828
+ print("\nπŸ“€ Uploading input files to Supabase (pipeline succeeded)...")
1829
+ input_urls["qp_url"] = upload_file_to_supabase(qp_path, "qp", run_timestamp)
1830
+ input_urls["ms_url"] = upload_file_to_supabase(ms_path, "ms", run_timestamp)
1831
+ input_urls["ans_url"] = upload_file_to_supabase(ans_path, "ans", run_timestamp)
1832
+
1833
+
1834
  # Build URLs summary
1835
  urls_summary = ""
1836
  if supabase_client: