From 914759a815a663c36fea8d357d8150abc374ded5 Mon Sep 17 00:00:00 2001 From: Jules Neny Date: Thu, 30 Apr 2026 02:29:16 +0200 Subject: [PATCH] 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. --- components/ChatbotPlaceholder.vue | 36 +++- components/ChatbotSheet.vue | 35 +-- pages/pratiques-regeneratives.vue | 75 ++++++- server/api/chatbot-pratiques.post.ts | 304 +++++++++++++++++++++++++++ 4 files changed, 421 insertions(+), 29 deletions(-) create mode 100644 server/api/chatbot-pratiques.post.ts diff --git a/components/ChatbotPlaceholder.vue b/components/ChatbotPlaceholder.vue index 00cdf6e..7b41141 100644 --- a/components/ChatbotPlaceholder.vue +++ b/components/ChatbotPlaceholder.vue @@ -27,7 +27,7 @@ - {{ isExpanded ? 'Chatbot AEP' : 'Pose une question sur le réseau…' }} + {{ isExpanded ? title : placeholder }} @@ -52,18 +52,20 @@
-

Ce chatbot fonctionne sur un serveur européen souverain + +

Ce chatbot fonctionne sur un serveur européen souverain (Mistral FR, zéro rétention), conçu sobre en énergie.

-

Pour m'aider à te répondre efficacement, +

Pour m'aider à te répondre efficacement, formule ta requête ainsi :

-
    -
  • • Besoin : [ce que tu cherches]
  • -
  • • Thématique : [juridique / technique / économique / ...]
  • -
  • • Lieu : [région ou ville]
  • -
-

Exemple : "Je suis salarié d'agence, litige avec mon +

    +
  • • Besoin : [ce que tu cherches]
  • +
  • • Thématique : [juridique / technique / économique / ...]
  • +
  • • Lieu : [région ou ville]
  • +
+

Exemple : "Je suis salarié d'agence, litige avec mon employeur, besoin conseil juridique droit du travail, Île-de-France."

+
@@ -76,7 +78,7 @@ employeur, besoin conseil juridique droit du travail, {{ fiche.nom }} @@ -134,6 +136,18 @@ interface ChatMessage { fiches?: FicheReco[] } +const props = withDefaults(defineProps<{ + endpoint?: string + title?: string + placeholder?: string + ficheBasePath?: string +}>(), { + endpoint: '/api/chatbot', + title: 'Chatbot AEP', + placeholder: 'Pose une question sur le réseau…', + ficheBasePath: '/fiche', +}) + const emit = defineEmits<{ 'highlightOrgs': [ids: (number | string)[]] }>() @@ -165,7 +179,7 @@ async function sendMessage() { const res = await $fetch<{ reponse_texte: string fiches_recommandees: { id: number | string; nom: string; explication: string }[] - }>('/api/chatbot', { + }>(props.endpoint, { method: 'POST', body: { question }, }) diff --git a/components/ChatbotSheet.vue b/components/ChatbotSheet.vue index b92dc3f..e63e39f 100644 --- a/components/ChatbotSheet.vue +++ b/components/ChatbotSheet.vue @@ -61,7 +61,7 @@
- Chatbot + {{ title }} @@ -69,18 +69,20 @@
-

Ce chatbot fonctionne sur un serveur européen souverain + +

Ce chatbot fonctionne sur un serveur européen souverain (Mistral FR, zéro rétention), conçu sobre en énergie.

-

Pour m'aider à te répondre efficacement, +

Pour m'aider à te répondre efficacement, formule ta requête ainsi :

-
    -
  • • Besoin : [ce que tu cherches]
  • -
  • • Thématique : [juridique / technique / économique / ...]
  • -
  • • Lieu : [région ou ville]
  • -
-

Exemple : "Je suis salarié d'agence, litige avec mon +

    +
  • • Besoin : [ce que tu cherches]
  • +
  • • Thématique : [juridique / technique / économique / ...]
  • +
  • • Lieu : [région ou ville]
  • +
+

Exemple : "Je suis salarié d'agence, litige avec mon employeur, besoin conseil juridique droit du travail, Île-de-France."

+
@@ -100,7 +102,7 @@ employeur, besoin conseil juridique droit du travail,
{{ fiche.nom }} @@ -176,9 +178,16 @@ interface ChatMessage { fiches?: FicheReco[] } -const props = defineProps<{ +const props = withDefaults(defineProps<{ modelValue: boolean -}>() + endpoint?: string + title?: string + ficheBasePath?: string +}>(), { + endpoint: '/api/chatbot', + title: 'Chatbot', + ficheBasePath: '/fiche', +}) const emit = defineEmits<{ 'update:modelValue': [value: boolean] @@ -225,7 +234,7 @@ async function sendMessage() { const res = await $fetch<{ reponse_texte: string fiches_recommandees: { id: number | string; nom: string; explication: string }[] - }>('/api/chatbot', { + }>(props.endpoint, { method: 'POST', body: { question }, }) diff --git a/pages/pratiques-regeneratives.vue b/pages/pratiques-regeneratives.vue index 9bc147c..7f0d667 100644 --- a/pages/pratiques-regeneratives.vue +++ b/pages/pratiques-regeneratives.vue @@ -75,6 +75,27 @@
+ + + @@ -278,22 +299,21 @@ - + + + + + + @@ -332,6 +376,27 @@ const pays = ref( const selectedId = ref(null) const mobileMapView = ref<'europe' | 'outremer'>('europe') const desktopMapView = ref<'europe' | 'outremer'>('europe') +const chatbotOpen = ref(false) + +// Surlignage temporaire (5 sec) suite a une reponse chatbot +let highlightTimer: ReturnType | null = null +const prevSelectedId = ref(null) + +function onHighlightOrgs(ids: (number | string)[]) { + if (!ids.length) return + const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0] + if (isNaN(firstId)) return + + prevSelectedId.value = selectedId.value + selectedId.value = firstId + + if (highlightTimer) clearTimeout(highlightTimer) + highlightTimer = setTimeout(() => { + selectedId.value = prevSelectedId.value + prevSelectedId.value = null + highlightTimer = null + }, 5000) +} // Refs vers les instances EuropeMap const europeMapRef = ref(null) diff --git a/server/api/chatbot-pratiques.post.ts b/server/api/chatbot-pratiques.post.ts new file mode 100644 index 0000000..fa02e6a --- /dev/null +++ b/server/api/chatbot-pratiques.post.ts @@ -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 +})