|
|
@@ -0,0 +1,385 @@
|
|
|
+#!/usr/bin/env python3
|
|
|
+"""将 run_log 文本渲染为可折叠 HTML 页面。
|
|
|
+
|
|
|
+直接在脚本内修改 INPUT_LOG_PATH / OUTPUT_HTML_PATH 后运行:
|
|
|
+ python examples/piaoquan_needs/render_log_html.py
|
|
|
+"""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import html
|
|
|
+from dataclasses import dataclass, field
|
|
|
+from pathlib import Path
|
|
|
+
|
|
|
+
|
|
|
+@dataclass
|
|
|
+class Node:
|
|
|
+ title: str | None = None
|
|
|
+ entries: list[str | "Node"] = field(default_factory=list)
|
|
|
+
|
|
|
+ @property
|
|
|
+ def is_fold(self) -> bool:
|
|
|
+ return self.title is not None
|
|
|
+
|
|
|
+
|
|
|
+def parse_log(content: str) -> Node:
|
|
|
+ root = Node(title=None)
|
|
|
+ stack: list[Node] = [root]
|
|
|
+
|
|
|
+ for raw_line in content.splitlines():
|
|
|
+ line = raw_line.rstrip("\n")
|
|
|
+ if line.startswith("[FOLD:") and line.endswith("]"):
|
|
|
+ title = line[len("[FOLD:") : -1]
|
|
|
+ node = Node(title=title)
|
|
|
+ stack[-1].entries.append(node)
|
|
|
+ stack.append(node)
|
|
|
+ continue
|
|
|
+ if line == "[/FOLD]":
|
|
|
+ if len(stack) > 1:
|
|
|
+ stack.pop()
|
|
|
+ else:
|
|
|
+ root.entries.append(line)
|
|
|
+ continue
|
|
|
+ stack[-1].entries.append(line)
|
|
|
+
|
|
|
+ while len(stack) > 1:
|
|
|
+ unclosed = stack.pop()
|
|
|
+ # 容错: 遇到缺失 [/FOLD] 时,保留原有内容,不丢日志
|
|
|
+ stack[-1].entries.append(unclosed)
|
|
|
+ return root
|
|
|
+
|
|
|
+
|
|
|
+DEFAULT_COLLAPSE_PREFIXES = ["🔧", "📥", "📤"]
|
|
|
+DEFAULT_COLLAPSE_KEYWORDS = ["调用参数", "返回内容"]
|
|
|
+
|
|
|
+# 工具功能摘要(静态映射,用于日志可视化展示)
|
|
|
+TOOL_DESCRIPTION_MAP: dict[str, str] = {
|
|
|
+ "think_and_plan": "系统化记录思考、计划与下一步行动,不查询数据也不修改数据。",
|
|
|
+ "get_weight_score_topn": "按层级和维度查询权重分 TopN,快速定位高权重元素或分类。",
|
|
|
+ "get_weight_score_by_name": "按名称精确查询指定元素或分类的权重分,返回匹配明细。",
|
|
|
+ "get_category_tree": "获取分类树快照,快速查看实质/形式/意图三维结构全貌。",
|
|
|
+ "get_frequent_itemsets": "查询高频共现的分类组合,按维度模式和深度分组返回。",
|
|
|
+ "get_itemset_detail": "获取频繁项集完整详情,包括项集结构和匹配帖子列表。",
|
|
|
+ "get_post_elements": "按帖子查看结构化元素内容,支持点类型及三维元素下钻。",
|
|
|
+ "search_elements": "按关键词搜索元素,返回分类归属、出现频次与帖子覆盖。",
|
|
|
+ "get_element_category_chain": "从元素名称反查所属分类链,查看从根到叶的路径。",
|
|
|
+ "get_category_detail": "查看分类节点上下文,含祖先、子节点、兄弟节点与元素。",
|
|
|
+ "search_categories": "按关键词搜索分类节点,返回分类 ID 与路径等导航信息。",
|
|
|
+ "get_category_elements": "获取指定分类下的元素列表及出现统计,便于落地选题。",
|
|
|
+ "get_category_co_occurrences": "查询分类级共现关系,发现目标分类常同现的其他分类。",
|
|
|
+ "get_element_co_occurrences": "查询元素级共现关系,发现目标元素常同现的其他元素。",
|
|
|
+}
|
|
|
+
|
|
|
+# =========================
|
|
|
+# 运行配置(直接改变量即可)
|
|
|
+# =========================
|
|
|
+INPUT_LOG_PATH = "examples/piaoquan_needs/output/run_log_17_20260324_204533.txt"
|
|
|
+# 设为 None 则默认生成到输入文件同名 .html
|
|
|
+OUTPUT_HTML_PATH: str | None = None
|
|
|
+# 是否默认折叠所有 [FOLD] 块
|
|
|
+COLLAPSE_ALL_FOLDS = False
|
|
|
+# 命中这些前缀/关键词的折叠块默认收起
|
|
|
+COLLAPSE_PREFIXES = DEFAULT_COLLAPSE_PREFIXES
|
|
|
+COLLAPSE_KEYWORDS = DEFAULT_COLLAPSE_KEYWORDS
|
|
|
+
|
|
|
+
|
|
|
+def resolve_config_path(path_str: str) -> Path:
|
|
|
+ """解析配置中的路径,兼容从项目根目录或脚本目录运行。"""
|
|
|
+ raw = Path(path_str).expanduser()
|
|
|
+ if raw.is_absolute():
|
|
|
+ return raw.resolve()
|
|
|
+
|
|
|
+ cwd_candidate = (Path.cwd() / raw).resolve()
|
|
|
+ if cwd_candidate.exists():
|
|
|
+ return cwd_candidate
|
|
|
+
|
|
|
+ script_dir = Path(__file__).resolve().parent
|
|
|
+ script_candidate = (script_dir / raw).resolve()
|
|
|
+ if script_candidate.exists():
|
|
|
+ return script_candidate
|
|
|
+
|
|
|
+ project_root = script_dir.parent.parent
|
|
|
+ project_candidate = (project_root / raw).resolve()
|
|
|
+ if project_candidate.exists():
|
|
|
+ return project_candidate
|
|
|
+
|
|
|
+ # 如果都不存在,返回项目根拼接结果,便于报错信息更稳定
|
|
|
+ return project_candidate
|
|
|
+
|
|
|
+
|
|
|
+def should_collapse(
|
|
|
+ title: str,
|
|
|
+ collapse_prefixes: list[str],
|
|
|
+ collapse_keywords: list[str],
|
|
|
+ collapse_all: bool,
|
|
|
+) -> bool:
|
|
|
+ if collapse_all:
|
|
|
+ return True
|
|
|
+ if any(title.startswith(prefix) for prefix in collapse_prefixes):
|
|
|
+ return True
|
|
|
+ return any(keyword in title for keyword in collapse_keywords)
|
|
|
+
|
|
|
+
|
|
|
+def render_text_block(lines: list[str]) -> str:
|
|
|
+ if not lines:
|
|
|
+ return ""
|
|
|
+
|
|
|
+ normalized = lines[:]
|
|
|
+ while normalized and normalized[0].strip() == "":
|
|
|
+ normalized.pop(0)
|
|
|
+ while normalized and normalized[-1].strip() == "":
|
|
|
+ normalized.pop()
|
|
|
+ if not normalized:
|
|
|
+ return ""
|
|
|
+
|
|
|
+ compact: list[str] = []
|
|
|
+ empty_streak = 0
|
|
|
+ for line in normalized:
|
|
|
+ if line.strip() == "":
|
|
|
+ empty_streak += 1
|
|
|
+ if empty_streak <= 1:
|
|
|
+ compact.append("")
|
|
|
+ else:
|
|
|
+ empty_streak = 0
|
|
|
+ compact.append(line)
|
|
|
+
|
|
|
+ escaped = html.escape("\n".join(compact))
|
|
|
+ return f'<pre class="log-text">{escaped}</pre>'
|
|
|
+
|
|
|
+
|
|
|
+def enrich_fold_title(title: str) -> str:
|
|
|
+ """为工具调用标题附加工具功能描述。"""
|
|
|
+ tool_prefix = "🔧 "
|
|
|
+ if not title.startswith(tool_prefix):
|
|
|
+ return title
|
|
|
+
|
|
|
+ tool_name = title[len(tool_prefix):].strip()
|
|
|
+ description = TOOL_DESCRIPTION_MAP.get(tool_name)
|
|
|
+ if not description:
|
|
|
+ return title
|
|
|
+ return f"{tool_prefix}{tool_name}({description})"
|
|
|
+
|
|
|
+
|
|
|
+def render_node(
|
|
|
+ node: Node,
|
|
|
+ collapse_prefixes: list[str],
|
|
|
+ collapse_keywords: list[str],
|
|
|
+ collapse_all: bool,
|
|
|
+) -> str:
|
|
|
+ parts: list[str] = []
|
|
|
+ text_buffer: list[str] = []
|
|
|
+
|
|
|
+ def flush_text_buffer() -> None:
|
|
|
+ if text_buffer:
|
|
|
+ parts.append(render_text_block(text_buffer))
|
|
|
+ text_buffer.clear()
|
|
|
+
|
|
|
+ for entry in node.entries:
|
|
|
+ if isinstance(entry, str):
|
|
|
+ text_buffer.append(entry)
|
|
|
+ continue
|
|
|
+
|
|
|
+ child = entry
|
|
|
+ if child.is_fold:
|
|
|
+ flush_text_buffer()
|
|
|
+ title = child.title or ""
|
|
|
+ is_collapsed = should_collapse(
|
|
|
+ title=title,
|
|
|
+ collapse_prefixes=collapse_prefixes,
|
|
|
+ collapse_keywords=collapse_keywords,
|
|
|
+ collapse_all=collapse_all,
|
|
|
+ )
|
|
|
+ folded_class = "fold tool-fold" if is_collapsed else "fold normal-fold"
|
|
|
+ open_attr = "" if is_collapsed else " open"
|
|
|
+ display_title = enrich_fold_title(title)
|
|
|
+ inner = render_node(
|
|
|
+ child,
|
|
|
+ collapse_prefixes=collapse_prefixes,
|
|
|
+ collapse_keywords=collapse_keywords,
|
|
|
+ collapse_all=collapse_all,
|
|
|
+ )
|
|
|
+ parts.append(
|
|
|
+ f'<details class="{folded_class}"{open_attr}>'
|
|
|
+ f'<summary>{html.escape(display_title)}</summary>'
|
|
|
+ f"{inner}"
|
|
|
+ "</details>"
|
|
|
+ )
|
|
|
+
|
|
|
+ flush_text_buffer()
|
|
|
+
|
|
|
+ return "".join(parts)
|
|
|
+
|
|
|
+
|
|
|
+def build_html(body: str, source_name: str) -> str:
|
|
|
+ return f"""<!doctype html>
|
|
|
+<html lang="zh-CN">
|
|
|
+<head>
|
|
|
+ <meta charset="UTF-8" />
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
|
+ <title>Run Log 可视化 - {html.escape(source_name)}</title>
|
|
|
+ <style>
|
|
|
+ :root {{
|
|
|
+ --bg: #0b1020;
|
|
|
+ --panel: #131a2a;
|
|
|
+ --text: #e8edf7;
|
|
|
+ --muted: #98a2b3;
|
|
|
+ --accent: #6ea8fe;
|
|
|
+ --border: #263146;
|
|
|
+ }}
|
|
|
+ * {{
|
|
|
+ box-sizing: border-box;
|
|
|
+ }}
|
|
|
+ body {{
|
|
|
+ margin: 0;
|
|
|
+ background: var(--bg);
|
|
|
+ color: var(--text);
|
|
|
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
|
|
+ }}
|
|
|
+ .wrap {{
|
|
|
+ max-width: 1200px;
|
|
|
+ margin: 0 auto;
|
|
|
+ padding: 20px;
|
|
|
+ }}
|
|
|
+ .header {{
|
|
|
+ margin-bottom: 14px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ }}
|
|
|
+ .title {{
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 700;
|
|
|
+ }}
|
|
|
+ .source {{
|
|
|
+ color: var(--muted);
|
|
|
+ font-size: 13px;
|
|
|
+ }}
|
|
|
+ button {{
|
|
|
+ border: 1px solid var(--border);
|
|
|
+ background: var(--panel);
|
|
|
+ color: var(--text);
|
|
|
+ padding: 6px 10px;
|
|
|
+ border-radius: 8px;
|
|
|
+ cursor: pointer;
|
|
|
+ }}
|
|
|
+ button:hover {{
|
|
|
+ border-color: var(--accent);
|
|
|
+ color: var(--accent);
|
|
|
+ }}
|
|
|
+ .content {{
|
|
|
+ background: var(--panel);
|
|
|
+ border: 1px solid var(--border);
|
|
|
+ border-radius: 10px;
|
|
|
+ padding: 10px;
|
|
|
+ }}
|
|
|
+ details {{
|
|
|
+ margin: 6px 0;
|
|
|
+ border: 1px solid var(--border);
|
|
|
+ border-radius: 8px;
|
|
|
+ background: rgba(255, 255, 255, 0.01);
|
|
|
+ }}
|
|
|
+ details > summary {{
|
|
|
+ cursor: pointer;
|
|
|
+ padding: 8px 10px;
|
|
|
+ font-size: 13px;
|
|
|
+ list-style: none;
|
|
|
+ user-select: none;
|
|
|
+ color: #cdd6e5;
|
|
|
+ }}
|
|
|
+ details > summary::-webkit-details-marker {{
|
|
|
+ display: none;
|
|
|
+ }}
|
|
|
+ details > summary::before {{
|
|
|
+ content: "▶";
|
|
|
+ display: inline-block;
|
|
|
+ margin-right: 6px;
|
|
|
+ transform: rotate(0deg);
|
|
|
+ transition: transform 120ms ease;
|
|
|
+ color: var(--muted);
|
|
|
+ }}
|
|
|
+ details[open] > summary::before {{
|
|
|
+ transform: rotate(90deg);
|
|
|
+ }}
|
|
|
+ .tool-fold > summary {{
|
|
|
+ color: #f6cf76;
|
|
|
+ }}
|
|
|
+ .log-text {{
|
|
|
+ margin: 0;
|
|
|
+ padding: 10px;
|
|
|
+ border-top: 1px dashed var(--border);
|
|
|
+ color: var(--text);
|
|
|
+ white-space: pre-wrap;
|
|
|
+ word-break: break-word;
|
|
|
+ line-height: 1.4;
|
|
|
+ font-size: 13px;
|
|
|
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
|
+ }}
|
|
|
+ </style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+ <div class="wrap">
|
|
|
+ <div class="header">
|
|
|
+ <div class="title">Run Log 可视化</div>
|
|
|
+ <div class="source">{html.escape(source_name)}</div>
|
|
|
+ <button id="expand-tools">展开全部工具调用</button>
|
|
|
+ <button id="collapse-tools">折叠全部工具调用</button>
|
|
|
+ </div>
|
|
|
+ <div class="content">{body}</div>
|
|
|
+ </div>
|
|
|
+ <script>
|
|
|
+ const toolFolds = Array.from(document.querySelectorAll("details.tool-fold"));
|
|
|
+ document.getElementById("expand-tools").addEventListener("click", () => {{
|
|
|
+ toolFolds.forEach((el) => (el.open = true));
|
|
|
+ }});
|
|
|
+ document.getElementById("collapse-tools").addEventListener("click", () => {{
|
|
|
+ toolFolds.forEach((el) => (el.open = false));
|
|
|
+ }});
|
|
|
+ </script>
|
|
|
+</body>
|
|
|
+</html>
|
|
|
+"""
|
|
|
+
|
|
|
+
|
|
|
+def generate_html(
|
|
|
+ input_path: Path,
|
|
|
+ output_path: Path,
|
|
|
+ collapse_prefixes: list[str],
|
|
|
+ collapse_keywords: list[str],
|
|
|
+ collapse_all: bool = False,
|
|
|
+) -> None:
|
|
|
+ content = input_path.read_text(encoding="utf-8")
|
|
|
+ tree = parse_log(content)
|
|
|
+ body = render_node(
|
|
|
+ tree,
|
|
|
+ collapse_prefixes=collapse_prefixes,
|
|
|
+ collapse_keywords=collapse_keywords,
|
|
|
+ collapse_all=collapse_all,
|
|
|
+ )
|
|
|
+ html_content = build_html(body=body, source_name=input_path.name)
|
|
|
+ output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
+ output_path.write_text(html_content, encoding="utf-8")
|
|
|
+
|
|
|
+
|
|
|
+def main() -> None:
|
|
|
+ input_path = resolve_config_path(INPUT_LOG_PATH)
|
|
|
+ if not input_path.exists():
|
|
|
+ raise FileNotFoundError(f"输入文件不存在: {input_path}")
|
|
|
+
|
|
|
+ if OUTPUT_HTML_PATH:
|
|
|
+ output_path = resolve_config_path(OUTPUT_HTML_PATH)
|
|
|
+ else:
|
|
|
+ output_path = input_path.with_suffix(".html")
|
|
|
+
|
|
|
+ generate_html(
|
|
|
+ input_path=input_path,
|
|
|
+ output_path=output_path,
|
|
|
+ collapse_prefixes=COLLAPSE_PREFIXES,
|
|
|
+ collapse_keywords=COLLAPSE_KEYWORDS,
|
|
|
+ collapse_all=COLLAPSE_ALL_FOLDS,
|
|
|
+ )
|
|
|
+ print(f"HTML 已生成: {output_path}")
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ main()
|