lllin000_PaperForge/paperforge/worker/ocr_roles.py
LLLin000 41e35239f2 fix: inline <table> HTML blocks incorrectly assigned to media_asset
Three-part fix:
1. ocr_roles.py: add inline <table> check before raw_label=table → media_asset
   fallback. Blocks starting with <table> now get table_html directly regardless
   of raw_label.
2. ocr_structural_gate.py: add table_html verifier — inline <table> HTML is
   self-identifying, accepted without structural verification.
3. ocr_document.py: remove table_html→table_html_candidate conversion step.
   This role was never handled by any downstream pipeline, causing blocks to
   be downgraded to unknown_structural.

Validated on AH6Q7DLC (worst case, 30 blocks): 29/30 now correctly table_html,
1 remaining is reference_item (bibliography table, different classification).
Full corpus data pending rebuild.

585 figure/table/role tests pass.
2026-07-03 01:46:46 +08:00

1748 lines
69 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import re
from dataclasses import dataclass, field
@dataclass
class RoleAssignment:
role: str
confidence: float = 0.5
evidence: list[str] = field(default_factory=list)
_HEADING_NUMBER_PATTERN = re.compile(
r"^\d+(?:\.\d+)*\.?\s+(?:[A-Z0-9])",
)
_FIGURE_PREFIX_PATTERN = re.compile(
r"^(?:Figure|Fig\.?|Supplementary\s+Figure|Supplementary\s+Fig\.?|"
r"Extended\s+Data\s+Figure|Extended\s+Data\s+Fig\.?)\s+\d+",
flags=re.IGNORECASE,
)
_TABLE_NUM_TOKEN = r"(?:\d+(?:\.\d+)?|[IVXLCDM]+)"
_TABLE_PREFIX_PATTERN = re.compile(
rf"^(?:Table|Supplementary\s+Table|Extended\s+Data\s+Table)\s*"
rf"(?:S\.?\s*)?({_TABLE_NUM_TOKEN})\b",
flags=re.IGNORECASE,
)
_PREPROOF_MARKER = re.compile(
r"^(?:journal\s+)?pre-?proof\b",
re.IGNORECASE,
)
_BACKMATTER_TITLE_DENY_LIST: set[str] = set()
_BACKMATTER_HEADINGS = {
"publisher's note",
"publisher\u2019s note",
}
_PAGE1_ARTICLE_TYPE_LABELS = frozenset(
{
"review",
"review article",
"research article",
"original article",
"case report",
"brief communication",
"rapid communication",
}
)
FRONTMATTER_NOISE = {
"open access",
"citation",
"keywords",
"edited by",
"reviewed by",
"correspondence",
"received",
"accepted",
"published",
"present address",
"these authors have contributed equally",
"informed consent",
"orcid",
}
_REFERENCE_PATTERN = re.compile(
r"^\s*(?:\(\d+\)\s|\d+\.\s|[A-Z][A-Za-z'\-]+\s+et al\.\s*\(\d{4}[a-z]?\)|\([A-Z][A-Za-z'\-]+\s+et al\.,\s*\d{4}[a-z]?\))",
)
_PAREN_REF_PREFIX = re.compile(r"^\s*\(\d+\)\s*")
_FRONTIERS_FIGURE_TITLE_PATTERN = re.compile(
r"^FIGURE\s+\d+[A-Za-z]?\s*\|\s+.+",
flags=re.IGNORECASE,
)
_PANEL_LABEL_PATTERN = re.compile(
r"^\(?[A-Za-z]\)?[\.:]?$",
)
_ROMAN_SECTION_PATTERN = re.compile(
r"^(?:[IVXLCDM]+)\.\s+[A-Z][A-Z0-9 ,;:\-/()]+$",
re.IGNORECASE,
)
_ALPHA_SUBSECTION_PATTERN = re.compile(
r"^[A-Z]\.\s+[A-Z][A-Za-z0-9 ,;:\-/()]+$",
)
_INITIAL_LASTNAME_PATTERN = re.compile(
r"^(?:[A-Z]\s*){1,3}[A-Z][A-Za-z'\u2019\-]+(?:\s+[A-Z][A-Za-z'\u2019\-]+)?$"
)
_FIGURE_DESCRIPTION_OPENING_PATTERN = re.compile(
r"^(?:This figure|The figure|This Fig\.?|The Fig\.?|"
r"Figure\s+\d+|Fig\.?\s+\d+)\b",
flags=re.IGNORECASE,
)
_REFERENCE_HEADING_PATTERN = re.compile(
r"^(?:"
r"references?"
r"|references?\s+and\s+(?:notes?|further\s+reading)"
r"|bibliography"
r"|cited\s+references?"
r")$",
re.IGNORECASE,
)
def _normalize_heading_text(text: str) -> str:
text = text.strip().rstrip(":")
text = re.sub(r"\.{2,}\s*\d+\s*$", "", text)
text = re.sub(r"\s+\d+\s*$", "", text)
return text.strip()
def _is_reference_heading_text(text: str) -> bool:
return bool(_REFERENCE_HEADING_PATTERN.fullmatch(_normalize_heading_text(text)))
def _looks_like_figure_description_opening(text: str) -> bool:
return bool(_FIGURE_DESCRIPTION_OPENING_PATTERN.match(text.strip()))
def _looks_like_initial_lastname_byline(text: str) -> bool:
"""Check if text looks like a list of author initials + lastnames.
Matches patterns like "J. Smith", "J C Buckland-Wright", "J. C. Smith".
Rejects ALL-CAPS phrases like "A RESEARCH VISION" (journal taglines, not names).
"""
compact = " ".join(text.split())
if not any(c.islower() for c in compact):
return False
return bool(_INITIAL_LASTNAME_PATTERN.fullmatch(compact))
_COMMON_SECTION_HEADINGS = {
"introduction",
"materials and methods",
"methods",
"results",
"results and discussion",
"discussion",
"conclusion",
"conclusions",
}
_BODY_ORDINAL_OPENINGS: frozenset[str] = frozenset({
"first", "second", "third", "fourth", "fifth",
"sixth", "seventh", "eighth", "ninth", "tenth",
"additionally", "historically", "metabolically", "clinically",
"conversely", "however", "interestingly", "surprisingly",
"importantly", "notably", "moreover", "furthermore",
"nevertheless", "nonetheless", "therefore", "thus",
"hence", "accordingly", "consequently",
})
# citation line like "Masante B, Gabetti S, Silva JC, Putame G... and Massai D (2025)"
_CITATION_LINE_PATTERN = re.compile(
r"^[A-Z][a-z]+\'?[a-z]* [A-Z](?:\.[, ]|[A-Z]\.?,|[,\s])",
)
# author list with superscript affiliation markers like "$^{1,2\dagger}$"
_AUTHOR_AFFILIATION_MARKER = re.compile(r"\$\s*\^\{")
def _has_heading_numbering(text: str) -> bool:
return bool(_HEADING_NUMBER_PATTERN.match(text.strip()))
def _heading_number_depth(text: str) -> int:
match = re.match(r"^(\d+(?:\.\d+)*\.?)\s+", text.strip())
if not match:
return 0
token = match.group(1).rstrip(".")
return len([part for part in token.split(".") if part])
def is_preproof_marker(text: str) -> bool:
return bool(_PREPROOF_MARKER.match(text.strip()))
def _has_figure_prefix(text: str) -> bool:
return bool(_FIGURE_PREFIX_PATTERN.match(text.strip()))
def _is_obviously_formal_figure_caption(text: str, block: dict, page_blocks: list[dict]) -> bool:
if not _has_figure_prefix(text):
return False
verb_patterns = ["shows", "illustrates", "depicts", "demonstrates", "presents", "summarizes"]
has_verb = any(v in text.lower() for v in verb_patterns)
sentence_markers = [" is ", " are ", " was ", " were "]
has_sentence = any(m in text.lower() for m in sentence_markers)
if has_verb and has_sentence:
return False
is_short = len(text) <= 80
near_media = _is_near_figure_media(block, page_blocks)
return is_short or near_media
def _is_near_figure_media(block: dict, page_blocks: list[dict], max_gap: int = 200) -> bool:
block_bbox = block.get("block_bbox", [0, 0, 0, 0])
if not block_bbox or len(block_bbox) < 4:
return False
bx1, by1, bx2 = block_bbox[0], block_bbox[1], block_bbox[2]
for other in page_blocks:
label = str(other.get("block_label") or other.get("raw_label") or "").strip()
if label not in {"image", "chart", "figure"}:
continue
ob = other.get("block_bbox", [0, 0, 0, 0])
if not ob or len(ob) < 4:
continue
ox1, oy1, ox2, oy2 = ob[:4]
h_overlap = bx1 < ox2 and ox1 < bx2
if not h_overlap:
continue
gap = by1 - oy2
if -max_gap * 0.3 <= gap <= max_gap:
return True
return False
def _looks_like_frontmatter_support_text(text: str) -> bool:
lower = text.lower().strip()
return (
"corresponding author" in lower
or lower.startswith("correspondence")
or "contributed equally" in lower
or "share first authorship" in lower
)
def _has_confirmatory_watermark_text(text: str) -> bool:
lower = text.lower()
return "downloaded from" in lower or "for personal use only" in lower
def _looks_like_margin_band_noise(block: dict, page_width: int, page_height: int) -> bool:
bbox = block.get("block_bbox") or block.get("bbox") or [0, 0, 0, 0]
if len(bbox) < 4 or page_width <= 0 or page_height <= 0:
return False
x0, y0, x1, y1 = bbox
width = x1 - x0
height = y1 - y0
if width <= 0 or height <= 0:
return False
at_left_edge = x0 <= page_width * 0.03 and x1 <= page_width * 0.12
at_right_edge = x1 >= page_width * 0.97 and x0 >= page_width * 0.88
edge_band = at_left_edge or at_right_edge
very_tall = height >= page_height * 0.30
very_narrow = width <= page_width * 0.12
text = str(block.get("text") or block.get("block_content") or "")
return edge_band and very_tall and (very_narrow or _has_confirmatory_watermark_text(text))
# ponytail: edge thresholds (3%/12%/88%/97%) and aspect ratios (30% tall, 12% narrow) are document-layout heuristics calibrated for common publisher watermarks on A4/Letter. Upgrade path: accept as config params when publisher-specific layout profiles are introduced.
def _looks_like_figure_inner_label(text: str, block: dict, page_blocks: list[dict]) -> bool:
# ponytail: 12-char max and 80px proximity are layout heuristics for single-word panel labels. Upgrade path: derive from page-median block width and font size when span_metadata is available.
t = text.strip()
if not t or len(t) > 12:
return False
if _has_figure_prefix(t):
return False
token_count = len(t.split())
if token_count > 2:
return False
has_alnum = any(ch.isalnum() for ch in t)
if not has_alnum:
return False
bb = block.get("block_bbox") or block.get("bbox") or [0, 0, 0, 0]
if len(bb) < 4:
return False
for other in page_blocks:
if other is block:
continue
if str(other.get("block_label") or other.get("raw_label") or "") not in {"image", "chart"}:
continue
obb = other.get("block_bbox") or other.get("bbox") or [0, 0, 0, 0]
if len(obb) < 4:
continue
# ponytail: identity comparison (other is block) assumes same dict objects, upgrade to block_id match if page_blocks is reconstructed
close_x = not (bb[2] < obb[0] - 80 or bb[0] > obb[2] + 80)
close_y = not (bb[3] < obb[1] - 80 or bb[1] > obb[3] + 80)
if close_x and close_y:
return True
return False
def _looks_like_edge_band_noise(block: dict, page_width: int, page_height: int) -> bool:
# ponytail: edge % thresholds (3%/10%/97%/90%) assume letter/A4 layout; upgrade to config if non-standard page sizes appear
# ponytail: 50% height and 3% width thresholds calibrated for publisher watermarks; upgrade to config when publisher-specific profiles are introduced
raw_label = str(block.get("block_label") or block.get("raw_label") or "")
if raw_label not in {"aside_text", "header_image"}:
return False
bbox = block.get("block_bbox") or block.get("bbox") or [0, 0, 0, 0]
if len(bbox) < 4 or page_width <= 0 or page_height <= 0:
return False
x0, y0, x1, y1 = bbox
width = x1 - x0
height = y1 - y0
if width <= 0 or height <= 0:
return False
at_left = x0 <= page_width * 0.03 and x1 <= page_width * 0.10
at_right = x1 >= page_width * 0.97 and x0 >= page_width * 0.90
if not (at_left or at_right):
return False
if height < page_height * 0.50:
return False
if width > page_width * 0.03:
return False
return True
def _is_textual_table(text: str) -> bool:
"""Check if a table-formatted block is really a textual list/box.
Returns True if the text:
- has no HTML table markup
- has bullet-like lines (*, -, \u2022, digits.)
- or has lines that are too varied to be a real table
"""
if text.lower().startswith("<table"):
return False
lines = [l.strip() for l in text.split("\n") if l.strip()]
if len(lines) == 0:
return False
bullet_count = sum(1 for l in lines if l.startswith(("\u2022", "-", "*")) or (l[0].isdigit() and ". " in l[:4]))
return bullet_count >= 2
def _has_table_prefix(text: str) -> bool:
return bool(_TABLE_PREFIX_PATTERN.match(text.strip()))
def _looks_like_reference(text: str) -> bool:
return bool(_REFERENCE_PATTERN.match(text.strip()))
def _is_backmatter_boundary_heading(block: dict, page_num: int, total_pages: int) -> bool:
text = str(block.get("block_content", "") or block.get("text", "") or "").strip()
if not text:
return False
# Relative tail position instead of absolute page gate
if total_pages > 0 and (page_num / total_pages) < 0.5:
return False
raw_label = str(block.get("block_label", "") or "").strip()
is_heading_label = raw_label == "paragraph_title"
# Span visual signal — boundary headings are typically bold 11pt+
span_meta = block.get("span_metadata", {}) or {}
if isinstance(span_meta, dict):
font_size = span_meta.get("size", 0) or 0
font_flags = (str(span_meta.get("flags", "") or "")).lower()
is_visually_heading = ("bold" in font_flags and font_size >= 11) or font_size >= 14
elif isinstance(span_meta, list):
sizes = [s.get("size", 0) or 0 for s in span_meta if s.get("size")]
flags = [s.get("flags", 0) for s in span_meta]
mean_size = sum(sizes) / len(sizes) if sizes else 0
is_bold = any(bool(f & 16) for f in flags if isinstance(f, int))
is_text_bold = any("bold" in (str(s.get("flags", "") or "")).lower() for s in span_meta)
is_visually_heading = ((is_bold or is_text_bold) and mean_size >= 11) or mean_size >= 14
else:
is_visually_heading = False
if not is_heading_label and not is_visually_heading:
return False
upper = text.upper()
has_container_words = (
"ADDITIONAL" in upper or "DECLARATION" in upper or "INFORMATION" in upper
)
if not is_visually_heading and not has_container_words:
return False
if len(text) <= 20:
return False
lower = text.lower()
if lower in ("references", "bibliography"):
return False
if lower in _BACKMATTER_HEADINGS:
return False
return lower not in _BACKMATTER_TITLE_DENY_LIST
def _looks_like_author_list(text: str) -> bool:
"""Check if text looks like a list of author names (not a title or body)."""
if not text:
return False
has_name_comma = bool(re.search(r"[A-Z][a-z]+,\s+[A-Z]", text))
has_and_name = bool(re.search(r"\band\b\s+[A-Z][a-z]+", text))
has_author_marker = bool(re.search(r"[\*†‡§¶#]", text))
has_two_name_pairs = bool(re.search(r"[A-Z][a-z]+\s+[A-Z][a-z]+[^,;]*[·•,;]\s*[A-Z][a-z]+", text))
has_many_name_pairs = len(re.findall(r"\b[A-Z][a-z]+\s+[A-Z][a-z]+\b", text)) >= 3
has_et_al = "et al" in text.lower()
return (has_name_comma or has_and_name or has_author_marker or has_two_name_pairs or has_many_name_pairs or has_et_al) and len(text) < 500
def _looks_like_affiliation(text: str) -> bool:
"""Check if text looks like an affiliation block.
Affiliation blocks typically contain institution names, addresses,
and superscript markers. Common academic words like ``science``,
``technology``, ``research``, ``medicine`` are NOT used because they
appear in every body paragraph.
"""
lower_txt = text.lower()
# Only genuinely affiliation-specific keywords
inst_keywords = [
"university",
"department",
"institute",
"college",
"school of",
"faculty",
"laboratory",
"lab",
"hospital",
"center",
"centre",
"academy",
"division",
]
kw_matches = sum(1 for kw in inst_keywords if kw in lower_txt)
has_inst = kw_matches >= 2 or (kw_matches >= 1 and len(text) < 80)
has_city_country = bool(
re.search(
r"(?:,\s*)(?:USA|UK|China|Germany|France|Japan|Italy|Canada|"
r"Australia|Brazil|India|Korea|Spain|Netherlands|Switzerland|"
r"Sweden|Norway|Denmark|Austria|Belgium|Finland|Poland|"
r"Russia|Mexico|Argentina|Singapore|Taiwan|Hong\s*Kong)",
text,
)
)
has_number_prefix = bool(re.match(r"^[\$\s]*\^?\d+\s*", text))
has_superscript_number = bool(re.search(r"\$?\^\d+\$?", text))
has_curly_superscript = bool(re.search(r"\$?\^\{", text))
return (
has_inst
or (has_city_country and has_number_prefix)
or has_superscript_number
or (has_curly_superscript and has_inst)
or (has_curly_superscript and has_city_country)
)
def _infer_heading_level(
text: str,
font_size: float = 0,
role_profiles: dict | None = None,
block: dict | None = None,
) -> str | None:
"""Infer heading level for unnumbered papers using text content heuristics
and optional role span profiles.
"""
if not text:
return "section_heading"
# Profile-based matching (preferred)
if role_profiles and block:
from paperforge.worker.ocr_profiles import compare_against_role_family, extract_block_span_profile
block_profile = extract_block_span_profile(block)
if block_profile:
for candidate_role in (
"section_heading",
"subsection_heading",
"sub_subsection_heading",
):
fam = role_profiles.get(candidate_role)
if fam and fam.get("quality") in ("strong", "moderate"):
match = compare_against_role_family(block_profile, fam)
if match["size_compatible"] and match["match_score"] > 0.6:
return candidate_role
# Visual signal: font_size + bold overrides text heuristics
ss = (block or {}).get("span_signature") or {}
fs = ss.get("font_size_median", 0) or ss.get("font_size", 0) or font_size
is_bold = ss.get("bold", False)
if fs > 0:
# Larger font (section_heading tier) → highest level
if fs >= 11:
return "section_heading"
# Same-as-body bold → intermediate level
if is_bold:
return "subsection_heading"
# Same-as-body regular → lowest level
return "sub_subsection_heading"
# Legacy text heuristics (fallback when no visual data)
upper_ratio = sum(1 for c in text if c.isupper()) / max(len(text), 1)
is_mostly_upper = upper_ratio > 0.7
sentence_verbs = {" is ", " are ", " was ", " were ", " have ", " has ", " been "}
has_sentence_verb = any(v in text.lower() for v in sentence_verbs)
word_count = len(text.split())
# ponytail: all-caps single-word headings (e.g. "MCUb", "EMRE") are
# sub-subsection headings, not top-level section headings
if is_mostly_upper and word_count >= 2 and not has_sentence_verb:
return "section_heading"
if word_count >= 2 and not has_sentence_verb:
return "subsection_heading"
if word_count == 1 and not has_sentence_verb:
return "sub_subsection_heading"
return "section_heading"
def _seems_like_authors(text: str) -> bool:
parts = [p.strip() for p in text.replace(" ", " ").split(",")]
if len(parts) >= 2:
name_count = sum(1 for p in parts if re.search(r"[A-Z][a-z]+\s+[A-Z]", p))
return name_count >= 2
return False
def _explicit_scholarly_heading_role(text: str) -> str | None:
stripped = text.strip().lstrip("*•·-–—")
lower = stripped.lower()
if lower in _COMMON_SECTION_HEADINGS:
return "section_heading"
if _ROMAN_SECTION_PATTERN.match(stripped):
return "section_heading"
if _ALPHA_SUBSECTION_PATTERN.match(stripped):
return "subsection_heading"
return None
def _resolve_late_family_context(block: dict, families: dict | None) -> tuple[dict, str, str, str, str]:
block_id = str(block.get("block_id") or "")
family_ctx = {}
if isinstance(families, dict):
family_ctx = families.get(block_id) or {}
block_zone = str(block.get("zone") or "")
block_style_family = str(block.get("style_family") or "")
block_style_family_authority = str(block.get("style_family_authority") or "")
zone = block_zone or str(family_ctx.get("zone") or "")
style_family = block_style_family or str(family_ctx.get("style_family") or "")
style_family_authority = block_style_family_authority or str(
family_ctx.get("style_family_authority") or family_ctx.get("authority") or ""
)
context_source = "block"
if (not block_zone or not block_style_family or not block_style_family_authority) and family_ctx:
context_source = "families"
return family_ctx, zone, style_family, style_family_authority, context_source
def _looks_like_late_figure_narrative_prose(text: str) -> bool:
if not text:
return False
sentence_count = text.count(". ") + text.count(".\n")
if sentence_count >= 2:
return True
lower = text.lower()
prose_markers = ["we ", "our ", "this study", "here we", "in this"]
if any(marker in lower for marker in prose_markers):
return True
if re.search(
r"\b(?:figure|fig\.?)\s+\d+[a-z]?\s+(?:shows|show|shown|demonstrates|demonstrate|illustrates|illustrate|depicts|presents|reveals|indicates|compares|summarizes)\b",
lower,
):
return True
return bool(re.search(r"\$?\^\{[^}]+\}\$?", text) and sentence_count >= 1)
def resolve_final_role(
block: dict,
anchors: dict | None = None,
families: dict | None = None,
) -> RoleAssignment:
"""Resolve a late-stage role using normalized zone/family context.
This is intentionally narrow for the current task: eager role assignment
remains the seed, but late structural context can override obviously wrong
body defaults.
"""
anchors = anchors or {}
family_ctx, zone, style_family, style_family_authority, context_source = _resolve_late_family_context(
block, families
)
marker_signature = block.get("marker_signature") or {}
marker_type = str(marker_signature.get("type") or "none")
current_role = str(block.get("role") or "body_paragraph")
current_confidence = float(block.get("role_confidence") or 0.6)
# When role is 'unassigned', fall back to seed_role (the eager assignment
# stored during assign_block_role). This ensures late resolution sees a
# meaningful role rather than the bare sentinel.
if current_role == "unassigned":
seed_role = str(block.get("seed_role") or "")
if seed_role:
current_role = seed_role
current_confidence = float(block.get("seed_confidence") or current_confidence)
body_anchor = anchors.get("body_family_anchor") or {}
reference_anchor = anchors.get("reference_family_anchor") or {}
body_anchor_accepted = str(body_anchor.get("status") or "").upper() == "ACCEPT"
reference_anchor_accepted = str(reference_anchor.get("status") or "").upper() == "ACCEPT"
in_body_zone = zone == "body_zone"
strong_legend_authority = style_family_authority in {"figure_marker", "figure_family_anchor"}
strong_table_authority = style_family_authority == "table_marker"
raw_label = str(block.get("raw_label") or block.get("block_label") or "")
text = str(block.get("text") or "")
text_lower = text.lower()
editorial_tail_phrase = any(
phrase in text_lower
for phrase in (
"conflict of interest",
"publisher's note",
"publisher\u2019s note",
"the remaining authors declare",
"published online",
)
)
if current_role == "body_paragraph" and editorial_tail_phrase:
return RoleAssignment(
role="backmatter_body" if zone == "tail_nonref_hold_zone" else "frontmatter_noise",
confidence=max(current_confidence, 0.88),
evidence=[
"late role resolution: editorial phrase cross-validates non-body classification",
f"zone={zone or 'unknown'}",
f"style_family={style_family or 'unknown'}",
],
)
if current_role == "body_paragraph":
if (
zone == "reference_zone"
and reference_anchor_accepted
and style_family == "reference_like"
and marker_type
in {
"reference_numeric_bracket",
"reference_numeric_dot",
"reference_numeric_parenthesis",
"reference_pattern",
"citation_line",
}
):
return RoleAssignment(
role="reference_item",
confidence=max(current_confidence, 0.82),
evidence=[
"late role resolution: reference_like family + reference zone",
f"style_family_authority={style_family_authority or 'none'}",
f"context_source={context_source}",
],
)
if (
in_body_zone
and body_anchor_accepted
and strong_legend_authority
and style_family == "legend_like"
and marker_type == "figure_number"
and not _looks_like_late_figure_narrative_prose(str(block.get("text") or ""))
):
return RoleAssignment(
role="figure_caption_candidate",
confidence=max(current_confidence, 0.78),
evidence=[
"late role resolution: legend_like family + figure_number marker",
f"zone={zone or 'unknown'}",
f"body_anchor={str(body_anchor.get('status') or 'none').lower()}",
f"style_family_authority={style_family_authority or 'none'}",
f"context_source={context_source}",
],
)
if (
style_family == "legend_like"
and marker_type == "figure_number"
and not _looks_like_late_figure_narrative_prose(text)
and len(text.split()) >= 6
and (strong_legend_authority or raw_label == "figure_title")
):
return RoleAssignment(
role="figure_caption_candidate",
confidence=max(current_confidence, 0.8),
evidence=[
"late role resolution: cross-validated figure legend candidate",
f"zone={zone or 'unknown'}",
f"style_family_authority={style_family_authority or 'none'}",
f"context_source={context_source}",
],
)
if (
style_family == "table_caption_like"
and marker_type == "table_number"
and len(text.split()) >= 3
and (strong_table_authority or raw_label == "figure_title")
):
return RoleAssignment(
role="table_caption_candidate",
confidence=max(current_confidence, 0.8),
evidence=[
"late role resolution: cross-validated table caption candidate",
f"zone={zone or 'unknown'}",
f"style_family_authority={style_family_authority or 'none'}",
f"context_source={context_source}",
],
)
if (
zone == "display_zone"
and strong_legend_authority
and style_family == "legend_like"
and marker_type == "figure_number"
and not _looks_like_late_figure_narrative_prose(str(block.get("text") or ""))
):
return RoleAssignment(
role="figure_caption_candidate",
confidence=max(current_confidence, 0.8),
evidence=[
"late role resolution: display-zone legend candidate from figure family",
f"style_family_authority={style_family_authority or 'none'}",
f"context_source={context_source}",
],
)
if current_role == "body_paragraph" and style_family:
_NON_BODY_FAMILIES = {"reference_like", "table_caption_like", "legend_like", "heading_like", "support_like"}
_STRONG_AUTHORITIES = {
"figure_marker",
"figure_family_anchor",
"reference_marker",
"reference_family_anchor",
"table_marker",
"heading_marker",
"body_family_anchor",
}
if style_family in _NON_BODY_FAMILIES and style_family_authority in _STRONG_AUTHORITIES:
if style_family == "legend_like":
if str(block.get("zone") or "") == "body_zone":
pass # body text with figure mention, not a caption
elif _looks_like_late_figure_narrative_prose(str(block.get("text") or "")):
pass # narrative prose stays body_paragraph
else:
_FAMILY_ROLE_MAP = {
"legend_like": "figure_caption_candidate",
}
mapped_role = _FAMILY_ROLE_MAP.get(style_family)
if mapped_role:
return RoleAssignment(
role=mapped_role,
confidence=max(current_confidence, 0.78),
evidence=[
f"late role resolution: non-body family '{style_family}' overrides body_paragraph",
f"style_family_authority={style_family_authority or 'none'}",
f"context_source={context_source}",
],
)
elif style_family in {"reference_like", "table_caption_like", "heading_like"}:
_FAMILY_ROLE_MAP = {
"reference_like": "reference_item",
"table_caption_like": "table_caption_candidate",
"heading_like": "section_heading",
}
mapped_role = _FAMILY_ROLE_MAP.get(style_family)
if mapped_role:
return RoleAssignment(
role=mapped_role,
confidence=max(current_confidence, 0.78),
evidence=[
f"late role resolution: non-body family '{style_family}' overrides body_paragraph",
f"style_family_authority={style_family_authority or 'none'}",
f"context_source={context_source}",
],
)
if current_role == "body_paragraph" and style_family in {
"legend_like",
"table_caption_like",
"reference_like",
"support_like",
}:
if zone == "reference_zone" and style_family == "reference_like":
return RoleAssignment(
role="reference_item",
confidence=max(current_confidence, 0.86),
evidence=[
"late role resolution: reference zone + reference_like cannot remain body",
f"style_family_authority={style_family_authority or 'none'}",
],
)
if (
style_family == "legend_like"
and raw_label == "figure_title"
and not _looks_like_late_figure_narrative_prose(text)
):
return RoleAssignment(
role="figure_caption",
confidence=max(current_confidence, 0.9),
evidence=[
"late role resolution: figure_title + legend_like + display context resolves to figure_caption",
f"zone={zone or 'unknown'}",
],
)
if (
style_family == "table_caption_like"
and marker_type == "table_number"
and (zone == "display_zone" or raw_label == "figure_title" or strong_table_authority)
):
return RoleAssignment(
role="table_caption_candidate",
confidence=max(current_confidence, 0.84),
evidence=[
"late role resolution: table_number + table_caption_like cannot remain body",
f"zone={zone or 'unknown'}",
],
)
if style_family == "support_like" and "published online" in text_lower:
return RoleAssignment(
role="frontmatter_noise",
confidence=max(current_confidence, 0.9),
evidence=[
"late role resolution: published-online furniture cannot remain heading/body",
f"zone={zone or 'unknown'}",
],
)
return RoleAssignment(
role=current_role,
confidence=current_confidence,
evidence=[],
)
def assign_block_role(
block: dict,
page_blocks: list[dict],
page_width: int = 0,
page_height: int = 0,
style_profiles: dict | None = None,
role_profiles: dict | None = None,
) -> RoleAssignment:
from paperforge.worker.ocr_document import _page_still_frontmatter
raw_label = str(block.get("block_label") or block.get("raw_label") or "").strip()
text = str(block.get("block_content") or block.get("text") or "").strip()
bbox = block.get("block_bbox") or block.get("bbox") or [0, 0, 0, 0]
# ocr_raw_error / ocr_text_missing: unrecovered OCR extraction failure
# Cases:
# missing_text_unrecovered — backfill ran, PDF had no text → ocr_raw_error
# missing_text_rejected — backfill found text but duplicates neighbor → ocr_raw_error
# None + raw_label=text + no text — backfill never ran (no PDF path) → ocr_text_missing
ocr_status = block.get("_ocr_raw_status")
if ocr_status in ("missing_text_unrecovered", "missing_text_rejected"):
return RoleAssignment(
role="ocr_raw_error",
confidence=0.99,
evidence=[f"unrecovered empty text: {block.get('_ocr_raw_error_type', 'unknown')}"],
)
if ocr_status is None and raw_label in ("text", "") and not text and len(bbox) >= 4:
bw = bbox[2] - bbox[0]
bh = bbox[3] - bbox[1]
if bw > 30 and bh > 15: # legitimate non-noise size
return RoleAssignment(
role="ocr_text_missing",
confidence=0.8,
evidence=[f"ocr detected text region (raw_label={raw_label}) but no text extracted; no pdf backfill available"],
)
# Page-1 article-type labels (e.g. "REVIEW", "Case Report") are frontmatter
# noise, not paper titles or section headings.
if (
raw_label == "paragraph_title"
and text.lower().strip() in _PAGE1_ARTICLE_TYPE_LABELS
and (block.get("page") or 1) == 1
):
return RoleAssignment(
role="frontmatter_noise",
confidence=0.8,
evidence=[f"page-1 article-type label: {text.lower().strip()}"],
)
if _PREPROOF_MARKER.match(text):
y_top = bbox[1] if len(bbox) >= 4 else 0
page_num = int(block.get("page", 0) or 0)
page_h = float(block.get("page_height") or page_height or 1700)
page_w = float(block.get("page_width") or page_width or 1200)
block_width = (bbox[2] - bbox[0]) if len(bbox) >= 4 else 0
# Running header on any page — pre-proof text at extreme top is page furniture
if y_top < page_h * 0.06:
return RoleAssignment(
role="frontmatter_noise",
confidence=0.98,
evidence=[f"journal pre-proof running header suppressed: p{page_num} y={y_top:.0f}/{page_h:.0f}"],
)
# Page 1 cover-page pre-proof is also page furniture
if page_num == 1 and raw_label == "paragraph_title" and y_top > page_h * 0.08:
return RoleAssignment(
role="frontmatter_noise",
confidence=0.98,
evidence=[
"journal pre-proof marker: page 1, paragraph_title, "
f"y={y_top:.0f}/{page_h:.0f}, width={block_width:.0f}/{page_w:.0f}"
],
)
# Panel label exclusion (single-letter figure labels like A, B, (C), A.)
if _PANEL_LABEL_PATTERN.match(text):
return RoleAssignment(
role="figure_inner_text",
confidence=0.9,
evidence=[f"panel label / figure inner text: {text}"],
)
# Frontiers figure title check (before general figure/table prefix)
if raw_label == "figure_title" and _FRONTIERS_FIGURE_TITLE_PATTERN.match(text):
return RoleAssignment(
role="figure_caption",
confidence=0.95,
evidence=[f"Frontiers figure title pattern: {text[:60]}"],
)
if _FRONTIERS_FIGURE_TITLE_PATTERN.match(text):
return RoleAssignment(
role="figure_caption",
confidence=0.9,
evidence=[f"Frontiers figure title pattern: {text[:60]}"],
)
# Figure / table caption patterns override any prior
if _has_figure_prefix(text):
if raw_label == "text":
if _is_obviously_formal_figure_caption(text, block, page_blocks):
is_long = len(text) > 80
near_media = _is_near_figure_media(block, page_blocks)
confidence = 0.9
evidence_parts = [f"figure prefix matched: {text[:60]}"]
if is_long:
confidence = 0.65
evidence_parts.append("long text, reduced confidence")
if near_media:
confidence = min(confidence + 0.25, 0.95)
evidence_parts.append("near figure media assets")
return RoleAssignment(
role="figure_caption",
confidence=confidence,
evidence=evidence_parts,
)
return RoleAssignment(
role="figure_caption_candidate",
confidence=0.9,
evidence=[f"figure caption candidate (body narrative): {text[:60]}"],
)
if raw_label == "figure_title":
return RoleAssignment(
role="figure_caption",
confidence=0.92,
evidence=[f"figure_title label: {text[:60]}"],
)
if _is_obviously_formal_figure_caption(text, block, page_blocks):
return RoleAssignment(
role="figure_caption",
confidence=0.9,
evidence=[f"figure prefix matched: {text[:60]}"],
)
return RoleAssignment(
role="figure_caption_candidate",
confidence=0.9,
evidence=[f"figure caption candidate: {text[:60]}"],
)
if _has_table_prefix(text):
return RoleAssignment(
role="table_caption",
confidence=0.9,
evidence=[f"table prefix matched: {text[:60]}"],
)
# --- First-surviving-page frontmatter zone pre-filter ---
page_num = block.get("page", 1) or 1
zone = None
if page_num in (1, 2) and raw_label in ("paragraph_title", "doc_title", "text", "footnote"):
from paperforge.worker.ocr_document import _detect_frontmatter_zone
zone = _detect_frontmatter_zone(
block,
page_blocks,
page_height,
page_width,
style_profiles,
)
if zone == "title_zone":
lower_stripped = text.strip().lstrip("*•·-–—").lower()
if lower_stripped not in _BACKMATTER_TITLE_DENY_LIST:
return RoleAssignment(
role="paper_title",
confidence=0.8,
evidence=[f"page-1 zone title_zone: {text[:60]}"],
)
# Fallback: first surviving page after pre-proof marker — next substantial text block is the real title
if zone is None and page_num in (1, 2) and raw_label in ("paragraph_title", "doc_title", "text"):
bbox = block.get("block_bbox") or block.get("bbox") or [0, 0, 0, 0]
y_top = bbox[1] if len(bbox) >= 4 else 0
ph = float(block.get("page_height") or page_height or 1700)
already_has_preproof = any(
str(b.get("text", "") or b.get("block_content", "") or "").strip().lower().startswith("journal pre")
for b in page_blocks
if b is not block
)
if already_has_preproof and y_top < ph * 0.35 and len(text) > 40:
lower_noise = text.strip().lstrip("*•·-–—").lower()
if lower_noise not in _BACKMATTER_TITLE_DENY_LIST:
return RoleAssignment(
role="paper_title",
confidence=0.85,
evidence=[f"page-1 title fallback after pre-proof: y={y_top:.0f}/{ph:.0f}"],
)
if zone == "author_zone":
return RoleAssignment(
role="authors",
confidence=0.8,
evidence=[f"page-1 zone author_zone: {text[:60]}"],
)
if zone == "affiliation_zone":
return RoleAssignment(
role="affiliation",
confidence=0.8,
evidence=[f"page-1 zone affiliation_zone: {text[:60]}"],
)
# Page-1 correspondence footnotes → frontmatter_support (must run before zone-based noise)
if raw_label in {"footnote", "vision_footnote"} and page_num == 1:
lower_txt = text.lower()
if lower_txt.startswith(("correspondence", "corresponding author", "address for correspondence")):
return RoleAssignment(
role="frontmatter_support",
confidence=0.75,
evidence=[f"page-1 correspondence footnote: {text[:60]}"],
)
# First-surviving-page support text: catch support-like lines before
# zone-based noise or generic body fallback
bbox = block.get("block_bbox") or block.get("bbox") or [0, 0, 0, 0]
topish = len(bbox) >= 4 and bbox[1] <= page_height * 0.65
if page_num in (1, 2) and topish and _looks_like_frontmatter_support_text(text):
return RoleAssignment(
role="frontmatter_support",
confidence=0.78,
evidence=[f"first-surviving-page support text: {text[:60]}"],
)
if zone == "journal_furniture_zone":
return RoleAssignment(
role="frontmatter_noise",
confidence=0.8,
evidence=[f"page-1 zone journal_furniture_zone: {text[:60]}"],
)
# zone == "abstract_zone" → let existing logic handle
# ---- First-surviving-page frontmatter guard ----
_pg = block.get("page", 1) or 1
if _pg in (1, 2) and raw_label in ("doc_title", "paragraph_title", "text"):
_tv = text
_lv = _tv.lower().strip().lstrip("*•·-–—")
block_bbox_local = block.get("block_bbox", [0, 0, 0, 0])
if _lv in _BACKMATTER_TITLE_DENY_LIST:
pass # let existing paragraph_title or fallthrough handle it
elif raw_label == "doc_title" or (
raw_label != "text"
and len(block_bbox_local) >= 4
and block_bbox_local[1] < page_height * 0.25
and len(_tv) > 20
and "abstract" not in _lv[:15]
and "keywords" not in _lv[:15]
and "introduction" not in _lv[:15]
):
if len(_tv) > 20 and not _seems_like_authors(_tv) and not _has_heading_numbering(_tv):
return RoleAssignment(
role="paper_title",
confidence=0.6,
evidence=[f"page-1 frontmatter title guard: {_tv[:60]}"],
)
# Paddle priors
if raw_label == "paragraph_title":
stripped = text.strip().lstrip("*•·-–—")
lower = stripped.lower()
if lower in FRONTMATTER_NOISE and _page_still_frontmatter(page_blocks, block.get("page", 1) or 1, page_height):
return RoleAssignment(
role="frontmatter_noise",
confidence=0.9,
evidence=[f"frontmatter noise: {text[:60]}"],
)
if lower == "abstract":
return RoleAssignment(
role="abstract_heading",
confidence=0.95,
evidence=["abstract heading"],
)
if lower in ("references", "bibliography"):
return RoleAssignment(
role="reference_heading",
confidence=0.9,
evidence=[f"references heading: {text[:60]}"],
)
# Running header guard: article-type labels in running-header
# position (page > 1, top margin zone) are not section headings.
_RUNNING_HEADER_LABELS = frozenset(
{
"review article",
"research article",
"original article",
"case report",
"brief communication",
"rapid communication",
}
)
if lower in _RUNNING_HEADER_LABELS and (block.get("page") or 1) == 1:
return RoleAssignment(
role="frontmatter_noise",
confidence=0.8,
evidence=[f"page-1 article-type label: {lower}"],
)
if lower in _RUNNING_HEADER_LABELS and (block.get("page") or 1) > 1:
bbox = block.get("block_bbox") or [0, 0, 0, 0]
_ph = block.get("page_height") or 1700
if bbox[1] < _ph * 0.12:
return RoleAssignment(
role="noise",
confidence=0.8,
evidence=[f"running header: {lower}"],
)
# Sidebar / structured-insert heading labels (exact matches only).
# Intentionally excluded: "box", "summary"
# Reason: too ambiguous, risks swallowing normal subsection headings.
if lower in {"highlights", "key points"} and page_num > 1:
return RoleAssignment(
role="structured_insert_candidate",
confidence=0.7,
evidence=[f"structured insert label: {lower}"],
)
# Backmatter heading detection (tail-zone + text evidence)
# Known backmatter phrases on tail pages (page > 1) are unambiguous -
# full-width headings are common in real papers, so geometric checks
# are not used here. Page-1 blocks with these phrases are frontmatter
# noise (already caught above) or genuine backmatter that fell through.
if lower in _BACKMATTER_HEADINGS:
page_num = block.get("page", 1) or 1
if page_num > 1:
return RoleAssignment(
role="backmatter_heading_candidate",
confidence=0.8,
evidence=[f"backmatter heading on page {page_num}: {text[:60]}"],
)
return RoleAssignment(
role="section_heading",
confidence=0.5,
evidence=[f"backmatter heading text on page 1, treated as section: {text[:60]}"],
)
# Backmatter boundary container heading (e.g. "ADDITIONAL INFORMATION AND DECLARATIONS")
# Checked after specific heading detection but before numbered/generic fallback.
_page_num = block.get("page", 1) or 1
_total_pages = max((b.get("page", 1) or 1) for b in page_blocks) if page_blocks else _page_num
if _is_backmatter_boundary_heading(block, _page_num, _total_pages):
role_name = "backmatter_boundary_heading"
confidence = 0.7
lower_txt = text.lower()
has_container_words = any(
w in lower_txt for w in ["additional information", "declaration"]
)
span_meta = block.get("span_metadata", {}) or {}
is_bold = False
if isinstance(span_meta, dict):
is_bold = "bold" in (span_meta.get("flags", "") or "").lower()
elif isinstance(span_meta, list):
is_bold = any(s.get("flags", 0) & 16 for s in span_meta)
if not (has_container_words and is_bold):
role_name = "backmatter_boundary_candidate"
confidence = 0.5
return RoleAssignment(
role=role_name,
confidence=confidence,
evidence=[
f"backmatter {'boundary heading' if role_name == 'backmatter_boundary_heading' else 'boundary candidate'}: {text[:60]}"
],
)
# Author-like heading guard: prevent bylines from becoming headings
_is_author_byline = (
(
re.search(r"&|,.*,", text)
or _seems_like_authors(text)
or _looks_like_initial_lastname_byline(text)
)
and len(text.split()) <= 15
and not _has_heading_numbering(text)
and not any(
text.lower().startswith(w)
for w in ["abstract", "introduction", "methods", "results", "discussion", "conclusion", "references"]
)
)
if _is_author_byline and page_num == 1:
return RoleAssignment(
role="authors",
confidence=0.6,
evidence=[f"author byline on page 1, assigned as authors: {text[:60]}"],
)
# Explicit correspondence line → frontmatter_support (must run before generic noise rule)
if page_num == 1:
lower_txt = text.lower().strip()
if lower_txt.startswith(("correspondence", "corresponding author", "address for correspondence")):
return RoleAssignment(
role="frontmatter_support",
confidence=0.75,
evidence=[f"page-1 correspondence support: {text[:60]}"],
)
if re.search(r"(?:correspondence|orcid|@)", text.lower()) and len(text.split()) <= 20 and page_num == 1:
return RoleAssignment(
role="frontmatter_noise",
confidence=0.5,
evidence=[f"correspondence/noise on page 1: {text[:60]}"],
)
if _has_heading_numbering(text):
depth = _heading_number_depth(text)
role_map = {1: "section_heading", 2: "subsection_heading"}
role = role_map.get(depth, "sub_subsection_heading")
return RoleAssignment(
role=role,
confidence=0.85,
evidence=[f"paragraph_title label with numbering: {text[:60]}"],
)
# Page 1 explicit scholarly heading override (Roman numerals, alpha subsections)
explicit_heading_role = None
if raw_label == "paragraph_title":
explicit_heading_role = _explicit_scholarly_heading_role(text)
if explicit_heading_role:
page_num = int(block.get("page", 1))
bbox = block.get("block_bbox") or block.get("bbox") or [0, 0, 0, 0]
y_top = bbox[1] if len(bbox) >= 4 else 0
page_height = float(block.get("page_height") or 1700)
if page_num > 1 or y_top > page_height * 0.25:
return RoleAssignment(
role=explicit_heading_role,
confidence=0.9,
evidence=[f"explicit scholarly heading: {text[:60]}"],
)
bbox = block.get("block_bbox", [0, 0, 0, 0])
page_num = block.get("page", 1) or 1
if page_num <= 1:
if bbox[1] < max(page_height, 1) * 0.25:
if lower in _BACKMATTER_TITLE_DENY_LIST:
depth = _heading_number_depth(text)
role_map = {1: "section_heading", 2: "subsection_heading"}
role = role_map.get(depth, "sub_subsection_heading")
return RoleAssignment(
role=role,
confidence=0.5,
evidence=[f"backmatter title in title zone, treated as heading: {text[:60]}"],
)
return RoleAssignment(
role="paper_title",
confidence=0.7,
evidence=[f"unnumbered paragraph_title in title zone on page 1: {text[:60]}"],
)
return RoleAssignment(
role="section_heading",
confidence=0.5,
evidence=[f"unnumbered paragraph_title on page 1 outside title zone: {text[:60]}"],
)
heading_text = text.strip()
if _is_reference_heading_text(heading_text):
return RoleAssignment(
role="reference_heading",
confidence=0.85,
evidence=[f"reference heading text match: {heading_text[:60]}"],
)
level = _infer_heading_level(text, block=block)
return RoleAssignment(
role=level,
confidence=0.6,
evidence=[f"unnumbered paragraph_title, inferred level {level}: {text[:60]}"],
)
if raw_label == "figure_title":
if _has_table_prefix(text):
return RoleAssignment(
role="table_caption",
confidence=0.92,
evidence=[f"figure_title with table prefix: {text[:60]}"],
)
return RoleAssignment(
role="figure_caption",
confidence=0.85,
evidence=[f"figure_title label: {text[:60]}"],
)
# Textual table check: bullet-point lists mislabeled as "table"
# must NOT enter media_asset — route to structured_insert_candidate instead.
if raw_label == "table" and _is_textual_table(text):
return RoleAssignment(
role="structured_insert_candidate",
confidence=0.7,
evidence=[f"textual table (bullet list) not media_asset: {text[:60]}"],
)
# Inline table HTML check: applies to all raw_labels, not just "text"
if text.strip().lower().startswith("<table"):
return RoleAssignment(
role="table_html",
confidence=0.95,
evidence=["inline table HTML"],
)
if raw_label in {"image", "chart", "table"}:
return RoleAssignment(
role="media_asset",
confidence=0.85,
evidence=[f"media label: {raw_label}"],
)
if raw_label == "header":
return RoleAssignment(
role="noise",
confidence=0.9,
evidence=["header label"],
)
if raw_label == "footer":
return RoleAssignment(
role="noise",
confidence=0.9,
evidence=["footer label"],
)
if raw_label == "number":
return RoleAssignment(
role="noise",
confidence=0.9,
evidence=["page number label"],
)
if raw_label == "abstract":
return RoleAssignment(
role="abstract_body",
confidence=0.85,
evidence=["abstract label from Paddle OCR"],
)
if raw_label == "reference_content":
return RoleAssignment(
role="reference_item",
confidence=0.85,
evidence=[f"reference content label: {text[:60]}"],
)
# Page-1 DOI / received / accepted / published footnotes → frontmatter_noise
if raw_label in {"footnote", "vision_footnote"} and page_num == 1:
lower_txt = text.lower()
if lower_txt.startswith(("doi", "received", "accepted", "published")):
return RoleAssignment(
role="frontmatter_noise",
confidence=0.75,
evidence=[f"page-1 DOI/date footnote: {text[:60]}"],
)
# Figure-description footnotes are not real footnotes.
if raw_label in {"footnote", "vision_footnote"} and _looks_like_figure_description_opening(text):
near_media = _is_near_figure_media(block, page_blocks)
return RoleAssignment(
role="figure_caption" if near_media else "figure_caption_candidate",
confidence=0.9 if near_media else 0.82,
evidence=[
f"{raw_label} with figure-description opening: {text[:60]}",
f"near_figure_media={near_media}",
],
)
if raw_label in {"footnote", "vision_footnote"}:
return RoleAssignment(
role="footnote",
confidence=0.7,
evidence=[f"{raw_label} label: {text[:60]}"],
)
# text with reference-like pattern
if _looks_like_reference(text):
first_word = text.strip().split(",")[0].strip().lower()
if first_word not in _BODY_ORDINAL_OPENINGS:
# Length guard: (N) prefix with very short body → likely a heading, not a ref
stripped = _PAREN_REF_PREFIX.sub("", text).strip()
if text.lstrip().startswith("(") and len(stripped) < 25:
pass # skip — short (N) text is not a reference item
else:
return RoleAssignment(
role="reference_item",
confidence=0.6,
evidence=[f"reference-like pattern: {text[:60]}"],
)
# text -> body paragraph by default, but with lower confidence
if raw_label == "text":
stripped = text.strip().lstrip("*•·-—_")
lower_txt = stripped.lower()
page_num = block.get("page", 1) or 1
still_frontmatter = _page_still_frontmatter(page_blocks, page_num, page_height)
# Explicit page-1 correspondence line → frontmatter_support
if page_num == 1 and lower_txt.startswith(("correspondence", "corresponding author", "address for correspondence")):
return RoleAssignment(
role="frontmatter_support",
confidence=0.75,
evidence=[f"page-1 correspondence support: {text[:60]}"],
)
# Check for inline table HTML
if text.strip().lower().startswith("<table"):
return RoleAssignment(
role="table_html",
confidence=0.95,
evidence=["inline table HTML"],
)
# Check for abstract heading (may appear as text block, not paragraph_title)
if lower_txt.startswith("abstract") and len(text) < 30:
return RoleAssignment(
role="abstract_heading",
confidence=0.85,
evidence=[f"abstract heading from text block: {text[:40]}"],
)
# Check for copyright — removed: position-based checks are sufficient
# Check for email / ORCID / DOI patterns (page 1 only)
if (
still_frontmatter
and "@" in text
and (".edu" in text.lower() or ".com" in text.lower() or ".org" in text.lower())
):
return RoleAssignment(
role="frontmatter_noise",
confidence=0.85,
evidence=[f"contact/email: {text[:60]}"],
)
# Check for frontmatter noise phrases — demoted to weak fallback.
# Backmatter-referring phrases (supplementary material, publisher's note)
# are intentionally removed: they are valid backmatter headings and
# should never suppress body text. Tail ownership is resolved by
# the renderer's multi-page tail spread, not by the role layer.
noise_phrases = [
"citation:",
"to cite this article",
"correspondence",
"orcid",
"these authors have contributed",
"equal contribution",
]
if still_frontmatter and any(phrase in lower_txt for phrase in noise_phrases):
return RoleAssignment(
role="frontmatter_noise",
confidence=0.8,
evidence=[f"frontmatter phrase: {text[:60]}"],
)
# Citation line like "Masante B, Gabetti S, ... and Surname (2025)" — page 1 only
if still_frontmatter and _CITATION_LINE_PATTERN.match(stripped) and " and " in text and ")" in text:
return RoleAssignment(
role="frontmatter_noise",
confidence=0.8,
evidence=[f"citation line pattern: {text[:60]}"],
)
# Affiliation block starting with superscript (page 1 only)
if (
still_frontmatter
and _AUTHOR_AFFILIATION_MARKER.match(stripped)
and any(kw in lower_txt for kw in ["department", "university", "institute", "college", "school of"])
):
return RoleAssignment(
role="frontmatter_noise",
confidence=0.85,
evidence=[f"affiliation block: {text[:60]}"],
)
# Keyword content block: short comma-separated list of terms (page 1 only)
if still_frontmatter and (
"," in text
and not any(w in lower_txt for w in [" is ", " are ", " was ", " were "])
and len(text.split(",")) >= 3
and len(text) < 200
):
return RoleAssignment(
role="frontmatter_noise",
confidence=0.7,
evidence=[f"keyword-like block: {text[:60]}"],
)
# Existing noise startswith check (page 1 only)
if still_frontmatter and any(lower_txt.startswith(n) for n in FRONTMATTER_NOISE):
return RoleAssignment(
role="frontmatter_noise",
confidence=0.7,
evidence=[f"frontmatter noise text: {text[:60]}"],
)
if _has_heading_numbering(text) and len(text) < 80 and ". " not in text:
bbox = block.get("block_bbox", [0, 0, 0, 0])
x1, y1, x2 = bbox[0], bbox[1], bbox[2]
block_width = x2 - x1
in_top_80 = y1 < max(page_height, 1) * 0.8
wide_enough = block_width > max(page_width, 1) * 0.3
sentence_verbs = [" is ", " are ", " was ", " were ", " have ", " has ", " been "]
no_sentence_verbs = not (len(text) > 50 and any(v in text.lower() for v in sentence_verbs))
if in_top_80 and wide_enough and no_sentence_verbs:
return RoleAssignment(
role="section_heading",
confidence=0.65,
evidence=[f"numbered text block: {text[:60]}"],
)
# References heading from text block
if lower_txt in ("references", "bibliography") and len(text) < 30:
return RoleAssignment(
role="reference_heading",
confidence=0.8,
evidence=[f"references heading from text block: {text[:40]}"],
)
# Style-aware heading disambiguation — primary signal for unnumbered papers
if style_profiles is not None and style_profiles:
from paperforge.worker.ocr_render import _disambiguate_heading_role
style_suggested = _disambiguate_heading_role(block, style_profiles, role_profiles=role_profiles)
if style_suggested is not None:
bbox = block.get("block_bbox", [0, 0, 0, 0])
in_top_80 = page_height == 0 or (len(bbox) >= 4 and bbox[1] < page_height * 0.8)
if in_top_80 and len(text) >= 5 and len(text) < 60 and ". " not in text:
return RoleAssignment(
role=style_suggested,
confidence=0.7,
evidence=[f"style-aware heading detection: role={style_suggested}, text={text[:40]}"],
)
# Visual heading detection — use span_metadata if available
span_meta = block.get("span_metadata", {}) or {}
if isinstance(span_meta, dict):
font_size = span_meta.get("size", 0) or 0
font_flags = (str(span_meta.get("flags", "") or "")).lower()
is_visually_prominent = ("bold" in font_flags and font_size >= 11) or font_size >= 14
elif isinstance(span_meta, list):
sizes = [s.get("size", 0) or 0 for s in span_meta if s.get("size")]
mean_size = sum(sizes) / len(sizes) if sizes else 0
all_flags = [s.get("flags", 0) for s in span_meta]
is_bold = any(bool(f & 16) for f in all_flags if isinstance(f, int))
font_size = mean_size
font_flags = "bold" if is_bold else "normal"
is_visually_prominent = (is_bold and mean_size >= 11) or mean_size >= 14
else:
font_size = 0
font_flags = ""
is_visually_prominent = False
if is_visually_prominent and len(text) >= 5 and len(text) < 60 and ". " not in text:
bbox = block.get("block_bbox", [0, 0, 0, 0])
in_top_80 = page_height == 0 or (len(bbox) >= 4 and bbox[1] < page_height * 0.8)
if in_top_80:
heading_level = _infer_heading_level(text, font_size, block=block)
return RoleAssignment(
role=heading_level,
confidence=0.65,
evidence=[
f"heading-style text block: size={font_size}, flags={font_flags},"
f" level={heading_level}, text={text[:40]}"
],
)
# Backmatter boundary container heading detection for text blocks
_pn2 = block.get("page", 1) or 1
_tp2 = max((b.get("page", 1) or 1) for b in page_blocks) if page_blocks else _pn2
if _is_backmatter_boundary_heading(block, _pn2, _tp2):
role_name = "backmatter_boundary_heading"
confidence = 0.7
lower_txt = text.lower()
has_container_words = any(
w in lower_txt for w in ["additional information", "declaration"]
)
span_meta = block.get("span_metadata", {}) or {}
is_bold = False
if isinstance(span_meta, dict):
is_bold = "bold" in (span_meta.get("flags", "") or "").lower()
elif isinstance(span_meta, list):
is_bold = any(s.get("flags", 0) & 16 for s in span_meta)
if not (has_container_words and is_bold):
role_name = "backmatter_boundary_candidate"
confidence = 0.5
return RoleAssignment(
role=role_name,
confidence=confidence,
evidence=[
f"backmatter {'boundary heading' if role_name == 'backmatter_boundary_heading' else 'boundary candidate'} from text block: {text[:60]}"
],
)
# Page-1 author byline with initial-lastname order (e.g. "J C Buckland-Wright")
if (
page_num == 1
and _looks_like_initial_lastname_byline(text)
and len(text.split()) <= 15
and not _has_heading_numbering(text)
):
return RoleAssignment(
role="authors",
confidence=0.6,
evidence=[f"page-1 initial-lastname author byline: {text[:60]}"],
)
if _looks_like_margin_band_noise(block, page_width, page_height):
return RoleAssignment(
role="noise",
confidence=0.95,
evidence=["extreme page-edge narrow/tall geometry; treated as margin-band noise"],
)
if _looks_like_figure_inner_label(text, block, page_blocks):
return RoleAssignment(
role="figure_inner_text",
confidence=0.88,
evidence=["short figure-adjacent label; treated as figure_inner_text"],
)
if len(text) < 20:
return RoleAssignment(
role="unknown_structural",
confidence=0.3,
evidence=["short text, uncertain role"],
)
return RoleAssignment(
role="body_paragraph",
confidence=0.6,
evidence=["default body_paragraph for text label"],
)
# Margin-band geometry noise: tall narrow strips pinned to page edges.
# Checked before edge_band_noise (which is stricter) as a broader net.
if _looks_like_margin_band_noise(block, page_width, page_height):
return RoleAssignment(
role="noise",
confidence=0.95,
evidence=["margin-band narrow/tall geometry; treated as noise"],
)
# Edge-band geometry noise: ultra-narrow/tall blocks pinned to page edges
# (aside_text, header_image) — must be caught before generic fallback.
if _looks_like_edge_band_noise(block, page_width, page_height):
return RoleAssignment(
role="noise",
confidence=0.98,
evidence=["extreme narrow/tall edge-band geometry; treated as structural noise"],
)
# fallback
return RoleAssignment(
role="unknown_structural",
confidence=0.2,
evidence=[f"unrecognized label '{raw_label}'"],
)
def second_pass_cross_validate(
block: dict,
role_profiles: dict,
) -> dict:
"""Second-pass cross-validation for low-confidence role assignments.
Examines low-confidence blocks against role family profiles.
Never overrides the assigned role -- only adjusts confidence and
suggests alternative roles for review.
Returns:
{"role": str, "confidence_adjustment": float,
"role_changed": bool, "suggested_roles": list[str],
"match_details": dict}
"""
from paperforge.worker.ocr_profiles import (
cross_validate_with_span,
extract_block_span_profile,
)
current_role = block.get("role", "")
current_confidence = block.get("role_confidence", 0.5)
# Skip if no span data
block_profile = extract_block_span_profile(block)
if block_profile is None:
return {
"role": current_role,
"confidence_adjustment": 0.0,
"role_changed": False,
"suggested_roles": [],
"match_details": {},
}
# Run cross-validation
xv_result = cross_validate_with_span(block, current_role, role_profiles)
adjustment = xv_result["adjustment"]
suggested_roles = xv_result["suggested_roles"]
# Don't change role -- only adjust confidence
# Exception: if suggested_roles has exactly one strong candidate
# AND current confidence is very low (< 0.3) AND adjustment is strongly negative
role_changed = False
if len(suggested_roles) == 1 and current_confidence < 0.3 and adjustment < -0.2:
# Roles that must never be overridden by second pass
never_override = {
"paper_title",
"abstract_body",
"reference_heading",
"reference_item",
"media_asset",
"figure_asset",
"table_html",
"noise",
"unknown_structural",
}
if current_role in never_override:
role_changed = False
elif suggested_roles[0] not in never_override:
role_changed = True
return {
"role": suggested_roles[0] if role_changed else current_role,
"confidence_adjustment": round(adjustment, 4),
"role_changed": role_changed,
"suggested_roles": suggested_roles,
"match_details": xv_result.get("match_details", {}),
}