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();