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:
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
|
||||
<span class="flex-1 text-sm" style="color: var(--nav-text-muted);">
|
||||
{{ isExpanded ? 'Chatbot AEP' : 'Pose une question sur le réseau…' }}
|
||||
{{ isExpanded ? title : placeholder }}
|
||||
</span>
|
||||
|
||||
<!-- Chevron -->
|
||||
@@ -52,18 +52,20 @@
|
||||
<div class="chatbot-body-inner" ref="messagesContainer">
|
||||
<!-- Onboarding -->
|
||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||
<p>Ce chatbot fonctionne sur un serveur européen souverain
|
||||
<slot name="onboarding">
|
||||
<p>Ce chatbot fonctionne sur un serveur européen souverain
|
||||
(Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
||||
<p>Pour m'aider à te répondre efficacement,
|
||||
<p>Pour m'aider à te répondre efficacement,
|
||||
formule ta requête ainsi :</p>
|
||||
<ul>
|
||||
<li>• Besoin : [ce que tu cherches]</li>
|
||||
<li>• Thématique : [juridique / technique / économique / ...]</li>
|
||||
<li>• Lieu : [région ou ville]</li>
|
||||
</ul>
|
||||
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
|
||||
<ul>
|
||||
<li>• Besoin : [ce que tu cherches]</li>
|
||||
<li>• Thématique : [juridique / technique / économique / ...]</li>
|
||||
<li>• Lieu : [région ou ville]</li>
|
||||
</ul>
|
||||
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
|
||||
employeur, besoin conseil juridique droit du travail,
|
||||
Île-de-France."</p>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
@@ -76,7 +78,7 @@ employeur, besoin conseil juridique droit du travail,
|
||||
<a
|
||||
v-for="fiche in msg.fiches"
|
||||
:key="fiche.id"
|
||||
:href="`/fiche/${fiche.id}`"
|
||||
:href="`${ficheBasePath}/${fiche.id}`"
|
||||
class="fiche-card"
|
||||
>
|
||||
<span class="fiche-nom">{{ fiche.nom }}</span>
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-bold text-sm" style="color: var(--nav-text);">Chatbot</span>
|
||||
<span class="font-bold text-sm" style="color: var(--nav-text);">{{ title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,18 +69,20 @@
|
||||
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
|
||||
<!-- Message onboarding (avant la première question) -->
|
||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||
<p>Ce chatbot fonctionne sur un serveur européen souverain
|
||||
<slot name="onboarding">
|
||||
<p>Ce chatbot fonctionne sur un serveur européen souverain
|
||||
(Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
||||
<p>Pour m'aider à te répondre efficacement,
|
||||
<p>Pour m'aider à te répondre efficacement,
|
||||
formule ta requête ainsi :</p>
|
||||
<ul>
|
||||
<li>• Besoin : [ce que tu cherches]</li>
|
||||
<li>• Thématique : [juridique / technique / économique / ...]</li>
|
||||
<li>• Lieu : [région ou ville]</li>
|
||||
</ul>
|
||||
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
|
||||
<ul>
|
||||
<li>• Besoin : [ce que tu cherches]</li>
|
||||
<li>• Thématique : [juridique / technique / économique / ...]</li>
|
||||
<li>• Lieu : [région ou ville]</li>
|
||||
</ul>
|
||||
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
|
||||
employeur, besoin conseil juridique droit du travail,
|
||||
Île-de-France."</p>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
@@ -100,7 +102,7 @@ employeur, besoin conseil juridique droit du travail,
|
||||
<a
|
||||
v-for="fiche in msg.fiches"
|
||||
:key="fiche.id"
|
||||
:href="`/fiche/${fiche.id}`"
|
||||
:href="`${ficheBasePath}/${fiche.id}`"
|
||||
class="fiche-card"
|
||||
>
|
||||
<span class="fiche-nom">{{ fiche.nom }}</span>
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -75,6 +75,27 @@
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<ChatbotPlaceholder
|
||||
endpoint="/api/chatbot-pratiques"
|
||||
title="Chatbot Pratiques régé"
|
||||
placeholder="Pose une question sur les pratiques régénératives…"
|
||||
ficheBasePath="/pratique"
|
||||
@highlightOrgs="onHighlightOrgs"
|
||||
>
|
||||
<template #onboarding>
|
||||
<p>Ce chatbot interroge la base des pratiques régénératives
|
||||
(Mistral FR, serveur européen souverain, zéro rétention).</p>
|
||||
<p>Pour t'aider à trouver les pratiques pertinentes,
|
||||
formule ta requête ainsi :</p>
|
||||
<ul>
|
||||
<li>• Besoin : [matériaux biosourcés / réemploi / posture politique...]</li>
|
||||
<li>• Type : [agence / coopérative / collectif / réseau...]</li>
|
||||
<li>• Lieu : [pays ou région]</li>
|
||||
</ul>
|
||||
<p class="example">Exemple : "Je cherche une coopérative qui travaille
|
||||
le réemploi de matériaux en Belgique."</p>
|
||||
</template>
|
||||
</ChatbotPlaceholder>
|
||||
</div>
|
||||
|
||||
<!-- Carte Outre-mer (pleine hauteur, scroll) -->
|
||||
@@ -278,22 +299,21 @@
|
||||
|
||||
</main>
|
||||
|
||||
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) — désactivé V1 -->
|
||||
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
||||
<button
|
||||
v-if="false"
|
||||
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
||||
style="
|
||||
height: 48px;
|
||||
background: var(--nav-primary);
|
||||
opacity: 0.5;
|
||||
opacity: 0.92;
|
||||
color: var(--nav-text-on-primary);
|
||||
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
||||
font-family: var(--nav-font);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
"
|
||||
aria-label="Chatbot (bientôt disponible)"
|
||||
disabled
|
||||
aria-label="Ouvrir l'assistant Chatbot"
|
||||
@click="chatbotOpen = true"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
@@ -301,6 +321,30 @@
|
||||
<span>Chatbot</span>
|
||||
</button>
|
||||
|
||||
<!-- ═══════════════════════════════════════ CHATBOT BOTTOM SHEET (mobile) -->
|
||||
<ChatbotSheet
|
||||
:modelValue="chatbotOpen"
|
||||
endpoint="/api/chatbot-pratiques"
|
||||
title="Chatbot Pratiques régé"
|
||||
ficheBasePath="/pratique"
|
||||
@update:modelValue="chatbotOpen = $event"
|
||||
@highlightOrgs="onHighlightOrgs"
|
||||
>
|
||||
<template #onboarding>
|
||||
<p>Ce chatbot interroge la base des pratiques régénératives
|
||||
(Mistral FR, serveur européen souverain, zéro rétention).</p>
|
||||
<p>Pour t'aider à trouver les pratiques pertinentes,
|
||||
formule ta requête ainsi :</p>
|
||||
<ul>
|
||||
<li>• Besoin : [matériaux biosourcés / réemploi / posture politique...]</li>
|
||||
<li>• Type : [agence / coopérative / collectif / réseau...]</li>
|
||||
<li>• Lieu : [pays ou région]</li>
|
||||
</ul>
|
||||
<p class="example">Exemple : "Je cherche une coopérative qui travaille
|
||||
le réemploi de matériaux en Belgique."</p>
|
||||
</template>
|
||||
</ChatbotSheet>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -332,6 +376,27 @@ const pays = ref<string[]>(
|
||||
const selectedId = ref<number | null>(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<typeof setTimeout> | null = null
|
||||
const prevSelectedId = ref<number | null>(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<any>(null)
|
||||
|
||||
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