|
@@ -0,0 +1,1861 @@
|
|
|
|
|
+"""
|
|
|
|
|
+Pipeline 执行追踪可视化工具。
|
|
|
|
|
+
|
|
|
|
|
+读取 tests/traces/{trace_id}/pipeline.jsonl,生成 HTML 可视化页面。
|
|
|
|
|
+HTML 风格与 tests/.cache/visualize_log.py 保持一致。
|
|
|
|
|
+
|
|
|
|
|
+用法:
|
|
|
|
|
+ python pipeline_visualize.py # 读取最新 trace
|
|
|
|
|
+ python pipeline_visualize.py <trace_id> # 指定 trace_id
|
|
|
|
|
+ python pipeline_visualize.py --list # 列出所有可用 trace
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import html as html_mod
|
|
|
|
|
+import json
|
|
|
|
|
+import re
|
|
|
|
|
+import sys
|
|
|
|
|
+from datetime import datetime
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+
|
|
|
|
|
+TRACES_DIR = Path(__file__).parent / "tests" / "traces"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────
|
|
|
|
|
+# 工具函数
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def _esc(s: str) -> str:
|
|
|
|
|
+ return html_mod.escape(str(s))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _ts(s: str) -> str:
|
|
|
|
|
+ return s[:19] if s else ""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _duration_label(ms: int | None) -> str:
|
|
|
|
|
+ if ms is None:
|
|
|
|
|
+ return ""
|
|
|
|
|
+ if ms < 1000:
|
|
|
|
|
+ return f"{ms}ms"
|
|
|
|
|
+ return f"{ms / 1000:.1f}s"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _calc_duration(start_ts: str, end_ts: str) -> str:
|
|
|
|
|
+ try:
|
|
|
|
|
+ t0 = datetime.strptime(start_ts[:19], "%Y-%m-%d %H:%M:%S")
|
|
|
|
|
+ t1 = datetime.strptime(end_ts[:19], "%Y-%m-%d %H:%M:%S")
|
|
|
|
|
+ delta = int((t1 - t0).total_seconds())
|
|
|
|
|
+ mins, secs = divmod(delta, 60)
|
|
|
|
|
+ return f"{mins}分{secs}秒" if mins else f"{secs}秒"
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ return "N/A"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _ts_readable(ts: int) -> str:
|
|
|
|
|
+ """将时间戳转为可读格式"""
|
|
|
|
|
+ try:
|
|
|
|
|
+ dt = datetime.fromtimestamp(ts)
|
|
|
|
|
+ return dt.strftime("%Y-%m-%d %H:%M")
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ return str(ts)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────
|
|
|
|
|
+# JSONL 读取
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def read_jsonl(path: Path) -> list[dict]:
|
|
|
|
|
+ events = []
|
|
|
|
|
+ for line in path.read_text(encoding="utf-8").splitlines():
|
|
|
|
|
+ line = line.strip()
|
|
|
|
|
+ if not line:
|
|
|
|
|
+ continue
|
|
|
|
|
+ try:
|
|
|
|
|
+ events.append(json.loads(line))
|
|
|
|
|
+ except json.JSONDecodeError:
|
|
|
|
|
+ pass
|
|
|
|
|
+ return events
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────
|
|
|
|
|
+# HTML 渲染
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+_STAGE_LABELS = {
|
|
|
|
|
+ "demand_analysis": "需求理解",
|
|
|
|
|
+ "content_search": "内容召回",
|
|
|
|
|
+ "hard_filter": "硬规则过滤",
|
|
|
|
|
+ "coarse_filter": "标题粗筛",
|
|
|
|
|
+ "quality_filter": "质量精排",
|
|
|
|
|
+ "account_precipitate": "账号沉淀",
|
|
|
|
|
+ "output_persist": "结果输出",
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+_GATE_LABELS = {
|
|
|
|
|
+ "content_search": "SearchCompletenessGate",
|
|
|
|
|
+ "quality_filter": "FilterSufficiencyGate",
|
|
|
|
|
+ "output_persist": "OutputSchemaGate",
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+_ACTION_COLORS = {
|
|
|
|
|
+ "proceed": "var(--green)",
|
|
|
|
|
+ "retry_stage": "var(--yellow)",
|
|
|
|
|
+ "fallback": "var(--orange)",
|
|
|
|
|
+ "abort": "var(--red)",
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────
|
|
|
|
|
+# 决策数据渲染
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def _render_decisions(stage: str, decisions: dict) -> str:
|
|
|
|
|
+ """根据阶段类型,将 decisions dict 渲染为 HTML(可折叠 <details> 卡片)。"""
|
|
|
|
|
+ if not decisions:
|
|
|
|
|
+ return ""
|
|
|
|
|
+ renderer = _DECISION_RENDERERS.get(stage)
|
|
|
|
|
+ if not renderer:
|
|
|
|
|
+ return ""
|
|
|
|
|
+ try:
|
|
|
|
|
+ inner = renderer(decisions)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ return ""
|
|
|
|
|
+ if not inner:
|
|
|
|
|
+ return ""
|
|
|
|
|
+ label = _STAGE_LABELS.get(stage, stage)
|
|
|
|
|
+ # 重要阶段默认展开
|
|
|
|
|
+ open_attr = " open" if stage in ("quality_filter", "demand_analysis", "content_search", "coarse_filter") else ""
|
|
|
|
|
+ return (
|
|
|
|
|
+ f'<details class="decision-card"{open_attr}>'
|
|
|
|
|
+ f'<summary>📋 {_esc(label)} 决策详情</summary>'
|
|
|
|
|
+ f'<div class="decision-body">{inner}</div>'
|
|
|
|
|
+ f'</details>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _render_demand_analysis(d: dict) -> str:
|
|
|
|
|
+ parts: list[str] = []
|
|
|
|
|
+
|
|
|
|
|
+ # 特征分层
|
|
|
|
|
+ feature_rows: list[str] = []
|
|
|
|
|
+ for key, label in [
|
|
|
|
|
+ ("substantive_features", "实质特征"),
|
|
|
|
|
+ ("formal_features", "形式特征"),
|
|
|
|
|
+ ("upper_features", "上层特征"),
|
|
|
|
|
+ ("lower_features", "下层特征"),
|
|
|
|
|
+ ]:
|
|
|
|
|
+ items = d.get(key, [])
|
|
|
|
|
+ if items:
|
|
|
|
|
+ tags = " ".join(f'<span class="tag">{_esc(t)}</span>' for t in items)
|
|
|
|
|
+ feature_rows.append(
|
|
|
|
|
+ f'<tr><td class="feature-label">{label}</td><td>{tags}</td></tr>'
|
|
|
|
|
+ )
|
|
|
|
|
+ if feature_rows:
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ '<div class="decision-section">'
|
|
|
|
|
+ '<div class="section-title">🧠 特征分层</div>'
|
|
|
|
|
+ '<table class="decision-table">'
|
|
|
|
|
+ + "\n".join(feature_rows)
|
|
|
|
|
+ + '</table></div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 搜索策略
|
|
|
|
|
+ ss = d.get("search_strategy", {})
|
|
|
|
|
+ precise = ss.get("precise_keywords", [])
|
|
|
|
|
+ topic = ss.get("topic_keywords", [])
|
|
|
|
|
+ if precise or topic:
|
|
|
|
|
+ rows: list[str] = []
|
|
|
|
|
+ if precise:
|
|
|
|
|
+ tags = " ".join(f'<span class="tag tag-blue">{_esc(k)}</span>' for k in precise)
|
|
|
|
|
+ rows.append(f'<tr><td class="feature-label">精准词</td><td>{tags}</td></tr>')
|
|
|
|
|
+ if topic:
|
|
|
|
|
+ tags = " ".join(f'<span class="tag tag-purple">{_esc(k)}</span>' for k in topic)
|
|
|
|
|
+ rows.append(f'<tr><td class="feature-label">主题词</td><td>{tags}</td></tr>')
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ '<div class="decision-section">'
|
|
|
|
|
+ '<div class="section-title">🔎 搜索策略</div>'
|
|
|
|
|
+ '<table class="decision-table">'
|
|
|
|
|
+ + "\n".join(rows)
|
|
|
|
|
+ + '</table></div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 筛选关注点
|
|
|
|
|
+ ff = d.get("filter_focus", {})
|
|
|
|
|
+ relevance = ff.get("relevance_focus", [])
|
|
|
|
|
+ risks = ff.get("elimination_risks", [])
|
|
|
|
|
+ if relevance or risks:
|
|
|
|
|
+ items_html = ""
|
|
|
|
|
+ if relevance:
|
|
|
|
|
+ items_html += '<div class="focus-group"><b>关注点</b><ul>'
|
|
|
|
|
+ items_html += "".join(f'<li>{_esc(r)}</li>' for r in relevance)
|
|
|
|
|
+ items_html += '</ul></div>'
|
|
|
|
|
+ if risks:
|
|
|
|
|
+ items_html += '<div class="focus-group"><b>淘汰风险</b><ul>'
|
|
|
|
|
+ items_html += "".join(f'<li>{_esc(r)}</li>' for r in risks)
|
|
|
|
|
+ items_html += '</ul></div>'
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ '<div class="decision-section">'
|
|
|
|
|
+ '<div class="section-title">🎯 筛选关注</div>'
|
|
|
|
|
+ + items_html
|
|
|
|
|
+ + '</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ return "\n".join(parts)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _render_content_search(d: dict) -> str:
|
|
|
|
|
+ parts: list[str] = []
|
|
|
|
|
+ stats = d.get("keyword_stats", [])
|
|
|
|
|
+ total = d.get("total_candidates", 0)
|
|
|
|
|
+ candidates = d.get("candidates", [])
|
|
|
|
|
+
|
|
|
|
|
+ # 搜索词命中统计
|
|
|
|
|
+ if stats:
|
|
|
|
|
+ rows = []
|
|
|
|
|
+ for s in stats:
|
|
|
|
|
+ kw = _esc(s.get("keyword", ""))
|
|
|
|
|
+ returned = s.get("returned", 0)
|
|
|
|
|
+ new = s.get("new", 0)
|
|
|
|
|
+ rows.append(
|
|
|
|
|
+ f'<tr><td><code>{kw}</code></td>'
|
|
|
|
|
+ f'<td class="num-cell">{returned}</td>'
|
|
|
|
|
+ f'<td class="num-cell">{new}</td></tr>'
|
|
|
|
|
+ )
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ '<div class="decision-section">'
|
|
|
|
|
+ '<div class="section-title">📊 搜索词命中</div>'
|
|
|
|
|
+ '<table class="decision-table kw-table">'
|
|
|
|
|
+ '<thead><tr><th>关键词</th><th>返回数</th><th>新增数</th></tr></thead>'
|
|
|
|
|
+ '<tbody>' + "\n".join(rows) + '</tbody>'
|
|
|
|
|
+ '</table></div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 全部召回文章列表
|
|
|
|
|
+ if candidates:
|
|
|
|
|
+ rows = []
|
|
|
|
|
+ for idx, c in enumerate(candidates, 1):
|
|
|
|
|
+ title = _esc(c.get("title", ""))
|
|
|
|
|
+ url = _esc(c.get("url", ""))
|
|
|
|
|
+ kw = _esc(c.get("source_keyword", ""))
|
|
|
|
|
+ pt = c.get("publish_time", 0)
|
|
|
|
|
+ pt_str = _ts_readable(pt) if pt else "未知"
|
|
|
|
|
+ view = c.get("view_count", 0)
|
|
|
|
|
+ rows.append(
|
|
|
|
|
+ f'<tr>'
|
|
|
|
|
+ f'<td class="num-cell">{idx}</td>'
|
|
|
|
|
+ f'<td class="article-title-cell"><a href="{url}" target="_blank">{title}</a></td>'
|
|
|
|
|
+ f'<td><code>{kw}</code></td>'
|
|
|
|
|
+ f'<td>{_esc(pt_str)}</td>'
|
|
|
|
|
+ f'<td class="num-cell">{view}</td>'
|
|
|
|
|
+ f'</tr>'
|
|
|
|
|
+ )
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ '<div class="decision-section">'
|
|
|
|
|
+ f'<div class="section-title">📋 全部召回文章({len(candidates)} 篇)</div>'
|
|
|
|
|
+ '<table class="decision-table recall-table">'
|
|
|
|
|
+ '<thead><tr><th>#</th><th>标题</th><th>来源关键词</th><th>发布时间</th><th>阅读量</th></tr></thead>'
|
|
|
|
|
+ '<tbody>' + "\n".join(rows) + '</tbody>'
|
|
|
|
|
+ '</table></div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ f'<div class="total-line">累计候选: <b>{total}</b> 篇</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+ return "\n".join(parts)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _render_hard_filter(d: dict) -> str:
|
|
|
|
|
+ count = d.get("after_filter_count", 0)
|
|
|
|
|
+ return (
|
|
|
|
|
+ '<div class="decision-section">'
|
|
|
|
|
+ f'<div class="section-title">📊 过滤结果</div>'
|
|
|
|
|
+ f'<div class="total-line">过滤后剩余: <b>{count}</b> 篇</div>'
|
|
|
|
|
+ '</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _render_coarse_filter(d: dict) -> str:
|
|
|
|
|
+ log = d.get("coarse_log", [])
|
|
|
|
|
+ total = d.get("total_count", len(log))
|
|
|
|
|
+ passed_cnt = d.get("passed_count", 0)
|
|
|
|
|
+ rejected_cnt = d.get("rejected_count", 0)
|
|
|
|
|
+ after_cnt = d.get("after_filter_count", 0)
|
|
|
|
|
+
|
|
|
|
|
+ if not log:
|
|
|
|
|
+ return f'<div class="decision-section">粗筛后剩余: {after_cnt} 篇</div>'
|
|
|
|
|
+
|
|
|
|
|
+ parts: list[str] = []
|
|
|
|
|
+
|
|
|
|
|
+ # 统计概览
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ '<div class="decision-section">'
|
|
|
|
|
+ f'<div class="section-title">📊 粗筛统计</div>'
|
|
|
|
|
+ f'<span class="stat-pill stat-accept">通过 {passed_cnt}</span>'
|
|
|
|
|
+ f'<span class="stat-pill stat-reject">淘汰 {rejected_cnt}</span>'
|
|
|
|
|
+ '</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 通过的文章
|
|
|
|
|
+ passed = [r for r in log if r.get("status") == "pass"]
|
|
|
|
|
+ if passed:
|
|
|
|
|
+ rows = []
|
|
|
|
|
+ for idx, r in enumerate(passed, 1):
|
|
|
|
|
+ title = _esc(r.get("title", ""))
|
|
|
|
|
+ url = _esc(r.get("url", ""))
|
|
|
|
|
+ reason = _esc(r.get("reason", ""))
|
|
|
|
|
+ src_kw = _esc(r.get("source_keyword", ""))
|
|
|
|
|
+ rows.append(
|
|
|
|
|
+ f'<tr class="row-accept">'
|
|
|
|
|
+ f'<td class="num-cell">{idx}</td>'
|
|
|
|
|
+ f'<td class="article-title-cell"><a href="{url}" target="_blank">{title}</a></td>'
|
|
|
|
|
+ f'<td><code>{src_kw}</code></td>'
|
|
|
|
|
+ f'<td class="reason-full-cell">{reason}</td>'
|
|
|
|
|
+ f'</tr>'
|
|
|
|
|
+ )
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ '<div class="decision-section">'
|
|
|
|
|
+ f'<div class="section-title">✅ 通过文章({len(passed)} 篇)</div>'
|
|
|
|
|
+ '<table class="decision-table review-table">'
|
|
|
|
|
+ '<thead><tr><th>#</th><th>标题</th><th>来源词</th><th>理由</th></tr></thead>'
|
|
|
|
|
+ '<tbody>' + "\n".join(rows) + '</tbody>'
|
|
|
|
|
+ '</table></div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 淘汰的文章
|
|
|
|
|
+ rejected = [r for r in log if r.get("status") == "reject"]
|
|
|
|
|
+ if rejected:
|
|
|
|
|
+ rows = []
|
|
|
|
|
+ for idx, r in enumerate(rejected, 1):
|
|
|
|
|
+ title = _esc(r.get("title", ""))
|
|
|
|
|
+ url = _esc(r.get("url", ""))
|
|
|
|
|
+ reason = _esc(r.get("reason", ""))
|
|
|
|
|
+ src_kw = _esc(r.get("source_keyword", ""))
|
|
|
|
|
+ rows.append(
|
|
|
|
|
+ f'<tr class="row-reject">'
|
|
|
|
|
+ f'<td class="num-cell">{idx}</td>'
|
|
|
|
|
+ f'<td class="article-title-cell"><a href="{url}" target="_blank">{title}</a></td>'
|
|
|
|
|
+ f'<td><code>{src_kw}</code></td>'
|
|
|
|
|
+ f'<td class="reason-full-cell">{reason}</td>'
|
|
|
|
|
+ f'</tr>'
|
|
|
|
|
+ )
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ '<div class="decision-section">'
|
|
|
|
|
+ f'<div class="section-title">❌ 淘汰文章({len(rejected)} 篇)</div>'
|
|
|
|
|
+ '<table class="decision-table review-table">'
|
|
|
|
|
+ '<thead><tr><th>#</th><th>标题</th><th>来源词</th><th>理由</th></tr></thead>'
|
|
|
|
|
+ '<tbody>' + "\n".join(rows) + '</tbody>'
|
|
|
|
|
+ '</table></div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ f'<div class="total-line">粗筛后剩余: <b>{after_cnt}</b> 篇</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+ return "\n".join(parts)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _render_quality_filter(d: dict) -> str:
|
|
|
|
|
+ reviews = d.get("review_log", [])
|
|
|
|
|
+ accepted_cnt = d.get("accepted_count", 0)
|
|
|
|
|
+ rejected_cnt = d.get("rejected_count", 0)
|
|
|
|
|
+ skipped_cnt = d.get("skipped_count", 0)
|
|
|
|
|
+ final_cnt = d.get("final_filtered_count", 0)
|
|
|
|
|
+ score_config = d.get("score_config", {})
|
|
|
|
|
+ match_terms = d.get("match_terms", [])
|
|
|
|
|
+
|
|
|
|
|
+ if not reviews:
|
|
|
|
|
+ return f'<div class="decision-section">最终入选: {final_cnt} 篇</div>'
|
|
|
|
|
+
|
|
|
|
|
+ parts: list[str] = []
|
|
|
|
|
+
|
|
|
|
|
+ # 评分配置
|
|
|
|
|
+ if score_config:
|
|
|
|
|
+ cfg_rows: list[str] = []
|
|
|
|
|
+ for key, label in [
|
|
|
|
|
+ ("min_body_length", "最小正文长度"),
|
|
|
|
|
+ ("high_relevance_ratio", "高相关性阈值"),
|
|
|
|
|
+ ("view_count_threshold", "阅读量阈值"),
|
|
|
|
|
+ ("engage_rate_threshold", "互动率阈值"),
|
|
|
|
|
+ ("spam_keywords_count", "标题党关键词数"),
|
|
|
|
|
+ ]:
|
|
|
|
|
+ val = score_config.get(key, "")
|
|
|
|
|
+ if val != "":
|
|
|
|
|
+ cfg_rows.append(
|
|
|
|
|
+ f'<tr><td class="feature-label">{label}</td>'
|
|
|
|
|
+ f'<td><code>{_esc(str(val))}</code></td></tr>'
|
|
|
|
|
+ )
|
|
|
|
|
+ if cfg_rows:
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ '<div class="decision-section">'
|
|
|
|
|
+ '<div class="section-title">⚙️ 评分配置</div>'
|
|
|
|
|
+ '<table class="decision-table">'
|
|
|
|
|
+ + "\n".join(cfg_rows)
|
|
|
|
|
+ + '</table></div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 匹配词表
|
|
|
|
|
+ if match_terms:
|
|
|
|
|
+ tags = " ".join(f'<span class="tag tag-blue">{_esc(t)}</span>' for t in match_terms)
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ '<div class="decision-section">'
|
|
|
|
|
+ f'<div class="section-title">🔑 匹配词表({len(match_terms)} 个)</div>'
|
|
|
|
|
+ f'{tags}'
|
|
|
|
|
+ '</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 统计概览
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ '<div class="decision-section">'
|
|
|
|
|
+ f'<div class="section-title">📊 审核统计</div>'
|
|
|
|
|
+ f'<span class="stat-pill stat-accept">入选 {accepted_cnt}</span>'
|
|
|
|
|
+ f'<span class="stat-pill stat-reject">淘汰 {rejected_cnt}</span>'
|
|
|
|
|
+ + (f'<span class="stat-pill stat-skip">跳过 {skipped_cnt}</span>' if skipped_cnt else '')
|
|
|
|
|
+ + '</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ review_table_head = (
|
|
|
|
|
+ '<thead><tr>'
|
|
|
|
|
+ '<th>#</th><th>标题</th><th>来源词</th><th>相关性</th><th>兴趣</th><th>阶段</th>'
|
|
|
|
|
+ '<th>发布日期</th><th>正文长度</th><th>阅读</th><th>点赞</th><th>分享</th><th>在看</th>'
|
|
|
|
|
+ '<th>原因</th>'
|
|
|
|
|
+ '</tr></thead>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 入选文章
|
|
|
|
|
+ accepted = [r for r in reviews if r.get("status") == "accept"]
|
|
|
|
|
+ accepted.sort(key=lambda r: int(r.get("view_count", 0) or 0), reverse=True)
|
|
|
|
|
+ if accepted:
|
|
|
|
|
+ rows = []
|
|
|
|
|
+ for idx, r in enumerate(accepted, 1):
|
|
|
|
|
+ title = _esc(r.get("title", ""))
|
|
|
|
|
+ url = _esc(r.get("url", ""))
|
|
|
|
|
+ relevance = _esc(r.get("relevance", ""))
|
|
|
|
|
+ interest = _esc(r.get("interest", ""))
|
|
|
|
|
+ reason = _esc(r.get("reason", ""))
|
|
|
|
|
+ phase = "LLM" if r.get("phase") == "llm" else "启发式"
|
|
|
|
|
+ pt = r.get("publish_time", 0)
|
|
|
|
|
+ pt_str = _ts_readable(pt) if pt else "未知"
|
|
|
|
|
+ view = r.get("view_count", 0)
|
|
|
|
|
+ like = r.get("like_count", 0)
|
|
|
|
|
+ share = r.get("share_count", 0)
|
|
|
|
|
+ looking = r.get("looking_count", 0)
|
|
|
|
|
+ body_len = r.get("body_length", "-")
|
|
|
|
|
+ src_kw = _esc(r.get("source_keyword", ""))
|
|
|
|
|
+ rows.append(
|
|
|
|
|
+ f'<tr class="row-accept">'
|
|
|
|
|
+ f'<td class="num-cell">{idx}</td>'
|
|
|
|
|
+ f'<td class="article-title-cell"><a href="{url}" target="_blank">{title}</a></td>'
|
|
|
|
|
+ f'<td><code>{src_kw}</code></td>'
|
|
|
|
|
+ f'<td class="score-cell">{relevance}</td>'
|
|
|
|
|
+ f'<td class="score-cell">{interest}</td>'
|
|
|
|
|
+ f'<td><span class="phase-badge">{phase}</span></td>'
|
|
|
|
|
+ f'<td class="date-cell">{_esc(pt_str)}</td>'
|
|
|
|
|
+ f'<td class="num-cell">{body_len}</td>'
|
|
|
|
|
+ f'<td class="num-cell">{view}</td>'
|
|
|
|
|
+ f'<td class="num-cell">{like}</td>'
|
|
|
|
|
+ f'<td class="num-cell">{share}</td>'
|
|
|
|
|
+ f'<td class="num-cell">{looking}</td>'
|
|
|
|
|
+ f'<td class="reason-full-cell">{reason}</td>'
|
|
|
|
|
+ f'</tr>'
|
|
|
|
|
+ )
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ '<div class="decision-section">'
|
|
|
|
|
+ f'<div class="section-title">✅ 入选文章({len(accepted)} 篇)</div>'
|
|
|
|
|
+ '<table class="decision-table review-table">'
|
|
|
|
|
+ + review_table_head +
|
|
|
|
|
+ '<tbody>' + "\n".join(rows) + '</tbody>'
|
|
|
|
|
+ '</table></div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 淘汰文章
|
|
|
|
|
+ rejected = [r for r in reviews if r.get("status") == "reject"]
|
|
|
|
|
+ if rejected:
|
|
|
|
|
+ rows = []
|
|
|
|
|
+ for idx, r in enumerate(rejected, 1):
|
|
|
|
|
+ title = _esc(r.get("title", ""))
|
|
|
|
|
+ url = _esc(r.get("url", ""))
|
|
|
|
|
+ relevance = _esc(r.get("relevance", ""))
|
|
|
|
|
+ interest = _esc(r.get("interest", ""))
|
|
|
|
|
+ reason = _esc(r.get("reason", ""))
|
|
|
|
|
+ phase = "LLM" if r.get("phase") == "llm" else "启发式"
|
|
|
|
|
+ pt = r.get("publish_time", 0)
|
|
|
|
|
+ pt_str = _ts_readable(pt) if pt else "未知"
|
|
|
|
|
+ view = r.get("view_count", 0)
|
|
|
|
|
+ like = r.get("like_count", 0)
|
|
|
|
|
+ share = r.get("share_count", 0)
|
|
|
|
|
+ looking = r.get("looking_count", 0)
|
|
|
|
|
+ body_len = r.get("body_length", "-")
|
|
|
|
|
+ src_kw = _esc(r.get("source_keyword", ""))
|
|
|
|
|
+ rows.append(
|
|
|
|
|
+ f'<tr class="row-reject">'
|
|
|
|
|
+ f'<td class="num-cell">{idx}</td>'
|
|
|
|
|
+ f'<td class="article-title-cell"><a href="{url}" target="_blank">{title}</a></td>'
|
|
|
|
|
+ f'<td><code>{src_kw}</code></td>'
|
|
|
|
|
+ f'<td class="score-cell">{relevance}</td>'
|
|
|
|
|
+ f'<td class="score-cell">{interest}</td>'
|
|
|
|
|
+ f'<td><span class="phase-badge">{phase}</span></td>'
|
|
|
|
|
+ f'<td class="date-cell">{_esc(pt_str)}</td>'
|
|
|
|
|
+ f'<td class="num-cell">{body_len}</td>'
|
|
|
|
|
+ f'<td class="num-cell">{view}</td>'
|
|
|
|
|
+ f'<td class="num-cell">{like}</td>'
|
|
|
|
|
+ f'<td class="num-cell">{share}</td>'
|
|
|
|
|
+ f'<td class="num-cell">{looking}</td>'
|
|
|
|
|
+ f'<td class="reason-full-cell">{reason}</td>'
|
|
|
|
|
+ f'</tr>'
|
|
|
|
|
+ )
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ '<div class="decision-section">'
|
|
|
|
|
+ f'<div class="section-title">❌ 淘汰文章({len(rejected)} 篇)</div>'
|
|
|
|
|
+ '<table class="decision-table review-table">'
|
|
|
|
|
+ + review_table_head +
|
|
|
|
|
+ '<tbody>' + "\n".join(rows) + '</tbody>'
|
|
|
|
|
+ '</table></div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 跳过文章
|
|
|
|
|
+ skipped = [r for r in reviews if r.get("status") == "skip"]
|
|
|
|
|
+ if skipped:
|
|
|
|
|
+ rows = []
|
|
|
|
|
+ for idx, r in enumerate(skipped, 1):
|
|
|
|
|
+ title = _esc(r.get("title", ""))
|
|
|
|
|
+ url = _esc(r.get("url", ""))
|
|
|
|
|
+ reason = _esc(r.get("reason", ""))
|
|
|
|
|
+ phase = "LLM" if r.get("phase") == "llm" else "启发式"
|
|
|
|
|
+ pt = r.get("publish_time", 0)
|
|
|
|
|
+ pt_str = _ts_readable(pt) if pt else "未知"
|
|
|
|
|
+ view = r.get("view_count", 0)
|
|
|
|
|
+ src_kw = _esc(r.get("source_keyword", ""))
|
|
|
|
|
+ rows.append(
|
|
|
|
|
+ f'<tr class="row-skip">'
|
|
|
|
|
+ f'<td class="num-cell">{idx}</td>'
|
|
|
|
|
+ f'<td class="article-title-cell"><a href="{url}" target="_blank">{title}</a></td>'
|
|
|
|
|
+ f'<td><code>{src_kw}</code></td>'
|
|
|
|
|
+ f'<td class="score-cell">-</td>'
|
|
|
|
|
+ f'<td class="score-cell">-</td>'
|
|
|
|
|
+ f'<td><span class="phase-badge">{phase}</span></td>'
|
|
|
|
|
+ f'<td class="date-cell">{_esc(pt_str)}</td>'
|
|
|
|
|
+ f'<td class="num-cell">-</td>'
|
|
|
|
|
+ f'<td class="num-cell">{view}</td>'
|
|
|
|
|
+ f'<td class="num-cell">-</td>'
|
|
|
|
|
+ f'<td class="num-cell">-</td>'
|
|
|
|
|
+ f'<td class="num-cell">-</td>'
|
|
|
|
|
+ f'<td class="reason-full-cell">{reason}</td>'
|
|
|
|
|
+ f'</tr>'
|
|
|
|
|
+ )
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ '<div class="decision-section">'
|
|
|
|
|
+ f'<div class="section-title">⏭️ 跳过文章({len(skipped)} 篇)</div>'
|
|
|
|
|
+ '<table class="decision-table review-table">'
|
|
|
|
|
+ + review_table_head +
|
|
|
|
|
+ '<tbody>' + "\n".join(rows) + '</tbody>'
|
|
|
|
|
+ '</table></div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ f'<div class="total-line">最终入选: <b>{final_cnt}</b> 篇</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+ return "\n".join(parts)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _render_account_precipitate(d: dict) -> str:
|
|
|
|
|
+ accounts = d.get("accounts", [])
|
|
|
|
|
+ if not accounts:
|
|
|
|
|
+ return '<div class="decision-section">聚合账号: 0 个</div>'
|
|
|
|
|
+
|
|
|
|
|
+ rows = []
|
|
|
|
|
+ for acc in accounts:
|
|
|
|
|
+ name = _esc(acc.get("account_name", ""))
|
|
|
|
|
+ count = acc.get("article_count", 0)
|
|
|
|
|
+ samples = acc.get("sample_articles", [])
|
|
|
|
|
+ sample_html = ""
|
|
|
|
|
+ if samples:
|
|
|
|
|
+ sample_html = '<div class="sample-titles">' + ", ".join(
|
|
|
|
|
+ _esc(s) for s in samples[:3]
|
|
|
|
|
+ ) + '</div>'
|
|
|
|
|
+ rows.append(
|
|
|
|
|
+ f'<tr><td><b>{name}</b></td>'
|
|
|
|
|
+ f'<td class="num-cell">{count} 篇</td>'
|
|
|
|
|
+ f'<td>{sample_html}</td></tr>'
|
|
|
|
|
+ )
|
|
|
|
|
+ return (
|
|
|
|
|
+ '<div class="decision-section">'
|
|
|
|
|
+ '<div class="section-title">👤 聚合账号</div>'
|
|
|
|
|
+ '<table class="decision-table acct-table">'
|
|
|
|
|
+ '<thead><tr><th>账号名</th><th>文章数</th><th>示例标题</th></tr></thead>'
|
|
|
|
|
+ '<tbody>' + "\n".join(rows) + '</tbody>'
|
|
|
|
|
+ '</table></div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _render_output_persist(d: dict) -> str:
|
|
|
|
|
+ path = d.get("output_file", "")
|
|
|
|
|
+ if not path:
|
|
|
|
|
+ return ""
|
|
|
|
|
+ return (
|
|
|
|
|
+ '<div class="decision-section">'
|
|
|
|
|
+ f'<div class="section-title">📄 输出文件</div>'
|
|
|
|
|
+ f'<code class="file-path">{_esc(path)}</code>'
|
|
|
|
|
+ '</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+_DECISION_RENDERERS = {
|
|
|
|
|
+ "demand_analysis": _render_demand_analysis,
|
|
|
|
|
+ "content_search": _render_content_search,
|
|
|
|
|
+ "hard_filter": _render_hard_filter,
|
|
|
|
|
+ "coarse_filter": _render_coarse_filter,
|
|
|
|
|
+ "quality_filter": _render_quality_filter,
|
|
|
|
|
+ "account_precipitate": _render_account_precipitate,
|
|
|
|
|
+ "output_persist": _render_output_persist,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────
|
|
|
|
|
+# LLM 交互追踪渲染
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def _render_llm_interactions(interactions: list[dict]) -> str:
|
|
|
|
|
+ """渲染阶段内所有 LLM 交互记录(折叠卡片,展示思考过程)。"""
|
|
|
|
|
+ if not interactions:
|
|
|
|
|
+ return ""
|
|
|
|
|
+
|
|
|
|
|
+ sections: list[str] = []
|
|
|
|
|
+ for idx, ix in enumerate(interactions, 1):
|
|
|
|
|
+ name = _esc(ix.get("name", "LLM 调用"))
|
|
|
|
|
+ model = _esc(ix.get("model", ""))
|
|
|
|
|
+ duration_ms = ix.get("duration_ms", 0)
|
|
|
|
|
+ tokens = ix.get("tokens", 0)
|
|
|
|
|
+ dur_label = _duration_label(duration_ms)
|
|
|
|
|
+
|
|
|
|
|
+ parts: list[str] = []
|
|
|
|
|
+
|
|
|
|
|
+ # 输入 Prompt
|
|
|
|
|
+ messages = ix.get("messages", [])
|
|
|
|
|
+ for msg in messages:
|
|
|
|
|
+ role = msg.get("role", "")
|
|
|
|
|
+ content = msg.get("content", "")
|
|
|
|
|
+ if not content:
|
|
|
|
|
+ continue
|
|
|
|
|
+ content_str = str(content) if not isinstance(content, str) else content
|
|
|
|
|
+ role_icon = {"system": "📜", "user": "👤", "assistant": "🤖"}.get(role, "💬")
|
|
|
|
|
+ role_label = {"system": "System Prompt", "user": "User Prompt", "assistant": "Assistant"}.get(role, role)
|
|
|
|
|
+ if len(content_str) > 500:
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ f'<details class="llm-msg llm-msg-{_esc(role)}">'
|
|
|
|
|
+ f'<summary>{role_icon} {role_label} ({len(content_str)} 字)</summary>'
|
|
|
|
|
+ f'<pre class="llm-msg-pre">{_esc(content_str)}</pre>'
|
|
|
|
|
+ f'</details>'
|
|
|
|
|
+ )
|
|
|
|
|
+ else:
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ f'<div class="llm-msg llm-msg-{_esc(role)}">'
|
|
|
|
|
+ f'<div class="llm-msg-header">{role_icon} {role_label}</div>'
|
|
|
|
|
+ f'<pre class="llm-msg-pre">{_esc(content_str)}</pre>'
|
|
|
|
|
+ f'</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 推理过程(reasoning)
|
|
|
|
|
+ reasoning = ix.get("reasoning", "")
|
|
|
|
|
+ if reasoning:
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ f'<details class="llm-reasoning" open>'
|
|
|
|
|
+ f'<summary>🧠 LLM 推理过程 ({len(reasoning)} 字)</summary>'
|
|
|
|
|
+ f'<pre class="llm-msg-pre llm-reasoning-text">{_esc(reasoning)}</pre>'
|
|
|
|
|
+ f'</details>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 工具调用
|
|
|
|
|
+ tool_calls = ix.get("tool_calls") or []
|
|
|
|
|
+ for tc in tool_calls:
|
|
|
|
|
+ tool_name = _esc(tc.get("tool_name", ""))
|
|
|
|
|
+ args = tc.get("arguments", "")
|
|
|
|
|
+ try:
|
|
|
|
|
+ args_obj = json.loads(args) if isinstance(args, str) else args
|
|
|
|
|
+ args_formatted = json.dumps(args_obj, ensure_ascii=False, indent=2)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ args_formatted = str(args)
|
|
|
|
|
+ result_preview = tc.get("result_preview", "")
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ f'<div class="llm-tool-call">'
|
|
|
|
|
+ f'<div class="llm-tool-name">🔧 <code>{tool_name}</code></div>'
|
|
|
|
|
+ f'<pre class="llm-tool-args">{_esc(args_formatted)}</pre>'
|
|
|
|
|
+ )
|
|
|
|
|
+ if result_preview:
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ f'<details class="llm-tool-result">'
|
|
|
|
|
+ f'<summary>📤 返回结果 ({len(result_preview)} 字)</summary>'
|
|
|
|
|
+ f'<pre class="llm-msg-pre">{_esc(result_preview)}</pre>'
|
|
|
|
|
+ f'</details>'
|
|
|
|
|
+ )
|
|
|
|
|
+ parts.append('</div>')
|
|
|
|
|
+
|
|
|
|
|
+ # LLM 回复
|
|
|
|
|
+ response_text = ix.get("response_text", "")
|
|
|
|
|
+ if response_text:
|
|
|
|
|
+ if len(response_text) > 2000:
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ f'<details class="llm-response">'
|
|
|
|
|
+ f'<summary>💬 LLM 回复 ({len(response_text)} 字)</summary>'
|
|
|
|
|
+ f'<pre class="llm-msg-pre">{_esc(response_text)}</pre>'
|
|
|
|
|
+ f'</details>'
|
|
|
|
|
+ )
|
|
|
|
|
+ else:
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ f'<div class="llm-response">'
|
|
|
|
|
+ f'<div class="llm-msg-header">💬 LLM 回复</div>'
|
|
|
|
|
+ f'<pre class="llm-msg-pre">{_esc(response_text)}</pre>'
|
|
|
|
|
+ f'</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ inner = "\n".join(parts)
|
|
|
|
|
+ meta_parts = []
|
|
|
|
|
+ if model:
|
|
|
|
|
+ meta_parts.append(f'<code>{model}</code>')
|
|
|
|
|
+ if dur_label:
|
|
|
|
|
+ meta_parts.append(dur_label)
|
|
|
|
|
+ if tokens:
|
|
|
|
|
+ meta_parts.append(f'{tokens} tokens')
|
|
|
|
|
+ meta_html = " · ".join(meta_parts)
|
|
|
|
|
+
|
|
|
|
|
+ sections.append(
|
|
|
|
|
+ f'<details class="llm-interaction-card" open>'
|
|
|
|
|
+ f'<summary>🧪 LLM 交互 #{idx}: {name}'
|
|
|
|
|
+ f'<span class="llm-interaction-meta"> ({meta_html})</span>'
|
|
|
|
|
+ f'</summary>'
|
|
|
|
|
+ f'<div class="llm-interaction-body">{inner}</div>'
|
|
|
|
|
+ f'</details>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ return "\n".join(sections)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────
|
|
|
|
|
+# Agent Trace 渲染
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def _load_agent_trace(trace_id: str) -> tuple[list[dict], dict]:
|
|
|
|
|
+ """读取 agent 子任务的 events.jsonl 和 meta.json。"""
|
|
|
|
|
+ agent_dir = TRACES_DIR / trace_id
|
|
|
|
|
+ events: list[dict] = []
|
|
|
|
|
+ meta: dict = {}
|
|
|
|
|
+ events_path = agent_dir / "events.jsonl"
|
|
|
|
|
+ meta_path = agent_dir / "meta.json"
|
|
|
|
|
+ if events_path.exists():
|
|
|
|
|
+ events = read_jsonl(events_path)
|
|
|
|
|
+ if meta_path.exists():
|
|
|
|
|
+ try:
|
|
|
|
|
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ pass
|
|
|
|
|
+ return events, meta
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _render_agent_meta(meta: dict) -> str:
|
|
|
|
|
+ """渲染 agent 元信息摘要。"""
|
|
|
|
|
+ task = meta.get("task", "")
|
|
|
|
|
+ model = meta.get("model", "")
|
|
|
|
|
+ status = meta.get("status", "")
|
|
|
|
|
+ total_tokens = meta.get("total_tokens", 0)
|
|
|
|
|
+ total_cost = meta.get("total_cost", 0)
|
|
|
|
|
+ created = meta.get("created_at", "")[:19]
|
|
|
|
|
+ completed = meta.get("completed_at", "")[:19]
|
|
|
|
|
+ duration = _calc_duration(created, completed) if created and completed else "N/A"
|
|
|
|
|
+
|
|
|
|
|
+ status_color = "var(--green)" if status == "completed" else "var(--red)"
|
|
|
|
|
+ return (
|
|
|
|
|
+ f'<div class="agent-meta">'
|
|
|
|
|
+ f'<span class="agent-meta-item">任务: <b>{_esc(task)}</b></span>'
|
|
|
|
|
+ f'<span class="agent-meta-item">模型: <code>{_esc(model)}</code></span>'
|
|
|
|
|
+ f'<span class="agent-meta-item">状态: <span style="color:{status_color}">{_esc(status)}</span></span>'
|
|
|
|
|
+ f'<span class="agent-meta-item">耗时: {_esc(duration)}</span>'
|
|
|
|
|
+ f'<span class="agent-meta-item">Tokens: {total_tokens}</span>'
|
|
|
|
|
+ f'<span class="agent-meta-item">Cost: ${total_cost:.4f}</span>'
|
|
|
|
|
+ f'</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _render_agent_message(msg: dict) -> str:
|
|
|
|
|
+ """渲染单条 agent 消息。"""
|
|
|
|
|
+ role = msg.get("role", "")
|
|
|
|
|
+ content = msg.get("content", "")
|
|
|
|
|
+ tool_call_id = msg.get("tool_call_id", "")
|
|
|
|
|
+ ts = msg.get("created_at", "")[:19]
|
|
|
|
|
+ tokens = msg.get("tokens", 0)
|
|
|
|
|
+
|
|
|
|
|
+ if role == "system":
|
|
|
|
|
+ # System prompt 折叠显示,只展示前 200 字
|
|
|
|
|
+ text = content if isinstance(content, str) else str(content)
|
|
|
|
|
+ preview = text[:200] + "..." if len(text) > 200 else text
|
|
|
|
|
+ return (
|
|
|
|
|
+ f'<details class="agent-msg agent-msg-system">'
|
|
|
|
|
+ f'<summary>📜 System Prompt ({len(text)} 字)</summary>'
|
|
|
|
|
+ f'<pre class="agent-msg-pre">{_esc(text)}</pre>'
|
|
|
|
|
+ f'</details>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ elif role == "user":
|
|
|
|
|
+ text = content if isinstance(content, str) else str(content)
|
|
|
|
|
+ # User prompt 折叠,如果太长
|
|
|
|
|
+ if len(text) > 500:
|
|
|
|
|
+ return (
|
|
|
|
|
+ f'<details class="agent-msg agent-msg-user">'
|
|
|
|
|
+ f'<summary>👤 User Prompt ({len(text)} 字)</summary>'
|
|
|
|
|
+ f'<pre class="agent-msg-pre">{_esc(text)}</pre>'
|
|
|
|
|
+ f'</details>'
|
|
|
|
|
+ )
|
|
|
|
|
+ return (
|
|
|
|
|
+ f'<div class="agent-msg agent-msg-user">'
|
|
|
|
|
+ f'<div class="agent-msg-header">👤 User</div>'
|
|
|
|
|
+ f'<pre class="agent-msg-pre">{_esc(text)}</pre>'
|
|
|
|
|
+ f'</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ elif role == "assistant":
|
|
|
|
|
+ parts: list[str] = []
|
|
|
|
|
+
|
|
|
|
|
+ # 提取文本和工具调用
|
|
|
|
|
+ if isinstance(content, dict):
|
|
|
|
|
+ text = content.get("text", "") or ""
|
|
|
|
|
+ tool_calls = content.get("tool_calls", []) or []
|
|
|
|
|
+ reasoning = content.get("reasoning_content", "") or ""
|
|
|
|
|
+ else:
|
|
|
|
|
+ text = str(content or "")
|
|
|
|
|
+ tool_calls = []
|
|
|
|
|
+ reasoning = ""
|
|
|
|
|
+
|
|
|
|
|
+ token_badge = f'<span class="agent-token-badge">{tokens} tokens</span>' if tokens else ""
|
|
|
|
|
+
|
|
|
|
|
+ # 推理内容
|
|
|
|
|
+ if reasoning:
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ f'<details class="agent-reasoning">'
|
|
|
|
|
+ f'<summary>🧠 推理过程</summary>'
|
|
|
|
|
+ f'<pre class="agent-msg-pre">{_esc(reasoning)}</pre>'
|
|
|
|
|
+ f'</details>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 文本回复
|
|
|
|
|
+ if text:
|
|
|
|
|
+ # 截断过长文本,折叠显示
|
|
|
|
|
+ if len(text) > 2000:
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ f'<details class="agent-thinking">'
|
|
|
|
|
+ f'<summary>💭 回复 ({len(text)} 字)</summary>'
|
|
|
|
|
+ f'<pre class="agent-msg-pre">{_esc(text)}</pre>'
|
|
|
|
|
+ f'</details>'
|
|
|
|
|
+ )
|
|
|
|
|
+ else:
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ f'<div class="agent-thinking">'
|
|
|
|
|
+ f'<pre class="agent-msg-pre">{_esc(text)}</pre>'
|
|
|
|
|
+ f'</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 工具调用
|
|
|
|
|
+ for tc in tool_calls:
|
|
|
|
|
+ fn = tc.get("function", {})
|
|
|
|
|
+ fn_name = fn.get("name", "?")
|
|
|
|
|
+ fn_args = fn.get("arguments", "")
|
|
|
|
|
+ # 格式化 JSON 参数
|
|
|
|
|
+ try:
|
|
|
|
|
+ args_obj = json.loads(fn_args) if isinstance(fn_args, str) else fn_args
|
|
|
|
|
+ fn_args_formatted = json.dumps(args_obj, ensure_ascii=False, indent=2)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ fn_args_formatted = str(fn_args)
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ f'<div class="agent-tool-call">'
|
|
|
|
|
+ f'<div class="agent-tool-name">🔧 <code>{_esc(fn_name)}</code></div>'
|
|
|
|
|
+ f'<pre class="agent-tool-args">{_esc(fn_args_formatted)}</pre>'
|
|
|
|
|
+ f'</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ inner = "\n".join(parts)
|
|
|
|
|
+ return (
|
|
|
|
|
+ f'<div class="agent-msg agent-msg-assistant">'
|
|
|
|
|
+ f'<div class="agent-msg-header">🤖 Assistant {token_badge}</div>'
|
|
|
|
|
+ f'{inner}'
|
|
|
|
|
+ f'</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ elif role == "tool":
|
|
|
|
|
+ # 工具返回
|
|
|
|
|
+ if isinstance(content, dict):
|
|
|
|
|
+ tool_name = content.get("tool_name", "")
|
|
|
|
|
+ result = content.get("result", "")
|
|
|
|
|
+ else:
|
|
|
|
|
+ tool_name = ""
|
|
|
|
|
+ result = str(content or "")
|
|
|
|
|
+
|
|
|
|
|
+ result_str = str(result)
|
|
|
|
|
+ if len(result_str) > 1500:
|
|
|
|
|
+ return (
|
|
|
|
|
+ f'<details class="agent-msg agent-msg-tool">'
|
|
|
|
|
+ f'<summary>📤 {_esc(tool_name)} 返回 ({len(result_str)} 字)</summary>'
|
|
|
|
|
+ f'<pre class="agent-msg-pre">{_esc(result_str)}</pre>'
|
|
|
|
|
+ f'</details>'
|
|
|
|
|
+ )
|
|
|
|
|
+ return (
|
|
|
|
|
+ f'<div class="agent-msg agent-msg-tool">'
|
|
|
|
|
+ f'<div class="agent-msg-header">📤 {_esc(tool_name)}</div>'
|
|
|
|
|
+ f'<pre class="agent-msg-pre">{_esc(result_str)}</pre>'
|
|
|
|
|
+ f'</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ return ""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _render_agent_trace_section(agent_trace_ids: list[str]) -> str:
|
|
|
|
|
+ """渲染阶段内所有 agent 子任务的执行详情(折叠卡片)。"""
|
|
|
|
|
+ if not agent_trace_ids:
|
|
|
|
|
+ return ""
|
|
|
|
|
+
|
|
|
|
|
+ sections: list[str] = []
|
|
|
|
|
+ for tid in agent_trace_ids:
|
|
|
|
|
+ agent_events, meta = _load_agent_trace(tid)
|
|
|
|
|
+ if not agent_events and not meta:
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ task_name = meta.get("task", tid[:8])
|
|
|
|
|
+ total_tokens = meta.get("total_tokens", 0)
|
|
|
|
|
+ total_cost = meta.get("total_cost", 0)
|
|
|
|
|
+
|
|
|
|
|
+ # 渲染元信息
|
|
|
|
|
+ meta_html = _render_agent_meta(meta) if meta else ""
|
|
|
|
|
+
|
|
|
|
|
+ # 渲染消息序列
|
|
|
|
|
+ msg_blocks: list[str] = []
|
|
|
|
|
+ for ev in agent_events:
|
|
|
|
|
+ if ev.get("event") == "message_added":
|
|
|
|
|
+ msg = ev.get("message", {})
|
|
|
|
|
+ rendered = _render_agent_message(msg)
|
|
|
|
|
+ if rendered:
|
|
|
|
|
+ msg_blocks.append(rendered)
|
|
|
|
|
+ elif ev.get("event") == "goal_added":
|
|
|
|
|
+ goal = ev.get("goal", {})
|
|
|
|
|
+ goal_desc = goal.get("description", "")
|
|
|
|
|
+ msg_blocks.append(
|
|
|
|
|
+ f'<div class="agent-msg agent-msg-goal">'
|
|
|
|
|
+ f'🎯 目标创建: <b>{_esc(goal_desc)}</b>'
|
|
|
|
|
+ f'</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ messages_html = "\n".join(msg_blocks)
|
|
|
|
|
+ sections.append(
|
|
|
|
|
+ f'<details class="agent-trace-card">'
|
|
|
|
|
+ f'<summary>🤖 Agent 执行详情: {_esc(task_name)}'
|
|
|
|
|
+ f'<span class="agent-trace-summary"> ({total_tokens} tokens · ${total_cost:.4f})</span>'
|
|
|
|
|
+ f'</summary>'
|
|
|
|
|
+ f'<div class="agent-trace-body">'
|
|
|
|
|
+ f'{meta_html}'
|
|
|
|
|
+ f'<div class="agent-messages">{messages_html}</div>'
|
|
|
|
|
+ f'</div>'
|
|
|
|
|
+ f'</details>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ return "\n".join(sections)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _parse_log_lines(log_text: str) -> list[dict]:
|
|
|
|
|
+ """将 full_log.log 文本解析为结构化行列表,供 HTML 渲染。"""
|
|
|
|
|
+ lines: list[dict] = []
|
|
|
|
|
+ for raw in log_text.splitlines():
|
|
|
|
|
+ level = "STDOUT"
|
|
|
|
|
+ if "| DEBUG |" in raw:
|
|
|
|
|
+ level = "DEBUG"
|
|
|
|
|
+ elif "| INFO |" in raw:
|
|
|
|
|
+ level = "INFO"
|
|
|
|
|
+ elif "| WARNING |" in raw:
|
|
|
|
|
+ level = "WARNING"
|
|
|
|
|
+ elif "| ERROR |" in raw:
|
|
|
|
|
+ level = "ERROR"
|
|
|
|
|
+ elif "| CRITICAL|" in raw:
|
|
|
|
|
+ level = "CRITICAL"
|
|
|
|
|
+ lines.append({"level": level, "text": raw})
|
|
|
|
|
+ return lines
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _render_full_log_section(log_lines: list[dict]) -> str:
|
|
|
|
|
+ """生成完整日志面板的 HTML(带过滤、搜索、行号、颜色)。"""
|
|
|
|
|
+ if not log_lines:
|
|
|
|
|
+ return ""
|
|
|
|
|
+
|
|
|
|
|
+ level_counts = {}
|
|
|
|
|
+ for ln in log_lines:
|
|
|
|
|
+ level_counts[ln["level"]] = level_counts.get(ln["level"], 0) + 1
|
|
|
|
|
+
|
|
|
|
|
+ rows: list[str] = []
|
|
|
|
|
+ for idx, ln in enumerate(log_lines, 1):
|
|
|
|
|
+ lvl = ln["level"].lower()
|
|
|
|
|
+ text = _esc(ln["text"])
|
|
|
|
|
+ rows.append(
|
|
|
|
|
+ f'<tr class="log-row log-{lvl}" data-level="{ln["level"]}">'
|
|
|
|
|
+ f'<td class="log-ln">{idx}</td>'
|
|
|
|
|
+ f'<td class="log-txt">{text}</td>'
|
|
|
|
|
+ f'</tr>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ filter_buttons: list[str] = []
|
|
|
|
|
+ filter_buttons.append(
|
|
|
|
|
+ f'<button class="log-btn log-btn-active" data-filter="ALL">ALL ({len(log_lines)})</button>'
|
|
|
|
|
+ )
|
|
|
|
|
+ for lvl in ("DEBUG", "INFO", "WARNING", "ERROR", "STDOUT"):
|
|
|
|
|
+ cnt = level_counts.get(lvl, 0)
|
|
|
|
|
+ if cnt:
|
|
|
|
|
+ filter_buttons.append(
|
|
|
|
|
+ f'<button class="log-btn" data-filter="{lvl}">{lvl} ({cnt})</button>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ '<div class="log-panel">'
|
|
|
|
|
+ '<h2 class="log-title">📋 完整执行日志</h2>'
|
|
|
|
|
+ '<div class="log-toolbar">'
|
|
|
|
|
+ '<div class="log-filters">' + "".join(filter_buttons) + '</div>'
|
|
|
|
|
+ '<input type="text" class="log-search" placeholder="🔍 搜索日志..." id="logSearch" />'
|
|
|
|
|
+ '</div>'
|
|
|
|
|
+ '<div class="log-container">'
|
|
|
|
|
+ '<table class="log-table"><tbody id="logBody">'
|
|
|
|
|
+ + "\n".join(rows) +
|
|
|
|
|
+ '</tbody></table>'
|
|
|
|
|
+ '</div>'
|
|
|
|
|
+ '</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+_LOG_VIEWER_CSS = """
|
|
|
|
|
+/* ── 日志面板 ── */
|
|
|
|
|
+.log-panel { margin-top:32px; border:1px solid var(--border); border-radius:10px; background:var(--bg2); overflow:hidden; }
|
|
|
|
|
+.log-title { font-size:18px; color:var(--blue); padding:16px 20px 0; margin:0; letter-spacing:-0.3px; }
|
|
|
|
|
+.log-toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; padding:12px 20px; border-bottom:1px solid var(--border); }
|
|
|
|
|
+.log-filters { display:flex; gap:4px; flex-wrap:wrap; }
|
|
|
|
|
+.log-btn {
|
|
|
|
|
+ background:rgba(139,148,158,.08); border:1px solid var(--border); border-radius:6px;
|
|
|
|
|
+ padding:3px 10px; font-size:11px; color:var(--dim); cursor:pointer; transition:all .15s;
|
|
|
|
|
+}
|
|
|
|
|
+.log-btn:hover { background:rgba(88,166,255,.1); color:var(--blue); border-color:rgba(88,166,255,.3); }
|
|
|
|
|
+.log-btn-active { background:rgba(88,166,255,.15); color:var(--blue); border-color:rgba(88,166,255,.4); }
|
|
|
|
|
+.log-search {
|
|
|
|
|
+ flex:1; min-width:180px; background:var(--bg); border:1px solid var(--border); border-radius:6px;
|
|
|
|
|
+ padding:4px 10px; font-size:12px; color:var(--text); outline:none;
|
|
|
|
|
+}
|
|
|
|
|
+.log-search:focus { border-color:var(--blue); }
|
|
|
|
|
+.log-container { max-height:calc(100vh - 200px); overflow-y:auto; }
|
|
|
|
|
+.log-table { width:100%; border-collapse:collapse; font-family:"SF Mono",Monaco,Menlo,monospace; font-size:11px; }
|
|
|
|
|
+.log-row { border-bottom:1px solid rgba(33,38,45,.3); }
|
|
|
|
|
+.log-row.log-hidden { display:none; }
|
|
|
|
|
+.log-ln { width:50px; text-align:right; padding:2px 8px 2px 4px; color:rgba(139,148,158,.4); user-select:none; vertical-align:top; }
|
|
|
|
|
+.log-txt { padding:2px 8px; white-space:pre-wrap; word-break:break-all; line-height:1.5; }
|
|
|
|
|
+.log-debug .log-txt { color:var(--dim); }
|
|
|
|
|
+.log-info .log-txt { color:var(--text); }
|
|
|
|
|
+.log-warning .log-txt { color:var(--yellow); }
|
|
|
|
|
+.log-error .log-txt, .log-critical .log-txt { color:var(--red); }
|
|
|
|
|
+.log-stdout .log-txt { color:var(--purple); }
|
|
|
|
|
+.log-highlight { background:rgba(227,179,65,.15); }
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+_LOG_VIEWER_JS = """
|
|
|
|
|
+<script>
|
|
|
|
|
+(function(){
|
|
|
|
|
+ var activeFilter = 'ALL';
|
|
|
|
|
+ var searchTerm = '';
|
|
|
|
|
+
|
|
|
|
|
+ function applyFilters(){
|
|
|
|
|
+ var rows = document.querySelectorAll('.log-row');
|
|
|
|
|
+ var term = searchTerm.toLowerCase();
|
|
|
|
|
+ rows.forEach(function(row){
|
|
|
|
|
+ var level = row.getAttribute('data-level');
|
|
|
|
|
+ var text = row.querySelector('.log-txt').textContent.toLowerCase();
|
|
|
|
|
+ var matchLevel = (activeFilter === 'ALL' || level === activeFilter);
|
|
|
|
|
+ var matchSearch = (!term || text.indexOf(term) !== -1);
|
|
|
|
|
+ if(matchLevel && matchSearch){
|
|
|
|
|
+ row.classList.remove('log-hidden');
|
|
|
|
|
+ if(term){
|
|
|
|
|
+ row.classList.add('log-highlight');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ row.classList.remove('log-highlight');
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ row.classList.add('log-hidden');
|
|
|
|
|
+ row.classList.remove('log-highlight');
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ document.querySelectorAll('.log-btn').forEach(function(btn){
|
|
|
|
|
+ btn.addEventListener('click', function(){
|
|
|
|
|
+ document.querySelectorAll('.log-btn').forEach(function(b){ b.classList.remove('log-btn-active'); });
|
|
|
|
|
+ btn.classList.add('log-btn-active');
|
|
|
|
|
+ activeFilter = btn.getAttribute('data-filter');
|
|
|
|
|
+ applyFilters();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ var searchInput = document.getElementById('logSearch');
|
|
|
|
|
+ if(searchInput){
|
|
|
|
|
+ var debounce;
|
|
|
|
|
+ searchInput.addEventListener('input', function(){
|
|
|
|
|
+ clearTimeout(debounce);
|
|
|
|
|
+ debounce = setTimeout(function(){
|
|
|
|
|
+ searchTerm = searchInput.value;
|
|
|
|
|
+ applyFilters();
|
|
|
|
|
+ }, 200);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+})();
|
|
|
|
|
+</script>
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def render_html(events: list[dict], full_log_lines: list[dict] | None = None) -> str:
|
|
|
|
|
+ init_ev = next((e for e in events if e["type"] == "init"), None)
|
|
|
|
|
+ complete_ev = next((e for e in events if e["type"] == "complete"), None)
|
|
|
|
|
+
|
|
|
|
|
+ query = init_ev.get("query", "") if init_ev else ""
|
|
|
|
|
+ model = init_ev.get("model", "") if init_ev else ""
|
|
|
|
|
+ demand_id = init_ev.get("demand_id", "") if init_ev else ""
|
|
|
|
|
+ trace_id = init_ev.get("trace_id", "") if init_ev else ""
|
|
|
|
|
+ target_count = init_ev.get("target_count", 0) if init_ev else 0
|
|
|
|
|
+
|
|
|
|
|
+ timestamps = [e.get("ts", "") for e in events if e.get("ts")]
|
|
|
|
|
+ start_ts = timestamps[0] if timestamps else ""
|
|
|
|
|
+ end_ts = timestamps[-1] if timestamps else ""
|
|
|
|
|
+ duration = _calc_duration(start_ts, end_ts) if start_ts and end_ts else "N/A"
|
|
|
|
|
+
|
|
|
|
|
+ stage_completes = [e for e in events if e["type"] == "stage_complete"]
|
|
|
|
|
+ gate_checks = [e for e in events if e["type"] == "gate_check"]
|
|
|
|
|
+ error_events = [e for e in events if e["type"] == "error"]
|
|
|
|
|
+
|
|
|
|
|
+ final_stats = complete_ev.get("stats", {}) if complete_ev else {}
|
|
|
|
|
+ candidate_count = final_stats.get("candidate_count", 0)
|
|
|
|
|
+ filtered_count = final_stats.get("filtered_count", 0)
|
|
|
|
|
+ account_count = final_stats.get("account_count", 0)
|
|
|
|
|
+ output_file = complete_ev.get("output_file", "") if complete_ev else ""
|
|
|
|
|
+ pipeline_status = complete_ev.get("status", "unknown") if complete_ev else "running"
|
|
|
|
|
+ ordered_stages = list(_STAGE_LABELS.keys())
|
|
|
|
|
+ stage_order_map = {name: idx + 1 for idx, name in enumerate(ordered_stages)}
|
|
|
|
|
+
|
|
|
|
|
+ # ── 构建事件块 ──
|
|
|
|
|
+ blocks: list[str] = []
|
|
|
|
|
+
|
|
|
|
|
+ for ev in events:
|
|
|
|
|
+ t = ev["type"]
|
|
|
|
|
+ ts = _ts(ev.get("ts", ""))
|
|
|
|
|
+
|
|
|
|
|
+ if t == "init":
|
|
|
|
|
+ # 基础信息
|
|
|
|
|
+ init_html_parts = [
|
|
|
|
|
+ f'<div class="ev-init">',
|
|
|
|
|
+ f'<div class="ev-label">🚀 Pipeline 启动</div>',
|
|
|
|
|
+ f'<div class="kv"><span class="k">查询词</span><span class="v">{_esc(query)}</span></div>',
|
|
|
|
|
+ f'<div class="kv"><span class="k">模型</span><span class="v">{_esc(model)}</span></div>',
|
|
|
|
|
+ f'<div class="kv"><span class="k">需求ID</span><span class="v">{_esc(str(demand_id))}</span></div>',
|
|
|
|
|
+ f'<div class="kv"><span class="k">目标条数</span><span class="v">{target_count}</span></div>',
|
|
|
|
|
+ f'<div class="kv"><span class="k">trace_id</span><span class="v mono">{_esc(trace_id)}</span></div>',
|
|
|
|
|
+ f'<div class="kv"><span class="k">时间</span><span class="v">{_esc(ts)}</span></div>',
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ # 执行计划(来自 Harness)
|
|
|
|
|
+ rp = ev.get("run_plan")
|
|
|
|
|
+ if rp:
|
|
|
|
|
+ init_html_parts.append('<hr style="border-color:#444; margin:12px 0;">')
|
|
|
|
|
+ init_html_parts.append('<div class="ev-label" style="margin-top:4px;">📋 执行计划</div>')
|
|
|
|
|
+ init_html_parts.append(
|
|
|
|
|
+ f'<div class="kv"><span class="k">超时上限</span>'
|
|
|
|
|
+ f'<span class="v">{rp.get("timeout_seconds", "N/A")} 秒</span></div>'
|
|
|
|
|
+ )
|
|
|
|
|
+ init_html_parts.append(
|
|
|
|
|
+ f'<div class="kv"><span class="k">目标文章上限</span>'
|
|
|
|
|
+ f'<span class="v">{rp.get("max_target_count", "N/A")} 篇</span></div>'
|
|
|
|
|
+ )
|
|
|
|
|
+ init_html_parts.append(
|
|
|
|
|
+ f'<div class="kv"><span class="k">最大补召回轮次</span>'
|
|
|
|
|
+ f'<span class="v">{rp.get("max_fallback_rounds", "N/A")} 轮</span></div>'
|
|
|
|
|
+ )
|
|
|
|
|
+ plan_stages = rp.get("stages", [])
|
|
|
|
|
+ if plan_stages:
|
|
|
|
|
+ init_html_parts.append(
|
|
|
|
|
+ '<div style="margin-top:8px; font-size:13px; color:#aaa;">阶段规划:</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+ for idx, ps in enumerate(plan_stages, 1):
|
|
|
|
|
+ sname = _esc(ps.get("name", ""))
|
|
|
|
|
+ slabel = _esc(ps.get("label", ""))
|
|
|
|
|
+ sicon = _STAGE_LABELS.get(ps.get("name", ""), sname)
|
|
|
|
|
+ gate = ps.get("gate", "")
|
|
|
|
|
+ gate_html = (
|
|
|
|
|
+ f' <span style="color:#e0a040; font-size:12px;">'
|
|
|
|
|
+ f'└─ Gate: {_esc(gate)}</span>'
|
|
|
|
|
+ ) if gate else ""
|
|
|
|
|
+ init_html_parts.append(
|
|
|
|
|
+ f'<div style="padding:2px 0 2px 16px; font-size:13px;">'
|
|
|
|
|
+ f'<span style="color:#6cf;">{idx}.</span> '
|
|
|
|
|
+ f'<code style="color:#8be9fd;">{sname}</code> '
|
|
|
|
|
+ f'<span style="color:#bbb;">← {slabel}</span>'
|
|
|
|
|
+ f'{gate_html}</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ init_html_parts.append('</div>')
|
|
|
|
|
+ blocks.append("\n".join(init_html_parts))
|
|
|
|
|
+
|
|
|
|
|
+ elif t == "stage_start":
|
|
|
|
|
+ stage = ev.get("stage", "")
|
|
|
|
|
+ icon = ev.get("icon", "▶")
|
|
|
|
|
+ label = _STAGE_LABELS.get(stage, stage)
|
|
|
|
|
+ stage_no = stage_order_map.get(stage, 0)
|
|
|
|
|
+ blocks.append(
|
|
|
|
|
+ f'<div class="ev-phase">'
|
|
|
|
|
+ f'<span class="ts">{_esc(ts)}</span> '
|
|
|
|
|
+ f'{icon} <h1 class="stage-h1">{stage_no}. {_esc(label)}</h1>'
|
|
|
|
|
+ f'<span class="stage-name"> ({_esc(stage)})</span>'
|
|
|
|
|
+ f'</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ elif t == "stage_complete":
|
|
|
|
|
+ stage = ev.get("stage", "")
|
|
|
|
|
+ icon = ev.get("icon", "▶")
|
|
|
|
|
+ label = _STAGE_LABELS.get(stage, stage)
|
|
|
|
|
+ attempt = ev.get("attempt", 1)
|
|
|
|
|
+ dur = _duration_label(ev.get("duration_ms"))
|
|
|
|
|
+ stats = ev.get("stats", {})
|
|
|
|
|
+ decisions = ev.get("decisions", {})
|
|
|
|
|
+ agent_trace_ids = ev.get("agent_trace_ids", [])
|
|
|
|
|
+ llm_interactions = ev.get("llm_interactions", [])
|
|
|
|
|
+ attempt_badge = f'<span class="badge-retry">重试#{attempt}</span>' if attempt > 1 else ""
|
|
|
|
|
+ dur_badge = f'<span class="badge-dur">{_esc(dur)}</span>' if dur else ""
|
|
|
|
|
+ stats_html = (
|
|
|
|
|
+ f'<span class="stat-pill">候选 {stats.get("candidate_count", 0)}</span>'
|
|
|
|
|
+ f'<span class="stat-pill">入选 {stats.get("filtered_count", 0)}</span>'
|
|
|
|
|
+ f'<span class="stat-pill">账号 {stats.get("account_count", 0)}</span>'
|
|
|
|
|
+ )
|
|
|
|
|
+ decisions_html = _render_decisions(stage, decisions)
|
|
|
|
|
+ agent_html = _render_agent_trace_section(agent_trace_ids)
|
|
|
|
|
+ llm_html = _render_llm_interactions(llm_interactions)
|
|
|
|
|
+ blocks.append(
|
|
|
|
|
+ f'<div class="ev-stage-ok">'
|
|
|
|
|
+ f'<span class="ts">{_esc(ts)}</span> '
|
|
|
|
|
+ f'✅ {icon} <b>{_esc(label)}</b> 完成 '
|
|
|
|
|
+ f'{attempt_badge}{dur_badge}'
|
|
|
|
|
+ f'<div class="stage-stats">{stats_html}</div>'
|
|
|
|
|
+ f'{llm_html}'
|
|
|
|
|
+ f'{decisions_html}'
|
|
|
|
|
+ f'{agent_html}'
|
|
|
|
|
+ f'</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ elif t == "gate_check":
|
|
|
|
|
+ gate = ev.get("gate", "")
|
|
|
|
|
+ gate_label = _GATE_LABELS.get(gate, gate)
|
|
|
|
|
+ passed = ev.get("passed", False)
|
|
|
|
|
+ action = ev.get("action", "proceed")
|
|
|
|
|
+ issues = ev.get("issues", [])
|
|
|
|
|
+ fallback = ev.get("fallback_stage", "")
|
|
|
|
|
+ icon = ev.get("icon", "🚦")
|
|
|
|
|
+ action_color = _ACTION_COLORS.get(action, "var(--dim)")
|
|
|
|
|
+ status_icon = "✅" if passed else "⚠️"
|
|
|
|
|
+ issues_html = ""
|
|
|
|
|
+ if issues:
|
|
|
|
|
+ issues_html = (
|
|
|
|
|
+ '<ul class="gate-issues">'
|
|
|
|
|
+ + "".join(f"<li>{_esc(i)}</li>" for i in issues)
|
|
|
|
|
+ + "</ul>"
|
|
|
|
|
+ )
|
|
|
|
|
+ fallback_html = (
|
|
|
|
|
+ f'<span class="gate-fallback">→ 回退到 <b>{_esc(fallback)}</b></span>'
|
|
|
|
|
+ if fallback else ""
|
|
|
|
|
+ )
|
|
|
|
|
+ cls = "ev-gate-ok" if passed else "ev-gate-warn"
|
|
|
|
|
+ blocks.append(
|
|
|
|
|
+ f'<div class="{cls}">'
|
|
|
|
|
+ f'<span class="ts">{_esc(ts)}</span> '
|
|
|
|
|
+ f'{status_icon} {icon} <b>{_esc(gate_label)}</b> '
|
|
|
|
|
+ f'<span class="gate-action" style="color:{action_color}">[{_esc(action)}]</span>'
|
|
|
|
|
+ f'{fallback_html}'
|
|
|
|
|
+ f'{issues_html}'
|
|
|
|
|
+ f'</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ elif t == "error":
|
|
|
|
|
+ stage = ev.get("stage", "")
|
|
|
|
|
+ msg = ev.get("msg", "")
|
|
|
|
|
+ blocks.append(
|
|
|
|
|
+ f'<details class="ev-error" open>'
|
|
|
|
|
+ f'<summary>❌ 错误 @ {_esc(stage)}: {_esc(msg[:200])}</summary>'
|
|
|
|
|
+ f'<pre>{_esc(msg)}</pre>'
|
|
|
|
|
+ f'</details>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ elif t == "complete":
|
|
|
|
|
+ status = ev.get("status", "unknown")
|
|
|
|
|
+ stats = ev.get("stats", {})
|
|
|
|
|
+ stage_count = ev.get("stage_count", 0)
|
|
|
|
|
+ err_count = ev.get("error_count", 0)
|
|
|
|
|
+ cls = "ev-complete-ok" if status == "completed" else "ev-complete-fail"
|
|
|
|
|
+ out_html = (
|
|
|
|
|
+ f'<div class="kv"><span class="k">输出文件</span>'
|
|
|
|
|
+ f'<span class="v mono">{_esc(output_file)}</span></div>'
|
|
|
|
|
+ if output_file else ""
|
|
|
|
|
+ )
|
|
|
|
|
+ blocks.append(
|
|
|
|
|
+ f'<div class="{cls}">'
|
|
|
|
|
+ f'<div class="ev-label">🏁 Pipeline 结束</div>'
|
|
|
|
|
+ f'<div class="kv"><span class="k">状态</span><span class="v">{_esc(status)}</span></div>'
|
|
|
|
|
+ f'<div class="kv"><span class="k">trace_id</span><span class="v mono">{_esc(trace_id)}</span></div>'
|
|
|
|
|
+ f'<div class="kv"><span class="k">阶段数</span><span class="v">{stage_count}</span></div>'
|
|
|
|
|
+ f'<div class="kv"><span class="k">错误数</span><span class="v">{err_count}</span></div>'
|
|
|
|
|
+ f'<div class="kv"><span class="k">候选文章</span><span class="v">{stats.get("candidate_count", 0)}</span></div>'
|
|
|
|
|
+ f'<div class="kv"><span class="k">入选文章</span><span class="v">{stats.get("filtered_count", 0)}</span></div>'
|
|
|
|
|
+ f'<div class="kv"><span class="k">账号数</span><span class="v">{stats.get("account_count", 0)}</span></div>'
|
|
|
|
|
+ f'{out_html}'
|
|
|
|
|
+ f'</div>'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ body = "\n".join(blocks)
|
|
|
|
|
+ status_color = "var(--green)" if pipeline_status == "completed" else "var(--red)"
|
|
|
|
|
+ completed_stage_names = {e.get("stage", "") for e in stage_completes}
|
|
|
|
|
+ errored_stage_names = {e.get("stage", "") for e in error_events}
|
|
|
|
|
+ ordered_stages = list(_STAGE_LABELS.keys())
|
|
|
|
|
+ stage_order_map = {name: idx + 1 for idx, name in enumerate(ordered_stages)}
|
|
|
|
|
+ flow_items: list[str] = []
|
|
|
|
|
+ for stage_key in ordered_stages:
|
|
|
|
|
+ label = _STAGE_LABELS.get(stage_key, stage_key)
|
|
|
|
|
+ if stage_key in errored_stage_names:
|
|
|
|
|
+ cls = "flow-step flow-error"
|
|
|
|
|
+ icon = "✖"
|
|
|
|
|
+ elif stage_key in completed_stage_names:
|
|
|
|
|
+ cls = "flow-step flow-done"
|
|
|
|
|
+ icon = "✔"
|
|
|
|
|
+ else:
|
|
|
|
|
+ cls = "flow-step flow-pending"
|
|
|
|
|
+ icon = "○"
|
|
|
|
|
+ stage_no = stage_order_map.get(stage_key, 0)
|
|
|
|
|
+ flow_items.append(
|
|
|
|
|
+ f'<div class="{cls}"><span class="flow-icon">{icon}</span>'
|
|
|
|
|
+ f'<h1 class="flow-h1">{stage_no}. {_esc(label)}</h1></div>'
|
|
|
|
|
+ )
|
|
|
|
|
+ flow_html = "".join(flow_items)
|
|
|
|
|
+
|
|
|
|
|
+ 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>Pipeline 执行追踪 — {_esc(query[:40])}</title>
|
|
|
|
|
+<style>
|
|
|
|
|
+:root {{
|
|
|
|
|
+ --bg: #0b1020; --bg2: #121a2b; --bg3: #1a2336; --border: #263149;
|
|
|
|
|
+ --text: #d9e1f2; --dim: #94a3bf; --blue: #66b3ff;
|
|
|
|
|
+ --purple: #c9a4ff; --green: #53d79f; --red: #ff6b7a;
|
|
|
|
|
+ --yellow: #f2c35d; --orange: #ff9f5a; --green-bg: #132d28;
|
|
|
|
|
+ --shadow: 0 12px 34px rgba(0,0,0,.28);
|
|
|
|
|
+}}
|
|
|
|
|
+* {{ margin:0; padding:0; box-sizing:border-box; }}
|
|
|
|
|
+body {{
|
|
|
|
|
+ font-family: -apple-system,"SF Pro Text","Segoe UI","Helvetica Neue",sans-serif;
|
|
|
|
|
+ background:
|
|
|
|
|
+ radial-gradient(1200px 520px at 10% -10%, rgba(102,179,255,.11), transparent 50%),
|
|
|
|
|
+ radial-gradient(900px 500px at 90% 0%, rgba(201,164,255,.10), transparent 45%),
|
|
|
|
|
+ var(--bg);
|
|
|
|
|
+ color:var(--text); line-height:1.7;
|
|
|
|
|
+ margin:0; padding:24px 16px;
|
|
|
|
|
+}}
|
|
|
|
|
+.page-wrap {{
|
|
|
|
|
+ width: 80vw;
|
|
|
|
|
+ max-width: 1920px;
|
|
|
|
|
+ margin: 0 auto;
|
|
|
|
|
+}}
|
|
|
|
|
+.page-nav {{
|
|
|
|
|
+ position:sticky; top:0; z-index:20; backdrop-filter: blur(6px);
|
|
|
|
|
+ background:rgba(10,15,28,.72); border:1px solid var(--border); border-radius:12px;
|
|
|
|
|
+ box-shadow: var(--shadow);
|
|
|
|
|
+ padding:8px 10px; margin-bottom:14px; display:flex; gap:8px; flex-wrap:wrap;
|
|
|
|
|
+}}
|
|
|
|
|
+.page-nav a {{
|
|
|
|
|
+ color:var(--dim); text-decoration:none; font-size:12px; padding:5px 12px;
|
|
|
|
|
+ border-radius:999px; border:1px solid rgba(148,163,191,.2); background:rgba(18,26,43,.92);
|
|
|
|
|
+}}
|
|
|
|
|
+.page-nav a:hover {{ color:var(--blue); border-color:rgba(102,179,255,.45); background:rgba(102,179,255,.08); }}
|
|
|
|
|
+header {{
|
|
|
|
|
+ border:1px solid var(--border); border-radius:14px;
|
|
|
|
|
+ background:linear-gradient(180deg, rgba(18,26,43,.95), rgba(18,26,43,.78));
|
|
|
|
|
+ box-shadow: var(--shadow);
|
|
|
|
|
+ padding:18px 20px; margin-bottom:20px;
|
|
|
|
|
+ display:flex; justify-content:space-between; align-items:flex-end; flex-wrap:wrap; gap:12px;
|
|
|
|
|
+}}
|
|
|
|
|
+header h1 {{ color:var(--blue); font-size:24px; letter-spacing:-0.5px; margin:0; }}
|
|
|
|
|
+header .sub {{ color:var(--dim); font-size:12px; margin-top:4px; }}
|
|
|
|
|
+header .sub span {{ margin:0 6px; }}
|
|
|
|
|
+.stats {{
|
|
|
|
|
+ display:grid; grid-template-columns:repeat(5,1fr);
|
|
|
|
|
+ gap:12px; margin-bottom:24px;
|
|
|
|
|
+}}
|
|
|
|
|
+.stat {{
|
|
|
|
|
+ background:linear-gradient(180deg, rgba(18,26,43,.95), rgba(18,26,43,.75));
|
|
|
|
|
+ border:1px solid var(--border);
|
|
|
|
|
+ border-radius:12px; padding:14px 16px; text-align:center;
|
|
|
|
|
+ box-shadow: var(--shadow);
|
|
|
|
|
+}}
|
|
|
|
|
+.stat .num {{ font-size:28px; font-weight:700; line-height:1.2; }}
|
|
|
|
|
+.stat .desc {{ font-size:11px; color:var(--dim); margin-top:4px; text-transform:uppercase; letter-spacing:0.5px; }}
|
|
|
|
|
+.s-time .num {{ color:var(--yellow); font-size:20px; }}
|
|
|
|
|
+.s-cand .num {{ color:var(--blue); }}
|
|
|
|
|
+.s-filt .num {{ color:var(--green); }}
|
|
|
|
|
+.s-acct .num {{ color:var(--purple); }}
|
|
|
|
|
+.s-err .num {{ color:var(--red); }}
|
|
|
|
|
+.section-title-bar {{
|
|
|
|
|
+ display:flex; align-items:center; justify-content:space-between;
|
|
|
|
|
+ margin:22px 0 10px;
|
|
|
|
|
+}}
|
|
|
|
|
+.section-title-bar h2 {{
|
|
|
|
|
+ margin:0; font-size:15px; color:var(--text); font-weight:700; letter-spacing:.2px;
|
|
|
|
|
+}}
|
|
|
|
|
+.section-title-bar .hint {{ color:var(--dim); font-size:11px; }}
|
|
|
|
|
+.flow-strip {{
|
|
|
|
|
+ display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr));
|
|
|
|
|
+ gap:8px; margin:8px 0 18px;
|
|
|
|
|
+}}
|
|
|
|
|
+.flow-step {{
|
|
|
|
|
+ background:linear-gradient(180deg, rgba(18,26,43,.92), rgba(18,26,43,.75));
|
|
|
|
|
+ border:1px solid var(--border); border-radius:10px;
|
|
|
|
|
+ padding:9px 11px; font-size:12px; display:flex; gap:8px; align-items:center;
|
|
|
|
|
+ box-shadow: 0 4px 16px rgba(0,0,0,.18);
|
|
|
|
|
+}}
|
|
|
|
|
+.flow-h1 {{
|
|
|
|
|
+ margin:0; font-size:16px; font-weight:700; letter-spacing:.1px; color:var(--text);
|
|
|
|
|
+}}
|
|
|
|
|
+.flow-icon {{ font-size:11px; opacity:.95; }}
|
|
|
|
|
+.flow-done {{ border-color:rgba(86,211,100,.45); background:rgba(86,211,100,.08); }}
|
|
|
|
|
+.flow-done .flow-icon {{ color:var(--green); }}
|
|
|
|
|
+.flow-error {{ border-color:rgba(248,81,73,.55); background:rgba(248,81,73,.10); }}
|
|
|
|
|
+.flow-error .flow-icon {{ color:var(--red); }}
|
|
|
|
|
+.flow-pending {{ border-color:rgba(139,148,158,.35); }}
|
|
|
|
|
+.timeline {{
|
|
|
|
|
+ position:relative; padding:12px 12px 12px 28px;
|
|
|
|
|
+ border:1px solid var(--border); border-radius:14px;
|
|
|
|
|
+ background:linear-gradient(180deg, rgba(18,26,43,.90), rgba(18,26,43,.72));
|
|
|
|
|
+ box-shadow: var(--shadow);
|
|
|
|
|
+}}
|
|
|
|
|
+.timeline::before {{
|
|
|
|
|
+ content:''; position:absolute; left:8px; top:0; bottom:0;
|
|
|
|
|
+ width:2px; background:linear-gradient(180deg, var(--blue) 0%, var(--green) 50%, var(--purple) 100%);
|
|
|
|
|
+ opacity:0.4;
|
|
|
|
|
+}}
|
|
|
|
|
+.ts {{ font-size:10px; color:var(--dim); margin-right:6px; }}
|
|
|
|
|
+.ev-label {{ font-weight:600; margin-bottom:6px; }}
|
|
|
|
|
+.kv {{ font-size:13px; margin:2px 0; }}
|
|
|
|
|
+.kv .k {{ color:var(--dim); margin-right:8px; }}
|
|
|
|
|
+.kv .k::after {{ content:':'; }}
|
|
|
|
|
+.kv .v {{ color:var(--text); }}
|
|
|
|
|
+.mono {{ font-family:monospace; font-size:11px; color:var(--dim); }}
|
|
|
|
|
+.ev-init, .ev-complete-ok, .ev-complete-fail {{
|
|
|
|
|
+ background:rgba(16,24,41,.85); border:1px solid var(--border);
|
|
|
|
|
+ border-radius:12px; padding:14px 16px; margin:12px 0;
|
|
|
|
|
+}}
|
|
|
|
|
+.ev-init {{ border-left:4px solid var(--blue); }}
|
|
|
|
|
+.ev-init .ev-label {{ color:var(--blue); }}
|
|
|
|
|
+.ev-complete-ok {{ border-left:4px solid var(--green); }}
|
|
|
|
|
+.ev-complete-ok .ev-label {{ color:var(--green); }}
|
|
|
|
|
+.ev-complete-fail {{ border-left:4px solid var(--red); }}
|
|
|
|
|
+.ev-complete-fail .ev-label {{ color:var(--red); }}
|
|
|
|
|
+.ev-phase {{
|
|
|
|
|
+ background:linear-gradient(135deg,#1f3f67,#1a2b47);
|
|
|
|
|
+ border:1px solid rgba(88,166,255,.3); border-radius:10px;
|
|
|
|
|
+ padding:11px 14px; margin:18px 0 8px;
|
|
|
|
|
+ font-size:15px; font-weight:700; color:var(--blue);
|
|
|
|
|
+ position:relative; display:flex; align-items:center; gap:8px;
|
|
|
|
|
+}}
|
|
|
|
|
+.ev-phase::before {{
|
|
|
|
|
+ content:''; position:absolute; left:-20px; top:14px;
|
|
|
|
|
+ width:10px; height:10px; background:var(--blue);
|
|
|
|
|
+ border-radius:50%; border:2px solid var(--bg);
|
|
|
|
|
+}}
|
|
|
|
|
+.stage-name {{ font-size:11px; color:var(--dim); font-weight:400; margin-left:6px; }}
|
|
|
|
|
+.stage-h1 {{
|
|
|
|
|
+ margin:0; font-size:22px; line-height:1.15; font-weight:800; color:var(--blue);
|
|
|
|
|
+}}
|
|
|
|
|
+.ev-stage-ok {{
|
|
|
|
|
+ background:linear-gradient(180deg, rgba(19,45,40,.92), rgba(19,45,40,.72));
|
|
|
|
|
+ border:1px solid rgba(83,215,159,.35);
|
|
|
|
|
+ border-radius:10px; padding:10px 12px; margin:8px 0; font-size:13px;
|
|
|
|
|
+}}
|
|
|
|
|
+.stage-stats {{ margin-top:4px; }}
|
|
|
|
|
+.stat-pill {{
|
|
|
|
|
+ display:inline-block; background:rgba(88,166,255,.1);
|
|
|
|
|
+ border:1px solid rgba(88,166,255,.2); border-radius:12px;
|
|
|
|
|
+ padding:1px 8px; font-size:11px; color:var(--blue); margin-right:6px;
|
|
|
|
|
+}}
|
|
|
|
|
+.badge-retry {{
|
|
|
|
|
+ background:rgba(227,179,65,.15); border:1px solid rgba(227,179,65,.3);
|
|
|
|
|
+ border-radius:4px; padding:1px 6px; font-size:10px; color:var(--yellow); margin-left:6px;
|
|
|
|
|
+}}
|
|
|
|
|
+.badge-dur {{
|
|
|
|
|
+ background:rgba(139,148,158,.1); border-radius:4px;
|
|
|
|
|
+ padding:1px 6px; font-size:10px; color:var(--dim); margin-left:4px;
|
|
|
|
|
+}}
|
|
|
|
|
+.ev-gate-ok, .ev-gate-warn {{
|
|
|
|
|
+ border-radius:10px; padding:9px 12px; margin:6px 0; font-size:12px;
|
|
|
|
|
+}}
|
|
|
|
|
+.ev-gate-ok {{
|
|
|
|
|
+ background:rgba(86,211,100,.05); border:1px solid rgba(86,211,100,.2);
|
|
|
|
|
+}}
|
|
|
|
|
+.ev-gate-warn {{
|
|
|
|
|
+ background:rgba(240,136,62,.05); border:1px solid rgba(240,136,62,.3);
|
|
|
|
|
+}}
|
|
|
|
|
+.gate-action {{ font-weight:600; margin-left:8px; }}
|
|
|
|
|
+.gate-fallback {{ color:var(--orange); font-size:11px; margin-left:8px; }}
|
|
|
|
|
+.gate-issues {{ margin:4px 0 0 16px; color:var(--dim); font-size:11px; }}
|
|
|
|
|
+.ev-error {{ background:#2d1215; border:1px solid rgba(248,81,73,.4); border-radius:8px; margin:6px 0; color:var(--red); }}
|
|
|
|
|
+.ev-error summary {{ padding:10px 14px; cursor:pointer; font-size:13px; }}
|
|
|
|
|
+.ev-error pre {{ white-space:pre-wrap; word-break:break-word; font-size:11px; color:#f0a0a0; padding:10px 14px; border-top:1px solid rgba(248,81,73,.2); max-height:300px; overflow-y:auto; }}
|
|
|
|
|
+/* ── 决策详情卡片 ── */
|
|
|
|
|
+.decision-card {{
|
|
|
|
|
+ margin:10px 0 6px; border:1px solid var(--border); border-radius:10px;
|
|
|
|
|
+ background:rgba(18,26,43,.92); overflow:hidden;
|
|
|
|
|
+}}
|
|
|
|
|
+.decision-card summary {{
|
|
|
|
|
+ padding:10px 16px; cursor:pointer; font-size:13px; font-weight:600;
|
|
|
|
|
+ color:var(--blue); user-select:none; background:rgba(88,166,255,.03);
|
|
|
|
|
+}}
|
|
|
|
|
+.decision-card summary:hover {{ background:rgba(88,166,255,.08); }}
|
|
|
|
|
+.decision-body {{ padding:8px 16px 16px; }}
|
|
|
|
|
+.decision-section {{ margin-bottom:12px; }}
|
|
|
|
|
+.section-title {{ font-size:12px; font-weight:600; color:var(--dim); margin-bottom:6px; }}
|
|
|
|
|
+.decision-table {{
|
|
|
|
|
+ width:100%; border-collapse:collapse; font-size:12px; margin-top:4px;
|
|
|
|
|
+ table-layout:auto;
|
|
|
|
|
+}}
|
|
|
|
|
+.decision-table th {{
|
|
|
|
|
+ text-align:left; padding:6px 10px; border-bottom:2px solid var(--border);
|
|
|
|
|
+ color:var(--dim); font-weight:600; font-size:11px; white-space:nowrap;
|
|
|
|
|
+ background:rgba(0,0,0,.2); position:sticky; top:0;
|
|
|
|
|
+}}
|
|
|
|
|
+.decision-table td {{ padding:5px 10px; border-bottom:1px solid rgba(33,38,45,.5); vertical-align:top; }}
|
|
|
|
|
+.feature-label {{ color:var(--dim); white-space:nowrap; width:70px; }}
|
|
|
|
|
+.tag {{
|
|
|
|
|
+ display:inline-block; background:rgba(210,168,255,.1); border:1px solid rgba(210,168,255,.25);
|
|
|
|
|
+ border-radius:4px; padding:1px 6px; font-size:11px; color:var(--purple); margin:1px 3px 1px 0;
|
|
|
|
|
+}}
|
|
|
|
|
+.tag-blue {{ background:rgba(88,166,255,.1); border-color:rgba(88,166,255,.25); color:var(--blue); }}
|
|
|
|
|
+.tag-purple {{ background:rgba(210,168,255,.1); border-color:rgba(210,168,255,.25); color:var(--purple); }}
|
|
|
|
|
+.focus-group {{ margin:4px 0 8px; font-size:12px; }}
|
|
|
|
|
+.focus-group b {{ color:var(--dim); font-size:11px; }}
|
|
|
|
|
+.focus-group ul {{ margin:2px 0 0 16px; color:var(--text); }}
|
|
|
|
|
+.focus-group li {{ margin:1px 0; }}
|
|
|
|
|
+.kw-table code {{ color:var(--blue); font-size:11px; }}
|
|
|
|
|
+.num-cell {{ text-align:right; font-variant-numeric:tabular-nums; }}
|
|
|
|
|
+.total-line {{ font-size:12px; color:var(--dim); margin-top:6px; padding-top:6px; border-top:1px solid var(--border); }}
|
|
|
|
|
+.article-title-cell {{ word-break:break-all; }}
|
|
|
|
|
+.article-title-cell a {{ color:var(--blue); text-decoration:none; }}
|
|
|
|
|
+.article-title-cell a:hover {{ text-decoration:underline; }}
|
|
|
|
|
+.recall-table .article-title-cell,
|
|
|
|
|
+.review-table .article-title-cell {{
|
|
|
|
|
+ min-width: 520px;
|
|
|
|
|
+ max-width: 680px;
|
|
|
|
|
+}}
|
|
|
|
|
+.score-cell {{ white-space:nowrap; font-size:11px; }}
|
|
|
|
|
+.reason-full-cell {{ font-size:11px; color:var(--dim); line-height:1.5; }}
|
|
|
|
|
+.date-cell {{ white-space:nowrap; font-size:11px; color:var(--dim); }}
|
|
|
|
|
+.review-table th {{ white-space:nowrap; }}
|
|
|
|
|
+.review-table {{ min-width:900px; }}
|
|
|
|
|
+.decision-section {{ overflow-x:auto; }}
|
|
|
|
|
+.recall-table code {{ color:var(--blue); font-size:11px; }}
|
|
|
|
|
+.phase-badge {{
|
|
|
|
|
+ display:inline-block; font-size:10px; padding:1px 5px; border-radius:3px;
|
|
|
|
|
+ background:rgba(139,148,158,.12); color:var(--dim); white-space:nowrap;
|
|
|
|
|
+}}
|
|
|
|
|
+.row-accept td {{ border-left:2px solid var(--green); }}
|
|
|
|
|
+.row-reject td {{ border-left:2px solid var(--red); }}
|
|
|
|
|
+.row-skip td {{ border-left:2px solid var(--dim); }}
|
|
|
|
|
+.stat-accept {{ background:rgba(86,211,100,.1); border-color:rgba(86,211,100,.3); color:var(--green); }}
|
|
|
|
|
+.stat-reject {{ background:rgba(248,81,73,.1); border-color:rgba(248,81,73,.3); color:var(--red); }}
|
|
|
|
|
+.stat-skip {{ background:rgba(139,148,158,.1); border-color:rgba(139,148,158,.3); color:var(--dim); }}
|
|
|
|
|
+.acct-table .sample-titles {{ font-size:11px; color:var(--dim); }}
|
|
|
|
|
+.file-path {{ font-size:11px; color:var(--dim); background:rgba(139,148,158,.08); padding:3px 8px; border-radius:4px; }}
|
|
|
|
|
+/* ── LLM 交互追踪卡片 ── */
|
|
|
|
|
+.llm-interaction-card {{
|
|
|
|
|
+ margin:10px 0 6px; border:1px solid rgba(102,179,255,.3); border-radius:10px;
|
|
|
|
|
+ background:rgba(18,26,43,.95); overflow:hidden;
|
|
|
|
|
+}}
|
|
|
|
|
+.llm-interaction-card summary {{
|
|
|
|
|
+ padding:8px 14px; cursor:pointer; font-size:12px; font-weight:600;
|
|
|
|
|
+ color:var(--blue); user-select:none;
|
|
|
|
|
+}}
|
|
|
|
|
+.llm-interaction-card summary:hover {{ background:rgba(102,179,255,.06); }}
|
|
|
|
|
+.llm-interaction-meta {{ font-weight:400; color:var(--dim); font-size:11px; margin-left:4px; }}
|
|
|
|
|
+.llm-interaction-body {{ padding:6px 14px 14px; display:flex; flex-direction:column; gap:6px; }}
|
|
|
|
|
+.llm-msg {{ border-radius:6px; font-size:12px; overflow:hidden; }}
|
|
|
|
|
+.llm-msg-header {{ font-size:11px; font-weight:600; color:var(--dim); margin-bottom:4px; }}
|
|
|
|
|
+.llm-msg-pre {{
|
|
|
|
|
+ white-space:pre-wrap; word-break:break-word; font-size:11px;
|
|
|
|
|
+ font-family:monospace; line-height:1.5; color:var(--text);
|
|
|
|
|
+ max-height:400px; overflow-y:auto; margin:0; padding:4px 0;
|
|
|
|
|
+}}
|
|
|
|
|
+.llm-msg-system {{
|
|
|
|
|
+ background:rgba(88,166,255,.04); border:1px solid rgba(88,166,255,.1);
|
|
|
|
|
+}}
|
|
|
|
|
+.llm-msg-system summary {{
|
|
|
|
|
+ padding:6px 10px; cursor:pointer; font-size:11px; color:var(--blue);
|
|
|
|
|
+}}
|
|
|
|
|
+.llm-msg-system .llm-msg-pre {{ padding:0 10px 10px; color:var(--dim); max-height:300px; }}
|
|
|
|
|
+.llm-msg-user {{
|
|
|
|
|
+ background:rgba(139,148,158,.04); border:1px solid rgba(139,148,158,.1);
|
|
|
|
|
+ padding:8px 10px;
|
|
|
|
|
+}}
|
|
|
|
|
+.llm-msg-user summary {{
|
|
|
|
|
+ padding:6px 10px; cursor:pointer; font-size:11px; color:var(--dim);
|
|
|
|
|
+}}
|
|
|
|
|
+.llm-msg-user .llm-msg-pre {{ padding:0 10px 10px; }}
|
|
|
|
|
+.llm-reasoning {{
|
|
|
|
|
+ background:rgba(227,179,65,.08); border:1px solid rgba(227,179,65,.25);
|
|
|
|
|
+ border-radius:6px; margin:4px 0;
|
|
|
|
|
+}}
|
|
|
|
|
+.llm-reasoning summary {{
|
|
|
|
|
+ cursor:pointer; font-size:12px; font-weight:600; color:var(--yellow); padding:6px 10px;
|
|
|
|
|
+}}
|
|
|
|
|
+.llm-reasoning-text {{ padding:4px 10px 10px; color:var(--yellow); opacity:0.9; }}
|
|
|
|
|
+.llm-response {{
|
|
|
|
|
+ background:rgba(86,211,100,.04); border:1px solid rgba(86,211,100,.15);
|
|
|
|
|
+ border-radius:6px; padding:8px 10px;
|
|
|
|
|
+}}
|
|
|
|
|
+.llm-response summary {{
|
|
|
|
|
+ cursor:pointer; font-size:11px; color:var(--green); padding:4px 0;
|
|
|
|
|
+}}
|
|
|
|
|
+.llm-tool-call {{
|
|
|
|
|
+ background:rgba(88,166,255,.06); border:1px solid rgba(88,166,255,.12);
|
|
|
|
|
+ border-radius:4px; margin:4px 0; padding:6px 8px;
|
|
|
|
|
+}}
|
|
|
|
|
+.llm-tool-name {{ font-size:11px; font-weight:600; color:var(--blue); margin-bottom:2px; }}
|
|
|
|
|
+.llm-tool-args {{
|
|
|
|
|
+ font-size:10px; color:var(--dim); font-family:monospace; margin:2px 0 0;
|
|
|
|
|
+ white-space:pre-wrap; max-height:200px; overflow-y:auto;
|
|
|
|
|
+}}
|
|
|
|
|
+.llm-tool-result {{ margin:4px 0; }}
|
|
|
|
|
+.llm-tool-result summary {{
|
|
|
|
|
+ cursor:pointer; font-size:11px; color:var(--purple); padding:2px 0;
|
|
|
|
|
+}}
|
|
|
|
|
+/* ── Agent Trace 卡片 ── */
|
|
|
|
|
+.agent-trace-card {{
|
|
|
|
|
+ margin:10px 0 6px; border:1px solid rgba(210,168,255,.3); border-radius:10px;
|
|
|
|
|
+ background:rgba(18,26,43,.95); overflow:hidden;
|
|
|
|
|
+}}
|
|
|
|
|
+.agent-trace-card summary {{
|
|
|
|
|
+ padding:8px 14px; cursor:pointer; font-size:12px; font-weight:600;
|
|
|
|
|
+ color:var(--purple); user-select:none;
|
|
|
|
|
+}}
|
|
|
|
|
+.agent-trace-card summary:hover {{ background:rgba(210,168,255,.06); }}
|
|
|
|
|
+.agent-trace-summary {{ font-weight:400; color:var(--dim); font-size:11px; margin-left:4px; }}
|
|
|
|
|
+.agent-trace-body {{ padding:6px 14px 14px; }}
|
|
|
|
|
+.agent-meta {{
|
|
|
|
|
+ display:flex; flex-wrap:wrap; gap:8px 16px; padding:6px 0 10px;
|
|
|
|
|
+ border-bottom:1px solid var(--border); margin-bottom:10px; font-size:11px;
|
|
|
|
|
+}}
|
|
|
|
|
+.agent-meta-item {{ color:var(--dim); }}
|
|
|
|
|
+.agent-meta-item b {{ color:var(--text); }}
|
|
|
|
|
+.agent-meta-item code {{ color:var(--blue); font-size:10px; }}
|
|
|
|
|
+.agent-messages {{ display:flex; flex-direction:column; gap:6px; }}
|
|
|
|
|
+.agent-msg {{ border-radius:6px; font-size:12px; overflow:hidden; }}
|
|
|
|
|
+.agent-msg-header {{
|
|
|
|
|
+ font-size:11px; font-weight:600; color:var(--dim); margin-bottom:4px;
|
|
|
|
|
+}}
|
|
|
|
|
+.agent-msg-pre {{
|
|
|
|
|
+ white-space:pre-wrap; word-break:break-word; font-size:11px;
|
|
|
|
|
+ font-family:monospace; line-height:1.5; color:var(--text);
|
|
|
|
|
+ max-height:400px; overflow-y:auto; margin:0; padding:4px 0;
|
|
|
|
|
+}}
|
|
|
|
|
+.agent-msg-system {{
|
|
|
|
|
+ background:rgba(88,166,255,.04); border:1px solid rgba(88,166,255,.1);
|
|
|
|
|
+}}
|
|
|
|
|
+.agent-msg-system summary {{
|
|
|
|
|
+ padding:6px 10px; cursor:pointer; font-size:11px; color:var(--blue);
|
|
|
|
|
+}}
|
|
|
|
|
+.agent-msg-system .agent-msg-pre {{ padding:0 10px 10px; color:var(--dim); max-height:300px; }}
|
|
|
|
|
+.agent-msg-user {{
|
|
|
|
|
+ background:rgba(139,148,158,.04); border:1px solid rgba(139,148,158,.1);
|
|
|
|
|
+ padding:8px 10px;
|
|
|
|
|
+}}
|
|
|
|
|
+.agent-msg-user summary {{
|
|
|
|
|
+ padding:6px 10px; cursor:pointer; font-size:11px; color:var(--dim);
|
|
|
|
|
+}}
|
|
|
|
|
+.agent-msg-user .agent-msg-pre {{ padding:0 10px 10px; }}
|
|
|
|
|
+.agent-msg-assistant {{
|
|
|
|
|
+ background:rgba(86,211,100,.04); border:1px solid rgba(86,211,100,.15);
|
|
|
|
|
+ padding:8px 10px;
|
|
|
|
|
+}}
|
|
|
|
|
+.agent-token-badge {{
|
|
|
|
|
+ display:inline-block; font-size:9px; padding:1px 5px; border-radius:3px;
|
|
|
|
|
+ background:rgba(139,148,158,.12); color:var(--dim); margin-left:6px; font-weight:400;
|
|
|
|
|
+}}
|
|
|
|
|
+.agent-thinking {{ margin:4px 0; }}
|
|
|
|
|
+.agent-thinking summary {{
|
|
|
|
|
+ cursor:pointer; font-size:11px; color:var(--dim); padding:2px 0;
|
|
|
|
|
+}}
|
|
|
|
|
+.agent-reasoning {{
|
|
|
|
|
+ background:rgba(227,179,65,.06); border:1px solid rgba(227,179,65,.15);
|
|
|
|
|
+ border-radius:4px; margin:4px 0;
|
|
|
|
|
+}}
|
|
|
|
|
+.agent-reasoning summary {{
|
|
|
|
|
+ cursor:pointer; font-size:11px; color:var(--yellow); padding:4px 8px;
|
|
|
|
|
+}}
|
|
|
|
|
+.agent-reasoning .agent-msg-pre {{ padding:4px 8px 8px; color:var(--yellow); }}
|
|
|
|
|
+.agent-tool-call {{
|
|
|
|
|
+ background:rgba(88,166,255,.06); border:1px solid rgba(88,166,255,.12);
|
|
|
|
|
+ border-radius:4px; margin:4px 0; padding:6px 8px;
|
|
|
|
|
+}}
|
|
|
|
|
+.agent-tool-name {{ font-size:11px; font-weight:600; color:var(--blue); margin-bottom:2px; }}
|
|
|
|
|
+.agent-tool-args {{
|
|
|
|
|
+ font-size:10px; color:var(--dim); font-family:monospace; margin:2px 0 0;
|
|
|
|
|
+ white-space:pre-wrap; max-height:200px; overflow-y:auto;
|
|
|
|
|
+}}
|
|
|
|
|
+.agent-msg-tool {{
|
|
|
|
|
+ background:rgba(210,168,255,.04); border:1px solid rgba(210,168,255,.1);
|
|
|
|
|
+ padding:6px 10px;
|
|
|
|
|
+}}
|
|
|
|
|
+.agent-msg-tool summary {{
|
|
|
|
|
+ cursor:pointer; font-size:11px; color:var(--purple); padding:4px 0;
|
|
|
|
|
+}}
|
|
|
|
|
+.agent-msg-tool .agent-msg-pre {{ max-height:250px; color:var(--dim); }}
|
|
|
|
|
+.agent-msg-goal {{
|
|
|
|
|
+ background:rgba(227,179,65,.06); border:1px solid rgba(227,179,65,.12);
|
|
|
|
|
+ border-radius:4px; padding:6px 10px; font-size:11px; color:var(--yellow);
|
|
|
|
|
+}}
|
|
|
|
|
+@media(max-width:900px) {{
|
|
|
|
|
+ body {{ padding:16px 12px; }}
|
|
|
|
|
+ .stats {{ grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); }}
|
|
|
|
|
+ .timeline {{ padding-left:16px; }}
|
|
|
|
|
+ .timeline::before {{ left:3px; }}
|
|
|
|
|
+ .ev-phase::before {{ left:-14px; width:8px; height:8px; }}
|
|
|
|
|
+ header {{ flex-direction:column; align-items:flex-start; }}
|
|
|
|
|
+}}
|
|
|
|
|
+{_LOG_VIEWER_CSS}
|
|
|
|
|
+</style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+<div class="page-wrap">
|
|
|
|
|
+<nav class="page-nav">
|
|
|
|
|
+ <a href="#overview">总览</a>
|
|
|
|
|
+ <a href="#flow">流程总览</a>
|
|
|
|
|
+ <a href="#timeline">执行时间线</a>
|
|
|
|
|
+ <a href="#logs">完整日志</a>
|
|
|
|
|
+</nav>
|
|
|
|
|
+
|
|
|
|
|
+<header id="overview">
|
|
|
|
|
+ <h1>🔄 Pipeline 执行追踪</h1>
|
|
|
|
|
+ <div class="sub">
|
|
|
|
|
+ 查询: {_esc(query)} |
|
|
|
|
|
+ 模型: {_esc(model)} |
|
|
|
|
|
+ 状态: <span style="color:{status_color}">{_esc(pipeline_status)}</span> |
|
|
|
|
|
+ 耗时: {_esc(duration)} |
|
|
|
|
|
+ trace_id: <span style="font-family:monospace">{_esc(trace_id)}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</header>
|
|
|
|
|
+
|
|
|
|
|
+<div class="stats">
|
|
|
|
|
+ <div class="stat s-time"><div class="num">{_esc(duration)}</div><div class="desc">总耗时</div></div>
|
|
|
|
|
+ <div class="stat s-cand"><div class="num">{candidate_count}</div><div class="desc">召回候选</div></div>
|
|
|
|
|
+ <div class="stat s-filt"><div class="num">{filtered_count}</div><div class="desc">入选文章</div></div>
|
|
|
|
|
+ <div class="stat s-acct"><div class="num">{account_count}</div><div class="desc">沉淀账号</div></div>
|
|
|
|
|
+ <div class="stat s-err"><div class="num">{len(error_events)}</div><div class="desc">错误</div></div>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+<div class="section-title-bar" id="flow">
|
|
|
|
|
+ <h2>流程总览</h2>
|
|
|
|
|
+ <span class="hint">按阶段展示执行状态</span>
|
|
|
|
|
+</div>
|
|
|
|
|
+<div class="flow-strip">
|
|
|
|
|
+{flow_html}
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+<div class="section-title-bar" id="timeline">
|
|
|
|
|
+ <h2>执行时间线</h2>
|
|
|
|
|
+ <span class="hint">按事件顺序展示关键决策与结果</span>
|
|
|
|
|
+</div>
|
|
|
|
|
+<div class="timeline">
|
|
|
|
|
+{body}
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+<div id="logs">
|
|
|
|
|
+{_render_full_log_section(full_log_lines or [])}
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+{_LOG_VIEWER_JS}
|
|
|
|
|
+
|
|
|
|
|
+</div>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>"""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────
|
|
|
|
|
+# 入口
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def list_traces() -> None:
|
|
|
|
|
+ if not TRACES_DIR.exists():
|
|
|
|
|
+ print(f"traces 目录不存在: {TRACES_DIR}")
|
|
|
|
|
+ return
|
|
|
|
|
+ dirs = sorted(
|
|
|
|
|
+ [d for d in TRACES_DIR.iterdir() if d.is_dir() and (d / "pipeline.jsonl").exists()],
|
|
|
|
|
+ key=lambda d: d.stat().st_mtime,
|
|
|
|
|
+ reverse=True,
|
|
|
|
|
+ )
|
|
|
|
|
+ if not dirs:
|
|
|
|
|
+ print("暂无可用 trace(需先运行 run_search_agent.py)")
|
|
|
|
|
+ return
|
|
|
|
|
+ print(f"{'trace_id':<40} {'修改时间'}")
|
|
|
|
|
+ print("-" * 60)
|
|
|
|
|
+ for d in dirs:
|
|
|
|
|
+ mtime = datetime.fromtimestamp(d.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
+ print(f"{d.name:<40} {mtime}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def find_latest_trace() -> Path | None:
|
|
|
|
|
+ if not TRACES_DIR.exists():
|
|
|
|
|
+ return None
|
|
|
|
|
+ dirs = sorted(
|
|
|
|
|
+ [d for d in TRACES_DIR.iterdir() if d.is_dir() and (d / "pipeline.jsonl").exists()],
|
|
|
|
|
+ key=lambda d: d.stat().st_mtime,
|
|
|
|
|
+ reverse=True,
|
|
|
|
|
+ )
|
|
|
|
|
+ return dirs[0] if dirs else None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def main() -> None:
|
|
|
|
|
+ args = sys.argv[1:]
|
|
|
|
|
+
|
|
|
|
|
+ if "--list" in args:
|
|
|
|
|
+ list_traces()
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ if args and not args[0].startswith("--"):
|
|
|
|
|
+ trace_id = args[0]
|
|
|
|
|
+ trace_dir = TRACES_DIR / trace_id
|
|
|
|
|
+ else:
|
|
|
|
|
+ trace_dir = find_latest_trace()
|
|
|
|
|
+ if trace_dir is None:
|
|
|
|
|
+ print(f"❌ 找不到任何 trace,请先运行 run_search_agent.py")
|
|
|
|
|
+ print(f" traces 目录: {TRACES_DIR}")
|
|
|
|
|
+ sys.exit(1)
|
|
|
|
|
+ trace_id = trace_dir.name
|
|
|
|
|
+
|
|
|
|
|
+ jsonl_path = trace_dir / "pipeline.jsonl"
|
|
|
|
|
+ if not jsonl_path.exists():
|
|
|
|
|
+ print(f"❌ 找不到 {jsonl_path}")
|
|
|
|
|
+ sys.exit(1)
|
|
|
|
|
+
|
|
|
|
|
+ events = read_jsonl(jsonl_path)
|
|
|
|
|
+ print(f"📄 读取了 {len(events)} 个事件 (trace_id={trace_id})")
|
|
|
|
|
+
|
|
|
|
|
+ # 读取完整日志文件(如有)
|
|
|
|
|
+ log_path = trace_dir / "full_log.log"
|
|
|
|
|
+ full_log_lines: list[dict] | None = None
|
|
|
|
|
+ if log_path.exists():
|
|
|
|
|
+ log_text = log_path.read_text(encoding="utf-8")
|
|
|
|
|
+ full_log_lines = _parse_log_lines(log_text)
|
|
|
|
|
+ print(f"📋 读取了 {len(full_log_lines)} 行完整日志")
|
|
|
|
|
+
|
|
|
|
|
+ html_content = render_html(events, full_log_lines=full_log_lines)
|
|
|
|
|
+ out_path = trace_dir / "pipeline_trace.html"
|
|
|
|
|
+ out_path.write_text(html_content, encoding="utf-8")
|
|
|
|
|
+ size_kb = out_path.stat().st_size / 1024
|
|
|
|
|
+ print(f"✅ 已生成: {out_path} ({size_kb:.0f} KB)")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ main()
|