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
@@ -0,0 +1,502 @@
"""Installation- and knowledge-base-scoped state using the SDK-bound Host proxy.
Bundled in each independent archive. No filesystem/global tenant registry.
Locks and fences are keyed by ``(installation binding, knowledge-base identity)``
so an ambiguous failure for one knowledge base never blocks the other knowledge
bases that share the same installation; the platform storage key is the same for
a whole installation, hence the knowledge-base id is always folded into our keys.
Fences are persisted in installation-bound Host storage (key includes the
knowledge-base id) with an in-process cache in front, so a restarted worker still
recognises them. An unreadable fence record refuses the operation instead of
being read as "no fence", and such a read is retried rather than cached. Only
dispatched mutations whose outcome is unknown fence; a deterministic validation
error raised before any remote call and ordinary cancellation before a call is
dispatched do not. A fence that cannot be persisted aborts the mutation before
it is dispatched, and a fence is released only after the provider answer was
confirmed and its Host record written.
"""
import asyncio
import hashlib
import inspect
import json
import logging
from functools import wraps
from contextlib import asynccontextmanager
import httpx
from weakref import WeakValueDictionary
logger = logging.getLogger(__name__)
HTTP_TOTAL_TIMEOUT = 150.0
FENCE_KEY_PREFIX = "ke.fence.v1."
# Fallback fence scope when the wrapped method carries no knowledge-base identity.
_INSTALLATION_FENCE_KEY = FENCE_KEY_PREFIX + "installation"
_IDENTITY_ARGUMENTS = ('kb_id', 'collection_id', 'knowledge_base_id')
class AmbiguousMutationError(RuntimeError):
"""A dispatched mutation's outcome is unknown and requires reconciliation.
Raise this from a call site when a remote mutation may have taken effect but
its result (or its durable record) is unknown. Only these failures fence a
knowledge base; deterministic errors raised before any remote call, and
ordinary cancellation before a call is dispatched, do not fence.
"""
class FenceStateUnavailableError(RuntimeError):
"""Persisted fence state could not be read, so the operation is refused.
An unreadable fence record cannot be told apart from a knowledge base that
is fenced after an ambiguous mutation, so an unknown fence state fails
closed. The read is not cached: the next attempt re-reads the durable state,
and nothing has been dispatched while the state stays unknown.
"""
class FenceLifecycleError(RuntimeError):
"""A mutation was aborted before dispatch because its fence is not in place.
Both failures below mean "this call dispatched nothing": the pre-dispatch
protection could not be persisted, or an earlier unresolved mutation is
still fenced. Call sites must report them instead of folding them into an
ordinary provider failure.
"""
class FencePersistError(FenceLifecycleError):
"""The pre-dispatch fence record could not be persisted.
Raised by ``fence`` when the Host write of the fence record fails: the fence
is not in place, so ``dispatched`` aborts before its request instead of
dispatching a mutation whose protection does not exist.
"""
class FencedKnowledgeBaseError(FenceLifecycleError):
"""A mutation was refused: an earlier mutation's outcome is unresolved.
Raised when a serialized operation, or a dispatch inside one, is attempted
while the knowledge base (or the whole installation) is still fenced. A
later successful operation therefore never clears an earlier unresolved
operation's fence.
"""
def is_ambiguous_http_failure(exc):
"""Whether a failed provider call had an unknown outcome.
Only a rejection the protocol makes unambiguous releases the caller's
protection: a 4xx response means the provider received and refused the
request, and a connection that was never established means nothing was
dispatched. A 5xx or gateway response is NOT proof that the request was not
applied — the provider or an intermediary may have committed the change and
then failed — so it stays ambiguous together with timeouts, lost connections
and unparsable responses.
"""
if isinstance(exc, httpx.HTTPStatusError):
response = getattr(exc, "response", None)
return response is None or response.status_code >= 500
if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)):
return False
return True
async def _settle_commit(task):
"""Wait for a dispatched commit and report its outcome.
Repeated caller cancellation never abandons the wait, so a Host commit that
was already dispatched settles before its lock is released. Returns
``(caller_cancelled, failure)``; ``failure`` is the commit's exception, or
``asyncio.CancelledError`` when the commit itself was cancelled.
"""
cancelled = False
while not task.done():
try:
await asyncio.shield(task)
except asyncio.CancelledError:
cancelled = True
except Exception:
# The commit raised; its outcome is read from the state below.
pass
if task.cancelled():
return cancelled, asyncio.CancelledError()
return cancelled, task.exception()
def _kb_identity(method, instance, args, kwargs):
"""Extract the knowledge-base identity from a serialized method's arguments.
Reads a ``kb_id`` / ``collection_id`` / ``knowledge_base_id`` argument first,
then an argument exposing ``knowledge_base_id`` or ``get_collection_id()``
(ingestion passes the whole context). The knowledge-base id is preferred: the
collection id is a vector-store identity that need not equal it, and every
other entry point of the connector is addressed by the knowledge-base id, so
locking and fencing on the collection id would split one knowledge base into
two independently protected scopes. Returns ``None`` when the wrapped method
carries no knowledge-base identity, in which case the caller falls back to an
installation-wide lock and fence.
"""
try:
bound = inspect.signature(method).bind_partial(instance, *args, **kwargs)
except (TypeError, ValueError):
return None
for name in _IDENTITY_ARGUMENTS:
value = bound.arguments.get(name)
if isinstance(value, str) and value:
return value
for value in bound.arguments.values():
identity = getattr(value, 'knowledge_base_id', None)
if isinstance(identity, str) and identity:
return identity
getter = getattr(value, 'get_collection_id', None)
if callable(getter):
try:
identity = getter()
except Exception:
continue
if isinstance(identity, str) and identity:
return identity
return None
def _kb_fence_key(kb_identity):
if kb_identity is None:
return _INSTALLATION_FENCE_KEY
return FENCE_KEY_PREFIX + hashlib.sha256(kb_identity.encode()).hexdigest()
class SerialState:
def __init__(self):
self._locks = WeakValueDictionary()
# binding -> set of fence keys; cache in front of persisted fences.
self._fenced = {}
self._fence_loaded = set()
# binding -> single-flight lock and mutation counter for the fence read.
self._fence_load_locks: dict = {}
self._fence_generation: dict = {}
def _bump_fence_generation(self, binding):
self._fence_generation[binding] = self._fence_generation.get(binding, 0) + 1
@staticmethod
def binding(plugin):
try:
binding = plugin.get_installation_binding()
except Exception:
# Revocation has no invocation context; fall back to dedicated scope.
binding = None
return binding if binding is not None else ('dedicated',)
@staticmethod
def _scope(binding, kb_identity):
return (binding, kb_identity)
def _fenced_keys(self, binding):
return self._fenced.setdefault(binding, set())
def is_fenced(self, binding, kb_identity):
fenced = self._fenced_keys(binding)
return (
_kb_fence_key(kb_identity) in fenced
or _INSTALLATION_FENCE_KEY in fenced
)
async def _load_fences(self, plugin, binding):
"""Read persisted fences once per binding, or refuse the operation.
A failed read means the pending ambiguity of the installation is
unknown, so it must not be mistaken for "no fences": the operation is
refused with ``FenceStateUnavailableError`` and nothing is dispatched.
The binding is not marked as loaded, so the next attempt re-reads and
the knowledge base becomes usable again once storage answers. The read
is single-flight per installation, and a snapshot read before a sibling
operation changed the fence state is discarded instead of installed.
"""
if binding in self._fence_loaded:
return
lock = self._fence_load_locks.get(binding)
if lock is None:
lock = self._fence_load_locks[binding] = asyncio.Lock()
async with lock:
if binding in self._fence_loaded:
return
for _ in range(8):
generation = self._fence_generation.get(binding, 0)
snapshot = await self._read_fences(plugin, binding)
if generation == self._fence_generation.get(binding, 0):
self._fenced_keys(binding).update(snapshot)
self._fence_loaded.add(binding)
return
raise FenceStateUnavailableError(
f"Fence state for {binding!r} kept changing while it was being read"
)
async def _read_fences(self, plugin, binding) -> set:
"""Read the persisted fence keys for one installation.
An unreadable marker counts as fenced and a failed read refuses the
operation with ``FenceStateUnavailableError``; the read is never cached.
"""
try:
keys = await plugin.get_plugin_storage_keys()
snapshot = set()
for key in keys:
if not key.startswith(FENCE_KEY_PREFIX):
continue
try:
marker = json.loads(await plugin.get_plugin_storage(key))
except Exception:
# Unreadable marker: fail closed on this knowledge base.
marker = {'reason': 'unreadable fence record'}
if marker is not None:
snapshot.add(key)
return snapshot
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Could not read persisted fences for %r", binding, exc_info=True)
raise FenceStateUnavailableError(
f"Could not read persisted fences for {binding!r}; pending ambiguous "
f"mutations are unknown: {exc!r}"
) from exc
async def run(self, plugin, kb_identity, operation, *, allow_fenced=False):
binding = self.binding(plugin)
key = self._scope(binding, kb_identity)
lock = self._locks.get(key)
if lock is None:
lock = self._locks[key] = asyncio.Lock()
async with lock:
await self._load_fences(plugin, binding)
if not allow_fenced and self.is_fenced(binding, kb_identity):
raise FencedKnowledgeBaseError(
f"Knowledge base {kb_identity!r} fenced after an ambiguous mutation; "
"reconcile with the provider before retrying"
)
try:
return await operation()
except AmbiguousMutationError:
await self.fence(plugin, kb_identity, 'ambiguous remote mutation')
raise
async def dispatched(self, plugin, kb_identity, operation, reason):
"""Dispatch one remote mutation under a pre-persisted fence.
``operation`` must cover the whole mutation: the provider request, the
response validation and the durable result/mapping write. The fence is
persisted before the request goes out — a failed persist aborts here,
before any remote call — and it is released only once ``operation`` has
returned, so an ambiguous outcome or a caller cancellation keeps the
knowledge base fenced for reconciliation. A knowledge base that is
already fenced is refused before anything is written or dispatched, so a
later successful operation never clears an earlier unresolved
operation's fence.
"""
binding = self.binding(plugin)
if self.is_fenced(binding, kb_identity):
# The enclosing serialized call loaded and validated the fence state,
# so this is a real unresolved mutation rather than a stale view.
raise FencedKnowledgeBaseError(
f"Knowledge base {kb_identity!r} fenced by an unresolved mutation; "
"reconcile with the provider before dispatching another mutation"
)
await self.fence(plugin, kb_identity, reason)
try:
result = await operation()
except asyncio.CancelledError:
# Dispatched, outcome unknown: keep the fence and let the caller see it.
raise
except Exception as exc:
if not is_ambiguous_http_failure(exc):
await self.clear_fence(plugin, kb_identity)
raise
await self.clear_fence(plugin, kb_identity)
return result
async def fence(self, plugin, kb_identity, reason):
"""Persist one fence, then mark that knowledge base in this process.
The durable record is written first and a failed write is fatal
(``FencePersistError``), so a caller that fences before dispatching a
mutation aborts before its request instead of dispatching under a fence
that does not exist. Only a persisted fence sets the in-process marker,
so a failed write and a caller cancellation both leave no marker behind.
The residual race — the Host write landed but its acknowledgement was
lost — can make a later read load a fence for an operation that was
never dispatched; that is the conservative direction and is accepted.
"""
binding = self.binding(plugin)
fence_key = _kb_fence_key(kb_identity)
payload = json.dumps({'kb_id': kb_identity, 'reason': reason}).encode()
cancelled, failure = await _settle_commit(
asyncio.create_task(plugin.set_plugin_storage(fence_key, payload))
)
if cancelled:
raise asyncio.CancelledError
if failure is not None:
raise FencePersistError(
f"Could not persist fence for {kb_identity!r}: {failure!r}"
) from failure
self._fenced_keys(binding).add(fence_key)
self._bump_fence_generation(binding)
async def clear_fence(self, plugin, kb_identity):
"""Drop the fence for one knowledge base, in process and in storage.
Used when a knowledge base is deleted: its pending ambiguity is moot, so
the operator path out of a fence is deletion. A lingering persisted
marker is inert because the configuration tombstone is already written.
"""
binding = self.binding(plugin)
fence_key = _kb_fence_key(kb_identity)
self._fenced_keys(binding).discard(fence_key)
self._bump_fence_generation(binding)
cancelled, failure = await _settle_commit(
asyncio.create_task(plugin.set_plugin_storage(fence_key, b'null'))
)
if failure is not None:
logger.warning("Could not clear fence for %r: %r", kb_identity, failure)
if cancelled:
raise asyncio.CancelledError
async def write(self, plugin, kb_identity, key, value):
# A caller cancellation must not abandon a dispatched Host commit while
# its lock is released; settle it and fence only a real ambiguity.
task = asyncio.create_task(plugin.set_plugin_storage(key, value))
cancelled, failure = await _settle_commit(task)
if failure is not None:
try:
await self.fence(plugin, kb_identity, f'Host write outcome unknown: {failure!r}')
except FencePersistError:
# The write failure is what the caller must see; a fence that
# could not be persisted is reported alongside it, not instead.
logger.error(
"Could not persist fence for %r after a failed Host write",
kb_identity, exc_info=True,
)
if cancelled:
raise asyncio.CancelledError
if failure is not None:
raise failure
return task.result()
def release_binding(self, binding):
"""Drop process-local state keyed by one installation binding."""
for key in [k for k in self._locks if k[0] == binding]:
self._locks.pop(key, None)
self._fenced.pop(binding, None)
self._fence_loaded.discard(binding)
self._fence_load_locks.pop(binding, None)
self._fence_generation.pop(binding, None)
def serialized(method=None, *, allow_fenced=False):
def decorate(func):
@wraps(func)
async def call(self, *args, **kwargs):
return await self._state.run(
self.plugin,
_kb_identity(func, self, args, kwargs),
lambda: func(self, *args, **kwargs),
allow_fenced=allow_fenced,
)
return call
return decorate if method is None else decorate(method)
class ConfigStore:
"""No credential cache: every delete resolves durable installation storage.
A JSON null tombstone makes missing KBs explicit without ambiguous delete RPCs.
The one mutation lock also orders ingest/delete/create inside this worker.
Configuration and document mappings are keyed by the Host knowledge-base id;
a record written under another knowledge base's id is never adopted, and only
an exact mapping for the requested knowledge base is honoured.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._state = SerialState()
self._http_slots = asyncio.Semaphore(4)
async def initialize(self) -> None:
await super().initialize()
# The runtime dispatches installation revocation to the plugin instance,
# which holds no back-reference to its components; hand it this
# component's state so the revocation hook can drop per-binding locks and
# fences. Without the hook (langbot-plugin 0.7.4) this attribute is simply
# never read.
plugin = getattr(self, 'plugin', None)
if plugin is not None:
plugin.knowledge_engine_serial_state = self._state
@asynccontextmanager
async def http_client(self):
# Include queueing and the whole response body in the deadline, not just
# individual socket reads. Clients/credentials never survive a call.
async with asyncio.timeout(HTTP_TOTAL_TIMEOUT):
async with self._http_slots:
async with httpx.AsyncClient() as client:
yield client
@staticmethod
def _key(kb_id):
return "ke.config.v1." + hashlib.sha256(kb_id.encode()).hexdigest()
async def _save_config(self, kb_id, config):
value = json.dumps(config, ensure_ascii=False).encode()
if len(value) > 65536:
raise ValueError("Knowledge-base configuration exceeds 64 KiB")
await self._state.write(self.plugin, kb_id, self._key(kb_id), value)
async def _load_config(self, kb_id):
key = self._key(kb_id)
# Only an authoritative keys response establishes absence; RPC errors propagate.
if key not in await self.plugin.get_plugin_storage_keys():
return None
value = json.loads(await self.plugin.get_plugin_storage(key))
if value is not None and not isinstance(value, dict):
raise ValueError("Invalid persisted knowledge-base configuration")
return value
@staticmethod
def _document_key(kb_id, host_document_id):
return "ke.document.v1." + hashlib.sha256(
json.dumps([kb_id, host_document_id]).encode()
).hexdigest()
async def _load_document(self, kb_id, host_document_id):
"""Resolve this knowledge base's mapping, never another knowledge base's.
A record written under another knowledge base's id is never adopted:
attributing historic collection-keyed data requires explicit
reconciliation. An absent exact mapping therefore refuses the operation,
and this path performs no writes.
"""
key = self._document_key(kb_id, host_document_id)
keys = await self.plugin.get_plugin_storage_keys()
if key in keys:
value = json.loads(await self.plugin.get_plugin_storage(key))
else:
# Current Host passes the durable upstream ID on delete; older Host
# revisions pass its file UUID. Resolve either only for a record this
# knowledge base itself wrote, without trusting an arbitrary
# unrecorded identifier.
matches = []
for candidate in keys:
if candidate.startswith('ke.document.v1.'):
item = json.loads(await self.plugin.get_plugin_storage(candidate))
if item.get('kb_id') == kb_id and item.get('upstream_id') == host_document_id:
matches.append(item)
if len(matches) > 1:
raise ValueError('Ambiguous upstream document ID')
return matches[0] if matches else None
if not isinstance(value, dict) or not isinstance(value.get('upstream_id'), str):
raise ValueError('Invalid upstream document mapping; reconcile installation')
return value
async def _save_document(self, kb_id, host_document_id, value):
value = {**value, 'kb_id': kb_id, 'host_document_id': host_document_id}
await self._state.write(self.plugin, kb_id, self._document_key(kb_id, host_document_id),
json.dumps(value).encode())