mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 20:35:22 +08:00
374 lines
16 KiB
Python
374 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
from components.shared_state import (
|
|
AmbiguousMutationError,
|
|
ConfigStore,
|
|
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_api_base_url(url: str) -> str:
|
|
"""Canonical provider target: ``scheme://host[:port]/path``, no trailing slash.
|
|
|
|
Used to bind a stored document mapping to the upstream instance that created
|
|
it, so a later configuration change is detected before any remote call.
|
|
"""
|
|
parts = urlsplit(url or '')
|
|
return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path.rstrip('/'), '', ''))
|
|
|
|
|
|
class DifyDatasetsConnector(ConfigStore, KnowledgeEngine):
|
|
"""Knowledge Engine powered by Dify Datasets.
|
|
|
|
Supports retrieval, document ingestion (file upload), and deletion
|
|
via the Dify Dataset 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 the knowledge-base config for deletion after worker restart."""
|
|
await self._save_config(kb_id, config)
|
|
logger.info(f"[DifyDatasetsConnector] Knowledge base created: {kb_id}")
|
|
|
|
@serialized(allow_fenced=True)
|
|
async def on_knowledge_base_delete(self, kb_id: str) -> None:
|
|
"""Tombstone the stored config and clear any fence for this knowledge base."""
|
|
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)
|
|
logger.info(f"[DifyDatasetsConnector] Knowledge base deleted: {kb_id}")
|
|
|
|
# ========== Helper Methods ==========
|
|
|
|
async def _fetch_dataset_reranking_config(
|
|
self, api_base_url: str, api_key: str, dataset_id: str
|
|
) -> dict:
|
|
"""Fetch the reranking model config from the Dify dataset settings."""
|
|
url = f"{api_base_url}/datasets/{dataset_id}"
|
|
headers = {"Authorization": f"Bearer {api_key}"}
|
|
try:
|
|
async with self.http_client() as client:
|
|
response = await client.get(url, headers=headers, timeout=15.0)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
retrieval_model = data.get("retrieval_model_dict") or data.get("retrieval_model") or {}
|
|
reranking = retrieval_model.get("reranking_model", {})
|
|
mode = retrieval_model.get("reranking_mode")
|
|
logger.info(
|
|
f"[DifyDatasetsConnector] Fetched dataset reranking config: "
|
|
f"mode={mode}, provider={reranking.get('reranking_provider_name')}, "
|
|
f"model={reranking.get('reranking_model_name')}"
|
|
)
|
|
return {
|
|
"reranking_mode": mode or "reranking_model",
|
|
"reranking_model": reranking,
|
|
}
|
|
except Exception:
|
|
logger.exception("[DifyDatasetsConnector] Failed to fetch dataset reranking config")
|
|
return {
|
|
"reranking_mode": "reranking_model",
|
|
"reranking_model": {"reranking_provider_name": "", "reranking_model_name": ""},
|
|
}
|
|
|
|
# ========== Core Methods ==========
|
|
|
|
async def retrieve(self, context: RetrievalContext) -> RetrievalResponse:
|
|
"""Execute retrieval against Dify Dataset API."""
|
|
config = context.creation_settings
|
|
retrieval = context.retrieval_settings
|
|
|
|
api_base_url = config.get("api_base_url", "https://api.dify.ai/v1").rstrip("/")
|
|
api_key = config.get("dify_apikey")
|
|
dataset_id = config.get("dataset_id")
|
|
top_k = retrieval.get("top_k", 5)
|
|
score_threshold_enabled = retrieval.get("score_threshold_enabled", False)
|
|
score_threshold = retrieval.get("score_threshold", 0.5)
|
|
search_method = retrieval.get("search_method", "semantic_search")
|
|
reranking_enable = retrieval.get("reranking_enable", False)
|
|
|
|
if not api_key or not dataset_id:
|
|
logger.error(
|
|
f"[DifyDatasetsConnector] Missing required configuration. "
|
|
f"Config keys: {list(config.keys())}"
|
|
)
|
|
return RetrievalResponse(results=[], total_found=0)
|
|
|
|
# Fetch reranking model config from Dify dataset when reranking is enabled
|
|
reranking_mode = None
|
|
reranking_model = {"reranking_provider_name": "", "reranking_model_name": ""}
|
|
if reranking_enable:
|
|
reranking_config = await self._fetch_dataset_reranking_config(
|
|
api_base_url, api_key, dataset_id
|
|
)
|
|
reranking_mode = reranking_config["reranking_mode"]
|
|
reranking_model = reranking_config["reranking_model"]
|
|
|
|
url = f"{api_base_url}/datasets/{dataset_id}/retrieve"
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
payload = {
|
|
"query": context.query,
|
|
"retrieval_model": {
|
|
"search_method": search_method,
|
|
"reranking_enable": reranking_enable,
|
|
"reranking_mode": reranking_mode,
|
|
"reranking_model": reranking_model,
|
|
"weights": None,
|
|
"top_k": int(top_k),
|
|
"score_threshold_enabled": score_threshold_enabled,
|
|
"score_threshold": float(score_threshold),
|
|
},
|
|
}
|
|
|
|
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()
|
|
|
|
logger.info(
|
|
f"[DifyDatasetsConnector] Request: search_method={search_method}, top_k={top_k}, "
|
|
f"score_threshold_enabled={score_threshold_enabled}, score_threshold={score_threshold}, "
|
|
f"reranking_enable={reranking_enable}, reranking_mode={reranking_mode}, "
|
|
f"reranking_model={reranking_model}, "
|
|
f"Response records count: {len(data.get('records', []))}"
|
|
)
|
|
|
|
for record in data.get("records", []):
|
|
segment = record.get("segment", {})
|
|
document = segment.get("document", {})
|
|
|
|
score = record.get("score")
|
|
if score is None:
|
|
score = 0.0
|
|
|
|
results.append(
|
|
RetrievalResultEntry(
|
|
id=segment.get("id", ""),
|
|
content=[ContentElement.from_text(segment.get("content", ""))],
|
|
metadata={
|
|
"document_id": segment.get("document_id", ""),
|
|
"document_name": document.get("name", ""),
|
|
"segment_id": segment.get("id", ""),
|
|
"keywords": segment.get("keywords", []),
|
|
"answer": segment.get("answer"),
|
|
},
|
|
distance=1.0 - float(score),
|
|
score=float(score),
|
|
)
|
|
)
|
|
|
|
logger.info(f"[DifyDatasetsConnector] Retrieved {len(results)} chunks from Dify.")
|
|
except Exception:
|
|
logger.exception("[DifyDatasetsConnector] Error during retrieval")
|
|
|
|
return RetrievalResponse(results=results, total_found=len(results))
|
|
|
|
@serialized
|
|
async def ingest(self, context: IngestionContext) -> IngestionResult:
|
|
"""Upload a file to a Dify dataset for indexing."""
|
|
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", "https://api.dify.ai/v1").rstrip("/")
|
|
upstream_target = _normalize_api_base_url(api_base_url)
|
|
api_key = config.get("dify_apikey")
|
|
dataset_id = config.get("dataset_id")
|
|
|
|
if not api_key or not dataset_id:
|
|
logger.error(
|
|
f"[DifyDatasetsConnector] Missing required configuration for ingestion. "
|
|
f"Config keys: {list(config.keys())}"
|
|
)
|
|
return IngestionResult(
|
|
document_id=doc_id,
|
|
status=DocumentStatus.FAILED,
|
|
error_message="Missing required config: dify_apikey or dataset_id.",
|
|
)
|
|
|
|
# Persist config for deletion after worker restart
|
|
kb_id = context.get_collection_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"[DifyDatasetsConnector] Failed to read file content: {e}")
|
|
return IngestionResult(
|
|
document_id=doc_id,
|
|
status=DocumentStatus.FAILED,
|
|
error_message=f"Could not read file: {e}",
|
|
)
|
|
|
|
# Intent is durable before remote I/O; an ambiguous timeout cannot create
|
|
# a second document on retry without explicit upstream reconciliation.
|
|
await self._save_document(kb_id, doc_id, {'upstream_id': '', 'dataset_id': dataset_id,
|
|
'status': 'pending'})
|
|
# 2. Upload to Dify dataset
|
|
url = f"{api_base_url}/datasets/{dataset_id}/document/create-by-file"
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
}
|
|
|
|
try:
|
|
data_payload = json.dumps({
|
|
"indexing_technique": "high_quality",
|
|
"process_rule": {"mode": "automatic"},
|
|
})
|
|
|
|
async def upload():
|
|
async with self.http_client() as client:
|
|
response = await client.post(
|
|
url,
|
|
headers=headers,
|
|
files={"file": (filename, file_bytes)},
|
|
data={"data": data_payload},
|
|
timeout=120.0,
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
resp_data = await self._state.dispatched(
|
|
self.plugin, kb_id, upload, 'Dify upload outcome unknown')
|
|
|
|
dify_document = resp_data.get("document", {})
|
|
dify_doc_id = dify_document.get("id")
|
|
if not isinstance(dify_doc_id, str) or not dify_doc_id:
|
|
raise AmbiguousMutationError("Dify upload omitted upstream document ID; outcome requires reconciliation")
|
|
await self._save_document(kb_id, doc_id, {'upstream_id': dify_doc_id,
|
|
'dataset_id': dataset_id, 'status': 'created',
|
|
'api_base_url': upstream_target})
|
|
|
|
logger.info(
|
|
f"[DifyDatasetsConnector] File uploaded: {filename} -> "
|
|
f"dify_doc_id={dify_doc_id}, status={dify_document.get('indexing_status')}"
|
|
)
|
|
|
|
return IngestionResult(
|
|
document_id=dify_doc_id,
|
|
status=DocumentStatus.PROCESSING,
|
|
)
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
# The provider answered, so this rejection is deterministic: no fence.
|
|
error_body = e.response.text
|
|
logger.error(
|
|
f"[DifyDatasetsConnector] Dify API error during ingestion: "
|
|
f"status={e.response.status_code}, body={error_body}"
|
|
)
|
|
return IngestionResult(
|
|
document_id=doc_id,
|
|
status=DocumentStatus.FAILED,
|
|
error_message=f"Dify API error {e.response.status_code}: {error_body}",
|
|
)
|
|
except AmbiguousMutationError:
|
|
# Already-dispatched upload with an unknown outcome: fence this KB.
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"[DifyDatasetsConnector] 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 a Dify dataset."""
|
|
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.warning(
|
|
f"[DifyDatasetsConnector] No stored config for kb_id={kb_id}. "
|
|
"Cannot delete document without API credentials."
|
|
)
|
|
return False
|
|
|
|
api_base_url = config.get("api_base_url", "https://api.dify.ai/v1").rstrip("/")
|
|
api_key = config.get("dify_apikey")
|
|
dataset_id = config.get("dataset_id")
|
|
|
|
if not api_key or not dataset_id:
|
|
logger.error(
|
|
f"[DifyDatasetsConnector] Missing required configuration for deletion. "
|
|
f"Config keys: {list(config.keys())}"
|
|
)
|
|
return False
|
|
|
|
if mapping['dataset_id'] != dataset_id:
|
|
raise RuntimeError('Dataset changed; reconcile before deletion')
|
|
# The mapping is bound to the upstream instance that created it: a changed
|
|
# base URL (or a legacy mapping without one) must not be sent to a
|
|
# different provider. Reject before dispatching any remote request.
|
|
if mapping.get('api_base_url') != _normalize_api_base_url(api_base_url):
|
|
raise RuntimeError('Upstream target changed; reconcile before deletion')
|
|
url = f"{api_base_url}/datasets/{dataset_id}/documents/{mapping['upstream_id']}"
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
}
|
|
|
|
async def remove():
|
|
async with self.http_client() as client:
|
|
response = await client.delete(url, headers=headers, timeout=30.0)
|
|
if response.status_code != 204:
|
|
# Some Dify versions may return 200 instead of 204.
|
|
response.raise_for_status()
|
|
return response.status_code
|
|
|
|
try:
|
|
status_code = await self._state.dispatched(
|
|
self.plugin, kb_id, remove, 'Dify delete outcome unknown')
|
|
await self._save_document(kb_id, mapping['host_document_id'], {**mapping, 'status': 'deleted'})
|
|
logger.info(
|
|
f"[DifyDatasetsConnector] Document deleted: {document_id} "
|
|
f"from dataset {dataset_id} (status={status_code})"
|
|
)
|
|
return True
|
|
except httpx.HTTPStatusError as e:
|
|
# The provider answered, so the delete was rejected deterministically.
|
|
logger.error(
|
|
f"[DifyDatasetsConnector] Failed to delete document {document_id}: {e}"
|
|
)
|
|
return False
|
|
except Exception as e:
|
|
logger.error(
|
|
f"[DifyDatasetsConnector] Failed to delete document {document_id}: {e}"
|
|
)
|
|
return False
|
|
|