Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions skills/deeppapernote/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ Formal Save states:
- The `创新点` section should not be empty praise. It should enumerate the paper's actual innovations and briefly explain why each one matters.
- High-quality notes should usually contain multiple meaningful `###` subheadings in the technical sections when the paper is non-trivial.
- Generate the complete figure/table decision table and satisfy the generated `writing_contract.figure_table_contract` before drafting or saving.
- After the synthesis bundle is built, complete the model-led Visual Review Gate and Figure/Table Decision Freeze before creating `note_plan`; no `review_pending` item may cross that boundary.
- Pass the grounding and final-note figure gates before advancing; revise any failed decision coverage, insertion, structure, or status check.
- An `insert` decision is complete only after Formal Save materializes the selected image into the paper-local `images/` directory and the write succeeds.
- The note must pass a style gate: no mixed Chinese-English prose lines except stable proper nouns or citation metadata.
Expand Down
28 changes: 28 additions & 0 deletions skills/deeppapernote/references/figure-placement.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,36 @@ Figure/table insertion has two separate gates:
- visual usability: the crop actually contains the visual body needed by the reader

A label or caption match is not insertion approval.
Deterministic signals may reject an obvious defect, but only an actual image inspection may approve insertion.
Fail closed when visual usability is weak: keep the placeholder instead of inserting the candidate.

Every selected candidate begins as `review_pending`. Review it after the synthesis bundle exists and before `note_plan`:

1. Open the exact candidate crop and its complete source page preview.
2. Compare them with the matched Figure/Table identity and external caption.
3. Confirm that the crop is a Visual-Body Crop: the complete scientific visual is present, all internal labels remain readable, and the external caption and surrounding paper prose are absent.
4. Record the review in that candidate's existing decision entry using the generated `writing_contract.figure_table_contract.visual_review` fields and values.

A passing review is valid only for the decision entry's current asset SHA-256. Any asset change returns the item to `review_pending`. Complete the Figure/Table Decision Freeze before planning or drafting; grounding must reject an unresolved or stale review.

## Visual-Body Crop Boundary

Keep axes, tick labels, legends, color bars, scale bars, panel letters, table headers, method labels, and other text that belongs inside the scientific visual. Keep a small safety margin so edge labels are not clipped. Exclude the source caption and unrelated running prose from the image pixels while retaining the caption as metadata and later Markdown explanation.

If the external caption cannot be separated without damaging the scientific visual, fail closed and keep the placeholder. The first implementation reviews complete Figure/Table bodies only; do not split panels into separate assets.

## Bounded Crop Repair

Use a Bounded Crop Repair only for a geometry-only failure allowed by the generated contract. Put the page-relative normalized bbox and repair request into the same decision entry, then run:

```bash
python3 scripts/plan_figure_table_decisions.py \
--review-decisions <figure_table_decisions.json> \
--output <figure_table_decisions.json>
```

The script validates the bbox, rerenders once from the source PDF at the contract's 300 dpi, refreshes the asset SHA-256, and returns the item to `review_pending` for a full fresh review. A terminal defect or a failed repaired crop remains a placeholder; never request a second recrop.

Reject candidates that are:
- caption-only crops
- tables with no visible table body
Expand Down
10 changes: 10 additions & 0 deletions skills/deeppapernote/scripts/build_synthesis_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,15 @@ def compact_writing_contract() -> dict:
)
usable_insert_candidate = dict(WRITING_CONTRACT_RULES["usable_insert_candidate"])
usable_insert_candidate["kinds"] = list(usable_insert_candidate["kinds"])
visual_review_contract = deepcopy(WRITING_CONTRACT_RULES["visual_review_contract"])
for field in (
"review_fields",
"review_status_values",
"review_evidence_fields",
"repairable_failure_reasons",
"terminal_failure_reasons",
):
visual_review_contract[field] = list(visual_review_contract[field])
analysis_coverage = deepcopy(WRITING_CONTRACT_RULES["analysis_coverage_contract"])
analysis_coverage["central_claim_fields"] = list(
analysis_coverage["central_claim_fields"]
Expand Down Expand Up @@ -394,6 +403,7 @@ def compact_writing_contract() -> dict:
WRITING_CONTRACT_RULES["automatic_fail_closed_visual_statuses"]
),
"manual_review_claim_requires_image_inspection": True,
"visual_review": visual_review_contract,
},
"analysis_coverage_contract": analysis_coverage,
}
Expand Down
11 changes: 11 additions & 0 deletions skills/deeppapernote/scripts/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,17 @@ def ensure_parent(path: str | Path) -> None:
Path(path).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True)


def file_sha256(path: str | Path) -> str:
candidate = Path(path).expanduser()
if not candidate.is_file():
return ""
digest = hashlib.sha256()
with candidate.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()


def emit(payload: dict[str, Any], output_path: str | None = None) -> None:
text = json.dumps(payload, ensure_ascii=False, indent=2)
if output_path:
Expand Down
52 changes: 51 additions & 1 deletion skills/deeppapernote/scripts/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,14 @@ def required_field_value_error(
"candidate_chunks",
"section_texts",
),
"figure_decision_values": ("insert", "placeholder", "low_priority", "visual_defect", "skip"),
"figure_decision_values": (
"review_pending",
"insert",
"placeholder",
"low_priority",
"visual_defect",
"skip",
),
"usable_insert_candidate": {
"kinds": ("figure", "table"),
"visual_quality_status": "usable_candidate",
Expand All @@ -335,6 +342,49 @@ def required_field_value_error(
"reject_visual_quality",
"asset_candidate_missing",
),
"visual_review_contract": {
"selected_render_dpi": 300,
"page_preview_dpi": 96,
"review_fields": (
"status",
"reviewed_asset_sha256",
"preserved_scientific_elements",
"omitted_scientific_elements",
"notes",
"failure_reason",
"repair_attempts",
"revised_bbox",
),
"review_status_values": ("pending", "pass", "fail", "repair_requested"),
"repair_limit": 1,
"asset_sha256_bound": True,
"caption_free_visual_body_required": True,
"decision_freeze_before": "note_plan",
"review_evidence_fields": (
"candidate_path",
"page_preview_path",
"source_pdf_path",
"source_page",
"caption",
"bbox_pt",
"normalized_bbox",
"render_dpi",
),
"repairable_failure_reasons": (
"caption_contamination",
"surrounding_prose_contamination",
"scientific_content_clipped",
"insufficient_safety_margin",
),
"terminal_failure_reasons": (
"identity_mismatch",
"caption_inseparable",
"ambiguous_visual_body",
"unreadable_source",
"scientific_content_missing",
"repair_limit_exhausted",
),
},
"note_plan_depth_requirements": {
"required_section_focus_min_chars": 20,
"required_section_focus_fields": ("focus", "reading_goal", "purpose"),
Expand Down
75 changes: 61 additions & 14 deletions skills/deeppapernote/scripts/extract_pdf_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
except ImportError: # pragma: no cover
pytesseract = None

FIGURE_RENDER_DPI = 200
FIGURE_RENDER_DPI = 96
PAGE_PREVIEW_DPI = 96
MIN_FIGURE_HEIGHT_PT = 60
MIN_FIGURE_WIDTH_PT = 100

Expand Down Expand Up @@ -628,8 +629,6 @@ def _estimate_figure_bbox_above_caption(
the nearest body-text block above and the caption.
"""
caption_y_top = caption_anchor["bbox"][1]
caption_y_bottom = caption_anchor["bbox"][3]

upper_bound = 0.0
if prev_anchor is not None:
upper_bound = prev_anchor["bbox"][3] + 2.0
Expand All @@ -647,10 +646,32 @@ def _estimate_figure_bbox_above_caption(
relevant.append((r[0], r[1], r[2], clipped_y1))

if relevant:
caption_x0, _, caption_x1, _ = caption_anchor["bbox"]
x0 = min([r[0] for r in relevant] + [caption_x0])
visual_bbox = (
min(r[0] for r in relevant),
min(r[1] for r in relevant),
max(r[2] for r in relevant),
max(r[3] for r in relevant),
)
for line in _collect_text_lines(page):
text = normalize_whitespace(str(line.get("text", "")))
bb = tuple(line.get("bbox", ()))
if (
len(bb) != 4
or len(text) > 80
or CAPTION_RE.match(text)
or bb[1] >= caption_y_top - 1.0
or bb[3] <= upper_bound
):
continue
horizontal_gap = max(visual_bbox[0] - bb[2], bb[0] - visual_bbox[2], 0.0)
vertical_gap = max(visual_bbox[1] - bb[3], bb[1] - visual_bbox[3], 0.0)
if (vertical_gap == 0.0 and horizontal_gap <= 36.0) or (
horizontal_gap == 0.0 and vertical_gap <= 12.0
):
relevant.append((bb[0], bb[1], bb[2], min(bb[3], caption_y_top - 2.0)))
x0 = min(r[0] for r in relevant)
y0 = min(r[1] for r in relevant)
x1 = max([r[2] for r in relevant] + [caption_x1])
x1 = max(r[2] for r in relevant)
y1 = max(r[3] for r in relevant)
else:
body_blocks = _find_body_text_blocks(page)
Expand All @@ -663,9 +684,8 @@ def _estimate_figure_bbox_above_caption(
x1 = page_rect.x1
y1 = caption_y_top - 2.0

y1 = max(y1, caption_y_bottom + 2.0)

bbox = _clip_to_page((x0, y0, x1, y1), page_rect)
bbox = (bbox[0], bbox[1], bbox[2], min(bbox[3], caption_y_top - 1.0))
width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]
if width < MIN_FIGURE_WIDTH_PT or height < MIN_FIGURE_HEIGHT_PT:
Expand Down Expand Up @@ -927,13 +947,13 @@ def _finalize_table_bbox(
caption_anchor: dict,
extra_rects: list[tuple[float, float, float, float]],
page_rect,
*,
direction: str,
) -> tuple[float, float, float, float] | None:
if not extra_rects:
return None
caption_x0, caption_y0, caption_x1, caption_y1 = caption_anchor["bbox"]
accepted: list[tuple[float, float, float, float]] = list(extra_rects) + [
(caption_x0, caption_y0, caption_x1, caption_y1)
]
_, caption_y0, _, caption_y1 = caption_anchor["bbox"]
accepted: list[tuple[float, float, float, float]] = list(extra_rects)

y0 = min(b[1] for b in accepted)
y1 = max(b[3] for b in accepted)
Expand All @@ -954,6 +974,10 @@ def _finalize_table_bbox(
y1 = max(b[3] for b in accepted)

bbox = _clip_to_page((x0, y0, x1, y1), page_rect, padding=6.0)
if direction == "down":
bbox = (bbox[0], max(bbox[1], caption_y1 + 1.0), bbox[2], bbox[3])
else:
bbox = (bbox[0], bbox[1], bbox[2], min(bbox[3], caption_y0 - 1.0))
width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]
if width < MIN_FIGURE_WIDTH_PT or height < MIN_FIGURE_HEIGHT_PT:
Expand Down Expand Up @@ -998,7 +1022,6 @@ def _estimate_table_bbox_with_rows(
the same y-range, in case the paper places company-logo plots inside a
table cell.
"""
caption_y0 = caption_anchor["bbox"][1]
caption_y1 = caption_anchor["bbox"][3]

upper_bound = page_rect.y0
Expand Down Expand Up @@ -1040,11 +1063,19 @@ def _estimate_table_bbox_with_rows(
if up_data > down_data:
chosen = up_lines
chosen_data_rows = up_data
direction = "up"
else:
chosen = down_lines
chosen_data_rows = down_data
direction = "down"

bbox = _finalize_table_bbox(page, caption_anchor, chosen, page_rect)
bbox = _finalize_table_bbox(
page,
caption_anchor,
chosen,
page_rect,
direction=direction,
)
if bbox is None:
return None
return bbox, chosen_data_rows
Expand Down Expand Up @@ -1176,6 +1207,7 @@ def main() -> None:

asset_root = Path(args.assets_dir).expanduser().resolve() if args.assets_dir else default_assets_dir(record)
images_dir = asset_root / "images"
previews_dir = asset_root / "page_previews"
images_dir.mkdir(parents=True, exist_ok=True)

figure_dpi = args.figure_dpi
Expand Down Expand Up @@ -1209,6 +1241,20 @@ def main() -> None:
image_assets.extend(page_images)

page_figures = extract_figure_regions(page, page_number, images_dir, dpi=figure_dpi)
page_preview_path = ""
if page_figures:
preview_path = previews_dir / f"page_{page_number:03d}.png"
save_image_bytes(
preview_path,
_render_crop(
page,
(page.rect.x0, page.rect.y0, page.rect.x1, page.rect.y1),
PAGE_PREVIEW_DPI,
),
)
page_preview_path = str(preview_path)
for figure in page_figures:
figure["page_preview_path"] = page_preview_path
figure_assets.extend(page_figures)

page_records.append(
Expand All @@ -1219,6 +1265,7 @@ def main() -> None:
"ocr_used": extraction_method == "ocr",
"image_count": len(page_images),
"figure_count": len(page_figures),
"page_preview_path": page_preview_path,
"page_text": text or ocr_text,
"text_preview": (text or ocr_text)[:240],
}
Expand Down
Loading