// Évaluation RAG hors-ligne : rappel des sources attendues, refus corrects hors corpus, // isolation stricte entre les cours. Produit evaluation/rag-evaluation.md. import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { hybridSearch, isConfidentEnough } from "../lib/rag/search.ts"; type TestSet = { course: string; questions: { q: string; expectDoc?: string; expectDocAny?: string[] }[]; outOfScope: string[]; isolationProbes?: { q: string; mustNotContainDoc: string; note: string }[]; }; const sets: TestSet[] = ["imm1003-test-set.json", "imm1033-test-set.json"].map((f) => JSON.parse(readFileSync(resolve(process.cwd(), "evaluation", f), "utf8")) ); const lines: string[] = [ "# Évaluation RAG — Immbot AI", "", `Exécutée le ${new Date().toISOString().slice(0, 16).replace("T", " ")} (recherche hybride hors-ligne, sans appel LLM).`, "", "Critère de rappel : le document attendu figure dans les sources récupérées (top-k).", "", ]; let totalRecall = 0, totalQuestions = 0, totalRefusals = 0, totalOOS = 0, isolationOK = 0, isolationTotal = 0; for (const set of sets) { const space = `official-${set.course.toLowerCase()}`; lines.push(`## ${set.course}`, "", "| Question | Attendu | Trouvé | Rappel |", "|---|---|---|---|"); for (const q of set.questions) { const results = await hybridSearch({ query: q.q, spaces: [space], k: 10 }); const files = [...new Set(results.map((r) => r.filename))]; const expected = q.expectDoc ? [q.expectDoc] : (q.expectDocAny ?? []); const hit = expected.some((e) => files.includes(e)); totalQuestions++; if (hit) totalRecall++; lines.push( `| ${q.q.slice(0, 70)} | ${expected.join(" ou ")} | ${files.slice(0, 3).join(", ")} | ${hit ? "✅" : "❌"} |` ); } lines.push("", "### Refus hors corpus (mode « Cours uniquement »)", ""); for (const q of set.outOfScope) { const results = await hybridSearch({ query: q, spaces: [space], k: 10 }); const confident = isConfidentEnough(results) && results[0].score > 2 / 61; // seuil : présence dans le top des DEUX recherches totalOOS++; if (!confident) totalRefusals++; lines.push(`- « ${q} » → ${confident ? "⚠ contexte jugé pertinent (à surveiller)" : "✅ refus (aucun contexte suffisant)"}`); } for (const probe of set.isolationProbes ?? []) { const results = await hybridSearch({ query: probe.q, spaces: [space], k: 10 }); const leaked = results.some((r) => r.filename === probe.mustNotContainDoc || (r.course_code && r.course_code !== set.course)); isolationTotal++; if (!leaked) isolationOK++; lines.push("", `### Isolation inter-cours`, "", `- ${probe.note} → ${leaked ? "❌ FUITE DÉTECTÉE" : "✅ aucun fragment de l'autre cours"}`); } lines.push(""); } const recallPct = ((totalRecall / totalQuestions) * 100).toFixed(0); lines.push( "## Synthèse", "", `| Mesure | Résultat |`, `|---|---|`, `| Rappel des sources attendues | ${totalRecall}/${totalQuestions} (${recallPct} %) |`, `| Refus corrects hors corpus | ${totalRefusals}/${totalOOS} |`, `| Isolation inter-cours | ${isolationOK}/${isolationTotal} |`, "", "La validation stricte des citations (toute balise [Sx] hors contexte est neutralisée) est couverte par les tests unitaires (`tests/citations.test.ts`)." ); writeFileSync(resolve(process.cwd(), "evaluation/rag-evaluation.md"), lines.join("\n")); console.log(lines.join("\n")); const ok = totalRecall / totalQuestions >= 0.8 && isolationOK === isolationTotal; console.log(ok ? "\n✅ Évaluation RAG : critères atteints" : "\n❌ Évaluation RAG : sous les critères (rappel ≥ 80 % exigé, isolation parfaite exigée)"); process.exit(ok ? 0 : 1);