Files
Deal/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs
T
Rustam Khalimov e3a2692507 Добить структуру Api, Contracts, SharedKernel и сервисов
Deal.Api/Http -> Services/Models/Extensions; Contracts/Integrations
и SharedKernel/Tenants -> Abstractions/Models; extension-классы
telegram/ml -> Extensions. namespace/using/FQN мигрированы, using
дедуплицированы.
2026-09-11 13:25:18 +03:00

448 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Ручная проверка/разметка ML на сообщениях канала (§8 ML: «проверка на сообщении/канале»).
/// </summary>
/// <remarks>
/// <para>
/// <b>Candidates</b>: собирает реальные сообщения-кандидаты по каналу (dialogId) либо по всей выборке, если
/// канал не задан, из трёх существующих источников тенанта — очереди обработки (<see cref="IPipelineStore.ListAsync"/>),
/// отсева (<see cref="IPipelineStore.ListPageAsync"/>) и карточек (<see cref="ICardStore.ListCardsAsync"/>),
/// объединяя по (dialogId, msgId): карточка «перекрывает» отсев, отсев — очередь. Каждый кандидат несёт
/// исходный текст и текущий вердикт; мнение ML добавляется прогнозом <see cref="IMlClient.PredictAsync"/>.
/// </para>
/// <para>
/// <b>Apply</b>: ручное решение пользователя — «спам», «в колонку», «пропустить» — применяется через
/// существующие сервисы/ядро: обучение ML — <see cref="IMlClient.PushAsync"/>, перенос/корзина карточки —
/// <see cref="CardsService"/> (он сам учит ML, дублирования сигналов нет), отсев сообщения из очереди —
/// <see cref="PipelineProcessingService.RejectAsync"/>. Действие 1:1 с прототипом ml_routes.py L137171
/// (skip/spam/board:&lt;id&gt;), плюс отсев ещё не обработанного сообщения и защита от неизвестной доски.
/// </para>
/// </remarks>
public sealed class MlReviewService(
IPipelineStore pipelineStore,
ICardStore cardStore,
CardsService cards,
PipelineProcessingService processing,
IMlClient mlClient)
{
/// <summary>
/// Минимум сообщений в выборке кандидатов (кламп запроса 1..60, как прототип).
/// </summary>
public const int MinCandidates = 1;
/// <summary>
/// Максимум сообщений в выборке кандидатов (кламп запроса 1..60, как прототип).
/// </summary>
public const int MaxCandidates = 60;
// Размер одного чтения из очереди/отсева при объединении кандидатов.
private const int MaxScan = 500;
// Длина текста кандидата в ответе (ml_routes.py L129: text[:600]).
private const int TextPreviewLength = 600;
/// <summary>
/// Вердикт кандидата: по сообщению уже есть карточка.
/// </summary>
public const string VerdictCard = "card";
/// <summary>
/// Вердикт кандидата: сообщение в отсеве.
/// </summary>
public const string VerdictRejected = "rejected";
/// <summary>
/// Вердикт кандидата: сообщение ждёт обработки в очереди.
/// </summary>
public const string VerdictQueued = "queued";
/// <summary>
/// Действие: пропустить без обучения (ml_routes.py L144145).
/// </summary>
public const string ActionSkip = "skip";
/// <summary>
/// Действие: спам — учим ML и (если есть) карточку в корзину (ml_routes.py L150156).
/// </summary>
public const string ActionSpam = "spam";
/// <summary>
/// Префикс действия «в колонку»: <c>board:&lt;id&gt;</c> (ml_routes.py L157).
/// </summary>
public const string ActionBoardPrefix = "board:";
/// <summary>
/// 400 apply: неизвестная доска-цель (ml_routes.py L159160).
/// </summary>
public const string UnknownBoardDetail = "Неизвестная доска";
/// <summary>
/// 400 apply: неизвестное действие (ml_routes.py L169170).
/// </summary>
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;
/// <summary>
/// Отбирает сообщения-кандидаты для проверки ML по каналу и/или размеру выборки.
/// </summary>
/// <param name="dialogId">Id канала/диалога; пусто — выборка по всем источникам тенанта.</param>
/// <param name="limit">Сколько последних сообщений вернуть (кламп 1..<see cref="MaxCandidates"/>, дефолт вызывающего).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Кандидаты (свежие первыми): текст, текущий вердикт и мнение ML по каждому.</returns>
public async Task<IReadOnlyList<MlCandidateDto>> CandidatesAsync(
string? dialogId,
int limit,
CancellationToken ct)
{
int take = Math.Clamp(limit, MinCandidates, MaxCandidates);
string dialog = (dialogId ?? string.Empty).Trim();
IReadOnlyList<QueueItemDto> queue = await pipelineStore.ListAsync(status: null, MaxScan, ct);
IReadOnlyList<RejectedItemDto> rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct);
IReadOnlyList<CardDto> 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<MlCandidateDto> ordered = merged.Values
.OrderByDescending(candidate => candidate.Time ?? 0)
.Take(take)
.ToList();
var withPredictions = new List<MlCandidateDto>(ordered.Count);
foreach (MlCandidateDto candidate in ordered)
{
withPredictions.Add(candidate with { Pred = await PredictSafelyAsync(candidate.Text, ct) });
}
return withPredictions;
}
/// <summary>
/// Применяет ручное решение по сообщению: обучение ML + перенос/корзина/отсев.
/// </summary>
/// <param name="dialogId">Id канала/диалога сообщения.</param>
/// <param name="msgId">Id исходного сообщения.</param>
/// <param name="action">Действие: <c>skip</c> | <c>spam</c> | <c>board:&lt;id&gt;</c>.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результат решения; null — исходное сообщение не найдено (404-семантика эндпоинта).</returns>
public async Task<MlApplyResult?> 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<MlApplyResult> 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<MlApplyResult> 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 L163165).
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<string?> 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<RejectedItemDto> 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<QueueItemDto?> FindQueuedAsync(
string dialog,
long msgId,
CancellationToken ct)
{
IReadOnlyList<QueueItemDto> 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<MlCandidatePredictionDto?> 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];
}