Compare commits

..
Author SHA1 Message Date
Jules NenyandClaude Opus 5.5 0c8f9fc1bd fix(outils): un seul repère main, tabpanel sur le panneau ; doublons retirés de l'arbre fmhy (B6-M5)
Le repère <main> passe à la racine de la page et le panneau d'onglet devient
un <div role="tabpanel"> (Lighthouse signalait role=tabpanel sur <main>).
FreeCAD, Blender, Inkscape, GIMP, LibreOffice Draw et Joplin sortent de
fmhy-curated.json : ils sont désormais dans la sélection open source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
2026-10-08 17:10:10 +02:00
Jules NenyandClaude Sonnet f4ea1639f2 chore(outils): script de contrôle des liens, outil de revue (B6-M4)
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
2026-10-08 17:07:37 +02:00
Jules NenyandClaude Sonnet 43ae065e26 feat(outils): sélection open source, sommaire d'ancres et garde-fous (B6-M4)
28 outils vérifiés en 4 rubriques (open_source.selection), affichés en premier
sous un sommaire d'ancres ; grille 2 colonnes dès 1024 px. Le test contrôle
la sélection (champs, https, liste noire) et les doublons d'URL.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
2026-10-08 17:07:37 +02:00
Jules NenyandClaude Sonnet dfbac7dd12 feat(outils): Partir de soi en vue fiches, graphe des sept mots (B6-M2)
Le 3e sous-onglet de /outils remplace le rendu provisoire par VueFiches
(état Mixte : liste des mots, graphe SVG statique, fiche d'un mot avec ses
ressources). L'en-tête se compacte sur cet onglet, la vue prend la hauteur.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
2026-10-08 16:56:18 +02:00
Jules NenyandClaude Sonnet 11f24c7925 feat(vue-fiches): tri ordre d'origine, triDefaut, libellés de vue et adaptateur mot (B6-M2)
Extensions optionnelles du composant partagé, sans effet sur / et /agences :
tri 'ordre' (ordre du tableau d'entrée) avec config.triDefaut et libellesTri,
prop libellesVue (Graphe au lieu de Carte, Voir dans le graphe), adaptateur
toMiniMot (kind 'mot'). 22 cas ajoutés à test-vue-fiches (80/80).

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
2026-10-08 16:56:18 +02:00
Jules NenyandClaude Sonnet 68d3b080d7 feat(outils): trois sous-onglets, contenu sorti du template (B6-M1)
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
2026-10-08 16:38:29 +02:00
Jules NenyandClaude Sonnet c6123f1f59 test(outils): garde-fou des données outils.json (B6-M1)
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
2026-10-08 16:38:29 +02:00
Jules NenyandClaude Sonnet 4f0f0d95c9 feat(outils): outils.json schéma v2 et purge des liens piratage (B6-M1)
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
2026-10-08 16:38:28 +02:00
35 changed files with 2835 additions and 1651 deletions
+2
View File
@@ -101,6 +101,8 @@ Contenus de détail en place : `/` → `FicheDetail` (`compact`, `etiquettes-cli
3. Le slot `#carte`, et le slot `#detail` (contenu de la fiche ; une page sans contenu de détail affiche seulement les actions). Les épingles appellent `vf.ouvrirFiche(id)`. 3. Le slot `#carte`, et le slot `#detail` (contenu de la fiche ; une page sans contenu de détail affiche seulement les actions). Les épingles appellent `vf.ouvrirFiche(id)`.
4. Ajouter des cas à `scripts/test-vue-fiches.mjs` si la page introduit une règle nouvelle. 4. Ajouter des cas à `scripts/test-vue-fiches.mjs` si la page introduit une règle nouvelle.
**Troisième page en place : `/outils?tab=partir-de-soi` (B6-M2)** — sept fiches, une par mot de la slide 17 (`components/OutilsPartirDeSoi.vue`, adaptateur `toMiniMot`). Trois extensions optionnelles du composant, sans effet sur `/` et `/agences` : tri `ordre` (ordre du tableau d'entrée) avec `config.triDefaut` (tri sans filtre ; la pertinence reste prioritaire dès qu'un filtre est actif) et `config.libellesTri` ; prop `libelles-vue` (`{ carte: 'Graphe' }` : le bouton, et « Voir dans le graphe » au lieu de « Voir sur la carte » ; l'URL garde `vue=carte`). Le slot `#carte` y est `GrapheMots.vue`, un SVG statique (nœud central, mots en couronne, ressources en feuilles) dans le vocabulaire du motif maillage. La page vit dans un onglet : `tab=` est un paramètre que la vue ne gère pas, donc recopié à chaque écriture.
**`FicheMiniCard`** : racine `<article>`, toute la carte ouvre la fiche par un bouton étiré (`::after`) ; avec `etiquettes-cliquables`, les chips deviennent des boutons (impossible dans l'ancienne racine `<button>`). Props `actif` (liseré safran au survol d'épingle), `etiquettes-actives`. Résumé et « Sans localisation » en `color-mix(--text 78 %)` : `--text-muted` et `--text-subtle` échouaient au contraste. **`FicheMiniCard`** : racine `<article>`, toute la carte ouvre la fiche par un bouton étiré (`::after`) ; avec `etiquettes-cliquables`, les chips deviennent des boutons (impossible dans l'ancienne racine `<button>`). Props `actif` (liseré safran au survol d'épingle), `etiquettes-actives`. Résumé et « Sans localisation » en `color-mix(--text 78 %)` : `--text-muted` et `--text-subtle` échouaient au contraste.
## Intention d'abord ## Intention d'abord
-21
View File
@@ -1,21 +0,0 @@
<template>
<span v-if="profondeur === 'approfondi'" class="chat-mode-badge">{{ CHAT_PROFONDEUR_LIBELLES.approfondi }}</span>
</template>
<script setup lang="ts">
// Étiquette sobre sur une réponse donnée en mode approfondi (rien en mode rapide).
defineProps<{ profondeur?: string }>()
</script>
<style scoped>
.chat-mode-badge {
display: inline-block;
margin-bottom: 6px;
padding: 1px 8px;
border-radius: 9999px;
border: 1px solid var(--nav-bg-alt);
font-size: 0.66rem;
font-family: var(--nav-font);
color: var(--nav-text-muted);
}
</style>
-80
View File
@@ -1,80 +0,0 @@
<template>
<div class="chat-mode" role="radiogroup" aria-label="Mode de réponse">
<button
v-for="(p, i) in CHAT_PROFONDEURS"
:key="p"
:ref="(el) => (boutons[i] = el as HTMLButtonElement | null)"
type="button"
role="radio"
class="chat-mode-option"
:class="{ active: modelValue === p }"
:aria-checked="modelValue === p"
:tabindex="modelValue === p ? 0 : -1"
:title="CHAT_PROFONDEUR_AIDES[p]"
:disabled="disabled"
@click="choisir(p)"
@keydown="onKey($event, i)"
>{{ CHAT_PROFONDEUR_LIBELLES[p] }}</button>
</div>
</template>
<script setup lang="ts">
// Toggle 2 options partagé par les 5 chatbots. Libellés : utils/chatProfondeur.ts.
// radiogroup : flèches déplacent la sélection, Tab entre/sort du groupe (roving tabindex).
const props = defineProps<{ modelValue: 'rapide' | 'approfondi'; disabled?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: 'rapide' | 'approfondi'] }>()
const boutons = ref<(HTMLButtonElement | null)[]>([])
function choisir(p: ChatProfondeur) {
if (!props.disabled) emit('update:modelValue', p)
}
function onKey(e: KeyboardEvent, i: number) {
const n = CHAT_PROFONDEURS.length
let j = -1
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') j = (i + 1) % n
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') j = (i - 1 + n) % n
else if (e.key === 'Home') j = 0
else if (e.key === 'End') j = n - 1
if (j < 0) return
e.preventDefault()
choisir(CHAT_PROFONDEURS[j])
nextTick(() => boutons.value[j]?.focus())
}
</script>
<style scoped>
.chat-mode {
display: inline-flex;
gap: 2px;
padding: 2px;
border-radius: 9999px;
background: var(--nav-bg-alt);
align-self: flex-start;
}
.chat-mode-option {
border: none;
background: transparent;
color: var(--nav-text-muted);
font-family: var(--nav-font);
font-size: 0.72rem;
line-height: 1.2;
padding: 4px 10px;
border-radius: 9999px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.chat-mode-option:hover:not(.active):not(:disabled) { color: var(--nav-text); }
.chat-mode-option.active {
background: var(--nav-primary);
color: var(--nav-text-on-primary);
font-weight: 600;
}
.chat-mode-option:focus-visible { outline: 2px solid var(--nav-primary); outline-offset: 2px; }
.chat-mode-option:disabled { opacity: 0.5; cursor: not-allowed; }
@media (prefers-reduced-motion: reduce) {
.chat-mode-option { transition: none; }
}
</style>
-30
View File
@@ -1,30 +0,0 @@
<template>
<button type="button" class="chat-new-conversation" @click="emit('reset')">
Nouvelle conversation
</button>
</template>
<script setup lang="ts">
// Bouton sobre « Nouvelle conversation », partagé par les chatbots.
// Le parent le rend seulement s'il y a un historique et gère la purge.
const emit = defineEmits<{ reset: [] }>()
</script>
<style scoped>
.chat-new-conversation {
align-self: flex-end;
flex-shrink: 0;
background: transparent;
border: none;
padding: 4px 6px;
border-radius: 6px;
font-size: 0.72rem;
font-family: var(--nav-font);
color: var(--nav-text-muted);
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.chat-new-conversation:hover { color: var(--nav-text); background: var(--nav-bg-alt); }
.chat-new-conversation:focus-visible { outline: 2px solid var(--nav-primary); outline-offset: 2px; }
</style>
+31 -161
View File
@@ -16,6 +16,16 @@
<span v-else class="chatbot-corpus-badge">FRACAS</span> <span v-else class="chatbot-corpus-badge">FRACAS</span>
</div> </div>
<div class="chatbot-header-right"> <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é"> <select v-model="corpusMode" class="chatbot-corpus-select" title="Corpus interrogé">
<option value="pensees">Pensées éco.</option> <option value="pensees">Pensées éco.</option>
<option value="both">Pensées + Projets</option> <option value="both">Pensées + Projets</option>
@@ -42,7 +52,6 @@
<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="chatbot-bubble user">{{ msg.content }}</div> <div v-if="msg.role === 'user'" class="chatbot-bubble user">{{ msg.content }}</div>
<div v-else class="chatbot-bubble assistant"> <div v-else class="chatbot-bubble assistant">
<ChatModeBadge :profondeur="msg.profondeur" />
<div class="chatbot-md" v-html="renderMd(msg.content)" /> <div class="chatbot-md" v-html="renderMd(msg.content)" />
<div v-if="msg.auteur" class="chatbot-auteur-tag"> <div v-if="msg.auteur" class="chatbot-auteur-tag">
<span>{{ msg.auteur.nom }}</span> <span>{{ msg.auteur.nom }}</span>
@@ -50,26 +59,13 @@
</div> </div>
</template> </template>
<!-- Chargement : texte d'attente progressif. L'annonce aux lecteurs d'écran passe par la région <!-- Chargement -->
live persistante ci-dessous (une région insérée à chaud n'est pas fiablement annoncée). --> <div v-if="loading" class="chatbot-bubble assistant loading">
<div v-if="loading" class="chatbot-bubble assistant loading" aria-hidden="true">
<span class="dot" /><span class="dot" /><span class="dot" /> <span class="dot" /><span class="dot" /><span class="dot" />
<span class="attente-texte">{{ attenteTexte }}</span>
</div> </div>
<div class="sr-only" role="status" aria-live="polite" aria-atomic="true">{{ loading ? attenteTexte : '' }}</div>
<!-- Erreur --> <!-- Erreur -->
<div v-if="errorMsg" class="chatbot-error" role="alert"> <div v-if="errorMsg" class="chatbot-error">{{ errorMsg }}</div>
<span>{{ errorMsg }}</span>
<button v-if="peutReessayer" type="button" class="chatbot-retry-btn" @click="reessayer">Réessayer</button>
</div>
<ChatNewConversation v-if="hasHistory && !loading" @reset="clearMessages" />
</div>
<!-- Mode de réponse -->
<div class="chatbot-mode-row">
<ChatModeToggle v-model="profondeur" :disabled="loading" />
</div> </div>
<!-- Input --> <!-- Input -->
@@ -106,7 +102,6 @@ interface ChatMessage {
role: 'user' | 'assistant' role: 'user' | 'assistant'
content: string content: string
auteur?: { slug: string; nom: string } | null auteur?: { slug: string; nom: string } | null
profondeur?: string
} }
const props = defineProps<{ const props = defineProps<{
@@ -114,78 +109,21 @@ const props = defineProps<{
inline?: boolean inline?: boolean
}>() }>()
// Une conversation par contexte (corpus général ou un auteur) : changer d'auteur const messages = ref<ChatMessage[]>([])
// ne mélange pas les historiques, et un rechargement restaure le bon.
const sessionKey = computed(() => props.auteurContext ? `pensees-${slugify(props.auteurContext)}` : 'pensees')
const { messages, hasHistory, clear } = useChatSession<ChatMessage>(sessionKey)
// `profondeur` (et non `mode`, réservé au mode de recherche LightRAG côté serveur)
const profondeur = useChatProfondeur('pensees')
const inputText = ref('') const inputText = ref('')
const loading = ref(false) const loading = ref(false)
const errorMsg = ref('') const errorMsg = ref('')
const corpusMode = ref<'pensees' | 'projets' | 'both'>('pensees') const corpusMode = ref<'pensees' | 'projets' | 'both'>('pensees')
const messagesContainer = ref<HTMLElement | null>(null) const messagesContainer = ref<HTMLElement | null>(null)
// --- Attente et erreurs : état d'interface uniquement, jamais persisté (hors useChatSession) --- watch(() => props.auteurContext, () => {
// Mesuré le 09/10 : récupération 2-5 s, génération 10-18 s, 19-29 s bout en bout. Les seuils suivent ces messages.value = []
// phases ; le texte est une minuterie (le serveur renvoie tout d'un bloc : il faut la réponse complète
// pour vérifier les citations, donc pas de flux de texte).
const SEUIL_REDIGE_S = 6
const SEUIL_LONG_S = 30
// Au-delà du timeout serveur (90 s) : le front ne lâche jamais avant la route, et ne tourne jamais sans fin.
const TIMEOUT_FRONT_MS = 95000
const attenteS = ref(0)
const attenteTexte = computed(() => {
if (attenteS.value >= SEUIL_LONG_S) return 'Le corpus est vaste, encore un instant…'
if (attenteS.value >= SEUIL_REDIGE_S) return 'Je rédige…'
return 'Je fouille le corpus…'
})
let attenteTimer: ReturnType<typeof setInterval> | null = null
function demarrerAttente() {
arreterAttente()
attenteS.value = 0
const t0 = Date.now()
attenteTimer = setInterval(() => { attenteS.value = Math.floor((Date.now() - t0) / 1000) }, 1000)
}
function arreterAttente() {
if (attenteTimer) { clearInterval(attenteTimer); attenteTimer = null }
}
onBeforeUnmount(arreterAttente)
interface Envoi { query: string; profondeur: string; corpus: string; auteurSlug?: string }
const dernierEnvoi = ref<Envoi | null>(null)
const peutReessayer = ref(false)
watch(sessionKey, () => {
errorMsg.value = '' errorMsg.value = ''
peutReessayer.value = false
dernierEnvoi.value = null
}) })
function clearMessages() { function clearMessages() {
clear() messages.value = []
errorMsg.value = '' errorMsg.value = ''
peutReessayer.value = false
dernierEnvoi.value = null
}
function messageErreur(e: any): { texte: string; reessayer: boolean } {
const status = e?.statusCode ?? e?.status ?? e?.response?.status
const detail = String(e?.data?.message ?? e?.statusMessage ?? e?.message ?? '')
if (status === 429) {
// 429 de la route : quota du jour (pas de réessai utile) ou RAG saturé (réessai possible)
if (/satur/i.test(detail)) return { texte: 'Le corpus est très sollicité en ce moment. Patiente quelques secondes, puis réessaie.', reessayer: true }
return { texte: 'Tu as atteint la limite de 20 questions par jour. Reviens demain, ou explore les fiches auteurs en attendant.', reessayer: false }
}
if (status === 503) return { texte: 'La bibliothèque est indisponible pour l\'instant. Réessaie dans quelques minutes.', reessayer: true }
if (status === 504) return { texte: 'La bibliothèque met trop de temps à répondre : la question est peut-être trop large. Réessaie, ou reformule-la plus précisément.', reessayer: true }
if (!status && /timeout|timed out|abort/i.test(`${detail} ${e?.name ?? ''} ${e?.cause?.name ?? ''}`)) {
return { texte: 'Désolé, la réponse met plus de temps que prévu et n\'a pas abouti. Tu peux relancer la même question.', reessayer: true }
}
if (!status) return { texte: 'La connexion semble coupée : la question n\'est pas partie. Vérifie ton réseau puis réessaie.', reessayer: true }
return { texte: 'Une erreur est survenue. Réessaie dans quelques instants.', reessayer: true }
} }
function scrollToBottom() { function scrollToBottom() {
@@ -199,27 +137,9 @@ async function sendMessage() {
if (!query || loading.value) return if (!query || loading.value) return
inputText.value = '' inputText.value = ''
messages.value.push({ role: 'user', content: query })
await envoyer({
query,
profondeur: profondeur.value,
corpus: corpusMode.value,
auteurSlug: props.auteurContext ? slugify(props.auteurContext) : undefined,
})
}
// Reposte la même question (même mode, même corpus) sans dupliquer le message de l'usager.
async function reessayer() {
if (!dernierEnvoi.value || loading.value) return
await envoyer(dernierEnvoi.value)
}
async function envoyer(envoi: Envoi) {
errorMsg.value = '' errorMsg.value = ''
peutReessayer.value = false messages.value.push({ role: 'user', content: query })
dernierEnvoi.value = envoi
loading.value = true loading.value = true
demarrerAttente()
await nextTick() await nextTick()
scrollToBottom() scrollToBottom()
@@ -230,27 +150,29 @@ async function envoyer(envoi: Envoi) {
auteur: { slug: string; nom: string } | null auteur: { slug: string; nom: string } | null
}>('/api/chatbot-pensees', { }>('/api/chatbot-pensees', {
method: 'POST', method: 'POST',
timeout: TIMEOUT_FRONT_MS,
body: { body: {
query: envoi.query, query,
corpus: envoi.corpus, corpus: corpusMode.value,
profondeur: envoi.profondeur, auteur_slug: props.auteurContext ? slugify(props.auteurContext) : undefined,
auteur_slug: envoi.auteurSlug,
}, },
}) })
messages.value.push({ messages.value.push({
role: 'assistant', role: 'assistant',
content: res.response, content: res.response,
auteur: res.auteur ?? null, auteur: res.auteur ?? null,
profondeur: envoi.profondeur,
}) })
dernierEnvoi.value = null
} catch (e: any) { } catch (e: any) {
const { texte, reessayer: ok } = messageErreur(e) const status = e?.statusCode ?? e?.status
errorMsg.value = texte if (status === 429) {
peutReessayer.value = ok 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 { } finally {
arreterAttente()
loading.value = false loading.value = false
await nextTick() await nextTick()
scrollToBottom() scrollToBottom()
@@ -439,25 +361,6 @@ function slugify(str: string): string {
padding: 10px 12px; padding: 10px 12px;
} }
.attente-texte {
margin-left: 6px;
font-size: 0.775rem;
color: var(--nav-text-muted);
font-style: italic;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.dot { .dot {
width: 6px; width: 6px;
height: 6px; height: 6px;
@@ -474,11 +377,6 @@ function slugify(str: string): string {
40% { opacity: 1; } 40% { opacity: 1; }
} }
/* Pas d'animation si l'usager la réduit : points fixes, le texte d'attente porte l'information */
@media (prefers-reduced-motion: reduce) {
.dot { animation: none; opacity: 0.6; }
}
/* Erreur */ /* Erreur */
.chatbot-error { .chatbot-error {
align-self: flex-start; align-self: flex-start;
@@ -488,36 +386,8 @@ function slugify(str: string): string {
border-radius: 8px; border-radius: 8px;
padding: 7px 10px; padding: 7px 10px;
max-width: 90%; max-width: 90%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
} }
.chatbot-retry-btn {
font: inherit;
font-size: 0.75rem;
font-weight: 600;
color: var(--nav-text-on-primary);
background: var(--nav-primary);
border: none;
border-radius: 6px;
padding: 4px 12px;
cursor: pointer;
}
.chatbot-retry-btn:hover { opacity: 0.85; }
.chatbot-retry-btn:focus-visible { outline: 2px solid var(--nav-primary); outline-offset: 2px; }
/* Mode de réponse */
.chatbot-mode-row {
flex-shrink: 0;
display: flex;
padding: 6px 10px 0;
border-top: 1px solid rgba(180, 170, 160, 0.22);
background: var(--nav-surface);
}
.chatbot-mode-row + .chatbot-input-row { border-top: none; }
/* Input */ /* Input */
.chatbot-input-row { .chatbot-input-row {
flex-shrink: 0; flex-shrink: 0;
+13 -80
View File
@@ -12,7 +12,7 @@
role="button" role="button"
tabindex="0" tabindex="0"
:aria-expanded="isExpanded" :aria-expanded="isExpanded"
:aria-label="`Ouvrir l'assistant : ${titre}`" aria-label="Ouvrir l'assistant chatbot"
@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 ? titre : placeholderReplie }} {{ isExpanded ? 'Chatbot AEP' : 'Pose une question sur le réseau…' }}
</span> </span>
<!-- Chevron --> <!-- Chevron -->
@@ -52,23 +52,21 @@
<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>{{ intro }}</p> <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 v-if="exemple" class="example">{{ exemple }}</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>
</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">
<ChatModeBadge :profondeur="msg.profondeur" /> <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">{{ fichesTitre }}</p> <p class="fiches-title">Fiches recommandees :</p>
<a <a
v-for="fiche in msg.fiches" v-for="fiche in msg.fiches"
:key="fiche.id" :key="fiche.id"
:href="ficheLienModele.replace('{id}', String(fiche.id))" :href="`/fiche/${fiche.id}`"
class="fiche-card" class="fiche-card"
> >
<span class="fiche-nom">{{ fiche.nom }}</span> <span class="fiche-nom">{{ fiche.nom }}</span>
@@ -100,22 +98,15 @@
<!-- Erreur --> <!-- Erreur -->
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div> <div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
<ChatNewConversation v-if="hasHistory && !loading" @reset="nouvelleConversation" />
</div>
<!-- Mode de réponse -->
<div class="chatbot-mode-row" style="border-top: 1px solid var(--nav-bg-alt);">
<ChatModeToggle v-model="profondeur" :disabled="loading" />
</div> </div>
<!-- Input --> <!-- Input -->
<div class="chatbot-input-row"> <div class="chatbot-input-row" style="border-top: 1px solid var(--nav-bg-alt);">
<input <input
v-model="inputText" v-model="inputText"
type="text" type="text"
:disabled="loading" :disabled="loading"
:placeholder="placeholderSaisie" placeholder="Pose ta question…"
class="chatbot-input" class="chatbot-input"
@keydown.enter.prevent="sendMessage" @keydown.enter.prevent="sendMessage"
/> />
@@ -136,9 +127,6 @@
</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
@@ -150,58 +138,15 @@ interface ChatMessage {
content: string content: string
fiches?: FicheReco[] fiches?: FicheReco[]
suggestedHashtags?: string[] suggestedHashtags?: string[]
profondeur?: 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
/** Clé de persistance (sessionStorage). Même clé = même conversation (ex. mobile + desktop) */
chatKey?: string
}>(), {
chatKey: 'accueil',
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]
}>() }>()
const isExpanded = ref(false) const isExpanded = ref(false)
const { messages, hasHistory, clear } = useChatSession<ChatMessage>(() => props.chatKey) const messages = ref<ChatMessage[]>([])
const profondeur = useChatProfondeur(() => props.chatKey)
const inputText = ref('') const inputText = ref('')
const loading = ref(false) const loading = ref(false)
const errorMsg = ref('') const errorMsg = ref('')
@@ -238,11 +183,6 @@ function detectHashtagsFromQuery(query: string): string[] {
return detected.slice(0, 3) return detected.slice(0, 3)
} }
function nouvelleConversation() {
clear()
errorMsg.value = ''
}
function toggleExpand() { function toggleExpand() {
isExpanded.value = !isExpanded.value isExpanded.value = !isExpanded.value
} }
@@ -255,7 +195,6 @@ async function sendMessage() {
errorMsg.value = '' errorMsg.value = ''
messages.value.push({ role: 'user', content: question }) messages.value.push({ role: 'user', content: question })
loading.value = true loading.value = true
const mode = profondeur.value
await nextTick() await nextTick()
scrollToBottom() scrollToBottom()
@@ -264,9 +203,9 @@ 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 }[]
}>(props.endpoint, { }>('/api/chatbot', {
method: 'POST', method: 'POST',
body: { question, profondeur: mode }, body: { question },
}) })
const suggestedHashtags = detectHashtagsFromQuery(question) const suggestedHashtags = detectHashtagsFromQuery(question)
@@ -275,7 +214,6 @@ async function sendMessage() {
content: res.reponse_texte, content: res.reponse_texte,
fiches: res.fiches_recommandees || [], fiches: res.fiches_recommandees || [],
suggestedHashtags: suggestedHashtags.length ? suggestedHashtags : undefined, suggestedHashtags: suggestedHashtags.length ? suggestedHashtags : undefined,
profondeur: mode,
} }
messages.value.push(assistantMsg) messages.value.push(assistantMsg)
@@ -285,7 +223,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 = props.messageLimite errorMsg.value = 'Limite de 10 questions par jour atteinte. Reviens demain.'
} 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 {
@@ -367,11 +305,6 @@ function scrollToBottom() {
gap: 8px; gap: 8px;
} }
.chatbot-mode-row {
display: flex;
padding: 8px 12px 0;
flex-shrink: 0;
}
.chatbot-input-row { .chatbot-input-row {
display: flex; display: flex;
align-items: center; align-items: center;
+4 -14
View File
@@ -54,7 +54,6 @@
<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">
<ChatModeBadge :profondeur="msg.profondeur" />
<div v-html="renderMd(msg.content)" /> <div v-html="renderMd(msg.content)" />
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list" style="margin-top:12px;"> <div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list" style="margin-top:12px;">
<p class="fiches-title">Structures recommandées :</p> <p class="fiches-title">Structures recommandées :</p>
@@ -75,11 +74,9 @@
<span class="dot" /><span class="dot" /><span class="dot" /> <span class="dot" /><span class="dot" /><span class="dot" />
</div> </div>
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div> <div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
<ChatNewConversation v-if="hasHistory && !loading" @reset="nouvelleConversation" />
</div> </div>
<div class="shrink-0 px-4 pt-3 border-t" style="border-color: var(--nav-bg-alt); padding-bottom: max(1rem, env(safe-area-inset-bottom));"> <div class="shrink-0 px-4 pt-3 border-t" style="border-color: var(--nav-bg-alt); padding-bottom: max(1rem, env(safe-area-inset-bottom));">
<ChatModeToggle v-model="profondeur" :disabled="loading" class="mb-2" />
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <input
v-model="inputText" v-model="inputText"
@@ -115,13 +112,12 @@ import { useMarkdown } from '~/composables/useMarkdown'
const { render: renderMd } = useMarkdown() const { render: renderMd } = useMarkdown()
interface FicheReco { id: number | string; nom: string; explication?: string } interface FicheReco { id: number | string; nom: string; explication?: string }
interface ChatMessage { role: 'user' | 'assistant'; content: string; fiches?: FicheReco[]; profondeur?: string } interface ChatMessage { role: 'user' | 'assistant'; content: string; fiches?: FicheReco[] }
const props = defineProps<{ modelValue: boolean }>() const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>() const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const { messages, hasHistory, clear } = useChatSession<ChatMessage>('agences') const messages = ref<ChatMessage[]>([])
const profondeur = useChatProfondeur('agences')
const inputText = ref('') const inputText = ref('')
const loading = ref(false) const loading = ref(false)
const errorMsg = ref('') const errorMsg = ref('')
@@ -140,11 +136,6 @@ onUnmounted(() => {
} }
}) })
function nouvelleConversation() {
clear()
errorMsg.value = ''
}
async function sendMessage() { async function sendMessage() {
const question = inputText.value.trim() const question = inputText.value.trim()
if (!question || loading.value) return if (!question || loading.value) return
@@ -156,12 +147,11 @@ async function sendMessage() {
if (messagesContainer.value) messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight if (messagesContainer.value) messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
try { try {
const mode = profondeur.value
const res = await $fetch<{ reponse_texte: string; fiches_recommandees: FicheReco[] }>( const res = await $fetch<{ reponse_texte: string; fiches_recommandees: FicheReco[] }>(
'/api/chatbot-reseaux', '/api/chatbot-reseaux',
{ method: 'POST', body: { question, profondeur: mode } } { method: 'POST', body: { question } }
) )
messages.value.push({ role: 'assistant', content: res.reponse_texte, fiches: res.fiches_recommandees || [], profondeur: mode }) messages.value.push({ role: 'assistant', content: res.reponse_texte, fiches: res.fiches_recommandees || [] })
} catch (e: any) { } catch (e: any) {
const s = e?.statusCode ?? e?.status const s = e?.statusCode ?? e?.status
errorMsg.value = s === 429 errorMsg.value = s === 429
+2 -15
View File
@@ -87,7 +87,6 @@
<!-- Message assistant --> <!-- Message assistant -->
<div v-else class="assistant-bubble"> <div v-else class="assistant-bubble">
<ChatModeBadge :profondeur="msg.profondeur" />
<div class="md-content" v-html="renderMd(msg.content)" /> <div class="md-content" v-html="renderMd(msg.content)" />
<!-- Fiches recommandées --> <!-- Fiches recommandées -->
@@ -115,8 +114,6 @@
<div v-if="errorMsg" class="error-bubble"> <div v-if="errorMsg" class="error-bubble">
{{ errorMsg }} {{ errorMsg }}
</div> </div>
<ChatNewConversation v-if="hasHistory && !loading" @reset="nouvelleConversation" />
</div> </div>
<!-- Input --> <!-- Input -->
@@ -124,7 +121,6 @@
class="shrink-0 px-4 pt-3 border-t" class="shrink-0 px-4 pt-3 border-t"
style="border-color: var(--nav-bg-alt); padding-bottom: max(1rem, env(safe-area-inset-bottom));" style="border-color: var(--nav-bg-alt); padding-bottom: max(1rem, env(safe-area-inset-bottom));"
> >
<ChatModeToggle v-model="profondeur" :disabled="loading" class="mb-2" />
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <input
v-model="inputText" v-model="inputText"
@@ -176,7 +172,6 @@ interface ChatMessage {
role: 'user' | 'assistant' role: 'user' | 'assistant'
content: string content: string
fiches?: FicheReco[] fiches?: FicheReco[]
profondeur?: string
} }
const props = defineProps<{ const props = defineProps<{
@@ -188,8 +183,7 @@ const emit = defineEmits<{
'highlightOrgs': [ids: (number | string)[]] 'highlightOrgs': [ids: (number | string)[]]
}>() }>()
const { messages, hasHistory, clear } = useChatSession<ChatMessage>('accueil') const messages = ref<ChatMessage[]>([])
const profondeur = useChatProfondeur('accueil')
const inputText = ref('') const inputText = ref('')
const loading = ref(false) const loading = ref(false)
const errorMsg = ref('') const errorMsg = ref('')
@@ -213,11 +207,6 @@ onUnmounted(() => {
} }
}) })
function nouvelleConversation() {
clear()
errorMsg.value = ''
}
async function sendMessage() { async function sendMessage() {
const question = inputText.value.trim() const question = inputText.value.trim()
if (!question || loading.value) return if (!question || loading.value) return
@@ -226,7 +215,6 @@ async function sendMessage() {
errorMsg.value = '' errorMsg.value = ''
messages.value.push({ role: 'user', content: question }) messages.value.push({ role: 'user', content: question })
loading.value = true loading.value = true
const mode = profondeur.value
await nextTick() await nextTick()
scrollToBottom() scrollToBottom()
@@ -237,14 +225,13 @@ async function sendMessage() {
fiches_recommandees: { id: number | string; nom: string; explication: string }[] fiches_recommandees: { id: number | string; nom: string; explication: string }[]
}>('/api/chatbot', { }>('/api/chatbot', {
method: 'POST', method: 'POST',
body: { question, profondeur: mode }, body: { question },
}) })
const assistantMsg: ChatMessage = { const assistantMsg: ChatMessage = {
role: 'assistant', role: 'assistant',
content: res.reponse_texte, content: res.reponse_texte,
fiches: res.fiches_recommandees || [], fiches: res.fiches_recommandees || [],
profondeur: mode,
} }
messages.value.push(assistantMsg) messages.value.push(assistantMsg)
+314
View File
@@ -0,0 +1,314 @@
<!--
GrapheMots — B6-M2. Le graphe des sept mots de « Partir de soi » : un nœud
central, les mots en couronne (ordre de la slide, sens horaire depuis le
haut), et autour de chaque mot ses ressources en petits nœuds-feuilles.
Vocabulaire du motif maillage (DESIGN-SYSTEM) : nœuds pleins, arêtes fines,
un nœud central mis en avant. Statique et déterministe : pas de simulation,
rien ne bouge ; la mise en page est une fonction pure de la taille mesurée.
Il vit dans le slot #carte de VueFiches et ne connaît que `vf` :
clic / Entrée sur un mot ouvre sa fiche (`vf.ouvrirFiche`), le mot ouvert
(`vf.ficheId`) et le mot survolé dans la liste (`vf.survolId`) s'allument,
les mots hors du résultat (`vf.idsResultat`) s'estompent sans disparaître.
Les feuilles ne sont pas dans l'ordre de tabulation (on y accède par leur
mot) ; leur libellé apparaît au survol.
-->
<template>
<div ref="racine" class="gm">
<svg
class="gm__svg"
:viewBox="`0 0 ${taille.w} ${taille.h}`"
preserveAspectRatio="xMidYMid meet"
role="group"
aria-labelledby="gm-titre"
>
<title id="gm-titre">Graphe des sept mots de « Partir de soi » et des ressources de chacun</title>
<!-- Arêtes : centre → mot, mot → feuilles -->
<g class="gm__aretes" aria-hidden="true">
<template v-for="n in noeuds" :key="`a-${n.id}`">
<line
:x1="centre.x" :y1="centre.y" :x2="n.x" :y2="n.y"
class="gm__arete" :class="{ 'gm__arete--vif': allume(n.id), 'gm__arete--hors': hors(n.id) }"
/>
<line
v-for="f in n.feuilles" :key="`a-${n.id}-${f.id}`"
:x1="n.x" :y1="n.y" :x2="f.x" :y2="f.y"
class="gm__arete gm__arete--feuille" :class="{ 'gm__arete--vif': allume(n.id), 'gm__arete--hors': hors(n.id) }"
/>
</template>
</g>
<!-- Nœud central -->
<g class="gm__centre" aria-hidden="true">
<circle :cx="centre.x" :cy="centre.y" :r="RAYON_CENTRE" class="gm__centre-rond" />
<text :x="centre.x" :y="centre.y - 7" class="gm__centre-texte" text-anchor="middle">Partir</text>
<text :x="centre.x" :y="centre.y + 8" class="gm__centre-texte" text-anchor="middle">de soi</text>
</g>
<!-- Feuilles (ressources) -->
<g v-for="n in noeuds" :key="`f-${n.id}`" class="gm__feuilles" :class="{ 'gm__feuilles--hors': hors(n.id) }">
<circle
v-for="f in n.feuilles" :key="f.id"
:cx="f.x" :cy="f.y" r="5.5"
class="gm__feuille" :class="{ 'gm__feuille--vive': allume(n.id) || survolFeuille === `${n.id}/${f.id}` }"
@click="vf.ouvrirFiche(n.id)"
@mouseenter="survolerFeuille(n.id, f.id)"
@mouseleave="survolerFeuille(null, null)"
>
<title>{{ f.titre }}</title>
</circle>
</g>
<!-- Mots -->
<g
v-for="n in noeuds"
:key="n.id"
class="gm__mot"
:class="{ 'gm__mot--actif': allume(n.id), 'gm__mot--hors': hors(n.id), 'gm__mot--vide': n.vide }"
role="button"
tabindex="0"
:aria-label="n.aria"
:aria-pressed="actif(n.id)"
@click="vf.ouvrirFiche(n.id)"
@keydown.enter.prevent="vf.ouvrirFiche(n.id)"
@keydown.space.prevent="vf.ouvrirFiche(n.id)"
@mouseenter="vf.survolId = n.id"
@mouseleave="vf.survolId = null"
@focus="vf.survolId = n.id"
@blur="vf.survolId = null"
>
<rect
:x="n.x - n.l / 2" :y="n.y - n.h / 2" :width="n.l" :height="n.h" :rx="n.h / 2"
class="gm__pilule"
/>
<text :x="n.x" :y="n.y" text-anchor="middle" class="gm__libelle" aria-hidden="true">
<tspan v-for="(ligne, i) in n.lignes" :key="i" :x="n.x" :y="n.y + (i - (n.lignes.length - 1) / 2) * INTERLIGNE + 4.5">{{ ligne }}</tspan>
</text>
</g>
<!-- Libellé de la feuille survolée, au-dessus de tout -->
<g v-if="bulle" class="gm__bulle" aria-hidden="true">
<rect :x="bulle.x - bulle.l / 2" :y="bulle.y - 11" :width="bulle.l" height="22" rx="6" class="gm__bulle-fond" />
<text :x="bulle.x" :y="bulle.y + 4" text-anchor="middle" class="gm__bulle-texte">{{ bulle.texte }}</text>
</g>
</svg>
</div>
</template>
<script setup lang="ts">
import type { VueFichesEtat } from '~/composables/useVueFiches'
const props = defineProps<{
vf: VueFichesEtat
mots: { id: string; mot: string; ressources: { id: string; titre: string }[]; vide?: boolean }[]
}>()
const RAYON_CENTRE = 36
const INTERLIGNE = 15
const CARACTERES_LIGNE = 16
const PORTEE_FEUILLES = 46
const MARGE = 10
// ── Taille : mesurée au montage (le rendu serveur part d'une taille plausible) ──
const racine = ref<HTMLElement | null>(null)
const taille = reactive({ w: 720, h: 600 })
onMounted(() => {
const el = racine.value
if (!el || typeof ResizeObserver === 'undefined') return
const maj = (w: number, h: number) => {
if (w > 40 && h > 40) { taille.w = Math.round(w); taille.h = Math.round(h) }
}
maj(el.clientWidth, el.clientHeight)
const ro = new ResizeObserver((entrees) => {
const r = entrees[0]?.contentRect
if (r) maj(r.width, r.height)
})
ro.observe(el)
onBeforeUnmount(() => ro.disconnect())
})
// ── Mise en page : fonction pure de (taille, mots) ──
/** Une ligne si ça tient, sinon deux lignes équilibrées (« naviguer / avec soi », pas « naviguer avec / soi »). */
function couper(texte: string): string[] {
if (texte.length <= CARACTERES_LIGNE) return [texte]
const mots = texte.split(' ')
let meilleur: string[] = [texte]
let pire = Infinity
for (let i = 1; i < mots.length; i++) {
const l = [mots.slice(0, i).join(' '), mots.slice(i).join(' ')]
const m = Math.max(l[0].length, l[1].length)
if (m < pire) { pire = m; meilleur = l }
}
return meilleur
}
function tronquer(t: string, max = 44): string {
return t.length > max ? `${t.slice(0, max - 1).trimEnd()}…` : t
}
const centre = computed(() => ({ x: taille.w / 2, y: taille.h / 2 }))
const noeuds = computed(() => {
const n = props.mots.length
const { w, h } = taille
const base = props.mots.map((m) => {
const lignes = couper(m.mot)
return { m, lignes, l: Math.max(...lignes.map((x) => x.length)) * 6.9 + 24, h: lignes.length * INTERLIGNE + 11 }
})
const lMax = Math.max(...base.map((b) => b.l))
const hMax = Math.max(...base.map((b) => b.h))
const rx = Math.max(48, w / 2 - lMax / 2 - PORTEE_FEUILLES - MARGE)
const ry = Math.max(70, h / 2 - hMax / 2 - PORTEE_FEUILLES - MARGE)
const cx = centre.value.x
const cy = centre.value.y
// Colonne étroite : la couronne ne tient plus (les mots du bas se chevaucheraient).
// Les mots s'étagent alors en zigzag autour du centre, une rangée chacun, dans le
// même sens de lecture (haut, puis la droite en descendant, puis la gauche en remontant),
// et leurs ressources se rangent SOUS le mot.
const etroit = rx < lMax * 1.3
let place: { x: number; y: number }[]
if (etroit) {
const droite = Math.ceil((n - 1) / 2)
const rangsDroite = Array.from({ length: droite }, (_, i) => 1 + 2 * i)
const rangsGauche = Array.from({ length: n }, (_, i) => i).filter((r) => r > 0 && !rangsDroite.includes(r)).reverse()
const rang = (i: number) => (i === 0 ? 0 : i <= droite ? rangsDroite[i - 1] : rangsGauche[i - droite - 1])
const y0 = hMax / 2 + MARGE
const y1 = h - hMax / 2 - MARGE - 24 // de la place sous le dernier mot pour ses feuilles
const pas = n > 1 ? (y1 - y0) / (n - 1) : 0
const dx = Math.max(0, w / 2 - lMax / 2 - 6)
place = base.map((_, i) => ({ x: i === 0 ? cx : i <= droite ? cx + dx : cx - dx, y: y0 + rang(i) * pas }))
} else {
place = base.map((_, i) => {
const a = -Math.PI / 2 + (i * 2 * Math.PI) / n // depuis le haut, sens horaire
return { x: cx + rx * Math.cos(a), y: cy + ry * Math.sin(a) }
})
}
return base.map((b, i) => {
const { x, y } = place[i]
const k = b.m.ressources.length
let feuilles: { id: string; titre: string; x: number; y: number }[]
if (etroit) {
const ecart = k > 1 ? Math.min(17, (b.l - 20) / (k - 1)) : 0
feuilles = b.m.ressources.map((r, j) => ({
id: r.id,
titre: r.titre,
x: x + (j - (k - 1) / 2) * ecart,
y: y + b.h / 2 + 13 + (j % 2 ? 9 : 0),
}))
} else {
// direction « vers l'extérieur » réelle (l'ellipse n'est pas un cercle)
const dir = Math.atan2(y - cy, x - cx)
const pas = Math.min(0.55, 2.3 / Math.max(k, 1))
const dist = Math.abs(Math.cos(dir)) * (b.l / 2) + Math.abs(Math.sin(dir)) * (b.h / 2) + 20
feuilles = b.m.ressources.map((r, j) => {
const t = dir + (j - (k - 1) / 2) * pas
const d = dist + (j % 2 ? 12 : 0)
return {
id: r.id,
titre: r.titre,
x: Math.min(w - 8, Math.max(8, x + d * Math.cos(t))),
y: Math.min(h - 8, Math.max(8, y + d * Math.sin(t))),
}
})
}
return {
id: b.m.id,
x, y, l: b.l, h: b.h, lignes: b.lignes,
vide: !!b.m.vide || k === 0,
aria: `${b.m.mot}, ${k === 0 ? 'aucune ressource pour l’instant' : k === 1 ? '1 ressource' : `${k} ressources`}`,
feuilles,
}
})
})
// ── États (tout vient de vf) ──
const actif = (id: string) => String(props.vf.ficheId ?? '') === id
const survol = (id: string) => props.vf.survolId != null && String(props.vf.survolId) === id
const allume = (id: string) => actif(id) || survol(id)
const hors = (id: string) => !props.vf.idsResultat.has(id)
const survolFeuille = ref<string | null>(null)
function survolerFeuille(mot: string | null, feuille: string | null) {
survolFeuille.value = mot && feuille ? `${mot}/${feuille}` : null
props.vf.survolId = mot
}
const bulle = computed(() => {
if (!survolFeuille.value) return null
const [mot, feuille] = survolFeuille.value.split('/')
const f = noeuds.value.find((n) => n.id === mot)?.feuilles.find((x) => x.id === feuille)
if (!f) return null
const texte = tronquer(f.titre)
const l = texte.length * 6.3 + 16
const x = Math.min(taille.w - l / 2 - 4, Math.max(l / 2 + 4, f.x))
const y = f.y < 30 ? f.y + 24 : f.y - 18
return { x, y, l, texte }
})
</script>
<style scoped>
.gm {
position: relative;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
background: var(--bg-alt);
}
.gm__svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
font-family: inherit;
}
/* Arêtes fines */
.gm__arete { stroke: var(--nav-primary-solid); stroke-width: 1; stroke-opacity: 0.35; }
.gm__arete--feuille { stroke-opacity: 0.28; }
.gm__arete--vif { stroke-width: 2; stroke-opacity: 0.8; }
.gm__arete--hors { stroke-opacity: 0.12; }
/* Nœud central, mis en avant */
.gm__centre-rond { fill: var(--nav-primary-solid); stroke: var(--accent); stroke-width: 3; }
.gm__centre-texte { fill: var(--nav-text-on-primary); font-size: 12px; font-weight: 700; }
/* Feuilles : petits nœuds pleins */
.gm__feuille {
fill: var(--nav-primary-solid);
stroke: var(--bg-alt);
stroke-width: 1.5;
cursor: pointer;
transition: fill 120ms ease-out;
}
.gm__feuille--vive { fill: var(--accent); stroke: var(--nav-primary-solid); }
.gm__feuilles--hors { opacity: 0.3; }
/* Mots : pastilles qui portent leur libellé */
.gm__mot { cursor: pointer; outline: none; transition: opacity 120ms ease-out; }
.gm__pilule {
fill: var(--surface);
stroke: var(--nav-primary-solid);
stroke-width: 2;
transition: fill 120ms ease-out, stroke 120ms ease-out;
}
.gm__libelle { fill: var(--text); font-size: 12.5px; font-weight: 600; pointer-events: none; }
.gm__mot--vide .gm__pilule { stroke-dasharray: 5 4; }
.gm__mot--actif .gm__pilule { fill: var(--accent); stroke: var(--nav-primary-solid); }
.gm__mot--actif .gm__libelle { fill: var(--on-accent); }
.gm__mot--hors { opacity: 0.4; }
.gm__mot:focus-visible .gm__pilule { stroke: var(--focus-ring); stroke-width: 4; }
/* Libellé de feuille */
.gm__bulle { pointer-events: none; }
.gm__bulle-fond { fill: var(--surface); stroke: var(--nav-primary-solid); stroke-width: 1; }
.gm__bulle-texte { fill: var(--text); font-size: 11.5px; font-weight: 500; }
@media (prefers-reduced-motion: reduce) {
.gm__feuille, .gm__mot, .gm__pilule { transition: none; }
}
</style>
+225
View File
@@ -0,0 +1,225 @@
<!--
OutilsPartirDeSoi — B6-M2. Le sous-onglet « Partir de soi » de /outils : les
sept mots de la slide 17 de la conférence, un mot = une fiche, rendus par
la vue fiches partagée (état Mixte : liste des mots | graphe | fiche ouverte).
Troisième page branchée sur VueFiches (DESIGN-SYSTEM §Brancher une troisième
page). Les données viennent de `partir_de_soi` dans public/data/outils.json.
Monté seulement quand `?tab=partir-de-soi` : l'état de la vue (q, filtres,
fiche…) vit dans l'URL et disparaît avec l'onglet. `tab=` est recopié par la
vue à chacune de ses écritures (paramètre qu'elle ne gère pas).
-->
<template>
<div class="ops">
<VueFiches
:vf="vf"
intention="Sept mots de la conférence, et pour chacun des ressources libres qui m'ont aidé ou qu'on m'a confiées. Ouvre un mot."
libelle-recherche="Rechercher une ressource…"
:libelles-vue="{ carte: 'Graphe' }"
:lien-page="() => null"
>
<template #carte>
<GrapheMots :vf="vf" :mots="motsGraphe" />
</template>
<template #detail="{ fiche, filtrer }">
<article v-if="parId.get(String(fiche.id))" class="mot">
<h2 class="mot__titre">{{ parId.get(String(fiche.id))!.mot }}</h2>
<p class="mot__resume">{{ parId.get(String(fiche.id))!.resume }}</p>
<template v-if="parId.get(String(fiche.id))!.vide">
<p class="mot__vide">{{ parId.get(String(fiche.id))!.vide }}</p>
<NuxtLink to="/proposer?categorie=outils" class="mot__proposer">Proposer une ressource</NuxtLink>
</template>
<ul v-else class="ress" :aria-label="`Ressources pour ${parId.get(String(fiche.id))!.mot}`">
<li v-for="r in ressourcesDe(String(fiche.id))" :key="r.id" class="ress__carte">
<p class="ress__titre">
<a v-if="r.url" :href="r.url" target="_blank" rel="noopener noreferrer">{{ r.titre }}<span class="ress__ext" aria-hidden="true"> ↗</span><span class="vf-sr-mot"> (nouvel onglet)</span></a>
<template v-else>{{ r.titre }}</template>
</p>
<ChipGroup :aria-label="`Format et accès de ${r.titre}`" class="ress__chips">
<Chip
:label="r.format"
variant="fonction"
:active="(vf.etat.filtres.format ?? []).includes(slugEtiquette(r.format))"
:aria-label="`Filtrer par format : ${r.format}`"
@toggle="filtrer('format', slugEtiquette(r.format))"
/>
<Chip
:label="r.acces"
variant="echelle"
:active="(vf.etat.filtres.acces ?? []).includes(slugEtiquette(r.acces))"
:aria-label="`Filtrer par accès : ${r.acces}`"
@toggle="filtrer('acces', slugEtiquette(r.acces))"
/>
</ChipGroup>
<p v-if="r.pourquoi" class="ress__pourquoi">{{ r.pourquoi }}</p>
<p v-if="r.urls?.length" class="ress__liens">
<template v-for="(u, i) in r.urls" :key="u">
<span v-if="i > 0" aria-hidden="true"> · </span>
<a :href="u" target="_blank" rel="noopener noreferrer">{{ u.replace(/^https:\/\//, '') }}<span class="vf-sr-mot"> (nouvel onglet)</span></a>
</template>
</p>
<p v-if="r.note" class="ress__note">{{ r.note }}</p>
<ul v-if="r.episodes?.length" class="eps" :aria-label="`Épisodes de ${r.titre}`">
<li v-for="ep in r.episodes" :key="ep.ep" class="eps__item" :class="{ 'eps__item--muet': !ep.url }">
<span class="eps__ep">EP/{{ ep.ep }}</span>
<span class="eps__corps">
<a v-if="ep.url" :href="ep.url" target="_blank" rel="noopener noreferrer" class="eps__titre">{{ ep.titre }}<span class="vf-sr-mot"> (nouvel onglet)</span></a>
<span v-else class="eps__titre">{{ ep.titre }}</span>
<span class="eps__personnes"> — {{ ep.personnes }}</span>
<span v-if="ep.note" class="eps__note"> ({{ ep.note }})</span>
</span>
</li>
</ul>
</li>
</ul>
</article>
</template>
</VueFiches>
</div>
</template>
<script setup lang="ts">
import type { ConfigVueFiches } from '~/utils/vueFiches'
import { toMiniMot, valeursRessources, slugEtiquette, type FicheMot, type RessourceMot } from '~/composables/useFicheAdapter'
interface RessourceComplete extends RessourceMot {
url?: string | null
urls?: string[]
note?: string
episodes?: { ep: string; titre: string; personnes: string; url?: string | null; note?: string }[]
}
interface DonneesPartirDeSoi {
fiches: (FicheMot & { vide?: string | null })[]
ressources: RessourceComplete[]
}
const props = defineProps<{ data: DonneesPartirDeSoi }>()
const ressourcesDe = (motId: string) => props.data.ressources.filter((r) => r.fiches.includes(motId))
const parId = computed(() => new Map(props.data.fiches.map((f) => [f.id, f])))
// Étape 1 : adaptateur (un mot → une MiniFiche). L'ordre du tableau est celui de la slide.
const fichesMini = computed(() => props.data.fiches.map((f) => toMiniMot(f, ressourcesDe(f.id))))
// Étape 2 : configuration. Sept fiches, pas de groupe ; tri « ordre de la slide » par défaut.
const config = computed<ConfigVueFiches>(() => ({
dimensions: [
{ cle: 'format', libelle: 'Format', variante: 'fonction', valeurs: valeursRessources(props.data.ressources, 'format') },
{ cle: 'acces', libelle: 'Accès', variante: 'echelle', valeurs: valeursRessources(props.data.ressources, 'acces') },
],
tris: ['ordre', 'pertinence', 'nom'],
triDefaut: 'ordre',
libellesTri: { ordre: 'Ordre de la slide' },
groupes: [],
etats: ['carte', 'mixte', 'fiches'],
}))
const vf = useVueFiches(fichesMini, config)
// Étape 3 : le graphe (slot #carte) lit les mêmes données et l'état de la vue.
const motsGraphe = computed(() =>
props.data.fiches.map((f) => ({
id: f.id,
mot: f.mot,
vide: !!f.vide,
ressources: ressourcesDe(f.id).map((r) => ({ id: r.id, titre: r.titre })),
})),
)
// Une fiche ouverte donne son nom à l'onglet du navigateur.
useSeoMeta({
title: () => (vf.ficheOuverte ? `${vf.ficheOuverte.nom} — Partir de soi — AEP` : 'Outils — AEP — Partir de soi'),
})
</script>
<style scoped>
.ops { height: 100%; min-height: 0; min-width: 0; }
/* Lecteurs d'écran seulement */
.vf-sr-mot {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
/* Fiche d'un mot */
.mot__titre { margin: 0 0 var(--space-xs); font-size: 1.25rem; font-weight: var(--fw-bold); line-height: 1.25; }
.mot__resume {
margin: 0 0 var(--space-md);
font-size: var(--fs-sm);
line-height: 1.55;
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.mot__vide {
margin: 0 0 var(--space-sm);
padding: var(--space-sm);
border: 1.5px dashed var(--border-strong);
border-radius: var(--radius-md);
font-size: var(--fs-sm);
line-height: 1.55;
}
.mot__proposer {
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 0 var(--space-md);
border-radius: var(--radius-md);
background: var(--accent);
color: var(--on-accent);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
text-decoration: none;
}
.mot__proposer:hover { opacity: 0.9; }
.mot__proposer:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
/* Ressources */
.ress { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-sm); }
.ress__carte {
display: flex;
flex-direction: column;
gap: var(--space-xs);
min-width: 0;
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg);
}
.ress__carte p { margin: 0; overflow-wrap: anywhere; }
.ress__titre { font-size: var(--fs-sm); font-weight: var(--fw-semibold); line-height: 1.35; }
.ress__titre a,
.ress__liens a { color: var(--nav-primary-solid); text-underline-offset: 2px; }
.ress__titre a:focus-visible,
.ress__liens a:focus-visible,
.eps__titre:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; border-radius: var(--radius-sm); }
.ress__pourquoi { font-size: var(--fs-sm); line-height: 1.5; }
.ress__liens { font-size: var(--fs-xs, 0.75rem); }
.ress__note { font-size: var(--fs-xs, 0.75rem); font-style: italic; color: color-mix(in srgb, var(--text) 78%, transparent); }
/* Épisodes OFQA */
.eps { list-style: none; margin: var(--space-2xs) 0 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2xs); }
.eps__item { display: flex; gap: var(--space-xs); align-items: baseline; font-size: var(--fs-sm); line-height: 1.4; }
.eps__ep {
flex: 0 0 3.6rem;
font-family: var(--font-mono, 'JetBrains Mono', 'Courier New', monospace);
font-size: var(--fs-xs, 0.75rem);
font-weight: var(--fw-semibold);
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.eps__corps { min-width: 0; overflow-wrap: anywhere; }
.eps__titre { font-weight: var(--fw-medium); color: var(--text); }
a.eps__titre { color: var(--nav-primary-solid); }
.eps__personnes,
.eps__note { color: color-mix(in srgb, var(--text) 78%, transparent); font-size: var(--fs-xs, 0.75rem); }
.eps__note { font-style: italic; }
.eps__item--muet .eps__titre { color: color-mix(in srgb, var(--text) 78%, transparent); }
</style>
+16
View File
@@ -14,6 +14,7 @@
<span v-if="tag" :class="['simu-feature__badge', `simu-feature__badge--${tag}`]">{{ tagLabel }}</span> <span v-if="tag" :class="['simu-feature__badge', `simu-feature__badge--${tag}`]">{{ tagLabel }}</span>
</div> </div>
<p class="simu-feature__desc">{{ description }}</p> <p class="simu-feature__desc">{{ description }}</p>
<p v-if="mention" class="simu-feature__mention">{{ mention }}</p>
</div> </div>
</div> </div>
<span v-if="cta && url" class="simu-feature__cta">{{ cta }}</span> <span v-if="cta && url" class="simu-feature__cta">{{ cta }}</span>
@@ -29,6 +30,8 @@ const props = defineProps<{
description?: string description?: string
cta?: string cta?: string
tag?: string tag?: string
/** Ligne de réserve datée sous la description (ex. « Ordre de grandeur — version du … »). */
mention?: string | null
}>() }>()
const tagLabels: Record<string, string> = { const tagLabels: Record<string, string> = {
@@ -127,6 +130,13 @@ const tagLabel = computed(() => props.tag ? (tagLabels[props.tag] ?? props.tag)
line-height: 1.55; line-height: 1.55;
} }
.simu-feature__mention {
font-size: 0.78rem;
font-style: italic;
color: color-mix(in srgb, var(--text) 78%, transparent);
margin: 0.4rem 0 0;
}
.simu-feature__cta { .simu-feature__cta {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -145,6 +155,12 @@ const tagLabel = computed(() => props.tag ? (tagLabels[props.tag] ?? props.tag)
opacity: 0.88; opacity: 0.88;
} }
@media (max-width: 640px) {
.simu-feature { padding: 1.1rem 1rem; }
.simu-feature__inner { flex-direction: column; align-items: stretch; gap: 1rem; }
.simu-feature__cta { justify-content: center; }
}
:global(.dark) .simu-feature__badge { :global(.dark) .simu-feature__badge {
background: #064e3b; background: #064e3b;
color: #a7f3d0; color: #a7f3d0;
+7 -3
View File
@@ -23,7 +23,7 @@
:class="`vf-seg__btn--${e}`" :class="`vf-seg__btn--${e}`"
:aria-pressed="vf.vueAffichee === e" :aria-pressed="vf.vueAffichee === e"
@click="vf.setVue(e)" @click="vf.setVue(e)"
>{{ LIBELLES_VUE[e] }}<span v-if="e === 'fiches'" class="vf-seg__n"> · {{ vf.resultat.length }}</span></button> >{{ libellesVue?.[e] ?? LIBELLES_VUE[e] }}<span v-if="e === 'fiches'" class="vf-seg__n"> · {{ vf.resultat.length }}</span></button>
</div> </div>
<label class="vf-recherche"> <label class="vf-recherche">
@@ -79,7 +79,7 @@
<label class="vf-select vf-select--tri"> <label class="vf-select vf-select--tri">
<span>Tri</span> <span>Tri</span>
<select :value="vf.tri" @change="vf.setTri(($event.target as HTMLSelectElement).value as TriFiches)"> <select :value="vf.tri" @change="vf.setTri(($event.target as HTMLSelectElement).value as TriFiches)">
<option v-for="t in vf.cfg.tris" :key="t" :value="t" :selected="t === vf.tri">{{ LIBELLES_TRI[t] }}</option> <option v-for="t in vf.cfg.tris" :key="t" :value="t" :selected="t === vf.tri">{{ vf.cfg.libellesTri?.[t] ?? LIBELLES_TRI[t] }}</option>
</select> </select>
</label> </label>
@@ -248,7 +248,7 @@
<template v-if="vf.ficheOuverte"> <template v-if="vf.ficheOuverte">
<div class="vf-detail__actions"> <div class="vf-detail__actions">
<a v-if="lienPageFiche" :href="lienPageFiche" class="vf-lien">Ouvrir la page de la fiche</a> <a v-if="lienPageFiche" :href="lienPageFiche" class="vf-lien">Ouvrir la page de la fiche</a>
<button v-if="peutCentrer" type="button" class="vf-lien" @click="vf.voirSurCarte(vf.ficheOuverte.id)">Voir sur la carte</button> <button v-if="peutCentrer" type="button" class="vf-lien" @click="vf.voirSurCarte(vf.ficheOuverte.id)">{{ libelleVoirCarte }}</button>
<button type="button" class="vf-lien" @click="copierLien">Copier le lien</button> <button type="button" class="vf-lien" @click="copierLien">Copier le lien</button>
<span class="vf-detail__statut" role="status">{{ statutLien }}</span> <span class="vf-detail__statut" role="status">{{ statutLien }}</span>
</div> </div>
@@ -311,6 +311,8 @@ const props = defineProps<{
selectedId?: string | number | null selectedId?: string | number | null
/** Page propre de la fiche (`/fiche/:id` pour l'Entraide) ; null = pas de page, le lien partagé est l'URL avec `fiche=`. */ /** Page propre de la fiche (`/fiche/:id` pour l'Entraide) ; null = pas de page, le lien partagé est l'URL avec `fiche=`. */
lienPage?: (f: MiniFiche) => string | null lienPage?: (f: MiniFiche) => string | null
/** Libellés des trois états quand « Carte » n'est pas le mot juste (ex. { carte: 'Graphe' }). Le paramètre d'URL reste `vue=carte`. */
libellesVue?: Partial<Record<EtatVue, string>>
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -319,6 +321,8 @@ const emit = defineEmits<{
}>() }>()
const LIBELLES_VUE: Record<EtatVue, string> = { carte: 'Carte', mixte: 'Mixte', fiches: 'Fiches' } const LIBELLES_VUE: Record<EtatVue, string> = { carte: 'Carte', mixte: 'Mixte', fiches: 'Fiches' }
/** « Voir sur la carte » suit le mot choisi pour l'état Carte : « Voir dans le graphe ». */
const libelleVoirCarte = computed(() => (props.libellesVue?.carte ? `Voir dans le ${props.libellesVue.carte.toLowerCase()}` : 'Voir sur la carte'))
const etatsBoutons = computed(() => (['carte', 'mixte', 'fiches'] as EtatVue[]).filter((e) => props.vf.cfg.etats.includes(e))) const etatsBoutons = computed(() => (['carte', 'mixte', 'fiches'] as EtatVue[]).filter((e) => props.vf.cfg.etats.includes(e)))
// ── Pastilles actives (état Carte, et téléphone en Fiches) ── // ── Pastilles actives (état Carte, et téléphone en Fiches) ──
-13
View File
@@ -1,13 +0,0 @@
/**
* Mode de profondeur courant d'un chatbot (M3). État partagé par clé via useState :
* desktop et mobile d'un même chatbot (clés 'accueil', 'agences'…) restent synchronisés.
* Volontairement non persisté : le mode est un choix par question, 'rapide' à chaque visite.
*/
export function useChatProfondeur(key: string | (() => string) = 'defaut') {
const k = typeof key === 'function' ? key : () => key
const store = useState<Record<string, ChatProfondeur>>('aep-chat-profondeur', () => ({}))
return computed<ChatProfondeur>({
get: () => store.value[k()] ?? CHAT_PROFONDEUR_DEFAUT,
set: (v) => { store.value = { ...store.value, [k()]: normaliserProfondeur(v) } },
})
}
-103
View File
@@ -1,103 +0,0 @@
import type { MaybeRefOrGetter } from 'vue'
/**
* Persistance de l'historique d'un chatbot dans sessionStorage.
*
* - Clé de stockage : `aep-chat-<pageKey>`, valeur `{ v: 1, messages: [...] }`
* (une valeur d'une autre version est ignorée).
* - SSR-safe : aucun accès au stockage hors de onMounted. Côté serveur et à
* l'hydratation l'historique est vide, il est restauré juste après le mount.
* - Partagé : deux composants (desktop + mobile) qui déclarent la même clé
* lisent et écrivent le même tableau (useState), donc un redimensionnement
* ne perd rien et un composant n'écrase pas l'autre.
* - Tolérant : toute lecture/écriture est dans un try/catch (navigation
* privée, stockage bloqué, quota). En cas d'échec le chatbot fonctionne
* comme avant, sans persistance.
* - Les messages marqués `transient: true` (erreurs) restent affichés mais ne
* sont jamais écrits. Chargement et erreurs vivent hors de l'historique.
* - La forme des messages est libre (fiches, auteur, mode...) : tout champ
* sérialisable est conservé.
*/
export interface ChatSessionMessage {
role: string
content: string
transient?: boolean
}
const STORAGE_PREFIX = 'aep-chat-'
const SCHEMA_VERSION = 1
const DEFAULT_MAX = 40
export function useChatSession<T extends ChatSessionMessage>(
pageKey: MaybeRefOrGetter<string>,
options: { max?: number } = {},
) {
const max = options.max ?? DEFAULT_MAX
const store = useState<Record<string, T[]>>('aep-chat-store', () => ({}))
const restored = useState<Record<string, boolean>>('aep-chat-restored', () => ({}))
const key = computed(() => toValue(pageKey))
const mounted = ref(false)
function ensure(k: string) {
if (!store.value[k]) store.value[k] = []
}
const messages = computed<T[]>({
get: () => store.value[key.value] ?? [],
set: (list) => { store.value[key.value] = list },
})
const hasHistory = computed(() => messages.value.length > 0)
function isValid(m: any): m is T {
return !!m && typeof m === 'object' && typeof m.role === 'string' && typeof m.content === 'string'
}
function restore() {
const k = key.value
if (restored.value[k]) return
restored.value[k] = true
ensure(k)
try {
const raw = sessionStorage.getItem(STORAGE_PREFIX + k)
if (!raw) return
const data = JSON.parse(raw)
if (!data || data.v !== SCHEMA_VERSION || !Array.isArray(data.messages)) return
const valid = (data.messages as unknown[]).filter(isValid).slice(-max)
if (valid.length && store.value[k].length === 0) store.value[k] = valid
} catch {
/* stockage indisponible ou valeur corrompue : on repart de zéro */
}
}
function save() {
if (!mounted.value) return
try {
const list = messages.value.filter(m => !m.transient).slice(-max)
const sk = STORAGE_PREFIX + key.value
if (list.length === 0) sessionStorage.removeItem(sk)
else sessionStorage.setItem(sk, JSON.stringify({ v: SCHEMA_VERSION, messages: list }))
} catch {
/* quota, navigation privée : pas de persistance, pas d'erreur */
}
}
/** Efface l'historique affiché et la clé de stockage. */
function clear() {
store.value[key.value] = []
try { sessionStorage.removeItem(STORAGE_PREFIX + key.value) } catch { /* ignoré */ }
}
watch(key, (k) => {
ensure(k)
if (mounted.value) restore()
}, { immediate: true })
watch(messages, save, { deep: true })
onMounted(() => {
restore()
mounted.value = true
})
return { messages, hasHistory, clear }
}
+61 -2
View File
@@ -27,7 +27,7 @@ export interface MiniFiche {
chipsDimension?: string chipsDimension?: string
echelle?: string echelle?: string
geoloc: boolean geoloc: boolean
kind: 'org' | 'structure' kind: 'org' | 'structure' | 'mot'
/** Étiquettes complètes par dimension (valeurs telles qu'écrites dans l'URL). */ /** Étiquettes complètes par dimension (valeurs telles qu'écrites dans l'URL). */
etiquettes?: Record<string, string[]> etiquettes?: Record<string, string[]>
coords?: [number, number] coords?: [number, number]
@@ -134,6 +134,65 @@ export function toMini(source: Org | StructureV2): MiniFiche {
} }
} }
// ── Partir de soi (B6-M2) : un mot de la slide 17 = une fiche ─────────────
/** Une fiche de `public/data/outils.json` → `partir_de_soi.fiches[]`. */
export interface FicheMot {
id: string
mot: string
resume: string
vide?: string | null
}
/** Une ressource de `partir_de_soi.ressources[]`, reliée à ses mots par `fiches`. */
export interface RessourceMot {
id: string
titre: string
format: string
acces: string
pourquoi?: string
fiches: string[]
}
/** « don libre » → `don-libre` : la forme d'une valeur de filtre dans l'URL (`acces=don-libre`). */
export function slugEtiquette(s: string): string {
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
/** Valeurs distinctes d'un champ des ressources, dans l'ordre de première apparition (ordre stable des chips). */
export function valeursRessources(ressources: RessourceMot[], champ: 'format' | 'acces'): { valeur: string; libelle: string }[] {
const vus = new Map<string, string>()
for (const r of ressources) {
const l = r[champ]
if (l && !vus.has(slugEtiquette(l))) vus.set(slugEtiquette(l), l)
}
return [...vus].map(([valeur, libelle]) => ({ valeur, libelle }))
}
/**
* Un mot de la conférence → `MiniFiche`. `ressources` = celles qui portent ce mot.
* Pas de carte géographique : `geoloc: true` (aucune fiche « sans adresse »).
* La recherche couvre aussi les titres et les « pourquoi » des ressources.
*/
export function toMiniMot(fiche: FicheMot, ressources: RessourceMot[]): MiniFiche {
const formats = [...new Set(ressources.map((r) => r.format).filter(Boolean))]
const acces = [...new Set(ressources.map((r) => r.acces).filter(Boolean))]
return {
id: fiche.id,
nom: fiche.mot,
resume: buildResume([fiche.resume, fiche.vide]),
chips: formats.slice(0, 3),
chipsDimension: 'format',
geoloc: true,
kind: 'mot',
etiquettes: {
format: formats.map(slugEtiquette),
acces: acces.map(slugEtiquette),
},
texte: [fiche.resume, ...ressources.flatMap((r) => [r.titre, r.pourquoi])].filter(Boolean).join(' '),
}
}
export function useFicheAdapter() { export function useFicheAdapter() {
return { toMini } return { toMini, toMiniMot }
} }
-11
View File
@@ -58,17 +58,6 @@
<!-- 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"
chat-key="agences"
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))"
/> />
+492 -527
View File
File diff suppressed because it is too large Load Diff
+5 -23
View File
@@ -254,11 +254,8 @@
<!-- Messages de la conversation --> <!-- Messages de la conversation -->
<template v-for="(msg, i) in chatMessages" :key="i"> <template v-for="(msg, i) in chatMessages" :key="i">
<div :class="['taff-msg', msg.role === 'user' ? 'taff-msg--user' : 'taff-msg--bot']"> <div :class="['taff-msg', msg.role === 'user' ? 'taff-msg--user' : 'taff-msg--bot']">
<template v-if="msg.role === 'bot'"> <div v-if="msg.role === 'bot'" class="md-content" v-html="renderMd(msg.content)" />
<ChatModeBadge :profondeur="msg.profondeur" /> <span v-else>{{ msg.content }}</span>
<div class="md-content" v-html="renderMd(msg.content)" />
</template>
<span v-if="msg.role !== 'bot'">{{ msg.content }}</span>
</div> </div>
<!-- Plateformes recommandées --> <!-- Plateformes recommandées -->
<div v-if="msg.role === 'bot' && msg.recommandations?.length" class="taff-chat-recos"> <div v-if="msg.role === 'bot' && msg.recommandations?.length" class="taff-chat-recos">
@@ -278,13 +275,6 @@
<div v-if="chatLoading" class="taff-msg taff-msg--bot"> <div v-if="chatLoading" class="taff-msg taff-msg--bot">
<span class="taff-typing"><span/><span/><span/></span> <span class="taff-typing"><span/><span/><span/></span>
</div> </div>
<ChatNewConversation v-if="chatHasHistory && !chatLoading" @reset="clearChat" />
</div>
<!-- Mode de réponse -->
<div class="taff-chat-mode">
<ChatModeToggle v-model="chatProfondeur" :disabled="chatLoading" />
</div> </div>
<!-- Input --> <!-- Input -->
@@ -591,9 +581,6 @@ interface ChatMessage {
role: 'user' | 'bot' role: 'user' | 'bot'
content: string content: string
recommandations?: { id: string; nom: string; raison: string }[] recommandations?: { id: string; nom: string; raison: string }[]
profondeur?: string
/** Message d'erreur : affiché mais jamais persisté */
transient?: boolean
} }
const { render: renderMd } = useMarkdown() const { render: renderMd } = useMarkdown()
@@ -601,8 +588,7 @@ const { render: renderMd } = useMarkdown()
const chatOpen = ref(false) const chatOpen = ref(false)
const chatInput = ref('') const chatInput = ref('')
const chatLoading = ref(false) const chatLoading = ref(false)
const { messages: chatMessages, hasHistory: chatHasHistory, clear: clearChat } = useChatSession<ChatMessage>('taff') const chatMessages = ref<ChatMessage[]>([])
const chatProfondeur = useChatProfondeur('taff')
const chatMessagesEl = ref<HTMLElement | null>(null) const chatMessagesEl = ref<HTMLElement | null>(null)
async function sendChat() { async function sendChat() {
@@ -611,22 +597,20 @@ async function sendChat() {
chatMessages.value.push({ role: 'user', content: q }) chatMessages.value.push({ role: 'user', content: q })
chatInput.value = '' chatInput.value = ''
chatLoading.value = true chatLoading.value = true
const mode = chatProfondeur.value
await nextTick() await nextTick()
chatMessagesEl.value?.scrollTo({ top: chatMessagesEl.value.scrollHeight, behavior: 'smooth' }) chatMessagesEl.value?.scrollTo({ top: chatMessagesEl.value.scrollHeight, behavior: 'smooth' })
try { try {
const res = await $fetch<{ reponse_texte: string; plateformes_recommandees: { id: string; nom: string; raison: string }[] }>( const res = await $fetch<{ reponse_texte: string; plateformes_recommandees: { id: string; nom: string; raison: string }[] }>(
'/api/chatbot-taff', '/api/chatbot-taff',
{ method: 'POST', body: { question: q, profondeur: mode } } { method: 'POST', body: { question: q } }
) )
chatMessages.value.push({ chatMessages.value.push({
role: 'bot', role: 'bot',
content: res.reponse_texte, content: res.reponse_texte,
recommandations: res.plateformes_recommandees ?? [], recommandations: res.plateformes_recommandees ?? [],
profondeur: mode,
}) })
} catch (e: any) { } catch (e: any) {
chatMessages.value.push({ role: 'bot', content: e?.data?.statusMessage ?? 'Erreur — réessaie dans un instant.', transient: true }) chatMessages.value.push({ role: 'bot', content: e?.data?.statusMessage ?? 'Erreur — réessaie dans un instant.' })
} finally { } finally {
chatLoading.value = false chatLoading.value = false
await nextTick() await nextTick()
@@ -984,8 +968,6 @@ const parsedDescription = computed(() => {
40% { transform: translateY(-5px); opacity: 1; } 40% { transform: translateY(-5px); opacity: 1; }
} }
.taff-chat-mode { padding: 0.5rem 1rem 0; }
.taff-chat-input-wrap { .taff-chat-input-wrap {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
+2 -12
View File
@@ -1,6 +1,6 @@
{ {
"name": "FMHY — Sélection Architecte", "name": "FMHY — Sélection Architecte",
"description": "~50 ressources curées depuis FMHY (Free Media Heck Yeah) — pertinentes pour un architecte, un praticien de la transition, un créateur solo.", "description": "Une quarantaine de ressources légales curées depuis FMHY (Free Media Heck Yeah) — pertinentes pour un architecte, un praticien de la transition, un créateur solo.",
"children": [ "children": [
{ {
"name": "IA & Outils cognitifs", "name": "IA & Outils cognitifs",
@@ -16,10 +16,6 @@
{ {
"name": "Lecture & Documentation", "name": "Lecture & Documentation",
"children": [ "children": [
{ "name": "Anna's Archive", "url": "https://annas-archive.org/", "desc": "Bibliothèque shadow la plus complète du web. Livres, articles, thèses." },
{ "name": "Sci-Hub", "url": "https://sci-hub.se/", "desc": "Accès libre aux articles scientifiques payants." },
{ "name": "Library Genesis", "url": "https://libgen.is/", "desc": "Livres techniques et académiques en PDF." },
{ "name": "Z-Library", "url": "https://z-lib.id/", "desc": "Bibliothèque numérique massive, interface soignée." },
{ "name": "OpenLibrary (Internet Archive)", "url": "https://openlibrary.org/", "desc": "Prêt numérique gratuit, millions de livres." }, { "name": "OpenLibrary (Internet Archive)", "url": "https://openlibrary.org/", "desc": "Prêt numérique gratuit, millions de livres." },
{ "name": "Calibre", "url": "https://calibre-ebook.com/", "desc": "Gestion de bibliothèque numérique, convertisseur de formats." } { "name": "Calibre", "url": "https://calibre-ebook.com/", "desc": "Gestion de bibliothèque numérique, convertisseur de formats." }
] ]
@@ -27,12 +23,7 @@
{ {
"name": "Dessin & Modélisation", "name": "Dessin & Modélisation",
"children": [ "children": [
{ "name": "FreeCAD", "url": "https://www.freecad.org/", "desc": "Modélisation 3D open source, paramétrique. Alternative à Rhino pour usage simple." }, { "name": "Krita", "url": "https://krita.org/", "desc": "Dessin digital et croquis. Excellent pour les concepts." }
{ "name": "Blender", "url": "https://www.blender.org/", "desc": "3D, rendu, animation. La référence open source." },
{ "name": "Inkscape", "url": "https://inkscape.org/", "desc": "Dessin vectoriel. Alternative à Illustrator." },
{ "name": "GIMP", "url": "https://www.gimp.org/", "desc": "Retouche photo. Alternative à Photoshop." },
{ "name": "Krita", "url": "https://krita.org/", "desc": "Dessin digital et croquis. Excellent pour les concepts." },
{ "name": "LibreOffice Draw", "url": "https://www.libreoffice.org/", "desc": "Diagrammes, plans rapides, sans suite Adobe." }
] ]
}, },
{ {
@@ -92,7 +83,6 @@
"name": "Divers Utiles", "name": "Divers Utiles",
"children": [ "children": [
{ "name": "Nextcloud", "url": "https://nextcloud.com/", "desc": "Cloud personnel self-hosted. Alternative à Google Drive/Dropbox." }, { "name": "Nextcloud", "url": "https://nextcloud.com/", "desc": "Cloud personnel self-hosted. Alternative à Google Drive/Dropbox." },
{ "name": "Joplin", "url": "https://joplinapp.org/", "desc": "Notes chiffrées, sync Nextcloud, open source." },
{ "name": "draw.io / diagrams.net", "url": "https://app.diagrams.net/", "desc": "Diagrammes et schémas, gratuit, pas de compte requis." }, { "name": "draw.io / diagrams.net", "url": "https://app.diagrams.net/", "desc": "Diagrammes et schémas, gratuit, pas de compte requis." },
{ "name": "Excalidraw", "url": "https://excalidraw.com/", "desc": "Tableau blanc collaboratif, style hand-drawn, open source." }, { "name": "Excalidraw", "url": "https://excalidraw.com/", "desc": "Tableau blanc collaboratif, style hand-drawn, open source." },
{ "name": "Fmhy.net (complet)", "url": "https://fmhy.net/", "desc": "L'arbre complet. Des milliers de ressources organisées par thème." } { "name": "Fmhy.net (complet)", "url": "https://fmhy.net/", "desc": "L'arbre complet. Des milliers de ressources organisées par thème." }
+930 -27
View File
@@ -1,5 +1,10 @@
{ {
"simulateurs": [ "version": 2,
"maj": "2026-10-08",
"intro": "En tant qu'architecte, on jongle avec une multitude d'outils — simulation, dessin, calcul, recherche, partage. Les mutualiser, se conseiller dessus, savoir lequel utiliser quand : c'est une forme d'entraide concrète. Voici ceux que je propose dans un premier temps. Chacun peut contribuer pour enrichir cette boîte à outils commune.",
"conception": {
"intention": "Les outils de calcul situés d'AEP, pour cadrer un projet avant de dessiner. Ouvre un simulateur et lis le résultat comme un ordre de grandeur.",
"outils": [
{ {
"id": "autonomie", "id": "autonomie",
"icon": "🟢", "icon": "🟢",
@@ -7,20 +12,444 @@
"url": "https://calculs.trans-former.fr/autonomie/", "url": "https://calculs.trans-former.fr/autonomie/",
"description": "Évaluer le degré d'autonomie d'une famille à un site donné selon les ressources locales (eau, énergie, alimentation).", "description": "Évaluer le degré d'autonomie d'une famille à un site donné selon les ressources locales (eau, énergie, alimentation).",
"cta": "Lancer le simulateur →", "cta": "Lancer le simulateur →",
"tag": "outil-aep" "tag": "outil-aep",
"mention": "Ordre de grandeur — version du 16/08/2026",
"verifie_le": "2026-10-08"
},
{
"id": "autoconstruction",
"icon": "🔨",
"titre": "Simulateur Autoconstruction",
"url": "https://calculs.trans-former.fr/autoconstruction/",
"description": "Cadrer un projet d'autoconstruction : quantités et coûts des matériaux, surfaces, géométrie, calculés en temps réel.",
"cta": "Lancer le simulateur →",
"tag": "outil-aep",
"mention": "Ordre de grandeur — version du 16/08/2026",
"verifie_le": "2026-10-08"
} }
], ],
"simulateurs_inspirations": [ "inspirations": [
{ {
"id": "florquin-prix-m2", "id": "florquin-prix-m2",
"icon": "💡", "icon": "💡",
"titre": "Estimation prix au m² — Florquin Studio", "titre": "Estimation prix au m² — Florquin Studio",
"url": "https://offre.florquinstudio.com/", "url": "https://offre.florquinstudio.com/",
"description": "Une agence parisienne qui a construit un système d'estimation au m² assez fin. Pas dans nos outils, mais inspirant pour qui veut industrialiser son chiffrage.", "description": "Une agence parisienne qui a construit un système d'estimation au m² assez fin. Pas dans nos outils, mais inspirant pour qui veut industrialiser son chiffrage.",
"tag": "inspiration-externe" "tag": "inspiration-externe",
"mention": null,
"verifie_le": null
} }
]
},
"open_source": {
"intention": "Des outils libres choisis pour un architecte indépendant, puis nos recommandations maison et un annuaire du web libre classé par usage. Choisis un bloc dans le sommaire ou fais défiler.",
"selection": {
"intention": "Une sélection d'outils libres utiles au quotidien d'un architecte indépendant : dictée, bureautique, dessin et modélisation, cartes et données publiques. Chacun a été ouvert et vérifié le 8 octobre 2026, avec sa licence et ses plateformes.",
"rubriques": [
{
"id": "dictee-voix",
"titre": "Dictée et voix",
"note": "Ces outils font tourner Whisper sur ton ordinateur : rien ne part dans le cloud, ce qui compte quand tu dictes un CR de chantier ou un échange client.",
"liens": [
{
"nom": "Handy",
"url": "https://handy.computer/",
"desc": "Raccourci clavier, tu parles, le texte s'insère au curseur dans n'importe quelle application : dicter un mail ou une note de chantier sans quitter le logiciel ouvert, avec transcription locale.",
"source": "aep",
"licence": "MIT",
"plateformes": [
"Windows",
"macOS",
"Linux"
], ],
"opensource": [ "verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (v0.9.8 du 2026-10-03)"
},
{
"nom": "Buzz",
"url": "https://buzzcaptions.com/",
"desc": "Transcrit un enregistrement de réunion ou de visite (fichier audio ou vidéo) en texte, SRT ou TXT, hors ligne : base brute pour un compte rendu.",
"source": "aep",
"licence": "MIT",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (v1.4.5 du 2026-08-23)"
},
{
"nom": "Vibe",
"url": "https://github.com/thewh1teagle/vibe",
"desc": "Transcription locale de fichiers audio et vidéo, interface simple : pratique pour transformer un vocal de visite en texte à retravailler.",
"source": "aep",
"licence": "MIT",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (v3.2.2 du 2026-09-05)"
}
]
},
{
"id": "quotidien",
"titre": "Le quotidien d'une agence",
"note": "De quoi tenir un cabinet d'architecte sans abonnement : bureautique, courrier, notes, PDF, visio, devis et factures.",
"liens": [
{
"nom": "LibreOffice",
"url": "https://www.libreoffice.org/",
"desc": "Rédiger CCTP, notes de calcul, tableurs de métrés et devis au format Word/Excel/PDF sans licence Microsoft.",
"source": "aep",
"licence": "MPL-2.0",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-08 (v26.8.1)"
},
{
"nom": "Thunderbird",
"url": "https://www.thunderbird.net/fr/",
"desc": "Client mail et agenda pour centraliser plusieurs adresses (pro, projets, associatives) hors webmail, avec filtres et signatures.",
"source": "aep",
"licence": "MPL-2.0",
"plateformes": [
"Windows",
"macOS",
"Linux",
"Android"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026 (v157.0.1 sur le site le 2026-10-08)"
},
{
"nom": "Joplin",
"url": "https://joplinapp.org/",
"desc": "Notes en markdown synchronisées sur téléphone et ordinateur (chiffrées de bout en bout) : carnet de chantier, listes de réserves, recherche rapide.",
"source": "aep",
"licence": "AGPL-3.0",
"plateformes": [
"Windows",
"macOS",
"Linux",
"Android",
"iOS"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-09 (v3.7.21 du 2026-09-25)"
},
{
"nom": "Stirling-PDF",
"url": "https://github.com/Stirling-Tools/Stirling-PDF",
"desc": "Fusionner, découper, compresser, signer ou passer en OCR un PDF (dossier de DP, pièces d'un marché) sans envoyer les plans chez un service tiers quand on l'installe en local.",
"source": "aep",
"licence": "MIT (cœur ; fonctions entreprise sous licence propriétaire, non nécessaires)",
"plateformes": [
"Web (auto-hébergé)",
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (v3.1.0 du 2026-10-05)"
},
{
"nom": "PDF Arranger",
"url": "https://github.com/pdfarranger/pdfarranger",
"desc": "Réordonner, pivoter, extraire ou fusionner les pages d'un PDF à la souris : composer un dossier de consultation propre en deux minutes.",
"source": "aep",
"licence": "GPL-3.0",
"plateformes": [
"Linux",
"Windows"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (v1.14.0 du 2026-05-23, dépôt actif le 2026-10-01)"
},
{
"nom": "Xournal++",
"url": "https://xournalpp.github.io/",
"desc": "Annoter un plan ou un PDF au stylet sur tablette ou écran tactile : markup de visite, retours sur un plan d'exécution.",
"source": "aep",
"licence": "GPL-2.0",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-09 (v1.3.8 du 2026-09-26)"
},
{
"nom": "Jitsi Meet",
"url": "https://jitsi.org/jitsi-meet/",
"desc": "Visioconférence sans compte, avec partage d'écran, pour une réunion de chantier à distance ou un rendez-vous client ; instance publique gratuite ou auto-hébergeable.",
"source": "aep",
"licence": "Apache-2.0",
"plateformes": [
"Web",
"Android",
"iOS"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-09 (version stable du 2026-09-14)"
},
{
"nom": "CryptPad",
"url": "https://cryptpad.org/",
"desc": "Documents, tableurs et tableaux partagés en ligne, chiffrés : co-éditer un CR ou un planning avec un client sans lui demander de créer un compte.",
"source": "aep",
"licence": "AGPL-3.0",
"plateformes": [
"Web"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (v2026.9.0 du 2026-10-07)"
},
{
"nom": "Dolibarr",
"url": "https://www.dolibarr.org/",
"desc": "ERP léger auto-hébergeable : devis, factures, suivi des paiements pour une agence ou un freelance qui veut sortir du tableur.",
"source": "aep",
"licence": "GPL-3.0",
"plateformes": [
"Web (auto-hébergé)"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-09 (v24.0.1 du 2026-09-07)"
}
]
},
{
"id": "dessiner-modeliser",
"titre": "Dessiner, modéliser, construire",
"note": "La chaîne libre de la CAO au BIM, de l'illustration à la maquette. FreeCAD et Bonsai sont sérieux mais demandent plusieurs semaines de prise en main.",
"liens": [
{
"nom": "FreeCAD",
"url": "https://www.freecad.org/",
"desc": "Modélisation paramétrique 3D avec atelier BIM, coupes et mise en plan : modéliser un projet et sortir des plans sans licence.",
"source": "aep",
"licence": "LGPL-2.1",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (v1.1.4 du 2026-09-28)"
},
{
"nom": "Bonsai (ex BlenderBIM)",
"url": "https://bonsaibim.org/",
"desc": "Créer et lire des modèles IFC (OpenBIM) dans Blender : produire un modèle BIM échangeable avec un bureau d'études sans passer par Revit ou ArchiCAD.",
"source": "aep",
"licence": "LGPL-3.0",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (build alpha du 2026-10-07, dépôt IfcOpenShell)"
},
{
"nom": "Blender",
"url": "https://www.blender.org/",
"desc": "Modélisation, rendu photoréaliste et animation pour illustrer un concours ou une esquisse ; socle de Bonsai.",
"source": "aep",
"licence": "GPL-3.0-or-later",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026 (v5.2 en téléchargement le 2026-10-08)"
},
{
"nom": "LibreCAD",
"url": "https://librecad.org/",
"desc": "Dessin 2D en DXF : reprendre un relevé, redessiner un plan de masse ou préparer un fichier pour la découpe, sans licence AutoCAD (pas de DWG natif).",
"source": "aep",
"licence": "GPL-2.0",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-05 (v2.2.1.5 du 2026-05-02, dépôt actif le 2026-10-07)"
},
{
"nom": "Inkscape",
"url": "https://inkscape.org/fr/",
"desc": "Dessin vectoriel : mettre au propre un schéma de principe, un plan-masse ou une planche de concours, export SVG et PDF.",
"source": "aep",
"licence": "GPL",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (commits du 2026-10-08)"
},
{
"nom": "GIMP",
"url": "https://www.gimp.org/",
"desc": "Retouche d'image : traiter des photos de visite, détourer, préparer des photomontages et des planches.",
"source": "aep",
"licence": "GPL-3.0-or-later",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (actualités du 2026-10-01)"
},
{
"nom": "WikiHouse Skylark",
"url": "https://github.com/wikihouseproject/Skylark",
"desc": "Système constructif open source en blocs isolants, plans de fabrication CNC publiés : référence pour étudier ou proposer une ossature bois partagée ; à faire valider par un ingénieur structure.",
"source": "aep",
"licence": "CC BY-SA 4.0",
"plateformes": [
"Web (dépôt de fichiers CAO)"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (commit du 2026-10-08)"
}
]
},
{
"id": "cartes-territoire",
"titre": "Cartes, cadastre et territoire",
"note": "Les données publiques utiles avant une étude de faisabilité : parcelle, urbanisme, risques, histoire du site.",
"liens": [
{
"nom": "QGIS",
"url": "https://qgis.org/",
"desc": "Système d'information géographique complet : croiser cadastre, PLU, relief et servitudes sur un même plan-masse, export DXF.",
"source": "aep",
"licence": "GPL-2.0",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-09 (v3.44.15 du 2026-09-25)"
},
{
"nom": "Géoportail (cartes.gouv.fr)",
"url": "https://cartes.gouv.fr/explorer-les-cartes/",
"desc": "Fonds IGN, cadastre, orthophotos et relief : repérer une parcelle, mesurer, imprimer un extrait ; l'ancien géoportail.gouv.fr redirige ici.",
"source": "aep",
"licence": "Service public, gratuit",
"plateformes": [
"Web"
],
"verifie_le": "2026-10-08",
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
},
{
"nom": "Cadastre – données ouvertes",
"url": "https://cadastre.data.gouv.fr/",
"desc": "Plan cadastral vectoriel et fichiers par commune en téléchargement libre, à charger dans QGIS pour un plan de situation.",
"source": "aep",
"licence": "Service public, gratuit",
"plateformes": [
"Web"
],
"verifie_le": "2026-10-08",
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
},
{
"nom": "Géoportail de l'Urbanisme",
"url": "https://www.geoportail-urbanisme.gouv.fr/",
"desc": "PLU, PLUi, cartes communales et servitudes opposables : la source officielle pour vérifier la règle d'une parcelle avant de rédiger une étude.",
"source": "aep",
"licence": "Service public, gratuit",
"plateformes": [
"Web"
],
"verifie_le": "2026-10-08",
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
},
{
"nom": "Géorisques",
"url": "https://www.georisques.gouv.fr/",
"desc": "Inondation, argiles, radon, sismicité, sites pollués pour une adresse : le contrôle de base avant d'acheter ou de chiffrer un projet.",
"source": "aep",
"licence": "Service public, gratuit",
"plateformes": [
"Web"
],
"verifie_le": "2026-10-08",
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
},
{
"nom": "Remonter le temps (IGN)",
"url": "https://remonterletemps.ign.fr/",
"desc": "Cartes et photos aériennes anciennes d'un lieu : lire l'histoire d'un site, repérer d'anciennes constructions ou d'anciens usages.",
"source": "aep",
"licence": "Service public, gratuit",
"plateformes": [
"Web"
],
"verifie_le": "2026-10-08",
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
},
{
"nom": "API Carto (IGN)",
"url": "https://apicarto.ign.fr/api/doc/",
"desc": "Interroger parcelles, zonage et servitudes par API : base technique pour automatiser un plan de situation.",
"source": "aep",
"licence": "Service public, gratuit",
"plateformes": [
"Web"
],
"verifie_le": "2026-10-08",
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
},
{
"nom": "Demandes de valeurs foncières",
"url": "https://app.dvf.etalab.gouv.fr/",
"desc": "Prix de vente des biens par commune ou parcelle : étayer une fourchette d'estimation ou un argumentaire de réhabilitation.",
"source": "aep",
"licence": "Service public, gratuit",
"plateformes": [
"Web"
],
"verifie_le": "2026-10-08",
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
},
{
"nom": "Cartofriches (Cerema)",
"url": "https://cartofriches.cerema.fr/cartofriches/",
"desc": "Recensement national des friches : repérer des sites à reconvertir pour une approche de réhabilitation ou de renaturation.",
"source": "aep",
"licence": "Service public, gratuit",
"plateformes": [
"Web"
],
"verifie_le": "2026-10-08",
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
}
]
}
]
},
"recommandes": [
{ {
"id": "dictee-universelle-groq", "id": "dictee-universelle-groq",
"icon": "🎤", "icon": "🎤",
@@ -54,37 +483,511 @@
"tag": "a-venir" "tag": "a-venir"
} }
], ],
"bifurcation": { "rubriques": [
"intro": "Beaucoup de jeunes diplômés en archi cherchent des chemins alternatifs. Cette section rassemble des témoignages, expériences, et ressources sur ce que c'est que de bifurquer.", {
"videos_ofqa": [ "id": "vie-privee",
"titre": "Vie privée & adblocking — les bases",
"note": "Le premier réflexe du guide débutant fmhy : un bon bloqueur de pubs rend la moitié du web à nouveau respirable.",
"liens": [
{ "nom": "uBlock Origin", "url": "https://github.com/gorhill/uBlock", "desc": "bloqueur de pubs de référence, léger et open source", "source": "fmhy" },
{ "nom": "SponsorBlock", "url": "https://sponsor.ajay.app/", "desc": "saute automatiquement les segments sponsorisés de YouTube", "source": "fmhy" },
{ "nom": "LibreWolf", "url": "https://librewolf.net/", "desc": "Firefox durci vie privée, pré-configuré", "source": "fmhy" },
{ "nom": "Proton VPN", "url": "https://protonvpn.com", "desc": "VPN suisse, offre gratuite illimitée", "source": "fmhy" },
{ "nom": "Bitwarden", "url": "https://bitwarden.com/", "desc": "gestionnaire de mots de passe open source", "source": "fmhy" },
{ "nom": "Cryptomator", "url": "https://cryptomator.org/", "desc": "chiffrement de tes fichiers avant envoi dans le cloud", "source": "fmhy" },
{ "nom": "Tor Browser", "url": "https://www.torproject.org/", "desc": "navigation anonyme via le réseau Tor", "source": "fmhy" },
{ "nom": "KeePassXC", "url": "https://keepassxc.org/", "desc": "coffre de mots de passe 100% local", "source": "fmhy" }
]
},
{
"id": "livres",
"titre": "Lire : domaine public & liseuses",
"liens": [
{ "nom": "Project Gutenberg", "url": "https://www.gutenberg.org/", "desc": "70 000+ classiques du domaine public, 100% légal", "source": "fmhy" },
{ "nom": "Calibre", "url": "https://calibre-ebook.com/", "desc": "gestion et conversion de bibliothèque numérique", "source": "fmhy" },
{ "nom": "KOReader", "url": "https://koreader.rocks/", "desc": "lecteur ebook open source pour liseuses", "source": "fmhy" }
]
},
{
"id": "films",
"titre": "Films & séries en accès légal",
"liens": [
{ "nom": "Tubi", "url": "https://tubitv.com", "desc": "streaming légal et gratuit (financé par la pub)", "source": "fmhy" },
{ "nom": "Stremio", "url": "https://www.stremio.com/", "desc": "centre multimédia extensible par extensions", "source": "fmhy" },
{ "nom": "Simkl", "url": "https://simkl.com/", "desc": "suivi de films, séries et anime déjà vus", "source": "fmhy" }
]
},
{
"id": "musique",
"titre": "Radios & podcasts",
"liens": [
{ "nom": "NTS Radio", "url": "https://www.nts.live/", "desc": "shows de DJ indépendants, archives immenses", "source": "fmhy" },
{ "nom": "SomaFM", "url": "https://somafm.com/", "desc": "30+ radios internet indépendantes", "source": "fmhy" },
{ "nom": "Radio Garden", "url": "https://radio.garden/", "desc": "explorer les radios du monde entier sur un globe", "source": "fmhy" },
{ "nom": "Pocket Casts", "url": "https://play.pocketcasts.com/", "desc": "lecteur de podcasts libre et complet, aussi dans le navigateur", "source": "fmhy" }
]
},
{
"id": "logiciels",
"titre": "Logiciels libres",
"liens": [
{ "nom": "qBittorrent", "url": "https://www.qbittorrent.org/", "desc": "client BitTorrent libre, sans pub — la référence", "source": "fmhy" }
]
},
{
"id": "mobile",
"titre": "Android / iOS",
"liens": [
{ "nom": "Aurora Store", "url": "https://auroraoss.com/", "desc": "Google Play alternatif, sans compte Google", "source": "fmhy" },
{ "nom": "Droid-ify", "url": "https://droidify.app/", "desc": "client F-Droid moderne — applications libres", "source": "fmhy" },
{ "nom": "APKMirror", "url": "https://www.apkmirror.com/", "desc": "archives APK vérifiées", "source": "fmhy" },
{ "nom": "Fossify", "url": "https://github.com/FossifyOrg", "desc": "suite d’apps Android simples et libres (galerie, agenda, fichiers…)", "source": "fmhy" },
{ "nom": "Obtainium", "url": "https://obtainium.imranr.dev/", "desc": "installe et met à jour tes APKs depuis les sources officielles", "source": "fmhy" }
]
},
{
"id": "education",
"titre": "Éducation, cours & recherche",
"liens": [
{ "nom": "MIT OpenCourseWare", "url": "https://ocw.mit.edu/", "desc": "les cours du MIT en accès libre complet", "source": "fmhy" },
{ "nom": "edX", "url": "https://www.edx.org/", "desc": "MOOCs de grandes universités", "source": "fmhy" },
{ "nom": "Khan Academy", "url": "https://www.khanacademy.org/", "desc": "maths, sciences, économie — gratuit, du collège au supérieur", "source": "fmhy" },
{ "nom": "Class Central", "url": "https://www.classcentral.com/", "desc": "moteur de recherche de cours en ligne", "source": "fmhy" },
{ "nom": "WolframAlpha", "url": "https://www.wolframalpha.com/", "desc": "moteur de calcul et base de connaissances", "source": "fmhy" },
{ "nom": "PhET", "url": "https://phet.colorado.edu/", "desc": "simulations interactives physique / chimie / maths", "source": "fmhy" }
]
},
{
"id": "ia",
"titre": "Intelligence artificielle gratuite",
"liens": [
{ "nom": "DeepSeek", "url": "https://chat.deepseek.com/", "desc": "modèle de pointe, usage illimité gratuit", "source": "fmhy" },
{ "nom": "Qwen Studio", "url": "https://chat.qwen.ai/", "desc": "chat IA (Qwen) illimité gratuit", "source": "fmhy" },
{ "nom": "Google AI Studio", "url": "https://aistudio.google.com/", "desc": "Gemini en accès généreux gratuit", "source": "fmhy" },
{ "nom": "Mistral Le Chat", "url": "https://chat.mistral.ai/", "desc": "LLM français, gratuit", "source": "fmhy" },
{ "nom": "LM Studio", "url": "https://lmstudio.ai/", "desc": "faire tourner des modèles en local, sans cloud", "source": "fmhy" },
{ "nom": "Jan", "url": "https://jan.ai/", "desc": "application desktop libre pour IA locale", "source": "fmhy" },
{ "nom": "Open WebUI", "url": "https://openwebui.com/", "desc": "interface web auto-hébergeable pour modèles locaux", "source": "fmhy" }
]
},
{
"id": "francophone",
"titre": "Contenus francophones",
"liens": [
{ "nom": "Gallica", "url": "https://gallica.bnf.fr/", "desc": "millions de documents numérisés de la BnF, domaine public", "source": "fmhy" },
{ "nom": "Nos Livres", "url": "https://noslivres.net/", "desc": "classiques français du domaine public, formats propres", "source": "fmhy" },
{ "nom": "Audiocite", "url": "https://www.audiocite.net/", "desc": "livres audio francophones du domaine public", "source": "fmhy" },
{ "nom": "Littérature audio", "url": "https://www.litteratureaudio.com/", "desc": "autre grande bibliothèque audio francophone libre", "source": "fmhy" },
{ "nom": "Dealabs", "url": "https://www.dealabs.com/", "desc": "bons plans et gratuités débusqués par la communauté", "source": "fmhy" }
]
}
],
"fmhy": {
"snapshot": "2026-08-25",
"url": "https://fmhy.net/",
"titre": "Annuaire du web libre — la cartographie FMHY",
"texte_intro": "FMHY (FreeMediaHeckYeah) est le plus grand index communautaire de ressources gratuites du web : des milliers de liens classés et vérifiés par des centaines de contributeurs bénévoles, mis à jour en continu. C'est une forme de résistance numérique — accéder librement au savoir, à la culture et aux outils, hors des catalogues marchands. J'en ai traduit et organisé les entrées phares par usage, pour aller droit au but.",
"avertissement": "Cette page ne relaie que des ressources légales. FMHY indexe aussi des contenus protégés, que cette page ne reprend pas. FMHY maintient une liste des sites dangereux (malwares, arnaques) : consulte-la avant de t'aventurer au-delà de cette sélection.",
"avertissement_lien": "https://fmhy.net/unsafe",
"avertissement_lien_texte": "liste des sites dangereux",
"snapshot_texte": "Cartographie établie d'après un snapshot du 25 août 2026 — l'index fmhy évolue en continu, voir",
"arbre_label": "Sélection interactive — clique sur les branches pour déplier",
"lien_texte": "Explorer tout l'arbre → fmhy.net"
},
"logiciels_pro": {
"badge": "Bientôt — nécessite un compte",
"titre": "Logiciels pro",
"description": "Logiciels lourds (Adobe, AutoCAD…) — accès mutualisé. Disponible après création de compte AEP.",
"status": "bientot-login"
}
},
"partir_de_soi": {
"intention": "Des ressources libres ou à porte gratuite, rangées sous les sept mots de la tête d'échelle. Cherche ton mot et ouvre ce qui te parle.",
"fiches": [
{
"id": "quete-de-sens",
"mot": "quête de sens",
"resume": "Bifurquer, donner du sens à son métier : un film, des témoignages d'architectes qui ont changé de voie, des podcasts.",
"vide": null
},
{
"id": "ou-habiter",
"mot": "où habiter, avec qui",
"resume": "Habiter avec d'autres : un guide pour monter un projet d'habitat participatif.",
"vide": null
},
{
"id": "gagner-sa-vie",
"mot": "gagner sa vie",
"resume": "Créer son activité et en vivre.",
"vide": "Rien encore : la conférence promettait des formations en accès libre (business, gestion du temps), aucune ressource libre ne les porte aujourd'hui. Si tu en connais une, propose-la."
},
{
"id": "sante-mentale",
"mot": "santé mentale",
"resume": "Repérer, comprendre, se faire accompagner : premiers secours en santé mentale, emprise, TDAH.",
"vide": null
},
{
"id": "mener-ses-projets",
"mot": "mener ses projets",
"resume": "Faire le bilan et se projeter, clarifier son après-diplôme. « Gérer son temps » est promis et pas encore porté.",
"vide": null
},
{
"id": "naviguer-avec-soi",
"mot": "naviguer avec soi",
"resume": "Méditation et pratiques d'introspection accessibles sans payer.",
"vide": null
},
{
"id": "competences-psychosociales",
"mot": "les compétences psychosociales",
"resume": "Le cadre de référence, la communication non violente, la facilitation, la confiance en soi.",
"vide": null
}
],
"ressources": [
{
"id": "en-quete-de-sens",
"titre": "En quête de sens (Coste, de la Ménardière)",
"url": "https://enquetedesens-lefilm.com",
"format": "film",
"acces": "don libre",
"pourquoi": "Le film nommé dans le deck ; streaming à participation libre.",
"fiches": ["quete-de-sens"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27",
"note": "lecteur non testé (rendu JS)"
},
{
"id": "ofqa",
"titre": "OFQA, « On fait quoi après ? »",
"url": null,
"format": "vidéo",
"acces": "libre",
"pourquoi": "Diplômés d'archi qui ont bifurqué (indépendant, artisanat, éco-construction).",
"fiches": ["quete-de-sens"],
"source": "outils.json (avant B6)",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27",
"episodes": [
{ "ep": "01", "titre": "Architectes indépendants", "personnes": "Jules Nény & Imane Fatmi", "url": "https://youtu.be/aMreB5KdNhY" }, { "ep": "01", "titre": "Architectes indépendants", "personnes": "Jules Nény & Imane Fatmi", "url": "https://youtu.be/aMreB5KdNhY" },
{ "ep": "02", "titre": "Artiste & Maçon", "personnes": "Romane Dutour & Maël Canal", "url": "https://youtu.be/9gpjokx2ndI" }, { "ep": "02", "titre": "Artiste & Maçon", "personnes": "Romane Dutour & Maël Canal", "url": "https://youtu.be/9gpjokx2ndI" },
{ "ep": "03", "titre": "Social & BTP", "personnes": "Célia Berdy & Esilda Perrot", "url": null, "note": "vidéo perdue, doc PDF seulement" }, { "ep": "03", "titre": "Social & BTP", "personnes": "Célia Berdy & Esilda Perrot", "url": null, "note": "vidéo perdue, doc PDF seulement" },
{ "ep": "04", "titre": "Menuisier & Paysagiste", "personnes": "Adel Mohamedi & Julie Bowie", "url": "https://youtu.be/yKaRQhA3Z6g" }, { "ep": "04", "titre": "Menuisier & Paysagiste", "personnes": "Adel Mohamedi & Julie Bowie", "url": "https://youtu.be/yKaRQhA3Z6g" },
{ "ep": "05", "titre": "Éco-construction", "personnes": "Edouard Vermès", "url": "https://youtu.be/97bDg1BjeuQ" }, { "ep": "05", "titre": "Éco-construction", "personnes": "Edouard Vermès", "url": "https://youtu.be/97bDg1BjeuQ" },
{ "ep": "06", "titre": "Musicien & Urbaniste", "personnes": "Ruben Madar & Antoine Troccaz", "url": "https://drive.google.com/drive/folders/14g8YBn5bZAy8aIkHzQlOTrTtnWOaqRO3" }, { "ep": "06", "titre": "Musicien & Urbaniste", "personnes": "Ruben Madar & Antoine Troccaz", "url": null, "note": "lien en attente d'ouverture du partage" },
{ "ep": "07", "titre": "AMO & Réemploi", "personnes": "Domitille Chaigne & Clémence Bondon", "url": "https://drive.google.com/file/d/1Q9Za81CElszmMn5n8dBsG0pJkiWmB32c/view" }, { "ep": "07", "titre": "AMO & Réemploi", "personnes": "Domitille Chaigne & Clémence Bondon", "url": null, "note": "lien en attente d'ouverture du partage" },
{ "ep": "08", "titre": "Gouvernance école", "personnes": "Solenn Guével", "url": "https://drive.google.com/drive/folders/1UaLsSyQcJydkXyV71klrY1tv9KgAh-mG" }, { "ep": "08", "titre": "Gouvernance école", "personnes": "Solenn Guével", "url": null, "note": "lien en attente d'ouverture du partage" },
{ "ep": "bonus", "titre": "PFE — invitation à faire collectif", "personnes": "Jules Nény & Imane Fatmi", "url": "https://youtu.be/4qTEIC2Lmqw" } { "ep": "bonus", "titre": "PFE — invitation à faire collectif", "personnes": "Jules Nény & Imane Fatmi", "url": "https://youtu.be/4qTEIC2Lmqw" }
], ]
"coalition_ensa_pb": {
"titre": "Coalition inter-asso ENSA-PB — victoire salle des enseignants",
"description": "Une coalition d'associations étudiantes a obtenu l'usage de la salle des enseignants pour des temps de travail collectif."
}, },
"ressources_externes": [
{ {
"id": "drop-the-kutch", "id": "drop-the-kutsch",
"icon": "🎧", "titre": "Drop the Kutsch (Sâm Afchar)",
"titre": "Podcast Drop the Kutch — Sâm Afchar", "url": "https://podcasts.apple.com/fr/podcast/drop-the-kutsch/id1536280194",
"url": "https://podcasts-francais.fr/podcast/drop-the-kutsch", "format": "podcast",
"description": "Témoignages de bifurcations post études d'archi. Super taf." "acces": "libre",
"pourquoi": "Témoignages d'architectes sortis de l'agence.",
"fiches": ["quete-de-sens"],
"source": "outils.json (avant B6)",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27"
},
{
"id": "ca-commence-par-moi",
"titre": "Ça commence par moi (Julien Vidal), épisode « Mon métier aura du sens »",
"url": "https://cacommenceparmoi.org/blog/2024/04/07/2030-glorieuses-111-caroline-sicard-et-julien-vidal-mon-metier-aura-du-sens/",
"format": "podcast",
"acces": "libre",
"pourquoi": "Sens au travail et reconversion, par l'auteur du livre de la sélection.",
"fiches": ["quete-de-sens"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27"
},
{
"id": "coalition-ensa-pb",
"titre": "Coalition inter-asso ENSA-PB — victoire salle des enseignants",
"url": null,
"format": "récit",
"acces": "libre",
"pourquoi": "Une coalition d'associations étudiantes a obtenu l'usage de la salle des enseignants pour des temps de travail collectif.",
"fiches": ["quete-de-sens"],
"source": "outils.json (avant B6)",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-10-08"
},
{
"id": "regain-habitat-participatif",
"titre": "Guide pratique de l'habitat participatif (REGAIN)",
"url": "https://www.regain-hg.org/wp-content/uploads/2021/06/REGAIN-GUIDE-HABITAT-PARTICIPATIF-MD.pdf",
"format": "guide PDF",
"acces": "libre",
"pourquoi": "Monter un projet d'habitat participatif, débouché direct pour un architecte.",
"fiches": ["ou-habiter"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27"
},
{
"id": "pssm-france",
"titre": "PSSM France, premiers secours en santé mentale",
"url": "https://www.pssmfrance.fr/",
"format": "formation",
"acces": "financé selon région",
"pourquoi": "Repérer et soutenir un collègue en difficulté.",
"fiches": ["sante-mentale", "competences-psychosociales"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27"
},
{
"id": "guide-prevention-manipulation",
"titre": "Guide de prévention de la manipulation (Femmes autistes francophones)",
"url": "https://femmesautistesfrancophones.com/wp-content/uploads/2018/04/guide-prevention-manipulation.pdf",
"format": "guide PDF",
"acces": "libre",
"pourquoi": "Reconnaître les mécanismes d'emprise, en agence comme ailleurs.",
"fiches": ["sante-mentale"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27"
},
{
"id": "revivre-apres-l-emprise",
"titre": "Revivre après l'emprise",
"url": "https://revivre-apres-lemprise.org/",
"format": "association",
"acces": "don libre",
"pourquoi": "Pairs et ressources pour sortir d'une relation d'emprise ; articles en lecture libre.",
"fiches": ["sante-mentale"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27"
},
{
"id": "manuel-auto-coaching-tdah",
"titre": "Manuel d'auto-coaching TDAH adulte",
"url": "https://www.tdah-adulte.org/TDAH-Manuel-Auto-Coaching.pdf",
"format": "guide PDF",
"acces": "libre",
"pourquoi": "S'organiser avec un TDAH.",
"fiches": ["sante-mentale"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27",
"note": "contenu non relu"
},
{
"id": "hypersupers-tdah-france",
"titre": "HyperSupers, TDAH France",
"url": "https://www.tdah-france.fr/",
"format": "association",
"acces": "libre",
"pourquoi": "Communauté de référence TDAH et haut potentiel ; fiches en lecture libre.",
"fiches": ["sante-mentale"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27",
"note": "adhésion facultative, 20-40 €/an"
},
{
"id": "yearcompass",
"titre": "YearCompass",
"url": "https://booklet.yearcompass.com/fr-FR-YearCompass-booklet-A5-printable.pdf",
"format": "livret PDF",
"acces": "libre",
"pourquoi": "Faire le bilan de son année et se projeter.",
"fiches": ["mener-ses-projets"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27",
"note": "licence CC BY-NC-SA 4.0"
},
{
"id": "fma-forum-metiers-archi",
"titre": "FMA, Forum des métiers de l'architecture (ENSA Paris-Belleville)",
"url": "https://www.paris-belleville.archi.fr/forum-des-metiers-de-larchitecture/",
"format": "événement",
"acces": "gratuit avec inscription",
"pourquoi": "Clarifier son projet après le diplôme.",
"fiches": ["mener-ses-projets"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27",
"note": "public restreint : L3, M, jeunes diplômés"
},
{
"id": "vipassana",
"titre": "Vipassana (Dhamma.org, S. N. Goenka)",
"url": "https://www.dhamma.org/fr",
"format": "retraite",
"acces": "don libre",
"pourquoi": "Introspection longue sans barrière d'argent : aucun frais, financé par les anciens élèves.",
"fiches": ["naviguer-avec-soi"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27",
"note": "retraite de 10 jours"
},
{
"id": "plum-village-appli",
"titre": "Application Plum Village (Village des Pruniers)",
"url": "https://plumvillage.org/mindfulness/mindful-apps",
"format": "appli",
"acces": "libre",
"pourquoi": "Porte gratuite du Village des Pruniers : méditations guidées, 300 enseignements audio, en français.",
"fiches": ["naviguer-avec-soi"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27"
},
{
"id": "isha-kriya",
"titre": "Isha Kriya (Isha Foundation)",
"url": "https://isha.sadhguru.org/en/wisdom/video/isha-kriya-guided-meditation",
"format": "vidéo",
"acces": "libre",
"pourquoi": "Porte gratuite d'« Isha yoga », sans compte.",
"fiches": ["naviguer-avec-soi"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27",
"note": "méditation guidée de 12 à 18 minutes"
},
{
"id": "hridaya-yoga",
"titre": "Hridaya Yoga, lives et chaîne",
"url": "https://www.youtube.com/c/HridayaYoga/videos",
"format": "vidéo",
"acces": "libre",
"pourquoi": "Méditations hebdomadaires en direct, sans passer par les retraites (140-595 €, tarif réduit sur demande).",
"fiches": ["naviguer-avec-soi"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27"
},
{
"id": "christophe-andre-meditations",
"titre": "Méditations guidées de Christophe André",
"url": "https://www.christopheandre.com/meditation-et-spiritualite/",
"format": "vidéo",
"acces": "libre",
"pourquoi": "Pleine conscience en français, par un psychiatre qui a vulgarisé la pratique.",
"fiches": ["naviguer-avec-soi"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27",
"note": "audio et vidéo"
},
{
"id": "spf-referentiel-cps",
"titre": "Compétences psychosociales, référentiel (Santé publique France)",
"url": "https://www.santepubliquefrance.fr/sites/default/files/cadic_files/documents/spf00006442_s1.pdf",
"format": "guide PDF",
"acces": "libre",
"pourquoi": "Le cadre public de référence des compétences psychosociales, celles que la salle du 23/09 a demandé d'apprendre.",
"fiches": ["competences-psychosociales", "sante-mentale"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27"
},
{
"id": "cercle-nonviolence-ateliers",
"titre": "Cercle de la NonViolence, ateliers gratuits",
"url": "https://cercle-nv.com/ateliers-et-webinaires-gratuits/",
"format": "atelier",
"acces": "gratuit avec inscription",
"pourquoi": "Porte gratuite vers la communication non violente.",
"fiches": ["competences-psychosociales"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27",
"note": "ateliers en ligne"
},
{
"id": "formation-cnv-famille-positive",
"titre": "Formation CNV (La Famille Positive, 16 vidéos)",
"url": "https://www.lafamillepositive.com/formation-gratuite-a-la-cnv/",
"format": "formation",
"acces": "gratuit avec inscription",
"pourquoi": "Les fondamentaux de Rosenberg en vidéo.",
"fiches": ["competences-psychosociales"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27"
},
{
"id": "universite-du-nous",
"titre": "Université du Nous, ressources",
"url": "https://universite-du-nous.org/fr_FR/ressources",
"format": "fiches",
"acces": "libre",
"pourquoi": "Gouvernance partagée et intelligence collective, outils de facilitation réutilisables.",
"fiches": ["competences-psychosociales"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27",
"note": "licence CC BY-SA"
},
{
"id": "fertiles",
"titre": "Fertîles, PACoo' et FACIL'",
"url": null,
"urls": ["https://fertiles.co/pacoo/", "https://fertiles.co/facilitation/"],
"format": "formation",
"acces": "prix libre",
"pourquoi": "Coopération et facilitation, citées par Jules le 23/09.",
"fiches": ["competences-psychosociales"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27",
"note": "une place à prix libre par session ; sinon 335-365 €"
},
{
"id": "fora-confiance-en-soi",
"titre": "Module « Confiance en soi » (Centre FORA)",
"url": "https://centrefora.com/wp-content/uploads/2019/05/Confiance-en-soi_VF.pdf",
"format": "guide PDF",
"acces": "libre",
"pourquoi": "Exercices structurés, par un organisme d'éducation publique.",
"fiches": ["competences-psychosociales"],
"source": "inventaire S 27/09",
"couche": "socle",
"statut": "vivant",
"verifie_le": "2026-09-27"
} }
] ]
}, },
"section_5_placeholder": { "contribution": {
"titre": "[V2 — Login] Logiciels pro", "texte": "Tu utilises un outil ou une ressource qui mérite d'être ici ?",
"description": "Logiciels lourds (Adobe, AutoCAD…) — accès mutualisé. Disponible après création de compte AEP.", "lien": "/proposer?categorie=outils",
"status": "bientot-login" "cta": "Proposer une ressource"
}, }
"footer_contribution": "Tu utilises un outil qui mérite d'être ici ? Écris-moi : contact@trans-former.fr"
} }
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Contrôle des liens de /outils (B6-M4) — outil de revue, PAS un test : ne fait jamais échouer le build.
* Lit public/data/outils.json (toutes les URL, y compris `urls[]` et `episodes[].url`) et
* public/data/fmhy-curated.json, fait un GET (redirections suivies, délai 15 s, 4 requêtes en parallèle)
* et liste les réponses hors 2xx/3xx.
*
* Un 403/429 d'un site qui bloque les robots n'est pas un lien mort : lire la liste avant de retirer quoi que ce soit.
* Usage : node scripts/check-liens-outils.mjs
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const lire = (nom) => JSON.parse(readFileSync(join(__dirname, '..', 'public', 'data', nom), 'utf8'))
const DELAI_MS = 15000
const PARALLELE = 4
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36'
/** url -> Set des entrées qui la portent. Toute chaîne https:// est prise, quel que soit son champ. */
const urls = new Map()
function ajoute(url, qui) {
if (!urls.has(url)) urls.set(url, new Set())
urls.get(url).add(qui)
}
/** Titre lisible de l'entrée courante : nom / titre / mot, sinon l'id. */
const titreDe = (o) => o.nom ?? o.titre ?? o.name ?? o.mot ?? o.id
function parcourt(valeur, fichier, contexte) {
if (typeof valeur === 'string') {
if (/^https:\/\//i.test(valeur) || /^http:\/\//i.test(valeur)) ajoute(valeur, `${fichier} › ${contexte}`)
} else if (Array.isArray(valeur)) {
for (const v of valeur) parcourt(v, fichier, contexte)
} else if (valeur && typeof valeur === 'object') {
// `lien_texte`-like chaînes ne commencent pas par https:// : seules les vraies URL sont retenues.
const ctx = titreDe(valeur) ? `${contexte ? contexte + ' › ' : ''}${titreDe(valeur)}` : contexte
for (const [, v] of Object.entries(valeur)) parcourt(v, fichier, ctx)
}
}
parcourt(lire('outils.json'), 'outils.json', '')
parcourt(lire('fmhy-curated.json'), 'fmhy-curated.json', '')
async function verifie(url) {
const ctrl = new AbortController()
const minuteur = setTimeout(() => ctrl.abort(), DELAI_MS)
try {
const r = await fetch(url, {
method: 'GET',
redirect: 'follow',
signal: ctrl.signal,
headers: { 'user-agent': UA, accept: 'text/html,application/xhtml+xml,*/*;q=0.8', 'accept-language': 'fr-FR,fr;q=0.9,en;q=0.7' },
})
r.body?.cancel().catch(() => {})
return r.status
} catch (e) {
return e?.name === 'AbortError' ? 'délai' : `erreur (${e?.cause?.code ?? e?.message ?? 'réseau'})`
} finally {
clearTimeout(minuteur)
}
}
const liste = [...urls.keys()]
const resultats = new Map()
let curseur = 0
async function travailleur() {
while (curseur < liste.length) {
const url = liste[curseur++]
resultats.set(url, await verifie(url))
}
}
await Promise.all(Array.from({ length: PARALLELE }, travailleur))
const ko = liste.filter((u) => {
const c = resultats.get(u)
return !(typeof c === 'number' && c >= 200 && c < 400)
})
console.log(`Contrôle de ${liste.length} URL (GET, délai ${DELAI_MS / 1000} s, ${PARALLELE} en parallèle)\n`)
if (ko.length) {
console.log('code'.padEnd(18) + 'URL · entrée')
for (const u of ko) {
console.log(String(resultats.get(u)).padEnd(18) + `${u} · ${[...urls.get(u)].join(' | ')}`)
}
}
console.log(`\n${ko.length} réponse(s) hors 2xx/3xx sur ${liste.length} URL.`)
// Outil de revue : toujours code de sortie 0.
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env node
// Test du garde-fou des citations (server/utils/citations.ts). Usage : node scripts/test-citations.mjs
import assert from 'node:assert/strict'
import { verifierCitations, contenusChunks } from '../server/utils/citations.ts'
const ctx = ["La technique est devenue autonome, et forme un monde dévorant.\r\nIl n’y a plus de liberté de choix. Plus on parle d’une valeur, plus c'est le signe de son absence."]
const t = (rep, c = ctx) => verifierCitations(rep, c)
// retrouvée (apostrophes, casse, ponctuation de bord, retours chariot) : gardée
let r = t('Ellul écrit : « Il n\'y a plus de liberté de choix ! » donc.')
assert.equal(r.gardees, 1); assert.equal(r.retirees, 0); assert.ok(r.texte.includes('«'))
// introuvable : guillemets retirés, texte conservé
r = t('Il dit : « La technique est une machine à fabriquer du pouvoir social inévitable ». Fin.')
assert.equal(r.retirees, 1); assert.equal(r.texte, 'Il dit : La technique est une machine à fabriquer du pouvoir social inévitable. Fin.')
// guillemets droits et anglais
r = t('"Une phrase inventée qui ne figure nulle part dans le texte" et “Une autre phrase inventée qui ne figure nulle part ici”')
assert.equal(r.retirees, 2); assert.ok(!/["“”]/.test(r.texte))
// court (< 6 mots) : intact, non compté
r = t('Le « rift métabolique » et la « ville intelligente ».')
assert.equal(r.gardees + r.retirees, 0); assert.ok(r.texte.includes('« rift métabolique »'))
// élision [...] : chaque morceau doit exister
r = t('« Plus on parle d\'une valeur [...] le signe de son absence »')
assert.equal(r.gardees, 1)
r = t('« Plus on parle d\'une valeur [...] le signe de sa présence éclatante »')
assert.equal(r.retirees, 1)
// pas de correspondance en milieu de mot
r = t('« ormation autonome, et forme un monde dévorant »')
assert.equal(r.retirees, 1)
// aucun contexte : rien de long n'est gardé
r = t('« Il n\'y a plus de liberté de choix »', [])
assert.equal(r.retirees, 1)
// contenusChunks : tableau de chaînes ou chaîne
assert.deepEqual(contenusChunks([{ content: ['a', 'b'] }, { content: 'c' }, {}, null]), ['a', 'b', 'c'])
assert.deepEqual(contenusChunks(undefined), [])
console.log('citations : tous les cas passent')
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env node
/**
* Test des données de /outils (B6-M1).
* Garde-fou pour les ajouts futurs (à la main, par M3, ou par un pipe n8n) :
* fiches de l'onglet « Partir de soi », ressources, purge des liens litigieux, URL en https.
* Charge le VRAI utils/echelles.ts via jiti (même patron que test-echelles-nav.mjs).
* Usage : node scripts/test-outils-data.mjs
*/
import { createJiti } from 'jiti'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const jiti = createJiti(import.meta.url)
const { ECHELLES } = await jiti.import(join(__dirname, '..', 'utils', 'echelles.ts'))
const lire = (nom) => JSON.parse(readFileSync(join(__dirname, '..', 'public', 'data', nom), 'utf8'))
const outils = lire('outils.json')
const fmhy = lire('fmhy-curated.json')
const LISTE_NOIRE = [
'annas-archive', 'z-lib', 'libgen', 'sci-hub', 'mobilism', 'zone-ebook', 'rivestream', 'cineby',
'flixer', 'miruro', 'monochrome.tf', 'lucida', 'spotx', 'fitgirl', 'steamrip', '1337x',
]
const FORMATS = ['film', 'vidéo', 'podcast', 'guide PDF', 'livret PDF', 'appli', 'retraite', 'atelier', 'formation', 'association', 'fiches', 'événement', 'récit']
const ACCES = ['libre', 'don libre', 'gratuit avec inscription', 'prix libre', 'financé selon région']
const SOURCES = ['inventaire S 27/09', 'outils.json (avant B6)']
let n = 0
const echecs = []
function verifie(ok, message) {
n++
if (!ok) echecs.push(message)
}
/** Parcourt un JSON à tous les niveaux : (chemin, clé, valeur) pour chaque chaîne. */
function chaines(valeur, chemin, visiteur) {
if (typeof valeur === 'string') visiteur(chemin, valeur)
else if (Array.isArray(valeur)) valeur.forEach((v, i) => chaines(v, `${chemin}[${i}]`, visiteur))
else if (valeur && typeof valeur === 'object') for (const [k, v] of Object.entries(valeur)) chaines(v, `${chemin}.${k}`, visiteur)
}
// ── Fiches ────────────────────────────────────────────────────
const p = outils.partir_de_soi
const fiches = p?.fiches ?? []
const mots = ECHELLES.S.mots
verifie(outils.version === 2, 'version doit valoir 2')
verifie(fiches.length === 7, `7 fiches attendues, ${fiches.length} trouvées`)
verifie(new Set(fiches.map((f) => f.id)).size === fiches.length, 'ids de fiches non uniques')
verifie(
JSON.stringify(fiches.map((f) => f.mot)) === JSON.stringify(mots),
`mots des fiches ≠ ECHELLES.S.mots (dans l'ordre) : ${JSON.stringify(fiches.map((f) => f.mot))}`,
)
const idsFiches = new Set(fiches.map((f) => f.id))
// ── Ressources ────────────────────────────────────────────────
const ressources = p?.ressources ?? []
const idsRes = new Set()
const DATE = /^\d{4}-\d{2}-\d{2}$/
for (const r of ressources) {
const nom = `ressource « ${r.id ?? r.titre ?? '?'} »`
verifie(typeof r.id === 'string' && r.id && !idsRes.has(r.id), `${nom} : id manquant ou en double`)
idsRes.add(r.id)
verifie(typeof r.titre === 'string' && r.titre, `${nom} : titre manquant`)
verifie(FORMATS.includes(r.format), `${nom} : format « ${r.format} » hors liste`)
verifie(ACCES.includes(r.acces), `${nom} : accès « ${r.acces} » hors liste`)
verifie(typeof r.pourquoi === 'string' && r.pourquoi, `${nom} : pourquoi manquant`)
verifie(Array.isArray(r.fiches) && r.fiches.length > 0, `${nom} : fiches vide`)
verifie((r.fiches ?? []).every((id) => idsFiches.has(id)), `${nom} : fiche inconnue dans ${JSON.stringify(r.fiches)}`)
verifie(SOURCES.includes(r.source), `${nom} : source « ${r.source} » hors liste`)
verifie(['socle', 'actualite'].includes(r.couche), `${nom} : couche « ${r.couche} »`)
verifie(['vivant', 'dormant', 'archive'].includes(r.statut), `${nom} : statut « ${r.statut} »`)
verifie(DATE.test(r.verifie_le ?? ''), `${nom} : verifie_le « ${r.verifie_le} » n'est pas AAAA-MM-JJ`)
}
// ── Fiche vide ⇔ sans ressource ───────────────────────────────
for (const f of fiches) {
const nb = ressources.filter((r) => r.fiches?.includes(f.id)).length
if (nb === 0) verifie(typeof f.vide === 'string' && f.vide, `fiche « ${f.id} » sans ressource : \`vide\` doit être renseigné`)
else verifie(f.vide == null, `fiche « ${f.id} » a ${nb} ressource(s) : \`vide\` doit être nul`)
}
// ── URL : https partout, rien de la liste noire ───────────────
for (const [fichier, donnees] of [['outils.json', outils], ['fmhy-curated.json', fmhy]]) {
chaines(donnees, fichier, (chemin, s) => {
const cle = chemin.split('.').pop().replace(/\[\d+\]$/, '')
const estUrl = /^https?:\/\//i.test(s) || ['url', 'urls', 'lien', 'avertissement_lien'].includes(cle)
// `lien` de contribution est un chemin interne (/proposer…) : seul le préfixe http est contrôlé.
if (/^https?:\/\//i.test(s)) {
verifie(s.startsWith('https://'), `${chemin} : URL non https (${s})`)
const bas = s.toLowerCase()
const hit = LISTE_NOIRE.find((d) => bas.includes(d))
verifie(!hit, `${chemin} : domaine de la liste noire « ${hit} » (${s})`)
} else if (estUrl && cle !== 'lien' && s) {
verifie(false, `${chemin} : URL sans https (${s})`)
}
})
}
// Liste noire aussi dans les noms/titres (un lien retiré ne doit pas survivre en texte).
chaines({ outils, fmhy }, 'données', (chemin, s) => {
const bas = s.toLowerCase()
verifie(!bas.includes('tantra'), `${chemin} : contient « tantra »`)
verifie(!bas.includes('the nest'), `${chemin} : contient « The Nest »`)
const hit = ['sci-hub', 'z-library', 'library genesis', "anna's archive", 'fitgirl', 'steamrip', '1337x'].find((d) => bas.includes(d))
verifie(!hit, `${chemin} : contient « ${hit} »`)
})
// ── Structure minimale des autres blocs ───────────────────────
verifie(Array.isArray(outils.conception?.outils) && outils.conception.outils.length > 0, 'conception.outils vide')
verifie(
(outils.conception?.outils ?? []).every((o) => o.mention && DATE.test(o.verifie_le ?? '')),
'conception.outils : `mention` et `verifie_le` obligatoires',
)
verifie(Array.isArray(outils.open_source?.rubriques) && outils.open_source.rubriques.every((r) => r.liens?.length > 0), 'open_source.rubriques : une rubrique vide doit disparaître')
verifie(!!outils.contribution?.texte && !!outils.contribution?.lien && !!outils.contribution?.cta, 'contribution incomplète')
// ── Open source : sélection (B6-M4) ───────────────────────────
// Chaque lien de la sélection est vérifié par une personne (licence, date) ; la liste noire
// piraterie s'applique déjà à toute chaîne d'outils.json (voir plus haut), sélection comprise.
const sel = outils.open_source?.selection
verifie(!!sel && typeof sel.intention === 'string' && sel.intention, 'open_source.selection : intention manquante')
verifie(Array.isArray(sel?.rubriques) && sel.rubriques.length >= 1, 'open_source.selection : au moins une rubrique attendue')
const idsRub = new Set()
for (const r of sel?.rubriques ?? []) {
const rub = `sélection « ${r.id ?? r.titre ?? '?'} »`
verifie(typeof r.id === 'string' && r.id && !idsRub.has(r.id), `${rub} : id manquant ou en double`)
idsRub.add(r.id)
verifie(typeof r.titre === 'string' && r.titre, `${rub} : titre manquant`)
verifie(Array.isArray(r.liens) && r.liens.length > 0, `${rub} : rubrique vide`)
for (const l of r.liens ?? []) {
const nom = `${rub} › ${l.nom ?? '?'}`
verifie(typeof l.nom === 'string' && l.nom, `${nom} : nom manquant`)
verifie(typeof l.url === 'string' && l.url.startsWith('https://'), `${nom} : url https manquante (${l.url})`)
verifie(typeof l.desc === 'string' && l.desc, `${nom} : desc manquante`)
verifie(typeof l.licence === 'string' && l.licence, `${nom} : licence manquante`)
verifie(DATE.test(l.verifie_le ?? ''), `${nom} : verifie_le « ${l.verifie_le} » n'est pas AAAA-MM-JJ`)
const bas = (l.url ?? '').toLowerCase()
const hit = LISTE_NOIRE.find((d) => bas.includes(d))
verifie(!hit, `${nom} : domaine de la liste noire « ${hit} »`)
}
}
// Aucun doublon d'URL dans tout open_source (sélection + recommandés + rubriques fmhy), à la barre finale et à la casse près.
const norm = (u) => u.toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/+$/, '')
const vues = new Map()
const urlsOS = [
...(sel?.rubriques ?? []).flatMap((r) => (r.liens ?? []).map((l) => [`sélection › ${l.nom}`, l.url])),
...(outils.open_source?.recommandes ?? []).filter((o) => o.url).map((o) => [`recommandé › ${o.titre}`, o.url]),
...(outils.open_source?.rubriques ?? []).flatMap((r) => (r.liens ?? []).map((l) => [`fmhy › ${l.nom}`, l.url])),
]
for (const [qui, url] of urlsOS) {
const k = norm(url)
verifie(!vues.has(k), `open_source : URL en double « ${url} » (${vues.get(k)} et ${qui})`)
vues.set(k, qui)
}
if (echecs.length) {
for (const e of echecs) console.log(`FAIL ${e}`)
console.log(`\n${n - echecs.length}/${n} vérifications OK — ${echecs.length} échec(s)`)
process.exit(1)
}
console.log(`${n} vérifications OK`)
+57
View File
@@ -186,5 +186,62 @@ nav = navigateur(qListe)
nav.ouvrir(1); nav.filtrer({ ...nav.cur(), echelle: 'National' }); nav.fermer() nav.ouvrir(1); nav.filtrer({ ...nav.cur(), echelle: 'National' }); nav.fermer()
ok(nav.cur().echelle === 'National' && V.lireFiche(nav.cur()) === null, 'filtre changé fiche ouverte : fermer garde le nouveau filtre (pas de retour arrière)') ok(nav.cur().echelle === 'National' && V.lireFiche(nav.cur()) === null, 'filtre changé fiche ouverte : fermer garde le nouveau filtre (pas de retour arrière)')
// ── Partir de soi : sept mots, ordre de la slide (B6-M2) ──
const MOTS = [
{ id: 'quete-de-sens', mot: 'quête de sens', resume: 'Bifurquer, donner du sens à son métier.' },
{ id: 'gagner-sa-vie', mot: 'gagner sa vie', resume: 'Créer son activité et en vivre.', vide: 'Rien encore.' },
{ id: 'sante-mentale', mot: 'santé mentale', resume: 'Repérer, comprendre, se faire accompagner.' },
{ id: 'naviguer-avec-soi', mot: 'naviguer avec soi', resume: 'Méditation et pratiques d’introspection.' },
]
const RESS = [
{ id: 'film', titre: 'En quête de sens', format: 'film', acces: 'don libre', pourquoi: 'Le film nommé dans le deck.', fiches: ['quete-de-sens'] },
{ id: 'ofqa', titre: 'OFQA', format: 'vidéo', acces: 'libre', pourquoi: 'Diplômés qui ont bifurqué.', fiches: ['quete-de-sens'] },
{ id: 'psc', titre: 'Premiers secours en santé mentale', format: 'formation', acces: 'financé selon région', pourquoi: 'Repérer les signes.', fiches: ['sante-mentale'] },
{ id: 'vipassana', titre: 'Vipassana', format: 'retraite', acces: 'don libre', pourquoi: 'Dix jours de silence.', fiches: ['naviguer-avec-soi'] },
{ id: 'insight', titre: 'Insight Timer', format: 'appli', acces: 'gratuit avec inscription', pourquoi: 'Méditations guidées.', fiches: ['naviguer-avec-soi', 'sante-mentale'] },
]
const parMot = (id) => RESS.filter((r) => r.fiches.includes(id))
const mots = MOTS.map((m) => A.toMiniMot(m, parMot(m.id)))
const cfgMots = {
dimensions: [
{ cle: 'format', libelle: 'Format', variante: 'fonction', valeurs: A.valeursRessources(RESS, 'format') },
{ cle: 'acces', libelle: 'Accès', variante: 'echelle', valeurs: A.valeursRessources(RESS, 'acces') },
],
tris: ['ordre', 'pertinence', 'nom'],
triDefaut: 'ordre',
libellesTri: { ordre: 'Ordre de la slide' },
groupes: [],
etats: ['carte', 'mixte', 'fiches'],
}
const etatM = (query = {}, over = {}) => ({ ...V.lireQuery(query, cfgMots), ...over })
const sansDefaut = { ...cfgMots, triDefaut: undefined, tris: ['pertinence', 'nom'] }
ok(mots[0].kind === 'mot' && mots[0].nom === 'quête de sens' && eq(mots[0].chips, ['film', 'vidéo']) && mots[0].chipsDimension === 'format' && mots[0].geoloc === true, 'mot → kind mot, nom = le mot, chips = formats, geoloc true (pas de carte géographique)')
ok(eq(mots[0].etiquettes, { format: ['film', 'video'], acces: ['don-libre', 'libre'] }), 'mot → étiquettes en slug (sans accent, sans espace) pour l’URL', JSON.stringify(mots[0].etiquettes))
ok(eq(mots[3].etiquettes.acces, ['don-libre', 'gratuit-avec-inscription']) && mots[3].chips.length === 2, 'mot → union des formats et des accès de ses ressources')
ok(mots[1].resume === 'Créer son activité et en vivre.' && eq(mots[1].etiquettes, { format: [], acces: [] }), 'mot sans ressource (gagner sa vie) → résumé gardé, aucune étiquette')
ok(A.toMiniMot({ id: 'x', mot: 'x', resume: '', vide: 'Rien encore. Propose.' }, []).resume === 'Rien encore.', 'mot sans résumé → texte « vide » (première phrase)')
ok(eq(A.valeursRessources(RESS, 'acces').map((v) => v.libelle), ['don libre', 'libre', 'financé selon région', 'gratuit avec inscription']) && A.slugEtiquette('financé selon région') === 'finance-selon-region', 'valeurs de dimension : libellé lisible, valeur en slug, ordre de première apparition')
const idsM = (q, tri) => ids(V.trierFiches(V.filtrerFiches(mots, etatM(q), cfgMots), tri ?? V.triEffectif(etatM(q), cfgMots), etatM(q)))
ok(eq(idsM({}), ['quete-de-sens', 'gagner-sa-vie', 'sante-mentale', 'naviguer-avec-soi']), 'tri ordre : l’ordre du tableau d’entrée, pas A → Z')
ok(eq(ids(V.trierFiches([...mots].reverse(), 'ordre', etatM())), ['naviguer-avec-soi', 'sante-mentale', 'gagner-sa-vie', 'quete-de-sens']), 'tri ordre : stable, suit l’entrée quelle qu’elle soit')
ok(V.triEffectif(etatM(), cfgMots) === 'ordre', 'triDefaut respecté sans filtre')
ok(V.triEffectif(etatM({ q: 'vipassana' }), cfgMots) === 'ordre', 'triDefaut respecté avec une recherche seule (pas de filtre d’étiquette)')
ok(V.triEffectif(etatM({ acces: 'don-libre' }), cfgMots) === 'pertinence', 'pertinence prioritaire dès qu’un filtre est actif, même avec triDefaut')
ok(V.triEffectif(etatM({ acces: 'don-libre', tri: 'ordre' }), cfgMots) === 'ordre', 'tri explicite (tri=ordre dans l’URL) prioritaire')
ok(V.lireQuery({ tri: 'ordre' }, cfgMots).tri === 'ordre', 'tri=ordre lu dans l’URL quand la page l’offre')
ok(V.lireQuery({ tri: 'ordre' }, cfg).tri === null && V.lireQuery({ tri: 'ordre' }, sansDefaut).tri === null, 'tri=ordre ignoré quand la page ne l’offre pas (/ et /agences)')
ok(V.triEffectif(etatM(), sansDefaut) === 'nom' && V.triEffectif(etat0(), cfg) === 'nom', 'sans triDefaut : comportement inchangé (nom)')
ok(V.triEffectif(etatM(), { ...cfgMots, triDefaut: 'recent' }) === 'nom', 'triDefaut absent de tris → ignoré')
ok(eq(idsM({ acces: 'don-libre' }), ['naviguer-avec-soi', 'quete-de-sens']), 'filtre Accès « don libre » : pertinence (ex æquo → A → Z), pas l’ordre de la slide', JSON.stringify(idsM({ acces: 'don-libre' })))
ok(eq(idsM({ q: 'vipassana' }), ['naviguer-avec-soi']), 'recherche « Vipassana » (titre d’une ressource) → le mot « naviguer avec soi »')
ok(eq(idsM({ q: 'VIPASSANA' }), ['naviguer-avec-soi']) && eq(idsM({ q: 'quete' }), ['quete-de-sens']), 'recherche insensible à la casse et aux accents')
const cm = V.compterEtiquettes(mots, etatM({ acces: 'don-libre' }), cfgMots)
ok(cm.format.film === 1 && cm.format.retraite === 1 && cm.format.formation === 0 && cm.acces['don-libre'] === 2 && cm.acces.libre === 1, 'compteurs : format dans le résultat du filtre Accès, accès dans celui des formats', JSON.stringify(cm))
const eMot = V.lireQuery({ tab: 'partir-de-soi', acces: 'don-libre', format: 'film,retraite', fiche: 'quete-de-sens' }, cfgMots)
const qMot = V.ecrireQuery(eMot, cfgMots, { tab: 'partir-de-soi', acces: 'don-libre', fiche: 'quete-de-sens' })
ok(qMot.tab === 'partir-de-soi' && qMot.fiche === 'quete-de-sens' && qMot.acces === 'don-libre' && qMot.format === 'film,retraite', 'tab= et fiche= survivent à l’écriture des filtres')
ok(V.queryAvecFiche({ tab: 'partir-de-soi', vue: 'carte' }, 'sante-mentale').tab === 'partir-de-soi' && V.queryAvecFiche({ tab: 'partir-de-soi', fiche: 'x' }, null).tab === 'partir-de-soi', 'tab= survit à l’ouverture et à la fermeture d’une fiche')
console.log(`\n${n - fail}/${n}`) console.log(`\n${n - fail}/${n}`)
process.exit(fail ? 1 : 0) process.exit(fail ? 1 : 0)
-68
View File
@@ -1,68 +0,0 @@
#!/usr/bin/env node
/**
* Contrôle des citations d'un chatbot LightRAG : chaque segment entre guillemets
* de la réponse doit figurer mot pour mot dans le contexte réellement récupéré.
*
* Usage :
* node scripts/verif-citations.mjs --rag http://HOTE:9621 --query "Cite ..." \
* [--route http://localhost:3334/api/chatbot-pensees] [--mode hybrid] [--auteur slug] \
* [--response-file reponse.txt] [--ctx-query-file autre-query.txt] [--min-mots 6] [--max-total-tokens 40000 (mode approfondi)] [--json]
*
* - Sans --response-file : la réponse est demandée à --route (POST {query, mode, auteur_slug}).
* - Contexte : POST {rag}/query {query, mode, only_need_context:true}. --ctx-query-file ajoute
* un second contexte (ex. la requête telle qu'envoyée avant amendement, préambule inclus) ;
* un segment est « trouvé » s'il figure dans l'un ou l'autre.
* - Segment >= min-mots (6) absent du contexte = inventé (algorithme : server/utils/citations.ts, partagé avec la route). Plus court : signalé, non compté.
* Lecture seule côté LightRAG. Réutilisable pour l'instance 9623 (--rag http://HOTE:9623).
*/
import { readFileSync } from 'node:fs'
// Node >= 22.18 charge le .ts directement (syntaxe effaçable uniquement dans citations.ts).
import { verifierCitations } from '../server/utils/citations.ts'
const args = Object.fromEntries(process.argv.slice(2).reduce((a, x, i, arr) => {
if (x.startsWith('--')) a.push([x.slice(2), arr[i + 1] && !arr[i + 1].startsWith('--') ? arr[i + 1] : true])
return a
}, []))
if (!args.rag || !args.query) { console.error('--rag et --query requis'); process.exit(2) }
const mode = args.mode || 'hybrid'
const minMots = Number(args['min-mots'] || 6)
async function post(url, body, timeout = 120000) {
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeout) })
if (!r.ok) throw new Error(`${url} -> HTTP ${r.status}`)
return r.json()
}
async function contexte(q) {
const j = await post(`${args.rag}/query`, { query: q, mode, only_need_context: true, ...(args['max-total-tokens'] ? { max_total_tokens: Number(args['max-total-tokens']) } : {}) })
return typeof j.response === 'string' ? j.response : JSON.stringify(j)
}
let reponse
if (args['response-file']) reponse = readFileSync(args['response-file'], 'utf8')
else {
if (!args.route) { console.error('--route ou --response-file requis'); process.exit(2) }
const b = { query: args.query, mode }
if (args.auteur) b.auteur_slug = args.auteur
reponse = (await post(args.route, b)).response
}
const ctxs = [await contexte(args.query)]
if (args['ctx-query-file']) ctxs.push(await contexte(readFileSync(args['ctx-query-file'], 'utf8')))
// Même fonction pure que la route (server/utils/citations.ts) : un seul algorithme, deux usages.
const v = verifierCitations(reponse, ctxs, minMots)
const lignes = v.segments.map(l => ({ segment: l.segment, mots: l.mots, trouve: l.trouve }))
const longs = lignes.filter(l => l.mots >= minMots)
const res = {
query: args.query, mode,
segments: lignes.length, longs: longs.length,
longs_trouves: longs.filter(l => l.trouve).length,
inventes: longs.filter(l => !l.trouve).length,
courts_non_comptes: lignes.length - longs.length,
contexte_chars: ctxs.map(c => c.length),
detail: lignes, reponse,
}
if (args.json) console.log(JSON.stringify(res, null, 2))
else {
console.log(`Q: ${res.query}\nsegments=${res.segments} longs(>=${minMots} mots)=${res.longs} trouvés=${res.longs_trouves} INVENTÉS=${res.inventes} courts=${res.courts_non_comptes}`)
for (const l of lignes) console.log(` [${l.mots >= minMots ? (l.trouve ? 'OK ' : 'INV') : 'crt'}] (${l.mots}) ${l.segment.slice(0, 160)}`)
}
+3 -61
View File
@@ -6,18 +6,6 @@
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson' import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { profondeurDuBody, REGLE_GUILLEMETS } from '~/server/utils/bifrost'
import { verifierCitations, contenusChunks } from '~/server/utils/citations'
/**
* Règle citations — commune aux 4 préambules (mesure M7 du 05/10 : le modèle invente des
* citations entre guillemets quand l'extrait n'est pas dans le contexte récupéré).
*/
const REGLE_CITATIONS = `RÈGLE DES CITATIONS (prioritaire sur toute autre consigne) :
- Ne mets entre guillemets (« ») que ce qui figure MOT POUR MOT dans le contexte fourni ci-dessus. Jamais de citation reconstituée de mémoire, de paraphrase ou de résumé entre guillemets. Même si la question demande des phrases « exactes » ou « mot pour mot », ne cède jamais : mieux vaut peu de citations, ou aucune, qu'une citation approximative.
- Avant d'écrire un guillemet, repère dans le contexte le passage exact : si tu ne le retrouves pas à l'identique, ne le cite pas. Au plus 3 citations, les plus courtes et les plus sûres ; recopie-les sans les raccourcir, sans les enchaîner, sans les compléter.
- Si aucun passage correspondant n'est disponible dans le contexte, dis explicitement « je ne cite pas : aucun passage exact n'est disponible dans les extraits » et reformule sans guillemets, en indiquant qu'il s'agit d'une synthèse.
- Nomme l'œuvre dont provient le passage dans le contexte, pas une autre.`
const SYSTEM_PREFACE_PENSEES = `Tu es un agent du RAG Pensées Écologiques, infrastructure militante du collectif trans-former.fr. 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...). 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...).
@@ -52,28 +40,6 @@ Règles :
- Ton praticien militant : direct, pas neutre, ancré dans la pratique architecturale. - Ton praticien militant : direct, pas neutre, ancré dans la pratique architecturale.
- Réponse en français, dense, sans délayage.` - Réponse en français, dense, sans délayage.`
/**
* Posture « réflexion » (mode approfondi, v1 — prompts définitifs par onglet : B7/B9).
* Ajoutée APRÈS le préambule du corpus et AVANT REGLE_CITATIONS (qui reste prioritaire).
* Pensées n'est pas servi par Bifrost : le LLM de génération est réglé côté LightRAG, donc
* ce mode change la consigne et les paramètres de récupération, pas le modèle.
*/
const POSTURE_REFLEXION = `MODE RÉFLEXION : l'usager veut penser, pas seulement s'informer.
- Mets les idées en relation : reformule d'abord ce que la question engage, puis fais dialoguer les auteurs et les écoles présents dans le contexte.
- Nomme les tensions et les désaccords entre écoles (ce qu'elles partagent, là où elles divergent, ce que chacune ne voit pas), sans trancher à la place de l'usager.
- Reste strictement dans le corpus fourni : si une idée n'y est pas, dis-le au lieu de la compléter de mémoire.
- Termine par 2 questions ouvertes qui aident l'usager à situer sa propre position.
- Réponse structurée en paragraphes courts, 450 mots maximum.
- ${REGLE_GUILLEMETS}`
/**
* Paramètres de récupération LightRAG du mode réflexion. Mesuré le 09/10 (contexte seul, hybrid) :
* top_k 60 / chunk_top_k 30 ne changent rien (115,7 → 115,9 Ko, le budget de tokens plafonne) ;
* max_total_tokens 50000 monte le contexte à 153 Ko. On prend 40000 : plus de matière pour croiser
* les écoles sans approcher la fenêtre du LLM de génération (réglé côté serveur).
*/
const PARAMS_RAG_REFLEXION = { max_total_tokens: 40000 }
function buildPrefaceAuteur(nomAuteur: string, slug: string): string { function buildPrefaceAuteur(nomAuteur: string, slug: string): string {
return `Tu réponds EXCLUSIVEMENT depuis les livres de ${nomAuteur} présents dans le RAG (fichiers commençant par "${slug}__"). return `Tu réponds EXCLUSIVEMENT depuis les livres de ${nomAuteur} présents dans le RAG (fichiers commençant par "${slug}__").
Si la question sort du périmètre de cet auteur, indique-le et propose de l'aborder sans le hashtag pour interroger la carte entière. Reste fidèle au style et à la pensée de ${nomAuteur}. Cite toujours le livre. Si la question sort du périmètre de cet auteur, indique-le et propose de l'aborder sans le hashtag pour interroger la carte entière. Reste fidèle au style et à la pensée de ${nomAuteur}. Cite toujours le livre.
@@ -122,8 +88,6 @@ export default defineEventHandler(async (event) => {
} }
const query: string = body.query.trim() const query: string = body.query.trim()
// jamais body.mode ici : réservé au mode de recherche LightRAG (hybrid…)
const profondeur = profondeurDuBody({ profondeur: body.profondeur })
const mode: string = body.mode || 'hybrid' const mode: string = body.mode || 'hybrid'
const corpus: string = body.corpus || 'pensees' const corpus: string = body.corpus || 'pensees'
const ragUrl: string = (config.ragPeUrl as string) || 'http://localhost:9621' const ragUrl: string = (config.ragPeUrl as string) || 'http://localhost:9621'
@@ -153,20 +117,8 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 503, message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.' }) throw createError({ statusCode: 503, message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.' })
} }
// user_prompt (LightRAG) : instructions ajoutées au prompt de génération, sans polluer la recherche const ragQuery = `${systemPreface}\n\nQuestion : ${query}`
// (vérifié sur 9621 le 09/10 : une consigne de fin de réponse est appliquée). La requête de const ragBody: Record<string, any> = { query: ragQuery, mode }
// recherche est la question seule.
const ragBody: Record<string, any> = {
query,
mode,
// textes des chunks utilisés : sert au garde-fou des citations (server/utils/citations.ts)
include_references: true,
include_chunk_content: true,
user_prompt: profondeur === 'approfondi'
? `${systemPreface}\n\n${POSTURE_REFLEXION}\n\n${REGLE_CITATIONS}`
: `${systemPreface}\n\n${REGLE_CITATIONS}`,
}
if (profondeur === 'approfondi') Object.assign(ragBody, PARAMS_RAG_REFLEXION)
if (auteurSlug && nomAuteurMatch) { if (auteurSlug && nomAuteurMatch) {
ragBody.hl_keywords = [nomAuteurMatch, auteurSlug] ragBody.hl_keywords = [nomAuteurMatch, auteurSlug]
ragBody.ll_keywords = [auteurSlug] ragBody.ll_keywords = [auteurSlug]
@@ -197,15 +149,8 @@ export default defineEventHandler(async (event) => {
} }
} }
// Garde-fou : un segment long entre guillemets absent des chunks récupérés perd ses guillemets.
const verif = verifierCitations(String(ragResponse.response ?? ''), contenusChunks(ragResponse.references))
if (verif.retirees > 0) {
console.warn(`[chatbot-pensees] ${verif.retirees} citation(s) non retrouvée(s) dans le contexte, guillemets retirés (${verif.gardees} gardée(s))`)
}
return { return {
response: verif.texte, response: ragResponse.response ?? '',
citations: { gardees: verif.gardees, retirees: verif.retirees },
mode, mode,
corpus, corpus,
auteur: auteurSlug && nomAuteurMatch ? { slug: auteurSlug, nom: nomAuteurMatch } : null, auteur: auteurSlug && nomAuteurMatch ? { slug: auteurSlug, nom: nomAuteurMatch } : null,
@@ -213,8 +158,5 @@ export default defineEventHandler(async (event) => {
auteur_chunks: auteurSlug && nomAuteurMatch ? { on_target: chunksOnTarget, off_target: chunksOffTarget } : null, auteur_chunks: auteurSlug && nomAuteurMatch ? { on_target: chunksOnTarget, off_target: chunksOffTarget } : null,
filter: { couche: body.filter_couche ?? null, ecole: body.filter_ecole ?? null }, filter: { couche: body.filter_couche ?? null, ecole: body.filter_ecole ?? null },
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
...(import.meta.dev
? { debug: { profondeur, user_prompt_chars: ragBody.user_prompt.length, rag_params: profondeur === 'approfondi' ? PARAMS_RAG_REFLEXION : null } }
: {}),
} }
}) })
+4 -31
View File
@@ -6,7 +6,7 @@
// @ts-ignore — JSON import résolu par Rollup // @ts-ignore — JSON import résolu par Rollup
import reseauxData from '../../public/data/reseaux-bifurcation.json' import reseauxData from '../../public/data/reseaux-bifurcation.json'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson' import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { pickBifrostTier, profondeurDuBody, bifrostMaxTokens, bifrostDebug, bifrostContent, REGLE_GUILLEMETS, type BifrostChatResponse } from '~/server/utils/bifrost' import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
interface Structure { interface Structure {
id: string id: string
@@ -41,29 +41,6 @@ FORMAT :
STRUCTURES DISPONIBLES : STRUCTURES DISPONIBLES :
{{STRUCTURES_JSON}}` {{STRUCTURES_JSON}}`
// Prompt « réflexion » (mode approfondi, v1 — prompts définitifs par onglet : B7/B9). Même format JSON.
const SYSTEM_PROMPT_REFLEXION = `Tu es un conseiller archi, au service des architectes engagés dans la transition écologique. Tu connais le réseau AEP (Architecture d'Écologie Politique) — une cartographie des collectifs, agences, initiatives et réseaux qui portent une vision alternative de l'architecture en France et en Europe.
POSTURE : avant de recommander des structures, tu questionnes le projet de l'usager. Tu reformules ce que tu comprends de son projet, tu nommes ce qui reste flou ou en tension (échelle, maîtrise d'ouvrage, filière de matériaux, rapport au territoire, posture professionnelle, moyens), tu poses 2 ou 3 questions précises qui l'aident à se situer, PUIS tu orientes vers les structures du contexte qui correspondent à ce qu'il a dit, en expliquant ce que chacune lui apporterait ou lui ferait choisir.
RÈGLES :
1. Ne cite QUE des structures présentes dans le contexte ci-dessous.
2. ${REGLE_GUILLEMETS}
3. Sois direct et engagé — c'est l'esprit AEP — mais n'affirme pas ce que tu ne sais pas de son projet : demande.
4. Réponse de 300 mots maximum, en français, en prose, tutoiement.
5. Retourne UNIQUEMENT un JSON valide, sans texte avant ou après.
FORMAT :
{
"reponse_texte": "Ta réponse en prose, max 300 mots : reformulation, tension, questions, puis structures",
"fiches_recommandees": [
{ "id": "slug-id", "nom": "Nom structure", "explication": "Pourquoi en 1 phrase" }
]
}
STRUCTURES DISPONIBLES :
{{STRUCTURES_JSON}}`
function scoreStructure(s: Structure, keywords: string[]): number { function scoreStructure(s: Structure, keywords: string[]): number {
if (keywords.length === 0) return 1 if (keywords.length === 0) return 1
const haystack = [s.nom, s.famille_principale, s.description_courte, s.description_longue, (s.hashtags ?? []).join(' '), s.ville, s.pays] const haystack = [s.nom, s.famille_principale, s.description_courte, s.description_longue, (s.hashtags ?? []).join(' '), s.ville, s.pays]
@@ -85,8 +62,7 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event) const body = await readBody(event)
const question: string = (body?.question ?? '').trim() const question: string = (body?.question ?? '').trim()
if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' }) if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' })
const profondeur = profondeurDuBody(body) const tier = pickBifrostTier(body?.mode)
const tier = pickBifrostTier(profondeur)
const structures: Structure[] = ((reseauxData as any).structures ?? []) const structures: Structure[] = ((reseauxData as any).structures ?? [])
const keywords = extractKeywords(question) const keywords = extractKeywords(question)
@@ -106,14 +82,13 @@ export default defineEventHandler(async (event) => {
description: (s.description_courte ?? s.description_longue ?? '').slice(0, 200), description: (s.description_courte ?? s.description_longue ?? '').slice(0, 200),
})) }))
const systemPrompt = (profondeur === 'approfondi' ? SYSTEM_PROMPT_REFLEXION : SYSTEM_PROMPT).replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0)) const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
const bifrostUrl = config.bifrostUrl as string const bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string const bifrostVk = config.bifrostVk as string
if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' }) if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' })
let mistralRaw: string let mistralRaw: string
let debugExtra: Record<string, unknown> = {}
try { try {
const res = await $fetch<BifrostChatResponse>( const res = await $fetch<BifrostChatResponse>(
`${bifrostUrl}/v1/chat/completions`, `${bifrostUrl}/v1/chat/completions`,
@@ -124,7 +99,7 @@ export default defineEventHandler(async (event) => {
model: tier.model, model: tier.model,
fallbacks: tier.fallbacks, fallbacks: tier.fallbacks,
temperature: 0.3, temperature: 0.3,
max_tokens: bifrostMaxTokens(700, profondeur), max_tokens: 700 + REASONING_MARGIN_TOKENS,
response_format: { type: 'json_object' }, response_format: { type: 'json_object' },
messages: [ messages: [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
@@ -134,7 +109,6 @@ export default defineEventHandler(async (event) => {
} }
) )
mistralRaw = bifrostContent(res, 'chatbot-reseaux') mistralRaw = bifrostContent(res, 'chatbot-reseaux')
debugExtra = bifrostDebug(res, 'chatbot-reseaux', profondeur)
} catch { } catch {
throw createError({ statusCode: 502, message: 'Erreur IA — réessaie dans quelques instants.' }) throw createError({ statusCode: 502, message: 'Erreur IA — réessaie dans quelques instants.' })
} }
@@ -148,7 +122,6 @@ export default defineEventHandler(async (event) => {
nom: r.nom ?? structures.find(s => s.id === r.id)?.nom ?? r.id, nom: r.nom ?? structures.find(s => s.id === r.id)?.nom ?? r.id,
explication: r.explication ?? '', explication: r.explication ?? '',
})), })),
...debugExtra,
} }
} catch { } catch {
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", fiches_recommandees: [] } return { reponse_texte: "Je n'ai pas pu analyser ta demande.", fiches_recommandees: [] }
+4 -31
View File
@@ -7,7 +7,7 @@
// @ts-ignore — JSON import résolu par Vite/Rollup // @ts-ignore — JSON import résolu par Vite/Rollup
import taffData from '../../public/data/plateformes-taff.json' import taffData from '../../public/data/plateformes-taff.json'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson' import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { pickBifrostTier, profondeurDuBody, bifrostMaxTokens, bifrostDebug, bifrostContent, REGLE_GUILLEMETS, type BifrostChatResponse } from '~/server/utils/bifrost' import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
interface PlateformeMinimal { interface PlateformeMinimal {
id: string id: string
@@ -49,29 +49,6 @@ FORMAT :
PLATEFORMES DISPONIBLES : PLATEFORMES DISPONIBLES :
{{PLATEFORMES_JSON}}` {{PLATEFORMES_JSON}}`
// Prompt « réflexion » (mode approfondi, v1 — prompts définitifs par onglet : B7/B9). Même format JSON.
const SYSTEM_PROMPT_REFLEXION = `Tu es un conseiller pour les architectes indépendants français qui cherchent des missions. Tu connais toutes les plateformes de mise en relation architecte↔particulier et les agrégateurs d'appels d'offres publics référencés par AEP (Architecture d'Écologie Politique).
POSTURE : avant de recommander une plateforme, tu aides l'architecte à clarifier ce qu'il cherche et qui il est : type de missions visées (particuliers, public, réno, neuf), volume et rythme, statut et assurances, ce qu'il accepte de céder (commission, abonnement, temps de réponse), ses valeurs. Tu reformules son profil tel que tu le comprends, tu nommes un arbitrage que sa demande laisse ouvert, tu poses 2 ou 3 questions qui l'aident à se situer, PUIS tu orientes vers LA ou LES plateformes du contexte qui correspondent, avec leurs limites.
RÈGLES :
1. Ne recommande QUE des plateformes présentes dans le contexte ci-dessous. Si l'une est « À éviter », signale-le clairement.
2. ${REGLE_GUILLEMETS}
3. Sois direct et opinionné — c'est la valeur d'AEP — mais ne présume pas de ce que tu ignores de son profil : demande.
4. Réponse de 300 mots maximum, en français, en prose, ton conseiller pair, tutoiement.
5. Retourne UNIQUEMENT un JSON valide, sans texte avant ou après.
FORMAT :
{
"reponse_texte": "Ta réponse en prose, max 300 mots : reformulation, arbitrage, questions, puis plateformes",
"plateformes_recommandees": [
{ "id": "slug-kebab", "nom": "Nom plateforme", "raison": "Pourquoi cette plateforme en 1 phrase" }
]
}
PLATEFORMES DISPONIBLES :
{{PLATEFORMES_JSON}}`
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
@@ -90,8 +67,7 @@ export default defineEventHandler(async (event) => {
if (!question || question.length < 5) { if (!question || question.length < 5) {
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' }) throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
} }
const profondeur = profondeurDuBody(body) const tier = pickBifrostTier(body?.mode)
const tier = pickBifrostTier(profondeur)
// Données bundlées statiquement à la compilation (import JSON) // Données bundlées statiquement à la compilation (import JSON)
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({ const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
@@ -115,7 +91,7 @@ export default defineEventHandler(async (event) => {
justification: p.scoring.justification_tag, justification: p.scoring.justification_tag,
})) }))
const systemPrompt = (profondeur === 'approfondi' ? SYSTEM_PROMPT_REFLEXION : SYSTEM_PROMPT).replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0)) const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
const bifrostUrl = config.bifrostUrl as string const bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string const bifrostVk = config.bifrostVk as string
@@ -124,7 +100,6 @@ export default defineEventHandler(async (event) => {
} }
let mistralRaw: string let mistralRaw: string
let debugExtra: Record<string, unknown> = {}
try { try {
const res = await $fetch<BifrostChatResponse>( const res = await $fetch<BifrostChatResponse>(
`${bifrostUrl}/v1/chat/completions`, `${bifrostUrl}/v1/chat/completions`,
@@ -135,7 +110,7 @@ export default defineEventHandler(async (event) => {
model: tier.model, model: tier.model,
fallbacks: tier.fallbacks, fallbacks: tier.fallbacks,
temperature: 0.3, temperature: 0.3,
max_tokens: bifrostMaxTokens(700, profondeur), max_tokens: 700 + REASONING_MARGIN_TOKENS,
response_format: { type: 'json_object' }, response_format: { type: 'json_object' },
messages: [ messages: [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
@@ -145,7 +120,6 @@ export default defineEventHandler(async (event) => {
} }
) )
mistralRaw = bifrostContent(res, 'chatbot-taff') mistralRaw = bifrostContent(res, 'chatbot-taff')
debugExtra = bifrostDebug(res, 'chatbot-taff', profondeur)
} catch { } catch {
throw createError({ statusCode: 502, statusMessage: 'Erreur IA — réessaie dans quelques instants.' }) throw createError({ statusCode: 502, statusMessage: 'Erreur IA — réessaie dans quelques instants.' })
} }
@@ -159,7 +133,6 @@ export default defineEventHandler(async (event) => {
nom: r.nom ?? plateformes.find(p => p.id === r.id)?.nom ?? r.id, nom: r.nom ?? plateformes.find(p => p.id === r.id)?.nom ?? r.id,
raison: r.raison ?? '', raison: r.raison ?? '',
})), })),
...debugExtra,
} }
} catch { } catch {
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", plateformes_recommandees: [] } return { reponse_texte: "Je n'ai pas pu analyser ta demande.", plateformes_recommandees: [] }
+194
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
}
})
+5 -34
View File
@@ -19,7 +19,7 @@
import { checkRateLimitJson } from '~/server/utils/rateLimitJson' import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker' import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
import { pickBifrostTier, profondeurDuBody, bifrostMaxTokens, bifrostDebug, bifrostContent, REGLE_GUILLEMETS, type BifrostChatResponse } from '~/server/utils/bifrost' import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
// ── Types ────────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────────
@@ -70,32 +70,6 @@ FORMAT DE SORTIE :
CONTEXTE — Organisations disponibles dans la base NAV : CONTEXTE — Organisations disponibles dans la base NAV :
{{FICHES_JSON}}` {{FICHES_JSON}}`
// ── Prompt « réflexion » (mode approfondi, v1 — prompts définitifs par onglet : B7/B9) ──────
// Même format de sortie JSON que SYSTEM_PROMPT (le front en dépend) ; seule la posture change.
const SYSTEM_PROMPT_REFLEXION = `Tu es un conseiller-ressources au service de la transition écologique des pratiques architecturales, sur AEP (Architecture d'Écologie Politique) — Écosystème Entraide, une base collaborative qui référence les acteurs de l'écologie politique appliquée à l'architecture et au territoire (organisations, outils, ressources) pour les architectes en France.
POSTURE : l'usager pose rarement le bon problème du premier coup. Tu l'aides d'abord à formuler son besoin, puis tu l'orientes vers les ressources et fiches du contexte. Tu es dialectique : tu reformules ce que tu comprends, tu nommes une tension ou un choix que la question laisse ouvert, tu poses 2 ou 3 questions qui l'aident à se situer (où en est son projet, avec qui, à quelle échelle, quelle contrainte pèse le plus), PUIS tu proposes des pistes.
RÈGLES ABSOLUES :
1. Tu ne peux recommander QUE des organisations présentes dans le contexte ci-dessous. Ne jamais inventer d'organisation absente.
2. Cite chaque organisation recommandée par son nom exact et son identifiant id. Si aucune n'est pertinente, dis-le honnêtement.
3. ${REGLE_GUILLEMETS}
4. Réponse de 300 mots maximum, en français, en prose (pas de liste à puces), ton de pair, tutoiement.
5. Si la question est hors du champ architecture / écologie / territoire / transition, recadre poliment vers le périmètre de la carte.
6. Retourne UNIQUEMENT un objet JSON valide, sans texte avant ou après.
FORMAT DE SORTIE :
{
"reponse_texte": "Ta réponse en prose (max 300 mots) : reformulation, tension, questions, puis pistes",
"fiches_recommandees": [
{ "id": 123, "explication": "Pourquoi cette fiche répond à la question (1-2 phrases max)" }
]
}
CONTEXTE — Organisations disponibles dans la base NAV :
{{FICHES_JSON}}`
// ── Recherche par mots-clés ──────────────────────────────────────────────────── // ── Recherche par mots-clés ────────────────────────────────────────────────────
function scoreOrg(org: OrgRow, keywords: string[]): number { function scoreOrg(org: OrgRow, keywords: string[]): number {
@@ -208,8 +182,7 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event) const body = await readBody(event)
const question: string = (body?.question ?? '').trim() const question: string = (body?.question ?? '').trim()
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {} const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
const profondeur = profondeurDuBody(body) const tier = pickBifrostTier(body?.mode)
const tier = pickBifrostTier(profondeur)
if (!question || question.length < 3) { if (!question || question.length < 3) {
throw createError({ throw createError({
@@ -272,7 +245,7 @@ export default defineEventHandler(async (event) => {
ville: o.localisation_ville ?? '', ville: o.localisation_ville ?? '',
})) }))
const systemPrompt = (profondeur === 'approfondi' ? SYSTEM_PROMPT_REFLEXION : SYSTEM_PROMPT).replace( const systemPrompt = SYSTEM_PROMPT.replace(
'{{FICHES_JSON}}', '{{FICHES_JSON}}',
JSON.stringify(fichesContext, null, 0), JSON.stringify(fichesContext, null, 0),
) )
@@ -292,7 +265,6 @@ export default defineEventHandler(async (event) => {
let tokensIn = 0 let tokensIn = 0
let tokensOut = 0 let tokensOut = 0
let realModel = tier.model let realModel = tier.model
let debugExtra: Record<string, unknown> = {}
try { try {
const bifrostRes = await $fetch<BifrostChatResponse>(`${bifrostUrl}/v1/chat/completions`, { const bifrostRes = await $fetch<BifrostChatResponse>(`${bifrostUrl}/v1/chat/completions`, {
@@ -305,7 +277,7 @@ export default defineEventHandler(async (event) => {
model: tier.model, model: tier.model,
fallbacks: tier.fallbacks, fallbacks: tier.fallbacks,
temperature: 0.3, temperature: 0.3,
max_tokens: bifrostMaxTokens(600, profondeur), max_tokens: 600 + REASONING_MARGIN_TOKENS,
response_format: { type: 'json_object' }, response_format: { type: 'json_object' },
messages: [ messages: [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
@@ -315,7 +287,6 @@ export default defineEventHandler(async (event) => {
}) })
mistralRaw = bifrostContent(bifrostRes, 'chatbot') mistralRaw = bifrostContent(bifrostRes, 'chatbot')
debugExtra = bifrostDebug(bifrostRes, 'chatbot', profondeur)
tokensIn = bifrostRes.usage?.prompt_tokens ?? 0 tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
tokensOut = bifrostRes.usage?.completion_tokens ?? 0 tokensOut = bifrostRes.usage?.completion_tokens ?? 0
realModel = realModel =
@@ -369,5 +340,5 @@ export default defineEventHandler(async (event) => {
coutEur, coutEur,
}) })
return { ...parsed, ...debugExtra } return parsed
}) })
+5 -43
View File
@@ -4,9 +4,8 @@
* Auth : header x-bf-vk * Auth : header x-bf-vk
* *
* 2 tiers (Mission M3 du build Bifrost ; modèles primaires changés le 28/09, session AEP front 4) : * 2 tiers (Mission M3 du build Bifrost ; modèles primaires changés le 28/09, session AEP front 4) :
* RAPIDE — défaut (mode « Question » du toggle front) * RAPIDE — défaut, pas de toggle UI mode rapide/approfondi sur le site actuellement
* APPROFONDI — body.profondeur === 'approfondi' (mode « Réflexion plus profonde », B1-M3 ; * APPROFONDI — activable via body.mode === 'approfondi' (prêt pour un futur toggle front)
* body.mode reste lu en compat ascendante). Contrat : utils/chatProfondeur.ts
* *
* ⚠ 28/09 : Groq a retiré groq/llama-3.1-8b-instant (RAPIDE) et groq/llama-3.3-70b-versatile * ⚠ 28/09 : Groq a retiré groq/llama-3.1-8b-instant (RAPIDE) et groq/llama-3.3-70b-versatile
* (APPROFONDI) — 404 model_not_found via Bifrost. Depuis le 23/09 environ, chaque appel * (APPROFONDI) — 404 model_not_found via Bifrost. Depuis le 23/09 environ, chaque appel
@@ -33,8 +32,6 @@
* juste plus dans cette chaîne tant que ce n'est pas fiabilisé. * juste plus dans cette chaîne tant que ce n'est pas fiabilisé.
*/ */
import { normaliserProfondeur, type ChatProfondeur } from '~/utils/chatProfondeur'
export const BIFROST_TIER_RAPIDE = { export const BIFROST_TIER_RAPIDE = {
model: 'groq/openai/gpt-oss-20b', model: 'groq/openai/gpt-oss-20b',
fallbacks: [ fallbacks: [
@@ -60,34 +57,11 @@ export const BIFROST_TIER_APPROFONDI = {
*/ */
export const REASONING_MARGIN_TOKENS = 800 export const REASONING_MARGIN_TOKENS = 800
/** Sélectionne le tier selon la profondeur ('approfondi' → APPROFONDI, tout le reste → RAPIDE). */ /** Sélectionne le tier selon le param optionnel body.mode. */
export function pickBifrostTier(profondeur?: string) { export function pickBifrostTier(mode?: string) {
return profondeur === 'approfondi' ? BIFROST_TIER_APPROFONDI : BIFROST_TIER_RAPIDE return mode === 'approfondi' ? BIFROST_TIER_APPROFONDI : BIFROST_TIER_RAPIDE
} }
/**
* Profondeur demandée par le body : `profondeur`, sinon `mode` (compat ascendante des
* routes Bifrost avant B1-M3). Valeur inconnue → 'rapide'.
*/
export function profondeurDuBody(body: any): ChatProfondeur {
return normaliserProfondeur(body?.profondeur ?? body?.mode)
}
/**
* Budget max_tokens = contenu + marge de raisonnement. Le mode approfondi autorise une
* réponse un peu plus longue (+200) et une marge plus large (+400) : le 120b raisonne plus.
* Reste sous les limites par minute d'une clé Groq (prompt ~3-4 k + 2,2 k max).
*/
export function bifrostMaxTokens(contenu: number, profondeur: ChatProfondeur): number {
return profondeur === 'approfondi'
? contenu + 200 + REASONING_MARGIN_TOKENS + 400
: contenu + REASONING_MARGIN_TOKENS
}
/** Règle guillemets, commune à tous les prompts « réflexion » (cf. REGLE_CITATIONS de Pensées). */
export const REGLE_GUILLEMETS =
'Ne mets entre guillemets que ce qui figure mot pour mot dans le contexte fourni ; sinon reformule sans guillemets.'
export interface BifrostChatResponse { export interface BifrostChatResponse {
/** reasoning : présent avec gpt-oss, jamais lu (la réponse utile est dans content). */ /** reasoning : présent avec gpt-oss, jamais lu (la réponse utile est dans content). */
choices: { message: { content: string; reasoning?: string }; finish_reason?: string }[] choices: { message: { content: string; reasoning?: string }; finish_reason?: string }[]
@@ -95,18 +69,6 @@ export interface BifrostChatResponse {
extra_fields?: { provider?: string; resolved_model_used?: string } extra_fields?: { provider?: string; resolved_model_used?: string }
} }
/**
* Preuve du tier réellement servi : journal serveur toujours (modèle dans le log seulement),
* et champ `debug` dans la réponse UNIQUEMENT en dev (import.meta.dev, éliminé du build de
* production). Aucun nom de modèle n'est exposé publiquement.
*/
export function bifrostDebug(res: BifrostChatResponse, route: string, profondeur: ChatProfondeur) {
const modele = res.extra_fields?.resolved_model_used ?? null
const fournisseur = res.extra_fields?.provider ?? null
console.log(`[${route}] profondeur=${profondeur} fournisseur=${fournisseur} modèle=${modele}`)
return import.meta.dev ? { debug: { profondeur, fournisseur, modele } } : {}
}
/** /**
* Contenu JSON de la réponse. Une troncature (finish_reason 'length') ne lève aucune erreur * Contenu JSON de la réponse. Une troncature (finish_reason 'length') ne lève aucune erreur
* et finit en message générique côté usager : on la signale au journal pour la voir. * et finit en message générique côté usager : on la signale au journal pour la voir.
-99
View File
@@ -1,99 +0,0 @@
/**
* Garde-fou des citations (B1-M4) — fonctions pures, sans dépendance (réutilisables par les scripts Node).
*
* Problème : une consigne de prompt (REGLE_CITATIONS) ne suffit pas, le modèle met entre guillemets des
* phrases qui ne figurent pas dans le contexte récupéré (mesuré le 09/10 : 7/7 introuvables sur une
* question ouverte). Une fausse citation attribuée à un auteur réel engage le site : on la vérifie dans
* le code. Tout segment long entre guillemets absent des textes de chunks réellement récupérés perd ses
* guillemets (le texte reste, sans valeur de citation).
*/
export const MIN_MOTS_CITATION = 6
/** Sans accents, minuscules, tout ce qui n'est ni lettre ni chiffre devient un espace (apostrophes, \r\n, markdown). */
export function normaliserTexte(s: string): string {
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim()
}
export interface SegmentCitation {
/** texte tel qu'il apparaît entre les guillemets */
segment: string
mots: number
/** true si segment court (< minMots) : jamais vérifié, jamais retiré */
court: boolean
/** true si retrouvé dans le contexte (toujours true pour un segment court) */
trouve: boolean
}
export interface ResultatCitations {
/** réponse dont les citations longues introuvables ont perdu leurs guillemets */
texte: string
gardees: number
retirees: number
segments: SegmentCitation[]
}
// « … » (espaces fines ou insécables tolérés), “ … ”, " … ". Les « » sont traités en premier :
// un "…" imbriqué ne doit pas casser l'appariement.
const MOTIFS: RegExp[] = [
/«[\s  ]*([^»]+?)[\s  ]*»/g,
/“([^”]+?)”/g,
/"([^"\n]+?)"/g,
]
// Élision dans une citation : [...], […], … ou ... — chaque morceau doit être retrouvé séparément.
const ELISION = /\[\s*(?:\.{3}|…)\s*\]|…|\.{3}/
function mots(n: string): number {
return n ? n.split(' ').length : 0
}
/**
* Vérifie les citations de `reponse` contre `contextes` (textes des chunks récupérés).
* - segment >= minMots mots : cherché (normalisé, borné aux mots entiers) ; absent => guillemets retirés.
* - segment < minMots mots (titres, expressions) : laissé tel quel.
* Aucun contexte exploitable => aucune citation longue ne peut être vérifiée, donc aucune n'est gardée.
*/
export function verifierCitations(reponse: string, contextes: string[], minMots: number = MIN_MOTS_CITATION): ResultatCitations {
const ctx = ` ${normaliserTexte(contextes.join('\n'))} `
const segments: SegmentCitation[] = []
let gardees = 0
let retirees = 0
const trouve = (seg: string): boolean => {
const parts = seg.split(ELISION).map(normaliserTexte).filter(Boolean)
return parts.length > 0 && parts.every(p => ctx.includes(` ${p} `))
}
let texte = reponse
for (const motif of MOTIFS) {
texte = texte.replace(motif, (entier: string, seg: string) => {
const n = normaliserTexte(seg.split(ELISION).join(' '))
const nb = mots(n)
if (nb < minMots) {
segments.push({ segment: seg, mots: nb, court: true, trouve: true })
return entier
}
const ok = trouve(seg)
segments.push({ segment: seg, mots: nb, court: false, trouve: ok })
if (ok) { gardees++; return entier }
retirees++
return seg
})
}
return { texte, gardees, retirees, segments }
}
/** Concatène les textes de chunks d'une réponse LightRAG (`references[].content`, tableau de chaînes ou chaîne). */
export function contenusChunks(references: unknown): string[] {
if (!Array.isArray(references)) return []
const out: string[] = []
for (const r of references) {
const c = (r as { content?: unknown })?.content
if (Array.isArray(c)) for (const x of c) { if (typeof x === 'string') out.push(x) }
else if (typeof c === 'string') out.push(c)
}
return out
}
+96
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 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 []
}
}
-27
View File
@@ -1,27 +0,0 @@
/**
* Mode de profondeur des chatbots (M3) — contrat partagé front/serveur.
* Champ de body : `profondeur` = 'rapide' | 'approfondi' (défaut 'rapide').
* Les libellés affichés vivent ICI, à un seul endroit : les changer ne touche ni
* le composant ni le serveur.
*/
export type ChatProfondeur = 'rapide' | 'approfondi'
export const CHAT_PROFONDEUR_DEFAUT: ChatProfondeur = 'rapide'
export const CHAT_PROFONDEUR_LIBELLES: Record<ChatProfondeur, string> = {
rapide: 'Question',
approfondi: 'Réflexion plus profonde',
}
/** Sous-texte (title/aria) de chaque option. */
export const CHAT_PROFONDEUR_AIDES: Record<ChatProfondeur, string> = {
rapide: 'Réponse directe et concise',
approfondi: 'Une réponse plus longue, qui questionne ton besoin avant de répondre',
}
export const CHAT_PROFONDEURS = Object.keys(CHAT_PROFONDEUR_LIBELLES) as ChatProfondeur[]
/** Normalise une valeur inconnue (body, stockage) vers un mode valide. */
export function normaliserProfondeur(v: unknown): ChatProfondeur {
return v === 'approfondi' ? 'approfondi' : 'rapide'
}
+8 -1
View File
@@ -10,13 +10,14 @@
import type { MiniFiche } from '../composables/useFicheAdapter' import type { MiniFiche } from '../composables/useFicheAdapter'
export type EtatVue = 'carte' | 'mixte' | 'fiches' export type EtatVue = 'carte' | 'mixte' | 'fiches'
export type TriFiches = 'pertinence' | 'nom' | 'recent' export type TriFiches = 'pertinence' | 'nom' | 'recent' | 'ordre'
export const ETATS_VUE: EtatVue[] = ['carte', 'mixte', 'fiches'] export const ETATS_VUE: EtatVue[] = ['carte', 'mixte', 'fiches']
export const LIBELLES_TRI: Record<TriFiches, string> = { export const LIBELLES_TRI: Record<TriFiches, string> = {
pertinence: 'Pertinence', pertinence: 'Pertinence',
nom: 'A → Z', nom: 'A → Z',
recent: 'Récentes', recent: 'Récentes',
ordre: 'Ordre d’origine',
} }
export interface ValeurFiltre { export interface ValeurFiltre {
@@ -57,6 +58,10 @@ export interface ConfigVueFiches {
groupes: GroupeDef[] groupes: GroupeDef[]
etats: EtatVue[] etats: EtatVue[]
sousVues?: SousVuesCarte sousVues?: SousVuesCarte
/** Tri sans filtre actif (défaut : `nom`). Doit figurer dans `tris`. */
triDefaut?: TriFiches
/** Libellés de tri propres à la page (sinon `LIBELLES_TRI`). */
libellesTri?: Partial<Record<TriFiches, string>>
} }
export interface EtatFiches { export interface EtatFiches {
@@ -227,6 +232,7 @@ export function triEffectif(etat: EtatFiches, cfg: ConfigVueFiches): TriFiches {
if (etat.tri) return etat.tri if (etat.tri) return etat.tri
const filtreEtiquette = Object.values(etat.filtres).some((v) => v.length > 0) const filtreEtiquette = Object.values(etat.filtres).some((v) => v.length > 0)
if (filtreEtiquette && cfg.tris.includes('pertinence')) return 'pertinence' if (filtreEtiquette && cfg.tris.includes('pertinence')) return 'pertinence'
if (cfg.triDefaut && cfg.tris.includes(cfg.triDefaut)) return cfg.triDefaut
return cfg.tris.includes('nom') ? 'nom' : cfg.tris[0] ?? 'nom' return cfg.tris.includes('nom') ? 'nom' : cfg.tris[0] ?? 'nom'
} }
@@ -250,6 +256,7 @@ function idNum(f: MiniFiche): number {
export function trierFiches(fiches: MiniFiche[], tri: TriFiches, etat: EtatFiches): MiniFiche[] { export function trierFiches(fiches: MiniFiche[], tri: TriFiches, etat: EtatFiches): MiniFiche[] {
const copie = [...fiches] const copie = [...fiches]
if (tri === 'ordre') return copie // ordre du tableau d'entrée (déjà filtré, jamais réordonné)
if (tri === 'nom') return copie.sort(parNom) if (tri === 'nom') return copie.sort(parNom)
if (tri === 'pertinence') { if (tri === 'pertinence') {
const score = new Map(copie.map((f) => [f, pertinence(f, etat)])) const score = new Map(copie.map((f) => [f, pertinence(f, etat)]))