Skip to content
Benaya Given Janto
All projects

Backend & Systems

Automated OCR & LLM Document Extraction Pipeline

Production invoice extraction for a finance team, routing each document to the cheapest method that can read it, from per-vendor templates through OCR to cloud AI.

Sub-1s per page on native PDF and under 3s on scanned. 95%+ field-level accuracy on known distributor templates, 90%+ blended across mixed formats, at hundreds to thousands of documents a day.

  • Python
  • FastAPI
  • OpenCV
  • PyMuPDF
  • Tesseract
  • Azure Document Intelligence
  • MySQL
  • SQL Server
  • Docker
  • Next.js
  • TypeScript
Repository
Invoice Extractor dashboard with a PDF drop zone and two processed files showing extracted vendor and total.

Problem

Finance staff were transcribing paper and scanned invoices by hand, across a dozen vendors that each used a different layout. The volume made it slow and the variety made it error prone, and a single mistyped total is expensive to trace back later.

Solution

A routing engine that tries the cheapest method capable of reading a given document. Known vendors resolve through rule-based templates with no model call at all, native PDFs skip OCR entirely, and only what defeats both reaches cloud AI.

Architecture

A tiered pipeline where every stage is cheaper and more deterministic than the one behind it, with each run logged so ambiguous results can be pulled for review.

  • Twelve customer-specific templates, each pairing a layout fingerprint with its own field mapper, so documents from a known vendor resolve without any model call.
  • Native PDFs are read directly through PyMuPDF. Scanned pages go through OpenCV deskewing and noise reduction before Tesseract sees them.
  • Azure Document Intelligence takes the tables and stamps that defeat the local tier, with an LLM behind it for layouts that defeat Azure.
  • Every tier normalises to one result shape, so nothing downstream has to branch on which method produced a field.
  • Each run writes its method, confidence score, and latency to an audit database, which is what lets low-confidence extractions be queued for a human instead of trusted silently.
  • The public repository is a clean rebuild without the company's templates or data, swapping Azure and the vendor mappers for pdfplumber and an interchangeable LLM provider.

Key features

  • Per-vendor template matching that resolves known layouts with no model call
  • OpenCV deskewing, noise filtering, and edge detection ahead of OCR on scanned documents
  • Table boundary detection across unstructured multi-page invoices
  • Confidence scoring on every extraction, with low-confidence results queued for human review
  • A web interface for uploading documents and checking extracted fields before they reach the database

Direct contributions

  • Sole developer. Built the pipeline end to end and deployed it into daily finance operations.
  • Wrote all twelve vendor templates and their field mappers, plus the routing that picks between them.
  • Built the OpenCV preprocessing that made skewed mobile photographs of invoices readable by OCR.
  • Designed the audit logging so the team reviewed low-confidence extractions instead of re-checking every document.
  • Rebuilt a public version from scratch with the company's templates and data removed.

Implementation

Cheapest method that can work, in order

The whole thesis of the pipeline in one function. A known vendor never reaches a model. An unknown one tries the LLM. If that fails for any reason the generic template still runs, because returning nothing is worse than returning something a human can correct.

Python
def run_extraction(pdf_path: str) -> ExtractionResult:
    """Run the full pipeline on a PDF file and return a normalized ExtractionResult.

    Strategy:
    1. If EXTRACTION_MODE=llm is forced, always use the LLM provider.
    2. Otherwise try to match a known vendor template (rule-based, offline, free).
    3. If nothing matches and an LLM key is configured, try the LLM provider.
    4. Fall back to the generic rule-based template so we never return nothing.
    """
    text = extract_pdf_text(pdf_path)
    force_llm = os.environ.get("EXTRACTION_MODE", "rule_based").lower() == "llm"

    if not force_llm:
        template = detect_template(text)
        if template is not None:
            return template.extract(text)

    try:
        return _get_llm_provider().extract(text)
    except LLMProviderError as exc:
        logging.getLogger(__name__).warning(
            "LLM-assisted extraction failed, falling back to generic template: %s", exc
        )

    return GenericInvoiceTemplate().extract(text)

Rupiah and dollars do not agree on what a period means

Rp1.500.000 is one and a half million, not one point five. Indonesian invoices also write a trailing comma-dash to mean no cents. Getting this wrong is a thousandfold error on a total, so currency is detected first and parsing follows from it.

Python
"""Currency detection and locale-aware amount parsing.

Handles the two number-formatting conventions this app has to support:
- US/generic: comma thousands separator, period decimal ("1,234.56").
- Indonesian Rupiah: period thousands separator, comma decimal, and often no
  decimals at all, sometimes written with a trailing ",-" to mean "even amount,
  no cents" ("Rp1.500.000" or "Rp1.500.000,-").
"""

def parse_amount(raw: str | None, currency: str) -> float | None:
    """Normalize a matched amount string to a float, given its detected currency."""
    if raw is None:
        return None

    cleaned = raw.strip()
    if cleaned.endswith(",-"):
        cleaned = cleaned[:-2]

    if currency == "IDR":
        # Period = thousands separator, comma = decimal separator.
        cleaned = cleaned.replace(".", "").replace(",", ".")
    else:
        # Comma = thousands separator, period = decimal separator.
        cleaned = cleaned.replace(",", "")

    try:
        return float(cleaned)
    except ValueError:
        return None

Interface

Extraction detail for a processed invoice showing vendor, total, confidence score, and the parsed line item table.
This document went through the cloud fallback tier. Invoices matching a known distributor template resolve far higher.