Литералы статусов/видов/фаз и wire-ключей заменены каталогами: значения сущностей и репозиториев — TenantStatuses/TenantLimitPeriods/InviteStatuses/ContainerKinds/ContainerSpaces/CardIds/Discovery*/PipelineQueueStatuses; контакты и виды вложений — CardContactTypes/CardFileKinds; источники и этапы отсева — PipelineRejectStages/PipelineRejectSources; ключи промптов/провайдеров — SettingsFieldKeys; wire-ключи канбана — KanbanWireKeys; режимы правил — ColumnRuleModes; операции/фазы/виды диалогов/контакты Telegram и общий BoolText — в Deal.Contracts/Deal.SharedKernel; имена метрик — DealMetrics. Значения не менялись.
296 lines
11 KiB
C#
296 lines
11 KiB
C#
using System.Globalization;
|
||
using System.Text.Json;
|
||
using System.Text.Json.Nodes;
|
||
using Deal.Contracts.Integrations.Models;
|
||
using Deal.Modules.Kanban.Application.Models;
|
||
using Deal.Modules.Kanban.Application.Services;
|
||
using Deal.Modules.Pipeline.Application.Models;
|
||
using Deal.Modules.Pipeline.Application.Parse;
|
||
using Deal.SharedKernel.Utilities;
|
||
|
||
namespace Deal.Modules.Pipeline.Application.Services;
|
||
|
||
/// <summary>
|
||
/// Строгий маппинг JSON-ответа ИИ-классификатора в контрактный разбор карточки.
|
||
/// </summary>
|
||
public static class AiRawCardMapper
|
||
{
|
||
private const int MaxTitleCodePoints = 140;
|
||
|
||
private const int MaxFallbackTextCodePoints = 140;
|
||
|
||
private const string BudgetAmountPattern = @"[0-9.,]+\s*[кkКK]?";
|
||
|
||
// Строковые значения bool-полей, которые считаются «ложью».
|
||
private static readonly IReadOnlySet<string> FalsyAnswerValues =
|
||
new HashSet<string>(StringComparer.Ordinal) { "0", BoolText.False, "no", "нет", "null", "none" };
|
||
|
||
// Регулярное выражение числа бюджета: число + необязательные «к»/валюта-суффиксы.
|
||
private static readonly System.Text.RegularExpressions.Regex BudgetNumRe =
|
||
new(@"\A" + BudgetAmountPattern + @"(?:руб|р|₽)?\z", System.Text.RegularExpressions.RegexOptions.CultureInvariant);
|
||
|
||
/// <summary>
|
||
/// Маппит JSON-ответ модели в контрактный разбор карточки.
|
||
/// </summary>
|
||
/// <param name="json">Сырой JSON-ответ модели (ClassifyReply.Json; типовую схему задаёт промпт).</param>
|
||
/// <param name="text">Исходный текст сообщения.</param>
|
||
/// <returns>Разбор: структурированный блок «О заявке»/суть, стек, бюджет, контакты, тип/спам/доска.</returns>
|
||
/// <exception cref="JsonException">Ответ не объект/не разбирается — «ИИ не дал разбора» (ветка aiFail воркера).</exception>
|
||
public static AiParsedCardDto Map(string json, string text)
|
||
{
|
||
JsonObject? root = JsonNode.Parse(json) as JsonObject
|
||
?? throw new JsonException("Ответ ИИ-классификатора — не JSON-объект (ok=true, но схема нарушена).");
|
||
|
||
string title = MessageTextCleaner.CleanShort(ReadRawString(root, AiRawCardKeys.Title), MaxTitleCodePoints);
|
||
if (title.Length == 0)
|
||
{
|
||
title = MessageTextCleaner.CleanShort(text, MaxFallbackTextCodePoints); // python L455 fallback
|
||
}
|
||
|
||
IReadOnlyList<string> stack = ReadStack(root);
|
||
AiBudgetDto? budget = ReadBudget(root);
|
||
IReadOnlyList<AiContactDto> contacts = ReadContacts(root, text);
|
||
|
||
string? board = ReadRawString(root, AiRawCardKeys.Board).Trim();
|
||
if (board.Length == 0)
|
||
{
|
||
board = null;
|
||
}
|
||
|
||
return new AiParsedCardDto(
|
||
Title: title,
|
||
Company: ReadNullableString(root, AiRawCardKeys.Company),
|
||
Format: ReadNullableString(root, AiRawCardKeys.Format),
|
||
Task: ReadNullableString(root, AiRawCardKeys.Task),
|
||
Requirements: ReadItems(root, AiRawCardKeys.Requirements),
|
||
Plus: ReadItems(root, AiRawCardKeys.Plus),
|
||
Conditions: ReadNullableString(root, AiRawCardKeys.Conditions),
|
||
Summary: ReadNullableString(root, AiRawCardKeys.Summary),
|
||
Stack: stack,
|
||
Budget: budget,
|
||
Contacts: contacts,
|
||
IsVacancy: ReadBool(root, AiRawCardKeys.IsVacancy),
|
||
IsVacancyKnown: ReadBool(root, AiRawCardKeys.IsVacancyKnown), // false — воркер стемпит true после успешного ИИ
|
||
IsSpam: ReadBool(root, AiRawCardKeys.IsSpam),
|
||
Board: board);
|
||
}
|
||
|
||
private static string ReadRawString(JsonObject root, string field)
|
||
{
|
||
if (!root.TryGetPropertyValue(field, out JsonNode? node) || node is null)
|
||
{
|
||
return string.Empty;
|
||
}
|
||
|
||
return node is JsonValue value ? ValueToString(value) : string.Empty;
|
||
}
|
||
|
||
private static string? ReadNullableString(JsonObject root, string field)
|
||
{
|
||
string value = ReadRawString(root, field);
|
||
return value.Length == 0 ? null : value;
|
||
}
|
||
|
||
private static bool ReadBool(JsonObject root, string field)
|
||
{
|
||
if (!root.TryGetPropertyValue(field, out JsonNode? node) || node is null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (node is not JsonValue value)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (value.TryGetValue<bool>(out bool flag))
|
||
{
|
||
return flag;
|
||
}
|
||
|
||
if (value.TryGetValue<int>(out int number))
|
||
{
|
||
return number != 0;
|
||
}
|
||
|
||
if (value.TryGetValue<string>(out string? text))
|
||
{
|
||
string lower = (text ?? string.Empty).Trim().ToLowerInvariant();
|
||
return lower.Length > 0 && !FalsyAnswerValues.Contains(lower);
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private static IReadOnlyList<string>? ReadItems(JsonObject root, string field)
|
||
{
|
||
if (!root.TryGetPropertyValue(field, out JsonNode? node) || node is null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
IReadOnlyList<string> normalized = node is JsonValue scalarValue
|
||
? MessageListNormalizer.NormalizeList(NodeToString(scalarValue)) // python: строка разбивается по [;|\n]
|
||
: node is JsonArray array
|
||
? MessageListNormalizer.NormalizeList(array.Select(NodeToString))
|
||
: Array.Empty<string>();
|
||
var result = new List<string>(normalized.Count);
|
||
foreach (string item in normalized)
|
||
{
|
||
string cleaned = MessageTextCleaner.CleanShort(item);
|
||
if (cleaned.Length > 0)
|
||
{
|
||
result.Add(cleaned);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private static IReadOnlyList<string> ReadStack(JsonObject root)
|
||
{
|
||
if (!root.TryGetPropertyValue(AiRawCardKeys.Stack, out JsonNode? node) || node is null)
|
||
{
|
||
return Array.Empty<string>();
|
||
}
|
||
|
||
return node is JsonValue value
|
||
? MessageListNormalizer.NormalizeStack(ValueToString(value))
|
||
: node is JsonArray array
|
||
? MessageListNormalizer.NormalizeStack(array.Select(NodeToString))
|
||
: Array.Empty<string>();
|
||
}
|
||
|
||
private static AiBudgetDto? ReadBudget(JsonObject root)
|
||
{
|
||
if (!root.TryGetPropertyValue(AiRawCardKeys.Budget, out JsonNode? node) || node is not JsonObject budget)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
string currency = ReadRawString(budget, AiRawCardKeys.Currency);
|
||
if (currency.Length == 0)
|
||
{
|
||
currency = ReadRawString(budget, AiRawCardKeys.CurrencyShort); // ai.py L327: currency или cur
|
||
}
|
||
|
||
double? from = ParseBudgetBound(budget, AiRawCardKeys.From);
|
||
double? to = ParseBudgetBound(budget, AiRawCardKeys.To);
|
||
CardBudgetDto? normalized = BudgetNormalizer.Normalize(new BudgetRangeDto(from, to, currency));
|
||
return normalized is null
|
||
? null
|
||
: new AiBudgetDto(normalized.From, normalized.To, normalized.Cur);
|
||
}
|
||
|
||
private static double? ParseBudgetBound(JsonObject budget, string field)
|
||
{
|
||
if (!budget.TryGetPropertyValue(field, out JsonNode? node) || node is null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
if (node is JsonValue value && value.TryGetValue<double>(out double direct))
|
||
{
|
||
return direct == 0 ? null : direct; // python L313: x == 0 → None
|
||
}
|
||
|
||
string text = ReadRawString(budget, field).Replace("\u00a0", string.Empty).Replace(" ", string.Empty).ToLowerInvariant();
|
||
if (text.Length == 0)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
double multiplier = 1.0;
|
||
if (text.EndsWith('к') || text.EndsWith('k'))
|
||
{
|
||
multiplier = 1000.0;
|
||
text = text[..^1];
|
||
}
|
||
|
||
if (text.EndsWith("руб", StringComparison.Ordinal))
|
||
{
|
||
text = text[..^3];
|
||
}
|
||
else if (text.EndsWith('р') || text.EndsWith('₽'))
|
||
{
|
||
text = text[..^1];
|
||
}
|
||
|
||
if (!BudgetNumRe.IsMatch(text) || !double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double amount))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
double result = amount * multiplier;
|
||
return result == 0 ? null : result;
|
||
}
|
||
|
||
private static IReadOnlyList<AiContactDto> ReadContacts(JsonObject root, string text)
|
||
{
|
||
List<string> candidates = new();
|
||
if (root.TryGetPropertyValue(AiRawCardKeys.Contacts, out JsonNode? node) && node is not null)
|
||
{
|
||
if (node is JsonValue)
|
||
{
|
||
candidates.Add(ValueToString((JsonValue)node)); // строка: разобьётся разделителями ниже
|
||
}
|
||
else if (node is JsonArray array)
|
||
{
|
||
foreach (JsonNode? item in array)
|
||
{
|
||
switch (item)
|
||
{
|
||
case null:
|
||
continue;
|
||
case JsonValue value:
|
||
candidates.Add(ValueToString(value));
|
||
break;
|
||
case JsonObject contactObject:
|
||
{
|
||
string value = ReadRawString(contactObject, AiRawCardKeys.Value);
|
||
if (value.Length > 0)
|
||
{
|
||
candidates.Add(value);
|
||
}
|
||
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
else if (candidates.Count == 0)
|
||
{
|
||
return Array.Empty<AiContactDto>();
|
||
}
|
||
}
|
||
|
||
IReadOnlyList<CardContactDto> qualified = ContactsQualifier.Build(
|
||
candidates.Count > 0 ? candidates : null, text);
|
||
var result = new List<AiContactDto>(qualified.Count);
|
||
foreach (CardContactDto contact in qualified)
|
||
{
|
||
result.Add(new AiContactDto(contact.Type, contact.Value));
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private static string NodeToString(JsonNode? node)
|
||
=> node is JsonValue value ? ValueToString(value) : string.Empty;
|
||
|
||
private static string ValueToString(JsonValue value)
|
||
{
|
||
if (value.TryGetValue<string>(out string? text))
|
||
{
|
||
return text ?? string.Empty;
|
||
}
|
||
|
||
if (value.TryGetValue<bool>(out bool flag))
|
||
{
|
||
return flag ? "true" : "false";
|
||
}
|
||
|
||
return value.ToJsonString().Trim('"');
|
||
}
|
||
}
|