From dcbfcb36f9b95ebd76f9f2b2725fd39592ee3d50 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Thu, 23 Jul 2026 17:07:55 +0200 Subject: [PATCH] Compaction plus efficace : resume borne, reduction garantie, jauge immediate - Resume borne et dense : max_tokens 700, consigne 250 mots max, plafond dur a 1500 caracteres, et surtout thinking DESACTIVE pour l'appel de resume (chat_template_kwargs enable_thinking:false) -> plus rapide, plus dense, plus de budget gaspille en par un modele hybride. - Garantie de reduction : la compaction n'est acceptee que si elle enleve >=20% du contexte estime, sinon refusee -> fini le "compactage a chaque message sans reduire". Queue plus agressive (tailFrac 0.35 -> 0.25) : le contexte retombe plus bas et met plus longtemps a re-declencher. - Jauge de contexte : chute immediatement apres compaction (delta ctx_used emis aussitot) au lieu d'attendre la fin du tour. --- internal/jean/chat_compact.go | 38 ++++++++++++++++++++-------- internal/jean/chat_conversation.go | 7 ++++- internal/jean/ui/index.html | 1 + internal/jean/ui/src/js/09-stream.js | 1 + 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/internal/jean/chat_compact.go b/internal/jean/chat_compact.go index 148756f..0810f51 100644 --- a/internal/jean/chat_compact.go +++ b/internal/jean/chat_compact.go @@ -31,7 +31,9 @@ const ( // dépasse cette fraction de la fenêtre de contexte. compactTriggerFrac = 0.75 // Budget de la queue : fraction de la fenêtre gardée intacte (tours récents). - compactTailFrac = 0.35 + // Plus la queue est petite, plus on compacte de torse d'un coup → le contexte + // retombe bas et met longtemps à re-déclencher (au lieu de compacter souvent). + compactTailFrac = 0.25 // Un résultat d'outil du torse plus long que ça est remplacé par un marqueur // avant le résumé (dégraissage sans IA, gratuit). compactToolPruneLen = 200 @@ -205,8 +207,12 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, out = append(out, mid...) out = append(out, msgs[tailStart:]...) - // Si on n'a rien gagné (résumé raté ET rien à dégraisser), signaler inchangé. - if len(out) == len(msgs) && estimateTokens(out) >= estimateTokens(msgs) { + // Garantie de réduction : on n'accepte la compaction que si elle enlève au + // moins ~20% du contexte estimé. Sinon (torse déjà maigre, résumé peu rentable) + // on la refuse — sans ça, jean « compactait » à presque chaque message sans + // vraiment réduire, puis re-déclenchait aussitôt. + before, after := estimateTokens(msgs), estimateTokens(out) + if after > before*4/5 { return msgs, false } return out, true @@ -258,13 +264,12 @@ type summarizeResp struct { // Un seul appel NON streamé, sans outils — comme Hermes, on réutilise le modèle // principal déjà chargé (aucune dépendance, cohérent avec la fenêtre de contexte). func summarizeTranscript(ctx context.Context, transcript string) (string, error) { - sys := `Tu es un compacteur de contexte. On te donne la transcription d'une conversation entre un utilisateur et un assistant IA (avec ses outils). Résume-la de façon dense et FIDÈLE en français, en préservant tout ce qui est nécessaire pour continuer la conversation sans perte : + sys := `Tu es un compacteur de contexte. On te donne la transcription d'anciens tours d'une conversation entre un utilisateur et un assistant IA (avec ses outils). Résume-les de façon TRÈS DENSE et fidèle en français, en ne gardant QUE l'essentiel pour continuer sans perte : - Objectif(s) et contraintes de l'utilisateur - Décisions prises et faits établis -- Actions/outils exécutés et leurs résultats importants (chemins, valeurs, configuration) -- Tâches terminées, en cours, bloquées -- Prochaines étapes prévues -N'invente rien, n'ajoute pas de préambule ni de conclusion : donne directement le résumé structuré. Complet mais concis.` +- Actions/outils exécutés et leurs résultats importants (chemins, valeurs, config) +- Tâches terminées, en cours, bloquées ; prochaines étapes +Règles STRICTES : pas de préambule ni de conclusion, pas de verbatim ni de citations longues, pas de détails jetables. Utilise des puces courtes. Vise 250 mots MAXIMUM — c'est un résumé de compression, pas un compte rendu.` payload := map[string]any{ "model": "jean", @@ -273,7 +278,14 @@ N'invente rien, n'ajoute pas de préambule ni de conclusion : donne directement {Role: "user", Content: transcript}, }, "stream": false, - "temperature": 0.3, + "temperature": 0.2, + // Borne dure : sans ça, un modèle bavard (surtout à reasoning) produit un + // résumé énorme et lent, donc peu de réduction → re-compaction à chaque tour. + "max_tokens": 700, + // Pas de réflexion pour un résumé : plus rapide, plus dense, et évite qu'un + // modèle hybride gaspille tout le budget en (résumé vide). llama.cpp + // passe ces kwargs au gabarit Jinja (--jinja). + "chat_template_kwargs": map[string]any{"enable_thinking": false}, } body, _ := json.Marshal(payload) url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()) @@ -305,5 +317,11 @@ N'invente rien, n'ajoute pas de préambule ni de conclusion : donne directement if i := strings.LastIndex(c, thinkClose); i >= 0 { c = c[i+len(thinkClose):] } - return strings.TrimSpace(c), nil + c = strings.TrimSpace(c) + // Garde-fou dur : même si le modèle ignore la consigne de longueur, on tronque + // (~1500 caractères ≈ 375 tokens) pour garantir une vraie compression. + if len(c) > 1500 { + c = strings.TrimSpace(c[:1500]) + " […]" + } + return c, nil } diff --git a/internal/jean/chat_conversation.go b/internal/jean/chat_conversation.go index 43c30d5..5b09679 100644 --- a/internal/jean/chat_conversation.go +++ b/internal/jean/chat_conversation.go @@ -198,13 +198,16 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa } if compacted, changed := MaybeCompact(ctx, msgs, caps, ctxUsed); changed { msgs = compacted + est := estimateTokens(compacted) c.mu.Lock() if c.epoch == epoch { c.Messages = compacted + c.CtxUsed = est // le vrai compte reviendra avec les stats du tour } c.mu.Unlock() c.appendDelta(epoch, map[string]any{"compacting": false}) c.appendDelta(epoch, map[string]any{"compacted": true}) + c.appendDelta(epoch, map[string]any{"ctx_used": est}) // fait chuter la jauge tout de suite } else if willCompact { c.appendDelta(epoch, map[string]any{"compacting": false}) } @@ -298,13 +301,15 @@ func (c *Conversation) CompactNow() error { c.appendDelta(epoch, map[string]any{"compact_noop": true}) return } + est := estimateTokens(compacted) c.mu.Lock() if c.epoch == epoch { c.Messages = compacted - c.CtxUsed = estimateTokens(compacted) // feedback immédiat ; le vrai compte revient au prochain tour + c.CtxUsed = est // feedback immédiat ; le vrai compte revient au prochain tour } c.mu.Unlock() c.appendDelta(epoch, map[string]any{"compacted": true}) + c.appendDelta(epoch, map[string]any{"ctx_used": est}) c.persist() }() return nil diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index 20e5949..32f80ca 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -1642,6 +1642,7 @@ function handleDelta(d){ if(d.compacting!==undefined){ const b=document.getElementById('compact-banner'); if(b) b.style.display = d.compacting ? 'flex' : 'none'; return; } if(d.compacted){ const b=document.getElementById('compact-banner'); if(b) b.style.display='none'; toast('contexte compacté — les vieux tours ont été résumés'); return; } if(d.compact_noop){ const b=document.getElementById('compact-banner'); if(b) b.style.display='none'; toast('rien à compacter (contexte déjà minimal)'); return; } + if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; } if(d.stats){ T.serverStats=d.stats; if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); } if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; } diff --git a/internal/jean/ui/src/js/09-stream.js b/internal/jean/ui/src/js/09-stream.js index 8a12bf7..2181d67 100644 --- a/internal/jean/ui/src/js/09-stream.js +++ b/internal/jean/ui/src/js/09-stream.js @@ -63,6 +63,7 @@ function handleDelta(d){ if(d.compacting!==undefined){ const b=document.getElementById('compact-banner'); if(b) b.style.display = d.compacting ? 'flex' : 'none'; return; } if(d.compacted){ const b=document.getElementById('compact-banner'); if(b) b.style.display='none'; toast('contexte compacté — les vieux tours ont été résumés'); return; } if(d.compact_noop){ const b=document.getElementById('compact-banner'); if(b) b.style.display='none'; toast('rien à compacter (contexte déjà minimal)'); return; } + if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; } if(d.stats){ T.serverStats=d.stats; if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); } if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }