feat: P0 fixes (ref sort, caption insert) + P1 figure containment render hygiene

P0:
- Fix 2: _ref_number_sort_key handles [N] bracket format (ocr_render.py:481)
- Fix 4: remove figure_caption from _INSERT_CANDIDATE_ROLES (ocr_document.py:3846)
- Fix 5: filter demoted body_paragraph from figure legends (ocr_figures.py:2950)

P1:
- figure containment render-hygiene pass after table inventory writeback
- 6 helpers: _cluster_bboxes_by_proximity, _is_contained,
  _highly_overlaps_any_matched_region, _figure_region_bbox,
  _matched_asset_keys, tag_figure_contained_text
- Wire call site in postprocess_ocr_result() (ocr.py:1891)

Infra:
- Sync workspace fulltext after rebuild (asset_index.py)
- Fix UnboundLocalError on index-refresh (sync_service.py)
- Add missing frontmatter fields to field registry (field_registry.yaml)
- 283 + 19 = 302 tests passing
This commit is contained in:
Research Assistant 2026-06-27 22:56:22 +08:00
parent 7dcb73b068
commit b1fdbc30eb
14 changed files with 2028 additions and 24 deletions

View file

@ -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 |

View file

@ -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:** ~5080 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

View file

@ -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 AE (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

View file

@ -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.

View file

@ -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)
```

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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"