mirror of
https://github.com/Ladebeze66/devsite.git
synced 2026-05-11 16:56:26 +02:00
chat_bio
This commit is contained in:
parent
d6949309f1
commit
a0e59442f4
@ -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
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -22,12 +22,20 @@ Pipeline :
|
||||
|
||||
Variables d'environnement (toutes optionnelles) :
|
||||
|
||||
- `OLLAMA_URL` (default: http://localhost:11434)
|
||||
- `LLM_MODEL` (default: qwen3:8b)
|
||||
- `VAULT_DIR` (default: <repo_root>/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: <repo_root>/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",
|
||||
},
|
||||
|
||||
69
vault-grasbot/30-Parcours/bio-fernand.md
Normal file
69
vault-grasbot/30-Parcours/bio-fernand.md
Normal 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
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user