diff --git a/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step3-sender-connection.png b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step3-sender-connection.png new file mode 100644 index 0000000..c554813 Binary files /dev/null and b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step3-sender-connection.png differ diff --git a/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step3-sender-processing.png b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step3-sender-processing.png new file mode 100644 index 0000000..d0b6381 Binary files /dev/null and b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step3-sender-processing.png differ diff --git a/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step4-csv-to-xml.png b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step4-csv-to-xml.png new file mode 100644 index 0000000..ed25d86 Binary files /dev/null and b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step4-csv-to-xml.png differ diff --git a/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step4-xml-to-json.png b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step4-xml-to-json.png new file mode 100644 index 0000000..2a5e57b Binary files /dev/null and b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step4-xml-to-json.png differ diff --git a/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step5-prepare-for-lookup.png b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step5-prepare-for-lookup.png new file mode 100644 index 0000000..7038091 Binary files /dev/null and b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step5-prepare-for-lookup.png differ diff --git a/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step6-cost-lookup.png b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step6-cost-lookup.png new file mode 100644 index 0000000..bc7ca6e Binary files /dev/null and b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step6-cost-lookup.png differ diff --git a/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step7-groovy-editor.png b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step7-groovy-editor.png new file mode 100644 index 0000000..7df0b72 Binary files /dev/null and b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step7-groovy-editor.png differ diff --git a/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step8-finished-iflow.png b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step8-finished-iflow.png new file mode 100644 index 0000000..53128b5 Binary files /dev/null and b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step8-finished-iflow.png differ diff --git a/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step8-write-curated.png b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step8-write-curated.png new file mode 100644 index 0000000..81bbb86 Binary files /dev/null and b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step8-write-curated.png differ diff --git a/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step9-monitor-completed.png b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step9-monitor-completed.png new file mode 100644 index 0000000..f2a2472 Binary files /dev/null and b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step9-monitor-completed.png differ diff --git a/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step9-run-summary.png b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step9-run-summary.png new file mode 100644 index 0000000..380bf02 Binary files /dev/null and b/tutorials/dev-garage-aws-pipeline-revenue-management/assets/aws-s3-step9-run-summary.png differ diff --git a/tutorials/dev-garage-aws-pipeline-revenue-management/dev-garage-aws-pipeline-revenue-management.md b/tutorials/dev-garage-aws-pipeline-revenue-management/dev-garage-aws-pipeline-revenue-management.md new file mode 100644 index 0000000..dadee5a --- /dev/null +++ b/tutorials/dev-garage-aws-pipeline-revenue-management/dev-garage-aws-pipeline-revenue-management.md @@ -0,0 +1,560 @@ +--- +parser: v2 +author_name: rojo-ashishkuvvarapu +author_profile: https://github.com/rojo-ashishkuvvarapu +keywords: tutorial +auto_validation: true +time: 60 +tags: [ tutorial>intermediate, software-product>sap-integration-suite, software-product>sap-business-technology-platform ] +primary_tag: software-product>sap-integration-suite +--- + +# SAP TechEd Developer Garage: AWS Pipeline for Revenue Management + + Build an integration flow in SAP Integration Suite that polls a sales file from Amazon S3, looks up unit costs in a reference file in S3, calculates profit margin, and writes the enriched data to a second S3 bucket in another region. + +## Prerequisites + +- An SAP BTP account or trial account with **SAP Integration Suite** and the **Cloud Integration** capability activated +- [Assign roles to your user for Cloud Integration](https://help.sap.com/docs/integration-suite/sap-integration-suite/configuring-user-access?locale=en-US&state=PRODUCTION&version=CLOUD#loio2c6214a3228e4b4cba207f49fda92ed4__section_xbf_glz_l2c), including the `Integration_Developer` role collection +- The file `From the Instructor.txt`, which your instructor shares with you through a secure medium. It is not part of this repository. It contains the **AWS Access Key ID** and **Secret Access Key** for the S3 buckets, plus the bucket, region, and folder names +- Familiarity with the SAP Integration Suite interface helps. No Groovy experience is required; the script is provided. + +You don't need an AWS account. Your instructor prepares the S3 buckets, the reference file, and the access keys, and places one sales file in the shared `daily/` folder. The file stays there, so your flow reprocesses it on every poll. + +## You will learn + +- How to build an integration flow from scratch in SAP Integration Suite +- How to store AWS credentials as Security Material +- How the AmazonWebServices adapter polls, reads, and writes files in Amazon S3 +- How to enrich messages with a reference-data lookup and calculate profit margin in Groovy +- How to deploy an integration flow and follow a message step by step in the Monitor + +## Scenario + +**Company:** A retailer with regional stores + +**Challenge:** Every store drops its sales extract (CSV) into an Amazon S3 "landing" bucket. Finance wants profit margin per sale, but the sales file only contains prices. Unit costs live in a separate reference file. + +**Data sources:** + +| Source | Location | Content | +| :--- | :--- | :--- | +| Sales file | `regional-landing-bkt/daily/` (region `eu-north-1`) | One row per sale: store, region, product, quantity, price | +| Reference file | `regional-landing-bkt/reference/product_master.csv` (region `eu-north-1`) | Unit cost of each of the 15 products | +| Curated output | `curated-corp/profit-margins/` (region `us-west-1`) | The enriched sales data, written by your flow | + +**Goal:** Calculate profit margin, a number that exists nowhere in the source file, for every sale, write the enriched data to the curated bucket, and summarize the run. + +The AmazonWebServices adapter is the hero of this flow. You use it three ways: polling for new files, reading one file on demand, and writing the result. + +The flow you'll build: + +```text +S3 sender (poll daily/, keep the file and process it again) + -> CSV to XML Converter (CSV -> structured XML, using xml_schema.xsd) + -> XML to JSON Converter (XML -> JSON) + -> Prepare for Lookup (Content Modifier: ingestion metadata, stash the sales JSON) + -> Cost LookUp (Request Reply -> S3 read: product_master.csv) + -> Margin Calculation (Groovy: profit margin per sale + run summary) + -> S3 Write (Request Reply -> S3 write: curated-corp/profit-margins/) + -> Show Run Summary (Content Modifier: run summary in the body) + -> End +``` + +| Part of the flow | What it does | +| :--- | :--- | +| **S3 sender** | Polls the `daily/` folder for the sales CSV and leaves the file in place, so it is processed again on every poll | +| **CSV to XML Converter** | Gives each row named fields, using the `xml_schema.xsd` schema | +| **XML to JSON Converter** | Turns the XML into JSON, the shape the rest of the flow works with | +| **Prepare for Lookup** | Records when and where the file was ingested, and sets the sales data aside so it survives the next step | +| **Cost LookUp** | Reads `product_master.csv` from `regional-landing-bkt/reference/` | +| **Margin Calculation** | Adds unit cost, gross margin, and margin percentage to each sale, and builds the run summary | +| **S3 Write** | Writes the enriched JSON to `curated-corp/profit-margins/` | +| **Show Run Summary** | Puts the run summary in the message, so it's the last thing you see in the Monitor | + +--- + +### Create a Package and an Integration Flow + +In SAP Integration Suite, go to **Design → Integrations and APIs** and click **Create**. + +| Field | Value | +| :--- | :--- | +| Name | `AWS Pipeline for Revenue Management` | +| Technical Name | *(auto-populated)* | +| Short Description | `Ingest and enrich sales data in Amazon S3` | + +Click **Save**. + +On the package's **Artifacts** tab, click **Add → Integration Flow**. Keep **Create** selected and fill in: + +| Field | Value | +| :--- | :--- | +| Name | `AWS Pipeline for Revenue Management` | +| ID | *(auto-populated)* | + +Click **OK**, open the integration flow, and click **Edit**. The canvas starts with a **Sender**, a **Start** event, an **End** event, and a **Receiver**. + +> **Tip:** You add steps from the palette at the top of the canvas. Converters, Content Modifier, and Groovy Script are under **Message Transformers**; Request Reply is under **Call → External Call**; extra receivers are under **Participants**. Menu names can differ slightly between tenant versions. + +Click **Save** regularly as you build. + +--- + +### Create Security Material + +Your instructor shares `From the Instructor.txt` with you through a secure medium. It holds the `access key id` and `secret access key`. Store them once; every AWS connection in your flow refers to them by name. + +Go to **Monitor → Integrations and APIs → Security Material**. Create two secure parameters, one after the other, with **Create → Secure Parameter**: + +| Name | Secure Parameter | +| :--- | :--- | +| `S3Key` | AWS Access Key ID | +| `S3Secret` | AWS Secret Access Key | + +For each one, enter the value in **Secure Parameter** and again in **Repeat Secure Parameter**, then click **Deploy**. + +> **Caution:** Use the exact names `S3Key` and `S3Secret`, including capitals. The AWS adapters look the secure parameters up by these names (as aliases); a mismatch makes the deployment fail. Never paste the keys into chat, slides, screenshots, or a shared file. + +--- + +### Connect the S3 Sender + +Drag a connection from the **Sender** to the **Start** event. Choose adapter **AmazonWebServices** and message protocol **S3**. + +Fill in the adapter settings: + +| Tab | Field | Value | +| :--- | :--- | :--- | +| Connection | Host | `amazonaws.com` | +| Connection | Region Name | `eu-north-1 Europe (Stockholm)` (choose from the dropdown) | +| Connection | Bucket Name | `regional-landing-bkt` | +| Connection | Polling Interval (in ms) | `300000` (every 5 minutes; each poll is a run) | +| Connection | Authentication Type | `Access and Secret Key` | +| Connection | Access Key Alias | `S3Key` | +| Connection | Secret Key Alias | `S3Secret` | +| Connection | Requester Pays | Unchecked | +| Connection | Enable Legacy Connectivity | Unchecked | +| Processing | Directory | `daily` | +| Processing | File Name | `sales*.csv` | +| Processing | Sorting | `None` | +| Processing | Include Sub-Directories | Unchecked | +| Processing | Execute Post-Processing When Message Successfully Processed | Unchecked | +| Processing | Post-Processing | `Keep File and Process Again` | +| Processing | Optional S3 Metadata | Leave empty | +| Processing | S3 Pre-Signed URL | Unchecked | +| Duplicate Handling | Duplicate Check | Unchecked | + +![S3 sender — Connection tab](./assets/aws-s3-step3-sender-connection.png) + +![S3 sender — Processing tab](./assets/aws-s3-step3-sender-processing.png) + +> **Caution:** Choose the region from the **Region Name** dropdown. A region label typed or pasted into a field makes the adapter fail with an `UnknownHostException`. + +The file is never moved or deleted, so every poll processes the same sales file again. You may see the design-time warning "AWS_SND_HTTP_S3_PULL may not pass Text message to CSV To XML Converter". It's an advisory and doesn't stop the flow. + +### Convert the CSV to JSON + +#### CSV to XML Converter + +Add a **CSV to XML Converter** right after the **Start** event. On its **Processing** tab, click **Select** next to **XML Schema** and upload `xml_schema.xsd`. If you don't have the file, save the XML below as `xml_schema.xsd` and upload that. + +| Field | Value | +| :--- | :--- | +| XML Schema | `xml_schema.xsd` | +| Path to Target Element in XSD | `/Records/Row` | +| Record Marker in CSV | *(leave blank)* | +| Field Separator in CSV | `Comma` | +| Exclude First Line Header | ✓ | + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +![CSV to XML Converter — Processing tab](./assets/aws-s3-step4-csv-to-xml.png) + +> The converter matches the CSV's header row against the XSD element names exactly, so the sales file's header must be lowercase: `transaction_id,store_id,region,product_id,product_name,category,quantity,unit_price,total_amount,sale_timestamp,payment_method`. The files your instructor's feeder generates already are. + +#### XML to JSON Converter + +Add an **XML to JSON Converter** after it: + +| Field | Value | +| :--- | :--- | +| Use Namespace Mapping | *(unchecked; the XSD declares no namespace)* | +| Suppress JSON Root Element | *(unchecked; keeps the `{"Records":{"Row":[...]}}` shape the script expects)* | +| Other settings | *(defaults)* | + +![XML to JSON Converter — Processing tab](./assets/aws-s3-step4-xml-to-json.png) + +This converter writes every value as a JSON string. The Groovy script in Step 7 turns the numbers back into real numbers. + +### Prepare for the Lookup + +Add a **Content Modifier** after the XML to JSON Converter and name it `Prepare for Lookup`. On its **Exchange Property** tab, add four rows: + +| Action | Name | Source Type | Source Value | Data Type | +| :--- | :--- | :--- | :--- | :--- | +| Create | `businessDate` | Expression | `${date:now:yyyy-MM-dd}` | | +| Create | `ingestedAt` | Expression | `${date:now:yyyy-MM-dd'T'HH:mm:ss'Z'}` | | +| Create | `sourceRegion` | Constant | `eu-north-1` | | +| Create | `preEnrichmentJson` | Expression | `${body}` | `java.lang.String` | + +![Prepare for Lookup — Exchange Property tab](./assets/aws-s3-step5-prepare-for-lookup.png) + +- `businessDate`, `ingestedAt`, and `sourceRegion` record when and where the file was ingested. +- `businessDate` also names the curated file (`sales_.json`). Running the flow twice on the same day overwrites that day's file. +- `preEnrichmentJson` sets the sales data aside, because the next step replaces the message body with the cost file. + +> **Caution:** Set the Data Type of `preEnrichmentJson` to `java.lang.String`. Without it, the property only points at the body's data stream, the lookup in the next step reads that stream to the end, and the script later finds the property empty ("Text must not be null or empty"). + +### Look Up Unit Costs from S3 + +Unit costs live in a reference file your instructor placed in S3: `regional-landing-bkt/reference/product_master.csv`. You read it mid-flow with the same AWS adapter, this time as a receiver. + +Add a **Request Reply** after Prepare for Lookup and name it `Cost LookUp`. Add a **Receiver** from the palette (its default name is `Receiver1`). Connect **Cost LookUp** to it and choose **AmazonWebServices**, protocol **S3**. + +| Field | Value | +| :--- | :--- | +| Host | `amazonaws.com` | +| Region Name | `eu-north-1 Europe (Stockholm)` (choose from the dropdown) | +| Bucket Name | `regional-landing-bkt` | +| Authentication Type | `Access and Secret Key` | +| Access Key Alias | `S3Key` | +| Secret Key Alias | `S3Secret` | +| Operation | `Read` | +| Directory | `reference` | +| File Name | `product_master.csv` | +| Post-Processing | `Keep File` | + +![Cost LookUp — S3 receiver, Read operation](./assets/aws-s3-step6-cost-lookup.png) + +> **Caution:** Post-Processing defaults to **Delete File**. Change it to **Keep File**. With the default, the read fails, because you're only allowed to read the reference folder. + +After this step, the message body is the cost file itself: + +```text +product_id,unit_cost +P-1001,11.99 +P-1002,23.99 +P-1003,5.99 +... +``` + +The sender waits for files to arrive; this receiver fetches one specific file when the flow asks for it. Same adapter, two different jobs. + +### Calculate Profit Margin + +Add a **Groovy Script** step after Cost LookUp and name it `Margin Calculation`. Click the step's create-script icon, replace the template in the editor with the script below, and click **OK**. + +![Margin Calculation — Groovy script editor](./assets/aws-s3-step7-groovy-editor.png) + +It's the only code in the flow. It: + +- reads the sales data back out of `preEnrichmentJson`, +- builds a cost lookup from the CSV in the body, +- adds `unit_cost`, `gross_margin`, and `margin_percentage` to every sale, +- stores a run summary in the `runHeadline` and `runSummary` properties. + +```groovy +import com.sap.gateway.ip.core.customdev.util.Message +import groovy.json.JsonOutput +import groovy.json.JsonSlurper + +// This script assumes the CSV->XML Converter and XML to JSON Converter have +// already run, and that the "Prepare for Lookup" Content Modifier stashed +// the resulting JSON in the "preEnrichmentJson" property (along with +// businessDate, ingestedAt and sourceRegion) before the Cost LookUp read +// replaced the message body with product_master.csv. +// +// This script's job is to compute profit margin per transaction using unit +// costs from the reference file in S3, and to leave a run summary in the +// runHeadline and runSummary properties, which participants read in the +// message trace. + +Message processData(Message message) { + + // 1. Pull back the JSON built by the XML to JSON Converter. The SAP CI + // Message wrapper only exposes single-arg getProperty() (no + // type-conversion overload like raw Camel's Message), and the property + // can come back as several different underlying types depending on + // Camel's stream caching -- a String, a raw byte[], or an + // InputStreamCache (a re-readable stream wrapper). Calling toString() + // on that last one just prints the Java object identity + // ("org.apache.camel.converter.stream.InputStreamCache@..."), not its + // content, so each case needs handling explicitly. + def rawPreEnrichmentJson = message.getProperty("preEnrichmentJson") + def preEnrichmentJson + switch (rawPreEnrichmentJson) { + case byte[]: + preEnrichmentJson = new String((byte[]) rawPreEnrichmentJson, "UTF-8") + break + case InputStream: + // The property can share one stream with the body. If a step in + // between (such as the Cost LookUp read) has already read that + // stream to the end, rewind it first, or getText() returns "". + try { rawPreEnrichmentJson.reset() } catch (ignored) { } + preEnrichmentJson = rawPreEnrichmentJson.getText("UTF-8") + break + default: + preEnrichmentJson = rawPreEnrichmentJson as String + } + if (!preEnrichmentJson?.trim()) { + throw new IllegalStateException("Property preEnrichmentJson is empty. In 'Prepare for Lookup', " + + "set the Data Type of preEnrichmentJson to java.lang.String so it holds a copy of the sales JSON.") + } + def parsedPayload = new JsonSlurper().parseText(preEnrichmentJson) + + // 1a. The XML to JSON Converter mirrors the XML element names exactly, + // so the rows live at Records.Row -- there is no flat "records" key. + // Confirm this shape in your own Message Processing Log; if your + // converter's settings ever produce something different, adjust this + // line to match. + def rawRows = parsedPayload.Records.Row + // Guard against the single-record edge case: some XML->JSON conversions + // collapse a lone repeating element into a single object instead of a + // one-element array, which would otherwise break the .each{} below. + def rows = rawRows instanceof List ? rawRows : [rawRows] + + // 1b. Attach the ingestion metadata from the properties set in + // "Prepare for Lookup". This also reshapes the payload into the flat + // { "source-region", "ingested-at", "records" } structure used + // everywhere else in this lab, instead of the nested Records.Row shape + // that only exists because that's how the XML happened to be structured. + def payload = [ + "source-region": message.getProperty("sourceRegion"), + "ingested-at" : message.getProperty("ingestedAt"), + "records" : rows + ] + + // 2. Parse the reference file read from S3 by Cost LookUp (the current + // body): regional-landing-bkt/reference/product_master.csv, with a + // header line "product_id,unit_cost" followed by one product per line. + // The header line is skipped, which also skips any byte-order mark a + // spreadsheet tool may add. Splitting on comma or semicolon covers Excel + // on European locales, which saves CSVs with semicolons. + def costLookup = [:] + message.getBody(java.io.Reader).readLines().drop(1).findAll { it.trim() }.each { line -> + def cols = line.split(/[,;]/) + costLookup[cols[0].trim()] = new BigDecimal(cols[1].trim()) + } + + // 3. The one genuinely custom piece of business logic left in code: + // derive profit margin, a number that doesn't exist anywhere in the + // source CSV, from cost data that only lives in the reference file. + payload.records.each { record -> + def unitCost = costLookup[record.product_id] + def quantity = record.quantity as BigDecimal + def totalAmount = record.total_amount as BigDecimal + def unitPrice = record.unit_price as BigDecimal + + // Write the numeric casts back onto the record. The XML to JSON + // Converter serializes every XML text node as a JSON string, so + // without this quantity/unit_price/total_amount would stay quoted + // in the curated output. + record.quantity = quantity + record.unit_price = unitPrice + record.total_amount = totalAmount + + if (unitCost != null) { + def cost = unitCost * quantity + def margin = totalAmount - cost + record.unit_cost = unitCost + record.gross_margin = margin.setScale(2, BigDecimal.ROUND_HALF_UP) + record.margin_percentage = totalAmount == 0 ? 0 : + (margin / totalAmount * 100).setScale(1, BigDecimal.ROUND_HALF_UP) + } else { + record.unit_cost = "unknown" + record.gross_margin = "unknown" + record.margin_percentage = "unknown" + } + } + + // 4. Build a short run summary. Nothing here changes the curated JSON -- + // it only sets two exchange properties, runHeadline and runSummary, + // which show up in Monitor -> the message's trace -> Properties for any + // step after this one. Edit the two path constants if your buckets differ. + // The sender keeps the source file in place (Post-Processing: Keep File + // and Process Again), so every poll reprocesses the same file. + def sourcePath = "regional-landing-bkt/daily/ (kept for the next run)" + def curatedPath = "curated-corp/profit-margins/sales_" + message.getProperty("businessDate") + ".json" + + def priced = payload.records.findAll { it.gross_margin instanceof Number } + def unpricedCount = payload.records.size() - priced.size() + def totalRevenue = priced.sum(BigDecimal.ZERO) { it.total_amount as BigDecimal } + def totalMargin = priced.sum(BigDecimal.ZERO) { it.gross_margin as BigDecimal } + def marginPct = totalRevenue == 0 ? BigDecimal.ZERO : + (totalMargin / totalRevenue * 100).setScale(1, BigDecimal.ROUND_HALF_UP) + + def marginBy = { String field -> + priced.groupBy { it[field] }.collectEntries { key, recs -> + [key, recs.sum(BigDecimal.ZERO) { it.gross_margin as BigDecimal }] + } + } + def topProduct = priced ? marginBy("product_name").max { it.value } : null + def topRegion = priced ? marginBy("region").max { it.value } : null + + // Locale.US pins the format to 1,234.56 regardless of the tenant JVM's + // default locale (which could otherwise render 1.234,56). + def money = { BigDecimal v -> "\$" + String.format(Locale.US, "%,.2f", v) } + + def lines = [] + lines << "Profit margin run complete" + lines << "" + lines << "Business date: " + message.getProperty("businessDate") + lines << "Records: " + payload.records.size() + " (" + unpricedCount + " without cost data)" + lines << "Revenue: " + money(totalRevenue) + lines << "Gross margin: " + money(totalMargin) + " (" + marginPct + "%)" + if (topProduct) { + lines << "" + lines << "Top product: " + topProduct.key + " (" + money(topProduct.value) + " margin)" + lines << "Top region: " + topRegion.key + " (" + money(topRegion.value) + " margin)" + } + lines << "" + lines << "Read from: " + sourcePath + lines << "Published to: " + curatedPath + + message.setProperty("runHeadline", + "Profit margins ready: " + message.getProperty("businessDate") + " | " + marginPct + "% gross margin") + message.setProperty("runSummary", lines.join("\n")) + + message.setBody(JsonOutput.toJson(payload)) + message.setHeader("Content-Type", "application/json") + return message +} +``` + +> The **Read from** and **Published to** lines in the summary use fixed paths. If your folders differ from the defaults, edit the `sourcePath` and `curatedPath` lines in the script. The flow itself doesn't depend on them. + +### Write the Curated File and Show the Summary + +#### S3 Write + +Add a **Request Reply** after Margin Calculation and name it `S3 Write`. Connect it to the **Receiver** that came with the canvas and choose **AmazonWebServices**, protocol **S3**. + +| Field | Value | +| :--- | :--- | +| Host | `amazonaws.com` | +| Region Name | `us-west-1 US West (N. California)` (choose from the dropdown) | +| Bucket Name | `curated-corp` | +| Authentication Type | `Access and Secret Key` | +| Access Key Alias | `S3Key` | +| Secret Key Alias | `S3Secret` | +| Operation | `Write` | +| Directory | `profit-margins` | +| File Name | `sales_${property.businessDate}.json` | +| Content Type | `application/json` | +| Existing File Handling | `Overwrite` | + +![S3 Write — S3 receiver, Write operation](./assets/aws-s3-step8-write-curated.png) + +#### Show Run Summary + +Add a **Content Modifier** after S3 Write, name it `Show Run Summary`, and connect it to the **End** event: + +| Tab | Setting | Value | +| :--- | :--- | :--- | +| Message Body | Type | `Expression` | +| Message Body | Body | `${property.runSummary}` | +| Message Header | `Content-Type` | Constant: `text/plain` | + +After the S3 write, the body is only S3's acknowledgement. This step puts the run summary there instead, so the last thing you see in the Monitor is the result of the whole run. + +Your finished flow: + +![Finished integration flow](./assets/aws-s3-step8-finished-iflow.png) + +--- + +### Deploy and Test + +Click **Save**, then **Deploy**. Go to **Monitor → Integrations and APIs → Manage Integration Content** and wait until your integration flow shows **Started**. + +In the same view, select your integration flow and set its **Log Level** to **Trace**. Trace switches itself off after a short while, so do this right before testing. + +Your flow processes the sales file in `daily/` on every poll, so a run starts within a few minutes of deploying. Open **Monitor Message Processing** and wait for your message to show **Completed**. Open it and click **Message Content**. The view shows the message *before* the step you select. + +| Check | Where | What you should see | +| :--- | :--- | :--- | +| Picked up | Monitor Message Processing | Your message appears. The file stays in `daily/` and is picked up again on the next poll | +| Enriched | **S3 Write** → Payload | Every sale has `unit_cost`, `gross_margin`, and `margin_percentage` | +| Summarized | **End 1** → Exchange Properties | `runSummary` holds the run summary | + +![Monitor Message Processing — message Completed](./assets/aws-s3-step9-monitor-completed.png) + +![Message Content — run summary at End 1](./assets/aws-s3-step9-run-summary.png) + +This example comes from a test run with the 1,000-row sample file `sales_2026-10-07.csv`: + +```text +Profit margin run complete + +Business date: 2026-10-07 +Records: 1000 (0 without cost data) +Revenue: $91,022.76 +Gross margin: $37,338.94 (41.0%) + +Top product: Standing Desk ($6,125.00 margin) +Top region: Northeast ($9,240.39 margin) + +Read from: regional-landing-bkt/daily/ (kept for the next run) +Published to: curated-corp/profit-margins/sales_.json +``` + +> Every run processes the same 1,000-row file, so your numbers match the example. **Records** should equal the number of rows in the file, and **0 without cost data** confirms every product was found in the reference file. + +#### Troubleshooting + +| Issue | Fix | +| :--- | :--- | +| `UnknownHostException: s3.Europe (Stockholm)...` | The region was entered as a label instead of chosen from the dropdown. Select `eu-north-1 Europe (Stockholm)`. | +| Deployment fails mentioning a credential | The secure parameter names don't match. They must be exactly `S3Key` and `S3Secret`, deployed, and entered in the Access Key Alias and Secret Key Alias fields of all three AWS adapters. | +| `Header TRANSACTION_ID not present in CSV headers` | The sales file's header doesn't match the XSD. It must be the lowercase header shown in Step 4. | +| Cost LookUp: access denied | Post-Processing must be **Keep File**, not Delete File. If it is, ask your instructor to check the reference folder permission. | +| Cost LookUp: file not found | Directory must be `reference` and File Name `product_master.csv`, exactly, in Region `eu-north-1`. | +| `Text must not be null or empty` (Margin Calculation) | The stashed sales data is empty. Set the Data Type of `preEnrichmentJson` to `java.lang.String` in Prepare for Lookup. | +| `JsonException ... current character read is '<'` | The stashed property holds XML. The XML to JSON Converter is missing, or Prepare for Lookup sits before it. | +| Every record shows `unit_cost` "unknown" | The lookup found no costs. Check in Message Content that the body after Cost LookUp starts with `product_id,unit_cost`. | +| Curated file is named `sales_.json` | `businessDate` is missing. Add it in Prepare for Lookup (Step 5). | +| Script check: JSONSlurper without streaming | A best-practice warning, not an error. The script already reads the body as a stream. | +| No Message Content in the Monitor | Trace wasn't active when the file was processed. Set the log level to Trace and wait for the next poll. | +| My message never appears | Check your flow is **Started** in Manage Integration Content, and that the sender Directory is `daily`. | + +#### Try It Yourself + +- Before you open the summary, predict which region earned the most margin. Then check. +- Ask your instructor to raise the unit cost of the Standing Desk in `product_master.csv`. The next run's margin drops, even though the sales data didn't change. +- Ask your instructor to replace the file in `daily/` with one that contains a sale of product `P-9999`. The summary reports one record without cost data, and that record shows `unknown` for its margin. + +You built a flow that polls Amazon S3, enriches the data from a reference file in S3, writes the result back to S3 in another region, and summarizes the run. + +#### References + +- [Amazon Web Services Adapter Guide for SAP Cloud Integration](https://hub.sap.com/odata/1.0/catalog.svc/Files('10541079cd834d69a3fbd978c85b11bd')/$value) +- [Amazon Web Services Adapter for SAP Cloud Integration (SAP Community)](https://blogs.sap.com/2021/02/17/amazon-web-services-adapter-for-sap-cloud-integration/) diff --git a/tutorials/dev-garage-aws-pipeline-revenue-management/files/TransformCsvToJson.groovy b/tutorials/dev-garage-aws-pipeline-revenue-management/files/TransformCsvToJson.groovy new file mode 100644 index 0000000..cd20876 --- /dev/null +++ b/tutorials/dev-garage-aws-pipeline-revenue-management/files/TransformCsvToJson.groovy @@ -0,0 +1,165 @@ +import com.sap.gateway.ip.core.customdev.util.Message +import groovy.json.JsonOutput +import groovy.json.JsonSlurper + +// This script assumes the CSV->XML Converter and XML to JSON Converter have +// already run, and that the "Prepare for Lookup" Content Modifier stashed +// the resulting JSON in the "preEnrichmentJson" property (along with +// businessDate, ingestedAt and sourceRegion) before the Cost LookUp read +// replaced the message body with product_master.csv. +// +// This script's job is to compute profit margin per transaction using unit +// costs from the reference file in S3, and to leave a run summary in the +// runHeadline and runSummary properties, which participants read in the +// message trace. + +Message processData(Message message) { + + // 1. Pull back the JSON built by the XML to JSON Converter. The SAP CI + // Message wrapper only exposes single-arg getProperty() (no + // type-conversion overload like raw Camel's Message), and the property + // can come back as several different underlying types depending on + // Camel's stream caching -- a String, a raw byte[], or an + // InputStreamCache (a re-readable stream wrapper). Calling toString() + // on that last one just prints the Java object identity + // ("org.apache.camel.converter.stream.InputStreamCache@..."), not its + // content, so each case needs handling explicitly. + def rawPreEnrichmentJson = message.getProperty("preEnrichmentJson") + def preEnrichmentJson + switch (rawPreEnrichmentJson) { + case byte[]: + preEnrichmentJson = new String((byte[]) rawPreEnrichmentJson, "UTF-8") + break + case InputStream: + // The property can share one stream with the body. If a step in + // between (such as the Cost LookUp read) has already read that + // stream to the end, rewind it first, or getText() returns "". + try { rawPreEnrichmentJson.reset() } catch (ignored) { } + preEnrichmentJson = rawPreEnrichmentJson.getText("UTF-8") + break + default: + preEnrichmentJson = rawPreEnrichmentJson as String + } + if (!preEnrichmentJson?.trim()) { + throw new IllegalStateException("Property preEnrichmentJson is empty. In 'Prepare for Lookup', " + + "set the Data Type of preEnrichmentJson to java.lang.String so it holds a copy of the sales JSON.") + } + def parsedPayload = new JsonSlurper().parseText(preEnrichmentJson) + + // 1a. The XML to JSON Converter mirrors the XML element names exactly, + // so the rows live at Records.Row -- there is no flat "records" key. + // Confirm this shape in your own Message Processing Log; if your + // converter's settings ever produce something different, adjust this + // line to match. + def rawRows = parsedPayload.Records.Row + // Guard against the single-record edge case: some XML->JSON conversions + // collapse a lone repeating element into a single object instead of a + // one-element array, which would otherwise break the .each{} below. + def rows = rawRows instanceof List ? rawRows : [rawRows] + + // 1b. Attach the ingestion metadata from the properties set in + // "Prepare for Lookup". This also reshapes the payload into the flat + // { "source-region", "ingested-at", "records" } structure used + // everywhere else in this lab, instead of the nested Records.Row shape + // that only exists because that's how the XML happened to be structured. + def payload = [ + "source-region": message.getProperty("sourceRegion"), + "ingested-at" : message.getProperty("ingestedAt"), + "records" : rows + ] + + // 2. Parse the reference file read from S3 by Cost LookUp (the current + // body): regional-landing-bkt/reference/product_master.csv, with a + // header line "product_id,unit_cost" followed by one product per line. + // The header line is skipped, which also skips any byte-order mark a + // spreadsheet tool may add. Splitting on comma or semicolon covers Excel + // on European locales, which saves CSVs with semicolons. + def costLookup = [:] + message.getBody(java.io.Reader).readLines().drop(1).findAll { it.trim() }.each { line -> + def cols = line.split(/[,;]/) + costLookup[cols[0].trim()] = new BigDecimal(cols[1].trim()) + } + + // 3. The one genuinely custom piece of business logic left in code: + // derive profit margin, a number that doesn't exist anywhere in the + // source CSV, from cost data that only lives in the reference file. + payload.records.each { record -> + def unitCost = costLookup[record.product_id] + def quantity = record.quantity as BigDecimal + def totalAmount = record.total_amount as BigDecimal + def unitPrice = record.unit_price as BigDecimal + + // Write the numeric casts back onto the record. The XML to JSON + // Converter serializes every XML text node as a JSON string, so + // without this quantity/unit_price/total_amount would stay quoted + // in the curated output. + record.quantity = quantity + record.unit_price = unitPrice + record.total_amount = totalAmount + + if (unitCost != null) { + def cost = unitCost * quantity + def margin = totalAmount - cost + record.unit_cost = unitCost + record.gross_margin = margin.setScale(2, BigDecimal.ROUND_HALF_UP) + record.margin_percentage = totalAmount == 0 ? 0 : + (margin / totalAmount * 100).setScale(1, BigDecimal.ROUND_HALF_UP) + } else { + record.unit_cost = "unknown" + record.gross_margin = "unknown" + record.margin_percentage = "unknown" + } + } + + // 4. Build a short run summary. Nothing here changes the curated JSON -- + // it only sets two exchange properties, runHeadline and runSummary, + // which show up in Monitor -> the message's trace -> Properties for any + // step after this one. Edit the two path constants if your buckets differ. + // The sender keeps the source file in place (Post-Processing: Keep File + // and Process Again), so every poll reprocesses the same file. + def sourcePath = "regional-landing-bkt/daily/ (kept for the next run)" + def curatedPath = "curated-corp/profit-margins/sales_" + message.getProperty("businessDate") + ".json" + + def priced = payload.records.findAll { it.gross_margin instanceof Number } + def unpricedCount = payload.records.size() - priced.size() + def totalRevenue = priced.sum(BigDecimal.ZERO) { it.total_amount as BigDecimal } + def totalMargin = priced.sum(BigDecimal.ZERO) { it.gross_margin as BigDecimal } + def marginPct = totalRevenue == 0 ? BigDecimal.ZERO : + (totalMargin / totalRevenue * 100).setScale(1, BigDecimal.ROUND_HALF_UP) + + def marginBy = { String field -> + priced.groupBy { it[field] }.collectEntries { key, recs -> + [key, recs.sum(BigDecimal.ZERO) { it.gross_margin as BigDecimal }] + } + } + def topProduct = priced ? marginBy("product_name").max { it.value } : null + def topRegion = priced ? marginBy("region").max { it.value } : null + + // Locale.US pins the format to 1,234.56 regardless of the tenant JVM's + // default locale (which could otherwise render 1.234,56). + def money = { BigDecimal v -> "\$" + String.format(Locale.US, "%,.2f", v) } + + def lines = [] + lines << "Profit margin run complete" + lines << "" + lines << "Business date: " + message.getProperty("businessDate") + lines << "Records: " + payload.records.size() + " (" + unpricedCount + " without cost data)" + lines << "Revenue: " + money(totalRevenue) + lines << "Gross margin: " + money(totalMargin) + " (" + marginPct + "%)" + if (topProduct) { + lines << "" + lines << "Top product: " + topProduct.key + " (" + money(topProduct.value) + " margin)" + lines << "Top region: " + topRegion.key + " (" + money(topRegion.value) + " margin)" + } + lines << "" + lines << "Read from: " + sourcePath + lines << "Published to: " + curatedPath + + message.setProperty("runHeadline", + "Profit margins ready: " + message.getProperty("businessDate") + " | " + marginPct + "% gross margin") + message.setProperty("runSummary", lines.join("\n")) + + message.setBody(JsonOutput.toJson(payload)) + message.setHeader("Content-Type", "application/json") + return message +} \ No newline at end of file diff --git a/tutorials/dev-garage-aws-pipeline-revenue-management/files/xml_schema.xsd b/tutorials/dev-garage-aws-pipeline-revenue-management/files/xml_schema.xsd new file mode 100644 index 0000000..ea8d4f2 --- /dev/null +++ b/tutorials/dev-garage-aws-pipeline-revenue-management/files/xml_schema.xsd @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step3-set-context.png b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step3-set-context.png new file mode 100644 index 0000000..d19b1a4 Binary files /dev/null and b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step3-set-context.png differ diff --git a/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step3-set-order-payload.png b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step3-set-order-payload.png new file mode 100644 index 0000000..3ab2e11 Binary files /dev/null and b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step3-set-order-payload.png differ diff --git a/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step3-validate-order.png b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step3-validate-order.png new file mode 100644 index 0000000..dc39296 Binary files /dev/null and b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step3-validate-order.png differ diff --git a/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step6-build-prompt.png b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step6-build-prompt.png new file mode 100644 index 0000000..a1bde7c Binary files /dev/null and b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step6-build-prompt.png differ diff --git a/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step6-local-process.png b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step6-local-process.png new file mode 100644 index 0000000..754e37d Binary files /dev/null and b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step6-local-process.png differ diff --git a/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step7-connection.png b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step7-connection.png new file mode 100644 index 0000000..b46e3b0 Binary files /dev/null and b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step7-connection.png differ diff --git a/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step7-processing.png b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step7-processing.png new file mode 100644 index 0000000..e367346 Binary files /dev/null and b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step7-processing.png differ diff --git a/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step8-exception-subprocess.png b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step8-exception-subprocess.png new file mode 100644 index 0000000..3661078 Binary files /dev/null and b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step8-exception-subprocess.png differ diff --git a/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step8-finished-iflow.png b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step8-finished-iflow.png new file mode 100644 index 0000000..21e59dd Binary files /dev/null and b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step8-finished-iflow.png differ diff --git a/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step9-ai-resolution.png b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step9-ai-resolution.png new file mode 100644 index 0000000..fcf821d Binary files /dev/null and b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step9-ai-resolution.png differ diff --git a/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step9-custom-headers.png b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step9-custom-headers.png new file mode 100644 index 0000000..9ce7158 Binary files /dev/null and b/tutorials/dev-garage-error-resolution-ai-adapter/assets/ai-adapter-step9-custom-headers.png differ diff --git a/tutorials/dev-garage-error-resolution-ai-adapter/dev-garage-error-resolution-ai-adapter.md b/tutorials/dev-garage-error-resolution-ai-adapter/dev-garage-error-resolution-ai-adapter.md new file mode 100644 index 0000000..2351b79 --- /dev/null +++ b/tutorials/dev-garage-error-resolution-ai-adapter/dev-garage-error-resolution-ai-adapter.md @@ -0,0 +1,710 @@ +--- +parser: v2 +author_name: rojo-ashishkuvvarapu +author_profile: https://github.com/rojo-ashishkuvvarapu +keywords: tutorial +auto_validation: true +time: 60 +tags: [ tutorial>intermediate, software-product>sap-integration-suite, software-product>sap-business-technology-platform ] +primary_tag: software-product>sap-integration-suite +--- + +# SAP TechEd Developer Garage: Error Resolution with AI Adapter + + Build an integration flow in SAP Integration Suite that catches an error, masks it, asks an LLM in SAP AI Core for a diagnosis through the AI Adapter, and attaches a readable resolution to the message. + +## Prerequisites + +- An SAP BTP account with **SAP Integration Suite** and the **Cloud Integration** capability activated, and the **AI Adapter** available on your tenant +- [Assign roles to your user for Cloud Integration](https://help.sap.com/docs/integration-suite/sap-integration-suite/configuring-user-access?locale=en-US&state=PRODUCTION&version=CLOUD#loio2c6214a3228e4b4cba207f49fda92ed4__section_xbf_glz_l2c), including the `Integration_Developer` role collection +- The file `From the Instructor.txt`, which your instructor shares with you through a secure medium. It is not part of this repository. It contains the values from an **SAP AI Core** service key and deployment: `address`, `orchestration deployment id`, `ai resource group`, `model`, `token service url`, `clientid`, and `clientsecret` +- Familiarity with the SAP Integration Suite interface helps. No Groovy experience is required; all scripts are provided. + +You don't need your own SAP AI Core instance. Your instructor prepares it. Generative AI Hub and orchestration need the **extended** SAP AI Core service plan. + +## You will learn + +- How to connect Cloud Integration to SAP AI Core with the AI Adapter (AICore-GenAIHub variant) +- How to store SAP AI Core credentials as OAuth2 Security Material +- How to catch and handle errors in an exception subprocess without losing the original error +- How to mask personal data before anything is sent to an LLM +- How to turn an AI answer into readable attachments and searchable fields in the Monitor + +## Scenario + +**Challenge:** Integration errors are often cryptic, and diagnosing them takes time. Support teams need a plain-language explanation and a suggested fix next to the error. + +**Approach:** You build an integration flow that fails on purpose. A sales order contains a text value where the schema expects a number. The flow catches the error, adds context, masks personal data, and sends the result to an LLM in SAP AI Core through the AI Adapter. The answer is attached to the message as a readable resolution. + +The AI Adapter is the hero of this flow. It calls the SAP AI Core orchestration service with OAuth2, and you never handle a token yourself. + +The flow consists of three connected pieces: + +```text +Main flow: + Start Timer -> Set Order Payload -> Set Context -> Validate Order -> End + +Exception Subprocess 1 (runs when anything in the main flow fails): + Error Start -> Capture Error -> Get AI Diagnosis (Process Call) -> Attach AI Diagnosis -> End Message + +Local process "Get AI Diagnosis": + Start -> Build Prompt -> Request Reply (AI Adapter -> SAP AI Core) -> End + with its own Exception Subprocess 2: Error Start -> SetAIStatus -> End Message +``` + +| Part of the flow | What it does | +| :--- | :--- | +| **Start Timer** | Starts the flow once, each time you deploy. No sender system is needed. | +| **Set Order Payload** | Creates a sample sales order with a deliberate error. | +| **Set Context** | Records the step and business object, so the error handler knows where it failed. | +| **Validate Order** | Checks the order against a schema and fails with a precise reason. | +| **Capture Error** | Collects the error and context, masks personal data, and prepares the AI input. | +| **Get AI Diagnosis** | Local process that builds the prompt and calls SAP AI Core through the AI Adapter. | +| **Attach AI Diagnosis** | Turns the AI answer into readable attachments and searchable fields in the Monitor. | + +--- + +### Create a Package and an Integration Flow + +In SAP Integration Suite, go to **Design → Integrations and APIs** and click **Create**. + +| Field | Value | +| :--- | :--- | +| Name | `Error Resolution with AI Adapter` | +| Technical Name | *(auto-populated)* | +| Short Description | `Resolve integration errors with AI using the AI Adapter and SAP AI Core` | + +Click **Save**. + +On the package's **Artifacts** tab, click **Add → Integration Flow**. Keep **Create** selected and fill in: + +| Field | Value | +| :--- | :--- | +| Name | `Error Resolution with AI Adapter` | +| ID | *(auto-populated)* | + +Click **OK**, open the integration flow, and click **Edit**. Delete the default **Sender** and **Receiver**. This flow is started by a timer and has no sender. + +> **Tip:** You add steps from the palette at the top of the canvas. Content Modifier and Groovy Script are under **Message Transformers**; Request Reply and Process Call are under **Call**; Exception Subprocess and Local Integration Process are under **Process**. Menu names can differ slightly between tenant versions. + +Click **Save** regularly as you build. + +### Create Security Material for SAP AI Core + +Your instructor shares `From the Instructor.txt` with you through a secure medium. It holds the `clientid`, `clientsecret`, and `token service url` from the SAP AI Core service key. Store them once; the AI Adapter refers to them by name. + +Go to **Monitor → Integrations and APIs → Security Material** and click **Create → OAuth2 Client Credentials**. + +| Field | Value | +| :--- | :--- | +| Name | `GenAI_Creds` | +| Token Service URL | `token service url` from `From the Instructor.txt`. It is the service key's `url` value with `/oauth/token` added, for example `https://.authentication..hana.ondemand.com/oauth/token` | +| Client ID | `clientid` from `From the Instructor.txt` | +| Client Secret | `clientsecret` from `From the Instructor.txt` | +| Client Authentication | `Send as Request Header` (Basic Authentication). `Send as Body Parameter` normally works as well. | + +Click **Deploy**. + +> **Caution:** The token URL comes from the SAP AI Core service key. It contains `.authentication.` and ends with `/oauth/token`. It is **not** the OAuth endpoint of your Cloud Integration tenant, and it is **not** the AI API URL (`https://api.ai.…`), which belongs in the adapter's Address field in Step 7. A wrong token URL fails with `403 Forbidden` when the adapter retrieves the access token. + +> **Caution:** Never paste the client secret into chat, slides, screenshots, or a shared file. + +### Build the Main Flow That Fails on Purpose + +The main flow creates a sales order with a deliberate error and validates it. A realistic, specific error gives the AI something concrete to diagnose. + +#### Start Timer + +Replace the **Start** event with a **Timer Start** event. On its **Scheduler** tab, choose **Run Once**, so the flow runs each time you deploy. + +#### Set Order Payload + +Add a **Content Modifier** after the start event and name it `Set Order Payload`. On the **Message Body** tab, paste the order below. `Quantity` contains text where the schema expects a number. + +```xml + + + SO-DEMO-0001 + 2026-10-08 + + CUST-A + buyer.demo@example.com + +31 20 123 4567 + + + + MAT-100 + five + 12.50 + EUR + + + +``` + +> **Tip:** The email address and phone number are fake on purpose. Later you will see them masked before anything is sent to the AI. A warning that the "expression text does not have expression parameters" on this step is harmless. Switching the body Type to **Constant** clears it. + +![Set Order Payload, Message Body tab](./assets/ai-adapter-step3-set-order-payload.png) + +#### Set Context + +Add a second **Content Modifier** named `Set Context`. On the **Exchange Property** tab, add three properties. Cloud Integration doesn't tell the error handler which step failed, so you record it yourself. + +| Action | Name | Source Type | Value | +| :--- | :--- | :--- | :--- | +| Create | `flowStep` | `Constant` | `ValidateOrder` | +| Create | `businessObject` | `Constant` | `SalesOrder` | +| Create | `iflowName` | `Expression` | `${camelId}` | + +![Set Context, Exchange Property tab](./assets/ai-adapter-step3-set-context.png) + +#### Validate Order + +Add a **Groovy Script** step, name it `Validate Order`, click the create-script icon, and paste the script below. Name the script file `ValidateOrder.groovy` and click **OK**. + +```groovy +/* + * ValidateOrder.groovy + * Main integration process - replaces the standard XML Validator step. + * Validates the SalesOrder payload against the embedded XSD and throws an + * exception whose message contains the full validation detail (line + reason), + * so the exception subprocess can pass it to the AI adapter. + */ +import com.sap.gateway.ip.core.customdev.util.Message +import groovy.transform.Field +import javax.xml.XMLConstants +import javax.xml.transform.stream.StreamSource +import javax.xml.validation.SchemaFactory +import org.xml.sax.ErrorHandler +import org.xml.sax.SAXParseException + +@Field String XSD = ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +''' + +def Message processData(Message message) { + String xml = message.getBody(String) + + def schema = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI) + .newSchema(new StreamSource(new StringReader(XSD))) + def validator = schema.newValidator() + + List errors = [] + validator.setErrorHandler([ + warning : { SAXParseException e -> }, + error : { SAXParseException e -> errors << "Line ${e.lineNumber}: ${e.message}" }, + fatalError: { SAXParseException e -> errors << "Line ${e.lineNumber}: ${e.message}" } + ] as ErrorHandler) + + try { + validator.validate(new StreamSource(new StringReader(xml))) + } catch (SAXParseException e) { + // Fatal errors (e.g. malformed XML) stop validation; the handler usually recorded them already + if (!errors) errors << "Line ${e.lineNumber}: ${e.message}" + } + + if (errors) { + throw new IllegalStateException("SalesOrder schema validation failed: " + errors.join(" | ")) + } + return message +} +``` + +> **Why not the standard XML Validator?** The XML Validator step only reports "Refer to MPL attachment (XML Validator Error Document) for details". The real reason is stored in an attachment that the error handler can't read. This script validates against the same schema and puts the full reason, with line numbers, into the error. + +Connect **Validate Order** to the **End** event. + +![Validate Order script in the editor](./assets/ai-adapter-step3-validate-order.png) + +### Deploy and See the Failure + +Click **Save**, then **Deploy**. The timer fires once. + +Go to **Monitor → Integrations and APIs → Monitor Message Processing** and open the newest message for your integration flow. + +The status is **Failed** at **Validate Order**, with an error similar to: + +```text +IllegalStateException: SalesOrder schema validation failed: Line 13: cvc-datatype-valid.1.2.1: 'five' is not a valid value for 'integer'. | Line 13: cvc-type.3.1.3: The value 'five' of element 'Quantity' is not valid. +``` + +That's expected. The rest of the tutorial turns this error into an AI-written resolution. + +### Capture the Error in an Exception Subprocess + +From the palette, choose **Process → Exception Subprocess** and drop it inside the **Integration Process**. Keep the **End Message** event that the subprocess comes with. + +Add a **Groovy Script** after the **Error Start** event, name it `Capture Error`, and paste the script below. Name the script file `CaptureError.groovy` and click **OK**. + +```groovy +/* + * CaptureError.groovy + * Exception subprocess - first step after Error Start. + * Captures the original error plus context, masks sensitive data, truncates, + * and builds the JSON input for the AI adapter. + * + * Expects these exchange properties (set in "Set Context" content modifier): + * iflowName = ${camelId} + * flowStep = ValidateOrder + * businessObject = SalesOrder + * + * Output: + * property origErrorClass / origErrorMessage (masked, for the attachment later) + * property aiErrorContext (compact JSON) + * message body (pretty JSON - AI input) + * property aiStatus = CONTEXT_FAILED (only if this script itself fails) + */ +import com.sap.gateway.ip.core.customdev.util.Message +import groovy.json.JsonOutput + +def Message processData(Message message) { + try { + // 1. The exception Cloud Integration caught + def ex = message.getProperty("CamelExceptionCaught") + + // Use the innermost exception: Cloud Integration wraps script errors several times + def root = ex + while (root?.getCause() != null && root.getCause() != root) { + root = root.getCause() + } + String errClass = root ? root.getClass().getName() : "unknown" + String errMsg = root?.getMessage() ?: ex?.getMessage() ?: "no error message" + + // 2. HTTP-type errors also carry a response body (optional, harmless if absent) + String httpBody = "" + if (ex != null && ex.metaClass.respondsTo(ex, "getResponseBody")) { + httpBody = ex.getResponseBody() ?: "" + } + + // 3. The payload as it was when the flow failed + String payload = message.getBody(String) ?: "" + + // 4. Build the context the AI will see - masked and truncated + def ctx = [ + iflow : message.getProperty("iflowName") ?: "unknown", + failedStep : message.getProperty("flowStep") ?: "unknown", + businessObject: message.getProperty("businessObject") ?: "", + mplId : message.getHeaders().get("SAP_MessageProcessingLogID") ?: "", + errorClass : errClass, + errorMessage : mask(truncate(errMsg, 4000)), + httpResponse : mask(truncate(httpBody, 1000)), + payloadExtract: mask(truncate(payload, 2000)) + ] + + // 5. Save the original error first, so nothing later can overwrite it + message.setProperty("origErrorClass", errClass) + message.setProperty("origErrorMessage", ctx.errorMessage) + + // 6. AI input: kept as a property and set as the body + String json = JsonOutput.toJson(ctx) + message.setProperty("aiErrorContext", json) + message.setBody(JsonOutput.prettyPrint(json)) + + } catch (Exception e) { + // Never throw from error handling - it would bury the original error + message.setProperty("aiStatus", "CONTEXT_FAILED") + message.setProperty("contextError", e.getMessage()) + } + return message +} + +String truncate(String s, int max) { + if (!s) return "" + return s.length() > max ? s.substring(0, max) + " ...[truncated]" : s +} + +// Regex-based safety net only - NOT a full data-protection solution. +// Note: the [PHONE] pattern also masks any long number (e.g. order/PO numbers); narrow it if needed. +String mask(String s) { + if (!s) return "" + return s + .replaceAll(/[\w.+-]+@[\w-]+\.[\w.]+/, "[EMAIL]") + .replaceAll(/\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/, "[IBAN]") + .replaceAll(/\b(?:\d[ -]?){13,19}\b/, "[CARD]") + .replaceAll(/(?]+)[^"'\s<,&]+/, '$1$2[SECRET]') + .replaceAll(/(?i)bearer\s+[A-Za-z0-9._~+\/=-]+/, "Bearer [SECRET]") +} +``` + +The script: + +- Reads the **innermost** exception. Cloud Integration wraps script errors several times, so the outer message repeats itself. +- Adds context: iFlow name, failed step, error class, and message processing log ID. +- Masks emails, phone numbers, IBANs, card numbers, and secrets, and truncates long text. +- Saves the result as the property `aiErrorContext` for the AI step. + +> **Why End Message?** End Message marks the error as handled, so the message shows **Completed** instead of Failed. The original error is kept in the AI Diagnosis attachment, and the custom header property `ErrorHandled = true` lets you find these messages in the Monitor. If you prefer the message to show **Failed**, use an **Error End** event instead. + +> **Caution:** Masking with regular expressions is a safety net. It can't find every personal detail, such as names written as free text. For real customer payloads, consider sending no payload extract at all. + +### Create the Local Process That Calls the AI + +The AI call lives in its own local process. If the AI is unreachable, that failure is caught there and can't hide the original error. + +From the palette, choose **Process → Local Integration Process**. Drop it **outside** the main Integration Process and name it `Get AI Diagnosis`. + +Add a **Content Modifier** named `Build Prompt`, then a **Request Reply** (**Call → External Call**). Connect **Start → Build Prompt → Request Reply → End**. + +In **Build Prompt**, open the **Message Body** tab, set **Type** to `Expression`, and paste: + +```text +You are an SAP Cloud Integration support engineer. Diagnose the integration error described in the JSON input below. + +Respond ONLY with valid JSON, no markdown, no extra text, matching exactly this structure: +{"root_cause": "string", "category": "DATA | MAPPING | CONNECTIVITY | AUTH | CONFIG | OTHER", "fix_steps": ["string"], "confidence": "LOW | MEDIUM | HIGH", "needs_human": true or false} + +Rules: +- Base your answer only on the input. Do not invent field names that are not in the error or payload. +- Values shown as [EMAIL], [PHONE], [IBAN], [CARD] or [SECRET] are masked on purpose; do not treat them as errors. +- If unsure, say so in root_cause and set confidence to LOW. +- Give 2-4 fix_steps: the immediate fix, where it should be fixed (source system, mapping, or iFlow configuration), and how to prevent it recurring. + +Input: +${property.aiErrorContext} +``` + +> **Caution:** **Type** must be **Expression**. With **Constant**, the AI receives the literal text `${property.aiErrorContext}` instead of your error and answers "insufficient information". + +![Build Prompt, Message Body tab with Type Expression](./assets/ai-adapter-step6-build-prompt.png) + +#### Exception Subprocess 2 + +Drop an **Exception Subprocess** inside the `Get AI Diagnosis` local process. Add a **Content Modifier** named `SetAIStatus` between its **Error Start** and **End Message** events. On the **Exchange Property** tab, add: + +| Action | Name | Source Type | Value | +| :--- | :--- | :--- | :--- | +| Create | `aiStatus` | `Constant` | `UNAVAILABLE` | +| Create | `aiError` | `Expression` | `${exception.message}` | + +Keep the **End Message** event here. The AI failure is handled, and processing returns to Exception Subprocess 1. + +![Local process Get AI Diagnosis](./assets/ai-adapter-step6-local-process.png) + +### Configure the AI Adapter + +From the palette, add a **Receiver** to the right of the canvas and name it `AI_Core`. Drag a connection from **Request Reply** to `AI_Core`, choose adapter type **AI**, and select the **AICore-GenAIHub** variant. + +Fill in the adapter settings, using the values from `From the Instructor.txt`: + +| Tab | Field | Value | +| :--- | :--- | :--- | +| Connection | Address | `address` from `From the Instructor.txt`, usually `https://api.ai..ml.hana.ondemand.com` | +| Connection | OAuth2 Client Credentials | `GenAI_Creds` | +| Connection | Reuse Connection | Unchecked | +| Connection | Connection Timeout (in ms) | `60000` | +| Connection | Response Timeout (in ms) | `60000` | +| Processing | Operation | `Orchestration - Completion` | +| Processing | Orchestration Deployment ID | `orchestration deployment id` from `From the Instructor.txt` | +| Processing | AI Resource Group | `ai resource group` from `From the Instructor.txt` (usually `default`) | +| Processing | Request Payload Source | `UI Configurable` | +| Processing | Model | `model` from `From the Instructor.txt`, for example `gpt-4o-mini` | +| Processing | Role | `user` | +| Processing | Input | `${body}` | + +> **Caution:** Keep the prompt out of the **Input** field. The adapter places the input into an internal URI. Braces, quotes, and line breaks there fail the deployment with `URISyntaxException: Illegal character in opaque part`. That's why the prompt is built in **Build Prompt** and **Input** holds only `${body}`. + +> **Note:** Field labels can differ slightly between adapter versions. If yours differ, match them by purpose. + +![AI Adapter, Connection tab](./assets/ai-adapter-step7-connection.png) + +![AI Adapter, Processing tab](./assets/ai-adapter-step7-processing.png) + +### Attach the AI Resolution to the Message + +In **Exception Subprocess 1**, add a **Process Call** (**Call → Local Call**) after **Capture Error**. Name it `Get AI Diagnosis` and select the local process **Get AI Diagnosis**. + +Add a **Groovy Script** after it, name it `Attach AI Diagnosis`, and paste the script below. Name the script file `AttachDiagnosis.groovy` and click **OK**. + +```groovy +/* + * AttachDiagnosis.groovy + * Exception subprocess 1 - runs after the "Get AI Diagnosis" process call. + * Reads the AI response, parses the JSON diagnosis, and writes: + * - MPL attachment "AI Diagnosis" (human-readable report) + * - custom header properties AI_Status, AI_Category, AI_Confidence, AI_NeedsHuman + * + * Handles three cases: + * 1. aiStatus = UNAVAILABLE / CONTEXT_FAILED -> attaches a "diagnosis unavailable" note + * 2. Response is (or contains) valid diagnosis JSON -> formatted report + * 3. Response is not parseable -> attaches raw response marked "unstructured" + * + * The response parser accepts either the model's text directly, or a full + * orchestration response (final_result / orchestration_result / choices[0].message.content). + * Never throws: error handling must not bury the original error. + */ +import com.sap.gateway.ip.core.customdev.util.Message +import groovy.json.JsonSlurper + +def Message processData(Message message) { + def log = messageLogFactory.getMessageLog(message) + // Flow ends with End Message (status Completed): flag the handled error so it stays searchable + log?.addCustomHeaderProperty("ErrorHandled", "true") + try { + String status = message.getProperty("aiStatus") ?: "OK" + String origClass = message.getProperty("origErrorClass") ?: "unknown" + String origMsg = message.getProperty("origErrorMessage") ?: "-" + String iflow = message.getProperty("iflowName") ?: "unknown" + String step = message.getProperty("flowStep") ?: "unknown" + + StringBuilder r = new StringBuilder() + r << "AI ERROR DIAGNOSIS\n" + r << "==================\n" + r << "iFlow : " << iflow << "\n" + r << "Failed step : " << step << "\n" + r << "Error class : " << origClass << "\n" + r << "Error : " << origMsg << "\n\n" + + // Case 1 - AI not called or AI call failed + if (status != "OK") { + r << "AI diagnosis unavailable (" << status << ").\n" + r << "Reason: " << (message.getProperty("aiError") ?: message.getProperty("contextError") ?: "-") << "\n" + r << "The original error above is unaffected - investigate manually.\n" + log?.addAttachmentAsString("AI Diagnosis", r.toString(), "text/plain") + log?.addCustomHeaderProperty("AI_Status", status) + return message + } + + String body = message.getBody(String) ?: "" + String content = extractContent(body) + def diag = parseJson(content) + + if (diag instanceof Map && diag.root_cause) { + // Case 2 - structured diagnosis + String category = (diag.category ?: "OTHER").toString() + String confidence = (diag.confidence ?: "LOW").toString() + String needsHuman = (diag.needs_human == null ? "true" : diag.needs_human.toString()) + + r << "Root cause : " << diag.root_cause << "\n" + r << "Category : " << category << "\n" + r << "Confidence : " << confidence << "\n" + r << "Needs human : " << needsHuman << "\n\n" + r << "Suggested fix steps:\n" + def steps = (diag.fix_steps instanceof List) ? diag.fix_steps : [diag.fix_steps ?: "-"] + steps.eachWithIndex { s, i -> r << " " << (i + 1) << ". " << s << "\n" } + + // Separate, resolution-only attachment for clean viewing + StringBuilder res = new StringBuilder() + res << "RESOLUTION\n" + res << "==========\n\n" + res << "Problem:\n " << diag.root_cause << "\n\n" + res << "What to do:\n" + steps.eachWithIndex { s, i -> res << " " << (i + 1) << ". " << s << "\n" } + res << "\nCategory: " << category << " | Confidence: " << confidence + res << " | Needs human: " << needsHuman << "\n" + res << "\n---\nAI-generated suggestion - verify before acting.\n" + log?.addAttachmentAsString("AI Resolution", res.toString(), "text/plain") + + log?.addCustomHeaderProperty("AI_Status", "OK") + log?.addCustomHeaderProperty("AI_Category", category) + log?.addCustomHeaderProperty("AI_Confidence", confidence) + log?.addCustomHeaderProperty("AI_NeedsHuman", needsHuman) + } else { + // Case 3 - model did not return the expected JSON + r << "AI response (unstructured - did not match expected JSON):\n" + r << (content ?: body) << "\n" + log?.addCustomHeaderProperty("AI_Status", "UNSTRUCTURED") + } + + r << "\n---\nAI-generated suggestion - verify before acting.\n" + log?.addAttachmentAsString("AI Diagnosis", r.toString(), "text/plain") + + } catch (Exception e) { + log?.addAttachmentAsString("AI Diagnosis", "Could not build AI diagnosis report: " + e.getMessage(), "text/plain") + log?.addCustomHeaderProperty("AI_Status", "REPORT_FAILED") + } + return message +} + +// Pull the model's text out of the response, whatever shape the adapter returns +String extractContent(String body) { + try { + def j = new JsonSlurper().parseText(body) + if (j instanceof Map) { + if (j.root_cause) return body // already the diagnosis itself + for (key in ["final_result", "orchestration_result"]) { + def c = j[key]?.choices?.getAt(0)?.message?.content + if (c) return c.toString() + } + def c = j.choices?.getAt(0)?.message?.content // plain chat-completion shape + if (c) return c.toString() + } + } catch (ignored) { } + return body // plain text response +} + +// Parse JSON, tolerating ```json fences or text around the object +def parseJson(String s) { + if (!s) return null + String t = s.trim() + t = t.replaceAll(/^```(?:json)?\s*/, "").replaceAll(/\s*```$/, "") + int a = t.indexOf('{') + int b = t.lastIndexOf('}') + if (a >= 0 && b > a) t = t.substring(a, b + 1) + try { + return new JsonSlurper().parseText(t) + } catch (ignored) { + return null + } +} +``` + +Connect the steps: **Error Start → Capture Error → Get AI Diagnosis → Attach AI Diagnosis → End Message**. + +![Exception Subprocess 1 complete](./assets/ai-adapter-step8-exception-subprocess.png) + +The script reads the model's answer from the orchestration response and writes: + +| Output | Content | +| :--- | :--- | +| Attachment **AI Resolution** | The problem and numbered fix steps only, for clean viewing | +| Attachment **AI Diagnosis** | The full report: iFlow, failed step, original error, root cause, category, confidence, and fix steps | +| Custom header properties | `ErrorHandled`, `AI_Status`, `AI_Category`, `AI_Confidence`, and `AI_NeedsHuman`, searchable in the Monitor | + +#### Optional: Debug AI Output + +While building, you can add a script between **Get AI Diagnosis** and **Attach AI Diagnosis** that attaches the raw adapter response. It shows exactly what the AI received and returned. Remove it when you're done. + +```groovy +import com.sap.gateway.ip.core.customdev.util.Message + +def Message processData(Message message) { + def log = messageLogFactory.getMessageLog(message) + String status = message.getProperty("aiStatus") ?: "OK" + String report = "aiStatus: " + status + "\n" + + "aiError: " + (message.getProperty("aiError") ?: "-") + "\n\n" + + "AI response body:\n" + (message.getBody(String) ?: "") + log?.addAttachmentAsString("AI output (debug)", report, "text/plain") + return message +} +``` + +Here is the finished flow: + +![The complete integration flow](./assets/ai-adapter-step8-finished-iflow.png) + +### Deploy and Read the AI Resolution + +Click **Save**, then **Deploy**. Go to **Monitor → Integrations and APIs → Manage Integration Content** and check that your integration flow shows **Started**. + +Open **Monitor Message Processing** and open the newest message. Its status is **Completed**: the error was caught and handled. Click **Attachments** and open **AI Resolution**. + +![AI Resolution attachment, status Completed](./assets/ai-adapter-step9-ai-resolution.png) + +The AI Resolution looks similar to this. The wording varies between runs. + +```text +RESOLUTION +========== + +Problem: + SalesOrder schema validation failed due to an invalid value for the Quantity field, which expects an integer but received a string. + +What to do: + 1. Modify the Quantity field in the SalesOrder payload to a valid integer value, replacing 'five' with a numerical value. + 2. Ensure that the source system sends data in the expected format adhering to the schema. + 3. Implement validation rules in the source system to catch such errors before the payload is sent. + 4. Review and update the mapping to appropriately handle and transform incoming data. + +Category: DATA | Confidence: HIGH | Needs human: false + +--- +AI-generated suggestion - verify before acting. +``` + +| Check | Where | What you should see | +| :--- | :--- | :--- | +| Original error kept | **Attachments → AI Diagnosis** | The `'five'` validation error for `Quantity` | +| Readable resolution | **Attachments → AI Resolution** | The problem and 2 to 4 fix steps that name `Quantity` | +| Searchable result | **Custom Header Properties** | `AI_Category = DATA`, `AI_Status = OK`, and `ErrorHandled = true` | + +![Custom Header Properties with AI_ and ErrorHandled values](./assets/ai-adapter-step9-custom-headers.png) + +> **Run it again:** The timer runs once per deployment. To run again, deploy again. The AI's wording varies between runs; the category and the field it names should stay the same. + +> **Caution:** If you raised the log level to **Trace** while building, set it back to **Info**. Trace stores full payloads at every step. + +#### Troubleshooting + +| Issue | Fix | +| :--- | :--- | +| Error says "Refer to MPL attachment (XML Validator Error Document)" | You're using the standard XML Validator. Use the Validate Order script from Step 3. | +| `403 Forbidden` while retrieving the access token | The Token Service URL is wrong. Use the service key's `url` plus `/oauth/token`, not your Cloud Integration tenant URL (Step 2). | +| `400 Bad Request` with `Missing Tenant Id` | Likely cause (unverified): the token and the address come from different SAP AI Core instances. Check the client ID, secret, token URL, Address, and deployment ID all come from the same service key and instance, redeploy the Security Material, then redeploy the flow. | +| Deployment fails: `URISyntaxException: Illegal character in opaque part` | The prompt is in the adapter's Input field. Set Input to `${body}` only and keep the prompt in Build Prompt (Step 6). | +| `404` from the AI Adapter | The deployment ID and resource group don't match. Both must belong to the same orchestration deployment. | +| `401` from the AI Adapter | Check the client ID and secret in the Security Material come from the same service key. | +| Model not found | That model isn't enabled for orchestration. Ask your instructor which models are available. | +| The AI answers "insufficient information" with confidence LOW | The AI never received the error. Set the Build Prompt body Type to **Expression**. The Debug AI Output script shows exactly what was sent. | +| The error message appears three times | Cloud Integration wraps script errors. Use the provided Capture Error script, which reads only the innermost exception. | +| `OrderDate` shows `[PHONE]` in the AI input | An older masking pattern matched ISO dates. Use the Capture Error script from Step 5. | +| AI Resolution is missing; only AI Diagnosis appears | An older Attach AI Diagnosis script is deployed. Paste the script from Step 8 and deploy again. | +| AI Diagnosis says "AI diagnosis unavailable (UNAVAILABLE)" | The AI call failed and Exception Subprocess 2 caught it. Check `aiError` in the Debug AI Output attachment for the reason. | +| `AI_Status = UNSTRUCTURED` | The model didn't return the expected JSON. Check the prompt in Build Prompt is complete, then compare with the raw response in Debug AI Output. | +| The message shows Failed instead of Completed | Exception Subprocess 1 ends with an Error End. Replace it with an **End Message** event. | +| Message Content shows raw JSON, not the resolution | Message Content is the message body, which still holds the adapter response. The resolution is under **Attachments → AI Resolution**. | +| My message never appears | The timer runs once per deployment. Deploy again, and check the flow is **Started** in Manage Integration Content. | + +#### Try It Yourself + +Change the payload in **Set Order Payload** and deploy again to compare the diagnoses: + +| Variant | Change | What the AI should conclude | +| :--- | :--- | :--- | +| A (default) | `five` | A data type error: the source sends text in a numeric field | +| B | Delete the `` line | A missing mandatory field | +| C | `08/10/2026` | A date format error; the date must be `YYYY-MM-DD` | +| D | `euro` | A currency code error; a 3-letter ISO code is expected | + +Then try these: + +- In the Monitor, filter by the custom header property `AI_Category`. +- Change the client secret in your Security Material to a wrong value and deploy. AI Diagnosis says the diagnosis is unavailable, while the original error is kept. Then restore the secret. +- Remove the Build Prompt rule that asks for 2 to 4 fix steps and compare the resolution. + +You built a flow that catches an integration error, masks it, asks SAP AI Core for a diagnosis through the AI Adapter, and attaches a readable resolution to the message. + +#### References + +- [AI Receiver Adapter](https://help.sap.com/docs/integration-suite/isuite-integrations-and-apis/ai-receiver-adapter) +- [AI Adapter for SAP Integration Suite (SAP Community)](https://community.sap.com/t5/technology-blog-posts-by-sap/ai-adapter-for-sap-integration-suite/ba-p/14175843) +- [Consumption of GenAI models Using Orchestration](https://developers.sap.com/tutorials/ai-core-orchestration-consumption.html) diff --git a/tutorials/dev-garage-error-resolution-ai-adapter/files/AttachDiagnosis.groovy b/tutorials/dev-garage-error-resolution-ai-adapter/files/AttachDiagnosis.groovy new file mode 100644 index 0000000..2c43d51 --- /dev/null +++ b/tutorials/dev-garage-error-resolution-ai-adapter/files/AttachDiagnosis.groovy @@ -0,0 +1,130 @@ +/* + * AttachDiagnosis.groovy + * Exception subprocess 1 - runs after the "Get AI Diagnosis" process call. + * Reads the AI response, parses the JSON diagnosis, and writes: + * - MPL attachment "AI Diagnosis" (human-readable report) + * - custom header properties AI_Status, AI_Category, AI_Confidence, AI_NeedsHuman + * + * Handles three cases: + * 1. aiStatus = UNAVAILABLE / CONTEXT_FAILED -> attaches a "diagnosis unavailable" note + * 2. Response is (or contains) valid diagnosis JSON -> formatted report + * 3. Response is not parseable -> attaches raw response marked "unstructured" + * + * The response parser accepts either the model's text directly, or a full + * orchestration response (final_result / orchestration_result / choices[0].message.content). + * Never throws: error handling must not bury the original error. + */ +import com.sap.gateway.ip.core.customdev.util.Message +import groovy.json.JsonSlurper + +def Message processData(Message message) { + def log = messageLogFactory.getMessageLog(message) + // Flow ends with End Message (status Completed): flag the handled error so it stays searchable + log?.addCustomHeaderProperty("ErrorHandled", "true") + try { + String status = message.getProperty("aiStatus") ?: "OK" + String origClass = message.getProperty("origErrorClass") ?: "unknown" + String origMsg = message.getProperty("origErrorMessage") ?: "-" + String iflow = message.getProperty("iflowName") ?: "unknown" + String step = message.getProperty("flowStep") ?: "unknown" + + StringBuilder r = new StringBuilder() + r << "AI ERROR DIAGNOSIS\n" + r << "==================\n" + r << "iFlow : " << iflow << "\n" + r << "Failed step : " << step << "\n" + r << "Error class : " << origClass << "\n" + r << "Error : " << origMsg << "\n\n" + + // Case 1 - AI not called or AI call failed + if (status != "OK") { + r << "AI diagnosis unavailable (" << status << ").\n" + r << "Reason: " << (message.getProperty("aiError") ?: message.getProperty("contextError") ?: "-") << "\n" + r << "The original error above is unaffected - investigate manually.\n" + log?.addAttachmentAsString("AI Diagnosis", r.toString(), "text/plain") + log?.addCustomHeaderProperty("AI_Status", status) + return message + } + + String body = message.getBody(String) ?: "" + String content = extractContent(body) + def diag = parseJson(content) + + if (diag instanceof Map && diag.root_cause) { + // Case 2 - structured diagnosis + String category = (diag.category ?: "OTHER").toString() + String confidence = (diag.confidence ?: "LOW").toString() + String needsHuman = (diag.needs_human == null ? "true" : diag.needs_human.toString()) + + r << "Root cause : " << diag.root_cause << "\n" + r << "Category : " << category << "\n" + r << "Confidence : " << confidence << "\n" + r << "Needs human : " << needsHuman << "\n\n" + r << "Suggested fix steps:\n" + def steps = (diag.fix_steps instanceof List) ? diag.fix_steps : [diag.fix_steps ?: "-"] + steps.eachWithIndex { s, i -> r << " " << (i + 1) << ". " << s << "\n" } + + // Separate, resolution-only attachment for clean viewing + StringBuilder res = new StringBuilder() + res << "RESOLUTION\n" + res << "==========\n\n" + res << "Problem:\n " << diag.root_cause << "\n\n" + res << "What to do:\n" + steps.eachWithIndex { s, i -> res << " " << (i + 1) << ". " << s << "\n" } + res << "\nCategory: " << category << " | Confidence: " << confidence + res << " | Needs human: " << needsHuman << "\n" + res << "\n---\nAI-generated suggestion - verify before acting.\n" + log?.addAttachmentAsString("AI Resolution", res.toString(), "text/plain") + + log?.addCustomHeaderProperty("AI_Status", "OK") + log?.addCustomHeaderProperty("AI_Category", category) + log?.addCustomHeaderProperty("AI_Confidence", confidence) + log?.addCustomHeaderProperty("AI_NeedsHuman", needsHuman) + } else { + // Case 3 - model did not return the expected JSON + r << "AI response (unstructured - did not match expected JSON):\n" + r << (content ?: body) << "\n" + log?.addCustomHeaderProperty("AI_Status", "UNSTRUCTURED") + } + + r << "\n---\nAI-generated suggestion - verify before acting.\n" + log?.addAttachmentAsString("AI Diagnosis", r.toString(), "text/plain") + + } catch (Exception e) { + log?.addAttachmentAsString("AI Diagnosis", "Could not build AI diagnosis report: " + e.getMessage(), "text/plain") + log?.addCustomHeaderProperty("AI_Status", "REPORT_FAILED") + } + return message +} + +// Pull the model's text out of the response, whatever shape the adapter returns +String extractContent(String body) { + try { + def j = new JsonSlurper().parseText(body) + if (j instanceof Map) { + if (j.root_cause) return body // already the diagnosis itself + for (key in ["final_result", "orchestration_result"]) { + def c = j[key]?.choices?.getAt(0)?.message?.content + if (c) return c.toString() + } + def c = j.choices?.getAt(0)?.message?.content // plain chat-completion shape + if (c) return c.toString() + } + } catch (ignored) { } + return body // plain text response +} + +// Parse JSON, tolerating ```json fences or text around the object +def parseJson(String s) { + if (!s) return null + String t = s.trim() + t = t.replaceAll(/^```(?:json)?\s*/, "").replaceAll(/\s*```$/, "") + int a = t.indexOf('{') + int b = t.lastIndexOf('}') + if (a >= 0 && b > a) t = t.substring(a, b + 1) + try { + return new JsonSlurper().parseText(t) + } catch (ignored) { + return null + } +} \ No newline at end of file diff --git a/tutorials/dev-garage-error-resolution-ai-adapter/files/CaptureError.groovy b/tutorials/dev-garage-error-resolution-ai-adapter/files/CaptureError.groovy new file mode 100644 index 0000000..3024047 --- /dev/null +++ b/tutorials/dev-garage-error-resolution-ai-adapter/files/CaptureError.groovy @@ -0,0 +1,88 @@ +/* + * CaptureError.groovy + * Exception subprocess - first step after Error Start. + * Captures the original error plus context, masks sensitive data, truncates, + * and builds the JSON input for the AI adapter. + * + * Expects these exchange properties (set in "Set Context" content modifier): + * iflowName = ${camelId} + * flowStep = ValidateOrder + * businessObject = SalesOrder + * + * Output: + * property origErrorClass / origErrorMessage (masked, for the attachment later) + * property aiErrorContext (compact JSON) + * message body (pretty JSON - AI input) + * property aiStatus = CONTEXT_FAILED (only if this script itself fails) + */ +import com.sap.gateway.ip.core.customdev.util.Message +import groovy.json.JsonOutput + +def Message processData(Message message) { + try { + // 1. The exception Cloud Integration caught + def ex = message.getProperty("CamelExceptionCaught") + + // Use the innermost exception: Cloud Integration wraps script errors several times + def root = ex + while (root?.getCause() != null && root.getCause() != root) { + root = root.getCause() + } + String errClass = root ? root.getClass().getName() : "unknown" + String errMsg = root?.getMessage() ?: ex?.getMessage() ?: "no error message" + + // 2. HTTP-type errors also carry a response body (optional, harmless if absent) + String httpBody = "" + if (ex != null && ex.metaClass.respondsTo(ex, "getResponseBody")) { + httpBody = ex.getResponseBody() ?: "" + } + + // 3. The payload as it was when the flow failed + String payload = message.getBody(String) ?: "" + + // 4. Build the context the AI will see - masked and truncated + def ctx = [ + iflow : message.getProperty("iflowName") ?: "unknown", + failedStep : message.getProperty("flowStep") ?: "unknown", + businessObject: message.getProperty("businessObject") ?: "", + mplId : message.getHeaders().get("SAP_MessageProcessingLogID") ?: "", + errorClass : errClass, + errorMessage : mask(truncate(errMsg, 4000)), + httpResponse : mask(truncate(httpBody, 1000)), + payloadExtract: mask(truncate(payload, 2000)) + ] + + // 5. Save the original error first, so nothing later can overwrite it + message.setProperty("origErrorClass", errClass) + message.setProperty("origErrorMessage", ctx.errorMessage) + + // 6. AI input: kept as a property and set as the body + String json = JsonOutput.toJson(ctx) + message.setProperty("aiErrorContext", json) + message.setBody(JsonOutput.prettyPrint(json)) + + } catch (Exception e) { + // Never throw from error handling - it would bury the original error + message.setProperty("aiStatus", "CONTEXT_FAILED") + message.setProperty("contextError", e.getMessage()) + } + return message +} + +String truncate(String s, int max) { + if (!s) return "" + return s.length() > max ? s.substring(0, max) + " ...[truncated]" : s +} + +// Regex-based safety net only - NOT a full data-protection solution. +// Note: the [PHONE] pattern also masks any long number (e.g. order/PO numbers); narrow it if needed. +String mask(String s) { + if (!s) return "" + return s + .replaceAll(/[\w.+-]+@[\w-]+\.[\w.]+/, "[EMAIL]") + .replaceAll(/\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/, "[IBAN]") + .replaceAll(/\b(?:\d[ -]?){13,19}\b/, "[CARD]") + .replaceAll(/(?]+)[^"'\s<,&]+/, '$1$2[SECRET]') + .replaceAll(/(?i)bearer\s+[A-Za-z0-9._~+\/=-]+/, "Bearer [SECRET]") +} \ No newline at end of file diff --git a/tutorials/dev-garage-error-resolution-ai-adapter/files/DebugAIOutput.groovy b/tutorials/dev-garage-error-resolution-ai-adapter/files/DebugAIOutput.groovy new file mode 100644 index 0000000..4db7943 --- /dev/null +++ b/tutorials/dev-garage-error-resolution-ai-adapter/files/DebugAIOutput.groovy @@ -0,0 +1,11 @@ +import com.sap.gateway.ip.core.customdev.util.Message + +def Message processData(Message message) { + def log = messageLogFactory.getMessageLog(message) + String status = message.getProperty("aiStatus") ?: "OK" + String report = "aiStatus: " + status + "\n" + + "aiError: " + (message.getProperty("aiError") ?: "-") + "\n\n" + + "AI response body:\n" + (message.getBody(String) ?: "") + log?.addAttachmentAsString("AI output (debug)", report, "text/plain") + return message +} \ No newline at end of file diff --git a/tutorials/dev-garage-error-resolution-ai-adapter/files/SalesOrder.xsd b/tutorials/dev-garage-error-resolution-ai-adapter/files/SalesOrder.xsd new file mode 100644 index 0000000..9eab1b4 --- /dev/null +++ b/tutorials/dev-garage-error-resolution-ai-adapter/files/SalesOrder.xsd @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tutorials/dev-garage-error-resolution-ai-adapter/files/ValidateOrder.groovy b/tutorials/dev-garage-error-resolution-ai-adapter/files/ValidateOrder.groovy new file mode 100644 index 0000000..4887756 --- /dev/null +++ b/tutorials/dev-garage-error-resolution-ai-adapter/files/ValidateOrder.groovy @@ -0,0 +1,84 @@ +/* + * ValidateOrder.groovy + * Main integration process - replaces the standard XML Validator step. + * Validates the SalesOrder payload against the embedded XSD and throws an + * exception whose message contains the full validation detail (line + reason), + * so the exception subprocess can pass it to the AI adapter. + */ +import com.sap.gateway.ip.core.customdev.util.Message +import groovy.transform.Field +import javax.xml.XMLConstants +import javax.xml.transform.stream.StreamSource +import javax.xml.validation.SchemaFactory +import org.xml.sax.ErrorHandler +import org.xml.sax.SAXParseException + +@Field String XSD = ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +''' + +def Message processData(Message message) { + String xml = message.getBody(String) + + def schema = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI) + .newSchema(new StreamSource(new StringReader(XSD))) + def validator = schema.newValidator() + + List errors = [] + validator.setErrorHandler([ + warning : { SAXParseException e -> }, + error : { SAXParseException e -> errors << "Line ${e.lineNumber}: ${e.message}" }, + fatalError: { SAXParseException e -> errors << "Line ${e.lineNumber}: ${e.message}" } + ] as ErrorHandler) + + try { + validator.validate(new StreamSource(new StringReader(xml))) + } catch (SAXParseException e) { + // Fatal errors (e.g. malformed XML) stop validation; the handler usually recorded them already + if (!errors) errors << "Line ${e.lineNumber}: ${e.message}" + } + + if (errors) { + throw new IllegalStateException("SalesOrder schema validation failed: " + errors.join(" | ")) + } + return message +} \ No newline at end of file