From cf60d4b973f2cb39c541bea2a238f40a52a6c70d Mon Sep 17 00:00:00 2001 From: Jules Neny Date: Wed, 6 May 2026 17:23:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(aep-v2):=20restore=20V2=20cascade=20compos?= =?UTF-8?q?ants=20r=C3=A9cup=C3=A9r=C3=A9s=20depuis=20vault=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Récupérés depuis commit vault b700612^ (état pré-chirurgie git) - FicheFamilleModal.vue (284L) — PV2-5g - FicheModalV2.vue (341L) + NavMapV2.vue (243L) — PV2-5 - HashtagFilter.vue (97L) + IntentionBanner.vue (76L) — PV2-5 - GraphView.vue (860L) — PV2-5b+5e+5f+5g complet - ChatbotPlaceholder.vue (423L) — version chatbot-v2 - pages/index.vue (517L) — carte unifiée 3 onglets - types/structure-v2.ts, assets/css/v2-bifurcation.css - server/api/chatbot-v2.post.ts, server/utils/vectorSearch.ts Co-Authored-By: Claude Sonnet 4.6 --- assets/css/v2-bifurcation.css | 23 + components/FicheFamilleModal.vue | 284 ++++++++++ components/FicheModalV2.vue | 341 ++++++++++++ components/GraphView.vue | 860 +++++++++++++++++++++++++++++++ components/HashtagFilter.vue | 97 ++++ components/IntentionBanner.vue | 76 +++ components/NavMapV2.vue | 243 +++++++++ server/api/admin/rag-info.get.ts | 39 ++ server/api/chatbot-v2.post.ts | 194 +++++++ server/utils/vectorSearch.ts | 96 ++++ types/structure-v2.ts | 91 ++++ 11 files changed, 2344 insertions(+) create mode 100644 assets/css/v2-bifurcation.css create mode 100644 components/FicheFamilleModal.vue create mode 100644 components/FicheModalV2.vue create mode 100644 components/GraphView.vue create mode 100644 components/HashtagFilter.vue create mode 100644 components/IntentionBanner.vue create mode 100644 components/NavMapV2.vue create mode 100644 server/api/admin/rag-info.get.ts create mode 100644 server/api/chatbot-v2.post.ts create mode 100644 server/utils/vectorSearch.ts create mode 100644 types/structure-v2.ts diff --git a/assets/css/v2-bifurcation.css b/assets/css/v2-bifurcation.css new file mode 100644 index 0000000..3d6ca22 --- /dev/null +++ b/assets/css/v2-bifurcation.css @@ -0,0 +1,23 @@ +/* Palette familles V2 - variables locales, ne pas toucher --nav-* */ +:root { + --bifurc-color-f1: #a85d3e; /* Réemploi & filières - terracotta */ + --bifurc-color-f2: #c4a472; /* Frugalité & low-tech - terre crue */ + --bifurc-color-f3: #d4a017; /* Architecture sociale - ocre */ + --bifurc-color-f4: #5a7a4a; /* Collectifs & AMO - vert mousse */ + --bifurc-color-f5: #3d6a8c; /* Urbanisme transition - bleu profond */ + + --bifurc-badge-f6: #6b3fa0; /* Recherche politique - violet */ + --bifurc-badge-cr: #2d8a6b; /* Centre ressources - vert foncé */ + --bifurc-badge-mm: #c44a2f; /* Mouvement manifeste - rouge brique */ + --bifurc-badge-cp: #1a3a6b; /* Contre-pouvoir - bleu nuit */ + + --bifurc-banner-bg: #faf8f5; + --bifurc-banner-border: #e0d8cc; + --bifurc-banner-text: #2c2416; +} + +.bifurc-pin-f1 { background: var(--bifurc-color-f1); } +.bifurc-pin-f2 { background: var(--bifurc-color-f2); } +.bifurc-pin-f3 { background: var(--bifurc-color-f3); } +.bifurc-pin-f4 { background: var(--bifurc-color-f4); } +.bifurc-pin-f5 { background: var(--bifurc-color-f5); } diff --git a/components/FicheFamilleModal.vue b/components/FicheFamilleModal.vue new file mode 100644 index 0000000..2d56e53 --- /dev/null +++ b/components/FicheFamilleModal.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/components/FicheModalV2.vue b/components/FicheModalV2.vue new file mode 100644 index 0000000..8cccf41 --- /dev/null +++ b/components/FicheModalV2.vue @@ -0,0 +1,341 @@ + + + + + diff --git a/components/GraphView.vue b/components/GraphView.vue new file mode 100644 index 0000000..05c9dad --- /dev/null +++ b/components/GraphView.vue @@ -0,0 +1,860 @@ + + + diff --git a/components/HashtagFilter.vue b/components/HashtagFilter.vue new file mode 100644 index 0000000..467d29b --- /dev/null +++ b/components/HashtagFilter.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/components/IntentionBanner.vue b/components/IntentionBanner.vue new file mode 100644 index 0000000..888f17d --- /dev/null +++ b/components/IntentionBanner.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/components/NavMapV2.vue b/components/NavMapV2.vue new file mode 100644 index 0000000..e0e3b43 --- /dev/null +++ b/components/NavMapV2.vue @@ -0,0 +1,243 @@ + + + diff --git a/server/api/admin/rag-info.get.ts b/server/api/admin/rag-info.get.ts new file mode 100644 index 0000000..d6ab77e --- /dev/null +++ b/server/api/admin/rag-info.get.ts @@ -0,0 +1,39 @@ +/** + * GET /api/admin/rag-info + * + * Retourne le statut du système RAG (v1 + v2) pour la page /admin/rag-status + */ + +import { existsSync, readFileSync } from 'fs' +import { resolve } from 'path' + +export default defineEventHandler(async (_event) => { + // Statut V2 : compter les embeddings + let v2Count = 0 + let v2Date: string | null = null + let v2Model: string | null = null + + try { + // Chercher depuis process.cwd() (racine du projet Nuxt) + const embPath = resolve(process.cwd(), 'server', 'data', 'embeddings-v2.json') + if (existsSync(embPath)) { + const data = JSON.parse(readFileSync(embPath, 'utf-8')) + v2Count = data.embeddings?.length ?? 0 + v2Date = data.meta?.date ?? null + v2Model = data.meta?.model ?? null + } + } catch (e: any) { + console.warn('[rag-info] Erreur lecture embeddings-v2.json :', e?.message ?? e) + } + + return { + v2_embeddings_count: v2Count, + v2_ready: v2Count > 0, + v2_model: v2Model ?? 'mistral-embed', + v2_generated_date: v2Date ?? null, + v1_enabled: process.env.RAG_V1_ENABLED !== 'false', + v1_deprecation_date: process.env.RAG_V1_DEPRECATION_DATE ?? 'non défini', + model_chat: 'mistral-small-latest', + setup_command: 'MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js' + } +}) diff --git a/server/api/chatbot-v2.post.ts b/server/api/chatbot-v2.post.ts new file mode 100644 index 0000000..af2e429 --- /dev/null +++ b/server/api/chatbot-v2.post.ts @@ -0,0 +1,194 @@ +/** + * POST /api/chatbot-v2 + * + * Chatbot V2 - Embedding-based search sur structures bifurcation + * Coexiste avec /api/chatbot (keyword NocoDB) pendant la transition. + * + * SETUP AVANT DEPLOY : + * cd nav-carte && MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js + * Coût estimé : ~0.10 EUR pour 120 fiches + * + * Flow : + * 1. Rate limit (réutilise checkRateLimitJson, 10 req/IP/jour) + * 2. Embed la query via Mistral Embed (mistral-embed) + * 3. Top-5 cosine similarity sur embeddings-v2.json + * 4. Si embeddings absents : réponse graceful (v2_ready: false) + * 5. Construit contexte RAG depuis les fiches candidates + * 6. Génère réponse Mistral Small (json_object) + * 7. Retourne { reponse_texte, fiches_recommandees, sources, v2_ready } + * + * Variables d'env : + * MISTRAL_API_KEY - Clé Mistral (partagée avec chatbot v1) + * RAG_V1_ENABLED - true/false (défaut: true) - coexistence pendant transition + * RAG_V1_DEPRECATION_DATE - Date prévue deprecation v1 (ex: 2026-05-18) + */ + +import { checkRateLimitJson } from '~/server/utils/rateLimitJson' +import { loadEmbeddingsV2, topKSearch } from '~/server/utils/vectorSearch' + +// ── System prompt V2 ─────────────────────────────────────────────────────────── + +const SYSTEM_PROMPT_V2 = `Tu es un assistant pour la carte des réseaux de bifurcation en architecture (projet AEP). +Tu réponds aux questions sur les structures, les pratiques, les pensées écologiques. + +Règles : +- Cite chaque structure par son nom exact et son fiche_id +- Indique la famille (1-5) entre parenthèses après chaque nom +- Reste sobre et descriptif - pas militant agressif +- Tirets longs interdits : utilise des - ou des ; +- Max 200 mots par réponse +- Si hors-scope (pas archi/habiter/écologie), redirige poliment vers la carte +- Retourne UNIQUEMENT un JSON valide, sans texte avant ou après + +Familles : +1 - Réemploi et filières +2 - Frugalité et low-tech +3 - Architecture sociale et précarités +4 - Collectifs, écolieux et AMO +5 - Urbanisme de transition et territoires + +FORMAT DE SORTIE : +{ + "reponse_texte": "Ta réponse en prose (max 200 mots)", + "fiches_recommandees": [ + { "fiche_id": "f1-rotor", "nom": "Rotor", "explication": "1-2 phrases pourquoi cette fiche" } + ] +} + +CONTEXTE - Structures disponibles : +{{CONTEXTE_RAG}}` + +// ── Handler ──────────────────────────────────────────────────────────────────── + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + + // 1. Rate limit + const ip = + getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() || + event.node.req.socket?.remoteAddress || + '0.0.0.0' + + const allowed = checkRateLimitJson(ip, 'chatbot-v2', 10) + if (!allowed) { + throw createError({ + statusCode: 429, + statusMessage: 'Limite de 10 questions par jour atteinte.' + }) + } + + // 2. Validation body + const body = await readBody(event) + const question: string = (body?.question ?? '').trim() + + if (!question || question.length < 3) { + throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' }) + } + + const mistralApiKey = config.mistralApiKey as string + if (!mistralApiKey) { + throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' }) + } + + // 3. Charger embeddings V2 (lazy, cachés en mémoire) + const embeddingsV2 = loadEmbeddingsV2() + + // Graceful fallback si le script vectorize-v2.js n'a pas encore été lancé + if (embeddingsV2.length === 0) { + return { + reponse_texte: "La base vectorielle V2 est en cours de préparation. Merci d'utiliser le chatbot classique en attendant.", + fiches_recommandees: [], + sources: [], + v2_ready: false + } + } + + // 4. Embed la query via Mistral Embed + let queryEmbedding: number[] + try { + const embedRes = await $fetch<{ data: { embedding: number[] }[] }>( + 'https://api.mistral.ai/v1/embeddings', + { + method: 'POST', + headers: { + Authorization: `Bearer ${mistralApiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: 'mistral-embed', + inputs: [question] + }) + } + ) + queryEmbedding = embedRes.data[0].embedding + } catch (e: any) { + console.error('[chatbot-v2] Erreur embedding Mistral :', e?.message ?? e) + throw createError({ statusCode: 502, statusMessage: 'Erreur embedding Mistral.' }) + } + + // 5. Top-5 cosine similarity + const v2Results = topKSearch(embeddingsV2, queryEmbedding, 5) + + // 6. Contexte RAG + const candidatesContext = v2Results.map(r => ({ + fiche_id: r.fiche_id, + nom: r.nom, + famille: r.famille, + hashtags: r.hashtags, + score: r.score, + preview: r.text_preview + })) + + const contextStr = candidatesContext + .map(c => `[${c.fiche_id}] ${c.nom} (famille ${c.famille}, score: ${c.score.toFixed(2)})\n${c.preview}`) + .join('\n\n---\n\n') + + const systemPrompt = SYSTEM_PROMPT_V2.replace('{{CONTEXTE_RAG}}', contextStr) + + // 7. Mistral Small - génération réponse + let mistralRaw: string + try { + const mistralRes = await $fetch<{ + choices: { message: { content: string } }[] + }>('https://api.mistral.ai/v1/chat/completions', { + method: 'POST', + headers: { + Authorization: `Bearer ${mistralApiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: 'mistral-small-latest', + temperature: 0.3, + max_tokens: 600, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: question } + ] + }) + }) + mistralRaw = mistralRes.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.' }) + } + + // 8. Parse JSON + let parsed: { reponse_texte: string; fiches_recommandees: any[] } + try { + parsed = JSON.parse(mistralRaw) + if (!parsed.reponse_texte) throw new Error('reponse_texte absent') + } catch { + parsed = { + reponse_texte: "Impossible d'analyser la réponse.", + fiches_recommandees: [] + } + } + + return { + reponse_texte: parsed.reponse_texte, + fiches_recommandees: parsed.fiches_recommandees ?? [], + sources: candidatesContext, + v2_ready: true + } +}) diff --git a/server/utils/vectorSearch.ts b/server/utils/vectorSearch.ts new file mode 100644 index 0000000..b1e851f --- /dev/null +++ b/server/utils/vectorSearch.ts @@ -0,0 +1,96 @@ +/** + * Recherche vectorielle sur les embeddings V2 + * Cosine similarity + top-K + * + * Utilisé par : server/api/chatbot-v2.post.ts + * Données : server/data/embeddings-v2.json (généré par scripts/vectorize-v2.js) + */ + +import { readFileSync, existsSync } from 'fs' +import { fileURLToPath } from 'url' +import { resolve, dirname } from 'path' + +// ── Types ────────────────────────────────────────────────────────────────────── + +export interface EmbeddingEntry { + fiche_id: string + nom: string + famille: number + hashtags: string[] + embedding: number[] + text_preview: string +} + +export interface SearchResult { + fiche_id: string + nom: string + famille: number + hashtags: string[] + score: number + text_preview: string +} + +// ── Cosine similarity ────────────────────────────────────────────────────────── + +export function cosineSimilarity(a: number[], b: number[]): number { + if (a.length !== b.length) return 0 + let dot = 0, normA = 0, normB = 0 + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i] + normA += a[i] * a[i] + normB += b[i] * b[i] + } + const denom = Math.sqrt(normA) * Math.sqrt(normB) + return denom === 0 ? 0 : dot / denom +} + +// ── Top-K search ─────────────────────────────────────────────────────────────── + +export function topKSearch( + embeddings: EmbeddingEntry[], + queryEmbedding: number[], + k: number = 5 +): SearchResult[] { + return embeddings + .map(e => ({ + fiche_id: e.fiche_id, + nom: e.nom, + famille: e.famille, + hashtags: e.hashtags, + score: cosineSimilarity(e.embedding, queryEmbedding), + text_preview: e.text_preview + })) + .sort((a, b) => b.score - a.score) + .slice(0, k) +} + +// ── Chargement lazy des embeddings (cache module-level) ──────────────────────── + +let _embeddingsV2: EmbeddingEntry[] | null = null + +export function loadEmbeddingsV2(): EmbeddingEntry[] { + if (_embeddingsV2 !== null) return _embeddingsV2 + + try { + // Résolution du chemin depuis server/utils/ vers server/data/ + const currentDir = dirname(fileURLToPath(import.meta.url)) + const embPath = resolve(currentDir, '..', 'data', 'embeddings-v2.json') + + if (!existsSync(embPath)) { + console.warn('[vectorSearch] embeddings-v2.json absent - V2 vector search désactivé') + console.warn('[vectorSearch] Lancer : MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js') + _embeddingsV2 = [] + return [] + } + + const raw = readFileSync(embPath, 'utf-8') + const data = JSON.parse(raw) + _embeddingsV2 = data.embeddings ?? [] + console.log(`[vectorSearch] ${_embeddingsV2!.length} embeddings V2 chargés (${data.meta?.model ?? 'unknown'})`) + return _embeddingsV2! + } catch (e: any) { + console.warn('[vectorSearch] Erreur chargement embeddings-v2.json :', e?.message ?? e) + _embeddingsV2 = [] + return [] + } +} diff --git a/types/structure-v2.ts b/types/structure-v2.ts new file mode 100644 index 0000000..b7eca5a --- /dev/null +++ b/types/structure-v2.ts @@ -0,0 +1,91 @@ +/** + * Types V2 - Carte des réseaux de bifurcation + * Source : public/data/reseaux-bifurcation.json + */ + +export interface StructureV2 { + id: string + nom: string + url: string + pays: string + ville: string + famille_principale: 1 | 2 | 3 | 4 | 5 + familles_secondaires?: number[] + hashtags: string[] + type_principal: string + badges: { + centre_ressources: boolean + mouvement_manifeste: boolean + contre_pouvoir_spatial: boolean + f6_recherche_politique: boolean + } + description_courte: string + description_longue: string + pensees: { id: string; label: string; confiance: string }[] + sources: { type: string; titre: string; url: string }[] + already_in_v1: boolean + eligible_v2: boolean + // Geocoords (ajoutés par géocodage - peut être null) + latitude?: number | null + longitude?: number | null +} + +export interface ReseauxBifurcationData { + version: string + meta: { + total_structures: number + total_projets_emblematiques: number + total_edges_graphe: number + familles: { id: number; label: string; color: string }[] + hashtags_officiels: string[] + } + structures: StructureV2[] + projets: ProjetEmblematique[] + graphe: { edges: GrapheEdge[] } +} + +export interface ProjetEmblematique { + id: string + nom: string + structure_parent: string + annee?: number + lieu?: string + geocoords?: { lat: number; lng: number } | null + description: string + url?: string | null + tags: string[] +} + +export interface GrapheEdge { + source: string + target: string + types: string[] + score: number + evidence: string +} + +// Mapping StructureV2 vers le format attendu par NavMap (interface Org) +// NavMap attend { Id, latitude, longitude, nom, ... } +export function structureToMapOrg(s: StructureV2, index: number): { + Id: number + nom: string + latitude?: number | null + longitude?: number | null + prioritaire?: boolean + famille_principale?: number + hashtags?: string[] + type_principal?: string + description_courte?: string +} { + return { + Id: index, + nom: s.nom, + latitude: s.latitude, + longitude: s.longitude, + prioritaire: s.badges?.centre_ressources || s.badges?.mouvement_manifeste || s.badges?.contre_pouvoir_spatial, + famille_principale: s.famille_principale, + hashtags: s.hashtags, + type_principal: s.type_principal, + description_courte: s.description_courte, + } +}