feat(chatbot-pensees): garde-fou serveur des citations (guillemets retirés si introuvables)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vcVGz8i4WpZm2dixKUiSD
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
52b79e1e8e
commit
85cd31d201
@@ -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')
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, any> = {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user