mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Exclude Python virtualenvs from the Docker build context (#7658)
# Description of Changes Stirling engine docker slimming Exclude Python virtualenvs from the Docker build context Drop unused provider SDKs from the engine dependency set Retry the SQLite WAL switch when workers race on startup Build the engine image in two stages and run it unprivileged Swap voyage SDK for api call removing 200MB bloat Bundle the AI engine in the fat image Publish the AI engine as a standalone image 886MB to 295MB in docker file And Docker fat is only 230MB bigger after adding (since it already has python and some deps) --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details.
This commit is contained in:
@@ -96,6 +96,14 @@ configs/
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
|
||||
# Python virtualenvs. Large, platform-specific, and their symlinks break the build.
|
||||
.venv/
|
||||
**/.venv/
|
||||
venv/
|
||||
**/venv/
|
||||
*.egg-info/
|
||||
**/*.egg-info/
|
||||
|
||||
# Local env
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -18,6 +18,16 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
build_engine:
|
||||
description: "Build & push the standalone stirling-engine image."
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
force_engine_rebuild:
|
||||
description: "Rebuild stirling-engine even if its source hash is unchanged."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches:
|
||||
- release
|
||||
@@ -50,6 +60,7 @@ jobs:
|
||||
env:
|
||||
RUN_MAIN_APP: ${{ github.event_name != 'workflow_dispatch' || inputs.build_main_app }}
|
||||
RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }}
|
||||
RUN_ENGINE: ${{ github.event_name != 'workflow_dispatch' || inputs.build_engine }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
@@ -387,3 +398,119 @@ jobs:
|
||||
else
|
||||
echo "Warning: COSIGN_PRIVATE_KEY not set, skipping unoserver image signing"
|
||||
fi
|
||||
|
||||
# Standalone AI engine image, same shape as the unoserver image above.
|
||||
- name: Compute engine image source hash
|
||||
id: engineHash
|
||||
if: env.RUN_ENGINE == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
hash=$( { cat engine/Dockerfile engine/pyproject.toml engine/uv.lock engine/.env; \
|
||||
find engine/src -type f -print0 | sort -z | xargs -0 cat; } \
|
||||
| sha256sum | cut -d' ' -f1)
|
||||
echo "hash=${hash}" >> "$GITHUB_OUTPUT"
|
||||
echo "Engine source hash: ${hash}"
|
||||
|
||||
- name: Decide whether to publish engine image
|
||||
id: engineDecision
|
||||
if: env.RUN_ENGINE == 'true'
|
||||
env:
|
||||
ENGINE_VERSION: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
ENGINE_HASH: ${{ steps.engineHash.outputs.hash }}
|
||||
ENGINE_IMAGE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-engine
|
||||
ENGINE_HASH_ANNOTATION: org.stirlingpdf.engine-source-hash
|
||||
FORCE_REBUILD: ${{ inputs.force_engine_rebuild }}
|
||||
GH_REF: ${{ github.ref }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -eu
|
||||
mode="skip"
|
||||
tags=""
|
||||
|
||||
read_published_hash() {
|
||||
local ref="$1"
|
||||
docker buildx imagetools inspect "$ref" --raw 2>/dev/null \
|
||||
| jq -r --arg key "$ENGINE_HASH_ANNOTATION" \
|
||||
'.annotations[$key] // empty' \
|
||||
2>/dev/null || true
|
||||
}
|
||||
|
||||
# Manual dispatch from any branch routes to the :alpha publish path.
|
||||
EFFECTIVE_REF="$GH_REF"
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
EFFECTIVE_REF="refs/heads/testMain"
|
||||
fi
|
||||
|
||||
case "$EFFECTIVE_REF" in
|
||||
refs/heads/release)
|
||||
if [ "${FORCE_REBUILD}" = "true" ]; then
|
||||
echo "force_engine_rebuild=true — building stable regardless"
|
||||
mode="stable"
|
||||
tags="${ENGINE_IMAGE}:${ENGINE_VERSION},${ENGINE_IMAGE}:latest"
|
||||
elif docker manifest inspect "${ENGINE_IMAGE}:${ENGINE_VERSION}" >/dev/null 2>&1; then
|
||||
echo "stirling-engine:${ENGINE_VERSION} already on GHCR — skipping"
|
||||
else
|
||||
echo "stirling-engine:${ENGINE_VERSION} is new — will publish"
|
||||
mode="stable"
|
||||
tags="${ENGINE_IMAGE}:${ENGINE_VERSION},${ENGINE_IMAGE}:latest"
|
||||
fi
|
||||
;;
|
||||
refs/heads/main|refs/heads/testMain)
|
||||
published_hash=$(read_published_hash "${ENGINE_IMAGE}:alpha")
|
||||
if [ "${FORCE_REBUILD}" = "true" ]; then
|
||||
echo "force_engine_rebuild=true — rebuilding :alpha regardless"
|
||||
mode="alpha"
|
||||
tags="${ENGINE_IMAGE}:alpha"
|
||||
elif [ -n "$published_hash" ] && [ "$published_hash" = "$ENGINE_HASH" ]; then
|
||||
echo "Published :alpha source hash matches (${published_hash}) — skipping"
|
||||
else
|
||||
if [ -z "$published_hash" ]; then
|
||||
echo ":alpha has no source-hash annotation (first publish) — will publish"
|
||||
else
|
||||
echo "Source hash changed (was ${published_hash}, now ${ENGINE_HASH}) — will publish"
|
||||
fi
|
||||
mode="alpha"
|
||||
tags="${ENGINE_IMAGE}:alpha"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Branch ${GH_REF} does not publish engine image"
|
||||
;;
|
||||
esac
|
||||
echo "mode=${mode}" >> "$GITHUB_OUTPUT"
|
||||
echo "tags=${tags}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push engine image
|
||||
id: build-push-engine
|
||||
if: env.RUN_ENGINE == 'true' && steps.engineDecision.outputs.mode != 'skip'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./engine/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-engine
|
||||
cache-to: type=gha,mode=max,scope=stirling-engine
|
||||
tags: ${{ steps.engineDecision.outputs.tags }}
|
||||
# Manifest annotation read by the decision step above to detect drift.
|
||||
annotations: |
|
||||
index:org.stirlingpdf.engine-source-hash=${{ steps.engineHash.outputs.hash }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign engine image
|
||||
if: env.RUN_ENGINE == 'true' && steps.engineDecision.outputs.mode == 'stable'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-engine.outputs.digest }}
|
||||
TAGS: ${{ steps.engineDecision.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
if [ -n "$COSIGN_PRIVATE_KEY" ]; then
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
else
|
||||
echo "Warning: COSIGN_PRIVATE_KEY not set, skipping engine image signing"
|
||||
fi
|
||||
|
||||
@@ -67,6 +67,30 @@ COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
|
||||
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
|
||||
|
||||
|
||||
# Stage 2b: AI engine. Built at its final path so the venv resolves after the copy, on uv's
|
||||
# managed CPython because the runtime base ships Python 3.12.
|
||||
FROM ghcr.io/astral-sh/uv:bookworm-slim@sha256:22334efe746f1b69217d455049b484d7b8cacfb2d5f42555580b62415a98e0a3 AS engine-build
|
||||
ENV UV_PYTHON_INSTALL_DIR=/opt/stirling-engine/python
|
||||
WORKDIR /opt/stirling-engine
|
||||
COPY engine/pyproject.toml engine/uv.lock ./
|
||||
# One layer: trimming in a second RUN would cache the untrimmed copy too, and this build
|
||||
# exports every layer to a GHA cache that is capped repo-wide. Trimming saves ~20MB.
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
set -eux; \
|
||||
apt-get update && apt-get install -y --no-install-recommends binutils; \
|
||||
uv python install 3.13; \
|
||||
uv sync --frozen --no-dev --no-install-project --group engine --python-preference only-managed; \
|
||||
P="$(ls -d /opt/stirling-engine/python/cpython-*)"; \
|
||||
rm -rf "$P/share" "$P/include" \
|
||||
"$P/lib/python3.13/idlelib" "$P/lib/python3.13/tkinter" \
|
||||
"$P/lib/python3.13/ensurepip" "$P/lib/python3.13/pydoc_data" \
|
||||
"$P/lib/python3.13/test" "$P/lib/python3.13/lib2to3"; \
|
||||
find /opt/stirling-engine -name '__pycache__' -type d -prune -exec rm -rf {} + ; \
|
||||
find /opt/stirling-engine \( -name '*.so' -o -name '*.so.*' \) -print0 \
|
||||
| xargs -0 -r strip --strip-unneeded 2>/dev/null || true; \
|
||||
apt-get purge -y binutils; apt-get autoremove -y; rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# Stage 3: Final runtime image on top of pre-built base
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
@@ -84,6 +108,12 @@ COPY --link --from=app-build --chown=1000:1000 \
|
||||
/app/build/libs/restart-helper.jar /restart-helper.jar
|
||||
COPY --link --chown=1000:1000 scripts/ /scripts/
|
||||
|
||||
# init-without-ocr.sh starts the engine when this directory exists, so other images are unaffected.
|
||||
COPY --link --from=engine-build --chown=1000:1000 /opt/stirling-engine/python /opt/stirling-engine/python
|
||||
COPY --link --from=engine-build --chown=1000:1000 /opt/stirling-engine/.venv /opt/stirling-engine/.venv
|
||||
COPY --link --chown=1000:1000 engine/.env /opt/stirling-engine/.env
|
||||
COPY --link --chown=1000:1000 engine/src/ /opt/stirling-engine/src/
|
||||
|
||||
# Fonts go to system dir, root ownership is correct (world-readable)
|
||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
|
||||
|
||||
@@ -97,6 +127,8 @@ RUN set -eux; \
|
||||
ln -s /storage /app/storage; \
|
||||
chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline /app/storage; \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app; \
|
||||
mkdir -p /opt/stirling-engine/data; \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup /opt/stirling-engine/data; \
|
||||
chmod 750 /tmp/stirling-pdf; \
|
||||
chmod 750 /tmp/stirling-pdf/heap_dumps; \
|
||||
fc-cache -f
|
||||
@@ -116,6 +148,10 @@ ENV VERSION_TAG=$VERSION_TAG \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
FAT_DOCKER=true \
|
||||
AIENGINE_ENABLED=true \
|
||||
STIRLING_ENGINE_HOME=/opt/stirling-engine \
|
||||
STIRLING_ENGINE_PORT=5001 \
|
||||
STIRLING_ENGINE_WORKERS=2 \
|
||||
INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
|
||||
+29
-21
@@ -1,35 +1,43 @@
|
||||
# syntax=docker/dockerfile:1.5
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim@sha256:531f855bda2c73cd6ef67d56b733b357cea384185b3022bd09f05e002cd144ca
|
||||
|
||||
ARG TASK_VERSION=3.52.0
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& ARCH=$(dpkg --print-architecture) \
|
||||
&& curl -fsSL "https://github.com/go-task/task/releases/download/v${TASK_VERSION}/task_${TASK_VERSION}_linux_${ARCH}.deb" -o /tmp/task.deb \
|
||||
&& dpkg -i /tmp/task.deb \
|
||||
&& rm /tmp/task.deb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# uv resolves the venv here so its ~52MB binary stays out of the runtime image.
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim@sha256:531f855bda2c73cd6ef67d56b733b357cea384185b3022bd09f05e002cd144ca AS builder
|
||||
|
||||
# Source under /app/engine/ to match root Taskfile's `includes.engine.dir: engine`.
|
||||
WORKDIR /app/engine
|
||||
COPY engine/pyproject.toml engine/uv.lock engine/.env ./
|
||||
COPY engine/scripts/ ./scripts/
|
||||
COPY engine/pyproject.toml engine/uv.lock ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --group engine
|
||||
uv sync --frozen --no-dev --no-install-project --group engine
|
||||
|
||||
COPY engine/src/ ./src/
|
||||
WORKDIR /app
|
||||
COPY Taskfile.yml ./
|
||||
COPY .taskfiles/ ./.taskfiles/
|
||||
FROM python:3.13-slim-bookworm@sha256:00faa2debb87529f9f0764e9491d8ba400a3678976616c3bd7cb193745ac20d1 AS runtime
|
||||
|
||||
# Created before the COPYs so they land owned; a later chown -R duplicates the venv layer.
|
||||
RUN set -eux; \
|
||||
groupadd --system --gid 1000 stirling; \
|
||||
useradd --system --uid 1000 --gid 1000 --home /app/engine stirling; \
|
||||
mkdir -p /app/engine/data; \
|
||||
chown stirling:stirling /app/engine /app/engine/data
|
||||
|
||||
WORKDIR /app/engine
|
||||
COPY --from=builder --chown=stirling:stirling /app/engine/.venv ./.venv
|
||||
# settings.py resolves ENGINE_ROOT to /app/engine, so .env must sit here.
|
||||
COPY --chown=stirling:stirling engine/.env ./
|
||||
COPY --chown=stirling:stirling engine/src/ ./src/
|
||||
|
||||
ENV PATH="/app/engine/.venv/bin:$PATH"
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV STIRLING_ENGINE_WORKERS=4
|
||||
# Container runs on a fixed port; skip the host-only free-port probe (its script
|
||||
# is not shipped in the image). engine:run honours these.
|
||||
ENV ENGINE_PORT_PROBE=false
|
||||
ENV STIRLING_ENGINE_PORT=5001
|
||||
# Fail closed: without a secret the document routes trust caller-supplied X-User-Id.
|
||||
# Set STIRLING_ENGINE_SHARED_SECRET (the backend sends it as X-Engine-Auth), or false to opt out.
|
||||
ENV STIRLING_ENGINE_REQUIRE_AUTH=true
|
||||
|
||||
# `stirling` resolves from the working directory.
|
||||
WORKDIR /app/engine/src
|
||||
USER stirling
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
CMD ["task", "engine:run"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||
CMD ["python", "-c", "import os,sys,urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:%s/health' % os.environ.get('STIRLING_ENGINE_PORT','5001'), timeout=4).status==200 else 1)"]
|
||||
|
||||
CMD ["sh", "-c", "exec uvicorn stirling.api.app:app --host 0.0.0.0 --port ${STIRLING_ENGINE_PORT:-5001} --workers ${STIRLING_ENGINE_WORKERS:-4}"]
|
||||
|
||||
@@ -16,8 +16,9 @@ engine = [
|
||||
"psycopg[binary,pool]>=3.3.4",
|
||||
"pydantic>=2.13.4",
|
||||
# <2 cap: 1.99.0 patches CVE-2026-46678; 2.0 is an untested major migration.
|
||||
"pydantic-ai>=1.107.2,<2.0.0",
|
||||
"pydantic-ai-slim[voyageai]>=1.107.2,<2.0.0",
|
||||
# Explicit extras: the `pydantic-ai` meta-package pulls all 20 providers (~230MB).
|
||||
# No `voyageai` extra either; stirling.documents.voyage speaks its API directly.
|
||||
"pydantic-ai-slim[anthropic,openai]>=1.107.2,<2.0.0",
|
||||
"pydantic-settings>=2.15.0",
|
||||
"python-dotenv>=1.2.2",
|
||||
"sqlite-vec>=0.1.9",
|
||||
|
||||
@@ -70,6 +70,19 @@ Provider credentials (and any local overrides) go in the uncommitted
|
||||
VOYAGE_API_KEY=your-key
|
||||
```
|
||||
|
||||
### Embedding providers
|
||||
|
||||
`STIRLING_RAG_EMBEDDING_MODEL` is a `provider:model` string. Any OpenAI-compatible
|
||||
`/v1/embeddings` endpoint (vLLM, Ollama, TEI, llama.cpp) works by pointing a base URL
|
||||
at it. Note `OPENAI_BASE_URL` is global and also redirects chat completions; push
|
||||
`provider`/`api_key`/`base_url` through admin AI settings to move embeddings only.
|
||||
Ollama reads `OLLAMA_BASE_URL`, and omitting it fails the first embed call, not startup.
|
||||
|
||||
```
|
||||
STIRLING_RAG_EMBEDDING_MODEL=ollama:nomic-embed-text
|
||||
OLLAMA_BASE_URL=http://ollama:11434/v1
|
||||
```
|
||||
|
||||
## Backends
|
||||
|
||||
**`sqlite`** - Embedded sqlite-vec. Single `.db` file, zero ops. Ideal for dev
|
||||
|
||||
@@ -6,6 +6,7 @@ from pydantic_ai.providers.openai import OpenAIProvider
|
||||
|
||||
from stirling.documents.chunker import chunk_text
|
||||
from stirling.documents.store import Document
|
||||
from stirling.documents.voyage import build_voyage_model
|
||||
|
||||
# Keep each upstream embed request under every major provider's per-call limit while
|
||||
# still batching large enough that a book-sized document ingests in a reasonable number
|
||||
@@ -14,6 +15,9 @@ from stirling.documents.store import Document
|
||||
DEFAULT_EMBED_BATCH_SIZE = 256
|
||||
|
||||
|
||||
VOYAGE_PROVIDER = "voyageai"
|
||||
|
||||
|
||||
def _build_embedder(
|
||||
model_name: str,
|
||||
*,
|
||||
@@ -23,11 +27,17 @@ def _build_embedder(
|
||||
) -> Embedder:
|
||||
"""Construct an :class:`Embedder`; explicit provider/api_key/base_url is the config-push path, else env form."""
|
||||
if not provider and not api_key and not base_url:
|
||||
# Env form is a "provider:model" string; Voyage needs the SDK-free adapter.
|
||||
env_provider, sep, env_model = model_name.partition(":")
|
||||
if sep and env_provider.lower() == VOYAGE_PROVIDER:
|
||||
return Embedder(build_voyage_model(env_model))
|
||||
return Embedder(model_name)
|
||||
|
||||
provider_name = (provider or "").lower()
|
||||
key = api_key or None
|
||||
if provider_name in ("voyageai", "openai"):
|
||||
if provider_name == VOYAGE_PROVIDER:
|
||||
return Embedder(build_voyage_model(model_name, api_key=key, base_url=base_url or None))
|
||||
if provider_name == "openai":
|
||||
return Embedder(f"{provider_name}:{model_name}")
|
||||
if provider_name in ("ollama", "custom"):
|
||||
openai_provider = OpenAIProvider(base_url=base_url or None, api_key=key or "ollama")
|
||||
|
||||
@@ -2,9 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -19,11 +21,31 @@ _READ_PERMISSION = "read"
|
||||
# write lock. With multiple worker processes opening the same file, they collide on
|
||||
# startup schema-init and get "database is locked". Wait for the lock instead.
|
||||
_BUSY_TIMEOUT_MS = 5000
|
||||
# journal_mode answers SQLITE_BUSY without consulting the busy handler, so it needs its own retry.
|
||||
_WAL_SWITCH_ATTEMPTS = 10
|
||||
_WAL_RETRY_DELAY_S = 0.1
|
||||
# sqlite stores TIMESTAMP as TEXT. We normalise to UTC ISO 8601 ``YYYY-MM-DD HH:MM:SS``
|
||||
# so lexicographic comparison against ``datetime('now')`` matches chronological order.
|
||||
_SQLITE_DATETIME_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _enable_wal(conn: sqlite3.Connection) -> None:
|
||||
"""Switch the connection to WAL, tolerating workers racing to do the same."""
|
||||
for _ in range(_WAL_SWITCH_ATTEMPTS):
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return
|
||||
except sqlite3.OperationalError:
|
||||
row = conn.execute("PRAGMA journal_mode").fetchone()
|
||||
if row is not None and str(row[0]).lower() == "wal":
|
||||
return # another worker won the race and already switched it
|
||||
time.sleep(_WAL_RETRY_DELAY_S)
|
||||
logger.warning("Could not switch the document store to WAL; continuing on the default journal mode.")
|
||||
|
||||
|
||||
def _to_sqlite_utc(dt: datetime | None) -> str | None:
|
||||
if dt is None:
|
||||
return None
|
||||
@@ -58,7 +80,7 @@ class SqliteVecStore(DocumentStore):
|
||||
if self._db_path is not None:
|
||||
# Set before the WAL switch below: that pragma also takes the lock.
|
||||
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
_enable_wal(conn)
|
||||
|
||||
self._conn = conn
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""VoyageAI embeddings over its OpenAI-shaped REST API.
|
||||
|
||||
The `voyageai` SDK pulls PIL, numpy, tokenizers and langchain at import for multimodal,
|
||||
chunking and local-inference features the engine never uses (~207MB).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
|
||||
from pydantic_ai.embeddings import EmbeddingResult, EmbeddingSettings
|
||||
from pydantic_ai.embeddings.openai import OpenAIEmbeddingModel
|
||||
from pydantic_ai.embeddings.result import EmbedInputType
|
||||
from pydantic_ai.providers.openai import OpenAIProvider
|
||||
|
||||
VOYAGE_BASE_URL = "https://api.voyageai.com/v1"
|
||||
VOYAGE_API_KEY_ENV = "VOYAGE_API_KEY"
|
||||
|
||||
# Keeps a keyless engine bootable, and stops the client falling back to OPENAI_API_KEY.
|
||||
_MISSING_API_KEY = "stirling-voyage-api-key-not-configured"
|
||||
|
||||
|
||||
class VoyageEmbeddingModel(OpenAIEmbeddingModel):
|
||||
"""Voyage embeddings spoken over the OpenAI wire format."""
|
||||
|
||||
async def embed(
|
||||
self,
|
||||
inputs: str | Sequence[str],
|
||||
*,
|
||||
input_type: EmbedInputType,
|
||||
settings: EmbeddingSettings | None = None,
|
||||
) -> EmbeddingResult:
|
||||
"""Embed `inputs`, forwarding Voyage's `input_type` that the OpenAI model drops."""
|
||||
if self._client.api_key == _MISSING_API_KEY:
|
||||
raise ValueError(
|
||||
f"VoyageAI embeddings need an API key: set {VOYAGE_API_KEY_ENV} or push one via admin AI settings."
|
||||
)
|
||||
merged: EmbeddingSettings = {**(settings or {})}
|
||||
# extra_body is declared `object`, so narrow rather than assume a mapping.
|
||||
current = merged.get("extra_body")
|
||||
extra_body: dict[str, object] = dict(current) if isinstance(current, dict) else {}
|
||||
extra_body.setdefault("input_type", input_type)
|
||||
merged["extra_body"] = extra_body
|
||||
return await super().embed(inputs, input_type=input_type, settings=merged)
|
||||
|
||||
|
||||
def build_voyage_model(
|
||||
model_name: str,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> VoyageEmbeddingModel:
|
||||
"""Build a Voyage embedding model; a missing key only fails once an embed is attempted."""
|
||||
key = api_key or os.environ.get(VOYAGE_API_KEY_ENV) or _MISSING_API_KEY
|
||||
provider = OpenAIProvider(base_url=base_url or VOYAGE_BASE_URL, api_key=key)
|
||||
return VoyageEmbeddingModel(model_name, provider=provider)
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from stirling.contracts import PageText
|
||||
@@ -647,3 +649,19 @@ def _dummy_tool_def() -> object:
|
||||
"""Sentinel passed to ``_prepare_search_knowledge``. The callback only inspects
|
||||
``_search_count``; it doesn't read anything off the tool_def or context."""
|
||||
return object()
|
||||
|
||||
|
||||
# concurrent store startup
|
||||
|
||||
|
||||
def test_many_stores_open_the_same_file_without_locking_out(tmp_path: Path) -> None:
|
||||
"""Workers all construct a store against one file on boot; the WAL switch races."""
|
||||
import concurrent.futures
|
||||
|
||||
db_path = tmp_path / "rag.db"
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
||||
stores = list(pool.map(lambda _: SqliteVecStore(db_path), range(8)))
|
||||
|
||||
assert len(stores) == 8
|
||||
mode = stores[0]._conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
assert str(mode).lower() == "wal"
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic_ai import Embedder
|
||||
from pydantic_ai.providers.openai import OpenAIProvider
|
||||
|
||||
from stirling.documents.embedder import _build_embedder
|
||||
from stirling.documents.voyage import VOYAGE_BASE_URL, VoyageEmbeddingModel, build_voyage_model
|
||||
|
||||
# Voyage's documented response body: OpenAI's shape, minus prompt_tokens.
|
||||
VOYAGE_RESPONSE = {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"object": "embedding", "embedding": [0.1, 0.2, 0.3], "index": 0},
|
||||
{"object": "embedding", "embedding": [0.4, 0.5, 0.6], "index": 1},
|
||||
],
|
||||
"model": "voyage-4",
|
||||
"usage": {"total_tokens": 7},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SentRequest:
|
||||
url: str
|
||||
auth: str | None
|
||||
body: dict[str, Any]
|
||||
|
||||
|
||||
def _recording_model(sent: list[SentRequest]) -> VoyageEmbeddingModel:
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
sent.append(
|
||||
SentRequest(
|
||||
url=str(request.url),
|
||||
auth=request.headers.get("authorization"),
|
||||
body=json.loads(request.content),
|
||||
)
|
||||
)
|
||||
return httpx.Response(200, json=VOYAGE_RESPONSE)
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
provider = OpenAIProvider(base_url=VOYAGE_BASE_URL, api_key="pa-test-key", http_client=client)
|
||||
return VoyageEmbeddingModel("voyage-4", provider=provider)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_posts_to_voyage_embeddings_endpoint_with_bearer_auth() -> None:
|
||||
sent: list[SentRequest] = []
|
||||
await Embedder(_recording_model(sent)).embed_documents(["alpha", "beta"])
|
||||
|
||||
assert sent[0].url == f"{VOYAGE_BASE_URL}/embeddings"
|
||||
assert sent[0].auth == "Bearer pa-test-key"
|
||||
assert sent[0].body["model"] == "voyage-4"
|
||||
assert sent[0].body["input"] == ["alpha", "beta"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("call", "expected"),
|
||||
[("embed_query", "query"), ("embed_documents", "document")],
|
||||
)
|
||||
async def test_forwards_voyage_input_type(call: str, expected: str) -> None:
|
||||
"""The stock OpenAI model drops this field; Voyage needs it."""
|
||||
sent: list[SentRequest] = []
|
||||
embedder = Embedder(_recording_model(sent))
|
||||
await getattr(embedder, call)(["text"])
|
||||
|
||||
assert sent[0].body["input_type"] == expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_caller_settings_win_over_the_default_input_type() -> None:
|
||||
sent: list[SentRequest] = []
|
||||
await Embedder(_recording_model(sent)).embed_documents(
|
||||
["text"], settings={"extra_body": {"input_type": "query", "output_dimension": 512}}
|
||||
)
|
||||
|
||||
assert sent[0].body["input_type"] == "query"
|
||||
assert sent[0].body["output_dimension"] == 512
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_parses_voyage_response_into_embeddings() -> None:
|
||||
result = await Embedder(_recording_model([])).embed_documents(["alpha", "beta"])
|
||||
|
||||
assert result.embeddings == [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]
|
||||
|
||||
|
||||
def test_build_voyage_model_reads_the_api_key_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("VOYAGE_API_KEY", "pa-env-key")
|
||||
|
||||
assert build_voyage_model("voyage-4").model_name == "voyage-4"
|
||||
|
||||
|
||||
def test_build_voyage_model_without_a_key_still_constructs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("VOYAGE_API_KEY", raising=False)
|
||||
|
||||
assert build_voyage_model("voyage-4").model_name == "voyage-4"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_embedding_without_a_key_fails_with_a_clear_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("VOYAGE_API_KEY", raising=False)
|
||||
model = build_voyage_model("voyage-4")
|
||||
|
||||
with pytest.raises(ValueError, match="VoyageAI embeddings need an API key"):
|
||||
await Embedder(model).embed_documents(["text"])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_an_openai_key_is_never_sent_to_voyage(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("VOYAGE_API_KEY", raising=False)
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-secret")
|
||||
model = build_voyage_model("voyage-4")
|
||||
|
||||
with pytest.raises(ValueError, match="VoyageAI embeddings need an API key"):
|
||||
await Embedder(model).embed_documents(["text"])
|
||||
|
||||
|
||||
def test_env_form_routes_voyageai_through_the_adapter(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("VOYAGE_API_KEY", "pa-env-key")
|
||||
|
||||
embedder = _build_embedder("voyageai:voyage-4")
|
||||
|
||||
assert isinstance(embedder.model, VoyageEmbeddingModel)
|
||||
assert embedder.model.model_name == "voyage-4"
|
||||
|
||||
|
||||
def test_config_push_form_routes_voyageai_through_the_adapter() -> None:
|
||||
embedder = _build_embedder("voyage-4", provider="voyageai", api_key="pa-pushed-key")
|
||||
|
||||
assert isinstance(embedder.model, VoyageEmbeddingModel)
|
||||
|
||||
|
||||
def test_the_voyageai_sdk_is_not_installed() -> None:
|
||||
"""Guards the ~207MB the SDK would add back."""
|
||||
with pytest.raises(ImportError):
|
||||
__import__("voyageai")
|
||||
|
||||
|
||||
# Live checks, skipped unless VOYAGE_API_KEY is set so CI stays offline.
|
||||
live_only = pytest.mark.skipif(
|
||||
not os.environ.get("VOYAGE_API_KEY"),
|
||||
reason="set VOYAGE_API_KEY to run the live VoyageAI checks",
|
||||
)
|
||||
|
||||
|
||||
def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
|
||||
dot = sum(x * y for x, y in zip(a, b, strict=True))
|
||||
return dot / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)))
|
||||
|
||||
|
||||
@live_only
|
||||
@pytest.mark.anyio
|
||||
async def test_live_voyage_returns_usable_embeddings() -> None:
|
||||
result = await Embedder(build_voyage_model("voyage-4")).embed_documents(["alpha", "beta"])
|
||||
|
||||
assert len(result.embeddings) == 2
|
||||
assert len(result.embeddings[0]) == 1024
|
||||
|
||||
|
||||
@live_only
|
||||
@pytest.mark.anyio
|
||||
async def test_live_voyage_honours_input_type_server_side() -> None:
|
||||
"""Voyage embeds the same text differently per input_type."""
|
||||
embedder = Embedder(build_voyage_model("voyage-4"))
|
||||
text = "How do I combine two PDFs?"
|
||||
|
||||
as_query = await embedder.embed_query(text)
|
||||
as_document = await embedder.embed_documents([text])
|
||||
|
||||
assert _cosine(as_query.embeddings[0], as_document.embeddings[0]) < 0.999
|
||||
|
||||
|
||||
@live_only
|
||||
@pytest.mark.anyio
|
||||
async def test_live_voyage_ranks_the_relevant_document_first() -> None:
|
||||
embedder = Embedder(build_voyage_model("voyage-4"))
|
||||
docs = await embedder.embed_documents(
|
||||
["Stirling PDF merges and splits PDF files.", "The capital of France is Paris."]
|
||||
)
|
||||
query = await embedder.embed_query("How do I combine two PDFs?")
|
||||
|
||||
relevant = _cosine(query.embeddings[0], docs.embeddings[0])
|
||||
irrelevant = _cosine(query.embeddings[0], docs.embeddings[1])
|
||||
assert relevant > irrelevant
|
||||
|
||||
|
||||
@live_only
|
||||
@pytest.mark.anyio
|
||||
async def test_live_voyage_accepts_voyage_only_parameters() -> None:
|
||||
"""output_dimension has no OpenAI equivalent, so this proves extra_body lands."""
|
||||
result = await Embedder(build_voyage_model("voyage-4")).embed_documents(
|
||||
["dimension test"], settings={"extra_body": {"output_dimension": 256}}
|
||||
)
|
||||
|
||||
assert len(result.embeddings[0]) == 256
|
||||
Generated
+2
-1777
File diff suppressed because it is too large
Load Diff
@@ -890,6 +890,11 @@ log "Setting permissions..."
|
||||
mkdir -p /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps /logs /configs /configs/heap_dumps /configs/cache /customFiles /pipeline /storage || true
|
||||
CHOWN_PATHS=("$HOME" "/logs" "/scripts" "/configs" "/customFiles" "/pipeline" "/storage" "/tmp/stirling-pdf" "/app.jar")
|
||||
[ -d /usr/share/fonts/truetype ] && CHOWN_PATHS+=("/usr/share/fonts/truetype")
|
||||
# Chowned here rather than at build time so it follows PUID/PGID remapping.
|
||||
if [ -d "${STIRLING_ENGINE_HOME:-/opt/stirling-engine}" ]; then
|
||||
mkdir -p "${STIRLING_ENGINE_HOME:-/opt/stirling-engine}/data" || true
|
||||
CHOWN_PATHS+=("${STIRLING_ENGINE_HOME:-/opt/stirling-engine}/data")
|
||||
fi
|
||||
CHOWN_OK=true
|
||||
for p in "${CHOWN_PATHS[@]}"; do
|
||||
if [ -e "$p" ]; then
|
||||
@@ -962,6 +967,38 @@ else
|
||||
JAVA_CMD+=("org.springframework.boot.loader.launch.JarLauncher")
|
||||
fi
|
||||
|
||||
# ---------- AI engine ----------
|
||||
# Only the fat image ships it. The backend already defaults to http://localhost:5001.
|
||||
STIRLING_ENGINE_HOME="${STIRLING_ENGINE_HOME:-/opt/stirling-engine}"
|
||||
ENGINE_PID=""
|
||||
if [ -x "$STIRLING_ENGINE_HOME/.venv/bin/python" ] && [ "${AIENGINE_ENABLED:-true}" = "false" ]; then
|
||||
log "AI engine bundled but AIENGINE_ENABLED=false; not starting it."
|
||||
elif [ -x "$STIRLING_ENGINE_HOME/.venv/bin/python" ]; then
|
||||
log "Starting bundled AI engine on port ${STIRLING_ENGINE_PORT:-5001}..."
|
||||
ENGINE_CMD=(
|
||||
"$STIRLING_ENGINE_HOME/.venv/bin/python" -m uvicorn
|
||||
stirling.api.app:app
|
||||
--host 127.0.0.1
|
||||
--port "${STIRLING_ENGINE_PORT:-5001}"
|
||||
--workers "${STIRLING_ENGINE_WORKERS:-2}"
|
||||
--app-dir "$STIRLING_ENGINE_HOME/src"
|
||||
)
|
||||
# init.sh exports PYTHONPATH for unoserver's 3.12 venv; inheriting it breaks the 3.13 engine.
|
||||
if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then
|
||||
env -u PYTHONPATH "${ENGINE_CMD[@]}" &
|
||||
elif [ "$CURRENT_UID" -eq 0 ] && command_exists setpriv; then
|
||||
env -u PYTHONPATH \
|
||||
HOME="$(getent passwd "$RUNTIME_USER" | cut -d: -f6)" \
|
||||
USER="$RUNTIME_USER" \
|
||||
LOGNAME="$RUNTIME_USER" \
|
||||
setpriv --reuid="$RUNTIME_USER" --regid="$(id -gn "$RUNTIME_USER")" --init-groups -- "${ENGINE_CMD[@]}" &
|
||||
else
|
||||
env -u PYTHONPATH "${ENGINE_CMD[@]}" &
|
||||
fi
|
||||
ENGINE_PID=$!
|
||||
log "AI engine started (PID $ENGINE_PID)"
|
||||
fi
|
||||
|
||||
if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then
|
||||
"${JAVA_CMD[@]}" &
|
||||
elif [ "$CURRENT_UID" -eq 0 ] && command_exists setpriv; then
|
||||
@@ -1074,6 +1111,12 @@ fi
|
||||
|
||||
wait "$JAVA_PID" || true
|
||||
exit_code=$?
|
||||
|
||||
if [ -n "$ENGINE_PID" ] && kill -0 "$ENGINE_PID" 2>/dev/null; then
|
||||
log "Stopping AI engine (PID $ENGINE_PID)..."
|
||||
kill "$ENGINE_PID" 2>/dev/null || true
|
||||
wait "$ENGINE_PID" 2>/dev/null || true
|
||||
fi
|
||||
case "$exit_code" in
|
||||
0) log "Stirling PDF exited normally." ;;
|
||||
137) log "Stirling PDF was OOM-killed (exit 137). Check container memory limits." ;;
|
||||
|
||||
Reference in New Issue
Block a user