Skip to content

Commit 66dfdaf

Browse files
committed
Harden figure placeholder and table crop gates
1 parent dd85992 commit 66dfdaf

11 files changed

Lines changed: 201 additions & 6 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Subproject commit 6cf896712dced7a03844cc2925d0589e3db60c19

SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,9 @@ Local-library-first rule (applies only when the Zotero check above succeeds):
133133
- High-quality notes should usually contain multiple meaningful `###` subheadings in the technical sections when the paper is non-trivial.
134134
- The note must include figure/table placeholders for all major visuals rather than silently skipping them.
135135
- Every kept figure/table placeholder must appear directly under the most relevant analytical section named by its `建议位置`; do not collect unresolved placeholders in catch-all sections such as `剩余图表占位` or `Remaining figures`.
136+
- Every kept figure/table placeholder must use the standard `> [!figure]` callout format with `建议位置`, `放置原因`, and `当前状态`; do not use ordinary paragraph markers such as `[图表占位 | Fig. 1]`, `图表占位:Table 2`, or `Figure Placeholder | Fig. 3`.
136137
- Real images may replace some placeholders, but only if they clearly match the corresponding paper figure/table.
138+
- When inserting a real image, prefer the `obsidian_embed` returned by `scripts/materialize_figure_asset.py`.
137139
- Figure captions in the note must preserve the original paper numbering such as `Fig. 1` or `Table 2`.
138140
- The note must pass a style gate: no mixed Chinese-English prose lines except stable proper nouns or citation metadata.
139141
- Style gate enforcement: when `lint_note.py` output contains `passes_style_gate: false`, fix the reported issues and re-run lint. Keep fixing and re-running until lint passes — multiple rounds are normal and expected. Do not decide that any failure is an acceptable exception — proper nouns, math formulas, and citation metadata are not automatic exemptions. Only escalate to the user if the same failures appear unchanged across multiple rounds with no reduction, indicating the model is unable to make further progress independently.

references/figure-placement.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,15 @@ Fail closed when visual usability is weak: keep the placeholder instead of inser
7171
Reject candidates that are:
7272
- caption-only crops
7373
- tables with no visible table body
74+
- table crops contaminated by running prose or another Figure/Table caption
7475
- large text, title-page, or abstract crops masquerading as figures
7576
- crops where the visual body is tiny relative to the crop
7677

7778
## Placeholder Requirements
7879

80+
Every kept placeholder in the final note must use the standard `[!figure]` callout format.
81+
Do not use ordinary paragraph markers such as `[图表占位 | Fig. 1]`, `图表占位:Table 2`, or `Figure Placeholder | Fig. 3`.
82+
7983
Each placeholder should include:
8084
- figure or table id
8185
- a short label
@@ -102,6 +106,7 @@ The placeholder text should be stable and explicit:
102106
If a real image is inserted:
103107
- keep the original paper identifier, for example `Fig. 2` or `Table 1`
104108
- do not renumber it according to note order
109+
- prefer the `obsidian_embed` returned by `scripts/materialize_figure_asset.py`
105110
- if the extracted image is only a subpanel or partial crop, say so explicitly
106111

107112
## When to Skip

references/final-writing.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,16 +211,18 @@ If the bundle contains candidate figure pages or candidate image files:
211211
- prefer the candidate with the strongest caption/page-context agreement
212212
- treat identity match and visual usability as separate gates
213213
- never treat a matching label or caption as sufficient approval to insert an image
214-
- reject caption-only crops, missing table bodies, large text/title/abstract crops, and crops with very low visual body ratio
214+
- reject caption-only crops, missing table bodies, table crops contaminated by running prose or another Figure/Table caption, large text/title/abstract crops, and crops with very low visual body ratio
215215
- if visual quality is missing, ambiguous, or failed, keep the placeholder
216216
- still make the final decision yourself rather than trusting the candidate ranking blindly
217217

218218
Final-note figure rules:
219219
- keep the original paper numbering, such as `Fig. 1`, `Fig. 3`, `Table 2`
220220
- do not rename them to `图 1`, `图 2` just because of note order
221221
- if you replace a placeholder with a real image, keep the same paper figure id in the caption
222+
- if you replace a placeholder with a real image, prefer the `obsidian_embed` returned by `scripts/materialize_figure_asset.py`
222223
- if an important figure cannot be confidently extracted, keep a placeholder with a short explanation
223224
- every kept placeholder must appear directly under its most relevant analytical section; do not create catch-all sections such as `剩余图表占位`, `未放置图表`, `Remaining figures`, or `Leftover figures`
225+
- every kept placeholder must use the standard `[!figure]` callout format; never use ordinary paragraph markers such as `[图表占位 | Fig. 1]`, `图表占位:Table 2`, or `Figure Placeholder | Fig. 3`
224226
- `reject_visual_quality` means the candidate image is unsafe to insert, not that the final note must keep a placeholder for that rejected candidate
225227
- for survey papers, summarize repetitive representative-work figures or appendix tables in prose when they do not materially help the reader as standalone callouts
226228
- text may be complete even when figures are partial; do not let missing images erase textual coverage

references/model-synthesis.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ The rule is simple:
3737
- inspect `figure_assets[].quality_signals` when available
3838
- match likely figures by page proximity, caption context, and candidate snippets
3939
- require both identity match and visual usability before insertion
40-
- fail closed on caption-only crops, missing table bodies, large text/title/abstract crops, or very low visual body ratio
40+
- fail closed on caption-only crops, missing table bodies, table crops contaminated by running prose or another Figure/Table caption, large text/title/abstract crops, or very low visual body ratio
4141
- keep the final semantic matching decision on the model side
4242
Build the note in placeholder-first order:
4343
- plan placeholders for all major figures/tables that matter to the note
@@ -51,10 +51,11 @@ The rule is simple:
5151
- `> 建议位置:...`
5252
- `> 放置原因:...`
5353
- `> 当前状态:...`
54+
- never use ordinary paragraph markers such as `[图表占位 | Fig. 1]`, `图表占位:Table 2`, or `Figure Placeholder | Fig. 3`
5455
If you decide to insert a real image instead of leaving a placeholder:
5556
- call `scripts/materialize_figure_asset.py`
5657
- copy the chosen candidate image into the vault
57-
- insert the returned Obsidian embed into the note
58+
- insert the returned Obsidian embed into the note; Markdown image syntax is a compatibility fallback, not the preferred note format
5859
This figure step belongs to the same note-generation task:
5960
- do not stop after a text-only draft just to ask the user whether figures should be inserted
6061
- finish the replacement-or-placeholder decision before final save

scripts/build_synthesis_bundle.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,11 +365,13 @@ def bundle(metadata: dict, evidence_wrapper: dict, figures_wrapper: dict, assets
365365
"figure_rules": [
366366
"先规划主要图表的占位标签,再决定哪些可以替换成真实图片",
367367
"label/caption match is not insertion approval; it only proves identity, and visual usability is a separate hard gate.",
368-
"Reject caption-only crops, missing table body crops, large text/title/abstract crops, and low visual body ratio crops.",
368+
"Reject caption-only crops, missing table body crops, table crops with paragraph or other-caption contamination, large text/title/abstract crops, and low visual body ratio crops.",
369369
"If visual quality is missing, ambiguous, or marked review/reject, fail closed: keep the placeholder instead of inserting the image.",
370370
"Every kept placeholder must appear directly under the most relevant analytical section named by its `建议位置`; do not collect unresolved placeholders in catch-all sections such as `剩余图表占位`, `未放置图表`, `Remaining figures`, or `Leftover figures`.",
371+
"Every kept placeholder must use the standard `> [!figure]` callout with `建议位置`, `放置原因`, and `当前状态`; never use plain paragraph placeholders such as `[图表占位 | Fig. 1]`, `图表占位:Table 2`, or `Figure Placeholder | Fig. 3`.",
371372
"`reject_visual_quality` means the candidate image is unsafe to insert; it does not require keeping a final-note placeholder for every rejected candidate.",
372373
"For survey papers, summarize repetitive representative-work figures or appendix tables in prose when they do not materially help the reader as standalone callouts.",
374+
"When inserting a real image, prefer the `obsidian_embed` returned by materialize_figure_asset.py; Markdown image syntax is a compatibility fallback, not the preferred note format.",
373375
"如果没有高置信度图像匹配,不要删除占位标签",
374376
"图注必须保留论文原始编号,例如 Fig. 1、Table 2",
375377
"如果插入的是局部子图或不完整裁剪,必须明确说明",

scripts/extract_pdf_assets.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,20 +91,26 @@ def _classify_visual_quality(
9191
paragraph_text_chars: int,
9292
table_body_rows: int,
9393
caption_text_chars: int,
94+
other_caption_labels: list[str] | None = None,
9495
) -> dict:
9596
"""Classify whether a caption-matched crop is visually usable.
9697
9798
This is intentionally conservative. A label/caption match proves identity,
9899
but not that the rendered crop contains the figure or table body.
99100
"""
100101
normalized_kind = kind.strip().lower()
102+
other_caption_labels = list(other_caption_labels or [])
101103
reasons: list[str] = []
102104

103105
if normalized_kind == "table":
104106
if table_body_rows <= 0:
105107
reasons.append("table_body_missing")
106108
if table_body_rows <= 1 and visual_body_ratio < 0.03 and caption_text_chars >= 40:
107109
reasons.append("caption_only_suspected")
110+
if paragraph_text_chars >= 450:
111+
reasons.append("table_text_contamination_suspected")
112+
if other_caption_labels:
113+
reasons.append("multiple_caption_regions_suspected")
108114
status = "reject" if reasons else "usable"
109115
else:
110116
if paragraph_text_chars >= 450:
@@ -131,6 +137,8 @@ def _classify_visual_quality(
131137
"paragraph_text_chars": int(paragraph_text_chars),
132138
"table_body_rows": int(table_body_rows),
133139
"caption_text_chars": int(caption_text_chars),
140+
"other_caption_count": len(other_caption_labels),
141+
"other_caption_labels": other_caption_labels,
134142
}
135143

136144

@@ -434,6 +442,34 @@ def _count_paragraph_text_chars_in_bbox(
434442
return chars
435443

436444

445+
def _other_caption_labels_for_crop(
446+
caption_anchors: list[dict],
447+
current_anchor: dict,
448+
bbox: tuple[float, float, float, float],
449+
) -> list[str]:
450+
"""Return other caption labels substantially covered by this crop."""
451+
labels: list[str] = []
452+
current_label = normalize_whitespace(str(current_anchor.get("label", "")))
453+
current_bbox = tuple(current_anchor.get("bbox", ()))
454+
455+
for anchor in caption_anchors:
456+
label = normalize_whitespace(str(anchor.get("label", "")))
457+
anchor_bbox = tuple(anchor.get("bbox", ()))
458+
if not label or len(anchor_bbox) != 4:
459+
continue
460+
if label == current_label and anchor_bbox == current_bbox:
461+
continue
462+
463+
overlap = _intersection_area(anchor_bbox, bbox)
464+
if overlap <= 0:
465+
continue
466+
if overlap / max(_rect_area(anchor_bbox), 1.0) < 0.5:
467+
continue
468+
labels.append(label)
469+
470+
return sorted(set(labels))
471+
472+
437473
def _quality_signals_for_crop(
438474
page,
439475
kind: str,
@@ -442,13 +478,15 @@ def _quality_signals_for_crop(
442478
page_rect,
443479
*,
444480
table_body_rows: int,
481+
caption_anchors: list[dict] | None = None,
445482
) -> dict:
446483
page_area = _rect_area((page_rect.x0, page_rect.y0, page_rect.x1, page_rect.y1))
447484
page_coverage_ratio = _rect_area(bbox) / page_area if page_area > 0 else 0.0
448485
visual_rect_count, visual_body_ratio = _visual_signal_for_bbox(page, bbox)
449486
caption_bbox = tuple(caption_anchor["bbox"])
450487
paragraph_text_chars = _count_paragraph_text_chars_in_bbox(page, bbox, caption_bbox)
451488
caption_text_chars = len(normalize_whitespace(str(caption_anchor.get("line_text", ""))))
489+
other_caption_labels = _other_caption_labels_for_crop(caption_anchors or [], caption_anchor, bbox)
452490
return _classify_visual_quality(
453491
kind=kind,
454492
page_coverage_ratio=page_coverage_ratio,
@@ -457,6 +495,7 @@ def _quality_signals_for_crop(
457495
paragraph_text_chars=paragraph_text_chars,
458496
table_body_rows=table_body_rows,
459497
caption_text_chars=caption_text_chars,
498+
other_caption_labels=other_caption_labels,
460499
)
461500

462501

@@ -897,6 +936,7 @@ def extract_figure_regions(
897936
anchor,
898937
page_rect,
899938
table_body_rows=table_body_rows,
939+
caption_anchors=anchors,
900940
)
901941

902942
assets.append(

scripts/lint_note.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,21 @@
6868
"placeholders",
6969
}
7070

71+
NONSTANDARD_FIGURE_PLACEHOLDER_RE = re.compile(
72+
r"""(?ix)
73+
^\s*
74+
(?:
75+
\[\s*(?:图表|图片|图|表)\s*占位\s*\|[^\]]+\]
76+
|
77+
(?:图表|图片|图|表)\s*占位\s*[::]\s*\S+
78+
|
79+
\[\s*(?:figure|fig|table)\s+placeholder\s*(?:\||:|\]|-|\s+(?:fig(?:ure)?|table)\.?\s*\d)
80+
|
81+
(?:figure|fig|table)\s+placeholder\s*(?:\||:|-|\s+(?:fig(?:ure)?|table)\.?\s*\d)
82+
)
83+
"""
84+
)
85+
7186

7287
def parser() -> argparse.ArgumentParser:
7388
p = argparse.ArgumentParser(description=__doc__ or "lint note")
@@ -359,6 +374,23 @@ def figure_bucket_heading_issues(text: str) -> list[dict[str, object]]:
359374
return issues
360375

361376

377+
def nonstandard_figure_placeholder_issues(text: str) -> list[dict[str, object]]:
378+
issues: list[dict[str, object]] = []
379+
for idx, line in enumerate(text.splitlines(), start=1):
380+
stripped = line.strip()
381+
if not stripped or stripped.startswith(">"):
382+
continue
383+
if NONSTANDARD_FIGURE_PLACEHOLDER_RE.match(stripped):
384+
issues.append(
385+
{
386+
"line_number": idx,
387+
"line": stripped,
388+
"reason": "nonstandard_figure_placeholder_format",
389+
}
390+
)
391+
return issues
392+
393+
362394
def figure_callout_placement_issues(text: str) -> list[dict[str, object]]:
363395
issues: list[dict[str, object]] = []
364396
lines = text.splitlines()
@@ -420,7 +452,11 @@ def figure_callout_placement_issues(text: str) -> list[dict[str, object]]:
420452

421453

422454
def figure_structure_issues(text: str) -> list[dict[str, object]]:
423-
return figure_bucket_heading_issues(text) + figure_callout_placement_issues(text)
455+
return (
456+
figure_bucket_heading_issues(text)
457+
+ nonstandard_figure_placeholder_issues(text)
458+
+ figure_callout_placement_issues(text)
459+
)
424460

425461

426462
def figure_structure_passes(text: str) -> bool:

tests/test_extract_evidence.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,5 @@ def test_bundle_exposes_sanitized_figure_asset_quality_and_hard_gate_rules() ->
141141
assert "directly under the most relevant analytical section" in figure_rules_text
142142
assert "reject_visual_quality" in figure_rules_text
143143
assert "catch-all sections" in figure_rules_text
144+
assert "[!figure]" in figure_rules_text
145+
assert "[图表占位 |" in figure_rules_text

tests/test_extract_pdf_assets_quality.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from extract_pdf_assets import _classify_visual_quality
3+
from extract_pdf_assets import _classify_visual_quality, _other_caption_labels_for_crop
44

55

66
def test_quality_classification_rejects_table_without_body_rows() -> None:
@@ -33,6 +33,63 @@ def test_quality_classification_rejects_caption_only_crop() -> None:
3333
assert "caption_only_suspected" in signals["quality_reason_codes"]
3434

3535

36+
def test_quality_classification_rejects_table_with_paragraph_text_contamination() -> None:
37+
signals = _classify_visual_quality(
38+
kind="table",
39+
page_coverage_ratio=0.14,
40+
visual_rect_count=4,
41+
visual_body_ratio=0.18,
42+
paragraph_text_chars=620,
43+
table_body_rows=6,
44+
caption_text_chars=80,
45+
)
46+
47+
assert signals["visual_quality_status"] == "reject"
48+
assert "table_text_contamination_suspected" in signals["quality_reason_codes"]
49+
50+
51+
def test_quality_classification_rejects_table_covering_other_caption() -> None:
52+
signals = _classify_visual_quality(
53+
kind="table",
54+
page_coverage_ratio=0.18,
55+
visual_rect_count=6,
56+
visual_body_ratio=0.22,
57+
paragraph_text_chars=40,
58+
table_body_rows=8,
59+
caption_text_chars=90,
60+
other_caption_labels=["Table 8"],
61+
)
62+
63+
assert signals["visual_quality_status"] == "reject"
64+
assert signals["other_caption_labels"] == ["Table 8"]
65+
assert "multiple_caption_regions_suspected" in signals["quality_reason_codes"]
66+
67+
68+
def test_other_caption_labels_for_crop_detects_substantial_overlap() -> None:
69+
current = {"label": "Table 7", "bbox": (10.0, 10.0, 80.0, 24.0)}
70+
other = {"label": "Table 8", "bbox": (12.0, 120.0, 82.0, 136.0)}
71+
72+
labels = _other_caption_labels_for_crop([current, other], current, (0.0, 0.0, 100.0, 140.0))
73+
74+
assert labels == ["Table 8"]
75+
76+
77+
def test_quality_classification_accepts_clean_table_crop() -> None:
78+
signals = _classify_visual_quality(
79+
kind="table",
80+
page_coverage_ratio=0.16,
81+
visual_rect_count=5,
82+
visual_body_ratio=0.16,
83+
paragraph_text_chars=70,
84+
table_body_rows=7,
85+
caption_text_chars=70,
86+
other_caption_labels=[],
87+
)
88+
89+
assert signals["visual_quality_status"] == "usable"
90+
assert signals["quality_reason_codes"] == []
91+
92+
3693
def test_quality_classification_rejects_large_text_page_crop() -> None:
3794
signals = _classify_visual_quality(
3895
kind="figure",

0 commit comments

Comments
 (0)