feat(aep-v1.1): PA5 chatbot pratiques regeneratives
- Nouveau endpoint server/api/chatbot-pratiques.post.ts qui interroge le JSON statique pratiques-regeneratives.json (52 fiches V1) avec Mistral Small. Prompt systeme adapte aux 8 criteres rege et types d'entites. Rate limit 10/jour, circuit breaker partage. - ChatbotPlaceholder + ChatbotSheet rendus generiques via props (endpoint, title, placeholder, ficheBasePath) + slot onboarding. La carte ecosysteme AEP continue d'utiliser /api/chatbot, la carte pratiques rege utilise /api/chatbot-pratiques. - pratiques-regeneratives.vue : ChatbotPlaceholder integre sous la carte Europe desktop (replie par defaut), FAB mobile + ChatbotSheet bottom sheet, handler highlightOrgs pour surligner la fiche reco.
This commit is contained in:
304
server/api/chatbot-pratiques.post.ts
Normal file
304
server/api/chatbot-pratiques.post.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* POST /api/chatbot-pratiques
|
||||
*
|
||||
* Chatbot semantique sur la base des pratiques regeneratives (JSON statique).
|
||||
* Adapte du endpoint /api/chatbot (ecosysteme AEP NocoDB).
|
||||
*
|
||||
* Flow :
|
||||
* 1. Rate limit : 10 req/IP/jour (JSON fichier, SHA-256)
|
||||
* 2. Circuit breaker : budget 20 EUR/mois partage avec /api/chatbot
|
||||
* 3. Lit public/data/pratiques-regeneratives.json (52 fiches V1)
|
||||
* 4. Score keyword puis top 20 fiches en contexte
|
||||
* 5. Appel Mistral Small avec prompt systeme adapte aux pratiques
|
||||
* 6. Parse JSON -> { reponse_texte, fiches_recommandees }
|
||||
* 7. Log stats_usage
|
||||
*
|
||||
* Reponse 200 : { reponse_texte, fiches_recommandees: [{ id, nom, explication }] }
|
||||
* Reponse 429 : rate limit depasse
|
||||
* Reponse 503 : budget IA epuise
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
||||
import { CRITERES, TYPES_ENTITE_LABELS, PAYS_LABELS } from '~/types/pratique'
|
||||
import type { Pratique } from '~/types/pratique'
|
||||
|
||||
interface FicheReco {
|
||||
id: number
|
||||
nom: string
|
||||
explication: string
|
||||
}
|
||||
|
||||
interface MistralResponse {
|
||||
reponse_texte: string
|
||||
fiches_recommandees: FicheReco[]
|
||||
}
|
||||
|
||||
// Prompt systeme dedie aux pratiques regeneratives.
|
||||
// Difference avec /api/chatbot : on parle de pratiques, criteres rege (8 axes),
|
||||
// types d'entites (agence, cooperative, collectif...), perimetre Europe + DOM-TOM.
|
||||
const SYSTEM_PROMPT = `Tu es un assistant engage au service de la transition ecologique des pratiques architecturales. Tu accedes a la base AEP - Pratiques regeneratives, qui referencee les acteurs concrets de l'architecture regenerative en Europe et dans les DOM-TOM (agences, cooperatives, collectifs, reseaux, associations, plateformes, recherche).
|
||||
|
||||
CRITERES DE REGENERATION (8 axes utilises pour decrire chaque pratique) :
|
||||
1. Materiaux (biosources, geosources, reemploi)
|
||||
2. Filieres (locales, courtes, paysannes)
|
||||
3. Posture (ethique, engagement politique, refus)
|
||||
4. Process (collaboratif, participatif, lent)
|
||||
5. Politique (lobbying, plaidoyer, contre-expertise)
|
||||
6. Modele economique (cooperatif, low-tech, soutenable)
|
||||
7. Vivant (biodiversite, sols, eau)
|
||||
8. Transmission (formation, partage, pedagogie)
|
||||
|
||||
REGLES ABSOLUES :
|
||||
1. Tu ne peux recommander QUE des pratiques presentes dans le contexte ci-dessous.
|
||||
2. Ne jamais inventer une pratique absente du contexte.
|
||||
3. Cite chaque pratique recommandee par son nom exact et son identifiant id.
|
||||
4. Si le contexte ne contient aucune pratique pertinente, dis-le honnetement.
|
||||
5. Reponses concises (200 mots max). Si l'usager demande explicitement plus de detail, tu peux developper.
|
||||
6. Retourne UNIQUEMENT un objet JSON valide, sans texte avant ou apres.
|
||||
7. Si la question est hors du champ architecture / ecologie / regeneration / territoire, recadre poliment.
|
||||
|
||||
FORMAT DE SORTIE :
|
||||
{
|
||||
"reponse_texte": "Ta reponse en prose (max 200 mots)",
|
||||
"fiches_recommandees": [
|
||||
{ "id": 12, "explication": "Pourquoi cette pratique repond a la question (1-2 phrases max)" }
|
||||
]
|
||||
}
|
||||
|
||||
CONTEXTE - Pratiques regeneratives disponibles :
|
||||
{{FICHES_JSON}}`
|
||||
|
||||
function scorePratique(p: Pratique, keywords: string[]): number {
|
||||
if (keywords.length === 0) return 1
|
||||
const critereLabels = (p.criteres ?? [])
|
||||
.map((cId) => CRITERES.find((c) => c.id === cId)?.label ?? '')
|
||||
.join(' ')
|
||||
const haystack = [
|
||||
p.nom,
|
||||
p.description,
|
||||
p.ville,
|
||||
p.type ? (TYPES_ENTITE_LABELS[p.type] ?? p.type) : '',
|
||||
p.pays ? (PAYS_LABELS[p.pays] ?? p.pays) : '',
|
||||
critereLabels,
|
||||
(p.tags ?? []).join(' '),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return keywords.reduce((score, kw) => 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)
|
||||
}
|
||||
|
||||
function loadPratiques(): Pratique[] {
|
||||
try {
|
||||
const jsonPath = resolve(process.cwd(), 'public/data/pratiques-regeneratives.json')
|
||||
const raw = readFileSync(jsonPath, 'utf-8')
|
||||
return JSON.parse(raw) as Pratique[]
|
||||
} catch (e) {
|
||||
console.error('[chatbot-pratiques] Erreur lecture JSON:', (e as Error).message)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function logUsage(params: {
|
||||
nocodbUrl: string
|
||||
nocodbToken: string
|
||||
statsTableId: string
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
coutEur: number
|
||||
}) {
|
||||
const { nocodbUrl, nocodbToken, statsTableId, 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: 'mistral-small-latest',
|
||||
endpoint: 'chatbot-pratiques',
|
||||
tokens_in: tokensIn,
|
||||
tokens_out: tokensOut,
|
||||
cout_eur: coutEur,
|
||||
timestamp: new Date().toISOString(),
|
||||
orga_id: null,
|
||||
}),
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[chatbot-pratiques] Log stats_usage echoue (non bloquant):', (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
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 (compteur dedie chatbot-pratiques)
|
||||
const allowed = checkRateLimitJson(ip, 'chatbot-pratiques', 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()
|
||||
|
||||
if (!question || question.length < 3) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Question trop courte.',
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Circuit breaker budget partage
|
||||
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 epuise - reouverture le 1er du mois prochain.',
|
||||
})
|
||||
}
|
||||
|
||||
// 5. Charger pratiques + scoring
|
||||
const allPratiques = loadPratiques()
|
||||
if (allPratiques.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: 'Donnees pratiques indisponibles.',
|
||||
})
|
||||
}
|
||||
|
||||
const keywords = extractKeywords(question)
|
||||
|
||||
const scored = allPratiques
|
||||
.map((p) => ({ pratique: p, score: scorePratique(p, keywords) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 20)
|
||||
.map((x) => x.pratique)
|
||||
|
||||
const fichesContext = scored.map((p) => ({
|
||||
id: p.id,
|
||||
nom: p.nom,
|
||||
type: p.type ? (TYPES_ENTITE_LABELS[p.type] ?? p.type) : '',
|
||||
pays: p.pays ? (PAYS_LABELS[p.pays] ?? p.pays) : '',
|
||||
ville: p.ville ?? '',
|
||||
criteres: (p.criteres ?? [])
|
||||
.map((cId) => CRITERES.find((c) => c.id === cId)?.label ?? '')
|
||||
.filter(Boolean),
|
||||
description: (p.description ?? '').slice(0, 250),
|
||||
tags: (p.tags ?? []).slice(0, 5),
|
||||
}))
|
||||
|
||||
const systemPrompt = SYSTEM_PROMPT.replace(
|
||||
'{{FICHES_JSON}}',
|
||||
JSON.stringify(fichesContext, null, 0),
|
||||
)
|
||||
|
||||
// 6. Appel Mistral Small
|
||||
const mistralApiKey = config.mistralApiKey as string
|
||||
|
||||
if (!mistralApiKey) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Cle API Mistral manquante.',
|
||||
})
|
||||
}
|
||||
|
||||
let mistralRaw: string
|
||||
let tokensIn = 0
|
||||
let tokensOut = 0
|
||||
|
||||
try {
|
||||
const mistralRes = await $fetch<{
|
||||
choices: { message: { content: string } }[]
|
||||
usage?: { prompt_tokens: number; completion_tokens: number }
|
||||
}>('https://api.mistral.ai/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${mistralApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'mistral-small-latest',
|
||||
temperature: 0.3,
|
||||
max_tokens: 600,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: question },
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
|
||||
tokensIn = mistralRes.usage?.prompt_tokens ?? 0
|
||||
tokensOut = mistralRes.usage?.completion_tokens ?? 0
|
||||
} catch (e: any) {
|
||||
console.error('[chatbot-pratiques] Erreur Mistral Small:', e?.message ?? e)
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: 'Erreur appel IA - reessaie 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 p = scored.find((x) => x.id === f.id)
|
||||
return {
|
||||
id: f.id,
|
||||
nom: p?.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)
|
||||
const coutEur = calcCoutMistralSmall(tokensIn, tokensOut)
|
||||
logUsage({
|
||||
nocodbUrl: config.nocodbUrl as string,
|
||||
nocodbToken: config.nocodbToken as string,
|
||||
statsTableId,
|
||||
tokensIn,
|
||||
tokensOut,
|
||||
coutEur,
|
||||
})
|
||||
|
||||
return parsed
|
||||
})
|
||||
Reference in New Issue
Block a user