mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-17 05:07:23 +08:00
## Summary - Align Go WenCai and SearXNG behavior, schemas, and node parameters with Python. - Add the `WenCai` and `SearXNG` Canvas components and register their tool factories. - Match Python's current WenCai behavior by returning an empty report while its upstream request is disabled. - Add SearXNG request validation, SSRF-safe DNS pinning, raw result preservation, and reference rendering. - Support context cancellation, error envelopes, and lock-safe retrieval references. ## Tests Passed: - `bash build.sh --test ./internal/agent/tool/...` - `bash build.sh --test ./internal/agent/component/...` - `bash build.sh --test ./internal/agent/runtime/...` - `bash build.sh --test ./internal/agent/...` - `cd web && npm run type-check` <img width="1900" height="1102" alt="image" src="https://github.com/user-attachments/assets/ec77d217-d9fd-455a-96ec-9aabf6841109" /> <img width="1900" height="1102" alt="image" src="https://github.com/user-attachments/assets/52ac129f-cb65-453d-ae48-cc518803ac23" />
251 lines
8.1 KiB
Go
251 lines
8.1 KiB
Go
//
|
|
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
|
|
package component
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
einotool "github.com/cloudwego/eino/components/tool"
|
|
|
|
agentcanvas "ragflow/internal/agent/canvas"
|
|
"ragflow/internal/agent/runtime"
|
|
"ragflow/internal/tokenizer"
|
|
)
|
|
|
|
type fakeSearXNGInvoker struct {
|
|
args map[string]any
|
|
calls int
|
|
out string
|
|
err error
|
|
}
|
|
|
|
func (f *fakeSearXNGInvoker) InvokableRun(_ context.Context, argsJSON string, _ ...einotool.Option) (string, error) {
|
|
f.calls++
|
|
if err := json.Unmarshal([]byte(argsJSON), &f.args); err != nil {
|
|
return "", err
|
|
}
|
|
return f.out, f.err
|
|
}
|
|
|
|
func TestSearXNGRegisteredFactoryMatchesPythonSurface(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
component, err := New("SearXNG", map[string]any{
|
|
"top_n": "10",
|
|
"searxng_url": "http://localhost:4000",
|
|
"outputs": map[string]any{"formalized_content": map[string]any{}},
|
|
"setups": map[string]any{"query": "configured query"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New(SearXNG): %v", err)
|
|
}
|
|
if component.Name() != "SearXNG" {
|
|
t.Fatalf("Name = %q, want SearXNG", component.Name())
|
|
}
|
|
if _, ok := component.Inputs()["query"]; !ok {
|
|
t.Fatal("Inputs missing query")
|
|
}
|
|
if _, ok := component.Inputs()["searxng_url"]; !ok {
|
|
t.Fatal("Inputs missing searxng_url")
|
|
}
|
|
for _, output := range []string{"formalized_content", "json"} {
|
|
if _, ok := component.Outputs()[output]; !ok {
|
|
t.Fatalf("Outputs missing %s", output)
|
|
}
|
|
}
|
|
form := component.(*searxngComponent).GetInputForm()
|
|
if len(form) != 2 {
|
|
t.Fatalf("GetInputForm size = %d, want 2", len(form))
|
|
}
|
|
query := form["query"].(map[string]any)
|
|
if query["name"] != "Query" || query["type"] != "line" {
|
|
t.Fatalf("query form = %#v", query)
|
|
}
|
|
serverURL := form["searxng_url"].(map[string]any)
|
|
if serverURL["name"] != "SearXNG URL" || serverURL["type"] != "line" || serverURL["placeholder"] != "http://localhost:4000" {
|
|
t.Fatalf("searxng_url form = %#v", serverURL)
|
|
}
|
|
}
|
|
|
|
func TestSearXNGInvokePreservesRawJSONPromptAndReferences(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
content := "RAGFlow content  remains"
|
|
fake := &fakeSearXNGInvoker{out: `{"results":[{"title":"RAGFlow\nDocs","url":"https://ragflow.io","content":` + mustJSONText(t, content) + `,"engine":"bing","score":0.9},{"title":"Empty","url":"https://example.com","content":""}]}`}
|
|
component := newSearXNGComponentWithInvoker(fake)
|
|
state := runtime.NewCanvasState("run", "task")
|
|
ctx := runtime.WithState(context.Background(), state)
|
|
out, err := component.Invoke(ctx, map[string]any{
|
|
"query": " ragflow ",
|
|
"searxng_url": "http://localhost:4000",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
if fake.args["query"] != " ragflow " || fake.args["searxng_url"] != "http://localhost:4000" {
|
|
t.Fatalf("runtime args = %#v", fake.args)
|
|
}
|
|
results, ok := out["json"].([]any)
|
|
if !ok || len(results) != 2 {
|
|
t.Fatalf("json = %#v, want two raw results", out["json"])
|
|
}
|
|
first := results[0].(map[string]any)
|
|
if first["engine"] != "bing" || first["score"] != float64(0.9) {
|
|
t.Fatalf("raw result lost fields: %#v", first)
|
|
}
|
|
|
|
cleaned := "RAGFlow content remains"
|
|
documentID := hashSearXNGString(cleaned, 100000000)
|
|
referenceID := hashSearXNGString(strconv.Itoa(documentID), 500)
|
|
if documentID != 93760153 || referenceID != 491 {
|
|
t.Fatalf("hash parity = %d/%d, want Python 93760153/491", documentID, referenceID)
|
|
}
|
|
formalized := out["formalized_content"].(string)
|
|
for _, want := range []string{
|
|
"ID: " + strconv.Itoa(referenceID),
|
|
"Title: RAGFlow Docs",
|
|
"URL: https://ragflow.io",
|
|
"Content:\n" + cleaned,
|
|
} {
|
|
if !strings.Contains(formalized, want) {
|
|
t.Fatalf("formalized_content missing %q: %s", want, formalized)
|
|
}
|
|
}
|
|
|
|
chunks := state.GetRetrievalChunks()
|
|
if len(chunks) != 1 {
|
|
t.Fatalf("retrieval chunks = %#v, want one non-empty-content chunk", chunks)
|
|
}
|
|
if chunks[0]["document_id"] != strconv.Itoa(documentID) || chunks[0]["content"] != cleaned {
|
|
t.Fatalf("retrieval chunk = %#v", chunks[0])
|
|
}
|
|
if chunks[0]["id"] != strconv.Itoa(referenceID) {
|
|
t.Fatalf("chunk id = %#v, want displayed reference ID", chunks[0]["id"])
|
|
}
|
|
if chunks[0]["chunk_id"] != strconv.Itoa(documentID) || chunks[0]["doc_id"] != strconv.Itoa(documentID) {
|
|
t.Fatalf("raw document IDs = %#v, want Python content hash", chunks[0])
|
|
}
|
|
if chunks[0]["similarity"] != 1 {
|
|
t.Fatalf("similarity = %#v, want 1", chunks[0]["similarity"])
|
|
}
|
|
aggs := state.GetRetrievalDocAggs()
|
|
if len(aggs) != 1 || aggs["RAGFlow\nDocs"]["doc_id"] != strconv.Itoa(documentID) {
|
|
t.Fatalf("doc_aggs = %#v", aggs)
|
|
}
|
|
}
|
|
|
|
func TestSearXNGInvokeEmptyQuerySkipsTool(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fake := &fakeSearXNGInvoker{}
|
|
component := newSearXNGComponentWithInvoker(fake)
|
|
out, err := component.Invoke(context.Background(), map[string]any{"query": " "})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
if fake.calls != 0 {
|
|
t.Fatalf("tool calls = %d, want 0", fake.calls)
|
|
}
|
|
if out["formalized_content"] != "" {
|
|
t.Fatalf("formalized_content = %#v", out["formalized_content"])
|
|
}
|
|
if results, ok := out["json"].([]any); !ok || len(results) != 0 {
|
|
t.Fatalf("json = %#v, want empty []any", out["json"])
|
|
}
|
|
}
|
|
|
|
func TestSearXNGInvokePreservesErrorEnvelope(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fake := &fakeSearXNGInvoker{
|
|
out: `{"results":[],"_ERROR":"Network error: upstream down"}`,
|
|
err: errors.New("upstream down"),
|
|
}
|
|
component := newSearXNGComponentWithInvoker(fake)
|
|
out, err := component.Invoke(context.Background(), map[string]any{"query": "ragflow"})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
if out["_ERROR"] != "Network error: upstream down" || out["formalized_content"] != "" {
|
|
t.Fatalf("output = %#v", out)
|
|
}
|
|
}
|
|
|
|
func TestSearXNGPromptBoundaryMatchesPython(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
first := "small content"
|
|
second := strings.Repeat("cross the token budget ", 50)
|
|
third := "must not render"
|
|
chunks := []map[string]any{
|
|
{"id": "1", "content": first},
|
|
{"id": "2", "content": second},
|
|
{"id": "3", "content": third},
|
|
}
|
|
firstTokens := tokenizer.NumTokensFromString(first)
|
|
maxTokens := int(math.Ceil(float64(firstTokens)/0.97)) + 1
|
|
rendered := renderSearXNGReferences(chunks, maxTokens)
|
|
if !strings.Contains(rendered, "ID: 1") || !strings.Contains(rendered, "ID: 2") {
|
|
t.Fatalf("crossing chunk must be included like Python: %s", rendered)
|
|
}
|
|
if strings.Contains(rendered, "ID: 3") {
|
|
t.Fatalf("chunk after budget crossing must be excluded: %s", rendered)
|
|
}
|
|
}
|
|
|
|
func TestSearXNGBuildWorkflowUsesPythonComponentName(t *testing.T) {
|
|
canvas := &agentcanvas.Canvas{
|
|
Components: map[string]agentcanvas.CanvasComponent{
|
|
"begin_0": {
|
|
Obj: agentcanvas.CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
|
|
Downstream: []string{"searxng_0"},
|
|
},
|
|
"searxng_0": {
|
|
Obj: agentcanvas.CanvasComponentObj{ComponentName: "SearXNG", Params: map[string]any{
|
|
"top_n": "10",
|
|
"searxng_url": "http://localhost:4000",
|
|
}},
|
|
Upstream: []string{"begin_0"},
|
|
Downstream: []string{"message_0"},
|
|
},
|
|
"message_0": {
|
|
Obj: agentcanvas.CanvasComponentObj{ComponentName: "Message", Params: map[string]any{}},
|
|
Upstream: []string{"searxng_0"},
|
|
},
|
|
},
|
|
Path: []string{"begin_0", "searxng_0", "message_0"},
|
|
}
|
|
if _, err := agentcanvas.BuildWorkflow(context.Background(), canvas); err != nil {
|
|
t.Fatalf("BuildWorkflow with SearXNG: %v", err)
|
|
}
|
|
}
|
|
|
|
func mustJSONText(t *testing.T, value string) string {
|
|
t.Helper()
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal: %v", err)
|
|
}
|
|
return string(raw)
|
|
}
|