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;
}
}