guardianhub 0.1.88__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. guardianhub/__init__.py +29 -0
  2. guardianhub/_version.py +1 -0
  3. guardianhub/agents/runtime.py +12 -0
  4. guardianhub/auth/token_provider.py +22 -0
  5. guardianhub/clients/__init__.py +2 -0
  6. guardianhub/clients/classification_client.py +52 -0
  7. guardianhub/clients/graph_db_client.py +161 -0
  8. guardianhub/clients/langfuse/dataset_client.py +157 -0
  9. guardianhub/clients/langfuse/manager.py +118 -0
  10. guardianhub/clients/langfuse/prompt_client.py +68 -0
  11. guardianhub/clients/langfuse/score_evaluation_client.py +92 -0
  12. guardianhub/clients/langfuse/tracing_client.py +250 -0
  13. guardianhub/clients/langfuse_client.py +63 -0
  14. guardianhub/clients/llm_client.py +144 -0
  15. guardianhub/clients/llm_service.py +295 -0
  16. guardianhub/clients/metadata_extractor_client.py +53 -0
  17. guardianhub/clients/ocr_client.py +81 -0
  18. guardianhub/clients/paperless_client.py +515 -0
  19. guardianhub/clients/registry_client.py +18 -0
  20. guardianhub/clients/text_cleaner_client.py +58 -0
  21. guardianhub/clients/vector_client.py +344 -0
  22. guardianhub/config/__init__.py +0 -0
  23. guardianhub/config/config_development.json +84 -0
  24. guardianhub/config/config_prod.json +39 -0
  25. guardianhub/config/settings.py +221 -0
  26. guardianhub/http/http_client.py +26 -0
  27. guardianhub/logging/__init__.py +2 -0
  28. guardianhub/logging/logging.py +168 -0
  29. guardianhub/logging/logging_filters.py +35 -0
  30. guardianhub/models/__init__.py +0 -0
  31. guardianhub/models/agent_models.py +153 -0
  32. guardianhub/models/base.py +2 -0
  33. guardianhub/models/registry/client.py +16 -0
  34. guardianhub/models/registry/dynamic_loader.py +73 -0
  35. guardianhub/models/registry/loader.py +37 -0
  36. guardianhub/models/registry/registry.py +17 -0
  37. guardianhub/models/registry/signing.py +70 -0
  38. guardianhub/models/template/__init__.py +0 -0
  39. guardianhub/models/template/agent_plan.py +65 -0
  40. guardianhub/models/template/agent_response_evaluation.py +67 -0
  41. guardianhub/models/template/extraction.py +29 -0
  42. guardianhub/models/template/reflection_critique.py +206 -0
  43. guardianhub/models/template/suggestion.py +42 -0
  44. guardianhub/observability/__init__.py +1 -0
  45. guardianhub/observability/instrumentation.py +271 -0
  46. guardianhub/observability/otel_helper.py +43 -0
  47. guardianhub/observability/otel_middlewares.py +73 -0
  48. guardianhub/prompts/base.py +7 -0
  49. guardianhub/prompts/providers/langfuse_provider.py +13 -0
  50. guardianhub/prompts/providers/local_provider.py +22 -0
  51. guardianhub/prompts/registry.py +14 -0
  52. guardianhub/scripts/script.sh +31 -0
  53. guardianhub/services/base.py +15 -0
  54. guardianhub/template/__init__.py +0 -0
  55. guardianhub/tools/gh_registry_cli.py +171 -0
  56. guardianhub/utils/__init__.py +0 -0
  57. guardianhub/utils/app_state.py +74 -0
  58. guardianhub/utils/fastapi_utils.py +152 -0
  59. guardianhub/utils/json_utils.py +137 -0
  60. guardianhub/utils/metrics.py +60 -0
  61. guardianhub-0.1.88.dist-info/METADATA +240 -0
  62. guardianhub-0.1.88.dist-info/RECORD +64 -0
  63. guardianhub-0.1.88.dist-info/WHEEL +4 -0
  64. guardianhub-0.1.88.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,344 @@
1
+ """
2
+ Vector Service Client for interacting with vector database.
3
+
4
+ This module provides functionality for:
5
+ - Managing vector collections
6
+ - Storing and retrieving document embeddings (via the service)
7
+ - Handling semantic and agentic tools
8
+ - Chunking large documents
9
+ - Sanitizing metadata
10
+ """
11
+
12
+ import json
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ import httpx
16
+
17
+ from guardianhub import get_logger
18
+ from guardianhub.config.settings import settings
19
+
20
+ logger = get_logger(__name__)
21
+
22
+ def _chunk_text(
23
+ text: str,
24
+ max_tokens: int = 500,
25
+ approx_chars_per_token: int = 4
26
+ ) -> List[str]:
27
+ """Split text into chunks of approximately max_tokens length."""
28
+ if not text:
29
+ return []
30
+
31
+ max_chars = max_tokens * approx_chars_per_token
32
+ if len(text) <= max_chars:
33
+ return [text]
34
+
35
+ chunks = []
36
+ start = 0
37
+
38
+ while start < len(text):
39
+ end = min(len(text), start + max_chars)
40
+ slice_ = text[start:end]
41
+
42
+ # Look for the last newline or space to cut cleanly
43
+ if end < len(text):
44
+ last_nl = slice_.rfind("\n")
45
+ last_space = slice_.rfind(" ")
46
+ cut = max(last_nl, last_space)
47
+ if cut > 0:
48
+ end = start + cut
49
+
50
+ chunks.append(text[start:end].strip())
51
+ start = end
52
+
53
+ return [c for c in chunks if c]
54
+
55
+ def _sanitize_metadata_value(value: Any) -> Any:
56
+ """Ensure metadata value is vector-DB safe."""
57
+ if value is None or value == []:
58
+ return None
59
+ if isinstance(value, (str, int, float, bool)):
60
+ return value
61
+ try:
62
+ # Complex types like dicts or lists should be JSON serialized
63
+ return json.dumps(value)
64
+ except Exception:
65
+ return str(value)
66
+
67
+ def sanitize_metadata(metadata: Dict[str, Any]) -> Dict[str, Any]:
68
+ """Clean metadata dictionary by dropping None values and normalizing types."""
69
+ return {
70
+ k: _sanitize_metadata_value(v)
71
+ for k, v in metadata.items()
72
+ if _sanitize_metadata_value(v) is not None
73
+ }
74
+
75
+ class VectorClient:
76
+ """Client for interacting with the vector database service."""
77
+
78
+ def __init__(
79
+ self,
80
+ base_url: Optional[str] = None,
81
+ collection: str = settings.vector.default_collection,
82
+ http_timeout: float = 30.0,
83
+ **collection_kwargs
84
+ ):
85
+ """Initialize the VectorClient.
86
+
87
+ Args:
88
+ base_url: Base URL for the vector service. If not provided, will try to get from settings.
89
+ collection_docs: Name of the document collection to use.
90
+ http_timeout: Timeout for HTTP requests in seconds.
91
+ **collection_kwargs: Additional collection configuration.
92
+ """
93
+
94
+ self.base_url = base_url or settings.endpoints.get('VECTOR_SERVICE_URL')
95
+ # self.base_url = base_url.rstrip("/")
96
+ self.collection = collection
97
+ self.collection_config = collection_kwargs
98
+ self._client = httpx.AsyncClient(
99
+ base_url=self.base_url,
100
+ timeout=http_timeout
101
+ )
102
+ self.initialized = False
103
+
104
+ async def initialize(self):
105
+ """Initialize the client and check connection asynchronously"""
106
+ try:
107
+ await self._check_connection()
108
+ self.initialized = True
109
+ return self.initialized
110
+ except Exception as e:
111
+ logger.error(f"Vector client initialization failed: {str(e)}")
112
+ self.initialized = False
113
+ return False
114
+
115
+ async def ensure_collection_exists(self, collection_name: str = None, **collection_kwargs) -> bool:
116
+ """Ensure the specified collection exists, create it if it doesn't.
117
+
118
+ Args:
119
+ collection_name: Name of the collection to check/create
120
+ **collection_kwargs: Additional arguments for collection creation
121
+
122
+ Returns:
123
+ bool: True if collection exists or was created, False otherwise
124
+ """
125
+ try:
126
+ # Try to create the collection - will succeed if it doesn't exist
127
+ await self.create_collection(collection_name, **collection_kwargs)
128
+ logger.info(f"Created collection: {collection_name}")
129
+ return True
130
+ except httpx.HTTPStatusError as e:
131
+ if e.response.status_code == 409: # Collection already exists
132
+ logger.debug(f"Collection {collection_name} already exists")
133
+ return True
134
+ logger.error(f"Error ensuring collection {collection_name} exists: {str(e)}")
135
+ return False
136
+ except Exception as e:
137
+ logger.error(f"Unexpected error ensuring collection {collection_name} exists: {str(e)}")
138
+ return False
139
+
140
+ async def _check_connection(self):
141
+ """Check if the vector service is available."""
142
+ try:
143
+ response = await self._client.get("/health")
144
+ response.raise_for_status()
145
+ self.initialized = True
146
+ except Exception as e:
147
+ logger.error(f"Vector client health check failed: {str(e)}")
148
+ self.initialized = False
149
+ raise
150
+
151
+ async def close(self):
152
+ """Close the HTTP client."""
153
+ await self._client.aclose()
154
+
155
+ async def upsert_documents(
156
+ self,
157
+ ids: List[str],
158
+ documents: List[str],
159
+ metadatas: Optional[List[Dict[str, Any]]],
160
+ collection: Optional[str] ,
161
+ embeddings: Optional[List[List[float]]] = None,
162
+ ) -> Any:
163
+ """Upsert documents into the vector database in a single batch call."""
164
+ if not ids or not documents or len(ids) != len(documents):
165
+ raise ValueError("ids and documents must be non-empty and same length")
166
+
167
+ if metadatas and len(metadatas) != len(ids):
168
+ raise ValueError("metadatas must be same length as ids if provided")
169
+
170
+ # Ensure metadata is safe for the vector store
171
+ sanitized_metas = [
172
+ sanitize_metadata(m) if m else {}
173
+ for m in (metadatas or [{}] * len(ids))
174
+ ]
175
+
176
+ payload = {
177
+ # Note: We do not pass embeddings here; the service will generate them.
178
+ "documents": documents,
179
+ "ids": ids,
180
+ "metadatas": sanitized_metas,
181
+ "embeddings": embeddings
182
+ }
183
+
184
+ response = await self._client.post(
185
+ f"/collections/{collection}/add",
186
+ json=payload
187
+ )
188
+ response.raise_for_status()
189
+ return response.json()
190
+
191
+ async def upsert_document_from_text(
192
+ self,
193
+ document_content: str,
194
+ doc_id: str,
195
+ metadata: Dict[str, Any],
196
+ collection: Optional[str] = None
197
+ ) -> Dict[str, Any]:
198
+ """
199
+ Chunks a single document and upserts all chunks into the vector database.
200
+
201
+ The ingestion activity (store_in_vector_db_activity) should call this method.
202
+ It returns the ID of the first chunk, which acts as the 'vector_id' for the document.
203
+ """
204
+ collection_name = collection
205
+
206
+ if not document_content:
207
+ logger.warning(f"Attempted to upsert empty content for document ID: {doc_id}")
208
+ return {"status": "skipped", "message": "Empty document content"}
209
+
210
+ # 1. Chunk the document
211
+ chunks = _chunk_text(document_content)
212
+
213
+ if not chunks:
214
+ logger.warning(f"Chunking failed for document ID: {doc_id}")
215
+ return {"status": "skipped", "message": "Chunking failed to produce content"}
216
+
217
+ # 2. Prepare IDs and Metadata for upsert
218
+ chunk_ids = [f"{doc_id}-{i}" for i in range(len(chunks))]
219
+
220
+ # All chunks share the same base metadata, plus chunk-specific indices
221
+ base_metadata = sanitize_metadata(metadata.copy())
222
+
223
+ chunk_metadatas = []
224
+ for i in range(len(chunks)):
225
+ meta = base_metadata.copy()
226
+ # Store the primary document ID in the metadata for easy filtering/retrieval
227
+ meta["original_doc_id"] = doc_id
228
+ meta["chunk_index"] = i
229
+ meta["chunk_total"] = len(chunks)
230
+ chunk_metadatas.append(meta)
231
+
232
+ # 3. Upsert the documents (chunks)
233
+ response = await self.upsert_documents(
234
+ ids=chunk_ids,
235
+ documents=chunks,
236
+ metadatas=chunk_metadatas,
237
+ collection=collection_name,
238
+ )
239
+
240
+ # Return the ID of the first chunk
241
+ return {"id": chunk_ids[0], "status": "success", "chunk_count": len(chunks), "service_response": response}
242
+
243
+
244
+ async def delete_document(self, doc_id: str, collection: str) -> None:
245
+ """Delete a document (and all its chunks) from the vector database.
246
+
247
+ Args:
248
+ doc_id: The ID of the document to delete
249
+ collection: The name of the collection containing the document
250
+
251
+ Raises:
252
+ httpx.HTTPStatusError: If the deletion request fails
253
+ """
254
+ # For a complete document deletion, we must delete based on the original_doc_id in metadata
255
+ response = await self._client.request(
256
+ method="DELETE",
257
+ url=f"/collections/{collection}/delete",
258
+ json={
259
+ "ids": [doc_id],
260
+ }
261
+ )
262
+ response.raise_for_status()
263
+ logger.info(f"Deleted document chunks for original ID {doc_id} from {collection}")
264
+ return response.json()
265
+
266
+ # Removed the previous embed_text method as embedding is handled by the service.
267
+
268
+ async def query(
269
+ self,
270
+ query: str,
271
+ collection: str,
272
+ n_results: int = 5,
273
+ where: Optional[Dict[str, Any]] = None
274
+ ) -> List[Dict[str, Any]]:
275
+ """Query the vector database using a text query."""
276
+ if not self.initialized:
277
+ await self.initialize()
278
+
279
+ payload = {
280
+ "query_texts": [query],
281
+ "n_results": n_results
282
+ }
283
+ # Where clause must be sanitized if needed, but we rely on the service to interpret Chroma syntax
284
+ if where:
285
+ payload["where"] = where
286
+
287
+ response = await self._client.post(
288
+ f"/collections/{collection}/query",
289
+ json=payload
290
+ )
291
+ response.raise_for_status()
292
+ return response.json()
293
+
294
+ async def create_collection(self, name: str, **kwargs) -> Dict[str, Any]:
295
+ """Create a new collection."""
296
+ response = await self._client.post(
297
+ f"/collections",
298
+ json={
299
+ "name": name,
300
+ **kwargs
301
+ }
302
+ )
303
+ response.raise_for_status()
304
+ return response.json()
305
+
306
+ async def delete_collection(self, name: str) -> None:
307
+ """Delete a collection."""
308
+ # response = await self._client.delete(f"/collections/{name}/delete")
309
+ # response.raise_for_status()
310
+ logger.info(f"Deleted collection {name}")
311
+
312
+ async def get_raw_embedding(self, document_text: str) -> Optional[List[float]]:
313
+ """
314
+ Retrieves the raw embedding vector for a single document text
315
+ via the Vector DB Service's dedicated generation endpoint.
316
+ """
317
+ endpoint = "/embed/text"
318
+
319
+ # NOTE: The Vector Service endpoint expects a list of texts
320
+ payload = {"texts": [document_text]}
321
+
322
+ try:
323
+ response = await self._client.post(endpoint, json=payload)
324
+ response.raise_for_status()
325
+
326
+ data = response.json()
327
+ embeddings = data.get("embeddings")
328
+
329
+ if embeddings and len(embeddings) > 0:
330
+ # We requested one text, so we return the first (and only) embedding
331
+ return embeddings[0]
332
+
333
+ logger.error("Embedding service returned success but embeddings list was empty.")
334
+ return None
335
+
336
+ except httpx.HTTPStatusError as e:
337
+ logger.error(
338
+ "HTTP error generating raw embedding: Status %d. Detail: %s",
339
+ e.response.status_code, e.response.text
340
+ )
341
+ return None
342
+ except httpx.RequestError as e:
343
+ logger.error("Network error connecting to Vector DB for embedding generation: %s", str(e))
344
+ return None
File without changes
@@ -0,0 +1,84 @@
1
+ {
2
+ "service": {
3
+ "name": "sutram-orchestration-service",
4
+ "id": "sutram-orchestration-service-01",
5
+ "host": "0.0.0.0",
6
+ "port": 7001
7
+ },
8
+ "logging": {
9
+ "level": "INFO",
10
+ "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
11
+ },
12
+ "vector": {
13
+ "default_collection": "test_collection"
14
+ },
15
+ "endpoints": {
16
+ "POSTGRES_URL": "postgresql+asyncpg://user:pass@postgres-host:5432/db_name",
17
+ "CONSUL_HTTP_ADDR": "http://guardian:8500",
18
+ "TOOL_REGISTRY_URL": "http://tools-registry.guardianhub.com",
19
+ "TOOL_EXECUTOR_URL": "http://agentic-orchestrator.guardianhub.com",
20
+ "GRAPH_DB_URL": "http://guardianhub:8009/",
21
+ "VECTOR_SERVICE_URL": "http://vectordb.guardianhub.com",
22
+ "LLM_URL": "http://aura-llm.guardianhub.com",
23
+ "LANGFUSE_HOST": "http://langfuse.guardianhub.com",
24
+ "OTEL_EXPORTER_OTLP_ENDPOINT": "http://guardianhub:4318",
25
+ "LANGFUSE_OTLP_TRACES_ENDPOINT": "http://langfuse.guardianhub.com:4318",
26
+ "KG_INGESTION_URL": "http://graph-db-service:8009/ingest-yaml-schema",
27
+ "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL": "http/protobuf",
28
+ "OTEL_EXPORTER_OTLP_TRACES_PATH": "http/protobuf",
29
+ "LF_EXCLUDED_URL": "http://guardianhub:3000.*,/v1/metrics",
30
+ "LANGFUSE_PROJECT_ID": "cmgy74xzc0006xo07iwmvamue"
31
+ },
32
+ "llm": {
33
+ "model_configs": {
34
+ "judge": {
35
+ "model_name": "gemini-2.0-flash",
36
+ "base_url": "https://generativelanguage.googleapis.com/v1beta/models",
37
+ "api_key": "AIzaSyC7R2Ag1e-6VPf_c15boWPFr99FyvpHcT8",
38
+ "headers": {
39
+ },
40
+ "temperature": 0.1,
41
+ "max_tokens": 2048,
42
+ "model_kwargs": {},
43
+ "provider": "GEMINI",
44
+ "streaming": false
45
+ },
46
+ "tuned": {
47
+ "model_name": "phi3:3.8b-mini-4k-instruct-q4_K_M",
48
+ "provider": "OPENAI",
49
+ "base_url": "http://guardianhub:11434",
50
+ "temperature": 0.1,
51
+ "max_tokens": 2048,
52
+ "streaming": false,
53
+ "model_kwargs": {}
54
+ },
55
+ "coding": {
56
+ "model_name": "deepseek-coder:1.3b",
57
+ "temperature": 0.1,
58
+ "max_tokens": 2048,
59
+ "base_url": "http://guardianhub:11434",
60
+ "api_key": "your-api-key-here",
61
+ "streaming": false,
62
+ "model_kwargs": {}
63
+ },
64
+ "default": {
65
+ "model_name": "phi3:mini",
66
+ "provider": "OLLAMA",
67
+ "base_url": "http://guardianhub:11434",
68
+ "temperature": 0.1,
69
+ "max_tokens": 2048,
70
+ "streaming": false,
71
+ "model_kwargs": {}
72
+ },
73
+ "large": {
74
+ "model_name": "mistral-7b-instruct",
75
+ "temperature": 0.1,
76
+ "max_tokens": 2048,
77
+ "base_url": "http://guardianhub:11434",
78
+ "api_key": "your-api-key-here",
79
+ "streaming": false,
80
+ "model_kwargs": {}
81
+ }
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "service": {
3
+ "name": "guardianhub-sdk",
4
+ "id": "guardianhub-sdk-01",
5
+ "host": "0.0.0.0",
6
+ "port": 8001
7
+ },
8
+ "logging": {
9
+ "level": "INFO",
10
+ "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
11
+ },
12
+ "excluded_urls": "http://langfuse-web.langfuse.svc.cluster.local:3000.*,/v1/metrics",
13
+ "endpoints": {
14
+ "POSTGRES_URL": "postgresql+asyncpg://user:pass@postgres-host:5432/db_name",
15
+ "CONSUL_HTTP_ADDR": "http://consul-server.consul.svc.cluster.local:8500",
16
+ "TOOL_REGISTRY_URL": "http://tool-registry-service.default.svc.cluster.local:8002",
17
+ "TOOL_EXECUTOR_URL": "http://agentic-orchestrator-service.default.svc.cluster.local:8003",
18
+ "LLM_URL": "http://aura-llm-service.default.svc.cluster.local:8000/v1",
19
+ "LANGFUSE_HOST": "http://langfuse-web.langfuse.svc.cluster.local:3000",
20
+ "LANGFUSE_OTLP_TRACES_ENDPOINT": "http://langfuse-web.langfuse.svc.cluster.local:3000",
21
+ "OTEL_EXPORTER_OTLP_ENDPOINT": "http://otel-collector-service.monitoring.svc.cluster.local:4318",
22
+ "KG_INGESTION_URL": "http://graph-db-service:8009/ingest-yaml-schema",
23
+ "ENVIRONMENT": "kubernetes-dev",
24
+ "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL": "http/protobuf",
25
+ "OTEL_EXPORTER_OTLP_TRACES_PATH": "http/protobuf",
26
+ "LF_EXCLUDED_URL": "http://langfuse-web.langfuse.svc.cluster.local:3000.*,/v1/metrics",
27
+ "GRAPH_DB_URL": "http://graph-db-service.default.svc.cluster.local:8009",
28
+ "VECTOR_SERVICE_URL": "http://vector-db-service.default.svc.cluster.local:8005"
29
+ },
30
+ "llm": {
31
+ "model_name": "phi-4",
32
+ "temperature": 0.1,
33
+ "max_tokens": 4096,
34
+ "base_url": "http://aura-llm-service.default.svc.cluster.local:8000/v1",
35
+ "api_key": "your-api-key-here",
36
+ "streaming": false,
37
+ "model_kwargs": {}
38
+ }
39
+ }
@@ -0,0 +1,221 @@
1
+ """Core settings for the GuardianHub Foundation SDK."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import json
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Optional
9
+
10
+ # Correct imports for Pydantic v2 and Pydantic-Settings v2
11
+ from pydantic import BaseModel, Field, ConfigDict, model_validator
12
+ from pydantic_settings import BaseSettings, SettingsConfigDict
13
+
14
+
15
+ def _load_config_defaults(env: str = None) -> dict:
16
+ """Load configuration defaults from JSON files based on environment."""
17
+ if env is None:
18
+ env = os.environ.get("ENVIRONMENT", "development").lower()
19
+
20
+ # Mapping common aliases
21
+ if env == "dev":
22
+ env = "development"
23
+
24
+ config_dir = Path(__file__).parent
25
+ possible_files = [f"config_{env}.json", "config_dev.json", "config_development.json"]
26
+
27
+ for filename in possible_files:
28
+ config_path = config_dir / filename
29
+ if config_path.exists():
30
+ try:
31
+ with open(config_path) as f:
32
+ return json.load(f)
33
+ except Exception:
34
+ continue
35
+ return {}
36
+
37
+ class ServiceInfo(BaseModel):
38
+ """Metadata about the microservice using the SDK."""
39
+ name: str = "guardianhub-service"
40
+ version: str = "0.0.1"
41
+ id: str = "guardian-01"
42
+ host: str = "0.0.0.0"
43
+ port: int = 8001
44
+
45
+
46
+ class LoggingSettings(BaseModel):
47
+ """Standardized logging configuration."""
48
+ level: str = "INFO"
49
+ format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
50
+
51
+
52
+ from typing import Dict, Any, Optional
53
+ from pydantic import BaseModel, Field, ConfigDict
54
+
55
+ class ModelConfig(BaseModel):
56
+ """Standardized schema for any LLM model configuration."""
57
+ model_name: str
58
+ provider: str = "OPENAI" # GEMINI, OLLAMA, OPENAI, etc.
59
+ base_url: str
60
+ api_key: str = "your-api-key-here"
61
+ temperature: float = 0.1
62
+ max_tokens: int = 2048
63
+ streaming: bool = False
64
+ headers: Dict[str, str] = {}
65
+ model_kwargs: Dict[str, Any] = {}
66
+
67
+ class LLMSettings(BaseModel):
68
+ """Registry of models defined in the config JSON."""
69
+ # This matches your "model_configs": { "judge": {...}, "tuned": {...} }
70
+ model_configs: Dict[str, ModelConfig] = Field(default_factory=dict)
71
+
72
+ def get_config(self, key: str = "default") -> ModelConfig:
73
+ """Helper to safely retrieve a model config by its key."""
74
+ return self.model_configs.get(key, self.model_configs.get("default"))
75
+
76
+ class ServiceEndpoints(BaseModel):
77
+ """
78
+ Endpoints for essential shared services.
79
+ Supports dictionary-style access for backward compatibility.
80
+ """
81
+ model_config = ConfigDict(extra="allow")
82
+
83
+ # Infrastructure & Databases
84
+ POSTGRES_URL: str = "postgresql://user:password@localhost:5432/guardianhub"
85
+ CONSUL_HTTP_ADDR: str = "http://localhost:8500"
86
+
87
+ # Shared GuardianHub Services
88
+ TOOL_REGISTRY_URL: str = "http://localhost:8000"
89
+ TOOL_EXECUTOR_URL: str = "http://localhost:8003"
90
+ LLM_URL: str = "http://localhost:8001"
91
+ LANGFUSE_HOST: str = "http://localhost:3000"
92
+ KG_INGESTION_URL: str = "http://localhost:8002"
93
+ GRAPH_DB_URL: str = "http://localhost:8009"
94
+ VECTOR_SERVICE_URL: str = "http://localhost:8005"
95
+
96
+ # Observability
97
+ OTEL_EXPORTER_OTLP_ENDPOINT: str = "http://localhost:4318"
98
+ LANGFUSE_OTLP_TRACES_ENDPOINT: str = "http://localhost:4318"
99
+ ENVIRONMENT: str = "development"
100
+
101
+ def __init__(self, **data):
102
+ # Handle case-insensitive environment variables
103
+ processed_data = {}
104
+ for k, v in data.items():
105
+ # Convert to uppercase for case-insensitive matching
106
+ if k.upper() in self.model_fields:
107
+ processed_data[k.upper()] = v
108
+ else:
109
+ processed_data[k] = v
110
+
111
+ # Initialize the model with known fields
112
+ super().__init__(**{k: v for k, v in processed_data.items()
113
+ if k in self.model_fields})
114
+
115
+ # Store extra fields in __pydantic_extra__
116
+ extra_data = {k: v for k, v in processed_data.items()
117
+ if k not in self.model_fields}
118
+ if extra_data:
119
+ if not hasattr(self, '__pydantic_extra__'):
120
+ object.__setattr__(self, '__pydantic_extra__', {})
121
+ self.__pydantic_extra__.update(extra_data)
122
+
123
+ def get(self, key: str, default: Any = None) -> Any:
124
+ """Dictionary-style get method for backward compatibility."""
125
+ # First try to get from model fields (case-insensitive)
126
+ key_upper = key.upper()
127
+ if key_upper in self.model_fields:
128
+ return getattr(self, key_upper, default)
129
+
130
+ # Then try to get from extra fields (case-sensitive)
131
+ if hasattr(self, '__pydantic_extra__'):
132
+ # Try exact match first
133
+ if key in self.__pydantic_extra__:
134
+ return self.__pydantic_extra__[key]
135
+ # Try case-insensitive match
136
+ for k, v in self.__pydantic_extra__.items():
137
+ if k.upper() == key_upper:
138
+ return v
139
+
140
+ # Finally, try direct attribute access
141
+ return getattr(self, key, default)
142
+
143
+ def __getitem__(self, key: str) -> Any:
144
+ """Enable dictionary-style access."""
145
+ value = self.get(key)
146
+ if value is None and not hasattr(self, key) and not (hasattr(self, '__pydantic_extra__') and key in self.__pydantic_extra__):
147
+ raise KeyError(f"'{key}' not found in ServiceEndpoints")
148
+ return value
149
+
150
+ class VectorConfig(BaseModel):
151
+ """Vector database configuration."""
152
+ default_collection: str = Field("", description="Default collection name")
153
+
154
+
155
+ class CoreSettings(BaseSettings):
156
+ """
157
+ The Core Settings class. Automatically merges bundled JSON
158
+ profiles with environment variables.
159
+ """
160
+ model_config = SettingsConfigDict(
161
+ env_prefix="GH_",
162
+ env_nested_delimiter="__",
163
+ extra="allow"
164
+ )
165
+
166
+ service: ServiceInfo = Field(default_factory=ServiceInfo)
167
+ logging: LoggingSettings = Field(default_factory=LoggingSettings)
168
+ llm: LLMSettings = Field(default_factory=LLMSettings)
169
+ endpoints: ServiceEndpoints = Field(default_factory=ServiceEndpoints)
170
+ vector: VectorConfig = Field(default_factory=VectorConfig)
171
+ excluded_urls: Optional[str] = None
172
+ max_retries: int = 3
173
+ retry_delay: int = 2
174
+ http_timeout: int = 30
175
+
176
+ def __init__(self, **data):
177
+ # Handle environment variable overrides for ENVIRONMENT
178
+ env = os.environ.get("ENVIRONMENT")
179
+ if env:
180
+ if "endpoints" not in data:
181
+ data["endpoints"] = {}
182
+ data["endpoints"]["ENVIRONMENT"] = env
183
+
184
+ # Let Pydantic handle the main initialization
185
+ super().__init__(**data)
186
+
187
+ @model_validator(mode="before")
188
+ @classmethod
189
+ def _bootstrap_config(cls, data: Any) -> Any:
190
+ if isinstance(data, dict):
191
+ # Load the appropriate config file based on environment
192
+ env = data.get("ENVIRONMENT") or os.environ.get("ENVIRONMENT", "development")
193
+ config_data = _load_config_defaults(env)
194
+
195
+ if isinstance(config_data, dict):
196
+ # Create a copy of the input data to avoid modifying it
197
+ result = config_data.copy()
198
+
199
+ # Update with environment variables, but preserve the endpoints
200
+ if "endpoints" in data and isinstance(data["endpoints"], dict):
201
+ # Create a new endpoints dict with the config defaults
202
+ endpoints = result.get("endpoints", {}).copy()
203
+ # Update with any environment overrides
204
+ endpoints.update(data["endpoints"])
205
+ result["endpoints"] = endpoints
206
+
207
+ # Update with any other non-endpoint data
208
+ for key, value in data.items():
209
+ if key != "endpoints":
210
+ result[key] = value
211
+
212
+ return result
213
+ return data
214
+
215
+ @lru_cache()
216
+ def get_cached_settings() -> CoreSettings:
217
+ return CoreSettings()
218
+
219
+
220
+ # Exported singleton
221
+ settings: CoreSettings = get_cached_settings()