using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; 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; /// /// Декоратор бюджетного гейта порта (Ruling 3, Task 9): поверх «платного» /// исполнителя (gRPC-адаптер ) перед каждым вызовом спрашивает гейт и при запрете ИИ /// не зовёт платный инструмент: бросает /// (вызывающий — воркер Discovery — сам уходит в эвристику, код не меняется, Ruling 10), /// отдаёт мягкую ошибку {ok:false, keywords:[], error} (Ruling 11: эндпоинт /// отвечает HTTP 200 {keywords: [], error}). /// /// /// Гейт — (public.tenant_limits + статус тенанта, Task 8): вызов /// разрешён, когда — тенант active и бюджет периода не исчерпан; запрещено — /// исчерпание либо приостановка тенанта (suspended замораживает ИИ, Ruling 3/10(5)). Тексты запрета различают /// приостановку и исчерпание по (стабильные строки без секретов, Ruling 13). /// Ошибки платного исполнителя () пробрасываются как раньше — ветки фолбэка /// Discovery не меняются. Списание usage остаётся внутри gRPC-адаптера (, Task 8) /// и выполняется только по реальным платным ответам. Регистрируется в AddDealIntegrations только при /// Services:Ai:UseLocal=false (порядок Grpc → Budgeted → наружу). SSE-уведомления о пересечении порогов /// 80/100% бюджета публикует BudgetAlertScheduler (Api-слой): здесь запрет только логируется. /// public sealed class BudgetedAiTools : IAiTools { // Текст мягкой ошибки generate-keywords при исчерпанном бюджете (Ruling 3). private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна"; // Текст мягкой ошибки generate-keywords при приостановке тенанта (Ruling 3/10(5)). private const string SuspendedKeywordsError = "Тенант приостановлен — генерация ключевых слов недоступна"; // Текст исключения EvaluateFit при исчерпанном бюджете (семантика локальной обработки, Ruling 3). private const string ExhaustedFitError = "ИИ-бюджет исчерпан — обработка в локальном режиме"; // Текст исключения EvaluateFit при приостановке тенанта (Ruling 3/10(5)). 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; } /// public async Task GenerateKeywordsAsync(string description, CancellationToken ct) { BudgetStateDto state = await GateStateAsync(ct); if (state.Allowed) { return await _paidTools.GenerateKeywordsAsync(description, ct); } // Мягкая ошибка для UI (Ruling 3/11): {ok:false, keywords:[], error} — эндпоинт отвечает HTTP 200. _logger.LogDebug( "generate-keywords: {Reason} — мягкая ошибка (тенант {TenantId})", DenyLogText(state), TenantIdForLog()); return new AiGenerateKeywordsResultDto( Ok: false, Keywords: Array.Empty(), Error: state.Status == TenantStatuses.Suspended ? SuspendedKeywordsError : ExhaustedKeywordsError); } /// public async Task 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); } // Сбой ИИ-оценки не роняет оценку кандидата: воркер Discovery падает в эвристику (Ruling 3/10, // python L186–194 — код вызывающего не меняется). _logger.LogDebug( "evaluate-fit: {Reason} — эвристика (тенант {TenantId})", DenyLogText(state), TenantIdForLog()); throw new AiUnavailableException( state.Status == TenantStatuses.Suspended ? SuspendedFitError : ExhaustedFitError); } // Текущее состояние бюджета тенанта (ленивый reset периода + Allowed/Status для гейта, Task 9). // ct: Токен отмены. // Возвращает: Состояние бюджета тенанта на сейчас. 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 ?? "-"; }