RAPIDE groq/llama-3.1-8b-instant → groq/openai/gpt-oss-20b ; APPROFONDI groq/llama-3.3-70b-versatile → groq/openai/gpt-oss-120b ; replis inchangés. Les chatbots vivaient sur le repli Gemini depuis le retrait. gpt-oss raisonne : ses tokens de raisonnement sont décomptés de max_tokens. REASONING_MARGIN_TOKENS (800) ajouté au budget des trois routes, et bifrostContent() signale au journal une réponse tronquée (finish_reason length). Le champ message.reasoning est ignoré. Défaut WORKER_MODEL aligné sur la prod. En-têtes « Mistral Small » corrigés, PIPE-IA-DOC §11.3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzGo8bxhHp6M9RxuAFbyLU
141 lines
4.8 KiB
TypeScript
141 lines
4.8 KiB
TypeScript
/**
|
|
* POST /api/chatbot-taff
|
|
* Chatbot d'aiguillage — Carte 3 "Trouver du taf"
|
|
* Lit plateformes-taff.json, appelle un LLM via Bifrost (server/utils/bifrost.ts), retourne recommandations.
|
|
*/
|
|
|
|
// @ts-ignore — JSON import résolu par Vite/Rollup
|
|
import taffData from '../../public/data/plateformes-taff.json'
|
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
|
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
|
|
|
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.' })
|
|
}
|
|
const tier = pickBifrostTier(body?.mode)
|
|
|
|
// Données bundlées statiquement à la compilation (import JSON)
|
|
const plateformes: PlateformeMinimal[] = ((taffData as any).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,
|
|
}))
|
|
|
|
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 bifrostUrl = config.bifrostUrl as string
|
|
const bifrostVk = config.bifrostVk as string
|
|
if (!bifrostVk) {
|
|
throw createError({ statusCode: 500, statusMessage: 'Clé Bifrost manquante.' })
|
|
}
|
|
|
|
let mistralRaw: string
|
|
try {
|
|
const res = await $fetch<BifrostChatResponse>(
|
|
`${bifrostUrl}/v1/chat/completions`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: tier.model,
|
|
fallbacks: tier.fallbacks,
|
|
temperature: 0.3,
|
|
max_tokens: 700 + REASONING_MARGIN_TOKENS,
|
|
response_format: { type: 'json_object' },
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: question },
|
|
],
|
|
}),
|
|
}
|
|
)
|
|
mistralRaw = bifrostContent(res, 'chatbot-taff')
|
|
} 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: [] }
|
|
}
|
|
})
|