fix(ocr): merge Box N / Key insights content boxes into structured_insert callout

Add _merge_box_content() post-processing to normalize_document_structure:
detects structured_insert blocks whose text starts with Box N or Key
insights, then consumes following body-like blocks as box content.
Consumed blocks get role=noise + render_default=False to suppress
double-rendering. Column-crossing boxes (common in multi-column
layouts) are handled by removing the x-overlap heuristic.
This commit is contained in:
Research Assistant 2026-06-14 22:16:57 +08:00
parent b0fe93f0df
commit 80b91f73fd
2 changed files with 58 additions and 2 deletions

View file

@ -2531,6 +2531,62 @@ def _check_reference_completeness(blocks: list[dict]) -> dict:
}
def _merge_box_content(blocks: list[dict]) -> None:
"""Merge content boxes (Box 1, Key insights, etc.) into their structured_insert callout.
When a ``structured_insert`` (or ``paragraph_title``) block has text starting
with ``Box N`` or ``Key insights``, the following body-like blocks within
the same column are owned by that box. Their text is merged into
``_container_text`` and they are marked ``render_default=False`` so the
render function renders the entire box as one callout.
"""
_BOX_HEADING_PATTERN = re.compile(r"^(?:Box\s+\d+|Key\s+insights)\b", re.IGNORECASE)
_BOX_BODY_ROLES = frozenset({"backmatter_body", "body_paragraph", "unknown_structural"})
_BOX_STOP_ROLES = frozenset(
{"section_heading", "subsection_heading", "sub_subsection_heading", "reference_item",
"reference_heading", "backmatter_heading", "figure_caption_candidate", "table_caption_candidate"}
)
for idx, block in enumerate(blocks):
text = (block.get("text") or "").strip()
if not _BOX_HEADING_PATTERN.match(text):
continue
role = block.get("role", "")
if role not in {"structured_insert", "paragraph_title", "subsection_heading"}:
continue
# Found a box heading — collect following body content
parts = [text]
consumed: list[int] = []
for j in range(idx + 1, len(blocks)):
next_block = blocks[j]
next_role = next_block.get("role", "")
next_text = (next_block.get("text") or "").strip()
if not next_text:
continue
actual_role = next_role if next_role not in {"unknown_structural", "noise"} else next_block.get("seed_role", "")
if actual_role in _BOX_STOP_ROLES:
break
if next_role not in _BOX_BODY_ROLES:
break
parts.append(next_text)
consumed.append(j)
if len(parts) <= 1:
continue
container = "\n".join(parts)
block["_container_text"] = container
for cj in consumed:
blocks[cj]["role"] = "noise"
blocks[cj]["render_default"] = False
if not blocks[cj].get("_decision_log"):
blocks[cj]["_decision_log"] = []
blocks[cj]["_decision_log"].append({
"stage": "box_content_merge",
"reason": f"consumed by box heading '{text[:60]}'"
})
def _exclude_frontmatter_side_from_body_flow(blocks: list[dict]) -> None:
for block in blocks:
effective_role = block.get("role")
@ -4526,6 +4582,8 @@ def normalize_document_structure(
ref_completeness = _check_reference_completeness(blocks)
_merge_box_content(blocks)
doc_structure = DocumentStructure(
body_end_page=tail_spread.body_end_page if tail_spread else None,
backmatter_start=PagePosition(page=tail_spread.backmatter_start, y=0.0)

View file

@ -1168,8 +1168,6 @@ def render_fulltext_markdown(
last_structured_insert_bbox = None
if role == "structured_insert":
if not block.get("render_default", True):
continue
pass # render as callout below
elif not block.get("render_default", True):
continue