diff --git a/paperforge/worker/ocr_figures.py b/paperforge/worker/ocr_figures.py index 6594b30b..3e404bed 100644 --- a/paperforge/worker/ocr_figures.py +++ b/paperforge/worker/ocr_figures.py @@ -17,6 +17,28 @@ _FIGURE_NUMBER_PATTERN = re.compile( flags=re.IGNORECASE, ) +_FRONTMATTER_VISUAL_VETO = ( + "graphical abstract", + "table of contents", + "highlights", + "available with this article", + "supplementary data", + "supporting information", + "video abstract", + "visual abstract", +) + + +def _has_frontmatter_visual_veto(text: str) -> bool: + lower = " ".join(text.lower().split()) + if re.search(r"\btoc\b", lower): + return True + return any( + phrase in lower + for phrase in _FRONTMATTER_VISUAL_VETO + ) + + _BODY_MENTION_VERBS = ( "shows", "illustrates", @@ -105,6 +127,48 @@ def _extract_figure_namespace(text: str) -> str: return "main" +_FIGURE_MARKER_PATTERN = re.compile( + r"(?PSupplementary\s+Figure|Supplementary\s+Fig\.?|" + r"Extended\s+Data\s+Figure|Extended\s+Data\s+Fig\.?|" + r"Figure|Fig\.?)\s*" + r"(?PS\.?\s*)?" + r"(?P\d+(?:\.\d+)?)", + re.I, +) + + +def _extract_figure_marker(text: str) -> dict: + m = _FIGURE_MARKER_PATTERN.search(text) + if not m: + return { + "namespace": "main", + "number": None, + "raw_prefix": "", + "has_s_prefix": False, + "marker_text": "", + } + lower = text.lower() + has_s = bool(m.group("s_prefix")) + if has_s or "supplementary" in lower or "supporting" in lower or "additional file" in lower or "appendix" in lower: + namespace = "supplementary" + elif "extended data" in lower or "extended figure" in lower: + namespace = "extended_data" + else: + namespace = "main" + number_raw = m.group("number") + try: + number = int(float(number_raw)) + except ValueError: + number = None + return { + "namespace": namespace, + "number": number, + "raw_prefix": m.group("prefix"), + "has_s_prefix": has_s, + "marker_text": m.group(0), + } + + def _validate_page_local_caption_grammar( hypotheses: list[dict], legends: list[dict], @@ -2618,6 +2682,232 @@ def _resolve_figure_id_collisions(figure_inventory: dict) -> None: _collision_seen[_fig_id] = 0 +def _coerce_int_figure_number(value) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + if isinstance(value, str) and value.strip().isdigit(): + return int(value.strip()) + return None + + +def _resolve_legend_bbox( + matched_item: dict, + structured_blocks: list[dict], + inventory: dict, +) -> list[float] | None: + legend_bbox = matched_item.get("legend_bbox") + if legend_bbox: + return legend_bbox + legend_block_id = matched_item.get("legend_block_id") + if legend_block_id: + legend_page = matched_item.get("legend_page") + for block in structured_blocks: + if block.get("block_id") == legend_block_id and block.get("page") == legend_page: + return block.get("bbox") + for entry in inventory.get("figure_legends", []): + if entry.get("block_id") == legend_block_id: + return entry.get("bbox") + return None + + +def _infer_missing_main_figure_numbers( + inventory: dict, + structured_blocks: list[dict], +) -> dict: + matched = inventory.get("matched_figures", []) + known_set: set[int] = set() + for item in matched: + num = _coerce_int_figure_number(item.get("figure_number")) + if num is None: + continue + marker = _extract_figure_marker(str(item.get("text", ""))) + if marker["namespace"] != "main" or marker["has_s_prefix"]: + continue + if num in known_set: + inventory["figure_number_inference"] = { + "status": "skipped", + "method": "leading_gap", + "reason": "duplicate_known_main_numbers", + "eligible_unknown_count": 0, + "known_main_numbers": [], + "inferred_figure_number": None, + } + return inventory + known_set.add(num) + + eligible: list[tuple[dict, list[float]]] = [] + for item in matched: + num = _coerce_int_figure_number(item.get("figure_number")) + if num is not None: + continue + marker = _extract_figure_marker(str(item.get("text", ""))) + if marker["namespace"] != "main" or marker["has_s_prefix"]: + continue + legend_block_id = item.get("legend_block_id") + if not legend_block_id or not isinstance(legend_block_id, str) or not legend_block_id.strip(): + continue + asset_block_ids = item.get("asset_block_ids", []) + if not isinstance(asset_block_ids, list) or not asset_block_ids: + continue + settlement_type = item.get("settlement_type", "") + if settlement_type not in {"same_page", "group_sequential", "cross_page_forward", + "cross_page_backward", "composite_parent"}: + continue + item_text = str(item.get("text", "")) + legend_text = "" + for entry in inventory.get("figure_legends", []): + if str(entry.get("block_id", "")) == str(legend_block_id): + legend_text = str(entry.get("text", "")) + break + combined = item_text + " " + legend_text + if _has_frontmatter_visual_veto(combined): + continue + legend_bbox = _resolve_legend_bbox(item, structured_blocks, inventory) + if legend_bbox is None: + continue + eligible.append((item, legend_bbox)) + + if not eligible: + inventory["figure_number_inference"] = { + "status": "skipped", + "method": "leading_gap", + "reason": "no_eligible_unknowns", + "eligible_unknown_count": 0, + "known_main_numbers": sorted(known_set), + "inferred_figure_number": None, + } + return inventory + + if not known_set or min(known_set) != 2: + inventory["figure_number_inference"] = { + "status": "skipped", + "method": "leading_gap", + "reason": "known_min_not_2", + "eligible_unknown_count": len(eligible), + "known_main_numbers": sorted(known_set), + "inferred_figure_number": None, + } + return inventory + + if len(eligible) != 1: + inventory["figure_number_inference"] = { + "status": "skipped", + "method": "leading_gap", + "reason": "multiple_eligible_unknowns", + "eligible_unknown_count": len(eligible), + "known_main_numbers": sorted(known_set), + "inferred_figure_number": None, + } + return inventory + + unknown_item, unknown_bbox = eligible[0] + first_known = None + for item in matched: + num = _coerce_int_figure_number(item.get("figure_number")) + if num == min(known_set): + first_known = item + break + if first_known is None: + inventory["figure_number_inference"] = { + "status": "skipped", + "method": "leading_gap", + "reason": "first_known_not_found", + "eligible_unknown_count": len(eligible), + "known_main_numbers": sorted(known_set), + "inferred_figure_number": None, + } + return inventory + + def _fig_order_key(_item, _bbox): + return ( + min(_item.get("asset_pages") or [_item.get("page", 1)]), + _item.get("legend_page") or _item.get("page", 1), + _bbox[1], + _bbox[0], + ) + + first_known_bbox = _resolve_legend_bbox(first_known, structured_blocks, inventory) + if first_known_bbox is None: + inventory["figure_number_inference"] = { + "status": "skipped", + "method": "leading_gap", + "reason": "first_known_bbox_unresolvable", + "eligible_unknown_count": len(eligible), + "known_main_numbers": sorted(known_set), + "inferred_figure_number": None, + } + return inventory + + unknown_key = _fig_order_key(unknown_item, unknown_bbox) + first_known_key = _fig_order_key(first_known, first_known_bbox) + if unknown_key >= first_known_key: + inventory["figure_number_inference"] = { + "status": "skipped", + "method": "leading_gap", + "reason": "unknown_not_before_first_known", + "eligible_unknown_count": len(eligible), + "known_main_numbers": sorted(known_set), + "inferred_figure_number": None, + } + return inventory + + all_items: list[dict] = list(matched) + inventory.get("held_figures", []) + inventory.get("ambiguous_figures", []) + for noise in all_items: + if noise is unknown_item or noise is first_known: + continue + noise_bbox = _resolve_legend_bbox(noise, structured_blocks, inventory) + if noise_bbox is None: + inventory["figure_number_inference"] = { + "status": "skipped", + "method": "leading_gap", + "reason": "intervening_unknown_unorderable", + "eligible_unknown_count": len(eligible), + "known_main_numbers": sorted(known_set), + "inferred_figure_number": None, + } + return inventory + noise_key = _fig_order_key(noise, noise_bbox) + if unknown_key < noise_key < first_known_key: + inventory["figure_number_inference"] = { + "status": "skipped", + "method": "leading_gap", + "reason": "intervening_items_between", + "eligible_unknown_count": len(eligible), + "known_main_numbers": sorted(known_set), + "inferred_figure_number": None, + } + return inventory + + unknown_item["figure_number"] = 1 + unknown_item["figure_id"] = _format_figure_id("main", 1) + unknown_item["figure_namespace"] = "main" + unknown_item["number_inference"] = { + "status": "accepted", + "method": "leading_gap", + "inferred_number": 1, + "known_numbers": sorted(known_set), + } + for legend in inventory.get("figure_legends", []): + if str(legend.get("block_id", "")) == str(unknown_item.get("legend_block_id", "")): + legend["inferred_figure_number"] = 1 + legend["figure_number_source"] = "sequence_gap_inference" + break + + inventory["figure_number_inference"] = { + "status": "accepted", + "method": "leading_gap", + "reason": "accepted", + "eligible_unknown_count": len(eligible), + "known_main_numbers": sorted(known_set), + "inferred_figure_number": 1, + } + return inventory + + def build_figure_inventory(structured_blocks: list[dict], page_width: float = 1200) -> dict[str, Any]: legends: list[dict] = [] held_figures: list[dict] = [] @@ -4271,6 +4561,8 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 inventory = _promote_sequence_matches(inventory, structured_blocks) + inventory = _infer_missing_main_figure_numbers(inventory, structured_blocks) + inventory["figure_legend_completeness"] = compute_figure_legend_completeness( structured_blocks, inventory, diff --git a/tests/audit/test_consistency.py b/tests/audit/test_consistency.py index 992b4756..c45de951 100644 --- a/tests/audit/test_consistency.py +++ b/tests/audit/test_consistency.py @@ -66,6 +66,10 @@ FORMAL_NOTE_OPTIONAL_KEYS: set[str] = { "collection_group", "type", "pdf_link", + "aliases", + "ocr_time", + "ocr_redo", + "citation_key", } OCR_VALID_STATUSES = {"pending", "processing", "done", "failed"} @@ -193,10 +197,11 @@ class TestStatusJsonConsistency: # Basic shape check on the inner data payload assert_json_shape( data, - {"vault", "system_dir", "resources_dir", "total_papers"}, - {"version", "formal_notes", "exports", "domains", "bases", "path_errors", - "env_configured", "ocr", "lifecycle_level_counts", - "health_aggregate", "maturity_distribution"}, + {"vault", "system_dir", "resources_dir", "total_papers"}, + {"version", "formal_notes", "exports", "domains", "bases", "path_errors", + "env_configured", "ocr", "lifecycle_level_counts", + "health_aggregate", "maturity_distribution", + "structured_ocr_health", "ocr_version_state"}, ) def test_status_json_counts_match_expected(self, golden_vault): diff --git a/tests/test_ocr_figures.py b/tests/test_ocr_figures.py index a08c2580..5637bb5c 100644 --- a/tests/test_ocr_figures.py +++ b/tests/test_ocr_figures.py @@ -5462,3 +5462,302 @@ class TestResolveFigureIdCollisions: result = self._run(figs) assert self._ids(result) == ["figure_001", "figure_s001"] + +# --- Tests for _infer_missing_main_figure_numbers --- + + +def test_infer_figure1_leading_gap() -> None: + from paperforge.worker.ocr_figures import _infer_missing_main_figure_numbers + + matched_figures = [ + {"figure_number": None, "text": "Figure 1.", "legend_block_id": "leg1", + "asset_block_ids": ["asset1"], "settlement_type": "same_page", + "page": 3, "legend_page": 3, "asset_pages": [3]}, + {"figure_number": 2, "text": "Figure 2.", "legend_block_id": "leg2", + "legend_bbox": [200, 100, 500, 200], "asset_block_ids": ["asset2"], + "settlement_type": "same_page", "page": 5, "legend_page": 5, "asset_pages": [5]}, + {"figure_number": 3, "text": "Figure 3.", "legend_block_id": "leg3", + "legend_bbox": [200, 100, 500, 200], "asset_block_ids": ["asset3"], + "settlement_type": "same_page", "page": 7, "legend_page": 7, "asset_pages": [7]}, + {"figure_number": 4, "text": "Figure 4.", "legend_block_id": "leg4", + "legend_bbox": [200, 100, 500, 200], "asset_block_ids": ["asset4"], + "settlement_type": "same_page", "page": 9, "legend_page": 9, "asset_pages": [9]}, + {"figure_number": 5, "text": "Figure 5.", "legend_block_id": "leg5", + "legend_bbox": [200, 100, 500, 200], "asset_block_ids": ["asset5"], + "settlement_type": "same_page", "page": 11, "legend_page": 11, "asset_pages": [11]}, + {"figure_number": 6, "text": "Figure 6.", "legend_block_id": "leg6", + "legend_bbox": [200, 100, 500, 200], "asset_block_ids": ["asset6"], + "settlement_type": "same_page", "page": 13, "legend_page": 13, "asset_pages": [13]}, + ] + figure_legends = [ + {"block_id": "leg1", "bbox": [100, 100, 500, 200], "text": "Figure 1. Test caption"}, + ] + inventory = { + "matched_figures": matched_figures, + "figure_legends": figure_legends, + "held_figures": [], + "ambiguous_figures": [], + } + result = _infer_missing_main_figure_numbers(inventory, []) + inf = result["figure_number_inference"] + assert inf["status"] == "accepted" + assert inf["reason"] == "accepted" + assert result["matched_figures"][0]["figure_number"] == 1 + assert result["matched_figures"][0]["figure_id"] == "figure_001" + assert result["matched_figures"][0]["figure_namespace"] == "main" + assert figure_legends[0]["inferred_figure_number"] == 1 + assert figure_legends[0]["figure_number_source"] == "sequence_gap_inference" + + +def test_infer_frontmatter_veto() -> None: + from paperforge.worker.ocr_figures import _infer_missing_main_figure_numbers + + matched_figures = [ + {"figure_number": None, "text": "Graphical Abstract", "legend_block_id": "leg_veto", + "asset_block_ids": ["asset_veto"], "settlement_type": "same_page", + "page": 1, "legend_page": 1, "asset_pages": [1], "legend_bbox": [0, 0, 100, 100]}, + {"figure_number": None, "text": "Figure 1.", "legend_block_id": "leg1", + "asset_block_ids": ["asset1"], "settlement_type": "same_page", + "page": 3, "legend_page": 3, "asset_pages": [3]}, + {"figure_number": 2, "text": "Figure 2.", "legend_block_id": "leg2", + "legend_bbox": [200, 100, 500, 200], "asset_block_ids": ["asset2"], + "settlement_type": "same_page", "page": 5, "legend_page": 5, "asset_pages": [5]}, + {"figure_number": 3, "text": "Figure 3.", "legend_block_id": "leg3", + "legend_bbox": [200, 100, 500, 200], "asset_block_ids": ["asset3"], + "settlement_type": "same_page", "page": 7, "legend_page": 7, "asset_pages": [7]}, + {"figure_number": 4, "text": "Figure 4.", "legend_block_id": "leg4", + "legend_bbox": [200, 100, 500, 200], "asset_block_ids": ["asset4"], + "settlement_type": "same_page", "page": 9, "legend_page": 9, "asset_pages": [9]}, + ] + figure_legends = [ + {"block_id": "leg1", "bbox": [100, 100, 500, 200], "text": "Figure 1. Test caption"}, + ] + inventory = { + "matched_figures": matched_figures, + "figure_legends": figure_legends, + "held_figures": [], + "ambiguous_figures": [], + } + result = _infer_missing_main_figure_numbers(inventory, []) + inf = result["figure_number_inference"] + assert inf["status"] == "accepted" + assert inf["reason"] == "accepted" + assert result["matched_figures"][1]["figure_number"] == 1 + assert result["matched_figures"][1]["figure_id"] == "figure_001" + assert result["matched_figures"][0]["figure_number"] is None + assert "number_inference" not in result["matched_figures"][0] + + +def test_infer_main_supplementary_isolation() -> None: + from paperforge.worker.ocr_figures import _infer_missing_main_figure_numbers + + matched_figures = [ + {"figure_number": None, "text": "Figure 1.", "legend_block_id": "leg1", + "asset_block_ids": ["asset1"], "settlement_type": "same_page", + "page": 3, "legend_page": 3, "asset_pages": [3]}, + {"figure_number": 1, "text": "Figure S1.", "legend_block_id": "leg_s1", + "legend_bbox": [200, 100, 500, 200], "asset_block_ids": ["asset_s1"], + "settlement_type": "same_page", "page": 7, "legend_page": 7, "asset_pages": [7]}, + {"figure_number": 2, "text": "Figure 2.", "legend_block_id": "leg2", + "legend_bbox": [200, 100, 500, 200], "asset_block_ids": ["asset2"], + "settlement_type": "same_page", "page": 5, "legend_page": 5, "asset_pages": [5]}, + {"figure_number": 3, "text": "Figure 3.", "legend_block_id": "leg3", + "legend_bbox": [200, 100, 500, 200], "asset_block_ids": ["asset3"], + "settlement_type": "same_page", "page": 9, "legend_page": 9, "asset_pages": [9]}, + ] + figure_legends = [ + {"block_id": "leg1", "bbox": [100, 100, 500, 200], "text": "Figure 1. Test caption"}, + ] + inventory = { + "matched_figures": matched_figures, + "figure_legends": figure_legends, + "held_figures": [], + "ambiguous_figures": [], + } + result = _infer_missing_main_figure_numbers(inventory, []) + inf = result["figure_number_inference"] + assert inf["status"] == "accepted" + assert inf["reason"] == "accepted" + assert result["matched_figures"][0]["figure_number"] == 1 + # S1 unchanged with its original number, not in main known set + assert result["matched_figures"][1]["figure_number"] == 1 + assert inf["known_main_numbers"] == [2, 3] + + +def test_infer_no_eligible_unknowns() -> None: + from paperforge.worker.ocr_figures import _infer_missing_main_figure_numbers + + matched_figures = [ + {"figure_number": 1, "text": "Figure 1.", "legend_block_id": "leg1", + "legend_bbox": [100, 100, 500, 200], "asset_block_ids": ["asset1"], + "settlement_type": "same_page", "page": 1}, + {"figure_number": 2, "text": "Figure 2.", "legend_block_id": "leg2", + "legend_bbox": [100, 100, 500, 200], "asset_block_ids": ["asset2"], + "settlement_type": "same_page", "page": 3}, + {"figure_number": 3, "text": "Figure 3.", "legend_block_id": "leg3", + "legend_bbox": [100, 100, 500, 200], "asset_block_ids": ["asset3"], + "settlement_type": "same_page", "page": 5}, + ] + inventory = { + "matched_figures": matched_figures, + "figure_legends": [], + "held_figures": [], + "ambiguous_figures": [], + } + result = _infer_missing_main_figure_numbers(inventory, []) + inf = result["figure_number_inference"] + assert inf["status"] == "skipped" + assert inf["reason"] == "no_eligible_unknowns" + + +def test_infer_known_min_not_2() -> None: + from paperforge.worker.ocr_figures import _infer_missing_main_figure_numbers + + matched_figures = [ + {"figure_number": None, "text": "Figure 1.", "legend_block_id": "leg1", + "asset_block_ids": ["asset1"], "settlement_type": "same_page", + "page": 1, "legend_page": 1, "asset_pages": [1]}, + {"figure_number": 1, "text": "Figure 1.", "legend_block_id": "leg1_known", + "legend_bbox": [100, 100, 500, 200], "asset_block_ids": ["asset1_known"], + "settlement_type": "same_page", "page": 3, "legend_page": 3, "asset_pages": [3]}, + {"figure_number": 3, "text": "Figure 3.", "legend_block_id": "leg3", + "legend_bbox": [100, 100, 500, 200], "asset_block_ids": ["asset3"], + "settlement_type": "same_page", "page": 5, "legend_page": 5, "asset_pages": [5]}, + {"figure_number": 4, "text": "Figure 4.", "legend_block_id": "leg4", + "legend_bbox": [100, 100, 500, 200], "asset_block_ids": ["asset4"], + "settlement_type": "same_page", "page": 7, "legend_page": 7, "asset_pages": [7]}, + ] + figure_legends = [ + {"block_id": "leg1", "bbox": [100, 100, 500, 200], "text": "Figure 1. Test caption"}, + ] + inventory = { + "matched_figures": matched_figures, + "figure_legends": figure_legends, + "held_figures": [], + "ambiguous_figures": [], + } + result = _infer_missing_main_figure_numbers(inventory, []) + inf = result["figure_number_inference"] + assert inf["status"] == "skipped" + assert inf["reason"] == "known_min_not_2" + + +def test_infer_multiple_eligible_unknowns() -> None: + from paperforge.worker.ocr_figures import _infer_missing_main_figure_numbers + + matched_figures = [ + {"figure_number": None, "text": "Figure 1.", "legend_block_id": "leg1", + "asset_block_ids": ["asset1"], "settlement_type": "same_page", + "page": 1, "legend_page": 1, "asset_pages": [1]}, + {"figure_number": None, "text": "Some figure", "legend_block_id": "leg2", + "asset_block_ids": ["asset2"], "settlement_type": "same_page", + "page": 2, "legend_page": 2, "asset_pages": [2]}, + {"figure_number": 2, "text": "Figure 2.", "legend_block_id": "leg2_known", + "legend_bbox": [100, 100, 500, 200], "asset_block_ids": ["asset2_known"], + "settlement_type": "same_page", "page": 3, "legend_page": 3, "asset_pages": [3]}, + {"figure_number": 3, "text": "Figure 3.", "legend_block_id": "leg3", + "legend_bbox": [100, 100, 500, 200], "asset_block_ids": ["asset3"], + "settlement_type": "same_page", "page": 5, "legend_page": 5, "asset_pages": [5]}, + ] + figure_legends = [ + {"block_id": "leg1", "bbox": [100, 100, 500, 200], "text": "Figure 1. Test caption"}, + {"block_id": "leg2", "bbox": [200, 100, 500, 200], "text": "Some caption"}, + ] + inventory = { + "matched_figures": matched_figures, + "figure_legends": figure_legends, + "held_figures": [], + "ambiguous_figures": [], + } + result = _infer_missing_main_figure_numbers(inventory, []) + inf = result["figure_number_inference"] + assert inf["status"] == "skipped" + assert inf["reason"] == "multiple_eligible_unknowns" + + +def test_infer_missing_legend_bbox() -> None: + from paperforge.worker.ocr_figures import _infer_missing_main_figure_numbers + + matched_figures = [ + {"figure_number": None, "text": "Figure 1.", "legend_block_id": "leg1", + "asset_block_ids": ["asset1"], "settlement_type": "same_page", + "page": 3, "legend_page": 3, "asset_pages": [3]}, + {"figure_number": 2, "text": "Figure 2.", "legend_block_id": "leg2", + "legend_bbox": [100, 100, 500, 200], "asset_block_ids": ["asset2"], + "settlement_type": "same_page", "page": 5, "legend_page": 5, "asset_pages": [5]}, + {"figure_number": 3, "text": "Figure 3.", "legend_block_id": "leg3", + "legend_bbox": [100, 100, 500, 200], "asset_block_ids": ["asset3"], + "settlement_type": "same_page", "page": 7, "legend_page": 7, "asset_pages": [7]}, + ] + inventory = { + "matched_figures": matched_figures, + "figure_legends": [], + "held_figures": [], + "ambiguous_figures": [], + } + result = _infer_missing_main_figure_numbers(inventory, []) + inf = result["figure_number_inference"] + assert inf["status"] == "skipped" + assert inf["reason"] == "no_eligible_unknowns" + + +def test_infer_unknown_not_before_first_known() -> None: + from paperforge.worker.ocr_figures import _infer_missing_main_figure_numbers + + matched_figures = [ + {"figure_number": 2, "text": "Figure 2.", "legend_block_id": "leg2", + "legend_bbox": [100, 100, 500, 200], "asset_block_ids": ["asset2"], + "settlement_type": "same_page", "page": 3, "legend_page": 3, "asset_pages": [3]}, + {"figure_number": None, "text": "Figure 1.", "legend_block_id": "leg1", + "asset_block_ids": ["asset1"], "settlement_type": "same_page", + "page": 5, "legend_page": 5, "asset_pages": [5]}, + {"figure_number": 3, "text": "Figure 3.", "legend_block_id": "leg3", + "legend_bbox": [100, 100, 500, 200], "asset_block_ids": ["asset3"], + "settlement_type": "same_page", "page": 7, "legend_page": 7, "asset_pages": [7]}, + ] + figure_legends = [ + {"block_id": "leg1", "bbox": [100, 100, 500, 200], "text": "Figure 1. Test caption"}, + ] + inventory = { + "matched_figures": matched_figures, + "figure_legends": figure_legends, + "held_figures": [], + "ambiguous_figures": [], + } + result = _infer_missing_main_figure_numbers(inventory, []) + inf = result["figure_number_inference"] + assert inf["status"] == "skipped" + assert inf["reason"] == "unknown_not_before_first_known" + + +def test_infer_duplicate_known_numbers_skips() -> None: + from paperforge.worker.ocr_figures import _infer_missing_main_figure_numbers + + matched_figures = [ + {"figure_number": None, "text": "Figure 1.", "legend_block_id": "leg1", + "asset_block_ids": ["asset1"], "settlement_type": "same_page", + "page": 1, "legend_page": 1, "asset_pages": [1]}, + {"figure_number": 2, "text": "Figure 2.", "legend_block_id": "leg2", + "legend_bbox": [100, 100, 500, 200], "asset_block_ids": ["asset2"], + "settlement_type": "same_page", "page": 3, "legend_page": 3, "asset_pages": [3]}, + {"figure_number": 2, "text": "Figure 2.", "legend_block_id": "leg2b", + "legend_bbox": [100, 100, 500, 200], "asset_block_ids": ["asset2b"], + "settlement_type": "same_page", "page": 4, "legend_page": 4, "asset_pages": [4]}, + {"figure_number": 3, "text": "Figure 3.", "legend_block_id": "leg3", + "legend_bbox": [100, 100, 500, 200], "asset_block_ids": ["asset3"], + "settlement_type": "same_page", "page": 5, "legend_page": 5, "asset_pages": [5]}, + ] + figure_legends = [ + {"block_id": "leg1", "bbox": [100, 100, 500, 200], "text": "Figure 1. Test caption"}, + ] + inventory = { + "matched_figures": matched_figures, + "figure_legends": figure_legends, + "held_figures": [], + "ambiguous_figures": [], + } + result = _infer_missing_main_figure_numbers(inventory, []) + inf = result["figure_number_inference"] + assert inf["status"] == "skipped" + assert inf["reason"] == "duplicate_known_main_numbers" +