fuli-context 0.5.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/README.en.md +2 -333
  2. package/README.md +370 -202
  3. package/README.zh-CN.md +448 -0
  4. package/compose.graphiti.yml +6 -6
  5. package/dist/web/assets/AboutPage-DXh7Fwxt.css +1 -0
  6. package/dist/web/assets/AboutPage-DfnZo7Lo.js +1 -0
  7. package/dist/web/assets/ConnectionsPage-BloaSKmt.js +1 -0
  8. package/dist/web/assets/ConnectionsPage-CBHAdZXT.css +1 -0
  9. package/dist/web/assets/KnowledgeInspector-Bjhdu2mf.css +1 -0
  10. package/dist/web/assets/KnowledgeInspector-QvNpv0_k.js +1 -0
  11. package/dist/web/assets/KnowledgeOrganizerPage-Cx1w1jb2.css +1 -0
  12. package/dist/web/assets/KnowledgeOrganizerPage-DKq1jVD5.js +1 -0
  13. package/dist/web/assets/KnowledgePage-BWL0jT01.js +1 -0
  14. package/dist/web/assets/KnowledgeWorkspace-Czq6aZ7b.js +4 -0
  15. package/dist/web/assets/OverviewPage-5sJoO1Gh.js +1 -0
  16. package/dist/web/assets/{PersonalProfilePage-S8ZcG2LU.css → PersonalProfilePage-CdmEzyvZ.css} +1 -1
  17. package/dist/web/assets/PersonalProfilePage-tVChM9ik.js +1 -0
  18. package/dist/web/assets/PersonalProjectsPage-BPFrup4V.js +1 -0
  19. package/dist/web/assets/PublicProjectsPage-BPJSeREN.js +1 -0
  20. package/dist/web/assets/ReviewPage-B7EN817i.js +1 -0
  21. package/dist/web/assets/SearchableSelect-ClLN0rIb.js +1 -0
  22. package/dist/web/assets/SettingsPage-C6ouU0sR.css +1 -0
  23. package/dist/web/assets/SettingsPage-Chd_B3eH.js +1 -0
  24. package/dist/web/assets/_plugin-vue_export-helper-BDNMzG2s.js +1 -0
  25. package/dist/web/assets/console-CbDsKrsO.js +1 -0
  26. package/dist/web/assets/fuli-logo-Bu9WBuQe.js +1 -0
  27. package/dist/web/assets/fuli-logo-ER1wQ8Kk.png +0 -0
  28. package/dist/web/assets/i18n-BQcns1PH.js +5 -0
  29. package/dist/web/assets/identity-B8K4VYg6.js +1 -0
  30. package/dist/web/assets/index-CB8Xmsc2.js +2 -0
  31. package/dist/web/assets/index-CZeuPh-U.css +1 -0
  32. package/dist/web/assets/model-eF52GgPF.js +1 -0
  33. package/dist/web/assets/paths-hwz-Ac1g.js +1 -0
  34. package/dist/web/assets/useMinimumLoadingDisplay-BpCyBeko.css +1 -0
  35. package/dist/web/assets/useMinimumLoadingDisplay-wCgGDmHg.js +1 -0
  36. package/dist/web/assets/vue-router-BsEp_XwI.js +1 -0
  37. package/dist/web/index.html +9 -6
  38. package/docs/external-knowledge-architecture.md +279 -0
  39. package/examples/external-knowledge/markdown-folder.mjs +235 -0
  40. package/graph-provider/fuli_graph/app.py +57 -1
  41. package/graph-provider/fuli_graph/graph_models.py +3 -0
  42. package/graph-provider/fuli_graph/graph_query.py +62 -16
  43. package/graph-provider/fuli_graph/knowledge_review.py +612 -0
  44. package/graph-provider/fuli_graph/knowledge_review_models.py +127 -0
  45. package/graph-provider/fuli_graph/knowledge_search.py +51 -4
  46. package/graph-provider/fuli_graph/models.py +19 -0
  47. package/graph-provider/fuli_graph/runtime.py +15 -0
  48. package/graph-provider/fuli_graph/store_knowledge.py +2 -1
  49. package/graph-provider/pyproject.toml +1 -1
  50. package/npm-shrinkwrap.json +5 -407
  51. package/package.json +7 -5
  52. package/skills/capturing-session-knowledge/SKILL.md +24 -4
  53. package/skills/flreview/SKILL.md +73 -0
  54. package/skills/flreview/agents/openai.yaml +6 -0
  55. package/src/agent-tools/graph-definitions.js +79 -4
  56. package/src/agent-tools/graph-handlers.js +12 -0
  57. package/src/cli/command-registry.js +3 -3
  58. package/src/cli/local-runtime-command.js +51 -28
  59. package/src/cli/local-runtime-options.js +14 -8
  60. package/src/cli/setup-agent-selection.js +12 -12
  61. package/src/cli/setup-command.js +41 -18
  62. package/src/cli/shell-argument.js +10 -0
  63. package/src/cli/uninstall-command.js +17 -15
  64. package/src/cli/update-command.js +38 -30
  65. package/src/external-knowledge/catalog.js +61 -0
  66. package/src/external-knowledge/conflict-policy.js +58 -0
  67. package/src/external-knowledge/connected-search.js +166 -0
  68. package/src/external-knowledge/connectors/custom.js +133 -0
  69. package/src/external-knowledge/connectors/feishu.js +461 -0
  70. package/src/external-knowledge/connectors/mcp.js +438 -0
  71. package/src/external-knowledge/connectors/notion.js +307 -0
  72. package/src/external-knowledge/connectors/retrieval-api.js +222 -0
  73. package/src/external-knowledge/document-mapping.js +151 -0
  74. package/src/external-knowledge/graph-projection.js +17 -0
  75. package/src/external-knowledge/index.js +8 -0
  76. package/src/external-knowledge/registry.js +91 -0
  77. package/src/external-knowledge/runtime.js +68 -0
  78. package/src/external-knowledge/safe-diagnostic.js +14 -0
  79. package/src/external-knowledge/service.js +811 -0
  80. package/src/graphiti/agent-knowledge-workflows.js +7 -2
  81. package/src/graphiti/federated-application.js +66 -41
  82. package/src/graphiti/knowledge-item-role.js +21 -0
  83. package/src/graphiti/knowledge-review.js +43 -0
  84. package/src/graphiti/project-profile-mapping.js +32 -0
  85. package/src/graphiti/provider-client.js +22 -1
  86. package/src/graphiti/source-marker.js +54 -3
  87. package/src/graphiti/task-knowledge-recall.js +250 -0
  88. package/src/http/api-router.js +23 -108
  89. package/src/http/external-knowledge-api-router.js +104 -0
  90. package/src/http/graph-api-router.js +12 -1
  91. package/src/http/request-policy.js +1 -1
  92. package/src/local-runtime/lifecycle.js +26 -15
  93. package/src/mcp/create-mcp-server.js +1 -0
  94. package/src/mcp/instructions.js +7 -7
  95. package/src/mcp/tool-annotations.js +6 -0
  96. package/src/mcp/tool-result.js +1 -0
  97. package/src/server/application-lifecycle.js +2 -11
  98. package/src/server/blocked-ports.js +11 -0
  99. package/src/server.js +36 -17
  100. package/src/setup/agent-installation-status.js +2 -1
  101. package/src/setup/agents.js +2 -1
  102. package/src/setup/claude-code-config.js +2 -1
  103. package/src/setup/codex-bootstrap.js +12 -7
  104. package/src/setup/container-runtime.js +25 -21
  105. package/src/setup/graph-runtime.js +148 -56
  106. package/src/setup/node-runtime.js +3 -3
  107. package/src/setup/options.js +2 -4
  108. package/src/setup/paths.js +18 -3
  109. package/src/setup/review-skill-adapters.js +49 -0
  110. package/src/setup/setup.js +26 -2
  111. package/src/setup/uninstall.js +4 -2
  112. package/src/system/resource-monitor.js +317 -0
  113. package/src/system/runtime-settings.js +131 -0
  114. package/src/system/system-service.js +119 -0
  115. package/dist/web/assets/ConnectionsPage-CCKWm-6p.js +0 -1
  116. package/dist/web/assets/KnowledgeInspector-BZkB2wJn.js +0 -1
  117. package/dist/web/assets/KnowledgeInspector-CrGYxEob.css +0 -1
  118. package/dist/web/assets/KnowledgeOrganizerPage-CZAF_OlN.css +0 -1
  119. package/dist/web/assets/KnowledgeOrganizerPage-DlvcLwlL.js +0 -1
  120. package/dist/web/assets/KnowledgePage-CNQXd15y.js +0 -1
  121. package/dist/web/assets/KnowledgeWorkspace-CXPE4GCD.js +0 -4
  122. package/dist/web/assets/OverviewPage-ul7g7HXh.js +0 -1
  123. package/dist/web/assets/PersonalProfilePage-CC40Pw2L.js +0 -1
  124. package/dist/web/assets/PersonalProjectsPage-CPIEcShD.js +0 -1
  125. package/dist/web/assets/PublicProjectsPage-C700sR7z.js +0 -1
  126. package/dist/web/assets/ReviewPage-7MDDUDgA.js +0 -1
  127. package/dist/web/assets/console-B_Ena2DS.js +0 -5
  128. package/dist/web/assets/fuli-logo-DGuyqchQ.png +0 -0
  129. package/dist/web/assets/identity-CjBwYSMX.js +0 -1
  130. package/dist/web/assets/index-CpLDx3of.css +0 -1
  131. package/dist/web/assets/index-DAzeFJSc.js +0 -2
  132. package/dist/web/assets/model-C-5RVIJ2.js +0 -1
  133. package/dist/web/assets/paths-BkULqmGI.js +0 -1
  134. package/dist/web/assets/vue-router-DnD3hUm7.js +0 -1
  135. package/src/agent/candidate-read-service.js +0 -53
  136. package/src/agent/context-pack-service.js +0 -83
  137. package/src/agent/public-read-policy.js +0 -39
  138. package/src/agent/public-read-service.js +0 -69
  139. package/src/agent/read-projection.js +0 -107
  140. package/src/agent/search-service.js +0 -61
  141. package/src/agent/space-boundary.js +0 -33
  142. package/src/app/bootstrap.js +0 -8
  143. package/src/app/create-application.js +0 -143
  144. package/src/app/state-service.js +0 -38
  145. package/src/candidates.js +0 -78
  146. package/src/classifier.js +0 -63
  147. package/src/cli/command-arguments.js +0 -14
  148. package/src/context-pack.js +0 -206
  149. package/src/extractor.js +0 -77
  150. package/src/fact-history.js +0 -39
  151. package/src/facts/fact-service.js +0 -89
  152. package/src/http/bounded-json-array.js +0 -19
  153. package/src/http/lens-route.js +0 -50
  154. package/src/ingestion.js +0 -161
  155. package/src/lens/lens-correction-service.js +0 -81
  156. package/src/lens/lens-fact-writer.js +0 -85
  157. package/src/lens/lens-input.js +0 -62
  158. package/src/lens/lens-query-projection.js +0 -87
  159. package/src/lens/lens-query-ranking.js +0 -62
  160. package/src/lens/lens-query.js +0 -143
  161. package/src/lens/lens-resource-projection.js +0 -93
  162. package/src/lens/lens-resource-selection.js +0 -45
  163. package/src/lens/lens-resource-service.js +0 -103
  164. package/src/lens/lens-search-query.js +0 -122
  165. package/src/lens/lens-service.js +0 -183
  166. package/src/models.js +0 -44
  167. package/src/observer.js +0 -83
  168. package/src/project-rules.js +0 -33
  169. package/src/publication/outbox-service.js +0 -46
  170. package/src/publication/publication-envelope-schema.js +0 -75
  171. package/src/publication/publication-envelope.js +0 -37
  172. package/src/publication/publication-policy.js +0 -82
  173. package/src/publication/publication-service.js +0 -48
  174. package/src/router.js +0 -58
  175. package/src/runtime-options.js +0 -138
  176. package/src/security/episode-visibility.js +0 -14
  177. package/src/security/fact-visibility.js +0 -15
  178. package/src/security/source-metadata-policy.js +0 -8
  179. package/src/storage/episode-evidence-preview.js +0 -100
  180. package/src/storage/fact-search-order.js +0 -18
  181. package/src/storage/file/episode-evidence.js +0 -37
  182. package/src/storage/file/episode-index.js +0 -59
  183. package/src/storage/file/fact-search.js +0 -60
  184. package/src/storage/file-store.js +0 -437
  185. package/src/storage/migrate.js +0 -128
  186. package/src/storage/migrations/001-initial.sql +0 -83
  187. package/src/storage/migrations/002-storage-integrity.sql +0 -113
  188. package/src/storage/record-validation.js +0 -46
  189. package/src/storage/snapshot-defaults.js +0 -54
  190. package/src/storage/sqlite/candidates-repository.js +0 -84
  191. package/src/storage/sqlite/episode-evidence-repository.js +0 -154
  192. package/src/storage/sqlite/episode-privacy-sql.js +0 -33
  193. package/src/storage/sqlite/episodes-repository.js +0 -52
  194. package/src/storage/sqlite/fact-search-repository.js +0 -162
  195. package/src/storage/sqlite/facts-repository.js +0 -146
  196. package/src/storage/sqlite/imports-repository.js +0 -39
  197. package/src/storage/sqlite/mapper.js +0 -84
  198. package/src/storage/sqlite/open-database.js +0 -15
  199. package/src/storage/sqlite/outbox-repository.js +0 -103
  200. package/src/storage/sqlite/snapshot-repository.js +0 -69
  201. package/src/storage/sqlite/spaces-repository.js +0 -52
  202. package/src/storage/sqlite/subscriptions-repository.js +0 -54
  203. package/src/storage/sqlite-store.js +0 -159
  204. package/src/storage/stable-json.js +0 -24
  205. package/src/storage/store-port.js +0 -20
  206. package/src/store.js +0 -2
@@ -0,0 +1,279 @@
1
+ # 外部知识库只读接入架构
2
+
3
+ 更新口径:2026-08-01。
4
+
5
+ 这套实现把第三方知识库当作“有来源、有权限边界的只读证据”,而不是 Fuli 可以反向修改的
6
+ 数据库。一个外部连接可以绑定到一个或多个现有个人项目,每个目标通过实时检索、镜像同步或
7
+ 混合模式参与 Agent 回答;它不会自动进入公共空间,也不会因一次 Agent 判断而被改写或确认。
8
+
9
+ 不存在可以脱离真实权限、规模和故障数据而证明“完美”的知识库架构。当前版本的目标是可用、
10
+ 可审计、故障隔离,并为后续增量同步和公共空间提升保留稳定边界。
11
+
12
+ ## 状态
13
+
14
+ | 标记 | 含义 |
15
+ | --- | --- |
16
+ | 🟢 已实现 | 已有运行时代码和自动化测试 |
17
+ | 🟠 Beta | 已有可调用路径,但公共部署和大规模真实数据仍需持续验证 |
18
+ | ⚪ TODO | 只保留架构位置,当前不应宣称可用 |
19
+
20
+ ```mermaid
21
+ flowchart LR
22
+ subgraph S["第三方知识源(只读)"]
23
+ MCP["MCP Server"]
24
+ NO["Notion"]
25
+ FS["飞书 / Lark"]
26
+ RA["Dify 兼容 Retrieval API"]
27
+ CU["可信本地自定义连接器"]
28
+ end
29
+
30
+ GW["连接器网关\ncheck / discover / sync / retrieve"]
31
+ BR["连接来源 + 多项目目标注册表\n目标独立模式 / 状态,无明文凭据"]
32
+ GN["项目图谱投影\n外部知识源节点 + 绑定关系"]
33
+ MP["文档规范化、哈希和分块"]
34
+ PG["个人项目图谱\npending + restricted"]
35
+ AG["聚合检索\n保留独立来源集合"]
36
+ CP["项目冲突策略\n询问用户 / Agent 本次判断"]
37
+ AI["Agent 对话\n说明来源、冲突和取舍"]
38
+ PS["选定公共项目检索\nBeta"]
39
+ PP["外部知识提升到公共空间\nTODO:人工审核与原子发布"]
40
+ DS["定时、Webhook、完整缺失项对账\nTODO"]
41
+ NW["第三方源写入\n永久禁止"]
42
+
43
+ MCP --> GW
44
+ NO --> GW
45
+ FS --> GW
46
+ RA --> GW
47
+ CU --> GW
48
+ BR --> GW
49
+ BR --> GN
50
+ GW -->|"mirror / hybrid 手动同步"| MP
51
+ MP --> PG
52
+ PG --> AG
53
+ GW -->|"live / hybrid 按查询读取"| AG
54
+ PS --> AG
55
+ CP --> AG
56
+ AG --> AI
57
+ PG -.-> PP
58
+ GW -.-> DS
59
+ GW -.-> NW
60
+
61
+ classDef done fill:#e8f5ed,stroke:#3f7f5d,color:#173d29;
62
+ classDef beta fill:#fff3d6,stroke:#c8841a,color:#5f3a00,stroke-dasharray:6 3;
63
+ classDef todo fill:#f2f3f3,stroke:#89918b,color:#4d5350,stroke-dasharray:2 4;
64
+ class MCP,NO,FS,RA,CU,GW,BR,GN,MP,PG,AG,CP,AI done;
65
+ class PS beta;
66
+ class PP,DS,NW todo;
67
+ ```
68
+
69
+ ## 成熟模式的取舍
70
+
71
+ 当前边界来自以下成熟做法,而不是把某一家产品的私有模型照搬进来:
72
+
73
+ - MCP 的 Resources 使用 `resources/list`、分页和 `resources/read` 表达可枚举的只读上下文;
74
+ 远程授权规范采用 OAuth 2.1。Fuli 因此把 MCP 作为默认协议,但当前 HTTP 客户端只支持预先
75
+ 配置的 Bearer/Header 环境变量,交互式 OAuth 流程仍是 TODO。
76
+ - Microsoft Graph connectors 把外部项拆成稳定 ID、属性、正文和 ACL,并区分完整爬取与增量
77
+ 爬取。Fuli 同样使用稳定外部 ID、内容哈希、游标和目标项目绑定,但不复制第三方 ACL 到
78
+ 公共空间。
79
+ - Elastic connectors 明确区分 full sync、incremental sync 和 deletion sync。当前 Fuli 已支持
80
+ 游标分页、哈希去重、连接器显式删除和解绑失效;定时调度、Webhook 和“完整快照缺失即删除”
81
+ 对账仍为 TODO。
82
+ - Glean 的检索强调权限感知结果和可追溯引用。Fuli 不把来源集合先压成一个无来源的答案;
83
+ 个人图谱、公共项目和每个外部绑定分别返回,最终由 Agent 引用实际看到的证据。
84
+ - Open WebUI 允许把知识集合显式附加给模型或按需工具。Fuli 对应采用显式项目目标,不让一个
85
+ 已连接来源默认泄漏到所有项目。
86
+ - Dify 的外部知识库协议把检索抽象为 `knowledge_id + query + retrieval_setting`。Fuli 内置
87
+ `retrieval_api` 连接器调用兼容端点;不兼容的 RAGFlow 或其他系统应在前面放只读适配器,或
88
+ 使用受信任自定义连接器。
89
+
90
+ 参考:
91
+
92
+ - [MCP Resources 规范(2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/server/resources)
93
+ - [MCP Authorization 规范(2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
94
+ - [Microsoft Graph connectors externalItem](https://learn.microsoft.com/en-us/graph/api/resources/externalconnectors-externalitem)
95
+ - [Microsoft Graph connectors 同步模型](https://learn.microsoft.com/en-us/graph/connecting-external-content-connectors-api-overview)
96
+ - [Elastic connector sync types](https://www.elastic.co/guide/en/elasticsearch/reference/current/es-connectors-sync-types.html)
97
+ - [Glean Developer Platform](https://developers.glean.com/)
98
+ - [Open WebUI Knowledge](https://docs.openwebui.com/features/workspace/knowledge/)
99
+ - [Dify 外部知识库接入](https://dify-6c0370d8.mintlify.app/versions/3-0-x/zh/user-guide/knowledge-base/connect-external-knowledge-base)
100
+
101
+ ## 运行模型
102
+
103
+ ### 绑定
104
+
105
+ 一个连接保存一份来源配置,并包含 1 至 32 个已经存在的个人项目目标。每个目标有独立 ID、
106
+ 检索模式、同步游标、镜像项索引和错误状态:
107
+
108
+ ```json
109
+ {
110
+ "name": "Product handbook",
111
+ "connectorType": "mcp",
112
+ "connectorConfig": {},
113
+ "source": {},
114
+ "targets": [
115
+ {
116
+ "personalSpaceId": "<active-personal-space>",
117
+ "personalProjectId": "<first-personal-project>",
118
+ "mode": "hybrid"
119
+ },
120
+ {
121
+ "personalSpaceId": "<active-personal-space>",
122
+ "personalProjectId": "<second-personal-project>",
123
+ "mode": "live"
124
+ }
125
+ ]
126
+ }
127
+ ```
128
+
129
+ 注册表格式为 v2。读取 v1 单项目绑定时,会把原 `target`、`mode` 和 `sync` 原样迁移成第一个
130
+ 目标;下一次写入注册表时持久化为 v2。连接响应暂时保留首目标的 `target`、`mode`、`sync`
131
+ 别名,供旧客户端平滑迁移。
132
+
133
+ 运行时拒绝把明文 `token`、`secret`、`password`、`apiKey` 等字段写入注册表。配置中只保存
134
+ 环境变量名称;真实值只在调用连接器时从进程环境读取。外部返回内容仍会经过 Fuli 的凭据检测。
135
+ 命中凭据边界的单个文档会被安全跳过,其余文档继续处理;跳过数量会进入同步和检索结果。
136
+
137
+ ### 三种模式
138
+
139
+ | 模式 | 查询时行为 | 本地图谱 | 适合场景 |
140
+ | --- | --- | --- | --- |
141
+ | `live` | 每次向外部源只读查询 | 不镜像 | 强实时、外部搜索质量高 |
142
+ | `mirror` | 只查已同步的个人图谱 | 手动同步 | 可离线、稳定项目资料 |
143
+ | `hybrid` | 同时返回图谱和实时结果 | 手动同步 | 默认;兼顾沉淀与时效 |
144
+
145
+ 混合检索不会在后端把多来源正文强行合成一条“事实”。这样 Agent 才能看到来源、状态和时间,
146
+ 并在发生矛盾时说明判断依据。
147
+
148
+ ### 同步状态
149
+
150
+ 1. 连接器按游标返回规范化文档。
151
+ 2. Fuli 对标题、正文、URL、更新时间和元数据计算 SHA-256。
152
+ 3. 未变化文档跳过;新文档或新版本按目标分别分块写入绑定的个人项目,幂等键包含目标 ID。
153
+ 4. 外部镜像统一标记为 `observed`、`pending`、`restricted`、`local_only`。
154
+ 5. 可疑凭据文档不会进入图谱或 Agent 上下文;若同一外部 ID 原有安全镜像,旧镜像会失效。
155
+ 6. 新版本写入后,旧版本实体通过修订记录失效;连接器报告删除或用户解绑时也会失效。
156
+ 7. 第三方源从不接收 Fuli 写操作。
157
+
158
+ 同步采用“新版本先写入、旧版本后失效”。如果新内容无法通过安全检查,旧版本仍保持有效,
159
+ 避免半更新。一个目标失败不会复用另一个目标的游标或镜像索引。当前没有后台定时器;同步由
160
+ 连接页或 API 显式触发。
161
+
162
+ ### 图谱投影
163
+
164
+ 个人项目图谱读取时,Fuli 会从绑定注册表动态追加 `ExternalKnowledgeSource` 节点和
165
+ `USES_EXTERNAL_KNOWLEDGE` 关系。投影只表达“项目使用这个来源”,不伪造正文实体,也不会
166
+ 修改个人项目档案。解除目标绑定后关系立即消失;若目标曾有镜像内容,镜像实体按修订流程失效。
167
+
168
+ ## 连接器
169
+
170
+ | 类型 | 读取能力 | 凭据 | 当前边界 |
171
+ | --- | --- | --- | --- |
172
+ | MCP | Resources `list/read`;可配置 `search` / `fetch` 只读工具 | Bearer/Header 环境变量或 stdio 环境映射 | HTTPS 或回环 HTTP;stdio 命令必须受信任;远程交互式 OAuth TODO |
173
+ | Notion | 页面 Markdown、Data Source 查询、工作区搜索 | Integration token 环境变量 | API 版本 `2026-03-11`;绑定必须明确 page IDs 或 data source IDs |
174
+ | 飞书 / Lark | Wiki 节点、子节点、搜索和 Docx 纯文本 | access token,或 app ID/secret 环境变量 | 当前只提取 Docx 正文;Wiki 搜索通常需要 user access token |
175
+ | RAG Retrieval API | Dify 兼容 `POST` 检索 | 可选 Bearer token 环境变量 | 仅 `live`;HTTPS 或回环 HTTP;稳定文档元数据可提升来源质量 |
176
+ | 自定义代码 | 由本地 ESM 模块实现 `sync` / `retrieve` | 只暴露用户列出的环境变量 | 可信代码,不是进程沙箱;模块必须位于连接器目录内 |
177
+
178
+ Notion 当前 Markdown 接口见 [Retrieve a page as markdown](https://developers.notion.com/guides/data-apis/working-with-markdown-content)。
179
+ 飞书接口依据 [获取文档纯文本内容](https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document/raw_content)、
180
+ [获取知识空间节点信息](https://open.feishu.cn/document/server-docs/docs/wiki-v2/space-node/get_node)和
181
+ [搜索知识空间](https://open.feishu.cn/document/server-docs/docs/wiki-v2/search_wiki)。
182
+
183
+ ### 自定义连接器
184
+
185
+ 模板见 [markdown-folder.mjs](../examples/external-knowledge/markdown-folder.mjs)。安装时把受信任模块放入
186
+ `${dataDir}/external-knowledge/connectors/`,然后在连接页填写相对模块名、来源 JSON 和允许暴露的
187
+ 环境变量名称。
188
+
189
+ 模块契约:
190
+
191
+ ```js
192
+ export default {
193
+ async check(context) {},
194
+ async discover(context) {},
195
+ async sync({ config, source, cursor, limit, env }) {},
196
+ async retrieve({ config, source, query, limit, env }) {}
197
+ }
198
+ ```
199
+
200
+ `context` 同时包含绑定 `mode`;连接建立时会校验实际协商能力是否满足该模式。MCP 以服务端
201
+ 协商的 Resources / Tools 能力为准,不会因为客户端对象“刚好有同名方法”就假定服务端可用。
202
+
203
+ `sync` 返回 `{ items, deleted, nextCursor, hasMore }`;`retrieve` 返回 `{ items }`。每个 item 至少
204
+ 包含稳定 `id`、`title` 和非空 `content`。模块可以读取网络或本地文件,因此只有用户明确安装的
205
+ 本地代码才应进入这个目录。
206
+
207
+ ## 冲突策略
208
+
209
+ 冲突策略按个人项目保存:
210
+
211
+ - `ask_human`(默认):Agent 发现对当前回答有实质影响的矛盾时,在对话中并列来源和差异,
212
+ 请用户选择;不改写任何来源。
213
+ - `agent_decide`:Agent 可以只为当前回答选择更适用的证据,必须说明时间、作用域、权威和
214
+ 来源依据;这个判断不会确认、失效或修改图谱与第三方知识库。
215
+
216
+ 后端只判断“图谱与外部源是否同时有证据并要求语义评估”,不伪装成已经解决语义冲突。真正的
217
+ 语义比较由看到正文的 Agent 完成。若用户之后要把结论沉淀成长期知识,仍走原有确认、决策轨迹
218
+ 或人工审核流程。
219
+
220
+ ## 公共空间
221
+
222
+ `search_connected_knowledge` 可以在一次调用中查询:
223
+
224
+ - 当前个人项目及获授权的个人项目来源;
225
+ - 当前项目绑定的实时外部知识;
226
+ - 调用方明确传入的公共项目 IDs(🟠 Beta)。
227
+
228
+ 公共结果由现有项目订阅和 Provider 边界约束。当前不支持把外部绑定直接指向公共空间,也不
229
+ 允许自动把第三方内容发布为公共知识。后续公共化必须采用“个人项目候选 → 人工审核 → 预览
230
+ 令牌 → 原子发布”,并保留第三方来源和许可信息;该链路目前是 ⚪ TODO。
231
+
232
+ ## HTTP 与 Agent 接口
233
+
234
+ | 方法 | 路径 | 用途 |
235
+ | --- | --- | --- |
236
+ | `GET` | `/api/external-knowledge/connectors` | 连接器能力清单 |
237
+ | `POST` | `/api/external-knowledge/discover` | 浏览可绑定来源 |
238
+ | `GET/POST` | `/api/external-knowledge/bindings` | 列表 / 创建绑定 |
239
+ | `PATCH` | `/api/external-knowledge/bindings/:id/targets` | 多选增删项目目标并设置目标模式 |
240
+ | `POST` | `/api/external-knowledge/bindings/:id/check` | 连通性与权限检查 |
241
+ | `POST` | `/api/external-knowledge/bindings/:id/sync` | 手动同步 |
242
+ | `POST` | `/api/external-knowledge/bindings/:id/retrieve` | 单绑定实时检索 |
243
+ | `DELETE` | `/api/external-knowledge/bindings/:id` | 解绑并失效镜像实体 |
244
+ | `GET/PATCH` | `/api/external-knowledge/conflict-policy` | 读取 / 修改项目冲突策略 |
245
+ | `POST` | `/api/connected-knowledge/search` | 聚合图谱、外部源和选定公共项目 |
246
+
247
+ Agent 使用 `search_connected_knowledge`。运行时只选择与当前 `personalSpaceId + personalProjectId`
248
+ 完全匹配且允许实时检索的目标,不会因为同一来源绑定了其他项目而跨项目返回。它属于开放世界
249
+ 读取:会访问外部网络,同时图谱检索会
250
+ 记录 Agent 查看事件,因此 MCP 注解不会把它伪标成纯本地、无副作用读取。
251
+ 外部错误只返回有界诊断;疑似包含凭据的错误文本会替换为保护性说明,不写入注册表或返回给
252
+ Agent。
253
+
254
+ ## 验证
255
+
256
+ 默认测试使用显式标注的 fixture / mock 响应验证协议、分页、安全边界、API、Agent 工具和 Vue
257
+ 连接页;这些数据不是运行事实。真实联网验证使用两个公开官方文档仓库,在系统临时目录完成
258
+ 浅克隆、同步映射和只读检索,并在 `finally` 中删除全部下载内容:
259
+
260
+ ```sh
261
+ npm run test:external-knowledge:live
262
+ ```
263
+
264
+ 验证源:
265
+
266
+ - [Model Context Protocol specification and documentation](https://github.com/modelcontextprotocol/modelcontextprotocol)
267
+ - [Official Notion JavaScript client documentation](https://github.com/makenotion/notion-sdk-js)
268
+
269
+ 这个联网测试不进入默认持续集成,因为它依赖 GitHub 可用性。脚本不会复用机器的 Git 身份或
270
+ 凭据配置,也不会把下载的知识库保留在仓库或本地数据目录中。
271
+
272
+ ## 后续边界
273
+
274
+ - ⚪ MCP 远程 OAuth 2.1 授权码 / PKCE 生命周期。
275
+ - ⚪ 后台定时同步、Webhook、退避重试和每源限流仪表盘。
276
+ - ⚪ 完整快照删除对账;当前只处理连接器明确报告的删除和解绑。
277
+ - ⚪ 附件、图片、PDF、飞书 Sheet/Bitable/Slides 的专用正文提取。
278
+ - ⚪ 外部知识经人工审核提升到公共空间,并保留许可与 ACL 证明。
279
+ - ⚪ 大规模真实租户上的召回质量、延迟、配额和灾难恢复指标。
@@ -0,0 +1,235 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { basename, extname, join, relative, resolve, sep } from 'node:path';
3
+
4
+ const DEFAULT_EXTENSIONS = ['.md', '.mdx', '.txt'];
5
+ const DEFAULT_MAX_FILES = 5_000;
6
+ const DEFAULT_MAX_FILE_BYTES = 1_000_000;
7
+
8
+ export default {
9
+ async check({ source }) {
10
+ const roots = configuredRoots(source);
11
+ for (const root of roots) {
12
+ const details = await stat(root.path);
13
+ if (!details.isDirectory()) {
14
+ throw new TypeError(`Markdown root is not a directory: ${root.id}`);
15
+ }
16
+ }
17
+ return {
18
+ status: 'ready',
19
+ capabilities: ['discover', 'sync', 'retrieve'],
20
+ roots: roots.map(({ id }) => id)
21
+ };
22
+ },
23
+
24
+ async discover({ source, query, cursor, limit }) {
25
+ const documents = await readDocuments(source, { includeContent: false });
26
+ const normalizedQuery = String(query ?? '').trim().toLocaleLowerCase();
27
+ const matches = normalizedQuery
28
+ ? documents.filter((item) =>
29
+ `${item.title}\n${item.metadata.relativePath}`
30
+ .toLocaleLowerCase()
31
+ .includes(normalizedQuery)
32
+ )
33
+ : documents;
34
+ const offset = cursorOffset(cursor);
35
+ const pageSize = boundedLimit(limit);
36
+ const items = matches.slice(offset, offset + pageSize);
37
+ const nextOffset = offset + items.length;
38
+ return {
39
+ items: items.map(({ content: _content, ...item }) => ({ ...item, type: 'document' })),
40
+ nextCursor: nextOffset < matches.length ? String(nextOffset) : null,
41
+ hasMore: nextOffset < matches.length
42
+ };
43
+ },
44
+
45
+ async sync({ source, cursor, limit }) {
46
+ const documents = await readDocuments(source, { includeContent: true });
47
+ const offset = cursorOffset(cursor);
48
+ const pageSize = boundedLimit(limit);
49
+ const items = documents.slice(offset, offset + pageSize);
50
+ const nextOffset = offset + items.length;
51
+ return {
52
+ items,
53
+ deleted: [],
54
+ nextCursor: nextOffset < documents.length ? String(nextOffset) : null,
55
+ hasMore: nextOffset < documents.length
56
+ };
57
+ },
58
+
59
+ async retrieve({ source, query, limit }) {
60
+ const terms = searchTerms(query);
61
+ const documents = await readDocuments(source, { includeContent: true });
62
+ const items = documents
63
+ .map((document) => ({ document, score: documentScore(document, terms) }))
64
+ .filter(({ score }) => score > 0)
65
+ .sort((left, right) => right.score - left.score ||
66
+ left.document.id.localeCompare(right.document.id))
67
+ .slice(0, boundedLimit(limit))
68
+ .map(({ document, score }) => ({
69
+ ...document,
70
+ metadata: { ...document.metadata, retrievalScore: score }
71
+ }));
72
+ return { items };
73
+ }
74
+ };
75
+
76
+ async function readDocuments(source, { includeContent }) {
77
+ const roots = configuredRoots(source);
78
+ const extensions = configuredExtensions(source?.extensions);
79
+ const maximumFiles = boundedInteger(
80
+ source?.maxFiles,
81
+ DEFAULT_MAX_FILES,
82
+ 1,
83
+ 20_000,
84
+ 'maxFiles'
85
+ );
86
+ const maximumBytes = boundedInteger(
87
+ source?.maxFileBytes,
88
+ DEFAULT_MAX_FILE_BYTES,
89
+ 1_024,
90
+ DEFAULT_MAX_FILE_BYTES,
91
+ 'maxFileBytes'
92
+ );
93
+ const files = [];
94
+ for (const root of roots) {
95
+ await walk(root.path, files, maximumFiles, extensions, root);
96
+ if (files.length >= maximumFiles) break;
97
+ }
98
+ files.sort((left, right) => left.path.localeCompare(right.path));
99
+
100
+ const documents = [];
101
+ for (const file of files.slice(0, maximumFiles)) {
102
+ const details = await stat(file.path);
103
+ if (!details.isFile() || details.size > maximumBytes) continue;
104
+ const content = includeContent ? await readFile(file.path, 'utf8') : '';
105
+ if (includeContent && !content.trim()) continue;
106
+ const relativePath = portablePath(relative(file.root.path, file.path));
107
+ documents.push({
108
+ id: `${file.root.id}:${relativePath}`,
109
+ title: markdownTitle(content) ?? basename(relativePath, extname(relativePath)),
110
+ content,
111
+ url: onlineDocumentUrl(file.root.webBaseUrl, relativePath),
112
+ updatedAt: details.mtime.toISOString(),
113
+ metadata: {
114
+ sourceId: file.root.id,
115
+ relativePath,
116
+ extension: extname(relativePath).toLocaleLowerCase(),
117
+ bytes: details.size
118
+ }
119
+ });
120
+ }
121
+ return documents;
122
+ }
123
+
124
+ async function walk(directory, files, maximumFiles, extensions, root = null) {
125
+ const activeRoot = root ?? { path: directory };
126
+ const entries = await readdir(directory, { withFileTypes: true });
127
+ entries.sort((left, right) => left.name.localeCompare(right.name));
128
+ for (const entry of entries) {
129
+ if (files.length >= maximumFiles) return;
130
+ if (entry.name === '.git' || entry.name === 'node_modules' || entry.isSymbolicLink()) continue;
131
+ const path = join(directory, entry.name);
132
+ if (entry.isDirectory()) {
133
+ await walk(path, files, maximumFiles, extensions, activeRoot);
134
+ } else if (entry.isFile() && extensions.has(extname(entry.name).toLocaleLowerCase())) {
135
+ files.push({ path, root: activeRoot });
136
+ }
137
+ }
138
+ }
139
+
140
+ function configuredRoots(source) {
141
+ if (!Array.isArray(source?.roots) || source.roots.length === 0 || source.roots.length > 16) {
142
+ throw new TypeError('Markdown source requires between 1 and 16 roots');
143
+ }
144
+ const ids = new Set();
145
+ return source.roots.map((value) => {
146
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
147
+ throw new TypeError('Each Markdown root must be an object');
148
+ }
149
+ const id = requiredString(value.id, 'Markdown root id');
150
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(id) || ids.has(id)) {
151
+ throw new TypeError('Markdown root ids must be unique stable identifiers');
152
+ }
153
+ ids.add(id);
154
+ const path = resolve(requiredString(value.path, `Markdown root ${id} path`));
155
+ return {
156
+ id,
157
+ path,
158
+ webBaseUrl: optionalOnlineUrl(value.webBaseUrl)
159
+ };
160
+ });
161
+ }
162
+
163
+ function configuredExtensions(value) {
164
+ const values = value ?? DEFAULT_EXTENSIONS;
165
+ if (!Array.isArray(values) || values.length === 0 ||
166
+ values.some((item) => typeof item !== 'string' || !/^\.[a-z0-9]+$/iu.test(item))) {
167
+ throw new TypeError('Markdown extensions must be a nonempty array such as [".md", ".mdx"]');
168
+ }
169
+ return new Set(values.map((item) => item.toLocaleLowerCase()));
170
+ }
171
+
172
+ function markdownTitle(content) {
173
+ if (!content) return null;
174
+ const match = content.match(/^#\s+(.+)$/mu);
175
+ return match?.[1]?.replace(/\s+#+\s*$/u, '').trim() || null;
176
+ }
177
+
178
+ function searchTerms(value) {
179
+ const terms = String(value ?? '').toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) ?? [];
180
+ if (!terms.length) throw new TypeError('Markdown retrieval query is required');
181
+ return [...new Set(terms)].slice(0, 20);
182
+ }
183
+
184
+ function documentScore(document, terms) {
185
+ const title = document.title.toLocaleLowerCase();
186
+ const path = document.metadata.relativePath.toLocaleLowerCase();
187
+ const content = document.content.toLocaleLowerCase();
188
+ return terms.reduce((score, term) => score +
189
+ (title.includes(term) ? 8 : 0) +
190
+ (path.includes(term) ? 4 : 0) +
191
+ Math.min(content.split(term).length - 1, 8), 0);
192
+ }
193
+
194
+ function onlineDocumentUrl(base, relativePath) {
195
+ if (!base) return null;
196
+ const url = new URL(base);
197
+ url.pathname = `${url.pathname.replace(/\/$/u, '')}/${relativePath}`;
198
+ return url.toString();
199
+ }
200
+
201
+ function optionalOnlineUrl(value) {
202
+ if (value === undefined || value === null || value === '') return null;
203
+ const url = new URL(value);
204
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
205
+ throw new TypeError('Markdown root webBaseUrl must be an HTTP(S) URL without credentials');
206
+ }
207
+ return url.toString();
208
+ }
209
+
210
+ function cursorOffset(value) {
211
+ if (value === undefined || value === null || value === '') return 0;
212
+ if (!/^\d+$/u.test(value)) throw new TypeError('Markdown cursor is invalid');
213
+ return Number(value);
214
+ }
215
+
216
+ function boundedLimit(value) {
217
+ return boundedInteger(value, 50, 1, 100, 'limit');
218
+ }
219
+
220
+ function boundedInteger(value, fallback, minimum, maximum, label) {
221
+ const resolved = value ?? fallback;
222
+ if (!Number.isInteger(resolved) || resolved < minimum || resolved > maximum) {
223
+ throw new TypeError(`${label} must be between ${minimum} and ${maximum}`);
224
+ }
225
+ return resolved;
226
+ }
227
+
228
+ function portablePath(value) {
229
+ return sep === '/' ? value : value.split(sep).join('/');
230
+ }
231
+
232
+ function requiredString(value, label) {
233
+ if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${label} is required`);
234
+ return value.trim();
235
+ }
@@ -11,6 +11,15 @@ from .knowledge_feedback_models import (
11
11
  KnowledgeFeedbackCreate,
12
12
  KnowledgeFeedbackResult,
13
13
  )
14
+ from .knowledge_review_models import (
15
+ KnowledgeReviewCandidatePage,
16
+ KnowledgeReviewCandidateRequest,
17
+ KnowledgeReviewDecision,
18
+ KnowledgeReviewFinish,
19
+ KnowledgeReviewProgress,
20
+ KnowledgeReviewRun,
21
+ KnowledgeReviewStart,
22
+ )
14
23
  from .common_knowledge_models import (
15
24
  CommonKnowledgePromotionPreview,
16
25
  CommonKnowledgePromotionRequest,
@@ -78,6 +87,12 @@ from .knowledge_audit import (
78
87
  search_human_changes,
79
88
  )
80
89
  from .knowledge_batch_confirmation import confirm_knowledge_batch
90
+ from .knowledge_review import (
91
+ finish_knowledge_review,
92
+ list_knowledge_review_candidates,
93
+ record_knowledge_review_progress,
94
+ start_knowledge_review,
95
+ )
81
96
  from .common_knowledge import (
82
97
  apply_common_knowledge_promotion,
83
98
  preview_common_knowledge_promotion,
@@ -322,6 +337,46 @@ def create_app(settings: Settings | None = None) -> FastAPI:
322
337
  async def commit_personal(request: KnowledgeCommit, actor: Actor) -> CommitResult:
323
338
  return await store.commit_personal(actor, request)
324
339
 
340
+ @application.post(
341
+ '/v1/knowledge/reviews/start',
342
+ response_model=KnowledgeReviewRun,
343
+ )
344
+ async def start_review(
345
+ request: KnowledgeReviewStart,
346
+ actor: Actor,
347
+ ) -> KnowledgeReviewRun:
348
+ return await start_knowledge_review(store, actor, request)
349
+
350
+ @application.post(
351
+ '/v1/knowledge/reviews/candidates',
352
+ response_model=KnowledgeReviewCandidatePage,
353
+ )
354
+ async def review_candidates(
355
+ request: KnowledgeReviewCandidateRequest,
356
+ actor: Actor,
357
+ ) -> KnowledgeReviewCandidatePage:
358
+ return await list_knowledge_review_candidates(store, actor, request)
359
+
360
+ @application.post(
361
+ '/v1/knowledge/reviews/progress',
362
+ response_model=KnowledgeReviewDecision,
363
+ )
364
+ async def record_review_progress(
365
+ request: KnowledgeReviewProgress,
366
+ actor: Actor,
367
+ ) -> KnowledgeReviewDecision:
368
+ return await record_knowledge_review_progress(store, actor, request)
369
+
370
+ @application.post(
371
+ '/v1/knowledge/reviews/finish',
372
+ response_model=KnowledgeReviewRun,
373
+ )
374
+ async def finish_review(
375
+ request: KnowledgeReviewFinish,
376
+ actor: Actor,
377
+ ) -> KnowledgeReviewRun:
378
+ return await finish_knowledge_review(store, actor, request)
379
+
325
380
  @application.patch(
326
381
  '/v1/knowledge/items/{item_id}',
327
382
  response_model=KnowledgeRevisionRecord,
@@ -586,8 +641,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
586
641
  actor: Actor,
587
642
  limit: Annotated[int | None, Query(ge=1, le=2000)] = None,
588
643
  personal_project_id: Annotated[str | None, Query(min_length=1, max_length=128)] = None,
644
+ offset: Annotated[int | None, Query(ge=0)] = None,
589
645
  ) -> GraphResult:
590
- return await store.graph(actor, space_id, limit, personal_project_id)
646
+ return await store.graph(actor, space_id, limit, personal_project_id, offset)
591
647
 
592
648
  return application
593
649
 
@@ -78,6 +78,8 @@ class GraphEdge(StrictModel):
78
78
  id: str
79
79
  source: str
80
80
  target: str
81
+ source_name: str | None = None
82
+ target_name: str | None = None
81
83
  type: str
82
84
  fact: str
83
85
  origin_quadrant: EpistemicQuadrant = 'known_known'
@@ -130,3 +132,4 @@ class GraphResult(StrictModel):
130
132
  nodes: list[GraphNode]
131
133
  edges: list[GraphEdge]
132
134
  truncated: bool = False
135
+ next_offset: int | None = Field(default=None, ge=0)