mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
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:
parent
7dcb73b068
commit
b1fdbc30eb
14 changed files with 2028 additions and 24 deletions
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
148
docs/superpowers/specs/2026-06-27-ocr-v2-audit-p0-plan.md
Normal file
148
docs/superpowers/specs/2026-06-27-ocr-v2-audit-p0-plan.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
178
tests/unit/worker/test_figure_containment.py
Normal file
178
tests/unit/worker/test_figure_containment.py
Normal 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"
|
||||
Loading…
Reference in a new issue