- CartePensees: noeuds ecole visibles (cercles proportionnels count auteurs, cliquables, emit select-ecole) - CartePensees: collision D3 ajustee pour repulsion auteurs autour des noeuds ecole - FicheEcole: nouveau composant modal (liste auteurs ingeres/non-ingeres, interroger RAG) - media: header lien Bonpote V2 cliquable + bouton i info RAG - media: popup FRACAS (description RAG, 662 dimensions, 3 couches, localStorage 1ere visite) - media: FicheEcole branchee (select-ecole, select-auteur-from-ecole, interroger-ecole) - ChatbotPensees: suppression mention corpusCount hardcoded (double source de verite) - chatbot, chatbot-v2, chatbot-reseaux, chatbot-taff: migration Mistral -> Nebius DeepSeek-V3.2 - nuxt.config: ajout nebiusApiKey runtime config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
126 lines
4.7 KiB
TypeScript
126 lines
4.7 KiB
TypeScript
/**
|
|
* 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: [] }
|
|
}
|
|
})
|