Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6c97a9625 | |||
| 76b21f1f7b | |||
| 4c368dfef9 | |||
| e76768da55 | |||
| 63d72a52c9 | |||
| 386122c8c7 | |||
| 7c8f10497e | |||
| 9f9ec0a671 | |||
| 3780105c6f | |||
| d237ad19f4 | |||
| 7652a2df52 | |||
| b316d98f24 | |||
| f0d88fcbe0 | |||
| 0d8a1ebac2 | |||
| 5a311dca2d | |||
| ab288380f1 | |||
| 30c73b24c1 |
@@ -14,7 +14,7 @@ ENV OLLAMA_ORIGINS="*"
|
||||
ENV CUDA_VISIBLE_DEVICES=""
|
||||
|
||||
# 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
|
||||
|
||||
# Expose Ollama API port
|
||||
|
||||
@@ -12,7 +12,7 @@ ENV OLLAMA_HOST="0.0.0.0"
|
||||
ENV OLLAMA_ORIGINS="*"
|
||||
|
||||
# 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
|
||||
|
||||
# Expose Ollama API port
|
||||
@@ -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"]
|
||||
@@ -44,7 +44,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
# Copy server files
|
||||
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
|
||||
|
||||
# Expose API port
|
||||
|
||||
@@ -58,7 +58,7 @@ RUN pip install --no-cache-dir \
|
||||
|
||||
# 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-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
|
||||
|
||||
# Expose API port
|
||||
|
||||
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
|
||||
echo -e "${GREEN}Building MiniCPM-V 4.5 GPU variant...${NC}"
|
||||
docker build \
|
||||
-f Dockerfile_minicpm45v \
|
||||
-f Dockerfile_minicpm45v_gpu \
|
||||
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:minicpm45v \
|
||||
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:minicpm45v-gpu \
|
||||
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:latest \
|
||||
@@ -29,10 +29,10 @@ docker build \
|
||||
-t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:minicpm45v-cpu \
|
||||
.
|
||||
|
||||
# Build PaddleOCR-VL GPU variant (vLLM)
|
||||
echo -e "${GREEN}Building PaddleOCR-VL GPU variant (vLLM)...${NC}"
|
||||
# Build PaddleOCR-VL GPU variant
|
||||
echo -e "${GREEN}Building PaddleOCR-VL GPU variant...${NC}"
|
||||
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-gpu \
|
||||
.
|
||||
|
||||
75
changelog.md
75
changelog.md
@@ -1,5 +1,80 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-01-18 - 1.12.0 - feat(tests)
|
||||
switch vision tests to multi-query extraction (count then per-row/field queries) and add logging/summaries
|
||||
|
||||
- Replace streaming + consensus pipeline with multi-query approach: count rows per page, then query each transaction/field individually (batched parallel queries).
|
||||
- Introduce unified helpers (queryVision / queryField / getTransaction / countTransactions) and simplify Ollama requests (stream:false, reduced num_predict, /no_think prompts).
|
||||
- Improve parsing and normalization for amounts (European formats), invoice numbers, dates and currency extraction.
|
||||
- Adjust model checks to look for generic 'minicpm' and update test names/messages; add pass/fail counters and a summary test output.
|
||||
- Remove previous consensus voting and streaming JSON accumulation logic, and add immediate per-transaction logging and batching.
|
||||
|
||||
## 2026-01-18 - 1.11.0 - feat(vision)
|
||||
process pages separately and make Qwen3-VL vision extraction more robust; add per-page parsing, safer JSON handling, reduced token usage, and multi-query invoice extraction
|
||||
|
||||
- Bank statements: split extraction into extractTransactionsFromPage and sequentially process pages to avoid thinking-token exhaustion
|
||||
- Bank statements: reduced num_predict from 8000 to 4000, send single image per request, added per-page logging and non-throwing handling for empty or non-JSON responses
|
||||
- Bank statements: catch JSON.parse errors and return empty array instead of throwing
|
||||
- Invoices: introduced queryField to request single values and perform multiple simple queries (reduces model thinking usage)
|
||||
- Invoices: reduced num_predict for invoice queries from 4000 to 500 and parse amounts robustly (handles European formats like 1.234,56)
|
||||
- Invoices: normalize currency to uppercase 3-letter code, return safe defaults (empty strings / 0) instead of nulls, and parse net/vat/total with fallbacks
|
||||
- General: simplified Ollama API error messages to avoid including response body content in thrown errors
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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[@]}"
|
||||
@@ -10,6 +10,7 @@ Provides REST API for document parsing using:
|
||||
|
||||
import os
|
||||
import io
|
||||
import re
|
||||
import base64
|
||||
import logging
|
||||
import tempfile
|
||||
@@ -261,23 +262,210 @@ def process_document(image: Image.Image) -> dict:
|
||||
|
||||
|
||||
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 = []
|
||||
image_height = result.get("image_size", [0, 1000])[1]
|
||||
|
||||
for block in result.get("blocks", []):
|
||||
block_type = block.get("type", "text")
|
||||
content = block.get("content", "")
|
||||
block_type = block.get("type", "text").lower()
|
||||
content = block.get("content", "").strip()
|
||||
bbox = block.get("bbox", [])
|
||||
|
||||
if "table" in block_type.lower():
|
||||
lines.append(f"\n{content}\n")
|
||||
elif "formula" in block_type.lower():
|
||||
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
|
||||
@@ -331,7 +519,7 @@ async def health_check():
|
||||
async def supported_formats():
|
||||
"""List supported output formats"""
|
||||
return {
|
||||
"output_formats": ["json", "markdown"],
|
||||
"output_formats": ["json", "markdown", "html"],
|
||||
"image_formats": ["PNG", "JPEG", "WebP", "BMP", "GIF", "TIFF"],
|
||||
"capabilities": [
|
||||
"Layout detection (PP-DocLayoutV2)",
|
||||
@@ -356,6 +544,9 @@ async def parse_document_endpoint(request: ParseRequest):
|
||||
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
|
||||
|
||||
@@ -408,6 +599,8 @@ async def chat_completions(request: dict):
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@host.today/ht-docker-ai",
|
||||
"version": "1.6.0",
|
||||
"version": "1.12.0",
|
||||
"type": "module",
|
||||
"private": false,
|
||||
"description": "Docker images for AI vision-language models including MiniCPM-V 4.5",
|
||||
|
||||
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 |
|
||||
|-----|-------------|--------------|
|
||||
| `minicpm45v` | MiniCPM-V 4.5 with GPU support | NVIDIA GPU, 9-18GB VRAM |
|
||||
| `minicpm45v-cpu` | MiniCPM-V 4.5 CPU-only | 8GB+ RAM |
|
||||
| `latest` | Alias for `minicpm45v` | NVIDIA GPU |
|
||||
| Model | Parameters | Best For | API |
|
||||
|-------|-----------|----------|-----|
|
||||
| **MiniCPM-V 4.5** | 8B | General vision understanding, image analysis, multi-image | Ollama-compatible |
|
||||
| **PaddleOCR-VL** | 0.9B | Document parsing, table extraction, OCR | OpenAI-compatible |
|
||||
|
||||
## 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
|
||||
docker run -d \
|
||||
--name minicpm \
|
||||
@@ -27,8 +44,7 @@ docker run -d \
|
||||
code.foss.global/host.today/ht-docker-ai:minicpm45v
|
||||
```
|
||||
|
||||
### CPU Only
|
||||
|
||||
**CPU Only:**
|
||||
```bash
|
||||
docker run -d \
|
||||
--name minicpm \
|
||||
@@ -37,18 +53,16 @@ docker run -d \
|
||||
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.
|
||||
|
||||
### List Available Models
|
||||
### API Examples
|
||||
|
||||
**List models:**
|
||||
```bash
|
||||
curl http://localhost:11434/api/tags
|
||||
```
|
||||
|
||||
### Generate Text from Image
|
||||
|
||||
**Analyze an image:**
|
||||
```bash
|
||||
curl http://localhost:11434/api/generate -d '{
|
||||
"model": "minicpm-v",
|
||||
@@ -57,60 +71,128 @@ curl http://localhost:11434/api/generate -d '{
|
||||
}'
|
||||
```
|
||||
|
||||
### Chat with Vision
|
||||
|
||||
**Chat with vision:**
|
||||
```bash
|
||||
curl http://localhost:11434/api/chat -d '{
|
||||
"model": "minicpm-v",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Describe this image in detail",
|
||||
"images": ["<base64-encoded-image>"]
|
||||
}
|
||||
]
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": "Describe this image in detail",
|
||||
"images": ["<base64-encoded-image>"]
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
### Hardware Requirements
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MODEL_NAME` | `minicpm-v` | Model to pull on startup |
|
||||
| `OLLAMA_HOST` | `0.0.0.0` | Host address for API |
|
||||
| `OLLAMA_ORIGINS` | `*` | Allowed CORS origins |
|
||||
| Variant | VRAM/RAM | Notes |
|
||||
|---------|----------|-------|
|
||||
| GPU (int4 quantized) | 9GB VRAM | Recommended for most use cases |
|
||||
| GPU (full precision) | 18GB VRAM | Maximum quality |
|
||||
| CPU (GGUF) | 8GB+ RAM | Slower but accessible |
|
||||
|
||||
## Hardware Requirements
|
||||
---
|
||||
|
||||
### GPU Variant (`minicpm45v`)
|
||||
## 📄 PaddleOCR-VL
|
||||
|
||||
- NVIDIA GPU with CUDA support
|
||||
- Minimum 9GB VRAM (int4 quantized)
|
||||
- Recommended 18GB VRAM (full precision)
|
||||
- NVIDIA Container Toolkit installed
|
||||
A specialized 0.9B Vision-Language Model optimized for document parsing. Native support for tables, formulas, charts, and text extraction in 109 languages.
|
||||
|
||||
### CPU Variant (`minicpm45v-cpu`)
|
||||
### Quick Start
|
||||
|
||||
- Minimum 8GB RAM
|
||||
- Recommended 16GB+ RAM for better performance
|
||||
- No GPU required
|
||||
**GPU:**
|
||||
```bash
|
||||
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)
|
||||
- **Capabilities**: Image understanding, OCR, multi-image analysis
|
||||
- **Languages**: 30+ languages including English, Chinese, French, Spanish
|
||||
PaddleOCR-VL exposes a fully OpenAI-compatible `/v1/chat/completions` endpoint:
|
||||
|
||||
## 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
|
||||
version: '3.8'
|
||||
services:
|
||||
# General vision tasks
|
||||
minicpm:
|
||||
image: code.foss.global/host.today/ht-docker-ai:minicpm45v
|
||||
container_name: minicpm
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
@@ -124,11 +206,50 @@ services:
|
||||
capabilities: [gpu]
|
||||
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:
|
||||
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
|
||||
# Clone the repository
|
||||
@@ -142,6 +263,77 @@ cd ht-docker-ai
|
||||
./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.
|
||||
|
||||
@@ -49,7 +49,7 @@ export const IMAGES = {
|
||||
|
||||
minicpm: {
|
||||
name: 'minicpm45v',
|
||||
dockerfile: 'Dockerfile_minicpm45v',
|
||||
dockerfile: 'Dockerfile_minicpm45v_gpu',
|
||||
buildContext: '.',
|
||||
containerName: 'minicpm-test',
|
||||
ports: ['11434:11434'],
|
||||
@@ -295,3 +295,91 @@ export async function ensurePaddleOcrVlFull(): Promise<boolean> {
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Bank statement extraction test using MiniCPM-V only (visual extraction)
|
||||
* Bank statement extraction using MiniCPM-V (visual extraction)
|
||||
*
|
||||
* This tests MiniCPM-V's ability to extract bank transactions directly from images
|
||||
* without any OCR augmentation.
|
||||
* Multi-query approach with thinking DISABLED for speed:
|
||||
* 1. First ask how many transactions on each page
|
||||
* 2. Then query each transaction individually
|
||||
* Single pass, no consensus voting.
|
||||
*/
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as fs from 'fs';
|
||||
@@ -11,24 +13,8 @@ import { execSync } from 'child_process';
|
||||
import * as os from 'os';
|
||||
import { ensureMiniCpm } from './helpers/docker.js';
|
||||
|
||||
// Service URL
|
||||
const OLLAMA_URL = 'http://localhost:11434';
|
||||
|
||||
// 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:
|
||||
- "- 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.`;
|
||||
const MODEL = 'minicpm-v:latest';
|
||||
|
||||
interface ITransaction {
|
||||
date: string;
|
||||
@@ -65,149 +51,146 @@ function convertPdfToImages(pdfPath: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract using MiniCPM-V via Ollama
|
||||
* Query MiniCPM-V with a prompt (thinking disabled for speed)
|
||||
*/
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
async function queryVision(image: string, prompt: string): Promise<string> {
|
||||
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
prompt: `/no_think\n${prompt}`,
|
||||
images: [image],
|
||||
stream: false,
|
||||
options: {
|
||||
num_predict: 500,
|
||||
temperature: 0.1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
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 data = await response.json();
|
||||
return (data.response || '').trim();
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let fullText = '';
|
||||
let lineBuffer = '';
|
||||
/**
|
||||
* Count transactions on a page
|
||||
*/
|
||||
async function countTransactions(image: string, pageNum: number): Promise<number> {
|
||||
const response = await queryVision(image,
|
||||
`Count the transaction rows in this bank statement table.
|
||||
Each transaction has a date, description, and amount (debit or credit).
|
||||
Do not count headers or totals.
|
||||
How many transaction rows are there? Answer with just the number.`
|
||||
);
|
||||
|
||||
console.log(`[${passLabel}] Extracting with MiniCPM-V...`);
|
||||
console.log(` [Page ${pageNum}] Count response: "${response}"`);
|
||||
const match = response.match(/(\d+)/);
|
||||
const count = match ? parseInt(match[1], 10) : 0;
|
||||
console.log(` [Page ${pageNum}] Parsed count: ${count}`);
|
||||
return count;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
/**
|
||||
* Get a single transaction by index (logs immediately)
|
||||
*/
|
||||
async function getTransaction(image: string, index: number, pageNum: number): Promise<ITransaction | null> {
|
||||
const response = await queryVision(image,
|
||||
`Look at transaction row #${index} in the bank statement table (row 1 is the first transaction after the header).
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n').filter((l) => l.trim());
|
||||
Extract:
|
||||
- DATE: in YYYY-MM-DD format
|
||||
- COUNTERPARTY: the description/name
|
||||
- AMOUNT: as a number (negative for debits like "- 21,47 €" = -21.47, positive for credits)
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.response) {
|
||||
fullText += json.response;
|
||||
lineBuffer += json.response;
|
||||
Format your answer as: DATE|COUNTERPARTY|AMOUNT
|
||||
Example: 2024-01-15|Amazon|-25.99`
|
||||
);
|
||||
|
||||
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
|
||||
}
|
||||
// Parse the response
|
||||
const lines = response.split('\n').filter(l => l.includes('|'));
|
||||
const line = lines[lines.length - 1] || response;
|
||||
const parts = line.split('|').map(p => p.trim());
|
||||
|
||||
if (parts.length >= 3) {
|
||||
// Parse amount - handle various formats
|
||||
let amountStr = parts[2].replace(/[€$£\s]/g, '').replace('−', '-').replace('–', '-');
|
||||
// European format: comma is decimal
|
||||
if (amountStr.includes(',')) {
|
||||
amountStr = amountStr.replace(/\./g, '').replace(',', '.');
|
||||
}
|
||||
const amount = parseFloat(amountStr) || 0;
|
||||
|
||||
const tx = {
|
||||
date: parts[0],
|
||||
counterparty: parts[1],
|
||||
amount: amount,
|
||||
};
|
||||
// Log immediately as this transaction completes
|
||||
console.log(` [P${pageNum} Tx${index.toString().padStart(2, ' ')}] ${tx.date} | ${tx.counterparty.substring(0, 25).padEnd(25)} | ${tx.amount >= 0 ? '+' : ''}${tx.amount.toFixed(2)}`);
|
||||
return tx;
|
||||
}
|
||||
|
||||
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));
|
||||
// Log raw response on parse failure
|
||||
console.log(` [P${pageNum} Tx${index.toString().padStart(2, ' ')}] PARSE FAILED: "${response.replace(/\n/g, ' ').substring(0, 60)}..."`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hash of transactions for comparison
|
||||
* Extract transactions from a single page using multi-query approach
|
||||
*/
|
||||
function hashTransactions(transactions: ITransaction[]): string {
|
||||
return transactions
|
||||
.map((t) => `${t.date}|${t.amount.toFixed(2)}`)
|
||||
.sort()
|
||||
.join(';');
|
||||
}
|
||||
async function extractTransactionsFromPage(image: string, pageNum: number): Promise<ITransaction[]> {
|
||||
// Step 1: Count transactions
|
||||
const count = await countTransactions(image, pageNum);
|
||||
|
||||
/**
|
||||
* Extract with consensus voting using MiniCPM-V only
|
||||
*/
|
||||
async function extractWithConsensus(
|
||||
images: string[],
|
||||
maxPasses: number = 5
|
||||
): Promise<ITransaction[]> {
|
||||
const results: Array<{ transactions: ITransaction[]; hash: string }> = [];
|
||||
const hashCounts: Map<string, number> = new Map();
|
||||
if (count === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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)}...)`
|
||||
// Step 2: Query each transaction (in batches to avoid overwhelming)
|
||||
// Each transaction logs itself as it completes
|
||||
const transactions: ITransaction[] = [];
|
||||
const batchSize = 5;
|
||||
|
||||
for (let start = 1; start <= count; start += batchSize) {
|
||||
const end = Math.min(start + batchSize - 1, count);
|
||||
const indices = Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
||||
|
||||
// Query batch in parallel - each logs as it completes
|
||||
const results = await Promise.all(
|
||||
indices.map(i => getTransaction(image, i, pageNum))
|
||||
);
|
||||
return hashCounts.get(hash)!;
|
||||
};
|
||||
|
||||
console.log('[Setup] Using MiniCPM-V only');
|
||||
|
||||
for (let pass = 1; 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;
|
||||
for (const tx of results) {
|
||||
if (tx) {
|
||||
transactions.push(tx);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
console.log(` [Page ${pageNum}] Complete: ${transactions.length}/${count} extracted`);
|
||||
return transactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all transactions from bank statement
|
||||
*/
|
||||
async function extractTransactions(images: string[]): Promise<ITransaction[]> {
|
||||
console.log(` [Vision] Processing ${images.length} page(s) with MiniCPM-V (multi-query, deep think)`);
|
||||
|
||||
const allTransactions: ITransaction[] = [];
|
||||
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const pageTransactions = await extractTransactionsFromPage(images[i], i + 1);
|
||||
allTransactions.push(...pageTransactions);
|
||||
}
|
||||
|
||||
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;
|
||||
console.log(` [Vision] Total: ${allTransactions.length} transactions`);
|
||||
return allTransactions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,62 +256,69 @@ function findTestCases(): Array<{ name: string; pdfPath: string; jsonPath: strin
|
||||
}
|
||||
}
|
||||
|
||||
return testCases;
|
||||
return testCases.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// Tests
|
||||
|
||||
tap.test('setup: ensure Docker containers are running', async () => {
|
||||
console.log('\n[Setup] Checking Docker containers...\n');
|
||||
|
||||
// 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 () => {
|
||||
tap.test('should have MiniCPM-V 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();
|
||||
expect(modelNames.some((name: string) => name.includes('minicpm'))).toBeTrue();
|
||||
});
|
||||
|
||||
// Dynamic test for each PDF/JSON pair
|
||||
const testCases = findTestCases();
|
||||
console.log(`\nFound ${testCases.length} bank statement test cases (MiniCPM-V only)\n`);
|
||||
console.log(`\nFound ${testCases.length} bank statement test cases (MiniCPM-V)\n`);
|
||||
|
||||
let passedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const testCase of testCases) {
|
||||
tap.test(`should extract transactions from ${testCase.name}`, async () => {
|
||||
// Load expected transactions
|
||||
tap.test(`should extract: ${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`);
|
||||
|
||||
// Convert PDF to images
|
||||
console.log('Converting PDF to images...');
|
||||
const images = convertPdfToImages(testCase.pdfPath);
|
||||
console.log(`Converted: ${images.length} pages\n`);
|
||||
console.log(` Pages: ${images.length}`);
|
||||
|
||||
// Extract with consensus (MiniCPM-V only)
|
||||
const extracted = await extractWithConsensus(images);
|
||||
console.log(`\nFinal: ${extracted.length} transactions`);
|
||||
const extracted = await extractTransactions(images);
|
||||
console.log(` Extracted: ${extracted.length} transactions`);
|
||||
|
||||
// Compare results
|
||||
const result = compareTransactions(extracted, expected);
|
||||
console.log(`Accuracy: ${result.matches}/${result.total}`);
|
||||
const accuracy = result.total > 0 ? result.matches / result.total : 0;
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
console.log('Errors:');
|
||||
result.errors.forEach((e) => console.log(` - ${e}`));
|
||||
if (accuracy >= 0.95 && extracted.length === expected.length) {
|
||||
passedCount++;
|
||||
console.log(` Result: PASS (${result.matches}/${result.total})`);
|
||||
} else {
|
||||
failedCount++;
|
||||
console.log(` Result: FAIL (${result.matches}/${result.total})`);
|
||||
result.errors.slice(0, 5).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);
|
||||
});
|
||||
}
|
||||
|
||||
tap.test('summary', async () => {
|
||||
const total = testCases.length;
|
||||
console.log(`\n======================================================`);
|
||||
console.log(` Bank Statement Summary (MiniCPM-V)`);
|
||||
console.log(`======================================================`);
|
||||
console.log(` Method: Multi-query (no_think)`);
|
||||
console.log(` Passed: ${passedCount}/${total}`);
|
||||
console.log(` Failed: ${failedCount}/${total}`);
|
||||
console.log(`======================================================\n`);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
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();
|
||||
345
test/test.bankstatements.qwen3vl.ts
Normal file
345
test/test.bankstatements.qwen3vl.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Bank statement extraction using Qwen3-VL 8B Vision (Direct)
|
||||
*
|
||||
* Multi-query approach:
|
||||
* 1. First ask how many transactions on each page
|
||||
* 2. Then query each transaction individually
|
||||
* Single pass, no consensus voting.
|
||||
*/
|
||||
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 ITransaction {
|
||||
date: string;
|
||||
counterparty: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert PDF to PNG images
|
||||
*/
|
||||
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 150 -quality 90 "${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 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Qwen3-VL with a simple prompt
|
||||
*/
|
||||
async function queryVision(image: string, prompt: string): Promise<string> {
|
||||
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],
|
||||
}],
|
||||
stream: false,
|
||||
options: {
|
||||
num_predict: 500,
|
||||
temperature: 0.1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return (data.message?.content || '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count transactions on a page
|
||||
*/
|
||||
async function countTransactions(image: string, pageNum: number): Promise<number> {
|
||||
const response = await queryVision(image,
|
||||
`How many transaction rows are in this bank statement table?
|
||||
Count only the data rows (with dates like "01.01.2024" and amounts like "- 50,00 €").
|
||||
Do NOT count the header row or summary/total rows.
|
||||
Answer with just the number, for example: 7`
|
||||
);
|
||||
|
||||
console.log(` [Page ${pageNum}] Count query response: "${response}"`);
|
||||
const match = response.match(/(\d+)/);
|
||||
const count = match ? parseInt(match[1], 10) : 0;
|
||||
console.log(` [Page ${pageNum}] Parsed count: ${count}`);
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single transaction by index (logs immediately when complete)
|
||||
*/
|
||||
async function getTransaction(image: string, index: number, pageNum: number): Promise<ITransaction | null> {
|
||||
const response = await queryVision(image,
|
||||
`This is a bank statement. Look at transaction row #${index} in the table (counting from top, excluding headers).
|
||||
|
||||
Extract this transaction's details:
|
||||
- Date in YYYY-MM-DD format
|
||||
- Counterparty/description name
|
||||
- Amount as number (negative for debits like "- 21,47 €" = -21.47, positive for credits like "+ 100,00 €" = 100.00)
|
||||
|
||||
Answer in format: DATE|COUNTERPARTY|AMOUNT
|
||||
Example: 2024-01-15|Amazon|−25.99`
|
||||
);
|
||||
|
||||
// Parse the response
|
||||
const lines = response.split('\n').filter(l => l.includes('|'));
|
||||
const line = lines[lines.length - 1] || response;
|
||||
const parts = line.split('|').map(p => p.trim());
|
||||
|
||||
if (parts.length >= 3) {
|
||||
// Parse amount - handle various formats
|
||||
let amountStr = parts[2].replace(/[€$£\s]/g, '').replace('−', '-').replace('–', '-');
|
||||
// European format: comma is decimal
|
||||
if (amountStr.includes(',')) {
|
||||
amountStr = amountStr.replace(/\./g, '').replace(',', '.');
|
||||
}
|
||||
const amount = parseFloat(amountStr) || 0;
|
||||
|
||||
const tx = {
|
||||
date: parts[0],
|
||||
counterparty: parts[1],
|
||||
amount: amount,
|
||||
};
|
||||
// Log immediately as this transaction completes
|
||||
console.log(` [P${pageNum} Tx${index.toString().padStart(2, ' ')}] ${tx.date} | ${tx.counterparty.substring(0, 25).padEnd(25)} | ${tx.amount >= 0 ? '+' : ''}${tx.amount.toFixed(2)}`);
|
||||
return tx;
|
||||
}
|
||||
|
||||
// Log raw response on parse failure
|
||||
console.log(` [P${pageNum} Tx${index.toString().padStart(2, ' ')}] PARSE FAILED: "${response.replace(/\n/g, ' ').substring(0, 60)}..."`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract transactions from a single page using multi-query approach
|
||||
*/
|
||||
async function extractTransactionsFromPage(image: string, pageNum: number): Promise<ITransaction[]> {
|
||||
// Step 1: Count transactions
|
||||
const count = await countTransactions(image, pageNum);
|
||||
|
||||
if (count === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Step 2: Query each transaction (in batches to avoid overwhelming)
|
||||
// Each transaction logs itself as it completes
|
||||
const transactions: ITransaction[] = [];
|
||||
const batchSize = 5;
|
||||
|
||||
for (let start = 1; start <= count; start += batchSize) {
|
||||
const end = Math.min(start + batchSize - 1, count);
|
||||
const indices = Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
||||
|
||||
// Query batch in parallel - each logs as it completes
|
||||
const results = await Promise.all(
|
||||
indices.map(i => getTransaction(image, i, pageNum))
|
||||
);
|
||||
|
||||
for (const tx of results) {
|
||||
if (tx) {
|
||||
transactions.push(tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` [Page ${pageNum}] Complete: ${transactions.length}/${count} extracted`);
|
||||
return transactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all transactions from bank statement
|
||||
*/
|
||||
async function extractTransactions(images: string[]): Promise<ITransaction[]> {
|
||||
console.log(` [Vision] Processing ${images.length} page(s) with Qwen3-VL (multi-query)`);
|
||||
|
||||
const allTransactions: ITransaction[] = [];
|
||||
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const pageTransactions = await extractTransactionsFromPage(images[i], i + 1);
|
||||
allTransactions.push(...pageTransactions);
|
||||
}
|
||||
|
||||
console.log(` [Vision] Total: ${allTransactions.length} transactions`);
|
||||
return allTransactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare transactions
|
||||
*/
|
||||
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 test cases 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: string) => 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 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 available: ${VISION_MODEL}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[Ollama] Pulling ${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');
|
||||
const ollamaOk = await ensureMiniCpm();
|
||||
expect(ollamaOk).toBeTrue();
|
||||
const visionOk = await ensureQwen3Vl();
|
||||
expect(visionOk).toBeTrue();
|
||||
console.log('\n[Setup] Ready!\n');
|
||||
});
|
||||
|
||||
const testCases = findTestCases();
|
||||
console.log(`\nFound ${testCases.length} bank statement test cases (Qwen3-VL)\n`);
|
||||
|
||||
let passedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const testCase of testCases) {
|
||||
tap.test(`should extract: ${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 images = convertPdfToImages(testCase.pdfPath);
|
||||
console.log(` Pages: ${images.length}`);
|
||||
|
||||
const extracted = await extractTransactions(images);
|
||||
console.log(` Extracted: ${extracted.length} transactions`);
|
||||
|
||||
const result = compareTransactions(extracted, expected);
|
||||
const accuracy = result.total > 0 ? result.matches / result.total : 0;
|
||||
|
||||
if (accuracy >= 0.95 && extracted.length === expected.length) {
|
||||
passedCount++;
|
||||
console.log(` Result: PASS (${result.matches}/${result.total})`);
|
||||
} else {
|
||||
failedCount++;
|
||||
console.log(` Result: FAIL (${result.matches}/${result.total})`);
|
||||
result.errors.slice(0, 5).forEach((e) => console.log(` - ${e}`));
|
||||
}
|
||||
|
||||
expect(accuracy).toBeGreaterThan(0.95);
|
||||
expect(extracted.length).toEqual(expected.length);
|
||||
});
|
||||
}
|
||||
|
||||
tap.test('summary', async () => {
|
||||
const total = testCases.length;
|
||||
console.log(`\n======================================================`);
|
||||
console.log(` Bank Statement Summary (Qwen3-VL Vision)`);
|
||||
console.log(`======================================================`);
|
||||
console.log(` Method: Multi-query (count then extract each)`);
|
||||
console.log(` Passed: ${passedCount}/${total}`);
|
||||
console.log(` Failed: ${failedCount}/${total}`);
|
||||
console.log(`======================================================\n`);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* 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.
|
||||
* Multi-query approach with thinking DISABLED for speed.
|
||||
* Single pass, no consensus voting.
|
||||
*/
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as fs from 'fs';
|
||||
@@ -24,28 +24,6 @@ interface IInvoice {
|
||||
total_amount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build extraction prompt (MiniCPM-V only, no OCR augmentation)
|
||||
*/
|
||||
function buildPrompt(): string {
|
||||
return `/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.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert PDF to PNG images using ImageMagick
|
||||
*/
|
||||
@@ -75,122 +53,312 @@ function convertPdfToImages(pdfPath: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Single extraction pass with MiniCPM-V
|
||||
* Query MiniCPM-V for a single field (thinking disabled for speed)
|
||||
*/
|
||||
async function extractOnce(images: string[], passNum: number): Promise<IInvoice> {
|
||||
const payload = {
|
||||
model: MODEL,
|
||||
prompt: buildPrompt(),
|
||||
images,
|
||||
stream: true,
|
||||
options: {
|
||||
num_predict: 2048,
|
||||
temperature: 0.1,
|
||||
},
|
||||
};
|
||||
|
||||
async function queryField(images: string[], question: string): Promise<string> {
|
||||
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
prompt: `/no_think\n${question}`,
|
||||
images: images,
|
||||
stream: false,
|
||||
options: {
|
||||
num_predict: 500,
|
||||
temperature: 0.1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
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 data = await response.json();
|
||||
const content = (data.response || '').trim();
|
||||
|
||||
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);
|
||||
// Return full content (no thinking to filter)
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hash of invoice for comparison (using key fields)
|
||||
* Extract invoice data using multiple queries with validation
|
||||
*/
|
||||
function hashInvoice(invoice: IInvoice): string {
|
||||
return `${invoice.invoice_number}|${invoice.invoice_date}|${invoice.total_amount.toFixed(2)}`;
|
||||
}
|
||||
async function extractInvoiceFromImages(images: string[]): Promise<IInvoice> {
|
||||
console.log(` [Vision] Processing ${images.length} page(s) with MiniCPM-V (multi-query + validation)`);
|
||||
|
||||
/**
|
||||
* Extract with consensus voting using MiniCPM-V only
|
||||
*/
|
||||
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)!;
|
||||
// Log each result as it comes in
|
||||
const queryAndLog = async (name: string, question: string): Promise<string> => {
|
||||
const result = await queryField(images, question);
|
||||
console.log(` [Query] ${name}: "${result}"`);
|
||||
return result;
|
||||
};
|
||||
|
||||
for (let pass = 1; pass <= maxPasses; pass++) {
|
||||
try {
|
||||
const invoice = await extractOnce(images, pass);
|
||||
const count = addResult(invoice, `Pass ${pass}`);
|
||||
// STRATEGY 1: List-then-pick for invoice number (avoids confusion with VAT/customer IDs)
|
||||
// Also ask for invoice number directly as backup
|
||||
const [allNumbers, directInvoiceNum] = await Promise.all([
|
||||
queryAndLog('All Numbers ', `List ALL document numbers visible on this invoice.
|
||||
For each number, identify what type it is.
|
||||
Format: type:number, type:number
|
||||
Example: >>>invoice:R0014359508, vat:DE123456789, customer:K001234<<<`),
|
||||
queryAndLog('Invoice # Dir ', `What is the INVOICE NUMBER (Rechnungsnummer)?
|
||||
NOT the VAT number (starts with DE/IE), NOT customer ID.
|
||||
Look for "Invoice No.", "Rechnungsnr.", "Invoice #", or "Facture".
|
||||
For Adobe: starts with IEE or R followed by digits.
|
||||
Return ONLY the number: >>>IEE2022006460244<<<`),
|
||||
]);
|
||||
|
||||
if (count >= 2) {
|
||||
console.log(` [Consensus] Reached after ${pass} passes`);
|
||||
return invoice;
|
||||
// STRATEGY 2: Query each field with >>> <<< delimiters
|
||||
const [invoiceDate, invoiceDateAlt, vendor, currency, totalAmount, netAmount, vatAmount] = await Promise.all([
|
||||
queryAndLog('Invoice Date ', `Find the INVOICE DATE (when issued, NOT due date).
|
||||
Look for: "Invoice Date", "Rechnungsdatum", "Date", "Datum"
|
||||
Return ONLY the date in YYYY-MM-DD format: >>>2024-01-15<<<`),
|
||||
|
||||
// STRATEGY 3: Ask same question differently for verification
|
||||
queryAndLog('Date Alt ', `What date appears next to the invoice number at the top?
|
||||
Return ONLY YYYY-MM-DD format: >>>2024-01-15<<<`),
|
||||
|
||||
queryAndLog('Vendor ', `What company ISSUED this invoice (seller, not buyer)?
|
||||
Look at letterhead/logo at top.
|
||||
Return ONLY the company name: >>>Adobe Inc.<<<`),
|
||||
|
||||
queryAndLog('Currency ', `What currency symbol appears next to amounts? € $ or £?
|
||||
Return the 3-letter code: >>>EUR<<<`),
|
||||
|
||||
queryAndLog('Total Amount ', `What is the FINAL TOTAL amount (including tax) the customer must pay?
|
||||
Look for "Total", "Grand Total", "Gesamtbetrag" at the bottom.
|
||||
Return ONLY the number (no symbol): >>>24.99<<<`),
|
||||
|
||||
queryAndLog('Net Amount ', `What is the NET/subtotal amount BEFORE tax?
|
||||
Look for "Net", "Netto", "Subtotal".
|
||||
Return ONLY the number: >>>20.99<<<`),
|
||||
|
||||
queryAndLog('VAT Amount ', `What is the VAT/tax amount?
|
||||
Look for "VAT", "MwSt", "USt", "Tax".
|
||||
Return ONLY the number: >>>4.00<<<`),
|
||||
]);
|
||||
|
||||
// Extract value from >>> <<< delimiters, or return original if not found
|
||||
const extractDelimited = (s: string): string => {
|
||||
const match = s.match(/>>>([^<]+)<<</);
|
||||
return match ? match[1].trim() : s.trim();
|
||||
};
|
||||
|
||||
// Parse amount from string (handles European format and prose)
|
||||
const parseAmount = (s: string): number => {
|
||||
if (!s) return 0;
|
||||
|
||||
// First try delimited format
|
||||
const delimitedMatch = s.match(/>>>([^<]+)<<</);
|
||||
if (delimitedMatch) {
|
||||
const numMatch = delimitedMatch[1].match(/([\d.,]+)/);
|
||||
if (numMatch) {
|
||||
const numStr = numMatch[1];
|
||||
const normalized = numStr.includes(',') && numStr.indexOf(',') > numStr.lastIndexOf('.')
|
||||
? numStr.replace(/\./g, '').replace(',', '.')
|
||||
: numStr.replace(/,/g, '');
|
||||
return parseFloat(normalized) || 0;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(` [Pass ${pass}] Error: ${err}`);
|
||||
}
|
||||
|
||||
// Try to find amount patterns in prose: "24.99", "24,99", "€24.99", "24.99 EUR"
|
||||
const amountPatterns = [
|
||||
/(?:€|EUR|USD|GBP)\s*([\d.,]+)/i, // €24.99 or EUR 24.99
|
||||
/([\d.,]+)\s*(?:€|EUR|USD|GBP)/i, // 24.99 EUR or 24.99€
|
||||
/(?:is|amount|total)[:\s]+([\d.,]+)/i, // "is 24.99" or "amount: 24.99"
|
||||
/\b(\d{1,3}(?:[.,]\d{2,3})*(?:[.,]\d{2}))\b/, // General number pattern with decimals
|
||||
];
|
||||
|
||||
for (const pattern of amountPatterns) {
|
||||
const match = s.match(pattern);
|
||||
if (match) {
|
||||
const numStr = match[1];
|
||||
// European format: 1.234,56 → 1234.56
|
||||
const normalized = numStr.includes(',') && numStr.indexOf(',') > numStr.lastIndexOf('.')
|
||||
? numStr.replace(/\./g, '').replace(',', '.')
|
||||
: numStr.replace(/,/g, '');
|
||||
const value = parseFloat(normalized);
|
||||
if (value > 0) return value;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
// STRATEGY 1: Parse "all numbers" to find invoice number
|
||||
const extractInvoiceFromList = (allNums: string): string | null => {
|
||||
const delimited = extractDelimited(allNums);
|
||||
|
||||
// Find ALL "invoice:XXX" matches
|
||||
const invoiceMatches = delimited.matchAll(/invoice[:\s]*([A-Z0-9-]+)/gi);
|
||||
const candidates: string[] = [];
|
||||
for (const match of invoiceMatches) {
|
||||
const value = match[1];
|
||||
// Filter out labels like "USt-IdNr", "INVOICE", short strings
|
||||
if (value.length > 5 && /\d{4,}/.test(value) && !/^(ust|vat|tax|nr|id|no)/i.test(value)) {
|
||||
candidates.push(value);
|
||||
}
|
||||
}
|
||||
if (candidates.length > 0) return candidates[0];
|
||||
|
||||
// Look for "rechnungsnr:XXX" pattern
|
||||
const rechnungMatch = delimited.match(/rechnung[snr]*[:\s]*([A-Z0-9-]{6,})/i);
|
||||
if (rechnungMatch && /\d{4,}/.test(rechnungMatch[1])) return rechnungMatch[1];
|
||||
|
||||
// Look for patterns like IEE2022..., R001... (Adobe invoice number patterns)
|
||||
const adobeMatch = delimited.match(/\b(IEE\d{10,})\b/i);
|
||||
if (adobeMatch) return adobeMatch[1];
|
||||
const rInvoiceMatch = delimited.match(/\b(R\d{8,})\b/i);
|
||||
if (rInvoiceMatch) return rInvoiceMatch[1];
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Fallback invoice number extraction
|
||||
const extractInvoiceNumber = (s: string): string => {
|
||||
const delimited = extractDelimited(s);
|
||||
if (delimited !== s.trim()) return delimited;
|
||||
|
||||
let clean = s.replace(/\*\*/g, '').replace(/`/g, '');
|
||||
const patterns = [
|
||||
/\b([A-Z]{2,3}\d{10,})\b/i,
|
||||
/\b([A-Z]\d{8,})\b/i,
|
||||
/\b(INV[-\s]?\d{4}[-\s]?\d+)\b/i,
|
||||
/\b(\d{7,})\b/,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = clean.match(pattern);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return clean.replace(/[^A-Z0-9-]/gi, '').trim() || clean.trim();
|
||||
};
|
||||
|
||||
// Extract date with fallback
|
||||
const extractDate = (s: string): string => {
|
||||
const delimited = extractDelimited(s);
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(delimited)) return delimited;
|
||||
|
||||
let clean = s.replace(/\*\*/g, '').replace(/`/g, '');
|
||||
const isoMatch = clean.match(/(\d{4}-\d{2}-\d{2})/);
|
||||
if (isoMatch) return isoMatch[1];
|
||||
const dmmyMatch = clean.match(/(\d{1,2})[-\/]([A-Z]{3})[-\/](\d{4})/i);
|
||||
if (dmmyMatch) {
|
||||
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',
|
||||
};
|
||||
return `${dmmyMatch[3]}-${monthMap[dmmyMatch[2].toUpperCase()] || '01'}-${dmmyMatch[1].padStart(2, '0')}`;
|
||||
}
|
||||
const dmyMatch = clean.match(/(\d{1,2})[\/.](\d{1,2})[\/.](\d{4})/);
|
||||
if (dmyMatch) {
|
||||
return `${dmyMatch[3]}-${dmyMatch[2].padStart(2, '0')}-${dmyMatch[1].padStart(2, '0')}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// Extract currency
|
||||
const extractCurrency = (s: string): string => {
|
||||
const delimited = extractDelimited(s);
|
||||
if (['EUR', 'USD', 'GBP'].includes(delimited.toUpperCase())) return delimited.toUpperCase();
|
||||
const upper = s.toUpperCase();
|
||||
if (upper.includes('EUR') || upper.includes('€')) return 'EUR';
|
||||
if (upper.includes('USD') || upper.includes('$')) return 'USD';
|
||||
if (upper.includes('GBP') || upper.includes('£')) return 'GBP';
|
||||
return 'EUR';
|
||||
};
|
||||
|
||||
// Extract vendor
|
||||
const extractVendor = (s: string): string => {
|
||||
const delimited = extractDelimited(s);
|
||||
if (delimited !== s.trim()) return delimited;
|
||||
let clean = s.replace(/\*\*/g, '').replace(/`/g, '').trim();
|
||||
if (clean.length < 50) return clean.replace(/[."]+$/, '').trim();
|
||||
const companyMatch = clean.match(/([A-Z][A-Za-z0-9\s&]+(?:Ltd|Limited|GmbH|Inc|BV|AG|SE|LLC|Co|Corp)[.]?)/i);
|
||||
if (companyMatch) return companyMatch[1].trim();
|
||||
return clean;
|
||||
};
|
||||
|
||||
// STRATEGY 1: Get invoice number - try multiple approaches
|
||||
// 1. From list with type labels
|
||||
// 2. From direct query
|
||||
// 3. From pattern matching
|
||||
const fromList = extractInvoiceFromList(allNumbers);
|
||||
const fromDirect = extractInvoiceNumber(directInvoiceNum);
|
||||
const fromFallback = extractInvoiceNumber(allNumbers);
|
||||
|
||||
// Prefer direct query if it has digits, otherwise use list
|
||||
const invoiceNumber = (fromDirect && /\d{6,}/.test(fromDirect)) ? fromDirect :
|
||||
(fromList && /\d{4,}/.test(fromList)) ? fromList :
|
||||
fromDirect || fromList || fromFallback;
|
||||
console.log(` [Parsed] Invoice Number: "${invoiceNumber}" (list: ${fromList}, direct: ${fromDirect})`);
|
||||
|
||||
// STRATEGY 3: Compare two date responses, pick the valid one
|
||||
const date1 = extractDate(invoiceDate);
|
||||
const date2 = extractDate(invoiceDateAlt);
|
||||
const finalDate = date1 || date2;
|
||||
if (date1 && date2 && date1 !== date2) {
|
||||
console.log(` [Validate] Date mismatch: "${date1}" vs "${date2}" - using first`);
|
||||
}
|
||||
|
||||
// Parse amounts
|
||||
let total = parseAmount(totalAmount);
|
||||
let net = parseAmount(netAmount);
|
||||
let vat = parseAmount(vatAmount);
|
||||
|
||||
// STRATEGY 4: Cross-field validation for amounts
|
||||
// If amounts seem wrong (e.g., 1690 instead of 1.69), try to fix
|
||||
if (total > 10000 && net < 100) {
|
||||
console.log(` [Validate] Total ${total} seems too high vs net ${net}, dividing by 100`);
|
||||
total = total / 100;
|
||||
}
|
||||
if (net > 10000 && total < 100) {
|
||||
console.log(` [Validate] Net ${net} seems too high vs total ${total}, dividing by 100`);
|
||||
net = net / 100;
|
||||
}
|
||||
|
||||
// Check if Net + VAT ≈ Total
|
||||
if (net > 0 && vat >= 0 && total > 0) {
|
||||
const calculated = net + vat;
|
||||
if (Math.abs(calculated - total) > 1) {
|
||||
console.log(` [Validate] Math check: ${net} + ${vat} = ${calculated} ≠ ${total}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
return {
|
||||
invoice_number: invoiceNumber,
|
||||
invoice_date: finalDate,
|
||||
vendor_name: extractVendor(vendor),
|
||||
currency: extractCurrency(currency),
|
||||
net_amount: net,
|
||||
vat_amount: vat,
|
||||
total_amount: total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')}`;
|
||||
}
|
||||
|
||||
if (!bestHash) {
|
||||
throw new Error(`No valid results for ${invoiceName}`);
|
||||
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')}`;
|
||||
}
|
||||
|
||||
const best = results.find((r) => r.hash === bestHash)!;
|
||||
console.log(` [No consensus] Using most common result (${bestCount}/${maxPasses} passes)`);
|
||||
return best.invoice;
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,7 +378,7 @@ function compareInvoice(
|
||||
}
|
||||
|
||||
// Compare date
|
||||
if (extracted.invoice_date !== expected.invoice_date) {
|
||||
if (normalizeDate(extracted.invoice_date) !== normalizeDate(expected.invoice_date)) {
|
||||
errors.push(`invoice_date: expected "${expected.invoice_date}", got "${extracted.invoice_date}"`);
|
||||
}
|
||||
|
||||
@@ -252,9 +420,7 @@ function findTestCases(): Array<{ name: string; pdfPath: string; jsonPath: strin
|
||||
}
|
||||
}
|
||||
|
||||
// Sort alphabetically
|
||||
testCases.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return testCases;
|
||||
}
|
||||
|
||||
@@ -262,24 +428,20 @@ function findTestCases(): Array<{ name: string; pdfPath: string; jsonPath: strin
|
||||
|
||||
tap.test('setup: ensure Docker containers are running', async () => {
|
||||
console.log('\n[Setup] Checking Docker containers...\n');
|
||||
|
||||
// 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 () => {
|
||||
tap.test('should have MiniCPM-V 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();
|
||||
expect(modelNames.some((name: string) => name.includes('minicpm'))).toBeTrue();
|
||||
});
|
||||
|
||||
// Dynamic test for each PDF/JSON pair
|
||||
const testCases = findTestCases();
|
||||
console.log(`\nFound ${testCases.length} invoice test cases (MiniCPM-V only)\n`);
|
||||
console.log(`\nFound ${testCases.length} invoice test cases (MiniCPM-V)\n`);
|
||||
|
||||
let passedCount = 0;
|
||||
let failedCount = 0;
|
||||
@@ -287,25 +449,20 @@ 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 (MiniCPM-V only)
|
||||
const extracted = await extractWithConsensus(images, testCase.name);
|
||||
const extracted = await extractInvoiceFromImages(images);
|
||||
console.log(` Extracted: ${extracted.invoice_number} | ${extracted.invoice_date} | ${extracted.total_amount} ${extracted.currency}`);
|
||||
|
||||
const endTime = Date.now();
|
||||
const elapsedMs = endTime - startTime;
|
||||
const elapsedMs = Date.now() - startTime;
|
||||
processingTimes.push(elapsedMs);
|
||||
|
||||
// Compare results
|
||||
const result = compareInvoice(extracted, expected);
|
||||
|
||||
if (result.match) {
|
||||
@@ -317,7 +474,6 @@ for (const testCase of testCases) {
|
||||
result.errors.forEach((e) => console.log(` - ${e}`));
|
||||
}
|
||||
|
||||
// Assert match
|
||||
expect(result.match).toBeTrue();
|
||||
});
|
||||
}
|
||||
@@ -326,18 +482,17 @@ 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;
|
||||
const avgTimeSec = processingTimes.length > 0 ? totalTimeMs / processingTimes.length / 1000 : 0;
|
||||
|
||||
console.log(`\n========================================`);
|
||||
console.log(` Invoice Extraction Summary (MiniCPM)`);
|
||||
console.log(`========================================`);
|
||||
console.log(` Method: Multi-query (no_think)`);
|
||||
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(` Total time: ${(totalTimeMs / 1000).toFixed(1)}s`);
|
||||
console.log(` Avg per inv: ${avgTimeSec.toFixed(1)}s`);
|
||||
console.log(`========================================\n`);
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -4,22 +4,25 @@
|
||||
* This tests the complete PaddleOCR-VL pipeline:
|
||||
* 1. PP-DocLayoutV2 for layout detection
|
||||
* 2. PaddleOCR-VL for recognition
|
||||
* 3. Structured Markdown output
|
||||
* 4. MiniCPM extracts invoice fields from structured Markdown
|
||||
* 3. Structured HTML output (semantic tags with proper tables)
|
||||
* 4. Qwen2.5 extracts invoice fields from structured HTML
|
||||
*
|
||||
* The structured Markdown has proper tables and formatting,
|
||||
* making it much easier for MiniCPM to extract invoice data.
|
||||
* 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, ensureMiniCpm } from './helpers/docker.js';
|
||||
import { ensurePaddleOcrVlFull, ensureQwen25 } from './helpers/docker.js';
|
||||
|
||||
const PADDLEOCR_VL_URL = 'http://localhost:8000';
|
||||
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 {
|
||||
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> {
|
||||
const response = await fetch(`${PADDLEOCR_VL_URL}/parse`, {
|
||||
@@ -68,7 +71,7 @@ async function parseDocument(imageBase64: string): Promise<string> {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
image: imageBase64,
|
||||
output_format: 'markdown',
|
||||
output_format: 'html',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -83,54 +86,63 @@ async function parseDocument(imageBase64: string): Promise<string> {
|
||||
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
|
||||
* Extract invoice fields using simple direct prompt
|
||||
* The OCR output has clearly labeled fields - just ask the LLM to read them
|
||||
*/
|
||||
async function extractInvoiceFromMarkdown(markdown: string, images: string[]): Promise<IInvoice> {
|
||||
// Truncate if too long
|
||||
const truncated = markdown.length > 8000 ? markdown.slice(0, 8000) : markdown;
|
||||
console.log(` [Extract] Processing ${truncated.length} chars of Markdown`);
|
||||
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`);
|
||||
|
||||
const prompt = `/nothink
|
||||
You are an invoice parser. Extract fields from this invoice image.
|
||||
|
||||
Required fields:
|
||||
- invoice_number: The invoice/receipt number
|
||||
- invoice_date: Date in YYYY-MM-DD format
|
||||
- vendor_name: Company that issued the invoice
|
||||
- currency: EUR, USD, etc.
|
||||
- net_amount: Amount before tax
|
||||
- vat_amount: Tax/VAT amount (0 if reverse charge)
|
||||
- total_amount: Final amount due
|
||||
|
||||
Return ONLY a JSON object like:
|
||||
{"invoice_number":"123","invoice_date":"2022-01-28","vendor_name":"Adobe","currency":"EUR","net_amount":24.99,"vat_amount":0,"total_amount":24.99}
|
||||
|
||||
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,
|
||||
// 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 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',
|
||||
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) {
|
||||
@@ -155,7 +167,9 @@ ${truncated}
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.response) {
|
||||
if (json.message?.content) {
|
||||
fullText += json.message.content;
|
||||
} else if (json.response) {
|
||||
fullText += json.response;
|
||||
}
|
||||
} catch {
|
||||
@@ -165,34 +179,78 @@ ${truncated}
|
||||
}
|
||||
|
||||
// Extract JSON from response
|
||||
const startIdx = fullText.indexOf('{');
|
||||
const endIdx = fullText.lastIndexOf('}') + 1;
|
||||
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)}`);
|
||||
}
|
||||
|
||||
const jsonStr = fullText.substring(startIdx, endIdx);
|
||||
return JSON.parse(jsonStr);
|
||||
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 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> {
|
||||
// Parse document with full pipeline
|
||||
const markdown = await parseDocument(images[0]);
|
||||
console.log(` [Parse] Got ${markdown.split('\n').length} lines of Markdown`);
|
||||
// Parse ALL pages and concatenate HTML with page markers
|
||||
const htmlParts: string[] = [];
|
||||
|
||||
// Extract invoice fields from Markdown with image context
|
||||
return extractInvoiceFromMarkdown(markdown, images);
|
||||
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 {
|
||||
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 +301,43 @@ async function extractWithConsensus(images: string[], invoiceName: string, maxPa
|
||||
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
|
||||
*/
|
||||
@@ -259,8 +354,10 @@ function compareInvoice(
|
||||
errors.push(`invoice_number: expected "${expected.invoice_number}", got "${extracted.invoice_number}"`);
|
||||
}
|
||||
|
||||
// Compare date
|
||||
if (extracted.invoice_date !== expected.invoice_date) {
|
||||
// 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}"`);
|
||||
}
|
||||
|
||||
@@ -317,9 +414,9 @@ tap.test('setup: ensure Docker containers are running', async () => {
|
||||
const paddleOk = await ensurePaddleOcrVlFull();
|
||||
expect(paddleOk).toBeTrue();
|
||||
|
||||
// Ensure MiniCPM is running (for field extraction from Markdown)
|
||||
const minicpmOk = await ensureMiniCpm();
|
||||
expect(minicpmOk).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');
|
||||
});
|
||||
@@ -380,7 +477,7 @@ tap.test('summary', async () => {
|
||||
console.log(`\n======================================================`);
|
||||
console.log(` Invoice Extraction Summary (PaddleOCR-VL Full)`);
|
||||
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(` Failed: ${failedCount}/${totalInvoices}`);
|
||||
console.log(` Accuracy: ${accuracy.toFixed(1)}%`);
|
||||
|
||||
351
test/test.invoices.qwen3vl.ts
Normal file
351
test/test.invoices.qwen3vl.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Invoice extraction using Qwen3-VL 8B Vision (Direct)
|
||||
*
|
||||
* Multi-query approach: 5 parallel simple queries to avoid token exhaustion.
|
||||
* Single pass, no consensus voting.
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Qwen3-VL for a single field
|
||||
* Uses simple prompts to minimize thinking tokens
|
||||
*/
|
||||
async function queryField(images: string[], question: string): Promise<string> {
|
||||
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: `${question} Reply with just the value, nothing else.`,
|
||||
images: images,
|
||||
}],
|
||||
stream: false,
|
||||
options: {
|
||||
num_predict: 500,
|
||||
temperature: 0.1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return (data.message?.content || '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract invoice data using multiple simple queries
|
||||
* Each query asks for 1-2 fields to minimize thinking tokens
|
||||
* (Qwen3's thinking mode uses all tokens on complex prompts)
|
||||
*/
|
||||
async function extractInvoiceFromImages(images: string[]): Promise<IInvoice> {
|
||||
console.log(` [Vision] Processing ${images.length} page(s) with Qwen3-VL (multi-query)`);
|
||||
|
||||
// Query each field separately to avoid excessive thinking tokens
|
||||
// Use explicit questions to avoid confusion between similar fields
|
||||
// Log each result as it comes in (not waiting for all to complete)
|
||||
const queryAndLog = async (name: string, question: string): Promise<string> => {
|
||||
const result = await queryField(images, question);
|
||||
console.log(` [Query] ${name}: "${result}"`);
|
||||
return result;
|
||||
};
|
||||
|
||||
const [invoiceNum, invoiceDate, vendor, currency, totalAmount, netAmount, vatAmount] = await Promise.all([
|
||||
queryAndLog('Invoice Number', 'What is the INVOICE NUMBER (not VAT number, not customer ID)? Look for "Invoice No", "Invoice #", "Rechnung Nr", "Facture". Just the number/code.'),
|
||||
queryAndLog('Invoice Date ', 'What is the INVOICE DATE (not due date, not delivery date)? The date the invoice was issued. Format: YYYY-MM-DD'),
|
||||
queryAndLog('Vendor ', 'What company ISSUED this invoice (the seller/vendor, not the buyer)? Look at the letterhead or "From" section.'),
|
||||
queryAndLog('Currency ', 'What CURRENCY is used? Look for € (EUR), $ (USD), or £ (GBP). Answer with 3-letter code: EUR, USD, or GBP'),
|
||||
queryAndLog('Total Amount ', 'What is the TOTAL AMOUNT INCLUDING TAX (the final amount to pay, with VAT/tax included)? Just the number, e.g. 24.99'),
|
||||
queryAndLog('Net Amount ', 'What is the NET AMOUNT (subtotal before VAT/tax)? Just the number, e.g. 20.99'),
|
||||
queryAndLog('VAT Amount ', 'What is the VAT/TAX AMOUNT? Just the number, e.g. 4.00'),
|
||||
]);
|
||||
|
||||
// Parse amount from string (handles European format)
|
||||
const parseAmount = (s: string): number => {
|
||||
if (!s) return 0;
|
||||
// Extract number from the response
|
||||
const match = s.match(/([\d.,]+)/);
|
||||
if (!match) return 0;
|
||||
const numStr = match[1];
|
||||
// Handle European format: 1.234,56 → 1234.56
|
||||
const normalized = numStr.includes(',') && numStr.indexOf(',') > numStr.lastIndexOf('.')
|
||||
? numStr.replace(/\./g, '').replace(',', '.')
|
||||
: numStr.replace(/,/g, '');
|
||||
return parseFloat(normalized) || 0;
|
||||
};
|
||||
|
||||
// Extract invoice number from potentially verbose response
|
||||
const extractInvoiceNumber = (s: string): string => {
|
||||
let clean = s.replace(/\*\*/g, '').replace(/`/g, '').trim();
|
||||
// Look for common invoice number patterns
|
||||
const patterns = [
|
||||
/\b([A-Z]{2,3}\d{10,})\b/i, // IEE2022006460244
|
||||
/\b([A-Z]\d{8,})\b/i, // R0014359508
|
||||
/\b(INV[-\s]?\d{4}[-\s]?\d+)\b/i, // INV-2024-001
|
||||
/\b(\d{7,})\b/, // 1579087430
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = clean.match(pattern);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return clean.replace(/[^A-Z0-9-]/gi, '').trim() || clean;
|
||||
};
|
||||
|
||||
// Extract date (YYYY-MM-DD) from response
|
||||
const extractDate = (s: string): string => {
|
||||
let clean = s.replace(/\*\*/g, '').replace(/`/g, '').trim();
|
||||
const isoMatch = clean.match(/(\d{4}-\d{2}-\d{2})/);
|
||||
if (isoMatch) return isoMatch[1];
|
||||
return clean.replace(/[^\d-]/g, '').trim();
|
||||
};
|
||||
|
||||
// Extract currency
|
||||
const extractCurrency = (s: string): string => {
|
||||
const upper = s.toUpperCase();
|
||||
if (upper.includes('EUR') || upper.includes('€')) return 'EUR';
|
||||
if (upper.includes('USD') || upper.includes('$')) return 'USD';
|
||||
if (upper.includes('GBP') || upper.includes('£')) return 'GBP';
|
||||
return 'EUR';
|
||||
};
|
||||
|
||||
return {
|
||||
invoice_number: extractInvoiceNumber(invoiceNum),
|
||||
invoice_date: extractDate(invoiceDate),
|
||||
vendor_name: vendor.replace(/\*\*/g, '').replace(/`/g, '').trim() || '',
|
||||
currency: extractCurrency(currency),
|
||||
net_amount: parseAmount(netAmount),
|
||||
vat_amount: parseAmount(vatAmount),
|
||||
total_amount: parseAmount(totalAmount),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: Multi-query (single pass)`);
|
||||
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