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; /// /// Локальная реализация без внешнего ML-сервиса (Ruling 4, план Task 5 L266–286). /// /// /// Этап 3: обучение копится локально в очередь MlOutbox (отправка в ML-сервис — фоновый воркер /// этапа 6), счётчики learning/outbox читаются из таблиц схемы тенанта (Ruling 4). Поведение 1:1 /// с backend/app/services/ml_client.py: PushAsync = push L40–49 (trim text/label, /// пустые — no-op, text[:6000], id mle_+12 hex); StatusAsync = snapshot L138–150 /// (learning = count(CardMoves), outbox = count(MlOutbox), ml/ai — KV-счётчики решений, на этапе 3 /// всегда 0 — не инкрементируются); ResetAsync = 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). /// /// KV-хранилище настроек тенанта (таблица settings). /// Хранилище обучения ML: очередь MlOutbox + счётчик журнала CardMoves. public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learningStore) : IMlClient { // Пустой словарь классов модели (неготовая модель, Ruling 5). private static readonly IReadOnlyDictionary EmptyClasses = new Dictionary(); // Пустой словарь весов предсказания (неготовая модель, Ruling 5). private static readonly IReadOnlyDictionary EmptyScores = new Dictionary(); /// public async Task 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); } /// public Task 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(), Type: null)); } /// public async Task 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); } /// 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 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; } }