Extractor Pipeline
The extractor turns an uploaded clinical document into structured extraction_results rows ready for review. Five content kinds today, all routed through the same de-identification + LLM safety pipeline before persistence.
Content kinds
content_kind | What it accepts | Schema | Spec |
|---|---|---|---|
clinical_note_pdf | text-bearing PDF, falls through to vision OCR if scanned | clinical_note_extraction_v1 | 084 |
clinical_note_docx | Microsoft Word (.docx); paragraphs + tables | clinical_note_extraction_v1 | 090 |
lab_csv | UTF-8 CSV; ≤500 rows hard cap, batched 50/LLM call | lab_extraction_v1 | 084 |
imaging_report | PDF imaging report (with vision-OCR fallback) | imaging_observation_v1 | 084 |
text_scribe | UTF-8 plain text | clinical_note_extraction_v1 | 084 |
audio_scribe | audio file → ASR transcript → text path | clinical_note_extraction_v1 | FOUND-C |
After primary extraction, a follow-on LLM call buckets the extracted text against a closed taxonomy (section_mapping_v1, Spec 090) so the review queue can show "Labs 92%" / "Vitals 80%" badges. Section mapping is best-effort — failure does not fail the upload.
Pipeline
Vision-LLM OCR (Spec 090, FR-S090-011..018)
Triggered automatically when pdfplumber.extract_text() returns empty (image-only / scanned PDFs). Each page is rendered to PNG at 200 DPI and shipped to Gemini 2.0 Flash (primary, vision-capable) or GPT-4o (fallback, vision-capable) — both already BAA-covered for text use, no new vendor.
Guardrails:
- 50-page hard cap per document — exceed it and the upload fails with
pdf_too_large_for_vision_ocr. - Per-page sanitize + deidentify — every page transcript runs through
sanitize.py+deidentify.pybefore join, same PHI safety as the text path. - Page images never persisted — they live only in the request lifetime.
- All-providers-down fail-closed (Spec 091 follow-up) — when every page returns the placeholder, the upload transitions to
failedwith reasonextractor_unreachableinstead of landing a 100% placeholder document in review. - Operator force-OCR re-extract —
force_vision_ocr=trueon re-extract re-runs OCR for cases where pdfplumber returned garbage text. Supersession viasupersedespointer (Spec 086).
Audit chain
Every step emits an event via AuditService.append() — payload carries counts + ids only, never extracted content.
| Action | When |
|---|---|
extraction.started | Pipeline entered |
extraction.sanitized | After sanitize.py |
extraction.deidentified | After deidentify.py |
extraction.llm_called | After primary LLM call |
extraction.section_mapped / extraction.section_mapping_failed | Section mapping (Spec 090) |
extraction.ocr_started | Vision OCR begins |
extraction.ocr_page_completed | Per page (with pageIndex, byteCount, charCount, providerUsed, fallbackOccurred) |
extraction.ocr_completed | OCR finished all pages |
extraction.ocr_failed | All pages unavailable (Spec 091 — distinct from extraction.failed) |
extraction.completed | Row persisted (ocrUsed, sectionMappingsCount flags) |
extraction.failed | Anything raised; carries reason from the failure-reason taxonomy |
Failure-reason taxonomy
Closed enum, defined in apps/api/src/modules/extractor/extractor.types.ts:
pdf_no_extractable_text, csv_too_large, invalid_payload, invalid_content_kind, extractor_timeout, extractor_schema_mismatch, deidentifier_emptied_payload, asr_failed, extractor_unreachable, docx_no_extractable_text, pdf_too_large_for_vision_ocr.
Adding a value requires a spec amendment.
Where to look
- API service:
apps/api/src/modules/extractor/— orchestrates the pipeline, owns audit + persistence - ML route:
apps/ml/services/routes/extract.py— single endpoint, content-kind-dispatched - DOCX + PDF parsers:
apps/ml/services/content_extract.py - Vision OCR:
apps/ml/services/vision_ocr.py - Schemas:
apps/ml/services/models/extraction.py(EXTRACTION_SCHEMASregistry) - Module README (AI-agent entry point):
apps/api/src/modules/extractor/README.md