import type { H3Event } from 'h3' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { checkRateLimitJson } from '~/server/utils/rateLimitJson' interface ChatbotPenseesRequest { query: string mode?: 'hybrid' | 'local' | 'global' | 'naive' | 'mix' corpus?: 'pensees' | 'projets' | 'both' filter_couche?: 'fond' | 'forme' | 'structure' | null filter_ecole?: string | null auteur_slug?: string | null history?: Array<{ role: 'user' | 'assistant'; content: string }> } interface LightRAGReference { reference_id?: string file_path?: string content?: string | null } interface LightRAGQueryResponse { response: string references?: LightRAGReference[] } interface AuteurMini { id: string nom: string ingere?: boolean } 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.` 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.` } // Chargement (et cache) de la liste des auteurs ingérés pour validation du slug let auteursIngeresCache: AuteurMini[] | null = null function loadAuteursIngeres(): AuteurMini[] { 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: AuteurMini[] = (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: H3Event) => { const config = useRuntimeConfig(event) // 1. Rate limit (20 req/jour/IP, IP hashée RGPD) 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.' }) } // 2. Body parse + validation 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 = body.query.trim() const mode = body.mode || 'hybrid' const corpus = body.corpus || 'both' const ragUrl = (config.ragPeUrl as string) || 'http://localhost:9621' // Validation auteur_slug (Phase 8.E) : match contre la liste des auteurs ingérés const auteurSlug = 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 } // Préface adaptative : auteur prioritaire si slug matché, sinon corpus 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 } // 3. Health guard — LightRAG down = erreur claire, pas de fallback hallucinatoire try { await $fetch(`${ragUrl}/health`, { timeout: 5000 }) } catch { throw createError({ statusCode: 503, message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.', }) } // 4. Call LightRAG VPS — préface système injectée dans la query const ragQuery = `${systemPreface}\n\nQuestion : ${query}` // Construction du body : hl_keywords + ll_keywords si auteur ciblé // NB : LightRAG ne supporte ni keyword_filter ni ids ni metadata_filter (preflight OpenAPI confirmé). // hl_keywords / ll_keywords sont les seuls leviers natifs de priorisation par auteur. const ragBody: Record = { query: ragQuery, mode } if (auteurSlug && nomAuteurMatch) { ragBody.hl_keywords = [nomAuteurMatch, auteurSlug] ragBody.ll_keywords = [auteurSlug] } let ragResponse: LightRAGQueryResponse 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.' }) } // Fallback post-process : si auteur ciblé et que les references LightRAG remontent // des chunks hors slug__, on l'indique pour transparence. La préface LLM est la garde principale. let chunksOffTarget = 0 let chunksOnTarget = 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++ } } // 5. Retour formaté return { response: ragResponse.response ?? '', 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(), } })