diff --git a/paperforge/worker/ocr_roles.py b/paperforge/worker/ocr_roles.py index e4b478f0..63fa3898 100644 --- a/paperforge/worker/ocr_roles.py +++ b/paperforge/worker/ocr_roles.py @@ -515,6 +515,42 @@ def resolve_final_role( ], ) + if current_role == "body_paragraph" and style_family: + _NON_BODY_FAMILIES = {"reference_like", "table_caption_like", "legend_like", "heading_like", "support_like"} + _STRONG_AUTHORITIES = { + "figure_marker", + "figure_family_anchor", + "reference_marker", + "reference_family_anchor", + "table_marker", + "heading_marker", + "body_family_anchor", + } + if style_family in _NON_BODY_FAMILIES and style_family_authority in _STRONG_AUTHORITIES: + if ( + style_family == "legend_like" + and _looks_like_late_figure_narrative_prose(str(block.get("text") or "")) + ): + pass # narrative prose stays body_paragraph + else: + _FAMILY_ROLE_MAP = { + "reference_like": "reference_item", + "table_caption_like": "table_caption_candidate", + "legend_like": "figure_caption_candidate", + "heading_like": "section_heading", + } + mapped_role = _FAMILY_ROLE_MAP.get(style_family) + if mapped_role: + return RoleAssignment( + role=mapped_role, + confidence=max(current_confidence, 0.78), + evidence=[ + f"late role resolution: non-body family '{style_family}' overrides body_paragraph", + f"style_family_authority={style_family_authority or 'none'}", + f"context_source={context_source}", + ], + ) + return RoleAssignment( role=current_role, confidence=current_confidence, diff --git a/tests/test_ocr_document.py b/tests/test_ocr_document.py index 13150534..1fedb19a 100644 --- a/tests/test_ocr_document.py +++ b/tests/test_ocr_document.py @@ -248,8 +248,9 @@ def test_normalize_document_structure_keeps_reference_family_anchor_anchor_first assert doc.reference_family_anchor is not None assert doc.reference_family_anchor["status"] == "ACCEPT" - assert normalized_blocks[3]["role"] == "body_paragraph" - assert normalized_blocks[4]["role"] == "body_paragraph" + # Non-body family override: reference_like + reference_family_anchor → reference_item + assert normalized_blocks[3]["role"] == "reference_item" + assert normalized_blocks[4]["role"] == "reference_item" def test_normalize_document_structure_wires_style_family_artifacts_into_blocks() -> None: diff --git a/tests/test_ocr_roles.py b/tests/test_ocr_roles.py index ee1c5643..c93f7763 100644 --- a/tests/test_ocr_roles.py +++ b/tests/test_ocr_roles.py @@ -1353,3 +1353,68 @@ def test_pipeline_does_not_commit_final_semantic_role_before_zone_and_family_par for row in rows: assert "seed_role" in row, f"block {row['block_id']} missing seed_role" assert row["seed_role"] != "unassigned", f"seed_role should never be 'unassigned', got {row['seed_role']}" + + +def test_body_paragraph_overridden_to_reference_item_when_style_family_is_reference_like(): + from paperforge.worker.ocr_roles import resolve_final_role + + block = { + "role": "body_paragraph", + "role_confidence": 0.6, + "style_family": "reference_like", + "style_family_authority": "reference_marker", + "zone": "body_zone", + "marker_signature": {"type": "reference_pattern"}, + } + result = resolve_final_role(block) + assert result.role == "reference_item" + assert result.confidence >= 0.78 + + +def test_body_paragraph_overridden_to_figure_caption_when_style_family_is_legend_like(): + from paperforge.worker.ocr_roles import resolve_final_role + + block = { + "role": "body_paragraph", + "role_confidence": 0.6, + "style_family": "legend_like", + "style_family_authority": "figure_marker", + "zone": "body_zone", + "marker_signature": {"type": "figure_number"}, + "text": "Figure 3.", + } + result = resolve_final_role(block) + assert result.role == "figure_caption_candidate" + assert result.confidence >= 0.78 + + +def test_body_paragraph_overridden_to_table_caption_when_style_family_is_table_caption_like(): + from paperforge.worker.ocr_roles import resolve_final_role + + block = { + "role": "body_paragraph", + "role_confidence": 0.6, + "style_family": "table_caption_like", + "style_family_authority": "table_marker", + "zone": "body_zone", + "marker_signature": {"type": "table_number"}, + "text": "Table 2.", + } + result = resolve_final_role(block) + assert result.role == "table_caption_candidate" + assert result.confidence >= 0.78 + + +def test_body_paragraph_survives_when_style_family_authority_is_weak(): + from paperforge.worker.ocr_roles import resolve_final_role + + block = { + "role": "body_paragraph", + "role_confidence": 0.6, + "style_family": "reference_like", + "style_family_authority": "fallback", + "zone": "body_zone", + "marker_signature": {"type": "none"}, + } + result = resolve_final_role(block) + assert result.role == "body_paragraph"