| | | 1 | | using System.Text.Json; |
| | | 2 | | using System.Text.RegularExpressions; |
| | | 3 | | using Microsoft.Extensions.Logging; |
| | | 4 | | using Services.AiAssistant; |
| | | 5 | | using Services.ApiServices.OpenRouterApi.Models; |
| | | 6 | | |
| | | 7 | | namespace Services.ApiServices.OpenRouterApi; |
| | | 8 | | |
| | | 9 | | public 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 | | { |
| | 16 | 201 | | var msk = TimeZoneInfo.ConvertTimeFromUtc( |
| | 16 | 202 | | DateTime.UtcNow, |
| | 16 | 203 | | TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time")); |
| | | 204 | | |
| | 16 | 205 | | return SystemPromptBase + |
| | 16 | 206 | | """ |
| | 16 | 207 | | |
| | 16 | 208 | | ═══════════════════════════════════════ |
| | 16 | 209 | | СПРАВОЧНИК ПОЛЕЙ ЗАКАЗА |
| | 16 | 210 | | ═══════════════════════════════════════ |
| | 16 | 211 | | """ + |
| | 16 | 212 | | OrderRussianFieldFormatter.FormatFieldGlossary() + |
| | 16 | 213 | | """ |
| | 16 | 214 | | |
| | 16 | 215 | | """ + |
| | 16 | 216 | | OrderHistoryFormatter.FormatFieldGlossary() + |
| | 16 | 217 | | """ |
| | 16 | 218 | | |
| | 16 | 219 | | """ + |
| | 16 | 220 | | AiTransactionTypeGlossary.FormatForPrompt() + |
| | 16 | 221 | | AiTransactionTypeGlossary.FormatTransitionTable() + |
| | 16 | 222 | | """ |
| | 16 | 223 | | |
| | 16 | 224 | | ВАЖНО: при анализе конкретного заказа используй русские названия полей из справочника. |
| | 16 | 225 | | Данные из базы уже подставляются автоматически — не проси пользователя «выгрузить» их вручную. |
| | 16 | 226 | | Если пользователь спрашивает про «этот заказ» / «его историю» — опирайся на контекст диалога и блок «Данн |
| | 16 | 227 | | ═══════════════════════════════════════ |
| | 16 | 228 | | """ + |
| | 16 | 229 | | $"\n\nТекущая дата и время (МСК): {msk:dd.MM.yyyy HH:mm}. " + |
| | 16 | 230 | | "Используй эту дату для «сегодня», «просрочки» и сравнения сроков."; |
| | | 231 | | } |
| | | 232 | | |
| | | 233 | | |
| | 28 | 234 | | public OpenRouterDataManager( |
| | 28 | 235 | | OpenRouterApiClient client, |
| | 28 | 236 | | OpenRouterApiConfig config, |
| | 28 | 237 | | ILogger<OpenRouterDataManager> logger) |
| | | 238 | | { |
| | 28 | 239 | | _client = client; |
| | 28 | 240 | | _config = config; |
| | 28 | 241 | | _logger = logger; |
| | 28 | 242 | | } |
| | | 243 | | |
| | 25 | 244 | | 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 | | { |
| | 16 | 253 | | if (!IsConfigured) |
| | | 254 | | { |
| | 0 | 255 | | _logger.LogInformation("OpenRouterDataManager: API-ключ не настроен, запрос контекста пропущен"); |
| | 0 | 256 | | return "AI-ассистент не настроен: отсутствует OpenRouter:ApiKey в конфигурации."; |
| | | 257 | | } |
| | | 258 | | |
| | 16 | 259 | | var systemContent = string.IsNullOrWhiteSpace(dataContext) |
| | 16 | 260 | | ? BuildSystemPrompt() |
| | 16 | 261 | | : BuildSystemPrompt() + "\n\n=== Данные из базы ===\n" + dataContext; |
| | | 262 | | |
| | 16 | 263 | | var messages = new List<ChatMessage> |
| | 16 | 264 | | { |
| | 16 | 265 | | new() { Role = "system", Content = systemContent } |
| | 16 | 266 | | }; |
| | | 267 | | |
| | 16 | 268 | | if (history is { Count: > 0 }) |
| | | 269 | | { |
| | 0 | 270 | | foreach (var turn in history.TakeLast(8)) |
| | | 271 | | { |
| | 0 | 272 | | if (string.IsNullOrWhiteSpace(turn.Content)) continue; |
| | 0 | 273 | | var role = string.Equals(turn.Role, "assistant", StringComparison.OrdinalIgnoreCase) |
| | 0 | 274 | | ? "assistant" |
| | 0 | 275 | | : "user"; |
| | 0 | 276 | | messages.Add(new ChatMessage |
| | 0 | 277 | | { |
| | 0 | 278 | | Role = role, |
| | 0 | 279 | | Content = TruncateForHistory(turn.Content, 900) |
| | 0 | 280 | | }); |
| | | 281 | | } |
| | | 282 | | } |
| | | 283 | | |
| | 16 | 284 | | messages.Add(new ChatMessage { Role = "user", Content = userQuestion }); |
| | | 285 | | |
| | 16 | 286 | | var model = !string.IsNullOrWhiteSpace(modelOverride) ? modelOverride : _config.Model; |
| | 15 | 287 | | var request = new ChatCompletionRequest |
| | 15 | 288 | | { |
| | 15 | 289 | | Model = model, |
| | 15 | 290 | | MaxTokens = 2048, |
| | 15 | 291 | | Temperature = 0.2, |
| | 15 | 292 | | Messages = messages |
| | 15 | 293 | | }; |
| | | 294 | | |
| | 15 | 295 | | _logger.LogDebug("Sending request to OpenRouter, model={Model}", model); |
| | 15 | 296 | | var response = await _client.CompleteChatAsync(request, ct); |
| | 15 | 297 | | return response?.Choices.FirstOrDefault()?.Message.Content |
| | 15 | 298 | | ?? "Не удалось получить ответ от AI. Попробуйте позже."; |
| | 15 | 299 | | } |
| | | 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 | | { |
| | 4 | 311 | | if (!IsConfigured) |
| | | 312 | | { |
| | 0 | 313 | | _logger.LogInformation("OpenRouterDataManager: API-ключ не настроен, запрос изолированного промпта пропущен" |
| | 0 | 314 | | return string.Empty; |
| | | 315 | | } |
| | | 316 | | |
| | 4 | 317 | | var request = new ChatCompletionRequest |
| | 4 | 318 | | { |
| | 4 | 319 | | Model = !string.IsNullOrWhiteSpace(modelOverride) ? modelOverride : _config.Model, |
| | 4 | 320 | | MaxTokens = 2048, |
| | 4 | 321 | | Temperature = 0.1, |
| | 4 | 322 | | Messages = |
| | 4 | 323 | | [ |
| | 4 | 324 | | new ChatMessage { Role = "system", Content = systemPrompt }, |
| | 4 | 325 | | new ChatMessage { Role = "user", Content = userInstruction } |
| | 4 | 326 | | ] |
| | 4 | 327 | | }; |
| | | 328 | | |
| | 4 | 329 | | _logger.LogDebug("Sending isolated OpenRouter request, model={Model}", request.Model); |
| | 4 | 330 | | var response = await _client.CompleteChatAsync(request, ct); |
| | 4 | 331 | | return response?.Choices.FirstOrDefault()?.Message.Content |
| | 4 | 332 | | ?? string.Empty; |
| | 4 | 333 | | } |
| | | 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 | | { |
| | 0 | 347 | | if (!IsConfigured) |
| | | 348 | | { |
| | 0 | 349 | | _logger.LogInformation("OpenRouterDataManager stream: API-ключ не настроен, стриминг пропущен"); |
| | 0 | 350 | | yield break; |
| | | 351 | | } |
| | | 352 | | |
| | 0 | 353 | | var systemContent = string.IsNullOrWhiteSpace(dataContext) |
| | 0 | 354 | | ? BuildSystemPrompt() |
| | 0 | 355 | | : BuildSystemPrompt() + "\n\n=== Данные из базы ===\n" + dataContext; |
| | | 356 | | |
| | 0 | 357 | | var messages = new List<ChatMessage> |
| | 0 | 358 | | { |
| | 0 | 359 | | new() { Role = "system", Content = systemContent } |
| | 0 | 360 | | }; |
| | | 361 | | |
| | 0 | 362 | | if (history is { Count: > 0 }) |
| | | 363 | | { |
| | 0 | 364 | | foreach (var turn in history.TakeLast(8)) |
| | | 365 | | { |
| | 0 | 366 | | if (string.IsNullOrWhiteSpace(turn.Content)) continue; |
| | 0 | 367 | | var role = string.Equals(turn.Role, "assistant", StringComparison.OrdinalIgnoreCase) |
| | 0 | 368 | | ? "assistant" : "user"; |
| | 0 | 369 | | messages.Add(new ChatMessage { Role = role, Content = TruncateForHistory(turn.Content, 900) }); |
| | | 370 | | } |
| | | 371 | | } |
| | 0 | 372 | | messages.Add(new ChatMessage { Role = "user", Content = userQuestion }); |
| | | 373 | | |
| | 0 | 374 | | var model = !string.IsNullOrWhiteSpace(modelOverride) ? modelOverride : _config.Model; |
| | 0 | 375 | | var request = new ChatCompletionRequest |
| | 0 | 376 | | { |
| | 0 | 377 | | Model = model, |
| | 0 | 378 | | MaxTokens = 2048, |
| | 0 | 379 | | Temperature = 0.2, |
| | 0 | 380 | | Messages = messages |
| | 0 | 381 | | }; |
| | | 382 | | |
| | 0 | 383 | | _logger.LogDebug("Streaming request to OpenRouter, model={Model}", model); |
| | 0 | 384 | | await foreach (var token in _client.StreamChatAsync(request, onUsage, ct).WithCancellation(ct)) |
| | | 385 | | { |
| | 0 | 386 | | yield return token; |
| | | 387 | | } |
| | 0 | 388 | | } |
| | | 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 | | { |
| | 0 | 401 | | if (!IsConfigured) |
| | 0 | 402 | | return []; |
| | | 403 | | |
| | 0 | 404 | | using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); |
| | 0 | 405 | | timeoutCts.CancelAfter(TimeSpan.FromSeconds(4)); |
| | | 406 | | |
| | | 407 | | try |
| | | 408 | | { |
| | 0 | 409 | | var prompt = |
| | 0 | 410 | | "На основе запроса пользователя и краткого результата предложи 2–3 коротких follow-up " + |
| | 0 | 411 | | "запроса для AI-ассистента заказов (по 3–7 слов, на русском, без кавычек внутри).\n" + |
| | 0 | 412 | | "Верни ТОЛЬКО JSON-массив строк, например: [\"Показать просроченные\", \"Сводка по статусам\"].\n\n" + |
| | 0 | 413 | | $"Запрос: {userQuestion.Trim()}\n" + |
| | 0 | 414 | | (string.IsNullOrWhiteSpace(toolName) ? "" : $"Инструмент: {toolName}\n") + |
| | 0 | 415 | | (totalFound.HasValue ? $"Найдено заказов: {totalFound}\n" : "") + |
| | 0 | 416 | | (string.IsNullOrWhiteSpace(dataContextSummary) |
| | 0 | 417 | | ? "" |
| | 0 | 418 | | : $"Контекст (фрагмент): {TruncateForHistory(dataContextSummary, 600)}\n"); |
| | | 419 | | |
| | 0 | 420 | | var raw = await AskWithContextAsync( |
| | 0 | 421 | | prompt, |
| | 0 | 422 | | dataContext: string.Empty, |
| | 0 | 423 | | history: null, |
| | 0 | 424 | | modelOverride: string.IsNullOrWhiteSpace(_config.ChipsModel) ? "openrouter/auto" : _config.ChipsModel, |
| | 0 | 425 | | ct: timeoutCts.Token); |
| | | 426 | | |
| | 0 | 427 | | return ParseStringArrayJson(raw); |
| | | 428 | | } |
| | 0 | 429 | | catch (OperationCanceledException) |
| | | 430 | | { |
| | 0 | 431 | | _logger.LogDebug("GenerateFollowUpChipsAsync: timeout or cancellation"); |
| | 0 | 432 | | return []; |
| | | 433 | | } |
| | 0 | 434 | | catch (Exception ex) |
| | | 435 | | { |
| | 0 | 436 | | _logger.LogWarning(ex, "GenerateFollowUpChipsAsync failed"); |
| | 0 | 437 | | return []; |
| | | 438 | | } |
| | 0 | 439 | | } |
| | | 440 | | |
| | | 441 | | private static List<string> ParseStringArrayJson(string raw) |
| | | 442 | | { |
| | 0 | 443 | | if (string.IsNullOrWhiteSpace(raw)) |
| | 0 | 444 | | return []; |
| | | 445 | | |
| | 0 | 446 | | var trimmed = raw.Trim(); |
| | 0 | 447 | | var json = trimmed.StartsWith('[') ? trimmed : Regex.Match(trimmed, @"\[[\s\S]*\]").Value; |
| | 0 | 448 | | if (string.IsNullOrWhiteSpace(json)) |
| | 0 | 449 | | return []; |
| | | 450 | | |
| | | 451 | | try |
| | | 452 | | { |
| | 0 | 453 | | var list = JsonSerializer.Deserialize<List<string>>(json); |
| | 0 | 454 | | return list? |
| | 0 | 455 | | .Where(s => !string.IsNullOrWhiteSpace(s)) |
| | 0 | 456 | | .Select(s => s.Trim()) |
| | 0 | 457 | | .Where(s => s.Length >= 3 && s.Length <= 120) |
| | 0 | 458 | | .Take(3) |
| | 0 | 459 | | .ToList() ?? []; |
| | | 460 | | } |
| | 0 | 461 | | catch |
| | | 462 | | { |
| | 0 | 463 | | return []; |
| | | 464 | | } |
| | 0 | 465 | | } |
| | | 466 | | |
| | | 467 | | private static string TruncateForHistory(string text, int maxLen) => |
| | 0 | 468 | | text.Length <= maxLen ? text : text[..maxLen] + "…"; |
| | | 469 | | } |