< Summary

Information
Class: Services.AiAssistant.AiOrderFilterLlmParser
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/AiAssistant/AiOrderFilterLlmParser.cs
Line coverage
97%
Covered lines: 192
Uncovered lines: 4
Coverable lines: 196
Total lines: 264
Line coverage: 97.9%
Branch coverage
96%
Covered branches: 31
Total branches: 32
Branch coverage: 96.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
TryParseAsync()100%2284.21%
BuildPrompt(...)100%11100%
DeserializeCriteria(...)87.5%8893.33%
ExtractJsonObject(...)100%44100%
get_ActionableOnly()100%11100%
get_OverdueOnly()100%11100%
get_EmptyPrice()100%11100%
get_IncludeArchived()100%11100%
get_OzonClientName()100%11100%
get_SupplierName()100%11100%
get_TransactionConductedBy()100%11100%
get_ChangedByUser()100%11100%
get_AppStatusName()100%11100%
get_ClientStatusName()100%11100%
get_OnlyWithReturns()100%11100%
get_Article()100%11100%
get_ProductName()100%11100%
get_ManufacturerName()100%11100%
get_NewCategory()100%11100%
get_MinQuantity()100%11100%
get_MaxQuantity()100%11100%
get_PriceMin()100%11100%
get_PriceMax()100%11100%
get_ProfitMin()100%11100%
get_ProfitMax()100%11100%
get_OnlyNegativeProfit()100%11100%
get_PurchasePriceMax()100%11100%
get_ShipmentWarehouseName()100%11100%
get_DeliveryProviderName()100%11100%
get_DeliveryName()100%11100%
get_DeliveryCity()100%11100%
get_OnlyWithTrackingNumber()100%11100%
get_ShippingDateFrom()100%11100%
get_ShippingDateTo()100%11100%
get_ProcessingDateFrom()100%11100%
get_ProcessingDateTo()100%11100%
get_LastMonths()100%11100%
get_ChangedFrom()100%11100%
get_ChangedTo()100%11100%
get_FieldChangedName()100%11100%
get_FieldChangedToValue()100%11100%
ToCriteria()100%1212100%
NullIfEmpty(...)100%22100%
ParseDate(...)100%44100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/AiAssistant/AiOrderFilterLlmParser.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Text.Json;
 3using System.Text.RegularExpressions;
 4using Microsoft.Extensions.Logging;
 5using Services.ApiServices.OpenRouterApi;
 6
 7namespace Services.AiAssistant;
 8
 9/// <summary>
 10/// Парсит свободный текст поиска заказов в <see cref="AiOrderFilterCriteria"/> через быстрый LLM-вызов.
 11/// Regex-fallback — <see cref="AiOrderFilterParser.TryParseRegex"/>.
 12/// </summary>
 13public sealed class AiOrderFilterLlmParser
 14{
 15    private const int TimeoutMs = 3000;
 16
 17    private readonly OpenRouterDataManager _openRouter;
 18    private readonly ILogger<AiOrderFilterLlmParser> _logger;
 19
 120    private static readonly JsonSerializerOptions JsonOptions = new()
 121    {
 122        PropertyNameCaseInsensitive = true,
 123        ReadCommentHandling         = JsonCommentHandling.Skip,
 124        AllowTrailingCommas         = true,
 125    };
 26
 2027    public AiOrderFilterLlmParser(
 2028        OpenRouterDataManager openRouter,
 2029        ILogger<AiOrderFilterLlmParser> logger)
 30    {
 2031        _openRouter = openRouter;
 2032        _logger     = logger;
 2033    }
 34
 35    public async Task<AiOrderFilterCriteria?> TryParseAsync(
 36        string message,
 37        DateTime nowMsk,
 38        CancellationToken ct = default)
 39    {
 2040        if (string.IsNullOrWhiteSpace(message))
 441            return null;
 42
 1643        using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
 1644        timeoutCts.CancelAfter(TimeoutMs);
 45
 46        try
 47        {
 1648            var prompt = BuildPrompt(message.Trim(), nowMsk);
 1649            var raw    = await _openRouter.AskWithContextAsync(
 1650                prompt,
 1651                dataContext: string.Empty,
 1652                history: null,
 1653                modelOverride: null,
 1654                ct: timeoutCts.Token);
 55
 1556            return DeserializeCriteria(raw);
 57        }
 058        catch (OperationCanceledException) when (!ct.IsCancellationRequested)
 59        {
 060            _logger.LogDebug("AiOrderFilterLlmParser: timeout after {Ms}ms", TimeoutMs);
 061            return null;
 62        }
 163        catch (Exception ex)
 64        {
 165            _logger.LogWarning(ex, "AiOrderFilterLlmParser: failed, falling back to regex");
 166            return null;
 67        }
 2068    }
 69
 70    internal static string BuildPrompt(string userMessage, DateTime nowMsk) =>
 1771        $$"""
 1772         Ты парсер поисковых запросов по заказам Ozon в системе Studio2.
 1773         Ответь ТОЛЬКО валидным JSON (без markdown, без пояснений) по схеме:
 1774         {
 1775           "actionableOnly": false,
 1776           "overdueOnly": false,
 1777           "emptyPrice": false,
 1778           "includeArchived": false,
 1779           "ozonClientName": null,
 1780           "supplierName": null,
 1781           "transactionConductedBy": null,
 1782           "changedByUser": null,
 1783           "appStatusName": null,
 1784           "clientStatusName": null,
 1785           "onlyWithReturns": null,
 1786           "article": null,
 1787           "productName": null,
 1788           "manufacturerName": null,
 1789           "newCategory": null,
 1790           "minQuantity": null,
 1791           "maxQuantity": null,
 1792           "priceMin": null,
 1793           "priceMax": null,
 1794           "profitMin": null,
 1795           "profitMax": null,
 1796           "onlyNegativeProfit": null,
 1797           "purchasePriceMax": null,
 1798           "shipmentWarehouseName": null,
 1799           "deliveryProviderName": null,
 17100           "deliveryName": null,
 17101           "deliveryCity": null,
 17102           "onlyWithTrackingNumber": null,
 17103           "shippingDateFrom": null,
 17104           "shippingDateTo": null,
 17105           "processingDateFrom": null,
 17106           "processingDateTo": null,
 17107           "lastMonths": null,
 17108           "changedFrom": null,
 17109           "changedTo": null,
 17110           "fieldChangedName": null,
 17111           "fieldChangedToValue": null
 17112         }
 17113
 17114         Правила:
 17115         - Даты в ISO 8601 (yyyy-MM-dd) или null.
 17116         - lastMonths — целое число месяцев назад от сегодня (ProcessingDate).
 17117         - «позавчера», «вчера», «прошлая неделя» → processingDateFrom/processingDateTo.
 17118         - «актуальные», «к обработке» → actionableOnly: true.
 17119         - «просроченные» → overdueOnly: true.
 17120         - «убыточные», «минусят», «в минусе» → onlyNegativeProfit: true.
 17121         - «по складу M2» → shipmentWarehouseName: «M2» (только код склада, без «на клиенте …»).
 17122         - «на клиенте Interparts RU» → ozonClientName: «Interparts RU» (Ozon-клиент, не склад).
 17123         - «за месяц», «за последний месяц» → lastMonths: 1.
 17124         - «со статусом X», «статус X» → appStatusName: X (статус в отчёте Studio2, не Ozon client status).
 17125         - «Отменен», «отменённые» → appStatusName: «Отменен», actionableOnly: false, includeArchived: true.
 17126         - Имена поставщиков/клиентов — только значение, без слова «поставщик» и без «со статусом …».
 17127         - Если запрос не про поиск заказов — верни {}.
 17128
 17129         Сегодня (МСК): {{nowMsk:yyyy-MM-dd HH:mm}}.
 17130
 17131         Запрос пользователя: {{userMessage}}
 17132         """;
 133
 134    private static AiOrderFilterCriteria? DeserializeCriteria(string raw)
 135    {
 15136        if (string.IsNullOrWhiteSpace(raw))
 1137            return null;
 138
 14139        var json = ExtractJsonObject(raw);
 14140        if (string.IsNullOrWhiteSpace(json))
 2141            return null;
 142
 143        LlmFilterDto? dto;
 144        try
 145        {
 12146            dto = JsonSerializer.Deserialize<LlmFilterDto>(json, JsonOptions);
 11147        }
 1148        catch
 149        {
 1150            return null;
 151        }
 152
 11153        if (dto == null)
 0154            return null;
 155
 11156        var criteria = dto.ToCriteria();
 11157        criteria = AiOrderFilterParser.NormalizeCriteria(criteria);
 11158        return criteria.HasCriteria ? criteria : null;
 1159    }
 160
 161    private static string? ExtractJsonObject(string raw)
 162    {
 14163        var trimmed = raw.Trim();
 14164        if (trimmed.StartsWith('{'))
 11165            return trimmed;
 166
 3167        var match = Regex.Match(trimmed, @"\{[\s\S]*\}");
 3168        return match.Success ? match.Value : null;
 169    }
 170
 171    private sealed class LlmFilterDto
 172    {
 20173        public bool    ActionableOnly         { get; set; }
 12174        public bool    OverdueOnly            { get; set; }
 12175        public bool    EmptyPrice             { get; set; }
 13176        public bool    IncludeArchived        { get; set; }
 13177        public string? OzonClientName         { get; set; }
 14178        public string? SupplierName           { get; set; }
 12179        public string? TransactionConductedBy { get; set; }
 12180        public string? ChangedByUser          { get; set; }
 13181        public string? AppStatusName          { get; set; }
 12182        public string? ClientStatusName       { get; set; }
 12183        public bool?   OnlyWithReturns        { get; set; }
 15184        public string? Article                { get; set; }
 13185        public string? ProductName            { get; set; }
 12186        public string? ManufacturerName       { get; set; }
 12187        public string? NewCategory            { get; set; }
 15188        public int?    MinQuantity            { get; set; }
 15189        public int?    MaxQuantity            { get; set; }
 12190        public decimal? PriceMin              { get; set; }
 12191        public decimal? PriceMax              { get; set; }
 12192        public decimal? ProfitMin             { get; set; }
 12193        public decimal? ProfitMax             { get; set; }
 12194        public bool?   OnlyNegativeProfit     { get; set; }
 12195        public decimal? PurchasePriceMax      { get; set; }
 13196        public string? ShipmentWarehouseName  { get; set; }
 12197        public string? DeliveryProviderName   { get; set; }
 12198        public string? DeliveryName           { get; set; }
 13199        public string? DeliveryCity           { get; set; }
 12200        public bool?   OnlyWithTrackingNumber { get; set; }
 13201        public string? ShippingDateFrom       { get; set; }
 13202        public string? ShippingDateTo         { get; set; }
 13203        public string? ProcessingDateFrom     { get; set; }
 13204        public string? ProcessingDateTo       { get; set; }
 15205        public int?    LastMonths             { get; set; }
 13206        public string? ChangedFrom            { get; set; }
 13207        public string? ChangedTo              { get; set; }
 12208        public string? FieldChangedName       { get; set; }
 12209        public string? FieldChangedToValue    { get; set; }
 210
 11211        public AiOrderFilterCriteria ToCriteria() => new()
 11212        {
 11213            ActionableOnly         = ActionableOnly,
 11214            OverdueOnly            = OverdueOnly,
 11215            EmptyPrice             = EmptyPrice,
 11216            IncludeArchived        = IncludeArchived,
 11217            OzonClientName         = NullIfEmpty(OzonClientName),
 11218            SupplierName           = NullIfEmpty(SupplierName),
 11219            TransactionConductedBy = NullIfEmpty(TransactionConductedBy),
 11220            ChangedByUser          = NullIfEmpty(ChangedByUser),
 11221            AppStatusName          = NullIfEmpty(AppStatusName),
 11222            ClientStatusName       = NullIfEmpty(ClientStatusName),
 11223            OnlyWithReturns        = OnlyWithReturns,
 11224            Article                = NullIfEmpty(Article),
 11225            ProductName            = NullIfEmpty(ProductName),
 11226            ManufacturerName       = NullIfEmpty(ManufacturerName),
 11227            NewCategory            = NullIfEmpty(NewCategory),
 11228            MinQuantity            = MinQuantity is > 0 ? MinQuantity : null,
 11229            MaxQuantity            = MaxQuantity is > 0 ? MaxQuantity : null,
 11230            PriceMin               = PriceMin,
 11231            PriceMax               = PriceMax,
 11232            ProfitMin              = ProfitMin,
 11233            ProfitMax              = ProfitMax,
 11234            OnlyNegativeProfit     = OnlyNegativeProfit,
 11235            PurchasePriceMax       = PurchasePriceMax,
 11236            ShipmentWarehouseName  = NullIfEmpty(ShipmentWarehouseName),
 11237            DeliveryProviderName   = NullIfEmpty(DeliveryProviderName),
 11238            DeliveryName           = NullIfEmpty(DeliveryName),
 11239            DeliveryCity           = NullIfEmpty(DeliveryCity),
 11240            OnlyWithTrackingNumber = OnlyWithTrackingNumber,
 11241            ShippingDateFrom       = ParseDate(ShippingDateFrom),
 11242            ShippingDateTo         = ParseDate(ShippingDateTo),
 11243            ProcessingDateFrom     = ParseDate(ProcessingDateFrom),
 11244            ProcessingDateTo       = ParseDate(ProcessingDateTo),
 11245            LastMonths             = LastMonths is > 0 ? LastMonths : null,
 11246            ChangedFrom            = ParseDate(ChangedFrom),
 11247            ChangedTo              = ParseDate(ChangedTo),
 11248            FieldChangedName       = NullIfEmpty(FieldChangedName),
 11249            FieldChangedToValue    = NullIfEmpty(FieldChangedToValue),
 11250        };
 251
 252        private static string? NullIfEmpty(string? s) =>
 176253            string.IsNullOrWhiteSpace(s) ? null : s.Trim();
 254
 255        private static DateTime? ParseDate(string? s)
 256        {
 66257            if (string.IsNullOrWhiteSpace(s))
 57258                return null;
 9259            if (DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dt))
 6260                return dt.Date;
 3261            return null;
 262        }
 263    }
 264}