fuli-context 0.11.1 → 0.12.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 (152) hide show
  1. package/dist/web/assets/AboutPage-BAD0iPVX.js +1 -0
  2. package/dist/web/assets/AgentConversations-1iGTd4sz.js +2 -0
  3. package/dist/web/assets/AgentHand-D_RafKOM.js +1 -0
  4. package/dist/web/assets/AgentName-B5LKQ09q.js +1 -0
  5. package/dist/web/assets/AgentProfilePage-CioOdkBM.js +1 -0
  6. package/dist/web/assets/AgentsDirectoryPage--1A9nvNA.js +1 -0
  7. package/dist/web/assets/AgentsDirectoryPage-C7OUQH9v.css +1 -0
  8. package/dist/web/assets/ConnectionsPage-Tr2rRVOZ.js +1 -0
  9. package/dist/web/assets/EmployeeRecruitDialog-1QevD4Qv.js +1 -0
  10. package/dist/web/assets/EmployeeWorkbenchPage-B0vQIgwA.js +3 -0
  11. package/dist/web/assets/EmployeeWorkbenchPage-BQes4L1f.css +1 -0
  12. package/dist/web/assets/KnowledgeEditDialog-ClnrfPZi.js +1 -0
  13. package/dist/web/assets/KnowledgeInspector-Te1hJZbx.js +1 -0
  14. package/dist/web/assets/KnowledgeOrganizerPage-CCCrWl6Y.js +1 -0
  15. package/dist/web/assets/KnowledgeOrganizerPage-ccgscrPd.css +1 -0
  16. package/dist/web/assets/KnowledgePage-DGOcBwr8.js +1 -0
  17. package/dist/web/assets/KnowledgeWorkspace-cKn1NCRe.js +4 -0
  18. package/dist/web/assets/NotFoundPage-fetSnp0N.js +1 -0
  19. package/dist/web/assets/OverviewPage-BD4yY2Xz.js +1 -0
  20. package/dist/web/assets/PersonalProfilePage-DGJVP_VB.js +1 -0
  21. package/dist/web/assets/PersonalProjectsPage-D25HYMgD.js +1 -0
  22. package/dist/web/assets/ProjectAgentFirstTask-BcxqmKXQ.js +1 -0
  23. package/dist/web/assets/ProjectAgentsPage-BWDJBESb.js +12 -0
  24. package/dist/web/assets/ProjectScopePicker-yodM7lF_.js +1 -0
  25. package/dist/web/assets/PublicProjectsPage-CyNxVzW3.js +1 -0
  26. package/dist/web/assets/ReviewPage-Cy0Rt4CO.js +1 -0
  27. package/dist/web/assets/RoundtablesPage-BjVhvwMc.js +1 -0
  28. package/dist/web/assets/SettingsPage-CCZqZtnH.js +1 -0
  29. package/dist/web/assets/SettingsPage-DYz5bTwa.css +1 -0
  30. package/dist/web/assets/SharedJefaReportPage-C56kPciB.js +1 -0
  31. package/dist/web/assets/UiDialog-CvDxg3B1.js +1 -0
  32. package/dist/web/assets/UiDisclosure-D0iMTplF.js +1 -0
  33. package/dist/web/assets/UiSegmented-DIJW6wv3.js +1 -0
  34. package/dist/web/assets/UiSelect-CMR0QqF6.js +1 -0
  35. package/dist/web/assets/WritingTastePage-QgNzssfF.js +1 -0
  36. package/dist/web/assets/agent-pins-BcJ_qr8n.js +1 -0
  37. package/dist/web/assets/agent-work-summary-C-yW7x7A.js +1 -0
  38. package/dist/web/assets/{attention-store-D-qIFRIb.js → attention-store-B1DI8wmd.js} +1 -1
  39. package/dist/web/assets/{catalog-UDv854JN.js → catalog-DbXv0_bR.js} +1 -1
  40. package/dist/web/assets/client-CR-W5Wzp.js +1 -0
  41. package/dist/web/assets/console-D7GHOMEC.js +1 -0
  42. package/dist/web/assets/conversation-launcher-settings-Dj2GMzCI.js +1 -0
  43. package/dist/web/assets/{i18n-b14l2pQA.js → i18n-CVFhT_k7.js} +2 -2
  44. package/dist/web/assets/{identity-BWbPmZZf.js → identity-CM6Dmy00.js} +1 -1
  45. package/dist/web/assets/index-CJ6Jmok9.css +1 -0
  46. package/dist/web/assets/index-CRa3GnYi.js +5 -0
  47. package/dist/web/assets/judgment-types-CJBEyJs1.js +1 -0
  48. package/dist/web/assets/model-WFhQZ3v_.js +1 -0
  49. package/dist/web/assets/{personal-profile-graph-BPTtMJTZ.js → personal-profile-graph-BwCh2M9G.js} +1 -1
  50. package/dist/web/assets/shared-report-CeIvrL9a.js +1 -0
  51. package/dist/web/assets/{agent-work-summary-LeW-Al_S.js → task-evidence-DTiPCs7r.js} +1 -1
  52. package/dist/web/assets/useAgentRoster-DCArHlxK.js +1 -0
  53. package/dist/web/assets/{useApi-CROJJdhE-CEQAEuug.js → useApi-CROJJdhE-BYhbjaZB.js} +1 -1
  54. package/dist/web/assets/vue-router-DaxrQwX3.js +1 -0
  55. package/dist/web/index.html +16 -12
  56. package/docs/employee-agents.md +1 -1
  57. package/docs/judgment-and-autonomy.md +33 -0
  58. package/graph-provider/fuli_graph/ai_review.py +154 -0
  59. package/graph-provider/fuli_graph/common_knowledge.py +12 -0
  60. package/graph-provider/fuli_graph/graph_models.py +14 -0
  61. package/graph-provider/fuli_graph/graph_query.py +217 -0
  62. package/graph-provider/fuli_graph/knowledge_audit.py +6 -0
  63. package/graph-provider/fuli_graph/knowledge_batch_confirmation.py +6 -0
  64. package/graph-provider/fuli_graph/knowledge_management.py +23 -0
  65. package/graph-provider/fuli_graph/knowledge_review.py +383 -4
  66. package/graph-provider/fuli_graph/knowledge_review_models.py +61 -2
  67. package/graph-provider/fuli_graph/personal_global_preferences.py +5 -1
  68. package/graph-provider/fuli_graph/preference_conflicts.py +91 -1
  69. package/graph-provider/fuli_graph/project_agent_task_models.py +2 -0
  70. package/graph-provider/fuli_graph/project_knowledge.py +41 -0
  71. package/graph-provider/fuli_graph/runtime.py +8 -0
  72. package/graph-provider/fuli_graph/store_agent_verification.py +12 -5
  73. package/graph-provider/fuli_graph/store_knowledge.py +98 -58
  74. package/graph-provider/fuli_graph/store_project_agent_attention.py +1 -1
  75. package/graph-provider/fuli_graph/store_project_agent_task_activity.py +26 -0
  76. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +2 -1
  77. package/graph-provider/fuli_graph/system_agents.py +20 -1
  78. package/graph-provider/fuli_graph/system_hr_identity.py +19 -5
  79. package/graph-provider/pyproject.toml +1 -1
  80. package/npm-shrinkwrap.json +2 -2
  81. package/package.json +3 -2
  82. package/src/agent-tools/graph-definitions.js +2 -0
  83. package/src/agent-tools/graph-handlers.js +7 -0
  84. package/src/agent-tools/interface-catalog.js +6 -0
  85. package/src/agent-tools/judgment-definitions.js +12 -0
  86. package/src/agents/claude-code/wake.js +7 -0
  87. package/src/agents/codex/wake.js +7 -0
  88. package/src/employees/catalog/tonborg.json +20 -0
  89. package/src/employees/manifest.js +2 -2
  90. package/src/employees/recruitment.js +20 -1
  91. package/src/graphiti/agent-collaboration.js +8 -2
  92. package/src/graphiti/federated-application.js +6 -1
  93. package/src/graphiti/knowledge-review.js +7 -3
  94. package/src/graphiti/project-agent-mapping.js +2 -0
  95. package/src/graphiti/project-agent-task-entry.js +2 -2
  96. package/src/graphiti/task-entry-preferences.js +5 -0
  97. package/src/http/api-router.js +2 -0
  98. package/src/judgment/acceptance.js +91 -0
  99. package/src/judgment/engine.js +168 -0
  100. package/src/judgment/http.js +24 -0
  101. package/src/judgment/model.js +24 -0
  102. package/src/judgment/routing.js +96 -0
  103. package/src/judgment/scheduler.js +25 -0
  104. package/src/judgment/service.js +66 -0
  105. package/src/judgment/store.js +123 -0
  106. package/src/mcp/session-id.js +1 -0
  107. package/src/mcp/tool-annotations.js +4 -2
  108. package/src/server.js +9 -2
  109. package/dist/web/assets/AboutPage-cW9hDghN.js +0 -1
  110. package/dist/web/assets/AgentConversations-CGznTWuV.js +0 -2
  111. package/dist/web/assets/AgentHand-DkyF4vgk.js +0 -1
  112. package/dist/web/assets/AgentName-C1mAVr-v.js +0 -1
  113. package/dist/web/assets/AgentProfilePage-pKUR0rfj.js +0 -1
  114. package/dist/web/assets/AgentsDirectoryPage-CUlbT2hD.js +0 -1
  115. package/dist/web/assets/AgentsDirectoryPage-yRWktiin.css +0 -1
  116. package/dist/web/assets/ConnectionsPage-CuDHIJXu.js +0 -1
  117. package/dist/web/assets/EmployeeRecruitDialog-D9QfGipd.js +0 -1
  118. package/dist/web/assets/EmployeeWorkbenchPage-D-c_Jm2z.css +0 -1
  119. package/dist/web/assets/EmployeeWorkbenchPage-DAtvGney.js +0 -3
  120. package/dist/web/assets/KnowledgeEditDialog-a-5rasG0.js +0 -1
  121. package/dist/web/assets/KnowledgeInspector-BGsqjNY2.js +0 -1
  122. package/dist/web/assets/KnowledgeOrganizerPage-BndgUsHS.js +0 -1
  123. package/dist/web/assets/KnowledgeOrganizerPage-DeL7Jp34.css +0 -1
  124. package/dist/web/assets/KnowledgePage-CQweO0YJ.js +0 -1
  125. package/dist/web/assets/KnowledgeWorkspace-BtUjKTLb.js +0 -4
  126. package/dist/web/assets/NotFoundPage-BmQTTqQn.js +0 -1
  127. package/dist/web/assets/OverviewPage-C73HyTsM.js +0 -1
  128. package/dist/web/assets/PersonalProfilePage-BMuOrppx.js +0 -1
  129. package/dist/web/assets/PersonalProjectsPage-Cq9_kFvs.js +0 -1
  130. package/dist/web/assets/ProjectAgentFirstTask-CaHvOjkp.js +0 -1
  131. package/dist/web/assets/ProjectAgentsPage-WVcNdzVi.js +0 -12
  132. package/dist/web/assets/ProjectScopePicker-zAgiaZKL.js +0 -1
  133. package/dist/web/assets/PublicProjectsPage-DL093f65.js +0 -1
  134. package/dist/web/assets/ReviewPage-D_NIhPx8.js +0 -1
  135. package/dist/web/assets/RoundtablesPage-CdVVyZE-.js +0 -1
  136. package/dist/web/assets/SettingsPage-BmYkLul4.css +0 -1
  137. package/dist/web/assets/SettingsPage-CbLaau8Q.js +0 -1
  138. package/dist/web/assets/SharedJefaReportPage-aygd6wvb.js +0 -1
  139. package/dist/web/assets/UiDialog-Q7fsJUoO.js +0 -1
  140. package/dist/web/assets/UiDisclosure-BS-muK5j.js +0 -1
  141. package/dist/web/assets/UiSegmented-CT28bxqw.js +0 -1
  142. package/dist/web/assets/UiSelect-DcGpPQQo.js +0 -1
  143. package/dist/web/assets/WritingTastePage-CYlwAFqV.js +0 -1
  144. package/dist/web/assets/client-DLS5FZBI.js +0 -1
  145. package/dist/web/assets/console-CqzwSo7a.js +0 -1
  146. package/dist/web/assets/conversation-launcher-settings-DRoIz42V.js +0 -1
  147. package/dist/web/assets/index-D1OHIz4c.js +0 -5
  148. package/dist/web/assets/index-kPB0Cf8Q.css +0 -1
  149. package/dist/web/assets/model-DupZy8Qb.js +0 -1
  150. package/dist/web/assets/shared-report-BYnLhmCZ.js +0 -1
  151. package/dist/web/assets/useAgentRoster-ep7SpKv1.js +0 -1
  152. package/dist/web/assets/vue-router-B6ZjK-Br.js +0 -1
@@ -211,11 +211,25 @@ async def reassign_knowledge_item(
211
211
  assignment_id = existing.id if existing else _stable_uuid(
212
212
  space['id'], 'knowledge-assignment', request.item_kind, item_id
213
213
  )
214
+ item_match = (
215
+ 'MATCH (item:Entity {uuid: $item_id, group_id: $group_id})'
216
+ if request.item_kind == 'entity'
217
+ else (
218
+ 'MATCH ()-[item:RELATES_TO '
219
+ '{uuid: $item_id, group_id: $group_id}]->()'
220
+ )
221
+ )
214
222
  records, _, _ = await store.runtime.driver.execute_query(
215
223
  '''
216
224
  MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
217
225
  MATCH (space)-[:CONTAINS_PROJECT]->
218
226
  (target:FuliPersonalProject {project_id: $target_project_id})
227
+ '''
228
+ + item_match
229
+ + '''
230
+ SET item.fuli_ai_review_lock_version =
231
+ coalesce(item.fuli_ai_review_lock_version, 0) + 1
232
+ WITH space, target
219
233
  MERGE (assignment:FuliKnowledgeAssignment {
220
234
  space_id: $space_id,
221
235
  item_kind: $item_kind,
@@ -236,6 +250,7 @@ async def reassign_knowledge_item(
236
250
  RETURN assignment
237
251
  ''',
238
252
  space_id=space['id'],
253
+ group_id=space['group_id'],
239
254
  target_project_id=request.target_project_id,
240
255
  item_kind=request.item_kind,
241
256
  item_id=item_id,
@@ -712,6 +727,8 @@ async def _update_item(store, space, item_id, item_kind, value, embedding):
712
727
  if item_kind == 'entity':
713
728
  query = '''
714
729
  MATCH (item:Entity {uuid: $item_id, group_id: $group_id})
730
+ SET item.fuli_ai_review_lock_version =
731
+ coalesce(item.fuli_ai_review_lock_version, 0) + 1
715
732
  SET item.name = $name,
716
733
  item.summary = $summary,
717
734
  item.fuli_invalid_at = $invalid_at,
@@ -744,6 +761,8 @@ async def _update_item(store, space, item_id, item_kind, value, embedding):
744
761
  else:
745
762
  query = '''
746
763
  MATCH ()-[item:RELATES_TO {uuid: $item_id, group_id: $group_id}]->()
764
+ SET item.fuli_ai_review_lock_version =
765
+ coalesce(item.fuli_ai_review_lock_version, 0) + 1
747
766
  SET item.fact = $fact,
748
767
  item.invalid_at = $invalid_at,
749
768
  item.fuli_origin_quadrant = $origin_quadrant,
@@ -844,12 +863,16 @@ async def _update_preference_scope(
844
863
  if item_kind == 'entity':
845
864
  query = '''
846
865
  MATCH (item:Entity {uuid: $item_id, group_id: $group_id})
866
+ SET item.fuli_ai_review_lock_version =
867
+ coalesce(item.fuli_ai_review_lock_version, 0) + 1
847
868
  SET item.fuli_preference_scope = $scope,
848
869
  item.fuli_preference_project_id = $project_id
849
870
  '''
850
871
  else:
851
872
  query = '''
852
873
  MATCH ()-[item:RELATES_TO {uuid: $item_id, group_id: $group_id}]->()
874
+ SET item.fuli_ai_review_lock_version =
875
+ coalesce(item.fuli_ai_review_lock_version, 0) + 1
853
876
  SET item.fuli_preference_scope = $scope,
854
877
  item.fuli_preference_project_id = $project_id
855
878
  '''
@@ -4,6 +4,7 @@ from typing import Any
4
4
  from fastapi import HTTPException
5
5
 
6
6
  from .knowledge_review_models import (
7
+ KnowledgeReviewAIAssessment,
7
8
  KnowledgeReviewCandidate,
8
9
  KnowledgeReviewCandidatePage,
9
10
  KnowledgeReviewCandidateRequest,
@@ -13,8 +14,10 @@ from .knowledge_review_models import (
13
14
  KnowledgeReviewRun,
14
15
  KnowledgeReviewStart,
15
16
  )
17
+ from .ai_review import ai_review_evidence_token
16
18
  from .personal_project_access import authorize_personal_project
17
19
  from .provider_values import native_datetime, stable_uuid
20
+ from .store_transactions import query_store_transaction
18
21
 
19
22
  # These are Provider ranking conventions, not product requirements. Keeping them
20
23
  # here gives every Agent the same deterministic policy and makes tuning explicit.
@@ -37,6 +40,8 @@ async def start_knowledge_review(
37
40
  store, actor, space, request.personal_project_id
38
41
  )
39
42
  scope_key = _scope_key(request.scope, request.personal_project_id)
43
+ if request.reviewer == 'tonborg':
44
+ scope_key += ':tonborg'
40
45
  changed_at = started_at or datetime.now(timezone.utc)
41
46
  active_records, _, _ = await store.runtime.driver.execute_query(
42
47
  '''
@@ -84,6 +89,10 @@ async def start_knowledge_review(
84
89
  completed_records[0].get('previous_completed_at')
85
90
  if completed_records else None
86
91
  )
92
+ # Tonborg has its own paginated queue and versioned decision ledger. It must
93
+ # not advance the human review watermark or lose items beyond one batch.
94
+ if request.reviewer == 'tonborg':
95
+ previous_completed_at = None
87
96
  review_id = stable_uuid(
88
97
  space['id'], 'knowledge-review', scope_key, changed_at.isoformat()
89
98
  )
@@ -96,6 +105,7 @@ async def start_knowledge_review(
96
105
  run.scope = $scope,
97
106
  run.personal_project_id = $personal_project_id,
98
107
  run.scope_key = $scope_key,
108
+ run.reviewer = $reviewer,
99
109
  run.status = 'active',
100
110
  run.previous_completed_at = $previous_completed_at,
101
111
  run.review_cutoff_at = $started_at,
@@ -112,6 +122,7 @@ async def start_knowledge_review(
112
122
  scope=request.scope,
113
123
  personal_project_id=request.personal_project_id,
114
124
  scope_key=scope_key,
125
+ reviewer=request.reviewer,
115
126
  previous_completed_at=previous_completed_at,
116
127
  started_at=changed_at,
117
128
  )
@@ -148,6 +159,7 @@ async def list_knowledge_review_candidates(
148
159
  conflict_rows, _, _ = await store.runtime.driver.execute_query(
149
160
  _CONFLICT_ITEM_QUERY,
150
161
  space_id=space['id'],
162
+ project_id=run.personal_project_id,
151
163
  routing_='r',
152
164
  )
153
165
  decision_rows, _, _ = await store.runtime.driver.execute_query(
@@ -187,12 +199,12 @@ async def list_knowledge_review_candidates(
187
199
  if key
188
200
  },
189
201
  )
190
- selected = candidates[:request.limit]
202
+ selected = candidates[request.offset:request.offset + request.limit]
191
203
  return KnowledgeReviewCandidatePage(
192
204
  review=run,
193
205
  candidates=selected,
194
206
  total_candidate_count=len(candidates),
195
- remaining_candidate_count=max(len(candidates) - len(selected), 0),
207
+ remaining_candidate_count=max(len(candidates) - request.offset - len(selected), 0),
196
208
  )
197
209
 
198
210
 
@@ -204,7 +216,7 @@ async def record_knowledge_review_progress(
204
216
  changed_at: datetime | None = None,
205
217
  ) -> KnowledgeReviewDecision:
206
218
  store._require_personal()
207
- await _personal_space(store, actor, request.personal_space_id, 'maintainer')
219
+ space = await _personal_space(store, actor, request.personal_space_id, 'maintainer')
208
220
  updated_at = changed_at or datetime.now(timezone.utc)
209
221
  decision_id = stable_uuid(
210
222
  request.personal_space_id,
@@ -212,6 +224,15 @@ async def record_knowledge_review_progress(
212
224
  request.review_id,
213
225
  request.candidate_key,
214
226
  )
227
+ if request.outcome == 'ai_reviewed':
228
+ return await _record_ai_review_progress(
229
+ store,
230
+ space,
231
+ request,
232
+ decision_id=decision_id,
233
+ updated_at=updated_at,
234
+ )
235
+
215
236
  records, _, _ = await store.runtime.driver.execute_query(
216
237
  '''
217
238
  MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
@@ -241,6 +262,97 @@ async def record_knowledge_review_progress(
241
262
  return _review_decision(dict(records[0]['decision']))
242
263
 
243
264
 
265
+ async def _record_ai_review_progress(
266
+ store,
267
+ space: dict,
268
+ request: KnowledgeReviewProgress,
269
+ *,
270
+ decision_id: str,
271
+ updated_at: datetime,
272
+ ) -> KnowledgeReviewDecision:
273
+ """CAS an AI result against a locked, freshly-read item snapshot."""
274
+
275
+ item_kind, separator, item_id = request.candidate_key.partition(':')
276
+ if not separator or item_kind not in {'entity', 'relationship'} or not item_id:
277
+ raise HTTPException(status_code=422, detail='invalid review candidate')
278
+
279
+ async with query_store_transaction(store) as scoped:
280
+ run_records, _, _ = await scoped.runtime.driver.execute_query(
281
+ _AI_REVIEW_RUN_QUERY,
282
+ space_id=space['id'],
283
+ review_id=request.review_id,
284
+ )
285
+ if not run_records:
286
+ raise HTTPException(status_code=404, detail='active knowledge review not found')
287
+ run = dict(run_records[0]['run'])
288
+ if run.get('reviewer') != 'tonborg' and not str(
289
+ run.get('scope_key', '')
290
+ ).endswith(':tonborg'):
291
+ raise HTTPException(
292
+ status_code=403,
293
+ detail='AI review progress is only valid for a Tonborg review run',
294
+ )
295
+ if run.get('status') not in {'active', 'paused'}:
296
+ raise HTTPException(status_code=404, detail='active knowledge review not found')
297
+
298
+ current_records, _, _ = await scoped.runtime.driver.execute_query(
299
+ _AI_REVIEW_CURRENT_ENTITY_QUERY
300
+ if item_kind == 'entity' else _AI_REVIEW_CURRENT_RELATIONSHIP_QUERY,
301
+ space_id=space['id'],
302
+ group_id=space['group_id'],
303
+ item_id=item_id,
304
+ project_id=run.get('personal_project_id'),
305
+ )
306
+ if not current_records:
307
+ raise HTTPException(status_code=404, detail='review candidate not found')
308
+ current = dict(current_records[0])
309
+ normalized = _normalize_item_row(current)
310
+ if not _item_in_scope(
311
+ normalized,
312
+ run['scope'],
313
+ run.get('personal_project_id'),
314
+ ):
315
+ raise HTTPException(
316
+ status_code=409,
317
+ detail='review candidate is outside the current project scope',
318
+ )
319
+ has_conflict = bool(current.get('has_conflict'))
320
+ current_token = ai_review_evidence_token(
321
+ normalized,
322
+ scope=run['scope'],
323
+ personal_project_id=run.get('personal_project_id'),
324
+ has_conflict=has_conflict,
325
+ )
326
+ if current_token != request.ai_review_evidence_token:
327
+ raise HTTPException(
328
+ status_code=409,
329
+ detail='knowledge review evidence changed; reload candidate',
330
+ )
331
+
332
+ assessment_json = request.ai_assessment.model_dump_json()
333
+ write_query = (
334
+ _AI_REVIEW_ENTITY_WRITE_QUERY
335
+ if item_kind == 'entity' else _AI_REVIEW_RELATIONSHIP_WRITE_QUERY
336
+ )
337
+ records, _, _ = await scoped.runtime.driver.execute_query(
338
+ write_query,
339
+ space_id=space['id'],
340
+ group_id=space['group_id'],
341
+ item_id=item_id,
342
+ review_id=request.review_id,
343
+ decision_id=decision_id,
344
+ candidate_key=request.candidate_key,
345
+ outcome=request.outcome,
346
+ note=request.note,
347
+ ai_review_evidence_token=current_token,
348
+ ai_assessment_json=assessment_json,
349
+ updated_at=updated_at,
350
+ )
351
+ if not records:
352
+ raise HTTPException(status_code=404, detail='active knowledge review not found')
353
+ return _review_decision(dict(records[0]['decision']))
354
+
355
+
244
356
  async def finish_knowledge_review(
245
357
  store,
246
358
  actor: dict,
@@ -300,6 +412,12 @@ def build_review_candidates(
300
412
  ):
301
413
  continue
302
414
  normalized['_candidate_key'] = candidate_key
415
+ normalized['ai_review_evidence_token'] = ai_review_evidence_token(
416
+ normalized,
417
+ scope=scope,
418
+ personal_project_id=personal_project_id,
419
+ has_conflict=candidate_key in conflict_item_keys,
420
+ )
303
421
  prepared.append(normalized)
304
422
 
305
423
  # One stable entity usually accumulates several sessions, while repeated
@@ -389,6 +507,10 @@ def _review_run(value: dict, *, resumed: bool = False) -> KnowledgeReviewRun:
389
507
  scope=value['scope'],
390
508
  personal_project_id=value.get('personal_project_id'),
391
509
  scope_key=value['scope_key'],
510
+ reviewer=value.get('reviewer') or (
511
+ 'tonborg' if str(value.get('scope_key', '')).endswith(':tonborg')
512
+ else 'human'
513
+ ),
392
514
  status=value['status'],
393
515
  previous_completed_at=native_datetime(value.get('previous_completed_at')),
394
516
  review_cutoff_at=native_datetime(
@@ -402,6 +524,17 @@ def _review_run(value: dict, *, resumed: bool = False) -> KnowledgeReviewRun:
402
524
 
403
525
 
404
526
  def _review_decision(value: dict) -> KnowledgeReviewDecision:
527
+ assessment = value.get('ai_assessment')
528
+ if assessment is None:
529
+ assessment = value.get('ai_assessment_json')
530
+ if isinstance(assessment, str):
531
+ try:
532
+ import json
533
+ assessment = json.loads(assessment)
534
+ except (TypeError, ValueError):
535
+ assessment = None
536
+ if assessment is not None:
537
+ assessment = KnowledgeReviewAIAssessment.model_validate(assessment)
405
538
  return KnowledgeReviewDecision(
406
539
  decision_id=value['id'],
407
540
  review_id=value['review_id'],
@@ -410,6 +543,10 @@ def _review_decision(value: dict) -> KnowledgeReviewDecision:
410
543
  note=value.get('note'),
411
544
  created_at=native_datetime(value['created_at']),
412
545
  updated_at=native_datetime(value['updated_at']),
546
+ ai_review_evidence_token=value.get('ai_review_evidence_token'),
547
+ ai_assessment=assessment,
548
+ ai_review_scope=value.get('ai_review_scope'),
549
+ ai_review_project_id=value.get('ai_review_project_id'),
413
550
  )
414
551
 
415
552
 
@@ -420,7 +557,12 @@ def _scope_key(scope: str, personal_project_id: str | None) -> str:
420
557
  def _normalize_item_row(row: dict[str, Any]) -> dict[str, Any]:
421
558
  project_ids = sorted({
422
559
  value
423
- for key in ('project_ids', 'assigned_project_ids', 'evidence_project_ids')
560
+ for key in (
561
+ 'project_ids',
562
+ 'assigned_project_ids',
563
+ 'reference_project_ids',
564
+ 'evidence_project_ids',
565
+ )
424
566
  for value in (row.get(key) or [])
425
567
  if value
426
568
  })
@@ -439,8 +581,11 @@ def _normalize_item_row(row: dict[str, Any]) -> dict[str, Any]:
439
581
  'profile_aspect': row.get('profile_aspect'),
440
582
  'preference_scope': row.get('preference_scope'),
441
583
  'preference_project_id': row.get('preference_project_id'),
584
+ 'preference_agent_id': row.get('preference_agent_id'),
585
+ 'inheritance_mode': row.get('inheritance_mode') or 'local_only',
442
586
  'project_ids': project_ids,
443
587
  'confirmation_status': row.get('confirmation_status') or 'pending',
588
+ 'confirmation_basis_json': row.get('confirmation_basis_json'),
444
589
  'current_quadrant': row.get('current_quadrant') or 'known_known',
445
590
  'utility_score': _float_or_default(row.get('utility_score'), 0),
446
591
  'confidence_score': _float_or_default(row.get('confidence_score'), 0.5),
@@ -451,6 +596,10 @@ def _normalize_item_row(row: dict[str, Any]) -> dict[str, Any]:
451
596
  'last_feedback_kind': row.get('last_feedback_kind'),
452
597
  'distinct_session_count': len(session_ids),
453
598
  'changed_at': changed_at,
599
+ 'valid_at': row.get('valid_at'),
600
+ 'invalid_at': row.get('invalid_at'),
601
+ 'human_change_version': int(row.get('human_change_version') or 0),
602
+ 'attributes_json': row.get('attributes_json'),
454
603
  '_session_ids': session_ids,
455
604
  }
456
605
 
@@ -550,6 +699,10 @@ OPTIONAL MATCH (episode:Episodic {group_id: $group_id})-[:MENTIONS]->(item)
550
699
  OPTIONAL MATCH (assignment:FuliKnowledgeAssignment {
551
700
  space_id: $space_id, item_kind: 'entity', item_id: item.uuid
552
701
  })
702
+ OPTIONAL MATCH (:FuliSpace {id: $space_id})-[:HAS_KNOWLEDGE_REFERENCE]->
703
+ (reference:FuliKnowledgeProjectReference {
704
+ item_kind: 'entity', item_id: item.uuid, status: 'active'
705
+ })
553
706
  OPTIONAL MATCH (:FuliSpace {id: $space_id})-[:HAS_KNOWLEDGE_REVISION]->
554
707
  (revision:FuliKnowledgeRevision {item_kind: 'entity', item_id: item.uuid})
555
708
  RETURN item.uuid AS item_id,
@@ -559,12 +712,20 @@ RETURN item.uuid AS item_id,
559
712
  item.fuli_profile_aspect AS profile_aspect,
560
713
  item.fuli_preference_scope AS preference_scope,
561
714
  item.fuli_preference_project_id AS preference_project_id,
715
+ item.fuli_preference_agent_id AS preference_agent_id,
716
+ coalesce(item.fuli_inheritance_mode, 'local_only') AS inheritance_mode,
562
717
  coalesce(
563
718
  item.fuli_current_quadrant,
564
719
  item.fuli_origin_quadrant,
565
720
  'known_known'
566
721
  ) AS current_quadrant,
722
+ item.fuli_confirmation_basis_json AS confirmation_basis_json,
723
+ NULL AS valid_at,
724
+ item.fuli_invalid_at AS invalid_at,
725
+ coalesce(item.fuli_human_change_version, 0) AS human_change_version,
726
+ coalesce(item.fuli_attributes_json, '{}') AS attributes_json,
567
727
  collect(DISTINCT assignment.project_id) AS assigned_project_ids,
728
+ collect(DISTINCT reference.project_id) AS reference_project_ids,
568
729
  collect(DISTINCT episode.fuli_personal_project_id) AS evidence_project_ids,
569
730
  collect(DISTINCT episode.fuli_session_id) AS session_ids,
570
731
  coalesce(item.fuli_confirmation_status, 'pending') AS confirmation_status,
@@ -590,6 +751,10 @@ WHERE episode.uuid IN coalesce(item.episodes, [])
590
751
  OPTIONAL MATCH (assignment:FuliKnowledgeAssignment {
591
752
  space_id: $space_id, item_kind: 'relationship', item_id: item.uuid
592
753
  })
754
+ OPTIONAL MATCH (:FuliSpace {id: $space_id})-[:HAS_KNOWLEDGE_REFERENCE]->
755
+ (reference:FuliKnowledgeProjectReference {
756
+ item_kind: 'relationship', item_id: item.uuid, status: 'active'
757
+ })
593
758
  OPTIONAL MATCH (:FuliSpace {id: $space_id})-[:HAS_KNOWLEDGE_REVISION]->
594
759
  (revision:FuliKnowledgeRevision {
595
760
  item_kind: 'relationship', item_id: item.uuid
@@ -601,12 +766,20 @@ RETURN item.uuid AS item_id,
601
766
  item.fuli_profile_aspect AS profile_aspect,
602
767
  item.fuli_preference_scope AS preference_scope,
603
768
  item.fuli_preference_project_id AS preference_project_id,
769
+ item.fuli_preference_agent_id AS preference_agent_id,
770
+ coalesce(item.fuli_inheritance_mode, 'local_only') AS inheritance_mode,
604
771
  coalesce(
605
772
  item.fuli_current_quadrant,
606
773
  item.fuli_origin_quadrant,
607
774
  'known_known'
608
775
  ) AS current_quadrant,
776
+ item.fuli_confirmation_basis_json AS confirmation_basis_json,
777
+ item.valid_at AS valid_at,
778
+ item.invalid_at AS invalid_at,
779
+ coalesce(item.fuli_human_change_version, 0) AS human_change_version,
780
+ coalesce(item.fuli_attributes_json, '{}') AS attributes_json,
609
781
  collect(DISTINCT assignment.project_id) AS assigned_project_ids,
782
+ collect(DISTINCT reference.project_id) AS reference_project_ids,
610
783
  collect(DISTINCT episode.fuli_personal_project_id) AS evidence_project_ids,
611
784
  collect(DISTINCT episode.fuli_session_id) AS session_ids,
612
785
  coalesce(item.fuli_confirmation_status, 'pending') AS confirmation_status,
@@ -628,6 +801,9 @@ _CONFLICT_ITEM_QUERY = '''
628
801
  MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
629
802
  OPTIONAL MATCH (space)-[:HAS_KNOWLEDGE_CONFLICT]->
630
803
  (knowledge:FuliKnowledgeConflict {status: 'pending'})
804
+ WHERE ($project_id IS NULL
805
+ OR knowledge.target_project_id = $project_id
806
+ OR knowledge.source_project_id = $project_id)
631
807
  WITH space, collect(DISTINCT 'entity:' + knowledge.item_id) +
632
808
  collect(DISTINCT 'relationship:' + knowledge.item_id) +
633
809
  collect(DISTINCT 'entity:' + knowledge.target_item_id) +
@@ -635,6 +811,7 @@ WITH space, collect(DISTINCT 'entity:' + knowledge.item_id) +
635
811
  AS knowledge_keys
636
812
  OPTIONAL MATCH (space)-[:HAS_PREFERENCE_CONFLICT]->
637
813
  (preference:FuliPreferenceConflict {status: 'ai_pending'})
814
+ WHERE $project_id IS NULL OR preference.preference_project_id = $project_id
638
815
  WITH knowledge_keys,
639
816
  collect(DISTINCT preference.left_item_kind + ':' + preference.left_item_id) +
640
817
  collect(DISTINCT preference.right_item_kind + ':' + preference.right_item_id)
@@ -655,3 +832,205 @@ WITH candidate_key, collect(decision)[0] AS latest_decision
655
832
  WHERE latest_decision.outcome = 'deferred'
656
833
  RETURN collect(candidate_key) AS candidate_keys
657
834
  '''
835
+
836
+
837
+ # These queries intentionally perform a property write before the dependent
838
+ # read. Neo4j then holds a write lock on the item until the surrounding
839
+ # query_store_transaction commits or rolls back, closing the read/decision CAS
840
+ # window even when a knowledge edit has not yet written its revision metadata.
841
+ _AI_REVIEW_CURRENT_ENTITY_QUERY = '''
842
+ MATCH (item:Entity {uuid: $item_id, group_id: $group_id})
843
+ SET item.fuli_ai_review_lock_version =
844
+ coalesce(item.fuli_ai_review_lock_version, 0) + 1
845
+ WITH item
846
+ OPTIONAL MATCH (episode:Episodic {group_id: $group_id})-[:MENTIONS]->(item)
847
+ OPTIONAL MATCH (assignment:FuliKnowledgeAssignment {
848
+ space_id: $space_id, item_kind: 'entity', item_id: item.uuid
849
+ })
850
+ OPTIONAL MATCH (:FuliSpace {id: $space_id})-[:HAS_KNOWLEDGE_REFERENCE]->
851
+ (reference:FuliKnowledgeProjectReference {
852
+ item_kind: 'entity', item_id: item.uuid, status: 'active'
853
+ })
854
+ WITH item,
855
+ collect(DISTINCT assignment.project_id) +
856
+ collect(DISTINCT reference.project_id) +
857
+ collect(DISTINCT episode.fuli_personal_project_id) AS project_ids
858
+ OPTIONAL MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
859
+ [:HAS_KNOWLEDGE_CONFLICT]->
860
+ (knowledge:FuliKnowledgeConflict {status: 'pending'})
861
+ WHERE (knowledge.item_id = item.uuid OR knowledge.target_item_id = item.uuid)
862
+ AND ($project_id IS NULL
863
+ OR knowledge.target_project_id = $project_id
864
+ OR knowledge.source_project_id = $project_id)
865
+ WITH item, project_ids, collect(DISTINCT knowledge.id) AS knowledge_conflicts
866
+ OPTIONAL MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
867
+ [:HAS_PREFERENCE_CONFLICT]->
868
+ (preference:FuliPreferenceConflict {status: 'ai_pending'})
869
+ WHERE (preference.left_item_id = item.uuid OR preference.right_item_id = item.uuid)
870
+ AND ($project_id IS NULL OR preference.preference_project_id = $project_id)
871
+ WITH item, project_ids, knowledge_conflicts,
872
+ collect(DISTINCT preference.id) AS preference_conflicts
873
+ RETURN item.uuid AS item_id,
874
+ 'entity' AS item_kind,
875
+ item.name AS title,
876
+ coalesce(item.summary, '') AS content,
877
+ item.fuli_profile_aspect AS profile_aspect,
878
+ item.fuli_preference_scope AS preference_scope,
879
+ item.fuli_preference_project_id AS preference_project_id,
880
+ item.fuli_preference_agent_id AS preference_agent_id,
881
+ coalesce(item.fuli_inheritance_mode, 'local_only') AS inheritance_mode,
882
+ coalesce(item.fuli_current_quadrant, item.fuli_origin_quadrant,
883
+ 'known_known') AS current_quadrant,
884
+ item.fuli_confirmation_status AS confirmation_status,
885
+ item.fuli_confirmation_basis_json AS confirmation_basis_json,
886
+ NULL AS valid_at,
887
+ item.fuli_invalid_at AS invalid_at,
888
+ coalesce(item.fuli_utility_score, 0.0) AS utility_score,
889
+ coalesce(item.fuli_confidence_score, 0.5) AS confidence_score,
890
+ coalesce(item.fuli_qualified_use_count, 0) AS qualified_use_count,
891
+ coalesce(item.fuli_distinct_task_count, 0) AS distinct_task_count,
892
+ coalesce(item.fuli_negative_evidence_count, 0) AS negative_evidence_count,
893
+ coalesce(item.fuli_requires_attention, false) AS requires_attention,
894
+ coalesce(item.fuli_human_change_version, 0) AS human_change_version,
895
+ coalesce(item.fuli_attributes_json, '{}') AS attributes_json,
896
+ project_ids,
897
+ CASE WHEN size(knowledge_conflicts) + size(preference_conflicts) > 0
898
+ THEN true ELSE false END AS has_conflict,
899
+ item.created_at AS created_at,
900
+ item.fuli_last_human_changed_at AS last_human_changed_at,
901
+ item.fuli_last_feedback_at AS last_feedback_at,
902
+ item.fuli_last_feedback_kind AS last_feedback_kind,
903
+ [] AS session_ids
904
+ '''
905
+
906
+
907
+ _AI_REVIEW_CURRENT_RELATIONSHIP_QUERY = '''
908
+ MATCH ()-[item:RELATES_TO {uuid: $item_id, group_id: $group_id}]->()
909
+ SET item.fuli_ai_review_lock_version =
910
+ coalesce(item.fuli_ai_review_lock_version, 0) + 1
911
+ WITH item
912
+ OPTIONAL MATCH (episode:Episodic {group_id: $group_id})
913
+ WHERE episode.uuid IN coalesce(item.episodes, [])
914
+ OPTIONAL MATCH (assignment:FuliKnowledgeAssignment {
915
+ space_id: $space_id, item_kind: 'relationship', item_id: item.uuid
916
+ })
917
+ OPTIONAL MATCH (:FuliSpace {id: $space_id})-[:HAS_KNOWLEDGE_REFERENCE]->
918
+ (reference:FuliKnowledgeProjectReference {
919
+ item_kind: 'relationship', item_id: item.uuid, status: 'active'
920
+ })
921
+ WITH item,
922
+ collect(DISTINCT assignment.project_id) +
923
+ collect(DISTINCT reference.project_id) +
924
+ collect(DISTINCT episode.fuli_personal_project_id) AS project_ids
925
+ OPTIONAL MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
926
+ [:HAS_KNOWLEDGE_CONFLICT]->
927
+ (knowledge:FuliKnowledgeConflict {status: 'pending'})
928
+ WHERE (knowledge.item_id = item.uuid OR knowledge.target_item_id = item.uuid)
929
+ AND ($project_id IS NULL
930
+ OR knowledge.target_project_id = $project_id
931
+ OR knowledge.source_project_id = $project_id)
932
+ WITH item, project_ids, collect(DISTINCT knowledge.id) AS knowledge_conflicts
933
+ OPTIONAL MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
934
+ [:HAS_PREFERENCE_CONFLICT]->
935
+ (preference:FuliPreferenceConflict {status: 'ai_pending'})
936
+ WHERE (preference.left_item_id = item.uuid OR preference.right_item_id = item.uuid)
937
+ AND ($project_id IS NULL OR preference.preference_project_id = $project_id)
938
+ WITH item, project_ids, knowledge_conflicts,
939
+ collect(DISTINCT preference.id) AS preference_conflicts
940
+ RETURN item.uuid AS item_id,
941
+ 'relationship' AS item_kind,
942
+ coalesce(item.name, 'RELATES_TO') AS title,
943
+ coalesce(item.fact, '') AS content,
944
+ item.fuli_profile_aspect AS profile_aspect,
945
+ item.fuli_preference_scope AS preference_scope,
946
+ item.fuli_preference_project_id AS preference_project_id,
947
+ item.fuli_preference_agent_id AS preference_agent_id,
948
+ coalesce(item.fuli_inheritance_mode, 'local_only') AS inheritance_mode,
949
+ coalesce(item.fuli_current_quadrant, item.fuli_origin_quadrant,
950
+ 'known_known') AS current_quadrant,
951
+ item.fuli_confirmation_status AS confirmation_status,
952
+ item.fuli_confirmation_basis_json AS confirmation_basis_json,
953
+ item.valid_at AS valid_at,
954
+ item.invalid_at AS invalid_at,
955
+ coalesce(item.fuli_utility_score, 0.0) AS utility_score,
956
+ coalesce(item.fuli_confidence_score, 0.5) AS confidence_score,
957
+ coalesce(item.fuli_qualified_use_count, 0) AS qualified_use_count,
958
+ coalesce(item.fuli_distinct_task_count, 0) AS distinct_task_count,
959
+ coalesce(item.fuli_negative_evidence_count, 0) AS negative_evidence_count,
960
+ coalesce(item.fuli_requires_attention, false) AS requires_attention,
961
+ coalesce(item.fuli_human_change_version, 0) AS human_change_version,
962
+ coalesce(item.fuli_attributes_json, '{}') AS attributes_json,
963
+ project_ids,
964
+ CASE WHEN size(knowledge_conflicts) + size(preference_conflicts) > 0
965
+ THEN true ELSE false END AS has_conflict,
966
+ item.created_at AS created_at,
967
+ item.fuli_last_human_changed_at AS last_human_changed_at,
968
+ item.fuli_last_feedback_at AS last_feedback_at,
969
+ item.fuli_last_feedback_kind AS last_feedback_kind,
970
+ [] AS session_ids
971
+ '''
972
+
973
+
974
+ _AI_REVIEW_RUN_QUERY = '''
975
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
976
+ [:HAS_KNOWLEDGE_REVIEW]->(run:FuliKnowledgeReviewRun {id: $review_id})
977
+ RETURN run
978
+ '''
979
+
980
+
981
+ _AI_REVIEW_ENTITY_WRITE_QUERY = '''
982
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
983
+ [:HAS_KNOWLEDGE_REVIEW]->(run:FuliKnowledgeReviewRun {id: $review_id})
984
+ MATCH (item:Entity {uuid: $item_id, group_id: $group_id})
985
+ WHERE run.status IN ['active', 'paused']
986
+ MERGE (decision:FuliKnowledgeReviewDecision {id: $decision_id})
987
+ ON CREATE SET decision.created_at = $updated_at
988
+ SET item.fuli_ai_review_evidence_token = $ai_review_evidence_token,
989
+ item.fuli_ai_assessment_json = $ai_assessment_json,
990
+ item.fuli_ai_review_scope = run.scope,
991
+ item.fuli_ai_review_project_id = run.personal_project_id,
992
+ item.fuli_ai_reviewed_at = $updated_at,
993
+ item.fuli_ai_review_id = $review_id,
994
+ item.fuli_ai_review_decision_id = $decision_id,
995
+ decision.review_id = $review_id,
996
+ decision.candidate_key = $candidate_key,
997
+ decision.outcome = $outcome,
998
+ decision.note = $note,
999
+ decision.ai_review_evidence_token = $ai_review_evidence_token,
1000
+ decision.ai_assessment_json = $ai_assessment_json,
1001
+ decision.ai_review_scope = run.scope,
1002
+ decision.ai_review_project_id = run.personal_project_id,
1003
+ decision.updated_at = $updated_at,
1004
+ run.updated_at = $updated_at
1005
+ MERGE (run)-[:HAS_REVIEW_DECISION]->(decision)
1006
+ RETURN decision
1007
+ '''
1008
+
1009
+
1010
+ _AI_REVIEW_RELATIONSHIP_WRITE_QUERY = '''
1011
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
1012
+ [:HAS_KNOWLEDGE_REVIEW]->(run:FuliKnowledgeReviewRun {id: $review_id})
1013
+ MATCH ()-[item:RELATES_TO {uuid: $item_id, group_id: $group_id}]->()
1014
+ WHERE run.status IN ['active', 'paused']
1015
+ MERGE (decision:FuliKnowledgeReviewDecision {id: $decision_id})
1016
+ ON CREATE SET decision.created_at = $updated_at
1017
+ SET item.fuli_ai_review_evidence_token = $ai_review_evidence_token,
1018
+ item.fuli_ai_assessment_json = $ai_assessment_json,
1019
+ item.fuli_ai_review_scope = run.scope,
1020
+ item.fuli_ai_review_project_id = run.personal_project_id,
1021
+ item.fuli_ai_reviewed_at = $updated_at,
1022
+ item.fuli_ai_review_id = $review_id,
1023
+ item.fuli_ai_review_decision_id = $decision_id,
1024
+ decision.review_id = $review_id,
1025
+ decision.candidate_key = $candidate_key,
1026
+ decision.outcome = $outcome,
1027
+ decision.note = $note,
1028
+ decision.ai_review_evidence_token = $ai_review_evidence_token,
1029
+ decision.ai_assessment_json = $ai_assessment_json,
1030
+ decision.ai_review_scope = run.scope,
1031
+ decision.ai_review_project_id = run.personal_project_id,
1032
+ decision.updated_at = $updated_at,
1033
+ run.updated_at = $updated_at
1034
+ MERGE (run)-[:HAS_REVIEW_DECISION]->(decision)
1035
+ RETURN decision
1036
+ '''