Files
Deal/src/core/Deal.Modules.Pipeline/Application/Services/AiRawCardMapper.cs
T
stepan 1fbe29a838 Вынести магические строки статусов, видов и ключей в каталоги
Литералы статусов/видов/фаз и wire-ключей заменены каталогами: значения сущностей и репозиториев — TenantStatuses/TenantLimitPeriods/InviteStatuses/ContainerKinds/ContainerSpaces/CardIds/Discovery*/PipelineQueueStatuses; контакты и виды вложений — CardContactTypes/CardFileKinds; источники и этапы отсева — PipelineRejectStages/PipelineRejectSources; ключи промптов/провайдеров — SettingsFieldKeys; wire-ключи канбана — KanbanWireKeys; режимы правил — ColumnRuleModes; операции/фазы/виды диалогов/контакты Telegram и общий BoolText — в Deal.Contracts/Deal.SharedKernel; имена метрик — DealMetrics. Значения не менялись.
2026-09-13 20:51:36 +03:00

296 lines
11 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 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('"');
}
}