Files
nav-carte/server/api/chatbot-pensees.post.ts
Jules Neny b36587cb08 feat(media): hashtag mentions chatbot #slug-auteur (Phase 8.E)
Frontend ChatbotPensees.vue :
- Parser regex #slug-auteur dans la query (case-insensitive)
- Auto-completion dropdown au-dessus de l'input (Slack/Discord pattern)
- Match fuzzy sur id et nom des auteurs ingeres (32 actuellement)
- Navigation ArrowDown/Up/Enter/Tab/Escape sur la dropdown
- send() extrait auteur_slug du premier hashtag matchant un ingere
- Si hashtag tape mais ne matche aucun ingere, on l'envoie comme unmatched
- Message info utilisateur si auteur_unmatched remonte

Backend chatbot-pensees.post.ts :
- Interface body etendue : auteur_slug?: string
- Cache local de la liste auteurs ingeres depuis public/data/auteurs-pensees.json
- Preface dediee buildPrefaceAuteur(nom, slug) si auteur_slug match un ingere
- LightRAG /query enrichi avec hl_keywords + ll_keywords (preflight OpenAPI :
  keyword_filter, ids et metadata_filter ne sont PAS supportes par cette version,
  hl_keywords / ll_keywords sont les seuls leviers natifs)
- Post-process references : compteur on_target / off_target sur slug__
- Fallback gracieux si auteur_slug ne matche pas : reponse normale + info front
- Response enrichie : auteur, auteur_unmatched, auteur_chunks

Pas d'em-dash sur le code modifie, accents francais preserves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:04:03 +02:00

206 lines
8.5 KiB
TypeScript

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<ChatbotPenseesRequest>(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<string, unknown> = { query: ragQuery, mode }
if (auteurSlug && nomAuteurMatch) {
ragBody.hl_keywords = [nomAuteurMatch, auteurSlug]
ragBody.ll_keywords = [auteurSlug]
}
let ragResponse: LightRAGQueryResponse
try {
ragResponse = await $fetch<LightRAGQueryResponse>(`${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(),
}
})