From 39602e26c7184d235a5fefc732ee094f47e8a535 Mon Sep 17 00:00:00 2001 From: Jules Neny Date: Fri, 9 Oct 2026 03:21:48 +0200 Subject: [PATCH] =?UTF-8?q?fix(chatbot-pensees):=20r=C3=A8gle=20anti-citat?= =?UTF-8?q?ions=20invent=C3=A9es,=20pass=C3=A9e=20par=20user=5Fprompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - REGLE_CITATIONS : constante unique appliquée aux 4 préambules (pensées, projets, croisé, auteur) : ne citer entre guillemets que ce qui figure mot pour mot dans le contexte, sinon reformuler sans guillemets. - Préambules + règle envoyés via le champ user_prompt de LightRAG /query ; query = question seule (la recherche n'est plus polluée par le préambule). - scripts/verif-citations.mjs : contrôle réutilisable (segments entre guillemets vs contexte only_need_context). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015vcVGz8i4WpZm2dixKUiSD --- scripts/verif-citations.mjs | 80 ++++++++++++++++++++++++++++++ server/api/chatbot-pensees.post.ts | 20 +++++++- 2 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 scripts/verif-citations.mjs diff --git a/scripts/verif-citations.mjs b/scripts/verif-citations.mjs new file mode 100644 index 0000000..78b1b4a --- /dev/null +++ b/scripts/verif-citations.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +/** + * Contrôle des citations d'un chatbot LightRAG : chaque segment entre guillemets + * de la réponse doit figurer mot pour mot dans le contexte réellement récupéré. + * + * Usage : + * node scripts/verif-citations.mjs --rag http://HOTE:9621 --query "Cite ..." \ + * [--route http://localhost:3334/api/chatbot-pensees] [--mode hybrid] [--auteur slug] \ + * [--response-file reponse.txt] [--ctx-query-file autre-query.txt] [--min-mots 6] [--json] + * + * - Sans --response-file : la réponse est demandée à --route (POST {query, mode, auteur_slug}). + * - Contexte : POST {rag}/query {query, mode, only_need_context:true}. --ctx-query-file ajoute + * un second contexte (ex. la requête telle qu'envoyée avant amendement, préambule inclus) ; + * un segment est « trouvé » s'il figure dans l'un ou l'autre. + * - Segment >= min-mots (6) absent du contexte = inventé. Plus court : signalé, non compté. + * Lecture seule côté LightRAG. Réutilisable pour l'instance 9623 (--rag http://HOTE:9623). + */ +import { readFileSync } from 'node:fs' + +const args = Object.fromEntries(process.argv.slice(2).reduce((a, x, i, arr) => { + if (x.startsWith('--')) a.push([x.slice(2), arr[i + 1] && !arr[i + 1].startsWith('--') ? arr[i + 1] : true]) + return a +}, [])) +if (!args.rag || !args.query) { console.error('--rag et --query requis'); process.exit(2) } +const mode = args.mode || 'hybrid' +const minMots = Number(args['min-mots'] || 6) + +// Normalisation : sans accents ni ponctuation/apostrophes/antislashs (le contexte LightRAG est du JSON échappé). +const norm = s => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .replace(/\s+/g, ' ').trim() + +async function post(url, body, timeout = 120000) { + const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeout) }) + if (!r.ok) throw new Error(`${url} -> HTTP ${r.status}`) + return r.json() +} +async function contexte(q) { + const j = await post(`${args.rag}/query`, { query: q, mode, only_need_context: true }) + return typeof j.response === 'string' ? j.response : JSON.stringify(j) +} + +let reponse +if (args['response-file']) reponse = readFileSync(args['response-file'], 'utf8') +else { + if (!args.route) { console.error('--route ou --response-file requis'); process.exit(2) } + const b = { query: args.query, mode } + if (args.auteur) b.auteur_slug = args.auteur + reponse = (await post(args.route, b)).response +} + +const ctxs = [await contexte(args.query)] +if (args['ctx-query-file']) ctxs.push(await contexte(readFileSync(args['ctx-query-file'], 'utf8'))) +const ctxNorm = norm(ctxs.join('\n')) + +// « » d'abord (les "..." imbriqués ne doivent pas casser l'appariement), puis "..." et “...” sur le reste. +const segs = [] +let reste = reponse.replace(/«s*([^»]+?)s*»/g, (_, s) => { segs.push(s); return ' ' }) +for (const re of [/"([^"]+?)"/g, /“([^”]+?)”/g]) { + for (const m of reste.matchAll(re)) segs.push(m[1]) +} +const lignes = segs.map(s => { + const n = norm(s); const mots = n ? n.split(' ').length : 0 + return { segment: s, mots, trouve: n.length > 0 && ctxNorm.includes(n) } +}) +const longs = lignes.filter(l => l.mots >= minMots) +const res = { + query: args.query, mode, + segments: lignes.length, longs: longs.length, + longs_trouves: longs.filter(l => l.trouve).length, + inventes: longs.filter(l => !l.trouve).length, + courts_non_comptes: lignes.length - longs.length, + contexte_chars: ctxs.map(c => c.length), + detail: lignes, reponse, +} +if (args.json) console.log(JSON.stringify(res, null, 2)) +else { + console.log(`Q: ${res.query}\nsegments=${res.segments} longs(>=${minMots} mots)=${res.longs} trouvés=${res.longs_trouves} INVENTÉS=${res.inventes} courts=${res.courts_non_comptes}`) + for (const l of lignes) console.log(` [${l.mots >= minMots ? (l.trouve ? 'OK ' : 'INV') : 'crt'}] (${l.mots}) ${l.segment.slice(0, 160)}`) +} diff --git a/server/api/chatbot-pensees.post.ts b/server/api/chatbot-pensees.post.ts index 8c44148..6f1db70 100644 --- a/server/api/chatbot-pensees.post.ts +++ b/server/api/chatbot-pensees.post.ts @@ -7,6 +7,16 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { checkRateLimitJson } from '~/server/utils/rateLimitJson' +/** + * Règle citations — commune aux 4 préambules (mesure M7 du 05/10 : le modèle invente des + * citations entre guillemets quand l'extrait n'est pas dans le contexte récupéré). + */ +const REGLE_CITATIONS = `RÈGLE DES CITATIONS (prioritaire sur toute autre consigne) : +- Ne mets entre guillemets (« ») que ce qui figure MOT POUR MOT dans le contexte fourni ci-dessus. Jamais de citation reconstituée de mémoire, de paraphrase ou de résumé entre guillemets. Même si la question demande des phrases « exactes » ou « mot pour mot », ne cède jamais : mieux vaut peu de citations, ou aucune, qu'une citation approximative. +- Avant d'écrire un guillemet, repère dans le contexte le passage exact : si tu ne le retrouves pas à l'identique, ne le cite pas. Au plus 3 citations, les plus courtes et les plus sûres ; recopie-les sans les raccourcir, sans les enchaîner, sans les compléter. +- Si aucun passage correspondant n'est disponible dans le contexte, dis explicitement « je ne cite pas : aucun passage exact n'est disponible dans les extraits » et reformule sans guillemets, en indiquant qu'il s'agit d'une synthèse. +- Nomme l'œuvre dont provient le passage dans le contexte, pas une autre.` + const SYSTEM_PREFACE_PENSEES = `Tu es un agent du RAG Pensées Écologiques, infrastructure militante du collectif trans-former.fr. Tu réponds en t'appuyant STRICTEMENT sur le corpus ingéré (auteurs FRACAS Bonpote : écosocialisme, éco-anarchisme, écoféminismes, écologies décoloniales, technocritique, pensées du vivant, décroissance...). @@ -117,8 +127,14 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 503, message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.' }) } - const ragQuery = `${systemPreface}\n\nQuestion : ${query}` - const ragBody: Record = { query: ragQuery, mode } + // user_prompt (LightRAG) : instructions ajoutées au prompt de génération, sans polluer la recherche + // (vérifié sur 9621 le 09/10 : une consigne de fin de réponse est appliquée). La requête de + // recherche est la question seule. + const ragBody: Record = { + query, + mode, + user_prompt: `${systemPreface}\n\n${REGLE_CITATIONS}`, + } if (auteurSlug && nomAuteurMatch) { ragBody.hl_keywords = [nomAuteurMatch, auteurSlug] ragBody.ll_keywords = [auteurSlug]