SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
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.Parse;
|
||||
|
||||
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]?";
|
||||
|
||||
private const string CurrencyFieldName = "currency";
|
||||
|
||||
private const string CurrencyFieldNameShort = "cur";
|
||||
|
||||
// Регулярное выражение числа бюджета: число + необязательные «к»/валюта-суффиксы.
|
||||
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, "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, "board").Trim();
|
||||
if (board.Length == 0)
|
||||
{
|
||||
board = null;
|
||||
}
|
||||
|
||||
return new AiParsedCardDto(
|
||||
Title: title,
|
||||
Company: ReadNullableString(root, "company"),
|
||||
Format: ReadNullableString(root, "format"),
|
||||
Task: ReadNullableString(root, "task"),
|
||||
Requirements: ReadItems(root, "requirements"),
|
||||
Plus: ReadItems(root, "plus"),
|
||||
Conditions: ReadNullableString(root, "conditions"),
|
||||
Summary: ReadNullableString(root, "summary"),
|
||||
Stack: stack,
|
||||
Budget: budget,
|
||||
Contacts: contacts,
|
||||
IsVacancy: ReadBool(root, "is_vacancy"),
|
||||
IsVacancyKnown: ReadBool(root, "is_vacancy_known"), // false — воркер стемпит true после успешного ИИ
|
||||
IsSpam: ReadBool(root, "is_spam"),
|
||||
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 && lower is not ("0" or "false" or "no" or "нет" or "null" or "none");
|
||||
}
|
||||
|
||||
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("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("budget", out JsonNode? node) || node is not JsonObject budget)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string currency = ReadRawString(budget, CurrencyFieldName);
|
||||
if (currency.Length == 0)
|
||||
{
|
||||
currency = ReadRawString(budget, CurrencyFieldNameShort); // ai.py L327: currency или cur
|
||||
}
|
||||
|
||||
double? from = ParseBudgetBound(budget, "from");
|
||||
double? to = ParseBudgetBound(budget, "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("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, "value"); // python L402–403: dict.get("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('"');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user