refactor(chatbot): ChatbotPlaceholder paramétrable, /agences desktop sur chatbot-reseaux

- ChatbotPlaceholder : props endpoint, titre, placeholderReplie, placeholderSaisie, intro, exemple, fichesTitre, ficheLienModele, rendreMarkdown, messageLimite ; défauts = comportement actuel de l'accueil
- pages/agences.vue : le desktop appelle /api/chatbot-reseaux (le câblage croisé vers le backend Entraide est supprimé), textes orientés structures, rendu markdown, liens /agences#id
- suppression de server/api/chatbot-v2.post.ts et server/utils/vectorSearch.ts (aucun appelant) ; base recherche sémantique par embeddings retrouvable au commit 39602e26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vcVGz8i4WpZm2dixKUiSD
This commit is contained in:
Jules Neny
2026-10-09 03:32:18 +02:00
co-authored by Claude Opus 5.5
parent 39602e26c7
commit 31edbd37af
4 changed files with 62 additions and 300 deletions
+52 -10
View File
@@ -12,7 +12,7 @@
role="button" role="button"
tabindex="0" tabindex="0"
:aria-expanded="isExpanded" :aria-expanded="isExpanded"
aria-label="Ouvrir l'assistant chatbot" :aria-label="`Ouvrir l'assistant : ${titre}`"
@keydown.enter="toggleExpand" @keydown.enter="toggleExpand"
@keydown.space.prevent="toggleExpand" @keydown.space.prevent="toggleExpand"
> >
@@ -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 ? 'Chatbot AEP' : 'Pose une question sur le réseau…' }} {{ isExpanded ? titre : placeholderReplie }}
</span> </span>
<!-- Chevron --> <!-- Chevron -->
@@ -52,21 +52,22 @@
<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">
<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>{{ intro }}</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 v-if="exemple" class="example">{{ exemple }}</p>
</div> </div>
<!-- Messages --> <!-- Messages -->
<template v-for="(msg, i) in messages" :key="i"> <template v-for="(msg, i) in messages" :key="i">
<div v-if="msg.role === 'user'" class="user-bubble">{{ msg.content }}</div> <div v-if="msg.role === 'user'" class="user-bubble">{{ msg.content }}</div>
<div v-else class="assistant-bubble"> <div v-else class="assistant-bubble">
<p>{{ msg.content }}</p> <div v-if="rendreMarkdown" v-html="renderMd(msg.content)" />
<p v-else>{{ 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 recommandees :</p> <p class="fiches-title">{{ fichesTitre }}</p>
<a <a
v-for="fiche in msg.fiches" v-for="fiche in msg.fiches"
:key="fiche.id" :key="fiche.id"
:href="`/fiche/${fiche.id}`" :href="ficheLienModele.replace('{id}', String(fiche.id))"
class="fiche-card" class="fiche-card"
> >
<span class="fiche-nom">{{ fiche.nom }}</span> <span class="fiche-nom">{{ fiche.nom }}</span>
@@ -106,7 +107,7 @@
v-model="inputText" v-model="inputText"
type="text" type="text"
:disabled="loading" :disabled="loading"
placeholder="Pose ta question…" :placeholder="placeholderSaisie"
class="chatbot-input" class="chatbot-input"
@keydown.enter.prevent="sendMessage" @keydown.enter.prevent="sendMessage"
/> />
@@ -127,6 +128,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMarkdown } from '~/composables/useMarkdown'
const { render: renderMd } = useMarkdown()
interface FicheReco { interface FicheReco {
id: number | string id: number | string
nom: string nom: string
@@ -140,6 +144,44 @@ interface ChatMessage {
suggestedHashtags?: string[] suggestedHashtags?: string[]
} }
// Composant unique pour les deux chatbots desktop. Les défauts reproduisent
// exactement le comportement de l'accueil (/api/chatbot, backend Entraide).
// Les deux backends renvoient { reponse_texte, fiches_recommandees } ; seul
// le rendu diffère (texte brut vs markdown, lien de fiche).
const props = withDefaults(defineProps<{
/** Route serveur appelée (body { question }) */
endpoint?: string
/** Titre affiché dans l'en-tête déplié */
titre?: string
/** Texte de l'en-tête replié */
placeholderReplie?: string
/** Placeholder du champ de saisie */
placeholderSaisie?: string
/** Texte d'accueil (première bulle) */
intro?: string
/** Exemple de question, affiché sous l'accueil */
exemple?: string
/** Intitulé de la liste de fiches/structures citées */
fichesTitre?: string
/** Modèle de lien vers une fiche, {id} est remplacé */
ficheLienModele?: string
/** Rendre la réponse en markdown (backend réseaux) plutôt qu'en texte brut */
rendreMarkdown?: boolean
/** Message affiché quand la limite quotidienne (429) est atteinte */
messageLimite?: string
}>(), {
endpoint: '/api/chatbot',
titre: 'Chatbot AEP',
placeholderReplie: 'Pose une question sur le réseau…',
placeholderSaisie: 'Pose ta question…',
intro: "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: '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."',
fichesTitre: 'Fiches recommandees :',
ficheLienModele: '/fiche/{id}',
rendreMarkdown: false,
messageLimite: 'Limite de 10 questions par jour atteinte. Reviens demain.',
})
const emit = defineEmits<{ const emit = defineEmits<{
'highlightOrgs': [ids: (number | string)[]] 'highlightOrgs': [ids: (number | string)[]]
'applyHashtag': [tag: string] 'applyHashtag': [tag: string]
@@ -203,7 +245,7 @@ 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 }[]
}>('/api/chatbot', { }>(props.endpoint, {
method: 'POST', method: 'POST',
body: { question }, body: { question },
}) })
@@ -223,7 +265,7 @@ async function sendMessage() {
} catch (e: any) { } catch (e: any) {
const status = e?.statusCode ?? e?.status const status = e?.statusCode ?? e?.status
if (status === 429) { if (status === 429) {
errorMsg.value = 'Limite de 10 questions par jour atteinte. Reviens demain.' errorMsg.value = props.messageLimite
} else if (status === 503) { } else if (status === 503) {
errorMsg.value = 'Le budget IA mensuel est épuisé. Soutiens NAV sur Liberapay pour continuer.' errorMsg.value = 'Le budget IA mensuel est épuisé. Soutiens NAV sur Liberapay pour continuer.'
} else { } else {
+10
View File
@@ -58,6 +58,16 @@
<!-- Chatbot sous la carte (ordinateur) --> <!-- Chatbot sous la carte (ordinateur) -->
<div v-if="vf.etat.sousVue !== 'outremer'" class="hidden lg:block shrink-0"> <div v-if="vf.etat.sousVue !== 'outremer'" class="hidden lg:block shrink-0">
<ChatbotPlaceholder <ChatbotPlaceholder
endpoint="/api/chatbot-reseaux"
titre="Assistant Réseaux AEP"
placeholder-replie="Pose une question sur les structures et agences du réseau…"
placeholder-saisie="Décris ta situation…"
intro="Je connais les 120 réseaux, collectifs et agences cartographiés dans AEP — ceux qui portent une vision écologique et politique de l'architecture. Décris ta situation : tu cherches un collectif, une agence inspirante, un partenaire sur un projet ?"
exemple="Exemple : « Quelles agences travaillent sur le réemploi ? »"
fiches-titre="Structures recommandées :"
fiche-lien-modele="/agences#{id}"
rendre-markdown
message-limite="Limite de 20 questions par jour atteinte."
@highlightOrgs="() => {}" @highlightOrgs="() => {}"
@applyHashtag="(tag) => vf.ajouterFiltre('tags', sansDiese(tag))" @applyHashtag="(tag) => vf.ajouterFiltre('tags', sansDiese(tag))"
/> />
-194
View File
@@ -1,194 +0,0 @@
/**
* 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
}
})
-96
View File
@@ -1,96 +0,0 @@
/**
* 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 []
}
}