render_log_html.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. #!/usr/bin/env python3
  2. """将 run_log 文本渲染为可折叠 HTML 页面。
  3. 直接在脚本内修改 INPUT_LOG_PATH / OUTPUT_HTML_PATH 后运行:
  4. python examples/piaoquan_demand/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_demand/output/element/run_log_17_20260326_141309.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. # 仅针对特定结构做“折叠内容复制”
  71. # 规则(HTML 渲染层面):
  72. # - 只对 `FOLD:🔧 think_and_plan` 进行处理
  73. # - 只复制其内部 `FOLD:📤 返回内容` 包裹的“内容文本”
  74. # - 将复制内容直接插入到 `think_and_plan` 对应的 `<details>` 之外,
  75. # 从而无需点开工具调用就能在最外层看到
  76. THINK_AND_PLAN_TOOL_TITLE = "🔧 think_and_plan"
  77. RETURN_CONTENT_FOLD_TITLE = "📤 返回内容"
  78. def resolve_config_path(path_str: str) -> Path:
  79. """解析配置中的路径,兼容从项目根目录或脚本目录运行。"""
  80. raw = Path(path_str).expanduser()
  81. if raw.is_absolute():
  82. return raw.resolve()
  83. cwd_candidate = (Path.cwd() / raw).resolve()
  84. if cwd_candidate.exists():
  85. return cwd_candidate
  86. script_dir = Path(__file__).resolve().parent
  87. script_candidate = (script_dir / raw).resolve()
  88. if script_candidate.exists():
  89. return script_candidate
  90. project_root = script_dir.parent.parent
  91. project_candidate = (project_root / raw).resolve()
  92. if project_candidate.exists():
  93. return project_candidate
  94. # 如果都不存在,返回项目根拼接结果,便于报错信息更稳定
  95. return project_candidate
  96. def should_collapse(
  97. title: str,
  98. collapse_prefixes: list[str],
  99. collapse_keywords: list[str],
  100. collapse_all: bool,
  101. ) -> bool:
  102. if collapse_all:
  103. return True
  104. if any(title.startswith(prefix) for prefix in collapse_prefixes):
  105. return True
  106. return any(keyword in title for keyword in collapse_keywords)
  107. def render_text_block(lines: list[str]) -> str:
  108. if not lines:
  109. return ""
  110. normalized = lines[:]
  111. while normalized and normalized[0].strip() == "":
  112. normalized.pop(0)
  113. while normalized and normalized[-1].strip() == "":
  114. normalized.pop()
  115. if not normalized:
  116. return ""
  117. compact: list[str] = []
  118. empty_streak = 0
  119. for line in normalized:
  120. if line.strip() == "":
  121. empty_streak += 1
  122. if empty_streak <= 1:
  123. compact.append("")
  124. else:
  125. empty_streak = 0
  126. compact.append(line)
  127. escaped = html.escape("\n".join(compact))
  128. return f'<pre class="log-text">{escaped}</pre>'
  129. def flatten_entries_to_lines(entries: list[str | Node]) -> list[str]:
  130. """把节点树(可能包含 fold)拍平成纯文本行,用于“无需点开折叠块”展示。"""
  131. out: list[str] = []
  132. for entry in entries:
  133. if isinstance(entry, str):
  134. out.append(entry)
  135. else:
  136. # 只取其内部文本,忽略 fold 标题本身
  137. out.extend(flatten_entries_to_lines(entry.entries))
  138. return out
  139. def enrich_fold_title(title: str) -> str:
  140. """为工具调用标题附加工具功能描述。"""
  141. tool_prefix = "🔧 "
  142. if not title.startswith(tool_prefix):
  143. return title
  144. tool_name = title[len(tool_prefix):].strip()
  145. description = TOOL_DESCRIPTION_MAP.get(tool_name)
  146. if not description:
  147. return title
  148. return f"{tool_prefix}{tool_name}({description})"
  149. def render_node(
  150. node: Node,
  151. collapse_prefixes: list[str],
  152. collapse_keywords: list[str],
  153. collapse_all: bool,
  154. # 预留参数:历史上用于判断折叠上下文;当前逻辑已不需要该状态。
  155. in_think_and_plan: bool = False,
  156. # 是否在渲染 `🔧 think_and_plan` 时,把其 `📤 返回内容` 复制为“details 外的纯文本”。
  157. # root 的“工具调用收集块”会负责把复制文本放到块外,因此在该场景下需要关闭。
  158. emit_think_plan_return_copy: bool = True,
  159. ) -> str:
  160. # root(最外层)只做一件事:
  161. # 把“连续出现的工具调用”打包到一个大折叠块里(文本之间的工具调用收集在一起)。
  162. if node.title is None:
  163. parts: list[str] = []
  164. text_buffer: list[str] = []
  165. tool_group_parts: list[str] = []
  166. def flush_text_buffer() -> None:
  167. if text_buffer:
  168. parts.append(render_text_block(text_buffer))
  169. text_buffer.clear()
  170. def flush_tool_group() -> None:
  171. if tool_group_parts:
  172. parts.append(
  173. '<details class="fold tool-fold tool-call-group">'
  174. '<summary>工具调用</summary>'
  175. + "".join(tool_group_parts)
  176. + "</details>"
  177. )
  178. tool_group_parts.clear()
  179. def render_fold_details(fold_node: Node) -> str:
  180. """把一个 fold 节点整体包装为 <details>(不仅渲染它的子内容)。"""
  181. title = fold_node.title or ""
  182. is_collapsed = should_collapse(
  183. title=title,
  184. collapse_prefixes=collapse_prefixes,
  185. collapse_keywords=collapse_keywords,
  186. collapse_all=collapse_all,
  187. )
  188. folded_class = "fold tool-fold" if is_collapsed else "fold normal-fold"
  189. open_attr = "" if is_collapsed else " open"
  190. display_title = enrich_fold_title(title)
  191. inner = render_node(
  192. fold_node,
  193. collapse_prefixes=collapse_prefixes,
  194. collapse_keywords=collapse_keywords,
  195. collapse_all=collapse_all,
  196. emit_think_plan_return_copy=False,
  197. )
  198. rendered = (
  199. f'<details class="{folded_class}"{open_attr}>'
  200. f"<summary>{html.escape(display_title)}</summary>"
  201. f"{inner}"
  202. "</details>"
  203. )
  204. return rendered
  205. for entry in node.entries:
  206. if isinstance(entry, str):
  207. # 空白行不算“文本”,不打断连续工具调用收集。
  208. if entry.strip() == "":
  209. if tool_group_parts:
  210. # 工具块之间的空白忽略
  211. continue
  212. text_buffer.append(entry)
  213. continue
  214. # 真正的文本出现:结束当前工具调用收集,并输出文本
  215. flush_tool_group()
  216. text_buffer.append(entry)
  217. continue
  218. child = entry
  219. child_title = child.title or ""
  220. is_tool_call = child.is_fold and child_title.startswith("🔧 ")
  221. if is_tool_call:
  222. # 工具调用开始:先把前面的文本输出,再收集到工具组里
  223. flush_text_buffer()
  224. tool_group_parts.append(render_fold_details(child))
  225. # 关键:`🔧 think_and_plan` 复制出来的 `📤 返回内容` 也算文本,
  226. # 需要放在工具调用收集块之外,从而打断相邻工具调用的收集分组。
  227. if child_title == THINK_AND_PLAN_TOOL_TITLE:
  228. return_nodes = [
  229. e
  230. for e in child.entries
  231. if isinstance(e, Node) and e.is_fold and (e.title or "") == RETURN_CONTENT_FOLD_TITLE
  232. ]
  233. if return_nodes:
  234. flattened_lines = flatten_entries_to_lines(return_nodes[0].entries)
  235. flush_tool_group()
  236. parts.append(render_text_block(flattened_lines))
  237. else:
  238. # 遇到非工具调用(或非工具 fold):结束当前工具组,再按原样输出
  239. flush_tool_group()
  240. flush_text_buffer()
  241. parts.append(
  242. render_node(
  243. child,
  244. collapse_prefixes=collapse_prefixes,
  245. collapse_keywords=collapse_keywords,
  246. collapse_all=collapse_all,
  247. )
  248. )
  249. flush_tool_group()
  250. flush_text_buffer()
  251. return "".join(parts)
  252. parts: list[str] = []
  253. text_buffer: list[str] = []
  254. def flush_text_buffer() -> None:
  255. if text_buffer:
  256. parts.append(render_text_block(text_buffer))
  257. text_buffer.clear()
  258. for entry in node.entries:
  259. if isinstance(entry, str):
  260. text_buffer.append(entry)
  261. continue
  262. child = entry
  263. if child.is_fold:
  264. flush_text_buffer()
  265. title = child.title or ""
  266. is_collapsed = should_collapse(
  267. title=title,
  268. collapse_prefixes=collapse_prefixes,
  269. collapse_keywords=collapse_keywords,
  270. collapse_all=collapse_all,
  271. )
  272. folded_class = "fold tool-fold" if is_collapsed else "fold normal-fold"
  273. open_attr = "" if is_collapsed else " open"
  274. display_title = enrich_fold_title(title)
  275. inner = render_node(
  276. child,
  277. collapse_prefixes=collapse_prefixes,
  278. collapse_keywords=collapse_keywords,
  279. collapse_all=collapse_all,
  280. )
  281. parts.append(
  282. f'<details class="{folded_class}"{open_attr}>'
  283. f'<summary>{html.escape(display_title)}</summary>'
  284. f"{inner}"
  285. "</details>"
  286. )
  287. # 关键点:只把 `📤 返回内容` 复制到 `think_and_plan` 的 <details> 之外
  288. # 但当 root 工具调用收集块要负责“details 外展示”时,需要关闭此复制。
  289. if emit_think_plan_return_copy and title == THINK_AND_PLAN_TOOL_TITLE:
  290. return_nodes = [
  291. e
  292. for e in child.entries
  293. if isinstance(e, Node) and e.is_fold and (e.title or "") == RETURN_CONTENT_FOLD_TITLE
  294. ]
  295. if return_nodes:
  296. # 一般只会有一个 `📤 返回内容`,取第一个
  297. return_node = return_nodes[0]
  298. flattened_lines = flatten_entries_to_lines(return_node.entries)
  299. parts.append(render_text_block(flattened_lines))
  300. flush_text_buffer()
  301. return "".join(parts)
  302. def build_html(body: str, source_name: str) -> str:
  303. return f"""<!doctype html>
  304. <html lang="zh-CN">
  305. <head>
  306. <meta charset="UTF-8" />
  307. <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  308. <title>Run Log 可视化 - {html.escape(source_name)}</title>
  309. <style>
  310. :root {{
  311. --bg: #0b1020;
  312. --panel: #131a2a;
  313. --text: #e8edf7;
  314. --muted: #98a2b3;
  315. --accent: #6ea8fe;
  316. --border: #263146;
  317. }}
  318. * {{
  319. box-sizing: border-box;
  320. }}
  321. body {{
  322. margin: 0;
  323. background: var(--bg);
  324. color: var(--text);
  325. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
  326. }}
  327. .wrap {{
  328. max-width: 1200px;
  329. margin: 0 auto;
  330. padding: 20px;
  331. }}
  332. .header {{
  333. margin-bottom: 14px;
  334. display: flex;
  335. align-items: center;
  336. gap: 10px;
  337. flex-wrap: wrap;
  338. }}
  339. .title {{
  340. font-size: 18px;
  341. font-weight: 700;
  342. }}
  343. .source {{
  344. color: var(--muted);
  345. font-size: 13px;
  346. }}
  347. button {{
  348. border: 1px solid var(--border);
  349. background: var(--panel);
  350. color: var(--text);
  351. padding: 6px 10px;
  352. border-radius: 8px;
  353. cursor: pointer;
  354. }}
  355. button:hover {{
  356. border-color: var(--accent);
  357. color: var(--accent);
  358. }}
  359. .content {{
  360. background: var(--panel);
  361. border: 1px solid var(--border);
  362. border-radius: 10px;
  363. padding: 10px;
  364. }}
  365. details {{
  366. margin: 6px 0;
  367. border: 1px solid var(--border);
  368. border-radius: 8px;
  369. background: rgba(255, 255, 255, 0.01);
  370. }}
  371. details > summary {{
  372. cursor: pointer;
  373. padding: 8px 10px;
  374. font-size: 13px;
  375. list-style: none;
  376. user-select: none;
  377. color: #cdd6e5;
  378. }}
  379. details > summary::-webkit-details-marker {{
  380. display: none;
  381. }}
  382. details > summary::before {{
  383. content: "▶";
  384. display: inline-block;
  385. margin-right: 6px;
  386. transform: rotate(0deg);
  387. transition: transform 120ms ease;
  388. color: var(--muted);
  389. }}
  390. details[open] > summary::before {{
  391. transform: rotate(90deg);
  392. }}
  393. .tool-fold > summary {{
  394. color: #f6cf76;
  395. }}
  396. .log-text {{
  397. margin: 0;
  398. padding: 10px;
  399. border-top: 1px dashed var(--border);
  400. color: var(--text);
  401. white-space: pre-wrap;
  402. word-break: break-word;
  403. line-height: 1.4;
  404. font-size: 13px;
  405. font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
  406. }}
  407. .fold-duplicate {{
  408. margin: 6px 0 12px 0;
  409. padding-left: 10px;
  410. border-left: 2px solid rgba(110, 168, 254, 0.6);
  411. }}
  412. .tool-call-group {{
  413. margin-top: 10px;
  414. }}
  415. </style>
  416. </head>
  417. <body>
  418. <div class="wrap">
  419. <div class="header">
  420. <div class="title">Run Log 可视化</div>
  421. <div class="source">{html.escape(source_name)}</div>
  422. <button id="expand-tools">展开全部工具调用</button>
  423. <button id="collapse-tools">折叠全部工具调用</button>
  424. </div>
  425. <div class="content">{body}</div>
  426. </div>
  427. <script>
  428. const toolFolds = Array.from(document.querySelectorAll("details.tool-fold"));
  429. document.getElementById("expand-tools").addEventListener("click", () => {{
  430. toolFolds.forEach((el) => (el.open = true));
  431. }});
  432. document.getElementById("collapse-tools").addEventListener("click", () => {{
  433. toolFolds.forEach((el) => (el.open = false));
  434. }});
  435. // 每个“工具调用”大折叠块:打开外层时,里面的子工具调用保持收起(不自动展开)
  436. Array.from(document.querySelectorAll("details.tool-call-group")).forEach((group) => {{
  437. group.addEventListener("toggle", () => {{
  438. group.querySelectorAll("details.tool-fold").forEach((el) => (el.open = false));
  439. }});
  440. }});
  441. </script>
  442. </body>
  443. </html>
  444. """
  445. def generate_html(
  446. input_path: Path,
  447. output_path: Path,
  448. collapse_prefixes: list[str],
  449. collapse_keywords: list[str],
  450. collapse_all: bool = False,
  451. ) -> None:
  452. content = input_path.read_text(encoding="utf-8")
  453. tree = parse_log(content)
  454. body = render_node(
  455. tree,
  456. collapse_prefixes=collapse_prefixes,
  457. collapse_keywords=collapse_keywords,
  458. collapse_all=collapse_all,
  459. )
  460. html_content = build_html(body=body, source_name=input_path.name)
  461. output_path.parent.mkdir(parents=True, exist_ok=True)
  462. output_path.write_text(html_content, encoding="utf-8")
  463. def main() -> None:
  464. input_path = resolve_config_path(INPUT_LOG_PATH)
  465. if not input_path.exists():
  466. raise FileNotFoundError(f"输入文件不存在: {input_path}")
  467. if OUTPUT_HTML_PATH:
  468. output_path = resolve_config_path(OUTPUT_HTML_PATH)
  469. else:
  470. output_path = input_path.with_suffix(".html")
  471. generate_html(
  472. input_path=input_path,
  473. output_path=output_path,
  474. collapse_prefixes=COLLAPSE_PREFIXES,
  475. collapse_keywords=COLLAPSE_KEYWORDS,
  476. collapse_all=COLLAPSE_ALL_FOLDS,
  477. )
  478. print(f"HTML 已生成: {output_path}")
  479. if __name__ == "__main__":
  480. main()