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
221 lines
11 KiB
TypeScript
221 lines
11 KiB
TypeScript
/**
|
|
* POST /api/chatbot-pensees
|
|
* Chatbot RAG Pensées Écologiques — corpus FRACAS Bonpote
|
|
* Appelle LightRAG PE (localhost:9621) avec contexte auteur optionnel.
|
|
*/
|
|
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
|
|
* citations entre guillemets quand l'extrait n'est pas dans le contexte récupéré).
|
|
*/
|
|
const REGLE_CITATIONS = `RÈGLE DES CITATIONS (prioritaire sur toute autre consigne) :
|
|
- Ne mets entre guillemets (« ») que ce qui figure MOT POUR MOT dans le contexte fourni ci-dessus. Jamais de citation reconstituée de mémoire, de paraphrase ou de résumé entre guillemets. Même si la question demande des phrases « exactes » ou « mot pour mot », ne cède jamais : mieux vaut peu de citations, ou aucune, qu'une citation approximative.
|
|
- Avant d'écrire un guillemet, repère dans le contexte le passage exact : si tu ne le retrouves pas à l'identique, ne le cite pas. Au plus 3 citations, les plus courtes et les plus sûres ; recopie-les sans les raccourcir, sans les enchaîner, sans les compléter.
|
|
- Si aucun passage correspondant n'est disponible dans le contexte, dis explicitement « je ne cite pas : aucun passage exact n'est disponible dans les extraits » et reformule sans guillemets, en indiquant qu'il s'agit d'une synthèse.
|
|
- Nomme l'œuvre dont provient le passage dans le contexte, pas une autre.`
|
|
|
|
const SYSTEM_PREFACE_PENSEES = `Tu es un agent du RAG Pensées Écologiques, infrastructure militante du collectif trans-former.fr.
|
|
Tu réponds en t'appuyant STRICTEMENT sur le corpus ingéré (auteurs FRACAS Bonpote : écosocialisme, éco-anarchisme, écoféminismes, écologies décoloniales, technocritique, pensées du vivant, décroissance...).
|
|
|
|
Règles :
|
|
- Cite les sources (auteur, livre) à chaque assertion importante.
|
|
- Si la question dépasse le corpus, dis-le clairement. Pas d'hallucination.
|
|
- Ton politique direct, pas de neutralité fade.
|
|
- Réponse en français, dense, sans délayage.
|
|
- Distingue les positions selon les écoles quand elles divergent.`
|
|
|
|
const SYSTEM_PREFACE_PROJETS = `Tu es un agent du RAG Projets de Jules Nény (architecte, collectif trans-former.fr).
|
|
Tu réponds STRICTEMENT à partir des documents projet (fichiers butte-pinson__*.md et autres projets archi de Jules).
|
|
N'utilise PAS le corpus FRACAS Pensées Écologiques pour répondre, sauf si l'usager te le demande explicitement.
|
|
|
|
Règles :
|
|
- Cite les sources (nom de projet, document) à chaque assertion importante.
|
|
- Si la question dépasse le corpus projet, dis-le clairement. Pas d'hallucination.
|
|
- Ton praticien réflexif : 1ère personne quand pertinent, narration située.
|
|
- Réponse en français, dense, sans délayage.`
|
|
|
|
const SYSTEM_PREFACE_BOTH = `Tu es un agent du RAG croisé Pensées x Projets de Jules Nény (architecte militant, collectif trans-former.fr).
|
|
CENTRE TA RÉPONSE sur les documents PROJETS (fichiers butte-pinson__*.md et autres projets archi).
|
|
Mobilise le corpus FRACAS Pensées (autres fichiers) UNIQUEMENT pour éclairer théoriquement les partis pris des projets, jamais l'inverse.
|
|
|
|
Pondération attendue : ~70% ancrage projet concret, ~30% éclairage théorique FRACAS.
|
|
|
|
Règles :
|
|
- Cite les sources (auteur ou nom de projet, document) à chaque assertion.
|
|
- Si un thème n'est pas couvert par les projets, dis-le clairement avant d'éventuellement étendre au corpus Pensées.
|
|
- Pas d'hallucination, pas d'extrapolation hors corpus.
|
|
- Ton praticien militant : direct, pas neutre, ancré dans la pratique architecturale.
|
|
- Réponse en français, dense, sans délayage.`
|
|
|
|
/**
|
|
* Posture « réflexion » (mode approfondi, v1 — prompts définitifs par onglet : B7/B9).
|
|
* Ajoutée APRÈS le préambule du corpus et AVANT REGLE_CITATIONS (qui reste prioritaire).
|
|
* Pensées n'est pas servi par Bifrost : le LLM de génération est réglé côté LightRAG, donc
|
|
* ce mode change la consigne et les paramètres de récupération, pas le modèle.
|
|
*/
|
|
const POSTURE_REFLEXION = `MODE RÉFLEXION : l'usager veut penser, pas seulement s'informer.
|
|
- Mets les idées en relation : reformule d'abord ce que la question engage, puis fais dialoguer les auteurs et les écoles présents dans le contexte.
|
|
- Nomme les tensions et les désaccords entre écoles (ce qu'elles partagent, là où elles divergent, ce que chacune ne voit pas), sans trancher à la place de l'usager.
|
|
- Reste strictement dans le corpus fourni : si une idée n'y est pas, dis-le au lieu de la compléter de mémoire.
|
|
- Termine par 2 questions ouvertes qui aident l'usager à situer sa propre position.
|
|
- Réponse structurée en paragraphes courts, 450 mots maximum.
|
|
- ${REGLE_GUILLEMETS}`
|
|
|
|
/**
|
|
* Paramètres de récupération LightRAG du mode réflexion. Mesuré le 09/10 (contexte seul, hybrid) :
|
|
* top_k 60 / chunk_top_k 30 ne changent rien (115,7 → 115,9 Ko, le budget de tokens plafonne) ;
|
|
* max_total_tokens 50000 monte le contexte à 153 Ko. On prend 40000 : plus de matière pour croiser
|
|
* les écoles sans approcher la fenêtre du LLM de génération (réglé côté serveur).
|
|
*/
|
|
const PARAMS_RAG_REFLEXION = { max_total_tokens: 40000 }
|
|
|
|
function buildPrefaceAuteur(nomAuteur: string, slug: string): string {
|
|
return `Tu réponds EXCLUSIVEMENT depuis les livres de ${nomAuteur} présents dans le RAG (fichiers commençant par "${slug}__").
|
|
Si la question sort du périmètre de cet auteur, indique-le et propose de l'aborder sans le hashtag pour interroger la carte entière. Reste fidèle au style et à la pensée de ${nomAuteur}. Cite toujours le livre.
|
|
|
|
Règles :
|
|
- Cite les sources (titre du livre) à chaque assertion.
|
|
- Pas d'hallucination. Si l'info n'est pas dans le corpus de cet auteur, dis-le.
|
|
- N'introduis JAMAIS d'autres auteurs sauf si ${nomAuteur} les commente explicitement.
|
|
- Ton politique direct, pas de neutralité fade.
|
|
- Réponse en français, dense, sans délayage.`
|
|
}
|
|
|
|
interface AuteurIngere { id: string; nom: string; ingere: boolean }
|
|
|
|
let auteursIngeresCache: AuteurIngere[] | null = null
|
|
|
|
function loadAuteursIngeres(): AuteurIngere[] {
|
|
if (auteursIngeresCache) return auteursIngeresCache
|
|
try {
|
|
const jsonPath = join(process.cwd(), 'public', 'data', 'auteurs-pensees.json')
|
|
const raw = readFileSync(jsonPath, 'utf-8')
|
|
const data = JSON.parse(raw)
|
|
const list = (data.auteurs ?? [])
|
|
.filter((a: any) => a.ingere === true)
|
|
.map((a: any) => ({ id: String(a.id), nom: String(a.nom), ingere: true }))
|
|
auteursIngeresCache = list
|
|
return list
|
|
} catch {
|
|
auteursIngeresCache = []
|
|
return []
|
|
}
|
|
}
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const config = useRuntimeConfig()
|
|
|
|
const ip = getHeader(event, 'x-forwarded-for')?.split(',')[0].trim()
|
|
|| event.node.req.socket?.remoteAddress
|
|
|| '0.0.0.0'
|
|
const allowed = checkRateLimitJson(ip, 'chatbot-pensees', 20)
|
|
if (!allowed) throw createError({ statusCode: 429, message: 'Limite de 20 questions par jour atteinte.' })
|
|
|
|
const body = await readBody(event)
|
|
if (!body?.query || body.query.trim().length < 3 || body.query.trim().length > 500) {
|
|
throw createError({ statusCode: 400, message: 'Query invalide (3-500 caractères).' })
|
|
}
|
|
|
|
const query: string = body.query.trim()
|
|
// jamais body.mode ici : réservé au mode de recherche LightRAG (hybrid…)
|
|
const profondeur = profondeurDuBody({ profondeur: body.profondeur })
|
|
const mode: string = body.mode || 'hybrid'
|
|
const corpus: string = body.corpus || 'pensees'
|
|
const ragUrl: string = (config.ragPeUrl as string) || 'http://localhost:9621'
|
|
const auteurSlug: string | null = body.auteur_slug?.trim().toLowerCase() || null
|
|
|
|
let nomAuteurMatch: string | null = null
|
|
if (auteurSlug) {
|
|
const ingeres = loadAuteursIngeres()
|
|
const auteur = ingeres.find(a => a.id === auteurSlug)
|
|
nomAuteurMatch = auteur?.nom ?? null
|
|
}
|
|
|
|
let systemPreface: string
|
|
if (auteurSlug && nomAuteurMatch) {
|
|
systemPreface = buildPrefaceAuteur(nomAuteurMatch, auteurSlug)
|
|
} else if (corpus === 'pensees') {
|
|
systemPreface = SYSTEM_PREFACE_PENSEES
|
|
} else if (corpus === 'projets') {
|
|
systemPreface = SYSTEM_PREFACE_PROJETS
|
|
} else {
|
|
systemPreface = SYSTEM_PREFACE_BOTH
|
|
}
|
|
|
|
try {
|
|
await $fetch(`${ragUrl}/health`, { timeout: 5000 })
|
|
} catch {
|
|
throw createError({ statusCode: 503, message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.' })
|
|
}
|
|
|
|
// user_prompt (LightRAG) : instructions ajoutées au prompt de génération, sans polluer la recherche
|
|
// (vérifié sur 9621 le 09/10 : une consigne de fin de réponse est appliquée). La requête de
|
|
// recherche est la question seule.
|
|
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}`,
|
|
}
|
|
if (profondeur === 'approfondi') Object.assign(ragBody, PARAMS_RAG_REFLEXION)
|
|
if (auteurSlug && nomAuteurMatch) {
|
|
ragBody.hl_keywords = [nomAuteurMatch, auteurSlug]
|
|
ragBody.ll_keywords = [auteurSlug]
|
|
}
|
|
|
|
let ragResponse: any
|
|
try {
|
|
ragResponse = await $fetch(`${ragUrl}/query`, {
|
|
method: 'POST',
|
|
body: ragBody,
|
|
timeout: 90000,
|
|
})
|
|
} catch (e: any) {
|
|
const status = e?.response?.status
|
|
if (status === 429) throw createError({ statusCode: 429, message: 'RAG saturé — réessaie dans quelques instants.' })
|
|
throw createError({ statusCode: 504, message: 'RAG en cours de processing — réessaie dans quelques secondes.' })
|
|
}
|
|
|
|
let chunksOnTarget = 0
|
|
let chunksOffTarget = 0
|
|
if (auteurSlug && nomAuteurMatch && Array.isArray(ragResponse.references)) {
|
|
const slugPrefix = `${auteurSlug}__`
|
|
for (const ref of ragResponse.references) {
|
|
const fp = (ref.file_path ?? '').toLowerCase()
|
|
if (!fp) continue
|
|
if (fp.startsWith(slugPrefix)) chunksOnTarget++
|
|
else chunksOffTarget++
|
|
}
|
|
}
|
|
|
|
// 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: verif.texte,
|
|
citations: { gardees: verif.gardees, retirees: verif.retirees },
|
|
mode,
|
|
corpus,
|
|
auteur: auteurSlug && nomAuteurMatch ? { slug: auteurSlug, nom: nomAuteurMatch } : null,
|
|
auteur_unmatched: auteurSlug && !nomAuteurMatch ? auteurSlug : null,
|
|
auteur_chunks: auteurSlug && nomAuteurMatch ? { on_target: chunksOnTarget, off_target: chunksOffTarget } : null,
|
|
filter: { couche: body.filter_couche ?? null, ecole: body.filter_ecole ?? null },
|
|
timestamp: new Date().toISOString(),
|
|
...(import.meta.dev
|
|
? { debug: { profondeur, user_prompt_chars: ragBody.user_prompt.length, rag_params: profondeur === 'approfondi' ? PARAMS_RAG_REFLEXION : null } }
|
|
: {}),
|
|
}
|
|
})
|