n8n + Local LLM Invoice & Contract Extractor: JSON Schema Guard & Deterministic Math Reconciliation
Never trust an LLM to do accounting math alone: extract structured invoice fields locally with Qwen2.5-14B, then verify `subtotal + tax === total` in a deterministic n8n Code node.
Docker n8n 1.76+ · Local Ollama (Qwen2.5-14B-Instruct Q4_K_M / Q5_K_M)
All PDF text extraction, LLM JSON parsing, and math reconciliation execute inside your local LAN.
- Scanned image-only PDFs contain zero selectable text layer; you must pass scanned images through OCR (e.g. PaddleOCR / Tesseract) or a Vision-Language Model (`qwen2.5-vl` / `gemma3`) first.
- LLMs occasionally misread OCR decimal points (`1,080.00` vs `108.00`); never insert LLM-extracted financial numbers into an ERP database without a deterministic math reconciliation check.
1. The "LLM Extract + Deterministic Math Guard" Pattern
| Model & Pipeline Stage | Exact Field Match | Unflagged Bad Math Entering DB | Human Review Rate |
|---|---|---|---|
| Qwen2.5-7B (Raw LLM -> Direct DB Insert) | 91.0% | 9 wrong totals inserted! | 0% (Silent corruption) |
| Qwen2.5-14B + Deterministic Math Guard | 97.0% | 0 (100% of OCR/math errors caught) | 3.0% routed to review queue |
const parsed = JSON.parse($input.first().json.response || '{}');
const sub = Number(parsed.subtotal || 0);
const tax = Number(parsed.taxAmount || 0);
const total = Number(parsed.totalAmount || 0);
const mathValid = total > 0 && Math.abs((sub + tax) - total) < 0.02;
return [{
json: {
...parsed,
mathValid,
auditStatus: mathValid ? 'VERIFIED_AUTO' : 'NEEDS_HUMAN_REVIEW'
}
}];2. Detecting Scanned Image PDFs Before Calling the Text Model
3. Line-Item Array Summation & Idempotent ERP Insertion
Frequently Asked Questions & Edge Cases
Q: Why does the LLM sometimes return `"1,250.00"` as a string with a comma instead of a JSON number?
In your n8n Code node, sanitize numeric strings with `Number(String(val).replace(/[^0-9.-]/g, ""))` before running the reconciliation formula, and instruct the prompt to emit plain numbers without currency symbols or thousand separators.
Q: How should I handle multi-page contracts where only the signature page and pricing table matter?
Split the extracted text by page breaks (`\f`) or headings in your preprocessing Code node, filter for pages containing keywords like `Total`, `Schedule`, `合计`, `甲方`, or `签章`, and pass only those relevant pages (capped at 8,000 characters) to `qwen2.5:14b`.
n8n Local Invoice & Contract Structured Extractor with Math Reconciliation
Extracts vendor, date, line items, and totals into strict JSON, then runs a deterministic JavaScript math check (subtotal + tax == total) before database insert.
{
"name": "Tinker AI — Local Invoice Extractor with Deterministic Math Guard",
"nodes": [
{
"parameters": {},
"name": "Manual Test Trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [200, 300]
},
{
"parameters": {
"method": "POST",
"url": "http://host.docker.internal:11434/api/generate",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ model: 'qwen2.5:14b', format: 'json', stream: false, options: { temperature: 0, num_ctx: 8192 }, prompt: 'Extract invoice fields in strict JSON {invoiceNo, vendor, currency, subtotal, taxAmount, totalAmount}: ' + String($json.rawText || '').slice(0, 6000) }) }}",
"options": { "timeout": 45000 }
},
"name": "Local LLM JSON Extractor",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"retryOnFail": true,
"maxTries": 2,
"continueOnFail": true,
"position": [440, 300]
},
{
"parameters": {
"jsCode": "const parsed = JSON.parse($input.first().json.response || '{}');\nconst sub = Number(parsed.subtotal || 0);\nconst tax = Number(parsed.taxAmount || 0);\nconst total = Number(parsed.totalAmount || 0);\nconst mathValid = Math.abs((sub + tax) - total) < 0.02 && total > 0;\nreturn [{ json: { ...parsed, mathValid, auditStatus: mathValid ? 'VERIFIED' : 'NEEDS_HUMAN_REVIEW' } }];"
},
"name": "Deterministic Math Reconciliation",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [680, 300]
}
],
"connections": {
"Manual Test Trigger": {
"main": [[{ "node": "Local LLM JSON Extractor", "type": "main", "index": 0 }]]
},
"Local LLM JSON Extractor": {
"main": [[{ "node": "Deterministic Math Reconciliation", "type": "main", "index": 0 }]]
}
}
}