Les 3 routes chatbot (chatbot.post.ts, chatbot-reseaux.post.ts, chatbot-taff.post.ts)
appellent désormais ${bifrostUrl}/v1/chat/completions (header x-bf-vk) au lieu de
api.mistral.ai direct. Tier RAPIDE par défaut (groq/llama-3.1-8b-instant + fallbacks
cerebras/gemini-flash-lite/cohere), tier APPROFONDI si body.mode === 'approfondi'
(prêt pour un futur toggle UI, hors-scope ici).
- server/utils/bifrost.ts (nouveau) : mutualise les 2 tiers pour les 3 routes.
- nuxt.config.ts : ajoute bifrostUrl/bifrostVk au runtimeConfig (mistralApiKey
conservé, juste plus utilisé par ces 3 routes).
- chatbot.post.ts : garde son circuit breaker + logging stats_usage tels quels,
mais logUsage reflète maintenant le provider/modèle réel ayant répondu
(extra_fields de Bifrost) et ne calcule un coût que si ce provider est Mistral
(les autres tiers Bifrost sont free-tier — cout_eur=0 sinon, pour ne pas fausser
le circuit breaker budget).
- chatbot-reseaux.post.ts / chatbot-taff.post.ts : aucun circuit breaker/logging
avant, aucun ajouté (asymétrie pré-existante préservée telle quelle).
- chatbot-v2.post.ts (orphelin) et chatbot-pensees.post.ts (proxy LightRAG,
config runtime séparée) non touchés.
Testé en dev local contre Bifrost (IP Tailscale) : 1 appel réel par route,
réponses conformes, provider réel confirmé dans les logs de test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
/**
|
|
* POST /api/chatbot
|
|
*
|
|
* Chatbot recherche sémantique — Mistral Small
|
|
* Spec : F §7 (endpoint), F §8 (rate limit), E-spec §6 (détails chatbot)
|
|
*
|
|
* Flow :
|
|
* 1. Rate limit : 10 req/IP/jour (JSON fichier, SHA-256)
|
|
* 2. Circuit breaker : budget 20€/mois
|
|
* 3. Fetch top-N fiches (keyword match sur nom+description+fonctions)
|
|
* 4. Appel Mistral Small avec contexte JSON compact
|
|
* 5. Parse JSON → { reponse_texte, fiches_recommandees }
|
|
* 6. Log stats_usage
|
|
*
|
|
* Réponse 200 : { reponse_texte, fiches_recommandees: [{ id, nom, explication }] }
|
|
* Réponse 429 : rate limit dépassé
|
|
* Réponse 503 : budget IA épuisé
|
|
*/
|
|
|
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
|
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
|
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
|
|
|
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
|
|
interface OrgRow {
|
|
Id: number
|
|
nom: string
|
|
description_enrichie?: string | null
|
|
description_user?: string | null
|
|
tags_fonction?: string | null
|
|
echelle?: string | null
|
|
localisation_ville?: string | null
|
|
}
|
|
|
|
interface FicheReco {
|
|
id: number
|
|
nom: string
|
|
explication: string
|
|
}
|
|
|
|
interface MistralResponse {
|
|
reponse_texte: string
|
|
fiches_recommandees: FicheReco[]
|
|
}
|
|
|
|
// ── System prompt Mistral Small ────────────────────────────────────────────────
|
|
// Construit depuis E-spec-frontend.md §Détails chatbot + §Prompt système
|
|
// (F-spec §3 concerne Mistral Nemo enrichissement — ne pas confondre)
|
|
|
|
const SYSTEM_PROMPT = `Tu es un assistant engagé au service de la transition écologique des pratiques architecturales. Tu accèdes à AEP (Architecture d'Écologie Politique) — Écosystème Entraide, une base de données collaborative qui référence les acteurs de l'écologie politique appliquée à l'architecture et au territoire (organisations, outils, ressources) pour les architectes en France.
|
|
|
|
RÈGLES ABSOLUES :
|
|
1. Tu ne peux recommander QUE des organisations présentes dans le contexte ci-dessous.
|
|
2. Ne jamais inventer d'organisation absente du contexte.
|
|
3. Cite chaque organisation recommandée par son nom exact et son identifiant id.
|
|
4. Si le contexte ne contient aucune organisation pertinente, dis-le honnêtement.
|
|
5. Réponses concises par défaut (200 mots max). Si l'usager demande explicitement plus de détail, tu peux développer.
|
|
6. Retourne UNIQUEMENT un objet JSON valide, sans texte avant ou après.
|
|
7. Si la question est hors du champ architecture / écologie / territoire / transition, recadre poliment vers le périmètre de la carte.
|
|
|
|
FORMAT DE SORTIE :
|
|
{
|
|
"reponse_texte": "Ta réponse en prose (max 200 mots), orientée vers les besoins de l'architecte",
|
|
"fiches_recommandees": [
|
|
{ "id": 123, "explication": "Pourquoi cette fiche répond à la question (1-2 phrases max)" }
|
|
]
|
|
}
|
|
|
|
CONTEXTE — Organisations disponibles dans la base NAV :
|
|
{{FICHES_JSON}}`
|
|
|
|
// ── Recherche par mots-clés ────────────────────────────────────────────────────
|
|
|
|
function scoreOrg(org: OrgRow, keywords: string[]): number {
|
|
if (keywords.length === 0) return 1
|
|
const haystack = [
|
|
org.nom,
|
|
org.description_enrichie,
|
|
org.description_user,
|
|
org.tags_fonction,
|
|
org.localisation_ville,
|
|
org.echelle,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.toLowerCase()
|
|
|
|
return keywords.reduce((score, kw) => {
|
|
return score + (haystack.includes(kw) ? 1 : 0)
|
|
}, 0)
|
|
}
|
|
|
|
function extractKeywords(question: string): string[] {
|
|
return question
|
|
.toLowerCase()
|
|
.replace(/[^\w\sàâäéèêëîïôùûüç-]/g, ' ')
|
|
.split(/\s+/)
|
|
.filter((w) => w.length >= 3)
|
|
.slice(0, 10)
|
|
}
|
|
|
|
// ── Fetch fiches depuis NocoDB ─────────────────────────────────────────────────
|
|
|
|
async function fetchApprovedOrgs(config: {
|
|
nocodbUrl: string
|
|
nocodbToken: string
|
|
orgTableId: string
|
|
}): Promise<OrgRow[]> {
|
|
const { nocodbUrl, nocodbToken, orgTableId } = config
|
|
const url = `${nocodbUrl}/api/v2/tables/${orgTableId}/records`
|
|
|
|
try {
|
|
const res = await $fetch<{ list: OrgRow[] }>(url, {
|
|
headers: { 'xc-token': nocodbToken },
|
|
query: {
|
|
where: '(moderation_status,eq,approved)',
|
|
limit: 200,
|
|
fields: 'Id,nom,description_enrichie,description_user,tags_fonction,echelle,localisation_ville',
|
|
},
|
|
})
|
|
return res?.list ?? []
|
|
} catch (e) {
|
|
console.error('[chatbot] Erreur fetch NocoDB:', (e as Error).message)
|
|
return []
|
|
}
|
|
}
|
|
|
|
// ── Log stats_usage ────────────────────────────────────────────────────────────
|
|
|
|
async function logUsage(params: {
|
|
nocodbUrl: string
|
|
nocodbToken: string
|
|
statsTableId: string
|
|
model: string
|
|
tokensIn: number
|
|
tokensOut: number
|
|
coutEur: number
|
|
}) {
|
|
const { nocodbUrl, nocodbToken, statsTableId, model, tokensIn, tokensOut, coutEur } = params
|
|
const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
|
try {
|
|
await $fetch(logUrl, {
|
|
method: 'POST',
|
|
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model,
|
|
endpoint: 'chatbot',
|
|
tokens_in: tokensIn,
|
|
tokens_out: tokensOut,
|
|
cout_eur: coutEur,
|
|
timestamp: new Date().toISOString(),
|
|
orga_id: null,
|
|
}),
|
|
})
|
|
} catch (e) {
|
|
console.warn('[chatbot] Log stats_usage échoué (non bloquant):', (e as Error).message)
|
|
}
|
|
}
|
|
|
|
// ── Handler principal ──────────────────────────────────────────────────────────
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const config = useRuntimeConfig()
|
|
|
|
// 1. IP (proxy-aware)
|
|
const ip =
|
|
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
|
event.node.req.socket?.remoteAddress ||
|
|
'0.0.0.0'
|
|
|
|
// 2. Rate limit : 10 req/IP/jour (JSON + SHA-256)
|
|
const allowed = checkRateLimitJson(ip, 'chatbot', 10)
|
|
if (!allowed) {
|
|
throw createError({
|
|
statusCode: 429,
|
|
statusMessage: 'Limite de 10 questions par jour atteinte.',
|
|
})
|
|
}
|
|
|
|
// 3. Lire le body
|
|
const body = await readBody(event)
|
|
const question: string = (body?.question ?? '').trim()
|
|
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
|
|
const tier = pickBifrostTier(body?.mode)
|
|
|
|
if (!question || question.length < 3) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
statusMessage: 'Question trop courte.',
|
|
})
|
|
}
|
|
|
|
// 4. Circuit breaker budget
|
|
const statsTableId = process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc'
|
|
const budget = await checkBudget({
|
|
nocodbUrl: config.nocodbUrl as string,
|
|
nocodbToken: config.nocodbToken as string,
|
|
statsTableId,
|
|
})
|
|
|
|
if (budget.blocked) {
|
|
throw createError({
|
|
statusCode: 503,
|
|
statusMessage: 'Budget IA mensuel épuisé — réouverture le 1er du mois prochain.',
|
|
})
|
|
}
|
|
|
|
// 5. Fetch fiches et scoring par mots-clés
|
|
const allOrgs = await fetchApprovedOrgs({
|
|
nocodbUrl: config.nocodbUrl as string,
|
|
nocodbToken: config.nocodbToken as string,
|
|
orgTableId: config.orgTableId as string,
|
|
})
|
|
|
|
const keywords = extractKeywords(question)
|
|
|
|
// Filtrage optionnel par taxonomie si filtres fournis
|
|
let filtered = allOrgs
|
|
if (filters.fonction) {
|
|
filtered = filtered.filter((o) =>
|
|
(o.tags_fonction ?? '').toLowerCase().includes(filters.fonction!.toLowerCase()),
|
|
)
|
|
}
|
|
if (filters.echelle) {
|
|
filtered = filtered.filter((o) =>
|
|
(o.echelle ?? '').toLowerCase() === filters.echelle!.toLowerCase(),
|
|
)
|
|
}
|
|
|
|
// Score + top 20
|
|
const scored = filtered
|
|
.map((o) => ({ org: o, score: scoreOrg(o, keywords) }))
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 20)
|
|
.map((x) => x.org)
|
|
|
|
// Contexte JSON compact pour le prompt
|
|
const fichesContext = scored.map((o) => ({
|
|
id: o.Id,
|
|
nom: o.nom,
|
|
fonctions: o.tags_fonction ?? '',
|
|
echelle: o.echelle ?? '',
|
|
description: (o.description_enrichie ?? o.description_user ?? '').slice(0, 200),
|
|
ville: o.localisation_ville ?? '',
|
|
}))
|
|
|
|
const systemPrompt = SYSTEM_PROMPT.replace(
|
|
'{{FICHES_JSON}}',
|
|
JSON.stringify(fichesContext, null, 0),
|
|
)
|
|
|
|
// 6. Appel Bifrost (gateway LLM — remplace l'appel Mistral direct)
|
|
const bifrostUrl = config.bifrostUrl as string
|
|
const bifrostVk = config.bifrostVk as string
|
|
|
|
if (!bifrostVk) {
|
|
throw createError({
|
|
statusCode: 500,
|
|
statusMessage: 'Clé Bifrost manquante.',
|
|
})
|
|
}
|
|
|
|
let mistralRaw: string
|
|
let tokensIn = 0
|
|
let tokensOut = 0
|
|
let realModel = tier.model
|
|
|
|
try {
|
|
const bifrostRes = 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: 600,
|
|
response_format: { type: 'json_object' },
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: question },
|
|
],
|
|
}),
|
|
})
|
|
|
|
mistralRaw = bifrostRes.choices?.[0]?.message?.content ?? '{}'
|
|
tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
|
|
tokensOut = bifrostRes.usage?.completion_tokens ?? 0
|
|
realModel =
|
|
bifrostRes.extra_fields?.resolved_model_used ??
|
|
(bifrostRes.extra_fields?.provider
|
|
? `${bifrostRes.extra_fields.provider}/${tier.model.split('/').slice(1).join('/')}`
|
|
: tier.model)
|
|
} catch (e: any) {
|
|
console.error('[chatbot] Erreur Bifrost:', e?.message ?? e)
|
|
throw createError({
|
|
statusCode: 502,
|
|
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
|
|
})
|
|
}
|
|
|
|
// 7. Parse JSON
|
|
let parsed: MistralResponse
|
|
try {
|
|
const raw = JSON.parse(mistralRaw)
|
|
parsed = {
|
|
reponse_texte: raw.reponse_texte ?? 'Je n\'ai pas pu analyser ta demande.',
|
|
fiches_recommandees: (raw.fiches_recommandees ?? []).map((f: any) => {
|
|
const org = scored.find((o) => o.Id === f.id)
|
|
return {
|
|
id: f.id,
|
|
nom: org?.nom ?? f.nom ?? `Fiche #${f.id}`,
|
|
explication: f.explication ?? '',
|
|
}
|
|
}),
|
|
}
|
|
} catch {
|
|
parsed = {
|
|
reponse_texte: 'Je n\'ai pas pu analyser ta demande correctement.',
|
|
fiches_recommandees: [],
|
|
}
|
|
}
|
|
|
|
// 8. Log usage (non bloquant)
|
|
// Coût réel calculable seulement si le provider ayant répondu est Mistral —
|
|
// les autres tiers Bifrost (Groq, Cerebras, Gemini free tier, Cohere) sont free-tier,
|
|
// donc cout_eur=0 pour ne pas fausser le circuit breaker avec un tarif Mistral inapplicable.
|
|
const isMistral = realModel.toLowerCase().startsWith('mistral')
|
|
const coutEur = isMistral ? calcCoutMistralSmall(tokensIn, tokensOut) : 0
|
|
logUsage({
|
|
nocodbUrl: config.nocodbUrl as string,
|
|
nocodbToken: config.nocodbToken as string,
|
|
statsTableId,
|
|
model: realModel,
|
|
tokensIn,
|
|
tokensOut,
|
|
coutEur,
|
|
})
|
|
|
|
return parsed
|
|
})
|