diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index 77e3372..2c5b0a8 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -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 … : 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 … en tête est retiré, seule la réponse compte ; +// - un 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 + // (un parseur, un gabarit) sans être du raisonnement. + if strings.HasPrefix(c, "") { + 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 } diff --git a/internal/loki/chat_compact_test.go b/internal/loki/chat_compact_test.go index 9b8c35d..c5eb6cd 100644 --- a/internal/loki/chat_compact_test.go +++ b/internal/loki/chat_compact_test.go @@ -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é", "je réfléchis\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 en tête", "", "stop", "- le parseur retire en tête", false}, + {"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}, + {"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) + } +}