Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41614b78ef |
@@ -67,13 +67,11 @@ def derive_bands(result, doc=None, rapid_glob=None):
|
|||||||
topnum = _topnumber_boxes(docs)
|
topnum = _topnumber_boxes(docs)
|
||||||
# gather parts with geometry, grouped by page
|
# gather parts with geometry, grouped by page
|
||||||
by_page = defaultdict(list) # page -> [(q, label, t, b)]
|
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 q in result.get("questions", []):
|
||||||
for p in q["parts"]:
|
for p in q["parts"]:
|
||||||
bb, pg = p.get("bbox"), p.get("page")
|
bb, pg = p.get("bbox"), p.get("page")
|
||||||
if bb and pg:
|
if bb and pg:
|
||||||
by_page[pg].append((q["question"], p["label"], bb["t"], bb["b"]))
|
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)
|
# global first page each question appears on (to mark the true start vs continuation pages)
|
||||||
q_first_page = {}
|
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):
|
for (q, lab), st, en, _ in _ends(part_items):
|
||||||
qen = main_band.get(q, (st, 0))[1] # don't run past the question end
|
qen = main_band.get(q, (st, 0))[1] # don't run past the question end
|
||||||
part.append({"label": lab, "question": q,
|
part.append({"label": lab, "question": q,
|
||||||
"y_start": round(st, 1), "y_end": round(max(en, qen), 1),
|
"y_start": round(st, 1), "y_end": round(max(en, qen), 1)})
|
||||||
"marks": part_marks.get((q, lab))})
|
|
||||||
pages[pg] = {"main": main, "part": part}
|
pages[pg] = {"main": main, "part": part}
|
||||||
|
|
||||||
return {"board": result.get("board"), "paper_code": result.get("paper_code"),
|
return {"board": result.get("board"), "paper_code": result.get("paper_code"),
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ def build(structured, bands, furniture, pdf=None, page_roles=None):
|
|||||||
"y_start": p["y_start"], "y_end": p["y_end"],
|
"y_start": p["y_start"], "y_end": p["y_end"],
|
||||||
"label_box": part_bbox.get(p["label"]), # anchor, not the part extent
|
"label_box": part_bbox.get(p["label"]), # anchor, not the part extent
|
||||||
"box": synthesize_part_box(p, xband),
|
"box": synthesize_part_box(p, xband),
|
||||||
"marks": p.get("marks"), # parsed per-part marks (born-digital)
|
|
||||||
"source": "auto", "confirmed": False,
|
"source": "auto", "confirmed": False,
|
||||||
})
|
})
|
||||||
pr = page_roles.get(pgs) or page_roles.get(pg) or {}
|
pr = page_roles.get(pgs) or page_roles.get(pg) or {}
|
||||||
|
|||||||
@@ -465,17 +465,6 @@ def _safe_confidence(value: Any = None) -> float:
|
|||||||
return 0.75
|
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]]:
|
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}
|
vals: Dict[str, Optional[float]] = {"left": None, "right": None, "top": None, "bottom": None}
|
||||||
for m in first_pass.get("margins") or []:
|
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))
|
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 = _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)
|
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")
|
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)):
|
for page_key in sorted(pages_obj, key=lambda k: int(k)):
|
||||||
@@ -645,6 +634,9 @@ def _run_auto_map_job(job_id: str, ctx: ExamContext, template_id: str, pdf_bytes
|
|||||||
_set_auto_map_status(job_id, {"status": "running", "template_id": template_id})
|
_set_auto_map_status(job_id, {"status": "running", "template_id": template_id})
|
||||||
try:
|
try:
|
||||||
rows = _run_auto_map_merge(ctx, template_id, pdf_bytes, source_label)
|
rows = _run_auto_map_merge(ctx, template_id, pdf_bytes, source_label)
|
||||||
|
# Project to Neo4j like the born-digital fast path does — otherwise image-only papers (R3's
|
||||||
|
# primary target, routed here because they need OCR) never reach the graph after auto-map.
|
||||||
|
project_template_safe(template_id)
|
||||||
_set_auto_map_status(job_id, {"status": "completed", "template_id": template_id, "counts": {k: len(v) for k, v in rows.items()}})
|
_set_auto_map_status(job_id, {"status": "completed", "template_id": template_id, "counts": {k: len(v) for k, v in rows.items()}})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception(f"auto-map job failed for template {template_id}: {exc}")
|
logger.exception(f"auto-map job failed for template {template_id}: {exc}")
|
||||||
|
|||||||
@@ -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"
|
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):
|
def test_auto_map_deduplicates_repeated_response_area_ids(monkeypatch):
|
||||||
store = _template_with_source()
|
store = _template_with_source()
|
||||||
client, store = make_client(store=store)
|
client, store = make_client(store=store)
|
||||||
@@ -733,4 +718,16 @@ def test_auto_map_ocr_returns_job_id_and_status_completes(monkeypatch):
|
|||||||
body = status.json()
|
body = status.json()
|
||||||
assert body["status"] == "completed"
|
assert body["status"] == "completed"
|
||||||
assert body["counts"]["questions"] >= 2
|
assert body["counts"]["questions"] >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_map_ocr_path_projects_to_neo4j(monkeypatch, _stub_projection):
|
||||||
|
# Image-only papers route through the async OCR job; regression: that path must project to Neo4j
|
||||||
|
# like the born-digital fast path, or the graph is never built for the primary target.
|
||||||
|
store = _template_with_source()
|
||||||
|
client, store = make_client(store=store)
|
||||||
|
_patch_auto_map(monkeypatch, store, fast=False)
|
||||||
|
resp = client.post("/api/exam/templates/t1/auto-map")
|
||||||
|
assert resp.status_code == 202
|
||||||
|
# the BackgroundTask runs after the response under TestClient
|
||||||
|
assert "t1" in _stub_projection
|
||||||
assert body["template"]["layout"]
|
assert body["template"]["layout"]
|
||||||
|
|||||||
Reference in New Issue
Block a user