Files
nav-carte/server/api/chatbot-taff.post.ts
Jules Neny cc93571d94 fix(chatbot-taff): Windows path — process.cwd() → fileURLToPath(import.meta.url)
Crash ESM loader sur Windows (protocole c:) corrigé.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 01:05:06 +02:00

144 lines
4.8 KiB
TypeScript

/**
* POST /api/chatbot-taff
* Chatbot d'aiguillage — Carte 3 "Trouver du taf"
* Lit plateformes-taff.json, appelle Mistral Small, retourne recommandations.
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
interface PlateformeMinimal {
id: string
nom: string
type: string
description_courte: string
scoring: {
remuneration: string | null
transparence: string | null
pratiques: string | null
ecologie: string | null
matching: string | null
tag_global: string
justification_tag: string
}
secteurs_servis: string[]
cout_entree: string
}
const SYSTEM_PROMPT = `Tu es un conseiller expert au service des architectes indépendants français. Tu connais toutes les plateformes de mise en relation architecte↔particulier et les agrégateurs d'appels d'offres publics référencés par AEP (Architecture d'Écologie Politique).
Ton rôle : aider l'architecte à choisir LA ou LES plateformes adaptées à sa situation, en t'appuyant exclusivement sur les données ci-dessous.
RÈGLES :
1. Ne recommande QUE des plateformes présentes dans le contexte ci-dessous.
2. Sois direct et opinionné — c'est ça la valeur d'AEP.
3. Si une plateforme est ❌ "À éviter", signale-le clairement.
4. Réponse max 250 mots, en français, ton conseiller pair.
5. Retourne UNIQUEMENT un JSON valide, sans texte avant ou après.
FORMAT :
{
"reponse_texte": "Ta réponse en prose, max 250 mots",
"plateformes_recommandees": [
{ "id": "slug-kebab", "nom": "Nom plateforme", "raison": "Pourquoi cette plateforme en 1 phrase" }
]
}
PLATEFORMES DISPONIBLES :
{{PLATEFORMES_JSON}}`
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ip =
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
event.node.req.socket?.remoteAddress ||
'0.0.0.0'
const allowed = checkRateLimitJson(ip, 'chatbot-taff', 20)
if (!allowed) {
throw createError({ statusCode: 429, statusMessage: 'Limite de 20 questions par jour atteinte.' })
}
const body = await readBody(event)
const question: string = (body?.question ?? '').trim()
if (!question || question.length < 5) {
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
}
// Lire le JSON statique des plateformes
let plateformes: PlateformeMinimal[] = []
try {
const jsonUrl = new URL('../../public/data/plateformes-taff.json', import.meta.url)
const raw = JSON.parse(readFileSync(fileURLToPath(jsonUrl), 'utf8'))
plateformes = (raw.plateformes ?? []).map((p: any) => ({
id: p.id,
nom: p.nom,
type: p.type,
description_courte: p.description_courte,
scoring: p.scoring,
secteurs_servis: p.secteurs_servis,
cout_entree: p.cout_entree,
}))
} catch (e) {
throw createError({ statusCode: 500, statusMessage: 'Données plateformes introuvables.' })
}
const context = plateformes.map(p => ({
id: p.id,
nom: p.nom,
type: p.type === 'b2c-mise-en-relation' ? 'B2C' : 'Appels offres publics',
tag: p.scoring.tag_global,
resume: p.description_courte,
secteurs: p.secteurs_servis.join(', '),
cout: p.cout_entree,
justification: p.scoring.justification_tag,
}))
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
const mistralApiKey = config.mistralApiKey as string
if (!mistralApiKey) {
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
}
let mistralRaw: string
try {
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
'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: 700,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: question },
],
}),
}
)
mistralRaw = res.choices?.[0]?.message?.content ?? '{}'
} catch {
throw createError({ statusCode: 502, statusMessage: 'Erreur IA — réessaie dans quelques instants.' })
}
try {
const parsed = JSON.parse(mistralRaw)
return {
reponse_texte: parsed.reponse_texte ?? "Je n'ai pas pu analyser ta demande.",
plateformes_recommandees: (parsed.plateformes_recommandees ?? []).map((r: any) => ({
id: r.id,
nom: r.nom ?? plateformes.find(p => p.id === r.id)?.nom ?? r.id,
raison: r.raison ?? '',
})),
}
} catch {
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", plateformes_recommandees: [] }
}
})