Нормализовать переводы строк в 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,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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user