using Deal.Modules.Pipeline.Application.Models; namespace Deal.Modules.Pipeline.Application.Parse; /// /// Сборка блока «О заявке» карточки (pipeline.py compose_summary L225–284, _local_summary L294–314, /// футер-хинты L288–291). /// /// /// Карточка всегда собирается из одних и тех же блоков — Компания → Формат → О задаче → Требования → /// Будет плюсом → Условия (1:1 с cardPrompt); недостающие блоки пропускаются. Если структурированных полей нет /// ( от локального/старого разбора) — суть сохраняется как есть, но /// отбрасывается, когда похожа на служебный футер агрегаторов (); тогда «О задаче» /// собирается из содержательных строк текста (, с префиксом «О задаче: »). /// 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 TrivialWords = new HashSet(StringComparer.Ordinal) { "вакансия", "вакансию", "фриланс", }; // Служебные строки/фразы футеров агрегаторов: «суть» с ними — не структура, а шум (python L288–291). private static readonly string[] FooterHintsArray = { "откликнуться через", "runello", "больше вакансий", "teletype", "при отклике укажите", "больше заявок", "узнать подробнее", "написать в лс", "пишите в лс", }; /// /// Собирает «О заявке» из структурированных полей либо текста (python compose_summary L225–284). /// /// Структура разбора: блоки Компания→…→Условия и/или legacy-суть; null — пустая структура. /// Исходный текст сообщения (источник локального пути «О задаче: …»). /// Текст блока «О заявке» (пустая строка — структуры и текста нет). public static string Compose(ParsedCardContent? content, string? text) { ParsedCardContent source = content ?? new ParsedCardContent(); var blocks = new List(); 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 requirements = ContentItems(source.Requirements); if (requirements.Count > 0) { blocks.Add("Требования: " + string.Join(", ", requirements.Take(MaxRequirementsItems))); } IReadOnlyList 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(); 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); } /// /// Суть карточки при локальном разборе: содержательные строки после заголовка, без меток-полей и /// мусора (python _local_summary L294–314). /// /// Весь очищенный текст (источник fallback-сути). /// Очищенные непустые строки текста (первая — заголовок). /// Сколько первых строк пропустить (заголовок); по умолчанию 1. /// Суть одним абзацем (≤600 символов). public static string LocalSummary(string? body, IReadOnlyList lines, int skipFirst = DefaultSkipFirst) { var parts = new List(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 ContentItems(IReadOnlyList? values) { var result = new List(); 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; } }