This commit is contained in:
Ladebeze66 2026-04-23 15:42:43 +02:00
parent d6949309f1
commit a0e59442f4
5 changed files with 226 additions and 16 deletions

View File

@ -79,6 +79,10 @@ uvicorn api:app --host 0.0.0.0 --port 8000 --reload
- **API** : http://localhost:8000 - **API** : http://localhost:8000
- **Endpoint IA** : http://localhost:8000/ask?q=votre_question - **Endpoint IA** : http://localhost:8000/ask?q=votre_question
- **Documentation** : http://localhost:8000/docs - **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 ## 📊 Ports Utilisés

View File

@ -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 - `relevant_notes` : notes effectivement incluses dans le contexte
- `system_chars`, `user_chars` : tailles utiles pour debug de fenêtre de 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 - `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**) ### Span `ollama-chat` (type **generation**)
- **input** : `[{role: "system", content}, {role: "user", content}]` - **input** : `[{role: "system", content}, {role: "user", content}]`
- **output** : réponse brute du modèle - **output** : réponse brute du modèle
- **model** : `LLM_MODEL` (ex. `qwen3:8b`) - **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 - **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. - 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). - 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). - **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 ## É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). - **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).

View File

@ -26,8 +26,16 @@ Variables d'environnement (toutes optionnelles) :
- `LLM_MODEL` (default: qwen3:8b) - `LLM_MODEL` (default: qwen3:8b)
- `VAULT_DIR` (default: <repo_root>/vault-grasbot) - `VAULT_DIR` (default: <repo_root>/vault-grasbot)
- `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
qu'aucune note pertinente n'a été trouvée. 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) : 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")) TOP_K = int(os.environ.get("SEARCH_TOP_K", "5"))
MIN_SCORE = float(os.environ.get("SEARCH_MIN_SCORE", "1.0")) 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) # 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. - 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. - 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. - 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]). - 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. - 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]: def build_prompt(query: str, scored_notes: list[ScoredNote]) -> tuple[str, str]:
"""Assemble (system, user) pour Qwen3. Notes **entières** dans le contexte.""" """Assemble (system, user) pour Qwen3.
# Seuil : si toutes les notes sont en-dessous, on considère "pas de contexte pertinent"
- 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] relevant = [s for s in scored_notes if s.score >= MIN_SCORE]
with langfuse.start_as_current_span( with langfuse.start_as_current_span(
name="prompt_build", name="prompt_build",
input={"query": query, "scored_count": len(scored_notes)}, input={"query": query, "scored_count": len(scored_notes)},
) as span: ) as span:
truncated_log: list[dict[str, Any]] = []
if relevant: if relevant:
top_score = relevant[0].score
keep_full_threshold = top_score * SECONDARY_KEEP_RATIO
context_blocks = [] context_blocks = []
for i, s in enumerate(relevant, 1): for i, s in enumerate(relevant, 1):
n = s.note 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}" 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) context = "\n\n---\n\n".join(context_blocks)
user = ( user = (
"Voici les notes pertinentes du vault personnel de Fernand :\n\n" "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), "system_chars": len(SYSTEM_PROMPT),
"user_chars": len(user), "user_chars": len(user),
"min_score_threshold": MIN_SCORE, "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, Span Langfuse de type `generation` expose latence, modèle, paramètres,
et tokens (si l'API Ollama les retourne dans `prompt_eval_count` / et tokens (si l'API Ollama les retourne dans `prompt_eval_count` /
`eval_count`) comme un LLM-call standard dans le dashboard. `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 = { model_params = {
"temperature": 0.4, "temperature": 0.4,
"num_predict": 512, "num_ctx": 8192,
"num_predict": 1024,
} }
messages = [ messages = [
{"role": "system", "content": system}, {"role": "system", "content": system},
@ -652,7 +750,7 @@ def generate(system: str, user: str) -> str:
name="ollama-chat", name="ollama-chat",
model=LLM_MODEL, model=LLM_MODEL,
input=messages, input=messages,
model_parameters=model_params, model_parameters={**model_params, "think": False},
) as generation: ) as generation:
response = requests.post( response = requests.post(
f"{OLLAMA_URL}/api/chat", f"{OLLAMA_URL}/api/chat",
@ -660,6 +758,7 @@ def generate(system: str, user: str) -> str:
"model": LLM_MODEL, "model": LLM_MODEL,
"messages": messages, "messages": messages,
"stream": False, "stream": False,
"think": False,
"options": model_params, "options": model_params,
"keep_alive": "30m", "keep_alive": "30m",
}, },

View File

@ -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

View File

@ -28,6 +28,7 @@ answers:
- "A-t-il de l'expérience professionnelle ?" - "A-t-il de l'expérience professionnelle ?"
priority: 10 priority: 10
linked: linked:
- "[[bio-fernand]]"
- "[[MOC-Parcours]]" - "[[MOC-Parcours]]"
- "[[MOC-Ecole-42]]" - "[[MOC-Ecole-42]]"
- "[[MOC-Ia]]" - "[[MOC-Ia]]"
@ -64,7 +65,7 @@ visibility: public
## Présentation ## 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 Perpignan. Je recherche une alternance de 2 ans pour me spécialiser dans
**l'automatisation agentique au sein des entreprises**, en y apportant mon **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. expérience sur le traitement de Data et les nouveaux process basés sur les LLM.