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

Первый коммит: модульный монолит ядра (.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,40 @@
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Pipeline.Application.Parse;
/// <summary>
/// Fallback бюджета карточки, когда разбор не выделил бюджет отдельным полем: первая сумма с валютой из
/// исходника или из структурированной «О заявке» (pipeline.py L459468).
/// </summary>
/// <remarks>
/// ИИ не всегда выделяет бюджет отдельным полем, но сумма с валютой есть в тексте сообщения либо ушла в блок
/// «Условия» структурированной сути — показываем её на карточке. Тот же источник, что и фильтр «не создавать
/// карточку без суммы» (Ruling 4: AmountParser модуля Kanban, извлечение — только суммы с валютой).
/// Порядок источников 1:1 с прототипом: сначала текст сообщения, затем summary; берётся первый распознанный
/// диапазон. Возвращается форма хранения (валюта-код) — дальнейшую нормализацию делает вызывающий
/// (BudgetNormalizer.Normalize, CardComposer).
/// </remarks>
public static class AmountRangeBudgetFallback
{
/// <summary>
/// Ищет бюджет-«заглушку»: первую сумму с валютой среди источников (python L463468).
/// </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 L344430, _norm_phone L661663,
/// _contacts_from L666679).
/// </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 L424430).
/// </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 L350386).
/// </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 L389421).
/// </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 L396397: строка разбивается по разделителям; пустая строка даёт [''] —
// «кандидаты есть», текст НЕ извлекаем (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 L396405:
/// элементы строки могут нести разделители — разбиваются).
/// </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 L424430).
/// </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 L666679).
/// </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 L661663).
// 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 L426428).
// 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 L408420).
// 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» L669678).
// 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 L261267; 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 L261267).
/// </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 L718798, _field_of L686698,
/// метки 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 L796797). Резюме-маркеры здесь не нужны — отсев резюме выполняет этап-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 L591596, порядок категорий 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 L718798 +
/// чтение hireMarkers/levelTerms L617632).
/// </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 L718798).
/// </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 L750766).
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 L686698).
// 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 L701715).
// 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 L769775: 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 L338351).
// 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-дикт L591596).
private const string FieldCategoryStack = "stack";
// Категория поля «Грейд:». Константа-строка (свитч по категориям меток).
private const string FieldCategoryGrade = "grade";
// Категория поля «Контакты:». Константа-строка (свитч по категориям меток).
private const string FieldCategoryContacts = "contacts";
// Категория поля «Бюджет:». Константа-строка (свитч по категориям меток).
private const string FieldCategoryBudget = "budget";
// Собирает каталог меток-полей (python _FIELD_LABELS L591596, порядок категорий 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 L317329, normalize_stack L332341; стоп-слова стека 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> L604610).
/// </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 L604610).
private static readonly IReadOnlySet<string> StopWordsSet = BuildStopWords();
/// <summary>
/// Нормализует строковый список/одиночную строку (python normalize_list L317329).
/// </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 L320329: элементы НЕ разбиваются
/// по разделителям — это сделал разбор).
/// </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 L332341): элементы короче 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 L332341).
/// </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> L604610): общеупотребительные слова, не являющиеся
/// технологией/услугой — локальный разбор метки «Стек: …» их отбрасывает (<c>_pick_stack</c> L701715).
/// </summary>
public static IReadOnlySet<string> StackStopWords => StopWordsSet;
// Очищает части списка: обрезка, снятие обрамляющих *`#, висящих запятых/точек,
// отбрасывание пустых/односимвольных и дублей (python L324328).
// 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 L335337), лимит 12 (L339340).
// 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 L604610).
// Возвращает: Набор слов в нижнем регистре.
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 L148156 / clean_block L158193, регэкспы/эмодзи 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> L682683).
/// </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 L148156).
/// </summary>
/// <param name="text">Сырой текст (markdown/ссылки/эмодзи); null → пустая строка (как <c>str(text or "")</c>).</param>
/// <param name="limit">Максимум кодовых точек результата; обрезка по границе переноса/пробела с многоточием
/// (L184192); null/0 — без обрезки.</param>
/// <returns>Очищенный однострочный текст.</returns>
public static string CleanShort(string? text, int? limit = null)
{
return NewlinesToSpaceRe.Replace(CleanBlock(text, limit), " ");
}
/// <summary>
/// Чистит блок текста с сохранением переносов строк (python clean_block L158193): применяет все
/// шаги прототипа в том же порядке (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> L682683):
/// убирает <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 L137145): доп. пиктограммы
// 1F0001FAFF (включая региональные флаги 1F1E6–1F1FF), разные символы 2600–27BF, стрелки 2B002BFF
// и 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 L225284, _local_summary L294314,
/// футер-хинты L288291).
/// </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 L288291).
private static readonly string[] FooterHintsArray =
{
"откликнуться через", "runello", "больше вакансий", "teletype", "при отклике укажите",
"больше заявок", "узнать подробнее", "написать в лс", "пишите в лс",
};
/// <summary>
/// Собирает «О заявке» из структурированных полей либо текста (python compose_summary L225284).
/// </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 L264268).
string legacy = MessageTextCleaner.CleanShort(source.Summary);
if (legacy.Length > 0 && !ContainsFooterHint(legacy))
{
return legacy;
}
// Локальный путь без ИИ: «О задаче» из содержательных строк (без хэштег-строк и футеров, python L271283).
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 L294314).
/// </summary>
/// <param name="body">Весь очищенный текст (источник fallback-сути).</param>
/// <param name="lines">Очищенные непустые строки текста (первая — заголовок).</param>
/// <param name="skipFirst">Сколько первых строк пропустить (заголовок); по умолчанию 1.</param>
/// <returns>Суть одним абзацем (≤600 символов).</returns>
public static string LocalSummary(string? body, IReadOnlyList<string> lines, int skipFirst = DefaultSkipFirst)
{
var parts = new List<string>(MaxSummaryParts);
foreach (string line in lines.Skip(skipFirst))
{
if (LocalFieldsParser.FieldOf(line) is not null)
{
continue; // «Стек: …», «Бюджет: …» и т.п. уже разобраны в поля (python L301303)
}
string cleaned = MessageTextCleaner.CleanShort(line);
if (MessageTextCleaner.CountCodePoints(cleaned) < 2
|| TrivialWords.Contains(cleaned.ToLowerInvariant()))
{
continue;
}
parts.Add(cleaned);
if (parts.Count >= MaxSummaryParts)
{
break;
}
}
string summary = string.Join(" ", parts);
if (MessageTextCleaner.CountCodePoints(summary) < MinSummaryLength)
{
// Мало содержательных строк — берём очищенное начало всего текста (python L311313).
summary = MessageTextCleaner.CleanShort(body ?? string.Empty, SummaryFallbackLimit);
}
return MessageTextCleaner.SliceCodePoints(summary, MaxSummaryLength);
}
// Нормализует пункты блока: элементы списка чистятся как в normalize_list(list), затем каждый —
// clean_short (python _items L235241).
// 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;
}
}