wip: snapshot V2 cascade onglet 2 (sauvegarde avant chirurgie git-hygiene)
This commit is contained in:
39
server/api/admin/rag-info.get.ts
Normal file
39
server/api/admin/rag-info.get.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* GET /api/admin/rag-info
|
||||
*
|
||||
* Retourne le statut du système RAG (v1 + v2) pour la page /admin/rag-status
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineEventHandler(async (_event) => {
|
||||
// Statut V2 : compter les embeddings
|
||||
let v2Count = 0
|
||||
let v2Date: string | null = null
|
||||
let v2Model: string | null = null
|
||||
|
||||
try {
|
||||
// Chercher depuis process.cwd() (racine du projet Nuxt)
|
||||
const embPath = resolve(process.cwd(), 'server', 'data', 'embeddings-v2.json')
|
||||
if (existsSync(embPath)) {
|
||||
const data = JSON.parse(readFileSync(embPath, 'utf-8'))
|
||||
v2Count = data.embeddings?.length ?? 0
|
||||
v2Date = data.meta?.date ?? null
|
||||
v2Model = data.meta?.model ?? null
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('[rag-info] Erreur lecture embeddings-v2.json :', e?.message ?? e)
|
||||
}
|
||||
|
||||
return {
|
||||
v2_embeddings_count: v2Count,
|
||||
v2_ready: v2Count > 0,
|
||||
v2_model: v2Model ?? 'mistral-embed',
|
||||
v2_generated_date: v2Date ?? null,
|
||||
v1_enabled: process.env.RAG_V1_ENABLED !== 'false',
|
||||
v1_deprecation_date: process.env.RAG_V1_DEPRECATION_DATE ?? 'non défini',
|
||||
model_chat: 'mistral-small-latest',
|
||||
setup_command: 'MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js'
|
||||
}
|
||||
})
|
||||
@@ -1,304 +0,0 @@
|
||||
/**
|
||||
* POST /api/chatbot-pratiques
|
||||
*
|
||||
* Chatbot semantique sur la base des pratiques regeneratives (JSON statique).
|
||||
* Adapte du endpoint /api/chatbot (ecosysteme AEP NocoDB).
|
||||
*
|
||||
* Flow :
|
||||
* 1. Rate limit : 10 req/IP/jour (JSON fichier, SHA-256)
|
||||
* 2. Circuit breaker : budget 20 EUR/mois partage avec /api/chatbot
|
||||
* 3. Lit public/data/pratiques-regeneratives.json (52 fiches V1)
|
||||
* 4. Score keyword puis top 20 fiches en contexte
|
||||
* 5. Appel Mistral Small avec prompt systeme adapte aux pratiques
|
||||
* 6. Parse JSON -> { reponse_texte, fiches_recommandees }
|
||||
* 7. Log stats_usage
|
||||
*
|
||||
* Reponse 200 : { reponse_texte, fiches_recommandees: [{ id, nom, explication }] }
|
||||
* Reponse 429 : rate limit depasse
|
||||
* Reponse 503 : budget IA epuise
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
||||
import { CRITERES, TYPES_ENTITE_LABELS, PAYS_LABELS } from '~/types/pratique'
|
||||
import type { Pratique } from '~/types/pratique'
|
||||
|
||||
interface FicheReco {
|
||||
id: number
|
||||
nom: string
|
||||
explication: string
|
||||
}
|
||||
|
||||
interface MistralResponse {
|
||||
reponse_texte: string
|
||||
fiches_recommandees: FicheReco[]
|
||||
}
|
||||
|
||||
// Prompt systeme dedie aux pratiques regeneratives.
|
||||
// Difference avec /api/chatbot : on parle de pratiques, criteres rege (8 axes),
|
||||
// types d'entites (agence, cooperative, collectif...), perimetre Europe + DOM-TOM.
|
||||
const SYSTEM_PROMPT = `Tu es un assistant engage au service de la transition ecologique des pratiques architecturales. Tu accedes a la base AEP - Pratiques regeneratives, qui referencee les acteurs concrets de l'architecture regenerative en Europe et dans les DOM-TOM (agences, cooperatives, collectifs, reseaux, associations, plateformes, recherche).
|
||||
|
||||
CRITERES DE REGENERATION (8 axes utilises pour decrire chaque pratique) :
|
||||
1. Materiaux (biosources, geosources, reemploi)
|
||||
2. Filieres (locales, courtes, paysannes)
|
||||
3. Posture (ethique, engagement politique, refus)
|
||||
4. Process (collaboratif, participatif, lent)
|
||||
5. Politique (lobbying, plaidoyer, contre-expertise)
|
||||
6. Modele economique (cooperatif, low-tech, soutenable)
|
||||
7. Vivant (biodiversite, sols, eau)
|
||||
8. Transmission (formation, partage, pedagogie)
|
||||
|
||||
REGLES ABSOLUES :
|
||||
1. Tu ne peux recommander QUE des pratiques presentes dans le contexte ci-dessous.
|
||||
2. Ne jamais inventer une pratique absente du contexte.
|
||||
3. Cite chaque pratique recommandee par son nom exact et son identifiant id.
|
||||
4. Si le contexte ne contient aucune pratique pertinente, dis-le honnetement.
|
||||
5. Reponses concises (200 mots max). Si l'usager demande explicitement plus de detail, tu peux developper.
|
||||
6. Retourne UNIQUEMENT un objet JSON valide, sans texte avant ou apres.
|
||||
7. Si la question est hors du champ architecture / ecologie / regeneration / territoire, recadre poliment.
|
||||
|
||||
FORMAT DE SORTIE :
|
||||
{
|
||||
"reponse_texte": "Ta reponse en prose (max 200 mots)",
|
||||
"fiches_recommandees": [
|
||||
{ "id": 12, "explication": "Pourquoi cette pratique repond a la question (1-2 phrases max)" }
|
||||
]
|
||||
}
|
||||
|
||||
CONTEXTE - Pratiques regeneratives disponibles :
|
||||
{{FICHES_JSON}}`
|
||||
|
||||
function scorePratique(p: Pratique, keywords: string[]): number {
|
||||
if (keywords.length === 0) return 1
|
||||
const critereLabels = (p.criteres ?? [])
|
||||
.map((cId) => CRITERES.find((c) => c.id === cId)?.label ?? '')
|
||||
.join(' ')
|
||||
const haystack = [
|
||||
p.nom,
|
||||
p.description,
|
||||
p.ville,
|
||||
p.type ? (TYPES_ENTITE_LABELS[p.type] ?? p.type) : '',
|
||||
p.pays ? (PAYS_LABELS[p.pays] ?? p.pays) : '',
|
||||
critereLabels,
|
||||
(p.tags ?? []).join(' '),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return keywords.reduce((score, kw) => score + (haystack.includes(kw) ? 1 : 0), 0)
|
||||
}
|
||||
|
||||
function extractKeywords(question: string): string[] {
|
||||
return question
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\sàâäéèêëîïôùûüç-]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length >= 3)
|
||||
.slice(0, 10)
|
||||
}
|
||||
|
||||
function loadPratiques(): Pratique[] {
|
||||
try {
|
||||
const jsonPath = resolve(process.cwd(), 'public/data/pratiques-regeneratives.json')
|
||||
const raw = readFileSync(jsonPath, 'utf-8')
|
||||
return JSON.parse(raw) as Pratique[]
|
||||
} catch (e) {
|
||||
console.error('[chatbot-pratiques] Erreur lecture JSON:', (e as Error).message)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function logUsage(params: {
|
||||
nocodbUrl: string
|
||||
nocodbToken: string
|
||||
statsTableId: string
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
coutEur: number
|
||||
}) {
|
||||
const { nocodbUrl, nocodbToken, statsTableId, tokensIn, tokensOut, coutEur } = params
|
||||
const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
||||
try {
|
||||
await $fetch(logUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'mistral-small-latest',
|
||||
endpoint: 'chatbot-pratiques',
|
||||
tokens_in: tokensIn,
|
||||
tokens_out: tokensOut,
|
||||
cout_eur: coutEur,
|
||||
timestamp: new Date().toISOString(),
|
||||
orga_id: null,
|
||||
}),
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[chatbot-pratiques] Log stats_usage echoue (non bloquant):', (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
// 1. IP (proxy-aware)
|
||||
const ip =
|
||||
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
||||
event.node.req.socket?.remoteAddress ||
|
||||
'0.0.0.0'
|
||||
|
||||
// 2. Rate limit : 10 req/IP/jour (compteur dedie chatbot-pratiques)
|
||||
const allowed = checkRateLimitJson(ip, 'chatbot-pratiques', 10)
|
||||
if (!allowed) {
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
statusMessage: 'Limite de 10 questions par jour atteinte.',
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Lire le 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.',
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Circuit breaker budget partage
|
||||
const statsTableId = process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc'
|
||||
const budget = await checkBudget({
|
||||
nocodbUrl: config.nocodbUrl as string,
|
||||
nocodbToken: config.nocodbToken as string,
|
||||
statsTableId,
|
||||
})
|
||||
|
||||
if (budget.blocked) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: 'Budget IA mensuel epuise - reouverture le 1er du mois prochain.',
|
||||
})
|
||||
}
|
||||
|
||||
// 5. Charger pratiques + scoring
|
||||
const allPratiques = loadPratiques()
|
||||
if (allPratiques.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: 'Donnees pratiques indisponibles.',
|
||||
})
|
||||
}
|
||||
|
||||
const keywords = extractKeywords(question)
|
||||
|
||||
const scored = allPratiques
|
||||
.map((p) => ({ pratique: p, score: scorePratique(p, keywords) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 20)
|
||||
.map((x) => x.pratique)
|
||||
|
||||
const fichesContext = scored.map((p) => ({
|
||||
id: p.id,
|
||||
nom: p.nom,
|
||||
type: p.type ? (TYPES_ENTITE_LABELS[p.type] ?? p.type) : '',
|
||||
pays: p.pays ? (PAYS_LABELS[p.pays] ?? p.pays) : '',
|
||||
ville: p.ville ?? '',
|
||||
criteres: (p.criteres ?? [])
|
||||
.map((cId) => CRITERES.find((c) => c.id === cId)?.label ?? '')
|
||||
.filter(Boolean),
|
||||
description: (p.description ?? '').slice(0, 250),
|
||||
tags: (p.tags ?? []).slice(0, 5),
|
||||
}))
|
||||
|
||||
const systemPrompt = SYSTEM_PROMPT.replace(
|
||||
'{{FICHES_JSON}}',
|
||||
JSON.stringify(fichesContext, null, 0),
|
||||
)
|
||||
|
||||
// 6. Appel Mistral Small
|
||||
const mistralApiKey = config.mistralApiKey as string
|
||||
|
||||
if (!mistralApiKey) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Cle API Mistral manquante.',
|
||||
})
|
||||
}
|
||||
|
||||
let mistralRaw: string
|
||||
let tokensIn = 0
|
||||
let tokensOut = 0
|
||||
|
||||
try {
|
||||
const mistralRes = await $fetch<{
|
||||
choices: { message: { content: string } }[]
|
||||
usage?: { prompt_tokens: number; completion_tokens: number }
|
||||
}>('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 ?? '{}'
|
||||
tokensIn = mistralRes.usage?.prompt_tokens ?? 0
|
||||
tokensOut = mistralRes.usage?.completion_tokens ?? 0
|
||||
} catch (e: any) {
|
||||
console.error('[chatbot-pratiques] Erreur Mistral Small:', e?.message ?? e)
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: 'Erreur appel IA - reessaie dans quelques instants.',
|
||||
})
|
||||
}
|
||||
|
||||
// 7. Parse JSON
|
||||
let parsed: MistralResponse
|
||||
try {
|
||||
const raw = JSON.parse(mistralRaw)
|
||||
parsed = {
|
||||
reponse_texte: raw.reponse_texte ?? "Je n'ai pas pu analyser ta demande.",
|
||||
fiches_recommandees: (raw.fiches_recommandees ?? []).map((f: any) => {
|
||||
const p = scored.find((x) => x.id === f.id)
|
||||
return {
|
||||
id: f.id,
|
||||
nom: p?.nom ?? f.nom ?? `Fiche #${f.id}`,
|
||||
explication: f.explication ?? '',
|
||||
}
|
||||
}),
|
||||
}
|
||||
} catch {
|
||||
parsed = {
|
||||
reponse_texte: "Je n'ai pas pu analyser ta demande correctement.",
|
||||
fiches_recommandees: [],
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Log usage (non bloquant)
|
||||
const coutEur = calcCoutMistralSmall(tokensIn, tokensOut)
|
||||
logUsage({
|
||||
nocodbUrl: config.nocodbUrl as string,
|
||||
nocodbToken: config.nocodbToken as string,
|
||||
statsTableId,
|
||||
tokensIn,
|
||||
tokensOut,
|
||||
coutEur,
|
||||
})
|
||||
|
||||
return parsed
|
||||
})
|
||||
194
server/api/chatbot-v2.post.ts
Normal file
194
server/api/chatbot-v2.post.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
})
|
||||
@@ -107,7 +107,8 @@ async function fetchApprovedOrgs(config: {
|
||||
orgTableId: string
|
||||
}): Promise<OrgRow[]> {
|
||||
const { nocodbUrl, nocodbToken, orgTableId } = config
|
||||
const url = `${nocodbUrl}/api/v2/tables/${orgTableId}/records`
|
||||
const nocoBaseId = process.env.NOCODB_BASE_ID || 'p_nav_v2'
|
||||
const url = `${nocodbUrl}/api/v1/db/data/noco/${nocoBaseId}/${orgTableId}`
|
||||
|
||||
try {
|
||||
const res = await $fetch<{ list: OrgRow[] }>(url, {
|
||||
@@ -136,9 +137,11 @@ async function logUsage(params: {
|
||||
coutEur: number
|
||||
}) {
|
||||
const { nocodbUrl, nocodbToken, statsTableId, tokensIn, tokensOut, coutEur } = params
|
||||
const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
||||
const nocoBaseId = process.env.NOCODB_BASE_ID || 'p_nav_v2'
|
||||
const url = `${nocodbUrl}/api/v1/db/data/noco/${nocoBaseId}/${statsTableId}`
|
||||
|
||||
try {
|
||||
await $fetch(logUrl, {
|
||||
await $fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import type { Pratique } from '~/types/pratique'
|
||||
|
||||
/**
|
||||
* GET /api/pratiques
|
||||
* Lit public/data/pratiques-regeneratives.json
|
||||
* Retourne { list: Pratique[], source: 'static' }
|
||||
*/
|
||||
export default defineEventHandler(async (_event) => {
|
||||
try {
|
||||
const jsonPath = resolve(process.cwd(), 'public/data/pratiques-regeneratives.json')
|
||||
const raw = readFileSync(jsonPath, 'utf-8')
|
||||
const list: Pratique[] = JSON.parse(raw)
|
||||
return { list, source: 'static' }
|
||||
} catch (err) {
|
||||
console.error('[PRATIQUES API] Erreur lecture JSON:', err)
|
||||
throw createError({ statusCode: 503, message: 'Données pratiques-regeneratives indisponibles' })
|
||||
}
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* POST /api/report-general
|
||||
*
|
||||
* Signalement général (bug, contenu inapproprié, suggestion)
|
||||
*
|
||||
* Body : { category: string, description: string, email?: string }
|
||||
* Rate limit : 5/IP/jour
|
||||
* Envoi vers jules@trans-former.fr via Resend API
|
||||
*/
|
||||
|
||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||
|
||||
const EMAIL_JULES = process.env.EMAIL_JULES || 'jules@trans-former.fr'
|
||||
|
||||
const VALID_CATEGORIES = ['Une fiche', 'Le chatbot', 'La carte', 'Autre'] as const
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 1. IP
|
||||
const ip =
|
||||
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
||||
event.node.req.socket?.remoteAddress ||
|
||||
'0.0.0.0'
|
||||
|
||||
// 2. Rate limit 5/IP/jour
|
||||
const allowed = checkRateLimitJson(ip, 'report-general', 5)
|
||||
if (!allowed) {
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
statusMessage: 'Limite de 5 signalements par jour atteinte.',
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Lire le body
|
||||
const body = await readBody(event)
|
||||
const category: string = (body?.category ?? '').trim()
|
||||
const description: string = (body?.description ?? '').trim()
|
||||
const email: string = (body?.email ?? '').trim()
|
||||
|
||||
// 4. Validation
|
||||
if (!VALID_CATEGORIES.includes(category as any)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Catégorie invalide.' })
|
||||
}
|
||||
if (!description || description.length < 5 || description.length > 500) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Description requise (5-500 caractères).' })
|
||||
}
|
||||
if (email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(email)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Email invalide.' })
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Envoi via Resend
|
||||
const resendApiKey = process.env.RESEND_API_KEY
|
||||
if (!resendApiKey) {
|
||||
console.error('[report-general] RESEND_API_KEY manquante')
|
||||
throw createError({ statusCode: 500, statusMessage: 'Configuration email manquante.' })
|
||||
}
|
||||
|
||||
const submittedAt = new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })
|
||||
|
||||
try {
|
||||
await $fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${resendApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: 'AEP Signalement <noreply@trans-former.fr>',
|
||||
to: EMAIL_JULES,
|
||||
subject: `[AEP] Signalement — ${category}`,
|
||||
html: `
|
||||
<h2>Signalement AEP — ${category}</h2>
|
||||
<p><strong>Date :</strong> ${submittedAt}</p>
|
||||
<p><strong>Catégorie :</strong> ${category}</p>
|
||||
${email ? `<p><strong>Email expéditeur :</strong> ${email}</p>` : '<p><em>Pas d\'email fourni</em></p>'}
|
||||
<p><strong>Description :</strong></p>
|
||||
<blockquote style="border-left:3px solid #ccc;padding-left:12px;color:#555;">
|
||||
${description.replace(/\n/g, '<br/>')}
|
||||
</blockquote>
|
||||
`,
|
||||
}),
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error('[report-general] Erreur Resend:', e?.message ?? e)
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: 'Erreur envoi email — réessaie dans quelques instants.',
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true, message: 'Signalement envoyé, merci !' }
|
||||
})
|
||||
@@ -1,117 +0,0 @@
|
||||
// Modération : Jules consulte public/data/pratiques-pending.json,
|
||||
// déplace les entrées validées dans public/data/pratiques-regeneratives.json,
|
||||
// supprime de pending. À automatiser en V2 (UI admin).
|
||||
|
||||
import { z } from 'zod'
|
||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// ── Schéma Zod (miroir du client) ─────────────────────────────────────────────
|
||||
|
||||
export const PratiqueSubmitSchema = z.object({
|
||||
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
||||
url: z
|
||||
.string()
|
||||
.url('URL invalide (commencer par https://)')
|
||||
.optional()
|
||||
.or(z.literal(''))
|
||||
.transform((v) => v || undefined),
|
||||
description_user: z
|
||||
.string()
|
||||
.min(50, 'Minimum 50 caractères')
|
||||
.max(500, 'Maximum 500 caractères')
|
||||
.trim(),
|
||||
criteres: z
|
||||
.array(z.number().int().min(1).max(8))
|
||||
.min(3, 'Sélectionne au moins 3 critères')
|
||||
.max(8, 'Maximum 8 critères'),
|
||||
pays: z.string().length(2, 'Code pays invalide').or(z.literal('AUTRE')),
|
||||
pays_autre: z.string().max(50).optional(),
|
||||
ville: z.string().max(100).optional().transform((v) => v?.trim() || undefined),
|
||||
type: z.enum([
|
||||
'agence', 'cooperative', 'collectif', 'reseau', 'asso',
|
||||
'recherche', 'mouvement', 'plateforme', 'inconnu',
|
||||
], { errorMap: () => ({ message: 'Type d\'entité invalide' }) }),
|
||||
tags: z.array(z.string().max(30)).max(6).optional(),
|
||||
submitted_by_email: z
|
||||
.string()
|
||||
.email('Email invalide')
|
||||
.optional()
|
||||
.or(z.literal(''))
|
||||
.transform((v) => v || undefined),
|
||||
})
|
||||
|
||||
export type PratiqueSubmitInput = z.infer<typeof PratiqueSubmitSchema>
|
||||
|
||||
// ── Chemin du fichier pending ─────────────────────────────────────────────────
|
||||
|
||||
function getPendingPath(): string {
|
||||
return resolve(process.cwd(), 'public/data/pratiques-pending.json')
|
||||
}
|
||||
|
||||
function readPending(): PratiqueSubmitInput[] {
|
||||
const path = getPendingPath()
|
||||
try {
|
||||
if (!existsSync(path)) return []
|
||||
return JSON.parse(readFileSync(path, 'utf-8'))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writePending(entries: unknown[]) {
|
||||
writeFileSync(getPendingPath(), JSON.stringify(entries, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
// ── Handler principal ─────────────────────────────────────────────────────────
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 1. Récupérer l'IP (proxy-aware)
|
||||
const ip =
|
||||
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
||||
event.node.req.socket?.remoteAddress ||
|
||||
'0.0.0.0'
|
||||
|
||||
// 2. Rate limit JSON : 3 soumissions / IP / jour
|
||||
const allowed = checkRateLimitJson(ip, 'submit-pratique', 3)
|
||||
if (!allowed) {
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
statusMessage: 'Trop de soumissions. Réessaie demain.',
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Lire et valider le body
|
||||
const body = await readBody(event)
|
||||
const parsed = PratiqueSubmitSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
throw createError({
|
||||
statusCode: 422,
|
||||
statusMessage: 'Validation échouée',
|
||||
data: parsed.error.flatten().fieldErrors,
|
||||
})
|
||||
}
|
||||
|
||||
const data = parsed.data
|
||||
|
||||
// 4. Construire l'entrée pending
|
||||
const timestamp = Date.now()
|
||||
const entry = {
|
||||
...data,
|
||||
id: timestamp,
|
||||
submitted_at: new Date().toISOString(),
|
||||
moderation_status: 'pending' as const,
|
||||
}
|
||||
|
||||
// 5. Append à pratiques-pending.json
|
||||
const pending = readPending()
|
||||
pending.push(entry)
|
||||
writePending(pending)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
trackingId: timestamp,
|
||||
}
|
||||
})
|
||||
@@ -145,7 +145,8 @@ export default defineEventHandler(async (event) => {
|
||||
const nocodbUrl = config.nocodbUrl
|
||||
const nocodbToken = config.nocodbToken
|
||||
const orgTableId = config.orgTableId
|
||||
const nocoBaseId = config.nocodbBase
|
||||
|
||||
const nocoBaseId = process.env.NOCODB_BASE_ID || 'p_nav_v2'
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
nom: data.nom,
|
||||
@@ -187,11 +188,6 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const submissionId = insertedRecord?.Id ?? insertedRecord?.id ?? null
|
||||
|
||||
// 6. Notification email Jules (fire-and-forget — n'impacte pas la réponse)
|
||||
notifyJules(config, data, submissionId).catch((e) =>
|
||||
console.warn('[submit] Email notification échouée:', e?.message ?? e),
|
||||
)
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
submissionId,
|
||||
@@ -201,41 +197,3 @@ export default defineEventHandler(async (event) => {
|
||||
: null,
|
||||
}
|
||||
})
|
||||
|
||||
async function notifyJules(
|
||||
config: ReturnType<typeof useRuntimeConfig>,
|
||||
data: SubmitInput,
|
||||
submissionId: number | null,
|
||||
): Promise<void> {
|
||||
if (!config.resendApiKey) return
|
||||
|
||||
const nocoAdminUrl = `http://localhost:8070`
|
||||
const body = {
|
||||
from: 'AEP Carte <contact@trans-former.fr>',
|
||||
to: [config.emailJules],
|
||||
subject: `[AEP] Nouvelle fiche soumise : ${data.nom}`,
|
||||
html: `
|
||||
<p><strong>Nouvelle fiche en attente de modération.</strong></p>
|
||||
<ul>
|
||||
<li><strong>Nom :</strong> ${data.nom}</li>
|
||||
<li><strong>URL :</strong> ${data.url ?? '—'}</li>
|
||||
<li><strong>Échelle :</strong> ${data.echelle}</li>
|
||||
<li><strong>Territoire :</strong> ${data.territoire}</li>
|
||||
<li><strong>Fonctions :</strong> ${data.fonctions.join(', ')}</li>
|
||||
<li><strong>Description :</strong> ${data.description_user}</li>
|
||||
${data.submitted_by_email ? `<li><strong>Email soumetteur :</strong> ${data.submitted_by_email}</li>` : ''}
|
||||
${submissionId ? `<li><strong>ID NocoDB :</strong> ${submissionId}</li>` : ''}
|
||||
</ul>
|
||||
<p><a href="${nocoAdminUrl}">Ouvrir NocoDB pour valider</a></p>
|
||||
`,
|
||||
}
|
||||
|
||||
await $fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.resendApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
0
server/data/.gitkeep
Normal file
0
server/data/.gitkeep
Normal file
124442
server/data/embeddings-v2.json
Normal file
124442
server/data/embeddings-v2.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const body = await readBody(event)
|
||||
const url = `${config.nocodbUrl}/api/v2/tables/${config.avisTableId}/records`
|
||||
|
||||
const data = await $fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'xc-token': config.nocodbToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
return data
|
||||
})
|
||||
@@ -1,13 +0,0 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const orgId = getRouterParam(event, 'orgId')
|
||||
const url = `${config.nocodbUrl}/api/v2/tables/${config.avisTableId}/records?where=(organisation_id,eq,${orgId})~and(status,eq,approved)`
|
||||
|
||||
const data = await $fetch(url, {
|
||||
headers: {
|
||||
'xc-token': config.nocodbToken,
|
||||
},
|
||||
})
|
||||
|
||||
return data
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const body = await readBody(event)
|
||||
const url = `${config.nocodbUrl}/api/v2/tables/${config.orgTableId}/records`
|
||||
|
||||
const data = await $fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'xc-token': config.nocodbToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
return data
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const id = getRouterParam(event, 'id')
|
||||
const url = `${config.nocodbUrl}/api/v2/tables/${config.orgTableId}/records?where=(Id,eq,${id})&limit=1`
|
||||
|
||||
const data: any = await $fetch(url, {
|
||||
headers: {
|
||||
'xc-token': config.nocodbToken,
|
||||
},
|
||||
})
|
||||
|
||||
const record = data?.list?.[0] ?? null
|
||||
if (!record) {
|
||||
throw createError({ statusCode: 404, message: 'Organisation non trouvée' })
|
||||
}
|
||||
return record
|
||||
})
|
||||
@@ -38,8 +38,8 @@ export async function checkBudget(config: {
|
||||
const monthStartIso = monthStart.toISOString()
|
||||
|
||||
try {
|
||||
// Fetch toutes les entrées du mois courant (NocoDB v2)
|
||||
const url = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
||||
// Fetch toutes les entrées du mois courant (limit 1000 — suffisant pour budget MVP)
|
||||
const url = `${nocodbUrl}/api/v1/db/data/noco/${process.env.NOCODB_BASE_ID || 'p_nav_v2'}/${statsTableId}`
|
||||
|
||||
const res = await $fetch<{ list: { cout_eur: number | null; timestamp: string }[] }>(
|
||||
url,
|
||||
|
||||
96
server/utils/vectorSearch.ts
Normal file
96
server/utils/vectorSearch.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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 : process.cwd() pointe sur la racine projet en dev/prod Nitro
|
||||
// (import.meta.url casse en bundle .nuxt compilé)
|
||||
const embPath = resolve(process.cwd(), 'server', '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