Files
Deal/src/core/Deal.Modules.Pipeline/Application/Parse/SummaryComposer.cs
T
Rustam Khalimov 9e07568ddd Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
2026-09-11 02:50:17 +03:00

226 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}