Verify AI-Extracted Values Against the PDF
November 12, 2026

An LLM pulls fields out of an invoice or contract. Before anyone relies on them, check each value against the document and show where it is printed.
Extracting fields from invoices, purchase orders and contracts is one of the most common uses of LLMs on documents. The model reads the PDF and returns clean JSON: invoice number, dates, amounts, bank details. It is fast, and it is usually right. But "usually" is not good enough for accounts payable or an audit. A model can transpose two digits in a total, reformat a date into the wrong day, or produce a plausible VAT number that appears nowhere in the document.
Citations do not cover this case well. Structured output is what extraction needs, and Claude's API, for example, does not allow citations and structured outputs in the same request. So the extracted values arrive without evidence.
PyMuPDF can provide the evidence afterwards. In this article we take the JSON an LLM extracted from an invoice, find every value in the PDF, check that it is printed next to the right label, and produce a copy of the invoice with each value marked as verified or wrong.
Step 1: Extract the fields
Use any model and any extraction method. Ask for dates in ISO format and amounts as plain numbers, so they can be compared reliably. Here’s an example prompt for your favourite LLM:
Extract these fields from the invoice and answer with JSON only:
vendor, invoice_number, invoice_date, due_date, po_number,
subtotal, tax, total, bank_account, vat_number.
Write dates as YYYY-MM-DD and amounts as plain numbers. Use null for missing fields.However, in one of our run test examples for provided invoice, the model returned the some incorrect data. Two values were wrong: the total has two digits swapped (the actual invoice says 16,020.00, but the LLM returned 16200.00), and the VAT number does not appear on the actual invoice at all, but the LLM provided one.
Output from LLM:
extracted = { # the model's answer
"vendor": "Northwind Cloud Services Ltd.",
"invoice_number": "INV-2026-0417",
"invoice_date": "2026-10-15",
"due_date": "2026-11-14",
"po_number": "PO-88213",
"subtotal": 13350.00,
"tax": 2670.00,
"total": 16200.00,
"bank_account": "43210987",
"vat_number": "GB 123 4567 89",
}The same check works on JSON from any extractor, including PDF.co's AI Invoice Parser: map its field names to the labels printed on your invoices.
Step 2: Find values by type
A value can be printed differently from how it was extracted. "16020.0" is printed as "16,020.00", and "2026-10-15" as "October 15, 2026". So each field is compared by its kind: text is located with the locate() function from the first article in this series, amounts are compared as numbers, and dates are compared as dates.
import re
from datetime import date, datetime
import pymupdf
from grounding import locate
DATE_PATTERNS = [
(r"[A-Z][a-z]+ \d{1,2}, \d{4}", "%B %d, %Y"), # October 15, 2026
(r"\d{1,2} [A-Z][a-z]+ \d{4}", "%d %B %Y"), # 15 October 2026
(r"\d{4}-\d{2}-\d{2}", "%Y-%m-%d"), # 2026-10-15
(r"\d{1,2}/\d{1,2}/\d{4}", "%m/%d/%Y"), # 10/15/2026 (US order)
(r"\d{1,2}\.\d{1,2}\.\d{4}", "%d.%m.%Y"), # 15.10.2026
]
def to_number(text):
"""'16,020.00' -> 16020.0, '(52)' -> -52.0, '$1,200' -> 1200.0; None if not a number."""
t = re.sub(r"[,$€£\s]", "", str(text))
if re.fullmatch(r"\(\d+(\.\d+)?\)", t):
t = "-" + t[1:-1]
try:
return float(t)
except ValueError:
return None
def find_amount(page, value):
"""Rects of words on the page whose numeric value equals value."""
target = to_number(value)
return [pymupdf.Rect(w[:4]) for w in page.get_text("words")
if (n := to_number(w[4])) is not None and abs(n - target) < 0.005]
def find_dates(page):
"""Every date printed on the page, as (date, rect)."""
found = []
for block in page.get_text("dict")["blocks"]:
for line in block.get("lines", []):
text = "".join(span["text"] for span in line["spans"])
for pattern, fmt in DATE_PATTERNS:
for m in re.finditer(pattern, text):
try:
d = datetime.strptime(m.group(), fmt).date()
except ValueError:
continue
for rect in page.search_for(m.group(), clip=line["bbox"]):
found.append((d, rect))
return found
def find_value(page, value, kind):
"""Rects where value appears on the page, comparing by kind: text, amount or date."""
if kind == "amount":
return find_amount(page, value)
if kind == "date":
target = date.fromisoformat(value)
return [rect for d, rect in find_dates(page) if d == target]
quads = locate(page, str(value))
return [q.rect for q in quads]Step 3: Check each value next to its label
Finding a value somewhere on the page is not enough. On the sample invoice, 1,200.00 appears twice in the line items: if the model had returned 1,200.00 as the tax, a plain search would confirm it. So when a field has a label, the value must be printed next to that label. If a different value is printed there, the field is a mismatch, and we report what the document actually says.
def same_line_right(rect, label):
"""True if rect is on the label's line, to its right."""
middle = (rect.y0 + rect.y1) / 2
return rect.x0 >= label.x1 - 1 and label.y0 <= middle <= label.y1
def printed_next_to(page, label_rect):
"""The text printed to the right of a label on the same line, with its rect."""
words = [w for w in page.get_text("words") if same_line_right(pymupdf.Rect(w[:4]), label_rect)]
if not words:
return None, None
rect = pymupdf.Rect()
for w in words:
rect |= pymupdf.Rect(w[:4])
return " ".join(w[4] for w in words), rect
def verify_field(doc, name, value, kind="text", label=None):
"""Check one extracted value against the document.
With a label, the value must be printed next to that label:
'verified' - the value is printed next to the label
'mismatch' - a different value is printed next to the label
Without a label, the value only has to appear somewhere in the document.
'not found' means neither the value nor the label was found.
"""
elsewhere = None
for page in doc:
rects = find_value(page, value, kind)
labels = page.search_for(label) if label else []
if label and labels:
near = [r for r in rects if same_line_right(r, labels[0])]
if near:
return {"field": name, "value": value, "status": "verified",
"page": page.number + 1, "rect": near[0]}
printed, rect = printed_next_to(page, labels[0])
return {"field": name, "value": value, "status": "mismatch",
"printed": printed, "page": page.number + 1, "rect": rect,
"found_elsewhere": bool(rects)}
if rects and elsewhere is None:
elsewhere = {"field": name, "value": value, "status": "verified",
"page": page.number + 1, "rect": rects[0]}
if elsewhere: # value found, but its label was not
if label:
elsewhere["note"] = "label not found"
return elsewhere
return {"field": name, "value": value, "status": "not found"}The schema says what kind each field is and which label it sits next to. Fields without a fixed label, such as the vendor name, only have to appear somewhere in the document.
schema = { # field: (kind, label printed next to the value)
"vendor": ("text", None),
"invoice_number": ("text", "Invoice number:"),
"invoice_date": ("date", "Invoice date:"),
"due_date": ("date", "Due date:"),
"po_number": ("text", "PO number:"),
"subtotal": ("amount", "Subtotal"),
"tax": ("amount", "VAT (20%)"),
"total": ("amount", "Total due (GBP)"),
"bank_account": ("text", None),
"vat_number": ("text", "VAT number"),
}
doc = pymupdf.open("invoice.pdf")
results = [verify_field(doc, name, value, *schema[name]) for name, value in extracted.items()]
for r in results:
extra = f' printed: {r["printed"]}' if r["status"] == "mismatch" else ""
where = f'page {r["page"]}' if "page" in r else ""
print(f'{r["field"]:15} {str(r["value"]):30} {r["status"]:10} {where}{extra}')vendor Northwind Cloud Services Ltd. verified page 1
invoice_number INV-2026-0417 verified page 1
invoice_date 2026-10-15 verified page 1
due_date 2026-11-14 verified page 1
po_number PO-88213 verified page 1
subtotal 13350.0 verified page 1
tax 2670.0 verified page 1
total 16200.0 mismatch page 1 printed: 16,020.00
bank_account 43210987 verified page 1
vat_number GB 123 4567 89 not foundEight values are confirmed. The total is caught as a mismatch, with the correct figure from the invoice. The VAT number is reported as not found, because neither the value nor a "VAT number" label is on the invoice.
Step 4: Mark the results on the invoice
A reviewer should see the result on the document itself. This marks each checked value with a green or red box and attaches a note with the extracted and printed values:
COLORS = {"verified": (0, 0.6, 0), "mismatch": (0.85, 0, 0)}
def mark_results(doc, results):
"""Box each checked value: green if verified, red if it does not match, with a note."""
for r in results:
if "rect" not in r:
continue
page = doc[r["page"] - 1]
annot = page.add_rect_annot(r["rect"] + (-2, -2, 2, 2))
annot.set_colors(stroke=COLORS[r["status"]])
annot.set_border(width=1.2)
note = f'{r["field"]}: extracted {r["value"]}'
if r["status"] == "mismatch":
note += f', document says {r["printed"]}'
annot.set_info(content=note)
annot.update()
mark_results(doc, results)
doc.save("invoice_checked.pdf") # a new file; the original is unchanged
Going further
- Check the arithmetic. Once the values are verified against the document, check that they are consistent with each other:
values = {r["field"]: r["value"] for r in results if r["status"] == "verified"}
if {"subtotal", "tax", "total"} <= values.keys():
if abs(values["subtotal"] + values["tax"] - values["total"]) > 0.005:
print("Totals do not add up")- Line items. For tables of line items, use the cell lookup from the table article in this series to check each row by its description and column.
- Scanned invoices. Combine this with the OCR article: report which verified values were read from a scan, so the reviewer can check them against the image.
- An evidence pack. Feed the results into the evidence pack from earlier in this series to produce one file per invoice for the audit trail.
Limits to know
- The label check assumes the value is printed to the right of its label, on the same line. Layouts with values under their labels need a small extension of
same_line_right(). to_number()reads US and UK number formats. European formats such as 1.234,56 need their own parser, and so do dates such as 03/04/2026, where the order of day and month depends on the country.- Labels vary between vendors. Keep a small label list per vendor or document type, or let the LLM return the label it used for each field.
- A verified value is printed in the document; that does not make it correct. The check confirms that the extraction matches the invoice, not that the invoice is right.
Why this matters
Automated extraction only saves time if people can trust it without re-reading every document. Checking each value against the source turns "the model said so" into "the document says so, here", and sends only the mismatches to a person. For accounts payable, audit and contract review, that is the difference between AI as a shortcut and AI as part of a controlled process.
Next steps
- Read the first article in the series: Cite Claude's answers at the exact spot in the PDF.
- See everything on grounding with PyMuPDF | Grounding.
Discuss This Article with the Community
Have a question, a different approach, or something you built after reading this? Share it on the forum or join the Discord, we'd love to hear from you.