diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f01bf5a --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 4118d02..be7cacb 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CONFIGURATION_SITE.md b/CONFIGURATION_SITE.md index 47275b0..bb90492 100644 --- a/CONFIGURATION_SITE.md +++ b/CONFIGURATION_SITE.md @@ -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é) diff --git a/app/api/proxy/route.js b/app/api/proxy/route.js index fc0be07..8aa3062 100644 --- a/app/api/proxy/route.js +++ b/app/api/proxy/route.js @@ -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" }, + } + ); + } } diff --git a/app/utils/askAI.js b/app/utils/askAI.js index 0254ad0..ba074a5 100644 --- a/app/utils/askAI.js +++ b/app/utils/askAI.js @@ -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); diff --git a/app/utils/grasbotIds.js b/app/utils/grasbotIds.js new file mode 100644 index 0000000..d76060e --- /dev/null +++ b/app/utils/grasbotIds.js @@ -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"); +} diff --git a/docs-site-interne/02-frontend-next.md b/docs-site-interne/02-frontend-next.md index b7bbeaa..d8223ec 100644 --- a/docs-site-interne/02-frontend-next.md +++ b/docs-site-interne/02-frontend-next.md @@ -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). diff --git a/docs-site-interne/langfuse-observability.md b/docs-site-interne/langfuse-observability.md new file mode 100644 index 0000000..847a43e --- /dev/null +++ b/docs-site-interne/langfuse-observability.md @@ -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. diff --git a/llm-api/.env.example b/llm-api/.env.example new file mode 100644 index 0000000..afe9926 --- /dev/null +++ b/llm-api/.env.example @@ -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 diff --git a/llm-api/__pycache__/api.cpython-313.pyc b/llm-api/__pycache__/api.cpython-313.pyc index 9182534..407c303 100644 Binary files a/llm-api/__pycache__/api.cpython-313.pyc and b/llm-api/__pycache__/api.cpython-313.pyc differ diff --git a/llm-api/api.py b/llm-api/api.py index 104b765..145cf2d 100644 --- a/llm-api/api.py +++ b/llm-api/api.py @@ -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(), + }, } diff --git a/llm-api/observability.py b/llm-api/observability.py new file mode 100644 index 0000000..fa99d2c --- /dev/null +++ b/llm-api/observability.py @@ -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}") diff --git a/llm-api/requirements.txt b/llm-api/requirements.txt index 15adbab..9d66478 100644 --- a/llm-api/requirements.txt +++ b/llm-api/requirements.txt @@ -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 diff --git a/llm-api/search.py b/llm-api/search.py index 170e9de..f179a28 100644 --- a/llm-api/search.py +++ b/llm-api/search.py @@ -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()), + } diff --git a/vault-grasbot/30-Parcours/cv-grascalvet-fernand.md b/vault-grasbot/30-Parcours/cv-grascalvet-fernand.md index 0073034..933f377 100644 --- a/vault-grasbot/30-Parcours/cv-grascalvet-fernand.md +++ b/vault-grasbot/30-Parcours/cv-grascalvet-fernand.md @@ -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é