A local, network-free portfolio MVP that turns synthetic vendor-invoice PDFs into validated structured records, routes uncertain or invalid results to a human reviewer, keeps append-only evidence, and exports approved data.
Synthetic data only. Not production software. No claim is made about real-world accuracy, regulatory compliance, customer usage or time savings.
Live demo: vendor-invoice-app-assistant on Streamlit Community Cloud. No PDF needed: on the Upload page, pick a sample invoice and click Run sample. The hosted demo shares one database across visitors and starts empty again when the app restarts.
Streamlit UI over plain-Python services, a domain layer with exact Decimal/date rules, and
a local SQLite database. Extraction is deterministic (no model, no network). An AI-provider
protocol, a test-only fake and a Pydantic validation boundary demonstrate structured-output
handling; no real provider is connected.
Accounts-payable and operations teams re-key semi-structured vendor invoices. Manual entry is slow and error-prone (identity, date, currency, tax, arithmetic, duplicates, PO references), while fully automatic extraction is risky on unfamiliar layouts, conflicting values or low confidence. This demo shows a controlled middle path: automate the clean cases, make every exception visible, and require a deliberate human approval.
A non-technical operations analyst who uploads invoices, reviews exceptions, compares raw and normalized values, corrects or confirms fields, approves valid records, rejects invalid ones, and exports approved data.
Synthetic PDF -> file-security checks -> deterministic text extraction -> raw field evidence
-> exact normalization -> validation + duplicate detection -> exception-based review
-> approve / reject -> approved CSV/JSON export -> append-only audit history + metrics
| Clean invoice upload | Explicit extraction retry |
|---|---|
![]() |
![]() |
| Human review queue | Operational and evaluation metrics |
|---|---|
![]() |
![]() |
app.py Streamlit entry point (sidebar navigation)
src/invoice_qa/
domain/ models, normalization, validation, transitions (no I/O)
extraction/ Extractor protocol, DeterministicExtractor, AI boundary
infrastructure/ SQLite repository, file-security checks
services/ intake, review, metrics, export
ui/ page renderers and display helpers
synthetic.py deterministic PDF builder shared by tests and the generator
sample_data/, scripts/ manifest, generator, evaluator
A short-lived SQLite connection is opened per repository operation. “Today” is injected through a clock dependency; domain and service code never read the system clock.
USD, EUR, GBP, SGD, VND. Dates accept ISO YYYY-MM-DD only.^[+-]?(\d{1,3}(,\d{3})+|\d+)(\.\d+)?$; locale variants such as
1.000,00 are malformed, never inferred.VND 0, others 2. Fewer digits are padded exactly; more digits
are malformed_value. Nothing is ever rounded.DEMO-TAX-\d{4} after trim and uppercase.missing_required_field, malformed_value, unsupported_currency,
future_invoice_date, invalid_amount_sign, arithmetic_mismatch, conflicting_values,
possible_logical_duplicate. Review warnings: low_field_confidence (< 8000 bps),
low_overall_confidence (< 8500 bps). Advisory: missing_po_reference.10000 = 100%): exact label 10000, documented alias
9000, loose alias 7500 (one field only), missing or conflicting 0. Overall confidence is
the floor mean over the seven required fields.auto_ready but still needs a deliberate human approval.low_field_confidence; blocking issues need a valid correction.review_required and records an approval_blocked event.approved and rejected are terminal. Reopen is production hardening.UPDATE/DELETE on
review events and any change to original evidence.See SECURITY.md. In short: PDF only, at most 5 MiB (enforced by the Streamlit widget, server config and the application), non-encrypted, structurally readable, at least one page; PDF bytes and full extracted text are never stored; vendor tax IDs are masked outside an explicit session-only edit action; UI errors are generic.
sample_data/manifest.json defines 13 fixtures (clean USD, clean VND, no PO, missing invoice
number, unsupported currency, malformed amount, arithmetic mismatch, future date,
low-confidence alias, conflicting total, content duplicate, logical duplicate, excess
fractional digits) with expected normalized values, confidence, exact issue-code sets and
initial status. scripts/evaluate.py processes them in manifest order against a fresh
temporary database with a fixed reference date.
Measured on 2026-10-10 (synthetic, local; code baseline in REVIEW_HANDOFF.md):
| Metric | Result |
|---|---|
| Scenarios matching the manifest | 13 / 13 |
| Field-level accuracy (fields with defined ground truth) | 111 / 111 |
| Initial review rate (review_required at creation / new candidates) | 0.75 (9 of 12) |
| False auto-ready | 0 |
| False review | 0 |
These numbers describe a small, hand-built, synthetic set whose expectations were written alongside the rules; they are a regression check, not evidence of real-world accuracy.
Requires Python 3.11 or newer.
python -m venv .venv
.venv/Scripts/python -m pip install -e ".[dev]" # Windows; use .venv/bin/python elsewhere
.venv/Scripts/python -m pytest -q
.venv/Scripts/ruff check .
.venv/Scripts/python sample_data/generate_samples.py
.venv/Scripts/python scripts/evaluate.py --manifest sample_data/manifest.json --output .evaluation/report.json
.venv/Scripts/streamlit run app.py
Configuration is read from the process environment only (.env.example documents
INVOICE_QA_DB_PATH; no dotenv loader). Default database: .data/invoice_qa.sqlite3
(gitignored).
sample_data/generated/fx01-clean-usd.pdf; it routes to auto_ready.fx06-malformed-amount.pdf; open the Review Queue, correct Subtotal to
1,000.00 and watch the status change.fx09-low-confidence-alias.pdf; confirm vendor_name (the only confirmable field).fx12-logical-duplicate.pdf; resolve it by rejecting or confirming distinctness.screenshots/03-extraction-error-with-retry.png and
screenshots/04-after-manual-retry.png): upload sample_data/generated/retry-demo-no-text.pdf (written by the
generator, a valid PDF with no text layer). The page shows an extraction error and a
Retry extraction button. Nothing retries until you click it; each click adds a numbered
attempt and keeps the failed ones (it fails again here by design, because the file never
has text). Metrics then show the failed attempts and retry attempts.started attempt younger than 10 minutes (STALE_ATTEMPT_AFTER in
infrastructure/database.py, measured with the injected database clock) is treated as
running: a second session gets an “already in progress” result and changes nothing. Only an
attempt at least 10 minutes old is preserved as extraction_interrupted when the user retries.
The threshold is a fixed guess, not derived from measurements; an extraction that really runs
longer than that could still be interrupted, and a crashed one blocks retry for up to 10 minutes.screenshots/ come from one scripted browser run on synthetic data; no
manual browser session was performed.Authentication and RBAC, managed database and retention, monitoring and alerting, a reopen flow, automatic retry policy, OCR, ERP integration, real-provider integration with its own threat model, and a legal/privacy review.
Direct dependencies: Streamlit, pypdf, ReportLab, Pydantic, pandas (runtime); pytest, Ruff (development). pypdf and ReportLab are permissive open-source libraries. This is not legal advice; confirm licenses before redistribution.
Licensed under the MIT License.