feat: add explicit OCR no-span degraded mode

This commit is contained in:
Research Assistant 2026-06-07 16:13:09 +08:00
parent a46fa2a297
commit ef73b3f0a1
4 changed files with 228 additions and 10 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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

View file

@ -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"