feat(rag-pe): PRG-5 + PRG-6 frontend pensees ecologiques

- server/api/chatbot-pensees.post.ts : endpoint LightRAG VPS (hybrid mode, preface militante, rate limit 20/jour, health guard)
- nuxt.config.ts : ragPeUrl runtimeConfig (NUXT_RAG_PE_URL)
- public/data/auteurs-pensees.json : 18 auteurs FRACAS, 8 ecoles, theses, livres RAG
- components/CartePensees.vue : D3 force-directed (8 ecoles fixes + auteurs gravitants)
- components/FicheAuteur.vue : modal auteur (bio + theses + livres RAG + bouton RAG)
- components/ChatbotPensees.vue : overlay chatbot bottom-right (sources expansibles)
- pages/pensees-ecologiques.vue : page dedicee /pensees-ecologiques (toggle Familiale/Graphe)
- pages/agences.vue : 4e onglet "Pensees" (desktop + mobile) -> /pensees-ecologiques

Branche : feat/aep-rag-pensees-ecologiques
Checkpoint Jules requis avant merge main.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jules Neny
2026-05-11 15:07:42 +02:00
parent f5732bf336
commit 668ae5caff
8 changed files with 890 additions and 0 deletions

135
components/CartePensees.vue Normal file
View File

@@ -0,0 +1,135 @@
<template>
<div style="width: 100%; height: 100%; position: relative; background: var(--nav-bg);">
<svg ref="svgRef" style="width: 100%; height: 100%;"></svg>
<div ref="tooltipRef" style="
position: absolute; pointer-events: none;
background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);
border-radius: 6px; padding: 8px 12px; font-size: 0.78rem;
color: var(--nav-text); max-width: 240px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
opacity: 0; transition: opacity 0.15s; z-index: 100;
"></div>
</div>
</template>
<script setup lang="ts">
interface EcoleData { id: string; label: string; description: string; color: string; x_hint: number; y_hint: number }
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
const props = defineProps<{ data: PenseesData | null; active?: boolean }>()
const emit = defineEmits<{ 'select-auteur': [id: string] }>()
const svgRef = ref<SVGElement | null>(null)
const tooltipRef = ref<HTMLElement | null>(null)
let simulation: any = null
let d3NodeSel: any = null
let d3LinkSel: any = null
async function initGraph() {
if (!svgRef.value || !props.data) return
const d3 = await import('d3')
const svgEl = svgRef.value
const W = svgEl.clientWidth || 900
const H = svgEl.clientHeight || 600
d3.select(svgEl).selectAll('*').remove()
const svg = d3.select(svgEl).attr('viewBox', `0 0 ${W} ${H}`)
const g = svg.append('g')
svg.call(d3.zoom<SVGElement, unknown>().scaleExtent([0.3, 4]).on('zoom', (e) => g.attr('transform', e.transform)) as any)
const ecoleMap = new Map<string, EcoleData>(props.data.ecoles.map(e => [e.id, e]))
const ecoleNodes: any[] = props.data.ecoles.map(e => ({
id: `ecole-${e.id}`, type: 'ecole', ecoleId: e.id, label: e.label, color: e.color, r: 38,
x: W * e.x_hint, y: H * e.y_hint, fx: W * e.x_hint, fy: H * e.y_hint,
}))
const auteurNodes: any[] = props.data.auteurs.map(a => ({
id: a.id, type: 'auteur', nom: a.nom, dates: a.dates, bio_courte: a.bio_courte,
ecole_principale: a.ecole_principale,
color: ecoleMap.get(a.ecole_principale)?.color ?? '#888', r: 11,
}))
const allNodes = [...ecoleNodes, ...auteurNodes]
const links: any[] = []
props.data.auteurs.forEach(a => {
links.push({ source: a.id, target: `ecole-${a.ecole_principale}`, strength: 0.65 })
a.ecoles.filter(e => e !== a.ecole_principale).forEach(e => links.push({ source: a.id, target: `ecole-${e}`, strength: 0.25 }))
})
if (simulation) simulation.stop()
simulation = d3.forceSimulation(allNodes)
.force('link', d3.forceLink(links).id((d: any) => d.id).distance(90).strength((d: any) => d.strength ?? 0.5))
.force('charge', d3.forceManyBody().strength(-80))
.force('center', d3.forceCenter(W / 2, H / 2))
.force('collision', d3.forceCollide().radius((d: any) => d.r + 5))
d3LinkSel = g.append('g').selectAll('line').data(links).join('line')
.attr('stroke', 'rgba(150,150,150,0.3)').attr('stroke-width', 1.2)
d3NodeSel = g.append('g').selectAll('g').data(allNodes).join('g')
.style('cursor', (d: any) => d.type === 'auteur' ? 'pointer' : 'default')
.call(d3.drag<any, any>()
.on('start', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })
.on('drag', (e: any, d: any) => { d.fx = e.x; d.fy = e.y })
.on('end', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0); if (d.type !== 'ecole') { d.fx = null; d.fy = null } }))
.on('click', (e: any, d: any) => { e.stopPropagation(); if (d.type === 'auteur') emit('select-auteur', d.id) })
d3NodeSel.append('circle')
.attr('r', (d: any) => d.r)
.attr('fill', (d: any) => d.type === 'ecole' ? d.color : d.color + 'cc')
.attr('stroke', (d: any) => d.type === 'ecole' ? 'rgba(255,255,255,0.6)' : d.color)
.attr('stroke-width', (d: any) => d.type === 'ecole' ? 3 : 1.5)
d3NodeSel.filter((d: any) => d.type === 'ecole').append('text')
.attr('text-anchor', 'middle').attr('dy', '0.35em').attr('font-size', '10px').attr('font-weight', '700').attr('fill', 'white')
.style('pointer-events', 'none')
.each(function(d: any) {
const el = d3.select(this as any)
const words: string[] = d.label.split(' ')
if (words.length <= 2) { el.text(d.label) } else {
const mid = Math.ceil(words.length / 2)
el.append('tspan').attr('x', 0).attr('dy', '-0.6em').text(words.slice(0, mid).join(' '))
el.append('tspan').attr('x', 0).attr('dy', '1.2em').text(words.slice(mid).join(' '))
}
})
d3NodeSel.filter((d: any) => d.type === 'auteur').append('text')
.attr('class', 'pensees-auteur-label')
.text((d: any) => d.nom.split(' ').pop() ?? d.nom)
.attr('text-anchor', 'middle').attr('dy', (d: any) => -(d.r + 4)).attr('font-size', '9px').attr('font-weight', '500')
.style('pointer-events', 'none')
d3NodeSel.filter((d: any) => d.type === 'auteur')
.on('mouseenter', (e: any, d: any) => {
if (!tooltipRef.value) return
const bio = d.bio_courte.length > 90 ? d.bio_courte.slice(0, 87) + '...' : d.bio_courte
tooltipRef.value.innerHTML = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.75;font-size:0.72rem;">${bio}</span>`
tooltipRef.value.style.opacity = '1'
})
.on('mousemove', (e: any) => {
if (!tooltipRef.value || !svgEl) return
const rect = (svgEl as HTMLElement).getBoundingClientRect()
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
})
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
simulation.on('tick', () => {
d3LinkSel.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y)
.attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y)
d3NodeSel.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
})
}
watch(() => props.active, (val) => { if (val && import.meta.client && props.data) requestAnimationFrame(() => requestAnimationFrame(() => initGraph())) })
watch(() => props.data, (val) => { if (val && props.active && import.meta.client) requestAnimationFrame(() => requestAnimationFrame(() => initGraph())) })
onMounted(async () => { if (import.meta.client && props.data && props.active) { await nextTick(); initGraph() } })
onUnmounted(() => { if (simulation) simulation.stop() })
</script>
<style>
.pensees-auteur-label { fill: var(--nav-text); opacity: 0.75; paint-order: stroke; stroke: var(--nav-bg); stroke-width: 3px; stroke-linejoin: round; user-select: none; }
</style>

View File

@@ -0,0 +1,141 @@
<template>
<button v-if="!open" @click="open = true"
class="fixed bottom-6 right-6 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
style="height:48px;background:var(--nav-primary);color:var(--nav-text-on-primary);font-size:0.875rem;font-weight:600;"
aria-label="Chatbot Pensees Ecologiques">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span>Pensees ?</span>
</button>
<Transition name="cpanel">
<div v-if="open" class="fixed bottom-6 right-6 z-[1000] flex flex-col"
style="width:min(360px,calc(100vw - 24px));max-height:60vh;background:var(--nav-surface);border-radius:14px;box-shadow:0 8px 32px rgba(26,34,56,0.22);overflow:hidden;border:1px solid var(--nav-bg-alt);"
role="dialog" aria-modal="true" aria-label="RAG Pensees Ecologiques">
<div class="flex items-center justify-between px-4 py-3 shrink-0" style="border-bottom:1px solid var(--nav-bg-alt);background:var(--nav-bg);">
<div>
<p class="text-sm font-bold" style="color:var(--nav-text);">RAG Pensees Ecologiques</p>
<p class="text-xs" style="color:var(--nav-text-muted);">{{ corpusCount }} auteurs ingeres</p>
</div>
<button @click="open = false" class="flex items-center justify-center w-7 h-7 rounded-full hover:opacity-70"
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);" aria-label="Fermer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div ref="msgEl" class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3" style="min-height:0;">
<div v-if="messages.length === 0" style="font-size:0.8rem;color:var(--nav-text-muted);line-height:1.5;">
Pose une question sur les pensees ecologiques : ecosocialisme, decroissance, ecofeminismes, technocritique, deep ecology...
</div>
<template v-for="(msg, i) in messages" :key="i">
<div v-if="msg.role === 'user'" class="self-end max-w-[85%] px-3 py-2 rounded-xl text-sm"
style="background:var(--nav-primary);color:var(--nav-text-on-primary);font-weight:500;">{{ msg.content }}</div>
<div v-else class="self-start max-w-full">
<div class="px-3 py-2 rounded-xl text-sm leading-relaxed" style="background:var(--nav-bg-alt);color:var(--nav-text);"
v-html="renderMd(stripSrc(msg.content))" />
<div v-if="parseSrc(msg.content).length" class="mt-1.5">
<button @click="toggled[i] = !toggled[i]" class="flex items-center gap-1 text-xs hover:opacity-70" style="color:var(--nav-text-muted);">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
:style="`transform:rotate(${toggled[i] ? 90 : 0}deg);transition:transform 0.15s`"><polyline points="9 18 15 12 9 6"/></svg>
Sources ({{ parseSrc(msg.content).length }})
</button>
<div v-if="toggled[i]" class="mt-1 flex flex-col gap-1">
<div v-for="(s, si) in parseSrc(msg.content)" :key="si" class="px-2 py-1 rounded text-xs"
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);border-left:2px solid var(--nav-primary-solid);">
<span style="font-weight:600;color:var(--nav-text);">[{{ si + 1 }}]</span> {{ s }}
</div>
</div>
</div>
</div>
</template>
<div v-if="loading" class="self-start px-3 py-2 rounded-xl" style="background:var(--nav-bg-alt);">
<span class="dots"><span/><span style="animation-delay:150ms"/><span style="animation-delay:300ms"/></span>
</div>
<div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div>
</div>
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
<div class="flex items-center gap-2">
<input ref="inputEl" v-model="q" type="text" placeholder="Ta question..." maxlength="500"
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);"
@keydown.enter.prevent="send" />
<button @click="send" :disabled="loading || !q.trim()"
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;'"
aria-label="Envoyer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:white;">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
interface Message { role: 'user' | 'assistant'; content: string }
const props = defineProps<{ auteurContext?: string | null }>()
const open = ref(false)
const q = ref('')
const messages = ref<Message[]>([])
const loading = ref(false)
const err = ref('')
const toggled = ref<Record<number, boolean>>({})
const msgEl = ref<HTMLElement | null>(null)
const inputEl = ref<HTMLInputElement | null>(null)
const corpusCount = 18
watch(open, (val) => {
if (!val) return
nextTick(() => inputEl.value?.focus())
if (props.auteurContext && messages.value.length === 0)
q.value = `Quelles sont les theses centrales de ${props.auteurContext} ?`
})
watch(() => props.auteurContext, (ctx) => {
if (!ctx) return
if (!open.value) open.value = true
if (messages.value.length === 0) q.value = `Quelles sont les theses centrales de ${ctx} ?`
})
async function send() {
const query = q.value.trim()
if (!query || loading.value) return
err.value = ''
messages.value.push({ role: 'user', content: query })
q.value = ''
loading.value = true
await nextTick(); scrollBottom()
try {
const res = await $fetch<{ response: string }>('/api/chatbot-pensees', { method: 'POST', body: { query, mode: 'hybrid' } })
messages.value.push({ role: 'assistant', content: res.response ?? '' })
} catch (e: any) {
const s = e?.response?.status ?? e?.statusCode
err.value = s === 429 ? 'Limite atteinte.' : s === 503 ? 'RAG indisponible.' : 'Erreur - reessaie.'
} finally {
loading.value = false
await nextTick(); scrollBottom()
}
}
function scrollBottom() { if (msgEl.value) msgEl.value.scrollTop = msgEl.value.scrollHeight }
function renderMd(t: string) {
return '<p>' + t.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\*(.+?)\*/g, '<em>$1</em>').replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>') + '</p>'
}
function stripSrc(t: string) { return t.replace(/\n*(?:Sources?|References?)\s*:[\s\S]*$/i, '').trim() }
function parseSrc(t: string): string[] {
const bloc = t.match(/\n*(?:Sources?|References?)\s*:\n?([\s\S]+?)$/i)
if (bloc) return bloc[1].split('\n').map(l => l.replace(/^[-*\d.[\]]+\s*/, '').trim()).filter(l => l.length > 3)
return [...new Set([...t.matchAll(/\[([^\]]{5,80})\]/g)].filter(m => m[1].includes(' - ')).map(m => m[1]))]
}
</script>
<style scoped>
.cpanel-enter-active { transition: opacity 0.2s, transform 0.22s cubic-bezier(0.34,1.56,0.64,1); }
.cpanel-leave-active { transition: opacity 0.18s, transform 0.15s ease-in; }
.cpanel-enter-from { opacity: 0; transform: translateY(12px) scale(0.95); }
.cpanel-leave-to { opacity: 0; transform: translateY(8px) scale(0.97); }
.dots span { display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--nav-text-muted);margin:0 2px;animation:bounce 1s infinite; }
@keyframes bounce { 0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-5px)} }
</style>

View File

@@ -0,0 +1,98 @@
<template>
<Teleport to="body">
<Transition name="backdrop">
<div v-if="open && auteur" class="fixed inset-0 z-[1500]" style="background: rgba(26,34,56,0.55);" @click="emit('close')" aria-hidden="true" />
</Transition>
<Transition name="modal">
<div v-if="open && auteur" class="fixed z-[1501] left-1/2 flex flex-col"
style="top:50%;transform:translate(-50%,-50%);width:min(520px,94vw);max-height:85vh;background:var(--nav-bg);border-radius:14px;box-shadow:0 16px 64px rgba(26,34,56,0.28);overflow:hidden;"
role="dialog" aria-modal="true">
<!-- Header -->
<div class="flex items-start justify-between px-5 py-4 shrink-0"
:style="`border-bottom: 3px solid ${ecoleColor}; background: var(--nav-surface);`">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold" :style="`background:${ecoleColor}22;color:${ecoleColor};`">{{ ecoleLabel }}</span>
<span v-for="eid in auteur.ecoles.filter(e => e !== auteur.ecole_principale)" :key="eid"
class="px-2 py-0.5 rounded-full text-xs" :style="`background:${getEcoleColor(eid)}22;color:${getEcoleColor(eid)};`">{{ getEcoleLabel(eid) }}</span>
</div>
<h2 class="mt-2 font-bold text-lg leading-tight" style="color:var(--nav-text);">{{ auteur.nom }}</h2>
<p class="text-sm" style="color:var(--nav-text-muted);">{{ auteur.dates }}</p>
</div>
<button @click="emit('close')" class="ml-3 shrink-0 flex items-center justify-center w-8 h-8 rounded-full hover:opacity-70"
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);" aria-label="Fermer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto px-5 py-4 flex flex-col gap-4">
<p class="text-sm leading-relaxed" style="color:var(--nav-text);">{{ auteur.bio_courte }}</p>
<div v-if="auteur.theses_cles.length">
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color:var(--nav-text-muted);">Theses cles</p>
<ul class="flex flex-col gap-1.5">
<li v-for="t in auteur.theses_cles" :key="t" class="flex items-start gap-2 text-sm" style="color:var(--nav-text);">
<span class="mt-1.5 w-1.5 h-1.5 rounded-full shrink-0" :style="`background:${ecoleColor};`"></span>
<span>{{ t }}</span>
</li>
</ul>
</div>
<div v-if="auteur.livres_rag.length">
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color:var(--nav-text-muted);">Livres dans le RAG</p>
<div class="flex flex-col gap-2">
<div v-for="l in auteur.livres_rag" :key="l.slug" class="flex items-start gap-3 p-3 rounded-lg" style="background:var(--nav-bg-alt);">
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold leading-snug" style="color:var(--nav-text);">{{ l.titre }}</p>
<p class="text-xs mt-0.5" style="color:var(--nav-text-muted);">{{ l.annee }}</p>
</div>
<div class="flex gap-1 shrink-0">
<span v-for="c in l.couches" :key="c" class="px-1.5 py-0.5 rounded text-xs" style="background:var(--nav-surface);color:var(--nav-text-muted);">{{ c }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="shrink-0 px-5 py-3 border-t" style="border-color:var(--nav-bg-alt);">
<button @click="emit('interroger-rag', auteurId!)" class="w-full py-2.5 rounded-lg text-sm font-semibold hover:opacity-80"
:style="`background:${ecoleColor};color:white;`">
Interroger le RAG sur {{ auteur.nom.split(' ').pop() }}
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
interface EcoleData { id: string; label: string; color: string }
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
const props = defineProps<{ open: boolean; auteurId: string | null; data: PenseesData | null }>()
const emit = defineEmits<{ close: []; 'interroger-rag': [auteurId: string] }>()
const auteur = computed<AuteurData | null>(() => {
if (!props.auteurId || !props.data) return null
return props.data.auteurs.find(a => a.id === props.auteurId) ?? null
})
const ecoleColor = computed(() => props.data?.ecoles.find(e => e.id === auteur.value?.ecole_principale)?.color ?? '#888')
const ecoleLabel = computed(() => props.data?.ecoles.find(e => e.id === auteur.value?.ecole_principale)?.label ?? '')
function getEcoleColor(id: string) { return props.data?.ecoles.find(e => e.id === id)?.color ?? '#888' }
function getEcoleLabel(id: string) { return props.data?.ecoles.find(e => e.id === id)?.label ?? id }
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && props.open) emit('close') }
onMounted(() => window.addEventListener('keydown', onKey))
onUnmounted(() => window.removeEventListener('keydown', onKey))
</script>
<style scoped>
.backdrop-enter-active,.backdrop-leave-active { transition: opacity 0.2s; }
.backdrop-enter-from,.backdrop-leave-to { opacity: 0; }
.modal-enter-active { transition: opacity 0.2s, transform 0.22s cubic-bezier(0.34,1.56,0.64,1); }
.modal-leave-active { transition: opacity 0.18s, transform 0.18s ease-in; }
.modal-enter-from { opacity: 0; transform: translate(-50%,-48%) scale(0.94); }
.modal-leave-to { opacity: 0; transform: translate(-50%,-48%) scale(0.96); }
</style>