render_log_html.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. #!/usr/bin/env python3
  2. """将 run_log 文本渲染为可折叠 HTML 页面。
  3. 直接在脚本内修改 INPUT_LOG_PATH / OUTPUT_HTML_PATH 后运行:
  4. python examples/piaoquan_needs/render_log_html.py
  5. """
  6. from __future__ import annotations
  7. import html
  8. from dataclasses import dataclass, field
  9. from pathlib import Path
  10. @dataclass
  11. class Node:
  12. title: str | None = None
  13. entries: list[str | "Node"] = field(default_factory=list)
  14. @property
  15. def is_fold(self) -> bool:
  16. return self.title is not None
  17. def parse_log(content: str) -> Node:
  18. root = Node(title=None)
  19. stack: list[Node] = [root]
  20. for raw_line in content.splitlines():
  21. line = raw_line.rstrip("\n")
  22. if line.startswith("[FOLD:") and line.endswith("]"):
  23. title = line[len("[FOLD:") : -1]
  24. node = Node(title=title)
  25. stack[-1].entries.append(node)
  26. stack.append(node)
  27. continue
  28. if line == "[/FOLD]":
  29. if len(stack) > 1:
  30. stack.pop()
  31. else:
  32. root.entries.append(line)
  33. continue
  34. stack[-1].entries.append(line)
  35. while len(stack) > 1:
  36. unclosed = stack.pop()
  37. # 容错: 遇到缺失 [/FOLD] 时,保留原有内容,不丢日志
  38. stack[-1].entries.append(unclosed)
  39. return root
  40. DEFAULT_COLLAPSE_PREFIXES = ["🔧", "📥", "📤"]
  41. DEFAULT_COLLAPSE_KEYWORDS = ["调用参数", "返回内容"]
  42. # 工具功能摘要(静态映射,用于日志可视化展示)
  43. TOOL_DESCRIPTION_MAP: dict[str, str] = {
  44. "think_and_plan": "系统化记录思考、计划与下一步行动,不查询数据也不修改数据。",
  45. "get_weight_score_topn": "按层级和维度查询权重分 TopN,快速定位高权重元素或分类。",
  46. "get_weight_score_by_name": "按名称精确查询指定元素或分类的权重分,返回匹配明细。",
  47. "get_category_tree": "获取分类树快照,快速查看实质/形式/意图三维结构全貌。",
  48. "get_frequent_itemsets": "查询高频共现的分类组合,按维度模式和深度分组返回。",
  49. "get_itemset_detail": "获取频繁项集完整详情,包括项集结构和匹配帖子列表。",
  50. "get_post_elements": "按帖子查看结构化元素内容,支持点类型及三维元素下钻。",
  51. "search_elements": "按关键词搜索元素,返回分类归属、出现频次与帖子覆盖。",
  52. "get_element_category_chain": "从元素名称反查所属分类链,查看从根到叶的路径。",
  53. "get_category_detail": "查看分类节点上下文,含祖先、子节点、兄弟节点与元素。",
  54. "search_categories": "按关键词搜索分类节点,返回分类 ID 与路径等导航信息。",
  55. "get_category_elements": "获取指定分类下的元素列表及出现统计,便于落地选题。",
  56. "get_category_co_occurrences": "查询分类级共现关系,发现目标分类常同现的其他分类。",
  57. "get_element_co_occurrences": "查询元素级共现关系,发现目标元素常同现的其他元素。",
  58. }
  59. # =========================
  60. # 运行配置(直接改变量即可)
  61. # =========================
  62. INPUT_LOG_PATH = "examples/piaoquan_needs/output/run_log_17_20260324_204533.txt"
  63. # 设为 None 则默认生成到输入文件同名 .html
  64. OUTPUT_HTML_PATH: str | None = None
  65. # 是否默认折叠所有 [FOLD] 块
  66. COLLAPSE_ALL_FOLDS = False
  67. # 命中这些前缀/关键词的折叠块默认收起
  68. COLLAPSE_PREFIXES = DEFAULT_COLLAPSE_PREFIXES
  69. COLLAPSE_KEYWORDS = DEFAULT_COLLAPSE_KEYWORDS
  70. def resolve_config_path(path_str: str) -> Path:
  71. """解析配置中的路径,兼容从项目根目录或脚本目录运行。"""
  72. raw = Path(path_str).expanduser()
  73. if raw.is_absolute():
  74. return raw.resolve()
  75. cwd_candidate = (Path.cwd() / raw).resolve()
  76. if cwd_candidate.exists():
  77. return cwd_candidate
  78. script_dir = Path(__file__).resolve().parent
  79. script_candidate = (script_dir / raw).resolve()
  80. if script_candidate.exists():
  81. return script_candidate
  82. project_root = script_dir.parent.parent
  83. project_candidate = (project_root / raw).resolve()
  84. if project_candidate.exists():
  85. return project_candidate
  86. # 如果都不存在,返回项目根拼接结果,便于报错信息更稳定
  87. return project_candidate
  88. def should_collapse(
  89. title: str,
  90. collapse_prefixes: list[str],
  91. collapse_keywords: list[str],
  92. collapse_all: bool,
  93. ) -> bool:
  94. if collapse_all:
  95. return True
  96. if any(title.startswith(prefix) for prefix in collapse_prefixes):
  97. return True
  98. return any(keyword in title for keyword in collapse_keywords)
  99. def render_text_block(lines: list[str]) -> str:
  100. if not lines:
  101. return ""
  102. normalized = lines[:]
  103. while normalized and normalized[0].strip() == "":
  104. normalized.pop(0)
  105. while normalized and normalized[-1].strip() == "":
  106. normalized.pop()
  107. if not normalized:
  108. return ""
  109. compact: list[str] = []
  110. empty_streak = 0
  111. for line in normalized:
  112. if line.strip() == "":
  113. empty_streak += 1
  114. if empty_streak <= 1:
  115. compact.append("")
  116. else:
  117. empty_streak = 0
  118. compact.append(line)
  119. escaped = html.escape("\n".join(compact))
  120. return f'<pre class="log-text">{escaped}</pre>'
  121. def enrich_fold_title(title: str) -> str:
  122. """为工具调用标题附加工具功能描述。"""
  123. tool_prefix = "🔧 "
  124. if not title.startswith(tool_prefix):
  125. return title
  126. tool_name = title[len(tool_prefix):].strip()
  127. description = TOOL_DESCRIPTION_MAP.get(tool_name)
  128. if not description:
  129. return title
  130. return f"{tool_prefix}{tool_name}({description})"
  131. def render_node(
  132. node: Node,
  133. collapse_prefixes: list[str],
  134. collapse_keywords: list[str],
  135. collapse_all: bool,
  136. ) -> str:
  137. parts: list[str] = []
  138. text_buffer: list[str] = []
  139. def flush_text_buffer() -> None:
  140. if text_buffer:
  141. parts.append(render_text_block(text_buffer))
  142. text_buffer.clear()
  143. for entry in node.entries:
  144. if isinstance(entry, str):
  145. text_buffer.append(entry)
  146. continue
  147. child = entry
  148. if child.is_fold:
  149. flush_text_buffer()
  150. title = child.title or ""
  151. is_collapsed = should_collapse(
  152. title=title,
  153. collapse_prefixes=collapse_prefixes,
  154. collapse_keywords=collapse_keywords,
  155. collapse_all=collapse_all,
  156. )
  157. folded_class = "fold tool-fold" if is_collapsed else "fold normal-fold"
  158. open_attr = "" if is_collapsed else " open"
  159. display_title = enrich_fold_title(title)
  160. inner = render_node(
  161. child,
  162. collapse_prefixes=collapse_prefixes,
  163. collapse_keywords=collapse_keywords,
  164. collapse_all=collapse_all,
  165. )
  166. parts.append(
  167. f'<details class="{folded_class}"{open_attr}>'
  168. f'<summary>{html.escape(display_title)}</summary>'
  169. f"{inner}"
  170. "</details>"
  171. )
  172. flush_text_buffer()
  173. return "".join(parts)
  174. def build_html(body: str, source_name: str) -> str:
  175. return f"""<!doctype html>
  176. <html lang="zh-CN">
  177. <head>
  178. <meta charset="UTF-8" />
  179. <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  180. <title>Run Log 可视化 - {html.escape(source_name)}</title>
  181. <style>
  182. :root {{
  183. --bg: #0b1020;
  184. --panel: #131a2a;
  185. --text: #e8edf7;
  186. --muted: #98a2b3;
  187. --accent: #6ea8fe;
  188. --border: #263146;
  189. }}
  190. * {{
  191. box-sizing: border-box;
  192. }}
  193. body {{
  194. margin: 0;
  195. background: var(--bg);
  196. color: var(--text);
  197. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
  198. }}
  199. .wrap {{
  200. max-width: 1200px;
  201. margin: 0 auto;
  202. padding: 20px;
  203. }}
  204. .header {{
  205. margin-bottom: 14px;
  206. display: flex;
  207. align-items: center;
  208. gap: 10px;
  209. flex-wrap: wrap;
  210. }}
  211. .title {{
  212. font-size: 18px;
  213. font-weight: 700;
  214. }}
  215. .source {{
  216. color: var(--muted);
  217. font-size: 13px;
  218. }}
  219. button {{
  220. border: 1px solid var(--border);
  221. background: var(--panel);
  222. color: var(--text);
  223. padding: 6px 10px;
  224. border-radius: 8px;
  225. cursor: pointer;
  226. }}
  227. button:hover {{
  228. border-color: var(--accent);
  229. color: var(--accent);
  230. }}
  231. .content {{
  232. background: var(--panel);
  233. border: 1px solid var(--border);
  234. border-radius: 10px;
  235. padding: 10px;
  236. }}
  237. details {{
  238. margin: 6px 0;
  239. border: 1px solid var(--border);
  240. border-radius: 8px;
  241. background: rgba(255, 255, 255, 0.01);
  242. }}
  243. details > summary {{
  244. cursor: pointer;
  245. padding: 8px 10px;
  246. font-size: 13px;
  247. list-style: none;
  248. user-select: none;
  249. color: #cdd6e5;
  250. }}
  251. details > summary::-webkit-details-marker {{
  252. display: none;
  253. }}
  254. details > summary::before {{
  255. content: "▶";
  256. display: inline-block;
  257. margin-right: 6px;
  258. transform: rotate(0deg);
  259. transition: transform 120ms ease;
  260. color: var(--muted);
  261. }}
  262. details[open] > summary::before {{
  263. transform: rotate(90deg);
  264. }}
  265. .tool-fold > summary {{
  266. color: #f6cf76;
  267. }}
  268. .log-text {{
  269. margin: 0;
  270. padding: 10px;
  271. border-top: 1px dashed var(--border);
  272. color: var(--text);
  273. white-space: pre-wrap;
  274. word-break: break-word;
  275. line-height: 1.4;
  276. font-size: 13px;
  277. font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
  278. }}
  279. </style>
  280. </head>
  281. <body>
  282. <div class="wrap">
  283. <div class="header">
  284. <div class="title">Run Log 可视化</div>
  285. <div class="source">{html.escape(source_name)}</div>
  286. <button id="expand-tools">展开全部工具调用</button>
  287. <button id="collapse-tools">折叠全部工具调用</button>
  288. </div>
  289. <div class="content">{body}</div>
  290. </div>
  291. <script>
  292. const toolFolds = Array.from(document.querySelectorAll("details.tool-fold"));
  293. document.getElementById("expand-tools").addEventListener("click", () => {{
  294. toolFolds.forEach((el) => (el.open = true));
  295. }});
  296. document.getElementById("collapse-tools").addEventListener("click", () => {{
  297. toolFolds.forEach((el) => (el.open = false));
  298. }});
  299. </script>
  300. </body>
  301. </html>
  302. """
  303. def generate_html(
  304. input_path: Path,
  305. output_path: Path,
  306. collapse_prefixes: list[str],
  307. collapse_keywords: list[str],
  308. collapse_all: bool = False,
  309. ) -> None:
  310. content = input_path.read_text(encoding="utf-8")
  311. tree = parse_log(content)
  312. body = render_node(
  313. tree,
  314. collapse_prefixes=collapse_prefixes,
  315. collapse_keywords=collapse_keywords,
  316. collapse_all=collapse_all,
  317. )
  318. html_content = build_html(body=body, source_name=input_path.name)
  319. output_path.parent.mkdir(parents=True, exist_ok=True)
  320. output_path.write_text(html_content, encoding="utf-8")
  321. def main() -> None:
  322. input_path = resolve_config_path(INPUT_LOG_PATH)
  323. if not input_path.exists():
  324. raise FileNotFoundError(f"输入文件不存在: {input_path}")
  325. if OUTPUT_HTML_PATH:
  326. output_path = resolve_config_path(OUTPUT_HTML_PATH)
  327. else:
  328. output_path = input_path.with_suffix(".html")
  329. generate_html(
  330. input_path=input_path,
  331. output_path=output_path,
  332. collapse_prefixes=COLLAPSE_PREFIXES,
  333. collapse_keywords=COLLAPSE_KEYWORDS,
  334. collapse_all=COLLAPSE_ALL_FOLDS,
  335. )
  336. print(f"HTML 已生成: {output_path}")
  337. if __name__ == "__main__":
  338. main()