From cce46305c9ee5d249c4544eb1656ff17bb5b40bb Mon Sep 17 00:00:00 2001 From: CC Worker Date: Thu, 2 Jul 2026 21:30:28 +0000 Subject: [PATCH] FX-2: surface born-digital per-part marks from auto-map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board grammar already parses per-part marks ([N marks] / "Total for Question N is M marks"), but they were dropped building the first-pass template and the row mapper hardcoded max_marks=0 — so every born-digital paper came back with zero marks for a teacher to re-key by hand. Thread the parsed mark through: bands.py derive_bands now carries each part's `marks`, template.build passes it into part_bands, and _map_first_pass_to_rows reads it via a defensive _safe_marks (non-negative int; unknown/None -> 0, so image-only OCR — which has no marks yet — is unchanged). Containers still roll up from parts. Adds test_auto_map_surfaces_born_digital_part_marks. Co-Authored-By: Claude Opus 4.8 --- api/services/docling/bands.py | 5 ++++- api/services/docling/template.py | 1 + routers/exam/templates.py | 13 ++++++++++++- tests/test_exam_templates.py | 15 +++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/api/services/docling/bands.py b/api/services/docling/bands.py index 3278f59..040f014 100644 --- a/api/services/docling/bands.py +++ b/api/services/docling/bands.py @@ -67,11 +67,13 @@ 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 = {} @@ -104,7 +106,8 @@ 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)}) + "y_start": round(st, 1), "y_end": round(max(en, qen), 1), + "marks": part_marks.get((q, lab))}) pages[pg] = {"main": main, "part": part} return {"board": result.get("board"), "paper_code": result.get("paper_code"), diff --git a/api/services/docling/template.py b/api/services/docling/template.py index 51307ef..f71d019 100644 --- a/api/services/docling/template.py +++ b/api/services/docling/template.py @@ -159,6 +159,7 @@ 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 {} diff --git a/routers/exam/templates.py b/routers/exam/templates.py index ae7b2f9..61f677d 100644 --- a/routers/exam/templates.py +++ b/routers/exam/templates.py @@ -465,6 +465,17 @@ 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 []: @@ -541,7 +552,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": 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"}) + 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"}) 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)): diff --git a/tests/test_exam_templates.py b/tests/test_exam_templates.py index 37e38d2..d23bda9 100644 --- a/tests/test_exam_templates.py +++ b/tests/test_exam_templates.py @@ -642,6 +642,21 @@ 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)