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
69 lines
3.7 KiB
JavaScript
69 lines
3.7 KiB
JavaScript
#!/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] [--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é (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])
|
|
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)
|
|
|
|
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, ...(args['max-total-tokens'] ? { max_total_tokens: Number(args['max-total-tokens']) } : {}) })
|
|
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')))
|
|
// 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,
|
|
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)}`)
|
|
}
|