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