Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

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:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -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 L402403: 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('"');
}
}