SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Порт хранилища пайплайна
|
||||
/// </summary>
|
||||
public interface IPipelineStore
|
||||
{
|
||||
// ── Очередь (QueueItems) ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Есть ли строка очереди с той же записью источника — дубль-гвард приёма.
|
||||
/// </summary>
|
||||
/// <param name="source">Ссылка на источник: вид + оригинал + внешний id.</param>
|
||||
/// <returns>true — строка с такой записью уже в очереди.</returns>
|
||||
public Task<bool> ExistsDuplicateAsync(
|
||||
SourceRef source,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Добавляет строку очереди
|
||||
/// </summary>
|
||||
/// <param name="item">Полная строка: id <c>p_</c>, статус new, QueuedAtMs=CreatedAt=UpdatedAt (задаёт модуль).</param>
|
||||
public Task AddAsync(QueueItemDto item, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Строки очереди в порядке постановки
|
||||
/// </summary>
|
||||
/// <param name="status">Статус new|filtered, либо null — все статусы.</param>
|
||||
/// <param name="limit">Максимум строк (лимиты сервиса: 12/4 у воркера, ≤500 у списка).</param>
|
||||
/// <returns>Строки очереди (включая внутренний <see cref="QueueItemDto.Force"/> для воркера).</returns>
|
||||
public Task<IReadOnlyList<QueueItemDto>> ListAsync(
|
||||
string? status,
|
||||
int limit,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Сколько строк очереди со статусом.
|
||||
/// </summary>
|
||||
/// <param name="status">Статус new|filtered.</param>
|
||||
/// <returns>Число строк со статусом.</returns>
|
||||
public Task<int> CountByStatusAsync(string status, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Меняет статус строки очереди
|
||||
/// </summary>
|
||||
/// <param name="id">Id строки (<c>p_...</c>).</param>
|
||||
/// <param name="status">Новый статус new|filtered.</param>
|
||||
public Task SetStatusAsync(
|
||||
string id,
|
||||
string status,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет строку очереди безвозвратно.
|
||||
/// </summary>
|
||||
/// <param name="id">Id строки (<c>p_...</c>).</param>
|
||||
public Task RemoveAsync(string id, CancellationToken ct);
|
||||
|
||||
// ── Отсев (RejectedItems) ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Пишет запись отсева
|
||||
/// </summary>
|
||||
/// <param name="record">Команда записи отсева (текст/канал/время + source/stage/reason/kw).</param>
|
||||
public Task UpsertAsync(RejectRecord record, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Страница отсева без поиска
|
||||
/// </summary>
|
||||
/// <param name="offset">Сдвиг от начала (0 — первая страница).</param>
|
||||
/// <param name="limit">Размер страницы (≤500; валидирует сервис).</param>
|
||||
/// <returns>Записи страницы.</returns>
|
||||
public Task<IReadOnlyList<RejectedItemDto>> ListPageAsync(
|
||||
int offset,
|
||||
int limit,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Кандидаты поиска по отсеву ПОЛНЫМИ СТРОКАМИ
|
||||
/// </summary>
|
||||
/// <param name="q">Поисковый запрос (сервис отдаёт нормализованный lower).</param>
|
||||
/// <param name="limitFts">Лимит FTS-кандидатов.</param>
|
||||
/// <param name="limitLike">Лимит LIKE-дополнения.</param>
|
||||
/// <returns>Упорядоченный список полных записей-кандидатов (FTS-ранжированные первыми, затем LIKE-дополнение).</returns>
|
||||
public Task<IReadOnlyList<RejectedItemDto>> SearchAsync(
|
||||
string q,
|
||||
int limitFts,
|
||||
int limitLike,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Всего записей отсева
|
||||
/// </summary>
|
||||
/// <returns>Число записей.</returns>
|
||||
public Task<int> CountAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Одна запись отсева по id
|
||||
/// </summary>
|
||||
/// <param name="id">Id записи (<c>r_...</c>).</param>
|
||||
/// <returns>Запись (полный DTO) или null, если строки нет.</returns>
|
||||
public Task<RejectedItemDto?> GetAsync(string id, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет одну запись отсева безвозвратно.
|
||||
/// </summary>
|
||||
/// <param name="id">Id записи (<c>r_...</c>).</param>
|
||||
public Task DeleteAsync(string id, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Полная очистка отсева.
|
||||
/// </summary>
|
||||
/// <returns>Сколько записей удалено.</returns>
|
||||
public Task<int> ClearAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Автоочистка: удаляет записи с RejectedAt старше olderThan.
|
||||
/// </summary>
|
||||
/// <param name="olderThan">Граница срока хранения (UTC): удаляются записи RejectedAt < olderThan.</param>
|
||||
/// <returns>Сколько записей удалено.</returns>
|
||||
public Task<int> PurgeExpiredAsync(DateTimeOffset olderThan, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Помечает запись отсева возвращённой
|
||||
/// </summary>
|
||||
/// <param name="id">Id записи (<c>r_...</c>).</param>
|
||||
/// <param name="reason">Причина возврата (уже обрезана сервисом до 500).</param>
|
||||
/// <param name="returnedAt">Момент возврата (UTC).</param>
|
||||
public Task MarkReturnedAsync(
|
||||
string id,
|
||||
string reason,
|
||||
DateTimeOffset returnedAt,
|
||||
CancellationToken ct);
|
||||
|
||||
// ── Дедуп (DedupEntries) ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Есть ли запись дедупа с хэшем.
|
||||
/// </summary>
|
||||
/// <param name="hash">SHA1-hex нормализованного текста (без префикса).</param>
|
||||
/// <returns>true — запись существует.</returns>
|
||||
public Task<bool> ExistsAsync(string hash, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Заявляет хэш за обрабатываемым сообщением
|
||||
/// </summary>
|
||||
/// <param name="hash">SHA1-hex нормализованного текста.</param>
|
||||
/// <returns>True — заявка занята этим вызовом (строка INSERT'нута); false — хэш уже заявлен (ON CONFLICT).</returns>
|
||||
public Task<bool> ClaimAsync(string hash, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Снимает незанятую заявку дедупа
|
||||
/// </summary>
|
||||
/// <param name="hash">SHA1-hex нормализованного текста.</param>
|
||||
public Task DeleteClaimAsync(string hash, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Связывает заявку дедупа с созданной карточкой
|
||||
/// </summary>
|
||||
/// <param name="hash">SHA1-hex нормализованного текста.</param>
|
||||
/// <param name="cardId">Id созданной карточки (<c>c_...</c>).</param>
|
||||
public Task LinkAsync(
|
||||
string hash,
|
||||
string cardId,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Чистит «мягкие» ссылки карточки при её жёстком удалении.
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id удаляемой карточки (<c>c_...</c>).</param>
|
||||
public Task DeleteByCardAsync(string cardId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Снимок глобальных исключений тенанта.
|
||||
/// </summary>
|
||||
/// <param name="Keywords">Ключевые слова/фразы/технологии (пусто — группа выключена).</param>
|
||||
/// <param name="Locations">Локации/языки (пусто — группа выключена).</param>
|
||||
/// <param name="Types">Типы заявки (vacancy|freelance|announcement; пусто — группа выключена).</param>
|
||||
/// <param name="BudgetFrom">Нижняя граница бюджета (null — не задана).</param>
|
||||
/// <param name="BudgetTo">Верхняя граница бюджета (null — не задана).</param>
|
||||
public sealed record GlobalExcludeSettings(
|
||||
IReadOnlyList<string> Keywords,
|
||||
IReadOnlyList<string> Locations,
|
||||
IReadOnlyList<string> Types,
|
||||
double? BudgetFrom,
|
||||
double? BudgetTo);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Сработавшее глобальное исключение
|
||||
/// </summary>
|
||||
/// <param name="Kind">Этап/правило исключения (константы <c>GlobalExclusionRules</c>).</param>
|
||||
/// <param name="Reason">Причина отсева для UI (какое исключение сработало).</param>
|
||||
/// <param name="Kw">Конкретное слово/фраза/тип (пусто — не терм).</param>
|
||||
public sealed record GlobalExclusionResult(string Kind, string Reason, string Kw);
|
||||
@@ -0,0 +1,22 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат локального разбора сообщения без ИИ — структура карточки.
|
||||
/// </summary>
|
||||
/// <param name="Title">Заголовок карточки — первая содержательная строка, очищенная (≤140).</param>
|
||||
/// <param name="Summary">Суть «О задаче» — содержательные строки после заголовка без меток-полей.</param>
|
||||
/// <param name="Stack">Стек/направления из меток или текста (≤12, без стоп-слов).</param>
|
||||
/// <param name="Grade">Грейды/уровни из метки «Грейд:» или текста (≤4, термины levelTerms в нижнем регистре).</param>
|
||||
/// <param name="Budget">Первая распознанная сумма/диапазон (из метки «Бюджет:» или текста); null — суммы нет.</param>
|
||||
/// <param name="Contacts">Сырые кандидаты контактов (@ник, email, телефон) через «; », ≤200 символов.</param>
|
||||
/// <param name="IsVacancy">Признак найма: в тексте есть маркер hireMarkers (маркерная гипотеза, не контекст).</param>
|
||||
public sealed record LocalParsedFields(
|
||||
string Title,
|
||||
string Summary,
|
||||
IReadOnlyList<string> Stack,
|
||||
IReadOnlyList<string> Grade,
|
||||
BudgetRangeDto? Budget,
|
||||
string Contacts,
|
||||
bool IsVacancy);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат ручного решения по сообщению.
|
||||
/// </summary>
|
||||
/// <param name="Error">Текст ошибки 400 (неизвестная доска/действие); null — решение применено.</param>
|
||||
/// <param name="Ok">True — решение принято.</param>
|
||||
/// <param name="Learned">True — ML обучен (передан обучающий сигнал).</param>
|
||||
/// <param name="Moved">Куда переехала карточка: <c>trash</c> | id колонки; null — карточки не было.</param>
|
||||
/// <param name="LeadId">Id карточки, если она была и переехала.</param>
|
||||
public sealed record MlApplyResult(
|
||||
string? Error,
|
||||
bool Ok,
|
||||
bool Learned,
|
||||
string? Moved,
|
||||
string? LeadId);
|
||||
@@ -0,0 +1,59 @@
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Запись-кандидат для ручной проверки ML.
|
||||
/// </summary>
|
||||
public sealed record MlCandidateDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Источник записи.
|
||||
/// </summary>
|
||||
public SourceRef Source { get; init; } = SourceRefs.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Содержимое записи источника.
|
||||
/// </summary>
|
||||
public SourceContent Content { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Текст записи.
|
||||
/// </summary>
|
||||
public string Text { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время исходного сообщения, epoch-ms; null — неизвестно.
|
||||
/// </summary>
|
||||
public long? Time { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True — по сообщению уже есть карточка.
|
||||
/// </summary>
|
||||
public bool Lead { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Текущий вердикт
|
||||
/// </summary>
|
||||
public string Verdict { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Колонка карточки
|
||||
/// </summary>
|
||||
public string? Col { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Этап отсева (для verdict=rejected) либо статус очереди (для verdict=queued).
|
||||
/// </summary>
|
||||
public string? Stage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Причина отсева
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Мнение ML по тексту; null — ML не ответил/не готов.
|
||||
/// </summary>
|
||||
public MlCandidatePredictionDto? Pred { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Мнение ML по сообщению-кандидату.
|
||||
/// </summary>
|
||||
/// <param name="Take">Модель «взяла бы» сообщение (готова и уверена).</param>
|
||||
/// <param name="Label">Метка решения (id доски либо <c>spam</c>); null — модель не уверена/не готова.</param>
|
||||
/// <param name="Scores">Оценки классов (пусто — модель не готова).</param>
|
||||
public sealed record MlCandidatePredictionDto(
|
||||
bool Take,
|
||||
string? Label,
|
||||
IReadOnlyDictionary<string, double> Scores);
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Структурированное содержимое блока «О заявке» карточки.
|
||||
/// </summary>
|
||||
/// <param name="Company">Кто ищет/разместил: компания, бренд, агентство, частное лицо, заказчик.</param>
|
||||
/// <param name="Format">Формат работы: удалённо/офис/гибрид, город/страна, график.</param>
|
||||
/// <param name="Task">Что за задача/роль → для кого → что нужно сделать.</param>
|
||||
/// <param name="Requirements">Реальные требования/обязанности (пункты списков).</param>
|
||||
/// <param name="Plus">Что отмечено как «будет плюсом»/«приветствуется»/«желательно».</param>
|
||||
/// <param name="Conditions">Условия одной строкой: оплата/ЗП/вилка, сроки, объём, тип занятости.</param>
|
||||
/// <param name="Summary">Неструктурированная суть разбора (legacy): возвращается как есть, если не похожа на служебный футер; иначе блок «О задаче: …» из текста.</param>
|
||||
public sealed record ParsedCardContent(
|
||||
string? Company = null,
|
||||
string? Format = null,
|
||||
string? Task = null,
|
||||
IReadOnlyList<string>? Requirements = null,
|
||||
IReadOnlyList<string>? Plus = null,
|
||||
string? Conditions = null,
|
||||
string? Summary = null);
|
||||
@@ -0,0 +1,21 @@
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат сухого прогона текста по конвейеру (без записи в систему).
|
||||
/// </summary>
|
||||
/// <param name="Passed">True — текст прошёл все включённые этапы.</param>
|
||||
/// <param name="WouldCreateCard">True — из текста была бы создана карточка.</param>
|
||||
/// <param name="TargetContainer">Контейнер, в который попала бы карточка, если она создаётся.</param>
|
||||
/// <param name="MatchHits">Совпавшие критерии правил целевого контейнера.</param>
|
||||
/// <param name="Parsed">Разбор текста (поля карточки), если прогон дошёл до сборки.</param>
|
||||
/// <param name="Stages">Результаты этапов в порядке прохождения.</param>
|
||||
public sealed record PipelineDryRunDto(
|
||||
bool Passed,
|
||||
bool WouldCreateCard,
|
||||
string? TargetContainer,
|
||||
IReadOnlyList<MatchHitDto> MatchHits,
|
||||
AiParsedCardDto? Parsed,
|
||||
IReadOnlyList<PipelineDryRunStageDto> Stages);
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат одного этапа сухого прогона текста по конвейеру.
|
||||
/// </summary>
|
||||
/// <param name="Stage">Код этапа: length|stop|resume|type|exclude|stale|ml|ai|budget.</param>
|
||||
/// <param name="Pass">True — этап пропустил текст дальше.</param>
|
||||
/// <param name="Skipped">True — этап выключен настройкой и не выполнялся.</param>
|
||||
/// <param name="Reason">Причина блокировки (пусто — блокировки нет).</param>
|
||||
/// <param name="Kw">Сработавшее слово/фраза правила, если есть.</param>
|
||||
/// <param name="Label">Метка решения ML/ИИ, если применимо.</param>
|
||||
public sealed record PipelineDryRunStageDto(
|
||||
string Stage,
|
||||
bool Pass,
|
||||
bool Skipped,
|
||||
string Reason,
|
||||
string? Kw = null,
|
||||
string? Label = null);
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Реестр префиксов коротких id модуля Pipeline.
|
||||
/// </summary>
|
||||
public static class PipelineIdPrefixes
|
||||
{
|
||||
/// <summary>
|
||||
/// Префикс id строки очереди
|
||||
/// </summary>
|
||||
public const string Queue = "p_";
|
||||
|
||||
/// <summary>
|
||||
/// Префикс id записи отсева.
|
||||
/// </summary>
|
||||
public const string Rejected = "r_";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат приёма сообщения — ответ PipelineIngestService.EnqueueAsync.
|
||||
/// </summary>
|
||||
/// <param name="Id">Id строки очереди (<c>p_...</c>) либо null — сообщение не принято.</param>
|
||||
/// <param name="Duplicate">true — то же сообщение диалога (dialogId+msgId) уже в очереди, вставки не было.</param>
|
||||
public sealed record PipelineIngestResultDto(string? Id, bool Duplicate);
|
||||
@@ -0,0 +1,59 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат одного прохода воркера pump — поле <c>pipeline</c> ответа POST /api/admin/tick.
|
||||
/// </summary>
|
||||
public sealed record PipelinePumpResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Сообщений прошли «new»-проход
|
||||
/// </summary>
|
||||
public int Staged { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Отсевов по правилам
|
||||
/// </summary>
|
||||
public int RulesStored { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Карточек создано ML-веткой.
|
||||
/// </summary>
|
||||
public int MlStored { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Отсевов решением ML
|
||||
/// </summary>
|
||||
public int MlDrop { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Отсевов «тип не под режим» по решению ML
|
||||
/// </summary>
|
||||
public int TypeDrop { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Карточек создано ИИ-веткой
|
||||
/// </summary>
|
||||
public int AiStored { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Отсевов решением ИИ
|
||||
/// </summary>
|
||||
public int AiDrop { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Сообщений, где ИИ не дал разбора — собран локальный разбор
|
||||
/// </summary>
|
||||
public int AiFail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Отсевов глобальным фильтром «без суммы»
|
||||
/// </summary>
|
||||
public int NoBudget { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Карточки, созданные за проход
|
||||
/// </summary>
|
||||
public IReadOnlyList<CardDto> CreatedCards { get; init; } = Array.Empty<CardDto>();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Статусы строк очереди — колонка QueueItems.Status.
|
||||
/// </summary>
|
||||
public static class PipelineQueueStatuses
|
||||
{
|
||||
/// <summary>
|
||||
/// Статус «ждёт правил/дедупа/ML».
|
||||
/// </summary>
|
||||
public const string New = "new";
|
||||
|
||||
/// <summary>
|
||||
/// Статус «прошла, ждёт ИИ».
|
||||
/// </summary>
|
||||
public const string Filtered = "filtered";
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Константы отсева
|
||||
/// </summary>
|
||||
public static class PipelineRejectConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// Срок жизни записи отсева в сутках
|
||||
/// </summary>
|
||||
public const int RetentionDays = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Словарь « → подпись»
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, string> StageLabels =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["length"] = "короткое сообщение",
|
||||
["stop"] = "стоп-фраза",
|
||||
["resume"] = "резюме соискателя",
|
||||
["type"] = "тип заявки",
|
||||
["budget"] = "нет суммы",
|
||||
["exclude_kw"] = "исключение: слова/технологии",
|
||||
["exclude_location"] = "исключение: локация/язык",
|
||||
["exclude_type"] = "исключение: тип",
|
||||
["exclude_budget"] = "исключение: бюджет",
|
||||
["stale"] = "устарело",
|
||||
["spam_ml"] = "спам (ML)",
|
||||
["spam_ai"] = "спам (ИИ)",
|
||||
["filter_ai"] = "ИИ-фильтр",
|
||||
["dup"] = "повтор",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Словарь «источник → подпись»
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, string> SourceLabels =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["stop"] = "правила",
|
||||
["ml"] = "ML",
|
||||
["ai"] = "ИИ",
|
||||
["stale"] = "система",
|
||||
["dup"] = "система",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Подпись отсева: словарь, иначе сам stage; пустой stage — «отсев».
|
||||
/// </summary>
|
||||
/// <param name="stage">Этап отсева.</param>
|
||||
/// <returns>Подпись для UI.</returns>
|
||||
public static string StageLabel(string stage)
|
||||
{
|
||||
if (stage.Length > 0 && StageLabels.TryGetValue(stage, out string? label))
|
||||
{
|
||||
return label;
|
||||
}
|
||||
|
||||
return stage.Length > 0 ? stage : "отсев";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подпись источника решения
|
||||
/// </summary>
|
||||
/// <param name="source">Источник решения.</param>
|
||||
/// <returns>Подпись для UI.</returns>
|
||||
public static string SourceLabel(string source)
|
||||
{
|
||||
if (source.Length > 0 && SourceLabels.TryGetValue(source, out string? label))
|
||||
{
|
||||
return label;
|
||||
}
|
||||
|
||||
return source.Length > 0 ? source : "система";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Сводка вкладки «Обработка» — тело GET /api/pipeline/stats.
|
||||
/// </summary>
|
||||
/// <param name="Queue">Счётчики очереди по статусам (ключ «queue» ответа).</param>
|
||||
/// <param name="Rejected">Число записей в отсеве (ключ «rejected»).</param>
|
||||
public sealed record PipelineStatsDto(QueueCountsDto Queue, int Rejected);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Счётчики очереди — <c>counts</c> ответа GET /api/pipeline/queue и <c>queue</c> ответа GET /api/pipeline/stats.
|
||||
/// </summary>
|
||||
public sealed record QueueCountsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Строк со статусом new
|
||||
/// </summary>
|
||||
public int New { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Строк со статусом filtered
|
||||
/// </summary>
|
||||
public int Ai { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Всего строк в очереди
|
||||
/// </summary>
|
||||
public int Total { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Элемент очереди входящих — item ответа GET /api/pipeline/queue.
|
||||
/// </summary>
|
||||
public sealed record QueueItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id строки очереди.
|
||||
/// </summary>
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Источник записи.
|
||||
/// </summary>
|
||||
public SourceRef Source { get; init; } = SourceRefs.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Содержимое записи источника.
|
||||
/// </summary>
|
||||
public SourceContent Content { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Текст сообщения.
|
||||
/// </summary>
|
||||
public string Text { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Статус строки: <c>new</c> | <c>filtered</c>.
|
||||
/// </summary>
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время исходного сообщения, epoch-ms.
|
||||
/// </summary>
|
||||
[property: JsonPropertyName("msgAt")]
|
||||
public long MsgAtMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Время постановки в очередь, epoch-ms.
|
||||
/// </summary>
|
||||
[property: JsonPropertyName("queuedAt")]
|
||||
public long QueuedAtMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Внутренний флаг «возвращено пользователем из отсева».
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool Force { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Команда приёма входящей записи — аргумент PipelineIngestService.EnqueueAsync.
|
||||
/// </summary>
|
||||
public sealed record QueuedMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Запись источника: источник и содержимое.
|
||||
/// </summary>
|
||||
public required SourceItem Item { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Флаг «возвращено из отсева».
|
||||
/// </summary>
|
||||
public bool Force { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Промежуточный прогресс пакетной переклассификации «Неразобранного».
|
||||
/// </summary>
|
||||
/// <param name="Done">Сколько карточек уже обработано.</param>
|
||||
/// <param name="Total">Сколько карточек отобрано всего.</param>
|
||||
/// <param name="Moved">Сколько ушло в смысловую колонку.</param>
|
||||
/// <param name="Kept">Сколько осталось в «Неразобранном».</param>
|
||||
/// <param name="Trashed">Сколько отправлено в корзину.</param>
|
||||
/// <param name="Skipped">Сколько пропущено (нет исходного текста).</param>
|
||||
public sealed record ReclassifyProgressDto(
|
||||
int Done,
|
||||
int Total,
|
||||
int Moved,
|
||||
int Kept,
|
||||
int Trashed,
|
||||
int Skipped);
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Итог ручной переклассификации «Неразобранного»/одной карточки
|
||||
/// </summary>
|
||||
/// <param name="Started">Переклассификация выполнена (target непуст).</param>
|
||||
/// <param name="Busy">Проход уже выполняется другим запросом.</param>
|
||||
/// <param name="Attempted">Сколько карточек отобрано.</param>
|
||||
/// <param name="Reclassified">Сколько карточек успешно переклассифицировано.</param>
|
||||
/// <param name="Moved">Сколько ушло в смысловую колонку.</param>
|
||||
/// <param name="Kept">Сколько осталось в «Неразобранном».</param>
|
||||
/// <param name="Trashed">Сколько отправлено в корзину (спам/не прошло ИИ-фильтр).</param>
|
||||
/// <param name="Skipped">Сколько пропущено (нет исходного текста).</param>
|
||||
/// <param name="UsedAi">True — использован порт ИИ; false — локальный детерминированный разбор.</param>
|
||||
/// <param name="Reason">Причина невыполнения/пустого target либо null.</param>
|
||||
public sealed record ReclassifyResultDto(
|
||||
bool Started,
|
||||
bool Busy,
|
||||
int Attempted,
|
||||
int Reclassified,
|
||||
int Moved,
|
||||
int Kept,
|
||||
int Trashed,
|
||||
int Skipped,
|
||||
bool UsedAi,
|
||||
string? Reason);
|
||||
@@ -0,0 +1,57 @@
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Команда записи отсева — аргумент IPipelineStore.UpsertAsync.
|
||||
/// </summary>
|
||||
public sealed record RejectRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Источник записи.
|
||||
/// </summary>
|
||||
public SourceRef Source { get; init; } = SourceRefs.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Содержимое записи источника.
|
||||
/// </summary>
|
||||
public SourceContent Content { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Текст отсеянного сообщения.
|
||||
/// </summary>
|
||||
public string Text { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время исходного сообщения, epoch-ms.
|
||||
/// </summary>
|
||||
public long MsgAtMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Кто вынес решение: stop|ml|ai|stale|dup.
|
||||
/// </summary>
|
||||
public string DecidedBy { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Этап отсева: length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup.
|
||||
/// </summary>
|
||||
public string Stage { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Причина отсева.
|
||||
/// </summary>
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Конкретное слово/фраза стоп-списка, сработавшая правилом.
|
||||
/// </summary>
|
||||
public string Kw { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Детерминированный id записи.
|
||||
/// </summary>
|
||||
public string? DeterministicId =>
|
||||
Source.ExternalId is { Length: > 0 } externalId
|
||||
? $"r_{Source.Kind}_{Source.OriginRef}_{externalId}"
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат возврата отсеянного сообщения в обработку — POST /api/pipeline/rejected/{id}/return.
|
||||
/// </summary>
|
||||
/// <param name="Error">Текст 400 либо null — успех.</param>
|
||||
/// <param name="Id">Id записи отсева (успех; эхо возвращаемой записи).</param>
|
||||
/// <param name="Returned">true — запись возвращена в обработку (успех).</param>
|
||||
/// <param name="ReturnedAtMs">Момент возврата, epoch-ms (ключ «returnedAt»; успех).</param>
|
||||
public sealed record RejectReturnResultDto(
|
||||
string? Error,
|
||||
string Id,
|
||||
bool Returned,
|
||||
[property: JsonPropertyName("returnedAt")] long ReturnedAtMs);
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Элемент отсева — item ответа GET /api/pipeline/rejected.
|
||||
/// </summary>
|
||||
public sealed record RejectedItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Id записи отсева.
|
||||
/// </summary>
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Источник записи.
|
||||
/// </summary>
|
||||
public SourceRef Source { get; init; } = SourceRefs.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Содержимое записи источника.
|
||||
/// </summary>
|
||||
public SourceContent Content { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Текст отсеянного сообщения.
|
||||
/// </summary>
|
||||
public string Text { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Этап отсева.
|
||||
/// </summary>
|
||||
public string Stage { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Человекочитаемая подпись для UI.
|
||||
/// </summary>
|
||||
public string StageLabel { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Причина отсева.
|
||||
/// </summary>
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Конкретное слово/фраза стоп-списка, сработавшая правилом.
|
||||
/// </summary>
|
||||
public string Kw { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Кто вынес решение: stop|ml|ai|stale|dup.
|
||||
/// </summary>
|
||||
public string DecidedBy { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Подпись источника решения.
|
||||
/// </summary>
|
||||
public string DecidedByLabel { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время исходного сообщения, epoch-ms.
|
||||
/// </summary>
|
||||
[property: JsonPropertyName("msgAt")]
|
||||
public long MsgAtMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Время отсева, epoch-ms.
|
||||
/// </summary>
|
||||
[property: JsonPropertyName("rejectedAt")]
|
||||
public long RejectedAtMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Флаг «возвращено в обработку».
|
||||
/// </summary>
|
||||
public bool Returned { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Время возврата, epoch-ms.
|
||||
/// </summary>
|
||||
[property: JsonPropertyName("returnedAt")]
|
||||
public long? ReturnedAtMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Причина возврата, указанная пользователем.
|
||||
/// </summary>
|
||||
public string ReturnReason { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Страница отсева — тело GET /api/pipeline/rejected.
|
||||
/// </summary>
|
||||
/// <param name="Items">Записи страницы.</param>
|
||||
/// <param name="Total">Всего записей по условию поиска.</param>
|
||||
/// <param name="Offset">Сдвиг от начала страницы (эхо запроса).</param>
|
||||
/// <param name="Limit">Размер страницы (эхо запроса; ≤500).</param>
|
||||
public sealed record RejectedPageDto(IReadOnlyList<RejectedItemDto> Items, int Total, int Offset, int Limit);
|
||||
@@ -0,0 +1,31 @@
|
||||
using Deal.Modules.Kanban.Application.ColumnRules;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Parse;
|
||||
|
||||
/// <summary>
|
||||
/// Fallback бюджета карточки, когда разбор не выделил бюджет отдельным полем
|
||||
/// </summary>
|
||||
public static class AmountRangeBudgetFallback
|
||||
{
|
||||
/// <summary>
|
||||
/// Ищет бюджет-«заглушку»
|
||||
/// </summary>
|
||||
/// <param name="text">Текст исходного сообщения (первый источник).</param>
|
||||
/// <param name="summary">Структурированная «О заявке» карточки.</param>
|
||||
/// <returns>Бюджет {from, to, cur} первой найденной суммы либо null — сумм с валютой нет.</returns>
|
||||
public static BudgetRangeDto? Extract(string? text, string? summary)
|
||||
{
|
||||
foreach (string source in new[] { text ?? string.Empty, summary ?? string.Empty })
|
||||
{
|
||||
IReadOnlyList<AmountRange> amounts = AmountParser.Parse(source);
|
||||
if (amounts.Count > 0)
|
||||
{
|
||||
AmountRange first = amounts[0];
|
||||
return new BudgetRangeDto(first.From, first.To, first.Cur);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Parse;
|
||||
|
||||
// Расширения кодовых точек для чистки текста сообщений.
|
||||
internal static class CodePointExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Входит ли кодовая точка в эмодзи-диапазоны.
|
||||
/// </summary>
|
||||
/// <param name="codePoint">Кодовая точка (BMP или доп. плоскость).</param>
|
||||
/// <returns>True — декоративный символ, подлежащий удалению.</returns>
|
||||
public static bool IsEmojiCodePoint(this int codePoint) =>
|
||||
codePoint is >= 0x1F000 and <= 0x1FAFF
|
||||
or >= 0x2600 and <= 0x27BF
|
||||
or >= 0x2B00 and <= 0x2BFF
|
||||
or 0xFE0F;
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Parse;
|
||||
|
||||
/// <summary>
|
||||
/// Квалификация контактов из разбора/текста сообщения.
|
||||
/// </summary>
|
||||
public static class ContactsQualifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Максимум записей контактов карточки.
|
||||
/// </summary>
|
||||
public const int MaxContacts = 6;
|
||||
|
||||
internal const int MaxTextCandidates = 4;
|
||||
|
||||
private const int MaxContactLength = 300;
|
||||
|
||||
private const int MinHandleLength = 4;
|
||||
|
||||
private const int MaxHandleLength = 32;
|
||||
|
||||
internal const int MaxNormalizedPhoneLength = 18;
|
||||
|
||||
private const string BotSuffix = "bot";
|
||||
|
||||
private static readonly IReadOnlySet<string> ReservedProfileNames = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"joinchat", "share", "s", "c", "addstickers", "addtheme", "proxy", "bg", "login",
|
||||
};
|
||||
|
||||
private static readonly IReadOnlySet<string> SkipSiteHosts = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"teletype.in", "forms.gle", "docs.google.com", "youtube.com", "youtu.be", "clck.ru",
|
||||
};
|
||||
|
||||
private static readonly Regex ProfileHandleRe = new(@"\A[A-Za-z0-9_]{4,32}\z", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex ProfileLinkRe = new(
|
||||
@"\Ahttps?://(?:www\.)?t\.me/([A-Za-z0-9_]{4,32})/?\z",
|
||||
RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex EmailRe = new(
|
||||
@"\A[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\z",
|
||||
RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex PhoneRe = new(@"\A\+?[\d\s\-()]{6,20}\z", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex LinkedinRe = new(@"linkedin\.com/in/", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex WhatsappRe = new(@"wa\.me|api\.whatsapp\.com", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex SiteHostRe = new(@"https?://(?:www\.)?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex AtNameRe = new(@"@[A-Za-z0-9_]{3,}", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex EmailInTextRe = new(
|
||||
@"[A-Za-z0-9._%+\-]+@[A-Za-z0-9\-]+(?:\.[A-Za-z0-9\-]+)+",
|
||||
RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex PhoneInTextRe = new(
|
||||
@"(?:\+7|8|7)[\s\-()]*\d{3}[\s\-()]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}",
|
||||
RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, int> PrimaryOrder = new Dictionary<string, int>(StringComparer.Ordinal)
|
||||
{
|
||||
["tg"] = 0,
|
||||
["phone"] = 1,
|
||||
["whatsapp"] = 2,
|
||||
["email"] = 3,
|
||||
["linkedin"] = 4,
|
||||
["site"] = 5,
|
||||
};
|
||||
|
||||
private const int UnknownTypePriority = 9;
|
||||
|
||||
/// <summary>
|
||||
/// Классифицирует один сырой контакт → {type, value} или null.
|
||||
/// </summary>
|
||||
/// <param name="raw">Сырое значение контакта («@user», ссылка на профиль, телефон, email, ссылка).</param>
|
||||
/// <returns>Квалифицированный контакт <see cref="CardContactDto"/> либо null — пусто/мусор/бот/сервисная ссылка/ «постовый» сайт.</returns>
|
||||
public static CardContactDto? Qualify(string? raw)
|
||||
{
|
||||
string s = (raw ?? string.Empty).Trim();
|
||||
if (s.Length == 0 || s.Length > MaxContactLength)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (s.StartsWith('@'))
|
||||
{
|
||||
string name = s[1..].Trim();
|
||||
if (ProfileHandleRe.IsMatch(name) && !name.EndsWith(BotSuffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new CardContactDto("tg", "@" + name);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Match link = ProfileLinkRe.Match(s);
|
||||
if (link.Success)
|
||||
{
|
||||
string name = link.Groups[1].Value;
|
||||
if (!ReservedProfileNames.Contains(name.ToLowerInvariant())
|
||||
&& !name.EndsWith(BotSuffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new CardContactDto("tg", "@" + name);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (EmailRe.IsMatch(s))
|
||||
{
|
||||
return new CardContactDto("email", s.ToLowerInvariant());
|
||||
}
|
||||
|
||||
string digits = new string(s.Where(char.IsDigit).ToArray());
|
||||
if (PhoneRe.IsMatch(s) && digits.Length is >= 10 and <= 15)
|
||||
{
|
||||
return new CardContactDto("phone", (s.StartsWith('+') ? "+" : string.Empty) + digits);
|
||||
}
|
||||
|
||||
if (LinkedinRe.IsMatch(s))
|
||||
{
|
||||
return new CardContactDto("linkedin", s);
|
||||
}
|
||||
|
||||
if (WhatsappRe.IsMatch(s))
|
||||
{
|
||||
return new CardContactDto("whatsapp", s);
|
||||
}
|
||||
|
||||
if (s.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string host = SiteHostRe.Replace(s.ToLowerInvariant(), string.Empty)
|
||||
.Split('/')[0]
|
||||
.Split('?')[0]
|
||||
.Split(':')[0];
|
||||
if (SkipSiteHosts.Contains(host) || host.EndsWith(".teletype.in", StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new CardContactDto("site", s);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Собирает квалифицированные контакты из разбора/текста.
|
||||
/// </summary>
|
||||
/// <param name="contacts">Сырые контакты разбора: строка со значениями через <c>;</c>/<c>|</c>/перенос.</param>
|
||||
/// <param name="text">Исходный текст сообщения — кандидаты, если в разборе контактов нет.</param>
|
||||
/// <returns>До <see cref="MaxContacts"/> записей {type, value} без дублей (casefold-значение).</returns>
|
||||
public static IReadOnlyList<CardContactDto> Build(string? contacts, string? text)
|
||||
{
|
||||
var candidates = new List<string>();
|
||||
if (contacts is not null)
|
||||
{
|
||||
candidates.AddRange(Regex.Split(contacts, @"[;|\n]+", RegexOptions.CultureInvariant));
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
candidates.Add(string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
return BuildFromCandidates(candidates, text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Собирает квалифицированные контакты из списка значений разбора.
|
||||
/// </summary>
|
||||
/// <param name="contactValues">Список сырых значений контактов (пустой/null — извлечение из текста).</param>
|
||||
/// <param name="text">Исходный текст сообщения — кандидаты, если список пуст.</param>
|
||||
/// <returns>До <see cref="MaxContacts"/> записей {type, value} без дублей.</returns>
|
||||
public static IReadOnlyList<CardContactDto> Build(IEnumerable<string>? contactValues, string? text)
|
||||
{
|
||||
var candidates = new List<string>();
|
||||
if (contactValues is not null)
|
||||
{
|
||||
foreach (string value in contactValues)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.AddRange(Regex.Split(value, @"[;|\n]+", RegexOptions.CultureInvariant));
|
||||
}
|
||||
}
|
||||
|
||||
return BuildFromCandidates(candidates, text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Основной контакт карточки — значение с наименьшим приоритетом.
|
||||
/// </summary>
|
||||
/// <param name="contacts">Квалифицированные контакты (см. <see cref="Build"/>).</param>
|
||||
/// <returns>Значение основного контакта или пустая строка, если контактов нет.</returns>
|
||||
public static string Primary(IReadOnlyList<CardContactDto> contacts)
|
||||
{
|
||||
if (contacts.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
CardContactDto best = contacts[0];
|
||||
int bestOrder = OrderOf(best.Type);
|
||||
foreach (CardContactDto contact in contacts)
|
||||
{
|
||||
int order = OrderOf(contact.Type);
|
||||
if (order < bestOrder)
|
||||
{
|
||||
best = contact;
|
||||
bestOrder = order;
|
||||
}
|
||||
}
|
||||
|
||||
return best.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Извлекает кандидатов контактов из текста
|
||||
/// </summary>
|
||||
/// <param name="body">Текст сообщения или значение метки «Контакты: …».</param>
|
||||
/// <returns>До <see cref="MaxTextCandidates"/> сырых кандидатов в порядке появления (телефон — нормализован).</returns>
|
||||
public static IReadOnlyList<string> ExtractFromText(string? body)
|
||||
{
|
||||
string text = body ?? string.Empty;
|
||||
var result = new List<string>();
|
||||
AddUnique(result, AtNameRe.Matches(text).Select(m => m.Value));
|
||||
AddUnique(result, EmailInTextRe.Matches(text).Select(m => m.Value));
|
||||
AddUnique(result, PhoneInTextRe.Matches(text).Select(m => NormalizePhone(m.Value)));
|
||||
return result.Count > MaxTextCandidates ? result.Take(MaxTextCandidates).ToList() : result;
|
||||
}
|
||||
|
||||
internal static string NormalizePhone(string phone)
|
||||
{
|
||||
string normalized = phone.Replace(" ", string.Empty)
|
||||
.Replace("\u00a0", string.Empty)
|
||||
.Replace("-", string.Empty)
|
||||
.Replace("(", string.Empty)
|
||||
.Replace(")", string.Empty);
|
||||
return normalized.Length > MaxNormalizedPhoneLength
|
||||
? normalized[..MaxNormalizedPhoneLength]
|
||||
: normalized;
|
||||
}
|
||||
|
||||
private static int OrderOf(string type)
|
||||
{
|
||||
return PrimaryOrder.TryGetValue(type, out int order) ? order : UnknownTypePriority;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CardContactDto> BuildFromCandidates(List<string> candidates, string? text)
|
||||
{
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
candidates.AddRange(ExtractFromText(text));
|
||||
}
|
||||
|
||||
var result = new List<CardContactDto>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (string candidate in candidates)
|
||||
{
|
||||
CardContactDto? qualified = Qualify(candidate);
|
||||
if (qualified is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string key = qualified.Value.ToLowerInvariant();
|
||||
if (!seen.Add(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(qualified);
|
||||
if (result.Count >= MaxContacts)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void AddUnique(List<string> result, IEnumerable<string> values)
|
||||
{
|
||||
foreach (string value in values)
|
||||
{
|
||||
if (!result.Contains(value, StringComparer.Ordinal))
|
||||
{
|
||||
result.Add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Parse;
|
||||
|
||||
/// <summary>
|
||||
/// Хэш текста для дедупликации сообщений.
|
||||
/// </summary>
|
||||
public static class DedupHasher
|
||||
{
|
||||
/// <summary>
|
||||
/// Хэширует текст сообщения для проверки «сообщение уже в системе».
|
||||
/// </summary>
|
||||
/// <param name="text">Текст сообщения; null/пустой — как пустая строка.</param>
|
||||
/// <returns>SHA1-hex (32 символа) нормализованного текста; детерминирован для равных по регистру/пунктуации текстов.</returns>
|
||||
public static string Hash(string? text)
|
||||
{
|
||||
string normalized = Normalize(text ?? string.Empty);
|
||||
byte[] hash = SHA1.HashData(Encoding.UTF8.GetBytes(normalized));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string Normalize(string text)
|
||||
{
|
||||
var builder = new StringBuilder(text.Length);
|
||||
foreach (Rune rune in text.EnumerateRunes())
|
||||
{
|
||||
if (Rune.IsLetterOrDigit(rune) || rune.Value == '_')
|
||||
{
|
||||
builder.Append(rune);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Deal.Modules.Kanban.Application.ColumnRules;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Parse;
|
||||
|
||||
/// <summary>
|
||||
/// Локальный структуратор сообщения без ИИ
|
||||
/// </summary>
|
||||
public sealed class LocalFieldsParser(ISettingsStore store)
|
||||
{
|
||||
private const int TitleLimit = 140;
|
||||
|
||||
private const int ContactsLimit = 200;
|
||||
|
||||
internal const int MaxGrades = 4;
|
||||
|
||||
private const int MaxPickTokens = 10;
|
||||
|
||||
private const int MaxStackResult = 12;
|
||||
|
||||
private static readonly Regex LabelRe = new(
|
||||
@"^[\s*>#_~]*([А-Яа-яЁёA-Za-z][А-Яа-яЁёA-Za-z0-9 /+\-]{1,36}?)\s*[:|]\s*(.+)$",
|
||||
RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex TokenRe = new(
|
||||
@"(?:[A-Za-zА-Яа-яЁё0-9][A-Za-zА-Яа-яЁё0-9#.+\-]*|\.[A-Za-zА-Яа-яЁё][A-Za-zА-Яа-яЁё0-9#.+\-]*)",
|
||||
RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex InlineStackRe = new(
|
||||
@"\b(?:стек|технологии|скиллы|скилы|языки|язык|инструменты)\s*[:|]\s*([^\n]{2,120})",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly IReadOnlyList<(string Category, IReadOnlySet<string> Synonyms)> FieldLabels =
|
||||
BuildFieldLabels();
|
||||
|
||||
private static readonly char[] WordTrimChars = ",;.:«»\"'()".ToCharArray();
|
||||
|
||||
/// <summary>
|
||||
/// Разбирает текст локальным структуратором по настройкам тенанта.
|
||||
/// </summary>
|
||||
/// <param name="text">Текст сообщения.</param>
|
||||
/// <returns>Локальные поля карточки (маркерная гипотеза типа, известность=false).</returns>
|
||||
public async Task<LocalParsedFields> ParseAsync(string? text, CancellationToken ct)
|
||||
{
|
||||
// Переопределения — одним GetAllAsync (C30: типизированный снимок вместо локальных KV-читателей).
|
||||
return Parse(text, await TenantSettingsSnapshot.LoadAsync(store, ct));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Разбирает текст по ПЕРЕДАННОМУ типизированному снимку настроек
|
||||
/// </summary>
|
||||
/// <param name="text">Текст сообщения.</param>
|
||||
/// <param name="settings">Типизированный снимок настроек тенанта на проход pump.</param>
|
||||
/// <returns>Локальные поля карточки (маркерная гипотеза типа, известность=false).</returns>
|
||||
public LocalParsedFields Parse(string? text, TenantSettingsSnapshot settings)
|
||||
{
|
||||
IReadOnlyList<string> hireMarkers =
|
||||
settings.GetStringList(SettingsKeys.HireMarkers, SettingsDefaults.HireMarkers);
|
||||
IReadOnlyList<string> levelTerms =
|
||||
settings.GetStringList(SettingsKeys.LevelTerms, SettingsDefaults.LevelTerms);
|
||||
return Parse(text, hireMarkers, levelTerms);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Чистое ядро локального разбора.
|
||||
/// </summary>
|
||||
/// <param name="text">Текст сообщения; null — пустая строка (как <c>text or ""</c>).</param>
|
||||
/// <param name="hireMarkers">Маркеры найма (как сохранены или дефолты; нормализуются внутри).</param>
|
||||
/// <param name="levelTerms">Термины грейдов (как сохранены или дефолты; нормализуются внутри).</param>
|
||||
/// <returns>Локальные поля: заголовок, суть, стек/грейд/бюджет/контакты, признак найма (known=false, board=null).</returns>
|
||||
public static LocalParsedFields Parse(
|
||||
string? text,
|
||||
IReadOnlyCollection<string>? hireMarkers,
|
||||
IReadOnlyCollection<string>? levelTerms)
|
||||
{
|
||||
var hire = NormalizeMarkers(hireMarkers);
|
||||
var levels = NormalizeMarkers(levelTerms);
|
||||
|
||||
var lines = new List<string>();
|
||||
foreach (string rawLine in (text ?? string.Empty).Split('\n'))
|
||||
{
|
||||
string cleaned = MessageTextCleaner.CleanLine(rawLine);
|
||||
if (cleaned.Length > 0)
|
||||
{
|
||||
lines.Add(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
string body = string.Join("\n", lines);
|
||||
string lower = body.ToLowerInvariant();
|
||||
|
||||
var stackTokens = new List<string>();
|
||||
var grades = new List<string>();
|
||||
var contacts = new List<string>();
|
||||
string budgetRaw = string.Empty;
|
||||
foreach (string line in lines.Skip(1))
|
||||
{
|
||||
(string Category, string Value)? hit = FieldOf(line);
|
||||
if (hit is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (hit.Value.Category)
|
||||
{
|
||||
case FieldCategoryStack:
|
||||
stackTokens.AddRange(PickStackTokens(hit.Value.Value));
|
||||
break;
|
||||
case FieldCategoryGrade:
|
||||
foreach (string token in TokensOf(hit.Value.Value))
|
||||
{
|
||||
string level = token.ToLowerInvariant();
|
||||
if (levels.Contains(level) && !grades.Contains(level, StringComparer.Ordinal))
|
||||
{
|
||||
grades.Add(level);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case FieldCategoryContacts:
|
||||
contacts.AddRange(ContactsQualifier.ExtractFromText(hit.Value.Value));
|
||||
break;
|
||||
case FieldCategoryBudget:
|
||||
budgetRaw = hit.Value.Value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (contacts.Count == 0)
|
||||
{
|
||||
contacts.AddRange(ContactsQualifier.ExtractFromText(body));
|
||||
}
|
||||
|
||||
if (stackTokens.Count == 0)
|
||||
{
|
||||
foreach (Match match in InlineStackRe.Matches(body))
|
||||
{
|
||||
stackTokens.AddRange(PickStackTokens(match.Groups[1].Value));
|
||||
if (stackTokens.Count > 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (grades.Count == 0)
|
||||
{
|
||||
foreach (string word in lower.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
string level = word.Trim(WordTrimChars).ToLowerInvariant();
|
||||
if (levels.Contains(level))
|
||||
{
|
||||
grades.Add(level);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (budgetRaw.Length == 0)
|
||||
{
|
||||
budgetRaw = body;
|
||||
}
|
||||
|
||||
BudgetRangeDto? budget = FirstAmount(budgetRaw);
|
||||
if (budget is null && lines.Count > 0)
|
||||
{
|
||||
budget = FirstAmount(body);
|
||||
}
|
||||
|
||||
string title = MessageTextCleaner.CleanShort(lines.Count > 0 ? lines[0] : body, TitleLimit);
|
||||
if (title.Length == 0)
|
||||
{
|
||||
title = MessageTextCleaner.CleanShort(body, TitleLimit);
|
||||
}
|
||||
|
||||
string summary = SummaryComposer.LocalSummary(body, lines);
|
||||
bool isVacancy = ContainsAny(lower, hire);
|
||||
string contactsText = MessageTextCleaner.SliceCodePoints(string.Join("; ", contacts), ContactsLimit);
|
||||
|
||||
var stack = new List<string>();
|
||||
foreach (string token in stackTokens)
|
||||
{
|
||||
if (!stack.Contains(token, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
stack.Add(token);
|
||||
}
|
||||
}
|
||||
|
||||
var resultStack = new List<string>(Math.Min(stack.Count, MaxStackResult));
|
||||
foreach (string token in stack)
|
||||
{
|
||||
resultStack.Add(token);
|
||||
if (resultStack.Count >= MaxStackResult)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var resultGrades = new List<string>(Math.Min(grades.Count, MaxGrades));
|
||||
foreach (string grade in grades)
|
||||
{
|
||||
resultGrades.Add(grade);
|
||||
if (resultGrades.Count >= MaxGrades)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new LocalParsedFields(title, summary, resultStack, resultGrades, budget, contactsText, isVacancy);
|
||||
}
|
||||
|
||||
internal static (string Category, string Value)? FieldOf(string line)
|
||||
{
|
||||
Match match = LabelRe.Match(line);
|
||||
if (!match.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string label = match.Groups[1].Value.Trim().ToLowerInvariant();
|
||||
string value = match.Groups[2].Value.Trim();
|
||||
if (value.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ((string category, IReadOnlySet<string> synonyms) in FieldLabels)
|
||||
{
|
||||
if (synonyms.Contains(label))
|
||||
{
|
||||
return (category, value);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> PickStackTokens(string value)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
foreach (string token in TokensOf(value))
|
||||
{
|
||||
string lower = token.ToLowerInvariant();
|
||||
if (MessageListNormalizer.StackStopWords.Contains(lower) || token.Length < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
tokens.Add(token);
|
||||
if (tokens.Count >= MaxPickTokens)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> TokensOf(string value)
|
||||
{
|
||||
foreach (Match match in TokenRe.Matches(value))
|
||||
{
|
||||
yield return match.Value;
|
||||
}
|
||||
}
|
||||
|
||||
private static BudgetRangeDto? FirstAmount(string source)
|
||||
{
|
||||
IReadOnlyList<AmountRange> amounts = AmountParser.Parse(source);
|
||||
if (amounts.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
AmountRange first = amounts[0];
|
||||
return new BudgetRangeDto(first.From, first.To, first.Cur);
|
||||
}
|
||||
|
||||
private static IReadOnlySet<string> NormalizeMarkers(IReadOnlyCollection<string>? markers)
|
||||
{
|
||||
var result = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (markers is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (string marker in markers)
|
||||
{
|
||||
string normalized = marker.Trim().ToLowerInvariant();
|
||||
if (normalized.Length > 0)
|
||||
{
|
||||
result.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool ContainsAny(string lower, IReadOnlySet<string> markers)
|
||||
{
|
||||
foreach (string marker in markers)
|
||||
{
|
||||
if (lower.Contains(marker, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private const string FieldCategoryStack = "stack";
|
||||
|
||||
// Категория поля «Грейд:». Константа-строка (свитч по категориям меток).
|
||||
private const string FieldCategoryGrade = "grade";
|
||||
|
||||
// Категория поля «Контакты:». Константа-строка (свитч по категориям меток).
|
||||
private const string FieldCategoryContacts = "contacts";
|
||||
|
||||
// Категория поля «Бюджет:». Константа-строка (свитч по категориям меток).
|
||||
private const string FieldCategoryBudget = "budget";
|
||||
|
||||
private static IReadOnlyList<(string Category, IReadOnlySet<string> Synonyms)> BuildFieldLabels()
|
||||
{
|
||||
return new List<(string, IReadOnlySet<string>)>
|
||||
{
|
||||
(FieldCategoryStack, new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"стек", "технологии", "технология", "скиллы", "скилы", "языки", "язык", "инструменты",
|
||||
"tools", "tech stack", "stack",
|
||||
}),
|
||||
(FieldCategoryGrade, new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"грейд", "уровень", "грейд/уровень", "seniority", "level",
|
||||
}),
|
||||
(FieldCategoryContacts, new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"контакт", "контакты", "связь", "телеграм", "почта", "email", "контакты для связи",
|
||||
}),
|
||||
(FieldCategoryBudget, new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"бюджет", "оплата", "зп", "зарплата", "вилка", "оклад", "ставка", "цена", "цену",
|
||||
"гонорар", "pay", "salary",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Parse;
|
||||
|
||||
/// <summary>
|
||||
/// Нормализация списков из ответов разбора
|
||||
/// </summary>
|
||||
public static class MessageListNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Максимум элементов стека.
|
||||
/// </summary>
|
||||
public const int MaxStackItems = 12;
|
||||
|
||||
private static readonly Regex ListSeparatorsRe = new(@"[;|\n]+", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex TrailingPunctSpaceRe = new(@"\s+([.,])\s*$", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly IReadOnlySet<string> StopWordsSet = BuildStopWords();
|
||||
|
||||
/// <summary>
|
||||
/// Нормализует строковый список/одиночную строку.
|
||||
/// </summary>
|
||||
/// <param name="value">Строка-список («Java; Kotlin») или null.</param>
|
||||
/// <returns>Элементы списка (очищенные, без дублей и мусора).</returns>
|
||||
public static IReadOnlyList<string> NormalizeList(string? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return CleanParts(ListSeparatorsRe.Split(value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Нормализует список элементов.
|
||||
/// </summary>
|
||||
/// <param name="values">Список элементов или null.</param>
|
||||
/// <returns>Очищенные элементы без дублей и мусора.</returns>
|
||||
public static IReadOnlyList<string> NormalizeList(IEnumerable<string>? values)
|
||||
{
|
||||
return CleanParts(values ?? Array.Empty<string>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Стек из строкового значения
|
||||
/// </summary>
|
||||
/// <param name="value">Строка-список стека или null.</param>
|
||||
/// <returns>Стек (≤12 элементов).</returns>
|
||||
public static IReadOnlyList<string> NormalizeStack(string? value)
|
||||
{
|
||||
return TakeStack(NormalizeList(value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Стек из списка элементов.
|
||||
/// </summary>
|
||||
/// <param name="values">Список технологий/направлений или null.</param>
|
||||
/// <returns>Стек (≤12 элементов).</returns>
|
||||
public static IReadOnlyList<string> NormalizeStack(IEnumerable<string>? values)
|
||||
{
|
||||
return TakeStack(NormalizeList(values));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Стоп-слова стека
|
||||
/// </summary>
|
||||
public static IReadOnlySet<string> StackStopWords => StopWordsSet;
|
||||
|
||||
private static IReadOnlyList<string> CleanParts(IEnumerable<string> parts)
|
||||
{
|
||||
var result = new List<string>();
|
||||
foreach (string? raw in parts)
|
||||
{
|
||||
string s = (raw ?? string.Empty).Trim().Trim('*', '`', '#').Trim();
|
||||
s = TrailingPunctSpaceRe.Replace(s, "$1");
|
||||
s = s.Trim(',').Trim();
|
||||
if (s.Length > 1 && !result.Contains(s, StringComparer.Ordinal))
|
||||
{
|
||||
result.Add(s);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> TakeStack(IReadOnlyList<string> items)
|
||||
{
|
||||
var stack = new List<string>(Math.Min(items.Count, MaxStackItems));
|
||||
foreach (string item in items)
|
||||
{
|
||||
if (item.Length < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
stack.Add(item);
|
||||
if (stack.Count >= MaxStackItems)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return stack;
|
||||
}
|
||||
|
||||
private static IReadOnlySet<string> BuildStopWords()
|
||||
{
|
||||
return new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"и", "или", "на", "по", "с", "не", "а", "в", "о", "об", "от", "до", "для", "опыт", "знание",
|
||||
"знания", "уметь", "умение", "умения", "работать", "работы", "работа", "работе", "требуется",
|
||||
"приветствуется", "будет", "плюсом", "разработка", "разработке", "разработчик", "разработчика",
|
||||
"вакансия", "вакансию", "вакансии", "команда", "команду", "команды", "проект", "проекта", "проекты",
|
||||
"приветствуются", "желательно", "уверенное", "хорошее", "понимание", "навыки", "навык", "навыков",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Parse;
|
||||
|
||||
/// <summary>
|
||||
/// Чистка текстовых полей сообщения/карточки от markdown-разметки, ссылок и служебных символов.
|
||||
/// </summary>
|
||||
public static class MessageTextCleaner
|
||||
{
|
||||
private const string HashGuard = "\u2063";
|
||||
|
||||
private const string ZeroWidthSpace = "\u200b";
|
||||
|
||||
private const string NonBreakingSpace = "\u00a0";
|
||||
|
||||
private static readonly Regex MarkdownLinkRe = new(@"\[([^\]]*)\]\([^)\s]+\)", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex MarkdownBoldRe = new(@"\*\*(.+?)\*\*", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex MarkdownBold2Re = new(@"__([^_\n]+?)__", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex MarkdownCodeRe = new(@"`([^`\n]+?)`", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex MarkdownStrikeRe = new(@"~~([^~\n]+?)~~", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex BareUrlRe = new(@"https?://[^\s<>""']+", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex HashProtectRe = new(@"\b([A-Za-zА-Яа-яЁё])\#", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex MarkdownSymbolsRe = new("[*`#>~]+", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex LineStartMarkersRe = new(@"(?m)^[\s>#*\-–—•▪▫●○‣]+\s*", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex SpaceCollapseRe = new(@"[ \t]+", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex LineIndentRe = new(@"\n[ \t]+", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex MultiNewlineRe = new(@"\n{2,}", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex NewlinesToSpaceRe = new(@"\n+", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex LineEdgesRe = new(@"^[\s*>#_~]+|[\s*>#_~]+$", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly char[] EdgeTrimChars = " \t\n\r-–—·•|:;,".ToCharArray();
|
||||
|
||||
// Одноразовые кодовые точки-разделители для ручного прохода символов.
|
||||
private const int EmptyCodePoint = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Чистит текстовое поле в одну строку
|
||||
/// </summary>
|
||||
/// <param name="text">Сырой текст (markdown/ссылки/эмодзи); null → пустая строка (как <c>str(text or "")</c>).</param>
|
||||
/// <param name="limit">Максимум кодовых точек результата; обрезка по границе переноса/пробела с многоточием; null/0 — без обрезки.</param>
|
||||
/// <returns>Очищенный однострочный текст.</returns>
|
||||
public static string CleanShort(string? text, int? limit = null)
|
||||
{
|
||||
return NewlinesToSpaceRe.Replace(CleanBlock(text, limit), " ");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Чистит блок текста с сохранением переносов строк
|
||||
/// </summary>
|
||||
/// <param name="text">Сырой текст; null → пустая строка.</param>
|
||||
/// <param name="limit">Максимум кодовых точек результата; обрезка по последнему переносу/пробелу ближе середины лимита, иначе жёсткая по лимиту; в конец добавляется «…». null/0 — без обрезки.</param>
|
||||
/// <returns>Очищенный текст с сохранённой структурой строк.</returns>
|
||||
public static string CleanBlock(string? text, int? limit = null)
|
||||
{
|
||||
string s = text ?? string.Empty;
|
||||
s = MarkdownLinkRe.Replace(s, m => m.Groups[1].Value.Trim());
|
||||
s = MarkdownBoldRe.Replace(s, "$1");
|
||||
s = MarkdownBold2Re.Replace(s, "$1");
|
||||
s = MarkdownCodeRe.Replace(s, "$1");
|
||||
s = MarkdownStrikeRe.Replace(s, "$1");
|
||||
s = s.Replace("||", string.Empty, StringComparison.Ordinal);
|
||||
s = BareUrlRe.Replace(s, " ");
|
||||
s = HashProtectRe.Replace(s, m => m.Groups[1].Value + HashGuard);
|
||||
s = s.Replace(ZeroWidthSpace, string.Empty, StringComparison.Ordinal);
|
||||
s = s.Replace(NonBreakingSpace, " ", StringComparison.Ordinal);
|
||||
s = MarkdownSymbolsRe.Replace(s, " ");
|
||||
s = s.Replace(HashGuard, "#", StringComparison.Ordinal);
|
||||
s = RemoveEmojiCodePoints(s);
|
||||
s = LineStartMarkersRe.Replace(s, string.Empty);
|
||||
s = SpaceCollapseRe.Replace(s, " ");
|
||||
s = LineIndentRe.Replace(s, "\n");
|
||||
s = MultiNewlineRe.Replace(s, "\n");
|
||||
s = s.Trim(EdgeTrimChars);
|
||||
if (limit is > 0 && CountCodePoints(s) > limit.Value)
|
||||
{
|
||||
s = CutByBoundary(s, limit.Value);
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обрезает строку по краевым служебным символам markdown
|
||||
/// </summary>
|
||||
/// <param name="line">Строка текста.</param>
|
||||
/// <returns>Строка без краевого мусора (пустая, если мусора было больше).</returns>
|
||||
public static string CleanLine(string? line)
|
||||
{
|
||||
return LineEdgesRe.Replace(line ?? string.Empty, string.Empty).Trim();
|
||||
}
|
||||
|
||||
internal static int CountCodePoints(string value)
|
||||
{
|
||||
int count = 0;
|
||||
for (int index = 0; index < value.Length; index++)
|
||||
{
|
||||
count++;
|
||||
if (char.IsHighSurrogate(value[index]) && index + 1 < value.Length && char.IsLowSurrogate(value[index + 1]))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
internal static string SliceCodePoints(string value, int max)
|
||||
{
|
||||
if (max <= 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (CountCodePoints(value) <= max)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(value.Length);
|
||||
int taken = 0;
|
||||
for (int index = 0; index < value.Length && taken < max; index++)
|
||||
{
|
||||
bool pair = char.IsHighSurrogate(value[index])
|
||||
&& index + 1 < value.Length
|
||||
&& char.IsLowSurrogate(value[index + 1]);
|
||||
builder.Append(value[index]);
|
||||
if (pair)
|
||||
{
|
||||
index++;
|
||||
builder.Append(value[index]);
|
||||
}
|
||||
|
||||
taken++;
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string RemoveEmojiCodePoints(string value)
|
||||
{
|
||||
var builder = new StringBuilder(value.Length);
|
||||
for (int index = 0; index < value.Length; index++)
|
||||
{
|
||||
int codePoint = DecodeCodePoint(value, index, out int length);
|
||||
if (codePoint == EmptyCodePoint)
|
||||
{
|
||||
builder.Append(value[index]); // непарный суррогат: не эмодзи — сохраняем как есть (1:1 python)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (codePoint.IsEmojiCodePoint())
|
||||
{
|
||||
index += length - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append(value, index, length);
|
||||
index += length - 1;
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
// Кодовая точка с позиции строки (суррогатная пара — целиком).
|
||||
// value: Строка.
|
||||
// index: Позиция символа.
|
||||
// length: Длина последовательности в UTF-16 единицах (1 или 2).
|
||||
// Возвращает: Кодовая точка или EmptyCodePoint для непарного суррогата.
|
||||
private static int DecodeCodePoint(
|
||||
string value,
|
||||
int index,
|
||||
out int length)
|
||||
{
|
||||
char current = value[index];
|
||||
if (char.IsHighSurrogate(current) && index + 1 < value.Length && char.IsLowSurrogate(value[index + 1]))
|
||||
{
|
||||
length = 2;
|
||||
return char.ConvertToUtf32(current, value[index + 1]);
|
||||
}
|
||||
|
||||
if (char.IsLowSurrogate(current) || char.IsHighSurrogate(current))
|
||||
{
|
||||
length = 1;
|
||||
return EmptyCodePoint;
|
||||
}
|
||||
|
||||
length = 1;
|
||||
return current;
|
||||
}
|
||||
|
||||
private static string CutByBoundary(string value, int limit)
|
||||
{
|
||||
string cut = SliceCodePoints(value, limit);
|
||||
int lineBreak = cut.LastIndexOf('\n');
|
||||
int space = cut.LastIndexOf(' ');
|
||||
int at = lineBreak > limit / 2
|
||||
? lineBreak
|
||||
: (space > limit / 2 ? space : -1);
|
||||
if (at >= 0)
|
||||
{
|
||||
cut = cut[..at];
|
||||
}
|
||||
|
||||
return cut.TrimEnd() + "…";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Parse;
|
||||
|
||||
// Расширения строк для разбора текста сообщений.
|
||||
internal static class StringExtensions
|
||||
{
|
||||
private static readonly string[] FooterHintsArray =
|
||||
{
|
||||
"откликнуться через", "runello", "больше вакансий", "teletype", "при отклике укажите",
|
||||
"больше заявок", "узнать подробнее", "написать в лс", "пишите в лс",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Содержит ли текст служебный футер-хинт.
|
||||
/// </summary>
|
||||
/// <param name="text">Текст (в любом регистре; null трактуется как пустая строка).</param>
|
||||
/// <returns>True — текст похож на футер агрегатора/служебную строку.</returns>
|
||||
public static bool ContainsFooterHint(this string text)
|
||||
{
|
||||
string lower = text.ToLowerInvariant();
|
||||
foreach (string hint in FooterHintsArray)
|
||||
{
|
||||
if (lower.Contains(hint, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Parse;
|
||||
|
||||
/// <summary>
|
||||
/// Сборка блока «О заявке» карточки.
|
||||
/// </summary>
|
||||
public static class SummaryComposer
|
||||
{
|
||||
private const int MaxRequirementsItems = 14;
|
||||
|
||||
private const int MaxPlusItems = 10;
|
||||
|
||||
private const int MaxSummaryParts = 4;
|
||||
|
||||
private const int MinSummaryLength = 40;
|
||||
|
||||
private const int SummaryFallbackLimit = 360;
|
||||
|
||||
private const int MaxSummaryLength = 600;
|
||||
|
||||
private const int DefaultSkipFirst = 1;
|
||||
|
||||
private static readonly IReadOnlySet<string> TrivialWords = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"вакансия", "вакансию", "фриланс",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Собирает «О заявке» из структурированных полей либо текста.
|
||||
/// </summary>
|
||||
/// <param name="content">Структура разбора: блоки Компания→…→Условия и/или legacy-суть; null — пустая структура.</param>
|
||||
/// <param name="text">Исходный текст сообщения (источник локального пути «О задаче: …»).</param>
|
||||
/// <returns>Текст блока «О заявке» (пустая строка — структуры и текста нет).</returns>
|
||||
public static string Compose(ParsedCardContent? content, string? text)
|
||||
{
|
||||
ParsedCardContent source = content ?? new ParsedCardContent();
|
||||
var blocks = new List<string>();
|
||||
|
||||
string company = MessageTextCleaner.CleanShort(source.Company);
|
||||
if (company.Length > 0)
|
||||
{
|
||||
blocks.Add("Компания: " + company);
|
||||
}
|
||||
|
||||
string format = MessageTextCleaner.CleanShort(source.Format);
|
||||
if (format.Length > 0)
|
||||
{
|
||||
blocks.Add("Формат: " + format);
|
||||
}
|
||||
|
||||
string task = MessageTextCleaner.CleanShort(source.Task);
|
||||
if (task.Length > 0)
|
||||
{
|
||||
blocks.Add("О задаче: " + task);
|
||||
}
|
||||
|
||||
IReadOnlyList<string> requirements = ContentItems(source.Requirements);
|
||||
if (requirements.Count > 0)
|
||||
{
|
||||
blocks.Add("Требования: " + string.Join(", ", requirements.Take(MaxRequirementsItems)));
|
||||
}
|
||||
|
||||
IReadOnlyList<string> plus = ContentItems(source.Plus);
|
||||
if (plus.Count > 0)
|
||||
{
|
||||
blocks.Add("Будет плюсом: " + string.Join(", ", plus.Take(MaxPlusItems)));
|
||||
}
|
||||
|
||||
string conditions = MessageTextCleaner.CleanShort(source.Conditions);
|
||||
if (conditions.Length > 0)
|
||||
{
|
||||
blocks.Add("Условия: " + conditions);
|
||||
}
|
||||
|
||||
if (blocks.Count > 0)
|
||||
{
|
||||
return string.Join("\n", blocks);
|
||||
}
|
||||
|
||||
string legacy = MessageTextCleaner.CleanShort(source.Summary);
|
||||
if (legacy.Length > 0 && !legacy.ContainsFooterHint())
|
||||
{
|
||||
return legacy;
|
||||
}
|
||||
|
||||
var keep = new List<string>();
|
||||
foreach (string rawLine in (text ?? string.Empty).Split('\n'))
|
||||
{
|
||||
string line = rawLine.Trim();
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string lower = line.ToLowerInvariant();
|
||||
if (line.StartsWith('#') || lower.StartsWith("**#") || lower.ContainsFooterHint())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string cleaned = MessageTextCleaner.CleanLine(line);
|
||||
if (cleaned.Length > 0)
|
||||
{
|
||||
keep.Add(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
if (keep.Count > 0)
|
||||
{
|
||||
string shortSummary = LocalSummary(string.Join("\n", keep), keep);
|
||||
if (shortSummary.Length > 0)
|
||||
{
|
||||
return "О задаче: " + shortSummary;
|
||||
}
|
||||
}
|
||||
|
||||
return MessageTextCleaner.CleanShort(text ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Суть карточки при локальном разборе
|
||||
/// </summary>
|
||||
/// <param name="body">Весь очищенный текст (источник fallback-сути).</param>
|
||||
/// <param name="lines">Очищенные непустые строки текста (первая — заголовок).</param>
|
||||
/// <param name="skipFirst">Сколько первых строк пропустить (заголовок); по умолчанию 1.</param>
|
||||
/// <returns>Суть одним абзацем (≤600 символов).</returns>
|
||||
public static string LocalSummary(
|
||||
string? body,
|
||||
IReadOnlyList<string> lines,
|
||||
int skipFirst = DefaultSkipFirst)
|
||||
{
|
||||
var parts = new List<string>(MaxSummaryParts);
|
||||
foreach (string line in lines.Skip(skipFirst))
|
||||
{
|
||||
if (LocalFieldsParser.FieldOf(line) is not null)
|
||||
{
|
||||
continue; // «Стек: …», «Бюджет: …» и т.п. уже разобраны в поля (python L301–303)
|
||||
}
|
||||
|
||||
string cleaned = MessageTextCleaner.CleanShort(line);
|
||||
if (MessageTextCleaner.CountCodePoints(cleaned) < 2
|
||||
|| TrivialWords.Contains(cleaned.ToLowerInvariant()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.Add(cleaned);
|
||||
if (parts.Count >= MaxSummaryParts)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
string summary = string.Join(" ", parts);
|
||||
if (MessageTextCleaner.CountCodePoints(summary) < MinSummaryLength)
|
||||
{
|
||||
summary = MessageTextCleaner.CleanShort(body ?? string.Empty, SummaryFallbackLimit);
|
||||
}
|
||||
|
||||
return MessageTextCleaner.SliceCodePoints(summary, MaxSummaryLength);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ContentItems(IReadOnlyList<string>? values)
|
||||
{
|
||||
var result = new List<string>();
|
||||
foreach (string item in MessageListNormalizer.NormalizeList(values))
|
||||
{
|
||||
string cleaned = MessageTextCleaner.CleanShort(item);
|
||||
if (cleaned.Length > 0)
|
||||
{
|
||||
result.Add(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Registrars;
|
||||
|
||||
/// <summary>
|
||||
/// DI-регистрация модуля Pipeline.
|
||||
/// </summary>
|
||||
public static class PipelineModuleRegistrar
|
||||
{
|
||||
/// <summary>
|
||||
/// Регистрирует сервисы модуля Pipeline в контейнере.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddPipelineModule(this IServiceCollection services)
|
||||
{
|
||||
// Ядра разбора задачи 4: статические (MessageTextCleaner/…/SummaryComposer) не регистрируются;
|
||||
// LocalFieldsParser читает маркеры через ISettingsStore — scoped, как IncomingRules (эталон KanbanModuleRegistrar).
|
||||
services.AddScoped<LocalFieldsParser>();
|
||||
|
||||
services.AddScoped<PipelineIngestService>();
|
||||
services.AddScoped<PipelineProcessingService>();
|
||||
|
||||
services.AddScoped<CardComposer>();
|
||||
services.AddScoped<PipelineCardWriter>();
|
||||
|
||||
services.AddScoped<PipelineWorkerService>();
|
||||
|
||||
services.AddScoped<AiClassifyContextBuilder>();
|
||||
|
||||
// Этап 12, пакет D: ручная переклассификация карточек (POST /api/cards/reclassify|{id}/reclassify).
|
||||
// Синхронный проход той же механикой, что и воркер (фильтр/классификация/сборка/ML), поэтому scoped —
|
||||
// как воркер. Замок single-flight живёт в singleton (общий для всех tenant-запросов процесса).
|
||||
services.AddSingleton<ReclassifyGate>();
|
||||
services.AddScoped<CardReclassifier>();
|
||||
|
||||
services.AddScoped<MlReviewService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.ColumnRules;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Обучающие сигналы ML по карточке ИИ-пути
|
||||
/// </summary>
|
||||
public static class AiCardLearning
|
||||
{
|
||||
/// <summary>
|
||||
/// Пушит обучающие сигналы ML
|
||||
/// </summary>
|
||||
/// <param name="kanjStore">Порт канбана: чтение правил/признака предложения доски.</param>
|
||||
/// <param name="mlClient">Клиент ML (PushAsync — обучающий сигнал).</param>
|
||||
/// <param name="col">Колонка карточки после классификации (реальная, после ContainerAccepts-страховки).</param>
|
||||
/// <param name="parsed">Разбор, на котором собрана карточка (тип/спам из классификатора).</param>
|
||||
/// <param name="text">Текст сообщения (обучающий пример — как source_msg карточки).</param>
|
||||
/// <param name="weight">Вес сигнала (гипотеза ИИ — <see cref="MlLearningLabels.AiPushWeight"/>).</param>
|
||||
public static async Task PushSignalsAsync(
|
||||
ICardStore kanjStore,
|
||||
IMlClient mlClient,
|
||||
string col,
|
||||
AiParsedCardDto parsed,
|
||||
string text,
|
||||
double weight,
|
||||
CancellationToken ct)
|
||||
{
|
||||
bool isServiceCol = col == CardIds.Inbox || col == CardIds.Trash || col == CardIds.Archive;
|
||||
if (!isServiceCol && !parsed.IsSpam)
|
||||
{
|
||||
ContainerDto? board = await kanjStore.GetContainerAsync(col, ct);
|
||||
bool free = board is not null && !board.Suggested && !ColumnRules.HasActiveRules(board.Rules);
|
||||
if (free)
|
||||
{
|
||||
await mlClient.PushAsync(text, col, weight, ct);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.IsVacancyKnown)
|
||||
{
|
||||
await mlClient.PushAsync(
|
||||
text,
|
||||
parsed.IsVacancy ? MlLearningLabels.TypeHireValue : MlLearningLabels.TypeOrderValue,
|
||||
weight,
|
||||
ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Маппинг локального разбора в контрактный <see cref="AiParsedCardDto"/>.
|
||||
/// </summary>
|
||||
public static class AiCardMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Строит разбор карточки из локальных полей и исходного текста.
|
||||
/// </summary>
|
||||
/// <param name="fields">Локальные поля (заголовок/суть/стек/бюджет/контакты/признак найма).</param>
|
||||
/// <param name="text">Исходный текст сообщения (fallback-источник кандидатов контактов).</param>
|
||||
/// <returns>Контрактный разбор: бюджет нормализован, контакты квалифицированы, board=null, is_vacancy_known=false.</returns>
|
||||
public static AiParsedCardDto FromLocal(LocalParsedFields fields, string text)
|
||||
{
|
||||
CardBudgetDto? normalized = BudgetNormalizer.Normalize(fields.Budget);
|
||||
AiBudgetDto? budget = normalized is null ? null : new AiBudgetDto(normalized.From, normalized.To, normalized.Cur);
|
||||
|
||||
IReadOnlyList<CardContactDto> qualified = ContactsQualifier.Build(fields.Contacts, text);
|
||||
var contacts = new List<AiContactDto>(qualified.Count);
|
||||
foreach (CardContactDto contact in qualified)
|
||||
{
|
||||
contacts.Add(new AiContactDto(contact.Type, contact.Value));
|
||||
}
|
||||
|
||||
return new AiParsedCardDto(
|
||||
Title: fields.Title,
|
||||
Company: null,
|
||||
Format: null,
|
||||
Task: null,
|
||||
Requirements: null,
|
||||
Plus: null,
|
||||
Conditions: null,
|
||||
Summary: fields.Summary,
|
||||
Stack: fields.Stack,
|
||||
Budget: budget,
|
||||
Contacts: contacts,
|
||||
IsVacancy: fields.IsVacancy,
|
||||
IsVacancyKnown: false, // маркерная гипотеза — тип подтверждает только ИИ по контексту (python L796)
|
||||
IsSpam: false,
|
||||
Board: null); // смысловые колонки до ИИ не назначаем (python L797)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Text;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.ColumnRules;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Сборка контекста ИИ-классификации из настроек и данных тенанта.
|
||||
/// </summary>
|
||||
/// <param name="settings">KV-настройки тенанта (промпты и «Сфера и ключи»).</param>
|
||||
/// <param name="kanjStore">Порт канбана: доски (non-suggested) и few-shot-примеры журнала CardMoves.</param>
|
||||
public sealed class AiClassifyContextBuilder(ISettingsStore settings, ICardStore kanjStore)
|
||||
{
|
||||
/// <summary>
|
||||
/// Лимит примеров разметки в контексте.
|
||||
/// </summary>
|
||||
public const int MaxLearningExamples = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит описания колонки в строке-описании.
|
||||
/// </summary>
|
||||
public const int MaxBoardDescriptionCodePoints = 160;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит текста примера разметки.
|
||||
/// </summary>
|
||||
public const int MaxMarkupTextCodePoints = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит текста сообщения в контексте.
|
||||
/// </summary>
|
||||
public const int MaxMessageTextCodePoints = 5000;
|
||||
|
||||
private const string NoBoardsLine = "- (колонок пока нет — верните board: null)";
|
||||
|
||||
private const string BoardsHeader = "Доски: ";
|
||||
|
||||
private const string ExamplesHeader = "Примеры разметки пользователя:";
|
||||
|
||||
private const string MessageHeader = "Новое сообщение:";
|
||||
|
||||
/// <summary>
|
||||
/// Заполненный промпт ИИ-фильтра
|
||||
/// </summary>
|
||||
/// <returns>Текст system-промпта фильтра для FilterRequest.</returns>
|
||||
public async Task<string> BuildFilterPromptAsync(CancellationToken ct)
|
||||
{
|
||||
TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct);
|
||||
return FillPrompt(SettingsKeys.AiFilterPrompt, SettingsDefaults.AiFilterPrompt, settingsSnapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Заполненный system-промпт классификации
|
||||
/// </summary>
|
||||
/// <returns>Текст system_prompt ClassifyRequest.</returns>
|
||||
public async Task<string> BuildClassifySystemPromptAsync(CancellationToken ct)
|
||||
{
|
||||
// Промпты/«Сфера и ключи» — одним типизированным снимком (C30): один GetAllAsync вместо 6 GetAsync.
|
||||
TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct);
|
||||
string prompt = FillPrompt(SettingsKeys.AiPrompt, SettingsDefaults.AiPrompt, settingsSnapshot);
|
||||
string card = FillPrompt(SettingsKeys.CardPrompt, SettingsDefaults.CardPrompt, settingsSnapshot);
|
||||
if (card.Length > 0)
|
||||
{
|
||||
prompt = prompt + "\n\n" + card;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// user-контекст классификации «Доски + примеры разметки + Сообщение».
|
||||
/// </summary>
|
||||
/// <param name="text">Текст сообщения (режется до <see cref="MaxMessageTextCodePoints"/>).</param>
|
||||
/// <returns>Текст user_context ClassifyRequest.</returns>
|
||||
public async Task<string> BuildClassifyUserContextAsync(string text, CancellationToken ct)
|
||||
{
|
||||
string boardMap = await BuildBoardMapAsync(ct);
|
||||
IReadOnlyList<AiMarkupExampleDto> examples = await kanjStore.GetAiMarkupExamplesAsync(MaxLearningExamples, ct);
|
||||
|
||||
var context = new StringBuilder();
|
||||
context.Append(BoardsHeader).Append(boardMap).Append("\n\n");
|
||||
if (examples.Count > 0)
|
||||
{
|
||||
context.Append(ExamplesHeader).Append('\n');
|
||||
for (int index = 0; index < examples.Count; index++)
|
||||
{
|
||||
if (index > 0)
|
||||
{
|
||||
context.Append('\n');
|
||||
}
|
||||
|
||||
context.Append("текст: ")
|
||||
.Append(MessageTextCleaner.SliceCodePoints(examples[index].Text, MaxMarkupTextCodePoints))
|
||||
.Append("\n→ колонка: ")
|
||||
.Append(examples[index].Board);
|
||||
}
|
||||
|
||||
context.Append("\n\n");
|
||||
}
|
||||
|
||||
context.Append(MessageHeader).Append('\n')
|
||||
.Append(MessageTextCleaner.SliceCodePoints(text, MaxMessageTextCodePoints));
|
||||
return context.ToString();
|
||||
}
|
||||
|
||||
private async Task<string> BuildBoardMapAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<ContainerDto> containers = await kanjStore.ListContainersAsync(ContainerSpaces.Dashboard, ct);
|
||||
var lines = new List<string>();
|
||||
foreach (ContainerDto container in containers)
|
||||
{
|
||||
if (container.Kind != ContainerKinds.Board || container.Suggested)
|
||||
{
|
||||
continue; // в классификации участвуют только принятые пользовательские колонки
|
||||
}
|
||||
|
||||
lines.Add(BuildBoardLine(container));
|
||||
}
|
||||
|
||||
return lines.Count == 0 ? NoBoardsLine : string.Join("\n", lines);
|
||||
}
|
||||
|
||||
private static string BuildBoardLine(ContainerDto container)
|
||||
{
|
||||
string line = "- " + container.Id + ": " + container.Name;
|
||||
if (ColumnRules.HasActiveRules(container.Rules))
|
||||
{
|
||||
line += " (критерии: " + RulesDescriber.Describe(container.Rules) + ")";
|
||||
}
|
||||
|
||||
string description = container.Description.Trim();
|
||||
if (description.Length > 0)
|
||||
{
|
||||
line += " — " + MessageTextCleaner.SliceCodePoints(description, MaxBoardDescriptionCodePoints);
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
private static string FillPrompt(
|
||||
string key,
|
||||
string defaultValue,
|
||||
TenantSettingsSnapshot settingsSnapshot)
|
||||
{
|
||||
string prompt = settingsSnapshot.GetString(key, defaultValue);
|
||||
string domain = settingsSnapshot.GetString(
|
||||
SettingsKeys.DomainDescription, SettingsDefaults.DomainDescription);
|
||||
IReadOnlyList<string> keywords = settingsSnapshot.GetStringList(
|
||||
SettingsKeys.DomainKeywords, SettingsDefaults.DomainKeywords);
|
||||
return PromptFiller.Fill(prompt, domain, keywords);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Строгий маппинг JSON-ответа ИИ-классификатора в контрактный разбор карточки.
|
||||
/// </summary>
|
||||
public static class AiRawCardMapper
|
||||
{
|
||||
private const int MaxTitleCodePoints = 140;
|
||||
|
||||
private const int MaxFallbackTextCodePoints = 140;
|
||||
|
||||
private const string BudgetAmountPattern = @"[0-9.,]+\s*[кkКK]?";
|
||||
|
||||
private const string CurrencyFieldName = "currency";
|
||||
|
||||
private const string CurrencyFieldNameShort = "cur";
|
||||
|
||||
// Регулярное выражение числа бюджета: число + необязательные «к»/валюта-суффиксы.
|
||||
private static readonly System.Text.RegularExpressions.Regex BudgetNumRe =
|
||||
new(@"\A" + BudgetAmountPattern + @"(?:руб|р|₽)?\z", System.Text.RegularExpressions.RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>
|
||||
/// Маппит JSON-ответ модели в контрактный разбор карточки.
|
||||
/// </summary>
|
||||
/// <param name="json">Сырой JSON-ответ модели (ClassifyReply.Json; типовую схему задаёт промпт).</param>
|
||||
/// <param name="text">Исходный текст сообщения.</param>
|
||||
/// <returns>Разбор: структурированный блок «О заявке»/суть, стек, бюджет, контакты, тип/спам/доска.</returns>
|
||||
/// <exception cref="JsonException">Ответ не объект/не разбирается — «ИИ не дал разбора» (ветка aiFail воркера).</exception>
|
||||
public static AiParsedCardDto Map(string json, string text)
|
||||
{
|
||||
JsonObject? root = JsonNode.Parse(json) as JsonObject
|
||||
?? throw new JsonException("Ответ ИИ-классификатора — не JSON-объект (ok=true, но схема нарушена).");
|
||||
|
||||
string title = MessageTextCleaner.CleanShort(ReadRawString(root, "title"), MaxTitleCodePoints);
|
||||
if (title.Length == 0)
|
||||
{
|
||||
title = MessageTextCleaner.CleanShort(text, MaxFallbackTextCodePoints); // python L455 fallback
|
||||
}
|
||||
|
||||
IReadOnlyList<string> stack = ReadStack(root);
|
||||
AiBudgetDto? budget = ReadBudget(root);
|
||||
IReadOnlyList<AiContactDto> contacts = ReadContacts(root, text);
|
||||
|
||||
string? board = ReadRawString(root, "board").Trim();
|
||||
if (board.Length == 0)
|
||||
{
|
||||
board = null;
|
||||
}
|
||||
|
||||
return new AiParsedCardDto(
|
||||
Title: title,
|
||||
Company: ReadNullableString(root, "company"),
|
||||
Format: ReadNullableString(root, "format"),
|
||||
Task: ReadNullableString(root, "task"),
|
||||
Requirements: ReadItems(root, "requirements"),
|
||||
Plus: ReadItems(root, "plus"),
|
||||
Conditions: ReadNullableString(root, "conditions"),
|
||||
Summary: ReadNullableString(root, "summary"),
|
||||
Stack: stack,
|
||||
Budget: budget,
|
||||
Contacts: contacts,
|
||||
IsVacancy: ReadBool(root, "is_vacancy"),
|
||||
IsVacancyKnown: ReadBool(root, "is_vacancy_known"), // false — воркер стемпит true после успешного ИИ
|
||||
IsSpam: ReadBool(root, "is_spam"),
|
||||
Board: board);
|
||||
}
|
||||
|
||||
private static string ReadRawString(JsonObject root, string field)
|
||||
{
|
||||
if (!root.TryGetPropertyValue(field, out JsonNode? node) || node is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return node is JsonValue value ? ValueToString(value) : string.Empty;
|
||||
}
|
||||
|
||||
private static string? ReadNullableString(JsonObject root, string field)
|
||||
{
|
||||
string value = ReadRawString(root, field);
|
||||
return value.Length == 0 ? null : value;
|
||||
}
|
||||
|
||||
private static bool ReadBool(JsonObject root, string field)
|
||||
{
|
||||
if (!root.TryGetPropertyValue(field, out JsonNode? node) || node is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node is not JsonValue value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.TryGetValue<bool>(out bool flag))
|
||||
{
|
||||
return flag;
|
||||
}
|
||||
|
||||
if (value.TryGetValue<int>(out int number))
|
||||
{
|
||||
return number != 0;
|
||||
}
|
||||
|
||||
if (value.TryGetValue<string>(out string? text))
|
||||
{
|
||||
string lower = (text ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return lower.Length > 0 && lower is not ("0" or "false" or "no" or "нет" or "null" or "none");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? ReadItems(JsonObject root, string field)
|
||||
{
|
||||
if (!root.TryGetPropertyValue(field, out JsonNode? node) || node is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<string> normalized = node is JsonValue scalarValue
|
||||
? MessageListNormalizer.NormalizeList(NodeToString(scalarValue)) // python: строка разбивается по [;|\n]
|
||||
: node is JsonArray array
|
||||
? MessageListNormalizer.NormalizeList(array.Select(NodeToString))
|
||||
: Array.Empty<string>();
|
||||
var result = new List<string>(normalized.Count);
|
||||
foreach (string item in normalized)
|
||||
{
|
||||
string cleaned = MessageTextCleaner.CleanShort(item);
|
||||
if (cleaned.Length > 0)
|
||||
{
|
||||
result.Add(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadStack(JsonObject root)
|
||||
{
|
||||
if (!root.TryGetPropertyValue("stack", out JsonNode? node) || node is null)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return node is JsonValue value
|
||||
? MessageListNormalizer.NormalizeStack(ValueToString(value))
|
||||
: node is JsonArray array
|
||||
? MessageListNormalizer.NormalizeStack(array.Select(NodeToString))
|
||||
: Array.Empty<string>();
|
||||
}
|
||||
|
||||
private static AiBudgetDto? ReadBudget(JsonObject root)
|
||||
{
|
||||
if (!root.TryGetPropertyValue("budget", out JsonNode? node) || node is not JsonObject budget)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string currency = ReadRawString(budget, CurrencyFieldName);
|
||||
if (currency.Length == 0)
|
||||
{
|
||||
currency = ReadRawString(budget, CurrencyFieldNameShort); // ai.py L327: currency или cur
|
||||
}
|
||||
|
||||
double? from = ParseBudgetBound(budget, "from");
|
||||
double? to = ParseBudgetBound(budget, "to");
|
||||
CardBudgetDto? normalized = BudgetNormalizer.Normalize(new BudgetRangeDto(from, to, currency));
|
||||
return normalized is null
|
||||
? null
|
||||
: new AiBudgetDto(normalized.From, normalized.To, normalized.Cur);
|
||||
}
|
||||
|
||||
private static double? ParseBudgetBound(JsonObject budget, string field)
|
||||
{
|
||||
if (!budget.TryGetPropertyValue(field, out JsonNode? node) || node is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node is JsonValue value && value.TryGetValue<double>(out double direct))
|
||||
{
|
||||
return direct == 0 ? null : direct; // python L313: x == 0 → None
|
||||
}
|
||||
|
||||
string text = ReadRawString(budget, field).Replace("\u00a0", string.Empty).Replace(" ", string.Empty).ToLowerInvariant();
|
||||
if (text.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
double multiplier = 1.0;
|
||||
if (text.EndsWith('к') || text.EndsWith('k'))
|
||||
{
|
||||
multiplier = 1000.0;
|
||||
text = text[..^1];
|
||||
}
|
||||
|
||||
if (text.EndsWith("руб", StringComparison.Ordinal))
|
||||
{
|
||||
text = text[..^3];
|
||||
}
|
||||
else if (text.EndsWith('р') || text.EndsWith('₽'))
|
||||
{
|
||||
text = text[..^1];
|
||||
}
|
||||
|
||||
if (!BudgetNumRe.IsMatch(text) || !double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double amount))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
double result = amount * multiplier;
|
||||
return result == 0 ? null : result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AiContactDto> ReadContacts(JsonObject root, string text)
|
||||
{
|
||||
List<string> candidates = new();
|
||||
if (root.TryGetPropertyValue("contacts", out JsonNode? node) && node is not null)
|
||||
{
|
||||
if (node is JsonValue)
|
||||
{
|
||||
candidates.Add(ValueToString((JsonValue)node)); // строка: разобьётся разделителями ниже
|
||||
}
|
||||
else if (node is JsonArray array)
|
||||
{
|
||||
foreach (JsonNode? item in array)
|
||||
{
|
||||
switch (item)
|
||||
{
|
||||
case null:
|
||||
continue;
|
||||
case JsonValue value:
|
||||
candidates.Add(ValueToString(value));
|
||||
break;
|
||||
case JsonObject contactObject:
|
||||
{
|
||||
string value = ReadRawString(contactObject, "value"); // python L402–403: dict.get("value")
|
||||
if (value.Length > 0)
|
||||
{
|
||||
candidates.Add(value);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (candidates.Count == 0)
|
||||
{
|
||||
return Array.Empty<AiContactDto>();
|
||||
}
|
||||
}
|
||||
|
||||
IReadOnlyList<CardContactDto> qualified = ContactsQualifier.Build(
|
||||
candidates.Count > 0 ? candidates : null, text);
|
||||
var result = new List<AiContactDto>(qualified.Count);
|
||||
foreach (CardContactDto contact in qualified)
|
||||
{
|
||||
result.Add(new AiContactDto(contact.Type, contact.Value));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string NodeToString(JsonNode? node)
|
||||
=> node is JsonValue value ? ValueToString(value) : string.Empty;
|
||||
|
||||
private static string ValueToString(JsonValue value)
|
||||
{
|
||||
if (value.TryGetValue<string>(out string? text))
|
||||
{
|
||||
return text ?? string.Empty;
|
||||
}
|
||||
|
||||
if (value.TryGetValue<bool>(out bool flag))
|
||||
{
|
||||
return flag ? "true" : "false";
|
||||
}
|
||||
|
||||
return value.ToJsonString().Trim('"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.ColumnRules;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Сборка снимка карточки из разобранного сообщения для записи через Kanban.
|
||||
/// </summary>
|
||||
public sealed class CardComposer(ICardStore kanjStore, ISettingsStore settings)
|
||||
{
|
||||
private const int MaxTitleCodePoints = 140;
|
||||
|
||||
private const int MaxSummaryCodePoints = 2000;
|
||||
|
||||
private const int MaxSourceMsgCodePoints = 4000;
|
||||
|
||||
private const int MaxPrimaryContactCodePoints = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Собирает полный снимок новой карточки из разбора и строки сообщения.
|
||||
/// </summary>
|
||||
/// <param name="parsed">Разбор сообщения (ИИ-классификатор или локальный путь; контакты квалифицированы).</param>
|
||||
/// <param name="message">Строка очереди с сообщением-источником (метаданные канала, текст, время).</param>
|
||||
/// <param name="cardId">Готовый id карточки (<c>c_...</c>; генерирует PipelineCardWriter через PrefixId).</param>
|
||||
/// <returns>Полный снимок карточки для <see cref="ICardStore.AddCardAsync"/> (CreatedAt проставит хранилище).</returns>
|
||||
public async Task<CardSnapshot> BuildAsync(
|
||||
AiParsedCardDto parsed,
|
||||
QueueItemDto message,
|
||||
string cardId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// «О заявке» всегда собирается из одинаковых блоков (Компания → … → Условия); структуры нет — суть
|
||||
// как есть либо «О задаче: …» из исходника (SummaryComposer.Compose), сверху clean_block 2000.
|
||||
ParsedCardContent content = ToParsedContent(parsed);
|
||||
string summary = MessageTextCleaner.CleanBlock(SummaryComposer.Compose(content, message.Text), MaxSummaryCodePoints);
|
||||
if (summary.Length == 0)
|
||||
{
|
||||
summary = MessageTextCleaner.CleanShort(message.Text, MaxSummaryCodePoints); // python L458 fallback
|
||||
}
|
||||
|
||||
string title = MessageTextCleaner.CleanShort(parsed.Title, MaxTitleCodePoints);
|
||||
if (title.Length == 0)
|
||||
{
|
||||
title = MessageTextCleaner.CleanShort(message.Text, MaxTitleCodePoints); // python L455 fallback
|
||||
}
|
||||
|
||||
IReadOnlyList<string> stack = MessageListNormalizer.NormalizeStack(parsed.Stack);
|
||||
CardBudgetDto? budget = ComposeBudget(parsed.Budget, message.Text, summary);
|
||||
|
||||
// Доска разбора (страховка ContainerAccepts) и конверсия бюджета требуют курсы: типизированный снимок
|
||||
// настроек читается ОДИН раз на карточку (C30) — мок-фолбэк при отсутствии кэша, как раньше LoadRatesAsync.
|
||||
string? boardCandidate = string.IsNullOrWhiteSpace(parsed.Board) ? null : parsed.Board.Trim();
|
||||
TenantSettingsSnapshot? settingsSnapshot = budget is not null || boardCandidate is not null
|
||||
? await TenantSettingsSnapshot.LoadAsync(settings, ct)
|
||||
: null;
|
||||
IReadOnlyDictionary<string, double>? rates = null;
|
||||
if (settingsSnapshot is not null)
|
||||
{
|
||||
rates = settingsSnapshot.TryGetRatesCache()?.Rates ?? MockRates.Values;
|
||||
}
|
||||
|
||||
CardBudgetDto? converted = null;
|
||||
if (budget is not null)
|
||||
{
|
||||
bool conversionOn = settingsSnapshot!.GetBool(SettingsKeys.ConversionOn, SettingsDefaults.ConversionOn);
|
||||
string targetCurrency = NormalizeTargetCurrency(
|
||||
settingsSnapshot.GetString(SettingsKeys.TargetCurrency, SettingsDefaults.TargetCurrency));
|
||||
converted = BudgetNormalizer.ToTarget(budget, conversionOn, targetCurrency, rates);
|
||||
}
|
||||
|
||||
string col = CardIds.Inbox;
|
||||
IReadOnlyList<MatchHitDto> matchHits = Array.Empty<MatchHitDto>();
|
||||
if (boardCandidate is not null)
|
||||
{
|
||||
ContainerDto? board = await kanjStore.GetContainerAsync(boardCandidate, ct);
|
||||
if (board is not null && ColumnRules.ContainerAccepts(board.Rules, message.Text, rates))
|
||||
{
|
||||
col = board.Id;
|
||||
matchHits = ColumnRules.ComputeHits(board.Rules, message.Text, rates);
|
||||
}
|
||||
}
|
||||
|
||||
IReadOnlyList<CardContactDto> contacts = ContactsQualifier.Build(
|
||||
parsed.Contacts.Select(contact => contact.Value).ToList(), message.Text);
|
||||
string contact = MessageTextCleaner.SliceCodePoints(ContactsQualifier.Primary(contacts), MaxPrimaryContactCodePoints);
|
||||
DateTimeOffset receivedAt = message.MsgAtMs != 0
|
||||
? DateTimeOffset.FromUnixTimeMilliseconds(message.MsgAtMs)
|
||||
: message.Source.ReceivedAt != default
|
||||
? message.Source.ReceivedAt
|
||||
: DateTimeOffset.UtcNow;
|
||||
|
||||
return new CardSnapshot
|
||||
{
|
||||
Id = cardId,
|
||||
Col = col,
|
||||
IsNew = true, // создание — точка «новое» (Ruling 2)
|
||||
IsVacancy = parsed.IsVacancy,
|
||||
IsVacancyKnown = parsed.IsVacancyKnown,
|
||||
Title = title,
|
||||
Summary = summary,
|
||||
Stack = stack,
|
||||
BudgetFrom = budget?.From,
|
||||
BudgetTo = budget?.To,
|
||||
BudgetCur = budget?.Cur ?? string.Empty,
|
||||
ConvFrom = converted?.From,
|
||||
ConvTo = converted?.To,
|
||||
ConvCur = converted?.Cur ?? string.Empty,
|
||||
Contact = contact,
|
||||
Contacts = contacts,
|
||||
Source = message.Source,
|
||||
Content = message.Content with { Text = MessageTextCleaner.SliceCodePoints(message.Text, MaxSourceMsgCodePoints) },
|
||||
ReceivedAt = receivedAt,
|
||||
PrevCol = CardIds.Inbox,
|
||||
ArchivedAt = null,
|
||||
MatchHits = matchHits,
|
||||
};
|
||||
}
|
||||
|
||||
private static CardBudgetDto? ComposeBudget(
|
||||
AiBudgetDto? parsedBudget,
|
||||
string? text,
|
||||
string summary)
|
||||
{
|
||||
if (parsedBudget is not null)
|
||||
{
|
||||
return BudgetNormalizer.Normalize(new BudgetRangeDto(parsedBudget.From, parsedBudget.To, parsedBudget.Cur));
|
||||
}
|
||||
|
||||
BudgetRangeDto? fallback = AmountRangeBudgetFallback.Extract(text, summary);
|
||||
return fallback is null ? null : BudgetNormalizer.Normalize(fallback);
|
||||
}
|
||||
|
||||
private static ParsedCardContent ToParsedContent(AiParsedCardDto parsed) => new(
|
||||
Company: parsed.Company,
|
||||
Format: parsed.Format,
|
||||
Task: parsed.Task,
|
||||
Requirements: parsed.Requirements,
|
||||
Plus: parsed.Plus,
|
||||
Conditions: parsed.Conditions,
|
||||
Summary: parsed.Summary);
|
||||
|
||||
// Код целевой валюты для конверсии: trim + верхний регистр; пусто → дефолт RUB (как писал PATCH).
|
||||
// value: Значение настройки targetCurrency (JSON-строка).
|
||||
// Возвращает: Код валюты (RUB/USD/…) либо дефолт.
|
||||
private static string NormalizeTargetCurrency(string value)
|
||||
{
|
||||
string currency = value.Trim().ToUpperInvariant();
|
||||
return currency.Length > 0 ? currency : SettingsDefaults.TargetCurrency;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Ручная переклассификация карточек
|
||||
/// </summary>
|
||||
/// <param name="store">Единый порт хранилища карточек (чтение inbox, обновление полей классификации).</param>
|
||||
/// <param name="settings">KV-хранилище настроек тенанта (выключатели aiEnabled/aiFilterEnabled, маркеры парсера).</param>
|
||||
/// <param name="aiClassifier">Порт ИИ: фильтр и классификация (в Local-режиме — детерминированный).</param>
|
||||
/// <param name="fieldsParser">Локальный структуратор (путь без ИИ / сбой классификатора).</param>
|
||||
/// <param name="composer">Сборка контента карточки (заголовок/суть/стек/бюджет/контакты/колонка).</param>
|
||||
/// <param name="cardsService">Доменные операции карточки (перенос в корзину без дублей логики).</param>
|
||||
/// <param name="mlClient">Клиент ML: обучающие сигналы переклассификации (вес гипотезы ИИ).</param>
|
||||
/// <param name="gate">Single-flight-замок переклассификации (одна за раз).</param>
|
||||
public sealed class CardReclassifier(
|
||||
ICardStore store,
|
||||
ISettingsStore settings,
|
||||
IAiClassifier aiClassifier,
|
||||
LocalFieldsParser fieldsParser,
|
||||
CardComposer composer,
|
||||
CardsService cardsService,
|
||||
IMlClient mlClient,
|
||||
ReclassifyGate gate)
|
||||
{
|
||||
/// <summary>
|
||||
/// Причина: в «Неразобранном» нет карточек для переклассификации
|
||||
/// </summary>
|
||||
public const string EmptyInboxReason = "В «Неразобранном» нет карточек для переклассификации";
|
||||
|
||||
/// <summary>
|
||||
/// Причина: у карточки нет исходного текста
|
||||
/// </summary>
|
||||
public const string NoSourceTextReason = "У карточки нет исходного текста для переклассификации";
|
||||
|
||||
private static readonly AiFilterResultDto PassSkipped = new(Pass: true, Reason: null, Skipped: true);
|
||||
|
||||
// Частота отчётов о прогрессе пакетного прохода (каждые N карточек + финальный отчёт).
|
||||
private const int ProgressStep = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Пакетная переклассификация «Неразобранного»
|
||||
/// </summary>
|
||||
/// <param name="ids">Опциональный список id (null/пусто — все карточки inbox).</param>
|
||||
/// <param name="progress">Наблюдатель прогресса (SSE); null — без отчётов.</param>
|
||||
/// <returns>Итог прохода (счётчики исхода) либо <c>busy</c>, если проход уже идёт.</returns>
|
||||
public async Task<ReclassifyResultDto> ReclassifyInboxAsync(
|
||||
IReadOnlyList<string>? ids,
|
||||
CancellationToken ct,
|
||||
IProgress<ReclassifyProgressDto>? progress = null)
|
||||
{
|
||||
if (!gate.TryEnter())
|
||||
{
|
||||
return Busy();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Pass pass = await CreatePassAsync(ct);
|
||||
IReadOnlyList<CardDto> inbox = await store.ListCardsAsync(new CardsQuery(CardIds.Inbox), ct);
|
||||
List<CardDto> target = SelectTarget(inbox, ids);
|
||||
if (target.Count == 0)
|
||||
{
|
||||
return Build(pass, attempted: 0, started: false, reason: EmptyInboxReason);
|
||||
}
|
||||
|
||||
progress?.Report(new ReclassifyProgressDto(0, target.Count, 0, 0, 0, 0));
|
||||
int done = 0;
|
||||
foreach (CardDto card in target)
|
||||
{
|
||||
await ReclassifyOneAsync(card, pass, ct);
|
||||
done++;
|
||||
if (progress is not null && (done % ProgressStep == 0 || done == target.Count))
|
||||
{
|
||||
progress.Report(new ReclassifyProgressDto(done, target.Count, pass.Moved, pass.Kept, pass.Trashed, pass.Skipped));
|
||||
}
|
||||
}
|
||||
|
||||
return Build(pass, attempted: target.Count, started: true, reason: null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Exit();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Переклассификация одной карточки
|
||||
/// </summary>
|
||||
/// <param name="card">Карточка (уже прочитана вызывающим — 404 остаётся за эндпоинтом).</param>
|
||||
/// <returns>Итог прохода либо <c>busy</c>, если проход уже идёт.</returns>
|
||||
public async Task<ReclassifyResultDto> ReclassifyCardAsync(CardDto card, CancellationToken ct)
|
||||
{
|
||||
if (!gate.TryEnter())
|
||||
{
|
||||
return Busy();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Pass pass = await CreatePassAsync(ct);
|
||||
if (string.IsNullOrWhiteSpace(card.Content.Text))
|
||||
{
|
||||
pass.Skipped++;
|
||||
return Build(pass, attempted: 1, started: false, reason: NoSourceTextReason);
|
||||
}
|
||||
|
||||
await ReclassifyOneAsync(card, pass, ct);
|
||||
return Build(pass, attempted: 1, started: true, reason: null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Exit();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Проход одной карточки ──────────────────────────────────────────────
|
||||
|
||||
// Прогоняет одну карточку по конвейеру переклассификации (фильтр → разбор → сборка → запись → ML).
|
||||
// Спам/непройденный фильтр отправляют карточку в корзину; остальные обновляются результатом разбора.
|
||||
// card: Карточка.
|
||||
// pass: Накопители прохода (настройки/счётчики/признак ИИ).
|
||||
// ct: Токен отмены.
|
||||
private async Task ReclassifyOneAsync(
|
||||
CardDto card,
|
||||
Pass pass,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string text = card.Content.Text ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
pass.Skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
AiParsedCardDto? parsed = null;
|
||||
if (pass.AiEnabled)
|
||||
{
|
||||
AiFilterResultDto filter = pass.AiFilterEnabled ? await FilterSafelyAsync(text, ct) : PassSkipped;
|
||||
if (!filter.Pass)
|
||||
{
|
||||
await TrashAsync(card, text, pass, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
parsed = await aiClassifier.ClassifyAsync(text, ct);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
if (parsed is not null)
|
||||
{
|
||||
parsed = parsed with { IsVacancyKnown = true };
|
||||
pass.AiUsed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed is null)
|
||||
{
|
||||
parsed = AiCardMapper.FromLocal(fieldsParser.Parse(text, pass.Snapshot), text);
|
||||
}
|
||||
|
||||
if (parsed.IsSpam)
|
||||
{
|
||||
await TrashAsync(card, text, pass, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
CardSnapshot snapshot = await composer.BuildAsync(parsed, BuildMessage(card, text), card.Id, ct);
|
||||
await store.ApplyReclassificationAsync(
|
||||
new CardReclassificationDto(
|
||||
CardId: card.Id,
|
||||
Col: snapshot.Col,
|
||||
IsNew: true,
|
||||
IsVacancy: snapshot.IsVacancy,
|
||||
IsVacancyKnown: snapshot.IsVacancyKnown,
|
||||
Title: snapshot.Title,
|
||||
Summary: snapshot.Summary,
|
||||
Stack: snapshot.Stack,
|
||||
Budget: ToBudget(snapshot.BudgetFrom, snapshot.BudgetTo, snapshot.BudgetCur),
|
||||
Converted: ToBudget(snapshot.ConvFrom, snapshot.ConvTo, snapshot.ConvCur),
|
||||
Contact: ResolveContact(card, snapshot.Contact),
|
||||
Contacts: snapshot.Contacts,
|
||||
MatchHits: snapshot.MatchHits),
|
||||
ct);
|
||||
|
||||
if (snapshot.Col == CardIds.Inbox)
|
||||
{
|
||||
pass.Kept++;
|
||||
}
|
||||
else
|
||||
{
|
||||
pass.Moved++;
|
||||
}
|
||||
|
||||
await AiCardLearning.PushSignalsAsync(store, mlClient, snapshot.Col, parsed, text, MlLearningLabels.AiPushWeight, ct);
|
||||
}
|
||||
|
||||
private async Task TrashAsync(
|
||||
CardDto card,
|
||||
string text,
|
||||
Pass pass,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// teach=false: журнал action=trash пишется, но сигнал «спам» кладём явно ниже — с весом ИИ (0.4).
|
||||
await cardsService.TrashCardAsync(card.Id, teach: false, ct);
|
||||
await mlClient.PushAsync(text, MlLearningLabels.Spam, MlLearningLabels.AiPushWeight, ct);
|
||||
pass.Trashed++;
|
||||
}
|
||||
|
||||
// ИИ-фильтр со сбоем-пропуском (недоступность фильтра не прерывает переклассификацию).
|
||||
// text: Текст сообщения.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Решение фильтра либо «пропуск» при сбое.
|
||||
private async Task<AiFilterResultDto> FilterSafelyAsync(string text, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await aiClassifier.FilterAsync(text, ct);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return PassSkipped;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Сборка входа/выхода ────────────────────────────────────────────────
|
||||
|
||||
// Собирает строку-сообщение для CardComposer из полей карточки (источник/время/исходный текст).
|
||||
// card: Карточка-источник (источник и содержимое сообщения).
|
||||
// text: Исходный текст записи источника.
|
||||
// Возвращает: Строка очереди, эквивалентная исходному сообщению карточки.
|
||||
private static QueueItemDto BuildMessage(CardDto card, string text) => new()
|
||||
{
|
||||
Source = card.Source,
|
||||
Content = card.Content with { Text = text },
|
||||
Text = text,
|
||||
MsgAtMs = card.ReceivedAtMs,
|
||||
};
|
||||
|
||||
// Восстанавливает бюджет из полей снимка: пустая валюта — бюджета нет (null).
|
||||
// from: Нижняя граница.
|
||||
// to: Верхняя граница.
|
||||
// cur: Валюта (пусто — нет).
|
||||
// Возвращает: Бюджет карточки либо null.
|
||||
private static CardBudgetDto? ToBudget(
|
||||
double? from,
|
||||
double? to,
|
||||
string cur) =>
|
||||
cur.Length == 0 ? null : new CardBudgetDto(from, to, cur);
|
||||
|
||||
private static string ResolveContact(CardDto card, string computed)
|
||||
{
|
||||
if (computed.Length > 0)
|
||||
{
|
||||
return computed;
|
||||
}
|
||||
|
||||
string oldContact = card.Contact.Trim();
|
||||
return oldContact.Length > 0 && ContactsQualifier.Qualify(oldContact) is not null ? oldContact : string.Empty;
|
||||
}
|
||||
|
||||
private static List<CardDto> SelectTarget(IReadOnlyList<CardDto> inbox, IReadOnlyList<string>? ids)
|
||||
{
|
||||
if (ids is null || ids.Count == 0)
|
||||
{
|
||||
return [.. inbox];
|
||||
}
|
||||
|
||||
var wanted = new HashSet<string>(ids, StringComparer.Ordinal);
|
||||
var target = new List<CardDto>();
|
||||
foreach (CardDto card in inbox)
|
||||
{
|
||||
if (wanted.Contains(card.Id))
|
||||
{
|
||||
target.Add(card);
|
||||
}
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
// Читает настройки прохода: снимок тенанта + выключатели ИИ/ИИ-фильтра.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Накопители прохода с настройками.
|
||||
private async Task<Pass> CreatePassAsync(CancellationToken ct)
|
||||
{
|
||||
TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct);
|
||||
return new Pass(
|
||||
snapshot,
|
||||
snapshot.GetBool(SettingsKeys.AiEnabled, SettingsDefaults.AiEnabled),
|
||||
snapshot.GetBool(SettingsKeys.AiFilterEnabled, SettingsDefaults.AiFilterEnabled));
|
||||
}
|
||||
|
||||
// Ответ занятости: проход уже выполняется.
|
||||
// Возвращает: Итог с busy = true.
|
||||
private static ReclassifyResultDto Busy() =>
|
||||
new(Started: false, Busy: true, Attempted: 0, Reclassified: 0, Moved: 0, Kept: 0, Trashed: 0, Skipped: 0, UsedAi: false, Reason: null);
|
||||
|
||||
// Собирает итог прохода из накопителей.
|
||||
// pass: Накопители прохода.
|
||||
// attempted: Сколько карточек отобрано.
|
||||
// started: Проход выполнен.
|
||||
// reason: Причина (если проход не выполнен) либо null.
|
||||
// Возвращает: Итог с полями wire-контракта.
|
||||
private static ReclassifyResultDto Build(
|
||||
Pass pass,
|
||||
int attempted,
|
||||
bool started,
|
||||
string? reason) =>
|
||||
new(
|
||||
Started: started,
|
||||
Busy: false,
|
||||
Attempted: attempted,
|
||||
Reclassified: pass.Moved + pass.Kept + pass.Trashed,
|
||||
Moved: pass.Moved,
|
||||
Kept: pass.Kept,
|
||||
Trashed: pass.Trashed,
|
||||
Skipped: pass.Skipped,
|
||||
UsedAi: pass.AiUsed,
|
||||
Reason: reason);
|
||||
|
||||
// Накопители одного прохода переклассификации: настройки, счётчики исхода, признак ИИ.
|
||||
// Snapshot: Снимок настроек тенанта (для локального парсера).
|
||||
// AiEnabled: ИИ-слот включён (фильтр/классификация через порт).
|
||||
// AiFilterEnabled: ИИ-фильтр включён.
|
||||
private sealed record Pass(TenantSettingsSnapshot Snapshot, bool AiEnabled, bool AiFilterEnabled)
|
||||
{
|
||||
/// <summary>
|
||||
/// Сколько карточек ушло в смысловую колонку.
|
||||
/// </summary>
|
||||
public int Moved { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Сколько карточек осталось в «Неразобранном».
|
||||
/// </summary>
|
||||
public int Kept { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Сколько карточек отправлено в корзину.
|
||||
/// </summary>
|
||||
public int Trashed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Сколько карточек пропущено
|
||||
/// </summary>
|
||||
public int Skipped { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True — разбор хотя бы одной карточки выполнен через порт ИИ.
|
||||
/// </summary>
|
||||
public bool AiUsed { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using Deal.Modules.Kanban.Application.ColumnRules;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Глобальные исключения тенанта — стоп-фильтр ДО ML/ИИ.
|
||||
/// </summary>
|
||||
public static class GlobalExclusionRules
|
||||
{
|
||||
/// <summary>
|
||||
/// Этап/правило: исключение по ключевым словам/технологиям.
|
||||
/// </summary>
|
||||
public const string KindKeywords = "exclude_kw";
|
||||
|
||||
/// <summary>
|
||||
/// Этап/правило: исключение по локации/языку.
|
||||
/// </summary>
|
||||
public const string KindLocation = "exclude_location";
|
||||
|
||||
/// <summary>
|
||||
/// Этап/правило: исключение по типу заявки.
|
||||
/// </summary>
|
||||
public const string KindType = "exclude_type";
|
||||
|
||||
/// <summary>
|
||||
/// Этап/правило: исключение по бюджету
|
||||
/// </summary>
|
||||
public const string KindBudget = "exclude_budget";
|
||||
|
||||
// Формат причины для слова/локации/типа: «{0}» — конкретный терм.
|
||||
private const string ReasonKeywordsFormat = "глобальное исключение: слово/технология «{0}»";
|
||||
|
||||
private const string ReasonLocationFormat = "глобальное исключение: локация/язык «{0}»";
|
||||
|
||||
private const string ReasonTypeFormat = "глобальное исключение: тип «{0}»";
|
||||
|
||||
// Формат причины для бюджета: «{0}» — человекочитаемый диапазон.
|
||||
private const string ReasonBudgetFormat = "глобальное исключение: бюджет {0}";
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет текст на срабатывание глобальных исключений
|
||||
/// </summary>
|
||||
/// <param name="text">Текст сообщения.</param>
|
||||
/// <param name="settings">Снимок глобальных исключений тенанта.</param>
|
||||
/// <returns>Результат исключения либо null — исключения не сработали.</returns>
|
||||
public static GlobalExclusionResult? Match(string? text, GlobalExcludeSettings settings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string lower = ContentNormalizer.ContentText(text).ToLowerInvariant();
|
||||
|
||||
GlobalExclusionResult? termHit = MatchTerms(lower, settings.Keywords, KindKeywords, ReasonKeywordsFormat);
|
||||
if (termHit is not null)
|
||||
{
|
||||
return termHit;
|
||||
}
|
||||
|
||||
GlobalExclusionResult? locationHit = MatchTerms(lower, settings.Locations, KindLocation, ReasonLocationFormat);
|
||||
if (locationHit is not null)
|
||||
{
|
||||
return locationHit;
|
||||
}
|
||||
|
||||
GlobalExclusionResult? typeHit = MatchTypes(lower, settings.Types);
|
||||
if (typeHit is not null)
|
||||
{
|
||||
return typeHit;
|
||||
}
|
||||
|
||||
return MatchBudget(text, settings);
|
||||
}
|
||||
|
||||
// Совпадение с текстовой группой: подстрочное вхождение терма (терм как сохранён в reason).
|
||||
// lower: Текст в нижнем регистре (без ссылок).
|
||||
// terms: Термы группы.
|
||||
// kind: Код правила.
|
||||
// reasonFormat: Формат причины с термом.
|
||||
// Возвращает: Первое совпадение либо null.
|
||||
private static GlobalExclusionResult? MatchTerms(
|
||||
string lower,
|
||||
IReadOnlyList<string> terms,
|
||||
string kind,
|
||||
string reasonFormat)
|
||||
{
|
||||
foreach (string? raw in terms)
|
||||
{
|
||||
string term = (raw ?? string.Empty).Trim();
|
||||
if (term.Length > 0 && lower.Contains(term.ToLowerInvariant(), StringComparison.Ordinal))
|
||||
{
|
||||
return new GlobalExclusionResult(kind, string.Format(reasonFormat, term), term);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Совпадение с группой типов (синонимы TypeAliases).
|
||||
// lower: Текст в нижнем регистре.
|
||||
// types: Теги типов.
|
||||
// Возвращает: Первое совпадение либо null.
|
||||
private static GlobalExclusionResult? MatchTypes(string lower, IReadOnlyList<string> types)
|
||||
{
|
||||
foreach (string? raw in types)
|
||||
{
|
||||
string term = (raw ?? string.Empty).Trim();
|
||||
if (term.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (string alias in TypeAliases.ExpandTerms(new[] { raw }))
|
||||
{
|
||||
if (lower.Contains(alias, StringComparison.Ordinal))
|
||||
{
|
||||
return new GlobalExclusionResult(KindType, string.Format(ReasonTypeFormat, term), term);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Совпадение с бюджетным диапазоном исключения (любая числовая сумма текста в границах).
|
||||
// text: Исходный текст сообщения.
|
||||
// settings: Снимок исключений (границы).
|
||||
// Возвращает: Результат исключения по бюджету либо null (границы не заданы/сумма вне диапазона).
|
||||
private static GlobalExclusionResult? MatchBudget(string text, GlobalExcludeSettings settings)
|
||||
{
|
||||
if (settings.BudgetFrom is null && settings.BudgetTo is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (AmountRange amount in AmountParser.Parse(text))
|
||||
{
|
||||
double? value = amount.From ?? amount.To;
|
||||
if (value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (settings.BudgetFrom is not null && value < settings.BudgetFrom)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (settings.BudgetTo is not null && value > settings.BudgetTo)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string described = DescribeBudget(settings);
|
||||
return new GlobalExclusionResult(KindBudget, string.Format(ReasonBudgetFormat, described), string.Empty);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Человекочитаемый диапазон бюджета исключения («от A до B», «до B», «от A»).
|
||||
// settings: Снимок исключений.
|
||||
// Возвращает: Строка диапазона с валютой.
|
||||
private static string DescribeBudget(GlobalExcludeSettings settings)
|
||||
{
|
||||
if (settings.BudgetFrom is not null && settings.BudgetTo is not null)
|
||||
{
|
||||
return $"от {Format(settings.BudgetFrom.Value)} до {Format(settings.BudgetTo.Value)}";
|
||||
}
|
||||
|
||||
if (settings.BudgetTo is not null)
|
||||
{
|
||||
return $"до {Format(settings.BudgetTo.Value)}";
|
||||
}
|
||||
|
||||
return $"от {Format(settings.BudgetFrom!.Value)}";
|
||||
}
|
||||
|
||||
// Формат числа границы без хвостовых нулей (инвариантная культура).
|
||||
// value: Значение границы.
|
||||
// Возвращает: Строковое представление.
|
||||
private static string Format(double value) =>
|
||||
value.ToString("G6", System.Globalization.CultureInfo.InvariantCulture).Replace('E', 'e');
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
using System.Globalization;
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Ручная проверка/разметка ML на записях источников.
|
||||
/// </summary>
|
||||
public sealed class MlReviewService(
|
||||
IPipelineStore pipelineStore,
|
||||
ICardStore cardStore,
|
||||
CardsService cards,
|
||||
PipelineProcessingService processing,
|
||||
IMlClient mlClient)
|
||||
{
|
||||
/// <summary>
|
||||
/// Минимум сообщений в выборке кандидатов.
|
||||
/// </summary>
|
||||
public const int MinCandidates = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Максимум сообщений в выборке кандидатов.
|
||||
/// </summary>
|
||||
public const int MaxCandidates = 60;
|
||||
|
||||
// Размер одного чтения из очереди/отсева при объединении кандидатов.
|
||||
private const int MaxScan = 500;
|
||||
|
||||
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>
|
||||
/// Действие: пропустить без обучения.
|
||||
/// </summary>
|
||||
public const string ActionSkip = "skip";
|
||||
|
||||
/// <summary>
|
||||
/// Действие: спам — учим ML и
|
||||
/// </summary>
|
||||
public const string ActionSpam = "spam";
|
||||
|
||||
/// <summary>
|
||||
/// Префикс действия «в колонку»
|
||||
/// </summary>
|
||||
public const string ActionBoardPrefix = "board:";
|
||||
|
||||
/// <summary>
|
||||
/// 400 apply: неизвестная доска-цель.
|
||||
/// </summary>
|
||||
public const string UnknownBoardDetail = "Неизвестная доска";
|
||||
|
||||
/// <summary>
|
||||
/// 400 apply: неизвестное действие.
|
||||
/// </summary>
|
||||
public const string UnknownActionDetail = "Неизвестное действие";
|
||||
|
||||
// Причина отсева при ручной разметке «спам» ещё не обработанного сообщения.
|
||||
private const string ManualSpamReason = "ручная разметка ML: спам";
|
||||
|
||||
// Этап отсева при ручной разметке «спам» (отсев решением ML).
|
||||
private const string ManualSpamStage = "spam_ml";
|
||||
|
||||
// Источник решения при ручной разметке.
|
||||
private const string ManualSource = "ml";
|
||||
|
||||
private const double UserPushWeight = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Отбирает записи-кандидаты для проверки ML по источнику и/или размеру выборки.
|
||||
/// </summary>
|
||||
/// <param name="dialogId">Оригинал источника (OriginRef); пусто — выборка по всем источникам тенанта.</param>
|
||||
/// <param name="limit">Сколько последних записей вернуть (кламп 1..<see cref="MaxCandidates"/>, дефолт вызывающего).</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);
|
||||
|
||||
// Объединение по ключу дедупа источника: очередь → отсев → карточка (последняя перекрывает предыдущие).
|
||||
var merged = new Dictionary<string, MlCandidateDto>(StringComparer.Ordinal);
|
||||
foreach (QueueItemDto row in queue)
|
||||
{
|
||||
if (!IsMessage(row.Source) || !MatchesDialog(dialog, row.Source.OriginRef))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
merged[row.Source.DedupeKey()] = BuildQueued(row);
|
||||
}
|
||||
|
||||
foreach (RejectedItemDto row in rejected)
|
||||
{
|
||||
if (!IsMessage(row.Source) || !MatchesDialog(dialog, row.Source.OriginRef))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
merged[row.Source.DedupeKey()] = BuildRejected(row);
|
||||
}
|
||||
|
||||
foreach (CardDto card in cardList)
|
||||
{
|
||||
if (!IsMessage(card.Source) || !MatchesDialog(dialog, card.Source.OriginRef))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
merged[card.Source.DedupeKey()] = BuildCard(card);
|
||||
}
|
||||
|
||||
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>
|
||||
/// Применяет ручное решение по записи источника
|
||||
/// </summary>
|
||||
/// <param name="dialogId">Оригинал источника (OriginRef) записи.</param>
|
||||
/// <param name="msgId">Внешний id записи в источнике.</param>
|
||||
/// <param name="action">Действие: <c>skip</c> | <c>spam</c> | <c>board:<id></c>.</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();
|
||||
string externalId = msgId.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
SourceRef? source = await ResolveSourceAsync(dialog, externalId, ct);
|
||||
if (source is null)
|
||||
{
|
||||
return null; // 404: исходная запись не найдена
|
||||
}
|
||||
|
||||
CardDto? card = await cardStore.GetCardBySourceAsync(source, ct);
|
||||
string? text = await FindTextAsync(dialog, externalId, 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, externalId, card, text, ct);
|
||||
}
|
||||
|
||||
if (normalized.StartsWith(ActionBoardPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
string boardId = normalized[ActionBoardPrefix.Length..].Trim();
|
||||
return await ApplyBoardAsync(boardId, card, text, ct);
|
||||
}
|
||||
|
||||
return new MlApplyResult(UnknownActionDetail, Ok: false, Learned: false, Moved: null, LeadId: null);
|
||||
}
|
||||
|
||||
// Действие «спам»: карточку — в корзину (с обучением), запись из очереди — в отсев; иначе учим ML.
|
||||
// dialog: Оригинал источника.
|
||||
// externalId: Внешний id записи.
|
||||
// card: Карточка сообщения (null — сообщение не становилось карточкой).
|
||||
// text: Текст сообщения.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Результат решения.
|
||||
private async Task<MlApplyResult> ApplySpamAsync(
|
||||
string dialog,
|
||||
string externalId,
|
||||
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, externalId, ct);
|
||||
if (queued is not null)
|
||||
{
|
||||
await processing.RejectAsync(new RejectRecord
|
||||
{
|
||||
Source = queued.Source,
|
||||
Content = queued.Content,
|
||||
Text = queued.Text,
|
||||
MsgAtMs = queued.MsgAtMs,
|
||||
DecidedBy = 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.
|
||||
// boardId: Id колонки-цели (inbox или b_...).
|
||||
// card: Карточка сообщения (null — сообщение не становилось карточкой).
|
||||
// text: Текст сообщения.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Результат решения.
|
||||
private async Task<MlApplyResult> ApplyBoardAsync(
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
// Ссылка на источник записи: очередь → карточка → отсев.
|
||||
// dialog: Оригинал источника.
|
||||
// externalId: Внешний id записи.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Ссылку на источник либо null, если записи нет.
|
||||
private async Task<SourceRef?> ResolveSourceAsync(
|
||||
string dialog,
|
||||
string externalId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
QueueItemDto? queued = await FindQueuedAsync(dialog, externalId, ct);
|
||||
if (queued is not null)
|
||||
{
|
||||
return queued.Source;
|
||||
}
|
||||
|
||||
IReadOnlyList<CardDto> cardList = await cardStore.ListCardsAsync(new CardsQuery(null), ct);
|
||||
foreach (CardDto card in cardList)
|
||||
{
|
||||
if (MatchesSource(card.Source, dialog, externalId))
|
||||
{
|
||||
return card.Source;
|
||||
}
|
||||
}
|
||||
|
||||
IReadOnlyList<RejectedItemDto> rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct);
|
||||
foreach (RejectedItemDto row in rejected)
|
||||
{
|
||||
if (MatchesSource(row.Source, dialog, externalId))
|
||||
{
|
||||
return row.Source;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Текст исходного сообщения: текст карточки, иначе текст строки очереди/записи отсева.
|
||||
// dialog: Оригинал источника.
|
||||
// externalId: Внешний id записи.
|
||||
// card: Карточка сообщения (уже прочитана вызывающим).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Непустой текст либо null, если записи нет ни в одном источнике.
|
||||
private async Task<string?> FindTextAsync(
|
||||
string dialog,
|
||||
string externalId,
|
||||
CardDto? card,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (card is not null && !string.IsNullOrWhiteSpace(card.Content.Text))
|
||||
{
|
||||
return card.Content.Text;
|
||||
}
|
||||
|
||||
QueueItemDto? queued = await FindQueuedAsync(dialog, externalId, 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 (MatchesSource(row.Source, dialog, externalId))
|
||||
{
|
||||
return row.Text;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Строка очереди записи (для отсева при ручной разметке «спам»).
|
||||
// dialog: Оригинал источника.
|
||||
// externalId: Внешний id записи.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Строка очереди либо null — сообщение уже обработано/не в очереди.
|
||||
private async Task<QueueItemDto?> FindQueuedAsync(
|
||||
string dialog,
|
||||
string externalId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<QueueItemDto> queue = await pipelineStore.ListAsync(status: null, MaxScan, ct);
|
||||
foreach (QueueItemDto row in queue)
|
||||
{
|
||||
if (MatchesSource(row.Source, dialog, externalId))
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Идентифицирована ли запись источника (есть внешний id).
|
||||
// source: Ссылка на источник.
|
||||
// Возвращает: True — запись адресуема как сообщение источника.
|
||||
private static bool IsMessage(SourceRef source) => source.ExternalId is { Length: > 0 };
|
||||
|
||||
// Соответствует ли источник фильтру оригинала (пустой фильтр — все источники).
|
||||
// filter: Запрошенный оригинал (пусто — без фильтра).
|
||||
// originRef: Оригинал источника записи.
|
||||
// Возвращает: True — кандидат подходит.
|
||||
private static bool MatchesDialog(string filter, string? originRef) =>
|
||||
filter.Length == 0 || string.Equals(filter, originRef ?? string.Empty, StringComparison.Ordinal);
|
||||
|
||||
// Совпадает ли источник с оригиналом и внешним id записи.
|
||||
// source: Ссылка на источник.
|
||||
// dialog: Оригинал источника.
|
||||
// externalId: Внешний id записи.
|
||||
// Возвращает: True — источник указывает на ту же запись.
|
||||
private static bool MatchesSource(SourceRef source, string dialog, string externalId) =>
|
||||
string.Equals(source.OriginRef ?? string.Empty, dialog, StringComparison.Ordinal)
|
||||
&& string.Equals(source.ExternalId ?? string.Empty, externalId, StringComparison.Ordinal);
|
||||
|
||||
// Кандидат из строки очереди (вердикт queued).
|
||||
// row: Строка очереди.
|
||||
// Возвращает: Кандидат.
|
||||
private static MlCandidateDto BuildQueued(QueueItemDto row) => new()
|
||||
{
|
||||
Source = row.Source,
|
||||
Content = row.Content,
|
||||
Text = Truncate(row.Text),
|
||||
Time = row.MsgAtMs == 0 ? null : row.MsgAtMs,
|
||||
Lead = false,
|
||||
Verdict = VerdictQueued,
|
||||
Stage = row.Status,
|
||||
};
|
||||
|
||||
// Кандидат из записи отсева (вердикт rejected).
|
||||
// row: Запись отсева.
|
||||
// Возвращает: Кандидат.
|
||||
private static MlCandidateDto BuildRejected(RejectedItemDto row) => new()
|
||||
{
|
||||
Source = row.Source,
|
||||
Content = row.Content,
|
||||
Text = Truncate(row.Text),
|
||||
Time = row.MsgAtMs == 0 ? null : row.MsgAtMs,
|
||||
Lead = false,
|
||||
Verdict = VerdictRejected,
|
||||
Stage = row.Stage,
|
||||
Reason = row.Reason,
|
||||
};
|
||||
|
||||
// Кандидат из карточки (вердикт card).
|
||||
// card: Карточка.
|
||||
// Возвращает: Кандидат.
|
||||
private static MlCandidateDto BuildCard(CardDto card) => new()
|
||||
{
|
||||
Source = card.Source,
|
||||
Content = card.Content,
|
||||
Text = Truncate(card.Content.Text ?? string.Empty),
|
||||
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];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Создание карточки пайплайна через публичный интерфейс Kanban.
|
||||
/// </summary>
|
||||
public sealed class PipelineCardWriter(ICardStore kanjStore, IPipelineStore pipelineStore, CardComposer composer)
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт карточку из разобранного сообщения и связывает её с заявкой дедупа.
|
||||
/// </summary>
|
||||
/// <param name="parsed">Разбор сообщения (ИИ/локальный путь; колонка разбора пройдёт ContainerAccepts-страховку).</param>
|
||||
/// <param name="message">Строка очереди с сообщением-источником (метаданные канала/текст/время).</param>
|
||||
/// <param name="dedupHash">SHA1-hex хэша текста.</param>
|
||||
/// <returns>Полная карточка.</returns>
|
||||
/// <exception cref="InvalidOperationException">Карточка не прочиталась сразу после создания.</exception>
|
||||
public async Task<CardDto> CreateCardAsync(
|
||||
AiParsedCardDto parsed,
|
||||
QueueItemDto message,
|
||||
string dedupHash,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string cardId = PrefixId.New(KanbanIdPrefixes.Card);
|
||||
CardSnapshot snapshot = await composer.BuildAsync(parsed, message, cardId, ct);
|
||||
await kanjStore.AddCardAsync(snapshot, ct);
|
||||
await pipelineStore.LinkAsync(dedupHash, cardId, ct);
|
||||
return await kanjStore.GetCardAsync(cardId, ct)
|
||||
?? throw new InvalidOperationException("Карточка не прочиталась после создания: " + cardId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Приём входящих сообщений пайплайна — постановка сырой записи источника в очередь QueueItems.
|
||||
/// </summary>
|
||||
public sealed class PipelineIngestService(IPipelineStore store)
|
||||
{
|
||||
private const int MaxQueueTextLength = 6000;
|
||||
|
||||
/// <summary>
|
||||
/// Ставит входящую запись источника в очередь
|
||||
/// </summary>
|
||||
/// <param name="message">Запись источника и флаг «возвращено из отсева».</param>
|
||||
/// <returns>Результат: Id строки (<c>p_...</c>) либо null, если запись не принята; Duplicate — дубль источника.</returns>
|
||||
public async Task<PipelineIngestResultDto> EnqueueAsync(QueuedMessage message, CancellationToken ct)
|
||||
{
|
||||
string text = (message.Item.Content.Text ?? string.Empty).Trim();
|
||||
if (text.Length == 0)
|
||||
{
|
||||
return new PipelineIngestResultDto(null, false);
|
||||
}
|
||||
|
||||
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
if (await store.ExistsDuplicateAsync(message.Item.Source, ct))
|
||||
{
|
||||
return new PipelineIngestResultDto(null, true);
|
||||
}
|
||||
|
||||
string sliced = MessageTextCleaner.SliceCodePoints(text, MaxQueueTextLength);
|
||||
DateTimeOffset receivedAt = message.Item.Source.ReceivedAt;
|
||||
long msgAtMs = receivedAt == default ? 0 : receivedAt.ToUnixTimeMilliseconds();
|
||||
|
||||
var item = new QueueItemDto
|
||||
{
|
||||
Id = PrefixId.New(PipelineIdPrefixes.Queue),
|
||||
Source = message.Item.Source,
|
||||
Content = message.Item.Content with { Text = sliced },
|
||||
Text = sliced,
|
||||
Status = PipelineQueueStatuses.New,
|
||||
MsgAtMs = msgAtMs,
|
||||
QueuedAtMs = nowMs,
|
||||
Force = message.Force,
|
||||
};
|
||||
await store.AddAsync(item, ct);
|
||||
return new PipelineIngestResultDto(item.Id, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Мониторинг и обслуживание пайплайна — вкладка «Обработка»
|
||||
/// </summary>
|
||||
public sealed class PipelineProcessingService(
|
||||
IPipelineStore store,
|
||||
IMlClient mlClient,
|
||||
PipelineIngestService ingest)
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// 400 return: запись уже возвращалась в обработку.
|
||||
/// </summary>
|
||||
public const string AlreadyReturnedDetail = "Сообщение уже возвращено в обработку";
|
||||
|
||||
/// <summary>
|
||||
/// 400 return: повтор по дедупу — карточка с текстом уже в системе.
|
||||
/// </summary>
|
||||
public const string DuplicateReturnDetail = "Повтор: карточка с таким текстом уже есть в системе — возвращать нечего";
|
||||
|
||||
/// <summary>
|
||||
/// 400 return: в записи нет текста сообщения.
|
||||
/// </summary>
|
||||
public const string EmptyReturnTextDetail = "В записи нет текста сообщения";
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Размер страницы по умолчанию для списков очереди/отсева.
|
||||
/// </summary>
|
||||
public const int DefaultPageSize = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Максимальный размер страницы списков
|
||||
/// </summary>
|
||||
public const int MaxPageSize = 500;
|
||||
|
||||
|
||||
// Вес снятия метки «спам»: реальное действие пользователя «это не спам» (delta=−1.0).
|
||||
private const double SpamUnlearnDelta = -1.0;
|
||||
|
||||
private const string DuplicateSource = "dup";
|
||||
|
||||
private static readonly HashSet<string> SpamStages = new(StringComparer.Ordinal)
|
||||
{
|
||||
"spam_ml",
|
||||
"spam_ai",
|
||||
"filter_ai",
|
||||
};
|
||||
|
||||
|
||||
private const int MaxRejectedTextLength = 6000;
|
||||
|
||||
private const int MaxReasonLength = 500;
|
||||
|
||||
private const int MaxKwLength = 200;
|
||||
|
||||
private const int MaxReturnReasonLength = 500;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Сохраняет отброшенное сообщение в отсев.
|
||||
/// </summary>
|
||||
/// <param name="record">Команда записи отсева (поля без ограничений длин — сервис нормализует).</param>
|
||||
/// <returns>Задача завершается после записи (upsert) или no-op пустого текста.</returns>
|
||||
public Task RejectAsync(RejectRecord record, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(record.Text))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return store.UpsertAsync(record with
|
||||
{
|
||||
Text = MessageTextCleaner.SliceCodePoints(record.Text, MaxRejectedTextLength),
|
||||
Reason = MessageTextCleaner.SliceCodePoints(record.Reason, MaxReasonLength),
|
||||
Kw = MessageTextCleaner.SliceCodePoints(record.Kw, MaxKwLength),
|
||||
}, ct);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Сырые сообщения очереди в порядке постановки
|
||||
/// </summary>
|
||||
/// <param name="limit">Максимум строк; clamp 1..500 — запрашивается больше — вернётся не больше 500.</param>
|
||||
/// <returns>Строки очереди.</returns>
|
||||
public async Task<IReadOnlyList<QueueItemDto>> ListQueueAsync(int limit, CancellationToken ct)
|
||||
{
|
||||
int clamped = Math.Clamp(limit, 1, MaxPageSize);
|
||||
return await store.ListAsync(null, clamped, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Счётчики очереди по статусам
|
||||
/// </summary>
|
||||
/// <returns>new/ai/total (форма counts ответа /queue и queue ответа /stats).</returns>
|
||||
public async Task<QueueCountsDto> QueueCountsAsync(CancellationToken ct)
|
||||
{
|
||||
int fresh = await store.CountByStatusAsync(PipelineQueueStatuses.New, ct);
|
||||
int ai = await store.CountByStatusAsync(PipelineQueueStatuses.Filtered, ct);
|
||||
return new QueueCountsDto { New = fresh, Ai = ai, Total = fresh + ai };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Число записей в отсеве.
|
||||
/// </summary>
|
||||
/// <returns>Всего записей RejectedItems.</returns>
|
||||
public Task<int> RejectedCountAsync(CancellationToken ct)
|
||||
{
|
||||
return store.CountAsync(ct);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Страница отсева
|
||||
/// </summary>
|
||||
/// <param name="q">Поисковый запрос (текст/причина/фраза/имя канала); пустой — весь отсев.</param>
|
||||
/// <param name="offset">Сдвиг от начала (clamp ≥ 0).</param>
|
||||
/// <param name="limit">Размер страницы (clamp 1..500).</param>
|
||||
/// <returns>Страница {items, total, offset, limit}.</returns>
|
||||
public async Task<RejectedPageDto> ListRejectedAsync(
|
||||
string q,
|
||||
int offset,
|
||||
int limit,
|
||||
CancellationToken ct)
|
||||
{
|
||||
int clampedOffset = Math.Max(0, offset);
|
||||
int clampedLimit = Math.Clamp(limit, 1, MaxPageSize);
|
||||
string query = (q ?? string.Empty).Trim().ToLowerInvariant();
|
||||
|
||||
if (query.Length == 0)
|
||||
{
|
||||
int total = await store.CountAsync(ct);
|
||||
IReadOnlyList<RejectedItemDto> page = await store.ListPageAsync(clampedOffset, clampedLimit, ct);
|
||||
return new RejectedPageDto(page, total, clampedOffset, clampedLimit);
|
||||
}
|
||||
|
||||
IReadOnlyList<RejectedItemDto> candidates = await store.SearchAsync(query, clampedLimit, clampedLimit * 2, ct);
|
||||
IReadOnlyList<RejectedItemDto> items = candidates.Skip(clampedOffset).Take(clampedLimit).ToList();
|
||||
|
||||
return new RejectedPageDto(items, candidates.Count, clampedOffset, clampedLimit);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает отсеянное сообщение в обработку
|
||||
/// </summary>
|
||||
/// <param name="rejectedId">Id записи отсева (<c>r_...</c>).</param>
|
||||
/// <param name="reason">Причина возврата (trim, ≤500; пишется на запись для аудита).</param>
|
||||
/// <returns>null — записи нет (404); иначе результат: Error (400) либо {id, returned:true, returnedAt}.</returns>
|
||||
public async Task<RejectReturnResultDto?> ReturnAsync(
|
||||
string rejectedId,
|
||||
string reason,
|
||||
CancellationToken ct)
|
||||
{
|
||||
RejectedItemDto? row = await store.GetAsync(rejectedId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (row.Returned)
|
||||
{
|
||||
return new RejectReturnResultDto(AlreadyReturnedDetail, rejectedId, false, 0);
|
||||
}
|
||||
|
||||
if (row.DecidedBy == DuplicateSource)
|
||||
{
|
||||
return new RejectReturnResultDto(DuplicateReturnDetail, rejectedId, false, 0);
|
||||
}
|
||||
|
||||
string text = row.Text.Trim();
|
||||
if (text.Length == 0)
|
||||
{
|
||||
return new RejectReturnResultDto(EmptyReturnTextDetail, rejectedId, false, 0);
|
||||
}
|
||||
|
||||
if (SpamStages.Contains(row.Stage))
|
||||
{
|
||||
await mlClient.PushAsync(text, MlLearningLabels.Spam, SpamUnlearnDelta, ct);
|
||||
}
|
||||
|
||||
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
await store.MarkReturnedAsync(
|
||||
rejectedId,
|
||||
MessageTextCleaner.SliceCodePoints(reason.Trim(), MaxReturnReasonLength),
|
||||
DateTimeOffset.FromUnixTimeMilliseconds(nowMs),
|
||||
ct);
|
||||
|
||||
var extra = new Dictionary<string, string>(row.Source.Extra ?? new Dictionary<string, string>())
|
||||
{
|
||||
[SourceRefs.HueKey] = row.Source.ResolveHue(),
|
||||
};
|
||||
SourceRef source = row.Source with { Extra = extra };
|
||||
|
||||
await ingest.EnqueueAsync(new QueuedMessage
|
||||
{
|
||||
Item = new SourceItem
|
||||
{
|
||||
Source = source,
|
||||
Content = row.Content with { Text = text },
|
||||
},
|
||||
Force = true,
|
||||
}, ct);
|
||||
|
||||
return new RejectReturnResultDto(null, rejectedId, true, nowMs);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет одну запись отсева безвозвратно.
|
||||
/// </summary>
|
||||
/// <param name="rejectedId">Id записи (<c>r_...</c>).</param>
|
||||
/// <returns>Задача завершается после удаления.</returns>
|
||||
public Task DeleteAsync(string rejectedId, CancellationToken ct)
|
||||
{
|
||||
return store.DeleteAsync(rejectedId, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Полная ручная очистка отсева.
|
||||
/// </summary>
|
||||
/// <returns>Сколько записей удалено (0 — отсев пуст).</returns>
|
||||
public Task<int> ClearAsync(CancellationToken ct)
|
||||
{
|
||||
return store.ClearAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Автоочистка отсева
|
||||
/// </summary>
|
||||
/// <returns>Сколько записей удалено.</returns>
|
||||
public Task<int> PurgeExpiredAsync(CancellationToken ct)
|
||||
{
|
||||
DateTimeOffset olderThan = DateTimeOffset.UtcNow.AddDays(-PipelineRejectConstants.RetentionDays);
|
||||
return store.PurgeExpiredAsync(olderThan, ct);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Сводка вкладки «Обработка» — форма GET /api/pipeline/stats
|
||||
/// </summary>
|
||||
/// <returns>Счётчики очереди и число записей отсева.</returns>
|
||||
public async Task<PipelineStatsDto> StatsAsync(CancellationToken ct)
|
||||
{
|
||||
QueueCountsDto queue = await QueueCountsAsync(ct);
|
||||
int rejected = await store.CountAsync(ct);
|
||||
return new PipelineStatsDto(queue, rejected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application.ColumnRules;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Проверки воркера — partial-часть <see cref="PipelineWorkerService"/>
|
||||
/// </summary>
|
||||
public sealed partial class PipelineWorkerService
|
||||
{
|
||||
// ── Проверки воркера ───────────────────────────────────────────────────
|
||||
|
||||
private bool IsStaleAsync(QueueItemDto row, WorkerRunSettings run)
|
||||
{
|
||||
if (row.MsgAtMs == 0)
|
||||
{
|
||||
return false; // python: not msg_at → False (L831–832)
|
||||
}
|
||||
|
||||
if (!run.AutoArchive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (run.ArchiveAfterDays <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long maxAgeMs = run.ArchiveAfterDays * DayMs;
|
||||
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
return nowMs - row.MsgAtMs > maxAgeMs;
|
||||
}
|
||||
|
||||
private Task<bool> SkipNoBudgetAsync(
|
||||
AiParsedCardDto parsed,
|
||||
string text,
|
||||
WorkerRunSettings run,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!run.BudgetRequiredHire && !run.BudgetRequiredOrder)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
bool isHire = parsed.IsVacancy;
|
||||
if ((isHire && !run.BudgetRequiredHire) || (!isHire && !run.BudgetRequiredOrder))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
if (parsed.Budget is not null)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
return Task.FromResult(AmountParser.Parse(text).Count == 0);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> MergeMlTerms(IReadOnlyList<string> terms, IReadOnlyList<string> stack)
|
||||
{
|
||||
var merged = new List<string>(stack);
|
||||
int added = 0;
|
||||
foreach (string term in terms)
|
||||
{
|
||||
string cleaned = (term ?? string.Empty).Trim().Trim('@', '+', '#', '.');
|
||||
if (cleaned.Length < 2 || cleaned.StartsWith("~", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string lower = cleaned.ToLowerInvariant();
|
||||
if (MessageListNormalizer.StackStopWords.Contains(lower))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (merged.Any(item => item.ToLowerInvariant() == lower))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
merged.Add(cleaned);
|
||||
added++;
|
||||
if (added >= MaxMlTermsAdded)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private async Task<MlPredictResultDto> PredictSafelyAsync(string text, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _mlClient.PredictAsync(text, ct);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return NotReadyPrediction;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AiFilterResultDto> FilterSafelyAsync(string text, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _aiClassifier.FilterAsync(text, ct);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return PassSkipped;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// KV-счётчики решений — partial-часть <see cref="PipelineWorkerService"/>
|
||||
/// </summary>
|
||||
public sealed partial class PipelineWorkerService
|
||||
{
|
||||
|
||||
private async Task TrackDecisionsAsync(PumpState state, CancellationToken ct)
|
||||
{
|
||||
int ml = state.MlStored + state.MlDrop;
|
||||
int ai = state.AiStored + state.AiDrop;
|
||||
if (ml > 0)
|
||||
{
|
||||
await IncrementCounterAsync(SettingsKeys.MlDecisions, ml, ct);
|
||||
}
|
||||
|
||||
if (ai > 0)
|
||||
{
|
||||
await IncrementCounterAsync(SettingsKeys.AiDecisions, ai, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// Read-modify-write целочисленного KV-счётчика (отсутствие/повреждение строки → 0).
|
||||
// key: Ключ счётчика (mlDecisions|aiDecisions).
|
||||
// delta: Приращение (>0 — только такие и пишем).
|
||||
// ct: Токен отмены.
|
||||
private async Task IncrementCounterAsync(
|
||||
string key,
|
||||
int delta,
|
||||
CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await _settings.GetAsync(key, ct);
|
||||
int current = 0;
|
||||
if (row is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Number
|
||||
&& document.RootElement.TryGetInt64(out long wide))
|
||||
{
|
||||
current = (int)Math.Clamp(wide, int.MinValue, int.MaxValue);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Повреждённая строка — 0 (мягкая семантика, как LocalMlClient).
|
||||
}
|
||||
}
|
||||
|
||||
await _settings.SetAsync(key, JsonSerializer.Serialize(current + delta), ct);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.ColumnRules;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Сухой прогон текста по конвейеру — partial-часть <see cref="PipelineWorkerService"/>
|
||||
/// </summary>
|
||||
public sealed partial class PipelineWorkerService
|
||||
{
|
||||
private const string StageExcludeDry = "exclude";
|
||||
|
||||
private const string StageMlDry = "ml";
|
||||
|
||||
private const string StageAiDry = "ai";
|
||||
|
||||
private const string StageBudgetDry = "budget";
|
||||
|
||||
private const string EmptyTextReason = "пустое сообщение";
|
||||
|
||||
private const string MlSpamDryReason = "ML уверен, что это спам/не заявка";
|
||||
|
||||
/// <summary>
|
||||
/// Прогоняет текст по этапам конвейера без записи в систему.
|
||||
/// </summary>
|
||||
/// <param name="text">Текст для проверки.</param>
|
||||
/// <returns>Результаты этапов, разбор и целевой контейнер (если карточка была бы создана).</returns>
|
||||
public async Task<PipelineDryRunDto> DryRunAsync(string text, CancellationToken ct)
|
||||
{
|
||||
string trimmed = (text ?? string.Empty).Trim();
|
||||
var stages = new List<PipelineDryRunStageDto>();
|
||||
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
stages.Add(new PipelineDryRunStageDto(SourceStop, false, false, EmptyTextReason));
|
||||
return Rejected(stages);
|
||||
}
|
||||
|
||||
WorkerRunSettings run = await LoadRunSettingsAsync(_settings, ct);
|
||||
|
||||
IncomingRulesResult stop = await _rules.CheckAsync(trimmed, ct);
|
||||
stages.Add(new PipelineDryRunStageDto(StopStageCode(stop), stop.Pass, false, stop.Reason ?? string.Empty, stop.Kw));
|
||||
if (!stop.Pass)
|
||||
{
|
||||
return Rejected(stages);
|
||||
}
|
||||
|
||||
GlobalExclusionResult? exclusion = GlobalExclusionRules.Match(trimmed, run.GlobalExclusions);
|
||||
if (exclusion is not null)
|
||||
{
|
||||
stages.Add(new PipelineDryRunStageDto(StageExcludeDry, false, false, exclusion.Reason, exclusion.Kw));
|
||||
return Rejected(stages);
|
||||
}
|
||||
|
||||
stages.Add(new PipelineDryRunStageDto(StageExcludeDry, true, false, string.Empty));
|
||||
|
||||
PipelineDryRunDto? mlRejection = await CheckMlAsync(trimmed, run, stages, ct);
|
||||
if (mlRejection is not null)
|
||||
{
|
||||
return mlRejection;
|
||||
}
|
||||
|
||||
AiParsedCardDto? parsed = await ResolveParsedAsync(trimmed, run, stages, ct);
|
||||
if (parsed is null)
|
||||
{
|
||||
return Rejected(stages);
|
||||
}
|
||||
|
||||
if (await SkipNoBudgetAsync(parsed, trimmed, run, ct))
|
||||
{
|
||||
stages.Add(new PipelineDryRunStageDto(StageBudgetDry, false, false, NoBudgetReason));
|
||||
return Rejected(stages, parsed);
|
||||
}
|
||||
|
||||
stages.Add(new PipelineDryRunStageDto(StageBudgetDry, true, false, string.Empty));
|
||||
|
||||
(string target, IReadOnlyList<MatchHitDto> hits) = await ResolveTargetAsync(parsed, trimmed, run, ct);
|
||||
return new PipelineDryRunDto(true, true, target, hits, parsed, stages);
|
||||
}
|
||||
|
||||
// Этап ML: спам-вердикт и несовпадение типа возвращают отказ, иначе этап отмечен пройденным.
|
||||
private async Task<PipelineDryRunDto?> CheckMlAsync(
|
||||
string text,
|
||||
WorkerRunSettings run,
|
||||
List<PipelineDryRunStageDto> stages,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!run.MlEnabled)
|
||||
{
|
||||
stages.Add(new PipelineDryRunStageDto(StageMlDry, true, true, string.Empty));
|
||||
return null;
|
||||
}
|
||||
|
||||
MlPredictResultDto decision = await PredictSafelyAsync(text, ct);
|
||||
if (decision.Ready && decision.Take && decision.Label == MlLearningLabels.Spam)
|
||||
{
|
||||
stages.Add(new PipelineDryRunStageDto(StageMlDry, false, false, MlSpamDryReason, Label: decision.Label));
|
||||
return Rejected(stages);
|
||||
}
|
||||
|
||||
stages.Add(new PipelineDryRunStageDto(StageMlDry, true, false, string.Empty, Label: decision.Label));
|
||||
|
||||
if (decision.Ready && decision.Type is { Take: true } typeDecision)
|
||||
{
|
||||
bool isHire = typeDecision.Label == MlLearningLabels.TypeHireLabel;
|
||||
if (WantedTypeRejects(run.WantedType, isHire))
|
||||
{
|
||||
string kindName = isHire ? MlHireKindName : MlOrderKindName;
|
||||
string reason = string.Format(MlTypeDropReasonFormat, kindName, run.WantedType);
|
||||
stages.Add(new PipelineDryRunStageDto(StageType, false, false, reason, Label: typeDecision.Label));
|
||||
return Rejected(stages);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Этап ИИ: фильтр, классификация и спам-вердикт; выключенный ИИ — локальный разбор.
|
||||
private async Task<AiParsedCardDto?> ResolveParsedAsync(
|
||||
string text,
|
||||
WorkerRunSettings run,
|
||||
List<PipelineDryRunStageDto> stages,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!run.AiEnabled)
|
||||
{
|
||||
stages.Add(new PipelineDryRunStageDto(StageAiDry, true, true, string.Empty));
|
||||
return AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text);
|
||||
}
|
||||
|
||||
AiFilterResultDto filter = run.AiFilterEnabled ? await FilterSafelyAsync(text, ct) : PassSkipped;
|
||||
stages.Add(new PipelineDryRunStageDto(StageAiDry, filter.Pass, filter.Skipped, filter.Reason ?? string.Empty));
|
||||
if (!filter.Pass)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
AiParsedCardDto? parsed = null;
|
||||
try
|
||||
{
|
||||
parsed = await _aiClassifier.ClassifyAsync(text, ct);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
if (parsed is not null)
|
||||
{
|
||||
parsed = parsed with { IsVacancyKnown = true };
|
||||
if (parsed.IsSpam)
|
||||
{
|
||||
stages.Add(new PipelineDryRunStageDto(StageSpamAi, false, false, AiSpamReason));
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text);
|
||||
}
|
||||
|
||||
// Целевой контейнер: доска разбора при соответствии правилам, иначе «Неразобранное».
|
||||
private async Task<(string Target, IReadOnlyList<MatchHitDto> Hits)> ResolveTargetAsync(
|
||||
AiParsedCardDto parsed,
|
||||
string text,
|
||||
WorkerRunSettings run,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string? boardCandidate = string.IsNullOrWhiteSpace(parsed.Board) ? null : parsed.Board.Trim();
|
||||
if (boardCandidate is null)
|
||||
{
|
||||
return (CardIds.Inbox, []);
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, double> rates = run.Settings.TryGetRatesCache()?.Rates ?? MockRates.Values;
|
||||
ContainerDto? board = await _kanjStore.GetContainerAsync(boardCandidate, ct);
|
||||
return board is not null && ColumnRules.ContainerAccepts(board.Rules, text, rates)
|
||||
? (board.Id, ColumnRules.ComputeHits(board.Rules, text, rates))
|
||||
: (CardIds.Inbox, []);
|
||||
}
|
||||
|
||||
private static PipelineDryRunDto Rejected(
|
||||
IReadOnlyList<PipelineDryRunStageDto> stages,
|
||||
AiParsedCardDto? parsed = null) =>
|
||||
new(false, false, null, [], parsed, stages);
|
||||
|
||||
private static string StopStageCode(IncomingRulesResult stop) =>
|
||||
stop.Kind.Length > 0 ? stop.Kind : SourceStop;
|
||||
|
||||
private static bool WantedTypeRejects(string wanted, bool isHire) =>
|
||||
wanted switch
|
||||
{
|
||||
WantedTypeFreelance => isHire,
|
||||
WantedTypeVacancy => !isHire,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Карточка и обучение ML — partial-часть <see cref="PipelineWorkerService"/>
|
||||
/// </summary>
|
||||
public sealed partial class PipelineWorkerService
|
||||
{
|
||||
// ── Карточка и обучение ML ────────────────────────────────────────────
|
||||
|
||||
private async Task<CardDto> CreateCardAndDropAsync(
|
||||
PumpState state,
|
||||
AiParsedCardDto parsed,
|
||||
QueueItemDto row,
|
||||
string digest,
|
||||
bool isAiPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
CardDto card = await _cardWriter.CreateCardAsync(parsed, row, digest, ct);
|
||||
state.Created.Add(card);
|
||||
await _store.RemoveAsync(row.Id, ct);
|
||||
if (isAiPath)
|
||||
{
|
||||
state.AiStored++;
|
||||
}
|
||||
else
|
||||
{
|
||||
state.MlStored++;
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
private Task LearnFromAiCardAsync(
|
||||
CardDto card,
|
||||
AiParsedCardDto parsed,
|
||||
string text,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Логика сигналов вынесена в общий AiCardLearning (её же использует ручная переклассификация).
|
||||
return AiCardLearning.PushSignalsAsync(_kanjStore, _mlClient, card.Col, parsed, text, MlLearningLabels.AiPushWeight, ct);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
using System.Globalization;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application.ColumnRules;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Проход pump — partial-часть <see cref="PipelineWorkerService"/>
|
||||
/// </summary>
|
||||
public sealed partial class PipelineWorkerService
|
||||
{
|
||||
// ── Проход воркера ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Один проход по очереди
|
||||
/// </summary>
|
||||
/// <returns>Сводка прохода: счётчики решений + созданные карточки (SSE new_card).</returns>
|
||||
public async Task<PipelinePumpResult> PumpOnceAsync(CancellationToken ct)
|
||||
{
|
||||
WorkerRunSettings runSettings = await LoadRunSettingsAsync(_settings, ct);
|
||||
var state = new PumpState();
|
||||
await PumpNewPassAsync(state, runSettings, ct);
|
||||
await PumpFilteredPassAsync(state, runSettings, ct);
|
||||
await TrackDecisionsAsync(state, ct);
|
||||
return state.ToResult();
|
||||
}
|
||||
|
||||
|
||||
private async Task PumpNewPassAsync(
|
||||
PumpState state,
|
||||
WorkerRunSettings run,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<QueueItemDto> rows = await _store.ListAsync(PipelineQueueStatuses.New, NewBatchLimit, ct);
|
||||
foreach (QueueItemDto row in rows)
|
||||
{
|
||||
bool force = row.Force;
|
||||
|
||||
if (!force && IsStaleAsync(row, run))
|
||||
{
|
||||
await DropStaleAsync(row, run, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!force)
|
||||
{
|
||||
IncomingRulesResult verdict = await _rules.CheckAsync(row.Text, ct);
|
||||
if (!verdict.Pass)
|
||||
{
|
||||
await RejectRowAsync(row, SourceStop, verdict.Kind, verdict.Reason ?? string.Empty, verdict.Kw, ct);
|
||||
await DropRowAsync(row, DedupHasher.Hash(row.Text), withDedup: true, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
GlobalExclusionResult? exclusion = GlobalExclusionRules.Match(row.Text, run.GlobalExclusions);
|
||||
if (exclusion is not null)
|
||||
{
|
||||
await RejectRowAsync(row, SourceStop, exclusion.Kind, exclusion.Reason, exclusion.Kw, ct);
|
||||
await DropRowAsync(row, DedupHasher.Hash(row.Text), withDedup: true, ct);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
string digest = DedupHasher.Hash(row.Text);
|
||||
if (await _store.ExistsAsync(digest, ct))
|
||||
{
|
||||
await RejectRowAsync(row, SourceDup, StageDup, DupReason, string.Empty, ct);
|
||||
await DropRowAsync(row, digest, withDedup: true, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!await _store.ClaimAsync(digest, ct))
|
||||
{
|
||||
await RejectRowAsync(row, SourceDup, StageDup, DupReason, string.Empty, ct);
|
||||
await _store.RemoveAsync(row.Id, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
state.Staged++;
|
||||
|
||||
if (run.MlEnabled && !force)
|
||||
{
|
||||
MlPredictResultDto decision = await PredictSafelyAsync(row.Text, ct);
|
||||
string? label = decision.Label;
|
||||
|
||||
if (decision.Ready && decision.Take && !string.IsNullOrEmpty(label))
|
||||
{
|
||||
if (label == MlLearningLabels.Spam)
|
||||
{
|
||||
double score = decision.Scores.TryGetValue(MlLearningLabels.Spam, out double value) ? value : 0.0;
|
||||
string reason = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
MlSpamReasonFormat,
|
||||
score.ToString("F2", CultureInfo.InvariantCulture));
|
||||
await RejectRowAsync(row, SourceMl, StageSpamMl, reason, string.Empty, ct);
|
||||
await DropRowAsync(row, digest, withDedup: true, ct);
|
||||
state.MlDrop++;
|
||||
continue;
|
||||
}
|
||||
|
||||
ContainerDto? board = await _kanjStore.GetContainerAsync(label, ct);
|
||||
bool boardAllowed = board is not null && !board.Suggested && !ColumnRules.HasActiveRules(board.Rules);
|
||||
if (boardAllowed)
|
||||
{
|
||||
// Карточка из локальных полей + доска ML + тип ML (если уверен) + термины в стек.
|
||||
LocalParsedFields fields = _fieldsParser.Parse(row.Text, run.Settings);
|
||||
AiParsedCardDto parsed = AiCardMapper.FromLocal(fields, row.Text) with { Board = label };
|
||||
if (decision.Type is { Take: true } mlType)
|
||||
{
|
||||
parsed = parsed with
|
||||
{
|
||||
IsVacancy = mlType.Label == MlLearningLabels.TypeHireLabel,
|
||||
IsVacancyKnown = true,
|
||||
};
|
||||
}
|
||||
|
||||
parsed = parsed with { Stack = MergeMlTerms(decision.Terms, parsed.Stack) };
|
||||
if (await SkipNoBudgetAsync(parsed, row.Text, run, ct))
|
||||
{
|
||||
state.NoBudget++;
|
||||
await RejectRowAsync(row, SourceStop, StageBudget, NoBudgetReason, string.Empty, ct);
|
||||
await DropRowAsync(row, digest, withDedup: true, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
await CreateCardAndDropAsync(state, parsed, row, digest, isAiPath: false, ct);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (decision.Ready && decision.Type is { Take: true } typeDecision)
|
||||
{
|
||||
bool isHire = typeDecision.Label == MlLearningLabels.TypeHireLabel;
|
||||
string wanted = run.WantedType;
|
||||
|
||||
if (wanted is WantedTypeVacancy or WantedTypeFreelance)
|
||||
{
|
||||
bool bad = (wanted == WantedTypeFreelance && isHire)
|
||||
|| (wanted == WantedTypeVacancy && !isHire);
|
||||
if (bad)
|
||||
{
|
||||
string kindName = isHire ? MlHireKindName : MlOrderKindName;
|
||||
string reason = string.Format(MlTypeDropReasonFormat, kindName, wanted);
|
||||
await RejectRowAsync(row, SourceMl, StageType, reason, string.Empty, ct);
|
||||
await DropRowAsync(row, digest, withDedup: true, ct);
|
||||
state.TypeDrop++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!run.AiEnabled)
|
||||
{
|
||||
LocalParsedFields fields = _fieldsParser.Parse(row.Text, run.Settings);
|
||||
AiParsedCardDto parsed = AiCardMapper.FromLocal(fields, row.Text) with
|
||||
{
|
||||
IsVacancy = isHire,
|
||||
IsVacancyKnown = true,
|
||||
};
|
||||
|
||||
if (await SkipNoBudgetAsync(parsed, row.Text, run, ct))
|
||||
{
|
||||
state.NoBudget++;
|
||||
await RejectRowAsync(row, SourceStop, StageBudget, NoBudgetReason, string.Empty, ct);
|
||||
await DropRowAsync(row, digest, withDedup: true, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
await CreateCardAndDropAsync(state, parsed, row, digest, isAiPath: false, ct);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _store.SetStatusAsync(row.Id, PipelineQueueStatuses.Filtered, ct);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task PumpFilteredPassAsync(
|
||||
PumpState state,
|
||||
WorkerRunSettings run,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<QueueItemDto> rows = await _store.ListAsync(PipelineQueueStatuses.Filtered, FilteredBatchLimit, ct);
|
||||
foreach (QueueItemDto row in rows)
|
||||
{
|
||||
bool force = row.Force;
|
||||
|
||||
if (!force && IsStaleAsync(row, run))
|
||||
{
|
||||
await DropStaleAsync(row, run, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
string text = row.Text;
|
||||
string digest = DedupHasher.Hash(text);
|
||||
|
||||
if (!run.AiEnabled)
|
||||
{
|
||||
// Карточку собирает локальный разбор (без вызова порта ИИ).
|
||||
AiParsedCardDto localParsed = AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text);
|
||||
if (!force && await SkipNoBudgetAsync(localParsed, text, run, ct))
|
||||
{
|
||||
state.NoBudget++;
|
||||
await RejectRowAsync(row, SourceStop, StageBudget, NoBudgetReason, string.Empty, ct);
|
||||
await DropRowAsync(row, digest, withDedup: true, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
await CreateCardAndDropAsync(state, localParsed, row, digest, isAiPath: true, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
AiFilterResultDto filter;
|
||||
if (force)
|
||||
{
|
||||
filter = PassSkipped;
|
||||
}
|
||||
else
|
||||
{
|
||||
bool filterEnabled = run.AiFilterEnabled;
|
||||
filter = filterEnabled ? await FilterSafelyAsync(text, ct) : PassSkipped;
|
||||
}
|
||||
|
||||
// Классификация: только если фильтр пропустил; сбой — «разбора нет» (локальный путь ниже).
|
||||
AiParsedCardDto? parsed = null;
|
||||
if (filter.Pass)
|
||||
{
|
||||
try
|
||||
{
|
||||
parsed = await _aiClassifier.ClassifyAsync(text, ct);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
if (parsed is not null)
|
||||
{
|
||||
parsed = parsed with { IsVacancyKnown = true };
|
||||
}
|
||||
}
|
||||
|
||||
bool isSpam = !filter.Pass || (parsed?.IsSpam ?? false);
|
||||
if (force && filter.Pass && parsed?.IsSpam == true)
|
||||
{
|
||||
parsed = parsed with { IsSpam = false };
|
||||
isSpam = false;
|
||||
}
|
||||
|
||||
if (isSpam)
|
||||
{
|
||||
string stage = !filter.Pass ? StageFilterAi : StageSpamAi;
|
||||
string reason = !filter.Pass
|
||||
? AiFilterReasonPrefix + (string.IsNullOrEmpty(filter.Reason) ? AiFilterDefaultReason : filter.Reason)
|
||||
: AiSpamReason;
|
||||
await RejectRowAsync(row, SourceAi, stage, reason, string.Empty, ct);
|
||||
await _mlClient.PushAsync(text, MlLearningLabels.Spam, MlLearningLabels.AiPushWeight, ct);
|
||||
await DropRowAsync(row, digest, withDedup: true, ct);
|
||||
state.AiDrop++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed is null)
|
||||
{
|
||||
parsed = AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text);
|
||||
state.AiFail++;
|
||||
}
|
||||
|
||||
if (!force && await SkipNoBudgetAsync(parsed, text, run, ct))
|
||||
{
|
||||
state.NoBudget++;
|
||||
await RejectRowAsync(row, SourceStop, StageBudget, NoBudgetReason, string.Empty, ct);
|
||||
await DropRowAsync(row, digest, withDedup: true, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
CardDto card = await CreateCardAndDropAsync(state, parsed, row, digest, isAiPath: true, ct);
|
||||
await LearnFromAiCardAsync(card, parsed, text, ct);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Отсев и удаление строк — partial-часть <see cref="PipelineWorkerService"/>
|
||||
/// </summary>
|
||||
public sealed partial class PipelineWorkerService
|
||||
{
|
||||
// ── Отсев / удаление строк ─────────────────────────────────────────────
|
||||
|
||||
private Task RejectRowAsync(
|
||||
QueueItemDto row,
|
||||
string source,
|
||||
string stage,
|
||||
string reason,
|
||||
string kw,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return _processing.RejectAsync(new RejectRecord
|
||||
{
|
||||
Source = row.Source,
|
||||
Content = row.Content,
|
||||
Text = row.Text,
|
||||
MsgAtMs = row.MsgAtMs,
|
||||
DecidedBy = source,
|
||||
Stage = stage,
|
||||
Reason = reason,
|
||||
Kw = kw,
|
||||
}, ct);
|
||||
}
|
||||
|
||||
private async Task DropRowAsync(
|
||||
QueueItemDto row,
|
||||
string digest,
|
||||
bool withDedup,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (withDedup)
|
||||
{
|
||||
await _store.DeleteClaimAsync(digest, ct);
|
||||
}
|
||||
|
||||
await _store.RemoveAsync(row.Id, ct);
|
||||
}
|
||||
|
||||
private async Task DropStaleAsync(
|
||||
QueueItemDto row,
|
||||
WorkerRunSettings run,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await RejectRowAsync(row, SourceStale, StageStale, string.Format(StaleReasonFormat, run.ArchiveAfterDays), string.Empty, ct);
|
||||
await DropRowAsync(row, DedupHasher.Hash(row.Text), withDedup: true, ct);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Снимок настроек прохода — partial-часть <see cref="PipelineWorkerService"/>
|
||||
/// </summary>
|
||||
public sealed partial class PipelineWorkerService
|
||||
{
|
||||
|
||||
private sealed record WorkerRunSettings(
|
||||
TenantSettingsSnapshot Settings,
|
||||
bool AutoArchive,
|
||||
int ArchiveAfterDays,
|
||||
bool MlEnabled,
|
||||
bool AiEnabled,
|
||||
bool AiFilterEnabled,
|
||||
bool BudgetRequiredHire,
|
||||
bool BudgetRequiredOrder,
|
||||
string WantedType,
|
||||
GlobalExcludeSettings GlobalExclusions);
|
||||
|
||||
// Читает настройки воркера одним запросом (GetAllAsync) и резолвит значения с дефолтами
|
||||
// SettingsDefaults через типизированный снимок (C30).
|
||||
// settingsStore: KV-хранилище настроек тенанта.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Снимок на проход pump (один на PumpOnce, передаётся в проверки обоих проходов).
|
||||
private static async Task<WorkerRunSettings> LoadRunSettingsAsync(ISettingsStore settingsStore, CancellationToken ct)
|
||||
{
|
||||
TenantSettingsSnapshot settings = await TenantSettingsSnapshot.LoadAsync(settingsStore, ct);
|
||||
return new WorkerRunSettings(
|
||||
settings,
|
||||
settings.GetBool(SettingsKeys.AutoArchive, SettingsDefaults.AutoArchive),
|
||||
settings.GetInt(SettingsKeys.ArchiveAfterDays, SettingsDefaults.ArchiveAfterDays),
|
||||
settings.GetBool(SettingsKeys.MlEnabled, SettingsDefaults.MlEnabled),
|
||||
settings.GetBool(SettingsKeys.AiEnabled, SettingsDefaults.AiEnabled),
|
||||
settings.GetBool(SettingsKeys.AiFilterEnabled, SettingsDefaults.AiFilterEnabled),
|
||||
settings.GetBool(SettingsKeys.BudgetRequiredHire, SettingsDefaults.BudgetRequiredHire),
|
||||
settings.GetBool(SettingsKeys.BudgetRequiredOrder, SettingsDefaults.BudgetRequiredOrder),
|
||||
NormalizeWantedType(settings.GetString(SettingsKeys.WantedType, SettingsDefaults.WantedType)),
|
||||
LoadGlobalExclusions(settings));
|
||||
}
|
||||
|
||||
private static GlobalExcludeSettings LoadGlobalExclusions(TenantSettingsSnapshot settings)
|
||||
{
|
||||
int from = settings.GetInt(SettingsKeys.ExcludeBudgetFrom, SettingsDefaults.ExcludeBudgetFrom);
|
||||
int to = settings.GetInt(SettingsKeys.ExcludeBudgetTo, SettingsDefaults.ExcludeBudgetTo);
|
||||
return new GlobalExcludeSettings(
|
||||
Keywords: settings.GetStringList(SettingsKeys.ExcludeKeywords, SettingsDefaults.ExcludeKeywords),
|
||||
Locations: settings.GetStringList(SettingsKeys.ExcludeLocations, SettingsDefaults.ExcludeLocations),
|
||||
Types: settings.GetStringList(SettingsKeys.ExcludeTypes, SettingsDefaults.ExcludeTypes),
|
||||
BudgetFrom: from > 0 ? from : null,
|
||||
BudgetTo: to > 0 ? to : null);
|
||||
}
|
||||
|
||||
private static string NormalizeWantedType(string value)
|
||||
{
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Накопители результата pump — partial-часть <see cref="PipelineWorkerService"/>
|
||||
/// </summary>
|
||||
public sealed partial class PipelineWorkerService
|
||||
{
|
||||
// ── Накопители результата pump (счётчики и карточки одного прохода) ──
|
||||
|
||||
private sealed class PumpState
|
||||
{
|
||||
/// <summary>
|
||||
/// Прошли «new»-проход и переведены в filtered.
|
||||
/// </summary>
|
||||
public int Staged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Карточек создано ML-веткой
|
||||
/// </summary>
|
||||
public int MlStored { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Отсевов решением ML «спам»
|
||||
/// </summary>
|
||||
public int MlDrop { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Отсевов «тип не под режим» по решению ML
|
||||
/// </summary>
|
||||
public int TypeDrop { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Карточек создано ИИ-веткой/локальным путём
|
||||
/// </summary>
|
||||
public int AiStored { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Отсевов решением ИИ
|
||||
/// </summary>
|
||||
public int AiDrop { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Сообщений, где ИИ не дал разбора — собран локальный разбор
|
||||
/// </summary>
|
||||
public int AiFail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Отсевов фильтром «без суммы»
|
||||
/// </summary>
|
||||
public int NoBudget { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Карточки, созданные за проход
|
||||
/// </summary>
|
||||
public List<CardDto> Created { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Собирает неизменяемый результат прохода.
|
||||
/// </summary>
|
||||
/// <returns>Результат pump для admin/tick и фонового цикла.</returns>
|
||||
public PipelinePumpResult ToResult() => new()
|
||||
{
|
||||
Staged = Staged,
|
||||
MlStored = MlStored,
|
||||
MlDrop = MlDrop,
|
||||
TypeDrop = TypeDrop,
|
||||
AiStored = AiStored,
|
||||
AiDrop = AiDrop,
|
||||
AiFail = AiFail,
|
||||
NoBudget = NoBudget,
|
||||
CreatedCards = Created,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Воркер разбора очереди входящих — один проход pump.
|
||||
/// </summary>
|
||||
public sealed partial class PipelineWorkerService
|
||||
{
|
||||
private readonly IPipelineStore _store;
|
||||
private readonly ISettingsStore _settings;
|
||||
private readonly IncomingRules _rules;
|
||||
private readonly ICardStore _kanjStore;
|
||||
private readonly IMlClient _mlClient;
|
||||
private readonly IAiClassifier _aiClassifier;
|
||||
private readonly PipelineProcessingService _processing;
|
||||
private readonly PipelineCardWriter _cardWriter;
|
||||
private readonly LocalFieldsParser _fieldsParser;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт воркер pump над портами модуля Pipeline
|
||||
/// </summary>
|
||||
/// <param name="store">Хранилище очереди/отсева/дедупа (порт IPipelineStore).</param>
|
||||
/// <param name="settings">KV-настройки тенанта.</param>
|
||||
/// <param name="rules">Этап-1 правила фильтра входящих (длина/стоп-фразы/резюме/тип, Settings).</param>
|
||||
/// <param name="kanjStore">Порт канбана: доски (проверка allowed-колонок ML/обучения) и чтение карточки.</param>
|
||||
/// <param name="mlClient">Порт ML-сервиса: predict (решения «решил сам») и push (обучение).</param>
|
||||
/// <param name="aiClassifier">Порт ИИ: фильтр и классификация.</param>
|
||||
/// <param name="processing">Запись отсева (RejectAsync) и обслуживание вкладки «Обработка».</param>
|
||||
/// <param name="cardWriter">Создание карточки через публичный интерфейс Kanban + связь дедупа.</param>
|
||||
/// <param name="fieldsParser">Локальный структуратор (aiEnabled=false / сбой ИИ / локальные поля ML-ветки).</param>
|
||||
public PipelineWorkerService(
|
||||
IPipelineStore store,
|
||||
ISettingsStore settings,
|
||||
IncomingRules rules,
|
||||
ICardStore kanjStore,
|
||||
IMlClient mlClient,
|
||||
IAiClassifier aiClassifier,
|
||||
PipelineProcessingService processing,
|
||||
PipelineCardWriter cardWriter,
|
||||
LocalFieldsParser fieldsParser)
|
||||
{
|
||||
_store = store;
|
||||
_settings = settings;
|
||||
_rules = rules;
|
||||
_kanjStore = kanjStore;
|
||||
_mlClient = mlClient;
|
||||
_aiClassifier = aiClassifier;
|
||||
_processing = processing;
|
||||
_cardWriter = cardWriter;
|
||||
_fieldsParser = fieldsParser;
|
||||
}
|
||||
|
||||
// Лимит «new»-прохода: сколько сообщений за проход проходят правила/дедуп/ML.
|
||||
private const int NewBatchLimit = 12;
|
||||
|
||||
// Лимит «filtered»-прохода: сколько сообщений за проход идут на ИИ (дорогой шаг).
|
||||
private const int FilteredBatchLimit = 4;
|
||||
|
||||
private const long DayMs = 86_400_000;
|
||||
|
||||
|
||||
private const string SourceStop = "stop";
|
||||
|
||||
// Источник «ML» (решения модели).
|
||||
private const string SourceMl = "ml";
|
||||
|
||||
// Источник «ИИ» (фильтр/классификатор).
|
||||
private const string SourceAi = "ai";
|
||||
|
||||
// Источник «система»: устарело.
|
||||
private const string SourceStale = "stale";
|
||||
|
||||
// Источник «система»: повтор по дедупу.
|
||||
private const string SourceDup = "dup";
|
||||
|
||||
// Этап отсева «спам (ML)».
|
||||
private const string StageSpamMl = "spam_ml";
|
||||
|
||||
// Этап отсева «спам (ИИ)».
|
||||
private const string StageSpamAi = "spam_ai";
|
||||
|
||||
// Этап отсева «ИИ-фильтр».
|
||||
private const string StageFilterAi = "filter_ai";
|
||||
|
||||
private const string StageDup = "dup";
|
||||
|
||||
private const string StageStale = "stale";
|
||||
|
||||
private const string StageBudget = "budget";
|
||||
|
||||
private const string StageType = "type";
|
||||
|
||||
|
||||
private const int MaxMlTermsAdded = 4;
|
||||
|
||||
// ── Типы заявок wantedType (строка настройки, как у IncomingRules) ──
|
||||
|
||||
// wantedType: только вакансии/занятость.
|
||||
private const string WantedTypeVacancy = "vacancy";
|
||||
|
||||
// wantedType: только разовые заказы.
|
||||
private const string WantedTypeFreelance = "freelance";
|
||||
|
||||
|
||||
private const string StaleReasonFormat = "сообщение старше {0} дн. (срок до автоархива) — не заводим в систему";
|
||||
|
||||
private const string DupReason = "сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается";
|
||||
|
||||
private const string NoBudgetReason = "включён фильтр «не создавать карточку без суммы» — в тексте не указан бюджет";
|
||||
|
||||
private const string MlSpamReasonFormat = "ML уверен, что это спам/не заявка (score {0})";
|
||||
|
||||
private const string MlTypeDropReasonFormat = "ML: тип «{0}», а вы ищете только «{1}»";
|
||||
|
||||
private const string MlHireKindName = "найм/занятость";
|
||||
|
||||
private const string MlOrderKindName = "разовые заказы";
|
||||
|
||||
private const string AiSpamReason = "ИИ: не заявка — спам, реклама, скам или служебное сообщение";
|
||||
|
||||
private const string AiFilterReasonPrefix = "ИИ-фильтр: ";
|
||||
|
||||
private const string AiFilterDefaultReason = "сообщение не относится к вашим интересам";
|
||||
|
||||
private static readonly MlPredictResultDto NotReadyPrediction = new(
|
||||
Take: false,
|
||||
Label: null,
|
||||
Scores: new Dictionary<string, double>(),
|
||||
Hits: 0,
|
||||
Ready: false,
|
||||
Margin: null,
|
||||
Terms: Array.Empty<string>(),
|
||||
Type: null);
|
||||
|
||||
private static readonly AiFilterResultDto PassSkipped = new(Pass: true, Reason: null, Skipped: true);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Single-flight-замок ручной переклассификации
|
||||
/// </summary>
|
||||
public sealed class ReclassifyGate : IDisposable
|
||||
{
|
||||
private readonly SemaphoreSlim _gate = new(initialCount: 1, maxCount: 1);
|
||||
|
||||
/// <summary>
|
||||
/// Пытается войти в критическую секцию без ожидания.
|
||||
/// </summary>
|
||||
/// <returns>True — вход получен (вызывающий обязан вызвать <see cref="Exit"/>); false — проход уже идёт.</returns>
|
||||
public bool TryEnter() => _gate.Wait(0);
|
||||
|
||||
/// <summary>
|
||||
/// Освобождает вход после завершения прохода.
|
||||
/// </summary>
|
||||
public void Exit() => _gate.Release();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _gate.Dispose();
|
||||
}
|
||||
Reference in New Issue
Block a user