using EAIAdminDAL.DTO; using GB5Shared.DTO.Framework.Login; using GB5Shared.ExternalAI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System; using System.Net.Http; using System.Text.Json; using System.Threading; using System.Threading.Tasks; namespace EAIAdminBLL.Sync { /// /// EAIAdmin's concrete IEAISyncClient — resolves its own AIEngineOptions from the /// "EAIAdmin:AIExtract" config section directly (not via generic IOptions<AIEngineOptions> /// DI, which would collide with KMS's own "KMS:AIExtract" binding of the same options TYPE /// inside one PlatformHost process — see GB5Shared.ExternalAI.AIEngineOptions's own doc /// comment). Built on top of the shared, assembly-scan-registered IAIEngineSyncClient /// transport. /// public class EAISyncClient : IEAISyncClient { private const string SectionName = "EAIAdmin:AIExtract"; private readonly IAIEngineSyncClient _transport; private readonly AIEngineOptions _options; private readonly ILogger _logger; public EAISyncClient( IAIEngineSyncClient transport, IConfiguration configuration, ILogger logger) { _transport = transport ?? throw new ArgumentNullException(nameof(transport)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _options = configuration.GetSection(SectionName).Get() ?? new AIEngineOptions(); if (string.IsNullOrWhiteSpace(_options.SigningKeyVaultPath)) _options.SigningKeyVaultPath = "eaiadmin/ai-enterprise-jwt-secret"; if (string.IsNullOrWhiteSpace(_options.Issuer)) _options.Issuer = "GB5-EAIAdmin"; } // ── Initiative ────────────────────────────────────────────────────────────── public Task PushInitiativeAsync( EAIInitiativeAdminDTO dto, LoginDTO login, CancellationToken ct = default) => PushAsync("/api/admin/initiative", "InitiativeCode", dto.InitiativeCode, new { initiativeCode = dto.InitiativeCode, initiativeName = dto.InitiativeName, maturityLevel = dto.MaturityLevel }, "aiInitiativeId", login, ct); public Task DeleteInitiativeAsync( EAIInitiativeAdminDTO dto, LoginDTO login, CancellationToken ct = default) => DeleteAsync("/api/admin/initiative", dto.EAIRemoteId, dto.InitiativeCode, login, ct); // ── Capability ────────────────────────────────────────────────────────────── public Task PushCapabilityAsync( EAICapabilityAdminDTO dto, LoginDTO login, CancellationToken ct = default) => PushAsync("/api/admin/capability", "CapabilityCode", dto.CapabilityCode, new { capabilityCode = dto.CapabilityCode, capabilityName = dto.CapabilityName, maxMaturityLevel = dto.MaxMaturityLevel, keywords = dto.Keywords }, "aiCapabilityId", login, ct); public Task DeleteCapabilityAsync( EAICapabilityAdminDTO dto, LoginDTO login, CancellationToken ct = default) => DeleteAsync("/api/admin/capability", dto.EAIRemoteId, dto.CapabilityCode, login, ct); // ── Bot ───────────────────────────────────────────────────────────────────── public Task PushBotAsync( EAIBotAdminDTO dto, LoginDTO login, CancellationToken ct = default) => PushAsync("/api/admin/bot", "BotCode", dto.BotCode, new { botCode = dto.BotCode, name = dto.Name, initiativeCode = dto.InitiativeCode, description = dto.Description, isReadOnly = dto.IsReadOnly }, "aiBotId", login, ct); public Task DeleteBotAsync( EAIBotAdminDTO dto, LoginDTO login, CancellationToken ct = default) => DeleteAsync("/api/admin/bot", dto.EAIRemoteId, dto.BotCode, login, ct); // ── PromptTemplate ────────────────────────────────────────────────────────── public async Task PushPromptTemplateAsync( EAIPromptTemplateAdminDTO dto, LoginDTO login, CancellationToken ct = default) { var body = new { tenantId = login.ClientId, initiativeCode = dto.InitiativeCode, capabilityCode = dto.CapabilityCode, promptTemplate = dto.PromptTemplate, variantCode = dto.VariantCode }; // Extends the existing PUT /api/admin/prompt (gbEAI Part 1 change: becomes a proper // upsert, gains a GET/DELETE sibling) rather than a new admin_* router — this is the // one entity gbEAI already had a partial admin route for. var response = await _transport.SendJsonAsync( _options, HttpMethod.Put, "/api/admin/prompt", body, login, ct).ConfigureAwait(false); if (!response.IsSuccess) { _logger.LogWarning( "EAISyncClient | PushPromptTemplate | Failed | {Initiative}/{Capability} | {Error}", dto.InitiativeCode, dto.CapabilityCode, response.ErrorMessage); return new EAISyncResult { Success = false, Message = response.ErrorMessage }; } return new EAISyncResult { Success = true, EAIRemoteId = TryGetLong(response.Body, "aiPromptId"), PromptVersion = (int?)TryGetLong(response.Body, "version") }; } public Task DeletePromptTemplateAsync( EAIPromptTemplateAdminDTO dto, LoginDTO login, CancellationToken ct = default) => DeleteAsync("/api/admin/prompt", dto.EAIRemoteId, $"{dto.InitiativeCode}/{dto.CapabilityCode}", login, ct); // ── ModelConfig ───────────────────────────────────────────────────────────── public async Task PushModelConfigAsync( EAIModelConfigAdminDTO dto, LoginDTO login, CancellationToken ct = default) { // Deliberately never sends a raw key — gbEAI has no Vault client of its own; the // secret stays GB5-side only, resolved at outbound-call time by whichever module // ends up calling the model (out of scope for this admin-config push). var body = new { tenantId = login.ClientId, modelCode = dto.ModelCode, modelName = dto.ModelName, provider = dto.Provider, endpoint = dto.Endpoint }; var response = await _transport.SendJsonAsync( _options, HttpMethod.Put, "/api/admin/model", body, login, ct).ConfigureAwait(false); if (!response.IsSuccess) { _logger.LogWarning( "EAISyncClient | PushModelConfig | Failed | ModelCode={Code} | {Error}", dto.ModelCode, response.ErrorMessage); return new EAISyncResult { Success = false, Message = response.ErrorMessage }; } return new EAISyncResult { Success = true, EAIRemoteId = TryGetLong(response.Body, "aiModelId") }; } public Task DeleteModelConfigAsync( EAIModelConfigAdminDTO dto, LoginDTO login, CancellationToken ct = default) => DeleteAsync("/api/admin/model", dto.EAIRemoteId, dto.ModelCode, login, ct); // ── AgentRoute ────────────────────────────────────────────────────────────── public Task PushAgentRouteAsync( EAIAgentRouteAdminDTO dto, LoginDTO login, CancellationToken ct = default) => PushAsync("/api/admin/agentroute", "Slug", dto.Slug, new { slug = dto.Slug, initiativeCode = dto.InitiativeCode, defaultCapability = dto.DefaultCapability, module = dto.Module, summary = dto.Summary, capabilities = dto.Capabilities, aliases = dto.Aliases, acceptsFile = dto.AcceptsFile, acceptsText = dto.AcceptsText, requiresFile = dto.RequiresFile }, "agentRouteId", login, ct); public Task DeleteAgentRouteAsync( EAIAgentRouteAdminDTO dto, LoginDTO login, CancellationToken ct = default) => DeleteAsync("/api/admin/agentroute", dto.EAIRemoteId, dto.Slug, login, ct); // ── Shared plumbing ───────────────────────────────────────────────────────── private async Task PushAsync( string path, string codeFieldName, string codeValue, object body, string remoteIdJsonProperty, LoginDTO login, CancellationToken ct) { var response = await _transport.SendJsonAsync( _options, HttpMethod.Post, path, body, login, ct).ConfigureAwait(false); if (!response.IsSuccess) { _logger.LogWarning( "EAISyncClient | Push | Failed | Path={Path} | {CodeField}={CodeValue} | {Error}", path, codeFieldName, codeValue, response.ErrorMessage); return new EAISyncResult { Success = false, Message = response.ErrorMessage }; } return new EAISyncResult { Success = true, EAIRemoteId = TryGetLong(response.Body, remoteIdJsonProperty) }; } private async Task DeleteAsync( string path, long? remoteId, string identifierForLog, LoginDTO login, CancellationToken ct) { if (remoteId is not { } id) return EAISyncResult.Skipped("Never synced to AI-Enterprise-v1.0 — nothing to delete remotely."); var response = await _transport.SendJsonAsync( _options, HttpMethod.Delete, $"{path}/{id}", null, login, ct).ConfigureAwait(false); if (!response.IsSuccess) { _logger.LogWarning( "EAISyncClient | Delete | Failed | Path={Path} | {Identifier} | {Error}", path, identifierForLog, response.ErrorMessage); return new EAISyncResult { Success = false, Message = response.ErrorMessage }; } return new EAISyncResult { Success = true }; } private static long? TryGetLong(string json, string propertyName) { if (string.IsNullOrWhiteSpace(json)) return null; try { using var doc = JsonDocument.Parse(json); if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty(propertyName, out var el) && el.ValueKind == JsonValueKind.Number) { return el.GetInt64(); } } catch (JsonException) { // Not the expected shape — leave the field unset rather than throw; the local // save already succeeded and remains authoritative. } return null; } } }