Files
nav-carte/server/api/chatbot-reseaux.post.ts
T
Jules NenyandClaude Opus 5.5 8f94a1acda fix(bifrost): tiers sur gpt-oss (Groq a retiré les llama) + marge de raisonnement
RAPIDE groq/llama-3.1-8b-instant → groq/openai/gpt-oss-20b ; APPROFONDI
groq/llama-3.3-70b-versatile → groq/openai/gpt-oss-120b ; replis inchangés.
Les chatbots vivaient sur le repli Gemini depuis le retrait.

gpt-oss raisonne : ses tokens de raisonnement sont décomptés de max_tokens.
REASONING_MARGIN_TOKENS (800) ajouté au budget des trois routes, et
bifrostContent() signale au journal une réponse tronquée (finish_reason
length). Le champ message.reasoning est ignoré. Défaut WORKER_MODEL aligné
sur la prod. En-têtes « Mistral Small » corrigés, PIPE-IA-DOC §11.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzGo8bxhHp6M9RxuAFbyLU
2026-09-28 16:48:24 +02:00

130 lines
4.9 KiB
TypeScript

/**
* POST /api/chatbot-reseaux
* Chatbot Réseaux AEP — Carte 2 "Réseaux de bifurcation"
* Keyword search sur reseaux-bifurcation.json + LLM via Bifrost (server/utils/bifrost.ts).
*/
// @ts-ignore — JSON import résolu par Rollup
import reseauxData from '../../public/data/reseaux-bifurcation.json'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
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 tier = pickBifrostTier(body?.mode)
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 bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string
if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' })
let mistralRaw: string
try {
const res = await $fetch<BifrostChatResponse>(
`${bifrostUrl}/v1/chat/completions`,
{
method: 'POST',
headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: tier.model,
fallbacks: tier.fallbacks,
temperature: 0.3,
max_tokens: 700 + REASONING_MARGIN_TOKENS,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: question },
],
}),
}
)
mistralRaw = bifrostContent(res, 'chatbot-reseaux')
} 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: [] }
}
})