extract_capabilities_auto.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. #!/usr/bin/env python3
  2. """
  3. 原子能力提取工作流 - 自动化版本
  4. 使用 openrouter (claude-sonnet) 逐个读取工具文档,迭代式提取和融合原子能力
  5. """
  6. import asyncio
  7. import json
  8. import os
  9. import re
  10. import sys
  11. from pathlib import Path
  12. # 添加项目根目录
  13. sys.path.insert(0, str(Path(__file__).parent.parent.parent))
  14. from dotenv import load_dotenv
  15. load_dotenv()
  16. from agent.llm.openrouter import openrouter_llm_call
  17. # ===== 配置 =====
  18. BASE_DIR = Path(__file__).parent
  19. TOOL_RESULTS_DIR = BASE_DIR / "tool_results"
  20. OUTPUT_FILE = BASE_DIR / "atomic_capabilities.md"
  21. PROMPT_FILE = BASE_DIR / "extract_atomic_capabilities.prompt"
  22. # ===== Prompt 加载(复用 match_nodes.py 的模式) =====
  23. def load_prompt(filepath: str) -> dict:
  24. """加载 .prompt 文件,解析 frontmatter 和 $role$ 分段"""
  25. text = Path(filepath).read_text(encoding="utf-8")
  26. config = {}
  27. if text.startswith("---"):
  28. _, fm, text = text.split("---", 2)
  29. for line in fm.strip().splitlines():
  30. if ":" in line:
  31. k, v = line.split(":", 1)
  32. k, v = k.strip(), v.strip()
  33. if v.replace(".", "", 1).isdigit():
  34. v = float(v) if "." in v else int(v)
  35. config[k] = v
  36. messages = []
  37. parts = re.split(r'^\$(\w+)\$\s*$', text.strip(), flags=re.MULTILINE)
  38. for i in range(1, len(parts), 2):
  39. role = parts[i].strip()
  40. content = parts[i + 1].strip() if i + 1 < len(parts) else ""
  41. messages.append({"role": role, "content": content})
  42. return {"config": config, "messages": messages}
  43. def render_messages(prompt_data: dict, variables: dict) -> list[dict]:
  44. """用变量替换 prompt 模板中的 {var} 占位符"""
  45. rendered = []
  46. for msg in prompt_data["messages"]:
  47. content = msg["content"]
  48. for k, v in variables.items():
  49. content = content.replace(f"{{{k}}}", str(v))
  50. rendered.append({"role": msg["role"], "content": content})
  51. return rendered
  52. # ===== 文件读取 =====
  53. def get_all_tool_dirs():
  54. """获取所有工具目录"""
  55. dirs = sorted([d for d in TOOL_RESULTS_DIR.iterdir() if d.is_dir()])
  56. return dirs
  57. def read_file(file_path):
  58. """读取文件内容"""
  59. with open(file_path, 'r', encoding='utf-8') as f:
  60. return f.read()
  61. def read_tool_files(tool_dir):
  62. """读取工具的使用介绍和实际用例"""
  63. usage_file = tool_dir / "使用介绍.md"
  64. case_file = tool_dir / "实际用例.md"
  65. content = ""
  66. if usage_file.exists():
  67. content += "# 使用介绍\n\n" + read_file(usage_file) + "\n\n"
  68. if case_file.exists():
  69. content += "# 实际用例\n\n" + read_file(case_file)
  70. return content
  71. # ===== 构建 user prompt =====
  72. def build_user_prompt(file_content, tool_name, existing_capabilities=""):
  73. """构建每轮迭代的 user prompt"""
  74. if existing_capabilities:
  75. user_prompt = f"""## 当前状态
  76. ### 已提取的原子能力
  77. {existing_capabilities}
  78. ## 你的工作
  79. 1. 仔细阅读下面的工具文档(使用介绍 + 实际用例)
  80. 2. 从中识别出**面向需求的原子能力**(注意:不是工具的技术操作)
  81. 3. 与已有能力对比:
  82. - 如果是全新的能力 → 添加,并说明来源
  83. - 如果已有能力可由新工具实现 → 融合,在「实现方式」中补充该工具
  84. - 如果是多个已有能力的组合 → 不添加,但在「发现的能力组合」中记录
  85. 4. 对于来源依据,要说明从哪个用例/帖子/文档章节提炼的,并简述其大概内容
  86. """
  87. else:
  88. user_prompt = """## 当前状态
  89. 这是第一次提取,当前没有已有能力。
  90. ## 你的工作
  91. 1. 仔细阅读下面的工具文档(使用介绍 + 实际用例)
  92. 2. 从中识别出**面向需求的原子能力**(注意:不是工具的技术操作)
  93. 3. 对于来源依据,要说明从哪个用例/帖子/文档章节提炼的,并简述其大概内容
  94. """
  95. user_prompt += f"""
  96. ## 当前要处理的工具
  97. **工具名称**: {tool_name}
  98. **文档内容**(包含使用介绍和实际用例):
  99. {file_content}
  100. ## 输出要求
  101. 请按以下格式输出:
  102. # 原子能力清单(更新后)
  103. ## 本轮分析
  104. 简要说明从 {tool_name} 中发现了哪些能力,哪些是新的,哪些与已有能力融合了。
  105. ## 新增能力
  106. [列出本次新增的能力,使用上述格式,每个能力都要有来源依据]
  107. ## 融合能力
  108. [列出本次融合/更新的能力,说明新增了哪些实现方式]
  109. ## 发现的能力组合
  110. [列出发现的能力组合关系,例如:能力A + 能力B + 能力C = 完成「电商产品图批量生成」]
  111. ## 完整能力清单
  112. [输出完整的、更新后的原子能力清单,包含所有能力(新增 + 已有 + 融合后的)]
  113. """
  114. return user_prompt
  115. # ===== LLM 调用 =====
  116. async def extract_capabilities_from_tool(prompt_data, tool_dir, existing_capabilities=""):
  117. """从工具目录提取原子能力"""
  118. tool_name = tool_dir.name
  119. print(f"\n📖 正在处理: {tool_name}")
  120. # 读取使用介绍和实际用例
  121. content = read_tool_files(tool_dir)
  122. # 构建 user prompt
  123. user_prompt = build_user_prompt(content, tool_name, existing_capabilities)
  124. # 渲染 prompt 模板
  125. messages = render_messages(prompt_data, {"user_prompt": user_prompt})
  126. # 从 prompt 文件读取配置
  127. model = prompt_data["config"].get("model", "anthropic/claude-sonnet-4-20250514")
  128. temperature = prompt_data["config"].get("temperature", 0.3)
  129. max_tokens = prompt_data["config"].get("max_tokens", 16000)
  130. try:
  131. result = await openrouter_llm_call(
  132. messages, model=model, temperature=temperature, max_tokens=max_tokens
  133. )
  134. response = result["content"]
  135. # 打印 token 用量
  136. pt = result.get("prompt_tokens", 0)
  137. ct = result.get("completion_tokens", 0)
  138. cost = result.get("cost", 0)
  139. print(f" tokens: {pt} prompt + {ct} completion | cost: ${cost:.4f}")
  140. # 提取"完整能力清单"部分
  141. if "## 完整能力清单" in response:
  142. complete_list = response.split("## 完整能力清单")[1].strip()
  143. else:
  144. complete_list = response
  145. print(f"✅ {tool_name} 处理完成")
  146. return response, complete_list
  147. except Exception as e:
  148. print(f"❌ {tool_name} 处理失败: {e}")
  149. return None, existing_capabilities
  150. async def generate_json_index(prompt_data, capabilities_md):
  151. """把 markdown 格式的能力清单转成简洁 JSON"""
  152. prompt = f"""请把以下原子能力清单转成 JSON 数组,每个能力包含以下字段:
  153. ```json
  154. [
  155. {{
  156. "id": "能力ID",
  157. "name": "能力名称",
  158. "description": "一句话功能描述",
  159. "criteria": "判定标准(简洁)",
  160. "tools": ["支持的工具/方案1", "支持的工具/方案2"],
  161. "scenarios": ["典型场景1", "典型场景2"],
  162. "source_summary": "来源依据的简要概括"
  163. }}
  164. ]
  165. ```
  166. 要求:
  167. - 只输出 JSON,不要任何其他文字
  168. - 保持所有能力,不要遗漏
  169. - description 控制在 30 字以内
  170. - criteria 控制在 30 字以内
  171. 原子能力清单:
  172. {capabilities_md}
  173. """
  174. model = prompt_data["config"].get("model", "anthropic/claude-sonnet-4-20250514")
  175. messages = [{"role": "user", "content": prompt}]
  176. try:
  177. result = await openrouter_llm_call(messages, model=model, temperature=0.1, max_tokens=8000)
  178. content = result["content"].strip()
  179. # 清理 markdown 代码块包裹
  180. if content.startswith("```"):
  181. content = content.split("\n", 1)[1]
  182. content = content.rsplit("```", 1)[0]
  183. # 验证是合法 JSON
  184. json.loads(content)
  185. return content
  186. except Exception as e:
  187. print(f"❌ JSON 索引生成失败: {e}")
  188. return None
  189. # ===== 主流程 =====
  190. async def main():
  191. print("🚀 开始提取原子能力...")
  192. print()
  193. # 加载 prompt 模板
  194. prompt_data = load_prompt(PROMPT_FILE)
  195. model = prompt_data["config"].get("model", "anthropic/claude-sonnet-4-20250514")
  196. print(f"🤖 使用模型: {model}")
  197. print()
  198. # 获取所有工具目录
  199. tool_dirs = get_all_tool_dirs()
  200. print(f"📁 找到 {len(tool_dirs)} 个工具:")
  201. for d in tool_dirs:
  202. files = list(d.glob("*.md"))
  203. print(f" - {d.name} ({len(files)} 个文件)")
  204. print()
  205. # 迭代处理每个工具
  206. existing_capabilities = ""
  207. all_responses = []
  208. for i, tool_dir in enumerate(tool_dirs, 1):
  209. print(f"{'='*60}")
  210. print(f"进度: [{i}/{len(tool_dirs)}]")
  211. response, complete_list = await extract_capabilities_from_tool(
  212. prompt_data, tool_dir, existing_capabilities
  213. )
  214. if response:
  215. all_responses.append({
  216. "tool": tool_dir.name,
  217. "response": response
  218. })
  219. existing_capabilities = complete_list
  220. # 保存最终结果
  221. print(f"\n{'='*60}")
  222. print("💾 保存结果...")
  223. # 保存完整能力清单(markdown)
  224. OUTPUT_FILE.write_text(existing_capabilities, encoding='utf-8')
  225. print(f"✅ 原子能力清单已保存到: {OUTPUT_FILE}")
  226. # 保存详细过程
  227. detail_file = BASE_DIR / "atomic_capabilities_detail.json"
  228. with open(detail_file, 'w', encoding='utf-8') as f:
  229. json.dump(all_responses, f, ensure_ascii=False, indent=2)
  230. print(f"✅ 详细过程已保存到: {detail_file}")
  231. # 最终一轮:让 LLM 把完整能力清单转成简洁 JSON
  232. print(f"\n{'='*60}")
  233. print("📋 生成简洁 JSON 索引...")
  234. json_result = await generate_json_index(prompt_data, existing_capabilities)
  235. if json_result:
  236. json_index_file = BASE_DIR / "atomic_capabilities_index.json"
  237. with open(json_index_file, 'w', encoding='utf-8') as f:
  238. f.write(json_result)
  239. print(f"✅ JSON 索引已保存到: {json_index_file}")
  240. print("\n🎉 所有文件处理完成!")
  241. if __name__ == "__main__":
  242. os.environ.setdefault("no_proxy", "*")
  243. asyncio.run(main())