Deal.Api/Http -> Services/Models/Extensions; Contracts/Integrations и SharedKernel/Tenants -> Abstractions/Models; extension-классы telegram/ml -> Extensions. namespace/using/FQN мигрированы, using дедуплицированы.
448 lines
20 KiB
C#
448 lines
20 KiB
C#
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 L137–171
|
||
/// (skip/spam/board:<id>), плюс отсев ещё не обработанного сообщения и защита от неизвестной доски.
|
||
/// </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 L144–145).
|
||
/// </summary>
|
||
public const string ActionSkip = "skip";
|
||
|
||
/// <summary>
|
||
/// Действие: спам — учим ML и (если есть) карточку в корзину (ml_routes.py L150–156).
|
||
/// </summary>
|
||
public const string ActionSpam = "spam";
|
||
|
||
/// <summary>
|
||
/// Префикс действия «в колонку»: <c>board:<id></c> (ml_routes.py L157).
|
||
/// </summary>
|
||
public const string ActionBoardPrefix = "board:";
|
||
|
||
/// <summary>
|
||
/// 400 apply: неизвестная доска-цель (ml_routes.py L159–160).
|
||
/// </summary>
|
||
public const string UnknownBoardDetail = "Неизвестная доска";
|
||
|
||
/// <summary>
|
||
/// 400 apply: неизвестное действие (ml_routes.py L169–170).
|
||
/// </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:<id></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 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<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];
|
||
}
|