diff --git a/paperforge/worker/ocr_decisions.py b/paperforge/worker/ocr_decisions.py index cd747e6f..d2dd01a2 100644 --- a/paperforge/worker/ocr_decisions.py +++ b/paperforge/worker/ocr_decisions.py @@ -5,6 +5,16 @@ from pathlib import Path from typing import Any +DECISION_STATUSES = { + "ACCEPT", + "HOLD", + "REJECT", + "SOURCE_ONLY", + "OBSERVATION_ONLY", + "CONFLICT", +} + + def record_decision( block: dict, *, diff --git a/paperforge/worker/ocr_health.py b/paperforge/worker/ocr_health.py index b4127b01..7a34bd06 100644 --- a/paperforge/worker/ocr_health.py +++ b/paperforge/worker/ocr_health.py @@ -4,6 +4,51 @@ from pathlib import Path from typing import Any +def _doc_attr(doc_structure: Any, name: str, default=None): + if doc_structure is None: + return default + if isinstance(doc_structure, dict): + return doc_structure.get(name, default) + return getattr(doc_structure, name, default) + + +def _decision_status(value: Any) -> str: + if isinstance(value, dict): + status = value.get("status") + if isinstance(status, str) and status: + return status + if isinstance(value, str) and value: + return value + return "OBSERVATION_ONLY" + + +def _build_anchor_summary(doc_structure: Any) -> dict[str, str]: + if isinstance(doc_structure, dict) and "anchor_summary" in doc_structure: + return dict(doc_structure.get("anchor_summary") or {}) + return { + "body_family_anchor": _decision_status(_doc_attr(doc_structure, "body_family_anchor", {})), + "reference_family_anchor": _decision_status(_doc_attr(doc_structure, "reference_family_anchor", {})), + } + + +def _build_zone_summary(doc_structure: Any) -> dict[str, str]: + if isinstance(doc_structure, dict) and "zone_summary" in doc_structure: + return dict(doc_structure.get("zone_summary") or {}) + region_bus = _doc_attr(doc_structure, "region_bus", {}) or {} + return {zone_name: _decision_status(zone) for zone_name, zone in region_bus.items()} + + +def _build_held_counts(doc_structure: Any, *, held_figure_count: int, held_table_count: int) -> dict[str, int]: + if isinstance(doc_structure, dict) and "held_counts" in doc_structure: + held = dict(doc_structure.get("held_counts") or {}) + held.setdefault("matches", held_figure_count + held_table_count) + return held + return { + "families": 0, + "matches": held_figure_count + held_table_count, + } + + def build_ocr_health( *, page_count: int, @@ -26,9 +71,11 @@ def build_ocr_health( table_caption_count = sum(1 for b in structured_blocks if b.get("role") == "table_caption") figure_asset_count = len(figure_inventory.get("matched_figures", [])) + held_figure_count = len(figure_inventory.get("held_figures", [])) unmatched_legends = len(figure_inventory.get("unmatched_legends", [])) unmatched_figure_assets = len(figure_inventory.get("unmatched_assets", [])) tables = table_inventory.get("tables", []) + held_table_count = len(table_inventory.get("held_tables", [])) table_asset_count = sum(1 for t in tables if t.get("has_asset")) empty_tables = sum(1 for t in tables if not t.get("has_asset")) formal_table_count = len([t for t in tables if not t.get("is_continuation")]) @@ -80,12 +127,11 @@ def build_ocr_health( layout = _run_layout_audit( structured_blocks, body_spine=spine, - page_layouts=doc_structure.page_layouts if doc_structure else None, + page_layouts=_doc_attr(doc_structure, "page_layouts", None), ) tail_score = {} - if doc_structure is not None and hasattr(doc_structure, "tail_boundary_score"): - tail_score = doc_structure.tail_boundary_score or {} + tail_score = _doc_attr(doc_structure, "tail_boundary_score", {}) or {} # Collect decision log summaries from paperforge.worker.ocr_decisions import collect_decisions, summarize_decisions @@ -110,6 +156,14 @@ def build_ocr_health( + candidate_forced_count ) + anchor_summary = _build_anchor_summary(doc_structure) + zone_summary = _build_zone_summary(doc_structure) + held_counts = _build_held_counts( + doc_structure, + held_figure_count=held_figure_count, + held_table_count=held_table_count, + ) + report = { "page_count": page_count, "blocks_count": raw_blocks_count, @@ -118,8 +172,10 @@ def build_ocr_health( "references_found": references_found, "figure_caption_count": figure_caption_count, "figure_asset_count": figure_asset_count, + "held_figure_count": held_figure_count, "table_caption_count": table_caption_count, "table_asset_count": table_asset_count, + "held_table_count": held_table_count, "formal_table_count": formal_table_count, "table_segment_count": table_segment_count, "ambiguous_table_match_count": ambiguous_table_match_count, @@ -145,8 +201,12 @@ def build_ocr_health( "ambiguous_match_count": ambiguous_figure_match_count + ambiguous_table_match_count, "ambiguous_figure_match_count": ambiguous_figure_match_count, "unresolved_cluster_count": unresolved_cluster_count, + "held_match_count": held_figure_count + held_table_count, "low_tail_boundary_confidence": low_tail_boundary_confidence, "hard_rule_decision_count": hard_rule_decision_count, + "anchor_summary": anchor_summary, + "zone_summary": zone_summary, + "held_counts": held_counts, } report.update(decision_summary) @@ -187,8 +247,9 @@ def build_ocr_health( report["table_match_confidence_distribution"] = _score_distribution(table_scores) layout_confidences = [] - if doc_structure is not None and getattr(doc_structure, "page_layouts", None): - layout_confidences = [float(p.confidence) for p in doc_structure.page_layouts.values()] + page_layouts = _doc_attr(doc_structure, "page_layouts", None) + if page_layouts: + layout_confidences = [float(p.confidence) for p in page_layouts.values()] report["layout_confidence_distribution"] = _score_distribution(layout_confidences) low_confidence_figures = [s for s in fig_scores if s < 0.4] diff --git a/tests/test_ocr_decisions.py b/tests/test_ocr_decisions.py index 4cc24e41..a68530c3 100644 --- a/tests/test_ocr_decisions.py +++ b/tests/test_ocr_decisions.py @@ -67,3 +67,16 @@ def test_write_structured_blocks_jsonl_strips_decision_log(tmp_path) -> None: with open(path, "r", encoding="utf-8") as f: data = json.loads(f.readline()) assert "_decision_log" not in data + + +def test_decision_status_vocabulary_is_exposed() -> None: + from paperforge.worker.ocr_decisions import DECISION_STATUSES + + assert DECISION_STATUSES == { + "ACCEPT", + "HOLD", + "REJECT", + "SOURCE_ONLY", + "OBSERVATION_ONLY", + "CONFLICT", + } diff --git a/tests/test_ocr_health.py b/tests/test_ocr_health.py index 7ca8655c..f70acd6c 100644 --- a/tests/test_ocr_health.py +++ b/tests/test_ocr_health.py @@ -382,3 +382,50 @@ def test_ocr_health_hard_rule_decision_count_uses_real_signals() -> None: ) 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