lllin000_PaperForge/scripts/dev/caption_pattern_analysis.py

383 lines
13 KiB
Python
Raw Normal View History

fix: appendix numbering cross-cutting bugs (TABLE regex, int block_id, table alpha prefix, weak-caption tie-break) 3 distinct bugs found and fixed: 1. TABLE in figure regex (_FIGURE_NUMBER_PATTERN) Removed TABLE/Table from figure regex — caused table captions to be extracted as figure numbers and roadmapped into figure inventory. Added unit tests (table_numeric_caption_is_not_a_figure_number, table_appendix_caption_marker_has_no_figure_number). 2. int block_id type mismatch in cross-page lookup CrossPageSettlementPass and PrimarySamePagePass compared block_id with === but deduped_legends use int keys while ResourceRef stores str. Fixed by casting both to str. Table continuation lookup missed page filter, picking wrong page's same block_id. Added page filter + int_block_id tests. 3. Table appendix support gaps - Table prefix regex only accepted digits/roman, not 'TABLE A1'. Added [A-Z]\d+ token and strip-leading-alpha in parse. - _is_validation_first_table_candidate didn't cover figure_title raw_label blocks with table_caption_like style. Added second gate. - _is_weak_explicit_table_caption only checked table_caption roles. Extended to include validation-first candidates. - Same-page tie-break didn't apply for weak-explicit captions. Ported _bare_table_tie_break into vnext pass. - figure_caption_candidate not excluded from note attachment. - Continuation merge stripped leading duplicate table marker. M84CTEM9 vault verification: 6/6 figures matched (3 main + 3 appendix + assets), 4/4 tables (Table 1,2 + Table A1,A2 + assets), 0 false positives, 0 figure asset leakage.
2026-07-03 17:29:06 +00:00
"""
Scan all 739 papers in the ocr vault for figure caption patterns.
Count each format, cross-reference against figure-map.json inventory,
and report matched/unmatched rates per pattern.
Output: scripts/dev/caption_pattern_analysis.json
"""
import json
import re
import sys
import traceback
from pathlib import Path
from collections import defaultdict, Counter
OCR_ROOT = Path("D:/L/OB/Literature-hub/System/PaperForge/ocr")
# Patterns for classification
# Order matters: more specific patterns first
PATTERNS = [
# ---- Standard numeric ----
("figure_n_numeric", re.compile(
r'\b(?:Figure|Fig\.?)\s+(\d+)\b', re.IGNORECASE
)),
# ---- Supplementary patterns ----
("figure_s_numeric", re.compile(
r'\b(?:Figure|Fig\.?)\s+S(\d+)\b', re.IGNORECASE
)),
("suppl_figure", re.compile(
r'\b(?:Supplementary|Supplemental|Suppl\.?)\s+(?:Figure|Fig\.?)\s*(\d*)\b', re.IGNORECASE
)),
# ---- Appendix patterns ----
("figure_appendix_letter_number", re.compile(
r'\b(?:Figure|Fig\.?)\s+([A-Z])(\d+)\b'
)),
("appendix_figure", re.compile(
r'\b(?:Appendix|Appx\.?|App\.?)\s+(?:Figure|Fig\.?)\s*([A-Z]?\d*)\b', re.IGNORECASE
)),
# ---- Letter-only (no number) ----
("figure_letter_only", re.compile(
r'\b(?:Figure|Fig\.?)\s+([A-Z])\b(?!\s*\d)'
)),
# ---- Figure with alpha suffix (e.g. Figure 1A, Figure 1a) ----
("figure_numeric_alpha", re.compile(
r'\b(?:Figure|Fig\.?)\s+(\d+)([A-Za-z])\b', re.IGNORECASE
)),
# ---- Box (not exactly a figure but some papers use it) ----
("box_pattern", re.compile(
r'\bBox\s+(\d+)\b', re.IGNORECASE
)),
]
# We'll classify captions into these categories
CATEGORIES = [
"standard_numeric", # Figure 1, Fig. 1, Fig 1
"supplementary", # Figure S1, Supplementary Figure 1, Suppl. Fig 1
"appendix", # Figure A1, Appendix Figure 1
"letter_only", # Figure A, Fig. B
"numeric_alpha", # Figure 1A, Figure 2b
"box", # Box 1
"unclassified", # No figure pattern matched
]
def classify_caption(text):
"""Classify a caption text into one of the categories."""
if not text or not text.strip():
return "unclassified", None
stripped = text.strip()
# Try patterns in order; first match wins
for cat, pat in PATTERNS:
m = pat.search(stripped)
if m:
if cat == "figure_n_numeric":
return "standard_numeric", m.group(1)
elif cat in ("figure_s_numeric", "suppl_figure"):
return "supplementary", m.group(0)
elif cat in ("figure_appendix_letter_number", "appendix_figure"):
return "appendix", m.group(0)
elif cat == "figure_letter_only":
return "letter_only", m.group(1)
elif cat == "figure_numeric_alpha":
return "numeric_alpha", m.group(0)
elif cat == "box_pattern":
return "box", m.group(0)
return "unclassified", None
def extract_figure_number(text):
"""Extract best-effort figure label (e.g. '1', 'S1', 'A1', 'A') from text."""
if not text:
return None
stripped = text.strip()
# Figure S1 or Supplementary Figure 1
m = re.search(r'\b(?:Figure|Fig\.?)\s+S(\d+)\b', stripped, re.IGNORECASE)
if m:
return f"S{m.group(1)}"
m = re.search(r'\b(?:Supplementary|Supplemental|Suppl\.?)\s+(?:Figure|Fig\.?)\s*(\d+)', stripped, re.IGNORECASE)
if m:
return f"S{m.group(1)}"
# Figure A1 (letter + number)
m = re.search(r'\b(?:Figure|Fig\.?)\s+([A-Z])(\d+)\b', stripped)
if m:
return f"{m.group(1)}{m.group(2)}"
# Figure 1A (number + alpha suffix)
m = re.search(r'\b(?:Figure|Fig\.?)\s+(\d+)([A-Za-z])\b', stripped, re.IGNORECASE)
if m:
return f"{m.group(1)}{m.group(2).upper()}"
# Figure N (plain number)
m = re.search(r'\b(?:Figure|Fig\.?)\s+(\d+)\b', stripped, re.IGNORECASE)
if m:
return m.group(1)
# Letter only
m = re.search(r'\b(?:Figure|Fig\.?)\s+([A-Z])\b(?!\s*\d)', stripped)
if m:
return m.group(1)
return None
def load_figure_map(paper_dir):
"""Load figure-map.json if it exists. Returns list of figure entries or None."""
figmap_path = paper_dir / "figure-map.json"
if not figmap_path.exists():
return None
try:
with open(figmap_path, "r", encoding="utf-8") as f:
data = json.load(f)
entries = []
for fig in data.get("figures", []):
entries.append({
"number": fig.get("number"),
"label": fig.get("label", ""),
"type": fig.get("type", "main_figure"),
})
for fig in data.get("supplementary_figures", []):
entries.append({
"number": fig.get("number"),
"label": fig.get("label", ""),
"type": "supplementary_figure",
})
return entries
except Exception:
return None
def figure_map_contains(fig_map_entries, fig_number):
"""Check if a figure number exists in the figure map entries."""
if not fig_map_entries or not fig_number:
return False
for entry in fig_map_entries:
if entry["number"] == fig_number:
return True
# Also match by label
label_num = re.search(r'(\d+|[A-Z]\d*)$', entry.get("label", ""))
if label_num and label_num.group(1) == fig_number:
return True
return False
def process_paper(paper_dir):
"""Process a single paper's blocks.structured.jsonl."""
blocks_path = paper_dir / "structure" / "blocks.structured.jsonl"
if not blocks_path.exists():
return None
paper_id = paper_dir.name
captions = []
fig_map = load_figure_map(paper_dir)
try:
with open(blocks_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
block = json.loads(line)
except json.JSONDecodeError:
continue
role = block.get("role")
if role not in ("figure_caption", "figure_caption_candidate"):
continue
text = block.get("text", "")
category, matched_pattern = classify_caption(text)
fig_number = extract_figure_number(text)
in_inventory = figure_map_contains(fig_map, fig_number) if fig_map else None
captions.append({
"paper_id": paper_id,
"role": role,
"text_preview": text[:120],
"category": category,
"fig_number": fig_number,
"fig_map_exists": fig_map is not None,
"in_inventory": in_inventory,
})
except Exception as e:
print(f" [WARN] Error reading {paper_id}: {e}", file=sys.stderr)
return None
return captions
def main():
paper_dirs = sorted(OCR_ROOT.glob("*/structure/blocks.structured.jsonl"))
total_papers = len(paper_dirs)
print(f"Found {total_papers} papers with blocks.structured.jsonl", file=sys.stderr)
all_captions = []
papers_with_captions = 0
papers_with_map = 0
papers_with_both = 0
errors = 0
for i, blocks_path in enumerate(paper_dirs):
paper_dir = blocks_path.parent.parent
paper_id = paper_dir.name
if (i + 1) % 50 == 0:
print(f" Progress: {i+1}/{total_papers}...", file=sys.stderr)
result = process_paper(paper_dir)
if result is None:
errors += 1
continue
if result:
papers_with_captions += 1
all_captions.extend(result)
# Check if figure-map exists
fig_map_path = paper_dir / "figure-map.json"
if fig_map_path.exists():
papers_with_map += 1
print(f"\nProcessed: {total_papers} papers", file=sys.stderr)
print(f" Papers with captions: {papers_with_captions}", file=sys.stderr)
print(f" Papers with figure-map.json: {papers_with_map}", file=sys.stderr)
print(f" Total caption blocks: {len(all_captions)}", file=sys.stderr)
print(f" Errors: {errors}", file=sys.stderr)
# ---- Analysis ----
# 1. Distribution by category
cat_counts = Counter(c["category"] for c in all_captions)
cat_with_map = Counter(c["category"] for c in all_captions if c["fig_map_exists"])
# 2. Per-category: matched vs unmatched in inventory
# Only count captions from papers that HAVE a figure-map
cat_matched = Counter()
cat_unmatched = Counter()
cat_without_map = Counter()
for c in all_captions:
cat = c["category"]
if c["fig_map_exists"]:
if c["in_inventory"] is True:
cat_matched[cat] += 1
elif c["in_inventory"] is False:
cat_unmatched[cat] += 1
else:
cat_without_map[cat] += 1 # shouldn't happen since fig_map_exists is True
else:
cat_without_map[cat] += 1
# 3. Detailed breakdown by role
role_cat_counts = defaultdict(Counter)
for c in all_captions:
role_cat_counts[c["role"]][c["category"]] += 1
# 4. Collect examples for each pattern
examples_by_cat = defaultdict(list)
for c in all_captions:
cat = c["category"]
if len(examples_by_cat[cat]) < 5:
examples_by_cat[cat].append(c["text_preview"])
# 5. Detailed unmatched analysis
unmatched_by_cat = defaultdict(list)
for c in all_captions:
if c["fig_map_exists"] and c["in_inventory"] is False:
unmatched_by_cat[c["category"]].append({
"paper_id": c["paper_id"],
"text_preview": c["text_preview"],
"fig_number": c["fig_number"],
})
# Build result
total_with_map = sum(cat_counts.get(c, 0) for c in CATEGORIES if c in cat_matched or c in cat_unmatched)
distribution = {}
for cat in CATEGORIES:
total = cat_counts.get(cat, 0)
matched = cat_matched.get(cat, 0)
unmatched = cat_unmatched.get(cat, 0)
no_map = cat_without_map.get(cat, 0)
with_map = matched + unmatched
distribution[cat] = {
"total": total,
"with_figure_map": with_map,
"matched_in_inventory": matched,
"unmatched_in_inventory": unmatched,
"no_figure_map": no_map,
"unmatched_rate": round(unmatched / with_map, 4) if with_map > 0 else None,
}
result = {
"summary": {
"total_papers": total_papers,
"papers_with_captions": papers_with_captions,
"papers_with_figure_map": papers_with_map,
"total_caption_blocks": len(all_captions),
"errors": errors,
},
"caption_role_breakdown": {
role: dict(counts) for role, counts in sorted(role_cat_counts.items())
},
"pattern_distribution": distribution,
"examples_by_category": dict(examples_by_cat),
"unmatched_details": {
cat: entries[:20] for cat, entries in sorted(unmatched_by_cat.items())
if entries
},
}
output_path = Path("scripts/dev/caption_pattern_analysis.json")
with open(output_path, "w", encoding="utf-8") as f:
json.dump(result, f, indent=2, ensure_ascii=False)
print(f"\nResults saved to {output_path}", file=sys.stderr)
# ---- Print summary to stdout ----
print("\n" + "=" * 70)
print(" CAPTION PATTERN ANALYSIS RESULTS")
print("=" * 70)
print(f" Papers scanned: {total_papers}")
print(f" Papers w/ captions: {papers_with_captions}")
print(f" Papers w/ figure-map:{papers_with_map}")
print(f" Total caption blocks: {len(all_captions)}")
print(f" Errors: {errors}")
print()
# Role breakdown
print(" --- Role Breakdown ---")
for role, counts in sorted(role_cat_counts.items()):
total_role = sum(counts.values())
print(f" {role}: {total_role}")
for cat, cnt in sorted(counts.items()):
print(f" {cat}: {cnt}")
print()
# Pattern distribution
print(" --- Pattern Distribution & Unmatched Rate ---")
print(f" {'Category':<25} {'Total':>8} {'WithMap':>8} {'Matched':>8} {'Unmatched':>8} {'UnmatchRate':>12}")
print(f" {'-'*25} {'-'*8} {'-'*8} {'-'*8} {'-'*8} {'-'*12}")
for cat in CATEGORIES:
d = distribution[cat]
rate_str = f"{d['unmatched_rate']*100:.1f}%" if d['unmatched_rate'] is not None else "N/A"
print(f" {cat:<25} {d['total']:>8} {d['with_figure_map']:>8} {d['matched_in_inventory']:>8} {d['unmatched_in_inventory']:>8} {rate_str:>12}")
# Extra: unclassified examples
if examples_by_cat.get("unclassified"):
print(f"\n --- Sample Unclassified Captions (first 5) ---")
for ex in examples_by_cat["unclassified"][:5]:
print(f" \"{ex}\"")
print("=" * 70)
if __name__ == "__main__":
main()