mirror of
https://github.com/docling-project/docling-serve.git
synced 2025-11-29 08:33:50 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2dcb0a20f | ||
|
|
36787bc061 | ||
|
|
509f4889f8 | ||
|
|
919cf5c041 | ||
|
|
35c2630c61 | ||
|
|
382d675631 | ||
|
|
c65f3c654c | ||
|
|
829effec1a | ||
|
|
494d66f992 | ||
|
|
14bafb2628 |
2
.github/workflows/ci-images-dryrun.yml
vendored
2
.github/workflows/ci-images-dryrun.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
platforms: linux/amd64, linux/arm64
|
||||
- name: docling-project/docling-serve-cpu
|
||||
build_args: |
|
||||
UV_SYNC_EXTRA_ARGS=--no-extra cu124
|
||||
UV_SYNC_EXTRA_ARGS=--no-extra cu124 --no-extra flash-attn
|
||||
platforms: linux/amd64, linux/arm64
|
||||
- name: docling-project/docling-serve-cu124
|
||||
build_args: |
|
||||
|
||||
2
.github/workflows/images.yml
vendored
2
.github/workflows/images.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
platforms: linux/amd64, linux/arm64
|
||||
- name: docling-project/docling-serve-cpu
|
||||
build_args: |
|
||||
UV_SYNC_EXTRA_ARGS=--no-extra cu124
|
||||
UV_SYNC_EXTRA_ARGS=--no-extra cu124 --no-extra flash-attn
|
||||
platforms: linux/amd64, linux/arm64
|
||||
- name: docling-project/docling-serve-cu124
|
||||
build_args: |
|
||||
|
||||
2
.github/workflows/job-build.yml
vendored
2
.github/workflows/job-build.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
enable-cache: true
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras --no-extra cu124
|
||||
run: uv sync --all-extras --no-extra cu124 --no-extra flash-attn
|
||||
- name: Build package
|
||||
run: uv build
|
||||
- name: Check content of wheel
|
||||
|
||||
2
.github/workflows/job-checks.yml
vendored
2
.github/workflows/job-checks.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --all-extras --no-extra cu124
|
||||
run: uv sync --frozen --all-extras --no-extra cu124 --no-extra flash-attn
|
||||
|
||||
- name: Run styling check
|
||||
run: pre-commit run --all-files
|
||||
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -1,3 +1,23 @@
|
||||
## [v0.10.1](https://github.com/docling-project/docling-serve/releases/tag/v0.10.1) - 2025-04-30
|
||||
|
||||
### Fix
|
||||
|
||||
* Avoid missing specialized keys in the options hash ([#166](https://github.com/docling-project/docling-serve/issues/166)) ([`36787bc`](https://github.com/docling-project/docling-serve/commit/36787bc0616356a6199da618d8646de51636b34e))
|
||||
* Allow users to set the area threshold for picture descriptions ([#165](https://github.com/docling-project/docling-serve/issues/165)) ([`509f488`](https://github.com/docling-project/docling-serve/commit/509f4889f8ed4c0f0ce25bec4126ef1f1199797c))
|
||||
* Expose max wait time in sync endpoints ([#164](https://github.com/docling-project/docling-serve/issues/164)) ([`919cf5c`](https://github.com/docling-project/docling-serve/commit/919cf5c0414f2f11eb8012f451fed7a8f582b7ad))
|
||||
* Add flash-attn for cuda images ([#161](https://github.com/docling-project/docling-serve/issues/161)) ([`35c2630`](https://github.com/docling-project/docling-serve/commit/35c2630c613cf229393fc67b6938152b063ff498))
|
||||
|
||||
## [v0.10.0](https://github.com/docling-project/docling-serve/releases/tag/v0.10.0) - 2025-04-28
|
||||
|
||||
### Feature
|
||||
|
||||
* Add support for file upload and return as file in async endpoints ([#152](https://github.com/docling-project/docling-serve/issues/152)) ([`c65f3c6`](https://github.com/docling-project/docling-serve/commit/c65f3c654c76c6b64b6aada1f0a153d74789d629))
|
||||
|
||||
### Documentation
|
||||
|
||||
* Fix new default pdf_backend ([#158](https://github.com/docling-project/docling-serve/issues/158)) ([`829effe`](https://github.com/docling-project/docling-serve/commit/829effec1a1b80320ccaf2c501be8015169b6fa3))
|
||||
* Fixing small typo in docs ([#155](https://github.com/docling-project/docling-serve/issues/155)) ([`14bafb2`](https://github.com/docling-project/docling-serve/commit/14bafb26286b94f80b56846c50d6e9a6d99a9763))
|
||||
|
||||
## [v0.9.0](https://github.com/docling-project/docling-serve/releases/tag/v0.9.0) - 2025-04-25
|
||||
|
||||
### Feature
|
||||
|
||||
@@ -46,7 +46,10 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \
|
||||
--mount=type=cache,target=/opt/app-root/src/.cache/uv,uid=1001 \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
umask 002 && uv sync --frozen --no-install-project --no-dev --all-extras ${UV_SYNC_EXTRA_ARGS}
|
||||
umask 002 && \
|
||||
UV_SYNC_ARGS="--frozen --no-install-project --no-dev --all-extras" && \
|
||||
uv sync ${UV_SYNC_ARGS} ${UV_SYNC_EXTRA_ARGS} --no-extra flash-attn && \
|
||||
FLASH_ATTENTION_SKIP_CUDA_BUILD=TRUE uv sync ${UV_SYNC_ARGS} ${UV_SYNC_EXTRA_ARGS} --no-build-isolation-package=flash-attn
|
||||
|
||||
ARG MODELS_LIST="layout tableformer picture_classifier easyocr"
|
||||
|
||||
|
||||
2
Makefile
2
Makefile
@@ -35,7 +35,7 @@ docling-serve-image: Containerfile
|
||||
.PHONY: docling-serve-cpu-image
|
||||
docling-serve-cpu-image: Containerfile ## Build docling-serve "cpu only" container image
|
||||
$(ECHO_PREFIX) printf " %-12s Containerfile\n" "[docling-serve CPU]"
|
||||
$(CMD_PREFIX) docker build --load --build-arg "UV_SYNC_EXTRA_ARGS=--no-extra cu124" -f Containerfile -t ghcr.io/docling-project/docling-serve-cpu:$(TAG) .
|
||||
$(CMD_PREFIX) docker build --load --build-arg "UV_SYNC_EXTRA_ARGS=--no-extra cu124 --no-extra flash-attn" -f Containerfile -t ghcr.io/docling-project/docling-serve-cpu:$(TAG) .
|
||||
$(CMD_PREFIX) docker tag ghcr.io/docling-project/docling-serve-cpu:$(TAG) ghcr.io/docling-project/docling-serve-cpu:$(BRANCH_TAG)
|
||||
$(CMD_PREFIX) docker tag ghcr.io/docling-project/docling-serve-cpu:$(TAG) quay.io/docling-project/docling-serve-cpu:$(BRANCH_TAG)
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import asyncio
|
||||
import importlib.metadata
|
||||
import logging
|
||||
import tempfile
|
||||
import shutil
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Optional, Union
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import (
|
||||
BackgroundTasks,
|
||||
@@ -35,6 +35,7 @@ from docling_serve.datamodel.callback import (
|
||||
from docling_serve.datamodel.convert import ConvertDocumentsOptions
|
||||
from docling_serve.datamodel.requests import (
|
||||
ConvertDocumentFileSourcesRequest,
|
||||
ConvertDocumentHttpSourcesRequest,
|
||||
ConvertDocumentsRequest,
|
||||
)
|
||||
from docling_serve.datamodel.responses import (
|
||||
@@ -44,11 +45,7 @@ from docling_serve.datamodel.responses import (
|
||||
TaskStatusResponse,
|
||||
WebsocketMessage,
|
||||
)
|
||||
from docling_serve.docling_conversion import (
|
||||
convert_documents,
|
||||
get_converter,
|
||||
get_pdf_pipeline_opts,
|
||||
)
|
||||
from docling_serve.datamodel.task import Task, TaskSource
|
||||
from docling_serve.engines.async_orchestrator import (
|
||||
BaseAsyncOrchestrator,
|
||||
ProgressInvalid,
|
||||
@@ -56,8 +53,8 @@ from docling_serve.engines.async_orchestrator import (
|
||||
from docling_serve.engines.async_orchestrator_factory import get_async_orchestrator
|
||||
from docling_serve.engines.base_orchestrator import TaskNotFoundError
|
||||
from docling_serve.helper_functions import FormDepends
|
||||
from docling_serve.response_preparation import process_results
|
||||
from docling_serve.settings import docling_serve_settings
|
||||
from docling_serve.storage import get_scratch
|
||||
|
||||
|
||||
# Set up custom logging as we'll be intermixes with FastAPI/Uvicorn's logging
|
||||
@@ -95,11 +92,11 @@ _log = logging.getLogger(__name__)
|
||||
# Context manager to initialize and clean up the lifespan of the FastAPI app
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Converter with default options
|
||||
pdf_format_option = get_pdf_pipeline_opts(ConvertDocumentsOptions())
|
||||
get_converter(pdf_format_option)
|
||||
|
||||
orchestrator = get_async_orchestrator()
|
||||
scratch_dir = get_scratch()
|
||||
|
||||
# Warm up processing cache
|
||||
await orchestrator.warm_up_caches()
|
||||
|
||||
# Start the background queue processor
|
||||
queue_task = asyncio.create_task(orchestrator.process_queue())
|
||||
@@ -113,6 +110,10 @@ async def lifespan(app: FastAPI):
|
||||
except asyncio.CancelledError:
|
||||
_log.info("Queue processor cancelled.")
|
||||
|
||||
# Remove scratch directory in case it was a tempfile
|
||||
if docling_serve_settings.scratch_path is not None:
|
||||
shutil.rmtree(scratch_dir, ignore_errors=True)
|
||||
|
||||
|
||||
##################################
|
||||
# App creation and configuration #
|
||||
@@ -162,7 +163,8 @@ def create_app(): # noqa: C901
|
||||
|
||||
from docling_serve.gradio_ui import ui as gradio_ui
|
||||
|
||||
tmp_output_dir = Path(tempfile.mkdtemp())
|
||||
tmp_output_dir = get_scratch() / "gradio"
|
||||
tmp_output_dir.mkdir(exist_ok=True, parents=True)
|
||||
gradio_ui.gradio_output_dir = tmp_output_dir
|
||||
app = gr.mount_gradio_app(
|
||||
app,
|
||||
@@ -210,6 +212,55 @@ def create_app(): # noqa: C901
|
||||
redoc_js_url="/static/redoc.standalone.js",
|
||||
)
|
||||
|
||||
########################
|
||||
# Async / Sync helpers #
|
||||
########################
|
||||
|
||||
async def _enque_source(
|
||||
orchestrator: BaseAsyncOrchestrator, conversion_request: ConvertDocumentsRequest
|
||||
) -> Task:
|
||||
sources: list[TaskSource] = []
|
||||
if isinstance(conversion_request, ConvertDocumentFileSourcesRequest):
|
||||
sources.extend(conversion_request.file_sources)
|
||||
if isinstance(conversion_request, ConvertDocumentHttpSourcesRequest):
|
||||
sources.extend(conversion_request.http_sources)
|
||||
|
||||
task = await orchestrator.enqueue(
|
||||
sources=sources, options=conversion_request.options
|
||||
)
|
||||
return task
|
||||
|
||||
async def _enque_file(
|
||||
orchestrator: BaseAsyncOrchestrator,
|
||||
files: list[UploadFile],
|
||||
options: ConvertDocumentsOptions,
|
||||
) -> Task:
|
||||
_log.info(f"Received {len(files)} files for processing.")
|
||||
|
||||
# Load the uploaded files to Docling DocumentStream
|
||||
file_sources: list[TaskSource] = []
|
||||
for i, file in enumerate(files):
|
||||
buf = BytesIO(file.file.read())
|
||||
suffix = "" if len(file_sources) == 1 else f"_{i}"
|
||||
name = file.filename if file.filename else f"file{suffix}.pdf"
|
||||
file_sources.append(DocumentStream(name=name, stream=buf))
|
||||
|
||||
task = await orchestrator.enqueue(sources=file_sources, options=options)
|
||||
return task
|
||||
|
||||
async def _wait_task_complete(
|
||||
orchestrator: BaseAsyncOrchestrator, task_id: str
|
||||
) -> bool:
|
||||
start_time = time.monotonic()
|
||||
while True:
|
||||
task = await orchestrator.task_status(task_id=task_id)
|
||||
if task.is_completed():
|
||||
return True
|
||||
await asyncio.sleep(5)
|
||||
elapsed_time = time.monotonic() - start_time
|
||||
if elapsed_time > docling_serve_settings.max_sync_wait:
|
||||
return False
|
||||
|
||||
#############################
|
||||
# API Endpoints definitions #
|
||||
#############################
|
||||
@@ -243,33 +294,34 @@ def create_app(): # noqa: C901
|
||||
}
|
||||
},
|
||||
)
|
||||
def process_url(
|
||||
background_tasks: BackgroundTasks, conversion_request: ConvertDocumentsRequest
|
||||
async def process_url(
|
||||
background_tasks: BackgroundTasks,
|
||||
orchestrator: Annotated[BaseAsyncOrchestrator, Depends(get_async_orchestrator)],
|
||||
conversion_request: ConvertDocumentsRequest,
|
||||
):
|
||||
sources: list[Union[str, DocumentStream]] = []
|
||||
headers: Optional[dict[str, Any]] = None
|
||||
if isinstance(conversion_request, ConvertDocumentFileSourcesRequest):
|
||||
for file_source in conversion_request.file_sources:
|
||||
sources.append(file_source.to_document_stream())
|
||||
else:
|
||||
for http_source in conversion_request.http_sources:
|
||||
sources.append(http_source.url)
|
||||
if headers is None and http_source.headers:
|
||||
headers = http_source.headers
|
||||
|
||||
# Note: results are only an iterator->lazy evaluation
|
||||
results = convert_documents(
|
||||
sources=sources, options=conversion_request.options, headers=headers
|
||||
task = await _enque_source(
|
||||
orchestrator=orchestrator, conversion_request=conversion_request
|
||||
)
|
||||
success = await _wait_task_complete(
|
||||
orchestrator=orchestrator, task_id=task.task_id
|
||||
)
|
||||
|
||||
# The real processing will happen here
|
||||
response = process_results(
|
||||
background_tasks=background_tasks,
|
||||
conversion_options=conversion_request.options,
|
||||
conv_results=results,
|
||||
)
|
||||
if not success:
|
||||
# TODO: abort task!
|
||||
return HTTPException(
|
||||
status_code=504,
|
||||
detail=f"Conversion is taking too long. The maximum wait time is configure as DOCLING_SERVE_MAX_SYNC_WAIT={docling_serve_settings.max_sync_wait}.",
|
||||
)
|
||||
|
||||
return response
|
||||
result = await orchestrator.task_result(
|
||||
task_id=task.task_id, background_tasks=background_tasks
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Task result not found. Please wait for a completion status.",
|
||||
)
|
||||
return result
|
||||
|
||||
# Convert a document from file(s)
|
||||
@app.post(
|
||||
@@ -283,29 +335,35 @@ def create_app(): # noqa: C901
|
||||
)
|
||||
async def process_file(
|
||||
background_tasks: BackgroundTasks,
|
||||
orchestrator: Annotated[BaseAsyncOrchestrator, Depends(get_async_orchestrator)],
|
||||
files: list[UploadFile],
|
||||
options: Annotated[
|
||||
ConvertDocumentsOptions, FormDepends(ConvertDocumentsOptions)
|
||||
],
|
||||
):
|
||||
_log.info(f"Received {len(files)} files for processing.")
|
||||
|
||||
# Load the uploaded files to Docling DocumentStream
|
||||
file_sources = []
|
||||
for file in files:
|
||||
buf = BytesIO(file.file.read())
|
||||
name = file.filename if file.filename else "file.pdf"
|
||||
file_sources.append(DocumentStream(name=name, stream=buf))
|
||||
|
||||
results = convert_documents(sources=file_sources, options=options)
|
||||
|
||||
response = process_results(
|
||||
background_tasks=background_tasks,
|
||||
conversion_options=options,
|
||||
conv_results=results,
|
||||
task = await _enque_file(
|
||||
orchestrator=orchestrator, files=files, options=options
|
||||
)
|
||||
success = await _wait_task_complete(
|
||||
orchestrator=orchestrator, task_id=task.task_id
|
||||
)
|
||||
|
||||
return response
|
||||
if not success:
|
||||
# TODO: abort task!
|
||||
return HTTPException(
|
||||
status_code=504,
|
||||
detail=f"Conversion is taking too long. The maximum wait time is configure as DOCLING_SERVE_MAX_SYNC_WAIT={docling_serve_settings.max_sync_wait}.",
|
||||
)
|
||||
|
||||
result = await orchestrator.task_result(
|
||||
task_id=task.task_id, background_tasks=background_tasks
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Task result not found. Please wait for a completion status.",
|
||||
)
|
||||
return result
|
||||
|
||||
# Convert a document from URL(s) using the async api
|
||||
@app.post(
|
||||
@@ -316,7 +374,35 @@ def create_app(): # noqa: C901
|
||||
orchestrator: Annotated[BaseAsyncOrchestrator, Depends(get_async_orchestrator)],
|
||||
conversion_request: ConvertDocumentsRequest,
|
||||
):
|
||||
task = await orchestrator.enqueue(request=conversion_request)
|
||||
task = await _enque_source(
|
||||
orchestrator=orchestrator, conversion_request=conversion_request
|
||||
)
|
||||
task_queue_position = await orchestrator.get_queue_position(
|
||||
task_id=task.task_id
|
||||
)
|
||||
return TaskStatusResponse(
|
||||
task_id=task.task_id,
|
||||
task_status=task.task_status,
|
||||
task_position=task_queue_position,
|
||||
task_meta=task.processing_meta,
|
||||
)
|
||||
|
||||
# Convert a document from file(s) using the async api
|
||||
@app.post(
|
||||
"/v1alpha/convert/file/async",
|
||||
response_model=TaskStatusResponse,
|
||||
)
|
||||
async def process_file_async(
|
||||
orchestrator: Annotated[BaseAsyncOrchestrator, Depends(get_async_orchestrator)],
|
||||
background_tasks: BackgroundTasks,
|
||||
files: list[UploadFile],
|
||||
options: Annotated[
|
||||
ConvertDocumentsOptions, FormDepends(ConvertDocumentsOptions)
|
||||
],
|
||||
):
|
||||
task = await _enque_file(
|
||||
orchestrator=orchestrator, files=files, options=options
|
||||
)
|
||||
task_queue_position = await orchestrator.get_queue_position(
|
||||
task_id=task.task_id
|
||||
)
|
||||
@@ -426,9 +512,12 @@ def create_app(): # noqa: C901
|
||||
)
|
||||
async def task_result(
|
||||
orchestrator: Annotated[BaseAsyncOrchestrator, Depends(get_async_orchestrator)],
|
||||
background_tasks: BackgroundTasks,
|
||||
task_id: str,
|
||||
):
|
||||
result = await orchestrator.task_result(task_id=task_id)
|
||||
result = await orchestrator.task_result(
|
||||
task_id=task_id, background_tasks=background_tasks
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
|
||||
@@ -9,6 +9,7 @@ from docling.datamodel.pipeline_options import (
|
||||
EasyOcrOptions,
|
||||
PdfBackend,
|
||||
PdfPipeline,
|
||||
PictureDescriptionBaseOptions,
|
||||
TableFormerMode,
|
||||
TableStructureOptions,
|
||||
)
|
||||
@@ -339,6 +340,14 @@ class ConvertDocumentsOptions(BaseModel):
|
||||
),
|
||||
] = False
|
||||
|
||||
picture_description_area_threshold: Annotated[
|
||||
float,
|
||||
Field(
|
||||
description="Minimum percentage of the area for a picture to be processed with the models.",
|
||||
examples=[PictureDescriptionBaseOptions().picture_area_threshold],
|
||||
),
|
||||
] = PictureDescriptionBaseOptions().picture_area_threshold
|
||||
|
||||
picture_description_local: Annotated[
|
||||
Optional[PictureDescriptionLocal],
|
||||
Field(
|
||||
|
||||
@@ -2,7 +2,7 @@ import base64
|
||||
from io import BytesIO
|
||||
from typing import Annotated, Any, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import AnyHttpUrl, BaseModel, Field
|
||||
|
||||
from docling.datamodel.base_models import DocumentStream
|
||||
|
||||
@@ -15,7 +15,7 @@ class DocumentsConvertBase(BaseModel):
|
||||
|
||||
class HttpSource(BaseModel):
|
||||
url: Annotated[
|
||||
str,
|
||||
AnyHttpUrl,
|
||||
Field(
|
||||
description="HTTP url to process",
|
||||
examples=["https://arxiv.org/pdf/2206.01062"],
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from docling.datamodel.base_models import DocumentStream
|
||||
|
||||
from docling_serve.datamodel.convert import ConvertDocumentsOptions
|
||||
from docling_serve.datamodel.engines import TaskStatus
|
||||
from docling_serve.datamodel.requests import ConvertDocumentsRequest
|
||||
from docling_serve.datamodel.requests import FileSource, HttpSource
|
||||
from docling_serve.datamodel.responses import ConvertDocumentResponse
|
||||
from docling_serve.datamodel.task_meta import TaskProcessingMeta
|
||||
|
||||
TaskSource = Union[HttpSource, FileSource, DocumentStream]
|
||||
|
||||
|
||||
class Task(BaseModel):
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
task_id: str
|
||||
task_status: TaskStatus = TaskStatus.PENDING
|
||||
request: Optional[ConvertDocumentsRequest]
|
||||
result: Optional[ConvertDocumentResponse] = None
|
||||
sources: list[TaskSource] = []
|
||||
options: Optional[ConvertDocumentsOptions]
|
||||
result: Optional[Union[ConvertDocumentResponse, FileResponse]] = None
|
||||
scratch_dir: Optional[Path] = None
|
||||
processing_meta: Optional[TaskProcessingMeta] = None
|
||||
|
||||
def is_completed(self) -> bool:
|
||||
|
||||
@@ -42,15 +42,12 @@ _log = logging.getLogger(__name__)
|
||||
# Custom serializer for PdfFormatOption
|
||||
# (model_dump_json does not work with some classes)
|
||||
def _hash_pdf_format_option(pdf_format_option: PdfFormatOption) -> bytes:
|
||||
data = pdf_format_option.model_dump()
|
||||
data = pdf_format_option.model_dump(serialize_as_any=True)
|
||||
|
||||
# pipeline_options are not fully serialized by model_dump, dedicated pass
|
||||
if pdf_format_option.pipeline_options:
|
||||
data["pipeline_options"] = pdf_format_option.pipeline_options.model_dump()
|
||||
|
||||
# Replace `artifacts_path` with a string representation
|
||||
data["pipeline_options"]["artifacts_path"] = repr(
|
||||
data["pipeline_options"]["artifacts_path"]
|
||||
data["pipeline_options"] = pdf_format_option.pipeline_options.model_dump(
|
||||
serialize_as_any=True, mode="json"
|
||||
)
|
||||
|
||||
# Replace `pipeline_cls` with a string representation
|
||||
@@ -59,12 +56,6 @@ def _hash_pdf_format_option(pdf_format_option: PdfFormatOption) -> bytes:
|
||||
# Replace `backend` with a string representation
|
||||
data["backend"] = repr(data["backend"])
|
||||
|
||||
# Handle `device` in `accelerator_options`
|
||||
if "accelerator_options" in data and "device" in data["accelerator_options"]:
|
||||
data["accelerator_options"]["device"] = repr(
|
||||
data["accelerator_options"]["device"]
|
||||
)
|
||||
|
||||
# Serialize the dictionary to JSON with sorted keys to have consistent hashes
|
||||
serialized_data = json.dumps(data, sort_keys=True)
|
||||
options_hash = hashlib.sha1(serialized_data.encode()).digest()
|
||||
@@ -150,6 +141,9 @@ def _parse_standard_pdf_opts(
|
||||
request.picture_description_api.model_dump()
|
||||
)
|
||||
)
|
||||
pipeline_options.picture_description_options.picture_area_threshold = (
|
||||
request.picture_description_area_threshold
|
||||
)
|
||||
|
||||
return pipeline_options
|
||||
|
||||
@@ -198,7 +192,7 @@ def get_pdf_pipeline_opts(
|
||||
if docling_serve_settings.artifacts_path is not None:
|
||||
if str(docling_serve_settings.artifacts_path.absolute()) == "":
|
||||
_log.info(
|
||||
"artifacts_path is an empty path, model weights will be dowloaded "
|
||||
"artifacts_path is an empty path, model weights will be downloaded "
|
||||
"at runtime."
|
||||
)
|
||||
artifacts_path = None
|
||||
|
||||
@@ -14,10 +14,11 @@ from docling_serve.datamodel.callback import (
|
||||
ProgressSetNumDocs,
|
||||
ProgressUpdateProcessed,
|
||||
)
|
||||
from docling_serve.datamodel.convert import ConvertDocumentsOptions
|
||||
from docling_serve.datamodel.engines import TaskStatus
|
||||
from docling_serve.datamodel.kfp import CallbackSpec
|
||||
from docling_serve.datamodel.requests import ConvertDocumentsRequest
|
||||
from docling_serve.datamodel.task import Task
|
||||
from docling_serve.datamodel.requests import HttpSource
|
||||
from docling_serve.datamodel.task import Task, TaskSource
|
||||
from docling_serve.datamodel.task_meta import TaskProcessingMeta
|
||||
from docling_serve.engines.async_kfp.kfp_pipeline import process
|
||||
from docling_serve.engines.async_orchestrator import (
|
||||
@@ -71,7 +72,9 @@ class AsyncKfpOrchestrator(BaseAsyncOrchestrator):
|
||||
# verify_ssl=False,
|
||||
)
|
||||
|
||||
async def enqueue(self, request: ConvertDocumentsRequest) -> Task:
|
||||
async def enqueue(
|
||||
self, sources: list[TaskSource], options: ConvertDocumentsOptions
|
||||
) -> Task:
|
||||
callbacks = []
|
||||
if docling_serve_settings.eng_kfp_self_callback_endpoint is not None:
|
||||
headers = {}
|
||||
@@ -92,6 +95,8 @@ class AsyncKfpOrchestrator(BaseAsyncOrchestrator):
|
||||
)
|
||||
|
||||
CallbacksType = TypeAdapter(list[CallbackSpec])
|
||||
SourcesListType = TypeAdapter(list[HttpSource])
|
||||
http_sources = [s for s in sources if isinstance(s, HttpSource)]
|
||||
# hack: since the current kfp backend is not resolving the job_id placeholder,
|
||||
# we set the run_name and pass it as argument to the job itself.
|
||||
run_name = f"docling-job-{uuid.uuid4()}"
|
||||
@@ -99,7 +104,8 @@ class AsyncKfpOrchestrator(BaseAsyncOrchestrator):
|
||||
process,
|
||||
arguments={
|
||||
"batch_size": 10,
|
||||
"request": request.model_dump(mode="json"),
|
||||
"sources": SourcesListType.dump_python(http_sources, mode="json"),
|
||||
"options": options.model_dump(mode="json"),
|
||||
"callbacks": CallbacksType.dump_python(callbacks, mode="json"),
|
||||
"run_name": run_name,
|
||||
},
|
||||
@@ -107,7 +113,7 @@ class AsyncKfpOrchestrator(BaseAsyncOrchestrator):
|
||||
)
|
||||
task_id = kfp_run.run_id
|
||||
|
||||
task = Task(task_id=task_id, request=request)
|
||||
task = Task(task_id=task_id, sources=sources, options=options)
|
||||
await self.init_task_tracking(task)
|
||||
return task
|
||||
|
||||
@@ -185,6 +191,9 @@ class AsyncKfpOrchestrator(BaseAsyncOrchestrator):
|
||||
async def process_queue(self):
|
||||
return
|
||||
|
||||
async def warm_up_caches(self):
|
||||
return
|
||||
|
||||
async def _get_run_id(self, run_name: str) -> str:
|
||||
res = self._client.list_runs(
|
||||
filter=json.dumps(
|
||||
|
||||
@@ -3,8 +3,9 @@ import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from docling_serve.datamodel.requests import ConvertDocumentsRequest
|
||||
from docling_serve.datamodel.task import Task
|
||||
from docling_serve.datamodel.convert import ConvertDocumentsOptions
|
||||
from docling_serve.datamodel.task import Task, TaskSource
|
||||
from docling_serve.docling_conversion import get_converter, get_pdf_pipeline_opts
|
||||
from docling_serve.engines.async_local.worker import AsyncLocalWorker
|
||||
from docling_serve.engines.async_orchestrator import BaseAsyncOrchestrator
|
||||
from docling_serve.settings import docling_serve_settings
|
||||
@@ -18,9 +19,11 @@ class AsyncLocalOrchestrator(BaseAsyncOrchestrator):
|
||||
self.task_queue = asyncio.Queue()
|
||||
self.queue_list: list[str] = []
|
||||
|
||||
async def enqueue(self, request: ConvertDocumentsRequest) -> Task:
|
||||
async def enqueue(
|
||||
self, sources: list[TaskSource], options: ConvertDocumentsOptions
|
||||
) -> Task:
|
||||
task_id = str(uuid.uuid4())
|
||||
task = Task(task_id=task_id, request=request)
|
||||
task = Task(task_id=task_id, sources=sources, options=options)
|
||||
await self.init_task_tracking(task)
|
||||
|
||||
self.queue_list.append(task_id)
|
||||
@@ -47,3 +50,8 @@ class AsyncLocalOrchestrator(BaseAsyncOrchestrator):
|
||||
# Wait for all workers to complete (they won't, as they run indefinitely)
|
||||
await asyncio.gather(*workers)
|
||||
_log.debug("All workers completed.")
|
||||
|
||||
async def warm_up_caches(self):
|
||||
# Converter with default options
|
||||
pdf_format_option = get_pdf_pipeline_opts(ConvertDocumentsOptions())
|
||||
get_converter(pdf_format_option)
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Optional, Union
|
||||
|
||||
from fastapi import BackgroundTasks
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from docling.datamodel.base_models import DocumentStream
|
||||
|
||||
from docling_serve.datamodel.engines import TaskStatus
|
||||
from docling_serve.datamodel.requests import ConvertDocumentFileSourcesRequest
|
||||
from docling_serve.datamodel.responses import ConvertDocumentResponse
|
||||
from docling_serve.datamodel.requests import FileSource, HttpSource
|
||||
from docling_serve.docling_conversion import convert_documents
|
||||
from docling_serve.response_preparation import process_results
|
||||
from docling_serve.storage import get_scratch
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docling_serve.engines.async_local.orchestrator import AsyncLocalOrchestrator
|
||||
@@ -44,59 +45,66 @@ class AsyncLocalWorker:
|
||||
# Notify clients about queue updates
|
||||
await self.orchestrator.notify_queue_positions()
|
||||
|
||||
# Get the current event loop
|
||||
asyncio.get_event_loop()
|
||||
|
||||
# Define a callback function to send progress updates to the client.
|
||||
# TODO: send partial updates, e.g. when a document in the batch is done
|
||||
def run_conversion():
|
||||
sources: list[Union[str, DocumentStream]] = []
|
||||
convert_sources: list[Union[str, DocumentStream]] = []
|
||||
headers: Optional[dict[str, Any]] = None
|
||||
if isinstance(task.request, ConvertDocumentFileSourcesRequest):
|
||||
for file_source in task.request.file_sources:
|
||||
sources.append(file_source.to_document_stream())
|
||||
else:
|
||||
for http_source in task.request.http_sources:
|
||||
sources.append(http_source.url)
|
||||
if headers is None and http_source.headers:
|
||||
headers = http_source.headers
|
||||
for source in task.sources:
|
||||
if isinstance(source, DocumentStream):
|
||||
convert_sources.append(source)
|
||||
elif isinstance(source, FileSource):
|
||||
convert_sources.append(source.to_document_stream())
|
||||
elif isinstance(source, HttpSource):
|
||||
convert_sources.append(str(source.url))
|
||||
if headers is None and source.headers:
|
||||
headers = source.headers
|
||||
|
||||
# Note: results are only an iterator->lazy evaluation
|
||||
results = convert_documents(
|
||||
sources=sources,
|
||||
options=task.request.options,
|
||||
sources=convert_sources,
|
||||
options=task.options,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# The real processing will happen here
|
||||
work_dir = get_scratch() / task_id
|
||||
response = process_results(
|
||||
background_tasks=BackgroundTasks(),
|
||||
conversion_options=task.request.options,
|
||||
conversion_options=task.options,
|
||||
conv_results=results,
|
||||
work_dir=work_dir,
|
||||
)
|
||||
|
||||
if work_dir.exists():
|
||||
task.scratch_dir = work_dir
|
||||
if not isinstance(response, FileResponse):
|
||||
_log.warning(
|
||||
f"Task {task_id=} produced content in {work_dir=} but the response is not a file."
|
||||
)
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
|
||||
return response
|
||||
|
||||
# Run the prediction in a thread to avoid blocking the event loop.
|
||||
start_time = time.monotonic()
|
||||
|
||||
# Run the prediction in a thread to avoid blocking the event loop.
|
||||
# Get the current event loop
|
||||
# loop = asyncio.get_event_loop()
|
||||
# future = asyncio.run_coroutine_threadsafe(
|
||||
# run_conversion(),
|
||||
# loop=loop
|
||||
# )
|
||||
# response = future.result()
|
||||
|
||||
# Run in a thread
|
||||
response = await asyncio.to_thread(
|
||||
run_conversion,
|
||||
)
|
||||
processing_time = time.monotonic() - start_time
|
||||
|
||||
if not isinstance(response, ConvertDocumentResponse):
|
||||
_log.error(
|
||||
f"Worker {self.worker_id} got un-processable "
|
||||
"result for {task_id}: {type(response)}"
|
||||
)
|
||||
task.result = response
|
||||
task.request = None
|
||||
task.sources = []
|
||||
task.options = None
|
||||
|
||||
task.task_status = TaskStatus.SUCCESS
|
||||
_log.info(
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from fastapi import WebSocket
|
||||
import shutil
|
||||
from typing import Union
|
||||
|
||||
from fastapi import BackgroundTasks, WebSocket
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from docling_serve.datamodel.callback import ProgressCallbackRequest
|
||||
from docling_serve.datamodel.engines import TaskStatus
|
||||
from docling_serve.datamodel.responses import (
|
||||
ConvertDocumentResponse,
|
||||
MessageKind,
|
||||
TaskStatusResponse,
|
||||
WebsocketMessage,
|
||||
@@ -13,6 +18,7 @@ from docling_serve.engines.base_orchestrator import (
|
||||
OrchestratorError,
|
||||
TaskNotFoundError,
|
||||
)
|
||||
from docling_serve.settings import docling_serve_settings
|
||||
|
||||
|
||||
class ProgressInvalid(OrchestratorError):
|
||||
@@ -37,8 +43,15 @@ class BaseAsyncOrchestrator(BaseOrchestrator):
|
||||
async def task_status(self, task_id: str, wait: float = 0.0) -> Task:
|
||||
return await self.get_raw_task(task_id=task_id)
|
||||
|
||||
async def task_result(self, task_id: str):
|
||||
async def task_result(
|
||||
self, task_id: str, background_tasks: BackgroundTasks
|
||||
) -> Union[ConvertDocumentResponse, FileResponse, None]:
|
||||
task = await self.get_raw_task(task_id=task_id)
|
||||
if task.is_completed() and task.scratch_dir is not None:
|
||||
if docling_serve_settings.single_use_results:
|
||||
background_tasks.add_task(
|
||||
shutil.rmtree, task.scratch_dir, ignore_errors=True
|
||||
)
|
||||
return task.result
|
||||
|
||||
async def notify_task_subscribers(self, task_id: str):
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from typing import Optional, Union
|
||||
|
||||
from docling_serve.datamodel.requests import ConvertDocumentsRequest
|
||||
from docling_serve.datamodel.task import Task
|
||||
from fastapi import BackgroundTasks
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from docling_serve.datamodel.convert import ConvertDocumentsOptions
|
||||
from docling_serve.datamodel.responses import ConvertDocumentResponse
|
||||
from docling_serve.datamodel.task import Task, TaskSource
|
||||
|
||||
|
||||
class OrchestratorError(Exception):
|
||||
@@ -15,7 +19,9 @@ class TaskNotFoundError(OrchestratorError):
|
||||
|
||||
class BaseOrchestrator(ABC):
|
||||
@abstractmethod
|
||||
async def enqueue(self, request: ConvertDocumentsRequest) -> Task:
|
||||
async def enqueue(
|
||||
self, sources: list[TaskSource], options: ConvertDocumentsOptions
|
||||
) -> Task:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -31,9 +37,15 @@ class BaseOrchestrator(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def task_result(self, task_id: str):
|
||||
async def task_result(
|
||||
self, task_id: str, background_tasks: BackgroundTasks
|
||||
) -> Union[ConvertDocumentResponse, FileResponse, None]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def process_queue(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def warm_up_caches(self):
|
||||
pass
|
||||
|
||||
@@ -6,6 +6,7 @@ import ssl
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import certifi
|
||||
import gradio as gr
|
||||
@@ -203,12 +204,16 @@ def clear_file_input():
|
||||
return None
|
||||
|
||||
|
||||
def auto_set_return_as_file(url_input, file_input, image_export_mode):
|
||||
def auto_set_return_as_file(
|
||||
url_input_value: str,
|
||||
file_input_value: Optional[list[str]],
|
||||
image_export_mode_value: str,
|
||||
):
|
||||
# If more than one input source is provided, return as file
|
||||
if (
|
||||
(len(url_input.split(",")) > 1)
|
||||
or (file_input and len(file_input) > 1)
|
||||
or (image_export_mode == "referenced")
|
||||
(len(url_input_value.split(",")) > 1)
|
||||
or (file_input_value and len(file_input_value) > 1)
|
||||
or (image_export_mode_value == "referenced")
|
||||
):
|
||||
return True
|
||||
else:
|
||||
@@ -344,7 +349,7 @@ def file_to_base64(file):
|
||||
|
||||
|
||||
def process_file(
|
||||
file,
|
||||
files,
|
||||
to_formats,
|
||||
image_export_mode,
|
||||
pipeline,
|
||||
@@ -361,10 +366,12 @@ def process_file(
|
||||
do_picture_classification,
|
||||
do_picture_description,
|
||||
):
|
||||
if not file or file == "":
|
||||
if not files or len(files) == 0:
|
||||
logger.error("No files provided.")
|
||||
raise gr.Error("No files provided.", print_exception=False)
|
||||
files_data = [{"base64_string": file_to_base64(file), "filename": file.name}]
|
||||
files_data = [
|
||||
{"base64_string": file_to_base64(file), "filename": file.name} for file in files
|
||||
]
|
||||
|
||||
parameters = {
|
||||
"file_sources": files_data,
|
||||
@@ -552,7 +559,7 @@ with gr.Blocks(
|
||||
".png",
|
||||
".gif",
|
||||
],
|
||||
file_count="single",
|
||||
file_count="multiple",
|
||||
scale=4,
|
||||
)
|
||||
with gr.Column(scale=1):
|
||||
@@ -625,9 +632,7 @@ with gr.Blocks(
|
||||
)
|
||||
with gr.Column(scale=1):
|
||||
abort_on_error = gr.Checkbox(label="Abort on Error", value=False)
|
||||
return_as_file = gr.Checkbox(
|
||||
label="Return as File", visible=False, value=False
|
||||
) # Disable until async handle output as file
|
||||
return_as_file = gr.Checkbox(label="Return as File", value=False)
|
||||
with gr.Row():
|
||||
with gr.Column():
|
||||
do_code_enrichment = gr.Checkbox(
|
||||
@@ -677,23 +682,22 @@ with gr.Blocks(
|
||||
# UI Actions #
|
||||
##############
|
||||
|
||||
# Disable until async handle output as file
|
||||
# Handle Return as File
|
||||
# url_input.change(
|
||||
# auto_set_return_as_file,
|
||||
# inputs=[url_input, file_input, image_export_mode],
|
||||
# outputs=[return_as_file],
|
||||
# )
|
||||
# file_input.change(
|
||||
# auto_set_return_as_file,
|
||||
# inputs=[url_input, file_input, image_export_mode],
|
||||
# outputs=[return_as_file],
|
||||
# )
|
||||
# image_export_mode.change(
|
||||
# auto_set_return_as_file,
|
||||
# inputs=[url_input, file_input, image_export_mode],
|
||||
# outputs=[return_as_file],
|
||||
# )
|
||||
url_input.change(
|
||||
auto_set_return_as_file,
|
||||
inputs=[url_input, file_input, image_export_mode],
|
||||
outputs=[return_as_file],
|
||||
)
|
||||
file_input.change(
|
||||
auto_set_return_as_file,
|
||||
inputs=[url_input, file_input, image_export_mode],
|
||||
outputs=[return_as_file],
|
||||
)
|
||||
image_export_mode.change(
|
||||
auto_set_return_as_file,
|
||||
inputs=[url_input, file_input, image_export_mode],
|
||||
outputs=[return_as_file],
|
||||
)
|
||||
|
||||
# URL processing
|
||||
url_process_btn.click(
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from fastapi import BackgroundTasks, HTTPException
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from docling.datamodel.base_models import OutputFormat
|
||||
@@ -124,9 +123,9 @@ def _export_documents_as_files(
|
||||
|
||||
|
||||
def process_results(
|
||||
background_tasks: BackgroundTasks,
|
||||
conversion_options: ConvertDocumentsOptions,
|
||||
conv_results: Iterable[ConversionResult],
|
||||
work_dir: Path,
|
||||
) -> Union[ConvertDocumentResponse, FileResponse]:
|
||||
# Let's start by processing the documents
|
||||
try:
|
||||
@@ -183,7 +182,6 @@ def process_results(
|
||||
# Multiple documents were processed, or we are forced returning as a file
|
||||
else:
|
||||
# Temporary directory to store the outputs
|
||||
work_dir = Path(tempfile.mkdtemp(prefix="docling_"))
|
||||
output_dir = work_dir / "output"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -203,7 +201,6 @@ def process_results(
|
||||
)
|
||||
|
||||
files = os.listdir(output_dir)
|
||||
|
||||
if len(files) == 0:
|
||||
raise HTTPException(status_code=500, detail="No documents were exported.")
|
||||
|
||||
@@ -216,7 +213,7 @@ def process_results(
|
||||
|
||||
# Other cleanups after the response is sent
|
||||
# Output directory
|
||||
background_tasks.add_task(shutil.rmtree, work_dir, ignore_errors=True)
|
||||
# background_tasks.add_task(shutil.rmtree, work_dir, ignore_errors=True)
|
||||
|
||||
response = FileResponse(
|
||||
file_path, filename=file_path.name, media_type="application/zip"
|
||||
|
||||
@@ -38,6 +38,8 @@ class DoclingServeSettings(BaseSettings):
|
||||
api_host: str = "localhost"
|
||||
artifacts_path: Optional[Path] = None
|
||||
static_path: Optional[Path] = None
|
||||
scratch_path: Optional[Path] = None
|
||||
single_use_results: bool = True
|
||||
options_cache_size: int = 2
|
||||
enable_remote_services: bool = False
|
||||
allow_external_plugins: bool = False
|
||||
@@ -46,6 +48,8 @@ class DoclingServeSettings(BaseSettings):
|
||||
max_num_pages: int = sys.maxsize
|
||||
max_file_size: int = sys.maxsize
|
||||
|
||||
max_sync_wait: int = 120 # 2 minutes
|
||||
|
||||
cors_origins: list[str] = ["*"]
|
||||
cors_methods: list[str] = ["*"]
|
||||
cors_headers: list[str] = ["*"]
|
||||
|
||||
16
docling_serve/storage.py
Normal file
16
docling_serve/storage.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import tempfile
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from docling_serve.settings import docling_serve_settings
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_scratch() -> Path:
|
||||
scratch_dir = (
|
||||
docling_serve_settings.scratch_path
|
||||
if docling_serve_settings.scratch_path is not None
|
||||
else Path(tempfile.mkdtemp(prefix="docling_"))
|
||||
)
|
||||
scratch_dir.mkdir(exist_ok=True, parents=True)
|
||||
return scratch_dir
|
||||
@@ -37,12 +37,15 @@ THe following table describes the options to configure the Docling Serve app.
|
||||
| -----------|-----|---------|-------------|
|
||||
| `--artifacts-path` | `DOCLING_SERVE_ARTIFACTS_PATH` | unset | If set to a valid directory, the model weights will be loaded from this path |
|
||||
| | `DOCLING_SERVE_STATIC_PATH` | unset | If set to a valid directory, the static assets for the docs and ui will be loaded from this path |
|
||||
| | `DOCLING_SERVE_SCRATCH_PATH` | | If set, this directory will be used as scratch workspace, e.g. storing the results before they get requested. If unset, a temporary created is created for this purpose. |
|
||||
| `--enable-ui` | `DOCLING_SERVE_ENABLE_UI` | `false` | Enable the demonstrator UI. |
|
||||
| | `DOCLING_SERVE_ENABLE_REMOTE_SERVICES` | `false` | Allow pipeline components making remote connections. For example, this is needed when using a vision-language model via APIs. |
|
||||
| | `DOCLING_SERVE_ALLOW_EXTERNAL_PLUGINS` | `false` | Allow the selection of third-party plugins. |
|
||||
| | `DOCLING_SERVE_SINGLE_USE_RESULTS` | `true` | If true, results can be accessed only once. If false, the results accumulate in the scratch directory. |
|
||||
| | `DOCLING_SERVE_MAX_DOCUMENT_TIMEOUT` | `604800` (7 days) | The maximum time for processing a document. |
|
||||
| | `DOCLING_SERVE_MAX_NUM_PAGES` | | The maximum number of pages for a document to be processed. |
|
||||
| | `DOCLING_SERVE_MAX_FILE_SIZE` | | The maximum file size for a document to be processed. |
|
||||
| | `DOCLING_SERVE_MAX_SYNC_WAIT` | `120` | Max number of seconds a synchronous endpoint is waiting for the task completion. |
|
||||
| | `DOCLING_SERVE_OPTIONS_CACHE_SIZE` | `2` | How many DocumentConveter objects (including their loaded models) to keep in the cache. |
|
||||
| | `DOCLING_SERVE_CORS_ORIGINS` | `["*"]` | A list of origins that should be permitted to make cross-origin requests. |
|
||||
| | `DOCLING_SERVE_CORS_METHODS` | `["*"]` | A list of HTTP methods that should be allowed for cross-origin requests. |
|
||||
@@ -73,4 +76,4 @@ The following table describes the options to configure the Docling Serve KFP eng
|
||||
| `DOCLING_SERVE_ENG_KFP_CA_CERT_PATH` | | Path to the CA certificates for the KFP endpoint. For in-cluster deployment, the app will load automatically the internal CA. |
|
||||
| `DOCLING_SERVE_ENG_KFP_SELF_CALLBACK_ENDPOINT` | | If set, it enables internal callbacks providing status update of the KFP job. Usually something like `https://NAME.NAMESPACE.svc.cluster.local:5001/v1alpha/callback/task/progress`. |
|
||||
| `DOCLING_SERVE_ENG_KFP_SELF_CALLBACK_TOKEN_PATH` | | The token used for authenticating the progress callback. For cluster-internal workloads, use `/run/secrets/kubernetes.io/serviceaccount/token`. |
|
||||
| `DOCLING_SERVE_ENG_KFP_SELF_CALLBACK_CA_CERT_PATH` | | The CA certifcate for the progress callback. For cluster-inetrnal workloads, use `/var/run/secrets/kubernetes.io/serviceaccount/service-ca.crt`. |
|
||||
| `DOCLING_SERVE_ENG_KFP_SELF_CALLBACK_CA_CERT_PATH` | | The CA certificate for the progress callback. For cluster-inetrnal workloads, use `/var/run/secrets/kubernetes.io/serviceaccount/service-ca.crt`. |
|
||||
|
||||
@@ -6,7 +6,7 @@ The API provides two endpoints: one for urls, one for files. This is necessary t
|
||||
|
||||
On top of the source of file (see below), both endpoints support the same parameters, which are almost the same as the Docling CLI.
|
||||
|
||||
- `from_format` (List[str]): Input format(s) to convert from. Allowed values: `docx`, `pptx`, `html`, `image`, `pdf`, `asciidoc`, `md`. Defaults to all formats.
|
||||
- `from_formats` (List[str]): Input format(s) to convert from. Allowed values: `docx`, `pptx`, `html`, `image`, `pdf`, `asciidoc`, `md`. Defaults to all formats.
|
||||
- `to_formats` (List[str]): Output format(s) to convert to. Allowed values: `md`, `json`, `html`, `text`, `doctags`. Defaults to `md`.
|
||||
- `pipeline` (str). The choice of which pipeline to use. Allowed values are `standard` and `vlm`. Defaults to `standard`.
|
||||
- `do_ocr` (bool): If enabled, the bitmap content will be processed using OCR. Defaults to `True`.
|
||||
@@ -14,7 +14,7 @@ On top of the source of file (see below), both endpoints support the same parame
|
||||
- `force_ocr` (bool): If enabled, replace any existing text with OCR-generated text over the full content. Defaults to `False`.
|
||||
- `ocr_engine` (str): OCR engine to use. Allowed values: `easyocr`, `tesseract_cli`, `tesseract`, `rapidocr`, `ocrmac`. Defaults to `easyocr`.
|
||||
- `ocr_lang` (List[str]): List of languages used by the OCR engine. Note that each OCR engine has different values for the language names. Defaults to empty.
|
||||
- `pdf_backend` (str): PDF backend to use. Allowed values: `pypdfium2`, `dlparse_v1`, `dlparse_v2`. Defaults to `dlparse_v2`.
|
||||
- `pdf_backend` (str): PDF backend to use. Allowed values: `pypdfium2`, `dlparse_v1`, `dlparse_v2`, `dlparse_v4`. Defaults to `dlparse_v4`.
|
||||
- `table_mode` (str): Table mode to use. Allowed values: `fast`, `accurate`. Defaults to `fast`.
|
||||
- `abort_on_error` (bool): If enabled, abort on error. Defaults to false.
|
||||
- `return_as_file` (boo): If enabled, return the output as a file. Defaults to false.
|
||||
@@ -23,6 +23,7 @@ On top of the source of file (see below), both endpoints support the same parame
|
||||
- `do_formula_enrichment` (bool): If enabled, perform formula OCR, return LaTeX code. Defaults to false.
|
||||
- `do_picture_classification` (bool): If enabled, classify pictures in documents. Defaults to false.
|
||||
- `do_picture_description` (bool): If enabled, describe pictures in documents. Defaults to false.
|
||||
- `picture_description_area_threshold` (float): Minimum percentage of the area for a picture to be processed with the models. Defaults to 0.05.
|
||||
- `picture_description_local` (dict): 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.
|
||||
- `picture_description_api` (dict): API details for using a vision-language model in the picture description. This parameter is mutually exclusive with picture_description_local.
|
||||
- `include_images` (bool): If enabled, images will be extracted from the document. Defaults to false.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "docling-serve"
|
||||
version = "0.9.0" # DO NOT EDIT, updated automatically
|
||||
version = "0.10.1" # DO NOT EDIT, updated automatically
|
||||
description = "Running Docling as a service"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
@@ -63,9 +63,13 @@ cu124 = [
|
||||
"torch>=2.6.0",
|
||||
"torchvision>=0.21.0",
|
||||
]
|
||||
flash-attn = [
|
||||
"flash-attn~=2.7.0; sys_platform == 'linux' and platform_machine == 'x86_64'"
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"asgi-lifespan~=2.0",
|
||||
"mypy~=1.11",
|
||||
"pre-commit-uv~=4.1",
|
||||
"pytest~=8.3",
|
||||
@@ -82,7 +86,10 @@ conflicts = [
|
||||
{ extra = "cpu" },
|
||||
{ extra = "cu124" },
|
||||
],
|
||||
]
|
||||
[
|
||||
{ extra = "cpu" },
|
||||
{ extra = "flash-attn" },
|
||||
],]
|
||||
environments = ["sys_platform != 'darwin' or platform_machine != 'x86_64'"]
|
||||
override-dependencies = [
|
||||
"urllib3~=2.0"
|
||||
|
||||
@@ -47,9 +47,7 @@ async def test_convert_file(async_client):
|
||||
"files": ("2206.01062v1.pdf", open(file_path, "rb"), "application/pdf"),
|
||||
}
|
||||
|
||||
response = await async_client.post(
|
||||
url, files=files, data={"options": json.dumps(options)}
|
||||
)
|
||||
response = await async_client.post(url, files=files, data=options)
|
||||
assert response.status_code == 200, "Response should be 200 OK"
|
||||
|
||||
data = response.json()
|
||||
|
||||
69
tests/test_1-file-async.py
Normal file
69
tests/test_1-file-async.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def async_client():
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_convert_url(async_client):
|
||||
"""Test convert URL to all outputs"""
|
||||
|
||||
base_url = "http://localhost:5001/v1alpha"
|
||||
payload = {
|
||||
"to_formats": ["md", "json", "html"],
|
||||
"image_export_mode": "placeholder",
|
||||
"ocr": False,
|
||||
"abort_on_error": False,
|
||||
"return_as_file": False,
|
||||
}
|
||||
|
||||
file_path = Path(__file__).parent / "2206.01062v1.pdf"
|
||||
files = {
|
||||
"files": (file_path.name, file_path.open("rb"), "application/pdf"),
|
||||
}
|
||||
|
||||
for n in range(1):
|
||||
response = await async_client.post(
|
||||
f"{base_url}/convert/file/async", files=files, data=payload
|
||||
)
|
||||
assert response.status_code == 200, "Response should be 200 OK"
|
||||
|
||||
task = response.json()
|
||||
|
||||
print(json.dumps(task, indent=2))
|
||||
|
||||
while task["task_status"] not in ("success", "failure"):
|
||||
response = await async_client.get(f"{base_url}/status/poll/{task['task_id']}")
|
||||
assert response.status_code == 200, "Response should be 200 OK"
|
||||
task = response.json()
|
||||
print(f"{task['task_status']=}")
|
||||
print(f"{task['task_position']=}")
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
assert task["task_status"] == "success"
|
||||
|
||||
result_resp = await async_client.get(f"{base_url}/result/{task['task_id']}")
|
||||
assert result_resp.status_code == 200, "Response should be 200 OK"
|
||||
result = result_resp.json()
|
||||
|
||||
assert "md_content" in result["document"]
|
||||
assert result["document"]["md_content"] is not None
|
||||
assert len(result["document"]["md_content"]) > 10
|
||||
|
||||
assert "html_content" in result["document"]
|
||||
assert result["document"]["html_content"] is not None
|
||||
assert len(result["document"]["html_content"]) > 10
|
||||
|
||||
assert "json_content" in result["document"]
|
||||
assert result["document"]["json_content"] is not None
|
||||
assert result["document"]["json_content"]["schema_name"] == "DoclingDocument"
|
||||
@@ -38,7 +38,7 @@ async def test_convert_url(async_client):
|
||||
}
|
||||
print(json.dumps(payload, indent=2))
|
||||
|
||||
for n in range(1):
|
||||
for n in range(3):
|
||||
response = await async_client.post(
|
||||
f"{base_url}/convert/source/async", json=payload
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
import httpx
|
||||
@@ -48,9 +47,7 @@ async def test_convert_file(async_client):
|
||||
("files", ("2408.09869v5.pdf", open(file_path, "rb"), "application/pdf")),
|
||||
]
|
||||
|
||||
response = await async_client.post(
|
||||
url, files=files, data={"options": json.dumps(options)}
|
||||
)
|
||||
response = await async_client.post(url, files=files, data=options)
|
||||
assert response.status_code == 200, "Response should be 200 OK"
|
||||
|
||||
# Check for zip file attachment
|
||||
|
||||
88
tests/test_2-urls-async-all-outputs.py
Normal file
88
tests/test_2-urls-async-all-outputs.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from pytest_check import check
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def async_client():
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_convert_url(async_client):
|
||||
"""Test convert URL to all outputs"""
|
||||
base_url = "http://localhost:5001/v1alpha"
|
||||
payload = {
|
||||
"options": {
|
||||
"from_formats": [
|
||||
"docx",
|
||||
"pptx",
|
||||
"html",
|
||||
"image",
|
||||
"pdf",
|
||||
"asciidoc",
|
||||
"md",
|
||||
"xlsx",
|
||||
],
|
||||
"to_formats": ["md", "json", "html", "text", "doctags"],
|
||||
"image_export_mode": "placeholder",
|
||||
"ocr": True,
|
||||
"force_ocr": False,
|
||||
"ocr_engine": "easyocr",
|
||||
"ocr_lang": ["en"],
|
||||
"pdf_backend": "dlparse_v2",
|
||||
"table_mode": "fast",
|
||||
"abort_on_error": False,
|
||||
"return_as_file": False,
|
||||
},
|
||||
"http_sources": [
|
||||
{"url": "https://arxiv.org/pdf/2206.01062"},
|
||||
{"url": "https://arxiv.org/pdf/2408.09869"},
|
||||
],
|
||||
}
|
||||
|
||||
response = await async_client.post(f"{base_url}/convert/source/async", json=payload)
|
||||
assert response.status_code == 200, "Response should be 200 OK"
|
||||
|
||||
task = response.json()
|
||||
|
||||
print(json.dumps(task, indent=2))
|
||||
|
||||
while task["task_status"] not in ("success", "failure"):
|
||||
response = await async_client.get(f"{base_url}/status/poll/{task['task_id']}")
|
||||
assert response.status_code == 200, "Response should be 200 OK"
|
||||
task = response.json()
|
||||
print(f"{task['task_status']=}")
|
||||
print(f"{task['task_position']=}")
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
assert task["task_status"] == "success"
|
||||
|
||||
result_resp = await async_client.get(f"{base_url}/result/{task['task_id']}")
|
||||
assert result_resp.status_code == 200, "Response should be 200 OK"
|
||||
|
||||
# Check for zip file attachment
|
||||
content_disposition = result_resp.headers.get("content-disposition")
|
||||
|
||||
with check:
|
||||
assert content_disposition is not None, (
|
||||
"Content-Disposition header should be present"
|
||||
)
|
||||
with check:
|
||||
assert "attachment" in content_disposition, "Response should be an attachment"
|
||||
with check:
|
||||
assert 'filename="converted_docs.zip"' in content_disposition, (
|
||||
"Attachment filename should be 'converted_docs.zip'"
|
||||
)
|
||||
|
||||
content_type = result_resp.headers.get("content-type")
|
||||
with check:
|
||||
assert content_type == "application/zip", (
|
||||
"Content-Type should be 'application/zip'"
|
||||
)
|
||||
@@ -1,22 +1,50 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from asgi_lifespan import LifespanManager
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pytest_check import check
|
||||
|
||||
from docling_serve.app import create_app
|
||||
|
||||
client = TestClient(create_app())
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
return asyncio.get_event_loop()
|
||||
|
||||
|
||||
def test_health():
|
||||
response = client.get("/health")
|
||||
@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_health(client: AsyncClient):
|
||||
response = await client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_convert_file():
|
||||
@pytest.mark.asyncio
|
||||
async def test_convert_file(client: AsyncClient):
|
||||
"""Test convert single file to all outputs"""
|
||||
|
||||
endpoint = "/v1alpha/convert/file"
|
||||
options = {
|
||||
"from_formats": [
|
||||
@@ -48,7 +76,7 @@ def test_convert_file():
|
||||
"files": ("2206.01062v1.pdf", open(file_path, "rb"), "application/pdf"),
|
||||
}
|
||||
|
||||
response = client.post(endpoint, files=files, data=options)
|
||||
response = await client.post(endpoint, files=files, data=options)
|
||||
assert response.status_code == 200, "Response should be 200 OK"
|
||||
|
||||
data = response.json()
|
||||
|
||||
54
tests/test_options_serialization.py
Normal file
54
tests/test_options_serialization.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from docling_serve.datamodel.convert import (
|
||||
ConvertDocumentsOptions,
|
||||
PictureDescriptionApi,
|
||||
)
|
||||
from docling_serve.docling_conversion import (
|
||||
_hash_pdf_format_option,
|
||||
get_pdf_pipeline_opts,
|
||||
)
|
||||
|
||||
|
||||
def test_options_cache_key():
|
||||
hashes = set()
|
||||
|
||||
opts = ConvertDocumentsOptions()
|
||||
pipeline_opts = get_pdf_pipeline_opts(opts)
|
||||
hash = _hash_pdf_format_option(pipeline_opts)
|
||||
assert hash not in hashes
|
||||
hashes.add(hash)
|
||||
|
||||
opts.do_picture_description = True
|
||||
pipeline_opts = get_pdf_pipeline_opts(opts)
|
||||
hash = _hash_pdf_format_option(pipeline_opts)
|
||||
# pprint(pipeline_opts.pipeline_options.model_dump(serialize_as_any=True))
|
||||
assert hash not in hashes
|
||||
hashes.add(hash)
|
||||
|
||||
opts.picture_description_api = PictureDescriptionApi(
|
||||
url="http://localhost",
|
||||
params={"model": "mymodel"},
|
||||
prompt="Hello 1",
|
||||
)
|
||||
pipeline_opts = get_pdf_pipeline_opts(opts)
|
||||
hash = _hash_pdf_format_option(pipeline_opts)
|
||||
# pprint(pipeline_opts.pipeline_options.model_dump(serialize_as_any=True))
|
||||
assert hash not in hashes
|
||||
hashes.add(hash)
|
||||
|
||||
opts.picture_description_api = PictureDescriptionApi(
|
||||
url="http://localhost",
|
||||
params={"model": "your-model"},
|
||||
prompt="Hello 1",
|
||||
)
|
||||
pipeline_opts = get_pdf_pipeline_opts(opts)
|
||||
hash = _hash_pdf_format_option(pipeline_opts)
|
||||
# pprint(pipeline_opts.pipeline_options.model_dump(serialize_as_any=True))
|
||||
assert hash not in hashes
|
||||
hashes.add(hash)
|
||||
|
||||
opts.picture_description_api.prompt = "World"
|
||||
pipeline_opts = get_pdf_pipeline_opts(opts)
|
||||
hash = _hash_pdf_format_option(pipeline_opts)
|
||||
# pprint(pipeline_opts.pipeline_options.model_dump(serialize_as_any=True))
|
||||
assert hash not in hashes
|
||||
hashes.add(hash)
|
||||
Reference in New Issue
Block a user