4 Commits

Author SHA1 Message Date
github-actions[bot]
b299af002b chore: bump version to 0.12.0 [skip ci] 2025-06-03 16:30:28 +00:00
Michele Dolfi
c4c41f16df feat: Export annotations in markdown and html (Docling upgrade) (#202)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-06-03 18:24:27 +02:00
Michele Dolfi
7066f3520a fix: processing complex params in multipart-form (#210)
Signed-off-by: Michele Dolfi <dol@zurich.ibm.com>
2025-06-03 18:24:05 +02:00
Rui Dias Gomes
6a8190c315 docs: add openshift replicasets examples (#209)
Signed-off-by: Rui-Dias-Gomes <rui.dias.gomes@ibm.com>
Co-authored-by: Rui-Dias-Gomes <rui.dias.gomes@ibm.com>
2025-06-03 17:43:41 +02:00
9 changed files with 300 additions and 19 deletions

2
.gitignore vendored
View File

@@ -444,3 +444,5 @@ pip-selfcheck.json
# Makefile
.action-lint
.markdown-lint
cookies.txt

View File

@@ -1,3 +1,17 @@
## [v0.12.0](https://github.com/docling-project/docling-serve/releases/tag/v0.12.0) - 2025-06-03
### Feature
* Export annotations in markdown and html (Docling upgrade) ([#202](https://github.com/docling-project/docling-serve/issues/202)) ([`c4c41f1`](https://github.com/docling-project/docling-serve/commit/c4c41f16dff83c5d2a0b8a4c625b5de19b36b7c5))
### Fix
* Processing complex params in multipart-form ([#210](https://github.com/docling-project/docling-serve/issues/210)) ([`7066f35`](https://github.com/docling-project/docling-serve/commit/7066f3520a88c07df1c80a0cc6c4339eaac4d6a7))
### Documentation
* Add openshift replicasets examples ([#209](https://github.com/docling-project/docling-serve/issues/209)) ([`6a8190c`](https://github.com/docling-project/docling-serve/commit/6a8190c315792bd1e0e2b0af310656baaa5551e5))
## [v0.11.0](https://github.com/docling-project/docling-serve/releases/tag/v0.11.0) - 2025-05-23
### Feature

View File

@@ -359,14 +359,24 @@ class ConvertDocumentsOptions(BaseModel):
picture_description_local: Annotated[
Optional[PictureDescriptionLocal],
Field(
description="Options for running a local vision-language model in the picture description. The parameters refer to a model hosted on Hugging Face. This parameter is mutually exclusive with picture_description_api."
description="Options for running a local vision-language model in the picture description. The parameters refer to a model hosted on Hugging Face. This parameter is mutually exclusive with picture_description_api.",
examples=[
PictureDescriptionLocal(repo_id="ibm-granite/granite-vision-3.2-2b"),
PictureDescriptionLocal(repo_id="HuggingFaceTB/SmolVLM-256M-Instruct"),
],
),
] = None
picture_description_api: Annotated[
Optional[PictureDescriptionApi],
Field(
description="API details for using a vision-language model in the picture description. This parameter is mutually exclusive with picture_description_local."
description="API details for using a vision-language model in the picture description. This parameter is mutually exclusive with picture_description_local.",
examples=[
PictureDescriptionApi(
url="http://localhost:11434/v1/chat/completions",
params={"model": "granite3.2-vision:2b"},
)
],
),
] = None

View File

@@ -1,9 +1,30 @@
import inspect
import json
import re
from typing import Union
from typing import Union, get_args, get_origin
from fastapi import Depends, Form
from pydantic import BaseModel
from pydantic import BaseModel, TypeAdapter
def is_pydantic_model(type_):
try:
if inspect.isclass(type_) and issubclass(type_, BaseModel):
return True
origin = get_origin(type_)
if origin is Union:
args = get_args(type_)
return any(
inspect.isclass(arg) and issubclass(arg, BaseModel)
for arg in args
if arg is not type(None)
)
except Exception:
pass
return False
# Adapted from
@@ -12,25 +33,62 @@ def FormDepends(cls: type[BaseModel]):
new_parameters = []
for field_name, model_field in cls.model_fields.items():
annotation = model_field.annotation
description = model_field.description
default = (
Form(..., description=description)
if model_field.is_required()
else Form(
model_field.default,
examples=model_field.examples,
description=description,
)
)
# Flatten nested Pydantic models by accepting them as JSON strings
if is_pydantic_model(annotation):
annotation = str
default = Form(
None
if model_field.default is None
else json.dumps(model_field.default.model_dump(mode="json")),
description=description,
examples=None
if not model_field.examples
else [
json.dumps(ex.model_dump(mode="json"))
for ex in model_field.examples
],
)
new_parameters.append(
inspect.Parameter(
name=field_name,
kind=inspect.Parameter.POSITIONAL_ONLY,
default=(
Form(...)
if model_field.is_required()
else Form(model_field.default)
),
annotation=model_field.annotation,
default=default,
annotation=annotation,
)
)
async def as_form_func(**data):
for field_name, model_field in cls.model_fields.items():
value = data.get(field_name)
annotation = model_field.annotation
# Parse nested models from JSON string
if value is not None and is_pydantic_model(annotation):
try:
validator = TypeAdapter(annotation)
data[field_name] = validator.validate_json(value)
except Exception as e:
raise ValueError(f"Invalid JSON for field '{field_name}': {e}")
return cls(**data)
sig = inspect.signature(as_form_func)
sig = sig.replace(parameters=new_parameters)
as_form_func.__signature__ = sig # type: ignore
return Depends(as_form_func)

View File

@@ -0,0 +1,76 @@
# This example deployment configures Docling Serve with a Route + Sticky sessions, a Service and cpu image
---
kind: Route
apiVersion: route.openshift.io/v1
metadata:
name: docling-serve
labels:
app: docling-serve
component: docling-serve-api
annotations:
haproxy.router.openshift.io/disable_cookies: "false" # this annotation enables the sticky sessions
spec:
path: /
to:
kind: Service
name: docling-serve
port:
targetPort: http
tls:
termination: edge
insecureEdgeTerminationPolicy: Redirect
---
apiVersion: v1
kind: Service
metadata:
name: docling-serve
labels:
app: docling-serve
component: docling-serve-api
spec:
ports:
- name: http
port: 5001
targetPort: http
selector:
app: docling-serve
component: docling-serve-api
---
kind: Deployment
apiVersion: apps/v1
metadata:
name: docling-serve
labels:
app: docling-serve
component: docling-serve-api
spec:
replicas: 3
selector:
matchLabels:
app: docling-serve
component: docling-serve-api
template:
metadata:
labels:
app: docling-serve
component: docling-serve-api
spec:
restartPolicy: Always
containers:
- name: api
resources:
limits:
cpu: 500m
memory: 2Gi
requests:
cpu: 250m
memory: 1Gi
env:
- name: DOCLING_SERVE_ENABLE_UI
value: 'true'
ports:
- name: http
containerPort: 5001
protocol: TCP
imagePullPolicy: Always
image: 'ghcr.io/docling-project/docling-serve'

View File

@@ -192,3 +192,45 @@ curl -X 'POST' \
"http_sources": [{"url": "https://arxiv.org/pdf/2501.17887"}]
}'
```
### ReplicaSets with `sticky sessions`
Manifest example: [docling-serve-replicas-w-sticky-sessions.yaml](./deploy-examples/docling-serve-replicas-w-sticky-sessions.yaml)
This deployment has the following features:
- Deployment configuration with 3 replicas
- Service configuration
- Expose the service using a OpenShift `Route` and enables sticky sessions
Install the app with:
```sh
oc apply -f docs/deploy-examples/docling-serve-replicas-w-sticky-sessions.yaml
```
For using the API:
```sh
# Retrieve the endpoint
DOCLING_NAME=docling-serve
DOCLING_ROUTE="https://$(oc get routes $DOCLING_NAME --template={{.spec.host}})"
# Make a test query, store the cookie and taskid
task_id=$(curl -s -X 'POST' \
"${DOCLING_ROUTE}/v1alpha/convert/source/async" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"http_sources": [{"url": "https://arxiv.org/pdf/2501.17887"}]
}' \
-c cookies.txt | grep -oP '"task_id":"\K[^"]+')
```
```sh
# Grab the taskid and cookie to check the task status
curl -v -X 'GET' \
"${DOCLING_ROUTE}/v1alpha/status/poll/$task_id?wait=0" \
-H "accept: application/json" \
-b "cookies.txt"
```

View File

@@ -1,6 +1,6 @@
[project]
name = "docling-serve"
version = "0.11.0" # DO NOT EDIT, updated automatically
version = "0.12.0" # DO NOT EDIT, updated automatically
description = "Running Docling as a service"
license = {text = "MIT"}
authors = [
@@ -31,6 +31,7 @@ classifiers = [
requires-python = ">=3.10"
dependencies = [
"docling[vlm]~=2.28",
"docling-core>=2.32.0",
"mlx-vlm~=0.1.12; sys_platform == 'darwin' and platform_machine == 'arm64'",
"fastapi[standard]~=0.115",
"httpx~=0.28",

77
tests/test_file_opts.py Normal file
View File

@@ -0,0 +1,77 @@
import asyncio
import json
import os
import pytest
import pytest_asyncio
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
from docling_core.types import DoclingDocument
from docling_core.types.doc.document import PictureDescriptionData
from docling_serve.app import create_app
@pytest.fixture(scope="session")
def event_loop():
return asyncio.get_event_loop()
@pytest_asyncio.fixture(scope="session")
async def app():
app = create_app()
async with LifespanManager(app) as manager:
print("Launching lifespan of app.")
yield manager.app
@pytest_asyncio.fixture(scope="session")
async def client(app):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://app.io"
) as client:
print("Client is ready")
yield client
@pytest.mark.asyncio
async def test_convert_file(client: AsyncClient):
"""Test convert single file to all outputs"""
endpoint = "/v1alpha/convert/file"
options = {
"to_formats": ["md", "json"],
"image_export_mode": "placeholder",
"ocr": False,
"do_picture_description": True,
"picture_description_api": json.dumps(
{
"url": "http://localhost:11434/v1/chat/completions", # ollama
"params": {"model": "granite3.2-vision:2b"},
"timeout": 60,
"prompt": "Describe this image in a few sentences. ",
}
),
}
current_dir = os.path.dirname(__file__)
file_path = os.path.join(current_dir, "2206.01062v1.pdf")
files = {
"files": ("2206.01062v1.pdf", open(file_path, "rb"), "application/pdf"),
}
response = await client.post(endpoint, files=files, data=options)
assert response.status_code == 200, "Response should be 200 OK"
data = response.json()
doc = DoclingDocument.model_validate(data["document"]["json_content"])
for pic in doc.pictures:
for ann in pic.annotations:
if isinstance(ann, PictureDescriptionData):
print(f"{pic.self_ref}")
print(ann.text)

17
uv.lock generated
View File

@@ -568,7 +568,7 @@ wheels = [
[[package]]
name = "docling"
version = "2.33.0"
version = "2.34.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beautifulsoup4", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin' or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-cu124') or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-flash-attn')" },
@@ -598,9 +598,9 @@ dependencies = [
{ name = "tqdm", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin' or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-cu124') or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-flash-attn')" },
{ name = "typer", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin' or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-cu124') or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-flash-attn')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/36/d8/76740f7d40a28794365e76029e7379f7d1b5994e24aaac899c3bb280e426/docling-2.33.0.tar.gz", hash = "sha256:40c27df6a7f90b8fb4279a4094ac26c48f3dbd38ceda0d3208e6d63eda2e8660", size = 132120, upload-time = "2025-05-20T19:56:06.46Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/42/33334eeaa3d42a4b3406adc7bbef8c655d026c1c41ad0a8fa858bbb7ef1b/docling-2.34.0.tar.gz", hash = "sha256:52ad3c79b7e56e978fcdd8f2040fe739584e25e1c2fd5f9519fe3d51002de0d2", size = 135016, upload-time = "2025-05-22T18:45:59.786Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/23/6dddf454610c4dff5799f9becbbc752b8595169de57c02a1d39067f5f2b5/docling-2.33.0-py3-none-any.whl", hash = "sha256:6c0ee223c5da551adc5da15aae205aaebf78222780198cb5a21c85648da618ba", size = 169441, upload-time = "2025-05-20T19:56:04.806Z" },
{ url = "https://files.pythonhosted.org/packages/be/b6/b855f19ab37a6f92b60a31a7db57160048f88a980aef4b97a5c260860ed4/docling-2.34.0-py3-none-any.whl", hash = "sha256:a69c368382cc824a5a5ad21882b8772f85117a25c81257a57d54582bf38b2810", size = 173153, upload-time = "2025-05-22T18:45:58.295Z" },
]
[package.optional-dependencies]
@@ -611,7 +611,7 @@ vlm = [
[[package]]
name = "docling-core"
version = "2.31.2"
version = "2.32.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonref", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin' or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-cu124') or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-flash-attn')" },
@@ -625,9 +625,9 @@ dependencies = [
{ name = "typer", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin' or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-cu124') or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-flash-attn')" },
{ name = "typing-extensions", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin' or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-cu124') or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-flash-attn')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/6c/8263ec8b44f96a42fff0856ea1c1b7471dba377f18274049e2235f979be7/docling_core-2.31.2.tar.gz", hash = "sha256:6d61863ce492affc45aa522c291631db0be7c50dc146cb93c42af7ff00bd22a2", size = 113011, upload-time = "2025-05-22T15:28:41.731Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/53/6017e92236172a994d8592db285990cd911dbbd328fab022406efc66e605/docling_core-2.32.0.tar.gz", hash = "sha256:3ec21461f309540bd8bf4880f6c2f0144f6895988102a4204ca5549c76a945c8", size = 113939, upload-time = "2025-05-27T13:45:19.861Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/75/8ff897047e9be98484fd3b2ce87021434bb3da2dc93ab47854ddcab5da31/docling_core-2.31.2-py3-none-any.whl", hash = "sha256:a6db62ac49febcc9e3e24a9acf58e88342ad7f76ab03217b6a3365509eb12eda", size = 142921, upload-time = "2025-05-22T15:28:40.254Z" },
{ url = "https://files.pythonhosted.org/packages/ee/29/eac2c6af02e21090fab38a1fcb2471baf59b346af4d35312cdb6ec68595a/docling_core-2.32.0-py3-none-any.whl", hash = "sha256:6c643b45a18c5ed8cecf12d1eeeb7ff677dcfdb24fa4aa88122e3c9cc2aeb58d", size = 143730, upload-time = "2025-05-27T13:45:18.061Z" },
]
[package.optional-dependencies]
@@ -703,10 +703,11 @@ wheels = [
[[package]]
name = "docling-serve"
version = "0.11.0"
version = "0.12.0"
source = { editable = "." }
dependencies = [
{ name = "docling", extra = ["vlm"], marker = "platform_machine != 'x86_64' or sys_platform != 'darwin' or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-cu124') or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-flash-attn')" },
{ name = "docling-core", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin' or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-cu124') or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-flash-attn')" },
{ name = "fastapi", extra = ["standard"], marker = "platform_machine != 'x86_64' or sys_platform != 'darwin' or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-cu124') or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-flash-attn')" },
{ name = "httpx", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin' or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-cu124') or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-flash-attn')" },
{ name = "kfp", extra = ["kubernetes"], marker = "platform_machine != 'x86_64' or sys_platform != 'darwin' or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-cu124') or (extra == 'extra-13-docling-serve-cpu' and extra == 'extra-13-docling-serve-flash-attn')" },
@@ -760,6 +761,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "docling", extras = ["vlm"], specifier = "~=2.28" },
{ name = "docling-core", specifier = ">=2.32.0" },
{ name = "fastapi", extras = ["standard"], specifier = "~=0.115" },
{ name = "flash-attn", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'flash-attn'", specifier = "~=2.7.0" },
{ name = "gradio", marker = "extra == 'ui'", specifier = "~=5.9" },
@@ -4785,7 +4787,6 @@ wheels = [
{ url = "https://download.pytorch.org/whl/cpu/torch-2.7.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:a845b6f3bda3c40f736847dede95d8bfec81fb7e11458cd25973ba13542cf1f6" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.7.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:64123c05615e27368c7a7816f6e39c6d219998693beabde0b0b9cedf91b5ed8b" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.7.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:69e25c973bdd7ea24b0fa9f9792114950afaeb8f819e5723819b923f50989175" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.7.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:1d7a6f33868276770a657beec7f77c7726b4da9d0739eff1b3ae64cc9a09d8e3" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.7.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:addf9107939522ffb3b60d2900fee838a77dbe098e2643e01164f46f8612f9c0" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.7.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3b09aa2c8d30fa567a8d13270fbf9af7ee472fdfafbc7dfdc87c607bf46001f7" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.7.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:99ca8f4cb53484c45bb668657069c17139c07367ea20ddef2c0ce8412f42da2f" },