mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
This batch implements the validated PR-2 / PR-3 workstreams. PR-2b: continued captions in vnext - add figure continuation helpers - mark continuation legends in candidate indexing - run ContinuationCaptionPass before PrimarySamePagePass - emit unique continuation records with trace metadata - skip continuation legends in ordinary same-page proposals PR-2a: short-caption geometry - reuse _TRUNCATED_LEGEND_ONLY_PATTERN - add narrow short-caption branch at the vnext group/page seam - require same page, same column/x-overlap, tight gap, and local uniqueness - avoid global threshold changes or score_figure_match edits PR-3: completeness normalization and additive status split - normalize legacy/vnext figure completeness shapes - keep overall unchanged - add structure_status, figure_status, table_status, dimension_statuses, status_reasons Verification: - 443 targeted tests passing - real-vault rebuilds: * SKXTCE6M: unmatched_legends 6->0, overall green * SRNJDAA2: unmatched_legends 9->0, figure green * 6QNRHRKX: matched 0->4, figure yellow (improved, not solved) * 9DM6MCIF: unmatched_legends 3->0, figure green * XGT9Z257: unmatched_legends 4->0, figure green * V4UTP5X7: figure green while structure remains red
1067 lines
39 KiB
Python
1067 lines
39 KiB
Python
from __future__ import annotations
|
|
|
|
|
|
def test_health_counts_all_heading_tiers_and_requires_stronger_reference_evidence() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
structured_blocks = [
|
|
{"role": "section_heading", "text": "Intro"},
|
|
{"role": "subsection_heading", "text": "Methods"},
|
|
{"role": "sub_subsection_heading", "text": "2.1 Setup"},
|
|
{"role": "body_paragraph", "raw_label": "reference_content", "text": "[1] weak raw label"},
|
|
]
|
|
health = build_ocr_health(
|
|
page_count=1, raw_blocks_count=4,
|
|
structured_blocks=structured_blocks,
|
|
figure_inventory={"matched_figures": [], "held_figures": [], "unmatched_legends": [], "unmatched_assets": [], "figure_legend_completeness": {}},
|
|
table_inventory={"tables": [], "held_tables": [], "unmatched_captions": [], "unmatched_assets": []},
|
|
reader_payload=None, rendered_markdown=None,
|
|
)
|
|
assert health["section_heading_count"] == 3
|
|
assert health["references_found"] is False
|
|
|
|
|
|
def test_health_report_is_independent_from_ocr_status() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
structured_blocks = [
|
|
{"role": "section_heading", "text": "1 Introduction"},
|
|
{"role": "body_paragraph", "text": "Body"},
|
|
{"role": "figure_caption", "text": "Figure 1. Example"},
|
|
]
|
|
figure_inventory = {
|
|
"matched_figures": [],
|
|
"unmatched_legends": [{"text": "Figure 1. Example"}],
|
|
"unmatched_assets": [],
|
|
}
|
|
table_inventory = {
|
|
"tables": [],
|
|
"unmatched_captions": [],
|
|
"unmatched_assets": [],
|
|
}
|
|
|
|
report = build_ocr_health(
|
|
page_count=3,
|
|
raw_blocks_count=20,
|
|
structured_blocks=structured_blocks,
|
|
figure_inventory=figure_inventory,
|
|
table_inventory=table_inventory,
|
|
)
|
|
|
|
assert report["page_count"] == 3
|
|
assert report["figure_caption_count"] == 1
|
|
assert report["overall"] in {"yellow", "red"}
|
|
|
|
|
|
def test_health_report_distinguishes_formal_tables_from_segments() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
table_inventory = {
|
|
"tables": [
|
|
{"table_id": "tbl_001", "has_asset": True},
|
|
{"table_id": "tbl_002", "has_asset": True},
|
|
],
|
|
"unmatched_captions": [],
|
|
"unmatched_assets": [
|
|
{"asset_id": "seg_001"},
|
|
{"asset_id": "seg_002"},
|
|
{"asset_id": "seg_003"},
|
|
],
|
|
}
|
|
|
|
report = build_ocr_health(
|
|
page_count=5,
|
|
raw_blocks_count=50,
|
|
structured_blocks=[],
|
|
figure_inventory={},
|
|
table_inventory=table_inventory,
|
|
)
|
|
|
|
assert report.get("formal_table_count", 0) == 2
|
|
assert report.get("table_segment_count", 0) == 5
|
|
|
|
|
|
def test_build_spine_health() -> None:
|
|
from paperforge.worker.ocr_health import build_spine_health
|
|
|
|
body_spine = {
|
|
"_meta": {
|
|
"quality": "strong",
|
|
"anchor_pages": [3, 4, 5],
|
|
"sample_count": 12,
|
|
"font_coherence": 0.92,
|
|
"width_dispersion": 0.15,
|
|
},
|
|
}
|
|
result = build_spine_health(body_spine)
|
|
assert result["body_spine_quality"] == "strong"
|
|
assert result["body_anchor_pages"] == [3, 4, 5]
|
|
assert result["body_spine_sample_count"] == 12
|
|
|
|
# Missing _meta defaults
|
|
empty = build_spine_health({})
|
|
assert empty["body_spine_quality"] == "weak"
|
|
assert empty["body_anchor_pages"] == []
|
|
assert empty["body_spine_sample_count"] == 0
|
|
|
|
|
|
def test_build_span_coverage_health() -> None:
|
|
from paperforge.worker.ocr_health import build_span_coverage_health
|
|
|
|
blocks = [
|
|
{"span_metadata": {"size": 10}},
|
|
{"span_metadata": {"size": 10}},
|
|
{"span_metadata": None},
|
|
]
|
|
result = build_span_coverage_health(blocks)
|
|
assert result["coverage_ratio"] == 2 / 3
|
|
assert result["coverage_quality"] == "moderate"
|
|
assert result["blocks_with_span"] == 2
|
|
assert result["blocks_without_span"] == 1
|
|
assert result["degraded_mode_active"] is False
|
|
|
|
# Empty blocks
|
|
empty = build_span_coverage_health([])
|
|
assert empty["degraded_mode_active"] is True
|
|
assert empty["coverage_quality"] == "weak"
|
|
|
|
|
|
def test_layout_audit_health_surface() -> None:
|
|
from paperforge.worker.ocr_health import build_layout_audit_health
|
|
|
|
audit = {
|
|
"status": "warn",
|
|
"page_warnings": {"3": ["heading owns body in different column above it"]},
|
|
"anomaly_count": 1,
|
|
"anomaly_pages": [3],
|
|
}
|
|
result = build_layout_audit_health(audit)
|
|
assert result["layout_audit_status"] == "warn"
|
|
assert result["layout_anomaly_pages"] == [3]
|
|
assert result["layout_anomaly_count"] == 1
|
|
|
|
empty = build_layout_audit_health({})
|
|
assert empty["layout_audit_status"] == "unknown"
|
|
assert empty["layout_anomaly_pages"] == []
|
|
assert empty["layout_anomaly_count"] == 0
|
|
|
|
|
|
def test_ocr_health_includes_span_spine_and_layout_signals() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
blocks = [
|
|
{"role": "section_heading", "span_metadata": [{"font": "Body"}]},
|
|
{"role": "section_heading", "span_metadata": [{"font": "Body"}]},
|
|
{"role": "abstract_body", "span_metadata": [{"font": "Body"}]},
|
|
{"role": "reference_item", "span_metadata": [{"font": "Body"}]},
|
|
]
|
|
|
|
health = build_ocr_health(
|
|
page_count=2, raw_blocks_count=4, structured_blocks=blocks, figure_inventory={}, table_inventory={}
|
|
)
|
|
|
|
assert "span_coverage_quality" in health
|
|
assert "body_spine_quality" in health
|
|
assert "layout_audit_status" in health
|
|
|
|
|
|
def test_ocr_health_includes_decision_counts() -> None:
|
|
from paperforge.worker.ocr_decisions import record_decision
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
blocks = [{"block_id": "a", "page": 1, "role": "body_paragraph", "bbox": [0, 0, 1, 1]}]
|
|
record_decision(blocks[0], stage="rescue", old_role="noise", new_role="body_paragraph", reason="body family")
|
|
report = build_ocr_health(
|
|
page_count=1, raw_blocks_count=1, structured_blocks=blocks, figure_inventory={}, table_inventory={}
|
|
)
|
|
assert report["role_mutation_count"] == 1
|
|
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
|
|
|
|
structured_blocks = [
|
|
{"role": "section_heading", "text": "1 Introduction"},
|
|
{"role": "body_paragraph", "text": "Body"},
|
|
{"role": "reference_heading", "text": "References"},
|
|
{"role": "reference_item", "text": "1. Author."},
|
|
]
|
|
figure_inventory = {
|
|
"matched_figures": [],
|
|
"unmatched_legends": [],
|
|
"unmatched_assets": [],
|
|
}
|
|
table_inventory = {
|
|
"tables": [],
|
|
"unmatched_captions": [],
|
|
"unmatched_assets": [],
|
|
}
|
|
report = build_ocr_health(
|
|
page_count=3,
|
|
raw_blocks_count=20,
|
|
structured_blocks=structured_blocks,
|
|
figure_inventory=figure_inventory,
|
|
table_inventory=table_inventory,
|
|
)
|
|
assert "tail_boundary_confidence" in report
|
|
assert isinstance(report["tail_boundary_confidence"], (int, float))
|
|
|
|
|
|
def test_ocr_health_includes_confidence_distributions() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
structured_blocks = [
|
|
{"role": "section_heading", "text": "1 Introduction"},
|
|
{"role": "body_paragraph", "text": "Body"},
|
|
]
|
|
figure_inventory = {
|
|
"matched_figures": [
|
|
{
|
|
"figure_id": "figure_001",
|
|
"caption_score": {"score": 0.9, "decision": "figure_caption", "evidence": ["figure_number"]},
|
|
},
|
|
{
|
|
"figure_id": "figure_002",
|
|
"caption_score": {"score": 0.5, "decision": "figure_caption_candidate", "evidence": []},
|
|
},
|
|
{
|
|
"figure_id": "figure_003",
|
|
"caption_score": {"score": 0.3, "decision": "rejected", "evidence": []},
|
|
},
|
|
],
|
|
"unmatched_legends": [],
|
|
"unmatched_assets": [],
|
|
}
|
|
table_inventory = {
|
|
"tables": [
|
|
{"match_score": {"score": 0.85, "decision": "matched", "evidence": ["same_page"]}},
|
|
{"match_score": {"score": 0.40, "decision": "ambiguous", "evidence": []}},
|
|
{"match_score": {"score": 0.20, "decision": "ambiguous", "evidence": []}},
|
|
],
|
|
"unmatched_captions": [],
|
|
"unmatched_assets": [],
|
|
}
|
|
report = build_ocr_health(
|
|
page_count=3,
|
|
raw_blocks_count=20,
|
|
structured_blocks=structured_blocks,
|
|
figure_inventory=figure_inventory,
|
|
table_inventory=table_inventory,
|
|
)
|
|
assert "figure_match_confidence_distribution" in report
|
|
assert "table_match_confidence_distribution" in report
|
|
assert "tail_boundary_confidence" in report
|
|
|
|
fig_dist = report["figure_match_confidence_distribution"]
|
|
assert fig_dist["high"] == 1
|
|
assert fig_dist["medium"] == 1
|
|
assert fig_dist["low"] == 1
|
|
|
|
tbl_dist = report["table_match_confidence_distribution"]
|
|
assert tbl_dist["high"] == 1
|
|
assert tbl_dist["medium"] == 1
|
|
assert tbl_dist["low"] == 1
|
|
|
|
|
|
def test_health_report_includes_warning_or_degraded_reasons() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
report = build_ocr_health(
|
|
page_count=1,
|
|
raw_blocks_count=0,
|
|
structured_blocks=[],
|
|
figure_inventory={},
|
|
table_inventory={},
|
|
)
|
|
|
|
assert "degraded_reasons" in report
|
|
assert "warning_reasons" in report
|
|
assert len(report["degraded_reasons"]) + len(report["warning_reasons"]) > 0
|
|
|
|
def test_health_report_separates_warning_reasons_from_hard_degradation() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
report = build_ocr_health(
|
|
page_count=1,
|
|
raw_blocks_count=1,
|
|
structured_blocks=[
|
|
{"role": "section_heading", "text": "1 Intro", "span_metadata": {"size": 12}},
|
|
{"role": "section_heading", "text": "2 Methods", "span_metadata": {"size": 12}},
|
|
{"role": "abstract_body", "text": "Abstract text", "span_metadata": {"size": 10}},
|
|
{"role": "reference_item", "text": "[1] Ref", "span_metadata": {"size": 10}},
|
|
{"role": "body_paragraph", "text": "Body text", "span_metadata": {"size": 10}},
|
|
],
|
|
figure_inventory={
|
|
"matched_figures": [{"caption_score": {"score": 0.2}, "matched_assets": [{}]}],
|
|
"unmatched_legends": [],
|
|
"unmatched_assets": [],
|
|
"figure_legend_completeness": {"total": 1, "accounted": 1, "gap_count": 0, "ratio": 1.0},
|
|
},
|
|
table_inventory={"tables": [], "unmatched_captions": [], "unmatched_assets": []},
|
|
)
|
|
|
|
assert any("low figure caption confidence" in r for r in report["warning_reasons"])
|
|
assert all("low figure caption confidence" not in r for r in report["degraded_reasons"])
|
|
|
|
|
|
def test_health_report_marks_rendered_text_gaps_as_hard_degradation() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
report = build_ocr_health(
|
|
page_count=1,
|
|
raw_blocks_count=1,
|
|
structured_blocks=[
|
|
{
|
|
"role": "body_paragraph",
|
|
"text": "Short OCR body",
|
|
"span_metadata": {"size": 10},
|
|
"pdf_region_text": "Important missing body segment from PDF",
|
|
}
|
|
],
|
|
figure_inventory={},
|
|
table_inventory={},
|
|
rendered_markdown="Short OCR body",
|
|
)
|
|
|
|
assert any("rendered text gaps" in r for r in report["degraded_reasons"])
|
|
assert report["rendered_text_gap_count"] > 0
|
|
|
|
def test_ocr_health_reports_layout_confidence_distribution() -> None:
|
|
from paperforge.worker.ocr_document import DocumentStructure, PageLayoutProfile
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
doc = DocumentStructure(
|
|
page_layouts={
|
|
1: PageLayoutProfile(confidence=0.8),
|
|
2: PageLayoutProfile(confidence=0.5),
|
|
3: PageLayoutProfile(confidence=0.2),
|
|
}
|
|
)
|
|
|
|
report = build_ocr_health(
|
|
page_count=3,
|
|
raw_blocks_count=3,
|
|
structured_blocks=[
|
|
{"role": "abstract_body"},
|
|
{"role": "reference_item"},
|
|
{"role": "section_heading"},
|
|
{"role": "section_heading"},
|
|
],
|
|
figure_inventory={},
|
|
table_inventory={},
|
|
doc_structure=doc,
|
|
)
|
|
|
|
assert report["layout_confidence_distribution"] == {"high": 1, "medium": 1, "low": 1}
|
|
|
|
|
|
def test_ocr_health_counts_low_confidence_insert_candidates() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
report = build_ocr_health(
|
|
page_count=1,
|
|
raw_blocks_count=2,
|
|
structured_blocks=[
|
|
{
|
|
"role": "structured_insert_candidate",
|
|
"insert_score": {"score": 0.45, "decision": "structured_insert_candidate"},
|
|
},
|
|
{"role": "structured_insert", "insert_score": {"score": 0.35, "decision": "body"}},
|
|
{"role": "abstract_body"},
|
|
{"role": "reference_item"},
|
|
{"role": "section_heading"},
|
|
{"role": "section_heading"},
|
|
],
|
|
figure_inventory={},
|
|
table_inventory={},
|
|
)
|
|
|
|
assert report["low_confidence_insert_candidate_count"] == 1
|
|
assert report["candidate_forced_count"] == 1
|
|
|
|
|
|
def test_ocr_health_counts_ambiguous_and_low_confidence_tables() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
report = build_ocr_health(
|
|
page_count=1,
|
|
raw_blocks_count=1,
|
|
structured_blocks=[
|
|
{"role": "abstract_body"},
|
|
{"role": "reference_item"},
|
|
{"role": "section_heading"},
|
|
{"role": "section_heading"},
|
|
],
|
|
figure_inventory={},
|
|
table_inventory={
|
|
"tables": [
|
|
{
|
|
"has_asset": False,
|
|
"is_continuation": False,
|
|
"match_status": "ambiguous",
|
|
"match_score": {"score": 0.5},
|
|
},
|
|
{
|
|
"has_asset": True,
|
|
"is_continuation": False,
|
|
"match_status": "matched_low_confidence",
|
|
"match_score": {"score": 0.45},
|
|
},
|
|
]
|
|
},
|
|
)
|
|
|
|
assert report["ambiguous_table_match_count"] == 1
|
|
assert report["low_confidence_table_match_count"] == 1
|
|
|
|
|
|
def test_ocr_health_reports_hard_rule_and_uncertainty_summary() -> None:
|
|
from paperforge.worker.ocr_document import DocumentStructure, PageLayoutProfile
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
doc = DocumentStructure(page_layouts={1: PageLayoutProfile(confidence=0.25)})
|
|
doc.tail_boundary_score = {"score": 0.35}
|
|
|
|
report = build_ocr_health(
|
|
page_count=1,
|
|
raw_blocks_count=6,
|
|
structured_blocks=[
|
|
{"role": "structured_insert", "insert_score": {"score": 0.35}},
|
|
{"role": "abstract_body"},
|
|
{"role": "reference_item"},
|
|
{"role": "section_heading"},
|
|
{"role": "section_heading"},
|
|
],
|
|
figure_inventory={
|
|
"matched_figures": [{"caption_score": {"score": 0.3}}],
|
|
"ambiguous_figures": [{"legend_block_id": "cap1"}],
|
|
"unresolved_clusters": [{"cluster_id": "unresolved_cluster_001"}],
|
|
},
|
|
table_inventory={
|
|
"tables": [
|
|
{
|
|
"match_status": "ambiguous",
|
|
"match_score": {"score": 0.5},
|
|
"has_asset": False,
|
|
"is_continuation": False,
|
|
}
|
|
]
|
|
},
|
|
doc_structure=doc,
|
|
)
|
|
|
|
assert report["low_score_but_matched_count"] >= 1
|
|
assert report["ambiguous_match_count"] >= 2
|
|
assert report["unresolved_cluster_count"] == 1
|
|
assert report["candidate_forced_count"] >= 1
|
|
assert report["low_tail_boundary_confidence"] is True
|
|
|
|
|
|
def test_ocr_health_has_hard_rule_decision_count_key() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
report = build_ocr_health(
|
|
page_count=1,
|
|
raw_blocks_count=1,
|
|
structured_blocks=[
|
|
{"role": "abstract_body"},
|
|
{"role": "reference_item"},
|
|
{"role": "section_heading"},
|
|
{"role": "section_heading"},
|
|
],
|
|
figure_inventory={},
|
|
table_inventory={},
|
|
)
|
|
|
|
assert "hard_rule_decision_count" in report
|
|
assert isinstance(report["hard_rule_decision_count"], int)
|
|
|
|
|
|
def test_ocr_health_hard_rule_decision_count_uses_real_signals() -> None:
|
|
from paperforge.worker.ocr_decisions import record_decision
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
blocks = [
|
|
{"block_id": "a", "page": 1, "role": "structured_insert", "insert_score": {"score": 0.35}},
|
|
{"block_id": "b", "page": 1, "role": "abstract_body"},
|
|
{"block_id": "c", "page": 1, "role": "reference_item"},
|
|
{"block_id": "d", "page": 1, "role": "section_heading"},
|
|
{"block_id": "e", "page": 1, "role": "section_heading"},
|
|
]
|
|
record_decision(
|
|
blocks[0],
|
|
stage="structured_insert_promotion",
|
|
old_role="body_paragraph",
|
|
new_role="structured_insert",
|
|
reason="forced fallback",
|
|
)
|
|
record_decision(
|
|
blocks[0],
|
|
stage="tail_candidate_resolution",
|
|
old_role="tail_candidate_body",
|
|
new_role="reference_item",
|
|
reason="tail ownership",
|
|
)
|
|
|
|
report = build_ocr_health(
|
|
page_count=1,
|
|
raw_blocks_count=len(blocks),
|
|
structured_blocks=blocks,
|
|
figure_inventory={},
|
|
table_inventory={},
|
|
)
|
|
|
|
assert report["hard_rule_decision_count"] >= 2
|
|
|
|
|
|
def test_health_reports_anchor_zone_and_hold_statuses() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
report = build_ocr_health(
|
|
page_count=2,
|
|
raw_blocks_count=3,
|
|
structured_blocks=[],
|
|
figure_inventory={"matched_figures": [], "held_figures": [], "unmatched_legends": [], "unmatched_assets": []},
|
|
table_inventory={"tables": [], "held_tables": [], "unmatched_assets": [], "unmatched_captions": []},
|
|
doc_structure={
|
|
"anchor_summary": {"reference_family_anchor": "ACCEPT", "body_family_anchor": "ACCEPT"},
|
|
"zone_summary": {"reference_zone": "ACCEPT", "body_zone": "ACCEPT"},
|
|
"held_counts": {"families": 1, "matches": 0},
|
|
},
|
|
)
|
|
|
|
assert "anchor_summary" in report
|
|
assert "zone_summary" in report
|
|
assert "held_counts" in report
|
|
|
|
|
|
def test_health_counts_held_figures_and_tables() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
report = build_ocr_health(
|
|
page_count=1,
|
|
raw_blocks_count=1,
|
|
structured_blocks=[
|
|
{"role": "abstract_body"},
|
|
{"role": "reference_item"},
|
|
{"role": "section_heading"},
|
|
{"role": "section_heading"},
|
|
],
|
|
figure_inventory={
|
|
"matched_figures": [],
|
|
"held_figures": [{"figure_id": "held_figure_001"}],
|
|
"unmatched_legends": [],
|
|
"unmatched_assets": [],
|
|
},
|
|
table_inventory={
|
|
"tables": [],
|
|
"held_tables": [{"table_id": "held_table_001"}],
|
|
"unmatched_assets": [],
|
|
"unmatched_captions": [],
|
|
},
|
|
)
|
|
|
|
assert report["held_figure_count"] == 1
|
|
assert report["held_table_count"] == 1
|
|
assert report["held_match_count"] == 2
|
|
|
|
|
|
def test_role_gate_health_corrections_are_not_errors() -> None:
|
|
from paperforge.worker.ocr_document import DocumentStructure
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
role_gate_summary = {
|
|
"status": "healthy",
|
|
"corrected_structural_seed_count": 3,
|
|
"held_structural_seed_count": 0,
|
|
"final_unverified_structural_role_count": 0,
|
|
"seed_role_passthrough_count": 0,
|
|
"abstract_body_outside_span_count": 0,
|
|
"reference_item_outside_reference_zone_count": 0,
|
|
}
|
|
doc = DocumentStructure()
|
|
doc.role_gate_summary = role_gate_summary
|
|
|
|
structured_blocks = [
|
|
{"role": "section_heading", "text": "Introduction"},
|
|
{"role": "section_heading", "text": "Methods"},
|
|
{"role": "abstract_body", "text": "Abstract."},
|
|
{"role": "reference_item", "text": "Ref."},
|
|
]
|
|
health = build_ocr_health(
|
|
page_count=1,
|
|
raw_blocks_count=len(structured_blocks),
|
|
structured_blocks=structured_blocks,
|
|
figure_inventory={"matched_figures": [], "unmatched_legends": [], "unmatched_assets": []},
|
|
table_inventory={"tables": [], "unmatched_captions": [], "unmatched_assets": []},
|
|
doc_structure=doc,
|
|
)
|
|
|
|
assert "role_gate_summary" in health
|
|
assert health["role_gate_summary"]["corrected_structural_seed_count"] == 3
|
|
assert health["overall"] != "red"
|
|
|
|
|
|
def test_role_gate_health_degrades_on_passthrough() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
role_gate_summary = {
|
|
"status": "degraded",
|
|
"corrected_structural_seed_count": 0,
|
|
"held_structural_seed_count": 2,
|
|
"final_unverified_structural_role_count": 2,
|
|
"seed_role_passthrough_count": 2,
|
|
"abstract_body_outside_span_count": 1,
|
|
"reference_item_outside_reference_zone_count": 1,
|
|
}
|
|
health = build_ocr_health(
|
|
page_count=1,
|
|
raw_blocks_count=0,
|
|
structured_blocks=[],
|
|
figure_inventory={},
|
|
table_inventory={},
|
|
doc_structure={"role_gate_summary": role_gate_summary},
|
|
)
|
|
|
|
assert "role_gate_summary" in health
|
|
assert health["role_gate_summary"]["status"] == "degraded"
|
|
|
|
|
|
def test_ocr_health_role_gate_degraded_forces_overall_red() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
role_gate_summary = {
|
|
"status": "degraded",
|
|
"corrected_structural_seed_count": 0,
|
|
"held_structural_seed_count": 1,
|
|
"final_unverified_structural_role_count": 1,
|
|
"seed_role_passthrough_count": 1,
|
|
"abstract_body_outside_span_count": 0,
|
|
"reference_item_outside_reference_zone_count": 0,
|
|
}
|
|
health = build_ocr_health(
|
|
page_count=3,
|
|
raw_blocks_count=0,
|
|
structured_blocks=[],
|
|
figure_inventory={},
|
|
table_inventory={},
|
|
doc_structure={"role_gate_summary": role_gate_summary},
|
|
)
|
|
|
|
assert health["overall"] == "red"
|
|
|
|
|
|
def test_ocr_health_exposes_completeness_signals_for_runtime_blocks() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
report = build_ocr_health(
|
|
page_count=1,
|
|
raw_blocks_count=2,
|
|
structured_blocks=[
|
|
{
|
|
"role": "body_paragraph",
|
|
"text": "short text",
|
|
"page": 1,
|
|
"pdf_text": "short text plus a much longer native PDF segment that should dominate coverage",
|
|
"pdf_region_text": "short text plus a much longer native PDF segment that should dominate coverage",
|
|
},
|
|
{"role": "section_heading", "text": "Intro"},
|
|
{"role": "section_heading", "text": "Methods"},
|
|
{"role": "reference_item", "text": "[1] Ref"},
|
|
{"role": "abstract_body", "text": "Abstract."},
|
|
],
|
|
figure_inventory={},
|
|
table_inventory={},
|
|
)
|
|
|
|
assert report["page_text_coverage"][1]["page_text_coverage_status"] == "low"
|
|
assert report["text_completeness_summary"]["short_vs_pdf"] == 1
|
|
|
|
|
|
def test_ocr_health_marks_missing_pdf_baseline_without_perfect_ratio() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
report = build_ocr_health(
|
|
page_count=1,
|
|
raw_blocks_count=1,
|
|
structured_blocks=[
|
|
{"role": "body_paragraph", "text": "Body text", "page": 1, "pdf_text": ""},
|
|
{"role": "section_heading", "text": "Intro"},
|
|
{"role": "section_heading", "text": "Methods"},
|
|
{"role": "reference_item", "text": "[1] Ref"},
|
|
{"role": "abstract_body", "text": "Abstract."},
|
|
],
|
|
figure_inventory={},
|
|
table_inventory={},
|
|
)
|
|
|
|
coverage = report["page_text_coverage"][1]
|
|
assert coverage["page_text_coverage_status"] == "missing_pdf_text"
|
|
assert coverage["page_text_coverage_ratio_chars"] is None
|
|
|
|
|
|
def test_rendered_text_coverage_normalizes_whitespace_and_case() -> None:
|
|
from paperforge.worker.ocr_health import audit_rendered_text_coverage
|
|
|
|
result = audit_rendered_text_coverage(
|
|
rendered_markdown="In Vivo methods are summarized here.",
|
|
pdf_segments=["in vivo methods are summarized here."],
|
|
)
|
|
|
|
assert result["rendered_text_gap_count"] == 0
|
|
|
|
|
|
def test_ocr_health_reports_rendered_gap_count_when_markdown_is_provided() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
report = build_ocr_health(
|
|
page_count=1,
|
|
raw_blocks_count=1,
|
|
structured_blocks=[
|
|
{"role": "body_paragraph", "text": "Short OCR body.", "page": 1, "pdf_region_text": "A long methods segment that is missing from render."},
|
|
{"role": "section_heading", "text": "Intro"},
|
|
{"role": "section_heading", "text": "Methods"},
|
|
{"role": "reference_item", "text": "[1] Ref"},
|
|
{"role": "abstract_body", "text": "Abstract."},
|
|
],
|
|
figure_inventory={},
|
|
table_inventory={},
|
|
rendered_markdown="Only the introduction survived.",
|
|
)
|
|
|
|
assert report["rendered_text_gap_count"] == 1
|
|
|
|
|
|
def test_health_emits_additive_v2_fields_without_replacing_overall() -> None:
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
health = build_ocr_health(
|
|
page_count=1, raw_blocks_count=0,
|
|
structured_blocks=[{"role": "section_heading", "text": "Intro"}],
|
|
figure_inventory={"matched_figures": [], "held_figures": [], "unmatched_legends": [], "unmatched_assets": [], "figure_legend_completeness": {}},
|
|
table_inventory={"tables": [], "held_tables": [], "unmatched_captions": [], "unmatched_assets": []},
|
|
reader_payload=None, rendered_markdown=None,
|
|
)
|
|
assert "overall" in health
|
|
assert "heading_total_v2" in health
|
|
assert "matched_figure_count_v2" in health
|
|
assert "issue_breakdown_v2" in health
|
|
|
|
def test_short_form_health_does_not_go_red_for_missing_abstract_headings_and_refs():
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
report = build_ocr_health(
|
|
page_count=2,
|
|
raw_blocks_count=2,
|
|
structured_blocks=[
|
|
{"page": 1, "role": "body_paragraph", "text": "Short letter text."},
|
|
{"page": 2, "role": "body_paragraph", "text": "More short letter text."},
|
|
],
|
|
figure_inventory={"matched_figures": [], "held_figures": [], "unmatched_legends": [], "unmatched_assets": [], "figure_legend_completeness": {}},
|
|
table_inventory={"tables": [], "held_tables": [], "unmatched_captions": [], "unmatched_assets": []},
|
|
)
|
|
|
|
assert report["health_profile"] == "short_form"
|
|
assert report["overall"] in {"green", "yellow"}
|
|
assert report["needs_rebuild"] is False
|
|
assert "abstract_found" in report["waived_gates"]
|
|
assert report["degraded_reason"] == "short_paper_format"
|
|
|
|
|
|
def test_standard_profile_still_flags_missing_structure():
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
report = build_ocr_health(
|
|
page_count=5,
|
|
raw_blocks_count=2,
|
|
structured_blocks=[
|
|
{"page": 1, "role": "body_paragraph", "text": "Body."},
|
|
{"page": 5, "role": "body_paragraph", "text": "Tail."},
|
|
],
|
|
figure_inventory={"matched_figures": [], "held_figures": [], "unmatched_legends": [], "unmatched_assets": [], "figure_legend_completeness": {}},
|
|
table_inventory={"tables": [], "held_tables": [], "unmatched_captions": [], "unmatched_assets": []},
|
|
)
|
|
|
|
assert report["health_profile"] == "standard"
|
|
assert report["overall"] == "red"
|
|
|
|
|
|
def test_figure_completeness_backward_compat_legacy() -> None:
|
|
"""_figure_completeness reads legacy 'figure_legend_completeness' shape."""
|
|
from paperforge.worker.ocr_health import _figure_completeness
|
|
|
|
fi = {"figure_legend_completeness": {"total": 5, "accounted_for": 4, "gap_count": 1}}
|
|
c = _figure_completeness(fi)
|
|
assert c == {"total": 5, "accounted_for": 4, "gap_count": 1}
|
|
|
|
|
|
def test_figure_completeness_vnext_format() -> None:
|
|
"""_figure_completeness reads vnext 'completeness' shape."""
|
|
from paperforge.worker.ocr_health import _figure_completeness
|
|
|
|
fi = {"completeness": {"total_numbered_legends": 3, "accounted_for": 3, "gap_count": 0}}
|
|
c = _figure_completeness(fi)
|
|
assert c == {"total": 3, "accounted_for": 3, "gap_count": 0}
|
|
|
|
|
|
def test_figure_completeness_legacy_preferred_over_vnext() -> None:
|
|
"""When both shapes present, legacy takes priority."""
|
|
from paperforge.worker.ocr_health import _figure_completeness
|
|
|
|
fi = {
|
|
"figure_legend_completeness": {"total": 5, "accounted_for": 4, "gap_count": 1},
|
|
"completeness": {"total_numbered_legends": 99, "accounted_for": 99, "gap_count": 0},
|
|
}
|
|
c = _figure_completeness(fi)
|
|
assert c == {"total": 5, "accounted_for": 4, "gap_count": 1}
|
|
|
|
|
|
def test_figure_completeness_empty_fallback() -> None:
|
|
"""_figure_completeness returns zero-safe dict when neither shape exists."""
|
|
from paperforge.worker.ocr_health import _figure_completeness
|
|
|
|
assert _figure_completeness({}) == {"total": 0, "accounted_for": 0, "gap_count": 0}
|
|
|
|
|
|
def test_figure_status_green_no_numbered_figures() -> None:
|
|
"""When total is 0, figure_status is green with 'no_numbered_figures'."""
|
|
from paperforge.worker.ocr_health import _compute_figure_status
|
|
|
|
status, reasons = _compute_figure_status({})
|
|
assert status == "green"
|
|
assert "no_numbered_figures" in reasons
|
|
|
|
|
|
def test_figure_status_green_full_accounting() -> None:
|
|
"""Full accounting without gaps yields green."""
|
|
from paperforge.worker.ocr_health import _compute_figure_status
|
|
|
|
fi = {"figure_legend_completeness": {"total": 10, "accounted_for": 10, "gap_count": 0}}
|
|
status, reasons = _compute_figure_status(fi)
|
|
assert status == "green"
|
|
assert any("figure_accounting_ok" in r for r in reasons)
|
|
|
|
|
|
def test_figure_status_yellow_partial() -> None:
|
|
"""Partial accounting >= 60% yields yellow."""
|
|
from paperforge.worker.ocr_health import _compute_figure_status
|
|
|
|
fi = {"figure_legend_completeness": {"total": 10, "accounted_for": 7, "gap_count": 3}}
|
|
status, reasons = _compute_figure_status(fi)
|
|
assert status == "yellow"
|
|
assert any("partial_figure_accounting" in r for r in reasons)
|
|
|
|
|
|
def test_figure_status_red_poor() -> None:
|
|
"""Poor accounting < 60% yields red."""
|
|
from paperforge.worker.ocr_health import _compute_figure_status
|
|
|
|
fi = {"figure_legend_completeness": {"total": 10, "accounted_for": 5, "gap_count": 5}}
|
|
status, reasons = _compute_figure_status(fi)
|
|
assert status == "red"
|
|
assert any("poor_figure_accounting" in r for r in reasons)
|
|
|
|
|
|
def test_figure_status_uses_normalized_completeness_not_caption_count() -> None:
|
|
"""figure_status relies on _figure_completeness, never figure_caption_count."""
|
|
from paperforge.worker.ocr_health import _compute_figure_status
|
|
|
|
fi = {"completeness": {"total_numbered_legends": 4, "accounted_for": 4, "gap_count": 0}}
|
|
status, reasons = _compute_figure_status(fi)
|
|
assert status == "green"
|
|
|
|
|
|
def test_structure_status_computation() -> None:
|
|
"""_compute_structure_status maps blocker count to traffic-light."""
|
|
from paperforge.worker.ocr_health import _compute_structure_status
|
|
|
|
assert _compute_structure_status(0) == ("green", ["all_structural_gates_passed"])
|
|
assert _compute_structure_status(1) == ("yellow", ["structural_blocker:1"])
|
|
assert _compute_structure_status(2) == ("red", ["structural_blockers:2"])
|
|
assert _compute_structure_status(3) == ("red", ["structural_blockers:3"])
|
|
|
|
|
|
def test_structure_status_role_gate_degraded_overrides() -> None:
|
|
"""Role gate degraded forces red regardless of blocker count."""
|
|
from paperforge.worker.ocr_health import _compute_structure_status
|
|
|
|
status, reasons = _compute_structure_status(0, role_gate_status="degraded")
|
|
assert status == "red"
|
|
assert "structural_role_gate_degraded" in reasons
|
|
|
|
|
|
def test_table_status_no_tables() -> None:
|
|
"""No formal tables yields green."""
|
|
from paperforge.worker.ocr_health import _compute_table_status
|
|
|
|
status, reasons = _compute_table_status(
|
|
formal_table_count=0, table_asset_count=0, ambiguous_count=0, low_confidence_count=0,
|
|
)
|
|
assert status == "green"
|
|
assert "no_formal_tables" in reasons
|
|
|
|
|
|
def test_table_status_green_full_coverage() -> None:
|
|
"""Full asset coverage without ambiguity yields green."""
|
|
from paperforge.worker.ocr_health import _compute_table_status
|
|
|
|
status, reasons = _compute_table_status(
|
|
formal_table_count=5, table_asset_count=5, ambiguous_count=0, low_confidence_count=0,
|
|
)
|
|
assert status == "green"
|
|
assert any("table_assets_ok" in r for r in reasons)
|
|
|
|
|
|
def test_table_status_yellow_partial() -> None:
|
|
"""Partial asset coverage with ambiguity yields yellow."""
|
|
from paperforge.worker.ocr_health import _compute_table_status
|
|
|
|
status, reasons = _compute_table_status(
|
|
formal_table_count=10, table_asset_count=7, ambiguous_count=2, low_confidence_count=0,
|
|
)
|
|
assert status == "yellow"
|
|
|
|
|
|
def test_table_status_red_no_assets() -> None:
|
|
"""No table assets at all yields red."""
|
|
from paperforge.worker.ocr_health import _compute_table_status
|
|
|
|
status, reasons = _compute_table_status(
|
|
formal_table_count=3, table_asset_count=0, ambiguous_count=0, low_confidence_count=0,
|
|
)
|
|
assert status == "red"
|
|
assert "no_table_assets" in reasons
|
|
|
|
|
|
def test_table_status_red_poor_ratio() -> None:
|
|
"""Asset ratio < 60% yields red."""
|
|
from paperforge.worker.ocr_health import _compute_table_status
|
|
|
|
status, reasons = _compute_table_status(
|
|
formal_table_count=10, table_asset_count=5, ambiguous_count=0, low_confidence_count=0,
|
|
)
|
|
assert status == "red"
|
|
|
|
|
|
def test_health_status_split_structure_red_figure_green() -> None:
|
|
"""Structure=red (missing abstract/refs/headings) + figure=green (full accounting)."""
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
report = build_ocr_health(
|
|
page_count=10,
|
|
raw_blocks_count=0,
|
|
structured_blocks=[
|
|
{"page": 1, "role": "body_paragraph", "text": "Intro."},
|
|
{"page": 5, "role": "body_paragraph", "text": "Body."},
|
|
],
|
|
figure_inventory={
|
|
"matched_figures": [],
|
|
"held_figures": [],
|
|
"unmatched_legends": [],
|
|
"unmatched_assets": [],
|
|
"figure_legend_completeness": {"total": 8, "accounted_for": 8, "gap_count": 0},
|
|
},
|
|
table_inventory={"tables": [], "held_tables": [], "unmatched_captions": [], "unmatched_assets": []},
|
|
)
|
|
|
|
assert report["overall"] == "red"
|
|
assert report["structure_status"] == "red"
|
|
assert report["figure_status"] == "green"
|
|
|
|
|
|
def test_health_status_split_all_fields_present() -> None:
|
|
"""All PR-3 status split fields are present alongside overall."""
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
report = build_ocr_health(
|
|
page_count=3,
|
|
raw_blocks_count=0,
|
|
structured_blocks=[
|
|
{"page": 1, "role": "abstract_heading", "text": "Abstract"},
|
|
{"page": 1, "role": "abstract_body", "text": "A."},
|
|
{"page": 1, "role": "section_heading", "text": "Intro"},
|
|
{"page": 2, "role": "subsection_heading", "text": "Method"},
|
|
{"page": 3, "role": "reference_item", "text": "Ref 1"},
|
|
{"page": 3, "role": "reference_item", "text": "Ref 2"},
|
|
],
|
|
figure_inventory={
|
|
"matched_figures": [],
|
|
"held_figures": [],
|
|
"unmatched_legends": [],
|
|
"unmatched_assets": [],
|
|
"figure_legend_completeness": {"total": 5, "accounted_for": 5, "gap_count": 0},
|
|
},
|
|
table_inventory={"tables": [], "held_tables": [], "unmatched_captions": [], "unmatched_assets": []},
|
|
)
|
|
|
|
assert "overall" in report
|
|
assert "structure_status" in report
|
|
assert "figure_status" in report
|
|
assert "table_status" in report
|
|
assert "dimension_statuses" in report
|
|
assert "status_reasons" in report
|
|
assert report["dimension_statuses"] == {
|
|
"structure": report["structure_status"],
|
|
"figure": report["figure_status"],
|
|
"table": report["table_status"],
|
|
}
|
|
assert len(report["status_reasons"]) >= 3
|
|
assert report["overall"] == "green"
|
|
|
|
|
|
def test_health_status_split_figure_uses_vnext_completeness() -> None:
|
|
"""figure_status works with vnext completeness shape."""
|
|
from paperforge.worker.ocr_health import build_ocr_health
|
|
|
|
report = build_ocr_health(
|
|
page_count=3,
|
|
raw_blocks_count=0,
|
|
structured_blocks=[
|
|
{"page": 1, "role": "abstract_heading", "text": "Abstract"},
|
|
{"page": 1, "role": "abstract_body", "text": "A."},
|
|
{"page": 1, "role": "section_heading", "text": "Intro"},
|
|
{"page": 2, "role": "subsection_heading", "text": "Method"},
|
|
{"page": 3, "role": "reference_item", "text": "Ref 1"},
|
|
],
|
|
figure_inventory={
|
|
"matched_figures": [],
|
|
"held_figures": [],
|
|
"unmatched_legends": [],
|
|
"unmatched_assets": [],
|
|
"completeness": {"total_numbered_legends": 3, "accounted_for": 3, "gap_count": 0},
|
|
},
|
|
table_inventory={"tables": [], "held_tables": [], "unmatched_captions": [], "unmatched_assets": []},
|
|
)
|
|
|
|
assert report["figure_status"] == "green"
|
|
assert report["overall"] == "green"
|