Нормализовать переводы строк в LF
Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF, CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения), .editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize. Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
This commit is contained in:
@@ -1,240 +1,240 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net.Http.Headers;
|
||||
using Deal.Infrastructure.Integrations.Extensions;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-реализация проверки подключения к AI-провайдеру.
|
||||
/// </summary>
|
||||
public sealed class AiConnectionChecker : IAiConnectionChecker
|
||||
{
|
||||
/// <summary>
|
||||
/// Таймаут HTTP-запроса проверки в секундах; применяется DI-регистрацией клиента.
|
||||
/// </summary>
|
||||
public const int RequestTimeoutSeconds = 12;
|
||||
|
||||
|
||||
private const string LocalServerMessageTemplate = "Локальный сервер «{0}» (ping в проде)";
|
||||
|
||||
// Сообщение ветки «API-ключ не задан».
|
||||
private const string NoApiKeyMessage = "Не задан API-ключ";
|
||||
|
||||
// Сообщение успешной проверки (HTTP < 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)";
|
||||
|
||||
|
||||
// Значение 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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
return BuildResult(request, name, ok: false, message: TimeoutMessage);
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
return BuildResult(request, name, ok: false,
|
||||
message: string.Format(ConnectionErrorMessageTemplate, ConnectionErrorDetail(exception)));
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net.Http.Headers;
|
||||
using Deal.Infrastructure.Integrations.Extensions;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-реализация проверки подключения к AI-провайдеру.
|
||||
/// </summary>
|
||||
public sealed class AiConnectionChecker : IAiConnectionChecker
|
||||
{
|
||||
/// <summary>
|
||||
/// Таймаут HTTP-запроса проверки в секундах; применяется DI-регистрацией клиента.
|
||||
/// </summary>
|
||||
public const int RequestTimeoutSeconds = 12;
|
||||
|
||||
|
||||
private const string LocalServerMessageTemplate = "Локальный сервер «{0}» (ping в проде)";
|
||||
|
||||
// Сообщение ветки «API-ключ не задан».
|
||||
private const string NoApiKeyMessage = "Не задан API-ключ";
|
||||
|
||||
// Сообщение успешной проверки (HTTP < 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)";
|
||||
|
||||
|
||||
// Значение 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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
return BuildResult(request, name, ok: false, message: TimeoutMessage);
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
return BuildResult(request, name, ok: false,
|
||||
message: string.Format(ConnectionErrorMessageTemplate, ConnectionErrorDetail(exception)));
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,150 +1,150 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Deal.Grpc.Ai;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Собирает конфиг активного ИИ-провайдера для запросов ai-service.
|
||||
/// </summary>
|
||||
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";
|
||||
|
||||
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.</param>
|
||||
public AiProviderConfigBuilder(ISettingsStore store, ISecretCipher secretCipher)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
ArgumentNullException.ThrowIfNull(secretCipher);
|
||||
_store = store;
|
||||
_secretCipher = secretCipher;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Собирает ProviderConfig активного провайдера для тела запроса ai-service.
|
||||
/// </summary>
|
||||
/// <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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
using System.Text.Json.Nodes;
|
||||
using Deal.Grpc.Ai;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Собирает конфиг активного ИИ-провайдера для запросов ai-service.
|
||||
/// </summary>
|
||||
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";
|
||||
|
||||
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.</param>
|
||||
public AiProviderConfigBuilder(ISettingsStore store, ISecretCipher secretCipher)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
ArgumentNullException.ThrowIfNull(secretCipher);
|
||||
_store = store;
|
||||
_secretCipher = secretCipher;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Собирает ProviderConfig активного провайдера для тела запроса ai-service.
|
||||
/// </summary>
|
||||
/// <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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +1,106 @@
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Infrastructure.Integrations.Exceptions;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Декоратор бюджетного гейта порта <see cref="IAiClassifier"/>
|
||||
/// </summary>
|
||||
public sealed class BudgetedAiClassifier : IAiClassifier
|
||||
{
|
||||
// Текст ошибки вызова вне tenant-контекста (гейт читает лимиты по тенанту).
|
||||
private const string NoTenantContextText =
|
||||
"BudgetedAiClassifier запрошен вне tenant-контекста (ITenantContext.TenantId == null/не-Guid).";
|
||||
|
||||
// Причина запрета в логе (общий текст для исчерпания и приостановки).
|
||||
private const string GateDeniedLogText = "бюджет исчерпан или тенант приостановлен";
|
||||
|
||||
private readonly IAiClassifier _paidClassifier;
|
||||
private readonly LocalAiClassifier _localClassifier;
|
||||
private readonly ITenantLimitStore _tenantLimits;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ILogger<BudgetedAiClassifier> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт декоратор бюджетного гейта классификатора.
|
||||
/// </summary>
|
||||
/// <param name="paidClassifier">Платный исполнитель (gRPC-адаптер ai-service; вызывается только при Allowed).</param>
|
||||
/// <param name="localClassifier">Бесплатный локальный разбор/фильтр (fallback при запрете гейта).</param>
|
||||
/// <param name="tenantLimits">Хранилище лимитов бюджета (public.tenant_limits; источник гейта).</param>
|
||||
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal; tenantId запроса).</param>
|
||||
/// <param name="logger">Логгер переходов на локальный путь.</param>
|
||||
public BudgetedAiClassifier(
|
||||
IAiClassifier paidClassifier,
|
||||
LocalAiClassifier localClassifier,
|
||||
ITenantLimitStore tenantLimits,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<BudgetedAiClassifier> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paidClassifier);
|
||||
ArgumentNullException.ThrowIfNull(localClassifier);
|
||||
ArgumentNullException.ThrowIfNull(tenantLimits);
|
||||
ArgumentNullException.ThrowIfNull(tenantContext);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_paidClassifier = paidClassifier;
|
||||
_localClassifier = localClassifier;
|
||||
_tenantLimits = tenantLimits;
|
||||
_tenantContext = tenantContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct)
|
||||
{
|
||||
if (await IsPaidAllowedAsync(ct))
|
||||
{
|
||||
return await _paidClassifier.FilterAsync(text, ct);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"ИИ-фильтр: {Reason} — Local-пропуск (тенант {TenantId})", GateDeniedLogText, TenantIdForLog());
|
||||
return await _localClassifier.FilterAsync(text, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AiParsedCardDto> ClassifyAsync(string text, CancellationToken ct)
|
||||
{
|
||||
if (await IsPaidAllowedAsync(ct))
|
||||
{
|
||||
return await _paidClassifier.ClassifyAsync(text, ct);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"ИИ-классификация: {Reason} — Local-разбор (тенант {TenantId})", GateDeniedLogText, TenantIdForLog());
|
||||
return await _localClassifier.ClassifyAsync(text, ct);
|
||||
}
|
||||
|
||||
private async Task<bool> IsPaidAllowedAsync(CancellationToken ct)
|
||||
{
|
||||
BudgetStateDto state = await _tenantLimits.GetStateAsync(RequireTenantId(), ct);
|
||||
return state.Allowed;
|
||||
}
|
||||
|
||||
// Текущий тенант scope как Guid строки public.tenants (гейт читает лимиты по тенанту).
|
||||
// Возвращает: Идентификатор тенанта (Guid).
|
||||
// Исключение InvalidOperationException: Вызов вне tenant-контекста или не-Guid формат id.
|
||||
private Guid RequireTenantId()
|
||||
{
|
||||
TenantId? tenantId = _tenantContext.TenantId;
|
||||
if (tenantId is null || !Guid.TryParse(tenantId.Value.Value, out Guid id))
|
||||
{
|
||||
throw new InvalidOperationException(NoTenantContextText);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
// Id тенанта для лога («-» вне контекста — недостижимо после RequireTenantId).
|
||||
// Возвращает: Строка id тенанта.
|
||||
private string TenantIdForLog() => _tenantContext.TenantId?.Value ?? "-";
|
||||
}
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Infrastructure.Integrations.Exceptions;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Декоратор бюджетного гейта порта <see cref="IAiClassifier"/>
|
||||
/// </summary>
|
||||
public sealed class BudgetedAiClassifier : IAiClassifier
|
||||
{
|
||||
// Текст ошибки вызова вне tenant-контекста (гейт читает лимиты по тенанту).
|
||||
private const string NoTenantContextText =
|
||||
"BudgetedAiClassifier запрошен вне tenant-контекста (ITenantContext.TenantId == null/не-Guid).";
|
||||
|
||||
// Причина запрета в логе (общий текст для исчерпания и приостановки).
|
||||
private const string GateDeniedLogText = "бюджет исчерпан или тенант приостановлен";
|
||||
|
||||
private readonly IAiClassifier _paidClassifier;
|
||||
private readonly LocalAiClassifier _localClassifier;
|
||||
private readonly ITenantLimitStore _tenantLimits;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ILogger<BudgetedAiClassifier> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт декоратор бюджетного гейта классификатора.
|
||||
/// </summary>
|
||||
/// <param name="paidClassifier">Платный исполнитель (gRPC-адаптер ai-service; вызывается только при Allowed).</param>
|
||||
/// <param name="localClassifier">Бесплатный локальный разбор/фильтр (fallback при запрете гейта).</param>
|
||||
/// <param name="tenantLimits">Хранилище лимитов бюджета (public.tenant_limits; источник гейта).</param>
|
||||
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal; tenantId запроса).</param>
|
||||
/// <param name="logger">Логгер переходов на локальный путь.</param>
|
||||
public BudgetedAiClassifier(
|
||||
IAiClassifier paidClassifier,
|
||||
LocalAiClassifier localClassifier,
|
||||
ITenantLimitStore tenantLimits,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<BudgetedAiClassifier> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paidClassifier);
|
||||
ArgumentNullException.ThrowIfNull(localClassifier);
|
||||
ArgumentNullException.ThrowIfNull(tenantLimits);
|
||||
ArgumentNullException.ThrowIfNull(tenantContext);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_paidClassifier = paidClassifier;
|
||||
_localClassifier = localClassifier;
|
||||
_tenantLimits = tenantLimits;
|
||||
_tenantContext = tenantContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct)
|
||||
{
|
||||
if (await IsPaidAllowedAsync(ct))
|
||||
{
|
||||
return await _paidClassifier.FilterAsync(text, ct);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"ИИ-фильтр: {Reason} — Local-пропуск (тенант {TenantId})", GateDeniedLogText, TenantIdForLog());
|
||||
return await _localClassifier.FilterAsync(text, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AiParsedCardDto> ClassifyAsync(string text, CancellationToken ct)
|
||||
{
|
||||
if (await IsPaidAllowedAsync(ct))
|
||||
{
|
||||
return await _paidClassifier.ClassifyAsync(text, ct);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"ИИ-классификация: {Reason} — Local-разбор (тенант {TenantId})", GateDeniedLogText, TenantIdForLog());
|
||||
return await _localClassifier.ClassifyAsync(text, ct);
|
||||
}
|
||||
|
||||
private async Task<bool> IsPaidAllowedAsync(CancellationToken ct)
|
||||
{
|
||||
BudgetStateDto state = await _tenantLimits.GetStateAsync(RequireTenantId(), ct);
|
||||
return state.Allowed;
|
||||
}
|
||||
|
||||
// Текущий тенант scope как Guid строки public.tenants (гейт читает лимиты по тенанту).
|
||||
// Возвращает: Идентификатор тенанта (Guid).
|
||||
// Исключение InvalidOperationException: Вызов вне tenant-контекста или не-Guid формат id.
|
||||
private Guid RequireTenantId()
|
||||
{
|
||||
TenantId? tenantId = _tenantContext.TenantId;
|
||||
if (tenantId is null || !Guid.TryParse(tenantId.Value.Value, out Guid id))
|
||||
{
|
||||
throw new InvalidOperationException(NoTenantContextText);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
// Id тенанта для лога («-» вне контекста — недостижимо после RequireTenantId).
|
||||
// Возвращает: Строка id тенанта.
|
||||
private string TenantIdForLog() => _tenantContext.TenantId?.Value ?? "-";
|
||||
}
|
||||
|
||||
@@ -1,120 +1,120 @@
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Infrastructure.Integrations.Exceptions;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Декоратор бюджетного гейта порта <see cref="IAiTools"/>
|
||||
/// </summary>
|
||||
public sealed class BudgetedAiTools : IAiTools
|
||||
{
|
||||
private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна";
|
||||
|
||||
private const string SuspendedKeywordsError = "Тенант приостановлен — генерация ключевых слов недоступна";
|
||||
|
||||
private const string ExhaustedFitError = "ИИ-бюджет исчерпан — обработка в локальном режиме";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
_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);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"evaluate-fit: {Reason} — эвристика (тенант {TenantId})",
|
||||
DenyLogText(state),
|
||||
TenantIdForLog());
|
||||
throw new AiUnavailableException(
|
||||
state.Status == TenantStatuses.Suspended ? SuspendedFitError : ExhaustedFitError);
|
||||
}
|
||||
|
||||
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 ?? "-";
|
||||
}
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Infrastructure.Integrations.Exceptions;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Декоратор бюджетного гейта порта <see cref="IAiTools"/>
|
||||
/// </summary>
|
||||
public sealed class BudgetedAiTools : IAiTools
|
||||
{
|
||||
private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна";
|
||||
|
||||
private const string SuspendedKeywordsError = "Тенант приостановлен — генерация ключевых слов недоступна";
|
||||
|
||||
private const string ExhaustedFitError = "ИИ-бюджет исчерпан — обработка в локальном режиме";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
_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);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"evaluate-fit: {Reason} — эвристика (тенант {TenantId})",
|
||||
DenyLogText(state),
|
||||
TenantIdForLog());
|
||||
throw new AiUnavailableException(
|
||||
state.Status == TenantStatuses.Suspended ? SuspendedFitError : ExhaustedFitError);
|
||||
}
|
||||
|
||||
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 ?? "-";
|
||||
}
|
||||
|
||||
@@ -1,160 +1,160 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-источник курсов ЦБ РФ
|
||||
/// </summary>
|
||||
public sealed class CbrRateSource : IRatesSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Таймаут HTTP-запроса в секундах.
|
||||
/// </summary>
|
||||
public const int RequestTimeoutSeconds = 15;
|
||||
|
||||
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; может быть > 1).
|
||||
private const string NominalPropertyName = "Nominal";
|
||||
|
||||
// Базовая валюта ответа: курсы даются к рублю.
|
||||
private const string BaseCurrency = "RUB";
|
||||
|
||||
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)
|
||||
{
|
||||
_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))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
rates[currency.Name] = rate;
|
||||
}
|
||||
|
||||
return rates;
|
||||
}
|
||||
|
||||
private static bool TryParseCurrency(JsonProperty currency, out double rate)
|
||||
{
|
||||
rate = 0;
|
||||
if (currency.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonElement item = currency.Value;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-источник курсов ЦБ РФ
|
||||
/// </summary>
|
||||
public sealed class CbrRateSource : IRatesSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Таймаут HTTP-запроса в секундах.
|
||||
/// </summary>
|
||||
public const int RequestTimeoutSeconds = 15;
|
||||
|
||||
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; может быть > 1).
|
||||
private const string NominalPropertyName = "Nominal";
|
||||
|
||||
// Базовая валюта ответа: курсы даются к рублю.
|
||||
private const string BaseCurrency = "RUB";
|
||||
|
||||
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)
|
||||
{
|
||||
_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))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
rates[currency.Name] = rate;
|
||||
}
|
||||
|
||||
return rates;
|
||||
}
|
||||
|
||||
private static bool TryParseCurrency(JsonProperty currency, out double rate)
|
||||
{
|
||||
rate = 0;
|
||||
if (currency.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonElement item = currency.Value;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,210 +1,210 @@
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Grpc.Ai;
|
||||
using Deal.Infrastructure.Integrations.Exceptions;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-адаптер порта <see cref="IAiClassifier"/> к автономному ai-service.
|
||||
/// </summary>
|
||||
public sealed class GrpcAiClassifier : IAiClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Deadline RPC ai-service — 120 с
|
||||
/// </summary>
|
||||
public const int RpcDeadlineSeconds = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит текста сообщения фильтра.
|
||||
/// </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);
|
||||
|
||||
return new AiFilterResultDto(
|
||||
Pass: reply.Pass,
|
||||
Reason: reply.HasReason ? reply.Reason : null,
|
||||
Skipped: false);
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
_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).");
|
||||
|
||||
private CallOptions CallOptions(string tenantId, CancellationToken ct)
|
||||
=> new(
|
||||
headers: _connection.CreateMetadata(tenantId),
|
||||
deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)),
|
||||
cancellationToken: ct);
|
||||
|
||||
private static string ErrorText(RpcException exception)
|
||||
{
|
||||
string detail = exception.Status.Detail?.Trim() ?? string.Empty;
|
||||
return detail.Length > 0 ? detail : ServiceUnavailableText;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Grpc.Ai;
|
||||
using Deal.Infrastructure.Integrations.Exceptions;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-адаптер порта <see cref="IAiClassifier"/> к автономному ai-service.
|
||||
/// </summary>
|
||||
public sealed class GrpcAiClassifier : IAiClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Deadline RPC ai-service — 120 с
|
||||
/// </summary>
|
||||
public const int RpcDeadlineSeconds = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит текста сообщения фильтра.
|
||||
/// </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);
|
||||
|
||||
return new AiFilterResultDto(
|
||||
Pass: reply.Pass,
|
||||
Reason: reply.HasReason ? reply.Reason : null,
|
||||
Skipped: false);
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
_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).");
|
||||
|
||||
private CallOptions CallOptions(string tenantId, CancellationToken ct)
|
||||
=> new(
|
||||
headers: _connection.CreateMetadata(tenantId),
|
||||
deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)),
|
||||
cancellationToken: ct);
|
||||
|
||||
private static string ErrorText(RpcException exception)
|
||||
{
|
||||
string detail = exception.Status.Detail?.Trim() ?? string.Empty;
|
||||
return detail.Length > 0 ? detail : ServiceUnavailableText;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,199 +1,199 @@
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Grpc.Ai;
|
||||
using Deal.Infrastructure.Integrations.Exceptions;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-адаптер порта <see cref="IAiTools"/> к автономному ai-service.
|
||||
/// </summary>
|
||||
public sealed class GrpcAiTools : IAiTools
|
||||
{
|
||||
/// <summary>
|
||||
/// Deadline RPC ai-service — 120 с
|
||||
/// </summary>
|
||||
public const int RpcDeadlineSeconds = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит описания ниши generate-keywords.
|
||||
/// </summary>
|
||||
public const int MaxDescriptionCodePoints = 4000;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит текста сообщения evaluate-fit.
|
||||
/// </summary>
|
||||
public const int MaxEvalTextCodePoints = 4000;
|
||||
|
||||
// Текст фолбэк-ошибки, когда RPC-ошибка не несёт detail (сервис недоступен).
|
||||
private const string ServiceUnavailableText = "ai-service недоступен — повторите попытку через несколько секунд";
|
||||
|
||||
private const string FitReasonDefault = "подходит";
|
||||
|
||||
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)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
_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).");
|
||||
|
||||
private CallOptions CallOptions(string tenantId, CancellationToken ct)
|
||||
=> new(
|
||||
headers: _connection.CreateMetadata(tenantId),
|
||||
deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)),
|
||||
cancellationToken: ct);
|
||||
|
||||
private static string ErrorText(RpcException exception)
|
||||
{
|
||||
string detail = exception.Status.Detail?.Trim() ?? string.Empty;
|
||||
return detail.Length > 0 ? detail : ServiceUnavailableText;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Grpc.Ai;
|
||||
using Deal.Infrastructure.Integrations.Exceptions;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-адаптер порта <see cref="IAiTools"/> к автономному ai-service.
|
||||
/// </summary>
|
||||
public sealed class GrpcAiTools : IAiTools
|
||||
{
|
||||
/// <summary>
|
||||
/// Deadline RPC ai-service — 120 с
|
||||
/// </summary>
|
||||
public const int RpcDeadlineSeconds = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит описания ниши generate-keywords.
|
||||
/// </summary>
|
||||
public const int MaxDescriptionCodePoints = 4000;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит текста сообщения evaluate-fit.
|
||||
/// </summary>
|
||||
public const int MaxEvalTextCodePoints = 4000;
|
||||
|
||||
// Текст фолбэк-ошибки, когда RPC-ошибка не несёт detail (сервис недоступен).
|
||||
private const string ServiceUnavailableText = "ai-service недоступен — повторите попытку через несколько секунд";
|
||||
|
||||
private const string FitReasonDefault = "подходит";
|
||||
|
||||
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)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
_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).");
|
||||
|
||||
private CallOptions CallOptions(string tenantId, CancellationToken ct)
|
||||
=> new(
|
||||
headers: _connection.CreateMetadata(tenantId),
|
||||
deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)),
|
||||
cancellationToken: ct);
|
||||
|
||||
private static string ErrorText(RpcException exception)
|
||||
{
|
||||
string detail = exception.Status.Detail?.Trim() ?? string.Empty;
|
||||
return detail.Length > 0 ? detail : ServiceUnavailableText;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,373 +1,373 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Grpc.Ml;
|
||||
using Deal.Infrastructure.Integrations.Abstractions;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-адаптер порта IMlClient к автономному ml-service.
|
||||
/// </summary>
|
||||
public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Deadline Predict — 5 с
|
||||
/// </summary>
|
||||
public const int PredictDeadlineSeconds = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Deadline Status/Reset — 10 с
|
||||
/// </summary>
|
||||
public const int StatusDeadlineSeconds = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Deadline TrainBatch — 30 с
|
||||
/// </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;
|
||||
|
||||
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 истории расхода.</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));
|
||||
|
||||
await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct);
|
||||
return MapPredict(reply);
|
||||
}
|
||||
catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
_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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
_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());
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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-сервис не ответил — повторите попытку через несколько секунд";
|
||||
|
||||
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));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Grpc.Ml;
|
||||
using Deal.Infrastructure.Integrations.Abstractions;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-адаптер порта IMlClient к автономному ml-service.
|
||||
/// </summary>
|
||||
public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Deadline Predict — 5 с
|
||||
/// </summary>
|
||||
public const int PredictDeadlineSeconds = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Deadline Status/Reset — 10 с
|
||||
/// </summary>
|
||||
public const int StatusDeadlineSeconds = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Deadline TrainBatch — 30 с
|
||||
/// </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;
|
||||
|
||||
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 истории расхода.</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));
|
||||
|
||||
await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct);
|
||||
return MapPredict(reply);
|
||||
}
|
||||
catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
_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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
_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());
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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-сервис не ответил — повторите попытку через несколько секунд";
|
||||
|
||||
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));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,472 +1,472 @@
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Grpc.Telegram;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-адаптер порта <see cref="ITelegramGateway"/> к автономному telegram-service.
|
||||
/// </summary>
|
||||
public sealed class GrpcTelegramClient : ITelegramGateway
|
||||
{
|
||||
/// <summary>
|
||||
/// Deadline локальных команд статуса/зеркала — 10 с.
|
||||
/// </summary>
|
||||
public const int ShortDeadlineSeconds = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Deadline сетевых команд Telegram — 60 с.
|
||||
/// </summary>
|
||||
public const int CommandDeadlineSeconds = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Deadline тяжёлых команд каталога/backfill — 120 с.
|
||||
/// </summary>
|
||||
public const int LongDeadlineSeconds = 120;
|
||||
|
||||
private const string NotConnectedDetail = "Telegram не подключён";
|
||||
|
||||
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<TelegramSourceContentDto> ReadSourceAsync(
|
||||
string dialogId,
|
||||
long msgId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
ReadSourceReply reply = await CallAsync(
|
||||
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
|
||||
(client, options) => client.ReadSourceAsync(
|
||||
new ReadSourceRequest { DialogId = dialogId ?? string.Empty, MsgId = msgId }, options));
|
||||
return new TelegramSourceContentDto(
|
||||
reply.Found,
|
||||
reply.HasText ? reply.Text : null,
|
||||
reply.HasTime ? reply.Time : null);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw TranslateTransportFailure(exception, tenantId, "read_source");
|
||||
}
|
||||
}
|
||||
|
||||
/// <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).");
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private Exception TranslateTransportFailure(
|
||||
Exception exception,
|
||||
TenantId tenantId,
|
||||
string operation)
|
||||
{
|
||||
// Отмена по токену вызывающего — не ошибка сервиса (пробрасываем как обычно).
|
||||
if (exception is OperationCanceledException)
|
||||
{
|
||||
return exception;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Grpc.Telegram;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-адаптер порта <see cref="ITelegramGateway"/> к автономному telegram-service.
|
||||
/// </summary>
|
||||
public sealed class GrpcTelegramClient : ITelegramGateway
|
||||
{
|
||||
/// <summary>
|
||||
/// Deadline локальных команд статуса/зеркала — 10 с.
|
||||
/// </summary>
|
||||
public const int ShortDeadlineSeconds = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Deadline сетевых команд Telegram — 60 с.
|
||||
/// </summary>
|
||||
public const int CommandDeadlineSeconds = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Deadline тяжёлых команд каталога/backfill — 120 с.
|
||||
/// </summary>
|
||||
public const int LongDeadlineSeconds = 120;
|
||||
|
||||
private const string NotConnectedDetail = "Telegram не подключён";
|
||||
|
||||
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<TelegramSourceContentDto> ReadSourceAsync(
|
||||
string dialogId,
|
||||
long msgId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
ReadSourceReply reply = await CallAsync(
|
||||
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
|
||||
(client, options) => client.ReadSourceAsync(
|
||||
new ReadSourceRequest { DialogId = dialogId ?? string.Empty, MsgId = msgId }, options));
|
||||
return new TelegramSourceContentDto(
|
||||
reply.Found,
|
||||
reply.HasText ? reply.Text : null,
|
||||
reply.HasTime ? reply.Time : null);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw TranslateTransportFailure(exception, tenantId, "read_source");
|
||||
}
|
||||
}
|
||||
|
||||
/// <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).");
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private Exception TranslateTransportFailure(
|
||||
Exception exception,
|
||||
TenantId tenantId,
|
||||
string operation)
|
||||
{
|
||||
// Отмена по токену вызывающего — не ошибка сервиса (пробрасываем как обычно).
|
||||
if (exception is OperationCanceledException)
|
||||
{
|
||||
return exception;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Локальная реализация <see cref="IAiClassifier"/> без внешнего ИИ-сервиса.
|
||||
/// </summary>
|
||||
/// <param name="fieldsParser">Локальный структуратор модуля Pipeline (маркеры hireMarkers/levelTerms — из настроек).</param>
|
||||
public sealed class LocalAiClassifier(LocalFieldsParser fieldsParser) : IAiClassifier
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Локальная реализация <see cref="IAiClassifier"/> без внешнего ИИ-сервиса.
|
||||
/// </summary>
|
||||
/// <param name="fieldsParser">Локальный структуратор модуля Pipeline (маркеры hireMarkers/levelTerms — из настроек).</param>
|
||||
public sealed class LocalAiClassifier(LocalFieldsParser fieldsParser) : IAiClassifier
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,202 +1,202 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён (см. CardsService) —
|
||||
// внутри Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство.
|
||||
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика.
|
||||
/// </summary>
|
||||
/// <param name="store">Порт хранилища (карточки «Неразобранного», переносы в колонки-доски).</param>
|
||||
/// <param name="settings">KV-хранилище настроек тенанта.</param>
|
||||
/// <param name="containersService">Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами.</param>
|
||||
public sealed class LocalColumnSuggester(
|
||||
ICardStore store,
|
||||
ISettingsStore settings,
|
||||
ContainersService containersService) : IColumnSuggester
|
||||
{
|
||||
|
||||
private const long CooldownSeconds = 20 * 60;
|
||||
|
||||
|
||||
private const string CooldownReason = "недавно предлагали — подождите";
|
||||
|
||||
private const string TooFewCardsReasonFormat = "мало карточек в «Неразобранном» (нужно от {0})";
|
||||
|
||||
private const string NothingGroupedReason = "похожие колонки уже есть или нечего сгруппировать";
|
||||
|
||||
private const string KeywordsTooFewReason = "мало карточек — сначала накопите заявки (нужно хотя бы 3)";
|
||||
|
||||
private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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.Content.Text ?? string.Empty).Trim().Length > 0)
|
||||
.Take(SuggestHeuristics.KeywordsSampleLimit)
|
||||
.Select(card => (card.Content.Text ?? string.Empty).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);
|
||||
}
|
||||
|
||||
private async Task<int> StoreSuggestedColumnsAsync(
|
||||
IReadOnlyList<CardDto> inbox,
|
||||
IReadOnlyList<SuggestedColumnPlan> plans,
|
||||
CancellationToken ct)
|
||||
{
|
||||
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.Content.Text ?? string.Empty, 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)
|
||||
{
|
||||
await containersService.DeleteAsync(container.Id, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
created++;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
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; // повреждённое значение не должно блокировать предложения
|
||||
}
|
||||
}
|
||||
|
||||
private Task WriteLastSuggestAtAsync(CancellationToken ct) =>
|
||||
settings.SetAsync(
|
||||
SettingsKeys.LastSuggestAt,
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
|
||||
ct);
|
||||
}
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён (см. CardsService) —
|
||||
// внутри Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство.
|
||||
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика.
|
||||
/// </summary>
|
||||
/// <param name="store">Порт хранилища (карточки «Неразобранного», переносы в колонки-доски).</param>
|
||||
/// <param name="settings">KV-хранилище настроек тенанта.</param>
|
||||
/// <param name="containersService">Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами.</param>
|
||||
public sealed class LocalColumnSuggester(
|
||||
ICardStore store,
|
||||
ISettingsStore settings,
|
||||
ContainersService containersService) : IColumnSuggester
|
||||
{
|
||||
|
||||
private const long CooldownSeconds = 20 * 60;
|
||||
|
||||
|
||||
private const string CooldownReason = "недавно предлагали — подождите";
|
||||
|
||||
private const string TooFewCardsReasonFormat = "мало карточек в «Неразобранном» (нужно от {0})";
|
||||
|
||||
private const string NothingGroupedReason = "похожие колонки уже есть или нечего сгруппировать";
|
||||
|
||||
private const string KeywordsTooFewReason = "мало карточек — сначала накопите заявки (нужно хотя бы 3)";
|
||||
|
||||
private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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.Content.Text ?? string.Empty).Trim().Length > 0)
|
||||
.Take(SuggestHeuristics.KeywordsSampleLimit)
|
||||
.Select(card => (card.Content.Text ?? string.Empty).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);
|
||||
}
|
||||
|
||||
private async Task<int> StoreSuggestedColumnsAsync(
|
||||
IReadOnlyList<CardDto> inbox,
|
||||
IReadOnlyList<SuggestedColumnPlan> plans,
|
||||
CancellationToken ct)
|
||||
{
|
||||
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.Content.Text ?? string.Empty, 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)
|
||||
{
|
||||
await containersService.DeleteAsync(container.Id, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
created++;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
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; // повреждённое значение не должно блокировать предложения
|
||||
}
|
||||
}
|
||||
|
||||
private Task WriteLastSuggestAtAsync(CancellationToken ct) =>
|
||||
settings.SetAsync(
|
||||
SettingsKeys.LastSuggestAt,
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
|
||||
ct);
|
||||
}
|
||||
|
||||
@@ -1,133 +1,133 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Локальная реализация <see cref="IMlClient"/> без внешнего ML-сервиса.
|
||||
/// </summary>
|
||||
/// <param name="store">KV-хранилище настроек тенанта (таблица settings).</param>
|
||||
/// <param name="learningStore">Хранилище обучения ML: очередь MlOutbox + счётчик журнала CardMoves.</param>
|
||||
public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learningStore) : IMlClient
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, double> EmptyClasses = new Dictionary<string, double>();
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, double> EmptyScores = new Dictionary<string, double>();
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MlStatusResponseDto> StatusAsync(CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
await learningStore.ClearOutboxAsync(ct);
|
||||
return new MlResetResultDto(Ok: true, Error: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task PushAsync(
|
||||
string text,
|
||||
string label,
|
||||
double delta,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await MlOutboxQueue.PushAsync(learningStore, text, label, delta, ct);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Локальная реализация <see cref="IMlClient"/> без внешнего ML-сервиса.
|
||||
/// </summary>
|
||||
/// <param name="store">KV-хранилище настроек тенанта (таблица settings).</param>
|
||||
/// <param name="learningStore">Хранилище обучения ML: очередь MlOutbox + счётчик журнала CardMoves.</param>
|
||||
public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learningStore) : IMlClient
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, double> EmptyClasses = new Dictionary<string, double>();
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, double> EmptyScores = new Dictionary<string, double>();
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MlStatusResponseDto> StatusAsync(CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
await learningStore.ClearOutboxAsync(ct);
|
||||
return new MlResetResultDto(Ok: true, Error: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task PushAsync(
|
||||
string text,
|
||||
string label,
|
||||
double delta,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await MlOutboxQueue.PushAsync(learningStore, text, label, delta, ct);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
using System.Security.Cryptography;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
internal static class MlOutboxQueue
|
||||
{
|
||||
internal const int MaxLearningTextLength = 6000;
|
||||
|
||||
private const int OutboxIdRandomBytes = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Пишет строку очереди обучения
|
||||
/// </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>
|
||||
/// <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);
|
||||
}
|
||||
|
||||
private static string NewOutboxId()
|
||||
=> KanbanIdPrefixes.MlOutbox + Convert.ToHexString(RandomNumberGenerator.GetBytes(OutboxIdRandomBytes)).ToLowerInvariant();
|
||||
|
||||
private static string TruncateText(string text)
|
||||
{
|
||||
if (text.Length <= MaxLearningTextLength)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
string cut = text[..MaxLearningTextLength];
|
||||
return char.IsHighSurrogate(cut[^1]) ? cut[..^1] : cut;
|
||||
}
|
||||
}
|
||||
using System.Security.Cryptography;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
internal static class MlOutboxQueue
|
||||
{
|
||||
internal const int MaxLearningTextLength = 6000;
|
||||
|
||||
private const int OutboxIdRandomBytes = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Пишет строку очереди обучения
|
||||
/// </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>
|
||||
/// <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);
|
||||
}
|
||||
|
||||
private static string NewOutboxId()
|
||||
=> KanbanIdPrefixes.MlOutbox + Convert.ToHexString(RandomNumberGenerator.GetBytes(OutboxIdRandomBytes)).ToLowerInvariant();
|
||||
|
||||
private static string TruncateText(string text)
|
||||
{
|
||||
if (text.Length <= MaxLearningTextLength)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
string cut = text[..MaxLearningTextLength];
|
||||
return char.IsHighSurrogate(cut[^1]) ? cut[..^1] : cut;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
using Deal.Infrastructure.Integrations.Extensions;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Grpc.Core;
|
||||
using Grpc.Health.V1;
|
||||
using Grpc.Net.Client;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Health-проба grpc.health.v1 автономных сервисов
|
||||
/// </summary>
|
||||
public sealed class ServiceHealthProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Дедлайн health-RPC, секунд.
|
||||
/// </summary>
|
||||
public const int HealthTimeoutSeconds = 3;
|
||||
|
||||
private readonly MtlsCertificates? _mtlsCertificates;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт пробу; mTLS-каналы — при переданных сертификатах
|
||||
/// </summary>
|
||||
/// <param name="mtlsCertificates">Сертификаты mTLS: null — plaintext-канал.</param>
|
||||
public ServiceHealthProbe(MtlsCertificates? mtlsCertificates = null)
|
||||
{
|
||||
_mtlsCertificates = mtlsCertificates;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет health-контракт gRPC-сервиса по базовому адресу
|
||||
/// </summary>
|
||||
/// <param name="endpoint">Базовый адрес сервиса (http://host:port; пустой/пробельный — ошибка аргумента).</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
using Deal.Infrastructure.Integrations.Extensions;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Grpc.Core;
|
||||
using Grpc.Health.V1;
|
||||
using Grpc.Net.Client;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Health-проба grpc.health.v1 автономных сервисов
|
||||
/// </summary>
|
||||
public sealed class ServiceHealthProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Дедлайн health-RPC, секунд.
|
||||
/// </summary>
|
||||
public const int HealthTimeoutSeconds = 3;
|
||||
|
||||
private readonly MtlsCertificates? _mtlsCertificates;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт пробу; mTLS-каналы — при переданных сертификатах
|
||||
/// </summary>
|
||||
/// <param name="mtlsCertificates">Сертификаты mTLS: null — plaintext-канал.</param>
|
||||
public ServiceHealthProbe(MtlsCertificates? mtlsCertificates = null)
|
||||
{
|
||||
_mtlsCertificates = mtlsCertificates;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет health-контракт gRPC-сервиса по базовому адресу
|
||||
/// </summary>
|
||||
/// <param name="endpoint">Базовый адрес сервиса (http://host:port; пустой/пробельный — ошибка аргумента).</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,228 +1,228 @@
|
||||
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.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Deal.SharedKernel.Observability;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Recorder расхода токенов
|
||||
/// </summary>
|
||||
public sealed class TokenUsageRecorder
|
||||
{
|
||||
private const string PromptField = "prompt";
|
||||
|
||||
private const string CompletionField = "completion";
|
||||
|
||||
private const string TotalField = "total";
|
||||
|
||||
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">Сервис истории расхода.</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
|
||||
/// </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>
|
||||
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);
|
||||
DealMetrics.RecordAiUsage(usage.Prompt, usage.Completion);
|
||||
await RecordEventAsync(
|
||||
provider,
|
||||
model,
|
||||
TokenUsageEventKinds.Ai,
|
||||
promptTokens: usage.Prompt,
|
||||
completionTokens: usage.Completion,
|
||||
totalTokens: usage.Total,
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Записывает событие локального ML-вызова
|
||||
/// </summary>
|
||||
/// <param name="text">Входной текст предсказания (оценка токенов запроса; null — 0).</param>
|
||||
/// <param name="provider">Провайдер/источник события (для локальной ML-модели — "local").</param>
|
||||
/// <param name="model">Модель/вид локального ML-вызова (событие истории).</param>
|
||||
/// <returns>Оценка токенов (для тестов/наблюдаемости).</returns>
|
||||
public async Task<long> AddEstimatedAsync(
|
||||
string? text,
|
||||
string provider,
|
||||
string model,
|
||||
CancellationToken ct)
|
||||
{
|
||||
long promptTokens = EstimateTokens(text);
|
||||
DealMetrics.RecordMlUsage(promptTokens);
|
||||
await RecordEventAsync(
|
||||
provider,
|
||||
model,
|
||||
TokenUsageEventKinds.Ml,
|
||||
promptTokens: promptTokens,
|
||||
completionTokens: 0,
|
||||
totalTokens: promptTokens,
|
||||
ct);
|
||||
return promptTokens;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Оценка токенов по символам.
|
||||
/// </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;
|
||||
}
|
||||
|
||||
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))!;
|
||||
}
|
||||
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.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Deal.SharedKernel.Observability;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Recorder расхода токенов
|
||||
/// </summary>
|
||||
public sealed class TokenUsageRecorder
|
||||
{
|
||||
private const string PromptField = "prompt";
|
||||
|
||||
private const string CompletionField = "completion";
|
||||
|
||||
private const string TotalField = "total";
|
||||
|
||||
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">Сервис истории расхода.</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
|
||||
/// </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>
|
||||
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);
|
||||
DealMetrics.RecordAiUsage(usage.Prompt, usage.Completion);
|
||||
await RecordEventAsync(
|
||||
provider,
|
||||
model,
|
||||
TokenUsageEventKinds.Ai,
|
||||
promptTokens: usage.Prompt,
|
||||
completionTokens: usage.Completion,
|
||||
totalTokens: usage.Total,
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Записывает событие локального ML-вызова
|
||||
/// </summary>
|
||||
/// <param name="text">Входной текст предсказания (оценка токенов запроса; null — 0).</param>
|
||||
/// <param name="provider">Провайдер/источник события (для локальной ML-модели — "local").</param>
|
||||
/// <param name="model">Модель/вид локального ML-вызова (событие истории).</param>
|
||||
/// <returns>Оценка токенов (для тестов/наблюдаемости).</returns>
|
||||
public async Task<long> AddEstimatedAsync(
|
||||
string? text,
|
||||
string provider,
|
||||
string model,
|
||||
CancellationToken ct)
|
||||
{
|
||||
long promptTokens = EstimateTokens(text);
|
||||
DealMetrics.RecordMlUsage(promptTokens);
|
||||
await RecordEventAsync(
|
||||
provider,
|
||||
model,
|
||||
TokenUsageEventKinds.Ml,
|
||||
promptTokens: promptTokens,
|
||||
completionTokens: 0,
|
||||
totalTokens: promptTokens,
|
||||
ct);
|
||||
return promptTokens;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Оценка токенов по символам.
|
||||
/// </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;
|
||||
}
|
||||
|
||||
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))!;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user