Coverage for src/lexigram/ai/di/provider.py: 97%

167 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 07:19 +0800

1"""Intelligence Provider for Lexigram Framework dependency injection. 

2 

3This module defines the main provider class that integrates Lexigram AI 

4with the Lexigram Framework's dependency injection system. 

5""" 

6 

7from __future__ import annotations 

8 

9from typing import TYPE_CHECKING, Any 

10 

11from lexigram.ai.config import AIConfig 

12from lexigram.contracts import ( 

13 CacheBackendProtocol, 

14 HealthCheckResult, 

15 HealthStatus, 

16 ProviderPriority, 

17) 

18from lexigram.contracts.ai import AIProviderProtocol 

19from lexigram.contracts.core.di import ( 

20 ContainerRegistrarProtocol, 

21 ContainerResolverProtocol, 

22) 

23from lexigram.contracts.data import DatabaseProviderProtocol 

24from lexigram.di.provider import Provider 

25from lexigram.logging import get_logger 

26 

27if TYPE_CHECKING: 

28 from lexigram.ai.llm.config import ClientConfig 

29 from lexigram.ai.observability.metrics import AIMetrics 

30 from lexigram.vector.config import VectorConfig 

31 

32logger = get_logger(__name__) 

33 

34 

35class AIProvider(Provider, AIProviderProtocol): 

36 """Provider for registering Intelligence services with Lexigram DI container. 

37 

38 This provider orchestrates sub-providers (LLMProvider, VectorProvider, 

39 RAGProvider) and is solely responsible for monitoring, 

40 governance, and AIProvider-specific services (RAGCache). 

41 

42 Example: 

43 >>> from lexigram.app import Application 

44 >>> from lexigram.ai import AIModule 

45 >>> 

46 >>> app = Application() 

47 >>> app.add_module(AIModule.configure(...)) 

48 >>> 

49 >>> # LLMClientProtocol is now available for injection 

50 >>> @Controller("/chat") 

51 >>> class ChatController: 

52 ... def __init__(self, llm: LLMClientProtocol): 

53 ... self.llm = llm 

54 """ 

55 

56 name = "ai" 

57 priority = ProviderPriority.DOMAIN 

58 config_key: str | None = "ai" 

59 config_model: type | None = AIConfig 

60 optional_dependencies: tuple[str, ...] = ("db", "cache") 

61 

62 def __init__( 

63 self, 

64 config: AIConfig | None = None, 

65 llm_config: ClientConfig | None = None, 

66 vector_config: VectorConfig | None = None, 

67 name: str = "ai", 

68 ) -> None: 

69 """Initialize the Intelligence Provider. 

70 

71 Args: 

72 config: Initial AI configuration (optional; can be set by orchestrator) 

73 llm_config: LLM-specific configuration (overrides config.llm) 

74 vector_config: Vector-specific configuration (overrides config.vector) 

75 name: Provider name 

76 """ 

77 super().__init__(name=name) 

78 self._config_override = config 

79 

80 # Configuration overrides 

81 self._llm_config_override = llm_config 

82 self._vector_config_override = vector_config 

83 

84 # Sub-provider references — populated in register() 

85 self._llm_sub: Any | None = None # LLMProvider 

86 self._vector_sub: Any | None = None # VectorProvider 

87 self._rag_sub: Any | None = None # RAGProvider 

88 

89 # AIProvider-specific instances — populated in boot() 

90 self._rag_cache: Any | None = None 

91 self._metrics: AIMetrics | None = None 

92 self._governance: Any | None = None 

93 

94 # Resolved optional dependencies 

95 self._database_provider: DatabaseProviderProtocol | None = None 

96 self._cache_backend: CacheBackendProtocol | None = None 

97 

98 @property 

99 def intelligence_config(self) -> AIConfig: 

100 """Get the current AI configuration (from override or container-provided config).""" 

101 cfg = self._config_override or self.config or AIConfig() 

102 # Apply config overrides 

103 if self._llm_config_override: 

104 cfg.llm = self._llm_config_override 

105 if self._vector_config_override: 

106 cfg.vector = self._vector_config_override 

107 return cfg 

108 

109 @property 

110 def database_provider(self) -> DatabaseProviderProtocol | None: 

111 """Get the resolved database provider (set during boot).""" 

112 return self._database_provider 

113 

114 @property 

115 def cache_backend(self) -> CacheBackendProtocol | None: 

116 """Get the resolved cache backend (set during boot).""" 

117 return self._cache_backend 

118 

119 async def register(self, container: ContainerRegistrarProtocol) -> None: 

120 """Register services with the DI container. 

121 

122 Registers monitoring and config singletons directly; governance is 

123 registered by the GovernanceProvider discovered via the 

124 "lexigram.ai.subsystems" entry point. Delegates LLM, Vector, and 

125 RAG service registration to the respective sub-providers. 

126 

127 Args: 

128 container: The Lexigram DI container 

129 """ 

130 logger.info("Registering Lexigram AI services with container") 

131 

132 # Resolve AIConfig from container (set by orchestrator) or use default 

133 intelligence_config = self.config or AIConfig() 

134 

135 # Apply config overrides if provided 

136 if self._llm_config_override: 

137 intelligence_config.llm = self._llm_config_override 

138 if self._vector_config_override: 

139 intelligence_config.vector = self._vector_config_override 

140 

141 # Register config first so sub-services can inject it 

142 container.singleton(AIConfig, lambda: intelligence_config) 

143 

144 # Monitoring — always registered; AIProvider is the observability orchestrator 

145 from lexigram.ai.observability.callbacks.manager import CallbackManagerImpl 

146 from lexigram.ai.observability.health import AIHealthMonitor 

147 from lexigram.ai.observability.metrics import AIMetrics 

148 from lexigram.ai.observability.tracing import AITracer 

149 from lexigram.contracts.ai.callbacks import CallbackManagerProtocol 

150 

151 container.singleton(AIHealthMonitor) 

152 container.singleton(AIMetrics) 

153 container.singleton("ai_metrics", AIMetrics) 

154 container.singleton(AITracer) 

155 container.singleton("ai_tracer", AITracer) 

156 container.singleton(CallbackManagerProtocol, CallbackManagerImpl) 

157 container.singleton("callback_manager", CallbackManagerImpl) 

158 

159 # LLM — delegate to LLMProvider 

160 if intelligence_config.llm: 

161 from lexigram.ai.llm.di.provider import LLMProvider 

162 

163 self._llm_sub = LLMProvider( 

164 intelligence_config.llm, 

165 cache_backend=self._cache_backend, 

166 ) 

167 await self._llm_sub.register(container) 

168 

169 # Vector — delegate to VectorProvider 

170 if intelligence_config.vector: 

171 from lexigram.vector.di.provider import VectorProvider 

172 

173 self._vector_sub = VectorProvider(intelligence_config.vector) 

174 await self._vector_sub.register(container) 

175 

176 # RAG — delegate to RAGProvider 

177 if intelligence_config.rag: 

178 from lexigram.ai.rag.di.provider import RAGProvider 

179 

180 self._rag_sub = RAGProvider(intelligence_config.rag) 

181 await self._rag_sub.register(container) 

182 

183 # RAG cache — AIProvider-specific (wraps injected CacheBackendProtocol) 

184 if self._cache_backend: 

185 from lexigram.ai.rag.cache.manager import RAGCache 

186 

187 container.singleton(RAGCache, lambda: self._rag_cache) 

188 container.singleton("rag_cache", lambda: self._rag_cache) 

189 

190 # ------------------------------------------------------------------ 

191 # Entry-point discovery for additional AI sub-packages. 

192 # Packages that declare the "lexigram.ai.subsystems" entry-point 

193 # group are loaded here without any hardcoded imports. 

194 # The three core subsystems (llm, vector, rag) are intentionally 

195 # skipped as they are wired above with explicit config injection. 

196 # ------------------------------------------------------------------ 

197 _SUBSYSTEM_CONFIGS: dict[str, Any] = { 

198 "llm": intelligence_config.llm, 

199 "vector": intelligence_config.vector, 

200 "rag": intelligence_config.rag, 

201 "governance": intelligence_config.governance, 

202 "observability": intelligence_config.observability, 

203 } 

204 try: 

205 from importlib.metadata import entry_points as _entry_points 

206 

207 for _ep in _entry_points(group="lexigram.ai.subsystems"): 

208 config_arg = _SUBSYSTEM_CONFIGS.get(_ep.name) 

209 _provider_cls = _ep.load() 

210 _sub_provider = ( 

211 _provider_cls(config=config_arg) 

212 if config_arg is not None 

213 else _provider_cls() 

214 ) 

215 await _sub_provider.register(container) 

216 logger.info( 

217 "Registered AI subsystem via entry-point", 

218 subsystem=_ep.name, 

219 provider=_ep.value, 

220 ) 

221 except ImportError: 

222 logger.debug( 

223 "importlib.metadata unavailable; skipping AI subsystem discovery" 

224 ) 

225 

226 logger.info("Lexigram AI services registered successfully") 

227 

228 async def chat( 

229 self, 

230 messages: list[Any], 

231 tools: list[Any] | None = None, 

232 **kwargs: Any, 

233 ) -> Any: 

234 """Chat with optional tool calling. Delegates to LLM sub-provider's client.""" 

235 if not self._llm_sub or not self._llm_sub._llm_client: 

236 raise RuntimeError("LLM client not configured. Cannot perform chat.") 

237 return await self._llm_sub._llm_client.complete(messages, tools=tools, **kwargs) 

238 

239 async def boot(self, container: ContainerResolverProtocol) -> None: 

240 """Start the intelligence provider. 

241 

242 Performs async I/O only for AIProvider-specific services: RAGCache. 

243 Sub-providers handle their own async initialization during register(). 

244 

245 Args: 

246 container: The DI container 

247 """ 

248 logger.info("Starting Lexigram AI provider") 

249 

250 # Resolve optional dependencies from container 

251 try: 

252 self._database_provider = await container.resolve(DatabaseProviderProtocol) 

253 except (ValueError, KeyError, TypeError): 

254 logger.debug( 

255 "DatabaseProviderProtocol not available; governance persistence disabled" 

256 ) 

257 

258 try: 

259 self._cache_backend = await container.resolve(CacheBackendProtocol) 

260 except (ValueError, KeyError, TypeError): 

261 logger.debug( 

262 "CacheBackendProtocol not available; RAG cache and governance caching disabled" 

263 ) 

264 

265 # RAG cache (wraps platform CacheBackendProtocol) 

266 if self._cache_backend: 

267 from lexigram.ai.rag.cache.manager import RAGCache 

268 

269 self._rag_cache = RAGCache(backend=self._cache_backend) 

270 logger.info("Initialized RAGCache") 

271 

272 logger.info("Lexigram AI provider started successfully") 

273 

274 async def shutdown(self) -> None: 

275 """Clean up resources on application shutdown.""" 

276 logger.info("Shutting down Lexigram AI provider") 

277 

278 # Shutdown sub-providers 

279 for sub in (self._llm_sub, self._vector_sub, self._rag_sub): 

280 if sub is not None: 

281 try: 

282 await sub.shutdown() 

283 except (ConnectionError, TimeoutError, OSError, RuntimeError) as exc: 

284 logger.warning("Error shutting down sub-provider: %s", exc) 

285 

286 # Clear all references 

287 self._llm_sub = None 

288 self._vector_sub = None 

289 self._rag_sub = None 

290 self._rag_cache = None 

291 

292 logger.info("Lexigram AI provider shutdown complete") 

293 

294 async def health_check(self, timeout: float = 5.0) -> HealthCheckResult: 

295 """Check provider health by aggregating sub-provider and local service checks. 

296 

297 Returns: 

298 Structured HealthCheckResult with component health information 

299 """ 

300 import time 

301 

302 start_time = time.perf_counter() 

303 details: dict[str, Any] = {"components": {}} 

304 overall_status = HealthStatus.HEALTHY 

305 errors = [] 

306 

307 # LLM health — delegate to sub-provider 

308 if self._llm_sub is not None: 

309 cl_start = time.perf_counter() 

310 try: 

311 cl_health = await self._llm_sub.health_check(timeout=timeout) 

312 if isinstance(cl_health, dict): 

313 details["components"]["llm"] = cl_health 

314 if cl_health.get("status") not in ("healthy", None): 

315 overall_status = HealthStatus.DEGRADED 

316 elif hasattr(cl_health, "model_dump"): 

317 details["components"]["llm"] = cl_health.model_dump() 

318 if ( 

319 hasattr(cl_health, "status") 

320 and cl_health.status != HealthStatus.HEALTHY 

321 ): 

322 overall_status = HealthStatus.DEGRADED 

323 else: 

324 details["components"]["llm"] = {"status": "healthy"} 

325 except (ConnectionError, TimeoutError, RuntimeError) as e: 

326 logger.exception("LLM health check failed") 

327 details["components"]["llm"] = {"status": "unhealthy", "error": str(e)} 

328 overall_status = HealthStatus.DEGRADED 

329 errors.append(f"LLM: {e}") 

330 details["components"]["llm"]["latency_ms"] = ( 

331 time.perf_counter() - cl_start 

332 ) * 1000 

333 

334 # Vector health — delegate to sub-provider 

335 if self._vector_sub is not None: 

336 v_start = time.perf_counter() 

337 try: 

338 v_health = await self._vector_sub.health_check(timeout=timeout) 

339 if isinstance(v_health, HealthCheckResult): 

340 details["components"]["vector"] = ( 

341 v_health.model_dump() 

342 if hasattr(v_health, "model_dump") 

343 else vars(v_health).copy() 

344 ) 

345 if v_health.status != HealthStatus.HEALTHY: 

346 overall_status = HealthStatus.DEGRADED 

347 elif isinstance(v_health, dict): 

348 details["components"]["vector"] = v_health 

349 else: 

350 details["components"]["vector"] = {"status": "healthy"} 

351 except (ConnectionError, TimeoutError, RuntimeError) as e: 

352 logger.exception("Vector store health check failed") 

353 details["components"]["vector"] = { 

354 "status": "unhealthy", 

355 "error": str(e), 

356 } 

357 overall_status = HealthStatus.DEGRADED 

358 errors.append(f"Vector: {e}") 

359 details["components"]["vector"]["latency_ms"] = ( 

360 time.perf_counter() - v_start 

361 ) * 1000 

362 

363 return HealthCheckResult( 

364 component="ai", 

365 status=overall_status, 

366 details=details, 

367 error=" | ".join(errors) if errors else None, 

368 duration_ms=(time.perf_counter() - start_time) * 1000, 

369 ) 

370 

371 

372__all__ = ["AIProvider"]