Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

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:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -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 &lt; 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 L301303)
}
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 L402403: 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:&lt;id&gt;</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 (L831832)
}
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;
}
}
}
@@ -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: Приращение (&gt;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);
}
}
}
@@ -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();
}