Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
226 lines
10 KiB
C#
226 lines
10 KiB
C#
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;
|
||
}
|
||
}
|