mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
feat: add explicit OCR no-span degraded mode
This commit is contained in:
parent
a46fa2a297
commit
ef73b3f0a1
4 changed files with 228 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue