@alvera-ai/platform-sdk 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent/AGENTS.md +82 -144
- package/.agent/account_management.md +2 -2
- package/.agent/action_logs.md +4 -4
- package/.agent/ai_agents.md +28 -21
- package/.agent/ai_sandbox.md +49 -39
- package/.agent/connected_apps.md +3 -3
- package/.agent/cookbook/_fixtures/README.md +1 -1
- package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_generic_table.liquid +1 -1
- package/.agent/cookbook/_fixtures/organic-marketing/_lead_submissions_foundation_legal_entity.liquid +80 -0
- package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_mdm.liquid +2 -1
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_generic_table.liquid +57 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_legal_entity.liquid +30 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_mdm.liquid +44 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_generic_table.liquid +57 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_legal_entity.liquid +36 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_mdm.liquid +41 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_generic_table.liquid +70 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_legal_entity.liquid +52 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_mdm.liquid +42 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_generic_table.liquid +38 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_legal_entity.liquid +48 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_mdm.liquid +49 -0
- package/.agent/cookbook/organic-marketing.md +2801 -0
- package/.agent/cookbook/payments-compliance.md +2180 -0
- package/.agent/cookbook/primary-care.md +2175 -0
- package/.agent/cookbook/subscription-saas.md +2403 -0
- package/.agent/data_activation_clients.md +65 -52
- package/.agent/datalakes.md +338 -171
- package/.agent/errors.md +3 -3
- package/.agent/generic_tables.md +151 -62
- package/.agent/interoperability_contracts.md +57 -22
- package/.agent/mdm.md +136 -153
- package/.agent/messages.md +36 -34
- package/.agent/mock-services.md +1 -1
- package/.agent/mutations.md +2 -2
- package/.agent/templates.md +14 -13
- package/.agent/tools.md +63 -21
- package/.agent/type_naming.md +13 -13
- package/.agent/workflows.md +99 -53
- package/README.md +2 -2
- package/dist/bin/platform-sdk.mjs +33 -47
- package/dist/bin/platform-sdk.mjs.map +1 -1
- package/dist/index.d.mts +565 -379
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +494 -59
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
- package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +0 -88
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +0 -47
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +0 -24
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +0 -38
- package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_compliance_screening.liquid +0 -59
- package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_mdm.liquid +0 -36
- package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_mdm.liquid +0 -30
- package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_payment_account.liquid +0 -55
- package/.agent/cookbook/_fixtures/subscription/_customers_subscription_mdm.liquid +0 -20
- package/.agent/cookbook/_setup/foundation.md +0 -359
- package/.agent/cookbook/_setup/healthcare.md +0 -361
- package/.agent/cookbook/_setup/payments.md +0 -365
- package/.agent/cookbook/_setup/subscription.md +0 -364
- package/.agent/cookbook/action-status-updaters.md +0 -278
- package/.agent/cookbook/ai-agent-invoke.md +0 -279
- package/.agent/cookbook/appointment-review-sms-workflow.md +0 -801
- package/.agent/cookbook/birthday-greeting-sms-trigger.md +0 -696
- package/.agent/cookbook/bulk-ingest.md +0 -302
- package/.agent/cookbook/contact-us-triage-with-llm.md +0 -663
- package/.agent/cookbook/dunning-sms-for-delinquent.md +0 -659
- package/.agent/cookbook/generic-tables.md +0 -244
- package/.agent/cookbook/invite-team.md +0 -200
- package/.agent/cookbook/kyc-notification-on-account-activation.md +0 -661
- package/.agent/cookbook/marketing-campaign-send.md +0 -1044
- package/.agent/cookbook/paginated-restapi-poller.md +0 -383
- package/.agent/cookbook/rest-fetch.md +0 -273
- package/.agent/cookbook/sanctions-screening-with-agent-review.md +0 -773
- package/.agent/cookbook/score-leads-with-llm-categorization.md +0 -665
- package/.agent/cookbook/system-templates.md +0 -165
- package/.agent/cookbook/talk-to-data.md +0 -178
- package/.agent/cookbook/triage-prospects-by-priority.md +0 -571
- package/.agent/cookbook/welcome-sms-for-customers.md +0 -647
- /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/memorandum-of-association-01.png +0 -0
- /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/sample_two_page.pdf +0 -0
- /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/_customers_subscription_customer.liquid +0 -0
- /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/stripe_customers_batch1.csv +0 -0
|
@@ -1,773 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Disambiguate gray-zone sanctions screenings via an LLM agent
|
|
3
|
-
summary: End-to-end agent-driven workflow — a chat-completion LLM agent disambiguates gray-zone-scored sanctions screenings into a verified/blocked verdict, the workflow's decision interpolates that verdict, two SMS actions (one per verdict) fan out, and the run is verified row-by-row. A clearly-clear screening is filtered out before the agent ever sees it.
|
|
4
|
-
industry: payments
|
|
5
|
-
slug: sanctions-screening-with-agent-review
|
|
6
|
-
vitest_source:
|
|
7
|
-
- integration-tests/tests/payments/sanctions-review-workflow.test.ts
|
|
8
|
-
- integration-tests/tests/payments/interoperability-contracts.test.ts
|
|
9
|
-
- integration-tests/tests/payments/run-dac-single.test.ts
|
|
10
|
-
- integration-tests/tests/payments/create-dac.test.ts
|
|
11
|
-
- integration-tests/tests/payments/tools.test.ts
|
|
12
|
-
- integration-tests/tests/payments/data-sources.test.ts
|
|
13
|
-
- integration-tests/tests/payments/bootstrap.test.ts
|
|
14
|
-
status: green
|
|
15
|
-
---
|
|
16
|
-
|
|
17
|
-
# Problem
|
|
18
|
-
|
|
19
|
-
A sanctions-screening provider returns a match score for every
|
|
20
|
-
name it checks against a watchlist. The extremes are easy: a
|
|
21
|
-
score near zero is a clean pass, a score near one is a confirmed
|
|
22
|
-
hit. The middle band — a name close enough to a sanctioned entity
|
|
23
|
-
to be plausible, different enough to be a likely false positive —
|
|
24
|
-
is where a compliance analyst spends their day.
|
|
25
|
-
|
|
26
|
-
A fixed threshold cannot resolve the gray zone: set it low and
|
|
27
|
-
every near-miss blocks a legitimate customer; set it high and a
|
|
28
|
-
real match slips through. The disambiguation is a judgement call
|
|
29
|
-
on the specific name, not a number comparison.
|
|
30
|
-
|
|
31
|
-
The Alvera platform's agent-driven workflow primitive turns that
|
|
32
|
-
judgement call into an LLM enrichment plus a Liquid
|
|
33
|
-
interpolation. A filter narrows the workflow to *only* gray-zone
|
|
34
|
-
scores; an AI agent receives the screening's entity name and
|
|
35
|
-
score via a context-mapping template, returns a JSON object whose
|
|
36
|
-
`verdict` field is `verified` or `blocked`; and the workflow's
|
|
37
|
-
decision_config interpolates that verdict into a one-element
|
|
38
|
-
decision array. Two SMS actions are registered against the
|
|
39
|
-
workflow, one per verdict; the agent's output picks which one
|
|
40
|
-
runs. Auto-clear and auto-block screenings never reach the agent
|
|
41
|
-
— the filter rejects them.
|
|
42
|
-
|
|
43
|
-
This cookbook walks the **whole** scenario: it provisions the
|
|
44
|
-
agent and workflow, ingests two compliance-screening events
|
|
45
|
-
through the production data-activation chain (one gray-zone, one
|
|
46
|
-
clearly clear), runs the workflow so the gray-zone filter routes
|
|
47
|
-
each row, and verifies the routing — the gray-zone screening
|
|
48
|
-
reaches the agent and fans out to a verdict-keyed SMS, the
|
|
49
|
-
clearly-clear screening is filtered out before any agent call.
|
|
50
|
-
|
|
51
|
-
The scenario is anchored to
|
|
52
|
-
`platform/integration-tests/tests/payments/sanctions-review-workflow.test.ts`
|
|
53
|
-
— a green end-to-end test. The setup file `_setup/payments.md`
|
|
54
|
-
already provisioned the tenant + datalake + tenant-scoped client;
|
|
55
|
-
this cookbook starts from there.
|
|
56
|
-
|
|
57
|
-
# Composition
|
|
58
|
-
|
|
59
|
-
| Resource provisioned | Owner |
|
|
60
|
-
|-------------------------------------|-------------|
|
|
61
|
-
| SMS tool (SNS-backed) | build |
|
|
62
|
-
| LLM tool (Ollama chat completion) | build |
|
|
63
|
-
| Sanctions Review AI agent | build |
|
|
64
|
-
| Sanctions Review workflow | build |
|
|
65
|
-
| Atomic FI data source | build |
|
|
66
|
-
| Manual Upload tool | build |
|
|
67
|
-
| Compliance Screening interop contract | build |
|
|
68
|
-
| Manual-upload DAC | build |
|
|
69
|
-
|
|
70
|
-
The setup file `_setup/payments.md` already provisioned the
|
|
71
|
-
tenant + datalake + tenant-scoped client; this cookbook starts
|
|
72
|
-
from there.
|
|
73
|
-
|
|
74
|
-
# Walkthrough
|
|
75
|
-
|
|
76
|
-
## 001 — create the SMS tool
|
|
77
|
-
|
|
78
|
-
The Sanctions Review workflow's actions invoke an SMS tool. The
|
|
79
|
-
tool's `body.tool_body_type: 'sns'` means it routes via AWS SNS;
|
|
80
|
-
local dev points it at LocalStack on `http://localhost:4566` via
|
|
81
|
-
`endpoint_url` so no real AWS credentials are needed. The
|
|
82
|
-
`intent: 'sms'` tags this tool for workflow actions that send SMS
|
|
83
|
-
(versus `data_exchange` for ingestion tools).
|
|
84
|
-
|
|
85
|
-
```typescript
|
|
86
|
-
const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
87
|
-
name: `Cookbook SMS Tool ${runSuffix}`,
|
|
88
|
-
description: 'SNS-backed SMS dispatcher for the Sanctions Review workflow, wired to LocalStack.',
|
|
89
|
-
intent: 'sms',
|
|
90
|
-
status: 'active',
|
|
91
|
-
datalake_id: ctx.datalakeId,
|
|
92
|
-
body: {
|
|
93
|
-
tool_body_type: 'sns',
|
|
94
|
-
auth_method: 'access_key',
|
|
95
|
-
region: 'us-east-1',
|
|
96
|
-
phone_number: '+15551234567',
|
|
97
|
-
endpoint_url: 'http://localhost:4566',
|
|
98
|
-
access_key_id: 'test',
|
|
99
|
-
secret_access_key: 'test',
|
|
100
|
-
},
|
|
101
|
-
})
|
|
102
|
-
toolId = smsToolResp.data.id!
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
## 002 — create the LLM tool
|
|
106
|
-
|
|
107
|
-
The Sanctions Review agent calls a chat-completion endpoint to
|
|
108
|
-
disambiguate each gray-zone screening. The tool's
|
|
109
|
-
`intent: 'llm_enrichment'` distinguishes it from the SMS tool above.
|
|
110
|
-
It is a **provider adapter**: `base_body` authors the provider's
|
|
111
|
-
request — here Ollama's native `/api/chat` shape with `think: false`
|
|
112
|
-
and a `format` schema so the model returns clean, schema-constrained
|
|
113
|
-
JSON — and `response_extractor` maps the provider's envelope back to
|
|
114
|
-
the canonical `{ output_json, … }` the platform reads. The extractor's
|
|
115
|
-
`output_schema` is required for an `llm_enrichment` tool. The
|
|
116
|
-
`api_key`/`auth_method` pair satisfies the REST tool's schema even
|
|
117
|
-
though Ollama ignores the header.
|
|
118
|
-
|
|
119
|
-
```typescript
|
|
120
|
-
const ENRICHMENT_OUTPUT_SCHEMA = {
|
|
121
|
-
type: 'object',
|
|
122
|
-
properties: {
|
|
123
|
-
output_json: {},
|
|
124
|
-
input_tokens: { type: ['integer', 'null'] },
|
|
125
|
-
output_tokens: { type: ['integer', 'null'] },
|
|
126
|
-
total_tokens: { type: ['integer', 'null'] },
|
|
127
|
-
explanation: { type: ['string', 'null'] },
|
|
128
|
-
},
|
|
129
|
-
required: ['output_json'],
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const OLLAMA_BASE_BODY =
|
|
133
|
-
'{"model": "{{ model }}", "messages": [{"role": "user", "content": "{{ rendered_prompt | json_escape }}", "images": [{% for img in images %}{% unless forloop.first %}, {% endunless %}"{{ img.data }}"{% endfor %}]}], "stream": false, "think": false, "options": {"temperature": 0, "num_ctx": 40960}, "format": {{ schema | to_json }}}'
|
|
134
|
-
|
|
135
|
-
const OLLAMA_EXTRACTOR =
|
|
136
|
-
'{"output_json": "{{ msg.message.content | json_escape }}", "explanation": "{{ msg.message.thinking | json_escape }}", "input_tokens": {{ msg.prompt_eval_count | default: 0 }}, "output_tokens": {{ msg.eval_count | default: 0 }}, "total_tokens": {{ msg.prompt_eval_count | default: 0 | plus: msg.eval_count }}}'
|
|
137
|
-
|
|
138
|
-
const llmToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
139
|
-
name: `Cookbook LLM Tool ${runSuffix}`,
|
|
140
|
-
description: 'Ollama-backed chat-completion adapter for sanctions-screening disambiguation.',
|
|
141
|
-
intent: 'llm_enrichment',
|
|
142
|
-
status: 'active',
|
|
143
|
-
datalake_id: ctx.datalakeId,
|
|
144
|
-
response_extractor: { type: 'custom', body: OLLAMA_EXTRACTOR, output_schema: ENRICHMENT_OUTPUT_SCHEMA },
|
|
145
|
-
body: {
|
|
146
|
-
tool_body_type: 'rest_api',
|
|
147
|
-
base_url: 'http://localhost:11434',
|
|
148
|
-
base_path: { type: 'custom', body: '/api/chat' },
|
|
149
|
-
auth_method: 'api_key',
|
|
150
|
-
api_key: 'stub-key',
|
|
151
|
-
api_key_name: 'Authorization',
|
|
152
|
-
api_key_location: 'header',
|
|
153
|
-
request_type: 'json',
|
|
154
|
-
response_type: 'json',
|
|
155
|
-
timeout_ms: 60_000,
|
|
156
|
-
base_body: { type: 'custom', body: OLLAMA_BASE_BODY },
|
|
157
|
-
},
|
|
158
|
-
})
|
|
159
|
-
ctx.llmToolId = llmToolResp.data.id!
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
## 003 — create the Sanctions Review AI agent
|
|
163
|
-
|
|
164
|
-
The agent binds three things: the model name, an input schema the
|
|
165
|
-
workflow's context-mapping must satisfy, and a response schema the
|
|
166
|
-
agent's output must match. The response schema's
|
|
167
|
-
`enum: ['verified','blocked']` constraint guards the downstream
|
|
168
|
-
decision interpolation from emitting a verdict the workflow has no
|
|
169
|
-
SMS action for. `temperature: 0.0` removes sampling noise.
|
|
170
|
-
|
|
171
|
-
`data_access: 'regulated'` is load-bearing here: the screened
|
|
172
|
-
entity's name is a `TokenizedDataType` field — tokenized on the
|
|
173
|
-
unregulated side. The agent needs the plaintext name to make a
|
|
174
|
-
judgement call, so it reads the *regulated* projection of the
|
|
175
|
-
compliance-screening row.
|
|
176
|
-
|
|
177
|
-
```typescript
|
|
178
|
-
const AGENT_INPUT_SCHEMA = {
|
|
179
|
-
type: 'object',
|
|
180
|
-
properties: {
|
|
181
|
-
screening_id: { type: 'string' },
|
|
182
|
-
screened_entity_name: { type: 'string' },
|
|
183
|
-
match_score: { type: 'number' },
|
|
184
|
-
},
|
|
185
|
-
required: ['screening_id', 'screened_entity_name', 'match_score'],
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const AGENT_RESPONSE_SCHEMA = {
|
|
189
|
-
type: 'object',
|
|
190
|
-
properties: {
|
|
191
|
-
verdict: { type: 'string', enum: ['verified', 'blocked'] },
|
|
192
|
-
comments: { type: 'string' },
|
|
193
|
-
},
|
|
194
|
-
required: ['verdict', 'comments'],
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const AGENT_PROMPT_BODY = `You are a sanctions-review disambiguation assistant. A name-match against a sanctions list has come in with a gray-zone score (close enough to be plausible, ambiguous enough to need a human-style judgement call). Decide whether this is a true match ("blocked") or a false positive on a similar name ("verified").
|
|
198
|
-
|
|
199
|
-
Screening id: {{ screening_id }}
|
|
200
|
-
Screened entity name: {{ screened_entity_name }}
|
|
201
|
-
Match score: {{ match_score }}
|
|
202
|
-
|
|
203
|
-
Respond with JSON: {"verdict": "verified" | "blocked", "comments": "<one-sentence rationale>"}
|
|
204
|
-
|
|
205
|
-
For this cookbook fixture, the entity name is a deliberately disambiguating non-sanctioned identity — respond with "verified".`
|
|
206
|
-
|
|
207
|
-
const agentResp = await api.aiAgents.create(tenantSlug, datalakeSlug, {
|
|
208
|
-
name: `Cookbook Sanctions Review Agent ${runSuffix}`,
|
|
209
|
-
tool_id: ctx.llmToolId,
|
|
210
|
-
model: 'qwen3-vl:8b-instruct',
|
|
211
|
-
data_access: 'regulated',
|
|
212
|
-
temperature: 0.0,
|
|
213
|
-
max_tokens: 256,
|
|
214
|
-
enabled: true,
|
|
215
|
-
input_schema: AGENT_INPUT_SCHEMA,
|
|
216
|
-
llm_response_schema: AGENT_RESPONSE_SCHEMA,
|
|
217
|
-
prompt_config: { type: 'custom', body: AGENT_PROMPT_BODY },
|
|
218
|
-
})
|
|
219
|
-
aiAgentId = agentResp.data.id!
|
|
220
|
-
ctx.agentSlug = agentResp.data.slug!
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
## 004 — create the Sanctions Review workflow
|
|
224
|
-
|
|
225
|
-
The workflow has the standard shape — filter, decision, actions —
|
|
226
|
-
plus the Sanctions Review agent **nested** in the create body (the
|
|
227
|
-
`workflow_ai_agents` array — a `cast_assoc`, not a separate attach
|
|
228
|
-
call) to bind it into the enrichment phase. Three pieces are worth
|
|
229
|
-
noting.
|
|
230
|
-
|
|
231
|
-
The **filter** is the gray-zone band: it passes only screenings
|
|
232
|
-
whose `screening_score` is at least `0.80` and below `0.95`. A
|
|
233
|
-
score below `0.80` is an auto-clear and a score at/above `0.95` is
|
|
234
|
-
an auto-block — neither needs the agent, so the filter rejects
|
|
235
|
-
them before any LLM call.
|
|
236
|
-
|
|
237
|
-
The **context_mapping** projects each screening into the agent's
|
|
238
|
-
input schema. `match_score` is interpolated *without* surrounding
|
|
239
|
-
quotes so it renders as a raw JSON number — the input schema
|
|
240
|
-
declares it `number`, and a quoted `"0.88"` would fail schema
|
|
241
|
-
validation. `screened_entity_name` is read from the regulated
|
|
242
|
-
projection so the agent sees the plaintext name.
|
|
243
|
-
|
|
244
|
-
The **decision_config** interpolates the agent's `verdict` (read
|
|
245
|
-
from `additional_context["<agent-slug>"].verdict`) into a
|
|
246
|
-
single-element decision array. The two `actions` are keyed
|
|
247
|
-
`decision_key: verified|blocked`; whichever verdict the agent
|
|
248
|
-
emits picks the action that fires, leaving the other `:skipped`.
|
|
249
|
-
Bracket access is used because the agent slug contains hyphens.
|
|
250
|
-
|
|
251
|
-
```typescript
|
|
252
|
-
const VERDICTS = ['verified', 'blocked'] as const
|
|
253
|
-
|
|
254
|
-
const GRAY_ZONE_FILTER =
|
|
255
|
-
'{% if compliance_screening.screening_score >= 0.80 ' +
|
|
256
|
-
'and compliance_screening.screening_score < 0.95 %}true{% endif %}'
|
|
257
|
-
|
|
258
|
-
const CONTEXT_MAPPING_BODY =
|
|
259
|
-
'{' +
|
|
260
|
-
'"screening_id": "{{ compliance_screening.id }}",' +
|
|
261
|
-
'"screened_entity_name": "{{ regulated_compliance_screening.screened_entity_name }}",' +
|
|
262
|
-
'"match_score": {{ compliance_screening.screening_score }}' +
|
|
263
|
-
'}'
|
|
264
|
-
|
|
265
|
-
const DECISION_CONFIG_BODY = `["{{ additional_context["${ctx.agentSlug}"].verdict }}"]`
|
|
266
|
-
const DECISION_OUTPUT_SCHEMA = { type: 'array', items: { type: 'string' } }
|
|
267
|
-
|
|
268
|
-
const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
269
|
-
name: `Cookbook Sanctions Review Workflow ${runSuffix}`,
|
|
270
|
-
description: 'Runs an LLM disambiguation pass on gray-zone-scored sanctions matches and fires a verdict-keyed SMS.',
|
|
271
|
-
dataset_type: 'compliance_screening',
|
|
272
|
-
status: 'live',
|
|
273
|
-
tags: ['compliance', 'sanctions'],
|
|
274
|
-
filter_config: {
|
|
275
|
-
type: 'custom',
|
|
276
|
-
body: GRAY_ZONE_FILTER,
|
|
277
|
-
output_schema: { type: 'boolean' },
|
|
278
|
-
},
|
|
279
|
-
decision_config: {
|
|
280
|
-
type: 'custom',
|
|
281
|
-
body: DECISION_CONFIG_BODY,
|
|
282
|
-
output_schema: DECISION_OUTPUT_SCHEMA,
|
|
283
|
-
},
|
|
284
|
-
actions: VERDICTS.map((verdict) => ({
|
|
285
|
-
decision_key: verdict,
|
|
286
|
-
action_type: 'sms',
|
|
287
|
-
tool_id: toolId,
|
|
288
|
-
position: 0,
|
|
289
|
-
trigger_template: 'now',
|
|
290
|
-
idempotency_template: `{{ compliance_screening.id }}-${verdict}`,
|
|
291
|
-
tool_call: {
|
|
292
|
-
tool_call_type: 'sms_request',
|
|
293
|
-
to: { type: 'custom', body: '+15550000000' },
|
|
294
|
-
body: {
|
|
295
|
-
type: 'custom',
|
|
296
|
-
body: `[${verdict}] {{ additional_context["${ctx.agentSlug}"].comments }}`,
|
|
297
|
-
},
|
|
298
|
-
sms_type: 'transactional',
|
|
299
|
-
},
|
|
300
|
-
})),
|
|
301
|
-
// Nest the sanctions-review agent inline. Workflow joins omit
|
|
302
|
-
// output_schema — the server pins it from the agent's input_schema.
|
|
303
|
-
workflow_ai_agents: [
|
|
304
|
-
{
|
|
305
|
-
ai_agent_id: aiAgentId,
|
|
306
|
-
position: 0,
|
|
307
|
-
context_mapping_config: { type: 'custom', body: CONTEXT_MAPPING_BODY },
|
|
308
|
-
},
|
|
309
|
-
],
|
|
310
|
-
})
|
|
311
|
-
workflowId = workflowResp.data.id!
|
|
312
|
-
ctx.workflowSlug = workflowResp.data.slug!
|
|
313
|
-
```
|
|
314
|
-
|
|
315
|
-
## 005 — create the Atomic FI data source
|
|
316
|
-
|
|
317
|
-
A workflow runs on rows; rows arrive through the data-activation
|
|
318
|
-
chain. The chain's first link is a `DataSource` — a registration
|
|
319
|
-
of where the rows originate. The `uri` is the system-of-record
|
|
320
|
-
address; it flows into each ingested row's `source_uri`.
|
|
321
|
-
|
|
322
|
-
```typescript
|
|
323
|
-
const dataSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
|
|
324
|
-
name: `Cookbook Atomic FI Source ${runSuffix}`,
|
|
325
|
-
uri: 'api.atomic.fi',
|
|
326
|
-
description: 'Atomic FI compliance API — origin of the compliance-screening rows the workflow runs on.',
|
|
327
|
-
status: 'active',
|
|
328
|
-
is_default: false,
|
|
329
|
-
})
|
|
330
|
-
dataSourceId = dataSourceResp.data.id!
|
|
331
|
-
```
|
|
332
|
-
|
|
333
|
-
## 006 — create the Manual Upload tool
|
|
334
|
-
|
|
335
|
-
The Data Activation Client needs a tool. For inline-JSON ingest a
|
|
336
|
-
`manual_upload` tool is the minimal choice — `intent:
|
|
337
|
-
'data_exchange'` distinguishes it from the SMS tool, and
|
|
338
|
-
`tool_body_type: 'manual_upload'` needs no endpoint or credential
|
|
339
|
-
wiring (the rows arrive in the ingest call body, not by the tool
|
|
340
|
-
fetching them).
|
|
341
|
-
|
|
342
|
-
```typescript
|
|
343
|
-
const manualUploadToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
344
|
-
name: `Cookbook Manual Upload Tool ${runSuffix}`,
|
|
345
|
-
description: 'Manual-upload data-exchange tool — backs the DAC that ingests compliance-screening rows.',
|
|
346
|
-
intent: 'data_exchange',
|
|
347
|
-
status: 'active',
|
|
348
|
-
datalake_id: ctx.datalakeId,
|
|
349
|
-
data_source_id: dataSourceId,
|
|
350
|
-
body: { tool_body_type: 'manual_upload' },
|
|
351
|
-
})
|
|
352
|
-
ctx.manualUploadToolId = manualUploadToolResp.data.id!
|
|
353
|
-
```
|
|
354
|
-
|
|
355
|
-
## 007 — create the Compliance Screening interoperability contract
|
|
356
|
-
|
|
357
|
-
The interoperability contract is the row-shaping rule: a Liquid
|
|
358
|
-
template that maps an inbound Atomic FI compliance-screening row
|
|
359
|
-
into a payments `ComplianceScreening` upsert. This cookbook
|
|
360
|
-
loads the production Atomic FI compliance-screening + MDM
|
|
361
|
-
templates from the vendored fixtures directory.
|
|
362
|
-
|
|
363
|
-
`template_config` shapes the ComplianceScreening resource —
|
|
364
|
-
including the `screened_entity_name` field the changeset
|
|
365
|
-
tokenizes automatically. `mdm_input_config` shapes the MDM input
|
|
366
|
-
— the `account_holder_id` plus the entity name the platform's
|
|
367
|
-
master-data resolution keys on to find-or-create the LegalEntity
|
|
368
|
-
+ AccountHolder pair the screening belongs to.
|
|
369
|
-
|
|
370
|
-
```typescript
|
|
371
|
-
const { readFileSync } = await import('node:fs')
|
|
372
|
-
const { join } = await import('node:path')
|
|
373
|
-
const csTemplate = readFileSync(
|
|
374
|
-
join(process.env.COOKBOOK_FIXTURES_DIR!, 'payments/_compliance_screenings_payments_compliance_screening.liquid'),
|
|
375
|
-
'utf8',
|
|
376
|
-
)
|
|
377
|
-
const mdmTemplate = readFileSync(
|
|
378
|
-
join(process.env.COOKBOOK_FIXTURES_DIR!, 'payments/_compliance_screenings_payments_mdm.liquid'),
|
|
379
|
-
'utf8',
|
|
380
|
-
)
|
|
381
|
-
|
|
382
|
-
const contractResp = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
|
|
383
|
-
name: `Cookbook Atomic FI Compliance Screening Contract ${runSuffix}`,
|
|
384
|
-
description: 'Atomic FI compliance-screenings → Payments ComplianceScreening (custom Liquid + MDM input).',
|
|
385
|
-
resource_type: 'compliance_screening',
|
|
386
|
-
template_config: { type: 'custom', body: csTemplate },
|
|
387
|
-
mdm_input_config: { type: 'custom', body: mdmTemplate },
|
|
388
|
-
generic_table_id: null,
|
|
389
|
-
})
|
|
390
|
-
interopContractId = contractResp.data.id!
|
|
391
|
-
```
|
|
392
|
-
|
|
393
|
-
## 008 — create the manual-upload DAC
|
|
394
|
-
|
|
395
|
-
The Data Activation Client binds the three preceding pieces — the
|
|
396
|
-
manual-upload tool, the data source, and the interop contract —
|
|
397
|
-
into one ingestion endpoint. `tool_call.tool_call_type:
|
|
398
|
-
'manual_upload'` selects the inline-JSON ingest path. The
|
|
399
|
-
server-derived `slug` is the handle §009 ingests rows against.
|
|
400
|
-
|
|
401
|
-
```typescript
|
|
402
|
-
const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
|
|
403
|
-
name: `Cookbook Compliance Screening DAC ${runSuffix}`,
|
|
404
|
-
description: 'Manual-upload DAC — ingests compliance-screening events into ComplianceScreening via the interop contract.',
|
|
405
|
-
tool_id: ctx.manualUploadToolId,
|
|
406
|
-
data_source_id: dataSourceId,
|
|
407
|
-
tool_call: { tool_call_type: 'manual_upload' },
|
|
408
|
-
interoperability_contract_ids: [interopContractId],
|
|
409
|
-
})
|
|
410
|
-
dacId = dacResp.data.id!
|
|
411
|
-
ctx.dacSlug = dacResp.data.slug!
|
|
412
|
-
```
|
|
413
|
-
|
|
414
|
-
## 009 — ingest two compliance-screening events
|
|
415
|
-
|
|
416
|
-
Two rows, ingested as inline JSON through the manual-upload DAC.
|
|
417
|
-
They differ in the field the workflow filter cares about:
|
|
418
|
-
`screening_score`. The first row scores `0.88` — squarely in the
|
|
419
|
-
gray zone `[0.80, 0.95)`, so the filter passes it to the agent.
|
|
420
|
-
The second scores `0.20` — an auto-clear, so the filter rejects
|
|
421
|
-
it before any agent call. Each row carries a distinct
|
|
422
|
-
`account_holder_id` so MDM resolves each to its own legal entity.
|
|
423
|
-
Each ingest call gets its own batch id; both are pinned for the
|
|
424
|
-
run scope in §011.
|
|
425
|
-
|
|
426
|
-
```typescript
|
|
427
|
-
const grayZoneRow = {
|
|
428
|
-
compliance_screening_number: `CS-GRAY-${runSuffix}`,
|
|
429
|
-
account_holder_id: `AH-SR-${runSuffix}-gray`,
|
|
430
|
-
scope: 'account_holder',
|
|
431
|
-
screening_type: 'sanctions',
|
|
432
|
-
screening_status: 'pending',
|
|
433
|
-
screened_entity_type: 'individual',
|
|
434
|
-
sanctions_screening_status: 'match',
|
|
435
|
-
screening_score: 0.88,
|
|
436
|
-
match_count: 1,
|
|
437
|
-
screened_entity_name: 'Jane Cookbook-Disambiguating-Doe',
|
|
438
|
-
source_uri: 'api.atomic.fi',
|
|
439
|
-
}
|
|
440
|
-
const autoClearRow = {
|
|
441
|
-
compliance_screening_number: `CS-CLEAR-${runSuffix}`,
|
|
442
|
-
account_holder_id: `AH-SR-${runSuffix}-clear`,
|
|
443
|
-
scope: 'account_holder',
|
|
444
|
-
screening_type: 'sanctions',
|
|
445
|
-
screening_status: 'pass',
|
|
446
|
-
screened_entity_type: 'individual',
|
|
447
|
-
sanctions_screening_status: 'cleared',
|
|
448
|
-
screening_score: 0.2,
|
|
449
|
-
match_count: 0,
|
|
450
|
-
screened_entity_name: 'John Cookbook-Clear-Smith',
|
|
451
|
-
source_uri: 'api.atomic.fi',
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
const [grayZoneIngest, autoClearIngest] = await Promise.all([
|
|
455
|
-
api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: grayZoneRow }),
|
|
456
|
-
api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: autoClearRow }),
|
|
457
|
-
])
|
|
458
|
-
ctx.batchGrayZone = grayZoneIngest.data.batch_id!
|
|
459
|
-
ctx.batchAutoClear = autoClearIngest.data.batch_id!
|
|
460
|
-
```
|
|
461
|
-
|
|
462
|
-
## 010 — wait for both ingest batches to reach steady-state
|
|
463
|
-
|
|
464
|
-
Ingestion is async — the DAC enqueues per-row jobs that the
|
|
465
|
-
`BatchMergeWorker` drains into the regulated compliance-screening
|
|
466
|
-
table. Poll the DAC's activation logs until both batches show a
|
|
467
|
-
row with `rows_ingested >= 1` and a non-empty `output_files`
|
|
468
|
-
array (the merged Parquet landed in object storage). Only then is
|
|
469
|
-
it safe to run the workflow against these rows.
|
|
470
|
-
|
|
471
|
-
```typescript
|
|
472
|
-
const targetBatches = new Set([ctx.batchGrayZone, ctx.batchAutoClear])
|
|
473
|
-
const deadline = Date.now() + 90_000
|
|
474
|
-
let greenCount = 0
|
|
475
|
-
while (Date.now() < deadline) {
|
|
476
|
-
const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
|
|
477
|
-
const green = new Set<string>()
|
|
478
|
-
for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
|
|
479
|
-
const b = row.batch_id
|
|
480
|
-
if (typeof b !== 'string' || !targetBatches.has(b)) continue
|
|
481
|
-
if (typeof row.rows_ingested !== 'number' || row.rows_ingested < 1) continue
|
|
482
|
-
const files = row.output_files
|
|
483
|
-
if (!Array.isArray(files) || files.length === 0) continue
|
|
484
|
-
green.add(b)
|
|
485
|
-
}
|
|
486
|
-
greenCount = green.size
|
|
487
|
-
if (greenCount === targetBatches.size) break
|
|
488
|
-
await new Promise((r) => setTimeout(r, 1_000))
|
|
489
|
-
}
|
|
490
|
-
if (greenCount !== targetBatches.size) {
|
|
491
|
-
throw new Error(`only ${greenCount}/2 compliance-screening batches reached steady-state within 90s`)
|
|
492
|
-
}
|
|
493
|
-
```
|
|
494
|
-
|
|
495
|
-
## 011 — run the workflow against the two batches
|
|
496
|
-
|
|
497
|
-
`workflows.run` with `manual_override: false` evaluates the
|
|
498
|
-
filter, so the gray-zone filter genuinely routes each row. The
|
|
499
|
-
SQL where-clause scopes the run to exactly the two batches §009
|
|
500
|
-
ingested (`rcs` is the regulated-compliance-screening alias the
|
|
501
|
-
run-query exposes).
|
|
502
|
-
|
|
503
|
-
For the gray-zone row the run drives the full agent-driven
|
|
504
|
-
pipeline: filter → agent enrichment (the Ollama call that returns
|
|
505
|
-
the verdict) → decision (the verdict interpolated into the
|
|
506
|
-
decision array) → action fan-out. The auto-clear row is rejected
|
|
507
|
-
at the filter and never reaches the agent. Poll
|
|
508
|
-
`batchLogs.refresh` until the run leaves `:pending`; the window
|
|
509
|
-
is generous because the gray-zone row's enrichment is a live LLM
|
|
510
|
-
inference call.
|
|
511
|
-
|
|
512
|
-
```typescript
|
|
513
|
-
const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
514
|
-
sql_where_clause: `rcs.batch_id IN ('${ctx.batchGrayZone}', '${ctx.batchAutoClear}')`,
|
|
515
|
-
mode: 'live',
|
|
516
|
-
manual_override: false,
|
|
517
|
-
})
|
|
518
|
-
// run-workflow only SCHEDULES the run. The log id and batch id are
|
|
519
|
-
// written when it fires, so read them back via workflowRuns.get.
|
|
520
|
-
const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
|
|
521
|
-
ctx.runLogId = fired.workflowRunLogId
|
|
522
|
-
ctx.runBatchId = fired.batchId!
|
|
523
|
-
|
|
524
|
-
const deadline = Date.now() + 240_000
|
|
525
|
-
let status: string | null = null
|
|
526
|
-
while (Date.now() < deadline) {
|
|
527
|
-
const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
|
|
528
|
-
status = log.status ?? null
|
|
529
|
-
if (status && status !== 'pending') break
|
|
530
|
-
await new Promise((r) => setTimeout(r, 2_000))
|
|
531
|
-
}
|
|
532
|
-
if (status === 'failed') throw new Error('agent-driven workflow run reached :failed')
|
|
533
|
-
if (!status || status === 'pending') {
|
|
534
|
-
throw new Error('workflow run did not leave :pending within 240s')
|
|
535
|
-
}
|
|
536
|
-
```
|
|
537
|
-
|
|
538
|
-
## 012 — verify the gray-zone filter routed and the agent steered the fan-out
|
|
539
|
-
|
|
540
|
-
Each row produced a Workflow Execution Log. The gray-zone
|
|
541
|
-
screening passes the filter, reaches the agent, and its WEL is
|
|
542
|
-
`:executing` or `:completed`. The auto-clear screening fails the
|
|
543
|
-
filter, so its WEL is `:filtered` — it never reached the agent. A
|
|
544
|
-
run where both passed — or both were filtered — would mean the
|
|
545
|
-
gray-zone band filter is not actually evaluating the score.
|
|
546
|
-
|
|
547
|
-
The pass-branch WEL carries exactly **two** action execution logs
|
|
548
|
-
— one per verdict — and exactly **one** is matched (`:pending` or
|
|
549
|
-
`:completed`, the verdict the agent emitted) while the other is
|
|
550
|
-
`:skipped`. That is the proof the agent's verdict actually steered
|
|
551
|
-
the decision rather than firing every action.
|
|
552
|
-
|
|
553
|
-
```typescript
|
|
554
|
-
const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
|
|
555
|
-
const ourWels = (wfLogs.data ?? []).filter(
|
|
556
|
-
(w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
|
|
557
|
-
)
|
|
558
|
-
if (ourWels.length !== 2) {
|
|
559
|
-
throw new Error(`expected 2 WELs for the run, got ${ourWels.length}`)
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
const byStatus: Record<string, number> = {}
|
|
563
|
-
for (const w of ourWels) {
|
|
564
|
-
const st = (w as { status?: string }).status ?? 'unknown'
|
|
565
|
-
byStatus[st] = (byStatus[st] ?? 0) + 1
|
|
566
|
-
}
|
|
567
|
-
if ((byStatus.filtered ?? 0) !== 1) {
|
|
568
|
-
throw new Error(
|
|
569
|
-
`expected 1 :filtered WEL (the auto-clear screening) — distribution ${JSON.stringify(byStatus)}`,
|
|
570
|
-
)
|
|
571
|
-
}
|
|
572
|
-
const passWel = ourWels.find((w) => {
|
|
573
|
-
const st = (w as { status?: string }).status
|
|
574
|
-
return st === 'executing' || st === 'completed'
|
|
575
|
-
})
|
|
576
|
-
if (!passWel) {
|
|
577
|
-
throw new Error(`no pass-branch WEL — distribution ${JSON.stringify(byStatus)}`)
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
const aels =
|
|
581
|
-
(passWel as { action_execution_logs?: Array<{ status?: string; decision_key?: string }> })
|
|
582
|
-
.action_execution_logs ?? []
|
|
583
|
-
if (aels.length !== 2) {
|
|
584
|
-
throw new Error(`expected 2 AELs (one per verdict) on the gray-zone WEL, got ${aels.length}`)
|
|
585
|
-
}
|
|
586
|
-
const aelByStatus: Record<string, number> = {}
|
|
587
|
-
for (const ael of aels) {
|
|
588
|
-
const st = ael.status ?? 'unknown'
|
|
589
|
-
aelByStatus[st] = (aelByStatus[st] ?? 0) + 1
|
|
590
|
-
}
|
|
591
|
-
const matched = (aelByStatus.pending ?? 0) + (aelByStatus.completed ?? 0)
|
|
592
|
-
if (matched !== 1) {
|
|
593
|
-
throw new Error(`expected exactly 1 matched AEL, got ${matched} — ${JSON.stringify(aelByStatus)}`)
|
|
594
|
-
}
|
|
595
|
-
if ((aelByStatus.skipped ?? 0) !== 1) {
|
|
596
|
-
throw new Error(`expected 1 :skipped AEL — ${JSON.stringify(aelByStatus)}`)
|
|
597
|
-
}
|
|
598
|
-
const routed = aels.find((a) => a.status === 'pending' || a.status === 'completed')
|
|
599
|
-
if (!routed?.decision_key || !/^(verified|blocked)$/.test(routed.decision_key)) {
|
|
600
|
-
throw new Error(`matched AEL has unexpected decision_key ${routed?.decision_key}`)
|
|
601
|
-
}
|
|
602
|
-
```
|
|
603
|
-
|
|
604
|
-
## 013 — confirm the verdict-keyed SMS rendered
|
|
605
|
-
|
|
606
|
-
The matched action fired immediately (`trigger_template: 'now'`)
|
|
607
|
-
and persisted a row in the regulated `message` dataset, carrying
|
|
608
|
-
the fully-rendered SMS body. The body is prefixed with the
|
|
609
|
-
verdict decision_key — `[verified]` or `[blocked]` — followed by
|
|
610
|
-
the agent's free-text `comments`. For this cookbook's fixture
|
|
611
|
-
entity name the agent is prompted toward `verified`, so the
|
|
612
|
-
persisted SMS is prefixed `[verified]`. Search the message
|
|
613
|
-
dataset scoped to this workflow and assert the prefix.
|
|
614
|
-
|
|
615
|
-
```typescript
|
|
616
|
-
const { data: userSearch } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'message', {
|
|
617
|
-
search_query: `rm.workflow_id = '${workflowId}'`,
|
|
618
|
-
})
|
|
619
|
-
if (userSearch.status !== 'completed') {
|
|
620
|
-
throw new Error(`message user-search status=${userSearch.status} error=${userSearch.error_message ?? '(none)'}`)
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
const deadline = Date.now() + 45_000
|
|
624
|
-
let messages: Array<Record<string, unknown>> = []
|
|
625
|
-
while (Date.now() < deadline && messages.length === 0) {
|
|
626
|
-
const { data } = await api.datasets.search(tenantSlug, datalakeSlug, 'message', {
|
|
627
|
-
userSearchId: userSearch.id!,
|
|
628
|
-
dataAccessMode: 'regulated',
|
|
629
|
-
})
|
|
630
|
-
messages = (data.data ?? []) as Array<Record<string, unknown>>
|
|
631
|
-
if (messages.length === 0) await new Promise((r) => setTimeout(r, 1_000))
|
|
632
|
-
}
|
|
633
|
-
if (messages.length === 0) {
|
|
634
|
-
throw new Error('no verdict SMS message persisted for the workflow within 45s')
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
const verdictBody = messages
|
|
638
|
-
.map((m) => String(m.body ?? ''))
|
|
639
|
-
.find((body) => body.includes('[verified]') || body.includes('[blocked]'))
|
|
640
|
-
if (!verdictBody) {
|
|
641
|
-
throw new Error(`no verdict-prefixed SMS body — bodies: ${JSON.stringify(messages.map((m) => m.body))}`)
|
|
642
|
-
}
|
|
643
|
-
if (!verdictBody.includes('[verified]')) {
|
|
644
|
-
throw new Error(`expected the agent verdict to render [verified] — got: ${verdictBody}`)
|
|
645
|
-
}
|
|
646
|
-
```
|
|
647
|
-
|
|
648
|
-
## 014 — write the integration test
|
|
649
|
-
|
|
650
|
-
End the build with a test you keep: re-read the workflow and prove the
|
|
651
|
-
pipeline still executes — without a side effect. `mode: 'dry_run'` with a
|
|
652
|
-
never-matching selection runs the FULL pipeline (selection → filter →
|
|
653
|
-
decision) and intercepts only the final action call, so no message
|
|
654
|
-
leaves, yet the acknowledgement proves the workflow is runnable. This
|
|
655
|
-
block runs live under `make validate-cookbook`.
|
|
656
|
-
|
|
657
|
-
```typescript
|
|
658
|
-
// Re-GET — the workflow must still be live, or nothing will run.
|
|
659
|
-
const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
|
|
660
|
-
if (wfRow.status !== 'live') {
|
|
661
|
-
throw new Error(`workflow regressed from live: ${wfRow.status}`)
|
|
662
|
-
}
|
|
663
|
-
// Behavioural probe — a dry run against a selection no row can match:
|
|
664
|
-
// the pipeline executes end-to-end, the final action call is
|
|
665
|
-
// intercepted, and the acknowledgement carries the scheduled run id. The
|
|
666
|
-
// clause must speak this workflow's selection dialect — the dataset
|
|
667
|
-
// alias is `rcs` here, the same alias the live run above uses.
|
|
668
|
-
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
669
|
-
sql_where_clause: "rcs.batch_id = 'test-never-matching-batch'",
|
|
670
|
-
mode: 'dry_run',
|
|
671
|
-
manual_override: false,
|
|
672
|
-
})
|
|
673
|
-
// The run-log id does not exist until the run fires — wait, do not read a null.
|
|
674
|
-
const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
|
|
675
|
-
if (probeFired.workflowRunLogId.length === 0) {
|
|
676
|
-
throw new Error('dry-run probe never produced a workflow_run_log_id')
|
|
677
|
-
}
|
|
678
|
-
```
|
|
679
|
-
|
|
680
|
-
If the probe fails in production, escalate with the run response as
|
|
681
|
-
evidence — don't flip the workflow's status or rewrite its configs to
|
|
682
|
-
chase the error.
|
|
683
|
-
|
|
684
|
-
# Branches
|
|
685
|
-
|
|
686
|
-
- **The auto-clear screening is filtered, not failed** — §012
|
|
687
|
-
asserts the `0.20`-scored screening's WEL is `:filtered`, a
|
|
688
|
-
distinct terminal status from `:failed`. A filtered row is a
|
|
689
|
-
*correct* outcome: the workflow looked at it, the gray-zone
|
|
690
|
-
filter rendered empty because the score was below `0.80`, and
|
|
691
|
-
the platform recorded the row as intentionally skipped. The
|
|
692
|
-
agent is never called for a filtered row — which is the whole
|
|
693
|
-
point of the band filter: do not spend an LLM inference on a
|
|
694
|
-
screening a threshold already settles.
|
|
695
|
-
- **The blocked verdict** — the agent's response schema admits
|
|
696
|
-
`blocked` as well as `verified`. A gray-zone screening on a
|
|
697
|
-
genuinely sanctioned entity would have the agent emit `blocked`,
|
|
698
|
-
the decision interpolate `["blocked"]`, and the blocked-keyed
|
|
699
|
-
SMS fire instead. This cookbook's fixture entity name is
|
|
700
|
-
deliberately disambiguating so the run is deterministic; a
|
|
701
|
-
production deployment lets the model's judgement pick the
|
|
702
|
-
branch.
|
|
703
|
-
- **Agent emits an out-of-enum verdict** — the LLM response
|
|
704
|
-
schema's `enum` constraint guards the decision interpolation.
|
|
705
|
-
If the model returned an unexpected string the workflow row's
|
|
706
|
-
execution log would land `:failed`. Production deployments
|
|
707
|
-
either tighten the prompt or add a fallback action.
|
|
708
|
-
|
|
709
|
-
# Rollback
|
|
710
|
-
|
|
711
|
-
The cookbook doctest harness does not currently tear down created
|
|
712
|
-
resources. The `_setup/payments.md` setup file's
|
|
713
|
-
runSuffix-scoped tenant / datalake / user names mean each run is
|
|
714
|
-
naturally isolated; the seeded local DB is cheap to reset
|
|
715
|
-
(`mix ecto.reset` on the platform repo).
|
|
716
|
-
|
|
717
|
-
# Outcome
|
|
718
|
-
|
|
719
|
-
After this cookbook's thirteen steps run green:
|
|
720
|
-
|
|
721
|
-
- A payments tenant exists with a payments-domain
|
|
722
|
-
datalake
|
|
723
|
-
- An SMS tool, a chat-completion LLM tool, a Sanctions Review AI
|
|
724
|
-
agent (qwen3-vl:8b-instruct, verified/blocked response schema), and a
|
|
725
|
-
Sanctions Review agent-driven workflow are all registered
|
|
726
|
-
- An Atomic FI data source, a Manual Upload tool, a Compliance
|
|
727
|
-
Screening interop contract, and a manual-upload DAC form a
|
|
728
|
-
working ingestion chain
|
|
729
|
-
- Two compliance screenings have been ingested through that chain
|
|
730
|
-
— one gray-zone-scored, one auto-clear
|
|
731
|
-
- Running the workflow routed them correctly: the gray-zone
|
|
732
|
-
screening passed the band filter, reached the agent, and fanned
|
|
733
|
-
out to a verdict-keyed SMS; the auto-clear screening was
|
|
734
|
-
`:filtered` before any agent call
|
|
735
|
-
- The pass-branch WEL is verified row-by-row — two AELs, exactly
|
|
736
|
-
one matched and one `:skipped`, proving the agent's verdict
|
|
737
|
-
steered the decision
|
|
738
|
-
- The rendered SMS in the `message` dataset carries the
|
|
739
|
-
`[verified]` verdict prefix
|
|
740
|
-
|
|
741
|
-
The business outcome — gray-zone sanctions screenings
|
|
742
|
-
disambiguated by an LLM and routed to a verdict-specific SMS,
|
|
743
|
-
with clear-cut screenings filtered out of the agent path
|
|
744
|
-
entirely — is demonstrated end-to-end, not merely provisioned.
|
|
745
|
-
|
|
746
|
-
# See also
|
|
747
|
-
|
|
748
|
-
- `_setup/payments.md` — the inlined bootstrap that
|
|
749
|
-
provisions the tenant + datalake this cookbook starts from
|
|
750
|
-
- `kyc-notification-on-account-activation.md` — the
|
|
751
|
-
static-decision payments cookbook; same SMS wiring, full
|
|
752
|
-
data-activation chain, no agent
|
|
753
|
-
- `score-leads-with-llm-categorization.md`,
|
|
754
|
-
`contact-us-triage-with-llm.md` — the foundation and healthcare
|
|
755
|
-
agent-driven cookbooks; same agent + decision-interpolation
|
|
756
|
-
shape
|
|
757
|
-
- `.agent/tools.md` — SMS and chat-completion tool body shapes;
|
|
758
|
-
intent classification
|
|
759
|
-
- `.agent/ai_agents.md` — AI agent registration shape; input +
|
|
760
|
-
response schemas; `data_access` regulated vs unregulated
|
|
761
|
-
- `.agent/workflows.md` — standard and agent-driven workflow
|
|
762
|
-
primitives; `ai_agents`, `context_mapping_config`, and
|
|
763
|
-
agent-output decision interpolation
|
|
764
|
-
- `.agent/interoperability_contracts.md` — custom contract shape,
|
|
765
|
-
`template_config` vs `mdm_input_config`
|
|
766
|
-
- `.agent/cookbook/_fixtures/payments/` — the vendored Atomic
|
|
767
|
-
FI compliance-screening / MDM Liquid templates §007 loads
|
|
768
|
-
- `integration-tests/tests/payments/sanctions-review-workflow.test.ts` —
|
|
769
|
-
the anchor green test these snippets are lifted from
|
|
770
|
-
- `integration-tests/tests/payments/interoperability-contracts.test.ts`,
|
|
771
|
-
`run-dac-single.test.ts`, `create-dac.test.ts`, `tools.test.ts`,
|
|
772
|
-
`data-sources.test.ts` — the per-resource create + ingest
|
|
773
|
-
snippets §005–§010 are lifted from
|