feat: switch OCR to anchor-first role authority

This commit is contained in:
Research Assistant 2026-06-09 22:10:54 +08:00
parent 5e47a70998
commit 08288ff3eb
11 changed files with 372 additions and 4 deletions

View file

@ -179,6 +179,37 @@ def build_structured_blocks(
if resolved.evidence:
row.setdefault("evidence", []).extend(resolved.evidence)
if doc_structure:
from paperforge.worker.ocr_document import _apply_zone_labels, infer_zones
from paperforge.worker.ocr_families import partition_zone_families
refreshed_anchors = {
"body_family_anchor": getattr(doc_structure, "body_family_anchor", None),
"reference_family_anchor": getattr(doc_structure, "reference_family_anchor", None),
}
refreshed_region_bus = infer_zones(rows, refreshed_anchors)
_apply_zone_labels(rows, refreshed_region_bus)
partition_zone_families(rows, refreshed_anchors)
doc_structure.region_bus = refreshed_region_bus
refreshed_reference_rows = [row for row in rows if row.get("zone") == "reference_zone"]
if refreshed_reference_rows:
reference_pages = sorted({int(row.get("page", 0) or 0) for row in refreshed_reference_rows if int(row.get("page", 0) or 0) > 0})
if reference_pages:
doc_structure.reference_zones = [
{
"page": page,
"column_index": 0,
"y_start": 0,
"y_end": float(next((row.get("page_height", 0) or 0) for row in refreshed_reference_rows if int(row.get("page", 0) or 0) == page) or 0),
"block_indices": [
idx for idx, row in enumerate(rows)
if row.get("zone") == "reference_zone" and int(row.get("page", 0) or 0) == page
],
}
for page in reference_pages
]
# Sync render_default/index_default after role normalizations
for row in rows:
role = row.get("role", "")

View file

@ -388,9 +388,9 @@ def _canonical_section_text(block: dict) -> str:
def _is_reference_heading_candidate(block: dict) -> bool:
marker_type = ((block.get("marker_signature") or {}).get("type") or "none")
if marker_type != "canonical_section_name":
if _canonical_section_text(block) not in _REFERENCE_ZONE_HEADING_TEXTS:
return False
return _canonical_section_text(block) in _REFERENCE_ZONE_HEADING_TEXTS
return marker_type in {"canonical_section_name", "short_fragment", "none"}
def _is_reference_item_candidate(block: dict) -> bool:
@ -3125,6 +3125,32 @@ def normalize_document_structure(blocks: list[dict]) -> tuple[DocumentStructure,
},
)
from paperforge.worker.ocr_roles import resolve_final_role
family_context = {
str(block.get("block_id") or f"block_{idx}"): {
"zone": block.get("zone"),
"style_family": block.get("style_family"),
"style_family_authority": block.get("style_family_authority"),
}
for idx, block in enumerate(blocks)
}
anchor_context = {
"body_family_anchor": body_family_anchor,
"reference_family_anchor": reference_family_anchor,
}
for block in blocks:
resolved = resolve_final_role(block, anchors=anchor_context, families=family_context)
if resolved.role != block.get("role"):
block["role"] = resolved.role
block["role_confidence"] = resolved.confidence
if resolved.evidence:
block.setdefault("evidence", []).extend(resolved.evidence)
region_bus = infer_zones(blocks, anchor_context)
_apply_zone_labels(blocks, region_bus)
partition_zone_families(blocks, anchor_context)
tail_spread = _reconcile_tail_spread(blocks, page_layouts)
if tail_spread is not None:
backmatter_form = _classify_backmatter_form(tail_spread, blocks)

View file

@ -509,7 +509,7 @@ def _has_reference_heading_near_family(blocks: list[dict], family_pages: list[in
continue
marker_type = ((block.get("marker_signature") or {}).get("type") or "none")
text = str(block.get("text") or block.get("block_content") or "").strip().lower()
if marker_type == "canonical_section_name" and text in _REFERENCE_HEADING_TEXTS:
if marker_type in {"canonical_section_name", "short_fragment", "none"} and text in _REFERENCE_HEADING_TEXTS:
return True
return False
@ -527,7 +527,8 @@ def _build_reference_family_candidate(
font_family, font_size_bucket, width_bucket, x_center_bucket = family_key
strong_tail = tail_continuity >= 1.0
strong_marker_family = item_count >= 2 and marker_count >= 2
can_accept = strong_marker_family and strong_tail
heading_supported_singleton = heading_present and item_count >= 1 and marker_count >= 1
can_accept = strong_tail and (strong_marker_family or heading_supported_singleton)
reason = "reference_markers_with_tail_continuity"
if heading_present:
reason = f"{reason}_and_heading_binding"

View file

@ -122,7 +122,11 @@ def _looks_like_figure_narrative_prose(text: str) -> bool:
def _is_body_mention(block: dict) -> bool:
zone = str(block.get("zone") or "")
style_family = str(block.get("style_family") or "")
raw_role = block.get("raw_role", block.get("role", ""))
if zone == "display_zone" and style_family == "legend_like":
return False
if raw_role == "body_paragraph":
return True
if raw_role == "figure_caption_candidate":

View file

@ -434,11 +434,34 @@ def resolve_final_role(
current_role = str(block.get("role") or "body_paragraph")
current_confidence = float(block.get("role_confidence") or 0.6)
body_anchor = anchors.get("body_family_anchor") or {}
reference_anchor = anchors.get("reference_family_anchor") or {}
body_anchor_accepted = str(body_anchor.get("status") or "").upper() == "ACCEPT"
reference_anchor_accepted = str(reference_anchor.get("status") or "").upper() == "ACCEPT"
in_body_zone = zone == "body_zone"
strong_legend_authority = style_family_authority in {"figure_marker", "figure_family_anchor"}
if current_role == "body_paragraph":
if (
zone == "reference_zone"
and reference_anchor_accepted
and style_family == "reference_like"
and marker_type in {
"reference_numeric_bracket",
"reference_numeric_dot",
"reference_numeric_parenthesis",
"reference_pattern",
"citation_line",
}
):
return RoleAssignment(
role="reference_item",
confidence=max(current_confidence, 0.82),
evidence=[
"late role resolution: reference_like family + reference zone",
f"style_family_authority={style_family_authority or 'none'}",
f"context_source={context_source}",
],
)
if (
in_body_zone
and body_anchor_accepted
@ -458,6 +481,22 @@ def resolve_final_role(
f"context_source={context_source}",
],
)
if (
zone == "display_zone"
and strong_legend_authority
and style_family == "legend_like"
and marker_type == "figure_number"
and not _looks_like_late_figure_narrative_prose(str(block.get("text") or ""))
):
return RoleAssignment(
role="figure_caption_candidate",
confidence=max(current_confidence, 0.8),
evidence=[
"late role resolution: display-zone legend candidate from figure family",
f"style_family_authority={style_family_authority or 'none'}",
f"context_source={context_source}",
],
)
return RoleAssignment(
role=current_role,

View file

@ -305,6 +305,56 @@ def test_normalize_document_structure_wires_style_family_artifacts_into_blocks()
assert normalized_blocks[3]["style_family"] == "legend_like"
def test_normalize_document_structure_preserves_reference_zone_integrity_from_anchor_flow() -> None:
from paperforge.worker.ocr_document import normalize_document_structure
blocks = [
{
"block_id": "p2_b1",
"page": 2,
"role": "body_paragraph",
"text": "Stable body paragraph with repeated typography and enough prose to form the body family anchor. " * 2,
"bbox": [100, 120, 460, 280],
"page_width": 1200,
"page_height": 1600,
"marker_signature": {"type": "none"},
"span_signature": {"font_size_median": 9.0, "font_size_bucket": 9.0, "font_family_norm": "Times"},
"layout_signature": {"width": 260, "width_bucket": 250, "x_center": 230, "x_center_bucket": 250},
},
{
"block_id": "p5_b1",
"page": 5,
"role": "reference_heading",
"text": "References",
"bbox": [100, 100, 340, 140],
"page_width": 1200,
"page_height": 1600,
"marker_signature": {"type": "canonical_section_name"},
"span_signature": {"font_size_median": 10.0, "font_family_norm": "Times"},
"layout_signature": {"width": 120, "x_center": 220},
},
{
"block_id": "p5_b2",
"page": 5,
"role": "body_paragraph",
"text": "[1] Example reference with authors and journal details.",
"bbox": [110, 180, 500, 260],
"page_width": 1200,
"page_height": 1600,
"marker_signature": {"type": "reference_numeric_bracket", "number": 1},
"span_signature": {"font_size_median": 8.5, "font_size_bucket": 8.5, "font_family_norm": "Times"},
"layout_signature": {"width": 250, "width_bucket": 250, "x_center": 240, "x_center_bucket": 250},
},
]
doc, normalized_blocks = normalize_document_structure(blocks)
assert doc.reference_zones
ref_row = next(block for block in normalized_blocks if block["block_id"] == "p5_b2")
assert ref_row.get("zone") == "reference_zone"
assert ref_row.get("style_family") == "reference_like"
def test_candidate_resolution_demotes_body_spine_narrative_figure_mentions() -> None:
from paperforge.worker.ocr_document import DocumentStructure, PageLayoutProfile, _resolve_ambiguous_candidates

View file

@ -228,6 +228,56 @@ def test_formal_figure_count_is_based_on_legends_not_raw_images() -> None:
assert len(inventory["figure_legends"]) == 1
def test_validation_first_legend_does_not_promote_body_figure_mention() -> None:
from paperforge.worker.ocr_figures import build_figure_inventory
structured_blocks = [
{
"paper_id": "KEY010B",
"page": 3,
"block_id": "p3_b1",
"role": "body_paragraph",
"raw_role": "body_paragraph",
"raw_label": "text",
"text": "Figure 2 shows the comparative response over time and this sentence continues as narrative body prose for the main discussion section.",
"bbox": [90, 100, 530, 180],
"zone": "body_zone",
"style_family": "body_like",
"style_family_authority": "body_family_anchor",
"body_spine_member": True,
"marker_signature": {"type": "figure_number", "number": 2},
},
{
"paper_id": "KEY010B",
"page": 3,
"block_id": "p3_b2",
"role": "figure_asset",
"raw_label": "image",
"text": "",
"bbox": [620, 140, 1030, 520],
},
{
"paper_id": "KEY010B",
"page": 3,
"block_id": "p3_b3",
"role": "body_paragraph",
"raw_label": "text",
"text": "Figure 2. Formal caption with sufficient descriptive text to support validation-first legend matching near the media asset.",
"bbox": [620, 540, 1040, 620],
"zone": "display_zone",
"style_family": "legend_like",
"style_family_authority": "figure_family_anchor",
"marker_signature": {"type": "figure_number", "number": 2},
},
]
inventory = build_figure_inventory(structured_blocks)
assert all(legend.get("legend_block_id", legend.get("block_id")) != "p3_b1" for legend in inventory["figure_legends"])
assert any(legend.get("legend_block_id", legend.get("block_id")) == "p3_b3" for legend in inventory["figure_legends"])
assert inventory["official_figure_count"] == 1
def test_figure_inventory_includes_all_sections() -> None:
from paperforge.worker.ocr_figures import build_figure_inventory

View file

@ -154,6 +154,37 @@ def test_ocr_health_includes_decision_counts() -> None:
assert report["role_rescue_count"] == 1
def test_ocr_health_reports_anchor_first_authority_summaries() -> None:
from paperforge.worker.ocr_document import DocumentStructure
from paperforge.worker.ocr_health import build_ocr_health
doc = DocumentStructure(
body_family_anchor={"status": "ACCEPT"},
reference_family_anchor={"status": "ACCEPT"},
region_bus={
"body_zone": {"status": "ACCEPT", "block_ids": ["p2_b1"]},
"reference_zone": {"status": "ACCEPT", "block_ids": ["p5_b2"]},
},
)
blocks = [
{"block_id": "p2_b1", "role": "body_paragraph", "text": "Body", "page": 2, "bbox": [0, 0, 10, 10]},
{"block_id": "p5_b2", "role": "reference_item", "text": "[1] Ref", "page": 5, "bbox": [0, 0, 10, 10]},
]
report = build_ocr_health(
page_count=5,
raw_blocks_count=2,
structured_blocks=blocks,
figure_inventory={},
table_inventory={},
doc_structure=doc,
)
assert report["anchor_summary"]["body_family_anchor"] == "ACCEPT"
assert report["anchor_summary"]["reference_family_anchor"] == "ACCEPT"
assert report["zone_summary"]["reference_zone"] == "ACCEPT"
def test_ocr_health_includes_tail_boundary_confidence() -> None:
from paperforge.worker.ocr_health import build_ocr_health

View file

@ -185,3 +185,19 @@ def test_control_papers_keep_body_and_tail_stability(rebuilt_real_papers: dict,
assert roles.count("body_paragraph") >= CONTROL_MIN_BODY[key]
assert any(w in fulltext for w in ["References", "references", "REFERENCE", "Bibliography"]), "Tail reference marker not found"
@pytest.mark.parametrize("key", PROBLEM_KEYS)
def test_problem_papers_keep_reference_roles_and_exclude_legend_family_from_body(
rebuilt_real_papers: dict,
_ocr_root: Path,
key: str,
) -> None:
_require_artifacts(_ocr_root, key)
blocks = _read_jsonl(_structured_path(_ocr_root, key))
assert any(block.get("role") == "reference_item" for block in blocks)
assert not any(
block.get("role") == "body_paragraph" and block.get("style_family") in {"legend_like", "table_caption_like", "reference_like"}
for block in blocks
)

View file

@ -256,6 +256,92 @@ def test_body_with_noise_phrase_below_backmatter_heading() -> None:
)
def test_pipeline_keeps_reference_zone_and_legend_family_out_of_default_body() -> None:
from paperforge.worker.ocr_blocks import build_structured_blocks
raw_blocks = [
{
"paper_id": "KEY010B",
"page": 2,
"block_id": "p2_b1",
"raw_label": "text",
"raw_order": 0,
"bbox": [110, 120, 470, 280],
"text": "Main body paragraph with stable narrative prose and enough repeated content to establish the body family anchor for the document. " * 2,
"page_width": 1200,
"page_height": 1600,
"span_metadata": {"size": 9.0, "font": "Times", "flags": ""},
},
{
"paper_id": "KEY010B",
"page": 3,
"block_id": "p3_b1",
"raw_label": "text",
"raw_order": 0,
"bbox": [112, 118, 472, 282],
"text": "Another core body paragraph repeating the same typography and width so the anchor-first body family remains dominant across the middle pages. " * 2,
"page_width": 1200,
"page_height": 1600,
"span_metadata": {"size": 9.0, "font": "Times", "flags": ""},
},
{
"paper_id": "KEY010B",
"page": 5,
"block_id": "p5_b1",
"raw_label": "paragraph_title",
"raw_order": 0,
"bbox": [100, 80, 350, 120],
"text": "References",
"page_width": 1200,
"page_height": 1600,
"span_metadata": {"size": 11.0, "font": "Times", "flags": "bold"},
},
{
"paper_id": "KEY010B",
"page": 5,
"block_id": "p5_b2",
"raw_label": "text",
"raw_order": 1,
"bbox": [110, 170, 500, 260],
"text": "[1] Example reference entry with journal and year details.",
"page_width": 1200,
"page_height": 1600,
"span_metadata": {"size": 8.5, "font": "Times", "flags": ""},
},
{
"paper_id": "KEY010B",
"page": 4,
"block_id": "p4_b9",
"raw_label": "text",
"raw_order": 2,
"bbox": [720, 780, 1030, 860],
"text": "Figure 1. Compact legend text for the nearby display panel.",
"page_width": 1200,
"page_height": 1600,
"span_metadata": {"size": 8.0, "font": "Times", "flags": ""},
},
{
"paper_id": "KEY010B",
"page": 4,
"block_id": "p4_b10",
"raw_label": "figure",
"raw_order": 3,
"bbox": [700, 420, 1040, 760],
"text": "",
"page_width": 1200,
"page_height": 1600,
},
]
rows, _ = build_structured_blocks(raw_blocks)
assert any(row["role"] == "reference_item" for row in rows)
assert not any(
row["role"] == "body_paragraph" and row.get("style_family") == "legend_like"
for row in rows
)
def test_style_aware_unnumbered_heading_detection() -> None:
"""Unnumbered headings with distinct visual style are detected as headings.

View file

@ -62,6 +62,40 @@ def test_table_without_asset_is_tracked_as_unmatched_caption() -> None:
assert len(inventory["unmatched_captions"]) == 1
def test_validation_first_table_candidate_remains_stable_under_anchor_first_roles() -> None:
from paperforge.worker.ocr_tables import build_table_inventory
structured_blocks = [
{
"paper_id": "KEY010B",
"page": 6,
"block_id": "p6_a1",
"role": "table_asset",
"raw_label": "table",
"text": "parsed table image",
"bbox": [120, 140, 760, 520],
},
{
"paper_id": "KEY010B",
"page": 6,
"block_id": "p6_c1",
"role": "body_paragraph",
"raw_label": "text",
"text": "Table 4. Quantitative outcomes for anchor-first validation.",
"bbox": [120, 540, 760, 600],
"zone": "display_zone",
"style_family": "table_caption_like",
"marker_signature": {"type": "table_number", "number": 4},
},
]
inventory = build_table_inventory(structured_blocks)
assert inventory["official_table_count"] == 1
assert inventory["tables"][0]["caption_block_id"] == "p6_c1"
assert inventory["tables"][0]["has_asset"] is True
def test_continuation_table_matches_same_page_asset() -> None:
from paperforge.worker.ocr_tables import build_table_inventory