merge: feat/exam-marker-neo4j-graph (exam-marker foundation)
This commit is contained in:
commit
8427063bd1
0
modules/database/schemas/nodes/exams/__init__.py
Normal file
0
modules/database/schemas/nodes/exams/__init__.py
Normal file
92
modules/database/schemas/nodes/exams/exam_nodes.py
Normal file
92
modules/database/schemas/nodes/exams/exam_nodes.py
Normal file
@ -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
|
||||
128
run/initialization/init_exam_graph.py
Normal file
128
run/initialization/init_exam_graph.py
Normal file
@ -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))
|
||||
Loading…
x
Reference in New Issue
Block a user