using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Models; using Deal.Grpc.Ai; using Deal.Infrastructure.Integrations.Exceptions; using Deal.Infrastructure.Integrations.Models; using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Models; using Grpc.Core; using Microsoft.Extensions.Logging; namespace Deal.Infrastructure.Integrations.Services; /// /// gRPC-адаптер порта к автономному ai-service. /// public sealed class GrpcAiTools : IAiTools { /// /// Deadline RPC ai-service — 120 с /// public const int RpcDeadlineSeconds = 120; /// /// Лимит описания ниши generate-keywords. /// public const int MaxDescriptionCodePoints = 4000; /// /// Лимит текста сообщения evaluate-fit. /// public const int MaxEvalTextCodePoints = 4000; // Текст фолбэк-ошибки, когда RPC-ошибка не несёт detail (сервис недоступен). private const string ServiceUnavailableText = "ai-service недоступен — повторите попытку через несколько секунд"; private const string FitReasonDefault = "подходит"; private const string NotFitReasonDefault = "не подходит"; private readonly ITenantContext _tenantContext; private readonly AiGrpcConnection _connection; private readonly AiProviderConfigBuilder _providerConfigBuilder; private readonly TokenUsageRecorder _usageRecorder; private readonly ILogger _logger; /// /// Создаёт gRPC-адаптер ИИ-инструментов ai-service. /// /// Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами). /// Транспорт ai-service (singleton-канал + service-token). /// Конфиг активного провайдера из настроек тенанта. /// Списание usage ответов: бюджет периода tenant_limits + lifetime-KV aiTokenUsage. /// Логгер сбоев. public GrpcAiTools( ITenantContext tenantContext, AiGrpcConnection connection, AiProviderConfigBuilder providerConfigBuilder, TokenUsageRecorder usageRecorder, ILogger logger) { ArgumentNullException.ThrowIfNull(tenantContext); ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(providerConfigBuilder); ArgumentNullException.ThrowIfNull(usageRecorder); ArgumentNullException.ThrowIfNull(logger); _tenantContext = tenantContext; _connection = connection; _providerConfigBuilder = providerConfigBuilder; _usageRecorder = usageRecorder; _logger = logger; } /// public async Task GenerateKeywordsAsync(string description, CancellationToken ct) { TenantId tenantId = RequireTenant(); try { AiService.AiServiceClient client = _connection.CreateClient(); ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct); GenerateKeywordsReply reply = await client.GenerateKeywordsAsync( new GenerateKeywordsRequest { Description = SliceCodePoints(description ?? string.Empty, MaxDescriptionCodePoints), ProviderConfig = providerConfig, }, CallOptions(tenantId.Value, ct)); await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct); return new AiGenerateKeywordsResultDto( Ok: true, Keywords: reply.Keywords.ToList(), Error: null); } catch (RpcException exception) { _logger.LogDebug(exception, "generate-keywords недоступен (тенант {TenantId})", tenantId.Value); return new AiGenerateKeywordsResultDto(Ok: false, Keywords: Array.Empty(), Error: ErrorText(exception)); } catch (HttpRequestException exception) { _logger.LogDebug(exception, "generate-keywords недоступен (тенант {TenantId})", tenantId.Value); return new AiGenerateKeywordsResultDto( Ok: false, Keywords: Array.Empty(), Error: ServiceUnavailableText); } } /// public async Task EvaluateFitAsync( string text, string description, IReadOnlyCollection keywords, CancellationToken ct) { TenantId tenantId = RequireTenant(); try { AiService.AiServiceClient client = _connection.CreateClient(); ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct); var request = new EvaluateFitRequest { Text = SliceCodePoints(text ?? string.Empty, MaxEvalTextCodePoints), Description = description ?? string.Empty, ProviderConfig = providerConfig, }; foreach (string keyword in keywords ?? Array.Empty()) { if (!string.IsNullOrEmpty(keyword)) { request.Keywords.Add(keyword); } } EvaluateFitReply reply = await client.EvaluateFitAsync(request, CallOptions(tenantId.Value, ct)); await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct); return new AiEvaluateFitResultDto( Fit: reply.Fit, Reason: reply.HasReason && reply.Reason.Length > 0 ? reply.Reason : (reply.Fit ? FitReasonDefault : NotFitReasonDefault)); } catch (RpcException exception) { _logger.LogDebug(exception, "evaluate-fit недоступен (тенант {TenantId})", tenantId.Value); throw new AiUnavailableException(ErrorText(exception)); } catch (HttpRequestException exception) { _logger.LogDebug(exception, "evaluate-fit недоступен (тенант {TenantId})", tenantId.Value); throw new AiUnavailableException(ServiceUnavailableText); } } // Текущий тенант scope (без него scoped-хранилища/metadata не имеют смысла). // Возвращает: Идентификатор тенанта. // Исключение InvalidOperationException: Вызов вне tenant-контекста (конфигурация). private TenantId RequireTenant() => _tenantContext.TenantId ?? throw new InvalidOperationException( "GrpcAiTools запрошен вне tenant-контекста (ITenantContext.TenantId == null)."); // tenantId: Id тенанта (формат N). // ct: Токен отмены вызова. // Возвращает: Опции вызова с заголовками, deadline и отменой. private CallOptions CallOptions(string tenantId, CancellationToken ct) => new( headers: _connection.CreateMetadata(tenantId), deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)), cancellationToken: ct); // секреты/тела ответов не логируются и в текст не попадают). // exception: Исключение RPC-вызова. // Возвращает: Текст ошибки. private static string ErrorText(RpcException exception) { string detail = exception.Status.Detail?.Trim() ?? string.Empty; return detail.Length > 0 ? detail : ServiceUnavailableText; } // text: Строка. // max: Лимит. // Возвращает: Усечённая строка. private static string SliceCodePoints(string text, int max) { if (text.Length <= max) { return text; } var builder = new System.Text.StringBuilder(max); int taken = 0; for (int index = 0; index < text.Length && taken < max; index++) { bool pair = char.IsHighSurrogate(text[index]) && index + 1 < text.Length && char.IsLowSurrogate(text[index + 1]); builder.Append(text[index]); if (pair) { index++; builder.Append(text[index]); } taken++; } return builder.ToString(); } }