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:
co-authored by
Claude Opus 5.5
parent
39602e26c7
commit
31edbd37af
@@ -12,7 +12,7 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="isExpanded"
|
||||
aria-label="Ouvrir l'assistant chatbot"
|
||||
:aria-label="`Ouvrir l'assistant : ${titre}`"
|
||||
@keydown.enter="toggleExpand"
|
||||
@keydown.space.prevent="toggleExpand"
|
||||
>
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Chevron -->
|
||||
@@ -52,21 +52,22 @@
|
||||
<div class="chatbot-body-inner" ref="messagesContainer">
|
||||
<!-- Onboarding -->
|
||||
<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 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>{{ intro }}</p>
|
||||
<p v-if="exemple" class="example">{{ exemple }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<template v-for="(msg, i) in messages" :key="i">
|
||||
<div v-if="msg.role === 'user'" class="user-bubble">{{ msg.content }}</div>
|
||||
<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">
|
||||
<p class="fiches-title">Fiches recommandees :</p>
|
||||
<p class="fiches-title">{{ fichesTitre }}</p>
|
||||
<a
|
||||
v-for="fiche in msg.fiches"
|
||||
:key="fiche.id"
|
||||
:href="`/fiche/${fiche.id}`"
|
||||
:href="ficheLienModele.replace('{id}', String(fiche.id))"
|
||||
class="fiche-card"
|
||||
>
|
||||
<span class="fiche-nom">{{ fiche.nom }}</span>
|
||||
@@ -106,7 +107,7 @@
|
||||
v-model="inputText"
|
||||
type="text"
|
||||
:disabled="loading"
|
||||
placeholder="Pose ta question…"
|
||||
:placeholder="placeholderSaisie"
|
||||
class="chatbot-input"
|
||||
@keydown.enter.prevent="sendMessage"
|
||||
/>
|
||||
@@ -127,6 +128,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMarkdown } from '~/composables/useMarkdown'
|
||||
const { render: renderMd } = useMarkdown()
|
||||
|
||||
interface FicheReco {
|
||||
id: number | string
|
||||
nom: string
|
||||
@@ -140,6 +144,44 @@ interface ChatMessage {
|
||||
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<{
|
||||
'highlightOrgs': [ids: (number | string)[]]
|
||||
'applyHashtag': [tag: string]
|
||||
@@ -203,7 +245,7 @@ async function sendMessage() {
|
||||
const res = await $fetch<{
|
||||
reponse_texte: string
|
||||
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
|
||||
}>('/api/chatbot', {
|
||||
}>(props.endpoint, {
|
||||
method: 'POST',
|
||||
body: { question },
|
||||
})
|
||||
@@ -223,7 +265,7 @@ async function sendMessage() {
|
||||
} catch (e: any) {
|
||||
const status = e?.statusCode ?? e?.status
|
||||
if (status === 429) {
|
||||
errorMsg.value = 'Limite de 10 questions par jour atteinte. Reviens demain.'
|
||||
errorMsg.value = props.messageLimite
|
||||
} else if (status === 503) {
|
||||
errorMsg.value = 'Le budget IA mensuel est épuisé. Soutiens NAV sur Liberapay pour continuer.'
|
||||
} else {
|
||||
|
||||
@@ -58,6 +58,16 @@
|
||||
<!-- Chatbot sous la carte (ordinateur) -->
|
||||
<div v-if="vf.etat.sousVue !== 'outremer'" class="hidden lg:block shrink-0">
|
||||
<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="() => {}"
|
||||
@applyHashtag="(tag) => vf.ajouterFiltre('tags', sansDiese(tag))"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user