< Summary

Information
Class: Services.ApiServices.OpenRouterApi.OpenRouterDataManager
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/OpenRouterApi/OpenRouterDataManager.cs
Line coverage
46%
Covered lines: 78
Uncovered lines: 88
Coverable lines: 166
Total lines: 469
Line coverage: 46.9%
Branch coverage
25%
Covered branches: 19
Total branches: 76
Branch coverage: 25%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
BuildSystemPrompt()100%11100%
.ctor(...)100%11100%
get_IsConfigured()100%22100%
AskWithContextAsync()45.45%422265.71%
AskWithSystemPromptAsync()70%101089.47%
StreamWithContextAsync()0%342180%
GenerateFollowUpChipsAsync()0%110100%
ParseStringArrayJson(...)0%156120%
TruncateForHistory(...)0%620%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/OpenRouterApi/OpenRouterDataManager.cs

#LineLine coverage
 1using System.Text.Json;
 2using System.Text.RegularExpressions;
 3using Microsoft.Extensions.Logging;
 4using Services.AiAssistant;
 5using Services.ApiServices.OpenRouterApi.Models;
 6
 7namespace Services.ApiServices.OpenRouterApi;
 8
 9public class OpenRouterDataManager
 10{
 11    private readonly OpenRouterApiClient _client;
 12    private readonly OpenRouterApiConfig _config;
 13    private readonly ILogger<OpenRouterDataManager> _logger;
 14
 15    private const string SystemPromptBase = """
 16        Ты — Стас, операционный ИИ-аналитик системы Studio2 (управление заказами Ozon).
 17        Твоя работа: помогать менеджерам склада и операционному отделу быстро находить проблемы в заказах.
 18
 19        АБСОЛЮТНЫЙ ЗАПРЕТ:
 20        НИКОГДА не описывай свой процесс мышления в тексте ответа. Не пиши фразы вида «мне нужно ответить», «согласно пр
 21
 22        ПРАВИЛА ОТВЕТОВ:
 23        - Отвечай только на русском языке, кратко и по делу.
 24        - НЕ начинай ответ со слова «Привет» и любых других приветствий — пользователь уже в диалоге.
 25        - Опирайся ИСКЛЮЧИТЕЛЬНО на данные из раздела «=== Данные из базы ===». Не придумывай.
 26        - Блоки «=== ФАКТЫ ЗАКАЗА ===» и «=== Проверка транзакции ===» имеют приоритет над историей чата.
 27
 28        ПРАВИЛА РЕЖИМА ЗАПИСИ (транзакции и редактирование заказов):
 29        - Когда пользователь просит провести транзакцию или изменить поля — система сама проверяет условия и показывает 
 30        - Если система показала карточку с блоком «Нужно уточнить» (missingFields) — кратко повтори что нужно, например:
 31        - Если система показала карточку без блока «Нужно уточнить» — просто подтверди действие одной фразой: «Проверьте
 32        - НЕ спрашивай: текущий статус (система читает из БД), номер заказа у поставщика (необязательное поле), причину 
 33        - Когда пользователь уточняет поле (например пишет «цена 150»), система автоматически перестроит карточку. Ты пр
 34        - Если данных нет — прямо скажи и предложи уточнить запрос.
 35        - При анализе заказа (order_detail, order_product, order_history) — РАЗВЁРНУТЫЙ ответ:
 36          • В начале описания заказа обязательно укажи количество единиц: «N шт.» (например: «В заказе 3 шт.»).
 37            Если истинное количество с кратностью отличается — упомяни оба значения.
 38          • «Хронология» — события по времени (создание, кто и что менял, статусы).
 39          • «Участники» — поставщик, склад отгрузки, Ozon-клиент (в кавычках «…» для подсветки).
 40          • «Транзакции» — все транзакции заказа: тип, дата MSK, кто провёл, комментарий; перечисли по порядку.
 41          • «Товар» — наименование, артикул, производитель, остатки из таблицы остатков, движения 1С.
 42          • «Синхронизация Ozon и 1С» — объясни по ВСЕМ отчётам в блоке «История синхронизаций»:
 43            откуда остаток на складе 1С, какой Action (Push/Decrease/Match…) и правило этапа 1→2,
 44            что накатил этап 2, были ли этапы 3–5 (сироты, партии [N], пуш цен этапа 4).
 45            Для «почему такая цена» — PriceOptimizationManager, индекс, C0, хронология пушей.
 46            Для партий [N] — floor(1С/N) упаковок и цена ×N. Ссылайся на строку «Правило: …» из данных.
 47            НЕ смешивай дробные остатки таблицы WarehouseStock с целыми OneCQty отчёта BitrixStock.
 48            НЕ выдумывай формулы цен (CostPrice×1.25) — только C0 и пуши из данных.
 49            В конце ответа предложи кнопку «Актуально на Ozon (API)» для live-данных.
 50            Блок «Актуальные данные Ozon Seller API (live)» — приоритет для «сколько сейчас на Ozon».
 51            Под ответом пользователь также видит цветные карточки — в тексте дай смысловое резюме.
 52          • «Ситуация сейчас» — актуальные статусы, просрочка, финансы, риски; если заказ в архиве (OrderArchive) — явно
 53          Минимум 4 смысловых блока; не ограничивайся одной строкой «Итог».
 54        - Статусы заказа упоминай в типографских кавычках «Название статуса» — в чате они подсветятся цветом.
 55        - При наличии списка заказов: дай итог (сколько штук), выдели критичные случаи.
 56        - Если в данных есть блок «Поиск заказов: …» — это результат гибкого фильтра; перечисли найденные заказы и крите
 57        - Используй цифры. Не используй markdown-заголовки (#, ##). Допустимы списки с «•».
 58        - Если вопрос не про заказы/систему — вежливо объясни, что ты специализируешься только на этом.
 59
 60        ═══════════════════════════════════════
 61        ЗНАНИЯ О СИСТЕМЕ STUDIO2
 62        ═══════════════════════════════════════
 63
 64        ## ЖИЗНЕННЫЙ ЦИКЛ ЗАКАЗА (AppStatus — внутренний статус)
 65        Начальный AppStatus в БД часто «Не указан» (не путать с «Новый» из документации).
 66        Новый (начальный, не финальный)
 67        → Заказан поставщику (не финальный)
 68        → Отгружен поставщиком (не финальный)
 69        → Принят на складе (не финальный)
 70        → Отгружен клиенту (ФИНАЛЬНЫЙ — заказ закрыт)
 71        Также финальные: Отменён, Возврат покупателя.
 72        Не финальные также: Приостановлен, Заказан реализатору, К отмене.
 73
 74        ## СТАТУСЫ OZON (ClientStatus — статус от маркетплейса)
 75        Это статус со стороны Ozon, независимый от AppStatus.
 76        Основные значения: «В ожидании оплаты», «Ожидает упаковки», «Ожидает отгрузки»,
 77        «Передан в доставку», «Доставляется», «Получен», «Отменён», «Не принят покупателем».
 78        Отмена со стороны Ozon: ClientStatus = «Отменён».
 79        Заказ считается возвратным (IsReturnable), если AppStatus = «Заказан поставщику» И ClientStatus = «Отменён».
 80
 81        ## ТРАНЗАКЦИИ И ИСТОРИЯ
 82        Подробный справочник типов транзакций и таблицы orders_history — в конце системного промпта.
 83        При вопросах «кто изменил», «история заказа», «действия сегодня» — используй блок «Данные из базы» (orders_histo
 84
 85        ## ФИНАНСОВЫЕ ПОЛЯ ЗАКАЗА
 86        • Price — цена заказа на Ozon, резервная при отсутствии цены сайта
 87        • CurrentPriceWithDiscount — актуальная цена сайта, база расчёта прибыли
 88        • PurchasePrice — закупочная цена у поставщика
 89        • CostPrice — себестоимость (с учётом коэффициентов поставщика, доставки)
 90        • MinProfit / MaxProfit — минимальная/максимальная прибыль (руб.)
 91        • TargetShipmentProfit135 — целевая прибыль = 13.5% от цены сайта (fallback: Price)
 92        • MinOzonCommission / MaxOzonCommission — комиссия Ozon (%)
 93        • MinDiscount / MaxDiscount — скидка (%)
 94        Заказ убыточен, если MinProfit < 0 или цена сайта (fallback: Price) < CostPrice × 1.1.
 95
 96        ## КЛЮЧЕВЫЕ ДАТЫ
 97        • ProcessingDate — дата поступления заказа в систему
 98        • ShippingDate — ДЕДЛАЙН отгрузки (должен быть отгружен до этой даты)
 99        • LastStatusChangeDate — дата последнего изменения статуса
 100        Просроченный заказ: ShippingDate < сегодня И статус не финальный.
 101
 102        ## РОЛИ ПОЛЬЗОВАТЕЛЕЙ
 103        • SuperAdmin — полный доступ ко всему, включая админ-панель; может управлять ролями и пользователями
 104        • Admin — расширенный доступ: Hangfire, настройки, все заказы всех клиентов
 105        • User — стандартный доступ к заказам, ограниченный data scope (может видеть только «свои» склады/статусы/клиент
 106        Роли применяются через Identity + прикладная система AppRole (страничные разрешения, полевые разрешения, data sc
 107
 108        ## ROW-LEVEL SECURITY (Data Scope)
 109        Пользователь может быть ограничен по:
 110        • AppStatus — видит только заказы с определёнными внутренними статусами
 111        • Supplier — только заказы определённых поставщиков
 112        • OzonClient — только определённый аккаунт Ozon
 113        • Warehouse — только определённые склады отгрузки
 114        • DeliveryProvider — только определённые службы доставки
 115
 116        ## СТРУКТУРА ЗАКАЗА — ПОЛНЫЙ СПРАВОЧНИК ПОЛЕЙ
 117
 118        ### Идентификация
 119        • ShipmentNumber — номер отправления Ozon (например «63894740-0501-1»; /1, /2 при разбивке)
 120        • Article / Артикул — артикул товара (код производителя, например «09146=FEB»)
 121        • ProductName / Наименование — название товара
 122        • TrackingNumber / Трек-номер — номер отслеживания отправления
 123        • OrderNumberToSupplier / Номер заказа у поставщика — номер переданный поставщику (например «АД 0008158»)
 124        • Key / Ключ — внутренний ключ заказа
 125
 126        ### Статусы
 127        • AppStatus / Статус в отчёте — внутренний статус (Новый, Заказан поставщику, Отгружен поставщиком, Принят на ск
 128        • ClientStatus / Статус Ozon — статус маркетплейса (В ожидании оплаты, Ожидает упаковки, Ожидает отгрузки, Перед
 129        • IsReturned / Есть возврат — заказ имеет отмену или возврат со стороны Ozon
 130
 131        ### Участники
 132        • OzonClient / Ozon-клиент — аккаунт Ozon (мультитенант)
 133        • Supplier / Поставщик — поставщик товара
 134        • ShipmentWarehouse / Склад отгрузки — склад Studio2, откуда отгружается
 135
 136        ### Финансы
 137        • Price / Цена — цена заказа на Ozon (руб.), резервная при отсутствии цены сайта
 138        • CurrentPriceWithDiscount / Цена сайта — актуальная цена сайта; база планового расчёта прибыли
 139        • PurchasePrice / Закупочная цена — цена закупки у поставщика
 140        • CostPrice / Себестоимость — полная себестоимость (закупка + доставка + коэфф. поставщика)
 141        • MinProfit / MaxProfit / Прибыль — минимальная/максимальная прибыль (Цена сайта − CostPrice − комиссия; при отс
 142        • MinOzonCommission / MaxOzonCommission / Комиссия Ozon — комиссия (%)
 143        • MinDiscount / MaxDiscount / Скидка — скидка (%)
 144        • ShipmentAmount / Сумма отправления — итоговая сумма по отправлению
 145        Заказ убыточен, если MinProfit < 0 или цена сайта (fallback: Price) < CostPrice × 1.1.
 146
 147        ### Количество
 148        • Quantity / Количество — количество единиц в заказе
 149        • TrueQuantity / Истинное количество — с учётом кратности упаковки (ProductKey)
 150
 151        ### Логистика
 152        • DeliveryProvider / Провайдер доставки — служба доставки (CDEK, DPD, Яндекс.Доставка...)
 153        • Delivery / Тип доставки — конкретный тариф доставки
 154        • DeliveryCity / Город доставки — город получателя
 155        • ShippingDate / Дедлайн отгрузки — крайний срок отгрузки (НЕ дата доставки покупателю!)
 156        • ProcessingDate / Дата принятия — дата поступления заказа в систему
 157
 158        ### Товар
 159        • Manufacturer / Производитель — производитель товара (бренд)
 160        • NewCategory / Категория — категория товара
 161        • ProductInfo.ImageUrl — изображение товара
 162
 163        ### Прочее
 164        • Comment / Комментарий — комментарий менеджера
 165        • OrderArchive / Архив — заказ скрыт из главной таблицы (/Studio2/OrderArchive)
 166        • OrderExtension["Засыл"] — код засыла поставщика
 167        • LastStatusChangeDate / Дата смены статуса — дата последнего изменения статуса
 168
 169        ## ПОИСК ПО ИСТОРИИ ИЗМЕНЕНИЙ (orders_history)
 170        Таблица orders_history хранит КАЖДОЕ изменение любого поля заказа:
 171        • ChangedBy — кто изменил (логин/имя пользователя)
 172        • ChangedAt — когда (UTC)
 173        • ColumnDisplayName — название поля по-русски (например «Поставщик», «Статус в отчёте», «Ozon-клиент»)
 174        • OldValue / NewValue — было → стало
 175
 176        Примеры запросов через историю (tool: orders_filter):
 177        • «заказы которые изменял Иван за последний месяц» → ChangedByUser=Иван, LastMonths=1
 178        • «заказы у которых статус поменялся на Заказан поставщику» → FieldChangedName=Статус, FieldChangedToValue=Заказ
 179        • «заказы у которых стал клиент Ozon-2» → FieldChangedName=Ozon-клиент, FieldChangedToValue=Ozon-2
 180        • «изменения за прошлый месяц» → ChangedFrom/ChangedTo
 181
 182        Если в запросе пользователя есть «изменил», «менял», «у которых стал», «у которых изменился» — используй tool or
 183
 184        ## ИНТЕГРАЦИИ
 185        • Ozon Seller API — получение заказов, статусов, отчётов комиссионера
 186        • 1С Fresh OData — создание приходных накладных и расходных документов
 187        • Bitrix24 / BitrixStock — синхронизация остатков и цен на Ozon (отчёты BitrixStockSyncReports)
 188        • Google Sheets — выгрузка складских остатков по преднастройкам
 189        • Hangfire — фоновые задачи: автозагрузка отчётов Ozon, создание транзакций
 190
 191        ## BITRIXSTOCK (синхронизация Ozon, /Studio2/BitrixStock?tab=sync)
 192        Этапы: 1 отчёт (Action) → 2 накат остатков → 3 лишние FBS → 4 цены (PriceOptimizationManager) → 5a партии [N] + 
 193        В блоке «Данные из базы» для артикула есть полные правила этапов и история ВСЕХ отчётов — опирайся на них.
 194        Отвечая «почему цена/количество»: склад 1С → Ozon, Action, правило этапа, партия [N] если есть, хронология цен.
 195        Product key: артикул=код производителя (09146=FEB). Под ответом — цветные карточки; в тексте — объяснение правил
 196        ═══════════════════════════════════════
 197        """;
 198
 199    private static string BuildSystemPrompt()
 200    {
 16201        var msk = TimeZoneInfo.ConvertTimeFromUtc(
 16202            DateTime.UtcNow,
 16203            TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time"));
 204
 16205        return SystemPromptBase +
 16206               """
 16207
 16208               ═══════════════════════════════════════
 16209               СПРАВОЧНИК ПОЛЕЙ ЗАКАЗА
 16210               ═══════════════════════════════════════
 16211               """ +
 16212               OrderRussianFieldFormatter.FormatFieldGlossary() +
 16213               """
 16214
 16215               """ +
 16216               OrderHistoryFormatter.FormatFieldGlossary() +
 16217               """
 16218
 16219               """ +
 16220               AiTransactionTypeGlossary.FormatForPrompt() +
 16221               AiTransactionTypeGlossary.FormatTransitionTable() +
 16222               """
 16223
 16224               ВАЖНО: при анализе конкретного заказа используй русские названия полей из справочника.
 16225               Данные из базы уже подставляются автоматически — не проси пользователя «выгрузить» их вручную.
 16226               Если пользователь спрашивает про «этот заказ» / «его историю» — опирайся на контекст диалога и блок «Данн
 16227               ═══════════════════════════════════════
 16228               """ +
 16229               $"\n\nТекущая дата и время (МСК): {msk:dd.MM.yyyy HH:mm}. " +
 16230               "Используй эту дату для «сегодня», «просрочки» и сравнения сроков.";
 231    }
 232
 233
 28234    public OpenRouterDataManager(
 28235        OpenRouterApiClient client,
 28236        OpenRouterApiConfig config,
 28237        ILogger<OpenRouterDataManager> logger)
 238    {
 28239        _client = client;
 28240        _config = config;
 28241        _logger = logger;
 28242    }
 243
 25244    public bool IsConfigured => _config?.IsConfigured ?? _client.IsConfigured;
 245
 246    public async Task<string> AskWithContextAsync(
 247        string userQuestion,
 248        string dataContext,
 249        IReadOnlyList<AiChatTurn>? history = null,
 250        string? modelOverride = null,
 251        CancellationToken ct = default)
 252    {
 16253        if (!IsConfigured)
 254        {
 0255            _logger.LogInformation("OpenRouterDataManager: API-ключ не настроен, запрос контекста пропущен");
 0256            return "AI-ассистент не настроен: отсутствует OpenRouter:ApiKey в конфигурации.";
 257        }
 258
 16259        var systemContent = string.IsNullOrWhiteSpace(dataContext)
 16260            ? BuildSystemPrompt()
 16261            : BuildSystemPrompt() + "\n\n=== Данные из базы ===\n" + dataContext;
 262
 16263        var messages = new List<ChatMessage>
 16264        {
 16265            new() { Role = "system", Content = systemContent }
 16266        };
 267
 16268        if (history is { Count: > 0 })
 269        {
 0270            foreach (var turn in history.TakeLast(8))
 271            {
 0272                if (string.IsNullOrWhiteSpace(turn.Content)) continue;
 0273                var role = string.Equals(turn.Role, "assistant", StringComparison.OrdinalIgnoreCase)
 0274                    ? "assistant"
 0275                    : "user";
 0276                messages.Add(new ChatMessage
 0277                {
 0278                    Role    = role,
 0279                    Content = TruncateForHistory(turn.Content, 900)
 0280                });
 281            }
 282        }
 283
 16284        messages.Add(new ChatMessage { Role = "user", Content = userQuestion });
 285
 16286        var model = !string.IsNullOrWhiteSpace(modelOverride) ? modelOverride : _config.Model;
 15287        var request = new ChatCompletionRequest
 15288        {
 15289            Model       = model,
 15290            MaxTokens   = 2048,
 15291            Temperature = 0.2,
 15292            Messages    = messages
 15293        };
 294
 15295        _logger.LogDebug("Sending request to OpenRouter, model={Model}", model);
 15296        var response = await _client.CompleteChatAsync(request, ct);
 15297        return response?.Choices.FirstOrDefault()?.Message.Content
 15298               ?? "Не удалось получить ответ от AI. Попробуйте позже.";
 15299    }
 300
 301    /// <summary>
 302    /// Выполняет изолированный запрос с системным промптом конкретного сценария.
 303    /// Не добавляет общий контекст чат-ассистента «Стас».
 304    /// </summary>
 305    public async Task<string> AskWithSystemPromptAsync(
 306        string systemPrompt,
 307        string userInstruction,
 308        string? modelOverride = null,
 309        CancellationToken ct = default)
 310    {
 4311        if (!IsConfigured)
 312        {
 0313            _logger.LogInformation("OpenRouterDataManager: API-ключ не настроен, запрос изолированного промпта пропущен"
 0314            return string.Empty;
 315        }
 316
 4317        var request = new ChatCompletionRequest
 4318        {
 4319            Model = !string.IsNullOrWhiteSpace(modelOverride) ? modelOverride : _config.Model,
 4320            MaxTokens = 2048,
 4321            Temperature = 0.1,
 4322            Messages =
 4323            [
 4324                new ChatMessage { Role = "system", Content = systemPrompt },
 4325                new ChatMessage { Role = "user", Content = userInstruction }
 4326            ]
 4327        };
 328
 4329        _logger.LogDebug("Sending isolated OpenRouter request, model={Model}", request.Model);
 4330        var response = await _client.CompleteChatAsync(request, ct);
 4331        return response?.Choices.FirstOrDefault()?.Message.Content
 4332               ?? string.Empty;
 4333    }
 334
 335    /// <summary>
 336    /// Стриминг ответа: возвращает текстовые чанки по мере поступления от OpenRouter.
 337    /// По окончании вызывает <paramref name="onUsage"/> с данными о потреблении токенов (если модель их возвращает).
 338    /// </summary>
 339    public async IAsyncEnumerable<string> StreamWithContextAsync(
 340        string userQuestion,
 341        string dataContext,
 342        IReadOnlyList<AiChatTurn>? history = null,
 343        string? modelOverride = null,
 344        Action<UsageInfo>? onUsage = null,
 345        [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
 346    {
 0347        if (!IsConfigured)
 348        {
 0349            _logger.LogInformation("OpenRouterDataManager stream: API-ключ не настроен, стриминг пропущен");
 0350            yield break;
 351        }
 352
 0353        var systemContent = string.IsNullOrWhiteSpace(dataContext)
 0354            ? BuildSystemPrompt()
 0355            : BuildSystemPrompt() + "\n\n=== Данные из базы ===\n" + dataContext;
 356
 0357        var messages = new List<ChatMessage>
 0358        {
 0359            new() { Role = "system", Content = systemContent }
 0360        };
 361
 0362        if (history is { Count: > 0 })
 363        {
 0364            foreach (var turn in history.TakeLast(8))
 365            {
 0366                if (string.IsNullOrWhiteSpace(turn.Content)) continue;
 0367                var role = string.Equals(turn.Role, "assistant", StringComparison.OrdinalIgnoreCase)
 0368                    ? "assistant" : "user";
 0369                messages.Add(new ChatMessage { Role = role, Content = TruncateForHistory(turn.Content, 900) });
 370            }
 371        }
 0372        messages.Add(new ChatMessage { Role = "user", Content = userQuestion });
 373
 0374        var model = !string.IsNullOrWhiteSpace(modelOverride) ? modelOverride : _config.Model;
 0375        var request = new ChatCompletionRequest
 0376        {
 0377            Model       = model,
 0378            MaxTokens   = 2048,
 0379            Temperature = 0.2,
 0380            Messages    = messages
 0381        };
 382
 0383        _logger.LogDebug("Streaming request to OpenRouter, model={Model}", model);
 0384        await foreach (var token in _client.StreamChatAsync(request, onUsage, ct).WithCancellation(ct))
 385        {
 0386            yield return token;
 387        }
 0388    }
 389
 390    /// <summary>
 391    /// Генерирует 2–3 коротких follow-up запроса для chips под ответом ассистента.
 392    /// При ошибке или таймауте возвращает пустой список (не критично для UX).
 393    /// </summary>
 394    public async Task<List<string>> GenerateFollowUpChipsAsync(
 395        string  userQuestion,
 396        string? toolName,
 397        string? dataContextSummary,
 398        int?    totalFound,
 399        CancellationToken ct = default)
 400    {
 0401        if (!IsConfigured)
 0402            return [];
 403
 0404        using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
 0405        timeoutCts.CancelAfter(TimeSpan.FromSeconds(4));
 406
 407        try
 408        {
 0409            var prompt =
 0410                "На основе запроса пользователя и краткого результата предложи 2–3 коротких follow-up " +
 0411                "запроса для AI-ассистента заказов (по 3–7 слов, на русском, без кавычек внутри).\n" +
 0412                "Верни ТОЛЬКО JSON-массив строк, например: [\"Показать просроченные\", \"Сводка по статусам\"].\n\n" +
 0413                $"Запрос: {userQuestion.Trim()}\n" +
 0414                (string.IsNullOrWhiteSpace(toolName) ? "" : $"Инструмент: {toolName}\n") +
 0415                (totalFound.HasValue ? $"Найдено заказов: {totalFound}\n" : "") +
 0416                (string.IsNullOrWhiteSpace(dataContextSummary)
 0417                    ? ""
 0418                    : $"Контекст (фрагмент): {TruncateForHistory(dataContextSummary, 600)}\n");
 419
 0420            var raw = await AskWithContextAsync(
 0421                prompt,
 0422                dataContext: string.Empty,
 0423                history: null,
 0424                modelOverride: string.IsNullOrWhiteSpace(_config.ChipsModel) ? "openrouter/auto" : _config.ChipsModel,
 0425                ct: timeoutCts.Token);
 426
 0427            return ParseStringArrayJson(raw);
 428        }
 0429        catch (OperationCanceledException)
 430        {
 0431            _logger.LogDebug("GenerateFollowUpChipsAsync: timeout or cancellation");
 0432            return [];
 433        }
 0434        catch (Exception ex)
 435        {
 0436            _logger.LogWarning(ex, "GenerateFollowUpChipsAsync failed");
 0437            return [];
 438        }
 0439    }
 440
 441    private static List<string> ParseStringArrayJson(string raw)
 442    {
 0443        if (string.IsNullOrWhiteSpace(raw))
 0444            return [];
 445
 0446        var trimmed = raw.Trim();
 0447        var json    = trimmed.StartsWith('[') ? trimmed : Regex.Match(trimmed, @"\[[\s\S]*\]").Value;
 0448        if (string.IsNullOrWhiteSpace(json))
 0449            return [];
 450
 451        try
 452        {
 0453            var list = JsonSerializer.Deserialize<List<string>>(json);
 0454            return list?
 0455                .Where(s => !string.IsNullOrWhiteSpace(s))
 0456                .Select(s => s.Trim())
 0457                .Where(s => s.Length >= 3 && s.Length <= 120)
 0458                .Take(3)
 0459                .ToList() ?? [];
 460        }
 0461        catch
 462        {
 0463            return [];
 464        }
 0465    }
 466
 467    private static string TruncateForHistory(string text, int maxLen) =>
 0468        text.Length <= maxLen ? text : text[..maxLen] + "…";
 469}