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