diff --git a/PROJECT-MANAGEMENT.md b/PROJECT-MANAGEMENT.md index 599c2e3b..f50f1bf4 100644 --- a/PROJECT-MANAGEMENT.md +++ b/PROJECT-MANAGEMENT.md @@ -1,6 +1,6 @@ # OCR-v2 Project Management Log -> **Branch:** `ocr-v2` | **Base:** `master` | **Last Updated:** 2026-06-25 (font-size heading level + P0 gaps recorded) +> **Branch:** `ocr-v2` | **Base:** `master` | **Last Updated:** 2026-06-27 (full rebuild audit + 5-fix spec deep investigation) > **Rule:** Every step is documented with: What was done, Why it was done, What comes next. --- @@ -2329,19 +2329,260 @@ Pending papers (no PDF / OCR queued): `ocr run` should be executed for JQMRCEXY, --- -## 15. Remaining Known Issues (updated 2026-06-26) +## 15. Remaining Known Issues (updated 2026-06-27) -Three P0 gaps + five items from post-rebuild audit: +### P0 (ready to commit — fixes designed, 3 independent changes) +1. ~~Footnote missing from `_SKIPPED_BODY_ROLES`~~ → already handled by code, stale entry +2. ~~Short "Table N" caption matching absent~~ → existing code handles it, stale entry +3. ~~Table caption fallback `###` should be `**`~~ → existing code uses `**`, stale entry +4. **Fix 2: Reference sorting bracket gap** — `_ref_number_sort_key` (ocr_render.py:481) regex `r"^\s*(\d+)[\.\)]"` doesn't match `[N]` bracket format. References like `[10]` fall through to lexicographic sort. Fix: add second capture group for brackets. Covers all 3 call sites (lines 524, 672, 864). **~5 lines total.** +5. **Fix 4: figure_caption → non_body_insert** — `_detect_non_body_insert_clusters()` (ocr_document.py:3846) lists `figure_caption` and `figure_caption_candidate` in `_INSERT_CANDIDATE_ROLES`, catching genuine narrow figure captions on early pages. YGH7VEX6 Figure 2 (width 442 < 0.9×507 median) was swallowed. **Fix: remove both from the set. ~2 lines.** +6. **Fix 5: Demoted body_paragraph re-enters figure legends** — `_is_validation_first_legend_candidate` (ocr_figures.py:405) uses `zone="display_zone"` + `style_family="legend_like"` proxy signals, ignoring that pipeline already demoted the block to `body_paragraph`. YGH7VEX6 Figure 11: body p6:7 matched assets via adjacent_x; real caption p6:13 demoted to `figure_s011`. **Fix: filter demoted body_paragraph from legends before validation candidate check. ~10 lines + tests.** -### P0 (carried forward) -1. Footnote missing from `_SKIPPED_BODY_ROLES` -2. Short "Table N" caption matching absent -3. Table caption fallback `###` should be `**` - -### P1 -4. **`resolve_pdf_path` lacks glob fallback for storage URIs** — when BBT-specified filename (correct UTF-8) doesn't match garbled NTFS filename (Chinese Win encoding double-pass), no fallback to `glob("storage/KEY/*.pdf")`. Causes `source_pdf` to store damaged filenames. +### P1 (separate spec/commit needed) +7. **Fix 1: Figure-internal text containment** — `_bind_inner_text_to_figures()` was proposed in design doc but never implemented. Current detection is regex (`_PANEL_LABEL_PATTERN` matching `A`, `(B)`) + proximity heuristic (80px to image blocks). No containment check exists. XD2BPCMG page 3: 21 text blocks entirely inside composite Figure 1 bbox, all misclassified (16→footnote, rest→heading/body). Needs: `_bbox_contains()` integration + matched_figures cluster_bbox + render-hygiene pass. **Needs separate spec.** +8. **Fix 3: Backmatter boundary recognition** — CRediT/Ethics headings not recognized because `_is_backmatter_boundary_heading()` (ocr_roles.py:303) requires `is_visually_heading` (11pt threshold, CRediT=7.97pt) OR `has_container_words` (no ADDITIONAL/DECLARATION/INFORMATION in CRediT). `_normalize_backmatter_roles_after_boundary` explicitly skips `subsection_heading`. 25K5KZAQ pages 9-10: CRediT/Ethics/Declaration as body_paragraph. **Needs separate spec.** ### P2 (deferred) -5. **Health model over-penalizes short-form papers** — `page_count <= 3` papers with no headings/abstract get `red` even when content is correct. -6. **Pre-proof / Accepted Manuscript layout template missing** — pipeline doesn't handle `Accepted Manuscript` cover pages and non-standard frontmatter. +9. **`resolve_pdf_path` lacks glob fallback for storage URIs** — when BBT-specified filename (correct UTF-8) doesn't match garbled NTFS filename (Chinese Win encoding double-pass), no fallback to `glob("storage/KEY/*.pdf")`. Causes `source_pdf` to store damaged filenames. +10. **Health model over-penalizes short-form papers** — `page_count <= 3` papers with no headings/abstract get `red` even when content is correct. +11. **Pre-proof / Accepted Manuscript layout template missing** — pipeline doesn't handle `Accepted Manuscript` cover pages and non-standard frontmatter. + +--- + +## 16. UI Polish Session (2026-06-26) + +### 16.1 Completed + +| Change | Files | Commit | +|--------|-------|--------| +| Timezone fix: `_fmt_iso` converts UTC → local via `astimezone()` | `ocr_maintenance.py` | `28536a6` | +| Rebuild timestamp: `rebuild_finished_at` written on rebuild | `ocr_rebuild.py` | `28536a6` | +| Title tooltip: `title_full` field in JSON, `setTooltip` API | `ocr_maintenance.py`, `settings.ts` | `28536a6` | +| Table sorted by time descending | `settings.ts` | `16feeb7` | +| Vercel CSS polish for Settings tab (description boxes, collapsible headers, status rows, embed section, vector config) | `styles.css`, `settings.ts` | `28536a6`, `8357590` | +| Dashboard CSS polish (snapshot pills, status grid, issue summary, funnel, technical details) | `styles.css` | `8357590` | +| Click-to-copy on title (dashed underline removed later) | `dashboard.ts` | `8357590` | +| Global "Start Working" cleanup: removed Doctor/Repair/Refresh (Doctor/Repair only in Issues section) | `dashboard.ts` | `111e2af` | +| Run OCR button shows pending count | `dashboard.ts` | (pending commit) | +| Redo OCR → Maintenance... button (opens settings→maintenance tab) | `dashboard.ts` | (pending commit) | +| Collection header white box reverted | `styles.css` | (pending commit) | +| pf-copy dashed underline removed | `styles.css` | (pending commit) | +| DESIGN.md (Vercel design reference) copied into repo | new file `plugin/docs/design/DESIGN.md` | `28536a6` | + +### 16.2 Decisions for Next Session + +1. **Collection search bar** — add input below Collection actions, filters current domain papers by title/author/key, click result → Paper page. Not implemented yet. +2. **Paper page: unfold technical details** — remove the collapsible toggle, show paths and health info directly below header. Agreed but not implemented. +3. **Paper page: show Key/DOI upfront** — visible without expanding, for easy copy. Agreed but not implemented. +4. **Redo OCR button removed post ocr-v2 merge** — Redo moves to settings maintenance tab; defer until ocr-v2 merge completes. +5. **Collection quick action: OCR batch run with pending count** — Done (Run OCR (N)). +6. **Global Issues section shows Doctor/Repair only when issues exist** — Already designed this way, works. + +### 16.3 Parked + +- No further UI changes requested by user before building search bar. + +--- + +## 17. Rebuild Audit + Index Repair Session (2026-06-27) + +### 17.1 Full Literature-hub Rebuild Audit + +Post-rebuild audit of `D:\L\OB\Literature-hub` (839 papers) revealed: + +| Issue | Severity | Status | +|-------|----------|--------| +| `sync_service.py` — `_time` import scoped inside `if not index_only:`, used outside → UnboundLocalError | HARD BLOCK | FIXED | +| Workspace fulltext never synced from OCR output (asset_index.py delete stale but no copy) | HIGH | FIXED | +| `build_index` early-return skips `_build_entry` entirely when export_hash matches — workspace fulltext never copied | HIGH | FIXED | +| Field registry missing 9 common frontmatter fields (aliases, tags, journal, first_author, pmid, impact_factor, abstract, keywords, ocr_time) — 6000+ WARN noise in doctor | MEDIUM | FIXED | +| `ocr_health` query: field exists at `entry["health"]["ocr_health"]`, was queried as `entry["ocr_health"]` | FALSE ALARM | N/A | +| "700 papers missing fulltext.md in workspace" — after fix, all 696 OCR-done papers have workspace fulltext | MISLEADING | FIXED | +| 3 Base files with legacy has_pdf/do_ocr columns — template itself uses them, user choice to add lifecycle | INFO | DEFERRED | +| P0/P1/P2 in §15 all stale (footnote in _SKIPPED_BODY_ROLES, "Table N" matching, caption fallback — all addressed in code) | DOC ROT | NOTED | + +### 17.2 Changes Made + +| File | Change | +|------|--------| +| `paperforge/services/sync_service.py` | Moved `import time as _time` from inside `if not index_only:` to function scope | +| `paperforge/worker/asset_index.py` | Added `shutil.copy2` after deleting stale workspace fulltext; added `_sync_workspace_fulltexts()` call in early-return path | +| `paperforge/schema/field_registry.yaml` | Added `aliases`, `tags`, `journal`, `first_author`, `pmid`, `impact_factor`, `abstract`, `keywords`, `ocr_time` under `frontmatter` | + +### 17.3 Remaining + +- 137 pending OCR papers + 2 failed — need `paperforge ocr` queue +- 4 papers eligible for redo +- `base-refresh --force` would upgrade Base templates but overwrite user view customizations — deferred to user +- PROJECT-MANAGEMENT §15 P0/P1/P2 entries stale — should be cleaned up next PM pass + +--- + +## 18. 2026-06-27: Deep Investigation — 5 Fix Spec Root Causes + +> **Branch:** `ocr-v2` | **Session:** Full vault rebuild audit + 5-paper vision audit + deep code-path investigations +> **Active queue:** P0 execution pending (Fix 2, Fix 4, Fix 5) + +### 18.1 Vault Survey (80 papers, 1133 pages) + +Random audit of Literature-hub OCR output: + +| Metric | Value | +|--------|-------| +| Single-column papers | 42.9% | +| Two-column papers | 56.8% | +| Mixed-tail pages | 0.3% | +| Avg pages per paper | 14.2 | +| Avg matched figures | 5.8 | +| Avg tables per paper | 1.6 | +| Papers with ≥4× asset/legend gap | 21.5% (false alarm — multi-asset figures render correctly) | +| Backmatter form | 100% flat (no container/PeerJ-style) | + +### 18.2 Vision Truth Audits (5 papers) + +**25K5KZAQ** (Bioactive Materials, 11p) — Backmatter boundary failure: +- CRediT/Ethics headings → `subsection_heading` not `backmatter_heading` +- All Declaration blocks → `body_zone` because above References heading +- Figure matching correct + +**NC66N4Q3** (Radiographic Atlas, 56p) — Figure-dense + same-page boundary: +- Pages 4-55: pure figures, body ends page 3 +- Page 56: 2-column with Discussion left + References right → boundary fails + +**9TW98JH8** (Acta Orthop Scand, 5p) — Clean audit: +- Mixed-tail page 5 correctly classified +- 20/20 blocks correct + +**YGH7VEX6** (Macromolecular Research, 8p) — 3 figure caption failure modes: +- 7 ambiguous figures across 3 failure modes +- Page 1: 5 frontmatter role errors (author/affiliation swap) +- Figure 2: caption → `non_body_insert` (width 442 < 0.9×507 threshold) +- Figure 11: body text matched via adjacent_x, real caption demoted to `figure_s011` + +**XD2BPCMG** (Cartilage Review, 26p) — Composite figure text leakage: +- 33+ figure-internal labels → `footnote` +- Page 3: 21 text blocks spatially inside Figure 1 bbox, all misclassified +- Panel labels like `(b) Type of cartilage` → `##` headings +- `1 | INTRODUCTION` pipe pattern not in `_HEADING_NUMBER_PATTERN` + +### 18.3 Fix 1: Figure-Internal Text Containment (P1 → P1 ready) + +**Status:** Spec finalized 2026-06-27 — §3.2/§3.3 updated to match §3.7 contracts. Plan-ready. +**Spec:** `docs/superpowers/specs/2026-06-27-figure-containment-and-backmatter-boundary-design.md` §3 + +**Key design decisions in spec:** +- **A:** Call site after `build_figure_inventory`, before `render_fulltext_markdown` — NOT inside `normalize_document_structure` +- **B:** Fallback asset eligibility includes `media_asset` with figure-like family, not only `figure_asset` +- **C:** Fallback assets exclude those already covered by `matched_figures.asset_block_ids` + dedup with `_highly_overlaps_any_matched_region` +- **D:** Proximity clustering (`_cluster_bboxes_by_proximity`, margin=40), not strict overlap +- **E:** Containment = center-inside + overlap/block_area >= 0.85 + width ≤ region_width*0.95 + +**Render behavior:** Renderer skips `figure_inner_text` by role. Do not mutate `render_default`/`index_default` in first release. + +**Previously recorded:** +**Location:** `ocr_roles.py:78` (_PANEL_LABEL_PATTERN), `ocr_roles.py:221` (_looks_like_figure_inner_label) +**Root cause:** Three gaps: +- Gap A: Figure 1 caption demoted → no matched_figures entry → no cluster_bbox for containment check +- Gap B: Inner-text detection only fires for `raw_label=="text"` — figure-internal blocks have `vision_footnote`/`paragraph_title`/`figure_title` raw_labels +- Gap C: No spatial containment logic anywhere (`_bbox_contains` exists in ocr.py but is never used for figure_inner_text) +- Gap D: `vision_footnote` blocks have no inner-figure pathway + +**Existing utilities:** +- `_PANEL_LABEL_PATTERN` — matches `A`, `(B)`, `C.` (single-letter only) +- `_looks_like_figure_inner_label` — 12-char limit, 80px proximity to image/chart +- `_bbox_contains(outer, inner, margin=12)` — in ocr.py:833, unused for this purpose +- `media_clusters()` / `_cluster_bbox()` — compute union bbox of asset groups + +**Fix proposal:** Add render-hygiene pass after `build_figure_inventory`: for each matched_figures entry, use cluster_bbox to check all text blocks inside it. Assign `figure_inner_text` to any non-figure-role block fully contained. Need to handle composite figures with caption demoted upstream. + +### 18.4 Fix 2: Reference Sorting Bracket Gap (P0) + +**Root cause:** `_ref_number_sort_key` (ocr_render.py:481) regex `r"^\s*(\d+)[\.\)]"` doesn't match `[N]` bracket format. References like `[10]` fall through to `return (1, text)` — full-text lexicographic sort → `[10]...[19][20]...[29][2]...[9]`. + +**Why multi-column fix didn't catch it:** That fix targeted page/section ordering, not within-reference numbering. `_ref_number_sort_key` was used as the fallback sort with wrong key function. + +**Call chain:** +1. `render_fulltext_markdown()` → `_order_tail_blocks()` → `_reorder_tail_run()` +2. `skip_section_grouping` path: `refs.sort(key=_ref_number_sort_key)` (line 524) +3. Main path: `ref_section["bodies"].sort(key=...)` (line 672) +4. Fallback: `ref_items.sort(key=...)` (line 864) + +**Existing correct function:** `parse_reference_number()` (ocr.py:607) handles both `N.` and `[N]` formats — but `ocr_render.py` never imports from `ocr.py`. They are independent modules. + +**Fix:** Add second capture group to regex: `r"^\s*(?:(\d+)[\.\)]|\[(\d+)\])"` — use `m.lastindex` to pick the right group. Two capture groups avoids false matches on plain year numbers. + +### 18.5 Fix 3: Backmatter Boundary Recognition (P1 → needs redesign) + +**Status:** Spec reviewed 2026-06-27 — NOT ready for implementation. 3 blockers unresolved: +1. **Multi-page refs:** `reference_heading` ≠ `reference_zone.end`. Algorithm must use verified reference zone extent, not the heading block alone. +2. **Same-page body+ref:** NC66N4Q3 page 56 column-mixed body/ref needs column-aware partition, not page/y-only. +3. **Pre-ref known-heading gate:** Exact-match only, document-progress >= 0.50 guard, no substring matching. + +**Fix 3 is deferred** until a new spec resolves all 3 blockers. See spec §4.8 for full review. + +**Root cause (25K5KZAQ):** Three converging failures: + +**A. Pattern gap in heading classification:** +- `_is_backmatter_boundary_heading()` (ocr_roles.py:303) checks `has_container_words`: `"ADDITIONAL" in upper or "DECLARATION" in upper or "INFORMATION" in upper` +- "CRediT authorship contribution statement" contains none → returns False +- `is_visually_heading` requires font_size >= 11pt; CRediT blocks at 7.97pt bold → False +- Both checks fail → `subsection_heading` instead of `backmatter_boundary_heading` + +**B. Multi-column layout masks body-end:** +- Page 10: left column has Discussion body only, right column has CRediT+Ethics+Declaration +- `_detect_forward_body_end()` checks per column: left has `any_body_without_tail=True` → scan continues +- Declaration anchor below CRediT/Ethics → backmatter_start after CRediT + +**C. Normalization skips pre-boundary blocks:** +- `_normalize_backmatter_roles_after_boundary()` line 2062: unconditionally skips `subsection_heading` +- Line 2103: blocks before `backmatter_started` are skipped +- `_promote_tail_body_candidates()` requires anchor above or `page > spread_start` — CRediT is above anchor, same page as spread_start + +**Affected papers:** All papers with CRediT/Ethics/Declaration backmatter headings that use small font (<11pt) and lack container keywords. + +### 18.6 Fix 4: Figure Caption → non_body_insert (P0) + +**Root cause (YGH7VEX6 Figure 2):** `_detect_non_body_insert_clusters()` (ocr_document.py:3792) includes `figure_caption` in `_INSERT_CANDIDATE_ROLES`. Figure 2 caption (width 442px) passes the strong-spine-quality check: `font_mismatch=True AND 442 < 0.9×507(median_width)=456.3`. Same page has another candidate (idx 1, width 232) → cluster of 2 → both marked `non_body_insert`. + +**Comparison:** +| Block | Width | <456.3? | Font mismatch? | Caught? | +|-------|-------|---------|----------------|---------| +| Figure 1 (p2:5) | 501 | No | Yes | **Escapes** | +| Figure 2 (p2:15) | 442 | **Yes** | Yes | **Caught** | + +**Downstream effect:** Figure 2 caption never enters `figure_legends` → asset (p2:14) consumed by Figure 1 → Figure 2 invisible to entire inventory system. + +**Fix:** Remove `figure_caption` and `figure_caption_candidate` from `_INSERT_CANDIDATE_ROLES` (line 3846). Original rationale ("PaddleOCR labels narrow author bios as figure_title/figure_caption") is a corner case handled by existing family-profile rescue. + +### 18.7 Fix 5: Demoted Body Paragraph → Figure Legend (P0) + +**Root cause (YGH7VEX6 Figure 11):** `_is_validation_first_legend_candidate` (ocr_figures.py:405) checks: +- `zone == "display_zone"` — True for figure-mention body text +- `style_family == "legend_like"` — True (contains "Figure N") +- `marker_type == "figure_number"` — True + +These are proxy geometry/style signals that don't reflect the pipeline's role decision. The block was already demoted to `body_paragraph` during candidate resolution. Despite demotion, it re-enters legend matching. + +**Figure 11 on page 6:** +| Block | Role | Text | Matched assets | +|-------|------|------|----------------| +| p6:7 | body_paragraph | "Figure 11 shows..." | **Assets 1,3** (via adjacent_x) | +| p6:13 | figure_caption | "Figure 11. Compressive stress-strain..." | Assets 9,12 | + +The body paragraph won the primary slot (`figure_011` at 0.885). Real caption (`figure_s011` at 1.0) was demoted to supplementary. Render uses wrong legend text. + +**Matching evidence:** `["same_page", "adjacent_x", "anchor_supported", "caption_text_supported", "family_supported", "multi_asset_coherence_bonus"]` — NOTICE: no `"caption_above_or_below"` or `"nearby_y"`. The match was cross-column via horizontal adjacency, which is plausible geometry but wrong semantics. + +**Fix:** Filter demoted `body_paragraph` from `figure_legends` before `_is_validation_first_legend_candidate` check. Do NOT mutate `render_default`/`index_default`. Write full audit trail to `rejected_legends` (page, block_id, text, role, seed_role, rejection_reason). + +### 18.8 Design Decisions + +| Decision | Rationale | +|----------|-----------| +| Backmatter: ref is dividing line | Pre-ref=body flow, post-ref=backmatter. CRediT above References → body_zone | +| Figure containment: render-hygiene pass | Post `build_figure_inventory`, not during legend matching. Containment shouldn't affect matching, only rendering | +| Reference sort: two capture groups | Prevents false matches on plain year numbers. Optional brackets in single group would match `2024` as `[024]` | +| Fix 4 before Fix 5 | Fix 4 removes captions from non_body_insert → more captions reach inventory → fewer ambiguous figures | +| P0 vs P1 split | Fix 2/4/5 are <30 lines total, fully understood. Fix 1/3 need new specs | diff --git a/docs/superpowers/specs/2026-06-27-figure-containment-and-backmatter-boundary-design.md b/docs/superpowers/specs/2026-06-27-figure-containment-and-backmatter-boundary-design.md new file mode 100644 index 00000000..dbc39367 --- /dev/null +++ b/docs/superpowers/specs/2026-06-27-figure-containment-and-backmatter-boundary-design.md @@ -0,0 +1,550 @@ +# Figure-Containment & Reference-Boundary Backmatter Design + +**Date:** 2026-06-27 +**Status:** Figure containment → P1 plan-ready; Backmatter → not implemented from this file +**Authorization:** This spec authorizes implementation of **Figure containment only** (§3). +Backmatter sections (§4, §2) are diagnostic notes only and must not be implemented from this file. +**Review verdict (2026-06-27):** + +``` +Figure containment: direction correct. §3.2 and §3.3 below are already updated to §3.7. +Backmatter redesign: not ready. Multi-page refs, same-page body+ref, and + reference_zone extent remain unsolved. Needs separate spec. +``` + +**Audit evidence:** 25K5KZAQ, YGH7VEX6, XD2BPCMG, NC66N4Q3 + +--- + +## Table of Contents + +1. [Problem: Figure-Containment Gap](#1-problem-figure-containment-gap) +2. [Problem: Reference-Boundary Backmatter](#2-problem-reference-boundary-backmatter) +3. [Design: Figure-Containment Render Pass](#3-design-figure-containment-render-pass) +4. [Design: Reference-Boundary Backmatter Redesign](#4-design-reference-boundary-backmatter-redesign) +5. [Implementation Order & Dependencies](#5-implementation-order--dependencies) +6. [Risk & Verification](#6-risk--verification) + +--- + +## 1. Problem: Figure-Containment Gap + +### 1.1 Evidence (XD2BPCMG) + +Cartilage review, 26 pages. Pages 3 and 16 contain large composite multi-panel figures (Figure 1 and Figure 6). 21 text blocks on page 3 are spatially inside Figure 1's bounding region. None are classified as `figure_inner_text`: + +- `vision_footnote` → `footnote` (16 blocks on p3, 17 on p16) +- `paragraph_title` → `subsection_heading` (panel headings like "(c) Cartilage Function") +- `figure_title` → `non_body_insert` ("(a) Cartilage distribution") + +### 1.2 Root cause + +Three converging gaps: + +**Gap A: No spatial containment check.** The pipeline has zero code that answers "does this text block's bbox fall inside a figure's bounding region?" The existing `_bbox_contains()` (ocr.py:833) exists for footnotes/media dedup, but is never used for figure-inner-text detection. The 2026-06-13 `v1-real-paper-audit-report.md` explicitly chose proximity over containment because "panel labels sit outside asset bboxes" — but for XD2BPCMG's composite figures, the labels are INSIDE. + +**Gap B: Legend detection gated by `raw_label`.** The `_looks_like_figure_inner_label()` function only fires for `raw_label == "text"`. All figure-internal text on XD2BPCMG pages 3/16 has `raw_label` in `{vision_footnote, paragraph_title, figure_title, footer}` — none reach the inner-label check. + +**Gap C: Figure 1 caption demoted.** Figure 1's legend was demoted from `figure_caption_candidate` to `body_paragraph` (reason: "figure mention with narrative prose in body spine"). Without a matched figure entry, no `cluster_bbox` exists, so even if containment logic existed, there'd be no reference region to check against. + +### 1.3 Scope of impact + +| Paper | Pages affected | Blocks misclassified | Effect | +|-------|---------------|---------------------|--------| +| XD2BPCMG | 3, 16 | 33+ | Figure labels → `footnote` in fulltext | +| Any paper with composite figures | ~21.5% of vault | Varies | Same pattern | + +--- + +## 2. Problem: Reference-Boundary Backmatter + +### 2.1 Evidence (25K5KZAQ, NC66N4Q3) + +**25K5KZAQ (Bioactive Materials, 11p):** +- CRediT/Ethics headings at y=422-802 → classified as `subsection_heading` +- "Declaration of competing interest" at y=829 → correctly `backmatter_heading` +- Result: CRediT text appears as body prose above Discussion, not as backmatter + +**NC66N4Q3 (Radiographic Atlas, 56p):** +- Page 56: two-column layout, left=Discussion, right=References +- `body_end_page=None`, `ref_start=None` — boundary detection fails because body+ref share final page +- `is_clean_separated = False` → tail spread logic enters degraded mode + +### 2.2 Root cause + +Three converging failures: + +**A: Backmatter heading pattern too narrow.** `_is_backmatter_boundary_heading()` checks only `{ADDITIONAL, DECLARATION, INFORMATION}` as container words. "CRediT authorship contribution statement" and "Ethics approval and consent to participate" contain none of these. Combined with font_size 7.97pt < 11pt visual-heading threshold, the function returns False. + +**B: Normalization skips subsection_heading.** `_normalize_backmatter_roles_after_boundary()` line 2062 unconditionally skips any block with role in `{section_heading, subsection_heading, sub_subsection_heading}`. Even a block that SHOULD be a backmatter boundary (but was classified as subsection_heading due to font size) never gets promoted. + +**C: Body-end detection breaks on same-page body+ref.** When body and references share a page (especially two-column: left=body, right=ref), the forward scan's per-column check finds the left column has body without tail → `any_body_without_tail = True` → scan continues past the actual body end. `body_end_page` stays at the last pure-body page, leaving the mixed page as a no-man's-land. + +### 2.3 Scope of impact + +| Paper | Effect | Frequency | +|-------|--------|-----------| +| 25K5KZAQ | CRediT/Ethics in body flow, not backmatter | ~30-50% of papers with CRediT | +| NC66N4Q3 | body_end_page=None, boundary degraded | ≤5% (same-page body+ref is rare) | +| Any with unknown boundary headings | Backmatter heading → subsection_heading | Unknown but likely >5% | + +--- + +## 3. Design: Figure-Containment Render Pass + +### 3.1 Strategy + +**Not a legend-matching fix.** The containment check is a **render-hygiene pass** that runs AFTER `build_figure_inventory` has completed. It does not affect asset assignment or legend selection — it only prevents figure-internal text from appearing as body prose in the rendered fulltext. + +Rationale: The containment fix must handle the case where Figure 1 has NO matched_figures entry (demoted caption). In that case, we compute a composite bbox from same-page `figure_asset` blocks directly. + +### 3.2 Algorithm (authoritative — matches §3.7 contracts) + +``` +def _tag_figure_contained_text(blocks: list[dict], matched_figures: list[dict]) -> None: + for page in set(b.get("page") for b in blocks): + page_blocks = [b for b in blocks if b.get("page") == page] + figure_regions, covered_asset_ids = [], set() + + # Source 1: matched_figures cluster_bbox + for mf in matched_figures: + if mf.get("page") == page: + cb = mf.get("cluster_bbox") or mf.get("asset_bbox") + if cb: + figure_regions.append(("matched", cb)) + for aid in mf.get("asset_block_ids", []): + covered_asset_ids.add(aid) + + # Source 2: uncovered figure-like assets (when caption is demoted — no matched entry) + fallback_assets = [ + b for b in page_blocks + if b.get("block_id") not in covered_asset_ids + and b.get("role") in {"figure_asset", "media_asset"} + and not b.get("_table_owned") + and (b.get("asset_family_hint") == "figure_like" + or str(b.get("raw_label") or "") in {"image", "chart", "figure_title"}) + ] + fallback_regions = _cluster_bboxes_by_proximity( + [b["bbox"] for b in fallback_assets], margin=40 + ) + for fr in fallback_regions: + if not _highly_overlaps_any_matched_region(fr, figure_regions): + figure_regions.append(("fallback", fr)) + + # Tag contained text blocks + for block in page_blocks: + if block.get("role") in {"figure_asset", "media_asset", "noise", + "figure_caption", "figure_caption_candidate"}: + continue + block_bbox = block.get("bbox") + if not block_bbox or len(block_bbox) < 4: + continue + for _, fr in figure_regions: + if _is_contained(block_bbox, fr): + block["_figure_contained"] = True + if block.get("role") in {"body_paragraph", "subsection_heading", + "footnote", "non_body_insert", "frontmatter_noise"}: + block["role"] = "figure_inner_text" + break + + +def _is_contained(block_bbox, region_bbox): + """Contract E: center-inside + overlap/block_area >= 0.85 + width guard.""" + bw = block_bbox[2] - block_bbox[0] + rw = region_bbox[2] - region_bbox[0] + if bw > rw * 0.95: + return False + cx = (block_bbox[0] + block_bbox[2]) / 2 + cy = (block_bbox[1] + block_bbox[3]) / 2 + if not (region_bbox[0] <= cx <= region_bbox[2] and region_bbox[1] <= cy <= region_bbox[3]): + return False + ix1, iy1 = max(block_bbox[0], region_bbox[0]), max(block_bbox[1], region_bbox[1]) + ix2, iy2 = min(block_bbox[2], region_bbox[2]), min(block_bbox[3], region_bbox[3]) + if ix2 <= ix1 or iy2 <= iy1: + return False + overlap_area = (ix2 - ix1) * (iy2 - iy1) + block_area = bw * (block_bbox[3] - block_bbox[1]) + return overlap_area / block_area >= 0.85 + + +def _highly_overlaps_any_matched_region(fallback_bbox, figure_regions): + for tag, matched_bbox in figure_regions: + if tag != "matched": + continue + ix1 = max(fallback_bbox[0], matched_bbox[0]) + iy1 = max(fallback_bbox[1], matched_bbox[1]) + ix2 = min(fallback_bbox[2], matched_bbox[2]) + iy2 = min(fallback_bbox[3], matched_bbox[3]) + if ix2 > ix1 and iy2 > iy1: + overlap = (ix2 - ix1) * (iy2 - iy1) + fb_area = (fallback_bbox[2] - fallback_bbox[0]) * (fallback_bbox[3] - fallback_bbox[1]) + if fb_area > 0 and overlap / fb_area >= 0.5: + return True + return False +``` + +### 3.3 Cluster bbox aggregation (proximity-based, Contract D) + +When no `matched_figures` entry exists for a page's figure assets (demoted caption case), +use proximity clustering — composite figures have gaps between panels that strict overlap misses. + +```python +def _cluster_bboxes_by_proximity(bboxes: list[list[int]], margin: int = 40) -> list[list[int]]: + """Union-Find on inflated bboxes. Two bboxes merge when their inflated + (by margin) versions overlap. Returns union of original (non-inflated) bboxes.""" + if not bboxes: + return [] + n = len(bboxes) + parent = list(range(n)) + def find(i): + while parent[i] != i: + parent[i] = parent[parent[i]] + i = parent[i] + return i + def union(i, j): + ri, rj = find(i), find(j) + if ri != rj: + parent[ri] = rj + for i in range(n): + for j in range(i + 1, n): + b1, b2 = bboxes[i], bboxes[j] + if (b1[2] + margin > b2[0] - margin and b2[2] + margin > b1[0] - margin + and b1[3] + margin > b2[1] - margin and b2[3] + margin > b1[1] - margin): + union(i, j) + components = {} + for i in range(n): + root = find(i) + components.setdefault(root, []).append(bboxes[i]) + return [ + [min(b[0] for b in group), min(b[1] for b in group), + max(b[2] for b in group), max(b[3] for b in group)] + for group in components.values() + ] +``` + +This replaces the earlier `_cluster_bboxes_by_overlap` sketch. + +### 3.4 Protection: DO NOT touch render_default + +Critical constraint from P0 Fix 5: `figure_inner_text` assignment must NOT change `render_default` or `index_default`. The original block's participation in figure matching is unaffected — only its rendered role changes. + +Standard blocks that appear inside a figure region: +- Already rendered: not touched (render_default already false in some cases) +- Previously body_paragraph: role changed to figure_inner_text. The renderer must already handle `figure_inner_text` skipping (confirmed: ocr_render.py already excludes figure_inner_text from body flow). + +### 3.5 Affected code locations + +| File | Change | +|------|--------| +| `ocr.py` (or `ocr_figures.py`) | New function `_tag_figure_contained_text()`, called in `postprocess_ocr_result()` AFTER `build_figure_inventory(blocks)` and BEFORE `render_fulltext_markdown(blocks)` — NOT inside `normalize_document_structure` | +| `ocr.py` or `ocr_figures.py` | Helper `_cluster_bboxes_by_proximity()` (can live in same file) | +| `ocr_render.py` | Verify figure_inner_text is already excluded from body flow (confirmed) — no change expected | + +### 3.6 Test strategy + +| Test case | What it verifies | +|-----------|-----------------| +| Block fully inside figure_asset bbox → role becomes figure_inner_text | Core containment | +| Block partially inside (margin-adjacent) → NOT tagged | Margin boundary | +| Block inside cluster_bbox from matched_figures → tagged | Cluster path | +| Block inside cluster_bbox from unmatched assets → tagged | Demoted caption path | +| render_default unchanged after role change | Render hygiene | +| XD2BPCMG page 3 regression | 33 footnote blocks → figure_inner_text | + +### 3.7 Implementation contracts (from review) + +**Contract A — Call site: after build_figure_inventory, before render.** +NOT inside `normalize_document_structure()` — it runs before figure inventory. +Correct placement: `postprocess_ocr_result()`, between `build_figure_inventory(blocks)` and `render_fulltext_markdown(blocks)`. + +**Contract B — Fallback asset eligibility: include `media_asset` with figure-like family.** +Fallback clustering must use the same asset eligibility as `build_figure_inventory`: +```python +role in {"figure_asset", "media_asset"} and not table_owned +and (asset_family_hint == "figure_like" or raw_label in {"image", "chart", "figure_title"}) +``` + +**Contract C — Fallback assets must exclude those already covered by matched_figures.** +```python +covered_asset_ids = set() +for mf in matched_figures: + for aid in mf.get("asset_block_ids", []): + covered_asset_ids.add((mf.get("page"), aid)) +# Fallback clustering only uses assets NOT in covered_asset_ids. +# If fallback region is contained by / highly overlaps a matched region, drop it. +``` + +**Contract D — Proximity clustering, not overlap clustering.** +Multi-panel composite figures have gaps between panels — strict overlap misses them. +```python +def _cluster_bboxes_by_proximity(bboxes, margin=40): + # inflate each bbox by margin, union overlapping inflated bboxes, return original-bbox unions +``` + +**Contract E — Containment test: center-inside + overlap ratio, not raw bbox_contains.** +```python +def _is_contained(block_bbox, region_bbox): + cx = (block_bbox[0] + block_bbox[2]) / 2 + cy = (block_bbox[1] + block_bbox[3]) / 2 + center_inside = (region_bbox[0] <= cx <= region_bbox[2] and region_bbox[1] <= cy <= region_bbox[3]) + overlap = intersection_area(block_bbox, region_bbox) / block_area(block_bbox) + return center_inside and overlap >= 0.85 and block_width <= region_width * 0.95 +``` + +**Out of scope (first release):** `render_default`/`index_default` unchanged. Renderer skips `figure_inner_text` by role (confirmed). Indexing behavior is out of scope unless indexer does not respect role — document this in the function docstring. + +--- + +## 4. Superseded Backmatter Sketch — DO NOT IMPLEMENT + +> **WARNING:** This section (§4.1–§4.7) documents a rejected design that was reviewed +> and found to have 3 unresolved blockers. It is preserved here as diagnostic reference +> material only. **Do not implement anything from §4.1–§4.7.** +> +> The current spec for backmatter redesign starts at §4.8 (review verdict). +> A future separate spec must resolve §4.8's blockers before any implementation. + +### 4.1 Core principle (superseded) + +**References are the dividing line.** Everything after the first `reference_heading` is backmatter. This replaces the current forward/backward scan approach. + +**Why this simplifies:** Because reference headings are detected by the structural gate with high confidence (line text matches "References" / "Bibliography" etc.), and reference items have strong typographic signatures (reference-like family anchor). Using ref as the anchor eliminates the dependency on body-end detection + backmatter heading recognition. + +### 4.2 Tail zone redefinition (superseded) + +| Current | Proposed (rejected) | +|---------|----------| +| `body_zone` up to inferred body_end_page | Body flows until first reference_heading | +| `tail_nonref_hold_zone` before ref | Merged into body_zone or post-ref-zone | +| `post_reference_backmatter_zone` after ref | `backmatter_zone`: everything after first reference_heading | +| `reference_zone` | Unchanged | + +### 4.3 Algorithm (superseded — see §4.8 for blockers) + +``` +1. Find the canonical reference_heading: + - Scan ALL pages for blocks with role == "reference_heading" + - Select the first one (minimum page number, then minimum y) + +2. Partition blocks: + - Page < ref_heading_page: body_zone (no change) + - Page == ref_heading_page AND y < ref_heading_y: body_zone + - Page == ref_heading_page AND y >= ref_heading_y AND role != reference_*: + → backmatter_zone, normalize to backmatter_body + - Page > ref_heading_page: backmatter_zone + +3. Normalize backmatter blocks: + - heading roles → backmatter_heading (regardless of font size / container words) + - body_paragraph → backmatter_body + - non_body_insert → backmatter_body (if text content exists) +``` + +### 4.4 Fix for 25K5KZAQ (superseded — incomplete without pre-ref handling) + +With the ref-boundary approach, page 10 would be partitioned as: + +``` +y < 1400 (References heading): body_zone — Discussion, CRediT, Ethics, Declaration +y >= 1400: reference_zone + backmatter_zone +``` + +Wait — this means CRediT/Ethics would still be in `body_zone` because they're ABOVE the References heading. The ref-boundary approach alone doesn't fix the CRediT classification — we still need to handle pre-ref backmatter. + +**Revised: Pre-ref backmatter detection via known heading patterns.** + +``` +1. Run ref-boundary partition (as above) +2. In pre-ref body_zone, scan for known backmatter section headings: + - "CRediT authorship contribution statement" + - "Ethics approval and consent to participate" + - "Declaration of competing interest" + - "Supplementary materials" + - "Data availability" + - "Acknowledgements" / "Acknowledgments" + - "Funding" + - "Author contributions" / "CRediT" + - Any heading matching _BACKMATTER_TITLE_DENY_LIST patterns +3. First matched heading → pre-ref backmatter start + - Blocks between first matched heading and next body heading → backmatter_zone + - Blocks after first matched heading AND before reference_heading → backmatter_zone +``` + +This combines the ref-boundary anchor with a known-heading table for pre-ref backmatter. The heading table is an explicit, curated list (not heuristic) — low false-positive risk because these are standardized section names. + +### 4.5 Known heading table (superseded — gate too loose for substring safety) + +```python +_PRE_REF_BACKMATTER_HEADINGS = { + "credit authorship contribution statement", + "ethics approval and consent to participate", + "ethics statement", + "declaration of competing interest", + "competing interests", + "conflict of interest", + "data availability", + "data availability statement", + "supplementary materials", + "supplementary data", + "supplementary information", + "appendix a", + "appendix", + "acknowledgements", + "acknowledgments", + "funding", + "author contributions", + "credit", +} +``` + +### 4.6 Fix for NC66N4Q3 (superseded — column-aware partition unresolved) + +The two-column same-page body+ref case (page 56: left=Discussion, right=References): + +``` +1. Detect candidate ref blocks per-column via marker_signature + reference_zone +2. If ref blocks exist on a page WITHOUT a reference_heading, + check if a reference_heading exists on the immediately previous page +3. If yes, this is a continuation page — treat remaining body as body_flow, + not as pre-ref-backmatter +``` + +This is a refinement of the ref-boundary approach: the ref-heading's page becomes the partition boundary, not the ref-blocks' page. + +### 4.7 Affected code locations (superseded — call graph not yet traced) + +| File | Change | +|------|--------| +| `ocr_roles.py` | `_is_backmatter_boundary_heading()` — add known-heading table OR defer to document-context pass | +| `ocr_document.py` | `_reconcile_tail_spread()` — replace forward/backward scan with ref-boundary scan | +| `ocr_document.py` | `_normalize_backmatter_roles_after_boundary()` — remove subsection_heading skip, add body_paragraph→backmatter_body conversion | +| `ocr_document.py` | `_promote_tail_body_candidates()` — allow same-page promotion below backmatter anchor | +| `ocr_document.py` | `infer_zones()` — simplify zone boundary using ref partition | +| `ocr_families.py` | May need reference anchor tuning | + +### 4.8 Review verdict: NOT ready for implementation + +The review identified **3 unresolved blockers** and **3 structural weaknesses** that must be resolved before a new backmatter spec can be written. + +#### Blocker 1: `reference_heading` ≠ `reference_zone` extent + +The algorithm's `Page > ref_heading_page → backmatter_zone` breaks on multi-page references. Most papers' reference sections span 2+ pages. The boundary is not `reference_heading` but `reference_zone.end`. + +Correct model: +``` +1. Detect verified reference_zone (heading + item range + page/y extent) +2. Blocks before reference_zone.start → body or pre-ref backmatter +3. Blocks inside reference_zone → preserve reference_heading/reference_item +4. Blocks after reference_zone.end → post-ref backmatter +``` + +#### Blocker 2: Same-page body+ref must be column-aware + +NC66N4Q3 page 56: left=Discussion, right=References. Cannot partition by page or y alone. +``` +If body and reference blocks share a page: + reference column / x-range → reference_zone + body column outside reference x-range → body_flow +``` +Current code has tail/backmatter ownership in `_promote_tail_body_candidates()`, +`_assign_tail_spread_ownership()`, and renderer `_reorder_tail_run()` — any redesign +must update all three consistently. + +#### Blocker 3: Pre-ref known-heading table needs stronger gate + +The `_PRE_REF_BACKMATTER_HEADINGS` set is directionally correct but risks false positives if using substring matching on generic words like `"funding"`, `"credit"`, `"appendix"`. + +Required gates: +- Normalized exact match OR anchored regex only — no substring matching except explicitly listed aliases +- Only trigger if reference_zone exists, heading is before reference_zone.start, + document progress >= 0.50 (or page >= max(2, total_pages - 3)), + heading role is heading-like OR bold/short block, + AND no later numbered body section heading appears before References + +#### Structural weaknesses + +1. **Corpus audit needed first.** Before writing a new spec, audit the full vault for: + - Papers with CRediT/Ethics/Declaration backmatter (count, patterns, font sizes, column layouts) + - Papers with multi-page reference sections + post-ref backmatter + - Papers with same-page body+ref on final page + This data determines whether the new design generalizes or remains paper-specific. + +2. **Verification target `body_end_page=3` is wrong.** NC66N4Q3 page 56 has Discussion in left column — body_end_page should not be 3. Replace with: + ``` + NC66N4Q3: page 56 right-column ref items remain reference_zone, + page 56 left-column Discussion remains body_flow, + tail spread does NOT enter degraded no-boundary mode + ``` + +3. **Affected code locations are still speculative.** Current tail/backmatter path includes + `_reconcile_tail_spread`, `infer_zones`, `_normalize_backmatter_roles_after_boundary`, + `_promote_tail_body_candidates`, `_assign_tail_spread_ownership`, `_reorder_tail_run`. + Before redesign, trace and document the full call graph. + +### 4.9 Recommended approach for new spec + +Write a new spec in this order: +``` +Phase A: Audit corpus for backmatter patterns (CRediT, font sizes, column layouts, ref spans) +Phase B: Trace and document full tail/backmatter call graph +Phase C: Define reference_zone.start/end model (not reference_heading alone) +Phase D: Define same-page column-aware partition for body+ref +Phase E: Define pre-ref backmatter known-heading table with exact-match gate +Phase F: Update role normalization (remove subsection_heading skip) +Phase G: Update renderer _reorder_tail_run consistently +Phase H: Real-paper regression (min 20 papers with diverse backmatter) +``` + +### 4.10 Test strategy (for the future spec) + +| Test case | What it verifies | +|-----------|-----------------| +| 25K5KZAQ: CRediT/Ethics → backmatter_body | Known heading detection | +| NC66N4Q3: ref items remain reference_zone, Discussion remains body_flow, no degraded mode | Same-page body+ref box partition | +| Standard paper: no pre-ref backmatter → no change | Non-regression | +| Paper with multi-page ref section + post-ref backmatter → all refs preserved | reference_zone.end model | +| Paper with appendix after ref → appendix in post-ref backmatter | Post-ref boundary | +| Paper with no references → fallback to old algorithm | Degraded mode | +| font_size < 11pt bold heading on tail page → backmatter_heading | Font-independent promotion | + +--- + +## 5. Implementation Order + +### Phase 1: Figure containment (only) + +- Independent of P0 changes. Can be implemented and tested standalone. +- Implementation-plan ready once §3.2/§3.3 are confirmed to match §3.7 contracts. +- **Estimated scope:** ~50–80 lines new code + imports + unit tests. + +**Backmatter redesign:** No implementation in this spec. Next action is corpus audit + call graph trace + new spec (see §4.9 for full roadmap). + +--- + +## 6. Risk & Verification + +### 6.1 Containment risks + +| Risk | Mitigation | +|------|-----------| +| False positives: body text inside figure bounding box | Use center-inside + overlap/block_area >= 0.85 + block_width <= region_width * 0.95 (Contract E). Only reassign roles in {body_paragraph, footnote, subsection_heading, non_body_insert, frontmatter_noise}. | +| Full-width text blocks that span past figure region | Width check (Contract E): block_width > region_width * 0.95 → not tagged | +| Matched figure region and fallback region double-count | covered_asset_ids + _highly_overlaps_any_matched_region guard (Contract C) | +| Performance: per-page bbox comparison O(n*m) | n < 200 blocks/page, m < 20 regions/page — negligible | + +### 6.2 Backmatter — not yet implementation-ready + +This section is preserved as reference material for the future redesign. The following blockers (§4.8) must be resolved before any implementation: + +1. Multi-page reference sections broken by `ref_heading → backmatter` model +2. Same-page body+ref requires column-aware partition (not page/y-only) +3. Pre-ref known-heading table needs stronger gate (exact match, document-progress guard) + +### 6.3 Verification checklist (Figure containment only) + +- [ ] All P0 tests still pass (regression) +- [ ] Containment: XD2BPCMG fulltext no longer contains footnote-inside-figure blocks +- [ ] Containment: render_default/index_default unchanged for tagged blocks +- [ ] Containment: matched_figures cluster_bbox path works (standard papers) +- [ ] Containment: fallback asset clustering path works (demoted-caption papers) +- [ ] Containment: covered_asset_ids exclusion prevents double-region +- [ ] 9TW98JH8 (already correct): unchanged diff --git a/docs/superpowers/specs/2026-06-27-figure-containment-implementation-plan.md b/docs/superpowers/specs/2026-06-27-figure-containment-implementation-plan.md new file mode 100644 index 00000000..ca1c00e5 --- /dev/null +++ b/docs/superpowers/specs/2026-06-27-figure-containment-implementation-plan.md @@ -0,0 +1,382 @@ +# P1 Implementation Plan: Figure Containment Render Hygiene + +> **Based on:** `2026-06-27-figure-containment-and-backmatter-boundary-design.md` §3 +> **Spec contracts:** §3.7 A–E (codified in §3.2 algorithm) +> **Verification:** XD2BPCMG (composite figures with demoted caption), YGH7VEX6 (standard composite) +> **Audit evidence:** XD2BPCMG p3/p16 (33 blocks inside Figure 1 bbox → `footnote`/`subsection_heading`) + +--- + +## Overview + +Add a render-hygiene pass after `build_table_inventory()` and `write_back_table_roles()` +that checks text blocks for spatial containment inside figure bounding regions. +Contained text blocks are reclassified as `figure_inner_text`, preventing them +from leaking into the fulltext as body prose / headings / footnotes. + +**Does NOT affect:** +- Figure legend matching or asset ownership +- `render_default` / `index_default` flags +- Any P0 logic (these are independent) + +--- + +## Helper: `_cluster_bboxes_by_proximity()` + +**File:** `paperforge/worker/ocr_figures.py` + +```python +def _cluster_bboxes_by_proximity( + bboxes: list[list[int]], margin: int = 40 +) -> list[list[int]]: + """Union-Find proximity clustering. + + Two bboxes merge when their *inflated* versions overlap. Returns union of + original (non-inflated) bboxes. Needed because composite multi-panel figures + have visible gaps between sub-panels — strict overlap misses them. + """ + if not bboxes: + return [] + n = len(bboxes) + parent = list(range(n)) + + def _find(i: int) -> int: + while parent[i] != i: + parent[i] = parent[parent[i]] + i = parent[i] + return i + + def _union(i: int, j: int) -> None: + ri, rj = _find(i), _find(j) + if ri != rj: + parent[ri] = rj + + for i in range(n): + b1 = bboxes[i] + for j in range(i + 1, n): + b2 = bboxes[j] + if (b1[2] + margin > b2[0] - margin + and b2[2] + margin > b1[0] - margin + and b1[3] + margin > b2[1] - margin + and b2[3] + margin > b1[1] - margin): + _union(i, j) + + components: dict[int, list[list[int]]] = {} + for i in range(n): + root = _find(i) + components.setdefault(root, []).append(bboxes[i]) + + return [ + [min(b[0] for b in group), min(b[1] for b in group), + max(b[2] for b in group), max(b[3] for b in group)] + for group in components.values() + ] +``` + +--- + +## Helper: `_is_contained()` — containment test (Contract E) + +```python +def _is_contained(block_bbox: list[int], region_bbox: list[int]) -> bool: + """Check if a text block is visually inside a figure region. + + Three tests, all must pass: + 1. Width guard: block not wider than 95% of region (catches full-width body text) + 2. Center-inside: block centroid falls within region + 3. Overlap ratio: >= 85% of block area overlaps with region + """ + bw = block_bbox[2] - block_bbox[0] + bh = block_bbox[3] - block_bbox[1] + rw = region_bbox[2] - region_bbox[0] + + if bw > rw * 0.95: + return False + + cx = (block_bbox[0] + block_bbox[2]) / 2 + cy = (block_bbox[1] + block_bbox[3]) / 2 + if not (region_bbox[0] <= cx <= region_bbox[2] + and region_bbox[1] <= cy <= region_bbox[3]): + return False + + ix1 = max(block_bbox[0], region_bbox[0]) + iy1 = max(block_bbox[1], region_bbox[1]) + ix2 = min(block_bbox[2], region_bbox[2]) + iy2 = min(block_bbox[3], region_bbox[3]) + if ix2 <= ix1 or iy2 <= iy1: + return False + + overlap_area = (ix2 - ix1) * (iy2 - iy1) + block_area = bw * bh + return block_area > 0 and overlap_area / block_area >= 0.85 +``` + +--- + +## Helper: `_highly_overlaps_any_matched_region()` — dedup guard (Contract C) + +```python +def _highly_overlaps_any_matched_region( + fallback_bbox: list[int], + figure_regions: list[tuple[str, list[int]]], +) -> bool: + """Drop fallback region if >50% of its area overlaps any matched region.""" + for tag, matched_bbox in figure_regions: + if tag != "matched": + continue + ix1 = max(fallback_bbox[0], matched_bbox[0]) + iy1 = max(fallback_bbox[1], matched_bbox[1]) + ix2 = min(fallback_bbox[2], matched_bbox[2]) + iy2 = min(fallback_bbox[3], matched_bbox[3]) + if ix2 > ix1 and iy2 > iy1: + overlap = (ix2 - ix1) * (iy2 - iy1) + fb_area = ((fallback_bbox[2] - fallback_bbox[0]) + * (fallback_bbox[3] - fallback_bbox[1])) + if fb_area > 0 and overlap / fb_area >= 0.5: + return True + return False +``` + +--- + +## Helper: `_figure_region_bbox()` — region resolver + +Matched figures may carry region info in different fields. Resolve in order: + +```python +def _figure_region_bbox(mf: dict) -> list[float] | None: + """Resolve a figure's bounding region from available fields. + + Resolution order: + 1. cluster_bbox (composite figure's full extent) + 2. asset_bbox (single-asset figure) + 3. union of matched_assets[].bbox (sequential fallback / legacy entries) + """ + cb = mf.get("cluster_bbox") + if cb and len(cb) >= 4: + return cb + + ab = mf.get("asset_bbox") + if ab and len(ab) >= 4: + return ab + + asset_bboxes = [ + a.get("bbox") + for a in mf.get("matched_assets", []) or [] + if a.get("bbox") and len(a.get("bbox")) >= 4 + ] + if asset_bboxes: + return [ + min(b[0] for b in asset_bboxes), + min(b[1] for b in asset_bboxes), + max(b[2] for b in asset_bboxes), + max(b[3] for b in asset_bboxes), + ] + + return None +``` + +--- + +## Helper: `_matched_asset_keys()` — page-qualified asset exclusion + +Both `matched_assets` and `asset_block_ids` must be checked, because different +match paths populate different fields. Keys are `(page, block_id)` to prevent +same-id-on-different-page collisions. + +```python +def _matched_asset_keys(mf: dict) -> set[tuple[int, str]]: + """Collect page-qualified asset identifiers from a matched figure. + + Reads from both matched_assets and asset_block_ids, then unions. + """ + keys: set[tuple[int, str]] = set() + page = int(mf.get("page", 0) or 0) + + for asset in mf.get("matched_assets", []) or []: + bid = asset.get("block_id") + ap = int(asset.get("page", page) or page) + if bid: + keys.add((ap, str(bid))) + + for bid in mf.get("asset_block_ids", []) or []: + if bid: + keys.add((page, str(bid))) + + return keys +``` + +--- + +## Main entry point: `tag_figure_contained_text()` + +**Location:** `paperforge/worker/ocr_figures.py` (same file as the helpers, exported) + +```python +def tag_figure_contained_text( + blocks: list[dict], + matched_figures: list[dict], +) -> None: + """Render-hygiene pass: tag text blocks spatially inside figure regions. + + Does NOT mutate render_default/index_default. Renderer skips + figure_inner_text by role. Indexing behavior out of scope. + + ponytail: containment is center-inside + 85% overlap heuristic. + Upgrade to pixel-exact polygon if false positives confirmed. + """ + _LEAK_ROLES = { + "body_paragraph", "section_heading", "subsection_heading", + "sub_subsection_heading", "backmatter_heading", "backmatter_body", + "tail_candidate_body", "footnote", "structured_insert", + "non_body_insert", "frontmatter_noise", + } + + pages = {b.get("page") for b in blocks if b.get("page") is not None} + matched_by_page: dict[int, list[dict]] = {} + for mf in matched_figures: + p = mf.get("page") + if p is not None: + matched_by_page.setdefault(p, []).append(mf) + + for page in sorted(pages): + page_blocks = [b for b in blocks if b.get("page") == page] + figure_regions: list[tuple[str, list[int]]] = [] + covered_asset_keys: set[tuple[int, str]] = set() + + # --- Source 1: matched_figures cluster_bbox --- + for mf in matched_by_page.get(page, []): + region = _figure_region_bbox(mf) + if region: + figure_regions.append(("matched", region)) + covered_asset_keys |= _matched_asset_keys(mf) + + # --- Source 2: uncovered figure-like assets (demoted-caption fallback) --- + fallback_assets = [ + b for b in page_blocks + if (int(b.get("page", 0) or 0), str(b.get("block_id", ""))) not in covered_asset_keys + and b.get("role") in {"figure_asset", "media_asset"} + and b.get("role") not in {"table_html", "table_asset"} + and ( + b.get("asset_family_hint") == "figure_like" + or str(b.get("raw_label", "") or "") in {"image", "chart", "figure_title", "figure"} + ) + ] + if fallback_assets: + fallback_bboxes = [b["bbox"] for b in fallback_assets if len(b.get("bbox") or []) >= 4] + for fr in _cluster_bboxes_by_proximity(fallback_bboxes, margin=40): + if not _highly_overlaps_any_matched_region(fr, figure_regions): + figure_regions.append(("fallback", fr)) + + if not figure_regions: + continue + + # --- Tag contained text blocks --- + for block in page_blocks: + role = str(block.get("role") or "") + if role in {"figure_asset", "media_asset", "noise", + "figure_caption", "figure_caption_candidate", + "table_html", "table_asset", + "figure_inner_text"}: + continue + bbox = block.get("bbox") + if not bbox or len(bbox) < 4: + continue + for _, fr in figure_regions: + if _is_contained(bbox, fr): + block["_figure_contained"] = True + if role in _LEAK_ROLES: + block["role"] = "figure_inner_text" + break +``` + +--- + +## Call site in `postprocess_ocr_result()` + +**File:** `paperforge/worker/ocr.py` + +Find the `postprocess_ocr_result()` function. The real pipeline order is: + +``` +build_figure_inventory(structured) +write_back_figure_roles(figure_inventory, structured) +synthesize_reader_figures(...) + +build_table_inventory(structured) +attach_ownership_conflicts(figure_inventory, table_inventory) +write_figure_inventory(...) +write_back_table_roles(table_inventory, structured) +write_table_inventory(...) + +==== INSERT HERE ==== +tag_figure_contained_text(structured, figure_inventory.get("matched_figures", [])) +===================== + +write_structured_blocks_jsonl(...) +render_fulltext_markdown(...) +``` + +Insert `tag_figure_contained_text()` after `write_back_table_roles()` and before +`write_structured_blocks_jsonl()`. This ensures: +- Table assets are already writeback'd to `table_html`, so fallback won't include them +- All figure role writeback is complete +- The structured blocks snapshot is still mutable before persist+render + +Also add the import at the top of `ocr.py`: +```python +from paperforge.worker.ocr_figures import tag_figure_contained_text +``` + +--- + +## Tests + +**File:** New `tests/unit/worker/test_figure_containment.py` + +### Tests from plan (10 cases) + +| Test | What it verifies | +|------|-----------------| +| `test_block_fully_inside_matched_region` | Block inside matched region → figure_inner_text | +| `test_block_inside_fallback_region` | Block inside fallback-clustered assets → figure_inner_text | +| `test_block_not_tagged_when_outside` | Block outside all regions → unchanged | +| `test_block_not_tagged_when_mostly_outside` | Overlap < 85% → unchanged | +| `test_full_width_block_not_tagged` | Block wider than 95% of region → unchanged | +| `test_figure_asset_never_tagged` | role=figure_asset → unchanged | +| `test_render_default_unchanged` | render_default/index_default not mutated | +| `test_covered_asset_exclusion` | Asset in matched_figures → excluded from fallback | +| `test_fallback_dropped_when_overlaps_matched` | Fallback region >50% overlap matched → dropped | +| `test_proximity_clustering_merges_gapped_panels` | Two disjoint bboxes 30px apart → merged | + +### Additional tests (from review, 4 cases) + +| Test | What it verifies | +|------|-----------------| +| `test_matched_assets_only_entry` | matched figure with `matched_assets` but no `asset_block_ids/cluster_bbox` → region resolved from asset bbox union | +| `test_page_qualified_exclusion` | Same block_id on page 1 and page 2 → only page-1 asset excluded from fallback | +| `test_table_html_not_in_fallback` | Table writeback to `table_html` role → excluded from fallback region | +| `test_structured_insert_inside_figure` | `structured_insert` inside figure → `figure_inner_text` (prevents callout leakage) | + +--- + +## Verification + +**Audit paper: XD2BPCMG** (cartilage review, composite Figure 1 on p3): +- Before: 33 blocks inside Figure 1 bbox → `footnote`/`subsection_heading` +- After: same blocks → `figure_inner_text` +- Render: no `## (b) Type of cartilage` in fulltext +- Structured inserts inside figure also tagged + +**Regression (no change expected):** +- 9TW98JH8 (5p, clean mixed-tail, 20/20 correct) +- YGH7VEX6 (8p, Springer — standard figures, no containment issue) +- `python -m pytest tests/unit/ tests/cli/ -v --tb=short` +- `ruff check --fix paperforge/ && ruff format paperforge/` + +--- + +## Total diff + +~140 lines new code (5 helpers + 1 main entry + 1 import + call site) +~110 lines new tests diff --git a/docs/superpowers/specs/2026-06-27-ocr-v2-audit-p0-plan.md b/docs/superpowers/specs/2026-06-27-ocr-v2-audit-p0-plan.md new file mode 100644 index 00000000..701c28d8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-27-ocr-v2-audit-p0-plan.md @@ -0,0 +1,148 @@ +# P0 Implementation Plan: Fix 2 + Fix 4 + Fix 5 + +> **Based on:** `2026-06-27-ocr-v2-audit-remediation-5-fixes.md` +> **Type:** 3 independent patches, one commit +> **Verification:** YGH7VEX6 + 25K5KZAQ audit papers + +--- + +## Step 1: Fix 4 — Remove `figure_caption` from non-body insert candidates + +**File:** `paperforge/worker/ocr_document.py` +**Function:** `_detect_non_body_insert_clusters` (~line 3846) + +**Change A — the set:** + +```python +# BEFORE +_INSERT_CANDIDATE_ROLES = { + "body_paragraph", + "figure_caption", + "figure_caption_candidate", + "unknown_structural", +} + +# AFTER +_INSERT_CANDIDATE_ROLES = { + "body_paragraph", + "unknown_structural", +} +``` + +**Change B — docstring (two locations):** + +1. Function docstring: remove `figure_caption, figure_caption_candidate` from role list in detection criteria +2. Inline comment (~line 3805-3806): delete or rephrase "figure_caption is included because PaddleOCR sometimes labels narrow author-bio side-panel blocks as figure_title/figure_caption" + +**Verify:** Run YGH7VEX6 audit — Figure 2 caption must be `figure_caption` not `non_body_insert`. + +--- + +## Step 2: Fix 2 — Support bracket format in reference sort + +**File:** `paperforge/worker/ocr_render.py` +**Function:** `_ref_number_sort_key` (~line 481) + +**Change:** + +```python +def _ref_number_sort_key(block: dict) -> tuple: + text = str(block.get("text") or block.get("block_content") or "") + m = re.match(r"^\s*(?:\[(\d+)\]|(\d+)[\.\)])\s*", text) + if m: + return (0, int(m.group(1) or m.group(2))) + return (1, text) +``` + +**Verify:** 25K5KZAQ fulltext — references `[1],[2],...,[10],[11],...` in correct numeric order. + +--- + +## Step 3: Fix 5 — Filter demoted body paragraphs from figure legends + +**File:** `paperforge/worker/ocr_figures.py` +**Function:** `build_figure_inventory` — legend collection loop + +**Change:** In `for block in structured_blocks:` around line ~2940-2970, before `_is_validation_first_legend_candidate()` call: + +```python +# Skip body paragraphs demoted from caption_candidate — narrative mentions, not legends +if block.get("role") == "body_paragraph" and block.get("seed_role") == "figure_caption_candidate": + rejected_legends.append({ + "page": block.get("page"), + "block_id": block.get("block_id", ""), + "text": block.get("text", ""), + "role": block.get("role", ""), + "seed_role": block.get("seed_role", ""), + "rejection_reason": "demoted_body_caption_candidate", + }) + continue +``` + +Do NOT touch `render_default` / `index_default` — these blocks stay as normal body text. + +**Verify:** YGH7VEX6 Figure 11 — `figure_011` uses real caption (block 13), no `figure_s011`. + +--- + +## Step 3 detail: Insert position for Fix 5 + +In `build_figure_inventory()`, the legend collection loop order is: + +``` +1. skip _non_body_media / non_body_insert +2. skip panel label (_PANEL_LABEL_PATTERN) +3. → INSERT Fix 5 FILTER HERE ← +4. is_validation_first_candidate = _is_validation_first_legend_candidate(block) +5. if role in caption_roles or is_validation_first_candidate: → append to legends +``` + +Do NOT place the filter inside step 5 — by then the block is already in legends. + +--- + +## Test additions + +**File:** `tests/test_ocr_render.py` + +```python +def test_ref_number_sort_key_supports_bracketed_numbers(): + """[1], [2], [10] → 1, 2, 10""" + ... + +def test_ref_number_sort_key_rejects_plain_year_prefix(): + """'2024 Elsevier...' → lexicographic fallback (not parsed as number)""" + assert _ref_number_sort_key({"text": "2024 Elsevier Ltd."})[0] == 1 +``` + +**File:** `tests/test_ocr_document.py` + +```python +def test_non_body_insert_candidates_exclude_figure_caption_roles(): + """figure_caption escapes non_body_insert even when narrow + font mismatch + cluster exists""" + # Construct: role=figure_caption, width < 0.9*median, font mismatch, 2 candidates on same page + # Expected: not in non_body_insert indices + ... + +def test_demoted_body_caption_candidate_excluded_from_legends_but_still_renderable(): + """body_paragraph + seed_role=figure_caption_candidate: + - excluded from figure_legends / matched_figures / ambiguous_figures / unmatched_legends + - render_default and index_default NOT mutated + - appears in rejected_legends with rejection_reason""" + ... +``` + +## Acceptance checklist + +```text +- pytest targeted tests pass +- YGH7VEX6: Figure 2 caption role = figure_caption, participates in matching +- YGH7VEX6: Figure 11 matched legend = real caption p6:13; p6:7 excluded from legends but remains body text +- 25K5KZAQ: references sort [1], [2], [3], ..., [10], [11] numerically +- No render_default/index_default mutation for demoted body caption candidates +``` + +## Rollback + +Each step is independent. If Fix 4 causes issues, revert the set change alone. +If Fix 5 misses a legitimate caption, the rejected_legends audit trail shows which blocks were excluded. diff --git a/docs/superpowers/specs/2026-06-27-ocr-v2-audit-remediation-5-fixes.md b/docs/superpowers/specs/2026-06-27-ocr-v2-audit-remediation-5-fixes.md new file mode 100644 index 00000000..e605c923 --- /dev/null +++ b/docs/superpowers/specs/2026-06-27-ocr-v2-audit-remediation-5-fixes.md @@ -0,0 +1,222 @@ +# 5 OCR Fixes from Vision Audit Remediation + +> **Date:** 2026-06-27 +> **Source:** 5-paper vision truth audit (NC66N4Q3, 25K5KZAQ, 9TW98JH8, YGH7VEX6, XD2BPCMG) +> **Status:** v2 after review + +--- + +## 实施拆分 + +| 包 | Fixes | 类型 | +|----|-------|------| +| **P0 quick fixes** | Fix 2 + Fix 4 + Fix 5 | bug patches, 可单独落地 | +| **P1 layout cleanup** | Fix 1 + Fix 3 (separate spec) | 结构性变更, 分开做 | + +**P0 三件套可以同时 commit,但每条改动是独立的。** Fix 3 拆成单独 spec。 + +--- + +# P0 QUICK FIXES + +--- + +## Fix 2: Reference Number Sort — Support Bracket Format `[N]` + +### Problem +`_ref_number_sort_key` (`ocr_render.py:481`) regex `r"^\s*(\d+)[\.\)]"` 不匹配 `[N]`。`[10]` 排在 `[2]` 前面。 + +### Implementation + +```python +# ocr_render.py:481 +def _ref_number_sort_key(block: dict) -> tuple: + text = str(block.get("text") or block.get("block_content") or "") + m = re.match(r"^\s*(?:\[(\d+)\]|(\d+)[\.\)])\s*", text) + if m: + return (0, int(m.group(1) or m.group(2))) + return (1, text) +``` + +只接受: +- `[1] Author...` → 1 +- `1. Author...` → 1 +- `1) Author...` → 1 + +不接受 `2024 something`(没有 bracket 或 dot/paren 的数字不匹配)。 + +### Risk +None. + +--- + +## Fix 4: Remove `figure_caption` from Non-Body Insert Candidate Set + +### Problem +`_detect_non_body_insert_clusters` (`ocr_document.py:3846`) 的 `_INSERT_CANDIDATE_ROLES` 包含 `figure_caption` 和 `figure_caption_candidate`。当 caption 宽度略窄于 body median + 字体不同时(caption 常用小字/斜体),被误杀为 `non_body_insert`。 + +YGH7VEX6 Figure 2: 宽度 442px vs median 507px (threshold 456)。差 14px 导致 Figure 2 在整个 pipeline 中不可见。 + +### Implementation + +```python +# BEFORE +_INSERT_CANDIDATE_ROLES = { + "body_paragraph", + "figure_caption", + "figure_caption_candidate", + "unknown_structural", +} + +# AFTER +_INSERT_CANDIDATE_ROLES = { + "body_paragraph", + "unknown_structural", +} +``` + +同时更新 docstring:删除"figure_caption is included because PaddleOCR sometimes labels narrow author-bio side-panel blocks as figure_title/figure_caption"这一段,避免后续维护误导。 + +### Risk +Minimal。原假设(PaddleOCR 误标)无证据。如果真的误标,会从 `body_paragraph` 或 `unknown_structural` 通道走,family-profile rescue 仍然能兜底。 + +--- + +## Fix 5: Caption Matching — Filter Demoted Body Paragraphs from Legends + +### Problem +被 `candidate_resolution` 降级为 `body_paragraph` 的块(原 `figure_caption_candidate`)仍然在 `figure_legends` 中,通过 `is_validation_first_candidate` 参与匹配,可抢占真 caption 的主 slot。 + +YGH7VEX6 Figure 11: 正文 p6:7("Figure 11 shows...")通过 `adjacent_x` 绑定左栏 assets,抢了 `figure_011`。真 caption p6:13 被踢到 `figure_s011`。 + +### Design — legend collection 入口过滤,不改 renderer + +在 `build_figure_inventory()` 的 legend collection 循环中,`is_validation_first_candidate` 之前加: + +```python +if block.get("role") == "body_paragraph" and block.get("seed_role") == "figure_caption_candidate": + rejected_legends.append({ + "page": block.get("page"), + "block_id": block.get("block_id", ""), + "text": block.get("text", ""), + "role": block.get("role", ""), + "seed_role": block.get("seed_role", ""), + "rejection_reason": "demoted_body_caption_candidate", + }) + continue +``` + +位置:在 `build_figure_inventory()` 的 `for block in structured_blocks` 循环中, +1. 跳过 `_non_body_media` / `non_body_insert` 后 +2. 跳过 panel label 后 +3. **计算 `is_validation_first_candidate` 之前** + +**不设置 `render_default=false` / `index_default=false`。** 这些块仍然是正常正文内容("Figure 11 shows..."),应该在 fulltext 中保留。 + +--- + +# P1 LAYOUT CLEANUP + +--- + +## Fix 1: Figure-Internal Text Containment Detection + +### Problem +文本块在空间上位于复合多图 bbox 内(panel labels、composition data、function labels),但未被识别为图内文字。泄漏到 fulltext 中成为 `##` 标题。 + +Page 3 of XD2BPCMG: 21 个文字块在 Figure 1 的 composite bbox 内,全部误标。 + +### Design + +新增 `_bind_inner_text_to_matched_figures()`,在 `build_figure_inventory()` 之后调用。 + +**复用基础:** +- `is_embedded_figure_text()` 已有初步逻辑(检查 block centroid 是否落在 figure/media asset 内,或窄文本与 asset 横向重叠) +- `matched_figures[].cluster_bbox` 已有 +- `_bbox_contains` (`ocr.py:833`) 已有 + +**实现:** + +``` +function _bind_inner_text_to_matched_figures(blocks, matched_figures): + for mf in matched_figures where mf.status == "matched": + fig_bbox = mf.get("cluster_bbox") # 优先使用 + if not fig_bbox: + assets = [b for b in blocks if b.id in mf.asset_block_ids] + fig_bbox = _cluster_bbox(assets) + + # guard: 跳过 page_area_ratio > 0.80 AND asset_fill_ratio very low 的假大框 + page_area = page_width * page_height + bbox_area = (fig_bbox.x2 - fig_bbox.x1) * (fig_bbox.y2 - fig_bbox.y1) + if page_area > 0 and bbox_area / page_area > 0.80: + asset_bboxes = [b.bbox for b in assets] + asset_area_sum = sum((b.x2-b.x1)*(b.y2-b.y1) for b in asset_bboxes) + if asset_area_sum / bbox_area < 0.3: + continue # 大框里没填满,可能是跨页合并 + + for block in same_page_blocks: + if block.role in PROTECTED_ROLES: skip + if _bbox_contains(fig_bbox, block.bbox, margin=10): + block["role"] = "figure_inner_text" + block["render_default"] = False + block["index_default"] = False +``` + +**PROTECTED_ROLES**(不覆盖): +- `figure_caption`, `figure_caption_candidate`, `table_caption` +- `paper_title`, `authors`, `abstract_heading`, `abstract_body` +- `reference_heading`, `reference_item`, `backmatter_heading`, `backmatter_body` +- `noise`, `page_header`, `page_footer` + +**注意:** 这是 render-hygiene pass,不是匹配防护。`build_figure_inventory()` 内部的 panel-title suppression 仍然是 legend 匹配的首道防线。本函数只解决 fulltext 泄漏问题。 + +### Placement + +在 `build_figure_inventory()` 返回后、`render_fulltext_markdown()` 之前。**不放在 `normalize_document_structure` 内**——此时 `matched_figures` 还不存在。 + +```text +orchestration: + structured_blocks = build_document_structure(raw_blocks) + figure_inventory = build_figure_inventory(structured_blocks) + _bind_inner_text_to_matched_figures(structured_blocks, figure_inventory) # ← 在这里 + render_fulltext_markdown(structured_blocks, figure_inventory, ...) +``` + +Helper 定义在 `ocr_figures.py` 或专用 postprocess 模块。 + +--- + +## Fix 3: Reference-Boundary Backmatter Redesign + +**不在此 spec 中实施。** 拆为独立 spec,需覆盖: + +1. 找 verified `reference_heading` / `reference_zone` +2. `reference_heading` 之前:禁止 backmatter role promotion +3. `reference_zone` 内:保留 `reference_heading` / `reference_item` +4. `reference_zone` 之后: + - same-page below reference zone end:可进入 `post_ref_backmatter` + - later pages:可进入 `post_ref_backmatter` +5. renderer `_reorder_tail_run()` 同步改,不再按旧 `backmatter_boundary_heading` 分组 +6. 不能简单"reference_heading 后全是 backmatter"——reference list 本身在 heading 后面,`reference_zone` / `reference_item` 优先 + +当前代码的 tail/backmatter 逻辑分布在 `_promote_tail_body_candidates`、`_assign_tail_spread_ownership`、`_reorder_tail_run` 等,不是按几个函数名删能搞定的。需要单独设计和测试。 + +--- + +## Verification Plan + +| Fix | Verification | +|-----|-------------| +| Fix 2 | 25K5KZAQ fulltext references: `[1],[2],[3],...,[10],[11],...` 正确数字顺序 | +| Fix 4 | YGH7VEX6 Figure 2 caption role = `figure_caption`,matched figure 有正确 legend | +| Fix 5 | YGH7VEX6 Figure 11: `figure_011` 用 p6:13(真 caption),无 `figure_s011` 条目。p6:7 仍在 fulltext 正文中 | +| Fix 1 | XD2BPCMG page 3: `(b) Type of cartilage` role = `figure_inner_text`,不在 fulltext 中作为 heading | +| Fix 3 | 单独 spec,单独验证 | + +### Test commands + +```bash +python -m pytest tests/ -v --tb=short -k "non_body_insert or reference_sort or caption_matching" +python .opencode/skills/paperforge-development/scripts/ocr_truth_audit.py YGH7VEX6 --refresh-artifacts --source-root D:/L/OB/Literature-hub/System/PaperForge/ocr +# verify figure_inventory.json for Figure 2 (matched) and Figure 11 (legend=block 13) +``` diff --git a/paperforge/schema/field_registry.yaml b/paperforge/schema/field_registry.yaml index 2eed59f1..44b8862c 100644 --- a/paperforge/schema/field_registry.yaml +++ b/paperforge/schema/field_registry.yaml @@ -1,4 +1,67 @@ frontmatter: + aliases: + type: list + required: false + public: true + description: "Obsidian wikilink aliases" + owner: obsidian + introduced_in: "1.5" + tags: + type: list + required: false + public: true + description: "Obsidian tags" + owner: obsidian + introduced_in: "1.5" + journal: + type: str + required: false + public: true + description: "Journal name" + owner: sync + introduced_in: "1.0" + first_author: + type: str + required: false + public: true + description: "First author name" + owner: sync + introduced_in: "1.0" + pmid: + type: str + required: false + public: true + description: "PubMed ID" + owner: sync + introduced_in: "1.0" + impact_factor: + type: str + required: false + public: true + description: "Journal impact factor" + owner: sync + introduced_in: "1.0" + abstract: + type: str + required: false + public: true + description: "Paper abstract" + owner: sync + introduced_in: "1.0" + keywords: + type: list + required: false + public: true + description: "Paper keywords" + owner: sync + introduced_in: "1.0" + ocr_time: + type: str + required: false + public: false + description: "OCR completion timestamp" + owner: ocr + introduced_in: "2.1" zotero_key: type: str required: true diff --git a/paperforge/services/sync_service.py b/paperforge/services/sync_service.py index d1c273af..e2122ecd 100644 --- a/paperforge/services/sync_service.py +++ b/paperforge/services/sync_service.py @@ -325,9 +325,9 @@ class SyncService: } # ── Phase 1: Select ── - if not index_only: - import time as _time + import time as _time + if not index_only: from paperforge.worker.sync import run_selection_sync _t0 = _time.time() diff --git a/paperforge/worker/asset_index.py b/paperforge/worker/asset_index.py index 063f08ac..329c1456 100644 --- a/paperforge/worker/asset_index.py +++ b/paperforge/worker/asset_index.py @@ -347,6 +347,8 @@ def _build_entry(item: dict, vault: Path, paths: dict, domain: str, zotero_dir: (workspace_dir / "ai").mkdir(parents=True, exist_ok=True) if stale_workspace_fulltext.exists(): stale_workspace_fulltext.unlink() + if source_fulltext.exists(): + shutil.copy2(str(source_fulltext), str(stale_workspace_fulltext)) fulltext_exists = source_fulltext.exists() @@ -526,6 +528,29 @@ def _compute_export_hash(paths: dict) -> str: return h.hexdigest() +def _sync_workspace_fulltexts(items: list[dict], vault: Path, paths: dict, verbose: bool = False) -> None: + for entry in items: + if entry.get("ocr_status") not in ("done", "done_degraded"): + continue + ws_dir_rel = entry.get("paper_root", "") + if not ws_dir_rel: + continue + ws_dir = vault / ws_dir_rel + ws_ft = ws_dir / "fulltext.md" + if ws_ft.exists(): + continue + ft_rel = entry.get("fulltext_path", "") + if not ft_rel: + ocr_ft = paths["ocr"] / entry["zotero_key"] / "fulltext.md" + else: + ocr_ft = vault / ft_rel + if ocr_ft.exists(): + ws_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(ocr_ft), str(ws_ft)) + if verbose: + print(f" synced fulltext to workspace: {entry['zotero_key']}") + + def build_index(vault: Path, verbose: bool = False, force_rebuild: bool = False) -> int: """Full rebuild of the canonical asset index for *vault*.""" from paperforge.config import load_vault_config @@ -561,6 +586,7 @@ def build_index(vault: Path, verbose: bool = False, force_rebuild: bool = False) print("index-refresh: ocr_redo or ocr_time field missing in index, forcing rebuild") else: count = len(items) + _sync_workspace_fulltexts(items, vault, paths, verbose) if verbose: print(f"index-refresh: {count} entries (unchanged)") return count diff --git a/paperforge/worker/ocr.py b/paperforge/worker/ocr.py index f9ecc7f6..a41fbdec 100644 --- a/paperforge/worker/ocr.py +++ b/paperforge/worker/ocr.py @@ -69,6 +69,7 @@ from paperforge.worker.ocr_errors import ( ) from paperforge.worker.ocr_figures import ( build_figure_inventory, + tag_figure_contained_text, write_back_figure_roles, write_figure_inventory, ) @@ -1889,6 +1890,9 @@ def postprocess_ocr_result(vault: Path, key: str, all_results: list[dict]) -> tu table_inventory, ) + # Figure containment render hygiene: tag text blocks inside figure regions + tag_figure_contained_text(structured, figure_inventory.get("matched_figures", [])) + # Re-persist structured blocks with writeback roles (table_html, figure_asset) # ponytail: writes entire list again; if throughput matters, write only changed blocks write_structured_blocks_jsonl(artifacts.blocks_structured, structured) diff --git a/paperforge/worker/ocr_document.py b/paperforge/worker/ocr_document.py index 9262b835..3e89a697 100644 --- a/paperforge/worker/ocr_document.py +++ b/paperforge/worker/ocr_document.py @@ -3839,11 +3839,14 @@ def _detect_non_body_insert_clusters( if page > max_early_page: continue - # body_paragraph, figure_caption, figure_caption_candidate, and - # unknown_structural can be non-body inserts — bio/profile blocks that - # OCR misclassified as body text or figure titles. frontmatter_noise - # blocks are genuine furniture, not bios, so they are excluded. - _INSERT_CANDIDATE_ROLES = {"body_paragraph", "figure_caption", "figure_caption_candidate", "unknown_structural"} + # body_paragraph and unknown_structural can be non-body inserts — bio/profile + # blocks that OCR misclassified as body text. figure_caption and + # figure_caption_candidate are excluded because a genuine caption is narrower + # than body text for geometric reasons (it sits below a single-column figure). + # frontmatter_noise blocks are genuine furniture, not bios, so excluded too. + # ponytail: narrow bio blocks that OCR mislabels as figure_title/caption slip + # through — the family-profile rescue handles them if they land as body_paragraph. + _INSERT_CANDIDATE_ROLES = {"body_paragraph", "unknown_structural"} if block.get("role") not in _INSERT_CANDIDATE_ROLES: continue @@ -4832,9 +4835,7 @@ def _build_accepted_heading_block_ids(blocks: list[dict], doc_structure) -> set: duplicate_block_ids = _duplicate_block_ids_from_blocks(blocks) result = set() for item in heading_artifact: - if isinstance(item, str) and item.startswith("p") and ":" in item: - result.add(item) - elif isinstance(item, int) and str(item) not in duplicate_block_ids: + if isinstance(item, str) and item.startswith("p") and ":" in item or isinstance(item, int) and str(item) not in duplicate_block_ids: result.add(item) heading_seed_roles = {"section_heading", "subsection_heading", "sub_subsection_heading"} for block in blocks: diff --git a/paperforge/worker/ocr_figures.py b/paperforge/worker/ocr_figures.py index abbce7e6..37578b63 100644 --- a/paperforge/worker/ocr_figures.py +++ b/paperforge/worker/ocr_figures.py @@ -2946,6 +2946,20 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 # Skip single-letter panel labels (A, B, (C), A.) in figure legends if _PANEL_LABEL_PATTERN.match(str(block.get("text", "")).strip()): continue + # Skip body_paragraph that was demoted from figure_caption_candidate — + # candidate resolution already judged it as narrative prose, not a legend. + # Re-activating it via geometry/style signals causes body text to leak + # into legend matching (e.g. YGH7VEX6 Figure 11). + if role == "body_paragraph" and str(block.get("seed_role") or "") in { + "figure_caption_candidate", "figure_caption" + }: + block["_rejected_legend"] = { + "reason": "demoted_body_paragraph", + "role": role, + "seed_role": str(block.get("seed_role") or ""), + } + rejected_legends.append(block) + continue is_validation_first_candidate = _is_validation_first_legend_candidate(block) if role in ("figure_caption", "figure_caption_candidate") or is_validation_first_candidate: if role == "figure_caption_candidate" and ( @@ -4858,5 +4872,174 @@ def write_back_figure_roles(inventory: dict, structured_blocks: list[dict]) -> N break +_LEAK_ROLES = { + "body_paragraph", "section_heading", "subsection_heading", + "sub_subsection_heading", "backmatter_heading", "backmatter_body", + "tail_candidate_body", "footnote", "structured_insert", + "non_body_insert", "frontmatter_noise", +} + + +def _cluster_bboxes_by_proximity( + bboxes: list[list[float]], + margin: float = 40, +) -> list[list[float]]: + if not bboxes: + return [] + if len(bboxes) == 1: + return [list(bboxes[0])] + n = len(bboxes) + parent = list(range(n)) + + def _find(x: int) -> int: + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + def _union(x: int, y: int) -> None: + px, py = _find(x), _find(y) + if px != py: + parent[py] = px + + for i in range(n): + ax1, ay1, ax2, ay2 = bboxes[i] + for j in range(i + 1, n): + bx1, by1, bx2, by2 = bboxes[j] + if (max(ax1, bx1) - margin < min(ax2, bx2) + margin + and max(ay1, by1) - margin < min(ay2, by2) + margin): + _union(i, j) + + clusters: dict[int, list[list[float]]] = {} + for i in range(n): + clusters.setdefault(_find(i), []).append(bboxes[i]) + return [ + [min(b[0] for b in g), min(b[1] for b in g), + max(b[2] for b in g), max(b[3] for b in g)] + for g in clusters.values() + ] + + +def _is_contained(block_bbox: list[float], region_bbox: list[float]) -> bool: + bx1, by1, bx2, by2 = block_bbox + rx1, ry1, rx2, ry2 = region_bbox + block_w = bx2 - bx1 + region_w = rx2 - rx1 + if block_w > region_w * 0.95: + return False + cx = (bx1 + bx2) / 2 + cy = (by1 + by2) / 2 + if not (rx1 <= cx <= rx2 and ry1 <= cy <= ry2): + return False + ix1 = max(bx1, rx1) + iy1 = max(by1, ry1) + ix2 = min(bx2, rx2) + iy2 = min(by2, ry2) + overlap = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1) + block_area = max(1.0, block_w * (by2 - by1)) + return overlap / block_area >= 0.85 + + +def _highly_overlaps_any_matched_region( + fallback_bbox: list[float], + figure_regions: list[tuple[str, list[float]]], +) -> bool: + fx1, fy1, fx2, fy2 = fallback_bbox + fallback_area = max(1.0, (fx2 - fx1) * (fy2 - fy1)) + for tag, region_bbox in figure_regions: + if tag != "matched": + continue + rx1, ry1, rx2, ry2 = region_bbox + ix1 = max(fx1, rx1) + iy1 = max(fy1, ry1) + ix2 = min(fx2, rx2) + iy2 = min(fy2, ry2) + overlap = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1) + if overlap / fallback_area > 0.5: + return True + return False + + +def _figure_region_bbox(mf: dict) -> list[float] | None: + cluster = mf.get("cluster_bbox") + if cluster and len(cluster) >= 4: + return cluster + asset = mf.get("asset_bbox") + if asset and len(asset) >= 4: + return asset + matched = mf.get("matched_assets", []) + bboxes = [a["bbox"] for a in matched if len(a.get("bbox") or []) >= 4] + if bboxes: + return _cluster_bbox(bboxes) + return None + + +def _matched_asset_keys(mf: dict) -> set[tuple[int, str]]: + keys: set[tuple[int, str]] = set() + page = int(mf.get("page", 0) or 0) + for asset in mf.get("matched_assets", []): + bid = asset.get("block_id") + if bid is not None: + keys.add((page, str(bid))) + for bid in mf.get("asset_block_ids", []): + if bid is not None: + keys.add((page, str(bid))) + return keys + + +def tag_figure_contained_text( + blocks: list[dict], + matched_figures: list[dict], +) -> None: + pages = {b.get("page") for b in blocks if b.get("page") is not None} + matched_by_page: dict[int, list[dict]] = {} + for mf in matched_figures: + p = mf.get("page") + if p is not None: + matched_by_page.setdefault(p, []).append(mf) + for page in sorted(pages): + page_blocks = [b for b in blocks if b.get("page") == page] + figure_regions: list[tuple[str, list[float]]] = [] + covered_asset_keys: set[tuple[int, str]] = set() + for mf in matched_by_page.get(page, []): + region = _figure_region_bbox(mf) + if region: + figure_regions.append(("matched", region)) + covered_asset_keys |= _matched_asset_keys(mf) + fallback_assets = [ + b for b in page_blocks + if (int(b.get("page", 0) or 0), str(b.get("block_id", ""))) not in covered_asset_keys + and b.get("role") in {"figure_asset", "media_asset"} + and b.get("role") not in {"table_html", "table_asset"} + and ( + b.get("asset_family_hint") == "figure_like" + or str(b.get("raw_label", "") or "") in {"image", "chart", "figure_title", "figure"} + ) + ] + if fallback_assets: + fallback_bboxes = [b["bbox"] for b in fallback_assets if len(b.get("bbox") or []) >= 4] + for fr in _cluster_bboxes_by_proximity(fallback_bboxes, margin=40): + if not _highly_overlaps_any_matched_region(fr, figure_regions): + figure_regions.append(("fallback", fr)) + if not figure_regions: + continue + for block in page_blocks: + role = str(block.get("role") or "") + if role in {"figure_asset", "media_asset", "noise", + "figure_caption", "figure_caption_candidate", + "table_html", "table_asset", + "figure_inner_text"}: + continue + bbox = block.get("bbox") + if not bbox or len(bbox) < 4: + continue + for _, fr in figure_regions: + if _is_contained(bbox, fr): + block["_figure_contained"] = True + if role in _LEAK_ROLES: + block["role"] = "figure_inner_text" + break + + def write_figure_inventory(dst: Path, inventory: dict[str, Any]) -> None: write_json(dst, inventory) diff --git a/paperforge/worker/ocr_render.py b/paperforge/worker/ocr_render.py index 90df64f0..70c0da6b 100644 --- a/paperforge/worker/ocr_render.py +++ b/paperforge/worker/ocr_render.py @@ -480,9 +480,13 @@ def _attach_container_bodies( def _ref_number_sort_key(block: dict) -> tuple: text = str(block.get("text") or block.get("block_content") or "") - m = re.match(r"^\s*(\d+)[\.\)]", text) + # Two capture groups — alternation prevents false matches on plain year numbers + # like "2024" that lack a period/bracket suffix. + # ponytail: only handles N. / N) / [N] prefix formats. Unnumbered refs fall + # through to lexicographic sort — upgrade to column-major sort if needed. + m = re.match(r"^\s*(?:(\d+)[\.\)]|\[(\d+)\])", text) if m: - return (0, int(m.group(1))) + return (0, int(m.group(1) or m.group(2))) return (1, text) diff --git a/tests/unit/schema/test_field_registry.py b/tests/unit/schema/test_field_registry.py index 3035d386..4c309e06 100644 --- a/tests/unit/schema/test_field_registry.py +++ b/tests/unit/schema/test_field_registry.py @@ -54,6 +54,8 @@ class TestFrontmatterFields: "collection_path", "collection_tags", "has_pdf", "pdf_path", "supplementary", "fulltext_md_path", "recommend_analyze", "analyze", "do_ocr", "ocr_status", "ocr_redo", "deep_reading_status", "path_error", + "aliases", "tags", "journal", "first_author", "pmid", + "impact_factor", "abstract", "keywords", "ocr_time", } assert set(fm.keys()) == expected diff --git a/tests/unit/worker/test_figure_containment.py b/tests/unit/worker/test_figure_containment.py new file mode 100644 index 00000000..402c02b4 --- /dev/null +++ b/tests/unit/worker/test_figure_containment.py @@ -0,0 +1,178 @@ +"""Tests for figure containment render hygiene.""" +from __future__ import annotations + +from paperforge.worker.ocr_figures import ( + _cluster_bboxes_by_proximity, + _highly_overlaps_any_matched_region, + _is_contained, + _matched_asset_keys, + tag_figure_contained_text, +) + + +def _bbox(x1, y1, x2, y2): + return [float(v) for v in (x1, y1, x2, y2)] + + +class TestIsContained: + def test_inside_block_gets_tagged(self): + block = _bbox(100, 100, 300, 300) + region = _bbox(50, 50, 400, 400) + assert _is_contained(block, region) + + def test_full_width_block_not_contained(self): + block = _bbox(50, 100, 400, 300) + region = _bbox(50, 50, 400, 400) + assert not _is_contained(block, region) + + def test_centroid_outside_region_not_contained(self): + block = _bbox(50, 450, 200, 550) + region = _bbox(50, 50, 400, 400) + assert not _is_contained(block, region) + + def test_low_overlap_not_contained(self): + block = _bbox(50, 50, 400, 500) + region = _bbox(50, 50, 400, 400) + assert not _is_contained(block, region) + + +class TestClusterBboxesByProximity: + def test_merges_overlapping_boxes(self): + bboxes = [ + _bbox(100, 100, 200, 200), + _bbox(180, 180, 280, 280), + _bbox(500, 500, 600, 600), + ] + result = _cluster_bboxes_by_proximity(bboxes, margin=40) + assert len(result) == 2 + + def test_empty_input(self): + assert _cluster_bboxes_by_proximity([]) == [] + + def test_single_bbox(self): + b = [_bbox(10, 10, 100, 100)] + result = _cluster_bboxes_by_proximity(b) + assert len(result) == 1 + assert result[0] == _bbox(10, 10, 100, 100) + + +class TestHighlyOverlapsAnyMatchedRegion: + def test_drops_when_over_50pct(self): + fallback = _bbox(100, 100, 300, 300) + regions = [("matched", _bbox(120, 120, 280, 280))] + assert _highly_overlaps_any_matched_region(fallback, regions) + + def test_keeps_when_no_overlap(self): + fallback = _bbox(500, 500, 600, 600) + regions = [("matched", _bbox(100, 100, 200, 200))] + assert not _highly_overlaps_any_matched_region(fallback, regions) + + def test_ignores_fallback_tag_regions(self): + fallback = _bbox(100, 100, 300, 300) + regions = [("fallback", _bbox(100, 100, 300, 300))] + assert not _highly_overlaps_any_matched_region(fallback, regions) + + +class TestMatchedAssetKeys: + def test_collects_from_matched_assets_and_asset_block_ids(self): + mf = { + "page": 3, + "matched_assets": [{"block_id": "a1"}, {"block_id": "a2"}], + "asset_block_ids": ["a2", "a3"], + } + keys = _matched_asset_keys(mf) + assert keys == {(3, "a1"), (3, "a2"), (3, "a3")} + + +class TestTagFigureContainedText: + def _block(self, bid, page, x1, y1, x2, y2, role="body_paragraph", + raw_label="", asset_family_hint=""): + b = { + "block_id": bid, + "page": page, + "bbox": _bbox(x1, y1, x2, y2), + "role": role, + } + if raw_label: + b["raw_label"] = raw_label + if asset_family_hint: + b["asset_family_hint"] = asset_family_hint + return b + + def _matched_fig(self, page, cluster_bbox, matched_assets=None): + return { + "page": page, + "cluster_bbox": cluster_bbox, + "matched_assets": matched_assets or [], + "asset_block_ids": [a["block_id"] for a in (matched_assets or [])], + } + + def test_body_paragraph_inside_matched_region_becomes_figure_inner_text(self): + region = _bbox(50, 50, 400, 400) + block = self._block("b1", 1, 100, 100, 300, 300, role="body_paragraph") + mf = self._matched_fig(1, region, matched_assets=[{"block_id": "a1", "bbox": _bbox(50, 50, 400, 400)}]) + blocks = [block, self._block("a1", 1, 50, 50, 400, 400, role="figure_asset")] + tag_figure_contained_text(blocks, [mf]) + assert block.get("_figure_contained") is True + assert block["role"] == "figure_inner_text" + + def test_body_paragraph_inside_fallback_region_becomes_figure_inner_text(self): + block = self._block("b1", 1, 100, 100, 300, 300, role="body_paragraph") + fallback_asset = self._block("a1", 1, 50, 50, 400, 400, role="media_asset", + raw_label="image", asset_family_hint="figure_like") + blocks = [block, fallback_asset] + tag_figure_contained_text(blocks, []) + assert block.get("_figure_contained") is True + assert block["role"] == "figure_inner_text" + + def test_block_outside_region_unchanged(self): + region = _bbox(50, 50, 200, 200) + block = self._block("b1", 1, 300, 300, 400, 400, role="body_paragraph") + mf = self._matched_fig(1, region, matched_assets=[{"block_id": "a1", "bbox": _bbox(50, 50, 200, 200)}]) + blocks = [block, self._block("a1", 1, 50, 50, 200, 200, role="figure_asset")] + tag_figure_contained_text(blocks, [mf]) + assert not block.get("_figure_contained") + assert block["role"] == "body_paragraph" + + def test_figure_asset_never_tagged(self): + region = _bbox(50, 50, 400, 400) + asset = self._block("a1", 1, 100, 100, 200, 200, role="figure_asset") + mf = self._matched_fig(1, region, matched_assets=[{"block_id": "a1", "bbox": _bbox(50, 50, 400, 400)}]) + blocks = [asset] + tag_figure_contained_text(blocks, [mf]) + assert not asset.get("_figure_contained") + assert asset["role"] == "figure_asset" + + def test_asset_in_matched_excluded_from_fallback(self): + block = self._block("b1", 1, 100, 100, 300, 300, role="body_paragraph") + matched_asset = self._block("a1", 1, 50, 50, 400, 400, role="media_asset", + raw_label="image", asset_family_hint="figure_like") + mf = self._matched_fig(1, _bbox(50, 50, 400, 400), + matched_assets=[{"block_id": "a1", "bbox": _bbox(50, 50, 400, 400)}]) + blocks = [block, matched_asset] + tag_figure_contained_text(blocks, [mf]) + assert block.get("_figure_contained") is True + + def test_table_html_not_in_fallback(self): + block = self._block("b1", 1, 100, 100, 300, 300, role="body_paragraph") + table = self._block("t1", 1, 50, 50, 400, 400, role="table_html") + blocks = [block, table] + tag_figure_contained_text(blocks, []) + assert not block.get("_figure_contained") + + def test_render_default_index_default_unchanged(self): + region = _bbox(50, 50, 400, 400) + for role in ("figure_caption", "noise", "figure_inner_text", "table_html", "table_asset"): + block = self._block("b1", 1, 100, 100, 300, 300, role=role) + mf = self._matched_fig(1, region, matched_assets=[{"block_id": "a1", "bbox": _bbox(50, 50, 400, 400)}]) + blocks = [block, self._block("a1", 1, 50, 50, 400, 400, role="figure_asset")] + tag_figure_contained_text(blocks, [mf]) + assert not block.get("_figure_contained"), f"role={role} should not be tagged" + + def test_structured_insert_inside_figure_becomes_figure_inner_text(self): + region = _bbox(50, 50, 400, 400) + block = self._block("b1", 1, 100, 100, 300, 300, role="structured_insert") + mf = self._matched_fig(1, region, matched_assets=[{"block_id": "a1", "bbox": _bbox(50, 50, 400, 400)}]) + blocks = [block, self._block("a1", 1, 50, 50, 400, 400, role="figure_asset")] + tag_figure_contained_text(blocks, [mf]) + assert block["role"] == "figure_inner_text"