Files
nav-carte/server/utils/citations.ts
T
Jules NenyandClaude Opus 5.5 85cd31d201 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
2026-10-09 03:59:32 +02:00

100 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
}