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

200 lines
13 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.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Settings.Application;
namespace Deal.Modules.Pipeline.Application;
/// <summary>
/// Сборка снимка карточки из разобранного сообщения для записи через Kanban (план Task 7 L393399,
/// Ruling 4; python pipeline.py _store_lead L433514).
/// </summary>
/// <remarks>
/// Чистый класс модуля Pipeline (без EF/HTTP): из разбора <see cref="AiParsedCardDto"/> (ИИ/локальный путь)
/// и строки-сообщения <see cref="QueueItemDto"/> собирает полный <see cref="CardSnapshot"/> 1:1 с _store_lead:
/// title — <see cref="MessageTextCleaner.CleanShort"/> (140, fallback — начало исходника); «О заявке» —
/// <see cref="SummaryComposer.Compose"/> (блоки Компания → … → Условия, 1:1 с cardPrompt/compose_summary
/// L225284) через <see cref="MessageTextCleaner.CleanBlock"/> (2000, fallback — clean_short исходника);
/// stack — <see cref="MessageListNormalizer.NormalizeStack"/> (≤12); бюджет — <see cref="BudgetNormalizer.Normalize"/>
/// из разбора (форма хранения) + fallback первой суммы <see cref="AmountRangeBudgetFallback.Extract"/> по
/// исходнику/«О заявке» (L459–468); конверсия один раз при поступлении — <see cref="BudgetNormalizer.ToTarget"/>
/// (conversionOn/targetCurrency/ratesCache из типизированного снимка TenantSettingsSnapshot, C30, с мок-фолбэком);
/// контакты — <see cref="ContactsQualifier.Build"/> из значений разбора или текста (L389–421, ≤6, дедуп),
/// contact = <see cref="ContactsQualifier.Primary"/> (L424430, ≤200); ch-поля канала, sourceMsg = text[:4000],
/// prevCol=inbox, isVacancy/isVacancyKnown из разбора.
/// <para>
/// Колонка: разбор может назначить доску (parsed.Board) — <see cref="BuildAsync"/> читает её через
/// <see cref="ICardStore.GetContainerAsync"/> и применяет страховку <see cref="ColumnRules.ContainerAccepts"/> (python
/// L449–450): доска отсутствует или текст не прошёл правила → col=inbox (ИИ/ML не кладут в отфильтрованную
/// колонку); прошла → col=доска, matchHits = <see cref="ColumnRules.ComputeHits"/> для прошедшей доски (иначе
/// пусто). Id карточки (<c>c_</c>) генерирует вызывающий (PipelineCardWriter) и передаёт готовым (Ruling 12).
/// </para>
/// </remarks>
public sealed class CardComposer(ICardStore kanjStore, ISettingsStore settings)
{
// Лимит заголовка карточки (python _store_lead L455: clean_short(title, 140)).
private const int MaxTitleCodePoints = 140;
// Лимит блока «О заявке» (python L458: clean_block(summary, 2000)).
private const int MaxSummaryCodePoints = 2000;
// Лимит исходного сообщения на карточке (python L503: text[:4000]).
private const int MaxSourceMsgCodePoints = 4000;
// Лимит «быстрого» контакта карточки (python L471: primary_contact(contacts)[:200]).
private const int MaxPrimaryContactCodePoints = 200;
/// <summary>
/// Собирает полный снимок новой карточки из разбора и строки сообщения (1:1 с _store_lead L433514).
/// </summary>
/// <remarks>
/// Использует только поля-сообщения QueueItemDto (DialogId/Channel/Text/MsgId/MsgAtMs) — статус/время
/// постановки строки на карточку не влияют. «Повтор по дедупу» здесь не проверяется (этап воркера,
/// Ruling 8): вызывающий (PipelineCardWriter/воркер) уже заявил хэш и связал карточку после записи.
/// </remarks>
/// <param name="parsed">Разбор сообщения (ИИ-классификатор или локальный путь; контакты квалифицированы).</param>
/// <param name="message">Строка очереди с сообщением-источником (метаданные канала, текст, время).</param>
/// <param name="cardId">Готовый id карточки (<c>c_...</c>; генерирует PipelineCardWriter через PrefixId).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Полный снимок карточки для <see cref="ICardStore.AddCardAsync"/> (CreatedAt проставит хранилище).</returns>
public async Task<CardSnapshot> BuildAsync(
AiParsedCardDto parsed,
QueueItemDto message,
string cardId,
CancellationToken ct)
{
// «О заявке» всегда собирается из одинаковых блоков (Компания → … → Условия); структуры нет — суть
// как есть либо «О задаче: …» из исходника (SummaryComposer.Compose), сверху clean_block 2000.
ParsedCardContent content = ToParsedContent(parsed);
string summary = MessageTextCleaner.CleanBlock(SummaryComposer.Compose(content, message.Text), MaxSummaryCodePoints);
if (summary.Length == 0)
{
summary = MessageTextCleaner.CleanShort(message.Text, MaxSummaryCodePoints); // python L458 fallback
}
string title = MessageTextCleaner.CleanShort(parsed.Title, MaxTitleCodePoints);
if (title.Length == 0)
{
title = MessageTextCleaner.CleanShort(message.Text, MaxTitleCodePoints); // python L455 fallback
}
IReadOnlyList<string> stack = MessageListNormalizer.NormalizeStack(parsed.Stack);
CardBudgetDto? budget = ComposeBudget(parsed.Budget, message.Text, summary);
// Доска разбора (страховка ContainerAccepts) и конверсия бюджета требуют курсы: типизированный снимок
// настроек читается ОДИН раз на карточку (C30) — мок-фолбэк при отсутствии кэша, как раньше LoadRatesAsync.
string? boardCandidate = string.IsNullOrWhiteSpace(parsed.Board) ? null : parsed.Board.Trim();
TenantSettingsSnapshot? settingsSnapshot = budget is not null || boardCandidate is not null
? await TenantSettingsSnapshot.LoadAsync(settings, ct)
: null;
IReadOnlyDictionary<string, double>? rates = null;
if (settingsSnapshot is not null)
{
rates = settingsSnapshot.TryGetRatesCache()?.Rates ?? MockRates.Values;
}
CardBudgetDto? converted = null;
if (budget is not null)
{
bool conversionOn = settingsSnapshot!.GetBool(SettingsKeys.ConversionOn, SettingsDefaults.ConversionOn);
string targetCurrency = NormalizeTargetCurrency(
settingsSnapshot.GetString(SettingsKeys.TargetCurrency, SettingsDefaults.TargetCurrency));
converted = BudgetNormalizer.ToTarget(budget, conversionOn, targetCurrency, rates);
}
// Колонка и «почему карточка здесь»: страховка L449–450 (доски нет/не прошла правила → inbox).
string col = CardIds.Inbox;
IReadOnlyList<MatchHitDto> matchHits = Array.Empty<MatchHitDto>();
if (boardCandidate is not null)
{
ContainerDto? board = await kanjStore.GetContainerAsync(boardCandidate, ct);
if (board is not null && ColumnRules.ContainerAccepts(board.Rules, message.Text, rates))
{
col = board.Id;
matchHits = ColumnRules.ComputeHits(board.Rules, message.Text, rates);
}
}
IReadOnlyList<CardContactDto> contacts = ContactsQualifier.Build(
parsed.Contacts.Select(contact => contact.Value).ToList(), message.Text);
string contact = MessageTextCleaner.SliceCodePoints(ContactsQualifier.Primary(contacts), MaxPrimaryContactCodePoints);
string hue = string.IsNullOrWhiteSpace(message.Channel.Hue) ? SourceDefaults.DefaultHue : message.Channel.Hue;
DateTimeOffset receivedAt = message.MsgAtMs != 0
? DateTimeOffset.FromUnixTimeMilliseconds(message.MsgAtMs)
: DateTimeOffset.UtcNow; // python row.get("msg_at") or now (L501)
return new CardSnapshot
{
Id = cardId,
Col = col,
IsNew = true, // создание — точка «новое» (Ruling 2)
IsVacancy = parsed.IsVacancy,
IsVacancyKnown = parsed.IsVacancyKnown,
Title = title,
Summary = summary,
Stack = stack,
BudgetFrom = budget?.From,
BudgetTo = budget?.To,
BudgetCur = budget?.Cur ?? string.Empty,
ConvFrom = converted?.From,
ConvTo = converted?.To,
ConvCur = converted?.Cur ?? string.Empty,
Contact = contact,
Contacts = contacts,
ChannelName = message.Channel.Name,
ChannelHandle = message.Channel.Handle,
ChannelHue = hue,
ReceivedAt = receivedAt,
SourceMsg = MessageTextCleaner.SliceCodePoints(message.Text, MaxSourceMsgCodePoints),
SourceDialogId = message.DialogId,
SourceMsgId = message.MsgId,
PrevCol = CardIds.Inbox,
ArchivedAt = null,
MatchHits = matchHits,
};
}
// Бюджет карточки: нормализация из разбора, иначе fallback первой суммы по исходнику/«О заявке»
// (python L453/L459468; формы хранения — CardBudgetDto).
// parsedBudget: Бюджет разбора (форма контракта; null — разбор не выделил сумму).
// text: Текст исходного сообщения (первый источник fallback).
// summary: Блок «О заявке» (второй источник fallback — сумма часто уходит в «Условия»).
// Возвращает: Нормализованный бюджет либо null — суммы с валютой нет ни в разборе, ни в тексте.
private static CardBudgetDto? ComposeBudget(AiBudgetDto? parsedBudget, string? text, string summary)
{
if (parsedBudget is not null)
{
return BudgetNormalizer.Normalize(new BudgetRangeDto(parsedBudget.From, parsedBudget.To, parsedBudget.Cur));
}
BudgetRangeDto? fallback = AmountRangeBudgetFallback.Extract(text, summary);
return fallback is null ? null : BudgetNormalizer.Normalize(fallback);
}
// Маппинг разбора классификатора в структуру блока «О заявке» (поля 1:1 с cardPrompt L116123).
// parsed: Разбор (пустые/отсутствующие поля блок не дают — SummaryComposer).
// Возвращает: Структура для SummaryComposer.Compose.
private static ParsedCardContent ToParsedContent(AiParsedCardDto parsed) => new(
Company: parsed.Company,
Format: parsed.Format,
Task: parsed.Task,
Requirements: parsed.Requirements,
Plus: parsed.Plus,
Conditions: parsed.Conditions,
Summary: parsed.Summary);
// Код целевой валюты для конверсии: trim + верхний регистр; пусто → дефолт RUB (как писал PATCH).
// value: Значение настройки targetCurrency (JSON-строка).
// Возвращает: Код валюты (RUB/USD/…) либо дефолт.
private static string NormalizeTargetCurrency(string value)
{
string currency = value.Trim().ToUpperInvariant();
return currency.Length > 0 ? currency : SettingsDefaults.TargetCurrency;
}
}