Story 1584: the native-extraction adapter β€” LiteParse default, pypdfium2 fallback, paragraph bbox on both paths

Why this exists (the catastrophe it prevents). Story-523 gave the worker native extraction, but it was hardwired to one engine that the code called "LiteParse" while actually being plain pypdfium2 β€” text only, no geometry. Two problems compound from there. (1) The name was a lie: the real LiteParse wheel β€” run-llama's Rust core that bundles the same PDFium plus per-item bounding boxes, fonts, and confidence β€” was never installed; the codebase squatted its name on a lesser engine, so adopting the real thing would collide. (2) Born-digital pages had no boxes: only OCR pages got document_pages.blocks, so the source-highlight UI ("jump to where this claim appears on page 4") could highlight a scanned page but not a born-digital one. Story-1584 fixes both: native extraction goes behind a port with two swappable vendors (the real LiteParse as default, pypdfium2 as a fallback), and a single vendor-neutral composition step gives both paths paragraph-level boxes.

Scope & doctrine. Native-only adoption per the Spike-1580 recommendation and Ankit's decisions on that spike: keep Textract as the OCR engine (no PaddleOCR), adopt LiteParse for born-digital PDFs behind an adapter mirroring the existing OcrPort seam, and fold the naming cleanup into this one Story. No behaviour change to the malware gate, the outbox relay, or the Textract recognition path itself.

πŸ›°οΈ Satellite: the port, the two vendors, the one composition step

document bytes
   β”‚
   β”œβ”€ NativeExtractorPort  (DOCUMENT_NATIVE_EXTRACTOR selects the vendor)
   β”‚     β”œβ”€ LiteParseAdapter  (default)  ── real wheel, ocr_enabled=False
   β”‚     β”‚      β†’ page text + per-ITEM blocks (bbox 0..1 + confidence)
   β”‚     └─ PdfiumAdapter    (fallback)  ── pypdfium2, text only, no blocks
   β”‚
   β”‚  born-digital? (stripped_text_length β‰₯ NATIVE_TEXT_MIN_CHARS)
   β”‚     yes ─▢ persist_native_result ─┐
   β”‚     no  ─▢ OcrPort/Textract ───────   ← scanned/no-text-layer pages only
   β”‚            β†’ page text + per-LINE blocks
   β”‚                                    β”‚
   β”‚                  compose_paragraphs(line/item blocks)   ← ONE step, both paths
   β”‚                  β†’ paragraph blocks: union bbox, mean confidence, reading order
   β”‚                                    β”‚
   └────────────────────────────────────▢ documents.document_pages.blocks (uniform JSONB)

The key shape: each vendor and Textract emit line/item-grained blocks; the same compose_paragraphs runs over them at persistence to emit paragraph blocks. So "paragraph box per page" is one function, written once, identical for LiteParse and Textract β€” not two parallel implementations that drift.

✈️ Airplane: the files

FileRole
src/shared/extraction/port.py (new)NativeExtractorPort ABC β€” the seam the worker depends on. Mirrors OcrPort; extract is sync (CPU work, run off-thread by the worker), where recognize is async (network).
src/shared/extraction/liteparse.py (new β€” name freed)LiteParseAdapter: the real run-llama wheel, constructed ocr_enabled=False / quiet=True / num_workers=1. Maps each TextItem (x/y/w/h, top-left origin) to a normalized 0..1 OcrBlock. Default vendor.
src/shared/extraction/pdfium.py (renamed from liteparse.py)PdfiumAdapter (was the misnamed LiteParseExtractor): today's pypdfium2 text-only path, kept as a selectable fallback.
src/shared/extraction/paragraphs.py (new)compose_paragraphs: groups line/item blocks into paragraphs by vertical gap, emits the union rectangle. Vendor-neutral β€” bare OcrBlock in, paragraph OcrBlock out.
src/shared/extraction/models.pyNativePage gains blocks (reusing the neutral OcrBlock/BoundingBox) + a mean_confidence property.
src/shared/extraction/__init__.pyExports the port + both vendors + build_native_extractor(vendor), the config→vendor factory.
src/domains/document_page/service.pyBoth persist_native_result and persist_ocr_result now run compose_paragraphs before the upsert β€” the single composition site.
src/shared/settings.py + workers/document_extraction/handler.py + processor.pyDOCUMENT_NATIVE_EXTRACTOR (default liteparse); the handler builds the chosen vendor; ExtractionDeps.native_extractor is typed as the port.
requirements.in / requirements.lockPin + hash liteparse==2.1.1 (cp312 manylinux_2_28 wheel; clean install on the Lambda AL2023 runtime, glibc 2.34 β‰₯ 2.28). pypdfium2 stays for the fallback.

πŸ”¬ Microscopic: the decisions that matter

1. A port, because there is now more than one native engine

The whole reason OcrPort exists is that nothing downstream may depend on which engine produced the text. Native extraction now has the same property β€” LiteParse and pypdfium2 β€” so it gets the same treatment. The worker holds a NativeExtractorPort; swapping vendors is a one-env change (DOCUMENT_NATIVE_EXTRACTOR=pdfium), never a rewrite. build_native_extractor rejects an unknown vendor loudly at cold start, so a typo fails fast β€” never a silent wrong-vendor parse.

2. Native-only is the safety contract, and it makes traineddata unnecessary

The extraction worker is VPC-attached for RDS with no internet egress and a read-only root filesystem. LiteParse's built-in Tesseract defaults to downloading eng.traineddata from GitHub into $HOME on first OCR β€” both the download (no NAT β†’ socket hang) and the write (read-only rootfs) would fail in prod while passing every local test (the classic "fixtures lie / first real run breaks" trap). We sidestep it entirely by constructing LiteParse(ocr_enabled=False): a text-less page returns empty text, Tesseract never initializes, no download is attempted, and the worker routes that page to the OcrPort (Textract) instead. So no eng.traineddata is bundled β€” it is unreachable by construction. This was verified empirically against the real wheel: parsing a glyph-less PDF with ocr_enabled=False produces zero text and never creates the ~/.tesseract-rs cache dir.

3. Engine stays pdfium; the vendor lives in engine_version β€” no migration

LiteParse bundles the same PDFium text layer pypdfium2 wraps, so the persisted document_pages.engine enum stays pdfium for both native vendors β€” the existing CHECK constraint already admits it, and this Story ships no migration. The vendor is distinguished by engine_version: liteparse/2.1.1 vs pypdfium2/5.10.1, so a page row is still auditable to the exact engine that produced it.

4. Paragraph bbox: composition, not pricier Textract LAYOUT

Both engines capture geometry at the line/item grain, but the UI wants a box per paragraph. Rather than pay Textract's analyze_document+LAYOUT per page, we compose paragraphs ourselves β€” free, our code, and crucially vendor-neutral because both vendors already normalize into the neutral OcrBlock (coordinates 0..1 of the page). A paragraph's box is the union rectangle of its constituent line boxes; its confidence is their mean; its text is the lines joined; output preserves reading order so list position is a stable ordering. Segmentation is a simple vertical-gap heuristic (a new paragraph starts when the blank space above a line exceeds ~0.6 of that line's height). The pricier native LAYOUT stays a documented future option, only if composed segmentation proves poor on real legal documents.

5. One composition site, so the two paths cannot drift

Composition runs at persistence β€” persist_native_result and persist_ocr_result both call compose_paragraphs(page line blocks) before the upsert. The Textract adapter is untouched (it still returns line blocks β€” no behaviour change to the OCR recognition path); the enrichment that the bbox requirement explicitly asked for happens once, downstream, for both paths. A page with no line blocks (the text-only pypdfium2 fallback) composes to [], exactly as before.

6. Off-thread, single worker, quiet

LiteParse's parse is synchronous CPU work, so the worker runs it via asyncio.to_thread β€” the same off-thread pattern the old pypdfium2 call used, and the reason the port's extract is sync. num_workers=1 matches the single-vCPU worker (avoid OCR-thread oversubscription) and quiet=True suppresses LiteParse's stdout timing chatter that would otherwise become log noise.

Unchanged on purpose. Textract stays the only OCR engine; the malware gate, the outbox relay, the state machine, idempotency, the scan gate, trace continuity, and RLS-for-a-system-actor all behave exactly as Story-523/524 left them. This Story changes the native vendor seam and what document_pages.blocks carries β€” nothing else.

Verification (system boundary, not component boundary)

Per the "done = a real request against a real DB, including the empty first-run state" rule, the worker's integration tests run against a real RLS-scoped DB on a freshly seeded firm with zero pre-existing page rows β€” the real first run:

Unit tests cover each vendor in isolation (LiteParse blocks + native-only safety: no ~/.tesseract-rs created; pypdfium2 text-only) and the composition function (single line β†’ one paragraph; tight lines β†’ union box; large gap β†’ split; reading order; per-page grouping). Full local gate: ruff clean, 1312 tests pass, make openapi produces no diff (no trust-boundary DTO changed), and the Alembic round-trip is clean on a fresh DB (no migration in this Story). The Lambda-target wheel hash (liteparse-2.1.1-cp312-cp312-manylinux_2_28_x86_64) is present in the hashed lock, so the deploy build installs it with no compile.

One sentence to remember: native extraction now sits behind a port with two swappable vendors β€” the real LiteParse (default, native bounding boxes, run strictly native-only so its Tesseract never fires) and pypdfium2 (text-only fallback) β€” and one vendor-neutral composition step gives every page, born-digital or scanned, a paragraph box the UI can jump to.