diff --git a/docs/features/knowledge-base/README.md b/docs/features/knowledge-base/README.md index b80d9725..31d3ec6f 100644 --- a/docs/features/knowledge-base/README.md +++ b/docs/features/knowledge-base/README.md @@ -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) | 脱敏记录真实实例的接口状态、字段结构、特色能力和耗时 | ## 术语 diff --git a/docs/features/knowledge-base/external-knowledge-logic-design.md b/docs/features/knowledge-base/external-knowledge-logic-design.md index 16c6b5cf..5dba110f 100644 --- a/docs/features/knowledge-base/external-knowledge-logic-design.md +++ b/docs/features/knowledge-base/external-knowledge-logic-design.md @@ -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。 - 依据真实延迟确定默认并发上限和跨知识库上下文配额。 diff --git a/docs/features/knowledge-base/external-knowledge-probe-baseline.json b/docs/features/knowledge-base/external-knowledge-probe-baseline.json new file mode 100644 index 00000000..c6f7455d --- /dev/null +++ b/docs/features/knowledge-base/external-knowledge-probe-baseline.json @@ -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" + ] + } + ] + } + ] +} diff --git a/docs/features/knowledge-base/external-knowledge-technical-design.md b/docs/features/knowledge-base/external-knowledge-technical-design.md index 7bb6cf7c..a91ee7a5 100644 --- a/docs/features/knowledge-base/external-knowledge-technical-design.md +++ b/docs/features/knowledge-base/external-knowledge-technical-design.md @@ -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 ` | @@ -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 ` | -列表响应同时包含文件夹和知识库,只有 `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__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= +``` + +默认模式覆盖目录、详情和基础检索。`--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. 实施顺序 这里的顺序只用于控制代码依赖,不裁剪产品设计: diff --git a/docs/features/knowledge-base/external-knowledge-ui-design.md b/docs/features/knowledge-base/external-knowledge-ui-design.md index 28e44f0b..8dfc41de 100644 --- a/docs/features/knowledge-base/external-knowledge-ui-design.md +++ b/docs/features/knowledge-base/external-knowledge-ui-design.md @@ -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. 外部知识库详情 选中外部绑定后,现有详情区按来源类型裁剪: diff --git a/docs/features/knowledge-base/progress.md b/docs/features/knowledge-base/progress.md index 9d00c4ed..f1ad8d40 100644 --- a/docs/features/knowledge-base/progress.md +++ b/docs/features/knowledge-base/progress.md @@ -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`;文本、页码定位与检索结果不受 diff --git a/scripts/external-knowledge-probe.mjs b/scripts/external-knowledge-probe.mjs new file mode 100644 index 00000000..9ea68ef5 --- /dev/null +++ b/scripts/external-knowledge-probe.mjs @@ -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) +} diff --git a/src/main/knowledge/external/external-knowledge-client.test.ts b/src/main/knowledge/external/external-knowledge-client.test.ts new file mode 100644 index 00000000..f64e9dcf --- /dev/null +++ b/src/main/knowledge/external/external-knowledge-client.test.ts @@ -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() + .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() + .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() + .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().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>({ + 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().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().mockResolvedValue(new Response('not-json')) + }) + await expect(invalid.listKnowledgeBases()).rejects.toMatchObject({ + code: 'EXTERNAL_KB_INVALID_RESPONSE' + }) + }) +}) diff --git a/src/main/knowledge/external/external-knowledge-client.ts b/src/main/knowledge/external/external-knowledge-client.ts new file mode 100644 index 00000000..5f8fb7fe --- /dev/null +++ b/src/main/knowledge/external/external-knowledge-client.ts @@ -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 | undefined => + value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : 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 { + 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 { + 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 { + 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 { + 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') + } +} diff --git a/tests/external-knowledge-probe-report.test.ts b/tests/external-knowledge-probe-report.test.ts new file mode 100644 index 00000000..7ce931c3 --- /dev/null +++ b/tests/external-knowledge-probe-report.test.ts @@ -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 + retrievals: Array> + operations: ProbeOperation[] +} + +type ProbeReport = { + schemaVersion: number + privacy: string + providers: ProbeProvider[] +} + +async function report(): Promise { + 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' }) + ])) + }) +})