Files
ai-agent-book/chapter2/agent-skills-ppt/demo.py
T
liqiang b119135836
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
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

431 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
实验 2-6:使用 Agent Skills 从论文生成演示文稿(自建同构 Skills 机制)
本 demo 复现《深入理解 AI Agent》第二章「Agent Skills / 渐进式披露」一节的思想。
由于 Anthropic key 无效,这里用 OpenAIgpt-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()