Files
ragflow/internal/agent/component/searxng_test.go
Hz_ d03b09b6a3 fix(go-agent): Align Go WenCai and SearXNG agent components with python (#16854)
## 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"
/>
2026-07-13 16:50:17 +08:00

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 ![img](data:image/png;base64,AAAA) 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)
}