Application разделён на Abstractions (25 интерфейсов), Models (13 доменных типов) и Dtos (CardMoveResultDto); namespace приведён к путям, using потребителей мигрированы.
202 lines
13 KiB
C#
202 lines
13 KiB
C#
using Deal.Contracts.Integrations;
|
||
using Deal.Contracts.Integrations.Models;
|
||
using Deal.Modules.Cards.Application.Abstractions;
|
||
using Deal.Modules.Cards.Application.Dtos;
|
||
using Deal.Modules.Cards.Application.Models;
|
||
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 L393–399,
|
||
/// Ruling 4; python pipeline.py _store_lead L433–514).
|
||
/// </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
|
||
/// L225–284) через <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"/> (L424–430, ≤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 L433–514).
|
||
/// </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/L459–468; формы хранения — 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 L116–123).
|
||
// 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;
|
||
}
|
||
}
|