feat(taff): layout colonne + modal positionné + chatbot flottant
- Grille : 3 colonnes → 1 colonne centrée 720px (respire, 16 fiches) - Modal : top fixe 72px au lieu de top-1/2 (ne mord plus le header) - Chatbot FAB : bouton fixe bas-droite + panel slide-in avec Mistral - /api/chatbot-taff : endpoint dédié lisant plateformes-taff.json - Cartes : layout restructuré tag/nom/axes/desc-3-lignes/footer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
143
server/api/chatbot-taff.post.ts
Normal file
143
server/api/chatbot-taff.post.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 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 { resolve } from 'node:path'
|
||||
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 jsonPath = resolve(process.cwd(), 'public/data/plateformes-taff.json')
|
||||
const raw = JSON.parse(readFileSync(jsonPath, '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: [] }
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user