Contrat unique `profondeur` ('rapide' | 'approfondi', défaut rapide) dans
utils/chatProfondeur.ts (libellés partagés). Les 3 routes Bifrost lisent
body.profondeur ?? body.mode, choisissent le tier (20b / 120b + replis), un
prompt système « réflexion » (Entraide, Réseaux, Taff) au même format JSON, et un
budget max_tokens adapté. Pensées lit body.profondeur (jamais body.mode, réservé à
LightRAG) : posture réflexion ajoutée au user_prompt + max_total_tokens 40000.
Modèle servi journalisé, et champ debug exposé uniquement en dev.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vcVGz8i4WpZm2dixKUiSD
157 lines
6.8 KiB
TypeScript
157 lines
6.8 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, profondeurDuBody, bifrostMaxTokens, bifrostDebug, bifrostContent, REGLE_GUILLEMETS, 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}}`
|
|
|
|
// Prompt « réflexion » (mode approfondi, v1 — prompts définitifs par onglet : B7/B9). Même format JSON.
|
|
const SYSTEM_PROMPT_REFLEXION = `Tu es un conseiller archi, 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.
|
|
|
|
POSTURE : avant de recommander des structures, tu questionnes le projet de l'usager. Tu reformules ce que tu comprends de son projet, tu nommes ce qui reste flou ou en tension (échelle, maîtrise d'ouvrage, filière de matériaux, rapport au territoire, posture professionnelle, moyens), tu poses 2 ou 3 questions précises qui l'aident à se situer, PUIS tu orientes vers les structures du contexte qui correspondent à ce qu'il a dit, en expliquant ce que chacune lui apporterait ou lui ferait choisir.
|
|
|
|
RÈGLES :
|
|
1. Ne cite QUE des structures présentes dans le contexte ci-dessous.
|
|
2. ${REGLE_GUILLEMETS}
|
|
3. Sois direct et engagé — c'est l'esprit AEP — mais n'affirme pas ce que tu ne sais pas de son projet : demande.
|
|
4. Réponse de 300 mots maximum, en français, en prose, tutoiement.
|
|
5. Retourne UNIQUEMENT un JSON valide, sans texte avant ou après.
|
|
|
|
FORMAT :
|
|
{
|
|
"reponse_texte": "Ta réponse en prose, max 300 mots : reformulation, tension, questions, puis structures",
|
|
"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 profondeur = profondeurDuBody(body)
|
|
const tier = pickBifrostTier(profondeur)
|
|
|
|
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 = (profondeur === 'approfondi' ? SYSTEM_PROMPT_REFLEXION : 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
|
|
let debugExtra: Record<string, unknown> = {}
|
|
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: bifrostMaxTokens(700, profondeur),
|
|
response_format: { type: 'json_object' },
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: question },
|
|
],
|
|
}),
|
|
}
|
|
)
|
|
mistralRaw = bifrostContent(res, 'chatbot-reseaux')
|
|
debugExtra = bifrostDebug(res, 'chatbot-reseaux', profondeur)
|
|
} 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 ?? '',
|
|
})),
|
|
...debugExtra,
|
|
}
|
|
} catch {
|
|
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", fiches_recommandees: [] }
|
|
}
|
|
})
|