The synthetic step replaces all detected PII with realistic fake data. It runs as a single Lambda invocation per job: all files' detections are aggregated, deduplicated, and processed together so the same PII value always maps to the same synthetic replacement across every file in the job.
Detection JSONs (all files)
→ Read from S3
→ Deduplicate by content
→ Categorize (value patterns → LLM type hint fallback)
→ Cluster within category (names, phones, addresses grouped by identity)
→ Merge super-groups (person = name + email, address = street + city + state + zip)
→ Dynamic batching (split if > max_synthetic_batch_tokens)
→ LLM generation (concurrent batches via ThreadPoolExecutor)
→ 3-phase repair (missed → inconsistent → collisions)
→ Post-process consistency (name variants, phone formats, SSN partials)
→ Validate (every unique PII has a mapping, remove hallucinated keys)
→ Store synthetic_mapping.json to S3
DynamoDB status progression: DETECT_COMPLETE → GENERATING_SYNTHETIC → SYNTHETIC_COMPLETE
When redaction.mode: "blackout" in config, the entire LLM pipeline is skipped:
# synthetic_pii_generator.py → batch_generate_synthetic_pii()
if redaction_mode == "blackout":
return {d.get("content", ""): "[REDACTED]" for d in pii_detections if d.get("content")}No LLM calls, no clustering, no repair. Every PII value maps to [REDACTED].
value_categorizer.py assigns each PII value a category using a two-tier approach. The category determines which clustering algorithm groups related values together.
High-precision regex/structural checks on the value itself, no LLM type hint needed:
| Category | Detection Logic | Examples |
|---|---|---|
email |
user@domain.tld regex |
john.smith@hospital.com |
ssn |
XXX-XX-XXXX, masked ***-**-XXXX, ending in XXXX |
123-45-6789, ***-**-1234 |
financial |
Currency symbol + digits, or digits.XX |
$45,230.00, 38715 |
date |
6 date patterns (MM/DD/YYYY, YYYY-MM-DD, Month Day Year, DD-Mon-YYYY, etc.) | 01/15/2024, January 15, 2024 |
phone |
10-15 digits with phone punctuation +()-./ |
(555) 123-4567, +1-555-123-4567 |
address |
Street number + street suffix (St, Ave, Blvd, etc.) or P.O. Box | 123 Main St, City, ST 12345 |
org_name |
Contains org keywords (Hospital, LLC, Bank, University, etc.) | Memorial Hospital, Acme Corp |
person_name |
2-4 capitalized words, no org/address keywords | John Smith, Dr. Sarah Chen |
Checks run in this exact order. First match wins: a value like Dr. Smith Hospital hits org_name (contains "Hospital") before person_name.
If no structural pattern matches, the LLM's type label from Step 1 detection is mapped via config.yaml:
# config.yaml → clustering.type_hint_map
clustering:
type_hint_map:
address: [address, street, city, state, zip, location]
person_name: [name, first_name, last_name, patient_name, borrower, attorney]
phone: [phone, fax, telephone]
ssn: [ssn, social]
date: [dob, birth, expiration_date]
org_name: [institution, hospital, clinic, company, bank, employer, court]
id_generic:
[
id,
record,
account,
policy,
reference,
case,
npi,
license,
passport,
vin,
credit_card,
ein,
tax_id,
docket,
credit_score,
]Matching is bidirectional substring: if the LLM says patient_name, it matches name in the person_name list. Add keywords here to handle new PII types without code changes.
After categorization, values within each category are clustered so related values get consistent synthetic replacements. Each category has its own clustering strategy:
| Category | Strategy | Grouping Logic |
|---|---|---|
person_name |
Token overlap | 2+ shared tokens for multi-word names; substring match for single-word. Titles (Mr, Dr, MD) stripped before comparison. |
address |
Street components | Street number must match exactly. Street name token similarity ≥50%. Fragments (city-only, state-only) matched by substring against full addresses. |
org_name |
Token set similarity | ≥90% token overlap (after stripping LLC/Inc/Corp). Substring match for short names (≤2 tokens). |
phone |
Normalized digits | Stripped to last 10 digits. (555) 123-4567 and +1-555-123-4567 → same cluster. |
ssn |
Normalized 9 digits | Masked forms (***-**-XXXX) excluded from normalization but linked later in post-processing. |
date |
Normalized ISO | All formats parsed to YYYY-MM-DD. 01/15/2024 and January 15, 2024 → same cluster. |
email |
Lowercased exact | John@Example.COM and john@example.com → same cluster. |
financial |
Digits + decimal | $45,230.00 and $45,230 → same cluster (both normalize to 45230). |
id_generic |
Alphanumeric lowercased | PT-12345 and pt12345 → same cluster. |
Max items per cluster: clustering.max_cluster_size (default 10). Prevents snowball merging where unrelated values chain-link into one giant cluster.
Before batching, related categories are merged into super-groups so the LLM generates cross-type consistent values in a single call:
SUPER_GROUPS = {
"person": ["name", "first_name", "last_name", "middle_name", "email"],
"address": ["address", "city", "state", "zip"],
}This ensures:
- A person's name and email are generated together →
sarah.johnson@company.comderives fromSarah Johnson - Address components (street, city, state, zip) are generated together → consistent synthetic address
Items keep their original type labels: super-groups only control batching, not category inference.
Two prompts from prompts.py:
BATCH_SYNTHETIC_SYSTEM_PROMPT: rules for the LLM:
- Preserve format exactly (word count, punctuation, case)
- Preserve gender (female stays female)
- Match character length ±20%
- Never add extensions/prefixes not in original
- Preserve alphabetic prefixes in IDs (
NTN-65647→NTN-83491, notKPR-83491) - Entity groups: one synthetic identity per group, derive all variants from it
- Uniqueness: different entities must get different synthetics
BATCH_SYNTHETIC_TASK_PROMPT_TEMPLATE: the actual data, built by _build_batch_prompt():
- Values section: each entity group with category label, items numbered, cluster members marked "(SAME entity)", per-category instructions from
CATEGORY_INSTRUCTIONS - Replacements section: same structure as template for LLM to fill
The LLM returns XML:
<synthetic_data>
<names>
<item>
<original>John Smith</original>
<synthetic>Robert Chen</synthetic>
</item>
</names>
</synthetic_data>parse_synthetic_data() handles:
- Markdown-wrapped XML (
```xml ... ```) - Escaped ampersands (
&→&) - Truncated responses (missing
</synthetic_data>→ raises error, triggers repair) - Uses
defusedxml.ElementTreefor safe parsing
_split_into_batches() checks if all PII fits in one LLM call:
# Measures actual prompt size (not just raw values)
# 3x multiplier: prompt has values twice + LLM output repeats in XML
prompt_tokens = estimate_tokens(full_prompt, chars_per_token)
if prompt_tokens * 3 <= max_batch_tokens:
return [pii_by_type] # single batch, one LLM callIf it exceeds max_synthetic_batch_tokens (default 10000), PII is split into multiple batches preserving type group boundaries: all items of a given type stay together for context.
Batches run concurrently via ThreadPoolExecutor using concurrency.max_workers (default 6).
Synthetic generation uses higher temperature than detection for diverse output:
# config.yaml
synthetic:
temperature: 0.8 # Creative, diverse synthetic data (detection uses 0)
top_k: 200 # Wide token choices (detection uses 5)
max_tokens: 64000 # Clamped to the model's output limit (e.g. Nova → 10000)
reasoning_effort: "none" # OpenAI GPT-5.x only; "none" recommended (creative step)
enable_thinking: false # Claude only; ignored by Nova/OpenAISynthetic is a creative step, so reasoning/thinking add little value.
reasoning_effort: noneis the recommended default for GPT, andenable_thinking(Claude only) is off by default. Both are read per-model: OpenAI usesreasoning_effort, Claude usesenable_thinking, Nova uses neither.
After the main LLM generation, _repair_with_llm() fixes three types of issues:
PII values the LLM didn't return a mapping for. Common when XML response gets truncated on large batches.
Synthetic value equals the original: the LLM returned the input back unchanged. Detected by comparing pii_mapping[orig] == orig.
Multiple different original values mapped to the same synthetic value. First mapping is kept; the rest are sent to repair.
- Items grouped by super-group (person, address, or individual type)
- Per group: existing mappings provided as context + items to fix
- LLM generates replacements using
BATCH_REPAIR_SYSTEM_PROMPT/BATCH_REPAIR_PROMPT_TEMPLATE - Validation per item:
- Must not be empty
- Must not equal the original
- Must not reuse any existing synthetic value
- Failed items fall back to Faker (up to 10 attempts to avoid collisions with existing synthetics)
generate_synthetic_pii_fallback() generates synthetic data without LLM when:
- LLM call fails entirely
- Repair LLM doesn't return a value
- Need to avoid collision with existing synthetics
Format-preserving generation per PII type:
| Type | Faker Logic |
|---|---|
| Names | Preserves word count. Gender detection via common female names list + titles (Ms., Mrs.). |
| SSN | Preserves format: dashed (XXX-XX-XXXX), undashed, masked (***-**-XXXX), "ending in XXXX". |
| Phone | Preserves exact format: +1, parentheses, dots, dashes, extensions (ext.), pager notation. |
| Date | Preserves format: MM/DD/YYYY, DD-Mon-YYYY, Month Day, Year. |
| Address | Preserves structure: street-only vs full (city/state/zip). Detects comma presence. |
| Age patterns | Detects 35 yo M, 42 y/o F formats. Generates new age within ±15 range, preserves format. |
| Account/ID | Preserves digit/letter/separator pattern exactly. Alphabetic chars → random uppercase, digits → random digits. |
| Credit scores | Generates in same range bracket (300-579, 580-669, 670-739, 740-799, 800-850). |
| Institution | Detects subtype (hospital, medical center, clinic). Generates matching institution name. |
| Generic | Replaces digit sequences with same-length random digits. Preserves all non-digit characters. |
_post_process_consistency() runs after all mappings are generated to ensure related synthetic values are consistent across entity variants.
Groups name variants by shared tokens using iterative merging:
"Sarah Elizabeth Johnson", "Sarah Johnson", "Johnson", "Ms. Johnson", "S.E.J."
→ all linked to one group
- Anchor = longest variant (most words). All others derive from anchor's synthetic.
- Word-by-word mapping:
Sarah→Laura,Johnson→Bennett - Initials:
S.E.J.→L.M.B.(derived from first letters of synthetic name) - Prefix matching for fuzzy cases (e.g.,
SaramatchesSarah)
Groups by normalized 10 digits. All format variants derive from one synthetic number:
(555) 123-4567 → (734) 229-4081
555-123-4567 → 734-229-4081
+1-555-123-4567 → +1-734-229-4081
5128675310 → 7342294081
Preserves: +1 prefix, parentheses, dots, dashes, extensions, pager notation.
Full SSN and masked forms share the same last 4 digits:
123-45-6789 → 987-65-4321
***-**-6789 → ***-**-4321
ending in 6789 → ending in 4321
Values differing only by punctuation/whitespace get the same synthetic:
"4521 Oak Street, Austin, TX" and "4521 Oak Street Austin TX"
→ same synthetic value
Institution names get special treatment because they often appear in multiple forms within a document:
identify_related_entities()extracts base entity by stripping department info (after comma) and location (after "of")- Groups:
"Memorial Hospital","Memorial Hospital, Dept of Surgery","Memorial Hospital of Washington"→ base ="Memorial Hospital" - One synthetic generated for the base entity
- Variants constructed by replacing the base part while preserving structure:
"Memorial Hospital, Dept of Surgery"→"Evergreen Medical Center, Dept of Surgery""Memorial Hospital of Washington"→"Evergreen Medical Center of Washington"
Stored at {output_prefix}/intermediate/synthetic/synthetic_mapping.json:
{
"job_id": "batch-test",
"pii_mapping": {
"John Smith": "Robert Chen",
"123-45-6789": "987-65-4321",
"***-**-6789": "***-**-4321",
"(555) 123-4567": "(734) 229-4081"
},
"stats": {
"total_detections": 150,
"unique_values": 45,
"duplicates_removed": 105,
"mappings_generated": 43,
"unmapped": 2,
"elapsed_seconds": 12.3
},
"token_usage": {
"detection": {
"input_tokens": 50000,
"output_tokens": 20000,
"requests": 15
},
"synthetic": { "input_tokens": 8000, "output_tokens": 3000, "requests": 2 },
"total": { "input_tokens": 58000, "output_tokens": 23000, "requests": 17 }
}
}Token usage includes both detection (aggregated from Step 1 JSONs) and synthetic generation totals. Cost estimation available via TokenTracker.estimate_cost() using pricing.yaml.
| Guard | Where | What It Does |
|---|---|---|
validate_synthetic_input() |
Before generation | Filters detections with empty content. Assigns "other" type if type missing. |
validate_synthetic_output() |
After generation | Logs any unique PII values that have no mapping. |
| Hallucination removal | After generation | Removes keys from pii_mapping that don't exist in the detection list (LLM invented them). |
| Final count check | After generation | Logs error if len(pii_mapping) != len(unique_values). |
- If the entire synthetic Lambda fails, DynamoDB status set to
FAILEDwith error details andfailed_step: "synthetic" - Step Functions retries on throttling/timeout: 5 attempts, 10s interval, 2.5x exponential backoff
- Individual batch failures within
ThreadPoolExecutorare caught: Faker fills missing mappings check_and_raise_throttling()detects Bedrock throttling errors and re-raises for SF retry- Boto3 client configured with adaptive retry mode, 5 max attempts, 300s read timeout
| Operation | Bucket | Purpose |
|---|---|---|
GetObject |
Output | Read detection JSONs from Step 1 |
PutObject |
Output | Write synthetic_mapping.json |
# Step 2 inference parameters
synthetic:
temperature: 0.8 # Higher = more diverse synthetic data
top_k: 200 # Wider token choices
max_tokens: 64000 # Max output tokens per LLM call
enable_thinking: false # Extended thinking (increases latency/cost)
thinking_budget_tokens: 4000
# Batching and concurrency
concurrency:
max_workers: 6 # Threads for concurrent batch processing
max_synthetic_batch_tokens: 10000 # Token limit before splitting into batches
chars_per_token: 4 # Token estimation ratio
# Clustering
clustering:
max_cluster_size: 10 # Max items per cluster
type_hint_map: # LLM type label → category mapping
person_name: [name, first_name, last_name, ...]
address: [address, street, city, state, zip, ...]
# ... (see config.yaml for full list)
# Redaction mode (affects synthetic step)
redaction:
mode: "synthetic" # "synthetic" = LLM generation, "blackout" = skip LLM| File | Purpose |
|---|---|
handlers/synthetic_handler.py |
Lambda entry point. Reads detection JSONs, deduplicates, orchestrates pipeline, stores mapping. |
core/synthetic_pii_generator.py |
batch_generate_synthetic_pii(): main entry. Batching, LLM calls, repair, Faker fallback, post-processing. |
core/value_categorizer.py |
infer_category(): two-tier category inference. cluster_items_by_category(): category-specific clustering. Normalization functions. |
core/prompts.py |
BATCH_SYNTHETIC_* prompts, BATCH_REPAIR_* prompts, CATEGORY_INSTRUCTIONS dict. |
helpers/model_config_helper.py |
get_creative_config_from_yaml(): synthetic inference params. get_concurrency_config(): batching config. |
helpers/token_tracker.py |
Thread-safe token accumulator. Cost estimation via pricing.yaml. |
helpers/text_chunker.py |
estimate_tokens(): token count estimation for batch splitting. |
validation/model_schemas.py |
validate_synthetic_input(), validate_synthetic_output(): input/output guards. |