From 72ba8e0fb61b5b6b6580de3998fa72c6026b5bd0 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 4 Oct 2026 01:04:03 +0200 Subject: [PATCH] =?UTF-8?q?Compaction=20:=20corrections=20de=20relecture?= =?UTF-8?q?=20=E2=80=94=20une=20balise=20=20cit=C3=A9e=20n'ampute?= =?UTF-8?q?=20plus=20le=20r=C3=A9sum=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cleanSummary coupait toujours au DERNIER . Un résumé de session Code qui cite la balise (un parseur, un gabarit — sessions sur Loki même) perdait donc tout ce qui la précédait, sans erreur ni trace : exactement la perte que le commit précédent voulait supprimer. - Le raisonnement ne se retire qu'en tête : un bloc … qui ouvre la réponse (plusieurs à la suite au besoin), ou un sans avant lui (gabarit qui ouvre le bloc dans le prompt). - Un bloc cité en milieu de texte reste tel quel. - La coupe par le filet (API qui ignore max_tokens) est tracée elle aussi. - Tests : CTX fixé (le filet en dépend), réponse du faux moteur derrière un mutex, cas de balises citées et de blocs multiples. Co-Authored-By: Claude Opus 5.5 --- internal/loki/chat_compact.go | 38 ++++++++++++++++++++---------- internal/loki/chat_compact_test.go | 24 +++++++++++++++++-- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index 2c5b0a8..51907ff 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -785,13 +785,26 @@ func summaryRuneCap() int { // - 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(content) + // Le raisonnement ne se retire qu'en TÊTE : soit un bloc … + // qui ouvre la réponse, soit un sans avant lui (gabarit qui + // ouvre le bloc dans le prompt). Couper au DERNIER , comme avant, + // amputait tout le début d'un résumé de session Code qui cite la balise (un + // parseur, un gabarit) — et une balise citée après un en milieu de + // texte n'est pas du raisonnement non plus. + for { + i := strings.Index(c, thinkClose) + if i < 0 { + break + } + if o := strings.Index(c[:i], ""); o > 0 { + break + } + c = strings.TrimSpace(c[i+len(thinkClose):]) + if !strings.HasPrefix(c, "") { + break + } } - c = strings.TrimSpace(c) - // En TÊTE seulement : un résumé de session Code peut citer la balise - // (un parseur, un gabarit) sans être du raisonnement. if strings.HasPrefix(c, "") { return "", fmt.Errorf("résumé: raisonnement jamais refermé, aucun résumé") } @@ -801,18 +814,19 @@ func cleanSummary(content, reasoning, finish string) (string, error) { } return "", fmt.Errorf("résumé: texte vide") } - cut := false + cut := "" 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 + cut = fmt.Sprintf("par le filet de %d caractères", summaryRuneCap()) } 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 + cut = fmt.Sprintf("par max_tokens=%d", compactSummaryBudget()) } - if cut { + if cut != "" { + // Tracé dans les deux cas : le filet ne mord que sur une API qui + // ignore max_tokens, et c'est l'ÉTAT D'AVANCEMENT qui tombe. + fmt.Fprintf(os.Stderr, "[compact] résumé coupé %s (%d caractères gardés)\n", cut, len([]rune(c))) c += " […]" } return c, nil diff --git a/internal/loki/chat_compact_test.go b/internal/loki/chat_compact_test.go index c5eb6cd..30196ef 100644 --- a/internal/loki/chat_compact_test.go +++ b/internal/loki/chat_compact_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "net/url" "strings" + "sync" "testing" ) @@ -259,6 +260,11 @@ func TestCompactageModeCodeGardeLEtat(t *testing.T) { // dernier. Seul le raisonnement sans résumé est refusé (repli sur le torse). func TestCleanSummary(t *testing.T) { testHome(t) + // Fenêtre fixée : le filet (budget×6) en dépend, le cas « long » ne doit pas + // basculer au-delà si la valeur par défaut de CTX change. + if err := SetConfigKey("CTX", "65536"); err != nil { + t.Fatal(err) + } long := strings.Repeat("é", 7000) + " ÉTAT D'AVANCEMENT" huge := strings.Repeat("x", summaryRuneCap()+500) cases := []struct { @@ -272,6 +278,11 @@ func TestCleanSummary(t *testing.T) { {"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 en tête", "", "stop", "- le parseur retire en tête", false}, + {"bloc cité en milieu gardé entier", "- A\n- le parseur retire x en tête\n- B", "", "stop", "- A\n- le parseur retire x en tête\n- B", false}, + {"think retiré, balise citée après", "r\n- A\n- le parseur coupe à \n- B", "", "stop", "- A\n- le parseur coupe à \n- B", false}, + {"ouverture dans le prompt", "je réfléchis\n- résumé", "", "stop", "- résumé", false}, + {"deux blocs en tête", "ab\n- résumé", "", "stop", "- résumé", false}, + {"second bloc jamais refermé", "a\nb", "", "length", "", true}, {"think jamais refermé", "je réfléchis encore", "", "length", "", true}, {"que du reasoning_content", "", "je réfléchis", "length", "", true}, {"think vide de réponse", "x ", "", "stop", "", true}, @@ -289,9 +300,18 @@ func TestCleanSummary(t *testing.T) { // réponse qui n'a que du raisonnement est une erreur (repli de l'appelant). func TestSummarizeLongNotTruncated(t *testing.T) { testHome(t) + if err := SetConfigKey("CTX", "65536"); err != nil { + t.Fatal(err) + } long := strings.Repeat("ü", 7000) + // Réponse changée entre deux requêtes : derrière un mutex, le handler tourne + // dans une goroutine du serveur. + var mu sync.Mutex var reply map[string]any + setReply := func(r map[string]any) { mu.Lock(); reply = r; mu.Unlock() } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() _ = json.NewEncoder(w).Encode(reply) })) t.Cleanup(srv.Close) @@ -299,12 +319,12 @@ func TestSummarizeLongNotTruncated(t *testing.T) { 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"}}} + setReply(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"}}} + setReply(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) }