Skip to content

Commit 7085f68

Browse files
michaelmcneesclaude
andcommitted
docs: add data transformation patterns guide (#14)
Covers three patterns — CEL-only structural transforms, AI-powered semantic transforms, and hybrid workflows that combine both. Includes a decision guide and a quick-reference table of all available CEL extension functions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e735241 commit 7085f68

1 file changed

Lines changed: 308 additions & 0 deletions

File tree

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
# Data Transformations
2+
3+
Workflows rarely move data from one place to another without changing its shape. A user record from one API uses camelCase and ISO dates; your database expects snake_case and Unix timestamps. A support ticket arrives as unstructured text; your alerting system needs a priority label and extracted entity names.
4+
5+
Mantle handles both categories: mechanical transforms using CEL expressions, and semantic transforms using the AI connector. This guide covers each pattern and when to reach for each one.
6+
7+
## Pattern 1: Structural Transforms with CEL
8+
9+
Use CEL when the mapping between source and target is known and deterministic -- field renaming, case normalization, type coercion, and filtering. CEL runs in the engine with no external calls, so structural transforms add no latency and no cost.
10+
11+
**Example:** An upstream API returns user records with camelCase fields and a date of birth string. Your database expects snake_case and rejects the original field names.
12+
13+
Source:
14+
15+
```json
16+
{"firstName": "Alice", "lastName": "Smith", "dob": "1995-03-24"}
17+
```
18+
19+
Target:
20+
21+
```json
22+
{"name": "alice smith", "birth_date": "1995-03-24"}
23+
```
24+
25+
Here is a complete workflow that fetches a list of user records, reshapes each one, and writes the results to Postgres:
26+
27+
```yaml
28+
name: normalize-users
29+
description: >
30+
Fetch user records from the upstream API, normalize field names and
31+
casing, then insert into the local database.
32+
33+
steps:
34+
- name: fetch-users
35+
action: http/request
36+
params:
37+
method: GET
38+
url: "https://api.example.com/users"
39+
40+
- name: insert-users
41+
action: http/request
42+
params:
43+
method: POST
44+
url: "https://internal.example.com/db/users/batch"
45+
headers:
46+
Content-Type: "application/json"
47+
body:
48+
records: >
49+
{{ steps['fetch-users'].output.json.users.map(u,
50+
obj(
51+
'name', toLower(u.firstName + ' ' + u.lastName),
52+
'birth_date', u.dob
53+
)
54+
) }}
55+
```
56+
57+
What the CEL expression does:
58+
59+
- `.map(u, ...)` -- iterates the `users` list, binding each element to `u`
60+
- `obj('name', ..., 'birth_date', ...)` -- constructs a new object with the renamed keys
61+
- `toLower(...)` -- normalizes the full name to lowercase
62+
- String concatenation (`+`) joins first and last name with a space
63+
64+
The output of the `map()` call is a new list of objects ready for the batch insert. Nothing left the workflow engine.
65+
66+
## Pattern 2: AI-Powered Transforms
67+
68+
Use the AI connector when the transform requires interpretation, classification, or understanding that a deterministic expression cannot provide. Common cases: classifying priority from free-form text, extracting named entities, generating summaries, or translating between domain vocabularies.
69+
70+
**Example:** Raw support tickets arrive as unstructured text. Your team needs each ticket classified by priority, categorized by product area, and tagged with any affected usernames or order IDs mentioned in the body.
71+
72+
Here is a workflow that fetches open tickets, uses the AI connector to extract structured data, and conditionally routes high-priority items to a separate store:
73+
74+
```yaml
75+
name: classify-tickets
76+
description: >
77+
Fetch open support tickets, classify each one using structured AI output,
78+
and store high-priority tickets in the escalation queue.
79+
80+
steps:
81+
- name: fetch-tickets
82+
action: http/request
83+
params:
84+
method: GET
85+
url: "https://support.example.com/api/tickets?status=open"
86+
87+
- name: classify
88+
action: ai/completion
89+
params:
90+
provider: openai
91+
credential: openai
92+
model: gpt-4o-mini
93+
prompt: >
94+
Classify the following support ticket. Extract the priority, product
95+
area, and any entity identifiers (usernames, order IDs) mentioned.
96+
97+
Ticket:
98+
{{ steps['fetch-tickets'].output.json.tickets[0].body }}
99+
output_schema:
100+
type: object
101+
properties:
102+
priority:
103+
type: string
104+
enum: [low, medium, high, critical]
105+
product_area:
106+
type: string
107+
enum: [billing, authentication, api, ui, other]
108+
entities:
109+
type: array
110+
items:
111+
type: object
112+
properties:
113+
type:
114+
type: string
115+
value:
116+
type: string
117+
required: [priority, product_area, entities]
118+
119+
- name: store-escalation
120+
action: http/request
121+
if: >
122+
steps.classify.output.json.priority == 'high' ||
123+
steps.classify.output.json.priority == 'critical'
124+
params:
125+
method: POST
126+
url: "https://internal.example.com/escalation-queue"
127+
headers:
128+
Content-Type: "application/json"
129+
body:
130+
ticket_id: "{{ steps['fetch-tickets'].output.json.tickets[0].id }}"
131+
priority: "{{ steps.classify.output.json.priority }}"
132+
product_area: "{{ steps.classify.output.json.product_area }}"
133+
entities: "{{ steps.classify.output.json.entities }}"
134+
```
135+
136+
Key points:
137+
138+
- `output_schema` tells the AI connector to return structured JSON matching the schema, not free-form text. The engine validates the response against the schema before making it available as `output.json`.
139+
- The `if` field on `store-escalation` is a bare CEL expression that reads from the AI step's structured output. CEL works on the schema-validated object directly.
140+
- For bulk processing, wrap this pattern in a `forEach` or a parent workflow that fans out over the ticket list.
141+
142+
See the [AI Workflows guide](/docs/getting-started/ai-workflows) for credential setup and the full `output_schema` reference.
143+
144+
## Pattern 3: Hybrid Transforms
145+
146+
Combine CEL for structural normalization with the AI connector for semantic enrichment. Use CEL first to extract and reshape the fields you need, then pass the cleaned data to the AI step. This keeps prompts concise and keeps AI costs proportional to the semantic work required.
147+
148+
**Example:** A product reviews feed includes raw ratings, dates, and freeform review text mixed with metadata you do not need. You want to store normalized records enriched with sentiment labels and key themes.
149+
150+
```yaml
151+
name: enrich-reviews
152+
description: >
153+
Fetch product reviews, normalize structure with CEL, enrich each review
154+
with AI-classified sentiment and themes, then store the enriched records.
155+
156+
steps:
157+
- name: fetch-reviews
158+
action: http/request
159+
params:
160+
method: GET
161+
url: "https://api.example.com/products/{{ inputs.product_id }}/reviews"
162+
163+
- name: normalize
164+
action: http/request
165+
params:
166+
method: POST
167+
url: "https://internal.example.com/transform/passthrough"
168+
headers:
169+
Content-Type: "application/json"
170+
body:
171+
reviews: >
172+
{{ steps['fetch-reviews'].output.json.data.map(r,
173+
obj(
174+
'id', r.reviewId,
175+
'rating', r.starRating,
176+
'reviewed_at', r.submittedAt,
177+
'text', trim(r.body)
178+
)
179+
) }}
180+
181+
- name: enrich
182+
action: ai/completion
183+
params:
184+
provider: openai
185+
credential: openai
186+
model: gpt-4o-mini
187+
prompt: >
188+
Analyze the following product reviews and classify the sentiment
189+
and key themes for each one.
190+
191+
Reviews:
192+
{{ steps.normalize.output.json.reviews }}
193+
output_schema:
194+
type: array
195+
items:
196+
type: object
197+
properties:
198+
id:
199+
type: string
200+
sentiment:
201+
type: string
202+
enum: [positive, neutral, negative]
203+
themes:
204+
type: array
205+
items:
206+
type: string
207+
required: [id, sentiment, themes]
208+
209+
- name: store
210+
action: http/request
211+
params:
212+
method: POST
213+
url: "https://internal.example.com/db/reviews/batch"
214+
headers:
215+
Content-Type: "application/json"
216+
body:
217+
records: "{{ steps.enrich.output.json }}"
218+
219+
inputs:
220+
product_id:
221+
type: string
222+
description: The product ID to fetch and enrich reviews for
223+
```
224+
225+
The three-stage pattern:
226+
227+
1. **Fetch** -- pull raw data from the source
228+
2. **Normalize (CEL)** -- extract only the fields you need, rename them, trim whitespace, coerce types
229+
3. **Enrich (AI)** -- pass the clean, minimal payload to the AI step; the smaller the input, the lower the token cost and the more reliable the output
230+
231+
The AI step receives already-cleaned data, so the prompt stays focused on the semantic task rather than field mapping instructions.
232+
233+
## When to Use CEL vs AI
234+
235+
| Situation | Use |
236+
|---|---|
237+
| Rename or reorder fields | CEL |
238+
| Change string case | CEL |
239+
| Parse or format dates and timestamps | CEL |
240+
| Filter a list by a field value | CEL |
241+
| Convert types (string to int, etc.) | CEL |
242+
| Compose values from multiple fields | CEL |
243+
| Classify text into known categories | AI |
244+
| Extract named entities from prose | AI |
245+
| Determine sentiment or tone | AI |
246+
| Summarize freeform content | AI |
247+
| Map between domain vocabularies without a fixed rule | AI |
248+
| Structural reshape + semantic enrichment | Hybrid |
249+
250+
The decision is usually straightforward: if you could write the rule as an `if` statement in Go, use CEL. If you would struggle to enumerate all the cases, use AI.
251+
252+
## Available Functions Reference
253+
254+
These are the Mantle CEL extensions available in workflow expressions. For full signatures, examples, and edge cases, see the [Expressions guide](/docs/concepts/expressions).
255+
256+
**List macros**
257+
258+
| Function | Description |
259+
|---|---|
260+
| `.map(var, expr)` | Transform each element, returning a new list |
261+
| `.filter(var, expr)` | Return elements where `expr` evaluates to true |
262+
| `.exists(var, expr)` | True if any element satisfies `expr` |
263+
| `.all(var, expr)` | True if every element satisfies `expr` |
264+
265+
**String**
266+
267+
| Function | Description |
268+
|---|---|
269+
| `toLower(s)` | Convert string to lowercase |
270+
| `toUpper(s)` | Convert string to uppercase |
271+
| `trim(s)` | Remove leading and trailing whitespace |
272+
| `replace(s, old, new)` | Replace all occurrences of `old` with `new` |
273+
| `split(s, sep)` | Split string into a list on separator |
274+
275+
**Object construction**
276+
277+
| Function | Description |
278+
|---|---|
279+
| `obj(key, val, ...)` | Construct a map from alternating key-value arguments |
280+
281+
**Type coercion**
282+
283+
| Function | Description |
284+
|---|---|
285+
| `parseInt(s)` | Parse string to integer |
286+
| `parseFloat(s)` | Parse string to float |
287+
| `toString(v)` | Convert any value to its string representation |
288+
289+
**JSON**
290+
291+
| Function | Description |
292+
|---|---|
293+
| `jsonEncode(v)` | Serialize a value to a JSON string |
294+
| `jsonDecode(s)` | Parse a JSON string into a CEL value |
295+
296+
**Time**
297+
298+
| Function | Description |
299+
|---|---|
300+
| `parseTimestamp(s)` | Parse an RFC 3339 string into a timestamp |
301+
| `formatTimestamp(t, layout)` | Format a timestamp using a Go time layout string |
302+
303+
**Utility**
304+
305+
| Function | Description |
306+
|---|---|
307+
| `default(v, fallback)` | Return `v` if it is set and non-null, otherwise `fallback` |
308+
| `flatten(list)` | Flatten a list of lists into a single list |

0 commit comments

Comments
 (0)