Shipping Document Extraction Pipeline
Extracts structured data from freight-forwarder PDFs (bills of lading, air waybills, tax invoices, etc.) using a local vision model, validates it, maps it into the format your ops system expects, and serves a review UI where clerks correct mistakes before handoff.
Runs entirely on this PC. No cloud, no API keys.
What it does
PDF file Review UI (browser) Ops system (desktop app)
│ ▲ ▲
▼ │ │
┌───────────┐ extract ┌───────────┐ clerk ┌───────────────┐
│ raw JSON │ ─────────▶ │ mapped JSON │ ────▶ reads JSON, fills fields
│(our shape)│ │(target shape)│
└───────────┘ └─────────────┘
The handoff to the desktop app is the mapped JSON file at out/mapped/<name>.<tag>.json. The desktop team reads that file and fills their own form fields using their own logic — we don't drive the desktop UI.
Pipeline stages
| # | Stage | Code | What it does |
|---|---|---|---|
| 1 | Render | pdfplumber → PIL |
Converts every PDF page to a PNG image at 150 DPI |
| 2 | Classify | extract_cli.py : classify_from_text / classify / ocr_image |
Decides the document type (sea BL, air waybill, tax invoice, etc). Three paths tried in order: text-layer rules → OCR rules → vision model |
| 3 | Extract | extract_cli.py : extract + prompts/<type>.txt |
Qwen vision model reads all pages and returns one JSON object matching the prompt's schema |
| 4 | Validate | validators.py |
Adds _meta.flags and _meta.validator_details — ISO 6346 checks, date formats, line-total math, MAWB checksums, etc. Sets review_required |
| 5 | Map | mappers/plvi.py / sibl.py / aiaw.py |
Converts the raw schema into the target ops-system record shape (PLVI / SIBL / AIAW) |
| 6 | Review | serve.py + templates |
Browser UI: PDF on the left, editable form on the right. Clerk corrects mistakes, clicks Save |
| 7 | Handoff | file on disk | Mapped JSON is the contract with the desktop team |
System requirements
Software (must be installed)
| Component | Purpose | Notes |
|---|---|---|
| Python 3.10+ | Runtime | Currently tested on 3.14 |
| Ollama (https://ollama.com) | Local LLM runtime | Installs as a Windows service; listens on localhost:11434 |
Ollama model: qwen3-vl:8b |
Vision extraction | ollama pull qwen3-vl:8b — 6 GB download |
| Tesseract OCR (https://github.com/UB-Mannheim/tesseract/wiki) | OCR for scanned PDFs | Installs to C:\Program Files\Tesseract-OCR\. Optional but strongly recommended — without it, scanned PDFs slow to ~30s per doc for classification |
Optional / upgrade path
| Component | When to install |
|---|---|
qwen3.5:35b-a3b-q4_K_M |
For complex multi-page invoices that qwen3-vl:8b parse-fails on. 17 GB. Run extractor with set EXTRACT_MODEL=qwen3.5:35b-a3b-q4_K_M |
Tesseract language packs (chi_sim, chi_tra, vie, jpn, kor, …) |
If you process non-English scanned PDFs. Drop .traineddata files into C:\Program Files\Tesseract-OCR\tessdata\ |
playwright + Chromium |
Only if you want the Playwright browser-automation push demo (push/push_cli.py). Real integration uses the JSON file instead. |
Hardware
Tested on: i9-13900KF / 64 GB RAM / RTX 4090 24 GB VRAM.
Minimum workable: any CPU + 16 GB RAM + 16 GB VRAM for qwen3-vl:8b (or CPU-only Ollama — much slower).
Python packages
Install once:
pip install fastapi uvicorn jinja2 python-multipart pdfplumber pillow requests pytesseract jsonschema
One-time setup
-
Install Ollama, then pull the model:
cmd ollama pull qwen3-vl:8b -
Install Tesseract. Run the installer; accept defaults. Check it worked:
cmd "C:\Program Files\Tesseract-OCR\tesseract.exe" --version -
(Optional) Tesseract language packs for Chinese / Vietnamese etc. Download
.traineddatafiles from https://github.com/tesseract-ocr/tessdata_fast and drop them intoC:\Program Files\Tesseract-OCR\tessdata\. -
Install Python packages (see list above).
-
(Optional) Firewall — if you want LAN access to the review UI from other PCs:
cmd netsh advfirewall firewall add rule name="ExtractionReviewUI" dir=in action=allow protocol=TCP localport=8000
Daily use
Option A — Web UI (recommended)
python serve.py
Open http://localhost:8000 (or http://<this-pc-ip>:8000 from another LAN machine).
- Drop a PDF on the upload zone
- Wait 2-5 minutes while extraction runs (PDF visible on the left during processing)
- When done, the page switches to the review form with fields filled in
- Edit any wrong fields; click Save (or Ctrl+S)
- The mapped JSON at
out/mapped/<name>.<tag>.jsonis updated on every save
Features in the review UI: - Split view: source PDF on the left, editable form on the right - Empty fields get an orange tint; format-invalid fields get a red tint with tooltip - Validator flags at the top are clickable — jump straight to the affected field - Add / delete rows on line-item tables (containers, invoice lines, dimensions) - Sticky Save button + Ctrl+S shortcut - Unsaved-changes guard on page unload - Home page filter box (type to narrow the file list) - "Only review_required" toggle to focus on docs that need attention
Option B — CLI only (batch / scripting)
REM Single PDF
python extract_cli.py "MBL OBD.pdf"
REM Every PDF in the working directory
python extract_cli.py --all
REM Separate override for a specific extraction
set EXTRACT_MODEL=qwen3.5:35b-a3b-q4_K_M
python extract_cli.py "EBS - Internal.pdf"
set EXTRACT_MODEL=
REM Validate existing JSON files
python validate_cli.py REM validates every out/*.json
python validate_cli.py "out/MBL OBD.json" REM one file
python validate_cli.py "out/*BL*.json" REM glob
REM Map raw JSONs into target-shaped records
python map_cli.py REM maps every out/*.json
python map_cli.py "out/MBL OBD.json"
File layout
c:\tmp\
README.md ← this file
extract_cli.py ← CLI: PDF → raw JSON (classify + extract)
validators.py ← Step-5 validators (ISO 6346, date, tax math, etc.)
validate_cli.py ← CLI: runs validators on out/*.json
map_cli.py ← CLI: raw JSON → mapped JSON (target shape)
serve.py ← FastAPI app: upload, processing, review UI
schema.json ← (optional) jsonschema for raw extractions
prompts/ ← One prompt per document type, read by extract_cli
classifier.txt
sea_bill_of_lading.txt
air_waybill.txt
tax_invoice.txt
commercial_invoice.txt
packing_list.txt
delivery_order.txt
purchase_order.txt
credit_note.txt
debit_note.txt
proforma_invoice.txt
statement_of_accounts.txt
unknown.txt
mappers/ ← raw JSON → target-system record shape
__init__.py
common.py (address splitter, party block, numbered-lines helpers)
plvi.py (tax_invoice / commercial_invoice → Vendor Invoice)
sibl.py (sea_bill_of_lading → Sea Import BL)
aiaw.py (air_waybill → Air Import AWB)
push/ ← Browser-automation demo (optional — not used in prod)
push_cli.py
selectors.py
templates/ ← Jinja2 HTML templates for the web UI
base.html
index.html (home: file list + upload zone + filter bar)
plvi.html (Vendor Invoice review form)
sibl.html (Sea Import review form)
aiaw.html (Air Import review form)
processing.html (upload progress page with PDF preview)
raw.html (view raw extraction JSON)
demo_target.html (fake target form — Playwright demo only)
_macros.html (shared template macros)
static/
app.js (client-side: drag-drop, validation, animate-fill, tabs)
style.css (shared styles)
out/ ← Pipeline output — both stages
<stem>.json (raw extraction per PDF)
mapped/
<stem>.<tag>.json (target-shaped record; tag = plvi|sibl|aiaw)
How each part works
extract_cli.py — classification + extraction
- Renders every PDF page to PNG
- Classify the document (fastest to slowest path):
- Text rules: regex over the PDF's native text layer — runs in <1 ms when the PDF has text
- OCR: runs Tesseract on the page image, then re-runs the text rules on the OCR output (~500-1000 ms)
- Vision classifier: last resort — one Ollama call on page 1 (~15-30 s)
- Extract: one Ollama call with ALL pages as image inputs and
prompts/<doc_type>.txtas the system prompt. Output token budget scales with page count (8k base + 4k per extra page). - Writes
out/<stem>.jsonwith_meta.classified_viaset totext/ocr+text/vision.
Classification rules are in TEXT_CLASSIFIER_RULES at the top of the file. Ordered — more specific first. Support English + Chinese (Simplified/Traditional) + Vietnamese. Extend by adding regex patterns per doc type.
Doc type hint: if the caller passes doc_type_hint="tax_invoice", classification is skipped entirely. The web upload form exposes this as a dropdown.
validators.py — Step 5 integrity checks
Runs after extraction. Walks the extracted JSON looking for integrity problems — bad container checksums, dates that don't parse, line-item totals that don't match the subtotal, etc. Emits:
_meta.flags: list of short codes like"bad_container_number","line_total_mismatch:5"_meta.validator_details: structured[{severity, code, msg}, ...]for the review UI_meta.extraction_confidence:high/medium/low(derived from flag severities)review_required: boolean — true if anyerror-severity flag fired
Known validator codes and the fields they map to in the review UI are in static/app.js : CODE_TO_SELECTORS.
map_cli.py + mappers/ — target-shape transformation
Each doc type dispatches to a mapper:
Raw document_type |
Mapper | Target tag |
|---|---|---|
tax_invoice, commercial_invoice |
map_plvi |
plvi (Vendor Invoice) |
sea_bill_of_lading |
map_sibl |
sibl (Sea Import) |
air_waybill |
map_aiaw |
aiaw (Air Import) |
| Anything else | skipped (no target shape yet) | — |
Writes out/mapped/<stem>.<tag>.json. The tag determines which HTML template the web UI uses.
Target field names match what the ops-system desktop app expects (e.g. BlNo, ContainerNo, ShipperAddress1..4). See mappers/*.py for the exact mappings.
serve.py — FastAPI web app
Binds to 0.0.0.0:8000. Routes:
| Route | Purpose |
|---|---|
GET / |
Home: file list + upload zone + filter |
POST /upload |
Accepts a PDF, kicks off the pipeline in a background thread, redirects to /processing/<job_id> |
GET /processing/{job_id} |
Split-view processing page (PDF left, progress right). Polls /api/job/ |
GET /api/job/{job_id} |
JSON status: {stage, progress, message, stem, tag, status} |
GET /doc/{stem} |
Review form (PDF left, editable form right). Template picked by tag |
POST /doc/{stem}/save |
Merges form fields into the mapped JSON |
GET /pdf/{stem} |
Serves the source PDF inline (for the left panel's iframe) |
GET /raw/{stem} |
Plain-text view of the raw extraction JSON |
GET /demo-target/{tag} |
Fake target form used by the Playwright demo only |
All mutations are synchronous file writes — no database.
static/app.js — client-side behaviour
- Drag-and-drop upload on the home page
- Animate-fill: when arriving at
/doc/<stem>?animate=1, clears all inputs, then re-fills them one by one with a green flash (so the clerk sees "the system just filled this") - Per-field validation: flags empty fields with an orange tint, format-invalid fields (bad dates, bad container numbers, etc.) with a red tint + tooltip
- Validator-chip click-to-jump: clicking a flag at the top of the page scrolls to the affected field(s) and flashes them yellow
- Add / delete row on line-item tables — re-indexes and syncs a hidden
__rowcount.PLVI2marker so the server can truncate the array properly on save - Unsaved-changes guard via
beforeunload - Ctrl+S saves
Configuration
All via environment variables — no config files:
| Variable | Default | Purpose |
|---|---|---|
EXTRACT_MODEL |
qwen3-vl:8b |
Which Ollama model to use for extraction |
EXTRACT_DPI |
150 |
PNG render DPI (higher = better OCR, slower) |
OLLAMA_URL |
http://localhost:11434/api/generate |
Ollama endpoint |
OLLAMA_TIMEOUT |
600 (seconds) |
Max wait for a single Ollama call |
OCR_LANGS |
eng+chi_sim+chi_tra+vie+msa+tha+jpn+kor |
Tesseract language set (missing packs auto-dropped) |
FORCE_VISION_CLASSIFY |
unset | If set to 1, skip text rules — force the vision classifier always (for debugging) |
Extending the system
Add a new language to classification
- Install the Tesseract language pack (drop
.traineddataintotessdata/). - Add regex patterns to the relevant entries in
TEXT_CLASSIFIER_RULES(top ofextract_cli.py). - No restart needed if running just
extract_cli.pyfrom CLI; restartserve.pyfor the web path.
Add a new document type
- Create
prompts/<new_type>.txtwith a JSON schema + rules. - Add
"<new_type>"toKNOWN_DOC_TYPESinextract_cli.py. - Add regex patterns for it in
TEXT_CLASSIFIER_RULES. - Add a classification rule in
prompts/classifier.txt. - If it needs a target-shape mapping, add a new file in
mappers/, export it frommappers/__init__.py, and add a dispatch entry inmap_cli.py : DISPATCHandserve.py : DISPATCH. - (For the UI) create
templates/<newtag>.htmlusingplvi.htmlas a template.
Change the target-system field names
Each mapper (mappers/plvi.py etc.) is the single source of truth for the target-shape record. Change field names there; re-run map_cli.py to regenerate all mapped files.
Use a different Ollama model
set EXTRACT_MODEL=qwen3.5:35b-a3b-q4_K_M
python serve.py
Or edit the DEFAULT_MODEL constant at the top of extract_cli.py.
Push to a real desktop/web target system
Two options:
- Desktop app (our current integration model): the desktop team reads
out/mapped/<stem>.<tag>.jsonon-disk and fills their form with their own logic. They open the review UI (/doc/<stem>) from within the desktop app (WebView2 or external browser) so the clerk can review before the desktop reads the file. - Web target system: edit
push/selectors.pyto map JSON paths to your form's CSS selectors, then runpython push/push_cli.py <mapped-json> --url https://your-target/form. Uses Playwright to drive the browser.
Troubleshooting
| Problem | Likely cause | Fix |
|---|---|---|
ollama call failed |
Ollama service not running, or wrong URL | Start Ollama, check http://localhost:11434 responds. Set OLLAMA_URL if it's on a different host. |
Extraction returns llm_returned_invalid_json |
Model ran out of output tokens or hit a formatting edge case | Re-extract with EXTRACT_MODEL=qwen3.5:35b-a3b-q4_K_M. Larger model handles complex tables better. |
| Classification stuck on "Classifying" for minutes | PDF is scanned AND Tesseract isn't installed → falling back to vision classifier on 35B model | Install Tesseract, or switch EXTRACT_MODEL back to qwen3-vl:8b, or pre-select the doc type with the dropdown on upload |
'str' object has no attribute 'get' crash |
Model returned a bare JSON string instead of an object | Already handled defensively in classify() and extract(). If it still fires, the raw response is too malformed — inspect out/<stem>.json._raw_response |
| Container number shows red even though it looks right | Real OCR error or a model misread. ISO 6346 check digit failed | Verify against the PDF text layer; if wrong, edit in the UI and save. If it's a real SEGU vs SECU issue, the number in the PDF IS what the validator flagged |
| Sibling strip shows for docs that aren't multi-doc | Leftover _meta.doc_total > 1 from an earlier extraction with the Option B pipeline |
Re-extract the file; new pipeline always writes doc_total=1 (or doesn't set it) |
/pdf/<stem> returns 404 |
Source PDF was deleted or renamed after extraction | Re-upload, or put the PDF back at c:\tmp\<original_name>.pdf |
Port 8000 already in use when starting serve.py |
Previous server still running | taskkill /F /PID <pid> (find via netstat -ano \| findstr :8000) |
| Dropdown override on upload doesn't work | Form field not submitted | Check browser console; ensure doc_type_hint option has a non-empty value attribute |
Known limitations
- One doc type per PDF — the pipeline assumes a PDF is a single logical document (possibly multi-page). True mixed-content PDFs (BL + invoice + packing list in one file) are not auto-segmented; split them manually first.
- Fixed row count in UI minus the add/delete buttons — you can add and delete rows but the server doesn't re-classify the doc if the row structure fundamentally changes.
- No user accounts or access control — anyone on LAN with the URL can read and edit. Fine for a trusted internal network; add auth for anything else.
- Validator rules are English-centric — the regex rules for things like "Tax Invoice" labels are English/CJK/Vietnamese only. Some tax ID format checks (SG GST, AU ABN, etc.) are country-specific.
- Extraction is CPU/GPU-bound — 2-5 minutes per doc on an RTX 4090 with
qwen3-vl:8b. Not suitable for high-volume automation without batching or a dedicated GPU server.
Contact points to hand over to the desktop team
When handing off to the team that'll integrate with the ops system:
- URL shape:
http://<this-pc>:8000/doc/<stem>— show this to clerks inside the desktop app (WebView2 or an external browser window). - Output file path:
C:\tmp\out\mapped\<stem>.<tag>.json— the desktop app reads this after the clerk clicks Save. - Tag values:
plvi(Vendor Invoice),sibl(Sea Import),aiaw(Air Import) — determines which screen to populate in the ops system. - Review signal: today, there's no explicit "reviewed" flag; the desktop app reads the file whenever it wants. If you need a "clerk finished reviewing" signal, add a button +
_meta.reviewed_atfield — see the backlog below.
Backlog / next steps
- "Mark as Reviewed" button that stamps
_meta.reviewed_atin the mapped JSON so the desktop app knows when to pull. - JSON API endpoints
GET /api/doc/{stem}(returns mapped JSON) andGET /api/status/{stem}(returns review state) for programmatic handoff. - True multi-document PDF segmentation — classify every page, group into sub-documents, emit one JSON per sub-doc. Option B scaffolding exists but was simplified for speed; re-enable with code from earlier commits if needed.
- Batch push to a real target system once it's known (web form via Playwright, or desktop app via
pywinauto). - Auth + audit log if the review UI grows beyond internal trust.