mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 07:28:13 +02:00
Compaction : corrections de relecture — une balise </think> citée n'ampute plus le résumé
cleanSummary coupait toujours au DERNIER </think>. 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 <think>…</think> qui ouvre la réponse (plusieurs à la suite au besoin), ou un </think> sans <think> 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
e14ead98c4
commit
72ba8e0fb6
2 files changed
+48
-14
No files matched your search
@@ -785,13 +785,26 @@ func summaryRuneCap() int {
|
|||||||
// - finish_reason=length : le texte est gardé (le début d'un bon résumé vaut
|
// - 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.
|
// mieux que rien), marqué « […] » et tracé dans le journal.
|
||||||
func cleanSummary(content, reasoning, finish string) (string, error) {
|
func cleanSummary(content, reasoning, finish string) (string, error) {
|
||||||
c := content
|
c := strings.TrimSpace(content)
|
||||||
if i := strings.LastIndex(c, thinkClose); i >= 0 {
|
// Le raisonnement ne se retire qu'en TÊTE : soit un bloc <think>…</think>
|
||||||
c = c[i+len(thinkClose):]
|
// qui ouvre la réponse, soit un </think> sans <think> avant lui (gabarit qui
|
||||||
|
// ouvre le bloc dans le prompt). Couper au DERNIER </think>, 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 <think> 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], "<think>"); o > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
c = strings.TrimSpace(c[i+len(thinkClose):])
|
||||||
|
if !strings.HasPrefix(c, "<think>") {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c = strings.TrimSpace(c)
|
|
||||||
// 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>") {
|
if strings.HasPrefix(c, "<think>") {
|
||||||
return "", fmt.Errorf("résumé: raisonnement jamais refermé, aucun résumé")
|
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")
|
return "", fmt.Errorf("résumé: texte vide")
|
||||||
}
|
}
|
||||||
cut := false
|
cut := ""
|
||||||
if r := []rune(c); len(r) > summaryRuneCap() {
|
if r := []rune(c); len(r) > summaryRuneCap() {
|
||||||
// Coupé sur une frontière de rune (é, … ne doivent pas devenir des �).
|
// Coupé sur une frontière de rune (é, … ne doivent pas devenir des �).
|
||||||
c = strings.TrimSpace(string(r[:summaryRuneCap()]))
|
c = strings.TrimSpace(string(r[:summaryRuneCap()]))
|
||||||
cut = true
|
cut = fmt.Sprintf("par le filet de %d caractères", summaryRuneCap())
|
||||||
}
|
}
|
||||||
if finish == "length" {
|
if finish == "length" {
|
||||||
fmt.Fprintf(os.Stderr, "[compact] résumé coupé par max_tokens=%d (%d caractères gardés)\n",
|
cut = fmt.Sprintf("par max_tokens=%d", compactSummaryBudget())
|
||||||
compactSummaryBudget(), len([]rune(c)))
|
|
||||||
cut = true
|
|
||||||
}
|
}
|
||||||
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 += " […]"
|
c += " […]"
|
||||||
}
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -259,6 +260,11 @@ func TestCompactageModeCodeGardeLEtat(t *testing.T) {
|
|||||||
// dernier. Seul le raisonnement sans résumé est refusé (repli sur le torse).
|
// dernier. Seul le raisonnement sans résumé est refusé (repli sur le torse).
|
||||||
func TestCleanSummary(t *testing.T) {
|
func TestCleanSummary(t *testing.T) {
|
||||||
testHome(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"
|
long := strings.Repeat("é", 7000) + " ÉTAT D'AVANCEMENT"
|
||||||
huge := strings.Repeat("x", summaryRuneCap()+500)
|
huge := strings.Repeat("x", summaryRuneCap()+500)
|
||||||
cases := []struct {
|
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},
|
{"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},
|
{"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},
|
{"balise citée en milieu", "- le parseur retire <think> en tête", "", "stop", "- le parseur retire <think> en tête", false},
|
||||||
|
{"bloc cité en milieu gardé entier", "- A\n- le parseur retire <think>x</think> en tête\n- B", "", "stop", "- A\n- le parseur retire <think>x</think> en tête\n- B", false},
|
||||||
|
{"think retiré, balise citée après", "<think>r</think>\n- A\n- le parseur coupe à </think>\n- B", "", "stop", "- A\n- le parseur coupe à </think>\n- B", false},
|
||||||
|
{"ouverture dans le prompt", "je réfléchis</think>\n- résumé", "", "stop", "- résumé", false},
|
||||||
|
{"deux blocs en tête", "<think>a</think><think>b</think>\n- résumé", "", "stop", "- résumé", false},
|
||||||
|
{"second bloc jamais refermé", "<think>a</think>\n<think>b", "", "length", "", true},
|
||||||
{"think jamais refermé", "<think>je réfléchis encore", "", "length", "", true},
|
{"think jamais refermé", "<think>je réfléchis encore", "", "length", "", true},
|
||||||
{"que du reasoning_content", "", "je réfléchis", "length", "", true},
|
{"que du reasoning_content", "", "je réfléchis", "length", "", true},
|
||||||
{"think vide de réponse", "<think>x</think> ", "", "stop", "", true},
|
{"think vide de réponse", "<think>x</think> ", "", "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).
|
// réponse qui n'a que du raisonnement est une erreur (repli de l'appelant).
|
||||||
func TestSummarizeLongNotTruncated(t *testing.T) {
|
func TestSummarizeLongNotTruncated(t *testing.T) {
|
||||||
testHome(t)
|
testHome(t)
|
||||||
|
if err := SetConfigKey("CTX", "65536"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
long := strings.Repeat("ü", 7000)
|
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
|
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) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
_ = json.NewEncoder(w).Encode(reply)
|
_ = json.NewEncoder(w).Encode(reply)
|
||||||
}))
|
}))
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
@@ -299,12 +319,12 @@ func TestSummarizeLongNotTruncated(t *testing.T) {
|
|||||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
got, err := summarizeTranscriptFor(context.Background(), "transcript", false)
|
||||||
if err != nil || got != long {
|
if err != nil || got != long {
|
||||||
t.Fatalf("résumé tronqué : %d caractères, %v", len([]rune(got)), err)
|
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 {
|
if got, err := summarizeTranscriptFor(context.Background(), "transcript", false); err == nil {
|
||||||
t.Fatalf("raisonnement seul accepté comme résumé : %q", got)
|
t.Fatalf("raisonnement seul accepté comme résumé : %q", got)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user