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