diff --git a/paperforge/worker/ocr_blocks.py b/paperforge/worker/ocr_blocks.py index 16f01bea..62a9b749 100644 --- a/paperforge/worker/ocr_blocks.py +++ b/paperforge/worker/ocr_blocks.py @@ -9,7 +9,6 @@ from paperforge.worker.ocr_roles import assign_block_role _CANDIDATE_ROLES = frozenset({ "figure_caption_candidate", "backmatter_heading_candidate", - "backmatter_boundary_candidate", }) diff --git a/paperforge/worker/ocr_document.py b/paperforge/worker/ocr_document.py index 79b9dc31..af3b9367 100644 --- a/paperforge/worker/ocr_document.py +++ b/paperforge/worker/ocr_document.py @@ -1704,27 +1704,7 @@ def _resolve_ambiguous_candidates( block["role"] = "backmatter_heading" - # ---- 2.2 Resolve backmatter_boundary_candidate ---- - if role == "backmatter_boundary_candidate": - is_in_tail = (body_end_page is not None and page >= body_end_page) or ( - doc_structure.spread_start is not None and page >= doc_structure.spread_start - ) - if not is_in_tail: - block["role"] = "section_heading" - continue - - layout = page_layouts.get(page) - boundaries = layout.column_boundaries if layout else [] - x_center = (bbox[0] + bbox[2]) / 2 - col = _get_column_index_by_boundaries(x_center, boundaries) - - child_count = _child_heading_count(i, page, col, boundaries) - if child_count >= 2.0: - block["role"] = "backmatter_boundary_heading" - else: - block["role"] = "section_heading" - - # ---- 2.3 Resolve figure_caption_candidate ---- + # ---- 2.2 Resolve figure_caption_candidate ---- if role == "figure_caption_candidate": text = block.get("text", "") or "" page_blocks = [b for b in blocks if b.get("page") == page] diff --git a/paperforge/worker/ocr_roles.py b/paperforge/worker/ocr_roles.py index a19b6da6..bbcf58e6 100644 --- a/paperforge/worker/ocr_roles.py +++ b/paperforge/worker/ocr_roles.py @@ -459,10 +459,28 @@ def assign_block_role( _page_num = block.get("page", 1) or 1 _total_pages = max((b.get("page", 1) or 1) for b in page_blocks) if page_blocks else _page_num if _is_backmatter_boundary_heading(block, _page_num, _total_pages): + role_name = "backmatter_boundary_heading" + confidence = 0.7 + lower_txt = text.lower() + has_container_words = any( + w in lower_txt + for w in ["additional information", "declaration", "supplementary"] + ) + span_meta = block.get("span_metadata", {}) or {} + is_bold = False + if isinstance(span_meta, dict): + is_bold = "bold" in (span_meta.get("flags", "") or "").lower() + elif isinstance(span_meta, list): + is_bold = any(s.get("flags", 0) & 16 for s in span_meta) + if not (has_container_words and is_bold): + role_name = "backmatter_boundary_candidate" + confidence = 0.5 return RoleAssignment( - role="backmatter_boundary_heading", - confidence=0.7, - evidence=[f"backmatter boundary heading: {text[:60]}"], + role=role_name, + confidence=confidence, + evidence=[ + f"backmatter {'boundary heading' if role_name == 'backmatter_boundary_heading' else 'boundary candidate'}: {text[:60]}" + ], ) if _has_heading_numbering(text): depth = _heading_number_depth(text) @@ -732,10 +750,28 @@ def assign_block_role( _pn2 = block.get("page", 1) or 1 _tp2 = max((b.get("page", 1) or 1) for b in page_blocks) if page_blocks else _pn2 if _is_backmatter_boundary_heading(block, _pn2, _tp2): + role_name = "backmatter_boundary_heading" + confidence = 0.7 + lower_txt = text.lower() + has_container_words = any( + w in lower_txt + for w in ["additional information", "declaration", "supplementary"] + ) + span_meta = block.get("span_metadata", {}) or {} + is_bold = False + if isinstance(span_meta, dict): + is_bold = "bold" in (span_meta.get("flags", "") or "").lower() + elif isinstance(span_meta, list): + is_bold = any(s.get("flags", 0) & 16 for s in span_meta) + if not (has_container_words and is_bold): + role_name = "backmatter_boundary_candidate" + confidence = 0.5 return RoleAssignment( - role="backmatter_boundary_heading", - confidence=0.7, - evidence=[f"backmatter boundary heading from text block: {text[:60]}"], + role=role_name, + confidence=confidence, + evidence=[ + f"backmatter {'boundary heading' if role_name == 'backmatter_boundary_heading' else 'boundary candidate'} from text block: {text[:60]}" + ], ) if len(text) < 20: diff --git a/tests/test_ocr_roles.py b/tests/test_ocr_roles.py index 16507727..86277a06 100644 --- a/tests/test_ocr_roles.py +++ b/tests/test_ocr_roles.py @@ -463,6 +463,33 @@ def test_formal_figure_caption_still_direct() -> None: ) +def test_weak_backmatter_boundary_signal_emits_candidate(): + """A paragraph with backmatter boundary text but no bold span_metadata + should emit backmatter_boundary_candidate, not backmatter_boundary_heading.""" + from paperforge.worker.ocr_roles import assign_block_role + block = { + "block_label": "paragraph_title", + "block_content": "ADDITIONAL INFORMATION", + "block_bbox": [100, 1200, 500, 1240], + "page": 8, + } + page_blocks = [block] + [ + { + "block_label": "text", + "block_content": f"Some body text {i}", + "block_bbox": [100, 200 + i * 100, 500, 260 + i * 100], + "page": 8, + } + for i in range(5) + ] + result = assign_block_role( + block, page_blocks=page_blocks, page_width=600, page_height=1600 + ) + assert result.role == "backmatter_boundary_candidate", ( + f"Expected backmatter_boundary_candidate, got {result.role}" + ) + + def test_backmatter_boundary_detects_on_early_page() -> None: """Backmatter boundary should be detectable on papers with fewer than 8 pages, without a hard page gate."""