From a2c0c78590f5a37b8179f1eda74aa8cd181346f1 Mon Sep 17 00:00:00 2001 From: Ladebeze66 Date: Thu, 23 Apr 2026 18:22:49 +0200 Subject: [PATCH] ajout_section_ia --- app/Competences/[slug]/[realisation]/page.tsx | 89 ++++++ app/Competences/[slug]/page.tsx | 268 +++++++++++++++++- app/Competences/page.jsx | 6 +- app/components/ContentSection.tsx | 60 +++- coffreobsidian | 1 + docs-site-interne/etat-actuel.md | 5 +- docs-site-interne/feuille-de-route.md | 3 +- ft_linear_regression | 1 + 8 files changed, 415 insertions(+), 18 deletions(-) create mode 100644 app/Competences/[slug]/[realisation]/page.tsx create mode 160000 coffreobsidian create mode 160000 ft_linear_regression diff --git a/app/Competences/[slug]/[realisation]/page.tsx b/app/Competences/[slug]/[realisation]/page.tsx new file mode 100644 index 0000000..1e5f9dc --- /dev/null +++ b/app/Competences/[slug]/[realisation]/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import ContentSection from "../../../components/ContentSection"; +import { getApiUrl } from "../../../utils/getApiUrl"; + +/** + * Page détail d'une réalisation liée à une compétence. + * + * Route : `/competences/[slug]/[realisation]` + * - `slug` : slug de la compétence parente (ex. `ia`). + * - `realisation` : slug de la réalisation (ex. `grasbot`, `newsletter-ia`…). + * + * Rendu : on réutilise intégralement `ContentSection` (même carousel Swiper, + * même prose Markdown Newsreader, même CTA jewel, même skeleton, même état + * 404) avec la collection Strapi `realisation-ias` et un bouton retour qui + * renvoie vers la page vignettes de la compétence parente plutôt que vers le + * portfolio. + * + * Le nom exact de la compétence parente est fetché pour personnaliser le + * kicker (ex. *"Réalisation · Mon exploration et maîtrise de l'IA"*). Si le + * fetch échoue, on tombe silencieusement sur un libellé générique + * *"Réalisation · Compétence"*. + */ +export default function RealisationDetailPage() { + const params = useParams(); + const competenceSlug = + typeof params?.slug === "string" ? params.slug : null; + const realisationSlug = + typeof params?.realisation === "string" ? params.realisation : null; + + const [competenceName, setCompetenceName] = useState(null); + const apiUrl = getApiUrl(); + + useEffect(() => { + if (!competenceSlug) return; + + let cancelled = false; + + async function fetchCompetenceName() { + try { + const res = await fetch( + `${apiUrl}/api/competences?filters[slug][$eq]=${encodeURIComponent( + competenceSlug! + )}` + ); + if (!res.ok) return; + const data = await res.json(); + const name: string | undefined = data?.data?.[0]?.name; + if (!cancelled && name) { + setCompetenceName(name); + } + } catch { + // silencieux : le kicker restera générique + } + } + + fetchCompetenceName(); + + return () => { + cancelled = true; + }; + }, [apiUrl, competenceSlug]); + + if (!competenceSlug || !realisationSlug) { + return ( +
+

⏳ Chargement...

+
+ ); + } + + const backHref = `/competences/${competenceSlug}`; + const kickerLabel = competenceName + ? `Réalisation · ${competenceName}` + : "Réalisation · Compétence"; + + return ( + + ); +} diff --git a/app/Competences/[slug]/page.tsx b/app/Competences/[slug]/page.tsx index cf8da8d..fd7616a 100644 --- a/app/Competences/[slug]/page.tsx +++ b/app/Competences/[slug]/page.tsx @@ -2,21 +2,277 @@ import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; +import Link from "next/link"; +import { getApiUrl } from "../../utils/getApiUrl"; +import VignetteCarousel from "../../components/VignetteCarousel"; import ContentSectionCompetencesContainer from "../../components/ContentSectionCompetencesContainer"; +/** + * Page détail d'une compétence — double rendu selon le contenu Strapi. + * + * Cas 1 — la compétence est liée à une ou plusieurs `realisation-ia` : + * on affiche une **grille de vignettes** alignée sur le pattern éditorial + * de `app/portfolio/page.jsx` (grille asymétrique 2/3 + 1/3 alternée). Chaque + * vignette est cliquable (lien externe si le champ `link` de la réalisation + * est renseigné, sinon lien interne vers la future page détail + * `/competences/[slug]/[realisation]` — pour l'instant 404 tant que cette + * route n'est pas ajoutée, ce qui est attendu pour l'étape de test du listing). + * + * Cas 2 — aucune réalisation liée : + * on conserve le rendu historique `ContentSectionCompetencesContainer` qui + * affiche le `content` richtext de la compétence. Les compétences Web / 3D + * restent donc strictement inchangées tant qu'on ne leur associe pas de + * réalisation côté Strapi. + * + * Endpoint Strapi utilisé : `/api/realisation-ias` (pluralName par défaut + * Strapi 5 pour un singularName `realisation-ia`). Filtre sur le slug de la + * compétence parente via la relation `competences` (many-to-many d'après + * le content-type créé côté admin). + */ +const spanPattern = ["md:col-span-4", "md:col-span-2", "md:col-span-2", "md:col-span-4"]; + +type Realisation = { + id: number; + name: string; + slug?: string; + description?: string; + link?: string; + order?: number; + picture?: Array<{ url?: string; name?: string }>; +}; + +type Competence = { + id: number; + name?: string; + slug?: string; + content?: string; +}; + export default function CompetencePage() { const params = useParams(); - const [slug, setSlug] = useState(null); + const slug = typeof params?.slug === "string" ? params.slug : null; + + const [competence, setCompetence] = useState(null); + const [realisations, setRealisations] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const apiUrl = getApiUrl(); useEffect(() => { - if (params?.slug) { - setSlug(params.slug as string); + if (!slug) return; + + let cancelled = false; + + async function fetchData() { + setIsLoading(true); + + try { + // 1. Compétence parente (pour le titre de la page vignettes). + const resCompet = await fetch( + `${apiUrl}/api/competences?filters[slug][$eq]=${encodeURIComponent( + slug! + )}&populate=*` + ); + const dataCompet = resCompet.ok ? await resCompet.json() : null; + const fetchedCompetence: Competence | null = + dataCompet?.data?.[0] ?? null; + + // 2. Réalisations IA liées à cette compétence. + // Si l'endpoint n'existe pas encore côté Strapi (404), on bascule + // silencieusement sur le rendu historique au lieu de crasher. + let fetchedRealisations: Realisation[] = []; + const resReal = await fetch( + `${apiUrl}/api/realisation-ias?filters[competences][slug][$eq]=${encodeURIComponent( + slug! + )}&populate=picture&sort=order:asc` + ); + if (resReal.ok) { + const dataReal = await resReal.json(); + fetchedRealisations = (dataReal?.data ?? []).sort( + (a: Realisation, b: Realisation) => + (a.order ?? 999) - (b.order ?? 999) + ); + } else if (resReal.status !== 404) { + // 404 = content-type absent, cas légitime → on log seulement les + // vraies erreurs HTTP (500, 403, etc.). + console.warn( + `⚠️ [competences/${slug}] realisation-ias HTTP ${resReal.status}` + ); + } + + if (!cancelled) { + setCompetence(fetchedCompetence); + setRealisations(fetchedRealisations); + } + } catch (err) { + if (!cancelled) { + console.error("❌ [competences/[slug]] Erreur fetch :", err); + setRealisations([]); + } + } finally { + if (!cancelled) setIsLoading(false); + } } - }, [params]); + + fetchData(); + + return () => { + cancelled = true; + }; + }, [apiUrl, slug]); if (!slug) { - return
⏳ Chargement...
; + return ( +
+

⏳ Chargement...

+
+ ); } - return ; + // Squelette commun pendant le premier fetch (avant de savoir s'il y a des vignettes ou pas). + if (isLoading) { + return ( +
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, idx) => ( +
+
+
+
+
+ ))} +
+
+ ); + } + + // Pas de réalisations associées → rendu historique (toutes les compétences hors IA). + if (!realisations || realisations.length === 0) { + return ( + + ); + } + + // Rendu vignettes. + return ( +
+
+
+ + + Retour aux compétences + + + Compétence · Réalisations + +

+ {competence?.name ?? "Compétence"} +

+

+ Une sélection de réalisations qui illustrent cette compétence en + contexte — ouvrez une vignette pour en voir le détail. +

+
+
+ +
+ {realisations.map((realisation, idx) => { + const pictures = realisation.picture ?? []; + const images = pictures.map((img) => ({ + url: img?.url ? `${apiUrl}${img.url}` : "/placeholder.jpg", + alt: img?.name || `Visuel de la réalisation ${realisation.name}`, + })); + const firstImage = images[0]; + + // Comportement voulu : la vignette renvoie TOUJOURS vers la fiche + // détail interne (`/competences/[slug]/[realisation]`). Le lien + // externe `realisation.link` est exposé en tant que CTA jewel en bas + // de la fiche détail par `ContentSection`, pas en court-circuit + // depuis la vignette. Cohérent avec le comportement des fiches + // `project` du portfolio. + const href = realisation.slug + ? `/competences/${slug}/${realisation.slug}` + : "#"; + + return ( + +
+ {images.length > 1 ? ( + + ) : firstImage ? ( + {firstImage.alt} + ) : ( +
+ +
+ )} +
+ +
+ + Réalisation + +

+ {realisation.name} +

+ {realisation.description && ( +

+ {realisation.description} +

+ )} + + + Découvrir + + +
+ + ); + })} +
+
+ ); } diff --git a/app/Competences/page.jsx b/app/Competences/page.jsx index 203aa78..62759c6 100644 --- a/app/Competences/page.jsx +++ b/app/Competences/page.jsx @@ -33,8 +33,12 @@ export default function Page() { const data = await response.json(); + // Strapi v4 : `attributes.order` — Strapi v5 (souvent) : `order` à la racine. + const getOrder = (item) => + item?.order ?? item?.attributes?.order ?? 999; + const sortedCompetences = (data.data ?? []).sort( - (a, b) => ((a.attributes?.order ?? 999) - (b.attributes?.order ?? 999)) + (a, b) => getOrder(a) - getOrder(b) ); setCompetences(sortedCompetences); diff --git a/app/components/ContentSection.tsx b/app/components/ContentSection.tsx index d79f539..fb2a531 100644 --- a/app/components/ContentSection.tsx +++ b/app/components/ContentSection.tsx @@ -19,7 +19,18 @@ interface ImageData { interface ContentData { name: string; - Resum: string; + /** + * Champ richtext Markdown de la fiche. + * + * Dette historique : le content-type Strapi `project` utilise `Resum` avec + * majuscule (legacy Strapi 4). Les nouveaux content-types (ex. + * `realisation-ia`) utilisent `resum` en minuscule, cohérent avec tous les + * autres champs (`name`, `slug`, `link`, `order`…). On tolère les deux + * orthographes dans le rendu pour ne pas avoir à renommer `Resum` côté + * `project` (ce qui casserait les 15+ fiches projet déjà saisies). + */ + Resum?: string; + resum?: string; picture?: ImageData[]; link?: string; linkText?: string; @@ -30,6 +41,26 @@ interface ContentSectionProps { slug: string; titleClass?: string; contentClass?: string; + /** + * Lien du bouton retour discret posé en haut de la page. + * Défaut : `/portfolio` (comportement historique pour les fiches projet). + */ + backHref?: string; + /** + * Libellé du bouton retour. Défaut : `"Portfolio"`. + */ + backLabel?: string; + /** + * Kicker affiché au-dessus du titre dans l'en-tête vellum. + * Défaut : `"Projet · Portfolio"` (fiches du portfolio). + * Exemple pour une réalisation de compétence : `"Réalisation · Compétence IA"`. + */ + kickerLabel?: string; + /** + * Message affiché dans l'état 404 (fiche introuvable). + * Défaut : `"Ce projet est introuvable."` + */ + notFoundLabel?: string; } /** @@ -41,10 +72,21 @@ interface ContentSectionProps { * héritées du composant pré-refonte restent acceptées pour compatibilité mais * sont ignorées (styles tokenisés désormais) — on les garde dans l'interface * pour ne pas casser les consommateurs. + * + * 2026-04-23 : composant rendu paramétrable pour être réutilisé par la page + * détail des réalisations IA (`/competences/[slug]/[realisation]`) avec un + * retour vers la compétence parente au lieu du portfolio. Les 4 props + * `backHref` / `backLabel` / `kickerLabel` / `notFoundLabel` ont des défauts + * strictement identiques au comportement historique → 100 % rétro-compatible + * pour les fiches projet existantes. */ export default function ContentSection({ collection, slug, + backHref = "/portfolio", + backLabel = "Portfolio", + kickerLabel = "Projet · Portfolio", + notFoundLabel = "Ce projet est introuvable.", }: ContentSectionProps) { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -89,10 +131,10 @@ export default function ContentSection({ search_off

- Ce projet est introuvable. + {notFoundLabel}

arrow_back - Retour au portfolio + Retour
); } - const { name, Resum: richText, picture, link, linkText } = data; + const { name, picture, link, linkText } = data; + // Legacy `Resum` (content-type `project`) OU `resum` moderne (nouveaux content-types). + const richText = data.Resum ?? data.resum ?? ""; const images = picture?.map((img: ImageData) => ({ @@ -121,7 +165,7 @@ export default function ContentSection({
{/* Bouton retour discret, posé sur le wallpaper comme une miette de fil d'Ariane. */} arrow_back - Portfolio + {backLabel} {/* En-tête "feuillet de vellum" aligné sur la home et les listes. */} @@ -141,7 +185,7 @@ export default function ContentSection({ >
- Projet · Portfolio + {kickerLabel}

` local mais dispatch `window.dispatchEvent(new CustomEvent("grasbot:open"))` capté par le FAB global (7.e). Container refait avec skeleton vellum à la place de `⏳ Chargement...`. **7.d** : `ChatBot.js` entièrement restylé (carte vellum `rounded-sheet shadow-ambient backdrop-blur-vellum`, header primary avec Material Symbol `smart_toy` + sous-titre « Assistant IA locale », bulles user `bg-primary text-white` et bot `bg-surface-container`, input Stitch avec `focus-visible:ring-primary`, bouton envoyer rond jewel Material Symbol `send`, auto-scroll, focus auto, envoi Enter, disabled pendant attente, message d'accueil vide éditorial). **7.e** : nouveau composant `app/components/GrasBotFab.tsx` — FAB jewel `fixed bottom-6 right-6 z-30` rond 56/64 px, `bg-primary shadow-jewel` Material Symbol `smart_toy`/`close`, monté dans `app/layout.tsx` → chatbot accessible depuis **toutes les pages** (plus seulement fiches compétences). Écoute `CustomEvent("grasbot:open")` dispatché depuis le keyword « IA locale ». Fermeture Escape globale, panneau responsive plein largeur mobile / 384 px desktop. Détails dans `REFONTE-VISUELLE.md` §7. | | 2026-04-22 | Refonte visuelle — **étape 6 : listes portfolio + compétences**. `app/portfolio/page.jsx` et `app/competences/page.jsx` entièrement réécrits. En-tête éditorial (kicker + titre Manrope extrabold + pitch Newsreader) cohérent avec le hero de la home. Grille **asymétrique 2/3 + 1/3** alternée (`md:grid-cols-6` + pattern de `col-span-4`/`col-span-2` sur modulo 4, `sm:grid-cols-2`, `grid-cols-1` mobile) — conforme DESIGN.md §6 "No-Grid-Lock". Cartes « feuillet vellum » alignées home : `rounded-sheet bg-surface-container-lowest/85 backdrop-blur-vellum shadow-ambient`, image `aspect-[4/3]` fixe avec `group-hover:scale-[1.03]`, titre `text-primary`, description `line-clamp-3` en Newsreader, CTA tertiaire « Découvrir → » / « Explorer → » avec Material Symbol `arrow_forward` qui se décale au hover (`translate="no"` appliqué). Hover : `hover:-translate-y-0.5 hover:shadow-jewel` (remplace le `scale-105` qui débordait). **`Swiper` retiré des vignettes de liste** (arbitrage acté § 2 : carousel réservé aux galeries intra-fiche) — une seule image par carte, `loading="lazy"`. États ajoutés : skeletons animés respectant la grille + état vide avec Material Symbol. Régressions corrigées au passage : largeur fixe `w-80` qui débordait sur S25 Ultra, `hover:scale-105` qui tapait sous le header, classes `bg-white/80 rounded-lg` remplacées par les tokens Stitch. Les composants `Carousel.tsx` et `CarouselCompetences.tsx` restent en place pour les fiches détail (étape 7). Détail dans `REFONTE-VISUELLE.md` §6. | +| 2026-04-23 | **GrasBot — tuning pipeline LLM + anti-hallucinations**. Audit des premières traces Langfuse : questions biographiques hallucinées (âge erroné, statut inventé), réponses longues tronquées. Quatre ajustements : (1) `llm-api/search.py` · `generate()` — `num_ctx=8192` explicite (stoppe la troncature silencieuse du prompt par le défaut Ollama 2048/4096 quand plusieurs notes entières sont injectées), `num_predict` 512 → 1024 (réponses longues complètes), `think: false` top-level (désactive le *thinking mode* de qwen3 qui consommait du budget de sortie). (2) `llm-api/search.py` · `build_prompt()` — troncature conditionnelle des sources rank 2+ via `_truncate_body()` + nouvelles variables `SEARCH_SECONDARY_MAX_CHARS` (1500) / `SEARCH_SECONDARY_KEEP_RATIO` (0.8). Aucune source n'est supprimée, seules celles dont le score est < 0.8 × score(#1) ET dont le body dépasse 1500 chars sont résumées. Loggé dans `prompt_build.metadata.truncation`. (3) Vault — nouvelle note `vault-grasbot/30-Parcours/bio-fernand.md` courte et factuelle (priority 10, aliases biographiques courts), canonique pour les questions du type *« qui est Fernand »*. Renvoie vers le CV complet pour le détail. Correction incohérence d'âge dans le CV (46 → 47 ans dans la section Présentation) qui alimentait les hallucinations. (4) `SYSTEM_PROMPT` — nouveau bloc *Règles de fidélité aux sources* : priorité `type=parcours` pour questions bio, interdiction d'inventer des faits factuels, gestion explicite des contradictions, signalement des notes tronquées. **Bascule Langfuse v4 → v3 dans `requirements.txt`** (`langfuse>=3.0,<4`) : le SDK v4 a supprimé `start_as_current_span`, la v3 reste compatible avec l'instrumentation existante. Dépendances Python ajoutées : `langfuse`, `python-dotenv`. Secrets Langfuse déplacés de `.env.local` Next vers `llm-api/.env` (non committé). Doc mise à jour : [`langfuse-observability.md`](./langfuse-observability.md) (nouvelle section *Tuning du pipeline — 2026-04-23*), `CONFIGURATION_SITE.md` (endpoints `/health` + `/reload-vault`), `etat-actuel.md` (42 notes + mention Langfuse). | | 2026-04-22 | **GrasBot v3 — bascule RAG vectoriel → retrieval graph + BM25**. Essais d'installation Windows bloqués par `chroma-hnswlib` (compilation C++ requise) et freezes RDP à chaque chargement de `qwen3:8b` + `nomic-embed-text` simultanément. Arbitrage : pour un vault de 40 notes, la RAG vectorielle sur-dimensionne ; on exploite directement la structure Obsidian (frontmatter, wikilinks, MOCs). **Nouveau pipeline** dans `llm-api/search.py` (scoring multi-signaux : aliases / titre-slug / answers / domains / tags / BM25 ; expansion par graphe via `linked`/`related`/wikilinks ; tokenizer FR avec normalisations `c++` → `cpp`, split `-`/`_`). **Déterministe, traçable (champ `reasons` dans les sources), 50 ms de retrieval**. Scoring calibré sur 12 cas (IA, push-swap, LLMs pluriel, hors-sujet clafoutis → `(aucun)`, etc.). **Dépendances allégées** : fini `chromadb`, `chroma-hnswlib`, `nomic-embed-text`. `requirements.txt` = fastapi + uvicorn + requests + pyyaml uniquement. Fichiers supprimés : `llm-api/rag.py`, `llm-api/index_vault.py`, `chroma-index/` (marqué pour suppression, verrouillé par Cursor au moment du cleanup — sera supprimé au reboot). **Vault enrichi** : `build-vault.py` étendu pour générer automatiquement `aliases` (à partir du slug/titre + `DOMAIN_ALIASES`), `answers` (questions-types adaptées au type de note), `priority` (heuristique CV=10, MOCs=7, compétences=7, projets=5). Note CV curatée (`source: manual`) enrichie manuellement avec 12 aliases et 7 answers. Nouvelle `vault-grasbot/TAXONOMIE.md` qui documente le vocabulaire contrôlé. Réécriture de `vault-grasbot/50-Technique/grasbot-rag.md` → `grasbot-retrieval.md` (nouveau pipeline), + `architecture-site.md` + `vault-structure.md` + `MOC-Technique.md`. Nouveau endpoint `POST /reload-vault` pour recharger sans redémarrer uvicorn. Documentation interne refaite : [`04-api-llm-et-chatbot.md`](./04-api-llm-et-chatbot.md), [`06-strapi-extraction.md`](./06-strapi-extraction.md), nouveau [`08-vault-obsidian-retrieval.md`](./08-vault-obsidian-retrieval.md) (remplace `08-vault-obsidian-rag.md`). | diff --git a/ft_linear_regression b/ft_linear_regression new file mode 160000 index 0000000..a561662 --- /dev/null +++ b/ft_linear_regression @@ -0,0 +1 @@ +Subproject commit a5616622a3e4ac6b4b44d57c3b2c150f33476255