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,29 @@
1
+ from fastapi import HTTPException
2
+
3
+
4
+ async def authorize_personal_project(
5
+ store,
6
+ actor: dict,
7
+ space: dict,
8
+ project_id: str,
9
+ ) -> dict:
10
+ if store.settings.provider_mode != 'personal' or space['kind'] != 'personal':
11
+ raise HTTPException(
12
+ status_code=422,
13
+ detail='personal project scope requires a personal space',
14
+ )
15
+ records, _, _ = await store.runtime.driver.execute_query(
16
+ '''
17
+ MATCH (:FuliPrincipal {id: $principal_id})-[:OWNS]->
18
+ (:FuliSpace {id: $space_id, kind: 'personal'})-[:CONTAINS_PROJECT]->
19
+ (project:FuliPersonalProject {project_id: $project_id})
20
+ RETURN project
21
+ ''',
22
+ principal_id=actor['id'],
23
+ space_id=space['id'],
24
+ project_id=project_id,
25
+ routing_='r',
26
+ )
27
+ if not records:
28
+ raise HTTPException(status_code=404, detail='personal project not found')
29
+ return dict(records[0]['project'])
@@ -0,0 +1,420 @@
1
+ import json
2
+ from datetime import datetime, timezone
3
+
4
+ from fastapi import HTTPException
5
+
6
+ from .knowledge_management import (
7
+ _read_item,
8
+ revise_knowledge_item,
9
+ set_preference_scope,
10
+ )
11
+ from .models import (
12
+ KnowledgeRevisionCreate,
13
+ PreferenceConflictCompleteCreate,
14
+ PreferenceConflictDeferCreate,
15
+ PreferenceConflictRecord,
16
+ PreferenceConflictResolveCreate,
17
+ PreferenceScopeChange,
18
+ )
19
+ from .provider_values import native_datetime as _native_datetime
20
+
21
+
22
+ async def defer_preference_conflict(
23
+ store,
24
+ actor: dict,
25
+ request: PreferenceConflictDeferCreate,
26
+ ) -> PreferenceConflictRecord:
27
+ space = await _authorize_personal_space(
28
+ store,
29
+ actor,
30
+ request.personal_space_id,
31
+ 'maintainer',
32
+ )
33
+ left = await _active_preference_item(
34
+ store,
35
+ space,
36
+ request.left_item_id,
37
+ request.left_item_kind,
38
+ )
39
+ right = await _active_preference_item(
40
+ store,
41
+ space,
42
+ request.right_item_id,
43
+ request.right_item_kind,
44
+ )
45
+ _validate_pair_scope(left, right, request)
46
+ deferred_at = datetime.now(timezone.utc)
47
+ records, _, _ = await store.runtime.driver.execute_query(
48
+ '''
49
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
50
+ MERGE (conflict:FuliPreferenceConflict {id: $conflict_id})
51
+ ON CREATE SET conflict.personal_space_id = $space_id,
52
+ conflict.preference_key = $preference_key,
53
+ conflict.preference_scope = $preference_scope,
54
+ conflict.preference_project_id = $preference_project_id,
55
+ conflict.left_item_id = $left_item_id,
56
+ conflict.left_item_kind = $left_item_kind,
57
+ conflict.right_item_id = $right_item_id,
58
+ conflict.right_item_kind = $right_item_kind,
59
+ conflict.created_at = $deferred_at
60
+ SET conflict.status = 'ai_pending',
61
+ conflict.requested_by = $requested_by,
62
+ conflict.reason = $reason,
63
+ conflict.resolution = NULL,
64
+ conflict.resolved_by = NULL,
65
+ conflict.resolution_reason = NULL,
66
+ conflict.resolved_at = NULL,
67
+ conflict.deferred_at = $deferred_at,
68
+ conflict.updated_at = $deferred_at
69
+ MERGE (space)-[:HAS_PREFERENCE_CONFLICT]->(conflict)
70
+ RETURN conflict
71
+ ''',
72
+ space_id=space['id'],
73
+ conflict_id=request.conflict_id,
74
+ preference_key=request.preference_key,
75
+ preference_scope=request.preference_scope,
76
+ preference_project_id=request.preference_project_id,
77
+ left_item_id=request.left_item_id,
78
+ left_item_kind=request.left_item_kind,
79
+ right_item_id=request.right_item_id,
80
+ right_item_kind=request.right_item_kind,
81
+ requested_by=request.operation_actor,
82
+ reason=request.reason,
83
+ deferred_at=deferred_at,
84
+ )
85
+ return _record(records[0]['conflict'])
86
+
87
+
88
+ async def list_preference_conflicts(
89
+ store,
90
+ actor: dict,
91
+ personal_space_id: str,
92
+ *,
93
+ status: str | None = None,
94
+ limit: int = 500,
95
+ ) -> list[PreferenceConflictRecord]:
96
+ await _authorize_personal_space(
97
+ store,
98
+ actor,
99
+ personal_space_id,
100
+ 'reader',
101
+ )
102
+ records, _, _ = await store.runtime.driver.execute_query(
103
+ '''
104
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
105
+ [:HAS_PREFERENCE_CONFLICT]->(conflict:FuliPreferenceConflict)
106
+ WHERE $status IS NULL OR conflict.status = $status
107
+ RETURN conflict
108
+ ORDER BY conflict.updated_at DESC
109
+ LIMIT $limit
110
+ ''',
111
+ space_id=personal_space_id,
112
+ status=status,
113
+ limit=min(max(limit, 1), 1000),
114
+ routing_='r',
115
+ )
116
+ return [_record(record['conflict']) for record in records]
117
+
118
+
119
+ async def resolve_preference_conflict(
120
+ store,
121
+ actor: dict,
122
+ conflict_id: str,
123
+ request: PreferenceConflictResolveCreate,
124
+ ) -> PreferenceConflictRecord:
125
+ space = await _authorize_personal_space(
126
+ store,
127
+ actor,
128
+ request.personal_space_id,
129
+ 'maintainer',
130
+ )
131
+ conflict = await _read_conflict(store, space['id'], conflict_id)
132
+ if conflict.status == 'resolved':
133
+ return conflict
134
+ if conflict.status != 'ai_pending':
135
+ raise HTTPException(status_code=409, detail='preference conflict is not queued for AI')
136
+
137
+ left = await _active_preference_item(
138
+ store,
139
+ space,
140
+ conflict.left_item_id,
141
+ conflict.left_item_kind,
142
+ )
143
+ right = await _active_preference_item(
144
+ store,
145
+ space,
146
+ conflict.right_item_id,
147
+ conflict.right_item_kind,
148
+ )
149
+
150
+ if request.resolution == 'merge':
151
+ await _merge_items(store, actor, space, conflict, left, right, request)
152
+ elif request.resolution in {'keep_left', 'keep_right'}:
153
+ await _keep_one_item(store, actor, conflict, request)
154
+ else:
155
+ await _split_scope(store, actor, space, conflict, left, right, request)
156
+
157
+ return await _complete(
158
+ store,
159
+ space['id'],
160
+ conflict_id,
161
+ request.resolution,
162
+ request.reason,
163
+ request.operation_actor,
164
+ )
165
+
166
+
167
+ async def complete_preference_conflict(
168
+ store,
169
+ actor: dict,
170
+ conflict_id: str,
171
+ request: PreferenceConflictCompleteCreate,
172
+ ) -> PreferenceConflictRecord:
173
+ space = await _authorize_personal_space(
174
+ store,
175
+ actor,
176
+ request.personal_space_id,
177
+ 'maintainer',
178
+ )
179
+ await _read_conflict(store, space['id'], conflict_id)
180
+ return await _complete(
181
+ store,
182
+ space['id'],
183
+ conflict_id,
184
+ request.resolution,
185
+ request.reason,
186
+ request.operation_actor,
187
+ )
188
+
189
+
190
+ async def _merge_items(store, actor, space, conflict, left, right, request):
191
+ if request.canonical_item_id not in {
192
+ conflict.left_item_id,
193
+ conflict.right_item_id,
194
+ }:
195
+ raise HTTPException(
196
+ status_code=422,
197
+ detail='canonical item must belong to the deferred conflict',
198
+ )
199
+ canonical_is_left = request.canonical_item_id == conflict.left_item_id
200
+ canonical_kind = (
201
+ conflict.left_item_kind if canonical_is_left else conflict.right_item_kind
202
+ )
203
+ canonical = left if canonical_is_left else right
204
+ historical_id = (
205
+ conflict.right_item_id if canonical_is_left else conflict.left_item_id
206
+ )
207
+ historical_kind = (
208
+ conflict.right_item_kind if canonical_is_left else conflict.left_item_kind
209
+ )
210
+ confirmation = _preserved_confirmation(canonical)
211
+ update = {
212
+ 'personal_space_id': space['id'],
213
+ 'item_kind': canonical_kind,
214
+ 'action': 'update',
215
+ 'reason': request.reason,
216
+ 'operation_actor': request.operation_actor,
217
+ }
218
+ if canonical_kind == 'entity':
219
+ update['summary'] = request.merged_instruction
220
+ else:
221
+ update['fact'] = request.merged_instruction
222
+ if confirmation:
223
+ update.update(confirmation)
224
+ await revise_knowledge_item(
225
+ store,
226
+ actor,
227
+ request.canonical_item_id,
228
+ KnowledgeRevisionCreate(**update),
229
+ )
230
+ await revise_knowledge_item(
231
+ store,
232
+ actor,
233
+ historical_id,
234
+ KnowledgeRevisionCreate(
235
+ personal_space_id=space['id'],
236
+ item_kind=historical_kind,
237
+ action='invalidate',
238
+ reason=request.reason,
239
+ replacement_item_id=request.canonical_item_id,
240
+ replacement_item_kind=canonical_kind,
241
+ operation_actor=request.operation_actor,
242
+ ),
243
+ )
244
+
245
+
246
+ async def _keep_one_item(store, actor, conflict, request):
247
+ keep_left = request.resolution == 'keep_left'
248
+ kept_id = conflict.left_item_id if keep_left else conflict.right_item_id
249
+ kept_kind = conflict.left_item_kind if keep_left else conflict.right_item_kind
250
+ historical_id = conflict.right_item_id if keep_left else conflict.left_item_id
251
+ historical_kind = (
252
+ conflict.right_item_kind if keep_left else conflict.left_item_kind
253
+ )
254
+ await revise_knowledge_item(
255
+ store,
256
+ actor,
257
+ historical_id,
258
+ KnowledgeRevisionCreate(
259
+ personal_space_id=request.personal_space_id,
260
+ item_kind=historical_kind,
261
+ action='invalidate',
262
+ reason=request.reason,
263
+ replacement_item_id=kept_id,
264
+ replacement_item_kind=kept_kind,
265
+ operation_actor=request.operation_actor,
266
+ ),
267
+ )
268
+
269
+
270
+ async def _split_scope(store, actor, space, conflict, left, right, request):
271
+ if request.split_item_id not in {
272
+ conflict.left_item_id,
273
+ conflict.right_item_id,
274
+ }:
275
+ raise HTTPException(
276
+ status_code=422,
277
+ detail='split item must belong to the deferred conflict',
278
+ )
279
+ split_left = request.split_item_id == conflict.left_item_id
280
+ item_kind = conflict.left_item_kind if split_left else conflict.right_item_kind
281
+ item = left if split_left else right
282
+ if (
283
+ item.get('preference_scope') == 'project'
284
+ and item.get('preference_project_id') == request.split_project_id
285
+ ):
286
+ return
287
+ await set_preference_scope(
288
+ store,
289
+ actor,
290
+ request.split_item_id,
291
+ PreferenceScopeChange(
292
+ personal_space_id=space['id'],
293
+ item_kind=item_kind,
294
+ scope='project',
295
+ project_id=request.split_project_id,
296
+ reason=request.reason,
297
+ operation_actor=request.operation_actor,
298
+ ),
299
+ )
300
+
301
+
302
+ async def _complete(
303
+ store,
304
+ space_id,
305
+ conflict_id,
306
+ resolution,
307
+ reason,
308
+ resolved_by,
309
+ ):
310
+ resolved_at = datetime.now(timezone.utc)
311
+ records, _, _ = await store.runtime.driver.execute_query(
312
+ '''
313
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
314
+ [:HAS_PREFERENCE_CONFLICT]->
315
+ (conflict:FuliPreferenceConflict {id: $conflict_id})
316
+ SET conflict.status = 'resolved',
317
+ conflict.resolution = $resolution,
318
+ conflict.resolved_by = $resolved_by,
319
+ conflict.resolution_reason = $reason,
320
+ conflict.resolved_at = $resolved_at,
321
+ conflict.updated_at = $resolved_at
322
+ RETURN conflict
323
+ ''',
324
+ space_id=space_id,
325
+ conflict_id=conflict_id,
326
+ resolution=resolution,
327
+ resolved_by=resolved_by,
328
+ reason=reason,
329
+ resolved_at=resolved_at,
330
+ )
331
+ if not records:
332
+ raise HTTPException(status_code=404, detail='preference conflict not found')
333
+ return _record(records[0]['conflict'])
334
+
335
+
336
+ async def _read_conflict(store, space_id, conflict_id):
337
+ records, _, _ = await store.runtime.driver.execute_query(
338
+ '''
339
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
340
+ [:HAS_PREFERENCE_CONFLICT]->
341
+ (conflict:FuliPreferenceConflict {id: $conflict_id})
342
+ RETURN conflict
343
+ ''',
344
+ space_id=space_id,
345
+ conflict_id=conflict_id,
346
+ routing_='r',
347
+ )
348
+ if not records:
349
+ raise HTTPException(status_code=404, detail='preference conflict not found')
350
+ return _record(records[0]['conflict'])
351
+
352
+
353
+ async def _active_preference_item(store, space, item_id, item_kind):
354
+ item = await _read_item(store, space, item_id, item_kind)
355
+ if item is None:
356
+ raise HTTPException(status_code=404, detail='preference item not found')
357
+ if item.get('invalid_at'):
358
+ raise HTTPException(status_code=409, detail='historical preference cannot be queued')
359
+ if not item.get('profile_aspect'):
360
+ raise HTTPException(status_code=422, detail='item is not a collaboration preference')
361
+ return item
362
+
363
+
364
+ def _validate_pair_scope(left, right, request):
365
+ left_scope = left.get('preference_scope') or 'global'
366
+ right_scope = right.get('preference_scope') or 'global'
367
+ if left_scope != right_scope or left_scope != request.preference_scope:
368
+ raise HTTPException(status_code=409, detail='preference scopes no longer match')
369
+ if (
370
+ left.get('preference_project_id') != right.get('preference_project_id')
371
+ or left.get('preference_project_id') != request.preference_project_id
372
+ ):
373
+ raise HTTPException(status_code=409, detail='preference projects no longer match')
374
+
375
+
376
+ def _preserved_confirmation(item):
377
+ if item.get('confirmation_status') != 'confirmed':
378
+ return {}
379
+ value = item.get('confirmation_basis_json')
380
+ if not value:
381
+ return {}
382
+ basis = json.loads(value) if isinstance(value, str) else value
383
+ if not basis.get('confirmed_by') or not basis.get('confirmed_at'):
384
+ return {}
385
+ return {
386
+ 'confirmation_status': 'confirmed',
387
+ 'confirmation_basis': basis,
388
+ }
389
+
390
+
391
+ async def _authorize_personal_space(store, actor, space_id, role):
392
+ store._require_personal()
393
+ space = await store.authorize(actor, space_id, role)
394
+ if space['kind'] != 'personal':
395
+ raise HTTPException(status_code=422, detail='preference conflicts are personal-only')
396
+ return space
397
+
398
+
399
+ def _record(value) -> PreferenceConflictRecord:
400
+ data = dict(value)
401
+ return PreferenceConflictRecord(
402
+ id=data['id'],
403
+ personal_space_id=data['personal_space_id'],
404
+ preference_key=data['preference_key'],
405
+ preference_scope=data['preference_scope'],
406
+ preference_project_id=data.get('preference_project_id'),
407
+ left_item_id=data['left_item_id'],
408
+ left_item_kind=data['left_item_kind'],
409
+ right_item_id=data['right_item_id'],
410
+ right_item_kind=data['right_item_kind'],
411
+ status=data['status'],
412
+ requested_by=data['requested_by'],
413
+ resolution=data.get('resolution'),
414
+ resolved_by=data.get('resolved_by'),
415
+ reason=data['reason'],
416
+ resolution_reason=data.get('resolution_reason'),
417
+ deferred_at=_native_datetime(data['deferred_at']),
418
+ resolved_at=_native_datetime(data.get('resolved_at')),
419
+ updated_at=_native_datetime(data['updated_at']),
420
+ )
@@ -0,0 +1,142 @@
1
+ from datetime import datetime
2
+ from typing import Literal
3
+
4
+ from pydantic import Field, model_validator
5
+
6
+ from .models import (
7
+ KnowledgeItemKind,
8
+ KnowledgeOperationActor,
9
+ ProjectRelationType,
10
+ StrictModel,
11
+ )
12
+
13
+ KnowledgeProjectActionMode = Literal['create', 'existing']
14
+ KnowledgeProjectReferenceStatus = Literal[
15
+ 'active',
16
+ 'pending_conflict',
17
+ 'rejected',
18
+ 'duplicate',
19
+ ]
20
+ KnowledgeConflictResolution = Literal[
21
+ 'defer',
22
+ 'keep_target',
23
+ 'use_source',
24
+ 'coexist',
25
+ ]
26
+ KnowledgeProjectMatchKind = Literal[
27
+ 'none',
28
+ 'already_linked',
29
+ 'exact_duplicate',
30
+ 'conflict',
31
+ ]
32
+
33
+
34
+ class KnowledgeProjectPreviewRequest(StrictModel):
35
+ personal_space_id: str = Field(min_length=1, max_length=128)
36
+ item_kind: Literal['entity'] = 'entity'
37
+ mode: KnowledgeProjectActionMode = 'existing'
38
+ target_project_id: str | None = Field(default=None, min_length=1, max_length=128)
39
+ new_project_id: str | None = Field(default=None, min_length=1, max_length=128)
40
+ new_project_name: str | None = Field(default=None, min_length=1, max_length=160)
41
+ new_project_purpose: str | None = Field(default=None, max_length=4096)
42
+ keep_source_relation: bool = True
43
+ relation_type: ProjectRelationType = 'RELATED_TO'
44
+ conflict_resolution: KnowledgeConflictResolution = 'defer'
45
+ reason: str | None = Field(default=None, min_length=1, max_length=2000)
46
+
47
+ @model_validator(mode='after')
48
+ def validate_target(self):
49
+ if self.mode == 'existing' and not self.target_project_id:
50
+ raise ValueError('existing mode requires target_project_id')
51
+ if self.mode == 'create' and not (
52
+ self.new_project_id and self.new_project_name
53
+ ):
54
+ raise ValueError('create mode requires new project id and name')
55
+ return self
56
+
57
+
58
+ class KnowledgeProjectActionRequest(StrictModel):
59
+ personal_space_id: str = Field(min_length=1, max_length=128)
60
+ item_kind: Literal['entity'] = 'entity'
61
+ mode: KnowledgeProjectActionMode
62
+ target_project_id: str | None = Field(default=None, min_length=1, max_length=128)
63
+ new_project_id: str | None = Field(default=None, min_length=1, max_length=128)
64
+ new_project_name: str | None = Field(default=None, min_length=1, max_length=160)
65
+ new_project_purpose: str | None = Field(default=None, max_length=4096)
66
+ keep_source_relation: bool = True
67
+ relation_type: ProjectRelationType = 'RELATED_TO'
68
+ conflict_resolution: KnowledgeConflictResolution = 'defer'
69
+ reason: str = Field(min_length=1, max_length=2000)
70
+ operation_actor: KnowledgeOperationActor = 'agent'
71
+
72
+ @model_validator(mode='after')
73
+ def validate_target(self):
74
+ if self.mode == 'existing' and not self.target_project_id:
75
+ raise ValueError('existing mode requires target_project_id')
76
+ if self.mode == 'create' and not (
77
+ self.new_project_id and self.new_project_name
78
+ ):
79
+ raise ValueError('create mode requires new project id and name')
80
+ return self
81
+
82
+
83
+ class KnowledgeProjectMatch(StrictModel):
84
+ kind: KnowledgeProjectMatchKind
85
+ reason: str
86
+ item_id: str | None = None
87
+ item_name: str | None = None
88
+ item_summary: str | None = None
89
+
90
+
91
+ class KnowledgeProjectPreviewRecord(StrictModel):
92
+ item_id: str
93
+ item_name: str
94
+ item_summary: str
95
+ source_project_id: str | None = None
96
+ target_project_id: str
97
+ match: KnowledgeProjectMatch
98
+ default_resolution: KnowledgeConflictResolution = 'defer'
99
+
100
+
101
+ class KnowledgeProjectReferenceRecord(StrictModel):
102
+ id: str
103
+ item_id: str
104
+ item_kind: KnowledgeItemKind
105
+ project_id: str
106
+ source_project_id: str | None = None
107
+ status: KnowledgeProjectReferenceStatus
108
+ matched_item_id: str | None = None
109
+ reason: str
110
+ created_at: datetime
111
+ updated_at: datetime
112
+
113
+
114
+ class KnowledgeConflictRecord(StrictModel):
115
+ id: str
116
+ item_id: str
117
+ target_item_id: str
118
+ source_project_id: str | None = None
119
+ target_project_id: str
120
+ status: Literal['pending', 'resolved']
121
+ resolution: KnowledgeConflictResolution
122
+ reason: str
123
+ created_at: datetime
124
+ updated_at: datetime
125
+
126
+
127
+ class KnowledgeProjectActionResult(StrictModel):
128
+ status: Literal[
129
+ 'created',
130
+ 'linked',
131
+ 'already_linked',
132
+ 'duplicate_reused',
133
+ 'conflict_pending',
134
+ 'conflict_resolved',
135
+ ]
136
+ source_project_id: str | None = None
137
+ target_project_id: str
138
+ project_created: bool = False
139
+ project_relation_created: bool = False
140
+ match: KnowledgeProjectMatch
141
+ reference: KnowledgeProjectReferenceRecord | None = None
142
+ conflict: KnowledgeConflictRecord | None = None