/** * 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 })