fuli-context 0.7.3 → 0.7.4-beta.1

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 (107) hide show
  1. package/README.md +170 -18
  2. package/README.zh-CN.md +146 -12
  3. package/compose.graphiti.yml +2 -0
  4. package/dist/web/assets/{AboutPage-qeO_vm0T.js → AboutPage-CftRexSY.js} +1 -1
  5. package/dist/web/assets/{ConnectionsPage-DO0GR9Xg.js → ConnectionsPage-D5J91x6d.js} +1 -1
  6. package/dist/web/assets/{useMinimumLoadingDisplay-BpCyBeko.css → KnowledgeEditDialog-DWUV_NPp.css} +1 -1
  7. package/dist/web/assets/KnowledgeEditDialog-wotVahvr.js +1 -0
  8. package/dist/web/assets/{KnowledgeInspector-Bjhdu2mf.css → KnowledgeInspector--1NujtXW.css} +1 -1
  9. package/dist/web/assets/KnowledgeInspector-BzkW0mVs.js +1 -0
  10. package/dist/web/assets/KnowledgeOrganizerPage-BebUDiHt.js +1 -0
  11. package/dist/web/assets/KnowledgePage-OUT07aKH.js +1 -0
  12. package/dist/web/assets/KnowledgeWorkspace-1pHJ0MNZ.js +4 -0
  13. package/dist/web/assets/{OverviewPage-CmCcDCAs.js → OverviewPage-CHE6XhdS.js} +1 -1
  14. package/dist/web/assets/PersonalProfilePage-B7T09bow.css +1 -0
  15. package/dist/web/assets/PersonalProfilePage-CXXiabUy.js +1 -0
  16. package/dist/web/assets/PersonalProjectsPage-d2wYGW8n.js +1 -0
  17. package/dist/web/assets/PublicProjectsPage-hxLShtnZ.js +1 -0
  18. package/dist/web/assets/{ReviewPage-B3vnZ6gn.js → ReviewPage-DUeyu7E8.js} +1 -1
  19. package/dist/web/assets/{SearchableSelect-ih69qbcS.js → SearchableSelect-C5RiB5F8.js} +1 -1
  20. package/dist/web/assets/SettingsPage-BMuASSzQ.js +1 -0
  21. package/dist/web/assets/SettingsPage-CAfz4qiV.css +1 -0
  22. package/dist/web/assets/WritingTastePage-B9BCYIwQ.js +1 -0
  23. package/dist/web/assets/WritingTastePage-DSgvTH6x.css +1 -0
  24. package/dist/web/assets/{console-DE40hXGC.js → console-DMvHxKf9.js} +1 -1
  25. package/dist/web/assets/conversation-launcher-settings-Dnlcw7Rj.js +1 -0
  26. package/dist/web/assets/{i18n-CDxBs1sz.js → i18n-CWfd8Dtt.js} +1 -1
  27. package/dist/web/assets/{identity-BCxb1mcy.js → identity-C9mLdR8L.js} +1 -1
  28. package/dist/web/assets/index-F5e2sQ7p.js +2 -0
  29. package/dist/web/assets/{index-CCOGfZzE.css → index-X2j29j8i.css} +1 -1
  30. package/dist/web/assets/{model-CCMdt10Q.js → model-CsYAR8O3.js} +1 -1
  31. package/dist/web/assets/paths-CDREouhi.js +1 -0
  32. package/dist/web/assets/useApi-CROJJdhE-BLQiPVw0.js +1 -0
  33. package/dist/web/assets/vue-router-DN8x6iY1.js +1 -0
  34. package/dist/web/index.html +7 -6
  35. package/graph-provider/fuli_graph/app.py +238 -2
  36. package/graph-provider/fuli_graph/auth.py +18 -0
  37. package/graph-provider/fuli_graph/collaboration_context.py +253 -17
  38. package/graph-provider/fuli_graph/common_knowledge.py +6 -2
  39. package/graph-provider/fuli_graph/config.py +5 -0
  40. package/graph-provider/fuli_graph/graph_models.py +6 -0
  41. package/graph-provider/fuli_graph/graph_projection.py +14 -0
  42. package/graph-provider/fuli_graph/graph_query.py +27 -3
  43. package/graph-provider/fuli_graph/knowledge_management.py +19 -1
  44. package/graph-provider/fuli_graph/knowledge_search.py +11 -0
  45. package/graph-provider/fuli_graph/models.py +15 -2
  46. package/graph-provider/fuli_graph/personal_global_preference_models.py +234 -0
  47. package/graph-provider/fuli_graph/personal_global_preferences.py +1247 -0
  48. package/graph-provider/fuli_graph/project_action_models.py +22 -0
  49. package/graph-provider/fuli_graph/project_knowledge.py +121 -2
  50. package/graph-provider/fuli_graph/provider_values.py +24 -0
  51. package/graph-provider/fuli_graph/runtime.py +78 -0
  52. package/graph-provider/fuli_graph/search_projection.py +14 -1
  53. package/graph-provider/fuli_graph/store_knowledge.py +116 -2
  54. package/graph-provider/fuli_graph/workflow_candidate_models.py +168 -0
  55. package/graph-provider/fuli_graph/workflow_candidates.py +996 -0
  56. package/graph-provider/fuli_graph/workflow_observation_models.py +30 -0
  57. package/graph-provider/fuli_graph/workflow_observations.py +84 -0
  58. package/graph-provider/pyproject.toml +1 -1
  59. package/npm-shrinkwrap.json +18 -18
  60. package/package.json +4 -4
  61. package/skills/capturing-session-knowledge/SKILL.md +32 -5
  62. package/skills/flreview/SKILL.md +4 -3
  63. package/src/agent-tools/graph-definitions.js +136 -20
  64. package/src/agent-tools/graph-handlers.js +10 -4
  65. package/src/cli/command-registry.js +1 -1
  66. package/src/cli/local-runtime-command.js +69 -2
  67. package/src/graphiti/agent-knowledge-workflows.js +497 -16
  68. package/src/graphiti/collaboration-preference-projection.js +162 -0
  69. package/src/graphiti/federated-application.js +155 -161
  70. package/src/graphiti/federated-search-support.js +102 -0
  71. package/src/graphiti/knowledge-provider-mapping.js +15 -0
  72. package/src/graphiti/provider-client.js +162 -10
  73. package/src/graphiti/related-project-suggestions.js +109 -0
  74. package/src/graphiti/runtime-config.js +14 -0
  75. package/src/graphiti/source-marker.js +2 -2
  76. package/src/graphiti/user-taste-skill.js +241 -0
  77. package/src/graphiti/workflow-candidate-mapping.js +87 -0
  78. package/src/graphiti/workflow-observation.js +82 -0
  79. package/src/graphiti/writing-taste-profile-workflow.js +25 -0
  80. package/src/graphiti/writing-taste-profile.js +490 -0
  81. package/src/http/graph-api-router.js +15 -2
  82. package/src/mcp/context-resources.js +158 -0
  83. package/src/mcp/create-mcp-server.js +39 -5
  84. package/src/mcp/instructions.js +5 -7
  85. package/src/mcp/session-id.js +60 -0
  86. package/src/mcp/task-context-registry.js +15 -3
  87. package/src/mcp/tool-annotations.js +8 -1
  88. package/src/mcp/tool-result.js +28 -4
  89. package/src/server.js +0 -1
  90. package/src/setup/claude-code-config.js +17 -6
  91. package/src/setup/graph-runtime.js +20 -2
  92. package/src/system/runtime-settings.js +121 -2
  93. package/src/system/system-service.js +5 -1
  94. package/dist/web/assets/KnowledgeInspector-DEW5lA1m.js +0 -1
  95. package/dist/web/assets/KnowledgeOrganizerPage-BkM568Ld.js +0 -1
  96. package/dist/web/assets/KnowledgePage-BAT4aZIm.js +0 -1
  97. package/dist/web/assets/KnowledgeWorkspace-DyGtBz--.js +0 -4
  98. package/dist/web/assets/PersonalProfilePage-C42mwKN8.js +0 -1
  99. package/dist/web/assets/PersonalProfilePage-CdmEzyvZ.css +0 -1
  100. package/dist/web/assets/PersonalProjectsPage-BwaXR62K.js +0 -1
  101. package/dist/web/assets/PublicProjectsPage-egKBulOq.js +0 -1
  102. package/dist/web/assets/SettingsPage-C6ouU0sR.css +0 -1
  103. package/dist/web/assets/SettingsPage-oLE8mNs5.js +0 -1
  104. package/dist/web/assets/index-BzhNfzU_.js +0 -2
  105. package/dist/web/assets/paths-DwHeiGMT.js +0 -1
  106. package/dist/web/assets/useMinimumLoadingDisplay-HdI7XAmu.js +0 -1
  107. package/dist/web/assets/vue-router-Drfl1F8i.js +0 -1
@@ -0,0 +1,1247 @@
1
+ import hashlib
2
+ import json
3
+ import secrets
4
+ from datetime import datetime, timedelta, timezone
5
+
6
+ from fastapi import HTTPException
7
+
8
+ from .models import ConfirmationBasis
9
+ from .personal_global_preference_models import (
10
+ PersonalGlobalPreferenceDecisionApply,
11
+ PersonalGlobalPreferenceDecisionInspection,
12
+ PersonalGlobalPreferenceDecisionIntent,
13
+ PersonalGlobalPreferenceDecisionPreview,
14
+ PersonalGlobalPreferenceDecisionRecord,
15
+ PersonalGlobalPreferenceDecisionRevision,
16
+ PersonalGlobalPreferenceDecisionStatusRequest,
17
+ PersonalGlobalPreferenceDecisionStatusResult,
18
+ PersonalGlobalPreferenceEligibleTargetScope,
19
+ PersonalGlobalPreferenceScopeOptions,
20
+ PersonalGlobalPreferenceScopeOptionsRequest,
21
+ PersonalGlobalPreferenceSourceSnapshot,
22
+ )
23
+ from .provider_values import (
24
+ json_object,
25
+ native_datetime,
26
+ now_utc,
27
+ preference_qualifiers,
28
+ stable_uuid,
29
+ )
30
+
31
+
32
+ DECISION_PREVIEW_TTL = timedelta(minutes=10)
33
+
34
+
35
+ async def personal_global_preference_scope_options(
36
+ store,
37
+ actor: dict,
38
+ candidate_id: str,
39
+ request: PersonalGlobalPreferenceScopeOptionsRequest,
40
+ ) -> PersonalGlobalPreferenceScopeOptions:
41
+ space = await _personal_space(
42
+ store,
43
+ actor,
44
+ request.personal_space_id,
45
+ 'reader',
46
+ )
47
+ snapshots = await _candidate_source_snapshots(
48
+ store,
49
+ space,
50
+ candidate_id,
51
+ request.source_items,
52
+ request.preference_key,
53
+ )
54
+ eligible_target_scopes = await _eligible_target_scopes(
55
+ store,
56
+ space,
57
+ snapshots,
58
+ )
59
+ return PersonalGlobalPreferenceScopeOptions(
60
+ personal_space_id=space['id'],
61
+ candidate_id=candidate_id,
62
+ candidate_version=_candidate_version(
63
+ snapshots,
64
+ eligible_target_scopes,
65
+ ),
66
+ preference_key=request.preference_key,
67
+ source_snapshots=snapshots,
68
+ eligible_target_scopes=eligible_target_scopes,
69
+ )
70
+
71
+
72
+ async def inspect_personal_global_preference_decision(
73
+ store,
74
+ actor: dict,
75
+ candidate_id: str,
76
+ request: PersonalGlobalPreferenceDecisionIntent,
77
+ ) -> PersonalGlobalPreferenceDecisionInspection:
78
+ space = await _personal_space(
79
+ store,
80
+ actor,
81
+ request.personal_space_id,
82
+ 'reader',
83
+ )
84
+ snapshots, eligible_target_scopes = await _validated_source_snapshots(
85
+ store,
86
+ space,
87
+ candidate_id,
88
+ request,
89
+ )
90
+ current_revision = await _decision_revision(
91
+ store,
92
+ space['id'],
93
+ candidate_id,
94
+ )
95
+ if current_revision != request.decision_revision:
96
+ raise HTTPException(
97
+ status_code=409,
98
+ detail='personal-global decision changed; refresh before reviewing',
99
+ )
100
+ return PersonalGlobalPreferenceDecisionInspection(
101
+ status='human_review_required',
102
+ personal_space_id=space['id'],
103
+ candidate_id=candidate_id,
104
+ candidate_version=request.candidate_version,
105
+ decision_revision=request.decision_revision,
106
+ decision=request.decision,
107
+ preference_key=request.preference_key,
108
+ target_scope=request.target_scope,
109
+ target_project_id=request.target_project_id,
110
+ eligible_target_scopes=eligible_target_scopes,
111
+ payload_fingerprint=_decision_payload_fingerprint(
112
+ candidate_id,
113
+ request,
114
+ ),
115
+ source_snapshots=snapshots,
116
+ required_action=(
117
+ 'An independent human-review channel must mint the one-time '
118
+ 'approval token for this exact fingerprint.'
119
+ ),
120
+ )
121
+
122
+
123
+ async def preview_personal_global_preference_decision(
124
+ store,
125
+ actor: dict,
126
+ candidate_id: str,
127
+ request: PersonalGlobalPreferenceDecisionIntent,
128
+ *,
129
+ created_at: datetime | None = None,
130
+ token_factory=secrets.token_urlsafe,
131
+ ) -> PersonalGlobalPreferenceDecisionPreview:
132
+ space = await _personal_space(
133
+ store,
134
+ actor,
135
+ request.personal_space_id,
136
+ 'maintainer',
137
+ )
138
+ snapshots, eligible_target_scopes = await _validated_source_snapshots(
139
+ store,
140
+ space,
141
+ candidate_id,
142
+ request,
143
+ )
144
+ issued_at = created_at or now_utc()
145
+ expires_at = issued_at + DECISION_PREVIEW_TTL
146
+ approval_token = token_factory(32)
147
+ token_hash = _sha256(approval_token)
148
+ payload_fingerprint = _decision_payload_fingerprint(candidate_id, request)
149
+ preview_id = stable_uuid(
150
+ space['id'],
151
+ 'personal-global-preference-preview',
152
+ token_hash,
153
+ )
154
+ records, _, _ = await store.runtime.driver.execute_query(
155
+ _CREATE_DECISION_PREVIEW_QUERY,
156
+ space_id=space['id'],
157
+ candidate_id=candidate_id,
158
+ candidate_version=request.candidate_version,
159
+ decision_revision=request.decision_revision,
160
+ target_scope=request.target_scope,
161
+ target_project_id=request.target_project_id,
162
+ preview_id=preview_id,
163
+ token_hash=token_hash,
164
+ payload_fingerprint=payload_fingerprint,
165
+ issued_to_actor_id=actor['id'],
166
+ issued_channel='independent_human_review',
167
+ created_at=issued_at,
168
+ expires_at=expires_at,
169
+ )
170
+ if not records:
171
+ raise HTTPException(
172
+ status_code=409,
173
+ detail='personal-global decision changed before preview was created',
174
+ )
175
+ return PersonalGlobalPreferenceDecisionPreview(
176
+ preview_id=preview_id,
177
+ candidate_id=candidate_id,
178
+ candidate_version=request.candidate_version,
179
+ decision_revision=request.decision_revision,
180
+ target_scope=request.target_scope,
181
+ target_project_id=request.target_project_id,
182
+ eligible_target_scopes=eligible_target_scopes,
183
+ payload_fingerprint=payload_fingerprint,
184
+ approval_token=approval_token,
185
+ expires_at=expires_at,
186
+ source_snapshots=snapshots,
187
+ )
188
+
189
+
190
+ async def apply_personal_global_preference_decision(
191
+ store,
192
+ actor: dict,
193
+ candidate_id: str,
194
+ request: PersonalGlobalPreferenceDecisionApply,
195
+ *,
196
+ applied_at: datetime | None = None,
197
+ ) -> PersonalGlobalPreferenceDecisionRecord:
198
+ space = await _personal_space(
199
+ store,
200
+ actor,
201
+ request.personal_space_id,
202
+ 'maintainer',
203
+ )
204
+ payload_fingerprint = _decision_payload_fingerprint(candidate_id, request)
205
+ event_id = stable_uuid(
206
+ space['id'],
207
+ 'personal-global-preference-decision-event',
208
+ candidate_id,
209
+ request.candidate_version,
210
+ request.idempotency_key,
211
+ )
212
+ existing_rows, _, _ = await store.runtime.driver.execute_query(
213
+ _DECISION_EVENT_QUERY,
214
+ space_id=space['id'],
215
+ event_id=event_id,
216
+ routing_='r',
217
+ )
218
+ if existing_rows:
219
+ if existing_rows[0].get('payload_fingerprint') != payload_fingerprint:
220
+ raise HTTPException(
221
+ status_code=409,
222
+ detail=(
223
+ 'personal-global decision idempotency key was used for '
224
+ 'another candidate version or payload'
225
+ ),
226
+ )
227
+ return _decision_record(existing_rows[0])
228
+
229
+ snapshots, eligible_target_scopes = await _validated_source_snapshots(
230
+ store,
231
+ space,
232
+ candidate_id,
233
+ request,
234
+ )
235
+ changed_at = applied_at or now_utc()
236
+ decision_status = (
237
+ 'approved' if request.decision == 'approve' else 'rejected'
238
+ )
239
+ global_assertion_id = (
240
+ stable_uuid(
241
+ space['group_id'],
242
+ 'personal-global-preference-assertion',
243
+ event_id,
244
+ payload_fingerprint,
245
+ )
246
+ if request.decision == 'approve'
247
+ else None
248
+ )
249
+ episode_id = (
250
+ stable_uuid(
251
+ space['group_id'],
252
+ 'personal-global-preference-episode',
253
+ event_id,
254
+ )
255
+ if global_assertion_id
256
+ else None
257
+ )
258
+ source_uris = sorted({
259
+ uri
260
+ for snapshot in snapshots
261
+ for uri in snapshot.source_uris
262
+ })
263
+ attributes = {
264
+ 'candidateId': candidate_id,
265
+ 'candidateVersion': request.candidate_version,
266
+ 'decisionRevision': request.decision_revision + 1,
267
+ 'preferenceKey': request.preference_key,
268
+ 'targetScope': request.target_scope,
269
+ 'targetProjectId': request.target_project_id,
270
+ 'eligibleTargetScopes': [
271
+ scope.model_dump(mode='json')
272
+ for scope in eligible_target_scopes
273
+ ],
274
+ 'scopeDecision': decision_status,
275
+ 'humanConfirmationReason': request.human_confirmation_reason,
276
+ 'sourceItemIds': [item.item_id for item in snapshots],
277
+ 'sourceProjectIds': [item.project_id for item in snapshots],
278
+ 'sourceUris': source_uris,
279
+ 'sourcePreferenceSnapshots': [
280
+ snapshot.model_dump(mode='json') for snapshot in snapshots
281
+ ],
282
+ 'searchTerms': [candidate_id, request.candidate_version],
283
+ }
284
+ confirmation_basis = ConfirmationBasis.model_validate({
285
+ 'existence_reason': request.human_confirmation_reason,
286
+ 'quadrant_reason': request.human_confirmation_reason,
287
+ 'proposed_by': {
288
+ 'kind': 'user',
289
+ 'label': 'Independent human scope review',
290
+ },
291
+ 'confirmed_by': {
292
+ 'kind': 'user',
293
+ 'label': 'Authenticated personal-space maintainer',
294
+ },
295
+ 'confirmed_at': request.confirmed_at,
296
+ })
297
+ embedding = (
298
+ await store.runtime.embedder.create(request.global_title)
299
+ if global_assertion_id
300
+ else None
301
+ )
302
+ token_hash = _sha256(request.approval_token)
303
+ records, _, _ = await store.runtime.driver.execute_query(
304
+ _APPLY_DECISION_QUERY,
305
+ space_id=space['id'],
306
+ group_id=space['group_id'],
307
+ candidate_id=candidate_id,
308
+ candidate_version=request.candidate_version,
309
+ decision_revision=request.decision_revision,
310
+ decision_status=decision_status,
311
+ target_scope=request.target_scope,
312
+ target_project_id=request.target_project_id,
313
+ source_project_ids=sorted({
314
+ snapshot.project_id for snapshot in snapshots
315
+ }),
316
+ expected_parent_scopes=[
317
+ {
318
+ 'target_project_id': scope.target_project_id,
319
+ 'max_distance': scope.max_distance,
320
+ }
321
+ for scope in eligible_target_scopes
322
+ if scope.target_scope == 'parent_project'
323
+ ],
324
+ token_hash=token_hash,
325
+ payload_fingerprint=payload_fingerprint,
326
+ event_id=event_id,
327
+ actor_id=actor['id'],
328
+ global_assertion_id=global_assertion_id,
329
+ episode_id=episode_id,
330
+ global_key=(
331
+ 'personal-preference-convergence:'
332
+ f'{candidate_id}:{request.target_scope}:'
333
+ f'{request.target_project_id or "personal-global"}'
334
+ ),
335
+ global_title=request.global_title,
336
+ global_instruction=request.global_instruction,
337
+ profile_aspect=request.profile_aspect,
338
+ preference_scope=(
339
+ 'project'
340
+ if request.target_scope == 'parent_project'
341
+ else 'global'
342
+ ),
343
+ preference_project_id=request.target_project_id,
344
+ inheritance_mode=(
345
+ 'descendants'
346
+ if request.target_scope == 'parent_project'
347
+ else 'local_only'
348
+ ),
349
+ confirmation_basis_json=json.dumps(
350
+ confirmation_basis.model_dump(mode='json'),
351
+ ensure_ascii=False,
352
+ sort_keys=True,
353
+ ),
354
+ human_confirmation_reason=request.human_confirmation_reason,
355
+ attributes_json=json.dumps(
356
+ attributes,
357
+ ensure_ascii=False,
358
+ sort_keys=True,
359
+ ),
360
+ source_uri=source_uris[0] if source_uris else None,
361
+ session_id=request.session_id,
362
+ source_items=[
363
+ item.model_dump(mode='json') for item in request.source_items
364
+ ],
365
+ source_items_json=json.dumps(
366
+ [item.model_dump(mode='json') for item in request.source_items],
367
+ ensure_ascii=False,
368
+ sort_keys=True,
369
+ ),
370
+ source_snapshots=[
371
+ _source_cas_row(snapshot) for snapshot in snapshots
372
+ ],
373
+ source_snapshots_json=json.dumps(
374
+ [snapshot.model_dump(mode='json') for snapshot in snapshots],
375
+ ensure_ascii=False,
376
+ sort_keys=True,
377
+ ),
378
+ embedding=embedding,
379
+ confirmed_at=request.confirmed_at,
380
+ changed_at=changed_at,
381
+ expires_after=changed_at,
382
+ )
383
+ if not records:
384
+ raise HTTPException(
385
+ status_code=409,
386
+ detail=(
387
+ 'personal-global review token is invalid, expired, used, '
388
+ 'stale, or another decision won this revision'
389
+ ),
390
+ )
391
+ return _decision_record(records[0])
392
+
393
+
394
+ async def personal_global_preference_decision_status(
395
+ store,
396
+ actor: dict,
397
+ request: PersonalGlobalPreferenceDecisionStatusRequest,
398
+ ) -> PersonalGlobalPreferenceDecisionStatusResult:
399
+ space = await _personal_space(
400
+ store,
401
+ actor,
402
+ request.personal_space_id,
403
+ 'reader',
404
+ )
405
+ rows, _, _ = await store.runtime.driver.execute_query(
406
+ _DECISION_STATUS_QUERY,
407
+ space_id=space['id'],
408
+ candidates=[item.model_dump() for item in request.candidates],
409
+ routing_='r',
410
+ )
411
+ decisions = []
412
+ revisions = []
413
+ for row in rows:
414
+ value = dict(row)
415
+ revisions.append(PersonalGlobalPreferenceDecisionRevision(
416
+ candidate_id=value['candidate_id'],
417
+ decision_revision=int(value.get('decision_revision') or 0),
418
+ current_candidate_version=value.get('current_candidate_version'),
419
+ ))
420
+ if (
421
+ value.get('decision_event_id')
422
+ and value.get('current_candidate_version')
423
+ == value.get('requested_candidate_version')
424
+ ):
425
+ decisions.append(_decision_record(value))
426
+ return PersonalGlobalPreferenceDecisionStatusResult(
427
+ personal_space_id=space['id'],
428
+ decisions=decisions,
429
+ revisions=revisions,
430
+ )
431
+
432
+
433
+ async def _validated_source_snapshots(
434
+ store,
435
+ space: dict,
436
+ candidate_id: str,
437
+ request: PersonalGlobalPreferenceDecisionIntent,
438
+ ) -> tuple[
439
+ list[PersonalGlobalPreferenceSourceSnapshot],
440
+ list[PersonalGlobalPreferenceEligibleTargetScope],
441
+ ]:
442
+ snapshots = await _candidate_source_snapshots(
443
+ store,
444
+ space,
445
+ candidate_id,
446
+ request.source_items,
447
+ request.preference_key,
448
+ )
449
+ eligible_target_scopes = await _eligible_target_scopes(
450
+ store,
451
+ space,
452
+ snapshots,
453
+ )
454
+ if _candidate_version(
455
+ snapshots,
456
+ eligible_target_scopes,
457
+ ) != request.candidate_version:
458
+ raise HTTPException(
459
+ status_code=409,
460
+ detail=(
461
+ 'candidate source or eligible target scope version changed; '
462
+ 'rediscover before reviewing'
463
+ ),
464
+ )
465
+ requested_target = (
466
+ request.target_scope,
467
+ request.target_project_id,
468
+ )
469
+ eligible_targets = {
470
+ (scope.target_scope, scope.target_project_id)
471
+ for scope in eligible_target_scopes
472
+ }
473
+ if requested_target not in eligible_targets:
474
+ raise HTTPException(
475
+ status_code=409,
476
+ detail=(
477
+ 'requested target scope is not an active, human-authorized '
478
+ 'common scope for every source project'
479
+ ),
480
+ )
481
+ return snapshots, eligible_target_scopes
482
+
483
+
484
+ async def _candidate_source_snapshots(
485
+ store,
486
+ space: dict,
487
+ candidate_id: str,
488
+ source_items,
489
+ requested_preference_key: str,
490
+ ) -> list[PersonalGlobalPreferenceSourceSnapshot]:
491
+ expected_candidate_id = _candidate_id(
492
+ [item.item_id for item in source_items]
493
+ )
494
+ if candidate_id != expected_candidate_id:
495
+ raise HTTPException(
496
+ status_code=409,
497
+ detail='candidate id does not match the selected source preferences',
498
+ )
499
+ source_refs = [item.model_dump() for item in source_items]
500
+ records = []
501
+ entity_sources = [
502
+ item for item in source_refs if item['item_kind'] == 'entity'
503
+ ]
504
+ relationship_sources = [
505
+ item for item in source_refs if item['item_kind'] == 'relationship'
506
+ ]
507
+ if entity_sources:
508
+ rows, _, _ = await store.runtime.driver.execute_query(
509
+ _SOURCE_ENTITY_QUERY,
510
+ group_id=space['group_id'],
511
+ sources=entity_sources,
512
+ routing_='r',
513
+ )
514
+ records.extend(rows)
515
+ if relationship_sources:
516
+ rows, _, _ = await store.runtime.driver.execute_query(
517
+ _SOURCE_RELATIONSHIP_QUERY,
518
+ group_id=space['group_id'],
519
+ sources=relationship_sources,
520
+ routing_='r',
521
+ )
522
+ records.extend(rows)
523
+ if len(records) != len(source_refs):
524
+ raise HTTPException(
525
+ status_code=409,
526
+ detail='one or more candidate source preferences are missing',
527
+ )
528
+ snapshot_by_key = {}
529
+ for record in records:
530
+ value = dict(record)
531
+ attributes = json_object(value.get('attributes_json'))
532
+ preference_key = (
533
+ attributes.get('preferenceKey')
534
+ or attributes.get('preference_key')
535
+ or value.get('key')
536
+ or value.get('item_id')
537
+ )
538
+ basis = json_object(value.get('confirmation_basis_json'))
539
+ try:
540
+ snapshot = PersonalGlobalPreferenceSourceSnapshot(
541
+ item_id=value['item_id'],
542
+ item_kind=value['item_kind'],
543
+ project_id=value['project_id'],
544
+ key=value.get('key') or value['item_id'],
545
+ preference_key=preference_key,
546
+ preference_qualifiers=preference_qualifiers(attributes),
547
+ title=value.get('title') or value['item_id'],
548
+ instruction=value.get('instruction') or '',
549
+ profile_aspect=value.get('profile_aspect'),
550
+ confirmation_status=value.get('confirmation_status'),
551
+ confirmation_basis=ConfirmationBasis.model_validate(basis),
552
+ human_change_version=int(
553
+ value.get('human_change_version') or 0
554
+ ),
555
+ usage_generation=int(value.get('usage_generation') or 1),
556
+ last_human_changed_at=native_datetime(
557
+ value.get('last_human_changed_at')
558
+ ),
559
+ negative_evidence_count=int(
560
+ value.get('negative_evidence_count') or 0
561
+ ),
562
+ requires_attention=value.get('requires_attention') is True,
563
+ last_feedback_at=native_datetime(
564
+ value.get('last_feedback_at')
565
+ ),
566
+ source_uris=sorted(set(value.get('source_uris') or [])),
567
+ stored_confirmation_basis_json=(
568
+ value.get('confirmation_basis_json') or '{}'
569
+ ),
570
+ stored_attributes_json=(
571
+ value.get('attributes_json') or '{}'
572
+ ),
573
+ )
574
+ except (KeyError, TypeError, ValueError) as error:
575
+ raise HTTPException(
576
+ status_code=409,
577
+ detail='candidate source preference is no longer eligible',
578
+ ) from error
579
+ snapshot_by_key[(snapshot.item_kind, snapshot.item_id)] = snapshot
580
+ snapshots = []
581
+ for reference in source_items:
582
+ snapshot = snapshot_by_key.get(
583
+ (reference.item_kind, reference.item_id)
584
+ )
585
+ if (
586
+ not snapshot
587
+ or snapshot.project_id != reference.project_id
588
+ or snapshot.preference_key != requested_preference_key
589
+ or snapshot.requires_attention
590
+ ):
591
+ raise HTTPException(
592
+ status_code=409,
593
+ detail='candidate source preference is no longer eligible',
594
+ )
595
+ snapshots.append(snapshot)
596
+ snapshots.sort(key=lambda item: item.item_id)
597
+ return snapshots
598
+
599
+
600
+ async def _eligible_target_scopes(
601
+ store,
602
+ space: dict,
603
+ snapshots: list[PersonalGlobalPreferenceSourceSnapshot],
604
+ ) -> list[PersonalGlobalPreferenceEligibleTargetScope]:
605
+ source_project_ids = sorted({item.project_id for item in snapshots})
606
+ rows, _, _ = await store.runtime.driver.execute_query(
607
+ _ELIGIBLE_PARENT_SCOPE_QUERY,
608
+ space_id=space['id'],
609
+ source_project_ids=source_project_ids,
610
+ source_project_count=len(source_project_ids),
611
+ routing_='r',
612
+ )
613
+ result = [
614
+ PersonalGlobalPreferenceEligibleTargetScope(
615
+ target_scope='parent_project',
616
+ target_project_id=row['target_project_id'],
617
+ max_distance=int(row['max_distance']),
618
+ )
619
+ for row in rows
620
+ if row.get('target_project_id') not in source_project_ids
621
+ ]
622
+ result.sort(key=lambda item: (
623
+ item.max_distance or 0,
624
+ item.target_project_id or '',
625
+ ))
626
+ result.append(PersonalGlobalPreferenceEligibleTargetScope(
627
+ target_scope='personal_global',
628
+ ))
629
+ return result
630
+
631
+
632
+ async def _decision_revision(store, space_id: str, candidate_id: str) -> int:
633
+ rows, _, _ = await store.runtime.driver.execute_query(
634
+ _DECISION_REVISION_QUERY,
635
+ space_id=space_id,
636
+ candidate_id=candidate_id,
637
+ routing_='r',
638
+ )
639
+ return int(rows[0]['decision_revision']) if rows else 0
640
+
641
+
642
+ async def _personal_space(store, actor, space_id: str, role: str) -> dict:
643
+ store._require_personal()
644
+ space = await store.authorize(actor, space_id, role)
645
+ if space['kind'] != 'personal':
646
+ raise HTTPException(
647
+ status_code=422,
648
+ detail='personal-global preference decisions are personal-only',
649
+ )
650
+ return space
651
+
652
+
653
+ def _candidate_id(item_ids: list[str]) -> str:
654
+ digest = _sha256('\n'.join(sorted(item_ids)))[:20]
655
+ return f'personal-global-{digest}'
656
+
657
+
658
+ def _candidate_version(
659
+ snapshots: list[PersonalGlobalPreferenceSourceSnapshot],
660
+ eligible_target_scopes: list[
661
+ PersonalGlobalPreferenceEligibleTargetScope
662
+ ],
663
+ ) -> str:
664
+ source_state = [{
665
+ 'item_id': item.item_id,
666
+ 'item_kind': item.item_kind,
667
+ 'project_id': item.project_id,
668
+ 'key': item.key,
669
+ 'preference_key': item.preference_key,
670
+ 'preference_qualifiers': item.preference_qualifiers,
671
+ 'title': item.title,
672
+ 'instruction': item.instruction,
673
+ 'profile_aspect': item.profile_aspect,
674
+ 'confirmation_status': item.confirmation_status,
675
+ 'confirmation_basis': item.confirmation_basis.model_dump(mode='json'),
676
+ 'stored_confirmation_basis_json': (
677
+ item.stored_confirmation_basis_json
678
+ ),
679
+ 'stored_attributes_json': item.stored_attributes_json,
680
+ 'human_change_version': item.human_change_version,
681
+ 'usage_generation': item.usage_generation,
682
+ 'last_human_changed_at': _json_datetime(item.last_human_changed_at),
683
+ 'negative_evidence_count': item.negative_evidence_count,
684
+ 'requires_attention': item.requires_attention,
685
+ 'last_feedback_at': _json_datetime(item.last_feedback_at),
686
+ 'source_uris': sorted(set(item.source_uris)),
687
+ } for item in sorted(snapshots, key=lambda value: value.item_id)]
688
+ candidate_state = {
689
+ 'eligible_target_scopes': [
690
+ item.model_dump(mode='json')
691
+ for item in eligible_target_scopes
692
+ ],
693
+ 'sources': source_state,
694
+ }
695
+ canonical = json.dumps(
696
+ candidate_state,
697
+ ensure_ascii=False,
698
+ sort_keys=True,
699
+ separators=(',', ':'),
700
+ )
701
+ return f'v1:{_sha256(canonical)[:24]}'
702
+
703
+
704
+ def _decision_payload_fingerprint(
705
+ candidate_id: str,
706
+ request: PersonalGlobalPreferenceDecisionIntent,
707
+ ) -> str:
708
+ payload = {
709
+ 'candidate_id': candidate_id,
710
+ **request.model_dump(
711
+ mode='json',
712
+ exclude={'approval_token'},
713
+ ),
714
+ }
715
+ return _sha256(json.dumps(
716
+ payload,
717
+ ensure_ascii=False,
718
+ sort_keys=True,
719
+ separators=(',', ':'),
720
+ ))
721
+
722
+
723
+ def _source_cas_row(
724
+ snapshot: PersonalGlobalPreferenceSourceSnapshot,
725
+ ) -> dict:
726
+ return {
727
+ 'item_id': snapshot.item_id,
728
+ 'item_kind': snapshot.item_kind,
729
+ 'project_id': snapshot.project_id,
730
+ 'key': snapshot.key,
731
+ 'preference_key': snapshot.preference_key,
732
+ 'title': snapshot.title,
733
+ 'instruction': snapshot.instruction,
734
+ 'profile_aspect': snapshot.profile_aspect,
735
+ 'confirmation_status': snapshot.confirmation_status,
736
+ 'confirmation_basis_json': (
737
+ snapshot.stored_confirmation_basis_json
738
+ ),
739
+ 'human_change_version': snapshot.human_change_version,
740
+ 'usage_generation': snapshot.usage_generation,
741
+ 'last_human_changed_at': snapshot.last_human_changed_at,
742
+ 'negative_evidence_count': snapshot.negative_evidence_count,
743
+ 'requires_attention': snapshot.requires_attention,
744
+ 'last_feedback_at': snapshot.last_feedback_at,
745
+ 'source_uris': snapshot.source_uris,
746
+ 'attributes_json': snapshot.stored_attributes_json,
747
+ }
748
+
749
+
750
+ def _decision_record(record) -> PersonalGlobalPreferenceDecisionRecord:
751
+ value = dict(record)
752
+ return PersonalGlobalPreferenceDecisionRecord(
753
+ decision_event_id=value['decision_event_id'],
754
+ candidate_id=value['candidate_id'],
755
+ candidate_version=value['candidate_version'],
756
+ decision_revision=int(value['decision_revision']),
757
+ decision=value['decision'],
758
+ target_scope=value.get('target_scope') or 'personal_global',
759
+ target_project_id=value.get('target_project_id'),
760
+ global_assertion_id=value.get('global_assertion_id'),
761
+ global_assertion_active=value.get('global_assertion_active') is True,
762
+ decision_sequence=int(
763
+ value.get('decision_sequence') or value['decision_revision']
764
+ ),
765
+ decided_at=native_datetime(value['decided_at']),
766
+ human_confirmation_reason=value['human_confirmation_reason'],
767
+ )
768
+
769
+
770
+ def _json_datetime(value: datetime | None) -> str | None:
771
+ if value is None:
772
+ return None
773
+ native = native_datetime(value)
774
+ if native.tzinfo is None:
775
+ native = native.replace(tzinfo=timezone.utc)
776
+ return native.astimezone(timezone.utc).isoformat().replace('+00:00', 'Z')
777
+
778
+
779
+ def _sha256(value: str) -> str:
780
+ return hashlib.sha256(value.encode('utf-8')).hexdigest()
781
+
782
+
783
+ _SOURCE_ENTITY_QUERY = '''
784
+ /* fuli:personal-global-source-entities */
785
+ UNWIND $sources AS source
786
+ MATCH (item:Entity {
787
+ group_id: $group_id,
788
+ uuid: source.item_id
789
+ })
790
+ WHERE item.fuli_invalid_at IS NULL
791
+ AND item.fuli_profile_aspect IS NOT NULL
792
+ AND item.fuli_preference_scope = 'project'
793
+ AND item.fuli_preference_project_id = source.project_id
794
+ AND coalesce(item.fuli_confirmation_status, 'pending')
795
+ IN ['confirmed', 'agent_confirmed']
796
+ OPTIONAL MATCH (episode:Episodic {group_id: $group_id})-[:MENTIONS]->(item)
797
+ WITH source, item,
798
+ [uri IN collect(DISTINCT episode.fuli_source_uri)
799
+ WHERE uri IS NOT NULL] AS source_uris
800
+ RETURN item.uuid AS item_id,
801
+ 'entity' AS item_kind,
802
+ item.fuli_preference_project_id AS project_id,
803
+ coalesce(item.fuli_key, item.uuid) AS key,
804
+ item.name AS title,
805
+ coalesce(item.summary, '') AS instruction,
806
+ item.fuli_profile_aspect AS profile_aspect,
807
+ item.fuli_confirmation_status AS confirmation_status,
808
+ item.fuli_confirmation_basis_json AS confirmation_basis_json,
809
+ coalesce(item.fuli_human_change_version, 0) AS human_change_version,
810
+ coalesce(item.fuli_usage_generation, 1) AS usage_generation,
811
+ item.fuli_last_human_changed_at AS last_human_changed_at,
812
+ coalesce(item.fuli_negative_evidence_count, 0)
813
+ AS negative_evidence_count,
814
+ coalesce(item.fuli_requires_attention, false) AS requires_attention,
815
+ item.fuli_last_feedback_at AS last_feedback_at,
816
+ item.fuli_attributes_json AS attributes_json,
817
+ source_uris
818
+ '''
819
+
820
+
821
+ _SOURCE_RELATIONSHIP_QUERY = '''
822
+ /* fuli:personal-global-source-relationships */
823
+ UNWIND $sources AS source
824
+ MATCH ()-[item:RELATES_TO {
825
+ group_id: $group_id,
826
+ uuid: source.item_id
827
+ }]->()
828
+ WHERE item.invalid_at IS NULL
829
+ AND item.fuli_profile_aspect IS NOT NULL
830
+ AND item.fuli_preference_scope = 'project'
831
+ AND item.fuli_preference_project_id = source.project_id
832
+ AND coalesce(item.fuli_confirmation_status, 'pending')
833
+ IN ['confirmed', 'agent_confirmed']
834
+ OPTIONAL MATCH (episode:Episodic {group_id: $group_id})
835
+ WHERE episode.uuid IN coalesce(item.episodes, [])
836
+ WITH source, item,
837
+ [uri IN collect(DISTINCT episode.fuli_source_uri)
838
+ WHERE uri IS NOT NULL] AS source_uris
839
+ RETURN item.uuid AS item_id,
840
+ 'relationship' AS item_kind,
841
+ item.fuli_preference_project_id AS project_id,
842
+ coalesce(item.fuli_key, item.uuid) AS key,
843
+ coalesce(item.name, 'Preference relationship') AS title,
844
+ coalesce(item.fact, '') AS instruction,
845
+ item.fuli_profile_aspect AS profile_aspect,
846
+ item.fuli_confirmation_status AS confirmation_status,
847
+ item.fuli_confirmation_basis_json AS confirmation_basis_json,
848
+ coalesce(item.fuli_human_change_version, 0) AS human_change_version,
849
+ coalesce(item.fuli_usage_generation, 1) AS usage_generation,
850
+ item.fuli_last_human_changed_at AS last_human_changed_at,
851
+ coalesce(item.fuli_negative_evidence_count, 0)
852
+ AS negative_evidence_count,
853
+ coalesce(item.fuli_requires_attention, false) AS requires_attention,
854
+ item.fuli_last_feedback_at AS last_feedback_at,
855
+ item.fuli_attributes_json AS attributes_json,
856
+ source_uris
857
+ '''
858
+
859
+
860
+ _ELIGIBLE_PARENT_SCOPE_QUERY = '''
861
+ /* fuli:personal-global-eligible-parent-scopes */
862
+ UNWIND $source_project_ids AS source_project_id
863
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
864
+ -[:CONTAINS_PROJECT]->
865
+ (source:FuliPersonalProject {project_id: source_project_id})
866
+ MATCH path=(source)-[:PERSONAL_PROJECT_RELATION*1..2]->
867
+ (target:FuliPersonalProject)
868
+ WHERE all(relation IN relationships(path)
869
+ WHERE relation.relation_type = 'PART_OF'
870
+ AND relation.status = 'active'
871
+ AND relation.confirmation_authority = 'human_review')
872
+ AND EXISTS {
873
+ MATCH (space)-[:CONTAINS_PROJECT]->(target)
874
+ }
875
+ AND NOT target.project_id IN $source_project_ids
876
+ WITH target, source_project_id, min(length(path)) AS distance
877
+ WITH target,
878
+ collect(DISTINCT source_project_id) AS covered_source_project_ids,
879
+ max(distance) AS max_distance
880
+ WHERE size(covered_source_project_ids) = $source_project_count
881
+ RETURN target.project_id AS target_project_id,
882
+ max_distance
883
+ ORDER BY max_distance ASC, target_project_id ASC
884
+ '''
885
+
886
+
887
+ _DECISION_REVISION_QUERY = '''
888
+ /* fuli:personal-global-decision-revision */
889
+ MATCH (decision:FuliPersonalGlobalPreferenceDecision {
890
+ space_id: $space_id,
891
+ candidate_id: $candidate_id
892
+ })
893
+ RETURN coalesce(decision.decision_revision, 0) AS decision_revision
894
+ '''
895
+
896
+
897
+ _CREATE_DECISION_PREVIEW_QUERY = '''
898
+ /* fuli:create-personal-global-decision-preview */
899
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
900
+ MERGE (decision:FuliPersonalGlobalPreferenceDecision {
901
+ space_id: $space_id,
902
+ candidate_id: $candidate_id
903
+ })
904
+ ON CREATE SET decision.decision_revision = 0,
905
+ decision.created_at = $created_at
906
+ WITH space, decision
907
+ WHERE decision.decision_revision = $decision_revision
908
+ CREATE (preview:FuliPersonalGlobalPreferenceDecisionPreview {
909
+ id: $preview_id,
910
+ space_id: $space_id,
911
+ candidate_id: $candidate_id,
912
+ candidate_version: $candidate_version,
913
+ decision_revision: $decision_revision,
914
+ target_scope: $target_scope,
915
+ target_project_id: $target_project_id,
916
+ token_hash: $token_hash,
917
+ payload_fingerprint: $payload_fingerprint,
918
+ issued_to_actor_id: $issued_to_actor_id,
919
+ issued_channel: $issued_channel,
920
+ created_at: $created_at,
921
+ expires_at: $expires_at,
922
+ used_at: null
923
+ })
924
+ RETURN preview.id AS preview_id
925
+ '''
926
+
927
+
928
+ _DECISION_EVENT_QUERY = '''
929
+ /* fuli:personal-global-decision-event */
930
+ MATCH (event:FuliPersonalGlobalPreferenceDecisionEvent {
931
+ space_id: $space_id,
932
+ id: $event_id
933
+ })
934
+ OPTIONAL MATCH (current:FuliPersonalGlobalPreferenceDecision {
935
+ space_id: $space_id,
936
+ candidate_id: event.candidate_id,
937
+ current_event_id: event.id
938
+ })
939
+ OPTIONAL MATCH (assertion:Entity {uuid: event.global_assertion_id})
940
+ RETURN event.id AS decision_event_id,
941
+ event.payload_fingerprint AS payload_fingerprint,
942
+ event.candidate_id AS candidate_id,
943
+ event.candidate_version AS candidate_version,
944
+ event.decision_revision AS decision_revision,
945
+ event.decision AS decision,
946
+ coalesce(event.target_scope, 'personal_global') AS target_scope,
947
+ event.target_project_id AS target_project_id,
948
+ event.global_assertion_id AS global_assertion_id,
949
+ current IS NOT NULL
950
+ AND event.global_assertion_id IS NOT NULL
951
+ AND assertion IS NOT NULL
952
+ AND assertion.fuli_invalid_at IS NULL AS global_assertion_active,
953
+ event.decision_revision AS decision_sequence,
954
+ event.decided_at AS decided_at,
955
+ event.human_confirmation_reason AS human_confirmation_reason
956
+ '''
957
+
958
+
959
+ _APPLY_DECISION_QUERY = '''
960
+ /* fuli:apply-personal-global-decision */
961
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
962
+ MATCH (current:FuliPersonalGlobalPreferenceDecision {
963
+ space_id: $space_id,
964
+ candidate_id: $candidate_id
965
+ })
966
+ SET current.cas_lock = coalesce(current.cas_lock, 0) + 1
967
+ WITH space, current
968
+ WHERE current.decision_revision = $decision_revision
969
+ MATCH (preview:FuliPersonalGlobalPreferenceDecisionPreview {
970
+ space_id: $space_id,
971
+ candidate_id: $candidate_id,
972
+ candidate_version: $candidate_version,
973
+ decision_revision: $decision_revision,
974
+ token_hash: $token_hash,
975
+ payload_fingerprint: $payload_fingerprint
976
+ })
977
+ WHERE preview.used_at IS NULL
978
+ AND preview.expires_at > $expires_after
979
+ AND preview.issued_to_actor_id = $actor_id
980
+ CALL {
981
+ WITH space
982
+ UNWIND [source IN $source_snapshots
983
+ WHERE source.item_kind = 'entity'] AS source
984
+ OPTIONAL MATCH (item:Entity {
985
+ group_id: $group_id,
986
+ uuid: source.item_id
987
+ })
988
+ OPTIONAL MATCH (episode:Episodic {group_id: $group_id})-[:MENTIONS]->(item)
989
+ WITH source, item,
990
+ [uri IN collect(DISTINCT episode.fuli_source_uri)
991
+ WHERE uri IS NOT NULL] AS current_source_uris
992
+ WHERE item.fuli_invalid_at IS NULL
993
+ AND item.fuli_preference_scope = 'project'
994
+ AND item.fuli_preference_project_id = source.project_id
995
+ AND coalesce(item.fuli_key, item.uuid) = source.key
996
+ AND item.name = source.title
997
+ AND coalesce(item.summary, '') = source.instruction
998
+ AND item.fuli_profile_aspect = source.profile_aspect
999
+ AND item.fuli_confirmation_status = source.confirmation_status
1000
+ AND coalesce(item.fuli_confirmation_basis_json, '{}') =
1001
+ source.confirmation_basis_json
1002
+ AND coalesce(item.fuli_human_change_version, 0) =
1003
+ source.human_change_version
1004
+ AND coalesce(item.fuli_usage_generation, 1) = source.usage_generation
1005
+ AND (
1006
+ item.fuli_last_human_changed_at = source.last_human_changed_at
1007
+ OR (
1008
+ item.fuli_last_human_changed_at IS NULL
1009
+ AND source.last_human_changed_at IS NULL
1010
+ )
1011
+ )
1012
+ AND coalesce(item.fuli_negative_evidence_count, 0) =
1013
+ source.negative_evidence_count
1014
+ AND coalesce(item.fuli_requires_attention, false) =
1015
+ source.requires_attention
1016
+ AND (
1017
+ item.fuli_last_feedback_at = source.last_feedback_at
1018
+ OR (
1019
+ item.fuli_last_feedback_at IS NULL
1020
+ AND source.last_feedback_at IS NULL
1021
+ )
1022
+ )
1023
+ AND coalesce(item.fuli_attributes_json, '{}') = source.attributes_json
1024
+ AND size(current_source_uris) = size(source.source_uris)
1025
+ AND all(uri IN current_source_uris WHERE uri IN source.source_uris)
1026
+ RETURN count(item) AS valid_entity_count
1027
+ }
1028
+ CALL {
1029
+ WITH space
1030
+ UNWIND [source IN $source_snapshots
1031
+ WHERE source.item_kind = 'relationship'] AS source
1032
+ OPTIONAL MATCH ()-[item:RELATES_TO {
1033
+ group_id: $group_id,
1034
+ uuid: source.item_id
1035
+ }]->()
1036
+ OPTIONAL MATCH (episode:Episodic {group_id: $group_id})
1037
+ WHERE episode.uuid IN coalesce(item.episodes, [])
1038
+ WITH source, item,
1039
+ [uri IN collect(DISTINCT episode.fuli_source_uri)
1040
+ WHERE uri IS NOT NULL] AS current_source_uris
1041
+ WHERE item.invalid_at IS NULL
1042
+ AND item.fuli_preference_scope = 'project'
1043
+ AND item.fuli_preference_project_id = source.project_id
1044
+ AND coalesce(item.fuli_key, item.uuid) = source.key
1045
+ AND coalesce(item.name, 'Preference relationship') = source.title
1046
+ AND coalesce(item.fact, '') = source.instruction
1047
+ AND item.fuli_profile_aspect = source.profile_aspect
1048
+ AND item.fuli_confirmation_status = source.confirmation_status
1049
+ AND coalesce(item.fuli_confirmation_basis_json, '{}') =
1050
+ source.confirmation_basis_json
1051
+ AND coalesce(item.fuli_human_change_version, 0) =
1052
+ source.human_change_version
1053
+ AND coalesce(item.fuli_usage_generation, 1) = source.usage_generation
1054
+ AND (
1055
+ item.fuli_last_human_changed_at = source.last_human_changed_at
1056
+ OR (
1057
+ item.fuli_last_human_changed_at IS NULL
1058
+ AND source.last_human_changed_at IS NULL
1059
+ )
1060
+ )
1061
+ AND coalesce(item.fuli_negative_evidence_count, 0) =
1062
+ source.negative_evidence_count
1063
+ AND coalesce(item.fuli_requires_attention, false) =
1064
+ source.requires_attention
1065
+ AND (
1066
+ item.fuli_last_feedback_at = source.last_feedback_at
1067
+ OR (
1068
+ item.fuli_last_feedback_at IS NULL
1069
+ AND source.last_feedback_at IS NULL
1070
+ )
1071
+ )
1072
+ AND coalesce(item.fuli_attributes_json, '{}') = source.attributes_json
1073
+ AND size(current_source_uris) = size(source.source_uris)
1074
+ AND all(uri IN current_source_uris WHERE uri IN source.source_uris)
1075
+ RETURN count(item) AS valid_relationship_count
1076
+ }
1077
+ CALL {
1078
+ WITH space
1079
+ UNWIND $source_project_ids AS source_project_id
1080
+ MATCH (space)-[:CONTAINS_PROJECT]->
1081
+ (source:FuliPersonalProject {project_id: source_project_id})
1082
+ MATCH path=(source)-[:PERSONAL_PROJECT_RELATION*1..2]->
1083
+ (target:FuliPersonalProject)
1084
+ WHERE all(relation IN relationships(path)
1085
+ WHERE relation.relation_type = 'PART_OF'
1086
+ AND relation.status = 'active'
1087
+ AND relation.confirmation_authority = 'human_review')
1088
+ AND EXISTS {
1089
+ MATCH (space)-[:CONTAINS_PROJECT]->(target)
1090
+ }
1091
+ AND NOT target.project_id IN $source_project_ids
1092
+ WITH target, source_project_id, min(length(path)) AS distance
1093
+ WITH target,
1094
+ collect(DISTINCT source_project_id) AS covered_source_project_ids,
1095
+ max(distance) AS max_distance
1096
+ WHERE size(covered_source_project_ids) = size($source_project_ids)
1097
+ WITH {
1098
+ target_project_id: target.project_id,
1099
+ max_distance: max_distance
1100
+ } AS option
1101
+ ORDER BY option.max_distance ASC, option.target_project_id ASC
1102
+ RETURN collect(option) AS current_parent_scopes
1103
+ }
1104
+ WITH space, preview, current, valid_entity_count,
1105
+ valid_relationship_count, current_parent_scopes
1106
+ WHERE valid_entity_count = size([
1107
+ source IN $source_snapshots WHERE source.item_kind = 'entity'
1108
+ ])
1109
+ AND valid_relationship_count = size([
1110
+ source IN $source_snapshots WHERE source.item_kind = 'relationship'
1111
+ ])
1112
+ AND current_parent_scopes = $expected_parent_scopes
1113
+ OPTIONAL MATCH (old_assertion:Entity {
1114
+ uuid: current.global_assertion_id,
1115
+ group_id: $group_id
1116
+ })
1117
+ SET preview.used_at = $changed_at
1118
+ FOREACH (_ IN CASE WHEN old_assertion IS NULL THEN [] ELSE [1] END |
1119
+ SET old_assertion.fuli_invalid_at = $changed_at,
1120
+ old_assertion.expired_at = $changed_at
1121
+ )
1122
+ SET current.candidate_version = $candidate_version,
1123
+ current.decision_revision = $decision_revision + 1,
1124
+ current.decision = $decision_status,
1125
+ current.target_scope = $target_scope,
1126
+ current.target_project_id = $target_project_id,
1127
+ current.global_assertion_id = $global_assertion_id,
1128
+ current.current_event_id = $event_id,
1129
+ current.updated_at = $changed_at
1130
+ CREATE (event:FuliPersonalGlobalPreferenceDecisionEvent {
1131
+ id: $event_id,
1132
+ space_id: $space_id,
1133
+ candidate_id: $candidate_id,
1134
+ candidate_version: $candidate_version,
1135
+ decision_revision: $decision_revision + 1,
1136
+ decision: $decision_status,
1137
+ target_scope: $target_scope,
1138
+ target_project_id: $target_project_id,
1139
+ payload_fingerprint: $payload_fingerprint,
1140
+ global_assertion_id: $global_assertion_id,
1141
+ decided_by_actor_id: $actor_id,
1142
+ decision_channel: 'independent_human_review_token',
1143
+ human_confirmation_reason: $human_confirmation_reason,
1144
+ source_items_json: $source_items_json,
1145
+ source_snapshots_json: $source_snapshots_json,
1146
+ decided_at: $confirmed_at,
1147
+ recorded_at: $changed_at
1148
+ })
1149
+ FOREACH (_ IN CASE WHEN $global_assertion_id IS NULL THEN [] ELSE [1] END |
1150
+ MERGE (assertion:Entity {uuid: $global_assertion_id})
1151
+ ON CREATE SET assertion.group_id = $group_id,
1152
+ assertion.name = $global_title,
1153
+ assertion.name_embedding = $embedding,
1154
+ assertion.summary = $global_instruction,
1155
+ assertion.created_at = $changed_at,
1156
+ assertion.fuli_key = $global_key,
1157
+ assertion.fuli_type = 'PersonalPreference',
1158
+ assertion.fuli_origin_quadrant = 'known_known',
1159
+ assertion.fuli_current_quadrant = 'known_known',
1160
+ assertion.fuli_epistemic_status = 'confirmed',
1161
+ assertion.fuli_confirmation_status = 'confirmed',
1162
+ assertion.fuli_confirmation_basis_json =
1163
+ $confirmation_basis_json,
1164
+ assertion.fuli_reasoning_summary =
1165
+ $human_confirmation_reason,
1166
+ assertion.fuli_profile_aspect = $profile_aspect,
1167
+ assertion.fuli_preference_scope = $preference_scope,
1168
+ assertion.fuli_preference_project_id =
1169
+ $preference_project_id,
1170
+ assertion.fuli_inheritance_mode = $inheritance_mode,
1171
+ assertion.fuli_inherited_project_ids = [],
1172
+ assertion.fuli_attributes_json = $attributes_json,
1173
+ assertion.fuli_utility_score = 0.0,
1174
+ assertion.fuli_confidence_score = 1.0,
1175
+ assertion.fuli_qualified_use_count = 0,
1176
+ assertion.fuli_distinct_task_count = 0,
1177
+ assertion.fuli_usage_generation = 1,
1178
+ assertion.fuli_invalid_at = null
1179
+ MERGE (episode:Episodic {uuid: $episode_id})
1180
+ ON CREATE SET episode.group_id = $group_id,
1181
+ episode.name = 'Personal-global preference human review',
1182
+ episode.source = 'json',
1183
+ episode.source_description = $human_confirmation_reason,
1184
+ episode.content = '',
1185
+ episode.valid_at = $confirmed_at,
1186
+ episode.created_at = $changed_at,
1187
+ episode.entity_edges = [],
1188
+ episode.fuli_session_id = $session_id,
1189
+ episode.fuli_source_kind = 'human_scope_decision',
1190
+ episode.fuli_source_uri = $source_uri,
1191
+ episode.fuli_summary = $global_instruction,
1192
+ episode.fuli_sensitivity = 'private',
1193
+ episode.fuli_personal_project_id =
1194
+ $preference_project_id,
1195
+ episode.fuli_idempotency_key = $event_id
1196
+ MERGE (episode)-[mention:MENTIONS]->(assertion)
1197
+ ON CREATE SET mention.uuid = $event_id + ':mention',
1198
+ mention.group_id = $group_id,
1199
+ mention.created_at = $changed_at
1200
+ )
1201
+ RETURN event.id AS decision_event_id,
1202
+ event.candidate_id AS candidate_id,
1203
+ event.candidate_version AS candidate_version,
1204
+ event.decision_revision AS decision_revision,
1205
+ event.decision AS decision,
1206
+ event.target_scope AS target_scope,
1207
+ event.target_project_id AS target_project_id,
1208
+ event.global_assertion_id AS global_assertion_id,
1209
+ event.global_assertion_id IS NOT NULL AS global_assertion_active,
1210
+ event.decision_revision AS decision_sequence,
1211
+ event.decided_at AS decided_at,
1212
+ event.human_confirmation_reason AS human_confirmation_reason
1213
+ '''
1214
+
1215
+
1216
+ _DECISION_STATUS_QUERY = '''
1217
+ /* fuli:personal-global-decision-status */
1218
+ UNWIND $candidates AS requested
1219
+ OPTIONAL MATCH (current:FuliPersonalGlobalPreferenceDecision {
1220
+ space_id: $space_id,
1221
+ candidate_id: requested.candidate_id
1222
+ })
1223
+ OPTIONAL MATCH (event:FuliPersonalGlobalPreferenceDecisionEvent {
1224
+ space_id: $space_id,
1225
+ id: current.current_event_id
1226
+ })
1227
+ OPTIONAL MATCH (assertion:Entity {uuid: current.global_assertion_id})
1228
+ RETURN event.id AS decision_event_id,
1229
+ requested.candidate_id AS candidate_id,
1230
+ requested.candidate_version AS requested_candidate_version,
1231
+ current.candidate_version AS current_candidate_version,
1232
+ requested.candidate_version AS candidate_version,
1233
+ coalesce(current.decision_revision, 0) AS decision_revision,
1234
+ current.decision AS decision,
1235
+ coalesce(event.target_scope, current.target_scope,
1236
+ 'personal_global') AS target_scope,
1237
+ coalesce(event.target_project_id,
1238
+ current.target_project_id) AS target_project_id,
1239
+ current.global_assertion_id AS global_assertion_id,
1240
+ current.decision = 'approved'
1241
+ AND assertion IS NOT NULL
1242
+ AND assertion.fuli_invalid_at IS NULL AS global_assertion_active,
1243
+ current.decision_revision AS decision_sequence,
1244
+ event.decided_at AS decided_at,
1245
+ event.human_confirmation_reason AS human_confirmation_reason
1246
+ ORDER BY current.updated_at DESC
1247
+ '''