fix: PR1 deterministic OCR residual fixes

- Clamp backfill words to original bbox (Issue 3)
- Allow validation-first bare tables to fall through (Issue 1A)
- Materialize split table caption continuations (Issue 1B)
- Add short-form OCR health profile (Issue 4)
This commit is contained in:
LLLin000 2026-07-01 21:49:45 +08:00
parent b32ddd3b29
commit 05e75e1b29
7 changed files with 382 additions and 51 deletions

View file

@ -106,6 +106,10 @@ def _build_held_counts(doc_structure: Any, *, held_figure_count: int, held_table
}
def _health_profile(page_count: int) -> str:
return "short_form" if int(page_count or 0) <= 2 else "standard"
def build_ocr_health(
*,
page_count: int,
@ -172,17 +176,20 @@ def build_ocr_health(
formal_legend_accounted = int(completeness.get("accounted_for", 0))
formal_legend_gaps = int(completeness.get("gap_count", 0))
# Only flag structural blockers as health-color issues.
# Caption/media gaps, empty tables, legend gaps, layout anomalies
# are kept in the report as data but do NOT drive the overall color —
# they are display-quality concerns, not rebuild triggers.
# Health profile determines which structural gates apply
profile = _health_profile(page_count)
waived_gates: list[str] = []
structural_blockers = 0
if not abstract_found:
structural_blockers += 1
if not references_found:
structural_blockers += 1
if section_heading_count < 2:
structural_blockers += 1
if profile == "short_form":
waived_gates.extend(["abstract_found", "section_heading_count"])
else:
if not abstract_found:
structural_blockers += 1
if not references_found:
structural_blockers += 1
if section_heading_count < 2:
structural_blockers += 1
# Count soft issues for the report (informational only)
soft_issues = 0
@ -202,8 +209,9 @@ def build_ocr_health(
else:
overall = "red"
# needs_rebuild: only when structural core is broken
needs_rebuild = structural_blockers >= 2 or (not abstract_found and not references_found)
# needs_rebuild: only when structural core is broken.
# Short-form papers are never red for missing structure alone.
needs_rebuild = structural_blockers >= 2 or (not abstract_found and not references_found and profile != "short_form")
# Compute structural health signals
from paperforge.worker.ocr_document import _compute_span_coverage, _detect_body_spine, _run_layout_audit
@ -289,6 +297,8 @@ def build_ocr_health(
"needs_rebuild": needs_rebuild,
"structural_blockers": structural_blockers,
"soft_issues": soft_issues,
"health_profile": profile,
"waived_gates": waived_gates,
"span_coverage": span,
"span_coverage_quality": span.get("coverage_quality", "weak"),
"degraded_mode_active": span.get("degraded_mode_active", True),
@ -355,18 +365,17 @@ def build_ocr_health(
if role_gate_summary:
report["role_gate_summary"] = role_gate_summary
degraded_reasons = []
hard_degraded_reasons = []
warning_reasons = []
if span.get("coverage_quality", "weak") == "weak":
degraded_reasons.append(f"weak span coverage ({span.get('coverage_ratio', 0):.0%})")
warning_reasons.append(f"weak span coverage ({span.get('coverage_ratio', 0):.0%})")
if spine.get("_meta", {}).get("quality", "weak") == "weak":
degraded_reasons.append("weak body spine")
warning_reasons.append("weak body spine")
if layout.get("error_count", 0) > 0:
degraded_reasons.append(f"layout audit errors ({layout.get('error_count', 0)} errors)")
warning_reasons.append(f"layout audit errors ({layout.get('error_count', 0)} errors)")
if role_gate_summary.get("status") == "degraded":
degraded_reasons.append("OCR structural role gate degraded")
report["degraded_reasons"] = degraded_reasons
warning_reasons.append("OCR structural role gate degraded")
def _score_distribution(scores: list[float]) -> dict:
return {
@ -402,16 +411,26 @@ def build_ocr_health(
low_confidence_figures = [s for s in fig_scores if s < 0.4]
if low_confidence_figures:
degraded_reasons.append(f"low figure caption confidence ({len(low_confidence_figures)} figures)")
warning_reasons.append(f"low figure caption confidence ({len(low_confidence_figures)} figures)")
low_confidence_tables = [s for s in table_scores if s < 0.4]
if low_confidence_tables:
degraded_reasons.append(f"low table match confidence ({len(low_confidence_tables)} tables)")
warning_reasons.append(f"low table match confidence ({len(low_confidence_tables)} tables)")
if formal_legend_gaps > 0:
degraded_reasons.append(
warning_reasons.append(
f"figure legend completeness gap ({formal_legend_gaps} numbered legends unaccounted for)"
)
if reader_payload is not None and reader_payload.get("reader_coverage", {}).get("gap_count", 0) > 0:
degraded_reasons.append("reader_figure_coverage_gap")
warning_reasons.append("reader_figure_coverage_gap")
if rendered_gap["rendered_text_gap_count"] > 0:
hard_degraded_reasons.append(
f"rendered text gaps ({rendered_gap['rendered_text_gap_count']} segments missing from fulltext)"
)
report["hard_degraded_reasons"] = hard_degraded_reasons
report["warning_reasons"] = warning_reasons
report["degraded_reasons"] = hard_degraded_reasons
if profile == "short_form":
report["degraded_reason"] = "short_paper_format"
return report

View file

@ -14,6 +14,14 @@ from typing import Any
import fitz
import os as _os
from contextlib import redirect_stderr as _redirect_stderr
def _fitz_quiet_open(path: str):
with _redirect_stderr(_os.devnull):
return fitz.open(path)
def _map_ocr_bbox_to_pdf_rect(
bbox: list[float],
@ -277,6 +285,21 @@ def _bbox_overlap_ratio(container_rect: Any, block_rect: Any) -> float:
return (overlap.width * overlap.height) / area_b
def _word_center_inside_rect(word_bbox, block_rect) -> bool:
x0, y0, x1, y1 = word_bbox
cx = (x0 + x1) / 2.0
cy = (y0 + y1) / 2.0
return block_rect.x0 <= cx <= block_rect.x1 and block_rect.y0 <= cy <= block_rect.y1
def _word_belongs_to_block(word_bbox, block_rect) -> bool:
word_rect = fitz.Rect(*word_bbox)
return (
_word_center_inside_rect(word_bbox, block_rect)
or _bbox_overlap_ratio(word_rect, block_rect) >= 0.30
)
def _has_container_text(
rect: Any,
*,
@ -401,15 +424,12 @@ def backfill_span_metadata_from_pdf(
"""
if not pdf_path:
return raw_blocks
pdf_path = Path(pdf_path)
if not pdf_path.exists():
return raw_blocks
import fitz
try:
doc = fitz.open(str(pdf_path))
doc = _fitz_quiet_open(str(pdf_path))
except Exception:
return raw_blocks
@ -608,10 +628,8 @@ def backfill_missing_text_from_pdf(
if not pdf_path.exists():
return raw_blocks
import fitz
try:
doc = fitz.open(str(pdf_path))
doc = _fitz_quiet_open(str(pdf_path))
except Exception:
return raw_blocks
@ -696,6 +714,11 @@ def backfill_missing_text_from_pdf(
except Exception:
words = []
words = [
w for w in words
if len(w) >= 4 and _word_belongs_to_block(tuple(w[:4]), rect)
]
text = _words_to_text(words)
if text:

View file

@ -60,6 +60,54 @@ def _is_weak_explicit_table_caption(block: dict) -> bool:
return _is_insufficient_table_caption_evidence(block)
def _find_table_caption_continuation(caption: dict, structured_blocks: list[dict]) -> dict | None:
"""Find the block immediately after a weak-truncated table caption that
looks like a continuation (stolen as figure_caption or similar)."""
caption_page = caption.get("page", 0)
caption_bbox = caption.get("bbox") or [0, 0, 0, 0]
next_idx = None
for i, block in enumerate(structured_blocks):
if block.get("block_id") == caption.get("block_id"):
next_idx = i + 1
break
if next_idx is None or next_idx >= len(structured_blocks):
return None
next_block = structured_blocks[next_idx]
bbox = next_block.get("bbox") or [0, 0, 0, 0]
# Trigger rules from spec
if bbox[1] - caption_bbox[3] > 25: # y-gap > 25px
return None
next_text = str(next_block.get("text", "") or "")
# reject if starts with Fig/Figure/Scheme/Plate
if next_text.lower().startswith(("fig", "figure", "scheme", "plate")):
return None
# x-overlap check
x_overlap = max(0, min(caption_bbox[2], bbox[2]) - max(caption_bbox[0], bbox[0]))
if x_overlap < min(caption_bbox[2] - caption_bbox[0], bbox[2] - bbox[0]) * 0.5:
left_edge_delta = abs(caption_bbox[0] - bbox[0])
if left_edge_delta >= 40:
return None
return next_block
def _materialize_table_caption(caption: dict, continuation: dict | None) -> tuple[dict, list[str]]:
"""Merge continuation text into caption, return (merged_caption, consumed_ids)."""
consumed_ids = [caption.get("block_id", "")]
if continuation is None:
return caption, consumed_ids
merged = dict(caption)
cont_text = str(continuation.get("text", "") or "")
merged["text"] = (merged.get("text", "") or "") + " " + cont_text
consumed_ids.append(continuation.get("block_id", ""))
return merged, consumed_ids
def _has_strong_spatial_evidence_for_bare_table(caption: dict, top_candidate_score: dict | None) -> bool:
if top_candidate_score is None:
return False
@ -198,25 +246,26 @@ def build_table_inventory(structured_blocks: list[dict]) -> dict[str, Any]:
]
if not same_page_assets:
held_tables.append(
{
"table_id": f"held_table_{len(held_tables) + 1:03d}",
"caption_block_id": caption.get("block_id", ""),
"page": caption_page,
"caption_text": caption_text,
"table_number": table_num,
"formal_table_number": formal_table_number,
"hold_reason": "insufficient_caption_evidence",
"zone": caption.get("zone", ""),
"style_family": caption.get("style_family", ""),
"marker_signature": caption.get("marker_signature", {}),
}
)
continue
{
"table_id": f"held_table_{len(held_tables) + 1:03d}",
"caption_block_id": caption.get("block_id", ""),
"page": caption_page,
"caption_text": caption_text,
"table_number": table_num,
"formal_table_number": formal_table_number,
"hold_reason": "insufficient_caption_evidence",
"zone": caption.get("zone", ""),
"style_family": caption.get("style_family", ""),
"marker_signature": caption.get("marker_signature", {}),
}
)
continue
# same-page asset exists → fall through into weak-explicit matching
continuation_ids: list[str] = []
if is_weak_truncated:
# Truncated table label with same-page assets: proceed
# to weak-explicit matching (same as figure's truncated
# legend now matching to nearby assets).
pass
continuation = _find_table_caption_continuation(caption, structured_blocks)
materialized_caption, continuation_ids = _materialize_table_caption(caption, continuation)
caption_text = materialized_caption.get("text", "")
all_candidates: list[tuple[int, dict, dict]] = []
if is_weak_explicit_caption:
@ -431,6 +480,7 @@ def build_table_inventory(structured_blocks: list[dict]) -> dict[str, Any]:
if matched_asset:
consumed_block_ids.append(matched_asset.get("block_id", ""))
consumed_block_ids.extend(note_block_ids)
consumed_block_ids.extend(continuation_ids)
consumed_block_ids = [bid for bid in consumed_block_ids if bid]
bridge_block_ids = [
str(block.get("block_id") or "")

View file

@ -294,7 +294,7 @@ def test_ocr_health_includes_confidence_distributions() -> None:
assert tbl_dist["low"] == 1
def test_health_report_includes_degraded_reasons() -> None:
def test_health_report_includes_warning_or_degraded_reasons() -> None:
from paperforge.worker.ocr_health import build_ocr_health
report = build_ocr_health(
@ -306,8 +306,56 @@ def test_health_report_includes_degraded_reasons() -> None:
)
assert "degraded_reasons" in report
assert len(report["degraded_reasons"]) > 0
assert "warning_reasons" in report
assert len(report["degraded_reasons"]) + len(report["warning_reasons"]) > 0
def test_health_report_separates_warning_reasons_from_hard_degradation() -> None:
from paperforge.worker.ocr_health import build_ocr_health
report = build_ocr_health(
page_count=1,
raw_blocks_count=1,
structured_blocks=[
{"role": "section_heading", "text": "1 Intro", "span_metadata": {"size": 12}},
{"role": "section_heading", "text": "2 Methods", "span_metadata": {"size": 12}},
{"role": "abstract_body", "text": "Abstract text", "span_metadata": {"size": 10}},
{"role": "reference_item", "text": "[1] Ref", "span_metadata": {"size": 10}},
{"role": "body_paragraph", "text": "Body text", "span_metadata": {"size": 10}},
],
figure_inventory={
"matched_figures": [{"caption_score": {"score": 0.2}, "matched_assets": [{}]}],
"unmatched_legends": [],
"unmatched_assets": [],
"figure_legend_completeness": {"total": 1, "accounted": 1, "gap_count": 0, "ratio": 1.0},
},
table_inventory={"tables": [], "unmatched_captions": [], "unmatched_assets": []},
)
assert any("low figure caption confidence" in r for r in report["warning_reasons"])
assert all("low figure caption confidence" not in r for r in report["degraded_reasons"])
def test_health_report_marks_rendered_text_gaps_as_hard_degradation() -> None:
from paperforge.worker.ocr_health import build_ocr_health
report = build_ocr_health(
page_count=1,
raw_blocks_count=1,
structured_blocks=[
{
"role": "body_paragraph",
"text": "Short OCR body",
"span_metadata": {"size": 10},
"pdf_region_text": "Important missing body segment from PDF",
}
],
figure_inventory={},
table_inventory={},
rendered_markdown="Short OCR body",
)
assert any("rendered text gaps" in r for r in report["degraded_reasons"])
assert report["rendered_text_gap_count"] > 0
def test_ocr_health_reports_layout_confidence_distribution() -> None:
from paperforge.worker.ocr_document import DocumentStructure, PageLayoutProfile
@ -623,7 +671,7 @@ def test_ocr_health_role_gate_degraded_forces_overall_red() -> None:
"reference_item_outside_reference_zone_count": 0,
}
health = build_ocr_health(
page_count=1,
page_count=3,
raw_blocks_count=0,
structured_blocks=[],
figure_inventory={},
@ -728,3 +776,41 @@ def test_health_emits_additive_v2_fields_without_replacing_overall() -> None:
assert "heading_total_v2" in health
assert "matched_figure_count_v2" in health
assert "issue_breakdown_v2" in health
def test_short_form_health_does_not_go_red_for_missing_abstract_headings_and_refs():
from paperforge.worker.ocr_health import build_ocr_health
report = build_ocr_health(
page_count=2,
raw_blocks_count=2,
structured_blocks=[
{"page": 1, "role": "body_paragraph", "text": "Short letter text."},
{"page": 2, "role": "body_paragraph", "text": "More short letter text."},
],
figure_inventory={"matched_figures": [], "held_figures": [], "unmatched_legends": [], "unmatched_assets": [], "figure_legend_completeness": {}},
table_inventory={"tables": [], "held_tables": [], "unmatched_captions": [], "unmatched_assets": []},
)
assert report["health_profile"] == "short_form"
assert report["overall"] in {"green", "yellow"}
assert report["needs_rebuild"] is False
assert "abstract_found" in report["waived_gates"]
assert report["degraded_reason"] == "short_paper_format"
def test_standard_profile_still_flags_missing_structure():
from paperforge.worker.ocr_health import build_ocr_health
report = build_ocr_health(
page_count=5,
raw_blocks_count=2,
structured_blocks=[
{"page": 1, "role": "body_paragraph", "text": "Body."},
{"page": 5, "role": "body_paragraph", "text": "Tail."},
],
figure_inventory={"matched_figures": [], "held_figures": [], "unmatched_legends": [], "unmatched_assets": [], "figure_legend_completeness": {}},
table_inventory={"tables": [], "held_tables": [], "unmatched_captions": [], "unmatched_assets": []},
)
assert report["health_profile"] == "standard"
assert report["overall"] == "red"

View file

@ -134,3 +134,60 @@ def test_local_reference_pdf_fallback_repairs_only_local_candidate_text() -> Non
repaired = repair_reference_entry_from_pdf_text(block_run, page_text)
assert "N Engl J Med" in repaired
assert repaired.startswith("Brown T, Green S.")
def test_backfill_expanded_clip_filters_words_to_original_bbox(monkeypatch, tmp_path):
from paperforge.worker import ocr_pdf_spans
import fitz
class FakePage:
rect = fitz.Rect(0, 0, 1000, 1000)
def get_text(self, kind, clip=None):
if kind == "words":
return [
(100, 100, 120, 110, "inside", 0, 0, 0),
(100, 111, 120, 121, "neighbor", 0, 0, 1),
]
if kind == "text":
return "inside neighbor"
return []
class FakeDoc:
def __len__(self): return 1
def __getitem__(self, idx): return FakePage()
def close(self): pass
monkeypatch.setattr(ocr_pdf_spans, "_fitz_quiet_open", lambda path: FakeDoc())
monkeypatch.setattr(
ocr_pdf_spans,
"_map_ocr_bbox_to_pdf_rect",
lambda bbox, pw, ph, page: fitz.Rect(100, 100, 120, 110),
)
pdf_path = tmp_path / "fake.pdf"
pdf_path.write_bytes(b"%PDF-1.7\n")
blocks = [{
"page": 1,
"bbox": [100, 100, 120, 110],
"page_width": 1000,
"page_height": 1000,
"raw_label": "text",
"text": "",
"block_content": "",
"span_metadata": [{"font": "Body"}],
}]
ocr_pdf_spans.backfill_missing_text_from_pdf(blocks, pdf_path)
assert blocks[0]["text"] == "inside"
assert blocks[0]["_ocr_raw_status"] == "missing_text_recovered"
def test_backfill_keeps_slightly_misaligned_words_by_center_or_overlap():
from paperforge.worker.ocr_pdf_spans import _word_belongs_to_block
import fitz
block_rect = fitz.Rect(100, 100, 120, 110)
assert _word_belongs_to_block((99, 100, 109, 110), block_rect) is True
assert _word_belongs_to_block((121, 111, 131, 121), block_rect) is False

View file

@ -504,3 +504,36 @@ def test_consumed_stringified_int_note_id_matches_block_int_id() -> None:
)
assert "Table note with int block_id." not in md
def test_materialized_table_caption_continuation_is_skipped_by_render_when_consumed():
from paperforge.worker.ocr_render import render_fulltext_markdown
blocks = [
{"page": 1, "block_id": "cap1", "role": "table_caption", "text": "Table 2", "bbox": [100, 100, 220, 120]},
{"page": 1, "block_id": "cap2", "role": "figure_caption", "text": "Structural parameters of nanocomposites obtained from the d", "bbox": [100, 121, 500, 145]},
]
table_inventory = {
"tables": [
{
"page": 1,
"caption_block_id": "cap1",
"caption_text": "Table 2 Structural parameters of nanocomposites obtained from the d",
"consumed_block_ids": ["cap1", "cap2"],
"has_asset": False,
"match_status": "unmatched_caption",
}
]
}
md = render_fulltext_markdown(
structured_blocks=blocks,
resolved_metadata={},
figure_inventory={"matched_figures": []},
table_inventory=table_inventory,
page_count=1,
document_structure=None,
reader_payload={"reader_figures": [], "consumed_caption_block_ids": []},
)
assert "Structural parameters of nanocomposites" not in md

View file

@ -1230,3 +1230,66 @@ def test_weak_match_caption_has_empty_consumed_block_ids() -> None:
assert table["has_asset"] is False
assert table["match_status"] in {"unmatched_caption", "ambiguous"}
assert "p5_caption" not in table.get("consumed_block_ids", [])
def test_validation_first_bare_table_with_same_page_asset_falls_through_to_weak_explicit_matching():
from paperforge.worker.ocr_tables import build_table_inventory
structured_blocks = [
{
"block_id": "cap",
"page": 1,
"role": "body_paragraph",
"raw_label": "text",
"text": "Table 1",
"bbox": [100, 100, 300, 120],
"marker_signature": {"type": "table_number"},
"zone": "display_zone",
"style_family": "table_caption_like",
},
{
"block_id": "asset",
"page": 1,
"role": "media_asset",
"raw_label": "table_image",
"text": "",
"bbox": [100, 130, 500, 400],
},
]
inventory = build_table_inventory(structured_blocks)
assert inventory["official_table_count"] == 1
assert inventory["tables"][0]["asset_block_id"] == "asset"
assert inventory["tables"][0]["caption_block_id"] == "cap"
def test_split_table_caption_materializes_continuation_stolen_as_figure_caption():
from paperforge.worker.ocr_tables import build_table_inventory
structured_blocks = [
{"block_id": "cap1", "page": 1, "role": "table_caption", "text": "Table 2", "bbox": [100, 100, 220, 120]},
{"block_id": "cap2", "page": 1, "role": "figure_caption", "text": "Structural parameters of nanocomposites obtained from the d", "bbox": [100, 121, 500, 145]},
{"block_id": "asset", "page": 1, "role": "media_asset", "raw_label": "table_image", "text": "", "bbox": [100, 150, 500, 400]},
]
inventory = build_table_inventory(structured_blocks)
assert inventory["official_table_count"] == 1
assert inventory["tables"][0]["caption_text"].startswith("Table 2 Structural parameters")
assert "cap2" in inventory["tables"][0]["consumed_block_ids"]
def test_split_table_caption_does_not_steal_real_figure_caption():
from paperforge.worker.ocr_tables import build_table_inventory
structured_blocks = [
{"block_id": "cap1", "page": 1, "role": "table_caption", "text": "Table 2", "bbox": [100, 100, 220, 120]},
{"block_id": "figcap", "page": 1, "role": "figure_caption", "text": "Figure 3. Histology results.", "bbox": [600, 100, 900, 125]},
{"block_id": "asset", "page": 1, "role": "media_asset", "raw_label": "table_image", "text": "", "bbox": [100, 150, 500, 400]},
]
inventory = build_table_inventory(structured_blocks)
assert inventory["tables"][0]["caption_text"] == "Table 2"
assert "figcap" not in inventory["tables"][0]["consumed_block_ids"]