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 metricSections(metrics) { const groups = [ { title: "Primary Ranking", keys: ["NDCG@5", "NDCG@10", "NDCG@20", "NDCG@50"], description: "Graded ranking quality across the four relevance tiers.", }, { title: "Top Slot Quality", keys: ["Exact_Precision@5", "Exact_Precision@10", "Strong_Precision@5", "Strong_Precision@10", "Strong_Precision@20"], description: "How much of the visible top rank is exact or strong business relevance.", }, { title: "Recall Coverage", keys: ["Useful_Precision@10", "Useful_Precision@20", "Useful_Precision@50", "Gain_Recall@10", "Gain_Recall@20", "Gain_Recall@50"], description: "How much judged relevance is captured in the returned list.", }, { title: "First Good Result", keys: ["Exact_Success@5", "Exact_Success@10", "Strong_Success@5", "Strong_Success@10", "MRR_Exact@10", "MRR_Strong@10", "Avg_Grade@10"], description: "Whether users see a good result early and how good the top page feels overall.", }, ]; const seen = new Set(); return groups .map((group) => { const items = group.keys .filter((key) => metrics && Object.prototype.hasOwnProperty.call(metrics, key)) .map((key) => { seen.add(key); return [key, metrics[key]]; }); return { ...group, items }; }) .filter((group) => group.items.length) .concat( (() => { const rest = Object.entries(metrics || {}).filter(([key]) => !seen.has(key)); return rest.length ? [{ title: "Other Metrics", description: "", items: rest }] : []; })() ); } function renderMetrics(metrics, metricContext) { const root = document.getElementById("metrics"); root.innerHTML = ""; const ctx = document.getElementById("metricContext"); const gainScheme = metricContext && metricContext.gain_scheme; const primary = metricContext && metricContext.primary_metric; ctx.textContent = primary ? `Primary metric: ${primary}. Gain scheme: ${Object.entries(gainScheme || {}).map(([label, gain]) => `${label}=${gain}`).join(", ")}.` : ""; metricSections(metrics || {}).forEach((section) => { const wrap = document.createElement("section"); wrap.className = "metric-section"; wrap.innerHTML = `

${section.title}

${section.description ? `

${section.description}

` : ""}
`; const grid = wrap.querySelector(".metric-grid"); section.items.forEach(([key, value]) => { const card = document.createElement("div"); card.className = "metric"; card.innerHTML = `
${key}
${fmtNumber(value)}
`; grid.appendChild(card); }); root.appendChild(wrap); }); } 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["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();