Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
431 lines
18 KiB
Python
431 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
实验 2-6:使用 Agent Skills 从论文生成演示文稿(自建同构 Skills 机制)
|
||
|
||
本 demo 复现《深入理解 AI Agent》第二章「Agent Skills / 渐进式披露」一节的思想。
|
||
由于 Anthropic key 无效,这里用 OpenAI(gpt-5.6-luna)+ 一套自建的、与 Anthropic
|
||
Skills 同构的机制来演示,核心是「渐进式披露(Progressive Disclosure)」:
|
||
|
||
第一层(元数据):Agent 启动时的 system prompt 里只放各 Skill 的 name +
|
||
description(薄目录,数百 token),并不含具体流程。
|
||
第二层(核心流程):当任务需要时,Agent 主动用 read_skill 工具加载完整 SKILL.md。
|
||
第三层(细则):Agent 可再用 read_skill_file 读取 reference.md / 脚本源码。
|
||
|
||
然后 Agent 用捆绑脚本 scripts/generate_pptx.py(经 run_skill_script 工具)用
|
||
python-pptx 生成真实的 .pptx,并读回校验页数与每页标题。
|
||
|
||
运行:
|
||
export OPENAI_API_KEY=your-openai-api-key
|
||
python demo.py
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
from openai import OpenAI
|
||
from pptx import Presentation
|
||
|
||
# 从同目录 .env 读取 OPENAI_API_KEY(若安装了 python-dotenv)
|
||
try:
|
||
from dotenv import load_dotenv
|
||
|
||
load_dotenv(Path(__file__).resolve().parent / ".env")
|
||
except ImportError:
|
||
pass
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 路径与配置
|
||
# ---------------------------------------------------------------------------
|
||
ROOT = Path(__file__).resolve().parent
|
||
SKILLS_DIR = ROOT / "skills"
|
||
PAPER_PATH = ROOT / "papers" / "sample_paper.md"
|
||
OUTPUT_DIR = ROOT / "output"
|
||
MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.6-luna")
|
||
|
||
|
||
def log(msg: str) -> None:
|
||
print(msg, flush=True)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 第一层:启动时扫描 skills/ 目录,只读取每个 SKILL.md 的 frontmatter
|
||
# (name + description),拼成薄目录注入 system prompt。这一步刻意「只看目录」。
|
||
# ---------------------------------------------------------------------------
|
||
def parse_frontmatter(skill_md: str) -> dict:
|
||
"""从 SKILL.md 顶部的 --- YAML frontmatter --- 中解析 name / description。"""
|
||
meta = {}
|
||
if not skill_md.startswith("---"):
|
||
return meta
|
||
end = skill_md.find("---", 3)
|
||
if end == -1:
|
||
return meta
|
||
for line in skill_md[3:end].splitlines():
|
||
if ":" in line:
|
||
k, v = line.split(":", 1)
|
||
meta[k.strip()] = v.strip()
|
||
return meta
|
||
|
||
|
||
def scan_skill_catalog() -> dict:
|
||
"""返回 {skill_name: {"description":..., "dir": Path}},只含元数据。"""
|
||
catalog = {}
|
||
for skill_md in sorted(SKILLS_DIR.glob("*/SKILL.md")):
|
||
meta = parse_frontmatter(skill_md.read_text(encoding="utf-8"))
|
||
name = meta.get("name") or skill_md.parent.name
|
||
catalog[name] = {
|
||
"description": meta.get("description", ""),
|
||
"dir": skill_md.parent,
|
||
}
|
||
return catalog
|
||
|
||
|
||
def build_system_prompt(catalog: dict) -> str:
|
||
lines = [
|
||
"你是一个能使用 Agent Skills 的助手。你并不预先知道每个 Skill 的详细流程,",
|
||
"只在下方看到一份「薄目录」——每个 Skill 的 name 与 description(路由条件)。",
|
||
"",
|
||
"当任务需要某个 Skill 时,你必须:",
|
||
" 1) 先用 read_skill(name) 加载它的完整 SKILL.md(第二层:核心流程);",
|
||
" 2) 如需实现/样式细节,再用 read_skill_file(name, path) 读取子文档或脚本(第三层);",
|
||
" 3) 按 SKILL.md 的约定,用 run_skill_script 调用捆绑脚本完成任务。",
|
||
"不要在没有 read_skill 的情况下臆测某个 Skill 的调用方式。",
|
||
"",
|
||
"== 已安装的 Skills(薄目录,仅元数据)==",
|
||
]
|
||
for name, info in catalog.items():
|
||
lines.append(f"- {name}: {info['description']}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 工具实现:read_skill / read_skill_file / run_skill_script
|
||
# 这些是「渐进式披露」的通道——第二、三层内容只有被调用时才进入上下文。
|
||
# ---------------------------------------------------------------------------
|
||
def tool_read_skill(catalog: dict, name: str) -> str:
|
||
info = catalog.get(name)
|
||
if not info:
|
||
return f"[error] 未找到 Skill: {name}"
|
||
content = (info["dir"] / "SKILL.md").read_text(encoding="utf-8")
|
||
log(f"\n >>> [渐进式披露·第二层] Agent 调用 read_skill('{name}'),"
|
||
f"加载完整 SKILL.md({len(content)} 字符)")
|
||
return content
|
||
|
||
|
||
def tool_read_skill_file(catalog: dict, name: str, rel_path: str) -> str:
|
||
info = catalog.get(name)
|
||
if not info:
|
||
return f"[error] 未找到 Skill: {name}"
|
||
target = (info["dir"] / rel_path).resolve()
|
||
# 防目录穿越:必须落在该 skill 目录内
|
||
if not str(target).startswith(str(info["dir"].resolve())):
|
||
return f"[error] 非法路径: {rel_path}"
|
||
if not target.exists():
|
||
return f"[error] 文件不存在: {rel_path}"
|
||
content = target.read_text(encoding="utf-8")
|
||
log(f" >>> [渐进式披露·第三层] Agent 调用 read_skill_file('{name}', '{rel_path}'),"
|
||
f"加载子文档({len(content)} 字符)")
|
||
return content
|
||
|
||
|
||
def tool_run_skill_script(catalog: dict, name: str, script: str, payload: str,
|
||
out_path: Path) -> str:
|
||
info = catalog.get(name)
|
||
if not info:
|
||
return f"[error] 未找到 Skill: {name}"
|
||
scripts_dir = (info["dir"] / "scripts").resolve()
|
||
script_path = (scripts_dir / script).resolve()
|
||
# 防目录穿越:脚本会被直接执行,必须落在该 skill 的 scripts 目录内
|
||
if not script_path.is_relative_to(scripts_dir):
|
||
return f"[error] 非法脚本路径: {script}"
|
||
if not script_path.exists():
|
||
return f"[error] 脚本不存在: {script}"
|
||
|
||
# 动态载入捆绑脚本(它就是 Skill 的一部分)
|
||
import importlib.util
|
||
spec = importlib.util.spec_from_file_location("bundled_generator", script_path)
|
||
module = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(module)
|
||
|
||
try:
|
||
data = json.loads(payload) if isinstance(payload, str) else payload
|
||
except json.JSONDecodeError as e:
|
||
return f"[error] payload 不是合法 JSON: {e}"
|
||
|
||
log(f" >>> [执行捆绑脚本] run_skill_script('{name}', '{script}') "
|
||
f"生成 {out_path.name} ...")
|
||
result = module.build_presentation(data, str(out_path))
|
||
return json.dumps(result, ensure_ascii=False)
|
||
|
||
|
||
TOOLS = [
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "read_skill",
|
||
"description": "加载指定 Skill 的完整 SKILL.md(核心流程,渐进式披露第二层)。",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {"name": {"type": "string", "description": "Skill 名称"}},
|
||
"required": ["name"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "read_skill_file",
|
||
"description": "读取某 Skill 目录内的子文档或脚本源码(细则,渐进式披露第三层)。",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {"type": "string"},
|
||
"path": {"type": "string", "description": "相对 skill 目录的路径,如 reference.md 或 scripts/generate_pptx.py"},
|
||
},
|
||
"required": ["name", "path"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "run_skill_script",
|
||
"description": "执行某 Skill 捆绑的脚本以完成实际产出(如生成 pptx)。",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {"type": "string"},
|
||
"script": {"type": "string", "description": "脚本文件名,如 generate_pptx.py"},
|
||
"payload": {"type": "string", "description": "传给脚本的 JSON 字符串(大纲)"},
|
||
},
|
||
"required": ["name", "script", "payload"],
|
||
},
|
||
},
|
||
},
|
||
]
|
||
|
||
|
||
def dispatch(catalog: dict, name: str, args: dict, out_path: Path) -> str:
|
||
# 模型给的 arguments 可能缺字段(或根本不是合法 JSON,被上层回退成 {})。
|
||
# 缺参时返回 [error] 让 Agent 在下一轮自我纠正,而不是抛 KeyError 中断 loop。
|
||
required = {
|
||
"read_skill": ["name"],
|
||
"read_skill_file": ["name", "path"],
|
||
"run_skill_script": ["name", "script", "payload"],
|
||
}
|
||
if name not in required:
|
||
return f"[error] 未知工具: {name}"
|
||
missing = [k for k in required[name] if k not in args]
|
||
if missing:
|
||
return f"[error] 工具 {name} 缺少参数: {', '.join(missing)}"
|
||
if name == "read_skill":
|
||
return tool_read_skill(catalog, args["name"])
|
||
if name == "read_skill_file":
|
||
return tool_read_skill_file(catalog, args["name"], args["path"])
|
||
return tool_run_skill_script(catalog, args["name"], args["script"],
|
||
args["payload"], out_path)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 主流程:agentic loop
|
||
# ---------------------------------------------------------------------------
|
||
def run_agent(paper_path: Path, model: str, out_path: Path,
|
||
max_turns: int = 8) -> Path | None:
|
||
# OPENAI_API_KEY 存在则官方直连;否则回退 OPENROUTER_API_KEY
|
||
# (gpt-* 模型名会被映射为 openai/…)。两者皆无则给出清晰错误。
|
||
from agentbook.providers import resolve_backend
|
||
|
||
if not os.environ.get("OPENAI_API_KEY") and not os.environ.get("OPENROUTER_API_KEY"):
|
||
log("错误:未设置 OPENAI_API_KEY,也未设置 OPENROUTER_API_KEY(通用回退)。")
|
||
log("请 export OPENAI_API_KEY=your-openai-api-key 或 export OPENROUTER_API_KEY=your-openrouter-api-key")
|
||
log("(无 key 时可用 --offline 走内置大纲、确定性地复现三层渐进式披露并生成 pptx。)")
|
||
sys.exit(1)
|
||
|
||
# 端点与 key 的对应关系由 agentbook 的 provider 注册表统一维护,
|
||
# OPENAI_BASE_URL 覆盖也在其中处理。
|
||
backend = resolve_backend("openai", model=model)
|
||
model = backend.model
|
||
# timeout + 自动重试:单次网络/SSL 抖动不至于让整个 agentic loop 崩溃
|
||
client = OpenAI(
|
||
api_key=backend.api_key, base_url=backend.base_url, timeout=60.0, max_retries=3
|
||
)
|
||
catalog = scan_skill_catalog()
|
||
|
||
system_prompt = build_system_prompt(catalog)
|
||
log("=" * 72)
|
||
log("【第一层·元数据】Agent 启动时只看到这份薄 Skill 目录(system prompt):")
|
||
log("-" * 72)
|
||
log(system_prompt)
|
||
log("-" * 72)
|
||
log(f"(薄目录约 {len(system_prompt)} 字符 / 数百 token;各 Skill 的详细流程此刻并不在上下文中)")
|
||
log("=" * 72)
|
||
|
||
paper = paper_path.read_text(encoding="utf-8")
|
||
user_task = (
|
||
"请把下面这篇论文做成一份 8-12 页的演示文稿(含标题页、目录页、问题背景、"
|
||
"方法概述、关键结果、局限性、小结页),总页数务必落在 8-12 页。"
|
||
"先判断该用哪个 Skill,再严格按其 SKILL.md 的页序与约束操作。\n\n"
|
||
"=== 论文全文 ===\n" + paper
|
||
)
|
||
|
||
messages = [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_task},
|
||
]
|
||
|
||
log("\n【任务下发】要求 Agent 从论文生成演示文稿。观察它如何按需渐进式披露:\n")
|
||
|
||
final_result = None
|
||
for turn in range(1, max_turns + 1):
|
||
resp = client.chat.completions.create(
|
||
model=model,
|
||
messages=messages,
|
||
tools=TOOLS,
|
||
temperature=0.2,
|
||
)
|
||
msg = resp.choices[0].message
|
||
messages.append(msg.model_dump(exclude_none=True))
|
||
|
||
if not msg.tool_calls:
|
||
log(f"\n【Agent 第 {turn} 轮·结束语】\n{msg.content}")
|
||
break
|
||
|
||
for tc in msg.tool_calls:
|
||
fn = tc.function.name
|
||
try:
|
||
args = json.loads(tc.function.arguments or "{}")
|
||
except json.JSONDecodeError:
|
||
args = {}
|
||
log(f"\n[Agent 第 {turn} 轮] 调用工具 -> {fn}({', '.join(f'{k}={_short(v)}' for k, v in args.items())})")
|
||
result = dispatch(catalog, fn, args, out_path)
|
||
if fn == "run_skill_script" and not result.startswith("[error]"):
|
||
final_result = json.loads(result)
|
||
log(f" >>> 生成结果:{result}")
|
||
messages.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": result,
|
||
})
|
||
|
||
if final_result:
|
||
return Path(final_result["path"])
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 离线复现:无 OpenAI key 时,用内置大纲(papers/sample_outline.json)确定性地
|
||
# 走完与在线完全相同的三层渐进式披露与工具通道(read_skill / read_skill_file /
|
||
# run_skill_script),从而在没有任何 API 访问权限时也能真实生成并校验 pptx。
|
||
# 唯一区别是「用哪个 Skill、大纲写什么」由预置脚本给定,而非模型即时决策。
|
||
# ---------------------------------------------------------------------------
|
||
OUTLINE_PATH = ROOT / "papers" / "sample_outline.json"
|
||
|
||
|
||
def run_offline(out_path: Path) -> Path | None:
|
||
catalog = scan_skill_catalog()
|
||
system_prompt = build_system_prompt(catalog)
|
||
log("=" * 72)
|
||
log("【离线模式】不调用 OpenAI,用内置大纲确定性地复现三层渐进式披露。")
|
||
log("【第一层·元数据】启动时只看到这份薄 Skill 目录(system prompt):")
|
||
log("-" * 72)
|
||
log(system_prompt)
|
||
log("-" * 72)
|
||
log(f"(薄目录约 {len(system_prompt)} 字符;各 Skill 的详细流程此刻并不在上下文中)")
|
||
log("=" * 72)
|
||
|
||
if not OUTLINE_PATH.exists():
|
||
log(f"错误:内置大纲不存在:{OUTLINE_PATH}")
|
||
return None
|
||
|
||
# 与在线 agentic loop 相同的工具通道,只是调用序列由脚本给定
|
||
log("\n【离线回放】按 SKILL.md 约定,逐层加载并调用捆绑脚本:")
|
||
dispatch(catalog, "read_skill", {"name": "pptx"}, out_path)
|
||
dispatch(catalog, "read_skill_file",
|
||
{"name": "pptx", "path": "reference.md"}, out_path)
|
||
|
||
payload = OUTLINE_PATH.read_text(encoding="utf-8")
|
||
result = dispatch(catalog, "run_skill_script",
|
||
{"name": "pptx", "script": "generate_pptx.py", "payload": payload},
|
||
out_path)
|
||
if result.startswith("[error]"):
|
||
log(f" >>> 生成失败:{result}")
|
||
return None
|
||
log(f" >>> 生成结果:{result}")
|
||
return Path(json.loads(result)["path"])
|
||
|
||
|
||
def _short(v, n=48):
|
||
s = str(v).replace("\n", " ")
|
||
return s if len(s) <= n else s[:n] + "…"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 校验:用 python-pptx 重新打开生成的文件,读回页数与每页标题,证明是有效 pptx。
|
||
# ---------------------------------------------------------------------------
|
||
def verify_pptx(path: Path) -> None:
|
||
log("\n" + "=" * 72)
|
||
log("【校验】用 python-pptx 重新打开生成的文件,读回页数与每页标题:")
|
||
log("-" * 72)
|
||
prs = Presentation(str(path))
|
||
slides = list(prs.slides)
|
||
log(f"文件: {path}")
|
||
log(f"总页数: {len(slides)}")
|
||
for i, slide in enumerate(slides, 1):
|
||
first_text = "(空)"
|
||
for shp in slide.shapes:
|
||
if shp.has_text_frame and shp.text_frame.text.strip():
|
||
first_text = shp.text_frame.text.strip().splitlines()[0]
|
||
break
|
||
log(f" 第 {i:>2} 页标题: {first_text}")
|
||
log("-" * 72)
|
||
log(f"校验通过:这是一个可被 python-pptx / PowerPoint 打开的有效 .pptx({len(slides)} 页)。")
|
||
log("=" * 72)
|
||
|
||
|
||
def parse_args():
|
||
p = argparse.ArgumentParser(
|
||
description="实验 2-6:用 Agent Skills 的「渐进式披露」从论文生成演示文稿。"
|
||
"Agent 启动只看到薄 Skill 目录,按需逐层加载 pptx Skill 的流程与脚本,"
|
||
"最后用 python-pptx 生成并校验 output/presentation.pptx。",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
)
|
||
p.add_argument("--paper", default=str(PAPER_PATH),
|
||
help="输入论文/大纲(markdown)路径,默认 papers/sample_paper.md。")
|
||
p.add_argument("--output", "-o", default=str(OUTPUT_DIR / "presentation.pptx"),
|
||
help="输出 .pptx 路径,默认 output/presentation.pptx。")
|
||
p.add_argument("--model", default=MODEL,
|
||
help="OpenAI 模型名,默认取环境变量 OPENAI_MODEL,否则 gpt-5.6-luna。")
|
||
p.add_argument("--max-turns", type=int, default=8,
|
||
help="agentic loop 的最大轮数,默认 8。")
|
||
p.add_argument("--offline", action="store_true",
|
||
help="离线演示:不调用 OpenAI,用内置大纲(papers/sample_outline.json)"
|
||
"确定性地走完三层渐进式披露并生成 pptx(无需 API key,可复现)。")
|
||
return p.parse_args()
|
||
|
||
|
||
def main():
|
||
args = parse_args()
|
||
paper_path = Path(args.paper)
|
||
out_path = Path(args.output)
|
||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
if args.offline:
|
||
pptx_path = run_offline(out_path)
|
||
else:
|
||
if not paper_path.exists():
|
||
log(f"错误:论文文件不存在:{paper_path}")
|
||
sys.exit(1)
|
||
pptx_path = run_agent(paper_path, args.model, out_path, args.max_turns)
|
||
|
||
if pptx_path and pptx_path.exists():
|
||
verify_pptx(pptx_path)
|
||
else:
|
||
log("\n未生成 pptx。请检查上面的日志。")
|
||
sys.exit(2)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|