Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -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 > 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>*#_~]</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: < 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user