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*
llm-api/.env
llm-api/.env.local
# Templates documentaires : forcer l'inclusion (ils contiennent des placeholders).
!.env.example
!llm-api/.env.example
# vercel
.vercel

View File

@ -14,12 +14,16 @@ Ce site utilise une architecture full-stack moderne avec :
my-next-site/
├── app/ # Application Next.js
├── 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
├── stop-my-site.ps1 # Script d'arrêt propre
└── 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
### Script Automatique (Recommandé)

View File

@ -1,31 +1,63 @@
/**
* 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) {
const { searchParams } = new URL(req.url);
const question = searchParams.get("q");
const { searchParams } = new URL(req.url);
const question = searchParams.get("q");
if (!question) {
return new Response(JSON.stringify({ error: "Question manquante" }), { status: 400 });
if (!question) {
return new Response(JSON.stringify({ error: "Question manquante" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// 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 = `https://llmapi.fernandgrascalvet.com/ask?q=${encodeURIComponent(question)}`;
const apiUrl = `${UPSTREAM_BASE}/ask?${upstream.toString()}`;
try {
const response = await fetch(apiUrl, {
headers: {
"Content-Type": "application/json",
},
});
try {
const response = await fetch(apiUrl, {
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();
return new Response(JSON.stringify(data), {
status: response.status,
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json",
},
});
} catch (error) {
return new Response(JSON.stringify({ error: "Erreur de communication avec l'API" }), {
status: 500,
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
status: response.status,
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json",
},
});
} catch (error) {
return new Response(
JSON.stringify({ error: "Erreur de communication avec l'API" }),
{
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).
*
@ -7,6 +9,9 @@
* - 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
* 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
* @returns {Promise<{
@ -21,8 +26,14 @@ export async function askAI(question) {
const controller = new AbortController();
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 {
const response = await fetch(`/api/proxy?q=${encodeURIComponent(question)}`, {
const response = await fetch(`/api/proxy?${params.toString()}`, {
signal: controller.signal,
});
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).
- Sections : `ContentSection.tsx`, `ContentSectionCompetences*.tsx`.
- `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`.
- `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).
* Plus de dépendance ChromaDB ni hnswlib ni nomic-embed-text.
* 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.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Optional
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 (
LLM_MODEL,
MIN_SCORE,
@ -28,22 +43,39 @@ from search import (
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")
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`.
Le front lit `data.response` : on conserve cette clé pour la compatibilité.
Les champs `sources` / `grounded` / `model` sont des ajouts non destructifs
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():
raise HTTPException(status_code=400, detail="Paramètre `q` manquant ou vide.")
try:
return answer(q)
return answer(q, session_id=session_id, user_id=user_id)
except Exception as exc:
print(f"❌ /ask failed ({exc})")
raise HTTPException(status_code=502, detail=str(exc))
@ -53,7 +85,6 @@ async def ask_question(q: str):
async def health():
"""Configuration active + stats du vault — utile pour debug / monitoring."""
vault = load_vault()
# Stats rapides pour vérifier que le vault est bien chargé
by_type: dict[str, int] = {}
for n in vault.values():
by_type[n.type] = by_type.get(n.type, 0) + 1
@ -71,6 +102,9 @@ async def health():
"top_k": TOP_K,
"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).
# - v3 : retour à un pipeline graph + BM25, 100 % pure Python, pas de
# 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
uvicorn[standard]>=0.27
requests>=2.31
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_MIN_SCORE` (default: 1.0) seuil en-dessous duquel on considère
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
@ -35,6 +43,7 @@ from __future__ import annotations
import math
import os
import re
import time
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
@ -43,6 +52,8 @@ from typing import Any
import requests
import yaml
from observability import langfuse
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
@ -487,31 +498,74 @@ 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]:
"""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
vault = load_vault()
if not vault:
return []
stats = _corpus_stats()
query_tokens = tokenize_fr(query)
with langfuse.start_as_current_span(
name="retrieval",
input={"query": query, "top_k": top_k},
) as span:
t0 = time.perf_counter()
stats = _corpus_stats()
query_tokens = tokenize_fr(query)
scored = [score_note(note, query, query_tokens, stats) for note in vault.values()]
scored = [s for s in scored if s.score > 0]
scored.sort(key=lambda x: -x.score)
scored = [score_note(note, query, query_tokens, stats) for note in vault.values()]
scored = [s for s in scored if s.score > 0]
scored.sort(key=lambda x: -x.score)
# Top-N brut avant expansion (garde 3 seeds pour expansion graphe)
seeds = scored[:3]
expanded = expand_by_graph(seeds, vault, max_extra=top_k - len(seeds))
expanded.sort(key=lambda x: -x.score)
return expanded[:top_k]
# Top-N brut avant expansion (garde 3 seeds pour expansion graphe)
seeds = scored[:3]
expanded = expand_by_graph(seeds, vault, max_extra=top_k - len(seeds))
expanded.sort(key=lambda x: -x.score)
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.
@ -532,69 +586,127 @@ 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"
relevant = [s for s in scored_notes if s.score >= MIN_SCORE]
if relevant:
context_blocks = []
for i, s in enumerate(relevant, 1):
n = s.note
header = f"[SOURCE {i} · slug={n.slug} · type={n.type} · score={s.score:.1f}] {n.title}"
context_blocks.append(f"{header}\n{n.body}")
context = "\n\n---\n\n".join(context_blocks)
user = (
"Voici les notes pertinentes du vault personnel de Fernand :\n\n"
f"{context}\n\n"
"---\n\n"
f"Question du visiteur : {query}\n\n"
"Réponds en t'appuyant sur ces notes. Si la question dépasse leur portée, dis-le."
)
else:
user = (
f"Question du visiteur : {query}\n\n"
"Note : aucune fiche du vault ne correspond clairement à cette question. "
"Réponds sobrement à partir de tes connaissances générales, "
"sans inventer de faits spécifiques sur Fernand. "
"Invite le visiteur à explorer /portfolio, /competences, /contact."
with langfuse.start_as_current_span(
name="prompt_build",
input={"query": query, "scored_count": len(scored_notes)},
) as span:
if relevant:
context_blocks = []
for i, s in enumerate(relevant, 1):
n = s.note
header = f"[SOURCE {i} · slug={n.slug} · type={n.type} · score={s.score:.1f}] {n.title}"
context_blocks.append(f"{header}\n{n.body}")
context = "\n\n---\n\n".join(context_blocks)
user = (
"Voici les notes pertinentes du vault personnel de Fernand :\n\n"
f"{context}\n\n"
"---\n\n"
f"Question du visiteur : {query}\n\n"
"Réponds en t'appuyant sur ces notes. Si la question dépasse leur portée, dis-le."
)
else:
user = (
f"Question du visiteur : {query}\n\n"
"Note : aucune fiche du vault ne correspond clairement à cette question. "
"Réponds sobrement à partir de tes connaissances générales, "
"sans inventer de faits spécifiques sur Fernand. "
"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:
"""Appelle Ollama `/api/chat` et renvoie le texte de réponse."""
response = requests.post(
f"{OLLAMA_URL}/api/chat",
json={
"model": LLM_MODEL,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"stream": False,
"options": {
"temperature": 0.4,
"num_predict": 512,
"""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(
f"{OLLAMA_URL}/api/chat",
json={
"model": LLM_MODEL,
"messages": messages,
"stream": False,
"options": model_params,
"keep_alive": "30m",
},
"keep_alive": "30m",
},
timeout=180,
)
response.raise_for_status()
data = response.json()
message = data.get("message") or {}
content = message.get("content", "").strip()
if not content:
raise RuntimeError(
f"generate: réponse vide du modèle '{LLM_MODEL}' — vérifier qu'il est pullé."
timeout=180,
)
return content
response.raise_for_status()
data = response.json()
message = data.get("message") or {}
content = message.get("content", "").strip()
if not content:
generation.update(
output=None,
metadata={"ollama_raw": data},
level="ERROR",
status_message=f"Empty response from model '{LLM_MODEL}'",
)
raise RuntimeError(
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
# ---------------------------------------------------------------------------
# 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`.
Retourne :
@ -605,33 +717,111 @@ 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
"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.
"""
scored = search(query, top_k=top_k)
system, user = build_prompt(query, scored)
text = generate(system, user)
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
sources = []
for s in scored:
url = None
if s.note.type == "projet":
url = f"/portfolio/{s.note.slug}"
elif s.note.type == "competence":
url = f"/competences/{s.note.slug}"
sources.append({
"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 {}),
})
langfuse.update_current_trace(**trace_update)
grounded = any(s.score >= MIN_SCORE for s in scored)
# --- Pipeline ---
t0 = time.perf_counter()
scored = search(query, top_k=top_k)
system, user = build_prompt(query, scored)
text = generate(system, user)
elapsed_ms = (time.perf_counter() - t0) * 1000
return {
"response": text,
"sources": sources,
"model": LLM_MODEL,
"grounded": grounded,
"vault_size": len(load_vault()),
}
# --- 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":
url = f"/competences/{s.note.slug}"
sources.append({
"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 {}),
})
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 {
"response": text,
"sources": sources,
"model": LLM_MODEL,
"grounded": grounded,
"vault_size": len(load_vault()),
}

View File

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