pipeline_visualize.py 74 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861
  1. """
  2. Pipeline 执行追踪可视化工具。
  3. 读取 tests/traces/{trace_id}/pipeline.jsonl,生成 HTML 可视化页面。
  4. HTML 风格与 tests/.cache/visualize_log.py 保持一致。
  5. 用法:
  6. python pipeline_visualize.py # 读取最新 trace
  7. python pipeline_visualize.py <trace_id> # 指定 trace_id
  8. python pipeline_visualize.py --list # 列出所有可用 trace
  9. """
  10. from __future__ import annotations
  11. import html as html_mod
  12. import json
  13. import re
  14. import sys
  15. from datetime import datetime
  16. from pathlib import Path
  17. TRACES_DIR = Path(__file__).parent / "tests" / "traces"
  18. # ─────────────────────────────────────────────────────────────
  19. # 工具函数
  20. # ─────────────────────────────────────────────────────────────
  21. def _esc(s: str) -> str:
  22. return html_mod.escape(str(s))
  23. def _ts(s: str) -> str:
  24. return s[:19] if s else ""
  25. def _duration_label(ms: int | None) -> str:
  26. if ms is None:
  27. return ""
  28. if ms < 1000:
  29. return f"{ms}ms"
  30. return f"{ms / 1000:.1f}s"
  31. def _calc_duration(start_ts: str, end_ts: str) -> str:
  32. try:
  33. t0 = datetime.strptime(start_ts[:19], "%Y-%m-%d %H:%M:%S")
  34. t1 = datetime.strptime(end_ts[:19], "%Y-%m-%d %H:%M:%S")
  35. delta = int((t1 - t0).total_seconds())
  36. mins, secs = divmod(delta, 60)
  37. return f"{mins}分{secs}秒" if mins else f"{secs}秒"
  38. except Exception:
  39. return "N/A"
  40. def _ts_readable(ts: int) -> str:
  41. """将时间戳转为可读格式"""
  42. try:
  43. dt = datetime.fromtimestamp(ts)
  44. return dt.strftime("%Y-%m-%d %H:%M")
  45. except Exception:
  46. return str(ts)
  47. # ─────────────────────────────────────────────────────────────
  48. # JSONL 读取
  49. # ─────────────────────────────────────────────────────────────
  50. def read_jsonl(path: Path) -> list[dict]:
  51. events = []
  52. for line in path.read_text(encoding="utf-8").splitlines():
  53. line = line.strip()
  54. if not line:
  55. continue
  56. try:
  57. events.append(json.loads(line))
  58. except json.JSONDecodeError:
  59. pass
  60. return events
  61. # ─────────────────────────────────────────────────────────────
  62. # HTML 渲染
  63. # ─────────────────────────────────────────────────────────────
  64. _STAGE_LABELS = {
  65. "demand_analysis": "需求理解",
  66. "content_search": "内容召回",
  67. "hard_filter": "硬规则过滤",
  68. "coarse_filter": "标题粗筛",
  69. "quality_filter": "质量精排",
  70. "account_precipitate": "账号沉淀",
  71. "output_persist": "结果输出",
  72. }
  73. _GATE_LABELS = {
  74. "content_search": "SearchCompletenessGate",
  75. "quality_filter": "FilterSufficiencyGate",
  76. "output_persist": "OutputSchemaGate",
  77. }
  78. _ACTION_COLORS = {
  79. "proceed": "var(--green)",
  80. "retry_stage": "var(--yellow)",
  81. "fallback": "var(--orange)",
  82. "abort": "var(--red)",
  83. }
  84. # ─────────────────────────────────────────────────────────────
  85. # 决策数据渲染
  86. # ─────────────────────────────────────────────────────────────
  87. def _render_decisions(stage: str, decisions: dict) -> str:
  88. """根据阶段类型,将 decisions dict 渲染为 HTML(可折叠 <details> 卡片)。"""
  89. if not decisions:
  90. return ""
  91. renderer = _DECISION_RENDERERS.get(stage)
  92. if not renderer:
  93. return ""
  94. try:
  95. inner = renderer(decisions)
  96. except Exception:
  97. return ""
  98. if not inner:
  99. return ""
  100. label = _STAGE_LABELS.get(stage, stage)
  101. # 重要阶段默认展开
  102. open_attr = " open" if stage in ("quality_filter", "demand_analysis", "content_search", "coarse_filter") else ""
  103. return (
  104. f'<details class="decision-card"{open_attr}>'
  105. f'<summary>📋 {_esc(label)} 决策详情</summary>'
  106. f'<div class="decision-body">{inner}</div>'
  107. f'</details>'
  108. )
  109. def _render_demand_analysis(d: dict) -> str:
  110. parts: list[str] = []
  111. # 特征分层
  112. feature_rows: list[str] = []
  113. for key, label in [
  114. ("substantive_features", "实质特征"),
  115. ("formal_features", "形式特征"),
  116. ("upper_features", "上层特征"),
  117. ("lower_features", "下层特征"),
  118. ]:
  119. items = d.get(key, [])
  120. if items:
  121. tags = " ".join(f'<span class="tag">{_esc(t)}</span>' for t in items)
  122. feature_rows.append(
  123. f'<tr><td class="feature-label">{label}</td><td>{tags}</td></tr>'
  124. )
  125. if feature_rows:
  126. parts.append(
  127. '<div class="decision-section">'
  128. '<div class="section-title">🧠 特征分层</div>'
  129. '<table class="decision-table">'
  130. + "\n".join(feature_rows)
  131. + '</table></div>'
  132. )
  133. # 搜索策略
  134. ss = d.get("search_strategy", {})
  135. precise = ss.get("precise_keywords", [])
  136. topic = ss.get("topic_keywords", [])
  137. if precise or topic:
  138. rows: list[str] = []
  139. if precise:
  140. tags = " ".join(f'<span class="tag tag-blue">{_esc(k)}</span>' for k in precise)
  141. rows.append(f'<tr><td class="feature-label">精准词</td><td>{tags}</td></tr>')
  142. if topic:
  143. tags = " ".join(f'<span class="tag tag-purple">{_esc(k)}</span>' for k in topic)
  144. rows.append(f'<tr><td class="feature-label">主题词</td><td>{tags}</td></tr>')
  145. parts.append(
  146. '<div class="decision-section">'
  147. '<div class="section-title">🔎 搜索策略</div>'
  148. '<table class="decision-table">'
  149. + "\n".join(rows)
  150. + '</table></div>'
  151. )
  152. # 筛选关注点
  153. ff = d.get("filter_focus", {})
  154. relevance = ff.get("relevance_focus", [])
  155. risks = ff.get("elimination_risks", [])
  156. if relevance or risks:
  157. items_html = ""
  158. if relevance:
  159. items_html += '<div class="focus-group"><b>关注点</b><ul>'
  160. items_html += "".join(f'<li>{_esc(r)}</li>' for r in relevance)
  161. items_html += '</ul></div>'
  162. if risks:
  163. items_html += '<div class="focus-group"><b>淘汰风险</b><ul>'
  164. items_html += "".join(f'<li>{_esc(r)}</li>' for r in risks)
  165. items_html += '</ul></div>'
  166. parts.append(
  167. '<div class="decision-section">'
  168. '<div class="section-title">🎯 筛选关注</div>'
  169. + items_html
  170. + '</div>'
  171. )
  172. return "\n".join(parts)
  173. def _render_content_search(d: dict) -> str:
  174. parts: list[str] = []
  175. stats = d.get("keyword_stats", [])
  176. total = d.get("total_candidates", 0)
  177. candidates = d.get("candidates", [])
  178. # 搜索词命中统计
  179. if stats:
  180. rows = []
  181. for s in stats:
  182. kw = _esc(s.get("keyword", ""))
  183. returned = s.get("returned", 0)
  184. new = s.get("new", 0)
  185. rows.append(
  186. f'<tr><td><code>{kw}</code></td>'
  187. f'<td class="num-cell">{returned}</td>'
  188. f'<td class="num-cell">{new}</td></tr>'
  189. )
  190. parts.append(
  191. '<div class="decision-section">'
  192. '<div class="section-title">📊 搜索词命中</div>'
  193. '<table class="decision-table kw-table">'
  194. '<thead><tr><th>关键词</th><th>返回数</th><th>新增数</th></tr></thead>'
  195. '<tbody>' + "\n".join(rows) + '</tbody>'
  196. '</table></div>'
  197. )
  198. # 全部召回文章列表
  199. if candidates:
  200. rows = []
  201. for idx, c in enumerate(candidates, 1):
  202. title = _esc(c.get("title", ""))
  203. url = _esc(c.get("url", ""))
  204. kw = _esc(c.get("source_keyword", ""))
  205. pt = c.get("publish_time", 0)
  206. pt_str = _ts_readable(pt) if pt else "未知"
  207. view = c.get("view_count", 0)
  208. rows.append(
  209. f'<tr>'
  210. f'<td class="num-cell">{idx}</td>'
  211. f'<td class="article-title-cell"><a href="{url}" target="_blank">{title}</a></td>'
  212. f'<td><code>{kw}</code></td>'
  213. f'<td>{_esc(pt_str)}</td>'
  214. f'<td class="num-cell">{view}</td>'
  215. f'</tr>'
  216. )
  217. parts.append(
  218. '<div class="decision-section">'
  219. f'<div class="section-title">📋 全部召回文章({len(candidates)} 篇)</div>'
  220. '<table class="decision-table recall-table">'
  221. '<thead><tr><th>#</th><th>标题</th><th>来源关键词</th><th>发布时间</th><th>阅读量</th></tr></thead>'
  222. '<tbody>' + "\n".join(rows) + '</tbody>'
  223. '</table></div>'
  224. )
  225. parts.append(
  226. f'<div class="total-line">累计候选: <b>{total}</b> 篇</div>'
  227. )
  228. return "\n".join(parts)
  229. def _render_hard_filter(d: dict) -> str:
  230. count = d.get("after_filter_count", 0)
  231. return (
  232. '<div class="decision-section">'
  233. f'<div class="section-title">📊 过滤结果</div>'
  234. f'<div class="total-line">过滤后剩余: <b>{count}</b> 篇</div>'
  235. '</div>'
  236. )
  237. def _render_coarse_filter(d: dict) -> str:
  238. log = d.get("coarse_log", [])
  239. total = d.get("total_count", len(log))
  240. passed_cnt = d.get("passed_count", 0)
  241. rejected_cnt = d.get("rejected_count", 0)
  242. after_cnt = d.get("after_filter_count", 0)
  243. if not log:
  244. return f'<div class="decision-section">粗筛后剩余: {after_cnt} 篇</div>'
  245. parts: list[str] = []
  246. # 统计概览
  247. parts.append(
  248. '<div class="decision-section">'
  249. f'<div class="section-title">📊 粗筛统计</div>'
  250. f'<span class="stat-pill stat-accept">通过 {passed_cnt}</span>'
  251. f'<span class="stat-pill stat-reject">淘汰 {rejected_cnt}</span>'
  252. '</div>'
  253. )
  254. # 通过的文章
  255. passed = [r for r in log if r.get("status") == "pass"]
  256. if passed:
  257. rows = []
  258. for idx, r in enumerate(passed, 1):
  259. title = _esc(r.get("title", ""))
  260. url = _esc(r.get("url", ""))
  261. reason = _esc(r.get("reason", ""))
  262. src_kw = _esc(r.get("source_keyword", ""))
  263. rows.append(
  264. f'<tr class="row-accept">'
  265. f'<td class="num-cell">{idx}</td>'
  266. f'<td class="article-title-cell"><a href="{url}" target="_blank">{title}</a></td>'
  267. f'<td><code>{src_kw}</code></td>'
  268. f'<td class="reason-full-cell">{reason}</td>'
  269. f'</tr>'
  270. )
  271. parts.append(
  272. '<div class="decision-section">'
  273. f'<div class="section-title">✅ 通过文章({len(passed)} 篇)</div>'
  274. '<table class="decision-table review-table">'
  275. '<thead><tr><th>#</th><th>标题</th><th>来源词</th><th>理由</th></tr></thead>'
  276. '<tbody>' + "\n".join(rows) + '</tbody>'
  277. '</table></div>'
  278. )
  279. # 淘汰的文章
  280. rejected = [r for r in log if r.get("status") == "reject"]
  281. if rejected:
  282. rows = []
  283. for idx, r in enumerate(rejected, 1):
  284. title = _esc(r.get("title", ""))
  285. url = _esc(r.get("url", ""))
  286. reason = _esc(r.get("reason", ""))
  287. src_kw = _esc(r.get("source_keyword", ""))
  288. rows.append(
  289. f'<tr class="row-reject">'
  290. f'<td class="num-cell">{idx}</td>'
  291. f'<td class="article-title-cell"><a href="{url}" target="_blank">{title}</a></td>'
  292. f'<td><code>{src_kw}</code></td>'
  293. f'<td class="reason-full-cell">{reason}</td>'
  294. f'</tr>'
  295. )
  296. parts.append(
  297. '<div class="decision-section">'
  298. f'<div class="section-title">❌ 淘汰文章({len(rejected)} 篇)</div>'
  299. '<table class="decision-table review-table">'
  300. '<thead><tr><th>#</th><th>标题</th><th>来源词</th><th>理由</th></tr></thead>'
  301. '<tbody>' + "\n".join(rows) + '</tbody>'
  302. '</table></div>'
  303. )
  304. parts.append(
  305. f'<div class="total-line">粗筛后剩余: <b>{after_cnt}</b> 篇</div>'
  306. )
  307. return "\n".join(parts)
  308. def _render_quality_filter(d: dict) -> str:
  309. reviews = d.get("review_log", [])
  310. accepted_cnt = d.get("accepted_count", 0)
  311. rejected_cnt = d.get("rejected_count", 0)
  312. skipped_cnt = d.get("skipped_count", 0)
  313. final_cnt = d.get("final_filtered_count", 0)
  314. score_config = d.get("score_config", {})
  315. match_terms = d.get("match_terms", [])
  316. if not reviews:
  317. return f'<div class="decision-section">最终入选: {final_cnt} 篇</div>'
  318. parts: list[str] = []
  319. # 评分配置
  320. if score_config:
  321. cfg_rows: list[str] = []
  322. for key, label in [
  323. ("min_body_length", "最小正文长度"),
  324. ("high_relevance_ratio", "高相关性阈值"),
  325. ("view_count_threshold", "阅读量阈值"),
  326. ("engage_rate_threshold", "互动率阈值"),
  327. ("spam_keywords_count", "标题党关键词数"),
  328. ]:
  329. val = score_config.get(key, "")
  330. if val != "":
  331. cfg_rows.append(
  332. f'<tr><td class="feature-label">{label}</td>'
  333. f'<td><code>{_esc(str(val))}</code></td></tr>'
  334. )
  335. if cfg_rows:
  336. parts.append(
  337. '<div class="decision-section">'
  338. '<div class="section-title">⚙️ 评分配置</div>'
  339. '<table class="decision-table">'
  340. + "\n".join(cfg_rows)
  341. + '</table></div>'
  342. )
  343. # 匹配词表
  344. if match_terms:
  345. tags = " ".join(f'<span class="tag tag-blue">{_esc(t)}</span>' for t in match_terms)
  346. parts.append(
  347. '<div class="decision-section">'
  348. f'<div class="section-title">🔑 匹配词表({len(match_terms)} 个)</div>'
  349. f'{tags}'
  350. '</div>'
  351. )
  352. # 统计概览
  353. parts.append(
  354. '<div class="decision-section">'
  355. f'<div class="section-title">📊 审核统计</div>'
  356. f'<span class="stat-pill stat-accept">入选 {accepted_cnt}</span>'
  357. f'<span class="stat-pill stat-reject">淘汰 {rejected_cnt}</span>'
  358. + (f'<span class="stat-pill stat-skip">跳过 {skipped_cnt}</span>' if skipped_cnt else '')
  359. + '</div>'
  360. )
  361. review_table_head = (
  362. '<thead><tr>'
  363. '<th>#</th><th>标题</th><th>来源词</th><th>相关性</th><th>兴趣</th><th>阶段</th>'
  364. '<th>发布日期</th><th>正文长度</th><th>阅读</th><th>点赞</th><th>分享</th><th>在看</th>'
  365. '<th>原因</th>'
  366. '</tr></thead>'
  367. )
  368. # 入选文章
  369. accepted = [r for r in reviews if r.get("status") == "accept"]
  370. accepted.sort(key=lambda r: int(r.get("view_count", 0) or 0), reverse=True)
  371. if accepted:
  372. rows = []
  373. for idx, r in enumerate(accepted, 1):
  374. title = _esc(r.get("title", ""))
  375. url = _esc(r.get("url", ""))
  376. relevance = _esc(r.get("relevance", ""))
  377. interest = _esc(r.get("interest", ""))
  378. reason = _esc(r.get("reason", ""))
  379. phase = "LLM" if r.get("phase") == "llm" else "启发式"
  380. pt = r.get("publish_time", 0)
  381. pt_str = _ts_readable(pt) if pt else "未知"
  382. view = r.get("view_count", 0)
  383. like = r.get("like_count", 0)
  384. share = r.get("share_count", 0)
  385. looking = r.get("looking_count", 0)
  386. body_len = r.get("body_length", "-")
  387. src_kw = _esc(r.get("source_keyword", ""))
  388. rows.append(
  389. f'<tr class="row-accept">'
  390. f'<td class="num-cell">{idx}</td>'
  391. f'<td class="article-title-cell"><a href="{url}" target="_blank">{title}</a></td>'
  392. f'<td><code>{src_kw}</code></td>'
  393. f'<td class="score-cell">{relevance}</td>'
  394. f'<td class="score-cell">{interest}</td>'
  395. f'<td><span class="phase-badge">{phase}</span></td>'
  396. f'<td class="date-cell">{_esc(pt_str)}</td>'
  397. f'<td class="num-cell">{body_len}</td>'
  398. f'<td class="num-cell">{view}</td>'
  399. f'<td class="num-cell">{like}</td>'
  400. f'<td class="num-cell">{share}</td>'
  401. f'<td class="num-cell">{looking}</td>'
  402. f'<td class="reason-full-cell">{reason}</td>'
  403. f'</tr>'
  404. )
  405. parts.append(
  406. '<div class="decision-section">'
  407. f'<div class="section-title">✅ 入选文章({len(accepted)} 篇)</div>'
  408. '<table class="decision-table review-table">'
  409. + review_table_head +
  410. '<tbody>' + "\n".join(rows) + '</tbody>'
  411. '</table></div>'
  412. )
  413. # 淘汰文章
  414. rejected = [r for r in reviews if r.get("status") == "reject"]
  415. if rejected:
  416. rows = []
  417. for idx, r in enumerate(rejected, 1):
  418. title = _esc(r.get("title", ""))
  419. url = _esc(r.get("url", ""))
  420. relevance = _esc(r.get("relevance", ""))
  421. interest = _esc(r.get("interest", ""))
  422. reason = _esc(r.get("reason", ""))
  423. phase = "LLM" if r.get("phase") == "llm" else "启发式"
  424. pt = r.get("publish_time", 0)
  425. pt_str = _ts_readable(pt) if pt else "未知"
  426. view = r.get("view_count", 0)
  427. like = r.get("like_count", 0)
  428. share = r.get("share_count", 0)
  429. looking = r.get("looking_count", 0)
  430. body_len = r.get("body_length", "-")
  431. src_kw = _esc(r.get("source_keyword", ""))
  432. rows.append(
  433. f'<tr class="row-reject">'
  434. f'<td class="num-cell">{idx}</td>'
  435. f'<td class="article-title-cell"><a href="{url}" target="_blank">{title}</a></td>'
  436. f'<td><code>{src_kw}</code></td>'
  437. f'<td class="score-cell">{relevance}</td>'
  438. f'<td class="score-cell">{interest}</td>'
  439. f'<td><span class="phase-badge">{phase}</span></td>'
  440. f'<td class="date-cell">{_esc(pt_str)}</td>'
  441. f'<td class="num-cell">{body_len}</td>'
  442. f'<td class="num-cell">{view}</td>'
  443. f'<td class="num-cell">{like}</td>'
  444. f'<td class="num-cell">{share}</td>'
  445. f'<td class="num-cell">{looking}</td>'
  446. f'<td class="reason-full-cell">{reason}</td>'
  447. f'</tr>'
  448. )
  449. parts.append(
  450. '<div class="decision-section">'
  451. f'<div class="section-title">❌ 淘汰文章({len(rejected)} 篇)</div>'
  452. '<table class="decision-table review-table">'
  453. + review_table_head +
  454. '<tbody>' + "\n".join(rows) + '</tbody>'
  455. '</table></div>'
  456. )
  457. # 跳过文章
  458. skipped = [r for r in reviews if r.get("status") == "skip"]
  459. if skipped:
  460. rows = []
  461. for idx, r in enumerate(skipped, 1):
  462. title = _esc(r.get("title", ""))
  463. url = _esc(r.get("url", ""))
  464. reason = _esc(r.get("reason", ""))
  465. phase = "LLM" if r.get("phase") == "llm" else "启发式"
  466. pt = r.get("publish_time", 0)
  467. pt_str = _ts_readable(pt) if pt else "未知"
  468. view = r.get("view_count", 0)
  469. src_kw = _esc(r.get("source_keyword", ""))
  470. rows.append(
  471. f'<tr class="row-skip">'
  472. f'<td class="num-cell">{idx}</td>'
  473. f'<td class="article-title-cell"><a href="{url}" target="_blank">{title}</a></td>'
  474. f'<td><code>{src_kw}</code></td>'
  475. f'<td class="score-cell">-</td>'
  476. f'<td class="score-cell">-</td>'
  477. f'<td><span class="phase-badge">{phase}</span></td>'
  478. f'<td class="date-cell">{_esc(pt_str)}</td>'
  479. f'<td class="num-cell">-</td>'
  480. f'<td class="num-cell">{view}</td>'
  481. f'<td class="num-cell">-</td>'
  482. f'<td class="num-cell">-</td>'
  483. f'<td class="num-cell">-</td>'
  484. f'<td class="reason-full-cell">{reason}</td>'
  485. f'</tr>'
  486. )
  487. parts.append(
  488. '<div class="decision-section">'
  489. f'<div class="section-title">⏭️ 跳过文章({len(skipped)} 篇)</div>'
  490. '<table class="decision-table review-table">'
  491. + review_table_head +
  492. '<tbody>' + "\n".join(rows) + '</tbody>'
  493. '</table></div>'
  494. )
  495. parts.append(
  496. f'<div class="total-line">最终入选: <b>{final_cnt}</b> 篇</div>'
  497. )
  498. return "\n".join(parts)
  499. def _render_account_precipitate(d: dict) -> str:
  500. accounts = d.get("accounts", [])
  501. if not accounts:
  502. return '<div class="decision-section">聚合账号: 0 个</div>'
  503. rows = []
  504. for acc in accounts:
  505. name = _esc(acc.get("account_name", ""))
  506. count = acc.get("article_count", 0)
  507. samples = acc.get("sample_articles", [])
  508. sample_html = ""
  509. if samples:
  510. sample_html = '<div class="sample-titles">' + ", ".join(
  511. _esc(s) for s in samples[:3]
  512. ) + '</div>'
  513. rows.append(
  514. f'<tr><td><b>{name}</b></td>'
  515. f'<td class="num-cell">{count} 篇</td>'
  516. f'<td>{sample_html}</td></tr>'
  517. )
  518. return (
  519. '<div class="decision-section">'
  520. '<div class="section-title">👤 聚合账号</div>'
  521. '<table class="decision-table acct-table">'
  522. '<thead><tr><th>账号名</th><th>文章数</th><th>示例标题</th></tr></thead>'
  523. '<tbody>' + "\n".join(rows) + '</tbody>'
  524. '</table></div>'
  525. )
  526. def _render_output_persist(d: dict) -> str:
  527. path = d.get("output_file", "")
  528. if not path:
  529. return ""
  530. return (
  531. '<div class="decision-section">'
  532. f'<div class="section-title">📄 输出文件</div>'
  533. f'<code class="file-path">{_esc(path)}</code>'
  534. '</div>'
  535. )
  536. _DECISION_RENDERERS = {
  537. "demand_analysis": _render_demand_analysis,
  538. "content_search": _render_content_search,
  539. "hard_filter": _render_hard_filter,
  540. "coarse_filter": _render_coarse_filter,
  541. "quality_filter": _render_quality_filter,
  542. "account_precipitate": _render_account_precipitate,
  543. "output_persist": _render_output_persist,
  544. }
  545. # ─────────────────────────────────────────────────────────────
  546. # LLM 交互追踪渲染
  547. # ─────────────────────────────────────────────────────────────
  548. def _render_llm_interactions(interactions: list[dict]) -> str:
  549. """渲染阶段内所有 LLM 交互记录(折叠卡片,展示思考过程)。"""
  550. if not interactions:
  551. return ""
  552. sections: list[str] = []
  553. for idx, ix in enumerate(interactions, 1):
  554. name = _esc(ix.get("name", "LLM 调用"))
  555. model = _esc(ix.get("model", ""))
  556. duration_ms = ix.get("duration_ms", 0)
  557. tokens = ix.get("tokens", 0)
  558. dur_label = _duration_label(duration_ms)
  559. parts: list[str] = []
  560. # 输入 Prompt
  561. messages = ix.get("messages", [])
  562. for msg in messages:
  563. role = msg.get("role", "")
  564. content = msg.get("content", "")
  565. if not content:
  566. continue
  567. content_str = str(content) if not isinstance(content, str) else content
  568. role_icon = {"system": "📜", "user": "👤", "assistant": "🤖"}.get(role, "💬")
  569. role_label = {"system": "System Prompt", "user": "User Prompt", "assistant": "Assistant"}.get(role, role)
  570. if len(content_str) > 500:
  571. parts.append(
  572. f'<details class="llm-msg llm-msg-{_esc(role)}">'
  573. f'<summary>{role_icon} {role_label} ({len(content_str)} 字)</summary>'
  574. f'<pre class="llm-msg-pre">{_esc(content_str)}</pre>'
  575. f'</details>'
  576. )
  577. else:
  578. parts.append(
  579. f'<div class="llm-msg llm-msg-{_esc(role)}">'
  580. f'<div class="llm-msg-header">{role_icon} {role_label}</div>'
  581. f'<pre class="llm-msg-pre">{_esc(content_str)}</pre>'
  582. f'</div>'
  583. )
  584. # 推理过程(reasoning)
  585. reasoning = ix.get("reasoning", "")
  586. if reasoning:
  587. parts.append(
  588. f'<details class="llm-reasoning" open>'
  589. f'<summary>🧠 LLM 推理过程 ({len(reasoning)} 字)</summary>'
  590. f'<pre class="llm-msg-pre llm-reasoning-text">{_esc(reasoning)}</pre>'
  591. f'</details>'
  592. )
  593. # 工具调用
  594. tool_calls = ix.get("tool_calls") or []
  595. for tc in tool_calls:
  596. tool_name = _esc(tc.get("tool_name", ""))
  597. args = tc.get("arguments", "")
  598. try:
  599. args_obj = json.loads(args) if isinstance(args, str) else args
  600. args_formatted = json.dumps(args_obj, ensure_ascii=False, indent=2)
  601. except Exception:
  602. args_formatted = str(args)
  603. result_preview = tc.get("result_preview", "")
  604. parts.append(
  605. f'<div class="llm-tool-call">'
  606. f'<div class="llm-tool-name">🔧 <code>{tool_name}</code></div>'
  607. f'<pre class="llm-tool-args">{_esc(args_formatted)}</pre>'
  608. )
  609. if result_preview:
  610. parts.append(
  611. f'<details class="llm-tool-result">'
  612. f'<summary>📤 返回结果 ({len(result_preview)} 字)</summary>'
  613. f'<pre class="llm-msg-pre">{_esc(result_preview)}</pre>'
  614. f'</details>'
  615. )
  616. parts.append('</div>')
  617. # LLM 回复
  618. response_text = ix.get("response_text", "")
  619. if response_text:
  620. if len(response_text) > 2000:
  621. parts.append(
  622. f'<details class="llm-response">'
  623. f'<summary>💬 LLM 回复 ({len(response_text)} 字)</summary>'
  624. f'<pre class="llm-msg-pre">{_esc(response_text)}</pre>'
  625. f'</details>'
  626. )
  627. else:
  628. parts.append(
  629. f'<div class="llm-response">'
  630. f'<div class="llm-msg-header">💬 LLM 回复</div>'
  631. f'<pre class="llm-msg-pre">{_esc(response_text)}</pre>'
  632. f'</div>'
  633. )
  634. inner = "\n".join(parts)
  635. meta_parts = []
  636. if model:
  637. meta_parts.append(f'<code>{model}</code>')
  638. if dur_label:
  639. meta_parts.append(dur_label)
  640. if tokens:
  641. meta_parts.append(f'{tokens} tokens')
  642. meta_html = " · ".join(meta_parts)
  643. sections.append(
  644. f'<details class="llm-interaction-card" open>'
  645. f'<summary>🧪 LLM 交互 #{idx}: {name}'
  646. f'<span class="llm-interaction-meta"> ({meta_html})</span>'
  647. f'</summary>'
  648. f'<div class="llm-interaction-body">{inner}</div>'
  649. f'</details>'
  650. )
  651. return "\n".join(sections)
  652. # ─────────────────────────────────────────────────────────────
  653. # Agent Trace 渲染
  654. # ─────────────────────────────────────────────────────────────
  655. def _load_agent_trace(trace_id: str) -> tuple[list[dict], dict]:
  656. """读取 agent 子任务的 events.jsonl 和 meta.json。"""
  657. agent_dir = TRACES_DIR / trace_id
  658. events: list[dict] = []
  659. meta: dict = {}
  660. events_path = agent_dir / "events.jsonl"
  661. meta_path = agent_dir / "meta.json"
  662. if events_path.exists():
  663. events = read_jsonl(events_path)
  664. if meta_path.exists():
  665. try:
  666. meta = json.loads(meta_path.read_text(encoding="utf-8"))
  667. except Exception:
  668. pass
  669. return events, meta
  670. def _render_agent_meta(meta: dict) -> str:
  671. """渲染 agent 元信息摘要。"""
  672. task = meta.get("task", "")
  673. model = meta.get("model", "")
  674. status = meta.get("status", "")
  675. total_tokens = meta.get("total_tokens", 0)
  676. total_cost = meta.get("total_cost", 0)
  677. created = meta.get("created_at", "")[:19]
  678. completed = meta.get("completed_at", "")[:19]
  679. duration = _calc_duration(created, completed) if created and completed else "N/A"
  680. status_color = "var(--green)" if status == "completed" else "var(--red)"
  681. return (
  682. f'<div class="agent-meta">'
  683. f'<span class="agent-meta-item">任务: <b>{_esc(task)}</b></span>'
  684. f'<span class="agent-meta-item">模型: <code>{_esc(model)}</code></span>'
  685. f'<span class="agent-meta-item">状态: <span style="color:{status_color}">{_esc(status)}</span></span>'
  686. f'<span class="agent-meta-item">耗时: {_esc(duration)}</span>'
  687. f'<span class="agent-meta-item">Tokens: {total_tokens}</span>'
  688. f'<span class="agent-meta-item">Cost: ${total_cost:.4f}</span>'
  689. f'</div>'
  690. )
  691. def _render_agent_message(msg: dict) -> str:
  692. """渲染单条 agent 消息。"""
  693. role = msg.get("role", "")
  694. content = msg.get("content", "")
  695. tool_call_id = msg.get("tool_call_id", "")
  696. ts = msg.get("created_at", "")[:19]
  697. tokens = msg.get("tokens", 0)
  698. if role == "system":
  699. # System prompt 折叠显示,只展示前 200 字
  700. text = content if isinstance(content, str) else str(content)
  701. preview = text[:200] + "..." if len(text) > 200 else text
  702. return (
  703. f'<details class="agent-msg agent-msg-system">'
  704. f'<summary>📜 System Prompt ({len(text)} 字)</summary>'
  705. f'<pre class="agent-msg-pre">{_esc(text)}</pre>'
  706. f'</details>'
  707. )
  708. elif role == "user":
  709. text = content if isinstance(content, str) else str(content)
  710. # User prompt 折叠,如果太长
  711. if len(text) > 500:
  712. return (
  713. f'<details class="agent-msg agent-msg-user">'
  714. f'<summary>👤 User Prompt ({len(text)} 字)</summary>'
  715. f'<pre class="agent-msg-pre">{_esc(text)}</pre>'
  716. f'</details>'
  717. )
  718. return (
  719. f'<div class="agent-msg agent-msg-user">'
  720. f'<div class="agent-msg-header">👤 User</div>'
  721. f'<pre class="agent-msg-pre">{_esc(text)}</pre>'
  722. f'</div>'
  723. )
  724. elif role == "assistant":
  725. parts: list[str] = []
  726. # 提取文本和工具调用
  727. if isinstance(content, dict):
  728. text = content.get("text", "") or ""
  729. tool_calls = content.get("tool_calls", []) or []
  730. reasoning = content.get("reasoning_content", "") or ""
  731. else:
  732. text = str(content or "")
  733. tool_calls = []
  734. reasoning = ""
  735. token_badge = f'<span class="agent-token-badge">{tokens} tokens</span>' if tokens else ""
  736. # 推理内容
  737. if reasoning:
  738. parts.append(
  739. f'<details class="agent-reasoning">'
  740. f'<summary>🧠 推理过程</summary>'
  741. f'<pre class="agent-msg-pre">{_esc(reasoning)}</pre>'
  742. f'</details>'
  743. )
  744. # 文本回复
  745. if text:
  746. # 截断过长文本,折叠显示
  747. if len(text) > 2000:
  748. parts.append(
  749. f'<details class="agent-thinking">'
  750. f'<summary>💭 回复 ({len(text)} 字)</summary>'
  751. f'<pre class="agent-msg-pre">{_esc(text)}</pre>'
  752. f'</details>'
  753. )
  754. else:
  755. parts.append(
  756. f'<div class="agent-thinking">'
  757. f'<pre class="agent-msg-pre">{_esc(text)}</pre>'
  758. f'</div>'
  759. )
  760. # 工具调用
  761. for tc in tool_calls:
  762. fn = tc.get("function", {})
  763. fn_name = fn.get("name", "?")
  764. fn_args = fn.get("arguments", "")
  765. # 格式化 JSON 参数
  766. try:
  767. args_obj = json.loads(fn_args) if isinstance(fn_args, str) else fn_args
  768. fn_args_formatted = json.dumps(args_obj, ensure_ascii=False, indent=2)
  769. except Exception:
  770. fn_args_formatted = str(fn_args)
  771. parts.append(
  772. f'<div class="agent-tool-call">'
  773. f'<div class="agent-tool-name">🔧 <code>{_esc(fn_name)}</code></div>'
  774. f'<pre class="agent-tool-args">{_esc(fn_args_formatted)}</pre>'
  775. f'</div>'
  776. )
  777. inner = "\n".join(parts)
  778. return (
  779. f'<div class="agent-msg agent-msg-assistant">'
  780. f'<div class="agent-msg-header">🤖 Assistant {token_badge}</div>'
  781. f'{inner}'
  782. f'</div>'
  783. )
  784. elif role == "tool":
  785. # 工具返回
  786. if isinstance(content, dict):
  787. tool_name = content.get("tool_name", "")
  788. result = content.get("result", "")
  789. else:
  790. tool_name = ""
  791. result = str(content or "")
  792. result_str = str(result)
  793. if len(result_str) > 1500:
  794. return (
  795. f'<details class="agent-msg agent-msg-tool">'
  796. f'<summary>📤 {_esc(tool_name)} 返回 ({len(result_str)} 字)</summary>'
  797. f'<pre class="agent-msg-pre">{_esc(result_str)}</pre>'
  798. f'</details>'
  799. )
  800. return (
  801. f'<div class="agent-msg agent-msg-tool">'
  802. f'<div class="agent-msg-header">📤 {_esc(tool_name)}</div>'
  803. f'<pre class="agent-msg-pre">{_esc(result_str)}</pre>'
  804. f'</div>'
  805. )
  806. return ""
  807. def _render_agent_trace_section(agent_trace_ids: list[str]) -> str:
  808. """渲染阶段内所有 agent 子任务的执行详情(折叠卡片)。"""
  809. if not agent_trace_ids:
  810. return ""
  811. sections: list[str] = []
  812. for tid in agent_trace_ids:
  813. agent_events, meta = _load_agent_trace(tid)
  814. if not agent_events and not meta:
  815. continue
  816. task_name = meta.get("task", tid[:8])
  817. total_tokens = meta.get("total_tokens", 0)
  818. total_cost = meta.get("total_cost", 0)
  819. # 渲染元信息
  820. meta_html = _render_agent_meta(meta) if meta else ""
  821. # 渲染消息序列
  822. msg_blocks: list[str] = []
  823. for ev in agent_events:
  824. if ev.get("event") == "message_added":
  825. msg = ev.get("message", {})
  826. rendered = _render_agent_message(msg)
  827. if rendered:
  828. msg_blocks.append(rendered)
  829. elif ev.get("event") == "goal_added":
  830. goal = ev.get("goal", {})
  831. goal_desc = goal.get("description", "")
  832. msg_blocks.append(
  833. f'<div class="agent-msg agent-msg-goal">'
  834. f'🎯 目标创建: <b>{_esc(goal_desc)}</b>'
  835. f'</div>'
  836. )
  837. messages_html = "\n".join(msg_blocks)
  838. sections.append(
  839. f'<details class="agent-trace-card">'
  840. f'<summary>🤖 Agent 执行详情: {_esc(task_name)}'
  841. f'<span class="agent-trace-summary"> ({total_tokens} tokens · ${total_cost:.4f})</span>'
  842. f'</summary>'
  843. f'<div class="agent-trace-body">'
  844. f'{meta_html}'
  845. f'<div class="agent-messages">{messages_html}</div>'
  846. f'</div>'
  847. f'</details>'
  848. )
  849. return "\n".join(sections)
  850. def _parse_log_lines(log_text: str) -> list[dict]:
  851. """将 full_log.log 文本解析为结构化行列表,供 HTML 渲染。"""
  852. lines: list[dict] = []
  853. for raw in log_text.splitlines():
  854. level = "STDOUT"
  855. if "| DEBUG |" in raw:
  856. level = "DEBUG"
  857. elif "| INFO |" in raw:
  858. level = "INFO"
  859. elif "| WARNING |" in raw:
  860. level = "WARNING"
  861. elif "| ERROR |" in raw:
  862. level = "ERROR"
  863. elif "| CRITICAL|" in raw:
  864. level = "CRITICAL"
  865. lines.append({"level": level, "text": raw})
  866. return lines
  867. def _render_full_log_section(log_lines: list[dict]) -> str:
  868. """生成完整日志面板的 HTML(带过滤、搜索、行号、颜色)。"""
  869. if not log_lines:
  870. return ""
  871. level_counts = {}
  872. for ln in log_lines:
  873. level_counts[ln["level"]] = level_counts.get(ln["level"], 0) + 1
  874. rows: list[str] = []
  875. for idx, ln in enumerate(log_lines, 1):
  876. lvl = ln["level"].lower()
  877. text = _esc(ln["text"])
  878. rows.append(
  879. f'<tr class="log-row log-{lvl}" data-level="{ln["level"]}">'
  880. f'<td class="log-ln">{idx}</td>'
  881. f'<td class="log-txt">{text}</td>'
  882. f'</tr>'
  883. )
  884. filter_buttons: list[str] = []
  885. filter_buttons.append(
  886. f'<button class="log-btn log-btn-active" data-filter="ALL">ALL ({len(log_lines)})</button>'
  887. )
  888. for lvl in ("DEBUG", "INFO", "WARNING", "ERROR", "STDOUT"):
  889. cnt = level_counts.get(lvl, 0)
  890. if cnt:
  891. filter_buttons.append(
  892. f'<button class="log-btn" data-filter="{lvl}">{lvl} ({cnt})</button>'
  893. )
  894. return (
  895. '<div class="log-panel">'
  896. '<h2 class="log-title">📋 完整执行日志</h2>'
  897. '<div class="log-toolbar">'
  898. '<div class="log-filters">' + "".join(filter_buttons) + '</div>'
  899. '<input type="text" class="log-search" placeholder="🔍 搜索日志..." id="logSearch" />'
  900. '</div>'
  901. '<div class="log-container">'
  902. '<table class="log-table"><tbody id="logBody">'
  903. + "\n".join(rows) +
  904. '</tbody></table>'
  905. '</div>'
  906. '</div>'
  907. )
  908. _LOG_VIEWER_CSS = """
  909. /* ── 日志面板 ── */
  910. .log-panel { margin-top:32px; border:1px solid var(--border); border-radius:10px; background:var(--bg2); overflow:hidden; }
  911. .log-title { font-size:18px; color:var(--blue); padding:16px 20px 0; margin:0; letter-spacing:-0.3px; }
  912. .log-toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; padding:12px 20px; border-bottom:1px solid var(--border); }
  913. .log-filters { display:flex; gap:4px; flex-wrap:wrap; }
  914. .log-btn {
  915. background:rgba(139,148,158,.08); border:1px solid var(--border); border-radius:6px;
  916. padding:3px 10px; font-size:11px; color:var(--dim); cursor:pointer; transition:all .15s;
  917. }
  918. .log-btn:hover { background:rgba(88,166,255,.1); color:var(--blue); border-color:rgba(88,166,255,.3); }
  919. .log-btn-active { background:rgba(88,166,255,.15); color:var(--blue); border-color:rgba(88,166,255,.4); }
  920. .log-search {
  921. flex:1; min-width:180px; background:var(--bg); border:1px solid var(--border); border-radius:6px;
  922. padding:4px 10px; font-size:12px; color:var(--text); outline:none;
  923. }
  924. .log-search:focus { border-color:var(--blue); }
  925. .log-container { max-height:calc(100vh - 200px); overflow-y:auto; }
  926. .log-table { width:100%; border-collapse:collapse; font-family:"SF Mono",Monaco,Menlo,monospace; font-size:11px; }
  927. .log-row { border-bottom:1px solid rgba(33,38,45,.3); }
  928. .log-row.log-hidden { display:none; }
  929. .log-ln { width:50px; text-align:right; padding:2px 8px 2px 4px; color:rgba(139,148,158,.4); user-select:none; vertical-align:top; }
  930. .log-txt { padding:2px 8px; white-space:pre-wrap; word-break:break-all; line-height:1.5; }
  931. .log-debug .log-txt { color:var(--dim); }
  932. .log-info .log-txt { color:var(--text); }
  933. .log-warning .log-txt { color:var(--yellow); }
  934. .log-error .log-txt, .log-critical .log-txt { color:var(--red); }
  935. .log-stdout .log-txt { color:var(--purple); }
  936. .log-highlight { background:rgba(227,179,65,.15); }
  937. """
  938. _LOG_VIEWER_JS = """
  939. <script>
  940. (function(){
  941. var activeFilter = 'ALL';
  942. var searchTerm = '';
  943. function applyFilters(){
  944. var rows = document.querySelectorAll('.log-row');
  945. var term = searchTerm.toLowerCase();
  946. rows.forEach(function(row){
  947. var level = row.getAttribute('data-level');
  948. var text = row.querySelector('.log-txt').textContent.toLowerCase();
  949. var matchLevel = (activeFilter === 'ALL' || level === activeFilter);
  950. var matchSearch = (!term || text.indexOf(term) !== -1);
  951. if(matchLevel && matchSearch){
  952. row.classList.remove('log-hidden');
  953. if(term){
  954. row.classList.add('log-highlight');
  955. } else {
  956. row.classList.remove('log-highlight');
  957. }
  958. } else {
  959. row.classList.add('log-hidden');
  960. row.classList.remove('log-highlight');
  961. }
  962. });
  963. }
  964. document.querySelectorAll('.log-btn').forEach(function(btn){
  965. btn.addEventListener('click', function(){
  966. document.querySelectorAll('.log-btn').forEach(function(b){ b.classList.remove('log-btn-active'); });
  967. btn.classList.add('log-btn-active');
  968. activeFilter = btn.getAttribute('data-filter');
  969. applyFilters();
  970. });
  971. });
  972. var searchInput = document.getElementById('logSearch');
  973. if(searchInput){
  974. var debounce;
  975. searchInput.addEventListener('input', function(){
  976. clearTimeout(debounce);
  977. debounce = setTimeout(function(){
  978. searchTerm = searchInput.value;
  979. applyFilters();
  980. }, 200);
  981. });
  982. }
  983. })();
  984. </script>
  985. """
  986. def render_html(events: list[dict], full_log_lines: list[dict] | None = None) -> str:
  987. init_ev = next((e for e in events if e["type"] == "init"), None)
  988. complete_ev = next((e for e in events if e["type"] == "complete"), None)
  989. query = init_ev.get("query", "") if init_ev else ""
  990. model = init_ev.get("model", "") if init_ev else ""
  991. demand_id = init_ev.get("demand_id", "") if init_ev else ""
  992. trace_id = init_ev.get("trace_id", "") if init_ev else ""
  993. target_count = init_ev.get("target_count", 0) if init_ev else 0
  994. timestamps = [e.get("ts", "") for e in events if e.get("ts")]
  995. start_ts = timestamps[0] if timestamps else ""
  996. end_ts = timestamps[-1] if timestamps else ""
  997. duration = _calc_duration(start_ts, end_ts) if start_ts and end_ts else "N/A"
  998. stage_completes = [e for e in events if e["type"] == "stage_complete"]
  999. gate_checks = [e for e in events if e["type"] == "gate_check"]
  1000. error_events = [e for e in events if e["type"] == "error"]
  1001. final_stats = complete_ev.get("stats", {}) if complete_ev else {}
  1002. candidate_count = final_stats.get("candidate_count", 0)
  1003. filtered_count = final_stats.get("filtered_count", 0)
  1004. account_count = final_stats.get("account_count", 0)
  1005. output_file = complete_ev.get("output_file", "") if complete_ev else ""
  1006. pipeline_status = complete_ev.get("status", "unknown") if complete_ev else "running"
  1007. ordered_stages = list(_STAGE_LABELS.keys())
  1008. stage_order_map = {name: idx + 1 for idx, name in enumerate(ordered_stages)}
  1009. # ── 构建事件块 ──
  1010. blocks: list[str] = []
  1011. for ev in events:
  1012. t = ev["type"]
  1013. ts = _ts(ev.get("ts", ""))
  1014. if t == "init":
  1015. # 基础信息
  1016. init_html_parts = [
  1017. f'<div class="ev-init">',
  1018. f'<div class="ev-label">🚀 Pipeline 启动</div>',
  1019. f'<div class="kv"><span class="k">查询词</span><span class="v">{_esc(query)}</span></div>',
  1020. f'<div class="kv"><span class="k">模型</span><span class="v">{_esc(model)}</span></div>',
  1021. f'<div class="kv"><span class="k">需求ID</span><span class="v">{_esc(str(demand_id))}</span></div>',
  1022. f'<div class="kv"><span class="k">目标条数</span><span class="v">{target_count}</span></div>',
  1023. f'<div class="kv"><span class="k">trace_id</span><span class="v mono">{_esc(trace_id)}</span></div>',
  1024. f'<div class="kv"><span class="k">时间</span><span class="v">{_esc(ts)}</span></div>',
  1025. ]
  1026. # 执行计划(来自 Harness)
  1027. rp = ev.get("run_plan")
  1028. if rp:
  1029. init_html_parts.append('<hr style="border-color:#444; margin:12px 0;">')
  1030. init_html_parts.append('<div class="ev-label" style="margin-top:4px;">📋 执行计划</div>')
  1031. init_html_parts.append(
  1032. f'<div class="kv"><span class="k">超时上限</span>'
  1033. f'<span class="v">{rp.get("timeout_seconds", "N/A")} 秒</span></div>'
  1034. )
  1035. init_html_parts.append(
  1036. f'<div class="kv"><span class="k">目标文章上限</span>'
  1037. f'<span class="v">{rp.get("max_target_count", "N/A")} 篇</span></div>'
  1038. )
  1039. init_html_parts.append(
  1040. f'<div class="kv"><span class="k">最大补召回轮次</span>'
  1041. f'<span class="v">{rp.get("max_fallback_rounds", "N/A")} 轮</span></div>'
  1042. )
  1043. plan_stages = rp.get("stages", [])
  1044. if plan_stages:
  1045. init_html_parts.append(
  1046. '<div style="margin-top:8px; font-size:13px; color:#aaa;">阶段规划:</div>'
  1047. )
  1048. for idx, ps in enumerate(plan_stages, 1):
  1049. sname = _esc(ps.get("name", ""))
  1050. slabel = _esc(ps.get("label", ""))
  1051. sicon = _STAGE_LABELS.get(ps.get("name", ""), sname)
  1052. gate = ps.get("gate", "")
  1053. gate_html = (
  1054. f' <span style="color:#e0a040; font-size:12px;">'
  1055. f'└─ Gate: {_esc(gate)}</span>'
  1056. ) if gate else ""
  1057. init_html_parts.append(
  1058. f'<div style="padding:2px 0 2px 16px; font-size:13px;">'
  1059. f'<span style="color:#6cf;">{idx}.</span> '
  1060. f'<code style="color:#8be9fd;">{sname}</code> '
  1061. f'<span style="color:#bbb;">← {slabel}</span>'
  1062. f'{gate_html}</div>'
  1063. )
  1064. init_html_parts.append('</div>')
  1065. blocks.append("\n".join(init_html_parts))
  1066. elif t == "stage_start":
  1067. stage = ev.get("stage", "")
  1068. icon = ev.get("icon", "▶")
  1069. label = _STAGE_LABELS.get(stage, stage)
  1070. stage_no = stage_order_map.get(stage, 0)
  1071. blocks.append(
  1072. f'<div class="ev-phase">'
  1073. f'<span class="ts">{_esc(ts)}</span> '
  1074. f'{icon} <h1 class="stage-h1">{stage_no}. {_esc(label)}</h1>'
  1075. f'<span class="stage-name"> ({_esc(stage)})</span>'
  1076. f'</div>'
  1077. )
  1078. elif t == "stage_complete":
  1079. stage = ev.get("stage", "")
  1080. icon = ev.get("icon", "▶")
  1081. label = _STAGE_LABELS.get(stage, stage)
  1082. attempt = ev.get("attempt", 1)
  1083. dur = _duration_label(ev.get("duration_ms"))
  1084. stats = ev.get("stats", {})
  1085. decisions = ev.get("decisions", {})
  1086. agent_trace_ids = ev.get("agent_trace_ids", [])
  1087. llm_interactions = ev.get("llm_interactions", [])
  1088. attempt_badge = f'<span class="badge-retry">重试#{attempt}</span>' if attempt > 1 else ""
  1089. dur_badge = f'<span class="badge-dur">{_esc(dur)}</span>' if dur else ""
  1090. stats_html = (
  1091. f'<span class="stat-pill">候选 {stats.get("candidate_count", 0)}</span>'
  1092. f'<span class="stat-pill">入选 {stats.get("filtered_count", 0)}</span>'
  1093. f'<span class="stat-pill">账号 {stats.get("account_count", 0)}</span>'
  1094. )
  1095. decisions_html = _render_decisions(stage, decisions)
  1096. agent_html = _render_agent_trace_section(agent_trace_ids)
  1097. llm_html = _render_llm_interactions(llm_interactions)
  1098. blocks.append(
  1099. f'<div class="ev-stage-ok">'
  1100. f'<span class="ts">{_esc(ts)}</span> '
  1101. f'✅ {icon} <b>{_esc(label)}</b> 完成 '
  1102. f'{attempt_badge}{dur_badge}'
  1103. f'<div class="stage-stats">{stats_html}</div>'
  1104. f'{llm_html}'
  1105. f'{decisions_html}'
  1106. f'{agent_html}'
  1107. f'</div>'
  1108. )
  1109. elif t == "gate_check":
  1110. gate = ev.get("gate", "")
  1111. gate_label = _GATE_LABELS.get(gate, gate)
  1112. passed = ev.get("passed", False)
  1113. action = ev.get("action", "proceed")
  1114. issues = ev.get("issues", [])
  1115. fallback = ev.get("fallback_stage", "")
  1116. icon = ev.get("icon", "🚦")
  1117. action_color = _ACTION_COLORS.get(action, "var(--dim)")
  1118. status_icon = "✅" if passed else "⚠️"
  1119. issues_html = ""
  1120. if issues:
  1121. issues_html = (
  1122. '<ul class="gate-issues">'
  1123. + "".join(f"<li>{_esc(i)}</li>" for i in issues)
  1124. + "</ul>"
  1125. )
  1126. fallback_html = (
  1127. f'<span class="gate-fallback">→ 回退到 <b>{_esc(fallback)}</b></span>'
  1128. if fallback else ""
  1129. )
  1130. cls = "ev-gate-ok" if passed else "ev-gate-warn"
  1131. blocks.append(
  1132. f'<div class="{cls}">'
  1133. f'<span class="ts">{_esc(ts)}</span> '
  1134. f'{status_icon} {icon} <b>{_esc(gate_label)}</b> '
  1135. f'<span class="gate-action" style="color:{action_color}">[{_esc(action)}]</span>'
  1136. f'{fallback_html}'
  1137. f'{issues_html}'
  1138. f'</div>'
  1139. )
  1140. elif t == "error":
  1141. stage = ev.get("stage", "")
  1142. msg = ev.get("msg", "")
  1143. blocks.append(
  1144. f'<details class="ev-error" open>'
  1145. f'<summary>❌ 错误 @ {_esc(stage)}: {_esc(msg[:200])}</summary>'
  1146. f'<pre>{_esc(msg)}</pre>'
  1147. f'</details>'
  1148. )
  1149. elif t == "complete":
  1150. status = ev.get("status", "unknown")
  1151. stats = ev.get("stats", {})
  1152. stage_count = ev.get("stage_count", 0)
  1153. err_count = ev.get("error_count", 0)
  1154. cls = "ev-complete-ok" if status == "completed" else "ev-complete-fail"
  1155. out_html = (
  1156. f'<div class="kv"><span class="k">输出文件</span>'
  1157. f'<span class="v mono">{_esc(output_file)}</span></div>'
  1158. if output_file else ""
  1159. )
  1160. blocks.append(
  1161. f'<div class="{cls}">'
  1162. f'<div class="ev-label">🏁 Pipeline 结束</div>'
  1163. f'<div class="kv"><span class="k">状态</span><span class="v">{_esc(status)}</span></div>'
  1164. f'<div class="kv"><span class="k">trace_id</span><span class="v mono">{_esc(trace_id)}</span></div>'
  1165. f'<div class="kv"><span class="k">阶段数</span><span class="v">{stage_count}</span></div>'
  1166. f'<div class="kv"><span class="k">错误数</span><span class="v">{err_count}</span></div>'
  1167. f'<div class="kv"><span class="k">候选文章</span><span class="v">{stats.get("candidate_count", 0)}</span></div>'
  1168. f'<div class="kv"><span class="k">入选文章</span><span class="v">{stats.get("filtered_count", 0)}</span></div>'
  1169. f'<div class="kv"><span class="k">账号数</span><span class="v">{stats.get("account_count", 0)}</span></div>'
  1170. f'{out_html}'
  1171. f'</div>'
  1172. )
  1173. body = "\n".join(blocks)
  1174. status_color = "var(--green)" if pipeline_status == "completed" else "var(--red)"
  1175. completed_stage_names = {e.get("stage", "") for e in stage_completes}
  1176. errored_stage_names = {e.get("stage", "") for e in error_events}
  1177. ordered_stages = list(_STAGE_LABELS.keys())
  1178. stage_order_map = {name: idx + 1 for idx, name in enumerate(ordered_stages)}
  1179. flow_items: list[str] = []
  1180. for stage_key in ordered_stages:
  1181. label = _STAGE_LABELS.get(stage_key, stage_key)
  1182. if stage_key in errored_stage_names:
  1183. cls = "flow-step flow-error"
  1184. icon = "✖"
  1185. elif stage_key in completed_stage_names:
  1186. cls = "flow-step flow-done"
  1187. icon = "✔"
  1188. else:
  1189. cls = "flow-step flow-pending"
  1190. icon = "○"
  1191. stage_no = stage_order_map.get(stage_key, 0)
  1192. flow_items.append(
  1193. f'<div class="{cls}"><span class="flow-icon">{icon}</span>'
  1194. f'<h1 class="flow-h1">{stage_no}. {_esc(label)}</h1></div>'
  1195. )
  1196. flow_html = "".join(flow_items)
  1197. return f"""<!DOCTYPE html>
  1198. <html lang="zh-CN">
  1199. <head>
  1200. <meta charset="UTF-8">
  1201. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  1202. <title>Pipeline 执行追踪 — {_esc(query[:40])}</title>
  1203. <style>
  1204. :root {{
  1205. --bg: #0b1020; --bg2: #121a2b; --bg3: #1a2336; --border: #263149;
  1206. --text: #d9e1f2; --dim: #94a3bf; --blue: #66b3ff;
  1207. --purple: #c9a4ff; --green: #53d79f; --red: #ff6b7a;
  1208. --yellow: #f2c35d; --orange: #ff9f5a; --green-bg: #132d28;
  1209. --shadow: 0 12px 34px rgba(0,0,0,.28);
  1210. }}
  1211. * {{ margin:0; padding:0; box-sizing:border-box; }}
  1212. body {{
  1213. font-family: -apple-system,"SF Pro Text","Segoe UI","Helvetica Neue",sans-serif;
  1214. background:
  1215. radial-gradient(1200px 520px at 10% -10%, rgba(102,179,255,.11), transparent 50%),
  1216. radial-gradient(900px 500px at 90% 0%, rgba(201,164,255,.10), transparent 45%),
  1217. var(--bg);
  1218. color:var(--text); line-height:1.7;
  1219. margin:0; padding:24px 16px;
  1220. }}
  1221. .page-wrap {{
  1222. width: 80vw;
  1223. max-width: 1920px;
  1224. margin: 0 auto;
  1225. }}
  1226. .page-nav {{
  1227. position:sticky; top:0; z-index:20; backdrop-filter: blur(6px);
  1228. background:rgba(10,15,28,.72); border:1px solid var(--border); border-radius:12px;
  1229. box-shadow: var(--shadow);
  1230. padding:8px 10px; margin-bottom:14px; display:flex; gap:8px; flex-wrap:wrap;
  1231. }}
  1232. .page-nav a {{
  1233. color:var(--dim); text-decoration:none; font-size:12px; padding:5px 12px;
  1234. border-radius:999px; border:1px solid rgba(148,163,191,.2); background:rgba(18,26,43,.92);
  1235. }}
  1236. .page-nav a:hover {{ color:var(--blue); border-color:rgba(102,179,255,.45); background:rgba(102,179,255,.08); }}
  1237. header {{
  1238. border:1px solid var(--border); border-radius:14px;
  1239. background:linear-gradient(180deg, rgba(18,26,43,.95), rgba(18,26,43,.78));
  1240. box-shadow: var(--shadow);
  1241. padding:18px 20px; margin-bottom:20px;
  1242. display:flex; justify-content:space-between; align-items:flex-end; flex-wrap:wrap; gap:12px;
  1243. }}
  1244. header h1 {{ color:var(--blue); font-size:24px; letter-spacing:-0.5px; margin:0; }}
  1245. header .sub {{ color:var(--dim); font-size:12px; margin-top:4px; }}
  1246. header .sub span {{ margin:0 6px; }}
  1247. .stats {{
  1248. display:grid; grid-template-columns:repeat(5,1fr);
  1249. gap:12px; margin-bottom:24px;
  1250. }}
  1251. .stat {{
  1252. background:linear-gradient(180deg, rgba(18,26,43,.95), rgba(18,26,43,.75));
  1253. border:1px solid var(--border);
  1254. border-radius:12px; padding:14px 16px; text-align:center;
  1255. box-shadow: var(--shadow);
  1256. }}
  1257. .stat .num {{ font-size:28px; font-weight:700; line-height:1.2; }}
  1258. .stat .desc {{ font-size:11px; color:var(--dim); margin-top:4px; text-transform:uppercase; letter-spacing:0.5px; }}
  1259. .s-time .num {{ color:var(--yellow); font-size:20px; }}
  1260. .s-cand .num {{ color:var(--blue); }}
  1261. .s-filt .num {{ color:var(--green); }}
  1262. .s-acct .num {{ color:var(--purple); }}
  1263. .s-err .num {{ color:var(--red); }}
  1264. .section-title-bar {{
  1265. display:flex; align-items:center; justify-content:space-between;
  1266. margin:22px 0 10px;
  1267. }}
  1268. .section-title-bar h2 {{
  1269. margin:0; font-size:15px; color:var(--text); font-weight:700; letter-spacing:.2px;
  1270. }}
  1271. .section-title-bar .hint {{ color:var(--dim); font-size:11px; }}
  1272. .flow-strip {{
  1273. display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr));
  1274. gap:8px; margin:8px 0 18px;
  1275. }}
  1276. .flow-step {{
  1277. background:linear-gradient(180deg, rgba(18,26,43,.92), rgba(18,26,43,.75));
  1278. border:1px solid var(--border); border-radius:10px;
  1279. padding:9px 11px; font-size:12px; display:flex; gap:8px; align-items:center;
  1280. box-shadow: 0 4px 16px rgba(0,0,0,.18);
  1281. }}
  1282. .flow-h1 {{
  1283. margin:0; font-size:16px; font-weight:700; letter-spacing:.1px; color:var(--text);
  1284. }}
  1285. .flow-icon {{ font-size:11px; opacity:.95; }}
  1286. .flow-done {{ border-color:rgba(86,211,100,.45); background:rgba(86,211,100,.08); }}
  1287. .flow-done .flow-icon {{ color:var(--green); }}
  1288. .flow-error {{ border-color:rgba(248,81,73,.55); background:rgba(248,81,73,.10); }}
  1289. .flow-error .flow-icon {{ color:var(--red); }}
  1290. .flow-pending {{ border-color:rgba(139,148,158,.35); }}
  1291. .timeline {{
  1292. position:relative; padding:12px 12px 12px 28px;
  1293. border:1px solid var(--border); border-radius:14px;
  1294. background:linear-gradient(180deg, rgba(18,26,43,.90), rgba(18,26,43,.72));
  1295. box-shadow: var(--shadow);
  1296. }}
  1297. .timeline::before {{
  1298. content:''; position:absolute; left:8px; top:0; bottom:0;
  1299. width:2px; background:linear-gradient(180deg, var(--blue) 0%, var(--green) 50%, var(--purple) 100%);
  1300. opacity:0.4;
  1301. }}
  1302. .ts {{ font-size:10px; color:var(--dim); margin-right:6px; }}
  1303. .ev-label {{ font-weight:600; margin-bottom:6px; }}
  1304. .kv {{ font-size:13px; margin:2px 0; }}
  1305. .kv .k {{ color:var(--dim); margin-right:8px; }}
  1306. .kv .k::after {{ content:':'; }}
  1307. .kv .v {{ color:var(--text); }}
  1308. .mono {{ font-family:monospace; font-size:11px; color:var(--dim); }}
  1309. .ev-init, .ev-complete-ok, .ev-complete-fail {{
  1310. background:rgba(16,24,41,.85); border:1px solid var(--border);
  1311. border-radius:12px; padding:14px 16px; margin:12px 0;
  1312. }}
  1313. .ev-init {{ border-left:4px solid var(--blue); }}
  1314. .ev-init .ev-label {{ color:var(--blue); }}
  1315. .ev-complete-ok {{ border-left:4px solid var(--green); }}
  1316. .ev-complete-ok .ev-label {{ color:var(--green); }}
  1317. .ev-complete-fail {{ border-left:4px solid var(--red); }}
  1318. .ev-complete-fail .ev-label {{ color:var(--red); }}
  1319. .ev-phase {{
  1320. background:linear-gradient(135deg,#1f3f67,#1a2b47);
  1321. border:1px solid rgba(88,166,255,.3); border-radius:10px;
  1322. padding:11px 14px; margin:18px 0 8px;
  1323. font-size:15px; font-weight:700; color:var(--blue);
  1324. position:relative; display:flex; align-items:center; gap:8px;
  1325. }}
  1326. .ev-phase::before {{
  1327. content:''; position:absolute; left:-20px; top:14px;
  1328. width:10px; height:10px; background:var(--blue);
  1329. border-radius:50%; border:2px solid var(--bg);
  1330. }}
  1331. .stage-name {{ font-size:11px; color:var(--dim); font-weight:400; margin-left:6px; }}
  1332. .stage-h1 {{
  1333. margin:0; font-size:22px; line-height:1.15; font-weight:800; color:var(--blue);
  1334. }}
  1335. .ev-stage-ok {{
  1336. background:linear-gradient(180deg, rgba(19,45,40,.92), rgba(19,45,40,.72));
  1337. border:1px solid rgba(83,215,159,.35);
  1338. border-radius:10px; padding:10px 12px; margin:8px 0; font-size:13px;
  1339. }}
  1340. .stage-stats {{ margin-top:4px; }}
  1341. .stat-pill {{
  1342. display:inline-block; background:rgba(88,166,255,.1);
  1343. border:1px solid rgba(88,166,255,.2); border-radius:12px;
  1344. padding:1px 8px; font-size:11px; color:var(--blue); margin-right:6px;
  1345. }}
  1346. .badge-retry {{
  1347. background:rgba(227,179,65,.15); border:1px solid rgba(227,179,65,.3);
  1348. border-radius:4px; padding:1px 6px; font-size:10px; color:var(--yellow); margin-left:6px;
  1349. }}
  1350. .badge-dur {{
  1351. background:rgba(139,148,158,.1); border-radius:4px;
  1352. padding:1px 6px; font-size:10px; color:var(--dim); margin-left:4px;
  1353. }}
  1354. .ev-gate-ok, .ev-gate-warn {{
  1355. border-radius:10px; padding:9px 12px; margin:6px 0; font-size:12px;
  1356. }}
  1357. .ev-gate-ok {{
  1358. background:rgba(86,211,100,.05); border:1px solid rgba(86,211,100,.2);
  1359. }}
  1360. .ev-gate-warn {{
  1361. background:rgba(240,136,62,.05); border:1px solid rgba(240,136,62,.3);
  1362. }}
  1363. .gate-action {{ font-weight:600; margin-left:8px; }}
  1364. .gate-fallback {{ color:var(--orange); font-size:11px; margin-left:8px; }}
  1365. .gate-issues {{ margin:4px 0 0 16px; color:var(--dim); font-size:11px; }}
  1366. .ev-error {{ background:#2d1215; border:1px solid rgba(248,81,73,.4); border-radius:8px; margin:6px 0; color:var(--red); }}
  1367. .ev-error summary {{ padding:10px 14px; cursor:pointer; font-size:13px; }}
  1368. .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; }}
  1369. /* ── 决策详情卡片 ── */
  1370. .decision-card {{
  1371. margin:10px 0 6px; border:1px solid var(--border); border-radius:10px;
  1372. background:rgba(18,26,43,.92); overflow:hidden;
  1373. }}
  1374. .decision-card summary {{
  1375. padding:10px 16px; cursor:pointer; font-size:13px; font-weight:600;
  1376. color:var(--blue); user-select:none; background:rgba(88,166,255,.03);
  1377. }}
  1378. .decision-card summary:hover {{ background:rgba(88,166,255,.08); }}
  1379. .decision-body {{ padding:8px 16px 16px; }}
  1380. .decision-section {{ margin-bottom:12px; }}
  1381. .section-title {{ font-size:12px; font-weight:600; color:var(--dim); margin-bottom:6px; }}
  1382. .decision-table {{
  1383. width:100%; border-collapse:collapse; font-size:12px; margin-top:4px;
  1384. table-layout:auto;
  1385. }}
  1386. .decision-table th {{
  1387. text-align:left; padding:6px 10px; border-bottom:2px solid var(--border);
  1388. color:var(--dim); font-weight:600; font-size:11px; white-space:nowrap;
  1389. background:rgba(0,0,0,.2); position:sticky; top:0;
  1390. }}
  1391. .decision-table td {{ padding:5px 10px; border-bottom:1px solid rgba(33,38,45,.5); vertical-align:top; }}
  1392. .feature-label {{ color:var(--dim); white-space:nowrap; width:70px; }}
  1393. .tag {{
  1394. display:inline-block; background:rgba(210,168,255,.1); border:1px solid rgba(210,168,255,.25);
  1395. border-radius:4px; padding:1px 6px; font-size:11px; color:var(--purple); margin:1px 3px 1px 0;
  1396. }}
  1397. .tag-blue {{ background:rgba(88,166,255,.1); border-color:rgba(88,166,255,.25); color:var(--blue); }}
  1398. .tag-purple {{ background:rgba(210,168,255,.1); border-color:rgba(210,168,255,.25); color:var(--purple); }}
  1399. .focus-group {{ margin:4px 0 8px; font-size:12px; }}
  1400. .focus-group b {{ color:var(--dim); font-size:11px; }}
  1401. .focus-group ul {{ margin:2px 0 0 16px; color:var(--text); }}
  1402. .focus-group li {{ margin:1px 0; }}
  1403. .kw-table code {{ color:var(--blue); font-size:11px; }}
  1404. .num-cell {{ text-align:right; font-variant-numeric:tabular-nums; }}
  1405. .total-line {{ font-size:12px; color:var(--dim); margin-top:6px; padding-top:6px; border-top:1px solid var(--border); }}
  1406. .article-title-cell {{ word-break:break-all; }}
  1407. .article-title-cell a {{ color:var(--blue); text-decoration:none; }}
  1408. .article-title-cell a:hover {{ text-decoration:underline; }}
  1409. .recall-table .article-title-cell,
  1410. .review-table .article-title-cell {{
  1411. min-width: 520px;
  1412. max-width: 680px;
  1413. }}
  1414. .score-cell {{ white-space:nowrap; font-size:11px; }}
  1415. .reason-full-cell {{ font-size:11px; color:var(--dim); line-height:1.5; }}
  1416. .date-cell {{ white-space:nowrap; font-size:11px; color:var(--dim); }}
  1417. .review-table th {{ white-space:nowrap; }}
  1418. .review-table {{ min-width:900px; }}
  1419. .decision-section {{ overflow-x:auto; }}
  1420. .recall-table code {{ color:var(--blue); font-size:11px; }}
  1421. .phase-badge {{
  1422. display:inline-block; font-size:10px; padding:1px 5px; border-radius:3px;
  1423. background:rgba(139,148,158,.12); color:var(--dim); white-space:nowrap;
  1424. }}
  1425. .row-accept td {{ border-left:2px solid var(--green); }}
  1426. .row-reject td {{ border-left:2px solid var(--red); }}
  1427. .row-skip td {{ border-left:2px solid var(--dim); }}
  1428. .stat-accept {{ background:rgba(86,211,100,.1); border-color:rgba(86,211,100,.3); color:var(--green); }}
  1429. .stat-reject {{ background:rgba(248,81,73,.1); border-color:rgba(248,81,73,.3); color:var(--red); }}
  1430. .stat-skip {{ background:rgba(139,148,158,.1); border-color:rgba(139,148,158,.3); color:var(--dim); }}
  1431. .acct-table .sample-titles {{ font-size:11px; color:var(--dim); }}
  1432. .file-path {{ font-size:11px; color:var(--dim); background:rgba(139,148,158,.08); padding:3px 8px; border-radius:4px; }}
  1433. /* ── LLM 交互追踪卡片 ── */
  1434. .llm-interaction-card {{
  1435. margin:10px 0 6px; border:1px solid rgba(102,179,255,.3); border-radius:10px;
  1436. background:rgba(18,26,43,.95); overflow:hidden;
  1437. }}
  1438. .llm-interaction-card summary {{
  1439. padding:8px 14px; cursor:pointer; font-size:12px; font-weight:600;
  1440. color:var(--blue); user-select:none;
  1441. }}
  1442. .llm-interaction-card summary:hover {{ background:rgba(102,179,255,.06); }}
  1443. .llm-interaction-meta {{ font-weight:400; color:var(--dim); font-size:11px; margin-left:4px; }}
  1444. .llm-interaction-body {{ padding:6px 14px 14px; display:flex; flex-direction:column; gap:6px; }}
  1445. .llm-msg {{ border-radius:6px; font-size:12px; overflow:hidden; }}
  1446. .llm-msg-header {{ font-size:11px; font-weight:600; color:var(--dim); margin-bottom:4px; }}
  1447. .llm-msg-pre {{
  1448. white-space:pre-wrap; word-break:break-word; font-size:11px;
  1449. font-family:monospace; line-height:1.5; color:var(--text);
  1450. max-height:400px; overflow-y:auto; margin:0; padding:4px 0;
  1451. }}
  1452. .llm-msg-system {{
  1453. background:rgba(88,166,255,.04); border:1px solid rgba(88,166,255,.1);
  1454. }}
  1455. .llm-msg-system summary {{
  1456. padding:6px 10px; cursor:pointer; font-size:11px; color:var(--blue);
  1457. }}
  1458. .llm-msg-system .llm-msg-pre {{ padding:0 10px 10px; color:var(--dim); max-height:300px; }}
  1459. .llm-msg-user {{
  1460. background:rgba(139,148,158,.04); border:1px solid rgba(139,148,158,.1);
  1461. padding:8px 10px;
  1462. }}
  1463. .llm-msg-user summary {{
  1464. padding:6px 10px; cursor:pointer; font-size:11px; color:var(--dim);
  1465. }}
  1466. .llm-msg-user .llm-msg-pre {{ padding:0 10px 10px; }}
  1467. .llm-reasoning {{
  1468. background:rgba(227,179,65,.08); border:1px solid rgba(227,179,65,.25);
  1469. border-radius:6px; margin:4px 0;
  1470. }}
  1471. .llm-reasoning summary {{
  1472. cursor:pointer; font-size:12px; font-weight:600; color:var(--yellow); padding:6px 10px;
  1473. }}
  1474. .llm-reasoning-text {{ padding:4px 10px 10px; color:var(--yellow); opacity:0.9; }}
  1475. .llm-response {{
  1476. background:rgba(86,211,100,.04); border:1px solid rgba(86,211,100,.15);
  1477. border-radius:6px; padding:8px 10px;
  1478. }}
  1479. .llm-response summary {{
  1480. cursor:pointer; font-size:11px; color:var(--green); padding:4px 0;
  1481. }}
  1482. .llm-tool-call {{
  1483. background:rgba(88,166,255,.06); border:1px solid rgba(88,166,255,.12);
  1484. border-radius:4px; margin:4px 0; padding:6px 8px;
  1485. }}
  1486. .llm-tool-name {{ font-size:11px; font-weight:600; color:var(--blue); margin-bottom:2px; }}
  1487. .llm-tool-args {{
  1488. font-size:10px; color:var(--dim); font-family:monospace; margin:2px 0 0;
  1489. white-space:pre-wrap; max-height:200px; overflow-y:auto;
  1490. }}
  1491. .llm-tool-result {{ margin:4px 0; }}
  1492. .llm-tool-result summary {{
  1493. cursor:pointer; font-size:11px; color:var(--purple); padding:2px 0;
  1494. }}
  1495. /* ── Agent Trace 卡片 ── */
  1496. .agent-trace-card {{
  1497. margin:10px 0 6px; border:1px solid rgba(210,168,255,.3); border-radius:10px;
  1498. background:rgba(18,26,43,.95); overflow:hidden;
  1499. }}
  1500. .agent-trace-card summary {{
  1501. padding:8px 14px; cursor:pointer; font-size:12px; font-weight:600;
  1502. color:var(--purple); user-select:none;
  1503. }}
  1504. .agent-trace-card summary:hover {{ background:rgba(210,168,255,.06); }}
  1505. .agent-trace-summary {{ font-weight:400; color:var(--dim); font-size:11px; margin-left:4px; }}
  1506. .agent-trace-body {{ padding:6px 14px 14px; }}
  1507. .agent-meta {{
  1508. display:flex; flex-wrap:wrap; gap:8px 16px; padding:6px 0 10px;
  1509. border-bottom:1px solid var(--border); margin-bottom:10px; font-size:11px;
  1510. }}
  1511. .agent-meta-item {{ color:var(--dim); }}
  1512. .agent-meta-item b {{ color:var(--text); }}
  1513. .agent-meta-item code {{ color:var(--blue); font-size:10px; }}
  1514. .agent-messages {{ display:flex; flex-direction:column; gap:6px; }}
  1515. .agent-msg {{ border-radius:6px; font-size:12px; overflow:hidden; }}
  1516. .agent-msg-header {{
  1517. font-size:11px; font-weight:600; color:var(--dim); margin-bottom:4px;
  1518. }}
  1519. .agent-msg-pre {{
  1520. white-space:pre-wrap; word-break:break-word; font-size:11px;
  1521. font-family:monospace; line-height:1.5; color:var(--text);
  1522. max-height:400px; overflow-y:auto; margin:0; padding:4px 0;
  1523. }}
  1524. .agent-msg-system {{
  1525. background:rgba(88,166,255,.04); border:1px solid rgba(88,166,255,.1);
  1526. }}
  1527. .agent-msg-system summary {{
  1528. padding:6px 10px; cursor:pointer; font-size:11px; color:var(--blue);
  1529. }}
  1530. .agent-msg-system .agent-msg-pre {{ padding:0 10px 10px; color:var(--dim); max-height:300px; }}
  1531. .agent-msg-user {{
  1532. background:rgba(139,148,158,.04); border:1px solid rgba(139,148,158,.1);
  1533. padding:8px 10px;
  1534. }}
  1535. .agent-msg-user summary {{
  1536. padding:6px 10px; cursor:pointer; font-size:11px; color:var(--dim);
  1537. }}
  1538. .agent-msg-user .agent-msg-pre {{ padding:0 10px 10px; }}
  1539. .agent-msg-assistant {{
  1540. background:rgba(86,211,100,.04); border:1px solid rgba(86,211,100,.15);
  1541. padding:8px 10px;
  1542. }}
  1543. .agent-token-badge {{
  1544. display:inline-block; font-size:9px; padding:1px 5px; border-radius:3px;
  1545. background:rgba(139,148,158,.12); color:var(--dim); margin-left:6px; font-weight:400;
  1546. }}
  1547. .agent-thinking {{ margin:4px 0; }}
  1548. .agent-thinking summary {{
  1549. cursor:pointer; font-size:11px; color:var(--dim); padding:2px 0;
  1550. }}
  1551. .agent-reasoning {{
  1552. background:rgba(227,179,65,.06); border:1px solid rgba(227,179,65,.15);
  1553. border-radius:4px; margin:4px 0;
  1554. }}
  1555. .agent-reasoning summary {{
  1556. cursor:pointer; font-size:11px; color:var(--yellow); padding:4px 8px;
  1557. }}
  1558. .agent-reasoning .agent-msg-pre {{ padding:4px 8px 8px; color:var(--yellow); }}
  1559. .agent-tool-call {{
  1560. background:rgba(88,166,255,.06); border:1px solid rgba(88,166,255,.12);
  1561. border-radius:4px; margin:4px 0; padding:6px 8px;
  1562. }}
  1563. .agent-tool-name {{ font-size:11px; font-weight:600; color:var(--blue); margin-bottom:2px; }}
  1564. .agent-tool-args {{
  1565. font-size:10px; color:var(--dim); font-family:monospace; margin:2px 0 0;
  1566. white-space:pre-wrap; max-height:200px; overflow-y:auto;
  1567. }}
  1568. .agent-msg-tool {{
  1569. background:rgba(210,168,255,.04); border:1px solid rgba(210,168,255,.1);
  1570. padding:6px 10px;
  1571. }}
  1572. .agent-msg-tool summary {{
  1573. cursor:pointer; font-size:11px; color:var(--purple); padding:4px 0;
  1574. }}
  1575. .agent-msg-tool .agent-msg-pre {{ max-height:250px; color:var(--dim); }}
  1576. .agent-msg-goal {{
  1577. background:rgba(227,179,65,.06); border:1px solid rgba(227,179,65,.12);
  1578. border-radius:4px; padding:6px 10px; font-size:11px; color:var(--yellow);
  1579. }}
  1580. @media(max-width:900px) {{
  1581. body {{ padding:16px 12px; }}
  1582. .stats {{ grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); }}
  1583. .timeline {{ padding-left:16px; }}
  1584. .timeline::before {{ left:3px; }}
  1585. .ev-phase::before {{ left:-14px; width:8px; height:8px; }}
  1586. header {{ flex-direction:column; align-items:flex-start; }}
  1587. }}
  1588. {_LOG_VIEWER_CSS}
  1589. </style>
  1590. </head>
  1591. <body>
  1592. <div class="page-wrap">
  1593. <nav class="page-nav">
  1594. <a href="#overview">总览</a>
  1595. <a href="#flow">流程总览</a>
  1596. <a href="#timeline">执行时间线</a>
  1597. <a href="#logs">完整日志</a>
  1598. </nav>
  1599. <header id="overview">
  1600. <h1>🔄 Pipeline 执行追踪</h1>
  1601. <div class="sub">
  1602. 查询: {_esc(query)} &nbsp;|&nbsp;
  1603. 模型: {_esc(model)} &nbsp;|&nbsp;
  1604. 状态: <span style="color:{status_color}">{_esc(pipeline_status)}</span> &nbsp;|&nbsp;
  1605. 耗时: {_esc(duration)} &nbsp;|&nbsp;
  1606. trace_id: <span style="font-family:monospace">{_esc(trace_id)}</span>
  1607. </div>
  1608. </header>
  1609. <div class="stats">
  1610. <div class="stat s-time"><div class="num">{_esc(duration)}</div><div class="desc">总耗时</div></div>
  1611. <div class="stat s-cand"><div class="num">{candidate_count}</div><div class="desc">召回候选</div></div>
  1612. <div class="stat s-filt"><div class="num">{filtered_count}</div><div class="desc">入选文章</div></div>
  1613. <div class="stat s-acct"><div class="num">{account_count}</div><div class="desc">沉淀账号</div></div>
  1614. <div class="stat s-err"><div class="num">{len(error_events)}</div><div class="desc">错误</div></div>
  1615. </div>
  1616. <div class="section-title-bar" id="flow">
  1617. <h2>流程总览</h2>
  1618. <span class="hint">按阶段展示执行状态</span>
  1619. </div>
  1620. <div class="flow-strip">
  1621. {flow_html}
  1622. </div>
  1623. <div class="section-title-bar" id="timeline">
  1624. <h2>执行时间线</h2>
  1625. <span class="hint">按事件顺序展示关键决策与结果</span>
  1626. </div>
  1627. <div class="timeline">
  1628. {body}
  1629. </div>
  1630. <div id="logs">
  1631. {_render_full_log_section(full_log_lines or [])}
  1632. </div>
  1633. {_LOG_VIEWER_JS}
  1634. </div>
  1635. </body>
  1636. </html>"""
  1637. # ─────────────────────────────────────────────────────────────
  1638. # 入口
  1639. # ─────────────────────────────────────────────────────────────
  1640. def list_traces() -> None:
  1641. if not TRACES_DIR.exists():
  1642. print(f"traces 目录不存在: {TRACES_DIR}")
  1643. return
  1644. dirs = sorted(
  1645. [d for d in TRACES_DIR.iterdir() if d.is_dir() and (d / "pipeline.jsonl").exists()],
  1646. key=lambda d: d.stat().st_mtime,
  1647. reverse=True,
  1648. )
  1649. if not dirs:
  1650. print("暂无可用 trace(需先运行 run_search_agent.py)")
  1651. return
  1652. print(f"{'trace_id':<40} {'修改时间'}")
  1653. print("-" * 60)
  1654. for d in dirs:
  1655. mtime = datetime.fromtimestamp(d.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
  1656. print(f"{d.name:<40} {mtime}")
  1657. def find_latest_trace() -> Path | None:
  1658. if not TRACES_DIR.exists():
  1659. return None
  1660. dirs = sorted(
  1661. [d for d in TRACES_DIR.iterdir() if d.is_dir() and (d / "pipeline.jsonl").exists()],
  1662. key=lambda d: d.stat().st_mtime,
  1663. reverse=True,
  1664. )
  1665. return dirs[0] if dirs else None
  1666. def main() -> None:
  1667. args = sys.argv[1:]
  1668. if "--list" in args:
  1669. list_traces()
  1670. return
  1671. if args and not args[0].startswith("--"):
  1672. trace_id = args[0]
  1673. trace_dir = TRACES_DIR / trace_id
  1674. else:
  1675. trace_dir = find_latest_trace()
  1676. if trace_dir is None:
  1677. print(f"❌ 找不到任何 trace,请先运行 run_search_agent.py")
  1678. print(f" traces 目录: {TRACES_DIR}")
  1679. sys.exit(1)
  1680. trace_id = trace_dir.name
  1681. jsonl_path = trace_dir / "pipeline.jsonl"
  1682. if not jsonl_path.exists():
  1683. print(f"❌ 找不到 {jsonl_path}")
  1684. sys.exit(1)
  1685. events = read_jsonl(jsonl_path)
  1686. print(f"📄 读取了 {len(events)} 个事件 (trace_id={trace_id})")
  1687. # 读取完整日志文件(如有)
  1688. log_path = trace_dir / "full_log.log"
  1689. full_log_lines: list[dict] | None = None
  1690. if log_path.exists():
  1691. log_text = log_path.read_text(encoding="utf-8")
  1692. full_log_lines = _parse_log_lines(log_text)
  1693. print(f"📋 读取了 {len(full_log_lines)} 行完整日志")
  1694. html_content = render_html(events, full_log_lines=full_log_lines)
  1695. out_path = trace_dir / "pipeline_trace.html"
  1696. out_path.write_text(html_content, encoding="utf-8")
  1697. size_kb = out_path.stat().st_size / 1024
  1698. print(f"✅ 已生成: {out_path} ({size_kb:.0f} KB)")
  1699. if __name__ == "__main__":
  1700. main()