Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions SW.Bitween.Api/Domain/OperatorAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/// <summary>A failed exchange sent, masked, to the AI provider under Settings, AI for an explanation.</summary>
public const string ExplainFailure = "explain-failure";
Expand Down
117 changes: 117 additions & 0 deletions SW.Bitween.Api/Resources/XchangeQueries/Ask.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#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; } = "";

/// <summary>The asker's time zone, as the browser names it: "Asia/Amman". UTC when missing or unknown.</summary>
public string? TimeZone { get; set; }
}

public class XchangeQueryResponse
{
/// <summary>The Exchanges page's URL parameters: <c>status=failed&amp;partnerId=12&amp;range=7d</c>.</summary>
public string Query { get; set; } = "";

/// <summary>A sentence for above the result, with <c>{count}</c> where the page's total goes.</summary>
public string Answer { get; set; } = "";

/// <summary>What the filter couldn't say.</summary>
public List<string> Notes { get; set; } = new();

public string? Model { get; set; }

/// <summary>Exactly what went to the provider.</summary>
public string Sent { get; set; } = "";
}

/// <summary>
/// Turns a plain-English question about exchanges into the Exchanges page's filter.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public class Ask(RequestContext requestContext, BitweenDbContext dbContext, AiModels models)
: ICommandHandler<XchangeQueryRequest, XchangeQueryResponse>
{
public async Task<XchangeQueryResponse> 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);

// 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
{
Query = query.Query,
Answer = query.Answer,
Notes = query.Notes.ToList(),
Model = models.ModelName,
Sent = message,
};
}

async Task<QueryNames> NamesAsync()
{
var partners = await dbContext.Set<Partner>().AsNoTracking()
.OrderBy(p => p.Name).Select(p => new QueryName(p.Id, p.Name, null))
.Take(ExchangeQueryPrompt.MaxNames + 1).ToListAsync();

var subscriptions = (await dbContext.Set<Subscription>().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<Document>().AsNoTracking()
.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)
: null))
.ToList();
var keys = documents.SelectMany(d => d.PromotedProperties?.Keys ?? Enumerable.Empty<string>())
.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;
}
266 changes: 266 additions & 0 deletions SW.Bitween.Api/Services/Ai/ExchangeQueryPrompt.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace SW.Bitween.Services.Ai;

/// <summary>A partner, subscription or information type, as the model may see it: an id and a name.</summary>
public record QueryName(int Id, string Name, string? Detail = null);

/// <summary>Everything a question may name, and the only things a filter may hold.</summary>
public record QueryNames(
IReadOnlyList<QueryName> Partners,
IReadOnlyList<QueryName> Subscriptions,
IReadOnlyList<QueryName> InformationTypes,
IReadOnlyList<string> PropertyKeys);

/// <summary>A question turned into an Exchanges page filter.</summary>
/// <param name="Query">The page's own URL parameters, so the filter is the page's chips and its link.</param>
/// <param name="Answer">A sentence for the page to show, with <c>{count}</c> where the page's total goes.</param>
/// <param name="Notes">What the filter couldn't say: parts of the question, or names that don't exist.</param>
public record ExchangeQuery(string Query, string Answer, IReadOnlyList<string> Notes);

/// <summary>
/// Turns a plain-English question about exchanges into the Exchanges page's filter.
/// </summary>
/// <remarks>
/// <para>
/// 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 <c>{count}</c>.
/// </para>
/// <para>
/// The answer is a filter in the model's own small shape rather than a URL, and every part of it is
/// checked against <see cref="QueryNames"/> 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.
/// </para>
/// </remarks>
public static class ExchangeQueryPrompt
{
/// <summary>How many of each kind of name are sent; the rest are left out, alphabetically last.</summary>
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" | "<number>m" | "<number>h" | "<number>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".
""";

/// <summary>The message for one question.</summary>
public static string Message(string question, DateTime now, string timeZone, QueryNames names)
{
var message = new StringBuilder();
// 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("<question>");
message.AppendLine(question.Trim());
message.AppendLine("</question>");
List("partners", names.Partners, message);
List("subscriptions", names.Subscriptions, message);
List("information_types", names.InformationTypes, message);
message.AppendLine();
message.AppendLine("<promoted_property_keys>");
message.AppendLine(names.PropertyKeys.Count == 0 ? "(none)" : string.Join(", ", names.PropertyKeys));
message.AppendLine("</promoted_property_keys>");
return message.ToString();
}

static void List(string tag, IReadOnlyList<QueryName> 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);

/// <summary>The model's answer as the page's filter, keeping only what <paramref name="names"/> holds.</summary>
public static ExchangeQuery Parse(string answer, QueryNames names)
{
var notes = new List<string>();
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<string>();

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<QueryName> known,
List<(string, string)> query, HashSet<string> set, List<string> notes)
{
var ids = new List<int>();
foreach (var token in json[field] as JArray ?? [])
{
if (token.Type != JTokenType.Integer) continue;
// 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())));
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;
}
Loading
Loading