mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-13 20:26:51 +08:00
### Summary Add Querit Contents as a built-in page content tool for RAGFlow Agents and Canvas workflows. Querit Contents crawls one or more known URLs and returns their page content and optional metadata. It complements the existing Querit Search tool: Search discovers relevant pages, while Contents retrieves pages already selected by an Agent or workflow. This integration supports two usage modes: - A standalone `QueritContents` node in Canvas workflows. - An embedded content tool available to RAGFlow Agents.
605 lines
22 KiB
Go
605 lines
22 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"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"reflect"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
einotool "github.com/cloudwego/eino/components/tool"
|
|
|
|
"ragflow/internal/agent/canvas"
|
|
"ragflow/internal/agent/runtime"
|
|
agenttool "ragflow/internal/agent/tool"
|
|
)
|
|
|
|
type fakeToolAdapter struct {
|
|
args map[string]any
|
|
referenceEnvelope map[string]any
|
|
outputEnvelope map[string]any
|
|
calls int
|
|
events []string
|
|
out string
|
|
err error
|
|
}
|
|
|
|
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripperFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
|
return f(request)
|
|
}
|
|
|
|
func (f *fakeToolAdapter) InvokableRun(_ context.Context, argsJSON string, _ ...einotool.Option) (string, error) {
|
|
f.calls++
|
|
if err := json.Unmarshal([]byte(argsJSON), &f.args); err != nil {
|
|
return "", err
|
|
}
|
|
if f.out != "" || f.err != nil {
|
|
return f.out, f.err
|
|
}
|
|
return `{"results":[{"title":"RAGFlow","url":"https://ragflow.io","content":"RAG engine"}]}`, nil
|
|
}
|
|
|
|
func (f *fakeToolAdapter) ComponentSpec() agenttool.ComponentSpec {
|
|
return agenttool.ComponentSpec{
|
|
Inputs: map[string]string{"query": "Search query."},
|
|
Outputs: map[string]string{"formalized_content": "Rendered results.", "json": "Raw results."},
|
|
InputForm: map[string]any{"query": map[string]any{"name": "Query", "type": "line"}},
|
|
}
|
|
}
|
|
|
|
func (f *fakeToolAdapter) BuildReferences(_ context.Context, envelope map[string]any) ([]map[string]any, []map[string]any) {
|
|
f.referenceEnvelope = envelope
|
|
f.events = append(f.events, "references")
|
|
return []map[string]any{{"chunk_id": "1", "content": "RAG engine", "docnm_kwd": "RAGFlow"}},
|
|
[]map[string]any{{"doc_name": "RAGFlow", "doc_id": "1", "count": 1}}
|
|
}
|
|
|
|
func (f *fakeToolAdapter) BuildComponentOutputs(envelope map[string]any) map[string]any {
|
|
f.outputEnvelope = envelope
|
|
f.events = append(f.events, "render")
|
|
formalizedContent := ""
|
|
results := anySlice(envelope["results"])
|
|
if len(results) > 0 {
|
|
if result, ok := results[0].(map[string]any); ok {
|
|
formalizedContent, _ = result["content"].(string)
|
|
}
|
|
}
|
|
return map[string]any{
|
|
"json": results,
|
|
"formalized_content": formalizedContent,
|
|
"tool_metadata": envelope["tool_metadata"],
|
|
}
|
|
}
|
|
|
|
func TestToolBackedComponentRegisteredGitHubFactory(t *testing.T) {
|
|
c, err := New("GitHub", map[string]any{
|
|
"top_n": float64(10),
|
|
"query": "runtime query",
|
|
"outputs": map[string]any{"json": map[string]any{}},
|
|
"setups": map[string]any{"query": "configured query"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New(GitHub): %v", err)
|
|
}
|
|
if _, ok := c.(*ToolBackedComponent); !ok {
|
|
t.Fatalf("New(GitHub) returned %T, want *ToolBackedComponent", c)
|
|
}
|
|
if c.Name() != "GitHub" {
|
|
t.Fatalf("Name = %q, want GitHub", c.Name())
|
|
}
|
|
form := c.(interface{ GetInputForm() map[string]any }).GetInputForm()
|
|
if query, ok := form["query"].(map[string]any); !ok || query["type"] != "line" {
|
|
t.Fatalf("query input form = %#v, want line", form["query"])
|
|
}
|
|
}
|
|
|
|
func TestToolBackedComponentRegisteredFactories(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
toolName string
|
|
params map[string]any
|
|
outputKey string
|
|
inputKey string
|
|
}{
|
|
{
|
|
name: "ArXiv",
|
|
toolName: "ArXiv",
|
|
params: map[string]any{"top_n": float64(3), "sort_by": "relevance", "outputs": map[string]any{"json": map[string]any{}}},
|
|
outputKey: "json",
|
|
inputKey: "query",
|
|
},
|
|
{
|
|
name: "BGPT",
|
|
toolName: "BGPT",
|
|
params: map[string]any{"api_key": "stored-key", "top_n": float64(3), "outputs": map[string]any{"json": map[string]any{}}},
|
|
outputKey: "json",
|
|
inputKey: "query",
|
|
},
|
|
{
|
|
name: "KeenableSearch",
|
|
toolName: "KeenableSearch",
|
|
params: map[string]any{"api_key": "stored-key", "mode": "realtime", "top_n": float64(3), "outputs": map[string]any{"json": map[string]any{}}},
|
|
outputKey: "json",
|
|
inputKey: "query",
|
|
},
|
|
{
|
|
name: "PubMed",
|
|
toolName: "PubMed",
|
|
params: map[string]any{"top_n": float64(3), "email": "node@example.com", "outputs": map[string]any{"json": map[string]any{}}},
|
|
outputKey: "json",
|
|
inputKey: "query",
|
|
},
|
|
{
|
|
name: "Google",
|
|
toolName: "Google",
|
|
params: map[string]any{"api_key": "stored-key", "country": "cn", "language": "en", "outputs": map[string]any{"json": map[string]any{}}},
|
|
outputKey: "json",
|
|
inputKey: "q",
|
|
},
|
|
{
|
|
name: "ExeSQL",
|
|
toolName: "ExeSQL",
|
|
params: map[string]any{
|
|
"database": "demo", "username": "root", "host": "db.example.com", "port": float64(3306), "password": "secret",
|
|
"top_n": float64(50), "outputs": map[string]any{"json": map[string]any{}},
|
|
},
|
|
outputKey: "json",
|
|
inputKey: "sql",
|
|
},
|
|
{
|
|
name: "GoogleScholar",
|
|
toolName: "GoogleScholar",
|
|
params: map[string]any{"top_n": float64(3), "sort_by": "relevance", "outputs": map[string]any{"json": map[string]any{}}},
|
|
outputKey: "json",
|
|
inputKey: "query",
|
|
},
|
|
{
|
|
name: "DuckDuckGo",
|
|
toolName: "DuckDuckGo",
|
|
params: map[string]any{"top_n": float64(3), "channel": "news", "outputs": map[string]any{"json": map[string]any{}}},
|
|
outputKey: "json",
|
|
inputKey: "query",
|
|
},
|
|
{
|
|
name: "Email",
|
|
toolName: "Email",
|
|
params: map[string]any{
|
|
"smtp_server": "smtp.example.com", "smtp_port": float64(465), "email": "sender@example.com",
|
|
"password": "secret", "sender_name": "Sender", "outputs": map[string]any{"success": map[string]any{}},
|
|
},
|
|
outputKey: "success",
|
|
inputKey: "to_email",
|
|
},
|
|
{
|
|
name: "QueritContents",
|
|
toolName: "QueritContents",
|
|
params: map[string]any{"api_key": "stored-key", "format": "markdown", "crawl_timeout": float64(10), "extras_meta": true, "outputs": map[string]any{"json": map[string]any{}}},
|
|
outputKey: "json",
|
|
inputKey: "urls",
|
|
},
|
|
{
|
|
name: "QueritSearch",
|
|
toolName: "QueritSearch",
|
|
params: map[string]any{"api_key": "stored-key", "count": float64(10), "chunks_per_doc": float64(3), "outputs": map[string]any{"json": map[string]any{}}},
|
|
outputKey: "json",
|
|
inputKey: "query",
|
|
},
|
|
{
|
|
name: "SearXNG",
|
|
toolName: "SearXNG",
|
|
params: map[string]any{"top_n": "10", "searxng_url": "https://searx.example.com", "outputs": map[string]any{"json": map[string]any{}}},
|
|
outputKey: "json",
|
|
inputKey: "query",
|
|
},
|
|
{
|
|
name: "WenCai",
|
|
toolName: "WenCai",
|
|
params: map[string]any{"top_n": float64(20), "query_type": "stock", "outputs": map[string]any{"report": map[string]any{}}},
|
|
outputKey: "report",
|
|
inputKey: "query",
|
|
},
|
|
{
|
|
name: "Wikipedia",
|
|
toolName: "Wikipedia",
|
|
params: map[string]any{"top_n": float64(3), "language": "en", "outputs": map[string]any{"json": map[string]any{}}},
|
|
outputKey: "json",
|
|
inputKey: "query",
|
|
},
|
|
{
|
|
name: "YahooFinance",
|
|
toolName: "YahooFinance",
|
|
params: map[string]any{"outputs": map[string]any{"report": map[string]any{}}},
|
|
outputKey: "report",
|
|
inputKey: "stock_code",
|
|
},
|
|
{
|
|
name: "TavilyExtract",
|
|
toolName: "TavilyExtract",
|
|
params: map[string]any{"api_key": "stored-key", "extract_depth": "advanced", "format": "text", "outputs": map[string]any{"json": map[string]any{}}},
|
|
outputKey: "json",
|
|
inputKey: "urls",
|
|
},
|
|
{
|
|
name: "TavilySearch",
|
|
toolName: "TavilySearch",
|
|
params: map[string]any{"api_key": "stored-key", "search_depth": "advanced", "max_results": float64(3), "outputs": map[string]any{"json": map[string]any{}}},
|
|
outputKey: "json",
|
|
inputKey: "query",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c, err := New(tt.toolName, tt.params)
|
|
if err != nil {
|
|
t.Fatalf("New(%s): %v", tt.toolName, err)
|
|
}
|
|
if _, ok := c.(*ToolBackedComponent); !ok {
|
|
t.Fatalf("New(%s) returned %T, want *ToolBackedComponent", tt.toolName, c)
|
|
}
|
|
if c.Name() != tt.toolName {
|
|
t.Fatalf("Name = %q, want %q", c.Name(), tt.toolName)
|
|
}
|
|
if _, ok := c.Inputs()[tt.inputKey]; !ok {
|
|
t.Fatalf("Inputs missing %q: %#v", tt.inputKey, c.Inputs())
|
|
}
|
|
if _, ok := c.Outputs()[tt.outputKey]; !ok {
|
|
t.Fatalf("Outputs missing %q: %#v", tt.outputKey, c.Outputs())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestToolBackedComponentWenCaiInvoke(t *testing.T) {
|
|
c, err := New("WenCai", map[string]any{"top_n": float64(20), "query_type": "stock"})
|
|
if err != nil {
|
|
t.Fatalf("New(WenCai): %v", err)
|
|
}
|
|
out, err := c.Invoke(context.Background(), nil, map[string]any{
|
|
"query": "商业航天",
|
|
"unrelated": "ignored by the tool parameter struct",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
if out["report"] != "" {
|
|
t.Fatalf("outputs = %#v, want empty report", out)
|
|
}
|
|
}
|
|
|
|
func TestToolBackedComponentRegisteredBuildWorkflow(t *testing.T) {
|
|
for _, componentName := range []string{"ArXiv", "BGPT", "DuckDuckGo", "Email", "Google", "GoogleScholar", "KeenableSearch", "PubMed", "QueritContents", "QueritSearch", "SearXNG", "WenCai", "TavilyExtract", "TavilySearch", "Wikipedia", "YahooFinance"} {
|
|
t.Run(componentName, func(t *testing.T) {
|
|
c := &canvas.Canvas{
|
|
Components: map[string]canvas.CanvasComponent{
|
|
"begin_0": {
|
|
Obj: canvas.CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
|
|
Downstream: []string{"tool_0"},
|
|
},
|
|
"tool_0": {
|
|
Obj: canvas.CanvasComponentObj{ComponentName: componentName, Params: map[string]any{}},
|
|
Upstream: []string{"begin_0"},
|
|
},
|
|
},
|
|
Path: []string{"begin_0", "tool_0"},
|
|
}
|
|
if _, err := canvas.BuildWorkflow(context.Background(), c); err != nil {
|
|
t.Fatalf("BuildWorkflow with %s: %v", componentName, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestToolBackedComponentCanvasBuildWorkflow(t *testing.T) {
|
|
c := &canvas.Canvas{
|
|
Components: map[string]canvas.CanvasComponent{
|
|
"begin_0": {
|
|
Obj: canvas.CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
|
|
Downstream: []string{"github_0"},
|
|
},
|
|
"github_0": {
|
|
Obj: canvas.CanvasComponentObj{ComponentName: "GitHub", Params: map[string]any{"top_n": float64(10)}},
|
|
Upstream: []string{"begin_0"},
|
|
},
|
|
},
|
|
Path: []string{"begin_0", "github_0"},
|
|
}
|
|
if _, err := canvas.BuildWorkflow(context.Background(), c); err != nil {
|
|
t.Fatalf("BuildWorkflow with GitHub: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestToolBackedComponentInvokeOrdersReferencesBeforeRendering(t *testing.T) {
|
|
fake := &fakeToolAdapter{out: `{"results":[{"title":"RAGFlow","content":"RAG engine"}],"tool_metadata":{"request_id":"request-1"}}`}
|
|
c := &ToolBackedComponent{name: "Search", tool: fake, spec: fake.ComponentSpec()}
|
|
state := runtime.NewCanvasState("run", "task")
|
|
out, err := c.Invoke(runtime.WithState(context.Background(), state), nil, map[string]any{
|
|
"query": "ragflow",
|
|
"top_n": float64(10),
|
|
"outputs": map[string]any{"json": map[string]any{}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
if fake.args["query"] != "ragflow" {
|
|
t.Fatalf("runtime args = %#v", fake.args)
|
|
}
|
|
if fake.args["top_n"] != float64(10) || fake.args["outputs"] == nil {
|
|
t.Fatalf("generic component did not pass all inputs: %#v", fake.args)
|
|
}
|
|
if !reflect.DeepEqual(fake.events, []string{"references", "render"}) {
|
|
t.Fatalf("post-process order = %#v, want references then render", fake.events)
|
|
}
|
|
if fake.referenceEnvelope["tool_metadata"] == nil || fake.outputEnvelope["tool_metadata"] == nil {
|
|
t.Fatalf("complete tool envelope was not passed to post-processors: references=%#v outputs=%#v", fake.referenceEnvelope, fake.outputEnvelope)
|
|
}
|
|
if _, exists := fake.outputEnvelope["chunks"]; exists {
|
|
t.Fatalf("generic component injected references into the tool envelope: %#v", fake.outputEnvelope)
|
|
}
|
|
if metadata, ok := out["tool_metadata"].(map[string]any); !ok || metadata["request_id"] != "request-1" {
|
|
t.Fatalf("tool-specific envelope fields were lost: %#v", out)
|
|
}
|
|
if out["formalized_content"] != "RAG engine" {
|
|
t.Fatalf("formalized_content = %#v", out["formalized_content"])
|
|
}
|
|
if len(state.GetRetrievalChunks()) != 1 {
|
|
t.Fatalf("retrieval chunks = %#v", state.GetRetrievalChunks())
|
|
}
|
|
}
|
|
|
|
func TestToolBackedComponentReturnsErrorEnvelopeWithoutReferences(t *testing.T) {
|
|
fake := &fakeToolAdapter{
|
|
out: `{"results":[],"_ERROR":"rate limited"}`,
|
|
err: errors.New("rate limited"),
|
|
}
|
|
c := &ToolBackedComponent{name: "Search", tool: fake, spec: fake.ComponentSpec()}
|
|
state := runtime.NewCanvasState("run-error", "task-error")
|
|
out, err := c.Invoke(runtime.WithState(context.Background(), state), nil, map[string]any{"query": "ragflow"})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
if out["_ERROR"] != "rate limited" || out["formalized_content"] != "" {
|
|
t.Fatalf("error outputs = %#v", out)
|
|
}
|
|
if results, ok := out["json"].([]any); !ok || len(results) != 0 {
|
|
t.Fatalf("error json output = %#v, want an empty array", out["json"])
|
|
}
|
|
if len(state.GetRetrievalChunks()) != 0 {
|
|
t.Fatalf("error path recorded references: %#v", state.GetRetrievalChunks())
|
|
}
|
|
}
|
|
|
|
func TestToolBackedComponentGitHubIntegration(t *testing.T) {
|
|
serverCalls := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
serverCalls++
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
_, _ = writer.Write([]byte(`{"items":[{"name":"ragflow","html_url":"https://github.com/infiniflow/ragflow","description":"RAG engine","watchers":12000,"private":false}]}`))
|
|
}))
|
|
defer server.Close()
|
|
target, err := url.Parse(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("parse test server URL: %v", err)
|
|
}
|
|
helper := agenttool.NewHTTPHelper().WithClient(&http.Client{Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) {
|
|
cloned := request.Clone(request.Context())
|
|
cloned.URL.Scheme = target.Scheme
|
|
cloned.URL.Host = target.Host
|
|
return http.DefaultTransport.RoundTrip(cloned)
|
|
})})
|
|
github := agenttool.NewGitHubToolWith(helper)
|
|
component := &ToolBackedComponent{name: "GitHub", tool: github, spec: github.ComponentSpec()}
|
|
state := runtime.NewCanvasState("run-github", "task-github")
|
|
empty, err := component.Invoke(context.Background(), nil, map[string]any{"query": ""})
|
|
if err != nil {
|
|
t.Fatalf("Invoke(empty query): %v", err)
|
|
}
|
|
if serverCalls != 0 || len(empty["json"].([]any)) != 0 || empty["formalized_content"] != "" {
|
|
t.Fatalf("empty query result = %#v, server calls = %d", empty, serverCalls)
|
|
}
|
|
out, err := component.Invoke(runtime.WithState(context.Background(), state), nil, map[string]any{"query": "ragflow"})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
rendered, _ := out["formalized_content"].(string)
|
|
if !strings.Contains(rendered, "Title: ragflow") || !strings.Contains(rendered, "RAG engine\n stars:12000") {
|
|
t.Fatalf("formalized_content = %q", rendered)
|
|
}
|
|
results, ok := out["json"].([]any)
|
|
if !ok || len(results) != 1 || results[0].(map[string]any)["private"] != false {
|
|
t.Fatalf("raw json results = %#v", out["json"])
|
|
}
|
|
chunks := state.GetRetrievalChunks()
|
|
if len(chunks) != 1 || chunks[0]["document_name"] != "ragflow" {
|
|
t.Fatalf("recorded references = %#v", chunks)
|
|
}
|
|
}
|
|
|
|
func TestToolBackedComponentTavilyIntegration(t *testing.T) {
|
|
serverCalls := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
serverCalls++
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
_, _ = writer.Write([]byte(`{"results":[{"title":"RAGFlow","url":"https://ragflow.io","raw_content":"RAG article","content":"fallback","score":0.8,"custom":"preserved"}]}`))
|
|
}))
|
|
defer server.Close()
|
|
target, err := url.Parse(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("parse test server URL: %v", err)
|
|
}
|
|
helper := agenttool.NewHTTPHelper().WithClient(&http.Client{Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) {
|
|
cloned := request.Clone(request.Context())
|
|
cloned.URL.Scheme = target.Scheme
|
|
cloned.URL.Host = target.Host
|
|
return http.DefaultTransport.RoundTrip(cloned)
|
|
})})
|
|
tavily := agenttool.NewTavilyToolWith(helper)
|
|
component := &ToolBackedComponent{name: "TavilySearch", tool: tavily, spec: tavily.ComponentSpec()}
|
|
state := runtime.NewCanvasState("run-tavily", "task-tavily")
|
|
empty, err := component.Invoke(context.Background(), nil, map[string]any{"query": ""})
|
|
if err != nil {
|
|
t.Fatalf("Invoke(empty query): %v", err)
|
|
}
|
|
if serverCalls != 0 || len(empty["json"].([]any)) != 0 || empty["formalized_content"] != "" {
|
|
t.Fatalf("empty query result = %#v, server calls = %d", empty, serverCalls)
|
|
}
|
|
out, err := component.Invoke(runtime.WithState(context.Background(), state), nil, map[string]any{"query": "ragflow", "api_key": "key"})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
rendered, _ := out["formalized_content"].(string)
|
|
if !strings.Contains(rendered, "Title: RAGFlow") || !strings.Contains(rendered, "RAG article") {
|
|
t.Fatalf("formalized_content = %q", rendered)
|
|
}
|
|
results, ok := out["json"].([]any)
|
|
if !ok || len(results) != 1 || results[0].(map[string]any)["custom"] != "preserved" {
|
|
t.Fatalf("raw json results = %#v", out["json"])
|
|
}
|
|
chunks := state.GetRetrievalChunks()
|
|
if len(chunks) != 1 || chunks[0]["document_name"] != "RAGFlow" || chunks[0]["similarity"] != float64(0.8) {
|
|
t.Fatalf("recorded references = %#v", chunks)
|
|
}
|
|
}
|
|
|
|
func TestToolBackedComponentQueritIntegration(t *testing.T) {
|
|
var serverCalls atomic.Int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
serverCalls.Add(1)
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
_, _ = writer.Write([]byte(`{"results":{"result":[{"title":"RAGFlow","url":"https://ragflow.io","snippet":"RAG article","custom":"preserved"}]},"search_id":11099848653006015581,"query_context":{"rewritten":"rag flow"}}`))
|
|
}))
|
|
defer server.Close()
|
|
target, err := url.Parse(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("parse test server URL: %v", err)
|
|
}
|
|
helper := agenttool.NewHTTPHelper().WithClient(&http.Client{Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) {
|
|
cloned := request.Clone(request.Context())
|
|
cloned.URL.Scheme = target.Scheme
|
|
cloned.URL.Host = target.Host
|
|
return http.DefaultTransport.RoundTrip(cloned)
|
|
})})
|
|
querit := agenttool.NewQueritToolWithEnvKey(helper, func() string { return "key" })
|
|
component := &ToolBackedComponent{name: "QueritSearch", tool: querit, spec: querit.ComponentSpec()}
|
|
state := runtime.NewCanvasState("run-querit", "task-querit")
|
|
empty, err := component.Invoke(context.Background(), nil, map[string]any{"query": ""})
|
|
if err != nil {
|
|
t.Fatalf("Invoke(empty query): %v", err)
|
|
}
|
|
emptyJSON, emptyJSONOK := empty["json"].(map[string]any)
|
|
if serverCalls.Load() != 0 || empty["formalized_content"] != "" || !emptyJSONOK || len(emptyJSON) != 0 {
|
|
t.Fatalf("empty query result = %#v, server calls = %d", empty, serverCalls.Load())
|
|
}
|
|
out, err := component.Invoke(runtime.WithState(context.Background(), state), nil, map[string]any{"query": "ragflow"})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
rendered, _ := out["formalized_content"].(string)
|
|
if !strings.Contains(rendered, "Title: RAGFlow") || !strings.Contains(rendered, "RAG article") {
|
|
t.Fatalf("formalized_content = %q", rendered)
|
|
}
|
|
complete, ok := out["json"].(map[string]any)
|
|
searchID, searchIDOK := complete["search_id"].(json.Number)
|
|
if !ok || !searchIDOK || searchID.String() != "11099848653006015581" || complete["query_context"] == nil {
|
|
t.Fatalf("complete json = %#v", out["json"])
|
|
}
|
|
encoded, err := json.Marshal(complete)
|
|
if err != nil || !strings.Contains(string(encoded), `"search_id":11099848653006015581`) {
|
|
t.Fatalf("re-encoded complete json = %s, %v", encoded, err)
|
|
}
|
|
chunks := state.GetRetrievalChunks()
|
|
if len(chunks) != 1 || chunks[0]["document_name"] != "RAGFlow" || chunks[0]["similarity"] != 1 {
|
|
t.Fatalf("recorded references = %#v", chunks)
|
|
}
|
|
}
|
|
|
|
func TestParseToolEnvelopeLosslessRejectsTrailingContent(t *testing.T) {
|
|
out := parseToolEnvelopeLossless(`{"results":{"result":[]}} trailing`)
|
|
if out["_raw"] == nil {
|
|
t.Fatalf("trailing content was accepted: %#v", out)
|
|
}
|
|
}
|
|
|
|
func TestToolBackedComponentYahooFinanceIntegration(t *testing.T) {
|
|
serverCalls := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
serverCalls++
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
switch request.URL.Path {
|
|
case "/v1/finance/search":
|
|
if q := request.URL.Query().Get("q"); q != "AAPL" {
|
|
t.Errorf("q = %q", q)
|
|
}
|
|
_, _ = writer.Write([]byte(`{"quotes":[{"symbol":"AAPL","currency":"USD"}],"news":[]}`))
|
|
case "/v8/finance/chart/AAPL":
|
|
_, _ = writer.Write([]byte(`{
|
|
"chart": {
|
|
"result": [{
|
|
"meta": {"regularMarketPrice": 189.5},
|
|
"timestamp": [],
|
|
"indicators": {"quote": [{}]}
|
|
}],
|
|
"error": null
|
|
}
|
|
}`))
|
|
default:
|
|
t.Fatalf("unexpected path %s", request.URL.Path)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
target, err := url.Parse(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("parse test server URL: %v", err)
|
|
}
|
|
helper := agenttool.NewHTTPHelper().WithClient(&http.Client{Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) {
|
|
cloned := request.Clone(request.Context())
|
|
cloned.URL.Scheme = target.Scheme
|
|
cloned.URL.Host = target.Host
|
|
return http.DefaultTransport.RoundTrip(cloned)
|
|
})})
|
|
yahoo := agenttool.NewYahooFinanceToolWith(helper)
|
|
component := &ToolBackedComponent{name: "YahooFinance", tool: yahoo, spec: yahoo.ComponentSpec()}
|
|
|
|
empty, err := component.Invoke(context.Background(), nil, map[string]any{"stock_code": ""})
|
|
if err != nil {
|
|
t.Fatalf("Invoke(empty stock_code): %v", err)
|
|
}
|
|
if serverCalls != 0 || empty["report"] != "" {
|
|
t.Fatalf("empty result = %#v, server calls = %d", empty, serverCalls)
|
|
}
|
|
|
|
out, err := component.Invoke(context.Background(), nil, map[string]any{"stock_code": "AAPL"})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
report, ok := out["report"].(string)
|
|
if !ok || !strings.Contains(report, "# Information:") || !strings.Contains(report, "| symbol | AAPL |") {
|
|
t.Fatalf("report = %#v", out["report"])
|
|
}
|
|
if serverCalls != 2 {
|
|
t.Fatalf("server calls = %d, want 2", serverCalls)
|
|
}
|
|
}
|