510 lines
19 KiB
JavaScript
510 lines
19 KiB
JavaScript
// report.js — 报告详情页渲染:元信息、指标卡片、分布图、分组表、低分样本复核。
|
||
|
||
const Report = {
|
||
distChart: null,
|
||
currentDetail: null,
|
||
activeGrouping: null,
|
||
_switcherLoaded: false,
|
||
|
||
// 加载并渲染指定运行的完整报告。
|
||
async render(runId) {
|
||
const empty = document.getElementById("report-empty");
|
||
const content = document.getElementById("report-content");
|
||
|
||
// 加载历史报告下拉(仅首次)
|
||
Report._loadSwitcher(runId);
|
||
|
||
if (!runId) {
|
||
empty.hidden = false;
|
||
content.hidden = true;
|
||
return;
|
||
}
|
||
empty.hidden = true;
|
||
content.hidden = false;
|
||
content.style.opacity = "0.4";
|
||
|
||
try {
|
||
const detail = await API.runDetail(runId);
|
||
Report.currentDetail = detail;
|
||
Report.renderMeta(detail.summary);
|
||
Report.renderMetricCards(detail.summary, detail.report);
|
||
Report.renderDistribution(detail.report);
|
||
Report.renderGroupings(detail.report);
|
||
Report.renderLowest(detail.report);
|
||
Report.renderAdvisorComparison(detail.report);
|
||
Report.renderAdvice(detail.summary, detail.report);
|
||
Report.renderTokenUsage(detail.report);
|
||
content.style.opacity = "1";
|
||
|
||
// 同步下拉选中项
|
||
const sel = document.getElementById("report-switcher-select");
|
||
if (sel) sel.value = runId;
|
||
} catch (err) {
|
||
empty.hidden = false;
|
||
content.hidden = true;
|
||
empty.innerHTML = `<p>加载报告失败:${App.escape(err.message)}</p>`;
|
||
}
|
||
},
|
||
|
||
// 加载并填充历史报告下拉选择框
|
||
async _loadSwitcher(currentRunId) {
|
||
const sel = document.getElementById("report-switcher-select");
|
||
if (!sel) return;
|
||
|
||
// 已加载过就只更新选中值,不重复请求
|
||
if (Report._switcherLoaded) {
|
||
if (currentRunId) sel.value = currentRunId;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const data = await API.runs();
|
||
const runs = data.runs || [];
|
||
sel.innerHTML = "";
|
||
if (runs.length === 0) {
|
||
sel.innerHTML = '<option value="">(无历史运行)</option>';
|
||
return;
|
||
}
|
||
runs.forEach((run) => {
|
||
const opt = document.createElement("option");
|
||
opt.value = run.run_id;
|
||
const timeStr = App.shortTime(run.finished_at);
|
||
const meanText = run.metric_means
|
||
? Object.entries(run.metric_means)
|
||
.filter(([, v]) => v !== null && v !== undefined)
|
||
.slice(0, 2)
|
||
.map(([k, v]) => `${App.shortMetric(k)}=${v.toFixed(2)}`)
|
||
.join(" ")
|
||
: "";
|
||
opt.textContent = `${run.scenario_name || run.run_id} ${timeStr}${meanText ? " [" + meanText + "]" : ""}`;
|
||
sel.appendChild(opt);
|
||
});
|
||
Report._switcherLoaded = true;
|
||
if (currentRunId) sel.value = currentRunId;
|
||
} catch (_e) {
|
||
sel.innerHTML = '<option value="">(加载失败)</option>';
|
||
}
|
||
|
||
// 绑定切换事件(只绑一次)
|
||
sel.addEventListener("change", () => {
|
||
const rid = sel.value;
|
||
if (!rid) return;
|
||
App.currentRunId = rid;
|
||
App.enableReportNav();
|
||
Report.render(rid);
|
||
});
|
||
},
|
||
|
||
// 顶部元信息条。
|
||
renderMeta(summary) {
|
||
const el = document.getElementById("report-meta");
|
||
el.innerHTML = `
|
||
<div>
|
||
<div class="report-meta-title">${App.escape(summary.scenario_name || summary.run_id)}
|
||
<span class="status-pill completed">● completed</span></div>
|
||
<div class="report-meta-info">run_id: ${App.escape(summary.run_id)}</div>
|
||
</div>
|
||
<div class="report-meta-info">
|
||
${App.escape(summary.mode || "—")} · judge: ${App.escape(summary.judge_model || "—")}
|
||
· ${summary.total_samples} 样本 (${summary.valid_samples} 有效 / ${summary.invalid_samples} 无效)
|
||
· ${App.escape(App.shortTime(summary.finished_at))}
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
// ① 指标均值卡片。
|
||
renderMetricCards(summary, report) {
|
||
const wrap = document.getElementById("metric-cards");
|
||
wrap.innerHTML = "";
|
||
const metrics = report.metrics && report.metrics.length ? report.metrics : summary.metrics;
|
||
metrics.forEach((metric) => {
|
||
const value = report.metric_means ? report.metric_means[metric] : null;
|
||
const cls = App.scoreClass(metric, value);
|
||
const text = value === null || value === undefined ? "n/a" : value.toFixed(2);
|
||
const description = MetricPresenter.describeMetric(metric);
|
||
const card = document.createElement("div");
|
||
card.className = "metric-card";
|
||
card.innerHTML = `
|
||
<div class="metric-value ${cls}">${text}</div>
|
||
<div class="metric-name">${App.escape(metric)}</div>
|
||
<div class="metric-desc">${App.escape(description)}</div>
|
||
`;
|
||
wrap.appendChild(card);
|
||
});
|
||
|
||
// 综合加权得分卡片(已暂时隐藏)
|
||
// const wsValue = (report && report.weighted_score_mean !== undefined) ? report.weighted_score_mean : null;
|
||
// const wsCard = document.createElement("div");
|
||
// wsCard.className = "metric-card weighted-score-card";
|
||
// const wsCls = App.scoreClass(wsValue);
|
||
// const wsText = wsValue === null || wsValue === undefined ? "n/a" : wsValue.toFixed(2);
|
||
// wsCard.innerHTML = `
|
||
// <div class="metric-value ${wsCls}">${wsText}</div>
|
||
// <div class="metric-name">综合加权得分</div>
|
||
// `;
|
||
// wrap.appendChild(wsCard);
|
||
},
|
||
|
||
// ② 分数分布直方图(可切换指标)。
|
||
renderDistribution(report) {
|
||
const select = document.getElementById("dist-metric-select");
|
||
const distributions = report.distributions || {};
|
||
const metricsWithDist = Object.keys(distributions);
|
||
|
||
select.innerHTML = "";
|
||
if (metricsWithDist.length === 0) {
|
||
Report._drawDistChart([], []);
|
||
return;
|
||
}
|
||
metricsWithDist.forEach((metric) => {
|
||
const opt = document.createElement("option");
|
||
opt.value = metric;
|
||
opt.textContent = metric;
|
||
select.appendChild(opt);
|
||
});
|
||
select.onchange = () => Report._updateDistChart(select.value);
|
||
Report._updateDistChart(metricsWithDist[0]);
|
||
},
|
||
|
||
// 用选定指标的分箱数据刷新直方图。
|
||
_updateDistChart(metric) {
|
||
const distributions = Report.currentDetail.report.distributions || {};
|
||
const bins = distributions[metric] || [];
|
||
const labels = bins.map((b) => b.label);
|
||
const counts = bins.map((b) => b.count);
|
||
const colors = bins.map((b) => Report._binColor(metric, b.lower));
|
||
Report._drawDistChart(labels, counts, colors);
|
||
},
|
||
|
||
// 低分箱偏红、高分箱偏绿,直观暴露长尾。
|
||
_binColor(metric, lower) {
|
||
return MetricPresenter.binColor(metric, lower);
|
||
},
|
||
|
||
// 实际绘制 Chart.js 柱状图。
|
||
_drawDistChart(labels, counts, colors) {
|
||
const canvas = document.getElementById("dist-chart");
|
||
if (Report.distChart) Report.distChart.destroy();
|
||
Report.distChart = new Chart(canvas, {
|
||
type: "bar",
|
||
data: {
|
||
labels,
|
||
datasets: [{ data: counts, backgroundColor: colors || "#009999", borderRadius: 4 }],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
plugins: { legend: { display: false } },
|
||
scales: {
|
||
y: { beginAtZero: true, ticks: { precision: 0 }, grid: { color: "#f1f5f9" } },
|
||
x: { grid: { display: false } },
|
||
},
|
||
},
|
||
});
|
||
},
|
||
|
||
// ③ 分组均值(difficulty / question_type / language)。
|
||
renderGroupings(report) {
|
||
const tabsEl = document.getElementById("grouping-tabs");
|
||
const tableEl = document.getElementById("grouping-table");
|
||
const groupings = report.groupings || {};
|
||
const fields = Object.keys(groupings);
|
||
|
||
tabsEl.innerHTML = "";
|
||
if (fields.length === 0) {
|
||
tableEl.innerHTML = '<p class="muted tiny">数据集未包含可分组字段(difficulty / question_type)。</p>';
|
||
return;
|
||
}
|
||
|
||
const fieldLabels = { difficulty: "难度", question_type: "类型", language: "语言" };
|
||
Report.activeGrouping = fields[0];
|
||
fields.forEach((field) => {
|
||
const tab = document.createElement("button");
|
||
tab.className = "grouping-tab" + (field === Report.activeGrouping ? " active" : "");
|
||
tab.textContent = fieldLabels[field] || field;
|
||
tab.onclick = () => {
|
||
Report.activeGrouping = field;
|
||
tabsEl.querySelectorAll(".grouping-tab").forEach((t) => t.classList.remove("active"));
|
||
tab.classList.add("active");
|
||
Report._drawGroupTable(report, field);
|
||
};
|
||
tabsEl.appendChild(tab);
|
||
});
|
||
Report._drawGroupTable(report, Report.activeGrouping);
|
||
},
|
||
|
||
// 渲染单个分组字段的均值表。
|
||
_drawGroupTable(report, field) {
|
||
const tableEl = document.getElementById("grouping-table");
|
||
const stats = report.groupings[field] || [];
|
||
const metrics = report.metrics || [];
|
||
|
||
let head = "<tr><th>组</th><th>样本</th>";
|
||
metrics.forEach((m) => (head += `<th>${App.escape(App.shortMetric(m))}</th>`));
|
||
head += "</tr>";
|
||
|
||
let body = "";
|
||
stats.forEach((stat) => {
|
||
body += `<tr><td>${App.escape(stat.key)}</td><td>${stat.count}</td>`;
|
||
metrics.forEach((m) => {
|
||
const v = stat.means ? stat.means[m] : null;
|
||
const cls = App.scoreClass(m, v);
|
||
const text = v === null || v === undefined ? "—" : v.toFixed(2);
|
||
body += `<td class="${cls}">${text}</td>`;
|
||
});
|
||
body += "</tr>";
|
||
});
|
||
tableEl.innerHTML = `<table class="group-table">${head}${body}</table>`;
|
||
},
|
||
|
||
// 渲染"Token 用量"面板:按模型分组的 input/output/调用次数表格。
|
||
renderTokenUsage(report) {
|
||
const wrap = document.getElementById("token-usage-wrap");
|
||
if (!wrap) return;
|
||
const usage = report.token_usage || {};
|
||
const models = Object.keys(usage).sort();
|
||
if (models.length === 0) {
|
||
wrap.innerHTML = '<p class="muted tiny">暂无 token 用量数据。</p>';
|
||
return;
|
||
}
|
||
let head = "<tr><th>模型</th><th>input_tokens</th><th>output_tokens</th><th>调用次数</th></tr>";
|
||
let body = "";
|
||
models.forEach((model) => {
|
||
const u = usage[model] || {};
|
||
body += `<tr><td>${App.escape(model)}</td><td>${u.input_tokens ?? 0}</td>` +
|
||
`<td>${u.output_tokens ?? 0}</td><td>${u.calls ?? 0}</td></tr>`;
|
||
});
|
||
wrap.innerHTML = `<table class="group-table">${head}${body}</table>`;
|
||
},
|
||
|
||
// 相比上次运行的顾问诊断对比(同 scenario_name 的最近一次前序运行,自动匹配)。
|
||
renderAdvisorComparison(report) {
|
||
const section = document.getElementById("advisor-comparison-section");
|
||
const meta = document.getElementById("advisor-comparison-meta");
|
||
const body = document.getElementById("advisor-comparison-body");
|
||
if (!section || !meta || !body) return;
|
||
|
||
const comparison = report.advisor_comparison;
|
||
if (!comparison || !comparison.entries || comparison.entries.length === 0) {
|
||
section.hidden = true;
|
||
return;
|
||
}
|
||
|
||
section.hidden = false;
|
||
meta.textContent = `对比:${comparison.previous_run_id}(${App.shortTime(comparison.previous_finished_at)})`;
|
||
|
||
const STATUS_LABEL = {
|
||
resolved: "✅ 已改善",
|
||
regressed: "⚠️ 新触发",
|
||
still_triggered: "❌ 仍未解决",
|
||
new_metric: "🆕 新增指标",
|
||
};
|
||
const STATUS_CLASS = {
|
||
resolved: "delta-good",
|
||
regressed: "delta-bad",
|
||
still_triggered: "delta-bad",
|
||
new_metric: "delta-flat",
|
||
};
|
||
|
||
const fmt = (v) => (v === null || v === undefined ? "—" : Number(v).toFixed(2));
|
||
|
||
let rows = "";
|
||
comparison.entries.forEach((entry) => {
|
||
const label = STATUS_LABEL[entry.status] || entry.status;
|
||
const cls = STATUS_CLASS[entry.status] || "delta-flat";
|
||
const d = MetricPresenter.deltaInfo(entry.metric, entry.current_score, entry.previous_score);
|
||
const deltaHtml = d.hasData && d.delta !== 0
|
||
? ` <span class="hist-delta ${d.cls}">${d.arrow}${d.magnitude}</span>`
|
||
: "";
|
||
rows += `
|
||
<div class="advisor-comparison-row">
|
||
<span class="advisor-comparison-status ${cls}">${label}</span>
|
||
<span class="advisor-comparison-metric">${App.escape(App.shortMetric(entry.metric))}</span>
|
||
<span class="advisor-comparison-scores">${fmt(entry.previous_score)} → ${fmt(entry.current_score)}${deltaHtml}</span>
|
||
</div>`;
|
||
});
|
||
|
||
let caveat = "";
|
||
if (comparison.judge_model_changed) {
|
||
caveat = `<p class="muted tiny">⚠️ judge_model 不同(${App.escape(comparison.previous_judge_model)} → ${App.escape(comparison.current_judge_model)}),对比仅供参考。</p>`;
|
||
}
|
||
|
||
body.innerHTML = rows + caveat;
|
||
},
|
||
|
||
// ④ 最低分样本逐条复核表(点击展开)。
|
||
renderLowest(report) { const wrap = document.getElementById("lowest-table");
|
||
const samples = report.lowest_samples || [];
|
||
wrap.innerHTML = "";
|
||
if (samples.length === 0) {
|
||
wrap.innerHTML = '<div class="lowest-detail-inner" style="padding:16px">暂无可复核样本。</div>';
|
||
return;
|
||
}
|
||
const metrics = report.metrics || [];
|
||
samples.forEach((sample, idx) => {
|
||
const row = document.createElement("div");
|
||
row.className = "lowest-row";
|
||
const scoreBadges = metrics
|
||
.map((m) => {
|
||
const v = sample.metrics ? sample.metrics[m] : null;
|
||
const cls = App.scoreClass(m, v);
|
||
const text = v === null || v === undefined ? "—" : v.toFixed(2);
|
||
return `<span class="score-badge ${cls}" title="${App.escape(m)}">${text}</span>`;
|
||
})
|
||
.join("");
|
||
row.innerHTML = `
|
||
<span class="sid">${App.escape(sample.sample_id)}</span>
|
||
<span class="q">${App.escape(sample.question || "—")}</span>
|
||
<span class="scores">${scoreBadges}</span>
|
||
`;
|
||
|
||
const detail = document.createElement("div");
|
||
detail.className = "lowest-detail";
|
||
detail.hidden = true;
|
||
detail.innerHTML = Report._detailHtml(sample, metrics);
|
||
|
||
row.addEventListener("click", () => {
|
||
detail.hidden = !detail.hidden;
|
||
});
|
||
wrap.appendChild(row);
|
||
wrap.appendChild(detail);
|
||
});
|
||
},
|
||
|
||
// 单条样本的展开详情:question / contexts / answer / ground_truth / 历史评分。
|
||
_detailHtml(sample, metrics) {
|
||
const contexts = (sample.contexts || [])
|
||
.map((c, i) => `<div class="ctx-item">[${i + 1}] ${App.escape(c)}</div>`)
|
||
.join("");
|
||
const errorBlock = sample.error
|
||
? `<div class="detail-field"><div class="detail-label">错误 error</div><div style="color:#dc2626">${App.escape(sample.error)}</div></div>`
|
||
: "";
|
||
return `
|
||
<div class="lowest-detail-inner">
|
||
<div class="detail-field">
|
||
<div class="detail-label">问题 question</div>
|
||
<div>${App.escape(sample.question || "—")}</div>
|
||
</div>
|
||
<div class="detail-field">
|
||
<div class="detail-label">检索片段 contexts</div>
|
||
<div class="detail-context">${contexts || "(空)"}</div>
|
||
</div>
|
||
<div class="detail-field">
|
||
<div class="detail-label">生成答案 answer</div>
|
||
<div>${App.escape(sample.answer || "—")}</div>
|
||
</div>
|
||
<div class="detail-field">
|
||
<div class="detail-label">标准答案 ground_truth</div>
|
||
<div class="detail-gt">${App.escape(sample.ground_truth || "—")}</div>
|
||
</div>
|
||
${errorBlock}
|
||
${Report._historyHtml(sample, metrics || [])}
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
// 同一问题的历史评分小表格:本次 + 历次(按时间倒序),逐行标注较更早一次的涨跌。
|
||
_historyHtml(sample, metrics) {
|
||
const history = sample.history || [];
|
||
if (!history.length) return "";
|
||
|
||
// 只展示当前样本与历史中实际出现过的指标列,避免空列。
|
||
const cols = metrics.filter(
|
||
(m) =>
|
||
(sample.metrics && sample.metrics[m] !== undefined && sample.metrics[m] !== null) ||
|
||
history.some((h) => h.metrics && h.metrics[m] !== undefined && h.metrics[m] !== null),
|
||
);
|
||
if (!cols.length) return "";
|
||
|
||
// 组合行:[本次, 历次...],相邻两行做涨跌对比(行 r 对比更早的行 r+1)。
|
||
const rows = [
|
||
{ label: "本次", sub: "", metrics: sample.metrics || {}, current: true },
|
||
...history.map((h) => ({
|
||
label: App.escape(h.scenario_name || h.run_id || "历史"),
|
||
sub: App.escape(App.shortTime(h.finished_at)),
|
||
metrics: h.metrics || {},
|
||
current: false,
|
||
})),
|
||
];
|
||
|
||
let head = "<tr><th>评测</th>";
|
||
cols.forEach((m) => (head += `<th>${App.escape(App.shortMetric(m))}</th>`));
|
||
head += "</tr>";
|
||
|
||
let body = "";
|
||
rows.forEach((row, r) => {
|
||
const older = rows[r + 1];
|
||
body += `<tr class="${row.current ? "hist-current" : ""}">`;
|
||
body += `<td class="hist-when"><span class="hist-label">${row.label}</span>${row.sub ? `<span class="hist-sub">${row.sub}</span>` : ""}</td>`;
|
||
cols.forEach((m) => {
|
||
const v = row.metrics ? row.metrics[m] : null;
|
||
const cls = App.scoreClass(m, v);
|
||
const text = v === null || v === undefined ? "—" : Number(v).toFixed(2);
|
||
let deltaHtml = "";
|
||
const baseline = older && older.metrics ? older.metrics[m] : undefined;
|
||
const d = MetricPresenter.deltaInfo(m, v, baseline);
|
||
if (d.hasData && d.delta !== 0) {
|
||
deltaHtml = ` <span class="hist-delta ${d.cls}">${d.arrow}${d.magnitude}</span>`;
|
||
}
|
||
body += `<td><span class="score-badge ${cls}">${text}</span>${deltaHtml}</td>`;
|
||
});
|
||
body += "</tr>";
|
||
});
|
||
|
||
return `
|
||
<div class="detail-field">
|
||
<div class="detail-label">历史评分 history(同一问题,最近 ${history.length} 次,含本次对比)</div>
|
||
<table class="history-table">${head}${body}</table>
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
// ⑤ 优化建议(仅 optimization_advice.md 存在时渲染)。
|
||
renderAdvice(summary, report) {
|
||
const section = document.getElementById("advice-section");
|
||
const body = document.getElementById("advice-body");
|
||
const modelLabel = document.getElementById("advice-model-label");
|
||
|
||
const md = report.advice_markdown || "";
|
||
if (!md.trim()) {
|
||
section.hidden = true;
|
||
return;
|
||
}
|
||
|
||
section.hidden = false;
|
||
modelLabel.textContent = summary.judge_model ? `judge: ${summary.judge_model}` : "";
|
||
|
||
// 简单 Markdown → HTML 转换(标题、列表、分隔线、粗体)
|
||
const escaped = md
|
||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
const html = escaped
|
||
.replace(/^#{3}\s+(.+)$/gm, "<h3>$1</h3>")
|
||
.replace(/^#{2}\s+(.+)$/gm, "<h2>$1</h2>")
|
||
.replace(/^#{1}\s+(.+)$/gm, "<h1>$1</h1>")
|
||
.replace(/^---+$/gm, "<hr>")
|
||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||
.replace(/^- (.+)$/gm, "<li>$1</li>")
|
||
.replace(/(<li>[^]*?<\/li>\n?)+/g, (m) => `<ul>${m}</ul>`)
|
||
.replace(/\n\n+/g, "\n<br>\n");
|
||
|
||
body.innerHTML = `<div class="advice-md">${html}</div>`;
|
||
},
|
||
|
||
// 导出 PDF:展开所有低分样本 → 打印 → 还原折叠状态
|
||
exportPdf() {
|
||
// 1. 记录当前各 detail 展开状态,并全部展开
|
||
const details = document.querySelectorAll("#lowest-table .lowest-detail");
|
||
const wasHidden = Array.from(details).map((el) => el.hidden);
|
||
details.forEach((el) => { el.hidden = false; });
|
||
|
||
// 2. 打印完成后还原折叠状态
|
||
const restore = () => {
|
||
details.forEach((el, i) => { el.hidden = wasHidden[i]; });
|
||
window.removeEventListener("afterprint", restore);
|
||
};
|
||
window.addEventListener("afterprint", restore);
|
||
|
||
// 3. 触发打印(浏览器弹出打印对话框,用户选"另存为 PDF")
|
||
window.print();
|
||
},
|
||
};
|