['""])(?!<)[^'""\r\n]{1,200}\k", RegexOptions.Compiled); + static readonly Regex QueryString = new(@"\?[^\s'""<>]+", RegexOptions.Compiled); + static readonly Regex LongNumber = new(@"(? +/// Explain this failure: a real failed exchange goes to the model with its values masked, the +/// answer comes back in three parts, and the request is in the audit trail. +/// +[Collection("Http")] +public class FailureExplanationTests(HttpFixture fixture) : IDisposable +{ + public void Dispose() + { + AiModels.ModelOverride = null; + using var admin = fixture.AdminAsync().GetAwaiter().GetResult(); + admin.DeleteAsync("/api/settings/Bitween.Ai.ApiKey").GetAwaiter().GetResult(); + } + + sealed class FakeModel(string answer) : IAiModel + { + public ListAsked { get; } = new(); + + public Task Complete(AiPrompt prompt) + { + Asked.Add(prompt); + return Task.FromResult(answer); + } + } + + /// An exchange that fails in mapping: "maybe" can't become a yes/no value. + async Task<(HttpClient Admin, string XchangeId)> FailedExchangeAsync() + { + var (admin, urlName, _, partnerId, subscriptionId) = await Api.GatewayAsync(fixture); + await Api.Json(await admin.PostAsJsonAsync($"/api/subscriptions/{subscriptionId}/savemapper", new + { + mapperId = "NativeMapper", + mapperProperties = new[] + { + new + { + key = "MappingRules", + value = """{ "version": 1, "fields": [ { "target": ["flag"], "from": { "kind": "path", "path": "order.flag" }, "type": "boolean" } ] }""", + }, + }, + })); + var key = await Api.NewKeyAsync(admin); + await Api.SetKeysAsync(admin, partnerId, ("default", key)); + using var _ = await Api.CallSyncAsync(fixture, urlName, key, """{ "order": { "flag": "maybe", "owner": "lina@acme.example" } }"""); + + var found = await Api.Json(await admin.GetAsync( + $"/api/xchanges?filter={Uri.EscapeDataString($"SubscriptionId:1:{subscriptionId}")}&size=1")); + return (admin, (string)found["result"]![0]!["id"]!); + } + + async Task ConfigureAsync(HttpClient admin) + { + await Api.Json(await admin.PostAsJsonAsync("/api/settings/Bitween.Ai.ApiKey", new { value = "sk-test-key" })); + var live = fixture.App.Services.GetService(typeof(BitweenOptions)) as BitweenOptions; + for (var waited = 0; live!.Ai.ApiKey != "sk-test-key"; waited += 50) + { + Assert.True(waited < 10_000, "the AI key never took effect"); + await Task.Delay(50); + } + } + + [Fact] + public async Task A_failed_exchange_is_explained_from_masked_data_and_audited() + { + var (admin, xchangeId) = await FailedExchangeAsync(); + await ConfigureAsync(admin); + var model = new FakeModel("""{ "summary": "S", "cause": "C", "fix": "F" }"""); + AiModels.ModelOverride = _ => model; + + var result = await Api.Json(await admin.PostAsJsonAsync("/api/xchangeexplanations", new { xchangeId })); + + Assert.Equal("S", (string)result["summary"]!); + Assert.Equal("F", (string)result["fix"]!); + var sent = Assert.Single(model.Asked).Message; + Assert.Equal(sent, (string)result["sent"]!); + Assert.Contains("Failed step: mapping", sent); + Assert.Contains("\"flag\": \"\"", sent); + Assert.DoesNotContain("maybe", sent); + Assert.DoesNotContain("lina@acme.example", sent); + + var audited = await fixture.InDbAsync(db => db.Set ().AsNoTracking() + .AnyAsync(a => a.Action == OperatorAction.ExplainFailure && a.Target == xchangeId)); + Assert.True(audited, "the explanation wasn't recorded"); + } + + [Fact] + public async Task Without_AI_set_up_nothing_is_read_or_sent() + { + var (admin, xchangeId) = await FailedExchangeAsync(); + + var response = await admin.PostAsJsonAsync("/api/xchangeexplanations", new { xchangeId }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("Settings, AI", await response.Content.ReadAsStringAsync()); + } +} diff --git a/SW.Bitween.UnitTests/Ai/MaskingTests.cs b/SW.Bitween.UnitTests/Ai/MaskingTests.cs new file mode 100644 index 00000000..89f066ad --- /dev/null +++ b/SW.Bitween.UnitTests/Ai/MaskingTests.cs @@ -0,0 +1,131 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.Services.Ai; + +namespace SW.Bitween.UnitTests.Ai; + +/// +/// What "Explain this failure" sends: structure and wording, never a value. Each test names a value +/// and checks it can't be found in what would go to the provider. +/// +[TestClass] +public class MaskingTests +{ + [TestMethod] + public void A_json_document_keeps_its_keys_and_types_and_loses_its_values() + { + var masked = new Masking().Document(""" + { "customer": { "name": "Lina Haddad", "vip": true, "balance": 1200.5, "note": "", "fax": null } } + """, "application/json")!; + + StringAssert.Contains(masked, "\"name\": \"\""); + StringAssert.Contains(masked, "\"vip\": \" \""); + StringAssert.Contains(masked, "\"balance\": \" \""); + StringAssert.Contains(masked, "\"note\": \"\""); + StringAssert.Contains(masked, "\"fax\": null"); + Assert.IsFalse(masked.Contains("Lina") || masked.Contains("1200")); + } + + [TestMethod] + public void An_xml_document_is_masked_attributes_and_all() + { + var masked = new Masking().Document(""" """, "text/xml")!; + + StringAssert.Contains(masked, " Atlas Trading <text> "); + Assert.IsFalse(masked.Contains("SO-77") || masked.Contains("Atlas")); + } + + [TestMethod] + public void A_long_list_shows_its_first_entries_and_how_many_more() + { + var masked = new Masking().Document("""{ "lines": [ {"sku":"A1"}, {"sku":"A2"}, {"sku":"A3"}, {"sku":"A4"}, {"sku":"A5"} ] }""", null)!; + + StringAssert.Contains(masked, "<2 more entries like these>"); + Assert.IsFalse(masked.Contains("A5")); + } + + ///A file without a header row must not send its first record as column names. + [TestMethod] + public void A_csv_without_a_header_keeps_none_of_its_first_row() + { + var masked = new Masking().Document("Lina Haddad,lina@acme.example,SO-77\nOmar Said,omar@acme.example,SO-78", "text/csv")!; + + foreach (var leaked in new[] { "Lina", "lina@acme.example", "SO-77", "Omar", "SO-78" }) + Assert.IsFalse(masked.Contains(leaked), $"'{leaked}' was sent: {masked}"); + StringAssert.Contains(masked, ""); + } + + [TestMethod] + public void A_csv_with_a_header_has_every_row_masked() + { + var masked = new Masking().Document("name,email\nLina Haddad,lina@acme.example", "text/csv")!; + + Assert.IsFalse(masked.Contains("Lina") || masked.Contains("lina@acme.example"), masked); + StringAssert.Contains(masked, " "); + } + + /// Nothing is sent of a document that can't be masked; guessing would send it whole. + [TestMethod] + public void A_document_that_isnt_json_xml_or_csv_isnt_sent_at_all() + { + Assert.IsNull(new Masking().Document("Dear Lina, your parcel 4471 is late.", "text/plain")); + } + + ///An HTTP adapter puts the partner's response body in the error text. + [TestMethod] + public void A_response_body_inside_an_error_is_masked_as_a_document() + { + var masked = new Masking().Error(""" + 400: BadRequest: {"error":"Customer Lina Haddad has no account","code":"ACC_MISSING"} + at SW.Bitween.NativeAdapters.NativeHttpHandler.Handle() in NativeHttpHandler.cs:line 170 + """); + + StringAssert.Contains(masked, "400: BadRequest:"); + StringAssert.Contains(masked, "\"error\": \"\""); + StringAssert.Contains(masked, "NativeHttpHandler.Handle()"); + Assert.IsFalse(masked.Contains("Lina") || masked.Contains("ACC_MISSING")); + } + + [TestMethod] + public void An_error_loses_document_values_quotes_emails_and_long_numbers() + { + var masking = new Masking(); + masking.Document("""{ "ref": "SO-5001", "city": "Amman" }""", "application/json"); + + var masked = masking.Error( + "Could not deliver SO-5001 to Amman for ops@acme.example: value 'blue-77' is not allowed, card 4111222233334444, see https://p.example/x?token=abc"); + + foreach (var leaked in new[] { "SO-5001", "Amman", "ops@acme.example", "blue-77", "4111222233334444", "token=abc" }) + Assert.IsFalse(masked.Contains(leaked), $"'{leaked}' was not masked: {masked}"); + StringAssert.Contains(masked, "Could not deliver"); + StringAssert.Contains(masked, "is not allowed"); + } + + /// A frame keeps its file and line; the folders above it describe the server, not the failure. + [TestMethod] + public void Stack_frames_keep_their_file_but_not_the_servers_folders() + { + var masked = new Masking().Error(""" + System.UriFormatException: Invalid URI + at SW.Bitween.NativeAdapters.DynamicHttpProxy.GetClient(String fullUrl) in /Users/someone/Work/Bitween-api/SW.Bitween.NativeAdapters/HttpHandler/DynamicHttpProxy.cs:line 22 + at SW.Bitween.XchangeService.Process(XchangeMessage message) in C:\agent\_work\1\s\SW.Bitween.Api\Services\XchangeService.cs:line 703 + """); + + StringAssert.Contains(masked, "GetClient(String fullUrl) in DynamicHttpProxy.cs:line 22"); + StringAssert.Contains(masked, "in XchangeService.cs:line 703"); + Assert.IsFalse(masked.Contains("/Users/someone") || masked.Contains("_work"), masked); + } + + [TestMethod] + public void The_answer_is_read_as_json_or_kept_as_prose() + { + var json = FailurePrompt.Parse("""```json {"summary":"S","cause":"C","fix":"F"} ```"""); + Assert.AreEqual(("S", "C", "F"), (json.Summary, json.Cause, json.Fix)); + + var steps = FailurePrompt.Parse("""{"summary":"S","cause":{"step":"mapping"},"fix":["Fix the Url.","Retry it."]}"""); + Assert.AreEqual("Fix the Url.\nRetry it.", steps.Fix); + Assert.AreEqual("""{"step":"mapping"}""", steps.Cause); + + var prose = FailurePrompt.Parse("The partner timed out; retry."); + Assert.AreEqual(("The partner timed out; retry.", "", ""), (prose.Summary, prose.Cause, prose.Fix)); + } +} diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index 0bbe0eba..92701281 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -753,6 +753,15 @@ export interface ApiClient { * AUTO_RETRY_SCHEDULED when an auto-retry is already pending. */ retryExchange(id: string, opts: { reset: boolean }): Promise<{ id: string }>; + /** + * Explains a failed exchange in plain words through the AI provider under Settings, AI. The + * error and documents go masked - every value replaced by its type - and `sent` is exactly what + * went. Recorded in the audit log. + */ + explainExchange( + xchangeId: string, + signal?: AbortSignal, + ): Promise<{ summary: string; cause: string; fix: string; model: string | null; sent: string }>; /** Retries many; exchanges with a pending auto-retry are skipped, not failed. */ bulkRetryExchanges(selection: BulkRetrySelection, opts: { reset: boolean }): Promise; previewBulkRetry(selection: BulkRetrySelection, opts: { reset: boolean }): Promise ; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts index 16b82ffc..9cea8e6e 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts @@ -14,7 +14,7 @@ import type { ScheduledRetryRow, } from "../types"; import { partnerMethods } from "./partners"; -import { download, get, post } from "./request"; +import { download, get, post, request } from "./request"; import { searchyQueryString } from "./searchQuery"; // --- backend shapes (camelCase over the wire) --- @@ -326,6 +326,13 @@ export const exchangeMethods = { }; }, + explainExchange( + xchangeId: string, + signal?: AbortSignal, + ): Promise<{ summary: string; cause: string; fix: string; model: string | null; sent: string }> { + return request("/xchangeexplanations", { method: "POST", body: { xchangeId }, signal }); + }, + async retryExchange(id: string, { reset }: { reset: boolean }): Promise<{ id: string }> { await post(`/xchanges/${id}/retry`, { reason: "Manual retry", reset }); // Retry.cs returns null - look up the retry it just created (the newest diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx index 11898d8e..b8aa0777 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx @@ -15,6 +15,7 @@ import { useSubscriptionsCache } from "../../components/config/lookups"; import { RetryDialog, type JourneyStage } from "./shared"; import { journeyStages } from "./journey"; import { RetryChain } from "./RetryChain"; +import { ExplainFailure } from "./ExplainFailure"; import { hasRetryChain, newestAttempt, retryTreeQuery } from "./retryTree"; import { keys } from "../../api/queryKeys"; @@ -479,6 +480,13 @@ export function ExchangeDrawer({ x }: { x: ExchangeRow }) { )} + {/* Outside the operate gate: an explanation changes nothing, so seeing the exchange is enough. */} + {(x.status === "failed" || x.status === "badResponse") && ( + ++ )} + {confirming && (+ >; + +/** + * "Explain this failure": the AI provider under Settings, AI reads the failed exchange and says + * what happened, why, and what to change. + * + * Offered to anyone who can see the exchange - it changes nothing. What goes to the provider is + * masked on the server, every value replaced by its type, and the dialog shows exactly what went. + */ +export function ExplainFailure({ xchangeId }: { xchangeId: string }) { + const [open, setOpen] = useState(false); + return ( + <> + + {open && setOpen(false)} />} + > + ); +} + +function ExplainDialog({ xchangeId, onClose }: { xchangeId: string; onClose: () => void }) { + const config = useQuery({ queryKey: keys.appConfig, queryFn: getAppConfig }); + const canSetUp = useSessionCan("settings.edit"); + const [result, setResult] = useState (null); + const [error, setError] = useState (null); + const [pending, setPending] = useState(false); + const [seconds, setSeconds] = useState(0); + const [showSent, setShowSent] = useState(false); + const running = useRef (null); + + 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]); + + // Stops waiting when the dialog closes, so a late answer has nowhere to land. + useEffect(() => () => running.current?.abort(), []); + + const explain = async () => { + const abort = new AbortController(); + running.current = abort; + setPending(true); + setError(null); + try { + const answer = await api.explainExchange(xchangeId, abort.signal); + if (!abort.signal.aborted) setResult(answer); + } 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); + }; + + if (config.data && !config.data.aiConfigured) + return ( + + ); + + return ( + + ); +} + +function Section({ title, children }: { title: string; children: string }) { + return ( + ++ ); +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/explainFailure.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/explainFailure.test.tsx new file mode 100644 index 00000000..b1bd64c4 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/explainFailure.test.tsx @@ -0,0 +1,81 @@ +import { screen, within } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; + +/** + * Explain this failure, from the exchange panel. What is masked and sent is the C# suite's + * business (MaskingTests, FailureExplanationTests); this is the button, the dialog and the answer. + */ + +const EXCHANGE_ID = "3f2a9c1e-0000-4000-8000-000000000002"; +const NONE = { result: [], totalCount: 0 }; + +/** GET /xchanges row (`RawXchangeRow`): finished, not successful - failed. */ +const FAILED = { + id: EXCHANGE_ID, subscriptionId: 12, subscriptionName: "Orders in", documentId: 3, documentName: "SHIPMENT_ORDER", + mapperId: "NativeMapper", status: false, exception: "flag: can't read{title}
+{children}
+as a boolean", + finishedOn: "2026-09-24T08:00:02Z", startedOn: "2026-09-24T08:00:00Z", + inputFileName: "input.json", outputFileName: null, responseFileName: null, + inputFileSize: 20, outputFileSize: 0, responseFileSize: 0, inputKey: "in-1", outputKey: null, responseKey: null, + promotedProperties: {}, retryFor: null, aggregationXchangeId: null, responseBad: null, correlationId: null, + partnerId: null, scheduledRetryOn: null, hasRetry: false, +}; + +function openFailed(aiConfigured: boolean, onExplain = () => {}) { + return renderApp(`/exchanges?ids=${EXCHANGE_ID}`, { + config: { aiConfigured }, + handlers: [ + http.get(apiPath("/xchanges"), () => HttpResponse.json({ result: [FAILED], totalCount: 1 })), + http.get(apiPath("/bitweendocs"), () => HttpResponse.json({ key: "in-1", data: '{"order":{"flag":"maybe"}}' })), + http.get(apiPath("/subscriptions"), () => HttpResponse.json(NONE)), + http.get(apiPath("/partners"), () => HttpResponse.json(NONE)), + http.get(apiPath("/documents"), () => HttpResponse.json(NONE)), + http.get(apiPath("/xchanges/summary"), () => + HttpResponse.json({ counts: { processing: 0, success: 0, badResponse: 0, failed: 1 }, buckets: [], bucketMinutes: 0 }), + ), + http.post(apiPath("/xchangeexplanations"), async ({ request }) => { + expect(await request.json()).toEqual({ xchangeId: EXCHANGE_ID }); + onExplain(); + return HttpResponse.json({ + summary: "The flag can't be read as yes or no.", + cause: "The rule for flag converts to boolean, and the partner sent text.", + fix: "Add a lookup from the partner's words to true and false.", + model: "claude-sonnet-5-5", + sent: 'Failed step: mapping\n{ "order": { "flag": " " } }', + }); + }), + ], + }); +} + +describe("Explain this failure", { timeout: 20000 }, () => { + it("explains a failed exchange in three parts and shows exactly what was sent", async () => { + let asked = 0; + const { user } = openFailed(true, () => asked++); + await screen.findByRole("complementary", { name: `Exchange ${EXCHANGE_ID}` }); + + await user.click(screen.getByRole("button", { name: /Explain this failure/ })); + const dialog = await screen.findByRole("dialog", { name: "Explain this failure" }); + // Nothing goes until asked, and the dialog says what will. + expect(asked).toBe(0); + expect(within(dialog).getByText(/Every value is/)).toBeInTheDocument(); + await user.click(within(dialog).getByRole("button", { name: /^Explain$/ })); + + expect(await within(dialog).findByText("The flag can't be read as yes or no.")).toBeInTheDocument(); + expect(within(dialog).getByText(/Add a lookup/)).toBeInTheDocument(); + expect(within(dialog).getByText(/claude-sonnet-5-5/)).toBeInTheDocument(); + await user.click(within(dialog).getByRole("button", { name: "See what was sent" })); + expect(within(dialog).getByLabelText("What was sent to the AI provider")).toHaveTextContent('"flag": " "'); + }); + + it("says where to turn it on until Settings has a provider key", async () => { + const { user } = openFailed(false); + await screen.findByRole("complementary", { name: `Exchange ${EXCHANGE_ID}` }); + + await user.click(screen.getByRole("button", { name: /Explain this failure/ })); + + const dialog = await screen.findByRole("dialog", { name: "Explain this failure" }); + expect(within(dialog).getByRole("link", { name: "Open Settings, AI in a new tab" })).toHaveAttribute("target", "_blank"); + }); +}); diff --git a/docs/admin-ui.md b/docs/admin-ui.md index 4fdf6e39..7e64b5ac 100644 --- a/docs/admin-ui.md +++ b/docs/admin-ui.md @@ -12,7 +12,7 @@ The sign-in page offers email and password, Microsoft, an [OpenID Connect provid | Page | What it does | |---|---| -| **Exchanges** | Search and filter every exchange with live refresh, optionally showing only the latest attempt of each retry chain. A filter's operator is picked on its chip: *is* or *is not*, *contains* or *does not contain*; subscription, partner and information type can pick several (*is any of*, *is none of*), and any number of promoted properties can be filtered on at once. Expand a row to see each stage's file, formatted or raw, plus the exception, promoted properties and the retry chain. Retry one exchange, a selection, or every match of the current filters after previewing the plan. Create an exchange by hand. | +| **Exchanges** | Search and filter every exchange with live refresh, optionally showing only the latest attempt of each retry chain. A filter's operator is picked on its chip: *is* or *is not*, *contains* or *does not contain*; subscription, partner and information type can pick several (*is any of*, *is none of*), and any number of promoted properties can be filtered on at once. Expand a row to see each stage's file, formatted or raw, plus the exception, promoted properties and the retry chain. On a failed exchange, **Explain this failure** asks the AI provider for what went wrong and what to change ([Explaining a failure](configuration.md#explaining-a-failure)). Retry one exchange, a selection, or every match of the current filters after previewing the plan. Create an exchange by hand. | | **Scheduled retries** | Automatic retries waiting to run, with the policy that scheduled each one. Run one now. | | **Queue health** | Live RabbitMQ lanes, backlogs, dead letters, alerts and orphaned queues. | diff --git a/docs/api-reference.md b/docs/api-reference.md index 4f6ea40f..d974cbd3 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -84,6 +84,7 @@ All need the global values permissions: `global-values.view` to read, `.create`, | `GET /api/xchanges/retrytree?id=` | `exchanges.view` | The retry chain any attempt belongs to | | `POST /api/xchanges` | signed in, no permission checked | Create an exchange by hand | | `POST /api/xchanges/{id}/retry` | `exchanges.operate` | `{ reason, reset }` | +| `POST /api/xchangeexplanations` | `exchanges.view` | Explain a failed exchange with AI: `{ xchangeId }`, replying with `summary`, `cause`, `fix`, `model`, and `sent` (exactly what went to the provider, masked). Recorded as an `explain-failure` operator action. See [Explaining a failure](configuration.md#explaining-a-failure). | | `POST /api/xchanges/bulkretrypreview` | `exchanges.operate` | Plan a bulk retry without running it | | `POST /api/xchanges/bulkretry` | `exchanges.operate` | Run a bulk retry | | `POST /api/xchanges/export` | `exchanges.view` | The selected exchanges' files as one zip | diff --git a/docs/configuration.md b/docs/configuration.md index c4b03756..3c432098 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -216,7 +216,7 @@ The **Mail server** section is the one SMTP server every Email notification chan ### AI -The **AI** section is the provider every AI feature calls; the first is [Generate with AI](mapping.md#generate-with-ai) in the mapping editor. It is read on each call, and AI features stay off until an API key is set. +The **AI** section is the provider every AI feature calls; they are [Generate with AI](mapping.md#generate-with-ai) in the mapping editor and [Explain this failure](#explaining-a-failure) on a failed exchange. It is read on each call, and AI features stay off until an API key is set. - **Provider** is `Anthropic` or `OpenAI`. Any other value, such as a misspelling in configuration, leaves AI features off. `OpenAI` also covers anything that speaks its chat completions API at **API address**: an Azure OpenAI resource (`https://your-resource.openai.azure.com/openai/v1/`), a local Ollama (`http://localhost:11434/v1/`) or a proxy. - **Model** empty uses `claude-opus-5-5` with Anthropic. OpenAI needs one named, or the Azure deployment's name. @@ -227,6 +227,18 @@ When the provider refuses or fails a call, the message gives its HTTP status, no Sample documents an AI feature sends go to this provider, with the names - never the values - of partner properties and global values. Secret ones are not named. +#### Explaining a failure + +**Explain this failure** on a failed exchange sends this, and only this, to the provider: + +- the error, which step failed, the subscription's kind and adapters, and the names of the handler's settings, never their values (they hold partner and global values already filled in, some of them credentials); +- the retry policy's groups, the attempt's place in its retry chain, why it won't be retried and any scheduled retry, and the first line of up to three earlier attempts' errors; +- the input, mapped and response documents, with every value replaced by its type: `"name": "Lina"` goes as `"name": " "`. Long lists show their first three entries. A CSV's every row is masked, a header row included, so a file without one can't send its first record as column names. A document that isn't JSON, XML or CSV, or is over 1 MB, isn't sent at all. + +Error text is masked too: a document inside it, such as a partner's response body, is masked as a document; every value the exchange's documents hold is taken out wherever it appears; quoted text, numbers of four or more digits, email addresses and URL query strings are replaced; and a stack frame keeps its file name and line but not the server's folders. + +The answer says what happened, the most likely cause and what to change. **See what was sent** shows exactly the masked text that went. Anyone who can view the exchange can ask, and each request is recorded as an `explain-failure` operator action in the audit trail before anything is sent, so a call that fails afterwards is on record too. + 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.