gnosisllm-knowledge 0.2.0__py3-none-any.whl
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.
- gnosisllm_knowledge/__init__.py +152 -0
- gnosisllm_knowledge/api/__init__.py +5 -0
- gnosisllm_knowledge/api/knowledge.py +548 -0
- gnosisllm_knowledge/backends/__init__.py +26 -0
- gnosisllm_knowledge/backends/memory/__init__.py +9 -0
- gnosisllm_knowledge/backends/memory/indexer.py +384 -0
- gnosisllm_knowledge/backends/memory/searcher.py +516 -0
- gnosisllm_knowledge/backends/opensearch/__init__.py +19 -0
- gnosisllm_knowledge/backends/opensearch/agentic.py +738 -0
- gnosisllm_knowledge/backends/opensearch/config.py +195 -0
- gnosisllm_knowledge/backends/opensearch/indexer.py +499 -0
- gnosisllm_knowledge/backends/opensearch/mappings.py +255 -0
- gnosisllm_knowledge/backends/opensearch/queries.py +445 -0
- gnosisllm_knowledge/backends/opensearch/searcher.py +383 -0
- gnosisllm_knowledge/backends/opensearch/setup.py +1390 -0
- gnosisllm_knowledge/chunking/__init__.py +9 -0
- gnosisllm_knowledge/chunking/fixed.py +138 -0
- gnosisllm_knowledge/chunking/sentence.py +239 -0
- gnosisllm_knowledge/cli/__init__.py +18 -0
- gnosisllm_knowledge/cli/app.py +509 -0
- gnosisllm_knowledge/cli/commands/__init__.py +7 -0
- gnosisllm_knowledge/cli/commands/agentic.py +529 -0
- gnosisllm_knowledge/cli/commands/load.py +369 -0
- gnosisllm_knowledge/cli/commands/search.py +440 -0
- gnosisllm_knowledge/cli/commands/setup.py +228 -0
- gnosisllm_knowledge/cli/display/__init__.py +5 -0
- gnosisllm_knowledge/cli/display/service.py +555 -0
- gnosisllm_knowledge/cli/utils/__init__.py +5 -0
- gnosisllm_knowledge/cli/utils/config.py +207 -0
- gnosisllm_knowledge/core/__init__.py +87 -0
- gnosisllm_knowledge/core/domain/__init__.py +43 -0
- gnosisllm_knowledge/core/domain/document.py +240 -0
- gnosisllm_knowledge/core/domain/result.py +176 -0
- gnosisllm_knowledge/core/domain/search.py +327 -0
- gnosisllm_knowledge/core/domain/source.py +139 -0
- gnosisllm_knowledge/core/events/__init__.py +23 -0
- gnosisllm_knowledge/core/events/emitter.py +216 -0
- gnosisllm_knowledge/core/events/types.py +226 -0
- gnosisllm_knowledge/core/exceptions.py +407 -0
- gnosisllm_knowledge/core/interfaces/__init__.py +20 -0
- gnosisllm_knowledge/core/interfaces/agentic.py +136 -0
- gnosisllm_knowledge/core/interfaces/chunker.py +64 -0
- gnosisllm_knowledge/core/interfaces/fetcher.py +112 -0
- gnosisllm_knowledge/core/interfaces/indexer.py +244 -0
- gnosisllm_knowledge/core/interfaces/loader.py +102 -0
- gnosisllm_knowledge/core/interfaces/searcher.py +178 -0
- gnosisllm_knowledge/core/interfaces/setup.py +164 -0
- gnosisllm_knowledge/fetchers/__init__.py +12 -0
- gnosisllm_knowledge/fetchers/config.py +77 -0
- gnosisllm_knowledge/fetchers/http.py +167 -0
- gnosisllm_knowledge/fetchers/neoreader.py +204 -0
- gnosisllm_knowledge/loaders/__init__.py +13 -0
- gnosisllm_knowledge/loaders/base.py +399 -0
- gnosisllm_knowledge/loaders/factory.py +202 -0
- gnosisllm_knowledge/loaders/sitemap.py +285 -0
- gnosisllm_knowledge/loaders/website.py +57 -0
- gnosisllm_knowledge/py.typed +0 -0
- gnosisllm_knowledge/services/__init__.py +9 -0
- gnosisllm_knowledge/services/indexing.py +387 -0
- gnosisllm_knowledge/services/search.py +349 -0
- gnosisllm_knowledge-0.2.0.dist-info/METADATA +382 -0
- gnosisllm_knowledge-0.2.0.dist-info/RECORD +64 -0
- gnosisllm_knowledge-0.2.0.dist-info/WHEEL +4 -0
- gnosisllm_knowledge-0.2.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,1390 @@
|
|
|
1
|
+
"""OpenSearch setup adapter implementation.
|
|
2
|
+
|
|
3
|
+
Handles the complete neural search setup:
|
|
4
|
+
1. OpenAI embedding connector creation
|
|
5
|
+
2. Model group creation
|
|
6
|
+
3. Model deployment
|
|
7
|
+
4. Ingest pipeline for automatic embedding
|
|
8
|
+
5. Search pipeline for hybrid search
|
|
9
|
+
6. Vector index creation
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
from gnosisllm_knowledge.backends.opensearch.config import OpenSearchConfig
|
|
21
|
+
from gnosisllm_knowledge.backends.opensearch.mappings import (
|
|
22
|
+
get_index_template,
|
|
23
|
+
get_knowledge_index_mappings,
|
|
24
|
+
get_knowledge_index_settings,
|
|
25
|
+
get_memory_index_mappings,
|
|
26
|
+
get_memory_index_settings,
|
|
27
|
+
)
|
|
28
|
+
from gnosisllm_knowledge.core.exceptions import SetupError
|
|
29
|
+
from gnosisllm_knowledge.core.interfaces.setup import (
|
|
30
|
+
DiagnosticReport,
|
|
31
|
+
HealthReport,
|
|
32
|
+
HealthStatus,
|
|
33
|
+
SetupResult,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from opensearchpy import AsyncOpenSearch
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OpenSearchSetupAdapter:
|
|
43
|
+
"""OpenSearch setup adapter for neural search configuration.
|
|
44
|
+
|
|
45
|
+
Configures OpenSearch with:
|
|
46
|
+
- OpenAI embedding model connector
|
|
47
|
+
- Model group and deployment
|
|
48
|
+
- Ingest pipeline for automatic text embedding
|
|
49
|
+
- Search pipeline for hybrid search
|
|
50
|
+
- Vector index for semantic search
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
```python
|
|
54
|
+
from opensearchpy import AsyncOpenSearch
|
|
55
|
+
|
|
56
|
+
config = OpenSearchConfig.from_env()
|
|
57
|
+
client = AsyncOpenSearch(hosts=[{"host": config.host, "port": config.port}])
|
|
58
|
+
setup = OpenSearchSetupAdapter(client, config)
|
|
59
|
+
|
|
60
|
+
# Run complete setup
|
|
61
|
+
result = await setup.setup()
|
|
62
|
+
print(f"Model ID: {setup.model_id}")
|
|
63
|
+
```
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
client: AsyncOpenSearch,
|
|
69
|
+
config: OpenSearchConfig,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Initialize the setup adapter.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
client: OpenSearch async client.
|
|
75
|
+
config: OpenSearch configuration.
|
|
76
|
+
"""
|
|
77
|
+
self._client = client
|
|
78
|
+
self._config = config
|
|
79
|
+
|
|
80
|
+
# IDs populated during setup
|
|
81
|
+
self._connector_id: str | None = None
|
|
82
|
+
self._model_group_id: str | None = None
|
|
83
|
+
self._model_id: str | None = None
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def name(self) -> str:
|
|
87
|
+
"""Human-readable backend name."""
|
|
88
|
+
return "OpenSearch"
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def model_id(self) -> str | None:
|
|
92
|
+
"""Get the deployed model ID."""
|
|
93
|
+
return self._model_id
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def connector_id(self) -> str | None:
|
|
97
|
+
"""Get the connector ID."""
|
|
98
|
+
return self._connector_id
|
|
99
|
+
|
|
100
|
+
async def health_check(self) -> bool:
|
|
101
|
+
"""Quick health check.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
True if cluster is responsive.
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
response = await self._client.cluster.health()
|
|
108
|
+
return response.get("status") in ("green", "yellow")
|
|
109
|
+
except Exception as e:
|
|
110
|
+
logger.warning(f"Health check failed: {e}")
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
async def deep_health_check(self) -> HealthReport:
|
|
114
|
+
"""Comprehensive health check with component status.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Detailed health report.
|
|
118
|
+
"""
|
|
119
|
+
components: dict[str, Any] = {}
|
|
120
|
+
start_time = datetime.now(timezone.utc)
|
|
121
|
+
|
|
122
|
+
# Check cluster health
|
|
123
|
+
try:
|
|
124
|
+
cluster_health = await self._client.cluster.health()
|
|
125
|
+
cluster_status = cluster_health.get("status", "unknown")
|
|
126
|
+
|
|
127
|
+
if cluster_status == "green":
|
|
128
|
+
status = HealthStatus.HEALTHY
|
|
129
|
+
elif cluster_status == "yellow":
|
|
130
|
+
status = HealthStatus.DEGRADED
|
|
131
|
+
else:
|
|
132
|
+
status = HealthStatus.UNHEALTHY
|
|
133
|
+
|
|
134
|
+
components["cluster"] = {
|
|
135
|
+
"status": status.value,
|
|
136
|
+
"cluster_status": cluster_status,
|
|
137
|
+
"node_count": cluster_health.get("number_of_nodes", 0),
|
|
138
|
+
"data_node_count": cluster_health.get("number_of_data_nodes", 0),
|
|
139
|
+
"active_shards": cluster_health.get("active_shards", 0),
|
|
140
|
+
"unassigned_shards": cluster_health.get("unassigned_shards", 0),
|
|
141
|
+
}
|
|
142
|
+
except Exception as e:
|
|
143
|
+
components["cluster"] = {
|
|
144
|
+
"status": HealthStatus.UNHEALTHY.value,
|
|
145
|
+
"error": str(e),
|
|
146
|
+
}
|
|
147
|
+
status = HealthStatus.UNHEALTHY
|
|
148
|
+
|
|
149
|
+
# Check knowledge index
|
|
150
|
+
try:
|
|
151
|
+
index_name = self._config.knowledge_index_name
|
|
152
|
+
index_exists = await self._client.indices.exists(index=index_name)
|
|
153
|
+
if index_exists:
|
|
154
|
+
stats = await self._client.indices.stats(index=index_name)
|
|
155
|
+
indices_stats = stats.get("indices", {}).get(index_name, {})
|
|
156
|
+
primaries = indices_stats.get("primaries", {})
|
|
157
|
+
docs = primaries.get("docs", {})
|
|
158
|
+
|
|
159
|
+
components["knowledge_index"] = {
|
|
160
|
+
"status": HealthStatus.HEALTHY.value,
|
|
161
|
+
"exists": True,
|
|
162
|
+
"doc_count": docs.get("count", 0),
|
|
163
|
+
"size_bytes": primaries.get("store", {}).get("size_in_bytes", 0),
|
|
164
|
+
}
|
|
165
|
+
else:
|
|
166
|
+
components["knowledge_index"] = {
|
|
167
|
+
"status": HealthStatus.DEGRADED.value,
|
|
168
|
+
"exists": False,
|
|
169
|
+
"message": "Index not created yet",
|
|
170
|
+
}
|
|
171
|
+
except Exception as e:
|
|
172
|
+
components["knowledge_index"] = {
|
|
173
|
+
"status": HealthStatus.UNKNOWN.value,
|
|
174
|
+
"error": str(e),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# Determine overall status
|
|
178
|
+
statuses = [c.get("status", HealthStatus.UNKNOWN.value) for c in components.values()]
|
|
179
|
+
if all(s == HealthStatus.HEALTHY.value for s in statuses):
|
|
180
|
+
overall_status = HealthStatus.HEALTHY
|
|
181
|
+
elif any(s == HealthStatus.UNHEALTHY.value for s in statuses):
|
|
182
|
+
overall_status = HealthStatus.UNHEALTHY
|
|
183
|
+
elif any(s == HealthStatus.DEGRADED.value for s in statuses):
|
|
184
|
+
overall_status = HealthStatus.DEGRADED
|
|
185
|
+
else:
|
|
186
|
+
overall_status = HealthStatus.UNKNOWN
|
|
187
|
+
|
|
188
|
+
return HealthReport(
|
|
189
|
+
healthy=overall_status in (HealthStatus.HEALTHY, HealthStatus.DEGRADED),
|
|
190
|
+
status=overall_status,
|
|
191
|
+
components=components,
|
|
192
|
+
checked_at=start_time,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
async def setup(self, **options: Any) -> SetupResult:
|
|
196
|
+
"""Run complete setup including ML model deployment.
|
|
197
|
+
|
|
198
|
+
Creates connector, deploys model, creates pipelines and indices.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
**options: Setup options:
|
|
202
|
+
- force_recreate: Delete existing resources first
|
|
203
|
+
- skip_ml: Skip ML model setup (use existing model_id from config)
|
|
204
|
+
- skip_sample_data: Don't ingest sample documents
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Setup result with model_id and details.
|
|
208
|
+
"""
|
|
209
|
+
steps_completed: list[str] = []
|
|
210
|
+
errors: list[str] = []
|
|
211
|
+
force_recreate = options.get("force_recreate", False)
|
|
212
|
+
skip_ml = options.get("skip_ml", False)
|
|
213
|
+
|
|
214
|
+
# Force cleanup if requested
|
|
215
|
+
if force_recreate:
|
|
216
|
+
cleanup_result = await self.cleanup()
|
|
217
|
+
if cleanup_result.steps_completed:
|
|
218
|
+
steps_completed.extend(cleanup_result.steps_completed)
|
|
219
|
+
|
|
220
|
+
# Step 1: Create connector (OpenAI)
|
|
221
|
+
if not skip_ml:
|
|
222
|
+
try:
|
|
223
|
+
await self._create_connector()
|
|
224
|
+
steps_completed.append(f"Created connector: {self._connector_id}")
|
|
225
|
+
except Exception as e:
|
|
226
|
+
errors.append(f"Failed to create connector: {e}")
|
|
227
|
+
logger.error(f"Failed to create connector: {e}")
|
|
228
|
+
|
|
229
|
+
# Step 2: Create model group
|
|
230
|
+
if not skip_ml and self._connector_id:
|
|
231
|
+
try:
|
|
232
|
+
await self._create_model_group()
|
|
233
|
+
steps_completed.append(f"Created model group: {self._model_group_id}")
|
|
234
|
+
except Exception as e:
|
|
235
|
+
errors.append(f"Failed to create model group: {e}")
|
|
236
|
+
logger.error(f"Failed to create model group: {e}")
|
|
237
|
+
|
|
238
|
+
# Step 3: Deploy model
|
|
239
|
+
if not skip_ml and self._connector_id and self._model_group_id:
|
|
240
|
+
try:
|
|
241
|
+
await self._deploy_model()
|
|
242
|
+
steps_completed.append(f"Deployed model: {self._model_id}")
|
|
243
|
+
except Exception as e:
|
|
244
|
+
errors.append(f"Failed to deploy model: {e}")
|
|
245
|
+
logger.error(f"Failed to deploy model: {e}")
|
|
246
|
+
|
|
247
|
+
# Use config model_id if skipping ML or if deployment failed
|
|
248
|
+
if not self._model_id:
|
|
249
|
+
self._model_id = self._config.model_id
|
|
250
|
+
|
|
251
|
+
# Step 4: Create ingest pipeline
|
|
252
|
+
if self._model_id:
|
|
253
|
+
try:
|
|
254
|
+
await self._create_ingest_pipeline()
|
|
255
|
+
pipeline_name = self._config.ingest_pipeline_name or f"{self._config.index_prefix}-ingest-pipeline"
|
|
256
|
+
steps_completed.append(f"Created ingest pipeline: {pipeline_name}")
|
|
257
|
+
except Exception as e:
|
|
258
|
+
errors.append(f"Failed to create ingest pipeline: {e}")
|
|
259
|
+
logger.error(f"Failed to create ingest pipeline: {e}")
|
|
260
|
+
|
|
261
|
+
# Step 5: Create search pipeline
|
|
262
|
+
try:
|
|
263
|
+
await self._create_search_pipeline()
|
|
264
|
+
pipeline_name = self._config.search_pipeline_name or f"{self._config.index_prefix}-search-pipeline"
|
|
265
|
+
steps_completed.append(f"Created search pipeline: {pipeline_name}")
|
|
266
|
+
except Exception as e:
|
|
267
|
+
errors.append(f"Failed to create search pipeline: {e}")
|
|
268
|
+
logger.error(f"Failed to create search pipeline: {e}")
|
|
269
|
+
|
|
270
|
+
# Step 6: Create index template
|
|
271
|
+
try:
|
|
272
|
+
template_name = f"{self._config.index_prefix}-template"
|
|
273
|
+
template_body = get_index_template(self._config)
|
|
274
|
+
|
|
275
|
+
await self._client.indices.put_index_template(
|
|
276
|
+
name=template_name,
|
|
277
|
+
body=template_body,
|
|
278
|
+
)
|
|
279
|
+
steps_completed.append(f"Created index template: {template_name}")
|
|
280
|
+
except Exception as e:
|
|
281
|
+
errors.append(f"Failed to create index template: {e}")
|
|
282
|
+
logger.error(f"Failed to create index template: {e}")
|
|
283
|
+
|
|
284
|
+
# Step 7: Create knowledge index
|
|
285
|
+
try:
|
|
286
|
+
index_name = self._config.knowledge_index_name
|
|
287
|
+
exists = await self._client.indices.exists(index=index_name)
|
|
288
|
+
|
|
289
|
+
if not exists:
|
|
290
|
+
settings = get_knowledge_index_settings(self._config)
|
|
291
|
+
# Add default pipeline
|
|
292
|
+
pipeline_name = self._config.ingest_pipeline_name or f"{self._config.index_prefix}-ingest-pipeline"
|
|
293
|
+
settings["index"]["default_pipeline"] = pipeline_name
|
|
294
|
+
|
|
295
|
+
await self._client.indices.create(
|
|
296
|
+
index=index_name,
|
|
297
|
+
body={
|
|
298
|
+
"settings": settings,
|
|
299
|
+
"mappings": get_knowledge_index_mappings(self._config),
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
steps_completed.append(f"Created knowledge index: {index_name}")
|
|
303
|
+
else:
|
|
304
|
+
steps_completed.append(f"Knowledge index already exists: {index_name}")
|
|
305
|
+
except Exception as e:
|
|
306
|
+
errors.append(f"Failed to create knowledge index: {e}")
|
|
307
|
+
logger.error(f"Failed to create knowledge index: {e}")
|
|
308
|
+
|
|
309
|
+
# Step 8: Create memory index
|
|
310
|
+
try:
|
|
311
|
+
memory_index = self._config.agentic_memory_index_name
|
|
312
|
+
exists = await self._client.indices.exists(index=memory_index)
|
|
313
|
+
|
|
314
|
+
if not exists:
|
|
315
|
+
await self._client.indices.create(
|
|
316
|
+
index=memory_index,
|
|
317
|
+
body={
|
|
318
|
+
"settings": get_memory_index_settings(self._config),
|
|
319
|
+
"mappings": get_memory_index_mappings(),
|
|
320
|
+
},
|
|
321
|
+
)
|
|
322
|
+
steps_completed.append(f"Created memory index: {memory_index}")
|
|
323
|
+
else:
|
|
324
|
+
steps_completed.append(f"Memory index already exists: {memory_index}")
|
|
325
|
+
except Exception as e:
|
|
326
|
+
errors.append(f"Failed to create memory index: {e}")
|
|
327
|
+
logger.error(f"Failed to create memory index: {e}")
|
|
328
|
+
|
|
329
|
+
return SetupResult(
|
|
330
|
+
success=len(errors) == 0,
|
|
331
|
+
steps_completed=steps_completed,
|
|
332
|
+
errors=errors if errors else None,
|
|
333
|
+
data={"model_id": self._model_id} if self._model_id else None,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
async def _create_connector(self) -> None:
|
|
337
|
+
"""Create OpenAI embedding connector."""
|
|
338
|
+
# Check if connector already exists
|
|
339
|
+
connector_name = f"{self._config.index_prefix}-openai-connector"
|
|
340
|
+
existing = await self._find_connector_by_name(connector_name)
|
|
341
|
+
if existing:
|
|
342
|
+
self._connector_id = existing
|
|
343
|
+
logger.info(f"Using existing connector: {existing}")
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
if not self._config.openai_api_key:
|
|
347
|
+
raise SetupError(
|
|
348
|
+
message="OPENAI_API_KEY required to create connector",
|
|
349
|
+
details={"hint": "Set OPENAI_API_KEY environment variable"},
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# Create new connector
|
|
353
|
+
connector_body = {
|
|
354
|
+
"name": connector_name,
|
|
355
|
+
"description": "OpenAI embedding connector for GnosisLLM Knowledge",
|
|
356
|
+
"version": 1,
|
|
357
|
+
"protocol": "http",
|
|
358
|
+
"parameters": {
|
|
359
|
+
"model": self._config.embedding_model,
|
|
360
|
+
},
|
|
361
|
+
"credential": {
|
|
362
|
+
"openAI_key": self._config.openai_api_key,
|
|
363
|
+
},
|
|
364
|
+
"actions": [
|
|
365
|
+
{
|
|
366
|
+
"action_type": "predict",
|
|
367
|
+
"method": "POST",
|
|
368
|
+
"url": "https://api.openai.com/v1/embeddings",
|
|
369
|
+
"headers": {
|
|
370
|
+
"Authorization": "Bearer ${credential.openAI_key}",
|
|
371
|
+
"Content-Type": "application/json",
|
|
372
|
+
},
|
|
373
|
+
"request_body": '{ "input": ${parameters.input}, "model": "${parameters.model}" }',
|
|
374
|
+
"pre_process_function": "connector.pre_process.openai.embedding",
|
|
375
|
+
"post_process_function": "connector.post_process.openai.embedding",
|
|
376
|
+
}
|
|
377
|
+
],
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
response = await self._client.transport.perform_request(
|
|
381
|
+
"POST",
|
|
382
|
+
"/_plugins/_ml/connectors/_create",
|
|
383
|
+
body=connector_body,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
self._connector_id = response.get("connector_id")
|
|
387
|
+
logger.info(f"Created connector: {self._connector_id}")
|
|
388
|
+
|
|
389
|
+
async def _create_model_group(self) -> None:
|
|
390
|
+
"""Create model group."""
|
|
391
|
+
model_group_name = f"{self._config.index_prefix}-model-group"
|
|
392
|
+
|
|
393
|
+
# Check if model group already exists
|
|
394
|
+
existing = await self._find_model_group_by_name(model_group_name)
|
|
395
|
+
if existing:
|
|
396
|
+
self._model_group_id = existing
|
|
397
|
+
logger.info(f"Using existing model group: {existing}")
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
response = await self._client.transport.perform_request(
|
|
401
|
+
"POST",
|
|
402
|
+
"/_plugins/_ml/model_groups/_register",
|
|
403
|
+
body={
|
|
404
|
+
"name": model_group_name,
|
|
405
|
+
"description": "Model group for GnosisLLM Knowledge embeddings",
|
|
406
|
+
},
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
self._model_group_id = response.get("model_group_id")
|
|
410
|
+
logger.info(f"Created model group: {self._model_group_id}")
|
|
411
|
+
|
|
412
|
+
async def _deploy_model(self) -> None:
|
|
413
|
+
"""Deploy embedding model."""
|
|
414
|
+
model_name = f"{self._config.index_prefix}-embedding-model"
|
|
415
|
+
|
|
416
|
+
# Check if model already exists
|
|
417
|
+
existing = await self._find_model_by_name(model_name)
|
|
418
|
+
if existing:
|
|
419
|
+
self._model_id = existing
|
|
420
|
+
# Check if deployed
|
|
421
|
+
status = await self._get_model_status(existing)
|
|
422
|
+
if status == "DEPLOYED":
|
|
423
|
+
logger.info(f"Using existing deployed model: {existing}")
|
|
424
|
+
return
|
|
425
|
+
# Deploy if not deployed
|
|
426
|
+
await self._deploy_model_by_id(existing)
|
|
427
|
+
logger.info(f"Deployed existing model: {existing}")
|
|
428
|
+
return
|
|
429
|
+
|
|
430
|
+
# Register new model
|
|
431
|
+
response = await self._client.transport.perform_request(
|
|
432
|
+
"POST",
|
|
433
|
+
"/_plugins/_ml/models/_register",
|
|
434
|
+
body={
|
|
435
|
+
"name": model_name,
|
|
436
|
+
"function_name": "remote",
|
|
437
|
+
"model_group_id": self._model_group_id,
|
|
438
|
+
"description": "OpenAI embedding model for GnosisLLM Knowledge",
|
|
439
|
+
"connector_id": self._connector_id,
|
|
440
|
+
},
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
task_id = response.get("task_id")
|
|
444
|
+
if not task_id:
|
|
445
|
+
raise SetupError(message="No task_id returned from model registration")
|
|
446
|
+
|
|
447
|
+
# Wait for registration
|
|
448
|
+
model_id = await self._wait_for_task(task_id, "model registration")
|
|
449
|
+
if not model_id:
|
|
450
|
+
raise SetupError(message="Model registration timed out")
|
|
451
|
+
|
|
452
|
+
self._model_id = model_id
|
|
453
|
+
|
|
454
|
+
# Deploy the model
|
|
455
|
+
await self._deploy_model_by_id(model_id)
|
|
456
|
+
logger.info(f"Deployed model: {model_id}")
|
|
457
|
+
|
|
458
|
+
async def _create_ingest_pipeline(self) -> None:
|
|
459
|
+
"""Create ingest pipeline for automatic embedding."""
|
|
460
|
+
pipeline_name = self._config.ingest_pipeline_name or f"{self._config.index_prefix}-ingest-pipeline"
|
|
461
|
+
|
|
462
|
+
pipeline_body = {
|
|
463
|
+
"description": "GnosisLLM ingest pipeline for text embedding",
|
|
464
|
+
"processors": [
|
|
465
|
+
{
|
|
466
|
+
"text_embedding": {
|
|
467
|
+
"model_id": self._model_id,
|
|
468
|
+
"field_map": {
|
|
469
|
+
"content": self._config.embedding_field,
|
|
470
|
+
},
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
"set": {
|
|
475
|
+
"field": "indexed_at",
|
|
476
|
+
"value": "{{_ingest.timestamp}}",
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
],
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
await self._client.ingest.put_pipeline(
|
|
483
|
+
id=pipeline_name,
|
|
484
|
+
body=pipeline_body,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
async def _create_search_pipeline(self) -> None:
|
|
488
|
+
"""Create search pipeline for hybrid search."""
|
|
489
|
+
pipeline_name = self._config.search_pipeline_name or f"{self._config.index_prefix}-search-pipeline"
|
|
490
|
+
|
|
491
|
+
pipeline_body = {
|
|
492
|
+
"description": "GnosisLLM search pipeline for hybrid search",
|
|
493
|
+
"phase_results_processors": [
|
|
494
|
+
{
|
|
495
|
+
"normalization-processor": {
|
|
496
|
+
"normalization": {"technique": "min_max"},
|
|
497
|
+
"combination": {
|
|
498
|
+
"technique": "arithmetic_mean",
|
|
499
|
+
"parameters": {"weights": [0.7, 0.3]},
|
|
500
|
+
},
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
],
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
await self._client.transport.perform_request(
|
|
507
|
+
"PUT",
|
|
508
|
+
f"/_search/pipeline/{pipeline_name}",
|
|
509
|
+
body=pipeline_body,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
async def cleanup(self) -> SetupResult:
|
|
513
|
+
"""Clean up all resources in correct order.
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
Cleanup result.
|
|
517
|
+
"""
|
|
518
|
+
steps_completed: list[str] = []
|
|
519
|
+
errors: list[str] = []
|
|
520
|
+
|
|
521
|
+
# Delete knowledge index
|
|
522
|
+
try:
|
|
523
|
+
index_name = self._config.knowledge_index_name
|
|
524
|
+
if await self._client.indices.exists(index=index_name):
|
|
525
|
+
await self._client.indices.delete(index=index_name)
|
|
526
|
+
steps_completed.append(f"Deleted index: {index_name}")
|
|
527
|
+
except Exception as e:
|
|
528
|
+
errors.append(f"Failed to delete knowledge index: {e}")
|
|
529
|
+
|
|
530
|
+
# Delete memory index
|
|
531
|
+
try:
|
|
532
|
+
memory_index = self._config.agentic_memory_index_name
|
|
533
|
+
if await self._client.indices.exists(index=memory_index):
|
|
534
|
+
await self._client.indices.delete(index=memory_index)
|
|
535
|
+
steps_completed.append(f"Deleted index: {memory_index}")
|
|
536
|
+
except Exception as e:
|
|
537
|
+
errors.append(f"Failed to delete memory index: {e}")
|
|
538
|
+
|
|
539
|
+
# Delete index template
|
|
540
|
+
try:
|
|
541
|
+
template_name = f"{self._config.index_prefix}-template"
|
|
542
|
+
await self._client.indices.delete_index_template(name=template_name)
|
|
543
|
+
steps_completed.append(f"Deleted template: {template_name}")
|
|
544
|
+
except Exception:
|
|
545
|
+
pass # May not exist
|
|
546
|
+
|
|
547
|
+
# Delete search pipeline
|
|
548
|
+
try:
|
|
549
|
+
pipeline_name = self._config.search_pipeline_name or f"{self._config.index_prefix}-search-pipeline"
|
|
550
|
+
await self._client.transport.perform_request(
|
|
551
|
+
"DELETE",
|
|
552
|
+
f"/_search/pipeline/{pipeline_name}",
|
|
553
|
+
)
|
|
554
|
+
steps_completed.append(f"Deleted search pipeline: {pipeline_name}")
|
|
555
|
+
except Exception:
|
|
556
|
+
pass # May not exist
|
|
557
|
+
|
|
558
|
+
# Delete ingest pipeline
|
|
559
|
+
try:
|
|
560
|
+
pipeline_name = self._config.ingest_pipeline_name or f"{self._config.index_prefix}-ingest-pipeline"
|
|
561
|
+
await self._client.ingest.delete_pipeline(id=pipeline_name)
|
|
562
|
+
steps_completed.append(f"Deleted ingest pipeline: {pipeline_name}")
|
|
563
|
+
except Exception:
|
|
564
|
+
pass # May not exist
|
|
565
|
+
|
|
566
|
+
# Undeploy and delete model
|
|
567
|
+
model_name = f"{self._config.index_prefix}-embedding-model"
|
|
568
|
+
model_id = self._model_id or await self._find_model_by_name(model_name)
|
|
569
|
+
if model_id:
|
|
570
|
+
try:
|
|
571
|
+
await self._client.transport.perform_request(
|
|
572
|
+
"POST",
|
|
573
|
+
f"/_plugins/_ml/models/{model_id}/_undeploy",
|
|
574
|
+
)
|
|
575
|
+
await asyncio.sleep(2)
|
|
576
|
+
await self._client.transport.perform_request(
|
|
577
|
+
"DELETE",
|
|
578
|
+
f"/_plugins/_ml/models/{model_id}",
|
|
579
|
+
)
|
|
580
|
+
steps_completed.append(f"Deleted model: {model_id}")
|
|
581
|
+
except Exception as e:
|
|
582
|
+
logger.warning(f"Failed to delete model: {e}")
|
|
583
|
+
|
|
584
|
+
# Delete model group
|
|
585
|
+
model_group_name = f"{self._config.index_prefix}-model-group"
|
|
586
|
+
model_group_id = self._model_group_id or await self._find_model_group_by_name(model_group_name)
|
|
587
|
+
if model_group_id:
|
|
588
|
+
try:
|
|
589
|
+
await self._client.transport.perform_request(
|
|
590
|
+
"DELETE",
|
|
591
|
+
f"/_plugins/_ml/model_groups/{model_group_id}",
|
|
592
|
+
)
|
|
593
|
+
steps_completed.append(f"Deleted model group: {model_group_id}")
|
|
594
|
+
except Exception as e:
|
|
595
|
+
logger.warning(f"Failed to delete model group: {e}")
|
|
596
|
+
|
|
597
|
+
# Delete connector
|
|
598
|
+
connector_name = f"{self._config.index_prefix}-openai-connector"
|
|
599
|
+
connector_id = self._connector_id or await self._find_connector_by_name(connector_name)
|
|
600
|
+
if connector_id:
|
|
601
|
+
try:
|
|
602
|
+
await self._client.transport.perform_request(
|
|
603
|
+
"DELETE",
|
|
604
|
+
f"/_plugins/_ml/connectors/{connector_id}",
|
|
605
|
+
)
|
|
606
|
+
steps_completed.append(f"Deleted connector: {connector_id}")
|
|
607
|
+
except Exception as e:
|
|
608
|
+
logger.warning(f"Failed to delete connector: {e}")
|
|
609
|
+
|
|
610
|
+
return SetupResult(
|
|
611
|
+
success=len(errors) == 0,
|
|
612
|
+
steps_completed=steps_completed,
|
|
613
|
+
errors=errors if errors else None,
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
async def diagnose(self) -> DiagnosticReport:
|
|
617
|
+
"""Run diagnostics and return recommendations.
|
|
618
|
+
|
|
619
|
+
Returns:
|
|
620
|
+
Diagnostic report with issues and recommendations.
|
|
621
|
+
"""
|
|
622
|
+
health = await self.deep_health_check()
|
|
623
|
+
issues: list[str] = []
|
|
624
|
+
warnings: list[str] = []
|
|
625
|
+
recommendations: list[str] = []
|
|
626
|
+
|
|
627
|
+
# Check cluster status
|
|
628
|
+
cluster_info = health.components.get("cluster", {})
|
|
629
|
+
if cluster_info.get("status") == HealthStatus.UNHEALTHY.value:
|
|
630
|
+
issues.append("OpenSearch cluster is unhealthy")
|
|
631
|
+
recommendations.append("Check OpenSearch logs and cluster state")
|
|
632
|
+
elif cluster_info.get("cluster_status") == "yellow":
|
|
633
|
+
warnings.append("Cluster is yellow - some replicas may be unassigned")
|
|
634
|
+
recommendations.append("Consider adding more nodes or reducing replica count")
|
|
635
|
+
|
|
636
|
+
# Check unassigned shards
|
|
637
|
+
unassigned = cluster_info.get("unassigned_shards", 0)
|
|
638
|
+
if unassigned > 0:
|
|
639
|
+
warnings.append(f"{unassigned} unassigned shards detected")
|
|
640
|
+
recommendations.append("Review shard allocation and disk space")
|
|
641
|
+
|
|
642
|
+
# Check index existence
|
|
643
|
+
index_info = health.components.get("knowledge_index", {})
|
|
644
|
+
if not index_info.get("exists", True):
|
|
645
|
+
issues.append("Knowledge index does not exist")
|
|
646
|
+
recommendations.append("Run setup() to create the index")
|
|
647
|
+
|
|
648
|
+
# Check document count
|
|
649
|
+
doc_count = index_info.get("doc_count", 0)
|
|
650
|
+
if doc_count == 0 and index_info.get("exists"):
|
|
651
|
+
warnings.append("Knowledge index is empty")
|
|
652
|
+
recommendations.append("Load documents using the loader")
|
|
653
|
+
|
|
654
|
+
return DiagnosticReport(
|
|
655
|
+
health=health,
|
|
656
|
+
issues=issues,
|
|
657
|
+
warnings=warnings,
|
|
658
|
+
recommendations=recommendations,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
def get_setup_steps(self) -> list[tuple[str, str]]:
|
|
662
|
+
"""Get list of setup steps.
|
|
663
|
+
|
|
664
|
+
Returns:
|
|
665
|
+
List of (step_name, description) tuples.
|
|
666
|
+
"""
|
|
667
|
+
return [
|
|
668
|
+
("connector", "Create OpenAI embedding connector"),
|
|
669
|
+
("model_group", "Create model group"),
|
|
670
|
+
("model", "Deploy embedding model"),
|
|
671
|
+
("ingest_pipeline", "Create ingest pipeline for text embedding"),
|
|
672
|
+
("search_pipeline", "Create search pipeline for hybrid search"),
|
|
673
|
+
("index_template", "Create index template"),
|
|
674
|
+
("knowledge_index", "Create knowledge document index"),
|
|
675
|
+
("memory_index", "Create conversation memory index"),
|
|
676
|
+
]
|
|
677
|
+
|
|
678
|
+
# === Helper Methods ===
|
|
679
|
+
|
|
680
|
+
async def _find_connector_by_name(self, name: str) -> str | None:
|
|
681
|
+
"""Find connector by exact name."""
|
|
682
|
+
try:
|
|
683
|
+
response = await self._client.transport.perform_request(
|
|
684
|
+
"POST",
|
|
685
|
+
"/_plugins/_ml/connectors/_search",
|
|
686
|
+
body={"query": {"term": {"name.keyword": name}}},
|
|
687
|
+
)
|
|
688
|
+
hits = response.get("hits", {}).get("hits", [])
|
|
689
|
+
if hits:
|
|
690
|
+
return hits[0]["_id"]
|
|
691
|
+
except Exception:
|
|
692
|
+
pass
|
|
693
|
+
return None
|
|
694
|
+
|
|
695
|
+
async def _find_model_group_by_name(self, name: str) -> str | None:
|
|
696
|
+
"""Find model group by exact name."""
|
|
697
|
+
try:
|
|
698
|
+
response = await self._client.transport.perform_request(
|
|
699
|
+
"POST",
|
|
700
|
+
"/_plugins/_ml/model_groups/_search",
|
|
701
|
+
body={"query": {"term": {"name.keyword": name}}},
|
|
702
|
+
)
|
|
703
|
+
hits = response.get("hits", {}).get("hits", [])
|
|
704
|
+
if hits:
|
|
705
|
+
return hits[0]["_id"]
|
|
706
|
+
except Exception:
|
|
707
|
+
pass
|
|
708
|
+
return None
|
|
709
|
+
|
|
710
|
+
async def _find_model_by_name(self, name: str) -> str | None:
|
|
711
|
+
"""Find model by exact name."""
|
|
712
|
+
try:
|
|
713
|
+
response = await self._client.transport.perform_request(
|
|
714
|
+
"POST",
|
|
715
|
+
"/_plugins/_ml/models/_search",
|
|
716
|
+
body={"query": {"term": {"name.keyword": name}}},
|
|
717
|
+
)
|
|
718
|
+
hits = response.get("hits", {}).get("hits", [])
|
|
719
|
+
if hits:
|
|
720
|
+
return hits[0]["_id"]
|
|
721
|
+
except Exception:
|
|
722
|
+
pass
|
|
723
|
+
return None
|
|
724
|
+
|
|
725
|
+
async def _get_model_status(self, model_id: str) -> str | None:
|
|
726
|
+
"""Get model deployment status."""
|
|
727
|
+
try:
|
|
728
|
+
response = await self._client.transport.perform_request(
|
|
729
|
+
"GET",
|
|
730
|
+
f"/_plugins/_ml/models/{model_id}",
|
|
731
|
+
)
|
|
732
|
+
return response.get("model_state")
|
|
733
|
+
except Exception:
|
|
734
|
+
return None
|
|
735
|
+
|
|
736
|
+
async def _deploy_model_by_id(self, model_id: str) -> None:
|
|
737
|
+
"""Deploy a model by ID."""
|
|
738
|
+
response = await self._client.transport.perform_request(
|
|
739
|
+
"POST",
|
|
740
|
+
f"/_plugins/_ml/models/{model_id}/_deploy",
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
task_id = response.get("task_id")
|
|
744
|
+
if task_id:
|
|
745
|
+
await self._wait_for_task(task_id, "model deployment")
|
|
746
|
+
|
|
747
|
+
# Wait for model to actually be in DEPLOYED state
|
|
748
|
+
await self._wait_for_model_deployed(model_id)
|
|
749
|
+
|
|
750
|
+
async def _wait_for_model_deployed(
|
|
751
|
+
self,
|
|
752
|
+
model_id: str,
|
|
753
|
+
timeout: int = 120,
|
|
754
|
+
) -> bool:
|
|
755
|
+
"""Wait for model to reach DEPLOYED state.
|
|
756
|
+
|
|
757
|
+
Args:
|
|
758
|
+
model_id: Model ID to check.
|
|
759
|
+
timeout: Maximum time to wait in seconds.
|
|
760
|
+
|
|
761
|
+
Returns:
|
|
762
|
+
True if deployed, False if timeout.
|
|
763
|
+
"""
|
|
764
|
+
start_time = time.time()
|
|
765
|
+
|
|
766
|
+
while time.time() - start_time < timeout:
|
|
767
|
+
status = await self._get_model_status(model_id)
|
|
768
|
+
if status == "DEPLOYED":
|
|
769
|
+
logger.info(f"Model {model_id} is now DEPLOYED")
|
|
770
|
+
return True
|
|
771
|
+
elif status in ("DEPLOY_FAILED", "UNDEPLOYED"):
|
|
772
|
+
logger.error(f"Model deployment failed, status: {status}")
|
|
773
|
+
return False
|
|
774
|
+
|
|
775
|
+
logger.debug(f"Waiting for model deployment, current status: {status}")
|
|
776
|
+
await asyncio.sleep(2)
|
|
777
|
+
|
|
778
|
+
logger.error(f"Model deployment timed out after {timeout}s")
|
|
779
|
+
return False
|
|
780
|
+
|
|
781
|
+
async def _wait_for_task(
|
|
782
|
+
self,
|
|
783
|
+
task_id: str,
|
|
784
|
+
task_name: str,
|
|
785
|
+
timeout: int = 120,
|
|
786
|
+
) -> str | None:
|
|
787
|
+
"""Wait for an ML task to complete."""
|
|
788
|
+
start_time = time.time()
|
|
789
|
+
|
|
790
|
+
while time.time() - start_time < timeout:
|
|
791
|
+
try:
|
|
792
|
+
response = await self._client.transport.perform_request(
|
|
793
|
+
"GET",
|
|
794
|
+
f"/_plugins/_ml/tasks/{task_id}",
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
state = response.get("state")
|
|
798
|
+
if state == "COMPLETED":
|
|
799
|
+
return response.get("model_id")
|
|
800
|
+
elif state in ("FAILED", "CANCELLED"):
|
|
801
|
+
logger.error(f"Task {task_name} failed: {response}")
|
|
802
|
+
return None
|
|
803
|
+
|
|
804
|
+
except Exception as e:
|
|
805
|
+
logger.warning(f"Error checking task status: {e}")
|
|
806
|
+
|
|
807
|
+
await asyncio.sleep(2)
|
|
808
|
+
|
|
809
|
+
logger.error(f"Task {task_name} timed out after {timeout}s")
|
|
810
|
+
return None
|
|
811
|
+
|
|
812
|
+
async def get_cluster_stats(self) -> dict[str, Any]:
|
|
813
|
+
"""Get cluster statistics.
|
|
814
|
+
|
|
815
|
+
Returns:
|
|
816
|
+
Cluster statistics dictionary.
|
|
817
|
+
"""
|
|
818
|
+
try:
|
|
819
|
+
health = await self._client.cluster.health()
|
|
820
|
+
stats = await self._client.cluster.stats()
|
|
821
|
+
|
|
822
|
+
return {
|
|
823
|
+
"cluster_name": health.get("cluster_name"),
|
|
824
|
+
"cluster_status": health.get("status"),
|
|
825
|
+
"node_count": health.get("number_of_nodes"),
|
|
826
|
+
"data_node_count": health.get("number_of_data_nodes"),
|
|
827
|
+
"active_shards": health.get("active_shards"),
|
|
828
|
+
"active_primary_shards": health.get("active_primary_shards"),
|
|
829
|
+
"relocating_shards": health.get("relocating_shards"),
|
|
830
|
+
"initializing_shards": health.get("initializing_shards"),
|
|
831
|
+
"unassigned_shards": health.get("unassigned_shards"),
|
|
832
|
+
"total_indices": stats.get("indices", {}).get("count", 0),
|
|
833
|
+
"total_docs": stats.get("indices", {}).get("docs", {}).get("count", 0),
|
|
834
|
+
}
|
|
835
|
+
except Exception as e:
|
|
836
|
+
raise SetupError(
|
|
837
|
+
message=f"Failed to get cluster stats: {e}",
|
|
838
|
+
cause=e,
|
|
839
|
+
) from e
|
|
840
|
+
|
|
841
|
+
async def get_index_stats(self, index_name: str) -> dict[str, Any]:
|
|
842
|
+
"""Get index-specific statistics.
|
|
843
|
+
|
|
844
|
+
Args:
|
|
845
|
+
index_name: Index to get stats for.
|
|
846
|
+
|
|
847
|
+
Returns:
|
|
848
|
+
Index statistics dictionary.
|
|
849
|
+
"""
|
|
850
|
+
try:
|
|
851
|
+
stats = await self._client.indices.stats(index=index_name)
|
|
852
|
+
index_stats = stats.get("indices", {}).get(index_name, {})
|
|
853
|
+
primaries = index_stats.get("primaries", {})
|
|
854
|
+
|
|
855
|
+
return {
|
|
856
|
+
"index_name": index_name,
|
|
857
|
+
"doc_count": primaries.get("docs", {}).get("count", 0),
|
|
858
|
+
"deleted_doc_count": primaries.get("docs", {}).get("deleted", 0),
|
|
859
|
+
"primary_store_size_bytes": primaries.get("store", {}).get("size_in_bytes", 0),
|
|
860
|
+
"query_total": primaries.get("search", {}).get("query_total", 0),
|
|
861
|
+
"query_time_ms": primaries.get("search", {}).get("query_time_in_millis", 0),
|
|
862
|
+
"index_total": primaries.get("indexing", {}).get("index_total", 0),
|
|
863
|
+
"index_time_ms": primaries.get("indexing", {}).get("index_time_in_millis", 0),
|
|
864
|
+
}
|
|
865
|
+
except Exception as e:
|
|
866
|
+
raise SetupError(
|
|
867
|
+
message=f"Failed to get index stats: {e}",
|
|
868
|
+
details={"index_name": index_name},
|
|
869
|
+
cause=e,
|
|
870
|
+
) from e
|
|
871
|
+
|
|
872
|
+
# === Agentic Search Setup Methods ===
|
|
873
|
+
|
|
874
|
+
async def setup_flow_agent(self) -> str:
|
|
875
|
+
"""Create and deploy flow agent for fast RAG.
|
|
876
|
+
|
|
877
|
+
Flow agents provide single-turn RAG with minimal reasoning overhead.
|
|
878
|
+
They're optimized for quick responses and API use cases.
|
|
879
|
+
|
|
880
|
+
Returns:
|
|
881
|
+
Agent ID of the created/existing flow agent.
|
|
882
|
+
|
|
883
|
+
Raises:
|
|
884
|
+
SetupError: If agent creation fails.
|
|
885
|
+
"""
|
|
886
|
+
agent_name = f"{self._config.index_prefix}-flow-agent"
|
|
887
|
+
|
|
888
|
+
# Check if agent already exists
|
|
889
|
+
existing = await self._find_agent_by_name(agent_name)
|
|
890
|
+
if existing:
|
|
891
|
+
logger.info(f"Using existing flow agent: {existing}")
|
|
892
|
+
return existing
|
|
893
|
+
|
|
894
|
+
# Validate prerequisites
|
|
895
|
+
if not self._config.model_id and not self._model_id:
|
|
896
|
+
raise SetupError(
|
|
897
|
+
message="Embedding model not configured",
|
|
898
|
+
step="flow_agent",
|
|
899
|
+
details={"hint": "Run 'gnosisllm-knowledge setup' first to deploy the embedding model."},
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
# Create LLM connector and model for reasoning
|
|
903
|
+
llm_model_id = await self._setup_llm_model()
|
|
904
|
+
|
|
905
|
+
# Create tool configurations
|
|
906
|
+
vector_tool = self._create_vector_db_tool_config()
|
|
907
|
+
answer_tool = self._create_answer_generator_tool_config(llm_model_id)
|
|
908
|
+
|
|
909
|
+
# Register flow agent with both search and answer generation tools
|
|
910
|
+
agent_body = {
|
|
911
|
+
"name": agent_name,
|
|
912
|
+
"type": "flow",
|
|
913
|
+
"description": "Fast RAG agent for GnosisLLM Knowledge - optimized for single-turn queries",
|
|
914
|
+
"tools": [vector_tool, answer_tool],
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
try:
|
|
918
|
+
response = await self._client.transport.perform_request(
|
|
919
|
+
"POST",
|
|
920
|
+
"/_plugins/_ml/agents/_register",
|
|
921
|
+
body=agent_body,
|
|
922
|
+
)
|
|
923
|
+
agent_id = response.get("agent_id")
|
|
924
|
+
logger.info(f"Created flow agent: {agent_id}")
|
|
925
|
+
return agent_id
|
|
926
|
+
except Exception as e:
|
|
927
|
+
raise SetupError(
|
|
928
|
+
message=f"Failed to create flow agent: {e}",
|
|
929
|
+
step="flow_agent",
|
|
930
|
+
cause=e,
|
|
931
|
+
) from e
|
|
932
|
+
|
|
933
|
+
async def setup_conversational_agent(self) -> str:
|
|
934
|
+
"""Create and deploy conversational agent with memory.
|
|
935
|
+
|
|
936
|
+
Conversational agents support multi-turn dialogue with memory
|
|
937
|
+
persistence. They maintain context across interactions.
|
|
938
|
+
|
|
939
|
+
Returns:
|
|
940
|
+
Agent ID of the created/existing conversational agent.
|
|
941
|
+
|
|
942
|
+
Raises:
|
|
943
|
+
SetupError: If agent creation fails.
|
|
944
|
+
"""
|
|
945
|
+
agent_name = f"{self._config.index_prefix}-conversational-agent"
|
|
946
|
+
|
|
947
|
+
# Check if agent already exists
|
|
948
|
+
existing = await self._find_agent_by_name(agent_name)
|
|
949
|
+
if existing:
|
|
950
|
+
logger.info(f"Using existing conversational agent: {existing}")
|
|
951
|
+
return existing
|
|
952
|
+
|
|
953
|
+
# Validate prerequisites
|
|
954
|
+
if not self._config.model_id and not self._model_id:
|
|
955
|
+
raise SetupError(
|
|
956
|
+
message="Embedding model not configured",
|
|
957
|
+
step="conversational_agent",
|
|
958
|
+
details={"hint": "Run 'gnosisllm-knowledge setup' first to deploy the embedding model."},
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
# Create LLM connector and model for reasoning
|
|
962
|
+
llm_model_id = await self._setup_llm_model()
|
|
963
|
+
|
|
964
|
+
# Create tool configurations
|
|
965
|
+
# Conversational agent uses chat_history in prompt for multi-turn support
|
|
966
|
+
vector_tool = self._create_vector_db_tool_config()
|
|
967
|
+
answer_tool = self._create_answer_generator_tool_config(
|
|
968
|
+
llm_model_id, include_chat_history=True
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
# Register conversational flow agent with memory and both tools
|
|
972
|
+
# Using "conversational_flow" type which:
|
|
973
|
+
# - Executes tools sequentially like flow agent
|
|
974
|
+
# - Supports conversation memory for multi-turn dialogue
|
|
975
|
+
# - Injects chat_history from memory when message_history_limit > 0
|
|
976
|
+
agent_body = {
|
|
977
|
+
"name": agent_name,
|
|
978
|
+
"type": "conversational_flow",
|
|
979
|
+
"app_type": "rag",
|
|
980
|
+
"description": "Conversational agent with memory for GnosisLLM Knowledge - supports multi-turn dialogue",
|
|
981
|
+
"llm": {
|
|
982
|
+
"model_id": llm_model_id,
|
|
983
|
+
"parameters": {
|
|
984
|
+
"message_history_limit": 10, # Include last 10 messages as chat_history
|
|
985
|
+
},
|
|
986
|
+
},
|
|
987
|
+
"tools": [vector_tool, answer_tool],
|
|
988
|
+
"memory": {
|
|
989
|
+
"type": "conversation_index",
|
|
990
|
+
},
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
try:
|
|
994
|
+
response = await self._client.transport.perform_request(
|
|
995
|
+
"POST",
|
|
996
|
+
"/_plugins/_ml/agents/_register",
|
|
997
|
+
body=agent_body,
|
|
998
|
+
)
|
|
999
|
+
agent_id = response.get("agent_id")
|
|
1000
|
+
logger.info(f"Created conversational agent: {agent_id}")
|
|
1001
|
+
return agent_id
|
|
1002
|
+
except Exception as e:
|
|
1003
|
+
raise SetupError(
|
|
1004
|
+
message=f"Failed to create conversational agent: {e}",
|
|
1005
|
+
step="conversational_agent",
|
|
1006
|
+
cause=e,
|
|
1007
|
+
) from e
|
|
1008
|
+
|
|
1009
|
+
async def setup_agents(self, agent_types: list[str] | None = None) -> dict[str, str]:
|
|
1010
|
+
"""Setup agentic search agents.
|
|
1011
|
+
|
|
1012
|
+
Args:
|
|
1013
|
+
agent_types: List of agent types to setup ('flow', 'conversational').
|
|
1014
|
+
If None, sets up all agent types.
|
|
1015
|
+
|
|
1016
|
+
Returns:
|
|
1017
|
+
Dictionary mapping agent type to agent ID.
|
|
1018
|
+
|
|
1019
|
+
Raises:
|
|
1020
|
+
SetupError: If any agent creation fails.
|
|
1021
|
+
"""
|
|
1022
|
+
if agent_types is None:
|
|
1023
|
+
agent_types = ["flow", "conversational"]
|
|
1024
|
+
|
|
1025
|
+
results: dict[str, str] = {}
|
|
1026
|
+
|
|
1027
|
+
if "flow" in agent_types:
|
|
1028
|
+
results["flow_agent_id"] = await self.setup_flow_agent()
|
|
1029
|
+
|
|
1030
|
+
if "conversational" in agent_types:
|
|
1031
|
+
results["conversational_agent_id"] = await self.setup_conversational_agent()
|
|
1032
|
+
|
|
1033
|
+
return results
|
|
1034
|
+
|
|
1035
|
+
async def cleanup_agents(self) -> SetupResult:
|
|
1036
|
+
"""Clean up agentic search agents.
|
|
1037
|
+
|
|
1038
|
+
Returns:
|
|
1039
|
+
Cleanup result with steps completed.
|
|
1040
|
+
"""
|
|
1041
|
+
steps_completed: list[str] = []
|
|
1042
|
+
errors: list[str] = []
|
|
1043
|
+
|
|
1044
|
+
# Delete flow agent
|
|
1045
|
+
flow_agent_name = f"{self._config.index_prefix}-flow-agent"
|
|
1046
|
+
flow_agent_id = await self._find_agent_by_name(flow_agent_name)
|
|
1047
|
+
if flow_agent_id:
|
|
1048
|
+
try:
|
|
1049
|
+
await self._client.transport.perform_request(
|
|
1050
|
+
"DELETE",
|
|
1051
|
+
f"/_plugins/_ml/agents/{flow_agent_id}",
|
|
1052
|
+
)
|
|
1053
|
+
steps_completed.append(f"Deleted flow agent: {flow_agent_id}")
|
|
1054
|
+
except Exception as e:
|
|
1055
|
+
errors.append(f"Failed to delete flow agent: {e}")
|
|
1056
|
+
|
|
1057
|
+
# Delete conversational agent
|
|
1058
|
+
conv_agent_name = f"{self._config.index_prefix}-conversational-agent"
|
|
1059
|
+
conv_agent_id = await self._find_agent_by_name(conv_agent_name)
|
|
1060
|
+
if conv_agent_id:
|
|
1061
|
+
try:
|
|
1062
|
+
await self._client.transport.perform_request(
|
|
1063
|
+
"DELETE",
|
|
1064
|
+
f"/_plugins/_ml/agents/{conv_agent_id}",
|
|
1065
|
+
)
|
|
1066
|
+
steps_completed.append(f"Deleted conversational agent: {conv_agent_id}")
|
|
1067
|
+
except Exception as e:
|
|
1068
|
+
errors.append(f"Failed to delete conversational agent: {e}")
|
|
1069
|
+
|
|
1070
|
+
# Delete LLM model
|
|
1071
|
+
llm_model_name = f"{self._config.index_prefix}-llm-model"
|
|
1072
|
+
llm_model_id = await self._find_model_by_name(llm_model_name)
|
|
1073
|
+
if llm_model_id:
|
|
1074
|
+
try:
|
|
1075
|
+
await self._client.transport.perform_request(
|
|
1076
|
+
"POST",
|
|
1077
|
+
f"/_plugins/_ml/models/{llm_model_id}/_undeploy",
|
|
1078
|
+
)
|
|
1079
|
+
await asyncio.sleep(2)
|
|
1080
|
+
await self._client.transport.perform_request(
|
|
1081
|
+
"DELETE",
|
|
1082
|
+
f"/_plugins/_ml/models/{llm_model_id}",
|
|
1083
|
+
)
|
|
1084
|
+
steps_completed.append(f"Deleted LLM model: {llm_model_id}")
|
|
1085
|
+
except Exception as e:
|
|
1086
|
+
logger.warning(f"Failed to delete LLM model: {e}")
|
|
1087
|
+
|
|
1088
|
+
# Delete LLM connector
|
|
1089
|
+
llm_connector_name = f"{self._config.index_prefix}-llm-connector"
|
|
1090
|
+
llm_connector_id = await self._find_connector_by_name(llm_connector_name)
|
|
1091
|
+
if llm_connector_id:
|
|
1092
|
+
try:
|
|
1093
|
+
await self._client.transport.perform_request(
|
|
1094
|
+
"DELETE",
|
|
1095
|
+
f"/_plugins/_ml/connectors/{llm_connector_id}",
|
|
1096
|
+
)
|
|
1097
|
+
steps_completed.append(f"Deleted LLM connector: {llm_connector_id}")
|
|
1098
|
+
except Exception as e:
|
|
1099
|
+
logger.warning(f"Failed to delete LLM connector: {e}")
|
|
1100
|
+
|
|
1101
|
+
return SetupResult(
|
|
1102
|
+
success=len(errors) == 0,
|
|
1103
|
+
steps_completed=steps_completed,
|
|
1104
|
+
errors=errors if errors else None,
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
def _create_vector_db_tool_config(self) -> dict[str, Any]:
|
|
1108
|
+
"""Create VectorDBTool configuration for knowledge search.
|
|
1109
|
+
|
|
1110
|
+
The tool uses ${parameters.question} to get the user's query from
|
|
1111
|
+
the agent execution parameters.
|
|
1112
|
+
|
|
1113
|
+
Returns:
|
|
1114
|
+
Tool configuration dictionary.
|
|
1115
|
+
"""
|
|
1116
|
+
embedding_model_id = self._model_id or self._config.model_id
|
|
1117
|
+
# Use wildcard pattern to search all collection indices
|
|
1118
|
+
# Format: {index_prefix}-knowledge-* (e.g., gnosisllm-knowledge-*)
|
|
1119
|
+
index_pattern = f"{self._config.index_prefix}-knowledge-*"
|
|
1120
|
+
return {
|
|
1121
|
+
"type": "VectorDBTool",
|
|
1122
|
+
"name": "knowledge_search",
|
|
1123
|
+
"description": "Search the knowledge base for relevant information. "
|
|
1124
|
+
"Use this tool to find documents related to user questions.",
|
|
1125
|
+
"parameters": {
|
|
1126
|
+
"input": "${parameters.question}",
|
|
1127
|
+
"index": index_pattern,
|
|
1128
|
+
"embedding_field": self._config.embedding_field,
|
|
1129
|
+
"source_field": '["content", "title", "url", "source", "collection_id"]',
|
|
1130
|
+
"model_id": embedding_model_id,
|
|
1131
|
+
},
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
def _create_answer_generator_tool_config(
|
|
1135
|
+
self, llm_model_id: str, include_chat_history: bool = False
|
|
1136
|
+
) -> dict[str, Any]:
|
|
1137
|
+
"""Create MLModelTool configuration for answer generation.
|
|
1138
|
+
|
|
1139
|
+
Args:
|
|
1140
|
+
llm_model_id: LLM model ID for answer generation.
|
|
1141
|
+
include_chat_history: Include chat_history placeholder for conversational agents.
|
|
1142
|
+
|
|
1143
|
+
Returns:
|
|
1144
|
+
Tool configuration dictionary.
|
|
1145
|
+
"""
|
|
1146
|
+
if include_chat_history:
|
|
1147
|
+
# Conversational prompt with chat_history for multi-turn support
|
|
1148
|
+
# ${parameters.chat_history:-} is populated by OpenSearch from memory
|
|
1149
|
+
prompt = """You are a helpful assistant that answers questions based on provided context and conversation history.
|
|
1150
|
+
|
|
1151
|
+
Context from knowledge base:
|
|
1152
|
+
${parameters.knowledge_search.output}
|
|
1153
|
+
|
|
1154
|
+
Previous conversation:
|
|
1155
|
+
${parameters.chat_history:-}
|
|
1156
|
+
|
|
1157
|
+
Question: ${parameters.question}
|
|
1158
|
+
|
|
1159
|
+
Instructions:
|
|
1160
|
+
- Use conversation history to understand follow-up questions
|
|
1161
|
+
- Answer based on the context and conversation history
|
|
1162
|
+
- If the answer is not available, say "I don't have enough information to answer this question."
|
|
1163
|
+
- Be concise and accurate
|
|
1164
|
+
- Cite sources when possible
|
|
1165
|
+
|
|
1166
|
+
Answer:"""
|
|
1167
|
+
else:
|
|
1168
|
+
# Simple prompt for flow agents (no conversation history)
|
|
1169
|
+
prompt = """You are a helpful assistant that answers questions based on provided context.
|
|
1170
|
+
|
|
1171
|
+
Context from knowledge base:
|
|
1172
|
+
${parameters.knowledge_search.output}
|
|
1173
|
+
|
|
1174
|
+
Question: ${parameters.question}
|
|
1175
|
+
|
|
1176
|
+
Instructions:
|
|
1177
|
+
- Answer based ONLY on the context provided above
|
|
1178
|
+
- If the answer is not in the context, say "I don't have enough information to answer this question."
|
|
1179
|
+
- Be concise and accurate
|
|
1180
|
+
- Cite sources when possible using the URLs provided
|
|
1181
|
+
|
|
1182
|
+
Answer:"""
|
|
1183
|
+
|
|
1184
|
+
return {
|
|
1185
|
+
"type": "MLModelTool",
|
|
1186
|
+
"name": "answer_generator",
|
|
1187
|
+
"description": "Generate a natural language answer from search results",
|
|
1188
|
+
"parameters": {
|
|
1189
|
+
"model_id": llm_model_id,
|
|
1190
|
+
"prompt": prompt,
|
|
1191
|
+
},
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
async def _setup_llm_model(self) -> str:
|
|
1195
|
+
"""Setup LLM model for agent reasoning.
|
|
1196
|
+
|
|
1197
|
+
Creates an LLM connector and deploys the model if not exists.
|
|
1198
|
+
|
|
1199
|
+
Returns:
|
|
1200
|
+
LLM model ID.
|
|
1201
|
+
|
|
1202
|
+
Raises:
|
|
1203
|
+
SetupError: If LLM setup fails.
|
|
1204
|
+
"""
|
|
1205
|
+
# Create LLM connector
|
|
1206
|
+
llm_connector_id = await self._create_llm_connector()
|
|
1207
|
+
|
|
1208
|
+
# Create LLM model
|
|
1209
|
+
llm_model_name = f"{self._config.index_prefix}-llm-model"
|
|
1210
|
+
existing_model = await self._find_model_by_name(llm_model_name)
|
|
1211
|
+
if existing_model:
|
|
1212
|
+
# Check if deployed
|
|
1213
|
+
status = await self._get_model_status(existing_model)
|
|
1214
|
+
if status == "DEPLOYED":
|
|
1215
|
+
logger.info(f"Using existing deployed LLM model: {existing_model}")
|
|
1216
|
+
return existing_model
|
|
1217
|
+
# Deploy if not deployed
|
|
1218
|
+
await self._deploy_model_by_id(existing_model)
|
|
1219
|
+
return existing_model
|
|
1220
|
+
|
|
1221
|
+
# Register new LLM model
|
|
1222
|
+
response = await self._client.transport.perform_request(
|
|
1223
|
+
"POST",
|
|
1224
|
+
"/_plugins/_ml/models/_register",
|
|
1225
|
+
body={
|
|
1226
|
+
"name": llm_model_name,
|
|
1227
|
+
"function_name": "remote",
|
|
1228
|
+
"model_group_id": self._model_group_id or await self._find_or_create_model_group(),
|
|
1229
|
+
"description": f"OpenAI {self._config.agentic_llm_model} for GnosisLLM agent reasoning",
|
|
1230
|
+
"connector_id": llm_connector_id,
|
|
1231
|
+
},
|
|
1232
|
+
)
|
|
1233
|
+
|
|
1234
|
+
task_id = response.get("task_id")
|
|
1235
|
+
if not task_id:
|
|
1236
|
+
raise SetupError(
|
|
1237
|
+
message="No task_id returned from LLM model registration",
|
|
1238
|
+
step="llm_model",
|
|
1239
|
+
)
|
|
1240
|
+
|
|
1241
|
+
# Wait for registration
|
|
1242
|
+
model_id = await self._wait_for_task(task_id, "LLM model registration")
|
|
1243
|
+
if not model_id:
|
|
1244
|
+
raise SetupError(
|
|
1245
|
+
message="LLM model registration timed out",
|
|
1246
|
+
step="llm_model",
|
|
1247
|
+
)
|
|
1248
|
+
|
|
1249
|
+
# Deploy the model
|
|
1250
|
+
await self._deploy_model_by_id(model_id)
|
|
1251
|
+
logger.info(f"Deployed LLM model: {model_id}")
|
|
1252
|
+
return model_id
|
|
1253
|
+
|
|
1254
|
+
async def _create_llm_connector(self) -> str:
|
|
1255
|
+
"""Create LLM connector for agent reasoning.
|
|
1256
|
+
|
|
1257
|
+
Returns:
|
|
1258
|
+
Connector ID.
|
|
1259
|
+
|
|
1260
|
+
Raises:
|
|
1261
|
+
SetupError: If connector creation fails.
|
|
1262
|
+
"""
|
|
1263
|
+
connector_name = f"{self._config.index_prefix}-llm-connector"
|
|
1264
|
+
|
|
1265
|
+
# Check if connector already exists
|
|
1266
|
+
existing = await self._find_connector_by_name(connector_name)
|
|
1267
|
+
if existing:
|
|
1268
|
+
logger.info(f"Using existing LLM connector: {existing}")
|
|
1269
|
+
return existing
|
|
1270
|
+
|
|
1271
|
+
if not self._config.openai_api_key:
|
|
1272
|
+
raise SetupError(
|
|
1273
|
+
message="OPENAI_API_KEY required for LLM connector",
|
|
1274
|
+
step="llm_connector",
|
|
1275
|
+
details={"hint": "Set OPENAI_API_KEY environment variable"},
|
|
1276
|
+
)
|
|
1277
|
+
|
|
1278
|
+
connector_body = {
|
|
1279
|
+
"name": connector_name,
|
|
1280
|
+
"description": f"OpenAI {self._config.agentic_llm_model} connector for agent reasoning",
|
|
1281
|
+
"version": 1,
|
|
1282
|
+
"protocol": "http",
|
|
1283
|
+
"parameters": {
|
|
1284
|
+
"model": self._config.agentic_llm_model,
|
|
1285
|
+
},
|
|
1286
|
+
"credential": {
|
|
1287
|
+
"openAI_key": self._config.openai_api_key,
|
|
1288
|
+
},
|
|
1289
|
+
"actions": [
|
|
1290
|
+
{
|
|
1291
|
+
"action_type": "predict",
|
|
1292
|
+
"method": "POST",
|
|
1293
|
+
"url": "https://api.openai.com/v1/chat/completions",
|
|
1294
|
+
"headers": {
|
|
1295
|
+
"Authorization": "Bearer ${credential.openAI_key}",
|
|
1296
|
+
"Content-Type": "application/json",
|
|
1297
|
+
},
|
|
1298
|
+
"request_body": '{ "model": "${parameters.model}", "messages": [{"role": "user", "content": "${parameters.prompt}"}], "temperature": ${parameters.temperature:-0} }',
|
|
1299
|
+
},
|
|
1300
|
+
],
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
try:
|
|
1304
|
+
response = await self._client.transport.perform_request(
|
|
1305
|
+
"POST",
|
|
1306
|
+
"/_plugins/_ml/connectors/_create",
|
|
1307
|
+
body=connector_body,
|
|
1308
|
+
)
|
|
1309
|
+
connector_id = response.get("connector_id")
|
|
1310
|
+
logger.info(f"Created LLM connector: {connector_id}")
|
|
1311
|
+
return connector_id
|
|
1312
|
+
except Exception as e:
|
|
1313
|
+
raise SetupError(
|
|
1314
|
+
message=f"Failed to create LLM connector: {e}",
|
|
1315
|
+
step="llm_connector",
|
|
1316
|
+
cause=e,
|
|
1317
|
+
) from e
|
|
1318
|
+
|
|
1319
|
+
async def _find_or_create_model_group(self) -> str:
|
|
1320
|
+
"""Find existing model group or create one.
|
|
1321
|
+
|
|
1322
|
+
Returns:
|
|
1323
|
+
Model group ID.
|
|
1324
|
+
"""
|
|
1325
|
+
if self._model_group_id:
|
|
1326
|
+
return self._model_group_id
|
|
1327
|
+
|
|
1328
|
+
model_group_name = f"{self._config.index_prefix}-model-group"
|
|
1329
|
+
existing = await self._find_model_group_by_name(model_group_name)
|
|
1330
|
+
if existing:
|
|
1331
|
+
return existing
|
|
1332
|
+
|
|
1333
|
+
# Create model group
|
|
1334
|
+
response = await self._client.transport.perform_request(
|
|
1335
|
+
"POST",
|
|
1336
|
+
"/_plugins/_ml/model_groups/_register",
|
|
1337
|
+
body={
|
|
1338
|
+
"name": model_group_name,
|
|
1339
|
+
"description": "Model group for GnosisLLM Knowledge",
|
|
1340
|
+
},
|
|
1341
|
+
)
|
|
1342
|
+
return response.get("model_group_id")
|
|
1343
|
+
|
|
1344
|
+
async def _find_agent_by_name(self, name: str) -> str | None:
|
|
1345
|
+
"""Find agent by exact name.
|
|
1346
|
+
|
|
1347
|
+
Args:
|
|
1348
|
+
name: Agent name to search for (exact match).
|
|
1349
|
+
|
|
1350
|
+
Returns:
|
|
1351
|
+
Agent ID if found, None otherwise.
|
|
1352
|
+
"""
|
|
1353
|
+
try:
|
|
1354
|
+
response = await self._client.transport.perform_request(
|
|
1355
|
+
"POST",
|
|
1356
|
+
"/_plugins/_ml/agents/_search",
|
|
1357
|
+
body={"query": {"term": {"name.keyword": name}}},
|
|
1358
|
+
)
|
|
1359
|
+
hits = response.get("hits", {}).get("hits", [])
|
|
1360
|
+
if hits:
|
|
1361
|
+
return hits[0]["_id"]
|
|
1362
|
+
except Exception:
|
|
1363
|
+
pass
|
|
1364
|
+
return None
|
|
1365
|
+
|
|
1366
|
+
async def get_agent_status(self, agent_id: str) -> dict[str, Any] | None:
|
|
1367
|
+
"""Get status of an agent.
|
|
1368
|
+
|
|
1369
|
+
Args:
|
|
1370
|
+
agent_id: Agent identifier.
|
|
1371
|
+
|
|
1372
|
+
Returns:
|
|
1373
|
+
Agent status info or None if not found.
|
|
1374
|
+
"""
|
|
1375
|
+
try:
|
|
1376
|
+
response = await self._client.transport.perform_request(
|
|
1377
|
+
"GET",
|
|
1378
|
+
f"/_plugins/_ml/agents/{agent_id}",
|
|
1379
|
+
)
|
|
1380
|
+
return {
|
|
1381
|
+
"agent_id": agent_id,
|
|
1382
|
+
"name": response.get("name"),
|
|
1383
|
+
"type": response.get("type"),
|
|
1384
|
+
"description": response.get("description"),
|
|
1385
|
+
"tools": [t.get("name") for t in response.get("tools", [])],
|
|
1386
|
+
"created_at": response.get("created_time"),
|
|
1387
|
+
}
|
|
1388
|
+
except Exception as e:
|
|
1389
|
+
logger.warning(f"Failed to get agent status: {e}")
|
|
1390
|
+
return None
|