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; /// /// gRPC-адаптер порта IMlClient к автономному ml-service. /// public sealed class GrpcMlClient : IMlClient, IMlTrainClient { /// /// Deadline Predict — 5 с /// public const int PredictDeadlineSeconds = 5; /// /// Deadline Status/Reset — 10 с /// public const int StatusDeadlineSeconds = 10; /// /// Deadline TrainBatch — 30 с /// public const int TrainBatchDeadlineSeconds = 30; // Текст мягкой ошибки, когда сервис вернул ResetReply.ok=false без error (резерв). private const string DefaultResetError = "ML-сервис не смог сбросить модель"; // Пустой словарь весов предсказания/классов неготовой модели. private static readonly IReadOnlyDictionary EmptyScores = new Dictionary(); // Контекст текущего тенанта (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 _logger; /// /// Создаёт gRPC-адаптер клиента ML-сервиса. /// /// Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами). /// KV-хранилище настроек тенанта. /// Хранилище обучения ML (очередь MlOutbox + журнал). /// Транспорт ml-service (singleton-канал + service-token). /// Кэш статуса сервиса на тенанта (singleton). /// Recorder истории расхода. /// Логгер сбоев. public GrpcMlClient( ITenantContext tenantContext, ISettingsStore store, IMlLearningStore learningStore, MlGrpcConnection connection, MlStatusCache statusCache, TokenUsageRecorder usageRecorder, ILogger 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; } /// async Task 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); } /// async Task 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; } } /// async Task 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); } /// async Task IMlClient.PushAsync( string text, string label, double delta, CancellationToken ct) { await MlOutboxQueue.PushAsync(_learningStore, text, label, delta, ct); } /// async Task IMlTrainClient.TrainBatchAsync(IReadOnlyList 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 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(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(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(), 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 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 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; } }