feat(exam): persist S4-9 region kinds + Part geometry; keep metadata out of graph
Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled

Backend follow-on to migration 73:
- schemas: ResponseAreaPayload.kind extended to response|context|question_number|
  mark_area|reference|furniture + context_type; QuestionPayload gains bounds+page.
- PUT serialization persists Part bounds/page and region context_type.
- Neo4j projection only emits Region nodes for response/context regions; the
  metadata kinds (question_number/mark_area/reference/furniture) are physical-layer
  only and stay out of cc.public.exams.
- Unit test: new kinds + Part geometry + context_type round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
CC Worker 2026-06-06 21:14:20 +00:00
parent 93972a62f7
commit 9c1aee28e2
4 changed files with 44 additions and 2 deletions

View File

@ -138,7 +138,12 @@ def project_template(template_id: str) -> Dict[str, Any]:
counts["assesses"] += (r["n"] if r else 0)
# 6. Region nodes + HAS_REGION edges.
# Only response/context regions are part of the knowledge graph (RegionNode.kind). The other
# S4-9 kinds (question_number, mark_area, reference, furniture) are physical-layer metadata
# about the paper, not curriculum structure — they stay in Supabase, out of cc.public.exams.
for rg in regions:
if rg.get("kind") not in ("response", "context"):
continue
s.run(
"MERGE (r:Region {uuid_string:$uid}) "
"SET r.exam_code=$ec, r.page=$page, r.kind=$kind, r.response_form=$rf, r.node_storage_path=$nsp",

View File

@ -57,17 +57,24 @@ class QuestionPayload(BaseModel):
mark_scheme: Dict[str, Any] = Field(default_factory=dict)
is_container: bool = False
spec_ref: Optional[str] = None
# 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
class ResponseAreaPayload(BaseModel):
id: Optional[str] = None # == Neo4j Region.uuid_string
id: Optional[str] = None # == Neo4j Region.uuid_string (only response/context project)
question_id: str
page: int
bounds: Dict[str, Any] # {x,y,w,h}
kind: Literal["response", "context"]
# S4-9 taxonomy (73-exam-marker-regions.sql): response/context graded-or-stimulus;
# question_number/mark_area = physical metadata; reference = student resource; furniture = ignore.
kind: Literal["response", "context", "question_number", "mark_area", "reference", "furniture"]
response_form: Optional[
Literal["lines", "answer-box", "working", "diagram", "tick-boxes", "table", "blanks"]
] = None
# Optional Context differentiation (v1 generic; future graph/chart/data_table/diagram/code_block/passage).
context_type: Optional[str] = None
source: Literal["manual", "ai"] = "manual"
confirmed: bool = True
confidence: Optional[float] = None

View File

@ -204,6 +204,8 @@ async def replace_template(
"mark_scheme": q.mark_scheme,
"is_container": q.is_container,
"spec_ref": q.spec_ref,
"bounds": q.bounds, # drawn Part box (73); null for derived main questions
"page": q.page,
}
if q.id:
r["id"] = q.id
@ -220,6 +222,7 @@ async def replace_template(
"bounds": ra.bounds,
"kind": ra.kind,
"response_form": ra.response_form,
"context_type": ra.context_type, # 73: optional Context differentiation
"source": ra.source,
"confirmed": ra.confirmed,
"confidence": ra.confidence,

View File

@ -234,6 +234,33 @@ def test_put_replace_persists_children_with_client_ids():
assert body["boundaries"][0]["id"] == "b-uuid-1"
def test_put_persists_region_kinds_and_part_geometry():
# S4-9 taxonomy: Part box geometry on the question; new region kinds + context_type.
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}]}
client, store = make_client(store=store)
resp = client.put("/api/exam/templates/t1", json={
"questions": [
{"id": "q1", "label": "01", "order": 0, "is_container": True},
{"id": "p1", "parent_id": "q1", "label": "01.1", "order": 0, "max_marks": 3,
"bounds": {"x": 1, "y": 2, "w": 3, "h": 4}, "page": 1},
],
"response_areas": [
{"id": "r1", "question_id": "p1", "page": 1, "bounds": {"x": 1}, "kind": "response", "response_form": "lines"},
{"id": "c1", "question_id": "p1", "page": 1, "bounds": {"x": 1}, "kind": "context", "context_type": "data_table"},
{"id": "qn1", "question_id": "p1", "page": 1, "bounds": {"x": 1}, "kind": "question_number"},
{"id": "m1", "question_id": "p1", "page": 1, "bounds": {"x": 1}, "kind": "mark_area"},
{"id": "f1", "question_id": "p1", "page": 1, "bounds": {"x": 1}, "kind": "furniture"},
],
})
assert resp.status_code == 200
part = next(q for q in store["exam_questions"] if q["id"] == "p1")
assert part["bounds"] == {"x": 1, "y": 2, "w": 3, "h": 4} and part["page"] == 1
ras = {r["id"]: r for r in store["exam_response_areas"]}
assert {ras["r1"]["kind"], ras["c1"]["kind"], ras["qn1"]["kind"], ras["m1"]["kind"], ras["f1"]["kind"]} == \
{"response", "context", "question_number", "mark_area", "furniture"}
assert ras["c1"]["context_type"] == "data_table"
def test_put_replace_clears_previous_children():
store = {
"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}],