fuli-context 0.2.0

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 (220) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +178 -0
  3. package/compose.graphiti.yml +86 -0
  4. package/dist/web/assets/ConnectionsPage-CY6H_mJJ.js +1 -0
  5. package/dist/web/assets/KnowledgeInspector-BSpz5Sfm.js +1 -0
  6. package/dist/web/assets/KnowledgeInspector-CHbB_0rC.css +1 -0
  7. package/dist/web/assets/KnowledgeOrganizerPage-BlEvhPrv.css +1 -0
  8. package/dist/web/assets/KnowledgeOrganizerPage-CvjGPlUc.js +1 -0
  9. package/dist/web/assets/KnowledgePage-Khuo3cNs.js +1 -0
  10. package/dist/web/assets/KnowledgeWorkspace-DVZusvem.js +4 -0
  11. package/dist/web/assets/OverviewPage-DC4C4Fhn.js +1 -0
  12. package/dist/web/assets/PersonalProfilePage-CSV9Mtgz.js +1 -0
  13. package/dist/web/assets/PersonalProfilePage-CVJ3TcRi.css +1 -0
  14. package/dist/web/assets/PersonalProjectsPage-A50EaseQ.js +1 -0
  15. package/dist/web/assets/PublicProjectsPage-BAdO_bae.js +1 -0
  16. package/dist/web/assets/ReviewPage-Dztk2jDG.js +1 -0
  17. package/dist/web/assets/console-tbvTUi6p.js +1 -0
  18. package/dist/web/assets/fuli-logo-DGuyqchQ.png +0 -0
  19. package/dist/web/assets/identity-B3s5CXhv.js +1 -0
  20. package/dist/web/assets/index-Devf7glZ.css +1 -0
  21. package/dist/web/assets/index-DwUDhQKc.js +6 -0
  22. package/dist/web/assets/model-DVT8sjy9.js +1 -0
  23. package/dist/web/assets/paths-B790iniO.js +1 -0
  24. package/dist/web/assets/runtime-dom.esm-bundler-DttsK21F.js +1 -0
  25. package/dist/web/assets/vue-router-BYTAoWT3.js +1 -0
  26. package/dist/web/index.html +22 -0
  27. package/graph-provider/Dockerfile +16 -0
  28. package/graph-provider/fuli_graph/__init__.py +5 -0
  29. package/graph-provider/fuli_graph/app.py +547 -0
  30. package/graph-provider/fuli_graph/auth.py +17 -0
  31. package/graph-provider/fuli_graph/collaboration_context.py +290 -0
  32. package/graph-provider/fuli_graph/config.py +34 -0
  33. package/graph-provider/fuli_graph/graph_models.py +123 -0
  34. package/graph-provider/fuli_graph/graph_projection.py +405 -0
  35. package/graph-provider/fuli_graph/graph_query.py +561 -0
  36. package/graph-provider/fuli_graph/knowledge_audit.py +765 -0
  37. package/graph-provider/fuli_graph/knowledge_batch_confirmation.py +246 -0
  38. package/graph-provider/fuli_graph/knowledge_management.py +816 -0
  39. package/graph-provider/fuli_graph/knowledge_records.py +65 -0
  40. package/graph-provider/fuli_graph/knowledge_search.py +723 -0
  41. package/graph-provider/fuli_graph/knowledge_usage_models.py +52 -0
  42. package/graph-provider/fuli_graph/model_validation.py +58 -0
  43. package/graph-provider/fuli_graph/models.py +1179 -0
  44. package/graph-provider/fuli_graph/personal_project_access.py +29 -0
  45. package/graph-provider/fuli_graph/preference_conflicts.py +420 -0
  46. package/graph-provider/fuli_graph/project_action_models.py +142 -0
  47. package/graph-provider/fuli_graph/project_knowledge.py +707 -0
  48. package/graph-provider/fuli_graph/project_management.py +251 -0
  49. package/graph-provider/fuli_graph/provider_values.py +40 -0
  50. package/graph-provider/fuli_graph/runtime.py +350 -0
  51. package/graph-provider/fuli_graph/search_projection.py +156 -0
  52. package/graph-provider/fuli_graph/store.py +24 -0
  53. package/graph-provider/fuli_graph/store_identity.py +237 -0
  54. package/graph-provider/fuli_graph/store_knowledge.py +447 -0
  55. package/graph-provider/fuli_graph/store_projects.py +242 -0
  56. package/graph-provider/fuli_graph/store_publication.py +370 -0
  57. package/graph-provider/fuli_graph/store_records.py +134 -0
  58. package/graph-provider/pyproject.toml +35 -0
  59. package/npm-shrinkwrap.json +6045 -0
  60. package/package.json +99 -0
  61. package/skills/capturing-session-knowledge/SKILL.md +226 -0
  62. package/skills/capturing-session-knowledge/agents/openai.yaml +13 -0
  63. package/skills/grilling-project/SKILL.md +129 -0
  64. package/skills/grilling-project/agents/openai.yaml +4 -0
  65. package/skills/grilling-project/references/project-profile.md +105 -0
  66. package/src/agent/candidate-read-service.js +53 -0
  67. package/src/agent/context-pack-service.js +83 -0
  68. package/src/agent/public-read-policy.js +39 -0
  69. package/src/agent/public-read-service.js +69 -0
  70. package/src/agent/read-projection.js +107 -0
  71. package/src/agent/search-service.js +61 -0
  72. package/src/agent/space-boundary.js +33 -0
  73. package/src/agent-tools/graph-definitions.js +479 -0
  74. package/src/agent-tools/graph-handlers.js +96 -0
  75. package/src/agent-tools/schema.js +37 -0
  76. package/src/agent-tools.js +16 -0
  77. package/src/app/application-error.js +13 -0
  78. package/src/app/bootstrap.js +8 -0
  79. package/src/app/create-application.js +143 -0
  80. package/src/app/state-service.js +38 -0
  81. package/src/candidates.js +78 -0
  82. package/src/classifier.js +63 -0
  83. package/src/cli/command-arguments.js +14 -0
  84. package/src/cli/command-registry.js +78 -0
  85. package/src/cli/commands/candidates.js +17 -0
  86. package/src/cli/commands/ingestion.js +32 -0
  87. package/src/cli/commands/reads.js +50 -0
  88. package/src/cli/commands/spaces.js +20 -0
  89. package/src/cli/invocation.js +47 -0
  90. package/src/cli/local-runtime-command.js +94 -0
  91. package/src/cli/local-runtime-options.js +64 -0
  92. package/src/cli/migrate-command.js +71 -0
  93. package/src/cli/migration-destination.js +117 -0
  94. package/src/cli/setup-agent-selection.js +195 -0
  95. package/src/cli/setup-command.js +85 -0
  96. package/src/cli/uninstall-command.js +67 -0
  97. package/src/cli.js +90 -0
  98. package/src/context-pack.js +206 -0
  99. package/src/defaults.js +1 -0
  100. package/src/extractor.js +77 -0
  101. package/src/fact-history.js +39 -0
  102. package/src/facts/fact-service.js +89 -0
  103. package/src/graphiti/agent-access-policy.js +88 -0
  104. package/src/graphiti/capture-policy.js +84 -0
  105. package/src/graphiti/federated-application.js +1292 -0
  106. package/src/graphiti/personal-context.js +29 -0
  107. package/src/graphiti/project-path-context.js +159 -0
  108. package/src/graphiti/provider-client.js +288 -0
  109. package/src/graphiti/runtime-config.js +85 -0
  110. package/src/graphiti/setup.js +43 -0
  111. package/src/graphiti/source-marker.js +142 -0
  112. package/src/http/api-router.js +133 -0
  113. package/src/http/bounded-json-array.js +19 -0
  114. package/src/http/error-mapping.js +36 -0
  115. package/src/http/graph-api-router.js +257 -0
  116. package/src/http/lens-route.js +50 -0
  117. package/src/http/request-policy.js +41 -0
  118. package/src/http/response.js +29 -0
  119. package/src/http/static-handler.js +55 -0
  120. package/src/ingestion.js +161 -0
  121. package/src/lens/lens-correction-service.js +81 -0
  122. package/src/lens/lens-fact-writer.js +85 -0
  123. package/src/lens/lens-input.js +62 -0
  124. package/src/lens/lens-query-projection.js +87 -0
  125. package/src/lens/lens-query-ranking.js +62 -0
  126. package/src/lens/lens-query.js +143 -0
  127. package/src/lens/lens-resource-projection.js +93 -0
  128. package/src/lens/lens-resource-selection.js +45 -0
  129. package/src/lens/lens-resource-service.js +103 -0
  130. package/src/lens/lens-search-query.js +122 -0
  131. package/src/lens/lens-service.js +183 -0
  132. package/src/local-runtime/lifecycle.js +254 -0
  133. package/src/mcp/create-mcp-server.js +85 -0
  134. package/src/mcp/instructions.js +10 -0
  135. package/src/mcp/project-action-preview-tokens.js +72 -0
  136. package/src/mcp/runtime.js +71 -0
  137. package/src/mcp/tool-annotations.js +63 -0
  138. package/src/mcp/tool-result.js +167 -0
  139. package/src/mcp/tool-schema.js +60 -0
  140. package/src/mcp-server.js +76 -0
  141. package/src/models.js +44 -0
  142. package/src/observer.js +83 -0
  143. package/src/package-metadata.js +6 -0
  144. package/src/project-rules.js +33 -0
  145. package/src/publication/canonical-json.js +39 -0
  146. package/src/publication/outbox-service.js +46 -0
  147. package/src/publication/publication-envelope-schema.js +75 -0
  148. package/src/publication/publication-envelope.js +37 -0
  149. package/src/publication/publication-policy.js +82 -0
  150. package/src/publication/publication-service.js +48 -0
  151. package/src/router.js +58 -0
  152. package/src/runtime-options.js +139 -0
  153. package/src/security/episode-visibility.js +14 -0
  154. package/src/security/fact-visibility.js +15 -0
  155. package/src/security/sensitive-content.js +18 -0
  156. package/src/security/source-metadata-policy.js +8 -0
  157. package/src/server/application-lifecycle.js +34 -0
  158. package/src/server/listen.js +24 -0
  159. package/src/server.js +135 -0
  160. package/src/setup/agent-installation-status.js +97 -0
  161. package/src/setup/agents.js +123 -0
  162. package/src/setup/codex-bootstrap.js +118 -0
  163. package/src/setup/codex-config.js +122 -0
  164. package/src/setup/config-backup.js +22 -0
  165. package/src/setup/container-runtime.js +388 -0
  166. package/src/setup/cursor-config.js +61 -0
  167. package/src/setup/graph-runtime.js +360 -0
  168. package/src/setup/node-runtime.js +24 -0
  169. package/src/setup/options.js +91 -0
  170. package/src/setup/paths.js +47 -0
  171. package/src/setup/session-skill.js +91 -0
  172. package/src/setup/setup.js +85 -0
  173. package/src/setup/uninstall.js +90 -0
  174. package/src/storage/episode-evidence-preview.js +100 -0
  175. package/src/storage/fact-search-order.js +18 -0
  176. package/src/storage/file/episode-evidence.js +37 -0
  177. package/src/storage/file/episode-index.js +59 -0
  178. package/src/storage/file/fact-search.js +60 -0
  179. package/src/storage/file-store.js +437 -0
  180. package/src/storage/import-json.js +70 -0
  181. package/src/storage/json-file.js +41 -0
  182. package/src/storage/legacy-record-validation.js +152 -0
  183. package/src/storage/legacy-snapshot-validation.js +32 -0
  184. package/src/storage/migrate.js +128 -0
  185. package/src/storage/migrations/001-initial.sql +83 -0
  186. package/src/storage/migrations/002-storage-integrity.sql +113 -0
  187. package/src/storage/record-validation.js +46 -0
  188. package/src/storage/snapshot-defaults.js +54 -0
  189. package/src/storage/sqlite/candidates-repository.js +84 -0
  190. package/src/storage/sqlite/episode-evidence-repository.js +154 -0
  191. package/src/storage/sqlite/episode-privacy-sql.js +33 -0
  192. package/src/storage/sqlite/episodes-repository.js +52 -0
  193. package/src/storage/sqlite/fact-search-repository.js +162 -0
  194. package/src/storage/sqlite/facts-repository.js +146 -0
  195. package/src/storage/sqlite/imports-repository.js +39 -0
  196. package/src/storage/sqlite/mapper.js +84 -0
  197. package/src/storage/sqlite/open-database.js +15 -0
  198. package/src/storage/sqlite/outbox-repository.js +103 -0
  199. package/src/storage/sqlite/snapshot-repository.js +69 -0
  200. package/src/storage/sqlite/spaces-repository.js +52 -0
  201. package/src/storage/sqlite/subscriptions-repository.js +54 -0
  202. package/src/storage/sqlite-store.js +159 -0
  203. package/src/storage/stable-json.js +24 -0
  204. package/src/storage/store-port.js +20 -0
  205. package/src/store.js +2 -0
  206. package/src/workspace-protocol/array-validation.js +19 -0
  207. package/src/workspace-protocol/base64url-contract.js +27 -0
  208. package/src/workspace-protocol/constants.js +37 -0
  209. package/src/workspace-protocol/error-contract.js +24 -0
  210. package/src/workspace-protocol/fact-contract.js +110 -0
  211. package/src/workspace-protocol/governance-contract.js +47 -0
  212. package/src/workspace-protocol/index.js +53 -0
  213. package/src/workspace-protocol/json-budget.js +19 -0
  214. package/src/workspace-protocol/manifest-contract.js +186 -0
  215. package/src/workspace-protocol/publication-contract.js +212 -0
  216. package/src/workspace-protocol/query-contract.js +149 -0
  217. package/src/workspace-protocol/scalar-contract.js +192 -0
  218. package/src/workspace-protocol/sync-contract.js +184 -0
  219. package/src/workspace-protocol/text-contract.js +39 -0
  220. package/src/workspace-protocol/validation.js +66 -0
@@ -0,0 +1,707 @@
1
+ from datetime import datetime, timezone
2
+
3
+ from fastapi import HTTPException
4
+
5
+ from .knowledge_audit import record_human_change
6
+ from .knowledge_records import conflict_record, reference_record
7
+ from .personal_project_access import authorize_personal_project
8
+ from .knowledge_management import (
9
+ _implicit_project_id,
10
+ _read_assignment,
11
+ _read_item,
12
+ reassign_knowledge_item,
13
+ )
14
+ from .models import (
15
+ KnowledgeAssignmentChange,
16
+ PersonalProjectUpsert,
17
+ ProjectProfile,
18
+ ProjectSource,
19
+ )
20
+ from .project_action_models import (
21
+ KnowledgeProjectActionRequest,
22
+ KnowledgeProjectActionResult,
23
+ KnowledgeProjectMatch,
24
+ KnowledgeProjectPreviewRecord,
25
+ KnowledgeProjectPreviewRequest,
26
+ )
27
+ from .provider_values import (
28
+ normalized_text as _normalized,
29
+ stable_uuid as _stable_uuid,
30
+ )
31
+
32
+
33
+ async def preview_knowledge_project_action(
34
+ store,
35
+ actor: dict,
36
+ item_id: str,
37
+ request: KnowledgeProjectPreviewRequest,
38
+ ) -> KnowledgeProjectPreviewRecord:
39
+ space, item = await _authorize_item(store, actor, item_id, request)
40
+ if request.mode == 'create':
41
+ source_project_id = await _source_project_id(
42
+ store, space, item_id, request.item_kind
43
+ )
44
+ target_project_id = request.new_project_id
45
+ await _ensure_new_project_id(store, space, target_project_id)
46
+ match = KnowledgeProjectMatch(
47
+ kind='none',
48
+ reason='新项目标识可用,尚未执行创建。',
49
+ )
50
+ else:
51
+ target_project_id = request.target_project_id
52
+ await authorize_personal_project(store, actor, space, target_project_id)
53
+ source_project_id = await _source_project_id(
54
+ store, space, item_id, request.item_kind
55
+ )
56
+ match = await _match_target_project(
57
+ store,
58
+ space,
59
+ item_id,
60
+ request.item_kind,
61
+ item,
62
+ target_project_id,
63
+ source_project_id,
64
+ )
65
+ return KnowledgeProjectPreviewRecord(
66
+ item_id=item_id,
67
+ item_name=item['name'],
68
+ item_summary=item.get('summary') or '',
69
+ source_project_id=source_project_id,
70
+ target_project_id=target_project_id,
71
+ match=match,
72
+ )
73
+
74
+
75
+ async def apply_knowledge_project_action(
76
+ store,
77
+ actor: dict,
78
+ item_id: str,
79
+ request: KnowledgeProjectActionRequest,
80
+ ) -> KnowledgeProjectActionResult:
81
+ space, item = await _authorize_item(store, actor, item_id, request)
82
+ source_project_id = await _source_project_id(
83
+ store, space, item_id, request.item_kind
84
+ )
85
+ project_created = request.mode == 'create'
86
+ if project_created:
87
+ target_project_id = request.new_project_id
88
+ await _ensure_new_project_id(store, space, target_project_id)
89
+ await store.upsert_personal_project(
90
+ actor,
91
+ PersonalProjectUpsert(
92
+ personal_space_id=space['id'],
93
+ project_id=target_project_id,
94
+ profile=_new_project_profile(item_id, item, request),
95
+ ),
96
+ )
97
+ match = KnowledgeProjectMatch(
98
+ kind='none',
99
+ reason='新项目中还没有同名知识,将以当前节点作为项目起点。',
100
+ )
101
+ else:
102
+ target_project_id = request.target_project_id
103
+ await authorize_personal_project(store, actor, space, target_project_id)
104
+ match = await _match_target_project(
105
+ store,
106
+ space,
107
+ item_id,
108
+ request.item_kind,
109
+ item,
110
+ target_project_id,
111
+ source_project_id,
112
+ )
113
+
114
+ if match.kind == 'already_linked':
115
+ relation_present = await _maybe_create_project_relation(
116
+ store,
117
+ space,
118
+ source_project_id,
119
+ target_project_id,
120
+ request,
121
+ actor['id'],
122
+ )
123
+ result = _action_result(
124
+ 'already_linked', source_project_id, target_project_id,
125
+ project_created, relation_present, match,
126
+ )
127
+ await _record_project_action_if_human(
128
+ store, actor, space, item_id, request
129
+ )
130
+ return result
131
+
132
+ if source_project_id is None and match.kind == 'none':
133
+ await reassign_knowledge_item(
134
+ store,
135
+ actor,
136
+ item_id,
137
+ KnowledgeAssignmentChange(
138
+ personal_space_id=space['id'],
139
+ item_kind=request.item_kind,
140
+ target_project_id=target_project_id,
141
+ reason=request.reason,
142
+ operation_actor=request.operation_actor,
143
+ ),
144
+ )
145
+ status = 'created' if project_created else 'linked'
146
+ return _action_result(
147
+ status, None, target_project_id, project_created, False, match,
148
+ )
149
+
150
+ reference_status = _reference_status(match.kind, request.conflict_resolution)
151
+ reference = await _upsert_reference(
152
+ store,
153
+ space,
154
+ actor,
155
+ item_id,
156
+ request.item_kind,
157
+ target_project_id,
158
+ source_project_id,
159
+ reference_status,
160
+ match.item_id,
161
+ request.reason,
162
+ )
163
+ conflict = None
164
+ if match.kind == 'conflict':
165
+ conflict = await _upsert_conflict(
166
+ store,
167
+ space,
168
+ actor,
169
+ item_id,
170
+ match.item_id,
171
+ source_project_id,
172
+ target_project_id,
173
+ request.conflict_resolution,
174
+ request.reason,
175
+ )
176
+
177
+ relation_present = await _maybe_create_project_relation(
178
+ store,
179
+ space,
180
+ source_project_id,
181
+ target_project_id,
182
+ request,
183
+ actor['id'],
184
+ )
185
+
186
+ status = _action_status(
187
+ project_created, match.kind, request.conflict_resolution
188
+ )
189
+ result = _action_result(
190
+ status,
191
+ source_project_id,
192
+ target_project_id,
193
+ project_created,
194
+ relation_present,
195
+ match,
196
+ reference,
197
+ conflict,
198
+ )
199
+ await _record_project_action_if_human(
200
+ store, actor, space, item_id, request
201
+ )
202
+ return result
203
+
204
+
205
+ async def _record_project_action_if_human(
206
+ store,
207
+ actor,
208
+ space,
209
+ item_id,
210
+ request,
211
+ ):
212
+ if request.operation_actor != 'human':
213
+ return
214
+ await record_human_change(
215
+ store,
216
+ actor,
217
+ space,
218
+ item_id,
219
+ request.item_kind,
220
+ reason=request.reason,
221
+ operation='project_action',
222
+ )
223
+
224
+
225
+ async def read_project_references(store, space_id: str, item_ids: list[str]):
226
+ if not item_ids:
227
+ return {}
228
+ records, _, _ = await store.runtime.driver.execute_query(
229
+ '''
230
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
231
+ [:HAS_KNOWLEDGE_REFERENCE]->
232
+ (reference:FuliKnowledgeProjectReference)
233
+ WHERE reference.item_id IN $item_ids
234
+ RETURN reference ORDER BY reference.updated_at DESC
235
+ ''',
236
+ space_id=space_id,
237
+ item_ids=item_ids,
238
+ routing_='r',
239
+ )
240
+ result = {}
241
+ for record in records:
242
+ value = reference_record(dict(record['reference']))
243
+ result.setdefault(value.item_id, []).append(value)
244
+ return result
245
+
246
+
247
+ async def read_knowledge_conflicts(store, space_id: str, item_ids: list[str]):
248
+ if not item_ids:
249
+ return {}
250
+ records, _, _ = await store.runtime.driver.execute_query(
251
+ '''
252
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
253
+ [:HAS_KNOWLEDGE_CONFLICT]->(conflict:FuliKnowledgeConflict)
254
+ WHERE conflict.item_id IN $item_ids OR conflict.target_item_id IN $item_ids
255
+ RETURN conflict ORDER BY conflict.updated_at DESC
256
+ ''',
257
+ space_id=space_id,
258
+ item_ids=item_ids,
259
+ routing_='r',
260
+ )
261
+ result = {}
262
+ for record in records:
263
+ value = conflict_record(dict(record['conflict']))
264
+ result.setdefault(value.item_id, []).append(value)
265
+ if value.target_item_id != value.item_id:
266
+ result.setdefault(value.target_item_id, []).append(value)
267
+ return result
268
+
269
+
270
+ async def read_personal_project_relations(store, space_id: str) -> list[dict]:
271
+ records, _, _ = await store.runtime.driver.execute_query(
272
+ '''
273
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
274
+ [:CONTAINS_PROJECT]->(source:FuliPersonalProject)-
275
+ [relation:PERSONAL_PROJECT_RELATION]->(target:FuliPersonalProject)
276
+ RETURN source.project_id AS source_project_id,
277
+ source.name AS source_name,
278
+ target.project_id AS target_project_id,
279
+ target.name AS target_name,
280
+ relation
281
+ ORDER BY relation.updated_at DESC
282
+ ''',
283
+ space_id=space_id,
284
+ routing_='r',
285
+ )
286
+ return [
287
+ {
288
+ 'source_project_id': record['source_project_id'],
289
+ 'source_name': record['source_name'],
290
+ 'target_project_id': record['target_project_id'],
291
+ 'target_name': record['target_name'],
292
+ **dict(record['relation']),
293
+ }
294
+ for record in records
295
+ ]
296
+
297
+
298
+ async def _authorize_item(store, actor, item_id, request):
299
+ store._require_personal()
300
+ space = await store.authorize(actor, request.personal_space_id, 'maintainer')
301
+ if space['kind'] != 'personal':
302
+ raise HTTPException(status_code=422, detail='knowledge project action is personal-only')
303
+ item = await _read_item(store, space, item_id, request.item_kind)
304
+ if item is None:
305
+ raise HTTPException(status_code=404, detail='knowledge item not found')
306
+ if item.get('profile_aspect'):
307
+ raise HTTPException(
308
+ status_code=422,
309
+ detail='personal profile knowledge cannot be added to a project',
310
+ )
311
+ if item.get('invalid_at'):
312
+ raise HTTPException(
313
+ status_code=422,
314
+ detail='historical knowledge must be restored before adding it to a project',
315
+ )
316
+ return space, item
317
+
318
+
319
+ async def _source_project_id(store, space, item_id, item_kind):
320
+ assignment = await _read_assignment(store, space['id'], item_id, item_kind)
321
+ if assignment:
322
+ return assignment.project_id
323
+ return await _implicit_project_id(store, space, item_id, item_kind)
324
+
325
+
326
+ async def _ensure_new_project_id(store, space, project_id):
327
+ records, _, _ = await store.runtime.driver.execute_query(
328
+ '''
329
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
330
+ [:CONTAINS_PROJECT]->
331
+ (project:FuliPersonalProject {project_id: $project_id})
332
+ RETURN project.project_id AS project_id
333
+ ''',
334
+ space_id=space['id'],
335
+ project_id=project_id,
336
+ routing_='r',
337
+ )
338
+ if records:
339
+ raise HTTPException(
340
+ status_code=409,
341
+ detail='personal project id already exists; choose the existing-project option',
342
+ )
343
+
344
+
345
+ def _new_project_profile(item_id, item, request):
346
+ purpose = request.new_project_purpose or item.get('summary') or None
347
+ return ProjectProfile(
348
+ name=request.new_project_name,
349
+ purpose=purpose,
350
+ scope=f'以图谱节点“{item["name"]}”作为项目知识起点。',
351
+ lifecycle='active',
352
+ sources=[ProjectSource(
353
+ key=f'graph-node-{item_id[:24]}',
354
+ kind='other',
355
+ title=f'图谱节点:{item["name"]}',
356
+ summary=item.get('summary') or '由本机个人图谱节点创建。',
357
+ sensitivity='private',
358
+ )],
359
+ )
360
+
361
+
362
+ async def _match_target_project(
363
+ store,
364
+ space,
365
+ item_id,
366
+ item_kind,
367
+ item,
368
+ target_project_id,
369
+ source_project_id,
370
+ ):
371
+ if source_project_id == target_project_id:
372
+ return KnowledgeProjectMatch(
373
+ kind='already_linked',
374
+ reason='这条知识的主要归属已经是目标项目。',
375
+ item_id=item_id,
376
+ item_name=item['name'],
377
+ item_summary=item.get('summary') or '',
378
+ )
379
+ existing = await _read_reference(
380
+ store, space['id'], item_id, item_kind, target_project_id
381
+ )
382
+ if existing and existing.status in {'active', 'pending_conflict', 'duplicate'}:
383
+ kind = {
384
+ 'active': 'already_linked',
385
+ 'pending_conflict': 'conflict',
386
+ 'duplicate': 'exact_duplicate',
387
+ }[existing.status]
388
+ target = await _read_entity_summary(
389
+ store, space, existing.matched_item_id
390
+ ) if existing.matched_item_id else None
391
+ return KnowledgeProjectMatch(
392
+ kind=kind,
393
+ reason={
394
+ 'active': '目标项目已经在引用这条知识。',
395
+ 'pending_conflict': '这条知识与目标项目内容的冲突仍在待处理。',
396
+ 'duplicate': '目标项目已经存在完全相同的内容。',
397
+ }[existing.status],
398
+ item_id=existing.matched_item_id or item_id,
399
+ item_name=(target or item).get('name'),
400
+ item_summary=(target or item).get('summary') or '',
401
+ )
402
+
403
+ records, _, _ = await store.runtime.driver.execute_query(
404
+ '''
405
+ MATCH (candidate:Entity {group_id: $group_id})
406
+ WHERE candidate.uuid <> $item_id
407
+ AND toLower(trim(candidate.name)) = $normalized_name
408
+ AND coalesce(candidate.fuli_type, 'Entity') = $item_type
409
+ AND candidate.fuli_invalid_at IS NULL
410
+ OPTIONAL MATCH (episode:Episodic {group_id: $group_id})-[:MENTIONS]->(candidate)
411
+ WITH candidate, collect(DISTINCT episode) AS episodes
412
+ OPTIONAL MATCH (assignment:FuliKnowledgeAssignment {
413
+ space_id: $space_id,
414
+ item_kind: 'entity',
415
+ item_id: candidate.uuid
416
+ })
417
+ OPTIONAL MATCH (:FuliSpace {id: $space_id})-[:HAS_KNOWLEDGE_REFERENCE]->
418
+ (reference:FuliKnowledgeProjectReference {item_id: candidate.uuid})
419
+ WITH candidate, episodes, assignment, collect(reference) AS references
420
+ WHERE assignment.project_id = $target_project_id
421
+ OR (assignment.project_id IS NULL AND any(
422
+ episode IN episodes
423
+ WHERE episode.fuli_personal_project_id = $target_project_id
424
+ ))
425
+ OR any(reference IN references WHERE
426
+ reference.project_id = $target_project_id
427
+ AND reference.status = 'active')
428
+ RETURN candidate.uuid AS id, candidate.name AS name,
429
+ coalesce(candidate.summary, '') AS summary,
430
+ coalesce(candidate.fuli_current_quadrant, 'known_known') AS current_quadrant,
431
+ coalesce(candidate.fuli_epistemic_status, 'confirmed') AS epistemic_status,
432
+ coalesce(candidate.fuli_confirmation_status, 'pending')
433
+ AS confirmation_status
434
+ ORDER BY candidate.created_at DESC LIMIT 8
435
+ ''',
436
+ group_id=space['group_id'],
437
+ space_id=space['id'],
438
+ item_id=item_id,
439
+ normalized_name=_normalized(item['name']),
440
+ item_type=item.get('type') or 'Entity',
441
+ target_project_id=target_project_id,
442
+ routing_='r',
443
+ )
444
+ exact = next((record for record in records if _same_content(item, record)), None)
445
+ if exact:
446
+ return KnowledgeProjectMatch(
447
+ kind='exact_duplicate',
448
+ reason='目标项目已经存在名称、类型和说明完全相同的内容,将复用现有节点。',
449
+ item_id=exact['id'],
450
+ item_name=exact['name'],
451
+ item_summary=exact['summary'],
452
+ )
453
+ conflict = next((record for record in records if _confirmed_current(item, record)), None)
454
+ if conflict:
455
+ return KnowledgeProjectMatch(
456
+ kind='conflict',
457
+ reason='目标项目存在同名的已确认知识,但两者说明不同。',
458
+ item_id=conflict['id'],
459
+ item_name=conflict['name'],
460
+ item_summary=conflict['summary'],
461
+ )
462
+ return KnowledgeProjectMatch(
463
+ kind='none',
464
+ reason='目标项目中没有检测到完全重复或已确认的同名冲突。',
465
+ )
466
+
467
+
468
+ async def _read_reference(store, space_id, item_id, item_kind, project_id):
469
+ records, _, _ = await store.runtime.driver.execute_query(
470
+ '''
471
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
472
+ [:HAS_KNOWLEDGE_REFERENCE]->
473
+ (reference:FuliKnowledgeProjectReference {
474
+ item_id: $item_id,
475
+ item_kind: $item_kind,
476
+ project_id: $project_id
477
+ })
478
+ RETURN reference
479
+ ''',
480
+ space_id=space_id,
481
+ item_id=item_id,
482
+ item_kind=item_kind,
483
+ project_id=project_id,
484
+ routing_='r',
485
+ )
486
+ return reference_record(dict(records[0]['reference'])) if records else None
487
+
488
+
489
+ async def _read_entity_summary(store, space, item_id):
490
+ if not item_id:
491
+ return None
492
+ return await _read_item(store, space, item_id, 'entity')
493
+
494
+
495
+ async def _upsert_reference(
496
+ store,
497
+ space,
498
+ actor,
499
+ item_id,
500
+ item_kind,
501
+ project_id,
502
+ source_project_id,
503
+ status,
504
+ matched_item_id,
505
+ reason,
506
+ ):
507
+ reference_id = _stable_uuid(
508
+ space['id'], 'knowledge-reference', item_kind, item_id, project_id
509
+ )
510
+ changed_at = datetime.now(timezone.utc)
511
+ records, _, _ = await store.runtime.driver.execute_query(
512
+ '''
513
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
514
+ MATCH (space)-[:CONTAINS_PROJECT]->
515
+ (target:FuliPersonalProject {project_id: $project_id})
516
+ MERGE (reference:FuliKnowledgeProjectReference {
517
+ space_id: $space_id,
518
+ item_kind: $item_kind,
519
+ item_id: $item_id,
520
+ project_id: $project_id
521
+ })
522
+ ON CREATE SET reference.id = $reference_id,
523
+ reference.created_at = $changed_at
524
+ SET reference.source_project_id = $source_project_id,
525
+ reference.status = $status,
526
+ reference.matched_item_id = $matched_item_id,
527
+ reference.reason = $reason,
528
+ reference.changed_by = $changed_by,
529
+ reference.updated_at = $changed_at
530
+ MERGE (space)-[:HAS_KNOWLEDGE_REFERENCE]->(reference)
531
+ MERGE (reference)-[:REFERENCES_IN]->(target)
532
+ RETURN reference
533
+ ''',
534
+ space_id=space['id'],
535
+ item_kind=item_kind,
536
+ item_id=item_id,
537
+ project_id=project_id,
538
+ reference_id=reference_id,
539
+ source_project_id=source_project_id,
540
+ status=status,
541
+ matched_item_id=matched_item_id,
542
+ reason=reason,
543
+ changed_by=actor['id'],
544
+ changed_at=changed_at,
545
+ )
546
+ return reference_record(dict(records[0]['reference']))
547
+
548
+
549
+ async def _upsert_conflict(
550
+ store,
551
+ space,
552
+ actor,
553
+ item_id,
554
+ target_item_id,
555
+ source_project_id,
556
+ target_project_id,
557
+ resolution,
558
+ reason,
559
+ ):
560
+ conflict_id = _stable_uuid(
561
+ space['id'], 'knowledge-conflict', item_id, target_project_id, target_item_id
562
+ )
563
+ changed_at = datetime.now(timezone.utc)
564
+ status = 'pending' if resolution == 'defer' else 'resolved'
565
+ records, _, _ = await store.runtime.driver.execute_query(
566
+ '''
567
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
568
+ MERGE (conflict:FuliKnowledgeConflict {id: $conflict_id})
569
+ ON CREATE SET conflict.created_at = $changed_at
570
+ SET conflict.space_id = $space_id,
571
+ conflict.item_id = $item_id,
572
+ conflict.target_item_id = $target_item_id,
573
+ conflict.source_project_id = $source_project_id,
574
+ conflict.target_project_id = $target_project_id,
575
+ conflict.status = $status,
576
+ conflict.resolution = $resolution,
577
+ conflict.reason = $reason,
578
+ conflict.decided_by = $decided_by,
579
+ conflict.updated_at = $changed_at
580
+ MERGE (space)-[:HAS_KNOWLEDGE_CONFLICT]->(conflict)
581
+ RETURN conflict
582
+ ''',
583
+ space_id=space['id'],
584
+ conflict_id=conflict_id,
585
+ item_id=item_id,
586
+ target_item_id=target_item_id,
587
+ source_project_id=source_project_id,
588
+ target_project_id=target_project_id,
589
+ status=status,
590
+ resolution=resolution,
591
+ reason=reason,
592
+ decided_by=actor['id'] if status == 'resolved' else None,
593
+ changed_at=changed_at,
594
+ )
595
+ return conflict_record(dict(records[0]['conflict']))
596
+
597
+
598
+ async def _maybe_create_project_relation(
599
+ store,
600
+ space,
601
+ source_project_id,
602
+ target_project_id,
603
+ request,
604
+ actor_id,
605
+ ):
606
+ if (
607
+ not request.keep_source_relation
608
+ or not source_project_id
609
+ or source_project_id == target_project_id
610
+ ):
611
+ return False
612
+ source, target = _relation_endpoints(
613
+ source_project_id, target_project_id, request
614
+ )
615
+ if request.relation_type == 'RELATED_TO' and source > target:
616
+ source, target = target, source
617
+ relation_id = _stable_uuid(
618
+ space['id'], 'personal-project-relation', source, target, request.relation_type
619
+ )
620
+ changed_at = datetime.now(timezone.utc)
621
+ records, _, _ = await store.runtime.driver.execute_query(
622
+ '''
623
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
624
+ [:CONTAINS_PROJECT]->
625
+ (source:FuliPersonalProject {project_id: $source_project_id})
626
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
627
+ [:CONTAINS_PROJECT]->
628
+ (target:FuliPersonalProject {project_id: $target_project_id})
629
+ MERGE (source)-[relation:PERSONAL_PROJECT_RELATION {
630
+ id: $relation_id,
631
+ relation_type: $relation_type
632
+ }]->(target)
633
+ ON CREATE SET relation.created_at = $changed_at,
634
+ relation.created_by = $created_by
635
+ SET relation.updated_at = $changed_at
636
+ RETURN relation.id AS id
637
+ ''',
638
+ space_id=space['id'],
639
+ source_project_id=source,
640
+ target_project_id=target,
641
+ relation_id=relation_id,
642
+ relation_type=request.relation_type,
643
+ created_by=actor_id,
644
+ changed_at=changed_at,
645
+ )
646
+ return bool(records)
647
+
648
+
649
+ def _relation_endpoints(source_project_id, target_project_id, request):
650
+ """Keep an extracted project as the child side of directional relations."""
651
+ if request.mode == 'create':
652
+ return target_project_id, source_project_id
653
+ return source_project_id, target_project_id
654
+
655
+
656
+ def _reference_status(match_kind, resolution):
657
+ if match_kind == 'exact_duplicate':
658
+ return 'duplicate'
659
+ if match_kind != 'conflict':
660
+ return 'active'
661
+ return {
662
+ 'defer': 'pending_conflict',
663
+ 'keep_target': 'rejected',
664
+ 'use_source': 'active',
665
+ 'coexist': 'active',
666
+ }[resolution]
667
+
668
+
669
+ def _action_status(project_created, match_kind, resolution):
670
+ if match_kind == 'exact_duplicate':
671
+ return 'duplicate_reused'
672
+ if match_kind == 'conflict':
673
+ return 'conflict_pending' if resolution == 'defer' else 'conflict_resolved'
674
+ return 'created' if project_created else 'linked'
675
+
676
+
677
+ def _action_result(
678
+ status,
679
+ source_project_id,
680
+ target_project_id,
681
+ project_created,
682
+ relation_present,
683
+ match,
684
+ reference=None,
685
+ conflict=None,
686
+ ):
687
+ return KnowledgeProjectActionResult(
688
+ status=status,
689
+ source_project_id=source_project_id,
690
+ target_project_id=target_project_id,
691
+ project_created=project_created,
692
+ project_relation_created=relation_present,
693
+ match=match,
694
+ reference=reference,
695
+ conflict=conflict,
696
+ )
697
+
698
+
699
+ def _same_content(source, target):
700
+ return _normalized(source.get('summary')) == _normalized(target.get('summary'))
701
+
702
+
703
+ def _confirmed_current(source, target):
704
+ return all(
705
+ item.get('confirmation_status', 'pending') == 'confirmed'
706
+ for item in (source, target)
707
+ )