run.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. """
  2. 两阶段 Pipeline:工具调研 + 工作流分析
  3. Stage 1:批量调研(qwen3.5-plus),每个需求输出到 output/research/NN/
  4. Stage 2:工作流分析(claude-sonnet),读取 Stage 1 输出,生成 output/analysis/result.json
  5. 用法:
  6. python run.py # 完整两阶段(默认)
  7. python run.py --stage research # 只跑调研
  8. python run.py --stage analysis # 只跑分析(用已有调研结果)
  9. python run.py --stage research --from 2 # 从第3个需求续跑
  10. """
  11. import argparse
  12. import json
  13. import os
  14. import sys
  15. import asyncio
  16. from pathlib import Path
  17. sys.path.insert(0, str(Path(__file__).parent.parent.parent))
  18. from dotenv import load_dotenv
  19. load_dotenv()
  20. from agent.llm.prompts import SimplePrompt
  21. from agent.core.runner import AgentRunner, RunConfig
  22. from agent.trace import FileSystemTraceStore, Trace, Message
  23. from agent.llm import create_qwen_llm_call, create_openrouter_llm_call
  24. from agent.cli import InteractiveController
  25. from agent.utils import setup_logging
  26. from config import (
  27. RESEARCH_RUN_CONFIG, ANALYSIS_RUN_CONFIG,
  28. RESEARCH_OUTPUT_DIR, ANALYSIS_OUTPUT_DIR,
  29. SKILLS_DIR, TRACE_STORE_PATH, DEBUG, LOG_LEVEL, LOG_FILE,
  30. IM_ENABLED, IM_CONTACT_ID, IM_SERVER_URL, IM_WINDOW_MODE, IM_NOTIFY_INTERVAL,
  31. )
  32. # ─────────────────────────────────────────────
  33. # Stage 1 helpers
  34. # ─────────────────────────────────────────────
  35. async def run_single(
  36. runner: AgentRunner,
  37. interactive: InteractiveController,
  38. store: FileSystemTraceStore,
  39. prompt: SimplePrompt,
  40. requirement: str,
  41. output_dir: Path,
  42. task_name: str,
  43. req_index: int,
  44. ) -> tuple[str, bool]:
  45. """执行单个需求的完整调研流程,返回 (最终响应文本, 是否应退出)。"""
  46. output_dir.mkdir(parents=True, exist_ok=True)
  47. messages = prompt.build_messages(
  48. requirement=requirement,
  49. output_dir=str(output_dir),
  50. )
  51. prompt_model = prompt.config.get("model", None)
  52. run_config = RunConfig(
  53. model=prompt_model or RESEARCH_RUN_CONFIG.model,
  54. temperature=RESEARCH_RUN_CONFIG.temperature,
  55. max_iterations=RESEARCH_RUN_CONFIG.max_iterations,
  56. extra_llm_params=RESEARCH_RUN_CONFIG.extra_llm_params,
  57. agent_type=RESEARCH_RUN_CONFIG.agent_type,
  58. name=f"{task_name}:需求{req_index:02d}",
  59. knowledge=RESEARCH_RUN_CONFIG.knowledge,
  60. )
  61. print(f"\n{'=' * 60}")
  62. print(f"[{req_index:02d}] 开始调研")
  63. print(f"需求:{requirement[:80]}{'...' if len(requirement) > 80 else ''}")
  64. print(f"输出:{output_dir}")
  65. print(f"{'=' * 60}")
  66. current_trace_id = None
  67. current_sequence = 0
  68. final_response = ""
  69. should_exit = False
  70. try:
  71. async for item in runner.run(messages=messages, config=run_config):
  72. cmd = interactive.check_stdin()
  73. if cmd == 'pause':
  74. print("\n⏸️ 正在暂停执行...")
  75. if current_trace_id:
  76. await runner.stop(current_trace_id)
  77. await asyncio.sleep(0.5)
  78. menu_result = await interactive.show_menu(current_trace_id, current_sequence)
  79. if menu_result["action"] == "stop":
  80. should_exit = True
  81. break
  82. elif menu_result["action"] == "continue":
  83. new_messages = menu_result.get("messages", [])
  84. run_config.after_sequence = menu_result.get("after_sequence")
  85. if new_messages:
  86. messages = new_messages
  87. break
  88. elif cmd == 'quit':
  89. print("\n🛑 用户请求停止...")
  90. if current_trace_id:
  91. await runner.stop(current_trace_id)
  92. should_exit = True
  93. break
  94. if isinstance(item, Trace):
  95. current_trace_id = item.trace_id
  96. if item.status == "running":
  97. print(f"[Trace] 开始: {item.trace_id[:8]}...")
  98. elif item.status == "completed":
  99. print(f"\n[Trace] ✅ 完成 messages={item.total_messages} cost=${item.total_cost:.4f}")
  100. elif item.status == "failed":
  101. print(f"\n[Trace] ❌ 失败: {item.error_message}")
  102. elif item.status == "stopped":
  103. print(f"\n[Trace] ⏸️ 已停止")
  104. elif isinstance(item, Message):
  105. current_sequence = item.sequence
  106. if item.role == "assistant":
  107. content = item.content
  108. if isinstance(content, dict):
  109. text = content.get("text", "")
  110. tool_calls = content.get("tool_calls")
  111. if text and not tool_calls:
  112. final_response = text
  113. print(f"\n[Response] Agent 回复:")
  114. print(text)
  115. elif text:
  116. preview = text[:150] + "..." if len(text) > 150 else text
  117. print(f"[Assistant] {preview}")
  118. elif item.role == "tool":
  119. content = item.content
  120. tool_name = "unknown"
  121. if isinstance(content, dict):
  122. tool_name = content.get("tool_name", "unknown")
  123. if item.description and item.description != tool_name:
  124. desc = item.description[:80] if len(item.description) > 80 else item.description
  125. print(f"[Tool Result] ✅ {tool_name}: {desc}...")
  126. else:
  127. print(f"[Tool Result] ✅ {tool_name}")
  128. except Exception as e:
  129. print(f"\n执行出错: {e}")
  130. import traceback
  131. traceback.print_exc()
  132. if final_response:
  133. output_file = output_dir / "result.txt"
  134. with open(output_file, 'w', encoding='utf-8') as f:
  135. f.write(final_response)
  136. print(f"\n✓ 结果已保存到: {output_file}")
  137. if current_trace_id:
  138. print(f" Trace ID: {current_trace_id}")
  139. return final_response, should_exit
  140. # ─────────────────────────────────────────────
  141. # Stage 2 helpers
  142. # ─────────────────────────────────────────────
  143. def load_workflows_from_dir(research_dir: Path) -> list[dict]:
  144. """
  145. 扫描 research_dir 下所有子目录(00/, 01/ ...),合并工序发现列表。
  146. 优先读取 workflows.json(Stage 1 新格式);
  147. 若不存在则把目录内 *.md 文件内容作为文本传给 coordinator(兜底)。
  148. """
  149. workflows = []
  150. wf_index = 1
  151. subdirs = sorted(
  152. [d for d in research_dir.iterdir() if d.is_dir()],
  153. key=lambda d: d.name,
  154. )
  155. if not subdirs:
  156. # 单次调研输出(直接含 JSON 文件)
  157. subdirs = [research_dir]
  158. for subdir in subdirs:
  159. workflows_json_path = subdir / "workflows.json"
  160. # ── 优先:读取 workflows.json ──
  161. if workflows_json_path.exists():
  162. try:
  163. with open(workflows_json_path, encoding='utf-8') as f:
  164. data = json.load(f)
  165. discovered = data.get("工序发现", [])
  166. for item in discovered:
  167. wf_id = f"wf_{wf_index:03d}"
  168. wf_index += 1
  169. workflows.append({
  170. "id": wf_id,
  171. "name": item.get("方案名称", "未命名工序"),
  172. "category": "",
  173. "source_channel": item.get("来源渠道", "未知"),
  174. "source_file": str(workflows_json_path.relative_to(research_dir)),
  175. "steps": item.get("工序步骤", []),
  176. "post_links": list(item.get("帖子链接", [])),
  177. })
  178. print(f" + {wf_id}: {item.get('方案名称', '未命名')[:50]}")
  179. continue
  180. except (json.JSONDecodeError, IOError) as e:
  181. print(f" [警告] workflows.json 解析失败: {subdir.name} ({e}),尝试 Markdown 兜底")
  182. # ── 兜底:读取 *.md 文件内容 ──
  183. md_files = sorted(subdir.glob("*.md"))
  184. if md_files:
  185. for md_file in md_files:
  186. try:
  187. content = md_file.read_text(encoding='utf-8')
  188. wf_id = f"wf_{wf_index:03d}"
  189. wf_index += 1
  190. workflows.append({
  191. "id": wf_id,
  192. "name": md_file.stem,
  193. "category": "",
  194. "source_channel": "Markdown报告",
  195. "source_file": str(md_file.relative_to(research_dir)),
  196. "steps": [],
  197. "raw_markdown": content, # coordinator 可直接阅读
  198. })
  199. print(f" + {wf_id}: [MD兜底] {md_file.name}")
  200. except IOError as e:
  201. print(f" [警告] 无法读取 {md_file.name}: {e}")
  202. else:
  203. print(f" [跳过] {subdir.name}:无 workflows.json 也无 .md 文件")
  204. return workflows
  205. async def fetch_atomic_capabilities() -> list[dict]:
  206. """从 knowhub API 获取全量原子能力表。"""
  207. import urllib.request
  208. knowhub_api = os.getenv("KNOWHUB_API", "http://43.106.118.91:9999")
  209. url = f"{knowhub_api}/api/capability?limit=500"
  210. try:
  211. with urllib.request.urlopen(url, timeout=10) as resp:
  212. data = json.loads(resp.read().decode())
  213. capabilities = data.get("results", [])
  214. print(f" 已获取原子能力表:{len(capabilities)} 条")
  215. return capabilities
  216. except Exception as e:
  217. print(f" [警告] 获取原子能力表失败:{e},将跳过匹配")
  218. return []
  219. async def run_analysis(
  220. research_dir: Path,
  221. analysis_dir: Path,
  222. store: FileSystemTraceStore,
  223. prompt_path: Path,
  224. ) -> bool:
  225. """执行 Stage 2 分析,返回是否成功。"""
  226. print(f"\n{'=' * 60}")
  227. print("Stage 2:工作流分析")
  228. print(f"输入:{research_dir}")
  229. print(f"输出:{analysis_dir}")
  230. print(f"{'=' * 60}")
  231. # 扫描工作流数据
  232. print("扫描调研结果...")
  233. workflows = load_workflows_from_dir(research_dir)
  234. if not workflows:
  235. print(" 错误: 未找到任何工序数据,请先运行 Stage 1")
  236. return False
  237. print(f" 共加载 {len(workflows)} 条工作流")
  238. analysis_dir.mkdir(parents=True, exist_ok=True)
  239. # 获取原子能力表并写入文件
  240. print("获取原子能力表...")
  241. atomic_capabilities = await fetch_atomic_capabilities()
  242. atomic_capabilities_path = analysis_dir / "atomic_capabilities.json"
  243. atomic_capabilities_path.write_text(
  244. json.dumps({"atomic_capabilities": atomic_capabilities}, ensure_ascii=False, indent=2),
  245. encoding='utf-8'
  246. )
  247. print(f" 已写入:{atomic_capabilities_path}")
  248. output_path = analysis_dir / "result.json"
  249. # 加载 coordinator prompt
  250. prompt = SimplePrompt(prompt_path)
  251. workflows_json = json.dumps({"workflows": workflows}, ensure_ascii=False, indent=2)
  252. messages = prompt.build_messages(
  253. workflows_json=workflows_json,
  254. output_dir=str(analysis_dir),
  255. output_path=str(output_path),
  256. )
  257. # 创建 Runner(OpenRouter / Claude)
  258. prompt_model = prompt.config.get("model", None) or ANALYSIS_RUN_CONFIG.model
  259. print(f" 模型: {prompt_model}")
  260. runner = AgentRunner(
  261. trace_store=store,
  262. llm_call=create_openrouter_llm_call(model=prompt_model),
  263. skills_dir=SKILLS_DIR,
  264. debug=DEBUG,
  265. )
  266. interactive = InteractiveController(runner=runner, store=store, enable_stdin_check=True)
  267. runner.stdin_check = interactive.check_stdin
  268. run_config = RunConfig(
  269. model=prompt_model,
  270. temperature=ANALYSIS_RUN_CONFIG.temperature,
  271. max_iterations=ANALYSIS_RUN_CONFIG.max_iterations,
  272. agent_type=ANALYSIS_RUN_CONFIG.agent_type,
  273. name=f"工作流分析:{len(workflows)} 条工作流",
  274. )
  275. current_trace_id = None
  276. current_sequence = 0
  277. try:
  278. async for item in runner.run(messages=messages, config=run_config):
  279. cmd = interactive.check_stdin()
  280. if cmd == 'pause':
  281. print("\n⏸️ 正在暂停...")
  282. if current_trace_id:
  283. await runner.stop(current_trace_id)
  284. await asyncio.sleep(0.5)
  285. menu_result = await interactive.show_menu(current_trace_id, current_sequence)
  286. if menu_result["action"] == "stop":
  287. break
  288. elif menu_result["action"] == "continue":
  289. new_messages = menu_result.get("messages", [])
  290. run_config.after_sequence = menu_result.get("after_sequence")
  291. if new_messages:
  292. messages = new_messages
  293. break
  294. elif cmd == 'quit':
  295. print("\n🛑 停止执行...")
  296. if current_trace_id:
  297. await runner.stop(current_trace_id)
  298. break
  299. if isinstance(item, Trace):
  300. current_trace_id = item.trace_id
  301. if item.status == "running":
  302. print(f"[Trace] 开始: {item.trace_id[:8]}...")
  303. elif item.status == "completed":
  304. print(f"\n[Trace] ✅ 完成 messages={item.total_messages} cost=${item.total_cost:.4f}")
  305. elif item.status == "failed":
  306. print(f"\n[Trace] ❌ 失败: {item.error_message}")
  307. elif item.status == "stopped":
  308. print(f"\n[Trace] ⏸️ 已停止")
  309. elif isinstance(item, Message):
  310. current_sequence = item.sequence
  311. if item.role == "assistant":
  312. content = item.content
  313. if isinstance(content, dict):
  314. text = content.get("text", "")
  315. tool_calls = content.get("tool_calls")
  316. if text and not tool_calls:
  317. print(f"\n[Response]\n{text}")
  318. elif text:
  319. preview = text[:150] + "..." if len(text) > 150 else text
  320. print(f"[Assistant] {preview}")
  321. elif item.role == "tool":
  322. content = item.content
  323. tool_name = "unknown"
  324. if isinstance(content, dict):
  325. tool_name = content.get("tool_name", "unknown")
  326. if item.description and item.description != tool_name:
  327. desc = item.description[:80] if len(item.description) > 80 else item.description
  328. print(f"[Tool] ✅ {tool_name}: {desc}...")
  329. else:
  330. print(f"[Tool] ✅ {tool_name}")
  331. except Exception as e:
  332. print(f"\n执行出错: {e}")
  333. import traceback
  334. traceback.print_exc()
  335. except KeyboardInterrupt:
  336. print("\n\n用户中断 (Ctrl+C)")
  337. if current_trace_id:
  338. await runner.stop(current_trace_id)
  339. # 结果摘要
  340. print()
  341. print("=" * 60)
  342. if output_path.exists():
  343. print(f"✅ 分析完成,结果已写入:{output_path}")
  344. try:
  345. with open(output_path, encoding='utf-8') as f:
  346. result = json.load(f)
  347. n_modules = len(result.get("capability_modules", []))
  348. n_coarse = len(result.get("coarse_workflows", []))
  349. print(f" - 能力模块(细工序):{n_modules} 个")
  350. print(f" - 粗工序:{n_coarse} 个品类")
  351. except Exception:
  352. pass
  353. return True
  354. else:
  355. print("⚠️ 未检测到最终输出文件,分析可能未完成")
  356. print(f" 期望路径:{output_path}")
  357. return False
  358. # ─────────────────────────────────────────────
  359. # Main
  360. # ─────────────────────────────────────────────
  361. async def main():
  362. parser = argparse.ArgumentParser(description="两阶段 Pipeline:工具调研 + 工作流分析")
  363. parser.add_argument(
  364. "--stage", choices=["research", "analysis", "all"], default="all",
  365. help="执行阶段:research=只调研, analysis=只分析, all=完整流程(默认)",
  366. )
  367. parser.add_argument(
  368. "--from", dest="from_index", type=int, default=0,
  369. help="从第几个需求开始(0-based,仅 stage=research/all 时有效)",
  370. )
  371. parser.add_argument(
  372. "--requirements", type=str, default=None,
  373. help="需求列表 JSON 文件路径(默认 requirements.json)",
  374. )
  375. args = parser.parse_args()
  376. base_dir = Path(__file__).parent
  377. project_root = base_dir.parent.parent
  378. research_output_dir = project_root / RESEARCH_OUTPUT_DIR
  379. analysis_output_dir = project_root / ANALYSIS_OUTPUT_DIR
  380. setup_logging(level=LOG_LEVEL, file=LOG_FILE)
  381. # 加载 presets
  382. presets_path = base_dir / "presets.json"
  383. if presets_path.exists():
  384. from agent.core.presets import load_presets_from_json
  385. load_presets_from_json(str(presets_path))
  386. print("已加载 presets")
  387. store = FileSystemTraceStore(base_path=TRACE_STORE_PATH)
  388. # ── Stage 1: Research ──
  389. if args.stage in ("all", "research"):
  390. req_path = Path(args.requirements) if args.requirements else base_dir / "requirements.json"
  391. if not req_path.exists():
  392. print(f"错误: 需求文件不存在: {req_path}")
  393. sys.exit(1)
  394. with open(req_path, encoding='utf-8') as f:
  395. requirements = json.load(f)
  396. if not isinstance(requirements, list) or len(requirements) == 0:
  397. print("错误: 需求文件必须是非空 JSON 数组")
  398. sys.exit(1)
  399. research_output_dir.mkdir(parents=True, exist_ok=True)
  400. prompt_path = base_dir / "prompts" / "tool_research.prompt"
  401. prompt = SimplePrompt(prompt_path)
  402. # IM 初始化(可选)
  403. if IM_ENABLED:
  404. from agent.tools.builtin.im.chat import im_setup, im_open_window
  405. result = await im_setup(
  406. contact_id=IM_CONTACT_ID,
  407. server_url=IM_SERVER_URL,
  408. notify_interval=IM_NOTIFY_INTERVAL,
  409. )
  410. print(f"IM: {result.output}")
  411. if IM_WINDOW_MODE:
  412. window_result = await im_open_window(contact_id=IM_CONTACT_ID)
  413. print(f"IM: {window_result.output}")
  414. prompt_model = prompt.config.get("model", None) or RESEARCH_RUN_CONFIG.model
  415. runner = AgentRunner(
  416. trace_store=store,
  417. llm_call=create_qwen_llm_call(model=prompt_model),
  418. skills_dir=SKILLS_DIR,
  419. debug=DEBUG,
  420. )
  421. interactive = InteractiveController(runner=runner, store=store, enable_stdin_check=True)
  422. runner.stdin_check = interactive.check_stdin
  423. task_name = RESEARCH_RUN_CONFIG.name or base_dir.name
  424. total = len(requirements)
  425. start = args.from_index
  426. print("=" * 60)
  427. print(f"Stage 1:{task_name}")
  428. print(f"共 {total} 个需求,从第 {start} 个开始")
  429. print("=" * 60)
  430. print("💡 输入 'p' 暂停,'q' 退出")
  431. print("=" * 60)
  432. completed = 0
  433. try:
  434. for i, requirement in enumerate(requirements):
  435. if i < start:
  436. continue
  437. req_output_dir = research_output_dir / f"{i:02d}"
  438. _, should_exit = await run_single(
  439. runner=runner,
  440. interactive=interactive,
  441. store=store,
  442. prompt=prompt,
  443. requirement=requirement,
  444. output_dir=req_output_dir,
  445. task_name=task_name,
  446. req_index=i,
  447. )
  448. completed += 1
  449. if should_exit:
  450. print(f"\n🛑 用户中止,已完成 {completed}/{total - start} 个需求")
  451. break
  452. except KeyboardInterrupt:
  453. print(f"\n\n用户中断 (Ctrl+C),已完成 {completed}/{total - start} 个需求")
  454. print()
  455. print("=" * 60)
  456. print(f"Stage 1 完成:{completed}/{total - start} 个需求")
  457. print(f"输出根目录:{research_output_dir}")
  458. print("=" * 60)
  459. if args.stage == "all":
  460. # 统计已采集工作流数量(粗略)
  461. wf_count = sum(
  462. 1 for d in research_output_dir.iterdir()
  463. if d.is_dir() and (d / "workflows.json").exists()
  464. )
  465. print(f"\n[Stage 1 完成] 共 {wf_count} 个目录含 workflows.json,自动进入 Stage 2 分析...")
  466. # ── Stage 2: Analysis ──
  467. if args.stage in ("all", "analysis"):
  468. coordinator_prompt_path = base_dir / "prompts" / "coordinator.prompt"
  469. await run_analysis(
  470. research_dir=research_output_dir,
  471. analysis_dir=analysis_output_dir,
  472. store=store,
  473. prompt_path=coordinator_prompt_path,
  474. )
  475. if __name__ == "__main__":
  476. asyncio.run(main())