Merge S5-6 schema layout/provenance surface (API)
Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled
Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled
This commit is contained in:
commit
2ebbfc1cf4
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user