••••••••
diff --git a/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx
index 6f3a64a5..cbdff7cc 100644
--- a/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx
+++ b/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx
@@ -1,4 +1,4 @@
-import { cleanup, screen, within } from "@testing-library/react";
+import { cleanup, screen, waitFor, within } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { afterEach, describe, expect, it } from "vitest";
import type { RetentionStatus, SettingRow } from "../../../api";
@@ -354,6 +354,36 @@ describe("the settings page", () => {
expect(dialog.getByRole("button", { name: "Save changes" })).toBeEnabled();
});
+ it("offers a setting with fixed choices as a dropdown, and saves the one picked", async () => {
+ let posted: unknown;
+ const { user } = renderApp(`/settings?section=AI`, {
+ handlers: [
+ http.get(apiPath("/settings"), () =>
+ HttpResponse.json([
+ {
+ ...editable({ key: "Bitween.Ai.Provider", section: "AI", label: "Provider", kind: "string", defaultValue: "Anthropic" }),
+ choices: ["Anthropic", "OpenAI"],
+ },
+ ]),
+ ),
+ http.post(apiPath("/settings/Bitween.Ai.Provider"), async ({ request }) => {
+ posted = await request.json();
+ return new HttpResponse(null, { status: 204 });
+ }),
+ ...pageHandlers.slice(1),
+ ],
+ });
+
+ const provider = await screen.findByRole("combobox", { name: "Provider" });
+ expect(provider).toHaveValue("Anthropic");
+ expect(within(provider).getAllByRole("option").map((o) => o.textContent)).toEqual(["Anthropic", "OpenAI"]);
+
+ await user.selectOptions(provider, "OpenAI");
+ await user.click(screen.getByRole("button", { name: "Save changes" }));
+
+ await waitFor(() => expect(posted).toEqual({ value: "OpenAI" }));
+ });
+
it("keeps an invalid retry schedule as an unsaved draft when the backend refuses it", async () => {
let posted: unknown;
server.use(
diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs
index f03fbee0..00a24ad6 100644
--- a/SW.Bitween.Web/Startup.cs
+++ b/SW.Bitween.Web/Startup.cs
@@ -153,6 +153,7 @@ public void ConfigureServices(IServiceCollection services)
services.AddScoped
();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddSingleton();
diff --git a/docs/admin-ui.md b/docs/admin-ui.md
index cc0f6389..d059a2ba 100644
--- a/docs/admin-ui.md
+++ b/docs/admin-ui.md
@@ -79,7 +79,7 @@ their next save.
| Page | What it does |
|---|---|
| **Team** | Members: add with an invite (emailed when a mail server is set, otherwise a link to send) or a password, resend an expired invite, assign roles, set a password, unlock, disable and remove. Invited members show as *Invited* until they choose a password. Roles: a permission matrix with a live preview of the navigation the role would see. |
-| **Settings** | Runtime settings by section, including branding with an instant preview. Values owned by the environment are shown read-only. Documents & storage shows the bucket's deletion rules and what the retention settings do, and asks before saving a change to them. **Mail server** is the server Email channels send through. **Notification channels** lists channels and who uses each, and links to any legacy notifiers. Each channel opens on a page of its own: Email, Microsoft Teams and Slack first, any other adapter one click away, a test message, and its recent sends. A role with the notification permissions but not Settings, such as the built-in Member and Viewer, finds only the Notification channels section here. |
+| **Settings** | Runtime settings by section, including branding with an instant preview. Values owned by the environment are shown read-only. Documents & storage shows the bucket's deletion rules and what the retention settings do, and asks before saving a change to them. **Mail server** is the server Email channels send through. **AI** is the provider and API key AI features use ([AI](configuration.md#ai)). **Notification channels** lists channels and who uses each, and links to any legacy notifiers. Each channel opens on a page of its own: Email, Microsoft Teams and Slack first, any other adapter one click away, a test message, and its recent sends. A role with the notification permissions but not Settings, such as the built-in Member and Viewer, finds only the Notification channels section here. |
| **Audit trail** | Every configuration change with its old and new values. |
The **Dashboard** opens from the logo, and includes failures still to act on and the retry chains that keep failing. **Profile** lets members change their display name and password.
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 1ebc4b35..ff1f2ee7 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -223,6 +223,7 @@ A value that breaks a rule is refused with a validation error whose message says
| `POST /api/adapterdrafts/{id}/try` | `adapter-source.edit` | Call a command: `{ settings, command, input }` |
| `POST /api/adapterdrafts/{id}/publish` | `adapter-source.operate` | Publish a version, not made current: `{ version, releaseNotes, settings }`. `version` is `major`, `minor`, `patch` (the default) or an exact version. |
| `POST /api/mappingpreviews` | signed in, no permission checked | Preview rules-based mapping |
+| `POST /api/mappingsuggestions` | `subscriptions.create` or `subscriptions.edit` | Generate rules-based mapping rules with AI: `{ mappingRules, sourceDocument, targetDocument, partnerId, refine, instructions }`. See [Generate with AI](mapping.md#generate-with-ai). |
| `POST /api/mappers` | `subscriptions.edit` | Preview a legacy Scriban template |
Adapter descriptions are cached per node. Publishing, promoting or withdrawing through the API clears them on every node; a package published straight to storage shows its old properties for up to a minute.
diff --git a/docs/configuration.md b/docs/configuration.md
index 75f6a94b..4f5621f0 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -189,6 +189,9 @@ Settings are stored in the `Settings` table and edited on the Settings page.
| `Bitween.RegistryInstanceId` | Marketplace | Yes | empty, set by registering |
| `Bitween.RegistryInstanceToken` | Marketplace | Yes, secret | empty, set by registering |
| `Bitween.RegistryUsageReports` | Marketplace | Yes | `false` |
+| `Bitween.Ai.Provider` | AI | Yes | `Anthropic` |
+| `Bitween.Ai.ApiKey` | AI | Yes, secret | empty, AI features off |
+| `Bitween.Ai.Model`, `BaseUrl` | AI | Yes | empty |
| `Bitween.MailServer.Host`, `Port`, `UseTls`, `CertificateThumbprint`, `Username`, `From`, `FromName` | Mail server | Yes | port `587`, `UseTls` `true`, the rest empty |
| `Bitween.MailServer.Password` | Mail server | Yes, secret | empty |
| `Bitween.MaxRetryAfterSeconds` | Reliability & jobs | Yes | `21600`, six hours |
@@ -207,11 +210,24 @@ The retry poll and retention crons are validated before they're saved, and savin
The **Mail server** section is the one SMTP server every Email notification channel sends through, so a channel only names its recipients. Member invites, password resets and account notices go through it too; see [Invites and password resets](security.md#invites-and-password-resets). It is read on each send. Until a host and a From address are set, Email channels can't send, and their page says so. The connection follows the [SMTP adapter's](adapters.md#nativesmtphandler) rules: a password is only sent over TLS, `CertificateThumbprint` trusts exactly that certificate, and a server that doesn't answer within 30 seconds fails the send. The password is a secret setting, so storing it needs `Bitween:SettingsEncryptionKey`. Without one, set it through configuration (`Bitween__MailServer__Password`).
+### AI
+
+The **AI** section is the provider every AI feature calls; the first is [Generate with AI](mapping.md#generate-with-ai) in the mapping editor. It is read on each call, and AI features stay off until an API key is set.
+
+- **Provider** is `Anthropic` or `OpenAI`. Any other value, such as a misspelling in configuration, leaves AI features off. `OpenAI` also covers anything that speaks its chat completions API at **API address**: an Azure OpenAI resource (`https://your-resource.openai.azure.com/openai/v1/`), a local Ollama (`http://localhost:11434/v1/`) or a proxy.
+- **Model** empty uses `claude-opus-5-5` with Anthropic. OpenAI needs one named, or the Azure deployment's name.
+- **API key** is a secret setting, so storing it needs `Bitween:SettingsEncryptionKey`.
+- **API address** must be `https`, except plain `http` to this machine (`localhost`), so the key is never sent in the clear. Calls go through the same address check as adapters: never to link-local or cloud metadata addresses, and not to private or loopback ones while `Bitween:BlockPrivateNetworkAddresses` is on.
+
+When the provider refuses or fails a call, the message gives its HTTP status, not the text it sent back.
+
+Sample documents an AI feature sends go to this provider, with the names - never the values - of partner properties and global values. Secret ones are not named.
+
The page also has a **Notification channels** section. Channels are records rather than settings: they have their own table, each opens on a page of its own, and the `notifiers` permissions cover them rather than `settings`. See [Notification channels](retries-and-alerts.md#notification-channels).
`GET /api/retention` and `POST /api/retention/preview` describe what the retention settings do, as saved or as proposed. The Settings page uses them. `POST /api/retention/aggregation` says whether exchanges would be removed before an aggregation on the given schedules rolls them up; the aggregation page uses it.
-`GET /api/settings/config` needs no sign-in. It returns the Microsoft sign-in values, whether email and password sign-in is disabled, whether RabbitMQ management is configured, and the theme. The sign-in page loads it.
+`GET /api/settings/config` needs no sign-in. It returns the Microsoft sign-in values, whether email and password sign-in is disabled, whether RabbitMQ management is configured, whether AI features are configured (`aiConfigured`), and the theme. The sign-in page loads it.
## Helm values
diff --git a/docs/mapping.md b/docs/mapping.md
index 42584f09..40c89485 100644
--- a/docs/mapping.md
+++ b/docs/mapping.md
@@ -248,10 +248,29 @@ The rules editor has three panels.
- **Output.** A tree of target fields and lists. Each row picks a source kind and opens the transform, lookup and type steps. Lists have filters and fixed entries. A new list gets its content from **+ Value**, **+ Field** or **+ List**. Choosing + Value makes a list of plain values, whose value is a row of its own.
- **Preview.** The server's output for the current rules, with failing rows marked.
-**Build from a sample** reads a target sample and adds rules for fields not yet mapped, filling in sources it can recognise. **Preview as** chooses a partner for resolving partner values. Save with Ctrl+S or Cmd+S.
+**Build from a sample** reads a target sample and adds rules for fields not yet mapped, filling in sources it can recognise. **Generate with AI** writes every rule from both samples; see below. **Preview as** chooses a partner for resolving partner values. Save with Ctrl+S or Cmd+S.
The toolbar's format chip, as **JSON → XML**, opens what the mapping reads and writes: each side's format, and its settings for [CSV](#csv) (separator, header, the byte-order mark Excel wants), [fixed-width text](#fixed-width-text) (a table of columns, lines to skip, trimming, and on the written side each column's alignment and padding) and [Excel](#excel) (the sheet and the header).
+### Generate with AI
+
+**Generate with AI** asks the provider under [Settings, AI](configuration.md#ai) for the rules that turn a source sample into an output sample. It opens a dialog with both samples, which are the editor's own, and an optional **Instructions** box for what the samples don't show, such as codes a lookup needs that the sample doesn't contain. Until Settings has an API key, the dialog says so and links there.
+
+- What is sent: both samples, the instructions, and the names, never the values, of the previewed partner's properties and the global values. Secret properties aren't named either. A rule the model writes from a name is filled in with the real value when the mapping runs, like any other.
+- The generated rules replace the editor's rules as one step. The formats and their settings are kept as the editor has them. The toolbar then says what was replaced, which model wrote the rules and how long it took, with **Undo** beside it. Rules it wrote differently light up for a moment.
+- Before answering, the server previews the rules against the source sample and compares the result with the output sample. The toolbar says **Matches the output sample**, or lists the differences. Each one marks its row, and clicking it in the list brings the row into view.
+- A fixed value, or the text of a `concat`, `replace` or `defaultIfEmpty`, that the source sample holds as data counts as a difference even when the output matches: it would be the same for every document. A constant the source doesn't have, such as a currency code, is fine.
+- A blank value in the output sample (empty, only spaces, or null) is a placeholder: any value written there counts as a match. The field itself still has to be written.
+- **Fix with AI**, under the differences, sends the editor's rules back with what was wrong, and asks for corrected ones.
+- Samples are limited to 200 KB each and instructions to 4,000 characters. A few records are enough.
+- If a sample or a rule changes while it runs, the answer is dropped and the dialog says so, since it was checked against what was sent.
+- While it runs, the dialog counts the seconds, usually 10 to 30, and **Cancel** stops waiting. The model finishes the call it started; its answer is dropped.
+- One generation is one model call. A second is made only when the first answer isn't readable rules, with the reason.
+- Values from the original document of a response subscription aren't used: the model can't see them. Add those rules by hand.
+- Nothing is saved until you save.
+
+`POST /api/mappingsuggestions` does the work: `{ mappingRules, sourceDocument, targetDocument, partnerId, refine, instructions }`, replying with `mappingRules`, the preview's `outputDocument`, `ruleErrors` and `error`, `differences` (each a `target` path and a `reason`), `matchesTarget` and `model`.
+
## Legacy JSON mapper
`NativeJSONMapper` renders a [Scriban](https://github.com/scriban/scriban) template over the JSON payload and stores the result as JSON.