diff --git a/components/ChatbotPlaceholder.vue b/components/ChatbotPlaceholder.vue index 7b41141..1f7d9e0 100644 --- a/components/ChatbotPlaceholder.vue +++ b/components/ChatbotPlaceholder.vue @@ -27,7 +27,7 @@ - {{ isExpanded ? title : placeholder }} + {{ isExpanded ? 'Chatbot AEP' : 'Pose une question sur le réseau…' }} @@ -52,20 +52,9 @@
- -

Ce chatbot fonctionne sur un serveur européen souverain -(Mistral FR, zéro rétention), conçu sobre en énergie.

-

Pour m'aider à te répondre efficacement, -formule ta requête ainsi :

-
    -
  • • Besoin : [ce que tu cherches]
  • -
  • • Thématique : [juridique / technique / économique / ...]
  • -
  • • Lieu : [région ou ville]
  • -
-

Exemple : "Je suis salarié d'agence, litige avec mon -employeur, besoin conseil juridique droit du travail, -Île-de-France."

-
+

Explore les 120 structures de la carte par la conversation. Je peux t'aider à trouver des collectifs, agences ou réseaux selon ta situation, ta pratique ou tes inspirations du moment.

+

Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."

+

Propulsé par Mistral FR - serveur européen souverain, zéro rétention.

@@ -74,17 +63,32 @@ employeur, besoin conseil juridique droit du travail,

{{ msg.content }}

-

Fiches recommandées :

+

Fiches recommandees :

{{ fiche.nom }} {{ fiche.explication }}
+
+

Filtrer par :

+
+ {{ tag }} +
+
@@ -134,22 +138,12 @@ interface ChatMessage { role: 'user' | 'assistant' content: string fiches?: FicheReco[] + suggestedHashtags?: string[] } -const props = withDefaults(defineProps<{ - endpoint?: string - title?: string - placeholder?: string - ficheBasePath?: string -}>(), { - endpoint: '/api/chatbot', - title: 'Chatbot AEP', - placeholder: 'Pose une question sur le réseau…', - ficheBasePath: '/fiche', -}) - const emit = defineEmits<{ 'highlightOrgs': [ids: (number | string)[]] + 'applyHashtag': [tag: string] }>() const isExpanded = ref(false) @@ -159,6 +153,37 @@ const loading = ref(false) const errorMsg = ref('') const messagesContainer = ref(null) +// Detection hashtags depuis la question posee +const HASHTAG_KEYWORDS: Record = { + '#reemploi-structurel': ['reemploi', 'materiaux recuperes', 'deconstruction', 'reemploi structurel'], + '#reemploi-second-oeuvre': ['revetement', 'second oeuvre', 'reemploi'], + '#biosource-geosource': ['biosource', 'geosource', 'paille', 'terre', 'chanvre', 'lin', 'biosource'], + '#low-tech-experimentation': ['low-tech', 'low tech', 'technique simple', 'autonomie', 'lowtech'], + '#chantier-ecole': ['formation', 'chantier ecole', 'chantier-ecole', 'apprendre', 'auto-construction', 'autoconstruction'], + '#sobriete-energetique': ['sobriete', 'energie', 'renovation energetique', 'isolation', 'chauffage', 'economie energie'], + '#mal-logement-precarite': ['mal-logement', 'precarite', 'sans-abri', 'logement social', 'squat', 'mal logement'], + '#tiers-lieux-friches': ['friche', 'tiers-lieu', 'tiers lieu', 'espace intermediaire', 'temporaire', 'reconversion'], + '#accompagnement-cooperatif': ['cooperative', 'accompagnement', 'cooperation', 'collectif', 'mutualisation'], + '#transition-energetique-territoriale': ['territoire', 'transition', 'energetique', 'local', 'region', 'transition energetique'], + '#communs-fonciers': ['communs', 'foncier', 'anti-speculatif', 'community land trust', 'commun foncier'], + '#hack-juridique': ['juridique', 'montage', 'structure legale', 'sci', 'cooperative', 'statut'], + '#retrofit-strates': ['retrofit', 'renovation lourde', 'sur-isolation', 'rehaussement'], + '#phytoconstruction': ['plantes', 'vegetal', 'arbre', 'construction vivante', 'phyto'], +} + +function detectHashtagsFromQuery(query: string): string[] { + const q = query.toLowerCase() + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + const detected: string[] = [] + for (const [hashtag, keywords] of Object.entries(HASHTAG_KEYWORDS)) { + if (keywords.some(kw => q.includes(kw))) { + detected.push(hashtag) + } + } + return detected.slice(0, 3) +} + function toggleExpand() { isExpanded.value = !isExpanded.value } @@ -179,15 +204,17 @@ async function sendMessage() { const res = await $fetch<{ reponse_texte: string fiches_recommandees: { id: number | string; nom: string; explication: string }[] - }>(props.endpoint, { + }>('/api/chatbot-v2', { method: 'POST', body: { question }, }) + const suggestedHashtags = detectHashtagsFromQuery(question) const assistantMsg: ChatMessage = { role: 'assistant', content: res.reponse_texte, fiches: res.fiches_recommandees || [], + suggestedHashtags: suggestedHashtags.length ? suggestedHashtags : undefined, } messages.value.push(assistantMsg) diff --git a/scripts/vectorize-v2.cjs b/scripts/vectorize-v2.cjs new file mode 100644 index 0000000..99cdb3b --- /dev/null +++ b/scripts/vectorize-v2.cjs @@ -0,0 +1,127 @@ +// scripts/vectorize-v2.js +// Usage : MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js +// Génère : server/data/embeddings-v2.json +// +// SETUP AVANT DEPLOY : +// cd nav-carte && MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js +// Coût estimé : ~0.10 EUR pour 120 fiches +// +// Prérequis : Node >= 18 (fetch natif disponible) + +const fs = require('fs') +const path = require('path') + +const MISTRAL_API_KEY = process.env.MISTRAL_API_KEY +if (!MISTRAL_API_KEY) { + console.error('Erreur : MISTRAL_API_KEY manquante') + console.error('Usage : MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js') + process.exit(1) +} + +const dataPath = path.join(process.cwd(), 'public', 'data', 'reseaux-bifurcation.json') +const outPath = path.join(process.cwd(), 'server', 'data', 'embeddings-v2.json') + +// Créer server/data/ si absent +const outDir = path.dirname(outPath) +if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }) + +const rawData = fs.readFileSync(dataPath, 'utf-8') +const data = JSON.parse(rawData) +const structures = data.structures + +if (!Array.isArray(structures) || structures.length === 0) { + console.error('Erreur : aucune structure trouvée dans reseaux-bifurcation.json') + process.exit(1) +} + +async function embedBatch(texts) { + const res = await fetch('https://api.mistral.ai/v1/embeddings', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${MISTRAL_API_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: 'mistral-embed', + input: texts + }) + }) + if (!res.ok) { + const err = await res.text() + throw new Error(`Mistral API error ${res.status}: ${err}`) + } + const json = await res.json() + return json.data.map(d => d.embedding) +} + +function buildText(s) { + const parts = [ + s.nom, + s.description_courte ?? '', + (s.description_longue ?? '').slice(0, 800), + (s.hashtags ?? []).join(' '), + (s.sources ?? []).map(src => src.titre).join(' '), + (s.pensees ?? []).map(p => p.label).join(' ') + ] + return parts.filter(Boolean).join('\n\n') +} + +async function main() { + const embeddings = [] + const BATCH_SIZE = 8 // Mistral embed : rate limit prudent + + console.log(`Vectorisation de ${structures.length} structures (modele : mistral-embed)...`) + console.log(`Sortie : ${outPath}`) + console.log() + + for (let i = 0; i < structures.length; i += BATCH_SIZE) { + const batch = structures.slice(i, i + BATCH_SIZE) + const texts = batch.map(buildText) + + try { + const batchEmbeddings = await embedBatch(texts) + batch.forEach((s, j) => { + embeddings.push({ + fiche_id: s.id, + nom: s.nom, + famille: s.famille_principale, + hashtags: s.hashtags ?? [], + embedding: batchEmbeddings[j], + text_preview: texts[j].slice(0, 300) + }) + }) + const batchNum = Math.floor(i / BATCH_SIZE) + 1 + const totalBatches = Math.ceil(structures.length / BATCH_SIZE) + console.log(` Batch ${batchNum}/${totalBatches} OK (${batch.length} fiches)`) + // Pause rate limit entre batches + await new Promise(r => setTimeout(r, 200)) + } catch (err) { + console.error(` Erreur batch ${i}-${i + BATCH_SIZE}:`, err.message) + process.exit(1) + } + } + + const output = { + meta: { + total: embeddings.length, + model: 'mistral-embed', + date: new Date().toISOString(), + source: 'reseaux-bifurcation.json' + }, + embeddings + } + + fs.writeFileSync(outPath, JSON.stringify(output, null, 2), 'utf-8') + + const sizeKb = Math.round(fs.statSync(outPath).size / 1024) + console.log() + console.log(`Done : ${embeddings.length} embeddings -> ${outPath}`) + console.log(`Taille : ${sizeKb} KB`) + console.log() + console.log('Prochaine etape : deployer le fichier sur le VPS avec les autres assets.') +} + +main().catch(err => { + console.error('Erreur fatale :', err) + process.exit(1) +})