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.
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
| File | Role |
|---|---|
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.py | NativePage gains blocks (reusing the neutral OcrBlock/BoundingBox) + a mean_confidence property. |
src/shared/extraction/__init__.py | Exports the port + both vendors + build_native_extractor(vendor), the configβvendor factory. |
src/domains/document_page/service.py | Both 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.py | DOCUMENT_NATIVE_EXTRACTOR (default liteparse); the handler builds the chosen vendor; ExtractionDeps.native_extractor is typed as the port. |
requirements.in / requirements.lock | Pin + 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.
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:
- Born-digital PDF β
indexedvia the real LiteParse wheel: apdfiumpage row whoseengine_versionisliteparse/*, with paragraph blocks carrying a 0..1 bbox and exact (1.0) confidence indocument_pagesβ and OCR never called. This is the Story's headline acceptance: a realstored β indexedrun with paragraph bbox present, proven against the DB, not a mock. - Vendor switch proven: the same born-digital upload with
DOCUMENT_NATIVE_EXTRACTOR=pdfiumstill reachesindexed(text-only, no blocks) β the fallback works. - Scanned input β Textract fallback β
indexedwith paragraph blocks composed from Textract lines β the bbox requirement on the OCR path too. - Low-confidence OCR β
needs_review; oversize βfailedwith bytes never fetched; redelivery of a terminal document β no-op β all unchanged from Story-523/524.
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.