const API_BASE = "/api"; const DECISION_HINTS = { CORRECT: "Contexte assez solide. Passages inutiles filtrés.", AMBIGUOUS: "Qualité moyenne. Reformulation et recherche complémentaire.", INCORRECT: "Contexte trop faible. SQL ou refus.", }; const METRIC_META = { context_precision: { label: "Précision retrieval", group: "retrieval", kind: "pct", higherIsBetter: true, }, context_recall: { label: "Rappel retrieval", group: "retrieval", kind: "pct", higherIsBetter: true, }, retrieval_hit_rate: { label: "Hit rate retrieval", group: "retrieval", kind: "pct", higherIsBetter: true, }, faithfulness: { label: "Fidélité", group: "quality", kind: "pct", higherIsBetter: true, }, answer_relevancy: { label: "Pertinence", group: "quality", kind: "pct", higherIsBetter: true, }, abstention_accuracy: { label: "Refus juste", group: "quality", kind: "pct", higherIsBetter: true, }, avg_latency_ms: { label: "Latence moyenne", group: "time", kind: "ms", higherIsBetter: false, }, sql_execution_accuracy: { label: "Exactitude SQL", group: "quality", kind: "pct", higherIsBetter: true, }, fallback_rate: { label: "Taux de correction", group: "quality", kind: "pct", higherIsBetter: null, }, }; const views = { chat: "view-chat", metrics: "view-metrics", about: "view-about", }; document.querySelectorAll(".nav-item").forEach((btn) => { btn.addEventListener("click", () => { document.querySelectorAll(".nav-item").forEach((b) => { b.classList.remove("active"); }); btn.classList.add("active"); Object.values(views).forEach((id) => { document.getElementById(id).classList.add("hidden"); }); document.getElementById(views[btn.dataset.view]).classList.remove("hidden"); if (btn.dataset.view === "metrics") loadMetrics(); }); }); async function checkHealth() { const dot = document.getElementById("status-dot"); const text = document.getElementById("status-text"); try { const res = await fetch(`${API_BASE}/health`); const data = await res.json(); if (data.status === "starting" || data.embedding_backend === "loading") { text.textContent = "Démarrage…"; setTimeout(checkHealth, 2000); return; } dot.classList.add("ok"); text.textContent = data.llm_provider === "mock" ? "Prêt (mock)" : "Prêt"; } catch (e) { dot.classList.add("err"); text.textContent = "Hors ligne"; } } checkHealth(); const messagesEl = document.getElementById("messages"); const form = document.getElementById("composer"); const input = document.getElementById("question-input"); const sendBtn = document.getElementById("send-btn"); function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function appendMessage(role, text, options = {}) { const wrapper = document.createElement("div"); const extras = []; if (options.isRefusal) extras.push("refusal"); if (options.thinking) extras.push("thinking"); wrapper.className = `message ${role}` + (extras.length ? " " + extras.join(" ") : ""); const bubble = document.createElement("div"); bubble.className = "bubble"; bubble.textContent = text; wrapper.appendChild(bubble); messagesEl.appendChild(wrapper); messagesEl.scrollTop = messagesEl.scrollHeight; return wrapper; } function renderGrades(grades) { const box = document.getElementById("grades-list"); box.innerHTML = ""; if (!grades || !grades.length) { box.innerHTML = '

Aucun grade

'; return; } grades.slice(0, 5).forEach((g) => { const row = document.createElement("div"); row.className = "grade-row"; const doc = g.doc_id || g.chunk_id || "passage"; row.innerHTML = `
${escapeHtml(String(doc))}
${g.score ?? "—"}/100
${escapeHtml(g.decision || "")}${ g.reason ? " · " + escapeHtml(g.reason) : "" }
`; box.appendChild(row); }); } function renderContext(data) { const badge = document.getElementById("decision-badge"); const hint = document.getElementById("decision-hint"); badge.textContent = data.decision || "—"; badge.className = `decision ${data.decision || ""}`; hint.textContent = DECISION_HINTS[data.decision] || ""; const sourceList = document.getElementById("source-list"); sourceList.innerHTML = ""; if (!data.sources || !data.sources.length) { sourceList.innerHTML = '
  • Aucune source
  • '; } else { data.sources.forEach((s) => { const li = document.createElement("li"); li.textContent = s; sourceList.appendChild(li); }); } renderGrades(data.grades || []); const traceList = document.getElementById("trace-list"); traceList.innerHTML = ""; const steps = data.trace || []; if (!steps.length) { traceList.innerHTML = '
  • En attente — posez une question
  • '; } else { steps.forEach((t, i) => { const li = document.createElement("li"); li.innerHTML = `${i + 1}` + `${escapeHtml(t)}`; traceList.appendChild(li); }); } document.getElementById("diag-supported").textContent = data.supported ? "Oui" : "Non"; document.getElementById("diag-structured").textContent = data.used_structured_data ? "Oui" : "Non"; document.getElementById("diag-loops").textContent = data.loop_count; document.getElementById("diag-latency").textContent = `${data.latency_ms} ms`; } async function sendQuestion(question) { appendMessage("user", question); input.value = ""; sendBtn.disabled = true; const thinking = appendMessage("assistant", "Analyse…", { thinking: true }); try { const res = await fetch(`${API_BASE}/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question }), }); const data = await res.json(); thinking.remove(); if (!res.ok) { const detail = typeof data.detail === "string" ? data.detail : "Erreur serveur."; appendMessage("assistant", detail, { isRefusal: true }); return; } const isRefusal = data.decision === "INCORRECT" && !data.used_structured_data && (!data.sources || data.sources.length === 0); appendMessage("assistant", data.answer, { isRefusal }); renderContext(data); } catch (e) { thinking.remove(); appendMessage("assistant", "Impossible de joindre le serveur.", { isRefusal: true, }); } finally { sendBtn.disabled = false; } } form.addEventListener("submit", (e) => { e.preventDefault(); const q = input.value.trim(); if (q) sendQuestion(q); }); document.querySelectorAll(".suggestion").forEach((chip) => { chip.addEventListener("click", () => sendQuestion(chip.dataset.q)); }); let metricsLoaded = false; function formatValue(key, value) { const meta = METRIC_META[key]; if (typeof value !== "number") return "—"; if (meta.kind === "ms") return `${Math.round(value)} ms`; return `${(value * 100).toFixed(1)} %`; } function formatDelta(key, classic, crag) { const meta = METRIC_META[key]; if (typeof classic !== "number" || typeof crag !== "number") return "—"; if (meta.kind === "ms") { const d = Math.round(crag - classic); const sign = d > 0 ? "+" : ""; return `${sign}${d} ms`; } const d = (crag - classic) * 100; const sign = d > 0 ? "+" : ""; return `${sign}${d.toFixed(1)} pts`; } function renderScoreCards(containerId, keys, crag, classic) { const grid = document.getElementById(containerId); grid.innerHTML = ""; keys.forEach((key) => { if (typeof crag[key] !== "number") return; const meta = METRIC_META[key]; const card = document.createElement("article"); card.className = "score-card"; const delta = typeof classic[key] === "number" ? `
    ${formatDelta(key, classic[key], crag[key])} vs classique
    ` : ""; card.innerHTML = `
    ${meta.label}
    ${formatValue(key, crag[key])}
    ${delta} `; grid.appendChild(card); }); } async function loadMetrics() { if (metricsLoaded) return; const res = await fetch(`${API_BASE}/metrics`); const data = await res.json(); if (!data.available) { document.getElementById("metrics-empty").classList.remove("hidden"); document.getElementById("metrics-content").classList.add("hidden"); return; } metricsLoaded = true; document.getElementById("metrics-empty").classList.add("hidden"); document.getElementById("metrics-content").classList.remove("hidden"); const crag = data.corrective_rag || {}; const classic = data.rag_classique || {}; document.getElementById("metrics-n").textContent = `${data.n_questions ?? "—"} questions`; document.getElementById("metrics-generated").textContent = data.generated_at ? data.generated_at.replace("T", " ") : ""; renderScoreCards( "retrieval-grid", ["context_precision", "context_recall", "retrieval_hit_rate"], crag, classic, ); renderScoreCards( "quality-grid", ["faithfulness", "abstention_accuracy", "answer_relevancy"], crag, classic, ); renderScoreCards("time-grid", ["avg_latency_ms"], crag, classic); const tbody = document.getElementById("metrics-tbody"); tbody.innerHTML = ""; Object.keys(METRIC_META).forEach((key) => { if (typeof crag[key] !== "number" && typeof classic[key] !== "number") { return; } const tr = document.createElement("tr"); const delta = formatDelta(key, classic[key], crag[key]); const better = METRIC_META[key].higherIsBetter === true && typeof classic[key] === "number" && crag[key] > classic[key]; const worse = METRIC_META[key].higherIsBetter === true && typeof classic[key] === "number" && crag[key] < classic[key]; const cls = better ? "delta-up" : worse ? "delta-down" : ""; tr.innerHTML = ` ${METRIC_META[key].label} ${formatValue(key, classic[key])} ${formatValue(key, crag[key])} ${delta} `; tbody.appendChild(tr); }); document.getElementById("metrics-footnote").textContent = "Scores mock non représentatifs. Relancer evaluate.py avec HF_TOKEN."; }