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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
75d8440601
commit
80f69565aa
@@ -6,6 +6,7 @@
|
|||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
import { profondeurDuBody, REGLE_GUILLEMETS } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Règle citations — commune aux 4 préambules (mesure M7 du 05/10 : le modèle invente des
|
* Règle citations — commune aux 4 préambules (mesure M7 du 05/10 : le modèle invente des
|
||||||
@@ -50,6 +51,28 @@ Règles :
|
|||||||
- Ton praticien militant : direct, pas neutre, ancré dans la pratique architecturale.
|
- Ton praticien militant : direct, pas neutre, ancré dans la pratique architecturale.
|
||||||
- Réponse en français, dense, sans délayage.`
|
- Réponse en français, dense, sans délayage.`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posture « réflexion » (mode approfondi, v1 — prompts définitifs par onglet : B7/B9).
|
||||||
|
* Ajoutée APRÈS le préambule du corpus et AVANT REGLE_CITATIONS (qui reste prioritaire).
|
||||||
|
* Pensées n'est pas servi par Bifrost : le LLM de génération est réglé côté LightRAG, donc
|
||||||
|
* ce mode change la consigne et les paramètres de récupération, pas le modèle.
|
||||||
|
*/
|
||||||
|
const POSTURE_REFLEXION = `MODE RÉFLEXION : l'usager veut penser, pas seulement s'informer.
|
||||||
|
- Mets les idées en relation : reformule d'abord ce que la question engage, puis fais dialoguer les auteurs et les écoles présents dans le contexte.
|
||||||
|
- Nomme les tensions et les désaccords entre écoles (ce qu'elles partagent, là où elles divergent, ce que chacune ne voit pas), sans trancher à la place de l'usager.
|
||||||
|
- Reste strictement dans le corpus fourni : si une idée n'y est pas, dis-le au lieu de la compléter de mémoire.
|
||||||
|
- Termine par 2 questions ouvertes qui aident l'usager à situer sa propre position.
|
||||||
|
- Réponse structurée en paragraphes courts, 450 mots maximum.
|
||||||
|
- ${REGLE_GUILLEMETS}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paramètres de récupération LightRAG du mode réflexion. Mesuré le 09/10 (contexte seul, hybrid) :
|
||||||
|
* top_k 60 / chunk_top_k 30 ne changent rien (115,7 → 115,9 Ko, le budget de tokens plafonne) ;
|
||||||
|
* max_total_tokens 50000 monte le contexte à 153 Ko. On prend 40000 : plus de matière pour croiser
|
||||||
|
* les écoles sans approcher la fenêtre du LLM de génération (réglé côté serveur).
|
||||||
|
*/
|
||||||
|
const PARAMS_RAG_REFLEXION = { max_total_tokens: 40000 }
|
||||||
|
|
||||||
function buildPrefaceAuteur(nomAuteur: string, slug: string): string {
|
function buildPrefaceAuteur(nomAuteur: string, slug: string): string {
|
||||||
return `Tu réponds EXCLUSIVEMENT depuis les livres de ${nomAuteur} présents dans le RAG (fichiers commençant par "${slug}__").
|
return `Tu réponds EXCLUSIVEMENT depuis les livres de ${nomAuteur} présents dans le RAG (fichiers commençant par "${slug}__").
|
||||||
Si la question sort du périmètre de cet auteur, indique-le et propose de l'aborder sans le hashtag pour interroger la carte entière. Reste fidèle au style et à la pensée de ${nomAuteur}. Cite toujours le livre.
|
Si la question sort du périmètre de cet auteur, indique-le et propose de l'aborder sans le hashtag pour interroger la carte entière. Reste fidèle au style et à la pensée de ${nomAuteur}. Cite toujours le livre.
|
||||||
@@ -98,6 +121,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const query: string = body.query.trim()
|
const query: string = body.query.trim()
|
||||||
|
// jamais body.mode ici : réservé au mode de recherche LightRAG (hybrid…)
|
||||||
|
const profondeur = profondeurDuBody({ profondeur: body.profondeur })
|
||||||
const mode: string = body.mode || 'hybrid'
|
const mode: string = body.mode || 'hybrid'
|
||||||
const corpus: string = body.corpus || 'pensees'
|
const corpus: string = body.corpus || 'pensees'
|
||||||
const ragUrl: string = (config.ragPeUrl as string) || 'http://localhost:9621'
|
const ragUrl: string = (config.ragPeUrl as string) || 'http://localhost:9621'
|
||||||
@@ -133,8 +158,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
const ragBody: Record<string, any> = {
|
const ragBody: Record<string, any> = {
|
||||||
query,
|
query,
|
||||||
mode,
|
mode,
|
||||||
user_prompt: `${systemPreface}\n\n${REGLE_CITATIONS}`,
|
user_prompt: profondeur === 'approfondi'
|
||||||
|
? `${systemPreface}\n\n${POSTURE_REFLEXION}\n\n${REGLE_CITATIONS}`
|
||||||
|
: `${systemPreface}\n\n${REGLE_CITATIONS}`,
|
||||||
}
|
}
|
||||||
|
if (profondeur === 'approfondi') Object.assign(ragBody, PARAMS_RAG_REFLEXION)
|
||||||
if (auteurSlug && nomAuteurMatch) {
|
if (auteurSlug && nomAuteurMatch) {
|
||||||
ragBody.hl_keywords = [nomAuteurMatch, auteurSlug]
|
ragBody.hl_keywords = [nomAuteurMatch, auteurSlug]
|
||||||
ragBody.ll_keywords = [auteurSlug]
|
ragBody.ll_keywords = [auteurSlug]
|
||||||
@@ -174,5 +202,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
auteur_chunks: auteurSlug && nomAuteurMatch ? { on_target: chunksOnTarget, off_target: chunksOffTarget } : null,
|
auteur_chunks: auteurSlug && nomAuteurMatch ? { on_target: chunksOnTarget, off_target: chunksOffTarget } : null,
|
||||||
filter: { couche: body.filter_couche ?? null, ecole: body.filter_ecole ?? null },
|
filter: { couche: body.filter_couche ?? null, ecole: body.filter_ecole ?? null },
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
...(import.meta.dev
|
||||||
|
? { debug: { profondeur, user_prompt_chars: ragBody.user_prompt.length, rag_params: profondeur === 'approfondi' ? PARAMS_RAG_REFLEXION : null } }
|
||||||
|
: {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// @ts-ignore — JSON import résolu par Rollup
|
// @ts-ignore — JSON import résolu par Rollup
|
||||||
import reseauxData from '../../public/data/reseaux-bifurcation.json'
|
import reseauxData from '../../public/data/reseaux-bifurcation.json'
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
import { pickBifrostTier, profondeurDuBody, bifrostMaxTokens, bifrostDebug, bifrostContent, REGLE_GUILLEMETS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
interface Structure {
|
interface Structure {
|
||||||
id: string
|
id: string
|
||||||
@@ -41,6 +41,29 @@ FORMAT :
|
|||||||
STRUCTURES DISPONIBLES :
|
STRUCTURES DISPONIBLES :
|
||||||
{{STRUCTURES_JSON}}`
|
{{STRUCTURES_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 archi, au service des architectes engagés dans la transition écologique. Tu connais le réseau AEP (Architecture d'Écologie Politique) — une cartographie des collectifs, agences, initiatives et réseaux qui portent une vision alternative de l'architecture en France et en Europe.
|
||||||
|
|
||||||
|
POSTURE : avant de recommander des structures, tu questionnes le projet de l'usager. Tu reformules ce que tu comprends de son projet, tu nommes ce qui reste flou ou en tension (échelle, maîtrise d'ouvrage, filière de matériaux, rapport au territoire, posture professionnelle, moyens), tu poses 2 ou 3 questions précises qui l'aident à se situer, PUIS tu orientes vers les structures du contexte qui correspondent à ce qu'il a dit, en expliquant ce que chacune lui apporterait ou lui ferait choisir.
|
||||||
|
|
||||||
|
RÈGLES :
|
||||||
|
1. Ne cite QUE des structures présentes dans le contexte ci-dessous.
|
||||||
|
2. ${REGLE_GUILLEMETS}
|
||||||
|
3. Sois direct et engagé — c'est l'esprit AEP — mais n'affirme pas ce que tu ne sais pas de son projet : demande.
|
||||||
|
4. Réponse de 300 mots maximum, en français, en prose, 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, tension, questions, puis structures",
|
||||||
|
"fiches_recommandees": [
|
||||||
|
{ "id": "slug-id", "nom": "Nom structure", "explication": "Pourquoi en 1 phrase" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
STRUCTURES DISPONIBLES :
|
||||||
|
{{STRUCTURES_JSON}}`
|
||||||
|
|
||||||
function scoreStructure(s: Structure, keywords: string[]): number {
|
function scoreStructure(s: Structure, keywords: string[]): number {
|
||||||
if (keywords.length === 0) return 1
|
if (keywords.length === 0) return 1
|
||||||
const haystack = [s.nom, s.famille_principale, s.description_courte, s.description_longue, (s.hashtags ?? []).join(' '), s.ville, s.pays]
|
const haystack = [s.nom, s.famille_principale, s.description_courte, s.description_longue, (s.hashtags ?? []).join(' '), s.ville, s.pays]
|
||||||
@@ -62,7 +85,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const question: string = (body?.question ?? '').trim()
|
const question: string = (body?.question ?? '').trim()
|
||||||
if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' })
|
if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' })
|
||||||
const tier = pickBifrostTier(body?.mode)
|
const profondeur = profondeurDuBody(body)
|
||||||
|
const tier = pickBifrostTier(profondeur)
|
||||||
|
|
||||||
const structures: Structure[] = ((reseauxData as any).structures ?? [])
|
const structures: Structure[] = ((reseauxData as any).structures ?? [])
|
||||||
const keywords = extractKeywords(question)
|
const keywords = extractKeywords(question)
|
||||||
@@ -82,13 +106,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
description: (s.description_courte ?? s.description_longue ?? '').slice(0, 200),
|
description: (s.description_courte ?? s.description_longue ?? '').slice(0, 200),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
|
const systemPrompt = (profondeur === 'approfondi' ? SYSTEM_PROMPT_REFLEXION : SYSTEM_PROMPT).replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
|
||||||
|
|
||||||
const bifrostUrl = config.bifrostUrl as string
|
const bifrostUrl = config.bifrostUrl as string
|
||||||
const bifrostVk = config.bifrostVk as string
|
const bifrostVk = config.bifrostVk as string
|
||||||
if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' })
|
if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' })
|
||||||
|
|
||||||
let mistralRaw: string
|
let mistralRaw: string
|
||||||
|
let debugExtra: Record<string, unknown> = {}
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<BifrostChatResponse>(
|
const res = await $fetch<BifrostChatResponse>(
|
||||||
`${bifrostUrl}/v1/chat/completions`,
|
`${bifrostUrl}/v1/chat/completions`,
|
||||||
@@ -99,7 +124,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
model: tier.model,
|
model: tier.model,
|
||||||
fallbacks: tier.fallbacks,
|
fallbacks: tier.fallbacks,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 700 + REASONING_MARGIN_TOKENS,
|
max_tokens: bifrostMaxTokens(700, profondeur),
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
@@ -109,6 +134,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
mistralRaw = bifrostContent(res, 'chatbot-reseaux')
|
mistralRaw = bifrostContent(res, 'chatbot-reseaux')
|
||||||
|
debugExtra = bifrostDebug(res, 'chatbot-reseaux', profondeur)
|
||||||
} catch {
|
} catch {
|
||||||
throw createError({ statusCode: 502, message: 'Erreur IA — réessaie dans quelques instants.' })
|
throw createError({ statusCode: 502, message: 'Erreur IA — réessaie dans quelques instants.' })
|
||||||
}
|
}
|
||||||
@@ -122,6 +148,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
nom: r.nom ?? structures.find(s => s.id === r.id)?.nom ?? r.id,
|
nom: r.nom ?? structures.find(s => s.id === r.id)?.nom ?? r.id,
|
||||||
explication: r.explication ?? '',
|
explication: r.explication ?? '',
|
||||||
})),
|
})),
|
||||||
|
...debugExtra,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", fiches_recommandees: [] }
|
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", fiches_recommandees: [] }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// @ts-ignore — JSON import résolu par Vite/Rollup
|
// @ts-ignore — JSON import résolu par Vite/Rollup
|
||||||
import taffData from '../../public/data/plateformes-taff.json'
|
import taffData from '../../public/data/plateformes-taff.json'
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
import { pickBifrostTier, profondeurDuBody, bifrostMaxTokens, bifrostDebug, bifrostContent, REGLE_GUILLEMETS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
interface PlateformeMinimal {
|
interface PlateformeMinimal {
|
||||||
id: string
|
id: string
|
||||||
@@ -49,6 +49,29 @@ FORMAT :
|
|||||||
PLATEFORMES DISPONIBLES :
|
PLATEFORMES DISPONIBLES :
|
||||||
{{PLATEFORMES_JSON}}`
|
{{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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
@@ -67,7 +90,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!question || question.length < 5) {
|
if (!question || question.length < 5) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
|
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
|
||||||
}
|
}
|
||||||
const tier = pickBifrostTier(body?.mode)
|
const profondeur = profondeurDuBody(body)
|
||||||
|
const tier = pickBifrostTier(profondeur)
|
||||||
|
|
||||||
// Données bundlées statiquement à la compilation (import JSON)
|
// Données bundlées statiquement à la compilation (import JSON)
|
||||||
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
|
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
|
||||||
@@ -91,7 +115,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
justification: p.scoring.justification_tag,
|
justification: p.scoring.justification_tag,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
|
const systemPrompt = (profondeur === 'approfondi' ? SYSTEM_PROMPT_REFLEXION : SYSTEM_PROMPT).replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
|
||||||
|
|
||||||
const bifrostUrl = config.bifrostUrl as string
|
const bifrostUrl = config.bifrostUrl as string
|
||||||
const bifrostVk = config.bifrostVk as string
|
const bifrostVk = config.bifrostVk as string
|
||||||
@@ -100,6 +124,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mistralRaw: string
|
let mistralRaw: string
|
||||||
|
let debugExtra: Record<string, unknown> = {}
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<BifrostChatResponse>(
|
const res = await $fetch<BifrostChatResponse>(
|
||||||
`${bifrostUrl}/v1/chat/completions`,
|
`${bifrostUrl}/v1/chat/completions`,
|
||||||
@@ -110,7 +135,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
model: tier.model,
|
model: tier.model,
|
||||||
fallbacks: tier.fallbacks,
|
fallbacks: tier.fallbacks,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 700 + REASONING_MARGIN_TOKENS,
|
max_tokens: bifrostMaxTokens(700, profondeur),
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
@@ -120,6 +145,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
mistralRaw = bifrostContent(res, 'chatbot-taff')
|
mistralRaw = bifrostContent(res, 'chatbot-taff')
|
||||||
|
debugExtra = bifrostDebug(res, 'chatbot-taff', profondeur)
|
||||||
} catch {
|
} catch {
|
||||||
throw createError({ statusCode: 502, statusMessage: 'Erreur IA — réessaie dans quelques instants.' })
|
throw createError({ statusCode: 502, statusMessage: 'Erreur IA — réessaie dans quelques instants.' })
|
||||||
}
|
}
|
||||||
@@ -133,6 +159,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
nom: r.nom ?? plateformes.find(p => p.id === r.id)?.nom ?? r.id,
|
nom: r.nom ?? plateformes.find(p => p.id === r.id)?.nom ?? r.id,
|
||||||
raison: r.raison ?? '',
|
raison: r.raison ?? '',
|
||||||
})),
|
})),
|
||||||
|
...debugExtra,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", plateformes_recommandees: [] }
|
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", plateformes_recommandees: [] }
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
||||||
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
import { pickBifrostTier, profondeurDuBody, bifrostMaxTokens, bifrostDebug, bifrostContent, REGLE_GUILLEMETS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -70,6 +70,32 @@ FORMAT DE SORTIE :
|
|||||||
CONTEXTE — Organisations disponibles dans la base NAV :
|
CONTEXTE — Organisations disponibles dans la base NAV :
|
||||||
{{FICHES_JSON}}`
|
{{FICHES_JSON}}`
|
||||||
|
|
||||||
|
// ── Prompt « réflexion » (mode approfondi, v1 — prompts définitifs par onglet : B7/B9) ──────
|
||||||
|
// Même format de sortie JSON que SYSTEM_PROMPT (le front en dépend) ; seule la posture change.
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT_REFLEXION = `Tu es un conseiller-ressources au service de la transition écologique des pratiques architecturales, sur AEP (Architecture d'Écologie Politique) — Écosystème Entraide, une base collaborative qui référence les acteurs de l'écologie politique appliquée à l'architecture et au territoire (organisations, outils, ressources) pour les architectes en France.
|
||||||
|
|
||||||
|
POSTURE : l'usager pose rarement le bon problème du premier coup. Tu l'aides d'abord à formuler son besoin, puis tu l'orientes vers les ressources et fiches du contexte. Tu es dialectique : tu reformules ce que tu comprends, tu nommes une tension ou un choix que la question laisse ouvert, tu poses 2 ou 3 questions qui l'aident à se situer (où en est son projet, avec qui, à quelle échelle, quelle contrainte pèse le plus), PUIS tu proposes des pistes.
|
||||||
|
|
||||||
|
RÈGLES ABSOLUES :
|
||||||
|
1. Tu ne peux recommander QUE des organisations présentes dans le contexte ci-dessous. Ne jamais inventer d'organisation absente.
|
||||||
|
2. Cite chaque organisation recommandée par son nom exact et son identifiant id. Si aucune n'est pertinente, dis-le honnêtement.
|
||||||
|
3. ${REGLE_GUILLEMETS}
|
||||||
|
4. Réponse de 300 mots maximum, en français, en prose (pas de liste à puces), ton de pair, tutoiement.
|
||||||
|
5. Si la question est hors du champ architecture / écologie / territoire / transition, recadre poliment vers le périmètre de la carte.
|
||||||
|
6. Retourne UNIQUEMENT un objet JSON valide, sans texte avant ou après.
|
||||||
|
|
||||||
|
FORMAT DE SORTIE :
|
||||||
|
{
|
||||||
|
"reponse_texte": "Ta réponse en prose (max 300 mots) : reformulation, tension, questions, puis pistes",
|
||||||
|
"fiches_recommandees": [
|
||||||
|
{ "id": 123, "explication": "Pourquoi cette fiche répond à la question (1-2 phrases max)" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
CONTEXTE — Organisations disponibles dans la base NAV :
|
||||||
|
{{FICHES_JSON}}`
|
||||||
|
|
||||||
// ── Recherche par mots-clés ────────────────────────────────────────────────────
|
// ── Recherche par mots-clés ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function scoreOrg(org: OrgRow, keywords: string[]): number {
|
function scoreOrg(org: OrgRow, keywords: string[]): number {
|
||||||
@@ -182,7 +208,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const question: string = (body?.question ?? '').trim()
|
const question: string = (body?.question ?? '').trim()
|
||||||
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
|
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
|
||||||
const tier = pickBifrostTier(body?.mode)
|
const profondeur = profondeurDuBody(body)
|
||||||
|
const tier = pickBifrostTier(profondeur)
|
||||||
|
|
||||||
if (!question || question.length < 3) {
|
if (!question || question.length < 3) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -245,7 +272,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
ville: o.localisation_ville ?? '',
|
ville: o.localisation_ville ?? '',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const systemPrompt = SYSTEM_PROMPT.replace(
|
const systemPrompt = (profondeur === 'approfondi' ? SYSTEM_PROMPT_REFLEXION : SYSTEM_PROMPT).replace(
|
||||||
'{{FICHES_JSON}}',
|
'{{FICHES_JSON}}',
|
||||||
JSON.stringify(fichesContext, null, 0),
|
JSON.stringify(fichesContext, null, 0),
|
||||||
)
|
)
|
||||||
@@ -265,6 +292,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
let tokensIn = 0
|
let tokensIn = 0
|
||||||
let tokensOut = 0
|
let tokensOut = 0
|
||||||
let realModel = tier.model
|
let realModel = tier.model
|
||||||
|
let debugExtra: Record<string, unknown> = {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bifrostRes = await $fetch<BifrostChatResponse>(`${bifrostUrl}/v1/chat/completions`, {
|
const bifrostRes = await $fetch<BifrostChatResponse>(`${bifrostUrl}/v1/chat/completions`, {
|
||||||
@@ -277,7 +305,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
model: tier.model,
|
model: tier.model,
|
||||||
fallbacks: tier.fallbacks,
|
fallbacks: tier.fallbacks,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 600 + REASONING_MARGIN_TOKENS,
|
max_tokens: bifrostMaxTokens(600, profondeur),
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
@@ -287,6 +315,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
mistralRaw = bifrostContent(bifrostRes, 'chatbot')
|
mistralRaw = bifrostContent(bifrostRes, 'chatbot')
|
||||||
|
debugExtra = bifrostDebug(bifrostRes, 'chatbot', profondeur)
|
||||||
tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
|
tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
|
||||||
tokensOut = bifrostRes.usage?.completion_tokens ?? 0
|
tokensOut = bifrostRes.usage?.completion_tokens ?? 0
|
||||||
realModel =
|
realModel =
|
||||||
@@ -340,5 +369,5 @@ export default defineEventHandler(async (event) => {
|
|||||||
coutEur,
|
coutEur,
|
||||||
})
|
})
|
||||||
|
|
||||||
return parsed
|
return { ...parsed, ...debugExtra }
|
||||||
})
|
})
|
||||||
|
|||||||
+43
-5
@@ -4,8 +4,9 @@
|
|||||||
* Auth : header x-bf-vk
|
* Auth : header x-bf-vk
|
||||||
*
|
*
|
||||||
* 2 tiers (Mission M3 du build Bifrost ; modèles primaires changés le 28/09, session AEP front 4) :
|
* 2 tiers (Mission M3 du build Bifrost ; modèles primaires changés le 28/09, session AEP front 4) :
|
||||||
* RAPIDE — défaut, pas de toggle UI mode rapide/approfondi sur le site actuellement
|
* RAPIDE — défaut (mode « Question » du toggle front)
|
||||||
* APPROFONDI — activable via body.mode === 'approfondi' (prêt pour un futur toggle front)
|
* APPROFONDI — body.profondeur === 'approfondi' (mode « Réflexion plus profonde », B1-M3 ;
|
||||||
|
* body.mode reste lu en compat ascendante). Contrat : utils/chatProfondeur.ts
|
||||||
*
|
*
|
||||||
* ⚠ 28/09 : Groq a retiré groq/llama-3.1-8b-instant (RAPIDE) et groq/llama-3.3-70b-versatile
|
* ⚠ 28/09 : Groq a retiré groq/llama-3.1-8b-instant (RAPIDE) et groq/llama-3.3-70b-versatile
|
||||||
* (APPROFONDI) — 404 model_not_found via Bifrost. Depuis le 23/09 environ, chaque appel
|
* (APPROFONDI) — 404 model_not_found via Bifrost. Depuis le 23/09 environ, chaque appel
|
||||||
@@ -32,6 +33,8 @@
|
|||||||
* juste plus dans cette chaîne tant que ce n'est pas fiabilisé.
|
* juste plus dans cette chaîne tant que ce n'est pas fiabilisé.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { normaliserProfondeur, type ChatProfondeur } from '~/utils/chatProfondeur'
|
||||||
|
|
||||||
export const BIFROST_TIER_RAPIDE = {
|
export const BIFROST_TIER_RAPIDE = {
|
||||||
model: 'groq/openai/gpt-oss-20b',
|
model: 'groq/openai/gpt-oss-20b',
|
||||||
fallbacks: [
|
fallbacks: [
|
||||||
@@ -57,11 +60,34 @@ export const BIFROST_TIER_APPROFONDI = {
|
|||||||
*/
|
*/
|
||||||
export const REASONING_MARGIN_TOKENS = 800
|
export const REASONING_MARGIN_TOKENS = 800
|
||||||
|
|
||||||
/** Sélectionne le tier selon le param optionnel body.mode. */
|
/** Sélectionne le tier selon la profondeur ('approfondi' → APPROFONDI, tout le reste → RAPIDE). */
|
||||||
export function pickBifrostTier(mode?: string) {
|
export function pickBifrostTier(profondeur?: string) {
|
||||||
return mode === 'approfondi' ? BIFROST_TIER_APPROFONDI : BIFROST_TIER_RAPIDE
|
return profondeur === 'approfondi' ? BIFROST_TIER_APPROFONDI : BIFROST_TIER_RAPIDE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profondeur demandée par le body : `profondeur`, sinon `mode` (compat ascendante des
|
||||||
|
* routes Bifrost avant B1-M3). Valeur inconnue → 'rapide'.
|
||||||
|
*/
|
||||||
|
export function profondeurDuBody(body: any): ChatProfondeur {
|
||||||
|
return normaliserProfondeur(body?.profondeur ?? body?.mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Budget max_tokens = contenu + marge de raisonnement. Le mode approfondi autorise une
|
||||||
|
* réponse un peu plus longue (+200) et une marge plus large (+400) : le 120b raisonne plus.
|
||||||
|
* Reste sous les limites par minute d'une clé Groq (prompt ~3-4 k + 2,2 k max).
|
||||||
|
*/
|
||||||
|
export function bifrostMaxTokens(contenu: number, profondeur: ChatProfondeur): number {
|
||||||
|
return profondeur === 'approfondi'
|
||||||
|
? contenu + 200 + REASONING_MARGIN_TOKENS + 400
|
||||||
|
: contenu + REASONING_MARGIN_TOKENS
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Règle guillemets, commune à tous les prompts « réflexion » (cf. REGLE_CITATIONS de Pensées). */
|
||||||
|
export const REGLE_GUILLEMETS =
|
||||||
|
'Ne mets entre guillemets que ce qui figure mot pour mot dans le contexte fourni ; sinon reformule sans guillemets.'
|
||||||
|
|
||||||
export interface BifrostChatResponse {
|
export interface BifrostChatResponse {
|
||||||
/** reasoning : présent avec gpt-oss, jamais lu (la réponse utile est dans content). */
|
/** reasoning : présent avec gpt-oss, jamais lu (la réponse utile est dans content). */
|
||||||
choices: { message: { content: string; reasoning?: string }; finish_reason?: string }[]
|
choices: { message: { content: string; reasoning?: string }; finish_reason?: string }[]
|
||||||
@@ -69,6 +95,18 @@ export interface BifrostChatResponse {
|
|||||||
extra_fields?: { provider?: string; resolved_model_used?: string }
|
extra_fields?: { provider?: string; resolved_model_used?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preuve du tier réellement servi : journal serveur toujours (modèle dans le log seulement),
|
||||||
|
* et champ `debug` dans la réponse UNIQUEMENT en dev (import.meta.dev, éliminé du build de
|
||||||
|
* production). Aucun nom de modèle n'est exposé publiquement.
|
||||||
|
*/
|
||||||
|
export function bifrostDebug(res: BifrostChatResponse, route: string, profondeur: ChatProfondeur) {
|
||||||
|
const modele = res.extra_fields?.resolved_model_used ?? null
|
||||||
|
const fournisseur = res.extra_fields?.provider ?? null
|
||||||
|
console.log(`[${route}] profondeur=${profondeur} fournisseur=${fournisseur} modèle=${modele}`)
|
||||||
|
return import.meta.dev ? { debug: { profondeur, fournisseur, modele } } : {}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contenu JSON de la réponse. Une troncature (finish_reason 'length') ne lève aucune erreur
|
* Contenu JSON de la réponse. Une troncature (finish_reason 'length') ne lève aucune erreur
|
||||||
* et finit en message générique côté usager : on la signale au journal pour la voir.
|
* et finit en message générique côté usager : on la signale au journal pour la voir.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Mode de profondeur des chatbots (M3) — contrat partagé front/serveur.
|
||||||
|
* Champ de body : `profondeur` = 'rapide' | 'approfondi' (défaut 'rapide').
|
||||||
|
* Les libellés affichés vivent ICI, à un seul endroit : les changer ne touche ni
|
||||||
|
* le composant ni le serveur.
|
||||||
|
*/
|
||||||
|
export type ChatProfondeur = 'rapide' | 'approfondi'
|
||||||
|
|
||||||
|
export const CHAT_PROFONDEUR_DEFAUT: ChatProfondeur = 'rapide'
|
||||||
|
|
||||||
|
export const CHAT_PROFONDEUR_LIBELLES: Record<ChatProfondeur, string> = {
|
||||||
|
rapide: 'Question',
|
||||||
|
approfondi: 'Réflexion plus profonde',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sous-texte (title/aria) de chaque option. */
|
||||||
|
export const CHAT_PROFONDEUR_AIDES: Record<ChatProfondeur, string> = {
|
||||||
|
rapide: 'Réponse directe et concise',
|
||||||
|
approfondi: 'Une réponse plus longue, qui questionne ton besoin avant de répondre',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHAT_PROFONDEURS = Object.keys(CHAT_PROFONDEUR_LIBELLES) as ChatProfondeur[]
|
||||||
|
|
||||||
|
/** Normalise une valeur inconnue (body, stockage) vers un mode valide. */
|
||||||
|
export function normaliserProfondeur(v: unknown): ChatProfondeur {
|
||||||
|
return v === 'approfondi' ? 'approfondi' : 'rapide'
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user