Skip to content

Technical — OCR V3

RAGA's PDF-to-Markdown OCR service, built on Python + FastAPI. Unlike the previous generation, this service (ocr-proxy) no longer runs its own OCR engine — it is an orchestration layer that proxies PDF files (upload or URL) to an external OCR processing engine (cloud or local), then takes over storing results in MinIO and Elasticsearch, and automatically triggers Summarize once OCR finishes. The system uses an in-memory queue with a single worker thread so large requests don't block incoming ones. Used by api-tarantula to process documents uploaded by users.

Repository

KeyValue
Git Remotehttps://git.tlab.co.id/tarantula/tarantula-v2/service/ocr-proxy.git
Active Branchmain
Staging Deploy Branchstaging (CI/CD auto-builds & deploys to the staging environment)
bash
git clone https://git.tlab.co.id/tarantula/tarantula-v2/service/ocr-proxy.git
cd ocr-proxy

Note: this repository is named ocr-proxy and lives under the tarantula-v2 namespace — unlike the previous generation (ocr-v3 under tarantula-v3), which ran the OCR engine (DotsOCRParser) directly inside the service. In the current architecture, the actual OCR work is delegated to an external engine via CLOUD_PROCESSING_ENDPOINT/LOCAL_PROCESSING_ENDPOINT.

Tech Stack

LayerTechnology
RuntimePython 3.10 (Dockerfile) / 3.11 (Dockerfile.stag)
FrameworkFastAPI + uvicorn
OCR ProcessingProxied to an external OCR engine (cloud/local) over HTTP — no internal engine
Object StorageMinIO (minio SDK)
Search / IndexElasticsearch (elasticsearch==8.15.0)
Summarize IntegrationHTTP client (requests + retry adapter) to an external Summarize service
File ConversionLibreOffice / unoconv (office → PDF), Pillow (image → PDF), pypdf (PDF merge)
Secret Managementinfisical_sdk (Python)
Loggingloguru
Testingpytest
ContainerizationDocker (python:3.10/3.11-bookworm + poppler-utils, libreoffice, unoconv)

Folder Structure

ocr-proxy/
├── main.py                       # FastAPI app, in-memory queue, worker thread, all endpoints
├── converter_utils.py            # FileConverter: converts office/image files to PDF (LibreOffice/unoconv/Python fallback)
├── merge_utils.py                # Downloads a URL to a temp file & merges multiple PDFs into one (pypdf)
├── config/
│   └── infisical_config.py       # Config loader via Infisical (get_secret, falls back to os.getenv)
├── services/
│   ├── elasticsearch_service.py  # Indexes & queries OCR + summarize results in Elasticsearch
│   ├── minio_service.py          # Uploads the resulting PDF (upload/merge) to MinIO
│   └── summarize_service.py      # Calls the external Summarize service + stores results in Elasticsearch
├── helper/
│   └── save_chunks_to_txt.py     # Saves OCR results (per chunk) to a txt/JSON file as Summarize input
├── tests/                        # Unit tests (pytest) — task status, Elasticsearch service, summarize service
├── .env.example
├── requirements.txt
├── Dockerfile                    # Production image
└── Dockerfile.stag               # Staging image

Environment Variables

.env File / Container Env (Infisical bootstrap)

bash
INFISICAL_PROJECT_ID=
INFISICAL_ENVIRONMENT=
INFISICAL_SECRET_PATH=
INFISICAL_CLIENT_ID=
INFISICAL_CLIENT_SECRET=
INFISICAL_HOST=
VariableDefaultDescription
INFISICAL_PROJECT_IDInfisical project ID (required)
INFISICAL_ENVIRONMENTstagingTarget environment
INFISICAL_SECRET_PATH/ocrproxySecrets path in Infisical
INFISICAL_CLIENT_IDUniversal Auth Client ID (required)
INFISICAL_CLIENT_SECRETUniversal Auth Client Secret (required)
INFISICAL_HOSThttp://10.1.102.15:8002Infisical server URL

Secrets via Infisical

OCR Processing Endpoint (Cloud / Local)

VariableDefaultDescription
CLOUD_PROCESSING_ENDPOINThttp://10.1.102.14:8010/process-pdf/External OCR engine endpoint for processing=cloud
LOCAL_PROCESSING_ENDPOINThttp://localhost:9000/v1/processExternal OCR engine endpoint for processing=local
CLOUD_STATUS_PROCESSING_ENDPOINThttp://localhost:9000/v1/processTask status endpoint on the cloud engine (must be set explicitly in production)
LOCAL_STATUS_PROCESSING_ENDPOINThttp://localhost:9000/v1/processTask status endpoint on the local engine
MAX_WAIT_OCR300Number of upstream status-poll iterations (2s/iteration ⇒ ~10 minutes before 504 Task timeout)

Elasticsearch

VariableDefaultDescription
ES_INDEX_NAMEpdf-parsing-resultsIndex that stores OCR result chunks
URL_ELASTICElasticsearch server URL
USER_ELASTICElasticsearch username
PASS_ELASTICElasticsearch password
VERIFY_ELASTICFalseVerify TLS certificates

MinIO

VariableDefaultDescription
MINIO_ENDPOINTMinIO host
MINIO_PORT9000MinIO port
MINIO_ACCESS_KEYMinIO access key
MINIO_SECRET_KEYMinIO secret key
MINIO_SSLfalseUse SSL (true/false)
MINIO_BUCKETTarget bucket for PDF uploads
MINIO_DOMAINPublic base URL used to build uploaded object URLs

Summarize

VariableDefaultDescription
SUMMARIZE_URLhttp://10.1.102.14:8014Summarize service URL
INDEX_DOCUMENT_SUMMARIZEsummarize_documentElasticsearch index for full document summaries
INDEX_DOCUMENT_SUMMARIZE_CHUNKsummarize_document_chunkElasticsearch index for per-chunk summaries
SUMMARIZE_CONNECT_TIMEOUT10 secondsConnection timeout to Summarize
SUMMARIZE_READ_TIMEOUT600 secondsRead timeout for the Summarize response

Endpoints

MethodPathDescription
POST/api/v1/pdfsUpload a file or submit a PDF URL for OCR (async, main entry point)
GET/api/v1/tasks/{task_id}/statusChecks a task's status & progress
POST/api/v1/pdfs/mergeMerges several files/URLs into one PDF, then OCRs it as a single task
GET/api/v1/pdfs/{id}Retrieves stored OCR results from Elasticsearch (paginated, by pdf_id)
POST/api/v1/pdfs/bulkSubmits many URLs at once — each becomes a separate task
POST/api/v1/pdfs/bulk/uploadUploads many files at once — each becomes a separate task
POST/api/v1/documentsIngests an arbitrary document directly into the Elasticsearch index
GET/api/v1/healthHealth check (Elasticsearch connectivity + OCR endpoint configuration status)

POST /api/v1/pdfs

Content-Type: multipart/form-data

file                (UploadFile, optional)  — PDF/image/office file; one of file/url_file is required
url_file             (str query, optional)   — PDF file URL
task_id              (str query, optional)   — custom task ID
metadata_document    (Form, optional)        — JSON metadata (string in multipart, or a raw JSON body when Content-Type: application/json)
type_ocr             (str query, default "ekstrak_only") — "ekstrak_only" | "representasi"
representasi_count   (int query, default 3)  — max image count for representation mode
processing           (str query, default "cloud") — "cloud" | "local", selects the target OCR engine

Note: lang, parse_method, formula_enable, and table_enable can no longer be set via the requestocr-proxy hardcodes these (lang="en", parse_method="auto", formula_enable/table_enable=true) before forwarding to the upstream OCR engine.

Response (immediate, task still pending):

json
{
  "task_id": "task-1751234567",
  "status": "pending",
  "processing_type": "cloud"
}

GET /api/v1/tasks/{task_id}/status

json
{
  "task_id": "task-1751234567",
  "status": "processing",
  "stage": "ocr_polling",
  "progress_pct": 45,
  "stage_detail": "OCR engine processing document",
  "created_at": "2026-06-30T10:00:00",
  "started_at": "2026-06-30T10:00:02"
}

status: pendingprocessingdone / failed. On failed, the response includes error and error_code.

Queue & Worker Architecture

Unlike the previous generation (parallel workers + asyncio.Queue), ocr-proxy uses a single worker thread reading from an in-memory queue.Queue (unbounded) — tasks are processed serially, not in parallel. Each task's status is kept in an in-memory OrderedDict (task_status, capped at 1,000 entries, oldest entries auto-evicted).

The task is marked done as soon as the summarize job is submitted to the ThreadPoolExecutor — summarization runs asynchronously in the background and does not block completion of the OCR task.

Progress Stages (stage / progress_pct)

progress_pctstageDescription
5%queuedTask picked up from the queue
10%uploadingSending the file to the OCR engine
20–80%ocr_pollingWaiting & polling the upstream OCR engine's status
85%downloading_resultDownloading the OCR result from upstream
86%uploading_pdfUploading the PDF to MinIO (only if the task has a local file, not a pure url_file)
88%indexingSaving results to Elasticsearch
95%summarizingTriggering the document summary
100%doneComplete

File Conversion & Merging

  • converter_utils.FileConverter — converts office files (.docx, .doc, .pptx, .ppt, .xlsx, .xls, .csv) and images (.jpg, .jpeg, .png, .bmp, .gif, .tiff, .webp) to PDF. For office files: tries LibreOffice (soffice --headless) → unoconv → a pure-Python fallback (.docx only, using python-docx + reportlab).
  • merge_utils.merge_pdfs_to_tempfile — merges multiple PDFs (via pypdf.PdfWriter) into one file, used by POST /api/v1/pdfs/merge. Non-PDF files/URLs are converted first via FileConverter before merging.
  • merge_utils.download_to_tempfile — downloads a file from a URL to a temp file (used by /pdfs/merge for url_files input).

Storage & Indexing

MinIO

Only runs for tasks with a local file (from an upload or merge — not a pure url_file task with no upload). The PDF is uploaded to MINIO_BUCKET under the object name ocr-documents/{task_id}.pdf, producing a public URL from MINIO_DOMAIN that is then stored as output_pdf_file in the Elasticsearch document.

Elasticsearch

ElasticsearchService.process_and_insert() splits the OCR result's content_list and md_content_list into groups of 5 pages (chunk_size=5), then indexes each chunk as a separate document (id: {task_id}_chunk_{n}) into the ES_INDEX_NAME index (default pdf-parsing-results), including metadata_document if one was sent with the request.

Stored data structure (example GET /api/v1/pdfs/{id}?page=1&size=1):

json
{
  "page": 1,
  "size": 1,
  "total_documents": 3,
  "total_pages": 3,
  "documents": [
    {
      "id": "task-123_chunk_1",
      "chunk_number": 1,
      "pdf_name": "document",
      "pdf_id": "task-123",
      "parse_method": "auto",
      "uploaded_at": "2026-06-30T10:05:23",
      "content_list": [{ "page": 1, "type": "text", "content": "..." }],
      "md_content_list": [{ "page": 1, "markdown": "# Title\n\n..." }],
      "output_path": "2026/06",
      "output_pdf_file": "https://minio-domain/bucket/ocr-documents/task-123.pdf",
      "metadata_document": { "...": "..." }
    }
  ]
}

ElasticsearchService also exposes ingest_data() / insert_data() / bulk_insert() as generic helpers — also used by SummarizeService to write summary results.

Summarize Integration

After the OCR result is indexed, OCRTextSaver saves all chunks to a ./outputs/summarize_{task_id}.txt file (JSON format). That file is sent (multipart, field file) to {SUMMARIZE_URL}/summarize with chunk_size=10000, target_context=128000, enable_ner=true. The call retries automatically (3x, backoff) on 500/502/503/504.

Results from Summarize are stored in Elasticsearch:

ResultIndexDescription
final_summaryINDEX_DOCUMENT_SUMMARIZEFull document summary, document id = task_id
summarize_chunk (list)INDEX_DOCUMENT_SUMMARIZE_CHUNKPer-chunk summary, id = {task_id}_{chunk_index}, bulk-inserted

Summarization runs on a background thread (ThreadPoolExecutor, separate from the OCR worker) — a summarize failure is logged but does not change the OCR task's status, which is already done. Both the input txt file and the summarize output JSON file are removed automatically once the process finishes.

OCR Modes (type_ocr)

ModeDescription
ekstrak_only (default)Text extraction only, no image analysis
representasiText + image descriptions; image count capped by representasi_count

This value is only forwarded as a parameter to the upstream OCR engine (CLOUD_PROCESSING_ENDPOINT/LOCAL_PROCESSING_ENDPOINT) — the actual extraction and image-description logic runs on that engine, not in ocr-proxy.

Health Check

GET /api/v1/health checks Elasticsearch's cluster.health() and reports whether CLOUD_PROCESSING_ENDPOINT/LOCAL_PROCESSING_ENDPOINT are configured. Returns 503 if the Elasticsearch status is red/error, 200 otherwise. MinIO, Summarize, and the upstream OCR engine are not covered by this check.

Build & Run

bash
# Install dependencies
pip install -r requirements.txt

# Run development
uvicorn main:app --host 0.0.0.0 --port 8000 --reload

# Docker build & run
docker build -t ocr-proxy .
docker run -p 8000:8000 \
  -e INFISICAL_PROJECT_ID=... \
  -e INFISICAL_ENVIRONMENT=staging \
  -e INFISICAL_SECRET_PATH=/ocrproxy \
  -e INFISICAL_CLIENT_ID=... \
  -e INFISICAL_CLIENT_SECRET=... \
  -e INFISICAL_HOST=http://10.1.102.15:8002 \
  ocr-proxy