#!/usr/bin/env python3 """将 run_log 文本渲染为可折叠 HTML 页面。 直接在脚本内修改 INPUT_LOG_PATH / OUTPUT_HTML_PATH 后运行: python examples/piaoquan_demand/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_demand/output/27/run_log_27_20260326_172851.txt" # 设为 None 则默认生成到输入文件同名 .html OUTPUT_HTML_PATH: str | None = None # 是否默认折叠所有 [FOLD] 块 COLLAPSE_ALL_FOLDS = False # 命中这些前缀/关键词的折叠块默认收起 COLLAPSE_PREFIXES = DEFAULT_COLLAPSE_PREFIXES COLLAPSE_KEYWORDS = DEFAULT_COLLAPSE_KEYWORDS # 仅针对特定结构做“折叠内容复制” # 规则(HTML 渲染层面): # - 只对 `FOLD:🔧 think_and_plan` 进行处理 # - 只复制其内部 `FOLD:📤 返回内容` 包裹的“内容文本” # - 将复制内容直接插入到 `think_and_plan` 对应的 `
` 之外, # 从而无需点开工具调用就能在最外层看到 THINK_AND_PLAN_TOOL_TITLE = "🔧 think_and_plan" RETURN_CONTENT_FOLD_TITLE = "📤 返回内容" 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'
{escaped}
' def flatten_entries_to_lines(entries: list[str | Node]) -> list[str]: """把节点树(可能包含 fold)拍平成纯文本行,用于“无需点开折叠块”展示。""" out: list[str] = [] for entry in entries: if isinstance(entry, str): out.append(entry) else: # 只取其内部文本,忽略 fold 标题本身 out.extend(flatten_entries_to_lines(entry.entries)) return out 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, # 预留参数:历史上用于判断折叠上下文;当前逻辑已不需要该状态。 in_think_and_plan: bool = False, # 是否在渲染 `🔧 think_and_plan` 时,把其 `📤 返回内容` 复制为“details 外的纯文本”。 # root 的“工具调用收集块”会负责把复制文本放到块外,因此在该场景下需要关闭。 emit_think_plan_return_copy: bool = True, ) -> str: # root(最外层)只做一件事: # 把“连续出现的工具调用”打包到一个大折叠块里(文本之间的工具调用收集在一起)。 if node.title is None: parts: list[str] = [] text_buffer: list[str] = [] tool_group_parts: list[str] = [] def flush_text_buffer() -> None: if text_buffer: parts.append(render_text_block(text_buffer)) text_buffer.clear() def flush_tool_group() -> None: if tool_group_parts: parts.append( '
' '工具调用' + "".join(tool_group_parts) + "
" ) tool_group_parts.clear() def render_fold_details(fold_node: Node) -> str: """把一个 fold 节点整体包装为
(不仅渲染它的子内容)。""" title = fold_node.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( fold_node, collapse_prefixes=collapse_prefixes, collapse_keywords=collapse_keywords, collapse_all=collapse_all, emit_think_plan_return_copy=False, ) rendered = ( f'
' f"{html.escape(display_title)}" f"{inner}" "
" ) return rendered for entry in node.entries: if isinstance(entry, str): # 空白行不算“文本”,不打断连续工具调用收集。 if entry.strip() == "": if tool_group_parts: # 工具块之间的空白忽略 continue text_buffer.append(entry) continue # 真正的文本出现:结束当前工具调用收集,并输出文本 flush_tool_group() text_buffer.append(entry) continue child = entry child_title = child.title or "" is_tool_call = child.is_fold and child_title.startswith("🔧 ") if is_tool_call: # 工具调用开始:先把前面的文本输出,再收集到工具组里 flush_text_buffer() tool_group_parts.append(render_fold_details(child)) # 关键:`🔧 think_and_plan` 复制出来的 `📤 返回内容` 也算文本, # 需要放在工具调用收集块之外,从而打断相邻工具调用的收集分组。 if child_title == THINK_AND_PLAN_TOOL_TITLE: return_nodes = [ e for e in child.entries if isinstance(e, Node) and e.is_fold and (e.title or "") == RETURN_CONTENT_FOLD_TITLE ] if return_nodes: flattened_lines = flatten_entries_to_lines(return_nodes[0].entries) flush_tool_group() parts.append(render_text_block(flattened_lines)) else: # 遇到非工具调用(或非工具 fold):结束当前工具组,再按原样输出 flush_tool_group() flush_text_buffer() parts.append( render_node( child, collapse_prefixes=collapse_prefixes, collapse_keywords=collapse_keywords, collapse_all=collapse_all, ) ) flush_tool_group() flush_text_buffer() return "".join(parts) 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'
' f'{html.escape(display_title)}' f"{inner}" "
" ) # 关键点:只把 `📤 返回内容` 复制到 `think_and_plan` 的
之外 # 但当 root 工具调用收集块要负责“details 外展示”时,需要关闭此复制。 if emit_think_plan_return_copy and title == THINK_AND_PLAN_TOOL_TITLE: return_nodes = [ e for e in child.entries if isinstance(e, Node) and e.is_fold and (e.title or "") == RETURN_CONTENT_FOLD_TITLE ] if return_nodes: # 一般只会有一个 `📤 返回内容`,取第一个 return_node = return_nodes[0] flattened_lines = flatten_entries_to_lines(return_node.entries) parts.append(render_text_block(flattened_lines)) flush_text_buffer() return "".join(parts) def build_html(body: str, source_name: str) -> str: return f""" Run Log 可视化 - {html.escape(source_name)}
Run Log 可视化
{html.escape(source_name)}
{body}
""" 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()