< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.Controllers.AiAssistantController
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/AiAssistantController.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 462
Coverable lines: 462
Total lines: 712
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 186
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
Suppliers()100%210%
Contracts()0%620%
Index()100%210%
Prepare()0%4260%
.cctor()100%210%
OpenOrders()0%7280%
OrderFilter()0%7280%
Chat()0%506220%
ExecuteAction()0%210140%
BuildSummaryContext(...)0%600240%
StreamChat()0%7140840%
EmitAsync()100%210%
EmitProgressAsync()0%7280%
BuildOrdersFilterUrl(...)0%620%
ExportSearchExcel()0%2040%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/AiAssistantController.cs

#LineLine coverage
 1using System.Diagnostics;
 2using System.Security.Claims;
 3using System.Text;
 4using Microsoft.AspNetCore.Authorization;
 5using Microsoft.AspNetCore.Http.Features;
 6using Microsoft.AspNetCore.Mvc;
 7using Newtonsoft.Json;
 8using Newtonsoft.Json.Serialization;
 9using OzonRepositories.Data;
 10using Services.AiAssistant;
 11using Services.ApiServices;
 12using Services.ApiServices._1CApi;
 13using Services.ApiServices.OpenRouterApi;
 14using Services.ApiServices.OpenRouterApi.Models;
 15using Services.DataServices;
 16using Services.ParserServices.FielParsers;
 17
 18namespace OzonOrdersWeb.Areas.Studio2.Controllers;
 19
 20[Authorize(Roles = "User,Admin")]
 21[Area("Studio2")]
 22[Route("Studio2/AiAssistant")]
 23public class AiAssistantController : Controller
 24{
 25    private readonly AiOrderAnalysisService      _analysisService;
 26    private readonly OpenRouterDataManager       _dataManager;
 27    private readonly AiActionSuggestorAggregator _suggestorAggregator;
 28    private readonly AiOrderWriteService         _writeService;
 29    private readonly SupplierDataServices        _supplierDataServices;
 30    private readonly OneCDataManager             _oneCDataManager;
 31    private readonly AiOrderSearchAgent          _searchAgent;
 32    private readonly OrderRepository             _orderRepository;
 33    private readonly ExcelExporter               _excelExporter;
 34    private readonly ILogger<AiAssistantController> _logger;
 35
 036    public AiAssistantController(
 037        AiOrderAnalysisService analysisService,
 038        OpenRouterDataManager dataManager,
 039        AiActionSuggestorAggregator suggestorAggregator,
 040        AiOrderWriteService writeService,
 041        SupplierDataServices supplierDataServices,
 042        OneCDataManager oneCDataManager,
 043        AiOrderSearchAgent searchAgent,
 044        OrderRepository orderRepository,
 045        ExcelExporter excelExporter,
 046        ILogger<AiAssistantController> logger)
 47    {
 048        _analysisService      = analysisService;
 049        _dataManager          = dataManager;
 050        _suggestorAggregator  = suggestorAggregator;
 051        _writeService         = writeService;
 052        _supplierDataServices = supplierDataServices;
 053        _oneCDataManager      = oneCDataManager;
 054        _searchAgent          = searchAgent;
 055        _orderRepository      = orderRepository;
 056        _excelExporter        = excelExporter;
 057        _logger               = logger;
 058    }
 59
 60    /// <summary>Список поставщиков для выпадающего списка в карточке proposal.</summary>
 61    [HttpGet("Suppliers")]
 62    public async Task<IActionResult> Suppliers()
 63    {
 064        var list = await _supplierDataServices.GetSuppliers();
 065        return Ok(list
 066            .Where(s => !string.IsNullOrWhiteSpace(s.Name))
 067            .OrderBy(s => s.Name)
 068            .Select(s => new { id = s.Id, name = s.Name }));
 069    }
 70
 71    /// <summary>Список договоров 1С по ИНН поставщика (для ShippedBySupplier карточки).</summary>
 72    [HttpGet("Contracts")]
 73    public async Task<IActionResult> Contracts(string? supplierInn, CancellationToken ct)
 74    {
 075        if (string.IsNullOrWhiteSpace(supplierInn))
 076            return Ok(Array.Empty<object>());
 77        try
 78        {
 079            var treaties = await _oneCDataManager.GetTreatyListAsync(supplierInn);
 080            return Ok(treaties.Select(t => new { link = t.Link, name = t.Name, partner = t.Partner }));
 81        }
 082        catch (Exception ex)
 83        {
 084            _logger.LogWarning(ex, "AI Contracts: failed to load treaties for INN={Inn}", supplierInn);
 085            return Ok(Array.Empty<object>());
 86        }
 087    }
 88
 89    [HttpGet("")]
 090    public IActionResult Index() => View();
 91
 92    [HttpPost("Prepare")]
 93    public async Task<IActionResult> Prepare([FromBody] AiChatRequest request, CancellationToken ct = default)
 94    {
 095        if (string.IsNullOrWhiteSpace(request.Message))
 096            return BadRequest(new { error = "Сообщение не может быть пустым." });
 97
 098        var intent = await _analysisService.DetectIntentAsync(request.Message, request.Context, ct);
 099        return Ok(new AiChatPrepareResponse
 0100        {
 0101            Steps     = AiThinkingStepsBuilder.BuildPending(intent, request.Message),
 0102            ToolLabel = AiOrderAnalysisService.GetToolLabel(intent?.ToolName),
 0103            ToolName  = intent?.ToolName,
 0104            Phase     = "intent"
 0105        });
 0106    }
 107
 108    /// <summary>
 109    /// Устанавливает cookie FilterData и открывает главную таблицу заказов с нужным фильтром.
 110    /// </summary>
 0111    private static readonly HashSet<string> FieldPresets = new(StringComparer.Ordinal)
 0112    {
 0113        "orders_today",
 0114        "cancelled_orders_today",
 0115        "unprofitable_orders",
 0116        "no_supplier_orders"
 0117    };
 118
 119    [HttpGet("OpenOrders")]
 120    public async Task<IActionResult> OpenOrders(
 121        string? preset,
 122        string? ids,
 123        CancellationToken ct = default)
 124    {
 0125        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
 126        OServices.FiltersServices.FilterModels.OrderFilterModel filter;
 127
 0128        if (!string.IsNullOrWhiteSpace(preset) && FieldPresets.Contains(preset))
 129        {
 0130            filter = AiOrdersFilterBuilder.FromPreset(preset);
 131        }
 0132        else if (!string.IsNullOrWhiteSpace(ids))
 133        {
 0134            var rawIds = ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 0135                .Select(s => int.TryParse(s, out var id) ? id : 0)
 0136                .Where(id => id > 0);
 137
 0138            var summaries = await _analysisService.GetSummariesByIdsAsync(rawIds, userId, ct);
 0139            filter = AiOrdersFilterBuilder.FromOrders(summaries);
 140        }
 0141        else if (!string.IsNullOrWhiteSpace(preset))
 142        {
 0143            filter = AiOrdersFilterBuilder.FromPreset(preset);
 144        }
 145        else
 146        {
 0147            filter = new OServices.FiltersServices.FilterModels.OrderFilterModel { GroupByShipment = true };
 148        }
 149
 0150        Response.Cookies.Append("FilterData", Newtonsoft.Json.JsonConvert.SerializeObject(filter));
 0151        return Redirect("/Orders/Index?page=1");
 0152    }
 153
 154    /// <summary>JSON фильтра для главной таблицы (без редиректа через OpenOrders).</summary>
 155    [HttpGet("OrderFilter")]
 156    public async Task<IActionResult> OrderFilter(
 157        string? preset,
 158        string? ids,
 159        CancellationToken ct = default)
 160    {
 0161        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
 162        OServices.FiltersServices.FilterModels.OrderFilterModel filter;
 163
 0164        if (!string.IsNullOrWhiteSpace(preset) && FieldPresets.Contains(preset))
 0165            filter = AiOrdersFilterBuilder.FromPreset(preset);
 0166        else if (!string.IsNullOrWhiteSpace(ids))
 167        {
 0168            var rawIds = ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 0169                .Select(s => int.TryParse(s, out var id) ? id : 0)
 0170                .Where(id => id > 0);
 0171            var summaries = await _analysisService.GetSummariesByIdsAsync(rawIds, userId, ct);
 0172            filter = AiOrdersFilterBuilder.FromOrders(summaries);
 173        }
 0174        else if (!string.IsNullOrWhiteSpace(preset))
 0175            filter = AiOrdersFilterBuilder.FromPreset(preset);
 176        else
 0177            return BadRequest(new { error = "Укажите ids или preset." });
 178
 0179        return Content(Newtonsoft.Json.JsonConvert.SerializeObject(filter), "application/json");
 0180    }
 181
 182    [HttpPost("Chat")]
 183    public async Task<IActionResult> Chat(
 184        [FromBody] AiChatRequest request,
 185        CancellationToken ct = default)
 186    {
 0187        if (string.IsNullOrWhiteSpace(request.Message))
 0188            return BadRequest(new AiChatResponse
 0189            {
 0190                Success = false,
 0191                Error   = "Сообщение не может быть пустым."
 0192            });
 193
 194        try
 195        {
 0196            var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
 0197            var intent = await _analysisService.DetectIntentAsync(request.Message, request.Context, ct);
 198
 0199            var dataContext = string.Empty;
 0200            var tableData   = new List<OrderSummaryDto>();
 0201            var enrichment  = (AiChatEnrichmentDto?)null;
 0202            var dbRan       = false;
 0203            string? dbQuery = null;
 204
 0205            AiActionProposalDto?       actionProposal  = null;
 0206            List<AiActionProposalDto>? actionProposals = null;
 0207            bool    isSearchResult           = false;
 0208            string? appliedFiltersDescription = null;
 0209            int?    totalFound               = null;
 0210            if (intent != null)
 211            {
 0212                var queryResult = await _analysisService.RunQueryAsync(intent, userId, ct, request.Message);
 0213                dataContext              = queryResult.Context;
 0214                tableData                = queryResult.Orders;
 0215                enrichment               = queryResult.Enrichment;
 0216                dbRan                    = true;
 0217                dbQuery                  = queryResult.SqlQuery;
 0218                actionProposal           = queryResult.ActionProposal;
 0219                actionProposals          = queryResult.ActionProposals;
 0220                isSearchResult           = queryResult.IsSearchResult;
 0221                appliedFiltersDescription = queryResult.AppliedFiltersDescription;
 0222                totalFound               = queryResult.TotalFound;
 0223                _logger.LogInformation("AI intent: {Tool}, rows: {Count}", intent.ToolName, tableData.Count);
 224            }
 225
 0226            var answer = await _dataManager.AskWithContextAsync(
 0227                request.Message,
 0228                dataContext,
 0229                request.History,
 0230                request.Model,
 0231                ct);
 232
 0233            var steps = AiThinkingStepsBuilder.BuildCompleted(
 0234                intent,
 0235                request.Message,
 0236                tableData.Count,
 0237                dbRan);
 238
 0239            var actionLinks = _suggestorAggregator.Suggest(new AiSuggestionContext
 0240            {
 0241                ToolName            = intent?.ToolName,
 0242                Article             = intent?.Article,
 0243                ManufacturerPrefix  = intent?.ManufacturerPrefix,
 0244                OrderId             = intent?.OrderId,
 0245                Orders              = tableData,
 0246                ConversationContext = request.Context
 0247            });
 0248            var newContext  = AiOrderAnalysisService.UpdateContext(request.Context, intent, tableData, actionProposal);
 0249            var palette     = AiStatusPaletteBuilder.FromOrders(tableData);
 250
 0251            return Ok(new AiChatResponse
 0252            {
 0253                Success       = true,
 0254                Answer        = answer,
 0255                ToolUsed      = intent?.ToolName,
 0256                ToolLabel     = AiOrderAnalysisService.GetToolLabel(intent?.ToolName),
 0257                Data          = tableData.Count > 0 ? tableData : null,
 0258                Steps         = steps,
 0259                ActionLinks   = actionLinks.Count > 0 ? actionLinks : null,
 0260                Context       = newContext,
 0261                StatusPalette = palette.Count > 0 ? palette : null,
 0262                Enrichment    = enrichment,
 0263                DbQuery       = dbQuery
 0264            });
 265        }
 0266        catch (OperationCanceledException)
 267        {
 0268            return StatusCode(499, new AiChatResponse { Success = false, Error = "Запрос отменён." });
 269        }
 0270        catch (Exception ex)
 271        {
 0272            _logger.LogError(ex, "AI assistant chat error");
 0273            return StatusCode(500, new AiChatResponse
 0274            {
 0275                Success = false,
 0276                Error   = "Произошла ошибка при обращении к AI. Попробуйте ещё раз."
 0277            });
 278        }
 0279    }
 280
 281    /// <summary>
 282    /// Выполняет write-действие агента (редактирование полей или создание транзакции).
 283    /// Вызывается фронтендом после подтверждения пользователем.
 284    /// </summary>
 285    [HttpPost("ExecuteAction")]
 286    public async Task<IActionResult> ExecuteAction(
 287        [FromBody] AiExecuteActionRequest request,
 288        CancellationToken ct = default)
 289    {
 0290        var userName = User.Identity?.Name ?? "AI Agent";
 291        AiExecuteActionResponse result;
 292
 293        try
 294        {
 0295            result = request.Kind switch
 0296            {
 0297                "edit_order" when request.EditDto != null =>
 0298                    await _writeService.ExecuteEditAsync(request.OrderId, request.EditDto, userName, ct, request.Model),
 0299
 0300                "create_transaction" when request.TransactionDto != null =>
 0301                    await _writeService.ExecuteTransactionAsync(request.OrderId, request.TransactionDto, userName, ct, r
 0302
 0303                _ => new AiExecuteActionResponse
 0304                {
 0305                    Success = false,
 0306                    Message = "Неизвестный тип действия или отсутствуют данные."
 0307                }
 0308            };
 0309        }
 0310        catch (Exception ex)
 311        {
 0312            _logger.LogError(ex, "ExecuteAction failed: kind={Kind}, orderId={OrderId}", request.Kind, request.OrderId);
 0313            result = new AiExecuteActionResponse
 0314            {
 0315                Success = false,
 0316                Message = $"Ошибка: {ex.Message}"
 0317            };
 0318        }
 319
 320        // Генерируем AI-резюме на основе результата
 0321        if (result.Success)
 322        {
 323            try
 324            {
 0325                var summaryContext = BuildSummaryContext(request, result);
 0326                result.AiSummary = await _dataManager.AskWithContextAsync(
 0327                    summaryContext,
 0328                    string.Empty,
 0329                    history: null,
 0330                    modelOverride: request.Model,
 0331                    ct);
 0332            }
 0333            catch (OperationCanceledException)
 334            {
 335                // Action already executed, user cancelled summary generation
 0336            }
 0337            catch (Exception ex)
 338            {
 0339                _logger.LogWarning(ex, "ExecuteAction: AI summary generation failed (non-blocking)");
 0340            }
 341        }
 342
 0343        return Ok(result);
 0344    }
 345
 346    /// <summary>
 347    /// Формирует промпт для AI-резюме выполненного действия.
 348    /// </summary>
 349    private static string BuildSummaryContext(AiExecuteActionRequest request, AiExecuteActionResponse result)
 350    {
 0351        var parts = new System.Text.StringBuilder();
 0352        parts.AppendLine("Ты — AI-агент Стас. Агент только что выполнил действие в системе управления заказами.");
 0353        parts.AppendLine("Напиши краткое резюме (2–3 предложения) от первого лица, обращаясь к пользователю.");
 0354        parts.AppendLine("Используй конкретные детали: номер заказа, тип транзакции, изменённые поля, новый статус.");
 0355        parts.AppendLine("Не используй markdown-заголовки и не повторяй технические детали дословно.");
 0356        parts.AppendLine();
 0357        parts.Append("Тип действия: ");
 0358        parts.AppendLine(request.Kind == "create_transaction" ? "транзакция" : "редактирование заказа");
 359
 0360        var shipment = result.ShipmentNumber ?? $"#{request.OrderId}";
 0361        parts.Append("Заказ: "); parts.AppendLine(shipment);
 362
 0363        if (request.TransactionDto != null)
 364        {
 0365            parts.Append("Транзакция: "); parts.AppendLine(request.TransactionDto.TransactionLabel);
 0366            if (!string.IsNullOrWhiteSpace(request.TransactionDto.SupplierName))
 367            {
 0368                parts.Append("Поставщик установлен: "); parts.AppendLine(request.TransactionDto.SupplierName);
 369            }
 0370            if (request.TransactionDto.PurchasePrice is > 0)
 371            {
 0372                parts.Append("Цена закупки: "); parts.Append(request.TransactionDto.PurchasePrice); parts.AppendLine(" ₽
 373            }
 374        }
 0375        if (request.EditDto != null)
 376        {
 0377            if (!string.IsNullOrWhiteSpace(request.EditDto.AppStatusName))
 0378            { parts.Append("Новый статус: "); parts.AppendLine(request.EditDto.AppStatusName); }
 0379            if (!string.IsNullOrWhiteSpace(request.EditDto.SupplierName))
 0380            { parts.Append("Поставщик: "); parts.AppendLine(request.EditDto.SupplierName); }
 0381            if (request.EditDto.PurchasePrice.HasValue)
 0382            { parts.Append("Цена закупки: "); parts.Append(request.EditDto.PurchasePrice); parts.AppendLine(" ₽"); }
 383        }
 0384        if (!string.IsNullOrWhiteSpace(result.NewStatus))
 0385        { parts.Append("Итоговый статус заказа: "); parts.AppendLine(result.NewStatus); }
 0386        if (!string.IsNullOrWhiteSpace(result.OneCResult))
 0387        { parts.Append("Результат проводки в 1С: "); parts.AppendLine(result.OneCResult); }
 388
 0389        parts.AppendLine();
 0390        parts.Append("Системное сообщение о результате: "); parts.AppendLine(result.Message);
 0391        parts.AppendLine();
 0392        parts.AppendLine("Напиши резюме:");
 0393        return parts.ToString();
 394    }
 395
 396    /// <summary>
 397    /// SSE-стриминг ответа AI. События: meta → token → done.
 398    /// Клиент читает через fetch + ReadableStream.
 399    /// </summary>
 400    [HttpPost("Stream")]
 401    public async Task StreamChat([FromBody] AiChatRequest request, CancellationToken ct)
 402    {
 0403        if (string.IsNullOrWhiteSpace(request.Message))
 404        {
 0405            Response.StatusCode = 400;
 0406            return;
 407        }
 408
 0409        Response.ContentType = "text/event-stream; charset=utf-8";
 0410        Response.Headers["Cache-Control"]    = "no-cache, no-store";
 0411        Response.Headers["X-Accel-Buffering"] = "no";
 0412        HttpContext.Features.Get<IHttpResponseBodyFeature>()?.DisableBuffering();
 413
 0414        var serSettings = new JsonSerializerSettings
 0415        {
 0416            NullValueHandling = NullValueHandling.Ignore,
 0417            ContractResolver  = new CamelCasePropertyNamesContractResolver()
 0418        };
 419
 420        async Task EmitAsync(string eventName, object data)
 421        {
 0422            var json = JsonConvert.SerializeObject(data, serSettings);
 0423            var line = $"event: {eventName}\ndata: {json}\n\n";
 0424            await Response.WriteAsync(line, Encoding.UTF8, ct);
 0425            await Response.Body.FlushAsync(ct);
 0426        }
 427
 0428        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
 0429        var intent = await _analysisService.DetectIntentAsync(request.Message, request.Context, ct);
 430
 431        // Параллельные агенты order_detail пишут progress из пула потоков — сериализуем SSE.
 0432        var progressGate = new SemaphoreSlim(1, 1);
 433        async Task EmitProgressAsync(AiAgentProgressUpdate update)
 434        {
 0435            await progressGate.WaitAsync(ct);
 436            try
 437            {
 0438                if (!string.IsNullOrWhiteSpace(update.LogLine))
 0439                    await EmitAsync("log", new SseLogEvent { Line = update.LogLine, Icon = update.LogIcon });
 0440                await EmitAsync("meta", new SseMetaEvent
 0441                {
 0442                    ToolName  = intent?.ToolName,
 0443                    ToolLabel = AiOrderAnalysisService.GetToolLabel(intent?.ToolName),
 0444                    Steps     = update.Steps,
 0445                    Phase     = update.Phase,
 0446                    FromCache = update.FromCache,
 0447                });
 0448            }
 449            finally
 450            {
 0451                progressGate.Release();
 452            }
 0453        }
 454
 455        // ── 1. Мета: intent + ожидаемые шаги ─────────────────────────────────
 0456        var pendingSteps = AiThinkingStepsBuilder.BuildPending(intent, request.Message);
 0457        await EmitAsync("meta", new SseMetaEvent
 0458        {
 0459            ToolName  = intent?.ToolName,
 0460            ToolLabel = AiOrderAnalysisService.GetToolLabel(intent?.ToolName),
 0461            Steps     = pendingSteps,
 0462            Phase     = "intent"
 0463        });
 0464        await EmitAsync("log", new SseLogEvent
 0465        {
 0466            Line = intent != null
 0467                ? $"✓ Распознано: {AiOrderAnalysisService.GetToolLabel(intent.ToolName)}"
 0468                : "⟳ Анализ запроса…",
 0469            Icon = intent != null ? "check" : "spin"
 0470        });
 471
 472        // ── 2. Запрос к БД ────────────────────────────────────────────────────
 0473        var dataContext = string.Empty;
 0474        var tableData   = new List<OrderSummaryDto>();
 0475        AiChatEnrichmentDto? enrichment = null;
 0476        AiActionProposalDto?       actionProposal  = null;
 0477        List<AiActionProposalDto>? actionProposals = null;
 0478        bool    isSearchResult            = false;
 0479        string? appliedFiltersDescription = null;
 0480        int?    totalFound                = null;
 0481        string? dbQuery  = null;
 0482        long    dbMs     = 0;
 0483        AiOrderFilterCriteria? appliedCriteria = null;
 0484        string?                suggestionQuery = null;
 0485        AiOrderFilterCriteria? relaxedSuggestion = null;
 0486        int?                   relaxedCount    = null;
 0487        string?                filterSpecification = null;
 488
 0489        if (intent != null)
 490        {
 0491            var sw = Stopwatch.StartNew();
 492            try
 493            {
 0494                var queryResult = await _analysisService.RunQueryAsync(
 0495                    intent, userId, ct, request.Message,
 0496                    onProgress: EmitProgressAsync);
 0497                sw.Stop();
 0498                dbMs                      = sw.ElapsedMilliseconds;
 0499                dataContext               = queryResult.Context;
 0500                tableData                 = queryResult.Orders;
 0501                enrichment                = queryResult.Enrichment;
 0502                dbQuery                   = queryResult.SqlQuery;
 0503                actionProposal            = queryResult.ActionProposal;
 0504                actionProposals           = queryResult.ActionProposals;
 0505                isSearchResult            = queryResult.IsSearchResult;
 0506                appliedFiltersDescription = queryResult.AppliedFiltersDescription;
 0507                totalFound                = queryResult.TotalFound;
 0508                appliedCriteria           = queryResult.AppliedCriteria;
 0509                suggestionQuery           = queryResult.SuggestionQuery;
 0510                relaxedSuggestion         = queryResult.RelaxedSuggestion;
 0511                relaxedCount              = queryResult.RelaxedCount;
 0512                filterSpecification       = queryResult.FilterSpecification;
 513
 0514                if (queryResult.OzonLiveUi != null)
 515                {
 0516                    enrichment ??= new AiChatEnrichmentDto();
 0517                    enrichment.OzonLive = queryResult.OzonLiveUi;
 518                }
 0519            }
 0520            catch (OperationCanceledException) { return; }
 0521            catch (Exception ex)
 522            {
 0523                sw.Stop();
 0524                dbMs = sw.ElapsedMilliseconds;
 0525                _logger.LogError(ex, "Stream: DB query failed");
 0526            }
 0527            sw.Stop();
 528
 0529            var completedSteps = AiThinkingStepsBuilder.BuildCompleted(intent, request.Message, tableData.Count, true);
 0530            await EmitAsync("meta", new SseMetaEvent
 0531            {
 0532                RowCount = tableData.Count,
 0533                DbMs     = dbMs,
 0534                Sql      = dbQuery,
 0535                Steps    = completedSteps,
 0536                Phase    = "db"
 0537            });
 0538            await EmitAsync("log", new SseLogEvent
 0539            {
 0540                Line = tableData.Count > 0
 0541                    ? $"✓ Данные: {tableData.Count} записей ({dbMs} мс)"
 0542                    : $"✓ Запрос к БД ({dbMs} мс)",
 0543                Icon = "check"
 0544            });
 545
 0546        }
 547
 548        // ── 3. Стриминг токенов AI ────────────────────────────────────────────
 549        {
 0550            var aiSteps = AiThinkingStepsBuilder.BuildCompleted(
 0551                intent, request.Message, tableData.Count, intent != null);
 0552            var aiStepIndex = intent?.ToolName == "orders_filter" ? 3 : 2;
 0553            if (aiSteps.Count > aiStepIndex)
 554            {
 0555                aiSteps[0].Status = "done";
 0556                if (intent?.ToolName == "orders_filter" && aiSteps.Count > 1)
 557                {
 0558                    aiSteps[1].Status = "done";
 0559                    aiSteps[1].Done   = true;
 560                }
 0561                aiSteps[aiStepIndex - 1].Status = "done";
 0562                aiSteps[aiStepIndex].Status     = "active";
 0563                aiSteps[aiStepIndex].Done       = false;
 0564                aiSteps[aiStepIndex].Label      = "Формирую ответ…";
 565            }
 0566            await EmitAsync("meta", new SseMetaEvent { Steps = aiSteps, Phase = "ai" });
 0567            await EmitAsync("log", new SseLogEvent { Line = "⟳ Генерация ответа…", Icon = "spin" });
 568        }
 569
 0570        UsageInfo? tokenUsage = null;
 0571        var answerBuilder = new StringBuilder();
 0572        var cannedAnswer = AiAssistantResponseGuard.TryBuildCannedAnswer(
 0573            intent?.ToolName, dataContext, actionProposal, tableData);
 574
 0575        if (!string.IsNullOrWhiteSpace(cannedAnswer))
 576        {
 0577            answerBuilder.Append(cannedAnswer);
 0578            await EmitAsync("token", new { t = cannedAnswer });
 579        }
 580        else
 581        {
 582            try
 583            {
 0584                await foreach (var token in _dataManager.StreamWithContextAsync(
 0585                                   request.Message, dataContext, request.History, request.Model,
 0586                                   onUsage: u => tokenUsage = u,
 0587                                   ct))
 588                {
 0589                    answerBuilder.Append(token);
 0590                    await EmitAsync("token", new { t = token });
 591                }
 0592            }
 0593            catch (OperationCanceledException) { return; }
 0594            catch (Exception ex)
 595            {
 0596                _logger.LogError(ex, "Stream: AI token stream failed");
 0597                await EmitAsync("error", new { message = "Ошибка при получении ответа от AI." });
 0598                return;
 599            }
 600        }
 601
 602        // ── 4. Финальные данные: enrichment, action links ─────────────────────
 0603        var actionLinks = _suggestorAggregator.Suggest(new AiSuggestionContext
 0604        {
 0605            ToolName            = intent?.ToolName,
 0606            Article             = intent?.Article,
 0607            ManufacturerPrefix  = intent?.ManufacturerPrefix,
 0608            OrderId             = intent?.OrderId,
 0609            Orders              = tableData,
 0610            ConversationContext = request.Context
 0611        });
 612        // Выбираем первый proposal для UpdateContext (для pending transaction)
 0613        var primaryProposal = actionProposal ?? actionProposals?.FirstOrDefault();
 0614        var newContext = AiOrderAnalysisService.UpdateContext(request.Context, intent, tableData, primaryProposal);
 0615        var palette    = AiStatusPaletteBuilder.FromOrders(tableData);
 616
 0617        List<string>? suggestedFollowUps = null;
 0618        if (intent != null)
 619        {
 0620            suggestedFollowUps = await _dataManager.GenerateFollowUpChipsAsync(
 0621                request.Message,
 0622                intent.ToolName,
 0623                dataContext,
 0624                totalFound,
 0625                ct);
 0626            if (suggestedFollowUps.Count == 0)
 0627                suggestedFollowUps = null;
 628        }
 629
 0630        var fullAnswer = answerBuilder.ToString();
 0631        var usedFallback = false;
 0632        if (string.IsNullOrWhiteSpace(cannedAnswer)
 0633            && AiAssistantResponseGuard.ShouldReplaceLlmAnswer(fullAnswer, tableData, intent?.ToolName)
 0634            && (!string.IsNullOrWhiteSpace(dataContext) || tableData.Count > 0))
 635        {
 0636            fullAnswer   = AiAssistantResponseGuard.BuildDataFallback(dataContext, tableData, intent?.ToolName);
 0637            usedFallback = true;
 638        }
 639
 0640        await EmitAsync("done", new SseDoneEvent
 0641        {
 0642            AnswerText                = usedFallback || !string.IsNullOrWhiteSpace(cannedAnswer) ? fullAnswer : null,
 0643            Enrichment                = enrichment,
 0644            ActionLinks               = actionLinks.Count > 0 ? actionLinks : null,
 0645            Data                      = tableData.Count > 0 ? tableData : null,
 0646            StatusPalette             = palette.Count > 0 ? palette : null,
 0647            Context                   = newContext,
 0648            ActionProposal            = actionProposal,
 0649            ActionProposals           = actionProposals,
 0650            IsSearchResult            = isSearchResult,
 0651            AppliedFiltersDescription = appliedFiltersDescription,
 0652            TotalFound                = totalFound,
 0653            AppliedCriteria           = appliedCriteria,
 0654            SuggestionQuery           = suggestionQuery,
 0655            RelaxedSuggestion         = relaxedSuggestion,
 0656            RelaxedCount              = relaxedCount,
 0657            DbQuery                   = dbQuery,
 0658            FilterSpecification       = filterSpecification,
 0659            ToolLabel                 = AiOrderAnalysisService.GetToolLabel(intent?.ToolName),
 0660            SuggestedFollowUps        = suggestedFollowUps,
 0661            TokenUsage      = tokenUsage == null ? null : new AiTokenUsageDto
 0662            {
 0663                PromptTokens     = tokenUsage.PromptTokens,
 0664                CompletionTokens = tokenUsage.CompletionTokens,
 0665                TotalTokens      = tokenUsage.TotalTokens,
 0666            },
 0667        });
 0668    }
 669
 670    /// <summary>Устанавливает cookie FilterData по критериям поиска и возвращает URL таблицы заказов.</summary>
 671    [HttpPost("BuildOrdersFilterUrl")]
 672    public IActionResult BuildOrdersFilterUrl([FromBody] AiOrderFilterCriteria? criteria)
 673    {
 0674        if (criteria == null)
 0675            return BadRequest(new { error = "criteria required" });
 676
 0677        var filter = AiFilterCriteriaMapper.ToOrderFilterModel(criteria);
 0678        var json   = JsonConvert.SerializeObject(filter);
 0679        Response.Cookies.Append("FilterData", json, new CookieOptions
 0680        {
 0681            MaxAge   = TimeSpan.FromMinutes(30),
 0682            Path     = "/",
 0683            SameSite = SameSiteMode.Lax,
 0684        });
 0685        return Ok(new { url = "/Orders/Index?page=1" });
 686    }
 687
 688    /// <summary>Экспортирует результаты AI-поиска в Excel-файл.</summary>
 689    [HttpPost("ExportSearchExcel")]
 690    public async Task<IActionResult> ExportSearchExcel(
 691        [FromBody] AiOrderFilterCriteria? criteria,
 692        CancellationToken ct = default)
 693    {
 0694        if (criteria == null)
 0695            return BadRequest(new { error = "criteria required" });
 696
 0697        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
 0698        var result = await _searchAgent.SearchAsync(criteria, userId, ct, maxResults: 5000);
 0699        var ids    = result.Orders.Select(o => o.Id).ToList();
 0700        if (ids.Count == 0)
 0701            return NotFound(new { error = "Заказы не найдены." });
 702
 0703        var orders = await _orderRepository.GetOrdersByIds(ids, criteria.IncludeArchived);
 0704        var bytes  = _excelExporter.ExportToExcel(orders);
 705
 0706        var filename = $"AI_поиск_{DateTime.Now:yyyyMMdd_HHmm}.xlsx";
 0707        return File(bytes,
 0708            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
 0709            filename);
 0710    }
 711
 712}