test: validate external knowledge provider contracts
Add read-only Dify, FastGPT, and RAGFlow probes, a redacted live capability baseline, and focused adapter tests. Document version-specific API and UI behavior, including Dify 0.x/1.x differences and RAGFlow GraphRAG evidence.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# 知识库
|
||||
|
||||
知识库已提供本地资料导入、解析、分块、索引、检索、图谱和带来源的知识问答。
|
||||
将 Dify、FastGPT 和 RAGFlow 中已有知识库接入同一检索范围仍处于规划阶段,不属于
|
||||
当前已提供能力。
|
||||
将 Dify、FastGPT 和 RAGFlow 中已有知识库接入同一检索范围已完成 Main 进程适配器和
|
||||
真实接口验证,但实例管理、绑定、统一检索和界面尚未接通,因此仍不属于当前已提供能力。
|
||||
|
||||
## 文档导航
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
| [外部知识库逻辑设计](./external-knowledge-logic-design.md) | 状态、不变量、配置优先级和失败规则 |
|
||||
| [外部知识库 UI 设计](./external-knowledge-ui-design.md) | 知识库页面、外部实例管理和创建向导交互 |
|
||||
| [外部知识库技术设计](./external-knowledge-technical-design.md) | Provider Adapter、凭据、持久化、IPC 和检索标准化 |
|
||||
| [外部知识库实测基线](./external-knowledge-probe-baseline.json) | 脱敏记录真实实例的接口状态、字段结构、特色能力和耗时 |
|
||||
|
||||
## 术语
|
||||
|
||||
|
||||
@@ -337,7 +337,10 @@ Provider 原始正文不能直接成为用户错误。Adapter 将其映射为稳
|
||||
|
||||
实施前仍需技术验证:
|
||||
|
||||
- 选定支持的 Dify、FastGPT、RAGFlow 最低版本,形成固定支持矩阵并保存真实响应 fixture。
|
||||
- 已完成第一轮真实目录与最小检索验证,并确认 Dify `1.17.0` 非空结果结构;仍需确认
|
||||
FastGPT、RAGFlow 产品版本和 Dify `0.15.8` 非空结果,再选定三者最低版本并形成固定
|
||||
支持矩阵与去敏 fixture。当前实测详情见
|
||||
[技术设计 9.0 节](./external-knowledge-technical-design.md#90-真实实例验证基线)。
|
||||
- 验证各版本检索探测的 API 审计、用量和内容修改行为。
|
||||
- 确定 Provider 返回 URL 的安全打开策略;未验证前只显示文本定位和远端 ID。
|
||||
- 依据真实延迟确定默认并发上限和跨知识库上下文配额。
|
||||
|
||||
@@ -0,0 +1,910 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"probedAt": "2026-09-09T16:25:05.512Z",
|
||||
"mode": "extended-read-only",
|
||||
"queryCharacters": 3,
|
||||
"privacy": "No credentials, origins, IDs, names, queries, or content are included.",
|
||||
"providers": [
|
||||
{
|
||||
"provider": "dify",
|
||||
"profile": "0.x",
|
||||
"detectedVersion": "0.15.8",
|
||||
"transport": {
|
||||
"protocol": "https:",
|
||||
"tlsVerificationDisabled": true
|
||||
},
|
||||
"catalog": {
|
||||
"count": 3,
|
||||
"total": 3,
|
||||
"itemKeys": [
|
||||
"app_count",
|
||||
"created_at",
|
||||
"created_by",
|
||||
"data_source_type",
|
||||
"description",
|
||||
"doc_form",
|
||||
"document_count",
|
||||
"embedding_available",
|
||||
"embedding_model",
|
||||
"embedding_model_provider",
|
||||
"external_knowledge_info",
|
||||
"external_retrieval_model",
|
||||
"id",
|
||||
"indexing_technique",
|
||||
"name",
|
||||
"permission",
|
||||
"provider",
|
||||
"retrieval_model_dict",
|
||||
"tags",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
"word_count"
|
||||
],
|
||||
"retrievalModelKeys": [
|
||||
"reranking_enable",
|
||||
"reranking_mode",
|
||||
"reranking_model",
|
||||
"score_threshold",
|
||||
"score_threshold_enabled",
|
||||
"search_method",
|
||||
"top_k",
|
||||
"weights"
|
||||
],
|
||||
"indexingTechniques": [
|
||||
"high_quality"
|
||||
],
|
||||
"metadataSchemaCount": 0,
|
||||
"multimodalCount": 0,
|
||||
"pipelineConfiguredCount": 0,
|
||||
"summaryIndexConfiguredCount": 0
|
||||
},
|
||||
"detail": {
|
||||
"keys": [
|
||||
"code",
|
||||
"message",
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"retrievals": [
|
||||
{
|
||||
"mode": "dataset-defaults",
|
||||
"count": 0,
|
||||
"itemKeys": [],
|
||||
"segmentKeys": [],
|
||||
"documentKeys": [],
|
||||
"childChunkKeys": [],
|
||||
"fileKeys": [],
|
||||
"contentPresent": false,
|
||||
"childContentPresent": false,
|
||||
"summaryPresent": false
|
||||
},
|
||||
{
|
||||
"mode": "explicit-current-config",
|
||||
"count": 0,
|
||||
"itemKeys": [],
|
||||
"segmentKeys": [],
|
||||
"documentKeys": [],
|
||||
"childChunkKeys": [],
|
||||
"fileKeys": [],
|
||||
"contentPresent": false,
|
||||
"childContentPresent": false,
|
||||
"summaryPresent": false
|
||||
}
|
||||
],
|
||||
"features": {
|
||||
"datasetDefaults": true,
|
||||
"detailSupported": false,
|
||||
"retrievalOverrideObserved": true,
|
||||
"retrievalOverrideProbed": true,
|
||||
"nonEmptyResultObserved": false
|
||||
},
|
||||
"operations": [
|
||||
{
|
||||
"name": "catalog",
|
||||
"method": "GET",
|
||||
"path": "/v1/datasets",
|
||||
"status": 200,
|
||||
"durationMs": 288,
|
||||
"responseKeys": [
|
||||
"data",
|
||||
"has_more",
|
||||
"limit",
|
||||
"page",
|
||||
"total"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "detail",
|
||||
"method": "GET",
|
||||
"path": "/v1/datasets/{id}",
|
||||
"status": 405,
|
||||
"durationMs": 111,
|
||||
"responseKeys": [
|
||||
"code",
|
||||
"message",
|
||||
"status"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "retrieve-defaults",
|
||||
"method": "POST",
|
||||
"path": "/v1/datasets/{id}/retrieve",
|
||||
"status": 200,
|
||||
"durationMs": 237,
|
||||
"responseKeys": [
|
||||
"query",
|
||||
"records"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "retrieve-explicit-config",
|
||||
"method": "POST",
|
||||
"path": "/v1/datasets/{id}/retrieve",
|
||||
"status": 200,
|
||||
"durationMs": 160,
|
||||
"responseKeys": [
|
||||
"query",
|
||||
"records"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"provider": "dify",
|
||||
"profile": "1.x",
|
||||
"detectedVersion": "1.17.0",
|
||||
"transport": {
|
||||
"protocol": "https:",
|
||||
"tlsVerificationDisabled": true
|
||||
},
|
||||
"catalog": {
|
||||
"count": 1,
|
||||
"total": 1,
|
||||
"itemKeys": [
|
||||
"app_count",
|
||||
"author_name",
|
||||
"built_in_field_enabled",
|
||||
"chunk_structure",
|
||||
"created_at",
|
||||
"created_by",
|
||||
"data_source_type",
|
||||
"description",
|
||||
"doc_form",
|
||||
"doc_metadata",
|
||||
"document_count",
|
||||
"embedding_available",
|
||||
"embedding_model",
|
||||
"embedding_model_provider",
|
||||
"enable_api",
|
||||
"external_knowledge_info",
|
||||
"external_retrieval_model",
|
||||
"icon_info",
|
||||
"id",
|
||||
"indexing_technique",
|
||||
"is_multimodal",
|
||||
"is_published",
|
||||
"maintainer",
|
||||
"name",
|
||||
"permission",
|
||||
"pipeline_id",
|
||||
"provider",
|
||||
"retrieval_model_dict",
|
||||
"runtime_mode",
|
||||
"summary_index_setting",
|
||||
"tags",
|
||||
"total_available_documents",
|
||||
"total_documents",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
"word_count"
|
||||
],
|
||||
"retrievalModelKeys": [
|
||||
"reranking_enable",
|
||||
"reranking_mode",
|
||||
"reranking_model",
|
||||
"score_threshold",
|
||||
"score_threshold_enabled",
|
||||
"search_method",
|
||||
"top_k",
|
||||
"weights"
|
||||
],
|
||||
"indexingTechniques": [
|
||||
"high_quality"
|
||||
],
|
||||
"metadataSchemaCount": 0,
|
||||
"multimodalCount": 0,
|
||||
"pipelineConfiguredCount": 0,
|
||||
"summaryIndexConfiguredCount": 1
|
||||
},
|
||||
"detail": {
|
||||
"keys": [
|
||||
"app_count",
|
||||
"author_name",
|
||||
"built_in_field_enabled",
|
||||
"chunk_structure",
|
||||
"created_at",
|
||||
"created_by",
|
||||
"data_source_type",
|
||||
"description",
|
||||
"doc_form",
|
||||
"doc_metadata",
|
||||
"document_count",
|
||||
"embedding_available",
|
||||
"embedding_model",
|
||||
"embedding_model_provider",
|
||||
"enable_api",
|
||||
"external_knowledge_info",
|
||||
"external_retrieval_model",
|
||||
"icon_info",
|
||||
"id",
|
||||
"indexing_technique",
|
||||
"is_multimodal",
|
||||
"is_published",
|
||||
"maintainer",
|
||||
"name",
|
||||
"permission",
|
||||
"pipeline_id",
|
||||
"provider",
|
||||
"retrieval_model_dict",
|
||||
"runtime_mode",
|
||||
"summary_index_setting",
|
||||
"tags",
|
||||
"total_available_documents",
|
||||
"total_documents",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
"word_count"
|
||||
]
|
||||
},
|
||||
"retrievals": [
|
||||
{
|
||||
"mode": "dataset-defaults",
|
||||
"count": 3,
|
||||
"itemKeys": [
|
||||
"child_chunks",
|
||||
"files",
|
||||
"score",
|
||||
"segment",
|
||||
"summary",
|
||||
"tsne_position"
|
||||
],
|
||||
"segmentKeys": [
|
||||
"answer",
|
||||
"completed_at",
|
||||
"content",
|
||||
"created_at",
|
||||
"created_by",
|
||||
"disabled_at",
|
||||
"disabled_by",
|
||||
"document",
|
||||
"document_id",
|
||||
"enabled",
|
||||
"error",
|
||||
"hit_count",
|
||||
"id",
|
||||
"index_node_hash",
|
||||
"index_node_id",
|
||||
"indexing_at",
|
||||
"keywords",
|
||||
"position",
|
||||
"sign_content",
|
||||
"status",
|
||||
"stopped_at",
|
||||
"tokens",
|
||||
"word_count"
|
||||
],
|
||||
"documentKeys": [
|
||||
"data_source_type",
|
||||
"doc_metadata",
|
||||
"doc_type",
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"childChunkKeys": [],
|
||||
"fileKeys": [],
|
||||
"contentPresent": true,
|
||||
"childContentPresent": false,
|
||||
"summaryPresent": false,
|
||||
"scoreRange": {
|
||||
"minimum": 0.5038624,
|
||||
"maximum": 0.6006959
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "explicit-current-config",
|
||||
"count": 3,
|
||||
"itemKeys": [
|
||||
"child_chunks",
|
||||
"files",
|
||||
"score",
|
||||
"segment",
|
||||
"summary",
|
||||
"tsne_position"
|
||||
],
|
||||
"segmentKeys": [
|
||||
"answer",
|
||||
"completed_at",
|
||||
"content",
|
||||
"created_at",
|
||||
"created_by",
|
||||
"disabled_at",
|
||||
"disabled_by",
|
||||
"document",
|
||||
"document_id",
|
||||
"enabled",
|
||||
"error",
|
||||
"hit_count",
|
||||
"id",
|
||||
"index_node_hash",
|
||||
"index_node_id",
|
||||
"indexing_at",
|
||||
"keywords",
|
||||
"position",
|
||||
"sign_content",
|
||||
"status",
|
||||
"stopped_at",
|
||||
"tokens",
|
||||
"word_count"
|
||||
],
|
||||
"documentKeys": [
|
||||
"data_source_type",
|
||||
"doc_metadata",
|
||||
"doc_type",
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"childChunkKeys": [],
|
||||
"fileKeys": [],
|
||||
"contentPresent": true,
|
||||
"childContentPresent": false,
|
||||
"summaryPresent": false,
|
||||
"scoreRange": {
|
||||
"minimum": 0.5038624,
|
||||
"maximum": 0.6006959
|
||||
}
|
||||
}
|
||||
],
|
||||
"features": {
|
||||
"datasetDefaults": true,
|
||||
"detailSupported": true,
|
||||
"retrievalOverrideObserved": true,
|
||||
"retrievalOverrideProbed": true,
|
||||
"nonEmptyResultObserved": true
|
||||
},
|
||||
"operations": [
|
||||
{
|
||||
"name": "catalog",
|
||||
"method": "GET",
|
||||
"path": "/v1/datasets",
|
||||
"status": 200,
|
||||
"durationMs": 950,
|
||||
"responseKeys": [
|
||||
"data",
|
||||
"has_more",
|
||||
"limit",
|
||||
"page",
|
||||
"total"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "detail",
|
||||
"method": "GET",
|
||||
"path": "/v1/datasets/{id}",
|
||||
"status": 200,
|
||||
"durationMs": 616,
|
||||
"responseKeys": [
|
||||
"app_count",
|
||||
"author_name",
|
||||
"built_in_field_enabled",
|
||||
"chunk_structure",
|
||||
"created_at",
|
||||
"created_by",
|
||||
"data_source_type",
|
||||
"description",
|
||||
"doc_form",
|
||||
"doc_metadata",
|
||||
"document_count",
|
||||
"embedding_available",
|
||||
"embedding_model",
|
||||
"embedding_model_provider",
|
||||
"enable_api",
|
||||
"external_knowledge_info",
|
||||
"external_retrieval_model",
|
||||
"icon_info",
|
||||
"id",
|
||||
"indexing_technique",
|
||||
"is_multimodal",
|
||||
"is_published",
|
||||
"maintainer",
|
||||
"name",
|
||||
"permission",
|
||||
"pipeline_id",
|
||||
"provider",
|
||||
"retrieval_model_dict",
|
||||
"runtime_mode",
|
||||
"summary_index_setting",
|
||||
"tags",
|
||||
"total_available_documents",
|
||||
"total_documents",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
"word_count"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "retrieve-defaults",
|
||||
"method": "POST",
|
||||
"path": "/v1/datasets/{id}/retrieve",
|
||||
"status": 200,
|
||||
"durationMs": 2468,
|
||||
"responseKeys": [
|
||||
"query",
|
||||
"records"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "retrieve-explicit-config",
|
||||
"method": "POST",
|
||||
"path": "/v1/datasets/{id}/retrieve",
|
||||
"status": 200,
|
||||
"durationMs": 801,
|
||||
"responseKeys": [
|
||||
"query",
|
||||
"records"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"provider": "fastgpt",
|
||||
"transport": {
|
||||
"protocol": "https:",
|
||||
"tlsVerificationDisabled": true
|
||||
},
|
||||
"catalog": {
|
||||
"count": 1,
|
||||
"envelope": "data-array",
|
||||
"itemKeys": [
|
||||
"_id",
|
||||
"avatar",
|
||||
"createTime",
|
||||
"inheritPermission",
|
||||
"intro",
|
||||
"name",
|
||||
"permission",
|
||||
"private",
|
||||
"sourceMember",
|
||||
"tmbId",
|
||||
"type",
|
||||
"updateTime",
|
||||
"vectorModel"
|
||||
],
|
||||
"itemTypes": [
|
||||
"dataset"
|
||||
]
|
||||
},
|
||||
"detail": {
|
||||
"keys": [
|
||||
"_id",
|
||||
"agentModel",
|
||||
"agentModelId",
|
||||
"avatar",
|
||||
"deleteTime",
|
||||
"inheritPermission",
|
||||
"intro",
|
||||
"name",
|
||||
"parentId",
|
||||
"permission",
|
||||
"status",
|
||||
"teamId",
|
||||
"tmbId",
|
||||
"type",
|
||||
"updateTime",
|
||||
"vectorModel",
|
||||
"vectorModelId",
|
||||
"vlmModel",
|
||||
"vlmModelId"
|
||||
],
|
||||
"vectorModelKeys": [
|
||||
"avatar",
|
||||
"charsPointsPrice",
|
||||
"config",
|
||||
"isActive",
|
||||
"isCustom",
|
||||
"model",
|
||||
"modelId",
|
||||
"name",
|
||||
"provider",
|
||||
"scope",
|
||||
"testMode",
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"retrievals": [
|
||||
{
|
||||
"searchMode": "embedding",
|
||||
"count": 8,
|
||||
"itemKeys": [
|
||||
"chunkIndex",
|
||||
"collectionId",
|
||||
"datasetId",
|
||||
"id",
|
||||
"q",
|
||||
"score",
|
||||
"sourceId",
|
||||
"sourceName",
|
||||
"updateTime"
|
||||
],
|
||||
"contentPresent": true
|
||||
},
|
||||
{
|
||||
"searchMode": "fullTextRecall",
|
||||
"count": 2,
|
||||
"itemKeys": [
|
||||
"chunkIndex",
|
||||
"collectionId",
|
||||
"datasetId",
|
||||
"id",
|
||||
"q",
|
||||
"score",
|
||||
"sourceId",
|
||||
"sourceName",
|
||||
"updateTime"
|
||||
],
|
||||
"contentPresent": true
|
||||
},
|
||||
{
|
||||
"searchMode": "mixedRecall",
|
||||
"count": 7,
|
||||
"itemKeys": [
|
||||
"chunkIndex",
|
||||
"collectionId",
|
||||
"datasetId",
|
||||
"id",
|
||||
"q",
|
||||
"score",
|
||||
"sourceId",
|
||||
"sourceName",
|
||||
"updateTime"
|
||||
],
|
||||
"contentPresent": true
|
||||
},
|
||||
{
|
||||
"searchMode": "mixedRecall",
|
||||
"usingRerank": true,
|
||||
"count": 9,
|
||||
"itemKeys": [
|
||||
"chunkIndex",
|
||||
"collectionId",
|
||||
"datasetId",
|
||||
"id",
|
||||
"q",
|
||||
"score",
|
||||
"sourceId",
|
||||
"sourceName",
|
||||
"updateTime"
|
||||
],
|
||||
"contentPresent": true
|
||||
}
|
||||
],
|
||||
"features": {
|
||||
"searchModes": [
|
||||
"embedding",
|
||||
"fullTextRecall",
|
||||
"mixedRecall"
|
||||
],
|
||||
"rerankProbed": true,
|
||||
"extensionQueryProbed": false
|
||||
},
|
||||
"operations": [
|
||||
{
|
||||
"name": "catalog",
|
||||
"method": "POST",
|
||||
"path": "/api/core/dataset/list",
|
||||
"status": 200,
|
||||
"durationMs": 707,
|
||||
"responseKeys": [
|
||||
"code",
|
||||
"data",
|
||||
"message",
|
||||
"statusText"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "detail",
|
||||
"method": "GET",
|
||||
"path": "/api/core/dataset/detail",
|
||||
"status": 200,
|
||||
"durationMs": 293,
|
||||
"responseKeys": [
|
||||
"code",
|
||||
"data",
|
||||
"message",
|
||||
"statusText"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "retrieve-embedding",
|
||||
"method": "POST",
|
||||
"path": "/api/core/dataset/searchTest",
|
||||
"status": 200,
|
||||
"durationMs": 797,
|
||||
"responseKeys": [
|
||||
"code",
|
||||
"data",
|
||||
"message",
|
||||
"statusText"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "retrieve-fullTextRecall",
|
||||
"method": "POST",
|
||||
"path": "/api/core/dataset/searchTest",
|
||||
"status": 200,
|
||||
"durationMs": 300,
|
||||
"responseKeys": [
|
||||
"code",
|
||||
"data",
|
||||
"message",
|
||||
"statusText"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "retrieve-mixedRecall",
|
||||
"method": "POST",
|
||||
"path": "/api/core/dataset/searchTest",
|
||||
"status": 200,
|
||||
"durationMs": 1707,
|
||||
"responseKeys": [
|
||||
"code",
|
||||
"data",
|
||||
"message",
|
||||
"statusText"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "retrieve-rerank",
|
||||
"method": "POST",
|
||||
"path": "/api/core/dataset/searchTest",
|
||||
"status": 200,
|
||||
"durationMs": 1727,
|
||||
"responseKeys": [
|
||||
"code",
|
||||
"data",
|
||||
"message",
|
||||
"statusText"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"provider": "ragflow",
|
||||
"transport": {
|
||||
"protocol": "http:"
|
||||
},
|
||||
"catalog": {
|
||||
"count": 28,
|
||||
"total": 28,
|
||||
"itemKeys": [
|
||||
"artifact_task_finish_at",
|
||||
"artifact_task_id",
|
||||
"avatar",
|
||||
"chunk_count",
|
||||
"chunk_method",
|
||||
"create_date",
|
||||
"create_time",
|
||||
"created_by",
|
||||
"description",
|
||||
"document_count",
|
||||
"embedding_model",
|
||||
"graphrag_task_finish_at",
|
||||
"graphrag_task_id",
|
||||
"id",
|
||||
"language",
|
||||
"mindmap_task_finish_at",
|
||||
"mindmap_task_id",
|
||||
"name",
|
||||
"nickname",
|
||||
"pagerank",
|
||||
"parser_config",
|
||||
"permission",
|
||||
"pipeline_id",
|
||||
"raptor_task_finish_at",
|
||||
"raptor_task_id",
|
||||
"similarity_threshold",
|
||||
"skill_task_finish_at",
|
||||
"skill_task_id",
|
||||
"status",
|
||||
"tenant_avatar",
|
||||
"tenant_embd_id",
|
||||
"tenant_id",
|
||||
"token_num",
|
||||
"update_date",
|
||||
"update_time",
|
||||
"vector_similarity_weight"
|
||||
],
|
||||
"parserConfigKeys": [
|
||||
"auto_keywords",
|
||||
"auto_questions",
|
||||
"built_in_metadata",
|
||||
"children_delimiter",
|
||||
"chunk_token_num",
|
||||
"compilation_template_group_id",
|
||||
"delimiter",
|
||||
"enable_children",
|
||||
"enable_metadata",
|
||||
"ext",
|
||||
"filename_embd_weight",
|
||||
"graphrag",
|
||||
"html4excel",
|
||||
"image_context_size",
|
||||
"image_table_context_window",
|
||||
"layout_recognize",
|
||||
"llm_id",
|
||||
"metadata",
|
||||
"mineru_formula_enable",
|
||||
"mineru_lang",
|
||||
"mineru_parse_method",
|
||||
"mineru_table_enable",
|
||||
"overlapped_percent",
|
||||
"pages",
|
||||
"parent_child",
|
||||
"raptor",
|
||||
"table_column_mode",
|
||||
"table_column_names",
|
||||
"table_column_roles",
|
||||
"table_context_size",
|
||||
"tag_kb_ids",
|
||||
"task_page_size",
|
||||
"tenant_llm_id",
|
||||
"toc_extraction",
|
||||
"topn_tags"
|
||||
],
|
||||
"graphConfiguredCount": 17,
|
||||
"graphCompletedCount": 15,
|
||||
"knowledgeCompilationConfiguredCount": 0
|
||||
},
|
||||
"detail": {
|
||||
"keys": [
|
||||
"artifact_task_finish_at",
|
||||
"artifact_task_id",
|
||||
"avatar",
|
||||
"chunk_count",
|
||||
"chunk_method",
|
||||
"create_date",
|
||||
"create_time",
|
||||
"created_by",
|
||||
"description",
|
||||
"document_count",
|
||||
"embedding_model",
|
||||
"graphrag_task_finish_at",
|
||||
"graphrag_task_id",
|
||||
"id",
|
||||
"language",
|
||||
"mindmap_task_finish_at",
|
||||
"mindmap_task_id",
|
||||
"name",
|
||||
"nickname",
|
||||
"pagerank",
|
||||
"parser_config",
|
||||
"permission",
|
||||
"pipeline_id",
|
||||
"raptor_task_finish_at",
|
||||
"raptor_task_id",
|
||||
"similarity_threshold",
|
||||
"skill_task_finish_at",
|
||||
"skill_task_id",
|
||||
"status",
|
||||
"tenant_avatar",
|
||||
"tenant_embd_id",
|
||||
"tenant_id",
|
||||
"token_num",
|
||||
"update_date",
|
||||
"update_time",
|
||||
"vector_similarity_weight"
|
||||
]
|
||||
},
|
||||
"retrievals": [
|
||||
{
|
||||
"feature": "baseline",
|
||||
"count": 0,
|
||||
"itemKeys": [],
|
||||
"contentPresent": false
|
||||
},
|
||||
{
|
||||
"feature": "graph",
|
||||
"count": 1,
|
||||
"itemKeys": [
|
||||
"content",
|
||||
"content_ltks",
|
||||
"dataset_id",
|
||||
"document_id",
|
||||
"document_keyword",
|
||||
"id",
|
||||
"image_id",
|
||||
"important_keywords",
|
||||
"positions",
|
||||
"similarity",
|
||||
"term_similarity",
|
||||
"vector_similarity"
|
||||
],
|
||||
"contentPresent": true,
|
||||
"scoreRange": {
|
||||
"minimum": 1,
|
||||
"maximum": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"feature": "knowledge-compilation",
|
||||
"count": 0,
|
||||
"itemKeys": [],
|
||||
"contentPresent": false
|
||||
}
|
||||
],
|
||||
"features": {
|
||||
"graphRetrievalProbed": true,
|
||||
"knowledgeCompilationProbed": true,
|
||||
"metadataConditionProbed": false
|
||||
},
|
||||
"operations": [
|
||||
{
|
||||
"name": "catalog",
|
||||
"method": "GET",
|
||||
"path": "/api/v1/datasets",
|
||||
"status": 200,
|
||||
"durationMs": 732,
|
||||
"responseKeys": [
|
||||
"code",
|
||||
"data",
|
||||
"total_datasets"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "detail",
|
||||
"method": "GET",
|
||||
"path": "/api/v1/datasets",
|
||||
"status": 200,
|
||||
"durationMs": 157,
|
||||
"responseKeys": [
|
||||
"code",
|
||||
"data",
|
||||
"total_datasets"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "retrieve-baseline",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/retrieval",
|
||||
"status": 200,
|
||||
"durationMs": 417,
|
||||
"responseKeys": [
|
||||
"code",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "retrieve-graph",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/retrieval",
|
||||
"status": 200,
|
||||
"durationMs": 478,
|
||||
"responseKeys": [
|
||||
"code",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "retrieve-knowledge-compilation",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/retrieval",
|
||||
"status": 200,
|
||||
"durationMs": 331,
|
||||
"responseKeys": [
|
||||
"code",
|
||||
"data"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -358,11 +358,28 @@ knowledge:external-retrieval:test
|
||||
本节记录 2026-09-03 核对的初始公开接口。实施时应把每个受支持版本的真实响应保存为
|
||||
去敏 fixture,并以 fixture 和真实实例测试作为发布依据。
|
||||
|
||||
### 9.0 真实实例验证基线
|
||||
|
||||
2026-09-09 使用本机未入库的测试地址和 API Key 完成第一轮只读验证。验证只记录状态、
|
||||
字段名、数量和长度,不记录 API Key、知识库名称或片段正文。
|
||||
|
||||
| Provider | 实例证据 | 目录实测 | 检索实测 | 当前结论 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Dify `0.x` | 响应头 `x-version: 0.15.8` | 列表返回 200 和 3 项;详情 GET 返回 405 | 默认配置和显式当前配置均返回 200,结果为空 | 从目录项读取配置,手工 ID 通过检索验证;非空结果仍未验证 |
|
||||
| Dify `1.x` | 响应头 `x-version: 1.17.0` | 列表返回 200 和 1 项;详情 GET 返回 200 | 默认配置和显式当前配置均返回相同的 3 条非空结果 | 详情和 `records[].segment/document` 已验证;新增摘要索引、元数据、多模态和 Pipeline 能力字段 |
|
||||
| FastGPT | 响应未提供版本,只能确认当前托管实例 | 基址包含 `/api`;列表和详情均返回 200,列表 `data` 直接为数组,本次 1 个 Dataset | embedding 8 条、fullTextRecall 2 条、mixedRecall 7 条、mixedRecall + Rerank 9 条 | 三种检索模式和 Rerank 均已实测;当前结果包含 `q`,未包含 `a` |
|
||||
| RAGFlow | 响应未提供产品版本,Server 为 nginx,不能据此推断版本 | `GET /api/v1/datasets` 返回 200,共 28 项;17 项配置 GraphRAG,15 项有完成证据 | 选定已完成图谱的库,基础检索 0 条,`use_kg=true` 返回 1 条,Knowledge Compilation 0 条 | 图谱检索已产生区别于基础检索的结果;当前没有库具备 Knowledge Compilation 配置证据 |
|
||||
|
||||
当前 Node 测试进程设置了 `NODE_TLS_REJECT_UNAUTHORIZED=0`,因此两个 HTTPS 实例的请求
|
||||
使用了放宽的证书校验。这只能证明接口在当前测试环境可用,不能证明严格 TLS 校验通过。
|
||||
产品实现不得读取该进程级设置作为隐式默认;实例测试和保存必须按 8.1 节处理传输风险。
|
||||
|
||||
### 9.1 Dify
|
||||
|
||||
| 操作 | 接口 |
|
||||
| --- | --- |
|
||||
| 列表 | `GET /v1/datasets` |
|
||||
| 详情 | `GET /v1/datasets/{dataset_id}`,`0.15.8` 不支持、`1.17.0` 支持 |
|
||||
| 检索 | `POST /v1/datasets/{dataset_id}/retrieve` |
|
||||
| 认证 | `Authorization: Bearer <knowledge-api-key>` |
|
||||
|
||||
@@ -375,6 +392,26 @@ Adapter 只接受 Knowledge Service API Key,不调用应用 `/info`、Chat 或
|
||||
Dify 当前文档限制查询不超过 250 字符。GoodBuddy 不静默截断;超出时检索测试和聊天
|
||||
诊断返回 Provider 限制,并允许后续单独设计查询压缩策略。
|
||||
|
||||
两个实测版本共同返回 `retrieval_model_dict`,键包括搜索方式、Top K、阈值、Rerank 和
|
||||
权重。显式原样复用该配置均返回 200,证明覆盖 envelope 可用,但 UI 编辑后的每种组合仍
|
||||
需单独契约测试。
|
||||
|
||||
版本差异:
|
||||
|
||||
| 能力 | `0.15.8` | `1.17.0` | Adapter 和 UI 规则 |
|
||||
| --- | --- | --- | --- |
|
||||
| Dataset 详情 | GET 返回 405 | GET 返回 200 | 能力探测后调用;旧版从目录项读取配置,不能把 405 视为实例故障 |
|
||||
| 非空检索结构 | 本次未命中 | 默认和显式配置均返回 3 条 | 1.17 已验证 `records[].segment.content`、`segment.document.id/name`、位置和分数映射 |
|
||||
| 元数据 | 无 `doc_metadata` | 字段存在,当前库定义数为 0 | 有实际字段定义时才显示受控元数据过滤器;不提供自由 JSON |
|
||||
| 多模态与附件 | 未返回能力字段 | `is_multimodal` 和 `files` 字段可用,当前库非多模态且结果无附件 | 只显示远端状态;仅在结果实际带 `files` 时显示附件引用 |
|
||||
| 父子分块 | 未验证 | 返回 `child_chunks` 数组,本次为空 | 仅在实际非空时显示子块证据,不补造父子关系 |
|
||||
| 摘要索引 | 未返回能力字段 | 当前库配置了 `summary_index_setting`,本次结果 `summary` 为空 | 概览显示只读状态;仅在结果实际返回摘要时展示 |
|
||||
| Knowledge Pipeline | 未返回能力字段 | `pipeline_id/runtime_mode` 存在,当前库未配置 Pipeline | 只读显示,不提供运行或编辑 Pipeline 的操作 |
|
||||
|
||||
Dify 1.x 官方 RetrievalModel 还声明 `metadata_filtering_conditions`,并支持
|
||||
`keyword_search/semantic_search/full_text_search/hybrid_search`。当前实测库没有 Metadata
|
||||
字段,未执行空条件或猜测字段探测;该配置保持禁用,直到所选库详情返回可用字段定义。
|
||||
|
||||
### 9.2 FastGPT
|
||||
|
||||
| 操作 | 接口 |
|
||||
@@ -384,10 +421,17 @@ Dify 当前文档限制查询不超过 250 字符。GoodBuddy 不静默截断;
|
||||
| 检索 | `POST /api/core/dataset/searchTest` |
|
||||
| 认证 | `Authorization: Bearer <api-key>` |
|
||||
|
||||
列表响应同时包含文件夹和知识库,只有 `type = dataset` 可以绑定。检索映射 `datasetId`、
|
||||
列表响应同时包含文件夹和知识库,只有 `type = dataset` 可以绑定。当前实测列表的
|
||||
`data` 直接为数组,不是 `data.list`;Adapter 可以仅对已经实测的这两种明确 envelope
|
||||
做版本化解析,不递归猜测任意嵌套。检索映射 `datasetId`、
|
||||
`text`、Token `limit`、`similarity`、`searchMode` 和 `usingReRank`。初始版本不发送查询优化
|
||||
字段,避免调用 FastGPT 配置的 LLM。
|
||||
响应从 `q`、`a`、`sourceName`、`sourceId`、`collectionId` 和 `score` 构造片段与引用。
|
||||
当前实测响应的 `data` 包含 `list/duration/limit/searchMode/usingReRank/similarity`;结果项
|
||||
包含 `q/id/datasetId/collectionId/sourceName/sourceId/chunkIndex/score`,未返回 `a`。
|
||||
Adapter 以 `q` 为必需片段,`a` 仅在远端实际返回时追加,不能要求 `a` 存在。
|
||||
当前实例的 embedding、fullTextRecall、mixedRecall 和 mixedRecall + Rerank 均返回 200 和
|
||||
非空结果,因此这四种界面组合可以启用。查询扩展会调用模型,仍不在初始界面和探测脚本中
|
||||
启用。
|
||||
|
||||
FastGPT 4.15.0 起部署实例的 `/apidoc/devapi` 是接口事实来源,手工文档可能落后。支持矩阵
|
||||
必须按真实版本测试;不能因为健康页可访问就假定列表和检索 Schema 相同。`searchTest`
|
||||
@@ -405,17 +449,29 @@ FastGPT 4.15.0 起部署实例的 `/apidoc/devapi` 是接口事实来源,手
|
||||
检索映射 `question`、`dataset_ids`、`page_size`、`similarity_threshold`、
|
||||
`vector_similarity_weight`、`knn_top_k`、重排、`use_kg` 和
|
||||
`include_knowledge_compilation`。不发送已弃用的 `top_k`。响应从 `data.chunks` 和
|
||||
`data.doc_aggs` 提取片段、文档、位置和原始相似度。
|
||||
`data.doc_aggs` 提取片段、文档、位置和原始相似度。当前实测非空 chunk 使用
|
||||
`document_keyword` 表示文档名,并包含 `id/document_id/content/positions/similarity/`
|
||||
`term_similarity/vector_similarity`;初始 Adapter 应按该字段映射,不假定存在
|
||||
`document_name`。
|
||||
|
||||
图谱规则:
|
||||
|
||||
- `parser_config.graphrag` 或等价详情字段作为已有图谱的证据,不写回。
|
||||
- `parser_config.graphrag.use_graphrag = true` 只表示库配置了 GraphRAG;单独出现该配置不
|
||||
表示图谱已经构建完成。
|
||||
- `graphrag_task_finish_at` 为有效非零时间才作为图谱完成证据,不写回。
|
||||
- `use_kg` 是检索时开关,只在已有图谱和受支持版本均确认后发送。
|
||||
- Knowledge Compilation 与旧 GraphRAG 分别建模。
|
||||
- `include_knowledge_compilation` 使用本地保存的显式布尔值,初始默认 false;即使服务端
|
||||
默认 true,也不依赖字段省略获得隐式行为。
|
||||
- `compilation_template_group_id` 或后续版本的等价受控字段作为 Knowledge Compilation 已
|
||||
配置证据;仅仅请求返回 200 不能启用界面开关。
|
||||
- Provider 没有返回图节点或路径证据时,GoodBuddy 不生成图谱引用。
|
||||
|
||||
当前实例 28 个库中 17 个配置 GraphRAG、15 个存在完成证据。对一个已完成库使用同一查询,
|
||||
基础检索返回 0 条,`use_kg=true` 返回 1 条且原始相似度为 1,说明图谱开关对实际结果有
|
||||
影响。当前 28 个库均没有 Knowledge Compilation 配置证据;该请求虽返回 200 和零结果,
|
||||
界面仍必须禁用开关并显示“当前知识库未配置 Knowledge Compilation”。
|
||||
|
||||
## 10. 检索编排
|
||||
|
||||
```text
|
||||
@@ -544,6 +600,39 @@ Adapter 可以保存少量受控事实,例如“目录接口可用”“详情
|
||||
|
||||
Mock 或公开文档通过不能代替真实实例验收。
|
||||
|
||||
2026-09-09 基线已完成 Dify `0.15.8`/`1.17.0` 目录、详情差异和 1.17 非空检索结构,
|
||||
FastGPT/RAGFlow 产品版本、严格 TLS、401/403/404/429、超时和取消尚未完成,因此不满足
|
||||
本节完整验收条件。
|
||||
|
||||
### 14.6 可重复探测脚本
|
||||
|
||||
仓库提供 `node scripts/external-knowledge-probe.mjs`。默认从进程环境变量读取
|
||||
`GOODBUDDY_<PROVIDER>_BASE_URL/API_KEY`,本地开发也兼容当前两行格式的 `.env.dify`、
|
||||
`.env.fastgpt` 和 `.env.ragflow`;Dify 文件还支持多个 `标签/URL/API Key` 三行配置块,用于
|
||||
同一次报告比较多个版本。输出不包含地址、凭据、远端 ID、名称、查询和正文。
|
||||
|
||||
```text
|
||||
node scripts/external-knowledge-probe.mjs
|
||||
node scripts/external-knowledge-probe.mjs --provider=ragflow --extended
|
||||
node scripts/external-knowledge-probe.mjs --extended --output=<report.json>
|
||||
```
|
||||
|
||||
默认模式覆盖目录、详情和基础检索。`--extended` 额外执行 Dify 当前配置覆盖、FastGPT
|
||||
Rerank、RAGFlow GraphRAG 和 Knowledge Compilation 检索。脚本只有固定 GET/POST 读取与
|
||||
检索端点,不实现或调用远端创建、更新、上传和删除。
|
||||
|
||||
### 14.7 CRUD 验收边界
|
||||
|
||||
| 对象 | Create | Read | Update | Delete | 验收方式 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| GoodBuddy 外部实例 | 是 | 是 | 是 | 是 | SQLite、凭据存储、IPC 和 Renderer 集成测试 |
|
||||
| GoodBuddy 外部绑定 | 是 | 是 | 是 | 是 | SQLite、统一知识库列表、检索和删除确认测试 |
|
||||
| Provider 远端知识库与内容 | 否 | 目录、详情、检索 | 否 | 否 | 探测脚本确认只有只读端点;代码审查确认无写端点 |
|
||||
|
||||
FastGPT 等 Provider 的公开 API 确实提供远端知识库、集合和数据 CRUD,但这不属于当前产品
|
||||
接入。若未来要验证远端 CRUD,必须先修改 PRD,并使用可删除的专用测试实例或命名空间;
|
||||
不能在本轮三个既有实例上执行破坏性测试。
|
||||
|
||||
## 15. 实施顺序
|
||||
|
||||
这里的顺序只用于控制代码依赖,不裁剪产品设计:
|
||||
|
||||
@@ -258,6 +258,33 @@ Rerank 用量。`
|
||||
图谱状态”并禁用。Knowledge Compilation 单独显示为 `包含 Knowledge Compilation`,默认
|
||||
关闭,不把它和旧 GraphRAG 合成同一开关。
|
||||
|
||||
### 7.2 实测能力驱动的界面适配
|
||||
|
||||
界面不能只按 Provider 名称展示全部开关,必须按实例和当前远端知识库的能力证据处理:
|
||||
|
||||
| Provider | 已实测可启用 | 保持隐藏或禁用 | 界面依据 |
|
||||
| --- | --- | --- | --- |
|
||||
| Dify `0.15.8` | 使用知识库默认配置;展示 `retrieval_model_dict` 中已出现的检索方式、Top K、阈值和 Rerank 配置 | Metadata、多模态、摘要和 Pipeline 状态 | 目录项能力;该版本详情 GET 返回 405,不能把详情失败显示成实例不可用 |
|
||||
| Dify `1.17.0` | 默认配置、当前完整配置;详情中的文档统计、摘要索引、多模态、Metadata 和 Pipeline 只读状态 | 当前库没有字段定义时禁用 Metadata 条件;远端 Pipeline 和摘要配置不可编辑 | 详情 GET 和 3 条非空检索结果已实测;`child_chunks/files/summary` 仅按结果实际存在显示 |
|
||||
| FastGPT 当前实例 | embedding、全文召回、混合召回;Rerank | 查询扩展、扩展模型和背景文本 | 三种模式和 Rerank 均有 200 非空实测;查询扩展会调用模型,初始版本不启用 |
|
||||
| RAGFlow 当前实例 | 相似度、向量权重、KNN Top K;具备完成证据时启用图谱检索 | 没有独立配置证据时禁用 Knowledge Compilation;Metadata 条件仍隐藏 | `graphrag_task_finish_at` 有效才启用图谱;`compilation_template_group_id` 有效才启用 Knowledge Compilation |
|
||||
|
||||
RAGFlow 列表项应区分三个图谱状态:“未配置”“构建中或未完成”“可用于图谱检索”。不能
|
||||
仅因 `parser_config.graphrag.use_graphrag = true` 就显示为可用。当前实测中 17 个库配置了
|
||||
GraphRAG,只有 15 个存在完成证据。
|
||||
|
||||
Knowledge Compilation 的请求被服务器接受但当前没有库提供配置证据,因此开关显示为禁用,
|
||||
说明为“当前知识库未配置 Knowledge Compilation”。返回 200 或零结果都不能改变此状态。
|
||||
|
||||
Dify 实例行持续显示探测版本。`0.15.8` 的详情请求返回 405 时显示“此版本从目录读取配置”,
|
||||
不显示错误状态;`1.17.0` 详情页增加只读能力行:文档可用数、Metadata 字段数、多模态、
|
||||
摘要索引和 Knowledge Pipeline。没有配置的能力显示“未配置”,不隐藏成未知,也不提供会
|
||||
写回 Dify 的编辑操作。
|
||||
|
||||
Dify 1.x 的 Metadata 条件使用受控字段编辑器:先从 `doc_metadata` 选择字段,再按字段类型
|
||||
提供比较操作和值输入。当前库字段数为 0 时控件禁用并显示“当前知识库没有 Metadata 字段”。
|
||||
不得允许自由 JSON,也不得把 `attachment_ids`、Pipeline 或摘要 Prompt 暴露为检索输入。
|
||||
|
||||
## 8. 外部知识库详情
|
||||
|
||||
选中外部绑定后,现有详情区按来源类型裁剪:
|
||||
|
||||
@@ -15,6 +15,36 @@
|
||||
|
||||
## 验证证据
|
||||
|
||||
2026-09-09 外部知识库适配基础验证:
|
||||
|
||||
- 使用本机未入库的测试配置累计执行 107 次只读请求,其中更新后的最终归档脚本执行 19 次;脚本
|
||||
调试产生的结果只写入批准的临时目录。三套目录、FastGPT 详情和 RAGFlow 详情均成功;
|
||||
Dify `0.15.8` 详情 GET 返回 405,Dify `1.17.0` 详情 GET 返回 200。验证过程未输出 API
|
||||
Key、知识库名称和业务正文。
|
||||
- Dify 响应头确认两个版本为 `0.15.8` 和 `1.17.0`。1.17 默认与显式当前配置检索均返回
|
||||
3 条非空结果,已验证 `segment/document` 字段;详情新增 Metadata、多模态、摘要索引和
|
||||
Pipeline 能力字段。FastGPT 与 RAGFlow 响应未提供产品版本。实测还确认
|
||||
FastGPT 列表 `data` 为直接数组、结果 `a` 可缺失,RAGFlow 文档名字段为
|
||||
`document_keyword`。详细基线见[外部知识库技术设计 9.0 节](./external-knowledge-technical-design.md#90-真实实例验证基线)。
|
||||
- 新增 `scripts/external-knowledge-probe.mjs`,可重复生成
|
||||
不含地址、凭据、远端 ID、名称、查询和正文的能力报告。最终报告归档在
|
||||
[`external-knowledge-probe-baseline.json`](./external-knowledge-probe-baseline.json)。
|
||||
- FastGPT 的 embedding、fullTextRecall、mixedRecall 和 mixedRecall + Rerank 均返回非空
|
||||
结果。RAGFlow 当前 28 个库中 17 个配置 GraphRAG、15 个存在完成证据;已完成库在基础
|
||||
检索为零结果时,图谱检索返回 1 条结果。当前没有库提供 Knowledge Compilation 配置证据。
|
||||
- 新增 `src/main/knowledge/external/external-knowledge-client.ts`,固定实现三种 Provider 的
|
||||
只读目录和检索端点、地址规范化、超时、响应大小限制、HTTP 错误分类和结果标准化。
|
||||
- `npx vitest run src/main/knowledge/external/external-knowledge-client.test.ts`:1 个测试文件、
|
||||
5 项测试通过。
|
||||
- `npx vitest run tests/external-knowledge-probe-report.test.ts src/main/knowledge/external/external-knowledge-client.test.ts`:
|
||||
2 个测试文件、7 项测试通过;覆盖归档报告脱敏、只读操作和特色能力证据。
|
||||
- `npx eslint src/main/knowledge/external/external-knowledge-client.ts src/main/knowledge/external/external-knowledge-client.test.ts`:通过。
|
||||
- `npm run typecheck`:被当前工作区已有的 `src/main/ipc.test.ts:7694` tuple 类型错误阻断;
|
||||
新增适配器、探测脚本和报告测试的定向测试与 ESLint 已通过。
|
||||
- 当前 Node 测试环境设置了 `NODE_TLS_REJECT_UNAUTHORIZED=0`,Dify 和 FastGPT 请求实际
|
||||
使用了放宽的 TLS 校验。产品接入不能继承该进程级默认,保存或测试实例时仍需按技术设计
|
||||
明确处理传输风险。
|
||||
|
||||
2026-09-05 最终验证:
|
||||
|
||||
- `npm test`:325 个测试文件、3511 项测试通过;8 个文件、55 项手动或集成测试按既有
|
||||
@@ -34,7 +64,8 @@
|
||||
|
||||
## 剩余工作
|
||||
|
||||
- 外部 Dify、FastGPT 和 RAGFlow 知识库接入仍为规划中能力。
|
||||
- 外部 Dify、FastGPT 和 RAGFlow 已完成 Main 适配基础;实例凭据持久化、绑定迁移、IPC、
|
||||
Renderer 管理界面、`retrieveMany`、聊天预检索和 MCP 网关仍未接入,当前不能由用户使用。
|
||||
- 扫描 PDF 的真实 OCR 验收依赖用户已安装并校验的 OCR 模型;本次 DOCX/PDF 回归覆盖
|
||||
原生文本 PDF,不宣称覆盖扫描件 OCR。
|
||||
- PDF.js 在测试环境会提示未配置 `standardFontDataUrl`;文本、页码定位与检索结果不受
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import process from 'node:process'
|
||||
import { URL } from 'node:url'
|
||||
import { TextDecoder } from 'node:util'
|
||||
|
||||
const fetch = globalThis.fetch
|
||||
const AbortSignal = globalThis.AbortSignal
|
||||
|
||||
const providers = ['dify', 'fastgpt', 'ragflow']
|
||||
const arguments_ = new Map(
|
||||
process.argv.slice(2).map((argument) => {
|
||||
const [key, ...value] = argument.replace(/^--/, '').split('=')
|
||||
return [key, value.join('=') || 'true']
|
||||
})
|
||||
)
|
||||
const selectedProvider = arguments_.get('provider') ?? 'all'
|
||||
const query = arguments_.get('query') ?? '知识库'
|
||||
const extended = arguments_.get('extended') === 'true'
|
||||
const outputPath = arguments_.get('output')
|
||||
|
||||
if (selectedProvider !== 'all' && !providers.includes(selectedProvider)) {
|
||||
throw new Error(`Unsupported provider: ${selectedProvider}`)
|
||||
}
|
||||
if (!query.trim() || query.length > 250) {
|
||||
throw new Error('Probe query must contain 1 to 250 characters')
|
||||
}
|
||||
|
||||
const asRecord = (value) =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value
|
||||
: undefined
|
||||
const objectKeys = (value) => Object.keys(asRecord(value) ?? {}).sort()
|
||||
const unionKeys = (values) => [...new Set(values.flatMap(objectKeys))].sort()
|
||||
const finiteNumbers = (values) => values.filter(Number.isFinite)
|
||||
|
||||
async function configurations(provider) {
|
||||
const prefix = `GOODBUDDY_${provider.toUpperCase()}`
|
||||
const environmentUrl = process.env[`${prefix}_BASE_URL`]?.trim()
|
||||
const environmentKey = process.env[`${prefix}_API_KEY`]?.trim()
|
||||
if (environmentUrl && environmentKey) {
|
||||
return [{ baseUrl: environmentUrl, apiKey: environmentKey }]
|
||||
}
|
||||
const file = resolve(`.env.${provider}`)
|
||||
const lines = (await readFile(file, 'utf8'))
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
if (lines.length === 2) {
|
||||
return [{ baseUrl: lines[0], apiKey: lines[1] }]
|
||||
}
|
||||
if (provider === 'dify' && lines.length % 3 === 0) {
|
||||
return Array.from({ length: lines.length / 3 }, (_, index) => {
|
||||
const label = lines[index * 3].replace(/:$/, '')
|
||||
const profile = /^dify[0-9.x-]+$/i.test(label)
|
||||
? label.replace(/^dify/i, '')
|
||||
: `profile-${index + 1}`
|
||||
return {
|
||||
profile,
|
||||
baseUrl: lines[index * 3 + 1],
|
||||
apiKey: lines[index * 3 + 2]
|
||||
}
|
||||
})
|
||||
}
|
||||
throw new Error(
|
||||
`${file} must contain one URL/key pair or labeled Dify URL/key triples`
|
||||
)
|
||||
}
|
||||
|
||||
function baseUrl(provider, input) {
|
||||
const url = new URL(input)
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new Error(`${provider} URL must use HTTP or HTTPS`)
|
||||
}
|
||||
if (url.username || url.password || url.search || url.hash) {
|
||||
throw new Error(`${provider} URL cannot contain credentials, query, or fragment`)
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/+$/, '')
|
||||
if (provider === 'dify') url.pathname = url.pathname.replace(/\/v1$/, '')
|
||||
if (provider === 'fastgpt' && !url.pathname.endsWith('/api')) url.pathname += '/api'
|
||||
if (provider === 'ragflow') url.pathname = url.pathname.replace(/\/api\/v1$/, '')
|
||||
return url
|
||||
}
|
||||
|
||||
function endpoint(base, path) {
|
||||
const url = new URL(base)
|
||||
url.pathname = `${url.pathname}${path}`.replace(/\/{2,}/g, '/')
|
||||
return url
|
||||
}
|
||||
|
||||
async function boundedJson(response, maximumBytes = 5_000_000) {
|
||||
const bytes = new Uint8Array(await response.arrayBuffer())
|
||||
if (bytes.byteLength > maximumBytes) throw new Error('response-too-large')
|
||||
return JSON.parse(new TextDecoder().decode(bytes))
|
||||
}
|
||||
|
||||
async function request(context, name, url, init = {}) {
|
||||
const startedAt = performance.now()
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
headers: {
|
||||
Authorization: `Bearer ${context.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
...(init.body ? { 'Content-Type': 'application/json' } : {})
|
||||
}
|
||||
})
|
||||
const body = await boundedJson(response)
|
||||
context.operations.push({
|
||||
name,
|
||||
method: init.method ?? 'GET',
|
||||
path: url.pathname
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, '{id}')
|
||||
.replace(/[0-9a-f]{24}/gi, '{id}'),
|
||||
status: response.status,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
responseKeys: objectKeys(body)
|
||||
})
|
||||
return { response, body }
|
||||
} catch (error) {
|
||||
context.operations.push({
|
||||
name,
|
||||
method: init.method ?? 'GET',
|
||||
path: url.pathname
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, '{id}')
|
||||
.replace(/[0-9a-f]{24}/gi, '{id}'),
|
||||
status: 'failed',
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
error: error?.name ?? 'Error'
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function resultShape(items, scoreKey) {
|
||||
const scores = finiteNumbers(items.map((item) => asRecord(item)?.[scoreKey]))
|
||||
return {
|
||||
count: items.length,
|
||||
itemKeys: unionKeys(items),
|
||||
contentPresent: items.some((item) => {
|
||||
const record = asRecord(item)
|
||||
return typeof record?.content === 'string' || typeof record?.q === 'string'
|
||||
}),
|
||||
scoreRange: scores.length > 0
|
||||
? { minimum: Math.min(...scores), maximum: Math.max(...scores) }
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
function difyResultShape(records) {
|
||||
const segments = records.map((record) => asRecord(record)?.segment)
|
||||
const documents = segments.map((segment) => asRecord(segment)?.document)
|
||||
const childChunks = records.flatMap((record) => {
|
||||
const value = asRecord(record)?.child_chunks
|
||||
return Array.isArray(value) ? value : []
|
||||
})
|
||||
const files = records.flatMap((record) => {
|
||||
const value = asRecord(record)?.files
|
||||
return Array.isArray(value) ? value : []
|
||||
})
|
||||
const scores = finiteNumbers(records.map((record) => asRecord(record)?.score))
|
||||
return {
|
||||
count: records.length,
|
||||
itemKeys: unionKeys(records),
|
||||
segmentKeys: unionKeys(segments),
|
||||
documentKeys: unionKeys(documents),
|
||||
childChunkKeys: unionKeys(childChunks),
|
||||
fileKeys: unionKeys(files),
|
||||
contentPresent: segments.some((segment) =>
|
||||
typeof asRecord(segment)?.content === 'string'
|
||||
),
|
||||
childContentPresent: childChunks.some((chunk) =>
|
||||
typeof asRecord(chunk)?.content === 'string'
|
||||
),
|
||||
summaryPresent: records.some((record) =>
|
||||
typeof asRecord(record)?.summary === 'string'
|
||||
),
|
||||
scoreRange: scores.length > 0
|
||||
? { minimum: Math.min(...scores), maximum: Math.max(...scores) }
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function probeDify(configuration_) {
|
||||
const context = { apiKey: configuration_.apiKey, operations: [] }
|
||||
const base = baseUrl('dify', configuration_.baseUrl)
|
||||
const catalogUrl = endpoint(base, '/v1/datasets')
|
||||
catalogUrl.searchParams.set('page', '1')
|
||||
catalogUrl.searchParams.set('limit', '100')
|
||||
const catalog = await request(context, 'catalog', catalogUrl)
|
||||
const catalogBody = asRecord(catalog?.body)
|
||||
const items = Array.isArray(catalogBody?.data) ? catalogBody.data : []
|
||||
const first = asRecord(items[0])
|
||||
let detail
|
||||
const retrievals = []
|
||||
if (typeof first?.id === 'string') {
|
||||
detail = await request(
|
||||
context,
|
||||
'detail',
|
||||
endpoint(base, `/v1/datasets/${encodeURIComponent(first.id)}`)
|
||||
)
|
||||
const retrieval = await request(
|
||||
context,
|
||||
'retrieve-defaults',
|
||||
endpoint(base, `/v1/datasets/${encodeURIComponent(first.id)}/retrieve`),
|
||||
{ method: 'POST', body: JSON.stringify({ query }) }
|
||||
)
|
||||
const retrievalBody = asRecord(retrieval?.body)
|
||||
const records = Array.isArray(retrievalBody?.records) ? retrievalBody.records : []
|
||||
retrievals.push({ mode: 'dataset-defaults', ...difyResultShape(records) })
|
||||
if (extended && asRecord(first.retrieval_model_dict)) {
|
||||
const override = await request(
|
||||
context,
|
||||
'retrieve-explicit-config',
|
||||
endpoint(base, `/v1/datasets/${encodeURIComponent(first.id)}/retrieve`),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
retrieval_model: first.retrieval_model_dict
|
||||
})
|
||||
}
|
||||
)
|
||||
const overrideBody = asRecord(override?.body)
|
||||
const overrideRecords = Array.isArray(overrideBody?.records) ? overrideBody.records : []
|
||||
retrievals.push({ mode: 'explicit-current-config', ...difyResultShape(overrideRecords) })
|
||||
}
|
||||
}
|
||||
return {
|
||||
provider: 'dify',
|
||||
profile: configuration_.profile,
|
||||
detectedVersion: catalog?.response.headers.get('x-version') ?? undefined,
|
||||
transport: {
|
||||
protocol: base.protocol,
|
||||
tlsVerificationDisabled: base.protocol === 'https:'
|
||||
? process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0'
|
||||
: undefined
|
||||
},
|
||||
catalog: {
|
||||
count: items.length,
|
||||
total: Number.isFinite(catalogBody?.total) ? catalogBody.total : undefined,
|
||||
itemKeys: unionKeys(items),
|
||||
retrievalModelKeys: unionKeys(items.map((item) => asRecord(item)?.retrieval_model_dict)),
|
||||
indexingTechniques: [...new Set(items.map((item) => asRecord(item)?.indexing_technique).filter((value) => typeof value === 'string'))].sort(),
|
||||
metadataSchemaCount: items.filter((item) => {
|
||||
const value = asRecord(item)?.doc_metadata
|
||||
return Array.isArray(value) && value.length > 0
|
||||
}).length,
|
||||
multimodalCount: items.filter((item) => asRecord(item)?.is_multimodal === true).length,
|
||||
pipelineConfiguredCount: items.filter((item) => typeof asRecord(item)?.pipeline_id === 'string').length,
|
||||
summaryIndexConfiguredCount: items.filter((item) => asRecord(item)?.summary_index_setting !== null && asRecord(item)?.summary_index_setting !== undefined).length
|
||||
},
|
||||
detail: { keys: objectKeys(detail?.body) },
|
||||
retrievals,
|
||||
features: {
|
||||
datasetDefaults: true,
|
||||
detailSupported: detail?.response.ok === true,
|
||||
retrievalOverrideObserved: items.some((item) => asRecord(item)?.retrieval_model_dict !== undefined),
|
||||
retrievalOverrideProbed: retrievals.some((item) => item.mode === 'explicit-current-config'),
|
||||
nonEmptyResultObserved: retrievals.some((item) => item.count > 0)
|
||||
},
|
||||
operations: context.operations
|
||||
}
|
||||
}
|
||||
|
||||
async function probeFastGpt(configuration_) {
|
||||
const context = { apiKey: configuration_.apiKey, operations: [] }
|
||||
const base = baseUrl('fastgpt', configuration_.baseUrl)
|
||||
const catalog = await request(
|
||||
context,
|
||||
'catalog',
|
||||
endpoint(base, '/core/dataset/list'),
|
||||
{ method: 'POST', body: JSON.stringify({ parentId: '' }) }
|
||||
)
|
||||
const catalogBody = asRecord(catalog?.body)
|
||||
const rawData = catalogBody?.data
|
||||
const nestedData = asRecord(rawData)
|
||||
const items = Array.isArray(rawData)
|
||||
? rawData
|
||||
: Array.isArray(nestedData?.list)
|
||||
? nestedData.list
|
||||
: []
|
||||
const datasets = items.filter((item) => asRecord(item)?.type === 'dataset')
|
||||
const first = asRecord(datasets[0])
|
||||
let detail
|
||||
const retrievals = []
|
||||
if (typeof first?._id === 'string') {
|
||||
const detailUrl = endpoint(base, '/core/dataset/detail')
|
||||
detailUrl.searchParams.set('id', first._id)
|
||||
detail = await request(context, 'detail', detailUrl)
|
||||
for (const searchMode of ['embedding', 'fullTextRecall', 'mixedRecall']) {
|
||||
const result = await request(
|
||||
context,
|
||||
`retrieve-${searchMode}`,
|
||||
endpoint(base, '/core/dataset/searchTest'),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
datasetId: first._id,
|
||||
text: query,
|
||||
limit: 1_000,
|
||||
similarity: 0,
|
||||
searchMode,
|
||||
usingReRank: false
|
||||
})
|
||||
}
|
||||
)
|
||||
const body = asRecord(result?.body)
|
||||
const data = asRecord(body?.data)
|
||||
const list = Array.isArray(body?.data)
|
||||
? body.data
|
||||
: Array.isArray(data?.list)
|
||||
? data.list
|
||||
: []
|
||||
retrievals.push({ searchMode, ...resultShape(list, 'score') })
|
||||
}
|
||||
if (extended) {
|
||||
const result = await request(
|
||||
context,
|
||||
'retrieve-rerank',
|
||||
endpoint(base, '/core/dataset/searchTest'),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
datasetId: first._id,
|
||||
text: query,
|
||||
limit: 1_000,
|
||||
similarity: 0,
|
||||
searchMode: 'mixedRecall',
|
||||
usingReRank: true
|
||||
})
|
||||
}
|
||||
)
|
||||
const body = asRecord(result?.body)
|
||||
const data = asRecord(body?.data)
|
||||
const list = Array.isArray(body?.data)
|
||||
? body.data
|
||||
: Array.isArray(data?.list)
|
||||
? data.list
|
||||
: []
|
||||
retrievals.push({
|
||||
searchMode: 'mixedRecall',
|
||||
usingRerank: true,
|
||||
...resultShape(list, 'score')
|
||||
})
|
||||
}
|
||||
}
|
||||
const detailData = asRecord(asRecord(detail?.body)?.data)
|
||||
return {
|
||||
provider: 'fastgpt',
|
||||
profile: configuration_.profile,
|
||||
detectedVersion: undefined,
|
||||
transport: {
|
||||
protocol: base.protocol,
|
||||
tlsVerificationDisabled: base.protocol === 'https:'
|
||||
? process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0'
|
||||
: undefined
|
||||
},
|
||||
catalog: {
|
||||
count: datasets.length,
|
||||
envelope: Array.isArray(rawData) ? 'data-array' : 'data-object',
|
||||
itemKeys: unionKeys(items),
|
||||
itemTypes: [...new Set(items.map((item) => asRecord(item)?.type).filter((value) => typeof value === 'string'))].sort()
|
||||
},
|
||||
detail: {
|
||||
keys: objectKeys(detailData),
|
||||
vectorModelKeys: objectKeys(detailData?.vectorModel)
|
||||
},
|
||||
retrievals,
|
||||
features: {
|
||||
searchModes: [...new Set(retrievals.filter((item) => item.count >= 0).map((item) => item.searchMode))],
|
||||
rerankProbed: extended,
|
||||
extensionQueryProbed: false
|
||||
},
|
||||
operations: context.operations
|
||||
}
|
||||
}
|
||||
|
||||
async function probeRagflow(configuration_) {
|
||||
const context = { apiKey: configuration_.apiKey, operations: [] }
|
||||
const base = baseUrl('ragflow', configuration_.baseUrl)
|
||||
const catalogUrl = endpoint(base, '/api/v1/datasets')
|
||||
catalogUrl.searchParams.set('page', '1')
|
||||
catalogUrl.searchParams.set('page_size', '100')
|
||||
const catalog = await request(context, 'catalog', catalogUrl)
|
||||
const catalogBody = asRecord(catalog?.body)
|
||||
const items = Array.isArray(catalogBody?.data) ? catalogBody.data : []
|
||||
const graphConfiguredItems = items.filter((item) => {
|
||||
const record = asRecord(item)
|
||||
const graph = asRecord(asRecord(record?.parser_config)?.graphrag)
|
||||
return graph?.use_graphrag === true
|
||||
})
|
||||
const graphCompletedItems = items.filter((item) => {
|
||||
const value = asRecord(item)?.graphrag_task_finish_at
|
||||
return (typeof value === 'number' && value > 0) || (typeof value === 'string' && value.length > 0)
|
||||
})
|
||||
const compilationConfiguredItems = items.filter((item) => {
|
||||
const value = asRecord(asRecord(item)?.parser_config)?.compilation_template_group_id
|
||||
return typeof value === 'string' && value.length > 0
|
||||
})
|
||||
const first = asRecord(graphCompletedItems[0] ?? graphConfiguredItems[0] ?? items[0])
|
||||
let detail
|
||||
const retrievals = []
|
||||
if (typeof first?.id === 'string') {
|
||||
const detailUrl = endpoint(base, '/api/v1/datasets')
|
||||
detailUrl.searchParams.set('id', first.id)
|
||||
detail = await request(context, 'detail', detailUrl)
|
||||
for (const feature of [
|
||||
{ name: 'baseline', use_kg: false, include_knowledge_compilation: false },
|
||||
...(extended
|
||||
? [
|
||||
{ name: 'graph', use_kg: true, include_knowledge_compilation: false },
|
||||
{ name: 'knowledge-compilation', use_kg: false, include_knowledge_compilation: true }
|
||||
]
|
||||
: [])
|
||||
]) {
|
||||
const result = await request(
|
||||
context,
|
||||
`retrieve-${feature.name}`,
|
||||
endpoint(base, '/api/v1/retrieval'),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
question: query,
|
||||
dataset_ids: [first.id],
|
||||
page: 1,
|
||||
page_size: 6,
|
||||
similarity_threshold: 0,
|
||||
vector_similarity_weight: 0.3,
|
||||
knn_top_k: 128,
|
||||
use_kg: feature.use_kg,
|
||||
include_knowledge_compilation: feature.include_knowledge_compilation
|
||||
})
|
||||
}
|
||||
)
|
||||
const data = asRecord(asRecord(result?.body)?.data)
|
||||
const chunks = Array.isArray(data?.chunks) ? data.chunks : []
|
||||
retrievals.push({ feature: feature.name, ...resultShape(chunks, 'similarity') })
|
||||
}
|
||||
}
|
||||
const detailBody = asRecord(detail?.body)
|
||||
const detailData = Array.isArray(detailBody?.data) ? detailBody.data[0] : detailBody?.data
|
||||
return {
|
||||
provider: 'ragflow',
|
||||
profile: configuration_.profile,
|
||||
detectedVersion: undefined,
|
||||
transport: {
|
||||
protocol: base.protocol,
|
||||
tlsVerificationDisabled: base.protocol === 'https:'
|
||||
? process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0'
|
||||
: undefined
|
||||
},
|
||||
catalog: {
|
||||
count: items.length,
|
||||
total: Number.isFinite(catalogBody?.total_datasets) ? catalogBody.total_datasets : undefined,
|
||||
itemKeys: unionKeys(items),
|
||||
parserConfigKeys: unionKeys(items.map((item) => asRecord(item)?.parser_config)),
|
||||
graphConfiguredCount: graphConfiguredItems.length,
|
||||
graphCompletedCount: graphCompletedItems.length,
|
||||
knowledgeCompilationConfiguredCount: compilationConfiguredItems.length
|
||||
},
|
||||
detail: { keys: objectKeys(detailData) },
|
||||
retrievals,
|
||||
features: {
|
||||
graphRetrievalProbed: extended,
|
||||
knowledgeCompilationProbed: extended,
|
||||
metadataConditionProbed: false
|
||||
},
|
||||
operations: context.operations
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProviders = selectedProvider === 'all' ? providers : [selectedProvider]
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
probedAt: new Date().toISOString(),
|
||||
mode: extended ? 'extended-read-only' : 'basic-read-only',
|
||||
queryCharacters: query.length,
|
||||
privacy: 'No credentials, origins, IDs, names, queries, or content are included.',
|
||||
providers: []
|
||||
}
|
||||
const sensitiveValues = []
|
||||
|
||||
for (const provider of selectedProviders) {
|
||||
for (const config of await configurations(provider)) {
|
||||
sensitiveValues.push(config.baseUrl, config.apiKey)
|
||||
report.providers.push(
|
||||
provider === 'dify'
|
||||
? await probeDify(config)
|
||||
: provider === 'fastgpt'
|
||||
? await probeFastGpt(config)
|
||||
: await probeRagflow(config)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const serialized = `${JSON.stringify(report, null, 2)}\n`
|
||||
const unsafeOutput =
|
||||
sensitiveValues.some((value) => value && serialized.includes(value)) ||
|
||||
/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i.test(serialized) ||
|
||||
/\b[0-9a-f]{24}\b/i.test(serialized) ||
|
||||
['PUT', 'PATCH', 'DELETE'].some((method) => serialized.includes(`"method": "${method}"`))
|
||||
if (unsafeOutput) {
|
||||
throw new Error('Probe report failed its redaction and read-only audit')
|
||||
}
|
||||
if (outputPath) {
|
||||
const target = resolve(outputPath)
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await writeFile(target, serialized, 'utf8')
|
||||
globalThis.console.log(`Wrote redacted probe report to ${target}`)
|
||||
} else {
|
||||
process.stdout.write(serialized)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ExternalKnowledgeClient,
|
||||
ExternalKnowledgeError
|
||||
} from './external-knowledge-client'
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200): Response =>
|
||||
Response.json(body, { status })
|
||||
|
||||
describe('ExternalKnowledgeClient', () => {
|
||||
it('lists and retrieves Dify knowledge without sending retrieval overrides', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
data: [{ id: 'dataset-1', name: 'Policies', description: 'Internal policies' }],
|
||||
total: 1,
|
||||
has_more: false
|
||||
}))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
query: 'leave policy',
|
||||
records: [{
|
||||
score: 0.91,
|
||||
segment: {
|
||||
id: 'segment-1',
|
||||
content: 'Annual leave policy',
|
||||
position: 2,
|
||||
document: { id: 'document-1', name: 'HR handbook' }
|
||||
}
|
||||
}]
|
||||
}))
|
||||
const client = new ExternalKnowledgeClient({
|
||||
provider: 'dify',
|
||||
baseUrl: 'https://dify.example/v1',
|
||||
apiKey: 'secret',
|
||||
fetcher
|
||||
})
|
||||
|
||||
await expect(client.listKnowledgeBases()).resolves.toMatchObject({
|
||||
items: [{ id: 'dataset-1', name: 'Policies' }],
|
||||
hasMore: false
|
||||
})
|
||||
await expect(client.retrieve('dataset-1', 'leave policy', {
|
||||
provider: 'dify',
|
||||
useDatasetDefaults: true
|
||||
})).resolves.toEqual([expect.objectContaining({
|
||||
documentTitle: 'HR handbook',
|
||||
snippet: 'Annual leave policy',
|
||||
remoteChunkId: 'segment-1',
|
||||
providerScore: 0.91
|
||||
})])
|
||||
|
||||
expect(String(fetcher.mock.calls[0]?.[0])).toBe('https://dify.example/v1/datasets?page=1&limit=100')
|
||||
expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({ query: 'leave policy' })
|
||||
})
|
||||
|
||||
it('filters FastGPT folders and maps searchTest results', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
code: 200,
|
||||
data: [
|
||||
{ _id: 'folder-1', name: 'Folder', type: 'folder' },
|
||||
{ _id: 'dataset-1', name: 'Products', intro: 'Product docs', type: 'dataset' }
|
||||
]
|
||||
}))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
code: 200,
|
||||
data: {
|
||||
list: [{
|
||||
id: 'chunk-1',
|
||||
q: 'Question',
|
||||
a: 'Answer',
|
||||
sourceName: 'Manual',
|
||||
sourceId: 'document-1',
|
||||
chunkIndex: 3,
|
||||
score: 0.8
|
||||
}]
|
||||
}
|
||||
}))
|
||||
const client = new ExternalKnowledgeClient({
|
||||
provider: 'fastgpt',
|
||||
baseUrl: 'https://fastgpt.example/api',
|
||||
apiKey: 'secret',
|
||||
fetcher
|
||||
})
|
||||
|
||||
await expect(client.listKnowledgeBases()).resolves.toMatchObject({
|
||||
items: [{ id: 'dataset-1', name: 'Products' }]
|
||||
})
|
||||
await expect(client.retrieve('dataset-1', 'pricing', {
|
||||
provider: 'fastgpt',
|
||||
searchMode: 'mixedRecall',
|
||||
tokenLimit: 2_000,
|
||||
similarity: 0.2,
|
||||
usingRerank: false
|
||||
})).resolves.toEqual([expect.objectContaining({
|
||||
snippet: 'Question\nAnswer',
|
||||
remoteDocumentId: 'document-1',
|
||||
remoteChunkId: 'chunk-1'
|
||||
})])
|
||||
|
||||
expect(String(fetcher.mock.calls[1]?.[0])).toBe('https://fastgpt.example/api/core/dataset/searchTest')
|
||||
})
|
||||
|
||||
it('maps RAGFlow graph capability and retrieval fields', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
code: 0,
|
||||
total_datasets: 1,
|
||||
data: [{
|
||||
id: 'dataset-1',
|
||||
name: 'Engineering',
|
||||
graphrag_task_finish_at: 1_788_880_000,
|
||||
parser_config: { graphrag: { use_graphrag: true } }
|
||||
}]
|
||||
}))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
code: 0,
|
||||
data: {
|
||||
chunks: [{
|
||||
id: 'chunk-1',
|
||||
document_id: 'document-1',
|
||||
document_keyword: 'Runbook',
|
||||
content: 'Deployment steps',
|
||||
similarity: 0.72,
|
||||
positions: [[1, 2, 3, 4, 5]]
|
||||
}],
|
||||
doc_aggs: [],
|
||||
total: 1
|
||||
}
|
||||
}))
|
||||
const client = new ExternalKnowledgeClient({
|
||||
provider: 'ragflow',
|
||||
baseUrl: 'http://ragflow.internal:7080/api/v1',
|
||||
apiKey: 'secret',
|
||||
fetcher
|
||||
})
|
||||
|
||||
await expect(client.listKnowledgeBases()).resolves.toMatchObject({
|
||||
items: [{ id: 'dataset-1', graphEnabled: true }]
|
||||
})
|
||||
await expect(client.retrieve('dataset-1', 'deploy', {
|
||||
provider: 'ragflow',
|
||||
similarityThreshold: 0.2,
|
||||
vectorSimilarityWeight: 0.3,
|
||||
knnTopK: 128,
|
||||
useKg: true,
|
||||
includeKnowledgeCompilation: false
|
||||
})).resolves.toEqual([expect.objectContaining({
|
||||
documentTitle: 'Runbook',
|
||||
snippet: 'Deployment steps',
|
||||
providerScore: 0.72
|
||||
})])
|
||||
|
||||
const request = JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))
|
||||
expect(request).toMatchObject({
|
||||
dataset_ids: ['dataset-1'],
|
||||
knn_top_k: 128,
|
||||
use_kg: true,
|
||||
include_knowledge_compilation: false
|
||||
})
|
||||
expect(request).not.toHaveProperty('top_k')
|
||||
})
|
||||
|
||||
it('maps authentication failures without including response bodies', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response('credential details', { status: 401 })
|
||||
)
|
||||
const client = new ExternalKnowledgeClient({
|
||||
provider: 'dify',
|
||||
baseUrl: 'https://dify.example',
|
||||
apiKey: 'secret',
|
||||
fetcher
|
||||
})
|
||||
|
||||
await expect(client.listKnowledgeBases()).rejects.toEqual(
|
||||
expect.objectContaining<Partial<ExternalKnowledgeError>>({
|
||||
code: 'EXTERNAL_KB_AUTH',
|
||||
message: 'External knowledge authentication failed'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects oversized and invalid JSON responses', async () => {
|
||||
const oversized = new ExternalKnowledgeClient({
|
||||
provider: 'dify',
|
||||
baseUrl: 'https://dify.example',
|
||||
apiKey: 'secret',
|
||||
maximumResponseBytes: 5,
|
||||
fetcher: vi.fn<typeof fetch>().mockResolvedValue(new Response('123456'))
|
||||
})
|
||||
await expect(oversized.listKnowledgeBases()).rejects.toMatchObject({
|
||||
code: 'EXTERNAL_KB_INVALID_RESPONSE'
|
||||
})
|
||||
|
||||
const invalid = new ExternalKnowledgeClient({
|
||||
provider: 'dify',
|
||||
baseUrl: 'https://dify.example',
|
||||
apiKey: 'secret',
|
||||
fetcher: vi.fn<typeof fetch>().mockResolvedValue(new Response('not-json'))
|
||||
})
|
||||
await expect(invalid.listKnowledgeBases()).rejects.toMatchObject({
|
||||
code: 'EXTERNAL_KB_INVALID_RESPONSE'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,389 @@
|
||||
export type ExternalKnowledgeProvider = 'dify' | 'fastgpt' | 'ragflow'
|
||||
|
||||
export type ExternalKnowledgeCatalogItem = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
graphEnabled?: boolean
|
||||
}
|
||||
|
||||
export type ExternalKnowledgeCatalogPage = {
|
||||
items: ExternalKnowledgeCatalogItem[]
|
||||
total?: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export type ExternalKnowledgeRetrievalConfig =
|
||||
| { provider: 'dify'; useDatasetDefaults: true }
|
||||
| {
|
||||
provider: 'fastgpt'
|
||||
searchMode: 'embedding' | 'fullTextRecall' | 'mixedRecall'
|
||||
tokenLimit: number
|
||||
similarity: number
|
||||
usingRerank: boolean
|
||||
}
|
||||
| {
|
||||
provider: 'ragflow'
|
||||
similarityThreshold: number
|
||||
vectorSimilarityWeight: number
|
||||
knnTopK: number
|
||||
useKg: boolean
|
||||
includeKnowledgeCompilation: boolean
|
||||
}
|
||||
|
||||
export type ExternalKnowledgeRetrievalResult = {
|
||||
documentTitle: string
|
||||
sourceDisplayName: string
|
||||
snippet: string
|
||||
providerScore?: number
|
||||
remoteDocumentId?: string
|
||||
remoteChunkId?: string
|
||||
location?: string
|
||||
}
|
||||
|
||||
export class ExternalKnowledgeError extends Error {
|
||||
constructor(
|
||||
readonly code:
|
||||
| 'EXTERNAL_KB_NETWORK'
|
||||
| 'EXTERNAL_KB_TIMEOUT'
|
||||
| 'EXTERNAL_KB_AUTH'
|
||||
| 'EXTERNAL_KB_FORBIDDEN'
|
||||
| 'EXTERNAL_KB_NOT_FOUND'
|
||||
| 'EXTERNAL_KB_RATE_LIMITED'
|
||||
| 'EXTERNAL_KB_INVALID_RESPONSE'
|
||||
| 'EXTERNAL_KB_SERVER',
|
||||
message: string,
|
||||
options?: ErrorOptions
|
||||
) {
|
||||
super(message, options)
|
||||
this.name = 'ExternalKnowledgeError'
|
||||
}
|
||||
}
|
||||
|
||||
type ExternalKnowledgeClientOptions = {
|
||||
provider: ExternalKnowledgeProvider
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
fetcher?: typeof fetch
|
||||
timeoutMs?: number
|
||||
maximumResponseBytes?: number
|
||||
}
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: undefined
|
||||
|
||||
const boundedString = (value: unknown, maximum: number): string | undefined =>
|
||||
typeof value === 'string' && value.length > 0
|
||||
? value.slice(0, maximum)
|
||||
: undefined
|
||||
|
||||
const finiteNumber = (value: unknown): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
||||
|
||||
function normalizeBaseUrl(provider: ExternalKnowledgeProvider, input: string): URL {
|
||||
const url = new URL(input.trim())
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new TypeError('External knowledge base URL must use HTTP or HTTPS')
|
||||
}
|
||||
if (url.username || url.password || url.search || url.hash) {
|
||||
throw new TypeError('External knowledge base URL cannot contain credentials, query, or fragment')
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/+$/, '')
|
||||
if (provider === 'dify') {
|
||||
url.pathname = url.pathname.replace(/\/v1$/, '')
|
||||
} else if (provider === 'fastgpt') {
|
||||
url.pathname = url.pathname.replace(/\/api\/core\/dataset$/, '/api')
|
||||
if (!url.pathname.endsWith('/api')) url.pathname += '/api'
|
||||
} else {
|
||||
url.pathname = url.pathname.replace(/\/api\/v1$/, '')
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
function endpoint(baseUrl: URL, path: string): URL {
|
||||
const url = new URL(baseUrl)
|
||||
url.pathname = `${url.pathname}${path}`.replace(/\/{2,}/g, '/')
|
||||
return url
|
||||
}
|
||||
|
||||
function errorForStatus(status: number): ExternalKnowledgeError {
|
||||
if (status === 401) return new ExternalKnowledgeError('EXTERNAL_KB_AUTH', 'External knowledge authentication failed')
|
||||
if (status === 403) return new ExternalKnowledgeError('EXTERNAL_KB_FORBIDDEN', 'External knowledge access is forbidden')
|
||||
if (status === 404) return new ExternalKnowledgeError('EXTERNAL_KB_NOT_FOUND', 'External knowledge target was not found')
|
||||
if (status === 429) return new ExternalKnowledgeError('EXTERNAL_KB_RATE_LIMITED', 'External knowledge service rate limit reached')
|
||||
return new ExternalKnowledgeError('EXTERNAL_KB_SERVER', `External knowledge service returned HTTP ${status}`)
|
||||
}
|
||||
|
||||
async function readBoundedJson(response: Response, maximumBytes: number): Promise<unknown> {
|
||||
const declaredLength = Number(response.headers.get('content-length'))
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
|
||||
throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is too large')
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is empty')
|
||||
}
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let total = 0
|
||||
try {
|
||||
while (true) {
|
||||
const part = await reader.read()
|
||||
if (part.done) break
|
||||
total += part.value.byteLength
|
||||
if (total > maximumBytes) {
|
||||
await reader.cancel()
|
||||
throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is too large')
|
||||
}
|
||||
chunks.push(part.value)
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
const body = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8')
|
||||
try {
|
||||
return JSON.parse(body) as unknown
|
||||
} catch (error) {
|
||||
throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is not valid JSON', { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
export class ExternalKnowledgeClient {
|
||||
readonly provider: ExternalKnowledgeProvider
|
||||
readonly baseUrl: string
|
||||
private readonly apiKey: string
|
||||
private readonly fetcher: typeof fetch
|
||||
private readonly timeoutMs: number
|
||||
private readonly maximumResponseBytes: number
|
||||
private readonly normalizedBaseUrl: URL
|
||||
|
||||
constructor(options: ExternalKnowledgeClientOptions) {
|
||||
this.provider = options.provider
|
||||
this.normalizedBaseUrl = normalizeBaseUrl(options.provider, options.baseUrl)
|
||||
this.baseUrl = this.normalizedBaseUrl.href.replace(/\/$/, '')
|
||||
this.apiKey = options.apiKey.trim()
|
||||
if (!this.apiKey) throw new TypeError('External knowledge API key is required')
|
||||
this.fetcher = options.fetcher ?? fetch
|
||||
this.timeoutMs = options.timeoutMs ?? 15_000
|
||||
this.maximumResponseBytes = options.maximumResponseBytes ?? 5_000_000
|
||||
}
|
||||
|
||||
async listKnowledgeBases(signal?: AbortSignal): Promise<ExternalKnowledgeCatalogPage> {
|
||||
if (this.provider === 'dify') {
|
||||
const url = endpoint(this.normalizedBaseUrl, '/v1/datasets')
|
||||
url.searchParams.set('page', '1')
|
||||
url.searchParams.set('limit', '100')
|
||||
const body = asRecord(await this.request(url, { method: 'GET' }, signal))
|
||||
const items = Array.isArray(body?.data) ? body.data : undefined
|
||||
if (!items) throw this.invalidResponse()
|
||||
return {
|
||||
items: items.slice(0, 100).map((item) => this.catalogItem(item)),
|
||||
total: finiteNumber(body?.total),
|
||||
hasMore: body?.has_more === true
|
||||
}
|
||||
}
|
||||
|
||||
if (this.provider === 'fastgpt') {
|
||||
const body = asRecord(await this.request(
|
||||
endpoint(this.normalizedBaseUrl, '/core/dataset/list'),
|
||||
{ method: 'POST', body: JSON.stringify({ pageNum: 1, pageSize: 100 }) },
|
||||
signal
|
||||
))
|
||||
const data = asRecord(body?.data)
|
||||
const items = Array.isArray(body?.data)
|
||||
? body.data
|
||||
: Array.isArray(data?.data)
|
||||
? data.data
|
||||
: Array.isArray(data?.list)
|
||||
? data.list
|
||||
: undefined
|
||||
if (body?.code !== 200 || !items) throw this.invalidResponse()
|
||||
const datasets = items.filter((item) => asRecord(item)?.type === 'dataset')
|
||||
return {
|
||||
items: datasets.slice(0, 100).map((item) => this.catalogItem(item, '_id', 'intro')),
|
||||
total: finiteNumber(data?.total) ?? datasets.length,
|
||||
hasMore: datasets.length > 100
|
||||
}
|
||||
}
|
||||
|
||||
const url = endpoint(this.normalizedBaseUrl, '/api/v1/datasets')
|
||||
url.searchParams.set('page', '1')
|
||||
url.searchParams.set('page_size', '100')
|
||||
const body = asRecord(await this.request(url, { method: 'GET' }, signal))
|
||||
if (body?.code !== 0 || !Array.isArray(body.data)) throw this.invalidResponse()
|
||||
return {
|
||||
items: body.data.slice(0, 100).map((item) => {
|
||||
const record = asRecord(item)
|
||||
const graphFinishedAt = record?.graphrag_task_finish_at
|
||||
return {
|
||||
...this.catalogItem(item),
|
||||
graphEnabled:
|
||||
(typeof graphFinishedAt === 'number' && graphFinishedAt > 0) ||
|
||||
(typeof graphFinishedAt === 'string' && graphFinishedAt.length > 0)
|
||||
}
|
||||
}),
|
||||
total: finiteNumber(body.total_datasets),
|
||||
hasMore: (finiteNumber(body.total_datasets) ?? 0) > 100
|
||||
}
|
||||
}
|
||||
|
||||
async retrieve(
|
||||
remoteKnowledgeBaseId: string,
|
||||
query: string,
|
||||
config: ExternalKnowledgeRetrievalConfig,
|
||||
signal?: AbortSignal
|
||||
): Promise<ExternalKnowledgeRetrievalResult[]> {
|
||||
if (config.provider !== this.provider) throw new TypeError('Provider configuration does not match client')
|
||||
const remoteId = remoteKnowledgeBaseId.trim()
|
||||
const question = query.trim()
|
||||
if (!remoteId || !question) throw new TypeError('Remote knowledge base ID and query are required')
|
||||
|
||||
if (this.provider === 'dify') {
|
||||
if (question.length > 250) throw new RangeError('Dify queries cannot exceed 250 characters')
|
||||
const body = asRecord(await this.request(
|
||||
endpoint(this.normalizedBaseUrl, `/v1/datasets/${encodeURIComponent(remoteId)}/retrieve`),
|
||||
{ method: 'POST', body: JSON.stringify({ query: question }) },
|
||||
signal
|
||||
))
|
||||
if (!Array.isArray(body?.records)) throw this.invalidResponse()
|
||||
return body.records.slice(0, 20).map((item) => this.difyResult(item))
|
||||
}
|
||||
|
||||
if (this.provider === 'fastgpt' && config.provider === 'fastgpt') {
|
||||
const body = asRecord(await this.request(
|
||||
endpoint(this.normalizedBaseUrl, '/core/dataset/searchTest'),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
datasetId: remoteId,
|
||||
text: question,
|
||||
limit: config.tokenLimit,
|
||||
similarity: config.similarity,
|
||||
searchMode: config.searchMode,
|
||||
usingReRank: config.usingRerank
|
||||
})
|
||||
},
|
||||
signal
|
||||
))
|
||||
const data = asRecord(body?.data)
|
||||
if (body?.code !== 200 || !Array.isArray(data?.list)) throw this.invalidResponse()
|
||||
return data.list.slice(0, 20).map((item) => this.fastGptResult(item))
|
||||
}
|
||||
|
||||
if (this.provider === 'ragflow' && config.provider === 'ragflow') {
|
||||
const body = asRecord(await this.request(
|
||||
endpoint(this.normalizedBaseUrl, '/api/v1/retrieval'),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
question,
|
||||
dataset_ids: [remoteId],
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
similarity_threshold: config.similarityThreshold,
|
||||
vector_similarity_weight: config.vectorSimilarityWeight,
|
||||
knn_top_k: config.knnTopK,
|
||||
use_kg: config.useKg,
|
||||
include_knowledge_compilation: config.includeKnowledgeCompilation
|
||||
})
|
||||
},
|
||||
signal
|
||||
))
|
||||
const data = asRecord(body?.data)
|
||||
if (body?.code !== 0 || !Array.isArray(data?.chunks)) throw this.invalidResponse()
|
||||
return data.chunks.slice(0, 20).map((item) => this.ragflowResult(item))
|
||||
}
|
||||
|
||||
throw new TypeError('Provider configuration does not match client')
|
||||
}
|
||||
|
||||
private async request(url: URL, init: RequestInit, signal?: AbortSignal): Promise<unknown> {
|
||||
const effectiveSignal = signal
|
||||
? AbortSignal.any([signal, AbortSignal.timeout(this.timeoutMs)])
|
||||
: AbortSignal.timeout(this.timeoutMs)
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.fetcher(url, {
|
||||
...init,
|
||||
redirect: 'error',
|
||||
signal: effectiveSignal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
...(init.body ? { 'Content-Type': 'application/json' } : {})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const code = effectiveSignal.aborted ? 'EXTERNAL_KB_TIMEOUT' : 'EXTERNAL_KB_NETWORK'
|
||||
throw new ExternalKnowledgeError(code, code === 'EXTERNAL_KB_TIMEOUT' ? 'External knowledge request timed out' : 'External knowledge service is unreachable', { cause: error })
|
||||
}
|
||||
if (!response.ok) throw errorForStatus(response.status)
|
||||
return readBoundedJson(response, this.maximumResponseBytes)
|
||||
}
|
||||
|
||||
private catalogItem(value: unknown, idKey = 'id', descriptionKey = 'description'): ExternalKnowledgeCatalogItem {
|
||||
const record = asRecord(value)
|
||||
const id = boundedString(record?.[idKey], 128)
|
||||
const name = boundedString(record?.name, 512)
|
||||
if (!id || !name) throw this.invalidResponse()
|
||||
return { id, name, description: boundedString(record?.[descriptionKey], 2_000) }
|
||||
}
|
||||
|
||||
private difyResult(value: unknown): ExternalKnowledgeRetrievalResult {
|
||||
const record = asRecord(value)
|
||||
const segment = asRecord(record?.segment)
|
||||
const document = asRecord(segment?.document)
|
||||
const snippet = boundedString(segment?.content, 8_000)
|
||||
if (!snippet) throw this.invalidResponse()
|
||||
const title = boundedString(document?.name, 512) ?? 'Dify document'
|
||||
return {
|
||||
documentTitle: title,
|
||||
sourceDisplayName: title,
|
||||
snippet,
|
||||
providerScore: finiteNumber(record?.score),
|
||||
remoteDocumentId: boundedString(document?.id, 128),
|
||||
remoteChunkId: boundedString(segment?.id, 128),
|
||||
location: finiteNumber(segment?.position) === undefined ? undefined : `segment ${segment?.position}`
|
||||
}
|
||||
}
|
||||
|
||||
private fastGptResult(value: unknown): ExternalKnowledgeRetrievalResult {
|
||||
const record = asRecord(value)
|
||||
const question = boundedString(record?.q, 8_000)
|
||||
const answer = boundedString(record?.a, 8_000)
|
||||
const snippet = [question, answer].filter(Boolean).join('\n')
|
||||
if (!snippet) throw this.invalidResponse()
|
||||
const title = boundedString(record?.sourceName, 512) ?? 'FastGPT document'
|
||||
return {
|
||||
documentTitle: title,
|
||||
sourceDisplayName: title,
|
||||
snippet: snippet.slice(0, 8_000),
|
||||
providerScore: finiteNumber(record?.score),
|
||||
remoteDocumentId: boundedString(record?.sourceId, 128),
|
||||
remoteChunkId: boundedString(record?.id, 128),
|
||||
location: finiteNumber(record?.chunkIndex) === undefined ? undefined : `chunk ${record?.chunkIndex}`
|
||||
}
|
||||
}
|
||||
|
||||
private ragflowResult(value: unknown): ExternalKnowledgeRetrievalResult {
|
||||
const record = asRecord(value)
|
||||
const snippet = boundedString(record?.content, 8_000)
|
||||
if (!snippet) throw this.invalidResponse()
|
||||
const title = boundedString(record?.document_keyword, 512) ?? 'RAGFlow document'
|
||||
const positions = Array.isArray(record?.positions) ? record.positions : undefined
|
||||
return {
|
||||
documentTitle: title,
|
||||
sourceDisplayName: title,
|
||||
snippet,
|
||||
providerScore: finiteNumber(record?.similarity),
|
||||
remoteDocumentId: boundedString(record?.document_id, 128),
|
||||
remoteChunkId: boundedString(record?.id, 128),
|
||||
location: positions ? JSON.stringify(positions).slice(0, 8_192) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
private invalidResponse(): ExternalKnowledgeError {
|
||||
return new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response does not match the supported API')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
type ProbeOperation = {
|
||||
name: string
|
||||
method: string
|
||||
path: string
|
||||
status: number | 'failed'
|
||||
}
|
||||
|
||||
type ProbeProvider = {
|
||||
provider: 'dify' | 'fastgpt' | 'ragflow'
|
||||
profile?: string
|
||||
catalog: Record<string, unknown>
|
||||
retrievals: Array<Record<string, unknown>>
|
||||
operations: ProbeOperation[]
|
||||
}
|
||||
|
||||
type ProbeReport = {
|
||||
schemaVersion: number
|
||||
privacy: string
|
||||
providers: ProbeProvider[]
|
||||
}
|
||||
|
||||
async function report(): Promise<ProbeReport> {
|
||||
return JSON.parse(
|
||||
await readFile(
|
||||
resolve('docs/features/knowledge-base/external-knowledge-probe-baseline.json'),
|
||||
'utf8'
|
||||
)
|
||||
) as ProbeReport
|
||||
}
|
||||
|
||||
describe('external knowledge probe baseline', () => {
|
||||
it('contains no endpoint, credential, remote identifier, or mutation operation', async () => {
|
||||
const value = await report()
|
||||
const serialized = JSON.stringify(value)
|
||||
|
||||
expect(serialized).not.toMatch(/https?:\/\//)
|
||||
expect(serialized).not.toMatch(
|
||||
/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i
|
||||
)
|
||||
expect(serialized).not.toMatch(/\b[0-9a-f]{24}\b/i)
|
||||
expect(value.providers.flatMap((provider) => provider.operations))
|
||||
.toSatisfy((operations: ProbeOperation[]) =>
|
||||
operations.every((operation) =>
|
||||
['GET', 'POST'].includes(operation.method)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('records the verified provider-specific behavior', async () => {
|
||||
const value = await report()
|
||||
expect(value.schemaVersion).toBe(1)
|
||||
expect([...new Set(value.providers.map((provider) => provider.provider))]).toEqual([
|
||||
'dify', 'fastgpt', 'ragflow'
|
||||
])
|
||||
|
||||
const difyProfiles = value.providers.filter((provider) => provider.provider === 'dify')
|
||||
expect(difyProfiles.length).toBeGreaterThanOrEqual(2)
|
||||
expect(difyProfiles.flatMap((provider) => provider.operations)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'retrieve-explicit-config', status: 200 })
|
||||
])
|
||||
)
|
||||
expect(difyProfiles.find((provider) => provider.profile === '0.x'))
|
||||
.toMatchObject({
|
||||
detectedVersion: '0.15.8',
|
||||
features: { detailSupported: false, nonEmptyResultObserved: false }
|
||||
})
|
||||
expect(difyProfiles.find((provider) => provider.profile === '1.x'))
|
||||
.toMatchObject({
|
||||
detectedVersion: '1.17.0',
|
||||
catalog: { summaryIndexConfiguredCount: 1 },
|
||||
features: { detailSupported: true, nonEmptyResultObserved: true }
|
||||
})
|
||||
|
||||
const fastGpt = value.providers.find((provider) => provider.provider === 'fastgpt')
|
||||
expect(fastGpt?.retrievals).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ searchMode: 'embedding', contentPresent: true }),
|
||||
expect.objectContaining({ searchMode: 'fullTextRecall', contentPresent: true }),
|
||||
expect.objectContaining({ searchMode: 'mixedRecall', usingRerank: true, contentPresent: true })
|
||||
]))
|
||||
|
||||
const ragflow = value.providers.find((provider) => provider.provider === 'ragflow')
|
||||
expect(ragflow?.catalog).toMatchObject({
|
||||
graphConfiguredCount: 17,
|
||||
graphCompletedCount: 15,
|
||||
knowledgeCompilationConfiguredCount: 0
|
||||
})
|
||||
expect(ragflow?.retrievals).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ feature: 'graph', contentPresent: true }),
|
||||
expect.objectContaining({ feature: 'knowledge-compilation' })
|
||||
]))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user