#!/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)}
"""
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()