Files
nav-carte/server/api/chatbot.post.ts
T
Jules NenyandClaude Opus 5.5 80f69565aa feat(chatbot): mode profondeur côté serveur (rapide / approfondi) sur les 4 routes
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
2026-10-09 03:45:27 +02:00

374 lines
14 KiB
TypeScript

/**
* POST /api/chatbot
*
* Chatbot recherche sémantique — LLM via Bifrost (tiers et replis : server/utils/bifrost.ts)
* 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 Bifrost (tier RAPIDE par défaut) 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, profondeurDuBody, bifrostMaxTokens, bifrostDebug, bifrostContent, REGLE_GUILLEMETS, 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}}`
// ── Prompt « réflexion » (mode approfondi, v1 — prompts définitifs par onglet : B7/B9) ──────
// Même format de sortie JSON que SYSTEM_PROMPT (le front en dépend) ; seule la posture change.
const SYSTEM_PROMPT_REFLEXION = `Tu es un conseiller-ressources au service de la transition écologique des pratiques architecturales, sur AEP (Architecture d'Écologie Politique) — Écosystème Entraide, une base 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.
POSTURE : l'usager pose rarement le bon problème du premier coup. Tu l'aides d'abord à formuler son besoin, puis tu l'orientes vers les ressources et fiches du contexte. Tu es dialectique : tu reformules ce que tu comprends, tu nommes une tension ou un choix que la question laisse ouvert, tu poses 2 ou 3 questions qui l'aident à se situer (où en est son projet, avec qui, à quelle échelle, quelle contrainte pèse le plus), PUIS tu proposes des pistes.
RÈGLES ABSOLUES :
1. Tu ne peux recommander QUE des organisations présentes dans le contexte ci-dessous. Ne jamais inventer d'organisation absente.
2. Cite chaque organisation recommandée par son nom exact et son identifiant id. Si aucune n'est pertinente, dis-le honnêtement.
3. ${REGLE_GUILLEMETS}
4. Réponse de 300 mots maximum, en français, en prose (pas de liste à puces), ton de pair, tutoiement.
5. Si la question est hors du champ architecture / écologie / territoire / transition, recadre poliment vers le périmètre de la carte.
6. Retourne UNIQUEMENT un objet JSON valide, sans texte avant ou après.
FORMAT DE SORTIE :
{
"reponse_texte": "Ta réponse en prose (max 300 mots) : reformulation, tension, questions, puis pistes",
"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 profondeur = profondeurDuBody(body)
const tier = pickBifrostTier(profondeur)
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 = (profondeur === 'approfondi' ? SYSTEM_PROMPT_REFLEXION : 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
let debugExtra: Record<string, unknown> = {}
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: bifrostMaxTokens(600, profondeur),
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: question },
],
}),
})
mistralRaw = bifrostContent(bifrostRes, 'chatbot')
debugExtra = bifrostDebug(bifrostRes, 'chatbot', profondeur)
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, ...debugExtra }
})