Разбить 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,149 @@
|
||||
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 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<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 ?? "-";
|
||||
}
|
||||
Reference in New Issue
Block a user