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