| | | 1 | | using Services.ApiServices.OpenRouterApi.Models; |
| | | 2 | | |
| | | 3 | | namespace Services.AiAssistant; |
| | | 4 | | |
| | | 5 | | /// <summary>Детерминированные ответы и проверка качества LLM-вывода.</summary> |
| | | 6 | | public static class AiAssistantResponseGuard |
| | | 7 | | { |
| | | 8 | | public static bool IsLowQualityLlmAnswer(string? answer) => |
| | 17 | 9 | | string.IsNullOrWhiteSpace(answer) |
| | 17 | 10 | | || answer.Length < 40 |
| | 17 | 11 | | || answer.Contains("User Safety:", StringComparison.OrdinalIgnoreCase); |
| | | 12 | | |
| | | 13 | | /// <summary>LLM утверждает «не найден», хотя в карточке есть заказы.</summary> |
| | | 14 | | public static bool ContradictsDbData(string? answer, IReadOnlyList<OrderSummaryDto> orders) |
| | | 15 | | { |
| | 17 | 16 | | if (orders.Count == 0 || string.IsNullOrWhiteSpace(answer)) |
| | 5 | 17 | | return false; |
| | | 18 | | |
| | 12 | 19 | | var lower = answer.ToLowerInvariant(); |
| | 12 | 20 | | return lower.Contains("не найден") |
| | 12 | 21 | | || lower.Contains("не найдены") |
| | 12 | 22 | | || lower.Contains("нет данных") |
| | 12 | 23 | | || lower.Contains("в предоставленных записях нет") |
| | 12 | 24 | | || lower.Contains("данные отсутствуют"); |
| | | 25 | | } |
| | | 26 | | |
| | | 27 | | public static bool ShouldReplaceLlmAnswer( |
| | | 28 | | string? answer, |
| | | 29 | | IReadOnlyList<OrderSummaryDto> orders, |
| | | 30 | | string? toolName = null) => |
| | 4 | 31 | | IsLowQualityLlmAnswer(answer) |
| | 4 | 32 | | || ContradictsDbData(answer, orders) |
| | 4 | 33 | | || 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 | | { |
| | 18 | 41 | | if (toolName is not ("order_detail" or "order_history") |
| | 18 | 42 | | || orders.Count == 0 |
| | 18 | 43 | | || string.IsNullOrWhiteSpace(answer)) |
| | 9 | 44 | | return false; |
| | | 45 | | |
| | 9 | 46 | | var o = orders[0]; |
| | 9 | 47 | | var lower = answer.ToLowerInvariant(); |
| | | 48 | | |
| | 9 | 49 | | var hasShipment = !string.IsNullOrWhiteSpace(o.ShipmentNumber) |
| | 9 | 50 | | && lower.Contains(o.ShipmentNumber.ToLowerInvariant()); |
| | 9 | 51 | | if (!hasShipment) |
| | 2 | 52 | | return true; |
| | | 53 | | |
| | 7 | 54 | | var hasStatus = !string.IsNullOrWhiteSpace(o.AppStatus) |
| | 7 | 55 | | && lower.Contains(o.AppStatus.ToLowerInvariant()); |
| | | 56 | | |
| | 7 | 57 | | var hasProduct = !string.IsNullOrWhiteSpace(o.ProductName) |
| | 7 | 58 | | && ProductNameMatches(o.ProductName, lower); |
| | | 59 | | |
| | 7 | 60 | | return !(hasStatus || hasProduct); |
| | | 61 | | } |
| | | 62 | | |
| | | 63 | | private static bool ProductNameMatches(string productName, string answerLower) |
| | | 64 | | { |
| | 87 | 65 | | foreach (var word in productName.Split(' ', StringSplitOptions.RemoveEmptyEntries)) |
| | | 66 | | { |
| | 38 | 67 | | if (word.Length < 4) |
| | | 68 | | continue; |
| | 23 | 69 | | if (answerLower.Contains(word.ToLowerInvariant())) |
| | 3 | 70 | | return true; |
| | | 71 | | } |
| | 4 | 72 | | return false; |
| | | 73 | | } |
| | | 74 | | |
| | | 75 | | /// <summary>Готовый ответ без LLM (write-транзакция с карточкой).</summary> |
| | | 76 | | public static string? TryBuildWriteTransactionAnswer( |
| | | 77 | | AiActionProposalDto? proposal, |
| | | 78 | | IReadOnlyList<OrderSummaryDto> orders) |
| | | 79 | | { |
| | 12 | 80 | | if (proposal?.Kind != "create_transaction" || orders.Count == 0) |
| | 5 | 81 | | return null; |
| | | 82 | | |
| | 7 | 83 | | var txLabel = proposal.TransactionDto?.TransactionLabel ?? "транзакция"; |
| | 7 | 84 | | var ship = proposal.ShipmentNumber ?? orders[0].ShipmentNumber ?? $"#{proposal.OrderId}"; |
| | | 85 | | |
| | 7 | 86 | | if (proposal.MissingFields is { Count: > 0 }) |
| | 3 | 87 | | return $"Для транзакции «{txLabel}» по заказу {ship} нужно уточнить: {string.Join("; ", proposal.MissingFiel |
| | | 88 | | |
| | 4 | 89 | | return $"Транзакция «{txLabel}» для заказа {ship} готова. " + |
| | 4 | 90 | | $"Текущий статус: «{proposal.CurrentStatus ?? orders[0].AppStatus ?? "—"}». " + |
| | 4 | 91 | | "Проверьте карточку под сообщением и нажмите «Подтвердить»."; |
| | | 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 | | { |
| | 7 | 101 | | if (toolName == "transaction_types_info" && !string.IsNullOrWhiteSpace(dataContext)) |
| | 1 | 102 | | return "Типы транзакций Studio2:\n\n" + dataContext; |
| | | 103 | | |
| | 6 | 104 | | if (toolName == "write_transaction") |
| | 1 | 105 | | return TryBuildWriteTransactionAnswer(proposal, orders); |
| | | 106 | | |
| | 5 | 107 | | if (toolName == "cancelled_orders_today" && orders.Count > 0) |
| | | 108 | | { |
| | 1 | 109 | | return $"Сегодня отменено **{orders.Count}** заказ(ов) (точное число из БД).\n\n" + |
| | 1 | 110 | | BuildDataFallback(dataContext, orders, toolName); |
| | | 111 | | } |
| | | 112 | | |
| | 4 | 113 | | return null; |
| | | 114 | | } |
| | | 115 | | |
| | | 116 | | /// <summary>Fallback когда модель вернула мусор, но контекст БД есть.</summary> |
| | | 117 | | public static string BuildDataFallback( |
| | | 118 | | string dataContext, |
| | | 119 | | IReadOnlyList<OrderSummaryDto> orders, |
| | | 120 | | string? toolName) |
| | | 121 | | { |
| | 14 | 122 | | if (orders.Count > 0) |
| | | 123 | | { |
| | 9 | 124 | | if (toolName is "order_detail" or "order_history" |
| | 9 | 125 | | && !string.IsNullOrWhiteSpace(dataContext) |
| | 9 | 126 | | && dataContext.Length > 200) |
| | | 127 | | { |
| | 3 | 128 | | var ship = orders[0].ShipmentNumber ?? $"#{orders[0].Id}"; |
| | 3 | 129 | | var preview = dataContext.Length > 2500 ? dataContext[..2500] + "…" : dataContext; |
| | 3 | 130 | | var title = toolName == "order_history" |
| | 3 | 131 | | ? $"История заказа {ship} (данные из БД):\n" |
| | 3 | 132 | | : $"Детали заказа {ship} (данные из БД):\n"; |
| | 3 | 133 | | return title + preview; |
| | | 134 | | } |
| | | 135 | | |
| | 6 | 136 | | var lines = orders.Take(5).Select(o => |
| | 33 | 137 | | $"• #{o.Id} {o.ShipmentNumber} — {o.ProductName} | статус «{o.AppStatus}»"); |
| | 6 | 138 | | var head = toolName switch |
| | 6 | 139 | | { |
| | 1 | 140 | | "orders_filter" => "Найденные заказы (данные из БД):", |
| | 2 | 141 | | "cancelled_orders_today" => $"Отменённые сегодня: {orders.Count} шт. (данные из БД):", |
| | 1 | 142 | | "overdue_orders" => $"Просроченные заказы (показано {orders.Count}):", |
| | 2 | 143 | | _ => "Данные из БД:" |
| | 6 | 144 | | }; |
| | 6 | 145 | | return head + "\n" + string.Join("\n", lines); |
| | | 146 | | } |
| | | 147 | | |
| | 5 | 148 | | if (!string.IsNullOrWhiteSpace(dataContext)) |
| | | 149 | | { |
| | 2 | 150 | | var preview = dataContext.Length > 1200 ? dataContext[..1200] + "…" : dataContext; |
| | 2 | 151 | | return "Краткий ответ по данным системы:\n" + preview; |
| | | 152 | | } |
| | | 153 | | |
| | 3 | 154 | | return "Не удалось сформировать ответ. Повторите запрос или выберите другую модель."; |
| | | 155 | | } |
| | | 156 | | } |