fuli-context 0.5.0 → 0.7.3

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 (206) hide show
  1. package/README.md +370 -202
  2. package/README.zh-CN.md +448 -0
  3. package/compose.graphiti.yml +6 -6
  4. package/dist/web/assets/AboutPage-DXh7Fwxt.css +1 -0
  5. package/dist/web/assets/AboutPage-qeO_vm0T.js +1 -0
  6. package/dist/web/assets/ConnectionsPage-CBHAdZXT.css +1 -0
  7. package/dist/web/assets/ConnectionsPage-DO0GR9Xg.js +1 -0
  8. package/dist/web/assets/KnowledgeInspector-Bjhdu2mf.css +1 -0
  9. package/dist/web/assets/KnowledgeInspector-DEW5lA1m.js +1 -0
  10. package/dist/web/assets/KnowledgeOrganizerPage-BkM568Ld.js +1 -0
  11. package/dist/web/assets/KnowledgeOrganizerPage-Cx1w1jb2.css +1 -0
  12. package/dist/web/assets/KnowledgePage-BAT4aZIm.js +1 -0
  13. package/dist/web/assets/KnowledgeWorkspace-DyGtBz--.js +4 -0
  14. package/dist/web/assets/OverviewPage-CmCcDCAs.js +1 -0
  15. package/dist/web/assets/PersonalProfilePage-C42mwKN8.js +1 -0
  16. package/dist/web/assets/{PersonalProfilePage-S8ZcG2LU.css → PersonalProfilePage-CdmEzyvZ.css} +1 -1
  17. package/dist/web/assets/PersonalProjectsPage-BwaXR62K.js +1 -0
  18. package/dist/web/assets/PublicProjectsPage-egKBulOq.js +1 -0
  19. package/dist/web/assets/ReviewPage-B3vnZ6gn.js +1 -0
  20. package/dist/web/assets/SearchableSelect-ih69qbcS.js +1 -0
  21. package/dist/web/assets/SettingsPage-C6ouU0sR.css +1 -0
  22. package/dist/web/assets/SettingsPage-oLE8mNs5.js +1 -0
  23. package/dist/web/assets/_plugin-vue_export-helper-BDNMzG2s.js +1 -0
  24. package/dist/web/assets/console-DE40hXGC.js +1 -0
  25. package/dist/web/assets/fuli-logo-Bu9WBuQe.js +1 -0
  26. package/dist/web/assets/fuli-logo-ER1wQ8Kk.png +0 -0
  27. package/dist/web/assets/i18n-CDxBs1sz.js +5 -0
  28. package/dist/web/assets/identity-BCxb1mcy.js +1 -0
  29. package/dist/web/assets/index-BzhNfzU_.js +2 -0
  30. package/dist/web/assets/index-CCOGfZzE.css +1 -0
  31. package/dist/web/assets/model-CCMdt10Q.js +1 -0
  32. package/dist/web/assets/paths-DwHeiGMT.js +1 -0
  33. package/dist/web/assets/useMinimumLoadingDisplay-BpCyBeko.css +1 -0
  34. package/dist/web/assets/useMinimumLoadingDisplay-HdI7XAmu.js +1 -0
  35. package/dist/web/assets/vue-router-Drfl1F8i.js +1 -0
  36. package/dist/web/index.html +9 -6
  37. package/docs/external-knowledge-architecture.md +279 -0
  38. package/examples/external-knowledge/markdown-folder.mjs +235 -0
  39. package/graph-provider/fuli_graph/app.py +57 -1
  40. package/graph-provider/fuli_graph/graph_models.py +3 -0
  41. package/graph-provider/fuli_graph/graph_query.py +62 -16
  42. package/graph-provider/fuli_graph/knowledge_review.py +657 -0
  43. package/graph-provider/fuli_graph/knowledge_review_models.py +133 -0
  44. package/graph-provider/fuli_graph/knowledge_search.py +51 -4
  45. package/graph-provider/fuli_graph/models.py +19 -0
  46. package/graph-provider/fuli_graph/runtime.py +15 -0
  47. package/graph-provider/fuli_graph/store_knowledge.py +2 -1
  48. package/graph-provider/pyproject.toml +1 -1
  49. package/npm-shrinkwrap.json +5 -407
  50. package/package.json +7 -5
  51. package/skills/capturing-session-knowledge/SKILL.md +24 -4
  52. package/skills/flreview/SKILL.md +100 -0
  53. package/skills/flreview/agents/openai.yaml +6 -0
  54. package/src/agent-tools/graph-definitions.js +81 -6
  55. package/src/agent-tools/graph-handlers.js +12 -0
  56. package/src/cli/command-registry.js +3 -3
  57. package/src/cli/local-runtime-command.js +51 -28
  58. package/src/cli/local-runtime-options.js +14 -8
  59. package/src/cli/setup-agent-selection.js +12 -12
  60. package/src/cli/setup-command.js +41 -18
  61. package/src/cli/shell-argument.js +10 -0
  62. package/src/cli/uninstall-command.js +17 -15
  63. package/src/cli/update-command.js +38 -30
  64. package/src/external-knowledge/catalog.js +61 -0
  65. package/src/external-knowledge/conflict-policy.js +58 -0
  66. package/src/external-knowledge/connected-search.js +166 -0
  67. package/src/external-knowledge/connectors/custom.js +133 -0
  68. package/src/external-knowledge/connectors/feishu.js +461 -0
  69. package/src/external-knowledge/connectors/mcp.js +438 -0
  70. package/src/external-knowledge/connectors/notion.js +307 -0
  71. package/src/external-knowledge/connectors/retrieval-api.js +222 -0
  72. package/src/external-knowledge/document-mapping.js +151 -0
  73. package/src/external-knowledge/graph-projection.js +17 -0
  74. package/src/external-knowledge/index.js +8 -0
  75. package/src/external-knowledge/registry.js +91 -0
  76. package/src/external-knowledge/runtime.js +68 -0
  77. package/src/external-knowledge/safe-diagnostic.js +14 -0
  78. package/src/external-knowledge/service.js +811 -0
  79. package/src/graphiti/agent-knowledge-workflows.js +7 -2
  80. package/src/graphiti/federated-application.js +68 -42
  81. package/src/graphiti/knowledge-item-role.js +21 -0
  82. package/src/graphiti/knowledge-review.js +43 -0
  83. package/src/graphiti/project-profile-mapping.js +32 -0
  84. package/src/graphiti/provider-client.js +22 -1
  85. package/src/graphiti/source-marker.js +54 -3
  86. package/src/graphiti/task-knowledge-recall.js +250 -0
  87. package/src/http/api-router.js +23 -108
  88. package/src/http/external-knowledge-api-router.js +104 -0
  89. package/src/http/graph-api-router.js +12 -1
  90. package/src/http/request-policy.js +1 -1
  91. package/src/local-runtime/lifecycle.js +26 -15
  92. package/src/mcp/create-mcp-server.js +4 -1
  93. package/src/mcp/instructions.js +7 -7
  94. package/src/mcp/tool-annotations.js +6 -0
  95. package/src/mcp/tool-result.js +1 -0
  96. package/src/server/application-lifecycle.js +2 -11
  97. package/src/server/blocked-ports.js +11 -0
  98. package/src/server.js +36 -17
  99. package/src/setup/agent-installation-status.js +2 -1
  100. package/src/setup/agents.js +2 -1
  101. package/src/setup/claude-code-config.js +2 -1
  102. package/src/setup/codex-bootstrap.js +12 -7
  103. package/src/setup/container-runtime.js +25 -21
  104. package/src/setup/graph-runtime.js +148 -56
  105. package/src/setup/node-runtime.js +3 -3
  106. package/src/setup/options.js +2 -4
  107. package/src/setup/paths.js +18 -3
  108. package/src/setup/review-skill-adapters.js +49 -0
  109. package/src/setup/setup.js +26 -2
  110. package/src/setup/uninstall.js +4 -2
  111. package/src/system/resource-monitor.js +317 -0
  112. package/src/system/runtime-settings.js +131 -0
  113. package/src/system/system-service.js +119 -0
  114. package/README.en.md +0 -336
  115. package/dist/web/assets/ConnectionsPage-CCKWm-6p.js +0 -1
  116. package/dist/web/assets/KnowledgeInspector-BZkB2wJn.js +0 -1
  117. package/dist/web/assets/KnowledgeInspector-CrGYxEob.css +0 -1
  118. package/dist/web/assets/KnowledgeOrganizerPage-CZAF_OlN.css +0 -1
  119. package/dist/web/assets/KnowledgeOrganizerPage-DlvcLwlL.js +0 -1
  120. package/dist/web/assets/KnowledgePage-CNQXd15y.js +0 -1
  121. package/dist/web/assets/KnowledgeWorkspace-CXPE4GCD.js +0 -4
  122. package/dist/web/assets/OverviewPage-ul7g7HXh.js +0 -1
  123. package/dist/web/assets/PersonalProfilePage-CC40Pw2L.js +0 -1
  124. package/dist/web/assets/PersonalProjectsPage-CPIEcShD.js +0 -1
  125. package/dist/web/assets/PublicProjectsPage-C700sR7z.js +0 -1
  126. package/dist/web/assets/ReviewPage-7MDDUDgA.js +0 -1
  127. package/dist/web/assets/console-B_Ena2DS.js +0 -5
  128. package/dist/web/assets/fuli-logo-DGuyqchQ.png +0 -0
  129. package/dist/web/assets/identity-CjBwYSMX.js +0 -1
  130. package/dist/web/assets/index-CpLDx3of.css +0 -1
  131. package/dist/web/assets/index-DAzeFJSc.js +0 -2
  132. package/dist/web/assets/model-C-5RVIJ2.js +0 -1
  133. package/dist/web/assets/paths-BkULqmGI.js +0 -1
  134. package/dist/web/assets/vue-router-DnD3hUm7.js +0 -1
  135. package/src/agent/candidate-read-service.js +0 -53
  136. package/src/agent/context-pack-service.js +0 -83
  137. package/src/agent/public-read-policy.js +0 -39
  138. package/src/agent/public-read-service.js +0 -69
  139. package/src/agent/read-projection.js +0 -107
  140. package/src/agent/search-service.js +0 -61
  141. package/src/agent/space-boundary.js +0 -33
  142. package/src/app/bootstrap.js +0 -8
  143. package/src/app/create-application.js +0 -143
  144. package/src/app/state-service.js +0 -38
  145. package/src/candidates.js +0 -78
  146. package/src/classifier.js +0 -63
  147. package/src/cli/command-arguments.js +0 -14
  148. package/src/context-pack.js +0 -206
  149. package/src/extractor.js +0 -77
  150. package/src/fact-history.js +0 -39
  151. package/src/facts/fact-service.js +0 -89
  152. package/src/http/bounded-json-array.js +0 -19
  153. package/src/http/lens-route.js +0 -50
  154. package/src/ingestion.js +0 -161
  155. package/src/lens/lens-correction-service.js +0 -81
  156. package/src/lens/lens-fact-writer.js +0 -85
  157. package/src/lens/lens-input.js +0 -62
  158. package/src/lens/lens-query-projection.js +0 -87
  159. package/src/lens/lens-query-ranking.js +0 -62
  160. package/src/lens/lens-query.js +0 -143
  161. package/src/lens/lens-resource-projection.js +0 -93
  162. package/src/lens/lens-resource-selection.js +0 -45
  163. package/src/lens/lens-resource-service.js +0 -103
  164. package/src/lens/lens-search-query.js +0 -122
  165. package/src/lens/lens-service.js +0 -183
  166. package/src/models.js +0 -44
  167. package/src/observer.js +0 -83
  168. package/src/project-rules.js +0 -33
  169. package/src/publication/outbox-service.js +0 -46
  170. package/src/publication/publication-envelope-schema.js +0 -75
  171. package/src/publication/publication-envelope.js +0 -37
  172. package/src/publication/publication-policy.js +0 -82
  173. package/src/publication/publication-service.js +0 -48
  174. package/src/router.js +0 -58
  175. package/src/runtime-options.js +0 -138
  176. package/src/security/episode-visibility.js +0 -14
  177. package/src/security/fact-visibility.js +0 -15
  178. package/src/security/source-metadata-policy.js +0 -8
  179. package/src/storage/episode-evidence-preview.js +0 -100
  180. package/src/storage/fact-search-order.js +0 -18
  181. package/src/storage/file/episode-evidence.js +0 -37
  182. package/src/storage/file/episode-index.js +0 -59
  183. package/src/storage/file/fact-search.js +0 -60
  184. package/src/storage/file-store.js +0 -437
  185. package/src/storage/migrate.js +0 -128
  186. package/src/storage/migrations/001-initial.sql +0 -83
  187. package/src/storage/migrations/002-storage-integrity.sql +0 -113
  188. package/src/storage/record-validation.js +0 -46
  189. package/src/storage/snapshot-defaults.js +0 -54
  190. package/src/storage/sqlite/candidates-repository.js +0 -84
  191. package/src/storage/sqlite/episode-evidence-repository.js +0 -154
  192. package/src/storage/sqlite/episode-privacy-sql.js +0 -33
  193. package/src/storage/sqlite/episodes-repository.js +0 -52
  194. package/src/storage/sqlite/fact-search-repository.js +0 -162
  195. package/src/storage/sqlite/facts-repository.js +0 -146
  196. package/src/storage/sqlite/imports-repository.js +0 -39
  197. package/src/storage/sqlite/mapper.js +0 -84
  198. package/src/storage/sqlite/open-database.js +0 -15
  199. package/src/storage/sqlite/outbox-repository.js +0 -103
  200. package/src/storage/sqlite/snapshot-repository.js +0 -69
  201. package/src/storage/sqlite/spaces-repository.js +0 -52
  202. package/src/storage/sqlite/subscriptions-repository.js +0 -54
  203. package/src/storage/sqlite-store.js +0 -159
  204. package/src/storage/stable-json.js +0 -24
  205. package/src/storage/store-port.js +0 -20
  206. package/src/store.js +0 -2
@@ -0,0 +1,657 @@
1
+ from datetime import datetime, timezone
2
+ from typing import Any
3
+
4
+ from fastapi import HTTPException
5
+
6
+ from .knowledge_review_models import (
7
+ KnowledgeReviewCandidate,
8
+ KnowledgeReviewCandidatePage,
9
+ KnowledgeReviewCandidateRequest,
10
+ KnowledgeReviewDecision,
11
+ KnowledgeReviewFinish,
12
+ KnowledgeReviewProgress,
13
+ KnowledgeReviewRun,
14
+ KnowledgeReviewStart,
15
+ )
16
+ from .personal_project_access import authorize_personal_project
17
+ from .provider_values import native_datetime, stable_uuid
18
+
19
+ # These are Provider ranking conventions, not product requirements. Keeping them
20
+ # here gives every Agent the same deterministic policy and makes tuning explicit.
21
+ LOW_UTILITY_SCORE = 0.25
22
+ LOW_CONFIDENCE_SCORE = 0.55
23
+ REPEATED_SESSION_COUNT = 3
24
+
25
+
26
+ async def start_knowledge_review(
27
+ store,
28
+ actor: dict,
29
+ request: KnowledgeReviewStart,
30
+ *,
31
+ started_at: datetime | None = None,
32
+ ) -> KnowledgeReviewRun:
33
+ store._require_personal()
34
+ space = await _personal_space(store, actor, request.personal_space_id, 'maintainer')
35
+ if request.personal_project_id:
36
+ await authorize_personal_project(
37
+ store, actor, space, request.personal_project_id
38
+ )
39
+ scope_key = _scope_key(request.scope, request.personal_project_id)
40
+ changed_at = started_at or datetime.now(timezone.utc)
41
+ active_records, _, _ = await store.runtime.driver.execute_query(
42
+ '''
43
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
44
+ [:HAS_KNOWLEDGE_REVIEW]->(run:FuliKnowledgeReviewRun {
45
+ scope_key: $scope_key
46
+ })
47
+ WHERE run.status IN ['active', 'paused']
48
+ RETURN run ORDER BY run.updated_at DESC LIMIT 1
49
+ ''',
50
+ space_id=space['id'],
51
+ scope_key=scope_key,
52
+ routing_='r',
53
+ )
54
+ if active_records:
55
+ run_value = dict(active_records[0]['run'])
56
+ resumed = run_value['status'] == 'paused'
57
+ if run_value['status'] == 'paused':
58
+ resumed_records, _, _ = await store.runtime.driver.execute_query(
59
+ '''
60
+ MATCH (run:FuliKnowledgeReviewRun {id: $review_id})
61
+ SET run.status = 'active', run.updated_at = $updated_at
62
+ RETURN run
63
+ ''',
64
+ review_id=run_value['id'],
65
+ updated_at=changed_at,
66
+ )
67
+ run_value = dict(resumed_records[0]['run'])
68
+ return _review_run(run_value, resumed=resumed)
69
+
70
+ completed_records, _, _ = await store.runtime.driver.execute_query(
71
+ '''
72
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
73
+ [:HAS_KNOWLEDGE_REVIEW]->(run:FuliKnowledgeReviewRun {
74
+ scope_key: $scope_key, status: 'completed'
75
+ })
76
+ RETURN max(coalesce(run.review_cutoff_at, run.started_at))
77
+ AS previous_completed_at
78
+ ''',
79
+ space_id=space['id'],
80
+ scope_key=scope_key,
81
+ routing_='r',
82
+ )
83
+ previous_completed_at = (
84
+ completed_records[0].get('previous_completed_at')
85
+ if completed_records else None
86
+ )
87
+ review_id = stable_uuid(
88
+ space['id'], 'knowledge-review', scope_key, changed_at.isoformat()
89
+ )
90
+ created_records, _, _ = await store.runtime.driver.execute_query(
91
+ '''
92
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
93
+ MERGE (run:FuliKnowledgeReviewRun {active_key: $active_key})
94
+ ON CREATE SET run.id = $review_id,
95
+ run.personal_space_id = $space_id,
96
+ run.scope = $scope,
97
+ run.personal_project_id = $personal_project_id,
98
+ run.scope_key = $scope_key,
99
+ run.status = 'active',
100
+ run.previous_completed_at = $previous_completed_at,
101
+ run.review_cutoff_at = $started_at,
102
+ run.started_at = $started_at,
103
+ run.updated_at = $started_at,
104
+ run.completed_at = null
105
+ ON MATCH SET run.status = 'active', run.updated_at = $started_at
106
+ MERGE (space)-[:HAS_KNOWLEDGE_REVIEW]->(run)
107
+ RETURN run
108
+ ''',
109
+ space_id=space['id'],
110
+ review_id=review_id,
111
+ active_key=f"{space['id']}:{scope_key}",
112
+ scope=request.scope,
113
+ personal_project_id=request.personal_project_id,
114
+ scope_key=scope_key,
115
+ previous_completed_at=previous_completed_at,
116
+ started_at=changed_at,
117
+ )
118
+ return _review_run(dict(created_records[0]['run']))
119
+
120
+
121
+ async def list_knowledge_review_candidates(
122
+ store,
123
+ actor: dict,
124
+ request: KnowledgeReviewCandidateRequest,
125
+ ) -> KnowledgeReviewCandidatePage:
126
+ space = await _personal_space(store, actor, request.personal_space_id, 'reader')
127
+ run = await _read_review_run(store, space['id'], request.review_id)
128
+ if run.status == 'completed':
129
+ return KnowledgeReviewCandidatePage(
130
+ review=run,
131
+ candidates=[],
132
+ total_candidate_count=0,
133
+ remaining_candidate_count=0,
134
+ )
135
+
136
+ entity_rows, _, _ = await store.runtime.driver.execute_query(
137
+ _ENTITY_CANDIDATE_QUERY,
138
+ space_id=space['id'],
139
+ group_id=space['group_id'],
140
+ routing_='r',
141
+ )
142
+ relationship_rows, _, _ = await store.runtime.driver.execute_query(
143
+ _RELATIONSHIP_CANDIDATE_QUERY,
144
+ space_id=space['id'],
145
+ group_id=space['group_id'],
146
+ routing_='r',
147
+ )
148
+ conflict_rows, _, _ = await store.runtime.driver.execute_query(
149
+ _CONFLICT_ITEM_QUERY,
150
+ space_id=space['id'],
151
+ routing_='r',
152
+ )
153
+ decision_rows, _, _ = await store.runtime.driver.execute_query(
154
+ '''
155
+ MATCH (:FuliKnowledgeReviewRun {id: $review_id})-
156
+ [:HAS_REVIEW_DECISION]->(decision:FuliKnowledgeReviewDecision)
157
+ RETURN decision.candidate_key AS candidate_key
158
+ ''',
159
+ review_id=run.review_id,
160
+ routing_='r',
161
+ )
162
+ deferred_rows, _, _ = await store.runtime.driver.execute_query(
163
+ _DEFERRED_ITEM_QUERY,
164
+ space_id=space['id'],
165
+ review_id=run.review_id,
166
+ routing_='r',
167
+ )
168
+ candidates = build_review_candidates(
169
+ [dict(row) for row in [*entity_rows, *relationship_rows]],
170
+ scope=run.scope,
171
+ personal_project_id=run.personal_project_id,
172
+ previous_completed_at=run.previous_completed_at,
173
+ review_cutoff_at=run.review_cutoff_at,
174
+ conflict_item_keys={
175
+ key
176
+ for row in conflict_rows
177
+ for key in row.get('candidate_keys', [])
178
+ if key
179
+ },
180
+ decided_candidate_keys={
181
+ row['candidate_key'] for row in decision_rows
182
+ },
183
+ deferred_candidate_keys={
184
+ key
185
+ for row in deferred_rows
186
+ for key in row.get('candidate_keys', [])
187
+ if key
188
+ },
189
+ )
190
+ selected = candidates[:request.limit]
191
+ return KnowledgeReviewCandidatePage(
192
+ review=run,
193
+ candidates=selected,
194
+ total_candidate_count=len(candidates),
195
+ remaining_candidate_count=max(len(candidates) - len(selected), 0),
196
+ )
197
+
198
+
199
+ async def record_knowledge_review_progress(
200
+ store,
201
+ actor: dict,
202
+ request: KnowledgeReviewProgress,
203
+ *,
204
+ changed_at: datetime | None = None,
205
+ ) -> KnowledgeReviewDecision:
206
+ store._require_personal()
207
+ await _personal_space(store, actor, request.personal_space_id, 'maintainer')
208
+ updated_at = changed_at or datetime.now(timezone.utc)
209
+ decision_id = stable_uuid(
210
+ request.personal_space_id,
211
+ 'knowledge-review-decision',
212
+ request.review_id,
213
+ request.candidate_key,
214
+ )
215
+ records, _, _ = await store.runtime.driver.execute_query(
216
+ '''
217
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
218
+ [:HAS_KNOWLEDGE_REVIEW]->(run:FuliKnowledgeReviewRun {id: $review_id})
219
+ WHERE run.status IN ['active', 'paused']
220
+ MERGE (decision:FuliKnowledgeReviewDecision {id: $decision_id})
221
+ ON CREATE SET decision.created_at = $updated_at
222
+ SET decision.review_id = $review_id,
223
+ decision.candidate_key = $candidate_key,
224
+ decision.outcome = $outcome,
225
+ decision.note = $note,
226
+ decision.updated_at = $updated_at,
227
+ run.updated_at = $updated_at
228
+ MERGE (run)-[:HAS_REVIEW_DECISION]->(decision)
229
+ RETURN decision
230
+ ''',
231
+ space_id=request.personal_space_id,
232
+ review_id=request.review_id,
233
+ decision_id=decision_id,
234
+ candidate_key=request.candidate_key,
235
+ outcome=request.outcome,
236
+ note=request.note,
237
+ updated_at=updated_at,
238
+ )
239
+ if not records:
240
+ raise HTTPException(status_code=404, detail='active knowledge review not found')
241
+ return _review_decision(dict(records[0]['decision']))
242
+
243
+
244
+ async def finish_knowledge_review(
245
+ store,
246
+ actor: dict,
247
+ request: KnowledgeReviewFinish,
248
+ *,
249
+ changed_at: datetime | None = None,
250
+ ) -> KnowledgeReviewRun:
251
+ store._require_personal()
252
+ await _personal_space(store, actor, request.personal_space_id, 'maintainer')
253
+ updated_at = changed_at or datetime.now(timezone.utc)
254
+ completed_at = updated_at if request.disposition == 'completed' else None
255
+ records, _, _ = await store.runtime.driver.execute_query(
256
+ '''
257
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
258
+ [:HAS_KNOWLEDGE_REVIEW]->(run:FuliKnowledgeReviewRun {id: $review_id})
259
+ WHERE run.status IN ['active', 'paused']
260
+ SET run.status = $status,
261
+ run.updated_at = $updated_at,
262
+ run.completed_at = $completed_at,
263
+ run.active_key = CASE WHEN $status = 'completed'
264
+ THEN null ELSE run.active_key END
265
+ RETURN run
266
+ ''',
267
+ space_id=request.personal_space_id,
268
+ review_id=request.review_id,
269
+ status=request.disposition,
270
+ updated_at=updated_at,
271
+ completed_at=completed_at,
272
+ )
273
+ if not records:
274
+ raise HTTPException(status_code=404, detail='active knowledge review not found')
275
+ return _review_run(dict(records[0]['run']))
276
+
277
+
278
+ def build_review_candidates(
279
+ rows: list[dict[str, Any]],
280
+ *,
281
+ scope: str,
282
+ personal_project_id: str | None,
283
+ previous_completed_at: datetime | None,
284
+ review_cutoff_at: datetime | None = None,
285
+ conflict_item_keys: set[str],
286
+ decided_candidate_keys: set[str],
287
+ deferred_candidate_keys: set[str] | None = None,
288
+ ) -> list[KnowledgeReviewCandidate]:
289
+ deferred_candidate_keys = deferred_candidate_keys or set()
290
+ prepared = []
291
+ for row in rows:
292
+ candidate_key = f"{row['item_kind']}:{row['item_id']}"
293
+ normalized = _normalize_item_row(row)
294
+ if not _item_in_scope(normalized, scope, personal_project_id):
295
+ continue
296
+ if (
297
+ review_cutoff_at is not None
298
+ and normalized['changed_at'] is not None
299
+ and normalized['changed_at'] > review_cutoff_at
300
+ ):
301
+ continue
302
+ normalized['_candidate_key'] = candidate_key
303
+ prepared.append(normalized)
304
+
305
+ # One stable entity usually accumulates several sessions, while repeated
306
+ # relationships may have distinct IDs. Aggregate both shapes, but attach the
307
+ # cross-session reason to only one representative so the user is not asked
308
+ # the same globalization question several times.
309
+ pattern_groups: dict[tuple, list[dict]] = {}
310
+ for item in prepared:
311
+ pattern_groups.setdefault(_pattern_signature(item), []).append(item)
312
+ repeated_representatives = {}
313
+ for signature, items in pattern_groups.items():
314
+ session_ids = {
315
+ session_id
316
+ for item in items
317
+ for session_id in item['_session_ids']
318
+ }
319
+ eligible = [
320
+ item for item in items
321
+ if item['_candidate_key'] not in decided_candidate_keys
322
+ ]
323
+ if len(session_ids) < REPEATED_SESSION_COUNT or not eligible:
324
+ continue
325
+ representative = max(
326
+ eligible,
327
+ key=lambda item: (
328
+ item['changed_at'] or datetime.min.replace(tzinfo=timezone.utc),
329
+ item['_candidate_key'],
330
+ ),
331
+ )
332
+ repeated_representatives[representative['_candidate_key']] = len(session_ids)
333
+
334
+ candidates = []
335
+ for normalized in prepared:
336
+ candidate_key = normalized['_candidate_key']
337
+ if candidate_key in decided_candidate_keys:
338
+ continue
339
+ if candidate_key in repeated_representatives:
340
+ normalized['distinct_session_count'] = repeated_representatives[candidate_key]
341
+ reasons = _candidate_reasons(
342
+ normalized,
343
+ previous_completed_at,
344
+ candidate_key in conflict_item_keys,
345
+ candidate_key in deferred_candidate_keys,
346
+ )
347
+ if not reasons:
348
+ continue
349
+ candidates.append(KnowledgeReviewCandidate(
350
+ candidate_key=candidate_key,
351
+ priority=_reason_priority(reasons[0]),
352
+ reasons=reasons,
353
+ **{
354
+ key: normalized[key]
355
+ for key in KnowledgeReviewCandidate.model_fields
356
+ if key not in {'candidate_key', 'priority', 'reasons'}
357
+ },
358
+ ))
359
+ return sorted(candidates, key=_candidate_sort_key)
360
+
361
+
362
+ async def _personal_space(store, actor, space_id: str, role: str) -> dict:
363
+ space = await store.authorize(actor, space_id, role)
364
+ if space['kind'] != 'personal':
365
+ raise HTTPException(status_code=422, detail='knowledge review is personal-only')
366
+ return space
367
+
368
+
369
+ async def _read_review_run(store, space_id: str, review_id: str) -> KnowledgeReviewRun:
370
+ records, _, _ = await store.runtime.driver.execute_query(
371
+ '''
372
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
373
+ [:HAS_KNOWLEDGE_REVIEW]->(run:FuliKnowledgeReviewRun {id: $review_id})
374
+ RETURN run
375
+ ''',
376
+ space_id=space_id,
377
+ review_id=review_id,
378
+ routing_='r',
379
+ )
380
+ if not records:
381
+ raise HTTPException(status_code=404, detail='knowledge review not found')
382
+ return _review_run(dict(records[0]['run']))
383
+
384
+
385
+ def _review_run(value: dict, *, resumed: bool = False) -> KnowledgeReviewRun:
386
+ return KnowledgeReviewRun(
387
+ review_id=value['id'],
388
+ personal_space_id=value['personal_space_id'],
389
+ scope=value['scope'],
390
+ personal_project_id=value.get('personal_project_id'),
391
+ scope_key=value['scope_key'],
392
+ status=value['status'],
393
+ previous_completed_at=native_datetime(value.get('previous_completed_at')),
394
+ review_cutoff_at=native_datetime(
395
+ value.get('review_cutoff_at') or value['started_at']
396
+ ),
397
+ started_at=native_datetime(value['started_at']),
398
+ updated_at=native_datetime(value['updated_at']),
399
+ completed_at=native_datetime(value.get('completed_at')),
400
+ resumed=resumed,
401
+ )
402
+
403
+
404
+ def _review_decision(value: dict) -> KnowledgeReviewDecision:
405
+ return KnowledgeReviewDecision(
406
+ decision_id=value['id'],
407
+ review_id=value['review_id'],
408
+ candidate_key=value['candidate_key'],
409
+ outcome=value['outcome'],
410
+ note=value.get('note'),
411
+ created_at=native_datetime(value['created_at']),
412
+ updated_at=native_datetime(value['updated_at']),
413
+ )
414
+
415
+
416
+ def _scope_key(scope: str, personal_project_id: str | None) -> str:
417
+ return f'{scope}:{personal_project_id}' if personal_project_id else scope
418
+
419
+
420
+ def _normalize_item_row(row: dict[str, Any]) -> dict[str, Any]:
421
+ project_ids = sorted({
422
+ value
423
+ for key in ('project_ids', 'assigned_project_ids', 'evidence_project_ids')
424
+ for value in (row.get(key) or [])
425
+ if value
426
+ })
427
+ session_ids = sorted({value for value in row.get('session_ids', []) if value})
428
+ changed_at = _latest_datetime(
429
+ row.get('created_at'),
430
+ row.get('last_human_changed_at'),
431
+ row.get('last_feedback_at'),
432
+ row.get('last_revision_at'),
433
+ )
434
+ return {
435
+ 'item_id': row['item_id'],
436
+ 'item_kind': row['item_kind'],
437
+ 'title': row.get('title') or row['item_id'],
438
+ 'content': row.get('content') or '',
439
+ 'profile_aspect': row.get('profile_aspect'),
440
+ 'preference_scope': row.get('preference_scope'),
441
+ 'preference_project_id': row.get('preference_project_id'),
442
+ 'project_ids': project_ids,
443
+ 'confirmation_status': row.get('confirmation_status') or 'pending',
444
+ 'current_quadrant': row.get('current_quadrant') or 'known_known',
445
+ 'utility_score': _float_or_default(row.get('utility_score'), 0),
446
+ 'confidence_score': _float_or_default(row.get('confidence_score'), 0.5),
447
+ 'qualified_use_count': int(row.get('qualified_use_count') or 0),
448
+ 'distinct_task_count': int(row.get('distinct_task_count') or 0),
449
+ 'negative_evidence_count': int(row.get('negative_evidence_count') or 0),
450
+ 'requires_attention': row.get('requires_attention') is True,
451
+ 'last_feedback_kind': row.get('last_feedback_kind'),
452
+ 'distinct_session_count': len(session_ids),
453
+ 'changed_at': changed_at,
454
+ '_session_ids': session_ids,
455
+ }
456
+
457
+
458
+ def _pattern_signature(item: dict) -> tuple:
459
+ content = ' '.join((item['content'] or item['title']).casefold().split())
460
+ return item['item_kind'], item['profile_aspect'], content
461
+
462
+
463
+ def _item_in_scope(item: dict, scope: str, project_id: str | None) -> bool:
464
+ is_preference = item['profile_aspect'] is not None
465
+ preference_scope = item['preference_scope'] or 'global'
466
+ is_global_preference = is_preference and preference_scope == 'global'
467
+ is_project_preference = is_preference and preference_scope == 'project'
468
+ is_project_knowledge = not is_preference and bool(item['project_ids'])
469
+ if scope == 'all':
470
+ return is_global_preference or is_project_preference or is_project_knowledge
471
+ if scope == 'preferences_global':
472
+ return is_global_preference
473
+ if scope == 'preferences_project':
474
+ return is_project_preference and item['preference_project_id'] == project_id
475
+ if scope == 'projects_all':
476
+ return is_project_preference or is_project_knowledge
477
+ if scope == 'project':
478
+ return (
479
+ is_project_preference and item['preference_project_id'] == project_id
480
+ ) or (
481
+ is_project_knowledge and project_id in item['project_ids']
482
+ )
483
+ raise ValueError(f'Unknown knowledge review scope: {scope}')
484
+
485
+
486
+ def _candidate_reasons(
487
+ item: dict,
488
+ previous_completed_at: datetime | None,
489
+ has_conflict: bool,
490
+ was_deferred: bool,
491
+ ) -> list[str]:
492
+ reasons = []
493
+ if previous_completed_at is None or (
494
+ item['changed_at'] is not None and item['changed_at'] > previous_completed_at
495
+ ):
496
+ reasons.append('changed_since_last')
497
+ if was_deferred:
498
+ reasons.append('deferred_from_previous')
499
+ if (
500
+ has_conflict
501
+ or item['requires_attention']
502
+ or item['negative_evidence_count'] > 0
503
+ ):
504
+ reasons.append('conflict_or_attention')
505
+ if (
506
+ item['utility_score'] <= LOW_UTILITY_SCORE
507
+ or item['confidence_score'] <= LOW_CONFIDENCE_SCORE
508
+ ):
509
+ reasons.append('low_weight')
510
+ if item['distinct_session_count'] >= REPEATED_SESSION_COUNT:
511
+ reasons.append('repeated_cross_session')
512
+ return reasons
513
+
514
+
515
+ def _reason_priority(reason: str) -> int:
516
+ return {
517
+ 'changed_since_last': 1,
518
+ 'deferred_from_previous': 1,
519
+ 'conflict_or_attention': 2,
520
+ 'low_weight': 3,
521
+ 'repeated_cross_session': 4,
522
+ }[reason]
523
+
524
+
525
+ def _candidate_sort_key(candidate: KnowledgeReviewCandidate) -> tuple:
526
+ changed_at = candidate.changed_at or datetime.min.replace(tzinfo=timezone.utc)
527
+ return (
528
+ candidate.priority,
529
+ not candidate.requires_attention,
530
+ -candidate.negative_evidence_count,
531
+ -changed_at.timestamp(),
532
+ candidate.utility_score,
533
+ candidate.candidate_key,
534
+ )
535
+
536
+
537
+ def _latest_datetime(*values) -> datetime | None:
538
+ normalized = [native_datetime(value) for value in values if value is not None]
539
+ return max(normalized) if normalized else None
540
+
541
+
542
+ def _float_or_default(value, default: float) -> float:
543
+ return default if value is None else float(value)
544
+
545
+
546
+ _ENTITY_CANDIDATE_QUERY = '''
547
+ MATCH (item:Entity {group_id: $group_id})
548
+ WHERE item.fuli_invalid_at IS NULL
549
+ OPTIONAL MATCH (episode:Episodic {group_id: $group_id})-[:MENTIONS]->(item)
550
+ OPTIONAL MATCH (assignment:FuliKnowledgeAssignment {
551
+ space_id: $space_id, item_kind: 'entity', item_id: item.uuid
552
+ })
553
+ OPTIONAL MATCH (:FuliSpace {id: $space_id})-[:HAS_KNOWLEDGE_REVISION]->
554
+ (revision:FuliKnowledgeRevision {item_kind: 'entity', item_id: item.uuid})
555
+ RETURN item.uuid AS item_id,
556
+ 'entity' AS item_kind,
557
+ item.name AS title,
558
+ coalesce(item.summary, '') AS content,
559
+ item.fuli_profile_aspect AS profile_aspect,
560
+ item.fuli_preference_scope AS preference_scope,
561
+ item.fuli_preference_project_id AS preference_project_id,
562
+ coalesce(
563
+ item.fuli_current_quadrant,
564
+ item.fuli_origin_quadrant,
565
+ 'known_known'
566
+ ) AS current_quadrant,
567
+ collect(DISTINCT assignment.project_id) AS assigned_project_ids,
568
+ collect(DISTINCT episode.fuli_personal_project_id) AS evidence_project_ids,
569
+ collect(DISTINCT episode.fuli_session_id) AS session_ids,
570
+ coalesce(item.fuli_confirmation_status, 'pending') AS confirmation_status,
571
+ coalesce(item.fuli_utility_score, 0.0) AS utility_score,
572
+ coalesce(item.fuli_confidence_score, 0.5) AS confidence_score,
573
+ coalesce(item.fuli_qualified_use_count, 0) AS qualified_use_count,
574
+ coalesce(item.fuli_distinct_task_count, 0) AS distinct_task_count,
575
+ coalesce(item.fuli_negative_evidence_count, 0) AS negative_evidence_count,
576
+ coalesce(item.fuli_requires_attention, false) AS requires_attention,
577
+ item.fuli_last_feedback_kind AS last_feedback_kind,
578
+ item.created_at AS created_at,
579
+ item.fuli_last_human_changed_at AS last_human_changed_at,
580
+ item.fuli_last_feedback_at AS last_feedback_at,
581
+ max(revision.created_at) AS last_revision_at
582
+ '''
583
+
584
+
585
+ _RELATIONSHIP_CANDIDATE_QUERY = '''
586
+ MATCH ()-[item:RELATES_TO {group_id: $group_id}]->()
587
+ WHERE item.invalid_at IS NULL
588
+ OPTIONAL MATCH (episode:Episodic {group_id: $group_id})
589
+ WHERE episode.uuid IN coalesce(item.episodes, [])
590
+ OPTIONAL MATCH (assignment:FuliKnowledgeAssignment {
591
+ space_id: $space_id, item_kind: 'relationship', item_id: item.uuid
592
+ })
593
+ OPTIONAL MATCH (:FuliSpace {id: $space_id})-[:HAS_KNOWLEDGE_REVISION]->
594
+ (revision:FuliKnowledgeRevision {
595
+ item_kind: 'relationship', item_id: item.uuid
596
+ })
597
+ RETURN item.uuid AS item_id,
598
+ 'relationship' AS item_kind,
599
+ coalesce(item.name, 'RELATES_TO') AS title,
600
+ coalesce(item.fact, '') AS content,
601
+ item.fuli_profile_aspect AS profile_aspect,
602
+ item.fuli_preference_scope AS preference_scope,
603
+ item.fuli_preference_project_id AS preference_project_id,
604
+ coalesce(
605
+ item.fuli_current_quadrant,
606
+ item.fuli_origin_quadrant,
607
+ 'known_known'
608
+ ) AS current_quadrant,
609
+ collect(DISTINCT assignment.project_id) AS assigned_project_ids,
610
+ collect(DISTINCT episode.fuli_personal_project_id) AS evidence_project_ids,
611
+ collect(DISTINCT episode.fuli_session_id) AS session_ids,
612
+ coalesce(item.fuli_confirmation_status, 'pending') AS confirmation_status,
613
+ coalesce(item.fuli_utility_score, 0.0) AS utility_score,
614
+ coalesce(item.fuli_confidence_score, 0.5) AS confidence_score,
615
+ coalesce(item.fuli_qualified_use_count, 0) AS qualified_use_count,
616
+ coalesce(item.fuli_distinct_task_count, 0) AS distinct_task_count,
617
+ coalesce(item.fuli_negative_evidence_count, 0) AS negative_evidence_count,
618
+ coalesce(item.fuli_requires_attention, false) AS requires_attention,
619
+ item.fuli_last_feedback_kind AS last_feedback_kind,
620
+ item.created_at AS created_at,
621
+ item.fuli_last_human_changed_at AS last_human_changed_at,
622
+ item.fuli_last_feedback_at AS last_feedback_at,
623
+ max(revision.created_at) AS last_revision_at
624
+ '''
625
+
626
+
627
+ _CONFLICT_ITEM_QUERY = '''
628
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
629
+ OPTIONAL MATCH (space)-[:HAS_KNOWLEDGE_CONFLICT]->
630
+ (knowledge:FuliKnowledgeConflict {status: 'pending'})
631
+ WITH space, collect(DISTINCT 'entity:' + knowledge.item_id) +
632
+ collect(DISTINCT 'relationship:' + knowledge.item_id) +
633
+ collect(DISTINCT 'entity:' + knowledge.target_item_id) +
634
+ collect(DISTINCT 'relationship:' + knowledge.target_item_id)
635
+ AS knowledge_keys
636
+ OPTIONAL MATCH (space)-[:HAS_PREFERENCE_CONFLICT]->
637
+ (preference:FuliPreferenceConflict {status: 'ai_pending'})
638
+ WITH knowledge_keys,
639
+ collect(DISTINCT preference.left_item_kind + ':' + preference.left_item_id) +
640
+ collect(DISTINCT preference.right_item_kind + ':' + preference.right_item_id)
641
+ AS preference_keys
642
+ RETURN knowledge_keys + preference_keys AS candidate_keys
643
+ '''
644
+
645
+
646
+ _DEFERRED_ITEM_QUERY = '''
647
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
648
+ [:HAS_KNOWLEDGE_REVIEW]->(past_run:FuliKnowledgeReviewRun)
649
+ WHERE past_run.id <> $review_id
650
+ MATCH (past_run)-[:HAS_REVIEW_DECISION]->
651
+ (decision:FuliKnowledgeReviewDecision)
652
+ WITH decision.candidate_key AS candidate_key, decision
653
+ ORDER BY decision.updated_at DESC, decision.id DESC
654
+ WITH candidate_key, collect(decision)[0] AS latest_decision
655
+ WHERE latest_decision.outcome = 'deferred'
656
+ RETURN collect(candidate_key) AS candidate_keys
657
+ '''