This commit is contained in:
Ladebeze66 2026-04-23 12:21:56 +02:00
parent 916ae8dfef
commit d6949309f1
15 changed files with 851 additions and 120 deletions

19
.env.example Normal file
View File

@ -0,0 +1,19 @@
# Copie ce fichier en .env.local et remplis chaque variable.
# Ne committe JAMAIS .env.local (il est déjà dans .gitignore).
# URL de l'API Strapi (CMS) utilisée par le front pour homepage / portfolio / competences.
NEXT_PUBLIC_API_URL=http://localhost:1337
# --- Brevo (formulaire de contact) ---------------------------------
# 1. Créer une clé API dédiée : https://app.brevo.com/settings/keys/api
BREVO_API_KEY=xkeysib-...
# 2. Email d'un expéditeur VÉRIFIÉ dans Brevo (pastille verte SPF/DKIM).
# Réutilisation de l'expéditeur newsletter OK.
CONTACT_FROM_EMAIL=contact@exemple.com
# Nom qui s'affichera comme expéditeur dans la boîte de réception.
CONTACT_FROM_NAME=Portfolio — nouveau message
# 3. Destinataire : ta vraie boîte mail, où tu veux recevoir les notifications.
CONTACT_TO_EMAIL=toi@exemple.com
CONTACT_TO_NAME=Ton Nom

5
.gitignore vendored
View File

@ -32,6 +32,11 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
llm-api/.env
llm-api/.env.local
# Templates documentaires : forcer l'inclusion (ils contiennent des placeholders).
!.env.example
!llm-api/.env.example
# vercel # vercel
.vercel .vercel

View File

@ -14,12 +14,16 @@ Ce site utilise une architecture full-stack moderne avec :
my-next-site/ my-next-site/
├── app/ # Application Next.js ├── app/ # Application Next.js
├── cmsbackend/ # Backend Strapi ├── cmsbackend/ # Backend Strapi
├── llm-api/ # API FastAPI pour IA ├── llm-api/ # API FastAPI pour IA (+ instrumentation Langfuse)
│ ├── .env # Secrets Python (Langfuse, etc.) — non committé
│ └── observability.py # Init client Langfuse (no-op safe)
├── start-my-site.ps1 # Script de démarrage ├── start-my-site.ps1 # Script de démarrage
├── stop-my-site.ps1 # Script d'arrêt propre ├── stop-my-site.ps1 # Script d'arrêt propre
└── package.json # Dépendances frontend └── package.json # Dépendances frontend
``` ```
**Observabilité** : le chatbot GrasBot est tracé dans une instance **Langfuse self-hosted** (`langfuse.fernandgrascalvet.com`). Chaque question déclenche une trace `ask` avec spans `retrieval` / `prompt_build` / `ollama-chat`, plus des scores auto (`grounded`, `retrieval_relevance`) et des tags. Voir `docs-site-interne/langfuse-observability.md` pour le détail.
## 🚀 Démarrage Rapide ## 🚀 Démarrage Rapide
### Script Automatique (Recommandé) ### Script Automatique (Recommandé)

View File

@ -1,12 +1,40 @@
/**
* Proxy Next.js vers l'API Python GrasBot.
*
* Rôle : éviter l'exposition directe du domaine `llmapi.fernandgrascalvet.com`
* depuis le navigateur (CORS, rate limiting applicatif, logging Next côté server).
*
* v3.1 (2026-04-23) relais des IDs d'observabilité Langfuse :
* - Les paramètres `session_id` et `user_id` passés par le front (voir
* `app/utils/grasbotIds.js`) sont propagés tels quels vers l'API Python
* qui les injecte dans la trace Langfuse.
* - Whitelist stricte des query params relayés (q, session_id, user_id).
* Toute autre clé est ignorée pas de risque de SSRF via query injection.
*/
const UPSTREAM_BASE = "https://llmapi.fernandgrascalvet.com";
const ALLOWED_PARAMS = new Set(["q", "session_id", "user_id"]);
export async function GET(req) { export async function GET(req) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const question = searchParams.get("q"); const question = searchParams.get("q");
if (!question) { if (!question) {
return new Response(JSON.stringify({ error: "Question manquante" }), { status: 400 }); return new Response(JSON.stringify({ error: "Question manquante" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
} }
const apiUrl = `https://llmapi.fernandgrascalvet.com/ask?q=${encodeURIComponent(question)}`; // Construction des params upstream : whitelist only.
const upstream = new URLSearchParams();
for (const [key, value] of searchParams.entries()) {
if (ALLOWED_PARAMS.has(key) && value) {
upstream.set(key, value);
}
}
const apiUrl = `${UPSTREAM_BASE}/ask?${upstream.toString()}`;
try { try {
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
@ -24,8 +52,12 @@ export async function GET(req) {
}, },
}); });
} catch (error) { } catch (error) {
return new Response(JSON.stringify({ error: "Erreur de communication avec l'API" }), { return new Response(
JSON.stringify({ error: "Erreur de communication avec l'API" }),
{
status: 500, status: 500,
}); headers: { "Content-Type": "application/json" },
}
);
} }
} }

View File

@ -1,3 +1,5 @@
import { getGrasbotSessionId, getGrasbotUserId } from "./grasbotIds";
/** /**
* Appelle l'API GrasBot via le proxy Next (/api/proxy). * Appelle l'API GrasBot via le proxy Next (/api/proxy).
* *
@ -7,6 +9,9 @@
* - v3 (2026-04-22) : retourne maintenant l'objet complet pour que `ChatBot.js` * - v3 (2026-04-22) : retourne maintenant l'objet complet pour que `ChatBot.js`
* puisse afficher les sources citées, le badge `grounded`, etc. Ajoute un * puisse afficher les sources citées, le badge `grounded`, etc. Ajoute un
* timeout (45 s) via `AbortController` pour éviter les spinners infinis. * timeout (45 s) via `AbortController` pour éviter les spinners infinis.
* - v3.1 (2026-04-23) : transmet `session_id` (sessionStorage) et `user_id`
* (localStorage) à chaque requête pour l'observabilité Langfuse. Pas de PII,
* juste des UUID anonymes. Voir `docs-site-interne/langfuse-observability.md`.
* *
* @param {string} question * @param {string} question
* @returns {Promise<{ * @returns {Promise<{
@ -21,8 +26,14 @@ export async function askAI(question) {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 45_000); const timeoutId = setTimeout(() => controller.abort(), 45_000);
const params = new URLSearchParams({ q: question });
const sessionId = getGrasbotSessionId();
const userId = getGrasbotUserId();
if (sessionId) params.set("session_id", sessionId);
if (userId) params.set("user_id", userId);
try { try {
const response = await fetch(`/api/proxy?q=${encodeURIComponent(question)}`, { const response = await fetch(`/api/proxy?${params.toString()}`, {
signal: controller.signal, signal: controller.signal,
}); });
clearTimeout(timeoutId); clearTimeout(timeoutId);

56
app/utils/grasbotIds.js Normal file
View File

@ -0,0 +1,56 @@
/**
* IDs anonymes pour l'instrumentation Langfuse du chatbot GrasBot.
*
* Modèle (voir docs-site-interne/langfuse-observability.md §Session / User) :
*
* - `user_id` : UUID v4 persistant dans localStorage (`grasbot_user_id`).
* Identifie un même "device" au fil du temps. Pas de PII, pas d'auth.
* Permet d'estimer les utilisateurs uniques et de regrouper l'historique
* des conversations d'un visiteur récurrent.
*
* - `session_id` : UUID v4 dans sessionStorage (`grasbot_session_id`).
* Expire à la fermeture de l'onglet. Regroupe dans Langfuse toutes les
* questions d'une même "conversation" pour voir le flow complet.
*
* On utilise `crypto.randomUUID()` (disponible partout depuis 2021, couvert
* par tous les navigateurs modernes). Fallback en cas d'environnement exotique
* (SSR, navigateur très ancien) : UUID généré par `Math.random` c'est pour
* l'observabilité, on ne dépend pas de la cryptographie forte.
*/
function safeRandomUUID() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
// Fallback RFC 4122 v4 : suffisant pour de l'observabilité (pas de sécurité).
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function readOrCreate(storage, key) {
if (typeof window === "undefined") return null;
try {
const existing = storage.getItem(key);
if (existing) return existing;
const fresh = safeRandomUUID();
storage.setItem(key, fresh);
return fresh;
} catch {
// localStorage/sessionStorage peut être bloqué (mode privé strict). On
// renvoie un ID éphémère pour que l'appel marche, sans persister.
return safeRandomUUID();
}
}
export function getGrasbotUserId() {
if (typeof window === "undefined") return null;
return readOrCreate(window.localStorage, "grasbot_user_id");
}
export function getGrasbotSessionId() {
if (typeof window === "undefined") return null;
return readOrCreate(window.sessionStorage, "grasbot_session_id");
}

View File

@ -43,6 +43,7 @@
- Carrousels : `Carousel.tsx`, `CarouselCompetences.tsx` (swiper / react-responsive-carousel). - Carrousels : `Carousel.tsx`, `CarouselCompetences.tsx` (swiper / react-responsive-carousel).
- Sections : `ContentSection.tsx`, `ContentSectionCompetences*.tsx`. - Sections : `ContentSection.tsx`, `ContentSectionCompetences*.tsx`.
- `ContactForm.tsx``POST /api/contact` → Brevo API (voir `docs-site-interne/contact-flow.md`). - `ContactForm.tsx``POST /api/contact` → Brevo API (voir `docs-site-interne/contact-flow.md`).
- `ChatBot.js``askAI.js``/api/proxy` → FastAPI `/ask` avec `session_id` + `user_id` (UUID anonymes via `app/utils/grasbotIds.js`, voir `docs-site-interne/langfuse-observability.md`).
- `ChatBot.js``askAI.js``/api/proxy`. - `ChatBot.js``askAI.js``/api/proxy`.
- `ModalGlossaire.tsx` — glossaire (données Strapi selon usage dans les pages). - `ModalGlossaire.tsx` — glossaire (données Strapi selon usage dans les pages).

View File

@ -0,0 +1,190 @@
# 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
### 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_predict: 512}`
- **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).
## É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.

21
llm-api/.env.example Normal file
View File

@ -0,0 +1,21 @@
# Copie ce fichier en llm-api/.env et remplis les valeurs.
# Ne committe JAMAIS llm-api/.env (il est dans .gitignore).
#
# Ce fichier est chargé par `observability.py` via python-dotenv au démarrage de FastAPI.
# Toutes les variables sont optionnelles — si elles sont absentes, l'API fonctionne
# normalement mais sans instrumentation Langfuse (no-op silencieux).
# --- Langfuse (observabilité du chatbot GrasBot) ---------------------------
# Projet → Settings → API Keys (instance self-hosted ou cloud).
LANGFUSE_PUBLIC_KEY=pk-lf-xxxxxxxx
LANGFUSE_SECRET_KEY=sk-lf-xxxxxxxx
# URL de l'instance Langfuse. Chez Fernand : self-hosted sous langfuse.fernandgrascalvet.com.
# Le SDK officiel supporte soit LANGFUSE_HOST soit LANGFUSE_BASE_URL — on lit les deux,
# BASE_URL en priorité (c'est ce que l'UI Langfuse recopie dans ses snippets).
LANGFUSE_BASE_URL=https://langfuse.exemple.com
# --- Runtime du pipeline (optionnels, mêmes defaults que search.py) -------
# OLLAMA_URL=http://localhost:11434
# LLM_MODEL=qwen3:8b
# SEARCH_TOP_K=5
# SEARCH_MIN_SCORE=1.0

View File

@ -9,14 +9,29 @@ Historique :
* Expansion par graphe (linked / related / wikilinks du body). * Expansion par graphe (linked / related / wikilinks du body).
* Plus de dépendance ChromaDB ni hnswlib ni nomic-embed-text. * Plus de dépendance ChromaDB ni hnswlib ni nomic-embed-text.
* Module `rag.py` / `index_vault.py` supprimés. * Module `rag.py` / `index_vault.py` supprimés.
- 2026-04-23 : intégration **Langfuse** pour observabilité complète du pipeline.
* `/ask` accepte `session_id` et `user_id` optionnels (passés par le front
depuis ChatBot.js via localStorage/sessionStorage).
* L'instrumentation vit dans `search.py` (retrieval + build_prompt + generate).
* Voir `docs-site-interne/langfuse-observability.md` pour le détail.
Voir `docs-site-interne/08-vault-obsidian-retrieval.md` pour l'architecture. Voir `docs-site-interne/08-vault-obsidian-retrieval.md` pour l'architecture.
""" """
from __future__ import annotations from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
# observability doit être importé AVANT search pour que load_dotenv() pose les
# variables d'environnement que search.py lit (OLLAMA_URL, LLM_MODEL, etc.)
# au moment de son import.
from observability import flush as langfuse_flush
from observability import is_enabled as langfuse_enabled
from observability import langfuse
from search import ( from search import (
LLM_MODEL, LLM_MODEL,
MIN_SCORE, MIN_SCORE,
@ -28,22 +43,39 @@ from search import (
reload_vault, reload_vault,
) )
app = FastAPI(title="GrasBot LLM API", version="3.0.0")
@asynccontextmanager
async def lifespan(_: FastAPI):
"""Flush des traces Langfuse au shutdown pour ne rien perdre en buffer."""
yield
langfuse_flush()
app = FastAPI(title="GrasBot LLM API", version="3.1.0", lifespan=lifespan)
@app.get("/ask") @app.get("/ask")
async def ask_question(q: str): async def ask_question(
q: str,
session_id: Optional[str] = None,
user_id: Optional[str] = None,
):
"""Endpoint historique consommé par `app/utils/askAI.js`. """Endpoint historique consommé par `app/utils/askAI.js`.
Le front lit `data.response` : on conserve cette clé pour la compatibilité. Le front lit `data.response` : on conserve cette clé pour la compatibilité.
Les champs `sources` / `grounded` / `model` sont des ajouts non destructifs Les champs `sources` / `grounded` / `model` sont des ajouts non destructifs
utilisés par `ChatBot.js` pour afficher les sources cliquables. utilisés par `ChatBot.js` pour afficher les sources cliquables.
`session_id` et `user_id` sont optionnels et transmis pour Langfuse :
- `session_id` : UUID sessionStorage côté front (même conversation = mêmes questions regroupées).
- `user_id` : UUID localStorage côté front (anonyme, stable par device).
Ils sont propagés au span root par `search.answer()` via `langfuse.update_current_trace`.
""" """
if not q or not q.strip(): if not q or not q.strip():
raise HTTPException(status_code=400, detail="Paramètre `q` manquant ou vide.") raise HTTPException(status_code=400, detail="Paramètre `q` manquant ou vide.")
try: try:
return answer(q) return answer(q, session_id=session_id, user_id=user_id)
except Exception as exc: except Exception as exc:
print(f"❌ /ask failed ({exc})") print(f"❌ /ask failed ({exc})")
raise HTTPException(status_code=502, detail=str(exc)) raise HTTPException(status_code=502, detail=str(exc))
@ -53,7 +85,6 @@ async def ask_question(q: str):
async def health(): async def health():
"""Configuration active + stats du vault — utile pour debug / monitoring.""" """Configuration active + stats du vault — utile pour debug / monitoring."""
vault = load_vault() vault = load_vault()
# Stats rapides pour vérifier que le vault est bien chargé
by_type: dict[str, int] = {} by_type: dict[str, int] = {}
for n in vault.values(): for n in vault.values():
by_type[n.type] = by_type.get(n.type, 0) + 1 by_type[n.type] = by_type.get(n.type, 0) + 1
@ -71,6 +102,9 @@ async def health():
"top_k": TOP_K, "top_k": TOP_K,
"min_score": MIN_SCORE, "min_score": MIN_SCORE,
}, },
"observability": {
"langfuse_enabled": langfuse_enabled(),
},
} }

156
llm-api/observability.py Normal file
View File

@ -0,0 +1,156 @@
"""Observabilité GrasBot via Langfuse — init client + helpers de tracing.
Conçu pour être **optionnel** : si les variables d'environnement Langfuse ne sont pas
définies, le module expose un client *no-op* (dummy) qui ignore silencieusement tous
les appels. Ainsi l'API FastAPI reste fonctionnelle même sans instance Langfuse, et
les dev qui clonent le repo n'ont rien à configurer pour tester `/ask` localement.
Chargement des variables d'environnement :
1. On appelle `load_dotenv()` qui lit `llm-api/.env` s'il existe.
2. On lit `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, et l'URL (priorité à
`LANGFUSE_BASE_URL` c'est ce que l'UI Langfuse recopie dans ses snippets
puis fallback sur `LANGFUSE_HOST` pour compatibilité avec le SDK standard).
3. Si les 3 sont présentes vrai client Langfuse. Sinon no-op.
Voir `docs-site-interne/langfuse-observability.md` pour l'architecture et ce qu'on trace.
"""
from __future__ import annotations
import os
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterator
from dotenv import load_dotenv
# --------------------------------------------------------------------------
# 1. Chargement du .env local (à côté de ce fichier, donc llm-api/.env)
# --------------------------------------------------------------------------
_ENV_PATH = Path(__file__).resolve().parent / ".env"
load_dotenv(_ENV_PATH)
# --------------------------------------------------------------------------
# 2. Client no-op (fallback si Langfuse n'est pas configuré)
# --------------------------------------------------------------------------
class _NullSpan:
"""Remplace un span Langfuse quand l'instrumentation est désactivée."""
def update(self, **kwargs: Any) -> None: # noqa: D401
"""No-op."""
def update_trace(self, **kwargs: Any) -> None: # noqa: D401
"""No-op."""
def score(self, **kwargs: Any) -> None: # noqa: D401
"""No-op."""
def end(self, **kwargs: Any) -> None: # noqa: D401
"""No-op."""
def __enter__(self) -> "_NullSpan":
return self
def __exit__(self, *exc: Any) -> None:
return None
class _NullLangfuse:
"""Client Langfuse factice : toutes les méthodes sont des no-op."""
enabled = False
@contextmanager
def start_as_current_span(self, *args: Any, **kwargs: Any) -> Iterator[_NullSpan]:
yield _NullSpan()
@contextmanager
def start_as_current_observation(self, *args: Any, **kwargs: Any) -> Iterator[_NullSpan]:
yield _NullSpan()
def update_current_trace(self, **kwargs: Any) -> None:
pass
def update_current_span(self, **kwargs: Any) -> None:
pass
def score_current_trace(self, **kwargs: Any) -> None:
pass
def score_current_observation(self, **kwargs: Any) -> None:
pass
def flush(self) -> None:
pass
# --------------------------------------------------------------------------
# 3. Résolution de l'URL + construction du client
# --------------------------------------------------------------------------
def _resolve_host() -> str | None:
"""Priorité LANGFUSE_BASE_URL → LANGFUSE_HOST (compat SDK)."""
return os.environ.get("LANGFUSE_BASE_URL") or os.environ.get("LANGFUSE_HOST")
def _build_client() -> Any:
"""Tente de construire le vrai client Langfuse. Retourne _NullLangfuse en cas d'échec."""
public_key = os.environ.get("LANGFUSE_PUBLIC_KEY")
secret_key = os.environ.get("LANGFUSE_SECRET_KEY")
host = _resolve_host()
if not (public_key and secret_key and host):
missing = [
name
for name, value in (
("LANGFUSE_PUBLIC_KEY", public_key),
("LANGFUSE_SECRET_KEY", secret_key),
("LANGFUSE_BASE_URL/HOST", host),
)
if not value
]
print(
f" Langfuse désactivé — variables manquantes : {', '.join(missing)}. "
"L'API fonctionne normalement, aucun trace ne sera envoyée."
)
return _NullLangfuse()
try:
from langfuse import Langfuse
client = Langfuse(
public_key=public_key,
secret_key=secret_key,
host=host,
)
print(f"✅ Langfuse initialisé (host={host})")
return client
except Exception as exc: # pragma: no cover — défensif
print(
f"⚠️ Langfuse init failed ({exc.__class__.__name__}: {exc}). "
"L'API continue de fonctionner sans observabilité."
)
return _NullLangfuse()
# Singleton : un seul client pour toute la durée du process.
langfuse = _build_client()
# --------------------------------------------------------------------------
# 4. Helper pour obtenir l'attribut `enabled` quel que soit le client
# --------------------------------------------------------------------------
def is_enabled() -> bool:
"""True si le vrai client Langfuse tourne (utile pour skip des calculs coûteux)."""
# Le vrai client n'expose pas `.enabled` ; on vérifie par type.
return not isinstance(langfuse, _NullLangfuse)
# --------------------------------------------------------------------------
# 5. Flush côté shutdown (évite de perdre les dernières traces)
# --------------------------------------------------------------------------
def flush() -> None:
"""À appeler au shutdown de l'API pour forcer l'envoi des traces en buffer."""
try:
langfuse.flush()
except Exception as exc:
print(f"⚠️ Langfuse flush error : {exc}")

View File

@ -6,8 +6,20 @@
# - v2 : ajout chromadb + pyyaml (RAG vectoriel avec nomic-embed-text). # - v2 : ajout chromadb + pyyaml (RAG vectoriel avec nomic-embed-text).
# - v3 : retour à un pipeline graph + BM25, 100 % pure Python, pas de # - v3 : retour à un pipeline graph + BM25, 100 % pure Python, pas de
# compilation C++, pas d'embeddings (lecture directe de vault-grasbot/). # compilation C++, pas d'embeddings (lecture directe de vault-grasbot/).
# - v3.1 (2026-04-23) : ajout Langfuse pour observabilité complète du pipeline
# (retrieval + prompt + génération) + python-dotenv pour charger
# `llm-api/.env` automatiquement. Voir docs-site-interne/langfuse-observability.md.
fastapi>=0.110 fastapi>=0.110
uvicorn[standard]>=0.27 uvicorn[standard]>=0.27
requests>=2.31 requests>=2.31
pyyaml>=6.0 pyyaml>=6.0
# Observabilité (optionnelles en runtime : l'API fonctionne sans si les clés sont absentes).
# NB : on reste sur Langfuse 3.x tant que l'instrumentation dans `observability.py`
# et `search.py` utilise `start_as_current_span` / `start_as_current_observation`
# (API v3). La v4 du SDK a supprimé `start_as_current_span` et modifié la surface
# publique — si on veut migrer, il faudra réécrire ces deux fichiers puis relever
# le plafond ci-dessous.
langfuse>=3.0,<4
python-dotenv>=1.0

View File

@ -28,6 +28,14 @@ Variables d'environnement (toutes optionnelles) :
- `SEARCH_TOP_K` (default: 5) - `SEARCH_TOP_K` (default: 5)
- `SEARCH_MIN_SCORE` (default: 1.0) seuil en-dessous duquel on considère - `SEARCH_MIN_SCORE` (default: 1.0) seuil en-dessous duquel on considère
qu'aucune note pertinente n'a été trouvée. qu'aucune note pertinente n'a été trouvée.
Instrumentation Langfuse (2026-04-23) :
- `answer()` : trace racine. Metadata (session_id, user_id, tags grounded/model).
- `search()` : span `retrieval` avec scores, reasons, seeds, voisins du graphe.
- `build_prompt()` : span `prompt_build` avec system/user en output.
- `generate()` : span `generation` (type Langfuse spécial : tokens, latence, model).
Voir `docs-site-interne/langfuse-observability.md`.
""" """
from __future__ import annotations from __future__ import annotations
@ -35,6 +43,7 @@ from __future__ import annotations
import math import math
import os import os
import re import re
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
@ -43,6 +52,8 @@ from typing import Any
import requests import requests
import yaml import yaml
from observability import langfuse
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Configuration # Configuration
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -487,15 +498,38 @@ def expand_by_graph(seed: list[ScoredNote], vault: dict[str, Note],
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# API haut-niveau : search # Sérialisation pour Langfuse (évite de loguer des objets Python opaques)
# ---------------------------------------------------------------------------
def _scored_note_to_dict(s: ScoredNote) -> dict[str, Any]:
"""Projection JSON-safe d'une `ScoredNote` pour l'UI Langfuse."""
return {
"slug": s.note.slug,
"title": s.note.title,
"type": s.note.type,
"score": round(s.score, 3),
"reasons": s.reasons,
}
# ---------------------------------------------------------------------------
# API haut-niveau : search (instrumenté Langfuse)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def search(query: str, top_k: int | None = None) -> list[ScoredNote]: def search(query: str, top_k: int | None = None) -> list[ScoredNote]:
"""Retourne la liste des notes pertinentes pour `query`, triée par score.""" """Retourne la liste des notes pertinentes pour `query`, triée par score.
Tracé dans Langfuse comme un span `retrieval` on y log les tokens extraits,
les seeds avant expansion, les voisins ajoutés par le graphe, et le top-K final.
"""
top_k = top_k or TOP_K top_k = top_k or TOP_K
vault = load_vault() vault = load_vault()
if not vault: if not vault:
return [] return []
with langfuse.start_as_current_span(
name="retrieval",
input={"query": query, "top_k": top_k},
) as span:
t0 = time.perf_counter()
stats = _corpus_stats() stats = _corpus_stats()
query_tokens = tokenize_fr(query) query_tokens = tokenize_fr(query)
@ -507,11 +541,31 @@ def search(query: str, top_k: int | None = None) -> list[ScoredNote]:
seeds = scored[:3] seeds = scored[:3]
expanded = expand_by_graph(seeds, vault, max_extra=top_k - len(seeds)) expanded = expand_by_graph(seeds, vault, max_extra=top_k - len(seeds))
expanded.sort(key=lambda x: -x.score) expanded.sort(key=lambda x: -x.score)
return expanded[:top_k] result = expanded[:top_k]
elapsed_ms = (time.perf_counter() - t0) * 1000
span.update(
output=[_scored_note_to_dict(s) for s in result],
metadata={
"query_tokens": query_tokens,
"vault_size": len(vault),
"candidates_with_signal": len(scored),
"seeds_before_graph": [_scored_note_to_dict(s) for s in seeds],
"bm25_stats": {
"N": stats["N"],
"avgdl": round(stats["avgdl"], 2),
"idf_terms": len(stats["idf"]),
},
"elapsed_ms": round(elapsed_ms, 1),
},
)
return result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Prompt building # Prompt building (instrumenté)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SYSTEM_PROMPT = """Tu es GrasBot, l'assistant IA du portfolio de Fernand Gras-Calvet, étudiant à l'École 42 Perpignan. SYSTEM_PROMPT = """Tu es GrasBot, l'assistant IA du portfolio de Fernand Gras-Calvet, étudiant à l'École 42 Perpignan.
@ -532,6 +586,10 @@ def build_prompt(query: str, scored_notes: list[ScoredNote]) -> tuple[str, str]:
# Seuil : si toutes les notes sont en-dessous, on considère "pas de contexte pertinent" # Seuil : si toutes les notes sont en-dessous, on considère "pas de contexte pertinent"
relevant = [s for s in scored_notes if s.score >= MIN_SCORE] relevant = [s for s in scored_notes if s.score >= MIN_SCORE]
with langfuse.start_as_current_span(
name="prompt_build",
input={"query": query, "scored_count": len(scored_notes)},
) as span:
if relevant: if relevant:
context_blocks = [] context_blocks = []
for i, s in enumerate(relevant, 1): for i, s in enumerate(relevant, 1):
@ -555,27 +613,54 @@ def build_prompt(query: str, scored_notes: list[ScoredNote]) -> tuple[str, str]:
"Invite le visiteur à explorer /portfolio, /competences, /contact." "Invite le visiteur à explorer /portfolio, /competences, /contact."
) )
grounded = bool(relevant)
span.update(
output={"system": SYSTEM_PROMPT, "user": user},
metadata={
"grounded": grounded,
"relevant_notes": [_scored_note_to_dict(s) for s in relevant],
"system_chars": len(SYSTEM_PROMPT),
"user_chars": len(user),
"min_score_threshold": MIN_SCORE,
},
)
return SYSTEM_PROMPT, user return SYSTEM_PROMPT, user
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Génération via Ollama # Génération via Ollama (instrumenté comme "generation" Langfuse)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def generate(system: str, user: str) -> str: def generate(system: str, user: str) -> str:
"""Appelle Ollama `/api/chat` et renvoie le texte de réponse.""" """Appelle Ollama `/api/chat` et renvoie le texte de réponse.
Span Langfuse de type `generation` expose latence, modèle, paramètres,
et tokens (si l'API Ollama les retourne dans `prompt_eval_count` /
`eval_count`) comme un LLM-call standard dans le dashboard.
"""
model_params = {
"temperature": 0.4,
"num_predict": 512,
}
messages = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
with langfuse.start_as_current_observation(
as_type="generation",
name="ollama-chat",
model=LLM_MODEL,
input=messages,
model_parameters=model_params,
) as generation:
response = requests.post( response = requests.post(
f"{OLLAMA_URL}/api/chat", f"{OLLAMA_URL}/api/chat",
json={ json={
"model": LLM_MODEL, "model": LLM_MODEL,
"messages": [ "messages": messages,
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"stream": False, "stream": False,
"options": { "options": model_params,
"temperature": 0.4,
"num_predict": 512,
},
"keep_alive": "30m", "keep_alive": "30m",
}, },
timeout=180, timeout=180,
@ -585,16 +670,43 @@ def generate(system: str, user: str) -> str:
message = data.get("message") or {} message = data.get("message") or {}
content = message.get("content", "").strip() content = message.get("content", "").strip()
if not content: if not content:
generation.update(
output=None,
metadata={"ollama_raw": data},
level="ERROR",
status_message=f"Empty response from model '{LLM_MODEL}'",
)
raise RuntimeError( raise RuntimeError(
f"generate: réponse vide du modèle '{LLM_MODEL}' — vérifier qu'il est pullé." f"generate: réponse vide du modèle '{LLM_MODEL}' — vérifier qu'il est pullé."
) )
# Ollama renvoie parfois les comptes de tokens — on les propage si dispos
# (compatible avec le format Langfuse "usage").
usage: dict[str, int] = {}
if "prompt_eval_count" in data:
usage["input"] = int(data["prompt_eval_count"])
if "eval_count" in data:
usage["output"] = int(data["eval_count"])
if usage:
usage["total"] = usage.get("input", 0) + usage.get("output", 0)
update_kwargs: dict[str, Any] = {"output": content}
if usage:
update_kwargs["usage_details"] = usage
generation.update(**update_kwargs)
return content return content
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Façade haut-niveau # Façade haut-niveau — trace racine Langfuse
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def answer(query: str, top_k: int | None = None) -> dict[str, Any]: def answer(
query: str,
top_k: int | None = None,
session_id: str | None = None,
user_id: str | None = None,
) -> dict[str, Any]:
"""Entrée principale consommée par `api.py`. """Entrée principale consommée par `api.py`.
Retourne : Retourne :
@ -605,11 +717,42 @@ def answer(query: str, top_k: int | None = None) -> dict[str, Any]:
"grounded": bool, # True si au moins 1 note a dépassé MIN_SCORE "grounded": bool, # True si au moins 1 note a dépassé MIN_SCORE
"vault_size": int, "vault_size": int,
} }
Côté Langfuse, crée une trace racine `ask` qui englobe :
- span `retrieval`
- span `prompt_build`
- span `generation` (type generation : model, params, usage)
Avec session_id/user_id propagés au trace-level pour regroupement dans Langfuse.
""" """
with langfuse.start_as_current_span(
name="ask",
input={"query": query},
) as root_span:
# Méta au niveau de la TRACE (pas du span), pour filtrer/grouper dans l'UI.
trace_metadata: dict[str, Any] = {
"top_k": top_k or TOP_K,
"min_score": MIN_SCORE,
}
trace_update: dict[str, Any] = {
"name": "ask",
"input": {"query": query},
"metadata": trace_metadata,
}
if session_id:
trace_update["session_id"] = session_id
if user_id:
trace_update["user_id"] = user_id
langfuse.update_current_trace(**trace_update)
# --- Pipeline ---
t0 = time.perf_counter()
scored = search(query, top_k=top_k) scored = search(query, top_k=top_k)
system, user = build_prompt(query, scored) system, user = build_prompt(query, scored)
text = generate(system, user) text = generate(system, user)
elapsed_ms = (time.perf_counter() - t0) * 1000
# --- Construction de la réponse API ---
sources = [] sources = []
for s in scored: for s in scored:
url = None url = None
@ -627,6 +770,53 @@ def answer(query: str, top_k: int | None = None) -> dict[str, Any]:
}) })
grounded = any(s.score >= MIN_SCORE for s in scored) grounded = any(s.score >= MIN_SCORE for s in scored)
max_score = max((s.score for s in scored), default=0.0)
# Score normalisé pour Langfuse : 0 si pas de contexte, sinon
# min(max_score / 15, 1) — 15 ≈ score typique d'un match fort (title + alias).
retrieval_relevance = min(max_score / 15.0, 1.0)
# --- Finalisation : output + scores + tags sur la trace ---
tags = [
"grounded" if grounded else "ungrounded",
f"model:{LLM_MODEL}",
]
if not scored:
tags.append("vault-miss")
langfuse.update_current_trace(
output={
"response": text,
"sources_count": len(sources),
"grounded": grounded,
},
tags=tags,
)
# Scores Langfuse : permettent de filtrer le dashboard (ex. "toutes les
# traces non-grounded du mois") et de tracer des régressions.
try:
langfuse.score_current_trace(
name="grounded",
value=1.0 if grounded else 0.0,
data_type="BOOLEAN",
)
langfuse.score_current_trace(
name="retrieval_relevance",
value=round(retrieval_relevance, 3),
data_type="NUMERIC",
)
except Exception as exc: # pragma: no cover
print(f"⚠ score_current_trace failed: {exc}")
root_span.update(
output={"response_chars": len(text)},
metadata={
"elapsed_ms": round(elapsed_ms, 1),
"sources_count": len(sources),
"max_score": round(max_score, 2),
"grounded": grounded,
},
)
return { return {
"response": text, "response": text,

View File

@ -49,7 +49,7 @@ visibility: public
## Identité ## Identité
- **Nom** : Gras-Calvet Fernand - **Nom** : Gras-Calvet Fernand
- **Âge** : 46 ans - **Âge** : 47 ans
- **Situation** : Étudiant en informatique, École 42 Perpignan - **Situation** : Étudiant en informatique, École 42 Perpignan
- **Objectif** : Alternance **Data / IA** (2 ans) - **Objectif** : Alternance **Data / IA** (2 ans)
- **RQTH** : reconversion professionnelle suite à problèmes de santé - **RQTH** : reconversion professionnelle suite à problèmes de santé