pipeline_visualize.py 76 KB

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