Files
Deal/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiClassifier.cs
T
Rustam Khalimov 206d61068a Перевести реализации интерфейсов на явные (§11, вариант A)
Codemod scripts/make_explicit.py: 161 член в 30 прод-файлах конвертирован в
вид "Тип IFoo.Член" (частичные классы и многострочные сигнатуры учтены; Card и
ICard-семейство — DTO, оставлены implicit). Потребители, дёргавшие классы
напрямую, перетипизированы на интерфейсы: 8 мест в проде (самовызовы через
((ISessionClient)this), снят дефолт параметра в явной реализации) и 17 тестовых
файлов (поля, tuple-деконструкции, var/target-typed new). Build 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
2026-09-11 20:16:06 +03:00

107 lines
5.0 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;
/// <summary>
/// Декоратор бюджетного гейта порта <see cref="IAiClassifier"/>
/// </summary>
public sealed class BudgetedAiClassifier : IAiClassifier
{
// Текст ошибки вызова вне tenant-контекста (гейт читает лимиты по тенанту).
private const string NoTenantContextText =
"BudgetedAiClassifier запрошен вне tenant-контекста (ITenantContext.TenantId == null/не-Guid).";
// Причина запрета в логе (общий текст для исчерпания и приостановки).
private const string GateDeniedLogText = "бюджет исчерпан или тенант приостановлен";
private readonly IAiClassifier _paidClassifier;
private readonly LocalAiClassifier _localClassifier;
private readonly ITenantLimitStore _tenantLimits;
private readonly ITenantContext _tenantContext;
private readonly ILogger<BudgetedAiClassifier> _logger;
/// <summary>
/// Создаёт декоратор бюджетного гейта классификатора.
/// </summary>
/// <param name="paidClassifier">Платный исполнитель (gRPC-адаптер ai-service; вызывается только при Allowed).</param>
/// <param name="localClassifier">Бесплатный локальный разбор/фильтр (fallback при запрете гейта).</param>
/// <param name="tenantLimits">Хранилище лимитов бюджета (public.tenant_limits; источник гейта).</param>
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal; tenantId запроса).</param>
/// <param name="logger">Логгер переходов на локальный путь.</param>
public BudgetedAiClassifier(
IAiClassifier paidClassifier,
LocalAiClassifier localClassifier,
ITenantLimitStore tenantLimits,
ITenantContext tenantContext,
ILogger<BudgetedAiClassifier> logger)
{
ArgumentNullException.ThrowIfNull(paidClassifier);
ArgumentNullException.ThrowIfNull(localClassifier);
ArgumentNullException.ThrowIfNull(tenantLimits);
ArgumentNullException.ThrowIfNull(tenantContext);
ArgumentNullException.ThrowIfNull(logger);
_paidClassifier = paidClassifier;
_localClassifier = localClassifier;
_tenantLimits = tenantLimits;
_tenantContext = tenantContext;
_logger = logger;
}
/// <inheritdoc />
async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct)
{
if (await IsPaidAllowedAsync(ct))
{
return await _paidClassifier.FilterAsync(text, ct);
}
_logger.LogDebug(
"ИИ-фильтр: {Reason} — Local-пропуск (тенант {TenantId})", GateDeniedLogText, TenantIdForLog());
return await _localClassifier.FilterAsync(text, ct);
}
/// <inheritdoc />
async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
{
if (await IsPaidAllowedAsync(ct))
{
return await _paidClassifier.ClassifyAsync(text, ct);
}
_logger.LogDebug(
"ИИ-классификация: {Reason} — Local-разбор (тенант {TenantId})", GateDeniedLogText, TenantIdForLog());
return await _localClassifier.ClassifyAsync(text, ct);
}
private async Task<bool> IsPaidAllowedAsync(CancellationToken ct)
{
BudgetStateDto state = await _tenantLimits.GetStateAsync(RequireTenantId(), ct);
return state.Allowed;
}
// Текущий тенант 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(NoTenantContextText);
}
return id;
}
// Id тенанта для лога («-» вне контекста — недостижимо после RequireTenantId).
// Возвращает: Строка id тенанта.
private string TenantIdForLog() => _tenantContext.TenantId?.Value ?? "-";
}