The detection step identifies PII in uploaded documents. It runs as a Map state in Step Functions: one Lambda invocation per file, up to 2 concurrent.
S3 document → Detection Handler → Route by extension → Processor → Bedrock LLM → Detection JSON → S3
↘ Textract (image-based only) ↗
DynamoDB status: IN_PROGRESS → DETECTING → DETECT_COMPLETE
The detection handler (pii_detection_handler.py) routes each file to the correct processor based on extension:
EXTENSION_MAP = {
".txt": detect_pii_txt,
".docx": detect_pii_word,
".xlsx": detect_pii_excel,
".csv": detect_pii_csv,
".json": detect_pii_json,
".jpg": detect_pii_image, # + .jpeg, .png, .tiff, .tif, .bmp, .webp
".mp3": detect_pii_audio, # + .wav: Transcribe → LLM (see docs/audio.md)
}
# PDFs route based on config.yaml → processing.approach
PDF_APPROACHES = {
"text": detect_pii_pdf_text, # pypdf text extraction → text LLM
"image": detect_pii_pdf_image, # Page renders → vision LLM + Textract
}Audio (
.mp3,.wav) routes todetect_pii_audio, which transcribes the file with Amazon Transcribe before detecting PII in the transcript. It has its own end-to-end guide: Audio PII Redaction.
The most complex pipeline. Designed for scanned or image-heavy PDFs.
- PDF downloaded from S3, validated (size, page count)
- Pages rendered as images at configurable DPI (default 300) via
pdf_processor.pyusing pypdfium2 - Each page processed concurrently with
ThreadPoolExecutor(defaultmax_workers: 5) - Per page:
- Single Textract call via
get_textract_full()returns word-level bboxes AND OCR text - OCR text passed to vision LLM alongside the page image as context
- Vision LLM returns PII with approximate bounding boxes
enhance_pii_detections_with_textract()refines LLM bboxes using Textract's precise word coordinates (see Textract Refinement below)
- Single Textract call via
- Cross-page sweep searches all unique PII on every page via Textract (see Cross-Page Sweep below)
- Detection JSON + raw Textract data (per page) stored in S3
Designed for digitally generated PDFs with extractable text.
- PDF downloaded from S3, validated
- Text extracted via pypdf (
PdfReader) - If no extractable text found, raises error suggesting image-based approach
- If embedded images found, logs warning:
"PII in images will NOT be detected. Use image-based approach for full coverage." - Text chunked by lines via
text_chunker.py(max_txt_chunk_tokens: 20000, clamped down to the selected model's output limit, e.g. Nova → 10000) - Chunks detected concurrently via
threaded_detector.py(defaultmax_workers: 6) - Results deduplicated across chunks
- File downloaded from S3, validated (max 10 MB)
- Text chunked by lines (same chunker as PDF-text)
- Chunks detected concurrently via threaded detector
- Results deduplicated
- File downloaded from S3, validated (max 50 MB)
- Text extracted via python-docx (paragraphs + table cells)
- Chunked and detected concurrently via threaded detector
- Results deduplicated
- File downloaded from S3, validated (max 50 MB, max 100 sheets)
- Each sheet's cell values extracted via openpyxl as a separate text chunk
- A sheet larger than the model's output limit is sub-split at row boundaries (so its detection output can't exceed the model's cap, e.g. Nova's 10K)
- All chunks detected concurrently via threaded detector (label: "Sheet")
- Results deduplicated across all sheets. Synthetic generation later runs once for cross-sheet consistency
- File downloaded from S3
- All cell values extracted via
csv.reader, structured like a single Excel sheet - Chunked at row boundaries to stay within the model's output limit (sub-split if large)
- File downloaded from S3
- All string values recursively extracted (traverses nested dicts and lists)
- Detected as a single chunk
- Detections mapped back to JSON paths (e.g.,
"patients[0].name")
- Image downloaded from S3, validated (max 100 MB, dimension limits 10 to 10000px)
- Single Textract call for word bboxes + OCR text
- Vision LLM detection with OCR context
- Textract refinement of bounding boxes
- Image downloaded from S3, validated (max 100 MB, max 50 frames)
- Multi-page TIFFs split into individual frames via PIL
- Each frame processed as a separate image (Textract + vision LLM)
- Frames processed concurrently
enhance_pii_detections_with_textract() in textract_helper.py refines the LLM's approximate bounding boxes using Textract's precise word-level coordinates. Three-stage matching:
Exact Match → Spatial Match (multi-column) → Fuzzy Match (80% threshold)
- Exact match (
bbox_source: "textract"): LLM text matches Textract word exactly - Spatial match (
bbox_source: "textract_spatial"): finds Textract words near the LLM's approximate coordinates. Handles multi-column layouts where the same text appears in different columns. - Fuzzy match (
bbox_source: "textract_fuzzy"): 80% similarity threshold catches OCR errors (e.g., "Sm1th" vs "Smith")
If none of the three stages match, the detection keeps the LLM's original approximate bbox (bbox_source: "llm_vision").
Detections are sorted by word count (longest first) before Textract matching. This prevents shorter detections from claiming Textract words needed by longer ones.
Example problem without this: "Learning Works" claims the Textract words "Learning" and "Works", leaving the full address "Learning Works, 1 Main St, Adger, AL, 35006" unable to find its first two words.
Multi-word detections produce bbox_segments: individual bounding boxes per word rather than one merged rectangle. These are serialized in the detection output JSON so they survive the S3 round-trip between Step 1 (detect) and Step 3 (redact).
Without this, multi-column address redactions (where words span different columns) render as one giant white box instead of precise per-word rectangles.
After per-page detection completes, the image-based processor runs a cross-page sweep to find PII occurrences the LLM missed:
- Collect all unique PII values detected across all pages
- For each page's Textract words:
- First "claim" word indices for PII the LLM already detected on that page (avoids double-counting)
- Then search for additional occurrences of ALL unique PII values
- Extra occurrences added with
detection_source: "textract" - Values ≤3 chars skipped to avoid false positives
- Excluded values:
Unknown,None,N/A,-,Male,Female
This catches:
- A name appearing 3 times on a page but the LLM only reported it once
- A name on page 5 that was only detected on page 1
- Any PII value that appears on pages the LLM didn't flag
For text-based PDFs and other text formats, duplicate detection happens during redaction (Step 3) instead:
text_replacer.py'sstr.countfinds ALL occurrences of each PII value- If more are found than the LLM reported, they're tracked as
_extra_occurrences - Included in the redaction report with
detection_source: "text_search"
For PDFs with embedded images (forms with scanned inserts, photos), there is no separate extract/re-embed step. Image-based redaction renders each whole page to a raster and flattens it, so any PII baked into an embedded image is redacted along with the rest of the page at its Textract-derived coordinates. The embedded image is just part of the rendered page pixels.
| Component | Default Workers | Configurable Via |
|---|---|---|
| Page detection (image-based) | 5 | performance.max_workers |
| Chunk detection (text-based) | 6 | concurrency.max_workers |
| Step Functions Map state | 2 | MaxConcurrency in ASL |
Text-based detection uses threaded_detector.py which wraps ThreadPoolExecutor. Each thread makes an independent Bedrock API call. Results are collected with a thread lock and deduplicated.
Image-based detection also uses ThreadPoolExecutor but each thread makes both a Textract call and a Bedrock call per page.
# config.yaml
processing:
approach: "image" # "text" or "image" (PDF only)
model:
id: "global.anthropic.claude-sonnet-4-6"
provider: "anthropic" # "amazon", "anthropic", or "openai"
capabilities: # Per-model inference registry (see docs/config.md)
"opus-4-8":
{
sampling: false,
thinking: "adaptive_effort",
efforts: [low, medium, high, max, xhigh],
max_output_tokens: 64000,
}
"openai.gpt-5":
{
sampling: false,
thinking: "reasoning_effort",
efforts: [none, low, medium, high, xhigh],
}
"nova": { sampling: true, thinking: null, max_output_tokens: 10000 } # caps maxTokens + chunk size
detection:
temperature: 0 # Deterministic, consistent detection (ignored by OpenAI)
top_k: 5 # Narrow token sampling (ignored by OpenAI; dropped when thinking on)
reasoning_effort: "none" # OpenAI GPT-5.x only: none|low|medium|high|xhigh. Ignored by Claude/Nova
max_tokens: 64000 # Text-based output limit (clamped per model, e.g. Nova → 10000)
image_max_tokens: 16000 # Vision-based output limit (clamped per model)
enable_thinking: false # CLAUDE ONLY: extended thinking. Ignored by Nova/OpenAI
thinking_budget_tokens: 4000
concurrency:
max_workers: 6 # Threads for text chunk detection
chars_per_token: 4 # Token estimation for chunking
max_txt_chunk_tokens: 20000 # Max tokens per text chunk (clamped to model output limit)
performance:
dpi: 300 # PDF page render quality
max_workers: 5 # Threads for image page detectionDetection prompts are in src/core/prompts.py. Default prompts target financial and healthcare documents.
Key prompt behaviors:
- Address splitting: explicitly instructs the LLM to detect Street, City, State, Zip as separate items when they're in separate form fields
- Work state: vision prompt includes "work state/province codes" in demographics list
- Male/Female exclusion: excludes "Male"/"Female" words (not just single letters M/F)
Prompt constants:
VISION_SYSTEM_PROMPT/VISION_TASK_PROMPT: image-based detection (uses<PAGE_IMAGE>placeholder for multimodal content)SYSTEM_PROMPT/PII_DETECTION_PROMPT: text-based detection
Review and customize these for your domain before deploying.
Stored at {output_prefix}/intermediate/detections/{safe_name}/detections.json:
{
"detections": [
{
"content": "John Smith",
"type": "name",
"confidence": 0.95,
"page_num": 1,
"bounding_box": {"left": 100, "top": 200, "width": 150, "height": 20},
"bbox_segments": [
{"left": 100, "top": 200, "width": 60, "height": 20},
{"left": 170, "top": 200, "width": 80, "height": 20}
],
"bbox_source": "textract_exact",
"detection_source": "llm"
}
],
"file_type": "pdf_image",
"token_usage": {"input_tokens": 5000, "output_tokens": 2000, "requests": 3},
"failed_chunks": [],
"textract_pages": [
{"page": 1, "ocr_text": "...", "raw": {...}}
]
}| Field | Values | Description |
|---|---|---|
file_type |
pdf_image, pdf_text, txt, xlsx, csv, json, image |
Determines redaction strategy in Step 3 |
bbox_source |
textract, textract_spatial, textract_fuzzy, llm_vision, form_field |
How the bounding box was obtained |
detection_source |
llm, textract |
LLM detected it vs cross-page sweep found it |
bbox_segments |
Array of {left, top, width, height} |
Per-word bboxes for multi-word detections (image-based only) |
textract_pages |
Array of {page, ocr_text, raw} |
Raw Textract data per page (stored separately in S3 for debugging) |
json_paths |
Array of strings | JSON-only: paths where the PII value was found (e.g., "patients[0].name") |
| Check | Image | Word | Excel | TXT | |
|---|---|---|---|---|---|
| Max file size | 50 MB | 100 MB | 50 MB | 50 MB | 10 MB |
| Max pages/frames | 1000 | 50 (TIFF) | N/A | 100 sheets | N/A |
| Min dimension | N/A | 10px | N/A | N/A | N/A |
| Max dimension | N/A | 10000px | N/A | N/A | N/A |
All limits configurable in config.yaml under validation. Customer can increase for their use case.
- Failed files recorded in DynamoDB via
append_failed_file(job_id, ts, "detect", source_key, error) - Step Functions Map state:
ToleratedFailurePercentage: 50, up to half the files can fail without aborting the job - Throttling errors (Bedrock rate limits) caught by
throttle_handler.py, re-raised asThrottlingExceptionfor Step Functions retry - Retry config: 5 attempts, 10s initial interval, 2.5x exponential backoff
- Retried errors:
Sandbox.Timedout,Lambda.TooManyRequestsException,ThrottlingException,ServiceQuotaExceededException,ProvisionedThroughputExceededException,RequestLimitExceeded,ServiceUnavailableException - LLM response validation via
model_schemas.pydataclasses (TextBasedOutput/ImageBasedOutput). JSON is parsed byextract_json_dict(), a layered, repair-tolerant parser (handles control characters, code fences, preamble, trailing commas, and truncated output) - Fail-loud on truncation: if a detection response hits the model's output-token limit (
stopReason=max_tokens), the chunk is raised as a failure (job fails) rather than silently returning a partial PII list. Use a larger-output model (Claude/GPT) or smaller input for very dense documents - A detection API failure (after retries, or a non-retryable error) is raised, never swallowed into an empty result, so a failed detection fails the job instead of producing a falsely-"clean" document
| Operation | Bucket | Purpose |
|---|---|---|
GetObject |
Input | Read source document |
PutObject |
Output | Write detection JSON, Textract raw data per page |
| File | Purpose |
|---|---|
handlers/pii_detection_handler.py |
Lambda entry point, routes to processor by extension |
core/pii_detector.py |
Vision LLM invocation (invoke_model), text LLM invocation (invoke_model_for_text), response parsing |
processors/pdf_image_processor.py |
PDF image pipeline: render → detect → Textract refine → cross-page sweep |
processors/pdf_text_processor.py |
PDF text pipeline: pypdf extract → chunk by lines → threaded detect |
processors/txt_processor.py |
Plain text: chunk → threaded detect |
processors/word_processor.py |
Word: python-docx extract → chunk → threaded detect |
processors/tabular_processor.py |
Excel (per-sheet chunks), CSV (single chunk), JSON (recursive extract, single chunk) |
processors/image_processor.py |
Standalone images: Textract + vision LLM. Multi-page TIFF support. |
helpers/textract_helper.py |
Textract OCR + 3-stage bbox refinement (exact → spatial → fuzzy) |
helpers/threaded_detector.py |
ThreadPoolExecutor wrapper for concurrent chunk detection |
helpers/text_chunker.py |
Text splitting by lines to stay under LLM context limits |
helpers/pdf_processor.py |
PDF page rendering to images (supports S3 URIs) |
helpers/page_type_checker.py |
Detect text vs scanned pages in PDFs |
helpers/token_tracker.py |
Token usage tracking across LLM calls |
helpers/model_config_helper.py |
Bedrock model config (inference params, concurrency settings) |
core/prompts.py |
All detection and generation prompts |
validation/pdf_validator.py |
PDF size/page count validation |
validation/document_validator.py |
TXT/Word/Excel file validation |
validation/model_schemas.py |
Dataclass-based I/O validation for LLM responses |