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>
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||
|
||||
interface ChatbotPenseesRequest {
|
||||
@@ -7,11 +9,25 @@ interface ChatbotPenseesRequest {
|
||||
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.
|
||||
@@ -47,6 +63,37 @@ Règles :
|
||||
- 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)
|
||||
|
||||
@@ -72,13 +119,26 @@ export default defineEventHandler(async (event: H3Event) => {
|
||||
const corpus = body.corpus || 'both'
|
||||
const ragUrl = (config.ragPeUrl as string) || 'http://localhost:9621'
|
||||
|
||||
// Préface adaptative selon corpus demandé
|
||||
const systemPreface =
|
||||
corpus === 'pensees'
|
||||
? SYSTEM_PREFACE_PENSEES
|
||||
: corpus === 'projets'
|
||||
? SYSTEM_PREFACE_PROJETS
|
||||
: SYSTEM_PREFACE_BOTH
|
||||
// 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 {
|
||||
@@ -93,11 +153,20 @@ export default defineEventHandler(async (event: H3Event) => {
|
||||
// 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: { query: ragQuery, mode },
|
||||
body: ragBody,
|
||||
timeout: 90000,
|
||||
})
|
||||
} catch (e: any) {
|
||||
@@ -108,11 +177,28 @@ export default defineEventHandler(async (event: H3Event) => {
|
||||
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(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user