fuli-context 0.5.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/README.en.md +2 -333
  2. package/README.md +370 -202
  3. package/README.zh-CN.md +448 -0
  4. package/compose.graphiti.yml +6 -6
  5. package/dist/web/assets/AboutPage-DXh7Fwxt.css +1 -0
  6. package/dist/web/assets/AboutPage-DfnZo7Lo.js +1 -0
  7. package/dist/web/assets/ConnectionsPage-BloaSKmt.js +1 -0
  8. package/dist/web/assets/ConnectionsPage-CBHAdZXT.css +1 -0
  9. package/dist/web/assets/KnowledgeInspector-Bjhdu2mf.css +1 -0
  10. package/dist/web/assets/KnowledgeInspector-QvNpv0_k.js +1 -0
  11. package/dist/web/assets/KnowledgeOrganizerPage-Cx1w1jb2.css +1 -0
  12. package/dist/web/assets/KnowledgeOrganizerPage-DKq1jVD5.js +1 -0
  13. package/dist/web/assets/KnowledgePage-BWL0jT01.js +1 -0
  14. package/dist/web/assets/KnowledgeWorkspace-Czq6aZ7b.js +4 -0
  15. package/dist/web/assets/OverviewPage-5sJoO1Gh.js +1 -0
  16. package/dist/web/assets/{PersonalProfilePage-S8ZcG2LU.css → PersonalProfilePage-CdmEzyvZ.css} +1 -1
  17. package/dist/web/assets/PersonalProfilePage-tVChM9ik.js +1 -0
  18. package/dist/web/assets/PersonalProjectsPage-BPFrup4V.js +1 -0
  19. package/dist/web/assets/PublicProjectsPage-BPJSeREN.js +1 -0
  20. package/dist/web/assets/ReviewPage-B7EN817i.js +1 -0
  21. package/dist/web/assets/SearchableSelect-ClLN0rIb.js +1 -0
  22. package/dist/web/assets/SettingsPage-C6ouU0sR.css +1 -0
  23. package/dist/web/assets/SettingsPage-Chd_B3eH.js +1 -0
  24. package/dist/web/assets/_plugin-vue_export-helper-BDNMzG2s.js +1 -0
  25. package/dist/web/assets/console-CbDsKrsO.js +1 -0
  26. package/dist/web/assets/fuli-logo-Bu9WBuQe.js +1 -0
  27. package/dist/web/assets/fuli-logo-ER1wQ8Kk.png +0 -0
  28. package/dist/web/assets/i18n-BQcns1PH.js +5 -0
  29. package/dist/web/assets/identity-B8K4VYg6.js +1 -0
  30. package/dist/web/assets/index-CB8Xmsc2.js +2 -0
  31. package/dist/web/assets/index-CZeuPh-U.css +1 -0
  32. package/dist/web/assets/model-eF52GgPF.js +1 -0
  33. package/dist/web/assets/paths-hwz-Ac1g.js +1 -0
  34. package/dist/web/assets/useMinimumLoadingDisplay-BpCyBeko.css +1 -0
  35. package/dist/web/assets/useMinimumLoadingDisplay-wCgGDmHg.js +1 -0
  36. package/dist/web/assets/vue-router-BsEp_XwI.js +1 -0
  37. package/dist/web/index.html +9 -6
  38. package/docs/external-knowledge-architecture.md +279 -0
  39. package/examples/external-knowledge/markdown-folder.mjs +235 -0
  40. package/graph-provider/fuli_graph/app.py +57 -1
  41. package/graph-provider/fuli_graph/graph_models.py +3 -0
  42. package/graph-provider/fuli_graph/graph_query.py +62 -16
  43. package/graph-provider/fuli_graph/knowledge_review.py +612 -0
  44. package/graph-provider/fuli_graph/knowledge_review_models.py +127 -0
  45. package/graph-provider/fuli_graph/knowledge_search.py +51 -4
  46. package/graph-provider/fuli_graph/models.py +19 -0
  47. package/graph-provider/fuli_graph/runtime.py +15 -0
  48. package/graph-provider/fuli_graph/store_knowledge.py +2 -1
  49. package/graph-provider/pyproject.toml +1 -1
  50. package/npm-shrinkwrap.json +5 -407
  51. package/package.json +7 -5
  52. package/skills/capturing-session-knowledge/SKILL.md +24 -4
  53. package/skills/flreview/SKILL.md +73 -0
  54. package/skills/flreview/agents/openai.yaml +6 -0
  55. package/src/agent-tools/graph-definitions.js +79 -4
  56. package/src/agent-tools/graph-handlers.js +12 -0
  57. package/src/cli/command-registry.js +3 -3
  58. package/src/cli/local-runtime-command.js +51 -28
  59. package/src/cli/local-runtime-options.js +14 -8
  60. package/src/cli/setup-agent-selection.js +12 -12
  61. package/src/cli/setup-command.js +41 -18
  62. package/src/cli/shell-argument.js +10 -0
  63. package/src/cli/uninstall-command.js +17 -15
  64. package/src/cli/update-command.js +38 -30
  65. package/src/external-knowledge/catalog.js +61 -0
  66. package/src/external-knowledge/conflict-policy.js +58 -0
  67. package/src/external-knowledge/connected-search.js +166 -0
  68. package/src/external-knowledge/connectors/custom.js +133 -0
  69. package/src/external-knowledge/connectors/feishu.js +461 -0
  70. package/src/external-knowledge/connectors/mcp.js +438 -0
  71. package/src/external-knowledge/connectors/notion.js +307 -0
  72. package/src/external-knowledge/connectors/retrieval-api.js +222 -0
  73. package/src/external-knowledge/document-mapping.js +151 -0
  74. package/src/external-knowledge/graph-projection.js +17 -0
  75. package/src/external-knowledge/index.js +8 -0
  76. package/src/external-knowledge/registry.js +91 -0
  77. package/src/external-knowledge/runtime.js +68 -0
  78. package/src/external-knowledge/safe-diagnostic.js +14 -0
  79. package/src/external-knowledge/service.js +811 -0
  80. package/src/graphiti/agent-knowledge-workflows.js +7 -2
  81. package/src/graphiti/federated-application.js +66 -41
  82. package/src/graphiti/knowledge-item-role.js +21 -0
  83. package/src/graphiti/knowledge-review.js +43 -0
  84. package/src/graphiti/project-profile-mapping.js +32 -0
  85. package/src/graphiti/provider-client.js +22 -1
  86. package/src/graphiti/source-marker.js +54 -3
  87. package/src/graphiti/task-knowledge-recall.js +250 -0
  88. package/src/http/api-router.js +23 -108
  89. package/src/http/external-knowledge-api-router.js +104 -0
  90. package/src/http/graph-api-router.js +12 -1
  91. package/src/http/request-policy.js +1 -1
  92. package/src/local-runtime/lifecycle.js +26 -15
  93. package/src/mcp/create-mcp-server.js +1 -0
  94. package/src/mcp/instructions.js +7 -7
  95. package/src/mcp/tool-annotations.js +6 -0
  96. package/src/mcp/tool-result.js +1 -0
  97. package/src/server/application-lifecycle.js +2 -11
  98. package/src/server/blocked-ports.js +11 -0
  99. package/src/server.js +36 -17
  100. package/src/setup/agent-installation-status.js +2 -1
  101. package/src/setup/agents.js +2 -1
  102. package/src/setup/claude-code-config.js +2 -1
  103. package/src/setup/codex-bootstrap.js +12 -7
  104. package/src/setup/container-runtime.js +25 -21
  105. package/src/setup/graph-runtime.js +148 -56
  106. package/src/setup/node-runtime.js +3 -3
  107. package/src/setup/options.js +2 -4
  108. package/src/setup/paths.js +18 -3
  109. package/src/setup/review-skill-adapters.js +49 -0
  110. package/src/setup/setup.js +26 -2
  111. package/src/setup/uninstall.js +4 -2
  112. package/src/system/resource-monitor.js +317 -0
  113. package/src/system/runtime-settings.js +131 -0
  114. package/src/system/system-service.js +119 -0
  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,612 @@
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
+ candidates = build_review_candidates(
163
+ [dict(row) for row in [*entity_rows, *relationship_rows]],
164
+ scope=run.scope,
165
+ personal_project_id=run.personal_project_id,
166
+ previous_completed_at=run.previous_completed_at,
167
+ review_cutoff_at=run.review_cutoff_at,
168
+ conflict_item_keys={
169
+ key
170
+ for row in conflict_rows
171
+ for key in row.get('candidate_keys', [])
172
+ if key
173
+ },
174
+ decided_candidate_keys={
175
+ row['candidate_key'] for row in decision_rows
176
+ },
177
+ )
178
+ selected = candidates[:request.limit]
179
+ return KnowledgeReviewCandidatePage(
180
+ review=run,
181
+ candidates=selected,
182
+ total_candidate_count=len(candidates),
183
+ remaining_candidate_count=max(len(candidates) - len(selected), 0),
184
+ )
185
+
186
+
187
+ async def record_knowledge_review_progress(
188
+ store,
189
+ actor: dict,
190
+ request: KnowledgeReviewProgress,
191
+ *,
192
+ changed_at: datetime | None = None,
193
+ ) -> KnowledgeReviewDecision:
194
+ store._require_personal()
195
+ await _personal_space(store, actor, request.personal_space_id, 'maintainer')
196
+ updated_at = changed_at or datetime.now(timezone.utc)
197
+ decision_id = stable_uuid(
198
+ request.personal_space_id,
199
+ 'knowledge-review-decision',
200
+ request.review_id,
201
+ request.candidate_key,
202
+ )
203
+ records, _, _ = await store.runtime.driver.execute_query(
204
+ '''
205
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
206
+ [:HAS_KNOWLEDGE_REVIEW]->(run:FuliKnowledgeReviewRun {id: $review_id})
207
+ WHERE run.status IN ['active', 'paused']
208
+ MERGE (decision:FuliKnowledgeReviewDecision {id: $decision_id})
209
+ ON CREATE SET decision.created_at = $updated_at
210
+ SET decision.review_id = $review_id,
211
+ decision.candidate_key = $candidate_key,
212
+ decision.outcome = $outcome,
213
+ decision.note = $note,
214
+ decision.updated_at = $updated_at,
215
+ run.updated_at = $updated_at
216
+ MERGE (run)-[:HAS_REVIEW_DECISION]->(decision)
217
+ RETURN decision
218
+ ''',
219
+ space_id=request.personal_space_id,
220
+ review_id=request.review_id,
221
+ decision_id=decision_id,
222
+ candidate_key=request.candidate_key,
223
+ outcome=request.outcome,
224
+ note=request.note,
225
+ updated_at=updated_at,
226
+ )
227
+ if not records:
228
+ raise HTTPException(status_code=404, detail='active knowledge review not found')
229
+ return _review_decision(dict(records[0]['decision']))
230
+
231
+
232
+ async def finish_knowledge_review(
233
+ store,
234
+ actor: dict,
235
+ request: KnowledgeReviewFinish,
236
+ *,
237
+ changed_at: datetime | None = None,
238
+ ) -> KnowledgeReviewRun:
239
+ store._require_personal()
240
+ await _personal_space(store, actor, request.personal_space_id, 'maintainer')
241
+ updated_at = changed_at or datetime.now(timezone.utc)
242
+ completed_at = updated_at if request.disposition == 'completed' else None
243
+ records, _, _ = await store.runtime.driver.execute_query(
244
+ '''
245
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
246
+ [:HAS_KNOWLEDGE_REVIEW]->(run:FuliKnowledgeReviewRun {id: $review_id})
247
+ WHERE run.status IN ['active', 'paused']
248
+ SET run.status = $status,
249
+ run.updated_at = $updated_at,
250
+ run.completed_at = $completed_at,
251
+ run.active_key = CASE WHEN $status = 'completed'
252
+ THEN null ELSE run.active_key END
253
+ RETURN run
254
+ ''',
255
+ space_id=request.personal_space_id,
256
+ review_id=request.review_id,
257
+ status=request.disposition,
258
+ updated_at=updated_at,
259
+ completed_at=completed_at,
260
+ )
261
+ if not records:
262
+ raise HTTPException(status_code=404, detail='active knowledge review not found')
263
+ return _review_run(dict(records[0]['run']))
264
+
265
+
266
+ def build_review_candidates(
267
+ rows: list[dict[str, Any]],
268
+ *,
269
+ scope: str,
270
+ personal_project_id: str | None,
271
+ previous_completed_at: datetime | None,
272
+ review_cutoff_at: datetime | None = None,
273
+ conflict_item_keys: set[str],
274
+ decided_candidate_keys: set[str],
275
+ ) -> list[KnowledgeReviewCandidate]:
276
+ prepared = []
277
+ for row in rows:
278
+ candidate_key = f"{row['item_kind']}:{row['item_id']}"
279
+ normalized = _normalize_item_row(row)
280
+ if not _item_in_scope(normalized, scope, personal_project_id):
281
+ continue
282
+ if (
283
+ review_cutoff_at is not None
284
+ and normalized['changed_at'] is not None
285
+ and normalized['changed_at'] > review_cutoff_at
286
+ ):
287
+ continue
288
+ normalized['_candidate_key'] = candidate_key
289
+ prepared.append(normalized)
290
+
291
+ # One stable entity usually accumulates several sessions, while repeated
292
+ # relationships may have distinct IDs. Aggregate both shapes, but attach the
293
+ # cross-session reason to only one representative so the user is not asked
294
+ # the same globalization question several times.
295
+ pattern_groups: dict[tuple, list[dict]] = {}
296
+ for item in prepared:
297
+ pattern_groups.setdefault(_pattern_signature(item), []).append(item)
298
+ repeated_representatives = {}
299
+ for signature, items in pattern_groups.items():
300
+ session_ids = {
301
+ session_id
302
+ for item in items
303
+ for session_id in item['_session_ids']
304
+ }
305
+ eligible = [
306
+ item for item in items
307
+ if item['_candidate_key'] not in decided_candidate_keys
308
+ ]
309
+ if len(session_ids) < REPEATED_SESSION_COUNT or not eligible:
310
+ continue
311
+ representative = max(
312
+ eligible,
313
+ key=lambda item: (
314
+ item['changed_at'] or datetime.min.replace(tzinfo=timezone.utc),
315
+ item['_candidate_key'],
316
+ ),
317
+ )
318
+ repeated_representatives[representative['_candidate_key']] = len(session_ids)
319
+
320
+ candidates = []
321
+ for normalized in prepared:
322
+ candidate_key = normalized['_candidate_key']
323
+ if candidate_key in decided_candidate_keys:
324
+ continue
325
+ if candidate_key in repeated_representatives:
326
+ normalized['distinct_session_count'] = repeated_representatives[candidate_key]
327
+ reasons = _candidate_reasons(
328
+ normalized,
329
+ previous_completed_at,
330
+ candidate_key in conflict_item_keys,
331
+ )
332
+ if not reasons:
333
+ continue
334
+ candidates.append(KnowledgeReviewCandidate(
335
+ candidate_key=candidate_key,
336
+ priority=_reason_priority(reasons[0]),
337
+ reasons=reasons,
338
+ **{
339
+ key: normalized[key]
340
+ for key in KnowledgeReviewCandidate.model_fields
341
+ if key not in {'candidate_key', 'priority', 'reasons'}
342
+ },
343
+ ))
344
+ return sorted(candidates, key=_candidate_sort_key)
345
+
346
+
347
+ async def _personal_space(store, actor, space_id: str, role: str) -> dict:
348
+ space = await store.authorize(actor, space_id, role)
349
+ if space['kind'] != 'personal':
350
+ raise HTTPException(status_code=422, detail='knowledge review is personal-only')
351
+ return space
352
+
353
+
354
+ async def _read_review_run(store, space_id: str, review_id: str) -> KnowledgeReviewRun:
355
+ records, _, _ = await store.runtime.driver.execute_query(
356
+ '''
357
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
358
+ [:HAS_KNOWLEDGE_REVIEW]->(run:FuliKnowledgeReviewRun {id: $review_id})
359
+ RETURN run
360
+ ''',
361
+ space_id=space_id,
362
+ review_id=review_id,
363
+ routing_='r',
364
+ )
365
+ if not records:
366
+ raise HTTPException(status_code=404, detail='knowledge review not found')
367
+ return _review_run(dict(records[0]['run']))
368
+
369
+
370
+ def _review_run(value: dict, *, resumed: bool = False) -> KnowledgeReviewRun:
371
+ return KnowledgeReviewRun(
372
+ review_id=value['id'],
373
+ personal_space_id=value['personal_space_id'],
374
+ scope=value['scope'],
375
+ personal_project_id=value.get('personal_project_id'),
376
+ scope_key=value['scope_key'],
377
+ status=value['status'],
378
+ previous_completed_at=native_datetime(value.get('previous_completed_at')),
379
+ review_cutoff_at=native_datetime(
380
+ value.get('review_cutoff_at') or value['started_at']
381
+ ),
382
+ started_at=native_datetime(value['started_at']),
383
+ updated_at=native_datetime(value['updated_at']),
384
+ completed_at=native_datetime(value.get('completed_at')),
385
+ resumed=resumed,
386
+ )
387
+
388
+
389
+ def _review_decision(value: dict) -> KnowledgeReviewDecision:
390
+ return KnowledgeReviewDecision(
391
+ decision_id=value['id'],
392
+ review_id=value['review_id'],
393
+ candidate_key=value['candidate_key'],
394
+ outcome=value['outcome'],
395
+ note=value.get('note'),
396
+ created_at=native_datetime(value['created_at']),
397
+ updated_at=native_datetime(value['updated_at']),
398
+ )
399
+
400
+
401
+ def _scope_key(scope: str, personal_project_id: str | None) -> str:
402
+ return f'{scope}:{personal_project_id}' if personal_project_id else scope
403
+
404
+
405
+ def _normalize_item_row(row: dict[str, Any]) -> dict[str, Any]:
406
+ project_ids = sorted({
407
+ value
408
+ for key in ('project_ids', 'assigned_project_ids', 'evidence_project_ids')
409
+ for value in (row.get(key) or [])
410
+ if value
411
+ })
412
+ session_ids = sorted({value for value in row.get('session_ids', []) if value})
413
+ changed_at = _latest_datetime(
414
+ row.get('created_at'),
415
+ row.get('last_human_changed_at'),
416
+ row.get('last_feedback_at'),
417
+ row.get('last_revision_at'),
418
+ )
419
+ return {
420
+ 'item_id': row['item_id'],
421
+ 'item_kind': row['item_kind'],
422
+ 'title': row.get('title') or row['item_id'],
423
+ 'content': row.get('content') or '',
424
+ 'profile_aspect': row.get('profile_aspect'),
425
+ 'preference_scope': row.get('preference_scope'),
426
+ 'preference_project_id': row.get('preference_project_id'),
427
+ 'project_ids': project_ids,
428
+ 'confirmation_status': row.get('confirmation_status') or 'pending',
429
+ 'utility_score': _float_or_default(row.get('utility_score'), 0),
430
+ 'confidence_score': _float_or_default(row.get('confidence_score'), 0.5),
431
+ 'qualified_use_count': int(row.get('qualified_use_count') or 0),
432
+ 'distinct_task_count': int(row.get('distinct_task_count') or 0),
433
+ 'negative_evidence_count': int(row.get('negative_evidence_count') or 0),
434
+ 'requires_attention': row.get('requires_attention') is True,
435
+ 'last_feedback_kind': row.get('last_feedback_kind'),
436
+ 'distinct_session_count': len(session_ids),
437
+ 'changed_at': changed_at,
438
+ '_session_ids': session_ids,
439
+ }
440
+
441
+
442
+ def _pattern_signature(item: dict) -> tuple:
443
+ content = ' '.join((item['content'] or item['title']).casefold().split())
444
+ return item['item_kind'], item['profile_aspect'], content
445
+
446
+
447
+ def _item_in_scope(item: dict, scope: str, project_id: str | None) -> bool:
448
+ is_preference = item['profile_aspect'] is not None
449
+ preference_scope = item['preference_scope'] or 'global'
450
+ is_global_preference = is_preference and preference_scope == 'global'
451
+ is_project_preference = is_preference and preference_scope == 'project'
452
+ is_project_knowledge = not is_preference and bool(item['project_ids'])
453
+ if scope == 'all':
454
+ return is_global_preference or is_project_preference or is_project_knowledge
455
+ if scope == 'preferences_global':
456
+ return is_global_preference
457
+ if scope == 'preferences_project':
458
+ return is_project_preference and item['preference_project_id'] == project_id
459
+ if scope == 'projects_all':
460
+ return is_project_preference or is_project_knowledge
461
+ if scope == 'project':
462
+ return (
463
+ is_project_preference and item['preference_project_id'] == project_id
464
+ ) or (
465
+ is_project_knowledge and project_id in item['project_ids']
466
+ )
467
+ raise ValueError(f'Unknown knowledge review scope: {scope}')
468
+
469
+
470
+ def _candidate_reasons(
471
+ item: dict,
472
+ previous_completed_at: datetime | None,
473
+ has_conflict: bool,
474
+ ) -> list[str]:
475
+ reasons = []
476
+ if previous_completed_at is None or (
477
+ item['changed_at'] is not None and item['changed_at'] > previous_completed_at
478
+ ):
479
+ reasons.append('changed_since_last')
480
+ if (
481
+ has_conflict
482
+ or item['requires_attention']
483
+ or item['negative_evidence_count'] > 0
484
+ ):
485
+ reasons.append('conflict_or_attention')
486
+ if (
487
+ item['utility_score'] <= LOW_UTILITY_SCORE
488
+ or item['confidence_score'] <= LOW_CONFIDENCE_SCORE
489
+ ):
490
+ reasons.append('low_weight')
491
+ if item['distinct_session_count'] >= REPEATED_SESSION_COUNT:
492
+ reasons.append('repeated_cross_session')
493
+ return reasons
494
+
495
+
496
+ def _reason_priority(reason: str) -> int:
497
+ return {
498
+ 'changed_since_last': 1,
499
+ 'conflict_or_attention': 2,
500
+ 'low_weight': 3,
501
+ 'repeated_cross_session': 4,
502
+ }[reason]
503
+
504
+
505
+ def _candidate_sort_key(candidate: KnowledgeReviewCandidate) -> tuple:
506
+ changed_at = candidate.changed_at or datetime.min.replace(tzinfo=timezone.utc)
507
+ return (
508
+ candidate.priority,
509
+ not candidate.requires_attention,
510
+ -candidate.negative_evidence_count,
511
+ -changed_at.timestamp(),
512
+ candidate.utility_score,
513
+ candidate.candidate_key,
514
+ )
515
+
516
+
517
+ def _latest_datetime(*values) -> datetime | None:
518
+ normalized = [native_datetime(value) for value in values if value is not None]
519
+ return max(normalized) if normalized else None
520
+
521
+
522
+ def _float_or_default(value, default: float) -> float:
523
+ return default if value is None else float(value)
524
+
525
+
526
+ _ENTITY_CANDIDATE_QUERY = '''
527
+ MATCH (item:Entity {group_id: $group_id})
528
+ WHERE item.fuli_invalid_at IS NULL
529
+ OPTIONAL MATCH (episode:Episodic {group_id: $group_id})-[:MENTIONS]->(item)
530
+ OPTIONAL MATCH (assignment:FuliKnowledgeAssignment {
531
+ space_id: $space_id, item_kind: 'entity', item_id: item.uuid
532
+ })
533
+ OPTIONAL MATCH (:FuliSpace {id: $space_id})-[:HAS_KNOWLEDGE_REVISION]->
534
+ (revision:FuliKnowledgeRevision {item_kind: 'entity', item_id: item.uuid})
535
+ RETURN item.uuid AS item_id,
536
+ 'entity' AS item_kind,
537
+ item.name AS title,
538
+ coalesce(item.summary, '') AS content,
539
+ item.fuli_profile_aspect AS profile_aspect,
540
+ item.fuli_preference_scope AS preference_scope,
541
+ item.fuli_preference_project_id AS preference_project_id,
542
+ collect(DISTINCT assignment.project_id) AS assigned_project_ids,
543
+ collect(DISTINCT episode.fuli_personal_project_id) AS evidence_project_ids,
544
+ collect(DISTINCT episode.fuli_session_id) AS session_ids,
545
+ coalesce(item.fuli_confirmation_status, 'pending') AS confirmation_status,
546
+ coalesce(item.fuli_utility_score, 0.0) AS utility_score,
547
+ coalesce(item.fuli_confidence_score, 0.5) AS confidence_score,
548
+ coalesce(item.fuli_qualified_use_count, 0) AS qualified_use_count,
549
+ coalesce(item.fuli_distinct_task_count, 0) AS distinct_task_count,
550
+ coalesce(item.fuli_negative_evidence_count, 0) AS negative_evidence_count,
551
+ coalesce(item.fuli_requires_attention, false) AS requires_attention,
552
+ item.fuli_last_feedback_kind AS last_feedback_kind,
553
+ item.created_at AS created_at,
554
+ item.fuli_last_human_changed_at AS last_human_changed_at,
555
+ item.fuli_last_feedback_at AS last_feedback_at,
556
+ max(revision.created_at) AS last_revision_at
557
+ '''
558
+
559
+
560
+ _RELATIONSHIP_CANDIDATE_QUERY = '''
561
+ MATCH ()-[item:RELATES_TO {group_id: $group_id}]->()
562
+ WHERE item.invalid_at IS NULL
563
+ OPTIONAL MATCH (episode:Episodic {group_id: $group_id})
564
+ WHERE episode.uuid IN coalesce(item.episodes, [])
565
+ OPTIONAL MATCH (assignment:FuliKnowledgeAssignment {
566
+ space_id: $space_id, item_kind: 'relationship', item_id: item.uuid
567
+ })
568
+ OPTIONAL MATCH (:FuliSpace {id: $space_id})-[:HAS_KNOWLEDGE_REVISION]->
569
+ (revision:FuliKnowledgeRevision {
570
+ item_kind: 'relationship', item_id: item.uuid
571
+ })
572
+ RETURN item.uuid AS item_id,
573
+ 'relationship' AS item_kind,
574
+ coalesce(item.name, 'RELATES_TO') AS title,
575
+ coalesce(item.fact, '') AS content,
576
+ item.fuli_profile_aspect AS profile_aspect,
577
+ item.fuli_preference_scope AS preference_scope,
578
+ item.fuli_preference_project_id AS preference_project_id,
579
+ collect(DISTINCT assignment.project_id) AS assigned_project_ids,
580
+ collect(DISTINCT episode.fuli_personal_project_id) AS evidence_project_ids,
581
+ collect(DISTINCT episode.fuli_session_id) AS session_ids,
582
+ coalesce(item.fuli_confirmation_status, 'pending') AS confirmation_status,
583
+ coalesce(item.fuli_utility_score, 0.0) AS utility_score,
584
+ coalesce(item.fuli_confidence_score, 0.5) AS confidence_score,
585
+ coalesce(item.fuli_qualified_use_count, 0) AS qualified_use_count,
586
+ coalesce(item.fuli_distinct_task_count, 0) AS distinct_task_count,
587
+ coalesce(item.fuli_negative_evidence_count, 0) AS negative_evidence_count,
588
+ coalesce(item.fuli_requires_attention, false) AS requires_attention,
589
+ item.fuli_last_feedback_kind AS last_feedback_kind,
590
+ item.created_at AS created_at,
591
+ item.fuli_last_human_changed_at AS last_human_changed_at,
592
+ item.fuli_last_feedback_at AS last_feedback_at,
593
+ max(revision.created_at) AS last_revision_at
594
+ '''
595
+
596
+
597
+ _CONFLICT_ITEM_QUERY = '''
598
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
599
+ OPTIONAL MATCH (space)-[:HAS_KNOWLEDGE_CONFLICT]->
600
+ (knowledge:FuliKnowledgeConflict {status: 'pending'})
601
+ WITH space, collect(DISTINCT 'entity:' + knowledge.item_id) +
602
+ collect(DISTINCT 'relationship:' + knowledge.item_id) +
603
+ collect(DISTINCT 'entity:' + knowledge.target_item_id) +
604
+ collect(DISTINCT 'relationship:' + knowledge.target_item_id)
605
+ AS knowledge_keys
606
+ OPTIONAL MATCH (space)-[:HAS_PREFERENCE_CONFLICT]->
607
+ (preference:FuliPreferenceConflict {status: 'ai_pending'})
608
+ RETURN knowledge_keys +
609
+ collect(DISTINCT preference.left_item_kind + ':' + preference.left_item_id) +
610
+ collect(DISTINCT preference.right_item_kind + ':' + preference.right_item_id)
611
+ AS candidate_keys
612
+ '''