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; /// /// Строгий маппинг JSON-ответа ИИ-классификатора в контрактный разбор карточки. /// 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 FalsyAnswerValues = new HashSet(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); /// /// Маппит JSON-ответ модели в контрактный разбор карточки. /// /// Сырой JSON-ответ модели (ClassifyReply.Json; типовую схему задаёт промпт). /// Исходный текст сообщения. /// Разбор: структурированный блок «О заявке»/суть, стек, бюджет, контакты, тип/спам/доска. /// Ответ не объект/не разбирается — «ИИ не дал разбора» (ветка aiFail воркера). 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 stack = ReadStack(root); AiBudgetDto? budget = ReadBudget(root); IReadOnlyList 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(out bool flag)) { return flag; } if (value.TryGetValue(out int number)) { return number != 0; } if (value.TryGetValue(out string? text)) { string lower = (text ?? string.Empty).Trim().ToLowerInvariant(); return lower.Length > 0 && !FalsyAnswerValues.Contains(lower); } return false; } private static IReadOnlyList? ReadItems(JsonObject root, string field) { if (!root.TryGetPropertyValue(field, out JsonNode? node) || node is null) { return null; } IReadOnlyList normalized = node is JsonValue scalarValue ? MessageListNormalizer.NormalizeList(NodeToString(scalarValue)) // python: строка разбивается по [;|\n] : node is JsonArray array ? MessageListNormalizer.NormalizeList(array.Select(NodeToString)) : Array.Empty(); var result = new List(normalized.Count); foreach (string item in normalized) { string cleaned = MessageTextCleaner.CleanShort(item); if (cleaned.Length > 0) { result.Add(cleaned); } } return result; } private static IReadOnlyList ReadStack(JsonObject root) { if (!root.TryGetPropertyValue(AiRawCardKeys.Stack, out JsonNode? node) || node is null) { return Array.Empty(); } return node is JsonValue value ? MessageListNormalizer.NormalizeStack(ValueToString(value)) : node is JsonArray array ? MessageListNormalizer.NormalizeStack(array.Select(NodeToString)) : Array.Empty(); } 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(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 ReadContacts(JsonObject root, string text) { List 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(); } } IReadOnlyList qualified = ContactsQualifier.Build( candidates.Count > 0 ? candidates : null, text); var result = new List(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(out string? text)) { return text ?? string.Empty; } if (value.TryGetValue(out bool flag)) { return flag ? "true" : "false"; } return value.ToJsonString().Trim('"'); } }