Compare commits
1 Commits
master
...
skill/slid
| Author | SHA1 | Date |
|---|---|---|
|
|
6d4db5cec5 |
|
|
@ -0,0 +1,65 @@
|
|||
---
|
||||
name: slidecast
|
||||
version: 1.0.0
|
||||
description: "论文转 PPT 大纲生成器:把按标题分节的论文文本转成中文 PPT 大纲(标题页 + 各章节页:标题 + 3-5 条要点 + 演讲备注 + 总结页),演讲备注按章节类型智能适配;若装了 python-pptx 可一并导出 .pptx。当用户提到「论文转 PPT」「组会汇报」「答辩讲稿」「paper2ppt」「生成幻灯片大纲」时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
optional_bins: ["python"]
|
||||
optional_pip: ["python-pptx"]
|
||||
---
|
||||
|
||||
# slidecast(论文转 PPT 大纲生成器)
|
||||
|
||||
组会前一晚,把论文丢进去,讲稿骨架就有了。slidecast 把论文切成一页页,每页一个
|
||||
标题、几条要点、外加一句怎么讲的备注。
|
||||
|
||||
## 何时使用本技能
|
||||
|
||||
- 把自己的论文转成汇报 PPT
|
||||
- 把读的文献转成 PPT 给组里讲
|
||||
- 答辩前快速出一版讲稿
|
||||
|
||||
## 与同类工具的区别
|
||||
|
||||
同类工具多调 LLM 直接生成幻灯片内容。本技能**纯本地出大纲**:不联网、不调 LLM,
|
||||
按论文结构切分、按信号词提炼要点,生成讲稿骨架。亮点是**演讲备注按章节类型适配**
|
||||
(方法页提示"讲直觉别陷公式"、结果页提示"先结论后数据"),这是单纯摘要给不出的。
|
||||
python-pptx 为可选依赖,仅 .pptx 导出需要,未装时照常输出 Markdown 大纲。
|
||||
|
||||
## 三个核心能力
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| 章节切分 | 按 Markdown 标题或「数字 章节名」识别论文结构 |
|
||||
| 要点提炼 | 按信号词(提出/结果/表明…)从各节抽 3-5 条要点 |
|
||||
| 大纲渲染 | 标题页 + 章节页(标题+要点+演讲备注)+ 总结页;可选导出 .pptx |
|
||||
|
||||
## 工作流
|
||||
|
||||
```bash
|
||||
# 出 Markdown 大纲
|
||||
python scripts/cast.py --input paper.md --title "我的汇报" --output slides.md
|
||||
|
||||
# 同时导出 pptx(需 python-pptx)
|
||||
python scripts/cast.py --input paper.md --pptx slides.pptx
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--input` | 论文文本/Markdown(*或用 `--text`) |
|
||||
| `--title` | 汇报标题 |
|
||||
| `--max-points` | 每页最多要点数(默认 5) |
|
||||
| `--format` | `markdown`(默认)或 `json` |
|
||||
| `--output` | Markdown 大纲输出 |
|
||||
| `--pptx` | 同时导出 .pptx(需 python-pptx) |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 论文用 Markdown 标题(`##`)分节时切分最准;纯文本按「数字 章节名」兜底。
|
||||
- 要点是从原文抽取的句子,不改写、不编造;`____` 占位需你填。
|
||||
- .pptx 导出需 `pip install python-pptx`;未装时输出 Markdown 大纲并提示。
|
||||
|
||||
## References
|
||||
|
||||
- [outline-rules.md](references/outline-rules.md) — 章节切分与要点提炼规则
|
||||
- [usage.md](references/usage.md) — 输入建议与讲稿打磨流程
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# 章节切分与要点提炼规则
|
||||
|
||||
## 章节切分
|
||||
|
||||
按优先级识别章节边界:
|
||||
|
||||
1. **Markdown 标题**:`#` / `##` / `###` 开头的行(最准,推荐输入用 Markdown)。
|
||||
2. **数字编号标题**:`1`、`1.`、`一、` 等开头且较短(< 30 字)的行。
|
||||
3. **章节名提示**:含「摘要/引言/方法/实验/结果/讨论/结论」等且较短的行。
|
||||
|
||||
正文归到最近的章节标题下;开头无标题的内容归入「(引言)」。
|
||||
|
||||
## 要点提炼
|
||||
|
||||
从每节正文按句切分后打分,取分高的若干句(保持原文顺序):
|
||||
|
||||
| 加分项 | 说明 |
|
||||
|--------|------|
|
||||
| 含信号词 | 我们/提出/结果/表明/因此/发现/贡献/方法… 每命中 +1 |
|
||||
| 长度适中 | 10-120 字 +1(太短无信息、太长不适合上 slide) |
|
||||
|
||||
每页默认最多 5 条要点(`--max-points` 可调)。要点是**从原文抽取的原句**,不改写、
|
||||
不编造。
|
||||
|
||||
## 幻灯片结构
|
||||
|
||||
| 页 | 内容 |
|
||||
|----|------|
|
||||
| 标题页 | 汇报标题 + 汇报人/日期占位 |
|
||||
| 章节页 | 每节一页:标题 + 要点 + 演讲备注 |
|
||||
| 总结页 | 主要结论 + 局限 + 致谢 |
|
||||
|
||||
## 演讲备注适配
|
||||
|
||||
备注按章节类型给不同建议:
|
||||
|
||||
| 章节类型 | 备注侧重 |
|
||||
|---------|---------|
|
||||
| 引言 | 用例子引出问题,30 秒讲清重要性 |
|
||||
| 方法 | 讲直觉别陷公式,配示意图 |
|
||||
| 结果/实验 | 先结论后数据,指图讲 |
|
||||
| 结论/讨论 | 重申贡献,坦诚局限,给方向 |
|
||||
|
||||
## 局限
|
||||
|
||||
- 要点是抽取式,不做摘要式压缩,复杂长句可能整句上 slide,需你精简。
|
||||
- 切分依赖标题结构,无标题的连续长文切分效果有限。
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# 输入建议与讲稿打磨流程
|
||||
|
||||
## 输入怎么准备
|
||||
|
||||
效果最好的输入是**带 Markdown 标题的论文文本**:
|
||||
|
||||
```
|
||||
## 引言
|
||||
……
|
||||
|
||||
## 方法
|
||||
……
|
||||
|
||||
## 实验
|
||||
……
|
||||
```
|
||||
|
||||
这样章节切分最准。没有标题的纯文本也能用,工具按「数字 章节名」兜底,但建议先
|
||||
把章节标题补上。
|
||||
|
||||
## 推荐流程
|
||||
|
||||
1. 把论文(或文献)整理成带标题的文本。
|
||||
2. 运行生成 Markdown 大纲,通读一遍。
|
||||
3. 删掉不必要的要点、精简过长句子。
|
||||
4. 填好 `____` 占位(汇报人、结论、局限)。
|
||||
5. 需要 .pptx 就加 `--pptx`,导出后在 PowerPoint 里配图、调版式。
|
||||
|
||||
## 一页几条要点
|
||||
|
||||
- 默认每页 ≤ 5 条,建议讲的时候每页 3-4 条最舒服。
|
||||
- 内容太多就拆成两页,别把一页塞满。
|
||||
|
||||
## 配图建议
|
||||
|
||||
工具只出文字骨架,配图靠你:
|
||||
|
||||
- 方法页配一张方法示意图(可用 figforge 出)。
|
||||
- 结果页配关键结果图(可用 figforge 出)。
|
||||
- 别整页纯文字,讲的时候听众会走神。
|
||||
|
||||
## 与其它技能配合
|
||||
|
||||
- figforge 出图 → 插到结果/方法页。
|
||||
- 自己的论文:draftsmith 起草 → polishpro 润色 → slidecast 转汇报。
|
||||
|
||||
## 局限
|
||||
|
||||
- 要点为抽取式,复杂论文仍需人工精简与重组。
|
||||
- 演讲备注是通用建议,具体讲法结合你的听众调整。
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
"""slidecast:论文转 PPT 大纲生成器。
|
||||
|
||||
把一篇论文(按标题分节的文本)转成一份可直接拿去讲的中文 PPT 大纲:每页一个标题、
|
||||
3-5 条要点、外加一句演讲备注。组会前一晚把论文丢进去,讲稿骨架就有了。
|
||||
|
||||
三个核心能力:
|
||||
1. 章节切分——按 Markdown 标题(# / ##)或「数字 章节名」识别论文结构,把正文
|
||||
切到各章节名下;无显式标题时按段落兜底。
|
||||
2. 要点提炼——从每节正文按句抽取要点(优先含「我们/提出/结果/表明/因此」等
|
||||
信号词的句子),每页压到 3-5 条,过长正文自动取最有信息量的若干句。
|
||||
3. 大纲渲染——生成标题页 + 各章节页(标题 + 要点 + 演讲备注),输出 Markdown
|
||||
大纲;若安装了 python-pptx,可一并导出 .pptx 文件。
|
||||
|
||||
纯本地、纯标准库出大纲;python-pptx 为可选依赖,仅 .pptx 导出需要,未安装时给
|
||||
提示并照常输出 Markdown 大纲。它给的是讲稿骨架,具体讲什么、配什么图由你定。
|
||||
|
||||
用法:
|
||||
python cast.py --input paper.md --output slides.md
|
||||
python cast.py --input paper.md --pptx slides.pptx
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 要点信号词:含这些词的句子更可能是核心论述
|
||||
SIGNAL_WORDS = ["我们", "提出", "结果", "表明", "因此", "发现", "贡献", "方法",
|
||||
"实验", "证明", "显示", "本文", "propose", "result", "show",
|
||||
"we ", "method", "conclude", "contribution"]
|
||||
|
||||
# 常见章节名(用于兜底识别)
|
||||
SECTION_HINTS = ["摘要", "abstract", "引言", "introduction", "背景", "相关工作",
|
||||
"related work", "方法", "method", "实验", "experiment", "结果",
|
||||
"result", "讨论", "discussion", "结论", "conclusion"]
|
||||
|
||||
|
||||
def split_paper(text: str) -> list[dict[str, Any]]:
|
||||
"""把论文切成章节:[{title, body}]。"""
|
||||
text = text.lstrip("\ufeff")
|
||||
lines = text.splitlines()
|
||||
sections: list[dict[str, Any]] = []
|
||||
cur_title = None
|
||||
cur_body: list[str] = []
|
||||
|
||||
def flush():
|
||||
if cur_title is not None or cur_body:
|
||||
sections.append({"title": cur_title or "(引言)",
|
||||
"body": "\n".join(cur_body).strip()})
|
||||
|
||||
for line in lines:
|
||||
s = line.strip()
|
||||
if not s:
|
||||
continue
|
||||
# Markdown 标题
|
||||
m = re.match(r"^#{1,3}\s+(.*)$", s)
|
||||
# 数字编号标题:1 / 1. / 一、 + 短标题
|
||||
m2 = re.match(r"^((?:\d+[.、]?|[一二三四五六七八九十]+、)\s*\S.{0,30})$", s)
|
||||
is_hint = any(h in s.lower() for h in SECTION_HINTS) and len(s) < 20
|
||||
if m:
|
||||
flush()
|
||||
cur_title, cur_body = m.group(1).strip(), []
|
||||
elif (m2 or is_hint) and len(s) < 30:
|
||||
flush()
|
||||
cur_title, cur_body = s, []
|
||||
else:
|
||||
cur_body.append(s)
|
||||
flush()
|
||||
return [sec for sec in sections if sec["body"] or sec["title"]]
|
||||
|
||||
|
||||
def _split_sentences(text: str) -> list[str]:
|
||||
parts = re.split(r"(?<=[。.!?!?])\s*", text)
|
||||
return [p.strip() for p in parts if len(p.strip()) > 4]
|
||||
|
||||
|
||||
def extract_points(body: str, max_points: int = 5) -> list[str]:
|
||||
"""从章节正文提炼要点。"""
|
||||
sentences = _split_sentences(body)
|
||||
if not sentences:
|
||||
return []
|
||||
# 按信号词命中数 + 适中长度打分
|
||||
scored = []
|
||||
for s in sentences:
|
||||
score = sum(1 for w in SIGNAL_WORDS if w in s.lower())
|
||||
length = len(s)
|
||||
if 10 <= length <= 120:
|
||||
score += 1
|
||||
scored.append((score, s))
|
||||
# 取分高的,但保持原文顺序
|
||||
top = sorted(scored, key=lambda x: -x[0])[:max_points]
|
||||
top_set = {s for _, s in top}
|
||||
ordered = [s for s in sentences if s in top_set]
|
||||
return ordered[:max_points]
|
||||
|
||||
|
||||
def build_slides(text: str, title: str = "", max_points: int = 5) -> dict[str, Any]:
|
||||
sections = split_paper(text)
|
||||
slides = []
|
||||
# 标题页
|
||||
slides.append({"type": "title", "title": title or "论文汇报",
|
||||
"points": ["汇报人:____", "日期:____"], "note": "开场:一句话说明本文做了什么。"})
|
||||
for sec in sections:
|
||||
points = extract_points(sec["body"], max_points=max_points)
|
||||
if not points and not sec["body"]:
|
||||
continue
|
||||
note = _speaker_note(sec["title"], points)
|
||||
slides.append({"type": "content", "title": sec["title"],
|
||||
"points": points or ["[本节要点待补]"], "note": note})
|
||||
# 结尾页
|
||||
slides.append({"type": "end", "title": "总结与致谢",
|
||||
"points": ["主要结论:____", "局限与未来工作:____", "谢谢,欢迎提问"],
|
||||
"note": "收束:呼应开场,留时间提问。"})
|
||||
return {"slide_count": len(slides), "slides": slides}
|
||||
|
||||
|
||||
def _speaker_note(section_title: str, points: list[str]) -> str:
|
||||
t = section_title.lower()
|
||||
if any(k in t for k in ["方法", "method"]):
|
||||
return "讲清楚做法的直觉,别陷入公式细节;配一张方法示意图。"
|
||||
if any(k in t for k in ["结果", "result", "实验", "experiment"]):
|
||||
return "先说结论再给数据;指着图讲,强调关键对比。"
|
||||
if any(k in t for k in ["引言", "introduction", "背景"]):
|
||||
return "用一个具体例子引出问题,30 秒内让听众明白为什么重要。"
|
||||
if any(k in t for k in ["结论", "conclusion", "讨论", "discussion"]):
|
||||
return "重申贡献,坦诚局限,给出未来方向。"
|
||||
return "每条要点停顿,给听众消化时间。"
|
||||
|
||||
|
||||
def render_markdown(data: dict[str, Any]) -> str:
|
||||
lines = [f"# PPT 大纲(共 {data['slide_count']} 页)", "",
|
||||
"> 由 slidecast 生成。每页一个标题 + 要点 + 演讲备注,`____` 处需你填。", ""]
|
||||
for i, sl in enumerate(data["slides"], start=1):
|
||||
lines.append(f"## 第 {i} 页:{sl['title']}")
|
||||
lines.append("")
|
||||
for p in sl["points"]:
|
||||
lines.append(f"- {p}")
|
||||
lines.append("")
|
||||
lines.append(f"> 演讲备注:{sl['note']}")
|
||||
lines.append("")
|
||||
lines += ["---", "", "由 slidecast 生成。讲稿骨架,配图与具体措辞由你定。"]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def export_pptx(data: dict[str, Any], path: Path) -> str:
|
||||
"""导出 .pptx(需 python-pptx)。"""
|
||||
try:
|
||||
from pptx import Presentation
|
||||
from pptx.util import Pt
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"导出 .pptx 需要 python-pptx。请先安装:pip install python-pptx\n"
|
||||
"(Markdown 大纲不需要它,可直接使用。)"
|
||||
) from exc
|
||||
|
||||
prs = Presentation()
|
||||
for sl in data["slides"]:
|
||||
layout = prs.slide_layouts[0] if sl["type"] == "title" else prs.slide_layouts[1]
|
||||
slide = prs.slides.add_slide(layout)
|
||||
slide.shapes.title.text = sl["title"]
|
||||
if len(slide.placeholders) > 1:
|
||||
body = slide.placeholders[1].text_frame
|
||||
body.text = sl["points"][0] if sl["points"] else ""
|
||||
for p in sl["points"][1:]:
|
||||
para = body.add_paragraph()
|
||||
para.text = p
|
||||
# 演讲备注
|
||||
slide.notes_slide.notes_text_frame.text = sl["note"]
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
prs.save(str(path))
|
||||
return str(path)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(prog="slidecast", description="论文转 PPT 大纲生成器")
|
||||
p.add_argument("--input", type=Path, help="论文文本/Markdown 文件")
|
||||
p.add_argument("--text", help="直接传入论文文本")
|
||||
p.add_argument("--title", default="", help="汇报标题")
|
||||
p.add_argument("--max-points", type=int, default=5, help="每页最多要点数")
|
||||
p.add_argument("--format", choices=["markdown", "json"], default="markdown")
|
||||
p.add_argument("--output", type=Path, help="Markdown 大纲输出")
|
||||
p.add_argument("--pptx", type=Path, help="同时导出 .pptx(需 python-pptx)")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if args.input and args.input.exists():
|
||||
text = args.input.read_text(encoding="utf-8-sig", errors="replace")
|
||||
elif args.text:
|
||||
text = args.text
|
||||
else:
|
||||
print("错误:请用 --input 或 --text 提供论文文本。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
data = build_slides(text, title=args.title, max_points=args.max_points)
|
||||
out = (json.dumps(data, ensure_ascii=False, indent=2) if args.format == "json"
|
||||
else render_markdown(data))
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(out, encoding="utf-8")
|
||||
print(f"已写入 {args.output}")
|
||||
else:
|
||||
print(out)
|
||||
|
||||
if args.pptx:
|
||||
try:
|
||||
path = export_pptx(data, args.pptx)
|
||||
print(f"已导出 {path}")
|
||||
except RuntimeError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 3
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
"""slidecast 单元测试。"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
import pytest
|
||||
from cast import split_paper, extract_points, build_slides, render_markdown, _speaker_note
|
||||
|
||||
|
||||
PAPER = """# 标题
|
||||
|
||||
## 引言
|
||||
医学分割很重要。然而标注稀缺。本文提出对比学习方法。
|
||||
|
||||
## 方法
|
||||
我们设计了预训练框架。该框架学习表征。
|
||||
|
||||
## 结果
|
||||
结果表明方法显著优于基线。消融证明预训练贡献最大。
|
||||
"""
|
||||
|
||||
|
||||
class TestSplitPaper:
|
||||
def test_sections(self):
|
||||
secs = split_paper(PAPER)
|
||||
titles = [s["title"] for s in secs]
|
||||
assert "引言" in titles and "方法" in titles and "结果" in titles
|
||||
|
||||
def test_numeric_heading(self):
|
||||
secs = split_paper("1. 引言\n这是引言内容很长一段。\n2. 方法\n这是方法内容。")
|
||||
titles = [s["title"] for s in secs]
|
||||
assert any("引言" in t for t in titles)
|
||||
|
||||
def test_bom(self):
|
||||
secs = split_paper("\ufeff## 引言\n内容内容内容。")
|
||||
assert secs
|
||||
|
||||
|
||||
class TestExtractPoints:
|
||||
def test_signal_words_prioritized(self):
|
||||
body = "天气不错。我们提出了一种新方法。结果表明它很好。随便一句废话。"
|
||||
pts = extract_points(body, max_points=2)
|
||||
# 含信号词的句子应被选中
|
||||
joined = "".join(pts)
|
||||
assert "提出" in joined or "结果" in joined
|
||||
|
||||
def test_max_points(self):
|
||||
body = "。".join([f"我们提出方法{i}" for i in range(10)]) + "。"
|
||||
pts = extract_points(body, max_points=3)
|
||||
assert len(pts) <= 3
|
||||
|
||||
def test_empty(self):
|
||||
assert extract_points("") == []
|
||||
|
||||
def test_preserves_order(self):
|
||||
body = "我们提出A。我们提出B。我们提出C。"
|
||||
pts = extract_points(body, max_points=3)
|
||||
assert pts.index("我们提出A。") < pts.index("我们提出B。")
|
||||
|
||||
|
||||
class TestSpeakerNote:
|
||||
def test_method_note(self):
|
||||
assert "直觉" in _speaker_note("方法", [])
|
||||
|
||||
def test_result_note(self):
|
||||
assert "结论" in _speaker_note("实验结果", [])
|
||||
|
||||
|
||||
class TestBuildSlides:
|
||||
def test_has_title_and_end(self):
|
||||
data = build_slides(PAPER, title="测试汇报")
|
||||
assert data["slides"][0]["type"] == "title"
|
||||
assert data["slides"][-1]["type"] == "end"
|
||||
|
||||
def test_content_slides(self):
|
||||
data = build_slides(PAPER)
|
||||
content = [s for s in data["slides"] if s["type"] == "content"]
|
||||
assert len(content) >= 3
|
||||
|
||||
def test_render(self):
|
||||
md = render_markdown(build_slides(PAPER, title="X"))
|
||||
assert "PPT 大纲" in md
|
||||
assert "演讲备注" in md
|
||||
assert "第 1 页" in md
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
Loading…
Reference in New Issue