Больше двух параметров — каждый на отдельной строке (закрывающая скобка в конце последнего); два и меньше — в одну строку. Правило добавлено в docs/spec/Код-стайл-Дейл.md; применено к 628 сигнатурам в 253 файлах.
174 lines
8.6 KiB
C#
174 lines
8.6 KiB
C#
using System.Text.Json;
|
||
using Deal.Contracts.Integrations;
|
||
using Deal.Contracts.Integrations.Models;
|
||
using Deal.Modules.Kanban.Application.Abstractions;
|
||
using Deal.Modules.Kanban.Application.Extensions;
|
||
using Deal.Modules.Kanban.Application.Models;
|
||
using Deal.Modules.Kanban.Application.Registrars;
|
||
using Deal.Modules.Kanban.Application.Services;
|
||
using Deal.Modules.Settings.Application.Abstractions;
|
||
using Deal.Modules.Settings.Application.Models;
|
||
using Deal.Modules.Settings.Application.Registrars;
|
||
using Deal.Modules.Settings.Application.Services;
|
||
using Deal.Infrastructure.Integrations.Abstractions;
|
||
using Deal.Infrastructure.Integrations.Exceptions;
|
||
using Deal.Infrastructure.Integrations.Extensions;
|
||
using Deal.Infrastructure.Integrations.Models;
|
||
using Deal.Infrastructure.Integrations.Options;
|
||
|
||
namespace Deal.Infrastructure.Integrations.Services;
|
||
|
||
/// <summary>
|
||
/// Локальная реализация <see cref="IMlClient"/> без внешнего ML-сервиса (Ruling 4, план Task 5 L266–286).
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Этап 3: обучение копится локально в очередь MlOutbox (отправка в ML-сервис — фоновый воркер
|
||
/// этапа 6), счётчики learning/outbox читаются из таблиц схемы тенанта (Ruling 4). Поведение 1:1
|
||
/// с <c>backend/app/services/ml_client.py</c>: <c>PushAsync</c> = push L40–49 (trim text/label,
|
||
/// пустые — no-op, text[:6000], id <c>mle_</c>+12 hex); <c>StatusAsync</c> = snapshot L138–150
|
||
/// (learning = count(CardMoves), outbox = count(MlOutbox), ml/ai — KV-счётчики решений, на этапе 3
|
||
/// всегда 0 — не инкрементируются); <c>ResetAsync</c> = reset_model L110–124 (чистится только
|
||
/// MlOutbox, журнал и KV не трогаются). Модель «не готова» до этапа 4 (ready=false, classes пусты,
|
||
/// learned=0, eval обнулён), предсказание — фиксированный «не уверен» (Ruling 5 L79–80); заглушка
|
||
/// «жива»: reachable=true. Зависимости — порты (ISettingsStore, IMlLearningStore), а не EF:
|
||
/// LocalMlClient остаётся unit-чистым (план Task 5). На этапе 6 адаптер заменяется gRPC-клиентом
|
||
/// с тем же контрактом (Ruling 4 L73–74).
|
||
/// </remarks>
|
||
/// <param name="store">KV-хранилище настроек тенанта (таблица settings).</param>
|
||
/// <param name="learningStore">Хранилище обучения ML: очередь MlOutbox + счётчик журнала CardMoves.</param>
|
||
public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learningStore) : IMlClient
|
||
{
|
||
// Пустой словарь классов модели (неготовая модель, Ruling 5).
|
||
private static readonly IReadOnlyDictionary<string, double> EmptyClasses = new Dictionary<string, double>();
|
||
|
||
// Пустой словарь весов предсказания (неготовая модель, Ruling 5).
|
||
private static readonly IReadOnlyDictionary<string, double> EmptyScores = new Dictionary<string, double>();
|
||
|
||
/// <inheritdoc />
|
||
public async Task<MlStatusResponseDto> StatusAsync(CancellationToken ct)
|
||
{
|
||
// Статус самой модели: обучение копится в outbox, реальная модель появится этапом 4 —
|
||
// сейчас модель всегда не готова (Ruling 5).
|
||
var service = new MlServiceStatusDto(
|
||
Ready: false,
|
||
Classes: EmptyClasses,
|
||
Learned: 0,
|
||
Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0));
|
||
|
||
bool enabled = await ReadMlEnabledAsync(ct);
|
||
int mlDecisions = await ReadCounterAsync(SettingsKeys.MlDecisions, ct);
|
||
int aiDecisions = await ReadCounterAsync(SettingsKeys.AiDecisions, ct);
|
||
|
||
// Локальная статистика (ml_client.snapshot L138–150): learning = count(CardMoves),
|
||
// outbox = count(MlOutbox) (Ruling 4); ml/ai — KV-счётчики РЕШЕНИЙ пайплайна (этап 4):
|
||
// на этапе 3 не инкрементируются и всегда 0.
|
||
int learning = await learningStore.CountLearningAsync(ct);
|
||
int outbox = await learningStore.CountOutboxAsync(ct);
|
||
|
||
var stats = new MlStatsDto(
|
||
Ml: mlDecisions,
|
||
Ai: aiDecisions,
|
||
Learning: learning,
|
||
Ready: service.Ready,
|
||
Classes: service.Classes,
|
||
Learned: service.Learned,
|
||
Reachable: true,
|
||
Outbox: outbox);
|
||
|
||
return new MlStatusResponseDto(Enabled: enabled, Service: service, Reachable: true, Stats: stats);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task<MlPredictResultDto> PredictAsync(string text, CancellationToken ct)
|
||
{
|
||
// Неготовая модель ничего не решает (Ruling 5 L79–80) — текст не влияет на ответ.
|
||
return Task.FromResult(new MlPredictResultDto(
|
||
Take: false,
|
||
Label: null,
|
||
Scores: EmptyScores,
|
||
Hits: 0,
|
||
Ready: false,
|
||
Margin: null,
|
||
Terms: Array.Empty<string>(),
|
||
Type: null));
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<MlResetResultDto> ResetAsync(CancellationToken ct)
|
||
{
|
||
// Сброс 1:1 с reset_model (L110–124): чистится только очередь обучения MlOutbox; журнал
|
||
// CardMoves и KV-счётчики не трогаются (Ruling 4, план L275–276). Реального сервиса нет — ok.
|
||
await learningStore.ClearOutboxAsync(ct);
|
||
return new MlResetResultDto(Ok: true, Error: null);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task PushAsync(
|
||
string text,
|
||
string label,
|
||
double delta,
|
||
CancellationToken ct)
|
||
{
|
||
// Обучение гарантированно и локально (ml_client.push L40–49): действие пользователя — строка
|
||
// очереди MlOutbox (отправку в ML-сервис делает воркер этапа 6). Общая логика (trim text/label,
|
||
// пустые — тихий no-op, text[:6000], id mle_+hex) — в MlOutboxQueue, общем для Local/Grpc-адаптеров.
|
||
await MlOutboxQueue.PushAsync(learningStore, text, label, delta, ct);
|
||
}
|
||
|
||
// Читает выключатель mlEnabled: «не false» (ml_routes.py L71) — false только при сохранённом JSON-false.
|
||
// ct: Токен отмены.
|
||
// Возвращает: True, если ключ отсутствует, повреждён или хранит JSON-true.
|
||
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;
|
||
}
|
||
}
|