[verified] add exam template auto-map endpoint
This commit is contained in:
@@ -481,3 +481,144 @@ def test_neo4j_sync_non_owner_403():
|
||||
def test_neo4j_sync_404():
|
||||
client, _ = make_client(store={"exam_templates": []})
|
||||
assert client.post("/api/exam/templates/does-not-exist/neo4j-sync").status_code == 404
|
||||
|
||||
|
||||
# ─── S5 auto-map endpoint ────────────────────────────────────────────────────
|
||||
|
||||
def _first_pass_template():
|
||||
return {
|
||||
"meta": {"schema": "exam-template/first-pass/v1", "paper_code": "8463/1", "n_pages": 1},
|
||||
"margins": [
|
||||
{"edge": "left", "axis": "x", "value": 50, "scope": "document", "source": "auto", "confirmed": False},
|
||||
{"edge": "right", "axis": "x", "value": 550, "scope": "document", "source": "auto", "confirmed": False},
|
||||
{"edge": "top", "axis": "y", "value": 780, "scope": "page", "page": 1, "source": "auto", "confirmed": False},
|
||||
{"edge": "bottom", "axis": "y", "value": 60, "scope": "page", "page": 1, "source": "auto", "confirmed": False},
|
||||
],
|
||||
"pages": {
|
||||
"1": {
|
||||
"role": "question", "role_source": "auto", "margins_enabled": True,
|
||||
"main_bands": [{"question": "01", "y_start": 780, "y_end": 60, "source": "auto", "confirmed": False}],
|
||||
"part_bands": [{"label": "01.1", "question": "01", "y_start": 700, "y_end": 500, "label_box": {"l": 50, "t": 700, "r": 90, "b": 680, "coord_origin": "BOTTOMLEFT"}, "source": "auto", "confirmed": False}],
|
||||
"furniture": [], "figures": [], "tables": [],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _patch_auto_map(monkeypatch, store, *, fast=True):
|
||||
monkeypatch.setattr(templates_mod, "StorageAdmin", _FakeStorageAdmin)
|
||||
monkeypatch.setattr(templates_mod, "SupabaseServiceRoleClient", lambda: _FakeServiceRoleClient(store))
|
||||
monkeypatch.setattr(templates_mod, "_pdf_has_text_layer", lambda _pdf: fast)
|
||||
monkeypatch.setattr(templates_mod, "auto_map", lambda *_a, **_k: _first_pass_template())
|
||||
monkeypatch.setattr(templates_mod, "detect_response_regions_from_pdf", lambda *_a, **_k: [])
|
||||
monkeypatch.setattr(templates_mod, "_pdf_page_geometry", lambda _pdf: [{"media_x0": 0.0, "crop_x0": 0.0, "crop_y0": 0.0, "page_pt_w": 600.0, "page_pt_h": 800.0, "rendered_w": 600.0, "rendered_h": 800.0, "page_top": 0.0}])
|
||||
templates_mod._AUTO_MAP_JOB_STATUS.clear()
|
||||
|
||||
|
||||
def _template_with_source(owner=TEACHER):
|
||||
return {
|
||||
"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": owner, "source_file_id": "f1"}],
|
||||
"files": [{"id": "f1", "bucket": "cc.users", "path": "exam-marker/i/c/f1/paper.pdf", "name": "paper.pdf"}],
|
||||
}
|
||||
|
||||
|
||||
def test_box_to_canvas_uses_cropbox_as_page_origin():
|
||||
pages = [{
|
||||
"media_x0": 0.0, "crop_x0": 100.0, "crop_y0": 200.0,
|
||||
"page_pt_w": 400.0, "page_pt_h": 600.0,
|
||||
"rendered_w": 400.0, "rendered_h": 600.0,
|
||||
"page_top": 25.0,
|
||||
}]
|
||||
box = {"l": 100.0, "t": 800.0, "r": 180.0, "b": 760.0, "coord_origin": "BOTTOMLEFT"}
|
||||
assert templates_mod._box_to_canvas(box, 1, pages) == {"x": 0.0, "y": 25.0, "w": 80.0, "h": 40.0}
|
||||
|
||||
|
||||
def test_response_region_types_are_mapped_to_response_form_enum(monkeypatch):
|
||||
monkeypatch.setattr(templates_mod, "_pdf_page_geometry", lambda _pdf: [{"media_x0": 0.0, "crop_x0": 0.0, "crop_y0": 0.0, "page_pt_w": 600.0, "page_pt_h": 800.0, "rendered_w": 600.0, "rendered_h": 800.0, "page_top": 0.0}])
|
||||
first_pass = _first_pass_template()
|
||||
regions = [
|
||||
{"page_index": 0, "bbox": {"l": 50, "t": 700, "r": 100, "b": 680, "coord_origin": "BOTTOMLEFT"}, "region_type": "answer_lines", "confidence": 0.9},
|
||||
{"page_index": 0, "bbox": {"l": 50, "t": 650, "r": 100, "b": 620, "coord_origin": "BOTTOMLEFT"}, "region_type": "answer_box", "confidence": 0.9},
|
||||
{"page_index": 0, "bbox": {"l": 50, "t": 600, "r": 100, "b": 560, "coord_origin": "BOTTOMLEFT"}, "region_type": "working_space", "confidence": 0.9},
|
||||
]
|
||||
rows = templates_mod._map_first_pass_to_rows("t1", first_pass, b"%PDF", regions)
|
||||
forms = [r.get("response_form") for r in rows["response_areas"] if r.get("derivation") == "opencv-response-region"]
|
||||
assert forms == ["lines", "answer-box", "working"]
|
||||
|
||||
|
||||
def test_auto_map_fast_path_merges_ai_rows_and_returns_detail(monkeypatch):
|
||||
store = _template_with_source()
|
||||
client, store = make_client(store=store)
|
||||
_patch_auto_map(monkeypatch, store, fast=True)
|
||||
resp = client.post("/api/exam/templates/t1/auto-map")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["exam_code"] == "8463/1"
|
||||
assert body["layout"] and body["layout"][0]["source"] == "ai"
|
||||
assert any(q["label"] == "01.1" and q["source"] == "ai" and q["confirmed"] is False for q in store["exam_questions"])
|
||||
assert store["exam_boundaries"] and store["exam_boundaries"][0]["derivation"] == "docling-main-band"
|
||||
|
||||
|
||||
def test_auto_map_preserves_manual_and_confirmed_rows_on_rerun(monkeypatch):
|
||||
store = _template_with_source()
|
||||
store.update({
|
||||
"exam_questions": [
|
||||
{"id": "manual", "template_id": "t1", "label": "manual", "order": 0, "source": "manual", "confirmed": True},
|
||||
{"id": "accepted-ai", "template_id": "t1", "label": "accepted", "order": 1, "source": "ai", "confirmed": True},
|
||||
{"id": "old-ai", "template_id": "t1", "label": "old", "order": 2, "source": "ai", "confirmed": False},
|
||||
],
|
||||
"exam_response_areas": [], "exam_boundaries": [], "exam_template_layout": [],
|
||||
})
|
||||
client, store = make_client(store=store)
|
||||
_patch_auto_map(monkeypatch, store, fast=True)
|
||||
assert client.post("/api/exam/templates/t1/auto-map").status_code == 200
|
||||
ids = {q["id"] for q in store["exam_questions"]}
|
||||
assert {"manual", "accepted-ai"}.issubset(ids)
|
||||
assert "old-ai" not in ids
|
||||
|
||||
|
||||
def test_auto_map_non_owner_is_403_before_download(monkeypatch):
|
||||
store = _template_with_source(owner=OTHER_TEACHER)
|
||||
client, store = make_client(user_id=TEACHER, institute_ids=(INST_A,), store=store)
|
||||
def _no_download(*_a, **_k):
|
||||
raise AssertionError("download should not run before owner gate")
|
||||
monkeypatch.setattr(templates_mod, "StorageAdmin", _no_download)
|
||||
resp = client.post("/api/exam/templates/t1/auto-map")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_auto_map_owner_lost_institute_membership_is_404_before_download(monkeypatch):
|
||||
store = _template_with_source(owner=TEACHER)
|
||||
client, store = make_client(user_id=TEACHER, institute_ids=(INST_B,), store=store)
|
||||
def _no_download(*_a, **_k):
|
||||
raise AssertionError("download should not run before visibility gate")
|
||||
monkeypatch.setattr(templates_mod, "StorageAdmin", _no_download)
|
||||
resp = client.post("/api/exam/templates/t1/auto-map")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_auto_map_blocks_when_marks_recorded(monkeypatch):
|
||||
store = _template_with_source()
|
||||
store.update({
|
||||
"marking_batches": [{"id": "b1", "template_id": "t1"}],
|
||||
"mark_entries": [{"id": "m1", "batch_id": "b1"}],
|
||||
})
|
||||
client, store = make_client(store=store)
|
||||
_patch_auto_map(monkeypatch, store, fast=True)
|
||||
resp = client.post("/api/exam/templates/t1/auto-map")
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_auto_map_ocr_returns_job_id_and_status_completes(monkeypatch):
|
||||
store = _template_with_source()
|
||||
client, store = make_client(store=store)
|
||||
_patch_auto_map(monkeypatch, store, fast=False)
|
||||
resp = client.post("/api/exam/templates/t1/auto-map")
|
||||
assert resp.status_code == 202
|
||||
job_id = resp.json()["job_id"]
|
||||
status = client.get(f"/api/exam/templates/t1/auto-map/{job_id}/status")
|
||||
assert status.status_code == 200
|
||||
body = status.json()
|
||||
assert body["status"] == "completed"
|
||||
assert body["counts"]["questions"] >= 2
|
||||
assert body["template"]["layout"]
|
||||
|
||||
Reference in New Issue
Block a user