15 KiB
Observabilité GrasBot via Langfuse
Créé : 2026-04-23
Statut : en production
Pré-requis lecture : 08-vault-obsidian-retrieval.md (architecture du pipeline graph + BM25).
Vue d'ensemble
Le chatbot GrasBot est instrumenté avec Langfuse (instance self-hosted : langfuse.fernandgrascalvet.com) pour tracer chaque requête visiteur bout en bout :
- Retrieval : quelles notes du vault ont été remontées, avec quels scores, pour quelles raisons.
- Prompt : le system + user effectivement envoyés à Qwen3.
- Génération : latence, tokens, paramètres du modèle.
- Trace globale : question, réponse, sources, scores dérivés (grounded, retrieval_relevance), tags.
But : debug, monitoring (qualité/latence dans le temps), et itération sur le pipeline retrieval en voyant directement l'effet d'un changement de règle de scoring.
Architecture
┌─────────────────────────┐ ┌──────────────────────────┐
│ ChatBot.js (front) │ │ Langfuse self-hosted │
│ - grasbotIds.js │ │ langfuse.fernandgrasc… │
│ - user_id localStorage│───▶│ │
│ - session_id sessionSt │ │ (ingestion HTTPS) │
└──────┬──────────────────┘ │ │
│ │ │
▼ │ ▲ │
┌─────────────────────────┐ │ │ SDK Python │
│ app/api/proxy/route.js │ │ │ (observability │
│ whitelist + GET fwd │ │ │ .py) │
└──────┬──────────────────┘ └─────────┼────────────────┘
│ │
▼ │
┌─────────────────────────┐ │
│ FastAPI /ask │ │
│ (llm-api/api.py) │ │
│ → @observe via Langfuse─────────────┘
│ → search.answer() │
└──────┬──────────────────┘
│
├── retrieval (span)
├── prompt_build (span)
└── ollama-chat (generation)
L'instrumentation vit côté Python (couche où on a accès aux détails du retrieval et du prompt). Le proxy Next ne fait que relayer le session_id / user_id depuis le front jusqu'à l'API Python.
Fichiers concernés
| Fichier | Rôle |
|---|---|
llm-api/observability.py |
Init client Langfuse (no-op safe si clés absentes) + flush() au shutdown |
llm-api/api.py |
FastAPI /ask — query params session_id/user_id + lifespan pour flush |
llm-api/search.py |
Spans retrieval + prompt_build + generation, trace racine ask, scores auto |
llm-api/.env |
Secrets Langfuse (non committé) |
llm-api/.env.example |
Template documentaire |
app/utils/grasbotIds.js |
Génération UUID v4 anonymes (localStorage + sessionStorage) |
app/utils/askAI.js |
Passe session_id/user_id en query params |
app/api/proxy/route.js |
Whitelist q, session_id, user_id → forward vers API Python |
Variables d'environnement (côté Python uniquement)
Dans llm-api/.env (chargé automatiquement par observability.py via python-dotenv) :
| Variable | Obligatoire | Notes |
|---|---|---|
LANGFUSE_PUBLIC_KEY |
oui | Format pk-lf-… |
LANGFUSE_SECRET_KEY |
oui | Format sk-lf-… — JAMAIS dans un log/commit/chat |
LANGFUSE_BASE_URL |
oui | URL du self-hosted (ex. https://langfuse.fernandgrascalvet.com) |
LANGFUSE_HOST |
fallback | Alternative à BASE_URL si jamais on passe sur le cloud Langfuse |
Si l'une des 3 est absente → observability.py instancie un client no-op : l'API fonctionne normalement, aucune trace n'est envoyée, aucune erreur. Pratique pour dev local / contributeurs externes.
Les variables Langfuse ne sont pas dans .env.local de Next.js — elles ne servent qu'au backend Python.
Structure d'une trace
Trace racine : ask
- input :
{ query: "..." } - output :
{ response, sources_count, grounded } - metadata :
{ top_k, min_score } - session_id, user_id (propagés depuis le front)
- tags :
grounded|ungrounded,model:qwen3:8b,vault-miss(si aucune note scorée) - scores auto :
grounded(BOOLEAN, 0/1) : au moins 1 note ≥MIN_SCOREretrieval_relevance(NUMERIC, 0-1) :min(max_score / 15, 1)
Span retrieval
- input :
{ query, top_k } - output :
[{slug, title, type, score, reasons}, …]— top-K final après expansion - metadata :
query_tokens: tokens extraits partokenize_frvault_size: nombre de notes publiques chargéescandidates_with_signal: combien de notes ont eu un score > 0seeds_before_graph: top-3 avant expansion par graphebm25_stats:{N, avgdl, idf_terms}(pour debug de régressions BM25)elapsed_ms: durée du retrieval seul
Span prompt_build
- input :
{ query, scored_count } - output :
{ system, user }— le prompt complet envoyé à Qwen - metadata :
grounded: bool (= au moins 1 note ≥ MIN_SCORE)relevant_notes: notes effectivement incluses dans le contextesystem_chars,user_chars: tailles utiles pour debug de fenêtre de contextemin_score_threshold: valeur duMIN_SCOREau moment de l'appeltruncation:{ secondary_max_chars, secondary_keep_ratio, truncated_notes: [...] }— liste des sources rank 2+ résumées automatiquement (avec leur slug, score, taille d'origine et taille tronquée). Vide s'il n'y a eu aucune troncature.
Span ollama-chat (type generation)
- input :
[{role: "system", content}, {role: "user", content}] - output : réponse brute du modèle
- model :
LLM_MODEL(ex.qwen3:8b) - model_parameters :
{temperature: 0.4, num_ctx: 8192, num_predict: 1024, think: false}(voir section "Tuning 2026-04-23" ci-dessous pour le rationnel). - usage :
{input, output, total}— extraits deprompt_eval_count/eval_countsi Ollama les renvoie - Si réponse vide → span
level: ERRORavec le payload Ollama brut en metadata.
Session / User IDs (côté front)
Pas de PII, pas d'authentification. Deux UUID v4 anonymes générés automatiquement à la première interaction :
grasbot_user_id→localStorage→ stable par device, sert à mesurer les utilisateurs uniques et à regrouper l'historique d'un visiteur récurrent.grasbot_session_id→sessionStorage→ expire à la fermeture de l'onglet, regroupe une conversation.
Générés par app/utils/grasbotIds.js, propagés par askAI.js → /api/proxy (whitelist) → /ask (query params) → search.answer() (update_current_trace(session_id=…, user_id=…)).
Impact RGPD : aucun identifiant déductible de l'utilisateur, aucune donnée persistante côté serveur autre que ce que Langfuse stocke de lui-même. L'utilisateur peut vider son storage pour "réinitialiser" son identité côté observabilité.
Procédure de test
Local
cd llm-api && pip install -r requirements.txt(ajoutelangfuse+python-dotenv).- Remplir
llm-api/.envavec les 3 clés (ou laisser vide pour tester le mode no-op). .\start-my-site.ps1(ou démarrer uvicorn manuellement).- Aller sur
http://localhost:3000→ ouvrir le chatbot (FAB en bas à droite) → poser une question. - Dans Langfuse → Traces → voir apparaître une trace
asken temps réel (quelques secondes après la réponse, le temps du flush).
Vérifier le no-op silencieux
- Commenter les 3 variables
LANGFUSE_*dansllm-api/.env. - Redémarrer uvicorn → les logs affichent
ℹ️ Langfuse désactivé — variables manquantes : …. - Poser une question au chatbot → réponse normale, aucun crash.
GET /healthrenvoie{"observability": {"langfuse_enabled": false}}.
Scénarios utiles à reproduire dans Langfuse
- Question grounded classique : "Parle-moi de push-swap" → tags
grounded, retrieval_relevance ~0.7-0.9. - Question hors-sujet : "Quel temps fait-il demain ?" → tags
ungrounded, grounded=0, sources_count=0 ou voisins faibles. - Question sur mot-clé ambigu : "C" (langage C vs lettre C) → voir dans le span
retrievalcomment_keyword_matchesfiltre ou non.
Dashboards Langfuse utiles
Qualité du retrieval dans le temps
Dashboard → filtrer score: grounded → voir le taux de grounded par jour. Une chute = problème de vault ou de scoring.
Latence p95
Dashboard → latency sur trace ask ou span ollama-chat. La génération est la source de latence majoritaire (≥ 90%), le retrieval reste sous ~100ms.
Questions sans contexte pertinent
Filtrer tags = ungrounded → voir les questions posées mais non couvertes par le vault → source d'insights pour enrichir le vault (nouveaux alias, nouvelles notes).
Sessions longues
Filtrer par session_id → enchaînement des questions d'un visiteur → voir si GrasBot garde la cohérence (pas de mémoire entre requêtes, attendu).
Conventions de nommage
- Spans : kebab-case en anglais (
retrieval,prompt-build,ollama-chat). Iciprompt_builda été laissé en snake pour rappeler la fonction Python, à remplacer parprompt-buildsi on refait un coup de ménage. - Tags : kebab-case, préfixés par concept (
model:qwen3:8b,vault-miss). - Scores : snake_case nom simple (
grounded,retrieval_relevance), + ajoutera plus tarduser_feedbacksi on branche un 👍/👎.
Rollback
Si Langfuse tombe en panne ou si l'instrumentation pose un souci :
- Soft rollback : vider / commenter les variables
LANGFUSE_*dansllm-api/.envet redémarrer uvicorn. Le client passe en no-op, aucun autre changement nécessaire. - Hard rollback :
git revertdu commit d'intégration Langfuse. Les fichiersobservability.py/.env/grasbotIds.jsdisparaîtront ; le pipeline revient exactement à la v3.0.
Sécurité — rappels
LANGFUSE_SECRET_KEYpermet d'écrire dans toutes les traces du projet → équivaut à un droit d'admin partiel. Jamais en clair dans un chat, un log, un screenshot, un commit.- Rotation : en cas de doute, Project Settings → API Keys → Delete puis recréer. Les traces déjà ingérées ne sont pas affectées.
- Le client Langfuse envoie les traces en asynchrone avec un buffer → bien appeler
flush()au shutdown pour ne rien perdre (déjà fait via lelifespanFastAPI). - Contenu sensible : les prompts complets passent dans Langfuse. Vérifier que le vault ne contient pas d'infos privées (
visibility: privateest filtré côté search, mais si tu ajoutais un jour un vault mixte public/privé, il faudrait un filtre supplémentaire avant l'envoi à Langfuse).
Tuning du pipeline — 2026-04-23
Audit des premières traces après mise en production : les réponses sur les questions biographiques ("qui est Fernand ?") étaient parfois hallucinées (âge erroné, statut inventé) et les réponses longues tronquées en plein milieu. Quatre ajustements ciblés ont stabilisé le comportement :
| # | Fichier | Changement | Effet attendu |
|---|---|---|---|
| 1 | search.py · generate() |
num_ctx explicite à 8192 |
Fin de la troncature silencieuse du prompt (le défaut Ollama à 2048/4096 coupait le début du contexte quand plusieurs notes entières étaient injectées). |
| 1 | search.py · generate() |
num_predict 512 → 1024 |
Réponses longues (descriptions de projet, explications) ne sont plus coupées en plein milieu. |
| 1 | search.py · generate() |
think: false top-level |
Désactive le mode thinking de qwen3. Le modèle n'utilise plus de budget de sortie pour du raisonnement interne. |
| 2 | search.py · build_prompt() |
Troncature conditionnelle des sources rank 2+ | Les notes secondaires (ex. inception sur une question bio) sont résumées à SEARCH_SECONDARY_MAX_CHARS chars quand leur score est < SEARCH_SECONDARY_KEEP_RATIO × score(#1). Réduit le bruit sans supprimer de source. |
| 3 | vault-grasbot/30-Parcours/bio-fernand.md |
Nouvelle note dédiée à la présentation courte | Source canonique pour les questions du type "qui est Fernand". Priorité 10, aliases biographiques courts. Renvoie vers le CV complet pour le détail. |
| 3 | CV (cv-grascalvet-fernand.md) |
Incohérence d'âge corrigée (46 → 47 ans) | Supprime la contradiction interne qui alimentait les hallucinations sur l'âge. |
| 4 | search.py · SYSTEM_PROMPT |
Section "Règles de fidélité aux sources" | Force le modèle à (a) s'appuyer en priorité sur type=parcours pour les questions bio, (b) ne jamais inventer un fait factuel, (c) écrire « non précisé dans les notes » si l'info manque, (d) gérer les contradictions, (e) signaler les notes tronquées. |
Observabilité : dans les spans Langfuse, prompt_build.metadata.truncation
liste chaque source tronquée automatiquement → sert de point de vigilance pour
vérifier que la troncature reste pertinente (et n'écrase pas une source qu'on
aurait dû garder entière).
Variables d'environnement associées (dans llm-api/.env ou shell) :
| Variable | Défaut | Effet |
|---|---|---|
SEARCH_SECONDARY_MAX_CHARS |
1500 |
Taille max des sources secondaires dans le prompt |
SEARCH_SECONDARY_KEEP_RATIO |
0.8 |
Tant que score(rank≥2) ≥ ratio × score(#1) → source gardée entière |
Rappel : load_vault() est mémoïsé. Après création/modification d'une note du
vault, appeler POST /reload-vault pour recharger le cache sans redémarrer
uvicorn (voir api.py).
Évolutions futures possibles
- Feedback utilisateur : ajouter un 👍/👎 sur chaque réponse bot dans
ChatBot.js, relayé à/api/feedbackqui appelleraitlangfuse.score(trace_id, name="user_feedback", value=1|0). Letrace_idserait retourné par/ask(actuellement omis). - Prompt versioning : stocker
SYSTEM_PROMPTdans Langfuse Prompts pour versionner et A/B tester sans redéploiement. - Coût / token pricing : si on branche un provider payant (OpenAI / Anthropic) à la place d'Ollama, Langfuse calcule automatiquement le coût à partir de l'
usage. - Dataset d'évaluation : capturer les meilleures traces comme dataset, puis relancer le pipeline sur ces mêmes questions après modif du scoring pour comparer les sorties.