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;
}
}
}