diff --git a/modules/database/schemas/nodes/exams/__init__.py b/modules/database/schemas/nodes/exams/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/database/schemas/nodes/exams/exam_nodes.py b/modules/database/schemas/nodes/exams/exam_nodes.py new file mode 100644 index 0000000..7ed8cee --- /dev/null +++ b/modules/database/schemas/nodes/exams/exam_nodes.py @@ -0,0 +1,92 @@ +""" +Neontology node schemas for the cc.public.exams knowledge graph. + +cc.public.exams is a dedicated, shared, public Neo4j database — co-primary/authoritative for the +exam knowledge graph (specs, spec-points, paper→question→part→region structure, ASSESSES links). +Supabase remains source of truth for operational data (geometry, marks, submissions); the two +layers join on shared UUIDs: + + exam_questions.id <-> Question|Part.uuid_string (container -> Question, leaf -> Part) + exam_response_areas.id <-> Region.uuid_string + eb_exams.exam_code <-> ExamPaper.exam_code + eb_specifications.spec_code <-> Specification.spec_code + +Ownership: created by an infra-init step; read by all authenticated API calls; written by the API +service role only (no direct client writes). +""" +from typing import ClassVar, Optional +from ..base_nodes import CCBaseNode + + +class ExamBaseNode(CCBaseNode): + __primarylabel__: ClassVar[str] = '' + + +class ExamBoardNode(ExamBaseNode): + __primarylabel__: ClassVar[str] = 'ExamBoard' + code: str # 'AQA' + name: str + + +class SpecificationNode(ExamBaseNode): + __primarylabel__: ClassVar[str] = 'Specification' + spec_code: str # 'AQA-PHYS-8463' (== Supabase eb_specifications.spec_code) + exam_board_code: str + subject_code: Optional[str] = None + award_code: Optional[str] = None + title: Optional[str] = None + + +class SpecPointNode(ExamBaseNode): + __primarylabel__: ClassVar[str] = 'SpecPoint' + ref: str # '4.1', '4.2.1' + description: str + spec_code: str + exam_board_code: str + + +class ExamPaperNode(ExamBaseNode): + __primarylabel__: ClassVar[str] = 'ExamPaper' + exam_code: str # == Supabase eb_exams.exam_code + spec_code: str + paper_code: Optional[str] = None + tier: Optional[str] = None + session: Optional[str] = None + title: Optional[str] = None + page_count: Optional[int] = None + + +class QuestionNode(ExamBaseNode): # roll-up container; uuid_string == exam_questions.id + __primarylabel__: ClassVar[str] = 'Question' + exam_code: str + label: str # '01' + order: int + max_marks: float + + +class PartNode(ExamBaseNode): # leaf; uuid_string == exam_questions.id + __primarylabel__: ClassVar[str] = 'Part' + exam_code: str + label: str # '01.1' + order: int + max_marks: float + answer_type: str + mark_scheme_type: str + + +class RegionNode(ExamBaseNode): # uuid_string == exam_response_areas.id + __primarylabel__: ClassVar[str] = 'Region' + page: int + kind: str # 'response' | 'context' + response_form: str + + +# Relationship reference (written by the projection / linker, not modelled as classes here): +# (:ExamBoard)-[:PUBLISHES]->(:Specification) +# (:Specification)-[:HAS_SPEC_POINT]->(:SpecPoint) +# (:Specification)-[:HAS_PAPER]->(:ExamPaper) +# (:ExamPaper)-[:HAS_QUESTION]->(:Question) +# (:Question)-[:HAS_PART]->(:Part) # nested questions allowed +# (:Part)-[:HAS_REGION]->(:Region) +# (:Part)-[:ASSESSES]->(:SpecPoint) # from exam_questions.spec_ref +# (:SpecPoint)-[:TEACHES]->(:LearningStatement) # DEFERRED cross-db bridge diff --git a/run/initialization/init_exam_graph.py b/run/initialization/init_exam_graph.py new file mode 100644 index 0000000..beceaf4 --- /dev/null +++ b/run/initialization/init_exam_graph.py @@ -0,0 +1,128 @@ +""" +init_exam_graph.py — Initialise the cc.public.exams Neo4j knowledge graph. + +Creates the shared, public exam database, its uniqueness constraints, and seeds the AQA exam +board + AQA GCSE Physics (8463) specification with its 8 top-level topic SpecPoints. Idempotent +(CREATE DATABASE IF NOT EXISTS / CREATE CONSTRAINT IF NOT EXISTS / MERGE). + +Run inside the ccapi container: + python3 -c "from run.initialization.init_exam_graph import init; import json; print(json.dumps(init()))" + +NOTE: the 8 SpecPoints seeded here are the real AQA GCSE Physics *top-level* topics. The full +sub-point breakdown (e.g. 4.1.1.1 ...) is a later data-population task (sourceable from the AQA +spec PDF via Docling). spec_code AQA-PHYS-8463 is the standalone GCSE Physics code that matches +"AQA Physics Paper 1H"; the eb_exams/eb_specifications seed (card S4-3) must use the same code. +""" +import uuid +from typing import Dict, Any + +from modules.database.tools.neo4j_driver_tools import get_driver + +EXAM_DB = "cc.public.exams" +NS = uuid.UUID("00000000-0000-0000-0000-00000000e8a1") # stable namespace for deterministic uuids + +BOARD = {"code": "AQA", "name": "AQA"} +SPEC = { + "spec_code": "AQA-PHYS-8463", + "exam_board_code": "AQA", + "subject_code": "PHYS", + "award_code": "GCSE", + "title": "AQA GCSE Physics (8463)", +} +# Real AQA GCSE Physics (8463) top-level topics (ref = topic number). +SPEC_POINTS = [ + ("4.1", "Energy"), + ("4.2", "Electricity"), + ("4.3", "Particle model of matter"), + ("4.4", "Atomic structure"), + ("4.5", "Forces"), + ("4.6", "Waves"), + ("4.7", "Magnetism and electromagnetism"), + ("4.8", "Space physics"), +] + +CONSTRAINTS = [ + "CREATE CONSTRAINT exam_board_uid IF NOT EXISTS FOR (n:ExamBoard) REQUIRE n.uuid_string IS UNIQUE", + "CREATE CONSTRAINT spec_uid IF NOT EXISTS FOR (n:Specification) REQUIRE n.uuid_string IS UNIQUE", + "CREATE CONSTRAINT specpoint_uid IF NOT EXISTS FOR (n:SpecPoint) REQUIRE n.uuid_string IS UNIQUE", + "CREATE CONSTRAINT exampaper_uid IF NOT EXISTS FOR (n:ExamPaper) REQUIRE n.uuid_string IS UNIQUE", + "CREATE CONSTRAINT question_uid IF NOT EXISTS FOR (n:Question) REQUIRE n.uuid_string IS UNIQUE", + "CREATE CONSTRAINT part_uid IF NOT EXISTS FOR (n:Part) REQUIRE n.uuid_string IS UNIQUE", + "CREATE CONSTRAINT region_uid IF NOT EXISTS FOR (n:Region) REQUIRE n.uuid_string IS UNIQUE", + "CREATE CONSTRAINT spec_code_unique IF NOT EXISTS FOR (n:Specification) REQUIRE n.spec_code IS UNIQUE", + "CREATE CONSTRAINT exam_code_unique IF NOT EXISTS FOR (n:ExamPaper) REQUIRE n.exam_code IS UNIQUE", + "CREATE CONSTRAINT board_code_unique IF NOT EXISTS FOR (n:ExamBoard) REQUIRE n.code IS UNIQUE", +] + + +def _uid(*parts: str) -> str: + return str(uuid.uuid5(NS, ":".join(parts))) + + +def init() -> Dict[str, Any]: + driver = get_driver() + result: Dict[str, Any] = {"db": EXAM_DB, "constraints": 0, "spec_points": 0} + + # 1. database + with driver.session(database="system") as s: + s.run(f"CREATE DATABASE `{EXAM_DB}` IF NOT EXISTS").consume() + # wait for availability + import time + for _ in range(30): + with driver.session(database="system") as s: + st = s.run("SHOW DATABASE $n YIELD currentStatus RETURN currentStatus", n=EXAM_DB).single() + if st and st["currentStatus"] == "online": + break + time.sleep(1) + + with driver.session(database=EXAM_DB) as s: + # 2. constraints + for c in CONSTRAINTS: + s.run(c).consume() + result["constraints"] += 1 + + # 3. board + spec + board_uid = _uid("ExamBoard", BOARD["code"]) + spec_uid = _uid("Specification", SPEC["spec_code"]) + s.run( + "MERGE (b:ExamBoard {uuid_string:$uid}) " + "SET b.code=$code, b.name=$name, b.node_storage_path=$nsp", + uid=board_uid, code=BOARD["code"], name=BOARD["name"], + nsp=f"{EXAM_DB}/ExamBoard/{BOARD['code']}", + ).consume() + s.run( + "MERGE (sp:Specification {uuid_string:$uid}) " + "SET sp.spec_code=$sc, sp.exam_board_code=$ebc, sp.subject_code=$subj, " + " sp.award_code=$award, sp.title=$title, sp.node_storage_path=$nsp " + "WITH sp MATCH (b:ExamBoard {code:$ebc}) MERGE (b)-[:PUBLISHES]->(sp)", + uid=spec_uid, sc=SPEC["spec_code"], ebc=SPEC["exam_board_code"], + subj=SPEC["subject_code"], award=SPEC["award_code"], title=SPEC["title"], + nsp=f"{EXAM_DB}/Specification/{SPEC['spec_code']}", + ).consume() + + # 4. spec points + for ref, desc in SPEC_POINTS: + sp_uid = _uid("SpecPoint", SPEC["spec_code"], ref) + s.run( + "MERGE (p:SpecPoint {uuid_string:$uid}) " + "SET p.ref=$ref, p.description=$desc, p.spec_code=$sc, " + " p.exam_board_code=$ebc, p.node_storage_path=$nsp " + "WITH p MATCH (s:Specification {spec_code:$sc}) MERGE (s)-[:HAS_SPEC_POINT]->(p)", + uid=sp_uid, ref=ref, desc=desc, sc=SPEC["spec_code"], + ebc=SPEC["exam_board_code"], nsp=f"{EXAM_DB}/SpecPoint/{SPEC['spec_code']}/{ref}", + ).consume() + result["spec_points"] += 1 + + counts = s.run( + "MATCH (b:ExamBoard) WITH count(b) AS boards " + "MATCH (sp:Specification) WITH boards, count(sp) AS specs " + "MATCH (p:SpecPoint) RETURN boards, specs, count(p) AS spec_points" + ).single() + result["verify"] = dict(counts) if counts else {} + + return result + + +if __name__ == "__main__": + import json + print(json.dumps(init(), indent=2, default=str))