Разбить Infrastructure и корень Deal.Api по назначению
Integrations -> Abstractions/Exceptions/Extensions/Models/Options/ Services (включая Storage); Persistence-конфигурации -> Configurations; корень Deal.Api (оркестратор/планировщики/DTO) -> Services/Dtos. namespace приведён к путям, using добавлены/дедуплицированы, FQN обновлены.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
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="IAiClassifier"/> (Ruling 3, Task 9): поверх «платного»
|
||||
/// исполнителя (gRPC-адаптер <see cref="GrpcAiClassifier"/>) перед каждым вызовом спрашивает гейт и при запрете
|
||||
/// ИИ уводит вызов на бесплатную локальную реализацию <see cref="LocalAiClassifier"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Гейт — <see cref="ITenantLimitStore.GetStateAsync"/> (public.tenant_limits + статус тенанта, Task 8):
|
||||
/// вызов разрешён, когда <see cref="BudgetStateDto.Allowed"/> — тенант active и бюджет периода не исчерпан
|
||||
/// (UsedTokens ≥ BudgetTokens; лимит 0 запрещает ИИ уже с нулевого расхода). Запрещено — исчерпание бюджета
|
||||
/// либо приостановка тенанта (suspended замораживает ИИ, Ruling 3/10(5)). При запрете фильтр/классификация
|
||||
/// выполняются Local-реализацией — семантика aiEnabled=false/aiFail этапов 4–5: фильтр {pass:true, skipped:true},
|
||||
/// разбор ядра <see cref="LocalFieldsParser"/> (детерминированный, бесплатный) — приём и обработка сообщений не
|
||||
/// блокируются, платный ИИ не зовётся и бюджет не расходуется. Списание usage остаётся внутри gRPC-адаптера
|
||||
/// (<see cref="TokenUsageRecorder"/>, Task 8) и выполняется только по реальным платным ответам. Регистрируется
|
||||
/// в <c>AddDealIntegrations</c> только при <c>Services:Ai:UseLocal=false</c> (порядок Grpc → Budgeted → наружу);
|
||||
/// в Local-режиме адаптер и так бесплатен — декоратор не нужен. Ошибки платного исполнителя
|
||||
/// (<see cref="AiUnavailableException"/>) пробрасываются как раньше — ветки фолбэка воркера не меняются.
|
||||
/// SSE-уведомления о пересечении порогов 80/100% бюджета публикует BudgetAlertScheduler (Api-слой): здесь
|
||||
/// запрет только логируется (Ruling 13: стабильные строки, без секретов).
|
||||
/// </remarks>
|
||||
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 />
|
||||
public async Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct)
|
||||
{
|
||||
if (await IsPaidAllowedAsync(ct))
|
||||
{
|
||||
return await _paidClassifier.FilterAsync(text, ct);
|
||||
}
|
||||
|
||||
// Запрет гейта — фильтр через Local-реализацию {pass:true, skipped:true} (семантика «фильтр недоступен»,
|
||||
// Ruling 3): сообщение не блокируется, платный фильтр не зовётся.
|
||||
_logger.LogDebug(
|
||||
"ИИ-фильтр: {Reason} — Local-пропуск (тенант {TenantId})", GateDeniedLogText, TenantIdForLog());
|
||||
return await _localClassifier.FilterAsync(text, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AiParsedCardDto> ClassifyAsync(string text, CancellationToken ct)
|
||||
{
|
||||
if (await IsPaidAllowedAsync(ct))
|
||||
{
|
||||
return await _paidClassifier.ClassifyAsync(text, ct);
|
||||
}
|
||||
|
||||
// Запрет гейта — локальный разбор ядра (семантика aiEnabled=false/aiFail, Ruling 3): карточка строится
|
||||
// без платного ИИ, приём не блокируется.
|
||||
_logger.LogDebug(
|
||||
"ИИ-классификация: {Reason} — Local-разбор (тенант {TenantId})", GateDeniedLogText, TenantIdForLog());
|
||||
return await _localClassifier.ClassifyAsync(text, ct);
|
||||
}
|
||||
|
||||
// Бюджетный гейт вызова (Ruling 3): true — платный ИИ разрешён (тенант active и бюджет не исчерпан).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: True — можно звать платного исполнителя.
|
||||
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 ?? "-";
|
||||
}
|
||||
Reference in New Issue
Block a user