fuli-context 0.2.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 (220) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +178 -0
  3. package/compose.graphiti.yml +86 -0
  4. package/dist/web/assets/ConnectionsPage-CY6H_mJJ.js +1 -0
  5. package/dist/web/assets/KnowledgeInspector-BSpz5Sfm.js +1 -0
  6. package/dist/web/assets/KnowledgeInspector-CHbB_0rC.css +1 -0
  7. package/dist/web/assets/KnowledgeOrganizerPage-BlEvhPrv.css +1 -0
  8. package/dist/web/assets/KnowledgeOrganizerPage-CvjGPlUc.js +1 -0
  9. package/dist/web/assets/KnowledgePage-Khuo3cNs.js +1 -0
  10. package/dist/web/assets/KnowledgeWorkspace-DVZusvem.js +4 -0
  11. package/dist/web/assets/OverviewPage-DC4C4Fhn.js +1 -0
  12. package/dist/web/assets/PersonalProfilePage-CSV9Mtgz.js +1 -0
  13. package/dist/web/assets/PersonalProfilePage-CVJ3TcRi.css +1 -0
  14. package/dist/web/assets/PersonalProjectsPage-A50EaseQ.js +1 -0
  15. package/dist/web/assets/PublicProjectsPage-BAdO_bae.js +1 -0
  16. package/dist/web/assets/ReviewPage-Dztk2jDG.js +1 -0
  17. package/dist/web/assets/console-tbvTUi6p.js +1 -0
  18. package/dist/web/assets/fuli-logo-DGuyqchQ.png +0 -0
  19. package/dist/web/assets/identity-B3s5CXhv.js +1 -0
  20. package/dist/web/assets/index-Devf7glZ.css +1 -0
  21. package/dist/web/assets/index-DwUDhQKc.js +6 -0
  22. package/dist/web/assets/model-DVT8sjy9.js +1 -0
  23. package/dist/web/assets/paths-B790iniO.js +1 -0
  24. package/dist/web/assets/runtime-dom.esm-bundler-DttsK21F.js +1 -0
  25. package/dist/web/assets/vue-router-BYTAoWT3.js +1 -0
  26. package/dist/web/index.html +22 -0
  27. package/graph-provider/Dockerfile +16 -0
  28. package/graph-provider/fuli_graph/__init__.py +5 -0
  29. package/graph-provider/fuli_graph/app.py +547 -0
  30. package/graph-provider/fuli_graph/auth.py +17 -0
  31. package/graph-provider/fuli_graph/collaboration_context.py +290 -0
  32. package/graph-provider/fuli_graph/config.py +34 -0
  33. package/graph-provider/fuli_graph/graph_models.py +123 -0
  34. package/graph-provider/fuli_graph/graph_projection.py +405 -0
  35. package/graph-provider/fuli_graph/graph_query.py +561 -0
  36. package/graph-provider/fuli_graph/knowledge_audit.py +765 -0
  37. package/graph-provider/fuli_graph/knowledge_batch_confirmation.py +246 -0
  38. package/graph-provider/fuli_graph/knowledge_management.py +816 -0
  39. package/graph-provider/fuli_graph/knowledge_records.py +65 -0
  40. package/graph-provider/fuli_graph/knowledge_search.py +723 -0
  41. package/graph-provider/fuli_graph/knowledge_usage_models.py +52 -0
  42. package/graph-provider/fuli_graph/model_validation.py +58 -0
  43. package/graph-provider/fuli_graph/models.py +1179 -0
  44. package/graph-provider/fuli_graph/personal_project_access.py +29 -0
  45. package/graph-provider/fuli_graph/preference_conflicts.py +420 -0
  46. package/graph-provider/fuli_graph/project_action_models.py +142 -0
  47. package/graph-provider/fuli_graph/project_knowledge.py +707 -0
  48. package/graph-provider/fuli_graph/project_management.py +251 -0
  49. package/graph-provider/fuli_graph/provider_values.py +40 -0
  50. package/graph-provider/fuli_graph/runtime.py +350 -0
  51. package/graph-provider/fuli_graph/search_projection.py +156 -0
  52. package/graph-provider/fuli_graph/store.py +24 -0
  53. package/graph-provider/fuli_graph/store_identity.py +237 -0
  54. package/graph-provider/fuli_graph/store_knowledge.py +447 -0
  55. package/graph-provider/fuli_graph/store_projects.py +242 -0
  56. package/graph-provider/fuli_graph/store_publication.py +370 -0
  57. package/graph-provider/fuli_graph/store_records.py +134 -0
  58. package/graph-provider/pyproject.toml +35 -0
  59. package/npm-shrinkwrap.json +6045 -0
  60. package/package.json +99 -0
  61. package/skills/capturing-session-knowledge/SKILL.md +226 -0
  62. package/skills/capturing-session-knowledge/agents/openai.yaml +13 -0
  63. package/skills/grilling-project/SKILL.md +129 -0
  64. package/skills/grilling-project/agents/openai.yaml +4 -0
  65. package/skills/grilling-project/references/project-profile.md +105 -0
  66. package/src/agent/candidate-read-service.js +53 -0
  67. package/src/agent/context-pack-service.js +83 -0
  68. package/src/agent/public-read-policy.js +39 -0
  69. package/src/agent/public-read-service.js +69 -0
  70. package/src/agent/read-projection.js +107 -0
  71. package/src/agent/search-service.js +61 -0
  72. package/src/agent/space-boundary.js +33 -0
  73. package/src/agent-tools/graph-definitions.js +479 -0
  74. package/src/agent-tools/graph-handlers.js +96 -0
  75. package/src/agent-tools/schema.js +37 -0
  76. package/src/agent-tools.js +16 -0
  77. package/src/app/application-error.js +13 -0
  78. package/src/app/bootstrap.js +8 -0
  79. package/src/app/create-application.js +143 -0
  80. package/src/app/state-service.js +38 -0
  81. package/src/candidates.js +78 -0
  82. package/src/classifier.js +63 -0
  83. package/src/cli/command-arguments.js +14 -0
  84. package/src/cli/command-registry.js +78 -0
  85. package/src/cli/commands/candidates.js +17 -0
  86. package/src/cli/commands/ingestion.js +32 -0
  87. package/src/cli/commands/reads.js +50 -0
  88. package/src/cli/commands/spaces.js +20 -0
  89. package/src/cli/invocation.js +47 -0
  90. package/src/cli/local-runtime-command.js +94 -0
  91. package/src/cli/local-runtime-options.js +64 -0
  92. package/src/cli/migrate-command.js +71 -0
  93. package/src/cli/migration-destination.js +117 -0
  94. package/src/cli/setup-agent-selection.js +195 -0
  95. package/src/cli/setup-command.js +85 -0
  96. package/src/cli/uninstall-command.js +67 -0
  97. package/src/cli.js +90 -0
  98. package/src/context-pack.js +206 -0
  99. package/src/defaults.js +1 -0
  100. package/src/extractor.js +77 -0
  101. package/src/fact-history.js +39 -0
  102. package/src/facts/fact-service.js +89 -0
  103. package/src/graphiti/agent-access-policy.js +88 -0
  104. package/src/graphiti/capture-policy.js +84 -0
  105. package/src/graphiti/federated-application.js +1292 -0
  106. package/src/graphiti/personal-context.js +29 -0
  107. package/src/graphiti/project-path-context.js +159 -0
  108. package/src/graphiti/provider-client.js +288 -0
  109. package/src/graphiti/runtime-config.js +85 -0
  110. package/src/graphiti/setup.js +43 -0
  111. package/src/graphiti/source-marker.js +142 -0
  112. package/src/http/api-router.js +133 -0
  113. package/src/http/bounded-json-array.js +19 -0
  114. package/src/http/error-mapping.js +36 -0
  115. package/src/http/graph-api-router.js +257 -0
  116. package/src/http/lens-route.js +50 -0
  117. package/src/http/request-policy.js +41 -0
  118. package/src/http/response.js +29 -0
  119. package/src/http/static-handler.js +55 -0
  120. package/src/ingestion.js +161 -0
  121. package/src/lens/lens-correction-service.js +81 -0
  122. package/src/lens/lens-fact-writer.js +85 -0
  123. package/src/lens/lens-input.js +62 -0
  124. package/src/lens/lens-query-projection.js +87 -0
  125. package/src/lens/lens-query-ranking.js +62 -0
  126. package/src/lens/lens-query.js +143 -0
  127. package/src/lens/lens-resource-projection.js +93 -0
  128. package/src/lens/lens-resource-selection.js +45 -0
  129. package/src/lens/lens-resource-service.js +103 -0
  130. package/src/lens/lens-search-query.js +122 -0
  131. package/src/lens/lens-service.js +183 -0
  132. package/src/local-runtime/lifecycle.js +254 -0
  133. package/src/mcp/create-mcp-server.js +85 -0
  134. package/src/mcp/instructions.js +10 -0
  135. package/src/mcp/project-action-preview-tokens.js +72 -0
  136. package/src/mcp/runtime.js +71 -0
  137. package/src/mcp/tool-annotations.js +63 -0
  138. package/src/mcp/tool-result.js +167 -0
  139. package/src/mcp/tool-schema.js +60 -0
  140. package/src/mcp-server.js +76 -0
  141. package/src/models.js +44 -0
  142. package/src/observer.js +83 -0
  143. package/src/package-metadata.js +6 -0
  144. package/src/project-rules.js +33 -0
  145. package/src/publication/canonical-json.js +39 -0
  146. package/src/publication/outbox-service.js +46 -0
  147. package/src/publication/publication-envelope-schema.js +75 -0
  148. package/src/publication/publication-envelope.js +37 -0
  149. package/src/publication/publication-policy.js +82 -0
  150. package/src/publication/publication-service.js +48 -0
  151. package/src/router.js +58 -0
  152. package/src/runtime-options.js +139 -0
  153. package/src/security/episode-visibility.js +14 -0
  154. package/src/security/fact-visibility.js +15 -0
  155. package/src/security/sensitive-content.js +18 -0
  156. package/src/security/source-metadata-policy.js +8 -0
  157. package/src/server/application-lifecycle.js +34 -0
  158. package/src/server/listen.js +24 -0
  159. package/src/server.js +135 -0
  160. package/src/setup/agent-installation-status.js +97 -0
  161. package/src/setup/agents.js +123 -0
  162. package/src/setup/codex-bootstrap.js +118 -0
  163. package/src/setup/codex-config.js +122 -0
  164. package/src/setup/config-backup.js +22 -0
  165. package/src/setup/container-runtime.js +388 -0
  166. package/src/setup/cursor-config.js +61 -0
  167. package/src/setup/graph-runtime.js +360 -0
  168. package/src/setup/node-runtime.js +24 -0
  169. package/src/setup/options.js +91 -0
  170. package/src/setup/paths.js +47 -0
  171. package/src/setup/session-skill.js +91 -0
  172. package/src/setup/setup.js +85 -0
  173. package/src/setup/uninstall.js +90 -0
  174. package/src/storage/episode-evidence-preview.js +100 -0
  175. package/src/storage/fact-search-order.js +18 -0
  176. package/src/storage/file/episode-evidence.js +37 -0
  177. package/src/storage/file/episode-index.js +59 -0
  178. package/src/storage/file/fact-search.js +60 -0
  179. package/src/storage/file-store.js +437 -0
  180. package/src/storage/import-json.js +70 -0
  181. package/src/storage/json-file.js +41 -0
  182. package/src/storage/legacy-record-validation.js +152 -0
  183. package/src/storage/legacy-snapshot-validation.js +32 -0
  184. package/src/storage/migrate.js +128 -0
  185. package/src/storage/migrations/001-initial.sql +83 -0
  186. package/src/storage/migrations/002-storage-integrity.sql +113 -0
  187. package/src/storage/record-validation.js +46 -0
  188. package/src/storage/snapshot-defaults.js +54 -0
  189. package/src/storage/sqlite/candidates-repository.js +84 -0
  190. package/src/storage/sqlite/episode-evidence-repository.js +154 -0
  191. package/src/storage/sqlite/episode-privacy-sql.js +33 -0
  192. package/src/storage/sqlite/episodes-repository.js +52 -0
  193. package/src/storage/sqlite/fact-search-repository.js +162 -0
  194. package/src/storage/sqlite/facts-repository.js +146 -0
  195. package/src/storage/sqlite/imports-repository.js +39 -0
  196. package/src/storage/sqlite/mapper.js +84 -0
  197. package/src/storage/sqlite/open-database.js +15 -0
  198. package/src/storage/sqlite/outbox-repository.js +103 -0
  199. package/src/storage/sqlite/snapshot-repository.js +69 -0
  200. package/src/storage/sqlite/spaces-repository.js +52 -0
  201. package/src/storage/sqlite/subscriptions-repository.js +54 -0
  202. package/src/storage/sqlite-store.js +159 -0
  203. package/src/storage/stable-json.js +24 -0
  204. package/src/storage/store-port.js +20 -0
  205. package/src/store.js +2 -0
  206. package/src/workspace-protocol/array-validation.js +19 -0
  207. package/src/workspace-protocol/base64url-contract.js +27 -0
  208. package/src/workspace-protocol/constants.js +37 -0
  209. package/src/workspace-protocol/error-contract.js +24 -0
  210. package/src/workspace-protocol/fact-contract.js +110 -0
  211. package/src/workspace-protocol/governance-contract.js +47 -0
  212. package/src/workspace-protocol/index.js +53 -0
  213. package/src/workspace-protocol/json-budget.js +19 -0
  214. package/src/workspace-protocol/manifest-contract.js +186 -0
  215. package/src/workspace-protocol/publication-contract.js +212 -0
  216. package/src/workspace-protocol/query-contract.js +149 -0
  217. package/src/workspace-protocol/scalar-contract.js +192 -0
  218. package/src/workspace-protocol/sync-contract.js +184 -0
  219. package/src/workspace-protocol/text-contract.js +39 -0
  220. package/src/workspace-protocol/validation.js +66 -0
@@ -0,0 +1,765 @@
1
+ import asyncio
2
+ import json
3
+ from datetime import datetime, timezone
4
+
5
+ from fastapi import HTTPException
6
+
7
+ from .knowledge_usage_models import (
8
+ KnowledgeUsageCreate,
9
+ KnowledgeUsageItemResult,
10
+ KnowledgeUsageResult,
11
+ )
12
+ from .models import (
13
+ KnowledgeAgentReviewCreate,
14
+ KnowledgeAgentViewCreate,
15
+ KnowledgeAgentViewResult,
16
+ KnowledgeAuditRecord,
17
+ KnowledgeHumanChangeItem,
18
+ KnowledgeHumanChangeSearchRequest,
19
+ KnowledgeHumanChangeSearchResult,
20
+ )
21
+ from .provider_values import json_object, native_datetime, stable_uuid
22
+
23
+
24
+ AGENT_CONFIRMATION_POLICY_VERSION = 'agent-usage-v1'
25
+ AGENT_CONFIRMATION_MIN_USES = 5
26
+ AGENT_CONFIRMATION_MIN_TASKS = 3
27
+ AGENT_CONFIRMATION_CONFIDENCE_CAP = 0.85
28
+ USAGE_WEIGHTS = {
29
+ 'cited': (0.12, 0.04),
30
+ 'applied': (0.18, 0.06),
31
+ }
32
+
33
+
34
+ async def record_human_change(
35
+ store,
36
+ actor: dict,
37
+ space: dict,
38
+ item_id: str,
39
+ item_kind: str,
40
+ *,
41
+ reason: str,
42
+ operation: str,
43
+ ) -> None:
44
+ changed_at = datetime.now(timezone.utc)
45
+ audit_id = stable_uuid(
46
+ space['id'],
47
+ 'knowledge-audit',
48
+ item_kind,
49
+ item_id,
50
+ operation,
51
+ changed_at.isoformat(),
52
+ actor['id'],
53
+ )
54
+ records, _, _ = await store.runtime.driver.execute_query(
55
+ _human_change_query(item_kind),
56
+ space_id=space['id'],
57
+ group_id=space['group_id'],
58
+ item_id=item_id,
59
+ item_kind=item_kind,
60
+ audit_id=audit_id,
61
+ reason=reason,
62
+ operation=operation,
63
+ created_by=actor['id'],
64
+ changed_at=changed_at,
65
+ )
66
+ if not records:
67
+ raise HTTPException(status_code=404, detail='knowledge item not found')
68
+
69
+
70
+ async def record_agent_views(
71
+ store,
72
+ actor: dict,
73
+ request: KnowledgeAgentViewCreate,
74
+ ) -> KnowledgeAgentViewResult:
75
+ store._require_personal()
76
+ space = await store.authorize(actor, request.personal_space_id, 'reader')
77
+ if space['kind'] != 'personal':
78
+ raise HTTPException(status_code=422, detail='Agent view audit is personal-only')
79
+
80
+ recorded = []
81
+ viewed_at = datetime.now(timezone.utc)
82
+ seen = set()
83
+ for item in request.items:
84
+ key = (item.item_kind, item.item_id)
85
+ if key in seen:
86
+ continue
87
+ seen.add(key)
88
+ audit_id = stable_uuid(
89
+ space['id'],
90
+ 'knowledge-audit',
91
+ item.item_kind,
92
+ item.item_id,
93
+ 'agent-view',
94
+ request.tool_name,
95
+ viewed_at.isoformat(),
96
+ actor['id'],
97
+ )
98
+ records, _, _ = await store.runtime.driver.execute_query(
99
+ _agent_view_query(item.item_kind),
100
+ space_id=space['id'],
101
+ group_id=space['group_id'],
102
+ item_id=item.item_id,
103
+ item_kind=item.item_kind,
104
+ audit_id=audit_id,
105
+ tool_name=request.tool_name,
106
+ reason=f'Agent 通过 {request.tool_name} 查看了这条人工变更。',
107
+ created_by=actor['id'],
108
+ viewed_at=viewed_at,
109
+ )
110
+ if records:
111
+ recorded.append(f'{item.item_kind}:{item.item_id}')
112
+ return KnowledgeAgentViewResult(
113
+ recorded_count=len(recorded),
114
+ item_keys=recorded,
115
+ )
116
+
117
+
118
+ async def record_knowledge_usage(
119
+ store,
120
+ actor: dict,
121
+ request: KnowledgeUsageCreate,
122
+ ) -> KnowledgeUsageResult:
123
+ """Record material Agent use; retrieval and automatic preference injection do not call this."""
124
+ store._require_personal()
125
+ space = await store.authorize(actor, request.personal_space_id, 'maintainer')
126
+ if space['kind'] != 'personal':
127
+ raise HTTPException(status_code=422, detail='knowledge usage is personal-only')
128
+
129
+ lock = store._group_locks.setdefault(space['group_id'], asyncio.Lock())
130
+ results = []
131
+ async with lock:
132
+ for item in request.items:
133
+ results.append(
134
+ await _record_one_knowledge_use(
135
+ store,
136
+ actor,
137
+ space,
138
+ request,
139
+ item,
140
+ )
141
+ )
142
+ return KnowledgeUsageResult(
143
+ recorded_count=sum(result.recorded for result in results),
144
+ duplicate_count=sum(not result.recorded for result in results),
145
+ promoted_count=sum(result.promoted for result in results),
146
+ items=results,
147
+ )
148
+
149
+
150
+ async def _record_one_knowledge_use(store, actor, space, request, item):
151
+ state = await _read_usage_state(
152
+ store,
153
+ space,
154
+ item.item_id,
155
+ item.item_kind,
156
+ )
157
+ if state is None:
158
+ raise HTTPException(status_code=404, detail='knowledge item not found')
159
+ if state.get('invalid_at') is not None:
160
+ raise HTTPException(
161
+ status_code=409,
162
+ detail='historical knowledge cannot receive usage evidence',
163
+ )
164
+
165
+ generation = int(state.get('usage_generation') or 1)
166
+ used_at = datetime.now(timezone.utc)
167
+ audit_id = stable_uuid(
168
+ space['id'],
169
+ 'knowledge-usage',
170
+ item.item_kind,
171
+ item.item_id,
172
+ str(generation),
173
+ request.task_id,
174
+ item.use_kind,
175
+ )
176
+ records, _, _ = await store.runtime.driver.execute_query(
177
+ _knowledge_use_query(item.item_kind),
178
+ space_id=space['id'],
179
+ group_id=space['group_id'],
180
+ item_id=item.item_id,
181
+ item_kind=item.item_kind,
182
+ audit_id=audit_id,
183
+ task_id=request.task_id,
184
+ session_id=request.session_id,
185
+ use_kind=item.use_kind,
186
+ usage_generation=generation,
187
+ reason=(
188
+ f'Agent materially {item.use_kind} this knowledge in task '
189
+ f'{request.task_id}.'
190
+ ),
191
+ tool_name=request.tool_name,
192
+ created_by=actor['id'],
193
+ used_at=used_at,
194
+ )
195
+ recorded = bool(records and records[0].get('recorded'))
196
+
197
+ aggregates, _, _ = await store.runtime.driver.execute_query(
198
+ '''
199
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
200
+ [:HAS_KNOWLEDGE_AUDIT]->(audit:FuliKnowledgeAudit {
201
+ item_id: $item_id,
202
+ item_kind: $item_kind,
203
+ action: 'knowledge_used',
204
+ usage_generation: $usage_generation
205
+ })
206
+ RETURN count(audit) AS qualified_use_count,
207
+ count(DISTINCT audit.task_id) AS distinct_task_count,
208
+ sum(CASE audit.use_kind WHEN 'applied' THEN 1 ELSE 0 END)
209
+ AS applied_count,
210
+ sum(CASE audit.use_kind WHEN 'cited' THEN 1 ELSE 0 END)
211
+ AS cited_count,
212
+ max(audit.created_at) AS last_used_at
213
+ ''',
214
+ space_id=space['id'],
215
+ item_id=item.item_id,
216
+ item_kind=item.item_kind,
217
+ usage_generation=generation,
218
+ routing_='r',
219
+ )
220
+ aggregate = dict(aggregates[0]) if aggregates else {}
221
+ qualified_use_count = int(aggregate.get('qualified_use_count') or 0)
222
+ distinct_task_count = int(aggregate.get('distinct_task_count') or 0)
223
+ cited_count = int(aggregate.get('cited_count') or 0)
224
+ applied_count = int(aggregate.get('applied_count') or 0)
225
+ utility_score = min(
226
+ 1.0,
227
+ cited_count * USAGE_WEIGHTS['cited'][0]
228
+ + applied_count * USAGE_WEIGHTS['applied'][0],
229
+ )
230
+ previous_status = state.get('confirmation_status') or 'pending'
231
+ if previous_status == 'confirmed':
232
+ confidence_score = 1.0
233
+ else:
234
+ confidence_score = min(
235
+ AGENT_CONFIRMATION_CONFIDENCE_CAP,
236
+ 0.5
237
+ + cited_count * USAGE_WEIGHTS['cited'][1]
238
+ + applied_count * USAGE_WEIGHTS['applied'][1],
239
+ )
240
+ promoted = (
241
+ previous_status == 'pending'
242
+ and not state.get('has_open_conflict')
243
+ and qualified_use_count >= AGENT_CONFIRMATION_MIN_USES
244
+ and distinct_task_count >= AGENT_CONFIRMATION_MIN_TASKS
245
+ )
246
+ confirmation_status = (
247
+ 'agent_confirmed' if promoted else previous_status
248
+ )
249
+ basis = json_object(state.get('confirmation_basis_json'))
250
+ if promoted:
251
+ basis = _agent_confirmation_basis(basis, used_at)
252
+
253
+ updated, _, _ = await store.runtime.driver.execute_query(
254
+ _usage_score_update_query(item.item_kind),
255
+ group_id=space['group_id'],
256
+ item_id=item.item_id,
257
+ usage_generation=generation,
258
+ utility_score=round(utility_score, 4),
259
+ confidence_score=round(confidence_score, 4),
260
+ qualified_use_count=qualified_use_count,
261
+ distinct_task_count=distinct_task_count,
262
+ last_used_at=native_datetime(aggregate.get('last_used_at')) or used_at,
263
+ confirmation_status=confirmation_status,
264
+ confirmation_basis_json=(
265
+ json.dumps(basis, ensure_ascii=False, sort_keys=True)
266
+ if basis else None
267
+ ),
268
+ promoted=promoted,
269
+ audit_id=audit_id,
270
+ )
271
+ if not updated:
272
+ raise HTTPException(
273
+ status_code=409,
274
+ detail='knowledge changed while usage evidence was recorded; retry',
275
+ )
276
+ return KnowledgeUsageItemResult(
277
+ item_id=item.item_id,
278
+ item_kind=item.item_kind,
279
+ recorded=recorded,
280
+ promoted=promoted,
281
+ confirmation_status=confirmation_status,
282
+ utility_score=round(utility_score, 4),
283
+ confidence_score=round(confidence_score, 4),
284
+ qualified_use_count=qualified_use_count,
285
+ distinct_task_count=distinct_task_count,
286
+ usage_generation=generation,
287
+ )
288
+
289
+
290
+ async def review_human_change(
291
+ store,
292
+ actor: dict,
293
+ item_id: str,
294
+ request: KnowledgeAgentReviewCreate,
295
+ ) -> KnowledgeAuditRecord:
296
+ store._require_personal()
297
+ space = await store.authorize(actor, request.personal_space_id, 'maintainer')
298
+ if space['kind'] != 'personal':
299
+ raise HTTPException(status_code=422, detail='Agent review is personal-only')
300
+
301
+ state = await _read_human_change_state(
302
+ store,
303
+ space,
304
+ item_id,
305
+ request.item_kind,
306
+ )
307
+ if state is None or not state.get('human_edited'):
308
+ raise HTTPException(status_code=404, detail='human-edited knowledge item not found')
309
+ version = int(state.get('human_change_version') or 0)
310
+ if version != request.human_change_version:
311
+ raise HTTPException(
312
+ status_code=409,
313
+ detail='human change version is stale; read the item again before reviewing',
314
+ )
315
+ if state.get('human_change_status') == 'unseen':
316
+ raise HTTPException(
317
+ status_code=409,
318
+ detail='Agent must read the current human change before reviewing it',
319
+ )
320
+
321
+ reviewed = (
322
+ request.conflict_check == 'no_conflict'
323
+ and request.classification_check == 'reasonable'
324
+ )
325
+ outcome = 'reviewed' if reviewed else 'requires_attention'
326
+ next_status = 'reviewed' if reviewed else 'viewed'
327
+ reviewed_at = datetime.now(timezone.utc)
328
+ audit_id = stable_uuid(
329
+ space['id'],
330
+ 'knowledge-audit',
331
+ request.item_kind,
332
+ item_id,
333
+ 'agent-review',
334
+ str(version),
335
+ reviewed_at.isoformat(),
336
+ actor['id'],
337
+ )
338
+ records, _, _ = await store.runtime.driver.execute_query(
339
+ _agent_review_query(request.item_kind),
340
+ space_id=space['id'],
341
+ group_id=space['group_id'],
342
+ item_id=item_id,
343
+ item_kind=request.item_kind,
344
+ audit_id=audit_id,
345
+ human_change_version=version,
346
+ conflict_check=request.conflict_check,
347
+ classification_check=request.classification_check,
348
+ outcome=outcome,
349
+ next_status=next_status,
350
+ reason=request.note,
351
+ created_by=actor['id'],
352
+ reviewed_at=reviewed_at,
353
+ )
354
+ if not records:
355
+ raise HTTPException(
356
+ status_code=409,
357
+ detail='human change moved while it was being reviewed; read it again',
358
+ )
359
+ return _audit_record(dict(records[0]['audit']))
360
+
361
+
362
+ async def search_human_changes(
363
+ store,
364
+ actor: dict,
365
+ request: KnowledgeHumanChangeSearchRequest,
366
+ ) -> KnowledgeHumanChangeSearchResult:
367
+ store._require_personal()
368
+ space = await store.authorize(actor, request.personal_space_id, 'reader')
369
+ if space['kind'] != 'personal':
370
+ raise HTTPException(status_code=422, detail='human change search is personal-only')
371
+
372
+ needle = request.query.casefold().strip()
373
+ common = {
374
+ 'space_id': space['id'],
375
+ 'group_id': space['group_id'],
376
+ 'status': request.status,
377
+ 'search_query': needle,
378
+ 'limit': request.limit,
379
+ 'routing_': 'r',
380
+ }
381
+ entity_records, _, _ = await store.runtime.driver.execute_query(
382
+ _HUMAN_ENTITY_SEARCH,
383
+ **common,
384
+ )
385
+ relationship_records, _, _ = await store.runtime.driver.execute_query(
386
+ _HUMAN_RELATIONSHIP_SEARCH,
387
+ **common,
388
+ )
389
+ values = [
390
+ {**dict(record), 'item_kind': 'entity'}
391
+ for record in entity_records
392
+ ] + [
393
+ {**dict(record), 'item_kind': 'relationship'}
394
+ for record in relationship_records
395
+ ]
396
+ values.sort(
397
+ key=lambda value: native_datetime(value['last_human_changed_at']),
398
+ reverse=True,
399
+ )
400
+ values = values[: request.limit]
401
+ audits = await read_knowledge_audits(
402
+ store,
403
+ space['id'],
404
+ [value['item_id'] for value in values],
405
+ )
406
+ return KnowledgeHumanChangeSearchResult(items=[
407
+ KnowledgeHumanChangeItem(
408
+ **{
409
+ **value,
410
+ 'last_human_changed_at': native_datetime(
411
+ value['last_human_changed_at']
412
+ ),
413
+ 'last_agent_viewed_at': native_datetime(
414
+ value.get('last_agent_viewed_at')
415
+ ),
416
+ 'last_agent_reviewed_at': native_datetime(
417
+ value.get('last_agent_reviewed_at')
418
+ ),
419
+ 'audit_events': audits.get(value['item_id'], []),
420
+ }
421
+ )
422
+ for value in values
423
+ ])
424
+
425
+
426
+ async def read_knowledge_audits(
427
+ store,
428
+ space_id: str,
429
+ item_ids: list[str],
430
+ ) -> dict[str, list[KnowledgeAuditRecord]]:
431
+ if not item_ids:
432
+ return {}
433
+ records, _, _ = await store.runtime.driver.execute_query(
434
+ '''
435
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
436
+ [:HAS_KNOWLEDGE_AUDIT]->(audit:FuliKnowledgeAudit)
437
+ WHERE audit.item_id IN $item_ids
438
+ RETURN audit ORDER BY audit.created_at DESC
439
+ ''',
440
+ space_id=space_id,
441
+ item_ids=item_ids,
442
+ routing_='r',
443
+ )
444
+ result = {}
445
+ for record in records:
446
+ value = _audit_record(dict(record['audit']))
447
+ result.setdefault(value.item_id, []).append(value)
448
+ return result
449
+
450
+
451
+ async def _read_human_change_state(store, space, item_id, item_kind):
452
+ records, _, _ = await store.runtime.driver.execute_query(
453
+ _state_query(item_kind),
454
+ group_id=space['group_id'],
455
+ item_id=item_id,
456
+ routing_='r',
457
+ )
458
+ return dict(records[0]) if records else None
459
+
460
+
461
+ def _audit_record(value: dict) -> KnowledgeAuditRecord:
462
+ return KnowledgeAuditRecord(
463
+ id=value['id'],
464
+ item_id=value['item_id'],
465
+ item_kind=value['item_kind'],
466
+ action=value['action'],
467
+ human_change_version=int(value.get('human_change_version') or 0),
468
+ reason=value['reason'],
469
+ tool_name=value.get('tool_name'),
470
+ task_id=value.get('task_id'),
471
+ session_id=value.get('session_id'),
472
+ use_kind=value.get('use_kind'),
473
+ usage_generation=(
474
+ int(value['usage_generation'])
475
+ if value.get('usage_generation') is not None else None
476
+ ),
477
+ conflict_check=value.get('conflict_check'),
478
+ classification_check=value.get('classification_check'),
479
+ outcome=value.get('outcome'),
480
+ created_at=native_datetime(value['created_at']),
481
+ )
482
+
483
+
484
+ def _item_match(item_kind: str) -> str:
485
+ if item_kind == 'entity':
486
+ return 'MATCH (item:Entity {uuid: $item_id, group_id: $group_id})'
487
+ return 'MATCH ()-[item:RELATES_TO {uuid: $item_id, group_id: $group_id}]->()'
488
+
489
+
490
+ def _human_change_query(item_kind: str) -> str:
491
+ return f'''
492
+ MATCH (space:FuliSpace {{id: $space_id, kind: 'personal'}})
493
+ {_item_match(item_kind)}
494
+ SET item.fuli_human_edited = true,
495
+ item.fuli_human_change_version =
496
+ coalesce(item.fuli_human_change_version, 0) + 1,
497
+ item.fuli_human_change_status = 'unseen',
498
+ item.fuli_last_human_changed_at = $changed_at,
499
+ item.fuli_last_agent_viewed_at = NULL,
500
+ item.fuli_last_agent_reviewed_at = NULL
501
+ CREATE (audit:FuliKnowledgeAudit {{
502
+ id: $audit_id,
503
+ space_id: $space_id,
504
+ item_id: $item_id,
505
+ item_kind: $item_kind,
506
+ action: 'human_change',
507
+ human_change_version: item.fuli_human_change_version,
508
+ reason: $reason,
509
+ operation: $operation,
510
+ outcome: 'pending_review',
511
+ created_by: $created_by,
512
+ created_at: $changed_at
513
+ }})
514
+ MERGE (space)-[:HAS_KNOWLEDGE_AUDIT]->(audit)
515
+ RETURN item.fuli_human_change_version AS human_change_version
516
+ '''
517
+
518
+
519
+ def _agent_view_query(item_kind: str) -> str:
520
+ return f'''
521
+ MATCH (space:FuliSpace {{id: $space_id, kind: 'personal'}})
522
+ {_item_match(item_kind)}
523
+ WHERE item.fuli_human_edited = true
524
+ SET item.fuli_human_change_status =
525
+ CASE WHEN item.fuli_human_change_status = 'reviewed'
526
+ THEN 'reviewed' ELSE 'viewed' END,
527
+ item.fuli_last_agent_viewed_at = $viewed_at
528
+ CREATE (audit:FuliKnowledgeAudit {{
529
+ id: $audit_id,
530
+ space_id: $space_id,
531
+ item_id: $item_id,
532
+ item_kind: $item_kind,
533
+ action: 'agent_view',
534
+ human_change_version: item.fuli_human_change_version,
535
+ reason: $reason,
536
+ tool_name: $tool_name,
537
+ outcome: CASE WHEN item.fuli_human_change_status = 'reviewed'
538
+ THEN 'reviewed' ELSE 'pending_review' END,
539
+ created_by: $created_by,
540
+ created_at: $viewed_at
541
+ }})
542
+ MERGE (space)-[:HAS_KNOWLEDGE_AUDIT]->(audit)
543
+ RETURN audit.id AS audit_id
544
+ '''
545
+
546
+
547
+ def _agent_review_query(item_kind: str) -> str:
548
+ return f'''
549
+ MATCH (space:FuliSpace {{id: $space_id, kind: 'personal'}})
550
+ {_item_match(item_kind)}
551
+ WHERE item.fuli_human_edited = true
552
+ AND item.fuli_human_change_version = $human_change_version
553
+ AND item.fuli_human_change_status IN ['viewed', 'reviewed']
554
+ SET item.fuli_human_change_status = $next_status,
555
+ item.fuli_last_agent_reviewed_at = $reviewed_at
556
+ CREATE (audit:FuliKnowledgeAudit {{
557
+ id: $audit_id,
558
+ space_id: $space_id,
559
+ item_id: $item_id,
560
+ item_kind: $item_kind,
561
+ action: 'agent_review',
562
+ human_change_version: $human_change_version,
563
+ reason: $reason,
564
+ conflict_check: $conflict_check,
565
+ classification_check: $classification_check,
566
+ outcome: $outcome,
567
+ created_by: $created_by,
568
+ created_at: $reviewed_at
569
+ }})
570
+ MERGE (space)-[:HAS_KNOWLEDGE_AUDIT]->(audit)
571
+ RETURN audit
572
+ '''
573
+
574
+
575
+ def _state_query(item_kind: str) -> str:
576
+ return f'''
577
+ {_item_match(item_kind)}
578
+ RETURN coalesce(item.fuli_human_edited, false) AS human_edited,
579
+ coalesce(item.fuli_human_change_version, 0)
580
+ AS human_change_version,
581
+ coalesce(item.fuli_human_change_status, 'none')
582
+ AS human_change_status
583
+ '''
584
+
585
+
586
+ async def _read_usage_state(store, space, item_id, item_kind):
587
+ records, _, _ = await store.runtime.driver.execute_query(
588
+ f'''
589
+ MATCH (space:FuliSpace {{id: $space_id, kind: 'personal'}})
590
+ {_item_match(item_kind)}
591
+ RETURN coalesce(item.fuli_confirmation_status, 'pending')
592
+ AS confirmation_status,
593
+ item.fuli_confirmation_basis_json AS confirmation_basis_json,
594
+ coalesce(item.fuli_usage_generation, 1) AS usage_generation,
595
+ {
596
+ "item.fuli_invalid_at"
597
+ if item_kind == "entity" else "item.invalid_at"
598
+ } AS invalid_at,
599
+ (
600
+ EXISTS {{
601
+ MATCH (space)-[:HAS_KNOWLEDGE_CONFLICT]->
602
+ (knowledge_conflict:FuliKnowledgeConflict)
603
+ WHERE knowledge_conflict.status = 'pending'
604
+ AND (
605
+ knowledge_conflict.item_id = $item_id
606
+ OR knowledge_conflict.target_item_id = $item_id
607
+ )
608
+ }}
609
+ OR EXISTS {{
610
+ MATCH (space)-[:HAS_PREFERENCE_CONFLICT]->
611
+ (preference_conflict:FuliPreferenceConflict)
612
+ WHERE preference_conflict.status = 'ai_pending'
613
+ AND (
614
+ preference_conflict.left_item_id = $item_id
615
+ OR preference_conflict.right_item_id = $item_id
616
+ )
617
+ }}
618
+ ) AS has_open_conflict
619
+ ''',
620
+ space_id=space['id'],
621
+ group_id=space['group_id'],
622
+ item_id=item_id,
623
+ routing_='r',
624
+ )
625
+ return dict(records[0]) if records else None
626
+
627
+
628
+ def _knowledge_use_query(item_kind: str) -> str:
629
+ return f'''
630
+ MATCH (space:FuliSpace {{id: $space_id, kind: 'personal'}})
631
+ {_item_match(item_kind)}
632
+ WHERE coalesce(item.fuli_usage_generation, 1) = $usage_generation
633
+ MERGE (audit:FuliKnowledgeAudit {{id: $audit_id}})
634
+ ON CREATE SET audit.space_id = $space_id,
635
+ audit.item_id = $item_id,
636
+ audit.item_kind = $item_kind,
637
+ audit.action = 'knowledge_used',
638
+ audit.human_change_version = 0,
639
+ audit.task_id = $task_id,
640
+ audit.session_id = $session_id,
641
+ audit.use_kind = $use_kind,
642
+ audit.usage_generation = $usage_generation,
643
+ audit.reason = $reason,
644
+ audit.tool_name = $tool_name,
645
+ audit.created_by = $created_by,
646
+ audit.created_at = $used_at
647
+ MERGE (space)-[:HAS_KNOWLEDGE_AUDIT]->(audit)
648
+ RETURN audit.created_at = $used_at AS recorded
649
+ '''
650
+
651
+
652
+ def _usage_score_update_query(item_kind: str) -> str:
653
+ return f'''
654
+ {_item_match(item_kind)}
655
+ WHERE coalesce(item.fuli_usage_generation, 1) = $usage_generation
656
+ SET item.fuli_utility_score = $utility_score,
657
+ item.fuli_confidence_score = $confidence_score,
658
+ item.fuli_qualified_use_count = $qualified_use_count,
659
+ item.fuli_distinct_task_count = $distinct_task_count,
660
+ item.fuli_last_used_at = $last_used_at,
661
+ item.fuli_confirmation_status = $confirmation_status,
662
+ item.fuli_confirmation_basis_json = $confirmation_basis_json,
663
+ item.fuli_epistemic_status =
664
+ CASE WHEN $confirmation_status = 'confirmed' THEN 'confirmed'
665
+ WHEN item.fuli_origin_quadrant = 'unknown_unknown'
666
+ THEN 'exploratory'
667
+ ELSE 'observed' END
668
+ WITH item
669
+ OPTIONAL MATCH (audit:FuliKnowledgeAudit {{id: $audit_id}})
670
+ FOREACH (_ IN CASE WHEN $promoted AND audit IS NOT NULL THEN [1] ELSE [] END |
671
+ SET audit.outcome = 'agent_confirmed',
672
+ audit.promotion = true
673
+ )
674
+ RETURN item.fuli_confirmation_status AS confirmation_status
675
+ '''
676
+
677
+
678
+ def _agent_confirmation_basis(value: dict, confirmed_at: datetime) -> dict:
679
+ return {
680
+ 'existence_reason': value.get(
681
+ 'existence_reason',
682
+ 'The knowledge was retained from an earlier Agent proposal.',
683
+ ),
684
+ 'quadrant_reason': value.get(
685
+ 'quadrant_reason',
686
+ 'The discovery quadrant remains the immutable capture-time label.',
687
+ ),
688
+ 'proposed_by': value.get(
689
+ 'proposed_by',
690
+ {'kind': 'agent', 'label': 'Fuli'},
691
+ ),
692
+ 'confirmed_by': {
693
+ 'kind': 'agent',
694
+ 'label': 'Fuli usage evidence policy',
695
+ },
696
+ 'confirmed_at': confirmed_at.isoformat(),
697
+ 'agent_policy_version': AGENT_CONFIRMATION_POLICY_VERSION,
698
+ }
699
+
700
+
701
+ _HUMAN_ENTITY_SEARCH = '''
702
+ MATCH (item:Entity {group_id: $group_id})
703
+ WHERE item.fuli_human_edited = true
704
+ AND (
705
+ $status IN ['all', 'human_changed']
706
+ OR item.fuli_human_change_status = $status
707
+ )
708
+ AND (
709
+ $search_query = ''
710
+ OR toLower(item.name) CONTAINS $search_query
711
+ OR toLower(coalesce(item.summary, '')) CONTAINS $search_query
712
+ OR toLower(coalesce(item.fuli_type, 'Entity')) CONTAINS $search_query
713
+ OR EXISTS {
714
+ MATCH (audit:FuliKnowledgeAudit {
715
+ space_id: $space_id,
716
+ item_id: item.uuid
717
+ })
718
+ WHERE toLower(coalesce(audit.reason, '')) CONTAINS $search_query
719
+ }
720
+ )
721
+ RETURN item.uuid AS item_id,
722
+ item.name AS title,
723
+ coalesce(item.summary, '') AS body,
724
+ coalesce(item.fuli_type, 'Entity') AS type,
725
+ item.fuli_human_change_status AS human_change_status,
726
+ item.fuli_human_change_version AS human_change_version,
727
+ item.fuli_last_human_changed_at AS last_human_changed_at,
728
+ item.fuli_last_agent_viewed_at AS last_agent_viewed_at,
729
+ item.fuli_last_agent_reviewed_at AS last_agent_reviewed_at
730
+ ORDER BY item.fuli_last_human_changed_at DESC
731
+ LIMIT $limit
732
+ '''
733
+
734
+
735
+ _HUMAN_RELATIONSHIP_SEARCH = '''
736
+ MATCH ()-[item:RELATES_TO {group_id: $group_id}]->()
737
+ WHERE item.fuli_human_edited = true
738
+ AND (
739
+ $status IN ['all', 'human_changed']
740
+ OR item.fuli_human_change_status = $status
741
+ )
742
+ AND (
743
+ $search_query = ''
744
+ OR toLower(item.name) CONTAINS $search_query
745
+ OR toLower(coalesce(item.fact, '')) CONTAINS $search_query
746
+ OR EXISTS {
747
+ MATCH (audit:FuliKnowledgeAudit {
748
+ space_id: $space_id,
749
+ item_id: item.uuid
750
+ })
751
+ WHERE toLower(coalesce(audit.reason, '')) CONTAINS $search_query
752
+ }
753
+ )
754
+ RETURN item.uuid AS item_id,
755
+ item.name AS title,
756
+ coalesce(item.fact, '') AS body,
757
+ item.name AS type,
758
+ item.fuli_human_change_status AS human_change_status,
759
+ item.fuli_human_change_version AS human_change_version,
760
+ item.fuli_last_human_changed_at AS last_human_changed_at,
761
+ item.fuli_last_agent_viewed_at AS last_agent_viewed_at,
762
+ item.fuli_last_agent_reviewed_at AS last_agent_reviewed_at
763
+ ORDER BY item.fuli_last_human_changed_at DESC
764
+ LIMIT $limit
765
+ '''