From 85cd31d2012cd09b6d385232f91552628474d6af Mon Sep 17 00:00:00 2001 From: Jules Neny Date: Fri, 9 Oct 2026 03:59:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(chatbot-pensees):=20garde-fou=20serveur=20?= =?UTF-8?q?des=20citations=20(guillemets=20retir=C3=A9s=20si=20introuvable?= =?UTF-8?q?s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La route demande include_references + include_chunk_content à LightRAG et vérifie chaque segment >= 6 mots entre « » / "…" / “…” dans les chunks réellement récupérés (normalisation accents, casse, apostrophes, ponctuation, élisions [...]). Un segment introuvable perd ses guillemets (texte conservé) ; champ optionnel citations: { gardees, retirees }. Fonction pure dans server/utils/citations.ts, réutilisée par scripts/verif-citations.mjs (qui gagne --max-total-tokens) ; scripts/test-citations.mjs couvre les cas limites. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015vcVGz8i4WpZm2dixKUiSD --- scripts/test-citations.mjs | 35 +++++++++++ scripts/verif-citations.mjs | 28 +++------ server/api/chatbot-pensees.post.ts | 13 +++- server/utils/citations.ts | 99 ++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 21 deletions(-) create mode 100644 scripts/test-citations.mjs create mode 100644 server/utils/citations.ts diff --git a/scripts/test-citations.mjs b/scripts/test-citations.mjs new file mode 100644 index 0000000..9c9943c --- /dev/null +++ b/scripts/test-citations.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +// Test du garde-fou des citations (server/utils/citations.ts). Usage : node scripts/test-citations.mjs +import assert from 'node:assert/strict' +import { verifierCitations, contenusChunks } from '../server/utils/citations.ts' + +const ctx = ["La technique est devenue autonome, et forme un monde dévorant.\r\nIl n’y a plus de liberté de choix. Plus on parle d’une valeur, plus c'est le signe de son absence."] +const t = (rep, c = ctx) => verifierCitations(rep, c) + +// retrouvée (apostrophes, casse, ponctuation de bord, retours chariot) : gardée +let r = t('Ellul écrit : « Il n\'y a plus de liberté de choix ! » donc.') +assert.equal(r.gardees, 1); assert.equal(r.retirees, 0); assert.ok(r.texte.includes('«')) +// introuvable : guillemets retirés, texte conservé +r = t('Il dit : « La technique est une machine à fabriquer du pouvoir social inévitable ». Fin.') +assert.equal(r.retirees, 1); assert.equal(r.texte, 'Il dit : La technique est une machine à fabriquer du pouvoir social inévitable. Fin.') +// guillemets droits et anglais +r = t('"Une phrase inventée qui ne figure nulle part dans le texte" et “Une autre phrase inventée qui ne figure nulle part ici”') +assert.equal(r.retirees, 2); assert.ok(!/["“”]/.test(r.texte)) +// court (< 6 mots) : intact, non compté +r = t('Le « rift métabolique » et la « ville intelligente ».') +assert.equal(r.gardees + r.retirees, 0); assert.ok(r.texte.includes('« rift métabolique »')) +// élision [...] : chaque morceau doit exister +r = t('« Plus on parle d\'une valeur [...] le signe de son absence »') +assert.equal(r.gardees, 1) +r = t('« Plus on parle d\'une valeur [...] le signe de sa présence éclatante »') +assert.equal(r.retirees, 1) +// pas de correspondance en milieu de mot +r = t('« ormation autonome, et forme un monde dévorant »') +assert.equal(r.retirees, 1) +// aucun contexte : rien de long n'est gardé +r = t('« Il n\'y a plus de liberté de choix »', []) +assert.equal(r.retirees, 1) +// contenusChunks : tableau de chaînes ou chaîne +assert.deepEqual(contenusChunks([{ content: ['a', 'b'] }, { content: 'c' }, {}, null]), ['a', 'b', 'c']) +assert.deepEqual(contenusChunks(undefined), []) +console.log('citations : tous les cas passent') diff --git a/scripts/verif-citations.mjs b/scripts/verif-citations.mjs index 78b1b4a..ad402b1 100644 --- a/scripts/verif-citations.mjs +++ b/scripts/verif-citations.mjs @@ -6,16 +6,18 @@ * 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] + * [--response-file reponse.txt] [--ctx-query-file autre-query.txt] [--min-mots 6] [--max-total-tokens 40000 (mode approfondi)] [--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é. + * - Segment >= min-mots (6) absent du contexte = inventé (algorithme : server/utils/citations.ts, partagé avec la route). 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' +// Node >= 22.18 charge le .ts directement (syntaxe effaçable uniquement dans citations.ts). +import { verifierCitations } from '../server/utils/citations.ts' 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]) @@ -25,18 +27,13 @@ if (!args.rag || !args.query) { console.error('--rag et --query requis'); proces 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 }) + const j = await post(`${args.rag}/query`, { query: q, mode, only_need_context: true, ...(args['max-total-tokens'] ? { max_total_tokens: Number(args['max-total-tokens']) } : {}) }) return typeof j.response === 'string' ? j.response : JSON.stringify(j) } @@ -51,18 +48,9 @@ else { 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) } -}) +// Même fonction pure que la route (server/utils/citations.ts) : un seul algorithme, deux usages. +const v = verifierCitations(reponse, ctxs, minMots) +const lignes = v.segments.map(l => ({ segment: l.segment, mots: l.mots, trouve: l.trouve })) const longs = lignes.filter(l => l.mots >= minMots) const res = { query: args.query, mode, diff --git a/server/api/chatbot-pensees.post.ts b/server/api/chatbot-pensees.post.ts index 332053b..b217a11 100644 --- a/server/api/chatbot-pensees.post.ts +++ b/server/api/chatbot-pensees.post.ts @@ -7,6 +7,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { checkRateLimitJson } from '~/server/utils/rateLimitJson' import { profondeurDuBody, REGLE_GUILLEMETS } from '~/server/utils/bifrost' +import { verifierCitations, contenusChunks } from '~/server/utils/citations' /** * Règle citations — commune aux 4 préambules (mesure M7 du 05/10 : le modèle invente des @@ -158,6 +159,9 @@ export default defineEventHandler(async (event) => { const ragBody: Record = { query, mode, + // textes des chunks utilisés : sert au garde-fou des citations (server/utils/citations.ts) + include_references: true, + include_chunk_content: true, user_prompt: profondeur === 'approfondi' ? `${systemPreface}\n\n${POSTURE_REFLEXION}\n\n${REGLE_CITATIONS}` : `${systemPreface}\n\n${REGLE_CITATIONS}`, @@ -193,8 +197,15 @@ export default defineEventHandler(async (event) => { } } + // Garde-fou : un segment long entre guillemets absent des chunks récupérés perd ses guillemets. + const verif = verifierCitations(String(ragResponse.response ?? ''), contenusChunks(ragResponse.references)) + if (verif.retirees > 0) { + console.warn(`[chatbot-pensees] ${verif.retirees} citation(s) non retrouvée(s) dans le contexte, guillemets retirés (${verif.gardees} gardée(s))`) + } + return { - response: ragResponse.response ?? '', + response: verif.texte, + citations: { gardees: verif.gardees, retirees: verif.retirees }, mode, corpus, auteur: auteurSlug && nomAuteurMatch ? { slug: auteurSlug, nom: nomAuteurMatch } : null, diff --git a/server/utils/citations.ts b/server/utils/citations.ts new file mode 100644 index 0000000..ddf71c6 --- /dev/null +++ b/server/utils/citations.ts @@ -0,0 +1,99 @@ +/** + * Garde-fou des citations (B1-M4) — fonctions pures, sans dépendance (réutilisables par les scripts Node). + * + * Problème : une consigne de prompt (REGLE_CITATIONS) ne suffit pas, le modèle met entre guillemets des + * phrases qui ne figurent pas dans le contexte récupéré (mesuré le 09/10 : 7/7 introuvables sur une + * question ouverte). Une fausse citation attribuée à un auteur réel engage le site : on la vérifie dans + * le code. Tout segment long entre guillemets absent des textes de chunks réellement récupérés perd ses + * guillemets (le texte reste, sans valeur de citation). + */ + +export const MIN_MOTS_CITATION = 6 + +/** Sans accents, minuscules, tout ce qui n'est ni lettre ni chiffre devient un espace (apostrophes, \r\n, markdown). */ +export function normaliserTexte(s: string): string { + return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() +} + +export interface SegmentCitation { + /** texte tel qu'il apparaît entre les guillemets */ + segment: string + mots: number + /** true si segment court (< minMots) : jamais vérifié, jamais retiré */ + court: boolean + /** true si retrouvé dans le contexte (toujours true pour un segment court) */ + trouve: boolean +} + +export interface ResultatCitations { + /** réponse dont les citations longues introuvables ont perdu leurs guillemets */ + texte: string + gardees: number + retirees: number + segments: SegmentCitation[] +} + +// « … » (espaces fines ou insécables tolérés), “ … ”, " … ". Les « » sont traités en premier : +// un "…" imbriqué ne doit pas casser l'appariement. +const MOTIFS: RegExp[] = [ + /«[\s  ]*([^»]+?)[\s  ]*»/g, + /“([^”]+?)”/g, + /"([^"\n]+?)"/g, +] + +// Élision dans une citation : [...], […], … ou ... — chaque morceau doit être retrouvé séparément. +const ELISION = /\[\s*(?:\.{3}|…)\s*\]|…|\.{3}/ + +function mots(n: string): number { + return n ? n.split(' ').length : 0 +} + +/** + * Vérifie les citations de `reponse` contre `contextes` (textes des chunks récupérés). + * - segment >= minMots mots : cherché (normalisé, borné aux mots entiers) ; absent => guillemets retirés. + * - segment < minMots mots (titres, expressions) : laissé tel quel. + * Aucun contexte exploitable => aucune citation longue ne peut être vérifiée, donc aucune n'est gardée. + */ +export function verifierCitations(reponse: string, contextes: string[], minMots: number = MIN_MOTS_CITATION): ResultatCitations { + const ctx = ` ${normaliserTexte(contextes.join('\n'))} ` + const segments: SegmentCitation[] = [] + let gardees = 0 + let retirees = 0 + + const trouve = (seg: string): boolean => { + const parts = seg.split(ELISION).map(normaliserTexte).filter(Boolean) + return parts.length > 0 && parts.every(p => ctx.includes(` ${p} `)) + } + + let texte = reponse + for (const motif of MOTIFS) { + texte = texte.replace(motif, (entier: string, seg: string) => { + const n = normaliserTexte(seg.split(ELISION).join(' ')) + const nb = mots(n) + if (nb < minMots) { + segments.push({ segment: seg, mots: nb, court: true, trouve: true }) + return entier + } + const ok = trouve(seg) + segments.push({ segment: seg, mots: nb, court: false, trouve: ok }) + if (ok) { gardees++; return entier } + retirees++ + return seg + }) + } + return { texte, gardees, retirees, segments } +} + +/** Concatène les textes de chunks d'une réponse LightRAG (`references[].content`, tableau de chaînes ou chaîne). */ +export function contenusChunks(references: unknown): string[] { + if (!Array.isArray(references)) return [] + const out: string[] = [] + for (const r of references) { + const c = (r as { content?: unknown })?.content + if (Array.isArray(c)) for (const x of c) { if (typeof x === 'string') out.push(x) } + else if (typeof c === 'string') out.push(c) + } + return out +}