From 40b406bd4188bb5be02ae6f4e8e8df00d2064f36 Mon Sep 17 00:00:00 2001 From: Jules Neny Date: Thu, 14 May 2026 05:56:09 +0200 Subject: [PATCH] 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 --- components/CartePensees.vue | 44 ++++++++++- components/ChatbotPensees.vue | 2 - components/FicheEcole.vue | 101 +++++++++++++++++++++++++ nuxt.config.ts | 1 + pages/media.vue | 114 +++++++++++++++++++++++++++-- server/api/chatbot-reseaux.post.ts | 10 +-- server/api/chatbot-taff.post.ts | 12 +-- server/api/chatbot-v2.post.ts | 19 +++-- server/api/chatbot.post.ts | 24 +++--- 9 files changed, 286 insertions(+), 41 deletions(-) create mode 100644 components/FicheEcole.vue diff --git a/components/CartePensees.vue b/components/CartePensees.vue index 2d9a72c..ada27d1 100644 --- a/components/CartePensees.vue +++ b/components/CartePensees.vue @@ -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(null) const tooltipRef = ref(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() + 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((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 = `${ecole.label} ${count} auteur${count > 1 ? 's' : ''}
${ecole.description}` + 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; diff --git a/components/ChatbotPensees.vue b/components/ChatbotPensees.vue index 4cbeb75..016b373 100644 --- a/components/ChatbotPensees.vue +++ b/components/ChatbotPensees.vue @@ -20,7 +20,6 @@

RAG Pensees Ecologiques

-

{{ corpusCount }} auteurs ingeres

@@ -32,6 +46,7 @@ :data="penseesData" :active="true" @select-auteur="onSelectAuteur" + @select-ecole="onSelectEcole" /> @@ -133,6 +203,9 @@ const DEFAULT_SPLIT_RATIO = 0.66 const ficheOpen = ref(false) const ficheAuteurId = ref(null) +const ficheEcoleOpen = ref(false) +const ficheEcoleId = ref(null) +const ragInfoOpen = ref(false) const chatbotAuteur = ref(null) const penseesData = ref(null) const layoutMode = ref('split') @@ -179,7 +252,6 @@ function onHandleMouseup() { } onMounted(async () => { - // Restaurer le mode de layout depuis localStorage if (typeof window !== 'undefined') { const saved = localStorage.getItem(STORAGE_KEY) as LayoutMode | null if (saved && ['split', 'carte-full', 'chatbot-full'].includes(saved)) { @@ -189,6 +261,11 @@ onMounted(async () => { if (!isNaN(savedRatio) && savedRatio >= 0.20 && savedRatio <= 0.80) { splitRatio.value = savedRatio } + // Afficher le popup info RAG a la premiere visite + if (!localStorage.getItem('rag-fracas-info-seen')) { + ragInfoOpen.value = true + localStorage.setItem('rag-fracas-info-seen', '1') + } } try { penseesData.value = await $fetch('/data/auteurs-pensees.json') @@ -223,6 +300,23 @@ function onSelectAuteur(id: string) { chatbotAuteur.value = null } +function onSelectEcole(id: string) { + ficheEcoleId.value = id + ficheEcoleOpen.value = true +} + +function onSelectAuteurFromEcole(auteurId: string) { + ficheEcoleOpen.value = false + onSelectAuteur(auteurId) +} + +function onInterrogerEcole(ecoleId: string) { + ficheEcoleOpen.value = false + const ecole = penseesData.value?.ecoles.find(e => e.id === ecoleId) + chatbotAuteur.value = ecole?.label ?? null + if (layoutMode.value === 'carte-full') setLayoutMode('split') +} + function onInterrogerRag(auteurId: string) { ficheOpen.value = false const auteur = penseesData.value?.auteurs.find(a => a.id === auteurId) @@ -392,6 +486,14 @@ useHead({ title: 'AEP - Media - Carte FRACAS Bonpote' }) overflow: hidden; } +/* --- Transitions modal RAG info --- */ +.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); } + /* --- Responsive mobile (<768px) --- */ /* Stack vertical : carte 60vh + chatbot 40vh en mode split */ @media (max-width: 767px) { diff --git a/server/api/chatbot-reseaux.post.ts b/server/api/chatbot-reseaux.post.ts index 19b1a4c..ad31482 100644 --- a/server/api/chatbot-reseaux.post.ts +++ b/server/api/chatbot-reseaux.post.ts @@ -82,18 +82,18 @@ export default defineEventHandler(async (event) => { const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0)) - const mistralApiKey = config.mistralApiKey as string - if (!mistralApiKey) throw createError({ statusCode: 500, message: 'Clé API Mistral manquante.' }) + const nebiusApiKey = config.nebiusApiKey as string + if (!nebiusApiKey) throw createError({ statusCode: 500, message: 'Clé API Nebius manquante.' }) let mistralRaw: string try { const res = await $fetch<{ choices: { message: { content: string } }[] }>( - 'https://api.mistral.ai/v1/chat/completions', + 'https://api.tokenfactory.nebius.com/v1/chat/completions', { method: 'POST', - headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' }, + headers: { Authorization: `Bearer ${nebiusApiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ - model: 'mistral-small-latest', + model: 'deepseek-ai/DeepSeek-V3.2', temperature: 0.3, max_tokens: 700, response_format: { type: 'json_object' }, diff --git a/server/api/chatbot-taff.post.ts b/server/api/chatbot-taff.post.ts index b76fbb1..dd74bcb 100644 --- a/server/api/chatbot-taff.post.ts +++ b/server/api/chatbot-taff.post.ts @@ -91,20 +91,20 @@ export default defineEventHandler(async (event) => { const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0)) - const mistralApiKey = config.mistralApiKey as string - if (!mistralApiKey) { - throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' }) + const nebiusApiKey = config.nebiusApiKey as string + if (!nebiusApiKey) { + throw createError({ statusCode: 500, statusMessage: 'Clé API Nebius manquante.' }) } let mistralRaw: string try { const res = await $fetch<{ choices: { message: { content: string } }[] }>( - 'https://api.mistral.ai/v1/chat/completions', + 'https://api.tokenfactory.nebius.com/v1/chat/completions', { method: 'POST', - headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' }, + headers: { Authorization: `Bearer ${nebiusApiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ - model: 'mistral-small-latest', + model: 'deepseek-ai/DeepSeek-V3.2', temperature: 0.3, max_tokens: 700, response_format: { type: 'json_object' }, diff --git a/server/api/chatbot-v2.post.ts b/server/api/chatbot-v2.post.ts index af2e429..d44ffd1 100644 --- a/server/api/chatbot-v2.post.ts +++ b/server/api/chatbot-v2.post.ts @@ -145,19 +145,22 @@ export default defineEventHandler(async (event) => { const systemPrompt = SYSTEM_PROMPT_V2.replace('{{CONTEXTE_RAG}}', contextStr) - // 7. Mistral Small - génération réponse + // 7. Nebius DeepSeek-V3.2 - génération réponse + const nebiusApiKey = config.nebiusApiKey as string + if (!nebiusApiKey) throw createError({ statusCode: 500, statusMessage: 'Clé API Nebius manquante.' }) + let mistralRaw: string try { - const mistralRes = await $fetch<{ + const nebiusRes = await $fetch<{ choices: { message: { content: string } }[] - }>('https://api.mistral.ai/v1/chat/completions', { + }>('https://api.tokenfactory.nebius.com/v1/chat/completions', { method: 'POST', headers: { - Authorization: `Bearer ${mistralApiKey}`, + Authorization: `Bearer ${nebiusApiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ - model: 'mistral-small-latest', + model: 'deepseek-ai/DeepSeek-V3.2', temperature: 0.3, max_tokens: 600, response_format: { type: 'json_object' }, @@ -167,10 +170,10 @@ export default defineEventHandler(async (event) => { ] }) }) - mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}' + mistralRaw = nebiusRes.choices?.[0]?.message?.content ?? '{}' } catch (e: any) { - console.error('[chatbot-v2] Erreur Mistral Small :', e?.message ?? e) - throw createError({ statusCode: 502, statusMessage: 'Erreur appel Mistral Small.' }) + console.error('[chatbot-v2] Erreur Nebius DeepSeek :', e?.message ?? e) + throw createError({ statusCode: 502, statusMessage: 'Erreur appel Nebius DeepSeek.' }) } // 8. Parse JSON diff --git a/server/api/chatbot.post.ts b/server/api/chatbot.post.ts index b568756..74cfb7f 100644 --- a/server/api/chatbot.post.ts +++ b/server/api/chatbot.post.ts @@ -247,13 +247,13 @@ export default defineEventHandler(async (event) => { JSON.stringify(fichesContext, null, 0), ) - // 6. Appel Mistral Small - const mistralApiKey = config.mistralApiKey as string + // 6. Appel Nebius DeepSeek-V3.2 + const nebiusApiKey = config.nebiusApiKey as string - if (!mistralApiKey) { + if (!nebiusApiKey) { throw createError({ statusCode: 500, - statusMessage: 'Clé API Mistral manquante.', + statusMessage: 'Clé API Nebius manquante.', }) } @@ -262,17 +262,17 @@ export default defineEventHandler(async (event) => { let tokensOut = 0 try { - const mistralRes = await $fetch<{ + const nebiusRes = await $fetch<{ choices: { message: { content: string } }[] usage?: { prompt_tokens: number; completion_tokens: number } - }>('https://api.mistral.ai/v1/chat/completions', { + }>('https://api.tokenfactory.nebius.com/v1/chat/completions', { method: 'POST', headers: { - Authorization: `Bearer ${mistralApiKey}`, + Authorization: `Bearer ${nebiusApiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ - model: 'mistral-small-latest', + model: 'deepseek-ai/DeepSeek-V3.2', temperature: 0.3, max_tokens: 600, response_format: { type: 'json_object' }, @@ -283,11 +283,11 @@ export default defineEventHandler(async (event) => { }), }) - mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}' - tokensIn = mistralRes.usage?.prompt_tokens ?? 0 - tokensOut = mistralRes.usage?.completion_tokens ?? 0 + mistralRaw = nebiusRes.choices?.[0]?.message?.content ?? '{}' + tokensIn = nebiusRes.usage?.prompt_tokens ?? 0 + tokensOut = nebiusRes.usage?.completion_tokens ?? 0 } catch (e: any) { - console.error('[chatbot] Erreur Mistral Small:', e?.message ?? e) + console.error('[chatbot] Erreur Nebius DeepSeek:', e?.message ?? e) throw createError({ statusCode: 502, statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',