diff --git a/modules/database/services/exam_projection.py b/modules/database/services/exam_projection.py index 2f5d969..7a848fe 100644 --- a/modules/database/services/exam_projection.py +++ b/modules/database/services/exam_projection.py @@ -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", diff --git a/routers/exam/schemas.py b/routers/exam/schemas.py index 55c2cdf..11a17dc 100644 --- a/routers/exam/schemas.py +++ b/routers/exam/schemas.py @@ -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 diff --git a/routers/exam/templates.py b/routers/exam/templates.py index 3417d16..382c81d 100644 --- a/routers/exam/templates.py +++ b/routers/exam/templates.py @@ -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, diff --git a/tests/test_exam_templates.py b/tests/test_exam_templates.py index 2ef22ce..0748d24 100644 --- a/tests/test_exam_templates.py +++ b/tests/test_exam_templates.py @@ -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}],