Нормализовать переводы строк в 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:
Rustam Khalimov
2026-09-11 19:01:42 +03:00
parent 39c9bdc1b7
commit 713d554dc2
751 changed files with 94507 additions and 94486 deletions
@@ -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;
}
}