Skip to main content

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_kindWhat it acceptsSchemaSpec
clinical_note_pdftext-bearing PDF, falls through to vision OCR if scannedclinical_note_extraction_v1084
clinical_note_docxMicrosoft Word (.docx); paragraphs + tablesclinical_note_extraction_v1090
lab_csvUTF-8 CSV; ≤500 rows hard cap, batched 50/LLM calllab_extraction_v1084
imaging_reportPDF imaging report (with vision-OCR fallback)imaging_observation_v1084
text_scribeUTF-8 plain textclinical_note_extraction_v1084
audio_scribeaudio file → ASR transcript → text pathclinical_note_extraction_v1FOUND-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.py before 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 failed with reason extractor_unreachable instead of landing a 100% placeholder document in review.
  • Operator force-OCR re-extractforce_vision_ocr=true on re-extract re-runs OCR for cases where pdfplumber returned garbage text. Supersession via supersedes pointer (Spec 086).

Audit chain

Every step emits an event via AuditService.append() — payload carries counts + ids only, never extracted content.

ActionWhen
extraction.startedPipeline entered
extraction.sanitizedAfter sanitize.py
extraction.deidentifiedAfter deidentify.py
extraction.llm_calledAfter primary LLM call
extraction.section_mapped / extraction.section_mapping_failedSection mapping (Spec 090)
extraction.ocr_startedVision OCR begins
extraction.ocr_page_completedPer page (with pageIndex, byteCount, charCount, providerUsed, fallbackOccurred)
extraction.ocr_completedOCR finished all pages
extraction.ocr_failedAll pages unavailable (Spec 091 — distinct from extraction.failed)
extraction.completedRow persisted (ocrUsed, sectionMappingsCount flags)
extraction.failedAnything 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_SCHEMAS registry)
  • Module README (AI-agent entry point): apps/api/src/modules/extractor/README.md