/** * 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 }