using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Models; using Deal.Modules.Cards.Application.Abstractions; using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.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.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Registrars; namespace Deal.Modules.Pipeline.Application.Services; /// /// Ручная проверка/разметка ML на сообщениях канала (§8 ML: «проверка на сообщении/канале»). /// /// /// /// Candidates: собирает реальные сообщения-кандидаты по каналу (dialogId) либо по всей выборке, если /// канал не задан, из трёх существующих источников тенанта — очереди обработки (), /// отсева () и карточек (), /// объединяя по (dialogId, msgId): карточка «перекрывает» отсев, отсев — очередь. Каждый кандидат несёт /// исходный текст и текущий вердикт; мнение ML добавляется прогнозом . /// /// /// Apply: ручное решение пользователя — «спам», «в колонку», «пропустить» — применяется через /// существующие сервисы/ядро: обучение ML — , перенос/корзина карточки — /// (он сам учит ML, дублирования сигналов нет), отсев сообщения из очереди — /// . Действие 1:1 с прототипом ml_routes.py L137–171 /// (skip/spam/board:<id>), плюс отсев ещё не обработанного сообщения и защита от неизвестной доски. /// /// public sealed class MlReviewService( IPipelineStore pipelineStore, ICardStore cardStore, CardsService cards, PipelineProcessingService processing, IMlClient mlClient) { /// /// Минимум сообщений в выборке кандидатов (кламп запроса 1..60, как прототип). /// public const int MinCandidates = 1; /// /// Максимум сообщений в выборке кандидатов (кламп запроса 1..60, как прототип). /// public const int MaxCandidates = 60; // Размер одного чтения из очереди/отсева при объединении кандидатов. private const int MaxScan = 500; // Длина текста кандидата в ответе (ml_routes.py L129: text[:600]). private const int TextPreviewLength = 600; /// /// Вердикт кандидата: по сообщению уже есть карточка. /// public const string VerdictCard = "card"; /// /// Вердикт кандидата: сообщение в отсеве. /// public const string VerdictRejected = "rejected"; /// /// Вердикт кандидата: сообщение ждёт обработки в очереди. /// public const string VerdictQueued = "queued"; /// /// Действие: пропустить без обучения (ml_routes.py L144–145). /// public const string ActionSkip = "skip"; /// /// Действие: спам — учим ML и (если есть) карточку в корзину (ml_routes.py L150–156). /// public const string ActionSpam = "spam"; /// /// Префикс действия «в колонку»: board:<id> (ml_routes.py L157). /// public const string ActionBoardPrefix = "board:"; /// /// 400 apply: неизвестная доска-цель (ml_routes.py L159–160). /// public const string UnknownBoardDetail = "Неизвестная доска"; /// /// 400 apply: неизвестное действие (ml_routes.py L169–170). /// public const string UnknownActionDetail = "Неизвестное действие"; // Причина отсева при ручной разметке «спам» ещё не обработанного сообщения. private const string ManualSpamReason = "ручная разметка ML: спам"; // Этап отсева при ручной разметке «спам» (отсев решением ML). private const string ManualSpamStage = "spam_ml"; // Источник решения при ручной разметке. private const string ManualSource = "ml"; // Вес обучающего сигнала ручной разметки — действие пользователя (ml_client.py USER_WEIGHT 1.0). private const double UserPushWeight = 1.0; /// /// Отбирает сообщения-кандидаты для проверки ML по каналу и/или размеру выборки. /// /// Id канала/диалога; пусто — выборка по всем источникам тенанта. /// Сколько последних сообщений вернуть (кламп 1.., дефолт вызывающего). /// Токен отмены. /// Кандидаты (свежие первыми): текст, текущий вердикт и мнение ML по каждому. public async Task> CandidatesAsync( string? dialogId, int limit, CancellationToken ct) { int take = Math.Clamp(limit, MinCandidates, MaxCandidates); string dialog = (dialogId ?? string.Empty).Trim(); IReadOnlyList queue = await pipelineStore.ListAsync(status: null, MaxScan, ct); IReadOnlyList rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct); IReadOnlyList cardList = await cardStore.ListCardsAsync(new CardsQuery(null), ct); // Объединение по (dialogId, msgId): очередь → отсев → карточка (последняя перекрывает предыдущие). var merged = new Dictionary<(string Dialog, long MsgId), MlCandidateDto>(); foreach (QueueItemDto row in queue) { if (row.MsgId is not { } msgId || !MatchesDialog(dialog, row.DialogId)) { continue; } merged[(row.DialogId, msgId)] = BuildQueued(row, msgId); } foreach (RejectedItemDto row in rejected) { if (row.MsgId is not { } msgId || !MatchesDialog(dialog, row.DialogId)) { continue; } merged[(row.DialogId, msgId)] = BuildRejected(row, msgId); } foreach (CardDto card in cardList) { if (card.SourceMsgId is not { } msgId || !MatchesDialog(dialog, card.SourceDialogId)) { continue; } merged[(card.SourceDialogId, msgId)] = BuildCard(card, msgId); } List ordered = merged.Values .OrderByDescending(candidate => candidate.Time ?? 0) .Take(take) .ToList(); var withPredictions = new List(ordered.Count); foreach (MlCandidateDto candidate in ordered) { withPredictions.Add(candidate with { Pred = await PredictSafelyAsync(candidate.Text, ct) }); } return withPredictions; } /// /// Применяет ручное решение по сообщению: обучение ML + перенос/корзина/отсев. /// /// Id канала/диалога сообщения. /// Id исходного сообщения. /// Действие: skip | spam | board:<id>. /// Токен отмены. /// Результат решения; null — исходное сообщение не найдено (404-семантика эндпоинта). public async Task ApplyAsync( string dialogId, long msgId, string? action, CancellationToken ct) { string normalized = (action ?? string.Empty).Trim(); string dialog = (dialogId ?? string.Empty).Trim(); CardDto? card = await cardStore.GetCardBySourceAsync(dialog, msgId, ct); string? text = await FindTextAsync(dialog, msgId, card, ct); if (string.IsNullOrWhiteSpace(text)) { return null; // 404: исходное сообщение не найдено } if (normalized == ActionSkip) { return new MlApplyResult(Error: null, Ok: true, Learned: false, Moved: null, LeadId: null); } if (normalized == ActionSpam) { return await ApplySpamAsync(dialog, msgId, card, text, ct); } if (normalized.StartsWith(ActionBoardPrefix, StringComparison.Ordinal)) { string boardId = normalized[ActionBoardPrefix.Length..].Trim(); return await ApplyBoardAsync(dialog, msgId, boardId, card, text, ct); } return new MlApplyResult(UnknownActionDetail, Ok: false, Learned: false, Moved: null, LeadId: null); } // Действие «спам»: карточку — в корзину (с обучением), сообщение из очереди — в отсев; иначе учим ML. // dialog: Id диалога. // msgId: Id сообщения. // card: Карточка сообщения (null — сообщение не становилось карточкой). // text: Текст сообщения. // ct: Токен отмены. // Возвращает: Результат решения. private async Task ApplySpamAsync( string dialog, long msgId, CardDto? card, string text, CancellationToken ct) { if (card is not null) { // TrashCardAsync(teach:true) сам шлёт обучающий сигнал «спам» — второй сигнал не нужен. CardDto? trashed = await cards.TrashCardAsync(card.Id, teach: true, ct); return new MlApplyResult(null, Ok: true, Learned: true, Moved: "trash", LeadId: trashed?.Id ?? card.Id); } await mlClient.PushAsync(text, MlLearningLabels.Spam, UserPushWeight, ct); // Сообщение ещё в очереди — отсеиваем его (решение пользователя), снимая строку. QueueItemDto? queued = await FindQueuedAsync(dialog, msgId, ct); if (queued is not null) { await processing.RejectAsync(new RejectRecord { DialogId = queued.DialogId, MsgId = queued.MsgId, Text = queued.Text, ChannelName = queued.Channel.Name, ChannelHandle = queued.Channel.Handle, ChannelHue = queued.Channel.Hue, MsgAtMs = queued.MsgAtMs, Source = ManualSource, Stage = ManualSpamStage, Reason = ManualSpamReason, Kw = string.Empty, }, ct); await pipelineStore.RemoveAsync(queued.Id, ct); } return new MlApplyResult(null, Ok: true, Learned: true, Moved: null, LeadId: null); } // Действие «в колонку»: карточку — переносим, уже в колонке — только учим; иначе учим ML. // dialog: Id диалога. // msgId: Id сообщения. // boardId: Id колонки-цели (inbox или b_...). // card: Карточка сообщения (null — сообщение не становилось карточкой). // text: Текст сообщения. // ct: Токен отмены. // Возвращает: Результат решения. private async Task ApplyBoardAsync( string dialog, long msgId, string boardId, CardDto? card, string text, CancellationToken ct) { if (boardId != CardIds.Inbox && await cardStore.GetContainerAsync(boardId, ct) is null) { return new MlApplyResult(UnknownBoardDetail, Ok: false, Learned: false, Moved: null, LeadId: null); } if (card is null) { await mlClient.PushAsync(text, boardId, UserPushWeight, ct); return new MlApplyResult(null, Ok: true, Learned: true, Moved: null, LeadId: null); } if (card.Col == boardId) { // Повторная разметка карточки в той же колонке — только обучение (ml_routes.py L163–165). await mlClient.PushAsync(text, boardId, UserPushWeight, ct); return new MlApplyResult(null, Ok: true, Learned: true, Moved: null, LeadId: card.Id); } // MoveDashboardCardAsync сам учит колонку (toCol ≠ inbox) — второй сигнал не нужен. CardResultDto moved = await cards.MoveDashboardCardAsync(card.Id, boardId, ct); if (moved.Error is not null) { return new MlApplyResult(moved.Error, Ok: false, Learned: false, Moved: null, LeadId: card.Id); } return new MlApplyResult(null, Ok: true, Learned: true, Moved: boardId, LeadId: card.Id); } // Текст исходного сообщения: source_msg карточки, иначе текст строки очереди/записи отсева. // dialog: Id диалога. // msgId: Id сообщения. // card: Карточка сообщения (уже прочитана вызывающим). // ct: Токен отмены. // Возвращает: Непустой текст либо null, если сообщения нет ни в одном источнике. private async Task FindTextAsync( string dialog, long msgId, CardDto? card, CancellationToken ct) { if (card is not null && !string.IsNullOrWhiteSpace(card.SourceMsg)) { return card.SourceMsg; } QueueItemDto? queued = await FindQueuedAsync(dialog, msgId, ct); if (queued is not null && !string.IsNullOrWhiteSpace(queued.Text)) { return queued.Text; } IReadOnlyList rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct); foreach (RejectedItemDto row in rejected) { if (row.MsgId == msgId && string.Equals(row.DialogId, dialog, StringComparison.Ordinal)) { return row.Text; } } return null; } // Строка очереди сообщения (для отсева при ручной разметке «спам»). // dialog: Id диалога. // msgId: Id сообщения. // ct: Токен отмены. // Возвращает: Строка очереди либо null — сообщение уже обработано/не в очереди. private async Task FindQueuedAsync( string dialog, long msgId, CancellationToken ct) { IReadOnlyList queue = await pipelineStore.ListAsync(status: null, MaxScan, ct); foreach (QueueItemDto row in queue) { if (row.MsgId == msgId && string.Equals(row.DialogId, dialog, StringComparison.Ordinal)) { return row; } } return null; } // Прогноз ML по тексту с защитой от сбоя (недоступный сервис — кандидат без мнения). // text: Текст сообщения. // ct: Токен отмены. // Возвращает: Мнение ML либо null при сбое. private async Task PredictSafelyAsync(string text, CancellationToken ct) { if (string.IsNullOrWhiteSpace(text)) { return null; } try { MlPredictResultDto result = await mlClient.PredictAsync(text, ct); return new MlCandidatePredictionDto(result.Take, result.Label, result.Scores); } catch (Exception) { return null; } } // Соответствует ли диалог фильтру канала (пустой фильтр — все диалоги). // filter: Запрошенный канал (пусто — без фильтра). // dialogId: Диалог сообщения. // Возвращает: True — кандидат подходит. private static bool MatchesDialog(string filter, string dialogId) => filter.Length == 0 || string.Equals(filter, dialogId, StringComparison.Ordinal); // Кандидат из строки очереди (вердикт queued). // row: Строка очереди. // msgId: Id сообщения. // Возвращает: Кандидат. private static MlCandidateDto BuildQueued(QueueItemDto row, long msgId) => new() { Id = msgId, DialogId = row.DialogId, Text = Truncate(row.Text), Time = row.MsgAtMs == 0 ? null : row.MsgAtMs, Lead = false, Verdict = VerdictQueued, Stage = row.Status, }; // Кандидат из записи отсева (вердикт rejected). // row: Запись отсева. // msgId: Id сообщения. // Возвращает: Кандидат. private static MlCandidateDto BuildRejected(RejectedItemDto row, long msgId) => new() { Id = msgId, DialogId = row.DialogId, Text = Truncate(row.Text), Time = row.MsgAtMs == 0 ? null : row.MsgAtMs, Lead = false, Verdict = VerdictRejected, Stage = row.Stage, Reason = row.Reason, }; // Кандидат из карточки (вердикт card). // card: Карточка. // msgId: Id сообщения. // Возвращает: Кандидат. private static MlCandidateDto BuildCard(CardDto card, long msgId) => new() { Id = msgId, DialogId = card.SourceDialogId, Text = Truncate(card.SourceMsg), Time = card.ReceivedAtMs == 0 ? null : card.ReceivedAtMs, Lead = true, Verdict = VerdictCard, Col = card.Col, }; // Обрезает текст кандидата до TextPreviewLength символов. // text: Исходный текст. // Возвращает: Обрезанный текст. private static string Truncate(string text) => text.Length <= TextPreviewLength ? text : text[..TextPreviewLength]; }