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.";
}