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:
@@ -76,11 +76,24 @@
|
|||||||
|
|
||||||
<!-- Input overlay -->
|
<!-- Input overlay -->
|
||||||
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
|
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2" style="position:relative;">
|
||||||
<input ref="inputElOverlay" v-model="q" type="text" placeholder="Ta question..." maxlength="500"
|
<!-- Hashtag autocomplete dropdown (Slack/Discord pattern, au-dessus de l'input) -->
|
||||||
|
<div v-if="hashtagDropdownOpen && hashtagSuggestions.length"
|
||||||
|
class="hashtag-dropdown"
|
||||||
|
style="position:absolute;bottom:100%;left:0;right:0;margin-bottom:6px;max-height:220px;overflow-y:auto;background:var(--nav-surface);border:1px solid var(--nav-bg-alt);border-radius:8px;box-shadow:0 -4px 12px rgba(0,0,0,0.12);z-index:50;">
|
||||||
|
<div v-for="(auteur, idx) in hashtagSuggestions" :key="auteur.id"
|
||||||
|
@mousedown.prevent="applyHashtagSuggestion(auteur)"
|
||||||
|
@mouseenter="hashtagSelectedIndex = idx"
|
||||||
|
class="px-3 py-2 cursor-pointer text-sm"
|
||||||
|
:style="idx === hashtagSelectedIndex ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'color:var(--nav-text);'">
|
||||||
|
<span style="font-weight:600;">#{{ auteur.id }}</span>
|
||||||
|
<span :style="idx === hashtagSelectedIndex ? 'opacity:0.85;margin-left:8px;font-size:0.78rem;' : 'opacity:0.65;margin-left:8px;font-size:0.78rem;color:var(--nav-text-muted);'">{{ auteur.nom }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input ref="inputElOverlay" v-model="q" type="text" placeholder="Ta question, tape #auteur pour cibler" maxlength="500"
|
||||||
class="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
|
class="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
|
||||||
style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);"
|
style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);"
|
||||||
@keydown.enter.prevent="send" />
|
@keydown="onInputKeydown" />
|
||||||
<button @click="send" :disabled="loading || !q.trim()"
|
<button @click="send" :disabled="loading || !q.trim()"
|
||||||
class="flex items-center justify-center w-9 h-9 rounded-lg"
|
class="flex items-center justify-center w-9 h-9 rounded-lg"
|
||||||
:style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'"
|
:style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'"
|
||||||
@@ -156,11 +169,24 @@
|
|||||||
|
|
||||||
<!-- Input inline -->
|
<!-- Input inline -->
|
||||||
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
|
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2" style="position:relative;">
|
||||||
<input ref="inputElInline" v-model="q" type="text" placeholder="Ta question..." maxlength="500"
|
<!-- Hashtag autocomplete dropdown (Slack/Discord pattern, au-dessus de l'input) -->
|
||||||
|
<div v-if="hashtagDropdownOpen && hashtagSuggestions.length"
|
||||||
|
class="hashtag-dropdown"
|
||||||
|
style="position:absolute;bottom:100%;left:0;right:0;margin-bottom:6px;max-height:220px;overflow-y:auto;background:var(--nav-surface);border:1px solid var(--nav-bg-alt);border-radius:8px;box-shadow:0 -4px 12px rgba(0,0,0,0.12);z-index:50;">
|
||||||
|
<div v-for="(auteur, idx) in hashtagSuggestions" :key="auteur.id"
|
||||||
|
@mousedown.prevent="applyHashtagSuggestion(auteur)"
|
||||||
|
@mouseenter="hashtagSelectedIndex = idx"
|
||||||
|
class="px-3 py-2 cursor-pointer text-sm"
|
||||||
|
:style="idx === hashtagSelectedIndex ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'color:var(--nav-text);'">
|
||||||
|
<span style="font-weight:600;">#{{ auteur.id }}</span>
|
||||||
|
<span :style="idx === hashtagSelectedIndex ? 'opacity:0.85;margin-left:8px;font-size:0.78rem;' : 'opacity:0.65;margin-left:8px;font-size:0.78rem;color:var(--nav-text-muted);'">{{ auteur.nom }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input ref="inputElInline" v-model="q" type="text" placeholder="Ta question, tape #auteur pour cibler" maxlength="500"
|
||||||
class="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
|
class="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
|
||||||
style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);"
|
style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);"
|
||||||
@keydown.enter.prevent="send" />
|
@keydown="onInputKeydown" />
|
||||||
<button @click="send" :disabled="loading || !q.trim()"
|
<button @click="send" :disabled="loading || !q.trim()"
|
||||||
class="flex items-center justify-center w-9 h-9 rounded-lg"
|
class="flex items-center justify-center w-9 h-9 rounded-lg"
|
||||||
:style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'"
|
:style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'"
|
||||||
@@ -177,6 +203,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
interface Message { role: 'user' | 'assistant'; content: string }
|
interface Message { role: 'user' | 'assistant'; content: string }
|
||||||
|
interface AuteurMini { id: string; nom: string }
|
||||||
|
|
||||||
type CorpusMode = 'pensees' | 'projets' | 'both'
|
type CorpusMode = 'pensees' | 'projets' | 'both'
|
||||||
|
|
||||||
@@ -213,11 +240,109 @@ const corpusCount = 18
|
|||||||
|
|
||||||
const corpus = ref<CorpusMode>('both')
|
const corpus = ref<CorpusMode>('both')
|
||||||
|
|
||||||
onMounted(() => {
|
// Phase 8.E : hashtag mentions
|
||||||
|
const auteursIngeres = ref<AuteurMini[]>([])
|
||||||
|
const hashtagSuggestions = ref<AuteurMini[]>([])
|
||||||
|
const hashtagDropdownOpen = ref(false)
|
||||||
|
const hashtagSelectedIndex = ref(0)
|
||||||
|
|
||||||
|
function getActiveInput(): HTMLInputElement | null {
|
||||||
|
return props.inline ? inputElInline.value : inputElOverlay.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectHashtagAtCursor(input: string, cursorPos: number): { start: number; partial: string } | null {
|
||||||
|
const before = input.slice(0, cursorPos)
|
||||||
|
const m = before.match(/#([a-z0-9-]*)$/i)
|
||||||
|
if (!m) return null
|
||||||
|
return { start: m.index ?? 0, partial: (m[1] || '').toLowerCase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHashtagSuggestions() {
|
||||||
|
const el = getActiveInput()
|
||||||
|
const cursorPos = el?.selectionStart ?? q.value.length
|
||||||
|
const detection = detectHashtagAtCursor(q.value, cursorPos)
|
||||||
|
// Ouvrir dès que le # est présent (partial vide accepté pour afficher la liste)
|
||||||
|
if (!detection) {
|
||||||
|
hashtagDropdownOpen.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const partial = detection.partial
|
||||||
|
const list = partial.length === 0
|
||||||
|
? auteursIngeres.value.slice(0, 8)
|
||||||
|
: auteursIngeres.value
|
||||||
|
.filter(a => a.id.toLowerCase().includes(partial) || a.nom.toLowerCase().includes(partial))
|
||||||
|
.slice(0, 8)
|
||||||
|
hashtagSuggestions.value = list
|
||||||
|
hashtagDropdownOpen.value = list.length > 0
|
||||||
|
hashtagSelectedIndex.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHashtagSuggestion(auteur: AuteurMini) {
|
||||||
|
const el = getActiveInput()
|
||||||
|
const cursorPos = el?.selectionStart ?? q.value.length
|
||||||
|
const detection = detectHashtagAtCursor(q.value, cursorPos)
|
||||||
|
if (!detection) return
|
||||||
|
const before = q.value.slice(0, detection.start)
|
||||||
|
const after = q.value.slice(cursorPos)
|
||||||
|
const insert = '#' + auteur.id + ' '
|
||||||
|
q.value = before + insert + after
|
||||||
|
hashtagDropdownOpen.value = false
|
||||||
|
nextTick(() => {
|
||||||
|
const focusEl = getActiveInput()
|
||||||
|
if (!focusEl) return
|
||||||
|
focusEl.focus()
|
||||||
|
const newPos = before.length + insert.length
|
||||||
|
focusEl.setSelectionRange(newPos, newPos)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInputKeydown(e: KeyboardEvent) {
|
||||||
|
if (hashtagDropdownOpen.value && hashtagSuggestions.value.length > 0) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
hashtagSelectedIndex.value = (hashtagSelectedIndex.value + 1) % hashtagSuggestions.value.length
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
hashtagSelectedIndex.value = (hashtagSelectedIndex.value - 1 + hashtagSuggestions.value.length) % hashtagSuggestions.value.length
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
e.preventDefault()
|
||||||
|
applyHashtagSuggestion(hashtagSuggestions.value[hashtagSelectedIndex.value])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
hashtagDropdownOpen.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(q, () => {
|
||||||
|
updateHashtagSuggestions()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
const saved = window.localStorage.getItem(CORPUS_STORAGE_KEY) as CorpusMode | null
|
const saved = window.localStorage.getItem(CORPUS_STORAGE_KEY) as CorpusMode | null
|
||||||
if (saved && ['pensees', 'projets', 'both'].includes(saved)) {
|
if (saved && ['pensees', 'projets', 'both'].includes(saved)) {
|
||||||
corpus.value = saved
|
corpus.value = saved
|
||||||
}
|
}
|
||||||
|
// Chargement liste auteurs ingérés pour autocomplete hashtag
|
||||||
|
try {
|
||||||
|
const data = await $fetch<any>('/data/auteurs-pensees.json')
|
||||||
|
auteursIngeres.value = (data?.auteurs ?? [])
|
||||||
|
.filter((a: any) => a.ingere === true)
|
||||||
|
.map((a: any) => ({ id: String(a.id), nom: String(a.nom) }))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erreur chargement auteurs-pensees.json pour hashtag', e)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function setCorpus(val: CorpusMode) {
|
function setCorpus(val: CorpusMode) {
|
||||||
@@ -240,21 +365,48 @@ watch(() => props.auteurContext, (ctx) => {
|
|||||||
async function send() {
|
async function send() {
|
||||||
const query = q.value.trim()
|
const query = q.value.trim()
|
||||||
if (!query || loading.value) return
|
if (!query || loading.value) return
|
||||||
|
|
||||||
|
// Extraire le premier hashtag matchant un auteur ingéré
|
||||||
|
let auteurSlug: string | null = null
|
||||||
|
const matches = [...query.matchAll(/#([a-z0-9-]+)/gi)]
|
||||||
|
for (const m of matches) {
|
||||||
|
const slug = m[1].toLowerCase()
|
||||||
|
if (auteursIngeres.value.find(a => a.id === slug)) {
|
||||||
|
auteurSlug = slug
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Premier hashtag non-matché (pour info utilisateur si jamais ne match aucun)
|
||||||
|
let auteurSlugUnmatched: string | null = null
|
||||||
|
if (!auteurSlug && matches.length > 0) {
|
||||||
|
auteurSlugUnmatched = matches[0][1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
err.value = ''
|
err.value = ''
|
||||||
messages.value.push({ role: 'user', content: query })
|
messages.value.push({ role: 'user', content: query })
|
||||||
q.value = ''
|
q.value = ''
|
||||||
|
hashtagDropdownOpen.value = false
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await nextTick()
|
await nextTick()
|
||||||
scrollBottom()
|
scrollBottom()
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<{ response: string }>('/api/chatbot-pensees', {
|
const res = await $fetch<any>('/api/chatbot-pensees', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { query, mode: 'hybrid', corpus: corpus.value },
|
body: {
|
||||||
|
query,
|
||||||
|
mode: 'hybrid',
|
||||||
|
corpus: corpus.value,
|
||||||
|
auteur_slug: auteurSlug ?? auteurSlugUnmatched,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
messages.value.push({ role: 'assistant', content: res.response ?? '' })
|
let responseText = res.response ?? ''
|
||||||
|
if (res.auteur_unmatched) {
|
||||||
|
responseText = `*(Aucun livre de #${res.auteur_unmatched} n'est ingéré dans le RAG. Je réponds depuis la carte entière.)*\n\n` + responseText
|
||||||
|
}
|
||||||
|
messages.value.push({ role: 'assistant', content: responseText })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const s = e?.response?.status ?? e?.statusCode
|
const s = e?.response?.status ?? e?.statusCode
|
||||||
err.value = s === 429 ? 'Limite atteinte.' : s === 503 ? 'RAG indisponible.' : 'Erreur - reessaie.'
|
err.value = s === 429 ? 'Limite atteinte.' : s === 503 ? 'RAG indisponible.' : 'Erreur, reessaie.'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { H3Event } from 'h3'
|
import type { H3Event } from 'h3'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
|
||||||
interface ChatbotPenseesRequest {
|
interface ChatbotPenseesRequest {
|
||||||
@@ -7,11 +9,25 @@ interface ChatbotPenseesRequest {
|
|||||||
corpus?: 'pensees' | 'projets' | 'both'
|
corpus?: 'pensees' | 'projets' | 'both'
|
||||||
filter_couche?: 'fond' | 'forme' | 'structure' | null
|
filter_couche?: 'fond' | 'forme' | 'structure' | null
|
||||||
filter_ecole?: string | null
|
filter_ecole?: string | null
|
||||||
|
auteur_slug?: string | null
|
||||||
history?: Array<{ role: 'user' | 'assistant'; content: string }>
|
history?: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LightRAGReference {
|
||||||
|
reference_id?: string
|
||||||
|
file_path?: string
|
||||||
|
content?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
interface LightRAGQueryResponse {
|
interface LightRAGQueryResponse {
|
||||||
response: string
|
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.
|
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.
|
- Ton praticien militant : direct, pas neutre, ancré dans la pratique architecturale.
|
||||||
- Réponse en français, dense, sans délayage.`
|
- 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) => {
|
export default defineEventHandler(async (event: H3Event) => {
|
||||||
const config = useRuntimeConfig(event)
|
const config = useRuntimeConfig(event)
|
||||||
|
|
||||||
@@ -72,13 +119,26 @@ export default defineEventHandler(async (event: H3Event) => {
|
|||||||
const corpus = body.corpus || 'both'
|
const corpus = body.corpus || 'both'
|
||||||
const ragUrl = (config.ragPeUrl as string) || 'http://localhost:9621'
|
const ragUrl = (config.ragPeUrl as string) || 'http://localhost:9621'
|
||||||
|
|
||||||
// Préface adaptative selon corpus demandé
|
// Validation auteur_slug (Phase 8.E) : match contre la liste des auteurs ingérés
|
||||||
const systemPreface =
|
const auteurSlug = body.auteur_slug?.trim().toLowerCase() || null
|
||||||
corpus === 'pensees'
|
let nomAuteurMatch: string | null = null
|
||||||
? SYSTEM_PREFACE_PENSEES
|
if (auteurSlug) {
|
||||||
: corpus === 'projets'
|
const ingeres = loadAuteursIngeres()
|
||||||
? SYSTEM_PREFACE_PROJETS
|
const auteur = ingeres.find(a => a.id === auteurSlug)
|
||||||
: SYSTEM_PREFACE_BOTH
|
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
|
// 3. Health guard — LightRAG down = erreur claire, pas de fallback hallucinatoire
|
||||||
try {
|
try {
|
||||||
@@ -93,11 +153,20 @@ export default defineEventHandler(async (event: H3Event) => {
|
|||||||
// 4. Call LightRAG VPS — préface système injectée dans la query
|
// 4. Call LightRAG VPS — préface système injectée dans la query
|
||||||
const ragQuery = `${systemPreface}\n\nQuestion : ${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
|
let ragResponse: LightRAGQueryResponse
|
||||||
try {
|
try {
|
||||||
ragResponse = await $fetch<LightRAGQueryResponse>(`${ragUrl}/query`, {
|
ragResponse = await $fetch<LightRAGQueryResponse>(`${ragUrl}/query`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { query: ragQuery, mode },
|
body: ragBody,
|
||||||
timeout: 90000,
|
timeout: 90000,
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} 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.' })
|
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é
|
// 5. Retour formaté
|
||||||
return {
|
return {
|
||||||
response: ragResponse.response ?? '',
|
response: ragResponse.response ?? '',
|
||||||
mode,
|
mode,
|
||||||
corpus,
|
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 },
|
filter: { couche: body.filter_couche ?? null, ecole: body.filter_ecole ?? null },
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user