fix(chatbot): switch endpoint v1 -> v2 + script vectorize CJS + payload Mistral input

- ChatbotPlaceholder.vue : fetch /api/chatbot -> /api/chatbot-v2 (utilise embeddings Mistral)
- scripts/vectorize-v2.js renomme en .cjs (package.json type=module incompat avec require)
- Fix payload Mistral : 'inputs' (deprecated) -> 'input' (API actuelle)
- Vectorisation testee : 120 embeddings -> server/data/embeddings-v2.json (3.4MB, gitignored)
- Cle Mistral en rotation : nouvelle dans .env local (pas commit) + a synchroniser sur VPS
This commit is contained in:
Jules Neny
2026-05-06 01:20:40 +02:00
parent 755d1ef9ae
commit 5878c56888
2 changed files with 184 additions and 30 deletions

View File

@@ -27,7 +27,7 @@
</div>
<span class="flex-1 text-sm" style="color: var(--nav-text-muted);">
{{ isExpanded ? title : placeholder }}
{{ isExpanded ? 'Chatbot AEP' : 'Pose une question sur le réseau…' }}
</span>
<!-- Chevron -->
@@ -52,20 +52,9 @@
<div class="chatbot-body-inner" ref="messagesContainer">
<!-- Onboarding -->
<div v-if="messages.length === 0" class="onboarding-bubble">
<slot name="onboarding">
<p>Ce chatbot fonctionne sur un serveur européen souverain
(Mistral FR, zéro rétention), conçu sobre en énergie.</p>
<p>Pour m'aider à te répondre efficacement,
formule ta requête ainsi :</p>
<ul>
<li>• Besoin : [ce que tu cherches]</li>
<li>• Thématique : [juridique / technique / économique / ...]</li>
<li>• Lieu : [région ou ville]</li>
</ul>
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
employeur, besoin conseil juridique droit du travail,
Île-de-France."</p>
</slot>
<p>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.</p>
<p class="example">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."</p>
<p style="margin-top: 8px; font-size: 0.72rem; opacity: 0.6;">Propulsé par Mistral FR - serveur européen souverain, zéro rétention.</p>
</div>
<!-- Messages -->
@@ -74,17 +63,32 @@ employeur, besoin conseil juridique droit du travail,
<div v-else class="assistant-bubble">
<p>{{ msg.content }}</p>
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
<p class="fiches-title">Fiches recommandées :</p>
<p class="fiches-title">Fiches recommandees :</p>
<a
v-for="fiche in msg.fiches"
:key="fiche.id"
:href="`${ficheBasePath}/${fiche.id}`"
:href="`/fiche/${fiche.id}`"
class="fiche-card"
>
<span class="fiche-nom">{{ fiche.nom }}</span>
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
</a>
</div>
<div v-if="msg.suggestedHashtags && msg.suggestedHashtags.length" style="margin-top: 8px;">
<p style="font-size: 0.7rem; color: var(--nav-text-muted); margin-bottom: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;">Filtrer par :</p>
<div style="display: flex; flex-wrap: wrap; gap: 4px;">
<span
v-for="tag in msg.suggestedHashtags"
:key="tag"
style="
padding: 2px 8px; border-radius: 9999px; font-size: 0.7rem; cursor: pointer;
background: var(--nav-bg-alt); color: var(--nav-text); border: 1px solid var(--nav-bg-alt);
transition: all 0.15s;
"
@click="emit('applyHashtag', tag)"
>{{ tag }}</span>
</div>
</div>
</div>
</template>
@@ -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<HTMLElement | null>(null)
// Detection hashtags depuis la question posee
const HASHTAG_KEYWORDS: Record<string, string[]> = {
'#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)

127
scripts/vectorize-v2.cjs Normal file
View File

@@ -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)
})