Разбить 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:
Rustam Khalimov
2026-09-11 13:20:10 +03:00
parent cd0b3b606b
commit 410194b0cb
289 changed files with 855 additions and 101 deletions
@@ -0,0 +1,267 @@
using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Headers;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
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>
/// HTTP-реализация проверки подключения к AI-провайдеру (Ruling 7; 1:1 settings_routes.py L195219).
/// </summary>
/// <remarks>
/// Лёгкая проверка БЕЗ LLM-вызовов: для OpenAI-совместимых — GET {base}/models, для Anthropic
/// (api_style <c>"anthropic"</c>) — GET {base}/v1/models c заголовком x-api-key. Без ключа и для
/// локальных провайдеров (Ollama/LM Studio) HTTP не выполняется — короткие ветки ответа.
/// Таймаут клиента — 12 с (HttpClient настраивается DI-регистрацией AddHttpClient в Deal.Api,
/// см. <see cref="RequestTimeoutSeconds"/>). Ключ в ответ не попадает: только keySet/keyMasked
/// (маска — <c>ai.py</c> mask_key L5358).
/// SSRF-контур dev-режима (см. отчёт Task 6): провайдер обязан быть из фиксированного каталога
/// <see cref="AiProviders"/> (allowlist), base URL — только абсолютный http(s)-адрес; host-level
/// рестрикции нет (локальные серверы на LAN + ветка «недоступный хост» приёмки плана).
/// </remarks>
public sealed class AiConnectionChecker : IAiConnectionChecker
{
/// <summary>
/// Таймаут HTTP-запроса проверки в секундах (Ruling 7 — 12 с); применяется DI-регистрацией клиента.
/// </summary>
public const int RequestTimeoutSeconds = 12;
// ── Фиксированные сообщения веток (Ruling 7, 1:1 с прототипом) ──
// Сообщение ветки «локальный провайдер» (вместо HTTP — ping на этапе 6).
private const string LocalServerMessageTemplate = "Локальный сервер «{0}» (ping в проде)";
// Сообщение ветки «API-ключ не задан».
private const string NoApiKeyMessage = "Не задан API-ключ";
// Сообщение успешной проверки (HTTP &lt; 400).
private const string SuccessMessage = "Подключение успешно";
// Сообщение ветки «ключ не принят» (HTTP 401/403).
private const string KeyRejectedMessageTemplate = "Ключ не принят (HTTP {0}) — проверьте ключ и доступ к модели";
// Сообщение ветки «иной HTTP-код» (≥ 400, кроме 401/403).
private const string HttpErrorMessageTemplate = "HTTP {0} — проверьте Base URL и модель";
// Сообщение ветки сетевого сбоя (деталь — текст исключения).
private const string ConnectionErrorMessageTemplate = "Ошибка соединения: {0}";
// Сообщение ветки таймаута HttpClient.
private const string TimeoutMessage = "Ошибка соединения: превышен таймаут ожидания";
// Сообщение SSRF-гейта: провайдер вне фиксированного каталога (allowlist).
private const string ProviderNotAllowedMessage = "Провайдер не из списка разрешённых";
// Сообщение SSRF-гейта: base URL не абсолютный http(s).
private const string InvalidBaseUrlMessage = "Недопустимый Base URL (ожидается http/https)";
// ── Константы протокола (референс settings_routes.py L206209) ──
// Значение api_style провайдера Anthropic (AiProviderDefinition.ApiStyle).
private const string AnthropicApiStyle = "anthropic";
// Путь списка моделей Anthropic: {base}/v1/models.
private const string AnthropicModelsPath = "/v1/models";
// Путь списка моделей OpenAI-совместимых: {base}/models.
private const string OpenAiModelsPath = "/models";
// Сообщение SSRF-гейта: base URL указывает на приватный/локальный адрес.
private const string PrivateEndpointNotAllowedMessage =
"Недопустимый Base URL (приватный/локальный адрес недоступен для проверки)";
// Заголовок ключа Anthropic.
private const string ApiKeyHeaderName = "x-api-key";
// Заголовок версии протокола Anthropic.
private const string AnthropicVersionHeaderName = "anthropic-version";
// Значение версии протокола Anthropic.
private const string AnthropicVersionHeaderValue = "2023-06-01";
private readonly HttpClient _httpClient;
/// <summary>
/// Создаёт проверку поверх HttpClient.
/// </summary>
/// <param name="httpClient">Клиент с таймаутом 12 с (DI: AddHttpClient в Deal.Api).</param>
public AiConnectionChecker(HttpClient httpClient)
{
ArgumentNullException.ThrowIfNull(httpClient);
_httpClient = httpClient;
}
/// <inheritdoc />
public async Task<AiCheckResultDto> CheckAsync(AiCheckRequest request, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(request);
AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == request.ProviderId);
string name = meta?.Name ?? request.ProviderId;
// SSRF-гейт (allowlist, preflight): проверка возможна только для провайдера фиксированного
// каталога AiProviders. В штатном потоке недостижимо (PATCH-гейт aiProvider/aiConfigs в
// SettingsService) — защита от ручного изменения БД/повреждённого хранилища.
if (meta is null)
{
return BuildResult(request, name, ok: false, message: ProviderNotAllowedMessage);
}
// Локальный провайдер (Ollama/LM Studio): HTTP наружу не ходим (Ruling 7 — ветка до ключа).
if (request.IsLocal)
{
return BuildResult(request, name, ok: true, message: string.Format(LocalServerMessageTemplate, name));
}
if (string.IsNullOrEmpty(request.ApiKey))
{
return BuildResult(request, name, ok: false, message: NoApiKeyMessage);
}
if (!TryBuildModelsUri(request.BaseUrl, request.ApiStyle, out Uri? modelsUri))
{
return BuildResult(request, name, ok: false, message: InvalidBaseUrlMessage);
}
// SSRF-гейт (Security review): проверка подключения выполняется только к публичным адресам.
// Private/loopback/link-local литералы и localhost запрещены для не-local провайдеров (локальные
// провайдеры — ветка IsLocal выше, HTTP для них не выполняется вовсе). DNS-имена не резолвятся
// здесь (полный egress-контроль с резолвом — на уровне сетевого периметра/прокси).
if (modelsUri.IsPrivateEndpoint())
{
return BuildResult(request, name, ok: false, message: PrivateEndpointNotAllowedMessage);
}
try
{
using HttpRequestMessage httpRequest = new(HttpMethod.Get, modelsUri);
AddAuthHeaders(httpRequest, request.ApiKey, request.ApiStyle);
using HttpResponseMessage response = await _httpClient.SendAsync(httpRequest, ct);
int statusCode = (int)response.StatusCode;
if (statusCode < 400)
{
return BuildResult(request, name, ok: true, message: SuccessMessage);
}
if (statusCode is 401 or 403)
{
return BuildResult(request, name, ok: false,
message: string.Format(KeyRejectedMessageTemplate, statusCode));
}
return BuildResult(request, name, ok: false,
message: string.Format(HttpErrorMessageTemplate, statusCode));
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Сработал HttpClient.Timeout (12 с) — ветка сетевого сбоя (прототип ловит все исключения).
return BuildResult(request, name, ok: false, message: TimeoutMessage);
}
catch (HttpRequestException exception)
{
return BuildResult(request, name, ok: false,
message: string.Format(ConnectionErrorMessageTemplate, ConnectionErrorDetail(exception)));
}
}
// Собирает ответ ветки: {ok, message} + статус провайдера (Ruling 7).
// request: Запрос проверки (поля статуса провайдера).
// name: Имя провайдера из каталога AiProviders.
// ok: Результат подключения.
// message: Сообщение ветки.
// Возвращает: DTO ответа (наружу — camelCase).
private static AiCheckResultDto BuildResult(AiCheckRequest request, string name, bool ok, string message)
{
return new AiCheckResultDto(
Ok: ok,
Message: message,
Provider: request.ProviderId,
Name: name,
Base: request.BaseUrl,
Model: request.Model,
Local: request.IsLocal,
KeySet: !string.IsNullOrEmpty(request.ApiKey),
KeyMasked: MaskKey(request.ApiKey));
}
// Маска ключа: пусто → "", len ≤ 8 → «x…», иначе «1234…5678» (ai.py mask_key L5358).
// key: Ключ открытым текстом.
// Возвращает: Маскированная строка.
private static string MaskKey(string key)
{
if (string.IsNullOrEmpty(key))
{
return string.Empty;
}
if (key.Length <= 8)
{
return string.Concat(key.AsSpan(0, 1), "…");
}
return string.Concat(key.AsSpan(0, 4), "…", key.AsSpan(key.Length - 4));
}
// Строит URL проверки: {base}/models или {base}/v1/models (Anthropic), как в ai_check L206207.
// baseUrl: Эффективный базовый URL из конфигурации провайдера.
// apiStyle: Стиль API провайдера (null — OpenAI-совместимый).
// modelsUri: URL списка моделей (валиден только при возврате true).
// Возвращает: True — URL построен; False — base URL не абсолютный http(s) (SSRF-гейт).
private static bool TryBuildModelsUri(string baseUrl, string? apiStyle, [NotNullWhen(true)] out Uri? modelsUri)
{
modelsUri = null;
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri? parsed)
|| (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
{
return false;
}
// rstrip("/") как в прототипе: baseUrl из настроек может заканчиваться слэшем.
string root = baseUrl.TrimEnd('/');
string relativePath = apiStyle == AnthropicApiStyle ? AnthropicModelsPath : OpenAiModelsPath;
if (!Uri.TryCreate(root + relativePath, UriKind.Absolute, out Uri? endpoint))
{
return false;
}
modelsUri = endpoint;
return true;
}
// Заголовки аутентификации: Bearer (OpenAI-совместимые) или x-api-key + версия (Anthropic).
// httpRequest: Запрос списка моделей.
// apiKey: Ключ открытым текстом (непустой — ветка ключа пройдена).
// apiStyle: Стиль API провайдера.
private static void AddAuthHeaders(HttpRequestMessage httpRequest, string apiKey, string? apiStyle)
{
if (apiStyle == AnthropicApiStyle)
{
httpRequest.Headers.TryAddWithoutValidation(ApiKeyHeaderName, apiKey);
httpRequest.Headers.TryAddWithoutValidation(AnthropicVersionHeaderName, AnthropicVersionHeaderValue);
return;
}
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
}
// Исключение HTTP-слоя.
// Возвращает: Человекочитаемый текст причины.
private static string ConnectionErrorDetail(HttpRequestException exception)
{
if (!string.IsNullOrWhiteSpace(exception.Message))
{
return exception.Message;
}
return exception.InnerException?.Message ?? exception.GetType().Name;
}
}
@@ -0,0 +1,173 @@
using System.Text.Json.Nodes;
using Deal.Grpc.Ai;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
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>
/// Собирает конфиг активного ИИ-провайдера для запросов ai-service (Ruling 5: ядро расшифровывает
/// aiConfigs и передаёт ProviderConfig в теле каждого запроса; сервис настроек тенанта не знает).
/// </summary>
/// <remarks>
/// Эффективный конфиг 1:1 с python <c>ai.py _cfg()</c> L2533 и формой ProviderConfig (ai.proto L7085):
/// активный провайдер — настройка <c>aiProvider</c> (дефолт «deepseek»), каталог — <see cref="AiProviders"/>
/// (fallback на первый — deepseek, как python L28); из переопределения <c>aiConfigs</c> берутся
/// apiKey/baseUrl/model, отсутствующие поля дополняются дефолтами каталога (base провайдера, первая модель);
/// apiKey расшифровывается (значения <c>enc:</c>+… через <see cref="ISecretCipher"/>; незашифрованные ранних
/// версий — как есть, python crypto.decrypt_text L5261); api_style провайдера — из каталога (Anthropic —
/// «anthropic», остальные — пусто = OpenAI-совместимый). Scoped: читает KV-настройки тенанта (ISettingsStore →
/// scoped TenantDbContext запроса), как LocalAiClassifier/GrpcMlClient.
/// </remarks>
public sealed class AiProviderConfigBuilder
{
// Ключ aiConfigs: поле apiKey переопределения провайдера.
private const string ApiKeyField = "apiKey";
// Ключ aiConfigs: поле baseUrl переопределения провайдера.
private const string BaseUrlField = "baseUrl";
// Ключ aiConfigs: поле model переопределения провайдера.
private const string ModelField = "model";
// Префикс зашифрованного значения apiKey (crypto.py L49: enc: + Base64(nonce‖ct‖tag)).
private const string EncryptedPrefix = "enc:";
private readonly ISettingsStore _store;
private readonly ISecretCipher _secretCipher;
/// <summary>
/// Создаёт сборщик конфига провайдера.
/// </summary>
/// <param name="store">KV-хранилище настроек тенанта (aiProvider/aiConfigs).</param>
/// <param name="secretCipher">Расшифровка секрета aiConfigs.apiKey (AES-GCM, Ruling 2).</param>
public AiProviderConfigBuilder(ISettingsStore store, ISecretCipher secretCipher)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(secretCipher);
_store = store;
_secretCipher = secretCipher;
}
/// <summary>
/// Собирает ProviderConfig активного провайдера для тела запроса ai-service.
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Конфиг: provider_id/base/model/api_key (расшифрованный)/api_style (см. ai.proto).</returns>
public async Task<ProviderConfig> BuildAsync(CancellationToken ct)
{
string providerId = await ReadProviderIdAsync(ct);
AiProviderDefinition meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId)
?? AiProviders.All[0]; // неизвестный id — дефолтный провайдер (python L28)
JsonObject? overrides = await ReadAiConfigsOverrideAsync(ct);
JsonObject? raw = overrides?[meta.Id] as JsonObject;
string apiKey = ReadField(raw, ApiKeyField);
if (apiKey.StartsWith(EncryptedPrefix, StringComparison.Ordinal))
{
apiKey = _secretCipher.Decrypt(apiKey);
}
string baseUrl = ReadField(raw, BaseUrlField);
if (baseUrl.Length == 0)
{
baseUrl = meta.Base; // python L90: cfg.baseUrl or meta.base
}
string model = ReadField(raw, ModelField);
if (model.Length == 0)
{
model = meta.Models.FirstOrDefault() ?? string.Empty; // python L91: cfg.model or models[0]
}
var config = new ProviderConfig
{
ProviderId = meta.Id,
BaseUrl = baseUrl,
Model = model,
};
if (apiKey.Length > 0)
{
config.ApiKey = apiKey;
}
if (meta.ApiStyle is { Length: > 0 } apiStyle)
{
config.ApiStyle = apiStyle;
}
return config;
}
// Активный провайдер из настройки aiProvider (дефолт «deepseek», python L26).
// ct: Токен отмены.
// Возвращает: Id провайдера (каталога AiProviders).
private async Task<string> ReadProviderIdAsync(CancellationToken ct)
{
SettingValue? row = await _store.GetAsync(SettingsKeys.AiProvider, ct);
if (row is null)
{
return SettingsDefaults.AiProvider;
}
try
{
JsonNode? value = JsonNode.Parse(row.ValueJson);
if (value is JsonValue scalar && scalar.TryGetValue<string>(out string? providerId)
&& !string.IsNullOrWhiteSpace(providerId))
{
return providerId;
}
}
catch (System.Text.Json.JsonException)
{
// Повреждённая строка — дефолт (мягкая семантика, как в SettingsService).
}
return SettingsDefaults.AiProvider;
}
// Переопределение aiConfigs тенанта (JSON-объект «id провайдера → конфиг»); null — дефолты.
// ct: Токен отмены.
// Возвращает: Объект переопределения или null.
private async Task<JsonObject?> ReadAiConfigsOverrideAsync(CancellationToken ct)
{
SettingValue? row = await _store.GetAsync(SettingsKeys.AiConfigs, ct);
if (row is null)
{
return null;
}
try
{
return JsonNode.Parse(row.ValueJson) as JsonObject;
}
catch (System.Text.Json.JsonException)
{
// Повреждённая строка — дефолты (мягкая семантика, как в SettingsService).
return null;
}
}
// Строковое поле конфига провайдера (отсутствие/null/не-строка → пустая строка).
// config: Объект конфига провайдера (может быть null — дефолты).
// field: Имя поля (apiKey/baseUrl/model).
// Возвращает: Значение строкой или пустая строка.
private static string ReadField(JsonObject? config, string field)
{
if (config is null || !config.TryGetPropertyValue(field, out JsonNode? node) || node is not JsonValue value)
{
return string.Empty;
}
return value.TryGetValue<string>(out string? text) ? text ?? string.Empty : string.Empty;
}
}
@@ -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 этапов 45: фильтр {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 ?? "-";
}
@@ -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 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 ?? "-";
}
@@ -0,0 +1,186 @@
using System.Globalization;
using System.Text.Json;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
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>
/// HTTP-источник курсов ЦБ РФ: GET daily_json.js (Ruling 6, Task 8; 1:1 rates.py L4359).
/// </summary>
/// <remarks>
/// Запрос — GET <c>https://www.cbr-xml-daily.ru/daily_json.js</c> (JSON-зеркало ЦБ). SSRF-контур: URL —
/// фиксированная константа (allowlist), тенант не управляет адресом источника (в отличие от baseUrl
/// AI-провайдеров, Task 6). Таймаут клиента — 15 с (python: <c>httpx timeout=15</c>), задаётся
/// DI-регистрацией AddHttpClient в Deal.Api. Парсинг: <c>Valute[code].Value / Nominal</c> (1 единица
/// валюты в рублях; Nominal может быть &gt; 1, напр. 100 KZT), к курсам добавляется <c>RUB:1</c>.
/// Любой сбой (HTTP-код ≠ 2xx, нераспознанное тело/запись, сетевая ошибка) → null + warning — кэш
/// RatesService при этом не трогает (Ruling 6). Отмена вызывающего пробрасывается (не «сбой»).
/// </remarks>
public sealed class CbrRateSource : IRatesSource
{
/// <summary>
/// Таймаут HTTP-запроса в секундах (python rates.py L46: <c>timeout=15</c>).
/// </summary>
public const int RequestTimeoutSeconds = 15;
// URL JSON-зеркала курсов ЦБ (constants.py L52). Фиксированный — SSRF-allowlist.
private const string CbrUrl = "https://www.cbr-xml-daily.ru/daily_json.js";
// Корневой объект ответа: валюта → {Value, Nominal, …}.
private const string ValutePropertyName = "Valute";
// Курс единицы валюты в рублях (число).
private const string ValuePropertyName = "Value";
// Номинал (сколько единиц за курс Value; может быть &gt; 1).
private const string NominalPropertyName = "Nominal";
// Базовая валюта ответа: курсы даются к рублю.
private const string BaseCurrency = "RUB";
// Курс рубля к рублю (всегда 1.0, rates.py L50).
private const double RubToRubRate = 1.0;
private readonly HttpClient _httpClient;
private readonly ILogger<CbrRateSource> _logger;
/// <summary>
/// Создаёт источник поверх HttpClient.
/// </summary>
/// <param name="httpClient">Клиент с таймаутом 15 с (DI: AddHttpClient в Deal.Api).</param>
/// <param name="logger">Логгер предупреждений о сбоях.</param>
public CbrRateSource(HttpClient httpClient, ILogger<CbrRateSource> logger)
{
ArgumentNullException.ThrowIfNull(httpClient);
ArgumentNullException.ThrowIfNull(logger);
_httpClient = httpClient;
_logger = logger;
}
/// <inheritdoc />
public async Task<Dictionary<string, double>?> FetchAsync(CancellationToken ct)
{
try
{
using HttpResponseMessage response = await _httpClient.GetAsync(CbrUrl, ct);
response.EnsureSuccessStatusCode();
return await ParseRatesAsync(response, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Отменил вызывающий (обрыв запроса/фоновой задачи) — пробрасываем, это не «сбой источника».
throw;
}
catch (Exception exception)
{
// Любой сбой HTTP/парсинга = неуспех источника (python ловит все исключения, rates.py L5759).
_logger.LogWarning("CBR fetch failed: {Reason}", exception.Message);
return null;
}
}
// Разбирает тело daily_json.js в курсы к рублю; нераспознанное тело/запись — null.
// response: Успешный HTTP-ответ (статус 2xx).
// ct: Токен отмены.
// Возвращает: Словарь «код валюты → курс к RUB» (RUB:1 в начале) или null.
private static async Task<Dictionary<string, double>?> ParseRatesAsync(HttpResponseMessage response, CancellationToken ct)
{
using Stream content = await response.Content.ReadAsStreamAsync(ct);
using JsonDocument document = await JsonDocument.ParseAsync(content, cancellationToken: ct);
JsonElement root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty(ValutePropertyName, out JsonElement valute)
|| valute.ValueKind != JsonValueKind.Object)
{
// Тело не похоже на daily_json.js (нет объекта Valute) — это не курсы ЦБ.
return null;
}
var rates = new Dictionary<string, double> { [BaseCurrency] = RubToRubRate };
foreach (JsonProperty currency in valute.EnumerateObject())
{
if (!TryParseCurrency(currency, out double rate))
{
// Нераспознанная запись валюты: как и исключение python внутри цикла, роняет весь fetch.
return null;
}
rates[currency.Name] = rate;
}
return rates;
}
// Разбирает одну запись валюты: курс = Value / Nominal, округлён до 6 знаков (rates.py L5155).
// currency: Пара «код валюты → объект {Value, Nominal}».
// rate: Курс единицы валюты к рублю (валиден при возврате true).
// Возвращает: True — запись распознана; False — повреждённая запись (весь fetch — сбой).
private static bool TryParseCurrency(JsonProperty currency, out double rate)
{
rate = 0;
if (currency.Value.ValueKind != JsonValueKind.Object)
{
return false;
}
JsonElement item = currency.Value;
// Значение по умолчанию, как в python: отсутствующий Value → 0, Nominal → 1 (иначе — сбой).
double value = 0;
if (item.TryGetProperty(ValuePropertyName, out JsonElement valueElement))
{
if (!TryReadDouble(valueElement, out value))
{
return false;
}
}
double nominal = 1;
if (item.TryGetProperty(NominalPropertyName, out JsonElement nominalElement))
{
if (!TryReadDouble(nominalElement, out nominal))
{
return false;
}
}
if (nominal == 0)
{
nominal = 1; // python: int(...) or 1 — нулевой номинал трактуем как 1
}
rate = Math.Round(value / nominal, 6);
return true;
}
// Читает число из JSON-элемента (число или строка, как их отдаёт зеркало).
// element: JSON-элемент записи валюты.
// value: Прочитанное число (инвариантная культура).
// Возвращает: True — элемент распознан как число.
private static bool TryReadDouble(JsonElement element, out double value)
{
switch (element.ValueKind)
{
case JsonValueKind.Number:
value = element.GetDouble();
return true;
case JsonValueKind.String:
return double.TryParse(element.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
default:
value = 0;
return false;
}
}
}
@@ -0,0 +1,255 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ai;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Pipeline.Application.Services;
using Deal.SharedKernel.Tenants;
using Grpc.Core;
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>
/// gRPC-адаптер порта <see cref="IAiClassifier"/> к автономному ai-service (Ruling 5/6, план Task 15
/// L412434).
/// </summary>
/// <remarks>
/// Регистрируется вместо Local-реализации при <c>Services:Ai:UseLocal=false</c> (выбор на старте, Ruling 6).
/// Поведение 1:1 с <c>backend/app/services/ai.py</c> filter_incoming L188198 / classify L218258 и ai.proto:
/// <list type="bullet">
/// <item><see cref="FilterAsync"/> — RPC Filter (deadline 120 с, README контрактов): заполненный aiFilterPrompt
/// из настроек (<see cref="AiClassifyContextBuilder"/>) + текст ≤4000; недоступность провайдера → RPC-ошибка →
/// <see cref="AiUnavailableException"/> (воркер отвечает «пропустить», python L11021106);</item>
/// <item><see cref="ClassifyAsync"/> — RPC Classify: system_prompt = aiPrompt+cardPrompt, user-контекст «Доски +
/// примеры разметки + Сообщение» (собирает билдер по данным тенанта, python L226251); ok=false/сбой →
/// <see cref="AiUnavailableException"/> (воркер собирает локальный разбор, aiFail, python L11081114);
/// ok=true → строгий маппинг JSON в <see cref="AiParsedCardDto"/> (<see cref="AiRawCardMapper"/>, 1:1
/// normalize_stack/clean_budget/build_contacts);</item>
/// <item>конфиг провайдера на каждый запрос — <see cref="AiProviderConfigBuilder"/> (Ruling 5: core читает
/// aiConfigs тенанта, расшифровывает apiKey); usage ответов списывается с бюджета тенанта в tenant_limits и
/// копится в lifetime-KV aiTokenUsage (<see cref="TokenUsageRecorder"/>, Ruling 3 этапа 7).</item>
/// </list>
/// Каждый вызов несёт metadata tenant-id + service-token (<see cref="AiGrpcConnection"/>, Ruling 1). Scoped:
/// настройки/доски/журнал тенанта читаются через scoped-хранилища (ISettingsStore/ICardStore), как
/// LocalAiClassifier/GrpcMlClient. Ветки выключателей aiEnabled/aiFilterEnabled порт не читает — их
/// отрабатывает воркер (Ruling 5 этапа 4).
/// </remarks>
public sealed class GrpcAiClassifier : IAiClassifier
{
/// <summary>
/// Deadline RPC ai-service — 120 с (README контрактов: провайдер 90/60 с + ретраи 0.8/2 с).
/// </summary>
public const int RpcDeadlineSeconds = 120;
/// <summary>
/// Лимит текста сообщения фильтра (ai.py filter_incoming L193: text[:4000]).
/// </summary>
public const int MaxFilterTextCodePoints = 4000;
// Текст фолбэк-ошибки, когда RPC-ошибка не несёт detail (сервис недоступен).
private const string ServiceUnavailableText = "ai-service недоступен — повторите попытку через несколько секунд";
// Текст ошибки ветки «модель не вернула разбор» (ClassifyReply.ok=false, README ai.proto).
private const string NoJsonAnswerText = "ИИ не дал разбора — ответ модели без JSON (повторите попытку через несколько секунд)";
private readonly ITenantContext _tenantContext;
private readonly AiGrpcConnection _connection;
private readonly AiProviderConfigBuilder _providerConfigBuilder;
private readonly AiClassifyContextBuilder _contextBuilder;
private readonly TokenUsageRecorder _usageRecorder;
private readonly ILogger<GrpcAiClassifier> _logger;
/// <summary>
/// Создаёт gRPC-адаптер классификатора ai-service.
/// </summary>
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами).</param>
/// <param name="connection">Транспорт ai-service (singleton-канал + service-token).</param>
/// <param name="providerConfigBuilder">Конфиг активного провайдера из настроек тенанта.</param>
/// <param name="contextBuilder">Промпты и user-контекст классификации (модуль Pipeline).</param>
/// <param name="usageRecorder">Списание usage ответов: бюджет периода tenant_limits + lifetime-KV aiTokenUsage.</param>
/// <param name="logger">Логгер сбоев.</param>
public GrpcAiClassifier(
ITenantContext tenantContext,
AiGrpcConnection connection,
AiProviderConfigBuilder providerConfigBuilder,
AiClassifyContextBuilder contextBuilder,
TokenUsageRecorder usageRecorder,
ILogger<GrpcAiClassifier> logger)
{
ArgumentNullException.ThrowIfNull(tenantContext);
ArgumentNullException.ThrowIfNull(connection);
ArgumentNullException.ThrowIfNull(providerConfigBuilder);
ArgumentNullException.ThrowIfNull(contextBuilder);
ArgumentNullException.ThrowIfNull(usageRecorder);
ArgumentNullException.ThrowIfNull(logger);
_tenantContext = tenantContext;
_connection = connection;
_providerConfigBuilder = providerConfigBuilder;
_contextBuilder = contextBuilder;
_usageRecorder = usageRecorder;
_logger = logger;
}
/// <inheritdoc />
public async Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
string prompt = await _contextBuilder.BuildFilterPromptAsync(ct);
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
AiService.AiServiceClient client = _connection.CreateClient();
FilterReply reply = await client.FilterAsync(
new FilterRequest
{
Prompt = prompt,
Text = SliceCodePoints(text, MaxFilterTextCodePoints), // python L193: text[:4000]
ProviderConfig = providerConfig,
},
CallOptions(tenantId.Value, ct));
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
// Фильтр применён (воркер звал его только при aiFilterEnabled и не force) — skipped=false
// (python filter_incoming L194198: {pass, reason, skipped:false}).
return new AiFilterResultDto(
Pass: reply.Pass,
Reason: reply.HasReason ? reply.Reason : null,
Skipped: false);
}
catch (RpcException exception)
{
// Провайдер/сервис недоступен — воркер отвечает «пропустить» (python L11021106: r2=pass+skipped).
_logger.LogDebug(exception, "ИИ-фильтр недоступен (тенант {TenantId})", tenantId.Value);
throw new AiUnavailableException(ErrorText(exception));
}
catch (HttpRequestException exception)
{
_logger.LogDebug(exception, "ИИ-фильтр недоступен (тенант {TenantId})", tenantId.Value);
throw new AiUnavailableException(ServiceUnavailableText);
}
}
/// <inheritdoc />
public async Task<AiParsedCardDto> ClassifyAsync(string text, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
string systemPrompt = await _contextBuilder.BuildClassifySystemPromptAsync(ct);
string userContext = await _contextBuilder.BuildClassifyUserContextAsync(text, ct);
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
AiService.AiServiceClient client = _connection.CreateClient();
ClassifyReply reply;
try
{
reply = await client.ClassifyAsync(
new ClassifyRequest
{
SystemPrompt = systemPrompt,
UserContext = userContext,
ProviderConfig = providerConfig,
},
CallOptions(tenantId.Value, ct));
}
catch (RpcException exception)
{
// Классификатор недоступен — как raw={} в прототипе (L1112–1114): локальный разбор, aiFail.
_logger.LogDebug(exception, "ИИ-классификация недоступна (тенант {TenantId})", tenantId.Value);
throw new AiUnavailableException(ErrorText(exception));
}
catch (HttpRequestException exception)
{
_logger.LogDebug(exception, "ИИ-классификация недоступна (тенант {TenantId})", tenantId.Value);
throw new AiUnavailableException(ServiceUnavailableText);
}
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
if (!reply.Ok)
{
// Модель не вернула разбираемый JSON после ретраев — контрактная ok=false (README ai.proto):
// ядро трактует как «разбора нет» и падает в локальный путь (python: RuntimeError → raw={}).
_logger.LogWarning("ИИ-классификация: ok=false (тенант {TenantId})", tenantId.Value);
throw new AiUnavailableException(NoJsonAnswerText);
}
try
{
return AiRawCardMapper.Map(reply.HasJson ? reply.Json : string.Empty, text);
}
catch (System.Text.Json.JsonException exception)
{
// ok=true, но тело не объект/не разбирается — защита от нарушения контракта сервисом: как ok=false.
_logger.LogWarning(exception, "ИИ-классификация: ответ ok=true без разбора (тенант {TenantId})", tenantId.Value);
throw new AiUnavailableException(NoJsonAnswerText);
}
}
// Текущий тенант scope (без него scoped-хранилища/metadata не имеют смысла).
// Возвращает: Идентификатор тенанта.
// Исключение InvalidOperationException: Вызов вне tenant-контекста (конфигурация).
private TenantId RequireTenant()
=> _tenantContext.TenantId
?? throw new InvalidOperationException(
"GrpcAiClassifier запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
// CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1).
// tenantId: Id тенанта (формат N).
// ct: Токен отмены вызова.
// Возвращает: Опции вызова с заголовками, deadline и отменой.
private CallOptions CallOptions(string tenantId, CancellationToken ct)
=> new(
headers: _connection.CreateMetadata(tenantId),
deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)),
cancellationToken: ct);
// Краткий текст ошибки: detail gRPC-ошибки (дружелюбный текст ai-service) либо фолбэк (Ruling 13:
// секреты/тела ответов не логируются и в текст не попадают).
// exception: Исключение RPC-вызова.
// Возвращает: Текст ошибки.
private static string ErrorText(RpcException exception)
{
string detail = exception.Status.Detail?.Trim() ?? string.Empty;
return detail.Length > 0 ? detail : ServiceUnavailableText;
}
// Первые max кодовых точек строки (python-срез без разрыва суррогатных пар).
// text: Строка.
// max: Лимит.
// Возвращает: Усечённая строка.
private static string SliceCodePoints(string text, int max)
{
return text.Length <= max ? text : SliceByCodePoints(text, max);
}
// Ручной срез по кодовым точкам (суррогатная пара не разрывается).
// text: Строка длиннее лимита.
// max: Максимум кодовых точек.
// Возвращает: Усечённая строка.
private static string SliceByCodePoints(string text, int max)
{
var builder = new System.Text.StringBuilder(max);
int taken = 0;
for (int index = 0; index < text.Length && taken < max; index++)
{
bool pair = char.IsHighSurrogate(text[index])
&& index + 1 < text.Length
&& char.IsLowSurrogate(text[index + 1]);
builder.Append(text[index]);
if (pair)
{
index++;
builder.Append(text[index]);
}
taken++;
}
return builder.ToString();
}
}
@@ -0,0 +1,231 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ai;
using Deal.SharedKernel.Tenants;
using Grpc.Core;
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>
/// gRPC-адаптер порта <see cref="IAiTools"/> к автономному ai-service (Ruling 9, план Task 15/18/19).
/// </summary>
/// <remarks>
/// Регистрируется вместо Local-реализации при <c>Services:Ai:UseLocal=false</c> (Ruling 6). Поведение 1:1 с
/// ai.proto GenerateKeywords/EvaluateFit и прототипом:
/// <list type="bullet">
/// <item><see cref="GenerateKeywordsAsync"/> — RPC GenerateKeywords (deadline 120 с): конфиг провайдера из
/// настроек, описание ≤4000 (discovery_routes L29); недоступность — мягкий {ok:false, keywords:[], error}
/// (Ruling 11: generate-keywords-эндпоинт отдаёт HTTP 200 {keywords: [], error});</item>
/// <item><see cref="EvaluateFitAsync"/> — RPC EvaluateFit: текст ≤4000 (discovery_eval L41) + описание и ключи
/// задачи; сбой — <see cref="AiUnavailableException"/> (воркер Discovery падает в эвристику, Ruling 10);
/// успех — {fit, reason} (потолок причины 200 задаёт сервис, _AI_REASON_LIMIT L43);</item>
/// <item>usage ответов списывается с бюджета тенанта в tenant_limits и копится в lifetime-KV aiTokenUsage
/// (<see cref="TokenUsageRecorder"/>, Ruling 3 этапа 7), как у классификатора.</item>
/// </list>
/// Каждый вызов несёт metadata tenant-id + service-token (<see cref="AiGrpcConnection"/>, Ruling 1). Scoped:
/// настройки провайдера читаются через scoped-хранилище тенанта (ISettingsStore), как GrpcAiClassifier.
/// Выключатель aiEnabled порт не читает — его отрабатывает вызывающий (воркер/эндпоинт Discovery, Ruling 10/11).
/// </remarks>
public sealed class GrpcAiTools : IAiTools
{
/// <summary>
/// Deadline RPC ai-service — 120 с (README контрактов: провайдер 90/60 с + ретраи 0.8/2 с).
/// </summary>
public const int RpcDeadlineSeconds = 120;
/// <summary>
/// Лимит описания ниши generate-keywords (discovery_routes L29: обрезает до 4000).
/// </summary>
public const int MaxDescriptionCodePoints = 4000;
/// <summary>
/// Лимит текста сообщения evaluate-fit (discovery_eval L41: _AI_TEXT_LIMIT=4000).
/// </summary>
public const int MaxEvalTextCodePoints = 4000;
// Текст фолбэк-ошибки, когда RPC-ошибка не несёт detail (сервис недоступен).
private const string ServiceUnavailableText = "ai-service недоступен — повторите попытку через несколько секунд";
// Причина по умолчанию при fit=true, если сервис причину не вернул (1:1 _AI_REASON_LIMIT L170).
private const string FitReasonDefault = "подходит";
// Причина по умолчанию при fit=false, если сервис причину не вернул (1:1 L170).
private const string NotFitReasonDefault = "не подходит";
private readonly ITenantContext _tenantContext;
private readonly AiGrpcConnection _connection;
private readonly AiProviderConfigBuilder _providerConfigBuilder;
private readonly TokenUsageRecorder _usageRecorder;
private readonly ILogger<GrpcAiTools> _logger;
/// <summary>
/// Создаёт gRPC-адаптер ИИ-инструментов ai-service.
/// </summary>
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами).</param>
/// <param name="connection">Транспорт ai-service (singleton-канал + service-token).</param>
/// <param name="providerConfigBuilder">Конфиг активного провайдера из настроек тенанта.</param>
/// <param name="usageRecorder">Списание usage ответов: бюджет периода tenant_limits + lifetime-KV aiTokenUsage.</param>
/// <param name="logger">Логгер сбоев.</param>
public GrpcAiTools(
ITenantContext tenantContext,
AiGrpcConnection connection,
AiProviderConfigBuilder providerConfigBuilder,
TokenUsageRecorder usageRecorder,
ILogger<GrpcAiTools> logger)
{
ArgumentNullException.ThrowIfNull(tenantContext);
ArgumentNullException.ThrowIfNull(connection);
ArgumentNullException.ThrowIfNull(providerConfigBuilder);
ArgumentNullException.ThrowIfNull(usageRecorder);
ArgumentNullException.ThrowIfNull(logger);
_tenantContext = tenantContext;
_connection = connection;
_providerConfigBuilder = providerConfigBuilder;
_usageRecorder = usageRecorder;
_logger = logger;
}
/// <inheritdoc />
public async Task<AiGenerateKeywordsResultDto> GenerateKeywordsAsync(string description, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
AiService.AiServiceClient client = _connection.CreateClient();
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
GenerateKeywordsReply reply = await client.GenerateKeywordsAsync(
new GenerateKeywordsRequest
{
Description = SliceCodePoints(description ?? string.Empty, MaxDescriptionCodePoints),
ProviderConfig = providerConfig,
},
CallOptions(tenantId.Value, ct));
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
return new AiGenerateKeywordsResultDto(
Ok: true,
Keywords: reply.Keywords.ToList(),
Error: null);
}
catch (RpcException exception)
{
// Мягкая ошибка для UI (Ruling 11): {ok:false, keywords:[], error} — эндпоинт отвечает HTTP 200.
_logger.LogDebug(exception, "generate-keywords недоступен (тенант {TenantId})", tenantId.Value);
return new AiGenerateKeywordsResultDto(Ok: false, Keywords: Array.Empty<string>(), Error: ErrorText(exception));
}
catch (HttpRequestException exception)
{
_logger.LogDebug(exception, "generate-keywords недоступен (тенант {TenantId})", tenantId.Value);
return new AiGenerateKeywordsResultDto(
Ok: false, Keywords: Array.Empty<string>(), Error: ServiceUnavailableText);
}
}
/// <inheritdoc />
public async Task<AiEvaluateFitResultDto> EvaluateFitAsync(
string text, string description, IReadOnlyCollection<string> keywords, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
AiService.AiServiceClient client = _connection.CreateClient();
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
var request = new EvaluateFitRequest
{
Text = SliceCodePoints(text ?? string.Empty, MaxEvalTextCodePoints),
Description = description ?? string.Empty,
ProviderConfig = providerConfig,
};
foreach (string keyword in keywords ?? Array.Empty<string>())
{
if (!string.IsNullOrEmpty(keyword))
{
request.Keywords.Add(keyword);
}
}
EvaluateFitReply reply = await client.EvaluateFitAsync(request, CallOptions(tenantId.Value, ct));
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
return new AiEvaluateFitResultDto(
Fit: reply.Fit,
Reason: reply.HasReason && reply.Reason.Length > 0
? reply.Reason
: (reply.Fit ? FitReasonDefault : NotFitReasonDefault));
}
catch (RpcException exception)
{
// Сбой ИИ-оценки не роняет оценку кандидата — воркер падает в эвристику (Ruling 10, python L191192).
_logger.LogDebug(exception, "evaluate-fit недоступен (тенант {TenantId})", tenantId.Value);
throw new AiUnavailableException(ErrorText(exception));
}
catch (HttpRequestException exception)
{
_logger.LogDebug(exception, "evaluate-fit недоступен (тенант {TenantId})", tenantId.Value);
throw new AiUnavailableException(ServiceUnavailableText);
}
}
// Текущий тенант scope (без него scoped-хранилища/metadata не имеют смысла).
// Возвращает: Идентификатор тенанта.
// Исключение InvalidOperationException: Вызов вне tenant-контекста (конфигурация).
private TenantId RequireTenant()
=> _tenantContext.TenantId
?? throw new InvalidOperationException(
"GrpcAiTools запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
// CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1).
// tenantId: Id тенанта (формат N).
// ct: Токен отмены вызова.
// Возвращает: Опции вызова с заголовками, deadline и отменой.
private CallOptions CallOptions(string tenantId, CancellationToken ct)
=> new(
headers: _connection.CreateMetadata(tenantId),
deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)),
cancellationToken: ct);
// Краткий текст ошибки: detail gRPC-ошибки (дружелюбный текст ai-service) либо фолбэк (Ruling 13:
// секреты/тела ответов не логируются и в текст не попадают).
// exception: Исключение RPC-вызова.
// Возвращает: Текст ошибки.
private static string ErrorText(RpcException exception)
{
string detail = exception.Status.Detail?.Trim() ?? string.Empty;
return detail.Length > 0 ? detail : ServiceUnavailableText;
}
// Первые max кодовых точек строки (python-срез без разрыва суррогатных пар).
// text: Строка.
// max: Лимит.
// Возвращает: Усечённая строка.
private static string SliceCodePoints(string text, int max)
{
if (text.Length <= max)
{
return text;
}
var builder = new System.Text.StringBuilder(max);
int taken = 0;
for (int index = 0; index < text.Length && taken < max; index++)
{
bool pair = char.IsHighSurrogate(text[index])
&& index + 1 < text.Length
&& char.IsLowSurrogate(text[index + 1]);
builder.Append(text[index]);
if (pair)
{
index++;
builder.Append(text[index]);
}
taken++;
}
return builder.ToString();
}
}
@@ -0,0 +1,422 @@
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ml;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
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 Grpc.Core;
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>
/// gRPC-адаптер порта IMlClient к автономному ml-service (Ruling 4/6, план Task 16 L438445).
/// </summary>
/// <remarks>
/// Регистрируется вместо Local-заглушки при <c>Services:Ml:UseLocal=false</c> (выбор на старте, Ruling 6).
/// Поведение 1:1 с <c>backend/app/services/ml_client.py</c> и ml.proto:
/// <list type="bullet">
/// <item><see cref="StatusAsync"/> — статус модели из ml-service (RPC Status, deadline 10 с) с кэшем 15 с
/// (<see cref="MlStatusCache"/>, python L3031/127135) + локальная статистика тенанта из KV/таблиц
/// (счётчики ml/ai, learning = count(CardMoves), outbox = count(MlOutbox)); сервис недоступен — старые
/// данные кэша (или «не готова») и <c>reachable=false</c>;</item>
/// <item><see cref="PredictAsync"/> — RPC Predict (deadline 5 с); сбой/недоступность → фиксированный «не
/// уверен» (python L101–107: решит ИИ/локальный путь воркера);</item>
/// <item><see cref="ResetAsync"/> — RPC Reset (deadline 10 с); при успехе — очистка своей очереди
/// MlOutbox (reset_model L110124) и инвалидация кэша статуса; сбой — мягкий <c>{ok:false,error}</c>,
/// очередь не трогается;</item>
/// <item><see cref="PushAsync"/> — ВСЕГДА запись в MlOutbox через <see cref="IMlLearningStore"/> (Ruling 6:
/// обучение гарантированно и локально; отправку батчами делает <c>MlOutboxFlushScheduler</c>);</item>
/// <item><see cref="TrainBatchAsync"/> (IMlTrainClient) — RPC TrainBatch (deadline 30 с) для фонового флашера.</item>
/// </list>
/// Каждый вызов несёт metadata tenant-id + service-token (<see cref="MlGrpcConnection"/>, Ruling 1). Scoped:
/// локальная статистика читает KV-настройки и таблицы тенанта (ISettingsStore/IMlLearningStore → scoped
/// TenantDbContext), как LocalMlClient.
/// </remarks>
public sealed class GrpcMlClient : IMlClient, IMlTrainClient
{
/// <summary>
/// Deadline Predict — 5 с (README контрактов: локальная модель).
/// </summary>
public const int PredictDeadlineSeconds = 5;
/// <summary>
/// Deadline Status/Reset — 10 с (README контрактов).
/// </summary>
public const int StatusDeadlineSeconds = 10;
/// <summary>
/// Deadline TrainBatch — 30 с (README контрактов: батч ≤100, 1 транзакция).
/// </summary>
public const int TrainBatchDeadlineSeconds = 30;
// Текст мягкой ошибки, когда сервис вернул ResetReply.ok=false без error (резерв).
private const string DefaultResetError = "ML-сервис не смог сбросить модель";
// Пустой словарь весов предсказания/классов неготовой модели.
private static readonly IReadOnlyDictionary<string, double> EmptyScores = new Dictionary<string, double>();
// Контекст текущего тенанта (id — в metadata вызовов; scoped-хранилища строятся от него же).
private readonly ITenantContext _tenantContext;
// KV-хранилище настроек тенанта (выключатель mlEnabled, счётчики ml/ai).
private readonly ISettingsStore _store;
// Хранилище обучения ML: очередь MlOutbox + счётчик журнала CardMoves.
private readonly IMlLearningStore _learningStore;
// Транспорт gRPC ml-service (канал + metadata).
private readonly MlGrpcConnection _connection;
// Кэш статуса сервиса на тенанта (15 с).
private readonly MlStatusCache _statusCache;
// Recorder истории расхода (ML-событие расхода, этап 10, T2): оценка токенов входного текста.
private readonly TokenUsageRecorder _usageRecorder;
// Логгер сбоев вызовов ml-service.
private readonly ILogger<GrpcMlClient> _logger;
/// <summary>
/// Создаёт gRPC-адаптер клиента ML-сервиса.
/// </summary>
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами).</param>
/// <param name="store">KV-хранилище настроек тенанта.</param>
/// <param name="learningStore">Хранилище обучения ML (очередь MlOutbox + журнал).</param>
/// <param name="connection">Транспорт ml-service (singleton-канал + service-token).</param>
/// <param name="statusCache">Кэш статуса сервиса на тенанта (singleton).</param>
/// <param name="usageRecorder">Recorder истории расхода (ML-событие predict, этап 10, T2).</param>
/// <param name="logger">Логгер сбоев.</param>
public GrpcMlClient(
ITenantContext tenantContext,
ISettingsStore store,
IMlLearningStore learningStore,
MlGrpcConnection connection,
MlStatusCache statusCache,
TokenUsageRecorder usageRecorder,
ILogger<GrpcMlClient> logger)
{
ArgumentNullException.ThrowIfNull(tenantContext);
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(learningStore);
ArgumentNullException.ThrowIfNull(connection);
ArgumentNullException.ThrowIfNull(statusCache);
ArgumentNullException.ThrowIfNull(usageRecorder);
ArgumentNullException.ThrowIfNull(logger);
_tenantContext = tenantContext;
_store = store;
_learningStore = learningStore;
_connection = connection;
_statusCache = statusCache;
_usageRecorder = usageRecorder;
_logger = logger;
}
/// <inheritdoc />
public async Task<MlStatusResponseDto> StatusAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
MlStatusCache.Snapshot snapshot = await GetServiceSnapshotAsync(tenantId, ct);
bool enabled = await ReadMlEnabledAsync(ct);
int mlDecisions = await ReadCounterAsync(SettingsKeys.MlDecisions, ct);
int aiDecisions = await ReadCounterAsync(SettingsKeys.AiDecisions, ct);
int learning = await _learningStore.CountLearningAsync(ct);
int outbox = await _learningStore.CountOutboxAsync(ct);
var stats = new MlStatsDto(
Ml: mlDecisions,
Ai: aiDecisions,
Learning: learning,
Ready: snapshot.Service.Ready,
Classes: snapshot.Service.Classes,
Learned: snapshot.Service.Learned,
Reachable: snapshot.Reachable,
Outbox: outbox);
return new MlStatusResponseDto(Enabled: enabled, Service: snapshot.Service, Reachable: snapshot.Reachable, Stats: stats);
}
/// <inheritdoc />
public async Task<MlPredictResultDto> PredictAsync(string text, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
MlService.MlServiceClient client = _connection.CreateClient();
PredictReply reply = await client.PredictAsync(
new PredictRequest { Text = text ?? string.Empty },
CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), ct));
// История расхода (этап 10, T2): ML-ответ токенов не несёт — оценка входного текста (≈chars/4),
// бюджет/lifetime AI-счётчик не затрагиваются (локальная модель бесплатна).
await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct);
return MapPredict(reply);
}
catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException)
{
// Сервис недоступен/таймаут/отмена — «не уверен» (python predict L101107): решит ИИ/локальный путь.
_logger.LogDebug(exception, "ML predict недоступен (тенант {TenantId})", tenantId.Value);
return NotReadyPrediction;
}
}
/// <inheritdoc />
public async Task<MlResetResultDto> ResetAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
ResetReply reply;
try
{
MlService.MlServiceClient client = _connection.CreateClient();
reply = await client.ResetAsync(
new ResetRequest(),
CallOptions(tenantId.Value, TimeSpan.FromSeconds(StatusDeadlineSeconds), ct));
}
catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException)
{
// Мягкая ошибка реального сервиса (python reset_model L117121): ok:false + текст; outbox не трогаем.
_logger.LogWarning(exception, "ML reset не удался (тенант {TenantId})", tenantId.Value);
return new MlResetResultDto(Ok: false, Error: ErrorText(exception));
}
if (!reply.Ok)
{
return new MlResetResultDto(Ok: false, Error: reply.HasError ? reply.Error : DefaultResetError);
}
// 1:1 reset_model L122123: после успешного сброса сервиса — очистка своей очереди + свежий статус.
await _learningStore.ClearOutboxAsync(ct);
_statusCache.Invalidate(tenantId.Value);
return new MlResetResultDto(Ok: true, Error: null);
}
/// <inheritdoc />
public async Task PushAsync(string text, string label, double delta, CancellationToken ct)
{
// Обучение гарантированно и локально (Ruling 6): сигнал всегда пишется в MlOutbox, отправку батчами
// делает MlOutboxFlushScheduler — и в Local-, и в gRPC-режиме (ml_client.py L67).
await MlOutboxQueue.PushAsync(_learningStore, text, label, delta, ct);
}
/// <inheritdoc />
public async Task<int> TrainBatchAsync(IReadOnlyList<MlOutboxEntryDto> items, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
var request = new TrainBatchRequest();
foreach (MlOutboxEntryDto item in items)
{
request.Items.Add(new TrainExample
{
Text = item.Text,
Label = item.Label,
Delta = item.Delta,
});
}
MlService.MlServiceClient client = _connection.CreateClient();
TrainBatchReply reply = await client.TrainBatchAsync(
request,
CallOptions(tenantId.Value, TimeSpan.FromSeconds(TrainBatchDeadlineSeconds), ct));
return reply.Learned;
}
// Текущий тенант scope (без него scoped-хранилища/metadata не имеют смысла).
// Возвращает: Идентификатор тенанта.
// Исключение InvalidOperationException: Вызов вне tenant-контекста (конфигурация).
private TenantId RequireTenant()
=> _tenantContext.TenantId
?? throw new InvalidOperationException(
"GrpcMlClient запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
// Возвращает статус модели из кэша либо обновляет его вызовом ml-service (кэш 15 с).
// tenantId: Id тенанта (формат N).
// ct: Токен отмены.
// Возвращает: Свежая запись кэша (при сбое сервиса — старые данные + reachable=false).
private async Task<MlStatusCache.Snapshot> GetServiceSnapshotAsync(TenantId tenantId, CancellationToken ct)
{
if (_statusCache.TryGetFresh(tenantId.Value, out MlStatusCache.Snapshot fresh))
{
return fresh;
}
// Последние известные данные (при сбое refresh останутся они — python refresh_status L132135).
_statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot stale);
MlServiceStatusDto previous = stale?.Service ?? NotReadyServiceStatus;
try
{
MlService.MlServiceClient client = _connection.CreateClient();
StatusReply reply = await client.StatusAsync(
new StatusRequest(),
CallOptions(tenantId.Value, TimeSpan.FromSeconds(StatusDeadlineSeconds), ct));
MlServiceStatusDto service = MapStatus(reply);
_statusCache.Set(tenantId.Value, service, reachable: true);
return _statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot updated)
? updated
: new MlStatusCache.Snapshot(service, Reachable: true, UpdatedAtMs: 0);
}
catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException)
{
_logger.LogDebug(exception, "ML status недоступен (тенант {TenantId})", tenantId.Value);
_statusCache.Set(tenantId.Value, previous, reachable: false);
return new MlStatusCache.Snapshot(previous, false, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
}
}
// Маппит ответ Status в контрактный статус модели (поля 1:1 с MlServiceStatusDto).
// reply: Ответ ml-service.
// Возвращает: DTO статуса модели.
private static MlServiceStatusDto MapStatus(StatusReply reply)
{
return new MlServiceStatusDto(
Ready: reply.Ready,
Classes: new Dictionary<string, double>(reply.Classes),
Learned: reply.Learned,
Eval: new MlEvalDto(
Count: reply.Eval?.Count ?? 0,
Correct: reply.Eval?.Correct ?? 0,
Accuracy: reply.Eval?.Accuracy ?? 0.0));
}
// Маппит ответ Predict в контрактный результат (поля 1:1 с MlPredictResultDto).
// reply: Ответ ml-service.
// Возвращает: DTO предсказания.
private static MlPredictResultDto MapPredict(PredictReply reply)
{
return new MlPredictResultDto(
Take: reply.Take,
Label: reply.HasLabel ? reply.Label : null,
Scores: new Dictionary<string, double>(reply.Scores),
Hits: reply.Hits,
Ready: reply.Ready,
Margin: reply.HasMargin ? reply.Margin : null,
Terms: reply.Terms.ToList(),
Type: MapTypeDecision(reply.Type));
}
// Маппит решение о типе заявки (null — модель тип не определила).
// decision: Ответ ml-service (TypeDecision) или null.
// Возвращает: DTO типа заявки или null.
private static MlTypeDecisionDto? MapTypeDecision(TypeDecision? decision)
{
return decision is null
? null
: new MlTypeDecisionDto(
Take: decision.Take,
Label: decision.Label,
Value: decision.Value,
Margin: decision.Margin);
}
// CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1).
// tenantId: Id тенанта (формат N).
// deadline: Лимит времени вызова.
// ct: Токен отмены вызова.
// Возвращает: Опции вызова с заголовками, deadline и отменой.
private CallOptions CallOptions(string tenantId, TimeSpan deadline, CancellationToken ct)
=> new(
headers: _connection.CreateMetadata(tenantId),
deadline: DateTime.UtcNow.Add(deadline),
cancellationToken: ct);
// Краткий текст ошибки для мягкого {ok:false,error} (секреты/тела ответов не логируются).
// exception: Исключение вызова.
// Возвращает: Текст ошибки.
private static string ErrorText(Exception exception)
=> exception is RpcException rpc && rpc.StatusCode == StatusCode.Unavailable
? "ML-сервис недоступен"
: "ML-сервис не ответил — повторите попытку через несколько секунд";
// Фиксированный ответ неготовой/недоступной модели: «не уверен» (Ruling 5, ml.proto L2123).
private static MlPredictResultDto NotReadyPrediction => new(
Take: false,
Label: null,
Scores: EmptyScores,
Hits: 0,
Ready: false,
Margin: null,
Terms: Array.Empty<string>(),
Type: null);
// Статус модели по умолчанию (нет данных кэша и сервис недоступен): «не готова».
private static MlServiceStatusDto NotReadyServiceStatus => new(
Ready: false,
Classes: EmptyScores,
Learned: 0,
Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0));
// Читает выключатель mlEnabled: «не false» (ml_routes.py L71) — false только при сохранённом JSON-false.
// ct: Токен отмены.
// Возвращает: True, если ключ отсутствует, повреждён или хранит JSON-true.
private async Task<bool> ReadMlEnabledAsync(CancellationToken ct)
{
SettingValue? row = await _store.GetAsync(SettingsKeys.MlEnabled, ct);
if (row is null)
{
return SettingsDefaults.MlEnabled;
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
if (document.RootElement.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
return document.RootElement.GetBoolean();
}
}
catch (JsonException)
{
// Повреждённая строка — дефолт (мягкая семантика, как в SettingsService).
}
return SettingsDefaults.MlEnabled;
}
// Читает целочисленный счётчик (mlDecisions/aiDecisions); отсутствие/повреждение → 0.
// key: Внутренний KV-ключ счётчика.
// ct: Токен отмены.
// Возвращает: Значение счётчика из хранилища или 0.
private async Task<int> ReadCounterAsync(string key, CancellationToken ct)
{
SettingValue? row = await _store.GetAsync(key, ct);
if (row is null)
{
return 0;
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
if (document.RootElement.ValueKind == JsonValueKind.Number
&& document.RootElement.TryGetInt64(out long wide))
{
return (int)Math.Clamp(wide, int.MinValue, int.MaxValue);
}
}
catch (JsonException)
{
// Повреждённая строка — 0 (мягкая семантика, как в SettingsService).
}
return 0;
}
}
@@ -0,0 +1,462 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Telegram;
using Deal.SharedKernel.Tenants;
using Grpc.Core;
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>
/// gRPC-адаптер порта <see cref="ITelegramGateway"/> к автономному telegram-service (Ruling 6/7, план Task 14).
/// </summary>
/// <remarks>
/// Регистрируется вместо Local-заглушки при <c>Services:Telegram:UseLocal=false</c> (выбор на старте, Ruling 6).
/// Каждый RPC telegram.proto (TelegramService) маппится 1:1 в метод порта: подключение/отключение аккаунта
/// (StartPhone/StartQr/SendCode/SendPassword/Logout), каталог диалогов (RefreshDialogs), мониторинг
/// (SetMonitor/SetMonitorAll), backfill (Backfill), превью (ReadRecent) и discovery-операции (Search/GetInfo/
/// ReadForEval/Join/Leave). Каждый вызов несёт metadata tenant-id + service-token
/// (<see cref="TelegramGrpcConnection"/>, Ruling 1) и deadline по README контрактов (src/contracts L6274).
/// <para>
/// Ошибки домена telegram-service приходят RPC-статусами с каноническими detail («Telegram не подключён»,
/// «Сначала сохраните Telegram api_id и api_hash в настройках», «Неверный код», …) — RpcException
/// пробрасывается наружу без изменений, текст причины решает HTTP-слой эндпоинтов (Ruling 7/8). Транспортные
/// сбои (сервис недоступен/таймаут) нормализуются в RpcException Unavailable с detail «Telegram не подключён»
/// — ветки эндпоинтов отвечают «не подключён», как при недоступном сервисе.
/// </para>
/// </remarks>
public sealed class GrpcTelegramClient : ITelegramGateway
{
/// <summary>
/// Deadline локальных команд статуса/зеркала — 10 с (README L66).
/// </summary>
public const int ShortDeadlineSeconds = 10;
/// <summary>
/// Deadline сетевых команд Telegram — 60 с (README L67: паузы анти-бана внутри сервиса).
/// </summary>
public const int CommandDeadlineSeconds = 60;
/// <summary>
/// Deadline тяжёлых команд каталога/backfill — 120 с (README L68: iter_dialogs 500, backfill).
/// </summary>
public const int LongDeadlineSeconds = 120;
// Detail недоступного telegram-service (Ruling 7: «недоступность сервиса → не подключён»).
private const string NotConnectedDetail = "Telegram не подключён";
// Контекст текущего тенанта (id — в metadata вызовов, Ruling 1).
private readonly ITenantContext _tenantContext;
// Транспорт gRPC telegram-service (канал + metadata).
private readonly TelegramGrpcConnection _connection;
// Логгер сбоев вызовов telegram-service.
private readonly ILogger<GrpcTelegramClient> _logger;
/// <summary>
/// Создаёт gRPC-адаптер гейта telegram-service.
/// </summary>
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами).</param>
/// <param name="connection">Транспорт telegram-service (singleton-канал + service-token).</param>
/// <param name="logger">Логгер сбоев.</param>
public GrpcTelegramClient(
ITenantContext tenantContext,
TelegramGrpcConnection connection,
ILogger<GrpcTelegramClient> logger)
{
ArgumentNullException.ThrowIfNull(tenantContext);
ArgumentNullException.ThrowIfNull(connection);
ArgumentNullException.ThrowIfNull(logger);
_tenantContext = tenantContext;
_connection = connection;
_logger = logger;
}
/// <inheritdoc />
public async Task<TelegramAccountStatusDto> StatusAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
GetStatusReply reply = await CallAsync(
tenantId, TimeSpan.FromSeconds(ShortDeadlineSeconds), ct,
(client, options) => client.GetStatusAsync(new GetStatusRequest(), options));
return new TelegramAccountStatusDto(
Phase: reply.Phase,
Connected: reply.Connected,
Listener: reply.Listener,
Account: reply.Account,
Error: reply.HasError ? reply.Error : null,
QrUrl: reply.HasQrUrl ? reply.QrUrl : null);
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "status");
}
}
/// <inheritdoc />
public async Task<TelegramAuthResultDto> StartPhoneAsync(string phone, int apiId, string apiHash, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
StartPhoneReply reply = await CallAsync(
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
(client, options) => client.StartPhoneAsync(
new StartPhoneRequest { Phone = phone ?? string.Empty, ApiId = apiId, ApiHash = apiHash ?? string.Empty },
options));
return new TelegramAuthResultDto(Phase: reply.Phase, QrUrl: null);
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "start_phone");
}
}
/// <inheritdoc />
public async Task<TelegramAuthResultDto> StartQrAsync(int apiId, string apiHash, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
StartQrReply reply = await CallAsync(
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
(client, options) => client.StartQrAsync(
new StartQrRequest { ApiId = apiId, ApiHash = apiHash ?? string.Empty },
options));
return new TelegramAuthResultDto(
Phase: reply.Phase,
QrUrl: string.IsNullOrEmpty(reply.QrUrl) ? null : reply.QrUrl);
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "start_qr");
}
}
/// <inheritdoc />
public async Task<string> SendCodeAsync(string code, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
SendCodeReply reply = await CallAsync(
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
(client, options) => client.SendCodeAsync(
new SendCodeRequest { Code = code ?? string.Empty }, options));
return reply.Phase;
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "send_code");
}
}
/// <inheritdoc />
public async Task<string> SendPasswordAsync(string password, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
SendPasswordReply reply = await CallAsync(
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
(client, options) => client.SendPasswordAsync(
new SendPasswordRequest { Password = password ?? string.Empty }, options));
return reply.Phase;
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "send_password");
}
}
/// <inheritdoc />
public async Task LogoutAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
await CallAsync(
tenantId, TimeSpan.FromSeconds(ShortDeadlineSeconds), ct,
(client, options) => client.LogoutAsync(new LogoutRequest(), options));
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "logout");
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<TelegramDialogEntryDto>> RefreshDialogsAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
RefreshDialogsReply reply = await CallAsync(
tenantId, TimeSpan.FromSeconds(LongDeadlineSeconds), ct,
(client, options) => client.RefreshDialogsAsync(new RefreshDialogsRequest(), options));
return MapEntries(reply.Entries);
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "refresh_dialogs");
}
}
/// <inheritdoc />
public async Task SetMonitorAsync(string dialogId, bool enabled, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
await CallAsync(
tenantId, TimeSpan.FromSeconds(ShortDeadlineSeconds), ct,
(client, options) => client.SetMonitorAsync(
new SetMonitorRequest { DialogId = dialogId ?? string.Empty, Enabled = enabled }, options));
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "set_monitor");
}
}
/// <inheritdoc />
public async Task SetMonitorAllAsync(bool enabled, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
await CallAsync(
tenantId, TimeSpan.FromSeconds(ShortDeadlineSeconds), ct,
(client, options) => client.SetMonitorAllAsync(
new SetMonitorAllRequest { Enabled = enabled }, options));
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "set_monitor_all");
}
}
/// <inheritdoc />
public async Task<int> BackfillAsync(string dialogId, bool force, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
BackfillReply reply = await CallAsync(
tenantId, TimeSpan.FromSeconds(LongDeadlineSeconds), ct,
(client, options) => client.BackfillAsync(
new BackfillRequest { DialogId = dialogId ?? string.Empty, Force = force }, options));
return reply.Processed;
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "backfill");
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(string dialogId, int limit, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
ReadRecentReply reply = await CallAsync(
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
(client, options) => client.ReadRecentAsync(
new ReadRecentRequest { DialogId = dialogId ?? string.Empty, Limit = limit }, options));
return reply.Messages
.Select(message => new TelegramRecentMessageDto(message.Id, message.Text, message.Time))
.ToList();
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "read_recent");
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(string query, int limit, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
SearchReply reply = await CallAsync(
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
(client, options) => client.SearchAsync(
new SearchRequest { Query = query ?? string.Empty, Limit = limit }, options));
return MapEntries(reply.Results);
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "search");
}
}
/// <inheritdoc />
public async Task<TelegramChannelInfoDto> InfoAsync(string dialogId, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
GetInfoReply reply = await CallAsync(
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
(client, options) => client.GetInfoAsync(
new GetInfoRequest { DialogId = dialogId ?? string.Empty }, options));
ChannelInfo info = reply.Info;
return new TelegramChannelInfoDto(
Id: info.Id,
Name: info.Name,
Username: info.Username,
Kind: info.Kind,
Hue: info.Hue,
Participants: info.HasParticipants ? info.Participants : null,
IsForum: info.IsForum);
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "get_info");
}
}
/// <inheritdoc />
public async Task<TelegramEvalReadDto> ReadForEvalAsync(string dialogId, int limit, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
ReadForEvalReply reply = await CallAsync(
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
(client, options) => client.ReadForEvalAsync(
new ReadForEvalRequest { DialogId = dialogId ?? string.Empty, Limit = limit }, options));
return new TelegramEvalReadDto(
Ok: reply.Ok,
Error: reply.HasError ? reply.Error : null,
Messages: reply.Messages
.Select(message => new TelegramEvalMessageDto(
Id: message.Id,
Text: message.Text,
DateMs: message.DateMs,
TopicId: message.HasTopicId ? message.TopicId : null,
TopicTitle: message.HasTopicTitle ? message.TopicTitle : null))
.ToList());
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "read_for_eval");
}
}
/// <inheritdoc />
public async Task JoinAsync(string username, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
await CallAsync(
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
(client, options) => client.JoinAsync(
new JoinRequest { Username = username ?? string.Empty }, options));
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "join");
}
}
/// <inheritdoc />
public async Task LeaveAsync(string dialogId, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
{
await CallAsync(
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
(client, options) => client.LeaveAsync(
new LeaveRequest { DialogId = dialogId ?? string.Empty }, options));
}
catch (Exception exception)
{
throw TranslateTransportFailure(exception, tenantId, "leave");
}
}
// Текущий тенант scope (без него metadata вызовов не имеет смысла).
// Возвращает: Идентификатор тенанта.
// Исключение InvalidOperationException: Вызов вне tenant-контекста (конфигурация).
private TenantId RequireTenant()
=> _tenantContext.TenantId
?? throw new InvalidOperationException(
"GrpcTelegramClient запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
// Выполняет unary RPC с metadata tenant-id/service-token, deadline и токеном отмены (Ruling 1).
// TReply: Тип ответа RPC.
// tenantId: Id тенанта (формат N).
// deadline: Лимит времени вызова (README контрактов L6274).
// ct: Токен отмены вызова.
// call: Вызов клиента (принимает клиент и CallOptions).
// Возвращает: Ответ RPC.
private async Task<TReply> CallAsync<TReply>(
TenantId tenantId, TimeSpan deadline, CancellationToken ct,
Func<TelegramService.TelegramServiceClient, CallOptions, AsyncUnaryCall<TReply>> call)
where TReply : class
{
TelegramService.TelegramServiceClient client = _connection.CreateClient();
var options = new CallOptions(
headers: _connection.CreateMetadata(tenantId.Value),
deadline: DateTime.UtcNow.Add(deadline),
cancellationToken: ct);
return await call(client, options);
}
// Нормализует транспортные сбои в RpcException «Telegram не подключён»; RpcException домена — как есть.
// Отмена по токену вызывающего пробрасывается без нормализации (не сбой сервиса). Доменные
// RPC-ошибки (INVALID_ARGUMENT/FAILED_PRECONDITION/…) несут канонический detail — их трогать нельзя:
// текст причины 1:1 уходит в {detail} эндпоинтов (Ruling 7/8).
// exception: Исключение вызова.
// tenantId: Id тенанта (лог).
// operation: Имя RPC (лог-аудит).
// Возвращает: Исключение для проброса: транспортный сбой — нормализованный RpcException.
private Exception TranslateTransportFailure(Exception exception, TenantId tenantId, string operation)
{
// Отмена по токену вызывающего — не ошибка сервиса (пробрасываем как обычно).
if (exception is OperationCanceledException)
{
return exception;
}
// Доменная RPC-ошибка сервиса (INVALID_ARGUMENT/FAILED_PRECONDITION/NOT_FOUND…) несёт канонический
// detail (Ruling 1) — пробрасываем без изменений, текст причины 1:1 уходит в {detail} эндпоинтов.
// Unavailable с detail (сервис сам ответил причиной) — тоже как есть.
if (exception is RpcException rpc &&
(rpc.StatusCode != StatusCode.Unavailable || !string.IsNullOrEmpty(rpc.Status.Detail)))
{
return rpc;
}
_logger.LogWarning(exception, "Telegram {Operation} недоступен (тенант {TenantId})", operation, tenantId.Value);
return new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail));
}
// Маппит записи каталога proto (DialogEntry) в контрактный DTO каталога/поиска (Ruling 7).
// entries: Записи каталога telegram-service.
// Возвращает: Записи в форме контракта (username → handle).
private static IReadOnlyList<TelegramDialogEntryDto> MapEntries(Google.Protobuf.Collections.RepeatedField<DialogEntry> entries)
{
return entries
.Select(entry => new TelegramDialogEntryDto(
Id: entry.Id,
Name: entry.Name,
Handle: entry.Username,
Kind: entry.Kind,
Hue: entry.Hue))
.ToList();
}
}
@@ -0,0 +1,51 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Pipeline.Application.Parse;
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 5, план Task 6 L370388).
/// </summary>
/// <remarks>
/// Адаптер поверх чистого ядра разбора модуля Pipeline (эталон — <see cref="LocalColumnSuggester"/>: ядро
/// владельца + тонкий адаптер): <see cref="ClassifyAsync"/> разбирает сообщение <see cref="LocalFieldsParser"/>
/// (pipeline.py _local_fields L718798 — заголовок, суть, стек/грейд/бюджет/контакты по меткам и fallback,
/// is_vacancy по hire-маркерам) и маппит в контрактный <see cref="AiParsedCardDto"/> через
/// <see cref="AiCardMapper.FromLocal"/> (Ruling 7 — модульный маппинг используют и локальные пути воркера):
/// бюджет нормализуется, контакты квалифицируются, блок «О заявке» заполняет только
/// legacy-суть, тип — маркерная гипотеза: is_vacancy_known=false, board=null («смысловые колонки до ИИ не
/// назначаем», python L954–958; карточку в колонку кладёт воркер после ContainerAccepts). Фильтр всегда
/// <c>{pass:true, skipped:true}</c> — реального ИИ-фильтра нет, а выключатель aiFilterEnabled порт не читает
/// (ветки выключателя отрабатывает воркер, как filter_incoming L190192 и L11031106). На этапе 6 адаптер
/// заменяется gRPC-клиентом ai-service с тем же контрактом. Scoped: LocalFieldsParser читает KV-настройки
/// тенанта (ISettingsStore → scoped TenantDbContext запроса).
/// </remarks>
/// <param name="fieldsParser">Локальный структуратор модуля Pipeline (маркеры hireMarkers/levelTerms — из настроек).</param>
public sealed class LocalAiClassifier(LocalFieldsParser fieldsParser) : IAiClassifier
{
/// <inheritdoc />
public Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct)
{
// Реального ИИ-фильтра нет (Ruling 5): локальная реализация всегда пропускает. Семантика ответа 1:1
// с ветками прототипа, где фильтр недоступен/выключен: {pass:true, reason:null, skipped:true}
// (filter_incoming L190198, сбой L11031106). Отсевы spam_ai/filter_ai станут достижимы этапом 6.
return Task.FromResult(new AiFilterResultDto(Pass: true, Reason: null, Skipped: true));
}
/// <inheritdoc />
public async Task<AiParsedCardDto> ClassifyAsync(string text, CancellationToken ct)
{
LocalParsedFields fields = await fieldsParser.ParseAsync(text, ct);
return AiCardMapper.FromLocal(fields, text);
}
}
@@ -0,0 +1,36 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
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 9, план Task 15).
/// </summary>
/// <remarks>
/// Регистрируется при <c>Services:Ai:UseLocal=true</c> (default, Ruling 6). Методы НЕ поддерживаются — на
/// этапе 6 локальной генерации ключей/оценки fit нет: Discovery-воркер сам выбирает эвристику (при
/// aiEnabled=false или сбое, python discovery_eval L186194), а generate-keywords-эндпоинт (Task 19) ловит
/// исключение и отдаёт мягкую ошибку {keywords: [], error} (Ruling 11). NotSupportedException — явный сигнал
/// «вызов порта в локальном режиме — ошибка сценария», чтобы будущий потребитель (Discovery) не получил
/// молча пустые ключи/ложный fit. Scoped-зависимостей нет (экземпляр лёгкий, как LocalAiClassifier на дефолты).
/// </remarks>
public sealed class LocalAiTools : IAiTools
{
// Сообщение исключения методов (локальный режим = ai-service не подключён).
private const string NotSupportedMessage =
"ИИ-инструменты доступны только при подключённом ai-service (Services:Ai:UseLocal=false).";
/// <inheritdoc />
public Task<AiGenerateKeywordsResultDto> GenerateKeywordsAsync(string description, CancellationToken ct)
=> throw new NotSupportedException(NotSupportedMessage);
/// <inheritdoc />
public Task<AiEvaluateFitResultDto> EvaluateFitAsync(
string text, string description, IReadOnlyCollection<string> keywords, CancellationToken ct)
=> throw new NotSupportedException(NotSupportedMessage);
}
@@ -0,0 +1,257 @@
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён (см. CardsService) —
// внутри Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство.
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
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>
/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика этапа 3 (Ruling 3, план Task 14).
/// </summary>
/// <remarks>
/// Реализует порт <see cref="IColumnSuggester"/> поверх порта <see cref="ICardStore"/> и чистого ядра
/// <see cref="SuggestHeuristics"/> (модуль Kanban): читает «Неразобранное» (ListInboxWithSourceAsync),
/// считает группы слов-тем и создаёт доски suggested=true (RulesJson {mode:"any", keywords:[…]},
/// note-обоснование, цвет/позицию даёт ContainersService) и раскладывает карточки (is_new=TRUE,
/// prev_col='inbox', matchHits по правилам доски — Ruling 2). Причины отказов — детерминированные
/// строки прототипа/Ruling 3: «мало карточек в «Неразобранном» (нужно от 6)», «похожие колонки уже
/// есть или нечего сгруппировать»; кулдаун повторов — KV-ключ <see cref="SettingsKeys.LastSuggestAt"/>
/// (прототип COOLDOWN_S L51 + «недавно предлагали — подождите» L95). Журнал CardMoves/ML-сигналы при
/// раскладке НЕ пишутся (suggest.py _assign_ids L220239 — это не действие пользователя, а предложение).
/// Suggest-keywords читает карточки вне trash/archive (suggest_domain_keywords L172178).
/// </remarks>
/// <param name="store">Порт хранилища (карточки «Неразобранного», переносы в колонки-доски).</param>
/// <param name="settings">KV-хранилище настроек тенанта (кулдаун lastSuggestAt, как KEY suggest.py L52).</param>
/// <param name="containersService">Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами.</param>
public sealed class LocalColumnSuggester(
ICardStore store,
ISettingsStore settings,
ContainersService containersService) : IColumnSuggester
{
// ── Кулдаун повторов (suggest.py COOLDOWN_S L51; KEY lastSuggestAt L52) ──
// Как часто можно переспрашивать ИИ-предложения: 20 минут (COOLDOWN_S = 20 * 60, L51).
private const long CooldownSeconds = 20 * 60;
// ── Детерминированные причины (Ruling 3; строки прототипа suggest.py) ──
// Кулдаун: повторный вызов слишком рано (suggest.py L95 «недавно предлагали — подождите»).
private const string CooldownReason = "недавно предлагали — подождите";
// Мало карточек в «Неразобранном»: {0} — порог MIN_INBOX (suggest.py L102).
private const string TooFewCardsReasonFormat = "мало карточек в «Неразобранном» (нужно от {0})";
// Групп не вышло: темы похожи на существующие доски или карточкам нечего разделить (L159).
private const string NothingGroupedReason = "похожие колонки уже есть или нечего сгруппировать";
// Мало карточек для ключей: нужно хотя бы 3 (suggest_domain_keywords L178).
private const string KeywordsTooFewReason = "мало карточек — сначала накопите заявки (нужно хотя бы 3)";
// Повторяющихся слов-маркеров не нашлось (suggest_domain_keywords L187, текст прототипа).
private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз";
// Режим правил колонки-предложения: «любое из условий» (suggest.py _rules_for L68 mode: any).
private const string RulesModeAny = "any";
/// <inheritdoc />
public async Task<SuggestColumnsResultDto> SuggestColumnsAsync(CancellationToken ct)
{
if (await WithinCooldownAsync(ct))
{
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: CooldownReason, Cooldown: true);
}
IReadOnlyList<CardDto> inbox = await store.ListInboxWithSourceAsync(ct);
if (inbox.Count < SuggestHeuristics.MinInbox)
{
return new SuggestColumnsResultDto(
Ok: false,
Created: 0,
Reason: string.Format(TooFewCardsReasonFormat, SuggestHeuristics.MinInbox),
Cooldown: false);
}
// Существующие (suggested=false) колонки: похожие темы не предлагаем (suggest.py L105, L138139).
IReadOnlyList<ContainerDto> containers = await containersService.ListAsync(ContainerSpaces.Dashboard, ct);
IReadOnlyList<string> existingNames = containers
.Where(container => !container.Suggested)
.Select(container => container.Name)
.ToList();
IReadOnlyList<SuggestedColumnPlan> plans = SuggestHeuristics.PlanColumns(inbox, existingNames);
if (plans.Count == 0)
{
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
}
int created = await StoreSuggestedColumnsAsync(inbox, plans, ct);
if (created == 0)
{
// Все колонки откатаны: карточки групп разобраны между чтением и раскладкой (suggest.py L153156).
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
}
await WriteLastSuggestAtAsync(ct);
return new SuggestColumnsResultDto(Ok: true, Created: created, Reason: null, Cooldown: false);
}
/// <inheritdoc />
public async Task<SuggestKeywordsResultDto> SuggestKeywordsAsync(CancellationToken ct)
{
// Выборка ключей — как suggest_domain_keywords L172176: карточки вне trash/archive с текстом,
// свежие 40 (ListCardsAsync(null) = «все, кроме taken», ORDER BY received_at DESC).
IReadOnlyList<CardDto> cards = await store.ListCardsAsync(new CardsQuery(null), ct);
List<string> texts = cards
.Where(card => card.Col != CardIds.Trash
&& card.Col != CardIds.Archive
&& card.SourceMsg.Trim().Length > 0)
.Take(SuggestHeuristics.KeywordsSampleLimit)
.Select(card => card.SourceMsg.Trim())
.ToList();
if (texts.Count < SuggestHeuristics.MinKeywordsSample)
{
return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsTooFewReason);
}
IReadOnlyList<string> keywords = SuggestHeuristics.SuggestDomainKeywords(texts);
if (keywords.Count == 0)
{
return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsEmptyReason);
}
return new SuggestKeywordsResultDto(Ok: true, Keywords: keywords, Reason: null);
}
// Создаёт доски-предложения по планам и раскладывает карточки (suggest.py L129156).
// inbox: Снимок «Неразобранного» (карточки планов берутся из него).
// plans: Планы колонок (SuggestHeuristics.PlanColumns, ≤4).
// ct: Токен отмены.
// Возвращает: Сколько досок реально создано (0 — все откатаны из-за разобранных карточек).
// Каждая доска — suggested=true c правилами {mode:"any", keywords:[тема]} и note-обоснованием.
// Перед раскладкой перечитывается «Неразобранное»: карточки, ушедшие из inbox между снимком и
// раскладкой (пользователь/тик), пропускаются — 1:1 со страховкой _assign_ids L231233. Если в
// колонку не легло ни одной карточки, пустая доска-предложение откатывается (_rollback_suggested
// L242248). matchHits считаются по правилам созданной доски (Ruling 2); журнал/ML не пишутся.
private async Task<int> StoreSuggestedColumnsAsync(
IReadOnlyList<CardDto> inbox,
IReadOnlyList<SuggestedColumnPlan> plans,
CancellationToken ct)
{
// Свежий снимок inbox — страховка «карточку уже разобрали» (suggest.py _assign_ids L231233).
HashSet<string> inboxIds = (await store.ListInboxWithSourceAsync(ct))
.Select(card => card.Id)
.ToHashSet(StringComparer.Ordinal);
Dictionary<string, string> textByCardId = inbox
.ToDictionary(card => card.Id, card => card.SourceMsg, StringComparer.Ordinal);
int created = 0;
foreach (SuggestedColumnPlan plan in plans)
{
var rules = new ContainerRulesDto(
Mode: RulesModeAny,
Direction: Array.Empty<string>(),
Keywords: [plan.Word],
Stack: Array.Empty<string>(),
Grade: Array.Empty<string>(),
Exclude: Array.Empty<string>(),
Budget: null);
ContainerDto container = await containersService.CreateAsync(new ContainerCreateDto(
Name: plan.Name,
Description: string.Empty,
Color: null,
Space: ContainerSpaces.Dashboard,
Kind: ContainerKinds.Board,
Suggested: true,
Rules: rules,
Note: plan.Note), ct);
int placed = 0;
foreach (string cardId in plan.CardIds)
{
if (!inboxIds.Contains(cardId))
{
continue; // карточка уже разобрана другим предложением/пользователем (L231–233)
}
IReadOnlyList<MatchHitDto> hits = KanbanColumnRules.ComputeHits(rules, textByCardId[cardId]);
await store.UpdateColumnAsync(new CardColumnUpdateDto(
CardId: cardId,
Col: container.Id,
IsNew: true,
PrevCol: CardIds.Inbox,
ArchivedAt: null,
MatchHits: hits), ct);
placed++;
}
if (placed == 0)
{
// Ничего не легло — пустое предложение не нужно (suggest.py L152156).
await containersService.DeleteAsync(container.Id, ct);
continue;
}
created++;
}
return created;
}
// Сработал ли кулдаун: с последнего успешного предложения прошло меньше 20 минут.
// Повреждённое/отсутствующее значение lastSuggestAt — кулдауна нет (как прототип: значение
// пишется только после успеха, L160–161; битый KV — дефолт «никогда»).
// ct: Токен отмены.
// Возвращает: True — повторный вызов слишком рано (ответ {ok:false, reason, cooldown:true}).
private async Task<bool> WithinCooldownAsync(CancellationToken ct)
{
SettingValue? row = await settings.GetAsync(SettingsKeys.LastSuggestAt, ct);
if (row is null)
{
return false;
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
if (document.RootElement.ValueKind != JsonValueKind.Number)
{
return false;
}
long lastSuggestAt = document.RootElement.GetInt64();
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() - lastSuggestAt < CooldownSeconds;
}
catch (JsonException)
{
return false; // повреждённое значение не должно блокировать предложения
}
}
// Записывает метку успешного предложения (suggest.py L160: set_setting(KEY, time.time())).
// ct: Токен отмены.
private Task WriteLastSuggestAtAsync(CancellationToken ct) =>
settings.SetAsync(
SettingsKeys.LastSuggestAt,
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
ct);
}
@@ -0,0 +1,169 @@
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
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="IMlClient"/> без внешнего ML-сервиса (Ruling 4, план Task 5 L266286).
/// </summary>
/// <remarks>
/// Этап 3: обучение копится локально в очередь MlOutbox (отправка в ML-сервис — фоновый воркер
/// этапа 6), счётчики learning/outbox читаются из таблиц схемы тенанта (Ruling 4). Поведение 1:1
/// с <c>backend/app/services/ml_client.py</c>: <c>PushAsync</c> = push L4049 (trim text/label,
/// пустые — no-op, text[:6000], id <c>mle_</c>+12 hex); <c>StatusAsync</c> = snapshot L138150
/// (learning = count(CardMoves), outbox = count(MlOutbox), ml/ai — KV-счётчики решений, на этапе 3
/// всегда 0 — не инкрементируются); <c>ResetAsync</c> = reset_model L110124 (чистится только
/// MlOutbox, журнал и KV не трогаются). Модель «не готова» до этапа 4 (ready=false, classes пусты,
/// learned=0, eval обнулён), предсказание — фиксированный «не уверен» (Ruling 5 L79–80); заглушка
/// «жива»: reachable=true. Зависимости — порты (ISettingsStore, IMlLearningStore), а не EF:
/// LocalMlClient остаётся unit-чистым (план Task 5). На этапе 6 адаптер заменяется gRPC-клиентом
/// с тем же контрактом (Ruling 4 L7374).
/// </remarks>
/// <param name="store">KV-хранилище настроек тенанта (таблица settings).</param>
/// <param name="learningStore">Хранилище обучения ML: очередь MlOutbox + счётчик журнала CardMoves.</param>
public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learningStore) : IMlClient
{
// Пустой словарь классов модели (неготовая модель, Ruling 5).
private static readonly IReadOnlyDictionary<string, double> EmptyClasses = new Dictionary<string, double>();
// Пустой словарь весов предсказания (неготовая модель, Ruling 5).
private static readonly IReadOnlyDictionary<string, double> EmptyScores = new Dictionary<string, double>();
/// <inheritdoc />
public async Task<MlStatusResponseDto> StatusAsync(CancellationToken ct)
{
// Статус самой модели: обучение копится в outbox, реальная модель появится этапом 4 —
// сейчас модель всегда не готова (Ruling 5).
var service = new MlServiceStatusDto(
Ready: false,
Classes: EmptyClasses,
Learned: 0,
Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0));
bool enabled = await ReadMlEnabledAsync(ct);
int mlDecisions = await ReadCounterAsync(SettingsKeys.MlDecisions, ct);
int aiDecisions = await ReadCounterAsync(SettingsKeys.AiDecisions, ct);
// Локальная статистика (ml_client.snapshot L138150): learning = count(CardMoves),
// outbox = count(MlOutbox) (Ruling 4); ml/ai — KV-счётчики РЕШЕНИЙ пайплайна (этап 4):
// на этапе 3 не инкрементируются и всегда 0.
int learning = await learningStore.CountLearningAsync(ct);
int outbox = await learningStore.CountOutboxAsync(ct);
var stats = new MlStatsDto(
Ml: mlDecisions,
Ai: aiDecisions,
Learning: learning,
Ready: service.Ready,
Classes: service.Classes,
Learned: service.Learned,
Reachable: true,
Outbox: outbox);
return new MlStatusResponseDto(Enabled: enabled, Service: service, Reachable: true, Stats: stats);
}
/// <inheritdoc />
public Task<MlPredictResultDto> PredictAsync(string text, CancellationToken ct)
{
// Неготовая модель ничего не решает (Ruling 5 L79–80) — текст не влияет на ответ.
return Task.FromResult(new MlPredictResultDto(
Take: false,
Label: null,
Scores: EmptyScores,
Hits: 0,
Ready: false,
Margin: null,
Terms: Array.Empty<string>(),
Type: null));
}
/// <inheritdoc />
public async Task<MlResetResultDto> ResetAsync(CancellationToken ct)
{
// Сброс 1:1 с reset_model (L110124): чистится только очередь обучения MlOutbox; журнал
// CardMoves и KV-счётчики не трогаются (Ruling 4, план L275276). Реального сервиса нет — ok.
await learningStore.ClearOutboxAsync(ct);
return new MlResetResultDto(Ok: true, Error: null);
}
/// <inheritdoc />
public async Task PushAsync(string text, string label, double delta, CancellationToken ct)
{
// Обучение гарантированно и локально (ml_client.push L4049): действие пользователя — строка
// очереди MlOutbox (отправку в ML-сервис делает воркер этапа 6). Общая логика (trim text/label,
// пустые — тихий no-op, text[:6000], id mle_+hex) — в MlOutboxQueue, общем для Local/Grpc-адаптеров.
await MlOutboxQueue.PushAsync(learningStore, text, label, delta, ct);
}
// Читает выключатель mlEnabled: «не false» (ml_routes.py L71) — false только при сохранённом JSON-false.
// ct: Токен отмены.
// Возвращает: True, если ключ отсутствует, повреждён или хранит JSON-true.
private async Task<bool> ReadMlEnabledAsync(CancellationToken ct)
{
SettingValue? row = await store.GetAsync(SettingsKeys.MlEnabled, ct);
if (row is null)
{
return SettingsDefaults.MlEnabled;
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
if (document.RootElement.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
return document.RootElement.GetBoolean();
}
}
catch (JsonException)
{
// Повреждённая строка — дефолт (мягкая семантика, как в SettingsService).
}
return SettingsDefaults.MlEnabled;
}
// Читает целочисленный счётчик (mlDecisions/aiDecisions); отсутствие/повреждение → 0.
// key: Внутренний KV-ключ счётчика.
// ct: Токен отмены.
// Возвращает: Значение счётчика из хранилища или 0.
private async Task<int> ReadCounterAsync(string key, CancellationToken ct)
{
SettingValue? row = await store.GetAsync(key, ct);
if (row is null)
{
return 0;
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
if (document.RootElement.ValueKind == JsonValueKind.Number
&& document.RootElement.TryGetInt64(out long wide))
{
return (int)Math.Clamp(wide, int.MinValue, int.MaxValue);
}
}
catch (JsonException)
{
// Повреждённая строка — 0 (мягкая семантика, как в SettingsService).
}
return 0;
}
}
@@ -0,0 +1,87 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
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="ITelegramGateway"/> без telegram-service (Ruling 6, план Task 13/14).
/// </summary>
/// <remarks>
/// Регистрируется как дефолт dev (до появления gRPC-клиента GrpcTelegramClient под флагом
/// Services:Telegram:UseLocal=false — Ruling 6): реальный telegram-service в dev не поднят, поэтому гейт
/// нейтрален — статус «idle/не подключён» (1:1 форма «сервис недоступен → idle-форма», Ruling 8), команды —
/// no-op, выборки пусты. На этапе 6 (Task 14 curl-приёмка) эндпоинты тестируются фейк-реализацией гейта в
/// тестах (не этой заглушкой); заглушка гарантирует разрешимость графа DI до подключения сервиса.
/// Команды подключения (StartPhone/StartQr/SendCode/SendPassword/Logout) и discovery-операции (Search/Info/
/// ReadForEval/Join/Leave) без сервиса не имеют смысла — их ветки эндпоинтов/воркера отдают ошибку
/// «Telegram не подключён» по статусу подключения (Ruling 7), сам гейт их не вызывает.
/// </remarks>
public sealed class LocalTelegramGateway : ITelegramGateway
{
// Фаза idle-формы (аккаунт не подключён — сервиса нет).
private const string IdlePhase = "idle";
/// <inheritdoc />
public Task<TelegramAccountStatusDto> StatusAsync(CancellationToken ct)
{
// «Сервис недоступен → idle-форма» (Ruling 8): connected=false, live-поля пусты.
return Task.FromResult(new TelegramAccountStatusDto(IdlePhase, false, false, string.Empty, null, null));
}
/// <inheritdoc />
public Task<TelegramAuthResultDto> StartPhoneAsync(string phone, int apiId, string apiHash, CancellationToken ct)
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
/// <inheritdoc />
public Task<TelegramAuthResultDto> StartQrAsync(int apiId, string apiHash, CancellationToken ct)
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
/// <inheritdoc />
public Task<string> SendCodeAsync(string code, CancellationToken ct) => Task.FromResult(IdlePhase);
/// <inheritdoc />
public Task<string> SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult(IdlePhase);
/// <inheritdoc />
public Task LogoutAsync(CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogEntryDto>> RefreshDialogsAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
/// <inheritdoc />
public Task SetMonitorAsync(string dialogId, bool enabled, CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
public Task SetMonitorAllAsync(bool enabled, CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
public Task<int> BackfillAsync(string dialogId, bool force, CancellationToken ct) => Task.FromResult(0);
/// <inheritdoc />
public Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(string dialogId, int limit, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]);
/// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(string query, int limit, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
/// <inheritdoc />
public Task<TelegramChannelInfoDto> InfoAsync(string dialogId, CancellationToken ct)
=> Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, SourceDefaults.DefaultHue, null, false));
/// <inheritdoc />
public Task<TelegramEvalReadDto> ReadForEvalAsync(string dialogId, int limit, CancellationToken ct)
=> Task.FromResult(new TelegramEvalReadDto(false, "no_history", []));
/// <inheritdoc />
public Task JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
public Task LeaveAsync(string dialogId, CancellationToken ct) => Task.CompletedTask;
}
@@ -0,0 +1,75 @@
using System.Security.Cryptography;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
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;
// Общая запись обучающего сигнала в очередь MlOutbox (ml_client.push L4049) для адаптеров IMlClient.
// Поведение 1:1 с прототипом и с LocalMlClient.PushAsync этапа 3: пустые после trim text/label —
// тихий no-op, text обрезается до 6000 символов (без разрыва суррогатной пары), id — mle_ +
// 12 случайных hex (store.uid L48). Обучение идёт ВСЕГДА (выключатель mlEnabled его не трогает) —
// и в Local-, и в gRPC-режиме сигнал сначала пишется в outbox, отправку в ml-service делает фоновый
// MlOutboxFlushScheduler (Ruling 6: PushAsync ВСЕГДА пишет MlOutbox).
internal static class MlOutboxQueue
{
// Максимальная длина текста обучающего примера (ml_client.push L48: text[:6000]).
internal const int MaxLearningTextLength = 6000;
// Случайный хвост id outbox: 6 байт → 12 hex-символов (прототип store.uid — uuid4().hex[:12]).
private const int OutboxIdRandomBytes = 6;
/// <summary>
/// Пишет строку очереди обучения: trim text/label (пустые — no-op), text[:6000], id mle_+hex.
/// </summary>
/// <param name="learningStore">Хранилище обучения (таблица MlOutbox схемы тенанта).</param>
/// <param name="text">Текст обучающего примера (source_msg карточки или title).</param>
/// <param name="label">Метка: id доски (<c>b_...</c>), <c>spam</c> либо <c>t:hire|t:order</c>.</param>
/// <param name="delta">Вес сигнала (1.0 — учить, −1.0 — снять метку).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Задача завершается после записи строки (отправку делает фоновый флашер).</returns>
public static async Task PushAsync(IMlLearningStore learningStore, string text, string label, double delta, CancellationToken ct)
{
string trimmedText = (text ?? string.Empty).Trim();
string trimmedLabel = (label ?? string.Empty).Trim();
if (trimmedText.Length == 0 || trimmedLabel.Length == 0)
{
return;
}
await learningStore.AddOutboxAsync(
NewOutboxId(),
TruncateText(trimmedText),
trimmedLabel,
delta,
ct);
}
// Генерирует id строки outbox: префикс mle_ + 12 случайных hex-символов (прототип store.uid).
// Возвращает: Короткий id записи очереди.
private static string NewOutboxId()
=> KanbanIdPrefixes.MlOutbox + Convert.ToHexString(RandomNumberGenerator.GetBytes(OutboxIdRandomBytes)).ToLowerInvariant();
// Обрезает текст до MaxLearningTextLength символов, не разбивая суррогатную пару на конце.
// text: Текст (уже trim-нут).
// Возвращает: Первые 6000 символов (или весь текст, если короче).
// .NET-срез идёт по UTF-16-единицам и может разбить суррогатную пару; Python-срез прототипа
// (text[:6000]) режет по code points — хвостовой high-surrogate убираем, чтобы в БД не ушла «битая» пара.
private static string TruncateText(string text)
{
if (text.Length <= MaxLearningTextLength)
{
return text;
}
string cut = text[..MaxLearningTextLength];
return char.IsHighSurrogate(cut[^1]) ? cut[..^1] : cut;
}
}
@@ -0,0 +1,104 @@
using System.Collections.Concurrent;
using Deal.Contracts.Integrations.Models;
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>
/// Кэш статуса ML-сервиса на тенанта (python ml_client L3031 + refresh_status L127135; Ruling 6).
/// </summary>
/// <remarks>
/// Кэш живёт 15 секунд и хранит последний известный статус + флаг <c>reachable</c>: обновление происходит
/// при вызове <c>GrpcMlClient.StatusAsync</c>, когда запись устарела/отсутствует; при сбое сервиса строка
/// остаётся со старыми данными и <c>reachable=false</c> (python L132135). Singleton: кэш переживает scope
/// запросов (в /api/ml/status и фоновых циклах тенант один и тот же), ключ — id тенанта (формат N).
/// </remarks>
public sealed class MlStatusCache
{
/// <summary>
/// Время жизни кэша статуса сервиса — 15 с (refresh_status python L3031).
/// </summary>
public const int CacheTtlSeconds = 15;
/// <summary>
/// Запись кэша: статус модели + доступность сервиса + момент обновления.
/// </summary>
/// <param name="Service">Последний известный статус модели тенанта.</param>
/// <param name="Reachable">Сервис ответил на последнем обновлении.</param>
/// <param name="UpdatedAtMs">Момент обновления (epoch-мс, UTC).</param>
public sealed record Snapshot(MlServiceStatusDto Service, bool Reachable, long UpdatedAtMs);
private readonly ConcurrentDictionary<string, Snapshot> _entries = new(StringComparer.Ordinal);
// Часы кэша (в проде — UtcNow; тесты подменяют для проверки TTL).
private readonly Func<DateTimeOffset> _utcNow;
/// <summary>
/// Создаёт кэш с системными часами (DateTimeOffset.UtcNow).
/// </summary>
public MlStatusCache()
: this(() => DateTimeOffset.UtcNow)
{
}
/// <summary>
/// Создаёт кэш с заданными часами (тесты TTL 15 с).
/// </summary>
/// <param name="utcNow">Источник текущего времени (UTC).</param>
public MlStatusCache(Func<DateTimeOffset> utcNow)
{
ArgumentNullException.ThrowIfNull(utcNow);
_utcNow = utcNow;
}
/// <summary>
/// Возвращает свежую запись кэша (возраст ≤ <see cref="CacheTtlSeconds"/>).
/// </summary>
/// <param name="tenantId">Id тенанта (формат N).</param>
/// <param name="snapshot">Свежая запись (если есть).</param>
/// <returns>True, если запись есть и не устарела.</returns>
public bool TryGetFresh(string tenantId, out Snapshot snapshot)
{
if (_entries.TryGetValue(tenantId, out Snapshot? cached))
{
long ageMs = _utcNow().ToUnixTimeMilliseconds() - cached.UpdatedAtMs;
if (ageMs < TimeSpan.FromSeconds(CacheTtlSeconds).TotalMilliseconds)
{
snapshot = cached;
return true;
}
}
snapshot = null!;
return false;
}
/// <summary>
/// Возвращает последнюю запись независимо от возраста (для «старые данные при сбое», python L134).
/// </summary>
/// <param name="tenantId">Id тенанта (формат N).</param>
/// <param name="snapshot">Последняя запись (если есть).</param>
/// <returns>True, если запись есть.</returns>
public bool TryGet(string tenantId, out Snapshot snapshot) => _entries.TryGetValue(tenantId, out snapshot!);
/// <summary>
/// Сохраняет запись статуса (момент обновления — сейчас).
/// </summary>
/// <param name="tenantId">Id тенанта (формат N).</param>
/// <param name="service">Статус модели.</param>
/// <param name="reachable">Доступность сервиса.</param>
public void Set(string tenantId, MlServiceStatusDto service, bool reachable)
{
_entries[tenantId] = new Snapshot(service, reachable, _utcNow().ToUnixTimeMilliseconds());
}
/// <summary>
/// Помечает запись устаревшей (сброс модели, python reset_model L123 — refresh после сброса).
/// </summary>
/// <param name="tenantId">Id тенанта (формат N).</param>
public void Invalidate(string tenantId) => _entries.TryRemove(tenantId, out _);
}
@@ -0,0 +1,81 @@
using Grpc.Core;
using Grpc.Health.V1;
using Grpc.Net.Client;
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>
/// Health-проба grpc.health.v1 автономных сервисов (ml/ai/telegram) для операторского health
/// (план Task 10: GET /api/operator/health, Ruling 3/6/9).
/// </summary>
/// <remarks>
/// Каждый вызов строит свой короткоживущий канал к <c>endpoint</c> сервиса (dev — без TLS, Ruling 2;
/// mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским сертификатом и
/// проверяет CA сервера — сертификаты передаются <see cref="MtlsCertificates"/> в конструктор) и спрашивает
/// Health.Check("") с дедлайном 3 с — health не должен висеть дольше таймаута. Классификация: ответ
/// <c>SERVING</c> → Reachable+Serving; ответ с иным статусом → Reachable без
/// Serving; таймаут/нет соединения (Unavailable/DeadlineExceeded, HTTP-транспорт) и сервис без health-контракта
/// (Unimplemented) → <see cref="ServiceHealthResult.Unreachable"/>.
/// </remarks>
public sealed class ServiceHealthProbe
{
/// <summary>
/// Дедлайн health-RPC, секунд (Ruling 3/9: операторский health отвечает за ~3 с на сервис).
/// </summary>
public const int HealthTimeoutSeconds = 3;
private readonly MtlsCertificates? _mtlsCertificates;
/// <summary>
/// Создаёт пробу; mTLS-каналы — при переданных сертификатах (иначе plaintext, dev).
/// </summary>
/// <param name="mtlsCertificates">Сертификаты mTLS (Ruling 6, Task 13): null — plaintext-канал.</param>
public ServiceHealthProbe(MtlsCertificates? mtlsCertificates = null)
{
_mtlsCertificates = mtlsCertificates;
}
/// <summary>
/// Проверяет health-контракт gRPC-сервиса по базовому адресу (grpc.health.v1, сервис "").
/// </summary>
/// <param name="endpoint">Базовый адрес сервиса (http://host:port; пустой/пробельный — ошибка аргумента).</param>
/// <param name="ct">Токен отмены вызывающего.</param>
/// <returns>Результат пробы (см. <see cref="ServiceHealthResult"/>).</returns>
public async Task<ServiceHealthResult> ProbeAsync(string endpoint, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(endpoint);
using SocketsHttpHandler? handler = _mtlsCertificates?.CreateClientHttpHandler();
GrpcChannelOptions channelOptions = new() { MaxRetryAttempts = 0, HttpHandler = handler };
using var channel = GrpcChannel.ForAddress(endpoint, channelOptions);
var client = new Health.HealthClient(channel);
try
{
HealthCheckResponse response = await client.CheckAsync(
new HealthCheckRequest { Service = string.Empty },
deadline: DateTime.UtcNow.AddSeconds(HealthTimeoutSeconds),
cancellationToken: ct);
return new ServiceHealthResult(
Reachable: true,
Serving: response.Status == HealthCheckResponse.Types.ServingStatus.Serving);
}
catch (RpcException exception) when (exception.IsCommunicationFailure())
{
return ServiceHealthResult.Unreachable;
}
catch (HttpRequestException)
{
// Ошибка транспорта HTTP/2 (DNS/соединение) — до gRPC-статуса не дошло.
return ServiceHealthResult.Unreachable;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Сработал дедлайн пробы (отмена вызывающего выше пробросилась бы дальше) — сервис не ответил за 3 с.
return ServiceHealthResult.Unreachable;
}
}
}
@@ -0,0 +1,259 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Deal.Grpc.Ai;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
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.Observability;
using Deal.SharedKernel.Tenants;
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>
/// Recorder расхода токенов (Ruling 3 этапа 7; история — этап 10, T2): успешный RPC ai-service
/// (Filter/Classify/GenerateKeywords/EvaluateFit) списывает usage с бюджета тенанта, копит lifetime-сумму
/// в tenant-KV и пишет событие в public.token_usage_events; локальный ML-вызов пишет событие (kind=ml).
/// </summary>
/// <remarks>
/// Точка вызова — та же, что у этапа 6 (GrpcAiClassifier/GrpcAiTools после успешного RPC; ML — GrpcMlClient/
/// LocalMlClient.Predict). Три учёта:
/// <list type="number">
/// <item><b>Бюджет периода</b> — <c>ITenantLimitStore.AddUsageAsync</c>: инкремент UsedTokens в public.tenant_limits
/// (тот же scoped DealDbContext запроса) с ленивым reset периода; источник истины бюджетного гейта Task 9.
/// Только для платных AI-вызовов (ML бюджет не расходует).</item>
/// <item><b>Lifetime-счётчик</b> — tenant-KV ключ aiTokenUsage ({prompt, completion, total}, существующий формат
/// этапа 6): «всего» за всё время. Только для AI (ML — локальный, aiTokenUsage не засоряет).</item>
/// <item><b>История событий</b> — <c>TokenUsageEventService.AppendAsync</c> (public.token_usage_events): провайдер,
/// модель, вид (ai|ml), токены; основа time-series аналитики оператора (этап 10, T3).</item>
/// </list>
/// Списание в tenant_limits выполняется только при Total&gt;0 (нулевой usage ответа моделью не заводит строку
/// лимита); lifetime-KV пишется всегда, как раньше. Scoped: пишет в KV-хранилище тенанта запроса (ISettingsStore
/// → scoped TenantDbContext), в public.tenant_limits/токен-историю — через scoped DealDbContext.
/// </remarks>
public sealed class TokenUsageRecorder
{
// Имена полей значения aiTokenUsage (1:1 с Usage ai.proto: prompt/completion/total).
private const string PromptField = "prompt";
private const string CompletionField = "completion";
private const string TotalField = "total";
// Оценка токенов по символам, символов на токен (конвенция проекта ai.proto Ruling 5: ≈chars/4).
private const int CharsPerToken = 4;
private readonly ISettingsStore _store;
private readonly ITenantLimitStore _tenantLimits;
private readonly ITenantContext _tenantContext;
private readonly TokenUsageEventService _events;
/// <summary>
/// Создаёт recorder расхода токенов.
/// </summary>
/// <param name="store">KV-хранилище настроек тенанта (ключ aiTokenUsage, lifetime-счётчик).</param>
/// <param name="tenantLimits">Хранилище лимитов бюджета (public.tenant_limits, списание периода).</param>
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal; tenantId списания/события).</param>
/// <param name="events">Сервис истории расхода (public.token_usage_events, этап 10).</param>
public TokenUsageRecorder(
ISettingsStore store,
ITenantLimitStore tenantLimits,
ITenantContext tenantContext,
TokenUsageEventService events)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(tenantLimits);
ArgumentNullException.ThrowIfNull(tenantContext);
ArgumentNullException.ThrowIfNull(events);
_store = store;
_tenantLimits = tenantLimits;
_tenantContext = tenantContext;
_events = events;
}
/// <summary>
/// Списывает usage ответа ai-service: (1) инкремент бюджета периода в tenant_limits, (2) lifetime-сумму
/// в KV aiTokenUsage, (3) событие истории (kind=ai). usage null — no-op (успешный RPC без оценки токенов).
/// </summary>
/// <param name="usage">Оценка токенов ответа (Usage ai.proto; reply без usage — нули; null — no-op).</param>
/// <param name="provider">Id активного провайдера (deepseek/openai/anthropic/…; событие истории).</param>
/// <param name="model">Модель провайдера (событие истории).</param>
/// <param name="ct">Токен отмены.</param>
public async Task AddAsync(Usage? usage, string provider, string model, CancellationToken ct)
{
if (usage is null)
{
return;
}
if (usage.Total > 0)
{
await _tenantLimits.AddUsageAsync(RequireTenantId(), usage.Total, ct);
}
await AddToLifetimeAsync(usage, ct);
// Прикладная метрика (этап 12, пакет A): счётчик вызовов/токенов ИИ — та же точка, что и событие
// token_usage_events (без tenantId в метках).
DealMetrics.RecordAiUsage(usage.Prompt, usage.Completion);
await RecordEventAsync(
provider,
model,
TokenUsageEventKinds.Ai,
promptTokens: usage.Prompt,
completionTokens: usage.Completion,
totalTokens: usage.Total,
ct);
}
/// <summary>
/// Записывает событие локального ML-вызова (kind=ml) с оценкой токенов по длине входного текста
/// (≈chars/4, конвенция ai.proto): бюджет/lifetime aiTokenUsage ML не затрагивает.
/// </summary>
/// <param name="text">Входной текст предсказания (оценка токенов запроса; null — 0).</param>
/// <param name="provider">Провайдер/источник события (для локальной ML-модели — "local").</param>
/// <param name="model">Модель/вид локального ML-вызова (событие истории).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Оценка токенов (для тестов/наблюдаемости).</returns>
public async Task<long> AddEstimatedAsync(string? text, string provider, string model, CancellationToken ct)
{
long promptTokens = EstimateTokens(text);
// Прикладная метрика (этап 12, пакет A): вызов локального ML + оценка токенов (та же точка,
// что и событие token_usage_events, kind=ml).
DealMetrics.RecordMlUsage(promptTokens);
await RecordEventAsync(
provider,
model,
TokenUsageEventKinds.Ml,
promptTokens: promptTokens,
completionTokens: 0,
totalTokens: promptTokens,
ct);
return promptTokens;
}
/// <summary>
/// Оценка токенов по символам (≈chars/4; конвенция проекта, ai.proto Ruling 5).
/// </summary>
/// <param name="text">Текст (null/пустой — 0).</param>
/// <returns>Оценка токенов (неотрицательная).</returns>
public static long EstimateTokens(string? text) =>
string.IsNullOrEmpty(text) ? 0 : text.Length / CharsPerToken;
// Пишет событие истории расхода токенов (public.token_usage_events, tenant-id текущего scope).
// provider: Провайдер/источник.
// model: Модель.
// kind: Вид вызова ai|ml.
// promptTokens: Токены запроса.
// completionTokens: Токены ответа.
// totalTokens: Всего токенов.
// ct: Токен отмены.
private async Task RecordEventAsync(
string provider,
string model,
string kind,
long promptTokens,
long completionTokens,
long totalTokens,
CancellationToken ct)
{
// Секретов в DetailJson нет: событие хранит только провайдера/модель/вид/токены.
await _events.AppendAsync(
new TokenUsageEventDto(
TenantId: RequireTenantId(),
At: default,
Provider: provider,
Model: model,
Kind: kind,
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
TotalTokens: totalTokens,
DetailJson: null),
ct);
}
// Текущий тенант 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(
"TokenUsageRecorder запрошен вне tenant-контекста (ITenantContext.TenantId == null/не-Guid).");
}
return id;
}
// Прибавляет usage к накопленному значению aiTokenUsage (lifetime-счётчик, формат этапа 6).
// usage: Оценка токенов ответа.
// ct: Токен отмены.
private async Task AddToLifetimeAsync(Usage usage, CancellationToken ct)
{
JsonObject? current = await ReadAsync(ct);
long prompt = ReadBound(current, PromptField) + usage.Prompt;
long completion = ReadBound(current, CompletionField) + usage.Completion;
long total = ReadBound(current, TotalField) + usage.Total;
var updated = new JsonObject
{
[PromptField] = ClampToUint(prompt),
[CompletionField] = ClampToUint(completion),
[TotalField] = ClampToUint(total),
};
await _store.SetAsync(SettingsKeys.AiTokenUsage, updated.ToJsonString(), ct);
}
// Текущее значение aiTokenUsage (JSON-объект) или null — строки нет.
// ct: Токен отмены.
// Возвращает: Объект значения или null.
private async Task<JsonObject?> ReadAsync(CancellationToken ct)
{
SettingValue? row = await _store.GetAsync(SettingsKeys.AiTokenUsage, ct);
if (row is null)
{
return null;
}
try
{
return JsonNode.Parse(row.ValueJson) as JsonObject;
}
catch (JsonException)
{
// Повреждённая строка — нули (мягкая семантика, как в SettingsService).
return null;
}
}
// Число поля значения: отсутствие/не-число → 0 (без clamp: сумма ограничивается при записи).
// value: Объект значения aiTokenUsage (может быть null).
// field: Имя поля (prompt/completion/total).
// Возвращает: Значение поля или 0.
private static long ReadBound(JsonObject? value, string field)
{
if (value is null || !value.TryGetPropertyValue(field, out JsonNode? node) || node is not JsonValue scalar)
{
return 0;
}
return scalar.TryGetValue<long>(out long number) && number > 0 ? number : 0;
}
// Ограничивает сумму диапазоном uint32 (proto Usage — uint; переполнение не ожидается).
// value: Накопленная сумма.
// Возвращает: Значение в диапазоне uint32.
private static JsonNode ClampToUint(long value)
=> JsonValue.Create(Math.Clamp(value, 0, uint.MaxValue))!;
}