Compare commits
23 Commits
586742d90e
...
feat/aep-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22a7a42206 | ||
|
|
b2fbbcb198 | ||
|
|
110fd58ec2 | ||
|
|
3917717eb1 | ||
|
|
55d7e4de55 | ||
|
|
88716657a2 | ||
|
|
86b95fa18e | ||
|
|
3a07d368f0 | ||
|
|
20d228fde7 | ||
|
|
fd33debf06 | ||
|
|
40b406bd41 | ||
|
|
46f57ae5fe | ||
|
|
b36587cb08 | ||
|
|
89608d894c | ||
|
|
fdd9d02859 | ||
|
|
a1c47002d5 | ||
|
|
c14a1ee01f | ||
|
|
1b1e373bea | ||
|
|
c6295ea228 | ||
|
|
cd2d225e91 | ||
|
|
11732a6a4b | ||
|
|
538c9a1214 | ||
|
|
8d673482b6 |
105
JOURNAL-V2.md
105
JOURNAL-V2.md
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
type: journal
|
type: journal
|
||||||
|
note_J8: Phase 8.G LIVREE 2026-05-14 - voir PILOTE-RAG-PE.md pour details
|
||||||
project: NAV V2
|
project: NAV V2
|
||||||
created: 2026-04-14
|
created: 2026-04-14
|
||||||
status: actif
|
status: actif
|
||||||
@@ -11,6 +12,56 @@ Journal technique de la V2. Décisions, anomalies, points bloquants, TODOs.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-05-08 — Fix mobile + chatbot prod (cause racine résolue)
|
||||||
|
|
||||||
|
**Commits :** session loggée sur main (pushé sur gitea)
|
||||||
|
**Pattern :** pilote direct, 2 batches successifs, ~3h, 11 fichiers
|
||||||
|
|
||||||
|
### Cause racine bug "chatbot Carte 1 == Carte 2"
|
||||||
|
|
||||||
|
`/api/chatbot-reseaux` était **404 en prod** (jamais déployé) — explique pourquoi 5 cycles de fix précédents (ChatbotReseaux.vue prop endpoint, useRoute fallback, useMarkdown direct, etc.) n'ont rien donné : le code source était correct depuis le début. Le rebuild + redeploy de cette session résout le bug.
|
||||||
|
|
||||||
|
**Verif :** `curl -s -X POST https://aep.trans-former.fr/api/chatbot-reseaux` → 200 + réponse distincte de `/api/chatbot`.
|
||||||
|
|
||||||
|
### Batch 1 — fixes mobile principaux
|
||||||
|
|
||||||
|
- Hamburger app.vue : ajout Jobs + Manifeste + Soutenir, ré-ordonnancement (Manifeste dans 2e groupe avec À propos/Soutenir/Signaler)
|
||||||
|
- BandeauBas.vue : FAB cœur jaune mobile retiré (Soutenir migré dans hamburger via lien Liberapay direct)
|
||||||
|
- agences.vue mobile : 3e onglet "Graphe" ajouté + masquage MobileSheet en mode graphe (canvas fullscreen)
|
||||||
|
- a-propos.vue : section 1 "Mission" retirée (devient pop-up Carte 1) + `overflow-x: hidden` sur `.apropos-page` + retrait `white-space: nowrap` problématique sur `.badge-detail`
|
||||||
|
- pages/manifeste.vue : nouvelle page (texte version `manifeste-page-carto-V1.md`, sans le diagramme ASCII pour V1 web)
|
||||||
|
- components/MissionPopup.vue : nouveau composant générique (props `title`, `ctaLabel`, `storageKey`, slot pour contenu, `:slotted()` pour styles)
|
||||||
|
- index.vue : intégration MissionPopup + bouton (i) `position:fixed` bottom-left + auto-show 1ère visite via `localStorage.aep_mission_seen`
|
||||||
|
- trouver-du-taf.vue : toggle "Filtres [N] [chevron]" mobile-only (`@media max-width: 767px`) avec `taff-filters-collapsible` max-height transition
|
||||||
|
- FicheModal.vue + FicheModalV2.vue : sur mobile `top: 76px` + `max-height: calc(100dvh - 92px)` au lieu de `top: 50% translate(-50%, -50%)` + `max-height: 90vh` qui mordait sur le header
|
||||||
|
|
||||||
|
### Batch 2 — pop-up Carte 2, logo, intro Jobs, labels graphe
|
||||||
|
|
||||||
|
- agences.vue : pop-up Réseaux AEP avec MissionPopup (storageKey `aep_reseaux_seen`, ctaLabel "Explorer les 120 réseaux") + bouton (i) flottant
|
||||||
|
- app.vue logo header : badge AEP + 2 spans `logo-line-1` ("Architecture") / `logo-line-2` ("d'Écologie Politique") avec font-size responsive (0.7rem mobile → 0.85rem ≥1024)
|
||||||
|
- trouver-du-taf.vue : `<details class="taff-pedago" open>` avec 3 blocs (deux onglets, trois étiquettes, cinq axes) + onglet "Plateformes B2C" → "Pour archi indépendants"
|
||||||
|
- GraphView.vue : `d3NodeSelection.filter(type==='structure').append('text')` avec class `graph-struct-label`, `dy: -(d.r + 5)`, font-size 9.5px, halo via `paint-order: stroke; stroke: var(--nav-bg)` (style global non-scoped pour piercer D3)
|
||||||
|
|
||||||
|
### Bug d'opération à retenir
|
||||||
|
|
||||||
|
Lors du 1er déploiement batch 2, `bash deploy.sh` semblait OK (HTTP 200) mais le HTML en prod ne contenait pas les modifs. **Cause** : Dropbox sync a effacé `.output/` entre `npm run build` et le tar SCP — le tar a uploadé un `.output` quasi-vide. Solution : 2e cycle clean (`Remove-Item .nuxt/dist + .output`) + rebuild + redeploy avec `yes y |` (skip confirm interactif `.env diff`).
|
||||||
|
|
||||||
|
**Réflexe à intégrer** : après build, vérifier `grep -o "<un-fragment-de-modif>" .output/public/_nuxt/*.js | head` AVANT le deploy. Si 0 match → ne pas deploy, rebuild.
|
||||||
|
|
||||||
|
### Bug de communication à retenir
|
||||||
|
|
||||||
|
Jules a signalé "le logo n'a pas marché", "B2C pas renommé", "hamburger pas modifié" alors que le HTML en prod contenait bien les modifs (vérifié curl avec `?nc=$(date +%s)`). **Cause** : cache navigateur / service worker Nuxt. Réflexe à mettre en place pour /done de toute session web : si Jules dit "ça n'apparaît pas", vérifier curl en bypass cache AVANT de chercher un bug. Si match curl → demander hard refresh (Ctrl+Shift+R).
|
||||||
|
|
||||||
|
### Reste à faire (batch 3)
|
||||||
|
|
||||||
|
Voir `0 INBOX/PROMPTS/cascade-megaboum/REPRISE-aep-carto-fix-batch3.md` :
|
||||||
|
- Bouton "+" → sélecteur 3 cartes (Entraide/Réseaux/Jobs)
|
||||||
|
- Pop-up explication 5 axes Jobs (paragraphe par axe)
|
||||||
|
- Pop-up Carte 1 visibilité (option à clarifier avec Jules)
|
||||||
|
- GraphView Carte 1 (centres = hashtags, couche échelle activable) — gros chantier session dédiée
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-04-27 — Session V3 : Finition mobile + Blog Liberapay + 3 deploys
|
## 2026-04-27 — Session V3 : Finition mobile + Blog Liberapay + 3 deploys
|
||||||
|
|
||||||
**Commit :** `a02a555` — feat(mobile): accordéon outremer, hamburger nav, logo AEP, fiches cliquables, chatbot fullscreen
|
**Commit :** `a02a555` — feat(mobile): accordéon outremer, hamburger nav, logo AEP, fiches cliquables, chatbot fullscreen
|
||||||
@@ -1003,3 +1054,57 @@ Incohérence entre `deploy.sh` (rsync vers `/opt/nav-carte/`) et `nav-carte.serv
|
|||||||
- **Caching API organisations** — actuellement re-fetch NocoDB à chaque render
|
- **Caching API organisations** — actuellement re-fetch NocoDB à chaque render
|
||||||
- **Full-text search côté client** — Fuse.js sur descriptions enrichies
|
- **Full-text search côté client** — Fuse.js sur descriptions enrichies
|
||||||
- **Mode offline / PWA** — manifest + service worker pour usage terrain
|
- **Mode offline / PWA** — manifest + service worker pour usage terrain
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Décisions structurantes (mémoire profonde)
|
||||||
|
|
||||||
|
> Archive des décisions structurantes du projet nav-carte (AEP V1/V2/Codev/Carte 3), déchargée hebdo depuis coordo-agent-dev. Tri chronologique inverse (plus récent en haut). Copies verbatim, pas de reformulation.
|
||||||
|
|
||||||
|
### 2026-W19 (décharge 2026-05-13)
|
||||||
|
|
||||||
|
- [W19 — 2026-05-08 19:21] **AEP nav-carte fix mobile batch1+2 LIVE + cause racine chatbot Carte1=Carte2 résolue** (~3h pilote direct, 2 batches successifs, 11 fichiers). **Cause racine identifiée** après 5 cycles de fix précédents infructueux : `/api/chatbot-reseaux` était **404 en prod** (jamais déployé). Le code source nav-carte était correct depuis le début. Rebuild + redeploy = bug résolu (vérifié curl POST sur les 2 endpoints, réponses distinctes). **Batch 1** : hamburger refondu (Jobs/Manifeste/Soutenir), FAB cœur jaune retiré, 3e onglet Graphe mobile sur agences, /a-propos refondue + scroll latéral fixé, page /manifeste créée (texte version page-carto-V1), MissionPopup générique avec slot/props (storageKey, title, ctaLabel) auto-show 1ère visite, filtres Jobs mobile repliables (toggle "Filtres [N]"), FicheModal/V2 décalage `top:76px` mobile pour ne plus mordre header. **Batch 2** : pop-up Carte 2 Réseaux AEP, logo header `Architecture d'Écologie Politique` en clair sur 2 lignes (logo-line-1/2 responsive), onglet "Plateformes B2C" → "Pour archi indépendants", intro pédagogique repliable Jobs (2 onglets / 3 tags / 5 axes éthiques), labels noms structures sur GraphView (D3 append text + halo via `paint-order: stroke`). **Pattern d'opération critique découvert** : Dropbox sync efface .output entre `npm run build` et `tar | ssh` du deploy.sh — 1er deploy batch 2 a uploadé un .output quasi-vide sans erreur visible (HTTP 200 trompeur). Réflexe : `grep "fragment-modif" .output/public/_nuxt/*.js | head` AVANT deploy. **Pattern de communication** : Jules a signalé "modifs pas faites" alors que HTML prod contenait bien les modifs (vérifié curl) — cache navigateur / service worker. Réflexe : si "ça apparaît pas", curl bypass cache AVANT chercher bug, puis demander hard refresh. Git : commit propre + push gitea/main (`yes y | bash deploy.sh` pour skip confirm interactif `.env diff`). Prompt batch 3 dans `0 INBOX/PROMPTS/cascade-megaboum/REPRISE-aep-carto-fix-batch3.md`.
|
||||||
|
|
||||||
|
- [W19 — 2026-05-07 17:18] **AEP Carte 3 "Trouver du taf" — T2 scoring 5 axes + T3/T4 LIVE** : 24 plateformes scorées (7✅/14⚠️/3❌), plateformes-taff.json prod, page /trouver-du-taf complète (filtres, grille 1 col, modal fiche, chatbot FAB Guide IA). Endpoints /api/chatbot-taff (import JSON statique + Mistral Small) + /api/chatbot-reseaux (keyword search 120 structures). ChatbotReseaux.vue créé (standalone Carte 2). useMarkdown.ts inline styles CSS-free. Onglet Jobs nav desktop. Bug dev : cache .nuxt corrompu par agent concurrent → bat rmdir+délai 12s. Chatbot séparation + markdown à vérifier en PROD (pas dev). Menu hamburger mobile Jobs manquant. Branche main.
|
||||||
|
|
||||||
|
- [W19 — 2026-05-07 01:11] **Codev MVP LIVRÉ en prod** — cascade M1→M5 complète (5 agents Sonnet), merge feat/codev-mvp→main, push Gitea. App entraide /codev live avec lock screen, formulaire, graphe D3 force-directed, annuaire table sticky, 2 modes matching (Solution tokenize direct + Alliance Jaccard), mode admin (DELETE fiche), QR code public, panel mobile bottom sheet. Décision build : TOUJOURS depuis C:\tmp\nav-build (Dropbox corrompt cache Vite = 500 prod). Algo fix critique : Solution compare textes besoin↔offre directement (ignore hashtags), évite que les 3 modes donnent le même graphe.
|
||||||
|
|
||||||
|
- [W19 — 2026-05-06 17:11] **MP-TAFF T1b scraping compléments DONE** — BrowserMCP utilisé pour débloquer Trustpilot (7 pages) + instao.fr + francemarches.com. `T1-output-plateformes.json` finalisé : 25 plateformes, 7 avec feedback Trustpilot. Signaux forts T2 : Archionline 2.4/5 (spam + permis PLU non conformes), hemea = courtier déguisé en MOE, TMA = meilleur ratio pros. Prochaine étape : **MP-TAFF T2 scoring 5 axes éthiques**.
|
||||||
|
|
||||||
|
- [W19 — 2026-05-06 13:30] **Cascade dispatchable Codev MVP livrée — 9 fichiers prêts à dispatcher en session Opus dédiée** : Cadrage `/orchestrateur` app entraide / co-développement Jules pour facilitation IRL jeudi 8/05 ~10 amis. 5 intersections tranchées : URL `/codev` (sous-route aep.trans-former.fr pas nouveau domaine), naming "Co-développement"/"Entraide entre pairs", mot de passe partagé `merci`, persistance NocoDB nouvelle table `codev_fiches` (carto vit hebdo, pas effacée), démo route publique `/codev/demo` (10 prénoms factices pour pitch univ). **Démêlage trinôme** (problème mathématique flaggé par Jules) : matching pair-only en MVP, trinômes émergent visuellement du D3 force-directed (triangles dans le graphe), pas de logique trinôme explicite codée — économie combinatoire majeure. 3 modes : Solution (besoin→offre asymétrique avec flèche), Alliance (besoin↔besoin symétrique), Surprise (offre↔offre symétrique). **Algo matching MVP simple** sans IA : Jaccard sur hashtags si présents, sinon Jaccard sur tokens FR (stop-words filtrés) seuil 0.15 — V2 embeddings si scaling. **Réutilisation pattern GraphView.vue** (~700 lignes existant) en simplifié (~200-300 lignes pour CodevGraph) — pas de greenfield. Cascade dans `codev-build/` (même pattern que `aep-communaute-build/`) : INDEX (table+décisions+statut) + META-PROMPT-OPUS (preflight+dispatch séquentiel+1 checkpoint deploy+format récap) + M1 (NocoDB table API + 3 endpoints + runtimeConfig) + M2 (lock+fiche+middleware auth skip /codev+/codev/demo) + M3 (CodevGraph D3 + page carto affichage seul) + M4 (matching 3 modes + boutons + animation force.alpha(0.5).restart()) + M5 split phase 1 (démo+build local+stop) + phase 2 (deploy prod après checkpoint Jules) + FEEDBACK-PASSES (10 risques pré-dispatch corrigés) + PHRASE-LANCEMENT (one-shot pour session Opus). **Patches en cours de session** : M1 fait création table NocoDB en autonomie via API (token NocoDB `e9rU...` déjà dans nav-carte/.env, endpoint `POST /api/v2/meta/bases/{baseId}/tables`), M5 phase 2 sync .env VPS automatiquement (ssh append + restart aep), règle d'or "couper M4/M5 si timing serré" retirée du META car Jules a confirmé scope OK. **1 seul checkpoint Jules** : entre M5 phase 1 (build local 200) et M5 phase 2 (deploy prod). Pattern méga-dispatch consécutif #5 (avant : Simulateur V2 30/04, Méga-cascade AEP V2 30/04, MP-TAFF cascade-megaboum 06/05, AEP V2 graphe PV2-5 micro-itérations 06/05). À dispatcher en session Opus dédiée (jauge propre, ~1h30 cascade attendue + ~5 min action manuelle Jules NocoDB).
|
||||||
|
|
||||||
|
- [W19 — 2026-05-06 21:30→01:45] **AEP V2 graphe interactif PV2-5b/e/f/g + chatbot v2 vivant + decouverte 2 repos imbriqués** : 4 commits vault `feat/aep-v2-cartobifurcation` (062337a sidebar+chatbot intégré, 2adffdf toggle Familles/Pratiques+popover famille, 5d7556a carte unifiée layers superposables+popover hashtag+lisibilité, e1ae1b9 popovers enrichis+FicheFamilleModal). 4 micro-itérations dispatch agent Sonnet en séquentiel, pilote commit lui-même chaque fois (pattern anti-hallucination établi après agent 1 a inventé hash 755d1ef). **Décisions design** : skip PV2-5c bicolores (8/120 structures = effet marginal), toggle PV2-5e exclusif fusionné en layers superposables PV2-5f (intuition Jules : séparation artificielle), Pratiques default OFF perf-friendly (174 noeuds + 640 liens si tout coché), FicheFamilleModal composant dédié réutilisable, skip définitions hashtags (pas de contenu source) → ligne générique "portée par N structures de M familles". **Découverte 2 repos git imbriqués** (vault parent ATIS-IPCJRA branche v2 + sous-repo nav-carte/ branche v1.1 distincte) → expliquait les "hallucinations" branche des agents. Notée dans `ATIS-Dev.md` section "Pièges connus" + réflexe pilote `git rev-parse --show-toplevel` au démarrage. **Chatbot v2** : endpoint v1→v2 dans ChatbotPlaceholder.vue (commit sous-repo 5878c56), vectorize-v2.js renommé en .cjs (incompat ESM type=module), payload Mistral fixed `inputs`→`input`, 120 embeddings générés (3.5MB embeddings-v2.json gitignored), patch vectorSearch.ts process.cwd() au lieu d'import.meta.url (bug Nitro bundle). **Rotation clé Mistral** : nouvelle clé `PXsPUhk...` notée _System/API-credentials.md, appliquée local .env + VPS /opt/aep/.env (backup .env.bak.before-rotation-20260506) + restart aep + smoke test prod chatbot v1 OK. **Doc features graphe** créée : `aep-communaute-build/PV2-5-FEATURES-GRAPHE.md` (briefing agent qui découvre en 30 sec). **Backlog différé** : PV2-5d sous-noeuds projets emblématiques (perf-critique 480 noeuds), définitions hashtags (session écriture éditoriale Jules), décision repo imbriqués (intersection stratégique demain). **Test live chatbot v2 bloqué** par lock Dropbox sur .nuxt/dev → prompt cloture demain `PV2-5h-test-chatbot-v2-local.md`.
|
||||||
|
|
||||||
|
- [W19 — 2026-05-06 02:30] **MP-TAFF V2 cadré + prompt scraping autonome séparé + rename atis-humain** : Brainstorm divergent Jules pour Carte 3 AEP "Trouver du taf en archi". 5 axes scoring éthique AEP validés (Rémunération / Transparence / Pratiques pro / Écologie / Matching) avec définitions + critères + échelles ✅⚠️❌ — c'est le différenciant central vs annuaires neutres. Décisions verrouillées : freelance only V1 (70% archis indé = cible la plus en galère), IA applique scoring (critères validés une fois = pas de validation fiche par fiche), onglet `aep.trans-former.fr` (pas sous-domaine), branche parallèle `feat/aep-taff-v1` (pas attendre merge V2), SEO reporté V2 (skill `/seo-page-aep` à créer). MP-TAFF V2 ~430 lignes avec 2 tours auto-feedback (table décision tag global, format desc IA 5 sections, §risque juridique nominatif, calibrage chatbot 3 questions, préflight conflit branche V2 + i18n). **MP-TAFF-T1-scraping-autonome.md créé** (~270 lignes) — sortable sur PC séparé pour parallélisation pendant qu'ATIS Dev bosse T0/T2/T3+. Pattern routing scraping documenté : Jina d'abord → crawl4ai SPA → BrowserMCP RGPD/auth → manuel flag. Forums commu intégrés : Team.Archi, Reddit r/Architecture FR, presse pro (Le Moniteur, AMC, D'A). Output JSON structuré consommable par T2. 🔒1 simplifié = récap scope synthétique (10 min Jules). Backlog cascade : MP-MENTOR (carte 4 entraide), MP-CROSS (n8n + GitHub OS) restent prêts. **Rename `tara` → `atis-humain`** : skill renommée, routing patché dans `atis-archi.md` ligne 390 et `ATIS-agents-specialises.md` ligne 29. Anciennes refs à Tara la personne (Mediathèque, done.md, ton-jules.md) inchangées.
|
||||||
|
|
||||||
|
- [W19 — 2026-05-05] **AEP V2 PV2-4+5+8 DONE + vue graphique D3** : PV2-4 (887 edges, 480 projets, reseaux-bifurcation.json 847KB). PV2-5 UI (NavMapV2, HashtagFilter, IntentionBanner, FicheModalV2, palette 5 familles, geocodage 118/120, GraphView.vue D3 force-directed). PV2-8 RAG (chatbot-v2.post.ts + vectorize-v2.js). Fixes UI : onglets outremer desktop, sidebar scroll, chips colorees, hashtags repliables, F6 filtre, intention overlay localStorage. EDITO-V2.md cree. 13 commits feat/aep-v2-cartobifurcation. 🔒 PV2-5 checkpoint visuel Jules en attente.
|
||||||
|
|
||||||
|
### 2026-W18 (décharge 2026-05-13)
|
||||||
|
|
||||||
|
- [W18 — 2026-05-03] **AEP V2 PV2-2ter DONE** : 10 emails récupérés. Volet A F2 (amaco/LTE), F4 (toitsdechoix/HPO/HabiterAuvergne/EmmanuelleDucos). Confirmed not public : F3 (LALCA/Sentiers/AOA/METALAB), F4 (unitoit/atelier15/a-tipic/HPF/atcoop). Blocages : rfcp.fr GravityView JS, a-tipic HTTP 400. Volet B F6 : 7 flags + 4 emails (Forensic/Centrala/NBL/Assemble) + 1 new fiche Collectif Etc (contact@collectifetc.com). Seed 122 fiches. Commit `7ce8e12`.
|
||||||
|
|
||||||
|
- [W18 — 2026-05-03] **AEP V2 PV2-2 F1 DONE** : 26 fiches réemploi & filières (14 V1 + 12 nouvelles). Nouvelles : Cycle Up (contact@cycle-up.fr), Backacia (form), Mobius (contact@mobius-corp.com), AD VITAM MATERIAL (reemploi@embuild.be), Cirkla (c/o insitu), CANCAN (contact@collectifcancan.fr), HArquitectes (harquitectes@harquitectes.com), isla (press@isla-architects.com), jdviv BE (EUmies 2026 co-winner), SalvoWEB, B+L Architectes, REFAIR/BDR. 6/12 emails high. Hashtag nouveau proposé : #amo-reemploi (AMO/diagnostic PEMD spécialisés). BrowserMCP off toute la session → Jina only (Reuse Foundation non scrapée, jdviv URL à confirmer). Commit `656cc2d`. **PV2-2 5/5 familles DONE.** → Reste PV2-3+PV2-4.
|
||||||
|
|
||||||
|
- [W18 — 2026-05-03] **AEP V2 PV2-2 F2 DONE** : 36 fiches frugalité & low-tech (22 V1 + 14 nouvelles). Nouvelles : Lacaton&Vassal (Pritzker 2021), Kéré Architecture (mail@kerearchitecture.com), Anna Heringer, CRATerre (secretariat@craterre.org), Les Grands Ateliers, AsTerre (secretariat@asterre.org), RFCP, EnvirobatBDM, NUNC, LAPS, Dorodango, BEES, amàco (contact@amaco.org), Lehm Ton Erde. 4/14 emails high conf. Sources productrices : AsTerre annuaire (19 agences archi identifiées), Pritzker (2 nouvelles), frugalite.org réseau. Blocages : RFCP annuaire JS, lehmtonerde.at 422, OFF laureats non scraped, BrowserMCP instable (3 décos). Commits `8808a35`+`301c3be`. → Reste F1 Réemploi.
|
||||||
|
|
||||||
|
- [W18 — 2026-05-03] **AEP V2 PV2-2 F3 DONE** : 22 fiches architecture sociale & précarités (11 V1 + 11 nouvelles). Nouvelles : PEROU, Plateau Urbain (SCIC), Bellastock, ASF France, Rural Studio, Forensic Architecture, Collectif Parenthèse, WoMa, Fab City Grand Paris, CivicWise. 6/11 emails high conf (PEROU·Bellastock·Rural Studio·Parenthèse·WoMa·CivicWise). Sources : Quatorze /partenaires-new (meilleur hub), YWC lieux (Grands Voisins + Coco Velten), A&P filtres. 4 multi-famille (Bellastock F3+F5, Parenthèse F3+F4, WoMa F3+F4, YWC F3+F4+F5). Commit `d2028f5`. → Reste F1 + F2.
|
||||||
|
|
||||||
|
- [W18 — 2026-05-02 23:23] **AEP V2 PV2-2 F4 DONE** : 20 nouvelles fiches collectifs/écolieux/AMO via Jina (BrowserMCP déco → pivot Jina). 11/20 emails high conf. Structures-clés : RAHP, HPF, Habicoop, Hab-Fab SCIC, Regain SCIC, Coopérative Oasis, Mietshäuser Syndikat. 9 contacts partiels (tel/form) à compléter BrowserMCP. Commit `f54afe3`.
|
||||||
|
|
||||||
|
- [W18 — 2026-05-02 19:51] **AEP V2 PV2-2 F5 DONE** : 15 fiches urbanisme transition via BrowserMCP. 7 emails high (CLER/TEPOS/Coloco/Bas Smets/EP/FNAU/Atelier Georges). Commit `56c93eb`.
|
||||||
|
|
||||||
|
- [W18 — 2026-05-02 18:17] **C3 smoke test + PV2-1 scrape DONE** : C3 = 2 bugs (P0 algo-config.json 404, P1 redirect 301 manquant) + rapport `C3-RAPPORT.md`. PV2-1 = 5/5 emails trouvés via BrowserMCP (Opalis/Frugalité/Quatorze/Tepop/Transition France), commit 6df5b84. Stack BrowserMCP validé pour batch PV2-2. Patcher P0+P1 avant merge master simulateur.
|
||||||
|
|
||||||
|
- [W18 — 2026-04-30 12:00] **AEP Cascade V2 Phase A2+A3 figées + PILOTE-V2 doc pilote vivant** : Session "AEP COMMU V3 suite" /atis-archi puis /atis-dev. SPEC-V2-FEEDBACK-DEV.md livré (faisabilité pipeline 3 passes, email cascade 5 niveaux estim 65-80%, reclaim JWT HS256 30j single-use, grain JSON suffisant si desc_longue 600+ + 3 sources, branches 2 dédiées, pre-flights standardisés, fix BOM). PILOTE-V2.md créé comme **source de vérité vivante** à la racine `aep-communaute-build/` (Jules pilote depuis ce fichier ; tout Opus suivant le lit en premier). 13 prompts PV2-X dans `0 INBOX/PROMPTS/aep-v2-cartobifurcation/` (README + PV2-0 preflight + PV2-1 scrape test 5 fiches + PV2-2 5 agents recherche par famille en parallèle (idée Jules : recoupement multi-famille = signal politique transversalité) + PV2-2bis recoupement + PV2-3 passe2 analyse + PV2-4 passe3 croisements + PV2-5 refonte UI + PV2-6 reclaim + PV2-7 badges statut + PV2-8 RAG coexistence v1+v2 + PV2-9 bandeau regards d'ailleurs + PV2-10 E2E build + PV2-11 batch emails + tri DOM-TOM). NEXT-SESSION-PROMPT-V3.md créé pour relais. **Amendements Jules sur SPEC-V2** : famille 1 "Réemploi & matière" → "Réemploi & filières", AMO ajouté famille 4 (Tepop/Hab-Fab/Habicoop), famille 5 Urbanisme transition gardée fermement (cibler scrape agressif), centres ressources DOM-TOM → carte ressources existante (sauf Caribois praticien direct), pas de cap fiches sur agents recherche, stratégie snowball depuis nodes établis (Frugalité, Opalis, A&P) + crawl collaborateurs/influences/prix/distinctions. Sessions batch nocturnes dimensionnées Claude Max 5h Opus. PV2-0 partiel exécuté : branche `feat/aep-v2-cartobifurcation` créée depuis origin/main (tracking unset = anti-push main accidentel) + BOM UTF-8 retiré de `nav-carte/deploy.sh`. PV2-0 effectif (checkout + structure `nav-carte/V2-cascade/` + hook pre-commit no-BOM + sources-par-famille.md) à faire prochaine session après commit/stash des 830 fichiers pending sur `feat/aep-website-v1.1`.
|
||||||
|
|
||||||
|
- [W18 — 2026-04-30] **Cascade MEGABOUM opérationnelle — 4 MP rédigés + cockpit lisible** : `0 INBOX/PROMPTS/cascade-megaboum/` créé avec `00-COCKPIT-CASCADE.md` (index lisible en 3 min par tout Opus dispatché, format différent de la mégaspec — celle-ci reste lecture profonde optionnelle). 4 méga-prompts prêts à dispatcher : **MP-TAFF** (app trouver du taf B2C/B2B/appels publics, ~5h, étend `aep-communaute-build/`), **MP-MENTOR** (app mentorat-entraide M7-M, ~5h, post-TAFF), **MP-CROSS** (pipeline cross-posting n8n LinkedIn+Substack+Listmonk+@aep.politique + GitHub OS publish skills/lightrag/vps-kit, ~5h, parallèle), **MP-DESIGN** (création agent atis-design, prompt court ~1h via /create-agent + scrape Prisme.one). Brief archive `MP10-manifeste-aep-INFO-BRIEF.md` (chantier déjà lancé par Jules). Chaque MP démarre par CHECKPOINT 0 réflexion faisabilité (l'Opus lit, propose, attend OK Jules avant dispatch). Backlog explicite : page-cerveau Astro from scratch, méga-RAG FRACAS vague 1, atis-philosophe, frontend-slides, rename atis-humain (P1 30 min), Insta @julesneny n8n (Q3). LightRAG VPS DOWN **déclassé** : pas bloqueur P0 semaine si on ne fait pas méga-RAG, devient bloqueur quand on attaquera RAG. Cadence : Jules pilote au fil des jours, 1-2 MP/jour, 2 clusters max simultanés.
|
||||||
|
|
||||||
|
- [W18 — 2026-04-30 02:46] **Méga-cascade V2 AEP Phase A1 figée + 3 agents background livrés** : Session Opus orchestrateur "AEP commu V2". 9 intersections tranchées par Jules en un message (5 familles fusion F4+F5, UX vignette + template carte 1, scope FR+Europe francophone + capture incidente régénératifs hors scope, articulation pensées<->structures reportée V2, passe profonde GO, email champ soft, charte reportée, filtre échelle drop, A3 absorbé A1). SPEC-V2.md figée. 3 agents Sonnet dispatchés en parallèle background : VOIE 2 V1.1 nav-carte (4 commits `feat/aep-v1.1-nav-carte` basée sur feat/aep-pratiques-regeneratives car main pré-V1 ; PA1 DOM-TOM pattern desktop 2 onglets, PA3 bouton Proposer contextuel, PA5 chatbot pratiques régé Mistral, 6/6 bugs E2E M1-M3+L1-L3 corrigés, build Nuxt OK), VOIE 3 website (1 commit `feat/aep-website-v1.1` e95f693 ; PB1 hamburger 4 entrées + stubs /manifeste /ressources /signaler ; **/!\ livré sur renovation-energetique.trans-former.fr - website pro, pas aep.trans-former.fr - à clarifier prochaine session**), PASSE PROFONDE (52 pratiques régé + 99 ressources institutionnelles analysés ; 5 familles confirmées avec garde-fous F5 ; 46 hashtags ; 7 cas limites + 4 hors-grille type "mouvement-manifeste" potentiel ; **226 acteurs candidats enrichissement carte ressources : P1=56 fiches urgentes dont 30 CAUE manquants top dpts + 4 CAUE DOM-TOM + 9 MA régionales + 2 CROA DOM ; constat critique : 0 DOM-TOM + 6/92 CAUE dans carte ressources actuelle**). 5 questions stratégiques remontées pour Phase A2 (Q-PP1 5 vs 6 familles, Q-PP2 type mouvement-manifeste, Q-PP3 F5 différée passe 2, Q-PP4 double-référencement KEBATI/AQUAA/RBD/Envirobat, Q-V3 site cible hamburger). NEXT-SESSION-PROMPT.md pré-écrit pour reprise propre Phase A2 /atis-dev. Pattern méga-dispatch consécutif #4. Tokens : 130k orchestrateur + ~451k délégués sous-agents.
|
||||||
|
|
||||||
|
- [W18 — 2026-04-30 01:42] **Méga-cascade V2 AEP cadrée** : `MEGA-V2.md` master orchestration 3 voies créé. VOIE 1 = V2 conceptuelle (refonte carte écosystème AEP en carte des réseaux de bifurcation, 5-6 familles éditoriales, reclaim email magique, pipeline IA cascade 3 passes, ~75-100 fiches). VOIE 2 = V1.1 nav-carte (items 1+2+4+5+8 ; item 3 absorbé par VOIE 1). VOIE 3 = website astro-site (hamburger + manifeste + ressources). Décisions Jules : `/atis-archi` pilote la spec V2 conceptuelle Phase A1, `/atis-dev` en relai Phase A2. Apports critiques : champ email obligatoire dans le scrape (sans email = pas de reclaim), passe profonde sur fiches existantes (~52+80) pour faire remonter hashtags sous-familles, item 4 (filtre échelle) à questionner, A3 absorbable dans A1, page Manifeste à ajouter au hamburger website. Phrase d'amorce + effort `high` recommandé pour la session Opus dédiée demain. PHRASE-LANCEMENT-OPUS-V2.md marqué SUPERSEDED. 2 briefs INBOX (V2-BRIEF-AGENT-OPUS + V2-RECAP-PROJET) déplacés dans aep-communaute-build/.
|
||||||
|
|
||||||
|
- [W18 — 2026-04-29 11:48] **AEP scrape P1-P7 FAIT** : BrowserMCP (P1 architecture-precarites.fr : 200+ projets, 5 catégories) + Jina (P4 vegetal-e ✅, P6 colorado-architecture ✅, P7 karibati ✅, P3 caribois ✅). P2 archidev bot-protégé + P5 envirobat 422 → consultation manuelle. `scrape-browsermcp.json` créé (7 entrées). Email auteurs architecture-precarites.fr envoyé par Jules. INCLURE : vegetal-e (5/8), envirobat-RE (7/8), karibati (5/8). EXCLURE : caribois (2/8), colorado (3/8).
|
||||||
|
|
||||||
|
- [W18 — 2026-04-29] **AEP V1 E2E PASS** : 5/5 scénarios OK (3 mobile, 3 laptop). Branche `feat/aep-pratiques-regeneratives` prête à merger main. `E2E-RESULTS.md` créé. Bugs mineurs capturés : M1 chips a11y + M2 reset searchbox + M3 floating button + L1 redirection.
|
||||||
|
|
||||||
|
- [W18 — 2026-04-29 08:11] **AEP V1 LIVRÉE** : 52 fiches prod (`aep.trans-former.fr/pratiques-regeneratives`), 12 commits feat/. V1.1 mode divergent cadrée (8 items brain-dump Jules).
|
||||||
|
|||||||
134
app.vue
134
app.vue
@@ -51,12 +51,11 @@
|
|||||||
Codev
|
Codev
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/rag"
|
to="/media"
|
||||||
class="nav-tab"
|
class="nav-tab"
|
||||||
:class="{ 'nav-tab--active': route.path === '/rag' }"
|
:class="{ 'nav-tab--active': route.path === '/media' }"
|
||||||
>
|
>
|
||||||
RAG
|
recherche-média
|
||||||
<span class="nav-tab-badge">en construction</span>
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -108,14 +107,52 @@
|
|||||||
>
|
>
|
||||||
Signaler
|
Signaler
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<!-- Proposer une ressource -->
|
<!-- Proposer — popover 3 choix -->
|
||||||
<NuxtLink
|
<div class="hidden sm:block relative" ref="proposerAnchor" data-proposer-popover>
|
||||||
to="/contribuer"
|
<button
|
||||||
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
|
@click="proposerOpen = !proposerOpen"
|
||||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 inline-flex items-center gap-1"
|
||||||
>
|
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||||
+ Proposer
|
aria-label="Proposer une contribution"
|
||||||
</NuxtLink>
|
>
|
||||||
|
+ Proposer
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="proposerOpen"
|
||||||
|
class="absolute right-0 top-full mt-1 rounded-lg shadow-lg min-w-[240px] py-1"
|
||||||
|
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
||||||
|
>
|
||||||
|
<NuxtLink
|
||||||
|
to="/contribuer"
|
||||||
|
class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70"
|
||||||
|
style="color: var(--nav-text);"
|
||||||
|
@click="proposerOpen = false"
|
||||||
|
>
|
||||||
|
<span>Fiche Entraide <span style="color: var(--nav-text-muted); font-weight: 400; font-size: 0.7rem; display: block;">Carte 1 — Écosystème archi</span></span>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</NuxtLink>
|
||||||
|
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
|
||||||
|
<NuxtLink
|
||||||
|
to="/contribuer-reseau"
|
||||||
|
class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70"
|
||||||
|
style="color: var(--nav-text);"
|
||||||
|
@click="proposerOpen = false"
|
||||||
|
>
|
||||||
|
<span>Réseau / collectif <span style="color: var(--nav-text-muted); font-weight: 400; font-size: 0.7rem; display: block;">Carte 2 — Réseaux AEP</span></span>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</NuxtLink>
|
||||||
|
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
|
||||||
|
<NuxtLink
|
||||||
|
to="/contribuer-job"
|
||||||
|
class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70"
|
||||||
|
style="color: var(--nav-text);"
|
||||||
|
@click="proposerOpen = false"
|
||||||
|
>
|
||||||
|
<span>Plateforme jobs <span style="color: var(--nav-text-muted); font-weight: 400; font-size: 0.7rem; display: block;">Carte 3 — Jobs archi</span></span>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Toggle dark mode -->
|
<!-- Toggle dark mode -->
|
||||||
<button
|
<button
|
||||||
@@ -137,18 +174,40 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Mobile : contribuer icône -->
|
<!-- Mobile : contribuer icône → popover -->
|
||||||
<NuxtLink
|
<div class="sm:hidden relative" data-proposer-popover>
|
||||||
to="/contribuer"
|
<button
|
||||||
class="sm:hidden p-2 rounded-lg"
|
@click="proposerOpen = !proposerOpen"
|
||||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
class="p-2 rounded-lg"
|
||||||
title="Contribuer une fiche"
|
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||||
aria-label="Contribuer"
|
title="Contribuer"
|
||||||
>
|
aria-label="Contribuer"
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
>
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
</svg>
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
</NuxtLink>
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="proposerOpen"
|
||||||
|
class="absolute right-0 top-full mt-1 rounded-lg shadow-lg min-w-[220px] py-1"
|
||||||
|
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
||||||
|
>
|
||||||
|
<NuxtLink to="/contribuer" class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" style="color: var(--nav-text);" @click="proposerOpen = false">
|
||||||
|
<span>Fiche Entraide</span>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</NuxtLink>
|
||||||
|
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
|
||||||
|
<NuxtLink to="/contribuer-reseau" class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" style="color: var(--nav-text);" @click="proposerOpen = false">
|
||||||
|
<span>Réseau / collectif</span>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</NuxtLink>
|
||||||
|
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
|
||||||
|
<NuxtLink to="/contribuer-job" class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" style="color: var(--nav-text);" @click="proposerOpen = false">
|
||||||
|
<span>Plateforme jobs</span>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Hamburger mobile (lg:hidden) — toujours en dernier à droite -->
|
<!-- Hamburger mobile (lg:hidden) — toujours en dernier à droite -->
|
||||||
<div class="lg:hidden relative">
|
<div class="lg:hidden relative">
|
||||||
@@ -175,7 +234,7 @@
|
|||||||
<NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink>
|
<NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink>
|
||||||
<NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/agences' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Réseaux AEP</NuxtLink>
|
<NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/agences' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Réseaux AEP</NuxtLink>
|
||||||
<NuxtLink to="/trouver-du-taf" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/trouver-du-taf' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Jobs</NuxtLink>
|
<NuxtLink to="/trouver-du-taf" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/trouver-du-taf' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Jobs</NuxtLink>
|
||||||
<NuxtLink to="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink>
|
<NuxtLink to="/media" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/media' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">recherche-média</NuxtLink>
|
||||||
<NuxtLink to="/codev" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
|
<NuxtLink to="/codev" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
|
||||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
||||||
<NuxtLink to="/manifeste" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'">Manifeste</NuxtLink>
|
<NuxtLink to="/manifeste" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'">Manifeste</NuxtLink>
|
||||||
@@ -205,6 +264,31 @@ const route = useRoute()
|
|||||||
const hamburgerOpen = ref(false)
|
const hamburgerOpen = ref(false)
|
||||||
watch(() => route.path, () => { hamburgerOpen.value = false })
|
watch(() => route.path, () => { hamburgerOpen.value = false })
|
||||||
|
|
||||||
|
// ── Popover "+ Proposer" ─────────────────────────────────────────────────
|
||||||
|
const proposerOpen = ref(false)
|
||||||
|
const proposerAnchor = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function onClickOutsideProposer(e: MouseEvent) {
|
||||||
|
// Ferme si le clic est hors de tout élément portant data-proposer-popover
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (!target.closest('[data-proposer-popover]')) {
|
||||||
|
proposerOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(proposerOpen, (open) => {
|
||||||
|
if (open) {
|
||||||
|
// Délai court pour ne pas attraper le clic d'ouverture lui-même
|
||||||
|
setTimeout(() => document.addEventListener('click', onClickOutsideProposer, true), 10)
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('click', onClickOutsideProposer, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', onClickOutsideProposer, true)
|
||||||
|
})
|
||||||
|
|
||||||
// ── Dark mode ─────────────────────────────────────────────────────────────
|
// ── Dark mode ─────────────────────────────────────────────────────────────
|
||||||
const isDark = ref(false)
|
const isDark = ref(false)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div style="width: 100%; height: 100%; position: relative; background: var(--nav-bg);">
|
<div style="width: 100%; height: 100%; position: relative; background: #f5f3f0;">
|
||||||
<svg ref="svgRef" style="width: 100%; height: 100%;"></svg>
|
<svg ref="svgRef" style="width: 100%; height: 100%;"></svg>
|
||||||
<div ref="tooltipRef" style="
|
<div ref="tooltipRef" style="
|
||||||
position: absolute; pointer-events: none;
|
position: absolute; pointer-events: none;
|
||||||
@@ -14,21 +14,45 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
interface EcoleData { id: string; label: string; description: string; color: string; x_hint: number; y_hint: number }
|
interface EcoleData { id: string; label: string; description: string; color: string; x_hint: number; y_hint: number }
|
||||||
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
|
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
|
||||||
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
|
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; ingere: boolean; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string; bio_courte_provisoire?: string }
|
||||||
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
|
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
|
||||||
|
|
||||||
|
// Liens d'influence inter-ecoles (Phase 7 - matrice de filiation)
|
||||||
|
const LINKS_INFLUENCE = [
|
||||||
|
// filiations directes
|
||||||
|
{ source: 'eco-anarchisme', target: 'technocritique', auteurs_passerelle: ['Bookchin', 'Illich'], type: 'filiation' },
|
||||||
|
{ source: 'eco-anarchisme', target: 'decroissance', auteurs_passerelle: ['Latouche', 'Kropotkine'], type: 'filiation' },
|
||||||
|
{ source: 'ecosocialisme', target: 'decroissance', auteurs_passerelle: ['Saito', 'Gorz'], type: 'filiation' },
|
||||||
|
{ source: 'ecosocialisme', target: 'ecologies-decoloniales', auteurs_passerelle: ['Klein', 'Ferdinand'], type: 'filiation' },
|
||||||
|
{ source: 'ecofeminismes', target: 'ecologies-decoloniales', auteurs_passerelle: ['Shiva', 'Ouassak'], type: 'filiation' },
|
||||||
|
{ source: 'ecofeminismes', target: 'pensees-vivant', auteurs_passerelle: ['Haraway', 'Despret'], type: 'filiation' },
|
||||||
|
{ source: 'technocritique', target: 'decroissance', auteurs_passerelle: ['Ellul', 'Latouche'], type: 'filiation' },
|
||||||
|
{ source: 'decroissance', target: 'pensees-vivant', auteurs_passerelle: ['Servigne', 'Despret'], type: 'filiation' },
|
||||||
|
{ source: 'pensees-vivant', target: 'ethiques-environnementales', auteurs_passerelle: ['Naess', 'Latour'], type: 'filiation' },
|
||||||
|
{ source: 'ecosocialisme', target: 'eco-anarchisme', auteurs_passerelle: ['Gorz', 'Graeber'], type: 'filiation' },
|
||||||
|
// liens de critique (toutes les ecoles progressistes vs cap-vert / ecofascismes)
|
||||||
|
{ source: 'ecosocialisme', target: 'capitalisme-vert', auteurs_passerelle: ['Klein', 'Malm'], type: 'critique' },
|
||||||
|
{ source: 'decroissance', target: 'capitalisme-vert', auteurs_passerelle: ['Latouche', 'Meadows'], type: 'critique' },
|
||||||
|
{ source: 'eco-anarchisme', target: 'capitalisme-vert', auteurs_passerelle: ['Bookchin'], type: 'critique' },
|
||||||
|
{ source: 'ethiques-environnementales', target: 'ecofascismes', auteurs_passerelle: ['Naess'], type: 'critique' },
|
||||||
|
{ source: 'capitalisme-vert', target: 'ecofascismes', auteurs_passerelle: [], type: 'critique' },
|
||||||
|
]
|
||||||
|
|
||||||
const props = defineProps<{ data: PenseesData | null; active?: boolean }>()
|
const props = defineProps<{ data: PenseesData | null; active?: boolean }>()
|
||||||
const emit = defineEmits<{ 'select-auteur': [id: string] }>()
|
const emit = defineEmits<{ 'select-auteur': [id: string]; 'select-ecole': [id: string] }>()
|
||||||
|
|
||||||
const svgRef = ref<SVGElement | null>(null)
|
const svgRef = ref<SVGElement | null>(null)
|
||||||
const tooltipRef = ref<HTMLElement | null>(null)
|
const tooltipRef = ref<HTMLElement | null>(null)
|
||||||
let simulation: any = null
|
let simulation: any = null
|
||||||
let d3NodeSel: any = null
|
|
||||||
let d3LinkSel: any = null
|
let d3LinkSel: any = null
|
||||||
|
let d3InfluenceSel: any = null
|
||||||
|
let d3NodeSel: any = null
|
||||||
|
let d3EdgeLabelSel: any = null
|
||||||
|
|
||||||
async function initGraph() {
|
async function initGraph() {
|
||||||
if (!svgRef.value || !props.data) return
|
if (!svgRef.value || !props.data) return
|
||||||
const d3 = await import('d3')
|
const d3 = await import('d3')
|
||||||
|
|
||||||
const svgEl = svgRef.value
|
const svgEl = svgRef.value
|
||||||
const W = svgEl.clientWidth || 900
|
const W = svgEl.clientWidth || 900
|
||||||
const H = svgEl.clientHeight || 600
|
const H = svgEl.clientHeight || 600
|
||||||
@@ -41,72 +65,194 @@ async function initGraph() {
|
|||||||
|
|
||||||
const ecoleMap = new Map<string, EcoleData>(props.data.ecoles.map(e => [e.id, e]))
|
const ecoleMap = new Map<string, EcoleData>(props.data.ecoles.map(e => [e.id, e]))
|
||||||
|
|
||||||
const ecoleNodes: any[] = props.data.ecoles.map(e => ({
|
// Positions fixes des ecoles (base pour forces D3)
|
||||||
id: `ecole-${e.id}`, type: 'ecole', ecoleId: e.id, label: e.label, color: e.color, r: 38,
|
const ecolePositions = new Map<string, { tx: number; ty: number }>()
|
||||||
x: W * e.x_hint, y: H * e.y_hint, fx: W * e.x_hint, fy: H * e.y_hint,
|
props.data.ecoles.forEach(e => {
|
||||||
}))
|
ecolePositions.set(e.id, { tx: W * e.x_hint, ty: H * e.y_hint })
|
||||||
|
|
||||||
const auteurNodes: any[] = props.data.auteurs.map(a => ({
|
|
||||||
id: a.id, type: 'auteur', nom: a.nom, dates: a.dates, bio_courte: a.bio_courte,
|
|
||||||
ecole_principale: a.ecole_principale,
|
|
||||||
color: ecoleMap.get(a.ecole_principale)?.color ?? '#888', r: 11,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const allNodes = [...ecoleNodes, ...auteurNodes]
|
|
||||||
const links: any[] = []
|
|
||||||
props.data.auteurs.forEach(a => {
|
|
||||||
links.push({ source: a.id, target: `ecole-${a.ecole_principale}`, strength: 0.65 })
|
|
||||||
a.ecoles.filter(e => e !== a.ecole_principale).forEach(e => links.push({ source: a.id, target: `ecole-${e}`, strength: 0.25 }))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- LIENS D'INFLUENCE INTER-ECOLES (couche 3) ----
|
||||||
|
const gInfluence = g.append('g').attr('class', 'links-influence')
|
||||||
|
|
||||||
|
LINKS_INFLUENCE.forEach(link => {
|
||||||
|
const src = ecolePositions.get(link.source)
|
||||||
|
const tgt = ecolePositions.get(link.target)
|
||||||
|
if (!src || !tgt) return
|
||||||
|
|
||||||
|
const isCritique = link.type === 'critique'
|
||||||
|
const lineEl = gInfluence.append('line')
|
||||||
|
.attr('class', 'influence-link')
|
||||||
|
.attr('x1', src.tx).attr('y1', src.ty)
|
||||||
|
.attr('x2', tgt.tx).attr('y2', tgt.ty)
|
||||||
|
.attr('stroke', isCritique ? '#d99' : '#9aa')
|
||||||
|
.attr('stroke-width', 1)
|
||||||
|
.attr('stroke-dasharray', isCritique ? '4,3' : '6,4')
|
||||||
|
.attr('stroke-opacity', isCritique ? 0.2 : 0.22)
|
||||||
|
|
||||||
|
if (link.auteurs_passerelle && link.auteurs_passerelle.length > 0) {
|
||||||
|
lineEl
|
||||||
|
.on('mouseenter', (e: any) => {
|
||||||
|
if (!tooltipRef.value) return
|
||||||
|
tooltipRef.value.innerHTML = `<strong>Influence</strong><br><span style="opacity:0.8;font-size:0.72rem;">Passerelles : ${link.auteurs_passerelle.join(', ')}</span>`
|
||||||
|
tooltipRef.value.style.opacity = '1'
|
||||||
|
})
|
||||||
|
.on('mousemove', (e: any) => {
|
||||||
|
if (!tooltipRef.value || !svgEl) return
|
||||||
|
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
||||||
|
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
|
||||||
|
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
|
||||||
|
})
|
||||||
|
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- SIMULATION D3 (auteurs) ----
|
||||||
|
// Pre-positionner chaque auteur pres de son ecole + jitter aleatoire pour eviter le rush initial vers la droite
|
||||||
|
const auteurNodes: any[] = props.data.auteurs.map(a => {
|
||||||
|
const ecole = ecoleMap.get(a.ecole_principale)
|
||||||
|
const jitter = () => (Math.random() - 0.5) * 80
|
||||||
|
return {
|
||||||
|
id: a.id, type: 'auteur', nom: a.nom, dates: a.dates,
|
||||||
|
bio_courte: a.bio_courte,
|
||||||
|
bio_provisoire: a.bio_courte_provisoire ?? '',
|
||||||
|
ingere: a.ingere,
|
||||||
|
ecole_principale: a.ecole_principale,
|
||||||
|
color: ecole?.color ?? '#888', r: 11,
|
||||||
|
x: W * (ecole?.x_hint ?? 0.5) + jitter(),
|
||||||
|
y: H * (ecole?.y_hint ?? 0.5) + jitter(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Liens appartenance auteur -> ecole (vers centroid fixe)
|
||||||
|
const links: any[] = []
|
||||||
|
props.data.auteurs.forEach(a => {
|
||||||
|
links.push({ source: a.id, target: a.ecole_principale, strength: 0.65, isSubcourant: false })
|
||||||
|
a.ecoles.filter(e => e !== a.ecole_principale).forEach(e => {
|
||||||
|
links.push({ source: a.id, target: e, strength: 0.25, isSubcourant: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Nodes fictifs fixes pour les ecoles (cibles des liens appartenance)
|
||||||
|
const ecoleFixedNodes: any[] = props.data.ecoles.map(e => ({
|
||||||
|
id: e.id, type: 'ecole-fixed', ecoleId: e.id,
|
||||||
|
x: W * e.x_hint, y: H * e.y_hint,
|
||||||
|
fx: W * e.x_hint, fy: H * e.y_hint,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Rayon proportionnel au nombre d'auteurs de l'ecole
|
||||||
|
const ecoleAuteurCounts = new Map<string, number>()
|
||||||
|
props.data.ecoles.forEach(e => ecoleAuteurCounts.set(e.id, 0))
|
||||||
|
props.data.auteurs.forEach(a => ecoleAuteurCounts.set(a.ecole_principale, (ecoleAuteurCounts.get(a.ecole_principale) ?? 0) + 1))
|
||||||
|
const ecoleRadius = (count: number) => Math.max(16, Math.min(36, 13 + count * 1.5))
|
||||||
|
|
||||||
|
const allNodes = [...ecoleFixedNodes, ...auteurNodes]
|
||||||
|
|
||||||
if (simulation) simulation.stop()
|
if (simulation) simulation.stop()
|
||||||
|
// Phase 8.D : sim ajustee pour 171 auteurs (vs 28 v2.1, densite 6x)
|
||||||
simulation = d3.forceSimulation(allNodes)
|
simulation = d3.forceSimulation(allNodes)
|
||||||
.force('link', d3.forceLink(links).id((d: any) => d.id).distance(90).strength((d: any) => d.strength ?? 0.5))
|
.force('link', d3.forceLink(links).id((d: any) => d.id).distance(120).strength((d: any) => d.strength ?? 0.5))
|
||||||
.force('charge', d3.forceManyBody().strength(-80))
|
.force('charge', d3.forceManyBody().strength(-70))
|
||||||
.force('center', d3.forceCenter(W / 2, H / 2))
|
.force('center', d3.forceCenter(W / 2, H / 2).strength(0.02))
|
||||||
.force('collision', d3.forceCollide().radius((d: any) => d.r + 5))
|
.force('collision', d3.forceCollide().radius((d: any) => d.type === 'ecole-fixed' ? ecoleRadius(ecoleAuteurCounts.get(d.ecoleId) ?? 0) + 4 : 12))
|
||||||
|
.force('forceX', d3.forceX<any>((d: any) => {
|
||||||
|
if (d.type === 'auteur') {
|
||||||
|
const pos = ecolePositions.get(d.ecole_principale)
|
||||||
|
return pos ? pos.tx : W / 2
|
||||||
|
}
|
||||||
|
return W / 2
|
||||||
|
}).strength(0.15))
|
||||||
|
.force('forceY', d3.forceY<any>((d: any) => {
|
||||||
|
if (d.type === 'auteur') {
|
||||||
|
const pos = ecolePositions.get(d.ecole_principale)
|
||||||
|
return pos ? pos.ty : H / 2
|
||||||
|
}
|
||||||
|
return H / 2
|
||||||
|
}).strength(0.15))
|
||||||
|
|
||||||
d3LinkSel = g.append('g').selectAll('line').data(links).join('line')
|
// ---- NOEUDS ECOLES visibles (couche 3.5) ----
|
||||||
.attr('stroke', 'rgba(150,150,150,0.3)').attr('stroke-width', 1.2)
|
// Cercles proportionnels au count d'auteurs, fixes aux centroids Bonpote, cliquables
|
||||||
|
const gEcoles = g.append('g').attr('class', 'ecoles-nodes')
|
||||||
|
ecoleFixedNodes.forEach(eNode => {
|
||||||
|
const ecole = ecoleMap.get(eNode.ecoleId)
|
||||||
|
if (!ecole) return
|
||||||
|
const count = ecoleAuteurCounts.get(eNode.ecoleId) ?? 0
|
||||||
|
const r = ecoleRadius(count)
|
||||||
|
gEcoles.append('circle')
|
||||||
|
.attr('cx', eNode.fx).attr('cy', eNode.fy).attr('r', r)
|
||||||
|
.attr('fill', ecole.color).attr('fill-opacity', 0.82).attr('stroke', ecole.color).attr('stroke-width', 2)
|
||||||
|
.attr('class', 'ecole-node').style('cursor', 'pointer')
|
||||||
|
.on('mouseenter', (e: any) => {
|
||||||
|
if (!tooltipRef.value) return
|
||||||
|
tooltipRef.value.innerHTML = `<strong>${ecole.label}</strong> <span style="opacity:0.6;font-size:0.7rem;">${count} auteur${count > 1 ? 's' : ''}</span><br><span style="opacity:0.75;font-size:0.72rem;">${ecole.description}</span>`
|
||||||
|
tooltipRef.value.style.opacity = '1'
|
||||||
|
})
|
||||||
|
.on('mousemove', (e: any) => {
|
||||||
|
if (!tooltipRef.value || !svgEl) return
|
||||||
|
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
||||||
|
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
|
||||||
|
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
|
||||||
|
})
|
||||||
|
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
|
||||||
|
.on('click', (e: any) => { e.stopPropagation(); emit('select-ecole', eNode.ecoleId) })
|
||||||
|
})
|
||||||
|
|
||||||
d3NodeSel = g.append('g').selectAll('g').data(allNodes).join('g')
|
// ---- LIENS APPARTENANCE (couche 4) ----
|
||||||
.style('cursor', (d: any) => d.type === 'auteur' ? 'pointer' : 'default')
|
const gLinks = g.append('g').attr('class', 'links-appartenance')
|
||||||
|
d3LinkSel = gLinks.selectAll('line').data(links).join('line')
|
||||||
|
.attr('stroke', 'rgba(150,150,150,0.28)').attr('stroke-width', 1.2)
|
||||||
|
|
||||||
|
// ---- EDGE LABELS - sous-courants (couche 4b) ----
|
||||||
|
// Afficher label "decroissance" sur lien Servigne (sous-courant specifique - option C)
|
||||||
|
const subcourantLinks = links.filter((l: any) => l.isSubcourant)
|
||||||
|
d3EdgeLabelSel = gLinks.selectAll('text.pensees-edge-label')
|
||||||
|
.data(subcourantLinks)
|
||||||
|
.join('text')
|
||||||
|
.attr('class', 'pensees-edge-label')
|
||||||
|
|
||||||
|
// ---- NODES AUTEURS (couche 5) ----
|
||||||
|
const gAuteurs = g.append('g').attr('class', 'auteurs')
|
||||||
|
d3NodeSel = gAuteurs.selectAll('g').data(auteurNodes).join('g')
|
||||||
|
.style('cursor', (d: any) => d.ingere ? 'pointer' : 'default')
|
||||||
.call(d3.drag<any, any>()
|
.call(d3.drag<any, any>()
|
||||||
.on('start', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })
|
.on('start', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })
|
||||||
.on('drag', (e: any, d: any) => { d.fx = e.x; d.fy = e.y })
|
.on('drag', (e: any, d: any) => { d.fx = e.x; d.fy = e.y })
|
||||||
.on('end', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0); if (d.type !== 'ecole') { d.fx = null; d.fy = null } }))
|
.on('end', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null }))
|
||||||
.on('click', (e: any, d: any) => { e.stopPropagation(); if (d.type === 'auteur') emit('select-auteur', d.id) })
|
.on('click', (e: any, d: any) => {
|
||||||
|
if (!d.ingere) return
|
||||||
d3NodeSel.append('circle')
|
e.stopPropagation()
|
||||||
.attr('r', (d: any) => d.r)
|
emit('select-auteur', d.id)
|
||||||
.attr('fill', (d: any) => d.type === 'ecole' ? d.color : d.color + 'cc')
|
|
||||||
.attr('stroke', (d: any) => d.type === 'ecole' ? 'rgba(255,255,255,0.6)' : d.color)
|
|
||||||
.attr('stroke-width', (d: any) => d.type === 'ecole' ? 3 : 1.5)
|
|
||||||
|
|
||||||
d3NodeSel.filter((d: any) => d.type === 'ecole').append('text')
|
|
||||||
.attr('text-anchor', 'middle').attr('dy', '0.35em').attr('font-size', '10px').attr('font-weight', '700').attr('fill', 'white')
|
|
||||||
.style('pointer-events', 'none')
|
|
||||||
.each(function(d: any) {
|
|
||||||
const el = d3.select(this as any)
|
|
||||||
const words: string[] = d.label.split(' ')
|
|
||||||
if (words.length <= 2) { el.text(d.label) } else {
|
|
||||||
const mid = Math.ceil(words.length / 2)
|
|
||||||
el.append('tspan').attr('x', 0).attr('dy', '-0.6em').text(words.slice(0, mid).join(' '))
|
|
||||||
el.append('tspan').attr('x', 0).attr('dy', '1.2em').text(words.slice(mid).join(' '))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
d3NodeSel.filter((d: any) => d.type === 'auteur').append('text')
|
// Phase 8.D : grisage conditionnel auteurs non-ingeres (ingere:false)
|
||||||
|
d3NodeSel.append('circle')
|
||||||
|
.attr('r', (d: any) => d.r)
|
||||||
|
.attr('fill', (d: any) => d.ingere ? (d.color + 'cc') : '#bbbbbb')
|
||||||
|
.attr('stroke', (d: any) => d.ingere ? d.color : '#999999')
|
||||||
|
.attr('stroke-width', 1.5)
|
||||||
|
.attr('opacity', (d: any) => d.ingere ? 1 : 0.35)
|
||||||
|
|
||||||
|
// ---- LABELS AUTEURS (couche 6 - fix 7.1 : drop-shadow blanc) ----
|
||||||
|
d3NodeSel.append('text')
|
||||||
.attr('class', 'pensees-auteur-label')
|
.attr('class', 'pensees-auteur-label')
|
||||||
.text((d: any) => d.nom.split(' ').pop() ?? d.nom)
|
.text((d: any) => d.nom.split(' ').pop() ?? d.nom)
|
||||||
.attr('text-anchor', 'middle').attr('dy', (d: any) => -(d.r + 4)).attr('font-size', '9px').attr('font-weight', '500')
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dy', (d: any) => -(d.r + 4))
|
||||||
.style('pointer-events', 'none')
|
.style('pointer-events', 'none')
|
||||||
|
.style('opacity', (d: any) => d.ingere ? 1 : 0.3)
|
||||||
|
.style('fill', (d: any) => d.ingere ? '#1a1a1a' : '#777777')
|
||||||
|
|
||||||
d3NodeSel.filter((d: any) => d.type === 'auteur')
|
d3NodeSel
|
||||||
.on('mouseenter', (e: any, d: any) => {
|
.on('mouseenter', (e: any, d: any) => {
|
||||||
if (!tooltipRef.value) return
|
if (!tooltipRef.value) return
|
||||||
const bio = d.bio_courte.length > 90 ? d.bio_courte.slice(0, 87) + '...' : d.bio_courte
|
let tooltipHtml = ''
|
||||||
tooltipRef.value.innerHTML = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.75;font-size:0.72rem;">${bio}</span>`
|
if (d.ingere) {
|
||||||
|
const rawBio = d.bio_courte || ''
|
||||||
|
const bio = rawBio.length > 90 ? rawBio.slice(0, 87) + '...' : rawBio
|
||||||
|
tooltipHtml = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.75;font-size:0.72rem;">${bio || 'Dans le RAG ATIS.'}</span>`
|
||||||
|
} else {
|
||||||
|
tooltipHtml = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.65;font-size:0.72rem;font-style:italic;">Présent dans Bonpote, pas encore ingéré dans le RAG ATIS.</span>`
|
||||||
|
}
|
||||||
|
tooltipRef.value.innerHTML = tooltipHtml
|
||||||
tooltipRef.value.style.opacity = '1'
|
tooltipRef.value.style.opacity = '1'
|
||||||
})
|
})
|
||||||
.on('mousemove', (e: any) => {
|
.on('mousemove', (e: any) => {
|
||||||
@@ -118,8 +264,19 @@ async function initGraph() {
|
|||||||
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
|
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
|
||||||
|
|
||||||
simulation.on('tick', () => {
|
simulation.on('tick', () => {
|
||||||
d3LinkSel.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y)
|
d3LinkSel
|
||||||
|
.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y)
|
||||||
.attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y)
|
.attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y)
|
||||||
|
|
||||||
|
// Edge labels positions (milieu du lien)
|
||||||
|
d3EdgeLabelSel
|
||||||
|
.attr('x', (d: any) => (d.source.x + d.target.x) / 2)
|
||||||
|
.attr('y', (d: any) => (d.source.y + d.target.y) / 2)
|
||||||
|
.text((d: any) => {
|
||||||
|
const targetId = typeof d.target === 'object' ? d.target.id : d.target
|
||||||
|
return targetId
|
||||||
|
})
|
||||||
|
|
||||||
d3NodeSel.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
|
d3NodeSel.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -128,8 +285,47 @@ watch(() => props.active, (val) => { if (val && import.meta.client && props.data
|
|||||||
watch(() => props.data, (val) => { if (val && props.active && import.meta.client) requestAnimationFrame(() => requestAnimationFrame(() => initGraph())) })
|
watch(() => props.data, (val) => { if (val && props.active && import.meta.client) requestAnimationFrame(() => requestAnimationFrame(() => initGraph())) })
|
||||||
onMounted(async () => { if (import.meta.client && props.data && props.active) { await nextTick(); initGraph() } })
|
onMounted(async () => { if (import.meta.client && props.data && props.active) { await nextTick(); initGraph() } })
|
||||||
onUnmounted(() => { if (simulation) simulation.stop() })
|
onUnmounted(() => { if (simulation) simulation.stop() })
|
||||||
|
|
||||||
|
function triggerResize() {
|
||||||
|
if (simulation) {
|
||||||
|
simulation.alpha(0.3).restart()
|
||||||
|
} else if (import.meta.client && props.data && props.active) {
|
||||||
|
initGraph()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defineExpose({ triggerResize })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.pensees-auteur-label { fill: var(--nav-text); opacity: 0.75; paint-order: stroke; stroke: var(--nav-bg); stroke-width: 3px; stroke-linejoin: round; user-select: none; }
|
/* ---- Labels auteurs : fix 7.1 drop-shadow blanc pour lisibilite sur pastel ---- */
|
||||||
|
.pensees-auteur-label {
|
||||||
|
fill: #1a1a1a;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 10px;
|
||||||
|
filter: drop-shadow(0 0 2.5px rgba(255,255,255,0.95));
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Labels edge sous-courants (option C : seulement les liens secondaires) ---- */
|
||||||
|
.pensees-edge-label {
|
||||||
|
fill: #555;
|
||||||
|
font-size: 8.5px;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.7;
|
||||||
|
text-anchor: middle;
|
||||||
|
dominant-baseline: middle;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Voronoi cellules : non-blurre Phase 8.F (revert Phase 8.D) ---- */
|
||||||
|
/* Blur retire ; les cellules colorees Bonpote-aligned suffisent visuellement. */
|
||||||
|
|
||||||
|
.ecole-node {
|
||||||
|
transition: opacity 0.15s, r 0.15s;
|
||||||
|
}
|
||||||
|
.ecole-node:hover {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,134 +1,439 @@
|
|||||||
<template>
|
<template>
|
||||||
<button v-if="!open" @click="open = true"
|
<!-- Mode overlay : bouton flottant bottom-right (legacy) -->
|
||||||
class="fixed bottom-6 right-6 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
<template v-if="!inline">
|
||||||
style="height:48px;background:var(--nav-primary);color:var(--nav-text-on-primary);font-size:0.875rem;font-weight:600;"
|
<button v-if="!open" @click="open = true"
|
||||||
aria-label="Chatbot Pensees Ecologiques">
|
class="fixed bottom-6 right-6 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
style="height:48px;background:var(--nav-primary);color:var(--nav-text-on-primary);font-size:0.875rem;font-weight:600;"
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
aria-label="Chatbot Pensees Ecologiques">
|
||||||
</svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<span>Pensees ?</span>
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
</button>
|
</svg>
|
||||||
|
<span>Pensees ?</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<Transition name="cpanel">
|
<Transition name="cpanel">
|
||||||
<div v-if="open" class="fixed bottom-6 right-6 z-[1000] flex flex-col"
|
<div v-if="open" class="fixed bottom-6 right-6 z-[1000] flex flex-col"
|
||||||
style="width:min(360px,calc(100vw - 24px));max-height:60vh;background:var(--nav-surface);border-radius:14px;box-shadow:0 8px 32px rgba(26,34,56,0.22);overflow:hidden;border:1px solid var(--nav-bg-alt);"
|
style="width:min(360px,calc(100vw - 24px));max-height:60vh;background:var(--nav-surface);border-radius:14px;box-shadow:0 8px 32px rgba(26,34,56,0.22);overflow:hidden;border:1px solid var(--nav-bg-alt);"
|
||||||
role="dialog" aria-modal="true" aria-label="RAG Pensees Ecologiques">
|
role="dialog" aria-modal="true" aria-label="RAG Pensees Ecologiques">
|
||||||
<div class="flex items-center justify-between px-4 py-3 shrink-0" style="border-bottom:1px solid var(--nav-bg-alt);background:var(--nav-bg);">
|
|
||||||
<div>
|
<!-- Header overlay -->
|
||||||
<p class="text-sm font-bold" style="color:var(--nav-text);">RAG Pensees Ecologiques</p>
|
<div class="flex items-center justify-between px-4 py-3 shrink-0" style="border-bottom:1px solid var(--nav-bg-alt);background:var(--nav-bg);">
|
||||||
<p class="text-xs" style="color:var(--nav-text-muted);">{{ corpusCount }} auteurs ingeres</p>
|
<div>
|
||||||
</div>
|
<p class="text-sm font-bold" style="color:var(--nav-text);">RAG Pensees Ecologiques</p>
|
||||||
<button @click="open = false" class="flex items-center justify-center w-7 h-7 rounded-full hover:opacity-70"
|
|
||||||
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);" aria-label="Fermer">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div ref="msgEl" class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3" style="min-height:0;">
|
|
||||||
<div v-if="messages.length === 0" style="font-size:0.8rem;color:var(--nav-text-muted);line-height:1.5;">
|
|
||||||
Pose une question sur les pensees ecologiques : ecosocialisme, decroissance, ecofeminismes, technocritique, deep ecology...
|
|
||||||
</div>
|
|
||||||
<template v-for="(msg, i) in messages" :key="i">
|
|
||||||
<div v-if="msg.role === 'user'" class="self-end max-w-[85%] px-3 py-2 rounded-xl text-sm"
|
|
||||||
style="background:var(--nav-primary);color:var(--nav-text-on-primary);font-weight:500;">{{ msg.content }}</div>
|
|
||||||
<div v-else class="self-start max-w-full">
|
|
||||||
<div class="px-3 py-2 rounded-xl text-sm leading-relaxed" style="background:var(--nav-bg-alt);color:var(--nav-text);"
|
|
||||||
v-html="renderMd(stripSrc(msg.content))" />
|
|
||||||
<div v-if="parseSrc(msg.content).length" class="mt-1.5">
|
|
||||||
<button @click="toggled[i] = !toggled[i]" class="flex items-center gap-1 text-xs hover:opacity-70" style="color:var(--nav-text-muted);">
|
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
|
||||||
:style="`transform:rotate(${toggled[i] ? 90 : 0}deg);transition:transform 0.15s`"><polyline points="9 18 15 12 9 6"/></svg>
|
|
||||||
Sources ({{ parseSrc(msg.content).length }})
|
|
||||||
</button>
|
|
||||||
<div v-if="toggled[i]" class="mt-1 flex flex-col gap-1">
|
|
||||||
<div v-for="(s, si) in parseSrc(msg.content)" :key="si" class="px-2 py-1 rounded text-xs"
|
|
||||||
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);border-left:2px solid var(--nav-primary-solid);">
|
|
||||||
<span style="font-weight:600;color:var(--nav-text);">[{{ si + 1 }}]</span> {{ s }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<button @click="open = false" class="flex items-center justify-center w-7 h-7 rounded-full hover:opacity-70"
|
||||||
<div v-if="loading" class="self-start px-3 py-2 rounded-xl" style="background:var(--nav-bg-alt);">
|
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);" aria-label="Fermer">
|
||||||
<span class="dots"><span/><span style="animation-delay:150ms"/><span style="animation-delay:300ms"/></span>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
</div>
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
<div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<input ref="inputEl" v-model="q" type="text" placeholder="Ta question..." maxlength="500"
|
|
||||||
class="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
|
|
||||||
style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);"
|
|
||||||
@keydown.enter.prevent="send" />
|
|
||||||
<button @click="send" :disabled="loading || !q.trim()"
|
|
||||||
class="flex items-center justify-center w-9 h-9 rounded-lg"
|
|
||||||
:style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'"
|
|
||||||
aria-label="Envoyer">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:white;">
|
|
||||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Corpus toggle overlay -->
|
||||||
|
<div class="shrink-0 px-3 pt-2 pb-1" style="background:var(--nav-bg);border-bottom:1px solid var(--nav-bg-alt);">
|
||||||
|
<div class="flex gap-1" role="group" aria-label="Choisir le corpus">
|
||||||
|
<button v-for="opt in corpusOptions" :key="opt.value" @click="setCorpus(opt.value)" :title="opt.tooltip"
|
||||||
|
class="flex-1 px-2 py-1 rounded text-xs font-medium transition-colors"
|
||||||
|
:style="corpus === opt.value ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'background:var(--nav-bg-alt);color:var(--nav-text-muted);'"
|
||||||
|
:aria-pressed="corpus === opt.value">{{ opt.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages overlay -->
|
||||||
|
<div ref="msgElOverlay" class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3" style="min-height:0;">
|
||||||
|
<div v-if="messages.length === 0" style="font-size:0.8rem;color:var(--nav-text-muted);line-height:1.5;">
|
||||||
|
<template v-if="corpus === 'pensees'">Pose une question sur les pensees ecologiques...</template>
|
||||||
|
<template v-else-if="corpus === 'projets'">Pose une question sur les projets d'architecture de Jules...</template>
|
||||||
|
<template v-else>Pose une question sur les pensees ecologiques ancrees dans les projets archi de Jules.</template>
|
||||||
|
</div>
|
||||||
|
<template v-for="(msg, i) in messages" :key="i">
|
||||||
|
<div v-if="msg.role === 'user'" class="self-end max-w-[85%] px-3 py-2 rounded-xl text-sm"
|
||||||
|
style="background:var(--nav-primary);color:var(--nav-text-on-primary);font-weight:500;">{{ msg.content }}</div>
|
||||||
|
<div v-else class="self-start max-w-full">
|
||||||
|
<div class="px-3 py-2 rounded-xl text-sm leading-relaxed" style="background:var(--nav-bg-alt);color:var(--nav-text);"
|
||||||
|
v-html="renderMd(stripSrc(msg.content))" />
|
||||||
|
<div v-if="filteredSources(msg.content).length" class="mt-1.5">
|
||||||
|
<button @click="toggled[i] = !toggled[i]" class="flex items-center gap-1 text-xs hover:opacity-70" style="color:var(--nav-text-muted);">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||||
|
:style="`transform:rotate(${toggled[i] ? 90 : 0}deg);transition:transform 0.15s`"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
Sources ({{ filteredSources(msg.content).length }})
|
||||||
|
</button>
|
||||||
|
<div v-if="toggled[i]" class="mt-1 flex flex-col gap-1">
|
||||||
|
<div v-for="(s, si) in filteredSources(msg.content)" :key="si" class="px-2 py-1 rounded text-xs"
|
||||||
|
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);border-left:2px solid var(--nav-primary-solid);">
|
||||||
|
<span style="font-weight:600;color:var(--nav-text);">[{{ si + 1 }}]</span> {{ s }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="loading" class="self-start px-3 py-2 rounded-xl" style="background:var(--nav-bg-alt);">
|
||||||
|
<span class="dots"><span/><span style="animation-delay:150ms"/><span style="animation-delay:300ms"/></span>
|
||||||
|
</div>
|
||||||
|
<div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input overlay -->
|
||||||
|
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
|
||||||
|
<div class="flex items-center gap-2" style="position:relative;">
|
||||||
|
<!-- Hashtag autocomplete dropdown (Slack/Discord pattern, au-dessus de l'input) -->
|
||||||
|
<div v-if="hashtagDropdownOpen && hashtagSuggestions.length"
|
||||||
|
class="hashtag-dropdown"
|
||||||
|
style="position:absolute;bottom:100%;left:0;right:0;margin-bottom:6px;max-height:220px;overflow-y:auto;background:var(--nav-surface);border:1px solid var(--nav-bg-alt);border-radius:8px;box-shadow:0 -4px 12px rgba(0,0,0,0.12);z-index:50;">
|
||||||
|
<div v-for="(auteur, idx) in hashtagSuggestions" :key="auteur.id"
|
||||||
|
@mousedown.prevent="applyHashtagSuggestion(auteur)"
|
||||||
|
@mouseenter="hashtagSelectedIndex = idx"
|
||||||
|
class="px-3 py-2 cursor-pointer text-sm"
|
||||||
|
:style="idx === hashtagSelectedIndex ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'color:var(--nav-text);'">
|
||||||
|
<span style="font-weight:600;">#{{ auteur.id }}</span>
|
||||||
|
<span :style="idx === hashtagSelectedIndex ? 'opacity:0.85;margin-left:8px;font-size:0.78rem;' : 'opacity:0.65;margin-left:8px;font-size:0.78rem;color:var(--nav-text-muted);'">{{ auteur.nom }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input ref="inputElOverlay" v-model="q" type="text" placeholder="Ta question, tape #auteur pour cibler" maxlength="500"
|
||||||
|
class="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
|
||||||
|
style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);"
|
||||||
|
@keydown="onInputKeydown" />
|
||||||
|
<button @click="send" :disabled="loading || !q.trim()"
|
||||||
|
class="flex items-center justify-center w-9 h-9 rounded-lg"
|
||||||
|
:style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'"
|
||||||
|
aria-label="Envoyer">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:white;">
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Mode inline : remplit 100% de son parent slot -->
|
||||||
|
<div v-else
|
||||||
|
class="flex flex-col w-full h-full"
|
||||||
|
style="background:var(--nav-surface);overflow:hidden;"
|
||||||
|
role="region" aria-label="RAG Pensees Ecologiques">
|
||||||
|
|
||||||
|
<!-- Header inline -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 shrink-0" style="border-bottom:1px solid var(--nav-bg-alt);background:var(--nav-bg);">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-bold" style="color:var(--nav-text);">RAG Pensees Ecologiques</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
|
||||||
|
<!-- Corpus toggle inline -->
|
||||||
|
<div class="shrink-0 px-3 pt-2 pb-1" style="background:var(--nav-bg);border-bottom:1px solid var(--nav-bg-alt);">
|
||||||
|
<div class="flex gap-1" role="group" aria-label="Choisir le corpus">
|
||||||
|
<button v-for="opt in corpusOptions" :key="opt.value" @click="setCorpus(opt.value)" :title="opt.tooltip"
|
||||||
|
class="flex-1 px-2 py-1 rounded text-xs font-medium transition-colors"
|
||||||
|
:style="corpus === opt.value ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'background:var(--nav-bg-alt);color:var(--nav-text-muted);'"
|
||||||
|
:aria-pressed="corpus === opt.value">{{ opt.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages inline -->
|
||||||
|
<div ref="msgElInline" class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3" style="min-height:0;">
|
||||||
|
<div v-if="messages.length === 0" style="font-size:0.8rem;color:var(--nav-text-muted);line-height:1.5;">
|
||||||
|
<template v-if="corpus === 'pensees'">Pose une question sur les pensees ecologiques : ecosocialisme, decroissance, ecofeminismes, technocritique, deep ecology...</template>
|
||||||
|
<template v-else-if="corpus === 'projets'">Pose une question sur les projets d'architecture de Jules : Butte Pinson, strategie thermique, partis pris constructifs...</template>
|
||||||
|
<template v-else>Pose une question sur les pensees ecologiques ancrees dans les projets archi de Jules (corpus croise, defaut).</template>
|
||||||
|
</div>
|
||||||
|
<template v-for="(msg, i) in messages" :key="i">
|
||||||
|
<div v-if="msg.role === 'user'" class="self-end max-w-[85%] px-3 py-2 rounded-xl text-sm"
|
||||||
|
style="background:var(--nav-primary);color:var(--nav-text-on-primary);font-weight:500;">{{ msg.content }}</div>
|
||||||
|
<div v-else class="self-start max-w-full">
|
||||||
|
<div class="px-3 py-2 rounded-xl text-sm leading-relaxed" style="background:var(--nav-bg-alt);color:var(--nav-text);"
|
||||||
|
v-html="renderMd(stripSrc(msg.content))" />
|
||||||
|
<div v-if="filteredSources(msg.content).length" class="mt-1.5">
|
||||||
|
<button @click="toggled[i] = !toggled[i]" class="flex items-center gap-1 text-xs hover:opacity-70" style="color:var(--nav-text-muted);">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||||
|
:style="`transform:rotate(${toggled[i] ? 90 : 0}deg);transition:transform 0.15s`"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
Sources ({{ filteredSources(msg.content).length }})
|
||||||
|
</button>
|
||||||
|
<div v-if="toggled[i]" class="mt-1 flex flex-col gap-1">
|
||||||
|
<div v-for="(s, si) in filteredSources(msg.content)" :key="si" class="px-2 py-1 rounded text-xs"
|
||||||
|
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);border-left:2px solid var(--nav-primary-solid);">
|
||||||
|
<span style="font-weight:600;color:var(--nav-text);">[{{ si + 1 }}]</span> {{ s }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="loading" class="self-start px-3 py-2 rounded-xl" style="background:var(--nav-bg-alt);">
|
||||||
|
<span class="dots"><span/><span style="animation-delay:150ms"/><span style="animation-delay:300ms"/></span>
|
||||||
|
</div>
|
||||||
|
<div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input inline -->
|
||||||
|
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
|
||||||
|
<div class="flex items-center gap-2" style="position:relative;">
|
||||||
|
<!-- Hashtag autocomplete dropdown (Slack/Discord pattern, au-dessus de l'input) -->
|
||||||
|
<div v-if="hashtagDropdownOpen && hashtagSuggestions.length"
|
||||||
|
class="hashtag-dropdown"
|
||||||
|
style="position:absolute;bottom:100%;left:0;right:0;margin-bottom:6px;max-height:220px;overflow-y:auto;background:var(--nav-surface);border:1px solid var(--nav-bg-alt);border-radius:8px;box-shadow:0 -4px 12px rgba(0,0,0,0.12);z-index:50;">
|
||||||
|
<div v-for="(auteur, idx) in hashtagSuggestions" :key="auteur.id"
|
||||||
|
@mousedown.prevent="applyHashtagSuggestion(auteur)"
|
||||||
|
@mouseenter="hashtagSelectedIndex = idx"
|
||||||
|
class="px-3 py-2 cursor-pointer text-sm"
|
||||||
|
:style="idx === hashtagSelectedIndex ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'color:var(--nav-text);'">
|
||||||
|
<span style="font-weight:600;">#{{ auteur.id }}</span>
|
||||||
|
<span :style="idx === hashtagSelectedIndex ? 'opacity:0.85;margin-left:8px;font-size:0.78rem;' : 'opacity:0.65;margin-left:8px;font-size:0.78rem;color:var(--nav-text-muted);'">{{ auteur.nom }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input ref="inputElInline" v-model="q" type="text" placeholder="Ta question, tape #auteur pour cibler" maxlength="500"
|
||||||
|
class="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
|
||||||
|
style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);"
|
||||||
|
@keydown="onInputKeydown" />
|
||||||
|
<button @click="send" :disabled="loading || !q.trim()"
|
||||||
|
class="flex items-center justify-center w-9 h-9 rounded-lg"
|
||||||
|
:style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'"
|
||||||
|
aria-label="Envoyer">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:white;">
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
interface Message { role: 'user' | 'assistant'; content: string }
|
interface Message { role: 'user' | 'assistant'; content: string }
|
||||||
const props = defineProps<{ auteurContext?: string | null }>()
|
interface AuteurMini { id: string; nom: string }
|
||||||
|
|
||||||
|
type CorpusMode = 'pensees' | 'projets' | 'both'
|
||||||
|
|
||||||
|
const CORPUS_STORAGE_KEY = 'chatbot-pensees-corpus'
|
||||||
|
|
||||||
|
const PROJECT_SOURCE_PATTERNS = [/butte.?pinson/i, /butte_pinson/i]
|
||||||
|
|
||||||
|
function isProjectSource(s: string): boolean {
|
||||||
|
return PROJECT_SOURCE_PATTERNS.some(p => p.test(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
const corpusOptions: { value: CorpusMode; label: string; tooltip: string }[] = [
|
||||||
|
{ value: 'pensees', label: 'Pensees', tooltip: 'Corpus FRACAS uniquement (auteurs ecologie politique)' },
|
||||||
|
{ value: 'projets', label: 'Projets', tooltip: 'Projets archi de Jules uniquement' },
|
||||||
|
{ value: 'both', label: 'Croise*', tooltip: 'Projets ancres + pensees en eclairage (defaut)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
auteurContext?: string | null
|
||||||
|
inline?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
const q = ref('')
|
const q = ref('')
|
||||||
const messages = ref<Message[]>([])
|
const messages = ref<Message[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const err = ref('')
|
const err = ref('')
|
||||||
const toggled = ref<Record<number, boolean>>({})
|
const toggled = ref<Record<number, boolean>>({})
|
||||||
const msgEl = ref<HTMLElement | null>(null)
|
const msgElOverlay = ref<HTMLElement | null>(null)
|
||||||
const inputEl = ref<HTMLInputElement | null>(null)
|
const msgElInline = ref<HTMLElement | null>(null)
|
||||||
|
const inputElOverlay = ref<HTMLInputElement | null>(null)
|
||||||
|
const inputElInline = ref<HTMLInputElement | null>(null)
|
||||||
const corpusCount = 18
|
const corpusCount = 18
|
||||||
|
|
||||||
|
const corpus = ref<CorpusMode>('pensees')
|
||||||
|
|
||||||
|
// Phase 8.E : hashtag mentions
|
||||||
|
const auteursIngeres = ref<AuteurMini[]>([])
|
||||||
|
const hashtagSuggestions = ref<AuteurMini[]>([])
|
||||||
|
const hashtagDropdownOpen = ref(false)
|
||||||
|
const hashtagSelectedIndex = ref(0)
|
||||||
|
|
||||||
|
function getActiveInput(): HTMLInputElement | null {
|
||||||
|
return props.inline ? inputElInline.value : inputElOverlay.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectHashtagAtCursor(input: string, cursorPos: number): { start: number; partial: string } | null {
|
||||||
|
const before = input.slice(0, cursorPos)
|
||||||
|
const m = before.match(/#([a-z0-9-]*)$/i)
|
||||||
|
if (!m) return null
|
||||||
|
return { start: m.index ?? 0, partial: (m[1] || '').toLowerCase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHashtagSuggestions() {
|
||||||
|
const el = getActiveInput()
|
||||||
|
const cursorPos = el?.selectionStart ?? q.value.length
|
||||||
|
const detection = detectHashtagAtCursor(q.value, cursorPos)
|
||||||
|
// Ouvrir dès que le # est présent (partial vide accepté pour afficher la liste)
|
||||||
|
if (!detection) {
|
||||||
|
hashtagDropdownOpen.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const partial = detection.partial
|
||||||
|
const list = partial.length === 0
|
||||||
|
? auteursIngeres.value.slice(0, 8)
|
||||||
|
: auteursIngeres.value
|
||||||
|
.filter(a => a.id.toLowerCase().includes(partial) || a.nom.toLowerCase().includes(partial))
|
||||||
|
.slice(0, 8)
|
||||||
|
hashtagSuggestions.value = list
|
||||||
|
hashtagDropdownOpen.value = list.length > 0
|
||||||
|
hashtagSelectedIndex.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHashtagSuggestion(auteur: AuteurMini) {
|
||||||
|
const el = getActiveInput()
|
||||||
|
const cursorPos = el?.selectionStart ?? q.value.length
|
||||||
|
const detection = detectHashtagAtCursor(q.value, cursorPos)
|
||||||
|
if (!detection) return
|
||||||
|
const before = q.value.slice(0, detection.start)
|
||||||
|
const after = q.value.slice(cursorPos)
|
||||||
|
const insert = '#' + auteur.id + ' '
|
||||||
|
q.value = before + insert + after
|
||||||
|
hashtagDropdownOpen.value = false
|
||||||
|
nextTick(() => {
|
||||||
|
const focusEl = getActiveInput()
|
||||||
|
if (!focusEl) return
|
||||||
|
focusEl.focus()
|
||||||
|
const newPos = before.length + insert.length
|
||||||
|
focusEl.setSelectionRange(newPos, newPos)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInputKeydown(e: KeyboardEvent) {
|
||||||
|
if (hashtagDropdownOpen.value && hashtagSuggestions.value.length > 0) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
hashtagSelectedIndex.value = (hashtagSelectedIndex.value + 1) % hashtagSuggestions.value.length
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
hashtagSelectedIndex.value = (hashtagSelectedIndex.value - 1 + hashtagSuggestions.value.length) % hashtagSuggestions.value.length
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
e.preventDefault()
|
||||||
|
applyHashtagSuggestion(hashtagSuggestions.value[hashtagSelectedIndex.value])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
hashtagDropdownOpen.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(q, () => {
|
||||||
|
updateHashtagSuggestions()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const saved = window.localStorage.getItem(CORPUS_STORAGE_KEY) as CorpusMode | null
|
||||||
|
if (saved && ['pensees', 'projets', 'both'].includes(saved)) {
|
||||||
|
corpus.value = saved
|
||||||
|
}
|
||||||
|
// Chargement liste auteurs ingérés pour autocomplete hashtag
|
||||||
|
try {
|
||||||
|
const data = await $fetch<any>('/data/auteurs-pensees.json')
|
||||||
|
auteursIngeres.value = (data?.auteurs ?? [])
|
||||||
|
.filter((a: any) => a.ingere === true)
|
||||||
|
.map((a: any) => ({ id: String(a.id), nom: String(a.nom) }))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erreur chargement auteurs-pensees.json pour hashtag', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function setCorpus(val: CorpusMode) {
|
||||||
|
corpus.value = val
|
||||||
|
window.localStorage.setItem(CORPUS_STORAGE_KEY, val)
|
||||||
|
}
|
||||||
|
|
||||||
watch(open, (val) => {
|
watch(open, (val) => {
|
||||||
if (!val) return
|
if (!val) return
|
||||||
nextTick(() => inputEl.value?.focus())
|
nextTick(() => inputElOverlay.value?.focus())
|
||||||
if (props.auteurContext && messages.value.length === 0)
|
if (props.auteurContext && messages.value.length === 0)
|
||||||
q.value = `Quelles sont les theses centrales de ${props.auteurContext} ?`
|
q.value = `Quelles sont les theses centrales de ${props.auteurContext} ?`
|
||||||
})
|
})
|
||||||
watch(() => props.auteurContext, (ctx) => {
|
watch(() => props.auteurContext, (ctx) => {
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
if (!open.value) open.value = true
|
if (!props.inline && !open.value) open.value = true
|
||||||
if (messages.value.length === 0) q.value = `Quelles sont les theses centrales de ${ctx} ?`
|
if (messages.value.length === 0) q.value = `Quelles sont les theses centrales de ${ctx} ?`
|
||||||
})
|
})
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
const query = q.value.trim()
|
const query = q.value.trim()
|
||||||
if (!query || loading.value) return
|
if (!query || loading.value) return
|
||||||
|
|
||||||
|
// Extraire le premier hashtag matchant un auteur ingéré
|
||||||
|
let auteurSlug: string | null = null
|
||||||
|
const matches = [...query.matchAll(/#([a-z0-9-]+)/gi)]
|
||||||
|
for (const m of matches) {
|
||||||
|
const slug = m[1].toLowerCase()
|
||||||
|
if (auteursIngeres.value.find(a => a.id === slug)) {
|
||||||
|
auteurSlug = slug
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Premier hashtag non-matché (pour info utilisateur si jamais ne match aucun)
|
||||||
|
let auteurSlugUnmatched: string | null = null
|
||||||
|
if (!auteurSlug && matches.length > 0) {
|
||||||
|
auteurSlugUnmatched = matches[0][1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
err.value = ''
|
err.value = ''
|
||||||
messages.value.push({ role: 'user', content: query })
|
messages.value.push({ role: 'user', content: query })
|
||||||
q.value = ''
|
q.value = ''
|
||||||
|
hashtagDropdownOpen.value = false
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await nextTick(); scrollBottom()
|
await nextTick()
|
||||||
|
scrollBottom()
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<{ response: string }>('/api/chatbot-pensees', { method: 'POST', body: { query, mode: 'hybrid' } })
|
const res = await $fetch<any>('/api/chatbot-pensees', {
|
||||||
messages.value.push({ role: 'assistant', content: res.response ?? '' })
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
query,
|
||||||
|
mode: 'hybrid',
|
||||||
|
corpus: corpus.value,
|
||||||
|
auteur_slug: auteurSlug ?? auteurSlugUnmatched,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
let responseText = res.response ?? ''
|
||||||
|
if (res.auteur_unmatched) {
|
||||||
|
responseText = `*(Aucun livre de #${res.auteur_unmatched} n'est ingéré dans le RAG. Je réponds depuis la carte entière.)*\n\n` + responseText
|
||||||
|
}
|
||||||
|
messages.value.push({ role: 'assistant', content: responseText })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const s = e?.response?.status ?? e?.statusCode
|
const s = e?.response?.status ?? e?.statusCode
|
||||||
err.value = s === 429 ? 'Limite atteinte.' : s === 503 ? 'RAG indisponible.' : 'Erreur - reessaie.'
|
err.value = s === 429 ? 'Limite atteinte.' : s === 503 ? 'RAG indisponible.' : 'Erreur, reessaie.'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
await nextTick(); scrollBottom()
|
await nextTick()
|
||||||
|
scrollBottom()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function scrollBottom() { if (msgEl.value) msgEl.value.scrollTop = msgEl.value.scrollHeight }
|
|
||||||
|
function scrollBottom() {
|
||||||
|
const el = props.inline ? msgElInline.value : msgElOverlay.value
|
||||||
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
function renderMd(t: string) {
|
function renderMd(t: string) {
|
||||||
return '<p>' + t.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\*(.+?)\*/g, '<em>$1</em>').replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>') + '</p>'
|
return '<p>' + t.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\*(.+?)\*/g, '<em>$1</em>').replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>') + '</p>'
|
||||||
}
|
}
|
||||||
function stripSrc(t: string) { return t.replace(/\n*(?:Sources?|References?)\s*:[\s\S]*$/i, '').trim() }
|
function stripSrc(t: string) { return t.replace(/\n*(?:Sources?|References?)\s*:[\s\S]*$/i, '').trim() }
|
||||||
|
|
||||||
function parseSrc(t: string): string[] {
|
function parseSrc(t: string): string[] {
|
||||||
const bloc = t.match(/\n*(?:Sources?|References?)\s*:\n?([\s\S]+?)$/i)
|
const bloc = t.match(/\n*(?:Sources?|References?)\s*:\n?([\s\S]+?)$/i)
|
||||||
if (bloc) return bloc[1].split('\n').map(l => l.replace(/^[-*\d.[\]]+\s*/, '').trim()).filter(l => l.length > 3)
|
if (bloc) return bloc[1].split('\n').map(l => l.replace(/^[-*\d.[\]]+\s*/, '').trim()).filter(l => l.length > 3)
|
||||||
return [...new Set([...t.matchAll(/\[([^\]]{5,80})\]/g)].filter(m => m[1].includes(' - ')).map(m => m[1]))]
|
return [...new Set([...t.matchAll(/\[([^\]]{5,80})\]/g)].filter(m => m[1].includes(' - ')).map(m => m[1]))]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filteredSources(t: string): string[] {
|
||||||
|
const all = parseSrc(t)
|
||||||
|
if (corpus.value === 'both') return all
|
||||||
|
if (corpus.value === 'projets') return all.filter(s => isProjectSource(s))
|
||||||
|
return all.filter(s => !isProjectSource(s))
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -138,4 +443,4 @@ function parseSrc(t: string): string[] {
|
|||||||
.cpanel-leave-to { opacity: 0; transform: translateY(8px) scale(0.97); }
|
.cpanel-leave-to { opacity: 0; transform: translateY(8px) scale(0.97); }
|
||||||
.dots span { display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--nav-text-muted);margin:0 2px;animation:bounce 1s infinite; }
|
.dots span { display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--nav-text-muted);margin:0 2px;animation:bounce 1s infinite; }
|
||||||
@keyframes bounce { 0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-5px)} }
|
@keyframes bounce { 0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-5px)} }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -52,9 +52,10 @@
|
|||||||
<div class="chatbot-body-inner" ref="messagesContainer">
|
<div class="chatbot-body-inner" ref="messagesContainer">
|
||||||
<!-- Onboarding -->
|
<!-- Onboarding -->
|
||||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||||
<p>Explore les 120 structures de la carte par la conversation. Je peux t'aider à trouver des collectifs, agences ou réseaux selon ta situation, ta pratique ou tes inspirations du moment.</p>
|
<p>Je connais les structures d'entraide pour architectes référencées sur cette carte — appui juridique, technique, économique, formation, santé mentale, gestion d'agence…</p>
|
||||||
<p class="example">Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."</p>
|
<p>Décris ta situation, je te propose les fiches les plus pertinentes.</p>
|
||||||
<p style="margin-top: 8px; font-size: 0.72rem; opacity: 0.6;">Propulsé par Mistral FR - serveur européen souverain, zéro rétention.</p>
|
<p class="example">Exemple : "Architecte salarié, litige avec mon employeur, besoin d'un appui juridique droit du travail, Île-de-France."</p>
|
||||||
|
<p style="margin-top: 8px; font-size: 0.72rem; opacity: 0.6;">Propulsé par Mistral FR — serveur européen souverain, zéro rétention.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
|
|||||||
@@ -1,38 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1">
|
||||||
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p>
|
<p class="filter-label">ÉCHELLE</p>
|
||||||
<!-- Inline sur 1 ligne — même pattern que FonctionFilter -->
|
<div class="chips-row">
|
||||||
<div class="flex flex-wrap gap-x-4 gap-y-1.5">
|
<span
|
||||||
<label
|
|
||||||
v-for="option in ECHELLES"
|
v-for="option in ECHELLES"
|
||||||
:key="option"
|
:key="option"
|
||||||
class="flex items-center gap-1.5 cursor-pointer select-none transition-opacity"
|
class="chip"
|
||||||
>
|
:style="isSelected(option)
|
||||||
<!-- Case carrée -->
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
<span
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
class="flex items-center justify-center shrink-0 transition-all"
|
@click="toggle(option)"
|
||||||
style="width: 18px; height: 18px; border: 1.5px solid; border-radius: 3px;"
|
>{{ option }}</span>
|
||||||
:style="isSelected(option)
|
|
||||||
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: #ffffff;'
|
|
||||||
: 'background: var(--nav-bg-alt); border-color: rgba(26,34,56,0.25); color: transparent;'"
|
|
||||||
>
|
|
||||||
<svg v-if="isSelected(option)" width="11" height="11" viewBox="0 0 12 12" fill="none">
|
|
||||||
<polyline points="2,6 5,9 10,3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<!-- Label -->
|
|
||||||
<span
|
|
||||||
class="text-sm leading-tight"
|
|
||||||
:style="isSelected(option) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
|
|
||||||
>{{ option }}</span>
|
|
||||||
<!-- Input réel (masqué) -->
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="sr-only"
|
|
||||||
:checked="isSelected(option)"
|
|
||||||
@change="toggle(option)"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -61,3 +39,24 @@ function toggle(option: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.chips-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
|
||||||
|
.chip {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
101
components/FicheEcole.vue
Normal file
101
components/FicheEcole.vue
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="backdrop">
|
||||||
|
<div v-if="open && ecole" class="fixed inset-0 z-[1500]" style="background: rgba(26,34,56,0.55);" @click="emit('close')" aria-hidden="true" />
|
||||||
|
</Transition>
|
||||||
|
<Transition name="modal">
|
||||||
|
<div v-if="open && ecole" class="fixed z-[1501] left-1/2 flex flex-col"
|
||||||
|
style="top:50%;transform:translate(-50%,-50%);width:min(540px,94vw);max-height:85vh;background:var(--nav-bg);border-radius:14px;box-shadow:0 16px 64px rgba(26,34,56,0.28);overflow:hidden;"
|
||||||
|
role="dialog" aria-modal="true">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-start justify-between px-5 py-4 shrink-0"
|
||||||
|
:style="`border-bottom: 3px solid ${ecole.color}; background: var(--nav-surface);`">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<span class="px-2 py-0.5 rounded-full text-xs font-semibold" :style="`background:${ecole.color}22;color:${ecole.color};`">Ecole de pensee</span>
|
||||||
|
<h2 class="mt-2 font-bold text-lg leading-tight" style="color:var(--nav-text);">{{ ecole.label }}</h2>
|
||||||
|
<p class="text-sm mt-1 leading-relaxed" style="color:var(--nav-text-muted);">{{ ecole.description }}</p>
|
||||||
|
</div>
|
||||||
|
<button @click="emit('close')" class="ml-3 shrink-0 flex items-center justify-center w-8 h-8 rounded-full hover:opacity-70"
|
||||||
|
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);" aria-label="Fermer">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="flex-1 overflow-y-auto px-5 py-4 flex flex-col gap-4">
|
||||||
|
<div v-if="auteursIngeres.length">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color:var(--nav-text-muted);">Dans le RAG ({{ auteursIngeres.length }})</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button v-for="a in auteursIngeres" :key="a.id"
|
||||||
|
class="px-2.5 py-1 rounded-full text-xs font-medium hover:opacity-80 transition-opacity"
|
||||||
|
:style="`background:${ecole.color}22;color:${ecole.color};border:1px solid ${ecole.color}44;cursor:pointer;`"
|
||||||
|
@click="onSelectAuteur(a.id)">
|
||||||
|
{{ a.nom }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="auteursNonIngeres.length">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color:var(--nav-text-muted);">Presents dans Bonpote, pas encore dans le RAG ({{ auteursNonIngeres.length }})</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span v-for="a in auteursNonIngeres" :key="a.id"
|
||||||
|
class="px-2.5 py-1 rounded-full text-xs"
|
||||||
|
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);">
|
||||||
|
{{ a.nom }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="shrink-0 px-5 py-3 border-t" style="border-color:var(--nav-bg-alt);">
|
||||||
|
<button @click="emit('interroger-ecole', ecoleId!)" class="w-full py-2.5 rounded-lg text-sm font-semibold hover:opacity-80"
|
||||||
|
:style="`background:${ecole.color};color:white;`">
|
||||||
|
Interroger le RAG sur {{ ecole.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface AuteurData { id: string; nom: string; ecoles: string[]; ecole_principale: string; ingere: boolean }
|
||||||
|
interface EcoleData { id: string; label: string; description: string; color: string }
|
||||||
|
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
|
||||||
|
|
||||||
|
const props = defineProps<{ open: boolean; ecoleId: string | null; data: PenseesData | null }>()
|
||||||
|
const emit = defineEmits<{ close: []; 'select-auteur': [auteurId: string]; 'interroger-ecole': [ecoleId: string] }>()
|
||||||
|
|
||||||
|
const ecole = computed<EcoleData | null>(() => {
|
||||||
|
if (!props.ecoleId || !props.data) return null
|
||||||
|
return props.data.ecoles.find(e => e.id === props.ecoleId) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const auteursIngeres = computed(() => {
|
||||||
|
if (!props.ecoleId || !props.data) return []
|
||||||
|
return props.data.auteurs.filter(a => a.ecole_principale === props.ecoleId && (a as any).ingere)
|
||||||
|
})
|
||||||
|
|
||||||
|
const auteursNonIngeres = computed(() => {
|
||||||
|
if (!props.ecoleId || !props.data) return []
|
||||||
|
return props.data.auteurs.filter(a => a.ecole_principale === props.ecoleId && !(a as any).ingere)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSelectAuteur(id: string) {
|
||||||
|
emit('close')
|
||||||
|
emit('select-auteur', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && props.open) emit('close') }
|
||||||
|
onMounted(() => window.addEventListener('keydown', onKey))
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backdrop-enter-active,.backdrop-leave-active { transition: opacity 0.2s; }
|
||||||
|
.backdrop-enter-from,.backdrop-leave-to { opacity: 0; }
|
||||||
|
.modal-enter-active { transition: opacity 0.2s, transform 0.22s cubic-bezier(0.34,1.56,0.64,1); }
|
||||||
|
.modal-leave-active { transition: opacity 0.18s, transform 0.18s ease-in; }
|
||||||
|
.modal-enter-from { opacity: 0; transform: translate(-50%,-48%) scale(0.94); }
|
||||||
|
.modal-leave-to { opacity: 0; transform: translate(-50%,-48%) scale(0.96); }
|
||||||
|
</style>
|
||||||
@@ -1,35 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1">
|
||||||
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p>
|
<!-- Label + toggle collapse -->
|
||||||
<div class="space-y-1">
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
||||||
|
<p class="filter-label" style="margin-bottom: 0;">
|
||||||
|
FONCTION
|
||||||
|
<span v-if="modelValue.length" style="font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 0.65rem; margin-left: 4px;">({{ modelValue.length }} active{{ modelValue.length > 1 ? 's' : '' }})</span>
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
|
@click="toggleCollapse"
|
||||||
|
style="font-size: 0.7rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0; white-space: nowrap;"
|
||||||
|
>{{ isOpen ? 'Replier' : 'Fonctions (' + FONCTIONS.length + ')' }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chips (visible si ouvert ou si des fonctions sont actives) -->
|
||||||
|
<div v-if="isOpen" class="chips-row">
|
||||||
|
<span
|
||||||
v-for="fn in FONCTIONS"
|
v-for="fn in FONCTIONS"
|
||||||
:key="fn"
|
:key="fn"
|
||||||
|
class="chip"
|
||||||
|
:style="modelValue.includes(fn)
|
||||||
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
@click="toggle(fn)"
|
@click="toggle(fn)"
|
||||||
:aria-pressed="modelValue.includes(fn)"
|
>{{ fn }}</span>
|
||||||
class="flex items-center gap-2.5 w-full rounded px-1 py-0.5 transition-all text-left hover:opacity-80"
|
|
||||||
:style="modelValue.includes(fn) ? 'background: rgba(26,34,56,0.06);' : ''"
|
|
||||||
>
|
|
||||||
<!-- Case : affiche le rang de priorité si actif, sinon le nombre d'orgs -->
|
|
||||||
<span
|
|
||||||
class="flex items-center justify-center shrink-0 text-xs font-bold transition-all"
|
|
||||||
style="width: 24px; height: 24px; border: 1.5px solid; border-radius: 4px;"
|
|
||||||
:style="modelValue.includes(fn)
|
|
||||||
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: var(--nav-text-on-primary);'
|
|
||||||
: 'background: var(--nav-bg-alt); border-color: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
|
||||||
>
|
|
||||||
{{ modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0) }}
|
|
||||||
</span>
|
|
||||||
<!-- Label -->
|
|
||||||
<span
|
|
||||||
class="text-sm leading-tight"
|
|
||||||
:style="modelValue.includes(fn) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
|
|
||||||
>{{ fn }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Effacer (visible même replié si filtres actifs) -->
|
||||||
<p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);">
|
<p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);">
|
||||||
{{ modelValue.length }} actif{{ modelValue.length > 1 ? 's' : '' }}
|
<button @click="emit('update:modelValue', [])" class="underline hover:opacity-70">Effacer</button>
|
||||||
<button @click="emit('update:modelValue', [])" class="ml-2 underline hover:opacity-70">Effacer</button>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -57,6 +55,25 @@ const emit = defineEmits<{
|
|||||||
'update:modelValue': [value: string[]]
|
'update:modelValue': [value: string[]]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Replié par défaut, ouvre automatiquement quand des filtres sont actifs
|
||||||
|
const manuallyOpen = ref(false)
|
||||||
|
|
||||||
|
const isOpen = computed(() => {
|
||||||
|
return manuallyOpen.value || props.modelValue.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleCollapse() {
|
||||||
|
// Si des filtres actifs forcent l'ouverture, on doit gérer le cas « forcer fermer »
|
||||||
|
if (isOpen.value) {
|
||||||
|
manuallyOpen.value = false
|
||||||
|
// Si des fonctions sont actives, le computed va les réouvrir — on les efface
|
||||||
|
// Non : on laisse le choix à l'utilisateur. On toggle juste manuallyOpen.
|
||||||
|
// Quand replié avec filtres actifs, l'indicateur "(N actives)" reste visible.
|
||||||
|
} else {
|
||||||
|
manuallyOpen.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggle(fn: string) {
|
function toggle(fn: string) {
|
||||||
if (props.modelValue.includes(fn)) {
|
if (props.modelValue.includes(fn)) {
|
||||||
emit('update:modelValue', props.modelValue.filter(f => f !== fn))
|
emit('update:modelValue', props.modelValue.filter(f => f !== fn))
|
||||||
@@ -65,3 +82,23 @@ function toggle(fn: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
display: block;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.chips-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
|
||||||
|
.chip {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -877,6 +877,7 @@ onUnmounted(() => {
|
|||||||
/* Labels des structures dans le graphe (D3 injecte les <text>, donc style global) */
|
/* Labels des structures dans le graphe (D3 injecte les <text>, donc style global) */
|
||||||
.graph-view .graph-struct-label {
|
.graph-view .graph-struct-label {
|
||||||
fill: var(--nav-text);
|
fill: var(--nav-text);
|
||||||
|
opacity: 0.7;
|
||||||
paint-order: stroke;
|
paint-order: stroke;
|
||||||
stroke: var(--nav-bg);
|
stroke: var(--nav-bg);
|
||||||
stroke-width: 3px;
|
stroke-width: 3px;
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ LOCAL_ENV_CONTENT=$(cat "$LOCAL_ENV" 2>/dev/null || echo "")
|
|||||||
if [ "$LOCAL_ENV_CONTENT" != "$REMOTE_ENV_CONTENT" ]; then
|
if [ "$LOCAL_ENV_CONTENT" != "$REMOTE_ENV_CONTENT" ]; then
|
||||||
log "AVERTISSEMENT : .env.production local != .env VPS"
|
log "AVERTISSEMENT : .env.production local != .env VPS"
|
||||||
log " --- Local ---"
|
log " --- Local ---"
|
||||||
echo "$LOCAL_ENV_CONTENT" | sed 's/TOKEN=.*/TOKEN=***/' | sed 's/^/ /'
|
echo "$LOCAL_ENV_CONTENT" | sed -E 's/(TOKEN|API_KEY|PASSWORD|SECRET)=.*$/\1=***/' | sed 's/^/ /'
|
||||||
log " --- VPS ---"
|
log " --- VPS ---"
|
||||||
echo "$REMOTE_ENV_CONTENT" | sed 's/TOKEN=.*/TOKEN=***/' | sed 's/^/ /'
|
echo "$REMOTE_ENV_CONTENT" | sed -E 's/(TOKEN|API_KEY|PASSWORD|SECRET)=.*$/\1=***/' | sed 's/^/ /'
|
||||||
read -p "Continuer malgre la difference ? [y/N] " CONFIRM
|
read -p "Continuer malgre la difference ? [y/N] " CONFIRM
|
||||||
[ "$CONFIRM" = "y" ] || { log "Deploiement annule."; exit 1; }
|
[ "$CONFIRM" = "y" ] || { log "Deploiement annule."; exit 1; }
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default defineNuxtConfig({
|
|||||||
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
|
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
|
||||||
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
|
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
|
||||||
mistralApiKey: process.env.MISTRAL_API_KEY,
|
mistralApiKey: process.env.MISTRAL_API_KEY,
|
||||||
|
nebiusApiKey: process.env.NEBIUS_API_KEY,
|
||||||
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
||||||
resendApiKey: process.env.RESEND_API_KEY,
|
resendApiKey: process.env.RESEND_API_KEY,
|
||||||
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
|
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
|
||||||
|
|||||||
@@ -128,12 +128,6 @@
|
|||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
@click="desktopMapView = 'graphe'"
|
@click="desktopMapView = 'graphe'"
|
||||||
>Vue graphique</button>
|
>Vue graphique</button>
|
||||||
<NuxtLink
|
|
||||||
to="/pensees-ecologiques"
|
|
||||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
|
||||||
style="color: var(--nav-text-muted); border-bottom: 2px solid transparent;"
|
|
||||||
active-class="!color-nav-text"
|
|
||||||
>Pensees</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Carte Métropole desktop -->
|
<!-- Carte Métropole desktop -->
|
||||||
@@ -225,11 +219,6 @@
|
|||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
@click="mobileMapView = 'graphe'"
|
@click="mobileMapView = 'graphe'"
|
||||||
>Graphe</button>
|
>Graphe</button>
|
||||||
<NuxtLink
|
|
||||||
to="/pensees-ecologiques"
|
|
||||||
class="flex-1 py-2 text-sm font-medium transition-colors text-center"
|
|
||||||
style="color: var(--nav-text-muted); border-bottom: 2px solid transparent;"
|
|
||||||
>Pensees</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lg:hidden flex-1 relative overflow-hidden">
|
<div class="lg:hidden flex-1 relative overflow-hidden">
|
||||||
|
|||||||
@@ -201,10 +201,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filtres FONCTION — chips flex-wrap -->
|
<!-- Filtres FONCTION — chips flex-wrap + toggle collapse -->
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
||||||
<div class="flex flex-wrap gap-1">
|
<span class="text-xs font-bold uppercase tracking-wide" style="color: var(--nav-text-muted);">
|
||||||
|
FONCTION
|
||||||
|
<span v-if="fonctions.length" style="font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 0.65rem; margin-left: 4px;">({{ fonctions.length }} active{{ fonctions.length > 1 ? 's' : '' }})</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
@click="mobileFonctionsOpen = !mobileFonctionsOpen"
|
||||||
|
style="font-size: 0.65rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0; white-space: nowrap;"
|
||||||
|
>{{ mobileFonctionsOpen || fonctions.length ? (mobileFonctionsOpen ? 'Replier' : 'Afficher') : 'Fonctions (' + FONCTIONS.length + ')' }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="mobileFonctionsOpen || fonctions.length" class="flex flex-wrap gap-1">
|
||||||
<span
|
<span
|
||||||
v-for="fn in FONCTIONS"
|
v-for="fn in FONCTIONS"
|
||||||
:key="fn"
|
:key="fn"
|
||||||
@@ -365,6 +374,7 @@ const ficheModalOpen = ref(false)
|
|||||||
const ficheModalId = ref<number | null>(null)
|
const ficheModalId = ref<number | null>(null)
|
||||||
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
|
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
|
||||||
const missionOpen = ref(false)
|
const missionOpen = ref(false)
|
||||||
|
const mobileFonctionsOpen = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
590
pages/media.vue
Normal file
590
pages/media.vue
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
<template>
|
||||||
|
<div class="media-page" style="background: var(--nav-bg);">
|
||||||
|
|
||||||
|
<!-- ZONE PRINCIPALE (pleine largeur, pas de sidebar) -->
|
||||||
|
<main class="media-main">
|
||||||
|
|
||||||
|
<!-- Header onglet -->
|
||||||
|
<div class="shrink-0 px-5 py-3"
|
||||||
|
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt); display:flex; align-items:center; justify-content:space-between; gap:12px;">
|
||||||
|
<div>
|
||||||
|
<h1 class="font-bold text-base" style="color: var(--nav-text);">ATIS Media</h1>
|
||||||
|
<p class="text-xs mt-0.5" style="color: var(--nav-text-muted);">
|
||||||
|
{{ corpusCount }} auteurs / {{ livresCount }} livres ingeres dans le RAG -
|
||||||
|
<a href="https://bonpote.com/la-carte-des-pensees-ecologiques/"
|
||||||
|
target="_blank" rel="noopener"
|
||||||
|
style="color: var(--nav-primary, #3b6ea5); text-decoration: underline; text-underline-offset: 2px;">
|
||||||
|
carte FRACAS Bonpote V2
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="ragInfoOpen = true"
|
||||||
|
title="A propos du RAG FRACAS"
|
||||||
|
style="width:26px;height:26px;border-radius:50%;border:1.5px solid var(--nav-text-muted);color:var(--nav-text-muted);font-size:0.72rem;font-weight:700;cursor:pointer;flex-shrink:0;background:var(--nav-bg-alt);display:flex;align-items:center;justify-content:center;"
|
||||||
|
aria-label="A propos du RAG">
|
||||||
|
i
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conteneur split / plein ecran -->
|
||||||
|
<div class="layout-container">
|
||||||
|
|
||||||
|
<!-- Slot carte D3 -->
|
||||||
|
<div
|
||||||
|
class="carte-slot"
|
||||||
|
:class="[
|
||||||
|
layoutMode === 'split' ? 'carte-split' : '',
|
||||||
|
layoutMode === 'carte-full' ? 'carte-full' : '',
|
||||||
|
layoutMode === 'chatbot-full' ? 'carte-hidden' : '',
|
||||||
|
]"
|
||||||
|
:style="layoutMode === 'split' ? { flexBasis: carteFlexBasis } : {}"
|
||||||
|
>
|
||||||
|
<ClientOnly>
|
||||||
|
<CartePensees
|
||||||
|
ref="cartePenseesRef"
|
||||||
|
:data="penseesData"
|
||||||
|
:active="true"
|
||||||
|
@select-auteur="onSelectAuteur"
|
||||||
|
@select-ecole="onSelectEcole"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div class="w-full h-full flex items-center justify-center" style="color: var(--nav-text-muted);">
|
||||||
|
Chargement de la carte...
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Barre de toggle -->
|
||||||
|
<div class="layout-toggle-bar shrink-0">
|
||||||
|
<button
|
||||||
|
@click="setLayoutMode('carte-full')"
|
||||||
|
:class="{ active: layoutMode === 'carte-full' }"
|
||||||
|
class="toggle-btn"
|
||||||
|
title="Carte en plein ecran"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
|
||||||
|
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
|
||||||
|
</svg>
|
||||||
|
Carte plein ecran
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="layoutMode !== 'split'"
|
||||||
|
@click="setLayoutMode('split')"
|
||||||
|
class="toggle-btn"
|
||||||
|
title="Vue partagee"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
Vue partagee
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="setLayoutMode('chatbot-full')"
|
||||||
|
:class="{ active: layoutMode === 'chatbot-full' }"
|
||||||
|
class="toggle-btn"
|
||||||
|
title="Chatbot en plein ecran"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
Chatbot plein ecran
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="setLayoutMode('bonpote')"
|
||||||
|
:class="{ active: layoutMode === 'bonpote' }"
|
||||||
|
class="toggle-btn"
|
||||||
|
title="A propos de la carte FRACAS Bonpote V2"
|
||||||
|
style="margin-left: auto;"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="10"/><polyline points="12 8 12 12 14 14"/>
|
||||||
|
</svg>
|
||||||
|
Bonpote V2
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Poignee draggable (visible uniquement en mode split, pas sur mobile) -->
|
||||||
|
<div
|
||||||
|
v-if="layoutMode === 'split'"
|
||||||
|
class="split-handle"
|
||||||
|
@mousedown.prevent="onHandleMousedown"
|
||||||
|
title="Redimensionner"
|
||||||
|
>
|
||||||
|
<span class="split-handle-grip"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slot chatbot inline -->
|
||||||
|
<div
|
||||||
|
class="chatbot-slot"
|
||||||
|
:class="[
|
||||||
|
layoutMode === 'split' ? 'chatbot-split' : '',
|
||||||
|
layoutMode === 'chatbot-full' ? 'chatbot-full-mode' : '',
|
||||||
|
layoutMode === 'carte-full' ? 'chatbot-hidden' : '',
|
||||||
|
]"
|
||||||
|
:style="layoutMode === 'split' ? { flexBasis: chatbotFlexBasis } : {}"
|
||||||
|
>
|
||||||
|
<ClientOnly>
|
||||||
|
<ChatbotPensees :auteurContext="chatbotAuteur" :inline="true" />
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vue Bonpote V2 -->
|
||||||
|
<div
|
||||||
|
v-if="layoutMode === 'bonpote'"
|
||||||
|
class="flex-1 overflow-y-auto px-6 py-8"
|
||||||
|
style="max-width: 680px; margin: 0 auto;"
|
||||||
|
>
|
||||||
|
<div class="mb-6">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color: var(--nav-text-muted);">Reference editoriale</p>
|
||||||
|
<h2 class="text-xl font-bold mb-3" style="color: var(--nav-text);">Carte FRACAS des pensees ecologiques</h2>
|
||||||
|
<p class="text-sm leading-relaxed mb-4" style="color: var(--nav-text);">
|
||||||
|
FRACAS (Familles, Racines et Arpentages des Courants et Alternatives Solidaires) est une carte des ecoles de pensee ecologique publiee par Bonpote en octobre 2024. Elle reference ~140 auteurs et autrices reparti-es en 10 ecoles de pensee, depuis l'ecosocialisme jusqu'a l'ethique environnementale.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text);">
|
||||||
|
Le RAG ATIS est construit sur cette reference : chaque auteur ingere dans la bibliotheque correspond a une entree de la carte FRACAS. Les ecoles de pensee, les positions et les couleurs de notre carte sont transposees 1:1 depuis Bonpote V2.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<a href="https://bonpote.com/la-carte-des-pensees-ecologiques/"
|
||||||
|
target="_blank" rel="noopener"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity"
|
||||||
|
style="background: var(--nav-primary, #3b6ea5); color: white; font-size: 0.875rem; font-weight: 600; text-decoration: none;">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||||
|
Lire l'article Bonpote + carte interactive
|
||||||
|
</a>
|
||||||
|
<a href="https://bonpote.com/wp-content/uploads/2024/10/FRACAS_BONPOTE_CARTE_VERSO_V2-OCT2024.pdf"
|
||||||
|
target="_blank" rel="noopener"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text); font-size: 0.875rem; font-weight: 500; text-decoration: none;">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
Telecharger le poster PDF (recto/verso)
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
@click="setLayoutMode('split')"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity text-left"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text); font-size: 0.875rem; font-weight: 500; border: none; cursor: pointer;">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
Interroger le RAG ATIS sur ces pensees
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-bold uppercase tracking-widest mb-3" style="color: var(--nav-text-muted);">Les 10 ecoles de pensee (FRACAS V2)</p>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div v-for="ecole in (penseesData?.ecoles ?? [])" :key="ecole.id"
|
||||||
|
class="flex items-start gap-3 px-3 py-2 rounded-lg"
|
||||||
|
style="background: var(--nav-bg-alt);">
|
||||||
|
<span class="w-3 h-3 rounded-full shrink-0 mt-1" :style="`background:${ecole.color};`"></span>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold" style="color: var(--nav-text);">{{ ecole.label }}</p>
|
||||||
|
<p class="text-xs mt-0.5 leading-relaxed" style="color: var(--nav-text-muted);">{{ ecole.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Fiche auteur modal -->
|
||||||
|
<FicheAuteur
|
||||||
|
:open="ficheOpen"
|
||||||
|
:auteurId="ficheAuteurId"
|
||||||
|
:data="penseesData"
|
||||||
|
@close="ficheOpen = false"
|
||||||
|
@interroger-rag="onInterrogerRag"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Fiche ecole modal -->
|
||||||
|
<FicheEcole
|
||||||
|
:open="ficheEcoleOpen"
|
||||||
|
:ecoleId="ficheEcoleId"
|
||||||
|
:data="penseesData"
|
||||||
|
@close="ficheEcoleOpen = false"
|
||||||
|
@select-auteur="onSelectAuteurFromEcole"
|
||||||
|
@interroger-ecole="onInterrogerEcole"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Modal info RAG -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="backdrop">
|
||||||
|
<div v-if="ragInfoOpen" class="fixed inset-0 z-[2000]" style="background:rgba(26,34,56,0.55);" @click="ragInfoOpen = false" aria-hidden="true" />
|
||||||
|
</Transition>
|
||||||
|
<Transition name="modal">
|
||||||
|
<div v-if="ragInfoOpen" class="fixed z-[2001] left-1/2 flex flex-col"
|
||||||
|
style="top:50%;transform:translate(-50%,-50%);width:min(580px,94vw);max-height:85vh;background:var(--nav-bg);border-radius:14px;box-shadow:0 16px 64px rgba(26,34,56,0.28);overflow:hidden;"
|
||||||
|
role="dialog" aria-modal="true" aria-label="A propos du RAG FRACAS">
|
||||||
|
<div class="flex items-center justify-between px-5 py-4 shrink-0"
|
||||||
|
style="border-bottom:2px solid var(--nav-bg-alt);background:var(--nav-surface);">
|
||||||
|
<h2 class="font-bold text-base" style="color:var(--nav-text);">FRACAS - Bibliotheque des pensees ecologiques</h2>
|
||||||
|
<button @click="ragInfoOpen = false" class="ml-3 shrink-0 flex items-center justify-center w-8 h-8 rounded-full hover:opacity-70"
|
||||||
|
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);" aria-label="Fermer">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-y-auto px-5 py-4" style="color:var(--nav-text);font-size:0.875rem;line-height:1.6;">
|
||||||
|
<p class="mb-3">Une bibliotheque parlante politisee - des pensees ecologiques de gauche, organisees pour aider a creer une pensee complexe et nuancee, critiquer le recit dominant et soutenir des alternatives concretes et des projets collectifs.</p>
|
||||||
|
<p class="mb-4" style="color:var(--nav-text-muted);font-size:0.8rem;">Projet open source, ouvert a toutes et a tous - <a href="https://bonpote.com/la-carte-des-pensees-ecologiques/" target="_blank" rel="noopener" style="text-decoration:underline;">article + carte FRACAS Bonpote V2</a>.</p>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="p-3 rounded-lg" style="background:var(--nav-bg-alt);">
|
||||||
|
<p class="font-semibold mb-1" style="font-size:0.8rem;color:var(--nav-text-muted);text-transform:uppercase;letter-spacing:0.05em;">Ce qu'est un RAG</p>
|
||||||
|
<p>Les textes sont vectorises dans un espace de 662 dimensions - chaque livre devient un nuage de points semantiques. La proximite entre les points capture la proximite entre les idees, pas les mots.</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 rounded-lg" style="background:var(--nav-bg-alt);">
|
||||||
|
<p class="font-semibold mb-1" style="font-size:0.8rem;color:var(--nav-text-muted);text-transform:uppercase;letter-spacing:0.05em;">Chunking intelligent</p>
|
||||||
|
<p>Lors de l'ingestion, nous selectionnons les entites cles (concepts, auteurs, relations entre idees) plutot que de decouper mecaniquement les textes.</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 rounded-lg" style="background:var(--nav-bg-alt);">
|
||||||
|
<p class="font-semibold mb-2" style="font-size:0.8rem;color:var(--nav-text-muted);text-transform:uppercase;letter-spacing:0.05em;">Trois couches d'analyse</p>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<div class="flex gap-2"><span class="font-semibold" style="min-width:70px;">Fond</span><span>Les idees, les theses, les arguments - ce qu'on interroge directement.</span></div>
|
||||||
|
<div class="flex gap-2"><span class="font-semibold" style="min-width:70px;">Forme</span><span>Les modeles narratifs, la rhetorique, la construction argumentative.</span></div>
|
||||||
|
<div class="flex gap-2"><span class="font-semibold" style="min-width:70px;">Structure</span><span>L'architecture des livres - comment les auteurs construisent leur pensee.</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface EcoleData { id: string; label: string; description: string; color: string; x_hint: number; y_hint: number }
|
||||||
|
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
|
||||||
|
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
|
||||||
|
interface PenseesData { meta: any; ecoles: EcoleData[]; auteurs: AuteurData[] }
|
||||||
|
|
||||||
|
type LayoutMode = 'split' | 'carte-full' | 'chatbot-full' | 'bonpote'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'media-layout-mode'
|
||||||
|
const SPLIT_RATIO_KEY = 'media-split-ratio'
|
||||||
|
const DEFAULT_SPLIT_RATIO = 0.66
|
||||||
|
|
||||||
|
const ficheOpen = ref(false)
|
||||||
|
const ficheAuteurId = ref<string | null>(null)
|
||||||
|
const ficheEcoleOpen = ref(false)
|
||||||
|
const ficheEcoleId = ref<string | null>(null)
|
||||||
|
const ragInfoOpen = ref(false)
|
||||||
|
const chatbotAuteur = ref<string | null>(null)
|
||||||
|
const penseesData = ref<PenseesData | null>(null)
|
||||||
|
const layoutMode = ref<LayoutMode>('split')
|
||||||
|
const cartePenseesRef = ref<{ triggerResize: () => void } | null>(null)
|
||||||
|
|
||||||
|
// Ratio de la carte vs chatbot en mode split (0.2 a 0.8)
|
||||||
|
const splitRatio = ref(DEFAULT_SPLIT_RATIO)
|
||||||
|
const carteFlexBasis = computed(() => `${splitRatio.value * 100}%`)
|
||||||
|
const chatbotFlexBasis = computed(() => `${(1 - splitRatio.value) * 100}%`)
|
||||||
|
|
||||||
|
const corpusCount = computed(() => penseesData.value?.auteurs.filter((a: any) => a.ingere).length ?? 0)
|
||||||
|
const livresCount = computed(() => {
|
||||||
|
if (!penseesData.value) return 0
|
||||||
|
const slugs = new Set<string>()
|
||||||
|
penseesData.value.auteurs
|
||||||
|
.filter((a: any) => a.ingere)
|
||||||
|
.forEach((a: any) => (a.livres_rag ?? []).forEach((l: any) => slugs.add(l.slug)))
|
||||||
|
return slugs.size
|
||||||
|
})
|
||||||
|
|
||||||
|
// Logique poignee draggable
|
||||||
|
let dragStartY = 0
|
||||||
|
let dragStartRatio = DEFAULT_SPLIT_RATIO
|
||||||
|
let containerHeight = 0
|
||||||
|
|
||||||
|
function onHandleMousedown(e: MouseEvent) {
|
||||||
|
dragStartY = e.clientY
|
||||||
|
dragStartRatio = splitRatio.value
|
||||||
|
// Hauteur du layout-container (carte + handle + chatbot)
|
||||||
|
const container = (e.target as HTMLElement)?.closest('.layout-container') as HTMLElement | null
|
||||||
|
containerHeight = container ? container.clientHeight : window.innerHeight
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', onHandleMousemove)
|
||||||
|
window.addEventListener('mouseup', onHandleMouseup)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHandleMousemove(e: MouseEvent) {
|
||||||
|
const delta = e.clientY - dragStartY
|
||||||
|
const newRatio = dragStartRatio + delta / containerHeight
|
||||||
|
splitRatio.value = Math.min(0.80, Math.max(0.20, newRatio))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHandleMouseup() {
|
||||||
|
window.removeEventListener('mousemove', onHandleMousemove)
|
||||||
|
window.removeEventListener('mouseup', onHandleMouseup)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(SPLIT_RATIO_KEY, String(splitRatio.value))
|
||||||
|
}
|
||||||
|
// Notifier D3 du resize apres relachement
|
||||||
|
cartePenseesRef.value?.triggerResize()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY) as LayoutMode | null
|
||||||
|
if (saved && ['split', 'carte-full', 'chatbot-full', 'bonpote'].includes(saved)) {
|
||||||
|
layoutMode.value = saved
|
||||||
|
}
|
||||||
|
const savedRatio = parseFloat(localStorage.getItem(SPLIT_RATIO_KEY) ?? '')
|
||||||
|
if (!isNaN(savedRatio) && savedRatio >= 0.20 && savedRatio <= 0.80) {
|
||||||
|
splitRatio.value = savedRatio
|
||||||
|
}
|
||||||
|
// Afficher le popup info RAG a la premiere visite
|
||||||
|
if (!localStorage.getItem('rag-fracas-info-seen')) {
|
||||||
|
ragInfoOpen.value = true
|
||||||
|
localStorage.setItem('rag-fracas-info-seen', '1')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
penseesData.value = await $fetch<PenseesData>('/data/auteurs-pensees.json?v=4.2')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erreur chargement auteurs-pensees.json', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Persister + reset D3 apres transition
|
||||||
|
function setLayoutMode(mode: LayoutMode) {
|
||||||
|
layoutMode.value = mode
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(STORAGE_KEY, mode)
|
||||||
|
}
|
||||||
|
// Restart simulation D3 apres la fin de la transition CSS (300ms)
|
||||||
|
if (mode !== 'chatbot-full') {
|
||||||
|
setTimeout(() => {
|
||||||
|
cartePenseesRef.value?.triggerResize()
|
||||||
|
}, 350)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(layoutMode, (v) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(STORAGE_KEY, v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSelectAuteur(id: string) {
|
||||||
|
ficheAuteurId.value = id
|
||||||
|
ficheOpen.value = true
|
||||||
|
chatbotAuteur.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectEcole(id: string) {
|
||||||
|
ficheEcoleId.value = id
|
||||||
|
ficheEcoleOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectAuteurFromEcole(auteurId: string) {
|
||||||
|
ficheEcoleOpen.value = false
|
||||||
|
onSelectAuteur(auteurId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInterrogerEcole(ecoleId: string) {
|
||||||
|
ficheEcoleOpen.value = false
|
||||||
|
const ecole = penseesData.value?.ecoles.find(e => e.id === ecoleId)
|
||||||
|
chatbotAuteur.value = ecole?.label ?? null
|
||||||
|
if (layoutMode.value === 'carte-full') setLayoutMode('split')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInterrogerRag(auteurId: string) {
|
||||||
|
ficheOpen.value = false
|
||||||
|
const auteur = penseesData.value?.auteurs.find(a => a.id === auteurId)
|
||||||
|
chatbotAuteur.value = auteur?.nom ?? null
|
||||||
|
// Basculer en split pour que le chatbot soit visible
|
||||||
|
if (layoutMode.value === 'carte-full') {
|
||||||
|
setLayoutMode('split')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({ title: 'AEP - Media - Carte FRACAS Bonpote' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Page container : flex column, prend toute la hauteur viewport */
|
||||||
|
.media-page {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Conteneur des slots carte + toggle + chatbot */
|
||||||
|
.layout-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Slot carte --- */
|
||||||
|
.carte-slot {
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-split {
|
||||||
|
flex: 0 0 66%;
|
||||||
|
min-height: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-full {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
min-height: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-hidden {
|
||||||
|
flex: 0 0 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Barre de toggle --- */
|
||||||
|
.layout-toggle-bar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
border-top: 1px solid rgba(180, 170, 160, 0.22);
|
||||||
|
border-bottom: 1px solid rgba(180, 170, 160, 0.22);
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn:hover {
|
||||||
|
background: var(--nav-surface);
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn.active {
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
border-color: var(--nav-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Poignee draggable entre carte et chatbot --- */
|
||||||
|
.split-handle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: row-resize;
|
||||||
|
background: transparent;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-handle:hover {
|
||||||
|
background: rgba(180, 170, 160, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-handle-grip {
|
||||||
|
display: block;
|
||||||
|
width: 32px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(160, 150, 140, 0.55) 0px,
|
||||||
|
rgba(160, 150, 140, 0.55) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 3px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Masquer la poignee sur mobile (ratio fixe) */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.split-handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Slot chatbot --- */
|
||||||
|
.chatbot-slot {
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
border-top: 1px solid rgba(180, 170, 160, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-split {
|
||||||
|
flex: 0 0 34%;
|
||||||
|
min-height: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-full-mode {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
min-height: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-hidden {
|
||||||
|
flex: 0 0 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Transitions modal RAG info --- */
|
||||||
|
.backdrop-enter-active,.backdrop-leave-active { transition: opacity 0.2s; }
|
||||||
|
.backdrop-enter-from,.backdrop-leave-to { opacity: 0; }
|
||||||
|
.modal-enter-active { transition: opacity 0.2s, transform 0.22s cubic-bezier(0.34,1.56,0.64,1); }
|
||||||
|
.modal-leave-active { transition: opacity 0.18s, transform 0.18s ease-in; }
|
||||||
|
.modal-enter-from { opacity: 0; transform: translate(-50%,-48%) scale(0.94); }
|
||||||
|
.modal-leave-to { opacity: 0; transform: translate(-50%,-48%) scale(0.96); }
|
||||||
|
|
||||||
|
/* --- Responsive mobile (<768px) --- */
|
||||||
|
/* Stack vertical : carte 60vh + chatbot 40vh en mode split */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.carte-split {
|
||||||
|
flex: 0 0 60vh;
|
||||||
|
height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-split {
|
||||||
|
flex: 0 0 calc(40vh - 38px);
|
||||||
|
height: calc(40vh - 38px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn span,
|
||||||
|
.toggle-btn {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 3px 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
|
||||||
|
|
||||||
<!-- ZONE PRINCIPALE (pleine largeur, pas de sidebar) -->
|
|
||||||
<main class="flex-1 flex flex-col overflow-hidden relative">
|
|
||||||
|
|
||||||
<!-- Header onglet -->
|
|
||||||
<div class="shrink-0 px-5 py-3"
|
|
||||||
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
|
||||||
<h1 class="font-bold text-base" style="color: var(--nav-text);">Pensees Ecologiques</h1>
|
|
||||||
<p class="text-xs mt-0.5" style="color: var(--nav-text-muted);">
|
|
||||||
{{ corpusCount }} auteurs ingeres dans le RAG - carte FRACAS Bonpote V2
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Carte pensees (D3 force-directed) -->
|
|
||||||
<div class="flex-1 overflow-hidden relative">
|
|
||||||
<ClientOnly>
|
|
||||||
<CartePensees
|
|
||||||
:data="penseesData"
|
|
||||||
:active="true"
|
|
||||||
@select-auteur="onSelectAuteur"
|
|
||||||
/>
|
|
||||||
<template #fallback>
|
|
||||||
<div class="w-full h-full flex items-center justify-center" style="color: var(--nav-text-muted);">
|
|
||||||
Chargement de la carte...
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Fiche auteur modal -->
|
|
||||||
<FicheAuteur
|
|
||||||
:open="ficheOpen"
|
|
||||||
:auteurId="ficheAuteurId"
|
|
||||||
:data="penseesData"
|
|
||||||
@close="ficheOpen = false"
|
|
||||||
@interroger-rag="onInterrogerRag"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Chatbot flottant -->
|
|
||||||
<ChatbotPensees :auteurContext="chatbotAuteur" />
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
interface EcoleData { id: string; label: string; description: string; color: string; x_hint: number; y_hint: number }
|
|
||||||
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
|
|
||||||
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
|
|
||||||
interface PenseesData { meta: any; ecoles: EcoleData[]; auteurs: AuteurData[] }
|
|
||||||
|
|
||||||
const ficheOpen = ref(false)
|
|
||||||
const ficheAuteurId = ref<string | null>(null)
|
|
||||||
const chatbotAuteur = ref<string | null>(null)
|
|
||||||
const penseesData = ref<PenseesData | null>(null)
|
|
||||||
|
|
||||||
const corpusCount = computed(() => penseesData.value?.auteurs.length ?? 0)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
penseesData.value = await $fetch<PenseesData>('/data/auteurs-pensees.json')
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Erreur chargement auteurs-pensees.json', e)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function onSelectAuteur(id: string) {
|
|
||||||
ficheAuteurId.value = id
|
|
||||||
ficheOpen.value = true
|
|
||||||
chatbotAuteur.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInterrogerRag(auteurId: string) {
|
|
||||||
ficheOpen.value = false
|
|
||||||
const auteur = penseesData.value?.auteurs.find(a => a.id === auteurId)
|
|
||||||
chatbotAuteur.value = auteur?.nom ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
useHead({ title: 'AEP - Pensees Ecologiques - Carte FRACAS' })
|
|
||||||
</script>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col items-center justify-center h-full gap-6" style="background: var(--nav-bg);">
|
|
||||||
<div class="text-center max-w-md px-6">
|
|
||||||
<div
|
|
||||||
class="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-5"
|
|
||||||
style="background: var(--nav-bg-alt);"
|
|
||||||
>
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted);">
|
|
||||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
|
||||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
|
||||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-2xl font-bold mb-3" style="color: var(--nav-text);">RAG — Retrieval Augmented Generation</h1>
|
|
||||||
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text-muted);">
|
|
||||||
Une base de connaissances interrogeable par IA — textes, rapports, manifestes et ressources documentaires sur l'architecture d'écologie politique.
|
|
||||||
</p>
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-widest mb-6" style="color: var(--nav-text-muted); opacity: 0.6;">
|
|
||||||
Bientôt disponible
|
|
||||||
</p>
|
|
||||||
<NuxtLink
|
|
||||||
to="/"
|
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all hover:opacity-80"
|
|
||||||
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
|
||||||
<line x1="19" y1="12" x2="5" y2="12"/>
|
|
||||||
<polyline points="12 19 5 12 12 5"/>
|
|
||||||
</svg>
|
|
||||||
Retour à l'écosystème
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
useHead({ title: 'RAG — AEP (bientôt disponible)' })
|
|
||||||
</script>
|
|
||||||
File diff suppressed because it is too large
Load Diff
376
scripts/build_authors_v3.mjs
Normal file
376
scripts/build_authors_v3.mjs
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
// Build auteurs-pensees.json v3.0 — Phase 8.A
|
||||||
|
// Sync corpus JSON unifié : Bonpote authors + LightRAG ingestion flags
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const JSON_PATH = 'C:\\Users\\jules\\Dropbox\\ATIS - IPCJRA\\1 PROJETS\\TECH - infra VPS, website pro, RAG\\nav-carte\\public\\data\\auteurs-pensees.json';
|
||||||
|
|
||||||
|
// === LightRAG slug prefixes (from /documents endpoint 2026-05-12) ===
|
||||||
|
const LIGHTRAG_PREFIX_TO_AUTHOR_SLUG = {
|
||||||
|
bookchin: 'murray-bookchin',
|
||||||
|
brand: 'steward-brand',
|
||||||
|
carson: 'rachel-carson',
|
||||||
|
charbonneau: 'bernard-charbonneau',
|
||||||
|
descola: 'philippe-descola',
|
||||||
|
despret: 'vinciane-despret',
|
||||||
|
eaubonne: 'francoise-deaubonne',
|
||||||
|
ellul: 'jacques-ellul',
|
||||||
|
federici: 'silvia-federici',
|
||||||
|
ferdinand: 'malcolm-ferdinand',
|
||||||
|
figueres: 'christiana-figueres',
|
||||||
|
georgescu: 'nicholas-georgescu-roegen',
|
||||||
|
gorz: 'andre-gorz',
|
||||||
|
graeber: 'david-graeber',
|
||||||
|
keith: 'david-keith',
|
||||||
|
klein: 'naomi-klein',
|
||||||
|
kropotkine: 'pierre-kropotkine',
|
||||||
|
latouche: 'serge-latouche',
|
||||||
|
latour: 'bruno-latour',
|
||||||
|
lowy: 'michael-lowy',
|
||||||
|
malm: 'andreas-malm',
|
||||||
|
marx: 'karl-marx',
|
||||||
|
meadows: 'donella-meadows',
|
||||||
|
morizot: 'baptiste-morizot',
|
||||||
|
naess: 'arne-naess',
|
||||||
|
ouassak: 'fatima-ouassak',
|
||||||
|
reclus: 'elisee-reclus',
|
||||||
|
saito: 'kohei-saito',
|
||||||
|
servigne: 'pablo-servigne',
|
||||||
|
shiva: 'vandana-shiva',
|
||||||
|
stengers: 'isabelle-stengers',
|
||||||
|
vettese: 'troy-vettese',
|
||||||
|
};
|
||||||
|
|
||||||
|
const INGESTED_AUTHOR_SLUGS = new Set(Object.values(LIGHTRAG_PREFIX_TO_AUTHOR_SLUG));
|
||||||
|
|
||||||
|
// === Bonpote authors (nom, dates, ecole_principale, ecoles_secondaires[]) ===
|
||||||
|
const BONPOTE_AUTHORS = [
|
||||||
|
// Éco-anarchisme
|
||||||
|
['Pierre Kropotkine', '1842-1921', 'eco-anarchisme', []],
|
||||||
|
['Élisée Reclus', '1830-1905', 'eco-anarchisme', []],
|
||||||
|
['Murray Bookchin', '1921-2006', 'eco-anarchisme', []],
|
||||||
|
['David Graeber', '1961-2020', 'eco-anarchisme', []],
|
||||||
|
['James C. Scott', '1936-2024', 'eco-anarchisme', []],
|
||||||
|
['Marshall Sahlins', '1930-2021', 'eco-anarchisme', []],
|
||||||
|
['Pierre Clastres', '1934-1977', 'eco-anarchisme', []],
|
||||||
|
['Cornélius Castoriadis', '1922-1997', 'eco-anarchisme', []],
|
||||||
|
['David Harvey', '1935-', 'eco-anarchisme', ['ecosocialisme']],
|
||||||
|
['Henri Lefebvre', '1901-1991', 'eco-anarchisme', ['ecosocialisme']],
|
||||||
|
['Émile Gravelle', '1855-1920', 'eco-anarchisme', []],
|
||||||
|
['Henri Zisly', '1872-1945', 'eco-anarchisme', []],
|
||||||
|
['Edward Carpenter', '1844-1929', 'eco-anarchisme', []],
|
||||||
|
['William Morris', '1834-1896', 'eco-anarchisme', []],
|
||||||
|
['John Ruskin', '1819-1900', 'eco-anarchisme', []],
|
||||||
|
['Kirkpatrick Sale', '1937-', 'eco-anarchisme', []],
|
||||||
|
['Wendell Berry', '1934-', 'eco-anarchisme', []],
|
||||||
|
['Kristin Ross', '1953-', 'eco-anarchisme', []],
|
||||||
|
['Theodore Kaczynski', '1942-2023', 'eco-anarchisme', ['technocritique']],
|
||||||
|
['Saint-Simon', '1760-1825', 'eco-anarchisme', []],
|
||||||
|
['Auguste Comte', '1798-1857', 'eco-anarchisme', []],
|
||||||
|
['Alberto Magnaghi', '1941-2023', 'eco-anarchisme', []],
|
||||||
|
['Peter Berg', '1937-2011', 'eco-anarchisme', []],
|
||||||
|
['Andreas Malm', '1977-', 'ecosocialisme', ['eco-anarchisme']],
|
||||||
|
|
||||||
|
// Écosocialisme
|
||||||
|
['Karl Marx', '1818-1883', 'ecosocialisme', []],
|
||||||
|
['Friedrich Engels', '1820-1895', 'ecosocialisme', []],
|
||||||
|
['Rosa Luxemburg', '1871-1919', 'ecosocialisme', []],
|
||||||
|
['Walter Benjamin', '1892-1940', 'ecosocialisme', []],
|
||||||
|
['John Maynard Keynes', '1883-1946', 'ecosocialisme', []],
|
||||||
|
['Pascal Lamy', '1947-', 'ecosocialisme', []],
|
||||||
|
['Ann Pettifor', '1947-', 'ecosocialisme', []],
|
||||||
|
['Holly Jean Buck', '', 'ecosocialisme', []],
|
||||||
|
['Cédric Durand', '1975-', 'ecosocialisme', []],
|
||||||
|
['Kim Stanley Robinson', '1952-', 'ecosocialisme', []],
|
||||||
|
['André Gorz', '1923-2007', 'ecosocialisme', ['decroissance', 'technocritique']],
|
||||||
|
['Kohei Saito', '1987-', 'ecosocialisme', ['decroissance']],
|
||||||
|
['Razmig Keucheyan', '1975-', 'ecosocialisme', []],
|
||||||
|
['Dominique Méda', '1962-', 'ecosocialisme', []],
|
||||||
|
['Dominique Bourg', '1953-', 'ecosocialisme', []],
|
||||||
|
['Troy Vettese', '', 'ecosocialisme', []],
|
||||||
|
['Loïc Blondiaux', '1962-', 'ecosocialisme', []],
|
||||||
|
['Drew Pendergrass', '', 'ecosocialisme', []],
|
||||||
|
['Jason W. Moore', '', 'ecosocialisme', []],
|
||||||
|
["James O'Connor", '1930-2017', 'ecosocialisme', []],
|
||||||
|
['Herman Daly', '1938-2022', 'ecosocialisme', ['capitalisme-vert']],
|
||||||
|
['John Bellamy Foster', '1953-', 'ecosocialisme', []],
|
||||||
|
['Michael Löwy', '1938-', 'ecosocialisme', []],
|
||||||
|
['Joel Kovel', '1936-2018', 'ecosocialisme', []],
|
||||||
|
['Naomi Klein', '1970-', 'ecosocialisme', []],
|
||||||
|
|
||||||
|
// Technocritique
|
||||||
|
['Jacques Ellul', '1912-1994', 'technocritique', []],
|
||||||
|
['Bernard Charbonneau', '1910-1996', 'technocritique', []],
|
||||||
|
['Lewis Mumford', '1895-1990', 'technocritique', []],
|
||||||
|
['Alain Caillé', '1944-', 'technocritique', []],
|
||||||
|
['Hans Jonas', '1903-1993', 'technocritique', ['ethiques-environnementales']],
|
||||||
|
['Herbert Marcuse', '1898-1979', 'technocritique', []],
|
||||||
|
['Günther Anders', '1902-1992', 'technocritique', []],
|
||||||
|
['Pierre Fournier', '1937-1973', 'technocritique', []],
|
||||||
|
['Alexandre Grothendieck', '1928-2014', 'technocritique', []],
|
||||||
|
['Patrick Viveret', '1948-', 'technocritique', []],
|
||||||
|
['Philippe Bihouix', '1971-', 'technocritique', []],
|
||||||
|
['Jean Baudrillard', '1929-2007', 'technocritique', []],
|
||||||
|
['Serge Latouche', '1940-', 'decroissance', ['technocritique']],
|
||||||
|
['Ivan Illich', '1926-2002', 'technocritique', ['decroissance']],
|
||||||
|
['Leopold Kohr', '1909-1994', 'technocritique', ['decroissance']],
|
||||||
|
['Ernst Schumacher', '1911-1977', 'technocritique', ['decroissance']],
|
||||||
|
['Nicholas Georgescu-Roegen', '1906-1994', 'decroissance', ['technocritique']],
|
||||||
|
|
||||||
|
// Écoféminismes
|
||||||
|
["Françoise d'Eaubonne", '1920-2005', 'ecofeminismes', []],
|
||||||
|
['Vandana Shiva', '1952-', 'ecofeminismes', ['ecologies-decoloniales']],
|
||||||
|
['Starhawk', '1951-', 'ecofeminismes', []],
|
||||||
|
['Ariel Salleh', '1944-', 'ecofeminismes', []],
|
||||||
|
['Maria Mies', '1931-2023', 'ecofeminismes', []],
|
||||||
|
['Carolyn Merchant', '1936-', 'ecofeminismes', []],
|
||||||
|
['Silvia Federici', '1942-', 'ecofeminismes', []],
|
||||||
|
['Val Plumwood', '1939-2008', 'ecofeminismes', []],
|
||||||
|
['Susan Griffin', '1943-', 'ecofeminismes', []],
|
||||||
|
['Veronika Bennholdt-Thomsen', '1944-', 'ecofeminismes', []],
|
||||||
|
['Geneviève Pruvost', '1973-', 'ecofeminismes', []],
|
||||||
|
['Donna Haraway', '1944-', 'ecofeminismes', ['pensees-vivant']],
|
||||||
|
['Émilie Hache', '', 'ecofeminismes', []],
|
||||||
|
['Joanna Macy', '1929-', 'ecofeminismes', ['ethiques-environnementales']],
|
||||||
|
|
||||||
|
// Capitalisme vert
|
||||||
|
['Bill Gates', '1955-', 'capitalisme-vert', []],
|
||||||
|
['Christiana Figueres', '1956-', 'capitalisme-vert', []],
|
||||||
|
['Nicholas Stern', '1946-', 'capitalisme-vert', []],
|
||||||
|
['Jeffrey Sachs', '1954-', 'capitalisme-vert', []],
|
||||||
|
['Jared Diamond', '1937-', 'capitalisme-vert', ['decroissance']],
|
||||||
|
['Jørgen Randers', '1945-', 'capitalisme-vert', ['decroissance']],
|
||||||
|
['Donella Meadows', '1941-2001', 'decroissance', ['capitalisme-vert']],
|
||||||
|
['Dennis Meadows', '1942-', 'decroissance', ['capitalisme-vert']],
|
||||||
|
['Kate Raworth', '1970-', 'capitalisme-vert', []],
|
||||||
|
['Al Gore', '1948-', 'capitalisme-vert', []],
|
||||||
|
['Hal Harvey', '1960-', 'capitalisme-vert', []],
|
||||||
|
['Laurence Tubiana', '1951-', 'capitalisme-vert', []],
|
||||||
|
['Amory Lovins', '1947-', 'capitalisme-vert', []],
|
||||||
|
['David Pearce', '1959-', 'capitalisme-vert', []],
|
||||||
|
['Kerry Turner', '1948-', 'capitalisme-vert', []],
|
||||||
|
['David Keith', '1963-', 'capitalisme-vert', []],
|
||||||
|
['Ted Nordhaus', '1965-', 'capitalisme-vert', []],
|
||||||
|
['Michael Shellenberger', '1971-', 'capitalisme-vert', []],
|
||||||
|
['Pavan Sukhdev', '1960-', 'capitalisme-vert', []],
|
||||||
|
['Janine Benyus', '1958-', 'capitalisme-vert', []],
|
||||||
|
['Robert Costanza', '1950-', 'capitalisme-vert', []],
|
||||||
|
['Peter Kareiva', '1951-', 'capitalisme-vert', []],
|
||||||
|
['Michelle Marvier', '', 'capitalisme-vert', []],
|
||||||
|
['Robert Lalasz', '1915-2003', 'capitalisme-vert', []],
|
||||||
|
['Steward Brand', '1938-', 'capitalisme-vert', []],
|
||||||
|
['Paul Crutzen', '1933-2021', 'capitalisme-vert', []],
|
||||||
|
['Kenneth Boulding', '1910-1993', 'capitalisme-vert', []],
|
||||||
|
['Eugene Odum', '1913-2002', 'capitalisme-vert', []],
|
||||||
|
['Howard Odum', '1924-2002', 'capitalisme-vert', []],
|
||||||
|
['Jean-Marc Jancovici', '1962-', 'capitalisme-vert', []],
|
||||||
|
['Yves Cochet', '1946-', 'capitalisme-vert', ['decroissance']],
|
||||||
|
['Pablo Servigne', '1978-', 'decroissance', ['capitalisme-vert']],
|
||||||
|
['Gauthier Chapelle', '1968-', 'decroissance', ['capitalisme-vert']],
|
||||||
|
|
||||||
|
// Écologies décoloniales
|
||||||
|
['Malcom Ferdinand', '1985-', 'ecologies-decoloniales', []],
|
||||||
|
['Frantz Fanon', '1925-1961', 'ecologies-decoloniales', []],
|
||||||
|
['Édouard Glissant', '1928-2011', 'ecologies-decoloniales', []],
|
||||||
|
['Aimé Césaire', '1913-2008', 'ecologies-decoloniales', []],
|
||||||
|
['Mohamad Amer Meziane', '', 'ecologies-decoloniales', []],
|
||||||
|
['Chico Mendes', '1944-1988', 'ecologies-decoloniales', []],
|
||||||
|
['Joan Martínez Alier', '1939-', 'ecologies-decoloniales', []],
|
||||||
|
['Arturo Escobar', '1951-', 'ecologies-decoloniales', []],
|
||||||
|
['Sous-commandant Marcos', '1957-', 'ecologies-decoloniales', []],
|
||||||
|
['Alberto Acosta', '1948-', 'ecologies-decoloniales', []],
|
||||||
|
['Jérôme Baschet', '1960-', 'ecologies-decoloniales', []],
|
||||||
|
['Fatima Ouassak', '1976-', 'ecofeminismes', ['ecologies-decoloniales']],
|
||||||
|
['William Acker', '1991-', 'ecologies-decoloniales', []],
|
||||||
|
['Giorgos Kallis', '1972-', 'ecologies-decoloniales', ['decroissance']],
|
||||||
|
['Bernard Lambert', '1931-1984', 'ecologies-decoloniales', []],
|
||||||
|
|
||||||
|
// Écofascismes
|
||||||
|
['Alain de Benoist', '1943-', 'ecofascismes', []],
|
||||||
|
['Paul Ralph Ehrlich', '1932-', 'ecofascismes', []],
|
||||||
|
['Garrett Hardin', '1915-2003', 'ecofascismes', []],
|
||||||
|
['Edward Osborne Wilson', '1929-2021', 'ecofascismes', []],
|
||||||
|
['Thomas Malthus', '1803-1882', 'ecofascismes', []],
|
||||||
|
['David Foreman', '1946-2022', 'ecofascismes', []],
|
||||||
|
['Piero San Giorgio', '1971-', 'ecofascismes', []],
|
||||||
|
|
||||||
|
// Éthique environnementale
|
||||||
|
['Arne Næss', '1912-2009', 'ethiques-environnementales', []],
|
||||||
|
['Rachel Carson', '1907-1964', 'ethiques-environnementales', []],
|
||||||
|
['Aldo Leopold', '1887-1948', 'ethiques-environnementales', []],
|
||||||
|
['Imanishi Kinji', '1902-1992', 'ethiques-environnementales', []],
|
||||||
|
['Paul Watson', '1950-', 'ethiques-environnementales', []],
|
||||||
|
['John Muir', '1838-1914', 'ethiques-environnementales', []],
|
||||||
|
['Edward Abbey', '1927-1989', 'ethiques-environnementales', []],
|
||||||
|
['John Baird Callicott', '1941-', 'ethiques-environnementales', []],
|
||||||
|
['Bill Mollison', '1928-2016', 'ethiques-environnementales', []],
|
||||||
|
['David Holmgren', '1955-', 'ethiques-environnementales', []],
|
||||||
|
['Peter Singer', '1946-', 'ethiques-environnementales', []],
|
||||||
|
['Pierre Rabhi', '1938-2021', 'ethiques-environnementales', []],
|
||||||
|
['Rob Hopkins', '1968-', 'ethiques-environnementales', []],
|
||||||
|
['Cyril Dion', '1978-', 'ethiques-environnementales', []],
|
||||||
|
['Gandhi', '1869-1948', 'ethiques-environnementales', []],
|
||||||
|
['Gifford Pinchot', '1865-1946', 'ethiques-environnementales', []],
|
||||||
|
['Lanza del Vasto', '1901-1981', 'ethiques-environnementales', []],
|
||||||
|
['Jorge Mario Bergoglio', '1936-', 'ethiques-environnementales', []],
|
||||||
|
['Gary Snyder', '1930-', 'ethiques-environnementales', []],
|
||||||
|
['Henry David Thoreau', '1817-1862', 'ethiques-environnementales', []],
|
||||||
|
['Ralph Waldo Emerson', '1803-1882', 'ethiques-environnementales', []],
|
||||||
|
['José Bové', '1953-', 'ethiques-environnementales', []],
|
||||||
|
['Glenn Albrecht', '1953-', 'ethiques-environnementales', []],
|
||||||
|
|
||||||
|
// Pensées du vivant
|
||||||
|
['Bruno Latour', '1947-2022', 'pensees-vivant', []],
|
||||||
|
['Isabelle Stengers', '1949-', 'pensees-vivant', []],
|
||||||
|
['Vinciane Despret', '1959-', 'pensees-vivant', []],
|
||||||
|
['Baptiste Morizot', '1983-', 'pensees-vivant', []],
|
||||||
|
['Philippe Descola', '1949-', 'pensees-vivant', []],
|
||||||
|
['Eduardo Viveiros de Castro', '1951-', 'pensees-vivant', []],
|
||||||
|
['Anna Tsing', '1952-', 'pensees-vivant', []],
|
||||||
|
['Deborah Bird Rose', '1946-2018', 'pensees-vivant', []],
|
||||||
|
['Lynn Margulis', '1938-2011', 'pensees-vivant', []],
|
||||||
|
['James Lovelock', '1919-2022', 'pensees-vivant', []],
|
||||||
|
['Serge Moscovici', '1925-2014', 'pensees-vivant', []],
|
||||||
|
['Theodore Roszak', '1933-2011', 'pensees-vivant', []],
|
||||||
|
['Baruch Spinoza', '1632-1677', 'pensees-vivant', []],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Special slug overrides (match v2.1 IDs + ligatures)
|
||||||
|
const NAME_TO_SLUG_OVERRIDES = {
|
||||||
|
'Malcom Ferdinand': 'malcolm-ferdinand',
|
||||||
|
"Françoise d'Eaubonne": 'francoise-deaubonne',
|
||||||
|
'Donella Meadows': 'donella-meadows',
|
||||||
|
'Dennis Meadows': 'dennis-meadows',
|
||||||
|
'Arne Næss': 'arne-naess',
|
||||||
|
'Jørgen Randers': 'jorgen-randers',
|
||||||
|
};
|
||||||
|
|
||||||
|
function slugify(name) {
|
||||||
|
// Pre-process special ligatures and chars not handled by NFKD
|
||||||
|
let pre = name
|
||||||
|
.replace(/[æÆ]/g, 'ae')
|
||||||
|
.replace(/[øØ]/g, 'o')
|
||||||
|
.replace(/[œŒ]/g, 'oe')
|
||||||
|
.replace(/ß/g, 'ss');
|
||||||
|
// Remove diacritical marks
|
||||||
|
const noAccent = pre.normalize('NFKD').replace(/[̀-ͯ]/g, '');
|
||||||
|
return noAccent
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthorSlug(name) {
|
||||||
|
if (NAME_TO_SLUG_OVERRIDES[name]) return NAME_TO_SLUG_OVERRIDES[name];
|
||||||
|
return slugify(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const raw = fs.readFileSync(JSON_PATH, 'utf-8');
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
|
||||||
|
const existingBySlug = {};
|
||||||
|
for (const a of data.auteurs) existingBySlug[a.id] = a;
|
||||||
|
|
||||||
|
const newAuthors = [];
|
||||||
|
const seenSlugs = new Set();
|
||||||
|
|
||||||
|
for (const [nom, dates, ecolePrincipale, ecolesSecondaires] of BONPOTE_AUTHORS) {
|
||||||
|
const slug = getAuthorSlug(nom);
|
||||||
|
if (seenSlugs.has(slug)) {
|
||||||
|
console.error(`DUPLICATE SKIP: ${nom} -> ${slug}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenSlugs.add(slug);
|
||||||
|
|
||||||
|
const ingere = INGESTED_AUTHOR_SLUGS.has(slug);
|
||||||
|
const ecoles = [ecolePrincipale, ...ecolesSecondaires];
|
||||||
|
|
||||||
|
if (existingBySlug[slug]) {
|
||||||
|
// Preserve enriched entry
|
||||||
|
const entry = { ...existingBySlug[slug], ingere };
|
||||||
|
newAuthors.push(entry);
|
||||||
|
} else {
|
||||||
|
// New minimal entry
|
||||||
|
const bioProvisoire = ingere
|
||||||
|
? `Auteur·ice ingéré·e dans le RAG ATIS, bio à enrichir lors de PRG-5.`
|
||||||
|
: `Théoricien·ne présent·e sur le poster Bonpote (${ecolePrincipale}), non ingéré·e dans le RAG ATIS.`;
|
||||||
|
newAuthors.push({
|
||||||
|
id: slug,
|
||||||
|
nom,
|
||||||
|
dates,
|
||||||
|
ecoles,
|
||||||
|
ecole_principale: ecolePrincipale,
|
||||||
|
livres_rag: [],
|
||||||
|
theses_cles_attendues: [],
|
||||||
|
bio_courte_provisoire: bioProvisoire,
|
||||||
|
ingere,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve any v2.1 author not in Bonpote list
|
||||||
|
for (const [slug, entry] of Object.entries(existingBySlug)) {
|
||||||
|
if (!seenSlugs.has(slug)) {
|
||||||
|
const copy = { ...entry };
|
||||||
|
if (!('ingere' in copy)) copy.ingere = INGESTED_AUTHOR_SLUGS.has(slug);
|
||||||
|
newAuthors.push(copy);
|
||||||
|
seenSlugs.add(slug);
|
||||||
|
console.error(`NOTE: preserved v2.1 author not in Bonpote canonical: ${slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const auteursCount = newAuthors.length;
|
||||||
|
const auteursIngeresCount = newAuthors.filter(a => a.ingere).length;
|
||||||
|
|
||||||
|
data.meta.version = '3.0';
|
||||||
|
data.meta.updated = '2026-05-12';
|
||||||
|
data.meta.auteurs_count = auteursCount;
|
||||||
|
data.meta.auteurs_ingeres_count = auteursIngeresCount;
|
||||||
|
data.meta.source = 'FRACAS Bonpote V2 oct 2024 + LightRAG corpus 12/05/2026 (v3.0 sync)';
|
||||||
|
data.meta.note_v3_0 = 'Phase 8.A sync corpus unifie : ~140 auteurs Bonpote integres, flag ingere:true/false selon LightRAG VPS. Auteurs non-ingeres = entrees minimales (bio provisoire, livres_rag vide), a enrichir lors de PRG-4/PRG-5.';
|
||||||
|
|
||||||
|
data.auteurs = newAuthors;
|
||||||
|
|
||||||
|
fs.writeFileSync(JSON_PATH, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
// Validate parse-back
|
||||||
|
const parsedBack = JSON.parse(fs.readFileSync(JSON_PATH, 'utf-8'));
|
||||||
|
if (parsedBack.auteurs.length !== auteursCount) {
|
||||||
|
console.error('PARSE-BACK MISMATCH');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const schoolsStats = {};
|
||||||
|
for (const a of newAuthors) {
|
||||||
|
const ep = a.ecole_principale || '?';
|
||||||
|
if (!schoolsStats[ep]) schoolsStats[ep] = { total: 0, ingere: 0 };
|
||||||
|
schoolsStats[ep].total++;
|
||||||
|
if (a.ingere) schoolsStats[ep].ingere++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== JSON v3.0 written ===');
|
||||||
|
console.log(`Total auteurs : ${auteursCount}`);
|
||||||
|
console.log(`Ingeres : ${auteursIngeresCount}`);
|
||||||
|
console.log(`Non-ingeres : ${auteursCount - auteursIngeresCount}`);
|
||||||
|
console.log(`Parse-back : OK (${parsedBack.auteurs.length} auteurs)`);
|
||||||
|
console.log('\nPer school (ecole_principale):');
|
||||||
|
const sortedSchools = Object.entries(schoolsStats).sort((a, b) => b[1].total - a[1].total);
|
||||||
|
for (const [school, st] of sortedSchools) {
|
||||||
|
console.log(` ${school.padEnd(30)} total=${String(st.total).padStart(3)} ingere=${String(st.ingere).padStart(3)} non-ing=${String(st.total - st.ingere).padStart(3)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top 5 schools with most non-ingested
|
||||||
|
const nonIngStats = sortedSchools
|
||||||
|
.map(([k, v]) => [k, v.total - v.ingere])
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 5);
|
||||||
|
console.log('\nTop 5 ecoles avec le plus de non-ingeres (PRG-4 priorities):');
|
||||||
|
for (const [school, count] of nonIngStats) {
|
||||||
|
console.log(` ${school.padEnd(30)} non-ing=${count}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -1,19 +1,36 @@
|
|||||||
import type { H3Event } from 'h3'
|
import type { H3Event } from 'h3'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
|
||||||
interface ChatbotPenseesRequest {
|
interface ChatbotPenseesRequest {
|
||||||
query: string
|
query: string
|
||||||
mode?: 'hybrid' | 'local' | 'global' | 'naive' | 'mix'
|
mode?: 'hybrid' | 'local' | 'global' | 'naive' | 'mix'
|
||||||
|
corpus?: 'pensees' | 'projets' | 'both'
|
||||||
filter_couche?: 'fond' | 'forme' | 'structure' | null
|
filter_couche?: 'fond' | 'forme' | 'structure' | null
|
||||||
filter_ecole?: string | null
|
filter_ecole?: string | null
|
||||||
|
auteur_slug?: string | null
|
||||||
history?: Array<{ role: 'user' | 'assistant'; content: string }>
|
history?: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LightRAGReference {
|
||||||
|
reference_id?: string
|
||||||
|
file_path?: string
|
||||||
|
content?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
interface LightRAGQueryResponse {
|
interface LightRAGQueryResponse {
|
||||||
response: string
|
response: string
|
||||||
|
references?: LightRAGReference[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const SYSTEM_PREFACE = `Tu es un agent du RAG Pensées Écologiques, infrastructure militante du collectif trans-former.fr.
|
interface AuteurMini {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
ingere?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
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...).
|
||||||
|
|
||||||
Règles :
|
Règles :
|
||||||
@@ -23,6 +40,60 @@ Règles :
|
|||||||
- Réponse en français, dense, sans délayage.
|
- Réponse en français, dense, sans délayage.
|
||||||
- Distingue les positions selon les écoles quand elles divergent.`
|
- Distingue les positions selon les écoles quand elles divergent.`
|
||||||
|
|
||||||
|
const SYSTEM_PREFACE_PROJETS = `Tu es un agent du RAG Projets de Jules Nény (architecte, collectif trans-former.fr).
|
||||||
|
Tu réponds STRICTEMENT à partir des documents projet (fichiers butte-pinson__*.md et autres projets archi de Jules).
|
||||||
|
N'utilise PAS le corpus FRACAS Pensées Écologiques pour répondre, sauf si l'usager te le demande explicitement.
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
- Cite les sources (nom de projet, document) à chaque assertion importante.
|
||||||
|
- Si la question dépasse le corpus projet, dis-le clairement. Pas d'hallucination.
|
||||||
|
- Ton praticien réflexif : 1ère personne quand pertinent, narration située.
|
||||||
|
- Réponse en français, dense, sans délayage.`
|
||||||
|
|
||||||
|
const SYSTEM_PREFACE_BOTH = `Tu es un agent du RAG croisé Pensées x Projets de Jules Nény (architecte militant, collectif trans-former.fr).
|
||||||
|
CENTRE TA RÉPONSE sur les documents PROJETS (fichiers butte-pinson__*.md et autres projets archi).
|
||||||
|
Mobilise le corpus FRACAS Pensées (autres fichiers) UNIQUEMENT pour éclairer théoriquement les partis pris des projets, jamais l'inverse.
|
||||||
|
|
||||||
|
Pondération attendue : ~70% ancrage projet concret, ~30% éclairage théorique FRACAS.
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
- Cite les sources (auteur ou nom de projet, document) à chaque assertion.
|
||||||
|
- Si un thème n'est pas couvert par les projets, dis-le clairement avant d'éventuellement étendre au corpus Pensées.
|
||||||
|
- Pas d'hallucination, pas d'extrapolation hors corpus.
|
||||||
|
- Ton praticien militant : direct, pas neutre, ancré dans la pratique architecturale.
|
||||||
|
- Réponse en français, dense, sans délayage.`
|
||||||
|
|
||||||
|
function buildPrefaceAuteur(nomAuteur: string, slug: string): string {
|
||||||
|
return `Tu réponds EXCLUSIVEMENT depuis les livres de ${nomAuteur} présents dans le RAG (fichiers commençant par "${slug}__").
|
||||||
|
Si la question sort du périmètre de cet auteur, indique-le et propose de l'aborder sans le hashtag pour interroger la carte entière. Reste fidèle au style et à la pensée de ${nomAuteur}. Cite toujours le livre.
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
- Cite les sources (titre du livre) à chaque assertion.
|
||||||
|
- Pas d'hallucination. Si l'info n'est pas dans le corpus de cet auteur, dis-le.
|
||||||
|
- N'introduis JAMAIS d'autres auteurs sauf si ${nomAuteur} les commente explicitement.
|
||||||
|
- Ton politique direct, pas de neutralité fade.
|
||||||
|
- Réponse en français, dense, sans délayage.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chargement (et cache) de la liste des auteurs ingérés pour validation du slug
|
||||||
|
let auteursIngeresCache: AuteurMini[] | null = null
|
||||||
|
function loadAuteursIngeres(): AuteurMini[] {
|
||||||
|
if (auteursIngeresCache) return auteursIngeresCache
|
||||||
|
try {
|
||||||
|
const jsonPath = join(process.cwd(), 'public', 'data', 'auteurs-pensees.json')
|
||||||
|
const raw = readFileSync(jsonPath, 'utf-8')
|
||||||
|
const data = JSON.parse(raw)
|
||||||
|
const list: AuteurMini[] = (data.auteurs ?? [])
|
||||||
|
.filter((a: any) => a.ingere === true)
|
||||||
|
.map((a: any) => ({ id: String(a.id), nom: String(a.nom), ingere: true }))
|
||||||
|
auteursIngeresCache = list
|
||||||
|
return list
|
||||||
|
} catch {
|
||||||
|
auteursIngeresCache = []
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event: H3Event) => {
|
export default defineEventHandler(async (event: H3Event) => {
|
||||||
const config = useRuntimeConfig(event)
|
const config = useRuntimeConfig(event)
|
||||||
|
|
||||||
@@ -45,8 +116,30 @@ export default defineEventHandler(async (event: H3Event) => {
|
|||||||
|
|
||||||
const query = body.query.trim()
|
const query = body.query.trim()
|
||||||
const mode = body.mode || 'hybrid'
|
const mode = body.mode || 'hybrid'
|
||||||
|
const corpus = body.corpus || 'both'
|
||||||
const ragUrl = (config.ragPeUrl as string) || 'http://localhost:9621'
|
const ragUrl = (config.ragPeUrl as string) || 'http://localhost:9621'
|
||||||
|
|
||||||
|
// Validation auteur_slug (Phase 8.E) : match contre la liste des auteurs ingérés
|
||||||
|
const auteurSlug = body.auteur_slug?.trim().toLowerCase() || null
|
||||||
|
let nomAuteurMatch: string | null = null
|
||||||
|
if (auteurSlug) {
|
||||||
|
const ingeres = loadAuteursIngeres()
|
||||||
|
const auteur = ingeres.find(a => a.id === auteurSlug)
|
||||||
|
nomAuteurMatch = auteur?.nom ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Préface adaptative : auteur prioritaire si slug matché, sinon corpus
|
||||||
|
let systemPreface: string
|
||||||
|
if (auteurSlug && nomAuteurMatch) {
|
||||||
|
systemPreface = buildPrefaceAuteur(nomAuteurMatch, auteurSlug)
|
||||||
|
} else if (corpus === 'pensees') {
|
||||||
|
systemPreface = SYSTEM_PREFACE_PENSEES
|
||||||
|
} else if (corpus === 'projets') {
|
||||||
|
systemPreface = SYSTEM_PREFACE_PROJETS
|
||||||
|
} else {
|
||||||
|
systemPreface = SYSTEM_PREFACE_BOTH
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Health guard — LightRAG down = erreur claire, pas de fallback hallucinatoire
|
// 3. Health guard — LightRAG down = erreur claire, pas de fallback hallucinatoire
|
||||||
try {
|
try {
|
||||||
await $fetch(`${ragUrl}/health`, { timeout: 5000 })
|
await $fetch(`${ragUrl}/health`, { timeout: 5000 })
|
||||||
@@ -58,13 +151,22 @@ export default defineEventHandler(async (event: H3Event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Call LightRAG VPS — préface système injectée dans la query
|
// 4. Call LightRAG VPS — préface système injectée dans la query
|
||||||
const ragQuery = `${SYSTEM_PREFACE}\n\nQuestion : ${query}`
|
const ragQuery = `${systemPreface}\n\nQuestion : ${query}`
|
||||||
|
|
||||||
|
// Construction du body : hl_keywords + ll_keywords si auteur ciblé
|
||||||
|
// NB : LightRAG ne supporte ni keyword_filter ni ids ni metadata_filter (preflight OpenAPI confirmé).
|
||||||
|
// hl_keywords / ll_keywords sont les seuls leviers natifs de priorisation par auteur.
|
||||||
|
const ragBody: Record<string, unknown> = { query: ragQuery, mode }
|
||||||
|
if (auteurSlug && nomAuteurMatch) {
|
||||||
|
ragBody.hl_keywords = [nomAuteurMatch, auteurSlug]
|
||||||
|
ragBody.ll_keywords = [auteurSlug]
|
||||||
|
}
|
||||||
|
|
||||||
let ragResponse: LightRAGQueryResponse
|
let ragResponse: LightRAGQueryResponse
|
||||||
try {
|
try {
|
||||||
ragResponse = await $fetch<LightRAGQueryResponse>(`${ragUrl}/query`, {
|
ragResponse = await $fetch<LightRAGQueryResponse>(`${ragUrl}/query`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { query: ragQuery, mode },
|
body: ragBody,
|
||||||
timeout: 90000,
|
timeout: 90000,
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -75,10 +177,28 @@ export default defineEventHandler(async (event: H3Event) => {
|
|||||||
throw createError({ statusCode: 504, message: 'RAG en cours de processing — réessaie dans quelques secondes.' })
|
throw createError({ statusCode: 504, message: 'RAG en cours de processing — réessaie dans quelques secondes.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback post-process : si auteur ciblé et que les references LightRAG remontent
|
||||||
|
// des chunks hors slug__, on l'indique pour transparence. La préface LLM est la garde principale.
|
||||||
|
let chunksOffTarget = 0
|
||||||
|
let chunksOnTarget = 0
|
||||||
|
if (auteurSlug && nomAuteurMatch && Array.isArray(ragResponse.references)) {
|
||||||
|
const slugPrefix = `${auteurSlug}__`
|
||||||
|
for (const ref of ragResponse.references) {
|
||||||
|
const fp = (ref.file_path ?? '').toLowerCase()
|
||||||
|
if (!fp) continue
|
||||||
|
if (fp.startsWith(slugPrefix)) chunksOnTarget++
|
||||||
|
else chunksOffTarget++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 5. Retour formaté
|
// 5. Retour formaté
|
||||||
return {
|
return {
|
||||||
response: ragResponse.response ?? '',
|
response: ragResponse.response ?? '',
|
||||||
mode,
|
mode,
|
||||||
|
corpus,
|
||||||
|
auteur: auteurSlug && nomAuteurMatch ? { slug: auteurSlug, nom: nomAuteurMatch } : null,
|
||||||
|
auteur_unmatched: auteurSlug && !nomAuteurMatch ? auteurSlug : null,
|
||||||
|
auteur_chunks: auteurSlug && nomAuteurMatch ? { on_target: chunksOnTarget, off_target: chunksOffTarget } : null,
|
||||||
filter: { couche: body.filter_couche ?? null, ecole: body.filter_ecole ?? null },
|
filter: { couche: body.filter_couche ?? null, ecole: body.filter_ecole ?? null },
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,18 +82,18 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
|
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
|
||||||
|
|
||||||
const mistralApiKey = config.mistralApiKey as string
|
const nebiusApiKey = config.nebiusApiKey as string
|
||||||
if (!mistralApiKey) throw createError({ statusCode: 500, message: 'Clé API Mistral manquante.' })
|
if (!nebiusApiKey) throw createError({ statusCode: 500, message: 'Clé API Nebius manquante.' })
|
||||||
|
|
||||||
let mistralRaw: string
|
let mistralRaw: string
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
|
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
|
||||||
'https://api.mistral.ai/v1/chat/completions',
|
'https://api.tokenfactory.nebius.com/v1/chat/completions',
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
|
headers: { Authorization: `Bearer ${nebiusApiKey}`, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'mistral-small-latest',
|
model: 'deepseek-ai/DeepSeek-V3.2',
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 700,
|
max_tokens: 700,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
|
|||||||
@@ -91,20 +91,20 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
|
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
|
||||||
|
|
||||||
const mistralApiKey = config.mistralApiKey as string
|
const nebiusApiKey = config.nebiusApiKey as string
|
||||||
if (!mistralApiKey) {
|
if (!nebiusApiKey) {
|
||||||
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
|
throw createError({ statusCode: 500, statusMessage: 'Clé API Nebius manquante.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
let mistralRaw: string
|
let mistralRaw: string
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
|
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
|
||||||
'https://api.mistral.ai/v1/chat/completions',
|
'https://api.tokenfactory.nebius.com/v1/chat/completions',
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
|
headers: { Authorization: `Bearer ${nebiusApiKey}`, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'mistral-small-latest',
|
model: 'deepseek-ai/DeepSeek-V3.2',
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 700,
|
max_tokens: 700,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
|
|||||||
@@ -145,19 +145,22 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const systemPrompt = SYSTEM_PROMPT_V2.replace('{{CONTEXTE_RAG}}', contextStr)
|
const systemPrompt = SYSTEM_PROMPT_V2.replace('{{CONTEXTE_RAG}}', contextStr)
|
||||||
|
|
||||||
// 7. Mistral Small - génération réponse
|
// 7. Nebius DeepSeek-V3.2 - génération réponse
|
||||||
|
const nebiusApiKey = config.nebiusApiKey as string
|
||||||
|
if (!nebiusApiKey) throw createError({ statusCode: 500, statusMessage: 'Clé API Nebius manquante.' })
|
||||||
|
|
||||||
let mistralRaw: string
|
let mistralRaw: string
|
||||||
try {
|
try {
|
||||||
const mistralRes = await $fetch<{
|
const nebiusRes = await $fetch<{
|
||||||
choices: { message: { content: string } }[]
|
choices: { message: { content: string } }[]
|
||||||
}>('https://api.mistral.ai/v1/chat/completions', {
|
}>('https://api.tokenfactory.nebius.com/v1/chat/completions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${mistralApiKey}`,
|
Authorization: `Bearer ${nebiusApiKey}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'mistral-small-latest',
|
model: 'deepseek-ai/DeepSeek-V3.2',
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 600,
|
max_tokens: 600,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
@@ -167,10 +170,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
|
mistralRaw = nebiusRes.choices?.[0]?.message?.content ?? '{}'
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('[chatbot-v2] Erreur Mistral Small :', e?.message ?? e)
|
console.error('[chatbot-v2] Erreur Nebius DeepSeek :', e?.message ?? e)
|
||||||
throw createError({ statusCode: 502, statusMessage: 'Erreur appel Mistral Small.' })
|
throw createError({ statusCode: 502, statusMessage: 'Erreur appel Nebius DeepSeek.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Parse JSON
|
// 8. Parse JSON
|
||||||
|
|||||||
@@ -247,13 +247,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
JSON.stringify(fichesContext, null, 0),
|
JSON.stringify(fichesContext, null, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 6. Appel Mistral Small
|
// 6. Appel Nebius DeepSeek-V3.2
|
||||||
const mistralApiKey = config.mistralApiKey as string
|
const nebiusApiKey = config.nebiusApiKey as string
|
||||||
|
|
||||||
if (!mistralApiKey) {
|
if (!nebiusApiKey) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: 'Clé API Mistral manquante.',
|
statusMessage: 'Clé API Nebius manquante.',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,17 +262,17 @@ export default defineEventHandler(async (event) => {
|
|||||||
let tokensOut = 0
|
let tokensOut = 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mistralRes = await $fetch<{
|
const nebiusRes = await $fetch<{
|
||||||
choices: { message: { content: string } }[]
|
choices: { message: { content: string } }[]
|
||||||
usage?: { prompt_tokens: number; completion_tokens: number }
|
usage?: { prompt_tokens: number; completion_tokens: number }
|
||||||
}>('https://api.mistral.ai/v1/chat/completions', {
|
}>('https://api.tokenfactory.nebius.com/v1/chat/completions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${mistralApiKey}`,
|
Authorization: `Bearer ${nebiusApiKey}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'mistral-small-latest',
|
model: 'deepseek-ai/DeepSeek-V3.2',
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 600,
|
max_tokens: 600,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
@@ -283,11 +283,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
|
mistralRaw = nebiusRes.choices?.[0]?.message?.content ?? '{}'
|
||||||
tokensIn = mistralRes.usage?.prompt_tokens ?? 0
|
tokensIn = nebiusRes.usage?.prompt_tokens ?? 0
|
||||||
tokensOut = mistralRes.usage?.completion_tokens ?? 0
|
tokensOut = nebiusRes.usage?.completion_tokens ?? 0
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('[chatbot] Erreur Mistral Small:', e?.message ?? e)
|
console.error('[chatbot] Erreur Nebius DeepSeek:', e?.message ?? e)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 502,
|
statusCode: 502,
|
||||||
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
|
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
|
||||||
|
|||||||
Reference in New Issue
Block a user