mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-09-08 10:14:35 +08:00
fix(go-agent): Added PubMed component support (#16817)
## Summary - Merge upstream main and retain PubMed component support. - Preserve newly registered tool components and update registry verification. ## Tests - `bash build.sh --test ./internal/agent/component/...` - `bash build.sh --test ./internal/agent/tool/...` <img width="1817" height="972" alt="image" src="https://github.com/user-attachments/assets/9fcb9448-9e26-41b9-940c-a9bfde9835e9" /> --------- Co-authored-by: Jin Hai <haijin.chn@gmail.com>
This commit is contained in:
@@ -522,5 +522,6 @@ func init() {
|
||||
Register("DuckDuckGo", newDuckDuckGoComponent)
|
||||
Register("Google", newGoogleComponent)
|
||||
Register("GoogleScholar", newGoogleScholarComponent)
|
||||
Register("PubMed", newPubMedComponent)
|
||||
Register("YahooFinance", newYahooFinanceComponent)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// 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"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
einotool "github.com/cloudwego/eino/components/tool"
|
||||
)
|
||||
|
||||
type fakePubMedInvoker struct {
|
||||
args map[string]any
|
||||
err error
|
||||
out string
|
||||
}
|
||||
|
||||
func (f *fakePubMedInvoker) InvokableRun(_ context.Context, argsJSON string, _ ...einotool.Option) (string, error) {
|
||||
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":"Deep learning for retrieval augmented generation","url":"https://pubmed.ncbi.nlm.nih.gov/12345678","content":"Title: Deep learning for retrieval augmented generation\nAuthors: Furqan Khan, Jane Smith\nJournal: Nature Machine Intelligence\nVolume: 10\nIssue: 2\nPages: 101-110\nDOI: 10.1000/example.doi\nAbstract: A short abstract."}]}`, nil
|
||||
}
|
||||
|
||||
func TestPubMed_RegisteredFactory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := New("PubMed", map[string]any{
|
||||
"top_n": 8,
|
||||
"email": "node@example.com",
|
||||
"outputs": map[string]any{"formalized_content": map[string]any{}},
|
||||
"setups": map[string]any{"query": "configured query"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New(PubMed) errored: %v", err)
|
||||
}
|
||||
if got := c.Name(); got != "PubMed" {
|
||||
t.Fatalf("Name() = %q, want PubMed", got)
|
||||
}
|
||||
formGetter, ok := c.(interface{ GetInputForm() map[string]any })
|
||||
if !ok {
|
||||
t.Fatal("PubMed component does not expose GetInputForm")
|
||||
}
|
||||
form := formGetter.GetInputForm()
|
||||
if len(form) != 1 {
|
||||
t.Fatalf("GetInputForm size = %d, want 1", len(form))
|
||||
}
|
||||
query, ok := form["query"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("GetInputForm()[query] has type %T, want map", form["query"])
|
||||
}
|
||||
if query["type"] != "line" {
|
||||
t.Fatalf("GetInputForm()[query][type] = %v, want line", query["type"])
|
||||
}
|
||||
if _, ok := c.Outputs()["formalized_content"]; !ok {
|
||||
t.Fatal("Outputs() missing formalized_content")
|
||||
}
|
||||
if _, ok := c.Outputs()["json"]; !ok {
|
||||
t.Fatal("Outputs() missing json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPubMed_InvokeOnlyPassesQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := &fakePubMedInvoker{}
|
||||
c := newPubMedComponentWithInvoker(fake)
|
||||
out, err := c.Invoke(context.Background(), map[string]any{
|
||||
"query": " retrieval augmented generation ",
|
||||
"top_n": float64(8),
|
||||
"email": "ignored@example.com",
|
||||
"unused": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke errored: %v", err)
|
||||
}
|
||||
if got := fake.args["query"]; got != "retrieval augmented generation" {
|
||||
t.Fatalf("query arg = %v, want trimmed query", got)
|
||||
}
|
||||
if len(fake.args) != 1 {
|
||||
t.Fatalf("runtime args = %#v, want only query", fake.args)
|
||||
}
|
||||
formalized, _ := out["formalized_content"].(string)
|
||||
for _, want := range []string{"ID: 0", "Title: Deep learning for retrieval augmented generation", "URL: https://pubmed.ncbi.nlm.nih.gov/12345678", "Content:", "Abstract: A short abstract."} {
|
||||
if !strings.Contains(formalized, want) {
|
||||
t.Fatalf("formalized_content missing %q: %s", want, formalized)
|
||||
}
|
||||
}
|
||||
results, ok := out["json"].([]any)
|
||||
if !ok || len(results) != 1 {
|
||||
t.Fatalf("json output = %#v, want one result", out["json"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPubMed_InvokeEmptyQueryReturnsEmptyPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newPubMedComponentWithInvoker(&fakePubMedInvoker{})
|
||||
out, err := c.Invoke(context.Background(), map[string]any{"query": " "})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke errored: %v", err)
|
||||
}
|
||||
if got := out["formalized_content"]; got != "" {
|
||||
t.Fatalf("formalized_content = %v, want empty string", got)
|
||||
}
|
||||
results, ok := out["json"].([]any)
|
||||
if !ok || len(results) != 0 {
|
||||
t.Fatalf("json output = %#v, want empty []any", out["json"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPubMed_InvokeSurfacesToolErrorEnvelope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := &fakePubMedInvoker{
|
||||
out: `{"results":[],"_ERROR":"upstream down"}`,
|
||||
err: errors.New("boom"),
|
||||
}
|
||||
c := newPubMedComponentWithInvoker(fake)
|
||||
out, err := c.Invoke(context.Background(), map[string]any{"query": "pubmed"})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke errored: %v", err)
|
||||
}
|
||||
if got := out["_ERROR"]; got != "upstream down" {
|
||||
t.Fatalf("_ERROR = %v, want upstream down", got)
|
||||
}
|
||||
if got := out["formalized_content"]; got != "" {
|
||||
t.Fatalf("formalized_content = %v, want empty string", got)
|
||||
}
|
||||
}
|
||||
@@ -1511,6 +1511,10 @@ type googleScholarInvoker interface {
|
||||
InvokableRun(ctx context.Context, argsJSON string, opts ...einotool.Option) (string, error)
|
||||
}
|
||||
|
||||
type pubmedInvoker interface {
|
||||
InvokableRun(ctx context.Context, argsJSON string, opts ...einotool.Option) (string, error)
|
||||
}
|
||||
|
||||
func newGoogleScholarComponent(params map[string]any) (Component, error) {
|
||||
cloned := make(map[string]any, len(params))
|
||||
for k, v := range params {
|
||||
@@ -1650,6 +1654,124 @@ func renderGoogleScholarResults(results []any) string {
|
||||
return strings.Join(blocks, "\n\n")
|
||||
}
|
||||
|
||||
// pubMedComponent delegates to the PubMed tool. Its node parameters are
|
||||
// consumed at construction time, leaving query as the sole runtime input.
|
||||
type pubMedComponent struct {
|
||||
inner pubmedInvoker
|
||||
}
|
||||
|
||||
func newPubMedComponent(params map[string]any) (Component, error) {
|
||||
toolParams := make(map[string]any, 2)
|
||||
for _, key := range []string{"top_n", "email"} {
|
||||
if value, ok := params[key]; ok {
|
||||
toolParams[key] = value
|
||||
}
|
||||
}
|
||||
inner, err := agenttool.BuildByName("pubmed", toolParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
invoker, ok := inner.(pubmedInvoker)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("PubMed: tool does not implement InvokableRun")
|
||||
}
|
||||
return newPubMedComponentWithInvoker(invoker), nil
|
||||
}
|
||||
|
||||
func newPubMedComponentWithInvoker(inner pubmedInvoker) Component {
|
||||
return &pubMedComponent{inner: inner}
|
||||
}
|
||||
|
||||
func (c *pubMedComponent) Name() string { return "PubMed" }
|
||||
|
||||
func (c *pubMedComponent) Inputs() map[string]string {
|
||||
return map[string]string{
|
||||
"query": "PubMed search query.",
|
||||
}
|
||||
}
|
||||
|
||||
func (c *pubMedComponent) Outputs() map[string]string {
|
||||
return map[string]string{
|
||||
"formalized_content": "Rendered PubMed references for downstream LLM prompts.",
|
||||
"json": "Raw PubMed result list.",
|
||||
}
|
||||
}
|
||||
|
||||
func (c *pubMedComponent) GetInputForm() map[string]any {
|
||||
return map[string]any{
|
||||
"query": map[string]any{
|
||||
"name": "Query",
|
||||
"type": "line",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *pubMedComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
query := strings.TrimSpace(stringParam(inputs["query"]))
|
||||
if query == "" {
|
||||
return map[string]any{"formalized_content": "", "json": []any{}}, nil
|
||||
}
|
||||
argsJSON, err := json.Marshal(map[string]any{"query": query})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("canvas: PubMed: encode query: %w", err)
|
||||
}
|
||||
out, err := c.inner.InvokableRun(ctx, string(argsJSON))
|
||||
decoded := parseToolEnvelope(out)
|
||||
results := anySlice(decoded["results"])
|
||||
if existing, _ := decoded["_ERROR"].(string); strings.TrimSpace(existing) != "" {
|
||||
return map[string]any{
|
||||
"formalized_content": "",
|
||||
"json": results,
|
||||
"_ERROR": existing,
|
||||
}, nil
|
||||
}
|
||||
if err != nil {
|
||||
if len(decoded) > 0 {
|
||||
return map[string]any{
|
||||
"formalized_content": "",
|
||||
"json": results,
|
||||
"_ERROR": decoded["_ERROR"],
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("canvas: PubMed: %w", err)
|
||||
}
|
||||
return map[string]any{
|
||||
"formalized_content": renderPubMedResults(results),
|
||||
"json": results,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *pubMedComponent) Stream(_ context.Context, _ map[string]any) (<-chan map[string]any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func renderPubMedResults(results []any) string {
|
||||
if len(results) == 0 {
|
||||
return ""
|
||||
}
|
||||
blocks := make([]string, 0, len(results))
|
||||
for i, item := range results {
|
||||
result, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
content := strings.TrimSpace(stringParam(result["content"]))
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
lines := []string{fmt.Sprintf("ID: %d", i)}
|
||||
if title := strings.TrimSpace(stringParam(result["title"])); title != "" {
|
||||
lines = append(lines, "Title: "+title)
|
||||
}
|
||||
if link := strings.TrimSpace(stringParam(result["url"])); link != "" {
|
||||
lines = append(lines, "URL: "+link)
|
||||
}
|
||||
lines = append(lines, "Content:", content)
|
||||
blocks = append(blocks, strings.Join(lines, "\n"))
|
||||
}
|
||||
return strings.Join(blocks, "\n\n")
|
||||
}
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ Component = (*retrievalComponent)(nil)
|
||||
@@ -1661,6 +1783,7 @@ var (
|
||||
_ Component = (*codeExecComponent)(nil)
|
||||
_ Component = (*wikipediaComponent)(nil)
|
||||
_ Component = (*googleScholarComponent)(nil)
|
||||
_ Component = (*pubMedComponent)(nil)
|
||||
_ Component = (*yahooFinanceComponent)(nil)
|
||||
)
|
||||
|
||||
@@ -1673,3 +1796,4 @@ var _ einotool.InvokableTool = (*agenttool.TavilyExtractTool)(nil)
|
||||
var _ einotool.InvokableTool = (*agenttool.DuckDuckGoTool)(nil)
|
||||
var _ einotool.InvokableTool = (*agenttool.YahooFinanceTool)(nil)
|
||||
var _ einotool.InvokableTool = (*agenttool.GoogleScholarTool)(nil)
|
||||
var _ einotool.InvokableTool = (*agenttool.PubMedTool)(nil)
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
// read from plan §2.11.10 — P0 (8) + P1 (5) + P2 (4) + P3 (2) + P4 (3) = 22
|
||||
// at plan completion, plus v1 fixture wrappers/stubs (including Retrieval,
|
||||
// TavilySearch, TavilyExtract, ExeSQL, CodeExec, Google, BGPT, YahooFinance,
|
||||
// Wikipedia, GoogleScholar, DuckDuckGo, Generate, Answer, Iteration, and IterationItem)
|
||||
// registered by fixture_stubs.go to keep the dsl-examples and canvas tool
|
||||
// surface compiling. The test allows counts between 12 (P0+P1 minus the
|
||||
// removed ExitLoop) and 55 to roll forward as subsequent batches land.
|
||||
// Wikipedia, GoogleScholar, PubMed, DuckDuckGo, Generate, Answer, Iteration,
|
||||
// and IterationItem) registered by fixture_stubs.go to keep the dsl-examples
|
||||
// and canvas tool surface compiling. The test allows counts between 12 (P0+P1
|
||||
// minus the removed ExitLoop) and 55 to roll forward as subsequent batches land.
|
||||
//
|
||||
// Note: ExitLoop is intentionally NOT in the registry anymore. The
|
||||
// canvas engine (internal/agent/canvas/canvas.go's legacyNoOpNames)
|
||||
|
||||
Reference in New Issue
Block a user