When EXAM_EXTRACT_URL is set, POST /templates/{id}/auto-map now runs the spike's
full recognition pipeline (via the P1 service) instead of the thin first-pass, as
an async job. New modules/services/exam_extract.py (HTTP client: POST paper, poll,
return the analyse contract). templates.py: _map_service_contract_to_rows adapts the
page-fraction analyse contract onto the app's 780-wide stacked canvas (fraction ×
rendered dims + page_top), re-namespaces the service's uuid5s per template (stable
re-map, no cross-template collision), and is FK-safe (drops orphan regions, de-parents
dangling parents). Rich meta (OMR boxes, select_n, unit/quantity, n_options) persists
to the new exam_response_areas.meta column (migration 75). Typed response_form + mcq
answer_type + kind:context reference links map natively (schema already allows them).
The thin path is preserved as fallback when EXAM_EXTRACT_URL is unset. Unit test
covers the mapping (coord conversion, id remap, FK-safety, meta passthrough).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
76 lines
4.1 KiB
Python
76 lines
4.1 KiB
Python
"""Test the extraction-service contract → app ghost-row mapping (P2).
|
||
|
||
Mocks the PDF geometry so the mapper is tested in isolation: page-fraction → 780-canvas conversion,
|
||
per-template uuid remap, FK-safety (orphan regions / dangling parents), and rich meta passthrough.
|
||
"""
|
||
import routers.exam.templates as T
|
||
|
||
TID = "11111111-1111-1111-1111-111111111111"
|
||
|
||
# two pages, each rendered 780 wide × 1000 tall; page 2 stacked below page 1
|
||
_GEOM = [
|
||
{"rendered_w": 780.0, "rendered_h": 1000.0, "page_top": 0.0, "page_pt_w": 595.0, "page_pt_h": 842.0, "crop_x0": 0.0, "crop_y0": 0.0},
|
||
{"rendered_w": 780.0, "rendered_h": 1000.0, "page_top": 1000.0, "page_pt_w": 595.0, "page_pt_h": 842.0, "crop_x0": 0.0, "crop_y0": 0.0},
|
||
]
|
||
|
||
_CONTRACT = {
|
||
"coordinate_space": "page_fraction",
|
||
"suggestions": {
|
||
"questions": [
|
||
{"uid": "Q1", "label": "1", "order": 0, "max_marks": 5, "is_container": True, "page": 1},
|
||
{"uid": "Q1a", "parent_uid": "Q1", "label": "1(a)", "order": 1, "max_marks": 3,
|
||
"answer_type": "short", "is_container": False, "page": 1},
|
||
{"uid": "Q1b", "parent_uid": "Q1", "label": "1(b)", "order": 2, "max_marks": 2,
|
||
"answer_type": "mcq", "is_container": False, "page": 2},
|
||
],
|
||
"response_areas": [
|
||
{"uid": "RA1", "question_uid": "Q1a", "page": 1, "kind": "response", "response_form": "answer-box",
|
||
"confidence": 0.9, "bounds": {"x": 0.1, "y": 0.2, "w": 0.5, "h": 0.05},
|
||
"meta": {"unit": "m/s", "quantity": "v", "n_lines": 1}},
|
||
{"uid": "RA2", "question_uid": "Q1b", "page": 2, "kind": "response", "response_form": "tick-boxes",
|
||
"bounds": {"x": 0.2, "y": 0.3, "w": 0.4, "h": 0.2},
|
||
"meta": {"select_n": 2, "n_options": 5, "boxes": [{"x0": 0.2, "y0": 0.3, "fill": 0.8}]}},
|
||
{"uid": "CTX1", "question_uid": "Q1b", "page": 2, "kind": "context", "response_form": None,
|
||
"bounds": {"x": 0.1, "y": 0.1, "w": 0.6, "h": 0.15}},
|
||
{"uid": "ORPH", "question_uid": "GHOST", "page": 1, "kind": "response", "response_form": "lines",
|
||
"bounds": {"x": 0.1, "y": 0.5, "w": 0.5, "h": 0.1}}, # orphan owner → must be dropped
|
||
],
|
||
"boundaries": [
|
||
{"question_uid": "Q1", "label": "1", "page_index": 0, "y": 0.18, "confidence": 0.85},
|
||
],
|
||
},
|
||
"meta": {"n_pages": 2},
|
||
}
|
||
|
||
|
||
def test_contract_maps_to_canvas_rows(monkeypatch):
|
||
monkeypatch.setattr(T, "_pdf_page_geometry", lambda _b: _GEOM)
|
||
rows = T._map_service_contract_to_rows(TID, _CONTRACT, b"%PDF-1.4")
|
||
|
||
q = rows["questions"]
|
||
assert len(q) == 3
|
||
container = next(x for x in q if x["label"] == "1")
|
||
a = next(x for x in q if x["label"] == "1(a)")
|
||
assert container["is_container"] is True and container["parent_id"] is None
|
||
assert a["parent_id"] == container["id"] # parent remapped to the container's per-template id
|
||
assert a["answer_type"] == "short" and a["max_marks"] == 3
|
||
assert all(x["source"] == "ai" and x["confirmed"] is False for x in q)
|
||
|
||
ra = rows["response_areas"]
|
||
assert len(ra) == 3 # orphan (owner GHOST) dropped
|
||
ids = {r["id"] for r in ra}
|
||
assert len(ids) == 3 # ids unique
|
||
box = next(r for r in ra if r["response_form"] == "answer-box")
|
||
# page-fraction → 780-canvas: x=0.1*780=78, y=0.2*1000=200, w=0.5*780=390, h=0.05*1000=50
|
||
assert box["bounds"] == {"x": 78.0, "y": 200.0, "w": 390.0, "h": 50.0}
|
||
assert box["meta"]["unit"] == "m/s" # rich meta preserved
|
||
tick = next(r for r in ra if r["response_form"] == "tick-boxes")
|
||
# page 2 y stacked: 0.3*1000 + page_top(1000) = 1300
|
||
assert tick["bounds"]["y"] == 1300.0 and tick["meta"]["select_n"] == 2
|
||
ctx = next(r for r in ra if r["kind"] == "context")
|
||
assert ctx["response_form"] is None # context carries no answer form
|
||
assert all(r["question_id"] in {x["id"] for x in q} for r in ra) # FK-safe
|
||
|
||
b = rows["boundaries"]
|
||
assert len(b) == 1 and b[0]["y"] == 180.0 and b[0]["question_id"] == container["id"]
|