From f9a087572785c43e86357a1fbf77d896c7718b50 Mon Sep 17 00:00:00 2001 From: Jules Neny Date: Thu, 7 May 2026 01:43:02 +0200 Subject: [PATCH] =?UTF-8?q?fix(chatbot):=20R=C3=A9seaux=20AEP=20=E2=86=92?= =?UTF-8?q?=20/api/chatbot-reseaux=20+=20prop=20endpoint=20ChatbotSheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server/api/chatbot-reseaux.post.ts : keyword search sur reseaux-bifurcation.json (120 structures, même pattern que chatbot-taff) - ChatbotSheet.vue : prop endpoint? (défaut /api/chatbot) + renderMd déjà actif - agences.vue : endpoint='/api/chatbot-reseaux' Markdown s'active au prochain restart du bat (cache .nuxt à nettoyer). Co-Authored-By: Claude Sonnet 4.6 --- components/ChatbotSheet.vue | 3 +- pages/agences.vue | 1 + server/api/chatbot-reseaux.post.ts | 125 +++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 server/api/chatbot-reseaux.post.ts diff --git a/components/ChatbotSheet.vue b/components/ChatbotSheet.vue index 85daaa9..21ef69f 100644 --- a/components/ChatbotSheet.vue +++ b/components/ChatbotSheet.vue @@ -180,6 +180,7 @@ interface ChatMessage { const props = defineProps<{ modelValue: boolean + endpoint?: string // défaut: /api/chatbot (Carte 1 NocoDB) }>() const emit = defineEmits<{ @@ -227,7 +228,7 @@ async function sendMessage() { const res = await $fetch<{ reponse_texte: string fiches_recommandees: { id: number | string; nom: string; explication: string }[] - }>('/api/chatbot', { + }>(props.endpoint ?? '/api/chatbot', { method: 'POST', body: { question }, }) diff --git a/pages/agences.vue b/pages/agences.vue index 71107c3..a5ef5b0 100644 --- a/pages/agences.vue +++ b/pages/agences.vue @@ -376,6 +376,7 @@ diff --git a/server/api/chatbot-reseaux.post.ts b/server/api/chatbot-reseaux.post.ts new file mode 100644 index 0000000..19b1a4c --- /dev/null +++ b/server/api/chatbot-reseaux.post.ts @@ -0,0 +1,125 @@ +/** + * POST /api/chatbot-reseaux + * Chatbot Réseaux AEP — Carte 2 "Réseaux de bifurcation" + * Keyword search sur reseaux-bifurcation.json + Mistral Small. + */ +// @ts-ignore — JSON import résolu par Rollup +import reseauxData from '../../public/data/reseaux-bifurcation.json' +import { checkRateLimitJson } from '~/server/utils/rateLimitJson' + +interface Structure { + id: string + nom: string + url?: string + pays?: string + ville?: string + famille_principale?: string + hashtags?: string[] + description_courte?: string + description_longue?: string +} + +const SYSTEM_PROMPT = `Tu es un conseiller expert au service des architectes engagés dans la transition écologique. Tu connais le réseau AEP (Architecture d'Écologie Politique) — une cartographie des collectifs, agences, initiatives et réseaux qui portent une vision alternative de l'architecture en France et en Europe. + +Ton rôle : orienter l'architecte vers les structures les plus pertinentes pour sa situation, à partir des données ci-dessous. + +RÈGLES : +1. Ne cite QUE des structures présentes dans le contexte ci-dessous. +2. Sois direct et engagé — c'est l'esprit AEP. +3. Réponse max 250 mots, en français. +4. Retourne UNIQUEMENT un JSON valide, sans texte avant ou après. + +FORMAT : +{ + "reponse_texte": "Ta réponse en prose, max 250 mots", + "fiches_recommandees": [ + { "id": "slug-id", "nom": "Nom structure", "explication": "Pourquoi en 1 phrase" } + ] +} + +STRUCTURES DISPONIBLES : +{{STRUCTURES_JSON}}` + +function scoreStructure(s: Structure, keywords: string[]): number { + if (keywords.length === 0) return 1 + const haystack = [s.nom, s.famille_principale, s.description_courte, s.description_longue, (s.hashtags ?? []).join(' '), s.ville, s.pays] + .filter(Boolean).join(' ').toLowerCase() + return keywords.reduce((score, kw) => score + (haystack.includes(kw) ? 1 : 0), 0) +} + +function extractKeywords(q: string): string[] { + return q.toLowerCase().replace(/[^\w\sàâäéèêëîïôùûüç-]/g, ' ').split(/\s+/).filter(w => w.length >= 3).slice(0, 10) +} + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + + const ip = getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() || event.node.req.socket?.remoteAddress || '0.0.0.0' + const allowed = checkRateLimitJson(ip, 'chatbot-reseaux', 20) + if (!allowed) throw createError({ statusCode: 429, message: 'Limite de 20 questions par jour atteinte.' }) + + const body = await readBody(event) + const question: string = (body?.question ?? '').trim() + if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' }) + + const structures: Structure[] = ((reseauxData as any).structures ?? []) + const keywords = extractKeywords(question) + + const top = structures + .map(s => ({ s, score: scoreStructure(s, keywords) })) + .sort((a, b) => b.score - a.score) + .slice(0, 20) + .map(x => x.s) + + const context = top.map(s => ({ + id: s.id, + nom: s.nom, + famille: s.famille_principale ?? '', + lieu: [s.ville, s.pays].filter(Boolean).join(', '), + tags: (s.hashtags ?? []).slice(0, 5).join(', '), + description: (s.description_courte ?? s.description_longue ?? '').slice(0, 200), + })) + + const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0)) + + const mistralApiKey = config.mistralApiKey as string + if (!mistralApiKey) throw createError({ statusCode: 500, message: 'Clé API Mistral manquante.' }) + + let mistralRaw: string + try { + const res = 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: 700, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: question }, + ], + }), + } + ) + mistralRaw = res.choices?.[0]?.message?.content ?? '{}' + } catch { + throw createError({ statusCode: 502, message: 'Erreur IA — réessaie dans quelques instants.' }) + } + + try { + const parsed = JSON.parse(mistralRaw) + return { + reponse_texte: parsed.reponse_texte ?? "Je n'ai pas pu analyser ta demande.", + fiches_recommandees: (parsed.fiches_recommandees ?? []).map((r: any) => ({ + id: r.id, + nom: r.nom ?? structures.find(s => s.id === r.id)?.nom ?? r.id, + explication: r.explication ?? '', + })), + } + } catch { + return { reponse_texte: "Je n'ai pas pu analyser ta demande.", fiches_recommandees: [] } + } +})