mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-17 05:07:23 +08:00
## 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>
309 lines
9.1 KiB
Go
309 lines
9.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 tool
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/cloudwego/eino/components/tool"
|
|
"github.com/cloudwego/eino/schema"
|
|
)
|
|
|
|
const (
|
|
pubmedToolName = "pubmed"
|
|
pubmedToolDescription = "Search PubMed for life sciences and biomedical references."
|
|
defaultPubMedTopN = 12
|
|
defaultPubMedEmail = "A.N.Other@example.com"
|
|
)
|
|
|
|
// pubmedParams mirrors Python PubMedParam. Info() still only exposes query to
|
|
// the LLM; top_n and email are canvas-side params merged with constructor
|
|
// defaults at runtime.
|
|
type pubmedParams struct {
|
|
Query string `json:"query"`
|
|
TopN int `json:"top_n"`
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
// pubmedResult is one PubMed reference rendered with the same fields and
|
|
// fallbacks as Python PubMed._format_pubmed_content.
|
|
type pubmedResult struct {
|
|
Title string `json:"title"`
|
|
URL string `json:"url"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type pubmedEnvelope struct {
|
|
Results []pubmedResult `json:"results"`
|
|
Error string `json:"_ERROR,omitempty"`
|
|
}
|
|
|
|
const pubmedUserAgent = "ragflow/1.0"
|
|
|
|
var pubmedESearchEndpoint = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
|
|
|
|
var pubmedEFetchEndpoint = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi"
|
|
|
|
// PubMedTool queries NCBI E-utilities with esearch followed by efetch XML,
|
|
// which is the same retrieval path as the Python PubMed component.
|
|
type PubMedTool struct {
|
|
helper *HTTPHelper
|
|
defaults pubmedParams
|
|
}
|
|
|
|
func NewPubMedTool() *PubMedTool {
|
|
return NewPubMedToolWith(NewHTTPHelper())
|
|
}
|
|
|
|
func NewPubMedToolWith(h *HTTPHelper) *PubMedTool {
|
|
return NewPubMedToolWithDefaults(h, pubmedParams{})
|
|
}
|
|
|
|
func NewPubMedToolWithDefaults(h *HTTPHelper, defaults pubmedParams) *PubMedTool {
|
|
if h == nil {
|
|
h = NewHTTPHelper()
|
|
}
|
|
if defaults.TopN == 0 {
|
|
defaults.TopN = defaultPubMedTopN
|
|
}
|
|
if strings.TrimSpace(defaults.Email) == "" {
|
|
defaults.Email = defaultPubMedEmail
|
|
}
|
|
return &PubMedTool{helper: h, defaults: defaults}
|
|
}
|
|
|
|
// Info exposes only query to the LLM. Node parameters belong to canvas setup,
|
|
// not model-emitted runtime arguments.
|
|
func (p *PubMedTool) Info(_ context.Context) (*schema.ToolInfo, error) {
|
|
return &schema.ToolInfo{
|
|
Name: pubmedToolName,
|
|
Desc: pubmedToolDescription,
|
|
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
|
|
"query": {
|
|
Type: schema.String,
|
|
Desc: "The search keywords to execute with PubMed. The keywords should be the most important words or terms, including synonyms, from the original request.",
|
|
Required: true,
|
|
},
|
|
}),
|
|
}, nil
|
|
}
|
|
|
|
func buildPubMedESearchURL(query string, topN int, email string) string {
|
|
q := url.Values{}
|
|
q.Set("db", "pubmed")
|
|
q.Set("term", query)
|
|
q.Set("retmax", strconv.Itoa(topN))
|
|
q.Set("retmode", "json")
|
|
q.Set("email", email)
|
|
return pubmedESearchEndpoint + "?" + q.Encode()
|
|
}
|
|
|
|
func buildPubMedEFetchURL(pmids []string, email string) string {
|
|
q := url.Values{}
|
|
q.Set("db", "pubmed")
|
|
q.Set("id", strings.Join(pmids, ","))
|
|
q.Set("retmode", "xml")
|
|
q.Set("email", email)
|
|
return pubmedEFetchEndpoint + "?" + q.Encode()
|
|
}
|
|
|
|
type pubmedESearchResponse struct {
|
|
ESearchResult struct {
|
|
IDList []string `json:"idlist"`
|
|
} `json:"esearchresult"`
|
|
}
|
|
|
|
type pubmedXMLAuthor struct {
|
|
LastName string `xml:"LastName"`
|
|
ForeName string `xml:"ForeName"`
|
|
}
|
|
|
|
type pubmedXMLArticleID struct {
|
|
Type string `xml:"IdType,attr"`
|
|
Value string `xml:",chardata"`
|
|
}
|
|
|
|
type pubmedXMLArticle struct {
|
|
PMID string `xml:"MedlineCitation>PMID"`
|
|
Article struct {
|
|
Title string `xml:"ArticleTitle"`
|
|
Abstract struct {
|
|
Text []string `xml:"AbstractText"`
|
|
} `xml:"Abstract"`
|
|
Journal struct {
|
|
Title string `xml:"Title"`
|
|
Issue struct {
|
|
Volume string `xml:"Volume"`
|
|
Number string `xml:"Issue"`
|
|
} `xml:"JournalIssue"`
|
|
} `xml:"Journal"`
|
|
Pages struct {
|
|
MedlinePgn string `xml:"MedlinePgn"`
|
|
} `xml:"Pagination"`
|
|
Authors []pubmedXMLAuthor `xml:"AuthorList>Author"`
|
|
} `xml:"MedlineCitation>Article"`
|
|
ArticleIDs []pubmedXMLArticleID `xml:"PubmedData>ArticleIdList>ArticleId"`
|
|
}
|
|
|
|
type pubmedXMLResponse struct {
|
|
Articles []pubmedXMLArticle `xml:"PubmedArticle"`
|
|
}
|
|
|
|
func (p *PubMedTool) InvokableRun(ctx context.Context, argsJSON string, _ ...tool.Option) (string, error) {
|
|
var params pubmedParams
|
|
if err := json.Unmarshal([]byte(argsJSON), ¶ms); err != nil {
|
|
err = fmt.Errorf("pubmed: parse arguments: %w", err)
|
|
return pubmedErrJSON(err), err
|
|
}
|
|
params = mergePubMedDefaults(p.defaults, params)
|
|
params.Query = strings.TrimSpace(params.Query)
|
|
if params.Query == "" {
|
|
err := fmt.Errorf("pubmed: query is required")
|
|
return pubmedErrJSON(err), err
|
|
}
|
|
|
|
headers := map[string]string{
|
|
"User-Agent": pubmedUserAgent,
|
|
"Accept": "application/json",
|
|
}
|
|
searchResp, err := p.helper.Do(ctx, http.MethodGet, buildPubMedESearchURL(params.Query, params.TopN, params.Email), "", "", headers)
|
|
if err != nil {
|
|
return pubmedErrJSON(err), err
|
|
}
|
|
defer searchResp.Body.Close()
|
|
if searchResp.StatusCode < http.StatusOK || searchResp.StatusCode >= http.StatusMultipleChoices {
|
|
err := fmt.Errorf("pubmed: esearch returned %d", searchResp.StatusCode)
|
|
return pubmedErrJSON(err), err
|
|
}
|
|
|
|
var searchBody pubmedESearchResponse
|
|
if err := json.NewDecoder(searchResp.Body).Decode(&searchBody); err != nil {
|
|
err = fmt.Errorf("pubmed: decode esearch: %w", err)
|
|
return pubmedErrJSON(err), err
|
|
}
|
|
if len(searchBody.ESearchResult.IDList) == 0 {
|
|
return pubmedJSON(pubmedEnvelope{Results: []pubmedResult{}}), nil
|
|
}
|
|
|
|
headers["Accept"] = "application/xml"
|
|
fetchResp, err := p.helper.Do(ctx, http.MethodGet, buildPubMedEFetchURL(searchBody.ESearchResult.IDList, params.Email), "", "", headers)
|
|
if err != nil {
|
|
return pubmedErrJSON(err), err
|
|
}
|
|
defer fetchResp.Body.Close()
|
|
if fetchResp.StatusCode < http.StatusOK || fetchResp.StatusCode >= http.StatusMultipleChoices {
|
|
err := fmt.Errorf("pubmed: efetch returned %d", fetchResp.StatusCode)
|
|
return pubmedErrJSON(err), err
|
|
}
|
|
|
|
var fetched pubmedXMLResponse
|
|
if err := xml.NewDecoder(fetchResp.Body).Decode(&fetched); err != nil {
|
|
err = fmt.Errorf("pubmed: decode efetch: %w", err)
|
|
return pubmedErrJSON(err), err
|
|
}
|
|
results := make([]pubmedResult, 0, len(fetched.Articles))
|
|
for _, article := range fetched.Articles {
|
|
results = append(results, formatPubMedResult(article))
|
|
}
|
|
return pubmedJSON(pubmedEnvelope{Results: results}), nil
|
|
}
|
|
|
|
func formatPubMedResult(article pubmedXMLArticle) pubmedResult {
|
|
title := fallbackPubMedField(article.Article.Title, "No title")
|
|
abstract := fallbackPubMedField(strings.Join(article.Article.Abstract.Text, " "), "No abstract available")
|
|
journal := fallbackPubMedField(article.Article.Journal.Title, "Unknown Journal")
|
|
volume := fallbackPubMedField(article.Article.Journal.Issue.Volume, "-")
|
|
issue := fallbackPubMedField(article.Article.Journal.Issue.Number, "-")
|
|
pages := fallbackPubMedField(article.Article.Pages.MedlinePgn, "-")
|
|
authors := formatPubMedAuthors(article.Article.Authors)
|
|
doi := "-"
|
|
for _, id := range article.ArticleIDs {
|
|
if id.Type == "doi" && strings.TrimSpace(id.Value) != "" {
|
|
doi = strings.TrimSpace(id.Value)
|
|
break
|
|
}
|
|
}
|
|
content := strings.Join([]string{
|
|
"Title: " + title,
|
|
"Authors: " + authors,
|
|
"Journal: " + journal,
|
|
"Volume: " + volume,
|
|
"Issue: " + issue,
|
|
"Pages: " + pages,
|
|
"DOI: " + doi,
|
|
"Abstract: " + abstract,
|
|
}, "\n")
|
|
return pubmedResult{
|
|
Title: title,
|
|
URL: "https://pubmed.ncbi.nlm.nih.gov/" + strings.TrimSpace(article.PMID),
|
|
Content: content,
|
|
}
|
|
}
|
|
|
|
func mergePubMedDefaults(defaults, params pubmedParams) pubmedParams {
|
|
if params.Query == "" {
|
|
params.Query = defaults.Query
|
|
}
|
|
if params.TopN == 0 {
|
|
params.TopN = defaults.TopN
|
|
}
|
|
if strings.TrimSpace(params.Email) == "" {
|
|
params.Email = defaults.Email
|
|
}
|
|
return params
|
|
}
|
|
|
|
func fallbackPubMedField(value, fallback string) string {
|
|
if value = strings.TrimSpace(value); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func formatPubMedAuthors(authors []pubmedXMLAuthor) string {
|
|
names := make([]string, 0, len(authors))
|
|
for _, author := range authors {
|
|
name := strings.TrimSpace(strings.TrimSpace(author.ForeName) + " " + strings.TrimSpace(author.LastName))
|
|
if name != "" {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
if len(names) == 0 {
|
|
return "Unknown Authors"
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
func pubmedJSON(env pubmedEnvelope) string {
|
|
b, err := json.Marshal(env)
|
|
if err != nil {
|
|
return fmt.Sprintf(`{"_ERROR":"pubmed: marshal result: %s"}`, err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func pubmedErrJSON(err error) string {
|
|
return pubmedJSON(pubmedEnvelope{Error: err.Error()})
|
|
}
|