Files
jay77721 0a4cc65db0 refactor(common): unify </think>-strip into shared StripThinkTrailing helper (#18179)
Collapse the nine identical **"cut through the last `</think>`"**
implementations — mirroring Python's `re.sub(r"^.*</think>", "", s,
re.DOTALL)` — into one shared helper `common.StripThinkTrailing`,
preventing future behavior drift between copies.
2026-08-13 13:56:56 +08:00

190 lines
7.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tree
import (
"context"
"strings"
"testing"
"ragflow/internal/ingestion/component/knowledge_compiler/common"
"ragflow/internal/tokenizer"
)
// fakeChat records the last request and returns scripted responses.
type fakeChat struct {
calls int
lastReq common.ChatRequest
// responses[i] is returned on the i-th call; nil entry means an error.
responses []*common.ChatResponse
errs []error
}
func (f *fakeChat) Chat(_ context.Context, req common.ChatRequest) (*common.ChatResponse, error) {
f.lastReq = req
i := f.calls
f.calls++
if i < len(f.errs) && f.errs[i] != nil {
return nil, f.errs[i]
}
if i < len(f.responses) {
return f.responses[i], nil
}
return &common.ChatResponse{Content: "ok"}, nil
}
func depsWithChat(c common.ChatInvoker) common.Deps {
return common.Deps{Chat: c, Embed: nil, TenantID: "t"}
}
func TestSummarizeTextsStripsThinkPreamble(t *testing.T) {
f := &fakeChat{responses: []*common.ChatResponse{{Content: "<think>let me think...\n\n</think>Final summary title\nbody"}}}
got, err := summarizeTexts(context.Background(), depsWithChat(f), "llm", "sys", "user", 512)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if strings.Contains(got, "<think>") || strings.Contains(got, "</think>") {
t.Fatalf("think preamble not stripped: %q", got)
}
if !strings.Contains(got, "Final summary title") {
t.Fatalf("body lost: %q", got)
}
}
func TestSummarizeTextsStripsVendorThinkCloseFallback(t *testing.T) {
// The </think:6124c78e> fallback only runs when no standard </think>
// is present (else-if semantics). Both forms strip the preamble.
tests := []struct {
name string
content string
}{
{"vendor_close", "<think:6124c78e>reasoning</think:6124c78e>Vendor summary"},
{"standard_close", "<think>reasoning</think>Standard summary"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := &fakeChat{responses: []*common.ChatResponse{{Content: tt.content}}}
got, err := summarizeTexts(context.Background(), depsWithChat(f), "llm", "sys", "user", 512)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if strings.Contains(got, "</think") {
t.Fatalf("think close tag not stripped: %q", got)
}
})
}
}
func TestSummarizeTextsStripsTruncationMarker(t *testing.T) {
marker := strings.Repeat("·", 6) + "\n由于长度的原因回答被截断了要继续吗"
f := &fakeChat{responses: []*common.ChatResponse{{Content: "title\nbody " + marker}}}
got, err := summarizeTexts(context.Background(), depsWithChat(f), "llm", "sys", "user", 512)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if strings.Contains(got, "回答被截断了") {
t.Fatalf("truncation marker not stripped: %q", got)
}
}
func TestSummarizeTextsRetriesOnErrorMarker(t *testing.T) {
f := &fakeChat{
responses: []*common.ChatResponse{
{Content: "**ERROR** something broke"},
{Content: "title\nclean summary"},
},
}
got, err := summarizeTexts(context.Background(), depsWithChat(f), "llm", "sys", "user", 512)
if err != nil {
t.Fatalf("unexpected err after retry: %v", err)
}
if got != "title\nclean summary" {
t.Fatalf("expected clean summary after retry, got %q", got)
}
if f.calls != 2 {
t.Fatalf("expected 2 calls (1 retry), got %d", f.calls)
}
}
func TestSummarizeTextsFailsAfterMaxRetries(t *testing.T) {
f := &fakeChat{responses: []*common.ChatResponse{
{Content: "**ERROR** 1"}, {Content: "**ERROR** 2"}, {Content: "**ERROR** 3"},
}}
if _, err := summarizeTexts(context.Background(), depsWithChat(f), "llm", "sys", "user", 512); err == nil {
t.Fatal("expected error after exhausting retries")
}
if f.calls != raptorMaxRetries {
t.Fatalf("expected %d attempts, got %d", raptorMaxRetries, f.calls)
}
}
func TestSummarizeTextsPassesMaxTokens(t *testing.T) {
f := &fakeChat{responses: []*common.ChatResponse{{Content: "title\nbody"}}}
if _, err := summarizeTexts(context.Background(), depsWithChat(f), "llm", "sys", "user", 1024); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if f.lastReq.MaxTokens == nil || *f.lastReq.MaxTokens != 1024 {
t.Fatalf("MaxTokens not passed through: %v", f.lastReq.MaxTokens)
}
}
func TestBuildClusterContentJoinsWithSingleNewline(t *testing.T) {
// delimiter must be "\n" to match Python's "\n".join, not "\n\n".
out := buildClusterContent([]string{"a", "b", "c"}, []int{0, 1, 2}, common.DefaultLLMContextLength, 512)
if out != "a\nb\nc" {
t.Fatalf("expected single-newline join, got %q", out)
}
}
func TestBuildClusterContentTruncatesPerChunk(t *testing.T) {
// A long text must be truncated to the per-chunk token budget so the
// cluster fits the LLM context window (Python len_per_chunk).
long := strings.Repeat("hello world ", 200)
out := buildClusterContent([]string{long}, []int{0}, common.DefaultLLMContextLength, 512)
per := (common.DefaultLLMContextLength - 512) / 1
if tokenizer.NumTokensFromString(out) > per {
t.Fatalf("output exceeded per-chunk budget: %d > %d", tokenizer.NumTokensFromString(out), per)
}
}
// TestBuildTreeNoPanicWhenAllSummariesFail guards the divide-by-zero that
// occurred when every deepest cluster failed: buildClusterContent divides by
// len(idxs), and the root synthesis built a cluster from allIndices(0) when
// topLevelTexts was empty. The root is now skipped and the partial tree is
// returned without error.
func TestBuildTreeNoPanicWhenAllSummariesFail(t *testing.T) {
errs := make([]error, 16)
for i := range errs {
errs[i] = context.DeadlineExceeded
}
f := &fakeChat{errs: errs}
deps := common.Deps{Chat: f, Embed: nil, TenantID: "t"}
// Pre-computed vectors so the tree never needs to call the embedder.
chunks := []common.Chunk{
{Text: "alpha", Vector: []float32{1, 0, 0, 0}},
{Text: "beta", Vector: []float32{0, 1, 0, 0}},
}
var products []common.Product
if err := buildTree(context.Background(), deps, "llm", "t", "d", chunks, 4, "", common.Param{}, &products); err != nil {
t.Fatalf("buildTree returned unexpected error: %v", err)
}
if len(products) != 0 {
t.Fatalf("expected no products when every summary fails, got %d", len(products))
}
}
// TestDefaultRaptorPromptMatchesTreeYAML locks the default summary prompt to the
// production tree.yaml template. It must equal the Python tree compilation
// template prompt (api/db/init_data/compilation_templates/tree.yaml), NOT the
// compiler.py:128 fallback. Critically, the YAML literal block carries a base
// indent of 6 spaces before {cluster_content}; those 6 spaces are part of the
// prompt and must be preserved (Python does self._prompt.format(...), splicing
// the cluster text after the 6-space indent).
func TestDefaultRaptorPromptMatchesTreeYAML(t *testing.T) {
want := "Please summarize the following paragraphs. Be careful with the numbers, do not make things up. Paragraphs as following:\n {cluster_content}\nThe above is the content you need to summarize."
if defaultRaptorPrompt != want {
t.Fatalf("defaultRaptorPrompt drifted from tree.yaml:\n got: %q\nwant: %q", defaultRaptorPrompt, want)
}
if !strings.Contains(defaultRaptorPrompt, "\n {cluster_content}") {
t.Errorf("defaultRaptorPrompt missing the 6-space indent before {cluster_content}: %q", defaultRaptorPrompt)
}
}