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,723 @@
1
+ import re
2
+ from datetime import datetime
3
+
4
+ from fastapi import HTTPException
5
+ from graphiti_core.edges import EntityEdge
6
+
7
+ from .models import ConfirmationBasis, EntitySearchResult, SearchRequest, SearchResult
8
+ from .personal_project_access import authorize_personal_project
9
+ from .provider_values import (
10
+ json_object,
11
+ native_datetime as _native_datetime,
12
+ normalized_text as _normalized,
13
+ )
14
+ from .search_projection import (
15
+ effective_confirmation_status,
16
+ fact_result,
17
+ is_default_retrievable,
18
+ read_edge_epistemic_metadata,
19
+ )
20
+
21
+ MIN_TERM_COVERAGE = 0.25
22
+ LATIN_QUERY_FILLERS = {
23
+ 'a', 'an', 'and', 'are', 'can', 'could', 'do', 'does', 'find', 'for',
24
+ 'from', 'give', 'how', 'i', 'in', 'is', 'it', 'me', 'of', 'on', 'please',
25
+ 'tell', 'that', 'the', 'this', 'to', 'was', 'what', 'when', 'where', 'which',
26
+ 'why', 'with', 'you',
27
+ }
28
+ CHINESE_QUERY_FILLERS = (
29
+ '请问', '麻烦', '帮忙', '帮我', '告诉我', '查找', '查询', '是什么', '在哪里',
30
+ '在哪儿', '为什么', '怎么样', '怎样', '怎么', '哪里', '哪个', '多少', '有没有',
31
+ '是否', '一下',
32
+ )
33
+
34
+
35
+ async def search_knowledge(store, actor: dict, request: SearchRequest) -> SearchResult:
36
+ spaces = [await store.authorize(actor, space_id, 'reader') for space_id in request.space_ids]
37
+ personal_space = await _personal_search_space(store, actor, spaces, request)
38
+ space_ids_by_group = {space['group_id']: space['id'] for space in spaces}
39
+ group_ids = [space['group_id'] for space in spaces]
40
+ edges = await store.runtime.graphiti.search(
41
+ request.query,
42
+ group_ids=group_ids,
43
+ num_results=min(max(request.limit * 8, 40), 100),
44
+ )
45
+ if not request.include_historical:
46
+ edges = [edge for edge in edges if edge.invalid_at is None]
47
+ project_scopes = {}
48
+ if personal_space:
49
+ project_scopes = await personal_project_scopes(
50
+ store,
51
+ personal_space,
52
+ request,
53
+ )
54
+ allowed = await personal_edge_scopes(
55
+ store,
56
+ personal_space,
57
+ request,
58
+ project_scopes,
59
+ request.include_personal_global,
60
+ [edge.uuid for edge in edges],
61
+ )
62
+ edges = [edge for edge in edges if edge.uuid in allowed]
63
+ epistemic = await read_edge_epistemic_metadata(store, [edge.uuid for edge in edges])
64
+ if personal_space:
65
+ epistemic = {
66
+ edge_id: {
67
+ **metadata,
68
+ **allowed.get(edge_id, {}),
69
+ }
70
+ for edge_id, metadata in epistemic.items()
71
+ }
72
+ if not request.include_pending:
73
+ edges = [
74
+ edge for edge in edges
75
+ if is_default_retrievable(epistemic.get(edge.uuid, {}))
76
+ ]
77
+ names = await _entity_names(store, edges)
78
+ ranked_edges = sorted(
79
+ (
80
+ (
81
+ _ranked_relevance(
82
+ _edge_relevance(
83
+ request.query,
84
+ edge,
85
+ names,
86
+ position,
87
+ len(edges),
88
+ ),
89
+ epistemic.get(edge.uuid, {}),
90
+ ),
91
+ edge,
92
+ )
93
+ for position, edge in enumerate(edges)
94
+ ),
95
+ key=lambda item: item[0],
96
+ reverse=True,
97
+ )
98
+ ranked_edges = [item for item in ranked_edges if item[0] > 0]
99
+ if personal_space:
100
+ ranked_edges = _dedupe_ranked_edges(
101
+ ranked_edges,
102
+ epistemic,
103
+ request.active_personal_project_id,
104
+ )
105
+ ranked_edges = ranked_edges[: request.limit]
106
+ entities = (
107
+ await _personal_entities(
108
+ store,
109
+ personal_space,
110
+ request,
111
+ project_scopes,
112
+ )
113
+ if personal_space else []
114
+ )
115
+ return SearchResult(
116
+ facts=[
117
+ fact_result(
118
+ edge,
119
+ names,
120
+ space_ids_by_group[edge.group_id],
121
+ epistemic.get(edge.uuid, {}),
122
+ ).model_copy(
123
+ update={'score': score}
124
+ )
125
+ for score, edge in ranked_edges
126
+ ],
127
+ entities=entities,
128
+ )
129
+
130
+
131
+ async def personal_edge_ids(
132
+ store,
133
+ space: dict,
134
+ project_ids: list[str],
135
+ include_personal_global: bool,
136
+ edge_ids: list[str],
137
+ ) -> set[str]:
138
+ request = SearchRequest(
139
+ space_ids=[space['id']],
140
+ query='scope',
141
+ personal_project_ids=project_ids,
142
+ include_personal_global=include_personal_global,
143
+ )
144
+ scopes = {
145
+ project_id: {
146
+ 'scope_distance': 0,
147
+ 'scope_path': [project_id],
148
+ 'inherited': False,
149
+ }
150
+ for project_id in project_ids
151
+ }
152
+ return set(await personal_edge_scopes(
153
+ store,
154
+ space,
155
+ request,
156
+ scopes,
157
+ include_personal_global,
158
+ edge_ids,
159
+ ))
160
+
161
+
162
+ async def personal_edge_scopes(
163
+ store,
164
+ space: dict,
165
+ request: SearchRequest,
166
+ project_scopes: dict[str, dict],
167
+ include_personal_global: bool,
168
+ edge_ids: list[str],
169
+ ) -> dict[str, dict]:
170
+ if not edge_ids or (not project_scopes and not include_personal_global):
171
+ return {}
172
+ records, _, _ = await store.runtime.driver.execute_query(
173
+ '''
174
+ MATCH ()-[edge:RELATES_TO {group_id: $group_id}]->()
175
+ WHERE edge.uuid IN $edge_ids
176
+ OPTIONAL MATCH (assignment:FuliKnowledgeAssignment {
177
+ space_id: $space_id,
178
+ item_kind: 'relationship',
179
+ item_id: edge.uuid
180
+ })
181
+ WITH edge, assignment
182
+ OPTIONAL MATCH (episode:Episodic {group_id: $group_id})
183
+ WHERE episode.uuid IN coalesce(edge.episodes, [])
184
+ WITH edge, assignment, collect(DISTINCT episode) AS episodes
185
+ OPTIONAL MATCH (:FuliSpace {id: $space_id})-[:HAS_KNOWLEDGE_REFERENCE]->
186
+ (reference:FuliKnowledgeProjectReference {
187
+ item_kind: 'relationship', item_id: edge.uuid, status: 'active'
188
+ })
189
+ RETURN edge.uuid AS id,
190
+ edge.fuli_profile_aspect AS profile_aspect,
191
+ coalesce(edge.fuli_preference_scope, 'global')
192
+ AS preference_scope,
193
+ edge.fuli_preference_project_id AS preference_project_id,
194
+ assignment.project_id AS assignment_project_id,
195
+ [episode IN episodes
196
+ WHERE episode.fuli_personal_project_id IS NOT NULL |
197
+ episode.fuli_personal_project_id] AS episode_project_ids,
198
+ any(episode IN episodes
199
+ WHERE episode.fuli_personal_project_id IS NULL)
200
+ AS has_global_episode,
201
+ collect(DISTINCT reference.project_id) AS reference_project_ids,
202
+ coalesce(edge.fuli_inheritance_mode, 'local_only')
203
+ AS inheritance_mode,
204
+ coalesce(edge.fuli_inherited_project_ids, [])
205
+ AS inherited_project_ids
206
+ ''',
207
+ group_id=space['group_id'],
208
+ space_id=space['id'],
209
+ project_ids=list(project_scopes),
210
+ include_personal_global=include_personal_global,
211
+ edge_ids=edge_ids,
212
+ routing_='r',
213
+ )
214
+ return {
215
+ record['id']: scope
216
+ for record in records
217
+ if (
218
+ scope := _item_scope_metadata(
219
+ record,
220
+ request,
221
+ project_scopes,
222
+ include_personal_global,
223
+ )
224
+ ) is not None
225
+ }
226
+
227
+
228
+ async def personal_project_scopes(
229
+ store,
230
+ space: dict,
231
+ request: SearchRequest,
232
+ ) -> dict[str, dict]:
233
+ scopes = {
234
+ project_id: {
235
+ 'scope_distance': 0,
236
+ 'scope_path': [project_id],
237
+ 'inherited': False,
238
+ }
239
+ for project_id in request.personal_project_ids
240
+ }
241
+ active_id = request.active_personal_project_id
242
+ if (
243
+ not active_id
244
+ or not request.inherit_project_knowledge
245
+ ):
246
+ return scopes
247
+ records, _, _ = await store.runtime.driver.execute_query(
248
+ '''
249
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
250
+ [:CONTAINS_PROJECT]->
251
+ (active:FuliPersonalProject {project_id: $active_project_id})
252
+ MATCH path=(active)-[:PERSONAL_PROJECT_RELATION*1..2]->
253
+ (source:FuliPersonalProject)
254
+ WHERE all(
255
+ relation IN relationships(path)
256
+ WHERE relation.relation_type IN ['PART_OF', 'USES_KNOWLEDGE_FROM']
257
+ )
258
+ AND EXISTS {
259
+ MATCH (space)-[:CONTAINS_PROJECT]->(source)
260
+ }
261
+ RETURN source.project_id AS project_id,
262
+ [node IN nodes(path) | node.project_id] AS scope_path,
263
+ length(path) AS scope_distance
264
+ ORDER BY scope_distance ASC
265
+ LIMIT $inheritance_limit
266
+ ''',
267
+ space_id=space['id'],
268
+ active_project_id=active_id,
269
+ inheritance_limit=min(
270
+ int(getattr(getattr(store, 'settings', None), 'graph_limit', 500)),
271
+ 64,
272
+ ),
273
+ routing_='r',
274
+ )
275
+ for record in records:
276
+ project_id = record['project_id']
277
+ distance = int(record['scope_distance'])
278
+ existing = scopes.get(project_id)
279
+ if existing is not None and existing['scope_distance'] <= distance:
280
+ continue
281
+ path = list(record.get('scope_path') or [])
282
+ if len(path) != len(set(path)):
283
+ continue
284
+ scopes[project_id] = {
285
+ 'scope_distance': distance,
286
+ 'scope_path': path,
287
+ 'inherited': True,
288
+ }
289
+ return scopes
290
+
291
+
292
+ async def _personal_search_space(store, actor, spaces, request):
293
+ personal_spaces = [space for space in spaces if space['kind'] == 'personal']
294
+ if not personal_spaces:
295
+ if request.personal_project_ids or request.include_personal_global:
296
+ raise HTTPException(
297
+ status_code=422,
298
+ detail='personal context can only be used with one personal space',
299
+ )
300
+ return None
301
+ if len(spaces) != 1:
302
+ raise HTTPException(
303
+ status_code=422,
304
+ detail='personal context search requires exactly one personal space',
305
+ )
306
+ space = personal_spaces[0]
307
+ for project_id in request.personal_project_ids:
308
+ await authorize_personal_project(store, actor, space, project_id)
309
+ return space
310
+
311
+
312
+ async def _personal_entities(
313
+ store,
314
+ space,
315
+ request: SearchRequest,
316
+ project_scopes: dict[str, dict],
317
+ ) -> list[EntitySearchResult]:
318
+ if not project_scopes and not request.include_personal_global:
319
+ return []
320
+ records, _, _ = await store.runtime.driver.execute_query(
321
+ '''
322
+ MATCH (node:Entity {group_id: $group_id})
323
+ OPTIONAL MATCH (episode:Episodic {group_id: $group_id})-[:MENTIONS]->(node)
324
+ WITH node, collect(DISTINCT episode) AS episodes
325
+ OPTIONAL MATCH (assignment:FuliKnowledgeAssignment {
326
+ space_id: $space_id,
327
+ item_kind: 'entity',
328
+ item_id: node.uuid
329
+ })
330
+ OPTIONAL MATCH (:FuliSpace {id: $space_id})-[:HAS_KNOWLEDGE_REFERENCE]->
331
+ (reference:FuliKnowledgeProjectReference {
332
+ item_kind: 'entity', item_id: node.uuid, status: 'active'
333
+ })
334
+ WITH node, episodes, assignment, collect(DISTINCT reference) AS references
335
+ WHERE ($include_historical OR node.fuli_invalid_at IS NULL)
336
+ AND (
337
+ $include_pending
338
+ OR coalesce(node.fuli_confirmation_status, 'pending')
339
+ IN ['confirmed', 'agent_confirmed']
340
+ )
341
+ AND NOT EXISTS {
342
+ MATCH (:FuliSpace {id: $space_id})-[:HAS_KNOWLEDGE_CONFLICT]->
343
+ (conflict:FuliKnowledgeConflict)
344
+ WHERE conflict.target_item_id = node.uuid
345
+ AND conflict.target_project_id IN $project_ids
346
+ AND conflict.status = 'resolved'
347
+ AND conflict.resolution = 'use_source'
348
+ }
349
+ RETURN node.uuid AS id, node.group_id AS group_id,
350
+ node.name AS name, coalesce(node.fuli_type, 'Entity') AS type,
351
+ coalesce(node.summary, '') AS summary,
352
+ node.created_at AS created_at,
353
+ coalesce(node.fuli_origin_quadrant, 'known_known') AS origin_quadrant,
354
+ coalesce(node.fuli_current_quadrant, 'known_known') AS current_quadrant,
355
+ coalesce(node.fuli_epistemic_status, 'confirmed') AS epistemic_status,
356
+ coalesce(node.fuli_confirmation_status, 'pending')
357
+ AS confirmation_status,
358
+ node.fuli_confirmation_basis_json AS confirmation_basis_json,
359
+ node.fuli_reasoning_summary AS reasoning_summary,
360
+ node.fuli_profile_aspect AS profile_aspect,
361
+ CASE WHEN node.fuli_profile_aspect IS NULL THEN NULL
362
+ ELSE coalesce(node.fuli_preference_scope, 'global') END
363
+ AS preference_scope,
364
+ node.fuli_preference_project_id AS preference_project_id,
365
+ coalesce(node.fuli_key, node.uuid) AS key,
366
+ assignment.project_id AS assignment_project_id,
367
+ [episode IN episodes
368
+ WHERE episode.fuli_personal_project_id IS NOT NULL |
369
+ episode.fuli_personal_project_id] AS episode_project_ids,
370
+ any(episode IN episodes
371
+ WHERE episode.fuli_personal_project_id IS NULL)
372
+ AS has_global_episode,
373
+ [reference IN references | reference.project_id]
374
+ AS reference_project_ids,
375
+ coalesce(node.fuli_inheritance_mode, 'local_only')
376
+ AS inheritance_mode,
377
+ coalesce(node.fuli_inherited_project_ids, [])
378
+ AS inherited_project_ids,
379
+ coalesce(node.fuli_human_edited, false) AS human_edited,
380
+ coalesce(node.fuli_human_change_status, 'none')
381
+ AS human_change_status,
382
+ coalesce(node.fuli_human_change_version, 0)
383
+ AS human_change_version,
384
+ node.fuli_last_human_changed_at AS last_human_changed_at,
385
+ node.fuli_last_agent_viewed_at AS last_agent_viewed_at,
386
+ node.fuli_last_agent_reviewed_at AS last_agent_reviewed_at,
387
+ coalesce(node.fuli_utility_score, 0.0) AS utility_score,
388
+ coalesce(node.fuli_confidence_score, 0.5) AS confidence_score,
389
+ coalesce(node.fuli_qualified_use_count, 0)
390
+ AS qualified_use_count,
391
+ coalesce(node.fuli_distinct_task_count, 0)
392
+ AS distinct_task_count,
393
+ node.fuli_last_used_at AS last_used_at
394
+ ORDER BY node.name, node.uuid
395
+ LIMIT $candidate_limit
396
+ ''',
397
+ group_id=space['group_id'],
398
+ space_id=space['id'],
399
+ project_ids=list(project_scopes),
400
+ include_historical=request.include_historical,
401
+ include_pending=request.include_pending,
402
+ candidate_limit=store.settings.graph_limit,
403
+ routing_='r',
404
+ )
405
+ scoped_records = []
406
+ for record in records:
407
+ record = dict(record)
408
+ effective_status = effective_confirmation_status(record)
409
+ if effective_status != record.get('confirmation_status'):
410
+ record['confidence_score'] = min(
411
+ float(record.get('confidence_score') or 0.5),
412
+ 0.5,
413
+ )
414
+ record['confirmation_status'] = effective_status
415
+ if (
416
+ not request.include_pending
417
+ and not is_default_retrievable(record)
418
+ ):
419
+ continue
420
+ scope = _item_scope_metadata(
421
+ record,
422
+ request,
423
+ project_scopes,
424
+ request.include_personal_global,
425
+ )
426
+ if scope is not None:
427
+ scoped_records.append({**record, **scope})
428
+ ranked = sorted(
429
+ (
430
+ (
431
+ record,
432
+ _ranked_relevance(
433
+ _relevance(request.query, record),
434
+ record,
435
+ ),
436
+ )
437
+ for record in scoped_records
438
+ ),
439
+ key=lambda item: (item[1], _timestamp(item[0].get('created_at'))),
440
+ reverse=True,
441
+ )
442
+ ranked = [item for item in ranked if item[1] > 0]
443
+ ranked = _dedupe_ranked_entities(
444
+ ranked,
445
+ request.active_personal_project_id,
446
+ )[: request.limit]
447
+ return [
448
+ _entity_search_result(record, score, space['id'])
449
+ for record, score in ranked
450
+ ]
451
+
452
+
453
+ def _item_scope_metadata(
454
+ record,
455
+ request: SearchRequest,
456
+ project_scopes: dict[str, dict],
457
+ include_personal_global: bool,
458
+ ) -> dict | None:
459
+ if not any(
460
+ field in record
461
+ for field in (
462
+ 'profile_aspect',
463
+ 'assignment_project_id',
464
+ 'episode_project_ids',
465
+ 'reference_project_ids',
466
+ 'has_global_episode',
467
+ )
468
+ ):
469
+ # Backward-compatible projection for older Provider clients that returned
470
+ # only already-filtered item IDs.
471
+ return _global_scope_metadata()
472
+ profile_aspect = record.get('profile_aspect')
473
+ if profile_aspect:
474
+ preference_scope = record.get('preference_scope') or 'global'
475
+ if preference_scope == 'global':
476
+ if not include_personal_global:
477
+ return None
478
+ return _global_scope_metadata()
479
+ project_id = record.get('preference_project_id')
480
+ if project_id not in request.personal_project_ids:
481
+ return None
482
+ return _project_scope_metadata(project_id, project_scopes[project_id])
483
+
484
+ assignment_project_id = record.get('assignment_project_id')
485
+ episode_project_ids = list(record.get('episode_project_ids') or [])
486
+ reference_project_ids = list(record.get('reference_project_ids') or [])
487
+ project_ids = (
488
+ [assignment_project_id]
489
+ if assignment_project_id else episode_project_ids
490
+ )
491
+ project_ids = list(dict.fromkeys([
492
+ *project_ids,
493
+ *reference_project_ids,
494
+ ]))
495
+ candidates = []
496
+ active_id = request.active_personal_project_id
497
+ inheritance_mode = record.get('inheritance_mode') or 'local_only'
498
+ selected_project_ids = set(record.get('inherited_project_ids') or [])
499
+ for project_id in project_ids:
500
+ scope = project_scopes.get(project_id)
501
+ if scope is None:
502
+ continue
503
+ if scope.get('inherited'):
504
+ eligible = (
505
+ inheritance_mode == 'descendants'
506
+ or (
507
+ inheritance_mode == 'selected_projects'
508
+ and active_id in selected_project_ids
509
+ )
510
+ )
511
+ if not eligible:
512
+ continue
513
+ candidates.append((project_id, scope))
514
+ if candidates:
515
+ project_id, scope = min(
516
+ candidates,
517
+ key=lambda item: (
518
+ 0 if item[0] == active_id else 1,
519
+ 1 if item[1].get('inherited') else 0,
520
+ int(item[1].get('scope_distance') or 0),
521
+ item[0],
522
+ ),
523
+ )
524
+ return _project_scope_metadata(project_id, scope)
525
+ if include_personal_global and record.get('has_global_episode'):
526
+ return _global_scope_metadata()
527
+ return None
528
+
529
+
530
+ def _global_scope_metadata() -> dict:
531
+ return {
532
+ 'defined_project_id': None,
533
+ 'scope_distance': 0,
534
+ 'inherited_from_project_id': None,
535
+ 'scope_path': [],
536
+ 'inherited': False,
537
+ }
538
+
539
+
540
+ def _project_scope_metadata(project_id: str, scope: dict) -> dict:
541
+ inherited = scope.get('inherited') is True
542
+ return {
543
+ 'defined_project_id': project_id,
544
+ 'scope_distance': int(scope.get('scope_distance') or 0),
545
+ 'inherited_from_project_id': project_id if inherited else None,
546
+ 'scope_path': list(scope.get('scope_path') or [project_id]),
547
+ 'inherited': inherited,
548
+ }
549
+
550
+
551
+ def _ranked_relevance(base_score: float, metadata) -> float:
552
+ if base_score <= 0:
553
+ return 0.0
554
+ status_factor = {
555
+ 'confirmed': 1.0,
556
+ 'agent_confirmed': 0.88,
557
+ 'pending': 0.72,
558
+ }.get(metadata.get('confirmation_status'), 0.72)
559
+ confidence = float(
560
+ metadata['confidence_score']
561
+ if metadata.get('confidence_score') is not None else 0.5
562
+ )
563
+ utility = float(metadata.get('utility_score') or 0)
564
+ scope_distance = int(metadata.get('scope_distance') or 0)
565
+ confidence_factor = 0.75 + min(max(confidence, 0), 1) * 0.25
566
+ utility_factor = 0.95 + min(max(utility, 0), 1) * 0.1
567
+ scope_factor = max(0.65, 1.0 - scope_distance * 0.15)
568
+ return round(
569
+ base_score
570
+ * status_factor
571
+ * confidence_factor
572
+ * utility_factor
573
+ * scope_factor,
574
+ 4,
575
+ )
576
+
577
+
578
+ def _scope_precedence(metadata, active_project_id: str | None) -> tuple:
579
+ project_id = metadata.get('defined_project_id')
580
+ return (
581
+ 0 if active_project_id and project_id == active_project_id else 1,
582
+ 1 if metadata.get('inherited_from_project_id') else 0,
583
+ int(metadata.get('scope_distance') or 0),
584
+ )
585
+
586
+
587
+ def _dedupe_ranked_edges(ranked, metadata_by_id, active_project_id):
588
+ selected = {}
589
+ for score, edge in ranked:
590
+ metadata = metadata_by_id.get(edge.uuid, {})
591
+ key = metadata.get('key') or edge.uuid
592
+ quality = (_scope_precedence(metadata, active_project_id), -score)
593
+ current = selected.get(key)
594
+ if current is None or quality < current[0]:
595
+ selected[key] = (quality, score, edge)
596
+ return sorted(
597
+ ((score, edge) for _, score, edge in selected.values()),
598
+ key=lambda item: item[0],
599
+ reverse=True,
600
+ )
601
+
602
+
603
+ def _dedupe_ranked_entities(ranked, active_project_id):
604
+ selected = {}
605
+ for record, score in ranked:
606
+ key = record.get('key') or record.get('id')
607
+ quality = (_scope_precedence(record, active_project_id), -score)
608
+ current = selected.get(key)
609
+ if current is None or quality < current[0]:
610
+ selected[key] = (quality, record, score)
611
+ return sorted(
612
+ ((record, score) for _, record, score in selected.values()),
613
+ key=lambda item: (item[1], _timestamp(item[0].get('created_at'))),
614
+ reverse=True,
615
+ )
616
+
617
+
618
+ def _entity_search_result(record, score: float, space_id: str) -> EntitySearchResult:
619
+ value = dict(record)
620
+ basis = json_object(value.pop('confirmation_basis_json', None))
621
+ created_at = _native_datetime(value.pop('created_at', None))
622
+ last_human_changed_at = _native_datetime(
623
+ value.pop('last_human_changed_at', None)
624
+ )
625
+ last_agent_viewed_at = _native_datetime(
626
+ value.pop('last_agent_viewed_at', None)
627
+ )
628
+ last_agent_reviewed_at = _native_datetime(
629
+ value.pop('last_agent_reviewed_at', None)
630
+ )
631
+ last_used_at = _native_datetime(value.pop('last_used_at', None))
632
+ for field in (
633
+ 'assignment_project_id',
634
+ 'episode_project_ids',
635
+ 'has_global_episode',
636
+ 'reference_project_ids',
637
+ 'inherited',
638
+ ):
639
+ value.pop(field, None)
640
+ return EntitySearchResult(
641
+ **value,
642
+ space_id=space_id,
643
+ created_at=created_at,
644
+ last_human_changed_at=last_human_changed_at,
645
+ last_agent_viewed_at=last_agent_viewed_at,
646
+ last_agent_reviewed_at=last_agent_reviewed_at,
647
+ last_used_at=last_used_at,
648
+ confirmation_basis=(
649
+ ConfirmationBasis.model_validate(basis) if basis else None
650
+ ),
651
+ score=score,
652
+ )
653
+
654
+
655
+ async def _entity_names(store, edges: list[EntityEdge]) -> dict[str, str]:
656
+ uuids = list({
657
+ uuid
658
+ for edge in edges
659
+ for uuid in (edge.source_node_uuid, edge.target_node_uuid)
660
+ })
661
+ if not uuids:
662
+ return {}
663
+ records, _, _ = await store.runtime.driver.execute_query(
664
+ 'MATCH (n:Entity) WHERE n.uuid IN $uuids RETURN n.uuid AS uuid, n.name AS name',
665
+ uuids=uuids,
666
+ routing_='r',
667
+ )
668
+ return {record['uuid']: record['name'] for record in records}
669
+
670
+
671
+ def _relevance(query: str, record) -> float:
672
+ needle = _normalized(query)
673
+ haystack = _normalized(' '.join(filter(None, [
674
+ record.get('name'), record.get('type'), record.get('summary'),
675
+ record.get('reasoning_summary'), record.get('profile_aspect'),
676
+ ])))
677
+ if not needle:
678
+ return 0.0
679
+ score = 8.0 if needle in haystack else 0.0
680
+ terms = _terms(needle)
681
+ if terms:
682
+ coverage = len(terms & _terms(haystack)) / len(terms)
683
+ if not score and coverage < MIN_TERM_COVERAGE:
684
+ return 0.0
685
+ score += coverage * 4.0
686
+ if score and record.get('profile_aspect'):
687
+ score += 0.25
688
+ return round(score, 4)
689
+
690
+
691
+ def _edge_relevance(query: str, edge, names: dict[str, str], position: int, total: int) -> float:
692
+ lexical = _relevance(query, {
693
+ 'name': edge.name,
694
+ 'type': 'Relationship',
695
+ 'summary': ' '.join(filter(None, [
696
+ edge.fact,
697
+ names.get(edge.source_node_uuid),
698
+ names.get(edge.target_node_uuid),
699
+ ])),
700
+ 'reasoning_summary': None,
701
+ 'profile_aspect': None,
702
+ })
703
+ if lexical == 0:
704
+ return 0.0
705
+ semantic_order = max(0.0, (total - position) / max(total, 1)) * 0.5
706
+ return round(lexical + semantic_order, 4)
707
+
708
+
709
+ def _terms(value: str) -> set[str]:
710
+ latin = {
711
+ term for term in re.findall(r'[a-z0-9_]+', value)
712
+ if term not in LATIN_QUERY_FILLERS
713
+ }
714
+ chinese = ''.join(re.findall(r'[\u4e00-\u9fff]', value))
715
+ for filler in CHINESE_QUERY_FILLERS:
716
+ chinese = chinese.replace(filler, '')
717
+ grams = {chinese[index:index + 2] for index in range(max(0, len(chinese) - 1))}
718
+ return latin | grams
719
+
720
+
721
+ def _timestamp(value) -> float:
722
+ native = _native_datetime(value)
723
+ return native.timestamp() if isinstance(native, datetime) else 0.0