From ef73b3f0a19866df5e1442fd9f721fa7e1d66ea5 Mon Sep 17 00:00:00 2001 From: Research Assistant Date: Sun, 7 Jun 2026 16:13:09 +0800 Subject: [PATCH] feat: add explicit OCR no-span degraded mode --- paperforge/worker/ocr_document.py | 44 ++++++-- paperforge/worker/ocr_health.py | 7 ++ tests/test_ocr_document.py | 166 +++++++++++++++++++++++++++++- tests/test_ocr_health.py | 21 ++++ 4 files changed, 228 insertions(+), 10 deletions(-) diff --git a/paperforge/worker/ocr_document.py b/paperforge/worker/ocr_document.py index 7297dbee..c95a03fc 100644 --- a/paperforge/worker/ocr_document.py +++ b/paperforge/worker/ocr_document.py @@ -80,24 +80,46 @@ class DocumentStructure: page_layouts: dict[int, PageLayoutProfile] | None = None tail_reading_order: list[dict] | None = None reference_zones: list[dict] | None = None + span_coverage: dict | None = None def _compute_span_coverage(blocks: list[dict]) -> dict: + """Compute span metadata coverage across the document. + + Returns: + coverage_ratio: float 0-1 + coverage_quality: str "strong" (>=0.7), "moderate" (>=0.3), "weak" (<0.3) + blocks_with_span: int + blocks_without_span: int + degraded_mode_active: bool + """ total = len(blocks) if total == 0: - return {"total_blocks": 0, "blocks_with_span": 0, "coverage": 1.0, "coverage_quality": "strong"} + return { + "coverage_ratio": 0.0, + "coverage_quality": "weak", + "blocks_with_span": 0, + "blocks_without_span": 0, + "degraded_mode_active": True, + } with_span = sum(1 for b in blocks if b.get("span_metadata")) - coverage = with_span / total + ratio = with_span / total - if coverage >= 0.7: + if ratio >= 0.7: quality = "strong" - elif coverage >= 0.3: + elif ratio >= 0.3: quality = "moderate" else: quality = "weak" - return {"total_blocks": total, "blocks_with_span": with_span, "coverage": coverage, "coverage_quality": quality} + return { + "coverage_ratio": ratio, + "coverage_quality": quality, + "blocks_with_span": with_span, + "blocks_without_span": total - with_span, + "degraded_mode_active": quality == "weak", + } def _cluster_page_columns(page_blocks: list[dict], page_width: float) -> list[float]: @@ -948,7 +970,7 @@ def rescue_roles_with_document_context( family_profiles = build_family_profiles(blocks) span_coverage = _compute_span_coverage(blocks) - coverage_quality = span_coverage["coverage_quality"] + degraded_mode_active = span_coverage["degraded_mode_active"] body_end_page = document_structure.body_end_page refs_start = document_structure.references_start @@ -1021,8 +1043,6 @@ def rescue_roles_with_document_context( # --- Rule 2: body_paragraph → reference_item (refs section + ref font) role = block.get("role", "") if role == "body_paragraph" and block.get("role_confidence", 1.0) < 0.7: - if coverage_quality == "weak": - continue bp = extract_block_span_profile(block) if bp: ref_rescued = False @@ -1071,13 +1091,16 @@ def rescue_roles_with_document_context( if not ref_rescued: ref_fam = role_profiles.get("reference_item", {}) if ref_fam: + threshold = 0.7 if degraded_mode_active else 0.5 match = compare_against_role_family(bp, ref_fam) - if match["size_compatible"] and match["match_score"] > 0.5: + if match["size_compatible"] and match["match_score"] > threshold: block["role"] = "reference_item" block["role_confidence"] = min(block.get("role_confidence", 0.5) + 0.2, 1.0) block.setdefault("evidence", []).append("rescue: body_paragraph → reference_item") # --- Rule 3: weak heading with body font → body_paragraph + if degraded_mode_active: + continue if ( role in {"section_heading", "subsection_heading", "sub_subsection_heading"} and block.get("role_confidence", 1.0) < 0.6 @@ -2279,6 +2302,9 @@ def normalize_document_structure(blocks: list[dict]) -> tuple[DocumentStructure, blocks = _promote_tail_body_candidates(blocks, doc_structure, header_band=header_band, footer_band=footer_band) blocks = _assign_tail_spread_ownership(blocks, doc_structure) + # Compute span coverage for degraded mode detection + doc_structure.span_coverage = _compute_span_coverage(blocks) + # Detect non-body insert clusters on early pages (relative to body length) body_spine = _detect_body_spine(blocks, doc_structure) pw = max((b.get("page_width", 0) or 0) for b in blocks) or 1200 diff --git a/paperforge/worker/ocr_health.py b/paperforge/worker/ocr_health.py index 1d09989f..77e272cd 100644 --- a/paperforge/worker/ocr_health.py +++ b/paperforge/worker/ocr_health.py @@ -80,6 +80,13 @@ def build_ocr_health( } +def build_span_coverage_health(blocks: list[dict]) -> dict: + """Compute span metadata coverage health from structured blocks.""" + from paperforge.worker.ocr_document import _compute_span_coverage + + return _compute_span_coverage(blocks) + + def build_spine_health(body_spine: dict) -> dict: quality = body_spine.get("_meta", {}).get("quality", "weak") return { diff --git a/tests/test_ocr_document.py b/tests/test_ocr_document.py index 16f716eb..722e9c87 100644 --- a/tests/test_ocr_document.py +++ b/tests/test_ocr_document.py @@ -2147,7 +2147,8 @@ def test_span_coverage_weak_when_no_metadata() -> None: blocks = [{"span_metadata": None}, {"span_metadata": {}}] result = _compute_span_coverage(blocks) - assert result.get("coverage", 1.0) < 0.5 + assert result.get("coverage_ratio", 1.0) < 0.5 + assert result.get("degraded_mode_active") is True def test_no_span_rescue_more_conservative() -> None: @@ -2409,3 +2410,166 @@ def test_structured_insert_cluster_detected() -> None: indices = _detect_structured_insert_clusters(blocks) # At least 3 of the 4 blocks should form clusters (two pairs) assert len(indices) >= 3 + + +# --------------------------------------------------------------------------- +# Degraded mode / span coverage tests (Task 6) +# --------------------------------------------------------------------------- + + +def test_degraded_mode_weaker_heading_promotion() -> None: + """Blocks without span_metadata, with weak heading-to-body rescue → + weaker promotion (higher threshold) — heading keeps its role in degraded mode.""" + from paperforge.worker.ocr_document import ( + DocumentStructure, + rescue_roles_with_document_context, + ) + + # Most blocks lack span_metadata → ratio = 3/10 = 0.3 → moderate, not weak. + # Need 3/11 = 0.27 < 0.3 → weak coverage → degraded mode. + blocks = [ + { + "role": "body_paragraph", + "role_confidence": 0.7, + "page": 1, + "text": "Normal body paragraph with font profile data.", + "span_metadata": {"size": 10, "flags": "normal"}, + }, + { + "role": "body_paragraph", + "role_confidence": 0.7, + "page": 2, + "text": "Another body paragraph to strengthen the body profile.", + "span_metadata": {"size": 10, "flags": "normal"}, + }, + { + "role": "section_heading", + "role_confidence": 0.5, + "page": 2, + "text": "Methods", + "span_metadata": {"size": 10, "flags": "normal"}, + }, + ] + # Add 9 blocks without span_metadata to push ratio to 3/12 = 0.25 < 0.3 + for i in range(9): + blocks.append({"role": "body_paragraph", "role_confidence": 0.5, "page": 2, "text": f"No span block {i}"}) + + role_profiles = { + "body_paragraph": { + "block_count": 11, + "mean_size": 10.0, + "max_size": 10.5, + "min_size": 9.5, + "dispersion": 0.05, + "quality": "strong", + "bold_ratio": 0.0, + "italic_ratio": 0.0, + "font_families": [], + }, + "section_heading": { + "block_count": 1, + "mean_size": 10.0, + "max_size": 10.5, + "min_size": 9.5, + "dispersion": 0.05, + "quality": "weak", + "bold_ratio": 0.0, + "italic_ratio": 0.0, + "font_families": [], + }, + } + + ds = DocumentStructure(body_end_page=2) + result = rescue_roles_with_document_context(blocks, role_profiles, ds) + + heading = next(b for b in result if b["text"] == "Methods") + assert heading["role"] == "section_heading", ( + f"Weak heading should keep its role in degraded mode, got {heading['role']}" + ) + + +def test_degraded_mode_weaker_non_body_insert() -> None: + """No-span blocks with moderate narrow+font signal → NOT promoted + to non_body_insert when spine weak.""" + from paperforge.worker.ocr_document import rescue_roles_with_document_context + + blocks = [ + # Body paragraphs with span_metadata to establish body_family + { + "role": "body_paragraph", + "page": 1, + "role_confidence": 0.8, + "text": "Normal body paragraph with enough text for profiling purposes.", + "span_metadata": {"size": 10, "flags": "normal"}, + }, + { + "role": "body_paragraph", + "page": 2, + "role_confidence": 0.8, + "text": "Another body paragraph to strengthen the body font profile.", + "span_metadata": {"size": 10, "flags": "normal"}, + }, + # non_body_insert block without span_metadata — cannot be compared + { + "role": "non_body_insert", + "page": 1, + "role_confidence": 0.5, + "_non_body_insert": True, + "bbox": [100, 400, 700, 440], + "page_width": 1200, + "text": "This block has no span metadata so rescue cannot run", + }, + ] + # Add 7 blocks without span to push coverage to 2/10 = 0.2 < 0.3 → degraded + for i in range(7): + blocks.append({"role": "body_paragraph", "role_confidence": 0.5, "page": 2, "text": f"No span {i}"}) + + role_profiles = { + "body_paragraph": { + "block_count": 9, + "mean_size": 10.0, + "max_size": 10.5, + "min_size": 9.5, + "dispersion": 0.05, + "quality": "strong", + "bold_ratio": 0.0, + "italic_ratio": 0.0, + "font_families": [], + }, + "non_body_insert": { + "block_count": 1, + "mean_size": 10.0, + "max_size": 10.5, + "min_size": 9.5, + "dispersion": 0.05, + "quality": "moderate", + "bold_ratio": 0.0, + "italic_ratio": 0.0, + "font_families": [], + }, + } + + result = rescue_roles_with_document_context(blocks, role_profiles) + # Without span_metadata, extract_block_span_profile returns None → + # the block cannot be rescued, should remain as non_body_insert + non_body = [b for b in result if b.get("_non_body_insert")] + assert len(non_body) == 1 + assert non_body[0]["role"] == "non_body_insert" + + +def test_span_coverage_strong_when_most_blocks_have_metadata() -> None: + """80%+ blocks have span_metadata → strong coverage.""" + from paperforge.worker.ocr_document import _compute_span_coverage + + blocks = [] + for i in range(8): + blocks.append({"span_metadata": {"size": 10, "flags": "normal"}}) + for i in range(2): + blocks.append({"span_metadata": None}) + + result = _compute_span_coverage(blocks) + assert result["coverage_quality"] == "strong" + assert result["coverage_ratio"] >= 0.7 + assert result["blocks_with_span"] == 8 + assert result["blocks_without_span"] == 2 + assert result["degraded_mode_active"] is False diff --git a/tests/test_ocr_health.py b/tests/test_ocr_health.py index 664fd42f..2b1e86b1 100644 --- a/tests/test_ocr_health.py +++ b/tests/test_ocr_health.py @@ -83,3 +83,24 @@ def test_build_spine_health() -> None: 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"