feat(agent): add BGPT structured literature evidence search tool (#16050)

## Summary

Adds a first-class **BGPT** Agent tool (backend + UI) in response to
[#15997](https://github.com/infiniflow/ragflow/issues/15997#issuecomment-4703864227).

BGPT calls `POST https://bgpt.pro/api/mcp-search` and returns structured
study evidence from full-text papers — not just titles/abstracts. Each
result is formatted for RAGFlow citations with:

- methods
- sample size / population
- results
- limitations
- conflicts of interest
- data availability
- study blind spots
- `how_to_falsify`

## Why this shape

- Mirrors existing literature tools (`PubMed`, `ArXiv`) and HTTP tools
(`SearXNG`).
- Works on the free tier (no API key required for first 50 results).
- Optional `api_key` and `days_back` in the node/tool config.
- Surfaces both `formalized_content` and raw `json` outputs (like
SearXNG).

## Files

- `agent/tools/bgpt.py` — REST client + evidence formatter
- Frontend: Operator enum, forms, tool picker, canvas accordion, en/zh
locales, icon

## Demo / docs

Runnable claim-interrogation demo:
https://github.com/connerlambden/bgpt-mcp/blob/main/EVIDENCE_DEMO.md

## Test plan

- [ ] Add BGPT node on Agent canvas, run query `GLP-1 alcohol craving`,
verify `formalized_content` includes limitations/COI fields
- [ ] Add BGPT as Agent sub-tool under Search, verify tool-calling works
- [ ] Confirm empty query / try-run returns gracefully
- [ ] Optional: paid-tier `api_key` path

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Zhichang Yu <yuzhichang@gmail.com>
This commit is contained in:
connerlambden
2026-06-30 23:52:24 -06:00
committed by GitHub
parent 508f6226f8
commit 9bf57600cf
17 changed files with 379 additions and 0 deletions

188
agent/tools/bgpt.py Normal file
View File

@@ -0,0 +1,188 @@
#
# Copyright 2024 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.
#
import logging
import os
import time
from abc import ABC
import requests
from agent.tools.base import ToolBase, ToolMeta, ToolParamBase
from common.connection_utils import timeout
BGPT_SEARCH_URL = "https://bgpt.pro/api/mcp-search"
class BGPTParam(ToolParamBase):
"""Define the BGPT component parameters."""
def __init__(self):
self.meta: ToolMeta = {
"name": "bgpt_search",
"description": (
"BGPT searches scientific papers and returns structured evidence extracted from full-text studies: "
"methods, sample sizes, results, limitations, conflicts of interest, data/code availability, "
"study blind spots, quality scores, and falsification prompts. "
"Useful when the agent must judge a scientific claim, not just find abstracts."
),
"parameters": {
"query": {
"type": "string",
"description": ("Natural-language scientific search query. Use the most important terms from the user's request."),
"default": "{sys.query}",
"required": True,
}
},
}
super().__init__()
self.top_n = 10
self.api_key = ""
self.days_back = None
def check(self):
try:
if isinstance(self.top_n, str):
self.top_n = int(self.top_n.strip())
except Exception:
pass
self.check_positive_integer(self.top_n, "Top N")
if self.days_back not in (None, ""):
try:
self.days_back = int(self.days_back)
except (TypeError, ValueError) as exc:
raise ValueError("days_back must be an integer") from exc
self.check_positive_integer(self.days_back, "Days back")
def get_input_form(self) -> dict[str, dict]:
return {"query": {"name": "Query", "type": "line"}}
class BGPT(ToolBase, ABC):
component_name = "BGPT"
@timeout(int(os.environ.get("COMPONENT_EXEC_TIMEOUT", 30)))
def _invoke(self, **kwargs):
if self.check_if_canceled("BGPT processing"):
return
query = kwargs.get("query")
if not query or not isinstance(query, str) or not query.strip():
self.set_output("formalized_content", "")
return ""
payload = {"query": query.strip(), "num_results": self._param.top_n}
if self._param.api_key:
payload["api_key"] = self._param.api_key
if self._param.days_back:
payload["days_back"] = self._param.days_back
last_e = ""
for _ in range(self._param.max_retries + 1):
if self.check_if_canceled("BGPT processing"):
return
try:
response = requests.post(BGPT_SEARCH_URL, json=payload, timeout=25)
response.raise_for_status()
data = response.json()
if not data or not isinstance(data, dict):
raise ValueError("Invalid response from BGPT")
results = data.get("results", [])
if not isinstance(results, list):
raise ValueError("Invalid results format from BGPT")
if self.check_if_canceled("BGPT processing"):
return
self._retrieve_chunks(
results,
get_title=lambda paper: paper.get("title") or "Untitled",
get_url=lambda paper: paper.get("url") or paper.get("doi") or "",
get_content=lambda paper: self._format_bgpt_paper(paper),
)
self.set_output("json", results)
return self.output("formalized_content")
except requests.HTTPError as e:
# Non-retryable 4xx (e.g. 400/401/403/404) should fail fast
# rather than wasting retries on bad requests or auth failures.
status = e.response.status_code if e.response is not None else None
if status is not None and 400 <= status < 500 and status != 429:
last_e = f"HTTP error: {e}"
logging.exception("BGPT non-retryable HTTP error: %s", e)
break
if self.check_if_canceled("BGPT processing"):
return
last_e = f"Network error: {e}"
logging.exception("BGPT network error: %s", e)
time.sleep(self._param.delay_after_error)
except requests.RequestException as e:
if self.check_if_canceled("BGPT processing"):
return
last_e = f"Network error: {e}"
logging.exception("BGPT network error: %s", e)
time.sleep(self._param.delay_after_error)
except Exception as e:
if self.check_if_canceled("BGPT processing"):
return
last_e = str(e)
logging.exception("BGPT error: %s", e)
time.sleep(self._param.delay_after_error)
if last_e:
self.set_output("_ERROR", last_e)
return f"BGPT error: {last_e}"
assert False, self.output()
def _format_bgpt_paper(self, paper: dict) -> str:
def field(*names: str) -> str:
for name in names:
value = paper.get(name)
if value is None:
continue
if isinstance(value, (dict, list)):
value = str(value)
text = str(value).strip()
if text:
return text
return "-"
lines = [
f"Title: {field('title')}",
f"Authors: {field('authors')}",
f"Journal: {field('journal')}",
f"Year: {field('year')}",
f"DOI: {field('doi')}",
f"Abstract: {field('abstract')}",
f"Methods: {field('methods_and_experimental_techniques', 'methods')}",
f"Sample size / population: {field('sample_size_and_population_characteristics', 'sample_size_and_population')}",
f"Results: {field('results_and_conclusions', 'results')}",
f"Limitations: {field('paper_limitations_and_biases', 'limitations')}",
f"Conflicts of interest: {field('conflict_of_interest_statements', 'conflict_of_interest')}",
f"Data availability: {field('data_availability_statements', 'data_availability')}",
f"Blind spots: {field('study_blindspots')}",
f"How to falsify: {field('how_to_falsify')}",
]
return "\n".join(lines)
def thoughts(self) -> str:
return "Searching BGPT for structured scientific evidence on `{}`.".format(self.get_input().get("query", "-_-!"))

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
<path d="M8 7h8"/>
<path d="M8 11h8"/>
<path d="M8 15h5"/>
<circle cx="17" cy="7" r="2.5" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 425 B

View File

@@ -109,6 +109,7 @@ export enum Operator {
UserFillUp = 'UserFillUp',
StringTransform = 'StringTransform',
SearXNG = 'SearXNG',
BGPT = 'BGPT',
KeenableSearch = 'KeenableSearch',
DocGenerator = 'DocGenerator',
Browser = 'Browser',

View File

@@ -2234,6 +2234,13 @@ Best for: Documents with flowing, contextually connected content — such as boo
pubMed: 'PubMed',
pubMedDescription:
'A component that searches from https://pubmed.ncbi.nlm.nih.gov/, allowing you to specify the number of search results using TopN. It supplements the existing datasets.',
bGPT: 'BGPT',
bGPTDescription:
'Search scientific papers via BGPT and return structured evidence from full-text studies: methods, sample sizes, limitations, conflicts of interest, data availability, blind spots, and falsification prompts. Optional API key after the free tier.',
bgptApiKey: 'API key',
bgptApiKeyTip: 'Optional. Leave blank for the free tier (first 50 results).',
bgptDaysBack: 'Days back',
bgptDaysBackTip: 'Optional recency filter (e.g. 365 for the last year).',
email: 'Email',
emailTip:
'E-mail is a required field. You must input an E-mail address here.',

View File

@@ -1908,6 +1908,13 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
pubMed: 'PubMed',
pubMedDescription:
'此组件用于从 https://pubmed.ncbi.nlm.nih.gov/ 获取搜索结果。通常它作为知识库的补充。Top N 指定您需要调整的搜索结果数。电子邮件是必填字段。',
bGPT: 'BGPT',
bGPTDescription:
'通过 BGPT 搜索科学论文,并返回全文提取的结构化证据:方法、样本量、局限性、利益冲突、数据可用性、研究盲点和可证伪性提示。免费额度后可选 API key。',
bgptApiKey: 'API key',
bgptApiKeyTip: '可选。留空使用免费额度(前 50 条结果)。',
bgptDaysBack: '天数限制',
bgptDaysBackTip: '可选的时效过滤(例如 365 表示最近一年)。',
arXiv: 'ArXiv',
arXivDescription:
'此组件用于从 https://arxiv.org/ 获取搜索结果。通常它作为知识库的补充。Top N 指定您需要调整的搜索结果数量。',

View File

@@ -118,6 +118,7 @@ export function AccordionOperators({
Operator.GoogleScholar,
Operator.ArXiv,
Operator.PubMed,
Operator.BGPT,
Operator.GitHub,
Operator.Invoke,
Operator.WenCai,

View File

@@ -239,6 +239,23 @@ export const initialPubMedValues = {
},
};
export const initialBGPTValues = {
top_n: 10,
api_key: '',
days_back: '',
query: AgentGlobals.SysQuery,
outputs: {
formalized_content: {
value: '',
type: 'string',
},
json: {
value: [],
type: 'Array<Object>',
},
},
};
export const initialArXivValues = {
top_n: 12,
sort_by: 'relevance',
@@ -682,6 +699,7 @@ export const RestrictedUpstreamMap = {
[Operator.DuckDuckGo]: [Operator.Begin, Operator.Retrieval],
[Operator.Wikipedia]: [Operator.Begin, Operator.Retrieval],
[Operator.PubMed]: [Operator.Begin, Operator.Retrieval],
[Operator.BGPT]: [Operator.Begin, Operator.Retrieval],
[Operator.ArXiv]: [Operator.Begin, Operator.Retrieval],
[Operator.Google]: [Operator.Begin, Operator.Retrieval],
[Operator.Bing]: [Operator.Begin, Operator.Retrieval],
@@ -734,6 +752,7 @@ export const NodeMap = {
[Operator.DuckDuckGo]: 'ragNode',
[Operator.Wikipedia]: 'ragNode',
[Operator.PubMed]: 'ragNode',
[Operator.BGPT]: 'ragNode',
[Operator.ArXiv]: 'ragNode',
[Operator.Google]: 'ragNode',
[Operator.Bing]: 'ragNode',

View File

@@ -25,6 +25,7 @@ import LoopForm from '../form/loop-form';
import MessageForm from '../form/message-form';
import ParserForm from '../form/parser-form';
import PubMedForm from '../form/pubmed-form';
import BGPTForm from '../form/bgpt-form';
import RetrievalForm from '../form/retrieval-form/next';
import RewriteQuestionForm from '../form/rewrite-question-form';
import SearXNGForm from '../form/searxng-form';
@@ -80,6 +81,9 @@ export const FormConfigMap = {
[Operator.PubMed]: {
component: PubMedForm,
},
[Operator.BGPT]: {
component: BGPTForm,
},
[Operator.ArXiv]: {
component: ArXivForm,
},

View File

@@ -31,6 +31,7 @@ const Menus = [
Operator.KeenableSearch,
Operator.YahooFinance,
Operator.PubMed,
Operator.BGPT,
Operator.GoogleScholar,
Operator.ArXiv,
Operator.WenCai,

View File

@@ -0,0 +1,99 @@
import { FormContainer } from '@/components/form-container';
import { TopNFormField } from '@/components/top-n-item';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { useTranslate } from '@/hooks/common-hooks';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo } from 'react';
import { useForm, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { initialBGPTValues } from '../../constant';
import { useFormValues } from '../../hooks/use-form-values';
import { useWatchFormChange } from '../../hooks/use-watch-form-change';
import { INextOperatorForm } from '../../interface';
import { buildOutputList } from '../../utils/build-output-list';
import { FormWrapper } from '../components/form-wrapper';
import { Output } from '../components/output';
import { QueryVariable } from '../components/query-variable';
export const BGPTFormPartialSchema = {
top_n: z.number(),
api_key: z.string().optional(),
days_back: z.union([z.number(), z.string()]).optional(),
};
export const FormSchema = z.object({
...BGPTFormPartialSchema,
query: z.string(),
});
export function BGPTFormWidgets() {
const form = useFormContext();
const { t } = useTranslate('flow');
return (
<>
<TopNFormField></TopNFormField>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{t('bgptApiKey')}</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder={t('bgptApiKeyTip')} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="days_back"
render={({ field }) => (
<FormItem>
<FormLabel>{t('bgptDaysBack')}</FormLabel>
<FormControl>
<Input {...field} placeholder={t('bgptDaysBackTip')} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
const outputList = buildOutputList(initialBGPTValues.outputs);
function BGPTForm({ node }: INextOperatorForm) {
const defaultValues = useFormValues(initialBGPTValues, node);
const form = useForm<z.infer<typeof FormSchema>>({
defaultValues,
resolver: zodResolver(FormSchema),
});
useWatchFormChange(form);
return (
<Form {...form}>
<FormWrapper>
<FormContainer>
<QueryVariable></QueryVariable>
<BGPTFormWidgets></BGPTFormWidgets>
<Output list={outputList}></Output>
</FormContainer>
</FormWrapper>
</Form>
);
}
export default memo(BGPTForm);

View File

@@ -0,0 +1,35 @@
import { FormContainer } from '@/components/form-container';
import { Form } from '@/components/ui/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { BGPTFormPartialSchema, BGPTFormWidgets } from '../../bgpt-form';
import { FormWrapper } from '../../components/form-wrapper';
import { useValues } from '../use-values';
import { useWatchFormChange } from '../use-watch-change';
function BGPTToolForm() {
const values = useValues();
const FormSchema = z.object(BGPTFormPartialSchema);
const form = useForm<z.infer<typeof FormSchema>>({
defaultValues: values,
resolver: zodResolver(FormSchema),
});
useWatchFormChange(form);
return (
<Form {...form}>
<FormWrapper>
<FormContainer>
<BGPTFormWidgets></BGPTFormWidgets>
</FormContainer>
</FormWrapper>
</Form>
);
}
export default memo(BGPTToolForm);

View File

@@ -10,6 +10,7 @@ import GoogleForm from './google-form';
import GoogleScholarForm from './google-scholar-form';
import KeenableForm from './keenable-form';
import PubMedForm from './pubmed-form';
import BGPTForm from './bgpt-form';
import RetrievalForm from './retrieval-form';
import SearXNGForm from './searxng-form';
import TavilyForm from './tavily-form';
@@ -23,6 +24,7 @@ export const ToolFormConfigMap = {
[Operator.DuckDuckGo]: DuckDuckGoForm,
[Operator.Wikipedia]: WikipediaForm,
[Operator.PubMed]: PubMedForm,
[Operator.BGPT]: BGPTForm,
[Operator.ArXiv]: ArXivForm,
[Operator.Google]: GoogleForm,
[Operator.Bing]: BingForm,

View File

@@ -36,6 +36,7 @@ import {
initialNoteValues,
initialParserValues,
initialPubMedValues,
initialBGPTValues,
initialRetrievalValues,
initialRewriteQuestionValues,
initialSearXNGValues,
@@ -143,6 +144,7 @@ export const useInitializeOperatorParams = () => {
[Operator.DuckDuckGo]: initialDuckValues,
[Operator.Wikipedia]: initialWikipediaValues,
[Operator.PubMed]: initialPubMedValues,
[Operator.BGPT]: initialBGPTValues,
[Operator.ArXiv]: initialArXivValues,
[Operator.Google]: initialGoogleValues,
[Operator.Bing]: initialBingValues,

View File

@@ -51,6 +51,8 @@ export function useAgentToolInitialValues() {
return pick(initialValues, 'top_n', 'sort_by');
case Operator.PubMed:
return pick(initialValues, 'top_n', 'email');
case Operator.BGPT:
return pick(initialValues, 'top_n', 'api_key', 'days_back');
case Operator.GitHub:
return pick(initialValues, 'top_n');
case Operator.WenCai:

View File

@@ -18,6 +18,7 @@ const clearSensitiveFields = <T,>(obj: T): T =>
Operator.TavilyExtract,
Operator.Google,
Operator.KeenableSearch,
Operator.BGPT,
].includes(value.component_name) &&
get(value, 'params.api_key')
) {

View File

@@ -25,6 +25,7 @@ type IToolIcon =
| Operator.Google
| Operator.GoogleScholar
| Operator.PubMed
| Operator.BGPT
| Operator.TavilyExtract
| Operator.TavilySearch
| Operator.KeenableSearch

View File

@@ -45,6 +45,7 @@ export const SVGIconMap = {
[Operator.Google]: 'google',
[Operator.GoogleScholar]: 'google-scholar',
[Operator.PubMed]: 'pubmed',
[Operator.BGPT]: 'bgpt',
[Operator.SearXNG]: 'searxng',
[Operator.KeenableSearch]: 'keenable',
[Operator.TavilyExtract]: 'tavily',