// agent

Receipt & Invoice Extractor

by ozzo · Sep 03, 2026 Public

Choose how to run this agent

⚡ Local runs on your GPU. For usable speed it needs a WebGPU-capable browser — Chrome or Edge on a machine with a graphics card, or an Apple Silicon Mac. Without a supported GPU, pick OpenAI or Anthropic above instead. Check your machine
Try it now

Requires an API key and an AgentOp account.

65 downloads
0 forks
0.0 rating

Description

Based on the Receipt & Invoice Extractor template.

What this agent can do

Receipt & Invoice Extractor is built from the Receipt & Invoice Extractor template and loads pypdf in the browser. Runs fully on your own device: llama.cpp compiled to WebAssembly, GPU-accelerated through WebGPU, with no API key and no server. After the one-time model download it works offline. Can run on OpenAI models with your own API key, encrypted in your browser. Can run on Anthropic Claude models with your own API key, encrypted in your browser.

Runs in the browser

  • Image reading (OCR). Reads images in the browser: OCR for text in photos and scans, plus image captioning, with no image leaving the device.

Source Code

agent.py
import json
import re

# How many extracted rows are shown to the model when answering questions.
MAX_ROWS_IN_PROMPT = [[[MAX_ROWS_IN_PROMPT|60]]]
# Hard cap on the facts sheet handed to the model (small local context windows).
MAX_SHEET_CHARS = [[[MAX_SHEET_CHARS|6000]]]
# How much receipt text the vendor/category classifier sees.
CLASSIFY_CHARS = [[[CLASSIFY_CHARS|700]]]

CATEGORIES = [
    "groceries", "restaurant", "fuel", "travel", "lodging", "software",
    "office", "utilities", "health", "other",
]

# One dict per extracted receipt (see _scan_receipt for the shape).
_RECEIPTS = []

_CURRENCY_SYMBOLS = {"$": "USD", "\u20ac": "EUR", "\u00a3": "GBP",
                     "\u20ba": "TRY", "\u00a5": "JPY"}
_CURRENCY_CODE_RE = re.compile(
    r"\b(USD|EUR|GBP|TRY|CHF|JPY|CAD|AUD|SEK|NOK|DKK|PLN)\b"
)
# Money, and only money: 1.234,56 / 1,234.56 / 249.00 / $254.
#
# A bare integer is deliberately NOT an amount unless a currency symbol is
# attached to it. Receipts are full of long digit strings that are not money —
# UPCs, product codes, store and phone numbers — and browser testing on real
# photographed receipts showed exactly that failure: "#60101" and
# "04900005375" were picked up as the largest amount on the page and reported
# as the total. Requiring a two-digit decimal, thousands grouping, or an
# adjacent symbol is what separates "$2.18" from a barcode.
_AMOUNT_RE = re.compile(
    r"(?<![0-9])"
    r"(?:"
    r"[$\u20ac\u00a3\u20ba\u00a5]\s?[0-9]{1,6}(?![0-9.,])"  # symbol + whole units (JPY)
    r"|[0-9]{1,3}(?:[.,\s][0-9]{3})+(?:[.,][0-9]{2})?"  # grouped thousands
    r"|[0-9]{1,6}[.,][0-9]{2}"  # plain decimal amount
    r")"
    r"(?![0-9])"
)
_TOTAL_RE = re.compile(
    r"grand total|total due|amount due|balance due|amount paid|to pay|\btotal\b"
    r"|\btoplam\b|\bgesamt\b|\bimporte\b", re.I
)
_SUBTOTAL_RE = re.compile(r"sub\s?-?total|net total|\bnet\b", re.I)
_TAX_RE = re.compile(r"\b(vat|tax|gst|hst|kdv|iva|mwst|tva)\b", re.I)
_PERCENT_RE = re.compile(r"[0-9]+(?:[.,][0-9]+)?\s*%")
_MONTHS = {
    "jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
    "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
}
_ISO_DATE_RE = re.compile(r"(?<![0-9])(20[0-9]{2})[-/.]([01]?[0-9])[-/.]([0-3]?[0-9])(?![0-9])")
_DMY_DATE_RE = re.compile(r"(?<![0-9])([0-3]?[0-9])[-/.]([0-3]?[0-9])[-/.]((?:20)?[0-9]{2})(?![0-9])")
_NAMED_DATE_RE = re.compile(
    r"(?<![0-9])([0-3]?[0-9])\s+([A-Za-z]{3,9})\.?\s+(20[0-9]{2})(?![0-9])"
)


def _parse_amount(raw):
    """Parse one money-ish token into a float, or None.

    Handles both separator conventions: whichever of "." / "," comes last and
    is followed by one or two digits is the decimal point; everything else is
    thousands grouping.
    """
    # Drop the currency symbol the amount pattern may have captured, plus any
    # stray spacing; what is left must be digits and separators only.
    s = re.sub(r"[^0-9.,]", "", str(raw))
    if not s:
        return None
    decimal_at = max(s.rfind("."), s.rfind(","))
    if decimal_at != -1 and len(s) - decimal_at - 1 in (1, 2):
        whole = re.sub(r"[.,]", "", s[:decimal_at])
        s = whole + "." + s[decimal_at + 1:]
    else:
        s = re.sub(r"[.,]", "", s)
    try:
        return round(float(s), 2)
    except ValueError:
        return None


def _amounts_in(line):
    """Every money-ish amount on one line.

    Percentages are stripped first, so "VAT 8.1% 1.26" yields the 1.26 charged
    and not the 8.1 rate.
    """
    found = []
    for match in _AMOUNT_RE.finditer(_PERCENT_RE.sub(" ", line)):
        value = _parse_amount(match.group(0))
        if value is not None:
            found.append(value)
    return found


def _normalize_date(text):
    """Return YYYY-MM-DD when the date is unambiguous, else the matched text.

    Deliberately does NOT guess between 03/04/2026 readings — an expense table
    that silently swaps day and month is worse than one that shows what it saw.
    """
    match = _ISO_DATE_RE.search(text)
    if match:
        year, month, day = (int(g) for g in match.groups())
        if 1 <= month <= 12 and 1 <= day <= 31:
            return "%04d-%02d-%02d" % (year, month, day)
    match = _NAMED_DATE_RE.search(text)
    if match:
        day, name, year = match.group(1), match.group(2)[:3].lower(), match.group(3)
        if name in _MONTHS:
            return "%s-%02d-%02d" % (year, _MONTHS[name], int(day))
    match = _DMY_DATE_RE.search(text)
    if match:
        first, second, year = (int(g) for g in match.groups())
        if year < 100:
            year += 2000
        if first > 12 and second <= 12:
            return "%04d-%02d-%02d" % (year, second, first)
        if second > 12 and first <= 12:
            return "%04d-%02d-%02d" % (year, first, second)
        return match.group(0)  # ambiguous — report it verbatim
    return ""


def _detect_currency(text):
    match = _CURRENCY_CODE_RE.search(text)
    if match:
        return match.group(1).upper()
    for symbol, code in _CURRENCY_SYMBOLS.items():
        if symbol in text:
            return code
    return ""


def _guess_vendor(lines):
    """Receipts put the merchant at the top — take the first line that reads
    like a name rather than an address, a date or a price."""
    for line in lines[:6]:
        letters = sum(character.isalpha() for character in line)
        if letters < 3 or letters < len(line) / 3:
            continue
        if _TOTAL_RE.search(line) or _normalize_date(line):
            continue
        return line[:60]
    return ""


def _find_total(lines):
    """(amount, how it was found) for the bottom-line total.

    A line that names a total wins over a big number found anywhere, and the
    LAST such line wins over earlier ones (grand totals come last).
    """
    candidate = None
    for line in lines:
        if _SUBTOTAL_RE.search(line) or not _TOTAL_RE.search(line):
            continue
        amounts = _amounts_in(line)
        if amounts:
            candidate = max(amounts)
    if candidate is not None:
        return candidate, "total line"
    everything = [value for line in lines for value in _amounts_in(line)]
    if everything:
        return max(everything), "largest amount"
    return None, ""


def _find_tax(lines):
    tax = None
    for line in lines:
        if not _TAX_RE.search(line):
            continue
        amounts = _amounts_in(line)
        if amounts:
            tax = max(amounts)
    return tax


def _scan_receipt(text):
    """Deterministic extraction of one receipt's text. No model involved."""
    lines = [" ".join(raw.split()) for raw in text.splitlines()]
    lines = [line for line in lines if line]
    total, total_source = _find_total(lines)
    return {
        "vendor": _guess_vendor(lines),
        "date": _normalize_date(text),
        "currency": _detect_currency(text),
        "total": total,
        "total_source": total_source,
        "tax": _find_tax(lines),
        "category": "",
        "lines": len(lines),
    }


def _first_json_object(reply):
    """Pull the first {...} block out of a model reply, or return None."""
    start = reply.find("{")
    end = reply.rfind("}")
    if start == -1 or end <= start:
        return None
    try:
        parsed = json.loads(reply[start:end + 1])
    except (ValueError, TypeError):
        return None
    return parsed if isinstance(parsed, dict) else None


async def _classify(row, text):
    """Ask the model for the two fuzzy fields only: vendor name and category.

    Best-effort by construction — any failure keeps the deterministic guess.
    Amounts are never sent back through the model.
    """
    prompt = (
        "Read this receipt text and reply with ONLY a JSON object, no prose:\n"
        '{"vendor": "<merchant name>", "category": "<one of: '
        + ", ".join(CATEGORIES) + '>"}\n\nRECEIPT TEXT:\n'
        + text[:CLASSIFY_CHARS]
    )
    reply = await agentop_llm.generate(prompt, "You extract fields as strict JSON.")
    parsed = _first_json_object(reply or "")
    if not parsed:
        return row["vendor"], "other"
    vendor = str(parsed.get("vendor") or row["vendor"]).strip()[:60]
    category = str(parsed.get("category") or "").strip().lower()
    return vendor or row["vendor"], category if category in CATEGORIES else "other"


async def _extract_receipt(source, text):
    """(JS-callable) Turn one receipt's text into a table row.

    Underscore-prefixed so it is never exposed to the LLM as a tool. Returns
    the row as JSON for the UI.
    """
    row = _scan_receipt(text)
    row["source"] = source
    try:
        row["vendor"], row["category"] = await _classify(row, text)
    except Exception:  # noqa: BLE001 — enrichment is optional, never fatal
        row["category"] = row["category"] or "other"
    _RECEIPTS.append(row)
    return json.dumps(row)


def _rows_json():
    """(JS-callable) Every extracted row, for the table and the CSV export."""
    return json.dumps(_RECEIPTS)


def _summary():
    """(JS-callable) Totals per currency and per category, for the summary bar."""
    by_currency, by_category = {}, {}
    for row in _RECEIPTS:
        if row.get("total") is None:
            continue
        currency = row.get("currency") or "?"
        by_currency[currency] = round(by_currency.get(currency, 0) + row["total"], 2)
        category = row.get("category") or "other"
        by_category[category] = round(by_category.get(category, 0) + row["total"], 2)
    return json.dumps(
        {"count": len(_RECEIPTS), "by_currency": by_currency, "by_category": by_category}
    )


def _reset():
    """(JS-callable) Clear the table."""
    _RECEIPTS.clear()
    return "0"


def _facts_sheet():
    """The extracted table as text, capped for small context windows."""
    rows = _RECEIPTS[-MAX_ROWS_IN_PROMPT:]
    out = ["#. vendor | date | category | total | currency | tax | file"]
    for index, row in enumerate(rows, 1):
        out.append(
            "%d. %s | %s | %s | %s | %s | %s | %s"
            % (
                index,
                row.get("vendor") or "(unknown)",
                row.get("date") or "(no date)",
                row.get("category") or "other",
                "(not found)" if row.get("total") is None else row["total"],
                row.get("currency") or "?",
                "-" if row.get("tax") is None else row["tax"],
                row.get("source") or "",
            )
        )
    return "\n".join(out)[:MAX_SHEET_CHARS]


async def process_user_query(query):
    """Answer questions about the extracted receipts, and only about them.

    Overrides the default router: the data is a small structured table, so it
    is handed over whole rather than retrieved (no embeddings needed).
    """
    if not _RECEIPTS:
        return (
            "No receipts yet. Add a photo or a PDF invoice above (or press "
            "Try a sample receipt) and I will pull out the vendor, date and total."
        )
    grounded_prompt = (
        "Answer the QUESTION using ONLY the EXPENSE TABLE below, which was "
        "extracted from the user's own receipts. Amounts in the table are exact "
        "- never invent or re-estimate one, and say so if a figure is missing. "
        "When you add figures up, show the arithmetic briefly.\n\n"
        "EXPENSE TABLE:\n" + _facts_sheet() + "\n\nQUESTION: " + query
    )
    return await agentop_llm.generate(
        grounded_prompt, globals().get("TEMPLATE_SYSTEM_PROMPT", "")
    )


async def _ocr_line(image_url):
    """(JS-callable) Read one cropped line strip with the on-device OCR model."""
    return (await agentop_ml.ocr(image_url)) or ""


def _extract_pdf_text(pdf_bytes):
    """(JS-callable) Extract text from a digital invoice PDF via pypdf."""
    import io
    from pypdf import PdfReader

    reader = PdfReader(io.BytesIO(pdf_bytes))
    return "\n\n".join((page.extract_text() or "") for page in reader.pages)

More by ozzo

New Hire Handbook Q&A

Based on the New Hire Handbook Q&A template.

Contract Plain-Language Explainer

Upload a contract and get it explained in plain language — obligations, fees, deadlines, exit claus…

WhatsApp Sales Copilot

Turn a raw WhatsApp chat export into a mini CRM — typed quotes, bookings, payments and boarding pas…

Private Quote & Material Estimator

Drop competing contractor quotes and compare them side by side — totals, inclusions, exclusions — t…

Messy Itinerary Travel Planner

Drop your messy pile of booking PDFs and trip notes and get a clean day-by-day itinerary — plus war…

Semantic Search

Search your own notes or documents by meaning, not keywords — instant results with no LLM download.