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,816 @@
1
+ import json
2
+ from datetime import datetime, timezone
3
+
4
+ from fastapi import HTTPException
5
+
6
+ from .knowledge_audit import record_human_change
7
+ from .knowledge_records import assignment_record
8
+ from .personal_project_access import authorize_personal_project
9
+ from .models import (
10
+ ConfirmationBasis,
11
+ KnowledgeAssignmentChange,
12
+ KnowledgeAssignmentRecord,
13
+ PreferenceScopeChange,
14
+ KnowledgeRevisionCreate,
15
+ KnowledgeRevisionRecord,
16
+ )
17
+ from .provider_values import (
18
+ native_datetime as _native_datetime,
19
+ stable_uuid as _stable_uuid,
20
+ )
21
+
22
+
23
+ async def revise_knowledge_item(store, actor: dict, item_id: str, request: KnowledgeRevisionCreate):
24
+ store._require_personal()
25
+ space = await store.authorize(actor, request.personal_space_id, 'maintainer')
26
+ if space['kind'] != 'personal':
27
+ raise HTTPException(status_code=422, detail='knowledge revision is personal-only')
28
+ if request.personal_project_id:
29
+ await authorize_personal_project(
30
+ store, actor, space, request.personal_project_id
31
+ )
32
+ if not await _item_belongs_to_project(
33
+ store, space, item_id, request.item_kind, request.personal_project_id
34
+ ):
35
+ raise HTTPException(status_code=404, detail='knowledge item not found in project')
36
+
37
+ current = await _read_item(store, space, item_id, request.item_kind)
38
+ if current is None:
39
+ raise HTTPException(status_code=404, detail='knowledge item not found')
40
+ previous = _snapshot(current, request.item_kind)
41
+ if (
42
+ request.action == 'update'
43
+ and request.origin_quadrant is not None
44
+ and previous.get('classificationStateExplicit')
45
+ and request.origin_quadrant != previous.get('originQuadrant')
46
+ ):
47
+ raise HTTPException(
48
+ status_code=422,
49
+ detail=(
50
+ 'origin quadrant is immutable after capture; '
51
+ 'update current_quadrant instead'
52
+ ),
53
+ )
54
+ next_profile_aspect = (
55
+ previous.get('profileAspect')
56
+ if request.profile_aspect is None
57
+ else None if request.profile_aspect == 'none'
58
+ else request.profile_aspect
59
+ )
60
+ next_inheritance_mode = (
61
+ request.inheritance_mode
62
+ if request.inheritance_mode is not None
63
+ else previous.get('inheritanceMode', 'local_only')
64
+ )
65
+ if next_profile_aspect and next_inheritance_mode != 'local_only':
66
+ raise HTTPException(
67
+ status_code=422,
68
+ detail='personal preferences cannot inherit across projects',
69
+ )
70
+ if request.action == 'link_replacement' and not previous.get('invalidAt'):
71
+ raise HTTPException(
72
+ status_code=409,
73
+ detail='only historical knowledge can be linked to a replacement',
74
+ )
75
+ if request.replacement_item_id:
76
+ if (
77
+ request.replacement_item_id == item_id
78
+ and request.replacement_item_kind == request.item_kind
79
+ ):
80
+ raise HTTPException(
81
+ status_code=422,
82
+ detail='knowledge cannot replace itself',
83
+ )
84
+ replacement = await _read_item(
85
+ store,
86
+ space,
87
+ request.replacement_item_id,
88
+ request.replacement_item_kind,
89
+ )
90
+ if replacement is None:
91
+ raise HTTPException(status_code=404, detail='replacement knowledge not found')
92
+ if request.personal_project_id and not await _item_belongs_to_project(
93
+ store,
94
+ space,
95
+ request.replacement_item_id,
96
+ request.replacement_item_kind,
97
+ request.personal_project_id,
98
+ ):
99
+ raise HTTPException(status_code=404, detail='replacement knowledge not found')
100
+ if _snapshot(replacement, request.replacement_item_kind).get('invalidAt'):
101
+ raise HTTPException(
102
+ status_code=409,
103
+ detail='replacement knowledge must be currently effective',
104
+ )
105
+ if request.action == 'confirm':
106
+ if previous.get('invalidAt'):
107
+ raise HTTPException(
108
+ status_code=409,
109
+ detail='historical knowledge cannot be confirmed',
110
+ )
111
+ if not previous.get('classificationStateExplicit'):
112
+ raise HTTPException(
113
+ status_code=409,
114
+ detail='knowledge must have an explicit discovery quadrant before confirmation',
115
+ )
116
+ if (
117
+ previous.get('confirmationStateExplicit')
118
+ and previous.get('confirmationStatus') == 'confirmed'
119
+ ):
120
+ raise HTTPException(
121
+ status_code=409,
122
+ detail='knowledge is already confirmed',
123
+ )
124
+ created_at = datetime.now(timezone.utc)
125
+ next_value = _next_snapshot(previous, request, created_at)
126
+ embedding = await _updated_embedding(store, previous, next_value, request)
127
+ await _update_item(
128
+ store, space, item_id, request.item_kind, next_value, embedding
129
+ )
130
+
131
+ revision_id = _stable_uuid(
132
+ space['id'], 'knowledge-revision', request.item_kind, item_id,
133
+ created_at.isoformat(), actor['id']
134
+ )
135
+ await store.runtime.driver.execute_query(
136
+ '''
137
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
138
+ CREATE (revision:FuliKnowledgeRevision {
139
+ id: $revision_id,
140
+ space_id: $space_id,
141
+ item_id: $item_id,
142
+ item_kind: $item_kind,
143
+ action: $action,
144
+ reason: $reason,
145
+ previous_json: $previous_json,
146
+ current_json: $current_json,
147
+ created_by: $created_by,
148
+ created_at: $created_at
149
+ })
150
+ MERGE (space)-[:HAS_KNOWLEDGE_REVISION]->(revision)
151
+ ''',
152
+ space_id=space['id'],
153
+ revision_id=revision_id,
154
+ item_id=item_id,
155
+ item_kind=request.item_kind,
156
+ action=request.action,
157
+ reason=request.reason,
158
+ previous_json=json.dumps(previous, ensure_ascii=False, sort_keys=True),
159
+ current_json=json.dumps(next_value, ensure_ascii=False, sort_keys=True),
160
+ created_by=actor['id'],
161
+ created_at=created_at,
162
+ )
163
+ if request.operation_actor == 'human':
164
+ await record_human_change(
165
+ store,
166
+ actor,
167
+ space,
168
+ item_id,
169
+ request.item_kind,
170
+ reason=request.reason,
171
+ operation=f'knowledge_{request.action}',
172
+ )
173
+ return KnowledgeRevisionRecord(
174
+ id=revision_id,
175
+ item_id=item_id,
176
+ item_kind=request.item_kind,
177
+ action=request.action,
178
+ reason=request.reason,
179
+ previous=previous,
180
+ current=next_value,
181
+ created_at=created_at,
182
+ )
183
+
184
+
185
+ async def reassign_knowledge_item(
186
+ store,
187
+ actor: dict,
188
+ item_id: str,
189
+ request: KnowledgeAssignmentChange,
190
+ ):
191
+ store._require_personal()
192
+ space = await store.authorize(actor, request.personal_space_id, 'maintainer')
193
+ if space['kind'] != 'personal':
194
+ raise HTTPException(status_code=422, detail='knowledge assignment is personal-only')
195
+ await authorize_personal_project(store, actor, space, request.target_project_id)
196
+ item = await _read_item(store, space, item_id, request.item_kind)
197
+ if item is None:
198
+ raise HTTPException(status_code=404, detail='knowledge item not found')
199
+ if item.get('profile_aspect'):
200
+ raise HTTPException(
201
+ status_code=422,
202
+ detail='personal profile knowledge cannot be assigned to a project',
203
+ )
204
+
205
+ existing = await _read_assignment(store, space['id'], item_id, request.item_kind)
206
+ previous_project_id = (
207
+ existing.project_id if existing
208
+ else await _implicit_project_id(store, space, item_id, request.item_kind)
209
+ )
210
+ changed_at = datetime.now(timezone.utc)
211
+ assignment_id = existing.id if existing else _stable_uuid(
212
+ space['id'], 'knowledge-assignment', request.item_kind, item_id
213
+ )
214
+ records, _, _ = await store.runtime.driver.execute_query(
215
+ '''
216
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
217
+ MATCH (space)-[:CONTAINS_PROJECT]->
218
+ (target:FuliPersonalProject {project_id: $target_project_id})
219
+ MERGE (assignment:FuliKnowledgeAssignment {
220
+ space_id: $space_id,
221
+ item_kind: $item_kind,
222
+ item_id: $item_id
223
+ })
224
+ ON CREATE SET assignment.id = $assignment_id,
225
+ assignment.created_at = $changed_at
226
+ SET assignment.previous_project_id = $previous_project_id,
227
+ assignment.project_id = $target_project_id,
228
+ assignment.reason = $reason,
229
+ assignment.changed_by = $changed_by,
230
+ assignment.updated_at = $changed_at
231
+ WITH assignment, target
232
+ OPTIONAL MATCH (assignment)-[old:ASSIGNED_TO]->(:FuliPersonalProject)
233
+ DELETE old
234
+ MERGE (assignment)-[:ASSIGNED_TO]->(target)
235
+ MERGE (space)-[:HAS_KNOWLEDGE_ASSIGNMENT]->(assignment)
236
+ RETURN assignment
237
+ ''',
238
+ space_id=space['id'],
239
+ target_project_id=request.target_project_id,
240
+ item_kind=request.item_kind,
241
+ item_id=item_id,
242
+ assignment_id=assignment_id,
243
+ previous_project_id=previous_project_id,
244
+ reason=request.reason,
245
+ changed_by=actor['id'],
246
+ changed_at=changed_at,
247
+ )
248
+ if request.operation_actor == 'human':
249
+ await record_human_change(
250
+ store,
251
+ actor,
252
+ space,
253
+ item_id,
254
+ request.item_kind,
255
+ reason=request.reason,
256
+ operation='knowledge_assignment',
257
+ )
258
+ return assignment_record(dict(records[0]['assignment']))
259
+
260
+
261
+ async def set_preference_scope(
262
+ store,
263
+ actor: dict,
264
+ item_id: str,
265
+ request: PreferenceScopeChange,
266
+ ) -> KnowledgeRevisionRecord:
267
+ store._require_personal()
268
+ space = await store.authorize(actor, request.personal_space_id, 'maintainer')
269
+ if space['kind'] != 'personal':
270
+ raise HTTPException(status_code=422, detail='preference scope is personal-only')
271
+ if request.scope == 'project':
272
+ await authorize_personal_project(store, actor, space, request.project_id)
273
+
274
+ current = await _read_item(store, space, item_id, request.item_kind)
275
+ if current is None:
276
+ raise HTTPException(status_code=404, detail='knowledge item not found')
277
+ if not current.get('profile_aspect'):
278
+ raise HTTPException(
279
+ status_code=422,
280
+ detail='only personal profile knowledge has a preference scope',
281
+ )
282
+
283
+ previous = _snapshot(current, request.item_kind)
284
+ next_value = {
285
+ **previous,
286
+ 'preferenceScope': request.scope,
287
+ 'preferenceProjectId': request.project_id,
288
+ }
289
+ if (
290
+ previous.get('preferenceScope') == request.scope
291
+ and previous.get('preferenceProjectId') == request.project_id
292
+ ):
293
+ raise HTTPException(status_code=409, detail='preference scope is unchanged')
294
+
295
+ await _update_preference_scope(
296
+ store,
297
+ space,
298
+ item_id,
299
+ request.item_kind,
300
+ request.scope,
301
+ request.project_id,
302
+ )
303
+ created_at = datetime.now(timezone.utc)
304
+ revision_id = _stable_uuid(
305
+ space['id'], 'knowledge-revision', request.item_kind, item_id,
306
+ created_at.isoformat(), actor['id']
307
+ )
308
+ await store.runtime.driver.execute_query(
309
+ '''
310
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
311
+ CREATE (revision:FuliKnowledgeRevision {
312
+ id: $revision_id,
313
+ space_id: $space_id,
314
+ item_id: $item_id,
315
+ item_kind: $item_kind,
316
+ action: 'scope_change',
317
+ reason: $reason,
318
+ previous_json: $previous_json,
319
+ current_json: $current_json,
320
+ created_by: $created_by,
321
+ created_at: $created_at
322
+ })
323
+ MERGE (space)-[:HAS_KNOWLEDGE_REVISION]->(revision)
324
+ ''',
325
+ space_id=space['id'],
326
+ revision_id=revision_id,
327
+ item_id=item_id,
328
+ item_kind=request.item_kind,
329
+ reason=request.reason,
330
+ previous_json=json.dumps(previous, ensure_ascii=False, sort_keys=True),
331
+ current_json=json.dumps(next_value, ensure_ascii=False, sort_keys=True),
332
+ created_by=actor['id'],
333
+ created_at=created_at,
334
+ )
335
+ if request.operation_actor == 'human':
336
+ await record_human_change(
337
+ store,
338
+ actor,
339
+ space,
340
+ item_id,
341
+ request.item_kind,
342
+ reason=request.reason,
343
+ operation='preference_scope',
344
+ )
345
+ return KnowledgeRevisionRecord(
346
+ id=revision_id,
347
+ item_id=item_id,
348
+ item_kind=request.item_kind,
349
+ action='scope_change',
350
+ reason=request.reason,
351
+ previous=previous,
352
+ current=next_value,
353
+ created_at=created_at,
354
+ )
355
+
356
+
357
+ async def _read_item(store, space: dict, item_id: str, item_kind: str):
358
+ if item_kind == 'entity':
359
+ query = '''
360
+ MATCH (item:Entity {uuid: $item_id, group_id: $group_id})
361
+ RETURN item.name AS name, coalesce(item.fuli_type, 'Entity') AS type,
362
+ coalesce(item.summary, '') AS summary,
363
+ item.fuli_invalid_at AS invalid_at,
364
+ item.fuli_origin_quadrant IS NOT NULL
365
+ AS classification_state_explicit,
366
+ coalesce(item.fuli_origin_quadrant, 'known_known') AS origin_quadrant,
367
+ coalesce(item.fuli_current_quadrant, 'known_known') AS current_quadrant,
368
+ coalesce(item.fuli_epistemic_status, 'confirmed') AS epistemic_status,
369
+ coalesce(item.fuli_confirmation_status, 'pending')
370
+ AS confirmation_status,
371
+ item.fuli_confirmation_status IS NOT NULL
372
+ AND item.fuli_confirmation_basis_json IS NOT NULL
373
+ AS confirmation_state_explicit,
374
+ item.fuli_confirmation_basis_json AS confirmation_basis_json,
375
+ item.fuli_reasoning_summary AS reasoning_summary,
376
+ item.fuli_profile_aspect AS profile_aspect,
377
+ CASE WHEN item.fuli_profile_aspect IS NULL THEN NULL
378
+ ELSE coalesce(item.fuli_preference_scope, 'global') END
379
+ AS preference_scope,
380
+ item.fuli_preference_project_id AS preference_project_id,
381
+ coalesce(item.fuli_inheritance_mode, 'local_only')
382
+ AS inheritance_mode,
383
+ coalesce(item.fuli_inherited_project_ids, [])
384
+ AS inherited_project_ids,
385
+ coalesce(item.fuli_utility_score, 0.0) AS utility_score,
386
+ coalesce(item.fuli_confidence_score, 0.5) AS confidence_score,
387
+ coalesce(item.fuli_qualified_use_count, 0)
388
+ AS qualified_use_count,
389
+ coalesce(item.fuli_distinct_task_count, 0)
390
+ AS distinct_task_count,
391
+ item.fuli_last_used_at AS last_used_at,
392
+ coalesce(item.fuli_usage_generation, 1) AS usage_generation,
393
+ item.fuli_replaced_by_item_id AS replaced_by_item_id,
394
+ item.fuli_replaced_by_item_kind AS replaced_by_item_kind
395
+ '''
396
+ else:
397
+ query = '''
398
+ MATCH ()-[item:RELATES_TO {uuid: $item_id, group_id: $group_id}]->()
399
+ RETURN item.name AS type, item.fact AS fact, item.invalid_at AS invalid_at,
400
+ item.fuli_origin_quadrant IS NOT NULL
401
+ AS classification_state_explicit,
402
+ coalesce(item.fuli_origin_quadrant, 'known_known') AS origin_quadrant,
403
+ coalesce(item.fuli_current_quadrant, 'known_known') AS current_quadrant,
404
+ coalesce(item.fuli_epistemic_status, 'confirmed') AS epistemic_status,
405
+ coalesce(item.fuli_confirmation_status, 'pending')
406
+ AS confirmation_status,
407
+ item.fuli_confirmation_status IS NOT NULL
408
+ AND item.fuli_confirmation_basis_json IS NOT NULL
409
+ AS confirmation_state_explicit,
410
+ item.fuli_confirmation_basis_json AS confirmation_basis_json,
411
+ item.fuli_reasoning_summary AS reasoning_summary,
412
+ item.fuli_profile_aspect AS profile_aspect,
413
+ CASE WHEN item.fuli_profile_aspect IS NULL THEN NULL
414
+ ELSE coalesce(item.fuli_preference_scope, 'global') END
415
+ AS preference_scope,
416
+ item.fuli_preference_project_id AS preference_project_id,
417
+ coalesce(item.fuli_inheritance_mode, 'local_only')
418
+ AS inheritance_mode,
419
+ coalesce(item.fuli_inherited_project_ids, [])
420
+ AS inherited_project_ids,
421
+ coalesce(item.fuli_utility_score, 0.0) AS utility_score,
422
+ coalesce(item.fuli_confidence_score, 0.5) AS confidence_score,
423
+ coalesce(item.fuli_qualified_use_count, 0)
424
+ AS qualified_use_count,
425
+ coalesce(item.fuli_distinct_task_count, 0)
426
+ AS distinct_task_count,
427
+ item.fuli_last_used_at AS last_used_at,
428
+ coalesce(item.fuli_usage_generation, 1) AS usage_generation,
429
+ item.fuli_replaced_by_item_id AS replaced_by_item_id,
430
+ item.fuli_replaced_by_item_kind AS replaced_by_item_kind
431
+ '''
432
+ records, _, _ = await store.runtime.driver.execute_query(
433
+ query,
434
+ item_id=item_id,
435
+ group_id=space['group_id'],
436
+ routing_='r',
437
+ )
438
+ return dict(records[0]) if records else None
439
+
440
+
441
+ async def _item_belongs_to_project(
442
+ store,
443
+ space: dict,
444
+ item_id: str,
445
+ item_kind: str,
446
+ project_id: str,
447
+ ) -> bool:
448
+ assignment = await _read_assignment(store, space['id'], item_id, item_kind)
449
+ if assignment:
450
+ return assignment.project_id == project_id
451
+ return await _implicit_project_id(store, space, item_id, item_kind) == project_id
452
+
453
+
454
+ async def _implicit_project_id(store, space: dict, item_id: str, item_kind: str):
455
+ if item_kind == 'entity':
456
+ query = '''
457
+ MATCH (episode:Episodic {group_id: $group_id})-[:MENTIONS]->
458
+ (:Entity {uuid: $item_id, group_id: $group_id})
459
+ WHERE episode.fuli_personal_project_id IS NOT NULL
460
+ RETURN collect(DISTINCT episode.fuli_personal_project_id) AS project_ids
461
+ '''
462
+ else:
463
+ query = '''
464
+ MATCH ()-[item:RELATES_TO {uuid: $item_id, group_id: $group_id}]->()
465
+ UNWIND coalesce(item.episodes, []) AS episode_id
466
+ MATCH (episode:Episodic {uuid: episode_id, group_id: $group_id})
467
+ WHERE episode.fuli_personal_project_id IS NOT NULL
468
+ RETURN collect(DISTINCT episode.fuli_personal_project_id) AS project_ids
469
+ '''
470
+ records, _, _ = await store.runtime.driver.execute_query(
471
+ query,
472
+ item_id=item_id,
473
+ group_id=space['group_id'],
474
+ routing_='r',
475
+ )
476
+ project_ids = records[0]['project_ids'] if records else []
477
+ return project_ids[0] if len(project_ids) == 1 else None
478
+
479
+
480
+ async def _read_assignment(store, space_id: str, item_id: str, item_kind: str):
481
+ records, _, _ = await store.runtime.driver.execute_query(
482
+ '''
483
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
484
+ [:HAS_KNOWLEDGE_ASSIGNMENT]->
485
+ (assignment:FuliKnowledgeAssignment {
486
+ item_id: $item_id,
487
+ item_kind: $item_kind
488
+ })
489
+ RETURN assignment
490
+ ''',
491
+ space_id=space_id,
492
+ item_id=item_id,
493
+ item_kind=item_kind,
494
+ routing_='r',
495
+ )
496
+ return assignment_record(dict(records[0]['assignment'])) if records else None
497
+
498
+
499
+ def _snapshot(value: dict, item_kind: str) -> dict:
500
+ confirmation_basis = _confirmation_basis(value.get('confirmation_basis_json'))
501
+ epistemic = {
502
+ 'classificationStateExplicit': (
503
+ value.get('classification_state_explicit') is True
504
+ ),
505
+ 'originQuadrant': value.get('origin_quadrant') or 'known_known',
506
+ 'currentQuadrant': value.get('current_quadrant') or 'known_known',
507
+ 'epistemicStatus': value.get('epistemic_status') or 'confirmed',
508
+ 'confirmationStatus': value.get('confirmation_status') or 'pending',
509
+ 'confirmationStateExplicit': (
510
+ value.get('confirmation_state_explicit') is True
511
+ ),
512
+ 'confirmationBasis': (
513
+ confirmation_basis.model_dump(mode='json')
514
+ if confirmation_basis else None
515
+ ),
516
+ 'reasoningSummary': value.get('reasoning_summary'),
517
+ 'profileAspect': value.get('profile_aspect'),
518
+ 'preferenceScope': value.get('preference_scope'),
519
+ 'preferenceProjectId': value.get('preference_project_id'),
520
+ 'inheritanceMode': value.get('inheritance_mode') or 'local_only',
521
+ 'inheritedProjectIds': value.get('inherited_project_ids') or [],
522
+ 'utilityScore': float(value.get('utility_score') or 0),
523
+ 'confidenceScore': float(value.get('confidence_score') or 0.5),
524
+ 'qualifiedUseCount': int(value.get('qualified_use_count') or 0),
525
+ 'distinctTaskCount': int(value.get('distinct_task_count') or 0),
526
+ 'lastUsedAt': _iso(value.get('last_used_at')),
527
+ 'usageGeneration': int(value.get('usage_generation') or 1),
528
+ 'replacedByItemId': value.get('replaced_by_item_id'),
529
+ 'replacedByItemKind': value.get('replaced_by_item_kind'),
530
+ }
531
+ if item_kind == 'entity':
532
+ return {
533
+ 'name': value['name'],
534
+ 'summary': value.get('summary') or '',
535
+ 'invalidAt': _iso(value.get('invalid_at')),
536
+ **epistemic,
537
+ }
538
+ return {
539
+ 'type': value['type'],
540
+ 'fact': value['fact'],
541
+ 'invalidAt': _iso(value.get('invalid_at')),
542
+ **epistemic,
543
+ }
544
+
545
+
546
+ def _next_snapshot(previous: dict, request: KnowledgeRevisionCreate, changed_at: datetime):
547
+ value = dict(previous)
548
+ if request.action == 'confirm':
549
+ basis = request.confirmation_basis.model_dump(mode='json')
550
+ basis['confirmed_at'] = changed_at.isoformat()
551
+ value['confirmationStatus'] = 'confirmed'
552
+ value['confirmationStateExplicit'] = True
553
+ value['confirmationBasis'] = basis
554
+ value['epistemicStatus'] = 'confirmed'
555
+ value['confidenceScore'] = 1.0
556
+ elif request.action == 'update':
557
+ content_changed = False
558
+ if request.item_kind == 'entity':
559
+ if request.name is not None:
560
+ content_changed = content_changed or request.name != previous.get('name')
561
+ value['name'] = request.name
562
+ if request.summary is not None:
563
+ content_changed = (
564
+ content_changed or request.summary != previous.get('summary')
565
+ )
566
+ value['summary'] = request.summary
567
+ elif request.fact is not None:
568
+ content_changed = request.fact != previous.get('fact')
569
+ value['fact'] = request.fact
570
+ origin_established = (
571
+ request.origin_quadrant is not None
572
+ and not previous.get('classificationStateExplicit')
573
+ )
574
+ classification_changed = origin_established or (
575
+ request.current_quadrant is not None
576
+ and request.current_quadrant != previous.get('currentQuadrant')
577
+ )
578
+ if request.origin_quadrant is not None:
579
+ if previous.get('classificationStateExplicit'):
580
+ value['originQuadrant'] = previous.get('originQuadrant')
581
+ else:
582
+ value['originQuadrant'] = request.origin_quadrant
583
+ value['classificationStateExplicit'] = True
584
+ if request.current_quadrant is None:
585
+ value['currentQuadrant'] = request.origin_quadrant
586
+ if request.current_quadrant is not None:
587
+ value['currentQuadrant'] = request.current_quadrant
588
+ if request.epistemic_status is not None:
589
+ value['epistemicStatus'] = request.epistemic_status
590
+ if request.confirmation_status is not None:
591
+ value['confirmationStatus'] = request.confirmation_status
592
+ value['confirmationBasis'] = request.confirmation_basis.model_dump(mode='json')
593
+ value['confirmationStateExplicit'] = True
594
+ elif content_changed or classification_changed:
595
+ value['confirmationStatus'] = 'pending'
596
+ value['confirmationBasis'] = _pending_confirmation_basis(
597
+ value.get('confirmationBasis'),
598
+ request.reason,
599
+ )
600
+ value['confirmationStateExplicit'] = True
601
+ trust_reset = (
602
+ request.confirmation_status == 'pending'
603
+ and previous.get('confirmationStatus') != 'pending'
604
+ )
605
+ if content_changed or classification_changed or trust_reset:
606
+ value['usageGeneration'] = int(
607
+ previous.get('usageGeneration') or 1
608
+ ) + 1
609
+ value['utilityScore'] = 0.0
610
+ value['qualifiedUseCount'] = 0
611
+ value['distinctTaskCount'] = 0
612
+ value['lastUsedAt'] = None
613
+ if request.reasoning_summary is not None:
614
+ value['reasoningSummary'] = request.reasoning_summary or None
615
+ if request.profile_aspect is not None:
616
+ value['profileAspect'] = (
617
+ None if request.profile_aspect == 'none' else request.profile_aspect
618
+ )
619
+ if value['profileAspect'] is None:
620
+ value['preferenceScope'] = None
621
+ value['preferenceProjectId'] = None
622
+ elif not previous.get('profileAspect'):
623
+ value['preferenceScope'] = 'global'
624
+ value['preferenceProjectId'] = None
625
+ if request.inheritance_mode is not None:
626
+ value['inheritanceMode'] = request.inheritance_mode
627
+ if request.inherited_project_ids is not None:
628
+ value['inheritedProjectIds'] = request.inherited_project_ids
629
+ if value.get('confirmationStatus') == 'confirmed':
630
+ value['epistemicStatus'] = 'confirmed'
631
+ value['confidenceScore'] = 1.0
632
+ elif value.get('confirmationStatus') == 'agent_confirmed':
633
+ value['epistemicStatus'] = 'observed'
634
+ value['confidenceScore'] = min(
635
+ float(value.get('confidenceScore') or 0.75),
636
+ 0.85,
637
+ )
638
+ else:
639
+ value['epistemicStatus'] = (
640
+ 'exploratory'
641
+ if value.get('originQuadrant') == 'unknown_unknown'
642
+ else 'observed'
643
+ )
644
+ if content_changed or classification_changed or trust_reset:
645
+ value['confidenceScore'] = 0.5
646
+ elif request.action == 'invalidate':
647
+ value['invalidAt'] = changed_at.isoformat()
648
+ value['replacedByItemId'] = request.replacement_item_id
649
+ value['replacedByItemKind'] = request.replacement_item_kind
650
+ elif request.action == 'link_replacement':
651
+ value['replacedByItemId'] = request.replacement_item_id
652
+ value['replacedByItemKind'] = request.replacement_item_kind
653
+ elif request.action == 'restore':
654
+ value['invalidAt'] = None
655
+ value['replacedByItemId'] = None
656
+ value['replacedByItemKind'] = None
657
+ return value
658
+
659
+
660
+ async def _updated_embedding(store, previous: dict, current: dict, request):
661
+ if request.action != 'update':
662
+ return None
663
+ key = 'name' if request.item_kind == 'entity' else 'fact'
664
+ if previous.get(key) == current.get(key):
665
+ return None
666
+ return (await store.runtime.embedder.create_batch([current[key]]))[0]
667
+
668
+
669
+ async def _update_item(store, space, item_id, item_kind, value, embedding):
670
+ invalid_at = _parse_datetime(value.get('invalidAt'))
671
+ if item_kind == 'entity':
672
+ query = '''
673
+ MATCH (item:Entity {uuid: $item_id, group_id: $group_id})
674
+ SET item.name = $name,
675
+ item.summary = $summary,
676
+ item.fuli_invalid_at = $invalid_at,
677
+ item.fuli_origin_quadrant = $origin_quadrant,
678
+ item.fuli_current_quadrant = $current_quadrant,
679
+ item.fuli_epistemic_status = $epistemic_status,
680
+ item.fuli_confirmation_status = $confirmation_status,
681
+ item.fuli_confirmation_basis_json = $confirmation_basis_json,
682
+ item.fuli_reasoning_summary = $reasoning_summary,
683
+ item.fuli_profile_aspect = $profile_aspect,
684
+ item.fuli_preference_scope = $preference_scope,
685
+ item.fuli_preference_project_id = $preference_project_id,
686
+ item.fuli_inheritance_mode = $inheritance_mode,
687
+ item.fuli_inherited_project_ids = $inherited_project_ids,
688
+ item.fuli_utility_score = $utility_score,
689
+ item.fuli_confidence_score = $confidence_score,
690
+ item.fuli_qualified_use_count = $qualified_use_count,
691
+ item.fuli_distinct_task_count = $distinct_task_count,
692
+ item.fuli_last_used_at = $last_used_at,
693
+ item.fuli_usage_generation = $usage_generation,
694
+ item.fuli_replaced_by_item_id = $replaced_by_item_id,
695
+ item.fuli_replaced_by_item_kind = $replaced_by_item_kind,
696
+ item.name_embedding = coalesce($embedding, item.name_embedding)
697
+ '''
698
+ parameters = {'name': value['name'], 'summary': value['summary']}
699
+ else:
700
+ query = '''
701
+ MATCH ()-[item:RELATES_TO {uuid: $item_id, group_id: $group_id}]->()
702
+ SET item.fact = $fact,
703
+ item.invalid_at = $invalid_at,
704
+ item.fuli_origin_quadrant = $origin_quadrant,
705
+ item.fuli_current_quadrant = $current_quadrant,
706
+ item.fuli_epistemic_status = $epistemic_status,
707
+ item.fuli_confirmation_status = $confirmation_status,
708
+ item.fuli_confirmation_basis_json = $confirmation_basis_json,
709
+ item.fuli_reasoning_summary = $reasoning_summary,
710
+ item.fuli_profile_aspect = $profile_aspect,
711
+ item.fuli_preference_scope = $preference_scope,
712
+ item.fuli_preference_project_id = $preference_project_id,
713
+ item.fuli_inheritance_mode = $inheritance_mode,
714
+ item.fuli_inherited_project_ids = $inherited_project_ids,
715
+ item.fuli_utility_score = $utility_score,
716
+ item.fuli_confidence_score = $confidence_score,
717
+ item.fuli_qualified_use_count = $qualified_use_count,
718
+ item.fuli_distinct_task_count = $distinct_task_count,
719
+ item.fuli_last_used_at = $last_used_at,
720
+ item.fuli_usage_generation = $usage_generation,
721
+ item.fuli_replaced_by_item_id = $replaced_by_item_id,
722
+ item.fuli_replaced_by_item_kind = $replaced_by_item_kind,
723
+ item.fact_embedding = coalesce($embedding, item.fact_embedding)
724
+ '''
725
+ parameters = {'fact': value['fact']}
726
+ await store.runtime.driver.execute_query(
727
+ query,
728
+ item_id=item_id,
729
+ group_id=space['group_id'],
730
+ invalid_at=invalid_at,
731
+ origin_quadrant=value['originQuadrant'],
732
+ current_quadrant=value['currentQuadrant'],
733
+ epistemic_status=value['epistemicStatus'],
734
+ confirmation_status=value.get('confirmationStatus', 'pending'),
735
+ confirmation_basis_json=(
736
+ json.dumps(
737
+ value['confirmationBasis'],
738
+ ensure_ascii=False,
739
+ sort_keys=True,
740
+ )
741
+ if value.get('confirmationBasis') else None
742
+ ),
743
+ reasoning_summary=value.get('reasoningSummary'),
744
+ profile_aspect=value.get('profileAspect'),
745
+ preference_scope=value.get('preferenceScope'),
746
+ preference_project_id=value.get('preferenceProjectId'),
747
+ inheritance_mode=value.get('inheritanceMode', 'local_only'),
748
+ inherited_project_ids=value.get('inheritedProjectIds') or [],
749
+ utility_score=float(value.get('utilityScore') or 0),
750
+ confidence_score=float(value.get('confidenceScore') or 0.5),
751
+ qualified_use_count=int(value.get('qualifiedUseCount') or 0),
752
+ distinct_task_count=int(value.get('distinctTaskCount') or 0),
753
+ last_used_at=_parse_datetime(value.get('lastUsedAt')),
754
+ usage_generation=int(value.get('usageGeneration') or 1),
755
+ replaced_by_item_id=value.get('replacedByItemId'),
756
+ replaced_by_item_kind=value.get('replacedByItemKind'),
757
+ embedding=embedding,
758
+ **parameters,
759
+ )
760
+
761
+
762
+ def _confirmation_basis(value) -> ConfirmationBasis | None:
763
+ if not value:
764
+ return None
765
+ if isinstance(value, str):
766
+ value = json.loads(value)
767
+ return ConfirmationBasis.model_validate(value)
768
+
769
+
770
+ def _pending_confirmation_basis(value, reason: str) -> dict:
771
+ basis = dict(value or {})
772
+ basis.setdefault('existence_reason', reason)
773
+ basis.setdefault('quadrant_reason', reason)
774
+ basis.setdefault('proposed_by', {'kind': 'user', 'label': '当前用户'})
775
+ basis['confirmed_by'] = None
776
+ basis['confirmed_at'] = None
777
+ basis.pop('agent_policy_version', None)
778
+ return basis
779
+
780
+
781
+ async def _update_preference_scope(
782
+ store,
783
+ space,
784
+ item_id,
785
+ item_kind,
786
+ scope,
787
+ project_id,
788
+ ):
789
+ if item_kind == 'entity':
790
+ query = '''
791
+ MATCH (item:Entity {uuid: $item_id, group_id: $group_id})
792
+ SET item.fuli_preference_scope = $scope,
793
+ item.fuli_preference_project_id = $project_id
794
+ '''
795
+ else:
796
+ query = '''
797
+ MATCH ()-[item:RELATES_TO {uuid: $item_id, group_id: $group_id}]->()
798
+ SET item.fuli_preference_scope = $scope,
799
+ item.fuli_preference_project_id = $project_id
800
+ '''
801
+ await store.runtime.driver.execute_query(
802
+ query,
803
+ item_id=item_id,
804
+ group_id=space['group_id'],
805
+ scope=scope,
806
+ project_id=project_id,
807
+ )
808
+ def _iso(value):
809
+ native = _native_datetime(value)
810
+ return native.isoformat() if isinstance(native, datetime) else native
811
+
812
+
813
+ def _parse_datetime(value):
814
+ if not value or isinstance(value, datetime):
815
+ return value
816
+ return datetime.fromisoformat(value.replace('Z', '+00:00'))