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 _logger; /// /// Создаёт декоратор бюджетного гейта ИИ-инструментов. /// /// Платный исполнитель (gRPC-адаптер ai-service; вызывается только при Allowed). /// Хранилище лимитов бюджета (public.tenant_limits; источник гейта). /// Контекст текущего тенанта (AsyncLocal; tenantId запроса). /// Логгер переходов на локальный путь. public BudgetedAiTools( IAiTools paidTools, ITenantLimitStore tenantLimits, ITenantContext tenantContext, ILogger logger) { ArgumentNullException.ThrowIfNull(paidTools); ArgumentNullException.ThrowIfNull(tenantLimits); ArgumentNullException.ThrowIfNull(tenantContext); ArgumentNullException.ThrowIfNull(logger); _paidTools = paidTools; _tenantLimits = tenantLimits; _tenantContext = tenantContext; _logger = logger; } async Task 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(), Error: state.Status == TenantStatuses.Suspended ? SuspendedKeywordsError : ExhaustedKeywordsError); } async Task IAiTools.EvaluateFitAsync( string text, string description, IReadOnlyCollection 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 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 ?? "-"; }