diff --git a/app/components/ChatBot.js b/app/components/ChatBot.js index a452cb7..1cf4936 100644 --- a/app/components/ChatBot.js +++ b/app/components/ChatBot.js @@ -11,6 +11,7 @@ import { newChatMessageId, saveGrasbotChatMessages, } from "../utils/grasbotChatStorage"; +import { getGrasbotSourceIconName, resolveGrasbotSourceHref } from "../utils/grasbotSourceUrl"; /** * GrasBot — UI du chatbot (Stitch). @@ -20,8 +21,9 @@ import { * `app/layout.tsx`. Ce composant se concentre sur le panneau de conversation. * * v3 (2026-04-22) — bascule retrieval graph + BM25 : - * - Affichage des `sources` renvoyées par l'API (pill par source, cliquable - * vers `/portfolio/` ou `/competences/` si dispo). + * - Affichage des `sources` renvoyées par l'API (pill par source). L'URL est + * résolue via `resolveGrasbotSourceHref` (API `url` / `route_parent` + fallback + * pour l'historique localStorage sans métadonnées à jour). * - Badge `grounded` sous chaque réponse (paperclip si sources exploitées, * info si réponse générale faute de contexte pertinent). * - Timeout 45 s côté fetch (géré dans `askAI.js`) avec message éditorial. @@ -292,7 +294,7 @@ function BotFooter({ sources, grounded }) { seen.add(s.slug); uniqueSources.push(s); } - const clickable = uniqueSources.filter((s) => s.url); + const clickable = uniqueSources.filter((s) => resolveGrasbotSourceHref(s) !== "#"); const displayed = clickable.slice(0, 4); return ( @@ -309,19 +311,22 @@ function BotFooter({ sources, grounded }) { {grounded ? "Basé sur le vault" : "Réponse générale"} - {displayed.map((s) => ( - - - {s.slug} - - ))} + {displayed.map((s) => { + const href = resolveGrasbotSourceHref(s); + return ( + + + {s.slug} + + ); + })} ); } diff --git a/app/utils/askAI.js b/app/utils/askAI.js index ba074a5..2accbff 100644 --- a/app/utils/askAI.js +++ b/app/utils/askAI.js @@ -16,7 +16,16 @@ import { getGrasbotSessionId, getGrasbotUserId } from "./grasbotIds"; * @param {string} question * @returns {Promise<{ * response: string, - * sources?: Array<{slug: string, title: string, type: string, score: number, url?: string}>, + * sources?: Array<{ + * slug: string, + * title: string, + * type: string, + * score: number, + * url?: string, + * route_parent?: string, + * path_slug?: string, + * site_slug?: string, + * }>, * grounded?: boolean, * model?: string, * vault_size?: number, diff --git a/app/utils/grasbotChatStorage.js b/app/utils/grasbotChatStorage.js index 0f0558f..aa61c6d 100644 --- a/app/utils/grasbotChatStorage.js +++ b/app/utils/grasbotChatStorage.js @@ -1,13 +1,13 @@ /** * Persistance locale du fil GrasBot (sans envoi à Ollama). * - * Clé : `grasbot_chat_v1:` où `user_id` est celui de `grasbotIds.js`. + * Clé : `grasbot_chat_v{VERSION}:` où `user_id` est celui de `grasbotIds.js`. * Limite : les derniers messages uniquement pour éviter quota localStorage (~5 Mo). */ import { getGrasbotUserId } from "./grasbotIds"; -export const GRASBOT_CHAT_STORAGE_VERSION = 1; +export const GRASBOT_CHAT_STORAGE_VERSION = 2; export const GRASBOT_CHAT_MAX_MESSAGES = 80; function storageKey(userId) { diff --git a/app/utils/grasbotSourceUrl.js b/app/utils/grasbotSourceUrl.js new file mode 100644 index 0000000..d667c4a --- /dev/null +++ b/app/utils/grasbotSourceUrl.js @@ -0,0 +1,135 @@ +/** + * URL publique pour les pilules « sources » GrasBot (aligné sur `llm-api/search.py`). + * + * Ne pas faire confiance à `source.url` seul : l’API ou l’historique localStorage + * peut contenir d’anciennes URLs (`/competences/ia/grasbot`). On reconstruit + * toujours à partir de `path_slug` / `site_slug` / fallbacks quand un segment + * parent Strapi est connu. + */ + +/** Slug vault → segment parent dans l’URL */ +export const GRASBOT_ROUTE_PARENT_FALLBACK = { + grasbot: "ia", + "newsletter-ia": "ia", + "transcription-video": "ia", + "transcription-audio-fgc-transcription": "ia", +}; + +/** Slug vault → dernier segment Strapi / Next (cf. Admin Strapi → realisation-ia) */ +export const GRASBOT_SITE_SLUG_FALLBACK = { + grasbot: "gras-bot-chatbot-ia-du-portfolio", + "transcription-video": "transcription-video-automatique", + "newsletter-ia": "newsletter-ia-generation-automatisee-avec-ollama-and-listmonk", +}; + +/** + * @param {{ + * slug?: string, + * type?: string, + * url?: string, + * route_parent?: string, + * site_slug?: string, + * path_slug?: string, + * }} source + * @returns {string} + */ +export function resolveGrasbotSourceHref(source) { + if (!source?.slug) return "#"; + + const vaultSlug = source.slug; + + /* Dernier segment d’URL Strapi : souvent `site_slug`, sinon slug vault. + * Piège : l’API peut n’exposer que le slug vault (`grasbot`) alors que la page + * Next/Strapi attend l’UID titre (`gras-bot-chatbot-ia-du-portfolio`). Une + * compétence comme transcription audio n’a pas ce décalage : path_slug = slug + * vault = slug Strapi → ça marche. Les projets IA oui : on corrige quand + * path_slug vaut encore le seul identifiant vault mais un fallback existe. */ + let pathSlug = + (source.path_slug && String(source.path_slug).trim()) || + (source.site_slug && String(source.site_slug).trim()) || + vaultSlug; + + const siteSlugMapped = GRASBOT_SITE_SLUG_FALLBACK[vaultSlug]; + if (siteSlugMapped && pathSlug === vaultSlug) { + pathSlug = siteSlugMapped; + } + + const parentRaw = + source.route_parent !== undefined && source.route_parent !== null + ? String(source.route_parent).trim() + : ""; + const parent = parentRaw || GRASBOT_ROUTE_PARENT_FALLBACK[vaultSlug] || ""; + /** Route imbriquée /competences/[parent]/[pathSlug] (réalisation IA, compétence sous ia, etc.) */ + const nested = Boolean(parent && parent !== vaultSlug); + + if (nested) { + return `/competences/${parent}/${pathSlug}`; + } + + const type = String(source.type || "").toLowerCase(); + + if (type === "projet") { + return `/portfolio/${pathSlug}`; + } + if (type === "competence") { + return `/competences/${pathSlug}`; + } + + /* moc, parcours, technique, … — garder l’URL serveur si fournie */ + if (typeof source.url === "string" && source.url.startsWith("/")) { + return source.url; + } + return "#"; +} + +/** + * Icône Material Symbols pour la pilule : alignée sur l’URL réelle, pas seulement `source.type`. + * Les réalisations IA sont des `type: projet` dans le vault mais vivent sous `/competences/.../...` + * (comme une section compétence) : l’ancien test `type === "competence" ? psychology : folder` + * leur assignait à tort l’icône « dossier » réservée au portfolio. + * + * @param {{ + * slug?: string, + * type?: string, + * url?: string, + * route_parent?: string, + * site_slug?: string, + * path_slug?: string, + * }} source + * @param {string} [resolvedHref] — résultat de `resolveGrasbotSourceHref(source)` pour éviter un double calcul + * @returns {string} nom d’icône Material Symbols + */ +export function getGrasbotSourceIconName(source, resolvedHref) { + const href = + typeof resolvedHref === "string" ? resolvedHref : resolveGrasbotSourceHref(source); + + if (href === "#") return "link"; + + if (href.startsWith("/portfolio/")) { + return "folder"; + } + + if (href.startsWith("/competences/")) { + const parts = href.split("/").filter(Boolean); + const nested = parts.length >= 3; + + const type = String(source?.type || "").toLowerCase(); + + if (nested) { + if (type === "competence") { + return "psychology"; + } + if (type === "projet") { + return "deployed_code"; + } + if (GRASBOT_SITE_SLUG_FALLBACK[source.slug]) { + return "deployed_code"; + } + return "psychology"; + } + + return "psychology"; + } + + return "link"; +} diff --git a/docs-site-interne/04-api-llm-et-chatbot.md b/docs-site-interne/04-api-llm-et-chatbot.md index 023e215..0c5c25d 100644 --- a/docs-site-interne/04-api-llm-et-chatbot.md +++ b/docs-site-interne/04-api-llm-et-chatbot.md @@ -28,10 +28,9 @@ Détails architecturaux dans Le champ consommé par le front reste **`data.response`**. Les champs ajoutés par la refonte (`sources`, `grounded`, `model`, `vault_size`) passent dans -la réponse JSON ; les **`sources`** incluent une **`url`** relative pour les types -`projet` et `compétence` (ex. `/portfolio/[slug]`, `/competences/[slug]` ou -`/competences/[route_parent]/[slug]` si la note compétence définit `route_parent` -dans le frontmatter du vault — utilisé pour les fiches sous `/competences/ia/…`). +la réponse JSON ; les **`sources`** incluent **`url`**, **`route_parent`**, **`path_slug`** +(dernier segment d’URL), et **`site_slug`** lorsque le vault le définit (alias Strapi). +Le front résout l’hyperlien via `app/utils/grasbotSourceUrl.js`. ## Parcours public (hors moteur Python) — cohérence contenu diff --git a/llm-api/search.py b/llm-api/search.py index aa3f10b..049b5b9 100644 --- a/llm-api/search.py +++ b/llm-api/search.py @@ -249,6 +249,11 @@ def parse_note(path: Path) -> Note | None: if rp is not None and str(rp).strip(): extra["route_parent"] = str(rp).strip() + # Slug public Strapi / Next (dernier segment d'URL) si différent du slug vault (ex. realisation-ia). + ss = fm.get("site_slug") + if ss is not None and str(ss).strip(): + extra["site_slug"] = str(ss).strip() + return Note( slug=slug, title=title, @@ -804,6 +809,35 @@ def generate(system: str, user: str) -> str: return content +def _path_slug(note: Note) -> str: + """Dernier segment d'URL site : `site_slug` Strapi si renseigné, sinon slug vault.""" + s = str(note.extra.get("site_slug") or "").strip() + return s if s else note.slug + + +def _source_public_url(note: Note) -> tuple[str | None, str]: + """URL relative pour les pilules GrasBot + segment `route_parent` si imbriqué. + + Retourne ``(url_ou_none, route_parent_expose)`` où `route_parent_expose` est + le segment parent (ex. ``ia``) uniquement lorsqu'il participe à l'URL + ``/competences/{parent}/{path_slug}``. Le dernier segment utilise + :func:`_path_slug` (alias Strapi ``site_slug`` dans le frontmatter). + """ + raw = str(note.extra.get("route_parent") or "").strip() + seg = raw if raw and raw != note.slug else "" + last = _path_slug(note) + + if note.type == "projet": + if seg: + return f"/competences/{seg}/{last}", seg + return f"/portfolio/{last}", "" + if note.type == "competence": + if seg: + return f"/competences/{seg}/{last}", seg + return f"/competences/{last}", "" + return None, "" + + # --------------------------------------------------------------------------- # Façade haut-niveau — trace racine Langfuse # --------------------------------------------------------------------------- @@ -818,7 +852,7 @@ def answer( Retourne : { "response": str, # texte LLM (consommé par askAI.js → ChatBot.js) - "sources": list[{slug, title, type, score, reasons, url?}], + "sources": list[{slug, title, type, score, reasons, url?, route_parent?, path_slug?}], "model": str, "grounded": bool, # True si au moins 1 note a dépassé MIN_SCORE "vault_size": int, @@ -861,23 +895,23 @@ def answer( # --- Construction de la réponse API --- sources = [] for s in scored: - url = None - if s.note.type == "projet": - url = f"/portfolio/{s.note.slug}" - elif s.note.type == "competence": - parent = str(s.note.extra.get("route_parent") or "").strip() - if parent: - url = f"/competences/{parent}/{s.note.slug}" - else: - url = f"/competences/{s.note.slug}" - sources.append({ + url, rp_out = _source_public_url(s.note) + entry: dict[str, Any] = { "slug": s.note.slug, "title": s.note.title, "type": s.note.type, "score": round(s.score, 2), "reasons": s.reasons, - **({"url": url} if url else {}), - }) + "path_slug": _path_slug(s.note), + } + if url: + entry["url"] = url + if rp_out: + entry["route_parent"] = rp_out + site_val = str(s.note.extra.get("site_slug") or "").strip() + if site_val: + entry["site_slug"] = site_val + sources.append(entry) grounded = any(s.score >= MIN_SCORE for s in scored) max_score = max((s.score for s in scored), default=0.0) diff --git a/reload-vault.ps1 b/reload-vault.ps1 index 46c7f7a..a821807 100644 --- a/reload-vault.ps1 +++ b/reload-vault.ps1 @@ -20,11 +20,11 @@ $uri = ($BaseUrl.TrimEnd("/") + "/reload-vault") try { $response = Invoke-RestMethod -Method Post -Uri $uri -TimeoutSec 60 - Write-Host ("Vault rechargé : {0} notes — {1}" -f $response.notes_total, $uri) -ForegroundColor Green + Write-Host ("Vault reloaded: {0} note(s) -> {1}" -f $response.notes_total, $uri) -ForegroundColor Green $response | ConvertTo-Json -Compress } catch { - Write-Host ("Échec : {0}" -f $_.Exception.Message) -ForegroundColor Red - Write-Host ("URI : {0}" -f $uri) + Write-Host ("Failed: {0}" -f $_.Exception.Message) -ForegroundColor Red + Write-Host ("POST {0}" -f $uri) exit 1 } diff --git a/vault-grasbot/10-Projets/grasbot.md b/vault-grasbot/10-Projets/grasbot.md index c7f8c4f..cc0ec4e 100644 --- a/vault-grasbot/10-Projets/grasbot.md +++ b/vault-grasbot/10-Projets/grasbot.md @@ -35,6 +35,9 @@ related: - "[[newsletter-ia]]" - "[[transcription-video]]" link: "https://fernandgrascalvet.com" +route_parent: ia +# Slug public Strapi (realisation-ia, UID depuis le titre) — dernier segment de l’URL site. +site_slug: gras-bot-chatbot-ia-du-portfolio updated: 2026-04-23 visibility: public --- diff --git a/vault-grasbot/10-Projets/newsletter-ia.md b/vault-grasbot/10-Projets/newsletter-ia.md index b1d07e1..9c4d468 100644 --- a/vault-grasbot/10-Projets/newsletter-ia.md +++ b/vault-grasbot/10-Projets/newsletter-ia.md @@ -27,6 +27,8 @@ related: - "[[ia]]" - "[[grasbot]]" - "[[architecture-site]]" +route_parent: ia +site_slug: newsletter-ia-generation-automatisee-avec-ollama-and-listmonk updated: 2026-04-23 visibility: public --- diff --git a/vault-grasbot/10-Projets/transcription-video.md b/vault-grasbot/10-Projets/transcription-video.md index 3f54b0f..359669a 100644 --- a/vault-grasbot/10-Projets/transcription-video.md +++ b/vault-grasbot/10-Projets/transcription-video.md @@ -27,6 +27,8 @@ related: - "[[newsletter-ia]]" - "[[grasbot]]" - "[[transcription-audio-fgc-transcription]]" +route_parent: ia +site_slug: transcription-video-automatique updated: 2026-05-10 visibility: public --- diff --git a/vault-grasbot/README.md b/vault-grasbot/README.md index 644fbf9..4396e0f 100644 --- a/vault-grasbot/README.md +++ b/vault-grasbot/README.md @@ -37,12 +37,38 @@ answers: # questions-types auxquelles répond la priority: 5 # 1..10, boost léger au scoring linked: ["[[MOC-...]]"] # voisins du graphe (sortants) related: ["[[autre-note]]"] -route_parent: ia # optionnel (compétence) : lien source `/competences/ia/{slug}` +route_parent: ia # optionnel — voir § URLs des tags GrasBot +site_slug: autre-slug-strapi # optionnel : dernier segment URL si différent du slug vault (realisation-ia) updated: YYYY-MM-DD visibility: public | private # `private` exclu du retrieval --- ``` +### URLs des tags GrasBot (`route_parent`) + +L’API (`llm-api/search.py`) ajoute aux **`sources`** une URL relative pour les pilules sous la réponse. Défaut : + +| `type` | URL si pas de `route_parent` | +|--------|------------------------------| +| `projet` | `/portfolio/{slug}` | +| `competence` | `/competences/{slug}` | + +Le site peut aussi servir **`/competences/[parent]/[slug]`** (réalisations IA sous la compétence **IA**, fiches compétence imbriquées, etc.). Dans ce cas, ajouter dans le frontmatter : + +```yaml +route_parent: ia # segment parent dans l’URL Next (ex. ia → /competences/ia/{slug}) +``` + +Règles : si `route_parent` est défini et **différent** de `slug` → lien **`/competences/{route_parent}/{slug}`** (pour `projet` et `competence`). Si `route_parent == slug`, on évite le doublon et on utilise `/competences/{slug}`. + +**Slug Strapi ≠ slug vault** — Les entrées `realisation-ia` utilisent un UID dérivé du **titre** dans Strapi (`slug` admin), souvent différent du fichier vault (`grasbot.md`, slug court pour les wikilinks). Dans ce cas, renseigner **`site_slug`** avec la valeur exacte du champ *slug* Strapi (voir Admin ou `GET /api/realisation-ias`). L’API GrasBot expose alors **`path_slug`** et l’URL utilise ce segment pour le dernier morceau du chemin. + +**Audit** — repérer les notes à corriger : corps ou bloc info qui mentionne `realisation-ia` et `[[ia]]`, ou une route documentée sous `/competences/ia/`. Dans `10-Projets/`, les réalisations IA typiques ont `route_parent: ia` (GrasBot, newsletter IA, transcription vidéo). Les projets 42 restent en `/portfolio/...` sans `route_parent`. + +Après édition du vault : **`POST /reload-vault`** ou **`.\reload-vault.ps1`** (racine du dépôt). + +**Front Next** : `app/utils/grasbotSourceUrl.js` — fallbacks `GRASBOT_ROUTE_PARENT_FALLBACK` et **`GRASBOT_SITE_SLUG_FALLBACK`** (à synchroniser avec Strapi si tu ajoutes des réalisations IA). + Voir `TAXONOMIE.md` pour le vocabulaire contrôlé des domaines/tags et les règles de rédaction des aliases/answers.