Compare commits
No commits in common. "2ebbfc1cf405cc458a1f9ac25713f8ce8cfa3a3d" and "71ddceb19e1019a1a51e285f02aa92b95d2c0401" have entirely different histories.
2ebbfc1cf4
...
71ddceb19e
@ -60,11 +60,6 @@ class QuestionPayload(BaseModel):
|
|||||||
# Drawn Part box geometry (73-exam-marker-regions.sql). Null for derived main questions.
|
# Drawn Part box geometry (73-exam-marker-regions.sql). Null for derived main questions.
|
||||||
bounds: Optional[Dict[str, Any]] = None # {x,y,w,h}
|
bounds: Optional[Dict[str, Any]] = None # {x,y,w,h}
|
||||||
page: Optional[int] = None
|
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):
|
class ResponseAreaPayload(BaseModel):
|
||||||
@ -82,10 +77,7 @@ class ResponseAreaPayload(BaseModel):
|
|||||||
context_type: Optional[str] = None
|
context_type: Optional[str] = None
|
||||||
source: Literal["manual", "ai"] = "manual"
|
source: Literal["manual", "ai"] = "manual"
|
||||||
confirmed: bool = True
|
confirmed: bool = True
|
||||||
confidence: Optional[float] = Field(default=None, ge=0, le=1)
|
confidence: Optional[float] = None
|
||||||
# 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):
|
class BoundaryPayload(BaseModel):
|
||||||
@ -97,24 +89,6 @@ class BoundaryPayload(BaseModel):
|
|||||||
bounds: Optional[Dict[str, Any]] = None
|
bounds: Optional[Dict[str, Any]] = None
|
||||||
source: Literal["manual", "ai"] = "manual"
|
source: Literal["manual", "ai"] = "manual"
|
||||||
confirmed: bool = True
|
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):
|
class TemplateReplaceRequest(BaseModel):
|
||||||
@ -123,7 +97,6 @@ class TemplateReplaceRequest(BaseModel):
|
|||||||
questions: List[QuestionPayload] = Field(default_factory=list)
|
questions: List[QuestionPayload] = Field(default_factory=list)
|
||||||
response_areas: List[ResponseAreaPayload] = Field(default_factory=list)
|
response_areas: List[ResponseAreaPayload] = Field(default_factory=list)
|
||||||
boundaries: List[BoundaryPayload] = Field(default_factory=list)
|
boundaries: List[BoundaryPayload] = Field(default_factory=list)
|
||||||
layout: List[TemplateLayoutPayload] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class PatchQuestionRequest(BaseModel):
|
class PatchQuestionRequest(BaseModel):
|
||||||
|
|||||||
@ -315,19 +315,11 @@ async def get_template(
|
|||||||
boundaries = _rows(
|
boundaries = _rows(
|
||||||
ctx.supabase.table("exam_boundaries").select("*").eq("template_id", template_id).execute()
|
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 {
|
return {
|
||||||
**template,
|
**template,
|
||||||
"questions": questions,
|
"questions": questions,
|
||||||
"response_areas": response_areas,
|
"response_areas": response_areas,
|
||||||
"boundaries": boundaries,
|
"boundaries": boundaries,
|
||||||
"layout": layout,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -420,7 +412,6 @@ async def replace_template(
|
|||||||
# remove them first (we delete by template_id rather than rely on cascade for predictability).
|
# 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_response_areas").delete().eq("template_id", template_id).execute()
|
||||||
sb.table("exam_boundaries").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()
|
sb.table("exam_questions").delete().eq("template_id", template_id).execute()
|
||||||
|
|
||||||
# Re-insert, preserving client-supplied UUIDs (Neo4j join keys, spec §2).
|
# Re-insert, preserving client-supplied UUIDs (Neo4j join keys, spec §2).
|
||||||
@ -440,10 +431,6 @@ async def replace_template(
|
|||||||
"spec_ref": q.spec_ref,
|
"spec_ref": q.spec_ref,
|
||||||
"bounds": q.bounds, # drawn Part box (73); null for derived main questions
|
"bounds": q.bounds, # drawn Part box (73); null for derived main questions
|
||||||
"page": q.page,
|
"page": q.page,
|
||||||
"source": q.source,
|
|
||||||
"confirmed": q.confirmed,
|
|
||||||
"confidence": q.confidence,
|
|
||||||
"derivation": q.derivation,
|
|
||||||
}
|
}
|
||||||
if q.id:
|
if q.id:
|
||||||
r["id"] = q.id
|
r["id"] = q.id
|
||||||
@ -464,8 +451,6 @@ async def replace_template(
|
|||||||
"source": ra.source,
|
"source": ra.source,
|
||||||
"confirmed": ra.confirmed,
|
"confirmed": ra.confirmed,
|
||||||
"confidence": ra.confidence,
|
"confidence": ra.confidence,
|
||||||
"mark_subtype": ra.mark_subtype,
|
|
||||||
"derivation": ra.derivation,
|
|
||||||
}
|
}
|
||||||
if ra.id:
|
if ra.id:
|
||||||
r["id"] = ra.id
|
r["id"] = ra.id
|
||||||
@ -484,41 +469,15 @@ async def replace_template(
|
|||||||
"bounds": b.bounds,
|
"bounds": b.bounds,
|
||||||
"source": b.source,
|
"source": b.source,
|
||||||
"confirmed": b.confirmed,
|
"confirmed": b.confirmed,
|
||||||
"confidence": b.confidence,
|
|
||||||
"derivation": b.derivation,
|
|
||||||
}
|
}
|
||||||
if b.id:
|
if b.id:
|
||||||
r["id"] = b.id
|
r["id"] = b.id
|
||||||
b_rows.append({k: v for k, v in r.items() if v is not None})
|
b_rows.append({k: v for k, v in r.items() if v is not None})
|
||||||
sb.table("exam_boundaries").insert(b_rows).execute()
|
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(
|
logger.info(
|
||||||
f"Exam template {template_id} replaced: {len(body.questions)} questions, "
|
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
|
# 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
|
# is acceptable for Sprint 4 (durability via a real queue is a later step); failures are
|
||||||
|
|||||||
@ -255,14 +255,12 @@ def test_get_template_bundles_children():
|
|||||||
"exam_questions": [{"id": "q1", "template_id": "t1", "label": "01", "order": 0}],
|
"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_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_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)
|
client, _ = make_client(store=store)
|
||||||
body = client.get("/api/exam/templates/t1").json()
|
body = client.get("/api/exam/templates/t1").json()
|
||||||
assert len(body["questions"]) == 1
|
assert len(body["questions"]) == 1
|
||||||
assert len(body["response_areas"]) == 1
|
assert len(body["response_areas"]) == 1
|
||||||
assert len(body["boundaries"]) == 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():
|
def test_get_other_institute_template_is_404():
|
||||||
@ -317,42 +315,6 @@ def test_put_persists_region_kinds_and_part_geometry():
|
|||||||
assert ras["c1"]["context_type"] == "data_table"
|
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():
|
def test_put_replace_clears_previous_children():
|
||||||
store = {
|
store = {
|
||||||
"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}],
|
"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user