feat(media): Phase 8.G noeuds-ecoles + popup RAG info + lien Bonpote + migration Nebius

- CartePensees: noeuds ecole visibles (cercles proportionnels count auteurs, cliquables, emit select-ecole)
- CartePensees: collision D3 ajustee pour repulsion auteurs autour des noeuds ecole
- FicheEcole: nouveau composant modal (liste auteurs ingeres/non-ingeres, interroger RAG)
- media: header lien Bonpote V2 cliquable + bouton i info RAG
- media: popup FRACAS (description RAG, 662 dimensions, 3 couches, localStorage 1ere visite)
- media: FicheEcole branchee (select-ecole, select-auteur-from-ecole, interroger-ecole)
- ChatbotPensees: suppression mention corpusCount hardcoded (double source de verite)
- chatbot, chatbot-v2, chatbot-reseaux, chatbot-taff: migration Mistral -> Nebius DeepSeek-V3.2
- nuxt.config: ajout nebiusApiKey runtime config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jules Neny
2026-05-14 05:56:09 +02:00
parent 46f57ae5fe
commit 40b406bd41
9 changed files with 286 additions and 41 deletions

View File

@@ -39,7 +39,7 @@ const LINKS_INFLUENCE = [
]
const props = defineProps<{ data: PenseesData | null; active?: boolean }>()
const emit = defineEmits<{ 'select-auteur': [id: string] }>()
const emit = defineEmits<{ 'select-auteur': [id: string]; 'select-ecole': [id: string] }>()
const svgRef = ref<SVGElement | null>(null)
const tooltipRef = ref<HTMLElement | null>(null)
@@ -201,6 +201,12 @@ async function initGraph() {
fx: W * e.x_hint, fy: H * e.y_hint,
}))
// Rayon proportionnel au nombre d'auteurs de l'ecole
const ecoleAuteurCounts = new Map<string, number>()
props.data.ecoles.forEach(e => ecoleAuteurCounts.set(e.id, 0))
props.data.auteurs.forEach(a => ecoleAuteurCounts.set(a.ecole_principale, (ecoleAuteurCounts.get(a.ecole_principale) ?? 0) + 1))
const ecoleRadius = (count: number) => Math.max(16, Math.min(36, 13 + count * 1.5))
const allNodes = [...ecoleFixedNodes, ...auteurNodes]
if (simulation) simulation.stop()
@@ -209,7 +215,7 @@ async function initGraph() {
.force('link', d3.forceLink(links).id((d: any) => d.id).distance(85).strength((d: any) => d.strength ?? 0.5))
.force('charge', d3.forceManyBody().strength(-30))
.force('center', d3.forceCenter(W / 2, H / 2).strength(0.02))
.force('collision', d3.forceCollide().radius((d: any) => d.type === 'auteur' ? 12 : 0))
.force('collision', d3.forceCollide().radius((d: any) => d.type === 'ecole-fixed' ? ecoleRadius(ecoleAuteurCounts.get(d.ecoleId) ?? 0) + 4 : 12))
.force('forceX', d3.forceX<any>((d: any) => {
if (d.type === 'auteur') {
const pos = ecolePositions.get(d.ecole_principale)
@@ -225,6 +231,33 @@ async function initGraph() {
return H / 2
}).strength(0.15))
// ---- NOEUDS ECOLES visibles (couche 3.5) ----
// Cercles proportionnels au count d'auteurs, fixes aux centroids Bonpote, cliquables
const gEcoles = g.append('g').attr('class', 'ecoles-nodes')
ecoleFixedNodes.forEach(eNode => {
const ecole = ecoleMap.get(eNode.ecoleId)
if (!ecole) return
const count = ecoleAuteurCounts.get(eNode.ecoleId) ?? 0
const r = ecoleRadius(count)
gEcoles.append('circle')
.attr('cx', eNode.fx).attr('cy', eNode.fy).attr('r', r)
.attr('fill', ecole.color + '22').attr('stroke', ecole.color).attr('stroke-width', 2.5)
.attr('class', 'ecole-node').style('cursor', 'pointer')
.on('mouseenter', (e: any) => {
if (!tooltipRef.value) return
tooltipRef.value.innerHTML = `<strong>${ecole.label}</strong> <span style="opacity:0.6;font-size:0.7rem;">${count} auteur${count > 1 ? 's' : ''}</span><br><span style="opacity:0.75;font-size:0.72rem;">${ecole.description}</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' })
.on('click', (e: any) => { e.stopPropagation(); emit('select-ecole', eNode.ecoleId) })
})
// ---- LIENS APPARTENANCE (couche 4) ----
const gLinks = g.append('g').attr('class', 'links-appartenance')
d3LinkSel = gLinks.selectAll('line').data(links).join('line')
@@ -353,6 +386,13 @@ defineExpose({ triggerResize })
cursor: default;
}
.ecole-node {
transition: opacity 0.15s, r 0.15s;
}
.ecole-node:hover {
opacity: 0.75;
}
/* ---- Labels ecoles : calque separe NON-blurre (Phase 8.D) ---- */
.voronoi-labels {
pointer-events: none;

View File

@@ -20,7 +20,6 @@
<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">
@@ -119,7 +118,6 @@
<div class="flex items-center justify-between px-4 py-2 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>
</div>

101
components/FicheEcole.vue Normal file
View File

@@ -0,0 +1,101 @@
<template>
<Teleport to="body">
<Transition name="backdrop">
<div v-if="open && ecole" 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 && ecole" class="fixed z-[1501] left-1/2 flex flex-col"
style="top:50%;transform:translate(-50%,-50%);width:min(540px,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 ${ecole.color}; background: var(--nav-surface);`">
<div class="flex-1 min-w-0">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold" :style="`background:${ecole.color}22;color:${ecole.color};`">Ecole de pensee</span>
<h2 class="mt-2 font-bold text-lg leading-tight" style="color:var(--nav-text);">{{ ecole.label }}</h2>
<p class="text-sm mt-1 leading-relaxed" style="color:var(--nav-text-muted);">{{ ecole.description }}</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">
<div v-if="auteursIngeres.length">
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color:var(--nav-text-muted);">Dans le RAG ({{ auteursIngeres.length }})</p>
<div class="flex flex-wrap gap-2">
<button v-for="a in auteursIngeres" :key="a.id"
class="px-2.5 py-1 rounded-full text-xs font-medium hover:opacity-80 transition-opacity"
:style="`background:${ecole.color}22;color:${ecole.color};border:1px solid ${ecole.color}44;cursor:pointer;`"
@click="onSelectAuteur(a.id)">
{{ a.nom }}
</button>
</div>
</div>
<div v-if="auteursNonIngeres.length">
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color:var(--nav-text-muted);">Presents dans Bonpote, pas encore dans le RAG ({{ auteursNonIngeres.length }})</p>
<div class="flex flex-wrap gap-2">
<span v-for="a in auteursNonIngeres" :key="a.id"
class="px-2.5 py-1 rounded-full text-xs"
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);">
{{ a.nom }}
</span>
</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-ecole', ecoleId!)" class="w-full py-2.5 rounded-lg text-sm font-semibold hover:opacity-80"
:style="`background:${ecole.color};color:white;`">
Interroger le RAG sur {{ ecole.label }}
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
interface AuteurData { id: string; nom: string; ecoles: string[]; ecole_principale: string; ingere: boolean }
interface EcoleData { id: string; label: string; description: string; color: string }
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
const props = defineProps<{ open: boolean; ecoleId: string | null; data: PenseesData | null }>()
const emit = defineEmits<{ close: []; 'select-auteur': [auteurId: string]; 'interroger-ecole': [ecoleId: string] }>()
const ecole = computed<EcoleData | null>(() => {
if (!props.ecoleId || !props.data) return null
return props.data.ecoles.find(e => e.id === props.ecoleId) ?? null
})
const auteursIngeres = computed(() => {
if (!props.ecoleId || !props.data) return []
return props.data.auteurs.filter(a => a.ecole_principale === props.ecoleId && (a as any).ingere)
})
const auteursNonIngeres = computed(() => {
if (!props.ecoleId || !props.data) return []
return props.data.auteurs.filter(a => a.ecole_principale === props.ecoleId && !(a as any).ingere)
})
function onSelectAuteur(id: string) {
emit('close')
emit('select-auteur', 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>