/** * 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 nebiusApiKey = config.nebiusApiKey as string if (!nebiusApiKey) throw createError({ statusCode: 500, message: 'Clé API Nebius manquante.' }) let mistralRaw: string try { const res = await $fetch<{ choices: { message: { content: string } }[] }>( 'https://api.tokenfactory.nebius.com/v1/chat/completions', { method: 'POST', headers: { Authorization: `Bearer ${nebiusApiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'deepseek-ai/DeepSeek-V3.2', 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: [] } } })