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
| Key | Value |
|---|---|
| Git Remote | https://git.tlab.co.id/tarantula/tarantula-v2/service/ocr-proxy.git |
| Active Branch | main |
| Staging Deploy Branch | staging (CI/CD auto-builds & deploys to the staging environment) |
git clone https://git.tlab.co.id/tarantula/tarantula-v2/service/ocr-proxy.git
cd ocr-proxyNote: this repository is named
ocr-proxyand lives under thetarantula-v2namespace — unlike the previous generation (ocr-v3undertarantula-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 viaCLOUD_PROCESSING_ENDPOINT/LOCAL_PROCESSING_ENDPOINT.
Tech Stack
| Layer | Technology |
|---|---|
| Runtime | Python 3.10 (Dockerfile) / 3.11 (Dockerfile.stag) |
| Framework | FastAPI + uvicorn |
| OCR Processing | Proxied to an external OCR engine (cloud/local) over HTTP — no internal engine |
| Object Storage | MinIO (minio SDK) |
| Search / Index | Elasticsearch (elasticsearch==8.15.0) |
| Summarize Integration | HTTP client (requests + retry adapter) to an external Summarize service |
| File Conversion | LibreOffice / unoconv (office → PDF), Pillow (image → PDF), pypdf (PDF merge) |
| Secret Management | infisical_sdk (Python) |
| Logging | loguru |
| Testing | pytest |
| Containerization | Docker (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 imageEnvironment Variables
.env File / Container Env (Infisical bootstrap)
INFISICAL_PROJECT_ID=
INFISICAL_ENVIRONMENT=
INFISICAL_SECRET_PATH=
INFISICAL_CLIENT_ID=
INFISICAL_CLIENT_SECRET=
INFISICAL_HOST=| Variable | Default | Description |
|---|---|---|
INFISICAL_PROJECT_ID | — | Infisical project ID (required) |
INFISICAL_ENVIRONMENT | staging | Target environment |
INFISICAL_SECRET_PATH | /ocrproxy | Secrets path in Infisical |
INFISICAL_CLIENT_ID | — | Universal Auth Client ID (required) |
INFISICAL_CLIENT_SECRET | — | Universal Auth Client Secret (required) |
INFISICAL_HOST | http://10.1.102.15:8002 | Infisical server URL |
Secrets via Infisical
OCR Processing Endpoint (Cloud / Local)
| Variable | Default | Description |
|---|---|---|
CLOUD_PROCESSING_ENDPOINT | http://10.1.102.14:8010/process-pdf/ | External OCR engine endpoint for processing=cloud |
LOCAL_PROCESSING_ENDPOINT | http://localhost:9000/v1/process | External OCR engine endpoint for processing=local |
CLOUD_STATUS_PROCESSING_ENDPOINT | http://localhost:9000/v1/process | Task status endpoint on the cloud engine (must be set explicitly in production) |
LOCAL_STATUS_PROCESSING_ENDPOINT | http://localhost:9000/v1/process | Task status endpoint on the local engine |
MAX_WAIT_OCR | 300 | Number of upstream status-poll iterations (2s/iteration ⇒ ~10 minutes before 504 Task timeout) |
Elasticsearch
| Variable | Default | Description |
|---|---|---|
ES_INDEX_NAME | pdf-parsing-results | Index that stores OCR result chunks |
URL_ELASTIC | — | Elasticsearch server URL |
USER_ELASTIC | — | Elasticsearch username |
PASS_ELASTIC | — | Elasticsearch password |
VERIFY_ELASTIC | False | Verify TLS certificates |
MinIO
| Variable | Default | Description |
|---|---|---|
MINIO_ENDPOINT | — | MinIO host |
MINIO_PORT | 9000 | MinIO port |
MINIO_ACCESS_KEY | — | MinIO access key |
MINIO_SECRET_KEY | — | MinIO secret key |
MINIO_SSL | false | Use SSL (true/false) |
MINIO_BUCKET | — | Target bucket for PDF uploads |
MINIO_DOMAIN | — | Public base URL used to build uploaded object URLs |
Summarize
| Variable | Default | Description |
|---|---|---|
SUMMARIZE_URL | http://10.1.102.14:8014 | Summarize service URL |
INDEX_DOCUMENT_SUMMARIZE | summarize_document | Elasticsearch index for full document summaries |
INDEX_DOCUMENT_SUMMARIZE_CHUNK | summarize_document_chunk | Elasticsearch index for per-chunk summaries |
SUMMARIZE_CONNECT_TIMEOUT | 10 seconds | Connection timeout to Summarize |
SUMMARIZE_READ_TIMEOUT | 600 seconds | Read timeout for the Summarize response |
Endpoints
| Method | Path | Description |
|---|---|---|
POST | /api/v1/pdfs | Upload a file or submit a PDF URL for OCR (async, main entry point) |
GET | /api/v1/tasks/{task_id}/status | Checks a task's status & progress |
POST | /api/v1/pdfs/merge | Merges 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/bulk | Submits many URLs at once — each becomes a separate task |
POST | /api/v1/pdfs/bulk/upload | Uploads many files at once — each becomes a separate task |
POST | /api/v1/documents | Ingests an arbitrary document directly into the Elasticsearch index |
GET | /api/v1/health | Health 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 engineNote:
lang,parse_method,formula_enable, andtable_enablecan no longer be set via the request —ocr-proxyhardcodes these (lang="en",parse_method="auto",formula_enable/table_enable=true) before forwarding to the upstream OCR engine.
Response (immediate, task still pending):
{
"task_id": "task-1751234567",
"status": "pending",
"processing_type": "cloud"
}GET /api/v1/tasks/{task_id}/status
{
"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: pending → processing → done / 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_pct | stage | Description |
|---|---|---|
5% | queued | Task picked up from the queue |
10% | uploading | Sending the file to the OCR engine |
20–80% | ocr_polling | Waiting & polling the upstream OCR engine's status |
85% | downloading_result | Downloading the OCR result from upstream |
86% | uploading_pdf | Uploading the PDF to MinIO (only if the task has a local file, not a pure url_file) |
88% | indexing | Saving results to Elasticsearch |
95% | summarizing | Triggering the document summary |
100% | done | Complete |
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 (.docxonly, usingpython-docx+reportlab).merge_utils.merge_pdfs_to_tempfile— merges multiple PDFs (viapypdf.PdfWriter) into one file, used byPOST /api/v1/pdfs/merge. Non-PDF files/URLs are converted first viaFileConverterbefore merging.merge_utils.download_to_tempfile— downloads a file from a URL to a temp file (used by/pdfs/mergeforurl_filesinput).
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):
{
"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:
| Result | Index | Description |
|---|---|---|
final_summary | INDEX_DOCUMENT_SUMMARIZE | Full document summary, document id = task_id |
summarize_chunk (list) | INDEX_DOCUMENT_SUMMARIZE_CHUNK | Per-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)
| Mode | Description |
|---|---|
ekstrak_only (default) | Text extraction only, no image analysis |
representasi | Text + 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
# 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