fuli-context 0.3.0 → 0.5.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 (95) hide show
  1. package/README.en.md +336 -0
  2. package/README.md +220 -174
  3. package/dist/web/assets/ConnectionsPage-CCKWm-6p.js +1 -0
  4. package/dist/web/assets/KnowledgeInspector-BZkB2wJn.js +1 -0
  5. package/dist/web/assets/KnowledgeInspector-CrGYxEob.css +1 -0
  6. package/dist/web/assets/KnowledgeOrganizerPage-CZAF_OlN.css +1 -0
  7. package/dist/web/assets/KnowledgeOrganizerPage-DlvcLwlL.js +1 -0
  8. package/dist/web/assets/KnowledgePage-CNQXd15y.js +1 -0
  9. package/dist/web/assets/KnowledgeWorkspace-CXPE4GCD.js +4 -0
  10. package/dist/web/assets/OverviewPage-ul7g7HXh.js +1 -0
  11. package/dist/web/assets/PersonalProfilePage-CC40Pw2L.js +1 -0
  12. package/dist/web/assets/PersonalProfilePage-S8ZcG2LU.css +1 -0
  13. package/dist/web/assets/PersonalProjectsPage-CPIEcShD.js +1 -0
  14. package/dist/web/assets/PublicProjectsPage-C700sR7z.js +1 -0
  15. package/dist/web/assets/ReviewPage-7MDDUDgA.js +1 -0
  16. package/dist/web/assets/console-B_Ena2DS.js +5 -0
  17. package/dist/web/assets/identity-CjBwYSMX.js +1 -0
  18. package/dist/web/assets/{index-Devf7glZ.css → index-CpLDx3of.css} +1 -1
  19. package/dist/web/assets/index-DAzeFJSc.js +2 -0
  20. package/dist/web/assets/model-C-5RVIJ2.js +1 -0
  21. package/dist/web/assets/{paths-B790iniO.js → paths-BkULqmGI.js} +1 -1
  22. package/dist/web/assets/{vue-router-BYTAoWT3.js → vue-router-DnD3hUm7.js} +1 -1
  23. package/dist/web/index.html +5 -6
  24. package/graph-provider/fuli_graph/app.py +48 -0
  25. package/graph-provider/fuli_graph/common_knowledge.py +443 -0
  26. package/graph-provider/fuli_graph/common_knowledge_models.py +54 -0
  27. package/graph-provider/fuli_graph/graph_models.py +9 -0
  28. package/graph-provider/fuli_graph/graph_query.py +25 -0
  29. package/graph-provider/fuli_graph/knowledge_audit.py +211 -0
  30. package/graph-provider/fuli_graph/knowledge_feedback_models.py +69 -0
  31. package/graph-provider/fuli_graph/knowledge_management.py +37 -0
  32. package/graph-provider/fuli_graph/knowledge_search.py +24 -2
  33. package/graph-provider/fuli_graph/models.py +69 -0
  34. package/graph-provider/fuli_graph/search_projection.py +59 -0
  35. package/graph-provider/fuli_graph/store_knowledge.py +2 -0
  36. package/graph-provider/pyproject.toml +1 -1
  37. package/npm-shrinkwrap.json +97 -2
  38. package/package.json +3 -1
  39. package/skills/capturing-session-knowledge/SKILL.md +83 -13
  40. package/src/agent-tools/graph-definitions.js +181 -13
  41. package/src/agent-tools/graph-handlers.js +19 -0
  42. package/src/cli/command-registry.js +3 -58
  43. package/src/cli/local-runtime-command.js +16 -2
  44. package/src/cli/local-runtime-options.js +6 -2
  45. package/src/cli.js +2 -37
  46. package/src/graphiti/agent-knowledge-workflows.js +543 -0
  47. package/src/graphiti/federated-application.js +85 -104
  48. package/src/graphiti/knowledge-provider-mapping.js +91 -0
  49. package/src/graphiti/provider-client.js +15 -0
  50. package/src/graphiti/source-uri.js +29 -0
  51. package/src/http/request-policy.js +37 -2
  52. package/src/local-runtime/lifecycle.js +11 -1
  53. package/src/mcp/common-knowledge-preview-tokens.js +67 -0
  54. package/src/mcp/create-mcp-server.js +43 -7
  55. package/src/mcp/instructions.js +6 -7
  56. package/src/mcp/lifecycle-audit.js +27 -0
  57. package/src/mcp/task-context-registry.js +93 -0
  58. package/src/mcp/tool-annotations.js +16 -1
  59. package/src/mcp/tool-result.js +24 -0
  60. package/src/runtime-options.js +7 -8
  61. package/src/server/lan-access.js +52 -0
  62. package/src/server.js +67 -8
  63. package/src/setup/agent-installation-status.js +14 -2
  64. package/src/setup/agents.js +12 -1
  65. package/src/setup/claude-code-config.js +194 -0
  66. package/src/setup/config-backup.js +20 -5
  67. package/src/setup/graph-runtime.js +78 -9
  68. package/dist/web/assets/ConnectionsPage-CY6H_mJJ.js +0 -1
  69. package/dist/web/assets/KnowledgeInspector-BSpz5Sfm.js +0 -1
  70. package/dist/web/assets/KnowledgeInspector-CHbB_0rC.css +0 -1
  71. package/dist/web/assets/KnowledgeOrganizerPage-BlEvhPrv.css +0 -1
  72. package/dist/web/assets/KnowledgeOrganizerPage-CvjGPlUc.js +0 -1
  73. package/dist/web/assets/KnowledgePage-Khuo3cNs.js +0 -1
  74. package/dist/web/assets/KnowledgeWorkspace-DVZusvem.js +0 -4
  75. package/dist/web/assets/OverviewPage-DC4C4Fhn.js +0 -1
  76. package/dist/web/assets/PersonalProfilePage-CSV9Mtgz.js +0 -1
  77. package/dist/web/assets/PersonalProfilePage-CVJ3TcRi.css +0 -1
  78. package/dist/web/assets/PersonalProjectsPage-A50EaseQ.js +0 -1
  79. package/dist/web/assets/PublicProjectsPage-BAdO_bae.js +0 -1
  80. package/dist/web/assets/ReviewPage-Dztk2jDG.js +0 -1
  81. package/dist/web/assets/console-tbvTUi6p.js +0 -1
  82. package/dist/web/assets/identity-B3s5CXhv.js +0 -1
  83. package/dist/web/assets/index-DwUDhQKc.js +0 -6
  84. package/dist/web/assets/model-DVT8sjy9.js +0 -1
  85. package/dist/web/assets/runtime-dom.esm-bundler-DttsK21F.js +0 -1
  86. package/src/cli/commands/candidates.js +0 -17
  87. package/src/cli/commands/ingestion.js +0 -32
  88. package/src/cli/commands/reads.js +0 -50
  89. package/src/cli/commands/spaces.js +0 -20
  90. package/src/cli/invocation.js +0 -47
  91. package/src/cli/migrate-command.js +0 -71
  92. package/src/cli/migration-destination.js +0 -117
  93. package/src/storage/import-json.js +0 -70
  94. package/src/storage/legacy-record-validation.js +0 -152
  95. package/src/storage/legacy-snapshot-validation.js +0 -32
@@ -9,12 +9,11 @@
9
9
  />
10
10
  <link rel="icon" type="image/png" href="/assets/fuli-logo-DGuyqchQ.png" />
11
11
  <title>复利 · Context Graph</title>
12
- <script type="module" crossorigin src="/assets/index-DwUDhQKc.js"></script>
13
- <link rel="modulepreload" crossorigin href="/assets/console-tbvTUi6p.js">
14
- <link rel="modulepreload" crossorigin href="/assets/runtime-dom.esm-bundler-DttsK21F.js">
15
- <link rel="modulepreload" crossorigin href="/assets/paths-B790iniO.js">
16
- <link rel="modulepreload" crossorigin href="/assets/vue-router-BYTAoWT3.js">
17
- <link rel="stylesheet" crossorigin href="/assets/index-Devf7glZ.css">
12
+ <script type="module" crossorigin src="/assets/index-DAzeFJSc.js"></script>
13
+ <link rel="modulepreload" crossorigin href="/assets/console-B_Ena2DS.js">
14
+ <link rel="modulepreload" crossorigin href="/assets/paths-BkULqmGI.js">
15
+ <link rel="modulepreload" crossorigin href="/assets/vue-router-DnD3hUm7.js">
16
+ <link rel="stylesheet" crossorigin href="/assets/index-CpLDx3of.css">
18
17
  </head>
19
18
  <body>
20
19
  <div id="app"></div>
@@ -7,6 +7,15 @@ from .auth import matches_bootstrap_token
7
7
  from .config import Settings, get_settings
8
8
  from .graph_models import GraphResult
9
9
  from .knowledge_usage_models import KnowledgeUsageCreate, KnowledgeUsageResult
10
+ from .knowledge_feedback_models import (
11
+ KnowledgeFeedbackCreate,
12
+ KnowledgeFeedbackResult,
13
+ )
14
+ from .common_knowledge_models import (
15
+ CommonKnowledgePromotionPreview,
16
+ CommonKnowledgePromotionRequest,
17
+ CommonKnowledgePromotionResult,
18
+ )
10
19
  from .project_action_models import (
11
20
  KnowledgeProjectActionRequest,
12
21
  KnowledgeProjectActionResult,
@@ -63,11 +72,16 @@ from .models import (
63
72
  )
64
73
  from .knowledge_audit import (
65
74
  record_agent_views,
75
+ record_knowledge_feedback,
66
76
  record_knowledge_usage,
67
77
  review_human_change,
68
78
  search_human_changes,
69
79
  )
70
80
  from .knowledge_batch_confirmation import confirm_knowledge_batch
81
+ from .common_knowledge import (
82
+ apply_common_knowledge_promotion,
83
+ preview_common_knowledge_promotion,
84
+ )
71
85
  from .knowledge_management import (
72
86
  reassign_knowledge_item,
73
87
  revise_knowledge_item,
@@ -339,6 +353,16 @@ def create_app(settings: Settings | None = None) -> FastAPI:
339
353
  ) -> KnowledgeUsageResult:
340
354
  return await record_knowledge_usage(store, actor, request)
341
355
 
356
+ @application.post(
357
+ '/v1/knowledge/feedback',
358
+ response_model=KnowledgeFeedbackResult,
359
+ )
360
+ async def record_negative_knowledge_evidence(
361
+ request: KnowledgeFeedbackCreate,
362
+ actor: Actor,
363
+ ) -> KnowledgeFeedbackResult:
364
+ return await record_knowledge_feedback(store, actor, request)
365
+
342
366
  @application.post(
343
367
  '/v1/knowledge/items/{item_id}/agent-review',
344
368
  response_model=KnowledgeAuditRecord,
@@ -392,6 +416,30 @@ def create_app(settings: Settings | None = None) -> FastAPI:
392
416
  ) -> KnowledgeRevisionRecord:
393
417
  return await set_preference_scope(store, actor, item_id, request)
394
418
 
419
+ @application.post(
420
+ '/v1/knowledge/common-promotions/preview',
421
+ response_model=CommonKnowledgePromotionPreview,
422
+ )
423
+ async def preview_common_promotion(
424
+ request: CommonKnowledgePromotionRequest,
425
+ actor: Actor,
426
+ ) -> CommonKnowledgePromotionPreview:
427
+ return await preview_common_knowledge_promotion(
428
+ store, actor, request
429
+ )
430
+
431
+ @application.post(
432
+ '/v1/knowledge/common-promotions',
433
+ response_model=CommonKnowledgePromotionResult,
434
+ )
435
+ async def apply_common_promotion(
436
+ request: CommonKnowledgePromotionRequest,
437
+ actor: Actor,
438
+ ) -> CommonKnowledgePromotionResult:
439
+ return await apply_common_knowledge_promotion(
440
+ store, actor, request
441
+ )
442
+
395
443
  @application.post(
396
444
  '/v1/preference-conflicts/defer',
397
445
  response_model=PreferenceConflictRecord,
@@ -0,0 +1,443 @@
1
+ import json
2
+ from dataclasses import dataclass
3
+ from datetime import datetime, timezone
4
+
5
+ from fastapi import HTTPException
6
+
7
+ from .common_knowledge_models import (
8
+ CommonKnowledgePromotionPreview,
9
+ CommonKnowledgePromotionRequest,
10
+ CommonKnowledgePromotionResult,
11
+ )
12
+ from .knowledge_management import (
13
+ _read_assignment,
14
+ _read_item,
15
+ _snapshot,
16
+ _implicit_project_id,
17
+ )
18
+ from .personal_project_access import authorize_personal_project
19
+ from .provider_values import stable_uuid
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class _PromotionItem:
24
+ item_id: str
25
+ project_id: str
26
+ previous: dict
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class _PromotionPlan:
31
+ space: dict
32
+ canonical: _PromotionItem
33
+ duplicates: list[_PromotionItem]
34
+
35
+ @property
36
+ def source_project_ids(self) -> list[str]:
37
+ return sorted([
38
+ self.canonical.project_id,
39
+ *(item.project_id for item in self.duplicates),
40
+ ])
41
+
42
+
43
+ async def preview_common_knowledge_promotion(
44
+ store,
45
+ actor: dict,
46
+ request: CommonKnowledgePromotionRequest,
47
+ ) -> CommonKnowledgePromotionPreview:
48
+ plan = await _promotion_plan(store, actor, request)
49
+ return CommonKnowledgePromotionPreview(
50
+ status='ready',
51
+ personal_space_id=request.personal_space_id,
52
+ parent_project_id=request.parent_project_id,
53
+ item_kind=request.item_kind,
54
+ canonical_item_id=request.canonical_item_id,
55
+ duplicate_item_ids=sorted(request.duplicate_item_ids),
56
+ source_project_ids=plan.source_project_ids,
57
+ reason=request.reason,
58
+ human_confirmation_reason=request.human_confirmation_reason,
59
+ )
60
+
61
+
62
+ async def apply_common_knowledge_promotion(
63
+ store,
64
+ actor: dict,
65
+ request: CommonKnowledgePromotionRequest,
66
+ *,
67
+ changed_at: datetime | None = None,
68
+ ) -> CommonKnowledgePromotionResult:
69
+ plan = await _promotion_plan(store, actor, request)
70
+ promoted_at = changed_at or datetime.now(timezone.utc)
71
+ promotion_id = stable_uuid(
72
+ request.personal_space_id,
73
+ 'common-knowledge-promotion',
74
+ request.parent_project_id,
75
+ request.item_kind,
76
+ request.canonical_item_id,
77
+ promoted_at.isoformat(),
78
+ )
79
+ assignment_id = stable_uuid(
80
+ request.personal_space_id,
81
+ 'knowledge-assignment',
82
+ request.item_kind,
83
+ request.canonical_item_id,
84
+ )
85
+ canonical_current = {
86
+ **plan.canonical.previous,
87
+ 'inheritanceMode': 'descendants',
88
+ 'inheritedProjectIds': [],
89
+ }
90
+ revision_specs = [{
91
+ 'revision_id': stable_uuid(
92
+ promotion_id, 'revision', request.canonical_item_id
93
+ ),
94
+ 'item_id': request.canonical_item_id,
95
+ 'action': 'promote_common',
96
+ 'previous_json': _json(plan.canonical.previous),
97
+ 'current_json': _json(canonical_current),
98
+ }]
99
+ duplicate_specs = []
100
+ for duplicate in plan.duplicates:
101
+ current = {
102
+ **duplicate.previous,
103
+ 'invalidAt': promoted_at.isoformat(),
104
+ 'replacedByItemId': request.canonical_item_id,
105
+ 'replacedByItemKind': request.item_kind,
106
+ }
107
+ revision_specs.append({
108
+ 'revision_id': stable_uuid(
109
+ promotion_id, 'revision', duplicate.item_id
110
+ ),
111
+ 'item_id': duplicate.item_id,
112
+ 'action': 'replace_common_duplicate',
113
+ 'previous_json': _json(duplicate.previous),
114
+ 'current_json': _json(current),
115
+ })
116
+ duplicate_specs.append({
117
+ 'item_id': duplicate.item_id,
118
+ 'project_id': duplicate.project_id,
119
+ })
120
+
121
+ query = _promotion_query(request.item_kind)
122
+ records, _, _ = await store.runtime.driver.execute_query(
123
+ query,
124
+ space_id=request.personal_space_id,
125
+ group_id=plan.space['group_id'],
126
+ parent_project_id=request.parent_project_id,
127
+ source_project_ids=plan.source_project_ids,
128
+ canonical_item_id=request.canonical_item_id,
129
+ canonical_source_project_id=plan.canonical.project_id,
130
+ duplicate_specs=duplicate_specs,
131
+ item_kind=request.item_kind,
132
+ assignment_id=assignment_id,
133
+ promotion_id=promotion_id,
134
+ revision_specs=revision_specs,
135
+ reason=request.reason,
136
+ human_confirmation_reason=request.human_confirmation_reason,
137
+ changed_by=actor['id'],
138
+ operation_actor=request.operation_actor,
139
+ changed_at=promoted_at,
140
+ )
141
+ if not records:
142
+ raise HTTPException(
143
+ status_code=409,
144
+ detail='common knowledge changed after preview; review it again',
145
+ )
146
+ record = records[0]
147
+ return CommonKnowledgePromotionResult(
148
+ status='promoted',
149
+ promotion_id=record.get('promotion_id') or promotion_id,
150
+ personal_space_id=request.personal_space_id,
151
+ parent_project_id=request.parent_project_id,
152
+ item_kind=request.item_kind,
153
+ canonical_item_id=request.canonical_item_id,
154
+ invalidated_item_ids=sorted(request.duplicate_item_ids),
155
+ source_project_ids=plan.source_project_ids,
156
+ revision_ids=record.get('revision_ids') or [
157
+ item['revision_id'] for item in revision_specs
158
+ ],
159
+ reason=request.reason,
160
+ human_confirmation_reason=request.human_confirmation_reason,
161
+ )
162
+
163
+
164
+ async def _promotion_plan(store, actor, request) -> _PromotionPlan:
165
+ store._require_personal()
166
+ space = await store.authorize(
167
+ actor, request.personal_space_id, 'maintainer'
168
+ )
169
+ if space['kind'] != 'personal':
170
+ raise HTTPException(
171
+ status_code=422,
172
+ detail='common knowledge promotion is personal-only',
173
+ )
174
+ await authorize_personal_project(
175
+ store, actor, space, request.parent_project_id
176
+ )
177
+ direct_child_ids = await _direct_child_project_ids(
178
+ store, space['id'], request.parent_project_id
179
+ )
180
+ item_ids = [request.canonical_item_id, *request.duplicate_item_ids]
181
+ items = []
182
+ for item_id in item_ids:
183
+ value = await _read_item(store, space, item_id, request.item_kind)
184
+ if value is None:
185
+ raise HTTPException(status_code=404, detail='knowledge item not found')
186
+ if value.get('profile_aspect'):
187
+ raise HTTPException(
188
+ status_code=422,
189
+ detail='personal profile knowledge cannot become common project knowledge',
190
+ )
191
+ if value.get('invalid_at'):
192
+ raise HTTPException(
193
+ status_code=409,
194
+ detail='historical knowledge cannot be promoted as common knowledge',
195
+ )
196
+ project_id = await _item_project_id(
197
+ store, space, item_id, request.item_kind
198
+ )
199
+ if project_id not in direct_child_ids:
200
+ raise HTTPException(
201
+ status_code=422,
202
+ detail='every promoted item must belong to a direct PART_OF child',
203
+ )
204
+ items.append(_PromotionItem(
205
+ item_id=item_id,
206
+ project_id=project_id,
207
+ previous=_snapshot(value, request.item_kind),
208
+ ))
209
+ project_ids = [item.project_id for item in items]
210
+ if len(set(project_ids)) != len(project_ids):
211
+ raise HTTPException(
212
+ status_code=422,
213
+ detail='common knowledge evidence must come from distinct child projects',
214
+ )
215
+ return _PromotionPlan(
216
+ space=space,
217
+ canonical=items[0],
218
+ duplicates=items[1:],
219
+ )
220
+
221
+
222
+ async def _direct_child_project_ids(store, space_id, parent_project_id):
223
+ records, _, _ = await store.runtime.driver.execute_query(
224
+ '''
225
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
226
+ [:CONTAINS_PROJECT]->(child:FuliPersonalProject)-
227
+ [:PERSONAL_PROJECT_RELATION {relation_type: 'PART_OF'}]->
228
+ (parent:FuliPersonalProject {project_id: $parent_project_id})
229
+ RETURN child.project_id AS child_project_id
230
+ ORDER BY child.project_id
231
+ ''',
232
+ space_id=space_id,
233
+ parent_project_id=parent_project_id,
234
+ routing_='r',
235
+ )
236
+ return {record['child_project_id'] for record in records}
237
+
238
+
239
+ async def _item_project_id(store, space, item_id, item_kind):
240
+ assignment = await _read_assignment(
241
+ store, space['id'], item_id, item_kind
242
+ )
243
+ if assignment:
244
+ return assignment.project_id
245
+ return await _implicit_project_id(store, space, item_id, item_kind)
246
+
247
+
248
+ def _promotion_query(item_kind):
249
+ canonical_project_guard = _current_project_guard(
250
+ item_kind,
251
+ item_variable='canonical',
252
+ variable_prefix='canonical',
253
+ item_id_expression='$canonical_item_id',
254
+ project_id_expression='$canonical_source_project_id',
255
+ )
256
+ duplicate_project_guard = _current_project_guard(
257
+ item_kind,
258
+ item_variable='duplicate',
259
+ variable_prefix='duplicate',
260
+ item_id_expression='duplicate_spec.item_id',
261
+ project_id_expression='duplicate_spec.project_id',
262
+ )
263
+ if item_kind == 'entity':
264
+ canonical_match = f'''
265
+ MATCH (canonical:Entity {{
266
+ uuid: $canonical_item_id,
267
+ group_id: $group_id
268
+ }})
269
+ WHERE canonical.fuli_invalid_at IS NULL
270
+ {canonical_project_guard}
271
+ '''
272
+ duplicate_match = f'''
273
+ MATCH (duplicate:Entity {{
274
+ uuid: duplicate_spec.item_id,
275
+ group_id: $group_id
276
+ }})
277
+ WHERE duplicate.fuli_invalid_at IS NULL
278
+ {duplicate_project_guard}
279
+ '''
280
+ duplicate_update = '''
281
+ FOREACH (pair IN duplicate_pairs |
282
+ SET pair.item.fuli_invalid_at = $changed_at,
283
+ pair.item.fuli_replaced_by_item_id = $canonical_item_id,
284
+ pair.item.fuli_replaced_by_item_kind = $item_kind
285
+ )
286
+ '''
287
+ else:
288
+ canonical_match = f'''
289
+ MATCH ()-[canonical:RELATES_TO {{
290
+ uuid: $canonical_item_id,
291
+ group_id: $group_id
292
+ }}]->()
293
+ WHERE canonical.invalid_at IS NULL
294
+ {canonical_project_guard}
295
+ '''
296
+ duplicate_match = f'''
297
+ MATCH ()-[duplicate:RELATES_TO {{
298
+ uuid: duplicate_spec.item_id,
299
+ group_id: $group_id
300
+ }}]->()
301
+ WHERE duplicate.invalid_at IS NULL
302
+ {duplicate_project_guard}
303
+ '''
304
+ duplicate_update = '''
305
+ FOREACH (pair IN duplicate_pairs |
306
+ SET pair.item.invalid_at = $changed_at,
307
+ pair.item.fuli_replaced_by_item_id = $canonical_item_id,
308
+ pair.item.fuli_replaced_by_item_kind = $item_kind
309
+ )
310
+ '''
311
+ return f'''
312
+ MATCH (space:FuliSpace {{id: $space_id, kind: 'personal'}})-
313
+ [:CONTAINS_PROJECT]->
314
+ (parent:FuliPersonalProject {{project_id: $parent_project_id}})
315
+ UNWIND $source_project_ids AS source_project_id
316
+ MATCH (space)-[:CONTAINS_PROJECT]->
317
+ (child:FuliPersonalProject {{project_id: source_project_id}})-
318
+ [:PERSONAL_PROJECT_RELATION {{relation_type: 'PART_OF'}}]->
319
+ (parent)
320
+ WITH space, parent, collect(DISTINCT child.project_id) AS direct_children
321
+ WHERE size(direct_children) = size($source_project_ids)
322
+ {canonical_match}
323
+ WITH space, parent, canonical, direct_children
324
+ UNWIND $duplicate_specs AS duplicate_spec
325
+ {duplicate_match}
326
+ WITH space, parent, canonical, direct_children,
327
+ collect({{item: duplicate, spec: duplicate_spec}}) AS duplicate_pairs
328
+ WHERE size(duplicate_pairs) = size($duplicate_specs)
329
+ SET canonical.fuli_inheritance_mode = 'descendants',
330
+ canonical.fuli_inherited_project_ids = []
331
+ {duplicate_update}
332
+ MERGE (assignment:FuliKnowledgeAssignment {{
333
+ space_id: $space_id,
334
+ item_kind: $item_kind,
335
+ item_id: $canonical_item_id
336
+ }})
337
+ ON CREATE SET assignment.id = $assignment_id,
338
+ assignment.created_at = $changed_at
339
+ SET assignment.previous_project_id = $canonical_source_project_id,
340
+ assignment.project_id = $parent_project_id,
341
+ assignment.reason = $reason,
342
+ assignment.changed_by = $changed_by,
343
+ assignment.updated_at = $changed_at
344
+ WITH space, parent, assignment
345
+ OPTIONAL MATCH (assignment)-[old:ASSIGNED_TO]->
346
+ (:FuliPersonalProject)
347
+ DELETE old
348
+ MERGE (assignment)-[:ASSIGNED_TO]->(parent)
349
+ MERGE (space)-[:HAS_KNOWLEDGE_ASSIGNMENT]->(assignment)
350
+ CREATE (promotion:FuliCommonKnowledgePromotion {{
351
+ id: $promotion_id,
352
+ space_id: $space_id,
353
+ parent_project_id: $parent_project_id,
354
+ item_kind: $item_kind,
355
+ canonical_item_id: $canonical_item_id,
356
+ duplicate_item_ids: [item IN $duplicate_specs | item.item_id],
357
+ source_project_ids: $source_project_ids,
358
+ reason: $reason,
359
+ human_confirmation_reason: $human_confirmation_reason,
360
+ operation_actor: $operation_actor,
361
+ created_by: $changed_by,
362
+ created_at: $changed_at
363
+ }})
364
+ MERGE (space)-[:HAS_COMMON_KNOWLEDGE_PROMOTION]->(promotion)
365
+ WITH promotion, assignment, space
366
+ UNWIND $revision_specs AS revision_spec
367
+ CREATE (revision:FuliKnowledgeRevision {{
368
+ id: revision_spec.revision_id,
369
+ space_id: $space_id,
370
+ item_id: revision_spec.item_id,
371
+ item_kind: $item_kind,
372
+ action: revision_spec.action,
373
+ reason: $reason,
374
+ previous_json: revision_spec.previous_json,
375
+ current_json: revision_spec.current_json,
376
+ created_by: $changed_by,
377
+ created_at: $changed_at
378
+ }})
379
+ MERGE (space)-[:HAS_KNOWLEDGE_REVISION]->(revision)
380
+ RETURN promotion.id AS promotion_id,
381
+ assignment.id AS assignment_id,
382
+ collect(revision.id) AS revision_ids
383
+ '''
384
+
385
+
386
+ def _current_project_guard(
387
+ item_kind,
388
+ *,
389
+ item_variable,
390
+ variable_prefix,
391
+ item_id_expression,
392
+ project_id_expression,
393
+ ):
394
+ if item_kind == 'entity':
395
+ implicit_project_match = f'''
396
+ MATCH ({variable_prefix}_episode:Episodic {{group_id: $group_id}})-
397
+ [:MENTIONS]->({item_variable})
398
+ '''
399
+ else:
400
+ implicit_project_match = f'''
401
+ UNWIND coalesce({item_variable}.episodes, [])
402
+ AS {variable_prefix}_episode_id
403
+ MATCH ({variable_prefix}_episode:Episodic {{
404
+ uuid: {variable_prefix}_episode_id,
405
+ group_id: $group_id
406
+ }})
407
+ '''
408
+ return f'''
409
+ AND (
410
+ EXISTS {{
411
+ MATCH (space)-[:HAS_KNOWLEDGE_ASSIGNMENT]->
412
+ ({variable_prefix}_assignment:FuliKnowledgeAssignment {{
413
+ item_kind: $item_kind,
414
+ item_id: {item_id_expression}
415
+ }})
416
+ WHERE {variable_prefix}_assignment.project_id = {project_id_expression}
417
+ }}
418
+ OR (
419
+ NOT EXISTS {{
420
+ MATCH (space)-[:HAS_KNOWLEDGE_ASSIGNMENT]->
421
+ (:FuliKnowledgeAssignment {{
422
+ item_kind: $item_kind,
423
+ item_id: {item_id_expression}
424
+ }})
425
+ }}
426
+ AND EXISTS {{
427
+ {implicit_project_match}
428
+ WHERE {variable_prefix}_episode.fuli_personal_project_id
429
+ IS NOT NULL
430
+ WITH collect(DISTINCT
431
+ {variable_prefix}_episode.fuli_personal_project_id)
432
+ AS {variable_prefix}_project_ids
433
+ WHERE {variable_prefix}_project_ids =
434
+ [{project_id_expression}]
435
+ RETURN {variable_prefix}_project_ids
436
+ }}
437
+ )
438
+ )
439
+ '''
440
+
441
+
442
+ def _json(value):
443
+ return json.dumps(value, ensure_ascii=False, sort_keys=True)
@@ -0,0 +1,54 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import Field, model_validator
4
+
5
+ from .models import KnowledgeItemKind, KnowledgeOperationActor, StrictModel
6
+
7
+
8
+ class CommonKnowledgePromotionRequest(StrictModel):
9
+ personal_space_id: str = Field(min_length=1, max_length=128)
10
+ parent_project_id: str = Field(min_length=1, max_length=128)
11
+ item_kind: KnowledgeItemKind
12
+ canonical_item_id: str = Field(min_length=1, max_length=256)
13
+ duplicate_item_ids: list[str] = Field(min_length=1, max_length=31)
14
+ reason: str = Field(min_length=1, max_length=2000)
15
+ human_confirmation_reason: str = Field(min_length=1, max_length=2000)
16
+ operation_actor: KnowledgeOperationActor = 'agent'
17
+
18
+ @model_validator(mode='after')
19
+ def validate_item_ids(self):
20
+ if len(set(self.duplicate_item_ids)) != len(self.duplicate_item_ids):
21
+ raise ValueError('duplicate item ids must be unique')
22
+ if self.canonical_item_id in self.duplicate_item_ids:
23
+ raise ValueError('canonical item cannot also be a duplicate')
24
+ return self
25
+
26
+
27
+ class CommonKnowledgePromotionPreview(StrictModel):
28
+ status: Literal['ready']
29
+ personal_space_id: str
30
+ parent_project_id: str
31
+ item_kind: KnowledgeItemKind
32
+ canonical_item_id: str
33
+ duplicate_item_ids: list[str]
34
+ source_project_ids: list[str]
35
+ inheritance_mode: Literal['descendants'] = 'descendants'
36
+ atomic: bool = True
37
+ requires_human_confirmation: bool = True
38
+ reason: str
39
+ human_confirmation_reason: str
40
+
41
+
42
+ class CommonKnowledgePromotionResult(StrictModel):
43
+ status: Literal['promoted']
44
+ promotion_id: str
45
+ personal_space_id: str
46
+ parent_project_id: str
47
+ item_kind: KnowledgeItemKind
48
+ canonical_item_id: str
49
+ invalidated_item_ids: list[str]
50
+ source_project_ids: list[str]
51
+ inheritance_mode: Literal['descendants'] = 'descendants'
52
+ revision_ids: list[str]
53
+ reason: str
54
+ human_confirmation_reason: str
@@ -13,6 +13,7 @@ from .models import (
13
13
  KnowledgeAssignmentRecord,
14
14
  KnowledgeAuditRecord,
15
15
  KnowledgeInheritanceMode,
16
+ KnowledgeFeedbackKind,
16
17
  KnowledgeItemKind,
17
18
  KnowledgeRevisionRecord,
18
19
  PersonalProfileAspect,
@@ -55,6 +56,10 @@ class GraphNode(StrictModel):
55
56
  qualified_use_count: int = Field(default=0, ge=0)
56
57
  distinct_task_count: int = Field(default=0, ge=0)
57
58
  last_used_at: datetime | None = None
59
+ negative_evidence_count: int = Field(default=0, ge=0)
60
+ requires_attention: bool = False
61
+ last_feedback_kind: KnowledgeFeedbackKind | None = None
62
+ last_feedback_at: datetime | None = None
58
63
  usage_generation: int = Field(default=1, ge=1)
59
64
  attributes: dict[str, Any] = Field(default_factory=dict)
60
65
  evidence: list[GraphEvidence] = Field(default_factory=list)
@@ -99,6 +104,10 @@ class GraphEdge(StrictModel):
99
104
  qualified_use_count: int = Field(default=0, ge=0)
100
105
  distinct_task_count: int = Field(default=0, ge=0)
101
106
  last_used_at: datetime | None = None
107
+ negative_evidence_count: int = Field(default=0, ge=0)
108
+ requires_attention: bool = False
109
+ last_feedback_kind: KnowledgeFeedbackKind | None = None
110
+ last_feedback_at: datetime | None = None
102
111
  usage_generation: int = Field(default=1, ge=1)
103
112
  valid_at: datetime | None
104
113
  invalid_at: datetime | None
@@ -220,6 +220,12 @@ def _node_query(project_scoped: bool) -> str:
220
220
  coalesce(node.fuli_distinct_task_count, 0)
221
221
  AS distinct_task_count,
222
222
  node.fuli_last_used_at AS last_used_at,
223
+ coalesce(node.fuli_negative_evidence_count, 0)
224
+ AS negative_evidence_count,
225
+ coalesce(node.fuli_requires_attention, false)
226
+ AS requires_attention,
227
+ node.fuli_last_feedback_kind AS last_feedback_kind,
228
+ node.fuli_last_feedback_at AS last_feedback_at,
223
229
  coalesce(node.fuli_usage_generation, 1) AS usage_generation,
224
230
  coalesce(node.fuli_attributes_json, '{}') AS attributes_json,
225
231
  [episode IN episodes WHERE episode IS NOT NULL | episode.uuid] AS episodes,
@@ -295,6 +301,12 @@ def _edge_query(project_scoped: bool) -> str:
295
301
  coalesce(edge.fuli_distinct_task_count, 0)
296
302
  AS distinct_task_count,
297
303
  edge.fuli_last_used_at AS last_used_at,
304
+ coalesce(edge.fuli_negative_evidence_count, 0)
305
+ AS negative_evidence_count,
306
+ coalesce(edge.fuli_requires_attention, false)
307
+ AS requires_attention,
308
+ edge.fuli_last_feedback_kind AS last_feedback_kind,
309
+ edge.fuli_last_feedback_at AS last_feedback_at,
298
310
  coalesce(edge.fuli_usage_generation, 1) AS usage_generation,
299
311
  edge.valid_at AS valid_at, edge.invalid_at AS invalid_at,
300
312
  edge.fuli_replaced_by_item_id AS replaced_by_item_id,
@@ -331,6 +343,7 @@ async def _read_evidence(store, episode_ids: list[str]) -> dict[str, GraphEviden
331
343
  RETURN episode.uuid AS id, episode.name AS name,
332
344
  episode.source_description AS source_description,
333
345
  coalesce(episode.fuli_source_kind, 'unknown') AS source_kind,
346
+ episode.fuli_source_uri AS source_uri,
334
347
  coalesce(episode.fuli_summary, '') AS summary,
335
348
  episode.fuli_session_id AS session_id,
336
349
  episode.fuli_source_application AS source_application,
@@ -446,6 +459,12 @@ def _graph_node(
446
459
  qualified_use_count=int(record.get('qualified_use_count') or 0),
447
460
  distinct_task_count=int(record.get('distinct_task_count') or 0),
448
461
  last_used_at=_native_datetime(record.get('last_used_at')),
462
+ negative_evidence_count=int(
463
+ record.get('negative_evidence_count') or 0
464
+ ),
465
+ requires_attention=record.get('requires_attention') is True,
466
+ last_feedback_kind=record.get('last_feedback_kind'),
467
+ last_feedback_at=_native_datetime(record.get('last_feedback_at')),
449
468
  usage_generation=int(record.get('usage_generation') or 1),
450
469
  attributes=json_object(record.get('attributes_json')),
451
470
  evidence=[evidence[item] for item in episode_ids if item in evidence],
@@ -510,6 +529,12 @@ def _graph_edge(
510
529
  qualified_use_count=int(record.get('qualified_use_count') or 0),
511
530
  distinct_task_count=int(record.get('distinct_task_count') or 0),
512
531
  last_used_at=_native_datetime(record.get('last_used_at')),
532
+ negative_evidence_count=int(
533
+ record.get('negative_evidence_count') or 0
534
+ ),
535
+ requires_attention=record.get('requires_attention') is True,
536
+ last_feedback_kind=record.get('last_feedback_kind'),
537
+ last_feedback_at=_native_datetime(record.get('last_feedback_at')),
513
538
  usage_generation=int(record.get('usage_generation') or 1),
514
539
  valid_at=_native_datetime(record.get('valid_at')),
515
540
  invalid_at=_native_datetime(record.get('invalid_at')),