nvikou's picture
Metrics side-by-side overview; CRAG arch diagram; clear pipeline steps
60e7ddd verified
Raw History Blame Contribute Delete
10.7 kB
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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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 = '<p class="muted small">Aucun grade</p>';
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 = `
<div class="grade-doc">${escapeHtml(String(doc))}</div>
<div class="grade-score">${g.score ?? "—"}/100</div>
<div class="grade-meta">${escapeHtml(g.decision || "")}${
g.reason ? " · " + escapeHtml(g.reason) : ""
}</div>
`;
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 = '<li class="muted">Aucune source</li>';
} 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 =
'<li class="muted">En attente — posez une question</li>';
} else {
steps.forEach((t, i) => {
const li = document.createElement("li");
li.innerHTML =
`<span class="step-idx">${i + 1}</span>` +
`<span class="step-text">${escapeHtml(t)}</span>`;
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"
? `<div class="delta">${formatDelta(key, classic[key], crag[key])} vs classique</div>`
: "";
card.innerHTML = `
<div class="label">${meta.label}</div>
<div class="value">${formatValue(key, crag[key])}</div>
${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 = `
<td>${METRIC_META[key].label}</td>
<td class="num">${formatValue(key, classic[key])}</td>
<td class="num strong">${formatValue(key, crag[key])}</td>
<td class="num ${cls}">${delta}</td>
`;
tbody.appendChild(tr);
});
document.getElementById("metrics-footnote").textContent =
"Scores mock non représentatifs. Relancer evaluate.py avec HF_TOKEN.";
}