Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 386122c8c7 | |||
| 7c8f10497e | |||
| 9f9ec0a671 | |||
| 3780105c6f | |||
| d237ad19f4 | |||
| 7652a2df52 | |||
| b316d98f24 | |||
| f0d88fcbe0 | |||
| 0d8a1ebac2 | |||
| 5a311dca2d | |||
| ab288380f1 | |||
| 30c73b24c1 | |||
| 311e7a8fd4 | |||
| 80e6866442 | |||
| addae20cbd | |||
| 0482c35b69 | |||
| 15ac1fcf67 | |||
| 3c5cf578a5 | |||
| 82358b2d5d | |||
| acded2a165 | |||
| bec379e9ca |
67
.gitea/workflows/docker_nottags.yaml
Normal file
67
.gitea/workflows/docker_nottags.yaml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
name: Docker (no tags)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags-ignore:
|
||||||
|
- '**'
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||||
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||||
|
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ${{ env.IMAGE }}
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
run: |
|
||||||
|
pnpm install -g pnpm
|
||||||
|
pnpm install -g @ship.zone/npmci
|
||||||
|
npmci npm prepare
|
||||||
|
|
||||||
|
- name: Audit production dependencies
|
||||||
|
run: |
|
||||||
|
npmci command npm config set registry https://registry.npmjs.org
|
||||||
|
npmci command pnpm audit --audit-level=high --prod
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Audit development dependencies
|
||||||
|
run: |
|
||||||
|
npmci command npm config set registry https://registry.npmjs.org
|
||||||
|
npmci command pnpm audit --audit-level=high --dev
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
test:
|
||||||
|
needs: security
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ${{ env.IMAGE }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
run: |
|
||||||
|
pnpm install -g pnpm
|
||||||
|
pnpm install -g @ship.zone/npmci
|
||||||
|
npmci npm prepare
|
||||||
|
|
||||||
|
- name: Test stable
|
||||||
|
run: |
|
||||||
|
npmci node install stable
|
||||||
|
npmci npm install
|
||||||
|
npmci npm test
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Test build
|
||||||
|
run: |
|
||||||
|
npmci node install stable
|
||||||
|
npmci npm install
|
||||||
|
npmci command npm run build
|
||||||
101
.gitea/workflows/docker_tags.yaml
Normal file
101
.gitea/workflows/docker_tags.yaml
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
name: Docker (tags)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||||
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||||
|
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ${{ env.IMAGE }}
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
run: |
|
||||||
|
pnpm install -g pnpm
|
||||||
|
pnpm install -g @ship.zone/npmci
|
||||||
|
npmci npm prepare
|
||||||
|
|
||||||
|
- name: Audit production dependencies
|
||||||
|
run: |
|
||||||
|
npmci command npm config set registry https://registry.npmjs.org
|
||||||
|
npmci command pnpm audit --audit-level=high --prod
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Audit development dependencies
|
||||||
|
run: |
|
||||||
|
npmci command npm config set registry https://registry.npmjs.org
|
||||||
|
npmci command pnpm audit --audit-level=high --dev
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
test:
|
||||||
|
needs: security
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ${{ env.IMAGE }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
run: |
|
||||||
|
pnpm install -g pnpm
|
||||||
|
pnpm install -g @ship.zone/npmci
|
||||||
|
npmci npm prepare
|
||||||
|
|
||||||
|
- name: Test stable
|
||||||
|
run: |
|
||||||
|
npmci node install stable
|
||||||
|
npmci npm install
|
||||||
|
npmci npm test
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Test build
|
||||||
|
run: |
|
||||||
|
npmci node install stable
|
||||||
|
npmci npm install
|
||||||
|
npmci command npm run build
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: test
|
||||||
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: code.foss.global/host.today/ht-docker-dbase:npmci
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
run: |
|
||||||
|
pnpm install -g pnpm
|
||||||
|
pnpm install -g @ship.zone/npmci
|
||||||
|
|
||||||
|
- name: Release
|
||||||
|
run: |
|
||||||
|
npmci docker login
|
||||||
|
npmci docker build
|
||||||
|
npmci docker push code.foss.global
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
needs: test
|
||||||
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ${{ env.IMAGE }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Trigger
|
||||||
|
run: npmci trigger
|
||||||
@@ -14,7 +14,7 @@ ENV OLLAMA_ORIGINS="*"
|
|||||||
ENV CUDA_VISIBLE_DEVICES=""
|
ENV CUDA_VISIBLE_DEVICES=""
|
||||||
|
|
||||||
# Copy and setup entrypoint
|
# Copy and setup entrypoint
|
||||||
COPY image_support_files/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
COPY image_support_files/minicpm45v_entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
# Expose Ollama API port
|
# Expose Ollama API port
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ ENV OLLAMA_HOST="0.0.0.0"
|
|||||||
ENV OLLAMA_ORIGINS="*"
|
ENV OLLAMA_ORIGINS="*"
|
||||||
|
|
||||||
# Copy and setup entrypoint
|
# Copy and setup entrypoint
|
||||||
COPY image_support_files/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
COPY image_support_files/minicpm45v_entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
# Expose Ollama API port
|
# Expose Ollama API port
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# PaddleOCR GPU Variant
|
|
||||||
# OCR processing with NVIDIA GPU support using PaddlePaddle
|
|
||||||
FROM paddlepaddle/paddle:3.0.0-gpu-cuda11.8-cudnn8.9-trt8.6
|
|
||||||
|
|
||||||
LABEL maintainer="Task Venture Capital GmbH <hello@task.vc>"
|
|
||||||
LABEL description="PaddleOCR PP-OCRv4 - GPU optimized"
|
|
||||||
LABEL org.opencontainers.image.source="https://code.foss.global/host.today/ht-docker-ai"
|
|
||||||
|
|
||||||
# Environment configuration
|
|
||||||
ENV OCR_LANGUAGE="en"
|
|
||||||
ENV SERVER_PORT="5000"
|
|
||||||
ENV SERVER_HOST="0.0.0.0"
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
libgl1-mesa-glx \
|
|
||||||
libglib2.0-0 \
|
|
||||||
curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Python dependencies
|
|
||||||
RUN pip install --no-cache-dir \
|
|
||||||
paddleocr \
|
|
||||||
fastapi \
|
|
||||||
uvicorn[standard] \
|
|
||||||
python-multipart \
|
|
||||||
opencv-python-headless \
|
|
||||||
pillow
|
|
||||||
|
|
||||||
# Copy server files
|
|
||||||
COPY image_support_files/paddleocr_server.py /app/paddleocr_server.py
|
|
||||||
COPY image_support_files/paddleocr-entrypoint.sh /usr/local/bin/paddleocr-entrypoint.sh
|
|
||||||
RUN chmod +x /usr/local/bin/paddleocr-entrypoint.sh
|
|
||||||
|
|
||||||
# Pre-download OCR models during build (PP-OCRv4)
|
|
||||||
RUN python -c "from paddleocr import PaddleOCR; \
|
|
||||||
ocr = PaddleOCR(use_angle_cls=True, lang='en', use_gpu=False, show_log=True); \
|
|
||||||
print('English model downloaded')"
|
|
||||||
|
|
||||||
# Expose API port
|
|
||||||
EXPOSE 5000
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
|
||||||
CMD curl -f http://localhost:5000/health || exit 1
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/paddleocr-entrypoint.sh"]
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# PaddleOCR CPU Variant
|
|
||||||
# OCR processing optimized for CPU-only inference
|
|
||||||
FROM python:3.10-slim
|
|
||||||
|
|
||||||
LABEL maintainer="Task Venture Capital GmbH <hello@task.vc>"
|
|
||||||
LABEL description="PaddleOCR PP-OCRv4 - CPU optimized"
|
|
||||||
LABEL org.opencontainers.image.source="https://code.foss.global/host.today/ht-docker-ai"
|
|
||||||
|
|
||||||
# Environment configuration for CPU-only mode
|
|
||||||
ENV OCR_LANGUAGE="en"
|
|
||||||
ENV SERVER_PORT="5000"
|
|
||||||
ENV SERVER_HOST="0.0.0.0"
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
# Disable GPU usage for CPU-only variant
|
|
||||||
ENV CUDA_VISIBLE_DEVICES="-1"
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
libgl1-mesa-glx \
|
|
||||||
libglib2.0-0 \
|
|
||||||
curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Python dependencies (CPU version of PaddlePaddle)
|
|
||||||
RUN pip install --no-cache-dir \
|
|
||||||
paddlepaddle \
|
|
||||||
paddleocr \
|
|
||||||
fastapi \
|
|
||||||
uvicorn[standard] \
|
|
||||||
python-multipart \
|
|
||||||
opencv-python-headless \
|
|
||||||
pillow
|
|
||||||
|
|
||||||
# Copy server files
|
|
||||||
COPY image_support_files/paddleocr_server.py /app/paddleocr_server.py
|
|
||||||
COPY image_support_files/paddleocr-entrypoint.sh /usr/local/bin/paddleocr-entrypoint.sh
|
|
||||||
RUN chmod +x /usr/local/bin/paddleocr-entrypoint.sh
|
|
||||||
|
|
||||||
# Pre-download OCR models during build (PP-OCRv4)
|
|
||||||
RUN python -c "from paddleocr import PaddleOCR; \
|
|
||||||
ocr = PaddleOCR(use_angle_cls=True, lang='en', use_gpu=False, show_log=True); \
|
|
||||||
print('English model downloaded')"
|
|
||||||
|
|
||||||
# Expose API port
|
|
||||||
EXPOSE 5000
|
|
||||||
|
|
||||||
# Health check (longer start-period for CPU variant)
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \
|
|
||||||
CMD curl -f http://localhost:5000/health || exit 1
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/paddleocr-entrypoint.sh"]
|
|
||||||
57
Dockerfile_paddleocr_vl_cpu
Normal file
57
Dockerfile_paddleocr_vl_cpu
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# PaddleOCR-VL CPU Variant
|
||||||
|
# Vision-Language Model for document parsing using transformers (slower, no GPU required)
|
||||||
|
FROM python:3.11-slim-bookworm
|
||||||
|
|
||||||
|
LABEL maintainer="Task Venture Capital GmbH <hello@task.vc>"
|
||||||
|
LABEL description="PaddleOCR-VL 0.9B CPU - Vision-Language Model for document parsing"
|
||||||
|
LABEL org.opencontainers.image.source="https://code.foss.global/host.today/ht-docker-ai"
|
||||||
|
|
||||||
|
# Environment configuration
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV HF_HOME=/root/.cache/huggingface
|
||||||
|
ENV CUDA_VISIBLE_DEVICES=""
|
||||||
|
ENV SERVER_PORT=8000
|
||||||
|
ENV SERVER_HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libgl1-mesa-glx \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libgomp1 \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
|
pip install --no-cache-dir \
|
||||||
|
torch==2.5.1 torchvision==0.20.1 --index-url https://download.pytorch.org/whl/cpu && \
|
||||||
|
pip install --no-cache-dir \
|
||||||
|
transformers \
|
||||||
|
accelerate \
|
||||||
|
safetensors \
|
||||||
|
pillow \
|
||||||
|
fastapi \
|
||||||
|
uvicorn[standard] \
|
||||||
|
python-multipart \
|
||||||
|
httpx \
|
||||||
|
protobuf \
|
||||||
|
sentencepiece \
|
||||||
|
einops
|
||||||
|
|
||||||
|
# Copy server files
|
||||||
|
COPY image_support_files/paddleocr_vl_server.py /app/paddleocr_vl_server.py
|
||||||
|
COPY image_support_files/paddleocr_vl_entrypoint.sh /usr/local/bin/paddleocr-vl-cpu-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/paddleocr-vl-cpu-entrypoint.sh
|
||||||
|
|
||||||
|
# Expose API port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check (longer start-period for CPU + model download)
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=600s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/paddleocr-vl-cpu-entrypoint.sh"]
|
||||||
90
Dockerfile_paddleocr_vl_full
Normal file
90
Dockerfile_paddleocr_vl_full
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# PaddleOCR-VL Full Pipeline (PP-DocLayoutV2 + PaddleOCR-VL + Structured Output)
|
||||||
|
# Self-contained GPU image with complete document parsing pipeline
|
||||||
|
FROM nvidia/cuda:12.4.0-devel-ubuntu22.04
|
||||||
|
|
||||||
|
LABEL maintainer="Task Venture Capital GmbH <hello@task.vc>"
|
||||||
|
LABEL description="PaddleOCR-VL Full Pipeline - Layout Detection + VL Recognition + JSON/Markdown Output"
|
||||||
|
LABEL org.opencontainers.image.source="https://code.foss.global/host.today/ht-docker-ai"
|
||||||
|
|
||||||
|
# Environment configuration
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV HF_HOME=/root/.cache/huggingface
|
||||||
|
ENV PADDLEOCR_HOME=/root/.paddleocr
|
||||||
|
ENV SERVER_PORT=8000
|
||||||
|
ENV SERVER_HOST=0.0.0.0
|
||||||
|
ENV VLM_PORT=8080
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3.11 \
|
||||||
|
python3.11-venv \
|
||||||
|
python3.11-dev \
|
||||||
|
python3-pip \
|
||||||
|
libgl1-mesa-glx \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libgomp1 \
|
||||||
|
libsm6 \
|
||||||
|
libxext6 \
|
||||||
|
libxrender1 \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
wget \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& update-alternatives --install /usr/bin/python python /usr/bin/python3.11 1 \
|
||||||
|
&& update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1
|
||||||
|
|
||||||
|
# Create and activate virtual environment
|
||||||
|
RUN python -m venv /opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
# Upgrade pip
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip setuptools wheel
|
||||||
|
|
||||||
|
# Install PaddlePaddle GPU (CUDA 12.x)
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
paddlepaddle-gpu==3.2.1 \
|
||||||
|
--extra-index-url https://www.paddlepaddle.org.cn/packages/stable/cu126/
|
||||||
|
|
||||||
|
# Install PaddleOCR with doc-parser (includes PP-DocLayoutV2)
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
"paddleocr[doc-parser]" \
|
||||||
|
safetensors
|
||||||
|
|
||||||
|
# Install PyTorch with CUDA support
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
torch==2.5.1 \
|
||||||
|
torchvision \
|
||||||
|
--index-url https://download.pytorch.org/whl/cu124
|
||||||
|
|
||||||
|
# Install transformers for PaddleOCR-VL inference (no vLLM - use local inference)
|
||||||
|
# PaddleOCR-VL requires transformers>=4.55.0 for use_kernel_forward_from_hub
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
transformers>=4.55.0 \
|
||||||
|
accelerate \
|
||||||
|
hf-kernels
|
||||||
|
|
||||||
|
# Install our API server dependencies
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
fastapi \
|
||||||
|
uvicorn[standard] \
|
||||||
|
python-multipart \
|
||||||
|
httpx \
|
||||||
|
pillow
|
||||||
|
|
||||||
|
# Copy server files
|
||||||
|
COPY image_support_files/paddleocr_vl_full_server.py /app/server.py
|
||||||
|
COPY image_support_files/paddleocr_vl_full_entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
# Expose ports (8000 = API, 8080 = internal VLM server)
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=600s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
71
Dockerfile_paddleocr_vl_gpu
Normal file
71
Dockerfile_paddleocr_vl_gpu
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# PaddleOCR-VL GPU Variant (Transformers-based, not vLLM)
|
||||||
|
# Vision-Language Model for document parsing using transformers with CUDA
|
||||||
|
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04
|
||||||
|
|
||||||
|
LABEL maintainer="Task Venture Capital GmbH <hello@task.vc>"
|
||||||
|
LABEL description="PaddleOCR-VL 0.9B GPU - Vision-Language Model using transformers"
|
||||||
|
LABEL org.opencontainers.image.source="https://code.foss.global/host.today/ht-docker-ai"
|
||||||
|
|
||||||
|
# Environment configuration
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV HF_HOME=/root/.cache/huggingface
|
||||||
|
ENV SERVER_PORT=8000
|
||||||
|
ENV SERVER_HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3.11 \
|
||||||
|
python3.11-venv \
|
||||||
|
python3.11-dev \
|
||||||
|
python3-pip \
|
||||||
|
libgl1-mesa-glx \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libgomp1 \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& update-alternatives --install /usr/bin/python python /usr/bin/python3.11 1 \
|
||||||
|
&& update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1
|
||||||
|
|
||||||
|
# Create and activate virtual environment
|
||||||
|
RUN python -m venv /opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
# Install PyTorch with CUDA support
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
|
pip install --no-cache-dir \
|
||||||
|
torch==2.5.1 \
|
||||||
|
torchvision \
|
||||||
|
--index-url https://download.pytorch.org/whl/cu124
|
||||||
|
|
||||||
|
# Install Python dependencies (transformers-based, not vLLM)
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
transformers \
|
||||||
|
accelerate \
|
||||||
|
safetensors \
|
||||||
|
pillow \
|
||||||
|
fastapi \
|
||||||
|
uvicorn[standard] \
|
||||||
|
python-multipart \
|
||||||
|
httpx \
|
||||||
|
protobuf \
|
||||||
|
sentencepiece \
|
||||||
|
einops
|
||||||
|
|
||||||
|
# Copy server files (same as CPU variant - it auto-detects CUDA)
|
||||||
|
COPY image_support_files/paddleocr_vl_server.py /app/paddleocr_vl_server.py
|
||||||
|
COPY image_support_files/paddleocr_vl_entrypoint.sh /usr/local/bin/paddleocr-vl-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/paddleocr-vl-entrypoint.sh
|
||||||
|
|
||||||
|
# Expose API port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=300s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/paddleocr-vl-entrypoint.sh"]
|
||||||
26
Dockerfile_qwen3vl
Normal file
26
Dockerfile_qwen3vl
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Qwen3-VL-30B-A3B Vision Language Model
|
||||||
|
# Q4_K_M quantization (~20GB model)
|
||||||
|
#
|
||||||
|
# Most powerful Qwen vision model:
|
||||||
|
# - 256K context (expandable to 1M)
|
||||||
|
# - Visual agent capabilities
|
||||||
|
# - Code generation from images
|
||||||
|
#
|
||||||
|
# Build: docker build -f Dockerfile_qwen3vl -t qwen3vl .
|
||||||
|
# Run: docker run --gpus all -p 11434:11434 -v ht-ollama-models:/root/.ollama qwen3vl
|
||||||
|
|
||||||
|
FROM ollama/ollama:latest
|
||||||
|
|
||||||
|
# Pre-pull the model during build (optional - can also pull at runtime)
|
||||||
|
# This makes the image larger but faster to start
|
||||||
|
# RUN ollama serve & sleep 5 && ollama pull qwen3-vl:30b-a3b && pkill ollama
|
||||||
|
|
||||||
|
# Expose Ollama API port
|
||||||
|
EXPOSE 11434
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:11434/api/tags || exit 1
|
||||||
|
|
||||||
|
# Start Ollama server
|
||||||
|
CMD ["serve"]
|
||||||
@@ -16,7 +16,7 @@ echo -e "${BLUE}Building ht-docker-ai images...${NC}"
|
|||||||
# Build GPU variant
|
# Build GPU variant
|
||||||
echo -e "${GREEN}Building MiniCPM-V 4.5 GPU variant...${NC}"
|
echo -e "${GREEN}Building MiniCPM-V 4.5 GPU variant...${NC}"
|
||||||
docker build \
|
docker build \
|
||||||
-f Dockerfile_minicpm45v \
|
-f Dockerfile_minicpm45v_gpu \
|
||||||
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:minicpm45v \
|
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:minicpm45v \
|
||||||
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:minicpm45v-gpu \
|
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:minicpm45v-gpu \
|
||||||
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:latest \
|
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:latest \
|
||||||
@@ -29,19 +29,19 @@ docker build \
|
|||||||
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:minicpm45v-cpu \
|
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:minicpm45v-cpu \
|
||||||
.
|
.
|
||||||
|
|
||||||
# Build PaddleOCR GPU variant
|
# Build PaddleOCR-VL GPU variant
|
||||||
echo -e "${GREEN}Building PaddleOCR GPU variant...${NC}"
|
echo -e "${GREEN}Building PaddleOCR-VL GPU variant...${NC}"
|
||||||
docker build \
|
docker build \
|
||||||
-f Dockerfile_paddleocr \
|
-f Dockerfile_paddleocr_vl_gpu \
|
||||||
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:paddleocr \
|
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:paddleocr-vl \
|
||||||
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:paddleocr-gpu \
|
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:paddleocr-vl-gpu \
|
||||||
.
|
.
|
||||||
|
|
||||||
# Build PaddleOCR CPU variant
|
# Build PaddleOCR-VL CPU variant
|
||||||
echo -e "${GREEN}Building PaddleOCR CPU variant...${NC}"
|
echo -e "${GREEN}Building PaddleOCR-VL CPU variant...${NC}"
|
||||||
docker build \
|
docker build \
|
||||||
-f Dockerfile_paddleocr_cpu \
|
-f Dockerfile_paddleocr_vl_cpu \
|
||||||
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:paddleocr-cpu \
|
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:paddleocr-vl-cpu \
|
||||||
.
|
.
|
||||||
|
|
||||||
echo -e "${GREEN}All images built successfully!${NC}"
|
echo -e "${GREEN}All images built successfully!${NC}"
|
||||||
@@ -52,7 +52,7 @@ echo " - ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:minicpm45v (GPU)"
|
|||||||
echo " - ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:minicpm45v-cpu (CPU)"
|
echo " - ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:minicpm45v-cpu (CPU)"
|
||||||
echo " - ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:latest (GPU)"
|
echo " - ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:latest (GPU)"
|
||||||
echo ""
|
echo ""
|
||||||
echo " PaddleOCR:"
|
echo " PaddleOCR-VL (Vision-Language Model):"
|
||||||
echo " - ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:paddleocr (GPU)"
|
echo " - ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:paddleocr-vl (GPU/vLLM)"
|
||||||
echo " - ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:paddleocr-gpu (GPU)"
|
echo " - ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:paddleocr-vl-gpu (GPU/vLLM)"
|
||||||
echo " - ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:paddleocr-cpu (CPU)"
|
echo " - ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:paddleocr-vl-cpu (CPU)"
|
||||||
|
|||||||
94
changelog.md
94
changelog.md
@@ -1,5 +1,99 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-01-18 - 1.10.1 - fix(tests)
|
||||||
|
improve Qwen3-VL invoice extraction test by switching to non-stream API, adding model availability/pull checks, simplifying response parsing, and tightening model options
|
||||||
|
|
||||||
|
- Replaced streaming reader logic with direct JSON parsing of the /api/chat response
|
||||||
|
- Added ensureQwen3Vl() to check and pull the Qwen3-VL:8b model from Ollama
|
||||||
|
- Switched to ensureMiniCpm() to verify Ollama service is running before model checks
|
||||||
|
- Use /no_think prompt for direct JSON output and set temperature to 0.0 and num_predict to 512
|
||||||
|
- Removed retry loop and streaming parsing; improved error messages to include response body
|
||||||
|
- Updated logging and test setup messages for clarity
|
||||||
|
|
||||||
|
## 2026-01-18 - 1.10.0 - feat(vision)
|
||||||
|
add Qwen3-VL vision model support with Dockerfile and tests; improve invoice OCR conversion and prompts; simplify extraction flow by removing consensus voting
|
||||||
|
|
||||||
|
- Add Dockerfile_qwen3vl to provide an Ollama-based image for Qwen3-VL and expose the Ollama API on port 11434
|
||||||
|
- Introduce test/test.invoices.qwen3vl.ts and ensureQwen3Vl() helper to pull and test qwen3-vl:8b
|
||||||
|
- Improve PDF->PNG conversion and prompt in ministral3 tests (higher DPI, max quality, sharpen) and increase num_predict from 512 to 1024
|
||||||
|
- Simplify extraction pipeline: remove consensus voting, log single-pass results, and simplify OCR HTML sanitization/truncation logic
|
||||||
|
|
||||||
|
## 2026-01-18 - 1.9.0 - feat(tests)
|
||||||
|
add Ministral 3 vision tests and improve invoice extraction pipeline to use Ollama chat schema, sanitization, and multi-page support
|
||||||
|
|
||||||
|
- Add new vision-based test suites for Ministral 3: test/test.invoices.ministral3.ts and test/test.bankstatements.ministral3.ts (model ministral-3:8b).
|
||||||
|
- Introduce ensureMinistral3() helper to start/check Ollama/MiniCPM model in test/helpers/docker.ts.
|
||||||
|
- Switch invoice extraction to use Ollama /api/chat with a JSON schema (format) and streaming support (reads message.content).
|
||||||
|
- Improve HTML handling: sanitizeHtml() to remove OCR artifacts, concatenate multi-page HTML with page markers, and increase truncation limits.
|
||||||
|
- Enhance response parsing: strip Markdown code fences, robustly locate JSON object boundaries, and provide clearer JSON parse errors.
|
||||||
|
- Add PDF->PNG conversion (ImageMagick) and direct image-based extraction flow for vision model tests.
|
||||||
|
|
||||||
|
## 2026-01-18 - 1.8.0 - feat(paddleocr-vl)
|
||||||
|
add structured HTML output and table parsing for PaddleOCR-VL, update API, tests, and README
|
||||||
|
|
||||||
|
- Add result_to_html(), parse_markdown_table(), and parse_paddleocr_table() to emit semantic HTML and convert OCR/markdown tables to proper <table> elements
|
||||||
|
- Enhance result_to_markdown() with positional/type hints (header/footer/title/table/figure) to improve downstream LLM processing
|
||||||
|
- Expose 'html' in supported formats and handle output_format='html' in parse endpoints and CLI flow
|
||||||
|
- Update tests to request HTML output and extract invoice fields from structured HTML (test/test.invoices.paddleocr-vl.ts)
|
||||||
|
- Refresh README with usage, new images/tags, architecture notes, and troubleshooting for the updated pipeline
|
||||||
|
|
||||||
|
## 2026-01-17 - 1.7.1 - fix(docker)
|
||||||
|
standardize Dockerfile and entrypoint filenames; add GPU-specific Dockerfiles and update build and test references
|
||||||
|
|
||||||
|
- Added Dockerfile_minicpm45v_gpu and image_support_files/minicpm45v_entrypoint.sh; removed the old Dockerfile_minicpm45v and docker-entrypoint.sh
|
||||||
|
- Renamed and simplified PaddleOCR entrypoint to image_support_files/paddleocr_vl_entrypoint.sh and updated CPU/GPU Dockerfile references
|
||||||
|
- Updated build-images.sh to use *_gpu Dockerfiles and clarified PaddleOCR GPU build log
|
||||||
|
- Updated test/helpers/docker.ts to point to Dockerfile_minicpm45v_gpu so tests build the GPU variant
|
||||||
|
|
||||||
|
## 2026-01-17 - 1.7.0 - feat(tests)
|
||||||
|
use Qwen2.5 (Ollama) for invoice extraction tests and add helpers for model management; normalize dates and coerce numeric fields
|
||||||
|
|
||||||
|
- Added ensureOllamaModel and ensureQwen25 test helpers to pull/check Ollama models via localhost:11434
|
||||||
|
- Updated invoices test to use qwen2.5:7b instead of MiniCPM and removed image payload from the text-only extraction step
|
||||||
|
- Increased Markdown truncate limit from 8000 to 12000 and reduced model num_predict from 2048 to 512
|
||||||
|
- Rewrote extraction prompt to require strict JSON output and added post-processing to parse/convert numeric fields
|
||||||
|
- Added normalizeDate and improved compareInvoice to normalize dates and handle numeric formatting/tolerance
|
||||||
|
- Updated test setup to ensure Qwen2.5 is available and adjusted logging/messages to reflect the Qwen2.5-based workflow
|
||||||
|
|
||||||
|
## 2026-01-17 - 1.6.0 - feat(paddleocr-vl)
|
||||||
|
add PaddleOCR-VL full pipeline Docker image and API server, plus integration tests and docker helpers
|
||||||
|
|
||||||
|
- Add Dockerfile_paddleocr_vl_full and entrypoint script to build a GPU-enabled image with PP-DocLayoutV2 + PaddleOCR-VL and a FastAPI server
|
||||||
|
- Introduce image_support_files/paddleocr_vl_full_server.py implementing the full pipeline API (/parse, OpenAI-compatible /v1/chat/completions) and a /formats endpoint
|
||||||
|
- Improve image handling: decode_image supports data URLs, HTTP(S), raw base64 and file paths; add optimize_image_resolution to auto-scale images into the recommended 1080-2048px range
|
||||||
|
- Add test helpers (test/helpers/docker.ts) to build/start/health-check Docker images and new ensurePaddleOcrVlFull workflow
|
||||||
|
- Add comprehensive integration tests for bank statements and invoices (MiniCPM and PaddleOCR-VL variants) and update tests to ensure required containers are running before tests
|
||||||
|
- Switch MiniCPM model references to 'minicpm-v:latest' and increase health/timeout expectations for the full pipeline
|
||||||
|
|
||||||
|
## 2026-01-17 - 1.5.0 - feat(paddleocr-vl)
|
||||||
|
add PaddleOCR-VL GPU Dockerfile, pin vllm, update CPU image deps, and improve entrypoint and tests
|
||||||
|
|
||||||
|
- Add a new GPU Dockerfile for PaddleOCR-VL (transformers-based) with CUDA support, healthcheck, and entrypoint.
|
||||||
|
- Pin vllm to 0.11.1 in Dockerfile_paddleocr_vl to use the first stable release with PaddleOCR-VL support.
|
||||||
|
- Update CPU image: add torchvision==0.20.1 and extra Python deps (protobuf, sentencepiece, einops) required by the transformers-based server.
|
||||||
|
- Rewrite paddleocr-vl-entrypoint.sh to build vllm args array, add MAX_MODEL_LEN and ENFORCE_EAGER env vars, include --limit-mm-per-prompt and optional --enforce-eager, and switch to exec vllm with constructed args.
|
||||||
|
- Update tests to use the OpenAI-compatible PaddleOCR-VL chat completions API (/v1/chat/completions) with image+text message payload and model 'paddleocr-vl'.
|
||||||
|
- Add @types/node to package.json dependencies and tidy devDependencies ordering.
|
||||||
|
|
||||||
|
## 2026-01-16 - 1.4.0 - feat(invoices)
|
||||||
|
add hybrid OCR + vision invoice/document parsing with PaddleOCR, consensus voting, and prompt/test refactors
|
||||||
|
|
||||||
|
- Add hybrid pipeline documentation and examples (PaddleOCR + MiniCPM-V) and architecture diagram in recipes/document.md
|
||||||
|
- Integrate PaddleOCR: new OCR extraction functions and OCR-only prompt flow in test/test.node.ts
|
||||||
|
- Add consensus voting and parallel-pass optimization to improve reliability (multiple passes, hashing, and majority voting)
|
||||||
|
- Refactor prompts and tests: introduce /nothink token, OCR truncation limits, separate visual and OCR-only prompts, and improved prompt building in test/test.invoices.ts
|
||||||
|
- Update image conversion defaults (200 DPI, filename change) and add TypeScript helper functions for extraction and consensus handling
|
||||||
|
|
||||||
|
## 2026-01-16 - 1.3.0 - feat(paddleocr)
|
||||||
|
add PaddleOCR OCR service (Docker images, server, tests, docs) and CI workflows
|
||||||
|
|
||||||
|
- Add GPU and CPU PaddleOCR Dockerfiles; pin paddlepaddle/paddle and paddleocr to stable 2.x and install libgomp1 for CPU builds
|
||||||
|
- Avoid pre-downloading OCR models at build-time to prevent build-time segfaults; models are downloaded on first run
|
||||||
|
- Refactor PaddleOCR FastAPI server: respect CUDA_VISIBLE_DEVICES, support per-request language, cache default language instance and create temporary instances for other languages
|
||||||
|
- Add comprehensive tests (test.paddleocr.ts) and improve invoice extraction tests (parallelize passes, JSON OCR API usage, prioritize certain test cases)
|
||||||
|
- Add Gitea CI workflows for tag and non-tag Docker runs and release pipeline (docker build/push, metadata trigger)
|
||||||
|
- Update documentation (readme.hints.md) with PaddleOCR usage and add docker registry entry to npmextra.json
|
||||||
|
|
||||||
## 2026-01-16 - 1.2.0 - feat(paddleocr)
|
## 2026-01-16 - 1.2.0 - feat(paddleocr)
|
||||||
add PaddleOCR support: Docker images, FastAPI server, entrypoint and tests
|
add PaddleOCR support: Docker images, FastAPI server, entrypoint and tests
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Configuration from environment
|
|
||||||
OCR_LANGUAGE="${OCR_LANGUAGE:-en}"
|
|
||||||
SERVER_PORT="${SERVER_PORT:-5000}"
|
|
||||||
SERVER_HOST="${SERVER_HOST:-0.0.0.0}"
|
|
||||||
|
|
||||||
echo "Starting PaddleOCR Server..."
|
|
||||||
echo " Language: ${OCR_LANGUAGE}"
|
|
||||||
echo " Host: ${SERVER_HOST}"
|
|
||||||
echo " Port: ${SERVER_PORT}"
|
|
||||||
|
|
||||||
# Check GPU availability
|
|
||||||
if [ "${CUDA_VISIBLE_DEVICES}" = "-1" ]; then
|
|
||||||
echo " GPU: Disabled (CPU mode)"
|
|
||||||
else
|
|
||||||
echo " GPU: Enabled"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Start the FastAPI server with uvicorn
|
|
||||||
exec python -m uvicorn paddleocr_server:app \
|
|
||||||
--host "${SERVER_HOST}" \
|
|
||||||
--port "${SERVER_PORT}" \
|
|
||||||
--workers 1
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
PaddleOCR FastAPI Server
|
|
||||||
Provides REST API for OCR operations using PaddleOCR
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import io
|
|
||||||
import base64
|
|
||||||
import logging
|
|
||||||
from typing import Optional, List, Any
|
|
||||||
|
|
||||||
from fastapi import FastAPI, File, UploadFile, Form, HTTPException
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from pydantic import BaseModel
|
|
||||||
import numpy as np
|
|
||||||
from PIL import Image
|
|
||||||
from paddleocr import PaddleOCR
|
|
||||||
|
|
||||||
# Configure logging
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Environment configuration
|
|
||||||
OCR_LANGUAGE = os.environ.get('OCR_LANGUAGE', 'en')
|
|
||||||
USE_GPU = os.environ.get('CUDA_VISIBLE_DEVICES', '') != '-1'
|
|
||||||
|
|
||||||
# Initialize FastAPI app
|
|
||||||
app = FastAPI(
|
|
||||||
title="PaddleOCR Server",
|
|
||||||
description="REST API for OCR operations using PaddleOCR PP-OCRv4",
|
|
||||||
version="1.0.0"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Global OCR instance
|
|
||||||
ocr_instance: Optional[PaddleOCR] = None
|
|
||||||
|
|
||||||
|
|
||||||
class OCRRequest(BaseModel):
|
|
||||||
"""Request model for base64 image OCR"""
|
|
||||||
image: str
|
|
||||||
language: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class BoundingBox(BaseModel):
|
|
||||||
"""Bounding box for detected text"""
|
|
||||||
points: List[List[float]]
|
|
||||||
|
|
||||||
|
|
||||||
class OCRResult(BaseModel):
|
|
||||||
"""Single OCR detection result"""
|
|
||||||
text: str
|
|
||||||
confidence: float
|
|
||||||
box: List[List[float]]
|
|
||||||
|
|
||||||
|
|
||||||
class OCRResponse(BaseModel):
|
|
||||||
"""OCR response model"""
|
|
||||||
success: bool
|
|
||||||
results: List[OCRResult]
|
|
||||||
error: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class HealthResponse(BaseModel):
|
|
||||||
"""Health check response"""
|
|
||||||
status: str
|
|
||||||
model: str
|
|
||||||
language: str
|
|
||||||
gpu_enabled: bool
|
|
||||||
|
|
||||||
|
|
||||||
def get_ocr() -> PaddleOCR:
|
|
||||||
"""Get or initialize the OCR instance"""
|
|
||||||
global ocr_instance
|
|
||||||
if ocr_instance is None:
|
|
||||||
logger.info(f"Initializing PaddleOCR with language={OCR_LANGUAGE}, use_gpu={USE_GPU}")
|
|
||||||
ocr_instance = PaddleOCR(
|
|
||||||
use_angle_cls=True,
|
|
||||||
lang=OCR_LANGUAGE,
|
|
||||||
use_gpu=USE_GPU,
|
|
||||||
show_log=False
|
|
||||||
)
|
|
||||||
logger.info("PaddleOCR initialized successfully")
|
|
||||||
return ocr_instance
|
|
||||||
|
|
||||||
|
|
||||||
def decode_base64_image(base64_string: str) -> np.ndarray:
|
|
||||||
"""Decode base64 string to numpy array"""
|
|
||||||
# Remove data URL prefix if present
|
|
||||||
if ',' in base64_string:
|
|
||||||
base64_string = base64_string.split(',')[1]
|
|
||||||
|
|
||||||
image_data = base64.b64decode(base64_string)
|
|
||||||
image = Image.open(io.BytesIO(image_data))
|
|
||||||
|
|
||||||
# Convert to RGB if necessary
|
|
||||||
if image.mode != 'RGB':
|
|
||||||
image = image.convert('RGB')
|
|
||||||
|
|
||||||
return np.array(image)
|
|
||||||
|
|
||||||
|
|
||||||
def process_ocr_result(result: Any) -> List[OCRResult]:
|
|
||||||
"""Process PaddleOCR result into structured format"""
|
|
||||||
results = []
|
|
||||||
|
|
||||||
if result is None or len(result) == 0:
|
|
||||||
return results
|
|
||||||
|
|
||||||
# PaddleOCR returns list of results per image
|
|
||||||
# Each result is a list of [box, (text, confidence)]
|
|
||||||
for line in result[0] if result[0] else []:
|
|
||||||
if line is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
box = line[0] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
|
|
||||||
text_info = line[1] # (text, confidence)
|
|
||||||
|
|
||||||
results.append(OCRResult(
|
|
||||||
text=text_info[0],
|
|
||||||
confidence=float(text_info[1]),
|
|
||||||
box=[[float(p[0]), float(p[1])] for p in box]
|
|
||||||
))
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
|
||||||
async def startup_event():
|
|
||||||
"""Pre-warm the OCR model on startup"""
|
|
||||||
logger.info("Pre-warming OCR model...")
|
|
||||||
try:
|
|
||||||
ocr = get_ocr()
|
|
||||||
# Create a small test image to warm up the model
|
|
||||||
test_image = np.zeros((100, 100, 3), dtype=np.uint8)
|
|
||||||
test_image.fill(255) # White image
|
|
||||||
ocr.ocr(test_image, cls=True)
|
|
||||||
logger.info("OCR model pre-warmed successfully")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to pre-warm OCR model: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", response_model=HealthResponse)
|
|
||||||
async def health_check():
|
|
||||||
"""Health check endpoint"""
|
|
||||||
try:
|
|
||||||
# Ensure OCR is initialized
|
|
||||||
get_ocr()
|
|
||||||
return HealthResponse(
|
|
||||||
status="healthy",
|
|
||||||
model="PP-OCRv4",
|
|
||||||
language=OCR_LANGUAGE,
|
|
||||||
gpu_enabled=USE_GPU
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Health check failed: {e}")
|
|
||||||
raise HTTPException(status_code=503, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/ocr", response_model=OCRResponse)
|
|
||||||
async def ocr_base64(request: OCRRequest):
|
|
||||||
"""
|
|
||||||
Perform OCR on a base64-encoded image
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: OCRRequest with base64 image and optional language
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
OCRResponse with detected text, confidence scores, and bounding boxes
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Decode image
|
|
||||||
image = decode_base64_image(request.image)
|
|
||||||
|
|
||||||
# Get OCR instance (use request language if provided)
|
|
||||||
ocr = get_ocr()
|
|
||||||
|
|
||||||
# If a different language is requested, create a new instance
|
|
||||||
if request.language and request.language != OCR_LANGUAGE:
|
|
||||||
logger.info(f"Creating OCR instance for language: {request.language}")
|
|
||||||
temp_ocr = PaddleOCR(
|
|
||||||
use_angle_cls=True,
|
|
||||||
lang=request.language,
|
|
||||||
use_gpu=USE_GPU,
|
|
||||||
show_log=False
|
|
||||||
)
|
|
||||||
result = temp_ocr.ocr(image, cls=True)
|
|
||||||
else:
|
|
||||||
result = ocr.ocr(image, cls=True)
|
|
||||||
|
|
||||||
# Process results
|
|
||||||
results = process_ocr_result(result)
|
|
||||||
|
|
||||||
return OCRResponse(success=True, results=results)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"OCR processing failed: {e}")
|
|
||||||
return OCRResponse(success=False, results=[], error=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/ocr/upload", response_model=OCRResponse)
|
|
||||||
async def ocr_upload(
|
|
||||||
img: UploadFile = File(...),
|
|
||||||
language: Optional[str] = Form(None)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Perform OCR on an uploaded image file
|
|
||||||
|
|
||||||
Args:
|
|
||||||
img: Uploaded image file
|
|
||||||
language: Optional language code (default: env OCR_LANGUAGE)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
OCRResponse with detected text, confidence scores, and bounding boxes
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Read image
|
|
||||||
contents = await img.read()
|
|
||||||
image = Image.open(io.BytesIO(contents))
|
|
||||||
|
|
||||||
# Convert to RGB if necessary
|
|
||||||
if image.mode != 'RGB':
|
|
||||||
image = image.convert('RGB')
|
|
||||||
|
|
||||||
image_array = np.array(image)
|
|
||||||
|
|
||||||
# Get OCR instance
|
|
||||||
ocr = get_ocr()
|
|
||||||
|
|
||||||
# If a different language is requested, create a new instance
|
|
||||||
if language and language != OCR_LANGUAGE:
|
|
||||||
logger.info(f"Creating OCR instance for language: {language}")
|
|
||||||
temp_ocr = PaddleOCR(
|
|
||||||
use_angle_cls=True,
|
|
||||||
lang=language,
|
|
||||||
use_gpu=USE_GPU,
|
|
||||||
show_log=False
|
|
||||||
)
|
|
||||||
result = temp_ocr.ocr(image_array, cls=True)
|
|
||||||
else:
|
|
||||||
result = ocr.ocr(image_array, cls=True)
|
|
||||||
|
|
||||||
# Process results
|
|
||||||
results = process_ocr_result(result)
|
|
||||||
|
|
||||||
return OCRResponse(success=True, results=results)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"OCR processing failed: {e}")
|
|
||||||
return OCRResponse(success=False, results=[], error=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=5000)
|
|
||||||
19
image_support_files/paddleocr_vl_entrypoint.sh
Normal file
19
image_support_files/paddleocr_vl_entrypoint.sh
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "==================================="
|
||||||
|
echo "PaddleOCR-VL Server (CPU)"
|
||||||
|
echo "==================================="
|
||||||
|
|
||||||
|
HOST="${SERVER_HOST:-0.0.0.0}"
|
||||||
|
PORT="${SERVER_PORT:-8000}"
|
||||||
|
|
||||||
|
echo "Host: ${HOST}"
|
||||||
|
echo "Port: ${PORT}"
|
||||||
|
echo "Device: CPU (no GPU)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Starting PaddleOCR-VL CPU server..."
|
||||||
|
echo "==================================="
|
||||||
|
|
||||||
|
exec python /app/paddleocr_vl_server.py
|
||||||
12
image_support_files/paddleocr_vl_full_entrypoint.sh
Normal file
12
image_support_files/paddleocr_vl_full_entrypoint.sh
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Starting PaddleOCR-VL Full Pipeline Server (Transformers backend)..."
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
SERVER_PORT=${SERVER_PORT:-8000}
|
||||||
|
SERVER_HOST=${SERVER_HOST:-0.0.0.0}
|
||||||
|
|
||||||
|
# Start our API server directly (no vLLM - uses local transformers inference)
|
||||||
|
echo "Starting API server on port $SERVER_PORT..."
|
||||||
|
exec python /app/server.py
|
||||||
636
image_support_files/paddleocr_vl_full_server.py
Normal file
636
image_support_files/paddleocr_vl_full_server.py
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PaddleOCR-VL Full Pipeline API Server (Transformers backend)
|
||||||
|
|
||||||
|
Provides REST API for document parsing using:
|
||||||
|
- PP-DocLayoutV2 for layout detection
|
||||||
|
- PaddleOCR-VL (transformers) for recognition
|
||||||
|
- Structured JSON/Markdown output
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
from typing import Optional, List, Union
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from PIL import Image
|
||||||
|
import torch
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Environment configuration
|
||||||
|
SERVER_HOST = os.environ.get('SERVER_HOST', '0.0.0.0')
|
||||||
|
SERVER_PORT = int(os.environ.get('SERVER_PORT', '8000'))
|
||||||
|
MODEL_NAME = "PaddlePaddle/PaddleOCR-VL"
|
||||||
|
|
||||||
|
# Device configuration
|
||||||
|
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
logger.info(f"Using device: {DEVICE}")
|
||||||
|
|
||||||
|
# Task prompts
|
||||||
|
TASK_PROMPTS = {
|
||||||
|
"ocr": "OCR:",
|
||||||
|
"table": "Table Recognition:",
|
||||||
|
"formula": "Formula Recognition:",
|
||||||
|
"chart": "Chart Recognition:",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize FastAPI app
|
||||||
|
app = FastAPI(
|
||||||
|
title="PaddleOCR-VL Full Pipeline Server",
|
||||||
|
description="Document parsing with PP-DocLayoutV2 + PaddleOCR-VL (transformers)",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Global model instances
|
||||||
|
vl_model = None
|
||||||
|
vl_processor = None
|
||||||
|
layout_model = None
|
||||||
|
|
||||||
|
|
||||||
|
def load_vl_model():
|
||||||
|
"""Load the PaddleOCR-VL model for element recognition"""
|
||||||
|
global vl_model, vl_processor
|
||||||
|
|
||||||
|
if vl_model is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Loading PaddleOCR-VL model: {MODEL_NAME}")
|
||||||
|
from transformers import AutoModelForCausalLM, AutoProcessor
|
||||||
|
|
||||||
|
vl_processor = AutoProcessor.from_pretrained(MODEL_NAME, trust_remote_code=True)
|
||||||
|
|
||||||
|
if DEVICE == "cuda":
|
||||||
|
vl_model = AutoModelForCausalLM.from_pretrained(
|
||||||
|
MODEL_NAME,
|
||||||
|
trust_remote_code=True,
|
||||||
|
torch_dtype=torch.bfloat16,
|
||||||
|
).to(DEVICE).eval()
|
||||||
|
else:
|
||||||
|
vl_model = AutoModelForCausalLM.from_pretrained(
|
||||||
|
MODEL_NAME,
|
||||||
|
trust_remote_code=True,
|
||||||
|
torch_dtype=torch.float32,
|
||||||
|
low_cpu_mem_usage=True,
|
||||||
|
).eval()
|
||||||
|
|
||||||
|
logger.info("PaddleOCR-VL model loaded successfully")
|
||||||
|
|
||||||
|
|
||||||
|
def load_layout_model():
|
||||||
|
"""Load the LayoutDetection model for layout detection"""
|
||||||
|
global layout_model
|
||||||
|
|
||||||
|
if layout_model is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Loading LayoutDetection model (PP-DocLayout_plus-L)...")
|
||||||
|
from paddleocr import LayoutDetection
|
||||||
|
|
||||||
|
layout_model = LayoutDetection()
|
||||||
|
logger.info("LayoutDetection model loaded successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not load LayoutDetection: {e}")
|
||||||
|
logger.info("Falling back to VL-only mode (no layout detection)")
|
||||||
|
|
||||||
|
|
||||||
|
def recognize_element(image: Image.Image, task: str = "ocr") -> str:
|
||||||
|
"""Recognize a single element using PaddleOCR-VL"""
|
||||||
|
load_vl_model()
|
||||||
|
|
||||||
|
prompt = TASK_PROMPTS.get(task, TASK_PROMPTS["ocr"])
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "image", "image": image},
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
inputs = vl_processor.apply_chat_template(
|
||||||
|
messages,
|
||||||
|
tokenize=True,
|
||||||
|
add_generation_prompt=True,
|
||||||
|
return_dict=True,
|
||||||
|
return_tensors="pt"
|
||||||
|
)
|
||||||
|
|
||||||
|
if DEVICE == "cuda":
|
||||||
|
inputs = {k: v.to(DEVICE) for k, v in inputs.items()}
|
||||||
|
|
||||||
|
with torch.inference_mode():
|
||||||
|
outputs = vl_model.generate(
|
||||||
|
**inputs,
|
||||||
|
max_new_tokens=4096,
|
||||||
|
do_sample=False,
|
||||||
|
use_cache=True
|
||||||
|
)
|
||||||
|
|
||||||
|
response = vl_processor.batch_decode(outputs, skip_special_tokens=True)[0]
|
||||||
|
|
||||||
|
# Extract only the assistant's response content
|
||||||
|
# The response format is: "User: <prompt>\nAssistant: <content>"
|
||||||
|
# We want to extract just the content after "Assistant:"
|
||||||
|
if "Assistant:" in response:
|
||||||
|
parts = response.split("Assistant:")
|
||||||
|
if len(parts) > 1:
|
||||||
|
response = parts[-1].strip()
|
||||||
|
elif "assistant:" in response.lower():
|
||||||
|
# Case-insensitive fallback
|
||||||
|
import re
|
||||||
|
match = re.split(r'[Aa]ssistant:', response)
|
||||||
|
if len(match) > 1:
|
||||||
|
response = match[-1].strip()
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def detect_layout(image: Image.Image) -> List[dict]:
|
||||||
|
"""Detect layout regions in the image"""
|
||||||
|
load_layout_model()
|
||||||
|
|
||||||
|
if layout_model is None:
|
||||||
|
# No layout model - return a single region covering the whole image
|
||||||
|
return [{
|
||||||
|
"type": "text",
|
||||||
|
"bbox": [0, 0, image.width, image.height],
|
||||||
|
"score": 1.0
|
||||||
|
}]
|
||||||
|
|
||||||
|
# Save image to temp file
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||||
|
image.save(tmp.name, "PNG")
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = layout_model.predict(tmp_path)
|
||||||
|
regions = []
|
||||||
|
|
||||||
|
for res in results:
|
||||||
|
# LayoutDetection returns boxes in 'boxes' key
|
||||||
|
for box in res.get("boxes", []):
|
||||||
|
coord = box.get("coordinate", [0, 0, image.width, image.height])
|
||||||
|
# Convert numpy floats to regular floats
|
||||||
|
bbox = [float(c) for c in coord]
|
||||||
|
regions.append({
|
||||||
|
"type": box.get("label", "text"),
|
||||||
|
"bbox": bbox,
|
||||||
|
"score": float(box.get("score", 1.0))
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort regions by vertical position (top to bottom)
|
||||||
|
regions.sort(key=lambda r: r["bbox"][1])
|
||||||
|
|
||||||
|
return regions if regions else [{
|
||||||
|
"type": "text",
|
||||||
|
"bbox": [0, 0, image.width, image.height],
|
||||||
|
"score": 1.0
|
||||||
|
}]
|
||||||
|
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def process_document(image: Image.Image) -> dict:
|
||||||
|
"""Process a document through the full pipeline"""
|
||||||
|
logger.info(f"Processing document: {image.size}")
|
||||||
|
|
||||||
|
# Step 1: Detect layout
|
||||||
|
regions = detect_layout(image)
|
||||||
|
logger.info(f"Detected {len(regions)} layout regions")
|
||||||
|
|
||||||
|
# Step 2: Recognize each region
|
||||||
|
blocks = []
|
||||||
|
for i, region in enumerate(regions):
|
||||||
|
region_type = region["type"].lower()
|
||||||
|
bbox = region["bbox"]
|
||||||
|
|
||||||
|
# Crop region from image
|
||||||
|
x1, y1, x2, y2 = [int(c) for c in bbox]
|
||||||
|
region_image = image.crop((x1, y1, x2, y2))
|
||||||
|
|
||||||
|
# Determine task based on region type
|
||||||
|
if "table" in region_type:
|
||||||
|
task = "table"
|
||||||
|
elif "formula" in region_type or "math" in region_type:
|
||||||
|
task = "formula"
|
||||||
|
elif "chart" in region_type or "figure" in region_type:
|
||||||
|
task = "chart"
|
||||||
|
else:
|
||||||
|
task = "ocr"
|
||||||
|
|
||||||
|
# Recognize the region
|
||||||
|
try:
|
||||||
|
content = recognize_element(region_image, task)
|
||||||
|
blocks.append({
|
||||||
|
"index": i,
|
||||||
|
"type": region_type,
|
||||||
|
"bbox": bbox,
|
||||||
|
"content": content,
|
||||||
|
"task": task
|
||||||
|
})
|
||||||
|
logger.info(f" Region {i} ({region_type}): {len(content)} chars")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f" Region {i} error: {e}")
|
||||||
|
blocks.append({
|
||||||
|
"index": i,
|
||||||
|
"type": region_type,
|
||||||
|
"bbox": bbox,
|
||||||
|
"content": "",
|
||||||
|
"error": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"blocks": blocks, "image_size": list(image.size)}
|
||||||
|
|
||||||
|
|
||||||
|
def result_to_markdown(result: dict) -> str:
|
||||||
|
"""Convert result to Markdown format with structural hints for LLM processing.
|
||||||
|
|
||||||
|
Adds positional and type-based formatting to help downstream LLMs
|
||||||
|
understand document structure:
|
||||||
|
- Tables are marked with **[TABLE]** prefix
|
||||||
|
- Header zone content (top 15%) is bolded
|
||||||
|
- Footer zone content (bottom 15%) is separated with horizontal rule
|
||||||
|
- Titles are formatted as # headers
|
||||||
|
- Figures/charts are marked with *[Figure: ...]*
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
image_height = result.get("image_size", [0, 1000])[1]
|
||||||
|
|
||||||
|
for block in result.get("blocks", []):
|
||||||
|
block_type = block.get("type", "text").lower()
|
||||||
|
content = block.get("content", "").strip()
|
||||||
|
bbox = block.get("bbox", [])
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine position zone (top 15%, middle, bottom 15%)
|
||||||
|
y_pos = bbox[1] if bbox and len(bbox) > 1 else 0
|
||||||
|
y_end = bbox[3] if bbox and len(bbox) > 3 else y_pos
|
||||||
|
is_header_zone = y_pos < image_height * 0.15
|
||||||
|
is_footer_zone = y_end > image_height * 0.85
|
||||||
|
|
||||||
|
# Format based on type and position
|
||||||
|
if "table" in block_type:
|
||||||
|
lines.append(f"\n**[TABLE]**\n{content}\n")
|
||||||
|
elif "title" in block_type:
|
||||||
|
lines.append(f"# {content}")
|
||||||
|
elif "formula" in block_type or "math" in block_type:
|
||||||
|
lines.append(f"\n$$\n{content}\n$$\n")
|
||||||
|
elif "figure" in block_type or "chart" in block_type:
|
||||||
|
lines.append(f"*[Figure: {content}]*")
|
||||||
|
elif is_header_zone:
|
||||||
|
lines.append(f"**{content}**")
|
||||||
|
elif is_footer_zone:
|
||||||
|
lines.append(f"---\n{content}")
|
||||||
|
else:
|
||||||
|
lines.append(content)
|
||||||
|
|
||||||
|
return "\n\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_markdown_table(content: str) -> str:
|
||||||
|
"""Convert table content to HTML table.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- PaddleOCR-VL format: <fcel>cell<lcel>cell<nl> (detected by <fcel> tags)
|
||||||
|
- Pipe-delimited tables: | Header | Header |
|
||||||
|
- Separator rows: |---|---|
|
||||||
|
- Returns HTML <table> structure
|
||||||
|
"""
|
||||||
|
content_stripped = content.strip()
|
||||||
|
|
||||||
|
# Check for PaddleOCR-VL table format (<fcel>, <lcel>, <ecel>, <nl>)
|
||||||
|
if '<fcel>' in content_stripped or '<nl>' in content_stripped:
|
||||||
|
return parse_paddleocr_table(content_stripped)
|
||||||
|
|
||||||
|
lines = content_stripped.split('\n')
|
||||||
|
if not lines:
|
||||||
|
return f'<pre>{content}</pre>'
|
||||||
|
|
||||||
|
# Check if it looks like a markdown table
|
||||||
|
if not any('|' in line for line in lines):
|
||||||
|
return f'<pre>{content}</pre>'
|
||||||
|
|
||||||
|
html_rows = []
|
||||||
|
is_header = True
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith('|') == False and '|' not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip separator rows (|---|---|)
|
||||||
|
if re.match(r'^[\|\s\-:]+$', line):
|
||||||
|
is_header = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse cells
|
||||||
|
cells = [c.strip() for c in line.split('|')]
|
||||||
|
cells = [c for c in cells if c] # Remove empty from edges
|
||||||
|
|
||||||
|
if is_header:
|
||||||
|
row = '<tr>' + ''.join(f'<th>{c}</th>' for c in cells) + '</tr>'
|
||||||
|
html_rows.append(f'<thead>{row}</thead>')
|
||||||
|
is_header = False
|
||||||
|
else:
|
||||||
|
row = '<tr>' + ''.join(f'<td>{c}</td>' for c in cells) + '</tr>'
|
||||||
|
html_rows.append(row)
|
||||||
|
|
||||||
|
if html_rows:
|
||||||
|
# Wrap body rows in tbody
|
||||||
|
header = html_rows[0] if '<thead>' in html_rows[0] else ''
|
||||||
|
body_rows = [r for r in html_rows if '<thead>' not in r]
|
||||||
|
body = f'<tbody>{"".join(body_rows)}</tbody>' if body_rows else ''
|
||||||
|
return f'<table>{header}{body}</table>'
|
||||||
|
|
||||||
|
return f'<pre>{content}</pre>'
|
||||||
|
|
||||||
|
|
||||||
|
def parse_paddleocr_table(content: str) -> str:
|
||||||
|
"""Convert PaddleOCR-VL table format to HTML table.
|
||||||
|
|
||||||
|
PaddleOCR-VL uses:
|
||||||
|
- <fcel> = first cell in a row
|
||||||
|
- <lcel> = subsequent cells
|
||||||
|
- <ecel> = empty cell
|
||||||
|
- <nl> = row separator (newline)
|
||||||
|
|
||||||
|
Example input:
|
||||||
|
<fcel>Header1<lcel>Header2<nl><fcel>Value1<lcel>Value2<nl>
|
||||||
|
"""
|
||||||
|
# Split into rows by <nl>
|
||||||
|
rows_raw = re.split(r'<nl>', content)
|
||||||
|
html_rows = []
|
||||||
|
is_first_row = True
|
||||||
|
|
||||||
|
for row_content in rows_raw:
|
||||||
|
row_content = row_content.strip()
|
||||||
|
if not row_content:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract cells: split by <fcel>, <lcel>, or <ecel>
|
||||||
|
# Each cell is the text between these markers
|
||||||
|
cells = []
|
||||||
|
|
||||||
|
# Pattern to match cell markers and capture content
|
||||||
|
# Content is everything between markers
|
||||||
|
parts = re.split(r'<fcel>|<lcel>|<ecel>', row_content)
|
||||||
|
for part in parts:
|
||||||
|
part = part.strip()
|
||||||
|
if part:
|
||||||
|
cells.append(part)
|
||||||
|
|
||||||
|
if not cells:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# First row is header
|
||||||
|
if is_first_row:
|
||||||
|
row_html = '<tr>' + ''.join(f'<th>{c}</th>' for c in cells) + '</tr>'
|
||||||
|
html_rows.append(f'<thead>{row_html}</thead>')
|
||||||
|
is_first_row = False
|
||||||
|
else:
|
||||||
|
row_html = '<tr>' + ''.join(f'<td>{c}</td>' for c in cells) + '</tr>'
|
||||||
|
html_rows.append(row_html)
|
||||||
|
|
||||||
|
if html_rows:
|
||||||
|
header = html_rows[0] if '<thead>' in html_rows[0] else ''
|
||||||
|
body_rows = [r for r in html_rows if '<thead>' not in r]
|
||||||
|
body = f'<tbody>{"".join(body_rows)}</tbody>' if body_rows else ''
|
||||||
|
return f'<table>{header}{body}</table>'
|
||||||
|
|
||||||
|
return f'<pre>{content}</pre>'
|
||||||
|
|
||||||
|
|
||||||
|
def result_to_html(result: dict) -> str:
|
||||||
|
"""Convert result to semantic HTML for optimal LLM processing.
|
||||||
|
|
||||||
|
Uses semantic HTML5 tags with position metadata as data-* attributes.
|
||||||
|
Markdown tables are converted to proper HTML <table> tags for
|
||||||
|
unambiguous parsing by downstream LLMs.
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
image_height = result.get("image_size", [0, 1000])[1]
|
||||||
|
|
||||||
|
parts.append('<!DOCTYPE html><html><body>')
|
||||||
|
|
||||||
|
for block in result.get("blocks", []):
|
||||||
|
block_type = block.get("type", "text").lower()
|
||||||
|
content = block.get("content", "").strip()
|
||||||
|
bbox = block.get("bbox", [])
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Position metadata
|
||||||
|
y_pos = bbox[1] / image_height if bbox and len(bbox) > 1 else 0
|
||||||
|
data_attrs = f'data-type="{block_type}" data-y="{y_pos:.2f}"'
|
||||||
|
|
||||||
|
# Format based on type
|
||||||
|
if "table" in block_type:
|
||||||
|
table_html = parse_markdown_table(content)
|
||||||
|
parts.append(f'<section {data_attrs} class="table-region">{table_html}</section>')
|
||||||
|
elif "title" in block_type:
|
||||||
|
parts.append(f'<h1 {data_attrs}>{content}</h1>')
|
||||||
|
elif "formula" in block_type or "math" in block_type:
|
||||||
|
parts.append(f'<div {data_attrs} class="formula"><code>{content}</code></div>')
|
||||||
|
elif "figure" in block_type or "chart" in block_type:
|
||||||
|
parts.append(f'<figure {data_attrs}><figcaption>{content}</figcaption></figure>')
|
||||||
|
elif y_pos < 0.15:
|
||||||
|
parts.append(f'<header {data_attrs}><strong>{content}</strong></header>')
|
||||||
|
elif y_pos > 0.85:
|
||||||
|
parts.append(f'<footer {data_attrs}>{content}</footer>')
|
||||||
|
else:
|
||||||
|
parts.append(f'<p {data_attrs}>{content}</p>')
|
||||||
|
|
||||||
|
parts.append('</body></html>')
|
||||||
|
return '\n'.join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# Request/Response models
|
||||||
|
class ParseRequest(BaseModel):
|
||||||
|
image: str # base64 encoded image
|
||||||
|
output_format: Optional[str] = "json"
|
||||||
|
|
||||||
|
|
||||||
|
class ParseResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
format: str
|
||||||
|
result: Union[dict, str]
|
||||||
|
processing_time: float
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def decode_image(image_source: str) -> Image.Image:
|
||||||
|
"""Decode image from base64 or data URL"""
|
||||||
|
if image_source.startswith("data:"):
|
||||||
|
header, data = image_source.split(",", 1)
|
||||||
|
image_data = base64.b64decode(data)
|
||||||
|
else:
|
||||||
|
image_data = base64.b64decode(image_source)
|
||||||
|
|
||||||
|
return Image.open(io.BytesIO(image_data)).convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
"""Pre-load models on startup"""
|
||||||
|
logger.info("Starting PaddleOCR-VL Full Pipeline Server...")
|
||||||
|
try:
|
||||||
|
load_vl_model()
|
||||||
|
load_layout_model()
|
||||||
|
logger.info("Models loaded successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to pre-load models: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return {
|
||||||
|
"status": "healthy" if vl_model is not None else "loading",
|
||||||
|
"service": "PaddleOCR-VL Full Pipeline (Transformers)",
|
||||||
|
"device": DEVICE,
|
||||||
|
"vl_model_loaded": vl_model is not None,
|
||||||
|
"layout_model_loaded": layout_model is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/formats")
|
||||||
|
async def supported_formats():
|
||||||
|
"""List supported output formats"""
|
||||||
|
return {
|
||||||
|
"output_formats": ["json", "markdown", "html"],
|
||||||
|
"image_formats": ["PNG", "JPEG", "WebP", "BMP", "GIF", "TIFF"],
|
||||||
|
"capabilities": [
|
||||||
|
"Layout detection (PP-DocLayoutV2)",
|
||||||
|
"Text recognition (OCR)",
|
||||||
|
"Table recognition",
|
||||||
|
"Formula recognition (LaTeX)",
|
||||||
|
"Chart recognition",
|
||||||
|
"Multi-language support (109 languages)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/parse", response_model=ParseResponse)
|
||||||
|
async def parse_document_endpoint(request: ParseRequest):
|
||||||
|
"""Parse a document image and return structured output"""
|
||||||
|
try:
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
image = decode_image(request.image)
|
||||||
|
result = process_document(image)
|
||||||
|
|
||||||
|
if request.output_format == "markdown":
|
||||||
|
markdown = result_to_markdown(result)
|
||||||
|
output = {"markdown": markdown}
|
||||||
|
elif request.output_format == "html":
|
||||||
|
html = result_to_html(result)
|
||||||
|
output = {"html": html}
|
||||||
|
else:
|
||||||
|
output = result
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
logger.info(f"Processing complete in {elapsed:.2f}s")
|
||||||
|
|
||||||
|
return ParseResponse(
|
||||||
|
success=True,
|
||||||
|
format=request.output_format,
|
||||||
|
result=output,
|
||||||
|
processing_time=elapsed
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing document: {e}", exc_info=True)
|
||||||
|
return ParseResponse(
|
||||||
|
success=False,
|
||||||
|
format=request.output_format,
|
||||||
|
result={},
|
||||||
|
processing_time=0,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/chat/completions")
|
||||||
|
async def chat_completions(request: dict):
|
||||||
|
"""OpenAI-compatible chat completions endpoint"""
|
||||||
|
try:
|
||||||
|
messages = request.get("messages", [])
|
||||||
|
output_format = request.get("output_format", "json")
|
||||||
|
|
||||||
|
# Find user message with image
|
||||||
|
image = None
|
||||||
|
for msg in reversed(messages):
|
||||||
|
if msg.get("role") == "user":
|
||||||
|
content = msg.get("content", [])
|
||||||
|
if isinstance(content, list):
|
||||||
|
for item in content:
|
||||||
|
if item.get("type") == "image_url":
|
||||||
|
url = item.get("image_url", {}).get("url", "")
|
||||||
|
image = decode_image(url)
|
||||||
|
break
|
||||||
|
break
|
||||||
|
|
||||||
|
if image is None:
|
||||||
|
raise HTTPException(status_code=400, detail="No image provided")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
result = process_document(image)
|
||||||
|
|
||||||
|
if output_format == "markdown":
|
||||||
|
content = result_to_markdown(result)
|
||||||
|
elif output_format == "html":
|
||||||
|
content = result_to_html(result)
|
||||||
|
else:
|
||||||
|
content = json.dumps(result, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": f"chatcmpl-{int(time.time()*1000)}",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": int(time.time()),
|
||||||
|
"model": "paddleocr-vl-full",
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": content},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 100,
|
||||||
|
"completion_tokens": len(content) // 4,
|
||||||
|
"total_tokens": 100 + len(content) // 4
|
||||||
|
},
|
||||||
|
"processing_time": elapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in chat completions: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT)
|
||||||
465
image_support_files/paddleocr_vl_server.py
Normal file
465
image_support_files/paddleocr_vl_server.py
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PaddleOCR-VL FastAPI Server (CPU variant)
|
||||||
|
Provides OpenAI-compatible REST API for document parsing using PaddleOCR-VL
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional, List, Any, Dict, Union
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import torch
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Environment configuration
|
||||||
|
SERVER_HOST = os.environ.get('SERVER_HOST', '0.0.0.0')
|
||||||
|
SERVER_PORT = int(os.environ.get('SERVER_PORT', '8000'))
|
||||||
|
MODEL_NAME = os.environ.get('MODEL_NAME', 'PaddlePaddle/PaddleOCR-VL')
|
||||||
|
|
||||||
|
# Device configuration
|
||||||
|
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
logger.info(f"Using device: {DEVICE}")
|
||||||
|
|
||||||
|
# Task prompts for PaddleOCR-VL
|
||||||
|
TASK_PROMPTS = {
|
||||||
|
"ocr": "OCR:",
|
||||||
|
"table": "Table Recognition:",
|
||||||
|
"formula": "Formula Recognition:",
|
||||||
|
"chart": "Chart Recognition:",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize FastAPI app
|
||||||
|
app = FastAPI(
|
||||||
|
title="PaddleOCR-VL Server",
|
||||||
|
description="OpenAI-compatible REST API for document parsing using PaddleOCR-VL",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Global model instances
|
||||||
|
model = None
|
||||||
|
processor = None
|
||||||
|
|
||||||
|
|
||||||
|
# Request/Response models (OpenAI-compatible)
|
||||||
|
class ImageUrl(BaseModel):
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class ContentItem(BaseModel):
|
||||||
|
type: str
|
||||||
|
text: Optional[str] = None
|
||||||
|
image_url: Optional[ImageUrl] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Message(BaseModel):
|
||||||
|
role: str
|
||||||
|
content: Union[str, List[ContentItem]]
|
||||||
|
|
||||||
|
|
||||||
|
class ChatCompletionRequest(BaseModel):
|
||||||
|
model: str = "paddleocr-vl"
|
||||||
|
messages: List[Message]
|
||||||
|
temperature: Optional[float] = 0.0
|
||||||
|
max_tokens: Optional[int] = 4096
|
||||||
|
|
||||||
|
|
||||||
|
class Choice(BaseModel):
|
||||||
|
index: int
|
||||||
|
message: Message
|
||||||
|
finish_reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class Usage(BaseModel):
|
||||||
|
prompt_tokens: int
|
||||||
|
completion_tokens: int
|
||||||
|
total_tokens: int
|
||||||
|
|
||||||
|
|
||||||
|
class ChatCompletionResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
object: str = "chat.completion"
|
||||||
|
created: int
|
||||||
|
model: str
|
||||||
|
choices: List[Choice]
|
||||||
|
usage: Usage
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
model: str
|
||||||
|
device: str
|
||||||
|
|
||||||
|
|
||||||
|
def load_model():
|
||||||
|
"""Load the PaddleOCR-VL model and processor"""
|
||||||
|
global model, processor
|
||||||
|
|
||||||
|
if model is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Loading PaddleOCR-VL model: {MODEL_NAME}")
|
||||||
|
|
||||||
|
from transformers import AutoModelForCausalLM, AutoProcessor
|
||||||
|
|
||||||
|
# Load processor
|
||||||
|
processor = AutoProcessor.from_pretrained(MODEL_NAME, trust_remote_code=True)
|
||||||
|
|
||||||
|
# Load model with appropriate settings for CPU/GPU
|
||||||
|
if DEVICE == "cuda":
|
||||||
|
model = AutoModelForCausalLM.from_pretrained(
|
||||||
|
MODEL_NAME,
|
||||||
|
trust_remote_code=True,
|
||||||
|
torch_dtype=torch.bfloat16,
|
||||||
|
).to(DEVICE).eval()
|
||||||
|
else:
|
||||||
|
# CPU mode - use float32 for compatibility
|
||||||
|
model = AutoModelForCausalLM.from_pretrained(
|
||||||
|
MODEL_NAME,
|
||||||
|
trust_remote_code=True,
|
||||||
|
torch_dtype=torch.float32,
|
||||||
|
low_cpu_mem_usage=True,
|
||||||
|
).eval()
|
||||||
|
|
||||||
|
logger.info("PaddleOCR-VL model loaded successfully")
|
||||||
|
|
||||||
|
|
||||||
|
def optimize_image_resolution(image: Image.Image, max_size: int = 2048, min_size: int = 1080) -> Image.Image:
|
||||||
|
"""
|
||||||
|
Optimize image resolution for PaddleOCR-VL.
|
||||||
|
|
||||||
|
Best results are achieved with images in the 1080p-2K range.
|
||||||
|
- Images larger than max_size are scaled down
|
||||||
|
- Very small images are scaled up to min_size
|
||||||
|
"""
|
||||||
|
width, height = image.size
|
||||||
|
max_dim = max(width, height)
|
||||||
|
min_dim = min(width, height)
|
||||||
|
|
||||||
|
# Scale down if too large (4K+ images often miss text)
|
||||||
|
if max_dim > max_size:
|
||||||
|
scale = max_size / max_dim
|
||||||
|
new_width = int(width * scale)
|
||||||
|
new_height = int(height * scale)
|
||||||
|
logger.info(f"Scaling down image from {width}x{height} to {new_width}x{new_height}")
|
||||||
|
image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||||
|
# Scale up if too small
|
||||||
|
elif max_dim < min_size and min_dim < min_size:
|
||||||
|
scale = min_size / max_dim
|
||||||
|
new_width = int(width * scale)
|
||||||
|
new_height = int(height * scale)
|
||||||
|
logger.info(f"Scaling up image from {width}x{height} to {new_width}x{new_height}")
|
||||||
|
image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||||
|
else:
|
||||||
|
logger.info(f"Image size {width}x{height} is optimal, no scaling needed")
|
||||||
|
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def decode_image(image_source: str, optimize: bool = True) -> Image.Image:
|
||||||
|
"""
|
||||||
|
Decode image from various sources.
|
||||||
|
|
||||||
|
Supported formats:
|
||||||
|
- Base64 data URL: data:image/png;base64,... or data:image/jpeg;base64,...
|
||||||
|
- HTTP/HTTPS URL: https://example.com/image.png
|
||||||
|
- Raw base64 string
|
||||||
|
- Local file path
|
||||||
|
|
||||||
|
Supported image types: PNG, JPEG, WebP, BMP, GIF, TIFF
|
||||||
|
"""
|
||||||
|
image = None
|
||||||
|
|
||||||
|
if image_source.startswith("data:"):
|
||||||
|
# Base64 encoded image with MIME type header
|
||||||
|
# Supports: data:image/png;base64,... data:image/jpeg;base64,... etc.
|
||||||
|
header, data = image_source.split(",", 1)
|
||||||
|
image_data = base64.b64decode(data)
|
||||||
|
image = Image.open(io.BytesIO(image_data)).convert("RGB")
|
||||||
|
logger.debug(f"Decoded base64 image with header: {header}")
|
||||||
|
elif image_source.startswith("http://") or image_source.startswith("https://"):
|
||||||
|
# URL - fetch image
|
||||||
|
import httpx
|
||||||
|
response = httpx.get(image_source, timeout=30.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
image = Image.open(io.BytesIO(response.content)).convert("RGB")
|
||||||
|
logger.debug(f"Fetched image from URL: {image_source[:50]}...")
|
||||||
|
else:
|
||||||
|
# Assume it's a file path or raw base64
|
||||||
|
try:
|
||||||
|
image_data = base64.b64decode(image_source)
|
||||||
|
image = Image.open(io.BytesIO(image_data)).convert("RGB")
|
||||||
|
logger.debug("Decoded raw base64 image")
|
||||||
|
except:
|
||||||
|
# Try as file path
|
||||||
|
image = Image.open(image_source).convert("RGB")
|
||||||
|
logger.debug(f"Loaded image from file: {image_source}")
|
||||||
|
|
||||||
|
# Optimize resolution for best OCR results
|
||||||
|
if optimize:
|
||||||
|
image = optimize_image_resolution(image)
|
||||||
|
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def extract_image_and_text(content: Union[str, List[ContentItem]]) -> tuple:
|
||||||
|
"""Extract image and text prompt from message content"""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return None, content
|
||||||
|
|
||||||
|
image = None
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
for item in content:
|
||||||
|
if item.type == "image_url" and item.image_url:
|
||||||
|
image = decode_image(item.image_url.url)
|
||||||
|
elif item.type == "text" and item.text:
|
||||||
|
text = item.text
|
||||||
|
|
||||||
|
return image, text
|
||||||
|
|
||||||
|
|
||||||
|
def generate_response(image: Image.Image, prompt: str, max_tokens: int = 4096) -> str:
|
||||||
|
"""Generate response using PaddleOCR-VL"""
|
||||||
|
load_model()
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "image", "image": image},
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
inputs = processor.apply_chat_template(
|
||||||
|
messages,
|
||||||
|
tokenize=True,
|
||||||
|
add_generation_prompt=True,
|
||||||
|
return_dict=True,
|
||||||
|
return_tensors="pt"
|
||||||
|
)
|
||||||
|
|
||||||
|
if DEVICE == "cuda":
|
||||||
|
inputs = {k: v.to(DEVICE) for k, v in inputs.items()}
|
||||||
|
|
||||||
|
with torch.inference_mode():
|
||||||
|
outputs = model.generate(
|
||||||
|
**inputs,
|
||||||
|
max_new_tokens=max_tokens,
|
||||||
|
do_sample=False,
|
||||||
|
use_cache=True
|
||||||
|
)
|
||||||
|
|
||||||
|
response = processor.batch_decode(outputs, skip_special_tokens=True)[0]
|
||||||
|
|
||||||
|
# Extract the assistant's response (after the prompt)
|
||||||
|
if "assistant" in response.lower():
|
||||||
|
parts = response.split("assistant")
|
||||||
|
if len(parts) > 1:
|
||||||
|
response = parts[-1].strip()
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
"""Pre-load the model on startup"""
|
||||||
|
logger.info("Pre-loading PaddleOCR-VL model...")
|
||||||
|
try:
|
||||||
|
load_model()
|
||||||
|
logger.info("Model pre-loaded successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to pre-load model: {e}")
|
||||||
|
# Don't fail startup - model will be loaded on first request
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", response_model=HealthResponse)
|
||||||
|
async def health_check():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return HealthResponse(
|
||||||
|
status="healthy" if model is not None else "loading",
|
||||||
|
model=MODEL_NAME,
|
||||||
|
device=DEVICE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/formats")
|
||||||
|
async def supported_formats():
|
||||||
|
"""List supported image formats and input methods"""
|
||||||
|
return {
|
||||||
|
"image_formats": {
|
||||||
|
"supported": ["PNG", "JPEG", "WebP", "BMP", "GIF", "TIFF"],
|
||||||
|
"recommended": ["PNG", "JPEG"],
|
||||||
|
"mime_types": [
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/webp",
|
||||||
|
"image/bmp",
|
||||||
|
"image/gif",
|
||||||
|
"image/tiff"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"input_methods": {
|
||||||
|
"base64_data_url": {
|
||||||
|
"description": "Base64 encoded image with MIME type header",
|
||||||
|
"example": "data:image/png;base64,iVBORw0KGgo..."
|
||||||
|
},
|
||||||
|
"http_url": {
|
||||||
|
"description": "Direct HTTP/HTTPS URL to image",
|
||||||
|
"example": "https://example.com/image.png"
|
||||||
|
},
|
||||||
|
"raw_base64": {
|
||||||
|
"description": "Raw base64 string without header",
|
||||||
|
"example": "iVBORw0KGgo..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resolution": {
|
||||||
|
"optimal_range": "1080p to 2K (1080-2048 pixels on longest side)",
|
||||||
|
"auto_scaling": True,
|
||||||
|
"note": "Images are automatically scaled to optimal range. 4K+ images are scaled down for better accuracy."
|
||||||
|
},
|
||||||
|
"task_prompts": TASK_PROMPTS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/models")
|
||||||
|
async def list_models():
|
||||||
|
"""List available models (OpenAI-compatible)"""
|
||||||
|
return {
|
||||||
|
"object": "list",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "paddleocr-vl",
|
||||||
|
"object": "model",
|
||||||
|
"created": int(time.time()),
|
||||||
|
"owned_by": "paddlepaddle"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/chat/completions", response_model=ChatCompletionResponse)
|
||||||
|
async def chat_completions(request: ChatCompletionRequest):
|
||||||
|
"""
|
||||||
|
OpenAI-compatible chat completions endpoint for PaddleOCR-VL
|
||||||
|
|
||||||
|
Supports tasks:
|
||||||
|
- "OCR:" - Text recognition
|
||||||
|
- "Table Recognition:" - Table extraction
|
||||||
|
- "Formula Recognition:" - Formula extraction
|
||||||
|
- "Chart Recognition:" - Chart extraction
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get the last user message
|
||||||
|
user_message = None
|
||||||
|
for msg in reversed(request.messages):
|
||||||
|
if msg.role == "user":
|
||||||
|
user_message = msg
|
||||||
|
break
|
||||||
|
|
||||||
|
if not user_message:
|
||||||
|
raise HTTPException(status_code=400, detail="No user message found")
|
||||||
|
|
||||||
|
# Extract image and prompt
|
||||||
|
image, prompt = extract_image_and_text(user_message.content)
|
||||||
|
|
||||||
|
if image is None:
|
||||||
|
raise HTTPException(status_code=400, detail="No image provided in message")
|
||||||
|
|
||||||
|
# Default to OCR if no specific prompt
|
||||||
|
if not prompt or prompt.strip() == "":
|
||||||
|
prompt = "OCR:"
|
||||||
|
|
||||||
|
logger.info(f"Processing request with prompt: {prompt[:50]}...")
|
||||||
|
|
||||||
|
# Generate response
|
||||||
|
start_time = time.time()
|
||||||
|
response_text = generate_response(image, prompt, request.max_tokens or 4096)
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
|
||||||
|
logger.info(f"Generated response in {elapsed:.2f}s ({len(response_text)} chars)")
|
||||||
|
|
||||||
|
# Build OpenAI-compatible response
|
||||||
|
return ChatCompletionResponse(
|
||||||
|
id=f"chatcmpl-{int(time.time()*1000)}",
|
||||||
|
created=int(time.time()),
|
||||||
|
model=request.model,
|
||||||
|
choices=[
|
||||||
|
Choice(
|
||||||
|
index=0,
|
||||||
|
message=Message(role="assistant", content=response_text),
|
||||||
|
finish_reason="stop"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
usage=Usage(
|
||||||
|
prompt_tokens=100, # Approximate
|
||||||
|
completion_tokens=len(response_text) // 4,
|
||||||
|
total_tokens=100 + len(response_text) // 4
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing request: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy endpoint for compatibility with old PaddleOCR API
|
||||||
|
class LegacyOCRRequest(BaseModel):
|
||||||
|
image: str
|
||||||
|
task: Optional[str] = "ocr"
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyOCRResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
result: str
|
||||||
|
task: str
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/ocr", response_model=LegacyOCRResponse)
|
||||||
|
async def legacy_ocr(request: LegacyOCRRequest):
|
||||||
|
"""
|
||||||
|
Legacy OCR endpoint for backwards compatibility
|
||||||
|
|
||||||
|
Tasks: ocr, table, formula, chart
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
image = decode_image(request.image)
|
||||||
|
prompt = TASK_PROMPTS.get(request.task, TASK_PROMPTS["ocr"])
|
||||||
|
|
||||||
|
result = generate_response(image, prompt)
|
||||||
|
|
||||||
|
return LegacyOCRResponse(
|
||||||
|
success=True,
|
||||||
|
result=result,
|
||||||
|
task=request.task
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Legacy OCR error: {e}")
|
||||||
|
return LegacyOCRResponse(
|
||||||
|
success=False,
|
||||||
|
result="",
|
||||||
|
task=request.task,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT)
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
{
|
{
|
||||||
"npmci": {
|
"npmci": {
|
||||||
"npmGlobalTools": [],
|
"npmGlobalTools": [],
|
||||||
"npmAccessLevel": "public"
|
"npmAccessLevel": "public",
|
||||||
|
"dockerRegistries": [
|
||||||
|
"code.foss.global"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"gitzone": {
|
"gitzone": {
|
||||||
"projectType": "docker",
|
"projectType": "docker",
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@host.today/ht-docker-ai",
|
"name": "@host.today/ht-docker-ai",
|
||||||
"version": "1.2.0",
|
"version": "1.10.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Docker images for AI vision-language models including MiniCPM-V 4.5",
|
"description": "Docker images for AI vision-language models including MiniCPM-V 4.5",
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
"test": "tstest test/ --verbose"
|
"test": "tstest test/ --verbose"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tstest": "^1.0.90",
|
"@git.zone/tsrun": "^1.3.3",
|
||||||
"@git.zone/tsrun": "^1.3.3"
|
"@git.zone/tstest": "^1.0.90"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -28,5 +28,8 @@
|
|||||||
"minicpm",
|
"minicpm",
|
||||||
"ollama",
|
"ollama",
|
||||||
"multimodal"
|
"multimodal"
|
||||||
]
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^25.0.9"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
pnpm-lock.yaml
generated
4
pnpm-lock.yaml
generated
@@ -7,6 +7,10 @@ settings:
|
|||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
|
dependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^25.0.9
|
||||||
|
version: 25.0.9
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@git.zone/tsrun':
|
'@git.zone/tsrun':
|
||||||
specifier: ^1.3.3
|
specifier: ^1.3.3
|
||||||
|
|||||||
126
readme.hints.md
126
readme.hints.md
@@ -77,6 +77,95 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
|||||||
|
|
||||||
CPU variant has longer `start-period` (120s) due to slower startup.
|
CPU variant has longer `start-period` (120s) due to slower startup.
|
||||||
|
|
||||||
|
## PaddleOCR-VL (Recommended)
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
PaddleOCR-VL is a 0.9B parameter Vision-Language Model specifically optimized for document parsing. It replaces the older PP-Structure approach with native VLM understanding.
|
||||||
|
|
||||||
|
**Key advantages over PP-Structure:**
|
||||||
|
- Native table understanding (no HTML parsing needed)
|
||||||
|
- 109 language support
|
||||||
|
- Better handling of complex multi-row tables
|
||||||
|
- Structured Markdown/JSON output
|
||||||
|
|
||||||
|
### Docker Images
|
||||||
|
|
||||||
|
| Tag | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| `paddleocr-vl` | GPU variant using vLLM (recommended) |
|
||||||
|
| `paddleocr-vl-cpu` | CPU variant using transformers |
|
||||||
|
|
||||||
|
### API Endpoints (OpenAI-compatible)
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/health` | GET | Health check with model info |
|
||||||
|
| `/v1/models` | GET | List available models |
|
||||||
|
| `/v1/chat/completions` | POST | OpenAI-compatible chat completions |
|
||||||
|
| `/ocr` | POST | Legacy OCR endpoint |
|
||||||
|
|
||||||
|
### Request/Response Format
|
||||||
|
|
||||||
|
**POST /v1/chat/completions (OpenAI-compatible)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "paddleocr-vl",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}},
|
||||||
|
{"type": "text", "text": "Table Recognition:"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"temperature": 0.0,
|
||||||
|
"max_tokens": 8192
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Task Prompts:**
|
||||||
|
- `"OCR:"` - Text recognition
|
||||||
|
- `"Table Recognition:"` - Table extraction (returns markdown)
|
||||||
|
- `"Formula Recognition:"` - Formula extraction
|
||||||
|
- `"Chart Recognition:"` - Chart extraction
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "chatcmpl-...",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "| Date | Description | Amount |\n|---|---|---|\n| 2021-06-01 | GITLAB INC | -119.96 |"
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `MODEL_NAME` | `PaddlePaddle/PaddleOCR-VL` | Model to load |
|
||||||
|
| `HOST` | `0.0.0.0` | Server host |
|
||||||
|
| `PORT` | `8000` | Server port |
|
||||||
|
| `MAX_BATCHED_TOKENS` | `16384` | vLLM max batch tokens |
|
||||||
|
| `GPU_MEMORY_UTILIZATION` | `0.9` | GPU memory usage (0-1) |
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- **GPU (vLLM)**: ~2-5 seconds per page
|
||||||
|
- **CPU**: ~30-60 seconds per page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Adding New Models
|
## Adding New Models
|
||||||
|
|
||||||
To add a new model variant:
|
To add a new model variant:
|
||||||
@@ -118,6 +207,43 @@ npmci docker build
|
|||||||
npmci docker push code.foss.global
|
npmci docker push code.foss.global
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Multi-Pass Extraction Strategy
|
||||||
|
|
||||||
|
The bank statement extraction uses a dual-VLM consensus approach:
|
||||||
|
|
||||||
|
### Architecture: Dual-VLM Consensus
|
||||||
|
|
||||||
|
| VLM | Model | Purpose |
|
||||||
|
|-----|-------|---------|
|
||||||
|
| **MiniCPM-V 4.5** | 8B params | Primary visual extraction |
|
||||||
|
| **PaddleOCR-VL** | 0.9B params | Table-specialized extraction |
|
||||||
|
|
||||||
|
### Extraction Strategy
|
||||||
|
|
||||||
|
1. **Pass 1**: MiniCPM-V visual extraction (images → JSON)
|
||||||
|
2. **Pass 2**: PaddleOCR-VL table recognition (images → markdown → JSON)
|
||||||
|
3. **Consensus**: If Pass 1 == Pass 2 → Done (fast path)
|
||||||
|
4. **Pass 3+**: MiniCPM-V visual if no consensus
|
||||||
|
|
||||||
|
### Why Dual-VLM Works
|
||||||
|
|
||||||
|
- **Different architectures**: Two independent models cross-check each other
|
||||||
|
- **Specialized strengths**: PaddleOCR-VL optimized for tables, MiniCPM-V for general vision
|
||||||
|
- **No structure loss**: Both VLMs see the original images directly
|
||||||
|
- **Fast consensus**: Most documents complete in 2 passes when VLMs agree
|
||||||
|
|
||||||
|
### Comparison vs Old PP-Structure Approach
|
||||||
|
|
||||||
|
| Approach | Bank Statement Result | Issue |
|
||||||
|
|----------|----------------------|-------|
|
||||||
|
| MiniCPM-V Visual | 28 transactions ✓ | - |
|
||||||
|
| PP-Structure HTML + Visual | 13 transactions ✗ | HTML merged rows incorrectly |
|
||||||
|
| PaddleOCR-VL Table | 28 transactions ✓ | Native table understanding |
|
||||||
|
|
||||||
|
**Key insight**: PP-Structure's HTML output loses structure for complex tables. PaddleOCR-VL's native VLM approach maintains table integrity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Related Resources
|
## Related Resources
|
||||||
|
|
||||||
- [Ollama Documentation](https://ollama.ai/docs)
|
- [Ollama Documentation](https://ollama.ai/docs)
|
||||||
|
|||||||
302
readme.md
302
readme.md
@@ -1,23 +1,40 @@
|
|||||||
# @host.today/ht-docker-ai
|
# @host.today/ht-docker-ai 🚀
|
||||||
|
|
||||||
Docker images for AI vision-language models, starting with MiniCPM-V 4.5.
|
Production-ready Docker images for state-of-the-art AI Vision-Language Models. Run powerful multimodal AI locally with GPU acceleration or CPU fallback—no cloud API keys required.
|
||||||
|
|
||||||
## Overview
|
## Issue Reporting and Security
|
||||||
|
|
||||||
This project provides ready-to-use Docker containers for running state-of-the-art AI vision-language models. Built on Ollama for simplified model management and a consistent REST API.
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
## Available Images
|
## 🎯 What's Included
|
||||||
|
|
||||||
| Tag | Description | Requirements |
|
| Model | Parameters | Best For | API |
|
||||||
|-----|-------------|--------------|
|
|-------|-----------|----------|-----|
|
||||||
| `minicpm45v` | MiniCPM-V 4.5 with GPU support | NVIDIA GPU, 9-18GB VRAM |
|
| **MiniCPM-V 4.5** | 8B | General vision understanding, image analysis, multi-image | Ollama-compatible |
|
||||||
| `minicpm45v-cpu` | MiniCPM-V 4.5 CPU-only | 8GB+ RAM |
|
| **PaddleOCR-VL** | 0.9B | Document parsing, table extraction, OCR | OpenAI-compatible |
|
||||||
| `latest` | Alias for `minicpm45v` | NVIDIA GPU |
|
|
||||||
|
|
||||||
## Quick Start
|
## 📦 Available Images
|
||||||
|
|
||||||
### GPU (Recommended)
|
```
|
||||||
|
code.foss.global/host.today/ht-docker-ai:<tag>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Tag | Model | Hardware | Port |
|
||||||
|
|-----|-------|----------|------|
|
||||||
|
| `minicpm45v` / `latest` | MiniCPM-V 4.5 | NVIDIA GPU (9-18GB VRAM) | 11434 |
|
||||||
|
| `minicpm45v-cpu` | MiniCPM-V 4.5 | CPU only (8GB+ RAM) | 11434 |
|
||||||
|
| `paddleocr-vl` / `paddleocr-vl-gpu` | PaddleOCR-VL | NVIDIA GPU | 8000 |
|
||||||
|
| `paddleocr-vl-cpu` | PaddleOCR-VL | CPU only | 8000 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖼️ MiniCPM-V 4.5
|
||||||
|
|
||||||
|
A GPT-4o level multimodal LLM from OpenBMB—handles image understanding, OCR, multi-image analysis, and visual reasoning across 30+ languages.
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
**GPU (Recommended):**
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name minicpm \
|
--name minicpm \
|
||||||
@@ -27,8 +44,7 @@ docker run -d \
|
|||||||
code.foss.global/host.today/ht-docker-ai:minicpm45v
|
code.foss.global/host.today/ht-docker-ai:minicpm45v
|
||||||
```
|
```
|
||||||
|
|
||||||
### CPU Only
|
**CPU Only:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name minicpm \
|
--name minicpm \
|
||||||
@@ -37,18 +53,16 @@ docker run -d \
|
|||||||
code.foss.global/host.today/ht-docker-ai:minicpm45v-cpu
|
code.foss.global/host.today/ht-docker-ai:minicpm45v-cpu
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Usage
|
> 💡 **Pro tip:** Mount the volume to persist downloaded models (~5GB). Without it, models re-download on every container start.
|
||||||
|
|
||||||
The container exposes the Ollama API on port 11434.
|
### API Examples
|
||||||
|
|
||||||
### List Available Models
|
|
||||||
|
|
||||||
|
**List models:**
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:11434/api/tags
|
curl http://localhost:11434/api/tags
|
||||||
```
|
```
|
||||||
|
|
||||||
### Generate Text from Image
|
**Analyze an image:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:11434/api/generate -d '{
|
curl http://localhost:11434/api/generate -d '{
|
||||||
"model": "minicpm-v",
|
"model": "minicpm-v",
|
||||||
@@ -57,60 +71,128 @@ curl http://localhost:11434/api/generate -d '{
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Chat with Vision
|
**Chat with vision:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:11434/api/chat -d '{
|
curl http://localhost:11434/api/chat -d '{
|
||||||
"model": "minicpm-v",
|
"model": "minicpm-v",
|
||||||
"messages": [
|
"messages": [{
|
||||||
{
|
"role": "user",
|
||||||
"role": "user",
|
"content": "Describe this image in detail",
|
||||||
"content": "Describe this image in detail",
|
"images": ["<base64-encoded-image>"]
|
||||||
"images": ["<base64-encoded-image>"]
|
}]
|
||||||
}
|
|
||||||
]
|
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Variables
|
### Hardware Requirements
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variant | VRAM/RAM | Notes |
|
||||||
|----------|---------|-------------|
|
|---------|----------|-------|
|
||||||
| `MODEL_NAME` | `minicpm-v` | Model to pull on startup |
|
| GPU (int4 quantized) | 9GB VRAM | Recommended for most use cases |
|
||||||
| `OLLAMA_HOST` | `0.0.0.0` | Host address for API |
|
| GPU (full precision) | 18GB VRAM | Maximum quality |
|
||||||
| `OLLAMA_ORIGINS` | `*` | Allowed CORS origins |
|
| CPU (GGUF) | 8GB+ RAM | Slower but accessible |
|
||||||
|
|
||||||
## Hardware Requirements
|
---
|
||||||
|
|
||||||
### GPU Variant (`minicpm45v`)
|
## 📄 PaddleOCR-VL
|
||||||
|
|
||||||
- NVIDIA GPU with CUDA support
|
A specialized 0.9B Vision-Language Model optimized for document parsing. Native support for tables, formulas, charts, and text extraction in 109 languages.
|
||||||
- Minimum 9GB VRAM (int4 quantized)
|
|
||||||
- Recommended 18GB VRAM (full precision)
|
|
||||||
- NVIDIA Container Toolkit installed
|
|
||||||
|
|
||||||
### CPU Variant (`minicpm45v-cpu`)
|
### Quick Start
|
||||||
|
|
||||||
- Minimum 8GB RAM
|
**GPU:**
|
||||||
- Recommended 16GB+ RAM for better performance
|
```bash
|
||||||
- No GPU required
|
docker run -d \
|
||||||
|
--name paddleocr \
|
||||||
|
--gpus all \
|
||||||
|
-p 8000:8000 \
|
||||||
|
-v hf-cache:/root/.cache/huggingface \
|
||||||
|
code.foss.global/host.today/ht-docker-ai:paddleocr-vl
|
||||||
|
```
|
||||||
|
|
||||||
## Model Information
|
**CPU:**
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name paddleocr \
|
||||||
|
-p 8000:8000 \
|
||||||
|
-v hf-cache:/root/.cache/huggingface \
|
||||||
|
code.foss.global/host.today/ht-docker-ai:paddleocr-vl-cpu
|
||||||
|
```
|
||||||
|
|
||||||
**MiniCPM-V 4.5** is a GPT-4o level multimodal large language model developed by OpenBMB.
|
### OpenAI-Compatible API
|
||||||
|
|
||||||
- **Parameters**: 8B (Qwen3-8B + SigLIP2-400M)
|
PaddleOCR-VL exposes a fully OpenAI-compatible `/v1/chat/completions` endpoint:
|
||||||
- **Capabilities**: Image understanding, OCR, multi-image analysis
|
|
||||||
- **Languages**: 30+ languages including English, Chinese, French, Spanish
|
|
||||||
|
|
||||||
## Docker Compose Example
|
```bash
|
||||||
|
curl http://localhost:8000/v1/chat/completions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "paddleocr-vl",
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,<base64>"}},
|
||||||
|
{"type": "text", "text": "Table Recognition:"}
|
||||||
|
]
|
||||||
|
}],
|
||||||
|
"max_tokens": 8192
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task Prompts
|
||||||
|
|
||||||
|
| Prompt | Output | Use Case |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| `OCR:` | Plain text | General text extraction |
|
||||||
|
| `Table Recognition:` | Markdown table | Invoices, bank statements, spreadsheets |
|
||||||
|
| `Formula Recognition:` | LaTeX | Math equations, scientific notation |
|
||||||
|
| `Chart Recognition:` | Description | Graphs and visualizations |
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/health` | GET | Health check with model/device info |
|
||||||
|
| `/formats` | GET | Supported image formats and input methods |
|
||||||
|
| `/v1/models` | GET | List available models |
|
||||||
|
| `/v1/chat/completions` | POST | OpenAI-compatible chat completions |
|
||||||
|
| `/ocr` | POST | Legacy OCR endpoint |
|
||||||
|
|
||||||
|
### Image Input Methods
|
||||||
|
|
||||||
|
PaddleOCR-VL accepts images in multiple formats:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Base64 data URL
|
||||||
|
"data:image/png;base64,iVBORw0KGgo..."
|
||||||
|
|
||||||
|
// HTTP URL
|
||||||
|
"https://example.com/document.png"
|
||||||
|
|
||||||
|
// Raw base64
|
||||||
|
"iVBORw0KGgo..."
|
||||||
|
```
|
||||||
|
|
||||||
|
**Supported formats:** PNG, JPEG, WebP, BMP, GIF, TIFF
|
||||||
|
|
||||||
|
**Optimal resolution:** 1080p–2K. Images are automatically scaled for best results.
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
| Mode | Speed per Page |
|
||||||
|
|------|----------------|
|
||||||
|
| GPU (CUDA) | 2–5 seconds |
|
||||||
|
| CPU | 30–60 seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐳 Docker Compose
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
|
# General vision tasks
|
||||||
minicpm:
|
minicpm:
|
||||||
image: code.foss.global/host.today/ht-docker-ai:minicpm45v
|
image: code.foss.global/host.today/ht-docker-ai:minicpm45v
|
||||||
container_name: minicpm
|
|
||||||
ports:
|
ports:
|
||||||
- "11434:11434"
|
- "11434:11434"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -124,11 +206,50 @@ services:
|
|||||||
capabilities: [gpu]
|
capabilities: [gpu]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Document parsing / OCR
|
||||||
|
paddleocr:
|
||||||
|
image: code.foss.global/host.today/ht-docker-ai:paddleocr-vl
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- hf-cache:/root/.cache/huggingface
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
ollama-data:
|
ollama-data:
|
||||||
|
hf-cache:
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building Locally
|
---
|
||||||
|
|
||||||
|
## ⚙️ Environment Variables
|
||||||
|
|
||||||
|
### MiniCPM-V 4.5
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `MODEL_NAME` | `minicpm-v` | Ollama model to pull on startup |
|
||||||
|
| `OLLAMA_HOST` | `0.0.0.0` | API bind address |
|
||||||
|
| `OLLAMA_ORIGINS` | `*` | Allowed CORS origins |
|
||||||
|
|
||||||
|
### PaddleOCR-VL
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `MODEL_NAME` | `PaddlePaddle/PaddleOCR-VL` | HuggingFace model ID |
|
||||||
|
| `SERVER_HOST` | `0.0.0.0` | API bind address |
|
||||||
|
| `SERVER_PORT` | `8000` | API port |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Building from Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
# Clone the repository
|
||||||
@@ -142,6 +263,77 @@ cd ht-docker-ai
|
|||||||
./test-images.sh
|
./test-images.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
---
|
||||||
|
|
||||||
MIT - Task Venture Capital GmbH
|
## 🏗️ Architecture Notes
|
||||||
|
|
||||||
|
### Dual-VLM Consensus Strategy
|
||||||
|
|
||||||
|
For production document extraction, consider using both models together:
|
||||||
|
|
||||||
|
1. **Pass 1:** MiniCPM-V visual extraction (images → JSON)
|
||||||
|
2. **Pass 2:** PaddleOCR-VL table recognition (images → markdown → JSON)
|
||||||
|
3. **Consensus:** If results match → Done (fast path)
|
||||||
|
4. **Pass 3+:** Additional visual passes if needed
|
||||||
|
|
||||||
|
This dual-VLM approach catches extraction errors that single models miss.
|
||||||
|
|
||||||
|
### Why This Works
|
||||||
|
|
||||||
|
- **Different architectures:** Two independent models cross-validate each other
|
||||||
|
- **Specialized strengths:** PaddleOCR-VL excels at tables; MiniCPM-V handles general vision
|
||||||
|
- **Native processing:** Both VLMs see original images—no intermediate HTML/structure loss
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Troubleshooting
|
||||||
|
|
||||||
|
### Model download hangs
|
||||||
|
```bash
|
||||||
|
docker logs -f <container-name>
|
||||||
|
```
|
||||||
|
Model downloads can take several minutes (~5GB for MiniCPM-V).
|
||||||
|
|
||||||
|
### Out of memory
|
||||||
|
- **GPU:** Use the CPU variant or upgrade VRAM
|
||||||
|
- **CPU:** Increase container memory: `--memory=16g`
|
||||||
|
|
||||||
|
### API not responding
|
||||||
|
1. Check container health: `docker ps`
|
||||||
|
2. Review logs: `docker logs <container>`
|
||||||
|
3. Verify port: `curl localhost:11434/api/tags` or `curl localhost:8000/health`
|
||||||
|
|
||||||
|
### Enable NVIDIA GPU support on host
|
||||||
|
```bash
|
||||||
|
# Install NVIDIA Container Toolkit
|
||||||
|
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
|
||||||
|
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
|
||||||
|
sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
|
||||||
|
sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
|
||||||
|
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
|
||||||
|
sudo nvidia-ctk runtime configure --runtime=docker
|
||||||
|
sudo systemctl restart docker
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License and Legal Information
|
||||||
|
|
||||||
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||||
|
|
||||||
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||||
|
|
||||||
|
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
|
### Company Information
|
||||||
|
|
||||||
|
Task Venture Capital GmbH
|
||||||
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
|
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||||
|
|||||||
@@ -1,129 +1,250 @@
|
|||||||
# Bank Statement Parsing with MiniCPM-V 4.5
|
# Document Recognition with Hybrid OCR + Vision AI
|
||||||
|
|
||||||
Recipe for extracting transactions from bank statement PDFs using vision-language AI.
|
Recipe for extracting structured data from invoices and documents using a hybrid approach:
|
||||||
|
PaddleOCR for text extraction + MiniCPM-V 4.5 for intelligent parsing.
|
||||||
|
|
||||||
## Model
|
## Architecture
|
||||||
|
|
||||||
- **Model**: MiniCPM-V 4.5 (8B parameters)
|
```
|
||||||
- **Ollama Name**: `openbmb/minicpm-v4.5:q8_0`
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
- **Quantization**: Q8_0 (9.8GB VRAM)
|
│ PDF/Image │ ───> │ PaddleOCR │ ───> │ Raw Text │
|
||||||
- **Runtime**: Ollama on GPU
|
└──────────────┘ └──────────────┘ └──────┬───────┘
|
||||||
|
│
|
||||||
|
┌──────────────┐ │
|
||||||
|
│ MiniCPM-V │ <───────────┘
|
||||||
|
│ 4.5 VLM │ <─── Image
|
||||||
|
└──────┬───────┘
|
||||||
|
│
|
||||||
|
┌──────▼───────┐
|
||||||
|
│ Structured │
|
||||||
|
│ JSON │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why Hybrid?
|
||||||
|
|
||||||
|
| Approach | Accuracy | Speed | Best For |
|
||||||
|
|----------|----------|-------|----------|
|
||||||
|
| VLM Only | 85-90% | Fast | Simple layouts |
|
||||||
|
| OCR Only | N/A | Fast | Just text extraction |
|
||||||
|
| **Hybrid** | **91%+** | Medium | Complex invoices |
|
||||||
|
|
||||||
|
The hybrid approach provides OCR text as context to the VLM, improving accuracy on:
|
||||||
|
- Small text and numbers
|
||||||
|
- Low contrast documents
|
||||||
|
- Dense tables
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
| Service | Port | Purpose |
|
||||||
|
|---------|------|---------|
|
||||||
|
| PaddleOCR | 5000 | Text extraction |
|
||||||
|
| Ollama (MiniCPM-V) | 11434 | Intelligent parsing |
|
||||||
|
|
||||||
|
## Running the Containers
|
||||||
|
|
||||||
|
**Start both services:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# PaddleOCR (CPU is sufficient for OCR)
|
||||||
|
docker run -d --name paddleocr -p 5000:5000 \
|
||||||
|
code.foss.global/host.today/ht-docker-ai:paddleocr-cpu
|
||||||
|
|
||||||
|
# MiniCPM-V 4.5 (GPU recommended)
|
||||||
|
docker run -d --name minicpm --gpus all -p 11434:11434 \
|
||||||
|
-v ollama-data:/root/.ollama \
|
||||||
|
code.foss.global/host.today/ht-docker-ai:minicpm45v
|
||||||
|
```
|
||||||
|
|
||||||
## Image Conversion
|
## Image Conversion
|
||||||
|
|
||||||
Convert PDF to PNG at 300 DPI for optimal OCR accuracy.
|
Convert PDF to PNG at 200 DPI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
convert -density 300 -quality 100 input.pdf \
|
convert -density 200 -quality 90 input.pdf \
|
||||||
-background white -alpha remove \
|
-background white -alpha remove \
|
||||||
output-%d.png
|
page-%d.png
|
||||||
```
|
```
|
||||||
|
|
||||||
**Parameters:**
|
## Step 1: Extract OCR Text
|
||||||
- `-density 300`: 300 DPI resolution (critical for accuracy)
|
|
||||||
- `-quality 100`: Maximum quality
|
|
||||||
- `-background white -alpha remove`: Remove transparency
|
|
||||||
- `output-%d.png`: Outputs page-0.png, page-1.png, etc.
|
|
||||||
|
|
||||||
**Dependencies:**
|
```typescript
|
||||||
```bash
|
async function extractOcrText(imageBase64: string): Promise<string> {
|
||||||
apt-get install imagemagick
|
const response = await fetch('http://localhost:5000/ocr', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ image: imageBase64 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success && data.results) {
|
||||||
|
return data.results.map((r: { text: string }) => r.text).join('\n');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Prompt
|
## Step 2: Build Enhanced Prompt
|
||||||
|
|
||||||
```
|
```typescript
|
||||||
You are a bank statement parser. Extract EVERY transaction from the table.
|
function buildPrompt(ocrText: string): string {
|
||||||
|
const base = `You are an invoice parser. Extract the following fields:
|
||||||
|
|
||||||
Read the Amount column carefully:
|
1. invoice_number: The invoice/receipt number
|
||||||
- "- 21,47 €" means DEBIT, output as: -21.47
|
2. invoice_date: Date in YYYY-MM-DD format
|
||||||
- "+ 1.000,00 €" means CREDIT, output as: 1000.00
|
3. vendor_name: Company that issued the invoice
|
||||||
- European format: comma = decimal point
|
4. currency: EUR, USD, etc.
|
||||||
|
5. net_amount: Amount before tax (if shown)
|
||||||
|
6. vat_amount: Tax/VAT amount (0 if reverse charge)
|
||||||
|
7. total_amount: Final amount due
|
||||||
|
|
||||||
For each row output: {"date":"YYYY-MM-DD","counterparty":"NAME","amount":-21.47}
|
Return ONLY valid JSON:
|
||||||
|
{"invoice_number":"XXX","invoice_date":"YYYY-MM-DD","vendor_name":"Company","currency":"EUR","net_amount":100.00,"vat_amount":19.00,"total_amount":119.00}`;
|
||||||
|
|
||||||
Do not skip any rows. Return complete JSON array:
|
if (ocrText) {
|
||||||
|
return `${base}
|
||||||
|
|
||||||
|
OCR text extracted from the invoice:
|
||||||
|
---
|
||||||
|
${ocrText}
|
||||||
|
---
|
||||||
|
|
||||||
|
Cross-reference the image with the OCR text above for accuracy.`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Call
|
## Step 3: Call Vision-Language Model
|
||||||
|
|
||||||
```python
|
```typescript
|
||||||
import base64
|
async function extractInvoice(images: string[], ocrText: string): Promise<Invoice> {
|
||||||
import requests
|
const payload = {
|
||||||
|
model: 'openbmb/minicpm-v4.5:q8_0',
|
||||||
|
prompt: buildPrompt(ocrText),
|
||||||
|
images, // Base64 encoded
|
||||||
|
stream: false,
|
||||||
|
options: {
|
||||||
|
num_predict: 2048,
|
||||||
|
temperature: 0.1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
# Load images
|
const response = await fetch('http://localhost:11434/api/generate', {
|
||||||
with open('page-0.png', 'rb') as f:
|
method: 'POST',
|
||||||
page0 = base64.b64encode(f.read()).decode('utf-8')
|
headers: { 'Content-Type': 'application/json' },
|
||||||
with open('page-1.png', 'rb') as f:
|
body: JSON.stringify(payload),
|
||||||
page1 = base64.b64encode(f.read()).decode('utf-8')
|
});
|
||||||
|
|
||||||
payload = {
|
const result = await response.json();
|
||||||
"model": "openbmb/minicpm-v4.5:q8_0",
|
return JSON.parse(result.response);
|
||||||
"prompt": prompt,
|
}
|
||||||
"images": [page0, page1], # Multiple pages supported
|
```
|
||||||
"stream": False,
|
|
||||||
"options": {
|
## Consensus Voting
|
||||||
"num_predict": 16384,
|
|
||||||
"temperature": 0.1
|
For production reliability, run multiple extraction passes and require consensus:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function extractWithConsensus(images: string[], maxPasses: number = 5): Promise<Invoice> {
|
||||||
|
const results: Map<string, { invoice: Invoice; count: number }> = new Map();
|
||||||
|
|
||||||
|
// Optimization: Run Pass 1 (no OCR) parallel with OCR + Pass 2
|
||||||
|
const [pass1Result, ocrText] = await Promise.all([
|
||||||
|
extractInvoice(images, ''),
|
||||||
|
extractOcrText(images[0]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add Pass 1 result
|
||||||
|
addResult(results, pass1Result);
|
||||||
|
|
||||||
|
// Pass 2 with OCR context
|
||||||
|
const pass2Result = await extractInvoice(images, ocrText);
|
||||||
|
addResult(results, pass2Result);
|
||||||
|
|
||||||
|
// Check for consensus (2 matching results)
|
||||||
|
for (const [hash, data] of results) {
|
||||||
|
if (data.count >= 2) {
|
||||||
|
return data.invoice; // Consensus reached!
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue until consensus or max passes
|
||||||
|
for (let pass = 3; pass <= maxPasses; pass++) {
|
||||||
|
const result = await extractInvoice(images, ocrText);
|
||||||
|
addResult(results, result);
|
||||||
|
// Check consensus...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return most common result
|
||||||
|
return getMostCommon(results);
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(
|
function hashInvoice(inv: Invoice): string {
|
||||||
'http://localhost:11434/api/generate',
|
return `${inv.invoice_number}|${inv.invoice_date}|${inv.total_amount.toFixed(2)}`;
|
||||||
json=payload,
|
}
|
||||||
timeout=600
|
|
||||||
)
|
|
||||||
|
|
||||||
result = response.json()['response']
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Output Format
|
## Output Format
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
{
|
||||||
{"date":"2022-04-01","counterparty":"DIGITALOCEAN.COM","amount":-21.47},
|
"invoice_number": "INV-2024-001234",
|
||||||
{"date":"2022-04-01","counterparty":"DIGITALOCEAN.COM","amount":-58.06},
|
"invoice_date": "2024-08-15",
|
||||||
{"date":"2022-04-12","counterparty":"LOSSLESS GMBH","amount":1000.00}
|
"vendor_name": "Hetzner Online GmbH",
|
||||||
]
|
"currency": "EUR",
|
||||||
|
"net_amount": 167.52,
|
||||||
|
"vat_amount": 31.83,
|
||||||
|
"total_amount": 199.35
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running the Container
|
|
||||||
|
|
||||||
**GPU (recommended):**
|
|
||||||
```bash
|
|
||||||
docker run -d --gpus all -p 11434:11434 \
|
|
||||||
-v ollama-data:/root/.ollama \
|
|
||||||
-e MODEL_NAME="openbmb/minicpm-v4.5:q8_0" \
|
|
||||||
ht-docker-ai:minicpm45v
|
|
||||||
```
|
|
||||||
|
|
||||||
**CPU (slower):**
|
|
||||||
```bash
|
|
||||||
docker run -d -p 11434:11434 \
|
|
||||||
-v ollama-data:/root/.ollama \
|
|
||||||
-e MODEL_NAME="openbmb/minicpm-v4.5:q4_0" \
|
|
||||||
ht-docker-ai:minicpm45v-cpu
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hardware Requirements
|
|
||||||
|
|
||||||
| Quantization | VRAM/RAM | Speed |
|
|
||||||
|--------------|----------|-------|
|
|
||||||
| Q8_0 (GPU) | 10GB | Fast |
|
|
||||||
| Q4_0 (CPU) | 8GB | Slow |
|
|
||||||
|
|
||||||
## Test Results
|
## Test Results
|
||||||
|
|
||||||
| Statement | Pages | Transactions | Accuracy |
|
Tested on 46 real invoices from various vendors:
|
||||||
|-----------|-------|--------------|----------|
|
|
||||||
| bunq-2022-04 | 2 | 26 | 100% |
|
| Metric | Value |
|
||||||
| bunq-2021-06 | 3 | 28 | 100% |
|
|--------|-------|
|
||||||
|
| **Accuracy** | 91.3% (42/46) |
|
||||||
|
| **Avg Time** | 42.7s per invoice |
|
||||||
|
| **Consensus Rate** | 85% in 2 passes |
|
||||||
|
|
||||||
|
### Per-Vendor Results
|
||||||
|
|
||||||
|
| Vendor | Invoices | Accuracy |
|
||||||
|
|--------|----------|----------|
|
||||||
|
| Hetzner | 3 | 100% |
|
||||||
|
| DigitalOcean | 4 | 100% |
|
||||||
|
| Adobe | 3 | 100% |
|
||||||
|
| Cloudflare | 1 | 100% |
|
||||||
|
| Wasabi | 4 | 100% |
|
||||||
|
| Figma | 3 | 100% |
|
||||||
|
| Google Cloud | 1 | 100% |
|
||||||
|
| MongoDB | 3 | 0% (date parsing) |
|
||||||
|
|
||||||
|
## Hardware Requirements
|
||||||
|
|
||||||
|
| Component | Minimum | Recommended |
|
||||||
|
|-----------|---------|-------------|
|
||||||
|
| PaddleOCR (CPU) | 4GB RAM | 8GB RAM |
|
||||||
|
| MiniCPM-V (GPU) | 10GB VRAM | 12GB VRAM |
|
||||||
|
| MiniCPM-V (CPU) | 16GB RAM | 32GB RAM |
|
||||||
|
|
||||||
## Tips
|
## Tips
|
||||||
|
|
||||||
1. **DPI matters**: 150 DPI causes missed rows; 300 DPI is optimal
|
1. **Use hybrid approach**: OCR text dramatically improves number/date accuracy
|
||||||
2. **PNG over JPEG**: PNG preserves text clarity better
|
2. **Consensus voting**: Run 2-5 passes to catch hallucinations
|
||||||
3. **Remove alpha**: Some models struggle with transparency
|
3. **200 DPI is optimal**: Higher doesn't help, lower loses detail
|
||||||
4. **Multi-page**: Pass all pages in single request for context
|
4. **PNG over JPEG**: Preserves text clarity
|
||||||
5. **Temperature 0.1**: Low temperature for consistent output
|
5. **Temperature 0.1**: Low temperature for consistent output
|
||||||
6. **European format**: Explicitly explain comma=decimal in prompt
|
6. **Multi-page support**: Pass all pages in single request for context
|
||||||
|
7. **Normalize for comparison**: Ignore case/whitespace when comparing invoice numbers
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
| Issue | Cause | Solution |
|
||||||
|
|-------|-------|----------|
|
||||||
|
| Wrong date | Multiple dates on invoice | Be specific in prompt about which date |
|
||||||
|
| Wrong currency | Symbol vs code mismatch | OCR helps disambiguate |
|
||||||
|
| Missing digits | Low resolution | Increase density to 300 DPI |
|
||||||
|
| Hallucinated data | VLM uncertainty | Use consensus voting |
|
||||||
|
|||||||
385
test/helpers/docker.ts
Normal file
385
test/helpers/docker.ts
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
// Project container names (only manage these)
|
||||||
|
const PROJECT_CONTAINERS = [
|
||||||
|
'paddleocr-vl-test',
|
||||||
|
'paddleocr-vl-gpu-test',
|
||||||
|
'paddleocr-vl-cpu-test',
|
||||||
|
'paddleocr-vl-full-test',
|
||||||
|
'minicpm-test',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Image configurations
|
||||||
|
export interface IImageConfig {
|
||||||
|
name: string;
|
||||||
|
dockerfile: string;
|
||||||
|
buildContext: string;
|
||||||
|
containerName: string;
|
||||||
|
ports: string[];
|
||||||
|
volumes?: string[];
|
||||||
|
gpus?: boolean;
|
||||||
|
healthEndpoint?: string;
|
||||||
|
healthTimeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IMAGES = {
|
||||||
|
paddleocrVlGpu: {
|
||||||
|
name: 'paddleocr-vl-gpu',
|
||||||
|
dockerfile: 'Dockerfile_paddleocr_vl_gpu',
|
||||||
|
buildContext: '.',
|
||||||
|
containerName: 'paddleocr-vl-test',
|
||||||
|
ports: ['8000:8000'],
|
||||||
|
volumes: ['ht-huggingface-cache:/root/.cache/huggingface'],
|
||||||
|
gpus: true,
|
||||||
|
healthEndpoint: 'http://localhost:8000/health',
|
||||||
|
healthTimeout: 300000, // 5 minutes for model loading
|
||||||
|
} as IImageConfig,
|
||||||
|
|
||||||
|
paddleocrVlCpu: {
|
||||||
|
name: 'paddleocr-vl-cpu',
|
||||||
|
dockerfile: 'Dockerfile_paddleocr_vl_cpu',
|
||||||
|
buildContext: '.',
|
||||||
|
containerName: 'paddleocr-vl-test',
|
||||||
|
ports: ['8000:8000'],
|
||||||
|
volumes: ['ht-huggingface-cache:/root/.cache/huggingface'],
|
||||||
|
gpus: false,
|
||||||
|
healthEndpoint: 'http://localhost:8000/health',
|
||||||
|
healthTimeout: 300000,
|
||||||
|
} as IImageConfig,
|
||||||
|
|
||||||
|
minicpm: {
|
||||||
|
name: 'minicpm45v',
|
||||||
|
dockerfile: 'Dockerfile_minicpm45v_gpu',
|
||||||
|
buildContext: '.',
|
||||||
|
containerName: 'minicpm-test',
|
||||||
|
ports: ['11434:11434'],
|
||||||
|
volumes: ['ht-ollama-models:/root/.ollama'],
|
||||||
|
gpus: true,
|
||||||
|
healthEndpoint: 'http://localhost:11434/api/tags',
|
||||||
|
healthTimeout: 120000,
|
||||||
|
} as IImageConfig,
|
||||||
|
|
||||||
|
// Full PaddleOCR-VL pipeline with PP-DocLayoutV2 + structured JSON output
|
||||||
|
paddleocrVlFull: {
|
||||||
|
name: 'paddleocr-vl-full',
|
||||||
|
dockerfile: 'Dockerfile_paddleocr_vl_full',
|
||||||
|
buildContext: '.',
|
||||||
|
containerName: 'paddleocr-vl-full-test',
|
||||||
|
ports: ['8000:8000'],
|
||||||
|
volumes: [
|
||||||
|
'ht-huggingface-cache:/root/.cache/huggingface',
|
||||||
|
'ht-paddleocr-cache:/root/.paddleocr',
|
||||||
|
],
|
||||||
|
gpus: true,
|
||||||
|
healthEndpoint: 'http://localhost:8000/health',
|
||||||
|
healthTimeout: 600000, // 10 minutes for model loading (vLLM + PP-DocLayoutV2)
|
||||||
|
} as IImageConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a shell command and return output
|
||||||
|
*/
|
||||||
|
function exec(command: string, silent = false): string {
|
||||||
|
try {
|
||||||
|
return execSync(command, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: silent ? 'pipe' : 'inherit',
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (silent) return '';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a Docker image exists locally
|
||||||
|
*/
|
||||||
|
export function imageExists(imageName: string): boolean {
|
||||||
|
const result = exec(`docker images -q ${imageName}`, true);
|
||||||
|
return result.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a container is running
|
||||||
|
*/
|
||||||
|
export function isContainerRunning(containerName: string): boolean {
|
||||||
|
const result = exec(`docker ps --filter "name=^${containerName}$" --format "{{.Names}}"`, true);
|
||||||
|
return result.trim() === containerName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a container exists (running or stopped)
|
||||||
|
*/
|
||||||
|
export function containerExists(containerName: string): boolean {
|
||||||
|
const result = exec(`docker ps -a --filter "name=^${containerName}$" --format "{{.Names}}"`, true);
|
||||||
|
return result.trim() === containerName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop and remove a container
|
||||||
|
*/
|
||||||
|
export function removeContainer(containerName: string): void {
|
||||||
|
if (containerExists(containerName)) {
|
||||||
|
console.log(`[Docker] Removing container: ${containerName}`);
|
||||||
|
exec(`docker rm -f ${containerName}`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop all project containers that conflict with the required one
|
||||||
|
*/
|
||||||
|
export function stopConflictingContainers(requiredContainer: string, requiredPort: string): void {
|
||||||
|
// Stop project containers using the same port
|
||||||
|
for (const container of PROJECT_CONTAINERS) {
|
||||||
|
if (container === requiredContainer) continue;
|
||||||
|
|
||||||
|
if (isContainerRunning(container)) {
|
||||||
|
// Check if this container uses the same port
|
||||||
|
const ports = exec(`docker port ${container} 2>/dev/null || true`, true);
|
||||||
|
if (ports.includes(requiredPort.split(':')[0])) {
|
||||||
|
console.log(`[Docker] Stopping conflicting container: ${container}`);
|
||||||
|
exec(`docker stop ${container}`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Docker image
|
||||||
|
*/
|
||||||
|
export function buildImage(config: IImageConfig): void {
|
||||||
|
console.log(`[Docker] Building image: ${config.name}`);
|
||||||
|
const cmd = `docker build --load -f ${config.dockerfile} -t ${config.name} ${config.buildContext}`;
|
||||||
|
exec(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a container from an image
|
||||||
|
*/
|
||||||
|
export function startContainer(config: IImageConfig): void {
|
||||||
|
// Remove existing container if it exists
|
||||||
|
removeContainer(config.containerName);
|
||||||
|
|
||||||
|
console.log(`[Docker] Starting container: ${config.containerName}`);
|
||||||
|
|
||||||
|
const portArgs = config.ports.map((p) => `-p ${p}`).join(' ');
|
||||||
|
const volumeArgs = config.volumes?.map((v) => `-v ${v}`).join(' ') || '';
|
||||||
|
const gpuArgs = config.gpus ? '--gpus all' : '';
|
||||||
|
|
||||||
|
const cmd = `docker run -d --name ${config.containerName} ${gpuArgs} ${portArgs} ${volumeArgs} ${config.name}`;
|
||||||
|
exec(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a container to become healthy
|
||||||
|
*/
|
||||||
|
export async function waitForHealth(
|
||||||
|
endpoint: string,
|
||||||
|
timeoutMs: number = 120000,
|
||||||
|
intervalMs: number = 5000
|
||||||
|
): Promise<boolean> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
console.log(`[Docker] Waiting for health: ${endpoint}`);
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
console.log(`[Docker] Service healthy!`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Service not ready yet
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
||||||
|
console.log(`[Docker] Waiting... (${elapsed}s)`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Docker] Health check timeout after ${timeoutMs / 1000}s`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a service is running and healthy
|
||||||
|
* - Builds image if missing
|
||||||
|
* - Stops conflicting project containers
|
||||||
|
* - Starts container if not running
|
||||||
|
* - Waits for health check
|
||||||
|
*/
|
||||||
|
export async function ensureService(config: IImageConfig): Promise<boolean> {
|
||||||
|
console.log(`\n[Docker] Ensuring service: ${config.name}`);
|
||||||
|
|
||||||
|
// Build image if it doesn't exist
|
||||||
|
if (!imageExists(config.name)) {
|
||||||
|
console.log(`[Docker] Image not found, building...`);
|
||||||
|
buildImage(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop conflicting containers on the same port
|
||||||
|
const mainPort = config.ports[0];
|
||||||
|
stopConflictingContainers(config.containerName, mainPort);
|
||||||
|
|
||||||
|
// Start container if not running
|
||||||
|
if (!isContainerRunning(config.containerName)) {
|
||||||
|
startContainer(config);
|
||||||
|
} else {
|
||||||
|
console.log(`[Docker] Container already running: ${config.containerName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for health
|
||||||
|
if (config.healthEndpoint) {
|
||||||
|
return waitForHealth(config.healthEndpoint, config.healthTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure PaddleOCR-VL GPU service is running
|
||||||
|
*/
|
||||||
|
export async function ensurePaddleOcrVlGpu(): Promise<boolean> {
|
||||||
|
return ensureService(IMAGES.paddleocrVlGpu);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure PaddleOCR-VL CPU service is running
|
||||||
|
*/
|
||||||
|
export async function ensurePaddleOcrVlCpu(): Promise<boolean> {
|
||||||
|
return ensureService(IMAGES.paddleocrVlCpu);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure MiniCPM service is running
|
||||||
|
*/
|
||||||
|
export async function ensureMiniCpm(): Promise<boolean> {
|
||||||
|
return ensureService(IMAGES.minicpm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if GPU is available
|
||||||
|
*/
|
||||||
|
export function isGpuAvailable(): boolean {
|
||||||
|
try {
|
||||||
|
const result = exec('nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null', true);
|
||||||
|
return result.trim().length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure PaddleOCR-VL service (auto-detect GPU/CPU)
|
||||||
|
*/
|
||||||
|
export async function ensurePaddleOcrVl(): Promise<boolean> {
|
||||||
|
if (isGpuAvailable()) {
|
||||||
|
console.log('[Docker] GPU detected, using GPU image');
|
||||||
|
return ensurePaddleOcrVlGpu();
|
||||||
|
} else {
|
||||||
|
console.log('[Docker] No GPU detected, using CPU image');
|
||||||
|
return ensurePaddleOcrVlCpu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure PaddleOCR-VL Full Pipeline service (PP-DocLayoutV2 + structured output)
|
||||||
|
* This is the recommended service for production use - outputs structured JSON/Markdown
|
||||||
|
*/
|
||||||
|
export async function ensurePaddleOcrVlFull(): Promise<boolean> {
|
||||||
|
if (!isGpuAvailable()) {
|
||||||
|
console.log('[Docker] WARNING: Full pipeline requires GPU, but none detected');
|
||||||
|
}
|
||||||
|
return ensureService(IMAGES.paddleocrVlFull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure an Ollama model is pulled and available
|
||||||
|
* Uses the MiniCPM container (which runs Ollama) to pull the model
|
||||||
|
*/
|
||||||
|
export async function ensureOllamaModel(modelName: string): Promise<boolean> {
|
||||||
|
const OLLAMA_URL = 'http://localhost:11434';
|
||||||
|
|
||||||
|
console.log(`\n[Ollama] Ensuring model: ${modelName}`);
|
||||||
|
|
||||||
|
// Check if model exists
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${OLLAMA_URL}/api/tags`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const models = data.models || [];
|
||||||
|
// Exact match required - don't match on prefix
|
||||||
|
const exists = models.some((m: { name: string }) => m.name === modelName);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
console.log(`[Ollama] Model already available: ${modelName}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log(`[Ollama] Cannot check models, Ollama may not be running`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull the model
|
||||||
|
console.log(`[Ollama] Pulling model: ${modelName} (this may take a while)...`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${OLLAMA_URL}/api/pull`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: modelName, stream: false }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log(`[Ollama] Model pulled successfully: ${modelName}`);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.log(`[Ollama] Failed to pull model: ${response.status}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`[Ollama] Error pulling model: ${err}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure Qwen2.5 7B model is available (for text-only JSON extraction)
|
||||||
|
*/
|
||||||
|
export async function ensureQwen25(): Promise<boolean> {
|
||||||
|
// First ensure the Ollama service (MiniCPM container) is running
|
||||||
|
const ollamaOk = await ensureMiniCpm();
|
||||||
|
if (!ollamaOk) return false;
|
||||||
|
|
||||||
|
// Then ensure the Qwen2.5 model is pulled
|
||||||
|
return ensureOllamaModel('qwen2.5:7b');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure Ministral 3 8B model is available (for structured JSON extraction)
|
||||||
|
* Ministral 3 has native JSON output support and OCR-style document extraction
|
||||||
|
*/
|
||||||
|
export async function ensureMinistral3(): Promise<boolean> {
|
||||||
|
// First ensure the Ollama service (MiniCPM container) is running
|
||||||
|
const ollamaOk = await ensureMiniCpm();
|
||||||
|
if (!ollamaOk) return false;
|
||||||
|
|
||||||
|
// Then ensure the Ministral 3 8B model is pulled
|
||||||
|
return ensureOllamaModel('ministral-3:8b');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure Qwen3-VL 8B model is available (vision-language model)
|
||||||
|
* Q4_K_M quantization (~5GB) - fits in 15GB VRAM with room to spare
|
||||||
|
*/
|
||||||
|
export async function ensureQwen3Vl(): Promise<boolean> {
|
||||||
|
// First ensure the Ollama service is running
|
||||||
|
const ollamaOk = await ensureMiniCpm();
|
||||||
|
if (!ollamaOk) return false;
|
||||||
|
|
||||||
|
// Then ensure Qwen3-VL 8B is pulled
|
||||||
|
return ensureOllamaModel('qwen3-vl:8b');
|
||||||
|
}
|
||||||
549
test/test.bankstatements.combined.ts
Normal file
549
test/test.bankstatements.combined.ts
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
/**
|
||||||
|
* Bank statement extraction test using MiniCPM-V (visual) + PaddleOCR-VL (table recognition)
|
||||||
|
*
|
||||||
|
* This is the combined/dual-VLM approach that uses both models for consensus:
|
||||||
|
* - MiniCPM-V for visual extraction
|
||||||
|
* - PaddleOCR-VL for table recognition
|
||||||
|
*/
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { ensurePaddleOcrVl, ensureMiniCpm } from './helpers/docker.js';
|
||||||
|
|
||||||
|
// Service URLs
|
||||||
|
const OLLAMA_URL = 'http://localhost:11434';
|
||||||
|
const PADDLEOCR_VL_URL = 'http://localhost:8000';
|
||||||
|
|
||||||
|
// Models
|
||||||
|
const MINICPM_MODEL = 'minicpm-v:latest';
|
||||||
|
const PADDLEOCR_VL_MODEL = 'paddleocr-vl';
|
||||||
|
|
||||||
|
// Prompt for MiniCPM-V visual extraction
|
||||||
|
const MINICPM_EXTRACT_PROMPT = `/nothink
|
||||||
|
You are a bank statement parser. Extract EVERY transaction from the table.
|
||||||
|
|
||||||
|
Read the Amount column carefully:
|
||||||
|
- "- 21,47 €" means DEBIT, output as: -21.47
|
||||||
|
- "+ 1.000,00 €" means CREDIT, output as: 1000.00
|
||||||
|
- European format: comma = decimal point
|
||||||
|
|
||||||
|
For each row output: {"date":"YYYY-MM-DD","counterparty":"NAME","amount":-21.47}
|
||||||
|
|
||||||
|
Do not skip any rows. Return ONLY the JSON array, no explanation.`;
|
||||||
|
|
||||||
|
// Prompt for PaddleOCR-VL table extraction
|
||||||
|
const PADDLEOCR_VL_TABLE_PROMPT = `Table Recognition:`;
|
||||||
|
|
||||||
|
// Post-processing prompt to convert PaddleOCR-VL output to JSON
|
||||||
|
const PADDLEOCR_VL_CONVERT_PROMPT = `/nothink
|
||||||
|
Convert the following bank statement table data to JSON.
|
||||||
|
|
||||||
|
Read the Amount values carefully:
|
||||||
|
- "- 21,47 €" means DEBIT, output as: -21.47
|
||||||
|
- "+ 1.000,00 €" means CREDIT, output as: 1000.00
|
||||||
|
- European format: comma = decimal point
|
||||||
|
|
||||||
|
For each transaction output: {"date":"YYYY-MM-DD","counterparty":"NAME","amount":-21.47}
|
||||||
|
|
||||||
|
Return ONLY the JSON array, no explanation.
|
||||||
|
|
||||||
|
Table data:
|
||||||
|
---
|
||||||
|
{TABLE_DATA}
|
||||||
|
---`;
|
||||||
|
|
||||||
|
interface ITransaction {
|
||||||
|
date: string;
|
||||||
|
counterparty: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert PDF to PNG images using ImageMagick
|
||||||
|
*/
|
||||||
|
function convertPdfToImages(pdfPath: string): string[] {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pdf-convert-'));
|
||||||
|
const outputPattern = path.join(tempDir, 'page-%d.png');
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`convert -density 300 -quality 100 "${pdfPath}" -background white -alpha remove "${outputPattern}"`,
|
||||||
|
{ stdio: 'pipe' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const files = fs.readdirSync(tempDir).filter((f: string) => f.endsWith('.png')).sort();
|
||||||
|
const images: string[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const imagePath = path.join(tempDir, file);
|
||||||
|
const imageData = fs.readFileSync(imagePath);
|
||||||
|
images.push(imageData.toString('base64'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return images;
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract using MiniCPM-V via Ollama
|
||||||
|
*/
|
||||||
|
async function extractWithMiniCPM(images: string[], passLabel: string): Promise<ITransaction[]> {
|
||||||
|
const payload = {
|
||||||
|
model: MINICPM_MODEL,
|
||||||
|
prompt: MINICPM_EXTRACT_PROMPT,
|
||||||
|
images,
|
||||||
|
stream: true,
|
||||||
|
options: {
|
||||||
|
num_predict: 16384,
|
||||||
|
temperature: 0.1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Ollama API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error('No response body');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let fullText = '';
|
||||||
|
let lineBuffer = '';
|
||||||
|
|
||||||
|
console.log(`[${passLabel}] Extracting with MiniCPM-V...`);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
const lines = chunk.split('\n').filter((l) => l.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(line);
|
||||||
|
if (json.response) {
|
||||||
|
fullText += json.response;
|
||||||
|
lineBuffer += json.response;
|
||||||
|
|
||||||
|
if (lineBuffer.includes('\n')) {
|
||||||
|
const parts = lineBuffer.split('\n');
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
console.log(parts[i]);
|
||||||
|
}
|
||||||
|
lineBuffer = parts[parts.length - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineBuffer) {
|
||||||
|
console.log(lineBuffer);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const startIdx = fullText.indexOf('[');
|
||||||
|
const endIdx = fullText.lastIndexOf(']') + 1;
|
||||||
|
|
||||||
|
if (startIdx < 0 || endIdx <= startIdx) {
|
||||||
|
throw new Error('No JSON array found in response');
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(fullText.substring(startIdx, endIdx));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract table using PaddleOCR-VL via OpenAI-compatible API
|
||||||
|
*/
|
||||||
|
async function extractTableWithPaddleOCRVL(imageBase64: string): Promise<string> {
|
||||||
|
const payload = {
|
||||||
|
model: PADDLEOCR_VL_MODEL,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: `data:image/png;base64,${imageBase64}` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: PADDLEOCR_VL_TABLE_PROMPT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0.0,
|
||||||
|
max_tokens: 8192,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${PADDLEOCR_VL_URL}/v1/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`PaddleOCR-VL API error: ${response.status} - ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.choices?.[0]?.message?.content || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert PaddleOCR-VL table output to transactions using MiniCPM-V
|
||||||
|
*/
|
||||||
|
async function convertTableToTransactions(
|
||||||
|
tableData: string,
|
||||||
|
passLabel: string
|
||||||
|
): Promise<ITransaction[]> {
|
||||||
|
const prompt = PADDLEOCR_VL_CONVERT_PROMPT.replace('{TABLE_DATA}', tableData);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
model: MINICPM_MODEL,
|
||||||
|
prompt,
|
||||||
|
stream: true,
|
||||||
|
options: {
|
||||||
|
num_predict: 16384,
|
||||||
|
temperature: 0.1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Ollama API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error('No response body');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let fullText = '';
|
||||||
|
|
||||||
|
console.log(`[${passLabel}] Converting table data to JSON...`);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
const lines = chunk.split('\n').filter((l) => l.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(line);
|
||||||
|
if (json.response) {
|
||||||
|
fullText += json.response;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIdx = fullText.indexOf('[');
|
||||||
|
const endIdx = fullText.lastIndexOf(']') + 1;
|
||||||
|
|
||||||
|
if (startIdx < 0 || endIdx <= startIdx) {
|
||||||
|
throw new Error('No JSON array found in response');
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(fullText.substring(startIdx, endIdx));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract using PaddleOCR-VL (table recognition) + conversion
|
||||||
|
*/
|
||||||
|
async function extractWithPaddleOCRVL(
|
||||||
|
images: string[],
|
||||||
|
passLabel: string
|
||||||
|
): Promise<ITransaction[]> {
|
||||||
|
console.log(`[${passLabel}] Extracting tables with PaddleOCR-VL...`);
|
||||||
|
|
||||||
|
// Extract table data from each page
|
||||||
|
const tableDataParts: string[] = [];
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
console.log(`[${passLabel}] Processing page ${i + 1}/${images.length}...`);
|
||||||
|
const tableData = await extractTableWithPaddleOCRVL(images[i]);
|
||||||
|
if (tableData.trim()) {
|
||||||
|
tableDataParts.push(`--- Page ${i + 1} ---\n${tableData}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedTableData = tableDataParts.join('\n\n');
|
||||||
|
console.log(`[${passLabel}] Got ${combinedTableData.length} chars of table data`);
|
||||||
|
|
||||||
|
// Convert to transactions
|
||||||
|
return convertTableToTransactions(combinedTableData, passLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a hash of transactions for comparison
|
||||||
|
*/
|
||||||
|
function hashTransactions(transactions: ITransaction[]): string {
|
||||||
|
return transactions
|
||||||
|
.map((t) => `${t.date}|${t.amount.toFixed(2)}`)
|
||||||
|
.sort()
|
||||||
|
.join(';');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if PaddleOCR-VL service is available
|
||||||
|
*/
|
||||||
|
async function isPaddleOCRVLAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${PADDLEOCR_VL_URL}/health`, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract with dual-VLM consensus
|
||||||
|
* Strategy:
|
||||||
|
* Pass 1 = MiniCPM-V visual extraction
|
||||||
|
* Pass 2 = PaddleOCR-VL table recognition (if available)
|
||||||
|
* Pass 3+ = MiniCPM-V visual (fallback)
|
||||||
|
*/
|
||||||
|
async function extractWithConsensus(
|
||||||
|
images: string[],
|
||||||
|
maxPasses: number = 5
|
||||||
|
): Promise<ITransaction[]> {
|
||||||
|
const results: Array<{ transactions: ITransaction[]; hash: string }> = [];
|
||||||
|
const hashCounts: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
const addResult = (transactions: ITransaction[], passLabel: string): number => {
|
||||||
|
const hash = hashTransactions(transactions);
|
||||||
|
results.push({ transactions, hash });
|
||||||
|
hashCounts.set(hash, (hashCounts.get(hash) || 0) + 1);
|
||||||
|
console.log(
|
||||||
|
`[${passLabel}] Got ${transactions.length} transactions (hash: ${hash.substring(0, 20)}...)`
|
||||||
|
);
|
||||||
|
return hashCounts.get(hash)!;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if PaddleOCR-VL is available
|
||||||
|
const paddleOCRVLAvailable = await isPaddleOCRVLAvailable();
|
||||||
|
if (paddleOCRVLAvailable) {
|
||||||
|
console.log('[Setup] PaddleOCR-VL service available - using dual-VLM consensus');
|
||||||
|
} else {
|
||||||
|
console.log('[Setup] PaddleOCR-VL not available - using MiniCPM-V only');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 1: MiniCPM-V visual extraction
|
||||||
|
try {
|
||||||
|
const pass1Result = await extractWithMiniCPM(images, 'Pass 1 MiniCPM-V');
|
||||||
|
addResult(pass1Result, 'Pass 1 MiniCPM-V');
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`[Pass 1] Error: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: PaddleOCR-VL table recognition (if available)
|
||||||
|
if (paddleOCRVLAvailable) {
|
||||||
|
try {
|
||||||
|
const pass2Result = await extractWithPaddleOCRVL(images, 'Pass 2 PaddleOCR-VL');
|
||||||
|
const count = addResult(pass2Result, 'Pass 2 PaddleOCR-VL');
|
||||||
|
if (count >= 2) {
|
||||||
|
console.log('[Consensus] MiniCPM-V and PaddleOCR-VL extractions match!');
|
||||||
|
return pass2Result;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`[Pass 2 PaddleOCR-VL] Error: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 3+: Continue with MiniCPM-V visual passes
|
||||||
|
const startPass = paddleOCRVLAvailable ? 3 : 2;
|
||||||
|
for (let pass = startPass; pass <= maxPasses; pass++) {
|
||||||
|
try {
|
||||||
|
const transactions = await extractWithMiniCPM(images, `Pass ${pass} MiniCPM-V`);
|
||||||
|
const count = addResult(transactions, `Pass ${pass} MiniCPM-V`);
|
||||||
|
|
||||||
|
if (count >= 2) {
|
||||||
|
console.log(`[Consensus] Reached after ${pass} passes`);
|
||||||
|
return transactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Pass ${pass}] No consensus yet, trying again...`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`[Pass ${pass}] Error: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No consensus reached - return the most common result
|
||||||
|
let bestHash = '';
|
||||||
|
let bestCount = 0;
|
||||||
|
for (const [hash, count] of hashCounts) {
|
||||||
|
if (count > bestCount) {
|
||||||
|
bestCount = count;
|
||||||
|
bestHash = hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestHash) {
|
||||||
|
throw new Error('No valid results obtained');
|
||||||
|
}
|
||||||
|
|
||||||
|
const best = results.find((r) => r.hash === bestHash)!;
|
||||||
|
console.log(`[No consensus] Using most common result (${bestCount}/${maxPasses} passes)`);
|
||||||
|
return best.transactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare extracted transactions against expected
|
||||||
|
*/
|
||||||
|
function compareTransactions(
|
||||||
|
extracted: ITransaction[],
|
||||||
|
expected: ITransaction[]
|
||||||
|
): { matches: number; total: number; errors: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
let matches = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < expected.length; i++) {
|
||||||
|
const exp = expected[i];
|
||||||
|
const ext = extracted[i];
|
||||||
|
|
||||||
|
if (!ext) {
|
||||||
|
errors.push(`Missing transaction ${i}: ${exp.date} ${exp.counterparty}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateMatch = ext.date === exp.date;
|
||||||
|
const amountMatch = Math.abs(ext.amount - exp.amount) < 0.01;
|
||||||
|
|
||||||
|
if (dateMatch && amountMatch) {
|
||||||
|
matches++;
|
||||||
|
} else {
|
||||||
|
errors.push(
|
||||||
|
`Mismatch at ${i}: expected ${exp.date}/${exp.amount}, got ${ext.date}/${ext.amount}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extracted.length > expected.length) {
|
||||||
|
errors.push(`Extra transactions: ${extracted.length - expected.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { matches, total: expected.length, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all test cases (PDF + JSON pairs) in .nogit/
|
||||||
|
*/
|
||||||
|
function findTestCases(): Array<{ name: string; pdfPath: string; jsonPath: string }> {
|
||||||
|
const testDir = path.join(process.cwd(), '.nogit');
|
||||||
|
if (!fs.existsSync(testDir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(testDir);
|
||||||
|
const pdfFiles = files.filter((f: string) => f.endsWith('.pdf'));
|
||||||
|
const testCases: Array<{ name: string; pdfPath: string; jsonPath: string }> = [];
|
||||||
|
|
||||||
|
for (const pdf of pdfFiles) {
|
||||||
|
const baseName = pdf.replace('.pdf', '');
|
||||||
|
const jsonFile = `${baseName}.json`;
|
||||||
|
if (files.includes(jsonFile)) {
|
||||||
|
testCases.push({
|
||||||
|
name: baseName,
|
||||||
|
pdfPath: path.join(testDir, pdf),
|
||||||
|
jsonPath: path.join(testDir, jsonFile),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return testCases;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests
|
||||||
|
|
||||||
|
tap.test('setup: ensure Docker containers are running', async () => {
|
||||||
|
console.log('\n[Setup] Checking Docker containers...\n');
|
||||||
|
|
||||||
|
// Ensure PaddleOCR-VL is running (auto-detects GPU/CPU)
|
||||||
|
const paddleOk = await ensurePaddleOcrVl();
|
||||||
|
expect(paddleOk).toBeTrue();
|
||||||
|
|
||||||
|
// Ensure MiniCPM is running
|
||||||
|
const minicpmOk = await ensureMiniCpm();
|
||||||
|
expect(minicpmOk).toBeTrue();
|
||||||
|
|
||||||
|
console.log('\n[Setup] All containers ready!\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should have MiniCPM-V 4.5 model loaded', async () => {
|
||||||
|
const response = await fetch(`${OLLAMA_URL}/api/tags`);
|
||||||
|
const data = await response.json();
|
||||||
|
const modelNames = data.models.map((m: { name: string }) => m.name);
|
||||||
|
expect(modelNames.some((name: string) => name.includes('minicpm-v4.5'))).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should check PaddleOCR-VL availability', async () => {
|
||||||
|
const available = await isPaddleOCRVLAvailable();
|
||||||
|
console.log(`PaddleOCR-VL available: ${available}`);
|
||||||
|
expect(available).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dynamic test for each PDF/JSON pair
|
||||||
|
const testCases = findTestCases();
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
tap.test(`should extract transactions from ${testCase.name}`, async () => {
|
||||||
|
// Load expected transactions
|
||||||
|
const expected: ITransaction[] = JSON.parse(fs.readFileSync(testCase.jsonPath, 'utf-8'));
|
||||||
|
console.log(`\n=== ${testCase.name} ===`);
|
||||||
|
console.log(`Expected: ${expected.length} transactions`);
|
||||||
|
|
||||||
|
// Convert PDF to images
|
||||||
|
console.log('Converting PDF to images...');
|
||||||
|
const images = convertPdfToImages(testCase.pdfPath);
|
||||||
|
console.log(`Converted: ${images.length} pages\n`);
|
||||||
|
|
||||||
|
// Extract with dual-VLM consensus
|
||||||
|
const extracted = await extractWithConsensus(images);
|
||||||
|
console.log(`\nFinal: ${extracted.length} transactions`);
|
||||||
|
|
||||||
|
// Compare results
|
||||||
|
const result = compareTransactions(extracted, expected);
|
||||||
|
console.log(`Accuracy: ${result.matches}/${result.total}`);
|
||||||
|
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
console.log('Errors:');
|
||||||
|
result.errors.forEach((e) => console.log(` - ${e}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert high accuracy
|
||||||
|
const accuracy = result.matches / result.total;
|
||||||
|
expect(accuracy).toBeGreaterThan(0.95);
|
||||||
|
expect(extracted.length).toEqual(expected.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Bank statement extraction test using MiniCPM-V only (visual extraction)
|
||||||
|
*
|
||||||
|
* This tests MiniCPM-V's ability to extract bank transactions directly from images
|
||||||
|
* without any OCR augmentation.
|
||||||
|
*/
|
||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
import { ensureMiniCpm } from './helpers/docker.js';
|
||||||
|
|
||||||
|
// Service URL
|
||||||
const OLLAMA_URL = 'http://localhost:11434';
|
const OLLAMA_URL = 'http://localhost:11434';
|
||||||
const MODEL = 'openbmb/minicpm-v4.5:q8_0';
|
|
||||||
|
|
||||||
const EXTRACT_PROMPT = `You are a bank statement parser. Extract EVERY transaction from the table.
|
// Model
|
||||||
|
const MINICPM_MODEL = 'minicpm-v:latest';
|
||||||
|
|
||||||
|
// Prompt for MiniCPM-V visual extraction
|
||||||
|
const MINICPM_EXTRACT_PROMPT = `/nothink
|
||||||
|
You are a bank statement parser. Extract EVERY transaction from the table.
|
||||||
|
|
||||||
Read the Amount column carefully:
|
Read the Amount column carefully:
|
||||||
- "- 21,47 €" means DEBIT, output as: -21.47
|
- "- 21,47 €" means DEBIT, output as: -21.47
|
||||||
@@ -37,7 +49,7 @@ function convertPdfToImages(pdfPath: string): string[] {
|
|||||||
{ stdio: 'pipe' }
|
{ stdio: 'pipe' }
|
||||||
);
|
);
|
||||||
|
|
||||||
const files = fs.readdirSync(tempDir).filter((f) => f.endsWith('.png')).sort();
|
const files = fs.readdirSync(tempDir).filter((f: string) => f.endsWith('.png')).sort();
|
||||||
const images: string[] = [];
|
const images: string[] = [];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -53,12 +65,12 @@ function convertPdfToImages(pdfPath: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single extraction pass
|
* Extract using MiniCPM-V via Ollama
|
||||||
*/
|
*/
|
||||||
async function extractOnce(images: string[], passNum: number): Promise<ITransaction[]> {
|
async function extractWithMiniCPM(images: string[], passLabel: string): Promise<ITransaction[]> {
|
||||||
const payload = {
|
const payload = {
|
||||||
model: MODEL,
|
model: MINICPM_MODEL,
|
||||||
prompt: EXTRACT_PROMPT,
|
prompt: MINICPM_EXTRACT_PROMPT,
|
||||||
images,
|
images,
|
||||||
stream: true,
|
stream: true,
|
||||||
options: {
|
options: {
|
||||||
@@ -86,7 +98,7 @@ async function extractOnce(images: string[], passNum: number): Promise<ITransact
|
|||||||
let fullText = '';
|
let fullText = '';
|
||||||
let lineBuffer = '';
|
let lineBuffer = '';
|
||||||
|
|
||||||
console.log(`[Pass ${passNum}] Extracting...`);
|
console.log(`[${passLabel}] Extracting with MiniCPM-V...`);
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
@@ -102,7 +114,6 @@ async function extractOnce(images: string[], passNum: number): Promise<ITransact
|
|||||||
fullText += json.response;
|
fullText += json.response;
|
||||||
lineBuffer += json.response;
|
lineBuffer += json.response;
|
||||||
|
|
||||||
// Print complete lines
|
|
||||||
if (lineBuffer.includes('\n')) {
|
if (lineBuffer.includes('\n')) {
|
||||||
const parts = lineBuffer.split('\n');
|
const parts = lineBuffer.split('\n');
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
@@ -143,31 +154,40 @@ function hashTransactions(transactions: ITransaction[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract with majority voting - run until 2 passes match
|
* Extract with consensus voting using MiniCPM-V only
|
||||||
*/
|
*/
|
||||||
async function extractWithConsensus(images: string[], maxPasses: number = 5): Promise<ITransaction[]> {
|
async function extractWithConsensus(
|
||||||
|
images: string[],
|
||||||
|
maxPasses: number = 5
|
||||||
|
): Promise<ITransaction[]> {
|
||||||
const results: Array<{ transactions: ITransaction[]; hash: string }> = [];
|
const results: Array<{ transactions: ITransaction[]; hash: string }> = [];
|
||||||
const hashCounts: Map<string, number> = new Map();
|
const hashCounts: Map<string, number> = new Map();
|
||||||
|
|
||||||
for (let pass = 1; pass <= maxPasses; pass++) {
|
const addResult = (transactions: ITransaction[], passLabel: string): number => {
|
||||||
const transactions = await extractOnce(images, pass);
|
|
||||||
const hash = hashTransactions(transactions);
|
const hash = hashTransactions(transactions);
|
||||||
|
|
||||||
results.push({ transactions, hash });
|
results.push({ transactions, hash });
|
||||||
hashCounts.set(hash, (hashCounts.get(hash) || 0) + 1);
|
hashCounts.set(hash, (hashCounts.get(hash) || 0) + 1);
|
||||||
|
console.log(
|
||||||
|
`[${passLabel}] Got ${transactions.length} transactions (hash: ${hash.substring(0, 20)}...)`
|
||||||
|
);
|
||||||
|
return hashCounts.get(hash)!;
|
||||||
|
};
|
||||||
|
|
||||||
console.log(`[Pass ${pass}] Got ${transactions.length} transactions (hash: ${hash.substring(0, 20)}...)`);
|
console.log('[Setup] Using MiniCPM-V only');
|
||||||
|
|
||||||
// Check if we have consensus (2+ matching)
|
for (let pass = 1; pass <= maxPasses; pass++) {
|
||||||
const count = hashCounts.get(hash)!;
|
try {
|
||||||
if (count >= 2) {
|
const transactions = await extractWithMiniCPM(images, `Pass ${pass} MiniCPM-V`);
|
||||||
console.log(`[Consensus] Reached after ${pass} passes (${count} matching results)`);
|
const count = addResult(transactions, `Pass ${pass} MiniCPM-V`);
|
||||||
return transactions;
|
|
||||||
}
|
if (count >= 2) {
|
||||||
|
console.log(`[Consensus] Reached after ${pass} passes`);
|
||||||
|
return transactions;
|
||||||
|
}
|
||||||
|
|
||||||
// After 2 passes, if no match yet, continue
|
|
||||||
if (pass >= 2) {
|
|
||||||
console.log(`[Pass ${pass}] No consensus yet, trying again...`);
|
console.log(`[Pass ${pass}] No consensus yet, trying again...`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`[Pass ${pass}] Error: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +201,10 @@ async function extractWithConsensus(images: string[], maxPasses: number = 5): Pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!bestHash) {
|
||||||
|
throw new Error('No valid results obtained');
|
||||||
|
}
|
||||||
|
|
||||||
const best = results.find((r) => r.hash === bestHash)!;
|
const best = results.find((r) => r.hash === bestHash)!;
|
||||||
console.log(`[No consensus] Using most common result (${bestCount}/${maxPasses} passes)`);
|
console.log(`[No consensus] Using most common result (${bestCount}/${maxPasses} passes)`);
|
||||||
return best.transactions;
|
return best.transactions;
|
||||||
@@ -234,7 +258,7 @@ function findTestCases(): Array<{ name: string; pdfPath: string; jsonPath: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
const files = fs.readdirSync(testDir);
|
const files = fs.readdirSync(testDir);
|
||||||
const pdfFiles = files.filter((f) => f.endsWith('.pdf'));
|
const pdfFiles = files.filter((f: string) => f.endsWith('.pdf'));
|
||||||
const testCases: Array<{ name: string; pdfPath: string; jsonPath: string }> = [];
|
const testCases: Array<{ name: string; pdfPath: string; jsonPath: string }> = [];
|
||||||
|
|
||||||
for (const pdf of pdfFiles) {
|
for (const pdf of pdfFiles) {
|
||||||
@@ -254,11 +278,14 @@ function findTestCases(): Array<{ name: string; pdfPath: string; jsonPath: strin
|
|||||||
|
|
||||||
// Tests
|
// Tests
|
||||||
|
|
||||||
tap.test('should connect to Ollama API', async () => {
|
tap.test('setup: ensure Docker containers are running', async () => {
|
||||||
const response = await fetch(`${OLLAMA_URL}/api/tags`);
|
console.log('\n[Setup] Checking Docker containers...\n');
|
||||||
expect(response.ok).toBeTrue();
|
|
||||||
const data = await response.json();
|
// Ensure MiniCPM is running
|
||||||
expect(data.models).toBeArray();
|
const minicpmOk = await ensureMiniCpm();
|
||||||
|
expect(minicpmOk).toBeTrue();
|
||||||
|
|
||||||
|
console.log('\n[Setup] All containers ready!\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should have MiniCPM-V 4.5 model loaded', async () => {
|
tap.test('should have MiniCPM-V 4.5 model loaded', async () => {
|
||||||
@@ -270,6 +297,8 @@ tap.test('should have MiniCPM-V 4.5 model loaded', async () => {
|
|||||||
|
|
||||||
// Dynamic test for each PDF/JSON pair
|
// Dynamic test for each PDF/JSON pair
|
||||||
const testCases = findTestCases();
|
const testCases = findTestCases();
|
||||||
|
console.log(`\nFound ${testCases.length} bank statement test cases (MiniCPM-V only)\n`);
|
||||||
|
|
||||||
for (const testCase of testCases) {
|
for (const testCase of testCases) {
|
||||||
tap.test(`should extract transactions from ${testCase.name}`, async () => {
|
tap.test(`should extract transactions from ${testCase.name}`, async () => {
|
||||||
// Load expected transactions
|
// Load expected transactions
|
||||||
@@ -282,7 +311,7 @@ for (const testCase of testCases) {
|
|||||||
const images = convertPdfToImages(testCase.pdfPath);
|
const images = convertPdfToImages(testCase.pdfPath);
|
||||||
console.log(`Converted: ${images.length} pages\n`);
|
console.log(`Converted: ${images.length} pages\n`);
|
||||||
|
|
||||||
// Extract with consensus voting
|
// Extract with consensus (MiniCPM-V only)
|
||||||
const extracted = await extractWithConsensus(images);
|
const extracted = await extractWithConsensus(images);
|
||||||
console.log(`\nFinal: ${extracted.length} transactions`);
|
console.log(`\nFinal: ${extracted.length} transactions`);
|
||||||
|
|
||||||
348
test/test.bankstatements.ministral3.ts
Normal file
348
test/test.bankstatements.ministral3.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* Bank Statement extraction using Ministral 3 Vision (Direct)
|
||||||
|
*
|
||||||
|
* NO OCR pipeline needed - Ministral 3 has built-in vision encoder:
|
||||||
|
* 1. Convert PDF to images
|
||||||
|
* 2. Send images directly to Ministral 3 via Ollama
|
||||||
|
* 3. Extract transactions as structured JSON
|
||||||
|
*/
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { ensureMinistral3 } from './helpers/docker.js';
|
||||||
|
|
||||||
|
const OLLAMA_URL = 'http://localhost:11434';
|
||||||
|
const VISION_MODEL = 'ministral-3:8b';
|
||||||
|
|
||||||
|
interface ITransaction {
|
||||||
|
date: string;
|
||||||
|
counterparty: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert PDF to PNG images using ImageMagick
|
||||||
|
*/
|
||||||
|
function convertPdfToImages(pdfPath: string): string[] {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pdf-convert-'));
|
||||||
|
const outputPattern = path.join(tempDir, 'page-%d.png');
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`convert -density 200 -quality 90 "${pdfPath}" -background white -alpha remove "${outputPattern}"`,
|
||||||
|
{ stdio: 'pipe' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const files = fs.readdirSync(tempDir).filter((f) => f.endsWith('.png')).sort();
|
||||||
|
const images: string[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const imagePath = path.join(tempDir, file);
|
||||||
|
const imageData = fs.readFileSync(imagePath);
|
||||||
|
images.push(imageData.toString('base64'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return images;
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract transactions from a single page image using Ministral 3 Vision
|
||||||
|
*/
|
||||||
|
async function extractTransactionsFromPage(image: string, pageNum: number): Promise<ITransaction[]> {
|
||||||
|
console.log(` [Vision] Processing page ${pageNum}`);
|
||||||
|
|
||||||
|
// JSON schema for array of transactions
|
||||||
|
const transactionSchema = {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
date: { type: 'string', description: 'Transaction date in YYYY-MM-DD format' },
|
||||||
|
counterparty: { type: 'string', description: 'Name of the other party' },
|
||||||
|
amount: { type: 'number', description: 'Amount (negative for debits, positive for credits)' },
|
||||||
|
},
|
||||||
|
required: ['date', 'counterparty', 'amount'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const prompt = `Extract ALL bank transactions from this bank statement page.
|
||||||
|
|
||||||
|
For each transaction, extract:
|
||||||
|
- date: Transaction date in YYYY-MM-DD format
|
||||||
|
- counterparty: The name/description of the other party (merchant, payee, etc.)
|
||||||
|
- amount: The amount as a number (NEGATIVE for debits/expenses, POSITIVE for credits/income)
|
||||||
|
|
||||||
|
Return a JSON array of transactions. If no transactions visible, return empty array [].
|
||||||
|
Example: [{"date":"2021-06-01","counterparty":"AMAZON","amount":-50.00}]`;
|
||||||
|
|
||||||
|
const response = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: VISION_MODEL,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
images: [image],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
format: transactionSchema,
|
||||||
|
stream: true,
|
||||||
|
options: {
|
||||||
|
num_predict: 4096, // Bank statements can have many transactions
|
||||||
|
temperature: 0.0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Ollama API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error('No response body');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let fullText = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
const lines = chunk.split('\n').filter((l) => l.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(line);
|
||||||
|
if (json.message?.content) {
|
||||||
|
fullText += json.message.content;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON response
|
||||||
|
let jsonStr = fullText.trim();
|
||||||
|
|
||||||
|
if (jsonStr.startsWith('```json')) jsonStr = jsonStr.slice(7);
|
||||||
|
else if (jsonStr.startsWith('```')) jsonStr = jsonStr.slice(3);
|
||||||
|
if (jsonStr.endsWith('```')) jsonStr = jsonStr.slice(0, -3);
|
||||||
|
jsonStr = jsonStr.trim();
|
||||||
|
|
||||||
|
// Find array boundaries
|
||||||
|
const startIdx = jsonStr.indexOf('[');
|
||||||
|
const endIdx = jsonStr.lastIndexOf(']') + 1;
|
||||||
|
|
||||||
|
if (startIdx < 0 || endIdx <= startIdx) {
|
||||||
|
console.log(` [Page ${pageNum}] No transactions found`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonStr.substring(startIdx, endIdx));
|
||||||
|
console.log(` [Page ${pageNum}] Found ${parsed.length} transactions`);
|
||||||
|
return parsed.map((t: { date?: string; counterparty?: string; amount?: number }) => ({
|
||||||
|
date: t.date || '',
|
||||||
|
counterparty: t.counterparty || '',
|
||||||
|
amount: parseFloat(String(t.amount)) || 0,
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` [Page ${pageNum}] Parse error: ${e}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all transactions from all pages
|
||||||
|
*/
|
||||||
|
async function extractAllTransactions(images: string[]): Promise<ITransaction[]> {
|
||||||
|
const allTransactions: ITransaction[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
const pageTransactions = await extractTransactionsFromPage(images[i], i + 1);
|
||||||
|
allTransactions.push(...pageTransactions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allTransactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize date to YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
function normalizeDate(dateStr: string): string {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return dateStr;
|
||||||
|
|
||||||
|
// Handle DD/MM/YYYY or DD.MM.YYYY
|
||||||
|
const match = dateStr.match(/^(\d{1,2})[\/.](\d{1,2})[\/.](\d{4})$/);
|
||||||
|
if (match) {
|
||||||
|
return `${match[3]}-${match[2].padStart(2, '0')}-${match[1].padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare extracted transactions vs expected
|
||||||
|
*/
|
||||||
|
function compareTransactions(
|
||||||
|
extracted: ITransaction[],
|
||||||
|
expected: ITransaction[]
|
||||||
|
): { matchRate: number; matched: number; missed: number; extra: number; errors: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
let matched = 0;
|
||||||
|
|
||||||
|
// Normalize all dates
|
||||||
|
const normalizedExtracted = extracted.map((t) => ({
|
||||||
|
...t,
|
||||||
|
date: normalizeDate(t.date),
|
||||||
|
counterparty: t.counterparty.toUpperCase().trim(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const normalizedExpected = expected.map((t) => ({
|
||||||
|
...t,
|
||||||
|
date: normalizeDate(t.date),
|
||||||
|
counterparty: t.counterparty.toUpperCase().trim(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Try to match each expected transaction
|
||||||
|
const matchedIndices = new Set<number>();
|
||||||
|
|
||||||
|
for (const exp of normalizedExpected) {
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < normalizedExtracted.length; i++) {
|
||||||
|
if (matchedIndices.has(i)) continue;
|
||||||
|
|
||||||
|
const ext = normalizedExtracted[i];
|
||||||
|
|
||||||
|
// Match by date + amount (counterparty names can vary)
|
||||||
|
if (ext.date === exp.date && Math.abs(ext.amount - exp.amount) < 0.02) {
|
||||||
|
matched++;
|
||||||
|
matchedIndices.add(i);
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
errors.push(`Missing: ${exp.date} | ${exp.counterparty} | ${exp.amount}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missed = expected.length - matched;
|
||||||
|
const extra = extracted.length - matched;
|
||||||
|
const matchRate = expected.length > 0 ? (matched / expected.length) * 100 : 0;
|
||||||
|
|
||||||
|
return { matchRate, matched, missed, extra, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find test cases (PDF + JSON pairs in .nogit/)
|
||||||
|
*/
|
||||||
|
function findTestCases(): Array<{ name: string; pdfPath: string; jsonPath: string }> {
|
||||||
|
const testDir = path.join(process.cwd(), '.nogit');
|
||||||
|
if (!fs.existsSync(testDir)) return [];
|
||||||
|
|
||||||
|
const files = fs.readdirSync(testDir);
|
||||||
|
const testCases: Array<{ name: string; pdfPath: string; jsonPath: string }> = [];
|
||||||
|
|
||||||
|
for (const pdf of files.filter((f) => f.endsWith('.pdf'))) {
|
||||||
|
const baseName = pdf.replace('.pdf', '');
|
||||||
|
const jsonFile = `${baseName}.json`;
|
||||||
|
if (files.includes(jsonFile)) {
|
||||||
|
// Skip invoice files - only bank statements
|
||||||
|
if (!baseName.includes('invoice')) {
|
||||||
|
testCases.push({
|
||||||
|
name: baseName,
|
||||||
|
pdfPath: path.join(testDir, pdf),
|
||||||
|
jsonPath: path.join(testDir, jsonFile),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return testCases.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests
|
||||||
|
|
||||||
|
tap.test('setup: ensure Ministral 3 is running', async () => {
|
||||||
|
console.log('\n[Setup] Checking Ministral 3...\n');
|
||||||
|
const ok = await ensureMinistral3();
|
||||||
|
expect(ok).toBeTrue();
|
||||||
|
console.log('\n[Setup] Ready!\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCases = findTestCases();
|
||||||
|
console.log(`\nFound ${testCases.length} bank statement test cases (Ministral 3 Vision)\n`);
|
||||||
|
|
||||||
|
let totalMatched = 0;
|
||||||
|
let totalExpected = 0;
|
||||||
|
const times: number[] = [];
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
tap.test(`should extract bank statement: ${testCase.name}`, async () => {
|
||||||
|
const expected: ITransaction[] = JSON.parse(fs.readFileSync(testCase.jsonPath, 'utf-8'));
|
||||||
|
console.log(`\n=== ${testCase.name} ===`);
|
||||||
|
console.log(`Expected: ${expected.length} transactions`);
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
const images = convertPdfToImages(testCase.pdfPath);
|
||||||
|
console.log(` Pages: ${images.length}`);
|
||||||
|
|
||||||
|
const extracted = await extractAllTransactions(images);
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
times.push(elapsed);
|
||||||
|
|
||||||
|
console.log(` Extracted: ${extracted.length} transactions`);
|
||||||
|
|
||||||
|
const result = compareTransactions(extracted, expected);
|
||||||
|
totalMatched += result.matched;
|
||||||
|
totalExpected += expected.length;
|
||||||
|
|
||||||
|
console.log(` Match rate: ${result.matchRate.toFixed(1)}% (${result.matched}/${expected.length})`);
|
||||||
|
console.log(` Missed: ${result.missed}, Extra: ${result.extra}`);
|
||||||
|
console.log(` Time: ${(elapsed / 1000).toFixed(1)}s`);
|
||||||
|
|
||||||
|
if (result.errors.length > 0 && result.errors.length <= 5) {
|
||||||
|
result.errors.forEach((e) => console.log(` - ${e}`));
|
||||||
|
} else if (result.errors.length > 5) {
|
||||||
|
console.log(` (${result.errors.length} missing transactions)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consider it a pass if we match at least 70% of transactions
|
||||||
|
expect(result.matchRate).toBeGreaterThan(70);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('summary', async () => {
|
||||||
|
const overallMatchRate = totalExpected > 0 ? (totalMatched / totalExpected) * 100 : 0;
|
||||||
|
const totalTime = times.reduce((a, b) => a + b, 0) / 1000;
|
||||||
|
const avgTime = times.length > 0 ? totalTime / times.length : 0;
|
||||||
|
|
||||||
|
console.log(`\n======================================================`);
|
||||||
|
console.log(` Bank Statement Extraction Summary (Ministral 3)`);
|
||||||
|
console.log(`======================================================`);
|
||||||
|
console.log(` Method: Ministral 3 8B Vision (Direct)`);
|
||||||
|
console.log(` Statements: ${testCases.length}`);
|
||||||
|
console.log(` Matched: ${totalMatched}/${totalExpected} transactions`);
|
||||||
|
console.log(` Match rate: ${overallMatchRate.toFixed(1)}%`);
|
||||||
|
console.log(`------------------------------------------------------`);
|
||||||
|
console.log(` Total time: ${totalTime.toFixed(1)}s`);
|
||||||
|
console.log(` Avg per stmt: ${avgTime.toFixed(1)}s`);
|
||||||
|
console.log(`======================================================\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
346
test/test.bankstatements.paddleocr-vl.ts
Normal file
346
test/test.bankstatements.paddleocr-vl.ts
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
/**
|
||||||
|
* Bank statement extraction test using PaddleOCR-VL Full Pipeline
|
||||||
|
*
|
||||||
|
* This tests the complete PaddleOCR-VL pipeline for bank statements:
|
||||||
|
* 1. PP-DocLayoutV2 for layout detection
|
||||||
|
* 2. PaddleOCR-VL for recognition (tables with proper structure)
|
||||||
|
* 3. Structured Markdown output with tables
|
||||||
|
* 4. MiniCPM extracts transactions from structured tables
|
||||||
|
*
|
||||||
|
* The structured Markdown has properly formatted tables,
|
||||||
|
* making it much easier for MiniCPM to extract transaction data.
|
||||||
|
*/
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { ensurePaddleOcrVlFull, ensureMiniCpm } from './helpers/docker.js';
|
||||||
|
|
||||||
|
const PADDLEOCR_VL_URL = 'http://localhost:8000';
|
||||||
|
const OLLAMA_URL = 'http://localhost:11434';
|
||||||
|
const MINICPM_MODEL = 'minicpm-v:latest';
|
||||||
|
|
||||||
|
interface ITransaction {
|
||||||
|
date: string;
|
||||||
|
counterparty: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert PDF to PNG images using ImageMagick
|
||||||
|
*/
|
||||||
|
function convertPdfToImages(pdfPath: string): string[] {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pdf-convert-'));
|
||||||
|
const outputPattern = path.join(tempDir, 'page-%d.png');
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`convert -density 300 -quality 100 "${pdfPath}" -background white -alpha remove "${outputPattern}"`,
|
||||||
|
{ stdio: 'pipe' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const files = fs.readdirSync(tempDir).filter((f: string) => f.endsWith('.png')).sort();
|
||||||
|
const images: string[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const imagePath = path.join(tempDir, file);
|
||||||
|
const imageData = fs.readFileSync(imagePath);
|
||||||
|
images.push(imageData.toString('base64'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return images;
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse document using PaddleOCR-VL Full Pipeline (returns structured Markdown)
|
||||||
|
*/
|
||||||
|
async function parseDocument(imageBase64: string): Promise<string> {
|
||||||
|
const response = await fetch(`${PADDLEOCR_VL_URL}/parse`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
image: imageBase64,
|
||||||
|
output_format: 'markdown',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`PaddleOCR-VL API error: ${response.status} - ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(`PaddleOCR-VL error: ${data.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.result?.markdown || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract transactions from structured Markdown using MiniCPM
|
||||||
|
*/
|
||||||
|
async function extractTransactionsFromMarkdown(markdown: string): Promise<ITransaction[]> {
|
||||||
|
console.log(` [Extract] Processing ${markdown.length} chars of Markdown`);
|
||||||
|
|
||||||
|
const prompt = `/nothink
|
||||||
|
Convert this bank statement to a JSON array of transactions.
|
||||||
|
|
||||||
|
Read the Amount values carefully:
|
||||||
|
- "- 21,47 €" means DEBIT, output as: -21.47
|
||||||
|
- "+ 1.000,00 €" means CREDIT, output as: 1000.00
|
||||||
|
- European format: comma = decimal point, dot = thousands
|
||||||
|
|
||||||
|
For each transaction output: {"date":"YYYY-MM-DD","counterparty":"NAME","amount":-21.47}
|
||||||
|
|
||||||
|
Return ONLY the JSON array, no explanation.
|
||||||
|
|
||||||
|
Document:
|
||||||
|
${markdown}`;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
model: MINICPM_MODEL,
|
||||||
|
prompt,
|
||||||
|
stream: true,
|
||||||
|
options: {
|
||||||
|
num_predict: 16384,
|
||||||
|
temperature: 0.1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Ollama API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error('No response body');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let fullText = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
const lines = chunk.split('\n').filter((l) => l.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(line);
|
||||||
|
if (json.response) {
|
||||||
|
fullText += json.response;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract JSON array from response
|
||||||
|
const startIdx = fullText.indexOf('[');
|
||||||
|
const endIdx = fullText.lastIndexOf(']') + 1;
|
||||||
|
|
||||||
|
if (startIdx < 0 || endIdx <= startIdx) {
|
||||||
|
throw new Error(`No JSON array found in response: ${fullText.substring(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonStr = fullText.substring(startIdx, endIdx);
|
||||||
|
return JSON.parse(jsonStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract transactions from all pages of a bank statement
|
||||||
|
*/
|
||||||
|
async function extractAllTransactions(images: string[]): Promise<ITransaction[]> {
|
||||||
|
const allTransactions: ITransaction[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
console.log(` Processing page ${i + 1}/${images.length}...`);
|
||||||
|
|
||||||
|
// Parse with full pipeline
|
||||||
|
const markdown = await parseDocument(images[i]);
|
||||||
|
console.log(` [Parse] Got ${markdown.split('\n').length} lines of Markdown`);
|
||||||
|
|
||||||
|
// Extract transactions
|
||||||
|
try {
|
||||||
|
const transactions = await extractTransactionsFromMarkdown(markdown);
|
||||||
|
console.log(` [Extracted] ${transactions.length} transactions`);
|
||||||
|
allTransactions.push(...transactions);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` [Error] ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allTransactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare transactions - find matching transaction in expected list
|
||||||
|
*/
|
||||||
|
function findMatchingTransaction(
|
||||||
|
tx: ITransaction,
|
||||||
|
expectedList: ITransaction[]
|
||||||
|
): ITransaction | undefined {
|
||||||
|
return expectedList.find((exp) => {
|
||||||
|
const dateMatch = tx.date === exp.date;
|
||||||
|
const amountMatch = Math.abs(tx.amount - exp.amount) < 0.02;
|
||||||
|
const counterpartyMatch =
|
||||||
|
tx.counterparty?.toLowerCase().includes(exp.counterparty?.toLowerCase().slice(0, 10)) ||
|
||||||
|
exp.counterparty?.toLowerCase().includes(tx.counterparty?.toLowerCase().slice(0, 10));
|
||||||
|
return dateMatch && amountMatch && counterpartyMatch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate extraction accuracy
|
||||||
|
*/
|
||||||
|
function calculateAccuracy(
|
||||||
|
extracted: ITransaction[],
|
||||||
|
expected: ITransaction[]
|
||||||
|
): { matched: number; total: number; accuracy: number } {
|
||||||
|
let matched = 0;
|
||||||
|
const usedExpected = new Set<number>();
|
||||||
|
|
||||||
|
for (const tx of extracted) {
|
||||||
|
for (let i = 0; i < expected.length; i++) {
|
||||||
|
if (usedExpected.has(i)) continue;
|
||||||
|
|
||||||
|
const exp = expected[i];
|
||||||
|
const dateMatch = tx.date === exp.date;
|
||||||
|
const amountMatch = Math.abs(tx.amount - exp.amount) < 0.02;
|
||||||
|
|
||||||
|
if (dateMatch && amountMatch) {
|
||||||
|
matched++;
|
||||||
|
usedExpected.add(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
total: expected.length,
|
||||||
|
accuracy: expected.length > 0 ? (matched / expected.length) * 100 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all test cases (PDF + JSON pairs) in .nogit/bankstatements/
|
||||||
|
*/
|
||||||
|
function findTestCases(): Array<{ name: string; pdfPath: string; jsonPath: string }> {
|
||||||
|
const testDir = path.join(process.cwd(), '.nogit/bankstatements');
|
||||||
|
if (!fs.existsSync(testDir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(testDir);
|
||||||
|
const pdfFiles = files.filter((f) => f.endsWith('.pdf'));
|
||||||
|
const testCases: Array<{ name: string; pdfPath: string; jsonPath: string }> = [];
|
||||||
|
|
||||||
|
for (const pdf of pdfFiles) {
|
||||||
|
const baseName = pdf.replace('.pdf', '');
|
||||||
|
const jsonFile = `${baseName}.json`;
|
||||||
|
if (files.includes(jsonFile)) {
|
||||||
|
testCases.push({
|
||||||
|
name: baseName,
|
||||||
|
pdfPath: path.join(testDir, pdf),
|
||||||
|
jsonPath: path.join(testDir, jsonFile),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testCases.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return testCases;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests
|
||||||
|
|
||||||
|
tap.test('setup: ensure Docker containers are running', async () => {
|
||||||
|
console.log('\n[Setup] Checking Docker containers...\n');
|
||||||
|
|
||||||
|
// Ensure PaddleOCR-VL Full Pipeline is running
|
||||||
|
const paddleOk = await ensurePaddleOcrVlFull();
|
||||||
|
expect(paddleOk).toBeTrue();
|
||||||
|
|
||||||
|
// Ensure MiniCPM is running (for field extraction from Markdown)
|
||||||
|
const minicpmOk = await ensureMiniCpm();
|
||||||
|
expect(minicpmOk).toBeTrue();
|
||||||
|
|
||||||
|
console.log('\n[Setup] All containers ready!\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dynamic test for each PDF/JSON pair
|
||||||
|
const testCases = findTestCases();
|
||||||
|
console.log(`\nFound ${testCases.length} bank statement test cases (PaddleOCR-VL Full Pipeline)\n`);
|
||||||
|
|
||||||
|
const results: Array<{ name: string; accuracy: number; matched: number; total: number }> = [];
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
tap.test(`should extract bank statement: ${testCase.name}`, async () => {
|
||||||
|
// Load expected data
|
||||||
|
const expected: ITransaction[] = JSON.parse(fs.readFileSync(testCase.jsonPath, 'utf-8'));
|
||||||
|
console.log(`\n=== ${testCase.name} ===`);
|
||||||
|
console.log(`Expected: ${expected.length} transactions`);
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Convert PDF to images
|
||||||
|
const images = convertPdfToImages(testCase.pdfPath);
|
||||||
|
console.log(` Pages: ${images.length}`);
|
||||||
|
|
||||||
|
// Extract all transactions
|
||||||
|
const extracted = await extractAllTransactions(images);
|
||||||
|
|
||||||
|
const endTime = Date.now();
|
||||||
|
const elapsedMs = endTime - startTime;
|
||||||
|
|
||||||
|
// Calculate accuracy
|
||||||
|
const accuracy = calculateAccuracy(extracted, expected);
|
||||||
|
results.push({
|
||||||
|
name: testCase.name,
|
||||||
|
accuracy: accuracy.accuracy,
|
||||||
|
matched: accuracy.matched,
|
||||||
|
total: accuracy.total,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` Extracted: ${extracted.length} transactions`);
|
||||||
|
console.log(` Matched: ${accuracy.matched}/${accuracy.total} (${accuracy.accuracy.toFixed(1)}%)`);
|
||||||
|
console.log(` Time: ${(elapsedMs / 1000).toFixed(1)}s`);
|
||||||
|
|
||||||
|
// We expect at least 50% accuracy
|
||||||
|
expect(accuracy.accuracy).toBeGreaterThan(50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('summary', async () => {
|
||||||
|
const totalStatements = results.length;
|
||||||
|
const avgAccuracy =
|
||||||
|
results.length > 0 ? results.reduce((a, b) => a + b.accuracy, 0) / results.length : 0;
|
||||||
|
const totalMatched = results.reduce((a, b) => a + b.matched, 0);
|
||||||
|
const totalExpected = results.reduce((a, b) => a + b.total, 0);
|
||||||
|
|
||||||
|
console.log(`\n======================================================`);
|
||||||
|
console.log(` Bank Statement Extraction Summary (PaddleOCR-VL Full)`);
|
||||||
|
console.log(`======================================================`);
|
||||||
|
console.log(` Method: PaddleOCR-VL Full Pipeline -> MiniCPM`);
|
||||||
|
console.log(` Statements: ${totalStatements}`);
|
||||||
|
console.log(` Transactions: ${totalMatched}/${totalExpected} matched`);
|
||||||
|
console.log(` Avg accuracy: ${avgAccuracy.toFixed(1)}%`);
|
||||||
|
console.log(`======================================================\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
455
test/test.invoices.combined.ts
Normal file
455
test/test.invoices.combined.ts
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
/**
|
||||||
|
* Invoice extraction test using MiniCPM-V (visual) + PaddleOCR-VL (OCR augmentation)
|
||||||
|
*
|
||||||
|
* This is the combined approach that uses both models for best accuracy:
|
||||||
|
* - MiniCPM-V for visual understanding
|
||||||
|
* - PaddleOCR-VL for OCR text to augment prompts
|
||||||
|
*/
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { ensurePaddleOcrVl, ensureMiniCpm } from './helpers/docker.js';
|
||||||
|
|
||||||
|
const OLLAMA_URL = 'http://localhost:11434';
|
||||||
|
const MODEL = 'minicpm-v:latest';
|
||||||
|
const PADDLEOCR_VL_URL = 'http://localhost:8000';
|
||||||
|
|
||||||
|
interface IInvoice {
|
||||||
|
invoice_number: string;
|
||||||
|
invoice_date: string;
|
||||||
|
vendor_name: string;
|
||||||
|
currency: string;
|
||||||
|
net_amount: number;
|
||||||
|
vat_amount: number;
|
||||||
|
total_amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract OCR text from an image using PaddleOCR-VL (OpenAI-compatible API)
|
||||||
|
*/
|
||||||
|
async function extractOcrText(imageBase64: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${PADDLEOCR_VL_URL}/v1/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'paddleocr-vl',
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'image_url', image_url: { url: `data:image/png;base64,${imageBase64}` } },
|
||||||
|
{ type: 'text', text: 'OCR:' }
|
||||||
|
]
|
||||||
|
}],
|
||||||
|
temperature: 0.0,
|
||||||
|
max_tokens: 4096
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return '';
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.choices?.[0]?.message?.content || '';
|
||||||
|
} catch {
|
||||||
|
// PaddleOCR-VL unavailable
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build prompt with optional OCR text
|
||||||
|
*/
|
||||||
|
function buildPrompt(ocrText: string): string {
|
||||||
|
const base = `/nothink
|
||||||
|
You are an invoice parser. Extract the following fields from this invoice:
|
||||||
|
|
||||||
|
1. invoice_number: The invoice/receipt number
|
||||||
|
2. invoice_date: Date in YYYY-MM-DD format
|
||||||
|
3. vendor_name: Company that issued the invoice
|
||||||
|
4. currency: EUR, USD, etc.
|
||||||
|
5. net_amount: Amount before tax (if shown)
|
||||||
|
6. vat_amount: Tax/VAT amount (if shown, 0 if reverse charge or no tax)
|
||||||
|
7. total_amount: Final amount due
|
||||||
|
|
||||||
|
Return ONLY valid JSON in this exact format:
|
||||||
|
{"invoice_number":"XXX","invoice_date":"YYYY-MM-DD","vendor_name":"Company Name","currency":"EUR","net_amount":100.00,"vat_amount":19.00,"total_amount":119.00}
|
||||||
|
|
||||||
|
If a field is not visible, use null for strings or 0 for numbers.
|
||||||
|
No explanation, just the JSON object.`;
|
||||||
|
|
||||||
|
if (ocrText) {
|
||||||
|
// Limit OCR text to prevent context overflow
|
||||||
|
const maxOcrLength = 4000;
|
||||||
|
const truncatedOcr = ocrText.length > maxOcrLength
|
||||||
|
? ocrText.substring(0, maxOcrLength) + '\n... (truncated)'
|
||||||
|
: ocrText;
|
||||||
|
|
||||||
|
return `${base}
|
||||||
|
|
||||||
|
OCR text extracted from the invoice (use for reference):
|
||||||
|
---
|
||||||
|
${truncatedOcr}
|
||||||
|
---
|
||||||
|
|
||||||
|
Cross-reference the image with the OCR text above for accuracy.`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert PDF to PNG images using ImageMagick
|
||||||
|
*/
|
||||||
|
function convertPdfToImages(pdfPath: string): string[] {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pdf-convert-'));
|
||||||
|
const outputPattern = path.join(tempDir, 'page-%d.png');
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`convert -density 200 -quality 90 "${pdfPath}" -background white -alpha remove "${outputPattern}"`,
|
||||||
|
{ stdio: 'pipe' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const files = fs.readdirSync(tempDir).filter((f) => f.endsWith('.png')).sort();
|
||||||
|
const images: string[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const imagePath = path.join(tempDir, file);
|
||||||
|
const imageData = fs.readFileSync(imagePath);
|
||||||
|
images.push(imageData.toString('base64'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return images;
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single extraction pass
|
||||||
|
*/
|
||||||
|
async function extractOnce(images: string[], passNum: number, ocrText: string = ''): Promise<IInvoice> {
|
||||||
|
const payload = {
|
||||||
|
model: MODEL,
|
||||||
|
prompt: buildPrompt(ocrText),
|
||||||
|
images,
|
||||||
|
stream: true,
|
||||||
|
options: {
|
||||||
|
num_predict: 2048,
|
||||||
|
temperature: 0.1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Ollama API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error('No response body');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let fullText = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
const lines = chunk.split('\n').filter((l) => l.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(line);
|
||||||
|
if (json.response) {
|
||||||
|
fullText += json.response;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract JSON from response
|
||||||
|
const startIdx = fullText.indexOf('{');
|
||||||
|
const endIdx = fullText.lastIndexOf('}') + 1;
|
||||||
|
|
||||||
|
if (startIdx < 0 || endIdx <= startIdx) {
|
||||||
|
throw new Error(`No JSON object found in response: ${fullText.substring(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonStr = fullText.substring(startIdx, endIdx);
|
||||||
|
return JSON.parse(jsonStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a hash of invoice for comparison (using key fields)
|
||||||
|
*/
|
||||||
|
function hashInvoice(invoice: IInvoice): string {
|
||||||
|
return `${invoice.invoice_number}|${invoice.invoice_date}|${invoice.total_amount.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract with majority voting - run until 2 passes match
|
||||||
|
* Optimization: Run Pass 1, OCR, and Pass 2 (after OCR) in parallel
|
||||||
|
*/
|
||||||
|
async function extractWithConsensus(images: string[], invoiceName: string, maxPasses: number = 5): Promise<IInvoice> {
|
||||||
|
const results: Array<{ invoice: IInvoice; hash: string }> = [];
|
||||||
|
const hashCounts: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
const addResult = (invoice: IInvoice, passLabel: string): number => {
|
||||||
|
const hash = hashInvoice(invoice);
|
||||||
|
results.push({ invoice, hash });
|
||||||
|
hashCounts.set(hash, (hashCounts.get(hash) || 0) + 1);
|
||||||
|
console.log(` [${passLabel}] ${invoice.invoice_number} | ${invoice.invoice_date} | ${invoice.total_amount} ${invoice.currency}`);
|
||||||
|
return hashCounts.get(hash)!;
|
||||||
|
};
|
||||||
|
|
||||||
|
// OPTIMIZATION: Run Pass 1 (no OCR) in parallel with OCR -> Pass 2 (with OCR)
|
||||||
|
let ocrText = '';
|
||||||
|
const pass1Promise = extractOnce(images, 1, '').catch((err) => ({ error: err }));
|
||||||
|
|
||||||
|
// OCR then immediately Pass 2
|
||||||
|
const ocrThenPass2Promise = (async () => {
|
||||||
|
ocrText = await extractOcrText(images[0]);
|
||||||
|
if (ocrText) {
|
||||||
|
console.log(` [OCR] Extracted ${ocrText.split('\n').length} text lines`);
|
||||||
|
}
|
||||||
|
return extractOnce(images, 2, ocrText).catch((err) => ({ error: err }));
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Wait for both to complete
|
||||||
|
const [pass1Result, pass2Result] = await Promise.all([pass1Promise, ocrThenPass2Promise]);
|
||||||
|
|
||||||
|
// Process Pass 1 result
|
||||||
|
if ('error' in pass1Result) {
|
||||||
|
console.log(` [Pass 1] Error: ${(pass1Result as {error: unknown}).error}`);
|
||||||
|
} else {
|
||||||
|
const count = addResult(pass1Result as IInvoice, 'Pass 1');
|
||||||
|
if (count >= 2) {
|
||||||
|
console.log(` [Consensus] Reached after parallel passes`);
|
||||||
|
return pass1Result as IInvoice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process Pass 2 result
|
||||||
|
if ('error' in pass2Result) {
|
||||||
|
console.log(` [Pass 2+OCR] Error: ${(pass2Result as {error: unknown}).error}`);
|
||||||
|
} else {
|
||||||
|
const count = addResult(pass2Result as IInvoice, 'Pass 2+OCR');
|
||||||
|
if (count >= 2) {
|
||||||
|
console.log(` [Consensus] Reached after parallel passes`);
|
||||||
|
return pass2Result as IInvoice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue with passes 3+ using OCR text if no consensus yet
|
||||||
|
for (let pass = 3; pass <= maxPasses; pass++) {
|
||||||
|
try {
|
||||||
|
const invoice = await extractOnce(images, pass, ocrText);
|
||||||
|
const count = addResult(invoice, `Pass ${pass}+OCR`);
|
||||||
|
|
||||||
|
if (count >= 2) {
|
||||||
|
console.log(` [Consensus] Reached after ${pass} passes`);
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` [Pass ${pass}] Error: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No consensus reached - return the most common result
|
||||||
|
let bestHash = '';
|
||||||
|
let bestCount = 0;
|
||||||
|
for (const [hash, count] of hashCounts) {
|
||||||
|
if (count > bestCount) {
|
||||||
|
bestCount = count;
|
||||||
|
bestHash = hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestHash) {
|
||||||
|
throw new Error(`No valid results for ${invoiceName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const best = results.find((r) => r.hash === bestHash)!;
|
||||||
|
console.log(` [No consensus] Using most common result (${bestCount}/${maxPasses} passes)`);
|
||||||
|
return best.invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare extracted invoice against expected
|
||||||
|
*/
|
||||||
|
function compareInvoice(
|
||||||
|
extracted: IInvoice,
|
||||||
|
expected: IInvoice
|
||||||
|
): { match: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Compare invoice number (normalize by removing spaces and case)
|
||||||
|
const extNum = extracted.invoice_number?.replace(/\s/g, '').toLowerCase() || '';
|
||||||
|
const expNum = expected.invoice_number?.replace(/\s/g, '').toLowerCase() || '';
|
||||||
|
if (extNum !== expNum) {
|
||||||
|
errors.push(`invoice_number: expected "${expected.invoice_number}", got "${extracted.invoice_number}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare date
|
||||||
|
if (extracted.invoice_date !== expected.invoice_date) {
|
||||||
|
errors.push(`invoice_date: expected "${expected.invoice_date}", got "${extracted.invoice_date}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare total amount (with tolerance)
|
||||||
|
if (Math.abs(extracted.total_amount - expected.total_amount) > 0.02) {
|
||||||
|
errors.push(`total_amount: expected ${expected.total_amount}, got ${extracted.total_amount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare currency
|
||||||
|
if (extracted.currency?.toUpperCase() !== expected.currency?.toUpperCase()) {
|
||||||
|
errors.push(`currency: expected "${expected.currency}", got "${extracted.currency}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { match: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all test cases (PDF + JSON pairs) in .nogit/invoices/
|
||||||
|
* Priority invoices (like vodafone) run first for quick feedback
|
||||||
|
*/
|
||||||
|
function findTestCases(): Array<{ name: string; pdfPath: string; jsonPath: string }> {
|
||||||
|
const testDir = path.join(process.cwd(), '.nogit/invoices');
|
||||||
|
if (!fs.existsSync(testDir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(testDir);
|
||||||
|
const pdfFiles = files.filter((f) => f.endsWith('.pdf'));
|
||||||
|
const testCases: Array<{ name: string; pdfPath: string; jsonPath: string }> = [];
|
||||||
|
|
||||||
|
for (const pdf of pdfFiles) {
|
||||||
|
const baseName = pdf.replace('.pdf', '');
|
||||||
|
const jsonFile = `${baseName}.json`;
|
||||||
|
if (files.includes(jsonFile)) {
|
||||||
|
testCases.push({
|
||||||
|
name: baseName,
|
||||||
|
pdfPath: path.join(testDir, pdf),
|
||||||
|
jsonPath: path.join(testDir, jsonFile),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort with priority invoices first, then alphabetically
|
||||||
|
const priorityPrefixes = ['vodafone'];
|
||||||
|
testCases.sort((a, b) => {
|
||||||
|
const aPriority = priorityPrefixes.findIndex((p) => a.name.startsWith(p));
|
||||||
|
const bPriority = priorityPrefixes.findIndex((p) => b.name.startsWith(p));
|
||||||
|
|
||||||
|
// Both have priority - sort by priority order
|
||||||
|
if (aPriority >= 0 && bPriority >= 0) return aPriority - bPriority;
|
||||||
|
// Only a has priority - a comes first
|
||||||
|
if (aPriority >= 0) return -1;
|
||||||
|
// Only b has priority - b comes first
|
||||||
|
if (bPriority >= 0) return 1;
|
||||||
|
// Neither has priority - alphabetical
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return testCases;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests
|
||||||
|
|
||||||
|
tap.test('setup: ensure Docker containers are running', async () => {
|
||||||
|
console.log('\n[Setup] Checking Docker containers...\n');
|
||||||
|
|
||||||
|
// Ensure PaddleOCR-VL is running (auto-detects GPU/CPU)
|
||||||
|
const paddleOk = await ensurePaddleOcrVl();
|
||||||
|
expect(paddleOk).toBeTrue();
|
||||||
|
|
||||||
|
// Ensure MiniCPM is running
|
||||||
|
const minicpmOk = await ensureMiniCpm();
|
||||||
|
expect(minicpmOk).toBeTrue();
|
||||||
|
|
||||||
|
console.log('\n[Setup] All containers ready!\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should have MiniCPM-V 4.5 model loaded', async () => {
|
||||||
|
const response = await fetch(`${OLLAMA_URL}/api/tags`);
|
||||||
|
const data = await response.json();
|
||||||
|
const modelNames = data.models.map((m: { name: string }) => m.name);
|
||||||
|
expect(modelNames.some((name: string) => name.includes('minicpm-v4.5'))).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dynamic test for each PDF/JSON pair
|
||||||
|
const testCases = findTestCases();
|
||||||
|
console.log(`\nFound ${testCases.length} invoice test cases\n`);
|
||||||
|
|
||||||
|
let passedCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
const processingTimes: number[] = [];
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
tap.test(`should extract invoice: ${testCase.name}`, async () => {
|
||||||
|
// Load expected data
|
||||||
|
const expected: IInvoice = JSON.parse(fs.readFileSync(testCase.jsonPath, 'utf-8'));
|
||||||
|
console.log(`\n=== ${testCase.name} ===`);
|
||||||
|
console.log(`Expected: ${expected.invoice_number} | ${expected.invoice_date} | ${expected.total_amount} ${expected.currency}`);
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Convert PDF to images
|
||||||
|
const images = convertPdfToImages(testCase.pdfPath);
|
||||||
|
console.log(` Pages: ${images.length}`);
|
||||||
|
|
||||||
|
// Extract with consensus voting
|
||||||
|
const extracted = await extractWithConsensus(images, testCase.name);
|
||||||
|
|
||||||
|
const endTime = Date.now();
|
||||||
|
const elapsedMs = endTime - startTime;
|
||||||
|
processingTimes.push(elapsedMs);
|
||||||
|
|
||||||
|
// Compare results
|
||||||
|
const result = compareInvoice(extracted, expected);
|
||||||
|
|
||||||
|
if (result.match) {
|
||||||
|
passedCount++;
|
||||||
|
console.log(` Result: MATCH (${(elapsedMs / 1000).toFixed(1)}s)`);
|
||||||
|
} else {
|
||||||
|
failedCount++;
|
||||||
|
console.log(` Result: MISMATCH (${(elapsedMs / 1000).toFixed(1)}s)`);
|
||||||
|
result.errors.forEach((e) => console.log(` - ${e}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert match
|
||||||
|
expect(result.match).toBeTrue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('summary', async () => {
|
||||||
|
const totalInvoices = testCases.length;
|
||||||
|
const accuracy = totalInvoices > 0 ? (passedCount / totalInvoices) * 100 : 0;
|
||||||
|
const totalTimeMs = processingTimes.reduce((a, b) => a + b, 0);
|
||||||
|
const avgTimeMs = processingTimes.length > 0 ? totalTimeMs / processingTimes.length : 0;
|
||||||
|
const avgTimeSec = avgTimeMs / 1000;
|
||||||
|
const totalTimeSec = totalTimeMs / 1000;
|
||||||
|
|
||||||
|
console.log(`\n========================================`);
|
||||||
|
console.log(` Invoice Extraction Summary`);
|
||||||
|
console.log(`========================================`);
|
||||||
|
console.log(` Passed: ${passedCount}/${totalInvoices}`);
|
||||||
|
console.log(` Failed: ${failedCount}/${totalInvoices}`);
|
||||||
|
console.log(` Accuracy: ${accuracy.toFixed(1)}%`);
|
||||||
|
console.log(`----------------------------------------`);
|
||||||
|
console.log(` Total time: ${totalTimeSec.toFixed(1)}s`);
|
||||||
|
console.log(` Avg per inv: ${avgTimeSec.toFixed(1)}s`);
|
||||||
|
console.log(`========================================\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Invoice extraction test using MiniCPM-V only (visual extraction)
|
||||||
|
*
|
||||||
|
* This tests MiniCPM-V's ability to extract invoice data directly from images
|
||||||
|
* without any OCR augmentation.
|
||||||
|
*/
|
||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
import { ensureMiniCpm } from './helpers/docker.js';
|
||||||
|
|
||||||
const OLLAMA_URL = 'http://localhost:11434';
|
const OLLAMA_URL = 'http://localhost:11434';
|
||||||
const MODEL = 'openbmb/minicpm-v4.5:q8_0';
|
const MODEL = 'minicpm-v:latest';
|
||||||
const PADDLEOCR_URL = 'http://localhost:5000';
|
|
||||||
|
|
||||||
interface IInvoice {
|
interface IInvoice {
|
||||||
invoice_number: string;
|
invoice_number: string;
|
||||||
@@ -19,38 +25,11 @@ interface IInvoice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract OCR text from an image using PaddleOCR
|
* Build extraction prompt (MiniCPM-V only, no OCR augmentation)
|
||||||
*/
|
*/
|
||||||
async function extractOcrText(imageBase64: string): Promise<string> {
|
function buildPrompt(): string {
|
||||||
const formData = new FormData();
|
return `/nothink
|
||||||
const imageBuffer = Buffer.from(imageBase64, 'base64');
|
You are an invoice parser. Extract the following fields from this invoice:
|
||||||
const blob = new Blob([imageBuffer], { type: 'image/png' });
|
|
||||||
formData.append('img', blob, 'image.png');
|
|
||||||
formData.append('outtype', 'json');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${PADDLEOCR_URL}/ocr`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) return '';
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.success && data.results) {
|
|
||||||
return data.results.map((r: { text: string }) => r.text).join('\n');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// PaddleOCR unavailable
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build prompt with optional OCR text
|
|
||||||
*/
|
|
||||||
function buildPrompt(ocrText: string): string {
|
|
||||||
const base = `You are an invoice parser. Extract the following fields from this invoice:
|
|
||||||
|
|
||||||
1. invoice_number: The invoice/receipt number
|
1. invoice_number: The invoice/receipt number
|
||||||
2. invoice_date: Date in YYYY-MM-DD format
|
2. invoice_date: Date in YYYY-MM-DD format
|
||||||
@@ -65,18 +44,6 @@ Return ONLY valid JSON in this exact format:
|
|||||||
|
|
||||||
If a field is not visible, use null for strings or 0 for numbers.
|
If a field is not visible, use null for strings or 0 for numbers.
|
||||||
No explanation, just the JSON object.`;
|
No explanation, just the JSON object.`;
|
||||||
|
|
||||||
if (ocrText) {
|
|
||||||
return `${base}
|
|
||||||
|
|
||||||
OCR text extracted from the invoice:
|
|
||||||
---
|
|
||||||
${ocrText}
|
|
||||||
---
|
|
||||||
|
|
||||||
Cross-reference the image with the OCR text above for accuracy.`;
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,12 +75,12 @@ function convertPdfToImages(pdfPath: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single extraction pass
|
* Single extraction pass with MiniCPM-V
|
||||||
*/
|
*/
|
||||||
async function extractOnce(images: string[], passNum: number, ocrText: string = ''): Promise<IInvoice> {
|
async function extractOnce(images: string[], passNum: number): Promise<IInvoice> {
|
||||||
const payload = {
|
const payload = {
|
||||||
model: MODEL,
|
model: MODEL,
|
||||||
prompt: buildPrompt(ocrText),
|
prompt: buildPrompt(),
|
||||||
images,
|
images,
|
||||||
stream: true,
|
stream: true,
|
||||||
options: {
|
options: {
|
||||||
@@ -179,30 +146,25 @@ function hashInvoice(invoice: IInvoice): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract with majority voting - run until 2 passes match
|
* Extract with consensus voting using MiniCPM-V only
|
||||||
*/
|
*/
|
||||||
async function extractWithConsensus(images: string[], invoiceName: string, maxPasses: number = 5): Promise<IInvoice> {
|
async function extractWithConsensus(images: string[], invoiceName: string, maxPasses: number = 5): Promise<IInvoice> {
|
||||||
const results: Array<{ invoice: IInvoice; hash: string }> = [];
|
const results: Array<{ invoice: IInvoice; hash: string }> = [];
|
||||||
const hashCounts: Map<string, number> = new Map();
|
const hashCounts: Map<string, number> = new Map();
|
||||||
|
|
||||||
// Extract OCR text from first page
|
const addResult = (invoice: IInvoice, passLabel: string): number => {
|
||||||
const ocrText = await extractOcrText(images[0]);
|
const hash = hashInvoice(invoice);
|
||||||
if (ocrText) {
|
results.push({ invoice, hash });
|
||||||
console.log(` [OCR] Extracted ${ocrText.split('\n').length} text lines`);
|
hashCounts.set(hash, (hashCounts.get(hash) || 0) + 1);
|
||||||
}
|
console.log(` [${passLabel}] ${invoice.invoice_number} | ${invoice.invoice_date} | ${invoice.total_amount} ${invoice.currency}`);
|
||||||
|
return hashCounts.get(hash)!;
|
||||||
|
};
|
||||||
|
|
||||||
for (let pass = 1; pass <= maxPasses; pass++) {
|
for (let pass = 1; pass <= maxPasses; pass++) {
|
||||||
try {
|
try {
|
||||||
const invoice = await extractOnce(images, pass, ocrText);
|
const invoice = await extractOnce(images, pass);
|
||||||
const hash = hashInvoice(invoice);
|
const count = addResult(invoice, `Pass ${pass}`);
|
||||||
|
|
||||||
results.push({ invoice, hash });
|
|
||||||
hashCounts.set(hash, (hashCounts.get(hash) || 0) + 1);
|
|
||||||
|
|
||||||
console.log(` [Pass ${pass}] ${invoice.invoice_number} | ${invoice.invoice_date} | ${invoice.total_amount} ${invoice.currency}`);
|
|
||||||
|
|
||||||
// Check if we have consensus (2+ matching)
|
|
||||||
const count = hashCounts.get(hash)!;
|
|
||||||
if (count >= 2) {
|
if (count >= 2) {
|
||||||
console.log(` [Consensus] Reached after ${pass} passes`);
|
console.log(` [Consensus] Reached after ${pass} passes`);
|
||||||
return invoice;
|
return invoice;
|
||||||
@@ -290,16 +252,22 @@ function findTestCases(): Array<{ name: string; pdfPath: string; jsonPath: strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort alphabetically
|
||||||
|
testCases.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
return testCases;
|
return testCases;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tests
|
// Tests
|
||||||
|
|
||||||
tap.test('should connect to Ollama API', async () => {
|
tap.test('setup: ensure Docker containers are running', async () => {
|
||||||
const response = await fetch(`${OLLAMA_URL}/api/tags`);
|
console.log('\n[Setup] Checking Docker containers...\n');
|
||||||
expect(response.ok).toBeTrue();
|
|
||||||
const data = await response.json();
|
// Ensure MiniCPM is running
|
||||||
expect(data.models).toBeArray();
|
const minicpmOk = await ensureMiniCpm();
|
||||||
|
expect(minicpmOk).toBeTrue();
|
||||||
|
|
||||||
|
console.log('\n[Setup] All containers ready!\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should have MiniCPM-V 4.5 model loaded', async () => {
|
tap.test('should have MiniCPM-V 4.5 model loaded', async () => {
|
||||||
@@ -311,7 +279,7 @@ tap.test('should have MiniCPM-V 4.5 model loaded', async () => {
|
|||||||
|
|
||||||
// Dynamic test for each PDF/JSON pair
|
// Dynamic test for each PDF/JSON pair
|
||||||
const testCases = findTestCases();
|
const testCases = findTestCases();
|
||||||
console.log(`\nFound ${testCases.length} invoice test cases\n`);
|
console.log(`\nFound ${testCases.length} invoice test cases (MiniCPM-V only)\n`);
|
||||||
|
|
||||||
let passedCount = 0;
|
let passedCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
@@ -330,7 +298,7 @@ for (const testCase of testCases) {
|
|||||||
const images = convertPdfToImages(testCase.pdfPath);
|
const images = convertPdfToImages(testCase.pdfPath);
|
||||||
console.log(` Pages: ${images.length}`);
|
console.log(` Pages: ${images.length}`);
|
||||||
|
|
||||||
// Extract with consensus voting
|
// Extract with consensus voting (MiniCPM-V only)
|
||||||
const extracted = await extractWithConsensus(images, testCase.name);
|
const extracted = await extractWithConsensus(images, testCase.name);
|
||||||
|
|
||||||
const endTime = Date.now();
|
const endTime = Date.now();
|
||||||
@@ -363,7 +331,7 @@ tap.test('summary', async () => {
|
|||||||
const totalTimeSec = totalTimeMs / 1000;
|
const totalTimeSec = totalTimeMs / 1000;
|
||||||
|
|
||||||
console.log(`\n========================================`);
|
console.log(`\n========================================`);
|
||||||
console.log(` Invoice Extraction Summary`);
|
console.log(` Invoice Extraction Summary (MiniCPM)`);
|
||||||
console.log(`========================================`);
|
console.log(`========================================`);
|
||||||
console.log(` Passed: ${passedCount}/${totalInvoices}`);
|
console.log(` Passed: ${passedCount}/${totalInvoices}`);
|
||||||
console.log(` Failed: ${failedCount}/${totalInvoices}`);
|
console.log(` Failed: ${failedCount}/${totalInvoices}`);
|
||||||
334
test/test.invoices.ministral3.ts
Normal file
334
test/test.invoices.ministral3.ts
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
/**
|
||||||
|
* Invoice extraction using Ministral 3 Vision (Direct)
|
||||||
|
*
|
||||||
|
* NO PaddleOCR needed - Ministral 3 has built-in vision encoder:
|
||||||
|
* 1. Convert PDF to images
|
||||||
|
* 2. Send images directly to Ministral 3 via Ollama
|
||||||
|
* 3. Extract structured JSON with native schema support
|
||||||
|
*
|
||||||
|
* This is the simplest possible pipeline.
|
||||||
|
*/
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { ensureMinistral3 } from './helpers/docker.js';
|
||||||
|
|
||||||
|
const OLLAMA_URL = 'http://localhost:11434';
|
||||||
|
const VISION_MODEL = 'ministral-3:8b';
|
||||||
|
|
||||||
|
interface IInvoice {
|
||||||
|
invoice_number: string;
|
||||||
|
invoice_date: string;
|
||||||
|
vendor_name: string;
|
||||||
|
currency: string;
|
||||||
|
net_amount: number;
|
||||||
|
vat_amount: number;
|
||||||
|
total_amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert PDF to PNG images using ImageMagick
|
||||||
|
*/
|
||||||
|
function convertPdfToImages(pdfPath: string): string[] {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pdf-convert-'));
|
||||||
|
const outputPattern = path.join(tempDir, 'page-%d.png');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// High quality conversion: 300 DPI, max quality, sharpen for better OCR
|
||||||
|
execSync(
|
||||||
|
`convert -density 300 -quality 100 "${pdfPath}" -background white -alpha remove -sharpen 0x1 "${outputPattern}"`,
|
||||||
|
{ stdio: 'pipe' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const files = fs.readdirSync(tempDir).filter((f) => f.endsWith('.png')).sort();
|
||||||
|
const images: string[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const imagePath = path.join(tempDir, file);
|
||||||
|
const imageData = fs.readFileSync(imagePath);
|
||||||
|
images.push(imageData.toString('base64'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return images;
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract invoice data directly from images using Ministral 3 Vision
|
||||||
|
*/
|
||||||
|
async function extractInvoiceFromImages(images: string[]): Promise<IInvoice> {
|
||||||
|
console.log(` [Vision] Processing ${images.length} page(s) with Ministral 3`);
|
||||||
|
|
||||||
|
// JSON schema for structured output
|
||||||
|
const invoiceSchema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
invoice_number: { type: 'string' },
|
||||||
|
invoice_date: { type: 'string' },
|
||||||
|
vendor_name: { type: 'string' },
|
||||||
|
currency: { type: 'string' },
|
||||||
|
net_amount: { type: 'number' },
|
||||||
|
vat_amount: { type: 'number' },
|
||||||
|
total_amount: { type: 'number' },
|
||||||
|
},
|
||||||
|
required: ['invoice_number', 'invoice_date', 'vendor_name', 'currency', 'net_amount', 'vat_amount', 'total_amount'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const prompt = `You are an expert invoice data extraction system. Carefully analyze this invoice document and extract the following fields with high precision.
|
||||||
|
|
||||||
|
INVOICE NUMBER:
|
||||||
|
- Look for labels: "Invoice No", "Invoice #", "Invoice Number", "Rechnung Nr", "Rechnungsnummer", "Document No", "Bill No", "Reference"
|
||||||
|
- Usually alphanumeric, often starts with letters (e.g., R0014359508, INV-2024-001)
|
||||||
|
- Located near the top of the invoice
|
||||||
|
|
||||||
|
INVOICE DATE:
|
||||||
|
- Look for labels: "Invoice Date", "Date", "Datum", "Rechnungsdatum", "Issue Date", "Bill Date"
|
||||||
|
- Convert ANY date format to YYYY-MM-DD (e.g., 14/10/2021 → 2021-10-14, Oct 14, 2021 → 2021-10-14)
|
||||||
|
- Usually near the invoice number
|
||||||
|
|
||||||
|
VENDOR NAME:
|
||||||
|
- The company ISSUING the invoice (not the recipient)
|
||||||
|
- Found in letterhead, logo area, or header - typically the largest/most prominent company name
|
||||||
|
- Examples: "Hetzner Online GmbH", "Adobe Inc", "DigitalOcean LLC"
|
||||||
|
|
||||||
|
CURRENCY:
|
||||||
|
- Detect from symbols: € = EUR, $ = USD, £ = GBP
|
||||||
|
- Or from text: "EUR", "USD", "GBP"
|
||||||
|
- Default to EUR if unclear
|
||||||
|
|
||||||
|
AMOUNTS (Critical - read carefully!):
|
||||||
|
- total_amount: The FINAL amount due/payable - look for "Total", "Grand Total", "Amount Due", "Balance Due", "Gesamtbetrag", "Endbetrag"
|
||||||
|
- net_amount: Subtotal BEFORE tax - look for "Subtotal", "Net", "Netto", "excl. VAT"
|
||||||
|
- vat_amount: Tax amount - look for "VAT", "Tax", "MwSt", "USt", "19%", "20%"
|
||||||
|
- For multi-page invoices: the FINAL totals are usually on the LAST page
|
||||||
|
|
||||||
|
Return ONLY valid JSON with the extracted values.`;
|
||||||
|
|
||||||
|
const response = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: VISION_MODEL,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
images: images, // Send all page images
|
||||||
|
},
|
||||||
|
],
|
||||||
|
format: invoiceSchema,
|
||||||
|
stream: true,
|
||||||
|
options: {
|
||||||
|
num_predict: 1024,
|
||||||
|
temperature: 0.0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Ollama API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error('No response body');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let fullText = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
const lines = chunk.split('\n').filter((l) => l.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(line);
|
||||||
|
if (json.message?.content) {
|
||||||
|
fullText += json.message.content;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON response
|
||||||
|
let jsonStr = fullText.trim();
|
||||||
|
|
||||||
|
if (jsonStr.startsWith('```json')) jsonStr = jsonStr.slice(7);
|
||||||
|
else if (jsonStr.startsWith('```')) jsonStr = jsonStr.slice(3);
|
||||||
|
if (jsonStr.endsWith('```')) jsonStr = jsonStr.slice(0, -3);
|
||||||
|
jsonStr = jsonStr.trim();
|
||||||
|
|
||||||
|
const startIdx = jsonStr.indexOf('{');
|
||||||
|
const endIdx = jsonStr.lastIndexOf('}') + 1;
|
||||||
|
|
||||||
|
if (startIdx < 0 || endIdx <= startIdx) {
|
||||||
|
throw new Error(`No JSON found: ${fullText.substring(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(jsonStr.substring(startIdx, endIdx));
|
||||||
|
|
||||||
|
return {
|
||||||
|
invoice_number: parsed.invoice_number || null,
|
||||||
|
invoice_date: parsed.invoice_date || null,
|
||||||
|
vendor_name: parsed.vendor_name || null,
|
||||||
|
currency: parsed.currency || 'EUR',
|
||||||
|
net_amount: parseFloat(parsed.net_amount) || 0,
|
||||||
|
vat_amount: parseFloat(parsed.vat_amount) || 0,
|
||||||
|
total_amount: parseFloat(parsed.total_amount) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize date to YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
function normalizeDate(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return dateStr;
|
||||||
|
|
||||||
|
const monthMap: Record<string, string> = {
|
||||||
|
JAN: '01', FEB: '02', MAR: '03', APR: '04', MAY: '05', JUN: '06',
|
||||||
|
JUL: '07', AUG: '08', SEP: '09', OCT: '10', NOV: '11', DEC: '12',
|
||||||
|
};
|
||||||
|
|
||||||
|
let match = dateStr.match(/^(\d{1,2})-([A-Z]{3})-(\d{4})$/i);
|
||||||
|
if (match) {
|
||||||
|
return `${match[3]}-${monthMap[match[2].toUpperCase()] || '01'}-${match[1].padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
match = dateStr.match(/^(\d{1,2})[\/.](\d{1,2})[\/.](\d{4})$/);
|
||||||
|
if (match) {
|
||||||
|
return `${match[3]}-${match[2].padStart(2, '0')}-${match[1].padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare extracted vs expected
|
||||||
|
*/
|
||||||
|
function compareInvoice(extracted: IInvoice, expected: IInvoice): { match: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
const extNum = extracted.invoice_number?.replace(/\s/g, '').toLowerCase() || '';
|
||||||
|
const expNum = expected.invoice_number?.replace(/\s/g, '').toLowerCase() || '';
|
||||||
|
if (extNum !== expNum) {
|
||||||
|
errors.push(`invoice_number: expected "${expected.invoice_number}", got "${extracted.invoice_number}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizeDate(extracted.invoice_date) !== normalizeDate(expected.invoice_date)) {
|
||||||
|
errors.push(`invoice_date: expected "${expected.invoice_date}", got "${extracted.invoice_date}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(extracted.total_amount - expected.total_amount) > 0.02) {
|
||||||
|
errors.push(`total_amount: expected ${expected.total_amount}, got ${extracted.total_amount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extracted.currency?.toUpperCase() !== expected.currency?.toUpperCase()) {
|
||||||
|
errors.push(`currency: expected "${expected.currency}", got "${extracted.currency}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { match: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find test cases
|
||||||
|
*/
|
||||||
|
function findTestCases(): Array<{ name: string; pdfPath: string; jsonPath: string }> {
|
||||||
|
const testDir = path.join(process.cwd(), '.nogit/invoices');
|
||||||
|
if (!fs.existsSync(testDir)) return [];
|
||||||
|
|
||||||
|
const files = fs.readdirSync(testDir);
|
||||||
|
const testCases: Array<{ name: string; pdfPath: string; jsonPath: string }> = [];
|
||||||
|
|
||||||
|
for (const pdf of files.filter((f) => f.endsWith('.pdf'))) {
|
||||||
|
const baseName = pdf.replace('.pdf', '');
|
||||||
|
const jsonFile = `${baseName}.json`;
|
||||||
|
if (files.includes(jsonFile)) {
|
||||||
|
testCases.push({
|
||||||
|
name: baseName,
|
||||||
|
pdfPath: path.join(testDir, pdf),
|
||||||
|
jsonPath: path.join(testDir, jsonFile),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return testCases.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests
|
||||||
|
|
||||||
|
tap.test('setup: ensure Ministral 3 is running', async () => {
|
||||||
|
console.log('\n[Setup] Checking Ministral 3...\n');
|
||||||
|
const ok = await ensureMinistral3();
|
||||||
|
expect(ok).toBeTrue();
|
||||||
|
console.log('\n[Setup] Ready!\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCases = findTestCases();
|
||||||
|
console.log(`\nFound ${testCases.length} invoice test cases (Ministral 3 Vision Direct)\n`);
|
||||||
|
|
||||||
|
let passedCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
const times: number[] = [];
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
tap.test(`should extract invoice: ${testCase.name}`, async () => {
|
||||||
|
const expected: IInvoice = JSON.parse(fs.readFileSync(testCase.jsonPath, 'utf-8'));
|
||||||
|
console.log(`\n=== ${testCase.name} ===`);
|
||||||
|
console.log(`Expected: ${expected.invoice_number} | ${expected.invoice_date} | ${expected.total_amount} ${expected.currency}`);
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
const images = convertPdfToImages(testCase.pdfPath);
|
||||||
|
console.log(` Pages: ${images.length}`);
|
||||||
|
|
||||||
|
const extracted = await extractInvoiceFromImages(images);
|
||||||
|
console.log(` Extracted: ${extracted.invoice_number} | ${extracted.invoice_date} | ${extracted.total_amount} ${extracted.currency}`);
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
times.push(elapsed);
|
||||||
|
|
||||||
|
const result = compareInvoice(extracted, expected);
|
||||||
|
|
||||||
|
if (result.match) {
|
||||||
|
passedCount++;
|
||||||
|
console.log(` Result: MATCH (${(elapsed / 1000).toFixed(1)}s)`);
|
||||||
|
} else {
|
||||||
|
failedCount++;
|
||||||
|
console.log(` Result: MISMATCH (${(elapsed / 1000).toFixed(1)}s)`);
|
||||||
|
result.errors.forEach((e) => console.log(` - ${e}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(result.match).toBeTrue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('summary', async () => {
|
||||||
|
const total = testCases.length;
|
||||||
|
const accuracy = total > 0 ? (passedCount / total) * 100 : 0;
|
||||||
|
const totalTime = times.reduce((a, b) => a + b, 0) / 1000;
|
||||||
|
const avgTime = times.length > 0 ? totalTime / times.length : 0;
|
||||||
|
|
||||||
|
console.log(`\n======================================================`);
|
||||||
|
console.log(` Invoice Extraction Summary (Ministral 3 Vision)`);
|
||||||
|
console.log(`======================================================`);
|
||||||
|
console.log(` Method: Ministral 3 8B Vision (Direct)`);
|
||||||
|
console.log(` Passed: ${passedCount}/${total}`);
|
||||||
|
console.log(` Failed: ${failedCount}/${total}`);
|
||||||
|
console.log(` Accuracy: ${accuracy.toFixed(1)}%`);
|
||||||
|
console.log(`------------------------------------------------------`);
|
||||||
|
console.log(` Total time: ${totalTime.toFixed(1)}s`);
|
||||||
|
console.log(` Avg per inv: ${avgTime.toFixed(1)}s`);
|
||||||
|
console.log(`======================================================\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
490
test/test.invoices.paddleocr-vl.ts
Normal file
490
test/test.invoices.paddleocr-vl.ts
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
/**
|
||||||
|
* Invoice extraction test using PaddleOCR-VL Full Pipeline
|
||||||
|
*
|
||||||
|
* This tests the complete PaddleOCR-VL pipeline:
|
||||||
|
* 1. PP-DocLayoutV2 for layout detection
|
||||||
|
* 2. PaddleOCR-VL for recognition
|
||||||
|
* 3. Structured HTML output (semantic tags with proper tables)
|
||||||
|
* 4. Qwen2.5 extracts invoice fields from structured HTML
|
||||||
|
*
|
||||||
|
* HTML output is used instead of Markdown because:
|
||||||
|
* - <table> tags are unambiguous (no parser variations)
|
||||||
|
* - LLMs are heavily trained on web/HTML data
|
||||||
|
* - Semantic tags (header, footer, section) provide clear structure
|
||||||
|
*/
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { ensurePaddleOcrVlFull, ensureQwen25 } from './helpers/docker.js';
|
||||||
|
|
||||||
|
const PADDLEOCR_VL_URL = 'http://localhost:8000';
|
||||||
|
const OLLAMA_URL = 'http://localhost:11434';
|
||||||
|
// Use Qwen2.5 for text-only JSON extraction (not MiniCPM which is vision-focused)
|
||||||
|
const TEXT_MODEL = 'qwen2.5:7b';
|
||||||
|
|
||||||
|
interface IInvoice {
|
||||||
|
invoice_number: string;
|
||||||
|
invoice_date: string;
|
||||||
|
vendor_name: string;
|
||||||
|
currency: string;
|
||||||
|
net_amount: number;
|
||||||
|
vat_amount: number;
|
||||||
|
total_amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert PDF to PNG images using ImageMagick
|
||||||
|
*/
|
||||||
|
function convertPdfToImages(pdfPath: string): string[] {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pdf-convert-'));
|
||||||
|
const outputPattern = path.join(tempDir, 'page-%d.png');
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`convert -density 200 -quality 90 "${pdfPath}" -background white -alpha remove "${outputPattern}"`,
|
||||||
|
{ stdio: 'pipe' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const files = fs.readdirSync(tempDir).filter((f) => f.endsWith('.png')).sort();
|
||||||
|
const images: string[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const imagePath = path.join(tempDir, file);
|
||||||
|
const imageData = fs.readFileSync(imagePath);
|
||||||
|
images.push(imageData.toString('base64'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return images;
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse document using PaddleOCR-VL Full Pipeline (returns structured HTML)
|
||||||
|
*/
|
||||||
|
async function parseDocument(imageBase64: string): Promise<string> {
|
||||||
|
const response = await fetch(`${PADDLEOCR_VL_URL}/parse`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
image: imageBase64,
|
||||||
|
output_format: 'html',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`PaddleOCR-VL API error: ${response.status} - ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(`PaddleOCR-VL error: ${data.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.result?.html || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract invoice fields using simple direct prompt
|
||||||
|
* The OCR output has clearly labeled fields - just ask the LLM to read them
|
||||||
|
*/
|
||||||
|
async function extractInvoiceFromHtml(html: string): Promise<IInvoice> {
|
||||||
|
// OCR output is already good - just truncate if too long
|
||||||
|
const truncated = html.length > 32000 ? html.slice(0, 32000) : html;
|
||||||
|
console.log(` [Extract] ${truncated.length} chars of HTML`);
|
||||||
|
|
||||||
|
// JSON schema for structured output
|
||||||
|
const invoiceSchema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
invoice_number: { type: 'string' },
|
||||||
|
invoice_date: { type: 'string' },
|
||||||
|
vendor_name: { type: 'string' },
|
||||||
|
currency: { type: 'string' },
|
||||||
|
net_amount: { type: 'number' },
|
||||||
|
vat_amount: { type: 'number' },
|
||||||
|
total_amount: { type: 'number' },
|
||||||
|
},
|
||||||
|
required: ['invoice_number', 'invoice_date', 'vendor_name', 'currency', 'net_amount', 'vat_amount', 'total_amount'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simple, direct prompt - the OCR output already has labeled fields
|
||||||
|
const systemPrompt = `You read invoice HTML and extract labeled fields. Return JSON only.`;
|
||||||
|
|
||||||
|
const userPrompt = `Extract from this invoice HTML:
|
||||||
|
- invoice_number: Find "Invoice no.", "Invoice #", "Invoice", "Rechnung", "Document No" and extract the value
|
||||||
|
- invoice_date: Find "Invoice date", "Date", "Datum" and convert to YYYY-MM-DD format
|
||||||
|
- vendor_name: The company name issuing the invoice (in header/letterhead)
|
||||||
|
- currency: EUR, USD, or GBP (look for € $ £ symbols or text)
|
||||||
|
- total_amount: Find "Total", "Grand Total", "Amount Due", "Gesamtbetrag" - the FINAL total amount
|
||||||
|
- net_amount: Amount before VAT/tax (Subtotal, Net)
|
||||||
|
- vat_amount: VAT/tax amount
|
||||||
|
|
||||||
|
HTML:
|
||||||
|
${truncated}
|
||||||
|
|
||||||
|
Return ONLY valid JSON: {"invoice_number":"...", "invoice_date":"YYYY-MM-DD", "vendor_name":"...", "currency":"EUR", "net_amount":0, "vat_amount":0, "total_amount":0}`;
|
||||||
|
|
||||||
|
const response = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: TEXT_MODEL,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userPrompt },
|
||||||
|
],
|
||||||
|
format: invoiceSchema,
|
||||||
|
stream: true,
|
||||||
|
options: { num_predict: 512, temperature: 0.0 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Ollama API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error('No response body');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let fullText = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
const lines = chunk.split('\n').filter((l) => l.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(line);
|
||||||
|
if (json.message?.content) {
|
||||||
|
fullText += json.message.content;
|
||||||
|
} else if (json.response) {
|
||||||
|
fullText += json.response;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract JSON from response
|
||||||
|
let jsonStr = fullText.trim();
|
||||||
|
|
||||||
|
// Remove markdown code block if present
|
||||||
|
if (jsonStr.startsWith('```json')) {
|
||||||
|
jsonStr = jsonStr.slice(7);
|
||||||
|
} else if (jsonStr.startsWith('```')) {
|
||||||
|
jsonStr = jsonStr.slice(3);
|
||||||
|
}
|
||||||
|
if (jsonStr.endsWith('```')) {
|
||||||
|
jsonStr = jsonStr.slice(0, -3);
|
||||||
|
}
|
||||||
|
jsonStr = jsonStr.trim();
|
||||||
|
|
||||||
|
// Find JSON object boundaries
|
||||||
|
const startIdx = jsonStr.indexOf('{');
|
||||||
|
const endIdx = jsonStr.lastIndexOf('}') + 1;
|
||||||
|
|
||||||
|
if (startIdx < 0 || endIdx <= startIdx) {
|
||||||
|
throw new Error(`No JSON object found in response: ${fullText.substring(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonStr = jsonStr.substring(startIdx, endIdx);
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(jsonStr);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Invalid JSON: ${jsonStr.substring(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize response to expected format
|
||||||
|
return {
|
||||||
|
invoice_number: parsed.invoice_number || null,
|
||||||
|
invoice_date: parsed.invoice_date || null,
|
||||||
|
vendor_name: parsed.vendor_name || null,
|
||||||
|
currency: parsed.currency || 'EUR',
|
||||||
|
net_amount: parseFloat(parsed.net_amount) || 0,
|
||||||
|
vat_amount: parseFloat(parsed.vat_amount) || 0,
|
||||||
|
total_amount: parseFloat(parsed.total_amount) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single extraction pass: Parse with PaddleOCR-VL Full, extract with Qwen2.5 (text-only)
|
||||||
|
* Processes ALL pages and concatenates HTML for multi-page invoice support
|
||||||
|
*/
|
||||||
|
async function extractOnce(images: string[], passNum: number): Promise<IInvoice> {
|
||||||
|
// Parse ALL pages and concatenate HTML with page markers
|
||||||
|
const htmlParts: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
const pageHtml = await parseDocument(images[i]);
|
||||||
|
// Add page marker for context
|
||||||
|
htmlParts.push(`<!-- Page ${i + 1} -->\n${pageHtml}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullHtml = htmlParts.join('\n\n');
|
||||||
|
console.log(` [Parse] Got ${fullHtml.split('\n').length} lines from ${images.length} page(s)`);
|
||||||
|
|
||||||
|
// Extract invoice fields from HTML using text-only model (no images)
|
||||||
|
return extractInvoiceFromHtml(fullHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a hash of invoice for comparison (using key fields)
|
||||||
|
*/
|
||||||
|
function hashInvoice(invoice: IInvoice): string {
|
||||||
|
// Ensure total_amount is a number
|
||||||
|
const amount = typeof invoice.total_amount === 'number'
|
||||||
|
? invoice.total_amount.toFixed(2)
|
||||||
|
: String(invoice.total_amount || 0);
|
||||||
|
return `${invoice.invoice_number}|${invoice.invoice_date}|${amount}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract with consensus voting
|
||||||
|
*/
|
||||||
|
async function extractWithConsensus(images: string[], invoiceName: string, maxPasses: number = 5): Promise<IInvoice> {
|
||||||
|
const results: Array<{ invoice: IInvoice; hash: string }> = [];
|
||||||
|
const hashCounts: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
const addResult = (invoice: IInvoice, passLabel: string): number => {
|
||||||
|
const hash = hashInvoice(invoice);
|
||||||
|
results.push({ invoice, hash });
|
||||||
|
hashCounts.set(hash, (hashCounts.get(hash) || 0) + 1);
|
||||||
|
console.log(` [${passLabel}] ${invoice.invoice_number} | ${invoice.invoice_date} | ${invoice.total_amount} ${invoice.currency}`);
|
||||||
|
return hashCounts.get(hash)!;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let pass = 1; pass <= maxPasses; pass++) {
|
||||||
|
try {
|
||||||
|
const invoice = await extractOnce(images, pass);
|
||||||
|
const count = addResult(invoice, `Pass ${pass}`);
|
||||||
|
|
||||||
|
if (count >= 2) {
|
||||||
|
console.log(` [Consensus] Reached after ${pass} passes`);
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` [Pass ${pass}] Error: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No consensus reached - return the most common result
|
||||||
|
let bestHash = '';
|
||||||
|
let bestCount = 0;
|
||||||
|
for (const [hash, count] of hashCounts) {
|
||||||
|
if (count > bestCount) {
|
||||||
|
bestCount = count;
|
||||||
|
bestHash = hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestHash) {
|
||||||
|
throw new Error(`No valid results for ${invoiceName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const best = results.find((r) => r.hash === bestHash)!;
|
||||||
|
console.log(` [No consensus] Using most common result (${bestCount}/${maxPasses} passes)`);
|
||||||
|
return best.invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize date to YYYY-MM-DD format
|
||||||
|
*/
|
||||||
|
function normalizeDate(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
|
||||||
|
// Already in correct format
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle DD-MMM-YYYY format (e.g., "28-JUN-2022")
|
||||||
|
const monthMap: Record<string, string> = {
|
||||||
|
JAN: '01', FEB: '02', MAR: '03', APR: '04', MAY: '05', JUN: '06',
|
||||||
|
JUL: '07', AUG: '08', SEP: '09', OCT: '10', NOV: '11', DEC: '12',
|
||||||
|
};
|
||||||
|
|
||||||
|
const match = dateStr.match(/^(\d{1,2})-([A-Z]{3})-(\d{4})$/i);
|
||||||
|
if (match) {
|
||||||
|
const day = match[1].padStart(2, '0');
|
||||||
|
const month = monthMap[match[2].toUpperCase()] || '01';
|
||||||
|
const year = match[3];
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle DD/MM/YYYY or DD.MM.YYYY
|
||||||
|
const match2 = dateStr.match(/^(\d{1,2})[\/.](\d{1,2})[\/.](\d{4})$/);
|
||||||
|
if (match2) {
|
||||||
|
const day = match2[1].padStart(2, '0');
|
||||||
|
const month = match2[2].padStart(2, '0');
|
||||||
|
const year = match2[3];
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare extracted invoice against expected
|
||||||
|
*/
|
||||||
|
function compareInvoice(
|
||||||
|
extracted: IInvoice,
|
||||||
|
expected: IInvoice
|
||||||
|
): { match: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Compare invoice number (normalize by removing spaces and case)
|
||||||
|
const extNum = extracted.invoice_number?.replace(/\s/g, '').toLowerCase() || '';
|
||||||
|
const expNum = expected.invoice_number?.replace(/\s/g, '').toLowerCase() || '';
|
||||||
|
if (extNum !== expNum) {
|
||||||
|
errors.push(`invoice_number: expected "${expected.invoice_number}", got "${extracted.invoice_number}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare date (normalize format first)
|
||||||
|
const extDate = normalizeDate(extracted.invoice_date);
|
||||||
|
const expDate = normalizeDate(expected.invoice_date);
|
||||||
|
if (extDate !== expDate) {
|
||||||
|
errors.push(`invoice_date: expected "${expected.invoice_date}", got "${extracted.invoice_date}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare total amount (with tolerance)
|
||||||
|
if (Math.abs(extracted.total_amount - expected.total_amount) > 0.02) {
|
||||||
|
errors.push(`total_amount: expected ${expected.total_amount}, got ${extracted.total_amount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare currency
|
||||||
|
if (extracted.currency?.toUpperCase() !== expected.currency?.toUpperCase()) {
|
||||||
|
errors.push(`currency: expected "${expected.currency}", got "${extracted.currency}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { match: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all test cases (PDF + JSON pairs) in .nogit/invoices/
|
||||||
|
*/
|
||||||
|
function findTestCases(): Array<{ name: string; pdfPath: string; jsonPath: string }> {
|
||||||
|
const testDir = path.join(process.cwd(), '.nogit/invoices');
|
||||||
|
if (!fs.existsSync(testDir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(testDir);
|
||||||
|
const pdfFiles = files.filter((f) => f.endsWith('.pdf'));
|
||||||
|
const testCases: Array<{ name: string; pdfPath: string; jsonPath: string }> = [];
|
||||||
|
|
||||||
|
for (const pdf of pdfFiles) {
|
||||||
|
const baseName = pdf.replace('.pdf', '');
|
||||||
|
const jsonFile = `${baseName}.json`;
|
||||||
|
if (files.includes(jsonFile)) {
|
||||||
|
testCases.push({
|
||||||
|
name: baseName,
|
||||||
|
pdfPath: path.join(testDir, pdf),
|
||||||
|
jsonPath: path.join(testDir, jsonFile),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort alphabetically
|
||||||
|
testCases.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
return testCases;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests
|
||||||
|
|
||||||
|
tap.test('setup: ensure Docker containers are running', async () => {
|
||||||
|
console.log('\n[Setup] Checking Docker containers...\n');
|
||||||
|
|
||||||
|
// Ensure PaddleOCR-VL Full Pipeline is running
|
||||||
|
const paddleOk = await ensurePaddleOcrVlFull();
|
||||||
|
expect(paddleOk).toBeTrue();
|
||||||
|
|
||||||
|
// Ensure Qwen2.5 is available (for text-only JSON extraction)
|
||||||
|
const qwenOk = await ensureQwen25();
|
||||||
|
expect(qwenOk).toBeTrue();
|
||||||
|
|
||||||
|
console.log('\n[Setup] All containers ready!\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dynamic test for each PDF/JSON pair
|
||||||
|
const testCases = findTestCases();
|
||||||
|
console.log(`\nFound ${testCases.length} invoice test cases (PaddleOCR-VL Full Pipeline)\n`);
|
||||||
|
|
||||||
|
let passedCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
const processingTimes: number[] = [];
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
tap.test(`should extract invoice: ${testCase.name}`, async () => {
|
||||||
|
// Load expected data
|
||||||
|
const expected: IInvoice = JSON.parse(fs.readFileSync(testCase.jsonPath, 'utf-8'));
|
||||||
|
console.log(`\n=== ${testCase.name} ===`);
|
||||||
|
console.log(`Expected: ${expected.invoice_number} | ${expected.invoice_date} | ${expected.total_amount} ${expected.currency}`);
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Convert PDF to images
|
||||||
|
const images = convertPdfToImages(testCase.pdfPath);
|
||||||
|
console.log(` Pages: ${images.length}`);
|
||||||
|
|
||||||
|
// Extract with consensus voting (PaddleOCR-VL Full -> MiniCPM)
|
||||||
|
const extracted = await extractWithConsensus(images, testCase.name);
|
||||||
|
|
||||||
|
const endTime = Date.now();
|
||||||
|
const elapsedMs = endTime - startTime;
|
||||||
|
processingTimes.push(elapsedMs);
|
||||||
|
|
||||||
|
// Compare results
|
||||||
|
const result = compareInvoice(extracted, expected);
|
||||||
|
|
||||||
|
if (result.match) {
|
||||||
|
passedCount++;
|
||||||
|
console.log(` Result: MATCH (${(elapsedMs / 1000).toFixed(1)}s)`);
|
||||||
|
} else {
|
||||||
|
failedCount++;
|
||||||
|
console.log(` Result: MISMATCH (${(elapsedMs / 1000).toFixed(1)}s)`);
|
||||||
|
result.errors.forEach((e) => console.log(` - ${e}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert match
|
||||||
|
expect(result.match).toBeTrue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('summary', async () => {
|
||||||
|
const totalInvoices = testCases.length;
|
||||||
|
const accuracy = totalInvoices > 0 ? (passedCount / totalInvoices) * 100 : 0;
|
||||||
|
const totalTimeMs = processingTimes.reduce((a, b) => a + b, 0);
|
||||||
|
const avgTimeMs = processingTimes.length > 0 ? totalTimeMs / processingTimes.length : 0;
|
||||||
|
const avgTimeSec = avgTimeMs / 1000;
|
||||||
|
const totalTimeSec = totalTimeMs / 1000;
|
||||||
|
|
||||||
|
console.log(`\n======================================================`);
|
||||||
|
console.log(` Invoice Extraction Summary (PaddleOCR-VL Full)`);
|
||||||
|
console.log(`======================================================`);
|
||||||
|
console.log(` Method: PaddleOCR-VL Full Pipeline (HTML) -> Qwen2.5 (text-only)`);
|
||||||
|
console.log(` Passed: ${passedCount}/${totalInvoices}`);
|
||||||
|
console.log(` Failed: ${failedCount}/${totalInvoices}`);
|
||||||
|
console.log(` Accuracy: ${accuracy.toFixed(1)}%`);
|
||||||
|
console.log(`------------------------------------------------------`);
|
||||||
|
console.log(` Total time: ${totalTimeSec.toFixed(1)}s`);
|
||||||
|
console.log(` Avg per inv: ${avgTimeSec.toFixed(1)}s`);
|
||||||
|
console.log(`======================================================\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
311
test/test.invoices.qwen3vl.ts
Normal file
311
test/test.invoices.qwen3vl.ts
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* Invoice extraction using Qwen3-VL 8B Vision (Direct)
|
||||||
|
*
|
||||||
|
* Single-step pipeline: PDF → Images → Qwen3-VL → JSON
|
||||||
|
* Uses /no_think to disable reasoning mode for fast, direct responses.
|
||||||
|
*
|
||||||
|
* Qwen3-VL outperforms PaddleOCR-VL on certain invoice formats.
|
||||||
|
*/
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { ensureMiniCpm } from './helpers/docker.js';
|
||||||
|
|
||||||
|
const OLLAMA_URL = 'http://localhost:11434';
|
||||||
|
const VISION_MODEL = 'qwen3-vl:8b';
|
||||||
|
|
||||||
|
interface IInvoice {
|
||||||
|
invoice_number: string;
|
||||||
|
invoice_date: string;
|
||||||
|
vendor_name: string;
|
||||||
|
currency: string;
|
||||||
|
net_amount: number;
|
||||||
|
vat_amount: number;
|
||||||
|
total_amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert PDF to PNG images using ImageMagick
|
||||||
|
*/
|
||||||
|
function convertPdfToImages(pdfPath: string): string[] {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pdf-convert-'));
|
||||||
|
const outputPattern = path.join(tempDir, 'page-%d.png');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 150 DPI is sufficient for invoice extraction, reduces context size
|
||||||
|
execSync(
|
||||||
|
`convert -density 150 -quality 90 "${pdfPath}" -background white -alpha remove "${outputPattern}"`,
|
||||||
|
{ stdio: 'pipe' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const files = fs.readdirSync(tempDir).filter((f) => f.endsWith('.png')).sort();
|
||||||
|
const images: string[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const imagePath = path.join(tempDir, file);
|
||||||
|
const imageData = fs.readFileSync(imagePath);
|
||||||
|
images.push(imageData.toString('base64'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return images;
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract invoice data directly from images using Qwen3-VL Vision
|
||||||
|
* Uses /no_think to disable reasoning mode for fast, direct JSON output
|
||||||
|
*/
|
||||||
|
async function extractInvoiceFromImages(images: string[]): Promise<IInvoice> {
|
||||||
|
console.log(` [Vision] Processing ${images.length} page(s) with Qwen3-VL`);
|
||||||
|
|
||||||
|
// /no_think disables Qwen3's reasoning mode - crucial for getting direct output
|
||||||
|
const prompt = `/no_think
|
||||||
|
Look at this invoice and extract these fields. Reply with ONLY JSON, no explanation.
|
||||||
|
|
||||||
|
- invoice_number
|
||||||
|
- invoice_date (format: YYYY-MM-DD)
|
||||||
|
- vendor_name
|
||||||
|
- currency (EUR, USD, or GBP)
|
||||||
|
- net_amount
|
||||||
|
- vat_amount
|
||||||
|
- total_amount
|
||||||
|
|
||||||
|
JSON: {"invoice_number":"...","invoice_date":"YYYY-MM-DD","vendor_name":"...","currency":"EUR","net_amount":0,"vat_amount":0,"total_amount":0}`;
|
||||||
|
|
||||||
|
const response = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: VISION_MODEL,
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
images: images, // Pass all pages
|
||||||
|
}],
|
||||||
|
stream: false,
|
||||||
|
options: {
|
||||||
|
num_predict: 512,
|
||||||
|
temperature: 0.0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.text();
|
||||||
|
throw new Error(`Ollama API error: ${response.status} - ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
let content = data.message?.content || '';
|
||||||
|
|
||||||
|
console.log(` [Vision] Response (${content.length} chars): ${content.substring(0, 200)}...`);
|
||||||
|
|
||||||
|
// Parse JSON from response
|
||||||
|
if (content.startsWith('```json')) content = content.slice(7);
|
||||||
|
else if (content.startsWith('```')) content = content.slice(3);
|
||||||
|
if (content.endsWith('```')) content = content.slice(0, -3);
|
||||||
|
content = content.trim();
|
||||||
|
|
||||||
|
const startIdx = content.indexOf('{');
|
||||||
|
const endIdx = content.lastIndexOf('}') + 1;
|
||||||
|
|
||||||
|
if (startIdx < 0 || endIdx <= startIdx) {
|
||||||
|
throw new Error(`No JSON found: ${content.substring(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(content.substring(startIdx, endIdx));
|
||||||
|
|
||||||
|
return {
|
||||||
|
invoice_number: parsed.invoice_number || null,
|
||||||
|
invoice_date: parsed.invoice_date || null,
|
||||||
|
vendor_name: parsed.vendor_name || null,
|
||||||
|
currency: parsed.currency || 'EUR',
|
||||||
|
net_amount: parseFloat(parsed.net_amount) || 0,
|
||||||
|
vat_amount: parseFloat(parsed.vat_amount) || 0,
|
||||||
|
total_amount: parseFloat(parsed.total_amount) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize date to YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
function normalizeDate(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return dateStr;
|
||||||
|
|
||||||
|
const monthMap: Record<string, string> = {
|
||||||
|
JAN: '01', FEB: '02', MAR: '03', APR: '04', MAY: '05', JUN: '06',
|
||||||
|
JUL: '07', AUG: '08', SEP: '09', OCT: '10', NOV: '11', DEC: '12',
|
||||||
|
};
|
||||||
|
|
||||||
|
let match = dateStr.match(/^(\d{1,2})-([A-Z]{3})-(\d{4})$/i);
|
||||||
|
if (match) {
|
||||||
|
return `${match[3]}-${monthMap[match[2].toUpperCase()] || '01'}-${match[1].padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
match = dateStr.match(/^(\d{1,2})[\/.](\d{1,2})[\/.](\d{4})$/);
|
||||||
|
if (match) {
|
||||||
|
return `${match[3]}-${match[2].padStart(2, '0')}-${match[1].padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare extracted vs expected
|
||||||
|
*/
|
||||||
|
function compareInvoice(extracted: IInvoice, expected: IInvoice): { match: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
const extNum = extracted.invoice_number?.replace(/\s/g, '').toLowerCase() || '';
|
||||||
|
const expNum = expected.invoice_number?.replace(/\s/g, '').toLowerCase() || '';
|
||||||
|
if (extNum !== expNum) {
|
||||||
|
errors.push(`invoice_number: expected "${expected.invoice_number}", got "${extracted.invoice_number}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizeDate(extracted.invoice_date) !== normalizeDate(expected.invoice_date)) {
|
||||||
|
errors.push(`invoice_date: expected "${expected.invoice_date}", got "${extracted.invoice_date}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(extracted.total_amount - expected.total_amount) > 0.02) {
|
||||||
|
errors.push(`total_amount: expected ${expected.total_amount}, got ${extracted.total_amount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extracted.currency?.toUpperCase() !== expected.currency?.toUpperCase()) {
|
||||||
|
errors.push(`currency: expected "${expected.currency}", got "${extracted.currency}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { match: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find test cases
|
||||||
|
*/
|
||||||
|
function findTestCases(): Array<{ name: string; pdfPath: string; jsonPath: string }> {
|
||||||
|
const testDir = path.join(process.cwd(), '.nogit/invoices');
|
||||||
|
if (!fs.existsSync(testDir)) return [];
|
||||||
|
|
||||||
|
const files = fs.readdirSync(testDir);
|
||||||
|
const testCases: Array<{ name: string; pdfPath: string; jsonPath: string }> = [];
|
||||||
|
|
||||||
|
for (const pdf of files.filter((f) => f.endsWith('.pdf'))) {
|
||||||
|
const baseName = pdf.replace('.pdf', '');
|
||||||
|
const jsonFile = `${baseName}.json`;
|
||||||
|
if (files.includes(jsonFile)) {
|
||||||
|
testCases.push({
|
||||||
|
name: baseName,
|
||||||
|
pdfPath: path.join(testDir, pdf),
|
||||||
|
jsonPath: path.join(testDir, jsonFile),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return testCases.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure Qwen3-VL 8B model is available
|
||||||
|
*/
|
||||||
|
async function ensureQwen3Vl(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${OLLAMA_URL}/api/tags`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const models = data.models || [];
|
||||||
|
if (models.some((m: { name: string }) => m.name === VISION_MODEL)) {
|
||||||
|
console.log(`[Ollama] Model already available: ${VISION_MODEL}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log('[Ollama] Cannot check models');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Ollama] Pulling model: ${VISION_MODEL}...`);
|
||||||
|
const pullResponse = await fetch(`${OLLAMA_URL}/api/pull`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: VISION_MODEL, stream: false }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return pullResponse.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests
|
||||||
|
|
||||||
|
tap.test('setup: ensure Qwen3-VL is running', async () => {
|
||||||
|
console.log('\n[Setup] Checking Qwen3-VL 8B...\n');
|
||||||
|
|
||||||
|
// Ensure Ollama service is running
|
||||||
|
const ollamaOk = await ensureMiniCpm();
|
||||||
|
expect(ollamaOk).toBeTrue();
|
||||||
|
|
||||||
|
// Ensure Qwen3-VL 8B model
|
||||||
|
const visionOk = await ensureQwen3Vl();
|
||||||
|
expect(visionOk).toBeTrue();
|
||||||
|
|
||||||
|
console.log('\n[Setup] Ready!\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCases = findTestCases();
|
||||||
|
console.log(`\nFound ${testCases.length} invoice test cases (Qwen3-VL Vision)\n`);
|
||||||
|
|
||||||
|
let passedCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
const times: number[] = [];
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
tap.test(`should extract invoice: ${testCase.name}`, async () => {
|
||||||
|
const expected: IInvoice = JSON.parse(fs.readFileSync(testCase.jsonPath, 'utf-8'));
|
||||||
|
console.log(`\n=== ${testCase.name} ===`);
|
||||||
|
console.log(`Expected: ${expected.invoice_number} | ${expected.invoice_date} | ${expected.total_amount} ${expected.currency}`);
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
const images = convertPdfToImages(testCase.pdfPath);
|
||||||
|
console.log(` Pages: ${images.length}`);
|
||||||
|
|
||||||
|
const extracted = await extractInvoiceFromImages(images);
|
||||||
|
console.log(` Extracted: ${extracted.invoice_number} | ${extracted.invoice_date} | ${extracted.total_amount} ${extracted.currency}`);
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
times.push(elapsed);
|
||||||
|
|
||||||
|
const result = compareInvoice(extracted, expected);
|
||||||
|
|
||||||
|
if (result.match) {
|
||||||
|
passedCount++;
|
||||||
|
console.log(` Result: MATCH (${(elapsed / 1000).toFixed(1)}s)`);
|
||||||
|
} else {
|
||||||
|
failedCount++;
|
||||||
|
console.log(` Result: MISMATCH (${(elapsed / 1000).toFixed(1)}s)`);
|
||||||
|
result.errors.forEach((e) => console.log(` - ${e}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(result.match).toBeTrue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('summary', async () => {
|
||||||
|
const total = testCases.length;
|
||||||
|
const accuracy = total > 0 ? (passedCount / total) * 100 : 0;
|
||||||
|
const totalTime = times.reduce((a, b) => a + b, 0) / 1000;
|
||||||
|
const avgTime = times.length > 0 ? totalTime / times.length : 0;
|
||||||
|
|
||||||
|
console.log(`\n======================================================`);
|
||||||
|
console.log(` Invoice Extraction Summary (Qwen3-VL Vision)`);
|
||||||
|
console.log(`======================================================`);
|
||||||
|
console.log(` Method: Qwen3-VL 8B Direct Vision (/no_think)`);
|
||||||
|
console.log(` Passed: ${passedCount}/${total}`);
|
||||||
|
console.log(` Failed: ${failedCount}/${total}`);
|
||||||
|
console.log(` Accuracy: ${accuracy.toFixed(1)}%`);
|
||||||
|
console.log(`------------------------------------------------------`);
|
||||||
|
console.log(` Total time: ${totalTime.toFixed(1)}s`);
|
||||||
|
console.log(` Avg per inv: ${avgTime.toFixed(1)}s`);
|
||||||
|
console.log(`======================================================\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
Reference in New Issue
Block a user