mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Compaction : le résumé n'est plus amputé de son état d'avancement
Le résumé de compaction était coupé net à 2200 caractères (≈ 550 tokens) alors que max_tokens l'autorise jusqu'à ~1600 tokens. Le prompt du résumeur place l'ÉTAT D'AVANCEMENT en dernier : c'était donc exactement ce dont l'agent a besoin pour reprendre qui tombait, et les tokens décodés au-delà étaient jetés. - La borne passe à compactSummaryBudget()×6 caractères : un simple filet pour une API qui ignorerait max_tokens, qui borne déjà la sortie. - finish_reason=length : le texte est gardé, marqué « […] » et tracé dans le journal ([compact] résumé coupé par max_tokens…). - Un <think> ouvert en tête et jamais refermé, ou un contenu vide à côté d'un reasoning_content, est une erreur : l'appelant retombe sur le torse dégraissé au lieu d'installer du raisonnement brut comme mémoire. - Tests : table de cleanSummary, et un résumé de 7000 caractères qui revient intact du moteur. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
26fc348506
commit
e14ead98c4
2 files changed
+111
-11
No files matched your search
@@ -651,8 +651,10 @@ func renderTranscript(msgs []Message) string {
|
||||
type summarizeResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
@@ -757,20 +759,61 @@ Write the summary in the SAME language as the conversation.`
|
||||
if len(out.Choices) == 0 {
|
||||
return "", fmt.Errorf("résumé: réponse vide")
|
||||
}
|
||||
c := out.Choices[0].Message.Content
|
||||
// Certains modèles à raisonnement préfixent un bloc <think>…</think> : on ne
|
||||
// garde que la réponse finale.
|
||||
ch := out.Choices[0]
|
||||
return cleanSummary(ch.Message.Content, ch.Message.ReasoningContent, ch.FinishReason)
|
||||
}
|
||||
|
||||
// summaryRuneCap borne le résumé en caractères, en filet de sécurité
|
||||
// seulement. L'ancienne borne fixe de 2200 caractères (≈ 550 tokens) coupait
|
||||
// un résumé que max_tokens autorisait à ~1600 tokens — et le prompt met l'ÉTAT
|
||||
// D'AVANCEMENT en dernier : c'était précisément lui, ce dont l'agent a besoin
|
||||
// pour reprendre, qui tombait. max_tokens borne déjà la sortie ; ×6 caractères
|
||||
// par token laisse passer tout résumé que le moteur a pu produire (le français
|
||||
// tourne autour de 4) et ne mord que sur une API qui ignorerait max_tokens.
|
||||
func summaryRuneCap() int {
|
||||
return compactSummaryBudget() * 6
|
||||
}
|
||||
|
||||
// cleanSummary extrait le texte final d'une réponse de résumé. Une erreur fait
|
||||
// retomber l'appelant sur le torse dégraissé, sans perte : bien mieux que
|
||||
// d'installer du raisonnement brut comme « mémoire » de la conversation.
|
||||
// - un bloc <think>…</think> en tête est retiré, seule la réponse compte ;
|
||||
// - un <think> ouvert en tête et jamais refermé (modèle qui ignore
|
||||
// enable_thinking=false et bute sur max_tokens), ou un texte vide à côté
|
||||
// d'un reasoning_content : il n'y a QUE du raisonnement, pas de résumé →
|
||||
// erreur ;
|
||||
// - finish_reason=length : le texte est gardé (le début d'un bon résumé vaut
|
||||
// mieux que rien), marqué « […] » et tracé dans le journal.
|
||||
func cleanSummary(content, reasoning, finish string) (string, error) {
|
||||
c := content
|
||||
if i := strings.LastIndex(c, thinkClose); i >= 0 {
|
||||
c = c[i+len(thinkClose):]
|
||||
}
|
||||
c = strings.TrimSpace(c)
|
||||
// Garde-fou dur : même si le modèle ignore la consigne de longueur, on tronque
|
||||
// pour garantir une vraie compression. 2200 caractères (≈ 550 tokens) et pas
|
||||
// 1500 : le résumé doit désormais porter les FAITS déjà trouvés, pas seulement
|
||||
// l'intention, sinon l'IA repart en recherche après chaque compactage. Coupé
|
||||
// sur une frontière de rune (é, … ne doivent pas devenir des �).
|
||||
if r := []rune(c); len(r) > 2200 {
|
||||
c = strings.TrimSpace(string(r[:2200])) + " […]"
|
||||
// En TÊTE seulement : un résumé de session Code peut citer la balise
|
||||
// <think> (un parseur, un gabarit) sans être du raisonnement.
|
||||
if strings.HasPrefix(c, "<think>") {
|
||||
return "", fmt.Errorf("résumé: raisonnement jamais refermé, aucun résumé")
|
||||
}
|
||||
if c == "" {
|
||||
if strings.TrimSpace(reasoning) != "" || strings.TrimSpace(content) != "" {
|
||||
return "", fmt.Errorf("résumé: rien hors du raisonnement")
|
||||
}
|
||||
return "", fmt.Errorf("résumé: texte vide")
|
||||
}
|
||||
cut := false
|
||||
if r := []rune(c); len(r) > summaryRuneCap() {
|
||||
// Coupé sur une frontière de rune (é, … ne doivent pas devenir des �).
|
||||
c = strings.TrimSpace(string(r[:summaryRuneCap()]))
|
||||
cut = true
|
||||
}
|
||||
if finish == "length" {
|
||||
fmt.Fprintf(os.Stderr, "[compact] résumé coupé par max_tokens=%d (%d caractères gardés)\n",
|
||||
compactSummaryBudget(), len([]rune(c)))
|
||||
cut = true
|
||||
}
|
||||
if cut {
|
||||
c += " […]"
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -252,3 +253,59 @@ func TestCompactageModeCodeGardeLEtat(t *testing.T) {
|
||||
t.Fatal("critères non rendus après compactage")
|
||||
}
|
||||
}
|
||||
|
||||
// Le résumé n'est plus coupé à 2200 caractères : max_tokens borne déjà la
|
||||
// sortie, et l'ancienne coupe faisait tomber l'ÉTAT D'AVANCEMENT, écrit en
|
||||
// dernier. Seul le raisonnement sans résumé est refusé (repli sur le torse).
|
||||
func TestCleanSummary(t *testing.T) {
|
||||
testHome(t)
|
||||
long := strings.Repeat("é", 7000) + " ÉTAT D'AVANCEMENT"
|
||||
huge := strings.Repeat("x", summaryRuneCap()+500)
|
||||
cases := []struct {
|
||||
name, content, reasoning, finish string
|
||||
want string
|
||||
err bool
|
||||
}{
|
||||
{"simple", " - fait A\n- fait B ", "", "stop", "- fait A\n- fait B", false},
|
||||
{"long gardé entier", long, "", "stop", long, false},
|
||||
{"think retiré", "<think>je réfléchis</think>\n- résumé", "", "stop", "- résumé", false},
|
||||
{"length gardé et marqué", "- début du résumé", "", "length", "- début du résumé […]", false},
|
||||
{"au-delà du filet", huge, "", "stop", strings.Repeat("x", summaryRuneCap()) + " […]", false},
|
||||
{"balise citée en milieu", "- le parseur retire <think> en tête", "", "stop", "- le parseur retire <think> en tête", false},
|
||||
{"think jamais refermé", "<think>je réfléchis encore", "", "length", "", true},
|
||||
{"que du reasoning_content", "", "je réfléchis", "length", "", true},
|
||||
{"think vide de réponse", "<think>x</think> ", "", "stop", "", true},
|
||||
{"vide", "", "", "stop", "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := cleanSummary(c.content, c.reasoning, c.finish)
|
||||
if (err != nil) != c.err || got != c.want {
|
||||
t.Errorf("%s : %q, %v", c.name, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bout à bout : un résumé de 7000 caractères revient intact du moteur, et une
|
||||
// réponse qui n'a que du raisonnement est une erreur (repli de l'appelant).
|
||||
func TestSummarizeLongNotTruncated(t *testing.T) {
|
||||
testHome(t)
|
||||
long := strings.Repeat("ü", 7000)
|
||||
var reply map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(reply)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reply = map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": long}, "finish_reason": "stop"}}}
|
||||
got, err := summarizeTranscriptFor(context.Background(), "transcript", false)
|
||||
if err != nil || got != long {
|
||||
t.Fatalf("résumé tronqué : %d caractères, %v", len([]rune(got)), err)
|
||||
}
|
||||
reply = map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "", "reasoning_content": "hmm"}, "finish_reason": "length"}}}
|
||||
if got, err := summarizeTranscriptFor(context.Background(), "transcript", false); err == nil {
|
||||
t.Fatalf("raisonnement seul accepté comme résumé : %q", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user