-
-
+
-
-
+
Chargement de la carte…
-
+
{}"
/>
@@ -146,81 +248,65 @@
-
+
-
+
+
+
+ 120 réseaux, collectifs et agences où des pensées écologiques deviennent des pratiques d'architecture.
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
ÉCHELLE
-
- {{ opt }}
-
-
-
-
-
-
FONCTION
-
- {{ fn }}
-
-
-
✕ Effacer les filtres
+ >Effacer les filtres
-
+
- {{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
+ {{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }}
Chargement des fiches…
@@ -233,46 +319,36 @@
- {{ org.nom }}
+ {{ structure.nom }}
{{ org.echelle }}
-
-
- {{ fn }}
-
-
- {{ org.localisation_ville }}
+ class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
+ :style="`background: ${familleColor(structure.famille_principale)};`"
+ />
+
{{ structure.type_principal }} · {{ structure.ville }}
-
-
-
+
@@ -301,268 +377,141 @@
{}"
/>
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,
+ }
+}