Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1,61 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Обучающие сигналы ML по карточке ИИ-пути (pipeline.py L1155–1180): колонка-доска и тип заявки.
/// </summary>
/// <remarks>
/// Общий источник для воркера pump (<see cref="PipelineWorkerService"/>) и ручной переклассификации
/// (<see cref="CardReclassifier"/>): оба «докладывают» ML те же сигналы гипотезы ИИ с весом
/// <see cref="MlLearningLabels.AiPushWeight"/>, поэтому логика вынесена из воркера без дублей.
/// Свободная колонка — не служебная (inbox/trash/archive), не ИИ-предложение и без активных правил:
/// именно такие доски ML может назначать сама (собираем аналогичные примеры).
/// </remarks>
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>
/// <param name="ct">Токен отмены.</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,65 @@
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Маппинг локального разбора в контрактный <see cref="AiParsedCardDto"/> (pipeline.py _local_fields L718–798 → raw-словарь карточки).
/// </summary>
/// <remarks>
/// Единый модульный способ превратить <see cref="LocalParsedFields"/> (LocalFieldsParser, Ruling 7) в форму
/// разбора, которую ест <see cref="CardComposer"/>: бюджет нормализуется <see cref="BudgetNormalizer.Normalize"/>
/// (ai.py clean_budget L316–326), контакты квалифицируются <see cref="ContactsQualifier.Build"/> (build_contacts
/// L389–421), блок «О заявке» — только legacy-суть (<see cref="LocalParsedFields.Summary"/>), тип — маркерная
/// гипотеза (<c>is_vacancy_known=false</c>), доска — inbox (<c>board=null</c>: «смысловые колонки до ИИ не
/// назначаем», python L954–958/L796–797). Маппинг используют локальные пути воркера (Ruling 8: aiEnabled=false,
/// сбой ИИ, ML-ветка с локальными полями) и адаптер <c>LocalAiClassifier</c> (Ruling 5 — эталон «ядро владельца +
/// тонкий адаптер»), поэтому он живёт в модуле, а не в Infrastructure: один источник истины для обеих сторон.
/// </remarks>
public static class AiCardMapper
{
/// <summary>
/// Строит разбор карточки из локальных полей и исходного текста (форма классификатора Ruling 5).
/// </summary>
/// <remarks>
/// Квалификация контактов идёт по строке <see cref="LocalParsedFields.Contacts"/> с fallback-поиском в тексте
/// (python build_contacts L389–421, ≤6, боты/сервисные ссылки отбрасываются) — как при сборке карточки
/// CardComposer'ом, чтобы разбор и карточка не расходились. Вызывающий при необходимости перекрывает
/// поля через <c>with</c> (ML-ветка: доска/тип ML, доклад terms в стек; локальные пути — нет).
/// </remarks>
/// <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,189 @@
using System.Text;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Settings.Application;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Сборка контекста ИИ-классификации из настроек и данных тенанта (план Task 15, Ruling 5;
/// ai.py fill_prompt L63–77, classify L218–258).
/// </summary>
/// <remarks>
/// Чистый сервис модуля Pipeline (без EF/HTTP): готовит то, что ядро кладёт в запросы ai-service, — 1:1 с
/// прототипом:
/// <list type="bullet">
/// <item><see cref="BuildFilterPromptAsync"/> — заполненный aiFilterPrompt (<c>fill_prompt</c>: подстановка
/// <c>{domain}</c>/<c>{keywords}</c> из настроек «Сфера и ключи», python L192);</item>
/// <item><see cref="BuildClassifySystemPromptAsync"/> — заполненные aiPrompt + cardPrompt, склеенные через
/// пустую строку, если cardPrompt непуст (python L252–257);</item>
/// <item><see cref="BuildClassifyUserContextAsync"/> — user-контекст «Доски + примеры разметки + Сообщение»
/// (python L226–251): доски non-suggested с критериями правил (RulesDescriber, python describe L341–368) либо
/// ключевыми словами (≤8) и описанием (≤160), few-shot-примеры пользовательской разметки по журналу CardMoves
/// (≤8, текст ≤500) и текст сообщения (≤5000).</item>
/// </list>
/// Ветки выключателей (aiEnabled/aiFilterEnabled) и решение «когда звать ИИ» остаются за воркером (Ruling 5
/// этапа 4) — билдер вызывает только потребитель gRPC-адаптера (GrpcAiClassifier); локальный классификатор
/// контекст не строит. Scoped: настройки/доски/журнал читаются из tenant-scope запроса (ISettingsStore/
/// ICardStore, как CardComposer).
/// </remarks>
/// <param name="settings">KV-настройки тенанта (промпты и «Сфера и ключи»).</param>
/// <param name="kanjStore">Порт канбана: доски (non-suggested) и few-shot-примеры журнала CardMoves.</param>
public sealed class AiClassifyContextBuilder(ISettingsStore settings, ICardStore kanjStore)
{
/// <summary>
/// Лимит примеров разметки в контексте (python _learning_examples L201: limit=8).
/// </summary>
public const int MaxLearningExamples = 8;
/// <summary>
/// Лимит описания колонки в строке-описании (python L241: description[:160]).
/// </summary>
public const int MaxBoardDescriptionCodePoints = 160;
/// <summary>
/// Лимит текста примера разметки (python L214: msg[:500]).
/// </summary>
public const int MaxMarkupTextCodePoints = 500;
/// <summary>
/// Лимит текста сообщения в контексте (python L250: message_text[:5000]).
/// </summary>
public const int MaxMessageTextCodePoints = 5000;
// Текст карты досок, когда колонок нет (python L243).
private const string NoBoardsLine = "- (колонок пока нет — верните board: null)";
// Заголовок раздела досок user-контекста (python L246).
private const string BoardsHeader = "Доски: ";
// Заголовок раздела примеров разметки (python L247).
private const string ExamplesHeader = "Примеры разметки пользователя:";
// Заголовок раздела нового сообщения (python L250).
private const string MessageHeader = "Новое сообщение:";
/// <summary>
/// Заполненный промпт ИИ-фильтра: aiFilterPrompt с подстановкой {domain}/{keywords} (python L192).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <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-промпт классификации: aiPrompt + cardPrompt (python L252–257).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Текст system_prompt ClassifyRequest (cardPrompt приклеен, если непуст).</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-контекст классификации «Доски + примеры разметки + Сообщение» (python L226–251).
/// </summary>
/// <param name="text">Текст сообщения (режется до <see cref="MaxMessageTextCodePoints"/>).</param>
/// <param name="ct">Токен отмены.</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();
}
// Карта досок классификации: non-suggested с критериями/ключами и описанием (python L226–243).
// ct: Токен отмены.
// Возвращает: Список строк «- id: имя (критерии) — описание»; пусто — фраза «колонок нет».
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);
}
// Строка колонки: «- id: имя (критерии)» + « — описание» (python L230–242).
// container: Колонка (non-suggested, kind=board).
// Возвращает: Однострочное описание колонки для промпта.
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;
}
// Заполненный промпт: значение настройки (или дефолт) + PromptFiller (python fill_prompt L63–77).
// key: Ключ промпта (aiPrompt/aiFilterPrompt/cardPrompt).
// defaultValue: Дефолт из SettingsDefaults (когда переопределения нет).
// settingsSnapshot: Типизированный снимок настроек тенанта (промпты/«Сфера и ключи»).
// Возвращает: Текст промпта с подставленными {domain}/{keywords}.
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,349 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Строгий маппинг JSON-ответа ИИ-классификатора в контрактный разбор карточки (план Task 15, Ruling 5;
/// python _store_lead L433–514 + clean_budget L316–339 + build_contacts L389–421).
/// </summary>
/// <remarks>
/// Потребитель — gRPC-адаптер <c>GrpcAiClassifier</c> (реализация <see cref="IAiClassifier"/> на этапе 6):
/// ответ ai-service (ClassifyReply.Json — сырой JSON модели, типовую схему задаёт промпт aiPrompt/cardPrompt)
/// маппится 1:1 с python: заголовок — <see cref="MessageTextCleaner.CleanShort"/> (≤140, fallback — начало
/// исходника L455), блок «О заявке» — структурированные поля raw (company/format/task/requirements/plus/
/// conditions) + legacy-суть summary (композицию в текст делает CardComposer через SummaryComposer), стек —
/// <see cref="MessageListNormalizer.NormalizeStack"/> (normalize_stack L332–341), бюджет — <see cref="BudgetNormalizer"/>
/// (clean_budget L316–339: алиасы валют, «2к»→2000, одна сумма → from=to, 0 → null; не распознан → null), контакты —
/// <see cref="ContactsQualifier.Build"/> (build_contacts L389–421, ≤6, дедуп, fallback-поиск в тексте), типы/спам/
/// доска — как вернула модель (доску страхует CardComposer правилами ContainerAccepts, python L446–450).
/// Ключи полей 1:1 с прототипом (title/summary/company/format/task/requirements/plus/conditions/stack/budget/
/// contacts/is_vacancy/is_vacancy_known/is_spam/board); вызов вне данных — <see cref="JsonException"/>.
/// </remarks>
public static class AiRawCardMapper
{
// Лимит заголовка карточки (python L455: clean_short(title, 140)).
private const int MaxTitleCodePoints = 140;
// Лимит исходного текста как fallback-заголовка (python L455: clean_short(text, 140)).
private const int MaxFallbackTextCodePoints = 140;
// Ключи числа бюджета: цифры с необязательным суффиксом «к/К» и «руб/р/₽» (ai.py _budget_num L294–313).
private const string BudgetAmountPattern = @"[0-9.,]+\s*[кkКK]?";
// Имя поля валюты в объекте бюджета (ai.py L327: currency или cur).
private const string CurrencyFieldName = "currency";
// Альтернативное имя поля валюты в объекте бюджета (ai.py L327: budget.get("cur")).
private const string CurrencyFieldNameShort = "cur";
// Регулярное выражение числа бюджета: число + необязательные «к»/валюта-суффиксы.
private static readonly System.Text.RegularExpressions.Regex BudgetNumRe =
new(@"\A" + BudgetAmountPattern + @"(?:руб|р|₽)?\z", System.Text.RegularExpressions.RegexOptions.CultureInvariant);
/// <summary>
/// Маппит JSON-ответ модели в контрактный разбор карточки (поля 1:1 с _store_lead).
/// </summary>
/// <param name="json">Сырой JSON-ответ модели (ClassifyReply.Json; типовую схему задаёт промпт).</param>
/// <param name="text">Исходный текст сообщения (fallback заголовка/контактов, python L455/L406).</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);
}
// Заголовок/строковые поля: значение как строка (python str(raw.get(k) or "")).
// root: Корневой объект ответа модели.
// field: Имя поля.
// Возвращает: Значение строкой (отсутствие/null → пустая строка).
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;
}
// Строковое поле блока «О заявке»: null/отсутствие/пусто → null (python: блока нет).
// root: Корневой объект ответа модели.
// field: Имя поля.
// Возвращает: Значение или null.
private static string? ReadNullableString(JsonObject root, string field)
{
string value = ReadRawString(root, field);
return value.Length == 0 ? null : value;
}
// Булево поле: true/1/строковые «да»-подобные → true, иначе false (python bool(raw.get(...))).
// root: Корневой объект ответа модели.
// field: Имя поля.
// Возвращает: True/False; отсутствие/неразбираемое → false.
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;
}
// Список пунктов (requirements/plus): python compose_summary _items L235–241 — normalize_list + чистка.
// root: Корневой объект ответа модели.
// field: Имя поля.
// Возвращает: Очищенные пункты; отсутствие/null → null (блока нет), пустой список → пустой список.
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;
}
// Стек из ответа модели (python normalize_stack L332–341: список или строка, ≤12, ≥2 символов).
// root: Корневой объект ответа модели.
// Возвращает: Нормализованный стек.
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>();
}
// Бюджет из ответа модели (ai.py clean_budget L316–339 через BudgetNormalizer).
// root: Корневой объект ответа модели.
// Возвращает: Нормализованный бюджет контракта или null (нет объекта/валюты/границ).
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);
}
// Граница бюджета (ai.py _budget_num L294–313): число, «2к» → 2000, «2000₽» → 2000.
// budget: Объект бюджета ответа модели.
// field: Имя границы (from/to).
// Возвращает: Число или null — поле отсутствует/пусто/не разбирается.
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;
}
// Контакты из ответа модели (python build_contacts L389–421: строка/список/значения-объекты).
// root: Корневой объект ответа модели.
// text: Исходный текст сообщения (fallback-кандидаты, если контактов в ответе нет).
// Возвращает: Квалифицированные контакты (≤6, дедуп по casefold-значению).
private static IReadOnlyList<AiContactDto> ReadContacts(JsonObject root, string text)
{
List<string> candidates = new();
if (root.TryGetPropertyValue("contacts", out JsonNode? node) && node is not null)
{
if (node is JsonValue)
{
candidates.Add(ValueToString((JsonValue)node)); // строка: разобьётся разделителями ниже
}
else if (node is JsonArray array)
{
foreach (JsonNode? item in array)
{
switch (item)
{
case null:
continue;
case JsonValue value:
candidates.Add(ValueToString(value));
break;
case JsonObject contactObject:
{
string value = ReadRawString(contactObject, "value"); // python L402–403: dict.get("value")
if (value.Length > 0)
{
candidates.Add(value);
}
break;
}
}
}
}
else if (candidates.Count == 0)
{
return Array.Empty<AiContactDto>();
}
}
IReadOnlyList<CardContactDto> qualified = ContactsQualifier.Build(
candidates.Count > 0 ? candidates : null, text);
var result = new List<AiContactDto>(qualified.Count);
foreach (CardContactDto contact in qualified)
{
result.Add(new AiContactDto(contact.Type, contact.Value));
}
return result;
}
// Значение JsonNode строкой: JsonValue — как ValueToString, прочее/отсутствие — пустая строка.
// node: Узел JSON (элемент массива).
// Возвращает: Строковое представление (python str()).
private static string NodeToString(JsonNode? node)
=> node is JsonValue value ? ValueToString(value) : string.Empty;
// Значение JsonValue строкой (python str(): строка — как есть, число — без дробного хвоста).
// value: JSON-значение.
// Возвращает: Строковое представление.
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,199 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application;
using Deal.Modules.Kanban.Application;
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;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Сборка снимка карточки из разобранного сообщения для записи через Kanban (план Task 7 L393–399,
/// Ruling 4; python pipeline.py _store_lead L433–514).
/// </summary>
/// <remarks>
/// Чистый класс модуля Pipeline (без EF/HTTP): из разбора <see cref="AiParsedCardDto"/> (ИИ/локальный путь)
/// и строки-сообщения <see cref="QueueItemDto"/> собирает полный <see cref="CardSnapshot"/> 1:1 с _store_lead:
/// title — <see cref="MessageTextCleaner.CleanShort"/> (140, fallback — начало исходника); «О заявке» —
/// <see cref="SummaryComposer.Compose"/> (блоки Компания → … → Условия, 1:1 с cardPrompt/compose_summary
/// L225–284) через <see cref="MessageTextCleaner.CleanBlock"/> (2000, fallback — clean_short исходника);
/// stack — <see cref="MessageListNormalizer.NormalizeStack"/> (≤12); бюджет — <see cref="BudgetNormalizer.Normalize"/>
/// из разбора (форма хранения) + fallback первой суммы <see cref="AmountRangeBudgetFallback.Extract"/> по
/// исходнику/«О заявке» (L459–468); конверсия один раз при поступлении — <see cref="BudgetNormalizer.ToTarget"/>
/// (conversionOn/targetCurrency/ratesCache из типизированного снимка TenantSettingsSnapshot, C30, с мок-фолбэком);
/// контакты — <see cref="ContactsQualifier.Build"/> из значений разбора или текста (L389–421, ≤6, дедуп),
/// contact = <see cref="ContactsQualifier.Primary"/> (L424–430, ≤200); ch-поля канала, sourceMsg = text[:4000],
/// prevCol=inbox, isVacancy/isVacancyKnown из разбора.
/// <para>
/// Колонка: разбор может назначить доску (parsed.Board) — <see cref="BuildAsync"/> читает её через
/// <see cref="ICardStore.GetContainerAsync"/> и применяет страховку <see cref="ColumnRules.ContainerAccepts"/> (python
/// L449–450): доска отсутствует или текст не прошёл правила → col=inbox (ИИ/ML не кладут в отфильтрованную
/// колонку); прошла → col=доска, matchHits = <see cref="ColumnRules.ComputeHits"/> для прошедшей доски (иначе
/// пусто). Id карточки (<c>c_</c>) генерирует вызывающий (PipelineCardWriter) и передаёт готовым (Ruling 12).
/// </para>
/// </remarks>
public sealed class CardComposer(ICardStore kanjStore, ISettingsStore settings)
{
// Лимит заголовка карточки (python _store_lead L455: clean_short(title, 140)).
private const int MaxTitleCodePoints = 140;
// Лимит блока «О заявке» (python L458: clean_block(summary, 2000)).
private const int MaxSummaryCodePoints = 2000;
// Лимит исходного сообщения на карточке (python L503: text[:4000]).
private const int MaxSourceMsgCodePoints = 4000;
// Лимит «быстрого» контакта карточки (python L471: primary_contact(contacts)[:200]).
private const int MaxPrimaryContactCodePoints = 200;
/// <summary>
/// Собирает полный снимок новой карточки из разбора и строки сообщения (1:1 с _store_lead L433–514).
/// </summary>
/// <remarks>
/// Использует только поля-сообщения QueueItemDto (DialogId/Channel/Text/MsgId/MsgAtMs) — статус/время
/// постановки строки на карточку не влияют. «Повтор по дедупу» здесь не проверяется (этап воркера,
/// Ruling 8): вызывающий (PipelineCardWriter/воркер) уже заявил хэш и связал карточку после записи.
/// </remarks>
/// <param name="parsed">Разбор сообщения (ИИ-классификатор или локальный путь; контакты квалифицированы).</param>
/// <param name="message">Строка очереди с сообщением-источником (метаданные канала, текст, время).</param>
/// <param name="cardId">Готовый id карточки (<c>c_...</c>; генерирует PipelineCardWriter через PrefixId).</param>
/// <param name="ct">Токен отмены.</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);
}
// Колонка и «почему карточка здесь»: страховка L449–450 (доски нет/не прошла правила → inbox).
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);
string hue = string.IsNullOrWhiteSpace(message.Channel.Hue) ? SourceDefaults.DefaultHue : message.Channel.Hue;
DateTimeOffset receivedAt = message.MsgAtMs != 0
? DateTimeOffset.FromUnixTimeMilliseconds(message.MsgAtMs)
: DateTimeOffset.UtcNow; // python row.get("msg_at") or now (L501)
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,
ChannelName = message.Channel.Name,
ChannelHandle = message.Channel.Handle,
ChannelHue = hue,
ReceivedAt = receivedAt,
SourceMsg = MessageTextCleaner.SliceCodePoints(message.Text, MaxSourceMsgCodePoints),
SourceDialogId = message.DialogId,
SourceMsgId = message.MsgId,
PrevCol = CardIds.Inbox,
ArchivedAt = null,
MatchHits = matchHits,
};
}
// Бюджет карточки: нормализация из разбора, иначе fallback первой суммы по исходнику/«О заявке»
// (python L453/L459–468; формы хранения — CardBudgetDto).
// parsedBudget: Бюджет разбора (форма контракта; null — разбор не выделил сумму).
// text: Текст исходного сообщения (первый источник fallback).
// summary: Блок «О заявке» (второй источник fallback — сумма часто уходит в «Условия»).
// Возвращает: Нормализованный бюджет либо null — суммы с валютой нет ни в разборе, ни в тексте.
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);
}
// Маппинг разбора классификатора в структуру блока «О заявке» (поля 1:1 с cardPrompt L116–123).
// parsed: Разбор (пустые/отсутствующие поля блок не дают — SummaryComposer).
// Возвращает: Структура для SummaryComposer.Compose.
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,375 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Ручная переклассификация карточек: повторный прогон через тот же конвейер, что и пайплайн
/// (leads.py reclassify_lead L292–389 + reclassify_inbox L392–417), но без создания новой карточки.
/// </summary>
/// <remarks>
/// Для каждой карточки повторяются шаги «filtered»-прохода воркера: ИИ-фильтр (при включённом ИИ) →
/// классификация через <see cref="IAiClassifier"/> → отсев спама в корзину с обучением ML → сборка контента
/// <see cref="CardComposer"/> (заголовок/«О заявке»/стек/бюджет/контакты) → страховка ContainerAccepts →
/// обновление карточки одним запросом и обучающие сигналы ML (<see cref="AiCardLearning"/>).
/// <para>
/// Путь без ИИ (выключен настройкой <c>aiEnabled</c>) и сбой классификатора — локальный детерминированный
/// разбор (<see cref="LocalFieldsParser"/> + <see cref="AiCardMapper"/>, как ветка воркера aiEnabled=false/
/// aiFail): сервис не падает без кредов/сервиса ИИ. ИИ-фильтр уважает выключатель <c>aiFilterEnabled</c>.
/// </para>
/// <para>
/// Проход синхронный, одна переклассификация за раз — <see cref="ReclassifyGate"/> (состояние singleton);
/// занятый проход отвечает <c>busy</c> без ожидания. Токены ИИ-пути учитывает сам адаптер
/// <c>GrpcAiClassifier</c> через <c>TokenUsageRecorder</c> — отдельного учёта переклассификации не нужно.
/// </para>
/// </remarks>
/// <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>
/// Причина: в «Неразобранном» нет карточек для переклассификации (пустой target).
/// </summary>
public const string EmptyInboxReason = "В «Неразобранном» нет карточек для переклассификации";
/// <summary>
/// Причина: у карточки нет исходного текста (переклассифицировать нечего).
/// </summary>
public const string NoSourceTextReason = "У карточки нет исходного текста для переклассификации";
// Ответ «фильтр пропущен» (выключен/сбой/путь без ИИ: python L1097–1106).
private static readonly AiFilterResultDto PassSkipped = new(Pass: true, Reason: null, Skipped: true);
/// <summary>
/// Пакетная переклассификация «Неразобранного»: все карточки inbox либо пересечение с ids.
/// </summary>
/// <remarks>Порядок обхода — как отдаёт хранилище (received_at DESC); ids ограничивает выборку.</remarks>
/// <param name="ids">Опциональный список id (null/пусто — все карточки inbox).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Итог прохода (счётчики исхода) либо <c>busy</c>, если проход уже идёт.</returns>
public async Task<ReclassifyResultDto> ReclassifyInboxAsync(IReadOnlyList<string>? ids, CancellationToken ct)
{
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);
}
foreach (CardDto card in target)
{
await ReclassifyOneAsync(card, pass, ct);
}
return Build(pass, attempted: target.Count, started: true, reason: null);
}
finally
{
gate.Exit();
}
}
/// <summary>
/// Переклассификация одной карточки (любой колонки; обычно — «Неразобранное»).
/// </summary>
/// <param name="card">Карточка (уже прочитана вызывающим — 404 остаётся за эндпоинтом).</param>
/// <param name="ct">Токен отмены.</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.SourceMsg))
{
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.SourceMsg;
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)
{
// Классификатор недоступен/сбой — локальный разбор (как raw={} python L1112–1114).
parsed = null;
}
if (parsed is not null)
{
// Успешная классификация ИИ подтверждает тип по контексту (python L1108–1111).
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);
}
// Отправляет карточку в корзину и обучает ML «спаму» с весом гипотезы ИИ (python L311–318).
// card: Карточка.
// text: Исходный текст (обучающий пример).
// pass: Накопители прохода.
// 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: Исходный текст (source_msg).
// Возвращает: Строка очереди, эквивалентная исходному сообщению карточки.
private static QueueItemDto BuildMessage(CardDto card, string text) => new()
{
DialogId = card.SourceDialogId,
MsgId = card.SourceMsgId,
Text = text,
Channel = new PipelineChannelDto(card.Channel.Name, card.Channel.Handle, card.Channel.Hue),
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);
// Контакт карточки: новый из разбора, иначе — валидный старый (python L327–331).
// card: Карточка до переклассификации (старый контакт).
// computed: Контакт, собранный из нового разбора.
// Возвращает: Значение основного контакта либо пустая строка.
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;
}
// Отбор карточек inbox по опциональному списку id (python L394–399).
// inbox: Карточки «Неразобранного» (порядок хранилища).
// ids: Опциональный фильтр id.
// Возвращает: Целевые карточки в порядке хранилища.
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,198 @@
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Глобальные исключения тенанта — стоп-фильтр ДО ML/ИИ (§5.14/§8, «стоп на уровне фильтров»).
/// </summary>
/// <remarks>
/// Чистая функция над снимком настроек (<see cref="GlobalExcludeSettings"/>) и текстом сообщения. Порядок
/// проверок — от самого дешёвого/явного к косвенному: ключевые слова → локация/язык → тип → бюджет.
/// Первое сработавшее исключение возвращается как <see cref="GlobalExclusionResult"/> (причина называет
/// конкретное исключение); иначе null — сообщение идёт дальше по пайплайну. Бюджет сравнивается по
/// числовым суммам текста (<see cref="AmountParser"/>) БЕЗ конвертации валют (у настройки нет валюты;
/// сравнение чисел предсказуемо и не зависит от кэша курсов). Так исключения экономят токены: отсев
/// происходит до ML-прогноза и ИИ-вызова.
/// </remarks>
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,195 @@
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Порт хранилища пайплайна (таблицы QueueItems/RejectedItems/DedupEntries тенанта), Ruling 1.
/// </summary>
/// <remarks>
/// Объявлен в модуле Pipeline (чистый: без EF/HTTP); реализация — EF-адаптер <c>PipelineStore</c> в
/// Deal.Infrastructure (Task 3, регистрация в AddDealPersistence). Порт оперирует DTO модуля; маппинг
/// DTO ↔ строки (включая DateTimeOffset ↔ epoch-ms наружу и подписи stageLabel/sourceLabel через
/// <see cref="PipelineRejectConstants"/>) выполняет адаптер вручную (эталон KanbanStore.cs). Набор методов —
/// ровно тот, что нужен задачам 3/5/8/9/11 (YAGNI): очередь (приём/списки/статусы/удаление), отсев
/// (upsert/страницы/поиск-кандидаты/очистки/возврат) и дедуп-заявки (проверка/claim/связь с карточкой).
/// Id строк очереди (<c>p_</c>) генерирует модуль и передаёт готовыми (Ruling 2); id отсева — детерминированный
/// из RejectRecord либо случайный <c>r_</c>+hex в адаптере (processing.record L77, Ruling 1).
/// Чтения — AsNoTracking; сортировки/лимиты — обязанность адаптера. Статусы очереди: <c>new</c> (ждёт правил/
/// дедупа/ML) и <c>filtered</c> (прошла «new»-проход, ждёт ИИ; счётчик «ai»).
/// </remarks>
public interface IPipelineStore
{
// ── Очередь (QueueItems) ───────────────────────────────────────────────
/// <summary>
/// Есть ли строка очереди с тем же сообщением диалога — дубль-гвард приёма (Ruling 2, enqueue L70–77).
/// </summary>
/// <param name="dialogId">Id диалога-источника.</param>
/// <param name="msgId">Id исходного сообщения; null — проверка не выполняется (вернёт false).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>true — строка с DialogId+MsgId уже в очереди (Telethon-дубль не пишем).</returns>
public Task<bool> ExistsDuplicateAsync(string dialogId, long? msgId, CancellationToken ct);
/// <summary>
/// Добавляет строку очереди (вставка без гвардов — сервис приёма уже проверил дубль и обрезал текст).
/// </summary>
/// <param name="item">Полная строка: id <c>p_</c>, статус new, QueuedAtMs=CreatedAt=UpdatedAt (задаёт модуль).</param>
/// <param name="ct">Токен отмены.</param>
public Task AddAsync(QueueItemDto item, CancellationToken ct);
/// <summary>
/// Строки очереди в порядке постановки (CreatedAt ASC), необязательно фильтр по статусу (воркер, Ruling 8).
/// </summary>
/// <param name="status">Статус new|filtered, либо null — все статусы (GET /api/pipeline/queue, L218–241).</param>
/// <param name="limit">Максимум строк (лимиты сервиса: 12/4 у воркера, ≤500 у списка).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Строки очереди (включая внутренний <see cref="QueueItemDto.Force"/> для воркера).</returns>
public Task<IReadOnlyList<QueueItemDto>> ListAsync(string? status, int limit, CancellationToken ct);
/// <summary>
/// Сколько строк очереди со статусом (для счётчиков queue_counts: new/ai=filtered, L207–215).
/// </summary>
/// <param name="status">Статус new|filtered.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Число строк со статусом.</returns>
public Task<int> CountByStatusAsync(string status, CancellationToken ct);
/// <summary>
/// Меняет статус строки очереди (new → filtered после «new»-прохода воркера; UpdatedAt = now).
/// </summary>
/// <param name="id">Id строки (<c>p_...</c>).</param>
/// <param name="status">Новый статус new|filtered.</param>
/// <param name="ct">Токен отмены.</param>
public Task SetStatusAsync(string id, string status, CancellationToken ct);
/// <summary>
/// Удаляет строку очереди безвозвратно (отсев на любом этапе; сброс dedup-claim — DeleteClaimAsync).
/// </summary>
/// <param name="id">Id строки (<c>p_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
public Task RemoveAsync(string id, CancellationToken ct);
// ── Отсев (RejectedItems) ──────────────────────────────────────────────
/// <summary>
/// Пишет запись отсева: детерминированный id по dialog+msgId либо случайный <c>r_</c>+hex; повторное
/// отбрасывание того же сообщения обновляет запись (upsert ON CONFLICT, processing.record L66–101).
/// Пустой/пробельный текст — no-op.
/// </summary>
/// <param name="record">Команда записи отсева (текст/канал/время + source/stage/reason/kw).</param>
/// <param name="ct">Токен отмены.</param>
public Task UpsertAsync(RejectRecord record, CancellationToken ct);
/// <summary>
/// Страница отсева без поиска: ORDER BY RejectedAt DESC, LIMIT/OFFSET (processing.list_rejected L278–284).
/// </summary>
/// <param name="offset">Сдвиг от начала (0 — первая страница).</param>
/// <param name="limit">Размер страницы (≤500; валидирует сервис).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Записи страницы (полные DTO с подписями этапа/источника).</returns>
public Task<IReadOnlyList<RejectedItemDto>> ListPageAsync(int offset, int limit, CancellationToken ct);
/// <summary>
/// Кандидаты поиска по отсеву ПОЛНЫМИ СТРОКАМИ: FTS-совпадения (SearchTsv @@ plainto_tsquery,
/// rank DESC, ≤limitFts) + LIKE-дополнение по lower(text)/reason/kw/ch_name (≤limitLike), без дублей —
/// объединение Ruling 6 (processing L252–270); итог — общий список страниц (total считает сервис).
/// Записи читаются сразу в выборках кандидатов — без N+1 «id → GetAsync» страниц поиска (Ruling 6).
/// </summary>
/// <param name="q">Поисковый запрос (сервис отдаёт нормализованный lower).</param>
/// <param name="limitFts">Лимит FTS-кандидатов.</param>
/// <param name="limitLike">Лимит LIKE-дополнения.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Упорядоченный список полных записей-кандидатов (FTS-ранжированные первыми, затем LIKE-дополнение).</returns>
public Task<IReadOnlyList<RejectedItemDto>> SearchAsync(string q, int limitFts, int limitLike, CancellationToken ct);
/// <summary>
/// Всего записей отсева (rejected_count; счётчик вкладки «Обработка»).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Число записей.</returns>
public Task<int> CountAsync(CancellationToken ct);
/// <summary>
/// Одна запись отсева по id (для возврата/удаления и страниц поиска).
/// </summary>
/// <param name="id">Id записи (<c>r_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Запись (полный DTO) или null, если строки нет.</returns>
public Task<RejectedItemDto?> GetAsync(string id, CancellationToken ct);
/// <summary>
/// Удаляет одну запись отсева безвозвратно (DELETE /rejected/{id}, delete_one L196–198).
/// </summary>
/// <param name="id">Id записи (<c>r_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
public Task DeleteAsync(string id, CancellationToken ct);
/// <summary>
/// Полная очистка отсева (POST /rejected/clear; clear_all L103–110).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Сколько записей удалено.</returns>
public Task<int> ClearAsync(CancellationToken ct);
/// <summary>
/// Автоочистка: удаляет записи с RejectedAt старше olderThan (purge_expired L104–117, Ruling 8).
/// </summary>
/// <param name="olderThan">Граница срока хранения (UTC): удаляются записи RejectedAt &lt; olderThan.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Сколько записей удалено.</returns>
public Task<int> PurgeExpiredAsync(DateTimeOffset olderThan, CancellationToken ct);
/// <summary>
/// Помечает запись отсева возвращённой: returned=true + returnedAt + returnReason (return_to_queue L164–174).
/// </summary>
/// <param name="id">Id записи (<c>r_...</c>).</param>
/// <param name="reason">Причина возврата (уже обрезана сервисом до 500).</param>
/// <param name="returnedAt">Момент возврата (UTC).</param>
/// <param name="ct">Токен отмены.</param>
public Task MarkReturnedAsync(string id, string reason, DateTimeOffset returnedAt, CancellationToken ct);
// ── Дедуп (DedupEntries) ──────────────────────────────────────────────
/// <summary>
/// Есть ли запись дедупа с хэшем (нормализованный текст уже в системе — отсев dup, Ruling 8).
/// </summary>
/// <param name="hash">SHA1-hex нормализованного текста (без префикса).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>true — запись существует.</returns>
public Task<bool> ExistsAsync(string hash, CancellationToken ct);
/// <summary>
/// Заявляет хэш за обрабатываемым сообщением: INSERT (hash, LeadId=null, CreatedAt=now)
/// ON CONFLICT DO NOTHING — параллельные дубли не проходят (Ruling 8, L947–949). Атомарность claim'а
/// позволяет воркеру проверить результат: false — хэш уже заявлен другим проходом pump, карточку не создаём.
/// </summary>
/// <param name="hash">SHA1-hex нормализованного текста.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — заявка занята этим вызовом (строка INSERT'нута); false — хэш уже заявлен (ON CONFLICT).</returns>
public Task<bool> ClaimAsync(string hash, CancellationToken ct);
/// <summary>
/// Снимает незанятую заявку дедупа: DELETE WHERE Hash=? AND LeadId IS NULL (не трогает строки,
/// уже связанные с карточкой; _drop_row L812–814, Ruling 8).
/// </summary>
/// <param name="hash">SHA1-hex нормализованного текста.</param>
/// <param name="ct">Токен отмены.</param>
public Task DeleteClaimAsync(string hash, CancellationToken ct);
/// <summary>
/// Связывает заявку дедупа с созданной карточкой: UPDATE DedupEntries SET LeadId=? WHERE Hash=?
/// (порядок AddCard → LinkDedup, Ruling 4; L512–513).
/// </summary>
/// <param name="hash">SHA1-hex нормализованного текста.</param>
/// <param name="cardId">Id созданной карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
public Task LinkAsync(string hash, string cardId, CancellationToken ct);
/// <summary>
/// Чистит «мягкие» ссылки карточки при её жёстком удалении (Ruling 3: DELETE DedupEntries
/// WHERE LeadId=? — KanbanStore зовёт при DeleteForever/Purge/ClearCol через адаптер, без цикла модулей).
/// </summary>
/// <param name="cardId">Id удаляемой карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
public Task DeleteByCardAsync(string cardId, CancellationToken ct);
}
@@ -0,0 +1,421 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Ручная проверка/разметка ML на сообщениях канала (§8 ML: «проверка на сообщении/канале»).
/// </summary>
/// <remarks>
/// <para>
/// <b>Candidates</b>: собирает реальные сообщения-кандидаты по каналу (dialogId) либо по всей выборке, если
/// канал не задан, из трёх существующих источников тенанта — очереди обработки (<see cref="IPipelineStore.ListAsync"/>),
/// отсева (<see cref="IPipelineStore.ListPageAsync"/>) и карточек (<see cref="ICardStore.ListCardsAsync"/>),
/// объединяя по (dialogId, msgId): карточка «перекрывает» отсев, отсев — очередь. Каждый кандидат несёт
/// исходный текст и текущий вердикт; мнение ML добавляется прогнозом <see cref="IMlClient.PredictAsync"/>.
/// </para>
/// <para>
/// <b>Apply</b>: ручное решение пользователя — «спам», «в колонку», «пропустить» — применяется через
/// существующие сервисы/ядро: обучение ML — <see cref="IMlClient.PushAsync"/>, перенос/корзина карточки —
/// <see cref="CardsService"/> (он сам учит ML, дублирования сигналов нет), отсев сообщения из очереди —
/// <see cref="PipelineProcessingService.RejectAsync"/>. Действие 1:1 с прототипом ml_routes.py L137–171
/// (skip/spam/board:&lt;id&gt;), плюс отсев ещё не обработанного сообщения и защита от неизвестной доски.
/// </para>
/// </remarks>
public sealed class MlReviewService(
IPipelineStore pipelineStore,
ICardStore cardStore,
CardsService cards,
PipelineProcessingService processing,
IMlClient mlClient)
{
/// <summary>
/// Минимум сообщений в выборке кандидатов (кламп запроса 1..60, как прототип).
/// </summary>
public const int MinCandidates = 1;
/// <summary>
/// Максимум сообщений в выборке кандидатов (кламп запроса 1..60, как прототип).
/// </summary>
public const int MaxCandidates = 60;
// Размер одного чтения из очереди/отсева при объединении кандидатов.
private const int MaxScan = 500;
// Длина текста кандидата в ответе (ml_routes.py L129: text[:600]).
private const int TextPreviewLength = 600;
/// <summary>
/// Вердикт кандидата: по сообщению уже есть карточка.
/// </summary>
public const string VerdictCard = "card";
/// <summary>
/// Вердикт кандидата: сообщение в отсеве.
/// </summary>
public const string VerdictRejected = "rejected";
/// <summary>
/// Вердикт кандидата: сообщение ждёт обработки в очереди.
/// </summary>
public const string VerdictQueued = "queued";
/// <summary>
/// Действие: пропустить без обучения (ml_routes.py L144–145).
/// </summary>
public const string ActionSkip = "skip";
/// <summary>
/// Действие: спам — учим ML и (если есть) карточку в корзину (ml_routes.py L150–156).
/// </summary>
public const string ActionSpam = "spam";
/// <summary>
/// Префикс действия «в колонку»: <c>board:&lt;id&gt;</c> (ml_routes.py L157).
/// </summary>
public const string ActionBoardPrefix = "board:";
/// <summary>
/// 400 apply: неизвестная доска-цель (ml_routes.py L159–160).
/// </summary>
public const string UnknownBoardDetail = "Неизвестная доска";
/// <summary>
/// 400 apply: неизвестное действие (ml_routes.py L169–170).
/// </summary>
public const string UnknownActionDetail = "Неизвестное действие";
// Причина отсева при ручной разметке «спам» ещё не обработанного сообщения.
private const string ManualSpamReason = "ручная разметка ML: спам";
// Этап отсева при ручной разметке «спам» (отсев решением ML).
private const string ManualSpamStage = "spam_ml";
// Источник решения при ручной разметке.
private const string ManualSource = "ml";
// Вес обучающего сигнала ручной разметки — действие пользователя (ml_client.py USER_WEIGHT 1.0).
private const double UserPushWeight = 1.0;
/// <summary>
/// Отбирает сообщения-кандидаты для проверки ML по каналу и/или размеру выборки.
/// </summary>
/// <param name="dialogId">Id канала/диалога; пусто — выборка по всем источникам тенанта.</param>
/// <param name="limit">Сколько последних сообщений вернуть (кламп 1..<see cref="MaxCandidates"/>, дефолт вызывающего).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Кандидаты (свежие первыми): текст, текущий вердикт и мнение ML по каждому.</returns>
public async Task<IReadOnlyList<MlCandidateDto>> CandidatesAsync(string? dialogId, int limit, CancellationToken ct)
{
int take = Math.Clamp(limit, MinCandidates, MaxCandidates);
string dialog = (dialogId ?? string.Empty).Trim();
IReadOnlyList<QueueItemDto> queue = await pipelineStore.ListAsync(status: null, MaxScan, ct);
IReadOnlyList<RejectedItemDto> rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct);
IReadOnlyList<CardDto> cardList = await cardStore.ListCardsAsync(new CardsQuery(null), ct);
// Объединение по (dialogId, msgId): очередь → отсев → карточка (последняя перекрывает предыдущие).
var merged = new Dictionary<(string Dialog, long MsgId), MlCandidateDto>();
foreach (QueueItemDto row in queue)
{
if (row.MsgId is not { } msgId || !MatchesDialog(dialog, row.DialogId))
{
continue;
}
merged[(row.DialogId, msgId)] = BuildQueued(row, msgId);
}
foreach (RejectedItemDto row in rejected)
{
if (row.MsgId is not { } msgId || !MatchesDialog(dialog, row.DialogId))
{
continue;
}
merged[(row.DialogId, msgId)] = BuildRejected(row, msgId);
}
foreach (CardDto card in cardList)
{
if (card.SourceMsgId is not { } msgId || !MatchesDialog(dialog, card.SourceDialogId))
{
continue;
}
merged[(card.SourceDialogId, msgId)] = BuildCard(card, msgId);
}
List<MlCandidateDto> ordered = merged.Values
.OrderByDescending(candidate => candidate.Time ?? 0)
.Take(take)
.ToList();
var withPredictions = new List<MlCandidateDto>(ordered.Count);
foreach (MlCandidateDto candidate in ordered)
{
withPredictions.Add(candidate with { Pred = await PredictSafelyAsync(candidate.Text, ct) });
}
return withPredictions;
}
/// <summary>
/// Применяет ручное решение по сообщению: обучение ML + перенос/корзина/отсев.
/// </summary>
/// <param name="dialogId">Id канала/диалога сообщения.</param>
/// <param name="msgId">Id исходного сообщения.</param>
/// <param name="action">Действие: <c>skip</c> | <c>spam</c> | <c>board:&lt;id&gt;</c>.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результат решения; null — исходное сообщение не найдено (404-семантика эндпоинта).</returns>
public async Task<MlApplyResult?> ApplyAsync(string dialogId, long msgId, string? action, CancellationToken ct)
{
string normalized = (action ?? string.Empty).Trim();
string dialog = (dialogId ?? string.Empty).Trim();
CardDto? card = await cardStore.GetCardBySourceAsync(dialog, msgId, ct);
string? text = await FindTextAsync(dialog, msgId, card, ct);
if (string.IsNullOrWhiteSpace(text))
{
return null; // 404: исходное сообщение не найдено
}
if (normalized == ActionSkip)
{
return new MlApplyResult(Error: null, Ok: true, Learned: false, Moved: null, LeadId: null);
}
if (normalized == ActionSpam)
{
return await ApplySpamAsync(dialog, msgId, card, text, ct);
}
if (normalized.StartsWith(ActionBoardPrefix, StringComparison.Ordinal))
{
string boardId = normalized[ActionBoardPrefix.Length..].Trim();
return await ApplyBoardAsync(dialog, msgId, boardId, card, text, ct);
}
return new MlApplyResult(UnknownActionDetail, Ok: false, Learned: false, Moved: null, LeadId: null);
}
// Действие «спам»: карточку — в корзину (с обучением), сообщение из очереди — в отсев; иначе учим ML.
// dialog: Id диалога.
// msgId: Id сообщения.
// card: Карточка сообщения (null — сообщение не становилось карточкой).
// text: Текст сообщения.
// ct: Токен отмены.
// Возвращает: Результат решения.
private async Task<MlApplyResult> ApplySpamAsync(string dialog, long msgId, CardDto? card, string text, CancellationToken ct)
{
if (card is not null)
{
// TrashCardAsync(teach:true) сам шлёт обучающий сигнал «спам» — второй сигнал не нужен.
CardDto? trashed = await cards.TrashCardAsync(card.Id, teach: true, ct);
return new MlApplyResult(null, Ok: true, Learned: true, Moved: "trash", LeadId: trashed?.Id ?? card.Id);
}
await mlClient.PushAsync(text, MlLearningLabels.Spam, UserPushWeight, ct);
// Сообщение ещё в очереди — отсеиваем его (решение пользователя), снимая строку.
QueueItemDto? queued = await FindQueuedAsync(dialog, msgId, ct);
if (queued is not null)
{
await processing.RejectAsync(new RejectRecord
{
DialogId = queued.DialogId,
MsgId = queued.MsgId,
Text = queued.Text,
ChannelName = queued.Channel.Name,
ChannelHandle = queued.Channel.Handle,
ChannelHue = queued.Channel.Hue,
MsgAtMs = queued.MsgAtMs,
Source = ManualSource,
Stage = ManualSpamStage,
Reason = ManualSpamReason,
Kw = string.Empty,
}, ct);
await pipelineStore.RemoveAsync(queued.Id, ct);
}
return new MlApplyResult(null, Ok: true, Learned: true, Moved: null, LeadId: null);
}
// Действие «в колонку»: карточку — переносим, уже в колонке — только учим; иначе учим ML.
// dialog: Id диалога.
// msgId: Id сообщения.
// boardId: Id колонки-цели (inbox или b_...).
// card: Карточка сообщения (null — сообщение не становилось карточкой).
// text: Текст сообщения.
// ct: Токен отмены.
// Возвращает: Результат решения.
private async Task<MlApplyResult> ApplyBoardAsync(
string dialog,
long msgId,
string boardId,
CardDto? card,
string text,
CancellationToken ct)
{
if (boardId != CardIds.Inbox && await cardStore.GetContainerAsync(boardId, ct) is null)
{
return new MlApplyResult(UnknownBoardDetail, Ok: false, Learned: false, Moved: null, LeadId: null);
}
if (card is null)
{
await mlClient.PushAsync(text, boardId, UserPushWeight, ct);
return new MlApplyResult(null, Ok: true, Learned: true, Moved: null, LeadId: null);
}
if (card.Col == boardId)
{
// Повторная разметка карточки в той же колонке — только обучение (ml_routes.py L163–165).
await mlClient.PushAsync(text, boardId, UserPushWeight, ct);
return new MlApplyResult(null, Ok: true, Learned: true, Moved: null, LeadId: card.Id);
}
// MoveDashboardCardAsync сам учит колонку (toCol ≠ inbox) — второй сигнал не нужен.
CardResultDto moved = await cards.MoveDashboardCardAsync(card.Id, boardId, ct);
if (moved.Error is not null)
{
return new MlApplyResult(moved.Error, Ok: false, Learned: false, Moved: null, LeadId: card.Id);
}
return new MlApplyResult(null, Ok: true, Learned: true, Moved: boardId, LeadId: card.Id);
}
// Текст исходного сообщения: source_msg карточки, иначе текст строки очереди/записи отсева.
// dialog: Id диалога.
// msgId: Id сообщения.
// card: Карточка сообщения (уже прочитана вызывающим).
// ct: Токен отмены.
// Возвращает: Непустой текст либо null, если сообщения нет ни в одном источнике.
private async Task<string?> FindTextAsync(string dialog, long msgId, CardDto? card, CancellationToken ct)
{
if (card is not null && !string.IsNullOrWhiteSpace(card.SourceMsg))
{
return card.SourceMsg;
}
QueueItemDto? queued = await FindQueuedAsync(dialog, msgId, ct);
if (queued is not null && !string.IsNullOrWhiteSpace(queued.Text))
{
return queued.Text;
}
IReadOnlyList<RejectedItemDto> rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct);
foreach (RejectedItemDto row in rejected)
{
if (row.MsgId == msgId && string.Equals(row.DialogId, dialog, StringComparison.Ordinal))
{
return row.Text;
}
}
return null;
}
// Строка очереди сообщения (для отсева при ручной разметке «спам»).
// dialog: Id диалога.
// msgId: Id сообщения.
// ct: Токен отмены.
// Возвращает: Строка очереди либо null — сообщение уже обработано/не в очереди.
private async Task<QueueItemDto?> FindQueuedAsync(string dialog, long msgId, CancellationToken ct)
{
IReadOnlyList<QueueItemDto> queue = await pipelineStore.ListAsync(status: null, MaxScan, ct);
foreach (QueueItemDto row in queue)
{
if (row.MsgId == msgId && string.Equals(row.DialogId, dialog, StringComparison.Ordinal))
{
return row;
}
}
return null;
}
// Прогноз ML по тексту с защитой от сбоя (недоступный сервис — кандидат без мнения).
// text: Текст сообщения.
// ct: Токен отмены.
// Возвращает: Мнение ML либо null при сбое.
private async Task<MlCandidatePredictionDto?> PredictSafelyAsync(string text, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
try
{
MlPredictResultDto result = await mlClient.PredictAsync(text, ct);
return new MlCandidatePredictionDto(result.Take, result.Label, result.Scores);
}
catch (Exception)
{
return null;
}
}
// Соответствует ли диалог фильтру канала (пустой фильтр — все диалоги).
// filter: Запрошенный канал (пусто — без фильтра).
// dialogId: Диалог сообщения.
// Возвращает: True — кандидат подходит.
private static bool MatchesDialog(string filter, string dialogId) =>
filter.Length == 0 || string.Equals(filter, dialogId, StringComparison.Ordinal);
// Кандидат из строки очереди (вердикт queued).
// row: Строка очереди.
// msgId: Id сообщения.
// Возвращает: Кандидат.
private static MlCandidateDto BuildQueued(QueueItemDto row, long msgId) => new()
{
Id = msgId,
DialogId = row.DialogId,
Text = Truncate(row.Text),
Time = row.MsgAtMs == 0 ? null : row.MsgAtMs,
Lead = false,
Verdict = VerdictQueued,
Stage = row.Status,
};
// Кандидат из записи отсева (вердикт rejected).
// row: Запись отсева.
// msgId: Id сообщения.
// Возвращает: Кандидат.
private static MlCandidateDto BuildRejected(RejectedItemDto row, long msgId) => new()
{
Id = msgId,
DialogId = row.DialogId,
Text = Truncate(row.Text),
Time = row.MsgAtMs == 0 ? null : row.MsgAtMs,
Lead = false,
Verdict = VerdictRejected,
Stage = row.Stage,
Reason = row.Reason,
};
// Кандидат из карточки (вердикт card).
// card: Карточка.
// msgId: Id сообщения.
// Возвращает: Кандидат.
private static MlCandidateDto BuildCard(CardDto card, long msgId) => new()
{
Id = msgId,
DialogId = card.SourceDialogId,
Text = Truncate(card.SourceMsg),
Time = card.ReceivedAtMs == 0 ? null : card.ReceivedAtMs,
Lead = true,
Verdict = VerdictCard,
Col = card.Col,
};
// Обрезает текст кандидата до TextPreviewLength символов.
// text: Исходный текст.
// Возвращает: Обрезанный текст.
private static string Truncate(string text) =>
text.Length <= TextPreviewLength ? text : text[..TextPreviewLength];
}
@@ -0,0 +1,16 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Снимок глобальных исключений тенанта (стоп-уровень до ML/ИИ, §5.14/§8).
/// </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>
/// Сработавшее глобальное исключение: какое правило, причина и конкретный терм (§5.14).
/// </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,32 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Результат локального разбора сообщения без ИИ — структура карточки (pipeline.py _local_fields L718–798).
/// </summary>
/// <remarks>
/// Заголовок/суть/стек/грейд/бюджет/контакты/признак найма извлекаются детерминированно: по меткам
/// «Стек:/Грейд:/Контакты:/Бюджет:» (<c>_field_of</c> L686–698) с fallback-поиском по тексту. Семантика полей
/// 1:1 с прототипом: <see cref="Budget"/> — первая распознанная сумма (формы хранения, валюта-код);
/// <see cref="Contacts"/> — сырые кандидаты контактов, склеенные «; » и ограниченные 200 символами (python L782:
/// финальную квалификацию делает <c>build_contacts</c> при сборке карточки). Тип заявки — маркерная гипотеза
/// (<see cref="IsVacancy"/> по hireMarkers); признак «тип подтверждён» для локального пути всегда false,
/// колонка — inbox (python L796–797: is_vacancy_known=False, board=None — смысловые колонки до ИИ не назначаем).
/// </remarks>
/// <param name="Title">Заголовок карточки — первая содержательная строка, очищенная (≤140).</param>
/// <param name="Summary">Суть «О задаче» — содержательные строки после заголовка без меток-полей (python
/// <c>_local_summary</c> L294–314; ≤600).</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>
/// Результат ручного решения по сообщению (тело ответа POST /api/ml/apply, §8).
/// </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,64 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Сообщение-кандидат для ручной проверки ML (элемент <c>items</c> ответа POST /api/ml/candidates, §8).
/// </summary>
/// <remarks>
/// <c>id</c> — id исходного сообщения (<c>msgId</c>), его же принимает POST /api/ml/apply. <see cref="Verdict"/>
/// описывает текущее состояние сообщения: <c>card</c> (уже карточка), <c>rejected</c> (в отсеве) либо
/// <c>queued</c> (ещё в очереди обработки). Для карточки заполнены <see cref="Col"/> (колонка), для отсева —
/// <see cref="Stage"/>/<see cref="Reason"/>. Наружу сериализуется camelCase: id/dialogId/text/time/lead/
/// verdict/col/stage/reason/pred.
/// </remarks>
public sealed record MlCandidateDto
{
/// <summary>
/// Id исходного сообщения в Telegram (для POST /api/ml/apply).
/// </summary>
public long Id { get; init; }
/// <summary>
/// Id диалога-источника сообщения.
/// </summary>
public string DialogId { get; init; } = string.Empty;
/// <summary>
/// Текст сообщения (обрезан до 600 символов, как прототип).
/// </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>
/// Текущий вердикт: <c>card</c> | <c>rejected</c> | <c>queued</c>.
/// </summary>
public string Verdict { get; init; } = string.Empty;
/// <summary>
/// Колонка карточки (для verdict=card).
/// </summary>
public string? Col { get; init; }
/// <summary>
/// Этап отсева (для verdict=rejected) либо статус очереди (для verdict=queued).
/// </summary>
public string? Stage { get; init; }
/// <summary>
/// Причина отсева (для verdict=rejected).
/// </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 по сообщению-кандидату (поле <c>pred</c> элемента POST /api/ml/candidates, §8).
/// </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,28 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Структурированное содержимое блока «О заявке» карточки (поля классификации cardPrompt L116–123;
/// python raw-словарь compose_summary L225–284).
/// </summary>
/// <remarks>
/// Карточка всегда собирается из одних и тех же блоков: Компания → Формат → О задаче → Требования →
/// Будет плюсом → Условия (Ruling 4, 1:1 с cardPrompt и compose_summary). Заполняет структурированный разбор
/// (ИИ-классификатор этапа 6); локальный разбор (без ИИ) заполняет только <see cref="Summary"/> — тогда
/// композиция идёт «суть как есть» либо путём «О задаче: …» из текста. Пустые/отсутствующие поля блок не дают.
/// </remarks>
/// <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): возвращается как есть, если не похожа на
/// служебный футер (python L266–268); иначе блок «О задаче: …» из текста.</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,14 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Канал-источник сообщения пайплайна — объект <c>ch</c> строк очереди/отсева (§4.5 L308/L310).
/// </summary>
/// <remarks>
/// Имя/хендл/цвет диалога, из которого пришло входящее сообщение. Общий для очереди и отсева (в прототипе —
/// один inline-объект ch в list_queue L218–241 / list_rejected L246–312); в JSON выходит под ключом
/// <c>ch</c>: ch.name/ch.handle/ch.hue.
/// </remarks>
/// <param name="Name">Имя канала/диалога.</param>
/// <param name="Handle">Handle канала/диалога (t.me/&lt;handle&gt;).</param>
/// <param name="Hue">Цвет канала (hex; дефолт <c>#666</c>, Ruling 1).</param>
public sealed record PipelineChannelDto(string Name, string Handle, string Hue);
@@ -0,0 +1,14 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Результат приёма сообщения — ответ PipelineIngestService.EnqueueAsync (Ruling 2, pipeline.enqueue L53–85).
/// </summary>
/// <remarks>
/// <see cref="Id"/> отсутствует (null), когда сообщение НЕ поставлено в очередь: no-op (пустой текст/нет
/// dialogId) либо дубль dialogId+msgId уже в очереди. <see cref="Duplicate"/> отличает дубль-гвард Telethon
/// (Ruling 2, enqueue L74–80) от остальных no-op. Прототип enqueue возвращает None — результат нужен
/// демо-ingest (Task 9): ответ {ok:true, id, queue:{new,ai,total}}.
/// </remarks>
/// <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,65 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Результат одного прохода воркера pump — поле <c>pipeline</c> ответа POST /api/admin/tick (Ruling 8, прототип _pump_unlocked L922–924).
/// </summary>
/// <remarks>
/// Счётчики 1:1 со словарём прототипа (staged/rulesStored/mlStored/mlDrop/typeDrop/aiStored/aiDrop/aiFail/
/// noBudget); wire-ключи admin/tick — те же (camelCase). <see cref="CreatedCards"/> — карточки, созданные за
/// проход (для SSE new_card из Api, Ruling 8/9). Счётчики решений для KV (mlDecisions/aiDecisions) воркер
/// инкрементирует сам как mlStored+mlDrop и aiStored+aiDrop (Ruling 5) — отдельных полей не нужно.
/// </remarks>
public sealed record PipelinePumpResult
{
/// <summary>
/// Сообщений прошли «new»-проход (правила/дедуп/ML) и переведены в статус filtered.
/// </summary>
public int Staged { get; init; }
/// <summary>
/// Отсевов по правилам этапа 1 (length|stop|resume|type; source=stop).
/// </summary>
public int RulesStored { get; init; }
/// <summary>
/// Карточек создано ML-веткой (решения ML: колонка/тип; на этапе 4 ML «спит» — 0).
/// </summary>
public int MlStored { get; init; }
/// <summary>
/// Отсевов решением ML (spam_ml/type).
/// </summary>
public int MlDrop { get; init; }
/// <summary>
/// Отсевов «тип не под режим» по решению ML (stage=type, source=ml).
/// </summary>
public int TypeDrop { get; init; }
/// <summary>
/// Карточек создано ИИ-веткой (классификация/локальный разбор).
/// </summary>
public int AiStored { get; init; }
/// <summary>
/// Отсевов решением ИИ (spam_ai/filter_ai).
/// </summary>
public int AiDrop { get; init; }
/// <summary>
/// Сообщений, где ИИ не дал разбора — собран локальный разбор (aiFail).
/// </summary>
public int AiFail { get; init; }
/// <summary>
/// Отсевов глобальным фильтром «без суммы» (stage=budget, source=stop).
/// </summary>
public int NoBudget { get; init; }
/// <summary>
/// Карточки, созданные за проход (порядок создания; полный CardDto для SSE new_card).
/// </summary>
public IReadOnlyList<CardDto> CreatedCards { get; init; } = Array.Empty<CardDto>();
}
@@ -0,0 +1,13 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Сводка вкладки «Обработка» — тело GET /api/pipeline/stats (api-map §3.6 L179, processing.stats L315–320).
/// </summary>
/// <remarks>
/// Ответ 1:1 с прототипом: <c>{queue: {new, ai, total}, rejected: int}</c>. <see cref="Queue"/> — счётчики
/// очереди (new/ai/total), <see cref="Rejected"/> — число записей в отсеве. Фронт держит вкладку на поллинге
/// (reloadAll), SSE pipeline_stats не публикуется (Ruling 9).
/// </remarks>
/// <param name="Queue">Счётчики очереди по статусам (ключ «queue» ответа).</param>
/// <param name="Rejected">Число записей в отсеве (ключ «rejected»).</param>
public sealed record PipelineStatsDto(QueueCountsDto Queue, int Rejected);
@@ -0,0 +1,26 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Счётчики очереди — <c>counts</c> ответа GET /api/pipeline/queue и <c>queue</c> ответа GET /api/pipeline/stats (§3.6 L178–181, processing.queue_counts L207–215).
/// </summary>
/// <remarks>
/// new — строки со статусом <c>new</c>; ai — строки со статусом <c>filtered</c> (имя bucket'а прототипа —
/// очередь «на ИИ»); total = new + ai. Wire-ключи: new/ai/total (camelCase).
/// </remarks>
public sealed record QueueCountsDto
{
/// <summary>
/// Строк со статусом new (ждут правил/дедупа/ML).
/// </summary>
public int New { get; init; }
/// <summary>
/// Строк со статусом filtered (прошли «new»-проход, ждут ИИ).
/// </summary>
public int Ai { get; init; }
/// <summary>
/// Всего строк в очереди (new + ai).
/// </summary>
public int Total { get; init; }
}
@@ -0,0 +1,65 @@
using System.Text.Json.Serialization;
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Элемент очереди входящих — item ответа GET /api/pipeline/queue (§4.5 L308, processing.list_queue L218–241).
/// </summary>
/// <remarks>
/// Поля 1:1 с §4.5 (фронт читает: id/text/status/ch/msgAt; dialogId/msgId/queuedAt — справочные). Status —
/// <c>new</c> (ждёт правил/дедупа/ML) | <c>filtered</c> (прошла «new»-проход, ждёт ИИ; в счётчиках — bucket
/// «ai»). Времена наружу epoch-ms; маппинг с DateTimeOffset-строкой (Ruling 1) выполняет адаптер хранилища.
/// <see cref="Force"/> — внутренний флаг строки «возвращено из отсева» (Ruling 2/8), в JSON не выходит
/// ([JsonIgnore]): прототипная запись pipeline_msg несёт force, но wire очередь её не отдаёт.
/// </remarks>
public sealed record QueueItemDto
{
/// <summary>
/// Короткий id строки очереди (префикс <c>p_</c>, Ruling 10).
/// </summary>
public string Id { get; init; } = string.Empty;
/// <summary>
/// Id диалога-источника сообщения.
/// </summary>
public string DialogId { get; init; } = string.Empty;
/// <summary>
/// Id исходного сообщения в Telegram (дубль-гвард приёма), либо null.
/// </summary>
public long? MsgId { get; init; }
/// <summary>
/// Текст сообщения (обрезан при приёме до 6000 символов, Ruling 2).
/// </summary>
public string Text { get; init; } = string.Empty;
/// <summary>
/// Статус строки: <c>new</c> | <c>filtered</c> (значения — как pipeline.py ST_NEW/ST_AI).
/// </summary>
public string Status { get; init; } = string.Empty;
/// <summary>
/// Канал-источник (выходит под ключом <c>ch</c>).
/// </summary>
[property: JsonPropertyName("ch")]
public PipelineChannelDto Channel { get; init; } = new(string.Empty, string.Empty, string.Empty);
/// <summary>
/// Время исходного сообщения, epoch-ms (выходит под ключом <c>msgAt</c>).
/// </summary>
[property: JsonPropertyName("msgAt")]
public long MsgAtMs { get; init; }
/// <summary>
/// Время постановки в очередь (= CreatedAt строки), epoch-ms (выходит под ключом <c>queuedAt</c>).
/// </summary>
[property: JsonPropertyName("queuedAt")]
public long QueuedAtMs { get; init; }
/// <summary>
/// Внутренний флаг «возвращено пользователем из отсева»: правила/устарело/ML для строки игнорируются (Ruling 2/8).
/// </summary>
[JsonIgnore]
public bool Force { get; init; }
}
@@ -0,0 +1,54 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Команда приёма входящего сообщения — аргумент PipelineIngestService.EnqueueAsync (Ruling 2, прототип pipeline.enqueue L53–85).
/// </summary>
/// <remarks>
/// Канальные поля плоские (как сигнатура enqueue: dialog_id/ch_name/ch_handle/ch_hue/msg_id/text/msg_at/force):
/// сервис приёма кладёт их в строку QueueItems (адаптер пишет колонки ChannelName/ChannelHandle/ChannelHue).
/// Пустой текст или нет dialogId → no-op (Ruling 2); текст обрезается до 6000; дубль-гвард dialog+msgId делает
/// сервис приёма до записи. <see cref="MsgAtMs"/> может отсутствовать (демо-ingest не шлёт) — приём подставит now.
/// <see cref="Force"/> — возврат из отсева (Ruling 2/10): для строки игнорируются правила/устарело/ML-решения.
/// </remarks>
public sealed record QueuedMessage
{
/// <summary>
/// Id диалога-источника сообщения (обязателен, иначе no-op).
/// </summary>
public string DialogId { get; init; } = string.Empty;
/// <summary>
/// Имя канала/диалога-источника.
/// </summary>
public string ChannelName { get; init; } = string.Empty;
/// <summary>
/// Handle канала/диалога-источника.
/// </summary>
public string ChannelHandle { get; init; } = string.Empty;
/// <summary>
/// Цвет канала-источника (hex; null — дефолт #666).
/// </summary>
public string ChannelHue { get; init; } = string.Empty;
/// <summary>
/// Id исходного сообщения в Telegram (дубль-гвард), либо null.
/// </summary>
public long? MsgId { get; init; }
/// <summary>
/// Текст сообщения (обрезается при приёме до 6000).
/// </summary>
public string Text { get; init; } = string.Empty;
/// <summary>
/// Время исходного сообщения, epoch-ms; null — приём подставит now.
/// </summary>
public long? MsgAtMs { get; init; }
/// <summary>
/// Флаг «возвращено из отсева»: правила/устарело/ML для сообщения игнорируются (Ruling 2).
/// </summary>
public bool Force { get; init; }
}
@@ -0,0 +1,39 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Итог ручной переклассификации «Неразобранного»/одной карточки (POST /api/cards/reclassify|{id}/reclassify).
/// </summary>
/// <remarks>
/// Расширяет совместимый контракт-заглушку этапа 3 (started/busy/attempted) счётчиками исхода:
/// <list type="bullet">
/// <item><see cref="Started"/> — переклассификация выполнена (target непуст); false — нечего или занято;</item>
/// <item><see cref="Busy"/> — другой проход уже выполняется (single-flight, как фоновая задача прототипа);</item>
/// <item><see cref="Attempted"/> — сколько карточек отобрано (batch — inbox, либо ids∩inbox);</item>
/// <item><see cref="Reclassified"/> — успешно обработано (moved + kept + trashed);</item>
/// <item><see cref="Moved"/> — ушло в доску, <see cref="Kept"/> — осталось в inbox, <see cref="Trashed"/> — спам/не прошло фильтр;</item>
/// <item><see cref="Skipped"/> — пропущено без обработки (нет исходного текста);</item>
/// <item><see cref="UsedAi"/> — хотя бы одна карточка разобрана через порт ИИ (иначе — локальный детерминированный разбор);</item>
/// <item><see cref="Reason"/> — понятная причина, когда проход не выполнен/нечего обрабатывать; иначе null.</item>
/// </list>
/// </remarks>
/// <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,76 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Команда записи отсева — аргумент IPipelineStore.UpsertAsync (processing.record L66–101).
/// </summary>
/// <remarks>
/// Содержит всё, что нужно строке RejectedItems: текст/канал/время исходного сообщения + решение
/// (source/stage/reason/kw). Id строки детерминирован при наличии dialog+msgId (Ruling 1: <c>r_&lt;dialog&gt;_&lt;msgId&gt;</c>)
/// — см. <see cref="DeterministicId"/>; иначе хранилище генерирует случайный <c>r_</c>+hex (как processing.record L77).
/// Повторное отбрасывание того же сообщения (dialog+msgId) обновляет запись, а не копит дубликаты (upsert
/// ON CONFLICT, Task 3). Пустой текст — no-op в хранилище. Ограничения длин (текст ≤6000, reason ≤500,
/// kw ≤200) соблюдает слой сервиса (Ruling 1/10).
/// </remarks>
public sealed record RejectRecord
{
/// <summary>
/// Id диалога-источника сообщения (пусто — детерминированного id нет).
/// </summary>
public string DialogId { get; init; } = string.Empty;
/// <summary>
/// Id исходного сообщения в Telegram, либо null.
/// </summary>
public long? MsgId { get; init; }
/// <summary>
/// Текст отсеянного сообщения (пустой — запись не создаётся).
/// </summary>
public string Text { get; init; } = string.Empty;
/// <summary>
/// Имя канала/диалога-источника.
/// </summary>
public string ChannelName { get; init; } = string.Empty;
/// <summary>
/// Handle канала/диалога-источника.
/// </summary>
public string ChannelHandle { get; init; } = string.Empty;
/// <summary>
/// Цвет канала-источника (hex; дефолт #666).
/// </summary>
public string ChannelHue { get; init; } = string.Empty;
/// <summary>
/// Время исходного сообщения, epoch-ms.
/// </summary>
public long MsgAtMs { get; init; }
/// <summary>
/// Источник решения: stop|ml|ai|stale|dup (Ruling 1; «система» = stale|dup).
/// </summary>
public string Source { get; init; } = string.Empty;
/// <summary>
/// Этап отсева: length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup (api-map §4.5).
/// </summary>
public string Stage { get; init; } = string.Empty;
/// <summary>
/// Причина отсева (текст 1:1 с прототипом).
/// </summary>
public string Reason { get; init; } = string.Empty;
/// <summary>
/// Конкретное слово/фраза стоп-списка, сработавшая правилом (пусто — не правило).
/// </summary>
public string Kw { get; init; } = string.Empty;
/// <summary>
/// Детерминированный id записи: <c>r_&lt;dialog&gt;_&lt;msgId&gt;</c> при наличии dialog+msgId, иначе null (хранилище берёт случайный).
/// </summary>
public string? DeterministicId =>
DialogId.Length > 0 && MsgId is not null ? $"r_{DialogId}_{MsgId}" : null;
}
@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Результат возврата отсеянного сообщения в обработку — POST /api/pipeline/rejected/{id}/return (processing.return_to_queue L128–193).
/// </summary>
/// <remarks>
/// <see cref="Error"/> — текст 400 (уже возвращено / повтор-dup / нет текста), при успехе null. Успех 1:1 с
/// прототипом: <c>{id, returned: true, returnedAt: ms}</c> — id записи отсева (запись НЕ удаляется, помечается
/// returned + причина, аудит Ruling 10). Случай «записи нет» сервис возвращает null — эндпоинт отвечает 404
/// «Запись не найдена» (текст 404 — слой эндпоинтов, паттерн CardsService → CardsEndpoints).
/// </remarks>
/// <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,101 @@
using System.Text.Json.Serialization;
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Элемент отсева — item ответа GET /api/pipeline/rejected (§4.5 L310–313, processing.list_rejected L246–312).
/// </summary>
/// <remarks>
/// Поля 1:1 с §4.5: stage/stageLabel/reason/kw/source/sourceLabel и т.д. stage — этап отсева
/// (<c>length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup</c>), source — «чьё» решение
/// (<c>stop|ml|ai|stale|dup</c>). Подписи stageLabel/sourceLabel считает слой маппинга через
/// <see cref="Deal.Modules.Pipeline.Application.PipelineRejectConstants"/> (prototype stage_label/source_label L40–45). returned/returnedAt/
/// returnReason — аудит возврата из отсева (processing.return_to_queue L128–193; «Возврат» в UI неактивен при
/// source=dup или returned). Времена наружу epoch-ms (адаптер мапит DateTimeOffset-строку, Ruling 1).
/// </remarks>
public sealed record RejectedItemDto
{
/// <summary>
/// Id записи отсева (детерминированный <c>r_&lt;dialog&gt;_&lt;msgId&gt;</c> либо <c>r_</c>+hex, Ruling 1).
/// </summary>
public string Id { get; init; } = string.Empty;
/// <summary>
/// Id диалога-источника сообщения.
/// </summary>
public string DialogId { get; init; } = string.Empty;
/// <summary>
/// Id исходного сообщения в Telegram, либо null.
/// </summary>
public long? MsgId { get; init; }
/// <summary>
/// Текст отсеянного сообщения.
/// </summary>
public string Text { get; init; } = string.Empty;
/// <summary>
/// Этап отсева (см. словарь подписей <see cref="Deal.Modules.Pipeline.Application.PipelineRejectConstants.StageLabels"/>).
/// </summary>
public string Stage { get; init; } = string.Empty;
/// <summary>
/// Человекочитаемая подпись этапа для UI («короткое сообщение», «повтор», …).
/// </summary>
public string StageLabel { get; init; } = string.Empty;
/// <summary>
/// Причина отсева (текст 1:1 с прототипом, ≤500).
/// </summary>
public string Reason { get; init; } = string.Empty;
/// <summary>
/// Конкретное слово/фраза стоп-списка, сработавшая правилом (≤200; пусто — не правило).
/// </summary>
public string Kw { get; init; } = string.Empty;
/// <summary>
/// Источник решения: stop|ml|ai|stale|dup.
/// </summary>
public string Source { get; init; } = string.Empty;
/// <summary>
/// Подпись источника («правила»/«ML»/«ИИ»/«система», см. <see cref="Deal.Modules.Pipeline.Application.PipelineRejectConstants.SourceLabels"/>).
/// </summary>
public string SourceLabel { get; init; } = string.Empty;
/// <summary>
/// Канал-источник (выходит под ключом <c>ch</c>).
/// </summary>
[property: JsonPropertyName("ch")]
public PipelineChannelDto Channel { get; init; } = new(string.Empty, string.Empty, string.Empty);
/// <summary>
/// Время исходного сообщения, epoch-ms (выходит под ключом <c>msgAt</c>).
/// </summary>
[property: JsonPropertyName("msgAt")]
public long MsgAtMs { get; init; }
/// <summary>
/// Время отсева, epoch-ms (выходит под ключом <c>rejectedAt</c>; сортировка списка DESC).
/// </summary>
[property: JsonPropertyName("rejectedAt")]
public long RejectedAtMs { get; init; }
/// <summary>
/// Флаг «возвращено в обработку» (запись остаётся для аудита, не удаляется).
/// </summary>
public bool Returned { get; init; }
/// <summary>
/// Время возврата, epoch-ms (выходит под ключом <c>returnedAt</c>); null — не возвращалась.
/// </summary>
[property: JsonPropertyName("returnedAt")]
public long? ReturnedAtMs { get; init; }
/// <summary>
/// Причина возврата, указанная пользователем (≤500; пусто — не задана).
/// </summary>
public string ReturnReason { get; init; } = string.Empty;
}
@@ -0,0 +1,15 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Страница отсева — тело GET /api/pipeline/rejected (api-map §3.6 L180, processing.list_rejected L246–312).
/// </summary>
/// <remarks>
/// Ответ 1:1 с прототипом: <c>{items, total, offset, limit}</c>. <see cref="Total"/> — число записей по условию:
/// без поиска — весь отсев (rejected_count), с поиском — размер объединения FTS- и LIKE-кандидатов (Ruling 6).
/// offset/limit — эхо запроса после clamp (offset ≥ 0, limit 1..500).
/// </remarks>
/// <param name="Items">Записи страницы (полные item'ы отсева §4.5, порядок RejectedAt DESC).</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,40 @@
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Pipeline.Application.Parse;
/// <summary>
/// Fallback бюджета карточки, когда разбор не выделил бюджет отдельным полем: первая сумма с валютой из
/// исходника или из структурированной «О заявке» (pipeline.py L459–468).
/// </summary>
/// <remarks>
/// ИИ не всегда выделяет бюджет отдельным полем, но сумма с валютой есть в тексте сообщения либо ушла в блок
/// «Условия» структурированной сути — показываем её на карточке. Тот же источник, что и фильтр «не создавать
/// карточку без суммы» (Ruling 4: AmountParser модуля Kanban, извлечение — только суммы с валютой).
/// Порядок источников 1:1 с прототипом: сначала текст сообщения, затем summary; берётся первый распознанный
/// диапазон. Возвращается форма хранения (валюта-код) — дальнейшую нормализацию делает вызывающий
/// (BudgetNormalizer.Normalize, CardComposer).
/// </remarks>
public static class AmountRangeBudgetFallback
{
/// <summary>
/// Ищет бюджет-«заглушку»: первую сумму с валютой среди источников (python L463–468).
/// </summary>
/// <param name="text">Текст исходного сообщения (первый источник).</param>
/// <param name="summary">Структурированная «О заявке» карточки (второй источник, python L463).</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,350 @@
using System.Text.RegularExpressions;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Pipeline.Application.Parse;
/// <summary>
/// Квалификация контактов из разбора/текста сообщения (pipeline.py L344–430, _norm_phone L661–663,
/// _contacts_from L666–679).
/// </summary>
/// <remarks>
/// Тип контакта — tg|phone|email|linkedin|whatsapp|site, формат записи — <see cref="CardContactDto"/> карточки
/// Kanban ({type, value}, Ruling 4). Отбрасываются боты (<c>@…bot</c>), сервисные t.me-ссылки
/// (joinchat/+/s/c/…), «постовые» сайты (teletype, google-формы, youtube и т.п.). <see cref="Build"/> собирает
/// до 6 записей с дедупликацией по значению (casefold); при отсутствии контактов в разборе пытается вытащить
/// кандидатов из текста (<c>_contacts_from</c>: @username, email, телефон). <see cref="Primary"/> — основной
/// контакт карточки для быстрого действия (tg → phone → whatsapp → email → linkedin → site, python L424–430).
/// </remarks>
public static class ContactsQualifier
{
/// <summary>
/// Максимум записей контактов карточки (python build_contacts L419: ≤6, Ruling 4).
/// </summary>
public const int MaxContacts = 6;
// Максимум кандидатов из текста за один вызов (python _contacts_from L679: [:4]).
internal const int MaxTextCandidates = 4;
// Максимум символов сырого контакта (python qualify_contact L358: len &gt; 300 → None).
private const int MaxContactLength = 300;
// Минимальная длина никнейма telegram (python L362/L367: {4,32}).
private const int MinTgNameLength = 4;
// Максимальная длина никнейма telegram (python L362/L367: {4,32}).
private const int MaxTgNameLength = 32;
// Ограничение нормализованного телефона (python _norm_phone L663: [:18]).
internal const int MaxNormalizedPhoneLength = 18;
// Подсказка бота в конце ника: @…bot отбрасывается (python _TG_BOT_HINTS L345).
private const string TgBotSuffix = "bot";
// Сервисные t.me-ссылки: не контакты людей (python _TG_SERVICE_NAMES L346).
private static readonly IReadOnlySet<string> TgServiceNames = new HashSet<string>(StringComparer.Ordinal)
{
"joinchat", "share", "s", "c", "addstickers", "addtheme", "proxy", "bg", "login",
};
// «Постовые» сайты-агрегаторы: не контакты (python _SKIP_SITE_HOSTS L347).
private static readonly IReadOnlySet<string> SkipSiteHosts = new HashSet<string>(StringComparer.Ordinal)
{
"teletype.in", "forms.gle", "docs.google.com", "youtube.com", "youtu.be", "clck.ru",
};
// Ник в telegram: @имя (python L362).
private static readonly Regex TelegramNameRe = new(@"\A[A-Za-z0-9_]{4,32}\z", RegexOptions.CultureInvariant);
// t.me-ссылка на профиль (python L366).
private static readonly Regex TelegramLinkRe = new(
@"\Ahttps?://(?:www\.)?t\.me/([A-Za-z0-9_]{4,32})/?\z",
RegexOptions.CultureInvariant);
// E-mail (python L372).
private static readonly Regex EmailRe = new(
@"\A[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\z",
RegexOptions.CultureInvariant);
// Телефон целиком: цифры/пробелы/дефисы/скобки, опциональный «+» (python L375).
private static readonly Regex PhoneRe = new(@"\A\+?[\d\s\-()]{6,20}\z", RegexOptions.CultureInvariant);
// Ссылка LinkedIn на профиль (python L377).
private static readonly Regex LinkedinRe = new(@"linkedin\.com/in/", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
// Ссылка WhatsApp (python L379).
private static readonly Regex WhatsappRe = new(@"wa\.me|api\.whatsapp\.com", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
// Хост сайта из URL (python L382).
private static readonly Regex SiteHostRe = new(@"https?://(?:www\.)?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
// Никнеймы в тексте: @имя (python _CONTACT_RE L601).
private static readonly Regex AtNameRe = new(@"@[A-Za-z0-9_]{3,}", RegexOptions.CultureInvariant);
// E-mail в тексте (python _EMAIL_RE L602).
private static readonly Regex EmailInTextRe = new(
@"[A-Za-z0-9._%+\-]+@[A-Za-z0-9\-]+(?:\.[A-Za-z0-9\-]+)+",
RegexOptions.CultureInvariant);
// Телефон в тексте (python _PHONE_RE L603).
private static readonly Regex PhoneInTextRe = new(
@"(?:\+7|8|7)[\s\-()]*\d{3}[\s\-()]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}",
RegexOptions.CultureInvariant);
// Приоритеты основного контакта: tg → phone → whatsapp → email → linkedin → site (python L426).
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,
};
// Приоритет неизвестного типа (не встречается — fallback на всякий случай, python L429: 9).
private const int UnknownTypePriority = 9;
/// <summary>
/// Классифицирует один сырой контакт → {type, value} или null (python qualify_contact L350–386).
/// </summary>
/// <param name="raw">Сырое значение контакта («@user», «https://t.me/x», телефон, email, ссылка).</param>
/// <returns>
/// Квалифицированный контакт <see cref="CardContactDto"/> либо null — пусто/мусор/бот/сервисная ссылка/
/// «постовый» сайт (python: None).
/// </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 (TelegramNameRe.IsMatch(name) && !name.EndsWith(TgBotSuffix, StringComparison.OrdinalIgnoreCase))
{
return new CardContactDto("tg", "@" + name);
}
return null;
}
Match link = TelegramLinkRe.Match(s);
if (link.Success)
{
string name = link.Groups[1].Value;
if (!TgServiceNames.Contains(name.ToLowerInvariant())
&& !name.EndsWith(TgBotSuffix, 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>
/// Собирает квалифицированные контакты из разбора/текста (python build_contacts L389–421).
/// </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)
{
// python build_contacts L396–397: строка разбивается по разделителям; пустая строка даёт [''] —
// «кандидаты есть», текст НЕ извлекаем (1:1, L406: if not cands and text).
candidates.AddRange(Regex.Split(contacts, @"[;|\n]+", RegexOptions.CultureInvariant));
if (candidates.Count == 0)
{
candidates.Add(string.Empty);
}
}
return BuildFromCandidates(candidates, text);
}
/// <summary>
/// Собирает квалифицированные контакты из списка значений разбора (python build_contacts L396–405:
/// элементы строки могут нести разделители — разбиваются).
/// </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;
}
// python: элемент списка-строки разбивается разделителями (L398).
candidates.AddRange(Regex.Split(value, @"[;|\n]+", RegexOptions.CultureInvariant));
}
}
return BuildFromCandidates(candidates, text);
}
/// <summary>
/// Основной контакт карточки — значение с наименьшим приоритетом (python primary_contact L424–430).
/// </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>
/// Извлекает кандидатов контактов из текста: @username, e-mail, телефон (python _contacts_from L666–679).
/// </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;
}
// Нормализует телефон: убирает пробелы/неразрывные пробелы/дефисы/скобки (python _norm_phone L661–663).
// phone: Телефон как встретился в тексте.
// Возвращает: Нормализованный телефон (≤MaxNormalizedPhoneLength символов).
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;
}
// Приоритет типа контакта для Primary (python L426–428).
// type: Тип контакта (tg/phone/whatsapp/email/linkedin/site).
// Возвращает: Приоритет (меньше — важнее); неизвестный тип — UnknownTypePriority.
private static int OrderOf(string type)
{
return PrimaryOrder.TryGetValue(type, out int order) ? order : UnknownTypePriority;
}
// Квалифицирует кандидатов и собирает результат: дедуп по casefold-значению, лимит (python L408–420).
// candidates: Сырые кандидаты.
// text: Текст сообщения для извлечения кандидатов, если список пуст.
// Возвращает: Квалифицированные контакты без дублей (≤MaxContacts).
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;
}
// Добавляет в список только отсутствующие значения (python «if m not in out» L669–678).
// result: Список-накопитель.
// values: Найденные совпадения.
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,48 @@
using System.Security.Cryptography;
using System.Text;
namespace Deal.Modules.Pipeline.Application.Parse;
/// <summary>
/// Хэш текста для дедупликации сообщений (ai.py normalize_dedup L261–267; Ruling 7: хэш-ключ
/// DedupEntries без префикса).
/// </summary>
/// <remarks>
/// Нормализация 1:1 с прототипом: текст приводится к нижнему регистру, удаляются все символы, кроме
/// «словесных» (буквы, включая кириллицу, цифры, подчёркивание — python <c>[^\\wа-яё]+</c>; регистр и
/// пунктуация/пробелы на хэш не влияют — «Тест!» ≡ «тест», «Привет мир» ≡ «Привет мир»). Итог — SHA1-hex
/// нормализованной строки в UTF-8 (прототип использует <c>hashlib.sha1</c>; Task 2/3: без префикса, колонка
/// Hash). Ссылки/служебный текст НЕ вырезаются отдельно — их буквы входят в нормализованную строку (1:1).
/// </remarks>
public static class DedupHasher
{
/// <summary>
/// Хэширует текст сообщения для проверки «сообщение уже в системе» (python normalize_dedup L261–267).
/// </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();
}
// Нормализация текста для дедупа: только буквы/цифры/подчёркивание (python [^\wа-яё]+,
// L266) затем lowercase — по кодовым точкам, как python-casefold (суррогатные пары целиком).
// text: Исходный текст.
// Возвращает: Нормализованная строка без регистра, пробелов и пунктуации.
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,394 @@
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;
namespace Deal.Modules.Pipeline.Application.Parse;
/// <summary>
/// Локальный структуратор сообщения без ИИ (pipeline.py _local_fields L718–798, _field_of L686–698,
/// метки L591–596, маркеры/токены L597–612): заголовок, суть, стек, грейд, бюджет, контакты, признак вакансии.
/// </summary>
/// <remarks>
/// Карточка при локальном пути (aiEnabled=false / сбой ИИ) не должна выглядеть как сырое сообщение: по меткам
/// «Стек:/Грейд:/Контакты:/Бюджет:» (синонимы <see cref="FieldLabels"/>) с fallback-извлечениями по тексту
/// заполняются поля. Маркеры найма (<c>hireMarkers</c>) и термины грейдов (<c>levelTerms</c>) — настройки
/// тенанта: <see cref="ParseAsync"/> читает дефолты <see cref="SettingsDefaults"/>, перекрытые сохранёнными
/// (нормализация trim+lowercase как в IncomingRules); чистое ядро — статический <see cref="Parse(string?,IReadOnlyCollection{string}?,IReadOnlyCollection{string}?)"/>.
/// is_vacancy — маркерная гипотеза: <c>is_vacancy_known=false</c>, колонка не назначается (board=null,
/// python L796–797). Резюме-маркеры здесь не нужны — отсев резюме выполняет этап-1 фильтр (IncomingRules).
/// </remarks>
public sealed class LocalFieldsParser(ISettingsStore store)
{
// Лимит заголовка (python L777: clean_short(…, 140)).
private const int TitleLimit = 140;
// Лимит сырых контактов карточки (python L782: контакты[:200]).
private const int ContactsLimit = 200;
// Максимум грейдов в результате (python L792: grade[:4]).
internal const int MaxGrades = 4;
// Максимум токенов, выбираемых из значения метки «Стек:» (python _pick_stack L713: 10).
private const int MaxPickTokens = 10;
// Максимум поля стека в результате (python L791: stack[:12], Ruling 4).
private const int MaxStackResult = 12;
// Строка метки: «Метка: значение» / «Метка| значение» (python _LABEL_RE L599).
private static readonly Regex LabelRe = new(
@"^[\s*>#_~]*([А-Яа-яЁёA-Za-z][А-Яа-яЁёA-Za-z0-9 /+\-]{1,36}?)\s*[:|]\s*(.+)$",
RegexOptions.CultureInvariant);
// Токены значения: слова/названия с цифрами, «#», «.» внутри (python _TOKEN_RE L600).
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);
// Fallback стека: метка «Стек: …» не в начале строки (однострочные объявления, python L755).
private static readonly Regex InlineStackRe = new(
@"\b(?:стек|технологии|скиллы|скилы|языки|язык|инструменты)\s*[:|]\s*([^\n]{2,120})",
RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.CultureInvariant);
// Метки-поля: категория → синонимы меток (python _FIELD_LABELS L591–596, порядок категорий 1:1).
private static readonly IReadOnlyList<(string Category, IReadOnlySet<string> Synonyms)> FieldLabels =
BuildFieldLabels();
// Знаки пунктуации, обрезаемые у слова при fallback-поиске грейда (python L761).
private static readonly char[] WordTrimChars = ",;.:«»\"'()".ToCharArray();
/// <summary>
/// Разбирает текст локальным структуратором по настройкам тенанта (python _local_fields L718–798 +
/// чтение hireMarkers/levelTerms L617–632).
/// </summary>
/// <param name="text">Текст сообщения.</param>
/// <param name="ct">Токен отмены.</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>
/// Разбирает текст по ПЕРЕДАННОМУ типизированному снимку настроек (снимок прохода воркера читает
/// таблицу настроек ОДИН раз на pump, а не на каждое сообщение — см. PipelineWorkerService.LoadRunSettingsAsync).
/// </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>
/// Чистое ядро локального разбора (1:1 _local_fields L718–798).
/// </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;
}
}
// Fallback-извлечения по всему тексту (если объявление без меток, python L750–766).
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);
// Дедуп стека без учёта регистра (python L784–787), лимиты стека/грейда (L791–792).
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);
}
// Разбирает строку «Метка: значение» → (категория, значение) или null (python _field_of L686–698).
// line: Строка текста (очищенная).
// Возвращает: Категория поля и значение метки; null — это не метка-поле.
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;
}
// Слова из значения метки стека: значимые токены без стоп-слов (python _pick_stack L701–715).
// value: Значение метки («Стек: Java, Kotlin» → «Java, Kotlin»).
// Возвращает: Токены-технологии (до MaxPickTokens).
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;
}
// Токены строки по токенизатору прототипа (python _TOKEN_RE.findall L600).
// value: Строка значения.
// Возвращает: Все совпадения токенов.
private static IEnumerable<string> TokensOf(string value)
{
foreach (Match match in TokenRe.Matches(value))
{
yield return match.Value;
}
}
// Первая распознанная сумма в тексте (python L769–775: extract_amounts, первый элемент).
// source: Текст (значение метки «Бюджет:» либо всё тело).
// Возвращает: Бюджет формы хранения (валюта-код) или null — суммы с валютой нет.
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);
}
// Нормализует маркеры для сравнения: trim + lowercase, пустые отбрасываются (как IncomingRules L338–351).
// markers: Список маркеров как сохранён (или дефолт); null — пусто.
// Возвращает: Нормализованный набор (порядок исходного, без дублей).
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;
}
// Содержит ли текст хотя бы один маркер (python «any(mk in lower …)», L781).
// lower: Текст в нижнем регистре.
// markers: Нормализованные маркеры.
// Возвращает: True — найден хотя бы один маркер.
private static bool ContainsAny(string lower, IReadOnlySet<string> markers)
{
foreach (string marker in markers)
{
if (lower.Contains(marker, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
// Категория поля «Стек:». Константа-строка (свитч по категориям меток, python-дикт L591–596).
private const string FieldCategoryStack = "stack";
// Категория поля «Грейд:». Константа-строка (свитч по категориям меток).
private const string FieldCategoryGrade = "grade";
// Категория поля «Контакты:». Константа-строка (свитч по категориям меток).
private const string FieldCategoryContacts = "contacts";
// Категория поля «Бюджет:». Константа-строка (свитч по категориям меток).
private const string FieldCategoryBudget = "budget";
// Собирает каталог меток-полей (python _FIELD_LABELS L591–596, порядок категорий 1:1: словарь-дикт).
// Возвращает: Список: категория → синонимы меток (нижний регистр).
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,143 @@
using System.Text.RegularExpressions;
namespace Deal.Modules.Pipeline.Application.Parse;
/// <summary>
/// Нормализация списков из ответов разбора: разделители, мусорные элементы, лимиты (pipeline.py
/// normalize_list L317–329, normalize_stack L332–341; стоп-слова стека L604–610).
/// </summary>
/// <remarks>
/// ИИ/локальный разбор возвращает список, иногда строку («Java, Kotlin») — строку разбиваем по
/// <c>;</c>/<c>|</c>/переносам (запятая НЕ разделитель — 1:1 с прототипом); элементы чистятся от обрамляющих
/// <c>*`#</c>, запятых и мусора, элементы короче 2 символов отбрасываются, дубликаты схлопываются.
/// <see cref="NormalizeStack"/> дополнительно отсеивает одиночные буквы и ограничивает стек 12 элементами.
/// Стоп-слова (<see cref="StackStopWords"/>) — общеупотребительные слова, не являющиеся технологией/услугой;
/// используются локальным разбором метки «Стек: …» (python <c>_STOP_STACK</c> L604–610).
/// </remarks>
public static class MessageListNormalizer
{
/// <summary>
/// Максимум элементов стека (python normalize_stack L340, Ruling 4: ≤12).
/// </summary>
public const int MaxStackItems = 12;
// Разделители элементов строкового списка (python normalize_list L322).
private static readonly Regex ListSeparatorsRe = new(@"[;|\n]+", RegexOptions.CultureInvariant);
// Пробел перед висящей пунктуацией в конце элемента (python L326).
private static readonly Regex TrailingPunctSpaceRe = new(@"\s+([.,])\s*$", RegexOptions.CultureInvariant);
// Стоп-слова стека: не технологии/услуги, а связки и общие слова объявлений (python _STOP_STACK L604–610).
private static readonly IReadOnlySet<string> StopWordsSet = BuildStopWords();
/// <summary>
/// Нормализует строковый список/одиночную строку (python normalize_list L317–329).
/// </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>
/// Нормализует список элементов (python normalize_list над list L320–329: элементы НЕ разбиваются
/// по разделителям — это сделал разбор).
/// </summary>
/// <param name="values">Список элементов или null.</param>
/// <returns>Очищенные элементы без дублей и мусора.</returns>
public static IReadOnlyList<string> NormalizeList(IEnumerable<string>? values)
{
return CleanParts(values ?? Array.Empty<string>());
}
/// <summary>
/// Стек из строкового значения (python normalize_stack L332–341): элементы короче 2 символов — не
/// технология, максимум <see cref="MaxStackItems"/>.
/// </summary>
/// <param name="value">Строка-список стека или null.</param>
/// <returns>Стек (≤12 элементов).</returns>
public static IReadOnlyList<string> NormalizeStack(string? value)
{
return TakeStack(NormalizeList(value));
}
/// <summary>
/// Стек из списка элементов (python normalize_stack L332–341).
/// </summary>
/// <param name="values">Список технологий/направлений или null.</param>
/// <returns>Стек (≤12 элементов).</returns>
public static IReadOnlyList<string> NormalizeStack(IEnumerable<string>? values)
{
return TakeStack(NormalizeList(values));
}
/// <summary>
/// Стоп-слова стека (python <c>_STOP_STACK</c> L604–610): общеупотребительные слова, не являющиеся
/// технологией/услугой — локальный разбор метки «Стек: …» их отбрасывает (<c>_pick_stack</c> L701–715).
/// </summary>
public static IReadOnlySet<string> StackStopWords => StopWordsSet;
// Очищает части списка: обрезка, снятие обрамляющих *`#, висящих запятых/точек,
// отбрасывание пустых/односимвольных и дублей (python L324–328).
// parts: Сырые части (после разбиения или элементы списка).
// Возвращает: Очищенный список.
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;
}
// Отбирает стек: одиночные буквы/мусор — не технология (python L335–337), лимит 12 (L339–340).
// items: Нормализованные элементы списка.
// Возвращает: Стек ≤12 элементов длиной ≥2.
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;
}
// Собирает набор стоп-слов стека (python _STOP_STACK L604–610).
// Возвращает: Набор слов в нижнем регистре.
private static IReadOnlySet<string> BuildStopWords()
{
return new HashSet<string>(StringComparer.Ordinal)
{
"и", "или", "на", "по", "с", "не", "а", "в", "о", "об", "от", "до", "для", "опыт", "знание",
"знания", "уметь", "умение", "умения", "работать", "работы", "работа", "работе", "требуется",
"приветствуется", "будет", "плюсом", "разработка", "разработке", "разработчик", "разработчика",
"вакансия", "вакансию", "вакансии", "команда", "команду", "команды", "проект", "проекта", "проекты",
"приветствуются", "желательно", "уверенное", "хорошее", "понимание", "навыки", "навык", "навыков",
};
}
}
@@ -0,0 +1,279 @@
using System.Text;
using System.Text.RegularExpressions;
namespace Deal.Modules.Pipeline.Application.Parse;
/// <summary>
/// Чистка текстовых полей сообщения/карточки от markdown-разметки, ссылок и служебных символов
/// (pipeline.py clean_short L148–156 / clean_block L158–193, регэкспы/эмодзи L131–145).
/// </summary>
/// <remarks>
/// 1:1 с прототипом: markdown-ссылки <c>[текст](url)</c> → текст, <c>**__`~~</c>-пары снимаются,
/// <c>||спойлер||</c> и голые URL удаляются, «C#»/«F#» защищаются от вырезания решётки (письмо + #),
/// эмодзи-диапазоны, маркеры списков в начале строк и лишние переносы/пробелы схлопываются. <see cref="CleanShort"/>
/// дополнительно переводит переносы в пробелы (заголовок); <see cref="CleanBlock"/> сохраняет структуру строк
/// («О заявке» карточки). <see cref="CleanLine"/> — обрезка краёв строки (python <c>_clean_line</c> L682–683).
/// </remarks>
public static class MessageTextCleaner
{
// Защитный символ, временно заменяющий решётку после буквы («C#», прототип L172/L176).
private const string HashGuard = "\u2063";
// Символ zero-width space (фото-превью Telegram), удаляется (прототип L173).
private const string ZeroWidthSpace = "\u200b";
// Неразрывный пробел, заменяется обычным (прототип L174).
private const string NonBreakingSpace = "\u00a0";
// Markdown-ссылка [текст](url) → текст ссылки (прототип _MD_LINK_RE L131).
private static readonly Regex MarkdownLinkRe = new(@"\[([^\]]*)\]\([^)\s]+\)", RegexOptions.CultureInvariant);
// Жирный **текст** (прототип _MD_BOLD L598).
private static readonly Regex MarkdownBoldRe = new(@"\*\*(.+?)\*\*", RegexOptions.CultureInvariant);
// Жирный __текст__ (прототип _MD_BOLD2_RE L132).
private static readonly Regex MarkdownBold2Re = new(@"__([^_\n]+?)__", RegexOptions.CultureInvariant);
// Код `текст` (прототип _MD_CODE_RE L133).
private static readonly Regex MarkdownCodeRe = new(@"`([^`\n]+?)`", RegexOptions.CultureInvariant);
// Зачёркнутый ~~текст~~ (прототип _MD_STRIKE_RE L134).
private static readonly Regex MarkdownStrikeRe = new(@"~~([^~\n]+?)~~", RegexOptions.CultureInvariant);
// Голая URL-ссылка (прототип _BARE_URL_RE L135).
private static readonly Regex BareUrlRe = new(@"https?://[^\s<>""']+", RegexOptions.CultureInvariant);
// Защита решётки в составе названия: «C#»/«F#» (прототип L172).
private static readonly Regex HashProtectRe = new(@"\b([A-Za-zА-Яа-яЁё])\#", RegexOptions.CultureInvariant);
// Служебные символы markdown в тексте (прототип L175).
private static readonly Regex MarkdownSymbolsRe = new("[*`#>~]+", RegexOptions.CultureInvariant);
// Маркеры списков/декоративные буллеты в начале строк (прототип L179).
private static readonly Regex LineStartMarkersRe = new(@"(?m)^[\s>#*\-–—•▪▫●○‣]+\s*", RegexOptions.CultureInvariant);
// Схлопывание пробелов и табуляций (прототип L180).
private static readonly Regex SpaceCollapseRe = new(@"[ \t]+", RegexOptions.CultureInvariant);
// Пробелы/табуляции после переноса строки (прототип L181).
private static readonly Regex LineIndentRe = new(@"\n[ \t]+", RegexOptions.CultureInvariant);
// Подряд идущие переносы строк → один (прототип L182).
private static readonly Regex MultiNewlineRe = new(@"\n{2,}", RegexOptions.CultureInvariant);
// Переносы строк в пробелы для clean_short (прототип L155).
private static readonly Regex NewlinesToSpaceRe = new(@"\n+", RegexOptions.CultureInvariant);
// Служебные символы на краях строки (python _MD_EDGES L597).
private static readonly Regex LineEdgesRe = new(@"^[\s*>#_~]+|[\s*>#_~]+$", RegexOptions.CultureInvariant);
// Символы, обрезаемые с краёв готового блока (прототип L183).
private static readonly char[] EdgeTrimChars = " \t\n\r-–—·•|:;,".ToCharArray();
// Одноразовые кодовые точки-разделители для ручного прохода символов.
private const int EmptyCodePoint = -1;
/// <summary>
/// Чистит текстовое поле в одну строку (заголовок, суть без структуры): как <see cref="CleanBlock"/>,
/// но переносы строк схлопываются в пробелы (python clean_short L148–156).
/// </summary>
/// <param name="text">Сырой текст (markdown/ссылки/эмодзи); null → пустая строка (как <c>str(text or "")</c>).</param>
/// <param name="limit">Максимум кодовых точек результата; обрезка по границе переноса/пробела с многоточием
/// (L184–192); null/0 — без обрезки.</param>
/// <returns>Очищенный однострочный текст.</returns>
public static string CleanShort(string? text, int? limit = null)
{
return NewlinesToSpaceRe.Replace(CleanBlock(text, limit), " ");
}
/// <summary>
/// Чистит блок текста с сохранением переносов строк (python clean_block L158–193): применяет все
/// шаги прототипа в том же порядке (markdown → URL → защита «C#» → эмодзи → маркеры списков → пробелы →
/// обрезка краёв → лимит по границе).
/// </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;
// Порядок 1:1 с прототипом (L163–183).
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 (python <c>_clean_line</c> L682–683):
/// убирает <c>[\s&gt;*#_~]</c> с краёв и тримит.
/// </summary>
/// <param name="line">Строка текста (может быть null — как <c>None</c> в python).</param>
/// <returns>Строка без краевого мусора (пустая, если мусора было больше).</returns>
public static string CleanLine(string? line)
{
return LineEdgesRe.Replace(line ?? string.Empty, string.Empty).Trim();
}
// Количество кодовых точек в строке (python len() — позиции суррогатных пар считаются одной).
// value: Строка (не null).
// Возвращает: Число кодовых точек.
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;
}
// Первые max кодовых точек строки (python-срез s[:max] без разрыва
// суррогатных пар).
// value: Строка.
// max: Максимум кодовых точек; ≤0 или ≥ длины — строка как есть.
// Возвращает: Усечённая строка.
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();
}
// Убирает декоративные эмодзи/символы-маркеры (python _EMOJI_RE L137–145): доп. пиктограммы
// 1F000–1FAFF (включая региональные флаги 1F1E6–1F1FF), разные символы 2600–27BF, стрелки 2B00–2BFF
// и variation selector FE0F.
// value: Текст после снятия markdown-разметки.
// Возвращает: Текст без эмодзи-диапазонов (суррогатные пары удаляются целиком).
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 (IsEmojiCodePoint(codePoint))
{
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;
}
// Входит ли кодовая точка в эмодзи-диапазоны прототипа (L137–145).
// codePoint: Кодовая точка (BMP или доп. плоскость).
// Возвращает: True — декоративный символ, подлежащий удалению.
private static bool IsEmojiCodePoint(int codePoint)
{
return codePoint is >= 0x1F000 and <= 0x1FAFF
or >= 0x2600 and <= 0x27BF
or >= 0x2B00 and <= 0x2BFF
or 0xFE0F;
}
// Обрезка по границе последнего переноса/пробела в первых limit кодовых точках
// (прототип L184–192): выбирается перенос (или пробел), если он после середины лимита; иначе режем жёстко.
// value: Текст длиннее лимита.
// limit: Лимит кодовых точек.
// Возвращает: Усечённый текст с многоточием в конце.
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,225 @@
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Modules.Pipeline.Application.Parse;
/// <summary>
/// Сборка блока «О заявке» карточки (pipeline.py compose_summary L225–284, _local_summary L294–314,
/// футер-хинты L288–291).
/// </summary>
/// <remarks>
/// Карточка всегда собирается из одних и тех же блоков — Компания → Формат → О задаче → Требования →
/// Будет плюсом → Условия (1:1 с cardPrompt); недостающие блоки пропускаются. Если структурированных полей нет
/// (<see cref="ParsedCardContent.Summary"/> от локального/старого разбора) — суть сохраняется как есть, но
/// отбрасывается, когда похожа на служебный футер агрегаторов (<see cref="FooterHints"/>); тогда «О задаче»
/// собирается из содержательных строк текста (<see cref="LocalSummary"/>, с префиксом «О задаче: »).
/// </remarks>
public static class SummaryComposer
{
// Лимит пунктов «Требования» в блоке (python L255: req[:14]).
private const int MaxRequirementsItems = 14;
// Лимит пунктов «Будет плюсом» в блоке (python L257: plus[:10]).
private const int MaxPlusItems = 10;
// Максимум содержательных строк локальной сути (python _local_summary L308: 4).
private const int MaxSummaryParts = 4;
// Минимальная длина сути без fallback на весь текст (python L311: &lt; 40 → clean_short(body, 360)).
private const int MinSummaryLength = 40;
// Лимит fallback-сути из всего текста (python L313: clean_short(body, 360)).
private const int SummaryFallbackLimit = 360;
// Лимит итоговой сути (python L314: out[:600]).
private const int MaxSummaryLength = 600;
// Сколько первых строк списка пропускает локальная суть по умолчанию — первая строка это заголовок
// (python _local_summary skip_first=1 L294).
private const int DefaultSkipFirst = 1;
// Однострочные слова-заглушки, не несущие сути (python L305).
private static readonly IReadOnlySet<string> TrivialWords = new HashSet<string>(StringComparer.Ordinal)
{
"вакансия", "вакансию", "фриланс",
};
// Служебные строки/фразы футеров агрегаторов: «суть» с ними — не структура, а шум (python L288–291).
private static readonly string[] FooterHintsArray =
{
"откликнуться через", "runello", "больше вакансий", "teletype", "при отклике укажите",
"больше заявок", "узнать подробнее", "написать в лс", "пишите в лс",
};
/// <summary>
/// Собирает «О заявке» из структурированных полей либо текста (python compose_summary L225–284).
/// </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);
}
// Структурированных полей нет. «summary» разбора часто является копией исходника/шумом — если в нём есть
// футеры/хэштеги-мусор, не используем его (python L264–268).
string legacy = MessageTextCleaner.CleanShort(source.Summary);
if (legacy.Length > 0 && !ContainsFooterHint(legacy))
{
return legacy;
}
// Локальный путь без ИИ: «О задаче» из содержательных строк (без хэштег-строк и футеров, python L271–283).
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("**#") || ContainsFooterHint(lower))
{
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>
/// Суть карточки при локальном разборе: содержательные строки после заголовка, без меток-полей и
/// мусора (python _local_summary L294–314).
/// </summary>
/// <param name="body">Весь очищенный текст (источник fallback-сути).</param>
/// <param name="lines">Очищенные непустые строки текста (первая — заголовок).</param>
/// <param name="skipFirst">Сколько первых строк пропустить (заголовок); по умолчанию 1.</param>
/// <returns>Суть одним абзацем (≤600 символов).</returns>
public static string LocalSummary(string? body, IReadOnlyList<string> lines, int skipFirst = DefaultSkipFirst)
{
var parts = new List<string>(MaxSummaryParts);
foreach (string line in lines.Skip(skipFirst))
{
if (LocalFieldsParser.FieldOf(line) is not null)
{
continue; // «Стек: …», «Бюджет: …» и т.п. уже разобраны в поля (python L301–303)
}
string cleaned = MessageTextCleaner.CleanShort(line);
if (MessageTextCleaner.CountCodePoints(cleaned) < 2
|| TrivialWords.Contains(cleaned.ToLowerInvariant()))
{
continue;
}
parts.Add(cleaned);
if (parts.Count >= MaxSummaryParts)
{
break;
}
}
string summary = string.Join(" ", parts);
if (MessageTextCleaner.CountCodePoints(summary) < MinSummaryLength)
{
// Мало содержательных строк — берём очищенное начало всего текста (python L311–313).
summary = MessageTextCleaner.CleanShort(body ?? string.Empty, SummaryFallbackLimit);
}
return MessageTextCleaner.SliceCodePoints(summary, MaxSummaryLength);
}
// Нормализует пункты блока: элементы списка чистятся как в normalize_list(list), затем каждый —
// clean_short (python _items L235–241).
// values: Сырые пункты разбора (может быть null).
// Возвращает: Очищенные непустые пункты.
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;
}
// Содержит ли текст служебный футер-хинт (python L267/L275: сравнение с casefold-текстом).
// text: Текст (в любом регистре).
// Возвращает: True — текст похож на футер агрегатора/служебную строку.
private static bool ContainsFooterHint(string text)
{
string lower = text.ToLowerInvariant();
foreach (string hint in FooterHintsArray)
{
if (lower.Contains(hint, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
}
@@ -0,0 +1,49 @@
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Создание карточки пайплайна через публичный интерфейс Kanban (план Task 7 L400–402, Ruling 3/4;
/// python _store_lead L483–514: INSERT карточки → UPDATE dedup.lead_id → чтение после записи).
/// </summary>
/// <remarks>
/// Тонкая обёртка создания: генерирует id карточки (<c>c_</c>, <see cref="PrefixId"/>/<see cref="KanbanIdPrefixes"/> —
/// переиспользуем генератор владельца), собирает полный снимок через <see cref="CardComposer"/> и пишет его
/// <see cref="ICardStore.AddCardAsync"/>, после чего связывает заявку дедупа с карточкой
/// (<see cref="IPipelineStore.LinkAsync"/> — порядок AddCard → Link 1:1 с Ruling 4, L512–513) и перечитывает
/// полный <see cref="CardDto"/> (для SSE new_card, Ruling 8/9).
/// <para>
/// Проверку «уже есть карточка по дедупу» обёртка НЕ дублирует — её делает воркер на «new»-проходе pump
/// (Ruling 8, L940–951): к моменту записи сообщение уже прошло дедуп-гвард и его хэш заявлен (LeadId=null).
/// Транзакционности карточка + dedup-link нет (как в прототипе — два отдельных statement'а адаптеров на общем
/// TenantDbContext, Ruling 3/4): сбой LinkAsync оставляет карточку без связи и пробрасывается вызывающему.
/// </para>
/// </remarks>
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 хэша текста (заявка дедупа уже создана воркером, Ruling 8).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Полная карточка (чтение после записи — §4.1, как lead_to_dict после INSERT, L514).</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,24 @@
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Реестр префиксов коротких id модуля Pipeline (Ruling 10; прототип store.uid).
/// </summary>
/// <remarks>
/// Очередь — <c>p_</c> (строка QueueItems, id генерирует модуль при приёме, Ruling 2); отсев — <c>r_</c>
/// (детерминированный <c>r_&lt;dialog&gt;_&lt;msgId&gt;</c> при наличии dialog+msgId, иначе <c>r_</c>+hex,
/// Ruling 1). Хэш-ключ дедупа — SHA1-hex БЕЗ префикса (первичный ключ DedupEntries.Hash; считает DedupHasher,
/// Task 4). Случайную часть id даёт общий генератор <c>PrefixId</c> модуля Kanban (переиспользуем публичный
/// чистый помощник владельца, без дублирования и без выноса в SharedKernel — модуль Kanban уже в зависимостях).
/// </remarks>
public static class PipelineIdPrefixes
{
/// <summary>
/// Префикс id строки очереди (таблица QueueItems; pipeline.py store.uid("p_")).
/// </summary>
public const string Queue = "p_";
/// <summary>
/// Префикс id записи отсева (таблица RejectedItems; processing.record L77).
/// </summary>
public const string Rejected = "r_";
}
@@ -0,0 +1,64 @@
using Deal.Modules.Kanban.Application;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Приём входящих сообщений пайплайна — постановка сырого сообщения в очередь QueueItems (Ruling 2, pipeline.py enqueue L53–85).
/// </summary>
/// <remarks>
/// Единственная точка входа сообщений в пайплайн этапа 4: вызывает gRPC-ингресс telegram-service
/// (интерфейс не плодим — YAGNI).
/// 1:1 с прототипом enqueue: текст тримится, пустой текст или нет dialogId → no-op; текст режется до 6000
/// кодовых точек (python text[:6000]); при msgId — дубль-гвард «диалог+сообщение уже в очереди» (защита от
/// двойного события Telethon: строка живёт, пока сообщение в очереди/обработке). Строка пишется со статусом
/// <c>new</c>, id <c>p_</c> генерирует <see cref="PrefixId"/> (Ruling 10), CreatedAt=UpdatedAt=now, msgAt=now
/// при отсутствии. Разбор очереди (правила/дедуп по тексту/ML/ИИ) — воркер (Ruling 8), не этот сервис; очистка
/// текста карточки — тоже позже (здесь только trim+лимит, как в прототипе).
/// </remarks>
public sealed class PipelineIngestService(IPipelineStore store)
{
// Лимит текста строки очереди (enqueue L84: text[:6000], Ruling 2).
private const int MaxQueueTextLength = 6000;
/// <summary>
/// Ставит входящее сообщение в очередь: trim → no-op пустого текста/нет dialogId → дубль-гвард → INSERT.
/// </summary>
/// <param name="message">Сырое сообщение: диалог, канальные поля, текст, время; Force — возврат из отсева (Ruling 2/10).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Id строки (<c>p_...</c>) либо null, если сообщение не принято; Duplicate — дубль диалога.</returns>
public async Task<PipelineIngestResultDto> EnqueueAsync(QueuedMessage message, CancellationToken ct)
{
// Пустой текст или нет диалога — no-op (enqueue L68–70): принимаем, но в очередь не пишем.
string text = (message.Text ?? string.Empty).Trim();
if (text.Length == 0 || string.IsNullOrEmpty(message.DialogId))
{
return new PipelineIngestResultDto(null, false);
}
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Дубль-гвард приёма: то же сообщение диалога уже в очереди/обработке — Telethon-повтор не пишем (L74–80).
if (message.MsgId is not null
&& await store.ExistsDuplicateAsync(message.DialogId, message.MsgId, ct))
{
return new PipelineIngestResultDto(null, true);
}
var item = new QueueItemDto
{
Id = PrefixId.New(PipelineIdPrefixes.Queue),
DialogId = message.DialogId,
MsgId = message.MsgId,
Text = MessageTextCleaner.SliceCodePoints(text, MaxQueueTextLength),
Status = PipelineQueueStatuses.New,
Channel = new PipelineChannelDto(message.ChannelName, message.ChannelHandle, message.ChannelHue),
MsgAtMs = message.MsgAtMs ?? nowMs,
QueuedAtMs = nowMs,
Force = message.Force,
};
await store.AddAsync(item, ct);
return new PipelineIngestResultDto(item.Id, false);
}
}
@@ -0,0 +1,72 @@
using Deal.Modules.Pipeline.Application.Parse;
using Microsoft.Extensions.DependencyInjection;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// DI-регистрация модуля Pipeline. Паттерн «port &amp; adapter» (Ruling 10).
/// </summary>
/// <remarks>
/// Регистрируются сервисы модуля: scoped-ядра с настройками (<see cref="LocalFieldsParser"/>), приём сообщений
/// (<see cref="PipelineIngestService"/>, Ruling 2), обработка/мониторинг (<see cref="PipelineProcessingService"/>, Task 5),
/// создание карточек через Kanban (<see cref="CardComposer"/>/<see cref="PipelineCardWriter"/>, Task 7) и воркер pump
/// (<see cref="PipelineWorkerService"/>, Task 8) — остальные сервисы этапа 4 добавляются по мере задач.
/// Ядра разбора — статические чистые классы
/// (задача 4), регистрация не нужна. Порт-адаптер (IPipelineStore → PipelineStore) реализован в
/// Deal.Infrastructure и регистрируется там
/// (AddDealPersistence, Task 3); IAiClassifier → LocalAiClassifier — в AddDealIntegrations (Task 6) — модуль
/// не знает про EF и HTTP. Зависимости модуля — Deal.Modules.Settings (ISettingsStore/IncomingRules),
/// Deal.Modules.Kanban (ICardStore + чистые ColumnRules/BudgetNormalizer/AmountParser/PrefixId, Ruling 3) и
/// Deal.Contracts (IMlClient, Task 5/8) — реверс-зависимостей нет (Kanban/Settings о Pipeline не знают).
/// </remarks>
public static class PipelineModuleRegistrar
{
/// <summary>
/// Регистрирует сервисы модуля Pipeline в контейнере.
/// </summary>
/// <param name="services">Коллекция сервисов.</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
/// <remarks>
/// Время жизни — scoped: зависимости (IPipelineStore/ISettingsStore/ICardStore/IMlClient) живут в рамках
/// tenant-запроса (EF-контекст), как IncomingRules/Kanban-сервисы (эталон KanbanModuleRegistrar).
/// <see cref="PipelineProcessingService"/> зависит от <see cref="PipelineIngestService"/> (возврат в
/// обработку ставит force-строку через полный путь приёма, Ruling 10) — цикла нет (Ingest о Processing
/// не знает). Вызывается из Program.cs (AddPipelineModule, Task 9) после AddDealPersistence.
/// </remarks>
public static IServiceCollection AddPipelineModule(this IServiceCollection services)
{
// Ядра разбора задачи 4: статические (MessageTextCleaner/…/SummaryComposer) не регистрируются;
// LocalFieldsParser читает маркеры через ISettingsStore — scoped, как IncomingRules (эталон KanbanModuleRegistrar).
services.AddScoped<LocalFieldsParser>();
// Task 5: приём входящих (gRPC-ингресс telegram-service) и обработка/мониторинг вкладки «Обработка».
services.AddScoped<PipelineIngestService>();
services.AddScoped<PipelineProcessingService>();
// Task 7: композитор карточки (Ruling 4) и обёртка создания через ICardStore.AddCardAsync (Ruling 3) —
// оба читают настройки тенанта/доски (ISettingsStore/ICardStore scoped, как IncomingRules).
services.AddScoped<CardComposer>();
services.AddScoped<PipelineCardWriter>();
// Task 8: воркер pump — один проход очереди (stale/правила/дедуп/ML/ИИ/карточка, Ruling 8). Вызывают
// фоновый цикл (Task 11) и POST /api/admin/tick (Task 10) из Api — модуль сам циклы не заводит.
services.AddScoped<PipelineWorkerService>();
// Task 15: контекст ИИ-классификации (промпты + доски + few-shot-примеры; Ruling 5). Читает настройки
// и доски/журнал тенанта — scoped, как CardComposer. Потребитель — gRPC-адаптер GrpcAiClassifier
// (Infrastructure, реализует порт IAiClassifier); локальный путь контекст не строит.
services.AddScoped<AiClassifyContextBuilder>();
// Этап 12, пакет D: ручная переклассификация карточек (POST /api/cards/reclassify|{id}/reclassify).
// Синхронный проход той же механикой, что и воркер (фильтр/классификация/сборка/ML), поэтому scoped —
// как воркер. Замок single-flight живёт в singleton (общий для всех tenant-запросов процесса).
services.AddSingleton<ReclassifyGate>();
services.AddScoped<CardReclassifier>();
// Этап 12, добивка по ТЗ §8: ручная проверка/разметка ML на сообщениях канала — кандидаты и применение
// решения (обучение ML + перенос/корзина/отсев через существующие сервисы). Scoped — как остальные
// сервисы модуля (EF-контекст tenant-запроса).
services.AddScoped<MlReviewService>();
return services;
}
}
@@ -0,0 +1,340 @@
using Deal.Contracts.Integrations;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Мониторинг и обслуживание пайплайна — вкладка «Обработка»: очередь, отсев, возврат, очистки, счётчики (processing.py L66–320, Rulings 8/10).
/// </summary>
/// <remarks>
/// Чистый сервис модуля (без EF/HTTP): запись отсева (1:1 processing.record L66–101 — детерминированный id
/// <c>r_&lt;dialog&gt;_&lt;msgId&gt;</c> либо случайный <c>r_</c>+hex в адаптере, лимиты text/reason/kw,
/// дефолт цвета канала <c>#666</c>; повторное отбрасывание того же сообщения — upsert, а не дубликат),
/// чтение очереди (list_queue L218–241, clamp лимита 1..500), счётчики (queue_counts L207–215 /
/// rejected_count L201–202), страницы отсева (list_rejected L246–312: no-q путь по RejectedAt DESC; q-путь —
/// FTS-кандидаты ∪ LIKE-дополнение, total = размер объединения, Ruling 6), возврат в обработку
/// (return_to_queue L128–193), ручные Delete/Clear и автоочистка <see cref="PurgeExpiredAsync"/> (3 суток от
/// RejectedAt; вызывает тик/фоновый цикл, Ruling 8/9), сводка <see cref="StatsAsync"/> (форма /pipeline/stats).
/// <para>
/// Возврат (Ruling 5/10): ошибки 400 — строки Ruling 10 (уже возвращено / повтор-dup / нет текста); отсев по
/// решению «спам» (spam_ml/spam_ai/filter_ai) снимает у ML вес спама <c>PushAsync(text, "spam", −1.0)</c>;
/// запись помечается returned+returnedAt+returnReason и НЕ удаляется (аудит); сообщение уходит в очередь с
/// force=true (правила/устарело/ML/ИИ-отсев для него игнорируются воркером). Записи нет — возвращает null
/// (эндпоинт отвечает 404 «Запись не найдена», текст 404 — слой эндпоинтов).
/// </para>
/// </remarks>
public sealed class PipelineProcessingService(
IPipelineStore store,
IMlClient mlClient,
PipelineIngestService ingest)
{
// ── Фиксированные строки прототипа (400-детали return; Ruling 10) ───────
/// <summary>
/// 400 return: запись уже возвращалась в обработку (return_to_queue L139–140).
/// </summary>
public const string AlreadyReturnedDetail = "Сообщение уже возвращено в обработку";
/// <summary>
/// 400 return: повтор по дедупу — карточка с текстом уже в системе (return_to_queue L141–142, Ruling 10).
/// </summary>
public const string DuplicateReturnDetail = "Повтор: карточка с таким текстом уже есть в системе — возвращать нечего";
/// <summary>
/// 400 return: в записи нет текста сообщения (return_to_queue L146–147, Ruling 10).
/// </summary>
public const string EmptyReturnTextDetail = "В записи нет текста сообщения";
// ── Лимиты страниц (processing.DEFAULT_LIMIT/MAX_LIMIT L48–49) ───────────
/// <summary>
/// Размер страницы по умолчанию для списков очереди/отсева.
/// </summary>
public const int DefaultPageSize = 100;
/// <summary>
/// Максимальный размер страницы списков (clamp 1..500).
/// </summary>
public const int MaxPageSize = 500;
// ── Обучение ML при возврате (Ruling 5/10, return_to_queue L149–153) ─────
// Вес снятия метки «спам»: реальное действие пользователя «это не спам» (delta=−1.0).
private const double SpamUnlearnDelta = -1.0;
// Источник решения «повтор» (source=dup): возврат заблокирован (Ruling 10).
private const string DuplicateSource = "dup";
// Этапы отсева по решению «спам»: при возврате снимаем у ML вес спама (L149–153).
private static readonly HashSet<string> SpamStages = new(StringComparer.Ordinal)
{
"spam_ml",
"spam_ai",
"filter_ai",
};
// ── Лимиты текста отсева и дефолты (Ruling 1/10; processing.record L86–96) ───
// Лимит текста записи отсева (record L90: text[:6000]).
private const int MaxRejectedTextLength = 6000;
// Лимит причины отсева (record L95: reason[:500]).
private const int MaxReasonLength = 500;
// Лимит фразы-ключа kw (record L96: kw[:200]).
private const int MaxKwLength = 200;
// Лимит причины возврата (return_to_queue L158: return_reason[:500]).
private const int MaxReturnReasonLength = 500;
// ── Отсев: запись (processing.record L66–101) ────────────────────────────
/// <summary>
/// Сохраняет отброшенное сообщение в отсев (вызывают воркер/этапы pump, Ruling 8).
/// </summary>
/// <remarks>
/// Пустой/пробельный текст — no-op (L73–74). Текст/причина/фраза режутся по лимитам (6000/500/200),
/// цвет канала — дефолт #666; id детерминированный по dialog+msgId (иначе случайный r_+hex — адаптер);
/// повторное отбрасывание того же сообщения обновляет запись (upsert), а не копит дубликаты.
/// </remarks>
/// <param name="record">Команда записи отсева (поля без ограничений длин — сервис нормализует).</param>
/// <param name="ct">Токен отмены.</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),
ChannelHue = string.IsNullOrEmpty(record.ChannelHue) ? SourceDefaults.DefaultHue : record.ChannelHue,
Reason = MessageTextCleaner.SliceCodePoints(record.Reason, MaxReasonLength),
Kw = MessageTextCleaner.SliceCodePoints(record.Kw, MaxKwLength),
}, ct);
}
// ── Очередь (list_queue L218–241 / queue_counts L207–215) ────────────────
/// <summary>
/// Сырые сообщения очереди в порядке постановки (CreatedAt ASC), все статусы (list_queue L218–241).
/// </summary>
/// <param name="limit">Максимум строк; clamp 1..500 (L219) — запрашивается больше — вернётся не больше 500.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Строки очереди (item'ы §4.5; внутренний Force в JSON не выходит).</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>
/// Счётчики очереди по статусам: new / filtered (bucket «ai») / total (queue_counts L207–215).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <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>
/// Число записей в отсеве (rejected_count L201–202; счётчик вкладки «Обработка»).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Всего записей RejectedItems.</returns>
public Task<int> RejectedCountAsync(CancellationToken ct)
{
return store.CountAsync(ct);
}
// ── Отсев: чтение/страницы (list_rejected L246–312) ──────────────────────
/// <summary>
/// Страница отсева: без q — свежие первыми (RejectedAt DESC); с q — FTS ∪ LIKE-кандидаты (Ruling 6).
/// </summary>
/// <remarks>
/// q нормализуется trim+lowercase (L249). Без поиска total = rejected_count, страница из БД (offset/limit).
/// С поиском total = размер объединения кандидатов (FTS-ранжированные первыми, затем LIKE-дополнение по
/// lower(text/reason/kw/ch_name)), страница срезается из кандидатов в памяти (как python ids[offset:…]);
/// кандидаты возвращаются портом полными строками одним вызовом — без N+1 чтений по id.
/// offset/limit clamp: offset ≥ 0, limit 1..500; значения эхом в ответе.
/// </remarks>
/// <param name="q">Поисковый запрос (текст/причина/фраза/имя канала); пустой — весь отсев.</param>
/// <param name="offset">Сдвиг от начала (clamp ≥ 0).</param>
/// <param name="limit">Размер страницы (clamp 1..500).</param>
/// <param name="ct">Токен отмены.</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);
}
// Поиск: кандидаты — полные записи FTS (limit) + LIKE-дополнение (limit*2), без дублей (порт, Ruling 6;
// L252–270). Порт возвращает строки сразу — без N+1 «id → GetAsync» по каждому кандидату страницы.
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);
}
// ── Отсев: возврат в обработку (return_to_queue L128–193, Ruling 5/10) ───
/// <summary>
/// Возвращает отсеянное сообщение в обработку (кнопка в «Обработке»): force-строка в очередь + аудит.
/// </summary>
/// <remarks>
/// Порядок проверок 1:1 с прототипом: записи нет → null (404); уже returned → 400; source=dup → 400 (повтор
/// — карточка с текстом уже в системе); нет текста → 400. Этап spam_ml/spam_ai/filter_ai — снимаем у ML вес
/// спама PushAsync(text, "spam", −1.0). Запись помечается returned/returnedAt/returnReason (НЕ удаляется).
/// Сообщение уходит в очередь с force=true: с dialog+msgId — через полный путь приёма (дубль-гвард
/// сохраняется, L163–173); старые записи без dialog/msgId — прямым INSERT (L174–192). msg_at записи
/// сохраняется (0/нет — now, как row.get("msg_at") or now).
/// </remarks>
/// <param name="rejectedId">Id записи отсева (<c>r_...</c>).</param>
/// <param name="reason">Причина возврата (trim, ≤500; пишется на запись для аудита).</param>
/// <param name="ct">Токен отмены.</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.Source == 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))
{
// Реальное действие пользователя: этот текст НЕ спам — снимаем у ML вес спама (L149–153).
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);
// msg_at исходного сообщения или now (python row.get("msg_at") or now, L171/L188).
long msgAtMs = row.MsgAtMs != 0 ? row.MsgAtMs : nowMs;
string hue = string.IsNullOrEmpty(row.Channel.Hue) ? SourceDefaults.DefaultHue : row.Channel.Hue;
if (row.DialogId.Length > 0 && row.MsgId is not null)
{
// Полный путь приёма: дубль-гвард по dialog+msgId сохраняется (enqueue L163–173), force=true.
await ingest.EnqueueAsync(new QueuedMessage
{
DialogId = row.DialogId,
ChannelName = row.Channel.Name,
ChannelHandle = row.Channel.Handle,
ChannelHue = hue,
MsgId = row.MsgId,
Text = text,
MsgAtMs = msgAtMs,
Force = true,
}, ct);
}
else
{
// Старые записи (до сохранения dialog_id/msg_id): текст сохранился — возвращаем без ссылки
// на исходное сообщение прямым INSERT (return_to_queue L174–192, force=true).
var item = new QueueItemDto
{
Id = PrefixId.New(PipelineIdPrefixes.Queue),
DialogId = row.DialogId,
MsgId = row.MsgId,
Text = MessageTextCleaner.SliceCodePoints(text, MaxRejectedTextLength),
Status = PipelineQueueStatuses.New,
Channel = new PipelineChannelDto(row.Channel.Name, row.Channel.Handle, hue),
MsgAtMs = msgAtMs,
QueuedAtMs = nowMs,
Force = true,
};
await store.AddAsync(item, ct);
}
return new RejectReturnResultDto(null, rejectedId, true, nowMs);
}
// ── Отсев: удаление/очистки (L104–125, L196–198) ─────────────────────────
/// <summary>
/// Удаляет одну запись отсева безвозвратно (DELETE /rejected/{id}, delete_one L196–198).
/// </summary>
/// <param name="rejectedId">Id записи (<c>r_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Задача завершается после удаления (прототип всегда отвечает ok, 404 не шлёт — Ruling 10).</returns>
public Task DeleteAsync(string rejectedId, CancellationToken ct)
{
return store.DeleteAsync(rejectedId, ct);
}
/// <summary>
/// Полная ручная очистка отсева (POST /rejected/clear, clear_all L120–125).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Сколько записей удалено (0 — отсев пуст).</returns>
public Task<int> ClearAsync(CancellationToken ct)
{
return store.ClearAsync(ct);
}
/// <summary>
/// Автоочистка отсева: записи старше 3 суток от RejectedAt удаляются безвозвратно (purge_expired L104–117, Ruling 8).
/// </summary>
/// <remarks>
/// Срок — <see cref="PipelineRejectConstants.RetentionDays"/> (3 суток). Вызывается из /admin/tick и
/// фонового StorageTickScheduler (Ruling 9/11) — модуль сам циклы не заводит.
/// </remarks>
/// <param name="ct">Токен отмены.</param>
/// <returns>Сколько записей удалено.</returns>
public Task<int> PurgeExpiredAsync(CancellationToken ct)
{
DateTimeOffset olderThan = DateTimeOffset.UtcNow.AddDays(-PipelineRejectConstants.RetentionDays);
return store.PurgeExpiredAsync(olderThan, ct);
}
// ── Сводка (stats L315–320) ──────────────────────────────────────────────
/// <summary>
/// Сводка вкладки «Обработка» — форма GET /api/pipeline/stats: {queue: {new, ai, total}, rejected}.
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Счётчики очереди и число записей отсева (поллинг вкладки, SSE pipeline_stats не публикуем — Ruling 9).</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,23 @@
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Статусы строк очереди — колонка QueueItems.Status (pipeline.py ST_NEW/ST_AI L42–44).
/// </summary>
/// <remarks>
/// <c>new</c> — сообщение ждёт «new»-прохода воркера (правила/дедуп/ML, Ruling 8); <c>filtered</c> — прошло
/// «new»-проход и ждёт ИИ-классификации (в счётчиках очереди — bucket «ai», processing.queue_counts L207–215).
/// Значения 1:1 с wire-статусами api-map §4.5 L308 (<c>"new"|"filtered"</c>); строки QueueItems пишет приём
/// (Ruling 2: статус new), переводит в filtered воркер (Ruling 8).
/// </remarks>
public static class PipelineQueueStatuses
{
/// <summary>
/// Статус «ждёт правил/дедупа/ML» (pipeline.py ST_NEW L43).
/// </summary>
public const string New = "new";
/// <summary>
/// Статус «прошла этап 1, ждёт ИИ» (pipeline.py ST_AI L44; в счётчиках — «ai»).
/// </summary>
public const string Filtered = "filtered";
}
@@ -0,0 +1,84 @@
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Константы отсева: подписи этапов/источников и срок хранения (processing.py L26–46, Rulings 1/9).
/// </summary>
/// <remarks>
/// stageLabel — человекочитаемая подпись этапа для UI («почему отсеяно»); sourceLabel — «чьё» решение:
/// правила/ML/ИИ/система. Источники stale|dup оба подписываются «система» (Ruling 9: «системный отсев» сверх
/// stale/dup в прототипе отсутствует — roadmap-смысл покрыт этими источниками). Неизвестные значения подписи
/// возвращаются как есть (пустое — «отсев»/«система»), 1:1 с prototype stage_label/source_label L40–45.
/// <see cref="RetentionDays"/> — автоочистка отсева раз в 3 суток (processing.RETENTION_DAYS, Ruling 8).
/// </remarks>
public static class PipelineRejectConstants
{
/// <summary>
/// Срок жизни записи отсева в сутках (после — безвозвратная автоочистка PurgeExpiredAsync).
/// </summary>
public const int RetentionDays = 3;
/// <summary>
/// Словарь «этап → подпись» (stage → stageLabel), 1:1 с _STAGE_LABELS processing.py L26–34.
/// </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>
/// Словарь «источник → подпись» (source → sourceLabel), 1:1 с _SOURCE_LABELS processing.py L36–41.
/// </summary>
public static readonly IReadOnlyDictionary<string, string> SourceLabels =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["stop"] = "правила",
["ml"] = "ML",
["ai"] = "ИИ",
["stale"] = "система",
["dup"] = "система",
};
/// <summary>
/// Подпись этапа отсева: словарь, иначе сам stage; пустой stage — «отсев» (stage_label L40–42).
/// </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>
/// Подпись источника решения: словарь, иначе сам source; пустой source — «система» (source_label L44–45).
/// </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,154 @@
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application;
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;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Проверки воркера — partial-часть <see cref="PipelineWorkerService"/> (C32: выделено из общего файла,
/// поведение не менялось): срок актуальности (IsStale), фильтр «без суммы» (_skip_no_budget), слияние
/// ML-терминов в стек и сбой-безопасные вызовы ML/ИИ-фильтра.
/// </summary>
public sealed partial class PipelineWorkerService
{
// ── Проверки воркера ───────────────────────────────────────────────────
// Срок актуальности сообщения = срок до автоархива (python _stale_max_age L816–826): автоархив
// включён и msg_at старше archiveAfterDays суток → устарело. msg_at=0/пусто — не устарело. Флаги — из
// снимка прохода (читаются один раз на pump, не на каждое сообщение).
// row: Строка очереди (время исходного сообщения).
// run: Снимок настроек воркера (autoArchive/archiveAfterDays).
// Возвращает: True — сообщение старше срока до автоархива (не заводим в систему).
private bool IsStaleAsync(QueueItemDto row, WorkerRunSettings run)
{
if (row.MsgAtMs == 0)
{
return false; // python: not msg_at → False (L831–832)
}
if (!run.AutoArchive)
{
return false;
}
if (run.ArchiveAfterDays <= 0)
{
return false;
}
long maxAgeMs = run.ArchiveAfterDays * DayMs;
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
return nowMs - row.MsgAtMs > maxAgeMs;
}
// Глобальный фильтр «без суммы» (python _skip_no_budget L196–218): для типа заявки включён
// соответствующий флаг (budgetRequiredHire/Order) и суммы нет ни в разборе, ни в тексте → карточку не создаём.
// Суммой считаем нормализованный бюджет разбора либо сумму с валютой в тексте (AmountParser.Parse,
// rules_svc.extract_amounts). Claim дедупа при отсеве снимается — после выключения фильтра сообщение можно
// обработать заново (как python L1015).
// parsed: Разбор сообщения (тип заявки и бюджет).
// text: Исходный текст сообщения (fallback-источник суммы).
// run: Снимок настроек воркера (флаги budgetRequiredHire/Order).
// ct: Токен отмены.
// Возвращает: True — фильтр требует сумму, а её нет (сообщение пропускаем).
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);
}
// Докладывает узнанные ML термины в стек карточки (python L997–1011): непустые, ≥2 символов, без
// ведущей «~», не стоп-слова стека, не дубликаты — до MaxMlTermsAdded терминов.
// terms: Термины класса из ответа ML (dec.terms).
// stack: Стек локального разбора (к нему добавляем; лимит 12 доберёт CardComposer).
// Возвращает: Стек с добавленными терминами ML.
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;
}
// Предсказание ML с защитой от сбоя: недоступность сервиса → «не уверен» (решит ИИ; ml_client.predict L101–107).
// text: Текст сообщения.
// ct: Токен отмены.
// Возвращает: Решение модели либо фиксированный «не готов/не уверен».
private async Task<MlPredictResultDto> PredictSafelyAsync(string text, CancellationToken ct)
{
try
{
return await _mlClient.PredictAsync(text, ct);
}
catch (Exception)
{
return NotReadyPrediction;
}
}
// ИИ-фильтр с защитой от сбоя: сбой фильтра — «пропустить» (python L1102–1106).
// text: Текст сообщения.
// ct: Токен отмены.
// Возвращает: Решение фильтра либо пропуск при сбое.
private async Task<AiFilterResultDto> FilterSafelyAsync(string text, CancellationToken ct)
{
try
{
return await _aiClassifier.FilterAsync(text, ct);
}
catch (Exception)
{
return PassSkipped;
}
}
}
@@ -0,0 +1,71 @@
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application;
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;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// KV-счётчики решений — partial-часть <see cref="PipelineWorkerService"/> (C32: выделено из общего
/// файла, поведение не менялось): mlDecisions/aiDecisions после pump (track_decisions L153–157, Ruling 5).
/// </summary>
public sealed partial class PipelineWorkerService
{
// ── Счётчики решений KV (Ruling 5, ml_client.track_decisions L153–157) ──
// Инкрементирует KV-счётчики решений после pump: mlDecisions = mlStored+mlDrop, aiDecisions = aiStored+aiDrop.
// Read-modify-write через ISettingsStore (те же ключи читает LocalMlClient.StatusAsync —
// stats.ml/stats.ai вкладки «ML»). Нулевые приращения не пишутся (как python L154–157).
// state: Накопители результата pump.
// ct: Токен отмены.
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,68 @@
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Карточка и обучение ML — partial-часть <see cref="PipelineWorkerService"/> (C32: выделено из общего
/// файла, поведение не менялось): создание карточки через PipelineCardWriter и обучающие push-сигналы по
/// карточке ИИ-пути (Ruling 5, L1155–1180).
/// </summary>
public sealed partial class PipelineWorkerService
{
// ── Карточка и обучение ML ────────────────────────────────────────────
// Создаёт карточку (CardComposer + PipelineCardWriter: AddCard → LinkDedup → чтение) и снимает строку очереди.
// Dedup-claim НЕ удаляется: он уже связан с карточкой (IPipelineStore.LinkAsync в писателе;
// python _drop_row(row, with_dedup=False) L1021/L1057/L1092/L1152). Счётчик — mlStored (ML-путь, Ruling 5)
// либо aiStored (ИИ/локальный путь) — имена прототипа.
// state: Накопители результата pump.
// parsed: Разбор сообщения (поля карточки + назначенная колонка).
// row: Строка очереди (метаданные канала/текст/время).
// digest: SHA1-hex текста (claim уже заявлен и будет связан с карточкой).
// isAiPath: true — ИИ/локальный путь (aiStored); false — ML-ветка (mlStored).
// ct: Токен отмены.
// Возвращает: Созданная карточка (полный CardDto, для CreatedCards/обучения).
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;
}
// Обучающие сигналы ML по карточке ИИ-пути (L1155–1180): колонка-доска (не inbox/служебная,
// без активных правил и не-suggested) и тип (t:hire/t:order при is_vacancy_known).
// «Не знаю» (inbox) и служебные колонки не учим; доска с активными правилами/ИИ-предложение — тоже
// (ML в своём пути назначает только такие же свободные колонки — собираем аналогичные примеры).
// card: Созданная карточка (реальная колонка после ContainerAccepts-страховки).
// parsed: Разбор, на котором собрана карточка (тип/спам из классификатора).
// text: Текст сообщения (обучающий пример — как source_msg карточки).
// ct: Токен отмены.
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,336 @@
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application;
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;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Проход pump — partial-часть <see cref="PipelineWorkerService"/> (C32: выделено из общего файла,
/// поведение не менялось): PumpOnceAsync и «new»/«filtered»-проходы батчей (pump_once L890–1183, Ruling 8).
/// </summary>
public sealed partial class PipelineWorkerService
{
// ── Проход воркера ─────────────────────────────────────────────────────
/// <summary>
/// Один проход по очереди: батч «new» (правила/дедуп/ML) → батч «filtered» (ИИ) (pump_once L890–917).
/// </summary>
/// <remarks>
/// Порядок и счётчики 1:1 с <c>_pump_unlocked</c> L920–1183. Строки, переведённые в filtered «new»-проходом,
/// видит «filtered»-батч того же вызова (как в прототипе: запросы на общем соединении). Публикацию new_card
/// воркер НЕ делает — она живёт в Api-слое (Ruling 8/9: из PumpOnce созданные карточки возвращаются в
/// <see cref="PipelinePumpResult.CreatedCards"/>, публикует вызывающий — admin/tick или фоновый цикл T11).
/// Исключения (хранилище/писатель) пробрасываются наружу — как в прототипе, очередь останавливается до
/// следующего тика, а упавшая строка остаётся в очереди со своим dedup-claim.
/// </remarks>
/// <param name="ct">Токен отмены.</param>
/// <returns>Сводка прохода: счётчики решений + созданные карточки (SSE new_card).</returns>
public async Task<PipelinePumpResult> PumpOnceAsync(CancellationToken ct)
{
// Настройки воркера читаются ОДИН раз на pump (Ruling 8): переопределения тенанта — одним GetAllAsync,
// значения/дефолты резолвятся в снимок, который передаётся во все проверки сообщений батча и
// LocalFieldsParser (раньше настройки читались на каждое сообщение — до 16 чтений таблицы за pump).
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();
}
// ── «new»-проход: правила → дедуп → ML (python L923–1065) ──────────────
// Батч сообщений статуса new: устарело/правила/повтор отсекаются, остальные — ML или в filtered.
// Настройки (сроки/флаги ML/ИИ/бюджета) — из снимка прохода: читаются один раз, не на каждое сообщение.
// state: Накопители результата pump (счётчики/карточки).
// run: Снимок настроек воркера на проход (Ruling 8).
// ct: Токен отмены.
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;
// Устарело: только не force; старше срока до автоархива — не заводим в систему (L929–932, L836–844).
if (!force && IsStaleAsync(row, run))
{
await DropStaleAsync(row, run, ct);
continue;
}
// Этап-1 правила: длина → стоп-фразы → резюме → тип (L933–939; source=stop, stage=kind).
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;
}
// Глобальные исключения тенанта (§5.14): стоп ДО дедупа/ML/ИИ, чтобы не тратить токены;
// причина называет конкретное сработавшее исключение (source=stop).
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;
}
}
// Дедуп по нормализованному тексту: повтор — отсев «система»; иначе заявляем хэш (L940–951).
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;
}
// Claim атомарен (INSERT … ON CONFLICT DO NOTHING): false — хэш уже заявлен ПАРАЛЛЕЛЬНЫМ проходом
// pump после нашей проверки ExistsAsync. Карточку не создаём (иначе два прохода завели бы две) —
// отсев «повтор» + снятие строки; чужую заявку НЕ удаляем (свяжется с карточкой победителя, Ruling 8).
if (!await _store.ClaimAsync(digest, ct))
{
await RejectRowAsync(row, SourceDup, StageDup, DupReason, string.Empty, ct);
await _store.RemoveAsync(row.Id, ct);
continue;
}
state.Staged++;
// ML-слот (Ruling 5): включён (mlEnabled не false) и не force — force идёт мимо ML к ИИ (L963–965).
if (run.MlEnabled && !force)
{
MlPredictResultDto decision = await PredictSafelyAsync(row.Text, ct);
string? label = decision.Label;
// Модель готова и уверена: spam → отсев; доска без правил/не-suggested → карточка (L969–1025).
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;
}
}
// ML уверен в типе (t:hire/t:order), даже если колонку не назначил (L1026–1061).
if (decision.Ready && decision.Type is { Take: true } typeDecision)
{
bool isHire = typeDecision.Label == MlLearningLabels.TypeHireLabel;
string wanted = run.WantedType;
// Тип не под режим «что собираем» — не тратим ИИ (typeDrop, L1029–1042).
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;
}
}
// ИИ выключен, но тип ML знает — карточка сама (inbox; L1043–1061).
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;
}
}
}
// ML не решил — сообщение ждёт ИИ (L1062–1065).
await _store.SetStatusAsync(row.Id, PipelineQueueStatuses.Filtered, ct);
}
}
// ── «filtered»-проход: ИИ-фильтр → классификация → карточка/отсев (L1067–1180) ──
// Батч сообщений статуса filtered: локальный путь (aiEnabled=false) или фильтр+классификация ИИ.
// Настройки — из снимка прохода (читаются один раз, не на каждое сообщение).
// state: Накопители результата pump (счётчики/карточки).
// run: Снимок настроек воркера на проход (Ruling 8).
// 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;
// Устарело: как в «new»-проходе, только не force (L1073–1076).
if (!force && IsStaleAsync(row, run))
{
await DropStaleAsync(row, run, ct);
continue;
}
string text = row.Text;
string digest = DedupHasher.Hash(text);
// ИИ выключен: классификацию/фильтр не зовём — карточку собирает локальный разбор (L1079–1096).
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;
}
// ИИ-фильтр (этап 2): возврат из отсева фильтр не пересматривает; выключен/сбой — пропуск (L1097–1106).
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)
{
// Классификатор недоступен/сбой — как raw={} в прототипе (L1112–1114): локальный разбор, aiFail.
parsed = null;
}
// python L1108–1111: успешная классификация ИИ — тип определён по контексту (не маркерной
// эвристикой). Стемп ставится ДО создания карточки (карточка получает is_vacancy_known=true) и
// переживает force-отмену вердикта «спам»; локальный разбор aiFail/aiEnabled=false стемпа не имеет.
if (parsed is not null)
{
parsed = parsed with { IsVacancyKnown = true };
}
}
// Вердикт «спам»: не прошёл фильтр либо классификатор пометил мусором (L1116–1121). Возврат (force)
// отменяет вердикт — пользователь уже подтвердил релевантность, карточку создаём без обучения «спаму».
bool isSpam = !filter.Pass || (parsed?.IsSpam ?? false);
if (force && filter.Pass && parsed?.IsSpam == true)
{
parsed = parsed with { IsSpam = false };
isSpam = false;
}
if (isSpam)
{
// Отсев spam_ai / filter_ai + обучение ML отличать спам (вес гипотезы, L1122–1138).
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)
{
// ИИ не дал разбора (сбой), но не спам — локальный разбор (L1139–1142).
parsed = AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text);
state.AiFail++;
}
// Глобальный фильтр «без суммы» (не force; Ruling 8, L1143–1147).
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,74 @@
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application;
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;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Отсев и удаление строк — partial-часть <see cref="PipelineWorkerService"/> (C32: выделено из общего
/// файла, поведение не менялось): запись отсева (RejectRowAsync), удаление строки очереди со снятием claim
/// и отсев «устарело» (_drop_row/_drop_stale, Ruling 8).
/// </summary>
public sealed partial class PipelineWorkerService
{
// ── Отсев / удаление строк ─────────────────────────────────────────────
// Пишет запись отсева по строке очереди (источник/этап/причина/фраза; обработка.record, Ruling 8).
// row: Строка очереди — источник полей записи (текст/канал/время).
// source: Источник решения: stop|ml|ai|stale|dup.
// stage: Этап отсева: length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup.
// reason: Причина (текст 1:1 с прототипом; режется сервисом до 500).
// kw: Сработавшая фраза/маркер правила (пусто — не правило).
// ct: Токен отмены.
private Task RejectRowAsync(QueueItemDto row, string source, string stage, string reason, string kw, CancellationToken ct)
{
return _processing.RejectAsync(new RejectRecord
{
DialogId = row.DialogId,
MsgId = row.MsgId,
Text = row.Text,
ChannelName = row.Channel.Name,
ChannelHandle = row.Channel.Handle,
ChannelHue = row.Channel.Hue,
MsgAtMs = row.MsgAtMs,
Source = source,
Stage = stage,
Reason = reason,
Kw = kw,
}, ct);
}
// Удаляет строку очереди (отсев на любом этапе; python _drop_row L809–814).
// row: Строка очереди.
// digest: Хэш текста (для снятия незанятого dedup-claim).
// withDedup: Снять claim дедупа (true — сообщение можно обработать заново после отсева).
// 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);
}
// Отсев «устарело»: запись + удаление строки, карточка НЕ создаётся (python _drop_stale L836–844).
// row: Строка очереди со старым msg_at.
// run: Снимок настроек воркера (срок до автоархива уже прочитан).
// 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,96 @@
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application;
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;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Снимок настроек прохода — partial-часть <see cref="PipelineWorkerService"/> (C32: выделено из общего
/// файла, поведение не менялось): WorkerRunSettings + один GetAllAsync на pump и резолв дефолтов.
/// </summary>
public sealed partial class PipelineWorkerService
{
// ── Снимок настроек на проход pump (Ruling 8/C30: один GetAllAsync вместо чтений на каждое сообщение) ──
// Снимок настроек воркера на один проход pump: значения прочитаны ОДИН раз (LoadRunSettingsAsync)
// и передаются во все проверки сообщений батча и в LocalFieldsParser — раньше настройки читались на каждое
// сообщение (ReadBool/ReadInt + GetAllAsync парсера — до 16 чтений таблицы за pump). Значения — из
// типизированного снимка TenantSettingsSnapshot (C30).
// Settings: Типизированный снимок переопределений тенанта (передаётся LocalFieldsParser).
// AutoArchive: Флаг автоархива (срок актуальности сообщения).
// ArchiveAfterDays: Срок до автоархива, сутки (stale/причина отсева).
// MlEnabled: ML-слот включён (не false — прогноз/решения).
// AiEnabled: ИИ-слот включён (классификация/фильтр).
// AiFilterEnabled: ИИ-фильтр включён (этап 2).
// BudgetRequiredHire: Фильтр «без суммы» для найма.
// BudgetRequiredOrder: Фильтр «без суммы» для разовых заказов.
// WantedType: Тип заявок «что собираем» (both|vacancy|freelance).
// GlobalExclusions: Снимок глобальных исключений (стоп до ML/ИИ, §5.14).
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));
}
// Собирает снимок глобальных исключений тенанта (§5.14): списки слов/локаций/типов и границы бюджета.
// Граница бюджета 0 в настройках трактуется как «не задана» (null); сравнение сумм — числовое,
// без конвертации валют (у настройки нет валюты).
// 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);
}
// wantedType: тип заявок (both|vacancy|freelance) в нижнем регистре (python L1030); пустая/отсут-
// ствующая/повреждённая строка → дефолт «both» (даёт GetString с дефолтом + нормализация).
// value: Значение настройки wantedType (JSON-строка либо дефолт из SettingsDefaults).
// Возвращает: Значение в нижнем регистре (пустое хранимое значение остаётся пустым — как wantedType="").
private static string NormalizeWantedType(string value)
{
return value.Trim().ToLowerInvariant();
}
}
@@ -0,0 +1,88 @@
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application;
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;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Накопители результата pump — partial-часть <see cref="PipelineWorkerService"/> (C32: выделено из
/// общего файла, поведение не менялось): приватный PumpState (счётчики словаря python L921 + карточки).
/// </summary>
public sealed partial class PipelineWorkerService
{
// ── Накопители результата pump (счётчики и карточки одного прохода) ──
// Мутабельные накопители одного прохода pump: счётчики словаря python L921 + созданные карточки.
private sealed class PumpState
{
/// <summary>
/// Прошли «new»-проход и переведены в filtered (L952).
/// </summary>
public int Staged { get; set; }
/// <summary>
/// Карточек создано ML-веткой (mlStored).
/// </summary>
public int MlStored { get; set; }
/// <summary>
/// Отсевов решением ML «спам» (mlDrop).
/// </summary>
public int MlDrop { get; set; }
/// <summary>
/// Отсевов «тип не под режим» по решению ML (typeDrop).
/// </summary>
public int TypeDrop { get; set; }
/// <summary>
/// Карточек создано ИИ-веткой/локальным путём (aiStored).
/// </summary>
public int AiStored { get; set; }
/// <summary>
/// Отсевов решением ИИ: spam_ai/filter_ai (aiDrop).
/// </summary>
public int AiDrop { get; set; }
/// <summary>
/// Сообщений, где ИИ не дал разбора — собран локальный разбор (aiFail).
/// </summary>
public int AiFail { get; set; }
/// <summary>
/// Отсевов фильтром «без суммы» (noBudget).
/// </summary>
public int NoBudget { get; set; }
/// <summary>
/// Карточки, созданные за проход (порядок создания; SSE new_card).
/// </summary>
public List<CardDto> Created { get; } = [];
/// <summary>
/// Собирает неизменяемый результат прохода (счётчики 1:1 со словарём python L921).
/// </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,196 @@
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application;
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;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Воркер разбора очереди входящих — один проход pump (Ruling 8; прототип _pump_unlocked L920–1183, порядок строго 1:1).
/// </summary>
/// <remarks>
/// Чистый оркестратор модуля (без EF/HTTP/циклов): <see cref="PumpOnceAsync"/> забирает батч «new» (лимит 12) и
/// «filtered» (лимит 4) и двигает сообщение по конвейеру: <c>стоп-фразы → дедуп → ML → ИИ → карточка/отсев</c>.
/// <para>
/// «new»-проход (Ruling 8 L928–1065): force? → stale (только не force; msgAt старше archiveAfterDays суток при
/// autoArchive=true → отсев «устарело» без карточки) → <see cref="IncomingRules"/> (не прошёл → отсев правил) →
/// дедуп по тексту (<see cref="DedupHasher"/>; хэш в системе → отсев «повтор»; иначе claim) → ML-слот
/// (Ruling 5: mlEnabled не false и не force → <see cref="IMlClient.PredictAsync"/>; готовая и уверенная модель
/// решает: spam → отсев spam_ml; доска без активных правил и не-suggested → карточка в доску; тип → typeDrop по
/// wantedType либо карточка inbox при aiEnabled=false) → не решено → статус filtered.
/// </para>
/// <para>
/// «filtered»-проход (L1067–1180): stale → aiEnabled=false → локальный разбор (<see cref="AiCardMapper"/>) и
/// карточка inbox; иначе force → пропуск ИИ-фильтра (возврат из отсева, L1097–1100), aiFilterEnabled=false →
/// фильтр пропущен; фильтр (<see cref="IAiClassifier.FilterAsync"/>, сбой → пропуск) → классификация
/// (<see cref="IAiClassifier.ClassifyAsync"/>; сбой → локальный разбор, aiFail) — успешная классификация
/// подтверждает тип: is_vacancy_known=true (python L1108–1111, карточка и обучающий push получают known) →
/// вердикт «спам» (сила отменяет
/// только для force, L1117–1121) → отсев spam_ai/filter_ai + обучение ML «спам» (вес 0.4) → no-budget (не force)
/// → карточка (<see cref="PipelineCardWriter"/>, col по ContainerAccepts/правилам, иначе inbox) + обучение ML по
/// колонке/типу (0.4). Результат — <see cref="PipelinePumpResult"/> (+CreatedCards для SSE); счётчики решений
/// mlDecisions/aiDecisions инкрементируются после pump (Ruling 5: mlStored+mlDrop / aiStored+aiDrop, через
/// KV read-modify-write). Исключения хранилища/писателя пробрасываются вызывающему (фоновый цикл Api логирует и
/// продолжит на следующем тике; строка остаётся в очереди с claim — как прототип).
/// </para>
/// C32: класс разделён на partial-файлы по темам (Pump/Learning/Rejections/Checks/Decisions/Settings/State.cs);
/// поведение, сигнатуры и тексты ошибок не менялись.
/// </remarks>
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 (DI-зависимости прохода).
/// </summary>
/// <param name="store">Хранилище очереди/отсева/дедупа (порт IPipelineStore).</param>
/// <param name="settings">KV-настройки тенанта (флаги/сроки воркера, Ruling 8).</param>
/// <param name="rules">Этап-1 правила фильтра входящих (длина/стоп-фразы/резюме/тип, Settings).</param>
/// <param name="kanjStore">Порт канбана: доски (проверка allowed-колонок ML/обучения) и чтение карточки.</param>
/// <param name="mlClient">Порт ML-сервиса: predict (решения «решил сам») и push (обучение).</param>
/// <param name="aiClassifier">Порт ИИ: фильтр и классификация (этап 4 — LocalAiClassifier).</param>
/// <param name="processing">Запись отсева (RejectAsync) и обслуживание вкладки «Обработка».</param>
/// <param name="cardWriter">Создание карточки через публичный интерфейс Kanban + связь дедупа (Ruling 3/4).</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;
}
// ── Лимиты батчей pump (прототип pump_once new_limit=12 / ai_limit=4, L890) ──
// Лимит «new»-прохода: сколько сообщений за проход проходят правила/дедуп/ML.
private const int NewBatchLimit = 12;
// Лимит «filtered»-прохода: сколько сообщений за проход идут на ИИ (дорогой шаг).
private const int FilteredBatchLimit = 4;
// Сутки в миллисекундах (срок актуальности сообщения, Ruling 8).
private const long DayMs = 86_400_000;
// ── Источники/этапы отсева (RejectRecord; словари подписей — PipelineRejectConstants) ──
// Источник «правила» (этап-1 фильтр/no-budget).
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";
// Этап отсева «повтор» / «устарело» / «нет суммы» (словарь подписей Ruling 1).
private const string StageDup = "dup";
private const string StageStale = "stale";
private const string StageBudget = "budget";
private const string StageType = "type";
// ── Обучение ML (ml_client.py L26–27: AI_WEIGHT=0.4 — MlLearningLabels.AiPushWeight; метки L47/L1177–1178) ──
// Максимум терминов ML, докладываемых в стек карточки (python L1010: added &gt;= 4 → break).
private const int MaxMlTermsAdded = 4;
// ── Типы заявок wantedType (строка настройки, как у IncomingRules) ──
// wantedType: только вакансии/занятость.
private const string WantedTypeVacancy = "vacancy";
// wantedType: только разовые заказы.
private const string WantedTypeFreelance = "freelance";
// ── Фиксированные строки причин (1:1 с прототипом) ──
// Причина отсева «устарело» (python _drop_stale L841–843: старше срока до автоархива).
private const string StaleReasonFormat = "сообщение старше {0} дн. (срок до автоархива) — не заводим в систему";
// Причина отсева «повтор» (python L942–945).
private const string DupReason = "сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается";
// Причина no-budget фильтра (python _reject_no_budget L853–858).
private const string NoBudgetReason = "включён фильтр «не создавать карточку без суммы» — в тексте не указан бюджет";
// Причина отсева ML-спама: «(score 0.90)» — вес класса spam (python L973–976).
private const string MlSpamReasonFormat = "ML уверен, что это спам/не заявка (score {0})";
// Причина typeDrop ML: тип не под режим «что собираем» (python L1037–1039).
private const string MlTypeDropReasonFormat = "ML: тип «{0}», а вы ищете только «{1}»";
// Подпись типа «найм/занятость» в причине typeDrop (python L1038).
private const string MlHireKindName = "найм/занятость";
// Подпись типа «разовые заказы» в причине typeDrop (python L1038).
private const string MlOrderKindName = "разовые заказы";
// Причина отсева «спам (ИИ)» (python L1131–1134).
private const string AiSpamReason = "ИИ: не заявка — спам, реклама, скам или служебное сообщение";
// Префикс причины отсева «ИИ-фильтр» (python L1126–1129).
private const string AiFilterReasonPrefix = "ИИ-фильтр: ";
// Дефолтная причина ИИ-фильтра, если фильтр не дал свою (python L1128).
private const string AiFilterDefaultReason = "сообщение не относится к вашим интересам";
// Ответ «не готова/не уверена» неготовая модель (LocalMlClient ready:false → predict, Ruling 5).
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);
// Ответ «фильтр пропущен» (force/выключен/сбой: python L1097–1106).
private static readonly AiFilterResultDto PassSkipped = new(Pass: true, Reason: null, Skipped: true);
}
@@ -0,0 +1,28 @@
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Single-flight-замок ручной переклассификации: одна переклассификация за раз (как фоновая задача прототипа).
/// </summary>
/// <remarks>
/// Регистрируется singleton (состояние общее для всех tenant-запросов процесса). Переклассификация выполняется
/// синхронно в запросе; если проход уже идёт, второй вызов получает <c>busy</c> без ожидания блокировки —
/// <see cref="TryEnter"/> не блокирует поток.
/// </remarks>
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();
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Deal.SharedKernel\Deal.SharedKernel.csproj" />
<ProjectReference Include="..\Deal.Contracts\Deal.Contracts.csproj" />
<ProjectReference Include="..\Deal.Modules.Settings\Deal.Modules.Settings.csproj" />
<ProjectReference Include="..\Deal.Modules.Kanban\Deal.Modules.Kanban.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.11" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,8 @@
namespace Deal.Modules.Pipeline;
/// <summary>
/// Маркер модуля Pipeline: используется для DI-сканирования и тестов.
/// </summary>
public sealed class PipelineModuleMarker
{
}