Вынести магические строки статусов, видов и ключей в каталоги

Литералы статусов/видов/фаз и wire-ключей заменены каталогами: значения сущностей и репозиториев — TenantStatuses/TenantLimitPeriods/InviteStatuses/ContainerKinds/ContainerSpaces/CardIds/Discovery*/PipelineQueueStatuses; контакты и виды вложений — CardContactTypes/CardFileKinds; источники и этапы отсева — PipelineRejectStages/PipelineRejectSources; ключи промптов/провайдеров — SettingsFieldKeys; wire-ключи канбана — KanbanWireKeys; режимы правил — ColumnRuleModes; операции/фазы/виды диалогов/контакты Telegram и общий BoolText — в Deal.Contracts/Deal.SharedKernel; имена метрик — DealMetrics. Значения не менялись.
This commit is contained in:
2026-09-13 20:51:36 +03:00
parent 8dde35de49
commit 1fbe29a838
61 changed files with 907 additions and 167 deletions
@@ -0,0 +1,107 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Ключи JSON-схемы разбора карточки от ИИ
/// </summary>
public static class AiRawCardKeys
{
/// <summary>
/// Заголовок
/// </summary>
public const string Title = "title";
/// <summary>
/// Колонка/доска
/// </summary>
public const string Board = "board";
/// <summary>
/// Компания
/// </summary>
public const string Company = "company";
/// <summary>
/// Формат работы
/// </summary>
public const string Format = "format";
/// <summary>
/// О задаче
/// </summary>
public const string Task = "task";
/// <summary>
/// Требования
/// </summary>
public const string Requirements = "requirements";
/// <summary>
/// Плюсы
/// </summary>
public const string Plus = "plus";
/// <summary>
/// Условия
/// </summary>
public const string Conditions = "conditions";
/// <summary>
/// Краткое описание
/// </summary>
public const string Summary = "summary";
/// <summary>
/// Стек
/// </summary>
public const string Stack = "stack";
/// <summary>
/// Бюджет
/// </summary>
public const string Budget = "budget";
/// <summary>
/// Контакты
/// </summary>
public const string Contacts = "contacts";
/// <summary>
/// Признак вакансии
/// </summary>
public const string IsVacancy = "is_vacancy";
/// <summary>
/// Признак определённого типа заявки
/// </summary>
public const string IsVacancyKnown = "is_vacancy_known";
/// <summary>
/// Признак спама
/// </summary>
public const string IsSpam = "is_spam";
/// <summary>
/// Значение контакта
/// </summary>
public const string Value = "value";
/// <summary>
/// Валюта бюджета
/// </summary>
public const string Currency = "currency";
/// <summary>
/// Короткий код валюты бюджета
/// </summary>
public const string CurrencyShort = "cur";
/// <summary>
/// Нижняя граница бюджета
/// </summary>
public const string From = "from";
/// <summary>
/// Верхняя граница бюджета
/// </summary>
public const string To = "to";
}
@@ -16,20 +16,20 @@ public static class PipelineRejectConstants
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"] = "повтор",
[PipelineRejectStages.Length] = "короткое сообщение",
[PipelineRejectStages.Stop] = "стоп-фраза",
[PipelineRejectStages.Resume] = "резюме соискателя",
[PipelineRejectStages.Type] = "тип заявки",
[PipelineRejectStages.Budget] = "нет суммы",
[PipelineRejectStages.ExcludeKeywords] = "исключение: слова/технологии",
[PipelineRejectStages.ExcludeLocation] = "исключение: локация/язык",
[PipelineRejectStages.ExcludeType] = "исключение: тип",
[PipelineRejectStages.ExcludeBudget] = "исключение: бюджет",
[PipelineRejectStages.Stale] = "устарело",
[PipelineRejectStages.SpamMl] = "спам (ML)",
[PipelineRejectStages.SpamAi] = "спам (ИИ)",
[PipelineRejectStages.FilterAi] = "ИИ-фильтр",
[PipelineRejectStages.Duplicate] = "повтор",
};
/// <summary>
@@ -38,11 +38,11 @@ public static class PipelineRejectConstants
public static readonly IReadOnlyDictionary<string, string> SourceLabels =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["stop"] = "правила",
["ml"] = "ML",
["ai"] = "ИИ",
["stale"] = "система",
["dup"] = "система",
[PipelineRejectSources.Rules] = "правила",
[PipelineRejectSources.Ml] = "ML",
[PipelineRejectSources.Ai] = "ИИ",
[PipelineRejectSources.System] = "система",
[PipelineRejectSources.Duplicate] = "система",
};
/// <summary>
@@ -0,0 +1,32 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Источники решения об отсеве
/// </summary>
public static class PipelineRejectSources
{
/// <summary>
/// Правила входящих (стоп-фразы, тип и т.п.)
/// </summary>
public const string Rules = "stop";
/// <summary>
/// Локальная ML-модель
/// </summary>
public const string Ml = "ml";
/// <summary>
/// ИИ-фильтр
/// </summary>
public const string Ai = "ai";
/// <summary>
/// Система (устаревание, дубликаты)
/// </summary>
public const string System = "system";
/// <summary>
/// Дубликат
/// </summary>
public const string Duplicate = "dup";
}
@@ -0,0 +1,77 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Этапы отсева входящих сообщений
/// </summary>
public static class PipelineRejectStages
{
/// <summary>
/// Слишком короткое сообщение
/// </summary>
public const string Length = "length";
/// <summary>
/// Стоп-фраза
/// </summary>
public const string Stop = "stop";
/// <summary>
/// Резюме соискателя
/// </summary>
public const string Resume = "resume";
/// <summary>
/// Неподходящий тип заявки
/// </summary>
public const string Type = "type";
/// <summary>
/// Нет суммы
/// </summary>
public const string Budget = "budget";
/// <summary>
/// Исключение по словам/технологиям
/// </summary>
public const string ExcludeKeywords = "exclude_kw";
/// <summary>
/// Исключение по локации/языку
/// </summary>
public const string ExcludeLocation = "exclude_location";
/// <summary>
/// Исключение по типу
/// </summary>
public const string ExcludeType = "exclude_type";
/// <summary>
/// Исключение по бюджету
/// </summary>
public const string ExcludeBudget = "exclude_budget";
/// <summary>
/// Устаревшее сообщение
/// </summary>
public const string Stale = "stale";
/// <summary>
/// Спам по ML
/// </summary>
public const string SpamMl = "spam_ml";
/// <summary>
/// Спам по ИИ
/// </summary>
public const string SpamAi = "spam_ai";
/// <summary>
/// Отсев ИИ-фильтром
/// </summary>
public const string FilterAi = "filter_ai";
/// <summary>
/// Дубликат
/// </summary>
public const string Duplicate = "dup";
}
@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Pipeline.Application.Parse;
@@ -65,12 +66,12 @@ public static class ContactsQualifier
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,
[CardContactTypes.Telegram] = 0,
[CardContactTypes.Phone] = 1,
[CardContactTypes.WhatsApp] = 2,
[CardContactTypes.Email] = 3,
[CardContactTypes.LinkedIn] = 4,
[CardContactTypes.Site] = 5,
};
private const int UnknownTypePriority = 9;
@@ -93,7 +94,7 @@ public static class ContactsQualifier
string name = s[1..].Trim();
if (ProfileHandleRe.IsMatch(name) && !name.EndsWith(BotSuffix, StringComparison.OrdinalIgnoreCase))
{
return new CardContactDto("tg", "@" + name);
return new CardContactDto(CardContactTypes.Telegram, "@" + name);
}
return null;
@@ -106,7 +107,7 @@ public static class ContactsQualifier
if (!ReservedProfileNames.Contains(name.ToLowerInvariant())
&& !name.EndsWith(BotSuffix, StringComparison.OrdinalIgnoreCase))
{
return new CardContactDto("tg", "@" + name);
return new CardContactDto(CardContactTypes.Telegram, "@" + name);
}
return null;
@@ -114,23 +115,23 @@ public static class ContactsQualifier
if (EmailRe.IsMatch(s))
{
return new CardContactDto("email", s.ToLowerInvariant());
return new CardContactDto(CardContactTypes.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);
return new CardContactDto(CardContactTypes.Phone, (s.StartsWith('+') ? "+" : string.Empty) + digits);
}
if (LinkedinRe.IsMatch(s))
{
return new CardContactDto("linkedin", s);
return new CardContactDto(CardContactTypes.LinkedIn, s);
}
if (WhatsappRe.IsMatch(s))
{
return new CardContactDto("whatsapp", s);
return new CardContactDto(CardContactTypes.WhatsApp, s);
}
if (s.StartsWith("http", StringComparison.OrdinalIgnoreCase))
@@ -144,7 +145,7 @@ public static class ContactsQualifier
return null;
}
return new CardContactDto("site", s);
return new CardContactDto(CardContactTypes.Site, s);
}
return null;
@@ -4,7 +4,9 @@ using System.Text.Json.Nodes;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.SharedKernel.Utilities;
namespace Deal.Modules.Pipeline.Application.Services;
@@ -19,9 +21,9 @@ public static class AiRawCardMapper
private const string BudgetAmountPattern = @"[0-9.,]+\s*[кkКK]?";
private const string CurrencyFieldName = "currency";
private const string CurrencyFieldNameShort = "cur";
// Строковые значения bool-полей, которые считаются «ложью».
private static readonly IReadOnlySet<string> FalsyAnswerValues =
new HashSet<string>(StringComparer.Ordinal) { "0", BoolText.False, "no", "нет", "null", "none" };
// Регулярное выражение числа бюджета: число + необязательные «к»/валюта-суффиксы.
private static readonly System.Text.RegularExpressions.Regex BudgetNumRe =
@@ -39,7 +41,7 @@ public static class AiRawCardMapper
JsonObject? root = JsonNode.Parse(json) as JsonObject
?? throw new JsonException("Ответ ИИ-классификатора — не JSON-объект (ok=true, но схема нарушена).");
string title = MessageTextCleaner.CleanShort(ReadRawString(root, "title"), MaxTitleCodePoints);
string title = MessageTextCleaner.CleanShort(ReadRawString(root, AiRawCardKeys.Title), MaxTitleCodePoints);
if (title.Length == 0)
{
title = MessageTextCleaner.CleanShort(text, MaxFallbackTextCodePoints); // python L455 fallback
@@ -49,7 +51,7 @@ public static class AiRawCardMapper
AiBudgetDto? budget = ReadBudget(root);
IReadOnlyList<AiContactDto> contacts = ReadContacts(root, text);
string? board = ReadRawString(root, "board").Trim();
string? board = ReadRawString(root, AiRawCardKeys.Board).Trim();
if (board.Length == 0)
{
board = null;
@@ -57,19 +59,19 @@ public static class AiRawCardMapper
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"),
Company: ReadNullableString(root, AiRawCardKeys.Company),
Format: ReadNullableString(root, AiRawCardKeys.Format),
Task: ReadNullableString(root, AiRawCardKeys.Task),
Requirements: ReadItems(root, AiRawCardKeys.Requirements),
Plus: ReadItems(root, AiRawCardKeys.Plus),
Conditions: ReadNullableString(root, AiRawCardKeys.Conditions),
Summary: ReadNullableString(root, AiRawCardKeys.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"),
IsVacancy: ReadBool(root, AiRawCardKeys.IsVacancy),
IsVacancyKnown: ReadBool(root, AiRawCardKeys.IsVacancyKnown), // false — воркер стемпит true после успешного ИИ
IsSpam: ReadBool(root, AiRawCardKeys.IsSpam),
Board: board);
}
@@ -114,7 +116,7 @@ public static class AiRawCardMapper
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 lower.Length > 0 && !FalsyAnswerValues.Contains(lower);
}
return false;
@@ -147,7 +149,7 @@ public static class AiRawCardMapper
private static IReadOnlyList<string> ReadStack(JsonObject root)
{
if (!root.TryGetPropertyValue("stack", out JsonNode? node) || node is null)
if (!root.TryGetPropertyValue(AiRawCardKeys.Stack, out JsonNode? node) || node is null)
{
return Array.Empty<string>();
}
@@ -161,19 +163,19 @@ public static class AiRawCardMapper
private static AiBudgetDto? ReadBudget(JsonObject root)
{
if (!root.TryGetPropertyValue("budget", out JsonNode? node) || node is not JsonObject budget)
if (!root.TryGetPropertyValue(AiRawCardKeys.Budget, out JsonNode? node) || node is not JsonObject budget)
{
return null;
}
string currency = ReadRawString(budget, CurrencyFieldName);
string currency = ReadRawString(budget, AiRawCardKeys.Currency);
if (currency.Length == 0)
{
currency = ReadRawString(budget, CurrencyFieldNameShort); // ai.py L327: currency или cur
currency = ReadRawString(budget, AiRawCardKeys.CurrencyShort); // ai.py L327: currency или cur
}
double? from = ParseBudgetBound(budget, "from");
double? to = ParseBudgetBound(budget, "to");
double? from = ParseBudgetBound(budget, AiRawCardKeys.From);
double? to = ParseBudgetBound(budget, AiRawCardKeys.To);
CardBudgetDto? normalized = BudgetNormalizer.Normalize(new BudgetRangeDto(from, to, currency));
return normalized is null
? null
@@ -226,7 +228,7 @@ public static class AiRawCardMapper
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 (root.TryGetPropertyValue(AiRawCardKeys.Contacts, out JsonNode? node) && node is not null)
{
if (node is JsonValue)
{
@@ -245,7 +247,7 @@ public static class AiRawCardMapper
break;
case JsonObject contactObject:
{
string value = ReadRawString(contactObject, "value"); // python L402403: dict.get("value")
string value = ReadRawString(contactObject, AiRawCardKeys.Value);
if (value.Length > 0)
{
candidates.Add(value);
@@ -220,7 +220,7 @@ public sealed class MlReviewService(
{
// 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);
return new MlApplyResult(null, Ok: true, Learned: true, Moved: CardIds.Trash, LeadId: trashed?.Id ?? card.Id);
}
await mlClient.PushAsync(text, MlLearningLabels.Spam, UserPushWeight, ct);
@@ -47,16 +47,16 @@ public sealed class PipelineProcessingService(
// Вес снятия метки «спам»: реальное действие пользователя «это не спам» (delta=1.0).
private const double SpamUnlearnDelta = -1.0;
private const string DuplicateSource = "dup";
private const string DuplicateSource = PipelineRejectSources.Duplicate;
// Имя сущности для текста ошибки «не найдено».
private const string RejectedEntityName = "Запись отсева";
private static readonly HashSet<string> SpamStages = new(StringComparer.Ordinal)
{
"spam_ml",
"spam_ai",
"filter_ai",
PipelineRejectStages.SpamMl,
PipelineRejectStages.SpamAi,
PipelineRejectStages.FilterAi,
};