2026-06-10 12:16:21 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_normalize_strict_inventory_maps_bucket_variants_to_common_fields() -> None:
|
|
|
|
|
from paperforge.worker.ocr_figure_reader import _normalize_strict_figure_inventory
|
|
|
|
|
|
|
|
|
|
strict_inventory = {
|
|
|
|
|
"matched_figures": [
|
|
|
|
|
{
|
|
|
|
|
"figure_number": 6,
|
|
|
|
|
"block_id": 15,
|
|
|
|
|
"text": "Fig. 6 The figure represents...",
|
|
|
|
|
"matched_assets": [{"block_id": 40, "bbox": [1, 2, 3, 4]}],
|
|
|
|
|
"match_score": 0.91,
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"ambiguous_figures": [
|
|
|
|
|
{
|
|
|
|
|
"figure_number": 3,
|
|
|
|
|
"legend_block_id": 9,
|
|
|
|
|
"text": "FIGURE 3 | Histological evaluation...",
|
2026-06-12 09:24:57 +00:00
|
|
|
"candidates": [
|
|
|
|
|
{"asset_block_id": 10, "match_score": 0.51},
|
|
|
|
|
{"asset_block_id": 11, "match_score": 0.49},
|
|
|
|
|
],
|
2026-06-10 12:16:21 +00:00
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"unmatched_legends": [
|
|
|
|
|
{
|
|
|
|
|
"block_id": 21,
|
|
|
|
|
"text": "FIGURE 2 | Treadmill exercise protocols...",
|
|
|
|
|
"figure_number": 2,
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"unresolved_clusters": [
|
|
|
|
|
{
|
|
|
|
|
"page": 7,
|
|
|
|
|
"media_block_ids": [30, 31],
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
structured_blocks = [
|
2026-06-12 09:24:57 +00:00
|
|
|
{
|
|
|
|
|
"block_id": 21,
|
|
|
|
|
"marker_signature": {"type": "figure_number"},
|
|
|
|
|
"zone": "display_zone",
|
|
|
|
|
"style_family": "legend_like",
|
|
|
|
|
}
|
2026-06-10 12:16:21 +00:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
normalized = _normalize_strict_figure_inventory(strict_inventory, structured_blocks)
|
|
|
|
|
|
|
|
|
|
assert normalized["matched_figures"][0]["legend_block_id"] == 15
|
|
|
|
|
assert normalized["matched_figures"][0]["caption_text"] == "Fig. 6 The figure represents..."
|
|
|
|
|
assert normalized["matched_figures"][0]["asset_block_ids"] == [40]
|
|
|
|
|
assert normalized["ambiguous_figures"][0]["candidate_asset_ids"] == [10, 11]
|
|
|
|
|
assert normalized["unmatched_legends"][0]["legend_block_id"] == 21
|
|
|
|
|
assert normalized["unresolved_clusters"][0]["asset_block_ids"] == [30, 31]
|
2026-06-10 12:19:16 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reader_figure_preserves_separate_reader_and_strict_status() -> None:
|
|
|
|
|
from paperforge.worker.ocr_figure_reader import synthesize_reader_figures
|
|
|
|
|
|
|
|
|
|
normalized_inventory = {
|
|
|
|
|
"matched_figures": [],
|
|
|
|
|
"held_figures": [],
|
|
|
|
|
"ambiguous_figures": [
|
|
|
|
|
{
|
|
|
|
|
"figure_number": 3,
|
|
|
|
|
"legend_block_id": 9,
|
|
|
|
|
"caption_text": "FIGURE 3 | Histological evaluation...",
|
|
|
|
|
"candidate_asset_ids": [10, 11],
|
|
|
|
|
"strict_status": "ambiguous",
|
|
|
|
|
"marker_type": "figure_number",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"unmatched_legends": [],
|
|
|
|
|
"unresolved_clusters": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result = synthesize_reader_figures(normalized_inventory, structured_blocks=[])
|
|
|
|
|
|
|
|
|
|
rf = result["reader_figures"][0]
|
|
|
|
|
assert rf["reader_status"] == "GROUPED_APPROXIMATE"
|
|
|
|
|
assert rf["strict_status"] == "ambiguous"
|
|
|
|
|
assert rf["strict_source"] == "ambiguous_figures"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reader_figure_id_uses_first_asset_id_for_visual_group_when_figure_number_missing() -> None:
|
|
|
|
|
from paperforge.worker.ocr_figure_reader import _stable_reader_figure_id
|
|
|
|
|
|
|
|
|
|
assert _stable_reader_figure_id(None, page=7, first_asset_block_id=31, ordinal=2) == "visual_group_7_31_reader"
|
2026-06-10 12:23:18 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_coverage_total_counts_deduplicated_eligible_inputs() -> None:
|
|
|
|
|
from paperforge.worker.ocr_figure_reader import synthesize_reader_figures
|
|
|
|
|
|
|
|
|
|
strict_inventory = {
|
|
|
|
|
"ambiguous_figures": [
|
|
|
|
|
{
|
|
|
|
|
"figure_number": 2,
|
|
|
|
|
"legend_block_id": 21,
|
|
|
|
|
"caption_text": "FIGURE 2 | Treadmill exercise protocols...",
|
|
|
|
|
"candidate_asset_ids": [30, 31],
|
|
|
|
|
"marker_type": "figure_number",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"unmatched_legends": [
|
|
|
|
|
{
|
|
|
|
|
"figure_number": 2,
|
|
|
|
|
"legend_block_id": 21,
|
|
|
|
|
"caption_text": "FIGURE 2 | Treadmill exercise protocols...",
|
|
|
|
|
"marker_type": "figure_number",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"matched_figures": [],
|
|
|
|
|
"held_figures": [],
|
|
|
|
|
"unresolved_clusters": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result = synthesize_reader_figures(strict_inventory, structured_blocks=[])
|
|
|
|
|
|
|
|
|
|
assert result["reader_coverage"]["total"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ambiguous_without_formal_legend_does_not_enter_reader_layer() -> None:
|
|
|
|
|
from paperforge.worker.ocr_figure_reader import synthesize_reader_figures
|
|
|
|
|
|
|
|
|
|
strict_inventory = {
|
|
|
|
|
"matched_figures": [],
|
|
|
|
|
"held_figures": [],
|
|
|
|
|
"ambiguous_figures": [
|
|
|
|
|
{
|
|
|
|
|
"legend_block_id": 50,
|
|
|
|
|
"caption_text": "Figure 2 shows the progression...",
|
|
|
|
|
"candidate_asset_ids": [70],
|
|
|
|
|
"marker_type": "figure_number",
|
|
|
|
|
"inline_mention": True,
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"unmatched_legends": [],
|
|
|
|
|
"unresolved_clusters": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result = synthesize_reader_figures(strict_inventory, structured_blocks=[])
|
|
|
|
|
|
|
|
|
|
assert result["reader_figures"] == []
|
|
|
|
|
assert result["reader_coverage"]["total"] == 0
|
2026-06-10 12:26:55 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_grouped_approximate_requires_visual_candidates() -> None:
|
|
|
|
|
from paperforge.worker.ocr_figure_reader import synthesize_reader_figures
|
|
|
|
|
|
|
|
|
|
strict_inventory = {
|
|
|
|
|
"matched_figures": [],
|
|
|
|
|
"held_figures": [],
|
|
|
|
|
"ambiguous_figures": [
|
|
|
|
|
{
|
|
|
|
|
"figure_number": 4,
|
|
|
|
|
"legend_block_id": 44,
|
|
|
|
|
"caption_text": "FIGURE 4 | Immunohistochemical staining...",
|
|
|
|
|
"candidate_asset_ids": [],
|
|
|
|
|
"marker_type": "figure_number",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"unmatched_legends": [],
|
|
|
|
|
"unresolved_clusters": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result = synthesize_reader_figures(strict_inventory, structured_blocks=[])
|
|
|
|
|
|
2026-06-13 09:21:29 +00:00
|
|
|
assert len(result["reader_figures"]) == 1
|
|
|
|
|
rf = result["reader_figures"][0]
|
|
|
|
|
assert rf["reader_status"] == "LEGEND_ONLY"
|
|
|
|
|
assert rf["visual_groups"] == [
|
|
|
|
|
{
|
|
|
|
|
"page": None,
|
|
|
|
|
"asset_block_ids": [],
|
|
|
|
|
"group_status": "legend_only_group",
|
|
|
|
|
"rendered_as_representative": False,
|
|
|
|
|
}
|
|
|
|
|
]
|
2026-06-10 12:26:55 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reader_hold_does_not_default_to_caption_consumption() -> None:
|
|
|
|
|
from paperforge.worker.ocr_figure_reader import _materialize_hold_outcome
|
|
|
|
|
|
|
|
|
|
hold = _materialize_hold_outcome(
|
|
|
|
|
legend_block_id=80,
|
|
|
|
|
caption_text="weak fragment",
|
|
|
|
|
page=10,
|
|
|
|
|
candidate_asset_ids=[],
|
|
|
|
|
hold_visibility="audit_hold",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert hold["consumed_caption_block_ids"] == []
|
|
|
|
|
assert hold["debug_refs"]["hold_visibility"] == "audit_hold"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_legend_only_consumes_caption_when_rendered() -> None:
|
|
|
|
|
from paperforge.worker.ocr_figure_reader import synthesize_reader_figures
|
|
|
|
|
|
|
|
|
|
strict_inventory = {
|
|
|
|
|
"matched_figures": [],
|
|
|
|
|
"held_figures": [],
|
|
|
|
|
"ambiguous_figures": [],
|
|
|
|
|
"unmatched_legends": [
|
|
|
|
|
{
|
|
|
|
|
"figure_number": 2,
|
|
|
|
|
"legend_block_id": 21,
|
|
|
|
|
"caption_text": "FIGURE 2 | Treadmill exercise protocols...",
|
|
|
|
|
"marker_type": "figure_number",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"unresolved_clusters": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result = synthesize_reader_figures(strict_inventory, structured_blocks=[])
|
|
|
|
|
|
2026-06-13 09:21:29 +00:00
|
|
|
assert len(result["reader_figures"]) == 1
|
|
|
|
|
rf = result["reader_figures"][0]
|
|
|
|
|
assert rf["reader_status"] == "LEGEND_ONLY"
|
|
|
|
|
assert rf["consumed_caption_block_ids"] == [{"page": None, "block_id": 21}]
|
2026-06-10 12:43:54 +00:00
|
|
|
|
|
|
|
|
|
2026-06-10 12:48:57 +00:00
|
|
|
def test_reader_sequence_match_promoted_figure_gets_proper_status() -> None:
|
|
|
|
|
"""A figure promoted via sequence_match must get SEQUENCE_MATCH reader_status."""
|
|
|
|
|
from paperforge.worker.ocr_figure_reader import synthesize_reader_figures
|
|
|
|
|
|
|
|
|
|
strict_inventory = {
|
|
|
|
|
"matched_figures": [
|
|
|
|
|
{
|
|
|
|
|
"figure_number": 2,
|
|
|
|
|
"legend_block_id": 12,
|
|
|
|
|
"text": "Figure 2. Histological analysis.",
|
|
|
|
|
"matched_assets": [],
|
|
|
|
|
"match_score": {"score": 0.0, "decision": "sequence_match", "evidence": ["sequence_promotion"]},
|
|
|
|
|
"flags": ["sequence_match"],
|
|
|
|
|
"strict_status": "sequence_match",
|
|
|
|
|
"marker_type": "figure_number",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"held_figures": [],
|
|
|
|
|
"ambiguous_figures": [],
|
|
|
|
|
"unmatched_legends": [],
|
|
|
|
|
"unresolved_clusters": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result = synthesize_reader_figures(strict_inventory, structured_blocks=[])
|
|
|
|
|
|
|
|
|
|
assert len(result["reader_figures"]) == 1
|
|
|
|
|
rf = result["reader_figures"][0]
|
2026-06-12 09:24:57 +00:00
|
|
|
assert rf["reader_status"] == "SEQUENCE_MATCH", f"Expected SEQUENCE_MATCH, got {rf['reader_status']}"
|
2026-06-10 12:48:57 +00:00
|
|
|
assert rf["strict_status"] == "sequence_match"
|
|
|
|
|
assert rf["strict_source"] == "matched_figures"
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 12:43:54 +00:00
|
|
|
def test_reader_payload_coverage_accounted_matches_reader_figures() -> None:
|
|
|
|
|
from paperforge.worker.ocr_figure_reader import synthesize_reader_figures
|
|
|
|
|
|
|
|
|
|
strict_inventory = {
|
|
|
|
|
"matched_figures": [
|
|
|
|
|
{
|
|
|
|
|
"figure_number": 1,
|
|
|
|
|
"legend_block_id": 5,
|
|
|
|
|
"text": "Fig. 1 Overview of the system...",
|
|
|
|
|
"matched_assets": [{"block_id": 10, "bbox": [1, 2, 3, 4]}],
|
|
|
|
|
"match_score": 0.91,
|
|
|
|
|
"marker_type": "figure_number",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"held_figures": [],
|
|
|
|
|
"ambiguous_figures": [],
|
|
|
|
|
"unmatched_legends": [
|
|
|
|
|
{
|
|
|
|
|
"figure_number": 2,
|
|
|
|
|
"legend_block_id": 21,
|
|
|
|
|
"text": "FIGURE 2 | Treadmill exercise protocols...",
|
|
|
|
|
"marker_type": "figure_number",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"unresolved_clusters": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result = synthesize_reader_figures(strict_inventory, structured_blocks=[])
|
|
|
|
|
|
|
|
|
|
assert result["reader_coverage"]["total"] == 2
|
2026-06-13 09:21:29 +00:00
|
|
|
assert result["reader_coverage"]["accounted"] == 2
|
|
|
|
|
assert result["reader_coverage"]["gap_count"] == 0
|
|
|
|
|
assert result["reader_coverage"]["ratio"] == 1.0
|
|
|
|
|
assert result["consumed_caption_block_ids"] == [
|
|
|
|
|
{"page": None, "block_id": 5},
|
|
|
|
|
{"page": None, "block_id": 21},
|
|
|
|
|
]
|
2026-06-11 13:48:59 +00:00
|
|
|
|
|
|
|
|
|
2026-06-12 09:02:59 +00:00
|
|
|
def test_reader_figures_emit_from_matched_or_unresolved_object_inputs() -> None:
|
|
|
|
|
from paperforge.worker.ocr_figure_reader import synthesize_reader_figures
|
|
|
|
|
|
|
|
|
|
payload = synthesize_reader_figures(
|
|
|
|
|
{
|
2026-06-12 09:24:57 +00:00
|
|
|
"matched_figures": [
|
|
|
|
|
{
|
|
|
|
|
"figure_id": "figure_001",
|
|
|
|
|
"page": 2,
|
|
|
|
|
"legend_block_id": 21,
|
|
|
|
|
"asset_block_ids": [90],
|
|
|
|
|
"strict_status": "matched",
|
|
|
|
|
}
|
|
|
|
|
],
|
2026-06-12 09:02:59 +00:00
|
|
|
"held_figures": [],
|
|
|
|
|
"ambiguous_figures": [],
|
|
|
|
|
"unmatched_legends": [],
|
|
|
|
|
"unresolved_clusters": [],
|
|
|
|
|
},
|
|
|
|
|
structured_blocks=[{"block_id": 21, "page": 2, "role": "figure_caption_candidate", "text": "Fig. 1 Caption"}],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert len(payload["reader_figures"]) >= 1
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 13:48:59 +00:00
|
|
|
def test_reader_normalization_keeps_same_block_id_legends_separate_by_page() -> None:
|
|
|
|
|
from paperforge.worker.ocr_figure_reader import synthesize_reader_figures
|
|
|
|
|
|
|
|
|
|
structured_blocks = [
|
|
|
|
|
{
|
|
|
|
|
"page": 5,
|
|
|
|
|
"block_id": 5,
|
|
|
|
|
"role": "figure_caption",
|
|
|
|
|
"text": "Fig. 1 Critical shoulder angle.",
|
|
|
|
|
"marker_signature": {"type": "figure_number", "number": 1},
|
|
|
|
|
"style_family": "legend_like",
|
|
|
|
|
"zone": "display_zone",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"page": 6,
|
|
|
|
|
"block_id": 5,
|
|
|
|
|
"role": "figure_caption",
|
|
|
|
|
"text": "Fig. 5 Acromial index.",
|
|
|
|
|
"marker_signature": {"type": "figure_number", "number": 5},
|
|
|
|
|
"style_family": "legend_like",
|
|
|
|
|
"zone": "display_zone",
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
strict_inventory = {
|
|
|
|
|
"figure_legends": structured_blocks,
|
|
|
|
|
"unmatched_legends": [structured_blocks[0], structured_blocks[1]],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload = synthesize_reader_figures(strict_inventory, structured_blocks)
|
|
|
|
|
|
|
|
|
|
numbers = [item["figure_number"] for item in payload["normalized_inputs"]["unmatched_legends"]]
|
|
|
|
|
rendered_numbers = [item["figure_number"] for item in payload["reader_figures"]]
|
|
|
|
|
|
|
|
|
|
assert numbers == [1, 5]
|
|
|
|
|
assert rendered_numbers == [1, 5]
|
2026-06-15 07:40:28 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reader_materializes_grouped_strict_match_as_single_visual_group() -> None:
|
|
|
|
|
from paperforge.worker.ocr_figure_reader import synthesize_reader_figures
|
|
|
|
|
|
|
|
|
|
strict_inventory = {
|
|
|
|
|
"figure_legends": [],
|
|
|
|
|
"matched_figures": [
|
|
|
|
|
{
|
|
|
|
|
"figure_number": 2,
|
|
|
|
|
"legend_block_id": 10,
|
|
|
|
|
"page": 3,
|
|
|
|
|
"text": "Fig. 2 A and B, paired figure.",
|
|
|
|
|
"matched_assets": [
|
|
|
|
|
{"block_id": 20, "bbox": [100, 100, 300, 300]},
|
|
|
|
|
{"block_id": 21, "bbox": [320, 100, 520, 300]},
|
|
|
|
|
],
|
|
|
|
|
"match_score": {"score": 0.82, "decision": "matched", "evidence": ["same_row_pair"]},
|
|
|
|
|
"caption_score": {"score": 0.9},
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"held_figures": [],
|
|
|
|
|
"ambiguous_figures": [],
|
|
|
|
|
"unmatched_legends": [],
|
|
|
|
|
"unresolved_clusters": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload = synthesize_reader_figures(strict_inventory, structured_blocks=[])
|
|
|
|
|
rf = payload["reader_figures"][0]
|
|
|
|
|
assert rf["figure_number"] == 2
|
|
|
|
|
assert len(rf["visual_groups"]) == 1
|
|
|
|
|
assert rf["visual_groups"][0]["asset_block_ids"] == [20, 21]
|
|
|
|
|
assert rf["visual_groups"][0]["group_status"] == "matched_group"
|
2026-06-22 14:15:39 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reader_matched_cross_page_figure_consumes_caption_on_legend_page() -> None:
|
|
|
|
|
from paperforge.worker.ocr_figure_reader import synthesize_reader_figures
|
|
|
|
|
|
|
|
|
|
strict_inventory = {
|
|
|
|
|
"figure_legends": [],
|
|
|
|
|
"matched_figures": [
|
|
|
|
|
{
|
|
|
|
|
"figure_id": "figure_004",
|
|
|
|
|
"figure_number": 4,
|
|
|
|
|
"legend_block_id": 6,
|
|
|
|
|
"page": 12,
|
|
|
|
|
"legend_page": 13,
|
|
|
|
|
"asset_pages": [12],
|
|
|
|
|
"text": "Figure 4. Cross-page caption.",
|
|
|
|
|
"matched_assets": [
|
|
|
|
|
{"block_id": 101, "bbox": [100, 100, 300, 300]},
|
|
|
|
|
],
|
|
|
|
|
"asset_block_ids": [101],
|
|
|
|
|
"strict_status": "matched",
|
|
|
|
|
"match_score": {"score": 0.8, "decision": "matched"},
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"held_figures": [],
|
|
|
|
|
"ambiguous_figures": [],
|
|
|
|
|
"unmatched_legends": [],
|
|
|
|
|
"unresolved_clusters": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload = synthesize_reader_figures(strict_inventory, structured_blocks=[])
|
|
|
|
|
|
|
|
|
|
rf = payload["reader_figures"][0]
|
|
|
|
|
assert rf["visual_groups"][0]["page"] == 12
|
|
|
|
|
assert rf["consumed_caption_block_ids"] == [{"page": 13, "block_id": 6}]
|
|
|
|
|
assert payload["consumed_caption_block_ids"] == [{"page": 13, "block_id": 6}]
|