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