ci / build-test (pull_request) Successful in 2m53s
Описание живёт только в интерфейсе: удалены классовые summary и док-блоки членов-реализаций (28 файлов). Правило уточнено в §11.
116 lines
5.5 KiB
C#
116 lines
5.5 KiB
C#
using Deal.Contracts.Integrations.Abstractions;
|
|
using Deal.Contracts.Integrations.Models;
|
|
using Deal.Infrastructure.Integrations.Exceptions;
|
|
using Deal.Modules.Tenants.Application.Abstractions;
|
|
using Deal.Modules.Tenants.Application.Models;
|
|
using Deal.SharedKernel.Tenants.Abstractions;
|
|
using Deal.SharedKernel.Tenants.Models;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Deal.Infrastructure.Integrations.Services;
|
|
|
|
public sealed class BudgetedAiTools : IAiTools
|
|
{
|
|
private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна";
|
|
|
|
private const string SuspendedKeywordsError = "Тенант приостановлен — генерация ключевых слов недоступна";
|
|
|
|
private const string ExhaustedFitError = "ИИ-бюджет исчерпан — обработка в локальном режиме";
|
|
|
|
private const string SuspendedFitError = "Тенант приостановлен — ИИ-оценка заморожена";
|
|
|
|
private readonly IAiTools _paidTools;
|
|
private readonly ITenantLimitStore _tenantLimits;
|
|
private readonly ITenantContext _tenantContext;
|
|
private readonly ILogger<BudgetedAiTools> _logger;
|
|
|
|
/// <summary>
|
|
/// Создаёт декоратор бюджетного гейта ИИ-инструментов.
|
|
/// </summary>
|
|
/// <param name="paidTools">Платный исполнитель (gRPC-адаптер ai-service; вызывается только при Allowed).</param>
|
|
/// <param name="tenantLimits">Хранилище лимитов бюджета (public.tenant_limits; источник гейта).</param>
|
|
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal; tenantId запроса).</param>
|
|
/// <param name="logger">Логгер переходов на локальный путь.</param>
|
|
public BudgetedAiTools(
|
|
IAiTools paidTools,
|
|
ITenantLimitStore tenantLimits,
|
|
ITenantContext tenantContext,
|
|
ILogger<BudgetedAiTools> logger)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(paidTools);
|
|
ArgumentNullException.ThrowIfNull(tenantLimits);
|
|
ArgumentNullException.ThrowIfNull(tenantContext);
|
|
ArgumentNullException.ThrowIfNull(logger);
|
|
_paidTools = paidTools;
|
|
_tenantLimits = tenantLimits;
|
|
_tenantContext = tenantContext;
|
|
_logger = logger;
|
|
}
|
|
|
|
async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
|
|
{
|
|
BudgetStateDto state = await GateStateAsync(ct);
|
|
if (state.Allowed)
|
|
{
|
|
return await _paidTools.GenerateKeywordsAsync(description, ct);
|
|
}
|
|
|
|
_logger.LogDebug(
|
|
"generate-keywords: {Reason} — мягкая ошибка (тенант {TenantId})",
|
|
DenyLogText(state),
|
|
TenantIdForLog());
|
|
return new AiGenerateKeywordsResultDto(
|
|
Ok: false,
|
|
Keywords: Array.Empty<string>(),
|
|
Error: state.Status == TenantStatuses.Suspended ? SuspendedKeywordsError : ExhaustedKeywordsError);
|
|
}
|
|
|
|
async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
|
|
string text,
|
|
string description,
|
|
IReadOnlyCollection<string> keywords,
|
|
CancellationToken ct)
|
|
{
|
|
BudgetStateDto state = await GateStateAsync(ct);
|
|
if (state.Allowed)
|
|
{
|
|
return await _paidTools.EvaluateFitAsync(text, description, keywords, ct);
|
|
}
|
|
|
|
_logger.LogDebug(
|
|
"evaluate-fit: {Reason} — эвристика (тенант {TenantId})",
|
|
DenyLogText(state),
|
|
TenantIdForLog());
|
|
throw new AiUnavailableException(
|
|
state.Status == TenantStatuses.Suspended ? SuspendedFitError : ExhaustedFitError);
|
|
}
|
|
|
|
private async Task<BudgetStateDto> GateStateAsync(CancellationToken ct)
|
|
=> await _tenantLimits.GetStateAsync(RequireTenantId(), ct);
|
|
|
|
// Причина запрета в логе: приостановка и исчерпание различаются (короткая строка без секретов).
|
|
// state: Состояние бюджета тенанта.
|
|
// Возвращает: Текст причины.
|
|
private static string DenyLogText(BudgetStateDto state)
|
|
=> state.Status == TenantStatuses.Suspended ? "тенант приостановлен" : "ИИ-бюджет исчерпан";
|
|
|
|
// Текущий тенант scope как Guid строки public.tenants (гейт читает лимиты по тенанту).
|
|
// Возвращает: Идентификатор тенанта (Guid).
|
|
// Исключение InvalidOperationException: Вызов вне tenant-контекста или не-Guid формат id.
|
|
private Guid RequireTenantId()
|
|
{
|
|
TenantId? tenantId = _tenantContext.TenantId;
|
|
if (tenantId is null || !Guid.TryParse(tenantId.Value.Value, out Guid id))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"BudgetedAiTools запрошен вне tenant-контекста (ITenantContext.TenantId == null/не-Guid).");
|
|
}
|
|
|
|
return id;
|
|
}
|
|
|
|
// Id тенанта для лога («-» вне контекста — недостижимо после RequireTenantId).
|
|
// Возвращает: Строка id тенанта.
|
|
private string TenantIdForLog() => _tenantContext.TenantId?.Value ?? "-";
|
|
}
|