Compare commits

..
Author SHA1 Message Date
CC WorkerandClaude Opus 4.8 47e45f59e8 FX-6: seed SpecPoint catalogue for all 6 test specs (ASSESSES beyond AQA physics)
Only AQA GCSE Physics 8463 (4.1-4.8) was seeded, so any other board/spec — or
any spec_ref that didn't match those 8 — projected zero (:Part)-[:ASSESSES]->
(:SpecPoint) edges, silently. Seed the full top-level topic catalogue for the 6
current test specs (GCSE + A-level Physics/Chemistry/Biology, 44 SpecPoints) and
loop the Specification/SpecPoint MERGE over all of them (board created once).

Idempotent; deterministic uuid5 keys unchanged. Sub-point granularity remains a
later data task. Run: python3 -c "from run.initialization.init_exam_graph import
init; import json; print(json.dumps(init()))" in the ccapi container.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 21:25:23 +00:00
5 changed files with 64 additions and 60 deletions
+1 -4
View File
@@ -67,13 +67,11 @@ def derive_bands(result, doc=None, rapid_glob=None):
topnum = _topnumber_boxes(docs)
# gather parts with geometry, grouped by page
by_page = defaultdict(list) # page -> [(q, label, t, b)]
part_marks = {} # (question, part label) -> parsed marks (born-digital grammar)
for q in result.get("questions", []):
for p in q["parts"]:
bb, pg = p.get("bbox"), p.get("page")
if bb and pg:
by_page[pg].append((q["question"], p["label"], bb["t"], bb["b"]))
part_marks[(q["question"], p["label"])] = p.get("marks")
# global first page each question appears on (to mark the true start vs continuation pages)
q_first_page = {}
@@ -106,8 +104,7 @@ def derive_bands(result, doc=None, rapid_glob=None):
for (q, lab), st, en, _ in _ends(part_items):
qen = main_band.get(q, (st, 0))[1] # don't run past the question end
part.append({"label": lab, "question": q,
"y_start": round(st, 1), "y_end": round(max(en, qen), 1),
"marks": part_marks.get((q, lab))})
"y_start": round(st, 1), "y_end": round(max(en, qen), 1)})
pages[pg] = {"main": main, "part": part}
return {"board": result.get("board"), "paper_code": result.get("paper_code"),
-1
View File
@@ -159,7 +159,6 @@ def build(structured, bands, furniture, pdf=None, page_roles=None):
"y_start": p["y_start"], "y_end": p["y_end"],
"label_box": part_bbox.get(p["label"]), # anchor, not the part extent
"box": synthesize_part_box(p, xband),
"marks": p.get("marks"), # parsed per-part marks (born-digital)
"source": "auto", "confirmed": False,
})
pr = page_roles.get(pgs) or page_roles.get(pg) or {}
+1 -12
View File
@@ -465,17 +465,6 @@ def _safe_confidence(value: Any = None) -> float:
return 0.75
def _safe_marks(value: Any = None) -> int:
"""Parsed per-part marks → a non-negative int; unknown/None → 0 (image-only OCR has no marks yet)."""
if isinstance(value, bool):
return 0
if isinstance(value, (int, float)):
return max(0, int(value))
if isinstance(value, str) and value.strip().isdigit():
return int(value.strip())
return 0
def _margin_values(first_pass: Dict[str, Any], page_number: int) -> Dict[str, Optional[float]]:
vals: Dict[str, Optional[float]] = {"left": None, "right": None, "top": None, "bottom": None}
for m in first_pass.get("margins") or []:
@@ -552,7 +541,7 @@ def _map_first_pass_to_rows(template_id: str, first_pass: Dict[str, Any], pdf_by
top = max(float(y1), float(y2)); bottom = min(float(y1), float(y2))
bounds = _box_to_canvas({"l": margins["left"], "r": margins["right"], "t": top, "b": bottom, "coord_origin": "BOTTOMLEFT"}, page_number, pages_geom)
bounds = bounds or _box_to_canvas(band.get("label_box"), page_number, pages_geom)
questions.append({"id": pid, "template_id": template_id, "parent_id": parent_id, "label": label, "order": len(questions), "max_marks": _safe_marks(band.get("marks")), "is_container": False, "bounds": bounds, "page": page_number, "source": "ai", "confirmed": False, "confidence": _safe_confidence(band.get("confidence")), "derivation": "docling-part-band-x-margins"})
questions.append({"id": pid, "template_id": template_id, "parent_id": parent_id, "label": label, "order": len(questions), "max_marks": 0, "is_container": False, "bounds": bounds, "page": page_number, "source": "ai", "confirmed": False, "confidence": _safe_confidence(band.get("confidence")), "derivation": "docling-part-band-x-margins"})
default_qid = questions[0]["id"] if questions else _ai_id(template_id, "question", "auto")
for page_key in sorted(pages_obj, key=lambda k: int(k)):
+62 -28
View File
@@ -1,17 +1,19 @@
"""
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).
Creates the shared, public exam database, its uniqueness constraints, and seeds the AQA exam board
+ the 6 current test specifications (GCSE & A-level Physics/Chemistry/Biology) with their top-level
topic SpecPoints (44 in total). 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.
NOTE: only *top-level* topics are seeded (the granularity a teacher plans against). 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). Seeding all 6 specs means a template's spec_ref finds a matching SpecPoint so
(:Part)-[:ASSESSES]->(:SpecPoint) fires beyond GCSE Physics; spec_code (e.g. AQA-PHYS-8463) must match
the eb_exams/eb_specifications seed (card S4-3) and the app's deriveSpecCode.
"""
import uuid
from typing import Dict, Any
@@ -41,6 +43,37 @@ SPEC_POINTS = [
("4.8", "Space physics"),
]
# Full AQA catalogue for the current test specs (top-level topics; ref = topic number). Seeding all of
# them means a template's spec_ref finds a matching SpecPoint so (:Part)-[:ASSESSES]->(:SpecPoint) fires
# beyond AQA GCSE Physics. Sub-point granularity (e.g. 4.1.1.1) remains a later data-population task.
SPECIFICATIONS = [
{**SPEC, "topics": SPEC_POINTS},
{"spec_code": "AQA-CHEM-8462", "exam_board_code": "AQA", "subject_code": "CHEM", "award_code": "GCSE",
"title": "AQA GCSE Chemistry (8462)", "topics": [
("4.1", "Atomic structure and the periodic table"), ("4.2", "Bonding, structure, and the properties of matter"),
("4.3", "Quantitative chemistry"), ("4.4", "Chemical changes"), ("4.5", "Energy changes"),
("4.6", "The rate and extent of chemical change"), ("4.7", "Organic chemistry"), ("4.8", "Chemical analysis"),
("4.9", "Chemistry of the atmosphere"), ("4.10", "Using resources")]},
{"spec_code": "AQA-BIOL-8461", "exam_board_code": "AQA", "subject_code": "BIOL", "award_code": "GCSE",
"title": "AQA GCSE Biology (8461)", "topics": [
("4.1", "Cell biology"), ("4.2", "Organisation"), ("4.3", "Infection and response"), ("4.4", "Bioenergetics"),
("4.5", "Homeostasis and response"), ("4.6", "Inheritance, variation and evolution"), ("4.7", "Ecology")]},
{"spec_code": "AQA-PHYS-7408", "exam_board_code": "AQA", "subject_code": "PHYS", "award_code": "A-level",
"title": "AQA A-level Physics (7408)", "topics": [
("3.1", "Measurements and their errors"), ("3.2", "Particles and radiation"), ("3.3", "Waves"),
("3.4", "Mechanics and materials"), ("3.5", "Electricity"), ("3.6", "Further mechanics and thermal physics"),
("3.7", "Fields and their consequences"), ("3.8", "Nuclear physics")]},
{"spec_code": "AQA-CHEM-7405", "exam_board_code": "AQA", "subject_code": "CHEM", "award_code": "A-level",
"title": "AQA A-level Chemistry (7405)", "topics": [
("3.1", "Physical chemistry"), ("3.2", "Inorganic chemistry"), ("3.3", "Organic chemistry")]},
{"spec_code": "AQA-BIOL-7402", "exam_board_code": "AQA", "subject_code": "BIOL", "award_code": "A-level",
"title": "AQA A-level Biology (7402)", "topics": [
("3.1", "Biological molecules"), ("3.2", "Cells"), ("3.3", "Organisms exchange substances with their environment"),
("3.4", "Genetic information, variation and relationships between organisms"),
("3.5", "Energy transfers in and between organisms"), ("3.6", "Organisms respond to changes"),
("3.7", "Genetics, populations, evolution and ecosystems"), ("3.8", "The control of gene expression")]},
]
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",
@@ -81,37 +114,38 @@ def init() -> Dict[str, Any]:
s.run(c).consume()
result["constraints"] += 1
# 3. board + spec
# 3. board (once)
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)
# 4. each specification + its top-level spec points (idempotent MERGE)
for spec in SPECIFICATIONS:
spec_uid = _uid("Specification", spec["spec_code"])
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}",
"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()
result["spec_points"] += 1
for ref, desc in spec["topics"]:
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 "
-15
View File
@@ -642,21 +642,6 @@ def test_auto_map_fast_path_merges_ai_rows_and_returns_detail(monkeypatch):
assert store["exam_boundaries"] and store["exam_boundaries"][0]["derivation"] == "docling-main-band"
def test_auto_map_surfaces_born_digital_part_marks(monkeypatch):
# Regression: the born-digital grammar parses per-part marks, but the row mapper hardcoded
# max_marks=0. A part band carrying `marks` must flow through to the question row's max_marks.
store = _template_with_source()
store.update({"exam_questions": [], "exam_response_areas": [], "exam_boundaries": [], "exam_template_layout": []})
client, store = make_client(store=store)
fp = _first_pass_template()
fp["pages"]["1"]["part_bands"][0]["marks"] = 4
_patch_auto_map(monkeypatch, store, fast=True)
monkeypatch.setattr(templates_mod, "auto_map", lambda *_a, **_k: fp) # override with the marked part band
assert client.post("/api/exam/templates/t1/auto-map").status_code == 200
part = next(q for q in store["exam_questions"] if q.get("label") == "01.1")
assert part["max_marks"] == 4
def test_auto_map_deduplicates_repeated_response_area_ids(monkeypatch):
store = _template_with_source()
client, store = make_client(store=store)