Files
Deal/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiTools.cs
T
stepan b8570e3197
ci / build-test (pull_request) Successful in 2m53s
Убрать XML-доки из реализаций интерфейсов
Описание живёт только в интерфейсе: удалены классовые summary и
док-блоки членов-реализаций (28 файлов). Правило уточнено в §11.
2026-09-13 14:15:23 +03:00

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 ?? "-";
}