ajout_section_ia

This commit is contained in:
Ladebeze66 2026-04-23 18:22:49 +02:00
parent a0e59442f4
commit a2c0c78590
8 changed files with 415 additions and 18 deletions

View File

@ -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<string | null>(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 (
<div className="mx-auto max-w-6xl px-4 py-10 text-center text-on-surface-variant">
<p className="font-body italic"> Chargement...</p>
</div>
);
}
const backHref = `/competences/${competenceSlug}`;
const kickerLabel = competenceName
? `Réalisation · ${competenceName}`
: "Réalisation · Compétence";
return (
<ContentSection
collection="realisation-ias"
slug={realisationSlug}
backHref={backHref}
backLabel="Réalisations"
kickerLabel={kickerLabel}
notFoundLabel="Cette réalisation est introuvable."
/>
);
}

View File

@ -2,21 +2,277 @@
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useEffect, useState } from "react"; 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"; 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() { export default function CompetencePage() {
const params = useParams(); const params = useParams();
const [slug, setSlug] = useState<string | null>(null); const slug = typeof params?.slug === "string" ? params.slug : null;
const [competence, setCompetence] = useState<Competence | null>(null);
const [realisations, setRealisations] = useState<Realisation[] | null>(null);
const [isLoading, setIsLoading] = useState(true);
const apiUrl = getApiUrl();
useEffect(() => { useEffect(() => {
if (params?.slug) { if (!slug) return;
setSlug(params.slug as string);
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}`
);
} }
}, [params]);
if (!cancelled) {
setCompetence(fetchedCompetence);
setRealisations(fetchedRealisations);
}
} catch (err) {
if (!cancelled) {
console.error("❌ [competences/[slug]] Erreur fetch :", err);
setRealisations([]);
}
} finally {
if (!cancelled) setIsLoading(false);
}
}
fetchData();
return () => {
cancelled = true;
};
}, [apiUrl, slug]);
if (!slug) { if (!slug) {
return <div className="text-center text-gray-500"> Chargement...</div>; return (
<div className="mx-auto max-w-6xl px-4 py-10 text-center text-on-surface-variant">
<p className="font-body italic"> Chargement...</p>
</div>
);
} }
return <ContentSectionCompetencesContainer collection="competences" slug={slug} />; // Squelette commun pendant le premier fetch (avant de savoir s'il y a des vignettes ou pas).
if (isLoading) {
return (
<div className="mx-auto flex w-full min-w-0 max-w-6xl flex-col gap-5 px-4 pb-10 sm:px-6">
<section className="rounded-sheet bg-surface-container-lowest/60 p-6 shadow-ambient-sm backdrop-blur-vellum sm:p-8">
<div className="h-3 w-24 animate-pulse rounded-full bg-surface-container-low/80" />
<div className="mt-4 h-8 w-2/3 animate-pulse rounded-full bg-surface-container-low/80" />
<div className="mt-3 h-4 w-full animate-pulse rounded-full bg-surface-container-low/60" />
</section>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-6">
{Array.from({ length: 4 }).map((_, idx) => (
<div
key={idx}
className={`${spanPattern[idx % spanPattern.length]} rounded-sheet bg-surface-container-lowest/60 p-5 shadow-ambient-sm backdrop-blur-vellum`}
>
<div className="aspect-[4/3] w-full animate-pulse rounded-tile bg-surface-container-low/80" />
<div className="mt-4 h-5 w-2/3 animate-pulse rounded-full bg-surface-container-low/80" />
<div className="mt-2 h-4 w-full animate-pulse rounded-full bg-surface-container-low/60" />
</div>
))}
</div>
</div>
);
}
// Pas de réalisations associées → rendu historique (toutes les compétences hors IA).
if (!realisations || realisations.length === 0) {
return (
<ContentSectionCompetencesContainer collection="competences" slug={slug} />
);
}
// Rendu vignettes.
return (
<div className="mx-auto flex w-full min-w-0 max-w-6xl flex-col gap-5 px-4 pb-10 sm:px-6">
<section
className="rounded-sheet bg-surface-container-lowest/85 p-5 shadow-ambient backdrop-blur-vellum sm:p-7 md:p-8"
aria-labelledby="realisations-title"
>
<div className="flex flex-col gap-3 text-center md:text-left">
<Link
href="/competences"
className="inline-flex items-center gap-1.5 self-center font-headline text-xs font-bold uppercase tracking-[0.3em] text-primary transition hover:text-primary/80 md:self-start"
>
<span
className="material-symbols-outlined text-lg"
aria-hidden="true"
translate="no"
>
arrow_back
</span>
Retour aux compétences
</Link>
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
Compétence · Réalisations
</span>
<h1
id="realisations-title"
className="font-headline text-3xl font-extrabold tracking-tight text-on-surface md:text-4xl lg:text-5xl"
>
{competence?.name ?? "Compétence"}
</h1>
<p className="font-body text-on-surface-variant sm:text-lg">
Une sélection de réalisations qui illustrent cette compétence en
contexte ouvrez une vignette pour en voir le détail.
</p>
</div>
</section>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-6">
{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 (
<Link
key={realisation.id}
href={href}
className={`${spanPattern[idx % spanPattern.length]} group flex flex-col overflow-hidden rounded-sheet bg-surface-container-lowest/85 shadow-ambient backdrop-blur-vellum transition duration-300 hover:-translate-y-0.5 hover:shadow-jewel focus:outline-none focus-visible:ring-2 focus-visible:ring-primary`}
>
<div className="relative aspect-[4/3] w-full overflow-hidden bg-surface-container-low">
{images.length > 1 ? (
<VignetteCarousel images={images} />
) : firstImage ? (
<img
src={firstImage.url}
alt={firstImage.alt}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
loading="lazy"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-sm text-on-surface-variant">
<span
className="material-symbols-outlined text-3xl"
aria-hidden="true"
translate="no"
>
image
</span>
</div>
)}
</div>
<div className="flex flex-1 flex-col gap-2 p-5 sm:p-6">
<span className="font-headline text-[10px] font-bold uppercase tracking-[0.3em] text-secondary">
Réalisation
</span>
<h2 className="font-headline text-xl font-extrabold leading-tight tracking-tight text-primary">
{realisation.name}
</h2>
{realisation.description && (
<p className="font-body line-clamp-3 text-sm leading-relaxed text-on-surface-variant sm:text-base">
{realisation.description}
</p>
)}
<span className="mt-auto inline-flex items-center gap-1.5 pt-3 font-headline text-sm font-bold uppercase tracking-[0.2em] text-primary">
Découvrir
<span
className="material-symbols-outlined text-lg transition-transform duration-300 group-hover:translate-x-1"
aria-hidden="true"
translate="no"
>
arrow_forward
</span>
</span>
</div>
</Link>
);
})}
</div>
</div>
);
} }

View File

@ -33,8 +33,12 @@ export default function Page() {
const data = await response.json(); 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( const sortedCompetences = (data.data ?? []).sort(
(a, b) => ((a.attributes?.order ?? 999) - (b.attributes?.order ?? 999)) (a, b) => getOrder(a) - getOrder(b)
); );
setCompetences(sortedCompetences); setCompetences(sortedCompetences);

View File

@ -19,7 +19,18 @@ interface ImageData {
interface ContentData { interface ContentData {
name: string; 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[]; picture?: ImageData[];
link?: string; link?: string;
linkText?: string; linkText?: string;
@ -30,6 +41,26 @@ interface ContentSectionProps {
slug: string; slug: string;
titleClass?: string; titleClass?: string;
contentClass?: 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 * 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 * sont ignorées (styles tokenisés désormais) on les garde dans l'interface
* pour ne pas casser les consommateurs. * 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({ export default function ContentSection({
collection, collection,
slug, slug,
backHref = "/portfolio",
backLabel = "Portfolio",
kickerLabel = "Projet · Portfolio",
notFoundLabel = "Ce projet est introuvable.",
}: ContentSectionProps) { }: ContentSectionProps) {
const [data, setData] = useState<ContentData | null>(null); const [data, setData] = useState<ContentData | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@ -89,10 +131,10 @@ export default function ContentSection({
search_off search_off
</span> </span>
<p className="font-body italic text-on-surface-variant"> <p className="font-body italic text-on-surface-variant">
Ce projet est introuvable. {notFoundLabel}
</p> </p>
<Link <Link
href="/portfolio" href={backHref}
className="mt-5 inline-flex items-center gap-1.5 font-headline text-sm font-bold uppercase tracking-[0.2em] text-primary hover:underline" className="mt-5 inline-flex items-center gap-1.5 font-headline text-sm font-bold uppercase tracking-[0.2em] text-primary hover:underline"
> >
<span <span
@ -102,14 +144,16 @@ export default function ContentSection({
> >
arrow_back arrow_back
</span> </span>
Retour au portfolio Retour
</Link> </Link>
</section> </section>
</div> </div>
); );
} }
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 = const images =
picture?.map((img: ImageData) => ({ picture?.map((img: ImageData) => ({
@ -121,7 +165,7 @@ export default function ContentSection({
<div className="mx-auto flex w-full min-w-0 max-w-3xl flex-col gap-5 px-4 pb-10 sm:px-6"> <div className="mx-auto flex w-full min-w-0 max-w-3xl flex-col gap-5 px-4 pb-10 sm:px-6">
{/* Bouton retour discret, posé sur le wallpaper comme une miette de fil d'Ariane. */} {/* Bouton retour discret, posé sur le wallpaper comme une miette de fil d'Ariane. */}
<Link <Link
href="/portfolio" href={backHref}
className="inline-flex w-fit items-center gap-1.5 rounded-full bg-surface-container-lowest/70 px-3 py-1.5 font-headline text-xs font-bold uppercase tracking-[0.2em] text-primary backdrop-blur-vellum transition-colors hover:bg-surface-container-lowest/95 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary" className="inline-flex w-fit items-center gap-1.5 rounded-full bg-surface-container-lowest/70 px-3 py-1.5 font-headline text-xs font-bold uppercase tracking-[0.2em] text-primary backdrop-blur-vellum transition-colors hover:bg-surface-container-lowest/95 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
> >
<span <span
@ -131,7 +175,7 @@ export default function ContentSection({
> >
arrow_back arrow_back
</span> </span>
Portfolio {backLabel}
</Link> </Link>
{/* En-tête "feuillet de vellum" aligné sur la home et les listes. */} {/* En-tête "feuillet de vellum" aligné sur la home et les listes. */}
@ -141,7 +185,7 @@ export default function ContentSection({
> >
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary"> <span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
Projet · Portfolio {kickerLabel}
</span> </span>
<h1 <h1
id="project-title" id="project-title"

1
coffreobsidian Submodule

@ -0,0 +1 @@
Subproject commit d2d513c23de79be2707d1267f5171c49cad55e36

View File

@ -1,6 +1,6 @@
# État actuel du site # État actuel du site
**Dernière mise à jour :** 2026-04-22 (post-refonte GrasBot v3) **Dernière mise à jour :** 2026-04-23 (post-tuning GrasBot + observabilité Langfuse)
## Ce qui est en place ## Ce qui est en place
@ -9,7 +9,8 @@
- **Formulaire contact** : POST vers Strapi `messages`. - **Formulaire contact** : POST vers Strapi `messages`.
- **Chatbot GrasBot v3** : FAB global (`GrasBotFab.tsx`) → proxy Next → API LLM hébergée (`llmapi.fernandgrascalvet.com`). - **Chatbot GrasBot v3** : FAB global (`GrasBotFab.tsx`) → proxy Next → API LLM hébergée (`llmapi.fernandgrascalvet.com`).
- **FastAPI + Ollama** dans `llm-api/` : modèle `qwen3:8b`, pipeline `search.py` (graph + BM25 sur vault Obsidian `vault-grasbot/`, sans embeddings). - **FastAPI + Ollama** dans `llm-api/` : modèle `qwen3:8b`, pipeline `search.py` (graph + BM25 sur vault Obsidian `vault-grasbot/`, sans embeddings).
- **Vault de connaissance `vault-grasbot/`** : 41 notes enrichies (aliases, answers, priority) — source de vérité du chatbot, régénéré depuis Strapi par `strapi_extraction/build-vault.py`. - **Vault de connaissance `vault-grasbot/`** : 42 notes enrichies (aliases, answers, priority) — source de vérité du chatbot, régénéré depuis Strapi par `strapi_extraction/build-vault.py`. Inclut une note `bio-fernand` courte (priority 10) dédiée aux questions biographiques et un CV complet complémentaire.
- **Observabilité Langfuse** : instance self-hosted `langfuse.fernandgrascalvet.com`, instrumentation Python (`llm-api/observability.py`) traçant chaque requête `/ask` (retrieval / prompt_build / ollama-chat) avec session/user IDs anonymes côté front. Mode no-op automatique si les clés sont absentes. Voir [`langfuse-observability.md`](./langfuse-observability.md).
- **Scripts** d'extraction et de doc dans `strapi_extraction/`. - **Scripts** d'extraction et de doc dans `strapi_extraction/`.
- Documentation opérationnelle : `CONFIGURATION_SITE.md`. - Documentation opérationnelle : `CONFIGURATION_SITE.md`.
- **Captures d'écran** de référence (WebP) : `docs-site-interne/captures/` — voir `captures/INDEX.md`. - **Captures d'écran** de référence (WebP) : `docs-site-interne/captures/` — voir `captures/INDEX.md`.

View File

@ -1,6 +1,6 @@
# Feuille de route # Feuille de route
**Dernière mise à jour :** 2026-04-22 **Dernière mise à jour :** 2026-04-23
Document vivant : ajuster les statuts et dates au fil du travail. Document vivant : ajuster les statuts et dates au fil du travail.
@ -56,4 +56,5 @@ Document vivant : ajuster les statuts et dates au fil du travail.
| 2026-04-22 | **Chatbot GrasBot — migration Mistral → Qwen3 + RAG sur vault Obsidian local**. Passage du modèle `mistral` à `qwen3:8b` dans `llm-api/api.py` (Q4_K_M, ~5 Go VRAM RTX 2080 Ti). Embeddings via `nomic-embed-text` (~500 Mo VRAM, multilingue FR). Nouveau pipeline RAG : `llm-api/rag.py` (embed / retrieve / build_prompt / generate / answer), `llm-api/index_vault.py` (parse frontmatter YAML, chunking par h2 au-delà de 3000 chars, upsert ChromaDB batch 32), `llm-api/requirements.txt` (fastapi, uvicorn, requests, chromadb, pyyaml). Nouveau script `strapi_extraction/build-vault.py` qui convertit `strapi_extraction/docs/*.md` + CV PDF (via `pypdf`) en vault Obsidian structuré `vault-grasbot/` : frontmatter YAML (type, source, domains, tags, linked, related, visibility), wikilinks vers les MOCs, MOCs auto-générés par type et par domaine (15 MOCs). Bootstrap v1 du vault : 17 projets, 4 compétences, 1 CV, 15 MOCs auto + 1 manuel (Technique), 3 notes auto-doc dans `50-Technique/` (architecture-site, grasbot-rag, vault-structure) pour que GrasBot puisse se présenter lui-même. Compatibilité ascendante `askAI.js`/`ChatBot.js` via le champ `response` conservé ; les `sources`, `rag`, `model` ajoutés sont non destructifs. Endpoint `/health` ajouté pour debug. Doc : nouveau [`08-vault-obsidian-rag.md`](./docs-site-interne/08-vault-obsidian-rag.md), mise à jour de [`04-api-llm-et-chatbot.md`](./docs-site-interne/04-api-llm-et-chatbot.md) et [`06-strapi-extraction.md`](./docs-site-interne/06-strapi-extraction.md). Fragilités préexistantes repérées (cleaner `homepages` absent, content-type `glossaire` non extrait) consignées mais non corrigées dans ce lot — à traiter lors du prochain enrichissement vault. | | 2026-04-22 | **Chatbot GrasBot — migration Mistral → Qwen3 + RAG sur vault Obsidian local**. Passage du modèle `mistral` à `qwen3:8b` dans `llm-api/api.py` (Q4_K_M, ~5 Go VRAM RTX 2080 Ti). Embeddings via `nomic-embed-text` (~500 Mo VRAM, multilingue FR). Nouveau pipeline RAG : `llm-api/rag.py` (embed / retrieve / build_prompt / generate / answer), `llm-api/index_vault.py` (parse frontmatter YAML, chunking par h2 au-delà de 3000 chars, upsert ChromaDB batch 32), `llm-api/requirements.txt` (fastapi, uvicorn, requests, chromadb, pyyaml). Nouveau script `strapi_extraction/build-vault.py` qui convertit `strapi_extraction/docs/*.md` + CV PDF (via `pypdf`) en vault Obsidian structuré `vault-grasbot/` : frontmatter YAML (type, source, domains, tags, linked, related, visibility), wikilinks vers les MOCs, MOCs auto-générés par type et par domaine (15 MOCs). Bootstrap v1 du vault : 17 projets, 4 compétences, 1 CV, 15 MOCs auto + 1 manuel (Technique), 3 notes auto-doc dans `50-Technique/` (architecture-site, grasbot-rag, vault-structure) pour que GrasBot puisse se présenter lui-même. Compatibilité ascendante `askAI.js`/`ChatBot.js` via le champ `response` conservé ; les `sources`, `rag`, `model` ajoutés sont non destructifs. Endpoint `/health` ajouté pour debug. Doc : nouveau [`08-vault-obsidian-rag.md`](./docs-site-interne/08-vault-obsidian-rag.md), mise à jour de [`04-api-llm-et-chatbot.md`](./docs-site-interne/04-api-llm-et-chatbot.md) et [`06-strapi-extraction.md`](./docs-site-interne/06-strapi-extraction.md). Fragilités préexistantes repérées (cleaner `homepages` absent, content-type `glossaire` non extrait) consignées mais non corrigées dans ce lot — à traiter lors du prochain enrichissement vault. |
| 2026-04-22 | Refonte visuelle — **étape 7 : fiches détail + glossaire + GrasBot flottant**. Cinq sous-lots. **7.a** : `Carousel.tsx` + `CarouselCompetences.tsx` harmonisés (pagination bullets primary via variables CSS Swiper, flèches primary, `rounded-tile shadow-ambient-sm`, autoplay 3500 ms + `loop` conditionnel, lightbox Stitch refaite avec voile `bg-on-surface/80`, image `object-contain rounded-sheet`, bouton close rond Material Symbol + Escape + verrouillage scroll body). **7.b** : `ContentSection.tsx` (fiche portfolio) — gabarit vellum cohérent avec la home/listes, pastille retour `arrow_back`, kicker `Projet · Portfolio`, titre Manrope, carousel détail plein cadre, corps Markdown en `prose` Stitch (mêmes overrides que la home, y compris pastille `prose-hr`), CTA externe jewel avec `open_in_new`, états loading/404 en vellum. **7.c** : `ContentSectionCompetences.tsx` + container — même gabarit. Refactor glossaire : styles inline `style="color:red/blue"` remplacés par les classes `.glossary-keyword` / `.chatbot-keyword` (ajoutées à `globals.css`, couleur primary + underline dotted offset 3 px). Event listeners `document.body.addEventListener` remplacés par un listener unique scopé au wrapper `contentRef`. Le clic sur « IA locale » ne monte plus un `<ChatBot>` 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 7 : fiches détail + glossaire + GrasBot flottant**. Cinq sous-lots. **7.a** : `Carousel.tsx` + `CarouselCompetences.tsx` harmonisés (pagination bullets primary via variables CSS Swiper, flèches primary, `rounded-tile shadow-ambient-sm`, autoplay 3500 ms + `loop` conditionnel, lightbox Stitch refaite avec voile `bg-on-surface/80`, image `object-contain rounded-sheet`, bouton close rond Material Symbol + Escape + verrouillage scroll body). **7.b** : `ContentSection.tsx` (fiche portfolio) — gabarit vellum cohérent avec la home/listes, pastille retour `arrow_back`, kicker `Projet · Portfolio`, titre Manrope, carousel détail plein cadre, corps Markdown en `prose` Stitch (mêmes overrides que la home, y compris pastille `prose-hr`), CTA externe jewel avec `open_in_new`, états loading/404 en vellum. **7.c** : `ContentSectionCompetences.tsx` + container — même gabarit. Refactor glossaire : styles inline `style="color:red/blue"` remplacés par les classes `.glossary-keyword` / `.chatbot-keyword` (ajoutées à `globals.css`, couleur primary + underline dotted offset 3 px). Event listeners `document.body.addEventListener` remplacés par un listener unique scopé au wrapper `contentRef`. Le clic sur « IA locale » ne monte plus un `<ChatBot>` 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-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`). | | 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`). |

1
ft_linear_regression Submodule

@ -0,0 +1 @@
Subproject commit a5616622a3e4ac6b4b44d57c3b2c150f33476255