Files
ragflow/internal/ingestion/component/knowledge_compiler/tree/raptor_test.go
Zhichang Yu 29287ef74b refactor(knowledge_compiler): derive variant from template kind via single template id (#17630)
## Summary

Refactor the Go `KnowledgeCompilerComponent` so its parameter is a
**single string template id** instead of a DSL-level `variant` (or
plural group id list). The `variant` is no longer in the DSL — it is now
**derived at runtime from the resolved compilation template's `kind`
field**.

This aligns the Go ingestion port with the frontend Compiler operator,
which emits a singular `compilation_template_group_id` and does not
write `variant` into the generated `compiler.json`.
2026-07-31 18:01:55 +08:00

166 lines
6.3 KiB
Go
Raw 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 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)
}
}