Shell Heuristics · application docs

publisher · enricher-v2 · corpus

OCR pipeline — current state + target architecture (2026-06-09)

Design doc. Captures the OCR rework requested 2026-06-09: decompose OCR into scalable, single-concern web services, separate OCR from text enrichment, and return a structured per-page JSON envelope. Not yet built — proposal for review.

Problem with today's pipeline

recover-ocr (enrich-tasks task) → processor/src/extract/ocr-recover.tsprocessor/src/ocr.ts runs the entire cascade in-process inside the enrich-runner:

  1. fetch PDF (local /data or SeaweedFS filer)
  2. pdftoppm rasterize every page to PNG in the runner's memory
  3. tika (text layer) → glm/VL (pool-ocr) → tesseract sidecar, per page
  4. merge into a single ocr_text blob; write ocr_text + body + ocr_method + ocr_confidence

Consequences:

  • OOM: rasterizing 56–79-page PDFs ×N concurrent docs in the runner spiked memory and crash-looped the whole runner fleet (node-eighteen = SPOF). 2026-06-09.
  • No structured output: per-page text-layer / tesseract / VL results are collapsed into one blob — can't compare engines, can't re-run one stage.
  • Mixed concerns: OCR orchestration lives next to (and competes with) the LLM enrichment tasks in the same process.

What already exists (reusable)

Service Replicas Endpoint Role
tika / tika_tika 3/3 + 2/2 :9998 text-layer extraction
postcrime-heuristics-tesseract-sidecar 6/6 :8082 per-page tesseract (scaled, splittable)
pool-ocr (gpumon ingress) gpumon-ingress:4001 VL OCR (NIM nemotron-vl + spark qwen2.5-vl)
gpumon-ocr-api / -worker / -cache 1/1 · 2/2 · 1/1 gpumon overlay PDF→OCR gateway + worker + cache (edgar uses it via submitPdf())

The building blocks are services already. Only the orchestration is misplaced.

Target architecture

OCR = only rasterize + tesseract + VL OCR + embeddings. Text enrichment (bluebook, summaries, scheme, money, …) is downstream and separate, consuming the OCR JSON.

PDF  ─▶  ocr-intake (orchestrator)
           │  poppler: pdf → per-page PNG "envelopes"  (pdftoppm/pdftotext)
           │  + extract text-layer (tika)              ── text_layer[page]
           ├─▶ tesseract-svc   (per-page PNG → text)   ── tesseract[page]   (scale N)
           ├─▶ vl-ocr-svc      (per-page PNG envelope → VL via pool-ocr) ── vl_ocr[page]
           └─▶ embed-svc       (page text → vector)    ── embedding[page]
                     │
                     ▼
         OCR JSON  { pages:[ {n, text_layer, tesseract, vl_ocr, confidence} ],
                     merged_text, page_count, methods[] }
                     │
                     ▼
   ── separate enrichment services consume the JSON ──
      bluebook-cite · summarize(5-sentence/sentence/paragraph) · scheme · money · …

Design rules:

  • Each stage is its own web service taking an envelope (PDF or per-page PNG) over HTTP and returning JSON. The orchestrator never rasterizes in the caller's memory — ocr-intake (or the existing gpumon-ocr-api) owns that, mem-capped
    • horizontally scaled.
  • tesseract is split-scalable (already 6/6) — it scales independently of VL.
  • VL OCR takes a per-page PNG envelope and routes through pool-ocr — one page per call so a 79-page doc fans out instead of one giant request.
  • OCR returns all three engines per page (text-layer, tesseract, VL) + confidence, so callers pick/diff and stages are independently re-runnable.
  • Enrichment is downstream: bluebook citation, the 5 summary variants, scheme/money/embeddings run as separate services/tasks keyed off the OCR JSON — NOT inside the OCR path.

Decision needed (build vs wire)

Two viable paths — needs your call before implementation:

  1. Wire to existing gpumon-ocr-api — if its API already does PDF→per-page tesseract+VL+JSON, change fraud's recover-ocr to submitPdf() (like edgar) and stop the in-process cascade. Smallest change. Open: confirm gpumon-ocr-api returns the structured per-page JSON (text_layer+tesseract+vl) we want, or only merged text.
  2. New fraud-ocr-orchestrator service — a dedicated, mem-capped Bun service that owns rasterize + fan-out to tesseract-svc / vl-ocr-svc / tika and returns the JSON envelope. More work but full control of the schema + scaling knobs.

DB shape change either way: add per-engine columns or an ocr_pages_json (per-page text_layer/tesseract/vl/confidence) so the structured output is preserved, not collapsed to ocr_text.

Chosen path (2026-06-09): wire to gpumon-ocr-api

gpumon-ocr-api (gpu-federation-monitor/services/ocr-api) is an async job gateway, already used by edgar:

  • POST /jobs { filename, bytes, backend?: "vision"|"tesseract", … } → { id }
  • GET /jobs/:id → status + total_pages/pages_done
  • GET /jobs/:id/result.{txt,md,json} → output text
  • client lib: shared/ocr-api-client (submitPdf, getJob, getResultText)
  • edgar reference: filing-ingester.ts submits + records ocr_job_id/ocr_status; ocr-reaper.ts polls finished jobs → writes text. Mirror this exactly.

Wire-up plan (fraud side):

  1. Schema: add ocr_job_id / ocr_status / ocr_attempts to postcrime.documents (idempotent; same columns edgar added).
  2. Replace recover-ocr's in-process ocr.ts cascade with submitPdf(filename, bytes, { backend: "vision" }) for image-only docs (tesseract fallback); record ocr_job_id, ACK immediately. No rasterization in the runner.
  3. Add an ocr-reaper (in-process poll loop, like edgar's) that reads finished jobs → writes ocr_text + flips the OCR sentinel.
  4. Retire the local pdftoppm/tesseract cascade from the runner once verified (the poppler stopgap below can then be dropped).

Known gap vs the target JSON: gpumon-ocr-api runs ONE backend per job (vision XOR tesseract) and returns merged text — NOT text_layer+tesseract+vl per page. To get the full per-page 3-engine envelope, follow-on work is needed: either (a) extend gpumon-ocr-api to fan a job across text-layer(tika)+tesseract+vl and emit per-page JSON, or (b) fraud submits two jobs (vision+tesseract) + runs tika and merges per page. Decide after the basic wire-up is live and OCR is off the runner.

Immediate stopgap (already shipped 2026-06-09)

poppler-utils added to the enrich-runner image so the in-process path at least functions; OCR recovery is parked (see TODO #3) and must NOT run unbounded on the shared runner (OOM risk) until this architecture lands.