From 9bf57600cfe9c979d0d394c9bde421a58a794bc6 Mon Sep 17 00:00:00 2001 From: connerlambden <9061871+connerlambden@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:52:24 -0600 Subject: [PATCH] feat(agent): add BGPT structured literature evidence search tool (#16050) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: Zhichang Yu --- agent/tools/bgpt.py | 188 ++++++++++++++++++ web/src/assets/svg/bgpt.svg | 8 + web/src/constants/agent.tsx | 1 + web/src/locales/en.ts | 7 + web/src/locales/zh.ts | 7 + .../node/dropdown/accordion-operators.tsx | 1 + web/src/pages/agent/constant/index.tsx | 19 ++ .../agent/form-sheet/form-config-map.tsx | 4 + .../agent-form/tool-popover/tool-command.tsx | 1 + web/src/pages/agent/form/bgpt-form/index.tsx | 99 +++++++++ .../agent/form/tool-form/bgpt-form/index.tsx | 35 ++++ .../pages/agent/form/tool-form/constant.tsx | 2 + web/src/pages/agent/hooks/use-add-node.ts | 2 + .../hooks/use-agent-tool-initial-values.ts | 2 + web/src/pages/agent/hooks/use-export-json.ts | 1 + .../agent/log-sheet/tool-timeline-item.tsx | 1 + web/src/pages/agent/operator-icon.tsx | 1 + 17 files changed, 379 insertions(+) create mode 100644 agent/tools/bgpt.py create mode 100644 web/src/assets/svg/bgpt.svg create mode 100644 web/src/pages/agent/form/bgpt-form/index.tsx create mode 100644 web/src/pages/agent/form/tool-form/bgpt-form/index.tsx diff --git a/agent/tools/bgpt.py b/agent/tools/bgpt.py new file mode 100644 index 000000000..8e6a1dbba --- /dev/null +++ b/agent/tools/bgpt.py @@ -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", "-_-!")) diff --git a/web/src/assets/svg/bgpt.svg b/web/src/assets/svg/bgpt.svg new file mode 100644 index 000000000..2a25835f4 --- /dev/null +++ b/web/src/assets/svg/bgpt.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/web/src/constants/agent.tsx b/web/src/constants/agent.tsx index d2095f5b4..baf98484d 100644 --- a/web/src/constants/agent.tsx +++ b/web/src/constants/agent.tsx @@ -109,6 +109,7 @@ export enum Operator { UserFillUp = 'UserFillUp', StringTransform = 'StringTransform', SearXNG = 'SearXNG', + BGPT = 'BGPT', KeenableSearch = 'KeenableSearch', DocGenerator = 'DocGenerator', Browser = 'Browser', diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 2a563a41b..1685ecbc9 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -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.', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 8e4683e52..28745279c 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -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 指定您需要调整的搜索结果数量。', diff --git a/web/src/pages/agent/canvas/node/dropdown/accordion-operators.tsx b/web/src/pages/agent/canvas/node/dropdown/accordion-operators.tsx index b0f93781d..29e3e29af 100644 --- a/web/src/pages/agent/canvas/node/dropdown/accordion-operators.tsx +++ b/web/src/pages/agent/canvas/node/dropdown/accordion-operators.tsx @@ -118,6 +118,7 @@ export function AccordionOperators({ Operator.GoogleScholar, Operator.ArXiv, Operator.PubMed, + Operator.BGPT, Operator.GitHub, Operator.Invoke, Operator.WenCai, diff --git a/web/src/pages/agent/constant/index.tsx b/web/src/pages/agent/constant/index.tsx index 06eba860c..fe459f454 100644 --- a/web/src/pages/agent/constant/index.tsx +++ b/web/src/pages/agent/constant/index.tsx @@ -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', + }, + }, +}; + 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', diff --git a/web/src/pages/agent/form-sheet/form-config-map.tsx b/web/src/pages/agent/form-sheet/form-config-map.tsx index ebc16dd93..cd5884330 100644 --- a/web/src/pages/agent/form-sheet/form-config-map.tsx +++ b/web/src/pages/agent/form-sheet/form-config-map.tsx @@ -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, }, diff --git a/web/src/pages/agent/form/agent-form/tool-popover/tool-command.tsx b/web/src/pages/agent/form/agent-form/tool-popover/tool-command.tsx index 0acc877e8..9541bb842 100644 --- a/web/src/pages/agent/form/agent-form/tool-popover/tool-command.tsx +++ b/web/src/pages/agent/form/agent-form/tool-popover/tool-command.tsx @@ -31,6 +31,7 @@ const Menus = [ Operator.KeenableSearch, Operator.YahooFinance, Operator.PubMed, + Operator.BGPT, Operator.GoogleScholar, Operator.ArXiv, Operator.WenCai, diff --git a/web/src/pages/agent/form/bgpt-form/index.tsx b/web/src/pages/agent/form/bgpt-form/index.tsx new file mode 100644 index 000000000..fa9f67236 --- /dev/null +++ b/web/src/pages/agent/form/bgpt-form/index.tsx @@ -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 ( + <> + + ( + + {t('bgptApiKey')} + + + + + + )} + /> + ( + + {t('bgptDaysBack')} + + + + + + )} + /> + + ); +} + +const outputList = buildOutputList(initialBGPTValues.outputs); + +function BGPTForm({ node }: INextOperatorForm) { + const defaultValues = useFormValues(initialBGPTValues, node); + + const form = useForm>({ + defaultValues, + resolver: zodResolver(FormSchema), + }); + + useWatchFormChange(form); + + return ( +
+ + + + + + + +
+ ); +} + +export default memo(BGPTForm); diff --git a/web/src/pages/agent/form/tool-form/bgpt-form/index.tsx b/web/src/pages/agent/form/tool-form/bgpt-form/index.tsx new file mode 100644 index 000000000..5723f08c2 --- /dev/null +++ b/web/src/pages/agent/form/tool-form/bgpt-form/index.tsx @@ -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>({ + defaultValues: values, + resolver: zodResolver(FormSchema), + }); + + useWatchFormChange(form); + + return ( +
+ + + + + +
+ ); +} + +export default memo(BGPTToolForm); diff --git a/web/src/pages/agent/form/tool-form/constant.tsx b/web/src/pages/agent/form/tool-form/constant.tsx index 6391a4550..34621adb2 100644 --- a/web/src/pages/agent/form/tool-form/constant.tsx +++ b/web/src/pages/agent/form/tool-form/constant.tsx @@ -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, diff --git a/web/src/pages/agent/hooks/use-add-node.ts b/web/src/pages/agent/hooks/use-add-node.ts index e89ea07e5..3a9b9cd32 100644 --- a/web/src/pages/agent/hooks/use-add-node.ts +++ b/web/src/pages/agent/hooks/use-add-node.ts @@ -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, diff --git a/web/src/pages/agent/hooks/use-agent-tool-initial-values.ts b/web/src/pages/agent/hooks/use-agent-tool-initial-values.ts index f0a7230ec..3d4b8112e 100644 --- a/web/src/pages/agent/hooks/use-agent-tool-initial-values.ts +++ b/web/src/pages/agent/hooks/use-agent-tool-initial-values.ts @@ -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: diff --git a/web/src/pages/agent/hooks/use-export-json.ts b/web/src/pages/agent/hooks/use-export-json.ts index 706cbddff..c7e8b1271 100644 --- a/web/src/pages/agent/hooks/use-export-json.ts +++ b/web/src/pages/agent/hooks/use-export-json.ts @@ -18,6 +18,7 @@ const clearSensitiveFields = (obj: T): T => Operator.TavilyExtract, Operator.Google, Operator.KeenableSearch, + Operator.BGPT, ].includes(value.component_name) && get(value, 'params.api_key') ) { diff --git a/web/src/pages/agent/log-sheet/tool-timeline-item.tsx b/web/src/pages/agent/log-sheet/tool-timeline-item.tsx index 509738ed8..c40741e14 100644 --- a/web/src/pages/agent/log-sheet/tool-timeline-item.tsx +++ b/web/src/pages/agent/log-sheet/tool-timeline-item.tsx @@ -25,6 +25,7 @@ type IToolIcon = | Operator.Google | Operator.GoogleScholar | Operator.PubMed + | Operator.BGPT | Operator.TavilyExtract | Operator.TavilySearch | Operator.KeenableSearch diff --git a/web/src/pages/agent/operator-icon.tsx b/web/src/pages/agent/operator-icon.tsx index 57936c8f3..fba627e22 100644 --- a/web/src/pages/agent/operator-icon.tsx +++ b/web/src/pages/agent/operator-icon.tsx @@ -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',