Files
siemens_ragas/webapp/static/js/report.js

378 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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.renderAdvice(detail.summary, 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(value);
const text = value === null || value === undefined ? "n/a" : value.toFixed(2);
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>
`;
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(b.lower));
Report._drawDistChart(labels, counts, colors);
},
// 低分箱偏红、高分箱偏绿,直观暴露长尾。
_binColor(lower) {
if (lower >= 0.8) return "#16a34a";
if (lower >= 0.6) return "#84cc16";
if (lower >= 0.4) return "#eab308";
if (lower >= 0.2) return "#f97316";
return "#dc2626";
},
// 实际绘制 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(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>`;
},
// ④ 最低分样本逐条复核表(点击展开)。
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(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);
row.addEventListener("click", () => {
detail.hidden = !detail.hidden;
});
wrap.appendChild(row);
wrap.appendChild(detail);
});
},
// 单条样本的展开详情question / contexts / answer / ground_truth。
_detailHtml(sample) {
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}
</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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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();
},
};