Прогон dotnet format (IDE0005) по 4 решениям: удалены лишние using, оставшиеся после миграции namespace (676 файлов).
167 lines
7.6 KiB
C#
167 lines
7.6 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// Собирает конфиг активного ИИ-провайдера для запросов ai-service (Ruling 5: ядро расшифровывает
|
||
/// aiConfigs и передаёт ProviderConfig в теле каждого запроса; сервис настроек тенанта не знает).
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Эффективный конфиг 1:1 с python <c>ai.py _cfg()</c> L25–33 и формой ProviderConfig (ai.proto L70–85):
|
||
/// активный провайдер — настройка <c>aiProvider</c> (дефолт «deepseek»), каталог — <see cref="AiProviders"/>
|
||
/// (fallback на первый — deepseek, как python L28); из переопределения <c>aiConfigs</c> берутся
|
||
/// apiKey/baseUrl/model, отсутствующие поля дополняются дефолтами каталога (base провайдера, первая модель);
|
||
/// apiKey расшифровывается (значения <c>enc:</c>+… через <see cref="ISecretCipher"/>; незашифрованные ранних
|
||
/// версий — как есть, python crypto.decrypt_text L52–61); api_style провайдера — из каталога (Anthropic —
|
||
/// «anthropic», остальные — пусто = OpenAI-совместимый). Scoped: читает KV-настройки тенанта (ISettingsStore →
|
||
/// scoped TenantDbContext запроса), как LocalAiClassifier/GrpcMlClient.
|
||
/// </remarks>
|
||
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;
|
||
|
||
/// <summary>
|
||
/// Создаёт сборщик конфига провайдера.
|
||
/// </summary>
|
||
/// <param name="store">KV-хранилище настроек тенанта (aiProvider/aiConfigs).</param>
|
||
/// <param name="secretCipher">Расшифровка секрета aiConfigs.apiKey (AES-GCM, Ruling 2).</param>
|
||
public AiProviderConfigBuilder(ISettingsStore store, ISecretCipher secretCipher)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(store);
|
||
ArgumentNullException.ThrowIfNull(secretCipher);
|
||
_store = store;
|
||
_secretCipher = secretCipher;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Собирает ProviderConfig активного провайдера для тела запроса ai-service.
|
||
/// </summary>
|
||
/// <param name="ct">Токен отмены.</param>
|
||
/// <returns>Конфиг: provider_id/base/model/api_key (расшифрованный)/api_style (см. ai.proto).</returns>
|
||
public async Task<ProviderConfig> 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<string> 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<string>(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<JsonObject?> 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<string>(out string? text) ? text ?? string.Empty : string.Empty;
|
||
}
|
||
}
|