Spaces:
Running
Running
Imprint
#4
by Narendra5805 - opened
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-
|
| 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
|
| 1121 |
-
|
| 1122 |
-
|
| 1123 |
-
|
| 1124 |
-
|
| 1125 |
-
|
| 1126 |
-
|
| 1127 |
-
|
| 1128 |
-
|
| 1129 |
-
|
| 1130 |
-
|
| 1131 |
-
|
| 1132 |
-
|
| 1133 |
-
|
| 1134 |
-
|
| 1135 |
-
|
| 1136 |
-
|
| 1137 |
-
|
| 1138 |
-
|
| 1139 |
-
|
| 1140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1141 |
Grading JSON:
|
| 1142 |
-
{json.dumps(grading_json, indent=2)}
|
| 1143 |
|
| 1144 |
-
|
| 1145 |
-
|
| 1146 |
-
|
| 1147 |
-
|
| 1148 |
-
|
| 1149 |
-
|
| 1150 |
-
|
| 1151 |
-
|
| 1152 |
-
|
| 1153 |
-
)
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
|
| 1164 |
-
|
| 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 |
-
|
| 1178 |
-
|
| 1179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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-
|
| 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 |
-
#
|
| 1547 |
-
qp_path
|
| 1548 |
-
|
| 1549 |
-
|
|
|
|
| 1550 |
|
| 1551 |
-
# Run the grading pipeline
|
| 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:
|