mirror of
https://github.com/Ladebeze66/devsite.git
synced 2026-05-11 16:56:26 +02:00
langfuse
This commit is contained in:
parent
916ae8dfef
commit
d6949309f1
19
.env.example
Normal file
19
.env.example
Normal 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
5
.gitignore
vendored
@ -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
|
||||||
|
|||||||
@ -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é)
|
||||||
|
|||||||
@ -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) {
|
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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
try {
|
||||||
const response = await fetch(apiUrl, {
|
const response = await fetch(apiUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return new Response(JSON.stringify(data), {
|
return new Response(JSON.stringify(data), {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
headers: {
|
headers: {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response(JSON.stringify({ error: "Erreur de communication avec l'API" }), {
|
return new Response(
|
||||||
status: 500,
|
JSON.stringify({ error: "Erreur de communication avec l'API" }),
|
||||||
});
|
{
|
||||||
}
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
56
app/utils/grasbotIds.js
Normal 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");
|
||||||
|
}
|
||||||
@ -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).
|
||||||
|
|
||||||
|
|||||||
190
docs-site-interne/langfuse-observability.md
Normal file
190
docs-site-interne/langfuse-observability.md
Normal 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
21
llm-api/.env.example
Normal 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
|
||||||
Binary file not shown.
@ -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
156
llm-api/observability.py
Normal 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}")
|
||||||
@ -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
|
||||||
|
|||||||
@ -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,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]:
|
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 []
|
||||||
|
|
||||||
stats = _corpus_stats()
|
with langfuse.start_as_current_span(
|
||||||
query_tokens = tokenize_fr(query)
|
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 = [score_note(note, query, query_tokens, stats) for note in vault.values()]
|
||||||
scored = [s for s in scored if s.score > 0]
|
scored = [s for s in scored if s.score > 0]
|
||||||
scored.sort(key=lambda x: -x.score)
|
scored.sort(key=lambda x: -x.score)
|
||||||
|
|
||||||
# Top-N brut avant expansion (garde 3 seeds pour expansion graphe)
|
# Top-N brut avant expansion (garde 3 seeds pour expansion graphe)
|
||||||
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,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"
|
# 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]
|
||||||
|
|
||||||
if relevant:
|
with langfuse.start_as_current_span(
|
||||||
context_blocks = []
|
name="prompt_build",
|
||||||
for i, s in enumerate(relevant, 1):
|
input={"query": query, "scored_count": len(scored_notes)},
|
||||||
n = s.note
|
) as span:
|
||||||
header = f"[SOURCE {i} · slug={n.slug} · type={n.type} · score={s.score:.1f}] {n.title}"
|
if relevant:
|
||||||
context_blocks.append(f"{header}\n{n.body}")
|
context_blocks = []
|
||||||
context = "\n\n---\n\n".join(context_blocks)
|
for i, s in enumerate(relevant, 1):
|
||||||
user = (
|
n = s.note
|
||||||
"Voici les notes pertinentes du vault personnel de Fernand :\n\n"
|
header = f"[SOURCE {i} · slug={n.slug} · type={n.type} · score={s.score:.1f}] {n.title}"
|
||||||
f"{context}\n\n"
|
context_blocks.append(f"{header}\n{n.body}")
|
||||||
"---\n\n"
|
context = "\n\n---\n\n".join(context_blocks)
|
||||||
f"Question du visiteur : {query}\n\n"
|
user = (
|
||||||
"Réponds en t'appuyant sur ces notes. Si la question dépasse leur portée, dis-le."
|
"Voici les notes pertinentes du vault personnel de Fernand :\n\n"
|
||||||
)
|
f"{context}\n\n"
|
||||||
else:
|
"---\n\n"
|
||||||
user = (
|
f"Question du visiteur : {query}\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."
|
||||||
"Note : aucune fiche du vault ne correspond clairement à cette question. "
|
)
|
||||||
"Réponds sobrement à partir de tes connaissances générales, "
|
else:
|
||||||
"sans inventer de faits spécifiques sur Fernand. "
|
user = (
|
||||||
"Invite le visiteur à explorer /portfolio, /competences, /contact."
|
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:
|
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.
|
||||||
response = requests.post(
|
|
||||||
f"{OLLAMA_URL}/api/chat",
|
Span Langfuse de type `generation` → expose latence, modèle, paramètres,
|
||||||
json={
|
et tokens (si l'API Ollama les retourne dans `prompt_eval_count` /
|
||||||
"model": LLM_MODEL,
|
`eval_count`) comme un LLM-call standard dans le dashboard.
|
||||||
"messages": [
|
"""
|
||||||
{"role": "system", "content": system},
|
model_params = {
|
||||||
{"role": "user", "content": user},
|
"temperature": 0.4,
|
||||||
],
|
"num_predict": 512,
|
||||||
"stream": False,
|
}
|
||||||
"options": {
|
messages = [
|
||||||
"temperature": 0.4,
|
{"role": "system", "content": system},
|
||||||
"num_predict": 512,
|
{"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,
|
||||||
},
|
|
||||||
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é."
|
|
||||||
)
|
)
|
||||||
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`.
|
"""Entrée principale consommée par `api.py`.
|
||||||
|
|
||||||
Retourne :
|
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
|
"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.
|
||||||
"""
|
"""
|
||||||
scored = search(query, top_k=top_k)
|
with langfuse.start_as_current_span(
|
||||||
system, user = build_prompt(query, scored)
|
name="ask",
|
||||||
text = generate(system, user)
|
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 = []
|
langfuse.update_current_trace(**trace_update)
|
||||||
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)
|
# --- 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 {
|
# --- Construction de la réponse API ---
|
||||||
"response": text,
|
sources = []
|
||||||
"sources": sources,
|
for s in scored:
|
||||||
"model": LLM_MODEL,
|
url = None
|
||||||
"grounded": grounded,
|
if s.note.type == "projet":
|
||||||
"vault_size": len(load_vault()),
|
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()),
|
||||||
|
}
|
||||||
|
|||||||
@ -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é
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user