cli.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. """CLI for visualizing agent run logs."""
  2. from __future__ import annotations
  3. import argparse
  4. import webbrowser
  5. from pathlib import Path
  6. from supply_agent.logging.visualize import generate_visualization
  7. # supply_agent/logging/cli.py → project root
  8. _PROJECT_ROOT = Path(__file__).resolve().parents[2]
  9. def _has_run_logs(directory: Path) -> bool:
  10. if not directory.is_dir():
  11. return False
  12. return any(directory.glob("run_*.jsonl")) or any(directory.glob("run_*.log"))
  13. def _resolve_logs_dir(logs_dir: str | Path) -> Path:
  14. """
  15. Resolve logs directory.
  16. Relative paths prefer the SupplyAgent project root (stable regardless of
  17. CWD). Falls back to CWD only when that location actually contains run logs.
  18. """
  19. path = Path(logs_dir)
  20. if path.is_absolute():
  21. return path.resolve()
  22. project_candidate = (_PROJECT_ROOT / path).resolve()
  23. cwd_candidate = (Path.cwd() / path).resolve()
  24. if _has_run_logs(project_candidate):
  25. return project_candidate
  26. if _has_run_logs(cwd_candidate):
  27. return cwd_candidate
  28. if project_candidate.is_dir():
  29. return project_candidate
  30. if cwd_candidate.is_dir():
  31. return cwd_candidate
  32. return project_candidate
  33. def _resolve_run_path(run: str | Path) -> Path:
  34. path = Path(run)
  35. if path.is_absolute():
  36. return path.resolve()
  37. cwd_candidate = (Path.cwd() / path).resolve()
  38. if cwd_candidate.exists():
  39. return cwd_candidate
  40. project_candidate = (_PROJECT_ROOT / path).resolve()
  41. if project_candidate.exists():
  42. return project_candidate
  43. # Stem / missing suffix: try under project logs/
  44. logs = _resolve_logs_dir("logs")
  45. for suffix in (".jsonl", ".log", ""):
  46. candidate = logs / f"{path.name}{suffix}" if suffix else logs / path.name
  47. if candidate.exists():
  48. return candidate
  49. return project_candidate
  50. def _find_latest_run(logs_dir: Path) -> Path:
  51. runs = _list_all_runs(logs_dir)
  52. if not runs:
  53. raise FileNotFoundError(f"No run_*.jsonl / run_*.log found in {logs_dir}")
  54. return runs[-1]
  55. def _list_all_runs(logs_dir: Path) -> list[Path]:
  56. """
  57. List all unique agent runs in logs_dir.
  58. Prefers ``.jsonl`` over ``.log`` when both exist for the same stem.
  59. Sorted by mtime ascending (oldest first).
  60. """
  61. logs_dir = _resolve_logs_dir(logs_dir)
  62. if not logs_dir.is_dir():
  63. raise FileNotFoundError(f"Logs directory not found: {logs_dir}")
  64. by_stem: dict[str, Path] = {}
  65. for path in logs_dir.glob("run_*.jsonl"):
  66. by_stem[path.stem] = path
  67. for path in logs_dir.glob("run_*.log"):
  68. by_stem.setdefault(path.stem, path)
  69. return sorted(by_stem.values(), key=lambda p: p.stat().st_mtime)
  70. def main(argv: list[str] | None = None) -> int:
  71. """
  72. Generate HTML visualizations for agent run logs.
  73. Parameters
  74. ----------
  75. argv:
  76. Optional CLI args. Use ``main()`` or ``main([])`` from Python to
  77. visualize all runs under the project ``logs/`` directory (CWD-independent).
  78. """
  79. parser = argparse.ArgumentParser(
  80. description="将 Agent 运行日志生成为可视化 HTML 页面",
  81. )
  82. parser.add_argument(
  83. "run",
  84. nargs="?",
  85. help="日志路径(.jsonl / .log / 不带后缀的 run id);省略则处理 logs 目录下全部运行",
  86. )
  87. parser.add_argument(
  88. "-o",
  89. "--output",
  90. help="输出 HTML 路径(仅单文件模式有效;默认与日志同目录同名 .html)",
  91. )
  92. parser.add_argument(
  93. "--logs-dir",
  94. default="logs",
  95. help="日志目录(默认项目根下 logs;无参数时批量生成,或配合 --latest)",
  96. )
  97. parser.add_argument(
  98. "--latest",
  99. action="store_true",
  100. help="仅可视化 logs 目录中最新一次运行",
  101. )
  102. parser.add_argument(
  103. "--open",
  104. action="store_true",
  105. help="生成后在浏览器中打开(批量模式打开最新一份)",
  106. )
  107. # 直接 main() / main(None) 视为无参数批量,避免 IDE / 其它入口的 argv 干扰
  108. args = parser.parse_args([] if argv is None else argv)
  109. logs_dir = _resolve_logs_dir(args.logs_dir)
  110. if args.latest:
  111. run_paths = [_find_latest_run(logs_dir)]
  112. elif args.run:
  113. run_paths = [_resolve_run_path(args.run)]
  114. else:
  115. try:
  116. run_paths = _list_all_runs(logs_dir)
  117. except FileNotFoundError as exc:
  118. print(exc)
  119. return 1
  120. if not run_paths:
  121. print(f"未找到可可视化的日志: {logs_dir}/run_*.jsonl|*.log")
  122. return 1
  123. if args.output:
  124. parser.error("批量生成时不能指定 --output,请省略 -o 或指定单个 run 路径")
  125. outputs: list[Path] = []
  126. errors = 0
  127. for run_path in run_paths:
  128. try:
  129. out = generate_visualization(
  130. run_path,
  131. args.output if len(run_paths) == 1 else None,
  132. )
  133. outputs.append(out)
  134. print(f"已生成可视化页面: {out}")
  135. except Exception as exc:
  136. errors += 1
  137. print(f"跳过 {run_path}: {exc}")
  138. if not outputs:
  139. return 1
  140. if args.open:
  141. webbrowser.open(outputs[-1].resolve().as_uri())
  142. if len(outputs) > 1:
  143. print(f"共生成 {len(outputs)} 个页面" + (f",失败 {errors} 个" if errors else ""))
  144. return 1 if errors else 0
  145. if __name__ == "__main__":
  146. import sys
  147. raise SystemExit(main(sys.argv[1:]))