diff --git a/SW.Bitween.Api/Resources/MappingPreviews/Preview.cs b/SW.Bitween.Api/Resources/MappingPreviews/Preview.cs index 3d07ccf1..659e972b 100644 --- a/SW.Bitween.Api/Resources/MappingPreviews/Preview.cs +++ b/SW.Bitween.Api/Resources/MappingPreviews/Preview.cs @@ -89,22 +89,12 @@ await requestContext.EnsurePermission(dbContext, $"Bitween understands up to version {MappingRules.CurrentVersion}.", }; - if (!DocumentFormats.TryGet(rules.SourceFormat, out var source, rules.SourceOptions())) + if (SourceFormat(rules, request.SourceDocument) is not { } source) return new MappingPreviewResponse { Error = DocumentFormats.Unsupported(rules.SourceFormat, "source") }; - if (!DocumentFormats.TryGet(rules.TargetFormat, out var target, rules.TargetOptions())) + if (TargetFormat(rules) is not { } target) return new MappingPreviewResponse { Error = DocumentFormats.Unsupported(rules.TargetFormat, "target") }; - // A workbook can't be pasted into the editor, so its sample is cells copied from Excel, - // which arrive as tab-separated text. Read that way when it isn't a workbook in base64; - // the exchange pipeline only ever reads the workbook itself. - if (source is ExcelFormat && !LooksLikeBase64(request.SourceDocument)) - source = new CsvFormat(new CsvOptions { Delimiter = "\t", HasHeader = rules.SourceExcel?.HasHeader != false }); - - // And a workbook can't be shown, so the preview shows its rows the same way. - if (target is ExcelFormat) - target = new CsvFormat(new CsvOptions { Delimiter = "\t", HasHeader = rules.TargetExcel?.HasHeader != false }); - ValueNode input; try { @@ -146,6 +136,30 @@ await requestContext.EnsurePermission(dbContext, } } + /// What reads an editor's source sample, or null for a format no reader answers to. + internal static IDocumentFormat? SourceFormat(MappingRules rules, string? sample) + { + if (!DocumentFormats.TryGet(rules.SourceFormat, out var source, rules.SourceOptions())) return null; + + // A workbook can't be pasted into the editor, so its sample is cells copied from Excel, + // which arrive as tab-separated text. Read that way when it isn't a workbook in base64; + // the exchange pipeline only ever reads the workbook itself. + return source is ExcelFormat && !LooksLikeBase64(sample ?? "") + ? new CsvFormat(new CsvOptions { Delimiter = "\t", HasHeader = rules.SourceExcel?.HasHeader != false }) + : source; + } + + /// What writes the preview's output, and reads an editor's output sample. + internal static IDocumentFormat? TargetFormat(MappingRules rules) + { + if (!DocumentFormats.TryGet(rules.TargetFormat, out var target, rules.TargetOptions())) return null; + + // And a workbook can't be shown, so the preview shows its rows the same way. + return target is ExcelFormat + ? new CsvFormat(new CsvOptions { Delimiter = "\t", HasHeader = rules.TargetExcel?.HasHeader != false }) + : target; + } + static bool LooksLikeBase64(string text) { var trimmed = (text ?? "").Trim(); diff --git a/SW.Bitween.Api/Resources/MappingSuggestions/Suggest.cs b/SW.Bitween.Api/Resources/MappingSuggestions/Suggest.cs new file mode 100644 index 00000000..6511d7f9 --- /dev/null +++ b/SW.Bitween.Api/Resources/MappingSuggestions/Suggest.cs @@ -0,0 +1,240 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using SW.Bitween.NativeAdapters.Mapper; +using SW.Bitween.NativeAdapters.Mapper.Formats; +using SW.Bitween.Resources.MappingPreviews; +using SW.Bitween.Services.Ai; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.MappingSuggestions; + +public class MappingSuggestionRequest +{ + /// + /// The rules in the editor, serialised. Their formats are always kept; with + /// they are also what gets fixed. + /// + public string MappingRules { get; set; } = ""; + + public string SourceDocument { get; set; } = ""; + + /// What should become. + public string TargetDocument { get; set; } = ""; + + /// Whose partner values the check maps against, as for a preview. + public int? PartnerId { get; set; } + + /// Fix the editor's rules where they miss the output sample, rather than write new ones. + public bool Refine { get; set; } + + /// + /// Notes for the model from the person building the mapping, for what the samples don't show - + /// codes a lookup needs that the sample doesn't happen to contain, say. + /// + public string? Instructions { get; set; } +} + +public class MappingSuggestionResponse +{ + /// The rules, serialised the way the editor stores them. + public string MappingRules { get; set; } = ""; + + /// The source sample mapped with these rules - a preview, run before answering. + public string? OutputDocument { get; set; } + + public string? ContentType { get; set; } + + public List RuleErrors { get; set; } = new(); + + /// Set when the rules could not be run against the sample at all. + public string? Error { get; set; } + + /// Where the output differs from the output sample, at most . + public List Differences { get; set; } = new(); + + /// Whether the rules turn the source sample into the output sample exactly. + public bool MatchesTarget { get; set; } + + /// The model that wrote the rules, so the editor can say which. + public string? Model { get; set; } +} + +/// +/// Asks the model Settings, AI names to write mapping rules from a source sample and an output +/// sample, and checks them before answering. +/// +/// +/// +/// The check is a preview - the same read, map and write the pipeline runs - and a comparison with +/// the output sample, so the editor can say whether the rules do what was asked instead of the +/// person having to find out. +/// +/// +/// One model call per request, and a second only when the first answer isn't readable rules at all. +/// Fixing differences is the editor's next request, with : +/// a model thinking about a large document can take most of a minute, and two of those in one +/// request would outlast the proxies in front of many deployments. +/// +/// +public class Suggest(RequestContext requestContext, BitweenDbContext dbContext, + MappingContextFactory contextFactory, AiModels models) + : ICommandHandler +{ + /// + /// Four times the 300-line sample generation was tested with, which answered in two seconds: + /// room for a real document, without a pasted export becoming a very large and slow prompt. + /// + internal const int MaxSampleLength = 200_000; + + internal const int MaxInstructionsLength = 4_000; + + public async Task Handle(MappingSuggestionRequest request) + { + // The same right as the preview it runs: the editor opens while creating and while editing. + await requestContext.EnsurePermission(dbContext, + Model.Permissions.Subscriptions.Create, Model.Permissions.Subscriptions.Edit); + + // Before anything else costs time or tokens: whatever is sent here goes to a paid provider. + if (request.SourceDocument?.Length > MaxSampleLength || request.TargetDocument?.Length > MaxSampleLength) + throw new SWValidationException("AI_SAMPLES", + $"Samples are limited to {MaxSampleLength / 1000:N0} KB each. A few records are enough to learn a mapping from."); + if (request.Instructions?.Length > MaxInstructionsLength) + throw new SWValidationException("AI_SAMPLES", $"Instructions are limited to {MaxInstructionsLength:N0} characters."); + + var model = models.Current(); + + MappingRules editor; + try + { + editor = JsonConvert.DeserializeObject(request.MappingRules ?? "") ?? new MappingRules(); + } + catch (JsonException ex) + { + throw new SWValidationException("MAPPING_RULES", $"The mapping rules could not be read: {ex.Message}"); + } + if (MappingRulesPrompt.NullIn(editor) is { } missing) + throw new SWValidationException("MAPPING_RULES", $"The mapping rules could not be read: {missing}"); + + if (string.IsNullOrWhiteSpace(request.SourceDocument)) + throw new SWValidationException("AI_SAMPLES", "Paste a sample of the source document first."); + if (string.IsNullOrWhiteSpace(request.TargetDocument)) + throw new SWValidationException("AI_SAMPLES", "Paste a sample of the output document first."); + + // Both samples are read before the model is asked anything: a sample that isn't valid in + // its format would only come back as a failed check, after paying for a call. + var source = Preview.SourceFormat(editor, request.SourceDocument) + ?? throw new SWValidationException("MAPPING_RULES", DocumentFormats.Unsupported(editor.SourceFormat, "source")); + var target = Preview.TargetFormat(editor) + ?? throw new SWValidationException("MAPPING_RULES", DocumentFormats.Unsupported(editor.TargetFormat, "target")); + var input = Read(source, request.SourceDocument, "source"); + var expected = Read(target, request.TargetDocument, "output"); + + (MappingRules, IReadOnlyList)? previous = null; + if (request.Refine) + { + var tried = await Check(editor, request, input, target, expected); + if (tried.MatchesTarget) return tried; + previous = (editor, Problems(tried)); + } + + // The same masked context the preview maps against, so a name offered here is one the + // check can resolve. Only its names leave the server. + var names = MappingRulesPrompt.NamesOf(await contextFactory.Build(request.PartnerId, maskSecrets: true)); + var message = MappingRulesPrompt.Message(editor, request.SourceDocument, request.TargetDocument, + names, request.Instructions, previous); + MappingRules rules; + try + { + rules = MappingRulesPrompt.Parse(await Ask(model, message), editor); + } + catch (FormatException first) + { + // Once more, told why: what went wrong is something a model can act on. + var again = $"{message}\nYour previous answer could not be used: {first.Message}\n" + + "Answer again with only the rules JSON."; + try + { + rules = MappingRulesPrompt.Parse(await Ask(model, again), editor); + } + catch (FormatException second) + { + throw new SWValidationException("AI_UNUSABLE_ANSWER", + $"The model's answer wasn't usable mapping rules ({second.Message}). Try again."); + } + } + + var checkedRules = await Check(rules, request, input, target, expected); + checkedRules.Model = models.ModelName; + return checkedRules; + } + + static Task Ask(IAiModel model, string message) => + model.Complete(new AiPrompt(MappingRulesPrompt.Instructions, message, Json: true)); + + static ValueNode Read(IDocumentFormat format, string sample, string which) + { + try + { + return format.Read(sample); + } + catch (DocumentFormatException ex) + { + throw new SWValidationException("AI_SAMPLES", $"The {which} sample isn't valid {format.Id}: {ex.Message}"); + } + } + + /// Previews the rules against the source sample and compares the result with the output sample. + async Task Check(MappingRules rules, MappingSuggestionRequest request, + ValueNode input, IDocumentFormat target, ValueNode expected) + { + var serialized = MappingRulesPrompt.Serialize(rules); + var preview = await new Preview(requestContext, dbContext, contextFactory).Handle(new MappingPreviewRequest + { + MappingRules = serialized, + SourceDocument = request.SourceDocument, + PartnerId = request.PartnerId, + }); + + var response = new MappingSuggestionResponse + { + MappingRules = serialized, + OutputDocument = preview.OutputDocument, + ContentType = preview.ContentType, + RuleErrors = preview.RuleErrors, + Error = preview.Error, + }; + + // Read back with the format that wrote it, so the comparison is between two trees rather + // than two texts that differ only in whitespace or key order. + if (preview.OutputDocument is not null) + { + try + { + response.Differences = DocumentDifferences.Between(expected, target.Read(preview.OutputDocument)); + } + catch (DocumentFormatException ex) + { + response.Differences.Add(new("(root)", $"the output could not be read back as {target.Id}: {ex.Message}")); + } + } + + // A value frozen from the source matches this sample and no other, which the comparison + // can't see: it is a difference all the same. + response.Differences.AddRange(FrozenValues.In(rules, input)); + + response.MatchesTarget = preview.OutputDocument is not null && preview.Error is null + && preview.RuleErrors.Count == 0 && response.Differences.Count == 0; + return response; + } + + static List Problems(MappingSuggestionResponse tried) => + [ + .. tried.Error is null ? [] : new[] { tried.Error }, + .. tried.RuleErrors.Select(e => $"The rule for {e.Target} failed: {e.Reason}"), + .. tried.Differences.Select(d => d.ToString()), + ]; +} diff --git a/SW.Bitween.Api/Resources/Settings/Config.cs b/SW.Bitween.Api/Resources/Settings/Config.cs index b951ed99..ff6a66e2 100644 --- a/SW.Bitween.Api/Resources/Settings/Config.cs +++ b/SW.Bitween.Api/Resources/Settings/Config.cs @@ -49,6 +49,8 @@ public async Task Handle() // sign-in page "Forgot password?", only when it can. MailServerConfigured = BitweenOptions.MailServer.IsSet, PasswordResetAvailable = Services.AccountMail.ResetAvailableFor(BitweenOptions), + // Whether AI features are offered: the mapping editor's Generate with AI, so far. + AiConfigured = BitweenOptions.Ai.IsSet, IsRabbitMqManagementConfigured = !string.IsNullOrWhiteSpace(BitweenOptions.RabbitMqManagementUrl) && !string.IsNullOrWhiteSpace(BitweenOptions.RabbitMqManagementUsername) && !string.IsNullOrWhiteSpace(BitweenOptions.RabbitMqManagementPassword), diff --git a/SW.Bitween.Api/Resources/Settings/Get.cs b/SW.Bitween.Api/Resources/Settings/Get.cs index fa860a7a..cf8eb422 100644 --- a/SW.Bitween.Api/Resources/Settings/Get.cs +++ b/SW.Bitween.Api/Resources/Settings/Get.cs @@ -49,6 +49,7 @@ public async Task Handle() DefaultValue = definition.Secret ? string.Empty : productDefault, Value = value, Secret = definition.Secret, + Choices = definition.Choices?.ToArray(), Overridden = definition.Secret ? secretIsSet : hasRow && stored != productDefault, HasValue = definition.Secret ? secretIsSet diff --git a/SW.Bitween.Api/SW.Bitween.Api.csproj b/SW.Bitween.Api/SW.Bitween.Api.csproj index ae08b10c..46087794 100644 --- a/SW.Bitween.Api/SW.Bitween.Api.csproj +++ b/SW.Bitween.Api/SW.Bitween.Api.csproj @@ -24,6 +24,9 @@ + + + diff --git a/SW.Bitween.Api/Services/Ai/AiModels.cs b/SW.Bitween.Api/Services/Ai/AiModels.cs new file mode 100644 index 00000000..83007d00 --- /dev/null +++ b/SW.Bitween.Api/Services/Ai/AiModels.cs @@ -0,0 +1,103 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Services.Ai; + +/// One request to a model: standing instructions, one message, and room for the answer. +/// The same on every call of a feature, so a provider can cache it. +/// What this call is about. +/// Whether the answer must be a single JSON object, for providers that can enforce it. +public record AiPrompt(string Instructions, string Message, bool Json = false, int MaxTokens = 16000); + +/// A configured model, as an AI feature sees it: text in, text out. +public interface IAiModel +{ + /// The model's answer. + /// When the provider can't be reached, refuses, or cuts the answer off. + Task Complete(AiPrompt prompt); +} + +/// +/// The model Settings, AI names, built fresh for each call so a changed key or model applies at once. +/// +/// +/// Each provider gets its own official SDK behind , rather than one SDK +/// pretending to be the other: their requests differ in ways a shim hides - Anthropic caches the +/// instructions, OpenAI enforces a JSON answer. +/// +public class AiModels(BitweenOptions options) +{ + // One client per address policy for the process, recycling its connections, as RegistryClient + // does. The SDKs set their own per-request timeout, so this one never cuts them short. + // + // Connections go through the adapters' address guard: the API address is an administrator's + // setting, and an AI call carries the key and the samples, so it gets no more reach than an + // HTTP adapter has - never link-local or cloud metadata, and not private or loopback either + // while Bitween:BlockPrivateNetworkAddresses is on. + static readonly Lazy Open = new(() => Guarded(blockPrivate: false)); + static readonly Lazy Closed = new(() => Guarded(blockPrivate: true)); + + static HttpClient Guarded(bool blockPrivate) => new(new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + ConnectCallback = SW.Bitween.NativeAdapters.OutboundAddressGuard.ConnectCallback(blockPrivate), + }) + { + Timeout = System.Threading.Timeout.InfiniteTimeSpan, + }; + + /// How long one call may take. Long enough for a model to think about a large document. + internal static readonly TimeSpan CallTimeout = TimeSpan.FromMinutes(3); + + /// For tests: answers in place of the network, as RegistryClient.HandlerOverride does. + internal static Func HandlerOverride { get; set; } + + /// For tests: a model in place of either provider, for features tested without HTTP. + internal static Func ModelOverride { get; set; } + + HttpClient Client => HandlerOverride is { } handler + ? new HttpClient(handler()) { Timeout = System.Threading.Timeout.InfiniteTimeSpan } + : options.BlockPrivateNetworkAddresses ? Closed.Value : Open.Value; + + public bool Configured => options.Ai.IsSet; + + /// The model calls, as Settings names it. + public string ModelName => options.Ai.ResolvedModel; + + /// AI_NOT_CONFIGURED until Settings, AI has a key. + public IAiModel Current() + { + var ai = options.Ai; + if (!ai.IsSet) + throw new SWValidationException("AI_NOT_CONFIGURED", + string.IsNullOrWhiteSpace(ai.ApiKey) + ? "AI features are off until an API key is set under Settings, AI." + : "AI features are off until a model is named under Settings, AI."); + + // Checked here as well as when the setting is saved: configuration binds it without the check. + if (AiOptions.AddressProblem(ai.BaseUrl) is { } problem) + throw new SWValidationException("AI_NOT_CONFIGURED", $"The API address under Settings, AI can't be used: {problem}"); + + if (ModelOverride is { } model) return model(ai); + + var baseUrl = string.IsNullOrWhiteSpace(ai.BaseUrl) ? null : ai.BaseUrl.Trim(); + return ai.IsAnthropic + ? new AnthropicModel(ai.ApiKey.Trim(), ai.ResolvedModel, baseUrl, Client) + : new OpenAiModel(ai.ApiKey.Trim(), ai.ResolvedModel, baseUrl, Client); + } + + // Fixed wording rather than the SDK's exception message: that can carry whatever the far end + // answered, and the far end is an address an administrator typed in. + internal static SWValidationException Unreachable(string provider, string detail) => + new("AI_UNREACHABLE", $"{provider} can't be reached right now: {detail}"); + + internal static SWValidationException Refused(string provider, string detail) => + new("AI_PROVIDER_ERROR", $"{provider} refused the request: {detail}"); + + internal const string NoConnection = + "the connection failed or timed out, or the address isn't one Bitween may connect to."; + + internal static string Answered(int status) => $"it answered HTTP {status}."; +} diff --git a/SW.Bitween.Api/Services/Ai/AiOptions.cs b/SW.Bitween.Api/Services/Ai/AiOptions.cs new file mode 100644 index 00000000..2e55af77 --- /dev/null +++ b/SW.Bitween.Api/Services/Ai/AiOptions.cs @@ -0,0 +1,61 @@ +using System; + +namespace SW.Bitween.Services.Ai; + +/// +/// The AI provider every AI feature calls - generating mapping rules first, more to follow. One +/// instance for the life of the app: Settings changes its properties in place, and each call reads +/// them, so a new key or model applies to the next request. +/// +public class AiOptions +{ + public const string Anthropic = "Anthropic"; + + /// OpenAI, or anything that speaks its API at . + public const string OpenAI = "OpenAI"; + + public static readonly string[] Providers = [Anthropic, OpenAI]; + + /// What an Anthropic provider uses while is empty. + public const string DefaultAnthropicModel = "claude-opus-5-5"; + + public string Provider { get; set; } = Anthropic; + + public string ApiKey { get; set; } + + /// Empty uses with Anthropic. Other providers need one. + public string Model { get; set; } + + /// + /// Where the provider's API is, when it isn't the provider's own - an Azure OpenAI resource, a + /// local Ollama, a company proxy. Empty uses the provider's public endpoint. + /// + public string BaseUrl { get; set; } + + public bool IsAnthropic => string.Equals(Provider?.Trim(), Anthropic, StringComparison.OrdinalIgnoreCase); + + public bool IsOpenAI => string.Equals(Provider?.Trim(), OpenAI, StringComparison.OrdinalIgnoreCase); + + public string ResolvedModel => + !string.IsNullOrWhiteSpace(Model) ? Model.Trim() : IsAnthropic ? DefaultAnthropicModel : null; + + /// + /// Whether AI features can run: a provider Bitween knows, a key, and a model to send it with. A + /// misspelt provider in configuration is off, not quietly sent down the OpenAI path. + /// + public bool IsSet => (IsAnthropic || IsOpenAI) && !string.IsNullOrWhiteSpace(ApiKey) && ResolvedModel is not null; + + /// + /// Why an API address can't be used, or null when it can - or is empty. Plain http only reaches + /// this machine, such as a local Ollama: anywhere else it would send the key in the clear. + /// + public static string AddressProblem(string address) + { + if (string.IsNullOrWhiteSpace(address)) return null; + if (!Uri.TryCreate(address.Trim(), UriKind.Absolute, out var url) || url.Scheme is not ("http" or "https")) + return $"'{address.Trim()}' isn't an http or https address."; + if (url.Scheme == "http" && !url.IsLoopback) + return "plain http is only for this machine (localhost); use https anywhere else, so the key isn't sent in the clear."; + return null; + } +} diff --git a/SW.Bitween.Api/Services/Ai/AnthropicModel.cs b/SW.Bitween.Api/Services/Ai/AnthropicModel.cs new file mode 100644 index 00000000..b7e7b9d9 --- /dev/null +++ b/SW.Bitween.Api/Services/Ai/AnthropicModel.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Anthropic; +using Anthropic.Exceptions; +using Anthropic.Models.Messages; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Services.Ai; + +/// Claude, through Anthropic's own SDK. +/// +/// +/// Neither thinking nor effort is set: the model is whatever Settings names, and the models disagree +/// about both - an older one rejects an effort it doesn't know. Each current model's own default +/// applies instead, which for the default, Opus 5.5, is adaptive thinking at medium effort. +/// +/// +/// The instructions are marked for caching: they're the same on every call of a feature, and the +/// larger part of each request. +/// +/// +sealed class AnthropicModel(string apiKey, string model, string baseUrl, HttpClient http) : IAiModel +{ + const string Name = "Anthropic"; + + public async Task Complete(AiPrompt prompt) + { + var client = baseUrl == null + ? new AnthropicClient { ApiKey = apiKey, HttpClient = http, Timeout = AiModels.CallTimeout } + : new AnthropicClient { ApiKey = apiKey, HttpClient = http, Timeout = AiModels.CallTimeout, BaseUrl = baseUrl }; + + var parameters = new MessageCreateParams + { + Model = model, + MaxTokens = prompt.MaxTokens, + System = new List + { + new() { Text = prompt.Instructions, CacheControl = new CacheControlEphemeral() }, + }, + Messages = [new() { Role = Role.User, Content = prompt.Message }], + }; + + Message response; + try + { + response = await client.Messages.Create(parameters); + } + catch (AnthropicUnauthorizedException) + { + throw AiModels.Refused(Name, "the API key under Settings, AI isn't valid."); + } + catch (AnthropicNotFoundException) + { + throw AiModels.Refused(Name, $"there's no model called '{model}'. Check Settings, AI."); + } + catch (AnthropicRateLimitException) + { + throw AiModels.Unreachable(Name, "the account's rate limit was reached. Try again in a minute."); + } + catch (Anthropic5xxException ex) + { + throw AiModels.Unreachable(Name, AiModels.Answered((int)ex.StatusCode)); + } + catch (AnthropicApiException ex) + { + throw AiModels.Refused(Name, AiModels.Answered((int)ex.StatusCode)); + } + catch (Exception ex) when (ex is AnthropicIOException or HttpRequestException or TaskCanceledException) + { + throw AiModels.Unreachable(Name, AiModels.NoConnection); + } + + if (response.StopReason == "refusal") + throw new SWValidationException("AI_REFUSED", + $"The model declined this request{(response.StopDetails?.Explanation is { } why ? ": " + why : ".")}"); + + if (response.StopReason == "max_tokens") + throw new SWValidationException("AI_TRUNCATED", + "The model's answer was too long and was cut off. Try smaller samples."); + + return string.Concat(response.Content.Select(b => b.Value).OfType().Select(t => t.Text)); + } +} diff --git a/SW.Bitween.Api/Services/Ai/DocumentDifferences.cs b/SW.Bitween.Api/Services/Ai/DocumentDifferences.cs new file mode 100644 index 00000000..97b1ce97 --- /dev/null +++ b/SW.Bitween.Api/Services/Ai/DocumentDifferences.cs @@ -0,0 +1,116 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using SW.Bitween.NativeAdapters.Mapper; + +namespace SW.Bitween.Services.Ai; + +/// +/// Where a mapped document differs from the one it should have been, in words a person - or a +/// model asked to fix the rules - can act on. +/// +/// +/// +/// Compared as trees rather than as text, so whitespace, key order and number formatting don't +/// count. Types do: 42 where "42" was wanted is a difference a partner's parser +/// notices. +/// +/// +/// A blank value in the output sample - null, empty or only whitespace - is a placeholder: output +/// samples are often a partner's template with the values left out, and any value the rules write +/// there is an answer, not a difference. The field itself still has to be written. +/// +/// +public static class DocumentDifferences +{ + /// Enough to fix the rules from; a wholly wrong document would otherwise list every field. + public const int Limit = 20; + + public static List Between(ValueNode expected, ValueNode actual) + { + var found = new List(); + Compare(expected, actual, "", found); + return found; + } + + static void Compare(ValueNode? expected, ValueNode? actual, string path, List found) + { + if (found.Count >= Limit) return; + // Named the way the mapper names a failing rule's document, so the editor reads both alike. + var at = path.Length == 0 ? "(root)" : path; + + if (expected is null && actual is null) return; + if (expected is null) + { + found.Add(new(at, $"not in the output sample, but the rules write {Describe(actual!)}")); + return; + } + if (actual is null) + { + found.Add(new(at, $"missing; the output sample has {Describe(expected)}")); + return; + } + if (IsPlaceholder(expected) && actual is ScalarNode) return; + if (expected.Kind != actual.Kind) + { + found.Add(new(at, $"the output sample has {Describe(expected)}, the rules write {Describe(actual)}")); + return; + } + + switch (expected) + { + case ScalarNode e when !Same(e.Value, ((ScalarNode)actual).Value): + found.Add(new(at, $"the output sample has {Describe(e)}, the rules write {Describe(actual)}")); + break; + + case ObjectNode e: + var a = (ObjectNode)actual; + foreach (var key in e.Keys) Compare(e[key], a[key], Join(path, key), found); + foreach (var key in a.Keys.Where(k => e[k] is null)) Compare(null, a[key], Join(path, key), found); + break; + + case ListNode e: + var items = ((ListNode)actual).Items; + if (e.Items.Count != items.Count) + found.Add(new(at, $"the output sample has {e.Items.Count} entries, the rules write {items.Count}")); + for (var i = 0; i < Math.Min(e.Items.Count, items.Count); i++) + Compare(e.Items[i], items[i], $"{path}[{i}]", found); + break; + } + } + + static string Join(string path, string key) => path.Length == 0 ? key : $"{path}.{key}"; + + static bool IsPlaceholder(ValueNode node) => + node is ScalarNode scalar && (scalar.Value is null || scalar.Value is string text && string.IsNullOrWhiteSpace(text)); + + static bool Same(object? expected, object? actual) => (expected, actual) switch + { + (null, null) => true, + (decimal e, decimal a) => e == a, + (string e, string a) => string.Equals(e, a, StringComparison.Ordinal), + (bool e, bool a) => e == a, + _ => false, + }; + + static string Describe(ValueNode node) => node switch + { + ScalarNode { Value: null } => "null", + ScalarNode { Value: string s } => $"the text {Quote(s)}", + ScalarNode { Value: decimal d } => $"the number {d.ToString(CultureInfo.InvariantCulture)}", + ScalarNode { Value: bool b } => b ? "true" : "false", + ObjectNode => "an object", + ListNode l => $"a list of {l.Items.Count}", + _ => "a value", + }; + + static string Quote(string text) => text.Length <= 60 ? $"\"{text}\"" : $"\"{text[..57]}...\""; +} + +/// One place a mapped document differs from its sample: a path such as items[0].sku, and how. +public sealed record MappingDifference(string Target, string Reason) +{ + public override string ToString() => $"{Target}: {Reason}"; +} diff --git a/SW.Bitween.Api/Services/Ai/FrozenValues.cs b/SW.Bitween.Api/Services/Ai/FrozenValues.cs new file mode 100644 index 00000000..8a4c92c2 --- /dev/null +++ b/SW.Bitween.Api/Services/Ai/FrozenValues.cs @@ -0,0 +1,102 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using SW.Bitween.NativeAdapters.Mapper; + +namespace SW.Bitween.Services.Ai; + +/// +/// Text a rule writes as a constant that the source sample holds as data. +/// +/// +/// +/// A constant the source doesn't have - a currency, a sender code - is what a fixed value is for. +/// One the source does have is a value that changes from document to document, frozen to match the +/// one sample: first + " Haddad" turns every customer into a Haddad, and still matches the +/// output sample, so the comparison alone would call it right. +/// +/// +/// Whole values only, ignoring case and surrounding spaces, and only text of three characters or +/// more: a constant "1" or "N" turns up in most documents by coincidence. +/// +/// +public static class FrozenValues +{ + const int MinLength = 3; + + /// One difference per frozen value, named the way the mapper names the rule's errors. + public static List In(MappingRules rules, ValueNode source) + { + var data = new HashSet(StringComparer.OrdinalIgnoreCase); + Collect(source, data); + var found = new List(); + if (data.Count == 0) return found; + + if (rules.Root is not null) List(rules.Root, "", data, found); + Fields(rules.Fields, rules.Lists, "", data, found); + return found; + } + + static void Fields(IEnumerable fields, IEnumerable lists, string prefix, + HashSet data, List found) + { + foreach (var field in fields) Field(field, Describe(prefix, field.Target), data, found); + foreach (var list in lists) List(list, prefix, data, found); + } + + static void List(ListRule list, string prefix, HashSet data, List found) + { + var key = Describe(prefix, list.Target); + if (list.Item is not null) Field(list.Item, key, data, found); + Fields(list.Fields, list.Lists, key, data, found); + foreach (var entry in list.Fixed.Concat(list.After)) + { + if (entry.Item is not null) Field(entry.Item, key, data, found); + Fields(entry.Fields, entry.Lists, key, data, found); + } + } + + static void Field(FieldRule field, string target, HashSet data, List found) + { + if (field.From.Kind == ValueSourceKind.Fixed && Frozen(field.From.Value, data) is { } value) + found.Add(new(target, $"the fixed value \"{value}\" is in the source sample, so it would be the same for every document; read it from the source")); + + if (field.Transform is { } transform + && transform.Fn is "concat" or "replace" or "defaultIfEmpty" + && transform.Args.TryGetValue(transform.Fn == "defaultIfEmpty" ? "value" : "with", out var arg) + && Frozen(arg, data) is { } text) + found.Add(new(target, $"the {transform.Fn} text \"{text}\" is in the source sample, so it would be the same for every document; read it from the source")); + } + + static string? Frozen(object? value, HashSet data) + { + var text = (value is JValue jvalue ? jvalue.Value : value) as string; + text = text?.Trim(); + return text is { Length: >= MinLength } && data.Contains(text) ? text : null; + } + + static void Collect(ValueNode node, HashSet data) + { + switch (node) + { + case ScalarNode { Value: string text } when text.Trim().Length >= MinLength: + data.Add(text.Trim()); + break; + case ObjectNode obj: + foreach (var (_, child) in obj.Children()) Collect(child, data); + break; + case ListNode list: + foreach (var item in list.Items) Collect(item, data); + break; + } + } + + /// As DocumentMapper describes a rule's target, so the editor marks the right row. + static string Describe(string prefix, List target) + { + var joined = target.Count == 0 ? "(no target)" : string.Join(".", target); + return prefix.Length == 0 ? joined : $"{prefix}[].{joined}"; + } +} diff --git a/SW.Bitween.Api/Services/Ai/MappingRulesPrompt.cs b/SW.Bitween.Api/Services/Ai/MappingRulesPrompt.cs new file mode 100644 index 00000000..0eeb26f4 --- /dev/null +++ b/SW.Bitween.Api/Services/Ai/MappingRulesPrompt.cs @@ -0,0 +1,367 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; +using SW.Bitween.NativeAdapters.Mapper; + +namespace SW.Bitween.Services.Ai; + +/// +/// What a model is told to write mapping rules, and how its answer becomes rules again. +/// +/// +/// +/// The answer is asked for as plain JSON and read with the mapper's own types, rather than +/// constrained by a schema: the rules nest lists inside lists, which providers' schema-constrained +/// output can't express. Reading it strictly - an unknown property is an error - and then running +/// it against the sample catches more than a schema would. +/// +/// +/// is the same text on every call, so a provider can cache it. Everything +/// that varies goes in the message. +/// +/// +public static class MappingRulesPrompt +{ + public const string Instructions = """ + You write mapping rules for Bitween's rules-based mapper. Given a sample of a source document + and the document that should be produced from it, answer with the rules that turn one into the + other. + + Answer with exactly one JSON object: the complete rules. No prose, no Markdown, no code fence. + + # The rules format + + { + "version": 1, + "sourceFormat": "json", // given in the message; copy it + "targetFormat": "json", // given in the message; copy it + "sourceDateOrder": "yearFirst", // yearFirst | dayFirst | monthFirst + "fields": [ FieldRule, ... ], + "lists": [ ListRule, ... ], + "root": ListRule // optional: makes the whole output an array + } + + Copy any sourceCsv, targetCsv, sourceFixedWidth, targetFixedWidth, sourceExcel and targetExcel + settings the message gives you, unchanged. Use no property this description doesn't name. + + ## FieldRule: writes one value + + { "target": ["customer", "name"], "from": ValueSource, "transform": Transform, "lookup": Lookup, "type": "number" } + + - target: the output path as segments, so a key containing a dot is one segment. Missing + objects along the path are created. + - from (required), then optionally transform, lookup and type, applied in that order. + - type converts the value to "string", "number" or "boolean". Use it whenever the output + wants a different JSON type than the source has, e.g. "42" in the source and 42 in the output. + + ValueSource, by kind: + - { "kind": "path", "path": "order.id" } - a dot path read from the current scope: the + document, or the current list entry inside a list. + - { "kind": "rootPath", "path": "order.id" } - a dot path from the document root, even inside + a list. Use it to copy a header value onto every line. + - { "kind": "fixed", "value": "EUR" } - a literal, keeping its JSON type. + - { "kind": "count" } - how many source entries the enclosing list walked; for trailer records. + - { "kind": "partner", "key": "SenderId" } - a property of the exchange's partner. + - { "kind": "global", "setId": "regions", "key": "default" } - a value from a global value set. + You only ever see these two by name, in the message. Use them only for names it lists, and + only when the output wants a value the source doesn't have and a name plainly matches it. + - "source" also exists, but needs a document you can't see. Don't use it. + + Paths match keys exactly and never step into a list: "lines.0.sku" is not a path. A list + rule is the only way to reach list entries. A missing path gives null, not an error. + + Transform, one per field, exactly these: + - { "fn": "upper" }, { "fn": "lower" }, { "fn": "trim" } + - { "fn": "substring", "start": 0, "length": 3 } - start counts from 0; length optional (to the end) + - { "fn": "replace", "find": "-", "with": "" } + - { "fn": "concat", "with": " Ltd" } - appends text + - { "fn": "round", "decimals": 2 } - halves away from zero + - { "fn": "multiply", "by": 100 }, { "fn": "add", "amount": -1 } + - { "fn": "formatDate", "format": "yyyy-MM-dd" } - reads a date, writes it with a .NET format string + - { "fn": "defaultIfEmpty", "value": "N/A" } - replaces null or empty text + Null passes through every transform except defaultIfEmpty. Numbers are exact decimals. + + Lookup: { "table": { "N": "New", "S": "Shipped" }, "fallback": "Unknown" } - replaces the value + by comparing it as text; anything not in the table becomes fallback (null when left out). Use it + when the samples show codes being translated. + + ## ListRule: writes an array + + { "over": "order.lines", "target": ["items"], "where": Filter, "fields": [...], "lists": [...] } + + - over: the path of the source list to walk. "" walks the document itself (a CSV, fixed-width or + Excel source is a list of rows, so it is walked with ""). Leaving over out walks nothing, and + the list holds only its fixed entries. + - target: where the array is written. + - fields and lists: applied to each entry, with paths relative to the entry. Lists nest. + - item: one FieldRule (its target unused) that makes each entry a plain value instead of an + object, for ["A1", "B7"] rather than [{ "sku": "A1" }]. + - where: { "field": "qty", "operator": "greaterThan", "value": 0 } skips entries that don't + match. Operators: equal, notEqual (compare as text), greaterThan, greaterThanOrEqual, + lessThan, lessThanOrEqual (compare as numbers). + - fixed and after: entries placed before or after the walked ones, each + { "item": FieldRule } or { "fields": [...], "lists": [...] }, read from the scope the list sits + in. For header and trailer records, or a list built from separate fields. + + A top-level "root" makes the output an array; top-level fields and lists must then be empty. + + ## Formats + + - XML read: the root element's name is the top-level key, attributes are "@name" keys, mixed + text is "#text", repeated elements are lists, and namespaces are dropped. + - XML written: exactly one top-level key, not a list; "@name" targets become attributes; + "@xmlns" or "@xmlns:prefix" targets declare namespaces. + - CSV, fixed-width and Excel read: a list of rows whose values are all text, keyed by header + name, or by position "1", "2", ... without a header. + - CSV, fixed-width and Excel written: a list of rows (use root), or one object for one row. A + nested target becomes a dotted column name. + - Dates: formatDate always reads year-first dates such as 2026-09-10 or 2026-09-10T08:30:00Z. + Set sourceDateOrder to dayFirst or monthFirst when the source writes dates like 10/09/2026. + + # How to choose rules + + - Take every output value from the source when the source has it, directly or after a + transform, lookup or type. Use "fixed" only for values the source can't supply. + - Never write a value that appears in the source sample as fixed text - not as a "fixed" + value, and not as the text of a concat, replace or defaultIfEmpty. It is data that + changes from one document to the next, and freezing it would match this sample only. + When the format can't build an output value from the source (it can't join two fields, + for example), map what you can and leave that value different; it will be finished by + hand. + - The output sample may hold placeholder values. Match its shape and its value types, and + don't copy placeholder values as fixed values when a source field plainly corresponds. + - Write the output keys in the order the output sample has them. + - Keep it minimal: no transform, lookup or type that the samples don't call for. + - When the message has instructions from the person building the mapping, follow them: they + say how the mapping should behave beyond what the samples show, such as codes a lookup + needs that the sample doesn't happen to contain. + """; + + /// At most this many names of each kind go in a message, so a big partner can't crowd out the samples. + public const int NameLimit = 100; + + /// The message for one call: the formats, both samples, and what went wrong last time. + /// The editor's rules, for their formats and format settings. + /// + /// The partner properties and global values the rules may read, by name. Never their values: + /// those stay on the server, and are only filled in when the mapping runs. + /// + /// What the person building the mapping wrote for the model, if anything. + /// Rules to fix rather than start from scratch, with what was wrong with them. + public static string Message(MappingRules formats, string sourceSample, string targetSample, + ValueNames? names = null, string? instructions = null, + (MappingRules Rules, IReadOnlyList Problems)? previous = null) + { + var message = new StringBuilder(); + message.AppendLine("Formats and their settings (copy them into your answer):"); + message.AppendLine(Serialize(FormatsOnly(formats))); + message.AppendLine(); + message.AppendLine(""); + message.AppendLine(sourceSample.Trim()); + message.AppendLine(""); + message.AppendLine(); + message.AppendLine(""); + message.AppendLine(targetSample.Trim()); + message.AppendLine(""); + + if (names is { } known && (known.Partner.Count > 0 || known.Globals.Count > 0)) + { + message.AppendLine(); + message.AppendLine("Values Bitween fills in when the mapping runs, by name only:"); + if (known.Partner.Count > 0) + message.AppendLine("- partner properties: " + string.Join(", ", known.Partner.Take(NameLimit))); + foreach (var (setId, keys) in known.Globals.Take(NameLimit)) + message.AppendLine($"- global value set \"{setId}\", keys: " + string.Join(", ", keys.Take(NameLimit))); + } + + if (!string.IsNullOrWhiteSpace(instructions)) + { + message.AppendLine(); + message.AppendLine(""); + message.AppendLine(instructions.Trim()); + message.AppendLine(""); + } + + if (previous is { } last) + { + message.AppendLine(); + message.AppendLine("These rules were tried and did not produce the output sample:"); + message.AppendLine(Serialize(last.Rules)); + message.AppendLine(); + message.AppendLine("What was wrong:"); + foreach (var problem in last.Problems) message.AppendLine("- " + problem); + message.AppendLine(); + message.AppendLine("Answer with corrected rules. Keep what was right."); + } + + return message.ToString(); + } + + /// + /// The rules in a model's answer, with the editor's formats put back. + /// + /// With a reason a model can act on, when the answer isn't rules. + public static MappingRules Parse(string answer, MappingRules formats) + { + var json = JsonPart(answer) + ?? throw new FormatException("The answer contained no JSON object."); + MappingRules? rules; + try + { + rules = JsonConvert.DeserializeObject(json, Strict); + } + catch (JsonException ex) + { + throw new FormatException($"The answer is not valid rules JSON: {ex.Message}"); + } + if (rules is null) throw new FormatException("The answer was empty."); + if (NullIn(rules) is { } missing) throw new FormatException(missing); + + if (rules.Version != MappingRules.CurrentVersion) + throw new FormatException($"\"version\" must be {MappingRules.CurrentVersion}."); + if (rules.Root is not null && (rules.Fields.Count > 0 || rules.Lists.Count > 0)) + throw new FormatException("With \"root\" set, top-level \"fields\" and \"lists\" must be empty."); + + // Whatever the model wrote, the formats are the editor's: they describe the exchange, and + // the samples were read with them. + rules.SourceFormat = formats.SourceFormat; + rules.TargetFormat = formats.TargetFormat; + rules.SourceCsv = formats.SourceCsv; + rules.TargetCsv = formats.TargetCsv; + rules.SourceFixedWidth = formats.SourceFixedWidth; + rules.TargetFixedWidth = formats.TargetFixedWidth; + rules.SourceExcel = formats.SourceExcel; + rules.TargetExcel = formats.TargetExcel; + return rules; + } + + /// + /// The names of the partner properties and global values a mapping can read - the keys of the + /// context a preview maps against, with every value left behind. + /// + /// A context built with its secrets masked, as the preview's is. + /// + /// Secrets are left out by name as well: a rule reading one would copy a credential into a + /// document, which is never what a sample asks for and is better written by hand if it is meant. + /// + public static ValueNames NamesOf(MappingContext masked) => new( + Plain(masked.Partner), + masked.Globals.OrderBy(set => set.Key, StringComparer.Ordinal) + .Select(set => (set.Key, Keys: Plain(set.Value))) + .Where(set => set.Keys.Count > 0) + .ToDictionary(set => set.Key, set => set.Keys)); + + static IReadOnlyList Plain(IReadOnlyDictionary values) => + values.Where(kv => kv.Value != AdapterSecretProperties.Sentinel) + .Select(kv => kv.Key).Order(StringComparer.Ordinal).ToList(); + + /// + /// Where the rules hold an explicit null for a list or a value source, which the mapper's types + /// start as empty and the code reading them never expects - "fields": null from a model + /// or a hand-written request. Null when there is none. + /// + public static string? NullIn(MappingRules rules) + { + if (rules.Fields is null) return "\"fields\" can't be null; use [] for none."; + if (rules.Lists is null) return "\"lists\" can't be null; use [] for none."; + var lists = new Stack(rules.Lists); + if (rules.Root is not null) lists.Push(rules.Root); + foreach (var field in rules.Fields) + if (FieldNull(field) is { } problem) return problem; + + while (lists.TryPop(out var list)) + { + if (list is null) return "a list can't be null."; + if (list.Target is null || list.Fields is null || list.Lists is null || list.Fixed is null || list.After is null) + return "a list's \"target\", \"fields\", \"lists\", \"fixed\" and \"after\" can't be null; use [] for none."; + if (list.Item is not null && FieldNull(list.Item) is { } itemProblem) return itemProblem; + foreach (var field in list.Fields) + if (FieldNull(field) is { } problem) return problem; + foreach (var entry in list.Fixed.Concat(list.After)) + { + if (entry is null || entry.Fields is null || entry.Lists is null) + return "an entry's \"fields\" and \"lists\" can't be null; use [] for none."; + if (entry.Item is not null && FieldNull(entry.Item) is { } entryProblem) return entryProblem; + foreach (var field in entry.Fields) + if (FieldNull(field) is { } problem) return problem; + foreach (var inner in entry.Lists) lists.Push(inner); + } + foreach (var inner in list.Lists) lists.Push(inner); + } + return null; + } + + static string? FieldNull(FieldRule? field) => + field is null ? "a field rule can't be null." + : field.Target is null || field.From is null ? "a field rule's \"target\" and \"from\" can't be null." + : field.Transform is { Args: null } ? "a transform can't be null." : null; + + /// The rules as the editor stores them: camelCase, enums as camelCase text, nulls left out. + public static string Serialize(MappingRules rules) => JsonConvert.SerializeObject(rules, Wire); + + /// + /// From the first brace to the last: some models wrap JSON in a code fence or a sentence even + /// when asked not to, and that shouldn't cost a second call. + /// + static string? JsonPart(string answer) + { + var start = answer.IndexOf('{'); + var end = answer.LastIndexOf('}'); + return start >= 0 && end > start ? answer[start..(end + 1)] : null; + } + + static MappingRules FormatsOnly(MappingRules rules) => new() + { + SourceFormat = rules.SourceFormat, + TargetFormat = rules.TargetFormat, + SourceDateOrder = rules.SourceDateOrder, + SourceCsv = rules.SourceCsv, + TargetCsv = rules.TargetCsv, + SourceFixedWidth = rules.SourceFixedWidth, + TargetFixedWidth = rules.TargetFixedWidth, + SourceExcel = rules.SourceExcel, + TargetExcel = rules.TargetExcel, + }; + + // An unknown property is an error rather than ignored: it is a rule the model meant and the + // mapper would silently drop. + static readonly JsonSerializerSettings Strict = new() { ContractResolver = new StrictExceptTransforms() }; + + /// + /// Strict for every type but a transform, whose arguments sit beside "fn" as extension data. + /// Strictness set on the serializer would refuse those too: Newtonsoft checks for a missing + /// member before it looks for extension data. + /// + sealed class StrictExceptTransforms : DefaultContractResolver + { + protected override JsonObjectContract CreateObjectContract(Type objectType) + { + var contract = base.CreateObjectContract(objectType); + contract.MissingMemberHandling = objectType == typeof(TransformRule) + ? MissingMemberHandling.Ignore + : MissingMemberHandling.Error; + return contract; + } + } + + static readonly JsonSerializerSettings Wire = new() + { + // Lookup tables are dictionaries whose keys are data, so they keep their case. + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy { ProcessDictionaryKeys = false }, + }, + Converters = { new StringEnumConverter(new CamelCaseNamingStrategy()) }, + NullValueHandling = NullValueHandling.Ignore, + }; +} + +/// Names a mapping can read values by, without the values. +public sealed record ValueNames( + IReadOnlyList Partner, + IReadOnlyDictionary> Globals); diff --git a/SW.Bitween.Api/Services/Ai/OpenAiModel.cs b/SW.Bitween.Api/Services/Ai/OpenAiModel.cs new file mode 100644 index 00000000..2c3ef3f4 --- /dev/null +++ b/SW.Bitween.Api/Services/Ai/OpenAiModel.cs @@ -0,0 +1,81 @@ +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using OpenAI; +using OpenAI.Chat; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Services.Ai; + +/// OpenAI's chat completions, and anything that speaks them at another address. +/// +/// No output-token limit is sent: compatible servers disagree about what the parameter is called, +/// and each one's own limit is generous. A cut-off answer still shows, as a finish reason. +/// +sealed class OpenAiModel(string apiKey, string model, string baseUrl, HttpClient http) : IAiModel +{ + string Name => baseUrl == null ? "OpenAI" : $"The AI provider at {baseUrl}"; + + public async Task Complete(AiPrompt prompt) + { + var clientOptions = new OpenAIClientOptions + { + Transport = new HttpClientPipelineTransport(http), + NetworkTimeout = AiModels.CallTimeout, + }; + if (baseUrl != null) clientOptions.Endpoint = new Uri(baseUrl); + + var client = new ChatClient(model, new ApiKeyCredential(apiKey), clientOptions); + var chatOptions = new ChatCompletionOptions(); + if (prompt.Json) chatOptions.ResponseFormat = ChatResponseFormat.CreateJsonObjectFormat(); + + ChatCompletion completion; + try + { + completion = (await client.CompleteChatAsync( + [new SystemChatMessage(prompt.Instructions), new UserChatMessage(prompt.Message)], + chatOptions)).Value; + } + catch (ClientResultException ex) when (ex.Status == 401) + { + throw AiModels.Refused(Name, "the API key under Settings, AI isn't valid."); + } + catch (ClientResultException ex) when (ex.Status == 404) + { + throw AiModels.Refused(Name, $"there's no model called '{model}' at this address. Check Settings, AI."); + } + catch (ClientResultException ex) when (ex.Status == 429) + { + throw AiModels.Unreachable(Name, "the account's rate limit was reached. Try again in a minute."); + } + catch (ClientResultException ex) when (ex.Status == 0) + { + // No response arrived at all. + throw AiModels.Unreachable(Name, AiModels.NoConnection); + } + catch (ClientResultException ex) when (ex.Status >= 500) + { + throw AiModels.Unreachable(Name, AiModels.Answered(ex.Status)); + } + catch (ClientResultException ex) + { + throw AiModels.Refused(Name, AiModels.Answered(ex.Status)); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or UriFormatException) + { + throw AiModels.Unreachable(Name, AiModels.NoConnection); + } + + if (completion.FinishReason == ChatFinishReason.ContentFilter) + throw new SWValidationException("AI_REFUSED", "The provider's content filter declined this request."); + + if (completion.FinishReason == ChatFinishReason.Length) + throw new SWValidationException("AI_TRUNCATED", + "The model's answer was too long and was cut off. Try smaller samples."); + + return string.Concat(completion.Content.Select(part => part.Text)); + } +} diff --git a/SW.Bitween.Api/Services/BitweenOptions.cs b/SW.Bitween.Api/Services/BitweenOptions.cs index f842ee94..5891297e 100644 --- a/SW.Bitween.Api/Services/BitweenOptions.cs +++ b/SW.Bitween.Api/Services/BitweenOptions.cs @@ -174,6 +174,12 @@ public BitweenOptions() /// public SW.Bitween.NativeAdapters.ChannelMessages.MailServerOptions MailServer { get; } = new(); + /// + /// The AI provider AI features call. One instance for the life of the app, for the same + /// reason as : Settings changes it in place. + /// + public SW.Bitween.Services.Ai.AiOptions Ai { get; } = new(); + /// Days an exchange is kept before the retention job removes it. 0 keeps exchanges for ever. public int ExchangeRetentionDays { get; set; } diff --git a/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs b/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs index 3a6b4fa5..c1cb39a4 100644 --- a/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs +++ b/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs @@ -63,6 +63,9 @@ public sealed record SettingDefinition( /// public Func OnChange { get; init; } + /// The only values a string setting takes, offered as a dropdown. Null for free text. + public IReadOnlyList Choices { get; init; } + /// Only editable settings get a row; the rest are read straight off the options. public bool Stored => Access == SettingAccess.Editable; } @@ -305,6 +308,39 @@ public static class SettingsCatalog t => Str(t.Bitween.RegistryUsageReports), (t, v) => t.Bitween.RegistryUsageReports = Bool(v)), + // --- AI --- + // Every AI feature reads these per call, so a new key or model applies to the next request. + new("Bitween.Ai.Provider", "AI", + "Provider", + "Who AI features call. OpenAI also covers anything that speaks its API - Azure OpenAI, Ollama, a company proxy - at the address below. Sample documents sent to AI features go to this provider, with the names - never the values - of partner properties and global values.", + SettingKind.String, false, + t => t.Bitween.Ai.Provider, + (t, v) => t.Bitween.Ai.Provider = Choice(v, Ai.AiOptions.Providers)) + { + Choices = Ai.AiOptions.Providers + }, + + new("Bitween.Ai.ApiKey", "AI", + "API key", + "The provider's API key. AI features stay off until one is set.", + SettingKind.String, true, + t => t.Bitween.Ai.ApiKey, + (t, v) => t.Bitween.Ai.ApiKey = Trimmed(v)), + + new("Bitween.Ai.Model", "AI", + "Model", + $"The model AI features use. Empty uses {Ai.AiOptions.DefaultAnthropicModel} with Anthropic; OpenAI needs one named, or your Azure deployment's name.", + SettingKind.String, false, + t => t.Bitween.Ai.Model, + (t, v) => t.Bitween.Ai.Model = Trimmed(v)), + + new("Bitween.Ai.BaseUrl", "AI", + "API address", + "Leave empty for the provider's own API. Set it for Azure OpenAI (https://your-resource.openai.azure.com/openai/v1/), a local Ollama (http://localhost:11434/v1/) or a proxy. Plain http only reaches this machine.", + SettingKind.String, false, + t => t.Bitween.Ai.BaseUrl, + (t, v) => t.Bitween.Ai.BaseUrl = AiAddress(v)), + // --- Mail server --- // Shared by every Email notification channel, which then only names its recipients. The // Email adapter reads these on each send, so a change applies to the next email. @@ -625,6 +661,15 @@ private static int Port(string value) => /// Empty clears it. private static string Trimmed(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + /// One of , spelled the way the list spells it. + private static string Choice(string value, IReadOnlyList choices) => + choices.FirstOrDefault(c => string.Equals(c, value?.Trim(), StringComparison.OrdinalIgnoreCase)) + ?? throw new FormatException($"'{value}' isn't one of {string.Join(", ", choices)}."); + + /// Empty clears it. Otherwise https, or http to this machine - see . + private static string AiAddress(string value) => + Ai.AiOptions.AddressProblem(value) is { } problem ? throw new FormatException(problem) : Trimmed(value); + /// Empty clears it. Otherwise one plain address, without the display name, which has a setting of its own. private static string EmailAddress(string value) { diff --git a/SW.Bitween.HttpTests/MappingSuggestionTests.cs b/SW.Bitween.HttpTests/MappingSuggestionTests.cs new file mode 100644 index 00000000..1befd39b --- /dev/null +++ b/SW.Bitween.HttpTests/MappingSuggestionTests.cs @@ -0,0 +1,376 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Services.Ai; +using Xunit; + +namespace SW.Bitween.HttpTests; + +/// +/// Generate with AI: the model under Settings, AI writes the rules, and the server checks them +/// against the samples before the editor gets them. +/// +[Collection("Http")] +public class MappingSuggestionTests(HttpFixture fixture) : IDisposable +{ + public void Dispose() + { + AiModels.ModelOverride = null; + AiModels.HandlerOverride = null; + using var admin = fixture.AdminAsync().GetAwaiter().GetResult(); + foreach (var key in new[] { "Bitween.Ai.Provider", "Bitween.Ai.ApiKey", "Bitween.Ai.Model", "Bitween.Ai.BaseUrl" }) + admin.DeleteAsync($"/api/settings/{key}").GetAwaiter().GetResult(); + } + + const string Source = """{ "order": { "id": "SO-1", "customer": "acme", "lines": [ { "sku": "A1", "qty": "2" } ] } }"""; + const string Target = """{ "reference": "SO-1", "customer": "ACME", "items": [ { "sku": "A1", "quantity": 2 } ] }"""; + + const string Right = """ + { "version": 1, + "fields": [ { "target": ["reference"], "from": { "kind": "path", "path": "order.id" } }, + { "target": ["customer"], "from": { "kind": "path", "path": "order.customer" }, "transform": { "fn": "upper" } } ], + "lists": [ { "over": "order.lines", "target": ["items"], "fields": [ + { "target": ["sku"], "from": { "kind": "path", "path": "sku" } }, + { "target": ["quantity"], "from": { "kind": "path", "path": "qty" }, "type": "number" } ] } ] } + """; + + /// Right but for the quantity, which stays text. + static readonly string AlmostRight = Right.Replace(""", "type": "number" """, " "); + + sealed class FakeModel(Func answer) : IAiModel + { + public List Asked { get; } = new(); + + public Task Complete(AiPrompt prompt) + { + Asked.Add(prompt); + return Task.FromResult(answer(prompt)); + } + } + + sealed class FakeProvider(Func answer) : HttpMessageHandler + { + public List<(HttpRequestMessage Request, string Body)> Received { get; } = new(); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken); + Received.Add((request, body)); + return answer(request, body); + } + } + + async Task ConfiguredAsync(params (string Key, string Value)[] settings) + { + var admin = await fixture.AdminAsync(); + var all = new[] { ("Bitween.Ai.ApiKey", "sk-test-key") }.Concat(settings).ToArray(); + foreach (var (key, value) in all) + await Api.Json(await admin.PostAsJsonAsync($"/api/settings/{key}", new { value })); + + // Each save also broadcasts a reload, which this node handles a moment later; one started by + // an earlier save can read the table before a later save lands and briefly put the earlier + // values back. Wait until what was saved is what's running, as it is by the time anyone + // clicks Generate after saving Settings. + var live = fixture.App.Services.GetRequiredService(); + for (var waited = 0; !all.All(s => Live(live, s.Item1) == s.Item2); waited += 50) + { + Assert.True(waited < 10_000, "the saved AI settings never took effect"); + await Task.Delay(50); + } + return admin; + } + + static string? Live(BitweenOptions options, string key) => key switch + { + "Bitween.Ai.ApiKey" => options.Ai.ApiKey, + "Bitween.Ai.Provider" => options.Ai.Provider, + "Bitween.Ai.Model" => options.Ai.Model, + "Bitween.Ai.BaseUrl" => options.Ai.BaseUrl, + _ => throw new ArgumentOutOfRangeException(nameof(key)), + }; + + static Task SuggestAsync(HttpClient client, bool refine = false, string rules = """{ "version": 1 }""") => + client.PostAsJsonAsync("/api/mappingsuggestions", + new { mappingRules = rules, sourceDocument = Source, targetDocument = Target, refine }); + + static HttpResponseMessage Ok(object body) => + new(HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") }; + + [Fact] + public async Task Without_a_key_it_says_where_to_set_one() + { + var admin = await fixture.AdminAsync(); + + var response = await SuggestAsync(admin); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("Settings, AI", await response.Content.ReadAsStringAsync()); + var config = await Api.Json(await fixture.Client().GetAsync("/api/settings/config")); + Assert.False((bool)config["aiConfigured"]!); + } + + [Fact] + public async Task Rules_that_produce_the_output_sample_come_back_checked() + { + var admin = await ConfiguredAsync(); + var model = new FakeModel(_ => $"```json\n{Right}\n```"); + AiModels.ModelOverride = _ => model; + + var result = await Api.Json(await SuggestAsync(admin)); + + Assert.True((bool)result["matchesTarget"]!, result.ToJsonString()); + Assert.Empty(result["differences"]!.AsArray()); + Assert.Contains("\"kind\":\"path\"", (string)result["mappingRules"]!); + // Both samples went to the model, after the same standing instructions every call gets. + var asked = Assert.Single(model.Asked); + Assert.Equal(MappingRulesPrompt.Instructions, asked.Instructions); + Assert.Contains("SO-1", asked.Message); + Assert.Contains("\"quantity\": 2", asked.Message); + Assert.True(asked.Json); + var config = await Api.Json(await fixture.Client().GetAsync("/api/settings/config")); + Assert.True((bool)config["aiConfigured"]!); + } + + [Fact] + public async Task Differences_are_reported_and_a_refinement_fixes_them() + { + var admin = await ConfiguredAsync(); + var model = new FakeModel(prompt => prompt.Message.Contains("did not produce") ? Right : AlmostRight); + AiModels.ModelOverride = _ => model; + + var first = await Api.Json(await SuggestAsync(admin)); + Assert.False((bool)first["matchesTarget"]!); + var difference = first["differences"]!.AsArray().Single()!; + Assert.Equal("items[0].quantity", (string)difference["target"]!); + Assert.Equal("the output sample has the number 2, the rules write the text \"2\"", (string)difference["reason"]!); + + // The editor sends back what it was given; the model sees those rules and what was wrong with them. + var second = await Api.Json(await SuggestAsync(admin, refine: true, rules: (string)first["mappingRules"]!)); + + Assert.True((bool)second["matchesTarget"]!, second.ToJsonString()); + Assert.Contains("items[0].quantity", model.Asked[1].Message); + } + + /// + /// The model may map a partner or global value it is told the name of; the value itself stays + /// here, filled in by the check's preview as it is when the mapping runs. + /// + [Fact] + public async Task Partner_and_global_values_reach_the_model_by_name_only() + { + var admin = await ConfiguredAsync(); + var setId = Api.Unique("depots").Replace(" ", "-"); + await Api.Json(await admin.PostAsJsonAsync("/api/globaladaptervaluessets", new + { + id = setId, name = setId, + values = new Dictionary { ["main"] = "DEPOT-AMM-9", ["vault"] = "do-not-send-1" }, + secretProperties = new[] { "vault" }, + })); + var partnerId = await Api.CreateAsync(admin, "/api/partners", new + { + name = Api.Unique("AI partner"), + adapterProperties = new Dictionary { ["SenderId"] = "SENDER-77" }, + }); + var model = new FakeModel(_ => $$""" + { "version": 1, "fields": [ + { "target": ["sender"], "from": { "kind": "partner", "key": "SenderId" } }, + { "target": ["depot"], "from": { "kind": "global", "setId": "{{setId}}", "key": "main" } } ] } + """); + AiModels.ModelOverride = _ => model; + + var result = await Api.Json(await admin.PostAsJsonAsync("/api/mappingsuggestions", new + { + mappingRules = """{ "version": 1 }""", + sourceDocument = """{ "order": 1 }""", + targetDocument = """{ "sender": "SENDER-77", "depot": "DEPOT-AMM-9" }""", + partnerId, + })); + + Assert.True((bool)result["matchesTarget"]!, result.ToJsonString()); + var asked = Assert.Single(model.Asked).Message; + Assert.Contains("- partner properties: SenderId", asked); + Assert.Contains($"- global value set \"{setId}\", keys: main", asked); + // Only the output sample the person pasted carries those values; nothing Bitween holds does. + var withoutSamples = asked[asked.IndexOf("", StringComparison.Ordinal)..]; + Assert.DoesNotContain("SENDER-77", withoutSamples); + Assert.DoesNotContain("DEPOT-AMM-9", withoutSamples); + Assert.DoesNotContain("vault", asked); + Assert.DoesNotContain("do-not-send-1", asked); + } + + [Fact] + public async Task Instructions_reach_the_model_and_the_answer_names_its_model() + { + var admin = await ConfiguredAsync(("Bitween.Ai.Model", "claude-sonnet-5-5")); + var model = new FakeModel(_ => Right); + AiModels.ModelOverride = _ => model; + + var result = await Api.Json(await admin.PostAsJsonAsync("/api/mappingsuggestions", new + { + mappingRules = """{ "version": 1 }""", sourceDocument = Source, targetDocument = Target, + instructions = "Quantities are always whole numbers.", + })); + + Assert.Equal("claude-sonnet-5-5", (string)result["model"]!); + Assert.Contains("\nQuantities are always whole numbers.".Replace("\n", Environment.NewLine), model.Asked.Single().Message); + } + + [Fact] + public async Task Oversized_samples_and_null_rules_are_refused_before_anything_is_asked() + { + var admin = await ConfiguredAsync(); + var model = new FakeModel(_ => Right); + AiModels.ModelOverride = _ => model; + + var huge = await admin.PostAsJsonAsync("/api/mappingsuggestions", new + { + mappingRules = """{ "version": 1 }""", sourceDocument = "{\"a\":\"" + new string('x', 200_001) + "\"}", targetDocument = Target, + }); + var nulls = await admin.PostAsJsonAsync("/api/mappingsuggestions", new + { + mappingRules = """{ "version": 1, "fields": null }""", sourceDocument = Source, targetDocument = Target, + }); + + Assert.Equal(HttpStatusCode.BadRequest, huge.StatusCode); + Assert.Contains("limited to 200 KB", await huge.Content.ReadAsStringAsync()); + Assert.Equal(HttpStatusCode.BadRequest, nulls.StatusCode); + Assert.Contains("be null; use [] for none", await nulls.Content.ReadAsStringAsync()); + Assert.Empty(model.Asked); + } + + /// A model writing an explicit null is an unreadable answer, asked for again - not a crash. + [Fact] + public async Task A_null_in_the_answer_is_asked_for_once_more() + { + var admin = await ConfiguredAsync(); + var model = new FakeModel(prompt => prompt.Message.Contains("could not be used") + ? Right + : """{ "version": 1, "fields": null, "lists": [] }"""); + AiModels.ModelOverride = _ => model; + + var result = await Api.Json(await SuggestAsync(admin)); + + Assert.True((bool)result["matchesTarget"]!); + Assert.Equal(2, model.Asked.Count); + } + + [Fact] + public async Task Provider_errors_are_reported_without_what_the_provider_said() + { + var admin = await ConfiguredAsync(); + AiModels.HandlerOverride = () => new FakeProvider((_, _) => new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("""{"type":"error","error":{"type":"invalid_request_error","message":"internal-detail-123"}}""", + Encoding.UTF8, "application/json"), + }); + + var text = await (await SuggestAsync(admin)).Content.ReadAsStringAsync(); + + Assert.Contains("Anthropic refused the request: it answered HTTP 400.", text); + Assert.DoesNotContain("internal-detail-123", text); + } + + [Fact] + public async Task An_unreadable_answer_is_asked_for_once_more_with_the_reason() + { + var admin = await ConfiguredAsync(); + var model = new FakeModel(prompt => prompt.Message.Contains("could not be used") + ? Right + : """{ "version": 1, "fields": [ { "target": ["reference"], "from": { "kind": "path", "path": "order.id" }, "when": "always" } ] }"""); + AiModels.ModelOverride = _ => model; + + var result = await Api.Json(await SuggestAsync(admin)); + + Assert.True((bool)result["matchesTarget"]!); + Assert.Equal(2, model.Asked.Count); + Assert.Contains("when", model.Asked[1].Message); + } + + [Fact] + public async Task A_sample_that_isnt_valid_is_refused_before_anything_is_asked() + { + var admin = await ConfiguredAsync(); + var model = new FakeModel(_ => Right); + AiModels.ModelOverride = _ => model; + + var response = await admin.PostAsJsonAsync("/api/mappingsuggestions", + new { mappingRules = """{ "version": 1 }""", sourceDocument = "{ not json", targetDocument = Target }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("source sample", await response.Content.ReadAsStringAsync()); + Assert.Empty(model.Asked); + } + + [Fact] + public async Task Anthropic_is_called_with_the_key_the_default_model_and_cached_instructions() + { + var admin = await ConfiguredAsync(); + var provider = new FakeProvider((_, _) => Ok(new + { + id = "msg_1", type = "message", role = "assistant", model = "claude-opus-5-5", + content = new[] { new { type = "text", text = Right } }, + stop_reason = "end_turn", stop_sequence = (string?)null, + usage = new { input_tokens = 10, output_tokens = 10 }, + })); + AiModels.HandlerOverride = () => provider; + + var result = await Api.Json(await SuggestAsync(admin)); + + Assert.True((bool)result["matchesTarget"]!, result.ToJsonString()); + var (request, body) = Assert.Single(provider.Received); + Assert.Equal("https://api.anthropic.com/v1/messages", request.RequestUri!.ToString()); + Assert.Equal("sk-test-key", request.Headers.GetValues("x-api-key").Single()); + var sent = JsonNode.Parse(body)!; + Assert.Equal("claude-opus-5-5", (string)sent["model"]!); + Assert.Equal("ephemeral", (string)sent["system"]![0]!["cache_control"]!["type"]!); + } + + [Fact] + public async Task A_key_Anthropic_refuses_is_reported_as_the_keys_fault() + { + var admin = await ConfiguredAsync(); + AiModels.HandlerOverride = () => new FakeProvider((_, _) => new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new StringContent("""{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}""", + Encoding.UTF8, "application/json"), + }); + + var response = await SuggestAsync(admin); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("API key under Settings, AI isn't valid", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task An_OpenAI_compatible_provider_is_called_at_its_own_address_for_JSON() + { + var admin = await ConfiguredAsync(("Bitween.Ai.Provider", "OpenAI"), ("Bitween.Ai.Model", "local-model"), + ("Bitween.Ai.BaseUrl", "https://ai.test/v1/")); + var provider = new FakeProvider((_, _) => Ok(new + { + id = "chatcmpl-1", @object = "chat.completion", created = 1760000000, model = "local-model", + choices = new[] { new { index = 0, message = new { role = "assistant", content = Right }, finish_reason = "stop" } }, + usage = new { prompt_tokens = 10, completion_tokens = 10, total_tokens = 20 }, + })); + AiModels.HandlerOverride = () => provider; + + var result = await Api.Json(await SuggestAsync(admin)); + + Assert.True((bool)result["matchesTarget"]!, result.ToJsonString()); + var (request, body) = Assert.Single(provider.Received); + Assert.Equal("https://ai.test/v1/chat/completions", request.RequestUri!.ToString()); + Assert.Equal("Bearer sk-test-key", request.Headers.Authorization!.ToString()); + var sent = JsonNode.Parse(body)!; + Assert.Equal("local-model", (string)sent["model"]!); + Assert.Equal("json_object", (string)sent["response_format"]!["type"]!); + } +} diff --git a/SW.Bitween.Sdk/Model/Settings.cs b/SW.Bitween.Sdk/Model/Settings.cs index 0bd1f24c..e4301cbb 100644 --- a/SW.Bitween.Sdk/Model/Settings.cs +++ b/SW.Bitween.Sdk/Model/Settings.cs @@ -25,6 +25,9 @@ public class SettingRow public bool Secret { get; set; } + /// The only values a string setting takes, for a dropdown. Null for free text. + public string[]? Choices { get; set; } + /// True when the stored value differs from the product default, i.e. a reset would do something. public bool Overridden { get; set; } diff --git a/SW.Bitween.UnitTests/Ai/DocumentDifferencesTests.cs b/SW.Bitween.UnitTests/Ai/DocumentDifferencesTests.cs new file mode 100644 index 00000000..b9d2746c --- /dev/null +++ b/SW.Bitween.UnitTests/Ai/DocumentDifferencesTests.cs @@ -0,0 +1,92 @@ +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.NativeAdapters.Mapper.Formats; +using SW.Bitween.Services.Ai; + +namespace SW.Bitween.UnitTests.Ai; + +/// +/// The comparison that tells the editor - and a model fixing its rules - whether generated rules do +/// what the output sample asks. +/// +[TestClass] +public class DocumentDifferencesTests +{ + static readonly JsonFormat Json = new(); + + static string[] Between(string expected, string actual) => + DocumentDifferences.Between(Json.Read(expected), Json.Read(actual)).Select(d => d.ToString()).ToArray(); + + /// The editor finds the rule to mark from the target, so it carries the path on its own. + [TestMethod] + public void A_difference_names_its_path_separately_from_the_reason() + { + var found = DocumentDifferences.Between(Json.Read("""{ "lines": [ { "qty": 1 } ] }"""), Json.Read("""{ "lines": [ { "qty": 2 } ] }""")); + + Assert.AreEqual("lines[0].qty", found.Single().Target); + Assert.AreEqual("the output sample has the number 1, the rules write the number 2", found.Single().Reason); + Assert.AreEqual("(root)", DocumentDifferences.Between(Json.Read("[]"), Json.Read("{}")).Single().Target); + } + + [TestMethod] + public void Whitespace_and_key_order_are_not_differences() + { + CollectionAssert.AreEqual(new string[0], + Between("""{ "a": 1, "b": { "c": "x" } }""", """{"b":{"c":"x"},"a":1.0}""")); + } + + /// A partner's parser notices 42 where "42" was wanted. + [TestMethod] + public void A_number_where_text_was_wanted_is_a_difference() + { + CollectionAssert.AreEqual(new[] { "qty: the output sample has the text \"42\", the rules write the number 42" }, + Between("""{ "qty": "42" }""", """{ "qty": 42 }""")); + } + + [TestMethod] + public void Missing_and_extra_fields_are_named_by_path() + { + var found = Between("""{ "order": { "id": "A", "ref": "R" } }""", """{ "order": { "id": "A", "note": "n" } }"""); + + CollectionAssert.AreEqual(new[] + { + "order.ref: missing; the output sample has the text \"R\"", + "order.note: not in the output sample, but the rules write the text \"n\"", + }, found); + } + + [TestMethod] + public void Lists_are_compared_entry_by_entry_and_by_length() + { + var found = Between("""{ "lines": [ { "sku": "A" }, { "sku": "B" } ] }""", """{ "lines": [ { "sku": "a" } ] }"""); + + CollectionAssert.AreEqual(new[] + { + "lines: the output sample has 2 entries, the rules write 1", + "lines[0].sku: the output sample has the text \"A\", the rules write the text \"a\"", + }, found); + } + + /// An output sample is often the partner's template, with the values left blank. + [TestMethod] + public void A_blank_value_in_the_output_sample_is_a_placeholder() + { + CollectionAssert.AreEqual(new string[0], + Between("""{ "name": "", "city": " ", "qty": null }""", """{ "name": "Acme", "city": "Roissy", "qty": 2 }""")); + + // The field still has to be written, and a value still can't stand where a list or object was asked for. + CollectionAssert.AreEqual(new[] + { + "name: missing; the output sample has the text \"\"", + "city: the output sample has the text \" \", the rules write a list of 0", + }, Between("""{ "name": "", "city": " " }""", """{ "city": [] }""")); + } + + [TestMethod] + public void A_wholly_wrong_document_is_described_in_a_bounded_list() + { + var expected = "{" + string.Join(",", Enumerable.Range(0, 50).Select(i => $"\"f{i}\": {i}")) + "}"; + + Assert.AreEqual(DocumentDifferences.Limit, Between(expected, "{}").Length); + } +} diff --git a/SW.Bitween.UnitTests/Ai/FrozenValuesTests.cs b/SW.Bitween.UnitTests/Ai/FrozenValuesTests.cs new file mode 100644 index 00000000..6a70e003 --- /dev/null +++ b/SW.Bitween.UnitTests/Ai/FrozenValuesTests.cs @@ -0,0 +1,58 @@ +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using SW.Bitween.NativeAdapters.Mapper; +using SW.Bitween.NativeAdapters.Mapper.Formats; +using SW.Bitween.Services.Ai; + +namespace SW.Bitween.UnitTests.Ai; + +/// +/// A constant the source doesn't have is what a fixed value is for; one it does have is data frozen +/// to match the one sample, and has to count as a difference even when the output matches. +/// +[TestClass] +public class FrozenValuesTests +{ + static readonly ValueNode Source = new JsonFormat().Read(""" + { "o": { "first": "Lina", "last": "Haddad", "currency": "N", "lines": [ { "sku": "A-100" } ] } } + """); + + static string[] In(string rules) => + FrozenValues.In(JsonConvert.DeserializeObject(rules)!, Source).Select(d => d.ToString()).ToArray(); + + [TestMethod] + public void Source_data_appended_as_text_is_frozen() + { + CollectionAssert.AreEqual(new[] + { + "fullName: the concat text \"Haddad\" is in the source sample, so it would be the same for every document; read it from the source", + }, In(""" + { "fields": [ { "target": ["fullName"], "from": { "kind": "path", "path": "o.first" }, + "transform": { "fn": "concat", "with": " Haddad" } } ] } + """)); + } + + [TestMethod] + public void A_fixed_value_the_source_holds_is_frozen_wherever_it_is_written() + { + CollectionAssert.AreEqual(new[] + { + "items[].sku: the fixed value \"a-100\" is in the source sample, so it would be the same for every document; read it from the source", + }, In(""" + { "lists": [ { "over": "o.lines", "target": ["items"], + "fields": [ { "target": ["sku"], "from": { "kind": "fixed", "value": "a-100" } } ] } ] } + """)); + } + + [TestMethod] + public void A_constant_the_source_doesnt_have_and_short_coincidences_are_fine() + { + CollectionAssert.AreEqual(new string[0], In(""" + { "fields": [ { "target": ["currency"], "from": { "kind": "fixed", "value": "JOD" } }, + { "target": ["flag"], "from": { "kind": "fixed", "value": "N" } }, + { "target": ["qty"], "from": { "kind": "fixed", "value": 4 } }, + { "target": ["name"], "from": { "kind": "path", "path": "o.last" } } ] } + """)); + } +} diff --git a/SW.Bitween.UnitTests/Ai/MappingRulesPromptTests.cs b/SW.Bitween.UnitTests/Ai/MappingRulesPromptTests.cs new file mode 100644 index 00000000..5e62c813 --- /dev/null +++ b/SW.Bitween.UnitTests/Ai/MappingRulesPromptTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.NativeAdapters.Mapper; +using SW.Bitween.NativeAdapters.Mapper.Formats; +using SW.Bitween.Services.Ai; + +namespace SW.Bitween.UnitTests.Ai; + +/// +/// A model's answer becomes rules only if the mapper would read it the same way: unknown properties +/// are refused rather than dropped, and the editor's formats always win. +/// +[TestClass] +public class MappingRulesPromptTests +{ + static readonly MappingRules Editor = new() + { + SourceFormat = "csv", + TargetFormat = "json", + SourceCsv = new CsvOptions { Delimiter = ";", HasHeader = true }, + }; + + [TestMethod] + public void An_answer_in_a_code_fence_is_read() + { + var rules = MappingRulesPrompt.Parse(""" + Here are the rules: + ```json + { "version": 1, "root": { "over": "", "fields": [ { "target": ["sku"], "from": { "kind": "path", "path": "SKU" } } ] } } + ``` + """, Editor); + + Assert.AreEqual("SKU", rules.Root!.Fields[0].From.Path); + Assert.AreEqual(ValueSourceKind.Path, rules.Root.Fields[0].From.Kind); + } + + [TestMethod] + public void The_editors_formats_replace_whatever_the_model_wrote() + { + var rules = MappingRulesPrompt.Parse(""" + { "version": 1, "sourceFormat": "json", "targetFormat": "xml", "sourceCsv": { "delimiter": "," }, + "fields": [ { "target": ["a"], "from": { "kind": "fixed", "value": 1 } } ] } + """, Editor); + + Assert.AreEqual("csv", rules.SourceFormat); + Assert.AreEqual("json", rules.TargetFormat); + Assert.AreEqual(";", rules.SourceCsv!.Delimiter); + } + + /// A property the mapper doesn't know is a rule the model meant and the mapper would silently skip. + [TestMethod] + public void An_unknown_property_is_refused_with_its_name() + { + var refused = Assert.ThrowsException(() => MappingRulesPrompt.Parse(""" + { "version": 1, "fields": [ { "target": ["a"], "from": { "kind": "path", "path": "x" }, "condition": "x > 1" } ] } + """, Editor)); + + StringAssert.Contains(refused.Message, "condition"); + } + + [TestMethod] + public void A_transforms_arguments_are_not_unknown_properties() + { + var rules = MappingRulesPrompt.Parse(""" + { "version": 1, "fields": [ { "target": ["a"], "from": { "kind": "path", "path": "x" }, + "transform": { "fn": "substring", "start": 0, "length": 3 } } ] } + """, Editor); + + Assert.AreEqual(3, (int)rules.Fields[0].Transform!.Args["length"]); + } + + [TestMethod] + public void An_answer_that_is_not_rules_says_why() + { + StringAssert.Contains(Assert.ThrowsException(() => MappingRulesPrompt.Parse("I can't help with that.", Editor)).Message, "no JSON"); + StringAssert.Contains(Assert.ThrowsException(() => MappingRulesPrompt.Parse("""{ "version": 2 }""", Editor)).Message, "version"); + StringAssert.Contains(Assert.ThrowsException(() => MappingRulesPrompt.Parse(""" + { "version": 1, "lists": [ { "over": "x", "target": ["a"], "fields": null } ] } + """, Editor)).Message, "can't be null"); + StringAssert.Contains(Assert.ThrowsException(() => MappingRulesPrompt.Parse(""" + { "version": 1, "root": { "over": "" }, "fields": [ { "target": ["a"], "from": { "kind": "fixed" } } ] } + """, Editor)).Message, "root"); + } + + /// The editor stores what this writes, so it has to read back as the same rules. + [TestMethod] + public void Serialized_rules_are_the_editors_wire_format() + { + var rules = MappingRulesPrompt.Parse(""" + { "version": 1, "fields": [ { "target": ["status"], "from": { "kind": "rootPath", "path": "s" }, + "lookup": { "table": { "N": "New" } }, "type": "string" } ], + "lists": [ { "over": "", "target": ["rows"], "fields": [] }, { "target": ["fixedOnly"], "fixed": [] } ] } + """, new MappingRules()); + + var json = MappingRulesPrompt.Serialize(rules); + + StringAssert.Contains(json, "\"kind\":\"rootPath\""); + StringAssert.Contains(json, "\"type\":\"string\""); + // A lookup's keys are data, and keep their case. + StringAssert.Contains(json, "\"N\":\"New\""); + // An empty "over" walks the document; an absent one walks nothing. Both survive. + StringAssert.Contains(json, "\"over\":\"\""); + Assert.AreEqual(1, json.Split("\"over\"").Length - 1); + } + + [TestMethod] + public void A_refinement_message_carries_the_rules_and_what_was_wrong() + { + var message = MappingRulesPrompt.Message(Editor, "SKU;QTY\nA1;2", "[{\"sku\":\"A1\"}]", + previous: (new MappingRules(), ["sku: missing; the output sample has the text \"A1\""])); + + StringAssert.Contains(message, "\"delimiter\":\";\""); + StringAssert.Contains(message, "\nSKU;QTY\nA1;2\n".Replace("\n", Environment.NewLine)); + StringAssert.Contains(message, "- sku: missing"); + Assert.IsFalse(message.Contains(""), "no instructions were given"); + } + + [TestMethod] + public void The_message_carries_instructions_and_names_but_never_values() + { + var context = new MappingContext + { + Partner = new Dictionary { ["SenderId"] = "ACME-7", ["ApiPassword"] = "__private__" }, + Globals = new Dictionary> + { + ["regions"] = new Dictionary { ["north"] = "N-1", ["default"] = "D-0" }, + ["keys"] = new Dictionary { ["token"] = "__private__" }, + }, + }; + + var message = MappingRulesPrompt.Message(Editor, "a;b", "[]", MappingRulesPrompt.NamesOf(context), + "C means Cancelled."); + + StringAssert.Contains(message, "- partner properties: SenderId"); + StringAssert.Contains(message, "- global value set \"regions\", keys: default, north"); + StringAssert.Contains(message, "" + Environment.NewLine + "C means Cancelled."); + foreach (var leaked in new[] { "ACME-7", "N-1", "D-0", "ApiPassword", "token", "\"keys\"" }) + Assert.IsFalse(message.Contains(leaked), $"'{leaked}' reached the message"); + } +} diff --git a/SW.Bitween.UnitTests/SettingsCatalogTests.cs b/SW.Bitween.UnitTests/SettingsCatalogTests.cs index 04c0810a..8b661cad 100644 --- a/SW.Bitween.UnitTests/SettingsCatalogTests.cs +++ b/SW.Bitween.UnitTests/SettingsCatalogTests.cs @@ -24,6 +24,7 @@ public void Sections_are_the_ones_the_page_lists_in_the_order_it_lists_them() "Single sign-on (OpenID Connect)", "Adapters", "Marketplace", + "AI", "Mail server", "Reliability & jobs", "Messaging", @@ -138,4 +139,42 @@ public void The_mail_server_is_editable_and_checks_what_it_is_given() mail["Bitween.MailServer.Host"].Write(target, ""); Assert.IsNull(target.Bitween.MailServer.Host); } + + /// + /// Every AI feature reads the provider per call, so all of it is an edit; only the key is secret, + /// and the provider is a choice rather than free text. + /// + [TestMethod] + public void The_ai_provider_is_editable_and_checks_what_it_is_given() + { + var ai = SettingsCatalog.All.Where(d => d.Section == "AI").ToDictionary(d => d.Key); + + Assert.IsTrue(ai.Values.All(d => d.Access == SettingAccess.Editable)); + CollectionAssert.AreEqual(new[] { "Bitween.Ai.ApiKey" }, ai.Values.Where(d => d.Secret).Select(d => d.Key).ToArray()); + CollectionAssert.AreEqual(new[] { "Anthropic", "OpenAI" }, ai["Bitween.Ai.Provider"].Choices.ToArray()); + Assert.AreEqual("Anthropic", SettingsService.DefaultOf(ai["Bitween.Ai.Provider"])); + + var target = new SettingsTarget(new BitweenOptions(), new ThemeOptions()); + Assert.IsFalse(target.Bitween.Ai.IsSet, "no key, no AI"); + ai["Bitween.Ai.ApiKey"].Write(target, " sk-test "); + Assert.IsTrue(target.Bitween.Ai.IsSet); + Assert.AreEqual("claude-opus-5-5", target.Bitween.Ai.ResolvedModel); + + // Spelled the way the list spells it, whatever case it arrived in. + ai["Bitween.Ai.Provider"].Write(target, "openai"); + Assert.AreEqual("OpenAI", target.Bitween.Ai.Provider); + Assert.IsFalse(target.Bitween.Ai.IsSet, "OpenAI has no default model"); + Assert.ThrowsException(() => ai["Bitween.Ai.Provider"].Write(target, "Gemini")); + + Assert.ThrowsException(() => ai["Bitween.Ai.BaseUrl"].Write(target, "localhost:11434")); + // Plain http would send the key in the clear anywhere but this machine. + Assert.ThrowsException(() => ai["Bitween.Ai.BaseUrl"].Write(target, "http://ollama.lan:11434/v1/")); + ai["Bitween.Ai.BaseUrl"].Write(target, "http://localhost:11434/v1/"); + Assert.AreEqual("http://localhost:11434/v1/", target.Bitween.Ai.BaseUrl); + + // A misspelt provider from configuration is off, not sent down the OpenAI path. + target.Bitween.Ai.Model = "some-model"; + target.Bitween.Ai.Provider = "Anthropc"; + Assert.IsFalse(target.Bitween.Ai.IsSet); + } } diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index 7fd392e8..507fe2eb 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -794,4 +794,28 @@ export interface ApiClient { ruleErrors: { target: string; reason: string }[]; error: string | null; }>; + + /** + * Asks the AI provider under Settings, AI for rules that turn the source sample into the target + * sample. The server previews them before answering, so `matchesTarget` and `differences` say + * how close they came. `refine` fixes `mappingRules` instead of writing new ones; either way, + * their formats are kept. `instructions` go to the model as they are. Partner and global + * values go by name only, never by value. + */ + suggestMappingRules( + input: { + mappingRules: string; + sourceDocument: string; + targetDocument: string; + partnerId?: number | null; + refine?: boolean; + instructions?: string; + }, + signal?: AbortSignal, + ): Promise<{ + mappingRules: string; + differences: { target: string; reason: string }[]; + matchesTarget: boolean; + model: string | null; + }>; } diff --git a/SW.Bitween.Web/ClientApp/src/api/http/appConfig.ts b/SW.Bitween.Web/ClientApp/src/api/http/appConfig.ts index 5b58bd9d..dd5f2797 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/appConfig.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/appConfig.ts @@ -16,6 +16,8 @@ export interface AppConfig { mailServerConfigured?: boolean; /** Whether the sign-in page can offer "Forgot password?": a mail server and a public address. */ passwordResetAvailable?: boolean; + /** Whether Settings, AI has a provider key, so AI features are offered. */ + aiConfigured?: boolean; /** Effective brand values (any stored override already applied), keyed like `ThemeOptions`. */ theme?: Record; /** The same keys as configured, before any override - lets us tell "set" from "untouched". */ diff --git a/SW.Bitween.Web/ClientApp/src/api/http/mappers.ts b/SW.Bitween.Web/ClientApp/src/api/http/mappers.ts index 8552fc04..9f2eaca2 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/mappers.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/mappers.ts @@ -1,5 +1,5 @@ import type { ApiClient } from "../client"; -import { post } from "./request"; +import { post, request } from "./request"; interface RawMapperPreviewResponse { outputJson: string | null; @@ -13,6 +13,13 @@ interface RawMappingPreviewResponse { error: string | null; } +interface RawMappingSuggestionResponse extends RawMappingPreviewResponse { + mappingRules: string; + differences: { target: string; reason: string }[] | null; + matchesTarget: boolean; + model: string | null; +} + export const mapperMethods = { async previewMapping(input: { scribanTemplate: string; @@ -41,4 +48,33 @@ export const mapperMethods = { error: res.error ?? null, }; }, + + async suggestMappingRules( + input: { + mappingRules: string; + sourceDocument: string; + targetDocument: string; + partnerId?: number | null; + refine?: boolean; + instructions?: string; + }, + signal?: AbortSignal, + ): Promise<{ + mappingRules: string; + differences: { target: string; reason: string }[]; + matchesTarget: boolean; + model: string | null; + }> { + const res = await request("/mappingsuggestions", { + method: "POST", + body: input, + signal, + }); + return { + mappingRules: res.mappingRules, + differences: res.differences ?? [], + matchesTarget: res.matchesTarget, + model: res.model ?? null, + }; + }, } satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 25dc82f1..139100db 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -1351,6 +1351,8 @@ export interface SettingRow { /** The stored value, or null for a secret - those never leave the server. */ value: string | null; secret: boolean; + /** The only values a string setting takes, shown as a dropdown. Absent for free text. */ + choices?: string[] | null; /** The stored value differs from the product default, so a reset would do something. */ overridden: boolean; /** Whether the effective value is non-empty - the only way a secret reveals it is set. */ diff --git a/SW.Bitween.Web/ClientApp/src/components/nativeMapper/GenerateWithAi.tsx b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/GenerateWithAi.tsx new file mode 100644 index 00000000..76d32b8f --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/GenerateWithAi.tsx @@ -0,0 +1,403 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2, Sparkles } from "lucide-react"; +import { api, getAppConfig } from "../../api"; +import { keys } from "../../api/queryKeys"; +import { useSessionCan } from "../../auth/useSessionCan"; +import { parseSample } from "../../lib/nativeMapper/documentTree"; +import { changedRows, differencesByRow, rowFor, ruleCount } from "../../lib/nativeMapper/aiTally"; +import type { AiTally } from "../../lib/nativeMapper/rulesReducer"; +import { useRules, useRulesDispatch } from "../../lib/nativeMapper/rulesEditorHooks"; +import { fromWire, toWire } from "../../lib/nativeMapper/serialize"; +import { sideOptions, type MappingRules } from "../../lib/nativeMapper/types"; +import { FormatButton } from "../ui/FormatButton"; +import { Popover } from "../ui/Popover"; +import { Dialog } from "../ui/overlays"; +import { Button, FormError } from "../ui/basics"; + +/** + * Asks the AI provider under Settings, AI for the rules that turn the source sample into + * the output sample, and puts them in place of the rules there are. + * + * Replaces rather than merges: the model sees both samples whole, so its rules are a + * complete answer, and mixing them with half of someone else's would be neither. The + * note it leaves says what was replaced, with Undo beside it. + * + * Opens a dialog rather than acting at once, because it needs both samples and the + * output one otherwise lives inside "Build from a sample" - and because it is the place + * to say what is sent, and to say it before anything is. + */ +export function GenerateWithAi({ partnerId }: { partnerId: number | null }) { + const { ai } = useRules(); + const [open, setOpen] = useState(false); + // Kept while the editor is open, so Fix with AI asks with the same notes. + const [instructions, setInstructions] = useState(""); + const run = useAiRun(partnerId, instructions, () => setOpen(false)); + + return ( + <> + + + {/* Closing the dialog leaves the request running; this is where it can still be stopped. */} + {run.pending && !open && ( + + )} + + {run.error && !run.pending && !open && ( + + {run.error} + + )} + + {ai && !run.pending && run.start(true)} />} + + {open && ( + setOpen(false)} + /> + )} + + ); +} + +type AiRun = ReturnType; + +/** + * One request to the server at a time, with the seconds it has taken so far and a way to + * stop waiting for it. + * + * Cancelling stops the editor waiting, not the model: the server finishes the call it + * started, and its answer is dropped. + */ +function useAiRun(partnerId: number | null, instructions: string, onApplied: () => void) { + const { rules, sourceSample, targetSample } = useRules(); + const dispatch = useRulesDispatch(); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [seconds, setSeconds] = useState(0); + const running = useRef(null); + // What the editor holds now, as opposed to what this render's closure saw when a run started. + const latest = useRef({ rules, sourceSample, targetSample }); + useEffect(() => { + latest.current = { rules, sourceSample, targetSample }; + }, [rules, sourceSample, targetSample]); + + useEffect(() => { + if (!pending) return; + const started = Date.now(); + setSeconds(0); + const tick = setInterval(() => setSeconds(Math.floor((Date.now() - started) / 1000)), 1000); + return () => clearInterval(tick); + }, [pending]); + + const start = async (refine: boolean) => { + running.current?.abort(); + const abort = new AbortController(); + running.current = abort; + const started = Date.now(); + setPending(true); + setError(null); + try { + const result = await api.suggestMappingRules( + { + mappingRules: JSON.stringify(toWire(rules)), + sourceDocument: sourceSample, + targetDocument: targetSample, + partnerId, + refine, + instructions: instructions.trim() || undefined, + }, + abort.signal, + ); + if (abort.signal.aborted) return; + // The answer was checked against what was sent. Anything edited since - a rule, either + // sample - would be overwritten, and the note would vouch for a sample that is gone. + const now = latest.current; + if (now.rules !== rules || now.sourceSample !== sourceSample || now.targetSample !== targetSample) { + setError("The mapping or a sample changed while the AI was working, so its answer was dropped. Generate again."); + return; + } + const next = fromWire(JSON.parse(result.mappingRules) as MappingRules); + dispatch({ + type: "APPLY_AI_RULES", + rules: next, + tally: { + matchesTarget: result.matchesTarget, + differences: result.differences, + byRow: differencesByRow(next, result.differences), + changed: changedRows(rules, next), + before: ruleCount(rules), + after: ruleCount(next), + model: result.model, + seconds: Math.max(1, Math.round((Date.now() - started) / 1000)), + }, + }); + onApplied(); + } catch (e) { + if (!abort.signal.aborted) setError((e as Error).message); + } finally { + if (running.current === abort) { + running.current = null; + setPending(false); + } + } + }; + + const cancel = () => { + running.current?.abort(); + running.current = null; + setPending(false); + }; + + return { start, cancel, pending, error, seconds }; +} + +function AiDialog({ + instructions, + onInstructions, + run, + onClose, +}: { + instructions: string; + onInstructions: (text: string) => void; + run: AiRun; + onClose: () => void; +}) { + const config = useQuery({ queryKey: keys.appConfig, queryFn: getAppConfig }); + const canSetUp = useSessionCan("settings.edit"); + const { rules, sourceSample, targetSample } = useRules(); + const dispatch = useRulesDispatch(); + + const source = useMemo( + () => parseSample(sourceSample, rules.sourceFormat, "source", rules.sourceCsv, sideOptions(rules, "source")), + [sourceSample, rules], + ); + const target = useMemo( + () => parseSample(targetSample, rules.targetFormat, "target", rules.targetCsv, sideOptions(rules, "target")), + [targetSample, rules], + ); + const ready = sourceSample.trim() !== "" && targetSample.trim() !== "" && !source.error && !target.error; + const existing = ruleCount(rules); + + if (config.data && !config.data.aiConfigured) + return ( + +
+

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

+ {canSetUp ? ( + // A new tab, so the mapping being built here isn't left behind unsaved. + + Open Settings, AI in a new tab + + ) : ( +

Ask an administrator to set it up.

+ )} +
+
+ ); + + return ( + +
+
+ dispatch({ type: "SET_SOURCE_SAMPLE", text })} + /> + dispatch({ type: "SET_TARGET_SAMPLE", text })} + /> +
+ +