< Summary

Information
Class: Services.AiAssistant.AiOrderFilterQueryStudio
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/AiAssistant/AiOrderFilterQueryStudio.cs
Line coverage
34%
Covered lines: 120
Uncovered lines: 232
Coverable lines: 352
Total lines: 622
Line coverage: 34%
Branch coverage
33%
Covered branches: 90
Total branches: 271
Branch coverage: 33.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%210%
AnalyzeAndBuildAsync()0%110100%
TryAnalyzeEntitiesAsync()100%210%
BuildAnalystPrompt(...)100%210%
MergeToCriteria(...)75%8886.66%
ReinforceRegexAnchors(...)83.33%161269.23%
ApplyEntity(...)35.46%1707220325.75%
TryDecimal(...)0%620%
BuildSpecificationSummary(...)0%2040%
EntityLabel(...)0%110100%
CopyCriteria(...)100%210%
.ctor(...)100%22100%
WithWarehouseAndClient(...)100%210%
Build()100%11100%
DeserializeAnalystEntities(...)0%4260%
ExtractJsonObject(...)0%2040%
BuildStudioAgentSteps(...)100%210%
EmitAsync()0%620%
get_Summary()100%210%
get_Entities()100%210%
get_Kind()100%210%
get_Value()100%210%
get_Phrase()100%210%
ToDto()0%7280%

File(s)

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

#LineLine coverage
 1using System.Globalization;
 2using System.Text.Json;
 3using System.Text.RegularExpressions;
 4using Microsoft.Extensions.Logging;
 5using Services.ApiServices.OpenRouterApi;
 6using Services.ApiServices.OpenRouterApi.Models;
 7
 8namespace Services.AiAssistant;
 9
 10/// <summary>
 11/// «Студия запроса» — двухфазный разбор поиска заказов: анализ сущностей (склад ≠ клиент) → детерминированная специфика
 12/// Regex — якорь истины; LLM только выделяет сущности, не собирает SQL-поля напрямую.
 13/// </summary>
 14public sealed class AiOrderFilterQueryStudio
 15{
 16    private const int AnalystTimeoutMs = 3500;
 17
 18    private readonly OpenRouterDataManager _openRouter;
 19    private readonly ILogger<AiOrderFilterQueryStudio> _logger;
 20
 021    private static readonly JsonSerializerOptions JsonOptions = new()
 022    {
 023        PropertyNameCaseInsensitive = true,
 024        ReadCommentHandling         = JsonCommentHandling.Skip,
 025        AllowTrailingCommas         = true,
 026    };
 27
 028    public AiOrderFilterQueryStudio(
 029        OpenRouterDataManager openRouter,
 030        ILogger<AiOrderFilterQueryStudio> logger)
 31    {
 032        _openRouter = openRouter;
 033        _logger     = logger;
 034    }
 35
 36    public async Task<AiOrderFilterStudioResult> AnalyzeAndBuildAsync(
 37        string message,
 38        DateTime nowMsk,
 39        Func<AiAgentProgressUpdate, Task>? onProgress = null,
 40        CancellationToken ct = default)
 41    {
 042        var agentSteps = BuildStudioAgentSteps(
 043            regex: ("active", "Разбор шаблонов…", false),
 044            llm:   ("pending", "Ожидание…", false),
 045            spec:  ("pending", "Ожидание…", false));
 46
 047        await EmitAsync(onProgress, agentSteps, "⟳ Студия запроса: разбор шаблонов…", "spin", "studio");
 48
 049        AiOrderFilterCriteria? regexCriteria = null;
 050        if (AiOrderFilterParser.TryParseRegex(message, out var parsed))
 051            regexCriteria = parsed;
 52
 053        agentSteps = BuildStudioAgentSteps(
 054            regex: ("done", regexCriteria != null ? "Шаблоны распознаны" : "Без шаблонов", false),
 055            llm:   ("active", "Анализ формулировки…", false),
 056            spec:  ("pending", "Ожидание…", false));
 057        await EmitAsync(onProgress, agentSteps, "⟳ Анализ формулировки (склад / клиент / период)…", "spin", "studio");
 58
 059        var llmEntitiesList = await TryAnalyzeEntitiesAsync(message, nowMsk, ct);
 060        var llmEntities     = llmEntitiesList ?? [];
 61
 062        var (criteria, entities, corrections) = MergeToCriteria(regexCriteria, llmEntities);
 063        criteria = AiOrderFilterParser.NormalizeCriteria(criteria);
 64
 065        var summary = BuildSpecificationSummary(entities, criteria, corrections);
 66
 067        agentSteps = BuildStudioAgentSteps(
 068            regex: ("done", regexCriteria != null ? "Шаблоны" : "—", false),
 069            llm:   ("done", llmEntitiesList != null ? "Сущности выделены" : "Только шаблоны", false),
 070            spec:  ("done", criteria.DescribeRu(), false));
 071        await EmitAsync(onProgress, agentSteps, $"✓ Спецификация: {summary}", "check", "studio");
 72
 073        return new AiOrderFilterStudioResult
 074        {
 075            Criteria              = criteria,
 076            SpecificationSummary  = summary,
 077            Entities              = entities,
 078            Corrections           = corrections,
 079            UsedRegex             = regexCriteria != null,
 080            UsedLlmAnalyst        = llmEntitiesList != null,
 081        };
 082    }
 83
 84    private async Task<IReadOnlyList<AiFilterEntityDto>?> TryAnalyzeEntitiesAsync(
 85        string message,
 86        DateTime nowMsk,
 87        CancellationToken ct)
 88    {
 089        using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
 090        timeoutCts.CancelAfter(AnalystTimeoutMs);
 91
 92        try
 93        {
 094            var prompt = BuildAnalystPrompt(message.Trim(), nowMsk);
 095            var raw    = await _openRouter.AskWithContextAsync(
 096                prompt,
 097                dataContext: string.Empty,
 098                history: null,
 099                modelOverride: null,
 0100                ct: timeoutCts.Token);
 101
 0102            return DeserializeAnalystEntities(raw);
 103        }
 0104        catch (OperationCanceledException) when (!ct.IsCancellationRequested)
 105        {
 0106            _logger.LogDebug("AiOrderFilterQueryStudio: analyst timeout after {Ms}ms", AnalystTimeoutMs);
 0107            return null;
 108        }
 0109        catch (Exception ex)
 110        {
 0111            _logger.LogWarning(ex, "AiOrderFilterQueryStudio: analyst failed");
 0112            return null;
 113        }
 0114    }
 115
 116    internal static string BuildAnalystPrompt(string userMessage, DateTime nowMsk) =>
 0117        $$"""
 0118         Ты аналитик поисковых запросов по заказам Ozon в Studio2.
 0119         НЕ строй SQL и НЕ смешивай разные сущности в одно поле.
 0120         Ответь ТОЛЬКО JSON (без markdown):
 0121         {
 0122           "summary": "кратко по-русски что ищем",
 0123           "entities": [
 0124             {"kind": "warehouse", "value": "M2", "phrase": "по складу m2"},
 0125             {"kind": "ozon_client", "value": "Interparts RU", "phrase": "на клиенте interparts ru"}
 0126           ]
 0127         }
 0128
 0129         kind — одно из:
 0130         warehouse, ozon_client, supplier, app_status, client_status,
 0131         negative_profit, overdue, actionable, empty_price, include_archived,
 0132         period_months, article, product_name, manufacturer, category,
 0133         price_min, price_max, profit_min, profit_max, purchase_price_max,
 0134         delivery_city, delivery_provider, min_quantity, with_returns, with_tracking
 0135
 0136         Правила:
 0137         - «по складу M2» → kind=warehouse, value только код склада (M2, C0, K1…).
 0138         - «на клиенте Interparts RU» → kind=ozon_client, value только имя Ozon-клиента.
 0139         - НИКОГДА не клади «на клиенте …» в warehouse.
 0140         - «минусят», «убыточные», «в минусе» → kind=negative_profit, value=true.
 0141         - «за месяц», «за последний месяц» → kind=period_months, value=1.
 0142         - «просроченные» → kind=overdue, value=true.
 0143         - Если запрос не про поиск заказов — {"summary":"","entities":[]}.
 0144
 0145         Сегодня (МСК): {{nowMsk:yyyy-MM-dd HH:mm}}.
 0146         Запрос: {{userMessage}}
 0147         """;
 148
 149    internal static (AiOrderFilterCriteria Criteria, List<AiFilterEntityDto> Entities, List<string> Corrections)
 150        MergeToCriteria(AiOrderFilterCriteria? regexBase, IReadOnlyList<AiFilterEntityDto> llmEntities)
 151    {
 3152        var entities = llmEntities.ToList();
 3153        var corrections = new List<string>();
 154
 3155        var builder = new CriteriaBuilder(regexBase);
 156
 16157        foreach (var entity in entities)
 5158            ApplyEntity(builder, entity, regexBase != null, corrections);
 159
 3160        if (regexBase != null)
 2161            ReinforceRegexAnchors(builder, regexBase, corrections);
 162
 3163        var criteria = builder.Build();
 164
 3165        var (wh, client) = AiOrderFilterParser.SplitWarehouseAndOzonClient(
 3166            criteria.ShipmentWarehouseName,
 3167            criteria.OzonClientName);
 168
 3169        if (wh != criteria.ShipmentWarehouseName || client != criteria.OzonClientName)
 170        {
 0171            corrections.Add("Склад и Ozon-клиент разделены (были склеены)");
 0172            criteria = builder.WithWarehouseAndClient(wh, client).Build();
 173        }
 174
 3175        return (criteria, entities, corrections);
 176    }
 177
 178    private static void ReinforceRegexAnchors(
 179        CriteriaBuilder target,
 180        AiOrderFilterCriteria regex,
 181        List<string> corrections)
 182    {
 2183        if (!string.IsNullOrWhiteSpace(regex.ShipmentWarehouseName)
 2184            && !string.Equals(target.ShipmentWarehouseName, regex.ShipmentWarehouseName, StringComparison.OrdinalIgnoreC
 185        {
 0186            corrections.Add($"Склад из шаблона: «{regex.ShipmentWarehouseName}»");
 0187            target.ShipmentWarehouseName = regex.ShipmentWarehouseName;
 188        }
 189
 2190        if (!string.IsNullOrWhiteSpace(regex.OzonClientName)
 2191            && !string.Equals(target.OzonClientName, regex.OzonClientName, StringComparison.OrdinalIgnoreCase))
 192        {
 0193            corrections.Add($"Ozon-клиент из шаблона: «{regex.OzonClientName}»");
 0194            target.OzonClientName = regex.OzonClientName;
 195        }
 196
 2197        if (regex.LastMonths.HasValue)
 2198            target.LastMonths = regex.LastMonths;
 2199        if (regex.OnlyNegativeProfit == true)
 2200            target.OnlyNegativeProfit = true;
 2201    }
 202
 203    private static void ApplyEntity(
 204        CriteriaBuilder c,
 205        AiFilterEntityDto entity,
 206        bool hasRegex,
 207        List<string> corrections)
 208    {
 5209        var kind  = entity.Kind.Trim().ToLowerInvariant();
 5210        var value = entity.Value?.Trim();
 211
 212        switch (kind)
 213        {
 214            case "warehouse":
 2215                if (string.IsNullOrWhiteSpace(value)) break;
 2216                if (value.Contains("на клиенте", StringComparison.OrdinalIgnoreCase)
 2217                    || value.Contains("у клиента", StringComparison.OrdinalIgnoreCase))
 218                {
 1219                    corrections.Add("LLM склеил склад и клиента — исправлено");
 1220                    var (wh, cl) = AiOrderFilterParser.SplitWarehouseAndOzonClient(value, c.OzonClientName);
 2221                    if (!string.IsNullOrWhiteSpace(wh)) c.ShipmentWarehouseName = wh;
 2222                    if (!string.IsNullOrWhiteSpace(cl)) c.OzonClientName = cl;
 223                }
 1224                else if (!hasRegex || string.IsNullOrWhiteSpace(c.ShipmentWarehouseName))
 0225                    c.ShipmentWarehouseName = value;
 0226                break;
 227
 228            case "ozon_client":
 1229                if (!string.IsNullOrWhiteSpace(value)
 1230                    && (!hasRegex || string.IsNullOrWhiteSpace(c.OzonClientName)))
 0231                    c.OzonClientName = value;
 0232                break;
 233
 234            case "supplier":
 0235                if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(c.SupplierName))
 0236                    c.SupplierName = value;
 0237                break;
 238
 239            case "app_status":
 0240                if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(c.AppStatusName))
 0241                    c.AppStatusName = value;
 0242                break;
 243
 244            case "client_status":
 0245                if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(c.ClientStatusName))
 0246                    c.ClientStatusName = value;
 0247                break;
 248
 249            case "negative_profit":
 1250                c.OnlyNegativeProfit = true;
 1251                break;
 252
 253            case "overdue":
 0254                c.OverdueOnly = true;
 0255                break;
 256
 257            case "actionable":
 0258                c.ActionableOnly = true;
 0259                break;
 260
 261            case "empty_price":
 0262                c.EmptyPrice = true;
 0263                break;
 264
 265            case "include_archived":
 0266                c.IncludeArchived = true;
 0267                break;
 268
 269            case "period_months":
 1270                if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var months) && months > 
 1271                    c.LastMonths ??= months;
 272                break;
 273
 274            case "article":
 0275                if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(c.Article))
 0276                    c.Article = value;
 0277                break;
 278
 279            case "product_name":
 0280                if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(c.ProductName))
 0281                    c.ProductName = value;
 0282                break;
 283
 284            case "manufacturer":
 0285                if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(c.ManufacturerName))
 0286                    c.ManufacturerName = value;
 0287                break;
 288
 289            case "category":
 0290                if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(c.NewCategory))
 0291                    c.NewCategory = value;
 0292                break;
 293
 294            case "with_returns":
 0295                c.OnlyWithReturns = true;
 0296                break;
 297
 298            case "with_tracking":
 0299                c.OnlyWithTrackingNumber = true;
 0300                break;
 301
 302            case "min_quantity":
 0303                if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var q) && q > 0)
 0304                    c.MinQuantity ??= q;
 305                break;
 306
 307            case "price_min":
 0308                if (TryDecimal(value, out var pmin)) c.PriceMin ??= pmin;
 309                break;
 310
 311            case "price_max":
 0312                if (TryDecimal(value, out var pmax)) c.PriceMax ??= pmax;
 313                break;
 314
 315            case "profit_min":
 0316                if (TryDecimal(value, out var prmin)) c.ProfitMin ??= prmin;
 317                break;
 318
 319            case "profit_max":
 0320                if (TryDecimal(value, out var prmax)) c.ProfitMax ??= prmax;
 321                break;
 322
 323            case "purchase_price_max":
 0324                if (TryDecimal(value, out var ppmax)) c.PurchasePriceMax ??= ppmax;
 325                break;
 326
 327            case "delivery_city":
 0328                if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(c.DeliveryCity))
 0329                    c.DeliveryCity = value;
 0330                break;
 331
 332            case "delivery_provider":
 0333                if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(c.DeliveryProviderName))
 0334                    c.DeliveryProviderName = value;
 335                break;
 336        }
 2337    }
 338
 339    private static bool TryDecimal(string? value, out decimal result)
 340    {
 0341        result = 0;
 0342        if (string.IsNullOrWhiteSpace(value))
 0343            return false;
 0344        return decimal.TryParse(value.Replace(" ", ""), NumberStyles.Number, CultureInfo.InvariantCulture, out result);
 345    }
 346
 347    internal static string BuildSpecificationSummary(
 348        IReadOnlyList<AiFilterEntityDto> entities,
 349        AiOrderFilterCriteria criteria,
 350        IReadOnlyList<string> corrections)
 351    {
 0352        if (!string.IsNullOrWhiteSpace(criteria.DescribeRu()))
 0353            return criteria.DescribeRu();
 354
 0355        if (entities.Count > 0)
 0356            return string.Join("; ", entities.Select(e => $"{EntityLabel(e.Kind)} «{e.Value}»"));
 357
 0358        return "критерии не определены";
 359    }
 360
 0361    private static string EntityLabel(string kind) => kind switch
 0362    {
 0363        "warehouse"       => "склад",
 0364        "ozon_client"     => "Ozon-клиент",
 0365        "supplier"        => "поставщик",
 0366        "negative_profit" => "прибыль < 0",
 0367        "period_months"   => "период, мес.",
 0368        _                 => kind,
 0369    };
 370
 371    private static AiOrderFilterCriteria CopyCriteria(AiOrderFilterCriteria s) =>
 0372        new CriteriaBuilder(s).Build();
 373
 374    private sealed class CriteriaBuilder
 375    {
 376        public bool ActionableOnly;
 377        public bool OverdueOnly;
 378        public bool EmptyPrice;
 379        public bool IncludeArchived;
 380        public string? OzonClientName;
 381        public string? SupplierName;
 382        public string? TransactionConductedBy;
 383        public string? ChangedByUser;
 384        public string? AppStatusName;
 385        public string? ClientStatusName;
 386        public bool? OnlyWithReturns;
 387        public string? Article;
 388        public string? ProductName;
 389        public string? ManufacturerName;
 390        public string? NewCategory;
 391        public int? MinQuantity;
 392        public int? MaxQuantity;
 393        public decimal? PriceMin;
 394        public decimal? PriceMax;
 395        public decimal? ProfitMin;
 396        public decimal? ProfitMax;
 397        public bool? OnlyNegativeProfit;
 398        public decimal? PurchasePriceMax;
 399        public string? ShipmentWarehouseName;
 400        public string? DeliveryProviderName;
 401        public string? DeliveryName;
 402        public string? DeliveryCity;
 403        public bool? OnlyWithTrackingNumber;
 404        public DateTime? ShippingDateFrom;
 405        public DateTime? ShippingDateTo;
 406        public DateTime? ProcessingDateFrom;
 407        public DateTime? ProcessingDateTo;
 408        public int? LastMonths;
 409        public DateTime? ChangedFrom;
 410        public DateTime? ChangedTo;
 411        public string? FieldChangedName;
 412        public string? FieldChangedToValue;
 413
 3414        public CriteriaBuilder(AiOrderFilterCriteria? seed = null)
 415        {
 3416            if (seed == null)
 1417                return;
 418
 2419            ActionableOnly         = seed.ActionableOnly;
 2420            OverdueOnly            = seed.OverdueOnly;
 2421            EmptyPrice             = seed.EmptyPrice;
 2422            IncludeArchived        = seed.IncludeArchived;
 2423            OzonClientName         = seed.OzonClientName;
 2424            SupplierName           = seed.SupplierName;
 2425            TransactionConductedBy = seed.TransactionConductedBy;
 2426            ChangedByUser          = seed.ChangedByUser;
 2427            AppStatusName          = seed.AppStatusName;
 2428            ClientStatusName       = seed.ClientStatusName;
 2429            OnlyWithReturns        = seed.OnlyWithReturns;
 2430            Article                = seed.Article;
 2431            ProductName            = seed.ProductName;
 2432            ManufacturerName       = seed.ManufacturerName;
 2433            NewCategory            = seed.NewCategory;
 2434            MinQuantity            = seed.MinQuantity;
 2435            MaxQuantity            = seed.MaxQuantity;
 2436            PriceMin               = seed.PriceMin;
 2437            PriceMax               = seed.PriceMax;
 2438            ProfitMin              = seed.ProfitMin;
 2439            ProfitMax              = seed.ProfitMax;
 2440            OnlyNegativeProfit     = seed.OnlyNegativeProfit;
 2441            PurchasePriceMax       = seed.PurchasePriceMax;
 2442            ShipmentWarehouseName  = seed.ShipmentWarehouseName;
 2443            DeliveryProviderName   = seed.DeliveryProviderName;
 2444            DeliveryName           = seed.DeliveryName;
 2445            DeliveryCity           = seed.DeliveryCity;
 2446            OnlyWithTrackingNumber = seed.OnlyWithTrackingNumber;
 2447            ShippingDateFrom       = seed.ShippingDateFrom;
 2448            ShippingDateTo         = seed.ShippingDateTo;
 2449            ProcessingDateFrom     = seed.ProcessingDateFrom;
 2450            ProcessingDateTo       = seed.ProcessingDateTo;
 2451            LastMonths             = seed.LastMonths;
 2452            ChangedFrom            = seed.ChangedFrom;
 2453            ChangedTo              = seed.ChangedTo;
 2454            FieldChangedName       = seed.FieldChangedName;
 2455            FieldChangedToValue    = seed.FieldChangedToValue;
 2456        }
 457
 458        public CriteriaBuilder WithWarehouseAndClient(string? warehouse, string? ozonClient)
 459        {
 0460            ShipmentWarehouseName = warehouse;
 0461            OzonClientName        = ozonClient;
 0462            return this;
 463        }
 464
 3465        public AiOrderFilterCriteria Build() => new()
 3466        {
 3467            ActionableOnly         = ActionableOnly,
 3468            OverdueOnly            = OverdueOnly,
 3469            EmptyPrice             = EmptyPrice,
 3470            IncludeArchived        = IncludeArchived,
 3471            OzonClientName         = OzonClientName,
 3472            SupplierName           = SupplierName,
 3473            TransactionConductedBy = TransactionConductedBy,
 3474            ChangedByUser          = ChangedByUser,
 3475            AppStatusName          = AppStatusName,
 3476            ClientStatusName       = ClientStatusName,
 3477            OnlyWithReturns        = OnlyWithReturns,
 3478            Article                = Article,
 3479            ProductName            = ProductName,
 3480            ManufacturerName       = ManufacturerName,
 3481            NewCategory            = NewCategory,
 3482            MinQuantity            = MinQuantity,
 3483            MaxQuantity            = MaxQuantity,
 3484            PriceMin               = PriceMin,
 3485            PriceMax               = PriceMax,
 3486            ProfitMin              = ProfitMin,
 3487            ProfitMax              = ProfitMax,
 3488            OnlyNegativeProfit     = OnlyNegativeProfit,
 3489            PurchasePriceMax       = PurchasePriceMax,
 3490            ShipmentWarehouseName  = ShipmentWarehouseName,
 3491            DeliveryProviderName   = DeliveryProviderName,
 3492            DeliveryName           = DeliveryName,
 3493            DeliveryCity           = DeliveryCity,
 3494            OnlyWithTrackingNumber = OnlyWithTrackingNumber,
 3495            ShippingDateFrom       = ShippingDateFrom,
 3496            ShippingDateTo         = ShippingDateTo,
 3497            ProcessingDateFrom     = ProcessingDateFrom,
 3498            ProcessingDateTo       = ProcessingDateTo,
 3499            LastMonths             = LastMonths,
 3500            ChangedFrom            = ChangedFrom,
 3501            ChangedTo              = ChangedTo,
 3502            FieldChangedName       = FieldChangedName,
 3503            FieldChangedToValue    = FieldChangedToValue,
 3504        };
 505    }
 506
 507    private static IReadOnlyList<AiFilterEntityDto>? DeserializeAnalystEntities(string raw)
 508    {
 0509        var json = ExtractJsonObject(raw);
 0510        if (string.IsNullOrWhiteSpace(json))
 0511            return null;
 512        try
 513        {
 0514            var dto = JsonSerializer.Deserialize<LlmAnalystDto>(json, JsonOptions);
 0515            if (dto?.Entities == null)
 0516                return [];
 0517            return dto.Entities
 0518                .Select(e => e.ToDto())
 0519                .Where(e => e != null)
 0520                .Cast<AiFilterEntityDto>()
 0521                .ToList();
 522        }
 0523        catch
 524        {
 0525            return null;
 526        }
 0527    }
 528
 529    private static string? ExtractJsonObject(string raw)
 530    {
 0531        var trimmed = raw.Trim();
 0532        if (trimmed.StartsWith('{'))
 0533            return trimmed;
 0534        var match = Regex.Match(trimmed, @"\{[\s\S]*\}");
 0535        return match.Success ? match.Value : null;
 536    }
 537
 538    private static List<AiProcessStepDto> BuildStudioAgentSteps(
 539        (string Status, string Label, bool FromCache) regex,
 540        (string Status, string Label, bool FromCache) llm,
 541        (string Status, string Label, bool FromCache) spec) =>
 0542    [
 0543        new AiProcessStepDto
 0544        {
 0545            Key = "studio_regex", Label = regex.Label, Status = regex.Status,
 0546            Done = regex.Status == "done", IsAgent = true, FromCache = regex.FromCache,
 0547            Detail = "Шаблоны",
 0548        },
 0549        new AiProcessStepDto
 0550        {
 0551            Key = "studio_llm", Label = llm.Label, Status = llm.Status,
 0552            Done = llm.Status == "done", IsAgent = true, FromCache = llm.FromCache,
 0553            Detail = "Анализ",
 0554        },
 0555        new AiProcessStepDto
 0556        {
 0557            Key = "studio_spec", Label = spec.Label, Status = spec.Status,
 0558            Done = spec.Status == "done", IsAgent = true, FromCache = spec.FromCache,
 0559            Detail = "Спецификация",
 0560        },
 0561    ];
 562
 563    private static async Task EmitAsync(
 564        Func<AiAgentProgressUpdate, Task>? onProgress,
 565        List<AiProcessStepDto> steps,
 566        string logLine,
 567        string icon,
 568        string phase)
 569    {
 0570        if (onProgress == null)
 0571            return;
 0572        await onProgress(new AiAgentProgressUpdate
 0573        {
 0574            Steps   = steps,
 0575            Phase   = phase,
 0576            LogLine = logLine,
 0577            LogIcon = icon,
 0578        });
 0579    }
 580
 581    private sealed class LlmAnalystDto
 582    {
 0583        public string? Summary { get; set; }
 0584        public List<LlmEntityDto>? Entities { get; set; }
 585    }
 586
 587    private sealed class LlmEntityDto
 588    {
 0589        public string? Kind   { get; set; }
 0590        public string? Value  { get; set; }
 0591        public string? Phrase { get; set; }
 592
 593        public AiFilterEntityDto? ToDto()
 594        {
 0595            if (string.IsNullOrWhiteSpace(Kind))
 0596                return null;
 0597            return new AiFilterEntityDto
 0598            {
 0599                Kind   = Kind.Trim(),
 0600                Value  = Value?.Trim() ?? string.Empty,
 0601                Phrase = Phrase?.Trim(),
 0602            };
 603        }
 604    }
 605}
 606
 607public sealed class AiOrderFilterStudioResult
 608{
 609    public required AiOrderFilterCriteria Criteria { get; init; }
 610    public string SpecificationSummary { get; init; } = string.Empty;
 611    public List<AiFilterEntityDto> Entities { get; init; } = [];
 612    public List<string> Corrections { get; init; } = [];
 613    public bool UsedRegex { get; init; }
 614    public bool UsedLlmAnalyst { get; init; }
 615}
 616
 617public sealed class AiFilterEntityDto
 618{
 619    public string Kind   { get; init; } = string.Empty;
 620    public string Value  { get; init; } = string.Empty;
 621    public string? Phrase { get; init; }
 622}

Methods/Properties

.cctor()
.ctor(Services.ApiServices.OpenRouterApi.OpenRouterDataManager,Microsoft.Extensions.Logging.ILogger`1<Services.AiAssistant.AiOrderFilterQueryStudio>)
AnalyzeAndBuildAsync()
TryAnalyzeEntitiesAsync()
BuildAnalystPrompt(System.String,System.DateTime)
MergeToCriteria(Services.AiAssistant.AiOrderFilterCriteria,System.Collections.Generic.IReadOnlyList`1<Services.AiAssistant.AiFilterEntityDto>)
ReinforceRegexAnchors(Services.AiAssistant.AiOrderFilterQueryStudio/CriteriaBuilder,Services.AiAssistant.AiOrderFilterCriteria,System.Collections.Generic.List`1<System.String>)
ApplyEntity(Services.AiAssistant.AiOrderFilterQueryStudio/CriteriaBuilder,Services.AiAssistant.AiFilterEntityDto,System.Boolean,System.Collections.Generic.List`1<System.String>)
TryDecimal(System.String,System.Decimal&)
BuildSpecificationSummary(System.Collections.Generic.IReadOnlyList`1<Services.AiAssistant.AiFilterEntityDto>,Services.AiAssistant.AiOrderFilterCriteria,System.Collections.Generic.IReadOnlyList`1<System.String>)
EntityLabel(System.String)
CopyCriteria(Services.AiAssistant.AiOrderFilterCriteria)
.ctor(Services.AiAssistant.AiOrderFilterCriteria)
WithWarehouseAndClient(System.String,System.String)
Build()
DeserializeAnalystEntities(System.String)
ExtractJsonObject(System.String)
BuildStudioAgentSteps(System.ValueTuple`3<System.String,System.String,System.Boolean>,System.ValueTuple`3<System.String,System.String,System.Boolean>,System.ValueTuple`3<System.String,System.String,System.Boolean>)
EmitAsync()
get_Summary()
get_Entities()
get_Kind()
get_Value()
get_Phrase()
ToDto()