Files
Deal/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiTools.cs
T
Rustam Khalimov 410194b0cb Разбить Infrastructure и корень Deal.Api по назначению
Integrations -> Abstractions/Exceptions/Extensions/Models/Options/
Services (включая Storage); Persistence-конфигурации -> Configurations;
корень Deal.Api (оркестратор/планировщики/DTO) -> Services/Dtos.
namespace приведён к путям, using добавлены/дедуплицированы, FQN
обновлены.
2026-09-11 13:20:10 +03:00

150 lines
9.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Декоратор бюджетного гейта порта <see cref="IAiTools"/> (Ruling 3, Task 9): поверх «платного»
/// исполнителя (gRPC-адаптер <see cref="GrpcAiTools"/>) перед каждым вызовом спрашивает гейт и при запрете ИИ
/// не зовёт платный инструмент: <see cref="EvaluateFitAsync"/> бросает <see cref="AiUnavailableException"/>
/// (вызывающий — воркер Discovery — сам уходит в эвристику, код не меняется, Ruling 10),
/// <see cref="GenerateKeywordsAsync"/> отдаёт мягкую ошибку {ok:false, keywords:[], error} (Ruling 11: эндпоинт
/// отвечает HTTP 200 {keywords: [], error}).
/// </summary>
/// <remarks>
/// Гейт — <see cref="ITenantLimitStore.GetStateAsync"/> (public.tenant_limits + статус тенанта, Task 8): вызов
/// разрешён, когда <see cref="BudgetStateDto.Allowed"/> — тенант active и бюджет периода не исчерпан; запрещено —
/// исчерпание либо приостановка тенанта (suspended замораживает ИИ, Ruling 3/10(5)). Тексты запрета различают
/// приостановку и исчерпание по <see cref="BudgetStateDto.Status"/> (стабильные строки без секретов, Ruling 13).
/// Ошибки платного исполнителя (<see cref="AiUnavailableException"/>) пробрасываются как раньше — ветки фолбэка
/// Discovery не меняются. Списание usage остаётся внутри gRPC-адаптера (<see cref="TokenUsageRecorder"/>, Task 8)
/// и выполняется только по реальным платным ответам. Регистрируется в <c>AddDealIntegrations</c> только при
/// <c>Services:Ai:UseLocal=false</c> (порядок Grpc → Budgeted → наружу). SSE-уведомления о пересечении порогов
/// 80/100% бюджета публикует BudgetAlertScheduler (Api-слой): здесь запрет только логируется.
/// </remarks>
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<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;
}
/// <inheritdoc />
public async Task<AiGenerateKeywordsResultDto> 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<string>(),
Error: state.Status == TenantStatuses.Suspended ? SuspendedKeywordsError : ExhaustedKeywordsError);
}
/// <inheritdoc />
public async Task<AiEvaluateFitResultDto> 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);
}
// Сбой ИИ-оценки не роняет оценку кандидата: воркер Discovery падает в эвристику (Ruling 3/10,
// python L186194 — код вызывающего не меняется).
_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<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 ?? "-";
}