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,
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/<slug>` ou `/competences/<slug>` 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 }) {
</span>
{grounded ? "Basé sur le vault" : "Réponse générale"}
</span>
{displayed.map((s) => (
<Link
key={s.slug}
href={s.url}
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"
title={s.title}
>
<span className="material-symbols-outlined text-[12px]" aria-hidden="true" translate="no">
{s.type === "competence" ? "psychology" : "folder"}
</span>
{s.slug}
</Link>
))}
{displayed.map((s) => {
const href = resolveGrasbotSourceHref(s);
return (
<Link
key={s.slug}
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"
title={s.title}
>
<span className="material-symbols-outlined text-[12px]" aria-hidden="true" translate="no">
{getGrasbotSourceIconName(s, href)}
</span>
{s.slug}
</Link>
);
})}
</div>
);
}

View File

@ -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,

View File

@ -1,13 +1,13 @@
/**
* 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).
*/
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) {

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
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 dURL), et **`site_slug`** lorsque le vault le définit (alias Strapi).
Le front résout lhyperlien via `app/utils/grasbotSourceUrl.js`.
## 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():
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)`` `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)

View File

@ -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
}

View File

@ -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 lURL site.
site_slug: gras-bot-chatbot-ia-du-portfolio
updated: 2026-04-23
visibility: public
---

View File

@ -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
---

View File

@ -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
---

View File

@ -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`)
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
règles de rédaction des aliases/answers.