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.
Files changed (64) hide show
  1. gnosisllm_knowledge/__init__.py +152 -0
  2. gnosisllm_knowledge/api/__init__.py +5 -0
  3. gnosisllm_knowledge/api/knowledge.py +548 -0
  4. gnosisllm_knowledge/backends/__init__.py +26 -0
  5. gnosisllm_knowledge/backends/memory/__init__.py +9 -0
  6. gnosisllm_knowledge/backends/memory/indexer.py +384 -0
  7. gnosisllm_knowledge/backends/memory/searcher.py +516 -0
  8. gnosisllm_knowledge/backends/opensearch/__init__.py +19 -0
  9. gnosisllm_knowledge/backends/opensearch/agentic.py +738 -0
  10. gnosisllm_knowledge/backends/opensearch/config.py +195 -0
  11. gnosisllm_knowledge/backends/opensearch/indexer.py +499 -0
  12. gnosisllm_knowledge/backends/opensearch/mappings.py +255 -0
  13. gnosisllm_knowledge/backends/opensearch/queries.py +445 -0
  14. gnosisllm_knowledge/backends/opensearch/searcher.py +383 -0
  15. gnosisllm_knowledge/backends/opensearch/setup.py +1390 -0
  16. gnosisllm_knowledge/chunking/__init__.py +9 -0
  17. gnosisllm_knowledge/chunking/fixed.py +138 -0
  18. gnosisllm_knowledge/chunking/sentence.py +239 -0
  19. gnosisllm_knowledge/cli/__init__.py +18 -0
  20. gnosisllm_knowledge/cli/app.py +509 -0
  21. gnosisllm_knowledge/cli/commands/__init__.py +7 -0
  22. gnosisllm_knowledge/cli/commands/agentic.py +529 -0
  23. gnosisllm_knowledge/cli/commands/load.py +369 -0
  24. gnosisllm_knowledge/cli/commands/search.py +440 -0
  25. gnosisllm_knowledge/cli/commands/setup.py +228 -0
  26. gnosisllm_knowledge/cli/display/__init__.py +5 -0
  27. gnosisllm_knowledge/cli/display/service.py +555 -0
  28. gnosisllm_knowledge/cli/utils/__init__.py +5 -0
  29. gnosisllm_knowledge/cli/utils/config.py +207 -0
  30. gnosisllm_knowledge/core/__init__.py +87 -0
  31. gnosisllm_knowledge/core/domain/__init__.py +43 -0
  32. gnosisllm_knowledge/core/domain/document.py +240 -0
  33. gnosisllm_knowledge/core/domain/result.py +176 -0
  34. gnosisllm_knowledge/core/domain/search.py +327 -0
  35. gnosisllm_knowledge/core/domain/source.py +139 -0
  36. gnosisllm_knowledge/core/events/__init__.py +23 -0
  37. gnosisllm_knowledge/core/events/emitter.py +216 -0
  38. gnosisllm_knowledge/core/events/types.py +226 -0
  39. gnosisllm_knowledge/core/exceptions.py +407 -0
  40. gnosisllm_knowledge/core/interfaces/__init__.py +20 -0
  41. gnosisllm_knowledge/core/interfaces/agentic.py +136 -0
  42. gnosisllm_knowledge/core/interfaces/chunker.py +64 -0
  43. gnosisllm_knowledge/core/interfaces/fetcher.py +112 -0
  44. gnosisllm_knowledge/core/interfaces/indexer.py +244 -0
  45. gnosisllm_knowledge/core/interfaces/loader.py +102 -0
  46. gnosisllm_knowledge/core/interfaces/searcher.py +178 -0
  47. gnosisllm_knowledge/core/interfaces/setup.py +164 -0
  48. gnosisllm_knowledge/fetchers/__init__.py +12 -0
  49. gnosisllm_knowledge/fetchers/config.py +77 -0
  50. gnosisllm_knowledge/fetchers/http.py +167 -0
  51. gnosisllm_knowledge/fetchers/neoreader.py +204 -0
  52. gnosisllm_knowledge/loaders/__init__.py +13 -0
  53. gnosisllm_knowledge/loaders/base.py +399 -0
  54. gnosisllm_knowledge/loaders/factory.py +202 -0
  55. gnosisllm_knowledge/loaders/sitemap.py +285 -0
  56. gnosisllm_knowledge/loaders/website.py +57 -0
  57. gnosisllm_knowledge/py.typed +0 -0
  58. gnosisllm_knowledge/services/__init__.py +9 -0
  59. gnosisllm_knowledge/services/indexing.py +387 -0
  60. gnosisllm_knowledge/services/search.py +349 -0
  61. gnosisllm_knowledge-0.2.0.dist-info/METADATA +382 -0
  62. gnosisllm_knowledge-0.2.0.dist-info/RECORD +64 -0
  63. gnosisllm_knowledge-0.2.0.dist-info/WHEEL +4 -0
  64. 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