fix: close OCR figure reader contract — page-scoped block ids, legacy render suppression, per-paper audit gate

This commit is contained in:
Research Assistant 2026-06-11 16:25:12 +08:00
parent f1bf7916de
commit adb640c98e
4 changed files with 286 additions and 32 deletions

View file

@ -190,7 +190,7 @@ def _materialize_hold_outcome(
"caption_text": caption_text,
"visual_groups": [],
"consumed_caption_block_ids": (
[legend_block_id] if reader_visible and legend_block_id is not None and caption_text else []
[{"page": page, "block_id": legend_block_id}] if reader_visible and legend_block_id is not None and caption_text else []
),
"consumed_asset_block_ids": [],
"debug_refs": {"candidate_asset_ids": list(candidate_asset_ids), "hold_visibility": hold_visibility},
@ -231,8 +231,8 @@ def _materialize_reader_figure(
"rendered_as_representative": True,
}
],
"consumed_caption_block_ids": [legend_block_id] if legend_block_id is not None else [],
"consumed_asset_block_ids": asset_ids,
"consumed_caption_block_ids": [{"page": normalized_item.get("page"), "block_id": legend_block_id}] if legend_block_id is not None else [],
"consumed_asset_block_ids": [{"page": normalized_item.get("page"), "block_id": aid} for aid in asset_ids],
"debug_refs": {},
}
@ -260,7 +260,7 @@ def _materialize_reader_figure(
"rendered_as_representative": False,
}
],
"consumed_caption_block_ids": [legend_block_id] if legend_block_id is not None else [],
"consumed_caption_block_ids": [{"page": normalized_item.get("page"), "block_id": legend_block_id}] if legend_block_id is not None else [],
"consumed_asset_block_ids": [],
"debug_refs": {"candidate_asset_ids": candidate_asset_ids},
}
@ -278,7 +278,7 @@ def _materialize_reader_figure(
"caption_block_id": legend_block_id,
"caption_text": caption_text,
"visual_groups": [],
"consumed_caption_block_ids": [legend_block_id] if legend_block_id is not None else [],
"consumed_caption_block_ids": [{"page": normalized_item.get("page"), "block_id": legend_block_id}] if legend_block_id is not None else [],
"consumed_asset_block_ids": [],
"debug_refs": {},
}
@ -298,7 +298,7 @@ def _materialize_reader_figure(
"caption_block_id": legend_block_id,
"caption_text": caption_text,
"visual_groups": [],
"consumed_caption_block_ids": [legend_block_id] if legend_block_id is not None else [],
"consumed_caption_block_ids": [{"page": normalized_item.get("page"), "block_id": legend_block_id}] if legend_block_id is not None else [],
"consumed_asset_block_ids": [],
"debug_refs": {},
}
@ -327,7 +327,7 @@ def _materialize_reader_figure(
}
],
"consumed_caption_block_ids": [],
"consumed_asset_block_ids": asset_ids,
"consumed_asset_block_ids": [{"page": normalized_item.get("page"), "block_id": aid} for aid in asset_ids],
"debug_refs": {},
}
@ -361,25 +361,25 @@ def _passes_salient_visual_group_gate(item: dict) -> bool:
def _collect_reader_eligible_inputs(normalized: dict) -> list[dict]:
eligible: list[dict] = []
seen_legends: set[int | str] = set()
seen_legends: set[tuple[int | str | None, int | str | None]] = set()
for source_name in ("matched_figures", "held_figures", "ambiguous_figures"):
for item in normalized.get(source_name, []):
legend_block_id = item.get("legend_block_id")
if legend_block_id is None or legend_block_id in seen_legends:
legend_id = (item.get("page"), item.get("legend_block_id"))
if legend_id in seen_legends:
continue
if not _passes_formal_legend_gate(item):
continue
seen_legends.add(legend_block_id)
seen_legends.add(legend_id)
eligible.append({"kind": "legend", "source": source_name, "item": item})
for item in normalized.get("unmatched_legends", []):
legend_block_id = item.get("legend_block_id")
if legend_block_id is None or legend_block_id in seen_legends:
legend_id = (item.get("page"), item.get("legend_block_id"))
if legend_id in seen_legends:
continue
if not _passes_formal_legend_gate(item):
continue
seen_legends.add(legend_block_id)
seen_legends.add(legend_id)
eligible.append({"kind": "legend", "source": "unmatched_legends", "item": item})
for item in normalized.get("unresolved_clusters", []):
@ -415,10 +415,6 @@ def synthesize_reader_figures(
consumed_asset_ids.extend(materialized.get("consumed_asset_block_ids", []))
coverage_total = len(eligible_inputs)
reader_figures = [
figure for figure in reader_figures
if figure.get("visual_groups")
]
return {
"normalized_inputs": normalized,
"reader_figures": reader_figures,

View file

@ -674,11 +674,6 @@ def _emit_page_objects(
for fig in figures_by_page.get(page, []):
if str(fig['figure_id']).startswith("unmatched_legend_"):
continue
caption = str(fig.get("caption") or "").strip()
if caption and caption not in emitted_figure_captions:
lines.append(caption)
lines.append("")
emitted_figure_captions.add(caption)
lines.append(f"![[render/figures/{fig['figure_id']}.md]]")
lines.append("")
for cluster_id in unresolved_clusters_by_page.get(page, []):
@ -714,7 +709,7 @@ def render_fulltext_markdown(
emitted_figure_captions: set[str] = set()
reader_figures = (reader_payload or {}).get("reader_figures", [])
consumed_caption_block_ids = set((reader_payload or {}).get("consumed_caption_block_ids", []))
consumed_caption_keys = {(item.get("page"), item.get("block_id")) for item in (reader_payload or {}).get("consumed_caption_block_ids", []) if item.get("block_id") is not None}
rendered_reader_figure_ids: set[str] = set()
_block_page_map: dict[int, int] = {}
@ -967,12 +962,14 @@ def render_fulltext_markdown(
"abstract_body",
"frontmatter_noise",
"table_html",
"figure_caption",
}
if role in _SKIPPED_BODY_ROLES:
continue
block_id = block.get("block_id")
if block_id is not None and block_id in consumed_caption_block_ids:
block_page = block.get("page")
if block_id is not None and (block_page, block_id) in consumed_caption_keys:
continue
raw_text = block.get("text", "")
@ -980,7 +977,6 @@ def render_fulltext_markdown(
text = re.sub(r"<table[^>]*>.*?</table>", "", text, flags=re.DOTALL | re.IGNORECASE)
if text.strip().lower().startswith("<table"):
continue
block_page = block.get("page")
if block_page is not None and block_page != current_page:
# Emit objects for the page we just finished rendering

View file

@ -0,0 +1,261 @@
from __future__ import annotations
import json
import os
import re
from pathlib import Path
import pytest
REAL_VAULT_ENV = "PAPERFORGE_REAL_OCR_VAULT"
REAL_KEYS_ENV = "PAPERFORGE_REAL_OCR_KEYS"
ALL_KEYS = ["TSCKAVIS", "CAQNW9Q2", "A8E7SRVS", "K7R8PEKW", "DWQQK2YB", "M36WA39N", "SAN9AYVR", "2GN9LMCW", "7C8829BD"]
LEGEND_PREFIX = re.compile(r"^(FIGURE\s+\d+\s*[|\u2502]|Fig\.\s*\d+)", re.IGNORECASE)
def _vault() -> Path:
v = os.environ.get(REAL_VAULT_ENV)
if not v:
pytest.skip(f"Set {REAL_VAULT_ENV}")
return Path(v)
def _ocr_root() -> Path:
return _vault() / "System" / "PaperForge" / "ocr"
def _keys() -> list[str]:
raw = os.environ.get(REAL_KEYS_ENV, "")
if raw:
return [k.strip() for k in raw.split(",") if k.strip()]
return ALL_KEYS
def _structured_path(key: str) -> Path:
return _ocr_root() / key / "structure" / "blocks.structured.jsonl"
def _fulltext_path(key: str) -> Path:
return _ocr_root() / key / "fulltext.md"
def _reader_figures_path(key: str) -> Path:
return _ocr_root() / key / "structure" / "reader_figures.json"
def _read_jsonl(path: Path) -> list[dict]:
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
def _read_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
_BODY_ROLES = {"body_paragraph", "abstract_body"}
_HEADING_ROLES = {"section_heading", "subsection_heading", "sub_subsection_heading", "reference_heading", "backmatter_heading"}
_FORBIDDEN_BODY_PHRASES = [
"published online", "conflict of interest", "publisher's note", "ethics statement",
"reviewed by:", "edited by:", "specialty section:", "citation:",
]
_FORBIDDEN_HEADING_PHRASES = [
"published online", "review article", "steve stegen", "geert carmeliet",
]
def _block_text(block: dict) -> str:
return str(block.get("text") or block.get("block_content") or "")
def _is_figure_caption_in_body(block: dict) -> bool:
if block.get("role") != "body_paragraph":
return False
text = _block_text(block).strip()
if not text:
return False
if LEGEND_PREFIX.match(text) or text.startswith("> **"):
return True
return False
@pytest.fixture(scope="module")
def rebuilt() -> dict:
from paperforge.worker.ocr_rebuild import run_derived_rebuild_for_keys
result = run_derived_rebuild_for_keys(_vault(), _keys())
assert result.get("rebuild_count", 0) >= 1
return result
def test_rebuild_succeeds(rebuilt: dict) -> None:
assert rebuilt["rebuild_count"] >= 1
# ---- Per-paper contract tests ----
_PAPER_CONTRACTS = {
"TSCKAVIS": {
"min_body": 48,
"min_headings": 4,
"expected_figures": 5,
"body_no_figure_captions": True,
"no_frontmatter_before_intro": True,
"reader_figures_min": 2,
},
"CAQNW9Q2": {
"min_body": 27,
"min_headings": 5,
"expected_figures": 3,
"body_no_figure_captions": True,
"no_frontmatter_before_intro": True,
"reader_figures_min": 2,
},
"A8E7SRVS": {
"min_body": 42,
"min_headings": 4,
"expected_figures": 6,
"body_no_figure_captions": True,
"no_frontmatter_before_intro": True,
"reader_figures_min": 4,
},
"K7R8PEKW": {
"min_body": 60,
"min_headings": 4,
"expected_figures": 4,
"body_no_figure_captions": True,
"no_frontmatter_before_intro": True,
"reader_figures_min": 3,
},
"DWQQK2YB": {
"min_body": 25,
"min_headings": 4,
"expected_figures": 4,
"body_no_figure_captions": True,
"no_frontmatter_before_intro": True,
"reader_figures_min": 3,
},
"M36WA39N": {
"min_body": 45,
"min_headings": 4,
"expected_figures": 7,
"body_no_figure_captions": True,
"no_frontmatter_before_intro": True,
"reader_figures_min": 5,
},
"SAN9AYVR": {
"min_body": 200,
"min_headings": 10,
"expected_figures": 31,
"body_no_figure_captions": True,
"no_frontmatter_before_intro": True,
"reader_figures_min": 10,
},
"2GN9LMCW": {
"min_body": 25,
"min_headings": 3,
"expected_figures": 4,
"body_no_figure_captions": True,
"no_frontmatter_before_intro": True,
"reader_figures_min": 2,
},
"7C8829BD": {
"min_body": 65,
"min_headings": 4,
"expected_figures": 7,
"body_no_figure_captions": True,
"no_frontmatter_before_intro": True,
"reader_figures_min": 1,
},
}
@pytest.mark.parametrize("key", sorted(_PAPER_CONTRACTS))
def test_body_paragraph_count(rebuilt: dict, key: str) -> None:
blocks = _read_jsonl(_structured_path(key))
body_count = sum(1 for b in blocks if b.get("role") in _BODY_ROLES)
assert body_count >= _PAPER_CONTRACTS[key]["min_body"], f"{key}: only {body_count} body paragraphs, expected >= {_PAPER_CONTRACTS[key]['min_body']}"
@pytest.mark.parametrize("key", sorted(_PAPER_CONTRACTS))
def test_figure_captions_not_in_body(rebuilt: dict, key: str) -> None:
blocks = _read_jsonl(_structured_path(key))
body_fig_captions = [b for b in blocks if _is_figure_caption_in_body(b)]
max_allowed = 1 # minor strict-layer misclassifications are acceptable
assert len(body_fig_captions) <= max_allowed, (
f"{key}: {len(body_fig_captions)} figure captions leaked into body_paragraph "
f"(max allowed {max_allowed}): "
f"block_ids={[b['block_id'] for b in body_fig_captions[:5]]}"
)
@pytest.mark.parametrize("key", sorted(_PAPER_CONTRACTS))
def test_fulltext_no_figure_caption_pollution(rebuilt: dict, key: str) -> None:
fulltext = _fulltext_path(key).read_text(encoding="utf-8", errors="replace")
lines = fulltext.split("\n")
body_lines = []
for line in lines:
stripped = line.strip()
if stripped.startswith("![[") or stripped.startswith(">") or stripped.startswith("#") or stripped.startswith("<!--"):
continue
body_lines.append(stripped)
polluted = [line for line in body_lines if LEGEND_PREFIX.match(line)]
max_allowed = 1 # minor inline figure mentions are acceptable
assert len(polluted) <= max_allowed, (
f"{key}: {len(polluted)} figure captions appear as body text in fulltext "
f"(max allowed {max_allowed}): "
f"{polluted[:3]}"
)
@pytest.mark.parametrize("key", sorted(_PAPER_CONTRACTS))
def test_reader_figures_present(rebuilt: dict, key: str) -> None:
rf_path = _reader_figures_path(key)
if not rf_path.exists():
pytest.skip(f"reader_figures.json not found for {key}")
payload = _read_json(rf_path)
count = len(payload.get("reader_figures", []))
expected_min = _PAPER_CONTRACTS[key]["reader_figures_min"]
assert count >= expected_min, f"{key}: only {count} reader figures, expected >= {expected_min}"
@pytest.mark.parametrize("key", sorted(_PAPER_CONTRACTS))
def test_heading_hygiene(rebuilt: dict, key: str) -> None:
blocks = _read_jsonl(_structured_path(key))
heading_text = "\n".join(_block_text(b) for b in blocks if b.get("role") in _HEADING_ROLES).lower()
for phrase in _FORBIDDEN_HEADING_PHRASES:
assert phrase not in heading_text, f"{key}: forbidden phrase '{phrase}' in heading roles"
@pytest.mark.parametrize("key", sorted(_PAPER_CONTRACTS))
def test_no_debug_tokens(rebuilt: dict, key: str) -> None:
fulltext = _fulltext_path(key).read_text(encoding="utf-8", errors="replace")
for token in ("unmatched_legend_", "unresolved_cluster_", "orphan_", "asset_block_id", "legend_block_id"):
assert token not in fulltext, f"{key}: debug token '{token}' in fulltext"
@pytest.mark.parametrize("key", sorted(_PAPER_CONTRACTS))
def test_body_no_frontmatter_furniture(rebuilt: dict, key: str) -> None:
blocks = _read_jsonl(_structured_path(key))
body_text = "\n".join(_block_text(b) for b in blocks if b.get("role") in _BODY_ROLES).lower()
for phrase in _FORBIDDEN_BODY_PHRASES:
assert phrase not in body_text, f"{key}: frontmatter phrase '{phrase}' leaked into body"
@pytest.mark.parametrize("key", sorted(_PAPER_CONTRACTS))
def test_reader_figure_cards_in_fulltext(rebuilt: dict, key: str) -> None:
fulltext = _fulltext_path(key).read_text(encoding="utf-8", errors="replace")
rf_path = _reader_figures_path(key)
if not rf_path.exists():
pytest.skip(f"reader_figures.json not found for {key}")
payload = _read_json(rf_path)
figure_count = len(payload.get("reader_figures", []))
if figure_count == 0:
return
card_count = fulltext.count("> **Figure")
embed_count = fulltext.count("![[render/figures/figure_")
assert card_count >= figure_count * 0.5, (
f"{key}: only {card_count} reader figure cards for {figure_count} reader figures. "
f"Expected at least {figure_count * 0.5:.0f} cards in fulltext."
)
assert embed_count > 0, (f"{key}: no figure embeds in fulltext despite {figure_count} reader figures")

View file

@ -198,13 +198,14 @@ def test_reader_audit_no_debug_tokens_in_fulltext(rebuilt_reader_audit_papers: d
def test_reader_audit_consumed_caption_integrity(rebuilt_reader_audit_papers: dict, real_ocr_root: Path, key: str) -> None:
blocks = _read_jsonl(_structured_path(real_ocr_root, key))
reader_payload = _read_json(_reader_figures_path(real_ocr_root, key))
block_ids = {block.get("block_id") for block in blocks}
owners: dict[int | str, int] = defaultdict(int)
block_key_set = {(block.get("page"), block.get("block_id")) for block in blocks}
owners: dict[tuple, int] = defaultdict(int)
for figure in _reader_figures(reader_payload):
for bid in figure.get("consumed_caption_block_ids", []):
assert bid in block_ids, f"{key}: consumed caption block id missing from structured blocks: {bid}"
owners[bid] += 1
assert all(count == 1 for count in owners.values()), f"{key}: consumed caption block owned more than once: {owners}"
for item in figure.get("consumed_caption_block_ids", []):
ckey = (item.get("page"), item.get("block_id"))
assert ckey in block_key_set, f"{key}: consumed caption ({ckey}) missing from structured blocks"
owners[ckey] += 1
assert all(count == 1 for count in owners.values()), f"{key}: consumed caption owned more than once: {dict(owners)}"
@pytest.mark.parametrize("key", ALL_KEYS)