mirror of
https://github.com/Ladebeze66/devsite.git
synced 2026-05-11 16:56:26 +02:00
maj_grasbot
This commit is contained in:
parent
9ee64f04f2
commit
a69a8f57ed
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Persistance locale du fil GrasBot (sans envoi à Ollama).
|
||||
*
|
||||
* Clé : `grasbot_chat_v1:<user_id>` où `user_id` est celui de `grasbotIds.js`.
|
||||
* Clé : `grasbot_chat_v{VERSION}:<user_id>` 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) {
|
||||
|
||||
135
app/utils/grasbotSourceUrl.js
Normal file
135
app/utils/grasbotSourceUrl.js
Normal 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 : 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";
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
---
|
||||
|
||||
@ -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
|
||||
---
|
||||
|
||||
@ -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
|
||||
---
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user