fix: distinguish external knowledge probe failures from empty results

Validate Dify and RAGFlow outcomes, normalize FastGPT URLs, and report response envelopes, score structures and actual rerank state. Align integration documentation with official APIs and archive 33 redacted read-only requests with strict HTTPS verification.

Validation: 43 focused tests, typecheck and lint passed. Full test runs exceeded 60s and 240s without a summary; this limitation is recorded in the feature progress document.
This commit is contained in:
mesalogo
2026-09-10 22:19:30 +08:00
parent d9114c20bf
commit 4483c92b57
10 changed files with 2255 additions and 74 deletions
+2
View File
@@ -20,6 +20,8 @@
| [外部知识库 UI 设计](./external-knowledge-ui-design.md) | 知识库页面、外部实例管理和创建向导交互 |
| [外部知识库技术设计](./external-knowledge-technical-design.md) | Provider Adapter、凭据、持久化、IPC 和检索标准化 |
| [外部知识库实测基线](./external-knowledge-probe-baseline.json) | 脱敏记录真实实例的接口状态、字段结构、特色能力和耗时 |
| [2026-09-10 基础复测](./external-knowledge-probe-2026-09-10-strict.json) | 开启证书校验的目录、详情和基础检索结果 |
| [2026-09-10 扩展复测](./external-knowledge-probe-2026-09-10-extended.json) | 配置覆盖、重排、图谱与知识编译开关的请求结果及结构 |
## 术语
@@ -253,7 +253,9 @@ GoodBuddy 固定安全上限
```
- 固定安全上限不可被实例或用户扩大。
- 远端知识库自身默认值只用于创建表单的建议值,不在每次请求时覆盖本地已保存值。
- 显式保存 Provider 参数时,远端默认值只用于创建表单的建议值。Dify
`useDatasetDefaults=true` 是例外:每次请求省略 `retrieval_model`,沿用远端当时的配置;
远端管理员修改配置后,后续检索随之变化。本地不保存一份默认参数快照。
- Provider 增加新字段不会自动启用。
- 当前实例版本离开 Adapter 支持矩阵,或 Adapter 新版本无法解析已保存配置时,绑定标记
配置失效;用户确认前不重写配置。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,828 @@
{
"schemaVersion": 1,
"probedAt": "2026-09-10T14:02:51.600Z",
"mode": "basic-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": false
},
"catalog": {
"success": true,
"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": {
"success": false,
"failure": "http-error",
"keys": [
"code",
"message",
"status"
]
},
"retrievals": [
{
"mode": "dataset-defaults",
"success": true,
"count": 0,
"itemKeys": [],
"segmentKeys": [],
"documentKeys": [],
"childChunkKeys": [],
"fileKeys": [],
"contentPresent": false,
"childContentPresent": false,
"summaryPresent": false
}
],
"features": {
"datasetDefaults": true,
"detailSupported": false,
"retrievalOverrideObserved": true,
"retrievalOverrideProbed": false,
"nonEmptyResultObserved": false
},
"operations": [
{
"name": "catalog",
"method": "GET",
"path": "/v1/datasets",
"status": 200,
"durationMs": 1255,
"responseKeys": [
"data",
"has_more",
"limit",
"page",
"total"
]
},
{
"name": "detail",
"method": "GET",
"path": "/v1/datasets/{id}",
"status": 405,
"durationMs": 105,
"responseKeys": [
"code",
"message",
"status"
]
},
{
"name": "retrieve-defaults",
"method": "POST",
"path": "/v1/datasets/{id}/retrieve",
"status": 200,
"durationMs": 582,
"responseKeys": [
"query",
"records"
]
}
]
},
{
"provider": "dify",
"profile": "1.x",
"detectedVersion": "1.17.0",
"transport": {
"protocol": "https:",
"tlsVerificationDisabled": false
},
"catalog": {
"success": true,
"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": {
"success": true,
"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",
"success": true,
"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": false,
"nonEmptyResultObserved": true
},
"operations": [
{
"name": "catalog",
"method": "GET",
"path": "/v1/datasets",
"status": 200,
"durationMs": 2261,
"responseKeys": [
"data",
"has_more",
"limit",
"page",
"total"
]
},
{
"name": "detail",
"method": "GET",
"path": "/v1/datasets/{id}",
"status": 200,
"durationMs": 1680,
"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": 3814,
"responseKeys": [
"query",
"records"
]
}
]
},
{
"provider": "fastgpt",
"transport": {
"protocol": "https:",
"tlsVerificationDisabled": false
},
"catalog": {
"success": true,
"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",
"success": true,
"envelope": "data-list",
"count": 8,
"itemKeys": [
"chunkIndex",
"collectionId",
"datasetId",
"id",
"q",
"score",
"sourceId",
"sourceName",
"updateTime"
],
"contentPresent": true,
"scoreTypes": [
"array"
],
"scoreItemKeys": [
"index",
"type",
"value"
],
"scoreValueRange": {
"minimum": 0.27009665966033936,
"maximum": 0.3160914182662964
},
"rerankEnabled": false
},
{
"searchMode": "fullTextRecall",
"success": true,
"envelope": "data-list",
"count": 2,
"itemKeys": [
"chunkIndex",
"collectionId",
"datasetId",
"id",
"q",
"score",
"sourceId",
"sourceName",
"updateTime"
],
"contentPresent": true,
"scoreTypes": [
"array"
],
"scoreItemKeys": [
"index",
"type",
"value"
],
"scoreValueRange": {
"minimum": 0.5047619047619047,
"maximum": 0.505
},
"rerankEnabled": false
},
{
"searchMode": "mixedRecall",
"success": true,
"envelope": "data-list",
"count": 7,
"itemKeys": [
"chunkIndex",
"collectionId",
"datasetId",
"id",
"q",
"score",
"sourceId",
"sourceName",
"updateTime"
],
"contentPresent": true,
"scoreTypes": [
"array"
],
"scoreItemKeys": [
"index",
"type",
"value"
],
"scoreValueRange": {
"minimum": 0.007575757575757576,
"maximum": 0.505
},
"rerankEnabled": false
}
],
"features": {
"searchModes": [
"embedding",
"fullTextRecall",
"mixedRecall"
],
"rerankProbed": false,
"rerankRequestSucceeded": false,
"extensionQueryProbed": false
},
"operations": [
{
"name": "catalog",
"method": "POST",
"path": "/api/core/dataset/list",
"status": 200,
"businessCode": 200,
"durationMs": 1785,
"responseKeys": [
"code",
"data",
"message",
"statusText"
]
},
{
"name": "detail",
"method": "GET",
"path": "/api/core/dataset/detail",
"status": 200,
"businessCode": 200,
"durationMs": 571,
"responseKeys": [
"code",
"data",
"message",
"statusText"
]
},
{
"name": "retrieve-embedding",
"method": "POST",
"path": "/api/core/dataset/searchTest",
"status": 200,
"businessCode": 200,
"durationMs": 1255,
"responseKeys": [
"code",
"data",
"message",
"statusText"
]
},
{
"name": "retrieve-fullTextRecall",
"method": "POST",
"path": "/api/core/dataset/searchTest",
"status": 200,
"businessCode": 200,
"durationMs": 777,
"responseKeys": [
"code",
"data",
"message",
"statusText"
]
},
{
"name": "retrieve-mixedRecall",
"method": "POST",
"path": "/api/core/dataset/searchTest",
"status": 200,
"businessCode": 200,
"durationMs": 2326,
"responseKeys": [
"code",
"data",
"message",
"statusText"
]
}
]
},
{
"provider": "ragflow",
"transport": {
"protocol": "http:"
},
"catalog": {
"success": true,
"count": 29,
"total": 29,
"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": 18,
"graphCompletedCount": 16,
"knowledgeCompilationConfiguredCount": 0
},
"detail": {
"success": true,
"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",
"success": true,
"count": 2,
"itemKeys": [
"content",
"content_ltks",
"dataset_id",
"doc_type_kwd",
"document_id",
"document_keyword",
"id",
"image_id",
"important_keywords",
"mom_id",
"positions",
"row_id",
"similarity",
"tag_kwd",
"term_similarity",
"vector_similarity"
],
"contentPresent": true,
"scoreRange": {
"minimum": 0.18431304078777125,
"maximum": 0.2623782504333604
}
}
],
"features": {
"graphRetrievalProbed": false,
"knowledgeCompilationProbed": false,
"metadataConditionProbed": false
},
"operations": [
{
"name": "catalog",
"method": "GET",
"path": "/api/v1/datasets",
"status": 200,
"businessCode": 0,
"durationMs": 2817,
"responseKeys": [
"code",
"data",
"total_datasets"
]
},
{
"name": "detail",
"method": "GET",
"path": "/api/v1/datasets",
"status": 200,
"businessCode": 0,
"durationMs": 159,
"responseKeys": [
"code",
"data",
"total_datasets"
]
},
{
"name": "retrieve-baseline",
"method": "POST",
"path": "/api/v1/retrieval",
"status": 200,
"businessCode": 0,
"durationMs": 776,
"responseKeys": [
"code",
"data"
]
}
]
}
]
}
@@ -355,9 +355,28 @@ knowledge:external-retrieval:test
## 9. Provider 映射
本节记录 2026-09-03 核对的初始公开接口。实施时应把每个受支持版本的真实响应保存为
本节依据 2026-09-10 核对的官方文档、官方源码和真实实例响应。实施时应把每个受支持版本的真实响应保存为
去敏 fixture,并以 fixture 和真实实例测试作为发布依据。
官方来源与版本边界:
- Dify:[Knowledge API 与密钥范围](https://docs.dify.ai/en/api-reference/guides/knowledge)、
[列表](https://docs.dify.ai/en/api-reference/knowledge-bases/list-knowledge-bases)、
[详情](https://docs.dify.ai/en/api-reference/knowledge-bases/get-knowledge-base)、
[检索](https://docs.dify.ai/en/api-reference/knowledge-bases/retrieve-chunks-from-a-knowledge-base-test-retrieval)。
缺省配置顺序另对照[官方实现](https://github.com/langgenius/dify/blob/main/api/services/hit_testing_service.py)。
- FastGPT:[API 说明](https://doc.fastgpt.io/zh-CN/openapi/intro)和
[已停止维护的手工接口页](https://doc.fastgpt.io/zh-CN/openapi/dataset)。当前契约对照
[生成文档 Schema](https://github.com/labring/FastGPT/blob/main/packages/global/openapi/core/dataset/api.ts)、
[检索处理器](https://github.com/labring/FastGPT/blob/main/projects/app/src/pages/api/core/dataset/searchTest.ts)和
[结果项 Schema](https://github.com/labring/FastGPT/blob/main/packages/global/core/dataset/type.ts)。
云端 `/apidoc/` 本次只取得应用壳,未读取渲染后的部署 Schema。
- RAGFlow:[官网 HTTP API](https://ragflow.io/docs/dev/http_api_reference)正文未能有效提取,
采用[官方当前文档原文](https://github.com/infiniflow/ragflow/blob/main/docs/references/http_api_reference.md),并与
[v0.24.0 固定版本](https://github.com/infiniflow/ragflow/blob/v0.24.0/docs/references/http_api_reference.md)比较。
`main` 分支文档与源码会变化,不是部署版本凭据。FastGPT、RAGFlow 的实际产品版本仍未确认。
### 9.0 真实实例验证基线
2026-09-09 使用本机未入库的测试地址和 API Key 完成第一轮只读验证。验证只记录状态、
@@ -370,10 +389,15 @@ knowledge:external-retrieval:test
| 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 实例的请求
2026-09-09 的 Node 测试进程设置了 `NODE_TLS_REJECT_UNAUTHORIZED=0`,因此当时 HTTPS 实例的请求
使用了放宽的证书校验。这只能证明接口在当前测试环境可用,不能证明严格 TLS 校验通过。
产品实现不得读取该进程级设置作为隐式默认;实例测试和保存必须按 8.1 节处理传输风险。
2026-09-10 复测显式设置 `NODE_TLS_REJECT_UNAUTHORIZED=1`,两个 Dify 实例和 FastGPT
均通过证书校验;RAGFlow 配置为 HTTP,不涉及 TLS。基础 14 次、扩展 19 次,共 33 次只读
请求。每个实例从目录选择一个库检索,未遍历全部库;未调用聊天或生成端点,检索仍可能
使用远端 Embedding、Rerank 并记录用量。两份报告与逐项结果见[实施进度](./progress.md)。
### 9.1 Dify
| 操作 | 接口 |
@@ -385,10 +409,15 @@ knowledge:external-retrieval:test
Adapter 只接受 Knowledge Service API Key,不调用应用 `/info`、Chat 或 Workflow。默认不
发送 `retrieval_model`,沿用远端知识库配置。用户选择覆盖时,Adapter 必须一次收集并验证
当前接口要求的搜索方式、Top K、阈值、`reranking_mode`,以及相应的 Rerank Provider/模型
或关键词/向量权重与 Embedding Provider/模型;缺少任何必填标识时不构造请求。响应从
当前接口要求的 `search_method`、`reranking_enable`、`top_k`、`score_threshold_enabled`。
开启重排时再验证 `reranking_mode`,以及该模式所需的 Provider、模型或权重;缺少必填
标识时不构造请求。显式对象不会与知识库配置逐字段合并。省略整个对象时,服务端优先使用
知识库配置,未保存配置时才使用内置默认。响应从
`records[].segment`、`score`、文档名称和元数据提取标准化引用。
当前官方文档说明限定知识库范围的 Key 不能调用列表,但可访问授权 ID 的详情和检索。
列表 403 不足以认定密钥无效;本轮测试配置均能列目录,尚未验证 Scoped Key。
Dify 当前文档限制查询不超过 250 字符。GoodBuddy 不静默截断;超出时检索测试和聊天
诊断返回 Provider 限制,并允许后续单独设计查询压缩策略。
@@ -421,19 +450,25 @@ Dify 1.x 官方 RetrievalModel 还声明 `metadata_filtering_conditions`,并
| 检索 | `POST /api/core/dataset/searchTest` |
| 认证 | `Authorization: Bearer <api-key>` |
列表响应同时包含文件夹和知识库,只有 `type = dataset` 可以绑定。当前实测列表的
`data` 直接为数组,不是 `data.list`;Adapter 可以仅对已经实测的这两种明确 envelope
做版本化解析,不递归猜测任意嵌套。检索映射 `datasetId`、
列表响应同时包含文件夹和知识库,只有 `type = dataset` 可以绑定。根列表请求发送
`parentId: null`;文件夹需按父 ID 继续读取。当前实测列表为 `data[]`,检索为
`data.list[]`,这是两个不同端点的结构。旧手工文档还展示检索 `data[]`,探测脚本接受
该旧结构并单独记录 envelope;当前客户端只接受检索 `data.list[]`,旧结构尚未经过真实
部署验证。不递归猜测任意嵌套。检索映射 `datasetId`、
`text`、Token `limit`、`similarity`、`searchMode` 和 `usingReRank`。初始版本不发送查询优化
字段,避免调用 FastGPT 配置的 LLM。
当前实测响应的 `data` 包含 `list/duration/limit/searchMode/usingReRank/similarity`;结果项
包含 `q/id/datasetId/collectionId/sourceName/sourceId/chunkIndex/score`,未返回 `a`。
Adapter 以 `q` 为必需片段,`a` 仅在远端实际返回时追加,不能要求 `a` 存在。
2026-09-10 实测 `score` 为 `{type, value, index}[]`,与当前官方结果 Schema 一致;
旧手工文档中的数值分数不能作为当前统一映射依据。应保留评分类型或按明确规则选分,
不能默认取第一项作为总分。当前客户端仅提取数值分数,数组评分的映射仍待实现。
当前实例的 embedding、fullTextRecall、mixedRecall 和 mixedRecall + Rerank 均返回 200 和
非空结果,因此这四种界面组合可以启用。查询扩展会调用模型,仍不在初始界面和探测脚本中
启用。
FastGPT 4.15.0 起部署实例的 `/apidoc/devapi` 是接口事实来源,手工文档可能落后。支持矩阵
FastGPT 4.15.0 起应优先查阅部署实例的自动生成文档,`/apidoc/devapi` 与
`/apidoc/systemopenapi` 的认证范围不同,Dev API 中出现不等于允许 API Key 调用。支持矩阵
必须按真实版本测试;不能因为健康页可访问就假定列表和检索 Schema 相同。`searchTest`
可能记录 `SEARCH_TEST` 等远端审计并更新 API Key 用量;Embedding 和 Rerank 也可能产生
费用。GoodBuddy 将这些行为作为检索成本显示,不宣称该接口没有副作用。
@@ -448,7 +483,11 @@ 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` 和
`include_knowledge_compilation`。当前文档以 `knn_top_k` 取代 `top_k`;v0.24.0 文档只声明
`top_k`,因此当前客户端不发送 `top_k` 的行为不能作为旧版本兼容承诺。
`knn_top_k` 控制向量候选数量,`page_size` 控制返回数量;请求成功不能单独证明未知参数
已生效。本轮未验证候选数参数的效果,也未确定新字段首次发布版本。
HTTP 成功且业务 `code === 0` 才能解析结果。响应从 `data.chunks` 和
`data.doc_aggs` 提取片段、文档、位置和原始相似度。当前实测非空 chunk 使用
`document_keyword` 表示文档名,并包含 `id/document_id/content/positions/similarity/`
`term_similarity/vector_similarity`;初始 Adapter 应按该字段映射,不假定存在
@@ -467,10 +506,13 @@ FastGPT 4.15.0 起部署实例的 `/apidoc/devapi` 是接口事实来源,手
配置证据;仅仅请求返回 200 不能启用界面开关。
- Provider 没有返回图节点或路径证据时,GoodBuddy 不生成图谱引用。
当前实例 28 个库中 17 个配置 GraphRAG、15 个存在完成证据。对一个已完成库使用同一查询,
2026-09-09 实例 28 个库中 17 个配置 GraphRAG、15 个存在完成证据。对一个已完成库使用同一查询,
基础检索返回 0 条,`use_kg=true` 返回 1 条且原始相似度为 1,说明图谱开关对实际结果有
影响。当前 28 个库均没有 Knowledge Compilation 配置证据;该请求虽返回 200 和零结果,
影响。当时 28 个库均没有 Knowledge Compilation 配置证据;该请求虽返回 200 和零结果,
界面仍必须禁用开关并显示“当前知识库未配置 Knowledge Compilation”。
2026-09-10 目录增至 29 项,18 项配置图谱、16 项有完成证据;所选库基础检索 2 条、
图谱检索 3 条。Knowledge Compilation 开关请求返回 2 条,目录仍无配置证据,不能据此
认定编译内容参与了检索。
## 10. 检索编排
@@ -601,8 +643,8 @@ Adapter 可以保存少量受控事实,例如“目录接口可用”“详情
Mock 或公开文档通过不能代替真实实例验收。
2026-09-09 基线已完成 Dify `0.15.8`/`1.17.0` 目录、详情差异和 1.17 非空检索结构,
FastGPT/RAGFlow 产品版本、严格 TLS、401/403/404/429、超时和取消尚未完成,因此不满足
本节完整验收条件。
2026-09-10 补充验证了 HTTPS 实例的严格 TLS。FastGPT/RAGFlow 产品版本、真实实例
401/403/404/429、超时和取消尚未完成,因此不满足本节完整验收条件。
### 14.6 可重复探测脚本
@@ -621,14 +663,18 @@ node scripts/external-knowledge-probe.mjs --extended --output=<report.json>
Rerank、RAGFlow GraphRAG 和 Knowledge Compilation 检索。脚本只有固定 GET/POST 读取与
检索端点,不实现或调用远端创建、更新、上传和删除。
FastGPT 报告将请求成功与命中数量分开:HTTP 必须成功、业务 `code` 必须为 `200`,
且 `data` 或 `data.list` 必须是数组,才记录 `success: true` 和 `count`。成功的空数组
三家报告均将请求成功与命中数量分开:HTTP 必须成功,FastGPT 业务 `code` 必须为 `200`、
RAGFlow 必须为 `0`,并分别校验 Dify `records`、FastGPT `data` 或 `data.list`、RAGFlow
`data.chunks` 数组结构,才记录检索 `success: true` 和 `count`。成功的空数组
记录 `count: 0`;HTTP、业务、响应结构、JSON 解析或网络失败记录 `success: false` 与
固定失败类别,不用零命中代替失败。目录失败时不继续调用详情和检索。
`features.searchModes` 只包含基础检索实际成功的模式;`rerankProbed` 表示实际尝试,
`rerankSucceeded` 单独表示成功。历史基线保留原始证据,不追填未观测的成功字段。
`tests/external-knowledge-probe-fastgpt.test.ts` 使用内存 fetch fixture 运行真实 CLI,
覆盖错误、成功零命中和非空结果,不连接外部知识服务。
`rerankRequestSucceeded` 表示请求成功,结果 `rerankEnabled` 单独记录远端实际返回的
`usingReRank` 布尔值。FastGPT 另记录检索 envelope、分数类型和分数数组项结构;
operation 保留 HTTP 状态、安全业务码及白名单网络错误码,不记录远端错误消息。
历史基线保留原始证据,不追填未观测的成功字段。
`tests/external-knowledge-probe-fastgpt.test.ts` 和 `tests/external-knowledge-probe-outcomes.test.ts`
使用内存 fetch fixture 运行真实 CLI,覆盖错误、成功零命中和非空结果,不连接外部知识服务。
### 14.7 CRUD 验收边界
+28
View File
@@ -15,6 +15,34 @@
## 验证证据
2026-09-10 官方 API 核对与真实实例复测:
- 官方资料链接与版本差异见[技术设计第 9 节](./external-knowledge-technical-design.md#9-provider-映射)。
已修正 Dify 默认配置优先级、FastGPT 端点响应与数组评分说明、RAGFlow 新旧候选数参数边界。
- 使用现有本地测试配置与脚本默认查询,执行基础 14 次、扩展 19 次,共 33 次只读请求,
其中检索 17 次。未调用远端内容写入、聊天或生成端点;检索所用 Embedding、重排及审计
用量由远端服务处理,不据此推断底层模型调用次数。
- 两轮均在独立 Node 进程显式设置 `NODE_TLS_REJECT_UNAUTHORIZED=1`。两个 Dify 实例与
FastGPT 的 HTTPS 请求通过严格证书校验;RAGFlow 使用 HTTP。
- [基础报告](./external-knowledge-probe-2026-09-10-strict.json)和
[扩展报告](./external-knowledge-probe-2026-09-10-extended.json)保留脱敏状态、数量、字段结构
和耗时。每个实例只对目录选出的一个库执行检索,未遍历全部库。
| 实例 | 目录与详情 | 检索结果 |
| --- | --- | --- |
| Dify 0.15.8 | 目录 3 项;详情 HTTP 405 | 默认与显式当前配置请求均成功,均为 0 条;非空结果尚未验证 |
| Dify 1.17.0 | 目录 1 项;详情成功 | 默认与显式当前配置均为 3 条非空结果 |
| FastGPT,版本未确认 | 根目录 1 个 Dataset;详情 HTTP 200、业务码 200 | 向量 8 条、全文 2 条、混合 7 条、混合加重排 9 条;重排响应明确为启用;检索包装为 `data.list`,`score` 为数组 |
| RAGFlow,版本未确认 | 目录 29 项;详情成功;18 项配置图谱、16 项有完成证据 | 基础 2 条、图谱 3 条、知识编译开关 2 条;请求均 HTTP 200、业务码 0,但没有知识编译配置证据 |
- 修正探测脚本将 Dify/RAGFlow 失败记成零命中的问题,增加实际调用与成功标记,统一
FastGPT 地址规范化并记录分数结构、响应中的重排状态。未修改产品客户端;旧 FastGPT
检索数组包装、数组评分映射及上一轮发现的取消分类问题仍待处理。
- 三组探测测试共 43 项通过,包含两份新报告的脱敏与只读操作检查;`npm run typecheck`、`npm run lint` 通过。
`npm test` 先后达到 60 秒和 240 秒执行时限,均未输出汇总,全量回归尚未完成。
- 本轮未验证真实 401/403/404/429、超时、取消、Scoped Key、分页、元数据过滤或候选数
参数效果。探测脚本不打包到桌面或 GoodBuddy Agent,本次未改变远程 Runtime 生产路径。
2026-09-09 外部知识库适配基础验证:
- 使用本机未入库的测试配置累计执行 107 次只读请求,其中更新后的最终归档脚本执行 19 次;脚本
+120 -44
View File
@@ -78,7 +78,10 @@ function baseUrl(provider, input) {
}
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 === 'fastgpt') {
url.pathname = url.pathname.replace(/\/api\/core\/dataset$/, '/api')
if (!url.pathname.endsWith('/api')) url.pathname += '/api'
}
if (provider === 'ragflow') url.pathname = url.pathname.replace(/\/api\/v1$/, '')
return url
}
@@ -97,8 +100,9 @@ async function boundedJson(response, maximumBytes = 5_000_000) {
async function request(context, name, url, init = {}) {
const startedAt = performance.now()
let response
try {
const response = await fetch(url, {
response = await fetch(url, {
...init,
redirect: 'error',
signal: AbortSignal.timeout(15_000),
@@ -113,9 +117,14 @@ async function request(context, name, url, init = {}) {
name,
method: init.method ?? 'GET',
path: url.pathname
.replace(/(\/datasets\/)[^/]+/, '$1{id}')
.replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, '{id}')
.replace(/[0-9a-f]{24}/gi, '{id}'),
status: response.status,
businessCode: Number.isFinite(body?.code) || [
'invalid_param', 'unauthorized', 'forbidden', 'not_found',
'dataset_not_found', 'internal_server_error', 'rate_limit_exceeded'
].includes(body?.code) ? body.code : undefined,
durationMs: Math.round(performance.now() - startedAt),
responseKeys: objectKeys(body)
})
@@ -125,16 +134,59 @@ async function request(context, name, url, init = {}) {
name,
method: init.method ?? 'GET',
path: url.pathname
.replace(/(\/datasets\/)[^/]+/, '$1{id}')
.replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, '{id}')
.replace(/[0-9a-f]{24}/gi, '{id}'),
status: 'failed',
status: response?.status ?? 'failed',
durationMs: Math.round(performance.now() - startedAt),
error: error?.name ?? 'Error'
error: ['Error', 'TypeError', 'SyntaxError', 'TimeoutError', 'AbortError'].includes(error?.name)
? error.name : 'Error',
causeCode: [
'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN',
'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET', 'CERT_HAS_EXPIRED',
'CERT_NOT_YET_VALID', 'DEPTH_ZERO_SELF_SIGNED_CERT',
'SELF_SIGNED_CERT_IN_CHAIN', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'ERR_TLS_CERT_ALTNAME_INVALID',
'ERR_SSL_WRONG_VERSION_NUMBER'
].includes(error?.cause?.code) ? error.cause.code : undefined
})
return undefined
return response ? { response, body: undefined } : undefined
}
}
function responseFailure(result, businessCode) {
if (!result) return { success: false, failure: 'request-failed' }
if (!result.response.ok) return { success: false, failure: 'http-error' }
if (result.body === undefined) return { success: false, failure: 'request-failed' }
if (businessCode !== undefined && asRecord(result.body)?.code !== businessCode) {
return { success: false, failure: 'business-error' }
}
return undefined
}
function difyOutcome(result, kind) {
const failure = responseFailure(result)
if (failure) return failure
const body = asRecord(result.body)
const valid = kind === 'detail'
? typeof body?.id === 'string' && body.id.length > 0
: kind === 'catalog'
? Array.isArray(body?.data) && body.data.every((item) => typeof asRecord(item)?.id === 'string')
: Array.isArray(body?.records) && body.records.every((item) => asRecord(asRecord(item)?.segment))
return valid ? { success: true } : { success: false, failure: 'invalid-shape' }
}
function ragflowOutcome(result, kind) {
const failure = responseFailure(result, 0)
if (failure) return failure
const data = asRecord(result.body)?.data
const valid = kind === 'retrieval'
? Array.isArray(asRecord(data)?.chunks) && data.chunks.every((item) => asRecord(item))
: Array.isArray(data) && data.every((item) => typeof asRecord(item)?.id === 'string') &&
(kind !== 'detail' || data.length > 0)
return valid ? { success: true } : { success: false, failure: 'invalid-shape' }
}
function resultShape(items, scoreKey) {
const scores = finiteNumbers(items.map((item) => asRecord(item)?.[scoreKey]))
return {
@@ -192,7 +244,8 @@ async function probeDify(configuration_) {
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 catalogOutcome = difyOutcome(catalog, 'catalog')
const items = catalogOutcome.success ? catalogBody.data : []
const first = asRecord(items[0])
let detail
const retrievals = []
@@ -209,8 +262,9 @@ async function probeDify(configuration_) {
{ 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) })
const outcome = difyOutcome(retrieval, 'retrieval')
retrievals.push({ mode: 'dataset-defaults', ...outcome,
...(outcome.success ? difyResultShape(retrievalBody.records) : {}) })
if (extended && asRecord(first.retrieval_model_dict)) {
const override = await request(
context,
@@ -225,8 +279,9 @@ async function probeDify(configuration_) {
}
)
const overrideBody = asRecord(override?.body)
const overrideRecords = Array.isArray(overrideBody?.records) ? overrideBody.records : []
retrievals.push({ mode: 'explicit-current-config', ...difyResultShape(overrideRecords) })
const overrideOutcome = difyOutcome(override, 'retrieval')
retrievals.push({ mode: 'explicit-current-config', ...overrideOutcome,
...(overrideOutcome.success ? difyResultShape(overrideBody.records) : {}) })
}
}
return {
@@ -240,24 +295,27 @@ async function probeDify(configuration_) {
: 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
...catalogOutcome,
...(catalogOutcome.success ? {
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) },
detail: { ...(typeof first?.id === 'string' ? difyOutcome(detail, 'detail') : { success: false, failure: 'not-probed' }), keys: objectKeys(detail?.body) },
retrievals,
features: {
datasetDefaults: true,
detailSupported: detail?.response.ok === true,
datasetDefaults: retrievals.some((item) => item.mode === 'dataset-defaults' && item.success),
detailSupported: difyOutcome(detail, 'detail').success,
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)
@@ -267,10 +325,9 @@ async function probeDify(configuration_) {
}
function fastGptListResult(result) {
if (!result) return { success: false, failure: 'request-failed' }
if (!result.response.ok) return { success: false, failure: 'http-error' }
const failure = responseFailure(result, 200)
if (failure) return failure
const body = asRecord(result.body)
if (body?.code !== 200) return { success: false, failure: 'business-error' }
const data = asRecord(body.data)
const list = Array.isArray(body.data) ? body.data : data?.list
if (!Array.isArray(list)) return { success: false, failure: 'invalid-shape' }
@@ -279,9 +336,23 @@ function fastGptListResult(result) {
function fastGptRetrievalShape(result) {
const { list, ...outcome } = fastGptListResult(result)
if (!outcome.success) return outcome
const data = asRecord(result.body.data)
const arrayScores = list.flatMap((item) => Array.isArray(asRecord(item)?.score) ? item.score : [])
const scoreValues = finiteNumbers(arrayScores.map((item) => asRecord(item)?.value))
return {
...outcome,
...(list ? resultShape(list, 'score') : {})
envelope: Array.isArray(result.body.data) ? 'data-array' : 'data-list',
...resultShape(list, 'score'),
scoreTypes: [...new Set(list.flatMap((item) => {
const score = asRecord(item)?.score
return Number.isFinite(score) ? ['number'] : Array.isArray(score) ? ['array'] : []
}))].sort(),
scoreItemKeys: unionKeys(arrayScores),
scoreValueRange: scoreValues.length > 0
? { minimum: Math.min(...scoreValues), maximum: Math.max(...scoreValues) }
: undefined,
rerankEnabled: typeof data?.usingReRank === 'boolean' ? data.usingReRank : undefined
}
}
@@ -292,7 +363,7 @@ async function probeFastGpt(configuration_) {
context,
'catalog',
endpoint(base, '/core/dataset/list'),
{ method: 'POST', body: JSON.stringify({ parentId: '' }) }
{ method: 'POST', body: JSON.stringify({ parentId: null }) }
)
const catalogBody = asRecord(catalog?.body)
const rawData = catalogBody?.data
@@ -375,7 +446,7 @@ async function probeFastGpt(configuration_) {
features: {
searchModes: [...new Set(retrievals.filter((item) => item.success && !item.usingRerank).map((item) => item.searchMode))],
rerankProbed: retrievals.some((item) => item.usingRerank),
rerankSucceeded: retrievals.some((item) => item.usingRerank && item.success),
rerankRequestSucceeded: retrievals.some((item) => item.usingRerank && item.success),
extensionQueryProbed: false
},
operations: context.operations
@@ -390,7 +461,8 @@ async function probeRagflow(configuration_) {
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 catalogOutcome = ragflowOutcome(catalog, 'catalog')
const items = catalogOutcome.success ? catalogBody.data : []
const graphConfiguredItems = items.filter((item) => {
const record = asRecord(item)
const graph = asRecord(asRecord(record?.parser_config)?.graphrag)
@@ -440,8 +512,9 @@ async function probeRagflow(configuration_) {
}
)
const data = asRecord(asRecord(result?.body)?.data)
const chunks = Array.isArray(data?.chunks) ? data.chunks : []
retrievals.push({ feature: feature.name, ...resultShape(chunks, 'similarity') })
const outcome = ragflowOutcome(result, 'retrieval')
retrievals.push({ feature: feature.name, ...outcome,
...(outcome.success ? resultShape(data.chunks, 'similarity') : {}) })
}
}
const detailBody = asRecord(detail?.body)
@@ -457,19 +530,22 @@ async function probeRagflow(configuration_) {
: 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
...catalogOutcome,
...(catalogOutcome.success ? {
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) },
detail: { ...(typeof first?.id === 'string' ? ragflowOutcome(detail, 'detail') : { success: false, failure: 'not-probed' }), keys: objectKeys(detailData) },
retrievals,
features: {
graphRetrievalProbed: extended,
knowledgeCompilationProbed: extended,
graphRetrievalProbed: retrievals.some((item) => item.feature === 'graph'),
knowledgeCompilationProbed: retrievals.some((item) => item.feature === 'knowledge-compilation'),
metadataConditionProbed: false
},
operations: context.operations
+32 -7
View File
@@ -20,7 +20,7 @@ type ProviderReport = {
count?: number
contentPresent?: boolean
}>
features: { searchModes: string[]; rerankProbed: boolean; rerankSucceeded: boolean }
features: { searchModes: string[]; rerankProbed: boolean; rerankRequestSucceeded: boolean }
operations: Array<{ name: string; status: number | 'failed' }>
}
@@ -40,8 +40,10 @@ async function probe(
globalThis.fetch = async (url, init) => {
const path = new URL(url).pathname;
let fixture;
if (path.endsWith('/list')) fixture = catalog;
else if (path.endsWith('/detail')) fixture = {body: {code: 200, data: {}}};
if (path === '/prefix/api/core/dataset/list') {
if (JSON.parse(init.body).parentId !== null) throw new Error('Invalid root parent');
fixture = catalog;
} else if (path.endsWith('/detail')) fixture = {body: {code: 200, data: {}}};
else if (path.endsWith('/searchTest')) {
const input = JSON.parse(init.body);
fixture = fixtures[input.usingReRank ? 'rerank' : input.searchMode];
@@ -60,7 +62,7 @@ async function probe(
{
env: {
...process.env,
GOODBUDDY_FASTGPT_BASE_URL: 'http://fixture.invalid',
GOODBUDDY_FASTGPT_BASE_URL: 'http://fixture.invalid/prefix/api/core/dataset/',
GOODBUDDY_FASTGPT_API_KEY: 'fixture-only-key'
},
timeout: 10_000
@@ -88,7 +90,7 @@ describe('FastGPT probe success evidence', () => {
embedding: fixture, fullTextRecall: fixture, mixedRecall: fixture, rerank: fixture
})
expect(report.features).toEqual({
searchModes: [], rerankProbed: true, rerankSucceeded: false,
searchModes: [], rerankProbed: true, rerankRequestSucceeded: false,
extensionQueryProbed: false
})
expect(report.retrievals).toHaveLength(4)
@@ -108,7 +110,7 @@ describe('FastGPT probe success evidence', () => {
})
expect(report.features).toMatchObject({
searchModes: ['embedding', 'fullTextRecall'],
rerankProbed: true, rerankSucceeded: true
rerankProbed: true, rerankRequestSucceeded: true
})
expect(report.retrievals[0]).toMatchObject({ success: true, count: 0, contentPresent: false })
expect(report.retrievals[1]).toMatchObject({ success: true, count: 0 })
@@ -126,8 +128,31 @@ describe('FastGPT probe success evidence', () => {
expect(report.catalog).not.toHaveProperty('count')
expect(report.retrievals).toEqual([])
expect(report.features).toMatchObject({
searchModes: [], rerankProbed: false, rerankSucceeded: false
searchModes: [], rerankProbed: false, rerankRequestSucceeded: false
})
expect(report.operations).toHaveLength(1)
})
it('reports both retrieval envelopes, score structures and actual rerank response separately', async () => {
const report = await probe({
embedding: { body: { code: 200, data: [{ score: 0.6, q: 'private text' }] } },
fullTextRecall: { body: { code: 200, data: { list: [], usingReRank: true } } },
mixedRecall: success(),
rerank: { body: { code: 200, data: {
usingReRank: false,
list: [{ score: [{ type: 'private score type', value: 0.2, index: 1 }], q: 'private text' }]
} } }
})
expect(report.retrievals[0]).toMatchObject({
envelope: 'data-array', scoreTypes: ['number'], scoreRange: { minimum: 0.6, maximum: 0.6 }
})
expect(report.retrievals[3]).toMatchObject({
envelope: 'data-list', scoreTypes: ['array'], scoreItemKeys: ['index', 'type', 'value'],
scoreValueRange: { minimum: 0.2, maximum: 0.2 }, rerankEnabled: false
})
expect(report.retrievals[1]).toMatchObject({ rerankEnabled: true })
expect(report.retrievals[0]).not.toHaveProperty('rerankEnabled')
expect(report.features.rerankRequestSucceeded).toBe(true)
expect(JSON.stringify(report)).not.toContain('private')
})
})
@@ -0,0 +1,127 @@
import { execFile } from 'node:child_process'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import { describe, expect, it } from 'vitest'
type Fixture = { status?: number; body?: unknown; raw?: string; causeCode?: string }
type Outcome = { success: boolean; failure?: string; count?: number }
type Report = {
catalog: Outcome
detail: Outcome
retrievals: Outcome[]
features: Record<string, unknown>
operations: Array<{ status: number | 'failed'; businessCode?: number | string; causeCode?: string }>
}
async function probe(provider: 'dify' | 'ragflow', fixtures: Fixture[]): Promise<Report> {
const script = `
const fixtures = ${JSON.stringify(fixtures)};
globalThis.fetch = async () => {
const fixture = fixtures.shift();
if (!fixture) throw new Error('Unexpected request');
if (fixture.causeCode) throw new TypeError('private network message', {
cause: { code: fixture.causeCode, message: 'private cause' }
});
return new Response(fixture.raw ?? JSON.stringify(fixture.body), { status: fixture.status ?? 200 });
};
process.argv = [process.execPath, 'probe', '--provider=${provider}', '--extended'];
await import(${JSON.stringify(pathToFileURL(resolve('scripts/external-knowledge-probe.mjs')).href)});
`
const { stdout } = await promisify(execFile)(process.execPath, ['--input-type=module', '--eval', script], {
env: {
...process.env,
[`GOODBUDDY_${provider.toUpperCase()}_BASE_URL`]: 'https://fixture.invalid',
[`GOODBUDDY_${provider.toUpperCase()}_API_KEY`]: 'fixture-only-key'
},
timeout: 10_000
})
expect(stdout).not.toMatch(/private|fixture.invalid|fixture-only-key|fixture-dataset/)
return JSON.parse(stdout).providers[0] as Report
}
describe.each(['dify', 'ragflow'] as const)('%s probe outcomes', (provider) => {
const catalog = { body: { code: 0, data: [{ id: 'fixture-dataset', retrieval_model_dict: {} }] } }
const detail = { body: provider === 'dify' ? { id: 'fixture-dataset' } : catalog.body }
const empty = { body: provider === 'dify' ? { records: [] } : { code: 0, data: { chunks: [] } } }
it('reports successful empty catalogs without claiming uncalled features', async () => {
const report = await probe(provider, [{ body: { code: 0, data: [] } }])
expect(report.catalog).toMatchObject({ success: true, count: 0 })
expect(report.detail).toMatchObject({ success: false, failure: 'not-probed' })
expect(report.retrievals).toEqual([])
expect(Object.values(report.features)).not.toContain(true)
expect(report.operations).toHaveLength(1)
})
it.each([
[{ status: 403, body: { code: 'forbidden', message: 'private message' } }, 'http-error'],
[{ status: 502, raw: '<html>private body</html>' }, 'http-error'],
[{ body: { code: 0, data: {} } }, 'invalid-shape'],
[{ raw: '{broken' }, 'request-failed']
] as const)('does not turn catalog failure into an empty catalog: %j', async (fixture, failure) => {
const report = await probe(provider, [fixture])
expect(report.catalog).toMatchObject({ success: false, failure })
expect(report.catalog).not.toHaveProperty('count')
expect(report.detail).toMatchObject({ success: false, failure: 'not-probed' })
expect(report.retrievals).toEqual([])
expect(Object.values(report.features)).not.toContain(true)
expect(report.operations[0]).toMatchObject({ status: (fixture as Fixture).status ?? 200 })
})
it('distinguishes successful empty retrievals from failed detail and retrievals', async () => {
const failure = { status: 500, body: { code: 500, message: 'private error' } }
const report = await probe(provider, [catalog, failure, empty, failure, failure])
expect(report.catalog).toMatchObject({ success: true, count: 1 })
expect(report.detail).toMatchObject({ success: false, failure: 'http-error' })
expect(report.retrievals[0]).toMatchObject({ success: true, count: 0 })
for (const retrieval of report.retrievals.slice(1)) {
expect(retrieval).toMatchObject({ success: false, failure: 'http-error' })
expect(retrieval).not.toHaveProperty('count')
}
expect(report.operations[1]).toMatchObject({ status: 500, businessCode: 500 })
})
it('validates detail and retrieval shapes', async () => {
const invalid = { body: { code: 0, records: [null], data: { chunks: null } } }
const report = await probe(provider, [catalog, invalid, invalid, invalid, invalid])
expect(report.detail).toMatchObject({ success: false, failure: 'invalid-shape' })
expect(report.retrievals.every((item) => !item.success && item.failure === 'invalid-shape')).toBe(true)
})
it('recognizes valid detail responses', async () => {
const report = await probe(provider, [catalog, detail, empty, empty, empty])
expect(report.detail.success).toBe(true)
})
it.each(['UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'ERR_TLS_CERT_ALTNAME_INVALID', 'private-code'])(
'only exposes allowlisted network cause codes: %s', async (causeCode) => {
const report = await probe(provider, [{ causeCode }])
expect(report.operations[0]).toMatchObject({ status: 'failed' })
expect(report.operations[0]?.causeCode).toBe(causeCode === 'private-code' ? undefined : causeCode)
expect(report.catalog).toMatchObject({ success: false, failure: 'request-failed' })
}
)
it.each(['forbidden', 'private-code'])('only exposes known string business codes: %s', async (code) => {
const report = await probe(provider, [{ status: 403, body: { code, message: 'private message' } }])
expect(report.operations[0]).toMatchObject({ status: 403 })
expect(report.operations[0]?.businessCode).toBe(code === 'forbidden' ? code : undefined)
})
})
it('rejects RAGFlow nonzero business codes in catalog, detail and retrieval', async () => {
const failure = { body: { code: 102, data: [{ id: 'fixture-dataset' }] } }
const catalogReport = await probe('ragflow', [failure])
expect(catalogReport.catalog).toMatchObject({ success: false, failure: 'business-error' })
expect(catalogReport.catalog).not.toHaveProperty('count')
const report = await probe('ragflow', [
{ body: { code: 0, data: [{ id: 'fixture-dataset' }] } },
failure, failure, failure, failure
])
expect(report.detail).toMatchObject({ success: false, failure: 'business-error' })
for (const retrieval of report.retrievals) {
expect(retrieval).toMatchObject({ success: false, failure: 'business-error' })
expect(retrieval).not.toHaveProperty('count')
}
})
@@ -23,18 +23,22 @@ type ProbeReport = {
providers: ProbeProvider[]
}
async function report(): Promise<ProbeReport> {
async function report(filename = 'external-knowledge-probe-baseline.json'): Promise<ProbeReport> {
return JSON.parse(
await readFile(
resolve('docs/features/knowledge-base/external-knowledge-probe-baseline.json'),
resolve('docs/features/knowledge-base', filename),
'utf8'
)
) as ProbeReport
}
describe('external knowledge probe baseline', () => {
it('contains no endpoint, credential, remote identifier, or mutation operation', async () => {
const value = await report()
it.each([
'external-knowledge-probe-baseline.json',
'external-knowledge-probe-2026-09-10-strict.json',
'external-knowledge-probe-2026-09-10-extended.json'
])('%s contains no endpoint, credential, remote identifier, or mutation operation', async (filename) => {
const value = await report(filename)
const serialized = JSON.stringify(value)
expect(serialized).not.toMatch(/https?:\/\//)