mirror of
https://github.com/Ladebeze66/devsite.git
synced 2026-05-11 16:56:26 +02:00
228 lines
15 KiB
Markdown
228 lines
15 KiB
Markdown
# 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_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_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
|
||
|
||
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.
|