Удалены <remarks>, <summary> сжаты до короткой фразы, вырезаны ссылки на Task/Ruling/этап/python/прототип; //-комментарии со ссылками на процесс удалены; то же в .proto. Правила обновлены в docs/spec/Код-стайл-Дейл.md. Строк комментариев 27210 -> ~19100.
209 lines
9.1 KiB
C#
209 lines
9.1 KiB
C#
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();
|
||
}
|
||
}
|