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

374 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 />
async Task<MlStatusResponseDto> IMlClient.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 />
async Task<MlPredictResultDto> IMlClient.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 />
async Task<MlResetResultDto> IMlClient.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 />
async Task IMlClient.PushAsync(
string text,
string label,
double delta,
CancellationToken ct)
{
await MlOutboxQueue.PushAsync(_learningStore, text, label, delta, ct);
}
/// <inheritdoc />
async Task<int> IMlTrainClient.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;
}
}