From 6c984bc70ea685a4ff9f2360bb5b8e847d0a7840 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 27 Oct 2026 16:59:49 +0300 Subject: [PATCH 1/3] feat: ask your exchanges in plain English --- SW.Bitween.Api/Domain/OperatorAction.cs | 1 + .../Resources/XchangeQueries/Ask.cs | 115 ++++++++ .../Services/Ai/ExchangeQueryPrompt.cs | 263 ++++++++++++++++++ SW.Bitween.HttpTests/AskExchangesTests.cs | 87 ++++++ .../Ai/ExchangeQueryPromptTests.cs | 88 ++++++ SW.Bitween.Web/ClientApp/src/api/client.ts | 10 + .../ClientApp/src/api/http/exchanges.ts | 10 +- .../src/pages/exchanges/AskExchanges.tsx | 221 +++++++++++++++ .../src/pages/exchanges/ExchangesPage.tsx | 9 +- .../src/pages/exchanges/QueryBar.tsx | 5 + .../exchanges/__tests__/askExchanges.test.tsx | 73 +++++ docs/admin-ui.md | 2 +- docs/api-reference.md | 1 + docs/configuration.md | 11 +- 14 files changed, 892 insertions(+), 4 deletions(-) create mode 100644 SW.Bitween.Api/Resources/XchangeQueries/Ask.cs create mode 100644 SW.Bitween.Api/Services/Ai/ExchangeQueryPrompt.cs create mode 100644 SW.Bitween.HttpTests/AskExchangesTests.cs create mode 100644 SW.Bitween.UnitTests/Ai/ExchangeQueryPromptTests.cs create mode 100644 SW.Bitween.Web/ClientApp/src/pages/exchanges/AskExchanges.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/askExchanges.test.tsx diff --git a/SW.Bitween.Api/Domain/OperatorAction.cs b/SW.Bitween.Api/Domain/OperatorAction.cs index 969c09ac..93e1850a 100644 --- a/SW.Bitween.Api/Domain/OperatorAction.cs +++ b/SW.Bitween.Api/Domain/OperatorAction.cs @@ -17,6 +17,7 @@ public class OperatorAction public const string DeleteQueues = "delete-queues"; public const string ForgetBookmark = "forget-bookmark"; public const string InstallKit = "install-kit"; + public const string AskExchanges = "ask-exchanges"; private OperatorAction() { diff --git a/SW.Bitween.Api/Resources/XchangeQueries/Ask.cs b/SW.Bitween.Api/Resources/XchangeQueries/Ask.cs new file mode 100644 index 00000000..0c43e891 --- /dev/null +++ b/SW.Bitween.Api/Resources/XchangeQueries/Ask.cs @@ -0,0 +1,115 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain; +using SW.Bitween.Services.Ai; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.XchangeQueries; + +public class XchangeQueryRequest +{ + public string Question { get; set; } = ""; + + /// The asker's time zone, as the browser names it: "Asia/Amman". UTC when missing or unknown. + public string? TimeZone { get; set; } +} + +public class XchangeQueryResponse +{ + /// The Exchanges page's URL parameters: status=failed&partnerId=12&range=7d. + public string Query { get; set; } = ""; + + /// A sentence for above the result, with {count} where the page's total goes. + public string Answer { get; set; } = ""; + + /// What the filter couldn't say. + public List Notes { get; set; } = new(); + + public string? Model { get; set; } + + /// Exactly what went to the provider. + public string Sent { get; set; } = ""; +} + +/// +/// Turns a plain-English question about exchanges into the Exchanges page's filter. +/// +/// +/// The model is sent the question, the time and names - of partners, subscriptions, information +/// types and promoted-property keys - and nothing about any exchange. The filter it answers with +/// is the page's own, so it runs as every other filter does, with the asker's rights. +/// Each question is recorded as an operator action, since it goes to a third party. +/// +public class Ask(RequestContext requestContext, BitweenDbContext dbContext, AiModels models) + : ICommandHandler +{ + public async Task Handle(XchangeQueryRequest request) + { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Exchanges.View); + + var question = request.Question?.Trim() ?? ""; + if (question.Length == 0) + throw new SWValidationException("QUESTION", "Type a question about your exchanges first."); + if (question.Length > ExchangeQueryPrompt.MaxQuestionLength) + throw new SWValidationException("QUESTION", + $"Questions are limited to {ExchangeQueryPrompt.MaxQuestionLength} characters."); + + var model = models.Current(); + + var zone = TimeZoneOf(request.TimeZone); + var now = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, zone); + var names = await NamesAsync(); + var message = ExchangeQueryPrompt.Message(question, now, zone.Id, names); + + var answer = await model.Complete(new AiPrompt(ExchangeQueryPrompt.Instructions, message, Json: true, MaxTokens: 2000)); + var query = ExchangeQueryPrompt.Parse(answer, names); + + await dbContext.RecordNowAsync(requestContext, OperatorAction.AskExchanges, "exchanges", + $"Asked {models.ModelName}: {question}"); + + return new XchangeQueryResponse + { + Query = query.Query, + Answer = query.Answer, + Notes = query.Notes.ToList(), + Model = models.ModelName, + Sent = message, + }; + } + + async Task NamesAsync() + { + var partners = await dbContext.Set().AsNoTracking() + .OrderBy(p => p.Name).Select(p => new QueryName(p.Id, p.Name, null)) + .Take(ExchangeQueryPrompt.MaxNames + 1).ToListAsync(); + + var subscriptions = (await dbContext.Set().AsNoTracking() + .OrderBy(s => s.Name) + .Select(s => new { s.Id, s.Name, s.Type, s.PartnerId, s.DocumentId, s.Inactive }) + .Take(ExchangeQueryPrompt.MaxNames + 1).ToListAsync()) + .Select(s => new QueryName(s.Id, s.Name, + $"{s.Type}, partner {(s.PartnerId?.ToString() ?? "none")}, information type {s.DocumentId}{(s.Inactive ? ", inactive" : "")}")) + .ToList(); + + var documents = await dbContext.Set().AsNoTracking() + .OrderBy(d => d.Name).Take(ExchangeQueryPrompt.MaxNames + 1).ToListAsync(); + var informationTypes = documents + .Select(d => new QueryName(d.Id, d.Name, d.PromotedProperties is { Count: > 0 } promoted + ? "promotes " + string.Join(", ", promoted.Keys) + : null)) + .ToList(); + var keys = documents.SelectMany(d => d.PromotedProperties?.Keys ?? Enumerable.Empty()) + .Distinct(StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase).ToList(); + + return new QueryNames(partners, subscriptions, informationTypes, keys); + } + + static TimeZoneInfo TimeZoneOf(string? id) => + !string.IsNullOrWhiteSpace(id) && TimeZoneInfo.TryFindSystemTimeZoneById(id.Trim(), out var zone) + ? zone + : TimeZoneInfo.Utc; +} diff --git a/SW.Bitween.Api/Services/Ai/ExchangeQueryPrompt.cs b/SW.Bitween.Api/Services/Ai/ExchangeQueryPrompt.cs new file mode 100644 index 00000000..0267a22a --- /dev/null +++ b/SW.Bitween.Api/Services/Ai/ExchangeQueryPrompt.cs @@ -0,0 +1,263 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace SW.Bitween.Services.Ai; + +/// A partner, subscription or information type, as the model may see it: an id and a name. +public record QueryName(int Id, string Name, string? Detail = null); + +/// Everything a question may name, and the only things a filter may hold. +public record QueryNames( + IReadOnlyList Partners, + IReadOnlyList Subscriptions, + IReadOnlyList InformationTypes, + IReadOnlyList PropertyKeys); + +/// A question turned into an Exchanges page filter. +/// The page's own URL parameters, so the filter is the page's chips and its link. +/// A sentence for the page to show, with {count} where the page's total goes. +/// What the filter couldn't say: parts of the question, or names that don't exist. +public record ExchangeQuery(string Query, string Answer, IReadOnlyList Notes); + +/// +/// Turns a plain-English question about exchanges into the Exchanges page's filter. +/// +/// +/// +/// The model sees the question, the time, and names: of partners, subscriptions and information +/// types, and promoted-property keys. Never an exchange, a count, or a property's value - the count +/// in the answer is the page's, filled in where the model leaves {count}. +/// +/// +/// The answer is a filter in the model's own small shape rather than a URL, and every part of it is +/// checked against before it becomes one: an id that doesn't exist or a key +/// no information type promotes is dropped and said so, never passed on to match nothing silently. +/// +/// +public static class ExchangeQueryPrompt +{ + /// How many of each kind of name are sent; the rest are left out, alphabetically last. + public const int MaxNames = 2_000; + + public const int MaxQuestionLength = 500; + + const int MaxText = 200; + const int MaxAnswer = 300; + + public const string Instructions = """ + You turn a question about exchanges in Bitween, an integration platform, into a filter for + its Exchanges page. + + An exchange is one document passing through Bitween for one subscription: received, mapped, + then delivered by a handler. A subscription belongs to a partner and carries one information + type (a kind of document, such as shipment orders). An exchange's status is one of: + - "failed": it failed. + - "badResponse": it was delivered, but the partner's response was marked as bad. + - "processing": not finished yet. + - "success": delivered fine. + Promoted properties are values Bitween copies out of a document so it can be found by them, + such as an order number; each has a key. + + You get the question, the current time in the asker's time zone, and the partners, + subscriptions and information types by id and name, with the promoted-property keys. You + never see exchanges or counts. + + Answer with one JSON object and nothing else. Leave out or set to null anything the question + doesn't ask for: + { + "status": "failed" | "badResponse" | "processing" | "success", + "subscriptionIds": [ids], "partnerIds": [ids], "informationTypeIds": [ids], + "properties": [{ "key": "a promoted-property key", "text": "text in its value, or null for any value", "not": false }], + "anyProperty": "text to find in any promoted property", + "error": "text to find in the error message", + "correlationId": "an exact correlation id", + "range": "15m" | "1h" | "24h" | "7d" | "30d" | "today" | "yesterday" | "m" | "h" | "d", + "from": "YYYY-MM-DDTHH:mm", "to": "YYYY-MM-DDTHH:mm", + "latest": true, "retry": true, "scheduled": true, "aggregated": true, + "not": ["subscription" | "partner" | "informationType" | "property" | "error" | "retry" | "scheduled" | "aggregated"], + "answer": "one short sentence describing the result, with {count} where the number goes", + "notUnderstood": "the part of the question the filter can't express, or null" + } + + Rules: + - Use only ids from the lists. Match names loosely - "skynet" is the partner "SkyNet Express" - + and when a name fits a partner, filter by partner rather than listing its subscriptions. + - Several ids of one kind mean any of them. All filters together must all hold. + - "not" turns the named filters round: "not": ["partner"] means every partner except those. + - For a time like "since Monday" or "on 3 October", use from and to in the asker's time zone. + For "the last 2 hours", "today" or "yesterday", use range. Never both. + - "latest": only the latest attempt of each exchange, leaving out ones that were retried. Use + it for "still failing", "unresolved" or "needs attention". + - "retry": the exchange is a retry of an earlier one. "scheduled": a retry is scheduled. + "aggregated": the exchange was gathered into an aggregation. + - For a count question ("how many ..."), still return the filter; the page counts it. + - The answer is shown above the result, so write it as a result, not a reply: + "{count} failed SkyNet exchanges since Monday", never "Here are...". + - If the question isn't about exchanges at all, return only "answer" and "notUnderstood". + """; + + /// The message for one question. + public static string Message(string question, DateTime now, string timeZone, QueryNames names) + { + var message = new StringBuilder(); + message.AppendLine($"Now: {now:yyyy-MM-dd HH:mm}, {now:dddd}, in {timeZone}."); + message.AppendLine(); + message.AppendLine(""); + message.AppendLine(question.Trim()); + message.AppendLine(""); + List("partners", names.Partners, message); + List("subscriptions", names.Subscriptions, message); + List("information_types", names.InformationTypes, message); + message.AppendLine(); + message.AppendLine(""); + message.AppendLine(names.PropertyKeys.Count == 0 ? "(none)" : string.Join(", ", names.PropertyKeys)); + message.AppendLine(""); + return message.ToString(); + } + + static void List(string tag, IReadOnlyList names, StringBuilder message) + { + message.AppendLine(); + message.AppendLine($"<{tag}>"); + foreach (var name in names.Take(MaxNames)) + message.AppendLine(name.Detail is null ? $"{name.Id}: {name.Name}" : $"{name.Id}: {name.Name} ({name.Detail})"); + if (names.Count == 0) message.AppendLine("(none)"); + if (names.Count > MaxNames) message.AppendLine($"({names.Count - MaxNames} more not listed)"); + message.AppendLine($""); + } + + static readonly string[] Statuses = ["failed", "badResponse", "processing", "success"]; + static readonly string[] Negatable = ["subscription", "partner", "informationType", "property", "error", "retry", "scheduled", "aggregated"]; + static readonly Regex Range = new(@"^(today|yesterday|[1-9]\d{0,3}[mhd])$", RegexOptions.Compiled); + static readonly Regex Bound = new(@"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$", RegexOptions.Compiled); + + /// The model's answer as the page's filter, keeping only what holds. + public static ExchangeQuery Parse(string answer, QueryNames names) + { + var notes = new List(); + var start = answer.IndexOf('{'); + var end = answer.LastIndexOf('}'); + JObject? json = null; + if (start >= 0 && end > start) + { + try + { + json = JObject.Parse(answer[start..(end + 1)]); + } + catch (JsonException) + { + } + } + if (json is null) + return new ExchangeQuery("", "", ["The AI's answer couldn't be read as a filter. Try asking again."]); + + var query = new List<(string Key, string Value)>(); + var set = new HashSet(); + + if (Text(json, "status") is { } status) + { + var known = Statuses.FirstOrDefault(s => s.Equals(status, StringComparison.OrdinalIgnoreCase)); + if (known is null) notes.Add($"Status \"{status}\" isn't one the page has."); + else query.Add(("status", known)); + } + + Ids(json, "subscriptionIds", "subscriptionId", "subscription", names.Subscriptions, query, set, notes); + Ids(json, "partnerIds", "partnerId", "partner", names.Partners, query, set, notes); + Ids(json, "informationTypeIds", "informationTypeId", "informationType", names.InformationTypes, query, set, notes); + + if (Text(json, "correlationId") is { } correlation) query.Add(("correlationId", correlation)); + + foreach (var property in json["properties"] as JArray ?? []) + { + if (property is not JObject p || Text(p, "key") is not { } key) continue; + var known = names.PropertyKeys.FirstOrDefault(k => k.Equals(key, StringComparison.OrdinalIgnoreCase)); + if (known is null) + { + notes.Add($"No information type promotes a property \"{key}\"."); + continue; + } + var text = Text(p, "text"); + query.Add(("prop", $"{(Flag(p, "not") ? "!" : "")}{known}{(text is null ? "" : $":{text}")}")); + } + if (Text(json, "anyProperty") is { } anyProperty) + { + query.Add(("property", anyProperty)); + set.Add("property"); + } + + if (Text(json, "error") is { } error) + { + query.Add(("error", error)); + set.Add("error"); + } + + foreach (var flag in new[] { "retry", "scheduled", "aggregated" }) + if (Flag(json, flag)) + { + query.Add((flag, "1")); + set.Add(flag); + } + if (Flag(json, "latest")) query.Add(("latest", "1")); + + var range = Text(json, "range")?.ToLowerInvariant(); + var from = Text(json, "from"); + var to = Text(json, "to"); + if (range is not null && Range.IsMatch(range)) + { + query.Add(("range", range)); + } + else + { + if (range is not null) notes.Add($"\"{range}\" isn't a time range the page has."); + if (from is not null && Bound.IsMatch(from)) query.Add(("from", from)); + if (to is not null && Bound.IsMatch(to)) query.Add(("to", to)); + } + + // Only filters that are set can be turned round; the page ignores the rest anyway. + var not = (json["not"] as JArray ?? []) + .Select(n => n.Type == JTokenType.String ? Negatable.FirstOrDefault(k => k.Equals((string?)n, StringComparison.OrdinalIgnoreCase)) : null) + .Where(n => n is not null && set.Contains(n)) + .Distinct().Order().ToList(); + if (not.Count > 0) query.Add(("not", string.Join(",", not))); + + if (Text(json, "notUnderstood") is { } notUnderstood) notes.Insert(0, notUnderstood); + + var sentence = Text(json, "answer", MaxAnswer) ?? ""; + return new ExchangeQuery( + string.Join("&", query.Select(q => $"{Uri.EscapeDataString(q.Key)}={Uri.EscapeDataString(q.Value)}")), + sentence, notes); + } + + static void Ids(JObject json, string field, string parameter, string kind, IReadOnlyList known, + List<(string, string)> query, HashSet set, List notes) + { + var ids = new List(); + foreach (var token in json[field] as JArray ?? []) + { + if (token.Type != JTokenType.Integer) continue; + var id = (int)token; + if (known.Any(n => n.Id == id)) ids.Add(id); + else notes.Add($"There is no {Words(kind)} with id {id}."); + } + if (ids.Count == 0) return; + query.Add((parameter, string.Join(",", ids.Distinct()))); + set.Add(kind); + } + + static string Words(string kind) => kind == "informationType" ? "information type" : kind; + + static string? Text(JObject json, string field, int max = MaxText) + { + if (json[field] is not { Type: JTokenType.String } token) return null; + var text = ((string?)token)?.Trim(); + return string.IsNullOrEmpty(text) ? null : text.Length <= max ? text : text[..max]; + } + + static bool Flag(JObject json, string field) => json[field] is { Type: JTokenType.Boolean } token && (bool)token; +} diff --git a/SW.Bitween.HttpTests/AskExchangesTests.cs b/SW.Bitween.HttpTests/AskExchangesTests.cs new file mode 100644 index 00000000..945fbd45 --- /dev/null +++ b/SW.Bitween.HttpTests/AskExchangesTests.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain; +using SW.Bitween.Services.Ai; +using Xunit; + +namespace SW.Bitween.HttpTests; + +/// +/// Ask your exchanges: a question goes to the model with names and the time, never an exchange, +/// and comes back as the Exchanges page's filter, recorded in the audit trail. +/// +[Collection("Http")] +public class AskExchangesTests(HttpFixture fixture) : IDisposable +{ + public void Dispose() + { + AiModels.ModelOverride = null; + using var admin = fixture.AdminAsync().GetAwaiter().GetResult(); + admin.DeleteAsync("/api/settings/Bitween.Ai.ApiKey").GetAwaiter().GetResult(); + } + + sealed class FakeModel(Func answer) : IAiModel + { + public List Asked { get; } = new(); + + public Task Complete(AiPrompt prompt) + { + Asked.Add(prompt); + return Task.FromResult(answer(prompt)); + } + } + + async Task ConfigureAsync(HttpClient admin) + { + await Api.Json(await admin.PostAsJsonAsync("/api/settings/Bitween.Ai.ApiKey", new { value = "sk-test-key" })); + var live = fixture.App.Services.GetService(typeof(BitweenOptions)) as BitweenOptions; + for (var waited = 0; live!.Ai.ApiKey != "sk-test-key"; waited += 50) + { + Assert.True(waited < 10_000, "the AI key never took effect"); + await Task.Delay(50); + } + } + + [Fact] + public async Task A_question_becomes_the_pages_filter_from_names_alone() + { + using var admin = await fixture.AdminAsync(); + var partnerName = Api.Unique("SkyNet"); + var partnerId = await Api.CreateAsync(admin, "/api/partners", new { name = partnerName }); + await ConfigureAsync(admin); + var model = new FakeModel(_ => $$"""{ "status": "failed", "partnerIds": [{{partnerId}}, 2147483000], "range": "7d", "answer": "{count} failed" }"""); + AiModels.ModelOverride = _ => model; + + var result = await Api.Json(await admin.PostAsJsonAsync("/api/xchangequeries", + new { question = "failed skynet this week", timeZone = "Asia/Amman" })); + + Assert.Equal($"status=failed&partnerId={partnerId}&range=7d", (string)result["query"]!); + Assert.Equal("{count} failed", (string)result["answer"]!); + Assert.Contains("There is no partner with id 2147483000.", result["notes"]!.AsArray().Select(n => (string)n!)); + var sent = Assert.Single(model.Asked).Message; + Assert.Equal(sent, (string)result["sent"]!); + Assert.Contains($"{partnerId}: {partnerName}", sent); + Assert.Contains("in Asia/Amman.", sent); + + var audited = await fixture.InDbAsync(db => db.Set().AsNoTracking() + .AnyAsync(a => a.Action == OperatorAction.AskExchanges && a.Detail.EndsWith("failed skynet this week"))); + Assert.True(audited, "the question wasn't recorded"); + } + + [Fact] + public async Task Without_AI_set_up_nothing_is_sent() + { + using var admin = await fixture.AdminAsync(); + + var response = await admin.PostAsJsonAsync("/api/xchangequeries", new { question = "failed today" }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("Settings, AI", await response.Content.ReadAsStringAsync()); + } +} diff --git a/SW.Bitween.UnitTests/Ai/ExchangeQueryPromptTests.cs b/SW.Bitween.UnitTests/Ai/ExchangeQueryPromptTests.cs new file mode 100644 index 00000000..a1f95e96 --- /dev/null +++ b/SW.Bitween.UnitTests/Ai/ExchangeQueryPromptTests.cs @@ -0,0 +1,88 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.Services.Ai; + +namespace SW.Bitween.UnitTests.Ai; + +/// +/// Ask your exchanges: what the model may say becomes the Exchanges page's own filter, and nothing +/// the instance doesn't have gets through. +/// +[TestClass] +public class ExchangeQueryPromptTests +{ + static readonly QueryNames Names = new( + [new QueryName(12, "SkyNet Express")], + [new QueryName(40, "SkyNet orders out", "Internal, partner 12, information type 3")], + [new QueryName(3, "Shipment order", "promotes orderNo")], + ["orderNo"]); + + [TestMethod] + public void A_filter_becomes_the_pages_url_parameters() + { + var query = ExchangeQueryPrompt.Parse(""" + { "status": "failed", "partnerIds": [12], "from": "2026-10-26T00:00", "latest": true, + "properties": [{ "key": "ORDERNO", "text": "1001" }], + "answer": "{count} failed SkyNet exchanges since Monday" } + """, Names); + + Assert.AreEqual("status=failed&partnerId=12&prop=orderNo%3A1001&latest=1&from=2026-10-26T00%3A00", query.Query); + Assert.AreEqual("{count} failed SkyNet exchanges since Monday", query.Answer); + Assert.AreEqual(0, query.Notes.Count); + } + + [TestMethod] + public void What_the_instance_doesnt_have_is_dropped_and_said() + { + var query = ExchangeQueryPrompt.Parse(""" + { "status": "broken", "partnerIds": [12, 99], "properties": [{ "key": "awb" }], "range": "forever" } + """, Names); + + Assert.AreEqual("partnerId=12", query.Query); + CollectionAssert.AreEqual(new[] + { + "Status \"broken\" isn't one the page has.", + "There is no partner with id 99.", + "No information type promotes a property \"awb\".", + "\"forever\" isn't a time range the page has.", + }, new System.Collections.Generic.List(query.Notes)); + } + + [TestMethod] + public void Only_filters_that_are_set_are_turned_round() + { + var query = ExchangeQueryPrompt.Parse(""" + { "partnerIds": [12], "error": "timeout", "not": ["partner", "subscription", "constructor"] } + """, Names); + + Assert.AreEqual("partnerId=12&error=timeout¬=partner", query.Query); + } + + [TestMethod] + public void A_range_wins_over_from_and_to() + { + var query = ExchangeQueryPrompt.Parse("""{ "range": "2H", "from": "2026-10-01T00:00" }""", Names); + + Assert.AreEqual("range=2h", query.Query); + } + + [TestMethod] + public void An_answer_that_isnt_json_is_a_note_not_a_filter() + { + var query = ExchangeQueryPrompt.Parse("Sorry, I can't help with that.", Names); + + Assert.AreEqual("", query.Query); + Assert.AreEqual(1, query.Notes.Count); + } + + [TestMethod] + public void The_message_has_the_names_and_the_time_and_nothing_else() + { + var message = ExchangeQueryPrompt.Message("failed skynet since monday", new DateTime(2026, 10, 27, 14, 5, 0), "Asia/Amman", Names); + + StringAssert.Contains(message, "Now: 2026-10-27 14:05, Tuesday, in Asia/Amman."); + StringAssert.Contains(message, "12: SkyNet Express"); + StringAssert.Contains(message, "40: SkyNet orders out (Internal, partner 12, information type 3)"); + StringAssert.Contains(message, "orderNo"); + } +} diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index 0bbe0eba..01f677e7 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -762,6 +762,16 @@ export interface ApiClient { checkExchangeFilesExport(selection: BulkRetrySelection): Promise; getRetryTree(id: string): Promise; /** Manually injects a payload, addressed at a subscription or an information type. */ + /** + * Turns a plain-English question into the Exchanges page's filter, through the AI provider under + * Settings, AI. Only the question, the time and names (partners, subscriptions, information + * types, promoted-property keys) are sent. `query` is the page's own URL parameters; `answer` + * holds `{count}` where the page's total goes; `sent` is exactly what went to the provider. + */ + askExchanges( + question: string, + signal?: AbortSignal, + ): Promise<{ query: string; answer: string; notes: string[]; model: string | null; sent: string }>; createExchange(input: { target: "subscription" | "informationType"; subscriptionId?: number; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts index 16b82ffc..959f87f0 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts @@ -14,7 +14,7 @@ import type { ScheduledRetryRow, } from "../types"; import { partnerMethods } from "./partners"; -import { download, get, post } from "./request"; +import { download, get, post, request } from "./request"; import { searchyQueryString } from "./searchQuery"; // --- backend shapes (camelCase over the wire) --- @@ -380,6 +380,14 @@ export const exchangeMethods = { return post("/xchanges/export/check", selectionBody(selection)); }, + async askExchanges(question: string, signal?: AbortSignal) { + const res = await request<{ query: string; answer: string; notes?: string[]; model?: string | null; sent: string }>( + "/xchangequeries", + { method: "POST", body: { question, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }, signal }, + ); + return { query: res.query, answer: res.answer, notes: res.notes ?? [], model: res.model ?? null, sent: res.sent }; + }, + async createExchange(input: { target: "subscription" | "informationType"; subscriptionId?: number; diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/AskExchanges.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/AskExchanges.tsx new file mode 100644 index 00000000..3d55c11d --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/AskExchanges.tsx @@ -0,0 +1,221 @@ +import { useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2, Sparkles, X } from "lucide-react"; +import { api, getAppConfig } from "../../api"; +import { keys } from "../../api/queryKeys"; +import { useSessionCan } from "../../auth/useSessionCan"; +import { Button, FormError } from "../../components/ui/basics"; +import { countLabel, viewSearch } from "./exchangeFilters"; + +type Changes = Record; + +/** A question that was turned into the page's filter, kept while that filter is the page's. */ +export interface AskedQuestion { + question: string; + /** The filter it became, as `viewSearch` writes it: while the page shows another, it's gone. */ + search: string; + answer: string; + notes: string[]; + model: string | null; + sent: string; +} + +/** + * Ask your exchanges: a question in plain English becomes the page's own filter - its chips, its + * link, its saved views. Only the question, the time and names go to the AI provider. + */ +export function AskButton({ + searchParams, + apply, + onAsked, +}: { + searchParams: URLSearchParams; + apply: (changes: Changes) => void; + onAsked: (asked: AskedQuestion) => void; +}) { + const config = useQuery({ queryKey: keys.appConfig, queryFn: getAppConfig }); + const canSetUp = useSessionCan("settings.edit"); + const [open, setOpen] = useState(false); + const [question, setQuestion] = useState(""); + const [busy, setBusy] = useState(false); + const [seconds, setSeconds] = useState(0); + const [error, setError] = useState(null); + const controller = useRef(null); + + useEffect(() => () => controller.current?.abort(), []); + useEffect(() => { + if (!busy) return; + setSeconds(0); + const timer = setInterval(() => setSeconds((s) => s + 1), 1000); + return () => clearInterval(timer); + }, [busy]); + + async function ask() { + if (!question.trim() || busy) return; + controller.current = new AbortController(); + setBusy(true); + setError(null); + try { + const res = await api.askExchanges(question.trim(), controller.current.signal); + // The answer replaces every filter, as picking a saved view does; the open row stays. + const target = new URLSearchParams(res.query); + const changes: Changes = {}; + for (const k of new Set([...searchParams.keys(), ...target.keys()])) if (k !== "open") changes[k] = target.getAll(k); + apply(changes); + onAsked({ question: question.trim(), search: viewSearch(target), ...res }); + setOpen(false); + } catch (e) { + if (!controller.current.signal.aborted) setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + const configured = !config.data || config.data.aiConfigured; + const close = () => { + controller.current?.abort(); + setOpen(false); + setError(null); + }; + + // A row of its own under the bar rather than a panel off the button: the button moves with the + // bar's wrapping, and a panel anchored to it ran off the screen's edge on a narrow window. + return ( + <> + + {open && ( +
+ {!configured ? ( +
+

AI isn't set up on this Bitween yet: it needs an AI provider and its API key under Settings, AI.

+ {canSetUp ? ( + + Open Settings, AI in a new tab + + ) : ( +

Ask an administrator to set it up.

+ )} +
+ ) : ( +
{ + e.preventDefault(); + void ask(); + }} + onKeyDown={(e) => e.key === "Escape" && close()} + > + +
+ setQuestion(e.target.value)} + placeholder="Failed SkyNet orders since Monday" + className="h-9 min-w-0 flex-[1_1_16rem] rounded-lg border border-ink-200 px-2.5 text-sm focus:border-ink-400 focus:outline-none" + /> + {busy ? ( + <> + + + Asking… {seconds}s + + + + ) : ( + + )} +
+

+ Only your question, the time and the names of partners, subscriptions, information types and promoted + properties are sent to the AI provider. Never an exchange or its data. +

+ {error && {error}} +
+ )} +
+ )} + + ); +} + +/** The answer above the list, with the page's own count where the AI left `{count}`. */ +export function AskedLine({ + asked, + total, + counting, + onDismiss, +}: { + asked: AskedQuestion; + total: number; + counting: boolean; + onDismiss: () => void; +}) { + const [showSent, setShowSent] = useState(false); + const count = counting ? "…" : countLabel(total); + const answer = asked.answer ? asked.answer.replaceAll("{count}", count) : `${count} exchanges match`; + + return ( +
+
+ +
+

{answer}

+

+ “{asked.question}”{asked.model ? ` · filter written by ${asked.model}, check the chips above` : ""} ·{" "} + +

+ {asked.notes.length > 0 && ( +
    + {asked.notes.map((n) => ( +
  • {n}
  • + ))} +
+ )} + {showSent && ( +
+              {asked.sent}
+            
+ )} +
+ +
+
+ ); +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx index fc35bf49..01cc3cae 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx @@ -21,6 +21,7 @@ import { NONE } from "../../lib/none"; import { RetryDialog } from "./shared"; import { ExchangeList } from "./ExchangeList"; import { ExchangeDetailPanel } from "./ExchangeDetailPanel"; +import { AskedLine, type AskedQuestion } from "./AskExchanges"; import { QueryBar, type FilterOptions } from "./QueryBar"; import { StatusTabs } from "./StatusTabs"; import { VolumeChart } from "./VolumeChart"; @@ -86,6 +87,8 @@ export function ExchangesPage() { /** Mirrors the confirm dialog's own choice, because the plan depends on it. */ const [bulkReset, setBulkReset] = useState(false); const [bulkResult, setBulkResult] = useState(null); + /** The last question asked, shown while its filter is the page's. */ + const [asked, setAsked] = useState(null); const queryClient = useQueryClient(); const apply = useCallback( @@ -471,7 +474,11 @@ export function ExchangesPage() { onToggleLatest={() => apply({ latest: query.latest ? null : "1" })} /> - + + + {asked && asked.search === viewSearch(searchParams) && ( + setAsked(null)} /> + )} {volume && volume.buckets.length > 0 && ( void; options: FilterOptions; lookups: ChipLookups; + /** A question the Ask box turned into the filter. */ + onAsked: (asked: AskedQuestion) => void; }) { const chips = filterChips(searchParams, lookups); @@ -63,6 +67,7 @@ export function QueryBar({ + ); } diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/askExchanges.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/askExchanges.test.tsx new file mode 100644 index 00000000..42deac59 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/askExchanges.test.tsx @@ -0,0 +1,73 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; + +/** + * Ask your exchanges, on the Exchanges page. What is sent and how the answer is checked is the C# + * suite's business (ExchangeQueryPromptTests, AskExchangesTests); this is the box, the filter it + * applies and the answer line. + */ + +const NONE = { result: [], totalCount: 0 }; + +function openPage(aiConfigured: boolean, searched: string[] = []) { + return renderApp("/exchanges", { + config: { aiConfigured }, + handlers: [ + http.get(apiPath("/xchanges"), ({ request }) => { + searched.push(decodeURIComponent(new URL(request.url).search)); + return HttpResponse.json({ result: [], totalCount: 42 }); + }), + http.get(apiPath("/subscriptions"), () => HttpResponse.json(NONE)), + http.get(apiPath("/partners"), () => HttpResponse.json(NONE)), + http.get(apiPath("/documents"), () => HttpResponse.json(NONE)), + http.get(apiPath("/xchanges/summary"), () => + HttpResponse.json({ counts: { processing: 0, success: 0, badResponse: 0, failed: 42 }, buckets: [], bucketMinutes: 0 }), + ), + http.post(apiPath("/xchangequeries"), async ({ request }) => { + const body = (await request.json()) as { question: string; timeZone: string }; + expect(body.question).toBe("failed in the last day"); + expect(body.timeZone).toBeTruthy(); + return HttpResponse.json({ + query: "status=failed&range=24h", + answer: "{count} failed exchanges in the last day", + notes: ["There is no partner with id 99."], + model: "claude-sonnet-5-5", + sent: "Now: 2026-10-27 14:05\n\nfailed in the last day\n", + }); + }), + ], + }); +} + +describe("Ask your exchanges", { timeout: 20000 }, () => { + it("turns a question into the page's filter and answers with the page's own count", async () => { + const searched: string[] = []; + const { user } = openPage(true, searched); + + await user.click(await screen.findByRole("button", { name: "Ask" })); + await user.type(screen.getByLabelText("Ask your exchanges"), "failed in the last day"); + await user.click(screen.getAllByRole("button", { name: "Ask" }).at(-1)!); + + const answer = await screen.findByRole("status", { name: "Answer" }); + await waitFor(() => expect(answer).toHaveTextContent("42 failed exchanges in the last day")); + expect(within(answer).getByText("There is no partner with id 99.")).toBeInTheDocument(); + expect(searched.at(-1)).toContain("StatusFilter:1:3"); + + await user.click(within(answer).getByRole("button", { name: "See what was sent" })); + expect(within(answer).getByLabelText("What was sent to the AI provider")).toHaveTextContent("failed in the last day"); + + // A filter changed by hand is no longer what was asked, so the answer goes. + await user.click(screen.getByRole("button", { name: "Clear all filters" })); + await waitFor(() => expect(screen.queryByRole("status", { name: "Answer" })).not.toBeInTheDocument()); + }); + + it("says where to turn it on until Settings has a provider key", async () => { + const { user } = openPage(false); + + await user.click(await screen.findByRole("button", { name: "Ask" })); + + expect(screen.getByRole("link", { name: "Open Settings, AI in a new tab" })).toHaveAttribute("target", "_blank"); + }); +}); diff --git a/docs/admin-ui.md b/docs/admin-ui.md index 4fdf6e39..7c5a8ce4 100644 --- a/docs/admin-ui.md +++ b/docs/admin-ui.md @@ -12,7 +12,7 @@ The sign-in page offers email and password, Microsoft, an [OpenID Connect provid | Page | What it does | |---|---| -| **Exchanges** | Search and filter every exchange with live refresh, optionally showing only the latest attempt of each retry chain. A filter's operator is picked on its chip: *is* or *is not*, *contains* or *does not contain*; subscription, partner and information type can pick several (*is any of*, *is none of*), and any number of promoted properties can be filtered on at once. Expand a row to see each stage's file, formatted or raw, plus the exception, promoted properties and the retry chain. Retry one exchange, a selection, or every match of the current filters after previewing the plan. Create an exchange by hand. | +| **Exchanges** | Search and filter every exchange with live refresh, optionally showing only the latest attempt of each retry chain. A filter's operator is picked on its chip: *is* or *is not*, *contains* or *does not contain*; subscription, partner and information type can pick several (*is any of*, *is none of*), and any number of promoted properties can be filtered on at once. Expand a row to see each stage's file, formatted or raw, plus the exception, promoted properties and the retry chain. Retry one exchange, a selection, or every match of the current filters after previewing the plan. Create an exchange by hand. **Ask** turns a plain-English question into these filters with AI ([Asking your exchanges](configuration.md#asking-your-exchanges)). | | **Scheduled retries** | Automatic retries waiting to run, with the policy that scheduled each one. Run one now. | | **Queue health** | Live RabbitMQ lanes, backlogs, dead letters, alerts and orphaned queues. | diff --git a/docs/api-reference.md b/docs/api-reference.md index 4f6ea40f..84cb018f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -80,6 +80,7 @@ All need the global values permissions: `global-values.view` to read, `.create`, | Method and path | Permission | Description | |---|---|---| | `GET /api/xchanges` | `exchanges.view` or `dashboard.view`, or signed in with `lookup=true` | Searchy. See the filters below. A lookup returns at most 100 exchange ids, newest first, each with its information type's name. | +| `POST /api/xchangequeries` | `exchanges.view` | Turn a question into an Exchanges page filter with AI: `{ question, timeZone }` returns `{ query, answer, notes, model, sent }`. See [Asking your exchanges](configuration.md#asking-your-exchanges). | | `GET /api/xchanges/statuslist` | `exchanges.view` | Status values for filters | | `GET /api/xchanges/retrytree?id=` | `exchanges.view` | The retry chain any attempt belongs to | | `POST /api/xchanges` | signed in, no permission checked | Create an exchange by hand | diff --git a/docs/configuration.md b/docs/configuration.md index c4b03756..44a216d0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -216,7 +216,7 @@ The **Mail server** section is the one SMTP server every Email notification chan ### AI -The **AI** section is the provider every AI feature calls; the first is [Generate with AI](mapping.md#generate-with-ai) in the mapping editor. It is read on each call, and AI features stay off until an API key is set. +The **AI** section is the provider every AI feature calls: [Generate with AI](mapping.md#generate-with-ai) in the mapping editor, and **Ask** on the Exchanges page. It is read on each call, and AI features stay off until an API key is set. - **Provider** is `Anthropic` or `OpenAI`. Any other value, such as a misspelling in configuration, leaves AI features off. `OpenAI` also covers anything that speaks its chat completions API at **API address**: an Azure OpenAI resource (`https://your-resource.openai.azure.com/openai/v1/`), a local Ollama (`http://localhost:11434/v1/`) or a proxy. - **Model** empty uses `claude-opus-5-5` with Anthropic. OpenAI needs one named, or the Azure deployment's name. @@ -227,6 +227,15 @@ When the provider refuses or fails a call, the message gives its HTTP status, no Sample documents an AI feature sends go to this provider, with the names - never the values - of partner properties and global values. Secret ones are not named. +#### Asking your exchanges + +**Ask** on the Exchanges page turns a question such as "failed SkyNet orders since Monday" into the page's own filter: its chips, its link, its saved views. The answer line above the list counts the result itself. It sends the provider only: + +- the question, the current time and the asker's time zone; +- the ids and names of partners, subscriptions and information types (up to 2,000 of each), and promoted-property keys. + +No exchange, count or property value is sent. Every id and key in the answer is checked against this Bitween before it becomes a filter; anything that doesn't exist is dropped and listed under the answer. **See what was sent** shows exactly what went, and each question is recorded in the audit log as `ask-exchanges`. It needs `exchanges.view`, and the filter runs with the asker's rights like any other. + The page also has a **Notification channels** section. Channels are records rather than settings: they have their own table, each opens on a page of its own, and the `notifiers` permissions cover them rather than `settings`. See [Notification channels](retries-and-alerts.md#notification-channels). `GET /api/retention` and `POST /api/retention/preview` describe what the retention settings do, as saved or as proposed. The Settings page uses them. `POST /api/retention/aggregation` says whether exchanges would be removed before an aggregation on the given schedules rolls them up; the aggregation page uses it. From ba09f088935827c8544f14142de3fc520eebe0e4 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Wed, 28 Oct 2026 10:13:35 +0300 Subject: [PATCH 2/3] fix: record a question before sending it --- SW.Bitween.Api/Resources/XchangeQueries/Ask.cs | 6 +++--- docs/configuration.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/SW.Bitween.Api/Resources/XchangeQueries/Ask.cs b/SW.Bitween.Api/Resources/XchangeQueries/Ask.cs index 0c43e891..835d0818 100644 --- a/SW.Bitween.Api/Resources/XchangeQueries/Ask.cs +++ b/SW.Bitween.Api/Resources/XchangeQueries/Ask.cs @@ -65,11 +65,11 @@ public async Task Handle(XchangeQueryRequest request) var names = await NamesAsync(); var message = ExchangeQueryPrompt.Message(question, now, zone.Id, names); - var answer = await model.Complete(new AiPrompt(ExchangeQueryPrompt.Instructions, message, Json: true, MaxTokens: 2000)); - var query = ExchangeQueryPrompt.Parse(answer, names); - + // Recorded before it goes, so a call that fails or times out after sending is on record too. await dbContext.RecordNowAsync(requestContext, OperatorAction.AskExchanges, "exchanges", $"Asked {models.ModelName}: {question}"); + var answer = await model.Complete(new AiPrompt(ExchangeQueryPrompt.Instructions, message, Json: true, MaxTokens: 2000)); + var query = ExchangeQueryPrompt.Parse(answer, names); return new XchangeQueryResponse { diff --git a/docs/configuration.md b/docs/configuration.md index 44a216d0..20df52c1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -234,7 +234,7 @@ Sample documents an AI feature sends go to this provider, with the names - never - the question, the current time and the asker's time zone; - the ids and names of partners, subscriptions and information types (up to 2,000 of each), and promoted-property keys. -No exchange, count or property value is sent. Every id and key in the answer is checked against this Bitween before it becomes a filter; anything that doesn't exist is dropped and listed under the answer. **See what was sent** shows exactly what went, and each question is recorded in the audit log as `ask-exchanges`. It needs `exchanges.view`, and the filter runs with the asker's rights like any other. +No exchange, count or property value is sent. Every id and key in the answer is checked against this Bitween before it becomes a filter; anything that doesn't exist is dropped and listed under the answer. **See what was sent** shows exactly what went, and each question is recorded in the audit log as `ask-exchanges` before it is sent. It needs `exchanges.view`, and the filter runs with the asker's rights like any other. The page also has a **Notification channels** section. Channels are records rather than settings: they have their own table, each opens on a page of its own, and the `notifiers` permissions cover them rather than `settings`. See [Notification channels](retries-and-alerts.md#notification-channels). From a3cb3b109d039b27b1ebcd30d02b32d092cdd47b Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Wed, 28 Oct 2026 16:18:51 +0300 Subject: [PATCH 3/3] fix: ask exchanges review findings --- .../Resources/XchangeQueries/Ask.cs | 4 ++- .../Services/Ai/ExchangeQueryPrompt.cs | 11 +++--- .../Ai/ExchangeQueryPromptTests.cs | 22 +++++++++++- SW.Bitween.Web/ClientApp/src/api/client.ts | 2 +- .../src/pages/exchanges/AskExchanges.tsx | 18 ++++++---- .../src/pages/exchanges/QueryBar.tsx | 2 +- .../exchanges/__tests__/askExchanges.test.tsx | 34 ++++++++++++++----- 7 files changed, 69 insertions(+), 24 deletions(-) diff --git a/SW.Bitween.Api/Resources/XchangeQueries/Ask.cs b/SW.Bitween.Api/Resources/XchangeQueries/Ask.cs index 835d0818..4839d119 100644 --- a/SW.Bitween.Api/Resources/XchangeQueries/Ask.cs +++ b/SW.Bitween.Api/Resources/XchangeQueries/Ask.cs @@ -96,7 +96,9 @@ async Task NamesAsync() .ToList(); var documents = await dbContext.Set().AsNoTracking() - .OrderBy(d => d.Name).Take(ExchangeQueryPrompt.MaxNames + 1).ToListAsync(); + .OrderBy(d => d.Name).Take(ExchangeQueryPrompt.MaxNames + 1) + .Select(d => new { d.Id, d.Name, d.PromotedProperties }) + .ToListAsync(); var informationTypes = documents .Select(d => new QueryName(d.Id, d.Name, d.PromotedProperties is { Count: > 0 } promoted ? "promotes " + string.Join(", ", promoted.Keys) diff --git a/SW.Bitween.Api/Services/Ai/ExchangeQueryPrompt.cs b/SW.Bitween.Api/Services/Ai/ExchangeQueryPrompt.cs index 0267a22a..69f76896 100644 --- a/SW.Bitween.Api/Services/Ai/ExchangeQueryPrompt.cs +++ b/SW.Bitween.Api/Services/Ai/ExchangeQueryPrompt.cs @@ -1,6 +1,7 @@ #nullable enable using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text; using System.Text.RegularExpressions; @@ -106,7 +107,8 @@ Answer with one JSON object and nothing else. Leave out or set to null anything public static string Message(string question, DateTime now, string timeZone, QueryNames names) { var message = new StringBuilder(); - message.AppendLine($"Now: {now:yyyy-MM-dd HH:mm}, {now:dddd}, in {timeZone}."); + // Fixed wording whatever the server's culture: an Arabic one would change the year and the weekday. + message.AppendLine(string.Create(CultureInfo.InvariantCulture, $"Now: {now:yyyy-MM-dd HH:mm}, {now:dddd}, in {timeZone}.")); message.AppendLine(); message.AppendLine(""); message.AppendLine(question.Trim()); @@ -241,9 +243,10 @@ static void Ids(JObject json, string field, string parameter, string kind, IRead foreach (var token in json[field] as JArray ?? []) { if (token.Type != JTokenType.Integer) continue; - var id = (int)token; - if (known.Any(n => n.Id == id)) ids.Add(id); - else notes.Add($"There is no {Words(kind)} with id {id}."); + // As text first: a number too big for an id is just an id that doesn't exist. + var text = token.ToString(Formatting.None); + if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id) && known.Any(n => n.Id == id)) ids.Add(id); + else notes.Add($"There is no {Words(kind)} with id {text}."); } if (ids.Count == 0) return; query.Add((parameter, string.Join(",", ids.Distinct()))); diff --git a/SW.Bitween.UnitTests/Ai/ExchangeQueryPromptTests.cs b/SW.Bitween.UnitTests/Ai/ExchangeQueryPromptTests.cs index a1f95e96..d0dfa18e 100644 --- a/SW.Bitween.UnitTests/Ai/ExchangeQueryPromptTests.cs +++ b/SW.Bitween.UnitTests/Ai/ExchangeQueryPromptTests.cs @@ -48,6 +48,15 @@ public void What_the_instance_doesnt_have_is_dropped_and_said() }, new System.Collections.Generic.List(query.Notes)); } + [TestMethod] + public void An_id_too_big_for_one_is_dropped_not_thrown() + { + var query = ExchangeQueryPrompt.Parse("""{ "partnerIds": [99999999999, 12] }""", Names); + + Assert.AreEqual("partnerId=12", query.Query); + CollectionAssert.AreEqual(new[] { "There is no partner with id 99999999999." }, new System.Collections.Generic.List(query.Notes)); + } + [TestMethod] public void Only_filters_that_are_set_are_turned_round() { @@ -78,7 +87,18 @@ public void An_answer_that_isnt_json_is_a_note_not_a_filter() [TestMethod] public void The_message_has_the_names_and_the_time_and_nothing_else() { - var message = ExchangeQueryPrompt.Message("failed skynet since monday", new DateTime(2026, 10, 27, 14, 5, 0), "Asia/Amman", Names); + var culture = System.Globalization.CultureInfo.CurrentCulture; + string message; + try + { + // A Hijri calendar and Arabic day names must not reach the model. + System.Globalization.CultureInfo.CurrentCulture = new System.Globalization.CultureInfo("ar-SA"); + message = ExchangeQueryPrompt.Message("failed skynet since monday", new DateTime(2026, 10, 27, 14, 5, 0), "Asia/Amman", Names); + } + finally + { + System.Globalization.CultureInfo.CurrentCulture = culture; + } StringAssert.Contains(message, "Now: 2026-10-27 14:05, Tuesday, in Asia/Amman."); StringAssert.Contains(message, "12: SkyNet Express"); diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index 127abb05..788c7b22 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -771,7 +771,6 @@ export interface ApiClient { /** How many of the selection's exchanges the export would come without files for. Changes nothing. */ checkExchangeFilesExport(selection: BulkRetrySelection): Promise; getRetryTree(id: string): Promise; - /** Manually injects a payload, addressed at a subscription or an information type. */ /** * Turns a plain-English question into the Exchanges page's filter, through the AI provider under * Settings, AI. Only the question, the time and names (partners, subscriptions, information @@ -782,6 +781,7 @@ export interface ApiClient { question: string, signal?: AbortSignal, ): Promise<{ query: string; answer: string; notes: string[]; model: string | null; sent: string }>; + /** Manually injects a payload, addressed at a subscription or an information type. */ createExchange(input: { target: "subscription" | "informationType"; subscriptionId?: number; diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/AskExchanges.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/AskExchanges.tsx index 3d55c11d..798b6605 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/AskExchanges.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/AskExchanges.tsx @@ -5,7 +5,7 @@ import { api, getAppConfig } from "../../api"; import { keys } from "../../api/queryKeys"; import { useSessionCan } from "../../auth/useSessionCan"; import { Button, FormError } from "../../components/ui/basics"; -import { countLabel, viewSearch } from "./exchangeFilters"; +import { FILTER_KEYS, countLabel, viewSearch } from "./exchangeFilters"; type Changes = Record; @@ -25,11 +25,9 @@ export interface AskedQuestion { * link, its saved views. Only the question, the time and names go to the AI provider. */ export function AskButton({ - searchParams, apply, onAsked, }: { - searchParams: URLSearchParams; apply: (changes: Changes) => void; onAsked: (asked: AskedQuestion) => void; }) { @@ -57,10 +55,16 @@ export function AskButton({ setError(null); try { const res = await api.askExchanges(question.trim(), controller.current.signal); - // The answer replaces every filter, as picking a saved view does; the open row stays. + // Nothing usable came back: the page keeps its filters rather than showing everything. + if (!res.query && res.notes.length > 0) { + setError(res.notes.join(" ")); + return; + } + // The answer replaces every filter, as picking a saved view does, including one changed + // while it was asked; the open row stays. const target = new URLSearchParams(res.query); const changes: Changes = {}; - for (const k of new Set([...searchParams.keys(), ...target.keys()])) if (k !== "open") changes[k] = target.getAll(k); + for (const k of new Set([...FILTER_KEYS, ...target.keys()])) if (k !== "open") changes[k] = target.getAll(k); apply(changes); onAsked({ question: question.trim(), search: viewSearch(target), ...res }); setOpen(false); @@ -192,8 +196,8 @@ export function AskedLine({

{asked.notes.length > 0 && (
    - {asked.notes.map((n) => ( -
  • {n}
  • + {asked.notes.map((n, i) => ( +
  • {n}
  • ))}
)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/QueryBar.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/QueryBar.tsx index bfe085b2..c2a1a37b 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/QueryBar.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/QueryBar.tsx @@ -67,7 +67,7 @@ export function QueryBar({ - + ); } diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/askExchanges.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/askExchanges.test.tsx index 42deac59..1f116411 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/askExchanges.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/askExchanges.test.tsx @@ -11,8 +11,16 @@ import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; const NONE = { result: [], totalCount: 0 }; -function openPage(aiConfigured: boolean, searched: string[] = []) { - return renderApp("/exchanges", { +const ANSWER = { + query: "status=failed&range=24h", + answer: "{count} failed exchanges in the last day", + notes: ["There is no partner with id 99."], + model: "claude-sonnet-5-5", + sent: "Now: 2026-10-27 14:05\n\nfailed in the last day\n", +}; + +function openPage(aiConfigured: boolean, searched: string[] = [], answered = ANSWER, path = "/exchanges") { + return renderApp(path, { config: { aiConfigured }, handlers: [ http.get(apiPath("/xchanges"), ({ request }) => { @@ -29,13 +37,7 @@ function openPage(aiConfigured: boolean, searched: string[] = []) { const body = (await request.json()) as { question: string; timeZone: string }; expect(body.question).toBe("failed in the last day"); expect(body.timeZone).toBeTruthy(); - return HttpResponse.json({ - query: "status=failed&range=24h", - answer: "{count} failed exchanges in the last day", - notes: ["There is no partner with id 99."], - model: "claude-sonnet-5-5", - sent: "Now: 2026-10-27 14:05\n\nfailed in the last day\n", - }); + return HttpResponse.json(answered); }), ], }); @@ -63,6 +65,20 @@ describe("Ask your exchanges", { timeout: 20000 }, () => { await waitFor(() => expect(screen.queryByRole("status", { name: "Answer" })).not.toBeInTheDocument()); }); + it("keeps the page's filters when nothing usable came back", async () => { + const unusable = { ...ANSWER, query: "", answer: "", notes: ["There is no partner with id 99."] }; + const { user, router } = openPage(true, [], unusable, "/exchanges?status=success"); + + await user.click(await screen.findByRole("button", { name: "Ask" })); + await user.type(screen.getByLabelText("Ask your exchanges"), "failed in the last day"); + await user.click(screen.getAllByRole("button", { name: "Ask" }).at(-1)!); + + expect(await screen.findByText("There is no partner with id 99.")).toBeVisible(); + expect(screen.getByLabelText("Ask your exchanges")).toBeVisible(); + expect(screen.queryByRole("status", { name: "Answer" })).not.toBeInTheDocument(); + expect(router.state.location.search).toBe("?status=success"); + }); + it("says where to turn it on until Settings has a provider key", async () => { const { user } = openPage(false);