Files
nav-carte/server/api/chatbot-taff.post.ts
T
Jules NenyandClaude Opus 5.5 80f69565aa feat(chatbot): mode profondeur côté serveur (rapide / approfondi) sur les 4 routes
Contrat unique `profondeur` ('rapide' | 'approfondi', défaut rapide) dans
utils/chatProfondeur.ts (libellés partagés). Les 3 routes Bifrost lisent
body.profondeur ?? body.mode, choisissent le tier (20b / 120b + replis), un
prompt système « réflexion » (Entraide, Réseaux, Taff) au même format JSON, et un
budget max_tokens adapté. Pensées lit body.profondeur (jamais body.mode, réservé à
LightRAG) : posture réflexion ajoutée au user_prompt + max_total_tokens 40000.
Modèle servi journalisé, et champ debug exposé uniquement en dev.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vcVGz8i4WpZm2dixKUiSD
2026-10-09 03:45:27 +02:00

168 lines
6.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, profondeurDuBody, bifrostMaxTokens, bifrostDebug, bifrostContent, REGLE_GUILLEMETS, 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}}`
// Prompt « réflexion » (mode approfondi, v1 — prompts définitifs par onglet : B7/B9). Même format JSON.
const SYSTEM_PROMPT_REFLEXION = `Tu es un conseiller pour les architectes indépendants français qui cherchent des missions. 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).
POSTURE : avant de recommander une plateforme, tu aides l'architecte à clarifier ce qu'il cherche et qui il est : type de missions visées (particuliers, public, réno, neuf), volume et rythme, statut et assurances, ce qu'il accepte de céder (commission, abonnement, temps de réponse), ses valeurs. Tu reformules son profil tel que tu le comprends, tu nommes un arbitrage que sa demande laisse ouvert, tu poses 2 ou 3 questions qui l'aident à se situer, PUIS tu orientes vers LA ou LES plateformes du contexte qui correspondent, avec leurs limites.
RÈGLES :
1. Ne recommande QUE des plateformes présentes dans le contexte ci-dessous. Si l'une est « À éviter », signale-le clairement.
2. ${REGLE_GUILLEMETS}
3. Sois direct et opinionné — c'est la valeur d'AEP — mais ne présume pas de ce que tu ignores de son profil : demande.
4. Réponse de 300 mots maximum, en français, en prose, ton conseiller pair, tutoiement.
5. Retourne UNIQUEMENT un JSON valide, sans texte avant ou après.
FORMAT :
{
"reponse_texte": "Ta réponse en prose, max 300 mots : reformulation, arbitrage, questions, puis plateformes",
"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 profondeur = profondeurDuBody(body)
const tier = pickBifrostTier(profondeur)
// 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 = (profondeur === 'approfondi' ? SYSTEM_PROMPT_REFLEXION : 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
let debugExtra: Record<string, unknown> = {}
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: bifrostMaxTokens(700, profondeur),
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: question },
],
}),
}
)
mistralRaw = bifrostContent(res, 'chatbot-taff')
debugExtra = bifrostDebug(res, 'chatbot-taff', profondeur)
} 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 ?? '',
})),
...debugExtra,
}
} catch {
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", plateformes_recommandees: [] }
}
})