fuli-context 0.7.3 → 0.7.4-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) 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-CCOGfZzE.css → index-X2j29j8i.css} +1 -1
  29. package/dist/web/assets/index-nP5p5OaY.js +2 -0
  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 +21 -21
  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/task-knowledge-recall.js +66 -15
  77. package/src/graphiti/user-taste-skill.js +241 -0
  78. package/src/graphiti/workflow-candidate-mapping.js +87 -0
  79. package/src/graphiti/workflow-observation.js +82 -0
  80. package/src/graphiti/writing-taste-profile-workflow.js +25 -0
  81. package/src/graphiti/writing-taste-profile.js +490 -0
  82. package/src/http/graph-api-router.js +15 -2
  83. package/src/mcp/context-resources.js +158 -0
  84. package/src/mcp/create-mcp-server.js +39 -5
  85. package/src/mcp/instructions.js +5 -7
  86. package/src/mcp/session-id.js +60 -0
  87. package/src/mcp/task-context-registry.js +15 -3
  88. package/src/mcp/tool-annotations.js +8 -1
  89. package/src/mcp/tool-result.js +28 -4
  90. package/src/server.js +0 -1
  91. package/src/setup/claude-code-config.js +17 -6
  92. package/src/setup/graph-runtime.js +20 -2
  93. package/src/system/runtime-settings.js +121 -2
  94. package/src/system/system-service.js +5 -1
  95. package/dist/web/assets/KnowledgeInspector-DEW5lA1m.js +0 -1
  96. package/dist/web/assets/KnowledgeOrganizerPage-BkM568Ld.js +0 -1
  97. package/dist/web/assets/KnowledgePage-BAT4aZIm.js +0 -1
  98. package/dist/web/assets/KnowledgeWorkspace-DyGtBz--.js +0 -4
  99. package/dist/web/assets/PersonalProfilePage-C42mwKN8.js +0 -1
  100. package/dist/web/assets/PersonalProfilePage-CdmEzyvZ.css +0 -1
  101. package/dist/web/assets/PersonalProjectsPage-BwaXR62K.js +0 -1
  102. package/dist/web/assets/PublicProjectsPage-egKBulOq.js +0 -1
  103. package/dist/web/assets/SettingsPage-C6ouU0sR.css +0 -1
  104. package/dist/web/assets/SettingsPage-oLE8mNs5.js +0 -1
  105. package/dist/web/assets/index-BzhNfzU_.js +0 -2
  106. package/dist/web/assets/paths-DwHeiGMT.js +0 -1
  107. package/dist/web/assets/useMinimumLoadingDisplay-HdI7XAmu.js +0 -1
  108. package/dist/web/assets/vue-router-Drfl1F8i.js +0 -1
@@ -1,4 +1,5 @@
1
1
  from collections import defaultdict
2
+ from math import isfinite
2
3
 
3
4
  from fastapi import HTTPException
4
5
 
@@ -33,12 +34,23 @@ async def read_collaboration_context(
33
34
  space,
34
35
  personal_project_id,
35
36
  )
37
+ project_scopes = await _project_scopes(
38
+ store,
39
+ space,
40
+ personal_project_id,
41
+ )
42
+ inherited_project_ids = [
43
+ project_id
44
+ for project_id, scope in project_scopes.items()
45
+ if scope['scope_distance'] > 0
46
+ ]
36
47
 
37
48
  candidate_limit = min(max(limit * 3, 100), store.settings.graph_limit)
38
49
  node_records, _, _ = await store.runtime.driver.execute_query(
39
50
  _node_query(),
40
51
  group_id=space['group_id'],
41
52
  project_id=personal_project_id,
53
+ inherited_project_ids=inherited_project_ids,
42
54
  limit=candidate_limit + 1,
43
55
  routing_='r',
44
56
  )
@@ -46,6 +58,7 @@ async def read_collaboration_context(
46
58
  _edge_query(),
47
59
  group_id=space['group_id'],
48
60
  project_id=personal_project_id,
61
+ inherited_project_ids=inherited_project_ids,
49
62
  limit=candidate_limit + 1,
50
63
  routing_='r',
51
64
  )
@@ -56,12 +69,12 @@ async def read_collaboration_context(
56
69
  nodes = [
57
70
  item
58
71
  for record in node_records[:candidate_limit]
59
- if (item := _node_item(record)) is not None
72
+ if (item := _node_item(record, project_scopes)) is not None
60
73
  ]
61
74
  edge_pairs = [
62
75
  (record, item)
63
76
  for record in edge_records[:candidate_limit]
64
- if (item := _edge_item(record)) is not None
77
+ if (item := _edge_item(record, project_scopes)) is not None
65
78
  ]
66
79
  edges = [item for _, item in edge_pairs]
67
80
  edge_endpoint_ids = {
@@ -84,19 +97,35 @@ async def read_collaboration_context(
84
97
  project_preferences = [
85
98
  item for item in items if item.preference_scope == 'project'
86
99
  ]
87
- global_effective, global_conflicts = _conflict_free(global_preferences)
88
- project_effective, project_conflicts = _conflict_free(project_preferences)
89
- project_keys = {item.preference_key for item in project_preferences}
90
- overridden_global_ids = [
100
+ (
101
+ authoritative_global,
102
+ authoritative_project,
103
+ cross_scope_lower_authority_ids,
104
+ ) = _highest_authority_by_key(global_preferences, project_preferences)
105
+ (
106
+ project_effective,
107
+ project_conflicts,
108
+ overridden_inherited_ids,
109
+ overridden_lower_authority_ids,
110
+ ) = _scope_conflict_free(authoritative_project)
111
+ overridden_lower_authority_ids = sorted({
112
+ *cross_scope_lower_authority_ids,
113
+ *overridden_lower_authority_ids,
114
+ })
115
+ # Any authoritative project-layer claim blocks global fallback for that
116
+ # semantic key. A same-layer project conflict must remain deferred instead
117
+ # of silently reviving a global value as effective.
118
+ project_keys = {item.preference_key for item in authoritative_project}
119
+ overridden_global_ids = sorted([
91
120
  item.id
92
- for item in global_effective
121
+ for item in authoritative_global
93
122
  if item.preference_key in project_keys
94
- ]
95
- effective_preferences = [
96
- item
97
- for item in global_effective
123
+ ])
124
+ global_effective, global_conflicts = _conflict_free([
125
+ item for item in authoritative_global
98
126
  if item.preference_key not in project_keys
99
- ] + project_effective
127
+ ])
128
+ effective_preferences = [*global_effective, *project_effective]
100
129
 
101
130
  return CollaborationContextResult(
102
131
  personal_space_id=space_id,
@@ -106,10 +135,67 @@ async def read_collaboration_context(
106
135
  effective_preferences=effective_preferences,
107
136
  conflicts=[*global_conflicts, *project_conflicts],
108
137
  overridden_global_ids=overridden_global_ids,
138
+ overridden_inherited_ids=overridden_inherited_ids,
139
+ overridden_lower_authority_ids=overridden_lower_authority_ids,
109
140
  truncated=truncated,
110
141
  )
111
142
 
112
143
 
144
+ async def _project_scopes(store, space, personal_project_id):
145
+ if not personal_project_id:
146
+ return {}
147
+ scopes = {
148
+ personal_project_id: {
149
+ 'scope_distance': 0,
150
+ 'scope_path': [personal_project_id],
151
+ },
152
+ }
153
+ records, _, _ = await store.runtime.driver.execute_query(
154
+ '''
155
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
156
+ [:CONTAINS_PROJECT]->
157
+ (active:FuliPersonalProject {project_id: $active_project_id})
158
+ MATCH path=(active)-[:PERSONAL_PROJECT_RELATION*1..2]->
159
+ (source:FuliPersonalProject)
160
+ WHERE all(
161
+ relation IN relationships(path)
162
+ WHERE relation.relation_type IN ['PART_OF', 'USES_KNOWLEDGE_FROM']
163
+ AND relation.status = 'active'
164
+ AND relation.confirmation_authority = 'human_review'
165
+ )
166
+ AND EXISTS {
167
+ MATCH (space)-[:CONTAINS_PROJECT]->(source)
168
+ }
169
+ RETURN source.project_id AS project_id,
170
+ [node IN nodes(path) | node.project_id] AS scope_path,
171
+ length(path) AS scope_distance
172
+ ORDER BY scope_distance ASC, project_id ASC
173
+ LIMIT $inheritance_limit
174
+ ''',
175
+ space_id=space['id'],
176
+ active_project_id=personal_project_id,
177
+ inheritance_limit=min(
178
+ int(getattr(getattr(store, 'settings', None), 'graph_limit', 500)),
179
+ 64,
180
+ ),
181
+ routing_='r',
182
+ )
183
+ for record in records:
184
+ project_id = record['project_id']
185
+ distance = int(record['scope_distance'])
186
+ path = list(record.get('scope_path') or [])
187
+ if len(path) != distance + 1 or len(path) != len(set(path)):
188
+ continue
189
+ existing = scopes.get(project_id)
190
+ if existing is not None and existing['scope_distance'] <= distance:
191
+ continue
192
+ scopes[project_id] = {
193
+ 'scope_distance': distance,
194
+ 'scope_path': path,
195
+ }
196
+ return scopes
197
+
198
+
113
199
  def _node_query() -> str:
114
200
  return '''
115
201
  MATCH (node:Entity {group_id: $group_id})
@@ -120,7 +206,19 @@ def _node_query() -> str:
120
206
  coalesce(node.fuli_preference_scope, 'global') = 'global'
121
207
  OR (
122
208
  node.fuli_preference_scope = 'project'
123
- AND node.fuli_preference_project_id = $project_id
209
+ AND (
210
+ node.fuli_preference_project_id = $project_id
211
+ OR (
212
+ node.fuli_preference_project_id IN $inherited_project_ids
213
+ AND (
214
+ node.fuli_inheritance_mode = 'descendants'
215
+ OR (
216
+ node.fuli_inheritance_mode = 'selected_projects'
217
+ AND $project_id IN coalesce(node.fuli_inherited_project_ids, [])
218
+ )
219
+ )
220
+ )
221
+ )
124
222
  )
125
223
  )
126
224
  RETURN node.uuid AS id,
@@ -132,6 +230,9 @@ def _node_query() -> str:
132
230
  node.fuli_preference_project_id AS preference_project_id,
133
231
  node.fuli_attributes_json AS attributes_json,
134
232
  node.fuli_confirmation_basis_json AS confirmation_basis_json,
233
+ node.fuli_reasoning_summary AS reasoning_summary,
234
+ coalesce(node.fuli_inheritance_mode, 'local_only') AS inheritance_mode,
235
+ coalesce(node.fuli_inherited_project_ids, []) AS inherited_project_ids,
135
236
  node.fuli_confirmation_status AS confirmation_status,
136
237
  node.created_at AS created_at
137
238
  ORDER BY node.created_at DESC
@@ -150,7 +251,19 @@ def _edge_query() -> str:
150
251
  coalesce(edge.fuli_preference_scope, 'global') = 'global'
151
252
  OR (
152
253
  edge.fuli_preference_scope = 'project'
153
- AND edge.fuli_preference_project_id = $project_id
254
+ AND (
255
+ edge.fuli_preference_project_id = $project_id
256
+ OR (
257
+ edge.fuli_preference_project_id IN $inherited_project_ids
258
+ AND (
259
+ edge.fuli_inheritance_mode = 'descendants'
260
+ OR (
261
+ edge.fuli_inheritance_mode = 'selected_projects'
262
+ AND $project_id IN coalesce(edge.fuli_inherited_project_ids, [])
263
+ )
264
+ )
265
+ )
266
+ )
154
267
  )
155
268
  )
156
269
  RETURN edge.uuid AS id,
@@ -165,6 +278,9 @@ def _edge_query() -> str:
165
278
  edge.fuli_preference_project_id AS preference_project_id,
166
279
  edge.fuli_attributes_json AS attributes_json,
167
280
  edge.fuli_confirmation_basis_json AS confirmation_basis_json,
281
+ edge.fuli_reasoning_summary AS reasoning_summary,
282
+ coalesce(edge.fuli_inheritance_mode, 'local_only') AS inheritance_mode,
283
+ coalesce(edge.fuli_inherited_project_ids, []) AS inherited_project_ids,
168
284
  edge.fuli_confirmation_status AS confirmation_status,
169
285
  edge.created_at AS created_at
170
286
  ORDER BY edge.created_at DESC
@@ -172,16 +288,17 @@ def _edge_query() -> str:
172
288
  '''
173
289
 
174
290
 
175
- def _node_item(record) -> CollaborationPreferenceItem | None:
291
+ def _node_item(record, project_scopes) -> CollaborationPreferenceItem | None:
176
292
  return _preference_item(
177
293
  record,
178
294
  item_kind='entity',
179
295
  title=record.get('title') or record.get('key') or record.get('id'),
180
296
  instruction=record.get('instruction') or record.get('title') or '',
297
+ project_scopes=project_scopes,
181
298
  )
182
299
 
183
300
 
184
- def _edge_item(record) -> CollaborationPreferenceItem | None:
301
+ def _edge_item(record, project_scopes) -> CollaborationPreferenceItem | None:
185
302
  source = record.get('source_name') or '偏好'
186
303
  target = record.get('target_name') or '适用场景'
187
304
  return _preference_item(
@@ -189,6 +306,7 @@ def _edge_item(record) -> CollaborationPreferenceItem | None:
189
306
  item_kind='relationship',
190
307
  title=f'{source} → {target}',
191
308
  instruction=record.get('instruction') or '',
309
+ project_scopes=project_scopes,
192
310
  )
193
311
 
194
312
 
@@ -198,6 +316,7 @@ def _preference_item(
198
316
  item_kind: str,
199
317
  title: str,
200
318
  instruction: str,
319
+ project_scopes: dict[str, dict],
201
320
  ) -> CollaborationPreferenceItem | None:
202
321
  basis_data = json_object(record.get('confirmation_basis_json'))
203
322
  if not basis_data:
@@ -217,6 +336,35 @@ def _preference_item(
217
336
  or attributes.get('preference_key')
218
337
  or key
219
338
  )
339
+ project_id = record.get('preference_project_id')
340
+ scope = project_scopes.get(project_id, {
341
+ 'scope_distance': 0,
342
+ 'scope_path': [],
343
+ })
344
+ scope_distance = int(scope['scope_distance'])
345
+ inheritance_mode = record.get('inheritance_mode') or 'local_only'
346
+ inherited_project_ids = list(record.get('inherited_project_ids') or [])
347
+ active_project_id = scope['scope_path'][0] if scope['scope_path'] else None
348
+ if scope_distance > 0 and (
349
+ inheritance_mode == 'local_only'
350
+ or (
351
+ inheritance_mode == 'selected_projects'
352
+ and active_project_id not in inherited_project_ids
353
+ )
354
+ ):
355
+ return None
356
+ weight = attributes.get('weight')
357
+ if (
358
+ not isinstance(weight, (int, float))
359
+ or isinstance(weight, bool)
360
+ or not isfinite(weight)
361
+ or weight < 0
362
+ or weight > 1
363
+ ):
364
+ weight = None
365
+ reason = attributes.get('reason')
366
+ if not isinstance(reason, str) or not reason.strip():
367
+ reason = record.get('reasoning_summary') or basis.existence_reason
220
368
  return CollaborationPreferenceItem(
221
369
  id=str(record['id']),
222
370
  item_kind=item_kind,
@@ -226,11 +374,20 @@ def _preference_item(
226
374
  instruction=str(instruction),
227
375
  profile_aspect=record['profile_aspect'],
228
376
  preference_scope=record['preference_scope'],
229
- preference_project_id=record.get('preference_project_id'),
377
+ preference_project_id=project_id,
230
378
  attributes=attributes,
379
+ weight=weight,
380
+ reason=reason,
381
+ confirmation_basis=basis,
382
+ reasoning_summary=record.get('reasoning_summary'),
383
+ inheritance_mode=inheritance_mode,
384
+ inherited_project_ids=inherited_project_ids,
231
385
  confirmation_status=confirmation_status,
232
386
  confirmed_at=basis.confirmed_at,
233
387
  created_at=_native_datetime(record.get('created_at')),
388
+ scope_distance=scope_distance,
389
+ inherited_from_project_id=project_id if scope_distance > 0 else None,
390
+ scope_path=list(scope['scope_path']),
234
391
  )
235
392
 
236
393
 
@@ -274,6 +431,84 @@ def _conflict_free(
274
431
  return effective, conflicts
275
432
 
276
433
 
434
+ def _scope_conflict_free(
435
+ items: list[CollaborationPreferenceItem],
436
+ ) -> tuple[
437
+ list[CollaborationPreferenceItem],
438
+ list[CollaborationPreferenceConflict],
439
+ list[str],
440
+ list[str],
441
+ ]:
442
+ grouped: dict[str, list[CollaborationPreferenceItem]] = defaultdict(list)
443
+ for item in items:
444
+ grouped[item.preference_key].append(item)
445
+
446
+ effective = []
447
+ conflicts = []
448
+ overridden = []
449
+ lower_authority = []
450
+ for candidates in grouped.values():
451
+ strongest_status = (
452
+ 'confirmed'
453
+ if any(item.confirmation_status == 'confirmed' for item in candidates)
454
+ else 'agent_confirmed'
455
+ )
456
+ authoritative = [
457
+ item for item in candidates
458
+ if item.confirmation_status == strongest_status
459
+ ]
460
+ lower_authority.extend(
461
+ item.id for item in candidates
462
+ if item.confirmation_status != strongest_status
463
+ )
464
+ nearest_distance = min(item.scope_distance for item in authoritative)
465
+ nearest = [
466
+ item for item in authoritative
467
+ if item.scope_distance == nearest_distance
468
+ ]
469
+ overridden.extend(
470
+ item.id for item in authoritative
471
+ if item.scope_distance > nearest_distance
472
+ )
473
+ group_effective, group_conflicts = _conflict_free(nearest)
474
+ effective.extend(group_effective)
475
+ conflicts.extend(group_conflicts)
476
+ effective.sort(key=_sort_key)
477
+ conflicts.sort(key=lambda conflict: conflict.preference_key)
478
+ return effective, conflicts, sorted(overridden), sorted(lower_authority)
479
+
480
+
481
+ def _highest_authority_by_key(
482
+ global_items: list[CollaborationPreferenceItem],
483
+ project_items: list[CollaborationPreferenceItem],
484
+ ) -> tuple[
485
+ list[CollaborationPreferenceItem],
486
+ list[CollaborationPreferenceItem],
487
+ list[str],
488
+ ]:
489
+ grouped: dict[str, list[CollaborationPreferenceItem]] = defaultdict(list)
490
+ for item in [*global_items, *project_items]:
491
+ grouped[item.preference_key].append(item)
492
+ selected_ids = set()
493
+ lower_authority_ids = []
494
+ for candidates in grouped.values():
495
+ strongest_status = (
496
+ 'confirmed'
497
+ if any(item.confirmation_status == 'confirmed' for item in candidates)
498
+ else 'agent_confirmed'
499
+ )
500
+ for item in candidates:
501
+ if item.confirmation_status == strongest_status:
502
+ selected_ids.add(item.id)
503
+ else:
504
+ lower_authority_ids.append(item.id)
505
+ return (
506
+ [item for item in global_items if item.id in selected_ids],
507
+ [item for item in project_items if item.id in selected_ids],
508
+ sorted(lower_authority_ids),
509
+ )
510
+
511
+
277
512
  def _sort_key(item: CollaborationPreferenceItem) -> tuple:
278
513
  created = -item.created_at.timestamp() if item.created_at else 0
279
514
  return (
@@ -281,6 +516,7 @@ def _sort_key(item: CollaborationPreferenceItem) -> tuple:
281
516
  0 if item.confirmation_status == 'confirmed' else 1,
282
517
  item.profile_aspect,
283
518
  item.preference_key,
519
+ -(item.weight or 0),
284
520
  created,
285
521
  item.id,
286
522
  )
@@ -224,8 +224,10 @@ async def _direct_child_project_ids(store, space_id, parent_project_id):
224
224
  '''
225
225
  MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
226
226
  [:CONTAINS_PROJECT]->(child:FuliPersonalProject)-
227
- [:PERSONAL_PROJECT_RELATION {relation_type: 'PART_OF'}]->
227
+ [relation:PERSONAL_PROJECT_RELATION {relation_type: 'PART_OF'}]->
228
228
  (parent:FuliPersonalProject {project_id: $parent_project_id})
229
+ WHERE relation.status = 'active'
230
+ AND relation.confirmation_authority = 'human_review'
229
231
  RETURN child.project_id AS child_project_id
230
232
  ORDER BY child.project_id
231
233
  ''',
@@ -315,8 +317,10 @@ def _promotion_query(item_kind):
315
317
  UNWIND $source_project_ids AS source_project_id
316
318
  MATCH (space)-[:CONTAINS_PROJECT]->
317
319
  (child:FuliPersonalProject {{project_id: source_project_id}})-
318
- [:PERSONAL_PROJECT_RELATION {{relation_type: 'PART_OF'}}]->
320
+ [relation:PERSONAL_PROJECT_RELATION {{relation_type: 'PART_OF'}}]->
319
321
  (parent)
322
+ WHERE relation.status = 'active'
323
+ AND relation.confirmation_authority = 'human_review'
320
324
  WITH space, parent, collect(DISTINCT child.project_id) AS direct_children
321
325
  WHERE size(direct_children) = size($source_project_ids)
322
326
  {canonical_match}
@@ -12,6 +12,11 @@ class Settings(BaseSettings):
12
12
  provider_id: str = 'local-personal'
13
13
  provider_name: str = 'Fuli Personal Provider'
14
14
  bootstrap_token: str = Field(min_length=24)
15
+ human_review_token: str | None = Field(default=None, min_length=32)
16
+ workflow_observation_token: str | None = Field(
17
+ default=None,
18
+ min_length=32,
19
+ )
15
20
  neo4j_uri: str = 'bolt://neo4j:7687'
16
21
  neo4j_user: str = 'neo4j'
17
22
  neo4j_password: str = Field(min_length=8)
@@ -32,6 +32,9 @@ class GraphNode(StrictModel):
32
32
  type: str
33
33
  group_id: str
34
34
  summary: str
35
+ key: str | None = None
36
+ preference_key: str | None = None
37
+ preference_qualifiers: dict[str, Any] = Field(default_factory=dict)
35
38
  origin_quadrant: EpistemicQuadrant = 'known_known'
36
39
  current_quadrant: EpistemicQuadrant = 'known_known'
37
40
  epistemic_status: EpistemicStatus = 'confirmed'
@@ -82,6 +85,9 @@ class GraphEdge(StrictModel):
82
85
  target_name: str | None = None
83
86
  type: str
84
87
  fact: str
88
+ key: str | None = None
89
+ preference_key: str | None = None
90
+ preference_qualifiers: dict[str, Any] = Field(default_factory=dict)
85
91
  origin_quadrant: EpistemicQuadrant = 'known_known'
86
92
  current_quadrant: EpistemicQuadrant = 'known_known'
87
93
  epistemic_status: EpistemicStatus = 'confirmed'
@@ -2,6 +2,7 @@ from collections.abc import Iterable
2
2
 
3
3
  from .graph_models import GraphEdge, GraphNode
4
4
  from .models import ProjectProfile
5
+ from .provider_values import native_datetime
5
6
 
6
7
 
7
8
  def management_projection(
@@ -58,6 +59,7 @@ def management_projection(
58
59
  target=target_id,
59
60
  type=relation['relation_type'],
60
61
  fact=_relation_fact(relation),
62
+ attributes=_relation_attributes(relation),
61
63
  valid_at=None,
62
64
  invalid_at=None,
63
65
  ))
@@ -118,6 +120,7 @@ def personal_project_projection(
118
120
  target=related_id if current_is_source else root_id,
119
121
  type=relation['relation_type'],
120
122
  fact=_relation_fact(relation),
123
+ attributes=_relation_attributes(relation),
121
124
  valid_at=None,
122
125
  invalid_at=None,
123
126
  ))
@@ -183,6 +186,17 @@ def personal_project_projection(
183
186
  return nodes, edges
184
187
 
185
188
 
189
+ def _relation_attributes(relation: dict) -> dict:
190
+ return {
191
+ 'status': relation.get('status') or 'pending',
192
+ 'confirmationAuthority': relation.get('confirmation_authority'),
193
+ 'decisionRevision': int(relation.get('decision_revision') or 0),
194
+ 'reviewReason': relation.get('review_reason'),
195
+ 'reviewedBy': relation.get('reviewed_by'),
196
+ 'reviewedAt': native_datetime(relation.get('reviewed_at')),
197
+ }
198
+
199
+
186
200
  def coalesce_personal_project_identity(
187
201
  project: dict,
188
202
  management_nodes: list[GraphNode],
@@ -12,7 +12,11 @@ from .models import (
12
12
  from .knowledge_audit import read_knowledge_audits
13
13
  from .knowledge_records import assignment_record, revision_record
14
14
  from .personal_project_access import authorize_personal_project
15
- from .provider_values import json_object, native_datetime as _native_datetime
15
+ from .provider_values import (
16
+ json_object,
17
+ native_datetime as _native_datetime,
18
+ preference_qualifiers,
19
+ )
16
20
  from .project_knowledge import (
17
21
  read_knowledge_conflicts,
18
22
  read_personal_project_relations,
@@ -239,6 +243,7 @@ def _node_query(project_scoped: bool, paginated: bool = False) -> str:
239
243
  node.fuli_last_feedback_at AS last_feedback_at,
240
244
  coalesce(node.fuli_usage_generation, 1) AS usage_generation,
241
245
  coalesce(node.fuli_attributes_json, '{}') AS attributes_json,
246
+ coalesce(node.fuli_key, node.uuid) AS key,
242
247
  [episode IN episodes WHERE episode IS NOT NULL | episode.uuid] AS episodes,
243
248
  node.created_at AS created_at,
244
249
  node.fuli_invalid_at AS invalid_at,
@@ -358,6 +363,7 @@ def _edge_query(project_scoped: bool, paginated: bool = False) -> str:
358
363
  edge.created_at AS created_at,
359
364
  edge.fuli_confidence AS confidence,
360
365
  coalesce(edge.fuli_attributes_json, '{}') AS attributes_json,
366
+ coalesce(edge.fuli_key, edge.uuid) AS key,
361
367
  coalesce(edge.episodes, []) AS episodes
362
368
  ORDER BY created_at DESC, id DESC
363
369
  ''' + pagination
@@ -467,12 +473,21 @@ def _graph_node(
467
473
  status, basis, confidence, confirmation_explicit = _confirmation_projection(
468
474
  record
469
475
  )
476
+ attributes = json_object(record.get('attributes_json'))
470
477
  return GraphNode(
471
478
  id=record['id'],
472
479
  name=record['name'],
473
480
  type=record['type'],
474
481
  group_id=record['group_id'],
475
482
  summary=record['summary'],
483
+ key=record.get('key') or record['id'],
484
+ preference_key=(
485
+ attributes.get('preferenceKey')
486
+ or attributes.get('preference_key')
487
+ or record.get('key')
488
+ or record['id']
489
+ ),
490
+ preference_qualifiers=preference_qualifiers(attributes),
476
491
  origin_quadrant=record.get('origin_quadrant') or 'known_known',
477
492
  current_quadrant=record.get('current_quadrant') or 'known_known',
478
493
  epistemic_status=record.get('epistemic_status') or 'confirmed',
@@ -510,7 +525,7 @@ def _graph_node(
510
525
  last_feedback_kind=record.get('last_feedback_kind'),
511
526
  last_feedback_at=_native_datetime(record.get('last_feedback_at')),
512
527
  usage_generation=int(record.get('usage_generation') or 1),
513
- attributes=json_object(record.get('attributes_json')),
528
+ attributes=attributes,
514
529
  evidence=[evidence[item] for item in episode_ids if item in evidence],
515
530
  created_at=_native_datetime(record.get('created_at')),
516
531
  invalid_at=_native_datetime(record.get('invalid_at')),
@@ -537,6 +552,7 @@ def _graph_edge(
537
552
  status, basis, confidence, confirmation_explicit = _confirmation_projection(
538
553
  record
539
554
  )
555
+ attributes = json_object(record.get('attributes_json'))
540
556
  return GraphEdge(
541
557
  id=record['id'],
542
558
  source=record['source'],
@@ -545,6 +561,14 @@ def _graph_edge(
545
561
  target_name=record.get('target_name'),
546
562
  type=record['type'],
547
563
  fact=record['fact'],
564
+ key=record.get('key') or record['id'],
565
+ preference_key=(
566
+ attributes.get('preferenceKey')
567
+ or attributes.get('preference_key')
568
+ or record.get('key')
569
+ or record['id']
570
+ ),
571
+ preference_qualifiers=preference_qualifiers(attributes),
548
572
  origin_quadrant=record.get('origin_quadrant') or 'known_known',
549
573
  current_quadrant=record.get('current_quadrant') or 'known_known',
550
574
  epistemic_status=record.get('epistemic_status') or 'confirmed',
@@ -588,7 +612,7 @@ def _graph_edge(
588
612
  replaced_by_item_kind=record.get('replaced_by_item_kind'),
589
613
  created_at=_native_datetime(record.get('created_at')),
590
614
  confidence=record.get('confidence'),
591
- attributes=json_object(record.get('attributes_json')),
615
+ attributes=attributes,
592
616
  episodes=episode_ids,
593
617
  evidence=[evidence[item] for item in episode_ids if item in evidence],
594
618
  revisions=revisions.get(record['id'], []),
@@ -280,6 +280,21 @@ async def set_preference_scope(
280
280
  detail='only personal profile knowledge has a preference scope',
281
281
  )
282
282
 
283
+ current_scope = current.get('preference_scope') or 'global'
284
+ if not (
285
+ current_scope == 'global'
286
+ and request.scope == 'project'
287
+ and request.project_id
288
+ ):
289
+ raise HTTPException(
290
+ status_code=403,
291
+ detail=(
292
+ 'direct preference scope changes may only narrow a global '
293
+ 'preference to one project; broader or lateral scope changes '
294
+ 'require the independently reviewed convergence workflow'
295
+ ),
296
+ )
297
+
283
298
  previous = _snapshot(current, request.item_kind)
284
299
  next_value = {
285
300
  **previous,
@@ -332,7 +347,10 @@ async def set_preference_scope(
332
347
  created_by=actor['id'],
333
348
  created_at=created_at,
334
349
  )
335
- if request.operation_actor == 'human':
350
+ if (
351
+ request.operation_actor == 'human'
352
+ and actor.get('_human_review_verified') is True
353
+ ):
336
354
  await record_human_change(
337
355
  store,
338
356
  actor,
@@ -10,6 +10,7 @@ from .provider_values import (
10
10
  json_object,
11
11
  native_datetime as _native_datetime,
12
12
  normalized_text as _normalized,
13
+ preference_qualifiers,
13
14
  )
14
15
  from .search_projection import (
15
16
  effective_confirmation_status,
@@ -255,6 +256,8 @@ async def personal_project_scopes(
255
256
  WHERE all(
256
257
  relation IN relationships(path)
257
258
  WHERE relation.relation_type IN ['PART_OF', 'USES_KNOWLEDGE_FROM']
259
+ AND relation.status = 'active'
260
+ AND relation.confirmation_authority = 'human_review'
258
261
  )
259
262
  AND EXISTS {
260
263
  MATCH (space)-[:CONTAINS_PROJECT]->(source)
@@ -634,6 +637,14 @@ def _dedupe_ranked_entities(ranked, active_project_id):
634
637
 
635
638
  def _entity_search_result(record, score: float, space_id: str) -> EntitySearchResult:
636
639
  value = dict(record)
640
+ attributes = json_object(value.get('attributes_json'))
641
+ value['preference_key'] = (
642
+ attributes.get('preferenceKey')
643
+ or attributes.get('preference_key')
644
+ or value.get('key')
645
+ or value.get('id')
646
+ )
647
+ value['preference_qualifiers'] = preference_qualifiers(attributes)
637
648
  basis = json_object(value.pop('confirmation_basis_json', None))
638
649
  source_references = value.pop('source_references', [])
639
650
  created_at = _native_datetime(value.pop('created_at', None))