8 Commits

Author SHA1 Message Date
d237ad19f4 v1.9.0
Some checks failed
Docker (tags) / security (push) Successful in 33s
Docker (tags) / test (push) Failing after 39s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-01-18 02:53:24 +00:00
7652a2df52 feat(tests): add Ministral 3 vision tests and improve invoice extraction pipeline to use Ollama chat schema, sanitization, and multi-page support 2026-01-18 02:53:24 +00:00
b316d98f24 v1.8.0
Some checks failed
Docker (tags) / security (push) Successful in 31s
Docker (tags) / test (push) Failing after 41s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-01-18 00:11:17 +00:00
f0d88fcbe0 feat(paddleocr-vl): add structured HTML output and table parsing for PaddleOCR-VL, update API, tests, and README 2026-01-18 00:11:17 +00:00
0d8a1ebac2 v1.7.1
Some checks failed
Docker (tags) / security (push) Successful in 31s
Docker (tags) / test (push) Failing after 39s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-01-17 23:13:47 +00:00
5a311dca2d fix(docker): standardize Dockerfile and entrypoint filenames; add GPU-specific Dockerfiles and update build and test references 2026-01-17 23:13:47 +00:00
ab288380f1 v1.7.0
Some checks failed
Docker (tags) / security (push) Successful in 30s
Docker (tags) / test (push) Failing after 40s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-01-17 21:50:09 +00:00
30c73b24c1 feat(tests): use Qwen2.5 (Ollama) for invoice extraction tests and add helpers for model management; normalize dates and coerce numeric fields 2026-01-17 21:50:09 +00:00
17 changed files with 1445 additions and 264 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -1,70 +0,0 @@
# PaddleOCR-VL GPU Variant
# Vision-Language Model for document parsing using vLLM
FROM nvidia/cuda:12.4.0-devel-ubuntu22.04
LABEL maintainer="Task Venture Capital GmbH <hello@task.vc>"
LABEL description="PaddleOCR-VL 0.9B - Vision-Language Model for document parsing"
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 VLLM_WORKER_MULTIPROC_METHOD=spawn
# 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 \
git \
curl \
build-essential \
&& 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 vLLM 0.11.1 (first stable release with PaddleOCR-VL support)
RUN pip install --no-cache-dir \
vllm==0.11.1 \
--extra-index-url https://download.pytorch.org/whl/cu124
# Install additional dependencies
RUN pip install --no-cache-dir \
transformers \
accelerate \
safetensors \
pillow \
fastapi \
uvicorn[standard] \
python-multipart \
openai \
httpx
# Copy entrypoint script
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 vLLM 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"]

View File

@@ -44,7 +44,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
# Copy server files # Copy server files
COPY image_support_files/paddleocr_vl_server.py /app/paddleocr_vl_server.py COPY image_support_files/paddleocr_vl_server.py /app/paddleocr_vl_server.py
COPY image_support_files/paddleocr-vl-cpu-entrypoint.sh /usr/local/bin/paddleocr-vl-cpu-entrypoint.sh 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 RUN chmod +x /usr/local/bin/paddleocr-vl-cpu-entrypoint.sh
# Expose API port # Expose API port

View File

@@ -58,7 +58,7 @@ RUN pip install --no-cache-dir \
# Copy server files (same as CPU variant - it auto-detects CUDA) # 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_server.py /app/paddleocr_vl_server.py
COPY image_support_files/paddleocr-vl-cpu-entrypoint.sh /usr/local/bin/paddleocr-vl-entrypoint.sh 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 RUN chmod +x /usr/local/bin/paddleocr-vl-entrypoint.sh
# Expose API port # Expose API port

View File

@@ -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,10 +29,10 @@ docker build \
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:minicpm45v-cpu \ -t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:minicpm45v-cpu \
. .
# Build PaddleOCR-VL GPU variant (vLLM) # Build PaddleOCR-VL GPU variant
echo -e "${GREEN}Building PaddleOCR-VL GPU variant (vLLM)...${NC}" echo -e "${GREEN}Building PaddleOCR-VL GPU variant...${NC}"
docker build \ docker build \
-f Dockerfile_paddleocr_vl \ -f Dockerfile_paddleocr_vl_gpu \
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:paddleocr-vl \ -t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:paddleocr-vl \
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:paddleocr-vl-gpu \ -t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:paddleocr-vl-gpu \
. .

View File

@@ -1,5 +1,42 @@
# Changelog # Changelog
## 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) ## 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 PaddleOCR-VL full pipeline Docker image and API server, plus integration tests and docker helpers

View File

@@ -1,59 +0,0 @@
#!/bin/bash
set -e
echo "==================================="
echo "PaddleOCR-VL Server"
echo "==================================="
# Configuration
MODEL_NAME="${MODEL_NAME:-PaddlePaddle/PaddleOCR-VL}"
HOST="${HOST:-0.0.0.0}"
PORT="${PORT:-8000}"
MAX_BATCHED_TOKENS="${MAX_BATCHED_TOKENS:-16384}"
GPU_MEMORY_UTILIZATION="${GPU_MEMORY_UTILIZATION:-0.9}"
MAX_MODEL_LEN="${MAX_MODEL_LEN:-8192}"
ENFORCE_EAGER="${ENFORCE_EAGER:-false}"
echo "Model: ${MODEL_NAME}"
echo "Host: ${HOST}"
echo "Port: ${PORT}"
echo "Max batched tokens: ${MAX_BATCHED_TOKENS}"
echo "GPU memory utilization: ${GPU_MEMORY_UTILIZATION}"
echo "Max model length: ${MAX_MODEL_LEN}"
echo "Enforce eager: ${ENFORCE_EAGER}"
echo ""
# Check GPU availability
if command -v nvidia-smi &> /dev/null; then
echo "GPU Information:"
nvidia-smi --query-gpu=name,memory.total,memory.free --format=csv
echo ""
else
echo "WARNING: nvidia-smi not found. GPU may not be available."
fi
echo "Starting vLLM server..."
echo "==================================="
# Build vLLM command
VLLM_ARGS=(
serve "${MODEL_NAME}"
--trust-remote-code
--host "${HOST}"
--port "${PORT}"
--max-num-batched-tokens "${MAX_BATCHED_TOKENS}"
--gpu-memory-utilization "${GPU_MEMORY_UTILIZATION}"
--max-model-len "${MAX_MODEL_LEN}"
--no-enable-prefix-caching
--mm-processor-cache-gb 0
--served-model-name "paddleocr-vl"
--limit-mm-per-prompt '{"image": 1}'
)
# Add enforce-eager if enabled (disables CUDA graphs, saves memory)
if [ "${ENFORCE_EAGER}" = "true" ]; then
VLLM_ARGS+=(--enforce-eager)
fi
# Start vLLM server with PaddleOCR-VL
exec vllm "${VLLM_ARGS[@]}"

View File

@@ -10,6 +10,7 @@ Provides REST API for document parsing using:
import os import os
import io import io
import re
import base64 import base64
import logging import logging
import tempfile import tempfile
@@ -261,23 +262,210 @@ def process_document(image: Image.Image) -> dict:
def result_to_markdown(result: dict) -> str: def result_to_markdown(result: dict) -> str:
"""Convert result to Markdown format""" """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 = [] lines = []
image_height = result.get("image_size", [0, 1000])[1]
for block in result.get("blocks", []): for block in result.get("blocks", []):
block_type = block.get("type", "text") block_type = block.get("type", "text").lower()
content = block.get("content", "") content = block.get("content", "").strip()
bbox = block.get("bbox", [])
if "table" in block_type.lower(): if not content:
lines.append(f"\n{content}\n") continue
elif "formula" in block_type.lower():
# 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") 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: else:
lines.append(content) lines.append(content)
return "\n\n".join(lines) 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 # Request/Response models
class ParseRequest(BaseModel): class ParseRequest(BaseModel):
image: str # base64 encoded image image: str # base64 encoded image
@@ -331,7 +519,7 @@ async def health_check():
async def supported_formats(): async def supported_formats():
"""List supported output formats""" """List supported output formats"""
return { return {
"output_formats": ["json", "markdown"], "output_formats": ["json", "markdown", "html"],
"image_formats": ["PNG", "JPEG", "WebP", "BMP", "GIF", "TIFF"], "image_formats": ["PNG", "JPEG", "WebP", "BMP", "GIF", "TIFF"],
"capabilities": [ "capabilities": [
"Layout detection (PP-DocLayoutV2)", "Layout detection (PP-DocLayoutV2)",
@@ -356,6 +544,9 @@ async def parse_document_endpoint(request: ParseRequest):
if request.output_format == "markdown": if request.output_format == "markdown":
markdown = result_to_markdown(result) markdown = result_to_markdown(result)
output = {"markdown": markdown} output = {"markdown": markdown}
elif request.output_format == "html":
html = result_to_html(result)
output = {"html": html}
else: else:
output = result output = result
@@ -408,6 +599,8 @@ async def chat_completions(request: dict):
if output_format == "markdown": if output_format == "markdown":
content = result_to_markdown(result) content = result_to_markdown(result)
elif output_format == "html":
content = result_to_html(result)
else: else:
content = json.dumps(result, ensure_ascii=False, indent=2) content = json.dumps(result, ensure_ascii=False, indent=2)

View File

@@ -1,6 +1,6 @@
{ {
"name": "@host.today/ht-docker-ai", "name": "@host.today/ht-docker-ai",
"version": "1.6.0", "version": "1.9.0",
"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",

296
readme.md
View File

@@ -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:** 1080p2K. Images are automatically scaled for best results.
### Performance
| Mode | Speed per Page |
|------|----------------|
| GPU (CUDA) | 25 seconds |
| CPU | 3060 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.

View File

@@ -49,7 +49,7 @@ export const IMAGES = {
minicpm: { minicpm: {
name: 'minicpm45v', name: 'minicpm45v',
dockerfile: 'Dockerfile_minicpm45v', dockerfile: 'Dockerfile_minicpm45v_gpu',
buildContext: '.', buildContext: '.',
containerName: 'minicpm-test', containerName: 'minicpm-test',
ports: ['11434:11434'], ports: ['11434:11434'],
@@ -295,3 +295,79 @@ export async function ensurePaddleOcrVlFull(): Promise<boolean> {
} }
return ensureService(IMAGES.paddleocrVlFull); 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 || [];
const exists = models.some((m: { name: string }) =>
m.name === modelName || m.name.startsWith(modelName.split(':')[0])
);
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');
}

View 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();

View File

@@ -0,0 +1,355 @@
/**
* 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 {
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 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 = `Extract invoice data from this document image(s).
Find and return:
- invoice_number: The invoice number/ID (look for "Invoice No", "Invoice #", "Rechnung Nr")
- invoice_date: The invoice date in YYYY-MM-DD format
- vendor_name: The company issuing the invoice (in letterhead)
- currency: EUR, USD, or GBP
- total_amount: The FINAL total amount due
- net_amount: Amount before VAT/tax
- vat_amount: VAT/tax amount
Return ONLY valid JSON.`;
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: 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;
}
} 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,
};
}
/**
* Extract with consensus voting (2 agreeing passes)
*/
async function extractWithConsensus(images: string[], name: string, maxPasses: number = 3): Promise<IInvoice> {
const results: Array<{ invoice: IInvoice; hash: string }> = [];
const hashCounts: Map<string, number> = new Map();
for (let pass = 1; pass <= maxPasses; pass++) {
try {
const invoice = await extractInvoiceFromImages(images);
const hash = `${invoice.invoice_number}|${invoice.invoice_date}|${invoice.total_amount?.toFixed(2)}`;
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}`);
if (hashCounts.get(hash)! >= 2) {
console.log(` [Consensus] Reached after ${pass} passes`);
return invoice;
}
} catch (err) {
console.log(` [Pass ${pass}] Error: ${err}`);
}
}
// Return 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 ${name}`);
console.log(` [No consensus] Using best result (${bestCount}/${maxPasses})`);
return results.find((r) => r.hash === bestHash)!.invoice;
}
/**
* 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 extractWithConsensus(images, testCase.name);
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();

View File

@@ -4,22 +4,25 @@
* This tests the complete PaddleOCR-VL pipeline: * This tests the complete PaddleOCR-VL pipeline:
* 1. PP-DocLayoutV2 for layout detection * 1. PP-DocLayoutV2 for layout detection
* 2. PaddleOCR-VL for recognition * 2. PaddleOCR-VL for recognition
* 3. Structured Markdown output * 3. Structured HTML output (semantic tags with proper tables)
* 4. MiniCPM extracts invoice fields from structured Markdown * 4. Qwen2.5 extracts invoice fields from structured HTML
* *
* The structured Markdown has proper tables and formatting, * HTML output is used instead of Markdown because:
* making it much easier for MiniCPM to extract invoice data. * - <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 { 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 { ensurePaddleOcrVlFull, ensureMiniCpm } from './helpers/docker.js'; import { ensurePaddleOcrVlFull, ensureQwen25 } from './helpers/docker.js';
const PADDLEOCR_VL_URL = 'http://localhost:8000'; const PADDLEOCR_VL_URL = 'http://localhost:8000';
const OLLAMA_URL = 'http://localhost:11434'; const OLLAMA_URL = 'http://localhost:11434';
const MINICPM_MODEL = 'minicpm-v:latest'; // Use Qwen2.5 for text-only JSON extraction (not MiniCPM which is vision-focused)
const TEXT_MODEL = 'qwen2.5:7b';
interface IInvoice { interface IInvoice {
invoice_number: string; invoice_number: string;
@@ -60,7 +63,7 @@ function convertPdfToImages(pdfPath: string): string[] {
} }
/** /**
* Parse document using PaddleOCR-VL Full Pipeline (returns structured Markdown) * Parse document using PaddleOCR-VL Full Pipeline (returns structured HTML)
*/ */
async function parseDocument(imageBase64: string): Promise<string> { async function parseDocument(imageBase64: string): Promise<string> {
const response = await fetch(`${PADDLEOCR_VL_URL}/parse`, { const response = await fetch(`${PADDLEOCR_VL_URL}/parse`, {
@@ -68,7 +71,7 @@ async function parseDocument(imageBase64: string): Promise<string> {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
image: imageBase64, image: imageBase64,
output_format: 'markdown', output_format: 'html',
}), }),
}); });
@@ -83,54 +86,75 @@ async function parseDocument(imageBase64: string): Promise<string> {
throw new Error(`PaddleOCR-VL error: ${data.error}`); throw new Error(`PaddleOCR-VL error: ${data.error}`);
} }
return data.result?.markdown || ''; return data.result?.html || '';
} }
/** /**
* Extract invoice fields from structured Markdown using MiniCPM with image context * Sanitize HTML to remove OCR artifacts that confuse the LLM
* Minimal cleaning - only remove truly problematic patterns
*/ */
async function extractInvoiceFromMarkdown(markdown: string, images: string[]): Promise<IInvoice> { function sanitizeHtml(html: string): string {
// Truncate if too long // Remove excessively repeated characters (OCR glitches)
const truncated = markdown.length > 8000 ? markdown.slice(0, 8000) : markdown; let sanitized = html.replace(/(\d)\1{20,}/g, '$1...');
console.log(` [Extract] Processing ${truncated.length} chars of Markdown`); // Remove extremely long strings (corrupted data)
sanitized = sanitized.replace(/\b[A-Za-z0-9]{50,}\b/g, '[OCR_ARTIFACT]');
return sanitized;
}
const prompt = `/nothink /**
You are an invoice parser. Extract fields from this invoice image. * 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> {
const sanitized = sanitizeHtml(html);
const truncated = sanitized.length > 32000 ? sanitized.slice(0, 32000) : sanitized;
console.log(` [Extract] ${truncated.length} chars of HTML`);
Required fields: // JSON schema for structured output
- invoice_number: The invoice/receipt number const invoiceSchema = {
- invoice_date: Date in YYYY-MM-DD format type: 'object',
- vendor_name: Company that issued the invoice properties: {
- currency: EUR, USD, etc. invoice_number: { type: 'string' },
- net_amount: Amount before tax invoice_date: { type: 'string' },
- vat_amount: Tax/VAT amount (0 if reverse charge) vendor_name: { type: 'string' },
- total_amount: Final amount due currency: { type: 'string' },
net_amount: { type: 'number' },
Return ONLY a JSON object like: vat_amount: { type: 'number' },
{"invoice_number":"123","invoice_date":"2022-01-28","vendor_name":"Adobe","currency":"EUR","net_amount":24.99,"vat_amount":0,"total_amount":24.99} total_amount: { type: 'number' },
Use null for missing strings, 0 for missing numbers. No explanation.
OCR text from the invoice (for reference):
---
${truncated}
---`;
const payload = {
model: MINICPM_MODEL,
prompt,
images, // Send the actual image to MiniCPM
stream: true,
options: {
num_predict: 2048,
temperature: 0.1,
}, },
required: ['invoice_number', 'invoice_date', 'vendor_name', 'currency', 'net_amount', 'vat_amount', 'total_amount'],
}; };
const response = await fetch(`${OLLAMA_URL}/api/generate`, { // 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), 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) { if (!response.ok) {
@@ -155,7 +179,9 @@ ${truncated}
for (const line of lines) { for (const line of lines) {
try { try {
const json = JSON.parse(line); const json = JSON.parse(line);
if (json.response) { if (json.message?.content) {
fullText += json.message.content;
} else if (json.response) {
fullText += json.response; fullText += json.response;
} }
} catch { } catch {
@@ -165,34 +191,78 @@ ${truncated}
} }
// Extract JSON from response // Extract JSON from response
const startIdx = fullText.indexOf('{'); let jsonStr = fullText.trim();
const endIdx = fullText.lastIndexOf('}') + 1;
// 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) { if (startIdx < 0 || endIdx <= startIdx) {
throw new Error(`No JSON object found in response: ${fullText.substring(0, 200)}`); throw new Error(`No JSON object found in response: ${fullText.substring(0, 200)}`);
} }
const jsonStr = fullText.substring(startIdx, endIdx); jsonStr = jsonStr.substring(startIdx, endIdx);
return JSON.parse(jsonStr);
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 MiniCPM * 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> { async function extractOnce(images: string[], passNum: number): Promise<IInvoice> {
// Parse document with full pipeline // Parse ALL pages and concatenate HTML with page markers
const markdown = await parseDocument(images[0]); const htmlParts: string[] = [];
console.log(` [Parse] Got ${markdown.split('\n').length} lines of Markdown`);
// Extract invoice fields from Markdown with image context for (let i = 0; i < images.length; i++) {
return extractInvoiceFromMarkdown(markdown, images); 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) * Create a hash of invoice for comparison (using key fields)
*/ */
function hashInvoice(invoice: IInvoice): string { function hashInvoice(invoice: IInvoice): string {
return `${invoice.invoice_number}|${invoice.invoice_date}|${invoice.total_amount.toFixed(2)}`; // 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}`;
} }
/** /**
@@ -243,6 +313,43 @@ async function extractWithConsensus(images: string[], invoiceName: string, maxPa
return best.invoice; 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 * Compare extracted invoice against expected
*/ */
@@ -259,8 +366,10 @@ function compareInvoice(
errors.push(`invoice_number: expected "${expected.invoice_number}", got "${extracted.invoice_number}"`); errors.push(`invoice_number: expected "${expected.invoice_number}", got "${extracted.invoice_number}"`);
} }
// Compare date // Compare date (normalize format first)
if (extracted.invoice_date !== expected.invoice_date) { 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}"`); errors.push(`invoice_date: expected "${expected.invoice_date}", got "${extracted.invoice_date}"`);
} }
@@ -317,9 +426,9 @@ tap.test('setup: ensure Docker containers are running', async () => {
const paddleOk = await ensurePaddleOcrVlFull(); const paddleOk = await ensurePaddleOcrVlFull();
expect(paddleOk).toBeTrue(); expect(paddleOk).toBeTrue();
// Ensure MiniCPM is running (for field extraction from Markdown) // Ensure Qwen2.5 is available (for text-only JSON extraction)
const minicpmOk = await ensureMiniCpm(); const qwenOk = await ensureQwen25();
expect(minicpmOk).toBeTrue(); expect(qwenOk).toBeTrue();
console.log('\n[Setup] All containers ready!\n'); console.log('\n[Setup] All containers ready!\n');
}); });
@@ -380,7 +489,7 @@ tap.test('summary', async () => {
console.log(`\n======================================================`); console.log(`\n======================================================`);
console.log(` Invoice Extraction Summary (PaddleOCR-VL Full)`); console.log(` Invoice Extraction Summary (PaddleOCR-VL Full)`);
console.log(`======================================================`); console.log(`======================================================`);
console.log(` Method: PaddleOCR-VL Full Pipeline -> MiniCPM`); console.log(` Method: PaddleOCR-VL Full Pipeline (HTML) -> Qwen2.5 (text-only)`);
console.log(` Passed: ${passedCount}/${totalInvoices}`); console.log(` Passed: ${passedCount}/${totalInvoices}`);
console.log(` Failed: ${failedCount}/${totalInvoices}`); console.log(` Failed: ${failedCount}/${totalInvoices}`);
console.log(` Accuracy: ${accuracy.toFixed(1)}%`); console.log(` Accuracy: ${accuracy.toFixed(1)}%`);