Files
Deal/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiTools.cs
T
Rustam Khalimov b053d58335 Почистить комментарии от упоминаний процесса
Удалены <remarks>, <summary> сжаты до короткой фразы, вырезаны
ссылки на Task/Ruling/этап/python/прототип; //-комментарии со ссылками
на процесс удалены; то же в .proto. Правила обновлены в
docs/spec/Код-стайл-Дейл.md. Строк комментариев 27210 -> ~19100.
2026-09-11 13:39:39 +03:00

209 lines
9.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// gRPC-адаптер порта <see cref="IAiTools"/> к автономному ai-service.
/// </summary>
public sealed class GrpcAiTools : IAiTools
{
/// <summary>
/// Deadline RPC ai-service — 120 с
/// </summary>
public const int RpcDeadlineSeconds = 120;
/// <summary>
/// Лимит описания ниши generate-keywords.
/// </summary>
public const int MaxDescriptionCodePoints = 4000;
/// <summary>
/// Лимит текста сообщения evaluate-fit.
/// </summary>
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<GrpcAiTools> _logger;
/// <summary>
/// Создаёт gRPC-адаптер ИИ-инструментов ai-service.
/// </summary>
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами).</param>
/// <param name="connection">Транспорт ai-service (singleton-канал + service-token).</param>
/// <param name="providerConfigBuilder">Конфиг активного провайдера из настроек тенанта.</param>
/// <param name="usageRecorder">Списание usage ответов: бюджет периода tenant_limits + lifetime-KV aiTokenUsage.</param>
/// <param name="logger">Логгер сбоев.</param>
public GrpcAiTools(
ITenantContext tenantContext,
AiGrpcConnection connection,
AiProviderConfigBuilder providerConfigBuilder,
TokenUsageRecorder usageRecorder,
ILogger<GrpcAiTools> 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;
}
/// <inheritdoc />
public async Task<AiGenerateKeywordsResultDto> 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<string>(), Error: ErrorText(exception));
}
catch (HttpRequestException exception)
{
_logger.LogDebug(exception, "generate-keywords недоступен (тенант {TenantId})", tenantId.Value);
return new AiGenerateKeywordsResultDto(
Ok: false, Keywords: Array.Empty<string>(), Error: ServiceUnavailableText);
}
}
/// <inheritdoc />
public async Task<AiEvaluateFitResultDto> EvaluateFitAsync(
string text,
string description,
IReadOnlyCollection<string> 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<string>())
{
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();
}
}