Убрать неиспользуемые using по код-стайлу
Прогон dotnet format (IDE0005) по 4 решениям: удалены лишние using, оставшиеся после миграции namespace (676 файлов).
This commit is contained in:
@@ -1,235 +1,232 @@
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Grpc.Ai;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Deal.Infrastructure.Integrations.Abstractions;
|
||||
using Deal.Infrastructure.Integrations.Exceptions;
|
||||
using Deal.Infrastructure.Integrations.Extensions;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Deal.Infrastructure.Integrations.Options;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-адаптер порта <see cref="IAiTools"/> к автономному ai-service (Ruling 9, план Task 15/18/19).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется вместо Local-реализации при <c>Services:Ai:UseLocal=false</c> (Ruling 6). Поведение 1:1 с
|
||||
/// ai.proto GenerateKeywords/EvaluateFit и прототипом:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="GenerateKeywordsAsync"/> — RPC GenerateKeywords (deadline 120 с): конфиг провайдера из
|
||||
/// настроек, описание ≤4000 (discovery_routes L29); недоступность — мягкий {ok:false, keywords:[], error}
|
||||
/// (Ruling 11: generate-keywords-эндпоинт отдаёт HTTP 200 {keywords: [], error});</item>
|
||||
/// <item><see cref="EvaluateFitAsync"/> — RPC EvaluateFit: текст ≤4000 (discovery_eval L41) + описание и ключи
|
||||
/// задачи; сбой — <see cref="AiUnavailableException"/> (воркер Discovery падает в эвристику, Ruling 10);
|
||||
/// успех — {fit, reason} (потолок причины 200 задаёт сервис, _AI_REASON_LIMIT L43);</item>
|
||||
/// <item>usage ответов списывается с бюджета тенанта в tenant_limits и копится в lifetime-KV aiTokenUsage
|
||||
/// (<see cref="TokenUsageRecorder"/>, Ruling 3 этапа 7), как у классификатора.</item>
|
||||
/// </list>
|
||||
/// Каждый вызов несёт metadata tenant-id + service-token (<see cref="AiGrpcConnection"/>, Ruling 1). Scoped:
|
||||
/// настройки провайдера читаются через scoped-хранилище тенанта (ISettingsStore), как GrpcAiClassifier.
|
||||
/// Выключатель aiEnabled порт не читает — его отрабатывает вызывающий (воркер/эндпоинт Discovery, Ruling 10/11).
|
||||
/// </remarks>
|
||||
public sealed class GrpcAiTools : IAiTools
|
||||
{
|
||||
/// <summary>
|
||||
/// Deadline RPC ai-service — 120 с (README контрактов: провайдер 90/60 с + ретраи 0.8/2 с).
|
||||
/// </summary>
|
||||
public const int RpcDeadlineSeconds = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит описания ниши generate-keywords (discovery_routes L29: обрезает до 4000).
|
||||
/// </summary>
|
||||
public const int MaxDescriptionCodePoints = 4000;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит текста сообщения evaluate-fit (discovery_eval L41: _AI_TEXT_LIMIT=4000).
|
||||
/// </summary>
|
||||
public const int MaxEvalTextCodePoints = 4000;
|
||||
|
||||
// Текст фолбэк-ошибки, когда RPC-ошибка не несёт detail (сервис недоступен).
|
||||
private const string ServiceUnavailableText = "ai-service недоступен — повторите попытку через несколько секунд";
|
||||
|
||||
// Причина по умолчанию при fit=true, если сервис причину не вернул (1:1 _AI_REASON_LIMIT L170).
|
||||
private const string FitReasonDefault = "подходит";
|
||||
|
||||
// Причина по умолчанию при fit=false, если сервис причину не вернул (1:1 L170).
|
||||
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)
|
||||
{
|
||||
// Мягкая ошибка для UI (Ruling 11): {ok:false, keywords:[], error} — эндпоинт отвечает HTTP 200.
|
||||
_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)
|
||||
{
|
||||
// Сбой ИИ-оценки не роняет оценку кандидата — воркер падает в эвристику (Ruling 10, python L191–192).
|
||||
_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).");
|
||||
|
||||
// CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1).
|
||||
// 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);
|
||||
|
||||
// Краткий текст ошибки: detail gRPC-ошибки (дружелюбный текст ai-service) либо фолбэк (Ruling 13:
|
||||
// секреты/тела ответов не логируются и в текст не попадают).
|
||||
// exception: Исключение RPC-вызова.
|
||||
// Возвращает: Текст ошибки.
|
||||
private static string ErrorText(RpcException exception)
|
||||
{
|
||||
string detail = exception.Status.Detail?.Trim() ?? string.Empty;
|
||||
return detail.Length > 0 ? detail : ServiceUnavailableText;
|
||||
}
|
||||
|
||||
// Первые max кодовых точек строки (python-срез без разрыва суррогатных пар).
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
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 (Ruling 9, план Task 15/18/19).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется вместо Local-реализации при <c>Services:Ai:UseLocal=false</c> (Ruling 6). Поведение 1:1 с
|
||||
/// ai.proto GenerateKeywords/EvaluateFit и прототипом:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="GenerateKeywordsAsync"/> — RPC GenerateKeywords (deadline 120 с): конфиг провайдера из
|
||||
/// настроек, описание ≤4000 (discovery_routes L29); недоступность — мягкий {ok:false, keywords:[], error}
|
||||
/// (Ruling 11: generate-keywords-эндпоинт отдаёт HTTP 200 {keywords: [], error});</item>
|
||||
/// <item><see cref="EvaluateFitAsync"/> — RPC EvaluateFit: текст ≤4000 (discovery_eval L41) + описание и ключи
|
||||
/// задачи; сбой — <see cref="AiUnavailableException"/> (воркер Discovery падает в эвристику, Ruling 10);
|
||||
/// успех — {fit, reason} (потолок причины 200 задаёт сервис, _AI_REASON_LIMIT L43);</item>
|
||||
/// <item>usage ответов списывается с бюджета тенанта в tenant_limits и копится в lifetime-KV aiTokenUsage
|
||||
/// (<see cref="TokenUsageRecorder"/>, Ruling 3 этапа 7), как у классификатора.</item>
|
||||
/// </list>
|
||||
/// Каждый вызов несёт metadata tenant-id + service-token (<see cref="AiGrpcConnection"/>, Ruling 1). Scoped:
|
||||
/// настройки провайдера читаются через scoped-хранилище тенанта (ISettingsStore), как GrpcAiClassifier.
|
||||
/// Выключатель aiEnabled порт не читает — его отрабатывает вызывающий (воркер/эндпоинт Discovery, Ruling 10/11).
|
||||
/// </remarks>
|
||||
public sealed class GrpcAiTools : IAiTools
|
||||
{
|
||||
/// <summary>
|
||||
/// Deadline RPC ai-service — 120 с (README контрактов: провайдер 90/60 с + ретраи 0.8/2 с).
|
||||
/// </summary>
|
||||
public const int RpcDeadlineSeconds = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит описания ниши generate-keywords (discovery_routes L29: обрезает до 4000).
|
||||
/// </summary>
|
||||
public const int MaxDescriptionCodePoints = 4000;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит текста сообщения evaluate-fit (discovery_eval L41: _AI_TEXT_LIMIT=4000).
|
||||
/// </summary>
|
||||
public const int MaxEvalTextCodePoints = 4000;
|
||||
|
||||
// Текст фолбэк-ошибки, когда RPC-ошибка не несёт detail (сервис недоступен).
|
||||
private const string ServiceUnavailableText = "ai-service недоступен — повторите попытку через несколько секунд";
|
||||
|
||||
// Причина по умолчанию при fit=true, если сервис причину не вернул (1:1 _AI_REASON_LIMIT L170).
|
||||
private const string FitReasonDefault = "подходит";
|
||||
|
||||
// Причина по умолчанию при fit=false, если сервис причину не вернул (1:1 L170).
|
||||
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)
|
||||
{
|
||||
// Мягкая ошибка для UI (Ruling 11): {ok:false, keywords:[], error} — эндпоинт отвечает HTTP 200.
|
||||
_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)
|
||||
{
|
||||
// Сбой ИИ-оценки не роняет оценку кандидата — воркер падает в эвристику (Ruling 10, python L191–192).
|
||||
_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).");
|
||||
|
||||
// CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1).
|
||||
// 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);
|
||||
|
||||
// Краткий текст ошибки: detail gRPC-ошибки (дружелюбный текст ai-service) либо фолбэк (Ruling 13:
|
||||
// секреты/тела ответов не логируются и в текст не попадают).
|
||||
// exception: Исключение RPC-вызова.
|
||||
// Возвращает: Текст ошибки.
|
||||
private static string ErrorText(RpcException exception)
|
||||
{
|
||||
string detail = exception.Status.Detail?.Trim() ?? string.Empty;
|
||||
return detail.Length > 0 ? detail : ServiceUnavailableText;
|
||||
}
|
||||
|
||||
// Первые max кодовых точек строки (python-срез без разрыва суррогатных пар).
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user