wip: snapshot V2 cascade onglet 2 (sauvegarde avant chirurgie git-hygiene)

This commit is contained in:
Jules Neny
2026-05-06 15:37:13 +02:00
parent 5878c56888
commit e63d02a351
101 changed files with 188900 additions and 3959 deletions

View 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'
}
})

View File

@@ -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
})

View 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
}
})

View File

@@ -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({

View File

@@ -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' })
}
})

View File

@@ -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 !' }
})

View File

@@ -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,
}
})

View File

@@ -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
View File

124442
server/data/embeddings-v2.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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
})

View File

@@ -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
})

View File

@@ -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
})

View File

@@ -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
})

View File

@@ -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,

View 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 []
}
}