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:
@@ -27,7 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="flex-1 text-sm" style="color: var(--nav-text-muted);">
|
<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>
|
</span>
|
||||||
|
|
||||||
<!-- Chevron -->
|
<!-- Chevron -->
|
||||||
@@ -52,20 +52,9 @@
|
|||||||
<div class="chatbot-body-inner" ref="messagesContainer">
|
<div class="chatbot-body-inner" ref="messagesContainer">
|
||||||
<!-- Onboarding -->
|
<!-- Onboarding -->
|
||||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||||
<slot name="onboarding">
|
<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>Ce chatbot fonctionne sur un serveur européen souverain
|
<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>
|
||||||
(Mistral FR, zéro rétention), conçu sobre en énergie.</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>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
@@ -74,17 +63,32 @@ employeur, besoin conseil juridique droit du travail,
|
|||||||
<div v-else class="assistant-bubble">
|
<div v-else class="assistant-bubble">
|
||||||
<p>{{ msg.content }}</p>
|
<p>{{ msg.content }}</p>
|
||||||
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
<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
|
<a
|
||||||
v-for="fiche in msg.fiches"
|
v-for="fiche in msg.fiches"
|
||||||
:key="fiche.id"
|
:key="fiche.id"
|
||||||
:href="`${ficheBasePath}/${fiche.id}`"
|
:href="`/fiche/${fiche.id}`"
|
||||||
class="fiche-card"
|
class="fiche-card"
|
||||||
>
|
>
|
||||||
<span class="fiche-nom">{{ fiche.nom }}</span>
|
<span class="fiche-nom">{{ fiche.nom }}</span>
|
||||||
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
|
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -134,22 +138,12 @@ interface ChatMessage {
|
|||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
fiches?: FicheReco[]
|
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<{
|
const emit = defineEmits<{
|
||||||
'highlightOrgs': [ids: (number | string)[]]
|
'highlightOrgs': [ids: (number | string)[]]
|
||||||
|
'applyHashtag': [tag: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isExpanded = ref(false)
|
const isExpanded = ref(false)
|
||||||
@@ -159,6 +153,37 @@ const loading = ref(false)
|
|||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
const messagesContainer = ref<HTMLElement | null>(null)
|
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() {
|
function toggleExpand() {
|
||||||
isExpanded.value = !isExpanded.value
|
isExpanded.value = !isExpanded.value
|
||||||
}
|
}
|
||||||
@@ -179,15 +204,17 @@ async function sendMessage() {
|
|||||||
const res = await $fetch<{
|
const res = await $fetch<{
|
||||||
reponse_texte: string
|
reponse_texte: string
|
||||||
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
|
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
|
||||||
}>(props.endpoint, {
|
}>('/api/chatbot-v2', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { question },
|
body: { question },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const suggestedHashtags = detectHashtagsFromQuery(question)
|
||||||
const assistantMsg: ChatMessage = {
|
const assistantMsg: ChatMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: res.reponse_texte,
|
content: res.reponse_texte,
|
||||||
fiches: res.fiches_recommandees || [],
|
fiches: res.fiches_recommandees || [],
|
||||||
|
suggestedHashtags: suggestedHashtags.length ? suggestedHashtags : undefined,
|
||||||
}
|
}
|
||||||
messages.value.push(assistantMsg)
|
messages.value.push(assistantMsg)
|
||||||
|
|
||||||
|
|||||||
127
scripts/vectorize-v2.cjs
Normal file
127
scripts/vectorize-v2.cjs
Normal 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)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user