feat(plugins): consolidate official plugins with independent releases (LANG-180) (#2797)

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
RockChinQ
2026-10-28 17:32:24 +08:00
committed by GitHub
co-authored by multica-agent
parent d099033d79
commit 3a77306c3c
1144 changed files with 142049 additions and 130 deletions
@@ -0,0 +1,545 @@
from __future__ import annotations
import logging
from components.shared_state import (
AmbiguousMutationError,
ConfigStore,
FenceLifecycleError,
serialized,
)
import httpx
from langbot_plugin.api.definition.components.knowledge_engine import KnowledgeEngine, KnowledgeEngineCapability
from langbot_plugin.api.entities.builtin.rag import (
IngestionContext,
IngestionResult,
DocumentStatus,
RetrievalContext,
RetrievalResultEntry,
RetrievalResponse,
)
from langbot_plugin.api.entities.builtin.provider.message import ContentElement
logger = logging.getLogger(__name__)
def _normalize_base_url(api_base_url: str) -> str:
"""Reduce a configured base URL to ``scheme://host[:port]/path``.
A document mapping must name the upstream it was created on, so the stored
form is canonical: scheme and host casing, a default port and a trailing
slash never distinguish one deployment from another. An address without a
host is stored stripped, so a later comparison still detects a change.
"""
url = httpx.URL(api_base_url)
if not url.host:
return api_base_url.rstrip("/")
default_port = {'http': 80, 'https': 443}.get(url.scheme)
port = f":{url.port}" if url.port is not None and url.port != default_port else ""
return f"{url.scheme}://{url.host}{port}{url.path.rstrip('/')}"
class RAGFlowConnector(ConfigStore, KnowledgeEngine):
"""Knowledge Engine powered by RAGFlow.
Supports retrieval, document ingestion (upload + parse), and deletion
via the RAGFlow HTTP API.
"""
@classmethod
def get_capabilities(cls) -> list[str]:
return [KnowledgeEngineCapability.DOC_INGESTION, KnowledgeEngineCapability.DOC_PARSING]
# ========== Lifecycle Hooks ==========
@serialized
async def on_knowledge_base_create(self, kb_id: str, config: dict) -> None:
"""Persist installation-bound configuration and validate dataset IDs."""
logger.info(f"[RAGFlowKnowledgeEngine] Knowledge base created: {kb_id}")
await self._save_config(kb_id, config)
# Validate dataset IDs
api_base_url = config.get("api_base_url", "http://localhost:9380").rstrip("/")
api_key = config.get("api_key")
dataset_ids_str = config.get("dataset_ids", "")
if not api_key or not dataset_ids_str:
return
dataset_ids = [did.strip() for did in dataset_ids_str.split(",") if did.strip()]
if not dataset_ids:
return
try:
async with self.http_client() as client:
resp = await client.get(
f"{api_base_url}/api/v1/datasets",
headers={"Authorization": f"Bearer {api_key}"},
params={"page": 1, "page_size": 100},
timeout=15.0,
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
logger.warning(
f"[RAGFlowKnowledgeEngine] Failed to validate datasets: {data.get('message')}"
)
return
existing_ids = {ds["id"] for ds in data.get("data", [])}
for did in dataset_ids:
if did in existing_ids:
logger.info(f"[RAGFlowKnowledgeEngine] Dataset {did} validated OK")
else:
logger.warning(
f"[RAGFlowKnowledgeEngine] Dataset {did} NOT found in RAGFlow! "
"Please check the dataset ID."
)
except Exception as e:
logger.warning(f"[RAGFlowKnowledgeEngine] Dataset validation failed: {e}")
@serialized(allow_fenced=True)
async def on_knowledge_base_delete(self, kb_id: str) -> None:
"""Tombstone the stored configuration and clear any fence for this KB."""
logger.info(f"[RAGFlowKnowledgeEngine] Knowledge base deleted: {kb_id}")
await self._save_config(kb_id, None)
# Deleting the knowledge base is the operator path out of a fence: the
# pending ambiguity is moot once it no longer exists.
await self._state.clear_fence(self.plugin, kb_id)
async def retrieve(self, context: RetrievalContext) -> RetrievalResponse:
"""Execute retrieval against RAGFlow API."""
config = context.creation_settings
retrieval = context.retrieval_settings
api_base_url = config.get("api_base_url", "http://localhost:9380").rstrip("/")
api_key = config.get("api_key")
dataset_ids_str = config.get("dataset_ids", "")
top_k = retrieval.get("top_k", 1024)
similarity_threshold = retrieval.get("similarity_threshold", 0.2)
vector_similarity_weight = retrieval.get("vector_similarity_weight", 0.3)
page_size = retrieval.get("page_size", 30)
keyword = retrieval.get("keyword", False)
rerank_id = retrieval.get("rerank_id", "")
use_kg = retrieval.get("use_kg", False)
if not api_key or not dataset_ids_str:
logger.error(
f"[RAGFlowKnowledgeEngine] Missing required configuration. "
f"Config keys: {list(config.keys())}"
)
return RetrievalResponse(results=[], total_found=0)
dataset_ids = [did.strip() for did in dataset_ids_str.split(",") if did.strip()]
if not dataset_ids:
logger.error("[RAGFlowKnowledgeEngine] No valid dataset IDs provided")
return RetrievalResponse(results=[], total_found=0)
url = f"{api_base_url}/api/v1/retrieval"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"question": context.query,
"dataset_ids": dataset_ids,
"page": 1,
"page_size": int(page_size),
"similarity_threshold": float(similarity_threshold),
"vector_similarity_weight": float(vector_similarity_weight),
"top_k": int(top_k),
"keyword": keyword,
"highlight": False,
"use_kg": use_kg,
}
if rerank_id:
payload["rerank_id"] = rerank_id
results: list[RetrievalResultEntry] = []
try:
async with self.http_client() as client:
response = await client.post(url, json=payload, headers=headers, timeout=30.0)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
logger.error(
f"[RAGFlowKnowledgeEngine] API returned error code: {data.get('code')}"
)
return RetrievalResponse(results=[], total_found=0)
logger.info(
f"[RAGFlowKnowledgeEngine] Request: vector_weight={vector_similarity_weight}, "
f"top_k={top_k}, page_size={page_size}, threshold={similarity_threshold}, "
f"keyword={keyword}, rerank_id={rerank_id or 'None'}, use_kg={use_kg}, "
f"Response chunks: {len(data.get('data', {}).get('chunks', []))}"
)
for chunk in data.get("data", {}).get("chunks", []):
similarity = chunk.get("similarity")
if similarity is None:
similarity = 0.0
results.append(
RetrievalResultEntry(
id=chunk.get("id", ""),
content=[ContentElement.from_text(chunk.get("content", ""))],
metadata={
"document_id": chunk.get("document_id", ""),
"kb_id": chunk.get("kb_id", ""),
"document_keyword": chunk.get("document_keyword", ""),
"important_keywords": chunk.get("important_keywords", []),
"term_similarity": chunk.get("term_similarity", 0.0),
"vector_similarity": chunk.get("vector_similarity", 0.0),
"image_id": chunk.get("image_id"),
},
distance=1.0 - float(similarity),
score=float(similarity),
)
)
logger.info(f"[RAGFlowKnowledgeEngine] Retrieved {len(results)} chunks from RAGFlow.")
except Exception:
logger.exception("[RAGFlowKnowledgeEngine] Error during retrieval")
return RetrievalResponse(results=results, total_found=len(results))
@serialized
async def ingest(self, context: IngestionContext) -> IngestionResult:
"""Upload a file to RAGFlow and trigger parsing."""
doc_id = context.file_object.metadata.document_id
filename = context.file_object.metadata.filename
config = context.creation_settings
api_base_url = config.get("api_base_url", "http://localhost:9380").rstrip("/")
api_key = config.get("api_key")
dataset_ids_str = config.get("dataset_ids", "")
if not api_key or not dataset_ids_str:
logger.error(
f"[RAGFlowKnowledgeEngine] Missing required configuration for ingestion. "
f"Config keys: {list(config.keys())}"
)
return IngestionResult(
document_id=doc_id,
status=DocumentStatus.FAILED,
error_message="Missing api_key or dataset_ids in configuration.",
)
dataset_ids = [did.strip() for did in dataset_ids_str.split(",") if did.strip()]
if not dataset_ids:
logger.error("[RAGFlowKnowledgeEngine] No valid dataset IDs provided for ingestion")
return IngestionResult(
document_id=doc_id,
status=DocumentStatus.FAILED,
error_message="No valid dataset IDs provided.",
)
# Use the first dataset as the ingestion target
target_dataset_id = dataset_ids[0]
# The mapping names the canonical upstream it was created on, so a later
# configuration change cannot be mistaken for the same deployment.
upstream_target = _normalize_base_url(api_base_url)
# The Host knowledge-base id is the identity every entry point shares — the
# create hook, deletion and the fence/lock scope — so the collection id,
# which is a vector-store identity that need not equal it, is not used.
kb_id = context.knowledge_base_id
await self._save_config(kb_id, config)
if await self._load_document(kb_id, doc_id) is not None:
raise RuntimeError('Existing upload intent; reconcile before retry')
# 1. Read file content from Host
try:
file_bytes = await self.plugin.get_knowledge_file_stream(context.file_object.storage_path)
except Exception as e:
logger.error(f"[RAGFlowKnowledgeEngine] Failed to get file content: {e}")
return IngestionResult(
document_id=doc_id,
status=DocumentStatus.FAILED,
error_message=f"Could not read file: {e}",
)
await self._save_document(kb_id, doc_id, {'upstream_id': '',
'dataset_id': target_dataset_id, 'api_base_url': upstream_target,
'status': 'pending'})
headers = {"Authorization": f"Bearer {api_key}"}
try:
async with self.http_client() as client:
# 2. Upload file to RAGFlow dataset
upload_url = f"{api_base_url}/api/v1/datasets/{target_dataset_id}/documents"
files = {"file": (filename, file_bytes)}
async def upload():
response = await client.post(
upload_url, headers=headers, files=files, timeout=60.0
)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
# The provider answered: the upload was rejected, so no
# Host record is written and the fence can be released.
return None, data.get("message") or "Unknown upload error"
# Extract the document ID returned by RAGFlow
docs = data.get("data", [])
ragflow_doc_id = docs[0].get("id") if docs and isinstance(docs[0], dict) else None
if not isinstance(ragflow_doc_id, str) or not ragflow_doc_id:
raise AmbiguousMutationError("RAGFlow upload omitted upstream document ID; outcome requires reconciliation")
# The mapping is recorded before the fence is released: a
# cancellation or a Host failure here must not leave an
# uploaded document with no durable record of it.
await self._save_document(kb_id, doc_id, {'upstream_id': ragflow_doc_id,
'dataset_id': target_dataset_id, 'api_base_url': upstream_target,
'status': 'created'})
return ragflow_doc_id, None
# The intent is persisted before the upload leaves, and the fence
# is released only once the response was validated and the mapping
# recorded.
ragflow_doc_id, upload_error = await self._state.dispatched(
self.plugin, kb_id, upload, 'RAGFlow upload outcome unknown',
)
if upload_error is not None:
logger.error(f"[RAGFlowKnowledgeEngine] Upload failed: {upload_error}")
return IngestionResult(
document_id=doc_id,
status=DocumentStatus.FAILED,
error_message=f"RAGFlow upload error: {upload_error}",
)
# 3. Trigger parsing
chunks_url = f"{api_base_url}/api/v1/datasets/{target_dataset_id}/chunks"
async def trigger_parse():
response = await client.post(
chunks_url,
headers={**headers, "Content-Type": "application/json"},
json={"document_ids": [ragflow_doc_id]},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
# The provider answered: parsing did not start, so the
# fence can be released.
return data.get("message") or "Unknown parsing error"
return None
parse_error = await self._state.dispatched(
self.plugin, kb_id,
trigger_parse, 'RAGFlow parsing trigger outcome unknown',
)
if parse_error is not None:
logger.warning(
f"[RAGFlowKnowledgeEngine] Parsing trigger returned error: {parse_error}"
)
# Document was uploaded but parsing failed to start
return IngestionResult(
document_id=ragflow_doc_id,
status=DocumentStatus.FAILED,
error_message=f"RAGFlow parsing trigger error: {parse_error}",
)
logger.info(
f"[RAGFlowKnowledgeEngine] File '{filename}' uploaded and parsing triggered "
f"(ragflow_doc_id={ragflow_doc_id})"
)
# Auto-trigger GraphRAG construction if enabled
auto_graphrag = config.get("auto_graphrag", False)
if auto_graphrag:
graphrag_url = f"{api_base_url}/api/v1/datasets/{target_dataset_id}/run_graphrag"
async def trigger_graphrag():
response = await client.post(
graphrag_url,
headers={**headers, "Content-Type": "application/json"},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
# The provider answered: GraphRAG did not start. That
# is a resolved answer, not an unresolved state.
return None, data.get("message") or "unknown reason"
task = (data.get("data") or {}).get("graphrag_task_id", "unknown")
return task, None
# An unresolved GraphRAG outcome is not swallowed: it keeps
# its own fence and the chain must not continue over it (the
# RAPTOR dispatch would refuse it, and a later success must
# never clear this fence).
graphrag_task_id, graphrag_error = await self._state.dispatched(
self.plugin, kb_id, trigger_graphrag,
'RAGFlow GraphRAG trigger outcome unknown',
)
if graphrag_error is None:
logger.info(
f"[RAGFlowKnowledgeEngine] GraphRAG construction triggered "
f"(task_id={graphrag_task_id})"
)
else:
logger.warning(
f"[RAGFlowKnowledgeEngine] GraphRAG trigger returned: {graphrag_error}"
)
# Auto-trigger RAPTOR construction if enabled
auto_raptor = config.get("auto_raptor", False)
if auto_raptor:
raptor_url = f"{api_base_url}/api/v1/datasets/{target_dataset_id}/run_raptor"
async def trigger_raptor():
response = await client.post(
raptor_url,
headers={**headers, "Content-Type": "application/json"},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
# The provider answered: RAPTOR did not start.
return None, data.get("message") or "unknown reason"
task = (data.get("data") or {}).get("raptor_task_id", "unknown")
return task, None
raptor_task_id, raptor_error = await self._state.dispatched(
self.plugin, kb_id, trigger_raptor,
'RAGFlow RAPTOR trigger outcome unknown',
)
if raptor_error is None:
logger.info(
f"[RAGFlowKnowledgeEngine] RAPTOR construction triggered "
f"(task_id={raptor_task_id})"
)
else:
logger.warning(
f"[RAGFlowKnowledgeEngine] RAPTOR trigger returned: {raptor_error}"
)
return IngestionResult(
document_id=ragflow_doc_id,
status=DocumentStatus.PROCESSING,
)
except (AmbiguousMutationError, FenceLifecycleError):
# An already-dispatched mutation with an unknown outcome, or a
# mutation that was never dispatched: the caller must see it.
raise
except Exception as e:
# An ambiguous failure already left its pre-dispatch fence in place.
logger.error(f"[RAGFlowKnowledgeEngine] Ingestion failed for {filename}: {e}")
return IngestionResult(
document_id=doc_id,
status=DocumentStatus.FAILED,
error_message=str(e),
)
@serialized
async def delete_document(self, kb_id: str, document_id: str) -> bool:
"""Delete a document from RAGFlow."""
mapping = await self._load_document(kb_id, document_id)
if not mapping or not mapping['upstream_id'] or mapping['status'] != 'created':
return False
config = await self._load_config(kb_id)
if not config:
logger.error(
f"[RAGFlowKnowledgeEngine] No stored config for kb_id={kb_id}, "
"cannot delete document"
)
return False
api_base_url = config.get("api_base_url", "http://localhost:9380").rstrip("/")
api_key = config.get("api_key")
dataset_ids_str = config.get("dataset_ids", "")
if not api_key or not dataset_ids_str:
logger.error(
f"[RAGFlowKnowledgeEngine] Missing api_key or dataset_ids for kb_id={kb_id}"
)
return False
dataset_ids = [did.strip() for did in dataset_ids_str.split(",") if did.strip()]
if not dataset_ids:
logger.error(
f"[RAGFlowKnowledgeEngine] No valid dataset IDs for kb_id={kb_id}"
)
return False
target_dataset_id = dataset_ids[0]
if mapping['dataset_id'] != target_dataset_id:
# Local validation before any remote call: deterministic, no fence.
raise RuntimeError('Dataset changed; reconcile before deletion')
if mapping.get('api_base_url') != _normalize_base_url(api_base_url):
# The recorded upstream is not the configured one, so the document id
# does not necessarily exist here; mappings written before the target
# was recorded are equally unresolvable.
raise RuntimeError('Upstream target changed; reconcile before deletion')
url = f"{api_base_url}/api/v1/datasets/{target_dataset_id}/documents"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
async def delete_upstream():
async with self.http_client() as client:
response = await client.request(
"DELETE", url, headers=headers,
json={"ids": [mapping['upstream_id']]},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
# The provider answered: the delete was rejected, so there is
# nothing to record and the fence can be released.
return False, data.get("message") or "Unknown error"
# The tombstone is recorded before the fence is released: a
# cancellation or a Host failure here must not leave a deleted
# document that the mapping still calls created.
await self._save_document(kb_id, mapping['host_document_id'], {**mapping, 'status': 'deleted'})
return True, None
try:
deleted, error = await self._state.dispatched(
self.plugin, kb_id, delete_upstream, 'RAGFlow delete outcome unknown',
)
if not deleted:
logger.error(
f"[RAGFlowKnowledgeEngine] Delete failed for doc={document_id}: {error}"
)
return False
logger.info(
f"[RAGFlowKnowledgeEngine] Document {document_id} deleted from "
f"dataset {target_dataset_id}"
)
return True
except (AmbiguousMutationError, FenceLifecycleError):
# The delete was dispatched with an unknown outcome, or never
# dispatched at all: the caller must not read this as a plain failure.
raise
except httpx.HTTPStatusError as e:
# The provider answered, so this is not a transport ambiguity: a 4xx
# rejection is a resolved failure whose fence was released, while a
# 5xx keeps its fence (the provider or an intermediary may have
# committed the delete and then failed), so the next mutation for
# this knowledge base is still refused.
logger.error(
f"[RAGFlowKnowledgeEngine] Delete rejected for doc={document_id}: {e}"
)
return False
except Exception:
# An ambiguous failure already left its pre-dispatch fence in place.
logger.exception(
f"[RAGFlowKnowledgeEngine] Error deleting document {document_id}"
)
return False
@@ -0,0 +1,161 @@
apiVersion: v1
kind: KnowledgeEngine
metadata:
name: ragflow-connector
label:
en_US: RAGFlow Knowledge Base
zh_Hans: RAGFlow 知识库
ja_JP: RAGFlow 知識ベース
description:
en_US: Retrieve knowledge from or store files into RAGFlow knowledge bases
zh_Hans: 从 RAGFlow 知识库中检索知识或向其中存入文件
ja_JP: RAGFlow 知識ベースから知識を検索またはファイルを保存
spec:
creation_schema:
- name: api_base_url
label:
en_US: API Base URL
zh_Hans: API 基础地址
ja_JP: API ベース URL
description:
en_US: 'Base URL for RAGFlow API (e.g., http://localhost:9380 for local, or your server URL)'
zh_Hans: 'RAGFlow API 基础地址(本地版本使用 http://localhost:9380,或使用您的服务器地址)'
ja_JP: 'RAGFlow API ベース URL(ローカル版は http://localhost:9380、またはサーバーの URL)'
type: string
default: http://localhost:9380
required: true
- name: api_key
label:
en_US: API Key
zh_Hans: API 密钥
ja_JP: API キー
description:
en_US: 'API key from your RAGFlow instance'
zh_Hans: '从您的 RAGFlow 实例获取的 API Key'
ja_JP: 'RAGFlow インスタンスから取得した API Key'
type: password
required: true
- name: dataset_ids
label:
en_US: Dataset IDs
zh_Hans: 数据集 IDs
ja_JP: データセット ID
description:
en_US: 'Comma-separated dataset IDs to search (e.g., "id1,id2,id3")'
zh_Hans: '要搜索的数据集 ID,用逗号分隔(例如:"id1,id2,id3")'
ja_JP: '検索するデータセット ID(カンマ区切り、例:"id1,id2,id3")'
type: string
required: true
- name: auto_graphrag
label:
en_US: Auto Build Knowledge Graph
zh_Hans: 自动构建知识图谱
ja_JP: ナレッジグラフ自動構築
description:
en_US: 'Automatically trigger GraphRAG construction after file ingestion'
zh_Hans: '文件上传解析后自动触发知识图谱构建'
ja_JP: 'ファイル取り込み後に自動的にナレッジグラフを構築'
type: boolean
default: false
required: false
- name: auto_raptor
label:
en_US: Auto Build RAPTOR
zh_Hans: 自动构建 RAPTOR
ja_JP: RAPTOR 自動構築
description:
en_US: 'Automatically trigger RAPTOR hierarchical summarization after file ingestion'
zh_Hans: '文件上传解析后自动触发 RAPTOR 层次摘要构建'
ja_JP: 'ファイル取り込み後に自動的に RAPTOR 階層要約を構築'
type: boolean
default: false
required: false
retrieval_schema:
- name: vector_similarity_weight
label:
en_US: Vector Similarity Weight
zh_Hans: 向量相似度权重
ja_JP: ベクトル類似性の重み
description:
en_US: 'Balance between keyword and vector search (0=keyword only, 1=vector only, default 0.3)'
zh_Hans: '关键词与向量检索的平衡(0=纯关键词,1=纯向量,默认 0.3)'
ja_JP: 'キーワードとベクトル検索のバランス(0=キーワードのみ、1=ベクトルのみ、デフォルト 0.3)'
type: float
default: 0.3
required: false
- name: top_k
label:
en_US: Top K
zh_Hans: 候选数量
ja_JP: 候補数
description:
en_US: 'Maximum number of candidates to consider before pagination'
zh_Hans: '分页前考虑的最大候选数量'
ja_JP: 'ページネーション前に考慮する最大候補数'
type: integer
default: 1024
required: false
- name: page_size
label:
en_US: Page Size
zh_Hans: 返回数量
ja_JP: ページサイズ
description:
en_US: 'Number of results to return'
zh_Hans: '返回的结果数量'
ja_JP: '返す結果の数'
type: integer
default: 30
required: false
- name: similarity_threshold
label:
en_US: Similarity Threshold
zh_Hans: 相似度阈值
ja_JP: 類似度しきい値
description:
en_US: 'Minimum similarity score (0-1)'
zh_Hans: '最小相似度分数 (0-1)'
ja_JP: '最小類似性スコア (0-1)'
type: float
default: 0.2
required: false
- name: keyword
label:
en_US: LLM Keyword Extraction
zh_Hans: LLM 关键词提取
ja_JP: LLM キーワード抽出
description:
en_US: 'Use LLM to extract keywords from query to enhance retrieval (increases response time)'
zh_Hans: '使用 LLM 从查询中提取关键词以增强检索(会增加响应时间)'
ja_JP: 'LLM でクエリからキーワードを抽出して検索を強化(応答時間が増加)'
type: boolean
default: false
required: false
- name: rerank_id
label:
en_US: Rerank Model ID
zh_Hans: 重排序模型 ID
ja_JP: リランクモデル ID
description:
en_US: 'Rerank model ID configured in RAGFlow (e.g., BAAI/bge-reranker-v2-m3). Leave empty to disable'
zh_Hans: '在 RAGFlow 中配置的重排序模型 ID(如 BAAI/bge-reranker-v2-m3),留空则不启用'
ja_JP: 'RAGFlow で設定されたリランクモデル ID(例:BAAI/bge-reranker-v2-m3)、空欄で無効'
type: string
default: ''
required: false
- name: use_kg
label:
en_US: Knowledge Graph
zh_Hans: 知识图谱
ja_JP: ナレッジグラフ
description:
en_US: 'Enable knowledge graph retrieval (increases response time)'
zh_Hans: '启用知识图谱检索(会增加响应时间)'
ja_JP: 'ナレッジグラフ検索を有効化(応答時間が増加)'
type: boolean
default: false
required: false
execution:
python:
path: engine.py
attr: RAGFlowConnector