The problem
The client is a German financial services company serving doctors and other healthcare professionals. Lending decisions there rest on a set of German financial documents: the annual profit determination, the income-surplus calculation that many practices file instead, and the running management accounts a tax adviser produces through the year.
Those documents were being read by hand. Somebody opened each one, found the numbers that mattered for that document type, typed them into the CRM, and only then could the decision be made. It was slow, it needed people, and it was expensive in the specific way manual data entry always is: the cost scales with volume and the accuracy does not improve with practice.
German financial documents are also not one format. Each type has its own layout, its own vocabulary and its own idea of which numbers matter, and the fields worth extracting from one are not the fields worth extracting from another. A single prompt over a single schema either collects everything badly or collects one type well and the rest not at all.
They also arrive long, and often badly. A model has a finite context window and the content worth extracting is a small fraction of the text, so deciding what to send is most of the engineering. And what arrives is whatever the practice had: a clean export, a phone photograph, a crooked scan.
What was built
A document extraction service that is one click inside the CRM from the user's point of view and a FastAPI application on Kubernetes from the engineer's. Clicking a document record sends its parameters and metadata to the service; the service reads the file from an Azure share, identifies what kind of document it is, parses it with LlamaIndex, extracts against a schema written for that type, computes the derived business columns and any calculation needed, writes the results back onto the record, and puts the JSON and CSV artifacts back on the share.
Each document type carries its own prompt, its own schema and its own rules rather than sharing one general prompt. Coverage spans the German financial documents the lending decision actually rests on, and adding a new type is a new schema, prompt and rule set rather than a change to the pipeline.
Input is whatever the customer sends. Scans, photographs, images, pages the wrong way round, handwriting of varying quality. The parsing stage is built to accept that rather than to assume a clean text layer, because a system that only reads tidy exports is a system somebody still has to do the untidy half of by hand.
The model runs on Azure OpenAI inside the client's own tenancy, so nothing in the chain, including the model call, leaves a boundary they control.
The pipeline
- then
Salesforce
The trigger. One click on a document record, sending its parameters and metadata as the request.
- then
Azure Files
Where documents live and where results are written back.
- then
FastAPI on Kubernetes
The service. Identifies the document type, parses it, and assembles the right schema and prompt for that type.
- identify
- parse
- select schema
- fit the context window
- then
Model
Azure OpenAI, inside the client's own tenancy. Extraction against the schema for that document type.
- then
Structured output
JSON and CSV, plus the business columns Salesforce expects and any calculation it needs.
Salesforce
Written back into the record, ready for the decision it was blocking.
A round trip that starts and ends in Salesforce. A user clicks one document; everything between the click and the filled fields is this service, and they never leave the CRM they already work in. Nothing in the chain leaves the client's Azure tenancy, including the model call.
Decisions
The model runs inside the client's own tenancy.
Azure OpenAI rather than a public API endpoint, in the client's Azure. For a company handling German financial documents this is the first question asked and the one that decides whether a project happens at all: the documents never cross a boundary the client does not control, and there is no second data processor to add to a register. It is the same posture as self-hosting the pipeline in the first engagement, and for the same reason. The convenient choice was available in both cases and would have been the wrong one in both.
When it cannot read something, it fails loudly rather than guessing.
A run that cannot produce a usable result returns a failure with an error code and a reason, and can be retried. It does not return its best guess. This is the single most important behaviour in a system feeding a lending decision, because the alternative is not a missing number, it is a confident wrong one sitting in a field that somebody will act on. A failure costs a retry. A plausible wrong figure costs the decision it distorts, and nothing downstream can tell it apart from a right one.
Every document type gets its own schema, prompt and rules.
German financial documents are not one format. Each type has its own layout, its own vocabulary and its own idea of which numbers matter, and the fields worth extracting from one are not the fields worth extracting from another. A single general prompt over all of them produces output that is plausible for every type and correct for none, which is the failure mode that makes document extraction demo well and deploy badly. Splitting by type costs more up front and is the reason the results hold.
Adding a document type is data, not a code change.
A new type is a schema, a prompt and its rules, not a new branch through the pipeline. That is what lets coverage grow without the system's structure changing, and it means the people who understand the documents can extend it without touching the service. A pipeline that needs an engineer for every new form stops growing the moment the engineer is busy.
Deciding what to send the model is most of the engineering.
These documents arrive long, the model has a finite context window, and the content worth extracting is a small fraction of the text. Everything upstream of the model call exists to answer one question well: which part of this document does the model need to see to fill this schema. Get that wrong and no prompt rescues it, which is why identification and parsing sit ahead of extraction rather than being folded into it.
The trigger lives where the work already happens.
The whole system is reachable by clicking one document in Salesforce. Nobody logs into a second tool, uploads a file, waits, downloads a result and pastes it back. That sounds like convenience and is really about adoption: an extraction service that requires a detour is used for the first week and abandoned, and the value of this one is that it is in the path somebody was already walking.
Results go back to where the decision is made.
The output is not a file somebody has to find. Extracted fields, the derived business columns and any calculation land back on the Salesforce record that triggered the run, which is the screen where the decision is taken. Artifacts are written to the Azure share as well, so the JSON and CSV exist for anything downstream, but the primary destination is the record.
What it found
Manual, before
The work this replaced was people typing numbers off documents.
Every document was opened, read, and keyed into the CRM by hand before a decision could be made. It was slow, it needed people, and the cost grew with volume. The service now does that step, which frees those people for work where reading a document carefully actually matters. No figure is published here for what it saved, because the client measured that and I did not.
It says why
Failure is a code and a reason, not an empty field.
When extraction cannot produce a usable result the run fails explicitly, reports which error it hit, and can be retried. The design goal was that no output is worse than a wrong output in a system feeding a credit decision, and the error codes are what make a retry an informed action rather than a hopeful one.
Whatever arrives
Scans, photographs, images, rotated pages, poor handwriting.
The parsing stage assumes nothing about input quality, because the documents come from thousands of individual practices rather than from one system that could be standardised. A pipeline that required a clean text layer would have automated the easy half and left the hard half exactly where it was.
What it does not do
- It reports failure, not error. When it cannot read a document it says so and can be retried. What it cannot do is tell you that it succeeded and got a number wrong, which is the harder and more dangerous case, and it is the entire subject of the invoice extraction project elsewhere in this section.
- Coverage is a list, not a capability. It reads the document types that have been given a schema, a prompt and their rules. A type nobody has written yet is a type it cannot read.
- It is built for German financial documents and the vocabulary and layouts they use. It is not a general document extractor and was never meant to be.
- It sits in the middle of a chain it does not own either end of. If the CRM or the Azure share is unreachable, there is no extraction to be had.
- Input quality still sets a floor. It is built to accept a bad scan rather than to refuse one, which is not the same as reading every bad scan correctly, and the honest outcome in that case is a failure with a reason rather than a guess.
Where it stands
In production and working well. It replaced a manual process and is the largest piece of work described on this site.
The obvious next step is that this data now supports a model rather than only a form: a scored credit assessment built on the extracted history. That project is agreed in principle and not started, and it is on hold for reasons on the client's side rather than technical ones.