devsite/docs-site-interne/langfuse-observability.md
2026-04-23 15:42:43 +02:00

15 KiB
Raw Blame History

Observabilité GrasBot via Langfuse

Créé : 2026-04-23
Statut : en production
Pré-requis lecture : docs-site-interne/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_SCORE
    • retrieval_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 par tokenize_fr
    • vault_size : nombre de notes publiques chargées
    • candidates_with_signal : combien de notes ont eu un score > 0
    • seeds_before_graph : top-3 avant expansion par graphe
    • bm25_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 contexte
    • system_chars, user_chars : tailles utiles pour debug de fenêtre de contexte
    • min_score_threshold : valeur du MIN_SCORE au moment de l'appel
    • truncation : { 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 de prompt_eval_count / eval_count si Ollama les renvoie
  • Si réponse vide → span level: ERROR avec 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_idlocalStorage → stable par device, sert à mesurer les utilisateurs uniques et à regrouper l'historique d'un visiteur récurrent.
  • grasbot_session_idsessionStorage → 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

  1. cd llm-api && pip install -r requirements.txt (ajoute langfuse + python-dotenv).
  2. Remplir llm-api/.env avec les 3 clés (ou laisser vide pour tester le mode no-op).
  3. .\start-my-site.ps1 (ou démarrer uvicorn manuellement).
  4. Aller sur http://localhost:3000 → ouvrir le chatbot (FAB en bas à droite) → poser une question.
  5. Dans Langfuse → Traces → voir apparaître une trace ask en temps réel (quelques secondes après la réponse, le temps du flush).

Vérifier le no-op silencieux

  1. Commenter les 3 variables LANGFUSE_* dans llm-api/.env.
  2. Redémarrer uvicorn → les logs affichent Langfuse désactivé — variables manquantes : ….
  3. Poser une question au chatbot → réponse normale, aucun crash.
  4. GET /health renvoie {"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 retrieval comment _keyword_matches filtre 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). Ici prompt_build a été laissé en snake pour rappeler la fonction Python, à remplacer par prompt-build si 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 tard user_feedback si on branche un 👍/👎.

Rollback

Si Langfuse tombe en panne ou si l'instrumentation pose un souci :

  1. Soft rollback : vider / commenter les variables LANGFUSE_* dans llm-api/.env et redémarrer uvicorn. Le client passe en no-op, aucun autre changement nécessaire.
  2. Hard rollback : git revert du commit d'intégration Langfuse. Les fichiers observability.py / .env / grasbotIds.js disparaîtront ; le pipeline revient exactement à la v3.0.

Sécurité — rappels

  • LANGFUSE_SECRET_KEY permet 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 le lifespan FastAPI).
  • Contenu sensible : les prompts complets passent dans Langfuse. Vérifier que le vault ne contient pas d'infos privées (visibility: private est 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/feedback qui appellerait langfuse.score(trace_id, name="user_feedback", value=1|0). Le trace_id serait retourné par /ask (actuellement omis).
  • Prompt versioning : stocker SYSTEM_PROMPT dans 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.