From a0e59442f4dbbba07ed3e068c077e3ea8d655b20 Mon Sep 17 00:00:00 2001 From: Ladebeze66 Date: Thu, 23 Apr 2026 15:42:43 +0200 Subject: [PATCH] chat_bio --- CONFIGURATION_SITE.md | 4 + docs-site-interne/langfuse-observability.md | 39 +++++- llm-api/search.py | 127 ++++++++++++++++-- vault-grasbot/30-Parcours/bio-fernand.md | 69 ++++++++++ .../30-Parcours/cv-grascalvet-fernand.md | 3 +- 5 files changed, 226 insertions(+), 16 deletions(-) create mode 100644 vault-grasbot/30-Parcours/bio-fernand.md diff --git a/CONFIGURATION_SITE.md b/CONFIGURATION_SITE.md index bb90492..1019d8e 100644 --- a/CONFIGURATION_SITE.md +++ b/CONFIGURATION_SITE.md @@ -79,6 +79,10 @@ uvicorn api:app --host 0.0.0.0 --port 8000 --reload - **API** : http://localhost:8000 - **Endpoint IA** : http://localhost:8000/ask?q=votre_question - **Documentation** : http://localhost:8000/docs +- **Santé** : http://localhost:8000/health — renvoie `status`, `ollama_url`, `llm_model`, métadonnées vault, et `observability.langfuse_enabled`. +- **Recharger le vault à chaud** : `POST http://localhost:8000/reload-vault` — à appeler après création/modification d'une note dans `vault-grasbot/` (sinon `load_vault()` reste en cache mémoïsé jusqu'au prochain redémarrage d'uvicorn). + +> **Tuning du pipeline LLM** : les paramètres Ollama (`num_ctx`, `num_predict`, `think`), la troncature des sources RAG secondaires (`SEARCH_SECONDARY_MAX_CHARS`, `SEARCH_SECONDARY_KEEP_RATIO`), le system prompt anti-hallucination et la note `bio-fernand` sont documentés en détail dans `docs-site-interne/langfuse-observability.md` (section *Tuning du pipeline — 2026-04-23*). ## 📊 Ports Utilisés diff --git a/docs-site-interne/langfuse-observability.md b/docs-site-interne/langfuse-observability.md index 847a43e..189ed88 100644 --- a/docs-site-interne/langfuse-observability.md +++ b/docs-site-interne/langfuse-observability.md @@ -105,12 +105,16 @@ Les variables Langfuse **ne sont pas** dans `.env.local` de Next.js — elles ne - `relevant_notes` : notes effectivement incluses dans le contexte - `system_chars`, `user_chars` : tailles utiles pour debug de fenêtre de contexte - `min_score_threshold` : valeur du `MIN_SCORE` au moment de l'appel + - `truncation` : `{ secondary_max_chars, secondary_keep_ratio, truncated_notes: [...] }` — + liste des sources rank 2+ résumées automatiquement (avec leur slug, score, + taille d'origine et taille tronquée). Vide s'il n'y a eu aucune troncature. ### Span `ollama-chat` (type **generation**) - **input** : `[{role: "system", content}, {role: "user", content}]` - **output** : réponse brute du modèle - **model** : `LLM_MODEL` (ex. `qwen3:8b`) -- **model_parameters** : `{temperature: 0.4, num_predict: 512}` +- **model_parameters** : `{temperature: 0.4, num_ctx: 8192, num_predict: 1024, think: false}` + (voir section "Tuning 2026-04-23" ci-dessous pour le rationnel). - **usage** : `{input, output, total}` — extraits de `prompt_eval_count` / `eval_count` si Ollama les renvoie - Si réponse vide → span `level: ERROR` avec le payload Ollama brut en metadata. @@ -182,6 +186,39 @@ Si Langfuse tombe en panne ou si l'instrumentation pose un souci : - Le client Langfuse envoie les traces **en asynchrone** avec un buffer → bien appeler `flush()` au shutdown pour ne rien perdre (déjà fait via le `lifespan` FastAPI). - **Contenu sensible** : les prompts complets passent dans Langfuse. Vérifier que **le vault ne contient pas d'infos privées** (`visibility: private` est filtré côté search, mais si tu ajoutais un jour un vault mixte public/privé, il faudrait un filtre supplémentaire avant l'envoi à Langfuse). +## Tuning du pipeline — 2026-04-23 + +Audit des premières traces après mise en production : les réponses sur les +questions biographiques ("qui est Fernand ?") étaient parfois **hallucinées** +(âge erroné, statut inventé) et les réponses longues **tronquées** en plein +milieu. Quatre ajustements ciblés ont stabilisé le comportement : + +| # | Fichier | Changement | Effet attendu | +|---|---------|------------|---------------| +| 1 | `search.py` · `generate()` | `num_ctx` explicite à **8192** | Fin de la troncature silencieuse du prompt (le défaut Ollama à 2048/4096 coupait le début du contexte quand plusieurs notes entières étaient injectées). | +| 1 | `search.py` · `generate()` | `num_predict` **512 → 1024** | Réponses longues (descriptions de projet, explications) ne sont plus coupées en plein milieu. | +| 1 | `search.py` · `generate()` | `think: false` **top-level** | Désactive le mode *thinking* de qwen3. Le modèle n'utilise plus de budget de sortie pour du raisonnement interne. | +| 2 | `search.py` · `build_prompt()` | Troncature conditionnelle des sources **rank 2+** | Les notes secondaires (ex. `inception` sur une question bio) sont résumées à `SEARCH_SECONDARY_MAX_CHARS` chars quand leur score est < `SEARCH_SECONDARY_KEEP_RATIO` × score(#1). Réduit le bruit sans supprimer de source. | +| 3 | `vault-grasbot/30-Parcours/bio-fernand.md` | **Nouvelle note** dédiée à la présentation courte | Source canonique pour les questions du type *"qui est Fernand"*. Priorité 10, aliases biographiques courts. Renvoie vers le CV complet pour le détail. | +| 3 | CV (`cv-grascalvet-fernand.md`) | Incohérence d'âge corrigée (46 → 47 ans) | Supprime la contradiction interne qui alimentait les hallucinations sur l'âge. | +| 4 | `search.py` · `SYSTEM_PROMPT` | Section "Règles de fidélité aux sources" | Force le modèle à (a) s'appuyer en priorité sur `type=parcours` pour les questions bio, (b) ne jamais inventer un fait factuel, (c) écrire *« non précisé dans les notes »* si l'info manque, (d) gérer les contradictions, (e) signaler les notes tronquées. | + +Observabilité : dans les spans Langfuse, `prompt_build.metadata.truncation` +liste chaque source tronquée automatiquement → sert de point de vigilance pour +vérifier que la troncature reste pertinente (et n'écrase pas une source qu'on +aurait dû garder entière). + +Variables d'environnement associées (dans `llm-api/.env` ou shell) : + +| Variable | Défaut | Effet | +|----------|--------|-------| +| `SEARCH_SECONDARY_MAX_CHARS` | `1500` | Taille max des sources secondaires dans le prompt | +| `SEARCH_SECONDARY_KEEP_RATIO` | `0.8` | Tant que score(rank≥2) ≥ ratio × score(#1) → source gardée entière | + +Rappel : `load_vault()` est mémoïsé. Après création/modification d'une note du +vault, appeler `POST /reload-vault` pour recharger le cache sans redémarrer +uvicorn (voir `api.py`). + ## Évolutions futures possibles - **Feedback utilisateur** : ajouter un 👍/👎 sur chaque réponse bot dans `ChatBot.js`, relayé à `/api/feedback` qui appellerait `langfuse.score(trace_id, name="user_feedback", value=1|0)`. Le `trace_id` serait retourné par `/ask` (actuellement omis). diff --git a/llm-api/search.py b/llm-api/search.py index f179a28..0b6f473 100644 --- a/llm-api/search.py +++ b/llm-api/search.py @@ -22,12 +22,20 @@ Pipeline : Variables d'environnement (toutes optionnelles) : -- `OLLAMA_URL` (default: http://localhost:11434) -- `LLM_MODEL` (default: qwen3:8b) -- `VAULT_DIR` (default: /vault-grasbot) -- `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. +- `OLLAMA_URL` (default: http://localhost:11434) +- `LLM_MODEL` (default: qwen3:8b) +- `VAULT_DIR` (default: /vault-grasbot) +- `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. +- `SEARCH_SECONDARY_MAX_CHARS` (default: 1500) — taille max (en chars) du body + des sources rank 2+ dans le prompt. Les sources + dépassant cette limite sont tronquées à la + frontière de paragraphe la plus proche. +- `SEARCH_SECONDARY_KEEP_RATIO` (default: 0.8) — seuil relatif au score de la + source #1. Tant que score(rank>=2) est ≥ + ratio × score(#1), la source est gardée + entière (considérée aussi pertinente). Instrumentation Langfuse (2026-04-23) : @@ -66,6 +74,16 @@ VAULT_DIR = Path(os.environ.get("VAULT_DIR", _DEFAULT_VAULT)) TOP_K = int(os.environ.get("SEARCH_TOP_K", "5")) MIN_SCORE = float(os.environ.get("SEARCH_MIN_SCORE", "1.0")) +# Troncature des sources secondaires dans le prompt (étape 2, 2026-04-23). +# Rationnel : BM25 peut remonter des projets entiers (ex. `inception`, `cpp-partie2`) +# avec un score respectable pour des questions biographiques — ils polluent le +# contexte sans apporter d'info pertinente. On garde la source #1 entière et on +# tronque uniquement les sources rank 2+ dont le score est < SECONDARY_KEEP_RATIO +# fois celui de la #1, ET dont le body dépasse SECONDARY_MAX_CHARS caractères. +# Aucune source n'est jamais supprimée : le modèle voit toujours le top-K complet. +SECONDARY_MAX_CHARS = int(os.environ.get("SEARCH_SECONDARY_MAX_CHARS", "1500")) +SECONDARY_KEEP_RATIO = float(os.environ.get("SEARCH_SECONDARY_KEEP_RATIO", "0.8")) + # --------------------------------------------------------------------------- # Tokenisation FR (stop-words minimalistes, suffisants pour 36 notes) # --------------------------------------------------------------------------- @@ -573,29 +591,93 @@ Ton rôle : - Répondre aux visiteurs du site sur le parcours, les projets, les compétences de Fernand. - T'appuyer sur les notes du vault personnel fournies dans le contexte. -Règles : +Règles de ton : - Réponds en français, ton sobre et précis, sans emojis. - Cite tes sources entre crochets carrés en utilisant le slug (ex. [push-swap], [ia]). -- Si l'information n'apparaît pas dans les notes fournies, dis-le honnêtement et oriente vers le site (/portfolio, /competences, /contact) sans inventer. - Reste concis (3 à 6 phrases en général), sauf demande explicite de détail. -- Si la question est hors sujet (ex. question généraliste sans rapport avec Fernand), indique poliment ton rôle et invite à poser une question sur son parcours.""" +- Si la question est hors sujet (ex. question généraliste sans rapport avec Fernand), indique poliment ton rôle et invite à poser une question sur son parcours. + +Règles de fidélité aux sources (important) : +- Chaque source fournie est annotée `type=parcours | projet | moc | competence | glossaire`. +- Pour toute question biographique (qui est Fernand, âge, situation, école, objectif, contact, localisation), appuie-toi **en priorité** sur les sources de `type=parcours` (ex. [bio-fernand], [cv-grascalvet-fernand]). Ne **déduis jamais** d'informations biographiques depuis une source `type=projet` ou `type=moc`. +- Ne **jamais inventer** un fait factuel (âge, date, diplôme, école, entreprise, technologie utilisée) qui n'apparaît pas littéralement dans les sources. Si l'info n'est pas présente, écris « non précisé dans les notes » et oriente vers /portfolio, /competences ou /contact. +- En cas de contradiction entre deux sources, privilégie la source de plus haut score, mentionne brièvement la divergence, et ne choisis jamais une valeur absente des deux. +- Une note dont le body se termine par « note tronquée » a été résumée : signale-le si tu t'appuies dessus pour un point précis, ou invite à consulter la note complète.""" + + +_TRUNCATION_MARKER = "\n\n… *(note tronquée — voir le vault pour le détail)*" + + +def _truncate_body(body: str, max_chars: int) -> str: + """Coupe `body` à `max_chars` en essayant de finir sur une frontière propre. + + Stratégie : + 1. Si le body est déjà ≤ max_chars → inchangé. + 2. Sinon on garde `body[:max_chars]` puis on cherche la dernière coupure + "naturelle" (double saut de ligne = fin de paragraphe, sinon fin de + phrase). On ne recule que si la coupure trouvée est dans la moitié + haute de la fenêtre, pour éviter de perdre trop de contenu. + 3. On ajoute un marqueur explicite pour signaler au modèle que la note + a été résumée (évite qu'il conclue "il n'y a pas d'info sur ..."). + """ + if len(body) <= max_chars: + return body + + truncated = body[:max_chars] + cutoff = -1 + for sep in ("\n\n", ". ", "\n", " "): + idx = truncated.rfind(sep) + if idx >= max_chars * 0.6: + cutoff = idx + (len(sep) if sep.endswith(" ") else 0) + break + if cutoff > 0: + truncated = truncated[:cutoff] + return truncated.rstrip() + _TRUNCATION_MARKER def build_prompt(query: str, scored_notes: list[ScoredNote]) -> tuple[str, str]: - """Assemble (system, user) pour Qwen3. Notes **entières** dans le contexte.""" - # Seuil : si toutes les notes sont en-dessous, on considère "pas de contexte pertinent" + """Assemble (system, user) pour Qwen3. + + - Sources gardées : celles dont le score ≥ MIN_SCORE. + - Troncature : la source #1 (top score) reste **entière**. Les sources + rank 2+ dont le score est < SECONDARY_KEEP_RATIO × score(#1) et dont le + body dépasse SECONDARY_MAX_CHARS sont résumées par `_truncate_body`. + Aucune source n'est supprimée — le modèle voit toujours tout le top-K. + """ relevant = [s for s in scored_notes if s.score >= MIN_SCORE] with langfuse.start_as_current_span( name="prompt_build", input={"query": query, "scored_count": len(scored_notes)}, ) as span: + truncated_log: list[dict[str, Any]] = [] + if relevant: + top_score = relevant[0].score + keep_full_threshold = top_score * SECONDARY_KEEP_RATIO context_blocks = [] for i, s in enumerate(relevant, 1): n = s.note + body = n.body + original_chars = len(body) + should_truncate = ( + i > 1 + and s.score < keep_full_threshold + and original_chars > SECONDARY_MAX_CHARS + ) + if should_truncate: + body = _truncate_body(body, SECONDARY_MAX_CHARS) + truncated_log.append( + { + "rank": i, + "slug": n.slug, + "score": round(s.score, 2), + "original_chars": original_chars, + "truncated_chars": len(body), + } + ) 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_blocks.append(f"{header}\n{body}") context = "\n\n---\n\n".join(context_blocks) user = ( "Voici les notes pertinentes du vault personnel de Fernand :\n\n" @@ -622,6 +704,11 @@ def build_prompt(query: str, scored_notes: list[ScoredNote]) -> tuple[str, str]: "system_chars": len(SYSTEM_PROMPT), "user_chars": len(user), "min_score_threshold": MIN_SCORE, + "truncation": { + "secondary_max_chars": SECONDARY_MAX_CHARS, + "secondary_keep_ratio": SECONDARY_KEEP_RATIO, + "truncated_notes": truncated_log, + }, }, ) @@ -637,10 +724,21 @@ def generate(system: str, user: str) -> str: 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. + + Paramètres clefs (tunés 2026-04-23 après audit des traces Langfuse) : + - `num_ctx=8192` : fenêtre de contexte explicite (le défaut Ollama + à 2048/4096 tronquait silencieusement le début du prompt quand les + sources du RAG étaient volumineuses, d'où hallucinations sur l'identité). + - `num_predict=1024` : budget de sortie doublé (512 coupait les réponses + détaillées — p. ex. description du site ou d'un projet — en plein milieu). + - `think=False` (top-level, hors `options`) : désactive le mode *thinking* + de qwen3. Sinon le modèle consomme du budget de sortie en raisonnement + interne avant de générer la réponse visible. """ model_params = { "temperature": 0.4, - "num_predict": 512, + "num_ctx": 8192, + "num_predict": 1024, } messages = [ {"role": "system", "content": system}, @@ -652,7 +750,7 @@ def generate(system: str, user: str) -> str: name="ollama-chat", model=LLM_MODEL, input=messages, - model_parameters=model_params, + model_parameters={**model_params, "think": False}, ) as generation: response = requests.post( f"{OLLAMA_URL}/api/chat", @@ -660,6 +758,7 @@ def generate(system: str, user: str) -> str: "model": LLM_MODEL, "messages": messages, "stream": False, + "think": False, "options": model_params, "keep_alive": "30m", }, diff --git a/vault-grasbot/30-Parcours/bio-fernand.md b/vault-grasbot/30-Parcours/bio-fernand.md new file mode 100644 index 0000000..57b5387 --- /dev/null +++ b/vault-grasbot/30-Parcours/bio-fernand.md @@ -0,0 +1,69 @@ +--- +title: "Bio — Fernand Gras-Calvet (présentation courte)" +slug: bio-fernand +type: parcours +source: manual +domains: [parcours, ecole-42, ia] +tags: [bio, presentation, parcours, alternance] +aliases: + - bio + - biographie + - présentation + - présentation courte + - en bref + - fernand en bref + - qui est fernand + - qui est-il + - parle-moi de fernand + - que peux-tu me dire de fernand +answers: + - "Qui est Fernand ?" + - "Qui est Fernand Gras-Calvet ?" + - "Que peux-tu me dire de Fernand ?" + - "Parle-moi de Fernand." + - "Fernand en quelques mots ?" + - "Présente-toi." +priority: 10 +linked: + - "[[cv-grascalvet-fernand]]" + - "[[MOC-Parcours]]" +related: + - "[[ia]]" +updated: 2026-04-23 +visibility: public +--- + +# Bio — Fernand Gras-Calvet + +> [!info] Rôle de cette note +> Version **courte et factuelle** de la présentation de Fernand, pensée +> comme premier résultat du chatbot sur les questions du type *« qui est +> Fernand ? »*. Pour le détail complet (compétences, expériences, passions), +> voir [[cv-grascalvet-fernand|le CV]]. + +## Identité + +- **Nom** : Fernand Gras-Calvet +- **Âge** : 47 ans +- **Situation** : étudiant en informatique à l'**École 42 Perpignan** +- **Localisation** : Rivesaltes (Pyrénées-Orientales, 66) +- **Statut** : reconversion professionnelle, bénéficiaire d'une **RQTH** + +## Objectif professionnel + +Trouver une **alternance de 2 ans** en **Data / IA** pour se spécialiser dans +l'**automatisation agentique en entreprise** (LLM, agents, intégration +d'outils internes). + +## Parcours en une phrase + +Ancien **infirmier diplômé d'État** (10 ans en gériatrie, 2014-2023), après +plus de 12 ans en **ostréiculture familiale** à Leucate, aujourd'hui en +reconversion IT à l'École 42 (2023-2025) avec un stage réalisé autour d'un +**chatbot multi-agent** en entreprise. + +## Pour aller plus loin + +- [[cv-grascalvet-fernand|CV détaillé]] — expériences, compétences techniques, intérêts +- [[MOC-Projets]] — vue d'ensemble des projets École 42 +- [[ia|IA]] — note thématique sur son domaine cible diff --git a/vault-grasbot/30-Parcours/cv-grascalvet-fernand.md b/vault-grasbot/30-Parcours/cv-grascalvet-fernand.md index 933f377..d1f74f6 100644 --- a/vault-grasbot/30-Parcours/cv-grascalvet-fernand.md +++ b/vault-grasbot/30-Parcours/cv-grascalvet-fernand.md @@ -28,6 +28,7 @@ answers: - "A-t-il de l'expérience professionnelle ?" priority: 10 linked: + - "[[bio-fernand]]" - "[[MOC-Parcours]]" - "[[MOC-Ecole-42]]" - "[[MOC-Ia]]" @@ -64,7 +65,7 @@ visibility: public ## Présentation -Ancien infirmier de 46 ans, actuellement étudiant en informatique à l'École 42 +Ancien infirmier de 47 ans, actuellement étudiant en informatique à l'École 42 Perpignan. Je recherche une alternance de 2 ans pour me spécialiser dans **l'automatisation agentique au sein des entreprises**, en y apportant mon expérience sur le traitement de Data et les nouveaux process basés sur les LLM.