fix(aep): chatbot RAG pensées + config ragPeUrl + layout media + a-propos

Composant + route chatbot non committés, runtimeConfig.ragPeUrl,
ajustements layout MediaTabVisuel, contenu a-propos.
This commit is contained in:
Jules Neny
2026-06-23 11:15:54 +02:00
parent cd8fe9e258
commit f3ab28bd69
6 changed files with 680 additions and 4 deletions

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ dist
*.log *.log
*.tmp.* *.tmp.*
.firecrawl/

View File

@@ -0,0 +1,448 @@
<template>
<div class="chatbot-pensees" :class="{ inline: inline }">
<!-- Header compact -->
<div class="chatbot-header">
<div class="chatbot-header-left">
<div class="chatbot-icon">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</div>
<span class="chatbot-title">
<template v-if="auteurContext">{{ auteurContext }}</template>
<template v-else>Bibliothèque des pensées</template>
</span>
<span v-if="auteurContext" class="chatbot-corpus-badge">auteur</span>
<span v-else class="chatbot-corpus-badge">FRACAS</span>
</div>
<div class="chatbot-header-right">
<button
v-if="messages.length > 0"
class="chatbot-clear-btn"
title="Effacer la conversation"
@click="clearMessages"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>
</svg>
</button>
<select v-model="corpusMode" class="chatbot-corpus-select" title="Corpus interrogé">
<option value="pensees">Pensées éco.</option>
<option value="both">Pensées + Projets</option>
<option value="projets">Projets</option>
</select>
</div>
</div>
<!-- Zone messages -->
<div ref="messagesContainer" class="chatbot-messages">
<!-- Onboarding -->
<div v-if="messages.length === 0" class="chatbot-onboarding">
<template v-if="auteurContext">
<p>Interroge le corpus de <strong>{{ auteurContext }}</strong> ses livres, ses thèses, ses arguments.</p>
<p class="chatbot-onboarding-hint">Ex : "Quelle est sa critique du capitalisme vert ?"</p>
</template>
<template v-else>
<p>Bibliothèque des pensées écologiques <strong>~140 auteurs FRACAS</strong>, de l'écosocialisme à l'éthique environnementale.</p>
<p class="chatbot-onboarding-hint">Ex : "Quelles différences entre décroissance et doughnut économics ?"</p>
</template>
</div>
<!-- Messages -->
<template v-for="(msg, i) in messages" :key="i">
<div v-if="msg.role === 'user'" class="chatbot-bubble user">{{ msg.content }}</div>
<div v-else class="chatbot-bubble assistant">
<div class="chatbot-md" v-html="renderMd(msg.content)" />
<div v-if="msg.auteur" class="chatbot-auteur-tag">
<span>{{ msg.auteur.nom }}</span>
</div>
</div>
</template>
<!-- Chargement -->
<div v-if="loading" class="chatbot-bubble assistant loading">
<span class="dot" /><span class="dot" /><span class="dot" />
</div>
<!-- Erreur -->
<div v-if="errorMsg" class="chatbot-error">{{ errorMsg }}</div>
</div>
<!-- Input -->
<div class="chatbot-input-row">
<input
v-model="inputText"
type="text"
:disabled="loading"
:placeholder="auteurContext ? `Interroger ${auteurContext}…` : 'Pose ta question sur les pensées éco…'"
class="chatbot-input"
@keydown.enter.prevent="sendMessage"
/>
<button
:disabled="loading || !inputText.trim()"
class="chatbot-send-btn"
aria-label="Envoyer"
@click="sendMessage"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useMarkdown } from '~/composables/useMarkdown'
const { render: renderMd } = useMarkdown()
interface ChatMessage {
role: 'user' | 'assistant'
content: string
auteur?: { slug: string; nom: string } | null
}
const props = defineProps<{
auteurContext: string | null
inline?: boolean
}>()
const messages = ref<ChatMessage[]>([])
const inputText = ref('')
const loading = ref(false)
const errorMsg = ref('')
const corpusMode = ref<'pensees' | 'projets' | 'both'>('pensees')
const messagesContainer = ref<HTMLElement | null>(null)
watch(() => props.auteurContext, () => {
messages.value = []
errorMsg.value = ''
})
function clearMessages() {
messages.value = []
errorMsg.value = ''
}
function scrollToBottom() {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
async function sendMessage() {
const query = inputText.value.trim()
if (!query || loading.value) return
inputText.value = ''
errorMsg.value = ''
messages.value.push({ role: 'user', content: query })
loading.value = true
await nextTick()
scrollToBottom()
try {
const res = await $fetch<{
response: string
auteur: { slug: string; nom: string } | null
}>('/api/chatbot-pensees', {
method: 'POST',
body: {
query,
corpus: corpusMode.value,
auteur_slug: props.auteurContext ? slugify(props.auteurContext) : undefined,
},
})
messages.value.push({
role: 'assistant',
content: res.response,
auteur: res.auteur ?? null,
})
} catch (e: any) {
const status = e?.statusCode ?? e?.status
if (status === 429) {
errorMsg.value = 'Limite de 20 questions par jour atteinte.'
} else if (status === 503) {
errorMsg.value = 'Le RAG est indisponible pour l\'instant — réessaie dans quelques minutes.'
} else if (status === 504) {
errorMsg.value = 'Le RAG met du temps à répondre — réessaie dans quelques secondes.'
} else {
errorMsg.value = 'Une erreur est survenue. Réessaie dans quelques instants.'
}
} finally {
loading.value = false
await nextTick()
scrollToBottom()
}
}
function slugify(str: string): string {
return str.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
</script>
<style scoped>
.chatbot-pensees {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--nav-bg);
}
/* Header */
.chatbot-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 12px;
background: var(--nav-surface);
border-bottom: 1px solid rgba(180, 170, 160, 0.22);
min-height: 36px;
}
.chatbot-header-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
}
.chatbot-icon {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--nav-primary);
display: flex;
align-items: center;
justify-content: center;
color: var(--nav-text-on-primary);
}
.chatbot-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--nav-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
.chatbot-corpus-badge {
flex-shrink: 0;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--nav-text-muted);
background: var(--nav-bg-alt);
border-radius: 4px;
padding: 1px 5px;
}
.chatbot-header-right {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.chatbot-clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
background: transparent;
color: var(--nav-text-muted);
border: none;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.chatbot-clear-btn:hover {
background: var(--nav-bg-alt);
color: var(--nav-text);
}
.chatbot-corpus-select {
font-size: 0.7rem;
padding: 2px 4px;
border-radius: 4px;
border: 1px solid rgba(180, 170, 160, 0.3);
background: var(--nav-bg-alt);
color: var(--nav-text-muted);
cursor: pointer;
max-width: 110px;
}
/* Messages */
.chatbot-messages {
flex: 1;
overflow-y: auto;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.chatbot-onboarding {
font-size: 0.8rem;
line-height: 1.55;
color: var(--nav-text-muted);
background: var(--nav-bg-alt);
border-radius: 8px;
padding: 10px 12px;
}
.chatbot-onboarding p { margin: 0 0 6px; }
.chatbot-onboarding p:last-child { margin-bottom: 0; }
.chatbot-onboarding strong { color: var(--nav-text); font-weight: 600; }
.chatbot-onboarding-hint { font-style: italic; font-size: 0.75rem; opacity: 0.75; }
/* Bulles */
.chatbot-bubble {
max-width: 88%;
font-size: 0.825rem;
line-height: 1.55;
border-radius: 10px;
padding: 8px 11px;
}
.chatbot-bubble.user {
align-self: flex-end;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border-radius: 10px 10px 3px 10px;
}
.chatbot-bubble.assistant {
align-self: flex-start;
background: var(--nav-surface);
border: 1px solid rgba(180, 170, 160, 0.22);
color: var(--nav-text);
border-radius: 10px 10px 10px 3px;
}
/* Markdown dans les bulles */
:deep(.chatbot-md) { font-size: inherit; line-height: 1.55; }
:deep(.chatbot-md p) { margin: 0 0 0.35em; }
:deep(.chatbot-md p:last-child) { margin-bottom: 0; }
:deep(.chatbot-md strong) { font-weight: 700; }
:deep(.chatbot-md em) { font-style: italic; }
:deep(.chatbot-md ul) { margin: 0.25em 0 0.25em 1em; list-style: disc; padding: 0; }
:deep(.chatbot-md li) { margin-bottom: 0.1em; }
.chatbot-auteur-tag {
margin-top: 6px;
font-size: 0.7rem;
color: var(--nav-text-muted);
font-style: italic;
}
/* Loading */
.chatbot-bubble.loading {
display: flex;
gap: 4px;
align-items: center;
padding: 10px 12px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--nav-text-muted);
animation: blink 1.2s infinite ease-in-out;
}
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes blink {
0%, 80%, 100% { opacity: 0.25; }
40% { opacity: 1; }
}
/* Erreur */
.chatbot-error {
align-self: flex-start;
font-size: 0.775rem;
color: #a85d3e;
background: rgba(168, 93, 62, 0.08);
border-radius: 8px;
padding: 7px 10px;
max-width: 90%;
}
/* Input */
.chatbot-input-row {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-top: 1px solid rgba(180, 170, 160, 0.22);
background: var(--nav-surface);
}
.chatbot-input {
flex: 1;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgba(180, 170, 160, 0.3);
background: var(--nav-bg);
color: var(--nav-text);
font-family: var(--nav-font);
font-size: 0.8rem;
min-width: 0;
transition: border-color 0.15s;
}
.chatbot-input:focus {
outline: none;
border-color: var(--nav-primary);
}
.chatbot-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chatbot-send-btn {
flex-shrink: 0;
width: 30px;
height: 30px;
border-radius: 7px;
border: none;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 0.15s;
}
.chatbot-send-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.chatbot-send-btn:not(:disabled):hover {
opacity: 0.85;
}
</style>

View File

@@ -606,8 +606,8 @@ function onInterrogerRag(auteurId: string) {
} }
.chatbot-split { .chatbot-split {
flex: 0 0 34%; flex: 1 1 0;
min-height: 0; min-height: 120px;
opacity: 1; opacity: 1;
} }
@@ -639,8 +639,8 @@ function onInterrogerRag(auteurId: string) {
height: 60vh; height: 60vh;
} }
.chatbot-split { .chatbot-split {
flex: 0 0 calc(40vh - 38px); flex: 1 1 0;
height: calc(40vh - 38px); min-height: 120px;
} }
.toggle-btn { .toggle-btn {
font-size: 0.7rem; font-size: 0.7rem;

View File

@@ -23,6 +23,7 @@ export default defineNuxtConfig({
codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80) codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
ragPeUrl: process.env.RAG_PE_URL || 'http://localhost:9621',
}, },
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client // Leaflet ne fonctionne pas en SSR — forcer le rendu côté client

View File

@@ -190,12 +190,38 @@
</a> </a>
</section> </section>
<!--
SECTION 8 - API publique
-->
<section class="section section-opensource">
<h2>API publique</h2>
<p class="section-text">
Les données de ce site internet sont publiques, toutes consultables via API directe, pour que des agents IA, des développeurs, ou d'autres sites puissent interroger la base sans passer par l'interface graphique.
</p>
<p class="section-text">
<strong>Données accessibles :</strong> organisations d'entraide, pratiques d'architecture d'écologie politique (graphe complet + projets), plateformes d'emploi, RAG et ses auteurs, et toutes autres données à venir.
</p>
<div class="api-copy-box">
<code class="api-url">https://api.trans-former.fr</code>
<button class="btn-copy" @click="copyApiUrl">{{ copied ? 'Copié !' : 'Copier' }}</button>
</div>
</section>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
useHead({ title: 'À propos - AEP' }) useHead({ title: 'À propos - AEP' })
const copied = ref(false)
function copyApiUrl() {
navigator.clipboard.writeText('https://api.trans-former.fr')
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
</script> </script>
<style scoped> <style scoped>
@@ -512,6 +538,44 @@ useHead({ title: 'À propos - AEP' })
background: var(--nav-bg-alt); background: var(--nav-bg-alt);
} }
/* ── API copy box ────────────────────────────────────────────────────────────── */
.api-copy-box {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
padding: 0.5rem 0.75rem;
background: var(--nav-surface);
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
}
.api-url {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 0.875rem;
color: var(--nav-primary-solid);
user-select: all;
}
.btn-copy {
padding: 0.375rem 0.75rem;
background: var(--nav-bg-alt);
border: 1px solid rgba(26, 34, 56, 0.15);
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
color: var(--nav-text);
transition: background 0.15s;
white-space: nowrap;
}
.btn-copy:hover {
background: var(--nav-primary-solid);
color: #fff;
}
/* ── Responsive général ──────────────────────────────────────────────────────── */ /* ── Responsive général ──────────────────────────────────────────────────────── */
@media (max-width: 480px) { @media (max-width: 480px) {

View File

@@ -0,0 +1,162 @@
/**
* POST /api/chatbot-pensees
* Chatbot RAG Pensées Écologiques — corpus FRACAS Bonpote
* Appelle LightRAG PE (localhost:9621) avec contexte auteur optionnel.
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
const SYSTEM_PREFACE_PENSEES = `Tu es un agent du RAG Pensées Écologiques, infrastructure militante du collectif trans-former.fr.
Tu réponds en t'appuyant STRICTEMENT sur le corpus ingéré (auteurs FRACAS Bonpote : écosocialisme, éco-anarchisme, écoféminismes, écologies décoloniales, technocritique, pensées du vivant, décroissance...).
Règles :
- Cite les sources (auteur, livre) à chaque assertion importante.
- Si la question dépasse le corpus, dis-le clairement. Pas d'hallucination.
- Ton politique direct, pas de neutralité fade.
- Réponse en français, dense, sans délayage.
- Distingue les positions selon les écoles quand elles divergent.`
const SYSTEM_PREFACE_PROJETS = `Tu es un agent du RAG Projets de Jules Nény (architecte, collectif trans-former.fr).
Tu réponds STRICTEMENT à partir des documents projet (fichiers butte-pinson__*.md et autres projets archi de Jules).
N'utilise PAS le corpus FRACAS Pensées Écologiques pour répondre, sauf si l'usager te le demande explicitement.
Règles :
- Cite les sources (nom de projet, document) à chaque assertion importante.
- Si la question dépasse le corpus projet, dis-le clairement. Pas d'hallucination.
- Ton praticien réflexif : 1ère personne quand pertinent, narration située.
- Réponse en français, dense, sans délayage.`
const SYSTEM_PREFACE_BOTH = `Tu es un agent du RAG croisé Pensées x Projets de Jules Nény (architecte militant, collectif trans-former.fr).
CENTRE TA RÉPONSE sur les documents PROJETS (fichiers butte-pinson__*.md et autres projets archi).
Mobilise le corpus FRACAS Pensées (autres fichiers) UNIQUEMENT pour éclairer théoriquement les partis pris des projets, jamais l'inverse.
Pondération attendue : ~70% ancrage projet concret, ~30% éclairage théorique FRACAS.
Règles :
- Cite les sources (auteur ou nom de projet, document) à chaque assertion.
- Si un thème n'est pas couvert par les projets, dis-le clairement avant d'éventuellement étendre au corpus Pensées.
- Pas d'hallucination, pas d'extrapolation hors corpus.
- Ton praticien militant : direct, pas neutre, ancré dans la pratique architecturale.
- Réponse en français, dense, sans délayage.`
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}__").
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.
Règles :
- Cite les sources (titre du livre) à chaque assertion.
- Pas d'hallucination. Si l'info n'est pas dans le corpus de cet auteur, dis-le.
- N'introduis JAMAIS d'autres auteurs sauf si ${nomAuteur} les commente explicitement.
- Ton politique direct, pas de neutralité fade.
- Réponse en français, dense, sans délayage.`
}
interface AuteurIngere { id: string; nom: string; ingere: boolean }
let auteursIngeresCache: AuteurIngere[] | null = null
function loadAuteursIngeres(): AuteurIngere[] {
if (auteursIngeresCache) return auteursIngeresCache
try {
const jsonPath = join(process.cwd(), 'public', 'data', 'auteurs-pensees.json')
const raw = readFileSync(jsonPath, 'utf-8')
const data = JSON.parse(raw)
const list = (data.auteurs ?? [])
.filter((a: any) => a.ingere === true)
.map((a: any) => ({ id: String(a.id), nom: String(a.nom), ingere: true }))
auteursIngeresCache = list
return list
} catch {
auteursIngeresCache = []
return []
}
}
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ip = getHeader(event, 'x-forwarded-for')?.split(',')[0].trim()
|| event.node.req.socket?.remoteAddress
|| '0.0.0.0'
const allowed = checkRateLimitJson(ip, 'chatbot-pensees', 20)
if (!allowed) throw createError({ statusCode: 429, message: 'Limite de 20 questions par jour atteinte.' })
const body = await readBody(event)
if (!body?.query || body.query.trim().length < 3 || body.query.trim().length > 500) {
throw createError({ statusCode: 400, message: 'Query invalide (3-500 caractères).' })
}
const query: string = body.query.trim()
const mode: string = body.mode || 'hybrid'
const corpus: string = body.corpus || 'both'
const ragUrl: string = (config.ragPeUrl as string) || 'http://localhost:9621'
const auteurSlug: string | null = body.auteur_slug?.trim().toLowerCase() || null
let nomAuteurMatch: string | null = null
if (auteurSlug) {
const ingeres = loadAuteursIngeres()
const auteur = ingeres.find(a => a.id === auteurSlug)
nomAuteurMatch = auteur?.nom ?? null
}
let systemPreface: string
if (auteurSlug && nomAuteurMatch) {
systemPreface = buildPrefaceAuteur(nomAuteurMatch, auteurSlug)
} else if (corpus === 'pensees') {
systemPreface = SYSTEM_PREFACE_PENSEES
} else if (corpus === 'projets') {
systemPreface = SYSTEM_PREFACE_PROJETS
} else {
systemPreface = SYSTEM_PREFACE_BOTH
}
try {
await $fetch(`${ragUrl}/health`, { timeout: 5000 })
} catch {
throw createError({ statusCode: 503, message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.' })
}
const ragQuery = `${systemPreface}\n\nQuestion : ${query}`
const ragBody: Record<string, any> = { query: ragQuery, mode }
if (auteurSlug && nomAuteurMatch) {
ragBody.hl_keywords = [nomAuteurMatch, auteurSlug]
ragBody.ll_keywords = [auteurSlug]
}
let ragResponse: any
try {
ragResponse = await $fetch(`${ragUrl}/query`, {
method: 'POST',
body: ragBody,
timeout: 90000,
})
} catch (e: any) {
const status = e?.response?.status
if (status === 429) throw createError({ statusCode: 429, message: 'RAG saturé — réessaie dans quelques instants.' })
throw createError({ statusCode: 504, message: 'RAG en cours de processing — réessaie dans quelques secondes.' })
}
let chunksOnTarget = 0
let chunksOffTarget = 0
if (auteurSlug && nomAuteurMatch && Array.isArray(ragResponse.references)) {
const slugPrefix = `${auteurSlug}__`
for (const ref of ragResponse.references) {
const fp = (ref.file_path ?? '').toLowerCase()
if (!fp) continue
if (fp.startsWith(slugPrefix)) chunksOnTarget++
else chunksOffTarget++
}
}
return {
response: ragResponse.response ?? '',
mode,
corpus,
auteur: auteurSlug && nomAuteurMatch ? { slug: auteurSlug, nom: nomAuteurMatch } : null,
auteur_unmatched: auteurSlug && !nomAuteurMatch ? auteurSlug : null,
auteur_chunks: auteurSlug && nomAuteurMatch ? { on_target: chunksOnTarget, off_target: chunksOffTarget } : null,
filter: { couche: body.filter_couche ?? null, ecole: body.filter_ecole ?? null },
timestamp: new Date().toISOString(),
}
})