< Summary

Information
Class: Services.AiAssistant.AiOrderFilterStudioResult
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/AiAssistant/AiOrderFilterQueryStudio.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 6
Coverable lines: 6
Total lines: 622
Line coverage: 0%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Criteria()100%210%
get_SpecificationSummary()100%210%
get_Entities()100%210%
get_Corrections()100%210%
get_UsedRegex()100%210%
get_UsedLlmAnalyst()100%210%

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
 21    private static readonly JsonSerializerOptions JsonOptions = new()
 22    {
 23        PropertyNameCaseInsensitive = true,
 24        ReadCommentHandling         = JsonCommentHandling.Skip,
 25        AllowTrailingCommas         = true,
 26    };
 27
 28    public AiOrderFilterQueryStudio(
 29        OpenRouterDataManager openRouter,
 30        ILogger<AiOrderFilterQueryStudio> logger)
 31    {
 32        _openRouter = openRouter;
 33        _logger     = logger;
 34    }
 35
 36    public async Task<AiOrderFilterStudioResult> AnalyzeAndBuildAsync(
 37        string message,
 38        DateTime nowMsk,
 39        Func<AiAgentProgressUpdate, Task>? onProgress = null,
 40        CancellationToken ct = default)
 41    {
 42        var agentSteps = BuildStudioAgentSteps(
 43            regex: ("active", "Разбор шаблонов…", false),
 44            llm:   ("pending", "Ожидание…", false),
 45            spec:  ("pending", "Ожидание…", false));
 46
 47        await EmitAsync(onProgress, agentSteps, "⟳ Студия запроса: разбор шаблонов…", "spin", "studio");
 48
 49        AiOrderFilterCriteria? regexCriteria = null;
 50        if (AiOrderFilterParser.TryParseRegex(message, out var parsed))
 51            regexCriteria = parsed;
 52
 53        agentSteps = BuildStudioAgentSteps(
 54            regex: ("done", regexCriteria != null ? "Шаблоны распознаны" : "Без шаблонов", false),
 55            llm:   ("active", "Анализ формулировки…", false),
 56            spec:  ("pending", "Ожидание…", false));
 57        await EmitAsync(onProgress, agentSteps, "⟳ Анализ формулировки (склад / клиент / период)…", "spin", "studio");
 58
 59        var llmEntitiesList = await TryAnalyzeEntitiesAsync(message, nowMsk, ct);
 60        var llmEntities     = llmEntitiesList ?? [];
 61
 62        var (criteria, entities, corrections) = MergeToCriteria(regexCriteria, llmEntities);
 63        criteria = AiOrderFilterParser.NormalizeCriteria(criteria);
 64
 65        var summary = BuildSpecificationSummary(entities, criteria, corrections);
 66
 67        agentSteps = BuildStudioAgentSteps(
 68            regex: ("done", regexCriteria != null ? "Шаблоны" : "—", false),
 69            llm:   ("done", llmEntitiesList != null ? "Сущности выделены" : "Только шаблоны", false),
 70            spec:  ("done", criteria.DescribeRu(), false));
 71        await EmitAsync(onProgress, agentSteps, $"✓ Спецификация: {summary}", "check", "studio");
 72
 73        return new AiOrderFilterStudioResult
 74        {
 75            Criteria              = criteria,
 76            SpecificationSummary  = summary,
 77            Entities              = entities,
 78            Corrections           = corrections,
 79            UsedRegex             = regexCriteria != null,
 80            UsedLlmAnalyst        = llmEntitiesList != null,
 81        };
 82    }
 83
 84    private async Task<IReadOnlyList<AiFilterEntityDto>?> TryAnalyzeEntitiesAsync(
 85        string message,
 86        DateTime nowMsk,
 87        CancellationToken ct)
 88    {
 89        using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
 90        timeoutCts.CancelAfter(AnalystTimeoutMs);
 91
 92        try
 93        {
 94            var prompt = BuildAnalystPrompt(message.Trim(), nowMsk);
 95            var raw    = await _openRouter.AskWithContextAsync(
 96                prompt,
 97                dataContext: string.Empty,
 98                history: null,
 99                modelOverride: null,
 100                ct: timeoutCts.Token);
 101
 102            return DeserializeAnalystEntities(raw);
 103        }
 104        catch (OperationCanceledException) when (!ct.IsCancellationRequested)
 105        {
 106            _logger.LogDebug("AiOrderFilterQueryStudio: analyst timeout after {Ms}ms", AnalystTimeoutMs);
 107            return null;
 108        }
 109        catch (Exception ex)
 110        {
 111            _logger.LogWarning(ex, "AiOrderFilterQueryStudio: analyst failed");
 112            return null;
 113        }
 114    }
 115
 116    internal static string BuildAnalystPrompt(string userMessage, DateTime nowMsk) =>
 117        $$"""
 118         Ты аналитик поисковых запросов по заказам Ozon в Studio2.
 119         НЕ строй SQL и НЕ смешивай разные сущности в одно поле.
 120         Ответь ТОЛЬКО JSON (без markdown):
 121         {
 122           "summary": "кратко по-русски что ищем",
 123           "entities": [
 124             {"kind": "warehouse", "value": "M2", "phrase": "по складу m2"},
 125             {"kind": "ozon_client", "value": "Interparts RU", "phrase": "на клиенте interparts ru"}
 126           ]
 127         }
 128
 129         kind — одно из:
 130         warehouse, ozon_client, supplier, app_status, client_status,
 131         negative_profit, overdue, actionable, empty_price, include_archived,
 132         period_months, article, product_name, manufacturer, category,
 133         price_min, price_max, profit_min, profit_max, purchase_price_max,
 134         delivery_city, delivery_provider, min_quantity, with_returns, with_tracking
 135
 136         Правила:
 137         - «по складу M2» → kind=warehouse, value только код склада (M2, C0, K1…).
 138         - «на клиенте Interparts RU» → kind=ozon_client, value только имя Ozon-клиента.
 139         - НИКОГДА не клади «на клиенте …» в warehouse.
 140         - «минусят», «убыточные», «в минусе» → kind=negative_profit, value=true.
 141         - «за месяц», «за последний месяц» → kind=period_months, value=1.
 142         - «просроченные» → kind=overdue, value=true.
 143         - Если запрос не про поиск заказов — {"summary":"","entities":[]}.
 144
 145         Сегодня (МСК): {{nowMsk:yyyy-MM-dd HH:mm}}.
 146         Запрос: {{userMessage}}
 147         """;
 148
 149    internal static (AiOrderFilterCriteria Criteria, List<AiFilterEntityDto> Entities, List<string> Corrections)
 150        MergeToCriteria(AiOrderFilterCriteria? regexBase, IReadOnlyList<AiFilterEntityDto> llmEntities)
 151    {
 152        var entities = llmEntities.ToList();
 153        var corrections = new List<string>();
 154
 155        var builder = new CriteriaBuilder(regexBase);
 156
 157        foreach (var entity in entities)
 158            ApplyEntity(builder, entity, regexBase != null, corrections);
 159
 160        if (regexBase != null)
 161            ReinforceRegexAnchors(builder, regexBase, corrections);
 162
 163        var criteria = builder.Build();
 164
 165        var (wh, client) = AiOrderFilterParser.SplitWarehouseAndOzonClient(
 166            criteria.ShipmentWarehouseName,
 167            criteria.OzonClientName);
 168
 169        if (wh != criteria.ShipmentWarehouseName || client != criteria.OzonClientName)
 170        {
 171            corrections.Add("Склад и Ozon-клиент разделены (были склеены)");
 172            criteria = builder.WithWarehouseAndClient(wh, client).Build();
 173        }
 174
 175        return (criteria, entities, corrections);
 176    }
 177
 178    private static void ReinforceRegexAnchors(
 179        CriteriaBuilder target,
 180        AiOrderFilterCriteria regex,
 181        List<string> corrections)
 182    {
 183        if (!string.IsNullOrWhiteSpace(regex.ShipmentWarehouseName)
 184            && !string.Equals(target.ShipmentWarehouseName, regex.ShipmentWarehouseName, StringComparison.OrdinalIgnoreC
 185        {
 186            corrections.Add($"Склад из шаблона: «{regex.ShipmentWarehouseName}»");
 187            target.ShipmentWarehouseName = regex.ShipmentWarehouseName;
 188        }
 189
 190        if (!string.IsNullOrWhiteSpace(regex.OzonClientName)
 191            && !string.Equals(target.OzonClientName, regex.OzonClientName, StringComparison.OrdinalIgnoreCase))
 192        {
 193            corrections.Add($"Ozon-клиент из шаблона: «{regex.OzonClientName}»");
 194            target.OzonClientName = regex.OzonClientName;
 195        }
 196
 197        if (regex.LastMonths.HasValue)
 198            target.LastMonths = regex.LastMonths;
 199        if (regex.OnlyNegativeProfit == true)
 200            target.OnlyNegativeProfit = true;
 201    }
 202
 203    private static void ApplyEntity(
 204        CriteriaBuilder c,
 205        AiFilterEntityDto entity,
 206        bool hasRegex,
 207        List<string> corrections)
 208    {
 209        var kind  = entity.Kind.Trim().ToLowerInvariant();
 210        var value = entity.Value?.Trim();
 211
 212        switch (kind)
 213        {
 214            case "warehouse":
 215                if (string.IsNullOrWhiteSpace(value)) break;
 216                if (value.Contains("на клиенте", StringComparison.OrdinalIgnoreCase)
 217                    || value.Contains("у клиента", StringComparison.OrdinalIgnoreCase))
 218                {
 219                    corrections.Add("LLM склеил склад и клиента — исправлено");
 220                    var (wh, cl) = AiOrderFilterParser.SplitWarehouseAndOzonClient(value, c.OzonClientName);
 221                    if (!string.IsNullOrWhiteSpace(wh)) c.ShipmentWarehouseName = wh;
 222                    if (!string.IsNullOrWhiteSpace(cl)) c.OzonClientName = cl;
 223                }
 224                else if (!hasRegex || string.IsNullOrWhiteSpace(c.ShipmentWarehouseName))
 225                    c.ShipmentWarehouseName = value;
 226                break;
 227
 228            case "ozon_client":
 229                if (!string.IsNullOrWhiteSpace(value)
 230                    && (!hasRegex || string.IsNullOrWhiteSpace(c.OzonClientName)))
 231                    c.OzonClientName = value;
 232                break;
 233
 234            case "supplier":
 235                if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(c.SupplierName))
 236                    c.SupplierName = value;
 237                break;
 238
 239            case "app_status":
 240                if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(c.AppStatusName))
 241                    c.AppStatusName = value;
 242                break;
 243
 244            case "client_status":
 245                if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(c.ClientStatusName))
 246                    c.ClientStatusName = value;
 247                break;
 248
 249            case "negative_profit":
 250                c.OnlyNegativeProfit = true;
 251                break;
 252
 253            case "overdue":
 254                c.OverdueOnly = true;
 255                break;
 256
 257            case "actionable":
 258                c.ActionableOnly = true;
 259                break;
 260
 261            case "empty_price":
 262                c.EmptyPrice = true;
 263                break;
 264
 265            case "include_archived":
 266                c.IncludeArchived = true;
 267                break;
 268
 269            case "period_months":
 270                if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var months) && months > 
 271                    c.LastMonths ??= months;
 272                break;
 273
 274            case "article":
 275                if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(c.Article))
 276                    c.Article = value;
 277                break;
 278
 279            case "product_name":
 280                if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(c.ProductName))
 281                    c.ProductName = value;
 282                break;
 283
 284            case "manufacturer":
 285                if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(c.ManufacturerName))
 286                    c.ManufacturerName = value;
 287                break;
 288
 289            case "category":
 290                if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(c.NewCategory))
 291                    c.NewCategory = value;
 292                break;
 293
 294            case "with_returns":
 295                c.OnlyWithReturns = true;
 296                break;
 297
 298            case "with_tracking":
 299                c.OnlyWithTrackingNumber = true;
 300                break;
 301
 302            case "min_quantity":
 303                if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var q) && q > 0)
 304                    c.MinQuantity ??= q;
 305                break;
 306
 307            case "price_min":
 308                if (TryDecimal(value, out var pmin)) c.PriceMin ??= pmin;
 309                break;
 310
 311            case "price_max":
 312                if (TryDecimal(value, out var pmax)) c.PriceMax ??= pmax;
 313                break;
 314
 315            case "profit_min":
 316                if (TryDecimal(value, out var prmin)) c.ProfitMin ??= prmin;
 317                break;
 318
 319            case "profit_max":
 320                if (TryDecimal(value, out var prmax)) c.ProfitMax ??= prmax;
 321                break;
 322
 323            case "purchase_price_max":
 324                if (TryDecimal(value, out var ppmax)) c.PurchasePriceMax ??= ppmax;
 325                break;
 326
 327            case "delivery_city":
 328                if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(c.DeliveryCity))
 329                    c.DeliveryCity = value;
 330                break;
 331
 332            case "delivery_provider":
 333                if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(c.DeliveryProviderName))
 334                    c.DeliveryProviderName = value;
 335                break;
 336        }
 337    }
 338
 339    private static bool TryDecimal(string? value, out decimal result)
 340    {
 341        result = 0;
 342        if (string.IsNullOrWhiteSpace(value))
 343            return false;
 344        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    {
 352        if (!string.IsNullOrWhiteSpace(criteria.DescribeRu()))
 353            return criteria.DescribeRu();
 354
 355        if (entities.Count > 0)
 356            return string.Join("; ", entities.Select(e => $"{EntityLabel(e.Kind)} «{e.Value}»"));
 357
 358        return "критерии не определены";
 359    }
 360
 361    private static string EntityLabel(string kind) => kind switch
 362    {
 363        "warehouse"       => "склад",
 364        "ozon_client"     => "Ozon-клиент",
 365        "supplier"        => "поставщик",
 366        "negative_profit" => "прибыль < 0",
 367        "period_months"   => "период, мес.",
 368        _                 => kind,
 369    };
 370
 371    private static AiOrderFilterCriteria CopyCriteria(AiOrderFilterCriteria s) =>
 372        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
 414        public CriteriaBuilder(AiOrderFilterCriteria? seed = null)
 415        {
 416            if (seed == null)
 417                return;
 418
 419            ActionableOnly         = seed.ActionableOnly;
 420            OverdueOnly            = seed.OverdueOnly;
 421            EmptyPrice             = seed.EmptyPrice;
 422            IncludeArchived        = seed.IncludeArchived;
 423            OzonClientName         = seed.OzonClientName;
 424            SupplierName           = seed.SupplierName;
 425            TransactionConductedBy = seed.TransactionConductedBy;
 426            ChangedByUser          = seed.ChangedByUser;
 427            AppStatusName          = seed.AppStatusName;
 428            ClientStatusName       = seed.ClientStatusName;
 429            OnlyWithReturns        = seed.OnlyWithReturns;
 430            Article                = seed.Article;
 431            ProductName            = seed.ProductName;
 432            ManufacturerName       = seed.ManufacturerName;
 433            NewCategory            = seed.NewCategory;
 434            MinQuantity            = seed.MinQuantity;
 435            MaxQuantity            = seed.MaxQuantity;
 436            PriceMin               = seed.PriceMin;
 437            PriceMax               = seed.PriceMax;
 438            ProfitMin              = seed.ProfitMin;
 439            ProfitMax              = seed.ProfitMax;
 440            OnlyNegativeProfit     = seed.OnlyNegativeProfit;
 441            PurchasePriceMax       = seed.PurchasePriceMax;
 442            ShipmentWarehouseName  = seed.ShipmentWarehouseName;
 443            DeliveryProviderName   = seed.DeliveryProviderName;
 444            DeliveryName           = seed.DeliveryName;
 445            DeliveryCity           = seed.DeliveryCity;
 446            OnlyWithTrackingNumber = seed.OnlyWithTrackingNumber;
 447            ShippingDateFrom       = seed.ShippingDateFrom;
 448            ShippingDateTo         = seed.ShippingDateTo;
 449            ProcessingDateFrom     = seed.ProcessingDateFrom;
 450            ProcessingDateTo       = seed.ProcessingDateTo;
 451            LastMonths             = seed.LastMonths;
 452            ChangedFrom            = seed.ChangedFrom;
 453            ChangedTo              = seed.ChangedTo;
 454            FieldChangedName       = seed.FieldChangedName;
 455            FieldChangedToValue    = seed.FieldChangedToValue;
 456        }
 457
 458        public CriteriaBuilder WithWarehouseAndClient(string? warehouse, string? ozonClient)
 459        {
 460            ShipmentWarehouseName = warehouse;
 461            OzonClientName        = ozonClient;
 462            return this;
 463        }
 464
 465        public AiOrderFilterCriteria Build() => new()
 466        {
 467            ActionableOnly         = ActionableOnly,
 468            OverdueOnly            = OverdueOnly,
 469            EmptyPrice             = EmptyPrice,
 470            IncludeArchived        = IncludeArchived,
 471            OzonClientName         = OzonClientName,
 472            SupplierName           = SupplierName,
 473            TransactionConductedBy = TransactionConductedBy,
 474            ChangedByUser          = ChangedByUser,
 475            AppStatusName          = AppStatusName,
 476            ClientStatusName       = ClientStatusName,
 477            OnlyWithReturns        = OnlyWithReturns,
 478            Article                = Article,
 479            ProductName            = ProductName,
 480            ManufacturerName       = ManufacturerName,
 481            NewCategory            = NewCategory,
 482            MinQuantity            = MinQuantity,
 483            MaxQuantity            = MaxQuantity,
 484            PriceMin               = PriceMin,
 485            PriceMax               = PriceMax,
 486            ProfitMin              = ProfitMin,
 487            ProfitMax              = ProfitMax,
 488            OnlyNegativeProfit     = OnlyNegativeProfit,
 489            PurchasePriceMax       = PurchasePriceMax,
 490            ShipmentWarehouseName  = ShipmentWarehouseName,
 491            DeliveryProviderName   = DeliveryProviderName,
 492            DeliveryName           = DeliveryName,
 493            DeliveryCity           = DeliveryCity,
 494            OnlyWithTrackingNumber = OnlyWithTrackingNumber,
 495            ShippingDateFrom       = ShippingDateFrom,
 496            ShippingDateTo         = ShippingDateTo,
 497            ProcessingDateFrom     = ProcessingDateFrom,
 498            ProcessingDateTo       = ProcessingDateTo,
 499            LastMonths             = LastMonths,
 500            ChangedFrom            = ChangedFrom,
 501            ChangedTo              = ChangedTo,
 502            FieldChangedName       = FieldChangedName,
 503            FieldChangedToValue    = FieldChangedToValue,
 504        };
 505    }
 506
 507    private static IReadOnlyList<AiFilterEntityDto>? DeserializeAnalystEntities(string raw)
 508    {
 509        var json = ExtractJsonObject(raw);
 510        if (string.IsNullOrWhiteSpace(json))
 511            return null;
 512        try
 513        {
 514            var dto = JsonSerializer.Deserialize<LlmAnalystDto>(json, JsonOptions);
 515            if (dto?.Entities == null)
 516                return [];
 517            return dto.Entities
 518                .Select(e => e.ToDto())
 519                .Where(e => e != null)
 520                .Cast<AiFilterEntityDto>()
 521                .ToList();
 522        }
 523        catch
 524        {
 525            return null;
 526        }
 527    }
 528
 529    private static string? ExtractJsonObject(string raw)
 530    {
 531        var trimmed = raw.Trim();
 532        if (trimmed.StartsWith('{'))
 533            return trimmed;
 534        var match = Regex.Match(trimmed, @"\{[\s\S]*\}");
 535        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) =>
 542    [
 543        new AiProcessStepDto
 544        {
 545            Key = "studio_regex", Label = regex.Label, Status = regex.Status,
 546            Done = regex.Status == "done", IsAgent = true, FromCache = regex.FromCache,
 547            Detail = "Шаблоны",
 548        },
 549        new AiProcessStepDto
 550        {
 551            Key = "studio_llm", Label = llm.Label, Status = llm.Status,
 552            Done = llm.Status == "done", IsAgent = true, FromCache = llm.FromCache,
 553            Detail = "Анализ",
 554        },
 555        new AiProcessStepDto
 556        {
 557            Key = "studio_spec", Label = spec.Label, Status = spec.Status,
 558            Done = spec.Status == "done", IsAgent = true, FromCache = spec.FromCache,
 559            Detail = "Спецификация",
 560        },
 561    ];
 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    {
 570        if (onProgress == null)
 571            return;
 572        await onProgress(new AiAgentProgressUpdate
 573        {
 574            Steps   = steps,
 575            Phase   = phase,
 576            LogLine = logLine,
 577            LogIcon = icon,
 578        });
 579    }
 580
 581    private sealed class LlmAnalystDto
 582    {
 583        public string? Summary { get; set; }
 584        public List<LlmEntityDto>? Entities { get; set; }
 585    }
 586
 587    private sealed class LlmEntityDto
 588    {
 589        public string? Kind   { get; set; }
 590        public string? Value  { get; set; }
 591        public string? Phrase { get; set; }
 592
 593        public AiFilterEntityDto? ToDto()
 594        {
 595            if (string.IsNullOrWhiteSpace(Kind))
 596                return null;
 597            return new AiFilterEntityDto
 598            {
 599                Kind   = Kind.Trim(),
 600                Value  = Value?.Trim() ?? string.Empty,
 601                Phrase = Phrase?.Trim(),
 602            };
 603        }
 604    }
 605}
 606
 607public sealed class AiOrderFilterStudioResult
 608{
 0609    public required AiOrderFilterCriteria Criteria { get; init; }
 0610    public string SpecificationSummary { get; init; } = string.Empty;
 0611    public List<AiFilterEntityDto> Entities { get; init; } = [];
 0612    public List<string> Corrections { get; init; } = [];
 0613    public bool UsedRegex { get; init; }
 0614    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}