ltcai 9.0.0 → 9.1.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.
- package/README.md +80 -46
- package/auto_setup.py +7 -853
- package/desktop/electron/README.md +9 -0
- package/desktop/electron/main.cjs +5 -3
- package/docs/CHANGELOG.md +146 -2
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
- package/docs/DEVELOPMENT.md +53 -14
- package/docs/LEGACY_COMPATIBILITY.md +4 -4
- package/docs/ONBOARDING.md +10 -2
- package/docs/OPERATIONS.md +8 -4
- package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
- package/docs/TRUST_MODEL.md +5 -2
- package/docs/WHY_LATTICE.md +15 -10
- package/docs/WORKFLOW_DESIGNER.md +22 -0
- package/docs/kg-schema.md +13 -2
- package/docs/mcp-tools.md +17 -6
- package/docs/privacy.md +19 -3
- package/docs/public-deploy.md +32 -3
- package/docs/security-model.md +46 -11
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +1 -6
- package/lattice_brain/context.py +66 -9
- package/lattice_brain/graph/_kg_fsutil.py +1 -5
- package/lattice_brain/graph/discovery_index.py +174 -20
- package/lattice_brain/graph/documents.py +44 -9
- package/lattice_brain/graph/ingest.py +47 -20
- package/lattice_brain/graph/provenance.py +13 -6
- package/lattice_brain/graph/retrieval.py +140 -39
- package/lattice_brain/graph/retrieval_docgen.py +37 -4
- package/lattice_brain/ingestion.py +4 -7
- package/lattice_brain/portability.py +28 -14
- package/lattice_brain/runtime/agent_runtime.py +5 -9
- package/lattice_brain/runtime/hooks.py +30 -13
- package/lattice_brain/runtime/multi_agent.py +27 -8
- package/lattice_brain/runtime/statuses.py +10 -0
- package/lattice_brain/utils.py +20 -2
- package/lattice_brain/workflow.py +1 -5
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agent_registry.py +10 -8
- package/latticeai/api/agents.py +22 -3
- package/latticeai/api/auth.py +78 -16
- package/latticeai/api/browser.py +303 -34
- package/latticeai/api/chat.py +320 -850
- package/latticeai/api/chat_agent_http.py +356 -0
- package/latticeai/api/chat_contracts.py +54 -0
- package/latticeai/api/chat_documents.py +256 -0
- package/latticeai/api/chat_helpers.py +24 -1
- package/latticeai/api/chat_history.py +99 -0
- package/latticeai/api/chat_intents.py +302 -0
- package/latticeai/api/chat_stream.py +115 -0
- package/latticeai/api/computer_use.py +62 -9
- package/latticeai/api/health.py +9 -3
- package/latticeai/api/hooks.py +17 -6
- package/latticeai/api/knowledge_graph.py +78 -14
- package/latticeai/api/local_files.py +7 -2
- package/latticeai/api/mcp.py +102 -23
- package/latticeai/api/models.py +72 -33
- package/latticeai/api/network.py +5 -5
- package/latticeai/api/permissions.py +67 -26
- package/latticeai/api/plugins.py +21 -7
- package/latticeai/api/portability.py +5 -5
- package/latticeai/api/realtime.py +33 -5
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +123 -24
- package/latticeai/api/tools.py +96 -14
- package/latticeai/api/workflow_designer.py +37 -0
- package/latticeai/api/workspace.py +2 -1
- package/latticeai/app_factory.py +110 -52
- package/latticeai/core/agent.py +19 -9
- package/latticeai/core/agent_registry.py +1 -5
- package/latticeai/core/config.py +23 -3
- package/latticeai/core/context_builder.py +21 -3
- package/latticeai/core/invitations.py +1 -4
- package/latticeai/core/io_utils.py +9 -1
- package/latticeai/core/legacy_compatibility.py +24 -3
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/plugins.py +15 -0
- package/latticeai/core/policy.py +1 -1
- package/latticeai/core/realtime.py +38 -15
- package/latticeai/core/sessions.py +7 -2
- package/latticeai/core/timeutil.py +10 -0
- package/latticeai/core/tool_registry.py +90 -18
- package/latticeai/core/users.py +1 -5
- package/latticeai/core/workspace_graph_trace.py +31 -5
- package/latticeai/core/workspace_memory.py +3 -1
- package/latticeai/core/workspace_os.py +18 -7
- package/latticeai/core/workspace_os_utils.py +0 -5
- package/latticeai/core/workspace_permissions.py +2 -1
- package/latticeai/core/workspace_plugins.py +1 -1
- package/latticeai/core/workspace_runs.py +14 -5
- package/latticeai/core/workspace_skills.py +1 -1
- package/latticeai/core/workspace_snapshots.py +2 -1
- package/latticeai/core/workspace_timeline.py +2 -1
- package/latticeai/integrations/telegram_bot.py +94 -43
- package/latticeai/models/router.py +130 -36
- package/latticeai/runtime/access_runtime.py +62 -4
- package/latticeai/runtime/automation_runtime.py +13 -7
- package/latticeai/runtime/brain_runtime.py +19 -7
- package/latticeai/runtime/chat_wiring.py +2 -0
- package/latticeai/runtime/config_runtime.py +58 -26
- package/latticeai/runtime/context_runtime.py +16 -4
- package/latticeai/runtime/hooks_runtime.py +1 -1
- package/latticeai/runtime/model_wiring.py +33 -5
- package/latticeai/runtime/namespace_runtime.py +62 -72
- package/latticeai/runtime/platform_runtime_wiring.py +4 -3
- package/latticeai/runtime/review_wiring.py +1 -1
- package/latticeai/runtime/router_registration.py +34 -1
- package/latticeai/runtime/security_runtime.py +110 -13
- package/latticeai/runtime/stages.py +27 -0
- package/latticeai/server_app.py +5 -1
- package/latticeai/services/architecture_readiness.py +74 -5
- package/latticeai/services/brain_automation.py +3 -26
- package/latticeai/services/chat_service.py +205 -15
- package/latticeai/services/local_knowledge.py +423 -0
- package/latticeai/services/memory_service.py +55 -21
- package/latticeai/services/model_engines.py +48 -33
- package/latticeai/services/model_errors.py +17 -0
- package/latticeai/services/model_loading.py +28 -25
- package/latticeai/services/model_runtime.py +228 -162
- package/latticeai/services/p_reinforce.py +22 -3
- package/latticeai/services/platform_runtime.py +83 -23
- package/latticeai/services/product_readiness.py +19 -17
- package/latticeai/services/review_queue.py +12 -0
- package/latticeai/services/router_context.py +1 -0
- package/latticeai/services/run_executor.py +4 -9
- package/latticeai/services/search_service.py +203 -28
- package/latticeai/services/tool_dispatch.py +28 -5
- package/latticeai/services/triggers.py +53 -4
- package/latticeai/services/upload_service.py +13 -2
- package/latticeai/setup/__init__.py +25 -0
- package/latticeai/setup/auto_setup.py +857 -0
- package/latticeai/setup/wizard.py +1264 -0
- package/latticeai/tools/__init__.py +278 -0
- package/{tools → latticeai/tools}/commands.py +67 -7
- package/{tools → latticeai/tools}/computer.py +1 -1
- package/{tools → latticeai/tools}/documents.py +1 -1
- package/{tools → latticeai/tools}/filesystem.py +3 -3
- package/latticeai/tools/knowledge.py +178 -0
- package/{tools → latticeai/tools}/local_files.py +1 -1
- package/{tools → latticeai/tools}/network.py +0 -1
- package/local_knowledge_api.py +4 -342
- package/package.json +22 -2
- package/scripts/brain_quality_eval.py +3 -1
- package/scripts/bump_version.py +3 -0
- package/scripts/capture_release_evidence.mjs +180 -0
- package/scripts/check_current_release_docs.mjs +142 -0
- package/scripts/check_i18n_literals.mjs +107 -31
- package/scripts/check_openapi_drift.mjs +56 -0
- package/scripts/export_openapi.py +67 -7
- package/scripts/i18n_literal_allowlist.json +22 -24
- package/scripts/run_integration_tests.mjs +72 -10
- package/scripts/validate_release_artifacts.py +49 -7
- package/scripts/wheel_smoke.py +5 -0
- package/setup_wizard.py +3 -1260
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/Act-Bzz0bUyW.js +1 -0
- package/static/app/assets/Brain-Dj2J20YA.js +321 -0
- package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
- package/static/app/assets/Library-B03FP1Yx.js +1 -0
- package/static/app/assets/System-80lHW0Ux.js +1 -0
- package/static/app/assets/index-BiMofBTM.js +17 -0
- package/static/app/assets/index-Bmx9rzTc.css +2 -0
- package/static/app/assets/primitives-Q1A96_7v.js +1 -0
- package/static/app/assets/textarea-D13RtnTo.js +1 -0
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/tools/__init__.py +21 -269
- package/docs/CODE_REVIEW_2026-07-06.md +0 -764
- package/latticeai/runtime/app_context_runtime.py +0 -13
- package/latticeai/runtime/tail_wiring.py +0 -21
- package/scripts/com.pts.claudecode.discord.plist +0 -31
- package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
- package/scripts/start-pts-claudecode-discord.sh +0 -51
- package/static/app/assets/Act-21lIXx2E.js +0 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
- package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
- package/static/app/assets/Library-bFMtyni3.js +0 -1
- package/static/app/assets/System-K6krGCqn.js +0 -1
- package/static/app/assets/index-C4R3ws30.js +0 -17
- package/static/app/assets/index-ChSeOB02.css +0 -2
- package/static/app/assets/primitives-sQU3it5I.js +0 -1
- package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
- package/tools/knowledge.py +0 -95
|
@@ -34,34 +34,86 @@ class SearchService:
|
|
|
34
34
|
raise ValueError("knowledge graph is disabled")
|
|
35
35
|
return self.graph_store
|
|
36
36
|
|
|
37
|
-
def _scope(
|
|
37
|
+
def _scope(
|
|
38
|
+
self,
|
|
39
|
+
matches,
|
|
40
|
+
allowed_workspaces,
|
|
41
|
+
*,
|
|
42
|
+
include_legacy_global: bool = False,
|
|
43
|
+
):
|
|
38
44
|
"""Drop matches scoped to workspaces the caller is not a member of
|
|
39
|
-
(None = no scoping; legacy-global
|
|
45
|
+
(None = no scoping; legacy-global requires an explicit opt-in)."""
|
|
40
46
|
if allowed_workspaces is None:
|
|
41
47
|
return matches
|
|
42
48
|
graph = self._require_graph()
|
|
43
|
-
return graph.filter_scoped_nodes(matches, allowed_workspaces)
|
|
44
|
-
|
|
45
|
-
def _graph_search(self, graph, query: str, *, limit: int, allowed_workspaces=None):
|
|
46
49
|
try:
|
|
47
|
-
return graph.
|
|
50
|
+
return graph.filter_scoped_nodes(
|
|
51
|
+
matches,
|
|
52
|
+
allowed_workspaces,
|
|
53
|
+
include_legacy_global=include_legacy_global,
|
|
54
|
+
)
|
|
55
|
+
except TypeError:
|
|
56
|
+
# Compatibility for pre-opt-in graph implementations. Rebuild the
|
|
57
|
+
# policy here instead of invoking their legacy fail-open filter.
|
|
58
|
+
candidates = list(matches)
|
|
59
|
+
scopes = graph.workspaces_of(
|
|
60
|
+
[item.get("id") or item.get("node_id") for item in candidates]
|
|
61
|
+
)
|
|
62
|
+
allowed = {str(item) for item in allowed_workspaces if item}
|
|
63
|
+
visible = []
|
|
64
|
+
for item in candidates:
|
|
65
|
+
node_id = str(item.get("id") or item.get("node_id") or "")
|
|
66
|
+
if not node_id or node_id not in scopes:
|
|
67
|
+
continue
|
|
68
|
+
scope = scopes[node_id]
|
|
69
|
+
if scope is None and include_legacy_global:
|
|
70
|
+
visible.append(item)
|
|
71
|
+
elif scope is not None and str(scope) in allowed:
|
|
72
|
+
visible.append(item)
|
|
73
|
+
return visible
|
|
74
|
+
|
|
75
|
+
def _graph_search(
|
|
76
|
+
self,
|
|
77
|
+
graph,
|
|
78
|
+
query: str,
|
|
79
|
+
*,
|
|
80
|
+
limit: int,
|
|
81
|
+
allowed_workspaces=None,
|
|
82
|
+
include_legacy_global: bool = False,
|
|
83
|
+
):
|
|
84
|
+
try:
|
|
85
|
+
return graph.search(
|
|
86
|
+
query,
|
|
87
|
+
limit,
|
|
88
|
+
allowed_workspaces=allowed_workspaces,
|
|
89
|
+
include_legacy_global=include_legacy_global,
|
|
90
|
+
)
|
|
48
91
|
except TypeError:
|
|
49
92
|
payload = graph.search(query, limit)
|
|
50
93
|
if allowed_workspaces is not None:
|
|
51
94
|
payload = {
|
|
52
95
|
**payload,
|
|
53
|
-
"matches":
|
|
96
|
+
"matches": self._scope(
|
|
54
97
|
payload.get("matches", []),
|
|
55
98
|
allowed_workspaces,
|
|
99
|
+
include_legacy_global=include_legacy_global,
|
|
56
100
|
),
|
|
57
101
|
}
|
|
58
102
|
return payload
|
|
59
103
|
|
|
60
|
-
def _relationship_search(
|
|
104
|
+
def _relationship_search(
|
|
105
|
+
self,
|
|
106
|
+
graph,
|
|
107
|
+
*,
|
|
108
|
+
allowed_workspaces=None,
|
|
109
|
+
include_legacy_global: bool = False,
|
|
110
|
+
**kwargs,
|
|
111
|
+
):
|
|
61
112
|
try:
|
|
62
113
|
return graph.relationship_search(
|
|
63
114
|
**kwargs,
|
|
64
115
|
allowed_workspaces=allowed_workspaces,
|
|
116
|
+
include_legacy_global=include_legacy_global,
|
|
65
117
|
)
|
|
66
118
|
except TypeError:
|
|
67
119
|
payload = graph.relationship_search(**kwargs)
|
|
@@ -72,25 +124,42 @@ class SearchService:
|
|
|
72
124
|
{"id": (rel.get("source") or {}).get("id")},
|
|
73
125
|
{"id": (rel.get("target") or {}).get("id")},
|
|
74
126
|
]
|
|
75
|
-
if len(
|
|
127
|
+
if len(
|
|
128
|
+
self._scope(
|
|
129
|
+
endpoints,
|
|
130
|
+
allowed_workspaces,
|
|
131
|
+
include_legacy_global=include_legacy_global,
|
|
132
|
+
)
|
|
133
|
+
) == 2:
|
|
76
134
|
kept.append(rel)
|
|
77
135
|
payload = {**payload, "relationships": kept}
|
|
78
136
|
return payload
|
|
79
137
|
|
|
80
|
-
def _traverse(
|
|
138
|
+
def _traverse(
|
|
139
|
+
self,
|
|
140
|
+
graph,
|
|
141
|
+
node_id: str,
|
|
142
|
+
*,
|
|
143
|
+
depth: int,
|
|
144
|
+
limit: int,
|
|
145
|
+
allowed_workspaces=None,
|
|
146
|
+
include_legacy_global: bool = False,
|
|
147
|
+
):
|
|
81
148
|
try:
|
|
82
149
|
return graph.traverse(
|
|
83
150
|
node_id,
|
|
84
151
|
depth=depth,
|
|
85
152
|
limit=limit,
|
|
86
153
|
allowed_workspaces=allowed_workspaces,
|
|
154
|
+
include_legacy_global=include_legacy_global,
|
|
87
155
|
)
|
|
88
156
|
except TypeError:
|
|
89
157
|
neighborhood = graph.traverse(node_id, depth=depth, limit=limit)
|
|
90
158
|
if allowed_workspaces is not None:
|
|
91
|
-
nodes =
|
|
159
|
+
nodes = self._scope(
|
|
92
160
|
neighborhood.get("nodes", []),
|
|
93
161
|
allowed_workspaces,
|
|
162
|
+
include_legacy_global=include_legacy_global,
|
|
94
163
|
)
|
|
95
164
|
kept = {item.get("id") for item in nodes}
|
|
96
165
|
edges = [
|
|
@@ -100,25 +169,48 @@ class SearchService:
|
|
|
100
169
|
neighborhood = {**neighborhood, "nodes": nodes, "edges": edges}
|
|
101
170
|
return neighborhood
|
|
102
171
|
|
|
103
|
-
def _get_node(
|
|
172
|
+
def _get_node(
|
|
173
|
+
self,
|
|
174
|
+
graph,
|
|
175
|
+
node_id: str,
|
|
176
|
+
*,
|
|
177
|
+
allowed_workspaces=None,
|
|
178
|
+
include_legacy_global: bool = False,
|
|
179
|
+
):
|
|
104
180
|
try:
|
|
105
|
-
return graph.get_node(
|
|
181
|
+
return graph.get_node(
|
|
182
|
+
node_id,
|
|
183
|
+
allowed_workspaces=allowed_workspaces,
|
|
184
|
+
include_legacy_global=include_legacy_global,
|
|
185
|
+
)
|
|
106
186
|
except TypeError:
|
|
107
187
|
node = graph.get_node(node_id)
|
|
108
188
|
if allowed_workspaces is not None:
|
|
109
|
-
visible =
|
|
189
|
+
visible = self._scope(
|
|
190
|
+
[node],
|
|
191
|
+
allowed_workspaces,
|
|
192
|
+
include_legacy_global=include_legacy_global,
|
|
193
|
+
)
|
|
110
194
|
if not visible:
|
|
111
195
|
raise ValueError(f"graph node not found: {node_id}")
|
|
112
196
|
return visible[0]
|
|
113
197
|
return node
|
|
114
198
|
|
|
115
|
-
def keyword_search(
|
|
199
|
+
def keyword_search(
|
|
200
|
+
self,
|
|
201
|
+
query: str,
|
|
202
|
+
*,
|
|
203
|
+
limit: int = 30,
|
|
204
|
+
allowed_workspaces=None,
|
|
205
|
+
include_legacy_global: bool = False,
|
|
206
|
+
) -> Dict[str, Any]:
|
|
116
207
|
graph = self._require_graph()
|
|
117
208
|
payload = self._graph_search(
|
|
118
209
|
graph,
|
|
119
210
|
query,
|
|
120
211
|
limit=limit,
|
|
121
212
|
allowed_workspaces=allowed_workspaces,
|
|
213
|
+
include_legacy_global=include_legacy_global,
|
|
122
214
|
)
|
|
123
215
|
matches = []
|
|
124
216
|
for rank, match in enumerate(payload.get("matches", []), start=1):
|
|
@@ -136,9 +228,25 @@ class SearchService:
|
|
|
136
228
|
"metadata": match.get("metadata") or {},
|
|
137
229
|
"updated_at": match.get("updated_at"),
|
|
138
230
|
})
|
|
139
|
-
return {
|
|
231
|
+
return {
|
|
232
|
+
"query": query,
|
|
233
|
+
"mode": "keyword",
|
|
234
|
+
"matches": self._scope(
|
|
235
|
+
matches,
|
|
236
|
+
allowed_workspaces,
|
|
237
|
+
include_legacy_global=include_legacy_global,
|
|
238
|
+
),
|
|
239
|
+
}
|
|
140
240
|
|
|
141
|
-
def vector_search(
|
|
241
|
+
def vector_search(
|
|
242
|
+
self,
|
|
243
|
+
query: str,
|
|
244
|
+
*,
|
|
245
|
+
limit: int = 30,
|
|
246
|
+
min_score: float = 0.0,
|
|
247
|
+
allowed_workspaces=None,
|
|
248
|
+
include_legacy_global: bool = False,
|
|
249
|
+
) -> Dict[str, Any]:
|
|
142
250
|
graph = self._require_graph()
|
|
143
251
|
payload = graph.vector_search(query, limit=limit, min_score=min_score)
|
|
144
252
|
matches = []
|
|
@@ -163,10 +271,22 @@ class SearchService:
|
|
|
163
271
|
"mode": "vector",
|
|
164
272
|
"embedding_model": payload.get("embedding_model"),
|
|
165
273
|
"embedding_dim": payload.get("embedding_dim"),
|
|
166
|
-
"matches": self._scope(
|
|
274
|
+
"matches": self._scope(
|
|
275
|
+
matches,
|
|
276
|
+
allowed_workspaces,
|
|
277
|
+
include_legacy_global=include_legacy_global,
|
|
278
|
+
),
|
|
167
279
|
}
|
|
168
280
|
|
|
169
|
-
def graph_search(
|
|
281
|
+
def graph_search(
|
|
282
|
+
self,
|
|
283
|
+
query: str,
|
|
284
|
+
*,
|
|
285
|
+
limit: int = 30,
|
|
286
|
+
expand_depth: int = 1,
|
|
287
|
+
allowed_workspaces=None,
|
|
288
|
+
include_legacy_global: bool = False,
|
|
289
|
+
) -> Dict[str, Any]:
|
|
170
290
|
graph = self._require_graph()
|
|
171
291
|
limit = max(1, min(int(limit or 30), 100))
|
|
172
292
|
expand_depth = max(0, min(int(expand_depth or 1), 3))
|
|
@@ -175,12 +295,14 @@ class SearchService:
|
|
|
175
295
|
query,
|
|
176
296
|
limit=max(limit, 10),
|
|
177
297
|
allowed_workspaces=allowed_workspaces,
|
|
298
|
+
include_legacy_global=include_legacy_global,
|
|
178
299
|
).get("matches", [])
|
|
179
300
|
relationships = self._relationship_search(
|
|
180
301
|
graph,
|
|
181
302
|
query=query,
|
|
182
303
|
limit=limit,
|
|
183
304
|
allowed_workspaces=allowed_workspaces,
|
|
305
|
+
include_legacy_global=include_legacy_global,
|
|
184
306
|
).get("relationships", [])
|
|
185
307
|
by_id: Dict[str, Dict[str, Any]] = {}
|
|
186
308
|
|
|
@@ -229,6 +351,7 @@ class SearchService:
|
|
|
229
351
|
depth=expand_depth,
|
|
230
352
|
limit=limit * 3,
|
|
231
353
|
allowed_workspaces=allowed_workspaces,
|
|
354
|
+
include_legacy_global=include_legacy_global,
|
|
232
355
|
)
|
|
233
356
|
except Exception:
|
|
234
357
|
neighborhood = {"nodes": [], "edges": []}
|
|
@@ -256,7 +379,16 @@ class SearchService:
|
|
|
256
379
|
match["rank"] = rank
|
|
257
380
|
match["score"] = round(float(match["score"]), 6)
|
|
258
381
|
match["source_scores"]["graph"] = round(float(match["source_scores"]["graph"]), 6)
|
|
259
|
-
return {
|
|
382
|
+
return {
|
|
383
|
+
"query": query,
|
|
384
|
+
"mode": "graph",
|
|
385
|
+
"expand_depth": expand_depth,
|
|
386
|
+
"matches": self._scope(
|
|
387
|
+
matches,
|
|
388
|
+
allowed_workspaces,
|
|
389
|
+
include_legacy_global=include_legacy_global,
|
|
390
|
+
),
|
|
391
|
+
}
|
|
260
392
|
|
|
261
393
|
def hybrid_search(
|
|
262
394
|
self,
|
|
@@ -268,14 +400,30 @@ class SearchService:
|
|
|
268
400
|
graph_limit: int = 30,
|
|
269
401
|
weights: Optional[Mapping[str, float]] = None,
|
|
270
402
|
allowed_workspaces=None,
|
|
403
|
+
include_legacy_global: bool = False,
|
|
271
404
|
) -> Dict[str, Any]:
|
|
272
405
|
weights = {**DEFAULT_HYBRID_WEIGHTS, **dict(weights or {})}
|
|
273
406
|
# Scope each channel at the source so out-of-scope rows never enter the
|
|
274
407
|
# fusion set (defense-in-depth — the fused result is re-scoped below too).
|
|
275
408
|
channels = {
|
|
276
|
-
"keyword": self.keyword_search(
|
|
277
|
-
|
|
278
|
-
|
|
409
|
+
"keyword": self.keyword_search(
|
|
410
|
+
query,
|
|
411
|
+
limit=keyword_limit,
|
|
412
|
+
allowed_workspaces=allowed_workspaces,
|
|
413
|
+
include_legacy_global=include_legacy_global,
|
|
414
|
+
),
|
|
415
|
+
"vector": self.vector_search(
|
|
416
|
+
query,
|
|
417
|
+
limit=vector_limit,
|
|
418
|
+
allowed_workspaces=allowed_workspaces,
|
|
419
|
+
include_legacy_global=include_legacy_global,
|
|
420
|
+
),
|
|
421
|
+
"graph": self.graph_search(
|
|
422
|
+
query,
|
|
423
|
+
limit=graph_limit,
|
|
424
|
+
allowed_workspaces=allowed_workspaces,
|
|
425
|
+
include_legacy_global=include_legacy_global,
|
|
426
|
+
),
|
|
279
427
|
}
|
|
280
428
|
fused: Dict[str, Dict[str, Any]] = {}
|
|
281
429
|
for source, payload in channels.items():
|
|
@@ -304,7 +452,11 @@ class SearchService:
|
|
|
304
452
|
current.setdefault("graph_context", [])
|
|
305
453
|
current["graph_context"].extend(result.get("graph_context") or [])
|
|
306
454
|
|
|
307
|
-
matches = self._scope(
|
|
455
|
+
matches = self._scope(
|
|
456
|
+
sorted(fused.values(), key=lambda item: item["score"], reverse=True),
|
|
457
|
+
allowed_workspaces,
|
|
458
|
+
include_legacy_global=include_legacy_global,
|
|
459
|
+
)[: max(1, min(limit, 100))]
|
|
308
460
|
for rank, match in enumerate(matches, start=1):
|
|
309
461
|
match["rank"] = rank
|
|
310
462
|
match["score"] = round(float(match["score"]), 6)
|
|
@@ -327,14 +479,28 @@ class SearchService:
|
|
|
327
479
|
"matches": matches,
|
|
328
480
|
}
|
|
329
481
|
|
|
330
|
-
def graph(
|
|
482
|
+
def graph(
|
|
483
|
+
self,
|
|
484
|
+
*,
|
|
485
|
+
limit: int = 300,
|
|
486
|
+
allowed_workspaces=None,
|
|
487
|
+
include_legacy_global: bool = False,
|
|
488
|
+
) -> Dict[str, Any]:
|
|
331
489
|
graph = self._require_graph()
|
|
332
490
|
try:
|
|
333
|
-
return graph.graph(
|
|
491
|
+
return graph.graph(
|
|
492
|
+
limit=limit,
|
|
493
|
+
allowed_workspaces=allowed_workspaces,
|
|
494
|
+
include_legacy_global=include_legacy_global,
|
|
495
|
+
)
|
|
334
496
|
except TypeError:
|
|
335
497
|
payload = graph.graph(limit=limit)
|
|
336
498
|
if allowed_workspaces is not None:
|
|
337
|
-
nodes =
|
|
499
|
+
nodes = self._scope(
|
|
500
|
+
payload.get("nodes", []),
|
|
501
|
+
allowed_workspaces,
|
|
502
|
+
include_legacy_global=include_legacy_global,
|
|
503
|
+
)
|
|
338
504
|
kept = {node.get("id") for node in nodes}
|
|
339
505
|
edges = [
|
|
340
506
|
edge for edge in payload.get("edges", [])
|
|
@@ -351,9 +517,15 @@ class SearchService:
|
|
|
351
517
|
depth: int = 1,
|
|
352
518
|
limit: int = 100,
|
|
353
519
|
allowed_workspaces=None,
|
|
520
|
+
include_legacy_global: bool = False,
|
|
354
521
|
) -> Dict[str, Any]:
|
|
355
522
|
graph = self._require_graph()
|
|
356
|
-
node = self._get_node(
|
|
523
|
+
node = self._get_node(
|
|
524
|
+
graph,
|
|
525
|
+
node_id,
|
|
526
|
+
allowed_workspaces=allowed_workspaces,
|
|
527
|
+
include_legacy_global=include_legacy_global,
|
|
528
|
+
)
|
|
357
529
|
payload = {"node": node}
|
|
358
530
|
if include_neighbors:
|
|
359
531
|
neighborhood = self._traverse(
|
|
@@ -362,6 +534,7 @@ class SearchService:
|
|
|
362
534
|
depth=depth,
|
|
363
535
|
limit=limit,
|
|
364
536
|
allowed_workspaces=allowed_workspaces,
|
|
537
|
+
include_legacy_global=include_legacy_global,
|
|
365
538
|
)
|
|
366
539
|
payload["neighborhood"] = neighborhood
|
|
367
540
|
return payload
|
|
@@ -374,6 +547,7 @@ class SearchService:
|
|
|
374
547
|
relationship_type: str = "",
|
|
375
548
|
limit: int = 30,
|
|
376
549
|
allowed_workspaces=None,
|
|
550
|
+
include_legacy_global: bool = False,
|
|
377
551
|
) -> Dict[str, Any]:
|
|
378
552
|
graph = self._require_graph()
|
|
379
553
|
payload = self._relationship_search(
|
|
@@ -383,6 +557,7 @@ class SearchService:
|
|
|
383
557
|
relationship_type=relationship_type,
|
|
384
558
|
limit=limit,
|
|
385
559
|
allowed_workspaces=allowed_workspaces,
|
|
560
|
+
include_legacy_global=include_legacy_global,
|
|
386
561
|
)
|
|
387
562
|
return payload
|
|
388
563
|
|
|
@@ -20,8 +20,9 @@ from latticeai.core.agent_prompts import (
|
|
|
20
20
|
MEMORY_UPDATER_PROMPT,
|
|
21
21
|
PLANNER_PROMPT,
|
|
22
22
|
)
|
|
23
|
+
from latticeai.core.policy import role_has_capability
|
|
23
24
|
from latticeai.core.tool_registry import ToolPermission, ToolPolicy
|
|
24
|
-
from tools import AGENT_ROOT, DEFAULT_TOOL_REGISTRY, ToolError, ensure_agent_root
|
|
25
|
+
from latticeai.tools import AGENT_ROOT, DEFAULT_TOOL_REGISTRY, ToolError, ensure_agent_root
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
def _default_load_users() -> Dict[str, Any]:
|
|
@@ -36,6 +37,12 @@ FILE_CREATE_ACTIONS = set(DEFAULT_TOOL_REGISTRY.file_create_actions)
|
|
|
36
37
|
TOOL_GOVERNANCE: Dict[str, ToolPolicy] = dict(DEFAULT_TOOL_REGISTRY.governance)
|
|
37
38
|
TOOL_GOVERNANCE_DEFAULT: ToolPolicy = DEFAULT_TOOL_REGISTRY.default_policy
|
|
38
39
|
ADMIN_ONLY_TOOLS: frozenset[str] = DEFAULT_TOOL_REGISTRY.admin_only_tools
|
|
40
|
+
EXPLICIT_CONSENT_TOOLS: frozenset[str] = frozenset({
|
|
41
|
+
"local_list",
|
|
42
|
+
"local_read",
|
|
43
|
+
"local_write",
|
|
44
|
+
"read_document",
|
|
45
|
+
})
|
|
39
46
|
LOCAL_WRITE_BLOCKED_PREFIXES = DEFAULT_TOOL_REGISTRY.local_write_blocked_prefixes
|
|
40
47
|
RISK_LEVEL_MAP = DEFAULT_TOOL_REGISTRY.risk_level_map
|
|
41
48
|
|
|
@@ -98,14 +105,22 @@ class ToolDispatchService:
|
|
|
98
105
|
return self.registry.manifest()
|
|
99
106
|
|
|
100
107
|
def check_role(self, tool_name: str, current_user: str) -> None:
|
|
101
|
-
|
|
108
|
+
admin_only = tool_name in self.registry.admin_only_tools
|
|
109
|
+
capability = self.policy_for(tool_name, {}).get("capability")
|
|
110
|
+
if not admin_only and not capability:
|
|
102
111
|
return
|
|
103
112
|
users = self.load_users()
|
|
104
|
-
|
|
113
|
+
role = str(self.get_user_role(current_user, users) or "user")
|
|
114
|
+
if admin_only and role not in {"admin", "owner"}:
|
|
105
115
|
raise HTTPException(
|
|
106
116
|
status_code=403,
|
|
107
117
|
detail=f"'{tool_name}' 툴은 관리자 전용입니다.",
|
|
108
118
|
)
|
|
119
|
+
if capability and not role_has_capability(role, capability):
|
|
120
|
+
raise HTTPException(
|
|
121
|
+
status_code=403,
|
|
122
|
+
detail=f"'{tool_name}' 툴에는 '{capability}' capability가 필요합니다.",
|
|
123
|
+
)
|
|
109
124
|
|
|
110
125
|
def user_role(self, current_user: str) -> str:
|
|
111
126
|
users = self.load_users()
|
|
@@ -132,6 +147,14 @@ class ToolDispatchService:
|
|
|
132
147
|
when explicitly excluded from a hardening pass.
|
|
133
148
|
"""
|
|
134
149
|
policy = self.policy_for(tool_name, args or {})
|
|
150
|
+
if source in {"mcp", "plugin"} and tool_name in EXPLICIT_CONSENT_TOOLS:
|
|
151
|
+
raise HTTPException(
|
|
152
|
+
status_code=403,
|
|
153
|
+
detail=(
|
|
154
|
+
f"'{tool_name}' requires the dedicated local-file approval flow "
|
|
155
|
+
"and cannot run through MCP or plugins."
|
|
156
|
+
),
|
|
157
|
+
)
|
|
135
158
|
if not trusted_admin:
|
|
136
159
|
self.check_role(tool_name, current_user)
|
|
137
160
|
if policy["destructive"] or policy["risk"] == "destructive":
|
|
@@ -143,13 +166,13 @@ class ToolDispatchService:
|
|
|
143
166
|
require_auto_approval
|
|
144
167
|
and not trusted_admin
|
|
145
168
|
and not policy["auto_approve"]
|
|
146
|
-
and self.user_role(current_user)
|
|
169
|
+
and self.user_role(current_user) not in {"admin", "owner"}
|
|
147
170
|
):
|
|
148
171
|
raise HTTPException(
|
|
149
172
|
status_code=403,
|
|
150
173
|
detail=(
|
|
151
174
|
f"'{tool_name}' 툴은 명시 승인이 필요합니다. "
|
|
152
|
-
"
|
|
175
|
+
"현재 릴리스에서는 승인 UI가 없는 직접 실행 경로에서 기본 차단됩니다."
|
|
153
176
|
),
|
|
154
177
|
)
|
|
155
178
|
return policy
|
|
@@ -209,13 +209,23 @@ class TriggerService:
|
|
|
209
209
|
def on_brain_event(self, event: str, payload: Optional[Dict[str, Any]] = None) -> int:
|
|
210
210
|
"""Fire workflows whose brain_event trigger matches this ingestion."""
|
|
211
211
|
payload = payload or {}
|
|
212
|
-
source_type = str(payload.get("source_type") or event
|
|
212
|
+
source_type = str(payload.get("source_type") or _ingestion_source_type(event, payload) or "")
|
|
213
|
+
event_workspace = str(payload.get("workspace_id") or "personal").strip()
|
|
214
|
+
event_user = str(payload.get("user_email") or "").strip().lower()
|
|
213
215
|
fired = 0
|
|
214
216
|
with self._lock:
|
|
215
217
|
state = self._load_state()
|
|
216
218
|
for item in self._triggered_workflows():
|
|
217
219
|
if item["kind"] != "brain_event":
|
|
218
220
|
continue
|
|
221
|
+
workflow_workspace = str(item["workflow"].get("workspace_id") or "personal").strip()
|
|
222
|
+
workflow_user = str(item["workflow"].get("user_email") or "").strip().lower()
|
|
223
|
+
if event_workspace != workflow_workspace:
|
|
224
|
+
continue
|
|
225
|
+
# Owned workflows only respond to their owner's ingestion.
|
|
226
|
+
# Ownerless legacy workflows remain compatible in personal.
|
|
227
|
+
if workflow_user and workflow_user != event_user:
|
|
228
|
+
continue
|
|
219
229
|
wanted = str(item["config"].get("source_type") or "").strip()
|
|
220
230
|
if wanted and wanted != source_type:
|
|
221
231
|
continue
|
|
@@ -237,6 +247,8 @@ class TriggerService:
|
|
|
237
247
|
"event": event,
|
|
238
248
|
"source_type": source_type,
|
|
239
249
|
"node_id": payload.get("node_id"),
|
|
250
|
+
"user_email": payload.get("user_email"),
|
|
251
|
+
"workspace_id": payload.get("workspace_id"),
|
|
240
252
|
})
|
|
241
253
|
self._save_state(state)
|
|
242
254
|
return fired
|
|
@@ -245,15 +257,37 @@ class TriggerService:
|
|
|
245
257
|
"""A post_tool hook runner: ingestion events fan into triggers."""
|
|
246
258
|
def runner(context):
|
|
247
259
|
event = str(getattr(context, "event", "") or "")
|
|
248
|
-
if not event.startswith("kg_ingest."):
|
|
249
|
-
return {"status": "ok", "output": "not an ingestion event"}
|
|
250
260
|
payload = context.payload if isinstance(context.payload, dict) else {}
|
|
251
|
-
|
|
261
|
+
source_type = _ingestion_source_type(event, payload)
|
|
262
|
+
if not source_type:
|
|
263
|
+
return {"status": "ok", "output": "not an ingestion event"}
|
|
264
|
+
if str(payload.get("status") or "ok") != "ok":
|
|
265
|
+
return {"status": "ok", "output": "ignored failed ingestion event"}
|
|
266
|
+
fired = self.on_brain_event(
|
|
267
|
+
f"kg_ingest.{source_type}",
|
|
268
|
+
{
|
|
269
|
+
**payload,
|
|
270
|
+
"source_type": source_type,
|
|
271
|
+
"user_email": getattr(context, "user_email", None) or payload.get("user_email"),
|
|
272
|
+
"workspace_id": getattr(context, "workspace_id", None) or payload.get("workspace_id"),
|
|
273
|
+
},
|
|
274
|
+
)
|
|
252
275
|
return {"status": "ok", "output": f"fired {fired} workflow trigger(s)"}
|
|
253
276
|
return runner
|
|
254
277
|
|
|
255
278
|
# ── execution + lifecycle ──────────────────────────────────────────────
|
|
256
279
|
def _fire(self, workflow_id: str, trigger_info: Dict[str, Any]) -> None:
|
|
280
|
+
try:
|
|
281
|
+
workflows = list(self._store.load_state().get("workflows") or [])
|
|
282
|
+
workflow = next((wf for wf in workflows if wf.get("id") == workflow_id), None) or {}
|
|
283
|
+
except Exception:
|
|
284
|
+
workflow = {}
|
|
285
|
+
trigger_info = dict(trigger_info)
|
|
286
|
+
if not trigger_info.get("user_email"):
|
|
287
|
+
trigger_info["user_email"] = workflow.get("user_email")
|
|
288
|
+
if not trigger_info.get("workspace_id"):
|
|
289
|
+
trigger_info["workspace_id"] = workflow.get("workspace_id")
|
|
290
|
+
|
|
257
291
|
def _run():
|
|
258
292
|
try:
|
|
259
293
|
result = self._run_workflow(workflow_id, {"__trigger__": trigger_info})
|
|
@@ -285,6 +319,8 @@ class TriggerService:
|
|
|
285
319
|
source=source,
|
|
286
320
|
run_result=result,
|
|
287
321
|
trigger_info=trigger_info,
|
|
322
|
+
user_email=trigger_info.get("user_email") or workflow.get("user_email"),
|
|
323
|
+
workspace_id=trigger_info.get("workspace_id") or workflow.get("workspace_id"),
|
|
288
324
|
)
|
|
289
325
|
except Exception as exc:
|
|
290
326
|
logging.warning("review_sink enqueue failed for %s: %s", workflow_id, exc)
|
|
@@ -326,4 +362,17 @@ class TriggerService:
|
|
|
326
362
|
self._thread.join(timeout=2)
|
|
327
363
|
|
|
328
364
|
|
|
365
|
+
def _ingestion_source_type(event: str, payload: Dict[str, Any]) -> str:
|
|
366
|
+
"""Normalize the real dispatch event (``tool.kg_ingest.*``) and legacy form."""
|
|
367
|
+
tool = str(payload.get("tool") or "").strip()
|
|
368
|
+
if tool.startswith("kg_ingest."):
|
|
369
|
+
return tool.removeprefix("kg_ingest.").strip()
|
|
370
|
+
normalized = str(event or "").strip()
|
|
371
|
+
if normalized.startswith("tool.kg_ingest."):
|
|
372
|
+
return normalized.removeprefix("tool.kg_ingest.").strip()
|
|
373
|
+
if normalized.startswith("kg_ingest."):
|
|
374
|
+
return normalized.removeprefix("kg_ingest.").strip()
|
|
375
|
+
return ""
|
|
376
|
+
|
|
377
|
+
|
|
329
378
|
__all__ = ["TriggerService", "TRIGGER_HOOK_NAME", "MIN_INTERVAL_SECONDS"]
|
|
@@ -11,7 +11,7 @@ from typing import Optional
|
|
|
11
11
|
from fastapi import HTTPException, Request, UploadFile
|
|
12
12
|
|
|
13
13
|
from lattice_brain.ingestion import IngestionItem
|
|
14
|
-
from tools import ToolError, read_document
|
|
14
|
+
from latticeai.tools import ToolError, read_document
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def _workspace_scope_from_request(request: Request) -> Optional[str]:
|
|
@@ -35,9 +35,20 @@ async def process_uploaded_document(
|
|
|
35
35
|
append_audit_event,
|
|
36
36
|
enforce_rate_limit,
|
|
37
37
|
hooks=None,
|
|
38
|
+
workspace_service=None,
|
|
38
39
|
) -> dict:
|
|
39
40
|
enforce_rate_limit(current_user, "upload")
|
|
40
|
-
|
|
41
|
+
requested_workspace = _workspace_scope_from_request(request)
|
|
42
|
+
if workspace_service is not None:
|
|
43
|
+
try:
|
|
44
|
+
workspace_id = workspace_service.resolve_write_scope(
|
|
45
|
+
requested_workspace,
|
|
46
|
+
current_user or None,
|
|
47
|
+
)
|
|
48
|
+
except PermissionError as exc:
|
|
49
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
50
|
+
else:
|
|
51
|
+
workspace_id = requested_workspace
|
|
41
52
|
suffix = Path(file.filename or "upload").suffix.lower()
|
|
42
53
|
allowed = {".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".csv"}
|
|
43
54
|
if suffix not in allowed:
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Setup services owned by the :mod:`latticeai` package."""
|
|
2
|
+
|
|
3
|
+
from .auto_setup import (
|
|
4
|
+
InstallPlan,
|
|
5
|
+
Recommendation,
|
|
6
|
+
SystemProfile,
|
|
7
|
+
plan,
|
|
8
|
+
preset,
|
|
9
|
+
probe,
|
|
10
|
+
recommend,
|
|
11
|
+
run_all,
|
|
12
|
+
verify,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"InstallPlan",
|
|
17
|
+
"Recommendation",
|
|
18
|
+
"SystemProfile",
|
|
19
|
+
"plan",
|
|
20
|
+
"preset",
|
|
21
|
+
"probe",
|
|
22
|
+
"recommend",
|
|
23
|
+
"run_all",
|
|
24
|
+
"verify",
|
|
25
|
+
]
|