maj_grasbot

This commit is contained in:
Ladebeze66 2026-05-10 11:36:52 +02:00
parent 9ee64f04f2
commit a69a8f57ed
11 changed files with 255 additions and 40 deletions

View File

@ -11,6 +11,7 @@ import {
newChatMessageId, newChatMessageId,
saveGrasbotChatMessages, saveGrasbotChatMessages,
} from "../utils/grasbotChatStorage"; } from "../utils/grasbotChatStorage";
import { getGrasbotSourceIconName, resolveGrasbotSourceHref } from "../utils/grasbotSourceUrl";
/** /**
* GrasBot UI du chatbot (Stitch). * GrasBot UI du chatbot (Stitch).
@ -20,8 +21,9 @@ import {
* `app/layout.tsx`. Ce composant se concentre sur le panneau de conversation. * `app/layout.tsx`. Ce composant se concentre sur le panneau de conversation.
* *
* v3 (2026-04-22) bascule retrieval graph + BM25 : * v3 (2026-04-22) bascule retrieval graph + BM25 :
* - Affichage des `sources` renvoyées par l'API (pill par source, cliquable * - Affichage des `sources` renvoyées par l'API (pill par source). L'URL est
* vers `/portfolio/<slug>` ou `/competences/<slug>` si dispo). * 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, * - Badge `grounded` sous chaque réponse (paperclip si sources exploitées,
* info si réponse générale faute de contexte pertinent). * 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. * - 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); seen.add(s.slug);
uniqueSources.push(s); uniqueSources.push(s);
} }
const clickable = uniqueSources.filter((s) => s.url); const clickable = uniqueSources.filter((s) => resolveGrasbotSourceHref(s) !== "#");
const displayed = clickable.slice(0, 4); const displayed = clickable.slice(0, 4);
return ( return (
@ -309,19 +311,22 @@ function BotFooter({ sources, grounded }) {
</span> </span>
{grounded ? "Basé sur le vault" : "Réponse générale"} {grounded ? "Basé sur le vault" : "Réponse générale"}
</span> </span>
{displayed.map((s) => ( {displayed.map((s) => {
<Link const href = resolveGrasbotSourceHref(s);
key={s.slug} return (
href={s.url} <Link
className="inline-flex items-center gap-1 rounded-full bg-surface-container-low px-2 py-0.5 font-headline text-[10px] text-primary transition-colors hover:bg-primary/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary" key={s.slug}
title={s.title} href={href}
> className="inline-flex items-center gap-1 rounded-full bg-surface-container-low px-2 py-0.5 font-headline text-[10px] text-primary transition-colors hover:bg-primary/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
<span className="material-symbols-outlined text-[12px]" aria-hidden="true" translate="no"> title={s.title}
{s.type === "competence" ? "psychology" : "folder"} >
</span> <span className="material-symbols-outlined text-[12px]" aria-hidden="true" translate="no">
{s.slug} {getGrasbotSourceIconName(s, href)}
</Link> </span>
))} {s.slug}
</Link>
);
})}
</div> </div>
); );
} }

View File

@ -16,7 +16,16 @@ import { getGrasbotSessionId, getGrasbotUserId } from "./grasbotIds";
* @param {string} question * @param {string} question
* @returns {Promise<{ * @returns {Promise<{
* response: string, * 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, * grounded?: boolean,
* model?: string, * model?: string,
* vault_size?: number, * vault_size?: number,

View File

@ -1,13 +1,13 @@
/** /**
* Persistance locale du fil GrasBot (sans envoi à Ollama). * Persistance locale du fil GrasBot (sans envoi à Ollama).
* *
* Clé : `grasbot_chat_v1:<user_id>` `user_id` est celui de `grasbotIds.js`. * Clé : `grasbot_chat_v{VERSION}:<user_id>` `user_id` est celui de `grasbotIds.js`.
* Limite : les derniers messages uniquement pour éviter quota localStorage (~5 Mo). * Limite : les derniers messages uniquement pour éviter quota localStorage (~5 Mo).
*/ */
import { getGrasbotUserId } from "./grasbotIds"; 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; export const GRASBOT_CHAT_MAX_MESSAGES = 80;
function storageKey(userId) { function storageKey(userId) {

View File

@ -0,0 +1,135 @@
/**
* URL publique pour les pilules « sources » GrasBot (aligné sur `llm-api/search.py`).
*
* Ne pas faire confiance à `source.url` seul : lAPI ou lhistorique localStorage
* peut contenir danciennes 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 lURL */
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 dURL Strapi : souvent `site_slug`, sinon slug vault.
* Piège : lAPI peut nexposer que le slug vault (`grasbot`) alors que la page
* Next/Strapi attend lUID titre (`gras-bot-chatbot-ia-du-portfolio`). Une
* compétence comme transcription audio na 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 lURL 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 lURL 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) : lancien test `type === "competence" ? psychology : folder`
* leur assignait à tort licô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 dicô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";
}

View File

@ -28,10 +28,9 @@ Détails architecturaux dans
Le champ consommé par le front reste **`data.response`**. Les champs ajoutés Le champ consommé par le front reste **`data.response`**. Les champs ajoutés
par la refonte (`sources`, `grounded`, `model`, `vault_size`) passent dans par la refonte (`sources`, `grounded`, `model`, `vault_size`) passent dans
la réponse JSON ; les **`sources`** incluent une **`url`** relative pour les types la réponse JSON ; les **`sources`** incluent **`url`**, **`route_parent`**, **`path_slug`**
`projet` et `compétence` (ex. `/portfolio/[slug]`, `/competences/[slug]` ou (dernier segment dURL), et **`site_slug`** lorsque le vault le définit (alias Strapi).
`/competences/[route_parent]/[slug]` si la note compétence définit `route_parent` Le front résout lhyperlien via `app/utils/grasbotSourceUrl.js`.
dans le frontmatter du vault — utilisé pour les fiches sous `/competences/ia/…`).
## Parcours public (hors moteur Python) — cohérence contenu ## Parcours public (hors moteur Python) — cohérence contenu

View File

@ -249,6 +249,11 @@ def parse_note(path: Path) -> Note | None:
if rp is not None and str(rp).strip(): if rp is not None and str(rp).strip():
extra["route_parent"] = 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( return Note(
slug=slug, slug=slug,
title=title, title=title,
@ -804,6 +809,35 @@ def generate(system: str, user: str) -> str:
return content 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)`` `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 # Façade haut-niveau — trace racine Langfuse
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -818,7 +852,7 @@ def answer(
Retourne : Retourne :
{ {
"response": str, # texte LLM (consommé par askAI.js → ChatBot.js) "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, "model": str,
"grounded": bool, # True si au moins 1 note a dépassé MIN_SCORE "grounded": bool, # True si au moins 1 note a dépassé MIN_SCORE
"vault_size": int, "vault_size": int,
@ -861,23 +895,23 @@ def answer(
# --- Construction de la réponse API --- # --- Construction de la réponse API ---
sources = [] sources = []
for s in scored: for s in scored:
url = None url, rp_out = _source_public_url(s.note)
if s.note.type == "projet": entry: dict[str, Any] = {
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({
"slug": s.note.slug, "slug": s.note.slug,
"title": s.note.title, "title": s.note.title,
"type": s.note.type, "type": s.note.type,
"score": round(s.score, 2), "score": round(s.score, 2),
"reasons": s.reasons, "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) grounded = any(s.score >= MIN_SCORE for s in scored)
max_score = max((s.score for s in scored), default=0.0) max_score = max((s.score for s in scored), default=0.0)

View File

@ -20,11 +20,11 @@ $uri = ($BaseUrl.TrimEnd("/") + "/reload-vault")
try { try {
$response = Invoke-RestMethod -Method Post -Uri $uri -TimeoutSec 60 $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 $response | ConvertTo-Json -Compress
} }
catch { catch {
Write-Host ("Échec : {0}" -f $_.Exception.Message) -ForegroundColor Red Write-Host ("Failed: {0}" -f $_.Exception.Message) -ForegroundColor Red
Write-Host ("URI : {0}" -f $uri) Write-Host ("POST {0}" -f $uri)
exit 1 exit 1
} }

View File

@ -35,6 +35,9 @@ related:
- "[[newsletter-ia]]" - "[[newsletter-ia]]"
- "[[transcription-video]]" - "[[transcription-video]]"
link: "https://fernandgrascalvet.com" link: "https://fernandgrascalvet.com"
route_parent: ia
# Slug public Strapi (realisation-ia, UID depuis le titre) — dernier segment de lURL site.
site_slug: gras-bot-chatbot-ia-du-portfolio
updated: 2026-04-23 updated: 2026-04-23
visibility: public visibility: public
--- ---

View File

@ -27,6 +27,8 @@ related:
- "[[ia]]" - "[[ia]]"
- "[[grasbot]]" - "[[grasbot]]"
- "[[architecture-site]]" - "[[architecture-site]]"
route_parent: ia
site_slug: newsletter-ia-generation-automatisee-avec-ollama-and-listmonk
updated: 2026-04-23 updated: 2026-04-23
visibility: public visibility: public
--- ---

View File

@ -27,6 +27,8 @@ related:
- "[[newsletter-ia]]" - "[[newsletter-ia]]"
- "[[grasbot]]" - "[[grasbot]]"
- "[[transcription-audio-fgc-transcription]]" - "[[transcription-audio-fgc-transcription]]"
route_parent: ia
site_slug: transcription-video-automatique
updated: 2026-05-10 updated: 2026-05-10
visibility: public visibility: public
--- ---

View File

@ -37,12 +37,38 @@ answers: # questions-types auxquelles répond la
priority: 5 # 1..10, boost léger au scoring priority: 5 # 1..10, boost léger au scoring
linked: ["[[MOC-...]]"] # voisins du graphe (sortants) linked: ["[[MOC-...]]"] # voisins du graphe (sortants)
related: ["[[autre-note]]"] 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 updated: YYYY-MM-DD
visibility: public | private # `private` exclu du retrieval visibility: public | private # `private` exclu du retrieval
--- ---
``` ```
### URLs des tags GrasBot (`route_parent`)
LAPI (`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 lURL 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`). LAPI GrasBot expose alors **`path_slug`** et lURL 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 Voir `TAXONOMIE.md` pour le vocabulaire contrôlé des domaines/tags et les
règles de rédaction des aliases/answers. règles de rédaction des aliases/answers.