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($"{tag}>");
+ }
+
+ 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.
+ )}
+ >
+ );
+}
+
+/** 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);