You need to agree to share your Hugging Face information to access this model

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

Log in or Sign Up to review the conditions and access this model content.

YAML Metadata Warning:empty or missing yaml metadata in repo card

Check out the documentation for more information.

PO Extractor

Offline purchase-order extraction API for Nexora UBL MT. It takes a list of PO file URLs (PDF, scanned PDF, photo/CamScanner image, XLSX, XLS, CSV), works out every purchase order inside each file, and returns clean JSON following PO Data Extraction Guideline.xlsx.

Supported clients:

Client name (send this in client_name) account_name in output Formats seen
ACI Logistics Ltd ACI Logistics Limited SAP PDF print (1 or many POs), SAP Excel export (many POs)
ALFAMART TRADING BANGLADESH LIMITED DC TONGI ATBL DC TONGI JasperReports PDF
AponTech Ltd APONTECH LIMITED Image-only PDF (screenshot)
CSD Exclusive CSD Exclusive Word-generated PDF, scanned PDF, CamScanner JPG
CSD General CSD Super Shop same 3S format as CSD Exclusive (scanned PDF)
A2Y Service Uposhohor Sylhet a2y service spreadsheet printed to PDF
AIR FORCE EXCLUSIVE SUPER SHOP AFESS "Purchase Order Details" Excel only
Akij Lifecare Ltd. AKIJ LIFECARE LTD Akij Pharmacy PDF (two layouts)
Direct Fresh Limited Direct Fresh Limited "Demand" Excel (no PO no / price)
Khulshi Mart Khulshi Mart AponTech web-app PDF, header drawn as images
Masala Bazaar Masala Bazaar web PO PDF with barcode
Shopping Bag Super Shop / THE BASKET / Utshab Super Market Shopping Bag / The Basket / Utshab Super Market Mediasoft " PURCHASE ORDER" PDF
Wholesale Club Ltd Wholesale Club Ltd Mediasoft "Purchase Order" PDF (two layouts)

Later phase (guideline rows in red; requests return UNSUPPORTED_CLIENT): A K SUPER SHOP CHATTOGRAM, AFESS Kurmitola PDF, MOSHAL BAZAR, Shihab Mart Ltd Keranigonj, Shihab Mart Ltd rangpur, Unique Mart Limited. Their documents are rejected by content too, when sent under a supported client's name. BIN is out of scope and not in the output.

Similar spellings are accepted (aci logistics limited, Apon Tech Limited, …).


Contents

  1. How it works
  2. Installation (conda) β€” or Docker
  3. Running
  4. API
  5. Output fields
  6. Field mapping per client
  7. Accuracy checks and needs_review
  8. Testing
  9. Configuration
  10. Project layout
  11. Adding a new client
  12. Troubleshooting

1. How it works

Backend ──POST /extract──▢ FastAPI ──▢ Redis queue ──▢ Worker (one file at a time)
        ◀── 202 {job_id} β”€β”€β”˜                              β”‚
Backend ──GET /jobs/{id}─▢ FastAPI ◀── job + results in Redis β—€β”˜

For every file in a job, the worker:

  1. Checks the client name from the request (unknown / not-yet-supported β†’ UNSUPPORTED_CLIENT).
  2. Downloads the URL and detects the real file type from the bytes (the URL needs no extension).
  3. Reads each page:
    • page has a usable text layer (system-generated or printed-to-PDF) β†’ the text is read directly, no OCR, exact values;
    • page has no text / garbled text (scan, CamScanner, screenshot PDF, photo) β†’ rendered at scan resolution and read with PaddleOCR PP-OCRv5 on the GPU, with automatic rotation and skew correction;
    • spreadsheets β†’ cells read directly. Decided page by page, so mixed PDFs work.
  4. Checks whose document it is (app/clients.py check_content; spreadsheets: check_spreadsheet). The document is always read with the rules of the client named in the request:
    • the requested client's own company text β†’ no remark;
    • a later-phase company's text (other_clients, e.g. a Shihab Mart PO sent as "Utshab Super Market") β†’ UNSUPPORTED_CLIENT, never processed;
    • another supported client's company text (e.g. a CSD General PO sent as "CSD Exclusive", a Khulshi Mart PO sent as "AponTech Ltd") β†’ read with the requested client's rules, flagged;
    • only the PO software's text (a scan with the letterhead cut off) β†’ a warning;
    • nothing identifying (PDF / image) β†’ flagged. If no PO is found the file returns NO_PO_FOUND.
  5. Parses into one or more POs (a new PO starts whenever the PO number changes, or when a new item table follows one that already ended). Only Shwapno (ACI) may send several POs in one file; any other client's file with 2+ POs returns every PO, each flagged.
  6. Scanned pages only β€” safety net:
    • math repair: a row whose qty Γ— price β‰  line total is corrected only when exactly one single-number fix makes it consistent and it resembles what OCR read;
    • vision model: rows that are still wrong, and rows OCR missed (gaps in SL numbers), are cropped at full resolution and transcribed by a local vision model (Ollama qwen2.5vl:7b); its numbers are used only if they are internally consistent. Descriptions always come from OCR when OCR has them.
  7. Merges duplicates (same PO number + same delivery address β†’ one PO; different address β†’ kept separate with a warning).
  8. Validates every PO (line math, item sum vs printed total, missing fields, SL gaps) and sets quality.needs_review.

Everything runs locally; no document ever leaves the machine.

1.1 Architecture diagram β€” the whole system in one picture

Read it top to bottom: a file goes in at the top, and by the bottom it has become a validated PO with a pass/fail flag. Rounded boxes are people/systems, rectangles are code steps, diamonds are decisions, and cylinders are where things wait or are configured.

flowchart TD
    Backend(["🏒 Backend system<br/>(the caller)"]) -->|"POST /extract<br/>{client_name, url} per file"| API["FastAPI app<br/>(app/main.py)"]
    API -->|"202 Accepted<br/>{job_id}"| Backend
    API -->|"job queued"| Redis[("Redis queue<br/>(app/jobs.py)")]
    Redis -->|"one file at a time"| Worker["Worker process<br/>(app/worker.py)"]
    Backend -.->|"GET /jobs/{id}<br/>poll until completed"| API
    API -.->|"job status + results"| Redis

    Worker --> P0["app/pipeline.py β€” process_file()"]

    P0 --> S1["β‘  Resolve client<br/>app/clients.py<br/>match client_name / aliases"]
    S1 -->|"unknown client"| E1["❌ UNSUPPORTED_CLIENT"]
    S1 -->|"known"| S2["β‘‘ Download file<br/>app/fetch.py"]
    S2 -->|"http error / timeout / too big"| E2["❌ DOWNLOAD_FAILED / FILE_TOO_LARGE"]
    S2 --> S3["β‘’ Detect real file type from BYTES<br/>app/filetypes.py<br/>(no extension needed)"]
    S3 -->|"not PDF/image/XLSX/XLS/CSV"| E3["❌ UNSUPPORTED_FILE_TYPE"]

    S3 -->|"PDF / image"| R1["Read pages<br/>app/documents/pdf.py, image.py"]
    S3 -->|"XLSX / XLS / CSV"| R2["Read cells directly<br/>app/documents/spreadsheet.py<br/>(exact values, no OCR)"]

    R1 --> D1{"Page has a usable<br/>text layer?"}
    D1 -->|"yes β€” born-digital or printed-to-PDF"| T1["Read text directly<br/>exact values, NO OCR"]
    D1 -->|"no β€” scan / photo / CamScanner / screenshot"| O1["πŸ€– PaddleOCR PP-OCRv5 (GPU)<br/>app/documents/ocr.py<br/>+ auto rotation & skew correction"]
    T1 --> PG["Page list<br/>(decided page-by-page β†’ mixed PDFs OK)"]
    O1 --> PG

    PG --> V1["β‘£ Whose document is it?<br/>app/clients.py check_content()<br/>signatures in config/clients.yaml<br/>(another client's β†’ its parser, flagged)"]
    R2 --> V1

    V1 --> CFG[("config/clients.yaml<br/>client_name, aliases, signatures,<br/>parser name, required_fields,<br/>po_amount rule, mrp_default")]
    CFG --> REG["app/registry.py<br/>parser name β†’ ParserSpec<br/>(one parser per PO format)"]
    REG --> S5["β‘€ Parse into RawPO(s)<br/>app/parsers/*.py<br/>values found by LABEL + column position,<br/>never by what a value 'looks like';<br/>a new PO starts when PO No. changes"]
    V1 --> S5

    S5 --> D2{"Any page read<br/>by OCR?"}
    D2 -->|"no β€” fully digital"| S7
    D2 -->|"yes"| S6["β‘₯ Scanned-page safety net<br/>app/reconcile.py"]

    S6 --> M1["Math repair (deterministic)<br/>fix a row ONLY if exactly one<br/>single-number correction makes<br/>qty Γ— price = line total"]
    M1 --> M2{"Row still wrong,<br/>or row missing<br/>(gap in SL numbers)?"}
    M2 -->|"no"| S7
    M2 -->|"yes"| VLM["πŸ€– Local vision model<br/>Ollama qwen2.5vl:7b<br/>app/vlm.py<br/>crop the row at full resolution β†’ transcribe"]
    VLM --> M3{"Model's own numbers<br/>internally consistent?<br/>(qty Γ— price = total)"}
    M3 -->|"yes β†’ use the number<br/>(description still from OCR if OCR has it)"| S7
    M3 -->|"no β†’ leave it, flag for review"| S7

    S7["⑦ Merge duplicates<br/>app/assemble.py merge_duplicates()<br/>same PO No. + same delivery address β†’ merge<br/>same PO No. + different address β†’ keep separate + warn"]
    S7 --> S8["β‘§ Validate + build quality block<br/>app/assemble.py to_purchase_order()"]

    S8 --> Q1["Check every line:<br/>qty Γ— price = line total?"]
    S8 --> Q2["Check item sum vs<br/>printed control total"]
    S8 --> Q3["Check required fields present<br/>(per config/clients.yaml)"]
    S8 --> Q4["Check SL numbers:<br/>no gaps, no duplicates, start at 1"]
    Q1 & Q2 & Q3 & Q4 --> D3{"Anything wrong<br/>or missing?"}
    D3 -->|"yes"| NR["needs_review = true<br/>+ human-readable warnings<br/>(PO is still returned β€” never dropped)"]
    D3 -->|"no"| OK["needs_review = false<br/>(safe to auto-accept)"]

    NR --> OUT["PurchaseOrder JSON<br/>account_name, po_no, po_date, po_amount,<br/>items[], damage_items, quality{}"]
    OK --> OUT
    OUT --> Redis
    Redis --> API
    API -->|"GET /jobs/{id} β†’ completed<br/>{purchase_orders, quality, warnings}"| Backend
    Backend --> Route{"needs_review?"}
    Route -->|"false"| Auto["βœ… Auto-accept into ERP/system"]
    Route -->|"true"| Human["πŸ§‘ Route to a person<br/>with the warnings shown"]

    E1 & E2 & E3 & E4 --> OUT2["error_code + error_message<br/>for THIS file only β€”<br/>other files in the job continue"]
    OUT2 --> Redis

What each shape means, in plain terms:

  • Backend β€” your system, e.g. Nexora UBL MT: sends file links in, polls for results.
  • Rounded boxes with πŸ€– β€” the two AI/ML models in the system: PaddleOCR PP-OCRv5 (reads text out of a scanned image, like a very accurate photo-to-text tool) and Ollama qwen2.5vl:7b (a vision-language model used only as a second opinion on numbers OCR could not read reliably, and only on scanned documents).
  • Diamonds β€” a yes/no decision the code makes automatically, on every page and every row, without a human involved.
  • Cylinders β€” a place where data sits: the Redis job queue (files waiting their turn) and config/clients.yaml (the rules for each company, editable without touching code).
  • Nothing is ever silently dropped β€” a bad row is flagged (needs_review: true) with a plain-English reason, not discarded; a bad file gets an error code but the rest of the job keeps going.

Every client sends the same format every time, but every value changes (PO numbers, dates, outlet codes, article codes, barcodes, quantities, prices, item counts, number of POs per file). The parsers are written so that no value is ever recognised by what it looks like:

  • Header values are read as "whatever is printed after the label" (PO. No., Order Date:, Please send to : …) up to the next label on that line. Any PO numbering scheme, date style or language (09 Maret 2026), BIN or outlet code format is accepted and returned exactly as printed.
  • Item rows are found by the table structure (a line with numbers in the numeric columns, the SL mark, the AxB=C quantity), and each value is taken from its column position, which is derived from the printed table header on every page β€” not from fixed coordinates or code lengths.
  • Wrapped text (descriptions over several lines, across page breaks) follows the row layout.
  • Several POs in one file are split whenever a PO header appears β€” at the top of a page or in the middle of one.
  • Client names, aliases, identifying text and the guideline rules live in config/clients.yaml (section 9.1), not in code.

This is verified by the variation tests (section 8): the real sample pages are rewritten with every value changed at random, numbers made longer and rows removed, and the extraction must return exactly the changed values.


2. Installation (conda)

Tested on Ubuntu 26.04, NVIDIA RTX 3060 12 GB, driver 595 (CUDA 13.2), conda 26.

2.1 Create the environment (Python, Redis, Ollama)

conda create -n po-extractor -c conda-forge --override-channels python=3.12 redis-server "ollama=0.9.6=cuda_129*"
conda activate po-extractor

Python 3.12 is required: PaddlePaddle has no wheels for 3.13/3.14 yet. CPU-only machine: use ollama=0.9.6=cpu* instead (the vision model will be very slow; set VLM_ENABLED=false if needed).

2.2 Install PaddlePaddle (GPU)

PaddlePaddle GPU is not on PyPI in a usable version; install it from Paddle's own index before requirements.txt:

pip install paddlepaddle-gpu==3.3.1 -i https://www.paddlepaddle.org.cn/packages/stable/cu129/

(~3.5 GB with its CUDA libraries.) CPU-only machine: pip install paddlepaddle==3.3.1 -i https://www.paddlepaddle.org.cn/packages/stable/cpu/ and set OCR_USE_GPU=false.

Check it:

python -c "import paddle; paddle.utils.run_check()"

2.3 Install the Python packages

cd po-extractor
pip install -r requirements.txt

OpenCV: PaddleX installs opencv-contrib-python. Never install opencv-python or opencv-python-headless into the same env β€” two OpenCV packages overwrite each other's files. If python -c "import cv2; print(cv2.__version__)" fails, repair it with pip install --force-reinstall --no-deps opencv-contrib-python==4.10.0.84.

2.4 Download the models (one time, needs internet)

# Vision model (~6 GB). Needs a running Ollama server: either the system service
# (sudo systemctl start ollama) or `ollama serve` in another terminal.
ollama pull qwen2.5vl:7b

# PaddleOCR models (~200 MB) download automatically on first use; trigger it now:
python -c "from app.documents import ocr; ocr.warm_up()"

After this the system works fully offline.

Ollama keeps models per server: the system service (user ollama) stores them under /usr/share/ollama/.ollama, while ollama serve run as yourself stores them in ~/.ollama. Pull the model into the server you will actually use.

2.5 Configuration file (optional)

cp .env.example .env    # defaults are fine for a single machine

Docker

Alternative to sections 2–3: everything (Redis, Ollama with qwen2.5vl:7b, API, worker, PaddleOCR models) runs in containers. Only Docker is needed; a GPU host additionally needs the NVIDIA driver (>= 575) and the NVIDIA Container Toolkit.

./scripts/docker-setup.sh       # once per machine: creates .env, picks GPU or CPU
docker compose up -d --build    # start
docker compose logs -f          # logs (add a service: worker, api, ollama)
docker compose down             # stop  (-v also drops the vision model and uploads)

docker-setup.sh writes COMPOSE_FILE into .env, which docker compose reads by itself β€” that is why the plain commands above need no -f flags. Re-run it with --cpu / --gpu to switch, or edit that line.

File Purpose
Dockerfile one image for API + worker. COMPUTE=cpu|gpu picks paddlepaddle / paddlepaddle-gpu (CUDA 12.9); PaddleOCR models are baked in; INSTALL_TORCH=true adds PyTorch (not used by the app)
docker-compose.yml CPU stack: redis, ollama, ollama-pull (one-shot model download into the ollama_models volume), api on port 8000, worker
docker-compose.gpu.yml override: GPU image, OCR_USE_GPU=true, GPU reserved for api, worker and ollama
docker-compose.files.yml override: serve FILES_DIR_HOST read-only at /files (like run.py --files-dir); set FILES_DIR_HOST in .env before re-running docker-setup.sh
scripts/docker-smoke-test.sh, scripts/lint-docker.sh build + health + one extraction; hadolint + compose validation (also run in .github/workflows/docker-ci.yml)

Notes:

  • The first up pulls the vision model (~6 GB); later starts reuse the volume. VLM_ENABLED=false skips it.
  • Inside Docker the worker downloads files by URL from its own network: localhost means the container. Files uploaded with POST /upload get http://api:8000/uploads/... URLs, which the worker can reach.
  • On CPU, scanned files are slow (OCR and especially the vision model); digital PDFs and spreadsheets are unaffected.
  • Scaling means more worker containers (--scale worker=N), each loading its own OCR models; on one GPU keep N=1.

3. Running

3.1 One command (recommended)

conda activate po-extractor
cd po-extractor
python run.py

Swagger UI: http://localhost:8000/docs

run.py starts and owns everything:

Part What happens
Redis a private, in-memory Redis on a free localhost port β€” never touches another Redis you may run
Ollama reuses an Ollama server already running at OLLAMA_URL; otherwise starts ollama serve itself
Worker python -m app.worker (OCR, extraction, vision model)
API uvicorn in the foreground
Uploads a temporary folder under /tmp/po-extractor-* for files sent to /upload

Stopping (Ctrl+C, kill <pid>, closing the terminal) stops the worker, Redis and an Ollama started by run.py, and deletes the temporary folder with all uploads and jobs. After kill -9 the kernel still stops the child processes; the leftover temp folder is removed the next time run.py starts. An Ollama system service (systemctl) is not owned by run.py and keeps running; its loaded model is freed automatically after 60 s of no use. To have run.py own Ollama too: sudo systemctl disable --now ollama (then pull the model again as yourself, see 2.4).

Options:

python run.py --port 8080
python run.py --no-vlm                                  # skip the vision model
python run.py --files-dir "/home/rakib/Desktop/Nexora UBL MT"   # also serve a folder at /files/...

3.2 Testing with file upload (Swagger)

Upload a file β†’ get its URL β†’ extract it β€” all in http://localhost:8000/docs:

  1. POST /upload β†’ Try it out β†’ Choose File (any PO on your computer) β†’ Execute.
    {"file_name": "Aus Bangla.pdf", "size_bytes": 5007290,
     "url": "http://127.0.0.1:8000/uploads/8c795be1292047da9b984eee0784e9bd/Aus%20Bangla.pdf"}
    
  2. POST /extract β†’ Try it out β†’ paste the url:
    {"files": [{"ref_id": "test-1", "client_name": "AponTech Ltd",
                "url": "http://127.0.0.1:8000/uploads/8c795be1292047da9b984eee0784e9bd/Aus%20Bangla.pdf"}]}
    
    β†’ {"job_id": "9a6f...", "status": "queued", "file_count": 1}
  3. GET /jobs/{job_id} β†’ repeat until "status": "completed".

Repeat step 1 for more files and put several entries (even for different clients) in one /extract request.

Shortcut: POST /upload/extract takes client_name plus one or more files and queues the job directly (steps 1 + 2 in one call).

Same with curl:

curl -s -X POST http://localhost:8000/upload -F "file=@/path/to/PO.pdf"
curl -s -X POST http://localhost:8000/extract -H "Content-Type: application/json" \
     -d '{"files":[{"client_name":"AponTech Ltd","url":"<url from upload>"}]}'
curl -s http://localhost:8000/jobs/<job_id> | python -m json.tool

Uploaded files disappear when the server stops. The URLs use PUBLIC_BASE_URL (default http://127.0.0.1:<port>), which is also where the worker downloads them from.

3.3 Testing a whole folder of POs

python run.py --files-dir "/home/rakib/Desktop/Nexora UBL MT"

then, in a second terminal:

python scripts/make_request.py "/home/rakib/Desktop/Nexora UBL MT" \
    --base-url http://localhost:8000/files --only-supported --submit http://localhost:8000

make_request.py finds every PO file under the folder, builds its URL, guesses the client from the folder name (3. ACI Logistics Limited β†’ ACI Logistics Ltd), and checks the URLs are reachable before submitting. Without --submit it prints the request body (paste it into POST /extract). Use --client "AponTech Ltd" to force one client for every file.

3.4 Extract a local file without the API (debugging)

python scripts/extract_local.py "AponTech Ltd" "../OLD/8. AponTech Ltd/Agami aparels.pdf" --summary
python scripts/extract_local.py "ACI Logistics Ltd" "../Final/PO SAMPLE/3. ACI Logistics Limited/09.08.2026.pdf" --out aci.json

3.5 Running the parts separately (production / several machines)

run.py is convenient for one machine. For a long-running deployment you can run the parts under systemd or Docker instead:

redis-server --port 6379 --bind 127.0.0.1 --save "" --appendonly no
ollama serve
python -m app.worker                                   # REDIS_URL=redis://localhost:6379/0
uvicorn app.main:app --host 0.0.0.0 --port 8000

Redis commands that help when running it yourself:

Task Command
alive? (PONG) redis-cli -p 6379 ping
jobs stored redis-cli -p 6379 --scan --pattern 'poex:job:*' | wc -l
jobs waiting redis-cli -p 6379 llen poex:queue
clear all jobs redis-cli -p 6379 --scan --pattern 'poex:*' | xargs -r redis-cli -p 6379 del
stop redis-cli -p 6379 shutdown nosave

Redis startup warnings "Memory overcommit must be enabled" (only matters for saving to disk, disabled here) and "does not require authentication" (fine on localhost) can be ignored.


4. API

POST /extract

{
  "files": [
    {"ref_id": "66f1a2b3c4", "client_name": "ACI Logistics Ltd", "url": "https://files.example.com/po/09.08.2026.pdf"},
    {"ref_id": "66f1a2b3c5", "client_name": "CSD Exclusive",     "url": "https://files.example.com/po/66f1a2b3c5"}
  ]
}
  • ref_id β€” optional, your own id; echoed back per file.
  • client_name β€” required.
  • url β€” required, plain http(s).

Response 202 Accepted:

{"job_id": "b1946ac92492d2347c6235b4d2611184", "status": "queued", "file_count": 2}

GET /jobs/{job_id}

status: queued β†’ processing β†’ completed (or failed if the worker crashed mid-job). While processing, files_done counts finished files and results fills up in input order. 404 = unknown job (or the server was restarted β€” jobs are not persisted).

{
  "job_id": "b1946ac92492d2347c6235b4d2611184",
  "status": "completed",
  "created_at": "2026-09-13T10:15:02.114Z",
  "started_at": "2026-09-13T10:15:02.130Z",
  "finished_at": "2026-09-13T10:15:05.902Z",
  "file_count": 2,
  "files_done": 2,
  "results": [
    {
      "ref_id": "66f1a2b3c4",
      "client_name": "ACI Logistics Ltd",
      "url": "https://files.example.com/po/09.08.2026.pdf",
      "status": "success",
      "file_type": "pdf",
      "po_count": 71,
      "purchase_orders": [
        {
          "account_name": "ACI Logistics Limited",
          "delivery_address_code": "E003",
          "delivery_address": "Phultala Bazar, Khulna, Jashore Road, Phultala Bazar, Phultala, 9210 Khulna",
          "po_no": "4007075571",
          "po_date": "09.08.2026",
          "po_amount": 60879.39,
          "items": [
            {"sl": 1, "client_item_code": "2303465", "item_description": "Junior Horlicks Stage2 450Β±50g (Jar)",
             "trade_price": 514.02, "mrp": null, "qty_pcs": 3.0}
          ],
          "damage_items": null,
          "quality": {
            "extraction_method": "text_layer",
            "source_pages": [1, 2, 3, 4],
            "items_total_label": "Net PO. Value",
            "items_total_printed": 60879.39,
            "items_total_computed": 60879.39,
            "items_total_matches": true,
            "line_math_errors": [],
            "missing_fields": [],
            "warnings": [],
            "needs_review": false
          }
        }
      ],
      "error_code": null,
      "error_message": null,
      "processing_ms": 1420
    },
    {
      "ref_id": "66f1a2b3c5",
      "client_name": "AIR FORCE EXCLUSIVE SUPER SHOP",
      "url": "https://files.example.com/po/66f1a2b3c5",
      "status": "error",
      "file_type": "pdf",
      "po_count": 0,
      "purchase_orders": [],
      "error_code": "UNSUPPORTED_FILE_TYPE",
      "error_message": "PDF / image POs are not supported for 'AIR FORCE EXCLUSIVE SUPER SHOP' (Excel expected).",
      "processing_ms": 40
    }
  ]
}

Error codes (per file)

Code Meaning
UNSUPPORTED_CLIENT client_name unknown, or a client not supported in this phase, or the document is a later-phase company's PO sent under a supported name
INVALID_URL not an http(s) URL
DOWNLOAD_FAILED HTTP error, timeout, empty file
FILE_TOO_LARGE over MAX_FILE_MB
UNSUPPORTED_FILE_TYPE not PDF / image / XLSX / XLS / CSV, or a file type the client does not send (guideline "Acceptable Format")
CORRUPT_FILE file cannot be opened
CLIENT_MISMATCH not returned any more: a document of another supported company is read with the requested client's rules and flagged
NO_PO_FOUND no purchase order recognised in the file
INTERNAL_ERROR unexpected bug (logged by the worker)

Other endpoints

  • POST /upload β€” multipart file; stores one file and returns its URL for /extract (testing).
  • POST /upload/extract β€” multipart client_name + files; uploads and queues a job in one step (testing).
  • GET /uploads/... β€” uploaded files; GET /files/... β€” the folder given to run.py --files-dir.
  • GET /clients β€” supported client names.
  • GET /health β€” API + Redis status, queued job count, vision model availability.

5. Output fields

Field Type Notes
account_name string fixed value from the guideline
delivery_address_code string | null
delivery_address string | null multi-line addresses joined with ", "
po_no string
po_date string exactly as printed (29.08.2026, 09 Maret 2026, 2026-06-02, 06-Aug-2026 06:38 PM)
po_amount float | null
items[].sl integer row number (running 1..n for ACI Excel)
items[].client_item_code string | null strings keep leading zeros
items[].item_description string clients with check_descriptions (AponTech, A2Y, Masala Bazaar): checked against the product master, see 7.1
items[].trade_price float
items[].mrp float | null AponTech: printed MRP (0)
items[].qty_pcs float
damage_items list | null AponTech only: sl, item_name, item_code, damage_qty, rate, value
quality object see section 7

Fields the guideline marks N/A are always present with null.


6. Field mapping per client

ACI Logistics Ltd β€” PDF

Output Source
delivery_address_code code line under Delivery Address: (D007, E003, F352…)
delivery_address following lines up to the table; the Company placeholder line is skipped
po_no / po_date PO. No. : / PO. Date :
po_amount Net PO. Value (if the PDF leaves it blank: sum of Total Amount, with a warning)
items Sl. No., Article, Article Description (wrapped lines incl. across pages), Net Price (Price + VAT), PO Qty

The text layer's broken ‘Ó is restored to Β±.

ACI Logistics Ltd β€” Excel (SAP export)

Output Source
po_no Pur. Doc. (rows grouped per PO)
po_date Doc. Date
delivery_address_code Plnt
delivery_address null (not in the export)
po_amount sum of Net Value
items running SL 1..n, Material, Short Text, Net Price, Quantity

ALFAMART TRADING BANGLADESH LIMITED DC TONGI

Output Source
delivery_address Please send to : β†’ ATBL TONGI [DCTGI] (code null)
po_no / po_date Number F P P / PO Date
po_amount TOTAL SUPPLIER INVOICE
items #n; code = barcode under the description; description = first line text; PRICE; Q_Pcs

Check total: sum of TOTAL BASE vs TOTAL PURCHASE PRICE.

AponTech Ltd

Output Source
delivery_address Shipping Address: text (code null)
po_no / po_date Purchase Order No: / Order Date:
po_amount Total row under the item table
items SL, ITEM, RATE, MRP (0), QTY.; no item code
damage_items the Damage Items table

CSD Exclusive / CSD General

Output Source
delivery_address Delivery Location: full text, e.g. CS15-CS15-Rajshahi Warehouse
po_no / po_date PO No. / PO Date: including the time
po_amount last value on the Total: line
items SL.; Code-Product split at the first -; Cost/Unit; PO Qty = number after =

Grid-table clients

a2y, Akij, Masala Bazaar, Shopping Bag / The Basket / Utshab and Wholesale Club share app/parsers/table.py: each format module lists its printed column labels, and rows are read by column position under those labels. The label β†’ field mapping of each client is in the docstring at the top of its parser module (app/parsers/<format>.py). Where the guideline gives the client's own name as the delivery address, delivery_address_default in config/clients.yaml supplies it. Khulshi Mart uses the AponTech parser (apontech_ship_to) with its BARCODE column and Ship To:.


7. Accuracy checks and needs_review

Every PO carries a quality block:

Field Meaning
extraction_method text_layer, spreadsheet, ocr, ocr+vlm, text_layer+ocr
source_pages pages the PO came from
items_total_label / items_total_printed / items_total_computed / items_total_matches printed control total vs. the sum of the item line totals
line_math_errors SL numbers where qty Γ— price β‰  line total
missing_fields required fields that could not be read (po_date, items[4].trade_price, …)
warnings human-readable notes: corrections made, merged duplicates, unprinted totals, gaps in SL numbers, …
needs_review true when anything above is wrong or missing

Recommendation for the backend: auto-accept POs with needs_review: false; route the rest to a person with the warnings shown. POs are never dropped for being imperfect β€” whatever was read is returned.

Digital PDFs and spreadsheets are exact. Scanned documents are as good as the scan: faint or handwritten-over digits can still fail; that is exactly what needs_review is for.

7.1 Item descriptions checked against the product master

Clients whose POs carry no item code (check_descriptions: true: AponTech, A2Y, Masala Bazaar) are recognised by description only, so each description is checked against the backend's product master (PRODUCT_MASTER_URL, read-only). The API process (for POST /extract/sync) and the worker (for POST /extract, POST /upload/extract) each pull it at start-up and log Product master pulled from MongoDB successfully for the … : 595 rows, 255 products; it is reloaded in the background every PRODUCT_MASTER_REFRESH_MIN (60) minutes, and GET /health shows product_master. The client's wording is kept; only what differs from the master in form is changed:

Step Example
every line: upper case, single spaces, sizes in one form Shampoo 340 ml β†’ SHAMPOO 340ML, 90 Gm β†’ 90G, 1 Ltr β†’ 1L, OCR 170 MI β†’ 170ML, White135g β†’ WHITE 135G, 475Β±25ml β†’ 475Β±25ML
the product is identified by brand + size, then by its variant words BABY TOOTH PASTE PEPSODENT KIDS ORANGE 45 GM β†’ PEPSODENT TOOTHPASTE KIDS ORANGE 45G
identified β†’ brand, size and misspelt words take that product's spelling Pepsodnet Germicheck 200 Gm β†’ PEPSODENT GERMICHECK 200G; CloseUp β†’ CLOSE-UP; Fair & Lovely β†’ GLOW & LOVELY; 1 KG β†’ 1000G when the master writes 1000G
not identified β†’ line left as in step 1, PO needs_review BEAUTY SOAP-DOVE-135 GM (pink or white?), SUNSILK HAIR FALL SOLUTION 340 ML (not in the master)

A product is identified only when

  1. exactly one product of that brand and size explains every variant word of the line: a word the brand uses for products of the same productFormat (PINK, GUM, HAIR FALL SOLUTION = HFS). Master abbreviations in both descriptions count (SKN, CLNSNG, JNR, LQ HW = liquid hand wash); the client's own words (BEAUTY, BUY 3 GET 1 FREE, origin marks BD, IN) are ignored;
  2. the line uses no word that names another brand's product of that format (INTENSIVE, HANDSOME);
  3. the line names the product, not just its kind: a word that is not a format word (TOTAL, WHITE, STD JAR β€” not SHAMPOO, BODY LOTION) and that the other products of this brand and size do not all have, or the product explains the whole line.

On the sample folder (AponTech, A2Y, Masala Bazaar: 1,012 lines) 402 lines are identified, every one checked by hand; the rest are flagged, mostly because the master has no product of that brand and size (Lux 90G, Sunsilk Hair Fall, Fair & Lovely men's lines…) or the line does not say the variant. Old brand names are mapped in config/product_master.yaml (brand_aliases). A word is respelt only when the master never uses it, so real words are not rewritten. Warnings list the corrected SL numbers and, with the reason, the unidentified ones. If the product API cannot be reached the descriptions are returned as printed with a warning (no needs_review).


8. Testing

pytest -q                          # all 108 tests (~75 s with GPU OCR)
pytest -q tests/test_units.py      # fast unit tests only
pytest -q tests/test_variations.py # value-independence tests
Test file What it proves
test_units.py client-name matching, file-type detection, duplicate merging, math repair never guesses
test_digital_samples.py exact field values on the real ACI (PDF, 71-PO PDF, Excel), Alfamart and CSD PDFs
test_scanned_samples.py the real AponTech / CSD scans and photo, incl. rejecting a CSD General scan
test_variations.py value independence, on every real sample (digital and scanned):

test_variations.py builds new documents from the real pages while keeping the format:

  • every value changed (5 random seeds): every digit and letter of every value β€” PO numbers, dates, BINs, outlet codes, addresses, article codes / barcodes, descriptions, quantities, prices, totals, damage items β€” is substituted; printed labels and SL numbers stay. The parsers must return exactly the substituted values.
  • bigger numbers: every number in the item tables gets an extra digit (wider, as a right-aligned column prints it).
  • fewer rows: random item rows are deleted; the remaining rows must be read unchanged.
  • spreadsheet values changed: the same for the ACI Excel export.

These tests already caught and fixed real issues, e.g. an address word starting with "Dlv" or "Pur" being mistaken for a header label, and a wrapped description line interleaving with the next row on a curved photo.

Results on the provided samples (RTX 3060, one API job, 12 files / 108 POs, 23 s total)

File Type POs Items Time needs_review
ACI 09.08.2026.pdf (296 pages) text layer 71 3402 1.2 s 0
ACI 200055 ARC Distribution (8).pdf text layer 1 72 0.03 s 0
ACI ACI Category P.O. 01-SEP-2026.xlsx spreadsheet 27 883 0.06 s 0
Alfamart AZ01POC26000225.pdf text layer 1 15 0.01 s 0
CSD Exclusive ARC DISTRIBUTION UNILEVER(1).pdf text layer 1 35 0.2 s 0
AponTech Aus Bangla.pdf (image PDF) OCR 1 38 (+11 damage) 5.4 s 0
AponTech Agami aparels.pdf (5 pages) OCR 1 81 10.2 s 0
CSD Exclusive 202608101541.pdf (scan) OCR 1 65 3.9 s 1 β€” row 35 is cut off the scan
CSD Exclusive CamScanner ….jpg OCR 1 35 1.8 s 0 (page 1 of 3 only: PO amount computed, with a warning)

For every text-layer / spreadsheet PO, each line satisfies qty Γ— price = line total and the items add up to the printed total. The CSD General scan sent as "CSD Exclusive" is read with CSD General's rules and flagged.

Known limitations

  • Marks written on the paper become part of the text when they touch it. The sample scans have orange annotation numbers; one fused with a PO number (PO-APON-JUN26-83 + 3. β†’ …-833). Clean production documents do not have these.
  • Faint or smudged numbers that neither OCR nor the vision model can read are left unverified; the PO is flagged instead of guessed.
  • Handwriting is not read (handwritten POs are out of scope for this phase).
  • Rounded documents: when a digital form prints whole taka (Akij) or one decimal (Masala), the line check allows exactly that rounding; scanned numbers get no such allowance.
  • Scanned ACI and Alfamart POs use the same parsers as their digital versions but no scanned sample existed to tune against β€” watch needs_review on the first real ones.
  • A changed format (a label renamed, a column added or moved) is not a value change: the client's parser in app/parsers/ has to be updated. The printed labels each parser relies on are listed as constants at the top of its file.

The sample-based tests read the real PO files from ../Final/PO SAMPLE and ../OLD (override with PO_SAMPLES_DIR / PO_OLD_SAMPLES_DIR); missing files are skipped. OCR tests are skipped when PaddleOCR is not installed.


9. Configuration

9.1 Clients β€” config/clients.yaml

Everything client-specific that is not a format rule is configuration:

clients:
  - key: csd_exclusive
    client_name: CSD Exclusive              # guideline "Clint Name"
    account_name: CSD Exclusive             # guideline "Account Name", written into every PO
    parser: csd_exclusive                   # format rules (PARSERS in app/registry.py)
    aliases: [CSD Exclusive, CSD Exclusive Rajshahi]      # accepted spellings of client_name
    signatures: [CSD EXCLUSIVE, EXCLUSIVE WAREHOUSE]      # text proving a document is this client's
    weak_signatures: [PURCHASE ORDER-DRAFT, 3s-bd.com]    # format only (letterhead cut) -> read with a warning
    required_fields: [delivery_address, po_no, po_date, po_amount]
    item_code_required: true
    po_amount_is_items_total: true          # PO amount = sum of line totals (computed if not printed)
    mrp_default: null                       # AponTech: 0.0
    has_damage_items: false
    # also: multiple_pos_per_file (Shwapno only), trade_price_required, delivery_address_default,
    #       check_descriptions (no item code: match descriptions to the product master, see 7.1)
other_clients:                              # later phase; UNSUPPORTED_CLIENT by name and by document text (company names, never places)
  Unique Mart Limited: [UNIQUE MART]

Restart run.py after editing. Another file can be used with CLIENTS_CONFIG=/path/clients.yaml. A second client that sends one of the existing formats (e.g. another CSD branch on the same 3S software) only needs a new entry here with parser: csd_exclusive.

9.2 Environment variables

Environment variables (or .env):

Variable Default
REDIS_URL redis://localhost:6379/0
REDIS_PREFIX poex key prefix
RESET_JOBS_ON_STARTUP true API wipes all jobs on start
UPLOAD_DIR /tmp/po-extractor-uploads set by run.py to its temporary folder
PUBLIC_BASE_URL http://127.0.0.1:8000 base of upload URLs (set by run.py from --port)
FILES_DIR – folder served at /files (run.py --files-dir)
DOWNLOAD_TIMEOUT_S 120
MAX_FILE_MB 100
OCR_USE_GPU true
OCR_RENDER_DPI 300 used when a PDF page is not a single embedded scan
OCR_DET_MAX_SIDE 2560 image size for text detection; 4000 needs more than 12 GB VRAM
VLM_ENABLED true second reader for scanned rows
OLLAMA_URL http://localhost:11434
OLLAMA_MODEL qwen2.5vl:7b
OLLAMA_TIMEOUT_S 600
PRODUCT_MASTER_URL https://bridge.hedigital.net/api/v1/products backend product API (GET, read-only) for 7.1; empty = off
PRODUCT_MASTER_REFRESH_MIN 60 how often the cached product list is reloaded (in the background)
PRODUCT_MASTER_TIMEOUT_S 30 per page request
CLIENTS_CONFIG config/clients.yaml client configuration file
AUDIT_LOG_ENABLED true write requests.log / responses.log (see 9.4)
AUDIT_LOG_DIR logs folder for those two files (Docker: /var/lib/po-extractor/logs)
AUDIT_LOG_MAX_MB 50 rotate each file at this size
AUDIT_LOG_BACKUPS 10 how many rotated files to keep
AUDIT_LOG_MAX_REQUEST_MB 2 larger request bodies are logged by size only (uploads)

9.3 Console log

The API, the worker, run.py and every module they import share one format (app/logging_setup.py):

15:40:15.991  INFO   uvicorn     Started server process [350689]
15:40:15.993  INFO   api         Job store reset on startup (0 keys removed)
15:40:22.593  INFO   access      127.0.0.1 - "POST /extract HTTP/1.1" 202
15:40:23.114  INFO   worker      Job 9569ec86: 1 file(s)
15:40:41.802  WARN   ocr         OCR warm-up failed (scanned files will fail until fixed)

Time, level and source sit in fixed columns, so messages line up and a traceback stays indented under its own message. Levels are coloured on a terminal and plain everywhere else β€” a redirected file, a pipe, docker compose logs β€” and NO_COLOR=1 turns colour off anywhere (FORCE_COLOR=1 forces it on).

9.4 Request / response log

Every API call is written to two files of its own (app/audit.py), separate from the console log:

logs/requests.log     one record per request  β€” the full input body
logs/responses.log    one record per response β€” the full response body, never truncated

Each record is a header line you can scan and an indented JSON object you can read, with a blank line between records:

─── REQUEST Β· POST /extract Β· 05:37:18.923 UTC Β· 9569ec86 ───
{
  "request_id": "9569ec865f0540f0b114deb9e5dcee6a",
  "ts": "2026-09-20T05:37:18.923805+00:00",
  "method": "POST",
  "path": "/extract",
  "query": null,
  "client": "10.0.0.7",
  "content_type": "application/json",
  "body": {
    "files": [
      {
        "ref_id": "r1",
        "client_name": "ACI Logistics Ltd",
        "url": "https://…/po.pdf"
      }
    ]
  }
}

─── RESPONSE 202 Β· POST /extract Β· 05:37:18.925 UTC Β·      3 ms Β· 9569ec86 ───
{
  "request_id": "9569ec865f0540f0b114deb9e5dcee6a",
  "status": 202,
  "duration_ms": 3,
  "size_bytes": 95,
  "body": {
    "job_id": "…",
    "status": "queued",
    "file_count": 1
  }
}

The request_id in the header pairs the two files and is also returned to the caller as the X-Request-ID response header.

Queued jobs are answered by GET /jobs/{job_id}, so the extracted POs are in the response record of that call (/extract/sync has them in its own). Everything the API ever returned is therefore in responses.log, in full β€” a single completed job can be several MB, hence the generous rotation defaults.

Not logged: the static mounts (/uploads, /files), /docs, /openapi.json, and file bytes β€” a multipart upload is recorded as <multipart/form-data, 1234567 bytes not logged>; its JSON response (file name, size, URL) is logged as usual.

Find one call:

grep "POST /extract" logs/requests.log | tail -5        # the headers alone: what came in, when
grep -A 30 9569ec86 logs/responses.log                  # the start of what was sent back for it

Back to objects (the headers are skipped):

python -c "from app import audit; print(len(audit.read('logs/responses.log')))"

Turn it off with AUDIT_LOG_ENABLED=false. In Docker the files live in the audit_logs volume: docker compose exec api tail -f /var/lib/po-extractor/logs/responses.log.


10. Project layout

po-extractor/
β”œβ”€β”€ app/
β”‚   β”œβ”€β”€ main.py              FastAPI app (routes)
β”‚   β”œβ”€β”€ worker.py            job worker (python -m app.worker)
β”‚   β”œβ”€β”€ jobs.py              Redis job store + queue
β”‚   β”œβ”€β”€ pipeline.py          one file: download β†’ type β†’ pages β†’ verify β†’ parse β†’ validate
β”‚   β”œβ”€β”€ fetch.py             URL download
β”‚   β”œβ”€β”€ filetypes.py         type detection from file bytes
β”‚   β”œβ”€β”€ clients.py           loads config/clients.yaml; name matching, content verification
β”‚   β”œβ”€β”€ registry.py          parser name -> parser functions + guideline rules
β”‚   β”œβ”€β”€ assemble.py          merge duplicates, validation, quality block
β”‚   β”œβ”€β”€ reconcile.py         math repair + vision-model re-read for scanned rows
β”‚   β”œβ”€β”€ descriptions.py      item descriptions checked / respelt against the product master
β”‚   β”œβ”€β”€ product_master.py    loads + caches the backend product API (read-only, hourly background reload)
β”‚   β”œβ”€β”€ vlm.py               Ollama client
β”‚   β”œβ”€β”€ schemas.py           request/response models (the JSON contract)
β”‚   β”œβ”€β”€ config.py            settings
β”‚   β”œβ”€β”€ logging_setup.py     console log format (shared by API, worker and run.py)
β”‚   β”œβ”€β”€ audit.py             request / response log (logs/requests.log, logs/responses.log)
β”‚   β”œβ”€β”€ errors.py            per-file error codes
β”‚   β”œβ”€β”€ textutil.py          number/text helpers
β”‚   β”œβ”€β”€ documents/
β”‚   β”‚   β”œβ”€β”€ layout.py        Token / Line / Page model shared by text layer and OCR
β”‚   β”‚   β”œβ”€β”€ pdf.py           text layer + page rendering
β”‚   β”‚   β”œβ”€β”€ ocr.py           PaddleOCR + rotation/skew correction
β”‚   β”‚   β”œβ”€β”€ image.py         JPG/PNG/TIFF loading
β”‚   β”‚   └── spreadsheet.py   XLSX / XLS / CSV
β”‚   └── parsers/
β”‚       β”œβ”€β”€ base.py          shared table-reading helpers
β”‚       β”œβ”€β”€ aci.py           ACI PDF + Excel
β”‚       β”œβ”€β”€ alfamart.py
β”‚       β”œβ”€β”€ apontech.py
β”‚       └── csd_exclusive.py
β”œβ”€β”€ config/clients.yaml      client names, aliases, signatures, guideline rules
β”œβ”€β”€ run.py                   one-command launcher (Redis + Ollama + worker + API, cleans up on exit)
β”œβ”€β”€ scripts/extract_local.py extract a local file without the API
β”œβ”€β”€ scripts/make_request.py  build /extract requests for a folder of files
β”œβ”€β”€ tests/
β”‚   β”œβ”€β”€ variations.py        generates value variations of real pages
β”‚   └── test_*.py
β”œβ”€β”€ requirements.txt
β”œβ”€β”€ .env.example
└── README.md

11. Adding a new client

Same format as an existing client (e.g. another branch on the same software): add an entry to config/clients.yaml with the existing parser, and remove the name from other_clients. No code.

New format:

  1. app/parsers/<format>.py β€” parse(pages) -> list[RawPO] (and parse_sheets for spreadsheets). Start from the closest parser: CSD for one-line rows with wrapped text, ACI for SAP-style blocks, AponTech for grid tables, Alfamart for two-line rows. Use label_value / find_label_value for header values and column positions from the table header for items β€” never value patterns.
  2. app/registry.py β€” register it in PARSERS.
  3. app/reconcile.py β€” add a row-start regex and a transcript row parser for the vision model.
  4. config/clients.yaml β€” add the client with parser: <format>.
  5. Tests β€” add the sample to tests/test_digital_samples.py / test_scanned_samples.py, its label words to LABELS in tests/variations.py, and the file to DIGITAL / SCANNED in tests/test_variations.py.

12. Troubleshooting

Symptom Fix
GET /jobs/.. stays queued the worker is not running, or points to another Redis (with run.py check the terminal for worker errors)
DOWNLOAD_FAILED ... Connection refused the file server behind the URL is not running; use /upload/extract or run.py --files-dir
run.py: "model ... is not downloaded" pull it into the Ollama server that is running (see 2.4)
every scanned file fails with OCR errors python -c "import paddle; paddle.utils.run_check()"; check nvidia-smi; try OCR_USE_GPU=false
warning "local vision model is not available" start ollama serve and ollama pull qwen2.5vl:7b
CUDA out of memory run only one worker; close other GPU apps; the 7B vision model needs ~6 GB
404 for a job right after restart expected: jobs are not persisted
a large PO (e.g. the ACI JBP DSD PDF: 2,564 pages, 582 POs, ~4.7 MB of JSON) works on one machine but not another 1) same code: git fetch && git status shows "up to date with origin/dev", then restart run.py / the containers (a running server keeps the old code); 2) compare .env with .env.example (.env is not in git); 3) the file takes ~15–30 s on a desktop CPU and longer on a slower one, so a caller of POST /extract/sync needs an HTTP timeout of β‰₯ 120 s (or use POST /extract + GET /jobs/{id}, which has no request timeout); 4) Swagger UI can hang the browser on multi-MB responses even with highlighting off; check logs/responses.log or call the API with curl instead
Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support