refactor(chatbot): ChatbotPlaceholder paramétrable, /agences desktop sur chatbot-reseaux
- ChatbotPlaceholder : props endpoint, titre, placeholderReplie, placeholderSaisie, intro, exemple, fichesTitre, ficheLienModele, rendreMarkdown, messageLimite ; défauts = comportement actuel de l'accueil
- pages/agences.vue : le desktop appelle /api/chatbot-reseaux (le câblage croisé vers le backend Entraide est supprimé), textes orientés structures, rendu markdown, liens /agences#id
- suppression de server/api/chatbot-v2.post.ts et server/utils/vectorSearch.ts (aucun appelant) ; base recherche sémantique par embeddings retrouvable au commit 39602e26
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
39602e26c7
commit
31edbd37af
@@ -12,7 +12,7 @@
|
|||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
:aria-expanded="isExpanded"
|
:aria-expanded="isExpanded"
|
||||||
aria-label="Ouvrir l'assistant chatbot"
|
:aria-label="`Ouvrir l'assistant : ${titre}`"
|
||||||
@keydown.enter="toggleExpand"
|
@keydown.enter="toggleExpand"
|
||||||
@keydown.space.prevent="toggleExpand"
|
@keydown.space.prevent="toggleExpand"
|
||||||
>
|
>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="flex-1 text-sm" style="color: var(--nav-text-muted);">
|
<span class="flex-1 text-sm" style="color: var(--nav-text-muted);">
|
||||||
{{ isExpanded ? 'Chatbot AEP' : 'Pose une question sur le réseau…' }}
|
{{ isExpanded ? titre : placeholderReplie }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Chevron -->
|
<!-- Chevron -->
|
||||||
@@ -52,21 +52,22 @@
|
|||||||
<div class="chatbot-body-inner" ref="messagesContainer">
|
<div class="chatbot-body-inner" ref="messagesContainer">
|
||||||
<!-- Onboarding -->
|
<!-- Onboarding -->
|
||||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||||
<p>Explore les 120 structures de la carte par la conversation. Je peux t'aider à trouver des collectifs, agences ou réseaux selon ta situation, ta pratique ou tes inspirations du moment.</p>
|
<p>{{ intro }}</p>
|
||||||
<p class="example">Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."</p>
|
<p v-if="exemple" class="example">{{ exemple }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
<template v-for="(msg, i) in messages" :key="i">
|
<template v-for="(msg, i) in messages" :key="i">
|
||||||
<div v-if="msg.role === 'user'" class="user-bubble">{{ msg.content }}</div>
|
<div v-if="msg.role === 'user'" class="user-bubble">{{ msg.content }}</div>
|
||||||
<div v-else class="assistant-bubble">
|
<div v-else class="assistant-bubble">
|
||||||
<p>{{ msg.content }}</p>
|
<div v-if="rendreMarkdown" v-html="renderMd(msg.content)" />
|
||||||
|
<p v-else>{{ msg.content }}</p>
|
||||||
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
||||||
<p class="fiches-title">Fiches recommandees :</p>
|
<p class="fiches-title">{{ fichesTitre }}</p>
|
||||||
<a
|
<a
|
||||||
v-for="fiche in msg.fiches"
|
v-for="fiche in msg.fiches"
|
||||||
:key="fiche.id"
|
:key="fiche.id"
|
||||||
:href="`/fiche/${fiche.id}`"
|
:href="ficheLienModele.replace('{id}', String(fiche.id))"
|
||||||
class="fiche-card"
|
class="fiche-card"
|
||||||
>
|
>
|
||||||
<span class="fiche-nom">{{ fiche.nom }}</span>
|
<span class="fiche-nom">{{ fiche.nom }}</span>
|
||||||
@@ -106,7 +107,7 @@
|
|||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
type="text"
|
type="text"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
placeholder="Pose ta question…"
|
:placeholder="placeholderSaisie"
|
||||||
class="chatbot-input"
|
class="chatbot-input"
|
||||||
@keydown.enter.prevent="sendMessage"
|
@keydown.enter.prevent="sendMessage"
|
||||||
/>
|
/>
|
||||||
@@ -127,6 +128,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useMarkdown } from '~/composables/useMarkdown'
|
||||||
|
const { render: renderMd } = useMarkdown()
|
||||||
|
|
||||||
interface FicheReco {
|
interface FicheReco {
|
||||||
id: number | string
|
id: number | string
|
||||||
nom: string
|
nom: string
|
||||||
@@ -140,6 +144,44 @@ interface ChatMessage {
|
|||||||
suggestedHashtags?: string[]
|
suggestedHashtags?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Composant unique pour les deux chatbots desktop. Les défauts reproduisent
|
||||||
|
// exactement le comportement de l'accueil (/api/chatbot, backend Entraide).
|
||||||
|
// Les deux backends renvoient { reponse_texte, fiches_recommandees } ; seul
|
||||||
|
// le rendu diffère (texte brut vs markdown, lien de fiche).
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
/** Route serveur appelée (body { question }) */
|
||||||
|
endpoint?: string
|
||||||
|
/** Titre affiché dans l'en-tête déplié */
|
||||||
|
titre?: string
|
||||||
|
/** Texte de l'en-tête replié */
|
||||||
|
placeholderReplie?: string
|
||||||
|
/** Placeholder du champ de saisie */
|
||||||
|
placeholderSaisie?: string
|
||||||
|
/** Texte d'accueil (première bulle) */
|
||||||
|
intro?: string
|
||||||
|
/** Exemple de question, affiché sous l'accueil */
|
||||||
|
exemple?: string
|
||||||
|
/** Intitulé de la liste de fiches/structures citées */
|
||||||
|
fichesTitre?: string
|
||||||
|
/** Modèle de lien vers une fiche, {id} est remplacé */
|
||||||
|
ficheLienModele?: string
|
||||||
|
/** Rendre la réponse en markdown (backend réseaux) plutôt qu'en texte brut */
|
||||||
|
rendreMarkdown?: boolean
|
||||||
|
/** Message affiché quand la limite quotidienne (429) est atteinte */
|
||||||
|
messageLimite?: string
|
||||||
|
}>(), {
|
||||||
|
endpoint: '/api/chatbot',
|
||||||
|
titre: 'Chatbot AEP',
|
||||||
|
placeholderReplie: 'Pose une question sur le réseau…',
|
||||||
|
placeholderSaisie: 'Pose ta question…',
|
||||||
|
intro: "Explore les 120 structures de la carte par la conversation. Je peux t'aider à trouver des collectifs, agences ou réseaux selon ta situation, ta pratique ou tes inspirations du moment.",
|
||||||
|
exemple: 'Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."',
|
||||||
|
fichesTitre: 'Fiches recommandees :',
|
||||||
|
ficheLienModele: '/fiche/{id}',
|
||||||
|
rendreMarkdown: false,
|
||||||
|
messageLimite: 'Limite de 10 questions par jour atteinte. Reviens demain.',
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'highlightOrgs': [ids: (number | string)[]]
|
'highlightOrgs': [ids: (number | string)[]]
|
||||||
'applyHashtag': [tag: string]
|
'applyHashtag': [tag: string]
|
||||||
@@ -203,7 +245,7 @@ async function sendMessage() {
|
|||||||
const res = await $fetch<{
|
const res = await $fetch<{
|
||||||
reponse_texte: string
|
reponse_texte: string
|
||||||
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
|
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
|
||||||
}>('/api/chatbot', {
|
}>(props.endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { question },
|
body: { question },
|
||||||
})
|
})
|
||||||
@@ -223,7 +265,7 @@ async function sendMessage() {
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const status = e?.statusCode ?? e?.status
|
const status = e?.statusCode ?? e?.status
|
||||||
if (status === 429) {
|
if (status === 429) {
|
||||||
errorMsg.value = 'Limite de 10 questions par jour atteinte. Reviens demain.'
|
errorMsg.value = props.messageLimite
|
||||||
} else if (status === 503) {
|
} else if (status === 503) {
|
||||||
errorMsg.value = 'Le budget IA mensuel est épuisé. Soutiens NAV sur Liberapay pour continuer.'
|
errorMsg.value = 'Le budget IA mensuel est épuisé. Soutiens NAV sur Liberapay pour continuer.'
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -58,6 +58,16 @@
|
|||||||
<!-- Chatbot sous la carte (ordinateur) -->
|
<!-- Chatbot sous la carte (ordinateur) -->
|
||||||
<div v-if="vf.etat.sousVue !== 'outremer'" class="hidden lg:block shrink-0">
|
<div v-if="vf.etat.sousVue !== 'outremer'" class="hidden lg:block shrink-0">
|
||||||
<ChatbotPlaceholder
|
<ChatbotPlaceholder
|
||||||
|
endpoint="/api/chatbot-reseaux"
|
||||||
|
titre="Assistant Réseaux AEP"
|
||||||
|
placeholder-replie="Pose une question sur les structures et agences du réseau…"
|
||||||
|
placeholder-saisie="Décris ta situation…"
|
||||||
|
intro="Je connais les 120 réseaux, collectifs et agences cartographiés dans AEP — ceux qui portent une vision écologique et politique de l'architecture. Décris ta situation : tu cherches un collectif, une agence inspirante, un partenaire sur un projet ?"
|
||||||
|
exemple="Exemple : « Quelles agences travaillent sur le réemploi ? »"
|
||||||
|
fiches-titre="Structures recommandées :"
|
||||||
|
fiche-lien-modele="/agences#{id}"
|
||||||
|
rendre-markdown
|
||||||
|
message-limite="Limite de 20 questions par jour atteinte."
|
||||||
@highlightOrgs="() => {}"
|
@highlightOrgs="() => {}"
|
||||||
@applyHashtag="(tag) => vf.ajouterFiltre('tags', sansDiese(tag))"
|
@applyHashtag="(tag) => vf.ajouterFiltre('tags', sansDiese(tag))"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
/**
|
|
||||||
* POST /api/chatbot-v2
|
|
||||||
*
|
|
||||||
* Chatbot V2 - Embedding-based search sur structures bifurcation
|
|
||||||
* Coexiste avec /api/chatbot (keyword NocoDB) pendant la transition.
|
|
||||||
*
|
|
||||||
* SETUP AVANT DEPLOY :
|
|
||||||
* cd nav-carte && MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js
|
|
||||||
* Coût estimé : ~0.10 EUR pour 120 fiches
|
|
||||||
*
|
|
||||||
* Flow :
|
|
||||||
* 1. Rate limit (réutilise checkRateLimitJson, 10 req/IP/jour)
|
|
||||||
* 2. Embed la query via Mistral Embed (mistral-embed)
|
|
||||||
* 3. Top-5 cosine similarity sur embeddings-v2.json
|
|
||||||
* 4. Si embeddings absents : réponse graceful (v2_ready: false)
|
|
||||||
* 5. Construit contexte RAG depuis les fiches candidates
|
|
||||||
* 6. Génère réponse Mistral Small (json_object)
|
|
||||||
* 7. Retourne { reponse_texte, fiches_recommandees, sources, v2_ready }
|
|
||||||
*
|
|
||||||
* Variables d'env :
|
|
||||||
* MISTRAL_API_KEY - Clé Mistral (partagée avec chatbot v1)
|
|
||||||
* RAG_V1_ENABLED - true/false (défaut: true) - coexistence pendant transition
|
|
||||||
* RAG_V1_DEPRECATION_DATE - Date prévue deprecation v1 (ex: 2026-05-18)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
|
||||||
import { loadEmbeddingsV2, topKSearch } from '~/server/utils/vectorSearch'
|
|
||||||
|
|
||||||
// ── System prompt V2 ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const SYSTEM_PROMPT_V2 = `Tu es un assistant pour la carte des réseaux de bifurcation en architecture (projet AEP).
|
|
||||||
Tu réponds aux questions sur les structures, les pratiques, les pensées écologiques.
|
|
||||||
|
|
||||||
Règles :
|
|
||||||
- Cite chaque structure par son nom exact et son fiche_id
|
|
||||||
- Indique la famille (1-5) entre parenthèses après chaque nom
|
|
||||||
- Reste sobre et descriptif - pas militant agressif
|
|
||||||
- Tirets longs interdits : utilise des - ou des ;
|
|
||||||
- Max 200 mots par réponse
|
|
||||||
- Si hors-scope (pas archi/habiter/écologie), redirige poliment vers la carte
|
|
||||||
- Retourne UNIQUEMENT un JSON valide, sans texte avant ou après
|
|
||||||
|
|
||||||
Familles :
|
|
||||||
1 - Réemploi et filières
|
|
||||||
2 - Frugalité et low-tech
|
|
||||||
3 - Architecture sociale et précarités
|
|
||||||
4 - Collectifs, écolieux et AMO
|
|
||||||
5 - Urbanisme de transition et territoires
|
|
||||||
|
|
||||||
FORMAT DE SORTIE :
|
|
||||||
{
|
|
||||||
"reponse_texte": "Ta réponse en prose (max 200 mots)",
|
|
||||||
"fiches_recommandees": [
|
|
||||||
{ "fiche_id": "f1-rotor", "nom": "Rotor", "explication": "1-2 phrases pourquoi cette fiche" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
CONTEXTE - Structures disponibles :
|
|
||||||
{{CONTEXTE_RAG}}`
|
|
||||||
|
|
||||||
// ── Handler ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
|
|
||||||
// 1. Rate limit
|
|
||||||
const ip =
|
|
||||||
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
|
||||||
event.node.req.socket?.remoteAddress ||
|
|
||||||
'0.0.0.0'
|
|
||||||
|
|
||||||
const allowed = checkRateLimitJson(ip, 'chatbot-v2', 10)
|
|
||||||
if (!allowed) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 429,
|
|
||||||
statusMessage: 'Limite de 10 questions par jour atteinte.'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Validation body
|
|
||||||
const body = await readBody(event)
|
|
||||||
const question: string = (body?.question ?? '').trim()
|
|
||||||
|
|
||||||
if (!question || question.length < 3) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const mistralApiKey = config.mistralApiKey as string
|
|
||||||
if (!mistralApiKey) {
|
|
||||||
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Charger embeddings V2 (lazy, cachés en mémoire)
|
|
||||||
const embeddingsV2 = loadEmbeddingsV2()
|
|
||||||
|
|
||||||
// Graceful fallback si le script vectorize-v2.js n'a pas encore été lancé
|
|
||||||
if (embeddingsV2.length === 0) {
|
|
||||||
return {
|
|
||||||
reponse_texte: "La base vectorielle V2 est en cours de préparation. Merci d'utiliser le chatbot classique en attendant.",
|
|
||||||
fiches_recommandees: [],
|
|
||||||
sources: [],
|
|
||||||
v2_ready: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Embed la query via Mistral Embed
|
|
||||||
let queryEmbedding: number[]
|
|
||||||
try {
|
|
||||||
const embedRes = await $fetch<{ data: { embedding: number[] }[] }>(
|
|
||||||
'https://api.mistral.ai/v1/embeddings',
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${mistralApiKey}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: 'mistral-embed',
|
|
||||||
inputs: [question]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
queryEmbedding = embedRes.data[0].embedding
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('[chatbot-v2] Erreur embedding Mistral :', e?.message ?? e)
|
|
||||||
throw createError({ statusCode: 502, statusMessage: 'Erreur embedding Mistral.' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Top-5 cosine similarity
|
|
||||||
const v2Results = topKSearch(embeddingsV2, queryEmbedding, 5)
|
|
||||||
|
|
||||||
// 6. Contexte RAG
|
|
||||||
const candidatesContext = v2Results.map(r => ({
|
|
||||||
fiche_id: r.fiche_id,
|
|
||||||
nom: r.nom,
|
|
||||||
famille: r.famille,
|
|
||||||
hashtags: r.hashtags,
|
|
||||||
score: r.score,
|
|
||||||
preview: r.text_preview
|
|
||||||
}))
|
|
||||||
|
|
||||||
const contextStr = candidatesContext
|
|
||||||
.map(c => `[${c.fiche_id}] ${c.nom} (famille ${c.famille}, score: ${c.score.toFixed(2)})\n${c.preview}`)
|
|
||||||
.join('\n\n---\n\n')
|
|
||||||
|
|
||||||
const systemPrompt = SYSTEM_PROMPT_V2.replace('{{CONTEXTE_RAG}}', contextStr)
|
|
||||||
|
|
||||||
// 7. Mistral Small - génération réponse
|
|
||||||
let mistralRaw: string
|
|
||||||
try {
|
|
||||||
const mistralRes = 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: 600,
|
|
||||||
response_format: { type: 'json_object' },
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: systemPrompt },
|
|
||||||
{ role: 'user', content: question }
|
|
||||||
]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('[chatbot-v2] Erreur Mistral Small :', e?.message ?? e)
|
|
||||||
throw createError({ statusCode: 502, statusMessage: 'Erreur appel Mistral Small.' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. Parse JSON
|
|
||||||
let parsed: { reponse_texte: string; fiches_recommandees: any[] }
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(mistralRaw)
|
|
||||||
if (!parsed.reponse_texte) throw new Error('reponse_texte absent')
|
|
||||||
} catch {
|
|
||||||
parsed = {
|
|
||||||
reponse_texte: "Impossible d'analyser la réponse.",
|
|
||||||
fiches_recommandees: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
reponse_texte: parsed.reponse_texte,
|
|
||||||
fiches_recommandees: parsed.fiches_recommandees ?? [],
|
|
||||||
sources: candidatesContext,
|
|
||||||
v2_ready: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
/**
|
|
||||||
* Recherche vectorielle sur les embeddings V2
|
|
||||||
* Cosine similarity + top-K
|
|
||||||
*
|
|
||||||
* Utilisé par : server/api/chatbot-v2.post.ts
|
|
||||||
* Données : server/data/embeddings-v2.json (généré par scripts/vectorize-v2.js)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { readFileSync, existsSync } from 'fs'
|
|
||||||
import { fileURLToPath } from 'url'
|
|
||||||
import { resolve, dirname } from 'path'
|
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface EmbeddingEntry {
|
|
||||||
fiche_id: string
|
|
||||||
nom: string
|
|
||||||
famille: number
|
|
||||||
hashtags: string[]
|
|
||||||
embedding: number[]
|
|
||||||
text_preview: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchResult {
|
|
||||||
fiche_id: string
|
|
||||||
nom: string
|
|
||||||
famille: number
|
|
||||||
hashtags: string[]
|
|
||||||
score: number
|
|
||||||
text_preview: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cosine similarity ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function cosineSimilarity(a: number[], b: number[]): number {
|
|
||||||
if (a.length !== b.length) return 0
|
|
||||||
let dot = 0, normA = 0, normB = 0
|
|
||||||
for (let i = 0; i < a.length; i++) {
|
|
||||||
dot += a[i] * b[i]
|
|
||||||
normA += a[i] * a[i]
|
|
||||||
normB += b[i] * b[i]
|
|
||||||
}
|
|
||||||
const denom = Math.sqrt(normA) * Math.sqrt(normB)
|
|
||||||
return denom === 0 ? 0 : dot / denom
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Top-K search ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function topKSearch(
|
|
||||||
embeddings: EmbeddingEntry[],
|
|
||||||
queryEmbedding: number[],
|
|
||||||
k: number = 5
|
|
||||||
): SearchResult[] {
|
|
||||||
return embeddings
|
|
||||||
.map(e => ({
|
|
||||||
fiche_id: e.fiche_id,
|
|
||||||
nom: e.nom,
|
|
||||||
famille: e.famille,
|
|
||||||
hashtags: e.hashtags,
|
|
||||||
score: cosineSimilarity(e.embedding, queryEmbedding),
|
|
||||||
text_preview: e.text_preview
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.score - a.score)
|
|
||||||
.slice(0, k)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Chargement lazy des embeddings (cache module-level) ────────────────────────
|
|
||||||
|
|
||||||
let _embeddingsV2: EmbeddingEntry[] | null = null
|
|
||||||
|
|
||||||
export function loadEmbeddingsV2(): EmbeddingEntry[] {
|
|
||||||
if (_embeddingsV2 !== null) return _embeddingsV2
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Résolution du chemin depuis server/utils/ vers server/data/
|
|
||||||
const currentDir = dirname(fileURLToPath(import.meta.url))
|
|
||||||
const embPath = resolve(currentDir, '..', 'data', 'embeddings-v2.json')
|
|
||||||
|
|
||||||
if (!existsSync(embPath)) {
|
|
||||||
console.warn('[vectorSearch] embeddings-v2.json absent - V2 vector search désactivé')
|
|
||||||
console.warn('[vectorSearch] Lancer : MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js')
|
|
||||||
_embeddingsV2 = []
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = readFileSync(embPath, 'utf-8')
|
|
||||||
const data = JSON.parse(raw)
|
|
||||||
_embeddingsV2 = data.embeddings ?? []
|
|
||||||
console.log(`[vectorSearch] ${_embeddingsV2!.length} embeddings V2 chargés (${data.meta?.model ?? 'unknown'})`)
|
|
||||||
return _embeddingsV2!
|
|
||||||
} catch (e: any) {
|
|
||||||
console.warn('[vectorSearch] Erreur chargement embeddings-v2.json :', e?.message ?? e)
|
|
||||||
_embeddingsV2 = []
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user