Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/app_admin/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
[project]
name = "app-admin"
version = "0.1.0"
description = "Admin FastAPI service."
description = "Admin service."
requires-python = "==3.12.8"
dependencies = [
"fastapi==0.115.12",
"uvicorn[standard]==0.34.1",
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"common-python",
]

Expand Down
2 changes: 2 additions & 0 deletions apps/app_admin/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
fastapi==0.115.12
uvicorn[standard]==0.34.1
310 changes: 310 additions & 0 deletions apps/app_admin/uv.lock

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions apps/app_docling_api/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Postgres
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=docling
POSTGRES_PASSWORD=docling
POSTGRES_DB=docling
DATABASE_URL=postgresql+psycopg://docling:docling@postgres:5432/docling

# Redis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_URL=redis://redis:6379/0

# MinIO / S3
S3_ENDPOINT=http://minio:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=docling
S3_PRESIGN_TTL=3600

# Worker
WORKER_CONCURRENCY=1
JOB_TIMEOUT_SECONDS=600
WEBHOOK_MAX_RETRIES=3
OMP_NUM_THREADS=2
MKL_NUM_THREADS=2

# OCR engine selection — rapidocr|easyocr|tesseract
# Primary is loaded eagerly at worker startup; fallback is loaded lazily on first failure.
# Set OCR_ENGINE_FALLBACK= (empty) to disable fallback.
OCR_ENGINE_PRIMARY=rapidocr
OCR_ENGINE_FALLBACK=easyocr

# API
API_MAX_UPLOAD_MB=100
API_LOG_LEVEL=info
58 changes: 58 additions & 0 deletions apps/app_docling_api/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# =====================================================================
# Builder — uv-managed Python 3.12 + venv at /app/.venv.
# Build context must be the stack-py repo root.
# =====================================================================
FROM python:3.12-slim-bookworm AS builder

COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /usr/local/bin/uv

ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
UV_PYTHON_PREFERENCE=only-managed \
UV_PYTHON_INSTALL_DIR=/python \
UV_PROJECT_ENVIRONMENT=/app/.venv

RUN uv python install 3.12.8

WORKDIR /app

# Workspace manifests first so the dep layer caches independently of source.
COPY pyproject.toml uv.lock* ./
COPY common/pyproject.toml common/pyproject.toml
COPY apps/app_docling_api/pyproject.toml apps/app_docling_api/pyproject.toml
COPY apps/app_docling_worker/pyproject.toml apps/app_docling_worker/pyproject.toml
COPY apps/app_docling_common/pyproject.toml apps/app_docling_common/pyproject.toml
COPY apps/app_admin/pyproject.toml apps/app_admin/pyproject.toml
COPY apps/app_sample/pyproject.toml apps/app_sample/pyproject.toml
COPY apps/app_nippon_rfq_matching/pyproject.toml apps/app_nippon_rfq_matching/pyproject.toml

RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --no-dev --package app-docling-api --no-install-project

# Source
COPY common ./common
COPY apps/app_docling_common ./apps/app_docling_common
COPY apps/app_docling_api ./apps/app_docling_api

RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --no-dev --package app-docling-api

# =====================================================================
# Runtime — distroless cc (glibc + libstdc++, no shell, no apt).
# =====================================================================
FROM gcr.io/distroless/cc-debian12:nonroot

COPY --from=builder --chown=nonroot:nonroot /python /python
COPY --from=builder --chown=nonroot:nonroot /app /app

# psycopg[binary] needs libz at runtime; distroless cc ships glibc + libstdc++ but not zlib.
COPY --from=builder /usr/lib/x86_64-linux-gnu/libz.so.1 /usr/lib/x86_64-linux-gnu/libz.so.1

ENV PATH="/app/.venv/bin:/python/bin:$PATH" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

WORKDIR /app
EXPOSE 8000

CMD ["/app/.venv/bin/python", "-m", "uvicorn", "apps.app_docling_api.app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Empty file.
40 changes: 40 additions & 0 deletions apps/app_docling_api/alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
[alembic]
script_location = %(here)s/migrations
# stack-py repo root, so `apps.app_docling_api.*` resolves regardless of CWD;
# `app_docling_common` is installed into the venv via the uv workspace.
prepend_sys_path = %(here)s/../..
sqlalchemy.url =

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
Empty file.
25 changes: 25 additions & 0 deletions apps/app_docling_api/app/deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from collections.abc import Iterator

from app_docling_common.config import get_settings
from app_docling_common.db import get_session_factory
from app_docling_common.storage import ObjectStorage
from arq import create_pool
from arq.connections import RedisSettings
from sqlalchemy.orm import Session


def get_db() -> Iterator[Session]:
session = get_session_factory()()
try:
yield session
finally:
session.close()


def get_storage() -> ObjectStorage:
return ObjectStorage()


async def get_arq_pool():
settings = get_settings()
return await create_pool(RedisSettings.from_dsn(settings.redis_url))
31 changes: 31 additions & 0 deletions apps/app_docling_api/app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from contextlib import asynccontextmanager

from app_docling_common.storage import ObjectStorage
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import ValidationError

from apps.app_docling_api.app.routes import health, jobs


@asynccontextmanager
async def lifespan(app: FastAPI):
ObjectStorage().ensure_bucket()
yield


app = FastAPI(title="docling-api", version="0.1.0", lifespan=lifespan)


# JobOptions is parsed inside the route via `model_validate_json` (it comes
# from a multipart form field, not a JSON body), so FastAPI's built-in
# RequestValidationError handler doesn't fire — pydantic's ValidationError
# bubbles up as a 500. Map it explicitly to 422 so clients see a typed
# validation failure instead of an opaque server error.
@app.exception_handler(ValidationError)
async def _on_validation_error(request: Request, exc: ValidationError) -> JSONResponse:
return JSONResponse(status_code=422, content={"detail": exc.errors()})


app.include_router(health.router)
app.include_router(jobs.router)
Empty file.
8 changes: 8 additions & 0 deletions apps/app_docling_api/app/routes/health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from fastapi import APIRouter

router = APIRouter(tags=["health"])


@router.get("/healthz")
def healthz() -> dict[str, str]:
return {"status": "ok"}
142 changes: 142 additions & 0 deletions apps/app_docling_api/app/routes/jobs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
from __future__ import annotations

import uuid
from datetime import UTC, datetime

from app_docling_common.config import get_settings
from app_docling_common.models import Job, JobStatus, WebhookStatus
from app_docling_common.queue import PARSE_TASK, QUEUE_NAME
from app_docling_common.storage import ObjectStorage
from arq import ArqRedis
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Request,
UploadFile,
status,
)
from sqlalchemy.orm import Session

from apps.app_docling_api.app.deps import get_arq_pool, get_db, get_storage
from apps.app_docling_api.app.schemas import (
JobCreateResponse,
JobOptions,
JobResult,
JobView,
)

router = APIRouter(prefix="/jobs", tags=["jobs"])

_MB = 1024 * 1024


@router.post("", response_model=JobCreateResponse, status_code=status.HTTP_202_ACCEPTED)
async def create_job(
request: Request,
file: UploadFile = File(...),
options: str | None = Form(default=None),
db: Session = Depends(get_db),
storage: ObjectStorage = Depends(get_storage),
pool: ArqRedis = Depends(get_arq_pool),
) -> JobCreateResponse:
settings = get_settings()
opts = JobOptions.model_validate_json(options) if options else JobOptions()

payload = await file.read()
if len(payload) > settings.api_max_upload_mb * _MB:
raise HTTPException(
status_code=413,
detail=f"file exceeds {settings.api_max_upload_mb} MB limit",
)

job_id = uuid.uuid4()
input_key = f"inputs/{job_id}/{file.filename or 'document'}"
storage.upload_bytes(payload, input_key, content_type=file.content_type)

job = Job(
job_id=job_id,
status=JobStatus.queued,
input_key=input_key,
output_format=opts.output_format,
options=opts.model_dump(mode="json", exclude={"webhook_url", "callback_token"}),
webhook_url=str(opts.webhook_url) if opts.webhook_url else None,
webhook_status=(
WebhookStatus.pending if opts.webhook_url else WebhookStatus.not_configured
),
)
db.add(job)
db.commit()

await pool.enqueue_job(
PARSE_TASK,
str(job_id),
opts.callback_token,
_queue_name=QUEUE_NAME,
_job_id=str(job_id),
)

return JobCreateResponse(
job_id=job_id,
status=JobStatus.queued,
status_url=str(request.url_for("get_job", job_id=str(job_id))),
)


@router.get("/{job_id}", response_model=JobView, name="get_job")
def get_job(job_id: uuid.UUID, db: Session = Depends(get_db)) -> JobView:
job = db.get(Job, job_id)
if not job:
raise HTTPException(status_code=404, detail="job not found")
return JobView(
job_id=job.job_id,
status=job.status,
output_format=job.output_format,
error=job.error,
webhook_status=job.webhook_status,
attempts=job.attempts,
created_at=job.created_at,
updated_at=job.updated_at,
started_at=job.started_at,
finished_at=job.finished_at,
)


@router.get("/{job_id}/result", response_model=JobResult)
def get_job_result(
job_id: uuid.UUID,
db: Session = Depends(get_db),
storage: ObjectStorage = Depends(get_storage),
) -> JobResult:
job = db.get(Job, job_id)
if not job:
raise HTTPException(status_code=404, detail="job not found")
if job.status not in (JobStatus.done, JobStatus.failed):
raise HTTPException(
status_code=409, detail=f"job not finished (status={job.status.value})"
)

result_url = storage.presigned_get(job.result_key) if job.result_key else None
return JobResult(
job_id=job.job_id,
status=job.status,
output_format=job.output_format,
result_url=result_url,
error=job.error,
)


@router.post("/{job_id}/cancel", response_model=JobView)
def cancel_job(job_id: uuid.UUID, db: Session = Depends(get_db)) -> JobView:
job = db.get(Job, job_id)
if not job:
raise HTTPException(status_code=404, detail="job not found")
if job.status not in (JobStatus.queued, JobStatus.processing):
raise HTTPException(status_code=409, detail="job already finished")
job.status = JobStatus.failed
job.error = "cancelled by client"
job.finished_at = datetime.now(UTC)
db.commit()
return get_job(job_id, db)
Loading
Loading