feat: close OCR object gate for reader outputs

When figure_caption_candidate blocks lack explicit marker_type or caption_text in the strict inventory, _normalize_bucket now falls back to the structured block's text and infers figure_number/marker_type from the text content. This ensures candidate captions propagate through the reader-figure pipeline to the renderer and health summary.
This commit is contained in:
Research Assistant 2026-06-12 17:02:59 +08:00
parent 669c3bfe07
commit 8dfc0a0425
2 changed files with 39 additions and 3 deletions

View file

@ -1,7 +1,16 @@
from __future__ import annotations
import contextlib
import re
from dataclasses import dataclass
_FIGURE_NUM_PATTERN = re.compile(
r"(?:Figure|Fig\.?|Supplementary\s+Figure|Supplementary\s+Fig\.?|"
r"Extended\s+Data\s+Figure|Extended\s+Data\s+Fig\.?)\s+"
r"(?:S)?(\d+(?:\.\d+)?)",
flags=re.IGNORECASE,
)
@dataclass(frozen=True)
class ReaderCoverage:
@ -100,7 +109,7 @@ def _normalize_bucket(
legend_block_id = _legend_block_id(source_item)
source_page = source_item.get("page")
block = (page_block_index or {}).get((source_page, legend_block_id), block_index.get(legend_block_id, {}))
source_text = str(source_item.get("text", source_item.get("caption_text", "")) or "")
source_text = str(source_item.get("text", source_item.get("caption_text", block.get("text", ""))) or "")
legend_data = (page_legends_index or {}).get((source_page, legend_block_id), (legends_index or {}).get(legend_block_id, {}))
source_marker = (
source_item.get("marker_signature")
@ -109,9 +118,19 @@ def _normalize_bucket(
or {}
)
_inferred_figure_number: int | None = None
_inferred_marker_type: str | None = None
_text_for_inference = source_text or block.get("text", "")
if not source_item.get("marker_type") and not source_marker.get("type") and _text_for_inference:
_m = _FIGURE_NUM_PATTERN.search(_text_for_inference)
if _m:
_inferred_marker_type = "figure_number"
with contextlib.suppress(ValueError):
_inferred_figure_number = int(float(_m.group(1)))
normalized.append(
{
"figure_number": source_item.get("figure_number") or source_marker.get("number"),
"figure_number": source_item.get("figure_number") or source_marker.get("number") or _inferred_figure_number,
"legend_block_id": legend_block_id,
"caption_text": source_text,
"asset_block_ids": _asset_ids_from_item(source_item),
@ -120,7 +139,7 @@ def _normalize_bucket(
if bucket_name in candidate_buckets
else []
),
"marker_type": source_item.get("marker_type") or source_marker.get("type"),
"marker_type": source_item.get("marker_type") or source_marker.get("type") or _inferred_marker_type,
"inline_mention": bool(source_item.get("inline_mention", False)),
"panel_label": bool(source_item.get("panel_label", False)),
"body_prose_likelihood": float(source_item.get("body_prose_likelihood", 0.0)),

View file

@ -277,6 +277,23 @@ def test_reader_payload_coverage_accounted_matches_reader_figures() -> None:
assert set(result["consumed_caption_block_ids"]) == {5, 21}
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(
{
"matched_figures": [{"figure_id": "figure_001", "page": 2, "legend_block_id": 21, "asset_block_ids": [90], "strict_status": "matched"}],
"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
def test_reader_normalization_keeps_same_block_id_legends_separate_by_page() -> None:
from paperforge.worker.ocr_figure_reader import synthesize_reader_figures