Решение по 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.
374 lines
16 KiB
C#
374 lines
16 KiB
C#
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;
|
||
}
|
||
}
|