Site icon Blog.Chiffers.com

Building AI Pipelines on Azure Logic Apps: Lessons from the Consumption Plan

TL;DR – Azure Logic Apps Consumption is not the same as Logic Apps Standard, and the documentation will not always tell you that until you have already deployed and watched something fail. This post covers two specific constraints that cost me several hours: the self-reference rule on SetVariable, and the absence of createObject() in the Consumption runtime. Both are solvable. The Compose and SetVariable pattern gets you around the first. Simplifying your data structures gets you around the second. The broader lesson is that premature optimisation in an orchestration layer is almost never worth it.

I have been building a document processing pipeline for a major insurance broker. The core idea: insurance handlers email in a policy document, the system extracts the genuinely significant clauses (conditions precedent, exclusions, sublimits) using an AI agent, and returns a structured report. The whole pipeline runs on Azure Logic Apps Consumption with a Python function app handling the heavy lifting.

It works well for a single document. The problem emerged when users started submitting two documents in the same email: a policy wording and a schedule.


The problem with insurance documents

Insurance documents come in two pieces. The policy wording is the master document, 50 to 150 pages, covering every possible clause across every section. The schedule (or quote summary) is the client-specific document, 20 to 30 pages, that specifies which sections are active, what the limits and excesses are, and crucially, what endorsements apply.

When you only submit the wording, the AI returns zero results. And it is doing the right thing. The wording says “if your schedule shows Property Damage is covered, then this condition applies.” Without the schedule, the AI correctly identifies that it cannot confirm anything is live, and its quality rules say “when in doubt, leave it out.”

When you only submit the schedule, you get good results from the endorsement clauses. These have their full operative clause bodies in the schedule, so the AI can extract them cleanly.

When you submit both, each document gets processed independently. The schedule does not know about the wording. The wording does not know about the schedule. Result: the schedule extracts endorsements correctly, the wording still returns zero because it still has no schedule context. Half the job done.

The fix is conceptually simple: when processing the wording, inject the schedule text as context. When processing the schedule, inject the wording text as context. Let the AI see the full picture. Getting there was less simple.


The architecture

The Logic App receives an array of files. For each file that needs processing, it reads the blob from Azure Storage, calls Azure Document Intelligence to extract text, then calls a Python function app with the pages and content. The function app chunks the document, prepends context, and calls the AI agent.

The function app already had a clientContext parameter (the email body, capped at 2000 chars) that gets prepended to every chunk. The plan was to add a siblingContext parameter carrying the other documents’ text, with a much higher limit.

The hard part is in the Logic App. Each file iteration runs concurrently. For sibling context to work, a file being processed needs access to the text extracted from the other files in the same batch. But each iteration only knows about its own file.


Attempt 1: pre-extract all documents first

The plan: add a pre-extraction scope that runs before the main processing loop. This loop calls Document Intelligence on all files sequentially, stores the results in a variable, then the main loop can access sibling data when processing each file.

I added varExtractedTextsMap as an object variable (initialised to {}) and varAllTexts as a string. The pre-extraction loop would build the map using setProperty() and concatenate all text into varAllTexts.

Deployed. Failed immediately.

WorkflowRunActionInputsInvalidProperty: The inputs of workflow run action
'PreExtract_StoreText' of type 'SetVariable' are not valid.
Self reference is not supported when updating the value of variable
'varExtractedTextsMap'.

The self-reference problem

In Logic Apps Consumption, a SetVariable action cannot read the variable it is writing to. The expression reads variables('varExtractedTextsMap') to build the new value, then writes back to varExtractedTextsMap. The runtime detects this at deployment time and rejects it.

This constraint exists at validation time, not runtime. The deployment fails before a single run ever executes.

The fix is to break the read and write into separate actions. A Compose action reads the variable and computes the new value. Compose has no side effects, so reading any variable is fine. A subsequent SetVariable writes the Compose output to the variable. The SetVariable now only sees the Compose output, not variables('varExtractedTextsMap') directly, so the self-reference check passes.

Updated. Deployed. Failed again.

InvalidTemplate: Unable to process template language expressions in action
'PreExtract_ComposeNewMap' inputs at line '0' and column '0':
'The template function 'createObject' is not defined or not valid.'

The createObject problem

createObject() is documented as a Logic Apps expression function. Except it only exists in Logic Apps Standard, not Logic Apps Consumption. The Consumption plan runs on an older workflow runtime that does not have it.

This is the kind of thing that wastes a significant chunk of time because the documentation does not clearly distinguish between plan tiers for individual expression functions. You have to discover it by deploying and watching it fail.

At this point I had two broken pieces: the self-reference constraint and the missing function. I could work around the self-reference with the Compose pattern. But without createObject(), I could not build the object value at all. I could construct JSON manually using concat() and json(), but that is brittle and error-prone when dealing with multi-kilobyte document content strings.

I stepped back and re-examined what I actually needed.


Simplifying the problem

The per-file cache was serving two purposes: store Document Intelligence results so the main loop does not have to call DI again, and enable per-file lookup so each file can access its own cached pages and content. Purpose two was the problem. Object maps with dynamic keys require setProperty() and createObject(), both of which have constraints in Consumption.

But the cache was a nice-to-have optimisation. The original main loop called DI on each file anyway. That still works. The only thing I genuinely needed from pre-extraction was the sibling context, which is just the concatenated text of all documents. That is a string, not a map, and string concatenation does not require any of the problematic functions.

The revised approach: pre-extraction builds varAllTexts only (a string) using the Compose and SetVariable pattern. The main loop keeps its original DI calls. The main loop passes varAllTexts as siblingContext to the function app when there are multiple files. Drop varExtractedTextsMap entirely.

For varAllTexts, the pattern handles the self-reference cleanly:

"PreExtract_ComposeCombinedTexts": {
  "type": "Compose",
  "inputs": "@concat(variables('varAllTexts'),
    if(empty(variables('varAllTexts')), '', '\n\n---\n\n'),
    coalesce(body('PreExtract_AnalyzeDocument')?['analyzeResult']?['content'], ''))"
},
"PreExtract_ConcatAllTexts": {
  "type": "SetVariable",
  "inputs": {
    "name": "varAllTexts",
    "value": "@outputs('PreExtract_ComposeCombinedTexts')"
  }
}

The SetVariable only sees the Compose output. No self-reference. It deployed.


The function app side

With siblingContext arriving as a separate field, the function app handles it independently from clientContext. The email body context stays capped at 2000 chars, which is right for an email. The sibling context gets 100K chars, which covers a full 20-page schedule without truncation.

Both get prepended to every chunk before the AI sees them, with clearly labelled blocks so the AI understands what each piece of context is and how to treat it. The AI prompt was updated with an explicit rule: treat the sibling document block as context only, do not extract terms from it directly, and ignore page markers within it so page attribution stays anchored to the primary document being processed.


The result

For a wording and schedule submission, the processing now works like this.

Pre-extraction phase (sequential): Document Intelligence runs on both files and concatenates their text into varAllTexts.

Main processing phase (concurrent): When processing the wording, every chunk now has the complete schedule injected as a prefix. The AI sees which sections are marked as covered, which endorsements apply, which sections are excluded. It can confirm that the Hot Work Permit condition applies to this client because Property Damage is covered and extract it accordingly. When processing the schedule, the full wording is available as context. The schedule endorsement bodies are already in the primary document, so the wording context mainly helps the AI understand the structure.

Yes, Document Intelligence runs twice per file. That is the cost of working within Consumption plan constraints. For typical one to three file submissions the overhead is a few seconds per file, which is acceptable given the accuracy improvement.


Key lessons

Logic Apps Consumption has a different expression function surface than Standard. The documentation does not make this obvious. createObject(), filter() as an inline expression, select() as an inline expression: all documented, all unavailable in Consumption. Before building anything complex with expression functions, test that the specific function exists in your plan tier. Do not assume the docs apply uniformly across both.

The self-reference constraint is deployment-time, not runtime. You will not see it in a test run. You will see it when you try to deploy. This means a feedback loop of: write code, deploy (two to four minutes), see the error, fix it, deploy again. Having the full workflow JSON in source control and deploying via pipeline helps, but the iteration cycle is still slow.

The Compose and SetVariable pattern is the standard workaround for updating a variable based on its current value. Split the computation (Compose, no side effects, can read anything) from the write (SetVariable, writes one variable, must not read that same variable). This works for both object mutations and string concatenation.

Resist the urge to cache. The per-file Document Intelligence cache seemed like a clean optimisation: run DI once, reuse the results. But it required the object map, which required createObject(), which does not exist in Consumption. The simpler solution, run DI twice, was available the whole time. Premature optimisation in an orchestration layer where the bottleneck is external API calls is rarely worth the complexity it introduces.

Separation of concerns between the orchestrator and the compute layer paid off. The function app had a configurable context size limit from day one, for no specific reason other than it seemed like a sensible parameter to expose. That meant adding sibling context support required one new parameter in the function app call and about ten lines of Python. The hard part was entirely in the Logic App. Keeping complex logic in code and the Logic App as a thin orchestrator made the eventual fix much cleaner.

The system now handles all three submission scenarios correctly: wording only, schedule only, and both together. The schedule-only path was always fine. The wording-only path improved because of a separate prompt change (when no schedule is present, treat all sections as active and extract anyway). The both-together path now works as intended, with each document processed in full context of the other.

0 0 votes
Article Rating
Exit mobile version