using System.Text.Json.Nodes; using Deal.Grpc.Ai; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; namespace Deal.Infrastructure.Integrations.Services; /// /// Собирает конфиг активного ИИ-провайдера для запросов ai-service (Ruling 5: ядро расшифровывает /// aiConfigs и передаёт ProviderConfig в теле каждого запроса; сервис настроек тенанта не знает). /// /// /// Эффективный конфиг 1:1 с python ai.py _cfg() L25–33 и формой ProviderConfig (ai.proto L70–85): /// активный провайдер — настройка aiProvider (дефолт «deepseek»), каталог — /// (fallback на первый — deepseek, как python L28); из переопределения aiConfigs берутся /// apiKey/baseUrl/model, отсутствующие поля дополняются дефолтами каталога (base провайдера, первая модель); /// apiKey расшифровывается (значения enc:+… через ; незашифрованные ранних /// версий — как есть, python crypto.decrypt_text L52–61); api_style провайдера — из каталога (Anthropic — /// «anthropic», остальные — пусто = OpenAI-совместимый). Scoped: читает KV-настройки тенанта (ISettingsStore → /// scoped TenantDbContext запроса), как LocalAiClassifier/GrpcMlClient. /// public sealed class AiProviderConfigBuilder { // Ключ aiConfigs: поле apiKey переопределения провайдера. private const string ApiKeyField = "apiKey"; // Ключ aiConfigs: поле baseUrl переопределения провайдера. private const string BaseUrlField = "baseUrl"; // Ключ aiConfigs: поле model переопределения провайдера. private const string ModelField = "model"; // Префикс зашифрованного значения apiKey (crypto.py L49: enc: + Base64(nonce‖ct‖tag)). private const string EncryptedPrefix = "enc:"; private readonly ISettingsStore _store; private readonly ISecretCipher _secretCipher; /// /// Создаёт сборщик конфига провайдера. /// /// KV-хранилище настроек тенанта (aiProvider/aiConfigs). /// Расшифровка секрета aiConfigs.apiKey (AES-GCM, Ruling 2). public AiProviderConfigBuilder(ISettingsStore store, ISecretCipher secretCipher) { ArgumentNullException.ThrowIfNull(store); ArgumentNullException.ThrowIfNull(secretCipher); _store = store; _secretCipher = secretCipher; } /// /// Собирает ProviderConfig активного провайдера для тела запроса ai-service. /// /// Токен отмены. /// Конфиг: provider_id/base/model/api_key (расшифрованный)/api_style (см. ai.proto). public async Task BuildAsync(CancellationToken ct) { string providerId = await ReadProviderIdAsync(ct); AiProviderDefinition meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId) ?? AiProviders.All[0]; // неизвестный id — дефолтный провайдер (python L28) JsonObject? overrides = await ReadAiConfigsOverrideAsync(ct); JsonObject? raw = overrides?[meta.Id] as JsonObject; string apiKey = ReadField(raw, ApiKeyField); if (apiKey.StartsWith(EncryptedPrefix, StringComparison.Ordinal)) { apiKey = _secretCipher.Decrypt(apiKey); } string baseUrl = ReadField(raw, BaseUrlField); if (baseUrl.Length == 0) { baseUrl = meta.Base; // python L90: cfg.baseUrl or meta.base } string model = ReadField(raw, ModelField); if (model.Length == 0) { model = meta.Models.FirstOrDefault() ?? string.Empty; // python L91: cfg.model or models[0] } var config = new ProviderConfig { ProviderId = meta.Id, BaseUrl = baseUrl, Model = model, }; if (apiKey.Length > 0) { config.ApiKey = apiKey; } if (meta.ApiStyle is { Length: > 0 } apiStyle) { config.ApiStyle = apiStyle; } return config; } // Активный провайдер из настройки aiProvider (дефолт «deepseek», python L26). // ct: Токен отмены. // Возвращает: Id провайдера (каталога AiProviders). private async Task ReadProviderIdAsync(CancellationToken ct) { SettingValue? row = await _store.GetAsync(SettingsKeys.AiProvider, ct); if (row is null) { return SettingsDefaults.AiProvider; } try { JsonNode? value = JsonNode.Parse(row.ValueJson); if (value is JsonValue scalar && scalar.TryGetValue(out string? providerId) && !string.IsNullOrWhiteSpace(providerId)) { return providerId; } } catch (System.Text.Json.JsonException) { // Повреждённая строка — дефолт (мягкая семантика, как в SettingsService). } return SettingsDefaults.AiProvider; } // Переопределение aiConfigs тенанта (JSON-объект «id провайдера → конфиг»); null — дефолты. // ct: Токен отмены. // Возвращает: Объект переопределения или null. private async Task ReadAiConfigsOverrideAsync(CancellationToken ct) { SettingValue? row = await _store.GetAsync(SettingsKeys.AiConfigs, ct); if (row is null) { return null; } try { return JsonNode.Parse(row.ValueJson) as JsonObject; } catch (System.Text.Json.JsonException) { // Повреждённая строка — дефолты (мягкая семантика, как в SettingsService). return null; } } // Строковое поле конфига провайдера (отсутствие/null/не-строка → пустая строка). // config: Объект конфига провайдера (может быть null — дефолты). // field: Имя поля (apiKey/baseUrl/model). // Возвращает: Значение строкой или пустая строка. private static string ReadField(JsonObject? config, string field) { if (config is null || !config.TryGetPropertyValue(field, out JsonNode? node) || node is not JsonValue value) { return string.Empty; } return value.TryGetValue(out string? text) ? text ?? string.Empty : string.Empty; } }