< Summary

Information
Class: Services.AiAssistant.AiAssistantResponseGuard
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/AiAssistant/AiAssistantResponseGuard.cs
Line coverage
100%
Covered lines: 75
Uncovered lines: 0
Coverable lines: 75
Total lines: 156
Line coverage: 100%
Branch coverage
98%
Covered branches: 102
Total branches: 104
Branch coverage: 98%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
IsLowQualityLlmAnswer(...)100%44100%
ContradictsDbData(...)100%1212100%
ShouldReplaceLlmAnswer(...)100%44100%
IgnoresDbFacts(...)88.88%1818100%
ProductNameMatches(...)100%66100%
TryBuildWriteTransactionAnswer(...)100%2222100%
TryBuildCannedAnswer(...)100%1010100%
BuildDataFallback(...)100%2828100%

File(s)

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

#LineLine coverage
 1using Services.ApiServices.OpenRouterApi.Models;
 2
 3namespace Services.AiAssistant;
 4
 5/// <summary>Детерминированные ответы и проверка качества LLM-вывода.</summary>
 6public static class AiAssistantResponseGuard
 7{
 8    public static bool IsLowQualityLlmAnswer(string? answer) =>
 179        string.IsNullOrWhiteSpace(answer)
 1710        || answer.Length < 40
 1711        || answer.Contains("User Safety:", StringComparison.OrdinalIgnoreCase);
 12
 13    /// <summary>LLM утверждает «не найден», хотя в карточке есть заказы.</summary>
 14    public static bool ContradictsDbData(string? answer, IReadOnlyList<OrderSummaryDto> orders)
 15    {
 1716        if (orders.Count == 0 || string.IsNullOrWhiteSpace(answer))
 517            return false;
 18
 1219        var lower = answer.ToLowerInvariant();
 1220        return lower.Contains("не найден")
 1221               || lower.Contains("не найдены")
 1222               || lower.Contains("нет данных")
 1223               || lower.Contains("в предоставленных записях нет")
 1224               || lower.Contains("данные отсутствуют");
 25    }
 26
 27    public static bool ShouldReplaceLlmAnswer(
 28        string? answer,
 29        IReadOnlyList<OrderSummaryDto> orders,
 30        string? toolName = null) =>
 431        IsLowQualityLlmAnswer(answer)
 432        || ContradictsDbData(answer, orders)
 433        || IgnoresDbFacts(answer, orders, toolName);
 34
 35    /// <summary>Ответ не содержит якорей из карточки заказа (типичная галлюцинация free-модели).</summary>
 36    public static bool IgnoresDbFacts(
 37        string? answer,
 38        IReadOnlyList<OrderSummaryDto> orders,
 39        string? toolName)
 40    {
 1841        if (toolName is not ("order_detail" or "order_history")
 1842            || orders.Count == 0
 1843            || string.IsNullOrWhiteSpace(answer))
 944            return false;
 45
 946        var o     = orders[0];
 947        var lower = answer.ToLowerInvariant();
 48
 949        var hasShipment = !string.IsNullOrWhiteSpace(o.ShipmentNumber)
 950                            && lower.Contains(o.ShipmentNumber.ToLowerInvariant());
 951        if (!hasShipment)
 252            return true;
 53
 754        var hasStatus = !string.IsNullOrWhiteSpace(o.AppStatus)
 755                        && lower.Contains(o.AppStatus.ToLowerInvariant());
 56
 757        var hasProduct = !string.IsNullOrWhiteSpace(o.ProductName)
 758                         && ProductNameMatches(o.ProductName, lower);
 59
 760        return !(hasStatus || hasProduct);
 61    }
 62
 63    private static bool ProductNameMatches(string productName, string answerLower)
 64    {
 8765        foreach (var word in productName.Split(' ', StringSplitOptions.RemoveEmptyEntries))
 66        {
 3867            if (word.Length < 4)
 68                continue;
 2369            if (answerLower.Contains(word.ToLowerInvariant()))
 370                return true;
 71        }
 472        return false;
 73    }
 74
 75    /// <summary>Готовый ответ без LLM (write-транзакция с карточкой).</summary>
 76    public static string? TryBuildWriteTransactionAnswer(
 77        AiActionProposalDto? proposal,
 78        IReadOnlyList<OrderSummaryDto> orders)
 79    {
 1280        if (proposal?.Kind != "create_transaction" || orders.Count == 0)
 581            return null;
 82
 783        var txLabel = proposal.TransactionDto?.TransactionLabel ?? "транзакция";
 784        var ship    = proposal.ShipmentNumber ?? orders[0].ShipmentNumber ?? $"#{proposal.OrderId}";
 85
 786        if (proposal.MissingFields is { Count: > 0 })
 387            return $"Для транзакции «{txLabel}» по заказу {ship} нужно уточнить: {string.Join("; ", proposal.MissingFiel
 88
 489        return $"Транзакция «{txLabel}» для заказа {ship} готова. " +
 490               $"Текущий статус: «{proposal.CurrentStatus ?? orders[0].AppStatus ?? "—"}». " +
 491               "Проверьте карточку под сообщением и нажмите «Подтвердить».";
 92    }
 93
 94    /// <summary>Ответ без LLM для инструментов с готовым контекстом.</summary>
 95    public static string? TryBuildCannedAnswer(
 96        string? toolName,
 97        string dataContext,
 98        AiActionProposalDto? proposal,
 99        IReadOnlyList<OrderSummaryDto> orders)
 100    {
 7101        if (toolName == "transaction_types_info" && !string.IsNullOrWhiteSpace(dataContext))
 1102            return "Типы транзакций Studio2:\n\n" + dataContext;
 103
 6104        if (toolName == "write_transaction")
 1105            return TryBuildWriteTransactionAnswer(proposal, orders);
 106
 5107        if (toolName == "cancelled_orders_today" && orders.Count > 0)
 108        {
 1109            return $"Сегодня отменено **{orders.Count}** заказ(ов) (точное число из БД).\n\n" +
 1110                   BuildDataFallback(dataContext, orders, toolName);
 111        }
 112
 4113        return null;
 114    }
 115
 116    /// <summary>Fallback когда модель вернула мусор, но контекст БД есть.</summary>
 117    public static string BuildDataFallback(
 118        string dataContext,
 119        IReadOnlyList<OrderSummaryDto> orders,
 120        string? toolName)
 121    {
 14122        if (orders.Count > 0)
 123        {
 9124            if (toolName is "order_detail" or "order_history"
 9125                && !string.IsNullOrWhiteSpace(dataContext)
 9126                && dataContext.Length > 200)
 127            {
 3128                var ship    = orders[0].ShipmentNumber ?? $"#{orders[0].Id}";
 3129                var preview = dataContext.Length > 2500 ? dataContext[..2500] + "…" : dataContext;
 3130                var title = toolName == "order_history"
 3131                    ? $"История заказа {ship} (данные из БД):\n"
 3132                    : $"Детали заказа {ship} (данные из БД):\n";
 3133                return title + preview;
 134            }
 135
 6136            var lines = orders.Take(5).Select(o =>
 33137                $"• #{o.Id} {o.ShipmentNumber} — {o.ProductName} | статус «{o.AppStatus}»");
 6138            var head = toolName switch
 6139            {
 1140                "orders_filter"          => "Найденные заказы (данные из БД):",
 2141                "cancelled_orders_today" => $"Отменённые сегодня: {orders.Count} шт. (данные из БД):",
 1142                "overdue_orders"         => $"Просроченные заказы (показано {orders.Count}):",
 2143                _                        => "Данные из БД:"
 6144            };
 6145            return head + "\n" + string.Join("\n", lines);
 146        }
 147
 5148        if (!string.IsNullOrWhiteSpace(dataContext))
 149        {
 2150            var preview = dataContext.Length > 1200 ? dataContext[..1200] + "…" : dataContext;
 2151            return "Краткий ответ по данным системы:\n" + preview;
 152        }
 153
 3154        return "Не удалось сформировать ответ. Повторите запрос или выберите другую модель.";
 155    }
 156}