From 43f0a9104cf32d624affa84e902dcd41c64c7614 Mon Sep 17 00:00:00 2001 From: kcar Date: Sun, 7 Jun 2026 20:05:47 +0100 Subject: [PATCH] [verified] round-trip S5 exam layout fields --- routers/exam/schemas.py | 29 +++++++++++++++++++++++- routers/exam/templates.py | 43 +++++++++++++++++++++++++++++++++++- tests/test_exam_templates.py | 38 +++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/routers/exam/schemas.py b/routers/exam/schemas.py index 11a17dc..099de21 100644 --- a/routers/exam/schemas.py +++ b/routers/exam/schemas.py @@ -60,6 +60,11 @@ class QuestionPayload(BaseModel): # Drawn Part box geometry (73-exam-marker-regions.sql). Null for derived main questions. bounds: Optional[Dict[str, Any]] = None # {x,y,w,h} page: Optional[int] = None + # S5 AI/manual seam + provenance. Existing manual rows default to authoritative. + source: Literal["manual", "ai"] = "manual" + confirmed: bool = True + confidence: Optional[float] = Field(default=None, ge=0, le=1) + derivation: Optional[str] = None class ResponseAreaPayload(BaseModel): @@ -77,7 +82,10 @@ class ResponseAreaPayload(BaseModel): context_type: Optional[str] = None source: Literal["manual", "ai"] = "manual" confirmed: bool = True - confidence: Optional[float] = None + confidence: Optional[float] = Field(default=None, ge=0, le=1) + # Only meaningful for kind='mark_area': part_marks|question_total|grader_box. + mark_subtype: Optional[Literal["part_marks", "question_total", "grader_box"]] = None + derivation: Optional[str] = None class BoundaryPayload(BaseModel): @@ -89,6 +97,24 @@ class BoundaryPayload(BaseModel): bounds: Optional[Dict[str, Any]] = None source: Literal["manual", "ai"] = "manual" confirmed: bool = True + confidence: Optional[float] = Field(default=None, ge=0, le=1) + derivation: Optional[str] = None + + +class TemplateLayoutPayload(BaseModel): + id: Optional[str] = None + page_index: int + role: Optional[str] = None + margin_left: Optional[float] = None + margin_right: Optional[float] = None + margin_top: Optional[float] = None + margin_bottom: Optional[float] = None + margins_enabled: bool = True + source: Literal["manual", "ai"] = "manual" + confirmed: bool = True + confidence: Optional[float] = Field(default=None, ge=0, le=1) + derivation: Optional[str] = None + meta: Dict[str, Any] = Field(default_factory=dict) class TemplateReplaceRequest(BaseModel): @@ -97,6 +123,7 @@ class TemplateReplaceRequest(BaseModel): questions: List[QuestionPayload] = Field(default_factory=list) response_areas: List[ResponseAreaPayload] = Field(default_factory=list) boundaries: List[BoundaryPayload] = Field(default_factory=list) + layout: List[TemplateLayoutPayload] = Field(default_factory=list) class PatchQuestionRequest(BaseModel): diff --git a/routers/exam/templates.py b/routers/exam/templates.py index c707ba3..71c2923 100644 --- a/routers/exam/templates.py +++ b/routers/exam/templates.py @@ -315,11 +315,19 @@ async def get_template( boundaries = _rows( ctx.supabase.table("exam_boundaries").select("*").eq("template_id", template_id).execute() ) + layout = _rows( + ctx.supabase.table("exam_template_layout") + .select("*") + .eq("template_id", template_id) + .order("page_index") + .execute() + ) return { **template, "questions": questions, "response_areas": response_areas, "boundaries": boundaries, + "layout": layout, } @@ -412,6 +420,7 @@ async def replace_template( # remove them first (we delete by template_id rather than rely on cascade for predictability). sb.table("exam_response_areas").delete().eq("template_id", template_id).execute() sb.table("exam_boundaries").delete().eq("template_id", template_id).execute() + sb.table("exam_template_layout").delete().eq("template_id", template_id).execute() sb.table("exam_questions").delete().eq("template_id", template_id).execute() # Re-insert, preserving client-supplied UUIDs (Neo4j join keys, spec ยง2). @@ -431,6 +440,10 @@ async def replace_template( "spec_ref": q.spec_ref, "bounds": q.bounds, # drawn Part box (73); null for derived main questions "page": q.page, + "source": q.source, + "confirmed": q.confirmed, + "confidence": q.confidence, + "derivation": q.derivation, } if q.id: r["id"] = q.id @@ -451,6 +464,8 @@ async def replace_template( "source": ra.source, "confirmed": ra.confirmed, "confidence": ra.confidence, + "mark_subtype": ra.mark_subtype, + "derivation": ra.derivation, } if ra.id: r["id"] = ra.id @@ -469,15 +484,41 @@ async def replace_template( "bounds": b.bounds, "source": b.source, "confirmed": b.confirmed, + "confidence": b.confidence, + "derivation": b.derivation, } if b.id: r["id"] = b.id b_rows.append({k: v for k, v in r.items() if v is not None}) sb.table("exam_boundaries").insert(b_rows).execute() + if body.layout: + layout_rows = [] + for item in body.layout: + r = { + "template_id": template_id, + "page_index": item.page_index, + "role": item.role, + "margin_left": item.margin_left, + "margin_right": item.margin_right, + "margin_top": item.margin_top, + "margin_bottom": item.margin_bottom, + "margins_enabled": item.margins_enabled, + "source": item.source, + "confirmed": item.confirmed, + "confidence": item.confidence, + "derivation": item.derivation, + "meta": item.meta, + } + if item.id: + r["id"] = item.id + layout_rows.append({k: v for k, v in r.items() if v is not None}) + sb.table("exam_template_layout").insert(layout_rows).execute() + logger.info( f"Exam template {template_id} replaced: {len(body.questions)} questions, " - f"{len(body.response_areas)} regions, {len(body.boundaries)} boundaries" + f"{len(body.response_areas)} regions, {len(body.boundaries)} boundaries, " + f"{len(body.layout)} layout rows" ) # R3.5.4: a successful save enqueues a graph projection into cc.public.exams. BackgroundTasks # is acceptable for Sprint 4 (durability via a real queue is a later step); failures are diff --git a/tests/test_exam_templates.py b/tests/test_exam_templates.py index e6626d7..df6f1d3 100644 --- a/tests/test_exam_templates.py +++ b/tests/test_exam_templates.py @@ -255,12 +255,14 @@ def test_get_template_bundles_children(): "exam_questions": [{"id": "q1", "template_id": "t1", "label": "01", "order": 0}], "exam_response_areas": [{"id": "r1", "template_id": "t1", "question_id": "q1", "page": 1}], "exam_boundaries": [{"id": "b1", "template_id": "t1", "page_index": 0, "y": 10}], + "exam_template_layout": [{"id": "l1", "template_id": "t1", "page_index": 0, "role": "question_page"}], } client, _ = make_client(store=store) body = client.get("/api/exam/templates/t1").json() assert len(body["questions"]) == 1 assert len(body["response_areas"]) == 1 assert len(body["boundaries"]) == 1 + assert body["layout"] == [{"id": "l1", "template_id": "t1", "page_index": 0, "role": "question_page"}] def test_get_other_institute_template_is_404(): @@ -315,6 +317,42 @@ def test_put_persists_region_kinds_and_part_geometry(): assert ras["c1"]["context_type"] == "data_table" + +def test_put_round_trips_s5_layout_and_provenance_fields(): + store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}]} + client, store = make_client(store=store) + payload = { + "questions": [{ + "id": "q1", "label": "01", "order": 0, "max_marks": 4, "source": "ai", + "confirmed": False, "confidence": 0.82, "derivation": "docling:heading", + }], + "response_areas": [{ + "id": "m1", "question_id": "q1", "page": 1, "bounds": {"x": 1}, "kind": "mark_area", + "mark_subtype": "grader_box", "source": "ai", "confirmed": False, "confidence": 0.71, + "derivation": "detected-explicit-grader-box", + }], + "boundaries": [{ + "id": "b1", "question_id": "q1", "page_index": 0, "y": 99, "source": "ai", + "confirmed": False, "confidence": 0.66, "derivation": "bbox-gap", + }], + "layout": [{ + "id": "l1", "page_index": 0, "role": "question_page", "margin_left": 12.5, + "margins_enabled": False, "source": "ai", "confirmed": False, "confidence": 0.93, + "derivation": "docling-page-layout", "meta": {"columns": 2}, + }], + } + resp = client.put("/api/exam/templates/t1", json=payload) + assert resp.status_code == 200 + assert store["exam_questions"][0]["source"] == "ai" + assert store["exam_questions"][0]["confidence"] == 0.82 + assert store["exam_response_areas"][0]["mark_subtype"] == "grader_box" + assert store["exam_response_areas"][0]["derivation"] == "detected-explicit-grader-box" + assert store["exam_boundaries"][0]["confidence"] == 0.66 + assert store["exam_template_layout"][0]["meta"] == {"columns": 2} + body = resp.json() + assert body["layout"][0]["id"] == "l1" + assert body["layout"][0]["margins_enabled"] is False + def test_put_replace_clears_previous_children(): store = { "exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}],