From 1e310ee351f3bb0cbd5c520e4ce6d5bf197e3d21 Mon Sep 17 00:00:00 2001 From: Research Assistant Date: Mon, 8 Jun 2026 19:15:36 +0800 Subject: [PATCH] fix: pre-proof marker suppression with page-1 context guard, rescue exemption, title fallback - _PREPROOF_MARKER suppression now guarded by page==1, y_top>8%, raw_label=paragraph_title, not_header, not_footer - _is_preproof_marker() exported for health use - rescue_roles_with_document_context: pre-proof frontmatter_noise blocks are exempt from body_paragraph rescue - Page-1 title fallback: when pre-proof steals the title zone, next substantial text block becomes paper_title - Tests: running header not suppressed, page-2 not suppressed, variants pass --- paperforge/worker/ocr_document.py | 4 +++ paperforge/worker/ocr_rebuild.py | 13 +++++-- paperforge/worker/ocr_roles.py | 54 ++++++++++++++++++++++++++++ tests/test_ocr_roles.py | 58 +++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 2 deletions(-) diff --git a/paperforge/worker/ocr_document.py b/paperforge/worker/ocr_document.py index cd59a509..35d7dcb9 100644 --- a/paperforge/worker/ocr_document.py +++ b/paperforge/worker/ocr_document.py @@ -1180,6 +1180,10 @@ def rescue_roles_with_document_context( # --- Rule 1: frontmatter_noise → body_paragraph (body section + body font) if block.get("role") == "frontmatter_noise": + # Never rescue pre-proof markers — they are intentional page furniture suppression + from paperforge.worker.ocr_roles import is_preproof_marker + if is_preproof_marker(str(block.get("text", "") or block.get("block_content", "") or "")): + continue page = block.get("page", 1) or 1 bbox = block.get("bbox") or block.get("block_bbox") or [0, 0, 0, 0] page_h = block.get("page_height") or 1700 diff --git a/paperforge/worker/ocr_rebuild.py b/paperforge/worker/ocr_rebuild.py index 9c9f1ea2..d8f820e5 100644 --- a/paperforge/worker/ocr_rebuild.py +++ b/paperforge/worker/ocr_rebuild.py @@ -118,7 +118,7 @@ def run_derived_rebuild_for_keys(vault: Path, keys: list[str]) -> dict: metadata_dir.mkdir(parents=True, exist_ok=True) frontmatter_candidates = extract_frontmatter_candidates(artifacts.blocks_structured) page1_blocks = [block for block in structured if int(block.get("page", 0) or 0) == 1] - resolved = resolve_metadata(source_meta, frontmatter_candidates, page1_blocks=page1_blocks) + resolved = resolve_metadata(source_meta, frontmatter_candidates, page1_blocks=page1_blocks, structured_blocks=structured) write_resolved_metadata(metadata_dir / "resolved_metadata.json", resolved) # Rebuild figure inventory @@ -247,14 +247,23 @@ def _enrich_meta_from_paper_note(vault: Path, key: str, meta_path: Path) -> None return meta = read_json(meta_path) if meta_path.exists() else {} changed = False + # Always re-extract authors on rebuild — source_metadata may carry stale + # first-author-only entries from previous runs with no way to detect them + if meta.get("authors_source") != "zotero": + meta.pop("authors", None) + meta.pop("authors_incomplete", None) + meta.pop("authors_source", None) + meta.pop("first_author", None) + changed = True for field in ("title", "authors", "year", "journal", "doi"): if field not in meta or not meta.get(field): val = fm.get(field) if val: meta[field] = val changed = True - if ("authors" not in meta or not meta.get("authors")) and fm.get("first_author"): + if (not meta.get("authors")) and fm.get("first_author"): meta["authors"] = [str(fm["first_author"])] + meta["first_author"] = str(fm["first_author"]) meta["authors_incomplete"] = True meta["authors_source"] = "paper_note.first_author_fallback" changed = True diff --git a/paperforge/worker/ocr_roles.py b/paperforge/worker/ocr_roles.py index 098c442e..bd189426 100644 --- a/paperforge/worker/ocr_roles.py +++ b/paperforge/worker/ocr_roles.py @@ -26,6 +26,11 @@ _TABLE_PREFIX_PATTERN = re.compile( flags=re.IGNORECASE, ) +_PREPROOF_MARKER = re.compile( + r"^(?:journal\s+)?pre-?proof\b", + re.IGNORECASE, +) + _BACKMATTER_TITLE_DENY_LIST = { "generative ai statement", "acknowledgments", @@ -128,6 +133,10 @@ def _heading_number_depth(text: str) -> int: 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())) @@ -382,6 +391,33 @@ def assign_block_role( 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() + if _PREPROOF_MARKER.match(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 + 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 + + preproof_likely = ( + page_num == 1 + and y_top > page_h * 0.08 + and raw_label == "paragraph_title" + ) + + is_running_header = y_top < page_h * 0.06 + is_footer = y_top > page_h * 0.94 + + if preproof_likely and not is_running_header and not is_footer: + 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( @@ -477,6 +513,24 @@ def assign_block_role( evidence=[f"page-1 zone title_zone: {text[:60]}"], ) + # Fallback: page 1 after pre-proof marker — next substantial text block is the real title + if zone is None and page_num == 1 and raw_label in ("paragraph_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", diff --git a/tests/test_ocr_roles.py b/tests/test_ocr_roles.py index 5bf63195..4c724433 100644 --- a/tests/test_ocr_roles.py +++ b/tests/test_ocr_roles.py @@ -658,3 +658,61 @@ def test_page1_top_title_not_misclassified_as_section_heading(): from paperforge.worker.ocr_roles import assign_block_role result = assign_block_role({"raw_label": "paragraph_title", "text": "I. INTRODUCTION", "page": 1, "page_width": 1200, "page_height": 1700, "block_bbox": [261, 100, 457, 120]}, page_blocks=[]) assert result.role != "section_heading" + + +def test_preproof_marker_is_frontmatter_noise(): + from paperforge.worker.ocr_roles import assign_block_role + result = assign_block_role({ + "raw_label": "paragraph_title", + "text": "Journal Pre-proof", + "page": 1, "page_width": 1224, "page_height": 1584, + "block_bbox": [190, 206, 475, 246], + }, page_blocks=[]) + assert result.role == "frontmatter_noise" + assert result.confidence >= 0.9 + + +def test_preproof_marker_variants(): + from paperforge.worker.ocr_roles import assign_block_role + for text in ["Journal Pre-proof", "Pre-proof", "journal pre-proof"]: + result = assign_block_role({ + "raw_label": "paragraph_title", + "text": text, "page": 1, "page_width": 1224, "page_height": 1584, + "block_bbox": [190, 206, 475, 246], + }, page_blocks=[]) + assert result.role == "frontmatter_noise" + + +def test_preproof_running_header_not_suppressed(): + """Pre-proof text at extreme top (like a running header) should NOT be suppressed.""" + from paperforge.worker.ocr_roles import assign_block_role + result = assign_block_role({ + "raw_label": "paragraph_title", + "text": "Journal Pre-proof", + "page": 1, "page_width": 1224, "page_height": 1584, + "block_bbox": [190, 30, 475, 55], + }, page_blocks=[]) + assert result.role != "frontmatter_noise" + + +def test_preproof_page2_not_suppressed(): + """Pre-proof text on page 2+ should NOT be suppressed.""" + from paperforge.worker.ocr_roles import assign_block_role + result = assign_block_role({ + "raw_label": "paragraph_title", + "text": "Journal Pre-proof", + "page": 2, "page_width": 1224, "page_height": 1584, + "block_bbox": [190, 206, 475, 246], + }, page_blocks=[]) + assert result.role != "frontmatter_noise" + + +def test_real_title_after_preproof_still_works(): + from paperforge.worker.ocr_roles import assign_block_role + result = assign_block_role({ + "raw_label": "paragraph_title", + "text": "Magnetoresponsive Stem Cell Spheroid-based Cartilage Recovery Platform", + "page": 1, "page_width": 1200, "page_height": 1700, + "block_bbox": [100, 200, 700, 230], + }, page_blocks=[], page_height=1700) + assert result.role == "paper_title"