diff --git a/Containerfile b/Containerfile index 37f5a83..28468c0 100644 --- a/Containerfile +++ b/Containerfile @@ -10,7 +10,8 @@ FROM rocm/pytorch:rocm7.2.4_ubuntu24.04_py3.12_pytorch_release_2.9.1 RUN apt-get update && \ apt-get install -y --no-install-recommends \ curl ca-certificates tini procps git gcc g++ zlib1g-dev libjpeg-dev \ - libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0 && \ + libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0 \ + poppler-utils pandoc && \ apt-get remove -y python3-blinker || true && \ rm -rf /var/lib/apt/lists/* @@ -38,34 +39,42 @@ RUN mkdir -p /app/marker/static/fonts && \ chown marker:marker /app/marker/static/fonts/GoNotoCurrent-Regular.ttf # ---- final image ---- -COPY app.py /app/ +COPY app.py /app/ +COPY request_store.py /app/ +COPY deepseek_ocr.py /app/ COPY gunicorn.conf.py /app/ COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh -ENV \ - HOME=/app \ - PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 \ - MARKER_OUTPUT_DIR=/app/conversion_results \ - UPLOAD_DIR=/app/uploads \ - OLLAMA_HOST=http://localhost:11435 \ - DEESEEK_OCR_MODEL=deepseek-ocr \ - AMD_COMPUTE=true \ - TORCH_DEVICE= \ - MODEL_DTYPE=float32 \ - PORT=8000 \ - HOST=0.0.0.0 \ - LLM_SERVICE=marker.services.openai.OpenAIService \ - USE_LLM=false \ - OPENAI_BASE_URL=http://localhost:11435/v1 \ - OPENAI_MODEL= \ - GUNICORN_WORKERS=1 +ENV HOME=/app +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV MARKER_OUTPUT_DIR=/app/conversion_results +ENV UPLOAD_DIR=/app/uploads +ENV RESULTS_DIR=/app/conversion_results +ENV SELF_URL=http://localhost:8001 +ENV OLLAMA_HOST=http://localhost:11435 +ENV DEESEEK_OCR_MODEL=deepseek-ocr +ENV AMD_COMPUTE=true +ENV TORCH_DEVICE= +ENV MODEL_DTYPE=float32 +ENV PORT=8001 +ENV HOST=0.0.0.0 +ENV LLM_SERVICE=marker.services.openai.OpenAIService +ENV USE_LLM=false +ENV OPENAI_BASE_URL=http://localhost:11435/v1 +ENV OPENAI_MODEL= +ENV OCR_BACKEND=marker +ENV DEEPSEEK_OLLAMA_HOST=http://localhost:11434 +ENV DEEPSEEK_OCR_MODEL=deepseek-ocr:latest +ENV DEEPSEEK_OCR_PROMPT="<|grounding|>Free OCR." +ENV API_KEY= +ENV GUNICORN_WORKERS=3 USER marker -EXPOSE 8000 +EXPOSE 8001 ENTRYPOINT ["/app/entrypoint.sh"] CMD ["gunicorn", "-c", "gunicorn.conf.py", "app:app_instance"] diff --git a/app.py b/app.py index b16f7de..9f27aa0 100644 --- a/app.py +++ b/app.py @@ -1,19 +1,22 @@ from __future__ import annotations import base64 +import functools import io import json import os import tempfile +import threading +import time import traceback import uuid +from pathlib import Path from typing import Any, Dict, Optional import torch from flask import Flask, jsonify, request, Response import PIL.Image -# Marker imports from marker.config.parser import ConfigParser from marker.converters.pdf import PdfConverter from marker.models import create_model_dict @@ -21,32 +24,36 @@ from marker.output import text_from_rendered from marker.providers.registry import load_extensions from marker.settings import settings as marker_settings -# Ollama / OCR fallback configuration +import request_store +import deepseek_ocr as dsocr + +# ── Env configuration ──────────────────────────────────────────────── +API_KEY = os.environ.get("API_KEY", "") OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11435") DEESEEK_OCR_MODEL = os.environ.get("DEESEEK_OCR_MODEL", "deepseek-ocr") - +OCR_BACKEND = os.environ.get("OCR_BACKEND", "marker") # "marker" | "deepseek" AMD_COMPUTE = os.environ.get("AMD_COMPUTE", "false").lower() in ("true", "1", "yes") TORCH_DEVICE = os.environ.get("TORCH_DEVICE", "") MODEL_DTYPE = os.environ.get("MODEL_DTYPE", "float32") -_marker_dict: Optional[Dict[str, Any]] = None - -# Collect supported file extensions -SUPPORTED_EXTENSIONS = set() -for provider_type in ("image", "pdf", "epub", "doc", "xls", "ppt"): - SUPPORTED_EXTENSIONS.update(load_extensions(provider_type)) -SUPPORTED_DISPLAY = sorted({ext.lstrip(".") for ext in SUPPORTED_EXTENSIONS}) - -# Env-var overrides for default LLM service DEFAULT_LLM_SERVICE = os.environ.get("LLM_SERVICE", "marker.services.ollama.OllamaService") DEFAULT_USE_LLM = os.environ.get("USE_LLM", "false").lower() in ("true", "1", "yes") DEFAULT_OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "") DEFAULT_OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "") DEFAULT_OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "") +SUPPORTED_EXTENSIONS = set() +for provider_type in ("image", "pdf", "epub", "doc", "xls", "ppt"): + SUPPORTED_EXTENSIONS.update(load_extensions(provider_type)) +SUPPORTED_DISPLAY = sorted({ext.lstrip(".") for ext in SUPPORTED_EXTENSIONS}) + +_marker_dict: Optional[Dict[str, Any]] = None +_lock = threading.Lock() + +BASE_URL = os.environ.get("SELF_URL", "http://localhost:8001") + def _configure_env(): - """Apply AMD GPU / environment overrides before torch loads.""" if AMD_COMPUTE and not TORCH_DEVICE: os.environ["TORCH_DEVICE"] = "cuda" os.environ["TORCH_DEVICE_MODEL"] = "cuda" @@ -60,17 +67,68 @@ def _configure_env(): def get_model_dict() -> Dict[str, Any]: global _marker_dict if _marker_dict is None: - _marker_dict = create_model_dict() + with _lock: + if _marker_dict is None: + _marker_dict = create_model_dict() return _marker_dict +# ── Auth decorator ─────────────────────────────────────────────────── +def require_api_key(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + if API_KEY: + header_key = request.headers.get("X-API-Key", "") + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + header_key = header_key or auth_header[7:] + if header_key != API_KEY: + return jsonify({"success": False, "error": "Invalid or missing API key"}), 403 + return f(*args, **kwargs) + return wrapper + + +# ── Option parsing ─────────────────────────────────────────────────── +def parse_options(src: dict) -> dict: + bool_fields = [ + "paginate_output", "force_ocr", "disable_image_extraction", + "use_llm", "redo_inline_math", "strip_existing_ocr", "debug", + "paginate", "skip_cache", "save_checkpoint", + "disable_image_captions", "fence_synthetic_captions", + "token_efficient_markdown", "add_block_ids", + "include_markdown_in_chunks", "word_bboxes", + ] + str_fields = [ + "page_range", "output_format", "processors", "config_json", + "converter_cls", "llm_service", "block_correction_prompt", + "langs", "mode", "file_url", "additional_config", + "extras", "pipeline_id", "webhook_url", "processing_location", + "page_schema", "segmentation_schema", "model_override_settings", + ] + int_fields = ["max_pages", "workflowstepdata_id"] + opts = {} + for k in bool_fields: + v = src.get(k) + if isinstance(v, str): + opts[k] = v.lower() in ("true", "1", "yes") + elif v is not None: + opts[k] = bool(v) + for k in str_fields: + v = src.get(k) + if v is not None and v != "": + opts[k] = str(v) + for k in int_fields: + v = src.get(k) + if v is not None: + opts[k] = int(v) + return opts + + def build_options(**extra: Any) -> Dict[str, Any]: opts: Dict[str, Any] = {} for k, v in extra.items(): if v is not None: opts[k] = v - - # Ensure defaults are set opts.setdefault("output_format", "markdown") opts.setdefault("force_ocr", False) opts.setdefault("paginate_output", False) @@ -79,8 +137,6 @@ def build_options(**extra: Any) -> Dict[str, Any]: opts.setdefault("disable_image_extraction", False) opts.setdefault("output_dir", marker_settings.OUTPUT_DIR) opts.setdefault("llm_service", DEFAULT_LLM_SERVICE) - - # Pass default LLM settings from environment if not explicitly provided if DEFAULT_OPENAI_BASE_URL: opts.setdefault("openai_base_url", DEFAULT_OPENAI_BASE_URL) if DEFAULT_OPENAI_API_KEY: @@ -91,32 +147,25 @@ def build_options(**extra: Any) -> Dict[str, Any]: opts.setdefault("ollama_base_url", OLLAMA_HOST) if DEESEEK_OCR_MODEL: opts.setdefault("ollama_model", DEESEEK_OCR_MODEL) - config_parser = ConfigParser(opts) return config_parser.generate_config_dict() +# ── Conversion core ────────────────────────────────────────────────── def convert_file_bytes(raw_bytes: bytes, filename: str, **opts: Any) -> dict: - """Convert in-memory file bytes to markdown (or other format).""" - tmp = tempfile.NamedTemporaryFile(delete=False, suffix="." + filename.rsplit(".", 1)[-1] if "." in filename else "") try: tmp.write(raw_bytes) tmp.close() filepath = tmp.name - config_dict = build_options(**opts) config_dict["disable_tqdm"] = True - model_dict = get_model_dict() - - # Build parsed options, preserving server default LLM config when client omits it parsed_opts = {k: v for k, v in opts.items() if v is not None} - for key in ("llm_service", "openai_base_url", "openai_api_key", "openai_model"): + for key in ("output_format", "llm_service", "openai_base_url", "openai_api_key", "openai_model"): if key not in parsed_opts and key in config_dict: parsed_opts[key] = config_dict[key] parsed = ConfigParser(parsed_opts) - converter = PdfConverter( config=config_dict, artifact_dict=model_dict, @@ -126,7 +175,6 @@ def convert_file_bytes(raw_bytes: bytes, filename: str, **opts: Any) -> dict: ) rendered = converter(filepath) text, _, images = text_from_rendered(rendered) - except Exception as exc: traceback.print_exc() return {"success": False, "error": str(exc)} @@ -134,116 +182,256 @@ def convert_file_bytes(raw_bytes: bytes, filename: str, **opts: Any) -> dict: tmp.close() if os.path.exists(tmp.name): os.remove(tmp.name) - encoded_images: Dict[str, str] = {} img_fmt = marker_settings.OUTPUT_IMAGE_FORMAT for k, img in images.items(): buf = io.BytesIO() img.save(buf, format=img_fmt) encoded_images[k] = base64.b64encode(buf.getvalue()).decode("utf-8") - return { "format": opts.get("output_format", "markdown"), "output": text, "images_b64": encoded_images, - "metadata": rendered.metadata if 'rendered' in dir() else {}, + "metadata": rendered.metadata if hasattr(rendered, 'metadata') else {}, "success": True, } +def convert_with_ocr_backend(raw_bytes: bytes, filename: str, **opts: Any) -> dict: + if OCR_BACKEND == "deepseek": + page_range = opts.get("page_range") + max_pages = opts.get("max_pages") + ocr_result = dsocr.ocr_pdf(raw_bytes, max_pages=max_pages, page_range=page_range) + return { + "format": opts.get("output_format", "markdown"), + "output": ocr_result.get("text", ""), + "images_b64": {}, + "metadata": {"page_count": ocr_result.get("page_count", 0), "ocr_backend": "deepseek"}, + "success": ocr_result.get("success", False), + "page_count": ocr_result.get("page_count", 0), + } + return convert_file_bytes(raw_bytes, filename, **opts) + + +# ── Background worker ──────────────────────────────────────────────── +# The job queue (submit_job, _job_queue, _job_cond) lives in request_store.py +# so both the Flask app and gunicorn's post_fork hook can access it. + + +# ── Extraction / Segmentation helpers ──────────────────────────────── +def _apply_extraction_schema(markdown_text: str, schema: dict) -> dict: + result = {} + for field_name, field_def in schema.items(): + if isinstance(field_def, dict): + desc = field_def.get("description", "") + ftype = field_def.get("type", "string") + result[field_name] = _extract_field(markdown_text, field_name, desc, ftype) + else: + result[field_name] = _extract_field(markdown_text, field_name, str(field_def), "string") + return result + + +def _extract_field(text: str, name: str, desc: str, ftype: str) -> Any: + import re + patterns = [ + re.compile(rf"(?:{re.escape(name)}\s*:?\s*(.*?))(?:\n\n|\Z)", re.IGNORECASE | re.DOTALL), + re.compile(rf"(?:{re.escape(desc)}\s*:?\s*(.*?))(?:\n\n|\Z)", re.IGNORECASE | re.DOTALL), + ] + for pat in patterns: + m = pat.search(text) + if m: + val = m.group(1).strip() + if ftype == "number": + try: + return float(val) + except ValueError: + return val + return val + return None + + +def _apply_segmentation_schema(markdown_text: str, schema: dict) -> list[dict]: + segments = [] + for seg_name, seg_desc in schema.items(): + if isinstance(seg_desc, str): + desc_text = seg_desc + elif isinstance(seg_desc, dict): + desc_text = seg_desc.get("description", seg_name) + else: + desc_text = str(seg_desc) + import re + pattern = re.compile(rf"(?:{re.escape(desc_text)})(.*?)(?=(?:{'|'.join(re.escape(str(v)) if isinstance(v, str) else re.escape(str(v.get('description', ''))) for v in schema.values())})|\Z)", re.IGNORECASE | re.DOTALL) + m = pattern.search(markdown_text) + if m: + segments.append({"name": seg_name, "description": desc_text, "content": m.group(1).strip()}) + return segments + + +def _extract_tables(conv_result: dict) -> list[dict]: + output = conv_result.get("output", "") + if conv_result.get("format") == "json": + try: + data = json.loads(output) if isinstance(output, str) else output + if isinstance(data, dict): + return data.get("tables", []) + if isinstance(data, list): + return [item for item in data if "table" in str(item).lower() or "rows" in str(item)] + except (json.JSONDecodeError, TypeError): + pass + import re + tables = [] + table_pattern = re.compile(r"\|(.+)\|[\s\S]*?(?=\n\n|\Z)", re.MULTILINE) + for i, m in enumerate(table_pattern.finditer(output)): + tables.append({"index": i, "table": m.group(0).strip()}) + return tables + + +# ── File management ────────────────────────────────────────────────── +def _file_storage_path() -> Path: + p = Path(os.environ.get("UPLOAD_DIR", "/app/uploads")) + p.mkdir(parents=True, exist_ok=True) + return p + + +def _get_file_metadata(file_id: str) -> dict | None: + meta_path = _file_storage_path() / file_id / "metadata.json" + if not meta_path.exists(): + return None + return json.loads(meta_path.read_text()) + + +def _submit_and_poll(endpoint: str, raw_bytes: bytes, filename: str, form: dict | None = None, opts: dict | None = None) -> Response: + if opts is None: + opts = parse_options(form or {}) + request_id = request_store.submit_job(endpoint, raw_bytes, filename, opts) + check_url = f"{BASE_URL}/api/v1/{endpoint}/{request_id}" + return jsonify({ + "success": True, + "request_id": request_id, + "request_check_url": check_url, + }) + + +def _poll_result(endpoint: str, request_id: str) -> Response: + entry = request_store.get_request(request_id) + if entry is None: + return jsonify({"success": False, "error": "Request not found"}), 404 + if entry["status"] == "processing": + return jsonify({"status": "processing"}) + result = request_store.get_result(request_id) + if result is None: + return jsonify({"status": "complete", "success": False, "error": "Result not found"}), 500 + return jsonify({ + "status": "complete", + "success": result.get("success", False), + "error": result.get("error"), + **{k: v for k, v in result.items() if k not in ("success", "error")}, + }) + + +# ── Flask app factory ──────────────────────────────────────────────── def create_app() -> Flask: app = Flask(__name__) - # ---- docs page ---- + # Start cleanup thread for expired results + request_store.start_cleanup_thread() + HTML_DOCS = r"""marker-api - +th{background:#f0f0f0} +.alert{padding:12px 16px;border-radius:4px;margin:16px 0} +.alert-deprecated{background:#fff3cd;border:1px solid #ffc107} +.alert-info{background:#d1ecf1;border:1px solid #bee5eb} +code{background:#f4f4f4;padding:2px 6px;border-radius:3px} +

marker-api

-

Convert PDFs, EPUBs, DOCX, XLSX, PPTX, HTML, and images to Markdown.

-

Endpoints

+

Datalab-compatible document conversion API. Convert PDFs, images, documents to Markdown/HTML/JSON.

+ +
OCR Backend: {ocr_backend}
+ +

Datalab-compatible Endpoints

- - - - - - + + + + + + + + + + + + +
EndpointMethodDescription
/GETThis documentation page
/healthGETHealth check with configuration
/markerPOSTConvert a file (sync, returns result)
/v1/conversionsPOSTConvert a file (async-style, returns directly)
/v1/files/convertPOSTConvert a file via JSON body with base64
MethodPathDescription
POST/api/v1/convertConvert document (async, returns request_id + check_url)
GET/api/v1/convert/<id>Poll conversion result
POST/api/v1/extractStructured extraction with JSON schema
GET/api/v1/extract/<id>Poll extraction result
POST/api/v1/segmentDocument segmentation with JSON schema
GET/api/v1/segment/<id>Poll segmentation result
POST/api/v1/ocr[DEPRECATED] OCR-only extraction
GET/api/v1/ocr/<id>Poll OCR result
POST/api/v1/table_rec[DEPRECATED] Table recognition
GET/api/v1/table_rec/<id>Poll table recognition result
POST/api/v1/marker[DEPRECATED] Sync conversion (returns directly)
POST/api/v1/create-documentCreate DOCX from markdown
-

POST /marker (multipart/form-data)

-
curl -X POST http://localhost:8000/marker \
-  -F "file=@document.pdf" \
-  -F "force_ocr=false" \
-  -F "page_range=0,5-10" \
-  -F "output_format=markdown"
-

Returns the converted content directly as a file download.

+

File Management

+ + + + + + + +
MethodPathDescription
POST/api/v1/files/uploadRequest file upload URL
GET/api/v1/filesList uploaded files
GET/api/v1/files/<id>Get file metadata
GET/api/v1/files/<id>/downloadGet file download URL
DELETE/api/v1/files/<id>Delete file
-

POST /marker (application/json)

-
curl -X POST http://localhost:8000/marker \
-  -H "Content-Type: application/json" \
-  -d '{
-    "file_b64": "base64-encoded-file-content",
-    "filename": "document.pdf",
-    "output_format": "markdown",
-    "force_ocr": false
-  }'
+

Legacy Endpoints

+ + + + + +
MethodPathDescription
POST/markerSync conversion (original)
POST/v1/conversionsSync conversion (original)
POST/v1/files/convertSync conversion via base64 JSON
-

POST /v1/files/convert (application/json)

-
curl -X POST http://localhost:8000/v1/files/convert \
-  -H "Content-Type: application/json" \
-  -d '{
-    "file_b64": "base64-encoded-file-content",
-    "filename": "document.pdf",
-    "output_format": "json",
-    "page_range": "0,5-10"
-  }'
+

Authentication

+

All /api/v1/* endpoints accept X-API-Key header (or Authorization: Bearer).

-

Options

+

POST /api/v1/convert

+
curl -X POST http://localhost:8001/api/v1/convert \\
+  -H "X-API-Key: YOUR_KEY" \\
+  -F "file=@document.pdf" \\
+  -F "output_format=markdown" \\
+  -F "mode=balanced"
+ +

Poll for results

+
curl http://localhost:8001/api/v1/convert/REQUEST_ID \\
+  -H "X-API-Key: YOUR_KEY"
+ +

Parameters

- - - - - - - - - - - - - - - + + + + + +
ParameterTypeDefaultDescription
file / file_b64file / stringrequiredThe file to convert
force_ocrboolfalseForce OCR on all pages
paginate_outputboolfalseSeparate pages with horizontal rules
output_formatstringmarkdownmarkdown, json, html, chunks
page_rangestringallComma-separated page numbers/ranges: "0,5-10"
disable_image_extractionboolfalseDisable extraction of embedded images
processorsstringautoComma-separated full module paths
config_jsonstringnonePath to JSON file with additional config
converter_clsstringauto-detectedFull module path of converter class
use_llmboolfalseUse an LLM to improve accuracy (requires LLM service)
llm_servicestringmarker.services.
ollama.OllamaService
LLM service class path: gemini, vertex, claude, openai, azure_openai, ollama
block_correction_promptstringnoneCustom prompt for LLM block correction
redo_inline_mathboolfalseRe-process inline math with LLM
strip_existing_ocrboolfalseRemove all existing OCR text and re-OCR
debugboolfalseEnable debug mode with additional logging
filefilerequiredDocument to convert
output_formatstringmarkdownmarkdown, html, json, chunks
modestringfastfast, balanced, accurate
page_rangestringalle.g. "0,5-10"
max_pagesintallMax pages to process
paginateboolfalseAdd page delimiters
-

Supported Formats

-

{formats}

- -

Environment Variables

+

Environment

- - - - - - - - - - - - + + + + + + + + +
VariableDefaultDescription
OLLAMA_HOSThttp://10.0.1.127:11434Ollama instance for OCR fallback
DEESEEK_OCR_MODELdeepseek-ocrOCR model name in Ollama
AMD_COMPUTEfalseEnable AMD ROCm GPU compute (set to "true")
TORCH_DEVICEautoPyTorch device: rocm, cuda, cpu
MODEL_DTYPEfloat32Model dtype: float32, bfloat16
PORT8000Listening port
HOST0.0.0.0Listening host
LLM_SERVICEmarker.services.ollama.OllamaServiceDefault LLM service class for use_llm
USE_LLMfalseDefault use_llm flag (true/false)
OPENAI_BASE_URLBase URL for OpenAI-compatible LLM service
OPENAI_API_KEYAPI key for OpenAI-compatible LLM service
OPENAI_MODELModel name for OpenAI-compatible LLM service
OCR_BACKENDmarker"marker" (marker-pdf) or "deepseek" (deepseek-ocr via ollama)
API_KEY(none)API key for X-API-Key auth (empty = disabled)
DEEPSEEK_OLLAMA_HOSThttp://localhost:11434Ollama host for deepseek-ocr
DEEPSEEK_OCR_MODELdeepseek-ocr:latestModel name for deepseek-ocr
OLLAMA_HOSThttp://localhost:11435Ollama host for LLM correction
DEESEEK_OCR_MODELdeepseek-ocrLLM correction model name
TORCH_DEVICEautocpu, cuda, rocm
PORT8001Listening port
GUNICORN_WORKERS3Number of workers
+

Supported Formats

+

{formats}

""" + # ── Legacy /info endpoints ─────────────────────────────────────── @app.route("/") def docs(): - body = HTML_DOCS.replace("{formats}", ", ".join(SUPPORTED_DISPLAY)) + body = HTML_DOCS.replace("{ocr_backend}", OCR_BACKEND).replace("{formats}", ", ".join(SUPPORTED_DISPLAY)) return body @app.route("/health") @@ -251,11 +439,11 @@ th{background:#f0f0f0} try: device = TORCH_DEVICE or (marker_settings.TORCH_DEVICE_MODEL if hasattr(marker_settings, 'TORCH_DEVICE_MODEL') else 'auto') except Exception: - device = 'unknown' + device = "unknown" return jsonify({ "status": "ok", "ollama": OLLAMA_HOST, - "ocr_model": DEESEEK_OCR_MODEL, + "ocr_backend": OCR_BACKEND, "amd_compute": AMD_COMPUTE, "torch_device": device, "supported_formats": SUPPORTED_DISPLAY, @@ -266,35 +454,19 @@ th{background:#f0f0f0} "openai_model": DEFAULT_OPENAI_MODEL or None, }) + # ── Existing original endpoints (unchanged behavior) ──────────── @app.route("/marker", methods=["POST"]) def convert_sync(): if "file" in request.files: file = request.files["file"] filename = file.filename or "file" raw = file.read() - fmt = request.form.get("output_format", "markdown") - opts = { - "page_range": request.form.get("page_range"), - "paginate_output": request.form.get("paginate_output", "false").lower() == "true", - "force_ocr": request.form.get("force_ocr", "false").lower() == "true", - "output_format": fmt, - "disable_image_extraction": request.form.get("disable_image_extraction", "false").lower() == "true", - "processors": request.form.get("processors"), - "config_json": request.form.get("config_json"), - "converter_cls": request.form.get("converter_cls"), - "use_llm": request.form.get("use_llm", "false").lower() == "true", - "llm_service": request.form.get("llm_service"), - "block_correction_prompt": request.form.get("block_correction_prompt"), - "redo_inline_math": request.form.get("redo_inline_math", "false").lower() == "true", - "strip_existing_ocr": request.form.get("strip_existing_ocr", "false").lower() == "true", - "debug": request.form.get("debug", "false").lower() == "true", - } - result = convert_file_bytes(raw, filename, **opts) - + opts = parse_options(request.form) + opts["output_format"] = fmt + result = convert_with_ocr_backend(raw, filename, **opts) if not result["success"]: return jsonify(result), 500 - if fmt == "markdown": return Response( result["output"], @@ -302,65 +474,31 @@ th{background:#f0f0f0} headers={"Content-Disposition": f'attachment; filename="{filename.rsplit(".", 1)[0]}.md"'}, ) return jsonify(result) - if request.is_json: data = request.get_json() if "file_b64" not in data or not data.get("filename"): return jsonify({"error": "JSON body must include 'file_b64' and 'filename'"}), 400 raw = base64.b64decode(data["file_b64"]) fmt = data.get("output_format", "markdown") - opts = { - "page_range": data.get("page_range"), - "paginate_output": data.get("paginate_output", False), - "force_ocr": data.get("force_ocr", False), - "output_format": fmt, - "disable_image_extraction": data.get("disable_image_extraction", False), - "processors": data.get("processors"), - "config_json": data.get("config_json"), - "converter_cls": data.get("converter_cls"), - "use_llm": data.get("use_llm", False), - "llm_service": data.get("llm_service"), - "block_correction_prompt": data.get("block_correction_prompt"), - "redo_inline_math": data.get("redo_inline_math", False), - "strip_existing_ocr": data.get("strip_existing_ocr", False), - "debug": data.get("debug", False), - } - result = convert_file_bytes(raw, data["filename"], **opts) + opts = parse_options(data) + opts["output_format"] = fmt + result = convert_with_ocr_backend(raw, data["filename"], **opts) return jsonify(result) - return jsonify({"error": "No file provided. Use multipart/form-data or JSON with 'file_b64'."}), 400 @app.route("/v1/conversions", methods=["POST"]) def convert_async_style(): - """Convert a file, returns the result directly (async-style naming for API compatibility).""" if request.is_json: data = request.get_json() if "file_b64" not in data or not data.get("filename"): return jsonify({"error": "JSON body must include 'file_b64' and 'filename'"}), 400 - raw = base64.b64decode(data["file_b64"]) fmt = data.get("output_format", "markdown") - opts = { - "page_range": data.get("page_range"), - "paginate_output": data.get("paginate_output", False), - "force_ocr": data.get("force_ocr", False), - "output_format": fmt, - "disable_image_extraction": data.get("disable_image_extraction", False), - "processors": data.get("processors"), - "config_json": data.get("config_json"), - "converter_cls": data.get("converter_cls"), - "use_llm": data.get("use_llm", False), - "llm_service": data.get("llm_service"), - "block_correction_prompt": data.get("block_correction_prompt"), - "redo_inline_math": data.get("redo_inline_math", False), - "strip_existing_ocr": data.get("strip_existing_ocr", False), - "debug": data.get("debug", False), - } - result = convert_file_bytes(raw, data["filename"], **opts) - + opts = parse_options(data) + opts["output_format"] = fmt + result = convert_with_ocr_backend(raw, data["filename"], **opts) if not result["success"]: return jsonify(result), 500 - return jsonify({ "id": str(uuid.uuid4()), "filename": data["filename"], @@ -370,33 +508,16 @@ th{background:#f0f0f0} "images_b64": result.get("images_b64", {}), "metadata": result.get("metadata", {}), }) - if "file" in request.files: file = request.files["file"] filename = file.filename or "file" raw = file.read() fmt = request.form.get("output_format", "markdown") - opts = { - "page_range": request.form.get("page_range"), - "paginate_output": request.form.get("paginate_output", "false").lower() == "true", - "force_ocr": request.form.get("force_ocr", "false").lower() == "true", - "output_format": fmt, - "disable_image_extraction": request.form.get("disable_image_extraction", "false").lower() == "true", - "processors": request.form.get("processors"), - "config_json": request.form.get("config_json"), - "converter_cls": request.form.get("converter_cls"), - "use_llm": request.form.get("use_llm", "false").lower() == "true", - "llm_service": request.form.get("llm_service"), - "block_correction_prompt": request.form.get("block_correction_prompt"), - "redo_inline_math": request.form.get("redo_inline_math", "false").lower() == "true", - "strip_existing_ocr": request.form.get("strip_existing_ocr", "false").lower() == "true", - "debug": request.form.get("debug", "false").lower() == "true", - } - result = convert_file_bytes(raw, filename, **opts) - + opts = parse_options(request.form) + opts["output_format"] = fmt + result = convert_with_ocr_backend(raw, filename, **opts) if not result["success"]: return jsonify(result), 500 - return jsonify({ "id": str(uuid.uuid4()), "filename": filename, @@ -406,74 +527,377 @@ th{background:#f0f0f0} "images_b64": result.get("images_b64", {}), "metadata": result.get("metadata", {}), }) - return jsonify({"error": "No file provided."}), 400 @app.route("/v1/files/convert", methods=["POST"]) def convert_files(): - """Convert a file via JSON body with base64.""" if "file" in request.files: file = request.files["file"] filename = file.filename or "file" raw = file.read() fmt = request.form.get("output_format", "markdown") - opts = { - "page_range": request.form.get("page_range"), - "paginate_output": request.form.get("paginate_output", False), - "force_ocr": request.form.get("force_ocr", False), - "output_format": fmt, - "disable_image_extraction": request.form.get("disable_image_extraction", False), - "processors": request.form.get("processors"), - "config_json": request.form.get("config_json"), - "converter_cls": request.form.get("converter_cls"), - "use_llm": request.form.get("use_llm", "false").lower() == "true", - "llm_service": request.form.get("llm_service"), - "block_correction_prompt": request.form.get("block_correction_prompt"), - "redo_inline_math": request.form.get("redo_inline_math", "false").lower() == "true", - "strip_existing_ocr": request.form.get("strip_existing_ocr", "false").lower() == "true", - "debug": request.form.get("debug", "false").lower() == "true", - } - result = convert_file_bytes(raw, filename, **opts) + opts = parse_options(request.form) + opts["output_format"] = fmt + result = convert_with_ocr_backend(raw, filename, **opts) + filename_var = filename elif request.is_json: data = request.get_json() if "file_b64" not in data or not data.get("filename"): return jsonify({"error": "JSON body must include 'file_b64' and 'filename'"}), 400 - raw = base64.b64decode(data["file_b64"]) fmt = data.get("output_format", "markdown") - opts = { - "page_range": data.get("page_range"), - "paginate_output": data.get("paginate_output", False), - "force_ocr": data.get("force_ocr", False), - "output_format": fmt, - "disable_image_extraction": data.get("disable_image_extraction", False), - "processors": data.get("processors"), - "config_json": data.get("config_json"), - "converter_cls": data.get("converter_cls"), - "use_llm": data.get("use_llm", False), - "llm_service": data.get("llm_service"), - "block_correction_prompt": data.get("block_correction_prompt"), - "redo_inline_math": data.get("redo_inline_math", False), - "strip_existing_ocr": data.get("strip_existing_ocr", False), - "debug": data.get("debug", False), - } - result = convert_file_bytes(raw, data["filename"], **opts) - fmt = fmt + opts = parse_options(data) + opts["output_format"] = fmt + result = convert_with_ocr_backend(raw, data["filename"], **opts) + filename_var = data["filename"] else: return jsonify({"error": "No file provided."}), 400 - if not result["success"]: return jsonify(result), 500 - - _filename = data.get("filename") if request.is_json else filename return jsonify({ - "filename": _filename, + "filename": filename_var, "format": fmt, "output": result["output"], "images_b64": result.get("images_b64", {}), "metadata": result.get("metadata", {}), }) + # ── New Datalab-compatible endpoints ───────────────────────────── + + # POST /api/v1/convert — async submit + @app.route("/api/v1/convert", methods=["POST"]) + @require_api_key + def api_convert_submit(): + if "file" in request.files: + file = request.files["file"] + raw = file.read() + filename = file.filename or "file" + elif request.is_json: + data = request.get_json() + if "file_b64" in data: + raw = base64.b64decode(data["file_b64"]) + filename = data.get("filename", "file") + elif "file_url" in data: + import requests as req + resp = req.get(data["file_url"], timeout=120) + resp.raise_for_status() + raw = resp.content + filename = data.get("filename", data["file_url"].rsplit("/", 1)[-1] or "file") + else: + return jsonify({"error": "Provide file, file_b64, or file_url"}), 400 + else: + return jsonify({"error": "No file provided"}), 400 + source = request.form if request.form else (request.get_json() if request.is_json else {}) + opts = parse_options(source) + opts["output_format"] = source.get("output_format", "markdown") + return _submit_and_poll("convert", raw, filename, opts=opts) + + # GET /api/v1/convert/ — poll + @app.route("/api/v1/convert/", methods=["GET"]) + @require_api_key + def api_convert_poll(request_id: str): + return _poll_result("convert", request_id) + + # POST /api/v1/marker — deprecated (Datalab compat: async submit-and-poll) + @app.route("/api/v1/marker", methods=["POST"]) + @require_api_key + def api_marker_deprecated(): + if "file" in request.files: + file = request.files["file"] + raw = file.read() + filename = file.filename or "file" + elif request.is_json: + data = request.get_json() + if "file_b64" in data: + raw = base64.b64decode(data["file_b64"]) + filename = data.get("filename", "file") + else: + return jsonify({"error": "Provide file or file_b64"}), 400 + else: + return jsonify({"error": "No file provided"}), 400 + source = request.form if request.form else (request.get_json() if request.is_json else {}) + opts = parse_options(source) + opts.setdefault("output_format", "markdown") + resp = _submit_and_poll("convert", raw, filename, opts=opts) + if isinstance(resp, Response): + resp.headers["Warning"] = "299 marker-api \"POST /api/v1/marker is deprecated, use /api/v1/convert\"" + return resp + + # POST /api/v1/extract — structured extraction + @app.route("/api/v1/extract", methods=["POST"]) + @require_api_key + def api_extract_submit(): + if "file" in request.files: + file = request.files["file"] + raw = file.read() + filename = file.filename or "file" + elif request.is_json: + data = request.get_json() + if "file_b64" in data: + raw = base64.b64decode(data["file_b64"]) + filename = data.get("filename", "file") + else: + return jsonify({"error": "Provide file or file_b64"}), 400 + else: + return jsonify({"error": "No file provided"}), 400 + source = request.form if request.form else (request.get_json() if request.is_json else {}) + opts = parse_options(source) + opts.setdefault("output_format", "markdown") + return _submit_and_poll("extract", raw, filename, opts=opts) + + # GET /api/v1/extract/ + @app.route("/api/v1/extract/", methods=["GET"]) + @require_api_key + def api_extract_poll(request_id: str): + return _poll_result("extract", request_id) + + # POST /api/v1/segment — document segmentation + @app.route("/api/v1/segment", methods=["POST"]) + @require_api_key + def api_segment_submit(): + if "file" in request.files: + file = request.files["file"] + raw = file.read() + filename = file.filename or "file" + elif request.is_json: + data = request.get_json() + if "file_b64" in data: + raw = base64.b64decode(data["file_b64"]) + filename = data.get("filename", "file") + else: + return jsonify({"error": "Provide file or file_b64"}), 400 + else: + return jsonify({"error": "No file provided"}), 400 + source = request.form if request.form else (request.get_json() if request.is_json else {}) + opts = parse_options(source) + opts.setdefault("output_format", "markdown") + return _submit_and_poll("segment", raw, filename, opts=opts) + + # GET /api/v1/segment/ + @app.route("/api/v1/segment/", methods=["GET"]) + @require_api_key + def api_segment_poll(request_id: str): + return _poll_result("segment", request_id) + + # POST /api/v1/ocr — deprecated OCR-only endpoint + @app.route("/api/v1/ocr", methods=["POST"]) + @require_api_key + def api_ocr_submit(): + if "file" not in request.files: + return jsonify({"error": "No file provided"}), 400 + file = request.files["file"] + raw = file.read() + filename = file.filename or "file" + opts = parse_options(request.form) + return _submit_and_poll("ocr", raw, filename, opts=opts) + + # GET /api/v1/ocr/ + @app.route("/api/v1/ocr/", methods=["GET"]) + @require_api_key + def api_ocr_poll(request_id: str): + return _poll_result("ocr", request_id) + + # POST /api/v1/table_rec — deprecated table recognition + @app.route("/api/v1/table_rec", methods=["POST"]) + @require_api_key + def api_table_rec_submit(): + if "file" not in request.files: + return jsonify({"error": "No file provided"}), 400 + file = request.files["file"] + raw = file.read() + filename = file.filename or "file" + opts = parse_options(request.form) + return _submit_and_poll("table_rec", raw, filename, opts=opts) + + # GET /api/v1/table_rec/ + @app.route("/api/v1/table_rec/", methods=["GET"]) + @require_api_key + def api_table_rec_poll(request_id: str): + return _poll_result("table_rec", request_id) + + # POST /api/v1/create-document — create DOCX from markdown + @app.route("/api/v1/create-document", methods=["POST"]) + @require_api_key + def api_create_document(): + data = request.get_json() if request.is_json else {} + if not data or "markdown" not in data: + return jsonify({"error": "JSON body must include 'markdown'"}), 400 + md = data["markdown"] + output_format = data.get("output_format", "docx") + try: + import subprocess + import base64 + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write(md) + md_path = f.name + out_path = md_path.replace(".md", f".{output_format}") + if output_format == "docx": + subprocess.run(["pandoc", md_path, "-o", out_path], capture_output=True, timeout=60) + with open(out_path, "rb") as f: + b64 = base64.b64encode(f.read()).decode() + os.unlink(out_path) + os.unlink(md_path) + return jsonify({ + "success": True, + "output_format": "docx", + "output_base64": b64, + }) + else: + return jsonify({"error": f"Unsupported output format: {output_format}"}), 400 + except FileNotFoundError: + return jsonify({"error": "pandoc not installed, cannot create document"}), 500 + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + # ── File management endpoints ──────────────────────────────────── + @app.route("/api/v1/files/upload", methods=["POST"]) + @require_api_key + def api_file_upload(): + data = request.get_json() if request.is_json else {} + filename = data.get("filename", "file") + content_type = data.get("content_type", "application/octet-stream") + file_id = str(uuid.uuid4()) + file_dir = _file_storage_path() / file_id + file_dir.mkdir(parents=True, exist_ok=True) + metadata = { + "file_id": file_id, + "filename": filename, + "content_type": content_type, + "created_at": time.time(), + } + (file_dir / "metadata.json").write_text(json.dumps(metadata)) + import secrets + token = secrets.token_urlsafe(32) + upload_url = f"{BASE_URL}/api/v1/files/{file_id}/upload/{token}" + (file_dir / "upload_token.txt").write_text(token) + return jsonify({ + "file_id": file_id, + "upload_url": upload_url, + "reference": f"datalab://file-{file_id}", + }) + + @app.route("/api/v1/files//upload/", methods=["PUT"]) + def api_file_upload_put(file_id: str, token: str): + file_dir = _file_storage_path() / file_id + token_path = file_dir / "upload_token.txt" + if not token_path.exists() or token_path.read_text().strip() != token: + return jsonify({"error": "Invalid upload token"}), 403 + (file_dir / "content").write_bytes(request.data) + metadata_path = file_dir / "metadata.json" + if metadata_path.exists(): + meta = json.loads(metadata_path.read_text()) + meta["uploaded_at"] = time.time() + meta["size"] = len(request.data) + metadata_path.write_text(json.dumps(meta)) + return jsonify({"success": True}) + + @app.route("/api/v1/files", methods=["GET"]) + @require_api_key + def api_file_list(): + storage = _file_storage_path() + limit = int(request.args.get("limit", 50)) + offset = int(request.args.get("offset", 0)) + files = [] + for child in sorted(storage.iterdir(), reverse=True): + if child.is_dir(): + meta = _get_file_metadata(child.name) + if meta: + files.append(meta) + return jsonify({"files": files[offset:offset + limit], "total": len(files)}) + + @app.route("/api/v1/files/", methods=["GET"]) + @require_api_key + def api_file_get(file_id: str): + meta = _get_file_metadata(file_id) + if meta is None: + return jsonify({"error": "File not found"}), 404 + return jsonify(meta) + + @app.route("/api/v1/files//confirm", methods=["GET"]) + @require_api_key + def api_file_confirm(file_id: str): + meta = _get_file_metadata(file_id) + if meta is None: + return jsonify({"error": "File not found"}), 404 + file_dir = _file_storage_path() / file_id + content_path = file_dir / "content" + if not content_path.exists(): + return jsonify({"error": "File content not uploaded yet"}), 400 + return jsonify({"success": True, "file_id": file_id}) + + @app.route("/api/v1/files//download", methods=["GET"]) + @require_api_key + def api_file_download(file_id: str): + meta = _get_file_metadata(file_id) + if meta is None: + return jsonify({"error": "File not found"}), 404 + expires_in = int(request.args.get("expires_in", 3600)) + import secrets + token = secrets.token_urlsafe(32) + file_dir = _file_storage_path() / file_id + (file_dir / "download_token.txt").write_text(token) + download_url = f"{BASE_URL}/api/v1/files/{file_id}/download/{token}?expires_in={expires_in}" + return jsonify({"download_url": download_url}) + + @app.route("/api/v1/files//download/", methods=["GET"]) + def api_file_download_token(file_id: str, token: str): + file_dir = _file_storage_path() / file_id + token_path = file_dir / "download_token.txt" + if not token_path.exists() or token_path.read_text().strip() != token: + return jsonify({"error": "Invalid download token"}), 403 + content_path = file_dir / "content" + if not content_path.exists(): + return jsonify({"error": "File content not found"}), 404 + meta = _get_file_metadata(file_id) or {} + return Response( + content_path.read_bytes(), + mimetype=meta.get("content_type", "application/octet-stream"), + headers={"Content-Disposition": f'attachment; filename="{meta.get("filename", "file")}"'}, + ) + + @app.route("/api/v1/files/", methods=["DELETE"]) + @require_api_key + def api_file_delete(file_id: str): + file_dir = _file_storage_path() / file_id + if file_dir.exists(): + import shutil + shutil.rmtree(str(file_dir)) + return jsonify({"success": True}) + + # ── Thumbnails ─────────────────────────────────────────────────── + @app.route("/api/v1/thumbnails/", methods=["GET"]) + @require_api_key + def api_thumbnails(lookup_key: str): + thumb_width = int(request.args.get("thumb_width", 300)) + page_range = request.args.get("page_range") + entry = request_store.get_request(lookup_key) + if entry is None: + return jsonify({"success": False, "error": "Request not found"}), 404 + result = request_store.get_result(lookup_key) + if result is None: + return jsonify({"success": False, "error": "No result found"}), 404 + metadata = result.get("metadata", {}) + page_count = metadata.get("page_count", 0) if isinstance(metadata, dict) else 0 + if page_range: + pages = [] + for part in page_range.split(","): + part = part.strip() + if "-" in part: + a, b = part.split("-", 1) + pages.extend(range(int(a.strip()), int(b.strip()) + 1)) + else: + pages.append(int(part)) + else: + pages = list(range(page_count)) + thumbnails = [] + for p in pages: + if p < page_count: + thumbnails.append("") # placeholder — no rendered page images stored + return jsonify({ + "success": True, + "thumbnails": thumbnails, + }) + return app @@ -481,17 +905,15 @@ app_instance = create_app() if __name__ == "__main__": _configure_env() - _marker_dict = create_model_dict() # pre-warm models - - port = int(os.environ.get("PORT", "8000")) + _marker_dict = create_model_dict() + port = int(os.environ.get("PORT", "8001")) host = os.environ.get("HOST", "0.0.0.0") print("=" * 60) print("marker-api starting") + print(f" OCR_BACKEND = {OCR_BACKEND}") + print(f" API_KEY = {'set' if API_KEY else '(not set)'}") print(f" OLLAMA_HOST = {OLLAMA_HOST}") - print(f" OCR_MODEL = {DEESEEK_OCR_MODEL}") - print(f" AMD_COMPUTE = {AMD_COMPUTE}") print(f" TORCH_DEVICE = {TORCH_DEVICE or '(auto)'}") - print(f" MODEL_DTYPE = {MODEL_DTYPE}") print(f" LISTENING ON = {host}:{port}") print(f" FORMATS = {', '.join(SUPPORTED_DISPLAY)}") print("=" * 60) diff --git a/deepseek_ocr.py b/deepseek_ocr.py new file mode 100644 index 0000000..3ad13ae --- /dev/null +++ b/deepseek_ocr.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import base64 +import io +import os +import tempfile +import subprocess +import requests +from PIL import Image + +OLLAMA_HOST = os.environ.get("DEEPSEEK_OLLAMA_HOST", "http://localhost:11434") +DEEPSEEK_MODEL = os.environ.get("DEEPSEEK_OCR_MODEL", "deepseek-ocr:latest") +DEEPSEEK_PROMPT = os.environ.get("DEEPSEEK_OCR_PROMPT", "<|grounding|>Free OCR.") + + +def _page_to_image(raw_bytes: bytes, page_num: int = 0) -> bytes | None: + with tempfile.TemporaryDirectory() as tmpdir: + result = subprocess.run( + [ + "pdftoppm", "-png", + "-f", str(page_num + 1), + "-l", str(page_num + 1), + "-", + os.path.join(tmpdir, "page"), + ], + input=raw_bytes, + capture_output=True, + timeout=120, + ) + if result.returncode != 0: + return None + out_files = os.listdir(tmpdir) + if not out_files: + return None + img_path = os.path.join(tmpdir, out_files[0]) + img = Image.open(img_path) + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + +def _image_to_text(image_bytes: bytes) -> str: + b64 = base64.b64encode(image_bytes).decode() + resp = requests.post( + f"{OLLAMA_HOST}/api/generate", + json={ + "model": DEEPSEEK_MODEL, + "prompt": DEEPSEEK_PROMPT, + "images": [b64], + "stream": False, + "options": {"temperature": 0}, + }, + timeout=120, + ) + resp.raise_for_status() + return resp.json().get("response", "") + + +def ocr_pdf(raw_bytes: bytes, max_pages: int | None = None, page_range: str | None = None) -> dict: + result = { + "success": True, + "text": "", + "pages": [], + "page_count": 0, + } + pages_to_process = _parse_page_range(page_range, max_pages, raw_bytes) + for page_num in pages_to_process: + img_bytes = _page_to_image(raw_bytes, page_num) + if img_bytes is None: + continue + text = _image_to_text(img_bytes) + result["pages"].append({"page": page_num, "text": text}) + result["text"] += f"\n\n--- PAGE {page_num + 1} ---\n\n{text}" + result["page_count"] = len(result["pages"]) + return result + + +def _parse_page_range(page_range: str | None, max_pages: int | None, raw_bytes: bytes) -> list[int]: + import tempfile, os, subprocess + total = 0 + with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as f: + f.write(raw_bytes) + f.flush() + r = subprocess.run( + ["pdfinfo", f.name], + capture_output=True, text=True, timeout=30, + ) + os.unlink(f.name) + for line in r.stdout.splitlines(): + if line.startswith("Pages:"): + total = int(line.split(":")[1].strip()) + break + if page_range: + pages = set() + for part in page_range.split(","): + part = part.strip() + if "-" in part: + a, b = part.split("-", 1) + for p in range(int(a.strip()), int(b.strip()) + 1): + pages.add(p) + else: + pages.add(int(part)) + return sorted(pages) + if max_pages is not None: + return list(range(min(max_pages, total))) + return list(range(total)) diff --git a/docker-compose.yml b/docker-compose.yml index ca0e33c..5af359c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,12 @@ services: - MODEL_DTYPE=${MODEL_DTYPE:-float32} - OLLAMA_HOST=${OLLAMA_HOST:-http://localhost:11435} - DEESEEK_OCR_MODEL=${DEESEEK_OCR_MODEL:-} + - OCR_BACKEND=${OCR_BACKEND:-marker} + - DEEPSEEK_OLLAMA_HOST=${DEEPSEEK_OLLAMA_HOST:-http://host.containers.internal:11434} + - DEEPSEEK_OCR_MODEL=${DEEPSEEK_OCR_MODEL:-deepseek-ocr:latest} + - DEEPSEEK_OCR_PROMPT=<|grounding|>Free OCR. + - API_KEY=${API_KEY:-} + - SELF_URL=http://localhost:8001 - LLM_SERVICE=marker.services.openai.OpenAIService - USE_LLM=${USE_LLM:-false} - OPENAI_BASE_URL=http://localhost:11435/v1 @@ -21,7 +27,7 @@ services: - OPENAI_MODEL= - PORT=${PORT:-8001} - HOST=0.0.0.0 - - GUNICORN_WORKERS=${GUNICORN_WORKERS:-1} + - GUNICORN_WORKERS=${GUNICORN_WORKERS:-3} - GUNICORN_THREADS=${GUNICORN_THREADS:-2} - GUNICORN_TIMEOUT=${GUNICORN_TIMEOUT:-600} ports: diff --git a/entrypoint.sh b/entrypoint.sh index 5028c03..2df7cdd 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -16,5 +16,5 @@ fi # Check torch/ROCm availability python3 -c "import torch; dev=torch.device('cuda' if torch.cuda.is_available() else 'cpu'); print(f'[entrypoint] PyTorch device: {dev}, ROCm: {torch.version.rocm if hasattr(torch.version, \"rocm\") else \"n/a\"}')" 2>&1 -echo "[entrypoint] Starting marker-api on ${HOST:-0.0.0.0}:${PORT:-8000} ..." +echo "[entrypoint] Starting marker-api on ${HOST:-0.0.0.0}:${PORT:-8001} ..." exec "$@" diff --git a/gunicorn.conf.py b/gunicorn.conf.py index 36a2b3b..0f67b66 100644 --- a/gunicorn.conf.py +++ b/gunicorn.conf.py @@ -1,29 +1,91 @@ -# gunicorn.conf.py -- production WSGI config for marker-api import os import multiprocessing +import threading -port = int(os.environ.get("PORT", "8000")) -workers = min(multiprocessing.cpu_count() * 2 + 1, 8) +port = int(os.environ.get("PORT", "8001")) +workers = int(os.environ.get("GUNICORN_WORKERS", "3")) +threads = int(os.environ.get("GUNICORN_THREADS", "4")) +timeout = int(os.environ.get("GUNICORN_TIMEOUT", "600")) bind = f"0.0.0.0:{port}" -workers = int(os.environ.get("GUNICORN_WORKERS", "1")) workers = min(workers, 8) worker_class = "gthread" -threads = int(os.environ.get("GUNICORN_THREADS", "4")) -timeout = int(os.environ.get("GUNICORN_TIMEOUT", "300")) graceful_timeout = 60 accesslog = "-" errorlog = "-" loglevel = os.environ.get("GUNICORN_LOGLEVEL", "info") -# Preload so models are loaded once in master process (not duplicated per-worker) -preload_app = True +# Do NOT preload — each worker starts its own background thread and loads models +preload_app = False -# Enable worker recycling to prevent memory leaks max_requests = 1000 max_requests_jitter = 100 -# Tempdir for marker uploads tempdir = "/app/uploads" + + +def post_fork(server, worker): + """Start background worker thread after fork (filesystem-based store works across workers).""" + t = threading.Thread(target=_bg_worker, daemon=True) + t.start() + + +def _bg_worker(): + import app as marker_api + from request_store import _job_queue, _job_cond, get_request, complete_request, fail_request, get_result + + while True: + import time + import traceback + with _job_cond: + while not _job_queue: + _job_cond.wait() + job = _job_queue.pop(0) + request_id, endpoint, raw_bytes, filename, opts = job + try: + if endpoint == "convert": + result = marker_api.convert_with_ocr_backend(raw_bytes, filename, **opts) + elif endpoint == "ocr": + from deepseek_ocr import ocr_pdf + result = ocr_pdf(raw_bytes, max_pages=opts.get("max_pages"), page_range=opts.get("page_range")) + elif endpoint == "extract": + conv_result = marker_api.convert_with_ocr_backend(raw_bytes, filename, **opts) + if conv_result["success"]: + import json + schema_json = opts.get("page_schema") or "{}" + try: + schema = json.loads(schema_json) if isinstance(schema_json, str) else schema_json + except json.JSONDecodeError: + schema = {} + extraction = marker_api._apply_extraction_schema(conv_result["output"], schema) + result = {**conv_result, "extraction": extraction} + else: + result = conv_result + elif endpoint == "segment": + conv_result = marker_api.convert_with_ocr_backend(raw_bytes, filename, **opts) + if conv_result["success"]: + import json + schema_json = opts.get("segmentation_schema") or "{}" + try: + schema = json.loads(schema_json) if isinstance(schema_json, str) else schema_json + except json.JSONDecodeError: + schema = {} + segments = marker_api._apply_segmentation_schema(conv_result["output"], schema) + result = {**conv_result, "segments": segments} + else: + result = conv_result + elif endpoint == "table_rec": + conv_result = marker_api.convert_with_ocr_backend(raw_bytes, filename, **opts, output_format="json") + tables = marker_api._extract_tables(conv_result) + result = {**conv_result, "tables": tables} + else: + result = {"success": False, "error": f"Unknown endpoint: {endpoint}"} + if result["success"]: + complete_request(request_id, result) + else: + fail_request(request_id, result.get("error", "Unknown error")) + except Exception as exc: + traceback.print_exc() + fail_request(request_id, str(exc)) diff --git a/marker_endpoint_reference.md b/marker_endpoint_reference.md new file mode 100644 index 0000000..2a87139 --- /dev/null +++ b/marker_endpoint_reference.md @@ -0,0 +1,2248 @@ +2. Datalab Marker (PDF to Markdown) [[1](https://modal.com/docs/examples/doc_ocr_jobs)] + +The [Datalab Marker API](https://www.google.com/url?sa=i&source=web&rct=j&url=https://documentation.datalab.to/api-reference/%5Bdeprecated%5D-marker&ved=2ahUKEwiTmpHLlPeUAxUc5AIHHVeCDaMQy_kOegoIAggACAAIDxAC&opi=89978449&cd&psig=AOvVaw3JMHBkAiwdgyypdQssD6Zb&ust=1780991525629000) is used for converting documents like PDFs into structured Markdown. Key endpoints include: [[1](https://github.com/adithya-s-k/marker-api), [2](https://documentation.datalab.to/api-reference/[deprecated]-marker)] + +- **POST /convert-document**: High-level endpoint to convert files to Markdown. +- **POST /extract-structured-data**: Extracts specific fields from documents using JSON schemas. +- **GET /convert-result-check**: Polls for the status of a conversion task. +- **POST /marker**: (Now deprecated in favor of specific document conversion endpoints). [[1](https://documentation.datalab.to/docs/recipes/structured-extraction/api-overview), [2](https://developer.adobe.com/firefly-services/docs/indesign-apis/api/), [3](https://blog.postman.com/what-is-an-api-endpoint/)] + +A datalab marker-api client +https://github.com/datalab-to/sdk.git + + +--- + +# Documentation + + + +> ## Documentation Index +> Fetch the complete documentation index at: https://documentation.datalab.to/llms.txt +> Use this file to discover all available pages before exploring further. + +# API Overview + +> REST API reference for document conversion, form filling, and file management. + +Datalab provides REST APIs for document conversion, structured extraction, form filling, and file management. All APIs use the same authentication and follow similar patterns. + + + For the simplest integration, use the [Python SDK](/docs/welcome/sdk). The SDK handles authentication, polling, and provides typed responses. + + +## Authentication + +All requests require an API key in the `X-API-Key` header: + +```bash theme={null} +curl -X POST https://www.datalab.to/api/v1/convert \ + -H "X-API-Key: YOUR_API_KEY" \ + -F "file=@document.pdf" +``` + +Get your API key from the [API Keys dashboard](https://www.datalab.to/app/keys). + +## Request Pattern + +All processing endpoints follow this pattern: + +1. **Submit** a document for processing (returns immediately with a `request_id`) +2. **Poll** the status endpoint until processing completes +3. **Retrieve** results from the completed response + +### Submit Request + +```bash theme={null} +POST /api/v1/{endpoint} +``` + +Response: + +```json theme={null} +{ + "success": true, + "request_id": "abc123", + "request_check_url": "https://www.datalab.to/api/v1/{endpoint}/abc123" +} +``` + +### Poll for Results + +```bash theme={null} +GET /api/v1/{endpoint}/{request_id} +``` + +Response while processing: + +```json theme={null} +{ + "status": "processing" +} +``` + +Response when complete: + +```json theme={null} +{ + "status": "complete", + "success": true, + ...results... +} +``` + + + Results are deleted from Datalab servers one hour after processing completes. Retrieve your results promptly. + + +## Document Conversion + +Convert documents to Markdown, HTML, JSON, or chunks. + +**Endpoint:** `POST /api/v1/convert` + +### Request + +```python theme={null} +import requests + +url = "https://www.datalab.to/api/v1/convert" +headers = {"X-API-Key": "YOUR_API_KEY"} + +with open("document.pdf", "rb") as f: + response = requests.post( + url, + files={"file": ("document.pdf", f, "application/pdf")}, + data={ + "output_format": "markdown", + "mode": "balanced", + }, + headers=headers + ) + +data = response.json() +check_url = data["request_check_url"] +``` + +### Parameters + +| Parameter | Type | Default | Description | +| ---------------------------- | ------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `file` | file | - | Document file (multipart upload) | +| `file_url` | string | - | URL to document (alternative to file upload) | +| `output_format` | string | `markdown` | Output format: `markdown`, `html`, `json`, `chunks` | +| `mode` | string | `fast` | Processing mode: `fast`, `balanced`, `accurate` | +| `max_pages` | int | - | Maximum pages to process | +| `page_range` | string | - | Specific pages (e.g., `"0-5,10"`, 0-indexed). For spreadsheets, filters by sheet index. | +| `paginate` | bool | `false` | Add page delimiters to output | +| `skip_cache` | bool | `false` | Skip cached results | +| `disable_image_extraction` | bool | `false` | Don't extract images | +| `disable_image_captions` | bool | `false` | Don't generate image captions | +| `save_checkpoint` | bool | `false` | Save checkpoint for reuse | +| `extras` | string | - | Comma-separated: `track_changes`, `chart_understanding`, `extract_links`, `table_row_bboxes`, `infographic`, `new_block_types` | +| `add_block_ids` | bool | `false` | Add block IDs to HTML for citations | +| `include_markdown_in_chunks` | bool | `false` | Include markdown content in chunks output | +| `token_efficient_markdown` | bool | `false` | Optimize markdown for LLM token efficiency | +| `fence_synthetic_captions` | bool | `false` | Wrap synthetic image captions in HTML comments | +| `additional_config` | string | - | JSON with extra config options | +| `webhook_url` | string | - | Override webhook URL for this request | + +### Processing Modes + +| Mode | Description | +| ---------- | --------------------------------------------------- | +| `fast` | Lowest latency, good for simple documents (default) | +| `balanced` | Balance of speed and accuracy | +| `accurate` | Highest accuracy, best for complex layouts | + +### Response + +Poll `request_check_url` until `status` is `complete`: + +```python theme={null} +import time + +while True: + response = requests.get(check_url, headers=headers) + result = response.json() + + if result["status"] == "complete": + break + time.sleep(2) + +print(result["markdown"]) +``` + +Response fields: + +| Field | Type | Description | +| --------------------- | ------ | ---------------------------------------- | +| `status` | string | `processing`, `complete`, or `failed` | +| `success` | bool | Whether conversion succeeded | +| `markdown` | string | Markdown output (if format is markdown) | +| `html` | string | HTML output (if format is html) | +| `json` | object | JSON output (if format is json) | +| `chunks` | object | Chunked output (if format is chunks) | +| `images` | object | Extracted images as `{filename: base64}` | +| `metadata` | object | Document metadata | +| `page_count` | int | Number of pages processed | +| `parse_quality_score` | float | Quality score (0-5) | +| `cost_breakdown` | object | Cost in cents | +| `error` | string | Error message if failed | + + + For structured data extraction, see the [Extract endpoint](#structured-extraction). For document segmentation, see the [Segment endpoint](#document-segmentation). + + +## Structured Extraction + +Extract structured data from documents using a JSON schema. + +**Endpoint:** `POST /api/v1/extract` + +### Request + +```python theme={null} +import requests +import json + +headers = {"X-API-Key": "YOUR_API_KEY"} + +schema = { + "invoice_number": {"type": "string", "description": "Invoice ID"}, + "total": {"type": "number", "description": "Total amount"}, + "line_items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "description": {"type": "string"}, + "amount": {"type": "number"} + } + } + } +} + +response = requests.post( + "https://www.datalab.to/api/v1/extract", + files={"file": ("invoice.pdf", open("invoice.pdf", "rb"), "application/pdf")}, + data={ + "page_schema": json.dumps(schema), + "mode": "balanced" + }, + headers=headers +) + +data = response.json() +check_url = data["request_check_url"] +``` + +### Parameters + +| Parameter | Type | Default | Description | +| ----------------- | ------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `file` | file | - | Document file (multipart upload) | +| `file_url` | string | - | URL to document (alternative to file upload) | +| `page_schema` | string | - | JSON schema defining the data to extract. Required unless `schema_id` is provided. | +| `schema_id` | string | - | ID of a [saved extraction schema](/docs/recipes/structured-extraction/saved-schemas) (e.g. `sch_k8Hx9mP2nQ4v`). Mutually exclusive with `page_schema`. | +| `schema_version` | int | - | Version of the saved schema to use. Only valid with `schema_id`; defaults to the latest version. | +| `checkpoint_id` | string | - | Checkpoint ID from a previous `/convert` call (with `save_checkpoint=true`). Skips re-parsing. | +| `mode` | string | `fast` | Processing mode: `fast`, `balanced`, `accurate` | +| `output_format` | string | `markdown` | Output format: `markdown`, `html`, `json`, `chunks` | +| `max_pages` | int | - | Maximum pages to process | +| `page_range` | string | - | Specific pages (e.g., `"0-5,10"`, 0-indexed). For spreadsheets, filters by sheet index. | +| `save_checkpoint` | bool | `false` | Save a checkpoint after processing for reuse with subsequent calls | +| `webhook_url` | string | - | Override webhook URL for this request | + +The extracted data is returned in `extraction_schema_json` in the poll response. + +See [Structured Extraction](/docs/recipes/structured-extraction/api-overview) for detailed examples. + +## Document Segmentation + +Segment documents into structured sections using a JSON schema. + +**Endpoint:** `POST /api/v1/segment` + +### Parameters + +| Parameter | Type | Default | Description | +| --------------------- | ------ | ------------ | ---------------------------------------------------------------------------------------------- | +| `file` | file | - | Document file (multipart upload) | +| `file_url` | string | - | URL to document (alternative to file upload) | +| `segmentation_schema` | string | **required** | JSON schema defining the segments to extract | +| `checkpoint_id` | string | - | Checkpoint ID from a previous `/convert` call (with `save_checkpoint=true`). Skips re-parsing. | +| `mode` | string | `fast` | Processing mode: `fast`, `balanced`, `accurate` | + +See [Document Segmentation](/docs/recipes/document-segmentation/auto-segmentation) for detailed examples. + +## Track Changes + +Extract tracked changes (insertions and deletions) from DOCX files. + +**Endpoint:** `POST /api/v1/track-changes` + +```python theme={null} +response = requests.post( + "https://www.datalab.to/api/v1/track-changes", + files={"file": ("document.docx", open("document.docx", "rb"), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")}, + headers=headers +) +``` + +See [Track Changes](/docs/recipes/extract-redlines-and-comments/track-changes-from-word-documents) for detailed examples. + +## Custom Processor + +This feature is currently in beta. The API may change. + +Execute custom AI-powered processors on documents. + +**Endpoint:** `POST /api/v1/custom-processor` + + + `POST /api/v1/custom-pipeline` is deprecated (sunset: September 30, 2026). Migrate to `POST /api/v1/custom-processor`. + + +### Parameters + +| Parameter | Type | Default | Description | +| --------------- | ------ | ------------ | --------------------------------------------------- | +| `file` | file | - | Document file (multipart upload) | +| `file_url` | string | - | URL to document | +| `pipeline_id` | string | **required** | Custom processor ID (`cp_XXXXX`) | +| `version` | int | - | Processor version to run (default: active version) | +| `run_eval` | bool | `false` | Run evaluation rules defined for the processor | +| `mode` | string | `fast` | Processing mode: `fast`, `balanced`, `accurate` | +| `output_format` | string | `markdown` | Output format: `markdown`, `html`, `json`, `chunks` | +| `webhook_url` | string | - | URL to POST when complete | + +## Form Filling + +Fill forms in PDFs and images. + +**Endpoint:** `POST /api/v1/fill` + +### Request + +```python theme={null} +import json + +field_data = { + "full_name": {"value": "John Doe", "description": "Full legal name"}, + "date": {"value": "2024-01-15", "description": "Today's date"}, + "signature": {"value": "John Doe", "description": "Signature field"} +} + +response = requests.post( + "https://www.datalab.to/api/v1/fill", + files={"file": ("form.pdf", open("form.pdf", "rb"), "application/pdf")}, + data={ + "field_data": json.dumps(field_data), + "confidence_threshold": "0.5" + }, + headers=headers +) +``` + +### Parameters + +| Parameter | Type | Default | Description | +| ---------------------- | ------ | ------- | ------------------------------------- | +| `file` | file | - | Form file (PDF or image) | +| `file_url` | string | - | URL to form | +| `field_data` | string | - | JSON mapping field names to values | +| `context` | string | - | Additional context for field matching | +| `confidence_threshold` | float | `0.5` | Minimum confidence for matching (0-1) | +| `page_range` | string | - | Specific pages to process | +| `skip_cache` | bool | `false` | Skip cached results | + +### Field Data Format + +```json theme={null} +{ + "field_key": { + "value": "The value to fill", + "description": "Description to help match the field" + } +} +``` + +### Response + +| Field | Type | Description | +| ------------------ | ------ | ------------------------------- | +| `status` | string | Processing status | +| `success` | bool | Whether filling succeeded | +| `output_format` | string | `pdf` or `png` | +| `output_base64` | string | Base64-encoded filled form | +| `fields_filled` | array | Successfully filled field names | +| `fields_not_found` | array | Unmatched field names | +| `page_count` | int | Pages processed | +| `cost_breakdown` | object | Cost details | + +See [Form Filling](/docs/recipes/form-filling/form-filling-api-overview) for more examples. + +## File Management + +Upload and manage files for use in pipelines. + +### Upload File + +**Step 1:** Request an upload URL + +```bash theme={null} +POST /api/v1/files/upload +Content-Type: application/json + +{ + "filename": "document.pdf", + "content_type": "application/pdf" +} +``` + +Response: + +```json theme={null} +{ + "file_id": 123, + "upload_url": "https://...", + "reference": "datalab://file-abc123" +} +``` + +**Step 2:** Upload directly to the presigned URL + +```bash theme={null} +PUT {upload_url} +Content-Type: application/pdf + + +``` + +**Step 3:** Confirm upload + +```bash theme={null} +GET /api/v1/files/{file_id}/confirm +``` + +### List Files + +```bash theme={null} +GET /api/v1/files?limit=50&offset=0 +``` + +### Get File Metadata + +```bash theme={null} +GET /api/v1/files/{file_id} +``` + +### Get Download URL + +```bash theme={null} +GET /api/v1/files/{file_id}/download?expires_in=3600 +``` + +### Delete File + +```bash theme={null} +DELETE /api/v1/files/{file_id} +``` + +See [File Management](/docs/recipes/file-management/file-upload-api) for detailed examples. + +## Thumbnails + +Generate page thumbnails from a previously processed document: + +```bash theme={null} +GET /api/v1/thumbnails/{lookup_key}?thumb_width=300&page_range=0-2 +``` + +| Parameter | Type | Default | Description | +| ------------- | ------ | --------- | ----------------------------------------- | +| `lookup_key` | string | Required | The request ID from a previous conversion | +| `thumb_width` | int | 300 | Thumbnail width in pixels | +| `page_range` | string | All pages | Pages to generate (e.g., `"0,2-4"`) | + +Response: + +```json theme={null} +{ + "success": true, + "thumbnails": ["base64_encoded_jpg_1", "base64_encoded_jpg_2"] +} +``` + +Thumbnails are returned as base64-encoded JPG images. + +## Create Document + +Generate DOCX files from markdown with track changes support: + +```bash theme={null} +POST /api/v1/create-document +Content-Type: application/json + +{ + "markdown": "# Title\n\nThis is newly added text.", + "output_format": "docx" +} +``` + +See [Create Document](/docs/recipes/create-document/create-document-api-overview) for detailed examples. + +## Webhooks + +Configure webhooks to receive notifications when processing completes instead of polling. + +Set a default webhook URL in your [account settings](https://www.datalab.to/settings), or override per-request with the `webhook_url` parameter. + +See [Webhooks](/platform/webhooks) for configuration details. + +## Rate Limits + +Default rate limits apply per API key. If you exceed limits, you'll receive a `429` response. + +See [Rate Limits](/docs/common/limits) for details and how to request higher limits. + +## Next Steps + + + + Use the Python SDK for a simpler integration with typed responses. + + + + Receive notifications when processing completes instead of polling. + + + + Understand file size limits, page limits, and rate limiting. + + + + Detailed guide to converting documents to Markdown, HTML, or JSON. + + + + +> ## Documentation Index +> Fetch the complete documentation index at: https://documentation.datalab.to/llms.txt +> Use this file to discover all available pages before exploring further. + +# [DEPRECATED] Marker + +> **DEPRECATED**: Use the new endpoints instead: +- `/convert` for document conversion +- `/extract` for structured data extraction +- `/segment` for document segmentation +- `/custom-pipeline` for custom pipeline execution + +This endpoint will be removed in a future version. + + + +## OpenAPI + +````yaml https://www.datalab.to/openapi.json post /api/v1/marker +openapi: 3.1.0 +info: + title: Datalab API + version: 0.0.1 +servers: + - url: https://www.datalab.to + description: Datalab API +security: [] +paths: + /api/v1/marker: + post: + summary: '[DEPRECATED] Marker' + description: |- + **DEPRECATED**: Use the new endpoints instead: + - `/convert` for document conversion + - `/extract` for structured data extraction + - `/segment` for document segmentation + - `/custom-pipeline` for custom pipeline execution + + This endpoint will be removed in a future version. + operationId: marker_api_v1_marker_post + parameters: + - name: wos-session + in: cookie + required: false + schema: + type: string + title: Wos-Session + - name: datalab_active_team + in: cookie + required: false + schema: + type: string + title: Datalab Active Team + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/Body_marker_api_v1_marker_post' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/InitialResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + deprecated: true + security: + - APIKeyHeader: [] +components: + schemas: + Body_marker_api_v1_marker_post: + properties: + file_url: + anyOf: + - type: string + - type: 'null' + title: File Url + description: >- + Optional file URL (http/https). If provided, the server will + download and process it. + mode: + type: string + title: Mode + description: >- + Which output mode to use. Valid values: 'fast' (lowest latency, + great for real-time use cases), 'balanced' (balanced accuracy and + latency, works well with most documents), 'accurate' (highest + accuracy and latency, good on the most complex documents). + default: fast + choices: + - fast + - balanced + - accurate + max_pages: + anyOf: + - type: integer + - type: 'null' + title: Max Pages + description: The maximum number of pages in the PDF to convert. + page_range: + anyOf: + - type: string + - type: 'null' + title: Page Range + description: >- + The page range to parse, comma separated like 0,5-10,20. This will + override max_pages if provided. Example: '0,2-4' will process pages + 0, 2, 3, and 4. + langs: + anyOf: + - type: string + - type: 'null' + title: Langs + description: >- + Note: This parameter has been deprecated, and will be ignored in the + current version. The languages to use if OCR is needed, comma + separated. Must be either the names or codes from + https://github.com/datalab-to/surya/blob/master/surya/languages.py. + Any other inputs will be ignored. + force_ocr: + type: boolean + title: Force Ocr + description: >- + [DEPRECATED] This parameter is deprecated and has no effect. OCR is + handled automatically by the parsing pipeline. + default: false + deprecated: true + format_lines: + type: boolean + title: Format Lines + description: >- + [DEPRECATED] This parameter is deprecated and has no effect. Line + formatting is handled automatically by the parsing pipeline. + default: false + deprecated: true + paginate: + type: boolean + title: Paginate + description: >- + Whether to paginate the output. Defaults to False. If set to True, + each page of the output will be separated by a horizontal rule that + contains the page number (2 newlines, {PAGE_NUMBER}, 48 - + characters, 2 newlines). + default: false + add_block_ids: + type: boolean + title: Add Block Ids + description: >- + Add data-block-id attributes to HTML elements for citation tracking. + Only applies when output_format includes 'html'. + default: false + include_markdown_in_chunks: + type: boolean + title: Include Markdown In Chunks + description: >- + Include markdown field in chunks and JSON output. When enabled, each + chunk will have a 'markdown' field with the markdown representation + of that block. Only applies when output_format includes 'json' or + 'chunks'. + default: false + strip_existing_ocr: + type: boolean + title: Strip Existing Ocr + description: >- + [DEPRECATED] This parameter is deprecated and has no effect. OCR + handling is managed automatically by the parsing pipeline. + default: false + deprecated: true + disable_image_extraction: + type: boolean + title: Disable Image Extraction + description: >- + Disable image extraction from the PDF. If use_llm is also set, then + images will be automatically captioned. Defaults to False. + default: false + disable_image_captions: + type: boolean + title: Disable Image Captions + description: >- + Disable synthetic image captions/descriptions in output. Images will + be rendered as plain img tags without alt text or the + img-description wrapper div. Defaults to False. + default: false + fence_synthetic_captions: + type: boolean + title: Fence Synthetic Captions + description: >- + Wrap synthetic image captions in markdown with HTML comment markers + ( ... ) for + easy identification/removal. Only applies to markdown output. + default: false + disable_ocr_math: + type: boolean + title: Disable Ocr Math + description: >- + [DEPRECATED] This parameter is deprecated and has no effect. Math + recognition is handled automatically by the parsing pipeline. + default: false + deprecated: true + use_llm: + type: boolean + title: Use Llm + description: >- + [DEPRECATED] This parameter is deprecated. Use the 'mode' parameter + instead: 'balanced' or 'accurate' modes. + default: false + deprecated: true + output_format: + anyOf: + - type: string + - type: 'null' + title: Output Format + description: >- + The output format for the text. Can be 'json', 'html', 'markdown', + or 'chunks'. Defaults to 'markdown'. You can comma separate + multiple formats, like `markdown,html`. + token_efficient_markdown: + type: boolean + title: Token Efficient Markdown + description: >- + When enabled, the markdown output uses token-efficient formatting + optimized for LLMs (compact tables with single-dash headers, + single-space list indents). + default: false + skip_cache: + type: boolean + title: Skip Cache + description: >- + Skip the cache and re-run the inference. Defaults to False. If set + to True, the cache will be skipped and the inference will be re-run. + default: false + save_checkpoint: + type: boolean + title: Save Checkpoint + description: >- + Save the checkpoint after processing. Defaults to False. This is + only useful if you're applying custom rules iteratively. + default: false + block_correction_prompt: + anyOf: + - type: string + - type: 'null' + title: Block Correction Prompt + description: >- + [DEPRECATED] This parameter is deprecated and has no effect. Block + correction is not currently supported. + deprecated: true + page_schema: + anyOf: + - type: string + - type: 'null' + title: Page Schema + description: >- + The schema to use for structured extraction (only used with + structured extraction endpoint). The ideal way to generate this is + to create a Pydantic schema, then convert to JSON with + .model_dump_json(). + segmentation_schema: + anyOf: + - type: string + - type: 'null' + title: Segmentation Schema + description: >- + The schema to use for document segmentation. Should be a JSON string + containing segment names and descriptions for identifying page + ranges of different document sections. + additional_config: + anyOf: + - type: string + - type: 'null' + title: Additional Config + description: >- + Additional configuration options as a JSON string. Only these keys + have effect: 'keep_pageheader_in_output' (bool), + 'keep_pagefooter_in_output' (bool), 'keep_spreadsheet_formatting' + (bool). + workflowstepdata_id: + anyOf: + - type: integer + - type: 'null' + title: Workflowstepdata Id + description: >- + Optional workflow step data ID. If provided, this request will be + associated with the specified workflow step execution. + extras: + anyOf: + - type: string + - type: 'null' + title: Extras + description: >- + Comma-separated list of extra features to enable. Currently + supports: 'track_changes', 'chart_understanding', + 'table_row_bboxes', 'extract_links', 'infographic', + 'new_block_types'. + webhook_url: + anyOf: + - type: string + - type: 'null' + title: Webhook Url + description: >- + Optional webhook URL to call when the request is complete. If + provided, this will override the webhook URL stored in your account + settings for this specific request. + processing_location: + anyOf: + - type: string + - type: 'null' + title: Processing Location + description: >- + Optional residency region override (e.g. us, eu). When provided, use + file_url or direct-upload; multipart uploads are rejected. When + omitted, the request uses the team's configured residency and + profile. + pipeline_id: + anyOf: + - type: string + - type: 'null' + title: Pipeline Id + description: >- + Optional custom pipeline ID. If provided, will execute the custom + pipeline configuration associated with this ID. + run_eval: + type: boolean + title: Run Eval + description: 'Internal: run evals over custom pipeline.' + default: false + model_override_settings: + anyOf: + - type: string + - type: 'null' + title: Model Override Settings + word_bboxes: + type: boolean + title: Word Bboxes + description: >- + When enabled, predict per-word bounding boxes for each page and + include them under page_info[id].metadata.words. Only supported by + the Chandra parse pipeline. + default: false + file: + anyOf: + - type: string + format: binary + - type: 'null' + title: File + description: >- + Input PDF, word document, powerpoint, or image file, uploaded as + multipart form data. Images must be png, jpg, or webp format. + type: object + title: Body_marker_api_v1_marker_post + InitialResponse: + properties: + success: + type: boolean + title: Success + description: Whether the request was successful. + default: true + error: + anyOf: + - type: string + - type: 'null' + title: Error + description: >- + If the request was not successful, this will contain an error + message. + request_id: + type: string + title: Request Id + description: >- + The ID of the request. This ID can be used to check the status of + the request. + request_check_url: + type: string + title: Request Check Url + description: The URL to check the status of the request and get results. + versions: + anyOf: + - additionalProperties: true + type: object + - type: string + - type: 'null' + title: Versions + description: A dictionary of the versions of the libraries used in the request. + type: object + required: + - request_id + - request_check_url + title: InitialResponse + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + type: object + required: + - loc + - msg + - type + title: ValidationError + securitySchemes: + APIKeyHeader: + type: apiKey + in: header + name: X-API-Key + +```` + +--- + +> ## Documentation Index +> Fetch the complete documentation index at: https://documentation.datalab.to/llms.txt +> Use this file to discover all available pages before exploring further. + +# [DEPRECATED] Table Recognition + +> [DEPRECATED] This endpoint is deprecated and will be removed in the future. +This endpoint is used to submit a request for table recognition. The detected tables will be returned, as well as their parsed structure. + + + +## OpenAPI + +````yaml https://www.datalab.to/openapi.json post /api/v1/table_rec +openapi: 3.1.0 +info: + title: Datalab API + version: 0.0.1 +servers: + - url: https://www.datalab.to + description: Datalab API +security: [] +paths: + /api/v1/table_rec: + post: + summary: '[DEPRECATED] Table Recognition' + description: >- + [DEPRECATED] This endpoint is deprecated and will be removed in the + future. + + This endpoint is used to submit a request for table recognition. The + detected tables will be returned, as well as their parsed structure. + operationId: table_rec_api_v1_table_rec_post + parameters: + - name: wos-session + in: cookie + required: false + schema: + type: string + title: Wos-Session + - name: datalab_active_team + in: cookie + required: false + schema: + type: string + title: Datalab Active Team + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/Body_table_rec_api_v1_table_rec_post' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/InitialResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + deprecated: true + security: + - APIKeyHeader: [] +components: + schemas: + Body_table_rec_api_v1_table_rec_post: + properties: + max_pages: + anyOf: + - type: integer + - type: 'null' + title: Max Pages + description: The maximum number of pages in the PDF to convert. + page_range: + anyOf: + - type: string + - type: 'null' + title: Page Range + description: >- + The page range to parse, comma separated like 0,5-10,20. This will + override max_pages if provided. Example: '0,2-4' will process pages + 0, 2, 3, and 4. + output_format: + anyOf: + - type: string + - type: 'null' + title: Output Format + description: >- + The output format for the table. Can be 'json', 'html', or + 'markdown'. Defaults to 'markdown'. + skip_cache: + type: boolean + title: Skip Cache + description: >- + Skip the cache and re-run the inference. Defaults to False. If set + to True, the cache will be skipped and the inference will be re-run. + default: false + processing_location: + anyOf: + - type: string + - type: 'null' + title: Processing Location + description: >- + Optional residency region override (e.g. us, eu). When provided, use + file_url or direct-upload; multipart uploads are rejected. When + omitted, the request uses the team's configured residency and + profile. + paginate: + type: boolean + title: Paginate + description: >- + Whether to paginate the output. Defaults to False. If set to True, + each page of the output will be separated by a horizontal rule that + contains the page number (2 newlines, {PAGE_NUMBER}, 48 - + characters, 2 newlines). + default: false + file: + anyOf: + - type: string + format: binary + - type: 'null' + title: File + description: >- + Input PDF, word document, powerpoint, or image file, uploaded as + multipart form data. Images must be png, jpg, or webp format. + type: object + title: Body_table_rec_api_v1_table_rec_post + InitialResponse: + properties: + success: + type: boolean + title: Success + description: Whether the request was successful. + default: true + error: + anyOf: + - type: string + - type: 'null' + title: Error + description: >- + If the request was not successful, this will contain an error + message. + request_id: + type: string + title: Request Id + description: >- + The ID of the request. This ID can be used to check the status of + the request. + request_check_url: + type: string + title: Request Check Url + description: The URL to check the status of the request and get results. + versions: + anyOf: + - additionalProperties: true + type: object + - type: string + - type: 'null' + title: Versions + description: A dictionary of the versions of the libraries used in the request. + type: object + required: + - request_id + - request_check_url + title: InitialResponse + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + type: object + required: + - loc + - msg + - type + title: ValidationError + securitySchemes: + APIKeyHeader: + type: apiKey + in: header + name: X-API-Key + +```` + +--- + +> ## Documentation Index +> Fetch the complete documentation index at: https://documentation.datalab.to/llms.txt +> Use this file to discover all available pages before exploring further. + +# [DEPRECATED] OCR + +> [DEPRECATED] This endpoint is deprecated and will be removed in the future. +This endpoint is used to submit a PDF or image for OCR. The OCR text lines will be returned, along with their bbox and polygon coordinates. + + + +## OpenAPI + +````yaml https://www.datalab.to/openapi.json post /api/v1/ocr +openapi: 3.1.0 +info: + title: Datalab API + version: 0.0.1 +servers: + - url: https://www.datalab.to + description: Datalab API +security: [] +paths: + /api/v1/ocr: + post: + summary: '[DEPRECATED] OCR' + description: >- + [DEPRECATED] This endpoint is deprecated and will be removed in the + future. + + This endpoint is used to submit a PDF or image for OCR. The OCR text + lines will be returned, along with their bbox and polygon coordinates. + operationId: ocr_api_v1_ocr_post + parameters: + - name: wos-session + in: cookie + required: false + schema: + type: string + title: Wos-Session + - name: datalab_active_team + in: cookie + required: false + schema: + type: string + title: Datalab Active Team + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/Body_ocr_api_v1_ocr_post' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/InitialResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + deprecated: true + security: + - APIKeyHeader: [] +components: + schemas: + Body_ocr_api_v1_ocr_post: + properties: + max_pages: + anyOf: + - type: integer + - type: 'null' + title: Max Pages + description: The maximum number of pages in the PDF to convert. + page_range: + anyOf: + - type: string + - type: 'null' + title: Page Range + description: >- + The page range to parse, comma separated like 0,5-10,20. This will + override max_pages if provided. Example: '0,2-4' will process pages + 0, 2, 3, and 4. + langs: + anyOf: + - type: string + - type: 'null' + title: Langs + description: >- + Note: This parameter has been deprecated, and is no longer used. The + languages to use for OCR, comma separated. Can be up to 4 + languages. Must be either the names or codes from + https://github.com/datalab-to/surya/blob/master/surya/languages.py. + Any other inputs will be ignored. Defaults to 'en' if not provided. + skip_cache: + type: boolean + title: Skip Cache + description: >- + Skip the cache and re-run the inference. Defaults to False. If set + to True, the cache will be skipped and the inference will be re-run. + default: false + processing_location: + anyOf: + - type: string + - type: 'null' + title: Processing Location + description: >- + Optional residency region override (e.g. us, eu). When provided, use + file_url or direct-upload; multipart uploads are rejected. When + omitted, the request uses the team's configured residency and + profile. + file: + anyOf: + - type: string + format: binary + - type: 'null' + title: File + description: >- + Input PDF, word document, powerpoint, or image file, uploaded as + multipart form data. Images must be png, jpg, or webp format. + type: object + title: Body_ocr_api_v1_ocr_post + InitialResponse: + properties: + success: + type: boolean + title: Success + description: Whether the request was successful. + default: true + error: + anyOf: + - type: string + - type: 'null' + title: Error + description: >- + If the request was not successful, this will contain an error + message. + request_id: + type: string + title: Request Id + description: >- + The ID of the request. This ID can be used to check the status of + the request. + request_check_url: + type: string + title: Request Check Url + description: The URL to check the status of the request and get results. + versions: + anyOf: + - additionalProperties: true + type: object + - type: string + - type: 'null' + title: Versions + description: A dictionary of the versions of the libraries used in the request. + type: object + required: + - request_id + - request_check_url + title: InitialResponse + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + type: object + required: + - loc + - msg + - type + title: ValidationError + securitySchemes: + APIKeyHeader: + type: apiKey + in: header + name: X-API-Key + +```` + +> ## Documentation Index +> Fetch the complete documentation index at: https://documentation.datalab.to/llms.txt +> Use this file to discover all available pages before exploring further. + +# Convert Document + +> Convert a PDF, image, or document to markdown, HTML, JSON, or chunks. Use save_checkpoint=true to save parsed state for later /extract or /segment calls. + + + +## OpenAPI + +````yaml https://www.datalab.to/openapi.json post /api/v1/convert +openapi: 3.1.0 +info: + title: Datalab API + version: 0.0.1 +servers: + - url: https://www.datalab.to + description: Datalab API +security: [] +paths: + /api/v1/convert: + post: + summary: Convert Document + description: >- + Convert a PDF, image, or document to markdown, HTML, JSON, or chunks. + Use save_checkpoint=true to save parsed state for later /extract or + /segment calls. + operationId: convert_api_v1_convert_post + parameters: + - name: wos-session + in: cookie + required: false + schema: + type: string + title: Wos-Session + - name: datalab_active_team + in: cookie + required: false + schema: + type: string + title: Datalab Active Team + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/Body_convert_api_v1_convert_post' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/InitialResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - APIKeyHeader: [] +components: + schemas: + Body_convert_api_v1_convert_post: + properties: + file_url: + anyOf: + - type: string + - type: 'null' + title: File Url + description: >- + Optional file URL (http/https). If provided, the server will + download and process it. + mode: + type: string + title: Mode + description: >- + Which output mode to use. Valid values: 'fast' (lowest latency), + 'balanced' (balanced accuracy and latency), 'accurate' (highest + accuracy). + default: fast + choices: + - fast + - balanced + - accurate + dashboard: + description: Processing mode balancing speed and accuracy. + max_pages: + anyOf: + - type: integer + - type: 'null' + title: Max Pages + description: The maximum number of pages in the document to convert. + page_range: + anyOf: + - type: string + - type: 'null' + title: Page Range + description: >- + The page range to convert, comma separated like 0,5-10,20. Overrides + max_pages if provided. + dashboard: + description: >- + Comma-separated page ranges to process, e.g. '0-2,4'. Leave empty + for all pages. + paginate: + type: boolean + title: Paginate + description: >- + Whether to paginate the output. Each page will be separated by a + horizontal rule with the page number. + default: false + dashboard: + description: Separate output by page with horizontal rules. + add_block_ids: + type: boolean + title: Add Block Ids + description: >- + Add data-block-id attributes to HTML elements for citation tracking. + Only applies when output_format includes 'html'. + default: false + include_markdown_in_chunks: + type: boolean + title: Include Markdown In Chunks + description: Include markdown field in chunks and JSON output. + default: false + disable_image_extraction: + type: boolean + title: Disable Image Extraction + description: Disable image extraction from the document. + default: false + dashboard: {} + disable_image_captions: + type: boolean + title: Disable Image Captions + description: Disable synthetic image captions/descriptions in output. + default: false + dashboard: {} + word_bboxes: + type: boolean + title: Word Bboxes + description: >- + When enabled, predict per-word bounding boxes for each page and + include them under page_info[id].metadata.words. Only supported by + the Chandra parse pipeline. + default: false + fence_synthetic_captions: + type: boolean + title: Fence Synthetic Captions + description: >- + Wrap synthetic image captions with HTML comment markers for easy + identification/removal. + default: false + output_format: + anyOf: + - type: string + - type: 'null' + title: Output Format + description: >- + The output format. Can be 'json', 'html', 'markdown', or 'chunks'. + Defaults to 'markdown'. Comma separate multiple formats. + dashboard: + choices: + - markdown + - html + - json + - chunks + description: Output format for the converted document. + type: select + token_efficient_markdown: + type: boolean + title: Token Efficient Markdown + description: >- + Optimize markdown for LLM token usage (compact tables, single-space + indents). + default: false + skip_cache: + type: boolean + title: Skip Cache + description: Skip the cache and re-run the conversion. + default: false + dashboard: + description: Skip cache and re-run processing. + save_checkpoint: + type: boolean + title: Save Checkpoint + description: >- + Save a checkpoint after conversion. The checkpoint_id in the + response can be used with /extract or /segment to skip re-parsing. + default: false + dashboard: + description: Save a checkpoint for later /extract or /segment calls. + additional_config: + anyOf: + - type: string + - type: 'null' + title: Additional Config + description: >- + Additional configuration as a JSON string. Supported keys: + 'keep_pageheader_in_output', 'keep_pagefooter_in_output', + 'keep_spreadsheet_formatting'. + workflowstepdata_id: + anyOf: + - type: integer + - type: 'null' + title: Workflowstepdata Id + description: Optional workflow step data ID to associate with this request. + extras: + anyOf: + - type: string + - type: 'null' + title: Extras + description: >- + Comma-separated list of extra features: 'track_changes', + 'chart_understanding', 'table_row_bboxes', 'extract_links', + 'infographic', 'new_block_types'. + dashboard: + description: >- + Comma-separated feature flags: chart_understanding, infographic, + extract_links, table_row_bboxes, new_block_types. + webhook_url: + anyOf: + - type: string + - type: 'null' + title: Webhook Url + description: Optional webhook URL to call when the request is complete. + processing_location: + anyOf: + - type: string + - type: 'null' + title: Processing Location + description: >- + Optional residency region override (e.g. us, eu). When provided, use + file_url or direct-upload; multipart uploads are rejected. When + omitted, the request uses the team's configured residency and + profile. + eval_rubric_id: + anyOf: + - type: integer + - type: 'null' + title: Eval Rubric Id + description: Optional eval rubric ID to run evaluation after conversion. + force_new: + type: boolean + title: Force New + description: 'Internal: force Modal backend.' + default: false + model_override_settings: + anyOf: + - type: string + - type: 'null' + title: Model Override Settings + file: + anyOf: + - type: string + format: binary + - type: 'null' + title: File + description: >- + Input PDF, word document, powerpoint, or image file, uploaded as + multipart form data. Images must be png, jpg, or webp format. + type: object + title: Body_convert_api_v1_convert_post + InitialResponse: + properties: + success: + type: boolean + title: Success + description: Whether the request was successful. + default: true + error: + anyOf: + - type: string + - type: 'null' + title: Error + description: >- + If the request was not successful, this will contain an error + message. + request_id: + type: string + title: Request Id + description: >- + The ID of the request. This ID can be used to check the status of + the request. + request_check_url: + type: string + title: Request Check Url + description: The URL to check the status of the request and get results. + versions: + anyOf: + - additionalProperties: true + type: object + - type: string + - type: 'null' + title: Versions + description: A dictionary of the versions of the libraries used in the request. + type: object + required: + - request_id + - request_check_url + title: InitialResponse + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + type: object + required: + - loc + - msg + - type + title: ValidationError + securitySchemes: + APIKeyHeader: + type: apiKey + in: header + name: X-API-Key + +```` + +--- + + +> ## Documentation Index +> Fetch the complete documentation index at: https://documentation.datalab.to/llms.txt +> Use this file to discover all available pages before exploring further. + +# Marker Result Check + +> Poll this endpoint to check status of Marker request and retrieve final results + + + +## OpenAPI + +````yaml https://www.datalab.to/openapi.json get /api/v1/marker/{request_id} +openapi: 3.1.0 +info: + title: Datalab API + version: 0.0.1 +servers: + - url: https://www.datalab.to + description: Datalab API +security: [] +paths: + /api/v1/marker/{request_id}: + get: + summary: Marker Result Check + description: >- + Poll this endpoint to check status of Marker request and retrieve final + results + operationId: result_response_api_v1_marker__request_id__get + parameters: + - name: request_id + in: path + required: true + schema: + type: string + title: Request Id + - name: wos-session + in: cookie + required: false + schema: + type: string + title: Wos-Session + - name: datalab_active_team + in: cookie + required: false + schema: + type: string + title: Datalab Active Team + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/MarkerFinalResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - APIKeyHeader: [] +components: + schemas: + MarkerFinalResponse: + properties: + status: + type: string + title: Status + description: >- + The status of the request. Should be 'complete' when the request is + done. + result_url: + anyOf: + - type: string + - type: 'null' + title: Result Url + description: >- + Signed URL for downloading the completed result JSON when direct + result download is required. + expires_in: + anyOf: + - type: integer + - type: 'null' + title: Expires In + description: Number of seconds until result_url expires. + output_format: + type: string + title: Output Format + description: The format of the output. 'markdown' or 'json'. + chunks: + anyOf: + - additionalProperties: true + type: object + - type: string + - type: 'null' + title: Chunks + description: >- + The output in chunks format. The top-level key 'blocks' contains a + list of blocks from the document with metadata. + json: + anyOf: + - additionalProperties: true + type: object + - type: string + - type: 'null' + title: Json + description: The JSON representation of the PDF if the output format is 'json'. + markdown: + anyOf: + - type: string + - type: 'null' + title: Markdown + description: >- + The markdown representation of the PDF if the output format is + 'markdown'. + html: + anyOf: + - type: string + - type: 'null' + title: Html + description: The HTML representation of the PDF if the output format is 'html'. + extraction_schema_json: + anyOf: + - type: string + - type: 'null' + title: Extraction Schema Json + description: >- + The output of a marker extraction request containing the filled in + extraction schema. + extraction_score_average: + anyOf: + - type: number + - type: 'null' + title: Extraction Score Average + description: >- + Average confidence score (1-5) across all extracted fields, when + scoring is applied. + extraction_mode: + anyOf: + - type: string + - type: 'null' + title: Extraction Mode + description: 'The extraction mode used for this request: ''fast'' or ''balanced''.' + segmentation_results: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Segmentation Results + description: >- + Results of document segmentation showing page ranges for each + identified segment. Contains segment names, page ranges, and + confidence levels (high/medium/low). + images: + anyOf: + - additionalProperties: + type: string + type: object + - type: 'null' + title: Images + description: >- + A dictionary of the images in the PDF, where the key is the filename + for the image, and the value is the base64 encoded image. Images + should be stored in the same directory as the PDF. + metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Metadata + description: A dictionary of metadata about the PDF and the conversion process. + success: + anyOf: + - type: boolean + - type: 'null' + title: Success + description: Whether the conversion was successful. + error: + anyOf: + - type: string + - type: 'null' + title: Error + description: >- + If the conversion was not successful, this will contain an error + message. + parse_quality_score: + anyOf: + - type: number + - type: 'null' + title: Parse Quality Score + description: The parse quality score of the output, if available. + page_count: + anyOf: + - type: integer + - type: 'null' + title: Page Count + description: The number of pages that were converted. + total_cost: + anyOf: + - type: integer + - type: 'null' + title: Total Cost + description: The total cost of the conversion. + deprecated: true + cost_breakdown: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Cost Breakdown + description: >- + A dictionary of the cost breakdown of this request. Includes the + list cost without discounts and final cost to clients after any + discounts (e.g. for opting into model training). + runtime: + anyOf: + - type: number + - type: 'null' + title: Runtime + description: The runtime of the conversion. + checkpoint_id: + anyOf: + - type: string + - type: 'null' + title: Checkpoint Id + description: >- + The ID of the checkpoint that was created for this conversion. This + can be used to retrieve the checkpoint later. + versions: + anyOf: + - additionalProperties: true + type: object + - type: string + - type: 'null' + title: Versions + description: A dictionary of the versions of the libraries used in the request. + evaluation: + anyOf: + - $ref: '#/components/schemas/EvaluationResults' + - type: 'null' + description: >- + Evaluation results, when available, for requests that run + evaluation. Contains per-rule scores for validating custom pipeline + behavior. + type: object + required: + - status + title: MarkerFinalResponse + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + EvaluationResults: + properties: + eval_definition_name: + type: string + title: Eval Definition Name + description: Name of the evaluation definition + evaluations: + items: + $ref: '#/components/schemas/EvaluationRuleSummary' + type: array + title: Evaluations + description: Per-rule evaluation summaries + total_items_evaluated: + type: integer + title: Total Items Evaluated + description: Total number of items evaluated across all rules + type: object + required: + - eval_definition_name + - evaluations + - total_items_evaluated + title: EvaluationResults + description: Container for evaluation results. + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + type: object + required: + - loc + - msg + - type + title: ValidationError + EvaluationRuleSummary: + properties: + name: + type: string + title: Name + description: Name of the evaluation rule + type: + type: string + title: Type + description: 'Type of evaluation: block, page, or document' + rule_score: + type: number + title: Rule Score + description: Aggregated score for this rule (0-5) + items_evaluated: + type: integer + title: Items Evaluated + description: Number of items evaluated + individual_results: + items: + additionalProperties: true + type: object + type: array + title: Individual Results + description: >- + Bottom-k lowest scoring individual results with score, feedback, + block_id, page_id, block_type + type: object + required: + - name + - type + - rule_score + - items_evaluated + title: EvaluationRuleSummary + description: Summary of a single evaluation rule result. + securitySchemes: + APIKeyHeader: + type: apiKey + in: header + name: X-API-Key + +```` + +> ## Documentation Index +> Fetch the complete documentation index at: https://documentation.datalab.to/llms.txt +> Use this file to discover all available pages before exploring further. + +# OCR Result Check + +> Poll this endpoint to check status of an OCR request and retrieve final results + + + +## OpenAPI + +````yaml https://www.datalab.to/openapi.json get /api/v1/ocr/{request_id} +openapi: 3.1.0 +info: + title: Datalab API + version: 0.0.1 +servers: + - url: https://www.datalab.to + description: Datalab API +security: [] +paths: + /api/v1/ocr/{request_id}: + get: + summary: OCR Result Check + description: >- + Poll this endpoint to check status of an OCR request and retrieve final + results + operationId: result_response_api_v1_ocr__request_id__get + parameters: + - name: request_id + in: path + required: true + schema: + type: string + title: Request Id + - name: wos-session + in: cookie + required: false + schema: + type: string + title: Wos-Session + - name: datalab_active_team + in: cookie + required: false + schema: + type: string + title: Datalab Active Team + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/OCRFinalResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - APIKeyHeader: [] +components: + schemas: + OCRFinalResponse: + properties: + status: + type: string + title: Status + description: >- + The status of the request. Should be 'complete' when the request is + done. + result_url: + anyOf: + - type: string + - type: 'null' + title: Result Url + description: >- + Signed URL for downloading the completed result JSON when direct + result download is required. + expires_in: + anyOf: + - type: integer + - type: 'null' + title: Expires In + description: Number of seconds until result_url expires. + pages: + anyOf: + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: Pages + description: >- + The detected OCR text on each page. Each page will have the bboxes + and detected text within each line. + success: + anyOf: + - type: boolean + - type: 'null' + title: Success + description: Whether the conversion was successful. + error: + anyOf: + - type: string + - type: 'null' + title: Error + description: >- + If the conversion was not successful, this will contain an error + message. + page_count: + anyOf: + - type: integer + - type: 'null' + title: Page Count + description: The number of pages that had ocr run on them. + total_cost: + anyOf: + - type: integer + - type: 'null' + title: Total Cost + description: The total cost of the conversion. + deprecated: true + cost_breakdown: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Cost Breakdown + description: >- + A dictionary of the cost breakdown of this request. Includes the + list cost without discounts and final cost to clients after any + discounts (e.g. for opting into model training). + versions: + anyOf: + - additionalProperties: true + type: object + - type: string + - type: 'null' + title: Versions + description: A dictionary of the versions of the libraries used in the request. + type: object + required: + - status + title: OCRFinalResponse + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + type: object + required: + - loc + - msg + - type + title: ValidationError + securitySchemes: + APIKeyHeader: + type: apiKey + in: header + name: X-API-Key + +```` diff --git a/request_store.py b/request_store.py new file mode 100644 index 0000000..a7584b0 --- /dev/null +++ b/request_store.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import json +import os +import time +import threading +from pathlib import Path + +DEFAULT_RESULTS_DIR = os.environ.get("RESULTS_DIR", "/app/conversion_results") +DEFAULT_TTL = 3600 # 1 hour (matches Datalab) + +_lock = threading.Lock() + + +def _req_path(request_id: str) -> Path: + return Path(DEFAULT_RESULTS_DIR) / request_id + + +def create_request(request_id: str, metadata: dict | None = None) -> dict: + d = _req_path(request_id) + d.mkdir(parents=True, exist_ok=True) + entry = { + "request_id": request_id, + "status": "processing", + "success": None, + "created_at": time.time(), + "metadata": metadata or {}, + } + with _lock: + (d / "status.json").write_text(json.dumps(entry)) + return entry + + +def get_request(request_id: str) -> dict | None: + p = _req_path(request_id) / "status.json" + if not p.exists(): + return None + with _lock: + return json.loads(p.read_text()) + + +def update_request(request_id: str, **kwargs) -> dict | None: + d = _req_path(request_id) + p = d / "status.json" + if not p.exists(): + return None + with _lock: + entry = json.loads(p.read_text()) + entry.update(kwargs) + entry["updated_at"] = time.time() + p.write_text(json.dumps(entry)) + return entry + + +def complete_request(request_id: str, result: dict): + entry = update_request(request_id, status="complete", success=True) + if entry: + (Path(DEFAULT_RESULTS_DIR) / request_id / "result.json").write_text( + json.dumps(result) + ) + + +def fail_request(request_id: str, error: str): + update_request(request_id, status="complete", success=False, error=error) + + +def get_result(request_id: str) -> dict | None: + p = _req_path(request_id) / "result.json" + if not p.exists(): + return None + return json.loads(p.read_text()) + + +def _cleanup_loop(interval: int = 300, ttl: int = DEFAULT_TTL): + while True: + time.sleep(interval) + now = time.time() + try: + results_dir = Path(DEFAULT_RESULTS_DIR) + if not results_dir.exists(): + continue + for child in results_dir.iterdir(): + if child.is_dir(): + status_p = child / "status.json" + if status_p.exists(): + entry = json.loads(status_p.read_text()) + if now - entry.get("created_at", 0) > ttl: + import shutil + shutil.rmtree(str(child)) + except Exception: + pass + + +def start_cleanup_thread(interval: int = 300, ttl: int = DEFAULT_TTL): + t = threading.Thread(target=_cleanup_loop, args=(interval, ttl), daemon=True) + t.start() + + +# ── Job queue for background processing (shared across workers via disk, but +# each worker has its own in-memory queue for picking up jobs) ────── +_job_queue: list[tuple[str, str, bytes, str, dict]] = [] +_job_cond = threading.Condition() + + +def submit_job(endpoint: str, raw_bytes: bytes, filename: str, opts: dict) -> str: + import uuid + request_id = str(uuid.uuid4()) + create_request(request_id, {"endpoint": endpoint, "filename": filename}) + with _job_cond: + _job_queue.append((request_id, endpoint, raw_bytes, filename, opts)) + _job_cond.notify() + return request_id + + +def wait_for_job(timeout: float | None = None) -> tuple | None: + with _job_cond: + if not _job_queue: + _job_cond.wait(timeout=timeout) + if not _job_queue: + return None + return _job_queue.pop(0)