async function fetchJSON(url, options) {
const res = await fetch(url, options);
if (!res.ok) throw new Error(await res.text());
return await res.json();
}
function fmtNumber(value, digits = 3) {
if (value == null || Number.isNaN(Number(value))) return "-";
return Number(value).toFixed(digits);
}
function metricColumns(metrics) {
const defs = [
{ title: "NDCG", keys: ["NDCG@5", "NDCG@10", "NDCG@20", "NDCG@50"] },
{ title: "ERR", keys: ["ERR@5", "ERR@10", "ERR@20", "ERR@50"] },
{
title: "Top slot",
keys: [
"Exact_Precision@5",
"Exact_Precision@10",
"Strong_Precision@5",
"Strong_Precision@10",
"Strong_Precision@20",
],
},
{
title: "Recall",
keys: [
"Useful_Precision@10",
"Useful_Precision@20",
"Useful_Precision@50",
"Gain_Recall@10",
"Gain_Recall@20",
"Gain_Recall@50",
],
},
{
title: "First good",
keys: [
"Exact_Success@5",
"Exact_Success@10",
"Strong_Success@5",
"Strong_Success@10",
"MRR_Exact@10",
"MRR_Strong@10",
"Avg_Grade@10",
],
},
];
const seen = new Set();
const columns = defs
.map((col) => {
const rows = col.keys
.filter((key) => metrics && Object.prototype.hasOwnProperty.call(metrics, key))
.map((key) => {
seen.add(key);
return [key, metrics[key]];
});
return { title: col.title, rows };
})
.filter((col) => col.rows.length);
const rest = Object.keys(metrics || {})
.filter((key) => !seen.has(key))
.sort()
.map((key) => [key, metrics[key]]);
if (rest.length) columns.push({ title: "Other", rows: rest });
return columns;
}
function renderMetrics(metrics, metricContext) {
const root = document.getElementById("metrics");
root.innerHTML = "";
const ctx = document.getElementById("metricContext");
const parts = [];
if (metricContext && metricContext.primary_metric) {
parts.push(`Primary: ${metricContext.primary_metric}`);
}
if (metricContext && metricContext.gain_scheme) {
parts.push(
`NDCG gains: ${Object.entries(metricContext.gain_scheme)
.map(([label, gain]) => `${label}=${gain}`)
.join(", ")}`
);
}
if (metricContext && metricContext.stop_prob_scheme) {
parts.push(
`ERR P(stop): ${Object.entries(metricContext.stop_prob_scheme)
.map(([label, p]) => `${label}=${p}`)
.join(", ")}`
);
}
ctx.textContent = parts.length ? `${parts.join(". ")}.` : "";
const bar = document.createElement("div");
bar.className = "metrics-columns";
metricColumns(metrics || {}).forEach((col) => {
const column = document.createElement("div");
column.className = "metric-column";
const h = document.createElement("h4");
h.className = "metric-column-title";
h.textContent = col.title;
column.appendChild(h);
col.rows.forEach(([key, value]) => {
const row = document.createElement("div");
row.className = "metric-row";
row.innerHTML = `${key}: ${fmtNumber(value)}`;
column.appendChild(row);
});
bar.appendChild(column);
});
root.appendChild(bar);
}
function labelBadgeClass(label) {
if (!label || label === "Unknown") return "badge-unknown";
return "label-" + String(label).toLowerCase().replace(/\s+/g, "-");
}
function renderResults(results, rootId = "results", showRank = true) {
const mount = document.getElementById(rootId);
mount.innerHTML = "";
(results || []).forEach((item) => {
const label = item.label || "Unknown";
const box = document.createElement("div");
box.className = "result";
box.innerHTML = `
${label}${showRank ? `#${item.rank || "-"}` : (item.rerank_score != null ? `rerank=${item.rerank_score.toFixed ? item.rerank_score.toFixed(4) : item.rerank_score}` : "not recalled")}
${item.title || ""}
${item.title_zh ? `
${item.title_zh}
` : ""}
${(item.option_values || [])[0] || ""}
${(item.option_values || [])[1] || ""}
${(item.option_values || [])[2] || ""}
`;
mount.appendChild(box);
});
if (!(results || []).length) {
mount.innerHTML = 'None.
';
}
}
function renderTips(data) {
const root = document.getElementById("tips");
const tips = [...(data.tips || [])];
const stats = data.label_stats || {};
tips.unshift(
`Cached labels: ${stats.total || 0}. Recalled hits: ${stats.recalled_hits || 0}. Missed judged useful results: ${stats.missing_relevant_count || 0} (Exact ${stats.missing_exact_count || 0}, High ${stats.missing_high_count || 0}, Low ${stats.missing_low_count || 0}).`
);
root.innerHTML = tips.map((text) => `${text}
`).join("");
}
async function loadQueries() {
const data = await fetchJSON("/api/queries");
const root = document.getElementById("queryList");
root.innerHTML = "";
data.queries.forEach((query) => {
const btn = document.createElement("button");
btn.className = "query-item";
btn.textContent = query;
btn.onclick = () => {
document.getElementById("queryInput").value = query;
runSingle();
};
root.appendChild(btn);
});
}
function historySummaryHtml(meta) {
const m = meta && meta.aggregate_metrics;
const nq = (meta && meta.queries && meta.queries.length) || (meta && meta.per_query && meta.per_query.length) || null;
const parts = [];
if (nq != null) parts.push(`Queries ${nq}`);
if (m && m["NDCG@10"] != null) parts.push(`NDCG@10 ${fmtNumber(m["NDCG@10"])}`);
if (m && m["ERR@10"] != null) parts.push(`ERR@10 ${fmtNumber(m["ERR@10"])}`);
if (m && m["Strong_Precision@10"] != null) parts.push(`Strong@10 ${fmtNumber(m["Strong_Precision@10"])}`);
if (m && m["Gain_Recall@50"] != null) parts.push(`Gain Recall@50 ${fmtNumber(m["Gain_Recall@50"])}`);
if (!parts.length) return "";
return `${parts.join(" · ")}
`;
}
async function loadHistory() {
const data = await fetchJSON("/api/history");
const root = document.getElementById("history");
root.classList.remove("muted");
const items = data.history || [];
if (!items.length) {
root.innerHTML = 'No history yet.';
return;
}
root.innerHTML = ``;
const list = root.querySelector(".history-list");
items.forEach((item) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "history-item";
btn.setAttribute("aria-label", `Open report ${item.batch_id}`);
const sum = historySummaryHtml(item.metadata);
btn.innerHTML = `${item.batch_id}
${item.created_at} · tenant ${item.tenant_id}
${sum}`;
btn.onclick = () => openBatchReport(item.batch_id);
list.appendChild(btn);
});
}
let _lastReportPath = "";
function closeReportModal() {
const el = document.getElementById("reportModal");
el.classList.remove("is-open");
el.setAttribute("aria-hidden", "true");
document.getElementById("reportModalBody").innerHTML = "";
document.getElementById("reportModalMeta").textContent = "";
}
async function openBatchReport(batchId) {
const el = document.getElementById("reportModal");
const body = document.getElementById("reportModalBody");
const metaEl = document.getElementById("reportModalMeta");
const titleEl = document.getElementById("reportModalTitle");
el.classList.add("is-open");
el.setAttribute("aria-hidden", "false");
titleEl.textContent = batchId;
metaEl.textContent = "";
body.className = "report-modal-body batch-report-md report-modal-loading";
body.textContent = "Loading report…";
try {
const rep = await fetchJSON("/api/history/" + encodeURIComponent(batchId) + "/report");
_lastReportPath = rep.report_markdown_path || "";
metaEl.textContent = rep.report_markdown_path || "";
const raw = marked.parse(rep.markdown || "", { gfm: true });
const safe = DOMPurify.sanitize(raw, { USE_PROFILES: { html: true } });
body.className = "report-modal-body batch-report-md";
body.innerHTML = safe;
} catch (e) {
body.className = "report-modal-body report-modal-error";
body.textContent = e && e.message ? e.message : String(e);
}
}
document.getElementById("reportModal").addEventListener("click", (ev) => {
if (ev.target && ev.target.getAttribute("data-close-report") === "1") closeReportModal();
});
document.addEventListener("keydown", (ev) => {
if (ev.key === "Escape") closeReportModal();
});
document.getElementById("reportCopyPath").addEventListener("click", async () => {
if (!_lastReportPath) return;
try {
await navigator.clipboard.writeText(_lastReportPath);
} catch (_) {}
});
async function runSingle() {
const query = document.getElementById("queryInput").value.trim();
if (!query) return;
document.getElementById("status").textContent = `Evaluating "${query}"...`;
const data = await fetchJSON("/api/search-eval", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, top_k: 100, auto_annotate: false }),
});
document.getElementById("status").textContent = `Done. total=${data.total}`;
renderMetrics(data.metrics, data.metric_context);
renderResults(data.results, "results", true);
renderResults(data.missing_relevant, "missingRelevant", false);
renderTips(data);
loadHistory();
}
async function runBatch() {
document.getElementById("status").textContent = "Running batch evaluation...";
const data = await fetchJSON("/api/batch-eval", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ top_k: 100, auto_annotate: false }),
});
document.getElementById("status").textContent = `Batch done. report=${data.batch_id}`;
renderMetrics(data.aggregate_metrics, data.metric_context);
renderResults([], "results", true);
renderResults([], "missingRelevant", false);
document.getElementById("tips").innerHTML = 'Batch evaluation uses cached labels only unless force refresh is requested via CLI/API.
';
loadHistory();
}
loadQueries();
loadHistory();