Skip to content

Commit dd85992

Browse files
committed
Prevent catch-all figure placeholder sections
1 parent 6cf8967 commit dd85992

10 files changed

Lines changed: 334 additions & 0 deletions

SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ Local-library-first rule (applies only when the Zotero check above succeeds):
132132
- The `创新点` section should not be empty praise. It should enumerate the paper's actual innovations and briefly explain why each one matters.
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.
135+
- 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`.
135136
- Real images may replace some placeholders, but only if they clearly match the corresponding paper figure/table.
136137
- Figure captions in the note must preserve the original paper numbering such as `Fig. 1` or `Table 2`.
137138
- The note must pass a style gate: no mixed Chinese-English prose lines except stable proper nouns or citation metadata.

references/figure-placement.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,20 @@ Do not let scripts make the final semantic choice; scripts should only prepare c
4545
- Do not produce a text-only note first and then ask the user in a follow-up whether figures should be inserted.
4646
- If no figure can be confidently replaced, finish the note with placeholders and explain that outcome in the final response.
4747

48+
## Integrated Placement Rule
49+
50+
Every kept placeholder must be placed directly under the most relevant substantive section named by its `建议位置`.
51+
Do not collect unresolved placeholders into a catch-all section such as `剩余图表占位`, `未放置图表`, `Remaining figures`, or `Leftover figures`.
52+
53+
`reject_visual_quality` means the candidate image must not be inserted.
54+
It does not by itself require a final-note placeholder.
55+
The final placeholder set should come from semantic importance to the note, not from the number of failed extraction candidates.
56+
57+
For survey papers with many representative project figures, appendix tables, or repetitive supplemental visuals:
58+
- keep a callout only when the visual materially helps the reader understand the argument
59+
- otherwise summarize the pattern in prose or point the reader back to the appendix/source paper
60+
- do not stack low-value callouts just to demonstrate that the pipeline saw them
61+
4862
## Visual Quality Gate
4963

5064
Figure/table insertion has two separate gates:

references/final-writing.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,9 @@ Final-note figure rules:
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
222222
- if an important figure cannot be confidently extracted, keep a placeholder with a short explanation
223+
- 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`
224+
- `reject_visual_quality` means the candidate image is unsafe to insert, not that the final note must keep a placeholder for that rejected candidate
225+
- for survey papers, summarize repetitive representative-work figures or appendix tables in prose when they do not materially help the reader as standalone callouts
223226
- text may be complete even when figures are partial; do not let missing images erase textual coverage
224227
- complete the figure decision inside the same task as the note generation
225228
- do not stop after the text draft and ask the user whether to continue with figures unless they explicitly asked for a staged workflow

references/model-synthesis.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ The rule is simple:
4343
- plan placeholders for all major figures/tables that matter to the note
4444
- replace a placeholder with a real image only when the candidate is good enough
4545
- keep the original paper figure/table id in either case
46+
- place every kept placeholder directly under its most relevant analytical section; never create catch-all sections such as `剩余图表占位`, `未放置图表`, `Remaining figures`, or `Leftover figures`
47+
- treat `reject_visual_quality` as "do not insert this image", not as an obligation to keep a final-note placeholder for every rejected candidate
48+
- for surveys, use prose rather than stacked callouts for repetitive representative-work figures or appendix tables that do not materially help the reader
4649
- when keeping a placeholder, use the stable four-line callout format:
4750
- `> [!figure] Fig. 3 ...`
4851
- `> 建议位置:...`

scripts/build_synthesis_bundle.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,9 @@ def bundle(metadata: dict, evidence_wrapper: dict, figures_wrapper: dict, assets
367367
"label/caption match is not insertion approval; it only proves identity, and visual usability is a separate hard gate.",
368368
"Reject caption-only crops, missing table body crops, 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.",
370+
"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+
"`reject_visual_quality` means the candidate image is unsafe to insert; it does not require keeping a final-note placeholder for every rejected candidate.",
372+
"For survey papers, summarize repetitive representative-work figures or appendix tables in prose when they do not materially help the reader as standalone callouts.",
370373
"如果没有高置信度图像匹配,不要删除占位标签",
371374
"图注必须保留论文原始编号,例如 Fig. 1、Table 2",
372375
"如果插入的是局部子图或不完整裁剪,必须明确说明",

scripts/lint_note.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,51 @@
2323
"引用",
2424
]
2525

26+
FIGURE_TARGET_SECTIONS = {
27+
"研究问题",
28+
"数据与任务定义",
29+
"方法主线",
30+
"关键结果",
31+
"深度分析",
32+
"局限",
33+
"我的笔记",
34+
}
35+
36+
FIGURE_BUCKET_RESIDUE_TOKENS = {
37+
"剩余",
38+
"残余",
39+
"未放置",
40+
"未处理",
41+
"待补",
42+
}
43+
44+
FIGURE_BUCKET_VISUAL_TOKENS = {
45+
"图",
46+
"表",
47+
"图片",
48+
"图表",
49+
"占位",
50+
}
51+
52+
ENGLISH_FIGURE_BUCKET_RESIDUE_TOKENS = {
53+
"remaining",
54+
"leftover",
55+
"unplaced",
56+
"unresolved",
57+
"backlog",
58+
}
59+
60+
ENGLISH_FIGURE_BUCKET_VISUAL_TOKENS = {
61+
"figure",
62+
"figures",
63+
"fig",
64+
"figs",
65+
"table",
66+
"tables",
67+
"placeholder",
68+
"placeholders",
69+
}
70+
2671

2772
def parser() -> argparse.ArgumentParser:
2873
p = argparse.ArgumentParser(description=__doc__ or "lint note")
@@ -285,6 +330,103 @@ def inspect_figure_callouts(text: str) -> list[str]:
285330
return warnings
286331

287332

333+
def is_figure_bucket_heading(title: str) -> bool:
334+
normalized = title.strip().lower()
335+
has_chinese_residue = any(token in normalized for token in FIGURE_BUCKET_RESIDUE_TOKENS)
336+
has_chinese_visual = any(token in normalized for token in FIGURE_BUCKET_VISUAL_TOKENS)
337+
if has_chinese_residue and has_chinese_visual:
338+
return True
339+
has_english_residue = any(token in normalized for token in ENGLISH_FIGURE_BUCKET_RESIDUE_TOKENS)
340+
has_english_visual = any(token in normalized for token in ENGLISH_FIGURE_BUCKET_VISUAL_TOKENS)
341+
return has_english_residue and has_english_visual
342+
343+
344+
def figure_bucket_heading_issues(text: str) -> list[dict[str, object]]:
345+
issues: list[dict[str, object]] = []
346+
for idx, line in enumerate(text.splitlines(), start=1):
347+
match = re.match(r"^(#{2,3})\s+(.+)$", line.strip())
348+
if not match:
349+
continue
350+
heading = match.group(2).strip()
351+
if is_figure_bucket_heading(heading):
352+
issues.append(
353+
{
354+
"line_number": idx,
355+
"heading": heading,
356+
"reason": "figure_placeholder_bucket_heading",
357+
}
358+
)
359+
return issues
360+
361+
362+
def figure_callout_placement_issues(text: str) -> list[dict[str, object]]:
363+
issues: list[dict[str, object]] = []
364+
lines = text.splitlines()
365+
for idx, line in enumerate(lines):
366+
stripped = line.strip()
367+
if stripped.startswith("[FIGURE_PLACEHOLDER]"):
368+
issues.append(
369+
{
370+
"line_number": idx + 1,
371+
"line": stripped,
372+
"reason": "legacy_figure_placeholder_block_used",
373+
}
374+
)
375+
continue
376+
if not stripped.startswith("> [!figure]"):
377+
continue
378+
379+
current_section = section_name_for_line(lines, idx)
380+
current_subsection = subsection_name_for_line(lines, idx)
381+
location = ""
382+
j = idx + 1
383+
while j < len(lines):
384+
nxt = lines[j].strip()
385+
if not nxt.startswith(">"):
386+
break
387+
if nxt.startswith("> 建议位置:"):
388+
location = nxt.removeprefix("> 建议位置:").strip()
389+
break
390+
j += 1
391+
392+
if not location:
393+
issues.append(
394+
{
395+
"line_number": idx + 1,
396+
"callout": stripped,
397+
"current_section": current_section,
398+
"current_subsection": current_subsection,
399+
"reason": "figure_callout_missing_location",
400+
}
401+
)
402+
continue
403+
404+
if current_subsection and current_subsection in location:
405+
continue
406+
target_sections = [section for section in FIGURE_TARGET_SECTIONS if section in location]
407+
if target_sections and current_section not in target_sections:
408+
issues.append(
409+
{
410+
"line_number": idx + 1,
411+
"callout": stripped,
412+
"current_section": current_section,
413+
"current_subsection": current_subsection,
414+
"declared_location": location,
415+
"target_sections": target_sections,
416+
"reason": "figure_callout_placement_mismatch",
417+
}
418+
)
419+
return issues
420+
421+
422+
def figure_structure_issues(text: str) -> list[dict[str, object]]:
423+
return figure_bucket_heading_issues(text) + figure_callout_placement_issues(text)
424+
425+
426+
def figure_structure_passes(text: str) -> bool:
427+
return not figure_structure_issues(text)
428+
429+
288430
def is_prose_line(line: str) -> bool:
289431
stripped = line.strip()
290432
if not stripped:
@@ -653,7 +795,12 @@ def main() -> None:
653795
linebreak_issues = suspicious_mid_sentence_linebreaks(body_text)
654796
code_math_issues = suspicious_code_formatted_math(text)
655797
math_issues = math_render_issues(text)
798+
figure_issues = figure_structure_issues(text)
656799
warnings.extend(inspect_figure_callouts(text))
800+
for issue in figure_issues:
801+
reason = str(issue.get("reason", ""))
802+
if reason and reason not in warnings:
803+
warnings.append(reason)
657804
warnings.extend(front_matter_order_warnings(text))
658805
warnings.extend(mechanism_flow_warnings(text))
659806
if not body_text.lstrip().startswith("# "):
@@ -689,9 +836,11 @@ def main() -> None:
689836
"linebreak_issues": linebreak_issues,
690837
"code_math_issues": code_math_issues,
691838
"math_render_issues": math_issues,
839+
"figure_structure_issues": figure_issues,
692840
"passes_basic_structure": not missing_sections and not {"title_heading_missing", "no_level2_sections", "front_matter_order_invalid"} & set(warnings),
693841
"passes_style_gate": not mixed_issues and not linebreak_issues and not code_math_issues,
694842
"passes_math_gate": not math_issues,
843+
"passes_figure_gate": not figure_issues,
695844
}
696845
emit(payload, args.output)
697846

scripts/write_obsidian_note.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ def main() -> None:
5252
raise SystemExit("write_obsidian_note.py refused to write note because style gate failed.")
5353
if not lint.get("passes_math_gate", False):
5454
raise SystemExit("write_obsidian_note.py refused to write note because math gate failed.")
55+
if "passes_figure_gate" in lint and not lint.get("passes_figure_gate", False):
56+
raise SystemExit("write_obsidian_note.py refused to write note because figure gate failed.")
5557

5658
if args.content_file:
5759
note_text = Path(args.content_file).expanduser().resolve().read_text(encoding="utf-8")

tests/test_cli_fallback.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,79 @@ def test_write_note_refuses_when_math_gate_fails(tmp_path: Path) -> None:
132132
)
133133
assert result.returncode != 0
134134
assert "math gate failed" in result.stderr
135+
136+
137+
def test_write_note_accepts_legacy_lint_json_without_figure_gate(tmp_path: Path) -> None:
138+
lint_path = tmp_path / "lint.json"
139+
lint_path.write_text(
140+
json.dumps(
141+
{
142+
"passes_basic_structure": True,
143+
"passes_style_gate": True,
144+
"passes_math_gate": True,
145+
}
146+
),
147+
encoding="utf-8",
148+
)
149+
env = os.environ.copy()
150+
env.pop("DEEPPAPERNOTE_OBSIDIAN_VAULT", None)
151+
env.pop("READ_ARXIV_OBSIDIAN_VAULT", None)
152+
env["DEEPPAPERNOTE_WORKSPACE_OUTPUT_DIR"] = "DeepPaperNote_output"
153+
env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1"
154+
155+
result = subprocess.run(
156+
[
157+
sys.executable,
158+
str(WRITE_SCRIPT),
159+
"--title",
160+
"Legacy Lint Test",
161+
"--content",
162+
"# Legacy Lint Test\n\nBody.\n",
163+
"--lint-json",
164+
str(lint_path),
165+
],
166+
cwd=tmp_path,
167+
env=env,
168+
check=True,
169+
capture_output=True,
170+
text=True,
171+
)
172+
payload = json.loads(result.stdout)
173+
assert Path(payload["note_path"]).exists()
174+
175+
176+
def test_write_note_refuses_when_figure_gate_fails(tmp_path: Path) -> None:
177+
lint_path = tmp_path / "lint.json"
178+
lint_path.write_text(
179+
json.dumps(
180+
{
181+
"passes_basic_structure": True,
182+
"passes_style_gate": True,
183+
"passes_math_gate": True,
184+
"passes_figure_gate": False,
185+
}
186+
),
187+
encoding="utf-8",
188+
)
189+
env = os.environ.copy()
190+
env["DEEPPAPERNOTE_DISABLE_SHELL_CONFIG"] = "1"
191+
192+
result = subprocess.run(
193+
[
194+
sys.executable,
195+
str(WRITE_SCRIPT),
196+
"--title",
197+
"Figure Gate Test",
198+
"--content",
199+
"# Figure Gate Test\n\nBody.\n",
200+
"--lint-json",
201+
str(lint_path),
202+
],
203+
cwd=tmp_path,
204+
env=env,
205+
check=False,
206+
capture_output=True,
207+
text=True,
208+
)
209+
assert result.returncode != 0
210+
assert "figure gate failed" in result.stderr

tests/test_extract_evidence.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,6 @@ def test_bundle_exposes_sanitized_figure_asset_quality_and_hard_gate_rules() ->
138138
assert "caption-only" in figure_rules_text
139139
assert "missing table body" in figure_rules_text
140140
assert "low visual body ratio" in figure_rules_text
141+
assert "directly under the most relevant analytical section" in figure_rules_text
142+
assert "reject_visual_quality" in figure_rules_text
143+
assert "catch-all sections" in figure_rules_text

0 commit comments

Comments
 (0)